009ea560f9
Reshape the lobby UI from a single Overview into a two-level sidebar (games · profile · DEV synthetic-reports) with four games sub-panels (active-past · recruitment · invitations · private-games). Move the `create new game` button into the private-games panel, merge the applications section into recruitment cards as status chips, and add DEV-only synthetic-report loader as a top-level screen. Add a paid-tier gate at backend `lobby.game.create`: free callers get `403 forbidden` before the lobby service is invoked. The UI hides the private-games sub-panel + create button on free tier (DEV affordances flag overrides). Update every integration test that creates a game to use a new `testenv.PromoteToPaid` helper; add a new `TestLobbyFlow_FreeUserCreateGameForbidden`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
6.8 KiB
Go
187 lines
6.8 KiB
Go
package lobby
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// EntitlementProvider is the read-only view the lobby needs over the
|
|
// user-domain entitlement snapshot. The canonical implementation is
|
|
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
|
|
// substitute a fake.
|
|
//
|
|
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
|
|
// — when the caller attempts to register a `pending_registration` row the
|
|
// lobby counts already-`registered` rows for that user against this limit.
|
|
//
|
|
// `IsPaid` is consumed by the user-facing private-game creation gate at
|
|
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
|
|
// callers are rejected with `403 forbidden` before the lobby Service is
|
|
// invoked. Admin-driven public-game creation
|
|
// (`POST /api/v1/admin/games`) bypasses the gate.
|
|
type EntitlementProvider interface {
|
|
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
|
|
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
|
|
// module to start, pause, resume, or stop an engine container. The real
|
|
// implementation lives in `backend/internal/runtime` ; until
|
|
// then `NewNoopRuntimeGateway` ships a logger-only stub that pretends the
|
|
// request was accepted so the lobby state machine stays exercisable
|
|
// end-to-end.
|
|
type RuntimeGateway interface {
|
|
StartGame(ctx context.Context, gameID uuid.UUID) error
|
|
StopGame(ctx context.Context, gameID uuid.UUID) error
|
|
PauseGame(ctx context.Context, gameID uuid.UUID) error
|
|
ResumeGame(ctx context.Context, gameID uuid.UUID) error
|
|
}
|
|
|
|
// RuntimeJobResult is the inbound shape used by the runtime reconciler
|
|
// when a labelled container that lobby believes is alive has
|
|
// disappeared. The wiring connects `Service.OnRuntimeJobResult` against
|
|
// this type; the no-op consumer logs the event at debug level.
|
|
type RuntimeJobResult struct {
|
|
Op string
|
|
Status string
|
|
Message string
|
|
}
|
|
|
|
// NotificationPublisher is the outbound surface the lobby uses to fan out
|
|
// notification intents (invite received, application submitted, race name
|
|
// promoted, etc.). The real implementation lives in
|
|
// `backend/internal/notification` ; until then
|
|
// `NewNoopNotificationPublisher` ships a logger-only stub.
|
|
type NotificationPublisher interface {
|
|
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
|
}
|
|
|
|
// DiplomailPublisher is the outbound surface the lobby uses to drop a
|
|
// durable system mail entry whenever a game-state or
|
|
// membership-state transition needs to land in the affected players'
|
|
// inboxes. The real implementation in `cmd/backend/main` adapts the
|
|
// `*diplomail.Service.PublishLifecycle` call; tests and partial
|
|
// wiring fall back to `NewNoopDiplomailPublisher`.
|
|
type DiplomailPublisher interface {
|
|
PublishLifecycle(ctx context.Context, event LifecycleEvent) error
|
|
}
|
|
|
|
// LifecycleEvent is the open shape carried by a system-mail intent.
|
|
// `Kind` is one of the lobby-internal constants
|
|
// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only
|
|
// for membership-scoped events; the publisher derives the game-scoped
|
|
// recipient set itself.
|
|
type LifecycleEvent struct {
|
|
GameID uuid.UUID
|
|
Kind string
|
|
Actor string
|
|
Reason string
|
|
TargetUser *uuid.UUID
|
|
}
|
|
|
|
// Lifecycle-event kinds the lobby emits.
|
|
const (
|
|
LifecycleKindGamePaused = "game.paused"
|
|
LifecycleKindGameCancelled = "game.cancelled"
|
|
LifecycleKindMembershipRemoved = "membership.removed"
|
|
LifecycleKindMembershipBlocked = "membership.blocked"
|
|
)
|
|
|
|
// LobbyNotification is the open shape carried by a notification intent.
|
|
// The implementation emits a small set of `Kind` values matching the catalog in
|
|
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
|
// blob; recipients are the user_ids the intent should reach.
|
|
//
|
|
// The struct lives in the lobby package on purpose: it is the producer
|
|
// vocabulary. The implementation will reuse it as the notification.Submit input
|
|
// (or wrap it in a domain-side type, if more channels show up).
|
|
type LobbyNotification struct {
|
|
Kind string
|
|
IdempotencyKey string
|
|
Recipients []uuid.UUID
|
|
Payload map[string]any
|
|
}
|
|
|
|
// NewNoopRuntimeGateway returns a RuntimeGateway that logs every call at
|
|
// debug level and returns nil. The lobby state machine treats the no-op
|
|
// as "request was accepted asynchronously" — the game stays in `starting`
|
|
// until the canonical implementation wires real `runtime` / `OnRuntimeSnapshot` interactions.
|
|
func NewNoopRuntimeGateway(logger *zap.Logger) RuntimeGateway {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &noopRuntimeGateway{logger: logger.Named("lobby.runtime.noop")}
|
|
}
|
|
|
|
type noopRuntimeGateway struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (g *noopRuntimeGateway) StartGame(_ context.Context, gameID uuid.UUID) error {
|
|
g.logger.Debug("noop start-game", zap.String("game_id", gameID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (g *noopRuntimeGateway) StopGame(_ context.Context, gameID uuid.UUID) error {
|
|
g.logger.Debug("noop stop-game", zap.String("game_id", gameID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (g *noopRuntimeGateway) PauseGame(_ context.Context, gameID uuid.UUID) error {
|
|
g.logger.Debug("noop pause-game", zap.String("game_id", gameID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (g *noopRuntimeGateway) ResumeGame(_ context.Context, gameID uuid.UUID) error {
|
|
g.logger.Debug("noop resume-game", zap.String("game_id", gameID.String()))
|
|
return nil
|
|
}
|
|
|
|
// NewNoopNotificationPublisher returns a NotificationPublisher that logs
|
|
// every call at debug level and returns nil. The implementation will swap in a
|
|
// real publisher backed by `notification.Submit`.
|
|
func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &noopNotificationPublisher{logger: logger.Named("lobby.notify.noop")}
|
|
}
|
|
|
|
type noopNotificationPublisher struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent LobbyNotification) error {
|
|
p.logger.Debug("noop notification",
|
|
zap.String("kind", intent.Kind),
|
|
zap.String("idempotency_key", intent.IdempotencyKey),
|
|
zap.Int("recipients", len(intent.Recipients)),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs
|
|
// every call at debug level and returns nil. Used by tests and by
|
|
// the lobby Service factory when the Deps.Diplomail field is left
|
|
// nil.
|
|
func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")}
|
|
}
|
|
|
|
type noopDiplomailPublisher struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error {
|
|
p.logger.Debug("noop diplomail lifecycle",
|
|
zap.String("kind", event.Kind),
|
|
zap.String("game_id", event.GameID.String()),
|
|
)
|
|
return nil
|
|
}
|