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 membership
import (
"errors"
"fmt"
)
// ErrNotFound reports that a membership record was requested but does not
// exist in the store.
var ErrNotFound = errors.New("membership not found")
// ErrConflict reports that a membership mutation could not be applied
// because the record changed concurrently or failed a compare-and-swap
// guard.
var ErrConflict = errors.New("membership conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects a
// `(from, to)` pair.
var ErrInvalidTransition = errors.New("invalid membership status transition")
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
// ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
}
// Error reports a human-readable summary of the rejected pair.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid membership status transition from %q to %q",
err.From, err.To,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+167
View File
@@ -0,0 +1,167 @@
// Package membership defines the membership record domain model, status
// machine, and sentinel errors owned by Game Lobby Service for platform
// participant roster state.
package membership
import (
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
)
// Membership stores one durable membership record owned by Game Lobby
// Service. Memberships are the platform roster entry that binds a user to
// a game with a confirmed race name.
type Membership struct {
// MembershipID identifies the record.
MembershipID common.MembershipID
// GameID identifies the game this membership belongs to.
GameID common.GameID
// UserID stores the platform user id of the member.
UserID string
// RaceName stores the confirmed in-game name in its original casing.
// It is reserved in the Race Name Directory under CanonicalKey.
RaceName string
// CanonicalKey stores the policy-derived canonical form of RaceName.
// It is the join key used by the Race Name Directory and downstream
// readers (capability evaluation, cascade release) so they never need
// to re-derive canonical keys from the original-casing RaceName.
CanonicalKey string
// Status stores the current membership status.
Status Status
// JoinedAt stores when the record entered the active status.
JoinedAt time.Time
// RemovedAt stores when the record transitioned out of active. It is
// nil while the membership is still active.
RemovedAt *time.Time
}
// NewMembershipInput groups all fields required to create an active
// membership record.
type NewMembershipInput struct {
// MembershipID identifies the new record.
MembershipID common.MembershipID
// GameID identifies the game the member is joining.
GameID common.GameID
// UserID stores the platform user id of the member.
UserID string
// RaceName stores the confirmed in-game name reserved for the member
// in its original casing.
RaceName string
// CanonicalKey stores the policy-derived canonical form of RaceName.
// Callers obtain it from RaceNameDirectory.Canonicalize before
// constructing the membership so that the record carries the same key
// the directory uses internally.
CanonicalKey string
// Now stores the creation wall-clock used for JoinedAt.
Now time.Time
}
// New validates input and returns an active Membership record. Validation
// errors are returned verbatim so callers can surface them as
// invalid_request.
func New(input NewMembershipInput) (Membership, error) {
if err := input.Validate(); err != nil {
return Membership{}, err
}
record := Membership{
MembershipID: input.MembershipID,
GameID: input.GameID,
UserID: strings.TrimSpace(input.UserID),
RaceName: strings.TrimSpace(input.RaceName),
CanonicalKey: strings.TrimSpace(input.CanonicalKey),
Status: StatusActive,
JoinedAt: input.Now.UTC(),
}
if err := record.Validate(); err != nil {
return Membership{}, err
}
return record, nil
}
// Validate reports whether input satisfies the frozen membership-record
// invariants required to construct an active record.
func (input NewMembershipInput) Validate() error {
if err := input.MembershipID.Validate(); err != nil {
return fmt.Errorf("membership id: %w", err)
}
if err := input.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(input.UserID) == "" {
return fmt.Errorf("user id must not be empty")
}
if strings.TrimSpace(input.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if strings.TrimSpace(input.CanonicalKey) == "" {
return fmt.Errorf("canonical key must not be empty")
}
if input.Now.IsZero() {
return fmt.Errorf("now must not be zero")
}
return nil
}
// Validate reports whether record satisfies the full invariants.
// Every marshal and unmarshal round-trip calls Validate to guarantee that
// the Redis store never exposes malformed records.
func (record Membership) Validate() error {
if err := record.MembershipID.Validate(); err != nil {
return fmt.Errorf("membership id: %w", err)
}
if err := record.GameID.Validate(); err != nil {
return fmt.Errorf("game id: %w", err)
}
if strings.TrimSpace(record.UserID) == "" {
return fmt.Errorf("user id must not be empty")
}
if strings.TrimSpace(record.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if strings.TrimSpace(record.CanonicalKey) == "" {
return fmt.Errorf("canonical key must not be empty")
}
if strings.TrimSpace(record.CanonicalKey) != record.CanonicalKey {
return fmt.Errorf("canonical key must not contain surrounding whitespace")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if record.JoinedAt.IsZero() {
return fmt.Errorf("joined at must not be zero")
}
if record.Status == StatusActive {
if record.RemovedAt != nil {
return fmt.Errorf("removed at must be nil for active memberships")
}
} else {
if record.RemovedAt == nil {
return fmt.Errorf("removed at must not be nil for %q memberships", record.Status)
}
if record.RemovedAt.IsZero() {
return fmt.Errorf("removed at must not be zero when present")
}
if record.RemovedAt.Before(record.JoinedAt) {
return fmt.Errorf("removed at must not be before joined at")
}
}
return nil
}
@@ -0,0 +1,80 @@
package membership
// Status identifies one lifecycle state of a Game Lobby membership record.
type Status string
const (
// StatusActive reports that the member is a full participant and may
// send commands through Game Master.
StatusActive Status = "active"
// StatusRemoved reports that the member was removed post-start. The
// engine slot is deactivated; the race name reservation is preserved
// until the game finishes.
StatusRemoved Status = "removed"
// StatusBlocked reports that the member is blocked at the platform
// level. The engine slot is retained; commands are blocked.
StatusBlocked Status = "blocked"
)
// IsKnown reports whether status belongs to the frozen membership status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusActive, StatusRemoved, StatusBlocked:
return true
default:
return false
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions.
func (status Status) IsTerminal() bool {
switch status {
case StatusRemoved, StatusBlocked:
return true
default:
return false
}
}
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions stores the set of permitted `(from, to)` status pairs.
// It mirrors the state machine frozen in lobby/README.md Membership Model
// section.
var allowedTransitions = map[transitionKey]struct{}{
{StatusActive, StatusRemoved}: {},
{StatusActive, StatusBlocked}: {},
}
// AllowedTransitions returns a copy of the `(from, to)` allowed-transitions
// table used by Transition. The returned map is safe to mutate.
func AllowedTransitions() map[Status][]Status {
result := make(map[Status][]Status)
for key := range allowedTransitions {
result[key.from] = append(result[key.from], key.to)
}
return result
}
// Transition reports whether from may transition to next. The function
// returns nil when the pair is permitted, and an *InvalidTransitionError
// wrapping ErrInvalidTransition otherwise. It does not touch any store and
// is safe to call from any layer.
func Transition(from Status, next Status) error {
if !from.IsKnown() || !next.IsKnown() {
return &InvalidTransitionError{From: from, To: next}
}
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
return &InvalidTransitionError{From: from, To: next}
}
return nil
}