Files
galaxy-game/lobby/internal/adapters/redisstate/gameturnstatsstore.go
T
2026-04-25 23:20:55 +02:00

295 lines
9.4 KiB
Go

package redisstate
import (
"context"
"errors"
"fmt"
"sort"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/ports"
"github.com/redis/go-redis/v9"
)
// saveInitialPlayerStatsScript stores the JSON aggregate under the primary
// key only when no aggregate exists yet for the user. The script also
// records the user id in the per-game lookup set so Load and Delete avoid
// scanning the keyspace. Inputs:
//
// KEYS[1] — primary aggregate key
// KEYS[2] — per-game lookup set key
// ARGV[1] — user id stored in the lookup set
// ARGV[2] — JSON payload to store on first observation
//
// Returns 1 when the script wrote the payload and 0 when the user already
// had an aggregate.
const saveInitialPlayerStatsScript = `
local primaryKey = KEYS[1]
local byGameKey = KEYS[2]
local userID = ARGV[1]
local payload = ARGV[2]
local existing = redis.call('GET', primaryKey)
if existing then
return 0
end
redis.call('SET', primaryKey, payload)
redis.call('SADD', byGameKey, userID)
return 1
`
// updateMaxPlayerStatsScript updates the running maxima for the user in
// place. When no aggregate exists yet the script seeds one whose initial
// fields and max fields both equal the observation. The script always
// keeps the max fields monotonically non-decreasing. Inputs:
//
// KEYS[1] — primary aggregate key
// KEYS[2] — per-game lookup set key
// ARGV[1] — user id stored in the lookup set
// ARGV[2] — observed planets
// ARGV[3] — observed population
// ARGV[4] — observed ships built
// ARGV[5] — JSON payload to seed when no aggregate exists yet
//
// Returns 1 when a new aggregate was created and 0 otherwise.
const updateMaxPlayerStatsScript = `
local primaryKey = KEYS[1]
local byGameKey = KEYS[2]
local userID = ARGV[1]
local newPlanets = tonumber(ARGV[2])
local newPopulation = tonumber(ARGV[3])
local newShipsBuilt = tonumber(ARGV[4])
local freshPayload = ARGV[5]
local existing = redis.call('GET', primaryKey)
if not existing then
redis.call('SET', primaryKey, freshPayload)
redis.call('SADD', byGameKey, userID)
return 1
end
local data = cjson.decode(existing)
local changed = false
if newPlanets > data.max_planets then
data.max_planets = newPlanets
changed = true
end
if newPopulation > data.max_population then
data.max_population = newPopulation
changed = true
end
if newShipsBuilt > data.max_ships_built then
data.max_ships_built = newShipsBuilt
changed = true
end
if changed then
redis.call('SET', primaryKey, cjson.encode(data))
end
return 0
`
// GameTurnStatsStore is the Redis-backed implementation of
// ports.GameTurnStatsStore. It keeps one JSON aggregate per (game, user)
// at the GameTurnStat key and indexes the user ids in a per-game set so
// Load and Delete reach every entry without scanning the full keyspace.
type GameTurnStatsStore struct {
client *redis.Client
keys Keyspace
saveInitialLua *redis.Script
updateMaxLua *redis.Script
}
// NewGameTurnStatsStore constructs one Redis-backed GameTurnStatsStore.
func NewGameTurnStatsStore(client *redis.Client) (*GameTurnStatsStore, error) {
if client == nil {
return nil, errors.New("new game turn stats store: nil redis client")
}
return &GameTurnStatsStore{
client: client,
keys: Keyspace{},
saveInitialLua: redis.NewScript(saveInitialPlayerStatsScript),
updateMaxLua: redis.NewScript(updateMaxPlayerStatsScript),
}, nil
}
// SaveInitial freezes the initial fields for every user in stats. The
// script in Redis enforces the «first observation wins» invariant per
// user; later calls observe an existing aggregate and return without
// writes.
func (store *GameTurnStatsStore) SaveInitial(ctx context.Context, gameID common.GameID, stats []ports.PlayerInitialStats) error {
if store == nil || store.client == 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)
}
}
byGameKey := store.keys.GameTurnStatsByGame(gameID)
for _, line := range stats {
primaryKey := store.keys.GameTurnStat(gameID, line.UserID)
payload, err := MarshalPlayerStats(ports.PlayerStatsAggregate{
UserID: line.UserID,
InitialPlanets: line.Planets,
InitialPopulation: line.Population,
InitialShipsBuilt: line.ShipsBuilt,
MaxPlanets: line.Planets,
MaxPopulation: line.Population,
MaxShipsBuilt: line.ShipsBuilt,
})
if err != nil {
return fmt.Errorf("save initial player stats: %w", err)
}
if _, err := store.saveInitialLua.Run(
ctx, store.client,
[]string{primaryKey, byGameKey},
line.UserID, string(payload),
).Result(); err != nil {
return fmt.Errorf("save initial player stats: %w", err)
}
}
return nil
}
// UpdateMax updates the per-user max fields by per-component maximum. New
// users observed for the first time receive an aggregate whose initial
// fields and max fields both equal the observation, so callers never need
// to invoke SaveInitial first to keep state consistent.
func (store *GameTurnStatsStore) UpdateMax(ctx context.Context, gameID common.GameID, stats []ports.PlayerObservedStats) error {
if store == nil || store.client == 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)
}
}
byGameKey := store.keys.GameTurnStatsByGame(gameID)
for _, line := range stats {
primaryKey := store.keys.GameTurnStat(gameID, line.UserID)
freshPayload, err := MarshalPlayerStats(ports.PlayerStatsAggregate{
UserID: line.UserID,
InitialPlanets: line.Planets,
InitialPopulation: line.Population,
InitialShipsBuilt: line.ShipsBuilt,
MaxPlanets: line.Planets,
MaxPopulation: line.Population,
MaxShipsBuilt: line.ShipsBuilt,
})
if err != nil {
return fmt.Errorf("update max player stats: %w", err)
}
if _, err := store.updateMaxLua.Run(
ctx, store.client,
[]string{primaryKey, byGameKey},
line.UserID,
line.Planets,
line.Population,
line.ShipsBuilt,
string(freshPayload),
).Result(); err != nil {
return fmt.Errorf("update max player stats: %w", err)
}
}
return nil
}
// Load returns the GameTurnStatsAggregate for gameID. The Players slice is
// sorted by UserID ascending so capability evaluation produces
// deterministic side-effect order on replay.
func (store *GameTurnStatsStore) Load(ctx context.Context, gameID common.GameID) (ports.GameTurnStatsAggregate, error) {
if store == nil || store.client == 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)
}
byGameKey := store.keys.GameTurnStatsByGame(gameID)
userIDs, err := store.client.SMembers(ctx, byGameKey).Result()
if err != nil {
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
}
if len(userIDs) == 0 {
return ports.GameTurnStatsAggregate{GameID: gameID}, nil
}
sort.Strings(userIDs)
keys := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
keys = append(keys, store.keys.GameTurnStat(gameID, userID))
}
payloads, err := store.client.MGet(ctx, keys...).Result()
if err != nil {
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
}
players := make([]ports.PlayerStatsAggregate, 0, len(payloads))
for index, raw := range payloads {
if raw == nil {
continue
}
text, ok := raw.(string)
if !ok {
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: unexpected payload type for %s", userIDs[index])
}
aggregate, err := UnmarshalPlayerStats([]byte(text))
if err != nil {
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
}
players = append(players, aggregate)
}
return ports.GameTurnStatsAggregate{GameID: gameID, Players: players}, nil
}
// Delete removes every aggregate entry for gameID and the per-game lookup
// set itself. It is a no-op when no entries exist.
func (store *GameTurnStatsStore) Delete(ctx context.Context, gameID common.GameID) error {
if store == nil || store.client == 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)
}
byGameKey := store.keys.GameTurnStatsByGame(gameID)
userIDs, err := store.client.SMembers(ctx, byGameKey).Result()
if err != nil {
return fmt.Errorf("delete player stats: %w", err)
}
pipeline := store.client.Pipeline()
for _, userID := range userIDs {
pipeline.Del(ctx, store.keys.GameTurnStat(gameID, userID))
}
pipeline.Del(ctx, byGameKey)
if _, err := pipeline.Exec(ctx); err != nil {
return fmt.Errorf("delete player stats: %w", err)
}
return nil
}
// Compile-time interface assertion.
var _ ports.GameTurnStatsStore = (*GameTurnStatsStore)(nil)