bug(engine): empty-brick level strands player (level-clear is event-consequence not state-invariant) #10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Empirically found by Engineer 28c1 during jam freeze; NOT reachable in shipped 5 levels (all have bricks) so not fixed under freeze. Logged for post-jam.
The bug
A level with zero destructible bricks strands the player forever.
Root cause:
_remaining === 0is only evaluated INSIDE_hitBrick. Level-clear is an event-consequence, not a state-invariant — so if brick count is zero without any brick ever being hit, engine never notices it has won.Surfaced as a design-smell: both Shipwright and Surveyor reached for
alive = falseshortcuts in independent tests and both got stranded. Two independent people hitting the same shortcut and getting the same result = the coupling itself is a smell, not just a test artifact.Fix (belt-and-braces, both close it, both is right)
_remaining === 0after_loadLevelAS WELL AS after_hitBrick— level-clear becomes a state invariant, not an event consequencevalidateLevelreject an empty grid — structurally prevent shippingRelated
alive = falseshortcut instances (test-time stranding)Anchor
wonfrom stored state to derived state — the field that broke was the stored one #18Narrowed — Carpenter is right, and I over-scoped this (verified at
src/levels.js)Authored levels cannot ship empty. The guard already exists and already runs:
So an all-zero grid in the exported
LEVELSthrows on import. The class of bug I described cannot reach a player through the authored level data, and I filed it as though it could.What actually remains
Engineacceptslevelsdirectly in its constructor and never callsvalidateLevel. So the invariant is enforced by the data module, not by the engine — and any caller that injects levels directly (a test, a future editor, a level-shuffle mode, a hot-reload path) bypasses the guard entirely and can strand the player on a brickless level.The residual is real but narrower than filed: the engine should not depend on its caller having validated the input. Level-clear should be a state invariant of the Engine (
remaining === 0 ⟹ levelclear, evaluated on load as well as on hit), not a property the level module happens to guarantee.Same shape as #18: the thing keeping the fact true lives somewhere the engine cannot see. Today
levels.jsholds it up. Nothing makes that contractual.Revised scope
Engineenforces the non-empty-grid invariant itself on level load (fail loud, or clear immediately — either is defensible; strand is not)Engine, bypassinglevels.jsvalidateLevel— the authored-data guard is correct and stays where it isCredit: Carpenter caught the over-scope (bus 3683), and I verified it against the tree rather than take it. No freeze change was requested and none was made.