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
+270
View File
@@ -0,0 +1,270 @@
// Package gamestub provides an in-memory ports.GameStore implementation for
// service-level tests. The stub mirrors the behavioural contract of the
// Redis-backed adapter in redisstate: it enforces game.Transition for status
// updates, the ExpectedFrom CAS check, and the StartedAt/FinishedAt side
// effects of the canonical status transitions.
//
// Production code never wires this stub; it is test-only but exposed as a
// regular (non _test.go) package so other service test packages can import
// it.
package gamestub
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
)
// Store is a concurrency-safe in-memory implementation of ports.GameStore.
// The zero value is not usable; call NewStore to construct.
type Store struct {
mu sync.Mutex
records map[common.GameID]game.Game
}
// NewStore constructs one empty Store ready for use.
func NewStore() *Store {
return &Store{records: make(map[common.GameID]game.Game)}
}
// Save upserts record. It honors the contract stated by
// ports.GameStore.Save: Save does not apply the domain transition gate but
// validates the record.
func (store *Store) Save(ctx context.Context, record game.Game) error {
if store == nil {
return errors.New("save game: nil store")
}
if ctx == nil {
return errors.New("save game: nil context")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("save game: %w", err)
}
store.mu.Lock()
defer store.mu.Unlock()
store.records[record.GameID] = record
return nil
}
// Get returns the record identified by gameID. It returns game.ErrNotFound
// when no record exists.
func (store *Store) Get(ctx context.Context, gameID common.GameID) (game.Game, error) {
if store == nil {
return game.Game{}, errors.New("get game: nil store")
}
if ctx == nil {
return game.Game{}, errors.New("get game: nil context")
}
if err := gameID.Validate(); err != nil {
return game.Game{}, fmt.Errorf("get game: %w", err)
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[gameID]
if !ok {
return game.Game{}, game.ErrNotFound
}
return record, nil
}
// CountByStatus returns the per-status game record count. Every status from
// game.AllStatuses is present in the result, with zero values for empty
// buckets, mirroring the Redis adapter contract.
func (store *Store) CountByStatus(ctx context.Context) (map[game.Status]int, error) {
if store == nil {
return nil, errors.New("count games by status: nil store")
}
if ctx == nil {
return nil, errors.New("count games by status: nil context")
}
store.mu.Lock()
defer store.mu.Unlock()
counts := make(map[game.Status]int, len(game.AllStatuses()))
for _, status := range game.AllStatuses() {
counts[status] = 0
}
for _, record := range store.records {
counts[record.Status]++
}
return counts, nil
}
// GetByStatus returns every record whose Status equals status. The slice is
// ordered by CreatedAt ascending to match the Redis adapter.
func (store *Store) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) {
if store == nil {
return nil, errors.New("get games by status: nil store")
}
if ctx == nil {
return nil, errors.New("get games by status: nil context")
}
if !status.IsKnown() {
return nil, fmt.Errorf("get games by status: status %q is unsupported", status)
}
store.mu.Lock()
defer store.mu.Unlock()
matching := make([]game.Game, 0, len(store.records))
for _, record := range store.records {
if record.Status == status {
matching = append(matching, record)
}
}
sort.Slice(matching, func(i, j int) bool {
return matching[i].CreatedAt.Before(matching[j].CreatedAt)
})
return matching, nil
}
// GetByOwner returns every record whose OwnerUserID equals userID. The
// slice is ordered by CreatedAt ascending to match the Redis adapter.
func (store *Store) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) {
if store == nil {
return nil, errors.New("get games by owner: nil store")
}
if ctx == nil {
return nil, errors.New("get games by owner: nil context")
}
trimmed := strings.TrimSpace(userID)
if trimmed == "" {
return nil, fmt.Errorf("get games by owner: user id must not be empty")
}
store.mu.Lock()
defer store.mu.Unlock()
matching := make([]game.Game, 0, len(store.records))
for _, record := range store.records {
if record.OwnerUserID == trimmed {
matching = append(matching, record)
}
}
sort.Slice(matching, func(i, j int) bool {
return matching[i].CreatedAt.Before(matching[j].CreatedAt)
})
return matching, nil
}
// UpdateStatus applies one status transition in a compare-and-swap fashion.
// It returns an error from game.Transition for invalid triplets, returns
// game.ErrNotFound for a missing record, and game.ErrConflict when the
// current status differs from input.ExpectedFrom.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
if store == nil {
return errors.New("update game status: nil store")
}
if ctx == nil {
return errors.New("update game status: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update game status: %w", err)
}
if err := game.Transition(input.ExpectedFrom, input.To, input.Trigger); err != nil {
return err
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[input.GameID]
if !ok {
return game.ErrNotFound
}
if record.Status != input.ExpectedFrom {
return fmt.Errorf("update game status: %w", game.ErrConflict)
}
at := input.At.UTC()
record.Status = input.To
record.UpdatedAt = at
if input.To == game.StatusRunning && record.StartedAt == nil {
startedAt := at
record.StartedAt = &startedAt
}
if input.To == game.StatusFinished && record.FinishedAt == nil {
finishedAt := at
record.FinishedAt = &finishedAt
}
store.records[input.GameID] = record
return nil
}
// UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot fields
// on the record identified by input.GameID. It does not change the status
// field.
func (store *Store) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error {
if store == nil {
return errors.New("update runtime snapshot: nil store")
}
if ctx == nil {
return errors.New("update runtime snapshot: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update runtime snapshot: %w", err)
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[input.GameID]
if !ok {
return game.ErrNotFound
}
record.RuntimeSnapshot = input.Snapshot
record.UpdatedAt = input.At.UTC()
store.records[input.GameID] = record
return nil
}
// UpdateRuntimeBinding overwrites the runtime binding metadata on the
// record identified by input.GameID. It does not change the status
// field. uses this method from the runtimejobresult worker
// after a successful container start.
func (store *Store) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error {
if store == nil {
return errors.New("update runtime binding: nil store")
}
if ctx == nil {
return errors.New("update runtime binding: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[input.GameID]
if !ok {
return game.ErrNotFound
}
binding := input.Binding
record.RuntimeBinding = &binding
record.UpdatedAt = input.At.UTC()
store.records[input.GameID] = record
return nil
}
// Ensure Store satisfies the ports.GameStore interface at compile time.
var _ ports.GameStore = (*Store)(nil)
@@ -0,0 +1,276 @@
package gamestub
import (
"context"
"errors"
"testing"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/require"
)
func newDraftRecord(t *testing.T, id common.GameID, createdAt time.Time) game.Game {
t.Helper()
record, err := game.New(game.NewGameInput{
GameID: id,
GameName: "Test Game",
GameType: game.GameTypePublic,
OwnerUserID: "",
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: createdAt.Add(24 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: createdAt,
})
require.NoError(t, err)
return record
}
func TestStoreSaveGetRoundtrip(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
record := newDraftRecord(t, "game-alpha", time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))
require.NoError(t, store.Save(ctx, record))
loaded, err := store.Get(ctx, "game-alpha")
require.NoError(t, err)
require.Equal(t, record.GameID, loaded.GameID)
require.Equal(t, record.Status, loaded.Status)
require.Equal(t, record.UpdatedAt.UTC(), loaded.UpdatedAt)
}
func TestStoreGetMissing(t *testing.T) {
t.Parallel()
store := NewStore()
_, err := store.Get(context.Background(), "game-missing")
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestStoreGetByStatusOrderedByCreatedAt(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
earlier := time.Date(2026, 4, 24, 9, 0, 0, 0, time.UTC)
later := earlier.Add(30 * time.Minute)
a := newDraftRecord(t, "game-a", earlier)
b := newDraftRecord(t, "game-b", later)
require.NoError(t, store.Save(ctx, b))
require.NoError(t, store.Save(ctx, a))
records, err := store.GetByStatus(ctx, game.StatusDraft)
require.NoError(t, err)
require.Len(t, records, 2)
require.Equal(t, common.GameID("game-a"), records[0].GameID)
require.Equal(t, common.GameID("game-b"), records[1].GameID)
}
func TestStoreCountByStatusReturnsAllStatusBuckets(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
createdAt := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
require.NoError(t, store.Save(ctx, newDraftRecord(t, "game-a", createdAt)))
require.NoError(t, store.Save(ctx, newDraftRecord(t, "game-b", createdAt)))
counts, err := store.CountByStatus(ctx)
require.NoError(t, err)
for _, status := range game.AllStatuses() {
_, present := counts[status]
require.True(t, present, "expected %s bucket", status)
}
require.Equal(t, 2, counts[game.StatusDraft])
require.Equal(t, 0, counts[game.StatusRunning])
}
func TestStoreUpdateStatusHappyPath(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
record := newDraftRecord(t, "game-open", created)
require.NoError(t, store.Save(ctx, record))
at := created.Add(time.Hour)
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-open",
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: at,
})
require.NoError(t, err)
loaded, err := store.Get(ctx, "game-open")
require.NoError(t, err)
require.Equal(t, game.StatusEnrollmentOpen, loaded.Status)
require.Equal(t, at.UTC(), loaded.UpdatedAt)
}
func TestStoreUpdateStatusInvalidTransition(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
record := newDraftRecord(t, "game-invalid", time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC))
require.NoError(t, store.Save(ctx, record))
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-invalid",
ExpectedFrom: game.StatusDraft,
To: game.StatusRunning,
Trigger: game.TriggerCommand,
At: time.Now().UTC(),
})
require.Error(t, err)
require.ErrorIs(t, err, game.ErrInvalidTransition)
}
func TestStoreUpdateStatusCASMismatch(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
record := newDraftRecord(t, "game-cas", created)
require.NoError(t, store.Save(ctx, record))
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-cas",
ExpectedFrom: game.StatusEnrollmentOpen,
To: game.StatusReadyToStart,
Trigger: game.TriggerManual,
At: created.Add(time.Hour),
})
require.Error(t, err)
require.ErrorIs(t, err, game.ErrConflict)
}
func TestStoreUpdateStatusMissing(t *testing.T) {
t.Parallel()
store := NewStore()
err := store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
GameID: "game-nope",
ExpectedFrom: game.StatusDraft,
To: game.StatusEnrollmentOpen,
Trigger: game.TriggerCommand,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, game.ErrNotFound)
}
func TestStoreUpdateRuntimeSnapshot(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
record := newDraftRecord(t, "game-snap", created)
require.NoError(t, store.Save(ctx, record))
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
GameID: "game-snap",
Snapshot: game.RuntimeSnapshot{
CurrentTurn: 7,
RuntimeStatus: "alive",
EngineHealthSummary: "ok",
},
At: created.Add(2 * time.Hour),
})
require.NoError(t, err)
loaded, err := store.Get(ctx, "game-snap")
require.NoError(t, err)
require.Equal(t, 7, loaded.RuntimeSnapshot.CurrentTurn)
require.Equal(t, "alive", loaded.RuntimeSnapshot.RuntimeStatus)
require.Equal(t, game.StatusDraft, loaded.Status, "snapshot update must not alter status")
}
func TestStoreValidateInputs(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{GameID: ""})
require.Error(t, err)
err = store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{GameID: ""})
require.Error(t, err)
_, err = store.GetByStatus(ctx, game.Status("ghost"))
require.Error(t, err)
require.True(t, errors.Is(game.ErrNotFound, game.ErrNotFound))
}
func TestStoreUpdateStatusSetsStartedAtAndFinishedAt(t *testing.T) {
t.Parallel()
store := NewStore()
ctx := context.Background()
created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
record := newDraftRecord(t, "game-timeline", created)
record.Status = game.StatusStarting
record.UpdatedAt = created.Add(time.Hour)
require.NoError(t, store.Save(ctx, record))
runningAt := created.Add(2 * time.Hour)
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-timeline",
ExpectedFrom: game.StatusStarting,
To: game.StatusRunning,
Trigger: game.TriggerRuntimeEvent,
At: runningAt,
})
require.NoError(t, err)
loaded, err := store.Get(ctx, "game-timeline")
require.NoError(t, err)
require.NotNil(t, loaded.StartedAt)
require.Equal(t, runningAt.UTC(), loaded.StartedAt.UTC())
require.Nil(t, loaded.FinishedAt)
finishAt := runningAt.Add(5 * time.Hour)
err = store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-timeline",
ExpectedFrom: game.StatusRunning,
To: game.StatusFinished,
Trigger: game.TriggerRuntimeEvent,
At: finishAt,
})
require.NoError(t, err)
loaded, err = store.Get(ctx, "game-timeline")
require.NoError(t, err)
require.NotNil(t, loaded.FinishedAt)
require.Equal(t, finishAt.UTC(), loaded.FinishedAt.UTC())
require.Equal(t, runningAt.UTC(), loaded.StartedAt.UTC(), "StartedAt must be preserved")
}