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