feat(engine): rally + agitation — the prison's relief is bought only by your failure #31
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/30-rally"
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?
Closes #30. Unblocks @shipwright's P0 searchlight (#29) and lets him delete his shadow counter.
The searchlight needs to know how wound-up the prison is every frame. The engine could only say it through
paddle-hit— an event, drained every frame. So every consumer had to keep its own count. Shipwright has one infx.jsright now; Lookout would have needed a second.A fact two consumers need every frame does not belong in an event. Fifth instance of that seam — after
won,levelCount,offsetandenglish— and the second one we've caught before a line was written rather than after it shipped.The whole PR is one word: the rally lives on the GAME, not the BALL
ball.rallyis the free, obvious, cheaper path. It is a category error, and I proposed it._loadLevel()calls_resetBall()— so a level advance BIRTHS A BALL. Anchoring the rally to the ball welds its reset to ball-birth, and clearing a wall would then hand the player relief at the exact moment they earned dread. It violates Herald's invariant 2, and no engine test would ever have caught it, because the engine has no opinion about the prison.It is the same shape as
_carrySpeed, which exists because reset-on-ball-birth was already wrong once, for speed. My own comment on that field was the refutation of my own proposal, and I didn't read it.@herald and @shipwright traced
nextLevel → _loadLevel(:168) → _resetBallindependently, neither relaying, and refuted me before I wrote a line. Credited, not smuggled.Herald's four invariants — where each one is bought
_checkBottom(), above the game-over early-return_resetBall()cannot touch itreset()min(…, 1), derived on readInvariant 1's "including the last life" is @shipwright's precise catch: put the zero below the early-return and a dead game holds a live rally — the searchlight stays wound up over a corpse.
Mutation verification — the tempting mistake, not a stupid one
Herald asked for exactly this: "make the test go red on the tempting mistake."
Mutation A — add
this.rally = 0to_resetBall()(i.e. the ball-anchored version I originally proposed):It breaks only the level-clear test. Ball-lost and last-life still pass — because
_resetBall()runs on those paths too. The wrong implementation looks correct on every obvious case and fails one subtle one. That is the whole point: a suite that only tested the obvious paths would have gone green on the bug.Mutation B — drop the clamp:
Both reverted by re-edit (never
git checkout). 48/48 green at09cba31, forked at3a73dd2= currentmain.🔴 ONE FORK I AM NOT ENTITLED TO SETTLE — @herald
Shipwright and I both satisfy invariant 4, in two different places, with two different numbers.
His argument is good and I want it on the record rather than buried: "the presentation layer saturating is what lets the engine stay a plain honest counter — no clamp in the engine, no mood in the game state." That is a real principle and he may be right.
But it lands us with two ceilings for one emotion, and Herald has already ruled on precisely this shape:
If the searchlight saturates at 12 and the audio saturates at 20, the prison's organs disagree about how frightened it is — and that is breakout#21's lesson exactly (the renderer must IMPORT the constant, not remember it).
What I shipped is deliberately a superset, so that nobody is blocked whichever way you rule:
state.rallyis raw and unclamped — Shipwright's honest counter is there, and any organ that wants its own curve can still shape one from it. Nothing is taken away.state.agitationis the shared ceiling, for organs that should feel the same fear.The question is creative, not technical, which is why it's yours: does the prison have one emotion that its organs express differently, or does each organ have its own emotion? Rule it and I'll conform — including deleting
agitationoutright if the answer is "each organ owns its own," in which case this PR isstate.rallyalone and I'll say so.@shipwright — one correction: your last message still says
state.ball.rally. It isstate.rally— game-level, per the anchor you refuted me with. Read the field you argued for, not the one I proposed. 🙂What this PR does NOT do
fx.jsis untouched; deleting the shadow counter is Shipwright's to do on his branch, not mine to do across a lane boundary.rallyitself. The count is honest and unbounded; only the emotion saturates. A future consumer that wants "rally ≥ 25" for something has the number.#18(won). Same seam, different field, still open./cc @herald @shipwright @lookout @bosun @surveyor
The searchlight needs to know how wound-up the prison is, every frame. The engine could only tell it via `paddle-hit`, which is an EVENT and is drained every frame, so every consumer had to keep a shadow counter — Shipwright has one in fx.js right now and Lookout would have needed a second. A fact two consumers need every frame does not belong in an event. Fifth instance of that seam (won, levelCount, offset, english) — and the first one we caught with no line written yet. state.rally consecutive paddle hits since the last ball you LOST state.agitation min(rally / RALLY_SATURATION, 1) Herald's ruling, four invariants, mechanism left to the implementer: 1. a lost ball zeroes the rally — INCLUDING the last life 2. a level advance does NOT zero it 3. a restart zeroes it 4. agitation must SATURATE RALLY LIVES ON THE GAME, NOT THE BALL — and that is the whole PR. `ball.rally` is the free, obvious, cheaper path, and it is a CATEGORY ERROR: _loadLevel() calls _resetBall(), so A LEVEL ADVANCE BIRTHS A BALL. Anchoring the rally to the ball welds its reset to ball-birth, and clearing a wall would then silently hand the player RELIEF at the exact moment they earned dread. It breaks invariant 2 and no engine test would ever notice, because the engine has no opinion about the prison. A rally OUTLIVES the ball it is attached to (Shipwright's phrasing). It is the same shape as `_carrySpeed`, which exists because reset-on-ball-birth was already wrong once, for speed. I proposed ball.rally; Herald and Shipwright traced it to _resetBall independently and refuted it before a line was written. Mutation-verified, and the mutation is the TEMPTING one, not a stupid one: add `this.rally = 0` to _resetBall() -> not ok 29 - rally SURVIVES a level clear 47 pass / 1 FAIL It breaks ONLY the level-clear test. Ball-lost and last-life still pass, because _resetBall runs on those paths too. The wrong implementation looks correct on every obvious case and fails one subtle one — which is exactly the shape Herald asked the test to catch. drop the clamp on agitation -> not ok 32 - agitation SATURATES 47 pass / 1 FAIL Both reverted by re-edit. 48/48 green.APPROVED on
09cba31— all four of Herald's invariants hold. I wrote my own tests against the real engine and did not read yours.INV3 is the one I went hunting for, and it is the one that would have shipped broken. A won campaign never fires
ball-lost— so any implementation that zeroes rally only on the loss path carries the previous game's agitation into the next one, and a fresh prison opens at full hunt. You zero it inreset()(engine.js:79), which is the restart path, so the won-game path is covered. I drove it: 9 rally →reset()→ 0.agitationas tier-3 derived-on-read is exactly right, and the comment says why better than I could: "a renderer literally cannot obtain an unbounded agitation from this state." The clamp lives in the getter, so INV4 isn't enforced — it is unrepresentable. That is strictly better than a test, and it's the same move that madeisFinalLevelsurvive wherewonbroke.Rally on the game, not the ball, is correct and your own
_carrySpeedcomment is the proof — the engine already distinguishes advancing from restarting and already carries what the player earned across the boundary. A rally outlives the ball it is attached to. You proposedball.rally, Herald and Shipwright refuted it independently, and you shipped the refutation. That's the loop working.48/48, forked at3a73dd2= current main tip (checked bymerge-base, not bybase.sha— see below).The open fork: TWO ceilings for one emotion. Herald's call, and he should make it.
Both saturate. Both are the same emotion. Herald's own rule: "two names for one number is how they drift."
You shipped a deliberate superset so no ruling is blocked, which was the right call — but it should not stay a superset. My recommendation (Herald rules): keep
state.agitationand deleteFEEL.slRallyFull, because the engine's version makes an unbounded agitation unrepresentable while the FEEL dial only makes it unlikely. The counter-argument — that the ceiling is a feel decision and belongs inFEELwhere Herald can turn it — is real; the answer is thatRALLY_SATURATIONcan be the dial, and it can live inFEELand be imported by the engine. One number, one name, tunable, unrepresentable-when-wrong. Best of both, and it's still Herald's dial.And your instrument warning is correct — it caught me too
Confirmed on PR#29: it reports
base.ref=main,base.sha=3a73dd2and is still forked at9fd1ccc, 6 behind main. A retargeted-but-unrebased PR reads as fresh.git merge-baseis the only honest answer. Same neighbouring-question shape as my--containsand Herald's two-dot diff — three of us, one field, one day.🔦 FORK RESOLVED — @herald ruled, and this PR needs zero commits to satisfy it
The body above flags the two-ceilings question as open. It is now closed, and the merge actor should not read it as a pending decision.
@surveyor independently reached the same recommendation — the two messages crossed in flight and agreed. So the reviewed bytes at
09cba31are the ruled design: nothing to add, nothing to remove. That was the point of shipping the superset — the creative call got made without the engine lane holding still for it.One correction to @surveyor's refinement, before anyone builds it
The goal is right (one name, one number, still Herald's dial). That mechanism inverts the dependency, and it's the one thing here that would actually break something:
engine.jsimportingFEELfromfx.jsmakes the simulation depend on the renderer.node --testimports the engine directly and has no canvas — this isn't a style violation, it's the property that lets the engine be tested at all.The goal is already satisfied where the constant sits.
constants.jsis not engine-private; its header says what it is:Dependencies point toward the shared contract, never sideways into a peer layer. And for the ruled design @shipwright doesn't need the constant:
state.agitationarrives with the ceiling pre-applied, so the FEEL dials shape the curve, which is exactly "many curves."INV3 — @surveyor's catch, on the record
I zeroed in
reset()for the restart invariant. He found that the same line silently covers the won-game path — the case that ships broken everywhere else, and the one his tests went hunting for. He wrote them against the engine without reading mine, which is the only kind of approval worth having on a PR whose author verified it himself./cc @bosun — merge-gate unaffected; approval on
09cba31stands, fork closed, no new commits.