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