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,250 @@
// Package listmyracenames implements the `lobby.race_names.list`
// message type. It returns the acting user's view of the Race Name
// Directory across all three levels of binding: permanent registered
// names, pending_registration entries waiting for their 30-day window
// to elapse, and active per-game reservations.
//
// The service is intentionally thin: it consults the user-keyed RND
// indexes through the port (no full scan of the directory) and joins
// each active reservation with the current `game.Status` from the
// Game Store so callers can render the reservation alongside the
// game it belongs to. No User Service eligibility lookup is required —
// reading one's own name set is unconditional.
package listmyracenames
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/shared"
)
// Service executes the race-name self-service read use case.
type Service struct {
directory ports.RaceNameDirectory
games ports.GameStore
logger *slog.Logger
}
// Dependencies groups the collaborators used by Service.
type Dependencies struct {
// Directory exposes the user-keyed RND indexes (registered,
// pending_registration, reservation) that satisfy the no-full-scan
// requirement of `lobby.race_names.list`.
Directory ports.RaceNameDirectory
// Games supplies the current `game.Status` of each active
// reservation so the response can render it next to the
// game id (README §Race Name self-service).
Games ports.GameStore
// Logger records structural diagnostics. Defaults to slog.Default
// when nil.
Logger *slog.Logger
}
// NewService constructs one Service with deps.
func NewService(deps Dependencies) (*Service, error) {
switch {
case deps.Directory == nil:
return nil, errors.New("new list my race names service: nil race name directory")
case deps.Games == nil:
return nil, errors.New("new list my race names service: nil game store")
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Service{
directory: deps.Directory,
games: deps.Games,
logger: logger.With("service", "lobby.listmyracenames"),
}, nil
}
// Input stores the arguments required to compute the acting user's
// race-name view.
type Input struct {
// Actor identifies the caller. Must be ActorKindUser; the
// `lobby.race_names.list` route is exclusively self-service and
// admin actors are rejected.
Actor shared.Actor
}
// RegisteredName describes one permanent race name owned by the
// acting user. Mirrors `ports.RegisteredName` field-for-field so the
// transport can return them as-is.
type RegisteredName struct {
CanonicalKey string
RaceName string
SourceGameID string
RegisteredAtMs int64
}
// PendingRegistration describes one promoted reservation waiting for
// the acting user to convert it into a registered name.
type PendingRegistration struct {
CanonicalKey string
RaceName string
SourceGameID string
ReservedAtMs int64
EligibleUntilMs int64
}
// Reservation describes one active per-game reservation owned by the
// acting user. GameStatus carries the current `game.Status` of the
// hosting game (empty when the game record could not be loaded).
type Reservation struct {
CanonicalKey string
RaceName string
GameID string
ReservedAtMs int64
GameStatus string
}
// Output stores the three slices returned by Handle. Each slice is
// non-nil but possibly empty. Items inside one slice are ordered by
// the relevant timestamp ascending, then by canonical key, so output
// is stable for tests and UI alike.
type Output struct {
Registered []RegisteredName
Pending []PendingRegistration
Reservations []Reservation
}
// Handle authorizes the actor as a user, fetches the three RND views
// for that user, and joins each reservation with the current game
// status.
func (service *Service) Handle(ctx context.Context, input Input) (Output, error) {
if service == nil {
return Output{}, errors.New("list my race names: nil service")
}
if ctx == nil {
return Output{}, errors.New("list my race names: nil context")
}
if err := input.Actor.Validate(); err != nil {
return Output{}, fmt.Errorf("list my race names: actor: %w", err)
}
if !input.Actor.IsUser() {
return Output{}, fmt.Errorf(
"%w: only authenticated user actors may list their race names",
shared.ErrForbidden,
)
}
registered, err := service.directory.ListRegistered(ctx, input.Actor.UserID)
if err != nil {
return Output{}, fmt.Errorf("list my race names: %w", err)
}
pending, err := service.directory.ListPendingRegistrations(ctx, input.Actor.UserID)
if err != nil {
return Output{}, fmt.Errorf("list my race names: %w", err)
}
reservations, err := service.directory.ListReservations(ctx, input.Actor.UserID)
if err != nil {
return Output{}, fmt.Errorf("list my race names: %w", err)
}
out := Output{
Registered: make([]RegisteredName, 0, len(registered)),
Pending: make([]PendingRegistration, 0, len(pending)),
Reservations: make([]Reservation, 0, len(reservations)),
}
for _, entry := range registered {
out.Registered = append(out.Registered, RegisteredName{
CanonicalKey: entry.CanonicalKey,
RaceName: entry.RaceName,
SourceGameID: entry.SourceGameID,
RegisteredAtMs: entry.RegisteredAtMs,
})
}
for _, entry := range pending {
out.Pending = append(out.Pending, PendingRegistration{
CanonicalKey: entry.CanonicalKey,
RaceName: entry.RaceName,
SourceGameID: entry.GameID,
ReservedAtMs: entry.ReservedAtMs,
EligibleUntilMs: entry.EligibleUntilMs,
})
}
for _, entry := range reservations {
status := service.lookupGameStatus(ctx, common.GameID(entry.GameID), input.Actor.UserID)
out.Reservations = append(out.Reservations, Reservation{
CanonicalKey: entry.CanonicalKey,
RaceName: entry.RaceName,
GameID: entry.GameID,
ReservedAtMs: entry.ReservedAtMs,
GameStatus: status,
})
}
sortRegistered(out.Registered)
sortPending(out.Pending)
sortReservations(out.Reservations)
return out, nil
}
// lookupGameStatus returns the current game status for gameID. When
// the game record cannot be loaded — including the defensive
// game.ErrNotFound case — the empty string is returned and the miss
// is logged so operators can investigate dangling RND state.
func (service *Service) lookupGameStatus(
ctx context.Context,
gameID common.GameID,
userID string,
) string {
record, err := service.games.Get(ctx, gameID)
if err == nil {
return string(record.Status)
}
level := slog.LevelWarn
if errors.Is(err, game.ErrNotFound) {
service.logger.Log(ctx, level,
"reservation references missing game",
"user_id", userID,
"game_id", gameID.String(),
)
return ""
}
service.logger.Log(ctx, level,
"load reservation game status",
"user_id", userID,
"game_id", gameID.String(),
"err", err.Error(),
)
return ""
}
func sortRegistered(items []RegisteredName) {
sort.Slice(items, func(i, j int) bool {
if items[i].RegisteredAtMs != items[j].RegisteredAtMs {
return items[i].RegisteredAtMs < items[j].RegisteredAtMs
}
return items[i].CanonicalKey < items[j].CanonicalKey
})
}
func sortPending(items []PendingRegistration) {
sort.Slice(items, func(i, j int) bool {
if items[i].EligibleUntilMs != items[j].EligibleUntilMs {
return items[i].EligibleUntilMs < items[j].EligibleUntilMs
}
return items[i].CanonicalKey < items[j].CanonicalKey
})
}
func sortReservations(items []Reservation) {
sort.Slice(items, func(i, j int) bool {
if items[i].ReservedAtMs != items[j].ReservedAtMs {
return items[i].ReservedAtMs < items[j].ReservedAtMs
}
return items[i].CanonicalKey < items[j].CanonicalKey
})
}
@@ -0,0 +1,302 @@
package listmyracenames_test
import (
"context"
"io"
"log/slog"
"testing"
"time"
"galaxy/lobby/internal/adapters/gamestub"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/listmyracenames"
"galaxy/lobby/internal/service/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// silentLogger discards all log output to keep test stdout clean.
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// fixture wires the listmyracenames service with the in-process
// race-name directory stub and the in-process game store.
type fixture struct {
now time.Time
directory *racenamestub.Directory
games *gamestub.Store
service *listmyracenames.Service
}
func newFixture(t *testing.T) *fixture {
t.Helper()
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
require.NoError(t, err)
games := gamestub.NewStore()
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
Directory: directory,
Games: games,
Logger: silentLogger(),
})
require.NoError(t, err)
return &fixture{now: now, directory: directory, games: games, service: svc}
}
// seedGame stores one game record so reservation lookups have a record
// to read. Private games receive a synthetic owner so domain
// validation accepts them.
func (f *fixture) seedGame(t *testing.T, id common.GameID, gameType game.GameType, status game.Status) {
t.Helper()
owner := ""
if gameType == game.GameTypePrivate {
owner = "owner-" + id.String()
}
record, err := game.New(game.NewGameInput{
GameID: id,
GameName: "Seed " + id.String(),
GameType: gameType,
OwnerUserID: owner,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: f.now.Add(24 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: f.now,
})
require.NoError(t, err)
if status != game.StatusDraft {
record.Status = status
}
require.NoError(t, f.games.Save(context.Background(), record))
}
func (f *fixture) reserve(t *testing.T, gameID, userID, raceName string) {
t.Helper()
require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName))
}
func (f *fixture) markPending(t *testing.T, gameID, userID, raceName string, eligibleUntil time.Time) {
t.Helper()
require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), gameID, userID, raceName, eligibleUntil))
}
func (f *fixture) register(t *testing.T, gameID, userID, raceName string) {
t.Helper()
require.NoError(t, f.directory.Register(context.Background(), gameID, userID, raceName))
}
func TestHandleHappyPath(t *testing.T) {
t.Parallel()
f := newFixture(t)
const userID = "user-1"
// One registered name converted from a finished game.
f.seedGame(t, "game-finished", game.GameTypePublic, game.StatusFinished)
f.reserve(t, "game-finished", userID, "Andromeda")
f.markPending(t, "game-finished", userID, "Andromeda", f.now.Add(7*24*time.Hour))
f.register(t, "game-finished", userID, "Andromeda")
// One pending registration awaiting the
f.seedGame(t, "game-pending", game.GameTypePublic, game.StatusFinished)
f.reserve(t, "game-pending", userID, "Vega")
f.markPending(t, "game-pending", userID, "Vega", f.now.Add(24*time.Hour))
// Two active reservations across a running and an enrollment_open game.
f.seedGame(t, "game-running", game.GameTypePrivate, game.StatusRunning)
f.reserve(t, "game-running", userID, "Orion")
f.seedGame(t, "game-open", game.GameTypePublic, game.StatusEnrollmentOpen)
f.reserve(t, "game-open", userID, "Cygnus")
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
Actor: shared.NewUserActor(userID),
})
require.NoError(t, err)
require.Len(t, out.Registered, 1)
assert.Equal(t, "Andromeda", out.Registered[0].RaceName)
assert.Equal(t, "game-finished", out.Registered[0].SourceGameID)
assert.Equal(t, f.now.UnixMilli(), out.Registered[0].RegisteredAtMs)
assert.NotEmpty(t, out.Registered[0].CanonicalKey)
require.Len(t, out.Pending, 1)
assert.Equal(t, "Vega", out.Pending[0].RaceName)
assert.Equal(t, "game-pending", out.Pending[0].SourceGameID)
assert.Equal(t, f.now.Add(24*time.Hour).UnixMilli(), out.Pending[0].EligibleUntilMs)
assert.Equal(t, f.now.UnixMilli(), out.Pending[0].ReservedAtMs)
require.Len(t, out.Reservations, 2)
gameStatusByID := map[string]string{}
for _, r := range out.Reservations {
gameStatusByID[r.GameID] = r.GameStatus
}
assert.Equal(t, string(game.StatusRunning), gameStatusByID["game-running"])
assert.Equal(t, string(game.StatusEnrollmentOpen), gameStatusByID["game-open"])
}
func TestHandleEmptyView(t *testing.T) {
t.Parallel()
f := newFixture(t)
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
Actor: shared.NewUserActor("user-empty"),
})
require.NoError(t, err)
assert.NotNil(t, out.Registered)
assert.NotNil(t, out.Pending)
assert.NotNil(t, out.Reservations)
assert.Empty(t, out.Registered)
assert.Empty(t, out.Pending)
assert.Empty(t, out.Reservations)
}
func TestHandleAdminActorIsForbidden(t *testing.T) {
t.Parallel()
f := newFixture(t)
_, err := f.service.Handle(context.Background(), listmyracenames.Input{
Actor: shared.NewAdminActor(),
})
require.Error(t, err)
assert.ErrorIs(t, err, shared.ErrForbidden)
}
func TestHandleInvalidActorIsRejected(t *testing.T) {
t.Parallel()
f := newFixture(t)
_, err := f.service.Handle(context.Background(), listmyracenames.Input{Actor: shared.Actor{}})
require.Error(t, err)
assert.NotErrorIs(t, err, shared.ErrForbidden)
}
func TestHandleVisibilityIsolation(t *testing.T) {
t.Parallel()
f := newFixture(t)
f.seedGame(t, "game-shared", game.GameTypePublic, game.StatusEnrollmentOpen)
f.reserve(t, "game-shared", "user-owner", "Polaris")
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
Actor: shared.NewUserActor("user-other"),
})
require.NoError(t, err)
assert.Empty(t, out.Reservations)
assert.Empty(t, out.Pending)
assert.Empty(t, out.Registered)
}
func TestHandleReservationOnMissingGame(t *testing.T) {
t.Parallel()
f := newFixture(t)
f.reserve(t, "game-vanished", "user-1", "Hydra")
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
Actor: shared.NewUserActor("user-1"),
})
require.NoError(t, err)
require.Len(t, out.Reservations, 1)
assert.Equal(t, "game-vanished", out.Reservations[0].GameID)
assert.Empty(t, out.Reservations[0].GameStatus)
}
func TestHandleSortByTimestamp(t *testing.T) {
t.Parallel()
const userID = "user-sort"
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
clock := now
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return clock }))
require.NoError(t, err)
games := gamestub.NewStore()
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
Directory: directory,
Games: games,
Logger: silentLogger(),
})
require.NoError(t, err)
seed := func(id common.GameID) {
record, err := game.New(game.NewGameInput{
GameID: id,
GameName: "Seed " + id.String(),
GameType: game.GameTypePublic,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(24 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: now,
})
require.NoError(t, err)
record.Status = game.StatusEnrollmentOpen
require.NoError(t, games.Save(context.Background(), record))
}
seed("game-a")
seed("game-b")
seed("game-c")
// Reserve three names against an advancing clock so reserved_at_ms
// is strictly increasing.
clock = now
require.NoError(t, directory.Reserve(context.Background(), "game-a", userID, "Lyra"))
clock = now.Add(time.Minute)
require.NoError(t, directory.Reserve(context.Background(), "game-b", userID, "Sirius"))
clock = now.Add(2 * time.Minute)
require.NoError(t, directory.Reserve(context.Background(), "game-c", userID, "Pavo"))
out, err := svc.Handle(context.Background(), listmyracenames.Input{
Actor: shared.NewUserActor(userID),
})
require.NoError(t, err)
require.Len(t, out.Reservations, 3)
for index := 1; index < len(out.Reservations); index++ {
prev := out.Reservations[index-1]
curr := out.Reservations[index]
if prev.ReservedAtMs == curr.ReservedAtMs {
assert.LessOrEqual(t, prev.CanonicalKey, curr.CanonicalKey)
continue
}
assert.Less(t, prev.ReservedAtMs, curr.ReservedAtMs)
}
assert.Equal(t, "game-a", out.Reservations[0].GameID)
assert.Equal(t, "game-b", out.Reservations[1].GameID)
assert.Equal(t, "game-c", out.Reservations[2].GameID)
}
// TestNewServiceRejectsMissingDeps guards against constructor misuse.
func TestNewServiceRejectsMissingDeps(t *testing.T) {
t.Parallel()
directory, err := racenamestub.NewDirectory()
require.NoError(t, err)
games := gamestub.NewStore()
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
Games: games,
})
require.Error(t, err)
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
Directory: directory,
})
require.Error(t, err)
}
// Sanity guard so a future port refactor that drops the user-keyed
// indexes immediately breaks the test build instead of silently
// regressing the no-full-scan invariant.
var _ ports.RaceNameDirectory = (*racenamestub.Directory)(nil)