Skip to content

fix(compiler,harness): the wave countdown is a liveness census, not a kill tally (task #178) - #268

Merged
stellarfeline merged 2 commits into
worker/spec-0026-valleyfrom
worker/wave-liveness-gate
Aug 5, 2026
Merged

fix(compiler,harness): the wave countdown is a liveness census, not a kill tally (task #178)#268
stellarfeline merged 2 commits into
worker/spec-0026-valleyfrom
worker/wave-liveness-gate

Conversation

@stellarfeline

Copy link
Copy Markdown
Owner

The divergence

The owner played the staged hollow-vigil build (engine worker/spec-0026-valley @ 649a164, content @ ce82515) on 2026-08-05 and hit a hard softlock: after the first zombie wave, the campaign never advances and there is nowhere to obtain the barrow key. The #157 ladder on a byte-identical build was all greendelvec build clean, PackTest 19/19, bot passed: true with both encounters census-cleared.

Both were telling the truth about different games.

The delve's rule. #<wave> dw.wave was a tally of PLAYER KILL CREDITS: spawn_<wave> seeded it with the wave size, and only the player_killed_entity advancement decremented it. The party's rule. A kill objective asks for the wave to be down — and a mob has many ways to die that credit nobody. This campaign's opening beat had its roof and two walls carved off for the open-air rule, the world is pinned at time set noon, and the wave stands one door from the yard. The footmen chased the party out and burned.

Reproduced live on the shipped datapack (rcon, worker-isolated server):

[01:48:43] Named entity '空洞步卒' … x=6.28 z=-3.24 died: 空洞步卒 burned to death
[01:48:43] Named entity '空洞步卒' … x=2.97 z=-8.35 died: 空洞步卒 burned to death

both outside the carved north wall. Terminal state:

shipped fixed
wave mobs left in the world 0 0
kill countdown 2 0
obj/purge never completes
flag/halls-quiet never set
quest/the-barrow-key armed never armed
barrow-key chest placed anywhere no yes, with the trial_key

Why every rung was green

  1. PackTest. The generated verb_kill kills the wave and then calls k_reward_<wave> once per mob by hand — it supplies the player-kill credits itself. It asserts the countdown arithmetic, never the causal link "the mob died ⇒ the countdown moved".
  2. Bot. MineflayerExecutor.kill was the one objective-bearing step that never called requireObjective. talk-to, reach, collect, interact all end on the delve's own [dw:complete …] marker; kill ended on MOB-side evidence — confirmed kills, every engaged mob down, the server's live wave census — and returned. critical-path.json format 2 already documented the marker contract for kill; the harness simply did not implement it.
  3. Compile time. Nothing relates "a mob can die here without a player" to "this objective's completion".

So the machine proved: the room can be emptied. The player needed: the campaign accepts that the room was emptied.

What changes

Emission — one line per wave a kill objective adjudicates, in tick, ahead of every completion check:

execute if score #<w> dw.wave matches 1.. store result score #<w> dw.wave if entity @e[tag=dw_wave_<w>]

Vanilla's own answer to "how many are left", so no new primitive and no bookkeeping to keep honest. matches 1.. does both guard jobs: before spawn_<wave> the counter is unset, so an empty selector can never read as "cleared"; after a legitimate zero the line stops firing, so a finished wave stays finished and a respawns_on_rest re-seat (whose kill+spawn are atomic) re-arms cleanly. The advancement decrement STAYS as a fast path — a mob the player just killed is matched by @e through its ~20-tick death animation, so without it the final blow would land a second before the objective noticed.

Harnesskill now ends on requireObjective(step.objective) like every other step. The census logic is untouched and still decides when to stop swinging; it no longer decides whether the step is proven.

Ladder — new generated PackTest kill_uncredited: verb_kill minus the hand-fed k_reward calls. The same wave, wiped the same way, with nobody credited for it — the objective must complete anyway. Atomic (one tick, no await), so the batch model needs no new reasoning about it.

What CI now proves

  • Red on the shipped delve. Shipped datapack/ + this branch's packtest-datapack/:
    1 required tests failed :( - hollow-vigil:kill_uncredited: Expected #party dw.o_warden to match 1, but got 0 (19/20).
  • Green on the fixed one. All 20 required tests passed :).
  • Bot tier on the fixed build: critical path 'hollow-vigil' PASSED (9 steps, 2 advisory finding(s)).
  • Harness: a new unit test pins the contract — a fight genuinely won whose marker never arrives reds the step, naming the objective; and the "soft lock, loudly" fixture now asserts the step reds rather than merely recording findings.
  • Determinism: two builds byte-identical. The datapack diff against the staged build is exactly the two census lines.

docs/reference/compiler.md records both halves (the kill/spawn-wave row and the critical-path.json format-2 note).

Not fixed here

The wave can still be won by the sun rather than the party — a difficulty defect, not a completability one. The DSL already carries the sanctioned remedy (equipment.head, "never set-time"); the content fix is delvewright-campaigns#worker/hollow-vigil-daylight-helm. The compile-time version of it — a diagnostic for daylight-burning undead whose arena is walk-reachable from sky-open cells, buildable on the existing compiler::nav BFS and light.rs::sky_open — needs an owner-approved spec and is proposed, not built.

Merge gate

Engine emission + campaign content, so the owner's own playtest is the gate (batched, never per-PR). The machine red→green above admits it to her batch; it does not replace her.

Branch is based on worker/spec-0026-valley @ 649a164 so it stacks on the build she will replay; it carries a pick of the already-merged #263 so the bot tier can run 0.9.0 from this base.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnrGdWS7ADsjCoJfiJc6GC

stellarfeline and others added 2 commits August 4, 2026 20:10
…his base

worker/spec-0026-valley predates the fix, so the bot tier refuses every
0.9.0 campaign from this base and no ladder run is possible here. Identical
patch to the one already on main; it drops out on rebase/merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnrGdWS7ADsjCoJfiJc6GC
… kill tally (task #178)

A `kill` objective asks for the wave to be DOWN. The emitted countdown asked
for something else — one player-credited kill per mob — and a mob has many
ways to die that credit nobody: burning in daylight, falling out of the world,
drowning, another mob. Every such death took a mob out of the world and left
the countdown above zero, asking the party to kill something that no longer
existed. No rest, no retry and no route recovers from that.

hollow-vigil shipped it (owner playtest 2026-08-05). Its opening beat's roof
and two walls come off for the open-air rule, the world is pinned at
`time set noon`, and the first wave walked out through the carved wall and
burned — `空洞步卒 burned to death`, reproduced live on the shipped datapack.
Countdown stuck at 3 with nothing alive, so `obj/purge` never completed,
`flag/halls-quiet` never set, `quest/the-barrow-key` never armed, and the
barrow key's chest was never placed anywhere in the world.

Design change, in two halves.

**Emission.** `tick` now reconciles the countdown against the world, once per
wave a `kill` objective adjudicates:

    execute if score #<w> dw.wave matches 1.. store result score #<w> dw.wave \
        if entity @e[tag=dw_wave_<w>]

Vanilla's own answer to "how many are left", so no new primitive and no
bookkeeping to keep honest. `matches 1..` does both guard jobs: before the
spawn the counter is unset, so an empty selector can never read as "cleared";
after a legitimate zero the line stops firing, so a finished wave stays
finished and a `respawns_on_rest` re-seat re-arms it cleanly. The
`player_killed_entity` decrement stays as a fast path — a mob the player just
killed is still matched by `@e` through its death animation.

**Harness.** `kill` was the one objective-bearing step that never called
`requireObjective`; it graded itself on mob-side evidence and returned. That
is why an all-green ladder shipped a softlock: `passed: true`, both encounters
census-cleared, and the delve considered the objective open forever. The
census still decides when to stop swinging; it no longer decides whether the
step is proven. `critical-path.json` format 2 already DOCUMENTED this contract
— `kill` simply did not implement it.

Ladder proof (same world both sides, `hollow-vigil` @ ce82515):
  * generated PackTest `kill_uncredited` — `verb_kill` minus the hand-fed
    `k_reward_<wave>` calls. Shipped datapack: 19/20, `Expected #party
    dw.o_warden to match 1, but got 0`. Fixed datapack: 20/20.
  * bot tier on the fixed build: `critical path 'hollow-vigil' PASSED`.
  * live rcon on both builds: countdown 3→3→3 (stuck, 0 alive, no chest) vs
    3→1→0 tracking the burn exactly.

Determinism: two builds byte-identical; the datapack diff against the staged
build is exactly the two census lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnrGdWS7ADsjCoJfiJc6GC
@stellarfeline
stellarfeline merged commit 477f259 into worker/spec-0026-valley Aug 5, 2026
9 checks passed
@stellarfeline
stellarfeline deleted the worker/wave-liveness-gate branch August 5, 2026 10:23
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