Skip to main content

ADR 0010 — Act Progression as ActState Scope with ActProgressionSystem

Context

Valenar's act ladder runs from Act 0 (Arrival and Core) through Act 7, with each Act Arc owning a set of authored beats and required outcomes. The eight act docs under ../../acts/README.md commit the per-act content; the Saga Arc in ../../systems/gd-quest-and-lore-design.md commits the campaign-scale envelope above the acts. What is not committed is the runtime carrier of "which act the campaign is in": which SECS entity holds CurrentAct, which system advances it, what fires when an act starts, and what gate predicate decides when Act N becomes Act N+1.

Today the four Act 0 quest thread docs and the seven cross-act lanes all assume the player progresses through acts in order, but nothing in the committed runtime answers "where is CurrentAct stored". A per-tick predicate function would carry the gate logic but produce no entity to subscribe to (no event target for "Act 4 started", no place to attach a Modifier that fires only during Act 5). A tag-set on the Realm scope from ./ad-0009-realm-scope-and-polity-rank-orthogonality.md would conflate two unrelated ladders: the Act ladder (a saga-scale content-progression structure) and the PolityRank ladder (a Realm-scale authority structure). gd-canon.md already commits the rule that polity emergence is its own ladder; trying to fold the Act ladder into the PolityRank tag-set would collapse those two structures. The AAA precedent — CK3 story cycle stages, Stellaris event chain stages, EU5 country-type named-state promotion, RimWorld quest sub-stages, Witcher 3 chapter progression with fact-flag gates — universally carries the campaign-stage in its own named entity with its own typed-state field.

The Foundation Hardening needs the Act-progression primitive committed before the Wave 3a state-machine row, before the per-act gate-spec docs can be authored, before Quest Thread ActScopeKind binding from ./ad-0007-quest-thread-and-mission-scope-primitives.md can reference a runtime CurrentAct, and before the campaign-end follow-on event can fire.

Decision

A new scope ActState is committed as the saga-scoped runtime carrier of the campaign's act position. There is exactly one ActState row per game, created by the existing one-shot setup path that creates the MainCharacter row (i.e., by the existing MapGenerationSystem). Fields committed in this ADR: CurrentAct: int (0..7), EnteredOnDay: int, and PreviousAct: int. The exact additional fields needed by per-act gate predicates are deferred to the per-act gd-act-N-gate-spec.md docs to be authored in Wave 3a.

One contract ActStateContract is committed, carrying two lifecycle hooks: OnActStarted(ActProgressionArgs) and OnActCompleted(ActProgressionArgs). The ActProgressionArgs type carries at minimum the previous-act value, the new-act value, and the day the transition occurred; the exact typed-args field list is authored in the Wave 5a runtime-backing wave.

