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