feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -0,0 +1,185 @@
// Package gameturnstatsinmem 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 gameturnstatsinmem
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)