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
Open
feat(dsl): runtime state, and the comparison that lives in the gate (spec-0031 §1-2)#348stellarfeline wants to merge 3 commits into
stellarfeline wants to merge 3 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
scopeis required and never inferred — it is the one fact about a datum no use site can supply, and the spec says so.initialis one field rather than a separatecleared, so a datum can never be un-returnable to its own start. Four operators (equals,not-equals,at-least,at-most), not six: over integersless-than nisat-most n-1, and a second spelling is a second emission path to keep honest.The gate type
crates/dsl/src/gate.rs:All six consumers answer
gate(), and all siximpls live ingate.rsrather 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), andfor_each_gatewith aGateBindingledger, in the shape ofdsl::effects.It is a borrowed view, not a
#[serde(flatten)]ed struct literal, and that is forced, not preferred. Serde rejectsflattenin combination withdeny_unknown_fields, which every stage struct carries and which is what turns an author's typo intoDW0100instead of silence. A flattened shared struct would have meant deletingdeny_unknown_fieldsfrom all 25 declaration sites — a real weakening of an existing check, which the debug doctrine forbids. Sorequires_statewas 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.rsdoes not read a list of consumers. It walks the seven stage JSON Schemas, whichschemarsderives from the Rust types, treats every object schema declaringrequires_flagsas a gate site, and requires each to declare all three fields. Enum variants are visited as the separate object schemas they are, soQuestEffect's nineteen gatable verbs are nineteen sites. A second test asserts the distinct declaring types number exactlyGateConsumer::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 readself.requires_state, so deleting the field from, say,EnvTriggerfails to compile:error[E0609]: no field 'requires_state' on type '&EnvTrigger', atcrates/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 thegate()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 norequires_state) ontoShortcut, 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:Reverted immediately; both tests green again.
Diagnostics (DW0500–DW0503)
DW0500state/…reference names a datum the campaign never declares.DW0501initialand the comparison was decided at authoring time.DW0502DW0503player-scoped datum read or written where emission has no acting player.Emission
One
dw.s_<local>objective per datum. Apartydatum is seeded insetup(world init is exactly its lifetime); aplayerdatum by a newstate_seedrunas @a[tag=!dw_state]from the tick — the tag lives in player data, so a relog does not re-seed.clear-statewrites the initial rather thanresetting: a reset score is absent, and an absent score makesunless … matchestrue, so a cleared datum would silently satisfy anot-equalsgate against its own starting value.The comparison is spliced into the guard each consumer already builds.
pending_guardwas rewritten to go through a singlegate_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
DW0503read every effect bundle as having an acting player. Three of the seven effect roots do not: a trigger'seffects, a trap'spayloadand a shortcut'son_unlockare all emittedAudience::Scheduled, polled on the tick with no executor. Aplayer-scoped datum read or written inside one of those would have emitted@sinto 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 inemit::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()returnsOption<bool>, andEffectanswersNone— "ask the root". A plaintruewas right foron_objective_completeand silently wrong for three of seven;Optionmakes the wrong answer unrepresentable.DW0503walk 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@swith nothing to resolve it to, whether the command is ascoreboard players setor anexecute if score.The gate walk inherits the two new effect roots — proven, not assumed
for_each_gate's effect branch is defined onfor_each_campaign_effect, hence onfor_each_effect_root, which #346 raised to seven. So a numeric gate insideon_death(R7) or insideshortcuts[].on_unlock(R6) is reached with no edit todsl::gate. That is a claim about a walk, so it is bound: an undeclared datum named from inside each of those bundles must raiseDW0500, 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_SITESstays 28 andGateConsumer::COUNTstays 6 after the merge, and that is a finding rather than a number adjusted to fit: #346 gaveShortcutan effect root, not a gate.Shortcutcarries norequires_flags/forbids_flagstoday, so adding onlyrequires_stateto it would be inventing a gate rather than completing one. Itson_unlockeffects 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-islandbuilt withb54f362's compiler and with this branch: the whole output tree is identical —datapack/,world/, server config,critical-path.json, every.mcfunction, andcreator-datapack/layout.jsontoo, because main already carriesdsl_version 0.10.0so its version stamp no longer moves.diff -rreports 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, theDW050xcatalog section (with the rewrittenDW0503rule 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.0allowlisted (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 isstate, not a flag", with the scope rule, the sites that have no acting player, and the two vacuity codes.What CI now proves
gate_consumers.rs);v10_state.rs);DW0141below it at every site, per stage;DW0503at a trigger's effects, a shortcut'son_unlockand asequencestep — and not aton_death, which is the dying player's own beat;runs_with_acting_player, over the closed root set, with both answers occurring;v10_state_emit.rs);Debugrendering is unchanged, so no existingseq_<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_statecomparison is excluded from the static producibility model exactly asforbids_flagsis — the flow/reachability proofs do not reason about integers.DW0501is 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 incompiler.mdunder theDW050xsection.One thing the spec got wrong
Spec §"Acceptance criteria" 2 lists "branch declaration" among the sites that must accept the comparison.
BranchDecl.flagsis not a gate — it is a pinning declaration ("these flags are SET on this branch, the rest offorks_onis pinned unset"), read by the chronicle and the branch proofs, and it is spelledflags, notrequires_flags. A branch pinningpurse == 500has 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