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

251 lines
7.7 KiB
Go

// 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
})
}