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.cscarries the singleton ActState host data; a newlegacy/v1/examples/valenar/Host/Systems/ActProgressionSystem.cshosts thesystem ActProgressionbody. Both are authored in the Wave 5a runtime-backing wave. - Generated/.secs owner:
legacy/v1/examples/valenar/Content/acts/scopes.secsdeclaresscope ActState;legacy/v1/examples/valenar/Content/acts/contracts.secsdeclarescontract ActStateContractwith the two lifecycle hooks;legacy/v1/examples/valenar/Content/acts/systems.secsdeclaressystem ActProgression;legacy/v1/examples/valenar/Content/acts/events.secsdeclaresevent OnActStartedandevent 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 existingAct 0 - Arrival and Corelabel 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
ActProgressionArgsfield roster; theCampaignEndedevent handler binding; thePhases.Progressiondeclaration if not already present. - Illegal fallback behavior: no host-side
int _currentActfield outside the ActState scope row may stand in forCurrentAct. No predicate function may be called from outside theActProgressionSystemto 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 withtrigger on_action).OnActCompleted(event withtrigger 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
./ad-0006-dynamic-quest-pressure-and-cross-act-lane-decisions.md./ad-0007-quest-thread-and-mission-scope-primitives.md./ad-0009-realm-scope-and-polity-rank-orthogonality.md./ad-0013-scope-state-channel-template-archetype-pattern.md../../acts/gd-act-0-arrival-and-core.md../../acts/gd-act-0-mission-spine.md../../systems/gd-quest-and-lore-design.md.claude/rules/secs-concepts.md.claude/rules/behavior-vocabulary.md.claude/rules/valenar-contract-backing.md