// 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 }