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