Skip to content

feat(dsl): runtime state, and the comparison that lives in the gate (spec-0031 §1-2) - #348

Open
stellarfeline wants to merge 3 commits into
mainfrom
feat/runtime-state
Open

feat(dsl): runtime state, and the comparison that lives in the gate (spec-0031 §1-2)#348
stellarfeline wants to merge 3 commits into
mainfrom
feat/runtime-state

Conversation

@stellarfeline

@stellarfeline stellarfeline commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Acceptance criteria 1 and 2 of spec-0031 (runtime state + interactive verbs). Nothing else from that spec is here — no region fill, no status-effect verb, no teleport, no lethal volume, no lift, no currency, no shop, no recovery stake. (on_death, spec-0031 §3, landed separately as #346 and is merged in here.)

The gap this closes

The only author-visible state was FlagId: boolean, party-wide and monotonic — no verb clears a flag. That is enough for "this has happened" and nothing else. A toll still owed, a floor a car is at, whether a ride is in progress: all numeric, all need to go down, and every one of them is read as a condition on some other thing rather than as an effect of its own.

What an author now writes

// stage 5
"state": [
  { "id": "state/toll",  "scope": "party",  "initial": 3, "note": "coins the keeper still wants" },
  { "id": "state/nerve", "scope": "player", "initial": 2, "note": "how much of your own nerve is left" }
],

{ "type": "set-state",   "state": "state/toll", "value": 0 },
{ "type": "add-state",   "state": "state/toll", "amount": -1 },   // signed; negative counts down
{ "type": "clear-state", "state": "state/toll" },                 // back to `initial`

// …and, anywhere `requires_flags` is accepted:
"requires_state": [ { "state": "state/toll", "op": "at-most", "value": 0 } ]

scope is required and never inferred — it is the one fact about a datum no use site can supply, and the spec says so. initial is one field rather than a separate cleared, so a datum can never be un-returnable to its own start. Four operators (equals, not-equals, at-least, at-most), not six: over integers less-than n is at-most n-1, and a second spelling is a second emission path to keep honest.

The gate type

crates/dsl/src/gate.rs:

pub struct Gate<'a> {
    pub requires_flags: &'a [FlagId],
    pub forbids_flags:  &'a [FlagId],
    pub requires_state: &'a [StateCompare],
}

All six consumers answer gate(), and all six impls live in gate.rs rather than beside their own types — the list of gate consumers is one fact, and a fact spread over six files is a fact nobody can read. Beside it: GateConsumer (closed set, ALL/COUNT), and for_each_gate with a GateBinding ledger, in the shape of dsl::effects.

It is a borrowed view, not a #[serde(flatten)]ed struct literal, and that is forced, not preferred. Serde rejects flatten in combination with deny_unknown_fields, which every stage struct carries and which is what turns an author's typo into DW0100 instead of silence. A flattened shared struct would have meant deleting deny_unknown_fields from all 25 declaration sites — a real weakening of an existing check, which the debug doctrine forbids. So requires_state was added to all 28 gate-declaring objects at once, and the test is what makes that a property rather than a coincidence.

The from-the-type test, and what it actually catches

crates/dsl/tests/gate_consumers.rs does not read a list of consumers. It walks the seven stage JSON Schemas, which schemars derives from the Rust types, treats every object schema declaring requires_flags as a gate site, and requires each to declare all three fields. Enum variants are visited as the separate object schemas they are, so QuestEffect's nineteen gatable verbs are nineteen sites. A second test asserts the distinct declaring types number exactly GateConsumer::COUNT. Binding counts (28 sites, 6 classes) are printed and asserted exactly, so a new gate consumer is a deliberate diff rather than a silent one.

