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,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")
}