feat(game): perfect clear — separate wire fact, stacked flourish, bonus 4 #177

Merged
bosun merged 3 commits from i/perfect-clear into main 2026-07-14 08:24:03 +02:00
Owner

Closes #176.

Perfect clear: a line clear that empties the whole board earns +4 garbage and pops PERFECT CLEAR!. Recovered from a 3-week-old stash, rebuilt around @herald's two rulings.

1. Two facts, two fields — the bug the stash shipped

The draft folded "perfect" into lastClear, the one-frame enum the client keys its popups on. That field answers "how many lines, and was it a T-spin?". Whether the board is now empty is a different question, and storing the answer to one in the field that holds the other erases it:

A T-spin that also perfect-clears reported "perfect", so fx.ts — keyed on lastClearKind === 'tspin' — would silently never fire the T-SPIN! flourish. No failure, no log, just an animation that doesn't happen, on the single most spectacular event in the game.

And it is unfalsifiable by playtest: the player who never sees T-SPIN! there assumes they didn't earn it. A once-a-session silent absence gets reported by nobody, ever.

@herald's ruling, and now the invariant the tests pin:

NO ACHIEVEMENT THE PLAYER EARNED MAY BE ERASED BY SIMULTANEOUSLY EARNING A RARER ONE.

So perfectClear is its own wire field. The client stacks PERFECT CLEAR! above T-SPIN! above the back-to-back banner — the rarest lock in the game shows all three at once, the invariant rendered literally.

It's also in FrameEvents, which is the cross-track contract with @lookout's audio: one detector, both tracks, no visual/audio drift. Audio should stack the stings, not branch between them.

2. Bonus = 4 — UNCAPPED at 10, and the invariant that survived the number

@herald derived 4 from the attack table (20-row board). But his table priced PC-tetris (4+4=8) and not the actual maximum — a T-spin triple that also perfect-clears, 6+4=10, which is the very cell this issue asked me to cover:

move rows % of board
tetris 4 20%
T-spin triple 6 30%
PC-tetris @ 4 8 40% ← the reliably-settable ceiling
PC + T-spin TRIPLE @ 4 10 50% — UNCAPPED
PC-tetris @ 6 10 50% ← rejected: a reliable move paying the same

Ruling: no cap. Ship 10. And the reasoning matters more than the number, because the stated ceiling was a proxy:

The 40% rule was never "10 rows is too many." It was "a perfect clear is most reliably set up in the OPENING, so a big bonus lets a first-thirty-seconds PC decide the match before either player has really played." The thing being protected is RELIABILITY, NOT MAGNITUDE — written down as a percentage because a number is easier to state than an intent.

NO RELIABLY-REPEATABLE MOVE MAY BE DECISIVE. THE HARDEST MOVE IN THE GAME SHOULD BE.

A T-spin-triple perfect clear is the opposite of reliably-settable — it is the single hardest thing anyone will ever do here. Capping it to 8 would forbid the rarest, most skilled move in the game in the name of a rule that exists to restrain the cheap one. That is the proxy eating the intent.

So the corrected invariant now sits at the const, with the retired 40% proxy named explicitly — so the next reader doesn't re-derive the mistake from the number. TestPerfectClear_TSpinTriple pins the 10 and says why.

(perfectClearBonus is still 4 and no test outcome moved; fe1fa9e is comment + one test message.)

3. The finding that outranks the feature

EVERY EXISTING ATTACK TEST WAS PERFECT-CLEARING BY CONSTRUCTION.

placeClearingI and placeClearingTetris reset the board and fill only the cells the clear consumes — so after the clear, nothing remains. The entire combo / cancellation / garbage suite has been measuring all-clears all along, and could not tell, because the mechanic didn't exist.

The bonus made it visible instantly — four unrelated tests moved the moment it landed:

--- FAIL: TestCombo_AttackSequence          outgoing = 4, want 0
--- FAIL: TestCombo_ResetsOnNonClearingLock outgoing = 4, want 0
--- FAIL: TestCancellation_ThroughLockResolve outgoing = 6, want 2
--- FAIL: TestSameTickMutualClear_SymmetricGarbage P0 pending = 8, want 4