There are two different failure modes, and it is worth being precise about which mechanism catches which:

  • Removing the field from an existing consumer is a rustc error, not a test failure. All six gate() impls read self.requires_state, so deleting the field from, say, EnvTrigger fails to compile: error[E0609]: no field 'requires_state' on type '&EnvTrigger', at crates/dsl/src/gate.rs. The test never gets to run. (An earlier revision of this description claimed a test red here; that is wrong — that red was produced under an edit that also removed the gate() impl, and it does not reproduce as written.)

  • The test catches a NEW consumer that carries only part of the gate — the seventh-consumer case the spec asks for. Demonstrated by injecting requires_flags + forbids_flags (and no requires_state) onto Shortcut, which feat(dsl): on_death is an effect root, and so is the one nobody had noticed #346 has just given its own effect root and is the realistic next candidate. Both tests red:

    1 gate consumer(s) carry only part of the gate. … [ "Shortcut is missing [\"requires_state\"]" ]
    gate binding: 29 gate-declaring object schemas examined (28 expected)
    the_consumer_set_covers_every_declaring_type:  left: 7   right: 6
    

    Reverted immediately; both tests green again.

Diagnostics (DW0500–DW0503)

Code Rule
DW0500 A state/… reference names a datum the campaign never declares.
DW0501 Read, never written — a gate reads a datum no verb ever writes, so it can only hold its initial and the comparison was decided at authoring time.
DW0502 Never read — a declared datum no gate reads: an inert write, or a dead declaration.
DW0503 A player-scoped datum read or written where emission has no acting player.

Emission

One dw.s_<local> objective per datum. A party datum is seeded in setup (world init is exactly its lifetime); a player datum by a new state_seed run as @a[tag=!dw_state] from the tick — the tag lives in player data, so a relog does not re-seed. clear-state writes the initial rather than resetting: a reset score is absent, and an absent score makes unless … matches true, so a cleared datum would silently satisfy a not-equals gate against its own starting value.

The comparison is spliced into the guard each consumer already builds. pending_guard was rewritten to go through a single gate_cond(plan, o.gate()) — byte-identical output, but it can no longer know about two of the gate's three fields, which is exactly how the numeric axis would have been missed.

A defect found while re-measuring after the #346 merge

DW0503 read every effect bundle as having an acting player. Three of the seven effect roots do not: a trigger's effects, a trap's payload and a shortcut's on_unlock are all emitted Audience::Scheduled, polled on the tick with no executor. A player-scoped datum read or written inside one of those would have emitted @s into a sourceless function — silent at runtime, green at every gate. Two of those three roots predate this PR, so the hole was mine, not the merge's; the merge is what made me re-derive it.

Fixed at the object class rather than inside the check:

  • EffectRootKind::runs_with_acting_player() — exhaustive over the closed root set, so an eighth root must answer it.
  • emit::root_audience(kind) — the one place the emitter picks a bundle's audience, replacing seven literals at seven call sites (byte-identical: each arm is the literal that site already passed), and bound to the DSL's answer by equality in emit::tests::root_audience_matches_the_dsl. A root whose emitted audience moved without that answer moving with it is now a red.
  • GateConsumer::evaluates_per_player() returns Option<bool>, and Effect answers None — "ask the root". A plain true was right for on_objective_complete and silently wrong for three of seven; Option makes the wrong answer unrepresentable.
  • The DW0503 walk seeds its latch from the root and checks reads as well as writes, because both fail identically: a per-player score named from a sourceless function is @s with nothing to resolve it to, whether the command is a scoreboard players set or an execute if score.

The gate walk inherits the two new effect roots — proven, not assumed

for_each_gate's effect branch is defined on for_each_campaign_effect, hence on for_each_effect_root, which #346 raised to seven. So a numeric gate inside on_death (R7) or inside shortcuts[].on_unlock (R6) is reached with no edit to dsl::gate. That is a claim about a walk, so it is bound: an undeclared datum named from inside each of those bundles must raise DW0500, and does (a_gate_inside_the_newest_effect_roots_is_still_walked). Shown red by hand-rolling the walk's effect branch down to a single root — the exact defect shape #301/#302/#321 exist for.

GATE_SITES stays 28 and GateConsumer::COUNT stays 6 after the merge, and that is a finding rather than a number adjusted to fit: #346 gave Shortcut an effect root, not a gate. Shortcut carries no requires_flags/forbids_flags today, so adding only requires_state to it would be inventing a gate rather than completing one. Its on_unlock effects each carry their own gate already, and those are covered by the walk above.

