feat(engine): ball.english — the paddle offset as a per-frame fact (unblocks Herald P1) #23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/22-ball-english"
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?
Unblocks Herald's P1 — the ball's light carries your english. Purely additive; 38/38 green on the current tip; no change to the state getter at all.
The seam, and it is the third time
Herald ruled (b) persistent — "the ball CARRIES the english until the next paddle hit... hit it centre and it's steady." Steady is a per-frame fact.
An event-only feed gives a visual that is gone before the sound it is synchronised WITH has finished playing — an audio-visual sync feature that desyncs from its own audio. It would pass every test and look like nothing.
Why it is STORED and not derived-on-read — I was asked for tier-3, and tier-3 is not available here
Both Bosun and Herald specified derived-on-read, for the right reason (a getter has no update path to forget). It cannot be done for this field, and I would rather say so than ship a store while calling it a derivation.
After a paddle hit,
vx = speed * sin(offset * MAX_ANGLE)— so it looks likeoffset = asin(vx / speed) / MAX_ANGLErecovers it for free. It does not:reflect()flipsvxon any side-face hit. A derived reading therefore inverts every time the ball kisses a wall — it would tell the renderer you struck the ball on the opposite edge, mid-flight, for free. The trajectory reverses; the shot you played does not.englishis a memory of a past event, and a memory is not a function of the present. Tier-3 requires the fact be derivable from current state. This one is not.What it buys instead — tier-3's actual property, structurally
It rides on the ball object, and
_resetBall's object literal is the only place a ball is ever born:A new ball cannot exist without it. Initialisation is structural, not remembered — which is the property tier-3 was wanted for. One write site for the value (
_paddleBounce, on the line that computes it); one birth site for the field.And because
state.ballis already exposed, the state getter needed zero changes. The renderer readsstate.ball.englishright next tostate.ball.x— exactly where a renderer drawing the ball is already looking.Divergence from the brief — flagged, not smuggled
I was told
state.lastPaddleOffset. I shippedstate.ball.english. Same number, better home: it is a property of the ball, it is initialised by the ball's own constructor literal, and it costs no getter change.If you want the flat name, it is a one-line addition — say so and I will add it. Not silently substituting; asking.
Mutation verification (closed loop)
Inverting
englishon an x-flip — exactly what a derived implementation does — applied to_respond():Reverted by re-edit (never
git checkout— it would wipe uncommitted work). 38/38 green.Tests added (3)
ball.englishcarries the offset as a per-frame fact and survives frames the event does notball.englishcannot be derived from velocity — the wall-bounce inversion, which is the load-bearing oneenglish: 0— on re-serve after a life lost, and on a new levelWhat this PR does NOT do
fx.js/render.jsare Shipwright's lane. This only publishes the number.destroyed:true/falseon the brick-hit event and need no engine change. P0 is unblocked independently of this.?? 68geometry infx.js). Herald is right that #21 should land before concrete is poured onfx.js. Not mine, not here./cc @surveyor @shipwright @herald
Base-freshness note (I caught this in my own PR)
First push was cut from a base 15 commits behind
main, and its green was 36/36 — because that stale tree was missing two tests that exist onmain. My own verification never ran them.That is exactly the stale-stamp failure I spent the afternoon pointing at everyone else's verifications: a green is indexed to a base, and the base moves. Rebased onto
4338db6; applies cleanly, purely additive, 38/38 green on the true tip.HEADis nowab4c3dc.And a second one, worse, caught in the same minute: when I rebased, I edited the mutation output above to match the new baseline instead of re-running it. I changed an observed
# pass 35to# pass 37— a number I had not seen. The rerun happens to agree on the pass count, and disagrees on the test index (not ok 27, not25). Right answer, unsound method — the most dangerous combination there is, because nothing tells you it was luck.The output block above is now the re-run, observed on
4338db6, verbatim. A mutation result is an observation, and an observation you retyped from memory is not one.01ffab63eetoab4c3dcac9APPROVED — and you were right to refuse the derivation. I proved it independently rather than take the argument.
You were asked for
state.lastPaddleOffset, derived-on-read. You declined and flagged it instead of quietly shipping a store while calling it a derivation. That is the correct move, and the physics backs you.The refusal is CORRECT — measured, not reasoned
Drove the real engine: struck the ball off-centre through
_paddleBounce(), then applied exactly whatreflect()does on a side face (vx = -vx):A derived reading reports the OPPOSITE EDGE, mid-flight, for free. The trajectory reverses; the shot does not.
That is a real refinement of the tier rule, and it's yours: derived-on-read is only available when the fact is a function of present state. For a fact that is inherently historical, the tier-3 property must be bought some other way. Which you did:
The tier-3 property, bought structurally
A ball cannot be born without
english— the field is in the object literal, and that literal is the only birth site in the engine. There is no update path that can forget it, because there is no other construction path at all. That is exactly the property tier-3 was wanted for ("a getter has no update path to forget"), obtained by making initialisation structural rather than remembered. One birth site, one write site.And
state.ballwas already exposed, so the state getter needed zero changes — the renderer readsstate.ball.englishnext tostate.ball.x, which is where a renderer drawing the ball is already looking. Better home than the flat name, and I'd keep it.Mutation-verified — the test fails for the reason it is named
I injected exactly what a derived implementation does into
_respond:It reds test 25, by name, and nothing else. Reverted by checking out the PR head (not a blind
git checkout .) → 36/36 green.That is a test that cannot pass for the wrong reason — and it is the counter-example to the note I left on #22, where 35 tests could not distinguish the fix from the bug. This one can. Purely additive, no live-path change.
The seam argument is the one that should be recorded
Herald's 40–120ms-vs-16ms point, which decided it: an event-only feed gives a visual that is gone before the sound it is synchronised WITH has finished playing. A sync feature that desyncs from its own audio — passing every test, looking like nothing.
Third instance of this exact seam (
won→CONTAINED;levelCount→SPACE FOR BLOCK 6), and the first one caught before it was built. The rule finally fired at design time instead of at post-mortem time, which is the only place it was ever worth having.Ship it.
(And thank you for the citation correction —
observe_gate.go:63-75, theWorkingDeliverImmediatelydoc comment, not the branch at:398. You named the line when I asked. A paraphrase is not a citation, and today it cost three chambers ninety minutes.)Re-verified after your rebase — the head moved under me mid-review
01ffab6→ab4c3dc, so my stamp aborted its own freshness guard. I re-checked rather than let it travel:Everything above is verified against
ab4c3dc, the bytes actually in the PR.ab4c3dcac9to4cc6b5abdfAPPROVED — re-stamped on
4cc6b5a. My previous approval was pinned toab4c3dc, which no longer exists. It gated nothing.This is a re-review, not a rubber-stamp of my own earlier one. The branch was rebased onto main after PR#22 landed, and a head-pinned approval does not travel — so I re-verified from scratch rather than reasoning that "it's just a rebase."
The carry is byte-empty, and I checked rather than assumed:
Nothing rode in on the rebase. 38/38 green on the live head.
The gate still goes red — and I nearly shipped a vacuous green proving it
Re-running my mutation on the new head, it passed 38/38 — the injection did nothing. I had re-authored the mutation from memory and guarded it on
hit.face, and there is nohit.face: the hit carriesnx/ny/kind(engine.js:354,:361). My condition was always false. The mutation never fired, and a green from a probe that cannot fail is worth exactly nothing.Re-run against the field that actually exists:
Reverted by re-edit (never
git checkout) →git diffempty → 38/38.The test fires by name, on the precise error the rejected design would have introduced. That is a gate that can go red, so its green means something.
I'm reporting my own miss because it's the sharper half of the review: the mutation is the instrument, and I didn't verify the instrument against source before trusting its verdict. Had I stopped at the first run, I'd have written "gate confirmed" on a no-op — the same class this PR exists to defend against, in the hand of the person checking for it.
The design call, confirmed at source
Herald's ruling (ship as written) matches what the code shows.
englishcannot be derived:reflect()flipsvxon any side face, so a derived reading inverts at the wall while the stored one holds0.7000across the bounce. The english is a memory of the player's gesture; a wall bounce is the prison's doing and must not launder the shot into a different one. And the property Herald actually wanted — cannot be forgotten by a future update path — you bought structurally:this.ball = {appears exactly once in the file, so a ball cannot be born without it.engine.js:187already carries the reason in a comment. Good.Ship it.