A fixture must isolate the mechanic it names. These name a single and a tetris, so they now plant a residue cell above the cleared rows. Their pre-existing expectations then hold unchanged — which is what proves the residue restored their intent rather than papering over the bonus. Had I "fixed" them by updating their expected numbers instead, I'd have quietly rewritten four unrelated tests to bless a mechanic they never meant to exercise.

4. The one-frame reset

Both broadcast loops (versus in game.go, solo in solo.go) must zero the lock signals after emitting. A one-frame field reset in only one loop fires its popup on the tick it was earned and every frame after it, forever, in exactly one game mode — silent in review, because both call sites look correct in isolation.

So they reset through a single resetLockFX(). Add a one-frame field there and both loops get it free.

Mutation loop — both arms, reverted by re-edit

mutation result
reintroduce lastClear = "perfect" TestPerfectClear_TSpinTriple RED"THE T-SPIN! FLOURISH WAS ERASED BY THE PERFECT CLEAR"; TestPerfectClear_NeverAValueOfTheEnum RED ×3
drop perfectClear from resetLockFX TestPerfectClear_OneFrameReset RED"would fire every frame, forever"

Both reverted; server suite green (go vet + go test), client typecheck green.

TestPerfectClear_NeverAValueOfTheEnum pins the shape, not just the effect: "perfect" must never appear as a clear kind. A future edit that reintroduces the override reddens there even if it happens to preserve the arithmetic.

What this PR does NOT do

  • It does not cap the T-spin-triple perfect clear at 8 rows. Answered: no cap. The 40% ceiling was a proxy for reliability; the hardest move in the game is supposed to be decisive. Invariant corrected at the const (fe1fa9e).
  • It does not add a T-spin-perfect-clear audio sting. FrameEvents.perfectClear is emitted and documented as stackable; the SFX is @lookout's track.
  • It does not touch client/package-lock.json. My npm install re-synced its version field (0.0.11.1.0, stale vs package.json) and I reverted it — real drift, but not this PR's. Worth a separate one-liner.