Byte identity (hard requirement, re-measured against the new base)

nobodys-cave-island built with b54f362's compiler and with this branch: the whole output tree is identicaldatapack/, world/, server config, critical-path.json, every .mcfunction, and creator-datapack/layout.json too, because main already carries dsl_version 0.10.0 so its version stamp no longer moves. diff -r reports nothing.

hollow-vigil (known-red, excluded in CI) emits exactly the same diagnostic codes on both: DW0188, DW0331 ×2, DW0465.

Also updated in the same PR

  • docs/reference/compiler.md: the stage-5 surface rows, the DW050x catalog section (with the rewritten DW0503 rule and the two closed sets that decide it), the "one gate, three fields" note, and the two stale version lines the reference-version gate now binds.
  • tools/check-capability-ownership.py: ("QuestEffect", "requires_state") ledgered as an inherited open finding — the comparison rides exactly the nineteen verbs the flag pair rides and is missing from exactly the same ten, on purpose. All three lift together or none do; the entry says so, so nobody closes it by widening the numeric axis alone.
  • harness/src/critical-path.ts: 0.10.0 allowlisted (a v0.10 path is walked exactly as a v0.9 one — all of this is server-side scoreboard state).
  • .claude/skills/new-delve/SKILL.md: "a number the world remembers is state, not a flag", with the scope rule, the sites that have no acting player, and the two vacuity codes.

What CI now proves

  • every gate-declaring object in the DSL carries the whole gate, enumerated from the schema, and a new partial consumer reds both tests (gate_consumers.rs);
  • a gate inside either of the two newest effect roots is still walked (v10_state.rs);
  • the surface validates clean at 0.10.0 and is DW0141 below it at every site, per stage;
  • each of the four DW codes fires on its own scenario, including DW0503 at a trigger's effects, a shortcut's on_unlock and a sequence step — and not at on_death, which is the dying player's own beat;
  • the emitter's audience per effect root equals the DSL's runs_with_acting_player, over the closed root set, with both answers occurring;
  • the comparison reaches the emitted guard of all six consumer classes on one campaign, with each class's binding asserted non-zero first (v10_state_emit.rs);
  • an ungated effect's Debug rendering is unchanged, so no existing seq_<hash> moves — and two effects differing only in their numeric gate render differently, so they cannot collide.

Known limit, stated rather than discovered

A requires_state comparison is excluded from the static producibility model exactly as forbids_flags is — the flow/reachability proofs do not reason about integers. DW0501 is what keeps that from being a silent hole (a comparison whose datum nothing drives is rejected outright), but a datum that is written and still never reaches the required range is not caught today. Recorded in compiler.md under the DW050x section.

One thing the spec got wrong

Spec §"Acceptance criteria" 2 lists "branch declaration" among the sites that must accept the comparison. BranchDecl.flags is not a gate — it is a pinning declaration ("these flags are SET on this branch, the rest of forks_on is pinned unset"), read by the chronicle and the branch proofs, and it is spelled flags, not requires_flags. A branch pinning purse == 500 has no meaning in that model. It is excluded here, and the 28-site count reflects that. If the spec meant something else by it, say so and I will take it in a follow-up rather than guess.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL

stellarfeline and others added 3 commits August 9, 2026 20:04
…spec-0031 §1-2)

The only author-visible state was `FlagId`: boolean, party-wide, monotonic — no
verb clears one. DSL v0.10 adds a declared, named, integer-valued datum with an
explicit scope, and puts its comparison in the SHARED GATE rather than in the
verb that first asked for it.

Surface (stage 5, fenced at dsl_version 0.10.0, per stage):

  "state": [{ "id": "state/toll", "scope": "party", "initial": 3, "note": "…" }]
  { "type": "set-state",   "state": "state/toll", "value": 0 }
  { "type": "add-state",   "state": "state/toll", "amount": -1 }   // signed
  { "type": "clear-state", "state": "state/toll" }                 // → initial
  "requires_state": [{ "state": "state/toll", "op": "at-most", "value": 0 }]

