package capabilityevaluation_test import ( "context" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/evaluationguardinmem" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/gameturnstatsinmem" "galaxy/lobby/internal/adapters/membershipinmem" "galaxy/lobby/internal/adapters/racenameinmem" "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 *gameinmem.Store memberships *membershipinmem.Store stats *gameturnstatsinmem.Store directory *racenameinmem.Directory intents *spyIntents guard *evaluationguardinmem.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 := gameinmem.NewStore() memberships := membershipinmem.NewStore() stats := gameturnstatsinmem.NewStore() directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now.Add(-time.Hour)))) require.NoError(t, err) intents := &spyIntents{} guard := evaluationguardinmem.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) }