feat(dsl): on_death is an effect root, and so is the one nobody had noticed - #346
Merged
Conversation
…oticed Two effect roots, added together because they are the same lesson read from opposite ends. R7 `on_death` (DSL v0.10, spec-0031 AC3) is new surface, and it starts inside the enumeration. With it, "the purse is dropped on death" stops being an engine feature and becomes ordinary content in a general mechanism. R6 `shortcuts[].on_unlock` is not new at all. It has been a `Vec<QuestEffect>` emission lowers since spec-0016 §2, and it was in no enumeration — so every proof, every l10n pass and every diagnostic written for the general path silently did not cover it. Zero campaigns had used it, which is the only reason it never shipped as a bug. Death detection rides the existing edge and adds no second detector: `dw.deaths` (`deathCount`) is still the only thing that notices a death and `cp_respawn_check` is still the only reader. What `on_death` adds is a second ACKNOWLEDGEMENT of that one counter, because the existing `dw.death_ack` is deliberately withheld while a player is dead — which is exactly the window the beat fires in. The death POSITION is not captured. `emit::death_position_capture` is a named, empty seam ahead of the dispatch; which vanilla mechanism records it is being measured live and is not guessed here. CI now proves: every root × every campaign-wide walker, with the roots enumerated from the type and the per-root fixture from an exhaustive match, so an eighth root is a compile error rather than a green test that says nothing about it (`tests/effect_root_walkers.rs`); the death edge's shape, both sides, with and without a checkpoint (`tests/v10_on_death.rs`); and that a campaign declaring no death beat emits a byte-identical tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
The gate added by #345 does exactly what it exists for: it caught this branch's SUPPORTED_DSL_VERSION bump leaving the compiler-reference header, its supported-version list and the DW0102 catalog row all claiming 0.9.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
stellarfeline
added a commit
that referenced
this pull request
Aug 10, 2026
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.
Implements spec-0031 acceptance criterion 3, plus the blind spot its
on_deathsection names.
dsl_version0.9.0 → 0.10.0.Two roots, added together because they are the same lesson read from both ends
R7
on_deathis new surface that starts inside the enumeration. The engine hadon_respawnand nothing that ran at the moment of death, so "the purse is droppedon death" would have had to be an engine feature. As a root it is ordinary content
in a general mechanism — which is the test this project applies before adding any
surface.
R6
shortcuts[].on_unlockis not new. It has been aVec<QuestEffect>emissionlowers since spec-0016 §2, and it was in no enumeration at all — so every proof,
every l10n pass and every diagnostic written for the general path silently did not
cover it: a
narrateinside it was never inventoried, aset-flagwas invisible tothe flag model, a
sequencewould have emitted a call to a function nothinggenerated. Zero campaigns had used it, which is the only reason it never shipped as
a bug.
tools/check-effect-roots.pycould never have found it — that gate greps for theroots it knows. The gate that did is
check-capability-ownership.pycheck E,which reads the effect-bundle fields out of
stages.rsand fails on any itcannot account for. Both gates' headers now say which shape each one catches.
Why R6 is a root and not a desugar
The audit's fix sequence recommended the
Ambush::to_triggerdesugar instead. Thatpattern works because an ambush is a trigger — the trigger is the entirety of
what it emits. A shortcut's unlock is not: its detection is a once-only
#sc_<id>sentinel poll that in the same function clears the gate region, retires the
affordance (
DW0421) and kills the wrong-side bodies, with permanence structural(
DW0372). Desugaring would have put two independent detectors on one event.The departure and its general rule are recorded in the audit.
What
on_deathlooks like to an authorCampaign-wide, on the quests stage, one bundle. Not a field on a checkpoint: where
you come back is a property of a checkpoint, that you died is not — it is true at
every point of the delve, and a bundle repeated per checkpoint is N copies with N
chances to forget one. Phase-specific behaviour uses the per-effect
requires_flags/forbids_flagsevery root already carries; no second gatingsurface. Audience is
Solo, the dying player, ason_respawnandon_caughtalready are.
It is optional to the completability model in the strongest sense it has —
nobody is forced to die — so it registers
close-gates only, and nothing inside itis credited as a flag producer. A mainline reachable only by dying is not reachable.
Death detection: the existing edge, one detector, two acknowledgements
dw.deaths(deathCount) is still the only thing in the delve that notices adeath;
cp_respawn_checkis still its only reader, off onetickline. Whaton_deathadds is a second acknowledgement of that one counter, not a seconddetector — because
dw.death_ackis deliberately withheld while a player is dead(task #145), which is exactly the window the beat fires in. So the corpse side gets
dw.death_seen, and the two branches are mutually exclusive by their ownHealth:0.0fguards.Contingent on the live measurement — stated plainly
The death position is not captured, and nothing here guesses at it.
emit::death_position_captureis a named, deliberately empty seam emitted ahead ofthe dispatch, carrying both candidate mechanisms (the pre-respawn death advancement;
the
LastDeathLocationplayer NBT) and what has to be true of each. Nothingdownstream reads a position yet, so filling it in is additive.
What is not contingent, because this repo already measured it:
deathCountticksup on the death rather than on the respawn,
@amatches a player on the deathscreen (the existing
aliveguard exists because it does), and the corpse standswhere it fell. The open question is only how to record that position durably.
One more limitation, reported rather than papered over: the corpse-side positive is
not drivable from PackTest — a generated template drives a fake player and a fake
player is alive. The guard's shape is proven at compile time instead. No generated
PackTest is added: no campaign declares
on_death, so a template would bind to zerocampaigns and be vacuous.
Walkers changed
Roots 6 and 7 were one edit in
effects.rs's macro. The type then forced exactlythree sites to decide (
plan::for_each_effect_root's adapter and the completabilityreading,
flow's producer policy,continuity's exclusion reasoning) and two enumsto widen (
EffectSiteindsl,EffectRootincompiler) — which is the designworking. Everything else inherited: l10n inventory + localize,
for_each_campaign_effect,the timeline,
DW0360's anchor seal,emit::declared_flags,all_campaign_effects,rehearsal,
combat::actor_beats.What CI now proves
tests/effect_root_walkers.rs— the matrix. Roots fromEffectRootKind::ALL,one campaign per root from an exhaustive match (an eighth root is a compile
error there), six walkers plus
DW0360asked about every root. The per-walkertests it sits beside each prove one walker against the roots their author
remembered and stay green when a root is added; this cannot. Demonstrated red by
making one walker skip one root: it names the walker and the root.
tests/v10_on_death.rs— the edge's shape both sides; one detector; theSoloaudience; a death beat with no checkpoint arming the detector alonewithout dragging in the respawn half; the
DW0141fence and its control; and thewhole tree identical with an empty beat.
effect_root_sweep— 7/7, plus a new test that an emptyon_deathbindsnothing, so
RootBindingkeeps being able to say "this campaign has no deathbeat" instead of reporting every campaign as bound.
Byte identity
Every campaign the content repo carries, built with the engine at this branch's
merge base and with this branch,
--lang enand--lang zh-cn:nobodys-cave-island: 610 files, 608 byte-identical.hollow-vigil: known-redDW0331on both engines, unchanged.build-every-campaign.pygreen: 2 discovered, 1 built, 1 known-red.The two files that differ are the same file and its hash:
creator-datapack/layout.json's"version"field, which is the engine's ownSUPPORTED_DSL_VERSIONstamp (creator.rs:812), plus that file's entry inmanifest.json. It moves on every version bump by construction and iscreator-side, not player-facing. The shipped
datapack/**is byte-identical.Version discipline
0.10.0with a per-stage fence (reserved_v10,DW0141— no new DW code). Nocampaign uses the surface, so no adoption round is owed.
check-harness-dsl-versioncaught the harness allowlist lagging, as designed;
critical-path.tsand its testnow carry
0.10.0.Coordination
envelope.rsandstages.rsare shared withfeat/runtime-state, which also needsa version bump —
0.10.0should carry both features. The gate struct is untouchedhere.
Notes for review
check-capability-ownership.pycheck D newly examinesEffectSite(7 variantstook it past
MODIFIER_MIN_VARIANTS) and flagsquestas a partial modifier. Itis justified rather than silenced:
questis an operand, and the cross-cuttingquestion is already lifted to the enum as
EffectSite::quest() -> Option<&str>.reasoning for the departure.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL