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
+7 -82
View File
@@ -3,7 +3,6 @@ package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
@@ -14,9 +13,6 @@ import (
type CreateAccountInput struct {
// Account stores the durable user-account state.
Account account.UserAccount
// Reservation stores the canonical race-name reservation linked to Account.
Reservation account.RaceNameReservation
}
// Validate reports whether CreateAccountInput is structurally complete.
@@ -24,61 +20,6 @@ func (input CreateAccountInput) Validate() error {
if err := input.Account.Validate(); err != nil {
return fmt.Errorf("create account input account: %w", err)
}
if err := input.Reservation.Validate(); err != nil {
return fmt.Errorf("create account input reservation: %w", err)
}
if input.Account.UserID != input.Reservation.UserID {
return fmt.Errorf("create account input reservation user id must match account user id")
}
if input.Account.RaceName != input.Reservation.RaceName {
return fmt.Errorf("create account input reservation race name must match account race name")
}
return nil
}
// RenameRaceNameInput stores the atomic state required to replace one stored
// race name and its canonical reservation.
type RenameRaceNameInput struct {
// UserID identifies the account that must be updated.
UserID common.UserID
// CurrentCanonicalKey stores the currently owned canonical reservation key.
CurrentCanonicalKey account.RaceNameCanonicalKey
// NewRaceName stores the replacement exact stored race name.
NewRaceName common.RaceName
// NewReservation stores the replacement canonical reservation.
NewReservation account.RaceNameReservation
// UpdatedAt stores the account mutation timestamp.
UpdatedAt time.Time
}
// Validate reports whether RenameRaceNameInput is structurally complete.
func (input RenameRaceNameInput) Validate() error {
if err := input.UserID.Validate(); err != nil {
return fmt.Errorf("rename race name input user id: %w", err)
}
if err := input.CurrentCanonicalKey.Validate(); err != nil {
return fmt.Errorf("rename race name input current canonical key: %w", err)
}
if err := input.NewRaceName.Validate(); err != nil {
return fmt.Errorf("rename race name input race name: %w", err)
}
if err := input.NewReservation.Validate(); err != nil {
return fmt.Errorf("rename race name input reservation: %w", err)
}
if err := common.ValidateTimestamp("rename race name input updated at", input.UpdatedAt); err != nil {
return err
}
if input.NewReservation.UserID != input.UserID {
return fmt.Errorf("rename race name input reservation user id must match user id")
}
if input.NewReservation.RaceName != input.NewRaceName {
return fmt.Errorf("rename race name input reservation race name must match new race name")
}
return nil
}
@@ -87,7 +28,7 @@ func (input RenameRaceNameInput) Validate() error {
// exact lookup mappings.
type UserAccountStore interface {
// Create stores one new account record. Implementations must wrap
// ErrConflict when the user id, e-mail, or exact race-name lookup already
// ErrConflict when the user id, e-mail, or exact user-name lookup already
// exists.
Create(ctx context.Context, input CreateAccountInput) error
@@ -98,33 +39,17 @@ type UserAccountStore interface {
// address.
GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error)
// GetByRaceName returns the stored account identified by the exact stored
// race name.
GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error)
// GetByUserName returns the stored account identified by the exact stored
// user name.
GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error)
// ExistsByUserID reports whether userID currently identifies a stored
// account.
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
// RenameRaceName replaces the stored race name of userID and swaps the
// exact race-name lookup atomically. Implementations must wrap ErrConflict
// when newRaceName is already owned by another account.
RenameRaceName(ctx context.Context, input RenameRaceNameInput) error
// Update replaces the stored account state for record.UserID.
//
// Implementations must wrap ErrConflict when the replacement record
// attempts to mutate `user_name` or `email`.
Update(ctx context.Context, record account.UserAccount) error
}
// RaceNameReservationStore persists source-of-truth race-name reservations.
type RaceNameReservationStore interface {
// Create stores one new race-name reservation keyed by its canonical
// uniqueness key. Implementations must wrap ErrConflict when the canonical
// key is already reserved.
Create(ctx context.Context, record account.RaceNameReservation) error
// GetByCanonicalKey returns the stored reservation identified by key.
GetByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) (account.RaceNameReservation, error)
// DeleteByCanonicalKey removes the reservation identified by key.
DeleteByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) error
}
@@ -130,9 +130,6 @@ type EnsureByEmailInput struct {
// EntitlementRecord stores the initial entitlement history record that must
// be created atomically with Entitlement.
EntitlementRecord entitlement.PeriodRecord
// Reservation stores the canonical race-name reservation for Account.
Reservation account.RaceNameReservation
}
// Validate reports whether EnsureByEmailInput is structurally complete.
@@ -149,9 +146,6 @@ func (input EnsureByEmailInput) Validate() error {
if err := input.EntitlementRecord.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input entitlement record: %w", err)
}
if err := input.Reservation.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input reservation: %w", err)
}
if input.Account.Email != input.Email {
return fmt.Errorf("ensure-by-email input account email must match request email")
}
@@ -161,12 +155,6 @@ func (input EnsureByEmailInput) Validate() error {
if input.Account.UserID != input.EntitlementRecord.UserID {
return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id")
}
if input.Account.UserID != input.Reservation.UserID {
return fmt.Errorf("ensure-by-email input account user id must match reservation user id")
}
if input.Account.RaceName != input.Reservation.RaceName {
return fmt.Errorf("ensure-by-email input account race name must match reservation race name")
}
if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode {
return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code")
}
+12 -4
View File
@@ -182,8 +182,13 @@ type ProfileChangedEvent struct {
// Operation stores the profile-change event kind.
Operation ProfileChangedOperation
// RaceName stores the latest exact race name after the commit.
RaceName common.RaceName
// UserName stores the immutable handle associated with the account at the
// moment the event is published.
UserName common.UserName
// DisplayName stores the latest display name after the commit. An empty
// value is valid and means no display name is set.
DisplayName common.DisplayName
}
// Validate reports whether event is structurally complete.
@@ -194,8 +199,11 @@ func (event ProfileChangedEvent) Validate() error {
if !event.Operation.IsKnown() {
return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation)
}
if err := event.RaceName.Validate(); err != nil {
return fmt.Errorf("profile changed event race name: %w", err)
if err := event.UserName.Validate(); err != nil {
return fmt.Errorf("profile changed event user name: %w", err)
}
if err := event.DisplayName.Validate(); err != nil {
return fmt.Errorf("profile changed event display name: %w", err)
}
return nil
+4 -4
View File
@@ -22,10 +22,10 @@ var (
)
var (
// ErrRaceNameConflict reports that a mutation specifically failed because a
// race-name lookup or canonical reservation is already owned by another
// user. The sentinel still matches ErrConflict via errors.Is so callers can
// ErrUserNameConflict reports that a mutation specifically failed because
// the auto-generated `user_name` lookup is already owned by another user.
// The sentinel still matches ErrConflict via errors.Is so callers can
// preserve the stable public conflict semantics while collecting more
// precise observability.
ErrRaceNameConflict = fmt.Errorf("%w: race name conflict", ErrConflict)
ErrUserNameConflict = fmt.Errorf("%w: user name conflict", ErrConflict)
)
+5 -4
View File
@@ -6,14 +6,15 @@ import (
"galaxy/user/internal/domain/policy"
)
// IDGenerator creates new user identifiers and generated initial race names.
// IDGenerator creates new user identifiers and auto-generated user names.
type IDGenerator interface {
// NewUserID returns one newly generated stable user identifier.
NewUserID() (common.UserID, error)
// NewInitialRaceName returns one generated initial race name in the
// `player-<shortid>` form.
NewInitialRaceName() (common.RaceName, error)
// NewUserName returns one generated immutable user name in the
// `player-<suffix>` form. The suffix is eight characters drawn from a
// confusable-free alphanumeric alphabet.
NewUserName() (common.UserName, error)
// NewEntitlementRecordID returns one newly generated entitlement history
// record identifier.
-14
View File
@@ -1,14 +0,0 @@
package ports
import (
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
)
// RaceNamePolicy produces the canonical uniqueness key used to reserve one
// replaceable race-name slot.
type RaceNamePolicy interface {
// CanonicalKey returns the stable reservation key for raceName. Callers are
// expected to pass a validated raceName value.
CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error)
}
@@ -0,0 +1,99 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// UserLifecycleEventType identifies one user-lifecycle event kind propagated
// to `Game Lobby` through the dedicated Redis Stream.
type UserLifecycleEventType string
const (
// UserLifecyclePermanentBlockedEventType identifies the post-commit event
// emitted when `SanctionCodePermanentBlock` becomes active on an account.
UserLifecyclePermanentBlockedEventType UserLifecycleEventType = "user.lifecycle.permanent_blocked"
// UserLifecycleDeletedEventType identifies the post-commit event emitted
// when a trusted `DeleteUser` command soft-deletes an account.
UserLifecycleDeletedEventType UserLifecycleEventType = "user.lifecycle.deleted"
)
// IsKnown reports whether the event type belongs to the frozen vocabulary.
func (eventType UserLifecycleEventType) IsKnown() bool {
switch eventType {
case UserLifecyclePermanentBlockedEventType, UserLifecycleDeletedEventType:
return true
default:
return false
}
}
// UserLifecycleEvent stores one post-commit user-lifecycle event envelope
// published to the `user:lifecycle_events` Redis Stream and consumed by
// `Game Lobby` for Race Name Directory cascade release.
type UserLifecycleEvent struct {
// EventType stores the frozen lifecycle event discriminator.
EventType UserLifecycleEventType
// UserID identifies the regular user whose lifecycle state changed.
UserID common.UserID
// OccurredAt stores the committed mutation timestamp.
OccurredAt time.Time
// Source stores the machine-readable mutation source. For Stage 22 this is
// always `admin_internal_api`.
Source common.Source
// Actor stores the audit actor metadata attached to the committed
// mutation.
Actor common.ActorRef
// ReasonCode stores the committed reason_code for the mutation.
ReasonCode common.ReasonCode
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
}
// Validate reports whether event is structurally complete.
func (event UserLifecycleEvent) Validate() error {
if !event.EventType.IsKnown() {
return fmt.Errorf("user lifecycle event type %q is unsupported", event.EventType)
}
if err := event.UserID.Validate(); err != nil {
return fmt.Errorf("user lifecycle event user id: %w", err)
}
if err := common.ValidateTimestamp("user lifecycle event occurred at", event.OccurredAt); err != nil {
return err
}
if err := event.Source.Validate(); err != nil {
return fmt.Errorf("user lifecycle event source: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("user lifecycle event actor: %w", err)
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("user lifecycle event reason code: %w", err)
}
if event.TraceID != "" && strings.TrimSpace(event.TraceID) != event.TraceID {
return fmt.Errorf("user lifecycle event trace id must not contain surrounding whitespace")
}
return nil
}
// UserLifecyclePublisher publishes one committed user-lifecycle event to the
// dedicated `user:lifecycle_events` Redis Stream.
type UserLifecyclePublisher interface {
// PublishUserLifecycleEvent propagates one committed lifecycle event. The
// implementation must validate the event envelope and perform exactly one
// idempotent append per call.
PublishUserLifecycleEvent(ctx context.Context, event UserLifecycleEvent) error
}
+49
View File
@@ -21,6 +21,29 @@ const (
MaxUserListPageSize = 200
)
// DisplayNameMatchMode selects between exact and prefix `display_name`
// comparison used by the admin listing filter.
type DisplayNameMatchMode string
const (
// DisplayNameMatchModeExact matches `display_name` exactly after trimming.
DisplayNameMatchModeExact DisplayNameMatchMode = "exact"
// DisplayNameMatchModePrefix matches `display_name` by stored-value prefix
// after trimming.
DisplayNameMatchModePrefix DisplayNameMatchMode = "prefix"
)
// IsKnown reports whether the mode belongs to the supported vocabulary.
func (mode DisplayNameMatchMode) IsKnown() bool {
switch mode {
case DisplayNameMatchModeExact, DisplayNameMatchModePrefix:
return true
default:
return false
}
}
// UserListFilters stores the frozen admin-listing filter set.
type UserListFilters struct {
// PaidState stores the optional coarse free-versus-paid filter.
@@ -43,6 +66,16 @@ type UserListFilters struct {
// LimitCode stores the optional active user-specific limit filter.
LimitCode policy.LimitCode
// UserName stores the optional exact `user_name` filter.
UserName common.UserName
// DisplayName stores the optional `display_name` filter value.
DisplayName common.DisplayName
// DisplayNameMatch selects between exact and prefix comparison for
// DisplayName. The zero value is treated as DisplayNameMatchModeExact.
DisplayNameMatch DisplayNameMatchMode
// CanLogin stores the optional derived login-eligibility filter.
CanLogin *bool
@@ -76,6 +109,22 @@ func (filters UserListFilters) Validate() error {
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
if !filters.UserName.IsZero() {
if err := filters.UserName.Validate(); err != nil {
return fmt.Errorf("user name: %w", err)
}
}
if !filters.DisplayName.IsZero() {
if err := filters.DisplayName.Validate(); err != nil {
return fmt.Errorf("display name: %w", err)
}
}
if filters.DisplayNameMatch != "" && !filters.DisplayNameMatch.IsKnown() {
return fmt.Errorf("display name match mode %q is unsupported", filters.DisplayNameMatch)
}
if filters.DisplayName.IsZero() && filters.DisplayNameMatch != "" {
return fmt.Errorf("display name match mode requires a display_name value")
}
return nil
}