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