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
}