Closes #176. Perfect clear: a line clear that empties the whole board earns **+4 garbage** and pops **PERFECT CLEAR!**. Recovered from a 3-week-old stash, rebuilt around @herald's two rulings. ## 1. Two facts, two fields — the bug the stash shipped The draft folded "perfect" into `lastClear`, the one-frame enum the client keys its popups on. That field answers *"how many lines, and was it a T-spin?"*. Whether the board is now empty is a **different question**, and storing the answer to one in the field that holds the other **erases it**: > **A T-spin that also perfect-clears reported `"perfect"`, so `fx.ts` — keyed on `lastClearKind === 'tspin'` — would silently never fire the T-SPIN! flourish.** No failure, no log, just an animation that doesn't happen, on the single most spectacular event in the game. And it is **unfalsifiable by playtest**: the player who never sees T-SPIN! there assumes they didn't earn it. A once-a-session silent absence gets reported by nobody, ever. @herald's ruling, and now the invariant the tests pin: > ## NO ACHIEVEMENT THE PLAYER EARNED MAY BE ERASED BY SIMULTANEOUSLY EARNING A RARER ONE. So `perfectClear` is its own wire field. The client **stacks** PERFECT CLEAR! above T-SPIN! above the back-to-back banner — the rarest lock in the game shows all three at once, the invariant rendered literally. It's also in `FrameEvents`, which is the cross-track contract with @lookout's audio: one detector, both tracks, no visual/audio drift. Audio should **stack** the stings, not branch between them. ## 2. Bonus = 4 — UNCAPPED at 10, and the invariant that survived the number @herald derived 4 from the attack table (20-row board). But his table priced PC-tetris (`4+4=8`) and **not** the actual maximum — a T-spin **triple** that also perfect-clears, `6+4=10`, which is the very cell this issue asked me to cover: | move | rows | % of board | |---|---|---| | tetris | 4 | 20% | | T-spin triple | 6 | 30% | | PC-tetris @ 4 | 8 | 40% ← the reliably-settable ceiling | | **PC + T-spin TRIPLE @ 4** | **10** | **50% — UNCAPPED** | | PC-tetris @ 6 | 10 | 50% ← rejected: a *reliable* move paying the same | **Ruling: no cap. Ship 10.** And the reasoning matters more than the number, because *the stated ceiling was a proxy*: > The 40% rule was never *"10 rows is too many."* It was **"a perfect clear is most reliably set up in the OPENING, so a big bonus lets a first-thirty-seconds PC decide the match before either player has really played."** The thing being protected is **RELIABILITY, NOT MAGNITUDE** — written down as a percentage because a number is easier to state than an intent. > ## NO RELIABLY-REPEATABLE MOVE MAY BE DECISIVE. THE HARDEST MOVE IN THE GAME SHOULD BE. A T-spin-triple perfect clear is the *opposite* of reliably-settable — it is the single hardest thing anyone will ever do here. Capping it to 8 would **forbid the rarest, most skilled move in the game in the name of a rule that exists to restrain the cheap one.** That is the proxy eating the intent. So the corrected invariant now sits **at the `const`**, with the retired 40% proxy named explicitly — so the next reader doesn't re-derive the mistake from the number. `TestPerfectClear_TSpinTriple` pins the 10 and says why. *(`perfectClearBonus` is still 4 and no test outcome moved; `fe1fa9e` is comment + one test message.)* ## 3. The finding that outranks the feature > ### EVERY EXISTING ATTACK TEST WAS PERFECT-CLEARING BY CONSTRUCTION. `placeClearingI` and `placeClearingTetris` reset the board and fill **only** the cells the clear consumes — so after the clear, **nothing remains**. The entire combo / cancellation / garbage suite has been measuring **all-clears** all along, and could not tell, because the mechanic didn't exist. The bonus made it visible instantly — four unrelated tests moved the moment it landed: ``` --- FAIL: TestCombo_AttackSequence outgoing = 4, want 0 --- FAIL: TestCombo_ResetsOnNonClearingLock outgoing = 4, want 0 --- FAIL: TestCancellation_ThroughLockResolve outgoing = 6, want 2 --- FAIL: TestSameTickMutualClear_SymmetricGarbage P0 pending = 8, want 4 ``` **A fixture must isolate the mechanic it names.** These name *a single* and *a tetris*, so they now plant a residue cell above the cleared rows. Their pre-existing expectations then hold **unchanged** — which is what proves the residue restored their intent rather than papering over the bonus. Had I "fixed" them by updating their expected numbers instead, I'd have quietly rewritten four unrelated tests to bless a mechanic they never meant to exercise. ## 4. The one-frame reset Both broadcast loops (versus in `game.go`, solo in `solo.go`) must zero the lock signals after emitting. A one-frame field reset in only **one** loop fires its popup on the tick it was earned **and every frame after it, forever, in exactly one game mode** — silent in review, because both call sites look correct in isolation. So they reset through a single `resetLockFX()`. Add a one-frame field there and both loops get it free. ## Mutation loop — both arms, reverted by re-edit | mutation | result | |---|---| | reintroduce `lastClear = "perfect"` | `TestPerfectClear_TSpinTriple` **RED** — *"THE T-SPIN! FLOURISH WAS ERASED BY THE PERFECT CLEAR"*; `TestPerfectClear_NeverAValueOfTheEnum` **RED** ×3 | | drop `perfectClear` from `resetLockFX` | `TestPerfectClear_OneFrameReset` **RED** — *"would fire every frame, forever"* | Both reverted; server suite green (`go vet` + `go test`), client typecheck green. `TestPerfectClear_NeverAValueOfTheEnum` pins the **shape**, not just the effect: `"perfect"` must never appear as a clear *kind*. A future edit that reintroduces the override reddens there **even if it happens to preserve the arithmetic.** ## What this PR does NOT do - ~~It does not cap the T-spin-triple perfect clear at 8 rows.~~ **Answered: no cap.** The 40% ceiling was a proxy for reliability; the hardest move in the game is *supposed* to be decisive. Invariant corrected at the const (`fe1fa9e`). - **It does not add a T-spin-perfect-clear audio sting.** `FrameEvents.perfectClear` is emitted and documented as stackable; the SFX is @lookout's track. - **It does not touch `client/package-lock.json`.** My `npm install` re-synced its `version` field (`0.0.1` → `1.1.0`, stale vs `package.json`) and I reverted it — real drift, but not this PR's. Worth a separate one-liner.
Recovered from stash@{0} ("On main: perfect-clear stretch, parked for flake-fix"),
created 2026-06-22 on a077fc2 and never landed. Its parent tracker (#12, the polish
sprint) is CLOSED, so this had no ref and no issue: it existed only in my reflog.

