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,42 @@
package application
import (
"errors"
"fmt"
)
// ErrNotFound reports that an application record was requested but does
// not exist in the store.
var ErrNotFound = errors.New("application not found")
// ErrConflict reports that an application mutation could not be applied.
// It is returned for single-active-application violations and for
// compare-and-swap mismatches on status transitions.
var ErrConflict = errors.New("application conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects a
// `(from, to)` pair.
var ErrInvalidTransition = errors.New("invalid application status transition")
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
// ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
}
// Error reports a human-readable summary of the rejected pair.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid application status transition from %q to %q",
err.From, err.To,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+147
View File
@@ -0,0 +1,147 @@
// Package application defines the application record domain model, status
// machine, and sentinel errors owned by Game Lobby Service for public-game
// enrollment requests.
package application
import (
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
)
// Application stores one durable application record owned by Game Lobby
// Service. Applications are used exclusively by public games; private
// games use the invite flow instead.
type Application struct {
// ApplicationID identifies the record.
ApplicationID common.ApplicationID
// GameID identifies the game this application belongs to.
GameID common.GameID
// ApplicantUserID stores the platform user id of the applicant.
ApplicantUserID string
// RaceName stores the desired in-game name submitted with the
// application.
RaceName string
// Status stores the current lifecycle state.
Status Status
// CreatedAt stores when the record was created.
CreatedAt time.Time
// DecidedAt stores when the record transitioned out of submitted. It
// is nil while the application is still submitted.
DecidedAt *time.Time
}
// NewApplicationInput groups all fields required to create a submitted
// application record.
type NewApplicationInput struct {
// ApplicationID identifies the new record.
ApplicationID common.ApplicationID
// GameID identifies the game the applicant is applying to.
GameID common.GameID
// ApplicantUserID stores the platform user id of the applicant.
ApplicantUserID string
// RaceName stores the desired in-game name submitted by the
// applicant.
RaceName string
// Now stores the creation wall-clock used for CreatedAt.
Now time.Time
}
// New validates input and returns a submitted Application record.
// Validation errors are returned verbatim so callers can surface them as
// invalid_request.
func New(input NewApplicationInput) (Application, error) {
if err := input.Validate(); err != nil {
return Application{}, err
}
record := Application{
ApplicationID: input.ApplicationID,
GameID: input.GameID,
ApplicantUserID: strings.TrimSpace(input.ApplicantUserID),
RaceName: strings.TrimSpace(input.RaceName),
Status: StatusSubmitted,
CreatedAt: input.Now.UTC(),
}
if err := record.Validate(); err != nil {
return Application{}, err
}
return record, nil
}
// Validate reports whether input satisfies the frozen application-record
// invariants required to construct a submitted record.
func (input NewApplicationInput) Validate() error {
if err := input.ApplicationID.Validate(); err != nil {
return fmt.Errorf("application id: %w", err)
}
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(input.ApplicantUserID) == "" {
return fmt.Errorf("applicant user id must not be empty")
}
if strings.TrimSpace(input.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if input.Now.IsZero() {
return fmt.Errorf("now must not be zero")
}
return nil
}
// Validate reports whether record satisfies the full invariants.
// Every marshal and unmarshal round-trip calls Validate to guarantee that
// the Redis store never exposes malformed records.
func (record Application) Validate() error {
if err := record.ApplicationID.Validate(); err != nil {
return fmt.Errorf("application id: %w", err)
}
if err := record.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(record.ApplicantUserID) == "" {
return fmt.Errorf("applicant user id must not be empty")
}
if strings.TrimSpace(record.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if record.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
if record.Status == StatusSubmitted {
if record.DecidedAt != nil {
return fmt.Errorf("decided at must be nil for submitted applications")
}
} else {
if record.DecidedAt == nil {
return fmt.Errorf("decided at must not be nil for %q applications", record.Status)
}
if record.DecidedAt.IsZero() {
return fmt.Errorf("decided at must not be zero when present")
}
if record.DecidedAt.Before(record.CreatedAt) {
return fmt.Errorf("decided at must not be before created at")
}
}
return nil
}
@@ -0,0 +1,79 @@
package application
// Status identifies one lifecycle state of a Game Lobby application record.
type Status string
const (
// StatusSubmitted reports that the application was created by the
// applicant and awaits admin decision.
StatusSubmitted Status = "submitted"
// StatusApproved reports that the admin accepted the application and
// a membership record was created for the applicant.
StatusApproved Status = "approved"
// StatusRejected reports that the admin declined the application.
// The applicant may submit a new application while enrollment is open.
StatusRejected Status = "rejected"
)
// IsKnown reports whether status belongs to the frozen application status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusSubmitted, StatusApproved, StatusRejected:
return true
default:
return false
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions.
func (status Status) IsTerminal() bool {
switch status {
case StatusApproved, StatusRejected:
return true
default:
return false
}
}
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions stores the set of permitted `(from, to)` status pairs.
// It mirrors the state machine frozen in lobby/README.md Application
// Lifecycle section.
var allowedTransitions = map[transitionKey]struct{}{
{StatusSubmitted, StatusApproved}: {},
{StatusSubmitted, StatusRejected}: {},
}
// AllowedTransitions returns a copy of the `(from, to)` allowed-transitions
// table used by Transition. The returned map is safe to mutate.
func AllowedTransitions() map[Status][]Status {
result := make(map[Status][]Status)
for key := range allowedTransitions {
result[key.from] = append(result[key.from], key.to)
}
return result
}
// Transition reports whether from may transition to next. The function
// returns nil when the pair is permitted, and an *InvalidTransitionError
// wrapping ErrInvalidTransition otherwise. It does not touch any store and
// is safe to call from any layer.
func Transition(from Status, next Status) error {
if !from.IsKnown() || !next.IsKnown() {
return &InvalidTransitionError{From: from, To: next}
}
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
return &InvalidTransitionError{From: from, To: next}
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package common
import (
"fmt"
"strings"
)
// gameIDPrefix is the mandatory opaque-identifier prefix for one Game Lobby
// game record.
const gameIDPrefix = "game-"
// applicationIDPrefix is the mandatory opaque-identifier prefix for one
// Game Lobby application record.
const applicationIDPrefix = "application-"
// inviteIDPrefix is the mandatory opaque-identifier prefix for one Game
// Lobby invite record.
const inviteIDPrefix = "invite-"
// membershipIDPrefix is the mandatory opaque-identifier prefix for one
// Game Lobby membership record.
const membershipIDPrefix = "membership-"
// GameID identifies one game record owned by Game Lobby Service. The value
// is opaque and stable; only its `game-*` prefix is observable to callers.
type GameID string
// String returns GameID as its stored identifier string.
func (id GameID) String() string {
return string(id)
}
// IsZero reports whether GameID does not contain a usable value.
func (id GameID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether GameID is non-empty, already trimmed, and carries
// the frozen `game-*` opaque prefix.
func (id GameID) Validate() error {
return validatePrefixedID("game id", string(id), gameIDPrefix)
}
// ApplicationID identifies one application record owned by Game Lobby
// Service. The value is opaque and stable; only its `application-*` prefix
// is observable to callers.
type ApplicationID string
// String returns ApplicationID as its stored identifier string.
func (id ApplicationID) String() string {
return string(id)
}
// IsZero reports whether ApplicationID does not contain a usable value.
func (id ApplicationID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether ApplicationID is non-empty, already trimmed, and
// carries the frozen `application-*` opaque prefix.
func (id ApplicationID) Validate() error {
return validatePrefixedID("application id", string(id), applicationIDPrefix)
}
// InviteID identifies one invite record owned by Game Lobby Service. The
// value is opaque and stable; only its `invite-*` prefix is observable to
// callers.
type InviteID string
// String returns InviteID as its stored identifier string.
func (id InviteID) String() string {
return string(id)
}
// IsZero reports whether InviteID does not contain a usable value.
func (id InviteID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether InviteID is non-empty, already trimmed, and
// carries the frozen `invite-*` opaque prefix.
func (id InviteID) Validate() error {
return validatePrefixedID("invite id", string(id), inviteIDPrefix)
}
// MembershipID identifies one membership record owned by Game Lobby
// Service. The value is opaque and stable; only its `membership-*` prefix
// is observable to callers.
type MembershipID string
// String returns MembershipID as its stored identifier string.
func (id MembershipID) String() string {
return string(id)
}
// IsZero reports whether MembershipID does not contain a usable value.
func (id MembershipID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether MembershipID is non-empty, already trimmed, and
// carries the frozen `membership-*` opaque prefix.
func (id MembershipID) Validate() error {
return validatePrefixedID("membership id", string(id), membershipIDPrefix)
}
// validatePrefixedID reports whether raw is a non-empty, trimmed identifier
// with the given opaque prefix and a non-empty suffix. The label is used to
// format error messages.
func validatePrefixedID(label, raw, prefix string) error {
switch {
case strings.TrimSpace(raw) == "":
return fmt.Errorf("%s must not be empty", label)
case strings.TrimSpace(raw) != raw:
return fmt.Errorf("%s must not contain surrounding whitespace", label)
case !strings.HasPrefix(raw, prefix):
return fmt.Errorf("%s must start with %q", label, prefix)
case len(raw) == len(prefix):
return fmt.Errorf("%s must carry a non-empty suffix after %q", label, prefix)
default:
return nil
}
}
+8
View File
@@ -0,0 +1,8 @@
// Package common defines shared value objects used across the Game Lobby
// Service domain model.
//
// The package exposes the opaque identifier types used by the game,
// application, invite, and membership entities. Each identifier validates
// its own frozen prefix and rejects surrounding whitespace so higher-level
// domains can trust the value without re-checking.
package common
+44
View File
@@ -0,0 +1,44 @@
package game
import (
"errors"
"fmt"
)
// ErrNotFound reports that a game record was requested but does not exist
// in the store.
var ErrNotFound = errors.New("game not found")
// ErrConflict reports that a game mutation could not be applied because the
// record changed concurrently or failed a compare-and-swap guard.
var ErrConflict = errors.New("game conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects a
// `(from, to, trigger)` triplet.
var ErrInvalidTransition = errors.New("invalid game status transition")
// InvalidTransitionError stores the rejected `(from, to, trigger)` triplet
// and wraps ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
// Trigger stores the transition trigger that was attempted.
Trigger Trigger
}
// Error reports a human-readable summary of the rejected triplet.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid game status transition from %q to %q with trigger %q",
err.From, err.To, err.Trigger,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+422
View File
@@ -0,0 +1,422 @@
// Package game defines the game record domain model, status machine, and
// sentinel errors owned by Game Lobby Service.
package game
import (
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
cron "github.com/robfig/cron/v3"
"golang.org/x/mod/semver"
)
// GameType identifies the admission model of one game record.
type GameType string
const (
// GameTypePublic reports that the game uses the application flow and
// is administered by system administrators.
GameTypePublic GameType = "public"
// GameTypePrivate reports that the game uses the invite flow and is
// administered by its owner.
GameTypePrivate GameType = "private"
)
// IsKnown reports whether value belongs to the frozen game type
// vocabulary.
func (value GameType) IsKnown() bool {
switch value {
case GameTypePublic, GameTypePrivate:
return true
default:
return false
}
}
// RuntimeSnapshot stores the denormalized runtime snapshot imported from
// Game Master.
type RuntimeSnapshot struct {
// CurrentTurn stores the last observed turn number. Zero means not
// yet running.
CurrentTurn int
// RuntimeStatus stores the last observed runtime status string from
// Game Master. Empty means not yet running.
RuntimeStatus string
// EngineHealthSummary stores the last observed engine health summary
// string from Game Master. Empty means not yet running.
EngineHealthSummary string
}
// RuntimeBinding stores the runtime binding metadata produced by Runtime
// Manager after a successful container start. The binding is required to
// register the running game with Game Master and to correlate the game
// record with the source job-result event for audit purposes.
type RuntimeBinding struct {
// 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
// RuntimeJobID stores the source `runtime:job_results` Redis Stream
// message id (in `<ms>-<seq>` form) that produced this binding. It
// gives operators a back-reference from the game record to the
// originating Runtime Manager event when investigating incidents.
RuntimeJobID string
// BoundAt stores when the binding was persisted.
BoundAt time.Time
}
// Validate reports whether binding contains the structural invariants
// required for a runtime binding produced by success-path
// processing.
func (binding RuntimeBinding) Validate() error {
if strings.TrimSpace(binding.ContainerID) == "" {
return fmt.Errorf("runtime binding container id must not be empty")
}
if strings.TrimSpace(binding.EngineEndpoint) == "" {
return fmt.Errorf("runtime binding engine endpoint must not be empty")
}
if strings.TrimSpace(binding.RuntimeJobID) == "" {
return fmt.Errorf("runtime binding runtime job id must not be empty")
}
if binding.BoundAt.IsZero() {
return fmt.Errorf("runtime binding bound at must not be zero")
}
return nil
}
// Game stores one durable game record owned by Game Lobby Service.
type Game struct {
// GameID identifies the record.
GameID common.GameID
// GameName stores the human-readable game name.
GameName string
// Description stores the optional human-readable description.
Description string
// GameType stores the admission model.
GameType GameType
// OwnerUserID stores the platform user id of the private-game owner.
// It must be empty for public games.
OwnerUserID string
// Status stores the current lifecycle state.
Status Status
// MinPlayers stores the minimum approved participants required
// before the game may start.
MinPlayers int
// MaxPlayers stores the target roster size that activates the gap
// window.
MaxPlayers int
// StartGapHours stores the length of the gap window in hours after
// max_players is reached.
StartGapHours int
// StartGapPlayers stores the number of additional participants
// admitted during the gap window.
StartGapPlayers int
// EnrollmentEndsAt stores the UTC deadline at which enrollment closes
// automatically.
EnrollmentEndsAt time.Time
// TurnSchedule stores the five-field cron expression passed to
// Game Master at registration.
TurnSchedule string
// TargetEngineVersion stores the semver string of the engine to
// launch.
TargetEngineVersion string
// CreatedAt stores when the record was created.
CreatedAt time.Time
// UpdatedAt stores when the record was last mutated.
UpdatedAt time.Time
// StartedAt stores when the record entered the running status.
StartedAt *time.Time
// FinishedAt stores when the record entered the finished status.
FinishedAt *time.Time
// RuntimeSnapshot stores the denormalized runtime snapshot from
// Game Master.
RuntimeSnapshot RuntimeSnapshot
// RuntimeBinding stores the runtime binding metadata produced by
// Runtime Manager after a successful container start. It is nil
// before the start succeeds and non-nil afterwards.
RuntimeBinding *RuntimeBinding
}
// NewGameInput groups all fields required to create a draft game record.
type NewGameInput struct {
// GameID identifies the draft record.
GameID common.GameID
// GameName stores the human-readable game name.
GameName string
// Description stores the optional human-readable description.
Description string
// GameType stores the admission model.
GameType GameType
// OwnerUserID stores the owner id for private games. It must be empty
// for public games.
OwnerUserID string
// MinPlayers stores the minimum approved participants required
// before the game may start.
MinPlayers int
// MaxPlayers stores the target roster size that activates the gap
// window.
MaxPlayers int
// StartGapHours stores the gap window length in hours.
StartGapHours int
// StartGapPlayers stores the number of additional participants
// admitted during the gap window.
StartGapPlayers int
// EnrollmentEndsAt stores the enrollment deadline.
EnrollmentEndsAt time.Time
// TurnSchedule stores the five-field cron expression.
TurnSchedule string
// TargetEngineVersion stores the semver of the engine to launch.
TargetEngineVersion string
// Now stores the creation wall-clock used for CreatedAt and
// UpdatedAt.
Now time.Time
}
// standardCronParser parses the frozen five-field cron expression grammar
// used by turn_schedule.
var standardCronParser = cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow,
)
// New validates input and returns a draft Game record. Validation errors
// are returned verbatim so callers can surface them as invalid_request.
func New(input NewGameInput) (Game, error) {
if err := input.Validate(); err != nil {
return Game{}, err
}
record := Game{
GameID: input.GameID,
GameName: input.GameName,
Description: input.Description,
GameType: input.GameType,
OwnerUserID: input.OwnerUserID,
Status: StatusDraft,
MinPlayers: input.MinPlayers,
MaxPlayers: input.MaxPlayers,
StartGapHours: input.StartGapHours,
StartGapPlayers: input.StartGapPlayers,
EnrollmentEndsAt: input.EnrollmentEndsAt.UTC(),
TurnSchedule: input.TurnSchedule,
TargetEngineVersion: input.TargetEngineVersion,
CreatedAt: input.Now.UTC(),
UpdatedAt: input.Now.UTC(),
}
if err := record.Validate(); err != nil {
return Game{}, err
}
return record, nil
}
// Validate reports whether input satisfies the frozen game-record
// invariants required to construct a draft record.
func (input NewGameInput) Validate() error {
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(input.GameName) == "" {
return fmt.Errorf("game name must not be empty")
}
if !input.GameType.IsKnown() {
return fmt.Errorf("game type %q is unsupported", input.GameType)
}
switch input.GameType {
case GameTypePrivate:
if strings.TrimSpace(input.OwnerUserID) == "" {
return fmt.Errorf("owner user id must not be empty for private games")
}
case GameTypePublic:
if input.OwnerUserID != "" {
return fmt.Errorf("owner user id must be empty for public games")
}
}
if input.MinPlayers <= 0 {
return fmt.Errorf("min players must be positive")
}
if input.MaxPlayers <= 0 {
return fmt.Errorf("max players must be positive")
}
if input.MaxPlayers < input.MinPlayers {
return fmt.Errorf("max players must not be less than min players")
}
if input.StartGapHours <= 0 {
return fmt.Errorf("start gap hours must be positive")
}
if input.StartGapPlayers <= 0 {
return fmt.Errorf("start gap players must be positive")
}
if input.Now.IsZero() {
return fmt.Errorf("now must not be zero")
}
if input.EnrollmentEndsAt.IsZero() {
return fmt.Errorf("enrollment ends at must not be zero")
}
if !input.EnrollmentEndsAt.After(input.Now) {
return fmt.Errorf("enrollment ends at must be after now")
}
if err := validateCronExpression(input.TurnSchedule); err != nil {
return err
}
if err := validateSemver(input.TargetEngineVersion); err != nil {
return err
}
return nil
}
// Validate reports whether record satisfies the full invariants.
// Every marshal and unmarshal round-trip calls Validate to guarantee that
// the Redis store never exposes malformed records.
func (record Game) Validate() error {
if err := record.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(record.GameName) == "" {
return fmt.Errorf("game name must not be empty")
}
if !record.GameType.IsKnown() {
return fmt.Errorf("game type %q is unsupported", record.GameType)
}
switch record.GameType {
case GameTypePrivate:
if strings.TrimSpace(record.OwnerUserID) == "" {
return fmt.Errorf("owner user id must not be empty for private games")
}
case GameTypePublic:
if record.OwnerUserID != "" {
return fmt.Errorf("owner user id must be empty for public games")
}
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if record.MinPlayers <= 0 {
return fmt.Errorf("min players must be positive")
}
if record.MaxPlayers <= 0 {
return fmt.Errorf("max players must be positive")
}
if record.MaxPlayers < record.MinPlayers {
return fmt.Errorf("max players must not be less than min players")
}
if record.StartGapHours <= 0 {
return fmt.Errorf("start gap hours must be positive")
}
if record.StartGapPlayers <= 0 {
return fmt.Errorf("start gap players must be positive")
}
if record.EnrollmentEndsAt.IsZero() {
return fmt.Errorf("enrollment ends at must not be zero")
}
if err := validateCronExpression(record.TurnSchedule); err != nil {
return err
}
if err := validateSemver(record.TargetEngineVersion); err != nil {
return err
}
if record.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
if record.UpdatedAt.IsZero() {
return fmt.Errorf("updated at must not be zero")
}
if record.UpdatedAt.Before(record.CreatedAt) {
return fmt.Errorf("updated at must not be before created at")
}
if record.StartedAt != nil {
if record.StartedAt.IsZero() {
return fmt.Errorf("started at must not be zero when present")
}
if record.StartedAt.Before(record.CreatedAt) {
return fmt.Errorf("started at must not be before created at")
}
}
if record.FinishedAt != nil {
if record.FinishedAt.IsZero() {
return fmt.Errorf("finished at must not be zero when present")
}
if record.FinishedAt.Before(record.CreatedAt) {
return fmt.Errorf("finished at must not be before created at")
}
}
if record.RuntimeSnapshot.CurrentTurn < 0 {
return fmt.Errorf("runtime snapshot current turn must not be negative")
}
if record.RuntimeBinding != nil {
if err := record.RuntimeBinding.Validate(); err != nil {
return err
}
if record.RuntimeBinding.BoundAt.Before(record.CreatedAt) {
return fmt.Errorf("runtime binding bound at must not be before created at")
}
}
return nil
}
func validateCronExpression(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("turn schedule must not be empty")
}
if _, err := standardCronParser.Parse(value); err != nil {
return fmt.Errorf("turn schedule must be a valid five-field cron expression: %w", err)
}
return nil
}
func validateSemver(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("target engine version must not be empty")
}
candidate := value
if !strings.HasPrefix(candidate, "v") {
candidate = "v" + candidate
}
if !semver.IsValid(candidate) {
return fmt.Errorf("target engine version %q must be a valid semver string", value)
}
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package game
import (
"strings"
"testing"
"time"
"galaxy/lobby/internal/domain/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validNewGameInput(now time.Time) NewGameInput {
return NewGameInput{
GameID: common.GameID("game-42"),
GameName: "Spring Classic",
Description: "optional",
GameType: GameTypePublic,
OwnerUserID: "",
MinPlayers: 4,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.2.3",
Now: now,
}
}
func TestNewGameSucceedsOnHappyPath(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
record, err := New(input)
require.NoError(t, err)
assert.Equal(t, StatusDraft, record.Status)
assert.Equal(t, input.GameID, record.GameID)
assert.Equal(t, now.UTC(), record.CreatedAt)
assert.Equal(t, now.UTC(), record.UpdatedAt)
assert.Nil(t, record.StartedAt)
assert.Nil(t, record.FinishedAt)
assert.Equal(t, 0, record.RuntimeSnapshot.CurrentTurn)
assert.Empty(t, record.RuntimeSnapshot.RuntimeStatus)
assert.Empty(t, record.RuntimeSnapshot.EngineHealthSummary)
}
func TestNewGameValidatesOwnerBinding(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
privateMissingOwner := validNewGameInput(now)
privateMissingOwner.GameType = GameTypePrivate
privateMissingOwner.OwnerUserID = ""
_, err := New(privateMissingOwner)
require.Error(t, err)
assert.Contains(t, err.Error(), "owner user id must not be empty for private games")
publicWithOwner := validNewGameInput(now)
publicWithOwner.OwnerUserID = "user-1"
_, err = New(publicWithOwner)
require.Error(t, err)
assert.Contains(t, err.Error(), "owner user id must be empty for public games")
privateWithOwner := validNewGameInput(now)
privateWithOwner.GameType = GameTypePrivate
privateWithOwner.OwnerUserID = "user-1"
_, err = New(privateWithOwner)
require.NoError(t, err)
}
func TestNewGameRejectsInvalidSizing(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
cases := map[string]func(*NewGameInput){
"min_players_zero": func(i *NewGameInput) { i.MinPlayers = 0 },
"min_players_negative": func(i *NewGameInput) { i.MinPlayers = -1 },
"max_players_zero": func(i *NewGameInput) { i.MaxPlayers = 0 },
"max_less_than_min": func(i *NewGameInput) { i.MaxPlayers = i.MinPlayers - 1 },
"start_gap_hours_zero": func(i *NewGameInput) { i.StartGapHours = 0 },
"start_gap_players_zero": func(i *NewGameInput) { i.StartGapPlayers = 0 },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
input := validNewGameInput(now)
mutate(&input)
_, err := New(input)
require.Error(t, err)
})
}
}
func TestNewGameRejectsInvalidEnrollmentDeadline(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
past := validNewGameInput(now)
past.EnrollmentEndsAt = now.Add(-time.Hour)
_, err := New(past)
require.Error(t, err)
zero := validNewGameInput(now)
zero.EnrollmentEndsAt = time.Time{}
_, err = New(zero)
require.Error(t, err)
}
func TestNewGameRejectsInvalidTurnSchedule(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
input.TurnSchedule = "not a cron"
_, err := New(input)
require.Error(t, err)
assert.Contains(t, err.Error(), "turn schedule")
}
func TestNewGameRejectsInvalidEngineVersion(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
input.TargetEngineVersion = "not-semver"
_, err := New(input)
require.Error(t, err)
assert.Contains(t, err.Error(), "target engine version")
input = validNewGameInput(now)
input.TargetEngineVersion = ""
_, err = New(input)
require.Error(t, err)
assert.Contains(t, err.Error(), "target engine version")
}
func TestNewGameRejectsEmptyName(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
input.GameName = " "
_, err := New(input)
require.Error(t, err)
assert.Contains(t, err.Error(), "game name must not be empty")
}
func TestNewGameRejectsInvalidGameID(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
input.GameID = common.GameID("bogus")
_, err := New(input)
require.Error(t, err)
assert.Contains(t, err.Error(), "game id")
}
func TestGameValidateAcceptsCanonicalSemverWithoutPrefix(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
input.TargetEngineVersion = "2.0.0"
record, err := New(input)
require.NoError(t, err)
assert.Equal(t, "2.0.0", record.TargetEngineVersion)
}
func TestGameValidateRuntimeBinding(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
record, err := New(input)
require.NoError(t, err)
bound := now.Add(time.Minute)
record.RuntimeBinding = &RuntimeBinding{
ContainerID: "container-1",
EngineEndpoint: "engine.local:9000",
RuntimeJobID: "1700000000000-0",
BoundAt: bound,
}
require.NoError(t, record.Validate())
cases := map[string]func(binding *RuntimeBinding){
"empty_container_id": func(b *RuntimeBinding) { b.ContainerID = "" },
"empty_engine_endpoint": func(b *RuntimeBinding) { b.EngineEndpoint = "" },
"empty_runtime_job_id": func(b *RuntimeBinding) { b.RuntimeJobID = "" },
"zero_bound_at": func(b *RuntimeBinding) { b.BoundAt = time.Time{} },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
input := validNewGameInput(now)
rec, err := New(input)
require.NoError(t, err)
binding := RuntimeBinding{
ContainerID: "container-1",
EngineEndpoint: "engine.local:9000",
RuntimeJobID: "1700000000000-0",
BoundAt: bound,
}
mutate(&binding)
rec.RuntimeBinding = &binding
require.Error(t, rec.Validate())
})
}
beforeCreated := validNewGameInput(now)
rec, err := New(beforeCreated)
require.NoError(t, err)
earlier := now.Add(-time.Hour)
rec.RuntimeBinding = &RuntimeBinding{
ContainerID: "container-1",
EngineEndpoint: "engine.local:9000",
RuntimeJobID: "1700000000000-0",
BoundAt: earlier,
}
err = rec.Validate()
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "runtime binding bound at must not be before created at")
}
func TestGameValidateDetectsStartedBeforeCreated(t *testing.T) {
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
input := validNewGameInput(now)
record, err := New(input)
require.NoError(t, err)
earlier := now.Add(-time.Minute)
record.StartedAt = &earlier
err = record.Validate()
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "started at")
}
+251
View File
@@ -0,0 +1,251 @@
package game
// Status identifies one platform-level lifecycle state of a game record.
type Status string
const (
// StatusDraft reports that the record was created but enrollment is
// not yet open.
StatusDraft Status = "draft"
// StatusEnrollmentOpen reports that applications (public game) or
// invite redemptions (private game) are being accepted.
StatusEnrollmentOpen Status = "enrollment_open"
// StatusReadyToStart reports that enrollment closed and the start
// command is accepted.
StatusReadyToStart Status = "ready_to_start"
// StatusStarting reports that a start job has been submitted to
// Runtime Manager and Lobby is waiting for the result.
StatusStarting Status = "starting"
// StatusStartFailed reports that the container start or metadata
// persistence step failed.
StatusStartFailed Status = "start_failed"
// StatusRunning reports that the engine container is live and normal
// gameplay has begun.
StatusRunning Status = "running"
// StatusPaused reports a platform-level pause; the engine container
// may still be alive.
StatusPaused Status = "paused"
// StatusFinished reports that the game ended; the record is terminal.
StatusFinished Status = "finished"
// StatusCancelled reports that the game was cancelled before start;
// the record is terminal.
StatusCancelled Status = "cancelled"
)
// IsKnown reports whether status belongs to the frozen Lobby status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusDraft,
StatusEnrollmentOpen,
StatusReadyToStart,
StatusStarting,
StatusStartFailed,
StatusRunning,
StatusPaused,
StatusFinished,
StatusCancelled:
return true
default:
return false
}
}
// AllStatuses returns the frozen list of every Lobby status value. The
// slice order is stable across calls and matches the README §Status
// vocabulary listing.
func AllStatuses() []Status {
return []Status{
StatusDraft,
StatusEnrollmentOpen,
StatusReadyToStart,
StatusStarting,
StatusStartFailed,
StatusRunning,
StatusPaused,
StatusFinished,
StatusCancelled,
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions.
func (status Status) IsTerminal() bool {
switch status {
case StatusFinished, StatusCancelled:
return true
default:
return false
}
}
// Trigger identifies what caused a status transition. The value is used by
// the domain transition gate and by later observability layers.
type Trigger string
const (
// TriggerCommand reports an explicit owner or admin command such as
// start, pause, resume, cancel, open-enrollment, or retry-start.
TriggerCommand Trigger = "command"
// TriggerManual reports that an admin or owner manually closed
// enrollment while min_players was already satisfied.
TriggerManual Trigger = "manual"
// TriggerDeadline reports that the enrollment automation worker
// detected enrollment_ends_at had passed with min_players satisfied.
TriggerDeadline Trigger = "deadline"
// TriggerGap reports that the enrollment automation worker detected
// the gap window exhausted its time or player budget.
TriggerGap Trigger = "gap"
// TriggerRuntimeEvent reports that the transition was caused by a
// Runtime Manager job result or a Game Master runtime event.
TriggerRuntimeEvent Trigger = "runtime_event"
// TriggerExternalBlock reports that the transition was caused by the
// user-lifecycle cascade reacting to a permanent_block or DeleteUser
// event on the game owner. The trigger is the only path that
// cancels a game from in-flight statuses
// (`starting`, `running`, `paused`).
TriggerExternalBlock Trigger = "external_block"
)
// IsKnown reports whether trigger belongs to the frozen trigger
// vocabulary.
func (trigger Trigger) IsKnown() bool {
switch trigger {
case TriggerCommand,
TriggerManual,
TriggerDeadline,
TriggerGap,
TriggerRuntimeEvent,
TriggerExternalBlock:
return true
default:
return false
}
}
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions maps each permitted `(from, to)` pair to the set of
// triggers that may drive it. The table mirrors the status transition
// table frozen in lobby/README.md.
var allowedTransitions = map[transitionKey]map[Trigger]struct{}{
{StatusDraft, StatusEnrollmentOpen}: {
TriggerCommand: {},
},
{StatusEnrollmentOpen, StatusReadyToStart}: {
TriggerManual: {},
TriggerDeadline: {},
TriggerGap: {},
},
{StatusReadyToStart, StatusStarting}: {
TriggerCommand: {},
},
{StatusStarting, StatusRunning}: {
TriggerRuntimeEvent: {},
},
{StatusStarting, StatusPaused}: {
TriggerRuntimeEvent: {},
},
{StatusStarting, StatusStartFailed}: {
TriggerRuntimeEvent: {},
},
{StatusStartFailed, StatusReadyToStart}: {
TriggerCommand: {},
},
{StatusRunning, StatusPaused}: {
TriggerCommand: {},
},
{StatusRunning, StatusFinished}: {
TriggerRuntimeEvent: {},
},
{StatusPaused, StatusRunning}: {
TriggerCommand: {},
},
{StatusPaused, StatusFinished}: {
TriggerRuntimeEvent: {},
},
{StatusDraft, StatusCancelled}: {
TriggerCommand: {},
TriggerExternalBlock: {},
},
{StatusEnrollmentOpen, StatusCancelled}: {
TriggerCommand: {},
TriggerExternalBlock: {},
},
{StatusReadyToStart, StatusCancelled}: {
TriggerCommand: {},
TriggerExternalBlock: {},
},
{StatusStartFailed, StatusCancelled}: {
TriggerCommand: {},
TriggerExternalBlock: {},
},
{StatusStarting, StatusCancelled}: {
TriggerExternalBlock: {},
},
{StatusRunning, StatusCancelled}: {
TriggerExternalBlock: {},
},
{StatusPaused, StatusCancelled}: {
TriggerExternalBlock: {},
},
}
// AllowedTransitions returns a copy of the `(from, to) -> {triggers}` table
// used by Transition. The returned map is safe to mutate; callers should
// not rely on iteration order.
func AllowedTransitions() map[Status]map[Status][]Trigger {
result := make(map[Status]map[Status][]Trigger, len(allowedTransitions))
for key, triggers := range allowedTransitions {
inner, ok := result[key.from]
if !ok {
inner = make(map[Status][]Trigger)
result[key.from] = inner
}
copied := make([]Trigger, 0, len(triggers))
for trigger := range triggers {
copied = append(copied, trigger)
}
inner[key.to] = copied
}
return result
}
// Transition reports whether from may transition to next under trigger.
// The function returns nil when the triplet is permitted, and an
// *InvalidTransitionError wrapping ErrInvalidTransition otherwise. It does
// not touch any store and is safe to call from any layer.
func Transition(from Status, next Status, trigger Trigger) error {
if !from.IsKnown() || !next.IsKnown() || !trigger.IsKnown() {
return &InvalidTransitionError{From: from, To: next, Trigger: trigger}
}
triggers, ok := allowedTransitions[transitionKey{from: from, to: next}]
if !ok {
return &InvalidTransitionError{From: from, To: next, Trigger: trigger}
}
if _, ok := triggers[trigger]; !ok {
return &InvalidTransitionError{From: from, To: next, Trigger: trigger}
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package game
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStatusIsKnown(t *testing.T) {
for _, status := range []Status{
StatusDraft,
StatusEnrollmentOpen,
StatusReadyToStart,
StatusStarting,
StatusStartFailed,
StatusRunning,
StatusPaused,
StatusFinished,
StatusCancelled,
} {
assert.Truef(t, status.IsKnown(), "expected %q known", status)
}
assert.False(t, Status("").IsKnown())
assert.False(t, Status("unknown").IsKnown())
}
func TestStatusIsTerminal(t *testing.T) {
assert.True(t, StatusFinished.IsTerminal())
assert.True(t, StatusCancelled.IsTerminal())
for _, status := range []Status{
StatusDraft,
StatusEnrollmentOpen,
StatusReadyToStart,
StatusStarting,
StatusStartFailed,
StatusRunning,
StatusPaused,
} {
assert.Falsef(t, status.IsTerminal(), "expected %q non-terminal", status)
}
}
func TestTriggerIsKnown(t *testing.T) {
for _, trigger := range []Trigger{
TriggerCommand,
TriggerManual,
TriggerDeadline,
TriggerGap,
TriggerRuntimeEvent,
TriggerExternalBlock,
} {
assert.Truef(t, trigger.IsKnown(), "expected %q known", trigger)
}
assert.False(t, Trigger("").IsKnown())
assert.False(t, Trigger("bogus").IsKnown())
}
func TestTransitionHappyPathsCoverFrozenTable(t *testing.T) {
cases := []struct {
from Status
to Status
triggers []Trigger
}{
{StatusDraft, StatusEnrollmentOpen, []Trigger{TriggerCommand}},
{StatusEnrollmentOpen, StatusReadyToStart, []Trigger{TriggerManual, TriggerDeadline, TriggerGap}},
{StatusReadyToStart, StatusStarting, []Trigger{TriggerCommand}},
{StatusStarting, StatusRunning, []Trigger{TriggerRuntimeEvent}},
{StatusStarting, StatusPaused, []Trigger{TriggerRuntimeEvent}},
{StatusStarting, StatusStartFailed, []Trigger{TriggerRuntimeEvent}},
{StatusStartFailed, StatusReadyToStart, []Trigger{TriggerCommand}},
{StatusRunning, StatusPaused, []Trigger{TriggerCommand}},
{StatusRunning, StatusFinished, []Trigger{TriggerRuntimeEvent}},
{StatusPaused, StatusRunning, []Trigger{TriggerCommand}},
{StatusPaused, StatusFinished, []Trigger{TriggerRuntimeEvent}},
{StatusDraft, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
{StatusEnrollmentOpen, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
{StatusReadyToStart, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
{StatusStartFailed, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
{StatusStarting, StatusCancelled, []Trigger{TriggerExternalBlock}},
{StatusRunning, StatusCancelled, []Trigger{TriggerExternalBlock}},
{StatusPaused, StatusCancelled, []Trigger{TriggerExternalBlock}},
}
for _, tc := range cases {
for _, trigger := range tc.triggers {
t.Run(string(tc.from)+"->"+string(tc.to)+"/"+string(trigger), func(t *testing.T) {
require.NoError(t, Transition(tc.from, tc.to, trigger))
})
}
}
}
func TestTransitionRejectsUnknownPair(t *testing.T) {
err := Transition(StatusDraft, StatusRunning, TriggerCommand)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidTransition))
var typed *InvalidTransitionError
require.True(t, errors.As(err, &typed))
assert.Equal(t, StatusDraft, typed.From)
assert.Equal(t, StatusRunning, typed.To)
assert.Equal(t, TriggerCommand, typed.Trigger)
}
func TestTransitionRejectsWrongTrigger(t *testing.T) {
err := Transition(StatusDraft, StatusEnrollmentOpen, TriggerDeadline)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidTransition))
}
func TestTransitionRejectsUnknownStatusOrTrigger(t *testing.T) {
require.Error(t, Transition(Status("bogus"), StatusEnrollmentOpen, TriggerCommand))
require.Error(t, Transition(StatusDraft, Status("bogus"), TriggerCommand))
require.Error(t, Transition(StatusDraft, StatusEnrollmentOpen, Trigger("bogus")))
}
func TestTransitionsOutOfTerminalStatusAllRejected(t *testing.T) {
triggers := []Trigger{
TriggerCommand,
TriggerManual,
TriggerDeadline,
TriggerGap,
TriggerRuntimeEvent,
TriggerExternalBlock,
}
for _, from := range []Status{StatusFinished, StatusCancelled} {
for _, to := range []Status{
StatusDraft,
StatusEnrollmentOpen,
StatusReadyToStart,
StatusStarting,
StatusStartFailed,
StatusRunning,
StatusPaused,
StatusFinished,
StatusCancelled,
} {
for _, trigger := range triggers {
if from == to {
continue
}
err := Transition(from, to, trigger)
require.Errorf(t, err, "%s->%s via %s should be rejected", from, to, trigger)
}
}
}
}
func TestAllowedTransitionsSnapshotMatchesTable(t *testing.T) {
snapshot := AllowedTransitions()
count := 0
for _, inner := range snapshot {
count += len(inner)
}
assert.Equal(t, len(allowedTransitions), count)
for key, triggers := range allowedTransitions {
inner, ok := snapshot[key.from]
require.Truef(t, ok, "expected from=%s in snapshot", key.from)
list, ok := inner[key.to]
require.Truef(t, ok, "expected to=%s under from=%s", key.to, key.from)
for trigger := range triggers {
assert.Containsf(t, list, trigger, "missing trigger %q for %s->%s", trigger, key.from, key.to)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
package invite
import (
"errors"
"fmt"
)
// ErrNotFound reports that an invite record was requested but does not
// exist in the store.
var ErrNotFound = errors.New("invite not found")
// ErrConflict reports that an invite mutation could not be applied because
// the record changed concurrently or failed a compare-and-swap guard.
var ErrConflict = errors.New("invite conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects a
// `(from, to)` pair.
var ErrInvalidTransition = errors.New("invalid invite status transition")
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
// ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
}
// Error reports a human-readable summary of the rejected pair.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid invite status transition from %q to %q",
err.From, err.To,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+188
View File
@@ -0,0 +1,188 @@
// Package invite defines the invite record domain model, status machine,
// and sentinel errors owned by Game Lobby Service for private-game
// enrollment.
package invite
import (
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
)
// Invite stores one durable invite record owned by Game Lobby Service.
// Invites are used exclusively by private games; public games use the
// application flow instead.
type Invite struct {
// InviteID identifies the record.
InviteID common.InviteID
// GameID identifies the game this invite belongs to.
GameID common.GameID
// InviterUserID stores the platform user id of the private-game owner
// who created the invite.
InviterUserID string
// InviteeUserID stores the platform user id of the invited user.
InviteeUserID string
// RaceName stores the invitee's chosen in-game name. It is empty until
// the invite transitions to redeemed.
RaceName string
// Status stores the current lifecycle state.
Status Status
// CreatedAt stores when the record was created.
CreatedAt time.Time
// ExpiresAt stores the business deadline after which the invite is no
// longer actionable. It equals enrollment_ends_at of the parent game
// at creation time.
ExpiresAt time.Time
// DecidedAt stores when the record transitioned out of created. It is
// nil while the invite is still created.
DecidedAt *time.Time
}
// NewInviteInput groups all fields required to create an invite record.
type NewInviteInput struct {
// InviteID identifies the new record.
InviteID common.InviteID
// GameID identifies the game the invitee is being invited to.
GameID common.GameID
// InviterUserID stores the platform user id of the private-game owner.
InviterUserID string
// InviteeUserID stores the platform user id of the invited user.
InviteeUserID string
// Now stores the creation wall-clock used for CreatedAt.
Now time.Time
// ExpiresAt stores the business deadline propagated from the parent
// game's enrollment_ends_at.
ExpiresAt time.Time
}
// New validates input and returns a created Invite record. Validation
// errors are returned verbatim so callers can surface them as
// invalid_request.
func New(input NewInviteInput) (Invite, error) {
if err := input.Validate(); err != nil {
return Invite{}, err
}
record := Invite{
InviteID: input.InviteID,
GameID: input.GameID,
InviterUserID: strings.TrimSpace(input.InviterUserID),
InviteeUserID: strings.TrimSpace(input.InviteeUserID),
Status: StatusCreated,
CreatedAt: input.Now.UTC(),
ExpiresAt: input.ExpiresAt.UTC(),
}
if err := record.Validate(); err != nil {
return Invite{}, err
}
return record, nil
}
// Validate reports whether input satisfies the frozen invite-record
// invariants required to construct a created record.
func (input NewInviteInput) Validate() error {
if err := input.InviteID.Validate(); err != nil {
return fmt.Errorf("invite id: %w", err)
}
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(input.InviterUserID) == "" {
return fmt.Errorf("inviter user id must not be empty")
}
if strings.TrimSpace(input.InviteeUserID) == "" {
return fmt.Errorf("invitee user id must not be empty")
}
if strings.TrimSpace(input.InviterUserID) == strings.TrimSpace(input.InviteeUserID) {
return fmt.Errorf("inviter and invitee must not be the same user")
}
if input.Now.IsZero() {
return fmt.Errorf("now must not be zero")
}
if input.ExpiresAt.IsZero() {
return fmt.Errorf("expires at must not be zero")
}
if !input.ExpiresAt.After(input.Now) {
return fmt.Errorf("expires at must be after now")
}
return nil
}
// Validate reports whether record satisfies the full invariants.
// Every marshal and unmarshal round-trip calls Validate to guarantee that
// the Redis store never exposes malformed records.
func (record Invite) Validate() error {
if err := record.InviteID.Validate(); err != nil {
return fmt.Errorf("invite id: %w", err)
}
if err := record.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(record.InviterUserID) == "" {
return fmt.Errorf("inviter user id must not be empty")
}
if strings.TrimSpace(record.InviteeUserID) == "" {
return fmt.Errorf("invitee user id must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if record.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
if record.ExpiresAt.IsZero() {
return fmt.Errorf("expires at must not be zero")
}
if record.ExpiresAt.Before(record.CreatedAt) {
return fmt.Errorf("expires at must not be before created at")
}
switch record.Status {
case StatusCreated:
if record.DecidedAt != nil {
return fmt.Errorf("decided at must be nil for created invites")
}
if record.RaceName != "" {
return fmt.Errorf("race name must be empty for created invites")
}
case StatusRedeemed:
if strings.TrimSpace(record.RaceName) == "" {
return fmt.Errorf("race name must not be empty for redeemed invites")
}
if record.DecidedAt == nil {
return fmt.Errorf("decided at must not be nil for redeemed invites")
}
default:
if record.RaceName != "" {
return fmt.Errorf("race name must be empty for %q invites", record.Status)
}
if record.DecidedAt == nil {
return fmt.Errorf("decided at must not be nil for %q invites", record.Status)
}
}
if record.DecidedAt != nil {
if record.DecidedAt.IsZero() {
return fmt.Errorf("decided at must not be zero when present")
}
if record.DecidedAt.Before(record.CreatedAt) {
return fmt.Errorf("decided at must not be before created at")
}
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package invite
// Status identifies one lifecycle state of a Game Lobby invite record.
type Status string
const (
// StatusCreated reports that the invite was created by the private-game
// owner and awaits invitee action.
StatusCreated Status = "created"
// StatusRedeemed reports that the invitee redeemed the invite; a
// membership record was created as part of the same operation.
StatusRedeemed Status = "redeemed"
// StatusDeclined reports that the invitee declined the invite.
StatusDeclined Status = "declined"
// StatusRevoked reports that the owner revoked the invite before the
// invitee acted on it.
StatusRevoked Status = "revoked"
// StatusExpired reports that the invite expired because the game
// transitioned out of enrollment_open.
StatusExpired Status = "expired"
)
// IsKnown reports whether status belongs to the frozen invite status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusCreated, StatusRedeemed, StatusDeclined, StatusRevoked, StatusExpired:
return true
default:
return false
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions.
func (status Status) IsTerminal() bool {
switch status {
case StatusRedeemed, StatusDeclined, StatusRevoked, StatusExpired:
return true
default:
return false
}
}
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions stores the set of permitted `(from, to)` status pairs.
// It mirrors the state machine frozen in lobby/README.md Invite Lifecycle
// section.
var allowedTransitions = map[transitionKey]struct{}{
{StatusCreated, StatusRedeemed}: {},
{StatusCreated, StatusDeclined}: {},
{StatusCreated, StatusRevoked}: {},
{StatusCreated, StatusExpired}: {},
}
// AllowedTransitions returns a copy of the `(from, to)` allowed-transitions
// table used by Transition. The returned map is safe to mutate.
func AllowedTransitions() map[Status][]Status {
result := make(map[Status][]Status)
for key := range allowedTransitions {
result[key.from] = append(result[key.from], key.to)
}
return result
}
// Transition reports whether from may transition to next. The function
// returns nil when the pair is permitted, and an *InvalidTransitionError
// wrapping ErrInvalidTransition otherwise. It does not touch any store and
// is safe to call from any layer.
func Transition(from Status, next Status) error {
if !from.IsKnown() || !next.IsKnown() {
return &InvalidTransitionError{From: from, To: next}
}
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
return &InvalidTransitionError{From: from, To: next}
}
return nil
}
@@ -0,0 +1,42 @@
package membership
import (
"errors"
"fmt"
)
// ErrNotFound reports that a membership record was requested but does not
// exist in the store.
var ErrNotFound = errors.New("membership not found")
// ErrConflict reports that a membership mutation could not be applied
// because the record changed concurrently or failed a compare-and-swap
// guard.
var ErrConflict = errors.New("membership conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects a
// `(from, to)` pair.
var ErrInvalidTransition = errors.New("invalid membership status transition")
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
// ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
}
// Error reports a human-readable summary of the rejected pair.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid membership status transition from %q to %q",
err.From, err.To,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+167
View File
@@ -0,0 +1,167 @@
// Package membership defines the membership record domain model, status
// machine, and sentinel errors owned by Game Lobby Service for platform
// participant roster state.
package membership
import (
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
)
// Membership stores one durable membership record owned by Game Lobby
// Service. Memberships are the platform roster entry that binds a user to
// a game with a confirmed race name.
type Membership struct {
// MembershipID identifies the record.
MembershipID common.MembershipID
// GameID identifies the game this membership belongs to.
GameID common.GameID
// UserID stores the platform user id of the member.
UserID string
// RaceName stores the confirmed in-game name in its original casing.
// It is reserved in the Race Name Directory under CanonicalKey.
RaceName string
// CanonicalKey stores the policy-derived canonical form of RaceName.
// It is the join key used by the Race Name Directory and downstream
// readers (capability evaluation, cascade release) so they never need
// to re-derive canonical keys from the original-casing RaceName.
CanonicalKey string
// Status stores the current membership status.
Status Status
// JoinedAt stores when the record entered the active status.
JoinedAt time.Time
// RemovedAt stores when the record transitioned out of active. It is
// nil while the membership is still active.
RemovedAt *time.Time
}
// NewMembershipInput groups all fields required to create an active
// membership record.
type NewMembershipInput struct {
// MembershipID identifies the new record.
MembershipID common.MembershipID
// GameID identifies the game the member is joining.
GameID common.GameID
// UserID stores the platform user id of the member.
UserID string
// RaceName stores the confirmed in-game name reserved for the member
// in its original casing.
RaceName string
// CanonicalKey stores the policy-derived canonical form of RaceName.
// Callers obtain it from RaceNameDirectory.Canonicalize before
// constructing the membership so that the record carries the same key
// the directory uses internally.
CanonicalKey string
// Now stores the creation wall-clock used for JoinedAt.
Now time.Time
}
// New validates input and returns an active Membership record. Validation
// errors are returned verbatim so callers can surface them as
// invalid_request.
func New(input NewMembershipInput) (Membership, error) {
if err := input.Validate(); err != nil {
return Membership{}, err
}
record := Membership{
MembershipID: input.MembershipID,
GameID: input.GameID,
UserID: strings.TrimSpace(input.UserID),
RaceName: strings.TrimSpace(input.RaceName),
CanonicalKey: strings.TrimSpace(input.CanonicalKey),
Status: StatusActive,
JoinedAt: input.Now.UTC(),
}
if err := record.Validate(); err != nil {
return Membership{}, err
}
return record, nil
}
// Validate reports whether input satisfies the frozen membership-record
// invariants required to construct an active record.
func (input NewMembershipInput) Validate() error {
if err := input.MembershipID.Validate(); err != nil {
return fmt.Errorf("membership id: %w", err)
}
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(input.UserID) == "" {
return fmt.Errorf("user id must not be empty")
}
if strings.TrimSpace(input.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if strings.TrimSpace(input.CanonicalKey) == "" {
return fmt.Errorf("canonical key must not be empty")
}
if input.Now.IsZero() {
return fmt.Errorf("now must not be zero")
}
return nil
}
// Validate reports whether record satisfies the full invariants.
// Every marshal and unmarshal round-trip calls Validate to guarantee that
// the Redis store never exposes malformed records.
func (record Membership) Validate() error {
if err := record.MembershipID.Validate(); err != nil {
return fmt.Errorf("membership id: %w", err)
}
if err := record.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(record.UserID) == "" {
return fmt.Errorf("user id must not be empty")
}
if strings.TrimSpace(record.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if strings.TrimSpace(record.CanonicalKey) == "" {
return fmt.Errorf("canonical key must not be empty")
}
if strings.TrimSpace(record.CanonicalKey) != record.CanonicalKey {
return fmt.Errorf("canonical key must not contain surrounding whitespace")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if record.JoinedAt.IsZero() {
return fmt.Errorf("joined at must not be zero")
}
if record.Status == StatusActive {
if record.RemovedAt != nil {
return fmt.Errorf("removed at must be nil for active memberships")
}
} else {
if record.RemovedAt == nil {
return fmt.Errorf("removed at must not be nil for %q memberships", record.Status)
}
if record.RemovedAt.IsZero() {
return fmt.Errorf("removed at must not be zero when present")
}
if record.RemovedAt.Before(record.JoinedAt) {
return fmt.Errorf("removed at must not be before joined at")
}
}
return nil
}
@@ -0,0 +1,80 @@
package membership
// Status identifies one lifecycle state of a Game Lobby membership record.
type Status string
const (
// StatusActive reports that the member is a full participant and may
// send commands through Game Master.
StatusActive Status = "active"
// StatusRemoved reports that the member was removed post-start. The
// engine slot is deactivated; the race name reservation is preserved
// until the game finishes.
StatusRemoved Status = "removed"
// StatusBlocked reports that the member is blocked at the platform
// level. The engine slot is retained; commands are blocked.
StatusBlocked Status = "blocked"
)
// IsKnown reports whether status belongs to the frozen membership status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusActive, StatusRemoved, StatusBlocked:
return true
default:
return false
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions.
func (status Status) IsTerminal() bool {
switch status {
case StatusRemoved, StatusBlocked:
return true
default:
return false
}
}
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions stores the set of permitted `(from, to)` status pairs.
// It mirrors the state machine frozen in lobby/README.md Membership Model
// section.
var allowedTransitions = map[transitionKey]struct{}{
{StatusActive, StatusRemoved}: {},
{StatusActive, StatusBlocked}: {},
}
// AllowedTransitions returns a copy of the `(from, to)` allowed-transitions
// table used by Transition. The returned map is safe to mutate.
func AllowedTransitions() map[Status][]Status {
result := make(map[Status][]Status)
for key := range allowedTransitions {
result[key.from] = append(result[key.from], key.to)
}
return result
}
// Transition reports whether from may transition to next. The function
// returns nil when the pair is permitted, and an *InvalidTransitionError
// wrapping ErrInvalidTransition otherwise. It does not touch any store and
// is safe to call from any layer.
func Transition(from Status, next Status) error {
if !from.IsKnown() || !next.IsKnown() {
return &InvalidTransitionError{From: from, To: next}
}
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
return &InvalidTransitionError{From: from, To: next}
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package racename
import (
"fmt"
"strings"
"galaxy/util"
confusables "github.com/disciplinedware/go-confusables"
"golang.org/x/text/cases"
)
// confusableSkeletoner abstracts the underlying TR39 confusable-skeleton
// computer so tests may substitute a deterministic stub.
type confusableSkeletoner interface {
Skeleton(string) string
}
// Policy produces canonical uniqueness keys and validates user-supplied race
// names under the Race Name Directory rules: Unicode case folding, explicit
// ASCII anti-fraud digit-to-letter mappings, and a TR39 confusable skeleton.
type Policy struct {
caseFolder cases.Caser
skeletoner confusableSkeletoner
}
// antiFraudReplacer collapses the frozen ASCII anti-fraud digit-to-letter
// pairs so `P1lot` and `Pilot` canonicalize together.
var antiFraudReplacer = strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
)
// NewPolicy returns the default race-name canonicalization policy.
func NewPolicy() (*Policy, error) {
policy := &Policy{
caseFolder: cases.Fold(),
skeletoner: confusables.Default(),
}
if policy.skeletoner == nil {
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
}
return policy, nil
}
// Canonical returns the stable uniqueness key for raceName.
//
// raceName is expected to be non-empty; surrounding whitespace is trimmed
// before canonicalization so callers that preserve original casing pass the
// untrimmed display form directly.
func (policy *Policy) Canonical(raceName string) (CanonicalKey, error) {
switch {
case policy == nil:
return "", fmt.Errorf("canonicalize race name: nil policy")
case policy.skeletoner == nil:
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
}
trimmed := strings.TrimSpace(raceName)
if trimmed == "" {
return "", fmt.Errorf("canonicalize race name: race name must not be empty")
}
folded := policy.caseFolder.String(trimmed)
antiFraudMapped := antiFraudReplacer.Replace(folded)
key := CanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
if err := key.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
return key, nil
}
// ValidateName reports whether raceName is structurally valid for use in the
// Race Name Directory. It delegates to galaxy/util.ValidateTypeName and
// returns the trimmed canonical display value on success.
func ValidateName(raceName string) (string, error) {
trimmed, ok := util.ValidateTypeName(raceName)
if !ok {
return "", fmt.Errorf("race name is invalid")
}
return trimmed, nil
}
// Canonicalize validates raceName as a Race Name Directory display value
// and returns its canonical uniqueness key. It composes ValidateName with
// the Canonical pipeline so every RND write and lookup shares a single
// entry point for both character-set and confusable-pair policy.
//
// Invalid raceName values surface the error returned by ValidateName;
// callers at the RaceNameDirectory port boundary map these to
// ports.ErrInvalidName.
func (policy *Policy) Canonicalize(raceName string) (CanonicalKey, error) {
trimmed, err := ValidateName(raceName)
if err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
return policy.Canonical(trimmed)
}
@@ -0,0 +1,188 @@
package racename
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPolicyCanonicalCollisions(t *testing.T) {
t.Parallel()
policy, err := NewPolicy()
require.NoError(t, err)
tests := []struct {
name string
left string
right string
}{
{
name: "case insensitive collision",
left: "Pilot Nova",
right: "pilot nova",
},
{
name: "ascii anti fraud collision",
left: "Pilot Nova",
right: "P1lot N0va",
},
{
name: "unicode confusable collision",
left: "paypal",
right: "раураl",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
leftKey, err := policy.Canonical(tt.left)
require.NoError(t, err)
rightKey, err := policy.Canonical(tt.right)
require.NoError(t, err)
require.Equal(t, rightKey, leftKey)
})
}
}
func TestPolicyCanonicalRejectsEmpty(t *testing.T) {
t.Parallel()
policy, err := NewPolicy()
require.NoError(t, err)
_, err = policy.Canonical("")
require.Error(t, err)
_, err = policy.Canonical(" ")
require.Error(t, err)
}
func TestPolicyCanonicalTrimsWhitespace(t *testing.T) {
t.Parallel()
policy, err := NewPolicy()
require.NoError(t, err)
trimmed, err := policy.Canonical("Pilot Nova")
require.NoError(t, err)
padded, err := policy.Canonical(" Pilot Nova ")
require.NoError(t, err)
require.Equal(t, trimmed, padded)
}
func TestValidateNameDelegatesToUtil(t *testing.T) {
t.Parallel()
trimmed, err := ValidateName(" PilotNova ")
require.NoError(t, err)
require.Equal(t, "PilotNova", trimmed)
_, err = ValidateName("")
require.Error(t, err)
_, err = ValidateName(" ")
require.Error(t, err)
// Internal whitespace is rejected by util.ValidateTypeName.
_, err = ValidateName("Pilot Nova")
require.Error(t, err)
}
func TestCanonicalKeyValidate(t *testing.T) {
t.Parallel()
require.Error(t, CanonicalKey("").Validate())
require.Error(t, CanonicalKey(" abc").Validate())
require.Error(t, CanonicalKey("abc ").Validate())
require.NoError(t, CanonicalKey("abc").Validate())
require.True(t, CanonicalKey("").IsZero())
require.False(t, CanonicalKey("abc").IsZero())
require.Equal(t, "abc", CanonicalKey("abc").String())
}
func TestPolicyCanonicalizeValid(t *testing.T) {
t.Parallel()
policy, err := NewPolicy()
require.NoError(t, err)
key, err := policy.Canonicalize(" PilotNova ")
require.NoError(t, err)
require.NoError(t, key.Validate())
require.False(t, key.IsZero())
paddedKey, err := policy.Canonicalize("PilotNova")
require.NoError(t, err)
require.Equal(t, paddedKey, key)
}
func TestPolicyCanonicalizeEquivalences(t *testing.T) {
t.Parallel()
policy, err := NewPolicy()
require.NoError(t, err)
cases := []struct {
name string
left string
right string
}{
{
name: "case insensitive collision",
left: "PilotNova",
right: "pilotnova",
},
{
name: "ascii anti fraud collision",
left: "PilotNova",
right: "P1l0tN0va",
},
{
name: "unicode confusable collision",
left: "paypal",
right: "раураl",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
leftKey, err := policy.Canonicalize(tt.left)
require.NoError(t, err)
rightKey, err := policy.Canonicalize(tt.right)
require.NoError(t, err)
require.Equal(t, leftKey, rightKey)
})
}
}
func TestPolicyCanonicalizeRejectsInvalid(t *testing.T) {
t.Parallel()
policy, err := NewPolicy()
require.NoError(t, err)
invalid := []struct {
name string
input string
}{
{name: "empty", input: ""},
{name: "whitespace only", input: " "},
{name: "internal space", input: "Pilot Nova"},
{name: "leading dash", input: "-Pilot"},
{name: "trailing dash", input: "Pilot-"},
}
for _, tt := range invalid {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := policy.Canonicalize(tt.input)
require.Error(t, err)
})
}
}
+35
View File
@@ -0,0 +1,35 @@
// Package racename defines the Lobby Race Name Directory canonical-key
// policy and shared value types used by the platform-wide race-name
// uniqueness arbiter.
package racename
import (
"fmt"
"strings"
)
// CanonicalKey stores the policy-produced uniqueness key used to arbitrate
// race-name ownership across the Race Name Directory.
type CanonicalKey string
// String returns CanonicalKey as its stored canonical string.
func (key CanonicalKey) String() string {
return string(key)
}
// IsZero reports whether CanonicalKey does not contain a usable value.
func (key CanonicalKey) IsZero() bool {
return strings.TrimSpace(string(key)) == ""
}
// Validate reports whether CanonicalKey is non-empty and trimmed.
func (key CanonicalKey) Validate() error {
switch {
case key.IsZero():
return fmt.Errorf("race name canonical key must not be empty")
case strings.TrimSpace(string(key)) != string(key):
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
default:
return nil
}
}