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,410 @@
// Package capabilityevaluation implements the per-membership
// capability evaluation at game finish. The evaluator is invoked by the
// gmevents consumer after the platform game record reaches the
// `finished` status; it walks every membership of the game, decides
// capable/incapable for active members based on the per-game stats
// aggregate, and resolves outstanding race-name reservations through the
// Race Name Directory.
//
// Capability rule (frozen in lobby/README.md §Game Finish Flow):
//
// capable = max_planets > initial_planets AND max_population > initial_population
//
// Capable members get their reservation promoted to `pending_registration`
// with eligible_until = finished_at + 30 days, plus a
// `lobby.race_name.registration_eligible` intent. Incapable members get
// their reservation released immediately, plus an optional
// `lobby.race_name.registration_denied` intent. `removed` and `blocked`
// memberships have their outstanding reservations released without an
// intent.
//
// Idempotency is guarded by ports.EvaluationGuardStore: the first pass for
// a game records a marker and proceeds with side effects; later passes
// observe the marker and return without mutating the directory or
// publishing intents. The guard is recorded only after the directory and
// stats mutations succeed so a transient failure replays from scratch on
// the next consumer tick.
package capabilityevaluation
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/logging"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/telemetry"
)
// PendingRegistrationWindow is the post-game window during which a capable
// member may convert their reservation into a registered race name. The
// 30-day value is frozen in lobby/README.md §Reservation lifecycle and
// capability.
const PendingRegistrationWindow = 30 * 24 * time.Hour
// EligibleEvent carries the inputs required to publish one
// lobby.race_name.registration_eligible intent. The struct exists so that
// the publisher receives a stable shape even though the real
// constructors live in pkg/notificationintent.
type EligibleEvent struct {
// GameID identifies the finished game whose capable finish produced
// the pending registration.
GameID common.GameID
// GameName is the human-readable game name copied from the game
// record at finish time.
GameName string
// UserID identifies the capable member.
UserID string
// RaceName is the original-casing race name reserved by the user.
RaceName string
// EligibleUntil is the deadline by which the user must call
// lobby.race_name.register to keep the name.
EligibleUntil time.Time
// FinishedAt is the wall-clock at which the game finished.
FinishedAt time.Time
}
// DeniedEvent carries the inputs required to publish one
// lobby.race_name.registration_denied intent. may decide to
// suppress the intent for a quieter post-game experience; the event is
// emitted by the evaluator regardless and the publisher is free to drop
// it.
type DeniedEvent struct {
// GameID identifies the finished game.
GameID common.GameID
// GameName is the human-readable game name copied from the game
// record at finish time.
GameName string
// UserID identifies the incapable member.
UserID string
// RaceName is the original-casing race name held by the user during
// the game.
RaceName string
// FinishedAt is the wall-clock at which the game finished.
FinishedAt time.Time
// Reason describes why the member did not satisfy the capability
// rule. The evaluator currently emits "capability_not_met" or
// "missing_stats".
Reason string
}
// Reasons emitted on DeniedEvent.
const (
// ReasonCapabilityNotMet reports that the member did not satisfy
// max_planets > initial_planets AND max_population >
// initial_population.
ReasonCapabilityNotMet = "capability_not_met"
// ReasonMissingStats reports that no stats observation was ever
// recorded for the member, so capability cannot be evaluated. The
// evaluator treats this as incapable.
ReasonMissingStats = "missing_stats"
)
// RaceNameIntents publishes the two race-name capability outcomes. Stage
// 15A wires a noop implementation by default; replaces the
// adapter with the real pkg/notificationintent publisher without touching
// the evaluator.
type RaceNameIntents interface {
// PublishEligible emits one lobby.race_name.registration_eligible
// intent for ev. Implementations must be idempotent on retry —
// the evaluator may invoke PublishEligible at most once per (game,
// user) tuple, but retry policy may cause downstream
// re-emission.
PublishEligible(ctx context.Context, ev EligibleEvent) error
// PublishDenied emits one lobby.race_name.registration_denied
// intent for ev. The intent is informational; implementations may
// drop it without surfacing an error.
PublishDenied(ctx context.Context, ev DeniedEvent) error
}
// NoopRaceNameIntents is the production-default RaceNameIntents adapter
// while is unimplemented. Both methods accept their input and
// return nil. The exported zero value is safe to share.
type NoopRaceNameIntents struct{}
// PublishEligible discards ev and returns nil.
func (NoopRaceNameIntents) PublishEligible(ctx context.Context, ev EligibleEvent) error {
return nil
}
// PublishDenied discards ev and returns nil.
func (NoopRaceNameIntents) PublishDenied(ctx context.Context, ev DeniedEvent) error {
return nil
}
// Compile-time interface assertion.
var _ RaceNameIntents = NoopRaceNameIntents{}
// Service evaluates capability at game finish.
type Service struct {
games ports.GameStore
memberships ports.MembershipStore
stats ports.GameTurnStatsStore
directory ports.RaceNameDirectory
intents RaceNameIntents
guard ports.EvaluationGuardStore
clock func() time.Time
logger *slog.Logger
telemetry *telemetry.Runtime
}
// Dependencies groups the collaborators used by Service.
type Dependencies struct {
// Games loads the finished game record. The evaluator reads the
// FinishedAt and GameName fields from the loaded record.
Games ports.GameStore
// Memberships supplies the per-game roster the evaluator iterates.
Memberships ports.MembershipStore
// Stats supplies the aggregate the evaluator reads, and is asked to
// delete the aggregate at the end of a successful pass.
Stats ports.GameTurnStatsStore
// Directory mutates reservations: MarkPendingRegistration on
// capable members, ReleaseReservation on incapable / removed /
// blocked members.
Directory ports.RaceNameDirectory
// Intents publishes the per-member capability intents. Wire
// NoopRaceNameIntents{} until lands the real publisher.
Intents RaceNameIntents
// Guard supplies the per-game «already evaluated» marker that
// keeps replayed `game_finished` events safe.
Guard ports.EvaluationGuardStore
// Clock supplies the wall-clock used for log timestamps; the
// evaluator otherwise reads its FinishedAt anchor from the game
// record.
Clock func() time.Time
// Logger records structured service-level events.
Logger *slog.Logger
// Telemetry records the `lobby.capability_evaluations` counter
// per evaluated active membership. Optional; nil disables metric
// emission.
Telemetry *telemetry.Runtime
}
// NewService constructs one Service with deps.
func NewService(deps Dependencies) (*Service, error) {
switch {
case deps.Games == nil:
return nil, errors.New("new capability evaluation service: nil game store")
case deps.Memberships == nil:
return nil, errors.New("new capability evaluation service: nil membership store")
case deps.Stats == nil:
return nil, errors.New("new capability evaluation service: nil game turn stats store")
case deps.Directory == nil:
return nil, errors.New("new capability evaluation service: nil race name directory")
case deps.Intents == nil:
return nil, errors.New("new capability evaluation service: nil race name intents")
case deps.Guard == nil:
return nil, errors.New("new capability evaluation service: nil evaluation guard store")
}
clock := deps.Clock
if clock == nil {
clock = time.Now
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Service{
games: deps.Games,
memberships: deps.Memberships,
stats: deps.Stats,
directory: deps.Directory,
intents: deps.Intents,
guard: deps.Guard,
clock: clock,
logger: logger.With("service", "lobby.capabilityevaluation"),
telemetry: deps.Telemetry,
}, nil
}
// Evaluate runs the capability evaluator for gameID. The caller — the
// gmevents consumer — must have already transitioned the game record to
// `finished` (or established that the game was already in `finished` from
// a prior pass) before invoking Evaluate. The method is idempotent: a
// second call after a successful first pass returns nil without further
// side effects.
//
// finishedAt is intentionally sourced from the game record by the caller
// rather than from the GM event timestamp so that retries of the same
// `game_finished` event always compute the same eligible_until anchor.
func (service *Service) Evaluate(ctx context.Context, gameID common.GameID, finishedAt time.Time) error {
if service == nil {
return errors.New("evaluate capability: nil service")
}
if ctx == nil {
return errors.New("evaluate capability: nil context")
}
if err := gameID.Validate(); err != nil {
return fmt.Errorf("evaluate capability: %w", err)
}
if finishedAt.IsZero() {
return errors.New("evaluate capability: finished at must not be zero")
}
record, err := service.games.Get(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: load game: %w", err)
}
if record.Status != game.StatusFinished {
return fmt.Errorf("evaluate capability: game %s status is %q, expected %q",
record.GameID.String(), record.Status, game.StatusFinished)
}
evaluated, err := service.guard.IsEvaluated(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: read guard: %w", err)
}
if evaluated {
service.telemetry.RecordCapabilityEvaluation(ctx, "noop")
logArgs := []any{"game_id", gameID.String()}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "capability evaluation replay absorbed by guard", logArgs...)
return nil
}
memberships, err := service.memberships.GetByGame(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: load memberships: %w", err)
}
aggregate, err := service.stats.Load(ctx, gameID)
if err != nil {
return fmt.Errorf("evaluate capability: load stats: %w", err)
}
statsByUser := make(map[string]ports.PlayerStatsAggregate, len(aggregate.Players))
for _, line := range aggregate.Players {
statsByUser[line.UserID] = line
}
eligibleUntil := finishedAt.Add(PendingRegistrationWindow)
pending := make([]EligibleEvent, 0, len(memberships))
denied := make([]DeniedEvent, 0, len(memberships))
for _, member := range memberships {
switch member.Status {
case membership.StatusActive:
stat, ok := statsByUser[member.UserID]
capable := ok && capabilityMet(stat)
switch {
case capable:
if err := service.directory.MarkPendingRegistration(
ctx, member.GameID.String(), member.UserID, member.RaceName, eligibleUntil,
); err != nil {
return fmt.Errorf(
"evaluate capability: mark pending registration for game %s user %s: %w",
member.GameID.String(), member.UserID, err,
)
}
service.telemetry.RecordCapabilityEvaluation(ctx, "capable")
pending = append(pending, EligibleEvent{
GameID: member.GameID,
GameName: record.GameName,
UserID: member.UserID,
RaceName: member.RaceName,
EligibleUntil: eligibleUntil,
FinishedAt: finishedAt,
})
default:
if err := service.directory.ReleaseReservation(
ctx, member.GameID.String(), member.UserID, member.RaceName,
); err != nil {
return fmt.Errorf(
"evaluate capability: release reservation for game %s user %s: %w",
member.GameID.String(), member.UserID, err,
)
}
service.telemetry.RecordCapabilityEvaluation(ctx, "incapable")
reason := ReasonCapabilityNotMet
if !ok {
reason = ReasonMissingStats
}
denied = append(denied, DeniedEvent{
GameID: member.GameID,
GameName: record.GameName,
UserID: member.UserID,
RaceName: member.RaceName,
FinishedAt: finishedAt,
Reason: reason,
})
}
case membership.StatusRemoved, membership.StatusBlocked:
if err := service.directory.ReleaseReservation(
ctx, member.GameID.String(), member.UserID, member.RaceName,
); err != nil {
return fmt.Errorf(
"evaluate capability: release post-start reservation for game %s user %s: %w",
member.GameID.String(), member.UserID, err,
)
}
}
}
if err := service.stats.Delete(ctx, gameID); err != nil {
return fmt.Errorf("evaluate capability: delete stats: %w", err)
}
if err := service.guard.MarkEvaluated(ctx, gameID); err != nil {
return fmt.Errorf("evaluate capability: mark evaluated: %w", err)
}
for _, ev := range pending {
if err := service.intents.PublishEligible(ctx, ev); err != nil {
service.logger.WarnContext(ctx, "publish race name eligible intent",
"game_id", ev.GameID.String(),
"user_id", ev.UserID,
"err", err.Error(),
)
}
}
for _, ev := range denied {
if err := service.intents.PublishDenied(ctx, ev); err != nil {
service.logger.WarnContext(ctx, "publish race name denied intent",
"game_id", ev.GameID.String(),
"user_id", ev.UserID,
"err", err.Error(),
)
}
}
logArgs := []any{
"game_id", gameID.String(),
"pending_count", len(pending),
"denied_count", len(denied),
}
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
service.logger.InfoContext(ctx, "capability evaluation complete", logArgs...)
return nil
}
// capabilityMet implements the capability rule frozen in
// lobby/README.md §Game Finish Flow.
func capabilityMet(stat ports.PlayerStatsAggregate) bool {
return stat.MaxPlanets > stat.InitialPlanets &&
stat.MaxPopulation > stat.InitialPopulation
}