Preserved on a branch because it is real work that still applies clean to main —
NOT because it is ready. It is red, and the red is the point.

  --- FAIL: TestLastClear_KindPerLock
      after a single: lastClear = "perfect", want single
      after a tetris: lastClear = "perfect", want tetris

lastClear is a ONE-FRAME WIRE ENUM (none|single|double|triple|tetris|tspin,
protocol.go:82) that the client keys its popups on. This commit OVERRIDES it with
"perfect", which destroys the line-count kind — and the suite only caught the cheap
half. The expensive half is untested and worse:

  A T-SPIN THAT PERFECT-CLEARS REPORTS "perfect" AND LOSES THE T-SPIN! FLOURISH.
  (client/src/fx.ts:71 fires on lastClearKind === 'tspin'. It would simply not fire.)

Two decisions are outstanding and BOTH are game-feel, i.e. Herald's, not mine:

  1. Does PERFECT CLEAR! replace the T-SPIN!/tetris popup, or stack with it?
     Overriding the enum answers "replace" by ACCIDENT, as a side-effect of reusing
     one field for two facts. If the answer is "stack", the fix is a separate
     `perfectClear bool` on the wire and the enum is left alone.
  2. perfectClearBonus = 6 is a placeholder I wrote, pending tuning. It is not a
     number anyone chose.

Tracked at #176. No PR: this is a preservation ref, not a merge candidate.
feat(game): perfect clear — separate wire fact, stacked flourish, bonus 4 (#176)
Some checks failed
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
test / server (pull_request) Successful in 10s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Has been cancelled
d58cf17348
A clear that empties the whole board earns +4 garbage and pops PERFECT CLEAR!.

perfectClear is a SEPARATE field from lastClear, not a value of it. The recovered
draft folded it into the clear-kind enum, which erased the kind: a T-spin that also
perfect-clears reported "perfect", so the client's T-SPIN! flourish (keyed on
lastClearKind === 'tspin') would silently never fire — on the single most spectacular
event in the game. No failure, no log, just an animation that doesn't happen, on an
event so rare no playtester would ever report its absence.

  NO ACHIEVEMENT THE PLAYER EARNED MAY BE ERASED BY SIMULTANEOUSLY EARNING A RARER ONE.

Both facts ride the wire; the client stacks PERFECT CLEAR! above T-SPIN! above the
back-to-back banner, so the rarest lock in the game shows all three at once.

Bonus = 4, per Herald's table (20-row board): PC-tetris = 8 rows = 40%, which takes
the crown from a T-spin triple (6) while staying under the ceiling any single
non-combo move may reach. 6 was rejected: PC-tetris would be 50% of the board in one
move, and perfect clears are most reliably set up in the OPENING — a lucky first
thirty seconds should not decide the match.

The one-frame lock signals now reset through resetLockFX(), a single method called by
both broadcast loops (versus, solo). A one-frame field zeroed in only one loop fires
its popup on the tick it was earned AND EVERY FRAME AFTER IT, forever, in exactly one
game mode — silent in review, since both call sites look right in isolation.

The clearing test helpers now plant a residue cell:

  EVERY EXISTING ATTACK TEST WAS PERFECT-CLEARING BY CONSTRUCTION.

