// Package adminforce implements the admin force-next-turn service-layer // orchestrator owned by Game Master. It is driven by Admin Service or // system administrators through // `POST /api/v1/internal/runtimes/{game_id}/force-next-turn` and runs // the turn-generation flow synchronously, then sets // `runtime_records.skip_next_tick=true` so the next scheduler-driven // generation skips one regular cron step. // // The skip rule guarantees that the inter-turn spacing is never shorter // than one schedule interval, regardless of when the force is issued. // Lifecycle and failure-mode semantics follow `gamemaster/README.md // §Lifecycles → Force-next-turn`. Design rationale is captured in // `gamemaster/docs/stage17-admin-operations.md`. package adminforce import ( "context" "errors" "fmt" "log/slog" "strings" "time" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/logging" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/turngeneration" "galaxy/gamemaster/internal/telemetry" ) // TurnGenerator narrows `*turngeneration.Service` to the single method // adminforce calls. The interface lets tests substitute a stub without // constructing the entire turn-generation collaborator graph. type TurnGenerator interface { Handle(ctx context.Context, input turngeneration.Input) (turngeneration.Result, error) } // Input stores the per-call arguments for one admin force-next-turn // operation. type Input struct { // GameID identifies the runtime to advance. GameID string // OpSource classifies how the request entered Game Master. Used to // stamp `operation_log.op_source` on both the driver entry and the // inner turn-generation entry. Defaults to `admin_rest` when // missing or unrecognised. OpSource operation.OpSource // SourceRef stores the optional opaque per-source reference (REST // request id, admin user id). Empty when the caller does not // provide one. SourceRef string } // Validate reports whether input carries the structural invariants the // service requires before the inner turn-generation call. func (input Input) Validate() error { if strings.TrimSpace(input.GameID) == "" { return fmt.Errorf("game id must not be empty") } return nil } // Result stores the deterministic outcome of one Handle call. Business // outcomes flow through Result; the Go-level error return is reserved // for non-business failures (nil context, nil receiver). type Result struct { // TurnGeneration carries the inner turn-generation result. Always // populated when Handle returns nil error and the input passed // validation; zero on early-rejection failures // (invalid_request). TurnGeneration turngeneration.Result // SkipScheduled reports whether the post-success // `skip_next_tick=true` write landed. False on failure paths and // when the inner turn-generation surfaced a failure. SkipScheduled bool // Outcome reports whether the operation completed (success) or // produced a stable failure code. Outcome operation.Outcome // ErrorCode stores the stable error code on failure. Empty on // success. ErrorCode string // ErrorMessage stores the operator-readable detail on failure. // Empty on success. ErrorMessage string } // IsSuccess reports whether the result represents a successful // operation. func (result Result) IsSuccess() bool { return result.Outcome == operation.OutcomeSuccess } // Dependencies groups the collaborators required by Service. type Dependencies struct { // RuntimeRecords drives the post-success scheduling write that // installs `skip_next_tick=true`. RuntimeRecords ports.RuntimeRecordStore // OperationLogs records the audit driver entry // (`op_kind=force_next_turn`). OperationLogs ports.OperationLogStore // TurnGeneration runs the inner turn-generation flow. Required. TurnGeneration TurnGenerator // Telemetry is required: every adminforce call ends with a // telemetry record on the inner turn-generation counter. Telemetry *telemetry.Runtime // Logger records structured service-level events. Defaults to // `slog.Default()` when nil. Logger *slog.Logger // Clock supplies the wall-clock used for operation timestamps. // Defaults to `time.Now` when nil. Clock func() time.Time } // Service executes the admin force-next-turn lifecycle operation. type Service struct { runtimeRecords ports.RuntimeRecordStore operationLogs ports.OperationLogStore turnGen TurnGenerator telemetry *telemetry.Runtime logger *slog.Logger clock func() time.Time } // NewService constructs one Service from deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.RuntimeRecords == nil: return nil, errors.New("new admin force service: nil runtime records") case deps.OperationLogs == nil: return nil, errors.New("new admin force service: nil operation logs") case deps.TurnGeneration == nil: return nil, errors.New("new admin force service: nil turn generation") case deps.Telemetry == nil: return nil, errors.New("new admin force service: nil telemetry runtime") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } logger = logger.With("service", "gamemaster.adminforce") return &Service{ runtimeRecords: deps.RuntimeRecords, operationLogs: deps.OperationLogs, turnGen: deps.TurnGeneration, telemetry: deps.Telemetry, logger: logger, clock: clock, }, nil } // Handle executes one admin force-next-turn operation end-to-end. // The Go-level error return is reserved for non-business failures (nil // context, nil receiver). Every business outcome flows through Result. func (service *Service) Handle(ctx context.Context, input Input) (Result, error) { if service == nil { return Result{}, errors.New("admin force: nil service") } if ctx == nil { return Result{}, errors.New("admin force: nil context") } opStartedAt := service.clock().UTC() if err := input.Validate(); err != nil { return service.recordFailure(ctx, opStartedAt, input, ErrorCodeInvalidRequest, err.Error()), nil } turnResult, err := service.turnGen.Handle(ctx, turngeneration.Input{ GameID: input.GameID, Trigger: turngeneration.TriggerForce, OpSource: fallbackOpSource(input.OpSource), SourceRef: input.SourceRef, }) if err != nil { return service.recordFailure(ctx, opStartedAt, input, ErrorCodeInternal, fmt.Sprintf("turn generation: %s", err.Error())), nil } if !turnResult.IsSuccess() { errorCode := turnResult.ErrorCode if errorCode == "" { errorCode = ErrorCodeInternal } return service.recordFailureWithTurn(ctx, opStartedAt, input, turnResult, errorCode, turnResult.ErrorMessage), nil } scheduledAt := service.clock().UTC() scheduling := ports.UpdateSchedulingInput{ GameID: input.GameID, NextGenerationAt: turnResult.Record.NextGenerationAt, SkipNextTick: true, CurrentTurn: turnResult.Record.CurrentTurn, Now: scheduledAt, } if err := service.runtimeRecords.UpdateScheduling(ctx, scheduling); err != nil { // The forced turn already landed; the skip flag did not. Report // as a service_unavailable so the admin UI can retry the skip // without re-driving the engine. return service.recordFailureWithTurn(ctx, opStartedAt, input, turnResult, ErrorCodeServiceUnavailable, fmt.Sprintf("update scheduling skip flag: %s", err.Error())), nil } service.appendSuccessLog(ctx, opStartedAt, input) logArgs := []any{ "game_id", input.GameID, "current_turn", turnResult.Record.CurrentTurn, "finished", turnResult.Finished, "op_source", string(fallbackOpSource(input.OpSource)), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "force next turn applied", logArgs...) return Result{ TurnGeneration: turnResult, SkipScheduled: true, Outcome: operation.OutcomeSuccess, }, nil } // recordFailure records a failure that occurred before the inner // turn-generation result was available. func (service *Service) recordFailure(ctx context.Context, opStartedAt time.Time, input Input, errorCode string, errorMessage string) Result { service.appendFailureLog(ctx, opStartedAt, input, errorCode, errorMessage) logArgs := []any{ "game_id", input.GameID, "op_source", string(input.OpSource), "error_code", errorCode, "error_message", errorMessage, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.WarnContext(ctx, "force next turn rejected", logArgs...) return Result{ Outcome: operation.OutcomeFailure, ErrorCode: errorCode, ErrorMessage: errorMessage, } } // recordFailureWithTurn records a failure after the inner turn- // generation step ran, propagating its result for caller-side // telemetry. func (service *Service) recordFailureWithTurn(ctx context.Context, opStartedAt time.Time, input Input, turnResult turngeneration.Result, errorCode string, errorMessage string) Result { service.appendFailureLog(ctx, opStartedAt, input, errorCode, errorMessage) logArgs := []any{ "game_id", input.GameID, "op_source", string(input.OpSource), "error_code", errorCode, "error_message", errorMessage, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.WarnContext(ctx, "force next turn failed", logArgs...) return Result{ TurnGeneration: turnResult, Outcome: operation.OutcomeFailure, ErrorCode: errorCode, ErrorMessage: errorMessage, } } // appendSuccessLog records the success driver operation_log entry. func (service *Service) appendSuccessLog(ctx context.Context, opStartedAt time.Time, input Input) { finishedAt := service.clock().UTC() service.bestEffortAppend(ctx, operation.OperationEntry{ GameID: input.GameID, OpKind: operation.OpKindForceNextTurn, OpSource: fallbackOpSource(input.OpSource), SourceRef: input.SourceRef, Outcome: operation.OutcomeSuccess, StartedAt: opStartedAt, FinishedAt: &finishedAt, }) } // appendFailureLog records the failure driver operation_log entry. func (service *Service) appendFailureLog(ctx context.Context, opStartedAt time.Time, input Input, errorCode string, errorMessage string) { finishedAt := service.clock().UTC() gameID := input.GameID if strings.TrimSpace(gameID) == "" { // Validation guard: the entry validator rejects empty GameID. // Skip the audit entry instead of crashing the service. return } service.bestEffortAppend(ctx, operation.OperationEntry{ GameID: gameID, OpKind: operation.OpKindForceNextTurn, OpSource: fallbackOpSource(input.OpSource), SourceRef: input.SourceRef, Outcome: operation.OutcomeFailure, ErrorCode: errorCode, ErrorMessage: errorMessage, StartedAt: opStartedAt, FinishedAt: &finishedAt, }) } // bestEffortAppend writes one operation_log entry. A failure is logged // and discarded; the runtime row is the source of truth. func (service *Service) bestEffortAppend(ctx context.Context, entry operation.OperationEntry) { if _, err := service.operationLogs.Append(ctx, entry); err != nil { service.logger.ErrorContext(ctx, "append operation log", "game_id", entry.GameID, "op_kind", string(entry.OpKind), "outcome", string(entry.Outcome), "error_code", entry.ErrorCode, "err", err.Error(), ) } } // fallbackOpSource defaults to `admin_rest` when the caller did not // supply a known op source. Mirrors `gamemaster/README.md §Trusted // Surfaces`. func fallbackOpSource(source operation.OpSource) operation.OpSource { if source.IsKnown() { return source } return operation.OpSourceAdminRest }