Files
galaxy-game/lobby/internal/ports/racenamedir.go
T
2026-04-25 23:20:55 +02:00

239 lines
9.7 KiB
Go

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
}