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 }