Skip to content

feat(dsl): on_death is an effect root, and so is the one nobody had noticed - #346

Merged
stellarfeline merged 3 commits into
mainfrom
feat/on-death-root
Aug 10, 2026
Merged

feat(dsl): on_death is an effect root, and so is the one nobody had noticed#346
stellarfeline merged 3 commits into
mainfrom
feat/on-death-root

Conversation

@stellarfeline

Copy link
Copy Markdown
Owner

Implements spec-0031 acceptance criterion 3, plus the blind spot its on_death
section names. dsl_version 0.9.0 → 0.10.0.

Two roots, added together because they are the same lesson read from both ends

R7 on_death is new surface that starts inside the enumeration. The engine had
on_respawn and nothing that ran at the moment of death, so "the purse is dropped
on 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_unlock is not new. It has been a Vec<QuestEffect> emission
lowers 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 narrate inside it was never inventoried, a set-flag was invisible to
the flag model, a sequence would have emitted a call to a function nothing
generated. Zero campaigns had used it, which is the only reason it never shipped as
a bug.

tools/check-effect-roots.py could never have found it — that gate greps for the
roots it knows. The gate that did is check-capability-ownership.py check E,
which reads the effect-bundle fields out of stages.rs and fails on any it
cannot 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_trigger desugar instead. That
pattern 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_death looks like to an author

"on_death": [
  { "type": "narrate", "style": "chat", "text": "The dark takes you." },
  { "type": "set-flag", "flag": "flag/has-fallen", "requires_flags": ["flag/act-two"] }
]

Campaign-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_flags every root already carries; no second gating
surface. Audience is Solo, the dying player, as on_respawn and on_caught
already 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 it
is 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 a
death; cp_respawn_check is still its only reader, off one tick line. What
on_death adds is a second acknowledgement of that one counter, not a second
detector — because dw.death_ack is 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 own
Health:0.0f guards.

Contingent on the live measurement — stated plainly

The death position is not captured, and nothing here guesses at it.
emit::death_position_capture is a named, deliberately empty seam emitted ahead of
the dispatch, carrying both candidate mechanisms (the pre-respawn death advancement;
the LastDeathLocation player NBT) and what has to be true of each. Nothing
downstream reads a position yet, so filling it in is additive.

What is not contingent, because this repo already measured it: deathCount ticks
up on the death rather than on the respawn, @a matches a player on the death
screen (the existing alive guard exists because it does), and the corpse stands
where 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 zero
campaigns and be vacuous.

Walkers changed

Roots 6 and 7 were one edit in effects.rs's macro. The type then forced exactly
three sites to decide (plan::for_each_effect_root's adapter and the completability
reading, flow's producer policy, continuity's exclusion reasoning) and two enums
to widen (EffectSite in dsl, EffectRoot in compiler) — which is the design
working. 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 from EffectRootKind::ALL,
    one campaign per root from an exhaustive match (an eighth root is a compile
    error there), six walkers plus DW0360 asked about every root. The per-walker
    tests 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; the
    Solo audience; a death beat with no checkpoint arming the detector alone
    without dragging in the respawn half; the DW0141 fence and its control; and the
    whole tree identical with an empty beat.
  • effect_root_sweep — 7/7, plus a new test that an empty on_death binds
    nothing, so RootBinding keeps being able to say "this campaign has no death
    beat" 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 en and --lang zh-cn:

  • nobodys-cave-island: 610 files, 608 byte-identical.
  • hollow-vigil: known-red DW0331 on both engines, unchanged.
  • build-every-campaign.py green: 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 own
SUPPORTED_DSL_VERSION stamp
(creator.rs:812), plus that file's entry in
manifest.json. It moves on every version bump by construction and is
creator-side, not player-facing. The shipped datapack/** is byte-identical.

Version discipline

0.10.0 with a per-stage fence (reserved_v10, DW0141 — no new DW code). No
campaign uses the surface, so no adoption round is owed. check-harness-dsl-version
caught the harness allowlist lagging, as designed; critical-path.ts and its test
now carry 0.10.0.

Coordination

envelope.rs and stages.rs are shared with feat/runtime-state, which also needs
a version bump — 0.10.0 should carry both features. The gate struct is untouched
here.

Notes for review

  • check-capability-ownership.py check D newly examines EffectSite (7 variants
    took it past MODIFIER_MIN_VARIANTS) and flags quest as a partial modifier. It
    is justified rather than silenced: quest is an operand, and the cross-cutting
    question is already lifted to the enum as EffectSite::quest() -> Option<&str>.
  • Audit item #16b and fix-sequence item 0 are marked closed with the
    reasoning for the departure.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL

stellarfeline and others added 3 commits August 9, 2026 19:57
…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
stellarfeline merged commit b54f362 into main Aug 10, 2026
12 checks passed
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
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