`requires_state` is accepted at all 28 sites `requires_flags`/`forbids_flags`
are: five objective kinds, nineteen gatable effect verbs, `triggers[]`,
`traps[]`, dialogue options, cast placements. Generality is decided at the FIRST
site; a second bespoke field would have been the defect, not the fix.

`crates/dsl/src/gate.rs` is the gate as one object — `Gate{requires_flags,
forbids_flags, requires_state}`, a closed `GateConsumer` set, and `for_each_gate`
with a binding ledger. `crates/dsl/tests/gate_consumers.rs` enumerates the
consumers from the GENERATED JSON SCHEMA (derived from the types, so complete by
construction) and reds when any gate-declaring object carries part of the gate;
it states and asserts its binding count. Demonstrated red by removing the field
from one consumer.

Diagnostics, both directions of the vacuity ledger: DW0500 undeclared datum,
DW0501 read-but-never-written, DW0502 never-read, DW0503 a player-scoped datum
where emission has no acting player (decided from `evaluates_per_player` on the
closed consumer set, not from a maintained list).

Byte identity: `nobodys-cave-island` built with the pre-change compiler and this
one is identical in `datapack/`, `world/` and server config; the only delta is
the engine `dsl_version` string stamped into the creator-loop `layout.json`.
`hollow-vigil` (known-red) emits exactly the same diagnostic codes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
Takes main's version-header binding note (#345, tools/check-reference-versions.py)
and this branch's numbers: dsl 0.10.0, and 0.10.0 in both the supported list and
the DW0102 row. The new gate passes on the merged result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
Reconciled onto the SINGLE `dsl_version 0.10.0` #346 already declared — no
0.11.0. Both spec-0031 surfaces are additive and now share one version, one
`is_v10` predicate, one `reserved_v10` fence and one doc paragraph everywhere the
two branches had written their own: `envelope.rs`, `validate.rs`, `stages.rs`
(`state[]` and `on_death` side by side on `QuestsContent`), `cli.rs`,
`critical-path.ts` and the skill.

The gate walk needed no edit to inherit the two new effect roots — `for_each_gate`'s
effect branch is defined on `for_each_campaign_effect`, hence on
`for_each_effect_root`, which is now 7. Proven rather than assumed
(`a_gate_inside_the_newest_effect_roots_is_still_walked`): an undeclared datum
named from inside `on_death` (R7) and from inside `shortcuts[].on_unlock` (R6)
must raise DW0500, and does. Shown red by hand-rolling the walk's effect branch
down to one root.

Re-measuring against the new base found a REAL DEFECT in DW0503 that predates the
merge: it read every effect bundle as having an acting player. Three of the seven
roots do not — a trigger's `effects`, a trap's `payload` and a shortcut's
`on_unlock` are all emitted `Audience::Scheduled`, polled on the tick with no
executor. A `player`-scoped datum read or written in one of those would have
emitted `@s` into a sourceless function: silent at runtime, green at every gate.

Fixed at the object class, not in the check:

* `EffectRootKind::runs_with_acting_player()` — exhaustive over the closed root
  set, so an eighth root must answer it.
* `emit::root_audience(kind)` — the ONE place the emitter picks a bundle's
  audience, replacing seven literals at seven call sites (byte-identical: each
  arm is the literal that site already passed), bound to the DSL's answer by
  equality in `emit::tests::root_audience_matches_the_dsl`.
* `GateConsumer::evaluates_per_player()` now returns `Option<bool>`, and `Effect`
  answers `None` — "ask the root". A plain `true` was right for
  `on_objective_complete` and silently wrong for three of seven; `Option` makes
  the wrong answer unrepresentable.
* the DW0503 walk seeds its latch from the root and checks READS as well as
  writes, since both fail identically.

Byte identity re-measured against the new base (b54f362): `nobodys-cave-island`
is now identical across the WHOLE output tree — including `layout.json`, whose
version stamp no longer moves because main already carries 0.10.0. `hollow-vigil`
emits the same four diagnostic codes on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant