411 lines
14 KiB
Go
411 lines
14 KiB
Go
// 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
|
|
}
|