feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+138
View File
@@ -0,0 +1,138 @@
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
}