feat: game lobby service
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user