feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,283 @@
// Package registerracename implements the `lobby.race_name.register`
// message type. It converts an active `pending_registration` Race Name
// Directory entry, produced by capability evaluation at game
// finish, into a permanent registered race name owned by the calling
// user. The service enforces the eligibility snapshot
// (`PermanentBlocked`, `MaxRegisteredRaceNames`), delegates atomic
// commit to `RaceNameDirectory.Register`, and publishes a
// `lobby.race_name.registered` notification intent on success.
//
// The 30-day pending window, capable-finish requirement, and one-name
// idempotency are all the directory's responsibility (see
// `lobby/README.md` §Reservation lifecycle and capability). This
// service only adds the user-facing layer: actor authorization, quota
// enforcement, and intent publication.
package registerracename
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/shared"
"galaxy/notificationintent"
)
// Service executes the race-name registration use case.
type Service struct {
directory ports.RaceNameDirectory
users ports.UserService
intents ports.IntentPublisher
clock func() time.Time
logger *slog.Logger
}
// Dependencies groups the collaborators used by Service.
type Dependencies struct {
// Directory atomically converts the pending entry into a registered
// race name. The service consults ListRegistered for the quota
// pre-check and idempotent-retry detection.
Directory ports.RaceNameDirectory
// Users supplies the lobby-relevant eligibility snapshot used to
// gate `PermanentBlocked` and `MaxRegisteredRaceNames`.
Users ports.UserService
// Intents publishes the lobby.race_name.registered intent on
// successful commit. The service treats publication failure as a
// notification-degradation signal: the intent is logged at warn but
// the registration result is returned successfully (README
// §Notification Contracts).
Intents ports.IntentPublisher
// Clock supplies the wall-clock used to anchor the published
// intent's `OccurredAt` and the fallback `RegisteredAtMs` of the
// returned record. Defaults to time.Now when nil.
Clock func() time.Time
// Logger records structured service-level events. 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 register race name service: nil race name directory")
case deps.Users == nil:
return nil, errors.New("new register race name service: nil user service")
case deps.Intents == nil:
return nil, errors.New("new register race name service: nil intent publisher")
}
clock := deps.Clock
if clock == nil {
clock = time.Now
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Service{
directory: deps.Directory,
users: deps.Users,
intents: deps.Intents,
clock: clock,
logger: logger.With("service", "lobby.registerracename"),
}, nil
}
// Input stores the arguments required to register one race name.
type Input struct {
// Actor identifies the caller. Must be ActorKindUser; admin actors
// are rejected because the README catalog scopes
// `lobby.race_name.register` to the authenticated user.
Actor shared.Actor
// SourceGameID identifies the game whose capable finish produced
// the pending registration that the caller wants to convert.
SourceGameID common.GameID
// RaceName is the original-casing name to register. The directory
// canonicalizes and validates it.
RaceName string
}
// Output stores the post-commit registration record returned to the
// caller. Fields mirror ports.RegisteredName so the HTTP layer can
// expose the same shape.
type Output struct {
CanonicalKey string
RaceName string
SourceGameID string
RegisteredAtMs int64
}
// Handle authorizes the actor, gates the eligibility snapshot,
// short-circuits idempotent retries, enforces the per-tariff quota,
// commits the registration through the Race Name Directory, and
// publishes the `lobby.race_name.registered` intent on success.
func (service *Service) Handle(ctx context.Context, input Input) (Output, error) {
if service == nil {
return Output{}, errors.New("register race name: nil service")
}
if ctx == nil {
return Output{}, errors.New("register race name: nil context")
}
if err := input.Actor.Validate(); err != nil {
return Output{}, fmt.Errorf("register race name: actor: %w", err)
}
if !input.Actor.IsUser() {
return Output{}, fmt.Errorf(
"%w: only authenticated user actors may register a race name",
shared.ErrForbidden,
)
}
if err := input.SourceGameID.Validate(); err != nil {
return Output{}, fmt.Errorf("register race name: %w", err)
}
raceName := strings.TrimSpace(input.RaceName)
if raceName == "" {
return Output{}, fmt.Errorf("register race name: race name must not be empty")
}
canonical, err := service.directory.Canonicalize(raceName)
if err != nil {
return Output{}, fmt.Errorf("register race name: %w", err)
}
eligibility, err := service.users.GetEligibility(ctx, input.Actor.UserID)
if err != nil {
return Output{}, fmt.Errorf("register race name: %w", err)
}
if !eligibility.Exists {
return Output{}, fmt.Errorf("register race name: user %q: %w",
input.Actor.UserID, shared.ErrSubjectNotFound)
}
if eligibility.PermanentBlocked {
return Output{}, fmt.Errorf(
"%w: user %q carries an active permanent_block sanction",
shared.ErrForbidden, input.Actor.UserID,
)
}
registered, err := service.directory.ListRegistered(ctx, input.Actor.UserID)
if err != nil {
return Output{}, fmt.Errorf("register race name: %w", err)
}
sourceGameIDStr := input.SourceGameID.String()
if existing, ok := findExistingRegistration(registered, sourceGameIDStr, canonical); ok {
// Idempotent retry: the same caller is replaying the same
// register call after a previous success. Re-publish the
// intent (consumer dedupes on the stable idempotency key) and
// return the existing record.
out := Output{
CanonicalKey: existing.CanonicalKey,
RaceName: existing.RaceName,
SourceGameID: existing.SourceGameID,
RegisteredAtMs: existing.RegisteredAtMs,
}
service.publishRegisteredIntent(ctx, sourceGameIDStr, input.Actor.UserID, existing.RaceName, out.RegisteredAtMs)
return out, nil
}
if eligibility.MaxRegisteredRaceNames > 0 && len(registered) >= eligibility.MaxRegisteredRaceNames {
return Output{}, fmt.Errorf(
"register race name: user %q has %d of %d registered: %w",
input.Actor.UserID, len(registered), eligibility.MaxRegisteredRaceNames,
ports.ErrQuotaExceeded,
)
}
if err := service.directory.Register(ctx, sourceGameIDStr, input.Actor.UserID, raceName); err != nil {
return Output{}, fmt.Errorf("register race name: %w", err)
}
registeredAfter, err := service.directory.ListRegistered(ctx, input.Actor.UserID)
if err != nil {
return Output{}, fmt.Errorf("register race name: load post-commit: %w", err)
}
out := Output{
CanonicalKey: canonical,
RaceName: raceName,
SourceGameID: sourceGameIDStr,
}
if record, ok := findExistingRegistration(registeredAfter, sourceGameIDStr, canonical); ok {
out.RaceName = record.RaceName
out.RegisteredAtMs = record.RegisteredAtMs
} else {
// Defensive: the directory should always expose the entry we
// just registered. Fall back to the wall-clock so the response
// still carries a usable timestamp.
out.RegisteredAtMs = service.clock().UTC().UnixMilli()
}
service.publishRegisteredIntent(ctx, sourceGameIDStr, input.Actor.UserID, out.RaceName, out.RegisteredAtMs)
service.logger.InfoContext(ctx, "race name registered",
"user_id", input.Actor.UserID,
"source_game_id", sourceGameIDStr,
"race_name", out.RaceName,
"canonical_key", out.CanonicalKey,
)
return out, nil
}
// findExistingRegistration returns the registered entry that matches
// (sourceGameID, canonicalKey) when one exists.
func findExistingRegistration(
registered []ports.RegisteredName,
sourceGameID, canonical string,
) (ports.RegisteredName, bool) {
for _, entry := range registered {
if entry.SourceGameID == sourceGameID && entry.CanonicalKey == canonical {
return entry, true
}
}
return ports.RegisteredName{}, false
}
// publishRegisteredIntent builds and publishes the
// `lobby.race_name.registered` notification intent. Failures are
// logged at warn level and do not roll back the registration commit
// (README §Notification Contracts).
func (service *Service) publishRegisteredIntent(
ctx context.Context,
sourceGameID, userID, raceName string,
registeredAtMs int64,
) {
occurredAt := service.clock().UTC()
if registeredAtMs > 0 {
occurredAt = time.UnixMilli(registeredAtMs).UTC()
}
intent, err := notificationintent.NewLobbyRaceNameRegisteredIntent(
notificationintent.Metadata{
IdempotencyKey: "lobby.race_name.registered:" + sourceGameID + ":" + userID,
OccurredAt: occurredAt,
},
userID,
notificationintent.LobbyRaceNameRegisteredPayload{
RaceName: raceName,
},
)
if err != nil {
service.logger.ErrorContext(ctx, "build race name registered intent",
"user_id", userID,
"source_game_id", sourceGameID,
"err", err.Error(),
)
return
}
if _, err := service.intents.Publish(ctx, intent); err != nil {
service.logger.WarnContext(ctx, "publish race name registered intent",
"user_id", userID,
"source_game_id", sourceGameID,
"err", err.Error(),
)
}
}