feat: game lobby service
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
// 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user