package ports import ( "context" "fmt" "strings" "galaxy/lobby/internal/domain/common" ) // PlayerObservedStats stores one decoded `player_turn_stats` element from a // Game Master event. The fields mirror the wire format documented in // lobby/README.md §Runtime Snapshot. Counts are non-negative cumulative // totals reported by the engine for the named user at the time the event // was emitted. type PlayerObservedStats struct { // UserID identifies the platform user the stats line belongs to. UserID string // Planets stores the cumulative number of planets controlled by the // user at observation time. Planets int64 // Population stores the cumulative population owned by the user at // observation time. Population int64 // ShipsBuilt stores the cumulative ships-built counter for the user // at observation time. ShipsBuilt int64 } // Validate reports whether stats contain the structural invariants required // by GameTurnStatsStore methods. func (stats PlayerObservedStats) Validate() error { if strings.TrimSpace(stats.UserID) == "" { return fmt.Errorf("player turn stats: user id must not be empty") } if stats.Planets < 0 { return fmt.Errorf("player turn stats: planets must not be negative") } if stats.Population < 0 { return fmt.Errorf("player turn stats: population must not be negative") } if stats.ShipsBuilt < 0 { return fmt.Errorf("player turn stats: ships built must not be negative") } return nil } // PlayerInitialStats is an alias for PlayerObservedStats. Game Lobby // Service freezes the first observation per user as that user's initial // values; the wire format is identical, only the persistence semantics // differ. type PlayerInitialStats = PlayerObservedStats // PlayerStatsAggregate stores the per-user aggregate maintained by // GameTurnStatsStore for one game. Initial fields are frozen at the first // SaveInitial call for the user; max fields are updated by per-component // maximum on each UpdateMax call. type PlayerStatsAggregate struct { // UserID identifies the platform user the aggregate belongs to. UserID string // InitialPlanets stores the user's planets count from the first // observation, frozen for the lifetime of the game. InitialPlanets int64 // InitialPopulation stores the user's population from the first // observation, frozen for the lifetime of the game. InitialPopulation int64 // InitialShipsBuilt stores the user's ships-built counter from the // first observation, frozen for the lifetime of the game. InitialShipsBuilt int64 // MaxPlanets stores the running maximum planets count observed for // the user across the lifetime of the game. MaxPlanets int64 // MaxPopulation stores the running maximum population observed for // the user across the lifetime of the game. MaxPopulation int64 // MaxShipsBuilt stores the running maximum ships-built counter // observed for the user across the lifetime of the game. MaxShipsBuilt int64 } // GameTurnStatsAggregate stores every PlayerStatsAggregate for one game. // The slice is empty when no SaveInitial call has succeeded yet. type GameTurnStatsAggregate struct { // GameID identifies the game the aggregate belongs to. GameID common.GameID // Players stores the per-user aggregates ordered by UserID ascending. Players []PlayerStatsAggregate } // GameTurnStatsStore stores per-game per-user initial and running-maximum // stats derived from Game Master `runtime_snapshot_update` events. The // aggregate is read once by capability evaluation at game finish and then // deleted (see lobby/README.md §Runtime Snapshot). // // Adapters must keep the initial fields frozen against later SaveInitial // calls and must keep the max fields monotonically non-decreasing on every // UpdateMax call. All methods are safe to call concurrently for the same // gameID. type GameTurnStatsStore interface { // SaveInitial freezes the initial fields for every user in stats. // Subsequent calls for the same (gameID, user_id) tuple do not // overwrite the stored initial values; they return nil so workers // may call SaveInitial on every received event without bookkeeping. // The first call for a user also primes the max fields with the same // values so a single observation is reflected by both the initial // and the running maximum. SaveInitial(ctx context.Context, gameID common.GameID, stats []PlayerInitialStats) error // UpdateMax updates the max fields for every user in stats by // per-component maximum. Existing aggregate entries below the new // observation are raised; existing entries at or above the new // observation are left unchanged. Calling UpdateMax for a user that // has no aggregate entry yet creates one whose initial fields and // max fields both equal the observation, so callers may safely call // UpdateMax without first calling SaveInitial. UpdateMax(ctx context.Context, gameID common.GameID, stats []PlayerObservedStats) error // Load returns the GameTurnStatsAggregate stored for gameID. The // aggregate is empty (Players length zero) when no observation has // been recorded yet; the GameID is always populated. The returned // Players slice is ordered by UserID ascending so capability // evaluation produces deterministic side-effect order on replay. Load(ctx context.Context, gameID common.GameID) (GameTurnStatsAggregate, error) // Delete removes every aggregate entry for gameID. The call is a // no-op when no entries exist; no error is returned in that case. Delete(ctx context.Context, gameID common.GameID) error }