// Package enrollmentautomation implements the periodic worker that // transitions games from `enrollment_open` to `ready_to_start` along the // three automatic paths frozen in lobby/README.md §Enrollment Rules: // deadline, gap window time exhaustion, and gap window roster // exhaustion. The same enrollment-close pipeline that // service/manualreadytostart calls is reused via shared.CloseEnrollment // so manual and automatic closes stay aligned. package enrollmentautomation import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/telemetry" ) // Dependencies groups the collaborators consumed by Worker. The struct // mirrors the shape used by Lobby services: each store / publisher port // is supplied explicitly so wiring stays a single concrete-adapter site. type Dependencies struct { // Games is scanned once per tick for records in // game.StatusEnrollmentOpen. Games ports.GameStore // Memberships supplies the active-membership count used to evaluate // the deadline and gap-roster preconditions. Memberships ports.MembershipStore // Invites is forwarded to shared.CloseEnrollment for the cascading // expiry of created invites on close. Invites ports.InviteStore // Intents publishes lobby.invite.expired notifications produced by // the cascading expiry. Intents ports.IntentPublisher // GapStore exposes the gap-window activation timestamp recorded by // approveapplication and redeeminvite. It must implement the Get // accessor introduced in the GapStore ports.GapActivationStore // Interval controls the tick cadence. It must be positive. Interval time.Duration // Clock supplies the wall-clock used for the per-tick "now" // reference and for the close timestamp. Defaults to time.Now when // nil. Clock func() time.Time // Logger receives structured worker-level events. Defaults to // slog.Default when nil. Logger *slog.Logger // Telemetry records the `lobby.enrollment_automation.checks` // counter per inspected game per tick and forwards into // shared.CloseEnrollment for the `lobby.game.transitions` and // `lobby.invite.outcomes` counters. Optional; nil disables metric // emission. Telemetry *telemetry.Runtime } // Worker drives the periodic enrollment-automation loop. type Worker struct { games ports.GameStore memberships ports.MembershipStore invites ports.InviteStore intents ports.IntentPublisher gapStore ports.GapActivationStore interval time.Duration clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // NewWorker constructs one Worker from deps. func NewWorker(deps Dependencies) (*Worker, error) { if deps.Games == nil { return nil, errors.New("new enrollment automation worker: nil game store") } if deps.Memberships == nil { return nil, errors.New("new enrollment automation worker: nil membership store") } if deps.Invites == nil { return nil, errors.New("new enrollment automation worker: nil invite store") } if deps.Intents == nil { return nil, errors.New("new enrollment automation worker: nil intent publisher") } if deps.GapStore == nil { return nil, errors.New("new enrollment automation worker: nil gap activation store") } if deps.Interval <= 0 { return nil, fmt.Errorf("new enrollment automation worker: interval must be positive, got %s", deps.Interval) } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Worker{ games: deps.Games, memberships: deps.Memberships, invites: deps.Invites, intents: deps.Intents, gapStore: deps.GapStore, interval: deps.Interval, clock: clock, logger: logger.With("worker", "lobby.enrollmentautomation"), telemetry: deps.Telemetry, }, nil } // Run drives the periodic ticker. It returns when ctx is cancelled. A // failure inside one tick does not terminate the loop — every tick logs // its outcome and the worker stays alive so subsequent ticks can pick up // once the underlying issue clears. func (worker *Worker) Run(ctx context.Context) error { if worker == nil { return errors.New("run enrollment automation worker: nil worker") } if ctx == nil { return errors.New("run enrollment automation worker: nil context") } if err := ctx.Err(); err != nil { return err } worker.logger.Info("enrollment automation worker started", "interval", worker.interval.String()) defer worker.logger.Info("enrollment automation worker stopped") ticker := time.NewTicker(worker.interval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: worker.Tick(ctx) } } } // Shutdown is a no-op: the worker holds no resources beyond its own // goroutine, which Run releases on context cancellation. func (worker *Worker) Shutdown(ctx context.Context) error { if ctx == nil { return errors.New("shutdown enrollment automation worker: nil context") } return nil } // Tick performs one automation pass over every enrollment_open game. It // is exported so tests may drive the loop deterministically without a // real ticker. Tick never returns an error; per-game failures are logged // and the iteration continues so a single bad record does not block the // rest of the roster. func (worker *Worker) Tick(ctx context.Context) { if worker == nil || ctx == nil { return } now := worker.clock().UTC() games, err := worker.games.GetByStatus(ctx, game.StatusEnrollmentOpen) if err != nil { worker.logger.WarnContext(ctx, "list enrollment_open games", "err", err.Error(), ) return } for _, record := range games { worker.evaluate(ctx, record, now) } } // evaluate inspects one game record and, when one of the three // auto-close preconditions holds, performs the enrollment close. // Per-record failures are logged and absorbed; they do not propagate to // the caller because that would terminate the worker. func (worker *Worker) evaluate(ctx context.Context, record game.Game, now time.Time) { approvedCount, err := shared.CountActiveMemberships(ctx, worker.memberships, record.GameID) if err != nil { worker.logger.WarnContext(ctx, "count active memberships", "game_id", record.GameID.String(), "err", err.Error(), ) return } gapActivatedAt, gapActive, err := worker.gapStore.Get(ctx, record.GameID) if err != nil { worker.logger.WarnContext(ctx, "read gap activation", "game_id", record.GameID.String(), "err", err.Error(), ) gapActive = false } gapPlayersExhausted := gapActive && approvedCount >= record.MaxPlayers+record.StartGapPlayers gapTimeExhausted := gapActive && !now.Before(gapActivatedAt.Add(time.Duration(record.StartGapHours)*time.Hour)) deadlineReady := !now.Before(record.EnrollmentEndsAt) && approvedCount >= record.MinPlayers var trigger game.Trigger switch { case gapPlayersExhausted, gapTimeExhausted: trigger = game.TriggerGap case deadlineReady: trigger = game.TriggerDeadline default: worker.telemetry.RecordEnrollmentAutomationCheck(ctx, "no_op") return } updated, err := shared.CloseEnrollment(ctx, shared.CloseEnrollmentDeps{ Games: worker.games, Invites: worker.invites, Intents: worker.intents, Logger: worker.logger, Telemetry: worker.telemetry, }, record.GameID, trigger, now) if err != nil { if errors.Is(err, game.ErrConflict) { worker.telemetry.RecordEnrollmentAutomationCheck(ctx, "no_op") worker.logger.InfoContext(ctx, "skipped game closed by another path", "game_id", record.GameID.String(), "trigger", string(trigger), ) return } worker.logger.WarnContext(ctx, "close enrollment", "game_id", record.GameID.String(), "trigger", string(trigger), "err", err.Error(), ) return } worker.telemetry.RecordEnrollmentAutomationCheck(ctx, "transitioned") logArgs := []any{ "game_id", updated.GameID.String(), "from_status", string(game.StatusEnrollmentOpen), "to_status", string(updated.Status), "trigger", string(trigger), "approved_count", approvedCount, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) worker.logger.InfoContext(ctx, "game moved to ready_to_start", logArgs...) }