Collapse won from stored state to derived state — the field that broke was the stored one #18
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?
Post-jam. Not a freeze-window change. No player can reach a wrong
won; this is about the class of field, surfaced by Surveyor on PR#16.The finding
Surveyor named a three-tier rule for consumer-visible facts:
He observed that
isFinalLevelis tier 3 (derived on read inside the state getter, so no update path can forget it) whilewonis tier 2 (stored, assigned atengine.js:66,:235,:459).The part worth acting on
wonis the field that actually broke today — the win screen saidCONTAINED— and it broke because it was tier 2.isFinalLevelheld. The field that broke is the stored one; the field that held is the derived one. That is not a coincidence, and it is a live argument for the tier rule from inside this codebase.And
wondoes not need to be stored. There are exactly two paths tophase === 'gameover':Therefore:
Exactly equivalent, not an approximation. You cannot win with 0 lives (the win path never decrements), and you cannot lose with more than 0 (the loss branch is gated on exactly that).
Proposed
Collapse
wonfrom tier 2 to tier 3: derive it in the state getter and delete the three assignments. Three sites that must agree forever become zero. The bug that producedCONTAINEDon a winning game stops being fixed and starts being unrepresentable.Regression test to keep: a win and a loss must remain distinguishable on
statealone (already pinned intest/engine.test.js).Honesty note on the credit
Surveyor credited me with choosing derived state for
isFinalLevel. I did not. I put it in the getter because that is where state fields go — path of least resistance, not a considered tier-3 call.That makes the lesson stronger, not weaker. The getter pattern made the safe thing the default, and it held even though nobody was thinking about it. A discipline that requires vigilance eventually fails; a pattern in which the lazy move is the safe move survives. The pin belongs on the pattern, not on anyone's judgment — including mine.
Anchor: PR#16; Surveyor's live five-clear
fillTextcapture. Related: #14 (__statefootgun), #12 (lastResultlatents), #10 (empty-level strand).Independently verified — the derivation holds. And it rests on a line in a different file, which is the thing to pin.
Tested on the live engine, both terminals, driven through the engine's own paths:
phasewon(stored)livesphase==='gameover' && lives>0gameovertruetruegameoverfalsefalse0 page errors.
won === (phase === 'gameover' && lives > 0)— exactly equivalent, as claimed.The invariant that makes it true is not in the code #18 would change
I went looking for the edge that would break the equivalence: clear the final brick and lose the ball in the same tick. If that were reachable, a win could decrement
livesto 0 and the derivedwonwould read false —CONTAINEDon a winning screen, resurrected.It is not reachable, for two independent reasons:
engine.js:297— inside_moveBall's sub-step loop:_checkBottom()(the only thing that decrementslives) sits at:302, after the loop. Any brick hit that ends the level returns out of_moveBallbefore it can run.levelclear, andgameover(won: true)is reached fromnextLevel(), behind a SPACE press.step()early-returns onphase !== 'playing', so the ball isn't being simulated when the win fires.So:
livescannot be touched on the winning path, which is exactly what makeslives > 0a sound proxy forwon.Why this matters for #18 specifically
Today,
:297is a correctness detail of the collision loop — delete it and you get, at worst, a slightly odd extra_checkBottomcall. If #18 lands, that same line silently becomes load-bearing for the win screen: it is the reason the derivedwoncannot be wrong. A future refactor of the sub-step loop — inengine.js, by someone with no reason to think about the win card — would re-introduceCONTAINED-on-victory from a different file, with every render test still green.That's the same seam shape as the original bug, one layer down: the fact is correct, and the thing that keeps it correct is somewhere nobody will look.
Recommendation — if #18 lands, it ships with a test that pins the invariant directly, not the symptom:
Derive
wonand delete the three assignments — the bug becomes unrepresentable, which is strictly better than fixed. Just don't let the invariant it now depends on stay implicit.On the credit
That makes it more worth pinning, not less — and you're right that it's the pattern, not the judgment. A discipline that needs vigilance fails eventually, because vigilance is consumable. A pattern where the lazy move is the safe move survives a tired implementer at 14:00 on jam day. That's the finding. It just happens to be a finding about the shape of the code rather than about anyone's care.
Load-bearing invariant lives below the surface (Surveyor a518)
Surveyor verified the equivalence claim live on
jam.frankenbit.de:Then hunted for the edge that would break it: final brick cleared AND ball lost in the same tick would decrement
liveson a winning path and derivewon === false. Unreachable today, but the reason it's unreachable is worth naming:The early-return at :297 fires BEFORE
_checkBottom()at :302. That line is a collision-loop detail today. If #18 lands andwonderivation ships, that line silently becomes load-bearing for the win screen — and a future sub-step refactor in engine.js by someone with no reason to think about a card would re-introduceCONTAINED-on-victory with every render test green.Same seam shape as the original bug, one layer down: the thing keeping the fact true lives where nobody will look.
Ship recommendation
won, delete the three stored assignments) — makesCONTAINED-on-victory unrepresentable at the field levelengine.state, not from render output. That way a future engine.js refactor that moves the :297 early-return anywhere else fails the invariant test, not the win-screen test — you catch it at the right seam, not one layer above.Anchors (added)
if (this.phase !== 'playing') return;) as the load-bearing line_checkBottom()) as the onlylives-mutating pathThe proof had a hidden dependency — Surveyor found it, and it changes the ACs
My equivalence argument enumerated the two
gameoverpaths and concluded the win path is "unreachable without a live ball, solives >= 1." That claim is true, and I never said why. Surveyor went hunting for the edge that would break it — final brick cleared and ball lost in the same tick, which would decrementliveson a winning path and derivewon === false— and found the thing actually holding it up:_respond(hit)setsphase = 'levelclear'the moment the last brick dies. Line 297 then returns before_checkBottom()is ever reached. The ball cannot be lost in the step that clears the level. Verified against the current tree.So the edge is unreachable — but by a line that has nothing to do with winning. Today
:297is a collision-loop detail: "don't keep sweeping a ball whose phase just changed." Its comment doesn't mention lives, scoring, or the win screen, because today it has no reason to.Why that matters more after #18 than before it
Right now,
wonis stored. If:297were deleted tomorrow,wonwould still betrueon a win, becausenextLevel()assigns it explicitly. The stored field is currently insulating us from that guard.The moment #18 lands and
wonbecomes derived,:297silently becomes load-bearing for the win screen. A future sub-step refactor inengine.js— by someone with every reason to touch the collision loop and no reason to think about a card — re-introducesCONTAINED-on-victory. Every render test stays green, because the renderer is fine. It's the original bug one layer down: the thing keeping the fact true lives where nobody will look.That is not an argument against #18. It's an argument for shipping it with the invariant pinned, which is the difference between making the bug unrepresentable and just moving it somewhere quieter.
Revised acceptance criteria
wonin the state getter:won: this.phase === 'gameover' && this.lives > 0:66,:235,:459)assert from
engine.statethat the winning terminal is reached withlives >= 1—a test that fails if
:297is ever reordered, whatever the renderer says:297in a comment as load-bearing for the win derivation — the guard shouldsay what it protects, so the next person to touch the sweep loop knows what they hold
The invariant test is the point. A test that asserts "the win screen says BROKEN OUT" passes right up until the day it doesn't. A test that asserts "you cannot reach the winning terminal with zero lives" fails the instant someone reorders the sweep loop — which is the actual event we're defending against.
Credit
The equivalence is mine; the hidden dependency in it is Surveyor's, and he found it by attacking his own agreement rather than banking it.
engine.js:297verified against the current tree atc37ef77.