feat: game lobby service
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
)
|
||||
|
||||
// gameRecord stores the strict Redis JSON shape used for one game record.
|
||||
type gameRecord struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
GameType game.GameType `json:"game_type"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
Status game.Status `json:"status"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAtSec int64 `json:"enrollment_ends_at_sec"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
||||
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status,omitempty"`
|
||||
EngineHealthSummary string `json:"engine_health_summary,omitempty"`
|
||||
RuntimeBinding *runtimeBindingRecord `json:"runtime_binding,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeBindingRecord stores the strict Redis JSON shape used for the
|
||||
// optional runtime binding object on one game record.
|
||||
type runtimeBindingRecord struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
RuntimeJobID string `json:"runtime_job_id"`
|
||||
BoundAtMS int64 `json:"bound_at_ms"`
|
||||
}
|
||||
|
||||
// MarshalGame encodes record into the strict Redis JSON shape used for
|
||||
// game records. The record is re-validated before marshalling.
|
||||
func MarshalGame(record game.Game) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis game record: %w", err)
|
||||
}
|
||||
|
||||
stored := gameRecord{
|
||||
GameID: record.GameID.String(),
|
||||
GameName: record.GameName,
|
||||
Description: record.Description,
|
||||
GameType: record.GameType,
|
||||
OwnerUserID: record.OwnerUserID,
|
||||
Status: record.Status,
|
||||
MinPlayers: record.MinPlayers,
|
||||
MaxPlayers: record.MaxPlayers,
|
||||
StartGapHours: record.StartGapHours,
|
||||
StartGapPlayers: record.StartGapPlayers,
|
||||
EnrollmentEndsAtSec: record.EnrollmentEndsAt.UTC().Unix(),
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
TargetEngineVersion: record.TargetEngineVersion,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
StartedAtMS: optionalUnixMilli(record.StartedAt),
|
||||
FinishedAtMS: optionalUnixMilli(record.FinishedAt),
|
||||
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
stored.RuntimeBinding = &runtimeBindingRecord{
|
||||
ContainerID: record.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
|
||||
BoundAtMS: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis game record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalGame decodes payload from the strict Redis JSON shape used for
|
||||
// game records. The decoded record is validated before returning.
|
||||
func UnmarshalGame(payload []byte) (game.Game, error) {
|
||||
var stored gameRecord
|
||||
if err := decodeStrictJSON("decode redis game record", payload, &stored); err != nil {
|
||||
return game.Game{}, err
|
||||
}
|
||||
|
||||
record := game.Game{
|
||||
GameID: common.GameID(stored.GameID),
|
||||
GameName: stored.GameName,
|
||||
Description: stored.Description,
|
||||
GameType: stored.GameType,
|
||||
OwnerUserID: stored.OwnerUserID,
|
||||
Status: stored.Status,
|
||||
MinPlayers: stored.MinPlayers,
|
||||
MaxPlayers: stored.MaxPlayers,
|
||||
StartGapHours: stored.StartGapHours,
|
||||
StartGapPlayers: stored.StartGapPlayers,
|
||||
EnrollmentEndsAt: time.Unix(stored.EnrollmentEndsAtSec, 0).UTC(),
|
||||
TurnSchedule: stored.TurnSchedule,
|
||||
TargetEngineVersion: stored.TargetEngineVersion,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
|
||||
StartedAt: inflateOptionalTime(stored.StartedAtMS),
|
||||
FinishedAt: inflateOptionalTime(stored.FinishedAtMS),
|
||||
RuntimeSnapshot: game.RuntimeSnapshot{
|
||||
CurrentTurn: stored.CurrentTurn,
|
||||
RuntimeStatus: stored.RuntimeStatus,
|
||||
EngineHealthSummary: stored.EngineHealthSummary,
|
||||
},
|
||||
}
|
||||
if stored.RuntimeBinding != nil {
|
||||
record.RuntimeBinding = &game.RuntimeBinding{
|
||||
ContainerID: stored.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: stored.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: stored.RuntimeBinding.RuntimeJobID,
|
||||
BoundAt: time.UnixMilli(stored.RuntimeBinding.BoundAtMS).UTC(),
|
||||
}
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return game.Game{}, fmt.Errorf("decode redis game record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(operation string, payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", operation)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalUnixMilli(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
milliseconds := value.UTC().UnixMilli()
|
||||
return &milliseconds
|
||||
}
|
||||
|
||||
func inflateOptionalTime(value *int64) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
converted := time.UnixMilli(*value).UTC()
|
||||
return &converted
|
||||
}
|
||||
Reference in New Issue
Block a user