// Package gameturnstatsstub provides an in-memory ports.GameTurnStatsStore // implementation for service-level tests. The stub mirrors the behavioural // contract of the Redis adapter in redisstate: SaveInitial freezes the // initial fields on the first call per user, UpdateMax keeps the max fields // monotonically non-decreasing, Load returns the aggregate sorted by user // id, and Delete is a no-op when no entries exist for the game. // // Production code never wires this stub; it is test-only but exposed as a // regular (non _test.go) package so downstream service test packages can // import it. package gameturnstatsstub import ( "context" "errors" "fmt" "sort" "sync" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/ports" ) // Store is a concurrency-safe in-memory implementation of // ports.GameTurnStatsStore. The zero value is not usable; call NewStore. type Store struct { mu sync.Mutex records map[common.GameID]map[string]ports.PlayerStatsAggregate } // NewStore constructs one empty Store ready for use. func NewStore() *Store { return &Store{records: make(map[common.GameID]map[string]ports.PlayerStatsAggregate)} } // SaveInitial freezes the initial fields for every user in stats. The // first call for a user also primes the max fields with the same values. // Subsequent calls leave both initial and max fields untouched; the // observation is silently ignored. func (store *Store) SaveInitial(ctx context.Context, gameID common.GameID, stats []ports.PlayerInitialStats) error { if store == nil { return errors.New("save initial player stats: nil store") } if ctx == nil { return errors.New("save initial player stats: nil context") } if err := gameID.Validate(); err != nil { return fmt.Errorf("save initial player stats: %w", err) } for _, line := range stats { if err := line.Validate(); err != nil { return fmt.Errorf("save initial player stats: %w", err) } } store.mu.Lock() defer store.mu.Unlock() bucket := store.records[gameID] if bucket == nil { bucket = make(map[string]ports.PlayerStatsAggregate) store.records[gameID] = bucket } for _, line := range stats { if _, ok := bucket[line.UserID]; ok { continue } bucket[line.UserID] = ports.PlayerStatsAggregate{ UserID: line.UserID, InitialPlanets: line.Planets, InitialPopulation: line.Population, InitialShipsBuilt: line.ShipsBuilt, MaxPlanets: line.Planets, MaxPopulation: line.Population, MaxShipsBuilt: line.ShipsBuilt, } } return nil } // UpdateMax updates the max fields by per-component maximum. New users // receive an aggregate whose initial fields and max fields both equal the // observation, so SaveInitial is not strictly required before UpdateMax. func (store *Store) UpdateMax(ctx context.Context, gameID common.GameID, stats []ports.PlayerObservedStats) error { if store == nil { return errors.New("update max player stats: nil store") } if ctx == nil { return errors.New("update max player stats: nil context") } if err := gameID.Validate(); err != nil { return fmt.Errorf("update max player stats: %w", err) } for _, line := range stats { if err := line.Validate(); err != nil { return fmt.Errorf("update max player stats: %w", err) } } store.mu.Lock() defer store.mu.Unlock() bucket := store.records[gameID] if bucket == nil { bucket = make(map[string]ports.PlayerStatsAggregate) store.records[gameID] = bucket } for _, line := range stats { entry, ok := bucket[line.UserID] if !ok { bucket[line.UserID] = ports.PlayerStatsAggregate{ UserID: line.UserID, InitialPlanets: line.Planets, InitialPopulation: line.Population, InitialShipsBuilt: line.ShipsBuilt, MaxPlanets: line.Planets, MaxPopulation: line.Population, MaxShipsBuilt: line.ShipsBuilt, } continue } if line.Planets > entry.MaxPlanets { entry.MaxPlanets = line.Planets } if line.Population > entry.MaxPopulation { entry.MaxPopulation = line.Population } if line.ShipsBuilt > entry.MaxShipsBuilt { entry.MaxShipsBuilt = line.ShipsBuilt } bucket[line.UserID] = entry } return nil } // Load returns the GameTurnStatsAggregate stored for gameID with Players // sorted by UserID ascending. Calling Load on an unknown gameID returns an // aggregate carrying gameID and an empty Players slice. func (store *Store) Load(ctx context.Context, gameID common.GameID) (ports.GameTurnStatsAggregate, error) { if store == nil { return ports.GameTurnStatsAggregate{}, errors.New("load player stats: nil store") } if ctx == nil { return ports.GameTurnStatsAggregate{}, errors.New("load player stats: nil context") } if err := gameID.Validate(); err != nil { return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err) } store.mu.Lock() defer store.mu.Unlock() bucket := store.records[gameID] players := make([]ports.PlayerStatsAggregate, 0, len(bucket)) for _, entry := range bucket { players = append(players, entry) } sort.Slice(players, func(i, j int) bool { return players[i].UserID < players[j].UserID }) return ports.GameTurnStatsAggregate{GameID: gameID, Players: players}, nil } // Delete removes every aggregate entry for gameID. It is a no-op when no // entries exist. func (store *Store) Delete(ctx context.Context, gameID common.GameID) error { if store == nil { return errors.New("delete player stats: nil store") } if ctx == nil { return errors.New("delete player stats: nil context") } if err := gameID.Validate(); err != nil { return fmt.Errorf("delete player stats: %w", err) } store.mu.Lock() defer store.mu.Unlock() delete(store.records, gameID) return nil } // Compile-time interface assertion. var _ ports.GameTurnStatsStore = (*Store)(nil)