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