feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,410 @@
// Package capabilityevaluation implements the per-membership
// capability evaluation at game finish. The evaluator is invoked by the
// gmevents consumer after the platform game record reaches the
// `finished` status; it walks every membership of the game, decides
// capable/incapable for active members based on the per-game stats
// aggregate, and resolves outstanding race-name reservations through the
// Race Name Directory.
//
// Capability rule (frozen in lobby/README.md §Game Finish Flow):
//
// capable = max_planets > initial_planets AND max_population > initial_population
//
// Capable members get their reservation promoted to `pending_registration`
// with eligible_until = finished_at + 30 days, plus a
// `lobby.race_name.registration_eligible` intent. Incapable members get
// their reservation released immediately, plus an optional
// `lobby.race_name.registration_denied` intent. `removed` and `blocked`
// memberships have their outstanding reservations released without an
// intent.
//
// Idempotency is guarded by ports.EvaluationGuardStore: the first pass for
// a game records a marker and proceeds with side effects; later passes
// observe the marker and return without mutating the directory or
// publishing intents. The guard is recorded only after the directory and
// stats mutations succeed so a transient failure replays from scratch on
// the next consumer tick.
package capabilityevaluation
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/logging"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/telemetry"
)
// PendingRegistrationWindow is the post-game window during which a capable
// member may convert their reservation into a registered race name. The
// 30-day value is frozen in lobby/README.md §Reservation lifecycle and
// capability.
const PendingRegistrationWindow = 30 * 24 * time.Hour
// EligibleEvent carries the inputs required to publish one
// lobby.race_name.registration_eligible intent. The struct exists so that
// the publisher receives a stable shape even though the real
// constructors live in pkg/notificationintent.
type EligibleEvent struct {
// GameID identifies the finished game whose capable finish produced
// the pending registration.
GameID common.GameID
// GameName is the human-readable game name copied from the game
// record at finish time.
GameName string
// UserID identifies the capable member.
UserID string
// RaceName is the original-casing race name reserved by the user.
RaceName string
// EligibleUntil is the deadline by which the user must call
// lobby.race_name.register to keep the name.
EligibleUntil time.Time
// FinishedAt is the wall-clock at which the game finished.
FinishedAt time.Time
}
// DeniedEvent carries the inputs required to publish one
// lobby.race_name.registration_denied intent. may decide to
// suppress the intent for a quieter post-game experience; the event is
// emitted by the evaluator regardless and the publisher is free to drop
// it.
type DeniedEvent struct {
// GameID identifies the finished game.
GameID common.GameID
// GameName is the human-readable game name copied from the game
// record at finish time.
GameName string
// UserID identifies the incapable member.
UserID string
// RaceName is the original-casing race name held by the user during
// the game.
RaceName string
// FinishedAt is the wall-clock at which the game finished.
FinishedAt time.Time
// Reason describes why the member did not satisfy the capability
// rule. The evaluator currently emits "capability_not_met" or
// "missing_stats".
Reason string
}
// Reasons emitted on DeniedEvent.
const (
// ReasonCapabilityNotMet reports that the member did not satisfy
// max_planets > initial_planets AND max_population >
// initial_population.
ReasonCapabilityNotMet = "capability_not_met"
// ReasonMissingStats reports that no stats observation was ever
// recorded for the member, so capability cannot be evaluated. The
// evaluator treats this as incapable.
ReasonMissingStats = "missing_stats"
)
// RaceNameIntents publishes the two race-name capability outcomes. Stage
// 15A wires a noop implementation by default; replaces the
// adapter with the real pkg/notificationintent publisher without touching
// the evaluator.
type RaceNameIntents interface {
// PublishEligible emits one lobby.race_name.registration_eligible
// intent for ev. Implementations must be idempotent on retry —
// the evaluator may invoke PublishEligible at most once per (game,
// user) tuple, but retry policy may cause downstream
// re-emission.
PublishEligible(ctx context.Context, ev EligibleEvent) error
// PublishDenied emits one lobby.race_name.registration_denied
// intent for ev. The intent is informational; implementations may
// drop it without surfacing an error.
PublishDenied(ctx context.Context, ev DeniedEvent) error
}
// NoopRaceNameIntents is the production-default RaceNameIntents adapter
// while is unimplemented. Both methods accept their input and
// return nil. The exported zero value is safe to share.
type NoopRaceNameIntents struct{}
// PublishEligible discards ev and returns nil.
func (NoopRaceNameIntents) PublishEligible(ctx context.Context, ev EligibleEvent) error {
return nil
}
// PublishDenied discards ev and returns nil.
func (NoopRaceNameIntents) PublishDenied(ctx context.Context, ev DeniedEvent) error {
return nil
}
// Compile-time interface assertion.
var _ RaceNameIntents = NoopRaceNameIntents{}
// Service evaluates capability at game finish.
type Service struct {
games ports.GameStore
memberships ports.MembershipStore
stats ports.GameTurnStatsStore
directory ports.RaceNameDirectory
intents RaceNameIntents
guard ports.EvaluationGuardStore
clock func() time.Time
logger *slog.Logger
telemetry *telemetry.Runtime
}
// Dependencies groups the collaborators used by Service.
type Dependencies struct {
// Games loads the finished game record. The evaluator reads the
// FinishedAt and GameName fields from the loaded record.
Games ports.GameStore
// Memberships supplies the per-game roster the evaluator iterates.
Memberships ports.MembershipStore
// Stats supplies the aggregate the evaluator reads, and is asked to
// delete the aggregate at the end of a successful pass.
Stats ports.GameTurnStatsStore
// Directory mutates reservations: MarkPendingRegistration on
// capable members, ReleaseReservation on incapable / removed /
// blocked members.
Directory ports.RaceNameDirectory
// Intents publishes the per-member capability intents. Wire
// NoopRaceNameIntents{} until lands the real publisher.
Intents RaceNameIntents
// Guard supplies the per-game «already evaluated» marker that
// keeps replayed `game_finished` events safe.
Guard ports.EvaluationGuardStore
// Clock supplies the wall-clock used for log timestamps; the
// evaluator otherwise reads its FinishedAt anchor from the game
// record.
Clock func() time.Time
// Logger records structured service-level events.
Logger *slog.Logger
// Telemetry records the `lobby.capability_evaluations` counter
// per evaluated active membership. Optional; nil disables metric
// emission.
Telemetry *telemetry.Runtime
}
// NewService constructs one Service with deps.
func NewService(deps Dependencies) (*Service, error) {
switch {
case deps.Games == nil:
return nil, errors.New("new capability evaluation service: nil game store")
case deps.Memberships == nil:
return nil, errors.New("new capability evaluation service: nil membership store")
case deps.Stats == nil:
return nil, errors.New("new capability evaluation service: nil game turn stats store")
case deps.Directory == nil:
return nil, errors.New("new capability evaluation service: nil race name directory")
case deps.Intents == nil:
return nil, errors.New("new capability evaluation service: nil race name intents")
case deps.Guard == nil:
return nil, errors.New("new capability evaluation service: nil evaluation guard store")
}
clock := deps.Clock
if clock == nil {
clock = time.Now
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Service{
games: deps.Games,
memberships: deps.Memberships,
stats: deps.Stats,
directory: deps.Directory,
intents: deps.Intents,
guard: deps.Guard,
clock: clock,
logger: logger.With("service", "lobby.capabilityevaluation"),
telemetry: deps.Telemetry,
}, nil
}
// Evaluate runs the capability evaluator for gameID. The caller — the
// gmevents consumer — must have already transitioned the game record to
// `finished` (or established that the game was already in `finished` from
// a prior pass) before invoking Evaluate. The method is idempotent: a
// second call after a successful first pass returns nil without further
// side effects.
//
// finishedAt is intentionally sourced from the game record by the caller
// rather than from the GM event timestamp so that retries of the same
// `game_finished` event always compute the same eligible_until anchor.
func (service *Service) Evaluate(ctx context.Context, gameID common.GameID, finishedAt time.Time) error {
if service == nil {
return errors.New("evaluate capability: nil service")
}
if ctx == nil {
return errors.New("evaluate capability: nil context")
}
if err := gameID.Validate(); err != nil {
return fmt.Errorf("evaluate capability: %w", err)
}
if finishedAt.IsZero() {
return errors.New("evaluate capability: finished at must not be zero")
}
record, err := service.games.Get(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: load game: %w", err)
}
if record.Status != game.StatusFinished {
return fmt.Errorf("evaluate capability: game %s status is %q, expected %q",
record.GameID.String(), record.Status, game.StatusFinished)
}
evaluated, err := service.guard.IsEvaluated(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: read guard: %w", err)
}
if evaluated {
service.telemetry.RecordCapabilityEvaluation(ctx, "noop")
logArgs := []any{"game_id", gameID.String()}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "capability evaluation replay absorbed by guard", logArgs...)
return nil
}
memberships, err := service.memberships.GetByGame(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: load memberships: %w", err)
}
aggregate, err := service.stats.Load(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: load stats: %w", err)
}
statsByUser := make(map[string]ports.PlayerStatsAggregate, len(aggregate.Players))
for _, line := range aggregate.Players {
statsByUser[line.UserID] = line
}
eligibleUntil := finishedAt.Add(PendingRegistrationWindow)
pending := make([]EligibleEvent, 0, len(memberships))
denied := make([]DeniedEvent, 0, len(memberships))
for _, member := range memberships {
switch member.Status {
case membership.StatusActive:
stat, ok := statsByUser[member.UserID]
capable := ok && capabilityMet(stat)
switch {
case capable:
if err := service.directory.MarkPendingRegistration(
ctx, member.GameID.String(), member.UserID, member.RaceName, eligibleUntil,
); err != nil {
return fmt.Errorf(
"evaluate capability: mark pending registration for game %s user %s: %w",
member.GameID.String(), member.UserID, err,
)
}
service.telemetry.RecordCapabilityEvaluation(ctx, "capable")
pending = append(pending, EligibleEvent{
GameID: member.GameID,
GameName: record.GameName,
UserID: member.UserID,
RaceName: member.RaceName,
EligibleUntil: eligibleUntil,
FinishedAt: finishedAt,
})
default:
if err := service.directory.ReleaseReservation(
ctx, member.GameID.String(), member.UserID, member.RaceName,
); err != nil {
return fmt.Errorf(
"evaluate capability: release reservation for game %s user %s: %w",
member.GameID.String(), member.UserID, err,
)
}
service.telemetry.RecordCapabilityEvaluation(ctx, "incapable")
reason := ReasonCapabilityNotMet
if !ok {
reason = ReasonMissingStats
}
denied = append(denied, DeniedEvent{
GameID: member.GameID,
GameName: record.GameName,
UserID: member.UserID,
RaceName: member.RaceName,
FinishedAt: finishedAt,
Reason: reason,
})
}
case membership.StatusRemoved, membership.StatusBlocked:
if err := service.directory.ReleaseReservation(
ctx, member.GameID.String(), member.UserID, member.RaceName,
); err != nil {
return fmt.Errorf(
"evaluate capability: release post-start reservation for game %s user %s: %w",
member.GameID.String(), member.UserID, err,
)
}
}
}
if err := service.stats.Delete(ctx, gameID); err != nil {
return fmt.Errorf("evaluate capability: delete stats: %w", err)
}
if err := service.guard.MarkEvaluated(ctx, gameID); err != nil {
return fmt.Errorf("evaluate capability: mark evaluated: %w", err)
}
for _, ev := range pending {
if err := service.intents.PublishEligible(ctx, ev); err != nil {
service.logger.WarnContext(ctx, "publish race name eligible intent",
"game_id", ev.GameID.String(),
"user_id", ev.UserID,
"err", err.Error(),
)
}
}
for _, ev := range denied {
if err := service.intents.PublishDenied(ctx, ev); err != nil {
service.logger.WarnContext(ctx, "publish race name denied intent",
"game_id", ev.GameID.String(),
"user_id", ev.UserID,
"err", err.Error(),
)
}
}
logArgs := []any{
"game_id", gameID.String(),
"pending_count", len(pending),
"denied_count", len(denied),
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "capability evaluation complete", logArgs...)
return nil
}
// capabilityMet implements the capability rule frozen in
// lobby/README.md §Game Finish Flow.
func capabilityMet(stat ports.PlayerStatsAggregate) bool {
return stat.MaxPlanets > stat.InitialPlanets &&
stat.MaxPopulation > stat.InitialPopulation
}
@@ -0,0 +1,308 @@
package capabilityevaluation_test
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"galaxy/lobby/internal/adapters/evaluationguardstub"
"galaxy/lobby/internal/adapters/gamestub"
"galaxy/lobby/internal/adapters/gameturnstatsstub"
"galaxy/lobby/internal/adapters/membershipstub"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/capabilityevaluation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
type spyIntents struct {
mu sync.Mutex
pending []capabilityevaluation.EligibleEvent
denied []capabilityevaluation.DeniedEvent
}
func (spy *spyIntents) PublishEligible(_ context.Context, ev capabilityevaluation.EligibleEvent) error {
spy.mu.Lock()
defer spy.mu.Unlock()
spy.pending = append(spy.pending, ev)
return nil
}
func (spy *spyIntents) PublishDenied(_ context.Context, ev capabilityevaluation.DeniedEvent) error {
spy.mu.Lock()
defer spy.mu.Unlock()
spy.denied = append(spy.denied, ev)
return nil
}
type fixture struct {
finishedAt time.Time
gameID common.GameID
gameName string
games *gamestub.Store
memberships *membershipstub.Store
stats *gameturnstatsstub.Store
directory *racenamestub.Directory
intents *spyIntents
guard *evaluationguardstub.Store
service *capabilityevaluation.Service
}
func newFixture(t *testing.T) *fixture {
t.Helper()
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
finishedAt := now
games := gamestub.NewStore()
memberships := membershipstub.NewStore()
stats := gameturnstatsstub.NewStore()
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now.Add(-time.Hour))))
require.NoError(t, err)
intents := &spyIntents{}
guard := evaluationguardstub.NewStore()
gameID := common.GameID("game-finished")
gameName := "Final Showdown"
gameRecord, err := game.New(game.NewGameInput{
GameID: gameID,
GameName: gameName,
GameType: game.GameTypePublic,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 2,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(-12 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: now.Add(-24 * time.Hour),
})
require.NoError(t, err)
gameRecord.Status = game.StatusFinished
startedAt := now.Add(-12 * time.Hour)
gameRecord.StartedAt = &startedAt
gameRecord.FinishedAt = &finishedAt
require.NoError(t, games.Save(context.Background(), gameRecord))
service, err := capabilityevaluation.NewService(capabilityevaluation.Dependencies{
Games: games,
Memberships: memberships,
Stats: stats,
Directory: directory,
Intents: intents,
Guard: guard,
Clock: fixedClock(now),
Logger: silentLogger(),
})
require.NoError(t, err)
return &fixture{
finishedAt: finishedAt,
gameID: gameID,
gameName: gameName,
games: games,
memberships: memberships,
stats: stats,
directory: directory,
intents: intents,
guard: guard,
service: service,
}
}
func (f *fixture) addMember(t *testing.T, userID, raceName string, status membership.Status) {
t.Helper()
member, err := membership.New(membership.NewMembershipInput{
MembershipID: common.MembershipID("membership-" + userID),
GameID: f.gameID,
UserID: userID,
RaceName: raceName,
CanonicalKey: raceName,
Now: f.finishedAt.Add(-3 * time.Hour),
})
require.NoError(t, err)
require.NoError(t, f.memberships.Save(context.Background(), member))
if status != membership.StatusActive {
require.NoError(t, f.memberships.UpdateStatus(context.Background(), ports.UpdateMembershipStatusInput{
MembershipID: member.MembershipID,
ExpectedFrom: membership.StatusActive,
To: status,
At: f.finishedAt.Add(-time.Hour),
}))
}
}
func (f *fixture) reserveRaceName(t *testing.T, userID, raceName string) {
t.Helper()
require.NoError(t, f.directory.Reserve(context.Background(), f.gameID.String(), userID, raceName))
}
func (f *fixture) seedStats(t *testing.T, userID string, initialPlanets, initialPopulation, maxPlanets, maxPopulation int64) {
t.Helper()
require.NoError(t, f.stats.SaveInitial(context.Background(), f.gameID, []ports.PlayerInitialStats{
{UserID: userID, Planets: initialPlanets, Population: initialPopulation, ShipsBuilt: 0},
}))
require.NoError(t, f.stats.UpdateMax(context.Background(), f.gameID, []ports.PlayerObservedStats{
{UserID: userID, Planets: maxPlanets, Population: maxPopulation, ShipsBuilt: 0},
}))
}
func TestEvaluateRequiresFinishedStatus(t *testing.T) {
f := newFixture(t)
record, err := f.games.Get(context.Background(), f.gameID)
require.NoError(t, err)
record.Status = game.StatusRunning
record.FinishedAt = nil
require.NoError(t, f.games.Save(context.Background(), record))
err = f.service.Evaluate(context.Background(), f.gameID, f.finishedAt)
require.Error(t, err)
}
func TestEvaluateCapableMemberMarksPending(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-capable", "Stellaris")
f.addMember(t, "user-capable", "Stellaris", membership.StatusActive)
f.seedStats(t, "user-capable", 3, 100, 5, 200)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-capable")
require.NoError(t, err)
require.Len(t, pending, 1)
assert.Equal(t, "Stellaris", pending[0].RaceName)
expectedEligible := f.finishedAt.Add(capabilityevaluation.PendingRegistrationWindow).UnixMilli()
assert.Equal(t, expectedEligible, pending[0].EligibleUntilMs)
require.Len(t, f.intents.pending, 1)
assert.Equal(t, "user-capable", f.intents.pending[0].UserID)
assert.Equal(t, f.gameName, f.intents.pending[0].GameName)
assert.Empty(t, f.intents.denied)
}
func TestEvaluateIncapableMemberReleasesReservation(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-incap", "Loseman")
f.addMember(t, "user-incap", "Loseman", membership.StatusActive)
f.seedStats(t, "user-incap", 3, 100, 3, 100)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
reservations, err := f.directory.ListReservations(context.Background(), "user-incap")
require.NoError(t, err)
assert.Empty(t, reservations)
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-incap")
require.NoError(t, err)
assert.Empty(t, pending)
require.Len(t, f.intents.denied, 1)
assert.Equal(t, capabilityevaluation.ReasonCapabilityNotMet, f.intents.denied[0].Reason)
}
func TestEvaluateMissingStatsReleasesAndReportsReason(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-no-stats", "Phantom")
f.addMember(t, "user-no-stats", "Phantom", membership.StatusActive)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
require.Len(t, f.intents.denied, 1)
assert.Equal(t, capabilityevaluation.ReasonMissingStats, f.intents.denied[0].Reason)
reservations, err := f.directory.ListReservations(context.Background(), "user-no-stats")
require.NoError(t, err)
assert.Empty(t, reservations)
}
func TestEvaluateMixedRoster(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-A", "Alpha")
f.addMember(t, "user-A", "Alpha", membership.StatusActive)
f.seedStats(t, "user-A", 1, 10, 9, 100)
f.reserveRaceName(t, "user-B", "Beta")
f.addMember(t, "user-B", "Beta", membership.StatusActive)
f.seedStats(t, "user-B", 4, 50, 4, 50)
f.reserveRaceName(t, "user-C", "Gamma")
f.addMember(t, "user-C", "Gamma", membership.StatusRemoved)
f.reserveRaceName(t, "user-D", "Delta")
f.addMember(t, "user-D", "Delta", membership.StatusBlocked)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-A")
require.NoError(t, err)
assert.Len(t, pending, 1)
for _, userID := range []string{"user-B", "user-C", "user-D"} {
reservations, err := f.directory.ListReservations(context.Background(), userID)
require.NoError(t, err)
assert.Empty(t, reservations, "user %s reservation not released", userID)
}
require.Len(t, f.intents.pending, 1)
require.Len(t, f.intents.denied, 1, "removed/blocked must not produce denied intents")
assert.Equal(t, "user-A", f.intents.pending[0].UserID)
assert.Equal(t, "user-B", f.intents.denied[0].UserID)
}
func TestEvaluateReplayDoesNotDoubleEmit(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-A", "Alpha")
f.addMember(t, "user-A", "Alpha", membership.StatusActive)
f.seedStats(t, "user-A", 1, 10, 9, 100)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-A")
require.NoError(t, err)
assert.Len(t, pending, 1)
assert.Len(t, f.intents.pending, 1)
assert.Empty(t, f.intents.denied)
}
func TestEvaluateDeletesStatsAggregate(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-A", "Alpha")
f.addMember(t, "user-A", "Alpha", membership.StatusActive)
f.seedStats(t, "user-A", 1, 10, 2, 11)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
aggregate, err := f.stats.Load(context.Background(), f.gameID)
require.NoError(t, err)
assert.Empty(t, aggregate.Players)
}
func TestEvaluateRequiresFinishedAt(t *testing.T) {
f := newFixture(t)
err := f.service.Evaluate(context.Background(), f.gameID, time.Time{})
require.Error(t, err)
}
func TestEvaluateRejectsInvalidGameID(t *testing.T) {
f := newFixture(t)
err := f.service.Evaluate(context.Background(), common.GameID(""), f.finishedAt)
require.Error(t, err)
}