feat: runtime manager
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user