package redisstate import ( "encoding/json" "fmt" "galaxy/lobby/internal/ports" ) // playerStatsRecord stores the strict Redis JSON shape used for one // per-game per-user stats aggregate. The shape mirrors the field set // documented in lobby/README.md §Runtime Snapshot. type playerStatsRecord struct { UserID string `json:"user_id"` InitialPlanets int64 `json:"initial_planets"` InitialPopulation int64 `json:"initial_population"` InitialShipsBuilt int64 `json:"initial_ships_built"` MaxPlanets int64 `json:"max_planets"` MaxPopulation int64 `json:"max_population"` MaxShipsBuilt int64 `json:"max_ships_built"` } // MarshalPlayerStats encodes aggregate into the strict Redis JSON shape. // Negative counters are rejected to match the validation surface of // ports.PlayerObservedStats.Validate. func MarshalPlayerStats(aggregate ports.PlayerStatsAggregate) ([]byte, error) { if err := validatePlayerStatsAggregate(aggregate); err != nil { return nil, fmt.Errorf("marshal player stats aggregate: %w", err) } return json.Marshal(playerStatsRecord{ UserID: aggregate.UserID, InitialPlanets: aggregate.InitialPlanets, InitialPopulation: aggregate.InitialPopulation, InitialShipsBuilt: aggregate.InitialShipsBuilt, MaxPlanets: aggregate.MaxPlanets, MaxPopulation: aggregate.MaxPopulation, MaxShipsBuilt: aggregate.MaxShipsBuilt, }) } // UnmarshalPlayerStats decodes payload into a PlayerStatsAggregate. The // returned aggregate is re-validated to guarantee the Redis store never // surfaces malformed records. func UnmarshalPlayerStats(payload []byte) (ports.PlayerStatsAggregate, error) { var stored playerStatsRecord if err := json.Unmarshal(payload, &stored); err != nil { return ports.PlayerStatsAggregate{}, fmt.Errorf("unmarshal player stats aggregate: %w", err) } aggregate := ports.PlayerStatsAggregate{ UserID: stored.UserID, InitialPlanets: stored.InitialPlanets, InitialPopulation: stored.InitialPopulation, InitialShipsBuilt: stored.InitialShipsBuilt, MaxPlanets: stored.MaxPlanets, MaxPopulation: stored.MaxPopulation, MaxShipsBuilt: stored.MaxShipsBuilt, } if err := validatePlayerStatsAggregate(aggregate); err != nil { return ports.PlayerStatsAggregate{}, fmt.Errorf("unmarshal player stats aggregate: %w", err) } return aggregate, nil } func validatePlayerStatsAggregate(aggregate ports.PlayerStatsAggregate) error { if aggregate.UserID == "" { return fmt.Errorf("user id must not be empty") } if aggregate.InitialPlanets < 0 { return fmt.Errorf("initial planets must not be negative") } if aggregate.InitialPopulation < 0 { return fmt.Errorf("initial population must not be negative") } if aggregate.InitialShipsBuilt < 0 { return fmt.Errorf("initial ships built must not be negative") } if aggregate.MaxPlanets < aggregate.InitialPlanets { return fmt.Errorf("max planets must not be below initial planets") } if aggregate.MaxPopulation < aggregate.InitialPopulation { return fmt.Errorf("max population must not be below initial population") } if aggregate.MaxShipsBuilt < aggregate.InitialShipsBuilt { return fmt.Errorf("max ships built must not be below initial ships built") } return nil }