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)