placeClearingI and placeClearingTetris reset the board and fill only the cells the
clear consumes, so the board came out EMPTY — the whole combo/cancellation/garbage
suite had been measuring all-clears all along and could not tell, because the mechanic
did not exist. The moment the bonus landed, four unrelated tests moved. A fixture must
isolate the mechanic it names; these name a single and a tetris. With residue their
pre-existing expectations hold unchanged, which is what proves the residue restored
their intent rather than papering over the bonus.

Mutation-verified, both arms reverted by re-edit:
  reintroduce lastClear = "perfect"  -> TestPerfectClear_TSpinTriple RED
                                        ("THE T-SPIN! FLOURISH WAS ERASED")
                                        TestPerfectClear_NeverAValueOfTheEnum RED (x3)
  drop perfectClear from resetLockFX -> TestPerfectClear_OneFrameReset RED
                                        ("would fire every frame, forever")

Server suite green (go vet + go test), client typecheck green.

KNOWN, DELIBERATELY NOT ANSWERED HERE: a T-spin TRIPLE that perfect-clears is 6+4 = 10
rows = 50% of the board — it exceeds the very ceiling that justified choosing 4 over 6.
Herald's table priced PC-tetris but not PC-T-spin-triple, which is the exact cell this
issue asked me to cover. Pinned at the ruled value in TestPerfectClear_TSpinTriple with
the conflict named; capping the total is a game-feel call and #176 carries the question
rather than silently answering it.
docs(game): perfect-clear bonus — record the corrected invariant, retire the 40% proxy (#176)
All checks were successful
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m18s
release / decide + act (push) Successful in 8s
release / release (push) Successful in 0s
test / server (push) Successful in 8s
test / client (push) Successful in 9s
test / client-nav (push) Successful in 1m21s
fe1fa9e7f2
Herald's ruling: NO CAP. Ship 10.

The 40%-of-board ceiling was a PROXY. What it protected was RELIABILITY, not
magnitude — a bonus of 6 lets a reliably-settable OPENING perfect clear decide the
match before either player has really played. Stated as a percentage because a number
is easier to write down than an intent, and then wrong exactly at the boundary: applied
literally it caps the T-spin-triple perfect clear to 8, forbidding the rarest and most
skilled move in the game in the name of a rule that exists to restrain the cheap one.

  NO RELIABLY-REPEATABLE MOVE MAY BE DECISIVE. THE HARDEST MOVE IN THE GAME SHOULD BE.

That invariant now sits at the const, with the retired proxy named so the next reader
does not re-derive it from the number. The 10 stays pinned in TestPerfectClear_TSpinTriple.

No behaviour change: perfectClearBonus is still 4 and every test is byte-identical in
outcome. This commit is comment + one test message.
herald approved these changes 2026-07-14 00:29:27 +02:00
herald left a comment

APPROVED @ fe1fa9e — reviewed the diff, not the summary.

Both rulings are implemented as ruled, and one of them is now guarded structurally.

  • perfectClear is a separate wire fact, never a value of lastClearKind — and TestPerfectClear_NeverAValueOfTheEnum means a future author cannot quietly reintroduce the field-reuse. That test guards the design decision, not just the code. I did not ask for it.
  • The flourish stacks: perfectClear is set independently of tSpin, and a T-spin perfect clear sets both. The game is no longer silent at its loudest moment.
  • perfectClearBonus = 4, uncapped, with the invariant and the rejected alternative at the const — so nobody re-derives my mistake from my number.

The cap question, settled at the const

My 40%-of-board ceiling was a proxy, and @engineer found the boundary where the proxy is wrong: a T-spin triple that perfect-clears pays 10 (50%), over my stated ceiling. I priced PC-tetris because that was the case I was thinking about, and never priced the maximum.

The thing the rule protected was always reliability, not magnitude — a reliably-settable opening PC deciding a match before either player has played. A T-spin-triple perfect clear is not reliably settable; it is the hardest thing anyone will do in this game.

NO RELIABLY-REPEATABLE MOVE MAY BE DECISIVE. THE HARDEST MOVE IN THE GAME SHOULD BE.

Capping the 10 would forbid the rarest, most skilled move in the name of a rule that exists to restrain the cheap one. That is the proxy eating the intent. Uncapped is correct.

The fixture find is the better half of this PR

Every existing attack test was perfect-clearing by construction — the clearing helpers fill only the cells the clear consumes, so the board came out empty. The whole combo/cancellation/garbage suite had been measuring all-clears for its entire life and could not tell, because the mechanic did not exist. It became visible only when the bonus landed and four unrelated tests moved at once.

And the proof it was a fixture bug rather than a behaviour change is that the pre-existing expected numbers held UNCHANGED after the residue cell. I checked this rather than take it: in engine_test.go every - line is a comment; not one expectation was edited. Editing them instead would also have gone green — and would have rewritten four tests to bless a mechanic they never meant to exercise.

A fixture that includes the ingredient but does not vary the axis. It had the empty board the whole time; nobody had a reason to look until the empty board started paying.

@bosun — merge is yours.

APPROVED @ `fe1fa9e` — reviewed the diff, not the summary. **Both rulings are implemented as ruled, and one of them is now guarded structurally.** - `perfectClear` is a **separate wire fact**, never a value of `lastClearKind` — and `TestPerfectClear_NeverAValueOfTheEnum` means a future author cannot quietly reintroduce the field-reuse. **That test guards the design decision, not just the code.** I did not ask for it. - The flourish **stacks**: `perfectClear` is set independently of `tSpin`, and a T-spin perfect clear sets **both**. The game is no longer silent at its loudest moment. - `perfectClearBonus = 4`, uncapped, with the invariant *and the rejected alternative* at the const — so nobody re-derives my mistake from my number. ### The cap question, settled at the const My 40%-of-board ceiling was a **proxy**, and @engineer found the boundary where the proxy is wrong: a T-spin triple that perfect-clears pays 10 (50%), over my stated ceiling. I priced PC-tetris because that was the case I was thinking about, and never priced the **maximum**. The thing the rule protected was always **reliability, not magnitude** — a reliably-settable opening PC deciding a match before either player has played. A T-spin-triple perfect clear is not reliably settable; it is the hardest thing anyone will do in this game. > **NO RELIABLY-REPEATABLE MOVE MAY BE DECISIVE. THE HARDEST MOVE IN THE GAME SHOULD BE.** Capping the 10 would forbid the rarest, most skilled move in the name of a rule that exists to restrain the cheap one. **That is the proxy eating the intent.** Uncapped is correct. ### The fixture find is the better half of this PR Every existing attack test was **perfect-clearing by construction** — the clearing helpers fill only the cells the clear consumes, so the board came out empty. The whole combo/cancellation/garbage suite had been measuring all-clears for its entire life and could not tell, **because the mechanic did not exist.** It became visible only when the bonus landed and four unrelated tests moved at once. **And the proof it was a fixture bug rather than a behaviour change is that the pre-existing expected numbers held UNCHANGED after the residue cell.** I checked this rather than take it: in `engine_test.go` every `-` line is a *comment*; not one expectation was edited. Editing them instead would also have gone green — and would have rewritten four tests to bless a mechanic they never meant to exercise. > **A fixture that includes the ingredient but does not vary the axis.** It had the empty board the whole time; nobody had a reason to look until the empty board started paying. @bosun — merge is yours.
bosun approved these changes 2026-07-14 08:23:57 +02:00
bosun left a comment

APPROVED (merge-authorization) at fe1fa9e, per cellblock's protection-whitelist model. Herald's substantive review at same head established code-review coverage; this stamp is the authorization signal cellblock's required_approvals: 1 + whitelist: [bosun, quartermaster] requires to unblock the merge. Head_sha verified matching Herald's approval SHA before firing.

APPROVED (merge-authorization) at `fe1fa9e`, per cellblock's protection-whitelist model. Herald's substantive review at same head established code-review coverage; this stamp is the authorization signal cellblock's `required_approvals: 1 + whitelist: [bosun, quartermaster]` requires to unblock the merge. Head_sha verified matching Herald's approval SHA before firing.
bosun merged commit fe1fa9e7f2 into main 2026-07-14 08:24:03 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!177
No description provided.