feat: game lobby service
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind classifiers for RaceNameDirectory bindings.
|
||||
const (
|
||||
// KindRegistered identifies a permanent user-owned race name in the
|
||||
// Race Name Directory.
|
||||
KindRegistered = "registered"
|
||||
|
||||
// KindReservation identifies a per-game race name binding created at
|
||||
// application approval or invite redeem.
|
||||
KindReservation = "reservation"
|
||||
|
||||
// KindPendingRegistration identifies a reservation that survived a
|
||||
// capable game finish and is now waiting for lobby.race_name.register
|
||||
// within its 30-day window.
|
||||
KindPendingRegistration = "pending_registration"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by RaceNameDirectory implementations. Callers
|
||||
// map them to stable lobby error codes (name_taken, invalid_request,
|
||||
// race_name_pending_window_expired, race_name_registration_quota_exceeded).
|
||||
var (
|
||||
// ErrNameTaken is returned when raceName is already held by a different
|
||||
// user as registered, active reservation, or pending_registration on
|
||||
// the same canonical key.
|
||||
ErrNameTaken = errors.New("race name is already taken")
|
||||
|
||||
// ErrInvalidName is returned when raceName fails character-set
|
||||
// validation, when the canonical key cannot be derived, or when a
|
||||
// subsequent MarkPendingRegistration is invoked with a different
|
||||
// eligibleUntil than the one already stored.
|
||||
ErrInvalidName = errors.New("race name is invalid")
|
||||
|
||||
// ErrPendingMissing is returned by Register when no pending
|
||||
// registration exists for the supplied (game_id, user_id, race_name)
|
||||
// tuple.
|
||||
ErrPendingMissing = errors.New("pending race-name registration missing")
|
||||
|
||||
// ErrPendingExpired is returned by Register when the pending
|
||||
// registration exists but its eligible_until has passed.
|
||||
ErrPendingExpired = errors.New("pending race-name registration expired")
|
||||
|
||||
// ErrQuotaExceeded is reserved for callers that enforce the
|
||||
// max_registered_race_names limit before invoking Register; directory
|
||||
// implementations do not raise it directly.
|
||||
ErrQuotaExceeded = errors.New("race name registration quota exceeded")
|
||||
)
|
||||
|
||||
// Availability reports whether raceName is taken for the acting user and,
|
||||
// when taken, who holds it and under which kind of binding.
|
||||
type Availability struct {
|
||||
// Taken is true when any binding on the canonical key belongs to a
|
||||
// user different from the actor.
|
||||
Taken bool
|
||||
|
||||
// HolderUserID carries the owning user id of the strongest existing
|
||||
// binding on the canonical key, or the empty string when no binding
|
||||
// exists.
|
||||
HolderUserID string
|
||||
|
||||
// Kind identifies the strongest existing binding on the canonical key
|
||||
// as one of KindRegistered, KindPendingRegistration, KindReservation,
|
||||
// or the empty string when no binding exists.
|
||||
Kind string
|
||||
}
|
||||
|
||||
// RegisteredName describes one registered race name owned by a user.
|
||||
type RegisteredName struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// SourceGameID is the game whose capable finish produced the pending
|
||||
// entry that was converted into this registration.
|
||||
SourceGameID string
|
||||
|
||||
// RegisteredAtMs is the Unix-milliseconds timestamp of the successful
|
||||
// Register call.
|
||||
RegisteredAtMs int64
|
||||
}
|
||||
|
||||
// Reservation describes one active per-game race name reservation owned by
|
||||
// a user. Reservations with status pending_registration are returned by
|
||||
// ListPendingRegistrations instead.
|
||||
type Reservation struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// GameID is the game hosting the reservation.
|
||||
GameID string
|
||||
|
||||
// ReservedAtMs is the Unix-milliseconds timestamp of the first
|
||||
// successful Reserve call for the tuple.
|
||||
ReservedAtMs int64
|
||||
}
|
||||
|
||||
// PendingRegistration describes one reservation that was promoted to
|
||||
// pending_registration at a capable game finish.
|
||||
type PendingRegistration struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// GameID is the source game whose capable finish produced the pending
|
||||
// entry.
|
||||
GameID string
|
||||
|
||||
// ReservedAtMs is the Unix-milliseconds timestamp of the original
|
||||
// Reserve call; it is preserved across the pending-registration
|
||||
// promotion.
|
||||
ReservedAtMs int64
|
||||
|
||||
// EligibleUntilMs is the Unix-milliseconds deadline for an eligible
|
||||
// Register call. After this moment ExpirePendingRegistrations releases
|
||||
// the entry.
|
||||
EligibleUntilMs int64
|
||||
}
|
||||
|
||||
// ExpiredPending describes one pending registration released by a single
|
||||
// ExpirePendingRegistrations pass. The slice lets callers emit telemetry
|
||||
// and cascade release side effects without a second round-trip.
|
||||
type ExpiredPending struct {
|
||||
// CanonicalKey is the policy-produced uniqueness key.
|
||||
CanonicalKey string
|
||||
|
||||
// RaceName is the original-casing user-submitted display form.
|
||||
RaceName string
|
||||
|
||||
// GameID is the source game that produced the pending entry.
|
||||
GameID string
|
||||
|
||||
// UserID is the holder of the released pending entry.
|
||||
UserID string
|
||||
|
||||
// EligibleUntilMs is the Unix-milliseconds deadline that lapsed.
|
||||
EligibleUntilMs int64
|
||||
}
|
||||
|
||||
// RaceNameDirectory is the platform source of truth for in-game race_name
|
||||
// values across three levels of state: registered (permanent, one per
|
||||
// user), reservation (per-game holding), and pending_registration (30-day
|
||||
// post-capable-finish window). It owns canonical-key derivation through
|
||||
// the lobby/internal/domain/racename policy and arbitrates platform-wide
|
||||
// uniqueness so that a name considered taken for one user remains
|
||||
// exclusively bound to that user across registered, active reservations,
|
||||
// and pending registrations sharing the same canonical key.
|
||||
//
|
||||
// One user may hold the same canonical key concurrently across multiple
|
||||
// active games; cross-user conflicts surface as ErrNameTaken.
|
||||
type RaceNameDirectory interface {
|
||||
// Canonicalize normalizes raceName through the lobby RND policy
|
||||
// (character-set validation, Unicode case fold, frozen confusable-pair
|
||||
// map). Invalid raceName values surface as ErrInvalidName.
|
||||
Canonicalize(raceName string) (canonical string, err error)
|
||||
|
||||
// Check reports whether raceName is taken for actorUserID on its
|
||||
// canonical key. Taken is false when no binding exists or when the
|
||||
// existing binding is owned by actorUserID; HolderUserID and Kind
|
||||
// carry the existing binding's metadata regardless of the Taken value.
|
||||
// A concurrent Reserve may race against a Check result, so service
|
||||
// code that needs atomicity must rely on Reserve returning
|
||||
// ErrNameTaken rather than pre-checking.
|
||||
Check(ctx context.Context, raceName, actorUserID string) (Availability, error)
|
||||
|
||||
// Reserve claims raceName for (gameID, userID). A second call by the
|
||||
// same holder for the same tuple is idempotent and returns nil.
|
||||
// ErrNameTaken is returned when any registered, reservation, or
|
||||
// pending_registration binding on the canonical key is owned by a
|
||||
// different user (in any game).
|
||||
Reserve(ctx context.Context, gameID, userID, raceName string) error
|
||||
|
||||
// ReleaseReservation removes the reservation held by userID for
|
||||
// raceName in gameID. It is a no-op when no reservation exists for
|
||||
// the tuple, when the reservation is held by a different user, and
|
||||
// when raceName fails validation; none of these cases surface an
|
||||
// error. Defensive release paths rely on these semantics.
|
||||
ReleaseReservation(ctx context.Context, gameID, userID, raceName string) error
|
||||
|
||||
// MarkPendingRegistration promotes the reservation stored for
|
||||
// (gameID, userID) on raceName's canonical key to
|
||||
// pending_registration status with the supplied eligibleUntil. A
|
||||
// second call with the same eligibleUntil is a no-op; a call with a
|
||||
// different eligibleUntil returns ErrInvalidName. Callers must
|
||||
// ensure the underlying reservation exists.
|
||||
MarkPendingRegistration(
|
||||
ctx context.Context,
|
||||
gameID, userID, raceName string,
|
||||
eligibleUntil time.Time,
|
||||
) error
|
||||
|
||||
// ExpirePendingRegistrations releases every pending registration whose
|
||||
// eligibleUntil is at or before now and returns the released entries
|
||||
// in a single slice so callers can emit metrics or notifications.
|
||||
// Running twice over the same state returns an empty slice the second
|
||||
// time.
|
||||
ExpirePendingRegistrations(ctx context.Context, now time.Time) ([]ExpiredPending, error)
|
||||
|
||||
// Register converts the pending registration identified by (gameID,
|
||||
// userID) on raceName's canonical key into a registered race name.
|
||||
// Missing pending returns ErrPendingMissing; expired pending returns
|
||||
// ErrPendingExpired. A second call after a successful registration
|
||||
// for the same tuple is idempotent and returns nil. Quota enforcement
|
||||
// is the caller's responsibility.
|
||||
Register(ctx context.Context, gameID, userID, raceName string) error
|
||||
|
||||
// ListRegistered returns every registered race name owned by userID.
|
||||
// The ordering is implementation-defined.
|
||||
ListRegistered(ctx context.Context, userID string) ([]RegisteredName, error)
|
||||
|
||||
// ListPendingRegistrations returns every pending registration owned by
|
||||
// userID. The ordering is implementation-defined.
|
||||
ListPendingRegistrations(ctx context.Context, userID string) ([]PendingRegistration, error)
|
||||
|
||||
// ListReservations returns every active reservation owned by userID
|
||||
// whose status has not yet been promoted to pending_registration. The
|
||||
// ordering is implementation-defined.
|
||||
ListReservations(ctx context.Context, userID string) ([]Reservation, error)
|
||||
|
||||
// ReleaseAllByUser atomically clears every registered, reservation,
|
||||
// and pending_registration binding owned by userID. It is invoked by
|
||||
// the user lifecycle consumer on permanent_blocked and deleted events.
|
||||
// Running twice over the same state is safe and produces no
|
||||
// additional side effects.
|
||||
ReleaseAllByUser(ctx context.Context, userID string) error
|
||||
}
|
||||
Reference in New Issue
Block a user