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
+90
View File
@@ -0,0 +1,90 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
)
// ApplicationStore stores application records and their secondary indexes.
// Adapters are responsible for maintaining the per-game set, per-user set,
// and the single-active lookup key together with the record.
type ApplicationStore interface {
// Save persists a new submitted application record. The adapter must
// enforce the single-active constraint — only one non-rejected
// application per (applicant_user_id, game_id) pair may exist at a
// time — and return application.ErrConflict if the constraint is
// violated. Save rejects records whose status is not submitted.
Save(ctx context.Context, record application.Application) error
// Get returns the record identified by applicationID. It returns
// application.ErrNotFound when no record exists.
Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error)
// GetByGame returns every application attached to gameID. The order
// is adapter-defined; callers may reorder as needed.
GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error)
// GetByUser returns every application submitted by applicantUserID.
// The order is adapter-defined; callers may reorder as needed.
GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error)
// UpdateStatus applies one status transition in a compare-and-swap
// fashion. The adapter must first call application.Transition to
// reject invalid pairs without touching the store; on success it must
// verify that the current status equals input.ExpectedFrom, update
// the primary record, and clear the single-active lookup key when
// transitioning to rejected. Adapters set DecidedAt to input.At.
UpdateStatus(ctx context.Context, input UpdateApplicationStatusInput) error
}
// UpdateApplicationStatusInput stores the arguments required to apply one
// status transition through an ApplicationStore.
type UpdateApplicationStatusInput struct {
// ApplicationID identifies the record to mutate.
ApplicationID common.ApplicationID
// ExpectedFrom stores the status the caller believes the record
// currently has. A mismatch results in application.ErrConflict.
ExpectedFrom application.Status
// To stores the destination status.
To application.Status
// At stores the wall-clock used for DecidedAt.
At time.Time
}
// Validate reports whether input contains a structurally valid status
// transition request.
func (input UpdateApplicationStatusInput) Validate() error {
if err := input.ApplicationID.Validate(); err != nil {
return fmt.Errorf("update application status: application id: %w", err)
}
if !input.ExpectedFrom.IsKnown() {
return fmt.Errorf(
"update application status: expected from status %q is unsupported",
input.ExpectedFrom,
)
}
if !input.To.IsKnown() {
return fmt.Errorf(
"update application status: to status %q is unsupported",
input.To,
)
}
if input.At.IsZero() {
return fmt.Errorf("update application status: at must not be zero")
}
return nil
}
// NormalizedApplicantUserID trims surrounding whitespace so adapter
// keyspace lookups match the form the domain persists.
func NormalizedApplicantUserID(userID string) string {
return strings.TrimSpace(userID)
}
@@ -0,0 +1,34 @@
package ports
import (
"context"
"galaxy/lobby/internal/domain/common"
)
// EvaluationGuardStore stores the per-game «already evaluated» marker used
// by capability evaluation to make replayed `game_finished` events
// idempotent. reads the marker before any RND mutation: a marker
// observed before mutation means the prior pass committed in full and the
// current pass can return without side effects. A marker absent before
// mutation means either a first attempt or a retry of an interrupted prior
// attempt; in both cases the evaluator re-runs the idempotent mutations
// and writes the marker only after every mutation, the stats cleanup, and
// the post-commit work succeed.
//
// The two methods exist to express that read-then-write order: IsEvaluated
// is a non-mutating check used early in the pass, and MarkEvaluated commits
// the marker once the pass is fully complete. Implementations must persist
// the marker durably; an in-process implementation is acceptable only for
// tests because process restarts must observe the same marker state.
type EvaluationGuardStore interface {
// IsEvaluated reports whether gameID is already marked. The method
// must not mutate state and must distinguish a missing marker from a
// transport error.
IsEvaluated(ctx context.Context, gameID common.GameID) (bool, error)
// MarkEvaluated records gameID as evaluated. Calling MarkEvaluated
// twice for the same gameID is safe; the second call leaves the
// marker untouched.
MarkEvaluated(ctx context.Context, gameID common.GameID) error
}
+169
View File
@@ -0,0 +1,169 @@
// Package ports defines the stable interfaces that connect Game Lobby
// Service use cases to external state and external services.
package ports
import (
"context"
"fmt"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
)
// GameStore stores game records and their secondary indexes. Adapters are
// responsible for maintaining the status index together with the record.
type GameStore interface {
// Save upserts record. It is used for draft creation and for
// field-only edits. The adapter must rewrite the status secondary
// index when the status field changes. Save does not apply the
// domain transition gate; callers that intend a status transition
// must use UpdateStatus instead.
Save(ctx context.Context, record game.Game) error
// Get returns the record identified by gameID. It returns
// game.ErrNotFound when no record exists.
Get(ctx context.Context, gameID common.GameID) (game.Game, error)
// GetByStatus returns every record currently indexed under status.
// The slice is ordered by the created-at score ascending; callers
// may reorder as needed.
GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error)
// CountByStatus returns the number of game records indexed under
// each known status. The map carries one entry per game.Status from
// game.AllStatuses, with zero counts for empty buckets. Telemetry
// uses the result to emit the `lobby.active_games` observable gauge
// without scanning record payloads.
CountByStatus(ctx context.Context) (map[game.Status]int, error)
// GetByOwner returns every record whose OwnerUserID equals userID.
// The order is adapter-defined; callers may reorder as needed. The
// secondary index is maintained alongside the per-status index;
// cascade-release callers consume it without touching the
// status listings.
GetByOwner(ctx context.Context, userID string) ([]game.Game, error)
// UpdateStatus applies one status transition in a compare-and-swap
// fashion. The adapter must first call game.Transition to reject
// invalid triplets without touching the store; on success it must
// verify that the current status equals input.ExpectedFrom, update
// the primary record, and rewrite the status secondary index.
// Adapters set StartedAt when transitioning to running and
// FinishedAt when transitioning to finished.
UpdateStatus(ctx context.Context, input UpdateStatusInput) error
// UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot
// fields on the record identified by input.GameID. It does not
// mutate the status field or the status secondary index.
UpdateRuntimeSnapshot(ctx context.Context, input UpdateRuntimeSnapshotInput) error
// UpdateRuntimeBinding overwrites the runtime binding metadata on the
// record identified by input.GameID. It does not mutate the status
// field or the status secondary index. The binding must satisfy the
// domain invariants (see game.RuntimeBinding.Validate). The adapter
// returns game.ErrNotFound when no record exists.
UpdateRuntimeBinding(ctx context.Context, input UpdateRuntimeBindingInput) error
}
// UpdateStatusInput stores the arguments required to apply one status
// transition through a GameStore.
type UpdateStatusInput struct {
// GameID identifies the record to mutate.
GameID common.GameID
// ExpectedFrom stores the status the caller believes the record
// currently has. A mismatch results in game.ErrConflict.
ExpectedFrom game.Status
// To stores the destination status.
To game.Status
// Trigger stores the transition trigger used by the domain gate.
Trigger game.Trigger
// At stores the wall-clock used for UpdatedAt, and for StartedAt or
// FinishedAt when the destination status requires it.
At time.Time
}
// Validate reports whether input contains a structurally valid status
// transition request.
func (input UpdateStatusInput) Validate() error {
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("update status: game id: %w", err)
}
if !input.ExpectedFrom.IsKnown() {
return fmt.Errorf("update status: expected from status %q is unsupported", input.ExpectedFrom)
}
if !input.To.IsKnown() {
return fmt.Errorf("update status: to status %q is unsupported", input.To)
}
if !input.Trigger.IsKnown() {
return fmt.Errorf("update status: trigger %q is unsupported", input.Trigger)
}
if input.At.IsZero() {
return fmt.Errorf("update status: at must not be zero")
}
return nil
}
// UpdateRuntimeSnapshotInput stores the arguments required to update the
// denormalized runtime snapshot on one game record.
type UpdateRuntimeSnapshotInput struct {
// GameID identifies the record to mutate.
GameID common.GameID
// Snapshot stores the new snapshot values to persist.
Snapshot game.RuntimeSnapshot
// At stores the wall-clock used for UpdatedAt.
At time.Time
}
// Validate reports whether input contains a structurally valid runtime
// snapshot update request.
func (input UpdateRuntimeSnapshotInput) Validate() error {
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("update runtime snapshot: game id: %w", err)
}
if input.Snapshot.CurrentTurn < 0 {
return fmt.Errorf("update runtime snapshot: current turn must not be negative")
}
if input.At.IsZero() {
return fmt.Errorf("update runtime snapshot: at must not be zero")
}
return nil
}
// UpdateRuntimeBindingInput stores the arguments required to persist
// runtime binding metadata on one game record.
type UpdateRuntimeBindingInput struct {
// GameID identifies the record to mutate.
GameID common.GameID
// Binding stores the runtime binding values to persist. The adapter
// validates the binding before writing.
Binding game.RuntimeBinding
// At stores the wall-clock used for UpdatedAt.
At time.Time
}
// Validate reports whether input contains a structurally valid runtime
// binding update request.
func (input UpdateRuntimeBindingInput) Validate() error {
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("update runtime binding: game id: %w", err)
}
if err := input.Binding.Validate(); err != nil {
return fmt.Errorf("update runtime binding: %w", err)
}
if input.At.IsZero() {
return fmt.Errorf("update runtime binding: at must not be zero")
}
return nil
}
+138
View File
@@ -0,0 +1,138 @@
package ports
import (
"context"
"fmt"
"strings"
"galaxy/lobby/internal/domain/common"
)
// PlayerObservedStats stores one decoded `player_turn_stats` element from a
// Game Master event. The fields mirror the wire format documented in
// lobby/README.md §Runtime Snapshot. Counts are non-negative cumulative
// totals reported by the engine for the named user at the time the event
// was emitted.
type PlayerObservedStats struct {
// UserID identifies the platform user the stats line belongs to.
UserID string
// Planets stores the cumulative number of planets controlled by the
// user at observation time.
Planets int64
// Population stores the cumulative population owned by the user at
// observation time.
Population int64
// ShipsBuilt stores the cumulative ships-built counter for the user
// at observation time.
ShipsBuilt int64
}
// Validate reports whether stats contain the structural invariants required
// by GameTurnStatsStore methods.
func (stats PlayerObservedStats) Validate() error {
if strings.TrimSpace(stats.UserID) == "" {
return fmt.Errorf("player turn stats: user id must not be empty")
}
if stats.Planets < 0 {
return fmt.Errorf("player turn stats: planets must not be negative")
}
if stats.Population < 0 {
return fmt.Errorf("player turn stats: population must not be negative")
}
if stats.ShipsBuilt < 0 {
return fmt.Errorf("player turn stats: ships built must not be negative")
}
return nil
}
// PlayerInitialStats is an alias for PlayerObservedStats. Game Lobby
// Service freezes the first observation per user as that user's initial
// values; the wire format is identical, only the persistence semantics
// differ.
type PlayerInitialStats = PlayerObservedStats
// PlayerStatsAggregate stores the per-user aggregate maintained by
// GameTurnStatsStore for one game. Initial fields are frozen at the first
// SaveInitial call for the user; max fields are updated by per-component
// maximum on each UpdateMax call.
type PlayerStatsAggregate struct {
// UserID identifies the platform user the aggregate belongs to.
UserID string
// InitialPlanets stores the user's planets count from the first
// observation, frozen for the lifetime of the game.
InitialPlanets int64
// InitialPopulation stores the user's population from the first
// observation, frozen for the lifetime of the game.
InitialPopulation int64
// InitialShipsBuilt stores the user's ships-built counter from the
// first observation, frozen for the lifetime of the game.
InitialShipsBuilt int64
// MaxPlanets stores the running maximum planets count observed for
// the user across the lifetime of the game.
MaxPlanets int64
// MaxPopulation stores the running maximum population observed for
// the user across the lifetime of the game.
MaxPopulation int64
// MaxShipsBuilt stores the running maximum ships-built counter
// observed for the user across the lifetime of the game.
MaxShipsBuilt int64
}
// GameTurnStatsAggregate stores every PlayerStatsAggregate for one game.
// The slice is empty when no SaveInitial call has succeeded yet.
type GameTurnStatsAggregate struct {
// GameID identifies the game the aggregate belongs to.
GameID common.GameID
// Players stores the per-user aggregates ordered by UserID ascending.
Players []PlayerStatsAggregate
}
// GameTurnStatsStore stores per-game per-user initial and running-maximum
// stats derived from Game Master `runtime_snapshot_update` events. The
// aggregate is read once by capability evaluation at game finish and then
// deleted (see lobby/README.md §Runtime Snapshot).
//
// Adapters must keep the initial fields frozen against later SaveInitial
// calls and must keep the max fields monotonically non-decreasing on every
// UpdateMax call. All methods are safe to call concurrently for the same
// gameID.
type GameTurnStatsStore interface {
// SaveInitial freezes the initial fields for every user in stats.
// Subsequent calls for the same (gameID, user_id) tuple do not
// overwrite the stored initial values; they return nil so workers
// may call SaveInitial on every received event without bookkeeping.
// The first call for a user also primes the max fields with the same
// values so a single observation is reflected by both the initial
// and the running maximum.
SaveInitial(ctx context.Context, gameID common.GameID, stats []PlayerInitialStats) error
// UpdateMax updates the max fields for every user in stats by
// per-component maximum. Existing aggregate entries below the new
// observation are raised; existing entries at or above the new
// observation are left unchanged. Calling UpdateMax for a user that
// has no aggregate entry yet creates one whose initial fields and
// max fields both equal the observation, so callers may safely call
// UpdateMax without first calling SaveInitial.
UpdateMax(ctx context.Context, gameID common.GameID, stats []PlayerObservedStats) error
// Load returns the GameTurnStatsAggregate stored for gameID. The
// aggregate is empty (Players length zero) when no observation has
// been recorded yet; the GameID is always populated. The returned
// Players slice is ordered by UserID ascending so capability
// evaluation produces deterministic side-effect order on replay.
Load(ctx context.Context, gameID common.GameID) (GameTurnStatsAggregate, error)
// Delete removes every aggregate entry for gameID. The call is a
// no-op when no entries exist; no error is returned in that case.
Delete(ctx context.Context, gameID common.GameID) error
}
@@ -0,0 +1,26 @@
package ports
import (
"context"
"time"
"galaxy/lobby/internal/domain/common"
)
// GapActivationStore records the moment a game's gap window opens —
// when approved_count first reaches max_players. The enrollment
// automation worker reads the timestamp to decide when start_gap_hours
// have elapsed; approveapplication and redeeminvite are the writers.
type GapActivationStore interface {
// MarkActivated records at as the gap window activation time for
// gameID iff no prior activation exists. A second call for the same
// game is a silent no-op so retries after partial failures stay
// safe. Implementations use SETNX semantics under the hood.
MarkActivated(ctx context.Context, gameID common.GameID, at time.Time) error
// Get returns the previously recorded gap-window activation time for
// gameID. The second return value is false when no activation has
// been recorded yet; in that case the time.Time is the zero value.
// An error is returned only for technical failures.
Get(ctx context.Context, gameID common.GameID) (time.Time, bool, error)
}
+84
View File
@@ -0,0 +1,84 @@
package ports
import (
"context"
"errors"
"fmt"
"strings"
"galaxy/lobby/internal/domain/common"
)
// ErrGMUnavailable signals that a Game Master call could not be
// completed because the upstream service was unreachable, returned an
// error response, or timed out. treats every non-success
// outcome of GMClient.RegisterGame uniformly: the start flow transitions
// to `paused` and an admin notification is published.
var ErrGMUnavailable = errors.New("game master unavailable")
// GMClient executes synchronous calls to Game Master. introduced
// the registration call; added the liveness probe used by the
// voluntary resume flow.
type GMClient interface {
// RegisterGame registers a running game with Game Master after a
// successful container start and runtime binding persistence. A
// non-nil error is returned for any non-success outcome (transport
// error, timeout, non-2xx response). Implementations wrap such
// failures with ErrGMUnavailable so callers can branch with
// errors.Is.
RegisterGame(ctx context.Context, request RegisterGameRequest) error
// Ping performs a synchronous liveness probe against Game Master.
// Implementations return nil when GM is reachable and healthy and
// wrap every other outcome (transport error, timeout, non-2xx
// response) with ErrGMUnavailable so callers can
// branch with errors.Is.
Ping(ctx context.Context) error
}
// RegisterGameRequest stores the parameters required to register one
// running game with Game Master. The shape mirrors the JSON body sent
// by the HTTP adapter and is independent of the GM-side schema so the
// adapter and the consumer can reason about it without leaking
// transport details.
type RegisterGameRequest struct {
// GameID identifies the running game.
GameID common.GameID
// ContainerID identifies the engine container assigned by Runtime
// Manager.
ContainerID string
// EngineEndpoint stores the network address Game Master uses to
// reach the engine container.
EngineEndpoint string
// TargetEngineVersion stores the semver of the engine version that
// was launched (copied from the game record at registration time).
TargetEngineVersion string
// TurnSchedule stores the cron expression that drives turn
// generation (copied from the game record at registration time).
TurnSchedule string
}
// Validate reports whether request stores the structurally valid
// arguments required for Game Master registration.
func (request RegisterGameRequest) Validate() error {
if err := request.GameID.Validate(); err != nil {
return fmt.Errorf("register game: game id: %w", err)
}
if strings.TrimSpace(request.ContainerID) == "" {
return fmt.Errorf("register game: container id must not be empty")
}
if strings.TrimSpace(request.EngineEndpoint) == "" {
return fmt.Errorf("register game: engine endpoint must not be empty")
}
if strings.TrimSpace(request.TargetEngineVersion) == "" {
return fmt.Errorf("register game: target engine version must not be empty")
}
if strings.TrimSpace(request.TurnSchedule) == "" {
return fmt.Errorf("register game: turn schedule must not be empty")
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package ports
import "galaxy/lobby/internal/domain/common"
// IDGenerator creates new stable identifiers for Game Lobby records.
//
// introduced NewGameID. added NewApplicationID and
// NewMembershipID. extends the interface with NewInviteID.
type IDGenerator interface {
// NewGameID returns one newly generated opaque game identifier. The
// returned value carries the frozen `game-*` prefix and passes
// common.GameID.Validate.
NewGameID() (common.GameID, error)
// NewApplicationID returns one newly generated opaque application
// identifier. The returned value carries the frozen `application-*`
// prefix and passes common.ApplicationID.Validate.
NewApplicationID() (common.ApplicationID, error)
// NewInviteID returns one newly generated opaque invite identifier.
// The returned value carries the frozen `invite-*` prefix and passes
// common.InviteID.Validate.
NewInviteID() (common.InviteID, error)
// NewMembershipID returns one newly generated opaque membership
// identifier. The returned value carries the frozen `membership-*`
// prefix and passes common.MembershipID.Validate.
NewMembershipID() (common.MembershipID, error)
}
+22
View File
@@ -0,0 +1,22 @@
package ports
import (
"context"
"galaxy/notificationintent"
)
// IntentPublisher is the lobby-facing producer port for normalized
// notification intents. The production adapter is a
// *notificationintent.Publisher which already satisfies this interface;
// service tests use an in-process stub that records every Publish call.
//
// A failed Publish call is a notification degradation per
// lobby/README.md §Notification Contracts and must not roll back already
// committed business state. Callers log the error and proceed.
type IntentPublisher interface {
// Publish normalizes intent and appends it to the configured Redis
// Stream, returning the stream entry id on success. Validation
// failures and transport errors are returned verbatim.
Publish(ctx context.Context, intent notificationintent.Intent) (string, error)
}
+106
View File
@@ -0,0 +1,106 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/invite"
)
// InviteStore stores invite records and their secondary indexes. Adapters
// are responsible for maintaining the per-game set and per-invitee set
// together with the record.
type InviteStore interface {
// Save persists a new created invite record. Save rejects records
// whose status is not created and is create-only: re-saving an
// existing invite id returns invite.ErrConflict.
Save(ctx context.Context, record invite.Invite) error
// Get returns the record identified by inviteID. It returns
// invite.ErrNotFound when no record exists.
Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error)
// GetByGame returns every invite attached to gameID. The order is
// adapter-defined; callers may reorder as needed.
GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error)
// GetByUser returns every invite addressed to inviteeUserID. The
// order is adapter-defined; callers may reorder as needed.
GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error)
// GetByInviter returns every invite created by inviterUserID. The
// order is adapter-defined; callers may reorder as needed. The
// secondary index is maintained alongside the per-game and per-user
// indexes; cascade-release callers read it without touching
// the per-game listings.
GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error)
// UpdateStatus applies one status transition in a compare-and-swap
// fashion. The adapter must first call invite.Transition to reject
// invalid pairs without touching the store; on success it must
// verify that the current status equals input.ExpectedFrom, update
// the primary record, set DecidedAt to input.At, and apply
// input.RaceName when transitioning to redeemed. input.RaceName must
// be non-empty when To is redeemed and empty otherwise.
UpdateStatus(ctx context.Context, input UpdateInviteStatusInput) error
}
// UpdateInviteStatusInput stores the arguments required to apply one
// status transition through an InviteStore.
type UpdateInviteStatusInput struct {
// InviteID identifies the record to mutate.
InviteID common.InviteID
// ExpectedFrom stores the status the caller believes the record
// currently has. A mismatch results in invite.ErrConflict.
ExpectedFrom invite.Status
// To stores the destination status.
To invite.Status
// At stores the wall-clock used for DecidedAt.
At time.Time
// RaceName carries the invitee's confirmed in-game name. It is
// required when To is redeemed and must be empty otherwise.
RaceName string
}
// Validate reports whether input contains a structurally valid status
// transition request.
func (input UpdateInviteStatusInput) Validate() error {
if err := input.InviteID.Validate(); err != nil {
return fmt.Errorf("update invite status: invite id: %w", err)
}
if !input.ExpectedFrom.IsKnown() {
return fmt.Errorf(
"update invite status: expected from status %q is unsupported",
input.ExpectedFrom,
)
}
if !input.To.IsKnown() {
return fmt.Errorf(
"update invite status: to status %q is unsupported",
input.To,
)
}
if input.At.IsZero() {
return fmt.Errorf("update invite status: at must not be zero")
}
if input.To == invite.StatusRedeemed {
if strings.TrimSpace(input.RaceName) == "" {
return fmt.Errorf(
"update invite status: race name must not be empty when redeeming",
)
}
} else if input.RaceName != "" {
return fmt.Errorf(
"update invite status: race name must be empty when transitioning to %q",
input.To,
)
}
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/membership"
)
// MembershipStore stores membership records and their secondary indexes.
// Adapters are responsible for maintaining the per-game set and per-user
// set together with the record.
type MembershipStore interface {
// Save persists a new active membership record. Save rejects records
// whose status is not active and is create-only: re-saving an
// existing membership id returns membership.ErrConflict.
Save(ctx context.Context, record membership.Membership) error
// Get returns the record identified by membershipID. It returns
// membership.ErrNotFound when no record exists.
Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error)
// GetByGame returns every membership attached to gameID. The order
// is adapter-defined; callers may reorder as needed.
GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error)
// GetByUser returns every membership held by userID. The order is
// adapter-defined; callers may reorder as needed.
GetByUser(ctx context.Context, userID string) ([]membership.Membership, error)
// UpdateStatus applies one status transition in a compare-and-swap
// fashion. The adapter must first call membership.Transition to
// reject invalid pairs without touching the store; on success it
// must verify that the current status equals input.ExpectedFrom,
// update the primary record, and set RemovedAt to input.At when
// transitioning out of active.
UpdateStatus(ctx context.Context, input UpdateMembershipStatusInput) error
// Delete removes the membership record identified by membershipID
// from the primary store and from the per-game and per-user
// secondary index sets in one operation. Delete is the pre-start
// path of removemember; the post-start path uses
// UpdateStatus(active → removed). Delete returns
// membership.ErrNotFound when no record exists for the id.
Delete(ctx context.Context, membershipID common.MembershipID) error
}
// UpdateMembershipStatusInput stores the arguments required to apply one
// status transition through a MembershipStore.
type UpdateMembershipStatusInput struct {
// MembershipID identifies the record to mutate.
MembershipID common.MembershipID
// ExpectedFrom stores the status the caller believes the record
// currently has. A mismatch results in membership.ErrConflict.
ExpectedFrom membership.Status
// To stores the destination status.
To membership.Status
// At stores the wall-clock used for RemovedAt.
At time.Time
}
// Validate reports whether input contains a structurally valid status
// transition request.
func (input UpdateMembershipStatusInput) Validate() error {
if err := input.MembershipID.Validate(); err != nil {
return fmt.Errorf("update membership status: membership id: %w", err)
}
if !input.ExpectedFrom.IsKnown() {
return fmt.Errorf(
"update membership status: expected from status %q is unsupported",
input.ExpectedFrom,
)
}
if !input.To.IsKnown() {
return fmt.Errorf(
"update membership status: to status %q is unsupported",
input.To,
)
}
if input.At.IsZero() {
return fmt.Errorf("update membership status: at must not be zero")
}
return nil
}
+238
View File
@@ -0,0 +1,238 @@
package ports
import (
"context"
"errors"
"time"
)
// Kind classifiers for RaceNameDirectory bindings.
const (
// KindRegistered identifies a permanent user-owned race name in the
// Race Name Directory.
KindRegistered = "registered"
// KindReservation identifies a per-game race name binding created at
// application approval or invite redeem.
KindReservation = "reservation"
// KindPendingRegistration identifies a reservation that survived a
// capable game finish and is now waiting for lobby.race_name.register
// within its 30-day window.
KindPendingRegistration = "pending_registration"
)
// Sentinel errors returned by RaceNameDirectory implementations. Callers
// map them to stable lobby error codes (name_taken, invalid_request,
// race_name_pending_window_expired, race_name_registration_quota_exceeded).
var (
// ErrNameTaken is returned when raceName is already held by a different
// user as registered, active reservation, or pending_registration on
// the same canonical key.
ErrNameTaken = errors.New("race name is already taken")
// ErrInvalidName is returned when raceName fails character-set
// validation, when the canonical key cannot be derived, or when a
// subsequent MarkPendingRegistration is invoked with a different
// eligibleUntil than the one already stored.
ErrInvalidName = errors.New("race name is invalid")
// ErrPendingMissing is returned by Register when no pending
// registration exists for the supplied (game_id, user_id, race_name)
// tuple.
ErrPendingMissing = errors.New("pending race-name registration missing")
// ErrPendingExpired is returned by Register when the pending
// registration exists but its eligible_until has passed.
ErrPendingExpired = errors.New("pending race-name registration expired")
// ErrQuotaExceeded is reserved for callers that enforce the
// max_registered_race_names limit before invoking Register; directory
// implementations do not raise it directly.
ErrQuotaExceeded = errors.New("race name registration quota exceeded")
)
// Availability reports whether raceName is taken for the acting user and,
// when taken, who holds it and under which kind of binding.
type Availability struct {
// Taken is true when any binding on the canonical key belongs to a
// user different from the actor.
Taken bool
// HolderUserID carries the owning user id of the strongest existing
// binding on the canonical key, or the empty string when no binding
// exists.
HolderUserID string
// Kind identifies the strongest existing binding on the canonical key
// as one of KindRegistered, KindPendingRegistration, KindReservation,
// or the empty string when no binding exists.
Kind string
}
// RegisteredName describes one registered race name owned by a user.
type RegisteredName struct {
// CanonicalKey is the policy-produced uniqueness key.
CanonicalKey string
// RaceName is the original-casing user-submitted display form.
RaceName string
// SourceGameID is the game whose capable finish produced the pending
// entry that was converted into this registration.
SourceGameID string
// RegisteredAtMs is the Unix-milliseconds timestamp of the successful
// Register call.
RegisteredAtMs int64
}
// Reservation describes one active per-game race name reservation owned by
// a user. Reservations with status pending_registration are returned by
// ListPendingRegistrations instead.
type Reservation struct {
// CanonicalKey is the policy-produced uniqueness key.
CanonicalKey string
// RaceName is the original-casing user-submitted display form.
RaceName string
// GameID is the game hosting the reservation.
GameID string
// ReservedAtMs is the Unix-milliseconds timestamp of the first
// successful Reserve call for the tuple.
ReservedAtMs int64
}
// PendingRegistration describes one reservation that was promoted to
// pending_registration at a capable game finish.
type PendingRegistration struct {
// CanonicalKey is the policy-produced uniqueness key.
CanonicalKey string
// RaceName is the original-casing user-submitted display form.
RaceName string
// GameID is the source game whose capable finish produced the pending
// entry.
GameID string
// ReservedAtMs is the Unix-milliseconds timestamp of the original
// Reserve call; it is preserved across the pending-registration
// promotion.
ReservedAtMs int64
// EligibleUntilMs is the Unix-milliseconds deadline for an eligible
// Register call. After this moment ExpirePendingRegistrations releases
// the entry.
EligibleUntilMs int64
}
// ExpiredPending describes one pending registration released by a single
// ExpirePendingRegistrations pass. The slice lets callers emit telemetry
// and cascade release side effects without a second round-trip.
type ExpiredPending struct {
// CanonicalKey is the policy-produced uniqueness key.
CanonicalKey string
// RaceName is the original-casing user-submitted display form.
RaceName string
// GameID is the source game that produced the pending entry.
GameID string
// UserID is the holder of the released pending entry.
UserID string
// EligibleUntilMs is the Unix-milliseconds deadline that lapsed.
EligibleUntilMs int64
}
// RaceNameDirectory is the platform source of truth for in-game race_name
// values across three levels of state: registered (permanent, one per
// user), reservation (per-game holding), and pending_registration (30-day
// post-capable-finish window). It owns canonical-key derivation through
// the lobby/internal/domain/racename policy and arbitrates platform-wide
// uniqueness so that a name considered taken for one user remains
// exclusively bound to that user across registered, active reservations,
// and pending registrations sharing the same canonical key.
//
// One user may hold the same canonical key concurrently across multiple
// active games; cross-user conflicts surface as ErrNameTaken.
type RaceNameDirectory interface {
// Canonicalize normalizes raceName through the lobby RND policy
// (character-set validation, Unicode case fold, frozen confusable-pair
// map). Invalid raceName values surface as ErrInvalidName.
Canonicalize(raceName string) (canonical string, err error)
// Check reports whether raceName is taken for actorUserID on its
// canonical key. Taken is false when no binding exists or when the
// existing binding is owned by actorUserID; HolderUserID and Kind
// carry the existing binding's metadata regardless of the Taken value.
// A concurrent Reserve may race against a Check result, so service
// code that needs atomicity must rely on Reserve returning
// ErrNameTaken rather than pre-checking.
Check(ctx context.Context, raceName, actorUserID string) (Availability, error)
// Reserve claims raceName for (gameID, userID). A second call by the
// same holder for the same tuple is idempotent and returns nil.
// ErrNameTaken is returned when any registered, reservation, or
// pending_registration binding on the canonical key is owned by a
// different user (in any game).
Reserve(ctx context.Context, gameID, userID, raceName string) error
// ReleaseReservation removes the reservation held by userID for
// raceName in gameID. It is a no-op when no reservation exists for
// the tuple, when the reservation is held by a different user, and
// when raceName fails validation; none of these cases surface an
// error. Defensive release paths rely on these semantics.
ReleaseReservation(ctx context.Context, gameID, userID, raceName string) error
// MarkPendingRegistration promotes the reservation stored for
// (gameID, userID) on raceName's canonical key to
// pending_registration status with the supplied eligibleUntil. A
// second call with the same eligibleUntil is a no-op; a call with a
// different eligibleUntil returns ErrInvalidName. Callers must
// ensure the underlying reservation exists.
MarkPendingRegistration(
ctx context.Context,
gameID, userID, raceName string,
eligibleUntil time.Time,
) error
// ExpirePendingRegistrations releases every pending registration whose
// eligibleUntil is at or before now and returns the released entries
// in a single slice so callers can emit metrics or notifications.
// Running twice over the same state returns an empty slice the second
// time.
ExpirePendingRegistrations(ctx context.Context, now time.Time) ([]ExpiredPending, error)
// Register converts the pending registration identified by (gameID,
// userID) on raceName's canonical key into a registered race name.
// Missing pending returns ErrPendingMissing; expired pending returns
// ErrPendingExpired. A second call after a successful registration
// for the same tuple is idempotent and returns nil. Quota enforcement
// is the caller's responsibility.
Register(ctx context.Context, gameID, userID, raceName string) error
// ListRegistered returns every registered race name owned by userID.
// The ordering is implementation-defined.
ListRegistered(ctx context.Context, userID string) ([]RegisteredName, error)
// ListPendingRegistrations returns every pending registration owned by
// userID. The ordering is implementation-defined.
ListPendingRegistrations(ctx context.Context, userID string) ([]PendingRegistration, error)
// ListReservations returns every active reservation owned by userID
// whose status has not yet been promoted to pending_registration. The
// ordering is implementation-defined.
ListReservations(ctx context.Context, userID string) ([]Reservation, error)
// ReleaseAllByUser atomically clears every registered, reservation,
// and pending_registration binding owned by userID. It is invoked by
// the user lifecycle consumer on permanent_blocked and deleted events.
// Running twice over the same state is safe and produces no
// additional side effects.
ReleaseAllByUser(ctx context.Context, userID string) error
}
@@ -0,0 +1,773 @@
// Package racenamedirtest exposes the shared behavioural test suite that
// every ports.RaceNameDirectory implementation must pass. The Redis
// adapter and the in-process stub run the same cases so both back ends
// stay behaviourally equivalent.
package racenamedirtest
import (
"context"
"errors"
"sort"
"testing"
"time"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Factory constructs a fresh ports.RaceNameDirectory for one test case.
// Implementations honour the supplied clock so tests can frame
// reserved_at_ms and registered_at_ms deterministically.
type Factory func(now func() time.Time) ports.RaceNameDirectory
// Run executes the shared behavioural suite against factory. Call it
// from each adapter's _test.go file alongside any adapter-specific
// assertions.
func Run(t *testing.T, factory Factory) {
t.Helper()
t.Run("Canonicalize rejects invalid input", func(t *testing.T) {
t.Parallel()
testCanonicalizeRejectsInvalid(t, factory)
})
t.Run("Canonicalize is deterministic", func(t *testing.T) {
t.Parallel()
testCanonicalizeDeterministic(t, factory)
})
t.Run("Check empty directory", func(t *testing.T) {
t.Parallel()
testCheckEmpty(t, factory)
})
t.Run("Check treats actor as own holder", func(t *testing.T) {
t.Parallel()
testCheckActorNotTaken(t, factory)
})
t.Run("Check exposes holder and kind to other users", func(t *testing.T) {
t.Parallel()
testCheckHolderAndKind(t, factory)
})
t.Run("Reserve records new holding", func(t *testing.T) {
t.Parallel()
testReserveRecords(t, factory)
})
t.Run("Reserve idempotent for same holder same game", func(t *testing.T) {
t.Parallel()
testReserveIdempotent(t, factory)
})
t.Run("Reserve allows same user across games", func(t *testing.T) {
t.Parallel()
testReserveCrossGame(t, factory)
})
t.Run("Reserve rejects cross-user same game", func(t *testing.T) {
t.Parallel()
testReserveCrossUserSameGame(t, factory)
})
t.Run("Reserve rejects cross-user different games", func(t *testing.T) {
t.Parallel()
testReserveCrossUserDifferentGames(t, factory)
})
t.Run("Reserve rejects invalid name", func(t *testing.T) {
t.Parallel()
testReserveInvalidName(t, factory)
})
t.Run("ReleaseReservation missing", func(t *testing.T) {
t.Parallel()
testReleaseReservationMissing(t, factory)
})
t.Run("ReleaseReservation wrong holder", func(t *testing.T) {
t.Parallel()
testReleaseReservationWrongHolder(t, factory)
})
t.Run("ReleaseReservation clears sole binding", func(t *testing.T) {
t.Parallel()
testReleaseReservationClears(t, factory)
})
t.Run("ReleaseReservation swallows invalid name", func(t *testing.T) {
t.Parallel()
testReleaseReservationInvalidName(t, factory)
})
t.Run("ReleaseReservation keeps cross-game holding visible", func(t *testing.T) {
t.Parallel()
testReleaseReservationKeepsCrossGame(t, factory)
})
t.Run("MarkPendingRegistration promotes reservation", func(t *testing.T) {
t.Parallel()
testMarkPendingPromotes(t, factory)
})
t.Run("MarkPendingRegistration idempotent same eligible", func(t *testing.T) {
t.Parallel()
testMarkPendingIdempotent(t, factory)
})
t.Run("MarkPendingRegistration rejects different eligible", func(t *testing.T) {
t.Parallel()
testMarkPendingDifferentEligible(t, factory)
})
t.Run("MarkPendingRegistration rejects missing reservation", func(t *testing.T) {
t.Parallel()
testMarkPendingMissing(t, factory)
})
t.Run("ExpirePendingRegistrations empty", func(t *testing.T) {
t.Parallel()
testExpirePendingEmpty(t, factory)
})
t.Run("ExpirePendingRegistrations releases expired entries", func(t *testing.T) {
t.Parallel()
testExpirePendingReleasesExpired(t, factory)
})
t.Run("ExpirePendingRegistrations skips future entries", func(t *testing.T) {
t.Parallel()
testExpirePendingSkipsFuture(t, factory)
})
t.Run("ExpirePendingRegistrations idempotent replay", func(t *testing.T) {
t.Parallel()
testExpirePendingIdempotent(t, factory)
})
t.Run("Register converts pending to registered", func(t *testing.T) {
t.Parallel()
testRegisterConverts(t, factory)
})
t.Run("Register idempotent on repeat", func(t *testing.T) {
t.Parallel()
testRegisterIdempotent(t, factory)
})
t.Run("Register rejects missing pending", func(t *testing.T) {
t.Parallel()
testRegisterMissingPending(t, factory)
})
t.Run("Register rejects expired pending", func(t *testing.T) {
t.Parallel()
testRegisterExpiredPending(t, factory)
})
t.Run("List methods partition correctly", func(t *testing.T) {
t.Parallel()
testListsPartition(t, factory)
})
t.Run("ReleaseAllByUser clears every kind", func(t *testing.T) {
t.Parallel()
testReleaseAllByUserClears(t, factory)
})
t.Run("ReleaseAllByUser leaves other users intact", func(t *testing.T) {
t.Parallel()
testReleaseAllByUserIsolated(t, factory)
})
t.Run("ReleaseAllByUser idempotent", func(t *testing.T) {
t.Parallel()
testReleaseAllByUserIdempotent(t, factory)
})
t.Run("Honors canceled context", func(t *testing.T) {
t.Parallel()
testContextCancellation(t, factory)
})
}
const (
raceNameA = "PilotNova"
raceNameB = "Vanguard"
gameA = "game-A"
gameB = "game-B"
userA = "user-A"
userB = "user-B"
)
// fixedClock returns a deterministic clock starting at instant and
// advanceable via the returned callbacks.
func fixedClock(instant time.Time) (now func() time.Time, advance func(delta time.Duration)) {
current := instant
now = func() time.Time { return current }
advance = func(delta time.Duration) { current = current.Add(delta) }
return now, advance
}
// baseTime pins a stable reference instant for deterministic timestamps
// across subtests.
func baseTime() time.Time {
return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
}
func testCanonicalizeRejectsInvalid(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
for _, input := range []string{"", " ", "Pilot Nova", "-Pilot", "Pilot-"} {
_, err := directory.Canonicalize(input)
require.Error(t, err, "input %q must be rejected", input)
require.ErrorIs(t, err, ports.ErrInvalidName, "input %q should expose ErrInvalidName", input)
}
}
func testCanonicalizeDeterministic(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
first, err := directory.Canonicalize("PilotNova")
require.NoError(t, err)
second, err := directory.Canonicalize("pilotnova")
require.NoError(t, err)
require.Equal(t, first, second, "case variants must produce identical canonical keys")
confusable, err := directory.Canonicalize("P1l0tN0va")
require.NoError(t, err)
require.Equal(t, first, confusable, "anti-fraud confusables must collapse to the same canonical key")
}
func testCheckEmpty(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
availability, err := directory.Check(context.Background(), raceNameA, userA)
require.NoError(t, err)
assert.False(t, availability.Taken)
assert.Empty(t, availability.HolderUserID)
assert.Empty(t, availability.Kind)
}
func testCheckActorNotTaken(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
availability, err := directory.Check(ctx, raceNameA, userA)
require.NoError(t, err)
assert.False(t, availability.Taken)
assert.Equal(t, userA, availability.HolderUserID)
assert.Equal(t, ports.KindReservation, availability.Kind)
}
func testCheckHolderAndKind(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
availability, err := directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.True(t, availability.Taken)
assert.Equal(t, userA, availability.HolderUserID)
assert.Equal(t, ports.KindReservation, availability.Kind)
eligibleUntil := now().Add(30 * 24 * time.Hour)
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
availability, err = directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.Equal(t, ports.KindPendingRegistration, availability.Kind)
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
availability, err = directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.Equal(t, ports.KindRegistered, availability.Kind)
}
func testReserveRecords(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
require.Len(t, reservations, 1)
assert.Equal(t, gameA, reservations[0].GameID)
assert.Equal(t, raceNameA, reservations[0].RaceName)
assert.Equal(t, now().UTC().UnixMilli(), reservations[0].ReservedAtMs)
}
func testReserveIdempotent(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
require.Len(t, reservations, 1)
}
func testReserveCrossGame(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameA))
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
require.Len(t, reservations, 2)
sort.Slice(reservations, func(i, j int) bool { return reservations[i].GameID < reservations[j].GameID })
assert.Equal(t, gameA, reservations[0].GameID)
assert.Equal(t, gameB, reservations[1].GameID)
}
func testReserveCrossUserSameGame(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
err := directory.Reserve(ctx, gameA, userB, raceNameA)
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrNameTaken)
}
func testReserveCrossUserDifferentGames(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
err := directory.Reserve(ctx, gameB, userB, raceNameA)
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrNameTaken)
}
func testReserveInvalidName(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
err := directory.Reserve(ctx, gameA, userA, " ")
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrInvalidName)
}
func testReleaseReservationMissing(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, raceNameA))
}
func testReleaseReservationWrongHolder(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userB, raceNameA))
availability, err := directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.True(t, availability.Taken)
assert.Equal(t, userA, availability.HolderUserID)
}
func testReleaseReservationClears(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, raceNameA))
availability, err := directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.False(t, availability.Taken)
assert.Empty(t, availability.HolderUserID)
assert.Empty(t, availability.Kind)
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, reservations)
}
func testReleaseReservationInvalidName(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, ""))
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, "Pilot Nova"))
}
func testReleaseReservationKeepsCrossGame(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameA))
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userA, raceNameA))
availability, err := directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.True(t, availability.Taken, "other users still blocked by the remaining game B reservation")
assert.Equal(t, userA, availability.HolderUserID)
assert.Equal(t, ports.KindReservation, availability.Kind)
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
require.Len(t, reservations, 1)
assert.Equal(t, gameB, reservations[0].GameID)
}
func testMarkPendingPromotes(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
eligibleUntil := now().Add(30 * 24 * time.Hour)
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
pending, err := directory.ListPendingRegistrations(ctx, userA)
require.NoError(t, err)
require.Len(t, pending, 1)
assert.Equal(t, gameA, pending[0].GameID)
assert.Equal(t, eligibleUntil.UTC().UnixMilli(), pending[0].EligibleUntilMs)
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, reservations, "pending entries must not appear in ListReservations")
}
func testMarkPendingIdempotent(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
eligibleUntil := now().Add(24 * time.Hour)
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
pending, err := directory.ListPendingRegistrations(ctx, userA)
require.NoError(t, err)
require.Len(t, pending, 1)
}
func testMarkPendingDifferentEligible(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
eligibleUntil := now().Add(24 * time.Hour)
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
err := directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil.Add(time.Hour))
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrInvalidName)
}
func testMarkPendingMissing(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
err := directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour))
require.Error(t, err)
}
func testExpirePendingEmpty(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
expired, err := directory.ExpirePendingRegistrations(context.Background(), time.Now())
require.NoError(t, err)
assert.Empty(t, expired)
}
func testExpirePendingReleasesExpired(t *testing.T, factory Factory) {
t.Helper()
now, advance := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
eligibleUntil := now().Add(time.Hour)
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
advance(2 * time.Hour)
expired, err := directory.ExpirePendingRegistrations(ctx, now())
require.NoError(t, err)
require.Len(t, expired, 1)
assert.Equal(t, userA, expired[0].UserID)
assert.Equal(t, gameA, expired[0].GameID)
assert.Equal(t, raceNameA, expired[0].RaceName)
assert.Equal(t, eligibleUntil.UTC().UnixMilli(), expired[0].EligibleUntilMs)
pending, err := directory.ListPendingRegistrations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, pending)
availability, err := directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.False(t, availability.Taken)
}
func testExpirePendingSkipsFuture(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
eligibleUntil := now().Add(time.Hour)
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, eligibleUntil))
expired, err := directory.ExpirePendingRegistrations(ctx, now())
require.NoError(t, err)
assert.Empty(t, expired)
}
func testExpirePendingIdempotent(t *testing.T, factory Factory) {
t.Helper()
now, advance := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
advance(2 * time.Hour)
expiredFirst, err := directory.ExpirePendingRegistrations(ctx, now())
require.NoError(t, err)
require.Len(t, expiredFirst, 1)
expiredSecond, err := directory.ExpirePendingRegistrations(ctx, now())
require.NoError(t, err)
assert.Empty(t, expiredSecond)
}
func testRegisterConverts(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
registered, err := directory.ListRegistered(ctx, userA)
require.NoError(t, err)
require.Len(t, registered, 1)
assert.Equal(t, gameA, registered[0].SourceGameID)
assert.Equal(t, raceNameA, registered[0].RaceName)
assert.Equal(t, now().UTC().UnixMilli(), registered[0].RegisteredAtMs)
pending, err := directory.ListPendingRegistrations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, pending)
}
func testRegisterIdempotent(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
registered, err := directory.ListRegistered(ctx, userA)
require.NoError(t, err)
require.Len(t, registered, 1)
}
func testRegisterMissingPending(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
err := directory.Register(ctx, gameA, userA, raceNameA)
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrPendingMissing)
// Reservation without pending is also missing pending.
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
err = directory.Register(ctx, gameA, userA, raceNameA)
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrPendingMissing)
}
func testRegisterExpiredPending(t *testing.T, factory Factory) {
t.Helper()
now, advance := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
advance(2 * time.Hour)
err := directory.Register(ctx, gameA, userA, raceNameA)
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrPendingExpired)
}
func testListsPartition(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
// userA: reserved in gameA (raceNameA), pending in gameB (raceNameB), registered raceNameA after convert.
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameB))
registered, err := directory.ListRegistered(ctx, userA)
require.NoError(t, err)
require.Len(t, registered, 1)
assert.Equal(t, raceNameA, registered[0].RaceName)
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
require.Len(t, reservations, 1)
assert.Equal(t, raceNameB, reservations[0].RaceName)
assert.Equal(t, gameB, reservations[0].GameID)
pending, err := directory.ListPendingRegistrations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, pending, "pending entry was converted to registered")
}
func testReleaseAllByUserClears(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, now().Add(time.Hour)))
require.NoError(t, directory.Register(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Reserve(ctx, gameB, userA, raceNameB))
require.NoError(t, directory.MarkPendingRegistration(ctx, gameB, userA, raceNameB, now().Add(2*time.Hour)))
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
registered, err := directory.ListRegistered(ctx, userA)
require.NoError(t, err)
assert.Empty(t, registered)
reservations, err := directory.ListReservations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, reservations)
pending, err := directory.ListPendingRegistrations(ctx, userA)
require.NoError(t, err)
assert.Empty(t, pending)
availabilityA, err := directory.Check(ctx, raceNameA, userB)
require.NoError(t, err)
assert.False(t, availabilityA.Taken)
availabilityB, err := directory.Check(ctx, raceNameB, userB)
require.NoError(t, err)
assert.False(t, availabilityB.Taken)
}
func testReleaseAllByUserIsolated(t *testing.T, factory Factory) {
t.Helper()
now, _ := fixedClock(baseTime())
directory := factory(now)
ctx := context.Background()
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.Reserve(ctx, gameB, userB, raceNameB))
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
availability, err := directory.Check(ctx, raceNameB, userA)
require.NoError(t, err)
assert.True(t, availability.Taken)
assert.Equal(t, userB, availability.HolderUserID)
reservationsB, err := directory.ListReservations(ctx, userB)
require.NoError(t, err)
require.Len(t, reservationsB, 1)
}
func testReleaseAllByUserIdempotent(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx := context.Background()
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
require.NoError(t, directory.Reserve(ctx, gameA, userA, raceNameA))
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
require.NoError(t, directory.ReleaseAllByUser(ctx, userA))
}
func testContextCancellation(t *testing.T, factory Factory) {
t.Helper()
directory := factory(nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := directory.Reserve(ctx, gameA, userA, raceNameA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled), "Reserve must surface context.Canceled, got %v", err)
_, err = directory.Check(ctx, raceNameA, userA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
err = directory.ReleaseReservation(ctx, gameA, userA, raceNameA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
err = directory.MarkPendingRegistration(ctx, gameA, userA, raceNameA, time.Now())
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
_, err = directory.ExpirePendingRegistrations(ctx, time.Now())
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
err = directory.Register(ctx, gameA, userA, raceNameA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
_, err = directory.ListRegistered(ctx, userA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
_, err = directory.ListReservations(ctx, userA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
_, err = directory.ListPendingRegistrations(ctx, userA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
err = directory.ReleaseAllByUser(ctx, userA)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
+25
View File
@@ -0,0 +1,25 @@
package ports
import "context"
// RuntimeManager publishes runtime jobs to Runtime Manager via Redis
// Streams. introduces start and stop jobs; future stages may
// extend the surface.
//
// The interface is intentionally narrow: callers pass only the game id.
// Runtime Manager fetches additional context (target engine version,
// turn schedule, etc.) through Lobby's internal HTTP API when it picks
// up the job.
type RuntimeManager interface {
// PublishStartJob enqueues one start job for gameID. Implementations
// must produce one event in the configured runtime start jobs stream
// per call. A zero-error return means the event is durably accepted
// into the stream (Redis XADD succeeded); it does not imply that the
// container has started.
PublishStartJob(ctx context.Context, gameID string) error
// PublishStopJob enqueues one stop job for gameID. Implementations
// must produce one event in the configured runtime stop jobs stream
// per call. The same durability semantics as PublishStartJob apply.
PublishStopJob(ctx context.Context, gameID string) error
}
+20
View File
@@ -0,0 +1,20 @@
package ports
import (
"context"
"time"
)
// StreamLagProbe reports how far the persisted consumer offset trails the
// stream tail. It feeds the three `lobby.*.oldest_unprocessed_age_ms`
// observable gauges defined in `lobby/README.md` §Observability.
type StreamLagProbe interface {
// OldestUnprocessedAge returns the age of the first stream entry that
// follows savedOffset on stream. The boolean return reports whether
// the implementation could compute an age at all: it is false when
// the stream is empty, when no entries follow savedOffset, or when
// the stream key is absent. An empty savedOffset is interpreted as
// "no progress yet"; the implementation falls back to the stream
// head in that case.
OldestUnprocessedAge(ctx context.Context, stream, savedOffset string) (time.Duration, bool, error)
}
+20
View File
@@ -0,0 +1,20 @@
package ports
import "context"
// StreamOffsetStore persists the last successfully processed Redis
// Stream entry id per stream. Workers call Load on startup to resume
// from the persisted offset and Save after every successful message
// handling so the next iteration advances past the just-processed
// entry. Lobby uses one logical store with the per-stream key encoded
// inside the implementation.
type StreamOffsetStore interface {
// Load returns the last processed entry id for stream when one is
// stored. The boolean return reports whether a value was present;
// implementations must not return an error for a missing key.
Load(ctx context.Context, stream string) (entryID string, found bool, err error)
// Save stores entryID as the new last processed offset for stream.
// Implementations overwrite any previous value unconditionally.
Save(ctx context.Context, stream, entryID string) error
}
+121
View File
@@ -0,0 +1,121 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
)
// UserLifecycleEventType identifies one supported user-lifecycle event
// kind propagated from User Service to Game Lobby through the
// `user:lifecycle_events` Redis Stream.
type UserLifecycleEventType string
const (
// UserLifecycleEventTypePermanentBlocked identifies the post-commit
// event emitted when `SanctionCodePermanentBlock` becomes active on
// an account.
UserLifecycleEventTypePermanentBlocked UserLifecycleEventType = "user.lifecycle.permanent_blocked"
// UserLifecycleEventTypeDeleted identifies the post-commit event
// emitted when `DeleteUser` soft-deletes an account.
UserLifecycleEventTypeDeleted UserLifecycleEventType = "user.lifecycle.deleted"
)
// String returns the wire value for eventType.
func (eventType UserLifecycleEventType) String() string {
return string(eventType)
}
// IsKnown reports whether eventType belongs to the frozen vocabulary.
func (eventType UserLifecycleEventType) IsKnown() bool {
switch eventType {
case UserLifecycleEventTypePermanentBlocked, UserLifecycleEventTypeDeleted:
return true
default:
return false
}
}
// UserLifecycleEvent stores the decoded shape of one entry from the
// `user:lifecycle_events` Redis Stream.
type UserLifecycleEvent struct {
// EntryID stores the Redis Streams entry id (`<ms>-<seq>` form). The
// consumer uses it as part of notification idempotency keys so a
// retried cascade publishes deterministically the same intent.
EntryID string
// EventType stores the frozen lifecycle event discriminator.
EventType UserLifecycleEventType
// UserID identifies the regular user whose lifecycle state changed.
UserID string
// OccurredAt stores the committed mutation timestamp emitted by User
// Service.
OccurredAt time.Time
// Source stores the machine-readable mutation source. always
// emits `admin_internal_api`.
Source string
// ActorType stores the audit actor type (e.g. `admin_user`,
// `system`).
ActorType string
// ActorID stores the optional audit actor identifier. It is empty
// when the upstream event carries no actor id.
ActorID string
// ReasonCode stores the committed `reason_code` emitted by User
// Service.
ReasonCode string
// TraceID stores the optional OpenTelemetry trace identifier
// propagated from the upstream request context.
TraceID string
}
// Validate reports whether event satisfies the structural invariants
// required for cascade processing.
func (event UserLifecycleEvent) Validate() error {
if strings.TrimSpace(event.EntryID) == "" {
return fmt.Errorf("user lifecycle event entry id must not be empty")
}
if !event.EventType.IsKnown() {
return fmt.Errorf("user lifecycle event type %q is unsupported", event.EventType)
}
if strings.TrimSpace(event.UserID) == "" {
return fmt.Errorf("user lifecycle event user id must not be empty")
}
if event.OccurredAt.IsZero() {
return fmt.Errorf("user lifecycle event occurred at must not be zero")
}
return nil
}
// UserLifecycleHandler processes one decoded lifecycle event. Returning
// nil advances the stream offset; returning a non-nil error holds the
// offset on the current entry so the consumer retries on the next loop
// iteration.
type UserLifecycleHandler func(ctx context.Context, event UserLifecycleEvent) error
// UserLifecycleConsumer drives the read loop over the
// `user:lifecycle_events` Redis Stream. The Redis adapter satisfies the
// interface in production; in-process stubs satisfy it in tests so the
// cascade worker can be exercised without spinning up Redis.
type UserLifecycleConsumer interface {
// OnEvent installs handler as the sole dispatcher for decoded events.
// A second call replaces the previous handler.
OnEvent(handler UserLifecycleHandler)
// Run drives the consumer loop until ctx is cancelled. The
// implementation is expected to be re-entrant only within a single
// goroutine.
Run(ctx context.Context) error
// Shutdown releases consumer-owned resources after Run has returned.
// The consumer must support being closed without an active Run loop.
Shutdown(ctx context.Context) error
}
+71
View File
@@ -0,0 +1,71 @@
package ports
import (
"context"
"errors"
)
// ErrUserServiceUnavailable is returned by UserService implementations when
// the upstream User Service is unreachable, returns an unexpected status,
// or violates the contract in a way that callers must surface as
// service_unavailable. Service-layer code wraps it with the
// shared.ErrServiceUnavailable sentinel before bubbling up to transport.
var ErrUserServiceUnavailable = errors.New("user service unavailable")
// Eligibility stores the lobby-relevant subset of the User Service
// eligibility snapshot. only consumes Exists and CanJoinGame; the
// remaining fields are pre-plumbed for (race name registration)
// so that adding consumers later does not churn the port surface.
type Eligibility struct {
// Exists reports whether userID currently identifies a stored user.
// false maps to subject_not_found at the service layer.
Exists bool
// CanLogin reports whether the user may currently log in.
CanLogin bool
// CanCreatePrivateGame reports whether the user may currently create
// a private game.
CanCreatePrivateGame bool
// CanManagePrivateGame reports whether the user may currently manage
// a private game they own.
CanManagePrivateGame bool
// CanJoinGame reports whether the user may currently join a game
// (submit application or redeem invite). false surfaces as
// eligibility_denied for the
CanJoinGame bool
// CanUpdateProfile reports whether the user may currently update
// self-service profile and settings fields.
CanUpdateProfile bool
// PermanentBlocked reports whether the user carries an active
// permanent_block sanction. rejects the
// `lobby.race_name.register` call with `shared.ErrForbidden` ⇒
// HTTP `403 forbidden`; the dedicated `race_name_permanent_blocked`
// envelope code is reserved for the cascade flows. Stage
// 11 currently surfaces it indirectly through CanJoinGame=false
// (collapses every can_* marker to false on
// permanent_block).
PermanentBlocked bool
// MaxRegisteredRaceNames stores the snapshot value for the
// max_registered_race_names limit. A value of 0 denotes unlimited
// per the lifetime tariff. The race-name registration service
// enforces the quota; this port only carries the value through.
MaxRegisteredRaceNames int
}
// UserService is the synchronous lobby-facing User Service eligibility
// reader. The application flow consumes it via a single
// GetEligibility call before accepting an applicant.
type UserService interface {
// GetEligibility returns the lobby-relevant eligibility snapshot for
// userID. It returns Eligibility{Exists:false} for unknown users
// without an error so callers can distinguish missing users from
// transport failures. Transport, decode, and unexpected status code
// failures wrap ErrUserServiceUnavailable.
GetEligibility(ctx context.Context, userID string) (Eligibility, error)
}