// Package listmyracenames implements the `lobby.race_names.list` // message type. It returns the acting user's view of the Race Name // Directory across all three levels of binding: permanent registered // names, pending_registration entries waiting for their 30-day window // to elapse, and active per-game reservations. // // The service is intentionally thin: it consults the user-keyed RND // indexes through the port (no full scan of the directory) and joins // each active reservation with the current `game.Status` from the // Game Store so callers can render the reservation alongside the // game it belongs to. No User Service eligibility lookup is required — // reading one's own name set is unconditional. package listmyracenames import ( "context" "errors" "fmt" "log/slog" "sort" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // Service executes the race-name self-service read use case. type Service struct { directory ports.RaceNameDirectory games ports.GameStore logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Directory exposes the user-keyed RND indexes (registered, // pending_registration, reservation) that satisfy the no-full-scan // requirement of `lobby.race_names.list`. Directory ports.RaceNameDirectory // Games supplies the current `game.Status` of each active // reservation so the response can render it next to the // game id (README §Race Name self-service). Games ports.GameStore // Logger records structural diagnostics. Defaults to slog.Default // when nil. Logger *slog.Logger } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Directory == nil: return nil, errors.New("new list my race names service: nil race name directory") case deps.Games == nil: return nil, errors.New("new list my race names service: nil game store") } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ directory: deps.Directory, games: deps.Games, logger: logger.With("service", "lobby.listmyracenames"), }, nil } // Input stores the arguments required to compute the acting user's // race-name view. type Input struct { // Actor identifies the caller. Must be ActorKindUser; the // `lobby.race_names.list` route is exclusively self-service and // admin actors are rejected. Actor shared.Actor } // RegisteredName describes one permanent race name owned by the // acting user. Mirrors `ports.RegisteredName` field-for-field so the // transport can return them as-is. type RegisteredName struct { CanonicalKey string RaceName string SourceGameID string RegisteredAtMs int64 } // PendingRegistration describes one promoted reservation waiting for // the acting user to convert it into a registered name. type PendingRegistration struct { CanonicalKey string RaceName string SourceGameID string ReservedAtMs int64 EligibleUntilMs int64 } // Reservation describes one active per-game reservation owned by the // acting user. GameStatus carries the current `game.Status` of the // hosting game (empty when the game record could not be loaded). type Reservation struct { CanonicalKey string RaceName string GameID string ReservedAtMs int64 GameStatus string } // Output stores the three slices returned by Handle. Each slice is // non-nil but possibly empty. Items inside one slice are ordered by // the relevant timestamp ascending, then by canonical key, so output // is stable for tests and UI alike. type Output struct { Registered []RegisteredName Pending []PendingRegistration Reservations []Reservation } // Handle authorizes the actor as a user, fetches the three RND views // for that user, and joins each reservation with the current game // status. func (service *Service) Handle(ctx context.Context, input Input) (Output, error) { if service == nil { return Output{}, errors.New("list my race names: nil service") } if ctx == nil { return Output{}, errors.New("list my race names: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("list my race names: actor: %w", err) } if !input.Actor.IsUser() { return Output{}, fmt.Errorf( "%w: only authenticated user actors may list their race names", shared.ErrForbidden, ) } registered, err := service.directory.ListRegistered(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("list my race names: %w", err) } pending, err := service.directory.ListPendingRegistrations(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("list my race names: %w", err) } reservations, err := service.directory.ListReservations(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("list my race names: %w", err) } out := Output{ Registered: make([]RegisteredName, 0, len(registered)), Pending: make([]PendingRegistration, 0, len(pending)), Reservations: make([]Reservation, 0, len(reservations)), } for _, entry := range registered { out.Registered = append(out.Registered, RegisteredName{ CanonicalKey: entry.CanonicalKey, RaceName: entry.RaceName, SourceGameID: entry.SourceGameID, RegisteredAtMs: entry.RegisteredAtMs, }) } for _, entry := range pending { out.Pending = append(out.Pending, PendingRegistration{ CanonicalKey: entry.CanonicalKey, RaceName: entry.RaceName, SourceGameID: entry.GameID, ReservedAtMs: entry.ReservedAtMs, EligibleUntilMs: entry.EligibleUntilMs, }) } for _, entry := range reservations { status := service.lookupGameStatus(ctx, common.GameID(entry.GameID), input.Actor.UserID) out.Reservations = append(out.Reservations, Reservation{ CanonicalKey: entry.CanonicalKey, RaceName: entry.RaceName, GameID: entry.GameID, ReservedAtMs: entry.ReservedAtMs, GameStatus: status, }) } sortRegistered(out.Registered) sortPending(out.Pending) sortReservations(out.Reservations) return out, nil } // lookupGameStatus returns the current game status for gameID. When // the game record cannot be loaded — including the defensive // game.ErrNotFound case — the empty string is returned and the miss // is logged so operators can investigate dangling RND state. func (service *Service) lookupGameStatus( ctx context.Context, gameID common.GameID, userID string, ) string { record, err := service.games.Get(ctx, gameID) if err == nil { return string(record.Status) } level := slog.LevelWarn if errors.Is(err, game.ErrNotFound) { service.logger.Log(ctx, level, "reservation references missing game", "user_id", userID, "game_id", gameID.String(), ) return "" } service.logger.Log(ctx, level, "load reservation game status", "user_id", userID, "game_id", gameID.String(), "err", err.Error(), ) return "" } func sortRegistered(items []RegisteredName) { sort.Slice(items, func(i, j int) bool { if items[i].RegisteredAtMs != items[j].RegisteredAtMs { return items[i].RegisteredAtMs < items[j].RegisteredAtMs } return items[i].CanonicalKey < items[j].CanonicalKey }) } func sortPending(items []PendingRegistration) { sort.Slice(items, func(i, j int) bool { if items[i].EligibleUntilMs != items[j].EligibleUntilMs { return items[i].EligibleUntilMs < items[j].EligibleUntilMs } return items[i].CanonicalKey < items[j].CanonicalKey }) } func sortReservations(items []Reservation) { sort.Slice(items, func(i, j int) bool { if items[i].ReservedAtMs != items[j].ReservedAtMs { return items[i].ReservedAtMs < items[j].ReservedAtMs } return items[i].CanonicalKey < items[j].CanonicalKey }) }