feat: game lobby service
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user