One system ActProgression is committed. The system iterates the singleton ActState entity, reads the named gate predicate for CurrentAct + 1, and if the predicate is true advances CurrentAct, records PreviousAct, sets EnteredOnDay, and fires the OnActCompleted hook for the outgoing act followed by OnActStarted for the incoming act. The system runs in the Phases.Progression phase. (The phase name is plain C# game vocabulary per .claude/rules/secs-concepts.md and per the existing PhaseDeclaration pattern; it is NOT a SECS keyword. If the Phases.Progression symbol does not already exist in the Generated phase-declaration tree, it is authored in the Wave 5a runtime-backing wave.)

Two events are committed with trigger on_action per the SECS on_action allowlist in .claude/rules/behavior-vocabulary.md: event OnActStarted and event OnActCompleted. Both events carry ActProgressionArgs as typed args.

Per-act gate predicates for CurrentAct + 1 are deferred to per-act gd-act-N-gate-spec.md docs to be authored in Wave 3a, one for each of Act 1 through Act 7. Act 0's gate (the Act 0 -> Act 1 transition) is already concrete in ../../acts/gd-act-0-mission-spine.md; the per-act gate-spec doc for Act 1 will codify that gate in the same authoring pattern the other six gate-spec docs use. The binding rule is firm: every gate predicate reads named state on existing or already-committed scopes (Realm, Settlement, QuestThread, Mission, ThreatSource, FrontCandidate, Site, MainCharacter, and similar). No gate predicate may read a day count, a time-since-start counter, or a real-time clock value. The "Never tune parameters as a substitute for fixing architecture" tenet applies: gates are state predicates, not timers.

Act 7's OnActCompleted hook fires a follow-on CampaignEnded event. The CampaignEnded event surfaces the campaign-end ceremony that the saga arc resolves into; its exact handler binding is authored in the Wave 5a runtime-backing wave.

Alternatives Considered

Per-act predicate function (a static function per act, called from a saga-progression system, that returns true when the act's gate is satisfied). Rejected because there is no entity to subscribe to. Quest Thread act_scope binding, mission lifecycle hooks tied to act transitions, dynamic quest pressure generation tied to current act, and Journal readback all need a queryable target. A static predicate function produces a boolean but no entity, no event-subscription surface, and no place to attach a Modifier or a derived channel. The AAA precedent universally uses a named entity for campaign-stage tracking; this ADR commits Valenar to that same shape.

Tag-set on the Realm scope (e.g., Tag(CurrentAct=4) applied to the singleton Realm). Rejected because Realm.PolityRank is its own ladder (per ./ad-0009) and the Act ladder is a different ladder. gd-canon.md already commits the rule that polity emergence is its own ladder rather than the Act ladder under another name. Folding CurrentAct into the Realm's tag-set would collapse the two ladders into one, exactly the drift the no-fallback redesign-propagation rule forbids. The Act-progression entity needs its own scope, not a tag on the Realm scope.

Tenets Applied

  • "Future-proofed solutions only." A dedicated ActState scope with typed CurrentAct / PreviousAct / EnteredOnDay fields leaves room for additional saga-scale runtime state (per-act day counters, per-act flag sets, campaign-pacing metrics) without reshape.
  • "Research before designing." The AAA precedent (CK3 story cycle stages; Stellaris event chain stages; EU5 country-type named-state promotion; RimWorld quest sub-stages; Witcher 3 chapter progression with fact-flag gates) was consulted before committing the scope + system + contract shape.
  • "Never tune parameters as a substitute for fixing architecture." Act gates read named state on committed scopes; they do not read day counts or real-time clocks. The architecture is the gate-predicate pattern, not the threshold constants that would otherwise live in a timer-based shape.
  • "No backwards compatibility." Neither the predicate-function fallback nor the tag-set fallback is preserved.

Runtime Backing Status

Status at this ADR's acceptance time: contract-only.

  • Host/runtime owner: a new legacy/v1/examples/valenar/Host/Data/ActStateData.cs carries the singleton ActState host data; a new legacy/v1/examples/valenar/Host/Systems/ActProgressionSystem.cs hosts the system ActProgression body. Both are authored in the Wave 5a runtime-backing wave.
  • Generated/.secs owner: legacy/v1/examples/valenar/Content/acts/scopes.secs declares scope ActState; legacy/v1/examples/valenar/Content/acts/contracts.secs declares contract ActStateContract with the two lifecycle hooks; legacy/v1/examples/valenar/Content/acts/systems.secs declares system ActProgression; legacy/v1/examples/valenar/Content/acts/events.secs declares event OnActStarted and event OnActCompleted. Matched by the parallel Generated stand-in tree.
  • Read-model/UI owner: the Valenar React client's saga-position UI surface reads ActState.CurrentAct; the existing Act 0 - Arrival and Core label discipline (spoiler-safe wording for player-facing labels) carries forward unchanged.
  • Tests: deferred to the Wave 5a runtime-backing wave.
  • Known gaps: per-act gate predicates for Acts 1 through 7 (authored in Wave 3a per-act gate-spec docs); the exact ActProgressionArgs field roster; the CampaignEnded event handler binding; the Phases.Progression declaration if not already present.
  • Illegal fallback behavior: no host-side int _currentAct field outside the ActState scope row may stand in for CurrentAct. No predicate function may be called from outside the ActProgressionSystem to advance the act. No day-count or real-time clock value may stand in for a state predicate in any gate expression.
  • Next closure wave: Wave 5a runtime-backing wave authors the host data, the scope, the contract, the system, the events, and the matching Generated stand-ins. Per-act gate-spec docs (Wave 3a) are a parallel authoring track.

Glossary Propagation

The following terms MUST land in gd-glossary.md, gd-canon.md, and README.md:

  • ActState (scope primitive — saga-scoped singleton).
  • CurrentAct (Base channel on ActState — the 0..7 act index).
  • ActProgression (system name).
  • ActProgressionSystem (host-side system class name).
  • OnActStarted (event with trigger on_action).
  • OnActCompleted (event with trigger on_action).
  • ActStateContract (contract carrying the two lifecycle hooks).
  • ActProgressionArgs (typed args carried by the two events).
  • CampaignEnded (follow-on event fired at Act 7 completion).

Consequences

For ../../acts/README.md per-act docs, the existing "Act N begins when..." prose remains the design-pacing description. The Wave 3a gd-act-N-gate-spec.md docs (one per Act 1 through Act 7) will codify each gate as a named-state predicate against the committed runtime scopes. The Act 0 -> Act 1 gate already has a concrete authored form in ../../acts/gd-act-0-mission-spine.md; the Act 1 gate-spec doc preserves that gate verbatim.

For [../../systems/gd-quest-and-lore-design.md] and the Quest Thread ActScopeKind enum committed in ./ad-0007, the act-scoped value of ActScopeKind becomes resolvable against ActState.CurrentAct at runtime. Cross-act Quest Threads (the seven committed lane keys) read ActState.CurrentAct for content gating without binding to a specific act.

For ./ad-0009-realm-scope-and-polity-rank-orthogonality.md, the ActState and Realm scopes remain independent. The Act ladder and the PolityRank ladder are different ladders; the per-act gate predicates may read Realm.PolityRank as one of many named-state inputs, but the two ladders do not share a single field.

For the verifier, any future doc that introduces a day-count or real-time gate predicate for an Act transition is a tenet FAIL. Any doc that introduces a CurrentAct carrier outside the ActState scope (host field, tag-set, Modifier flag, derived channel on the Realm) must supersede this ADR.

References