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 }