From f2f0eaca62ae459139e90d1b3092410c683e8be3 Mon Sep 17 00:00:00 2001 From: Hunter M Date: Sun, 9 Aug 2026 01:30:07 -0700 Subject: [PATCH 1/3] docs: ratify ART-DIRECTION and record the game-feel plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ART-DIRECTION.md is binding law with a reason attached to every rule, and §1.1 records the collisions measured in the build it was written against — including Contessa's card border and the target ring being the identical hex #ef4444, so "she holds a Contessa" and "you are about to be couped" rendered as the same pixel. §8's ship gate is checked in deliberately unticked. It is honest about what still fails. docs/AUDIO-MIX.md carries the measured mix: the method, the per-cue table, the regeneration procedure, and an explicit list of what remains unmeasured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LgH9q6E7j2KZp3PCDSMfV --- ART-DIRECTION.md | 570 +++++++++++++++++++++++++++++++++++++ docs/AUDIO-MIX.md | 303 ++++++++++++++++++++ docs/GAME-FEEL-PLAN.md | 630 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1503 insertions(+) create mode 100644 ART-DIRECTION.md create mode 100644 docs/AUDIO-MIX.md create mode 100644 docs/GAME-FEEL-PLAN.md diff --git a/ART-DIRECTION.md b/ART-DIRECTION.md new file mode 100644 index 0000000..b482bb8 --- /dev/null +++ b/ART-DIRECTION.md @@ -0,0 +1,570 @@ +# COUP ONLINE — ART DIRECTION: "THE MINISTRY" + +Ratified 2026-08-08 as Phase 0 of `docs/GAME-FEEL-PLAN.md`. **This document is binding.** +Where it and `docs/ASSETS.md` disagree, this document wins; ASSETS.md remains authoritative +on *how assets are produced* (Midjourney parameters, crop geometry, encoding, cache-busting), +not on what the game looks like. Provenance: the v2/v3 Midjourney system in `docs/ASSETS.md`; +`tailwind.config.ts:10-21`; `src/app/globals.css`; collisions and counts measured in this +repository on 2026-08-08 and cited inline. Contrast ratios use the WCAG 2.x +relative-luminance formula; the arithmetic is shown. + +--- + +## 0. The thesis + +**Screen-printed civic propaganda, lying on the council table of a blackened-teal palace.** + +Every object is a *printed thing* — gouache and ink pushed through a screen onto card stock +that has been handled. Every surface it lies on is *architecture* — oxblood enamel, aged +brass routing, blackened teal composite. Objects are printed; furniture is built. Printed +things curl, catch light on their edge, and cast a shadow. Built things do not float, do not +glow, and are cut *into* the table rather than laid on top of it. + +The thesis exists so ambiguous decisions have a right answer instead of a preference. When +something is uncertain, ask: **is that how screen-printed paper, enamelled metal, or brass +behaves?** A drop shadow under a card: yes, paper does that. A 16px amber glow around a +button (`globals.css:60`): no — nothing in this world emits light. A 1px grey stroke around +a panel: no — the palace does not draw outlines, it mills grooves. + +Two load-bearing corollaries: + +1. **The cards do not change with the table.** Card art is fixed raster (`docs/ASSETS.md`), + so anything the UI paints onto or around a card must be a *material* the card could + physically carry (a printed band, a foil hairline, a stamped hatch) — never a UI state + colour bled over the art. +2. **There is one light source and it is dim.** The background is already darkened by + `linear-gradient(rgba(5,9,10,0.58), rgba(5,9,10,0.70))` (`globals.css:16`). Nothing on + top of it may be brighter than paper in that room. + +--- + +## 1. Colour and material are two independent channels + +**This is the single most important section, because Coup's worst structural collision is +already shipping.** + +Coup needs two disjoint colour vocabularies and currently gives them the same 360°: +**categorical** — six characters plus two Reformation factions, eight categories — and +**semantic** — challenge, block, danger/target, your-turn, eliminated, illegal, affordable, +at least seven states. Fifteen meanings do not fit in a hue wheel with usable separation, +and the current build proves it. + +### 1.1 Measured collisions in the build today + +Hues are HSV, taken from the literal hex values in the source. + +| # | Collision | Values | Separation | +|---|---|---|---| +| **1** | **Contessa is literally the danger colour** | Contessa `border-red-500` = `#ef4444` (`CardFace.tsx:15`); target-selection ring `ring-2 ring-red-500` = `#ef4444` (`PlayerSeat.tsx:84`) | **0°, identical hex.** A Contessa in your hand and "you are about to be couped" are rendered in the same pixel value | +| **2** | **Captain vs the Loyalist faction** | Captain `border-blue-500` = `#3b82f6` (217°) (`CardFace.tsx:13`); Loyalist `border-l-blue-400` = `#60a5fa` (213°) (`PlayerSeat.tsx:68-77`, `GameTable.tsx:259`) | **4°.** Both appear on a `.card-container` seat at once | +| **3** | **Contessa vs the Reformist faction** | Contessa `#ef4444` (0°, S72); Reformist `border-l-red-400` = `#f87171` (0°, S54) | **0° hue, 18 points of saturation.** The only channel separating "she holds a Contessa" from "he is a Reformist" is saturation | +| 4 | **The Inquisitor is the same hue as the room** | Inquisitor `border-teal-500` = `#14b8a6` (173°); ground `#090d0e` (192°, S36) | 19°. The character disappears into the table it sits on | +| 5 | **The Assassin has no colour at all** | Assassin `border-gray-500` = `#6b7280` (220°, **S16**), which is also `.btn-secondary`'s `border-gray-600`, `.card-container`'s `border-gray-700`, and every disabled control | Achromatic. "Assassin" and "inert chrome" are the same treatment | +| 6 | **Duke has three different purples** | `tailwind.config.ts:15` `coup.duke: #9b59b6`; `CardFace.tsx:11` `purple-500 #a855f7`; `DukeIcon.tsx` hardcodes both `#9b59b6` *and* `#c084fc` | Three values for one character across three files | +| 7 | **Three brasses** | `coup.accent #d6a12a` (165 uses), `coup.gold #f2c744` (4 uses), `rgb(251,191,36)` = amber-400 baked into `.card-action-label` (`globals.css:146`) and every icon file (27 uses) | Three golds, no rule for which is which | + +Two structural facts behind the table: + +- The `coup.duke / assassin / captain / ambassador / contessa` tokens in + `tailwind.config.ts:15-19` are used **zero** times as Tailwind classes. The colours that + actually ship are raw Tailwind defaults, hand-copied into **five** separate per-character + palettes: `characterColors` in `CardFace.tsx:10`, `ChallengeRevealOverlay.tsx:8` and + `ExchangeView.tsx:11`; `characterThemes` in `HowToPlay.tsx:15`; and a fourth set inlined + per-entry in the `Tutorial.tsx:78` data array. A sixth palette is hardcoded inside the six + SVGs in `src/app/components/icons/`. There is no single source of truth to fix. + (`GameOverOverlay.tsx:28` and `AddBotModal.tsx:46` also carry hand-rolled palettes, but + they key on recap tone and bot personality rather than on character — separate duplication, + not part of this collision.) +- Those six SVGs use **six different stroke widths** (`0.5`, `1`, `1.5`, `2`, `2.5`, `3`) and mix + `strokeLinecap="round"`, `strokeLinejoin="round"`, and neither. They are not one line + language, so they cannot yet carry the categorical load §1.2 assigns them. + +### 1.2 The resolution + +**Hue-based signalling of *state* is banned.** Characters own colour. Semantics own material. + +| Layer | Carries meaning via | Never | +|---|---|---| +| **Characters (6)** | **Glyph silhouette first, colour second.** One distinct 1-bit silhouette per character, plus that character's hue as a solid printed band along one card edge and as the glyph fill | a ring, a glow, a gradient, a full-card saturated border, or any animation | +| **Factions (2)** | **Form, not hue.** The existing `▲ LOY` / `◆ REF` marks (`PlayerSeat.tsx:107`) are correct and are the whole signal; the seat carries a 3px *debossed* rail, not a coloured left border | a coloured tint over the seat | +| **Your turn / selection** | **Brass ring, 2px, stroked *outside* the object** | being a fill; being a pulsing glow (kill `animate-pulse-gold` and `turn-ready-ring`) | +| **Challenge / danger / target** | **Hazard material** — 45° stripes at 8px/2px in crimson, the same repeating gradient already used for revealed cards (`globals.css:343-349`), reused everywhere danger is meant | being a plain coloured chip or a red border | +| **Blocked / refused** | **Ink slab** — a solid `--ink` bar stamped across the object, achromatic | any hue at all | +| **Eliminated** | **Print degradation** — `grayscale(0.6) + opacity(0.5)` plus the hazard hatch, exactly as `.card-face-revealed` does today. Keep it; it is already right | a red X, a skull emoji | +| **Primary action** | **Brass slab**, flat fill, no gradient, no glow | `linear-gradient(...)` + `box-shadow: 0 0 16px` (`globals.css:56,60`) | + +**No character may ever wear a semantic material, and no semantic may ever be expressed as a +character hue.** A card is never ringed in crimson to say "targeted" — it gets the hazard +hatch. A seat is never tinted blue to say "Loyalist" — it gets the ◆/▲ mark and a debossed +rail. This is what makes collision #1 survivable: Contessa's rose and danger's crimson can +sit 17° apart without ambiguity, because one is only ever a printed fill inside a card and +the other is only ever a striped material outside one. + +**Six glyphs**, one line language, legible 1-bit at 14×14 on a 24px grid — the smallest card +today is `w-11 h-16` = 44×64px (`globals.css:322-324`) and the glyph must survive there: + +| Character | Silhouette | Note | +|---|---|---| +| Duke | Fractured crown reduced to three severe planes | Reuses the app-icon motif (`docs/ASSETS.md`, App Icon prompt). Angular, asymmetric, open at the top — see the RULING below for how it stays clear of the winner's crown | +| Assassin | Blunt wedge, point-down, one notch | Must not be a knife-and-drop-shadow cliché | +| Captain | Hooked bar (a grapple), horizontal | Reads as *taking*, matching STEAL | +| Ambassador | Two offset arrows forming an open loop | Reads as *exchange* | +| Contessa | Heraldic shield, flat top, single vertical split | The only closed convex form in the set — findable by shape alone | +| Inquisitor | Eclipse eye | `InquisitorIcon.tsx` is already this; it is the one glyph that survives the rewrite | + +**RULING — the crown (2026-08-08).** An earlier draft of this section reserved the crown for +the Duke and sent victory to an eclipse disc. That is overruled. **The crown means the +winner.** 👑-for-victory is one of the strongest conventions in the medium, and spending a +player's first-ever read of the game-over screen on teaching them a bespoke disc buys +internal tidiness at the cost of the one moment that should need no decoding. `CrownGlyph` +therefore stays on `win`, the winning hand, and the game-over header. + +The Duke keeps the fractured crown, and the separation is carried by **silhouette, not by +subject** — which is what §1's whole colour-versus-material argument already commits us to: + +| | Winner | Duke | +|---|---|---| +| Form | Closed, symmetric, resting on a solid base | Open-topped, asymmetric, three severe planes, no base | +| Register | Heraldic — a whole object | Technical-order — a fracture diagram of one | +| Where it appears | Game-over header at 56px; the win log row | Card face and character strip, never above 24px | + +Those never share a size or a surface, so the collision the earlier draft feared cannot +occur in practice. **The constraint this ruling creates:** whoever draws the Duke silhouette +must check it against `CrownGlyph` at 16px side by side, on the proof sheet, before it lands. +If they read as the same mark at that size, the Duke's is wrong and gets redrawn — not the +winner's. + +Drawing rules, from the technical-order idiom the wordmark already implies: **exactly two +stroke widths at a 2:1 ratio** (thick for object lines only); **square corners always** +(`miter` joins, `butt` caps); **never a gradient, blur, or soft shadow**; **patterns rather +than opacity for tints** — a 45° dot-grid reads as the halftone screen a screen-print +actually is, where `fill-opacity` just reads as faded. This replaces the five-stroke-width, +mixed-cap situation measured above. + +--- + +## 2. Tokens + +One palette, defined once in `tailwind.config.ts`, consumed by name. The five duplicated +per-character palettes and the sixth hardcoded across `src/app/components/icons/` (§1.1) are +deleted and replaced by references to these tokens. + +### 2.1 Ground and structure + +| Token | Hex | Purpose | Status | +|---|---|---|---| +| `--ground` | `#090D0E` | Body, deepest table | existing `coup.bg` — keep | +| `--ground-deep` | `#05090A` | Wells, the deck recess, inside a deboss | already in use as the gradient overlay (`globals.css:16`), promote to a token | +| `--surface` | `#17231F` | The felt; the plane most UI sits on | existing `coup.surface` — keep | +| `--raised` | `#22302B` | Seats, prompts, anything a step above the felt | existing `coup.card` — keep | +| `--line` | `#33443E` | The *only* hairline value, used for card-edge trim and deboss highlights — never as a panel outline (§3.1) | new | +| `--ink` | `#F1EBDE` | All primary text. Bone, not white — white is a flashlight in this room | new; replaces bare `text-white` | +| `--ink-mute` | `#9FADA6` | Secondary text | new; replaces `text-gray-400` / `text-gray-500` | +| `--brass` | `#D6A12A` | Your turn, primary slabs, focus ring | existing `coup.accent` — keep | +| `--brass-lit` | `#F2C744` | Coin figures and the treasury only | existing `coup.gold` — keep, but scope it | +| `--crimson` | `#F27366` | The danger *stripe* colour, and only that | new; replaces `red-500`/`red-400` as a semantic | +| `--oxblood` | `#5F141C` | Perimeter enamel, matching the table art | already in use as `rgba(95,20,28,…)` (`globals.css:15`), promote | + +Note the deletion: `--brass-lit` is scoped to figures, so the third gold (`amber-400`, +`rgb(251,191,36)`, `globals.css:146` plus 27 icon sites) goes entirely. Three golds become +two with an explicit division of labour. + +### 2.2 The six character hues + +Every one is **lower saturation than what ships today.** The current set (`purple-500` S66, +`blue-500` S76, `green-500` S83, `red-500` S72, `teal-500` S89) sits at Tailwind-default +saturation, which is why §3.5 reads as debug outlines against painterly gouache. + +| Character | Hex | HSV | vs today | +|---|---|---|---| +| Duke | `#B48AD0` | 276°, S34 | was `#a855f7` S66 | +| Assassin | `#8D9BA6` | 206°, S15 | was `#6b7280` S16 — **the fix here is the glyph, not the hue**; the Assassin stays cold steel and is identified by silhouette (see §2.4) | +| Captain | `#5FA5D6` | 205°, S56 | was `#3b82f6` S76 | +| Ambassador | `#A9BE5E` | 73°, S51 | was `#22c55e` S83. Chartreuse, not green — matches the art (`docs/ASSETS.md`: "Chartreuse and amber") | +| Contessa | `#E07B90` | 348°, S45 | was `#ef4444` S72. Rose-crimson, pulled off pure red to open a gap against `--crimson` | +| Inquisitor | `#5AC0C6` | 183°, S55 | was `#14b8a6` S89 | + +### 2.3 Contrast, computed + +Computed with the WCAG 2.x formula. Worked example for `--ink` on `--ground`: + +``` +#F1EBDE → sRGB (241,235,222) → linear (0.87965, 0.83078, 0.73050) +L1 = 0.2126(0.87965) + 0.7152(0.83078) + 0.0722(0.73050) = 0.83393 +#090D0E → sRGB (9,13,14) → linear (0.00273, 0.00402, 0.00439) +L2 = 0.2126(0.00273) + 0.7152(0.00402) + 0.0722(0.00439) = 0.00377 +ratio = (0.83393 + 0.05) / (0.00377 + 0.05) = 0.88393 / 0.05377 = 16.44 +``` + +**Pairs I actually computed** (`--raised` `#22302B` is the worst case, so it is the column +that matters): + +| Foreground | on `--ground` | on `--surface` | on `--raised` | +|---|---|---|---| +| `--ink` `#F1EBDE` | 16.44 | 13.63 | 11.59 | +| `--ink-mute` `#9FADA6` | 8.37 | 6.94 | 5.90 | +| `--brass` `#D6A12A` | 8.36 | 6.93 | 5.89 | +| `--brass-lit` `#F2C744` | 12.11 | 10.05 | 8.54 | +| `--crimson` `#F27366` | 6.90 | 5.72 | 4.86 | +| Duke `#B48AD0` | 6.96 | 5.77 | 4.91 | +| Assassin `#8D9BA6` | 6.85 | 5.69 | 4.83 | +| Captain `#5FA5D6` | 7.29 | 6.05 | 5.14 | +| Ambassador `#A9BE5E` | 9.50 | 7.88 | 6.69 | +| Contessa `#E07B90` | 6.89 | 5.71 | 4.85 | +| Inquisitor `#5AC0C6` | 9.10 | 7.55 | 6.42 | + +Every text token clears 4.5:1 on all three grounds. `--ground` on `--brass` (dark text on a +brass slab) is **8.36**. + +**What today fails, also computed:** `text-gray-500` `#6b7280` on `.card-container` +(`--raised`) is **2.85:1** — below the 4.5 floor and used in the game header +(`GameTable.tsx:129`) and the public-room list (`page.tsx:354`). `text-gray-500` on +`--ground` is **4.04:1**, also failing. These are the first two fixes. + +**Not computed — UNMEASURED:** + +- `--line` `#33443E` is **1.34:1** on `--raised` and **1.57:1** on `--surface`. Deliberate + (a hairline is not text), but it means the §5 deboss carries all the separation. + **Measure:** the L\* step between every adjacent furniture pair once §5 ships; if a pair's + field step is 0 L\* and only the stroke separates it, say so in the gate. +- Any token **against the card art itself** — raster, varies per pixel. **Measure:** sample + the label-plate region of all six `focus/` crops and confirm `--ink` clears 4.5:1 against + the *lightest* pixel under the plate, not the mean. +- The §1.2 card bands are fills, needing 3:1 against adjacent card stock. Stock value varies + per portrait; not computed. + +### 2.4 Residual hue proximities, stated rather than hidden + +- `--crimson` (5°) vs Contessa (348°) = **17°**. Mitigated by material: crimson is only ever + a 45° stripe or a ring *outside* an object; Contessa is only ever a fill *inside* one, + carrying the shield glyph. Verify in review. +- Inquisitor (183°) vs Captain (205°) = **22°**, both cool. Mitigated by value and by glyph. +- Assassin (206°, S15) vs Captain (205°, S56) = **1° of hue**. The Assassin is deliberately + near-achromatic; separation is entirely saturation plus glyph. **This is the weakest pair + in the system.** If review finds it fails, the Assassin — not the Captain — moves, toward + the "slate and electric blue" the art already carries (`docs/ASSETS.md`). +- Cyan is **reserved to the Inquisitor** and has no semantic role. The "restrained cyan" of + `docs/ASSETS.md` survives in the raster art only; no UI state may claim it. + +--- + +## 3. The web-app tells to kill + +Each of these is present in the build today, counted or cited. Each has a fix. + +1. **Rounded-rect + N-px border panels, ~9 per screen.** `.card-container` is + `rounded-xl border-2 border-gray-700` (`globals.css:79-82`), used at 7 call sites across + 4 files including every player seat. `.prompt-urgent` / `.prompt-action` / `.prompt-info` + (`globals.css:565-575`) are three more `rounded-xl border-2` variants, used 28 times. + Repo-wide, **110** source lines carry both a `rounded-*` and a `border` utility, and there + are **80** `border-gray-*` utilities. → **Zero `border: 1px solid` on table furniture.** Panels are debossed into the + felt (§5). The only surviving strokes are the card trim hairline and the focus ring. +2. **A phone column centred in a desktop viewport.** `GameTable.tsx:127` is + `h-dvh flex flex-col max-w-lg lg:max-w-xl mx-auto` — 512px, or 576px at `lg`. In a 1456px + window that leaves ~60% of the screen as unused background. → Desktop gets its own layout + with seats around a felt, a physical deck pile, a discard, and a treasury as objects + (`GAME-FEEL-PLAN.md` §1.4). Not a widened phone. +3. **System emoji as functional iconography.** **44 distinct pictographic emoji, 75 uses** + across `src/`, 44 of them in `src/shared/constants.ts` — the 12-entry `REACTIONS` table + (`constants.ts:110-123`) plus bot emotes — all crossing the wire and rendering as Apple + colour emoji on painterly gouache. → Replace functional emoji with the §1.2 SVG glyph set. + Player-chosen *reactions* may stay emoji (a reaction is a person speaking, not the game + speaking) but must render inside a printed speech plate, not naked on the felt. +4. **No typeface at all.** `src/app/layout.tsx` loads no font: zero `next/font`, zero + `@font-face`. The Midjourney wordmark is a heavy slab display face in crimson with brass + edges (`docs/ASSETS.md`, Title Banner); everything under it is `system-ui`. This is the + loudest mismatch on screen. → §4. +5. **Saturated per-character card borders that read as debug outlines.** `.card-face` is + `border-2` (`globals.css:84-86`) plus a `::before` hairline inset 3px + (`globals.css:92-100`). On the small card (44×64px = 2816px²): the 2px border consumes + 2816 − (40×60) = **416px², 14.8% of the card**; the `::before` ring adds + (38×58) − (36×56) = **188px², 6.7%**. **21.4% of the smallest card's pixels are frame**, + in `purple-500`/`blue-500`/`red-500`. → §1.2: a hairline trim in `--line` plus a + low-saturation printed band on one edge; the art and the glyph identify the card. +6. **A black gradient plate eating half the card.** `.card-face-sm .card-label-plate` is + `padding: 12px 2px 3px` over a 6.6px name and a 7px action label with a 1px gap + (`globals.css:168-178`) — ≈29.6px of a 64px card, **46% of the card height** under a + `rgba(0,0,0,0.94)` wash. The best asset in the project is half-covered by a scrim to make + 6.6px text legible. → Make the card bigger (`GAME-FEEL-PLAN.md` §1.4) so the plate can + shrink, and make it a *printed strip*, not a gradient wash. +7. **Glow as personality.** `globals.css:60` `box-shadow: 0 0 16px rgba(251,191,36,0.3)` on + button hover; `:651-652` `turnReadyGlow`; `:631` `box-shadow: 0 0 16px currentColor`; + `pulse-gold` in `tailwind.config.ts:48-51`. → Nothing in this world emits light (§0). + Your-turn is a 2px brass ring *outside* the seat, static. +8. **Gradient chrome.** `.btn-primary` carries + `background-image: linear-gradient(to bottom, rgba(255,255,255,0.1), transparent)` + (`globals.css:56`); 11 `bg-gradient-to-*` sites repo-wide. → Flat slabs with a hard offset + shadow (§5). +9. **52 `rounded-full` pills.** Pills are a web chrome idiom; a council table has plates, + chips and stamps. → Coins and the treasury stay circles, because coins are circles. + Everything else — badges, tabs, status — becomes a stamped plate at 2px radius. +10. **Refusal by greying out.** 19 `disabled={…}` bindings and 5 `disabled:opacity-50` rules, + with the reason hidden in a `title=` attribute (26 sites; `ActionBar.tsx:344-351` builds + a `disabledReason` string and puts it in a tooltip). A tooltip does not exist on touch. + → Keep controls live and refuse out loud: a cue, a shake, a haptic, and a sentence + (`GAME-FEEL-PLAN.md` §6.1). +11. **A full-screen modal disconnected from the table.** `ChallengeRevealOverlay.tsx` floats + a card in `bg-black/70` on a hardcoded 1500/2500/3500ms timeline (`:49-63`). The most + dramatic moment in the game happens nowhere. → The card flies from the accused player's + seat; the replacement flies from the deck pile. +12. **`backdrop-blur` as depth.** 2 sites, including `.practice-coach` (`globals.css:614`). + Frosted glass is an OS idiom, not a material this world contains. → Opaque printed panels. + +--- + +## 4. Type + +Three roles. No more. + +| Role | Used for | Stack | Treatment | +|---|---|---|---| +| **Display** | The phase banner, player names, game-over title, action names on the ActionBar | A heavy slab or condensed geometric face with the wordmark's civic-poster weight, self-hosted via `next/font/local` — **no CDN dependency**, because the app is offline-capable (`public/sw.js`) and a font that fails to load is a visible identity failure, not a graceful degradation | 2.5–4× body, weight 700–900, tracking **−0.02em** | +| **Body** | Rules text, chat, the action log, prompts | `system-ui, -apple-system, "Segoe UI", Roboto` | 400–600, sentence case. One fewer request; correct for reading | +| **Figures** | **Coins, deck count, treasury reserve, timers, room codes** | `ui-monospace, "SF Mono", "Cascadia Mono", Menlo` + `font-variant-numeric: tabular-nums` | This is the character | + +**Earn hierarchy with size and weight, never tracking.** Tracking is what you reach for when +the type scale is too flat to carry the hierarchy; today that is 11 `tracking-*` and 20 +`uppercase` utilities doing the work a 2.5× size step should do. Fix the scale, delete the +tracking. **The one exception** is the `CAPTAIN` / `STEAL` plate on card art +(`CardArtwork.tsx:12-19`, `.card-character-label` / `.card-action-label`) — tracked uppercase +survives there because it is not UI, it is *printed on the card*, the way a real card's role +strip is set. It is the app's only tracked caps. + +**Figures are monospace with `tabular-nums`, and the reason is mechanical.** In a +proportional face `1` is narrower than `8`, so a coin counter ticking 2 → 10 → 7 changes its +own width on every update and nudges everything beside it. That reflow reads as a webpage +recalculating; a counter that holds its column while the digits change reads as an instrument +with a value in it. **Current state: `tabular-nums` appears 0 times.** `font-mono` appears 6 +times — room codes (`page.tsx:354,395`, `GameTable.tsx:130`) and lobby timer settings — and +on **none** of the figures that change during play: `player.coins` (`PlayerSeat.tsx:136`), +`deckCount` (`GameTable.tsx:137`), `treasuryReserve` (`GameTable.tsx:140`), the turn timer. +Exactly the wrong four. + +--- + +## 5. Depth — two shadow languages + +Paper casts a shadow. Enamelled metal does not; it takes a hard offset when it moves. **Never +mix them**, because mixing them is what makes a UI read as "everything is a floating div." + +**Paper — cards, and only cards.** A soft multi-layer drop plus a hard 1px contact hairline: + +```css +--shadow-paper: + 0 0 0 1px rgba(0,0,0,.16), /* contact hairline — hard, no blur */ + 0 1px 1px rgba(0,0,0,.20), + 0 3px 6px rgba(0,0,0,.16), + 0 10px 24px rgba(0,0,0,.12); +``` + +The contact hairline is the load-bearing layer, not the soft ones. A sheet lying on a surface +has a hard dark line where it meets that surface, and that line separates sheet from table +*before any value difference does*. Coup's cards currently have the inverse — an inner +highlight, `box-shadow: inset 0 1px 2px rgba(255,255,255,0.08)` (`globals.css:89`) — which +lights the card from inside and reads as a button. In flight, scale offset and blur ~2.5× and +drop the hairline; a card in the air has no contact. + +**Chrome — pressables, and only pressables.** Hard offset, **zero blur**, collapsing on press: + +```css +--shadow-chrome: 3px 3px 0 var(--ground-deep); +/* :active { transform: translate(3px, 3px); box-shadow: 0 0 0 var(--ground-deep); } */ +``` + +Zero blur is the point: a blurred offset is a soft shadow pretending to be hard and reads as +neither. The collapse is the whole press mechanic — the object travels exactly the offset +distance so the shadow vanishes into the surface, which is what pressing a physical key looks +like. Coup currently has `active:scale-95` at 4 sites and nothing at all on cards, which are +the thing a player touches most. **Offset shadows appear only on pressables, only as a press +mechanic** — never on cards, panels, or decoration. + +**Table furniture is debossed, never outlined.** The felt is milled, not drawn on: + +```css +--deboss: + inset 0 2px 3px rgba(0,0,0,.25), + inset 0 -1px 0 rgba(255,255,255,.06); +``` + +This replaces every `rounded-xl border-2 border-gray-700` in §3.1. The 1px light line on the +*bottom* inside edge is the physics: a groove cut into a surface lit from above is dark at the +top and catches light at the bottom. Reverse it and the panel pops out instead of sinking in. + +**Corner radius: 6px on cards, 2px on furniture and plates, 0 on stripes and rails.** Not +12px — that is an app card; not 0 on cards — that is a spreadsheet cell. Coup is currently +`rounded-xl` (12px) on nearly everything. + +--- + +## 6. Motion budget + +**Two world-stopping moments, one physical system underneath.** + +Coup's two are: + +1. **An influence revealed and lost.** The only irreversible thing that happens in this game. +2. **Victory.** + +Everything else is the same flight engine with different parameters. There is no third +signature moment; if one appears, one of these two must give it up, because the reason these +two land is that nothing else has spent the attention. + +The engine is one rAF clock and one transform contract +(`translate(--fx,--fy) rotate(--tilt) scale(--fs)`, translate outermost). Verbs differ only +in arc, spin, duration and delay — and they must differ, or a Steal feels like a Tax and the +player learns nothing from watching the table. + +| Verb | Arc | Spin | Duration | Signature | +|---|---|---|---|---| +| **Deal / draw** | low, 14 | ±3° | 260ms, 64ms stagger | Comes off the deck pile, grows into the hand; `easeOutBack` on scale gives it a settle | +| **Take (Income, Tax, Foreign Aid, Embezzle)** | 18 | ±4° | 300ms | Deliberately plain. Coins travel from the treasury to the seat; the card does not move | +| **Strike (Coup, Assassinate)** | Coup: 0 (straight, fast). Assassinate: lunges **34%** of the way at the target and stops | 5° | 300ms | 34% is as far as a card can travel before it reads as a second flight rather than a threat. Coup arms hitstop; the target's seat takes the shake | +| **Take-from (Steal)** | 22, hero lift | ±6° | 340ms after a **120ms delay** | The delay is the tell — a theft you can see coming | +| **Swap (Exchange, Convert, Examine force-swap)** | mirrored **±34°** with a 60ms offset | ±8° | 380ms | Two cards passing each other, not one blur crossing the felt | +| **Refuse (Challenge, Block)** | the challenged card comes in fast; the loser's card is **shoved back** −0.22 | −9° | 280ms | Block is the same shove cut dead, no tail | +| **Fall (influence lost)** | 22 | ±26° tumble | 420ms | Lands face-up in the discard and **stays there** | + +**Hitstop** — freeze the whole animation list for 45ms on contact, min 200ms between freezes, +paid for at launch so the timing budget is untouched. Armed on exactly three things: a +revealed influence landing face-up, a Coup card landing, a challenge resolving. Freezing the +whole list rather than the landing card is the difference between a card pausing and the +table taking a hit. + +**Restraint is load-bearing, and it is directional.** Your card landing sparks; an opponent's +does not. An assassination aimed at *you* gets the flash and the shake; the same one aimed +elsewhere gets a small neutral ring. A table where every attack flashes red teaches nothing; +a table where only yours do is one you can read out of the corner of your eye. + +--- + +## 7. Reduced motion + +**Motion collapses to fades; sound and haptics stay.** + +Under `prefers-reduced-motion` or `html.reduce-motion`, every flight becomes an opacity fade +of ≤120ms **that fires its landing cue in the same tick**. Do not hang the sound off the back +of a cosmetic ramp, because the ramp is exactly what was just removed. + +The rule this enforces: **reduced motion must never mean reduced information.** A player who +asked for less motion must not become the one player at the table with no evidence anything +happened. Every state change legible through animation in the normal path must be legible +through some other channel in the reduced path — a cue, a haptic, a persistent visual state, +or a log line. Preferably all four. + +Two non-obvious consequences: + +- **The flash plate stays**, at 55% strength and 1.6× duration. It is opacity-only, so it + reads as a light coming up rather than a hit. Remove it and you remove the only + non-auditory signal that a Coup landed on you. +- **The current implementation is a blunt instrument.** `globals.css:547-555` sets + `animation-duration: 0.01ms !important` on `*`, `*::before`, `*::after`. The instinct is + right; the mechanism deletes information — a 0.01ms animation still *fires*, so anything + keyed to `animationend` survives, but anything whose only output was the animation itself + is gone with no substitute. Replace it with per-effect collapse rules once the motion + system exists, gated by the Phase 7 test: **every cue that fires in the normal path fires + in the reduced path, in the same tick.** + +--- + +## 8. Ship gate + +Checked against the build on **2026-08-08**. A box may only be ticked with the evidence +attached. + +- [ ] **Remove the wordmark — does it still look like this specific game?** + **No.** Strip `coup-online-banner-v2.webp` and what remains is a dark-teal background + image behind `system-ui` text in Tailwind default colours. The identity is carried + entirely by six raster portraits and one banner. +- [ ] **Grayscale the table — is hierarchy readable?** + **UNMEASURED**, likely marginal: `--surface` `#17231F` and `--raised` `#22302B` differ + by ~3 L\*, and every panel separation today comes from `border-gray-700`, which + grayscales to nearly the value of the panels either side of it. **Measure:** screenshot + mid-game at desktop and mobile, apply `filter: grayscale(1)`, record the L\* step for + every adjacent furniture pair. Target ≥12 L\*, and no pair separated only by a stroke. +- [ ] **Every text token ≥4.5:1 on every ground it appears on.** + **Contrast passes; tokenisation does not.** Swept 2026-08-09. Every sub-4.5:1 grey + text utility in `src/app` is gone except four sites in files owned by a concurrent + change (`ActionBar.tsx` ×2, `GameTable.tsx` ×1, `PlayerSeat.tsx` ×1). + Counts (comments stripped): `text-gray-500` **70 → 3**, `text-gray-600` **19 → 1**, + `text-gray-700` **2 → 0**, all replaced by `text-coup-ink-mute` `#9fada6` (91 uses). + `.input-field`'s `placeholder-gray-500` → `placeholder-coup-ink-mute`. + Ratios computed with the §2.3 formula, worst ground first: + + | Pair | before | after | + |---|---|---| + | secondary text on `--raised` `#22302b` | `#6b7280` **2.85** | `#9fada6` **5.90** | + | secondary text on `--surface` `#17231f` | `#6b7280` **3.35** | `#9fada6` **6.94** | + | secondary text on `--ground` `#090d0e` | `#6b7280` **4.04** | `#9fada6` **8.37** | + | tertiary text on `--ground` | `#4b5563` **2.58** | `#9fada6` **8.37** | + | tertiary text on `--raised` | `#4b5563` **1.82** | `#9fada6` **5.90** | + | footer separators on `--ground` | `#374151` **1.89** | `#9fada6` **8.37** | + | row text on `bg-gray-800` `#1f2937` (lobby icon buttons) | `#6b7280` **3.04** | `#9fada6` **6.29** | + | `.input-field` placeholder on `--surface` | `#6b7280` **3.35** | `#9fada6` **6.94** | + + **Audited and deliberately left, because they already pass:** `text-gray-400` + `#9ca3af` (97 uses) is **5.42** on `--raised`, **6.38** on `--surface`, **7.69** on + `--ground`; `text-gray-300` `#d1d5db` (65 uses) is **9.34 / 10.99 / 13.25**. They are + still un-tokenised, so the second half of this gate — "no un-tokenised colour survives + to be measured" — is **open**: 162 grey text utilities remain that pass contrast but + are not `--ink` / `--ink-mute`. + + `PracticeCoach`'s four tone grounds lost their stroke and gained 10 points of tint + opacity in the same pass, so they were recomputed rather than assumed. Composited + over `--ground` (the coach also carries `backdrop-filter: blur(10px)`, which is not + modelled — these are the flat-composite numbers): + gold `#d6a12a` on `#3c1805` **6.79** (was 7.01); blue `#93c5fd` on `#152149` **8.65** + (8.91); red `#fca5a5` on `#3f0a0a` **8.79** (9.02); green `#6ee7b7` on `#03271f` + **10.49** (10.80). All four still clear 4.5, as does the body copy on every one of + them (`#ffffff` ≥15.6, `#d1d5db` ≥10.5). **Still not computed:** any text set over the + card raster — §2.3 already flags it as UNMEASURED and this sweep did not change it. + Regression gate: `tests/app/styleBudget.test.ts`. +- [ ] **Is the hand fully on-screen on a phone?** + **UNMEASURED.** `GameTable.tsx:127` is `h-dvh … overflow-hidden`, so it cannot scroll — + anything that does not fit is clipped, not reachable. **Measure:** 390×844 and 375×667 + at 2–6 players with a prompt open; record whether both local cards are fully visible. +- [ ] **Does every state class have a screenshot?** + **No** — there is no screenshot corpus. Required: six characters × {hidden, known, + revealed, selected}; the eight `TurnPhase` values; Classic and Reformation; 2/4/6 + players; both text-size settings; and each refusal reason `ActionBar.tsx:344-351` + can produce. +- [ ] **`prefers-reduced-motion`: motion → fades, sound and haptics stay.** + **Fails.** `globals.css:547-555` kills animation globally with no substitute, and + haptics fire only on your own taps (24 call sites, all local). +- [ ] **Zero `border: 1px solid` / `border-2` on table furniture.** + **Passes for panels; the proxy metric is still open.** Swept 2026-08-09, counts with + comments stripped: `border-gray-*` **67 → 7**, and source lines carrying both a + `rounded-*` and a `border` **94 → 59** (110 and 80 when this gate was written). + Every panel now wears `.panel-sunk` (`globals.css`) — the §5 `--deboss` at the 2px + furniture radius, composed through `--tw-shadow` so a caller's `ring-*` still lands: + both modal shells, the game-over recap plates and row wells, the Log/Chat container, + the stat tiles, the awards grid, the history well, the practice coach, the PWA + prompt, the error banners and the tutorial callouts. Surviving hairlines (table row + rules, section dividers) are tokenised to `--line` via `border-coup-line`, which §2.1 + permits: they are hairlines, not panel outlines. + + **The 7 remaining `border-gray-*`, and why:** `.btn-secondary` and `.input-field` in + `globals.css` are **controls, not table furniture** — §3.1 is about panels and §5 + gives pressables their own hard-offset language, so they keep a stroke until §5 lands + on them; the other 5 are in `ActionBar.tsx` (1), `GameTable.tsx` (3) and + `PlayerSeat.tsx` (1), owned by a concurrent change during this sweep. + + **The 59 remaining `rounded-* + border` lines are not panels:** bordered pressables, + `rounded-full` badge pills (§3.9's fix, not this one), tutorial card mock-ups (§5 puts + cards at 6px with a paper shadow, and the frame belongs to `CardFace`), and the + Reformation faction demo plates in `ReformationTutorial.tsx`, which must keep whatever + language `PlayerSeat` uses or the tutorial stops teaching the shipped game. + + **Open, found during the sweep and not fixed:** the faction rails on the seat + (`border-l-blue-400` / `border-l-red-400`) and `ring-red-500/30` on the ◆REF badge in + `PlayerSeat.tsx` are a coloured seat border where a debossed rail belongs — a §1.2 + violation, listed here so it is not lost. + Regression gate: `tests/app/styleBudget.test.ts`. +- [ ] **One source of truth for character colour.** + **Fails.** Five duplicated per-character palettes (§1.1) plus a sixth hardcoded across + the SVGs in `src/app/components/icons/`; `coup.duke`…`coup.contessa` + (`tailwind.config.ts:15-19`) have **zero** usages. +- [ ] **All changing figures are monospace with `tabular-nums`.** + **Fails.** `tabular-nums`: 0 usages. `font-mono` is on room codes and lobby settings, + and on none of coins, deck count, treasury, or timer. +- [ ] **No functional system emoji in the UI.** + **Fails.** 44 distinct, 75 uses. Player-chosen reactions are exempt (§3.3). diff --git a/docs/AUDIO-MIX.md b/docs/AUDIO-MIX.md new file mode 100644 index 0000000..43a4dfc --- /dev/null +++ b/docs/AUDIO-MIX.md @@ -0,0 +1,303 @@ +# Audio mix + +The levels in `src/app/audio/SoundEngine.ts` are measured, not estimated. This file +records what was measured, how, what the numbers mean, and what is still unmeasured. + +Measured on **2026-08-10**, Chrome 151.0.0.0 / macOS, `OfflineAudioContext` 2ch @ 48 kHz. +Committed as data in `tests/app/audio/measurements.ts` and gated by +`tests/app/audio/mix.test.ts`. + +## The rule + +**Consequence tracks loudness.** Every routine sound sits below every loss. + +| tier | what it is | cues | +|---|---|---| +| 0 | the game turned | `gameOverWin` `gameOverLose` `playerEliminated` | +| 1 | you lost | `influenceLoss` `challengeRevealFail` `block` | +| 2 | a play resolved | `coup` `challengeRevealSuccess` `assassinationAlert` `exchange` | +| 3 | cards being handled | `cardShuffle` `actionDeclared` `coinsGained` `coinsLost` | +| 4 | chrome | `timerWarning` `denied` `chatMessage` `reaction` `yourTurn` `blockOpportunity` `challengeWindow` | + +The tier of each cue is `MIX_TIER` in `SoundEngine.ts` — data, so a test can catch it +being wrong. The trim is `MIX_DB`, applied in `voiceGain()` and nowhere else. + +## What the columns mean + +Three numbers per cue, because one is not enough. + +- **peak** — true peak dBFS over the whole render. What the ceiling is about. +- **loud** — the loudest 300 ms sliding-window RMS. **This is the ordering axis.** +- **rms** — RMS over the cue's own active window, from the first sample within 45 dB + of peak to the last. Recorded, not gated. + +Peak is not loudness. A 150 ms bandpassed noise swish carries 18.6 dB of crest and a +sustained 300→150 Hz sine carries 7.1 dB, so ranking by peak puts the deck shuffle above +a lost influence that any listener hears as louder. That is the failure chudopoly's audio +gate shipped — a peak-vs-peak assertion reading PASS on a build whose owner could not +hear the music — and it is why the tier ordering here runs on `loud`. + +Active-window RMS cannot carry the ordering either: it is a function of how long a cue +rings. `gameOverLose`'s mastered clip measures −26.23 dBFS on active RMS and −17.02 on +`loud`, because 6 seconds of ring-out drags the average down. Its synth fallback, 1.3 s +long, measures −19.42 / −17.01. On active RMS the two look 6.8 dB apart; on `loud` they +are 0.01 dB apart, which is the truth. So `rms` is recorded for shape and `loud` is gated. + +## The measured mix + +Trim is `MIX_DB`; all levels are dBFS at the graph output, at the shipped trim. +`lim` is how much gain reduction the master compressor + soft clip apply — 0 means the +chain is linear there and `MIX_DB` alone is setting the level. + +| tier | cue | trim | peak | loud | rms | lim | +|---|---|---:|---:|---:|---:|---:| +| 0 | `gameOverWin` (clip) | −2.8 | −4.61 | **−17.15** | −20.48 | 1.54 | +| 0 | `gameOverWin` (synth) | −2.8 | −5.63 | **−17.07** | −18.76 | 0.42 | +| 0 | `gameOverLose` (clip) | −5.0 | −7.23 | **−17.02** | −26.23 | 0 | +| 0 | `gameOverLose` (synth) | −5.0 | −8.58 | **−17.01** | −19.42 | 0 | +| 0 | `playerEliminated` | −3.0 | −10.46 | **−17.04** | −18.17 | 0 | +| 1 | `influenceLoss` | −1.9 | −11.86 | **−18.97** | −19.58 | 0 | +| 1 | `challengeRevealFail` | −2.1 | −10.46 | **−18.99** | −21.49 | 0 | +| 1 | `block` | +5.6 | −4.13 | **−21.13** | −18.07 | 2.11 | +| 2 | `exchange` | −4.3 | −10.18 | **−23.05** | −21.99 | 0 | +| 2 | `assassinationAlert` | −2.3 | −12.75 | **−23.00** | −23.74 | 0 | +| 2 | `coup` | −12.9 | −13.08 | **−22.97** | −25.79 | 0 | +| 2 | `challengeRevealSuccess` | −5.6 | −13.80 | **−22.97** | −24.91 | 0 | +| 3 | `coinsGained` | −0.7 | −14.20 | **−24.97** | −21.93 | 0 | +| 3 | `coinsLost` | −0.4 | −14.00 | **−26.42** | −23.39 | 0 | +| 3 | `actionDeclared` | −0.3 | −13.93 | **−29.06** | −23.29 | 0 | +| 3 | `cardShuffle` | +1.2 | −14.01 | **−32.58** | −29.53 | 0 | +| 4 | `timerWarning` | −9.4 | −21.67 | **−34.56** | −27.54 | 0 | +| 4 | `chatMessage` | −7.4 | −22.85 | **−34.57** | −30.56 | 0 | +| 4 | `reaction` | −7.6 | −21.13 | **−34.60** | −28.83 | 0 | +| 4 | `yourTurn` | −15.9 | −25.89 | **−34.60** | −33.79 | 0 | +| 4 | `challengeWindow` | −13.7 | −24.10 | **−34.61** | −34.58 | 0 | +| 4 | `blockOpportunity` | −11.2 | −26.92 | **−34.64** | −33.28 | 0 | +| 4 | `denied` | −12.0 | −21.56 | **−34.64** | −29.32 | 0 | + +Tier boundaries on `loud`, quietest-above minus loudest-below: + +| boundary | margin | +|---|---:| +| 0 / 1 | 1.82 dB | +| 1 / 2 | 1.84 dB | +| 2 / 3 | 1.92 dB | +| 3 / 4 | 1.98 dB | + +`denied` (added 2026-08-10) is the only new row. The whole bank was re-rendered +with it and every other figure came back byte-identical, so the boundaries are +unchanged. It was deliberately solved onto the **floor** of tier 4 rather than +into the middle: tier 4 tops out at `timerWarning`, −34.56, and the 3/4 margin +is only 1.98 dB, so a new chrome cue landing anywhere above `timerWarning` +would have eaten the boundary. At −12.0 it sits level with `blockOpportunity` +and the margin is exactly what it was. **No other trim moved.** + +`denied` is played from `ActionBar.tsx` through a single `DENIED_SOUND` +constant, which pointed at `timerWarning` while no refusal voice existed. + +On peak, the headline rule holds too: the quietest loss (`influenceLoss`, −11.86) stabs +2.07 dB above the hottest routine cue (`actionDeclared`, −13.93). + +## The ceiling + +The soft clip is a `WaveShaper`, and a `WaveShaper` clamps its input to [−1, 1] before the +table lookup, so its output cannot exceed `curve[last]` = `0.7 + 0.3·tanh(1)` = 0.92848 = +**−0.645 dBFS**. That is a property of the graph, not a mixing opinion. The render +confirms the arithmetic. + +The hottest single cue is `gameOverWin`'s mastered clip at −4.61 dBFS — **3.97 dB of +headroom**. The hottest realistic two-cue beat is `exchange` + `cardShuffle` at −6.92 dBFS. +Nothing is close to the ceiling and nothing is being levelled by the limiter: the worst +single-cue gain reduction is 2.11 dB (`block`) and the worst pair is 0.04 dB. + +## Two cues in one beat + +Beats a real game produces, rendered as one summed pass: + +| beat | peak | loud | lim | +|---|---:|---:|---:| +| `challengeRevealFail` + `cardShuffle` @400 ms | −10.46 | −18.88 | 0 | +| `challengeRevealFail` + `influenceLoss` @120 ms | −6.79 | −16.10 | 0.04 | +| `influenceLoss` + `playerEliminated` @150 ms | −7.71 | −15.63 | 0.01 | +| `coup` + `influenceLoss` @250 ms | −10.30 | −18.71 | 0 | +| `exchange` + `cardShuffle` @0 ms | −6.92 | −22.58 | 0.02 | +| `cardShuffle` ×2 @90 ms (multi-card exchange) | −14.01 | −29.74 | 0 | +| `coinsGained` + `actionDeclared` @60 ms | −10.25 | −23.53 | 0 | +| `denied` ×2 @90 ms (double-tap on a refused control) | −21.56 | −31.63 | 0 | + +The double-tap is the tightest a real one can be: `RATE_DEFAULT` drops a repeat +inside 80 ms, and 90 ms is still inside `FLAM_WINDOW`, so live the second tap +arrives at `FLAM_DB[1]` = −2.5 dB. The render gives both taps full gain, which +makes that row an upper bound rather than a picture — and even so its peak is +identical to one tap (−21.56), because at 90 ms the two do not overlap at all. + +None of them sums into the limiter. Before the retune, `influenceLoss` + `playerEliminated` +took 6.81 dB of gain reduction and `challengeRevealFail` + `influenceLoss` took 6.26 dB — +the compressor, not the mix, was deciding how loud an elimination landed. + +## Timbre, not level + +Level is only half of "this cue is right", and the gate above only measures level. +`timerWarning` stood in for a refusal for a whole release at exactly the correct +tier-4 weight and entirely the wrong shape — right number, wrong sound — and no +assertion here could have caught it. + +So `MEASURED_CONTRAST` records octave-band energy **normalised to each cue's own +total**. These figures say nothing about how loud a cue is and everything about +what it sounds like; they survive a retune of the trims. Bands are the ISO octave +centres; `low` is everything under 160 Hz; `centroid` is the power-weighted mean +frequency. + +| cue | active | 63 | 125 | 250 | 500 | 1k | 2k | 4k | 8k | low | centroid | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| `denied` | 88.1 ms | −29.4 | −16.4 | **−1.0** | −8.8 | −14.2 | −22.7 | −38.3 | −56.1 | −19.0 | 320 Hz | +| `timerWarning` | 59.6 ms | −89.7 | −82.6 | −74.8 | −65.5 | **−0.8** | −10.4 | −14.8 | −14.7 | −82.8 | 1561 Hz | +| `influenceLoss` | 346.5 ms | −113.2 | −32.1 | **0.0** | −82.8 | −107.9 | −104.7 | −101.2 | −94.8 | −51.5 | 237 Hz | +| `challengeRevealFail` | 714.8 ms | **−2.1** | −4.1 | −19.2 | −22.0 | −29.1 | −35.0 | −38.6 | −41.8 | −0.3 | 105 Hz | + +Read across, that is four separations and each one is gated: + +- **Duration.** 88 ms against 346 and 715 — 3.9× and 8.1× shorter. A refusal that + lingers reads as damage already done. +- **Buzz vs tone.** `influenceLoss` is a bare sine: one octave band holds + everything and the runner-up is 32.1 dB down. `denied` is a square behind a + filter and spreads across three bands within 14.2 dB. A filtered square and a + pure falling tone are not the same object even at the same pitch. +- **Mid vs bass.** `challengeRevealFail` puts essentially all of itself under + 160 Hz (−0.3 dB of its own total, centroid 105 Hz). `denied` puts ~1 % there + (−19.0 dB, centroid 320 Hz). No chest, so no dread. +- **Closed vs open.** Against the `timerWarning` it replaces, the 1400→760 Hz + lowpass drops the centroid from 1561 Hz to 320 Hz — a muted buzzer behind a + door instead of an alarm in the room. + +The FFT behind these is hand-rolled in `tests/app/audio/analysis.ts` (no new +dependency for a mix measurement) and is itself tested against signals whose +spectrum is known in advance, in `tests/app/audio/analysis.test.ts`. + +## The mastered stingers and their fallbacks + +`HERO_CLIPS` plays a mastered mp3 and falls back to the synth voice when the fetch fails. +Both go through the same head, so `MIX_DB` sets the pair's level and the clip's pre-trim +`gain` sets the clip **relative to its fallback**. Those two gains were solved for from +the render: + +| cue | clip loud | synth loud | Δ | clip gain | +|---|---:|---:|---:|---:| +| `gameOverWin` | −17.15 | −17.07 | 0.08 dB | 0.808 (was 0.61) | +| `gameOverLose` | −17.02 | −17.01 | 0.01 dB | 0.557 (was 0.52) | + +At the old gains the clips were 2.07 dB and 2.57 dB **quieter** than the fallbacks they +replace. Re-solve them whenever a tier-0 trim moves. + +## What was wrong before + +The previous trims were derived by summing oscillator gains on paper and were labelled +`UNMEASURED` in the source. Rendered, three of the four tier boundaries were inverted: + +| boundary | margin as shipped | +|---|---:| +| 0 / 1 | **−5.64 dB** | +| 1 / 2 | **−7.82 dB** | +| 2 / 3 | +1.42 dB | +| 3 / 4 | **−8.84 dB** | + +The worst individual case was `yourTurn` — tier 4 chrome — measuring −22.70 dBFS loud +against `cardShuffle` at −31.28 and `influenceLoss` at −10.09: a HUD prompt 8.6 dB above +the deck and only 12.6 dB under the only irreversible event in the game, where the tier +rule wants at least three boundaries between them. Five cues were also taking 3–5 dB of +limiting, so their level was being set downstream of `MIX_DB` entirely. + +## The measurement trap this pass found + +**Chrome's `DynamicsCompressorNode` applies an internal makeup gain — +6.5 dB for this +chain's settings — and it is not present at the first sample of a render.** It ramps in +over roughly 300 ms of context time, with or without input. A cue scheduled at t = 4 ms +therefore measures up to 6.5 dB quieter than the identical cue scheduled at t = 1 s, and +partially so *across* the cue, which biases short cues differently from long ones. Two +identical cues 60 ms apart rendered *louder than the arithmetic sum of their individual +peaks*, which is what exposed it. + +The live context runs for the whole session, so the settled state is the real one. +`renderSoundOffline()` therefore renders `RENDER_PRE_ROLL_S` = 1.0 s of silence before +every cue. Verified stable: 0.5 s, 1 s and 2 s of pre-roll agree to 0.02 dB. A render +without pre-roll is not a measurement of this mix. + +## Regenerating the measurements + +The render happens in a real browser. `OfflineAudioContext` does not exist in Node, and a +Node reimplementation is a different compressor and a different `WaveShaper` — the ceiling +above is a Chrome number. + +There is **one graph implementation**. `renderSoundOffline()` is exported from +`SoundEngine.ts` and calls `buildGraph()`, `startVoice()` and `voiceGain()` — the same +three functions the live `play()` path calls. There is no offline-only chain and no +offline-only copy of `MIX_DB`, so the harness cannot measure a mix the player never hears. + +```sh +D=$(mktemp -d) +npx esbuild tests/app/audio/harness.entry.ts \ + --bundle --format=esm --target=es2022 --outfile="$D/harness.bundle.js" +cp tests/app/audio/harness.html "$D/" +ln -s "$PWD/public/audio" "$D/audio" # the mastered stingers +python3 -m http.server 8137 --directory "$D" +``` + +Open `http://localhost:8137/harness.html`. The page renders on load and prints the JSON +report; it is also on `window.__COUP_REPORT`, and `window.__COUP_AUDIO.probe(id, opts)` +renders a single cue for ad-hoc work. Then: + +1. Paste `rows` into `MEASURED` / `MEASURED_HERO_CLIP` in + `tests/app/audio/measurements.ts`, `pairs` into `MEASURED_PAIRS`, and + `contrast` into `MEASURED_CONTRAST`. +2. Update `MEASURED_TRIM_DB` and `MEASURED_HERO_CLIP_GAIN` to the values you rendered at, + and `MEASURED_AT` to today. +3. `npx vitest run tests/app/audio`. + +Renders are deterministic — the noise buffers are seeded per graph and the jitter is +rendered at nominal pitch, so two runs agree exactly. Retuning is a loop: change `MIX_DB`, +re-render, read the margins, repeat. Three passes converged here. + +`MEASURED_TRIM_DB` is what makes this a gate rather than a snapshot. Change `MIX_DB` +without re-rendering and `mix.test.ts` fails immediately, because every level in the +recorded table now describes a mix nobody hears. + +## Still unmeasured + +- **The music bed.** `MUSIC_GAIN` (0.18) is untouched and was not rendered. The tier + ladder is now 17.6 dB tall, and tier 4 sits at −34.6 dBFS loud, so the chrome cues may + well sit under the bed. Cue-versus-bed is a separate measurement — chudopoly's gate does + it as "a card sound clears every in-match bed by 6 dB in the cue's own loudest octave", + and Coup has no equivalent assertion yet. +- **The `theirs` treatment.** Every cue was rendered as `mine`. `THEIRS_DB` (−6) is a flat + offset on the same head, so it moves the whole ladder together and the ordering survives, + but the 5.2 kHz lowpass's effect on loudness is not in the table. +- **Pitch jitter** (±2.5 %) — rendered at nominal pitch. `denied` is in + `JITTERED`, for the same reason `block` is: a refusal a player triggers three + times in a turn must not read as one click looped. +- **The flam ladder** (`FLAM_DB`) — rendered at run 0 only. The `denied ×2` + pair renders *both* taps at run 0, so it bounds the real double-tap (whose + second tap is attenuated 2.5 dB) rather than describing it. +- **Timbre outside the four contrast cues.** `MEASURED_CONTRAST` covers only + the set where "these must never be confused" is a stated requirement. Nothing + gates the shape of the other eighteen. +- **Safari and Firefox.** Their compressor makeup gain is not Chrome's, so every absolute + dBFS figure here is a Chrome figure. The ordering is a property of the trims and should + survive, but that has not been checked. +- **`softClip` at `oversample: 'none'`** is assumed, not asserted, by the render. The + ceiling bound depends on it. + +## Cues worth re-synthesising + +Levels only were changed. Two voices are mis-synthesised for the job their tier gives +them, and were left alone: + +- **`block` (tier 1)** is two triangle blips, 50 ms at 1200 Hz and 150 ms at 2400 Hz — + 148 ms of active audio with **13.7 dB of crest**. Giving a tier-1 event tier-1 loudness + therefore costs +5.6 dB of trim and makes it the second-hottest peak in the bank + (−4.13 dBFS) and the only cue taking more than 1 dB of limiting. A blocked action is a + substantial event with a UI-tick voice. It wants a body, not a bigger number. +- **`cardShuffle` (tier 3)** is a 149 ms bandpassed pink burst with **18.6 dB of crest** — + the highest in the bank. Its peak has to stay under the quietest loss, which pins its + loudness at −32.58 dBFS, which in turn pins the whole chrome tier below −34.5. Roughly + 4 dB of the ladder's total height is this one cue's crest. A longer, more sustained + shuffle would let tiers 3 and 4 come up. diff --git a/docs/GAME-FEEL-PLAN.md b/docs/GAME-FEEL-PLAN.md new file mode 100644 index 0000000..697dd77 --- /dev/null +++ b/docs/GAME-FEEL-PLAN.md @@ -0,0 +1,630 @@ +# Game Feel Plan — Coup Online + +Written 2026-08-08, after auditing Coup's current state against the juice/audio/motion +work in `~/chudopoly`. + +**Verdict: not at parity.** Coup's *art* is ahead of chudopoly's (which has zero raster +assets by design). Everything downstream of the art — type, motion, impact feedback, +mix discipline, haptics — is between 0% and 40% of where chudopoly sits. + +The important finding is that chudopoly's advantage is **not more effects**. It is that +every number in it is a measurement with the measurement written next to it, and every +rule has a stated reason. Copying its effects list without that discipline reproduces the +decoration and not the feel. + +--- + +## 0. Scorecard + +| Dimension | Coup | chudopoly | Note | +|---|---|---|---| +| Raster art / assets | **8** | 4 | Coup wins. chudopoly self-reports "custom visuals: 1, target ≥16" | +| Typography | 2 | 8 | Coup has *no* font system. Zero `next/font`, zero `@font-face` | +| Table presence / layout | 3 | 8 | Coup desktop is a phone column in a 1456px viewport | +| Motion system | 3 | 9 | Coup has scattered CSS keyframes; chudopoly has one clock + one transform contract | +| Impact FX | **0** | 9 | Coup has no particles, no shake, no flash, no floating text | +| Sound design | 4 | 9 | Good bones (bus, ducking, 3 mastered tracks), thin execution | +| Mix discipline | 2 | 9 | Coup's mix is *inverted* on the moments that matter (see §4) | +| Haptics | 2 | 8 | Coup only buzzes on your own taps | +| Input feel | 3 | 9 | No press pose, no refusal language | +| Measurement / gates | 1 | 9 | chudopoly can assert "loss is louder than shuffle" as a test | + +--- + +## 1. The three systems Coup does not have at all + +Everything else on this list is a refinement. These are absences. + +1. **A type system.** The Midjourney wordmark is a rich display face; the UI beneath it is + `system-ui`. That mismatch is the loudest thing on screen and the cheapest thing to fix. +2. **An impact layer.** No particles, no screen shake, no flash, no floating numbers. Every + dramatic beat in Coup currently resolves as a text change. +3. **A measurement culture.** No way to assert that the influence-loss sting is louder than + the coin sound, so it silently isn't (§4). + +--- + +## Phase 0 — Write the law + +**Half a day. Do this first; everything else checks against it.** + +Create `ART-DIRECTION.md` as a binding document, the way chudopoly does. Coup already has +the raw material scattered across `docs/ASSETS.md` and the Midjourney prompt log — it just +isn't stated as rules with reasons. + +It must state: + +- **The thesis.** chudopoly's is "real printed cards on a real airfield apron… is that how + paper, concrete, or paint behaves?" — a sentence you can check ambiguous decisions + against. Coup's is somewhere near *screen-printed civic propaganda in a blackened-teal + palace*. Write the actual sentence. +- **Colour vs material as two independent channels.** This is Coup's single worst + structural collision and it is already live: five characters need categorical colour, and + challenge/block/danger/your-turn need semantic colour, and they are currently fighting + over the same 360°. Resolution: characters own **colour + a distinct glyph silhouette**; + challenge/block/danger own a **material** (a ring outside the object, a hazard stripe, an + ink slab) that no character can ever wear. +- **A motion budget.** chudopoly's is "two world-stopping moments, one physical system + underneath." Coup's two should be **influence revealed/lost** and **victory**. Everything + else — income, tax, exchange, convert — is the same flight engine with different arcs. +- **Two shadow languages.** Paper casts a soft drop shadow; painted chrome gets a hard + offset with zero blur that collapses on `:active`. Never mix them. +- **A ship gate.** Remove the logo — does it still look like this specific game? Grayscale + the table — is hierarchy still readable? Both themes ≥4.5:1 on every text token. + +--- + +## Phase 1 — Look + +### 1.1 Type — the highest-value single change + +Currently: `src/app/layout.tsx` loads no font. Everything is `system-ui`. + +Adopt three roles: + +- **Display** — headings, the phase banner, player names, the game-over title. Something + with the wordmark's weight and civic-poster geometry. Load via `next/font/local` so it + self-hosts and there is no CDN dependency. +- **Body** — `system-ui` is fine here and is one fewer request. +- **Figures** — `ui-monospace` + `font-variant-numeric: tabular-nums` for **coins, deck + count, treasury, timers, room codes**. chudopoly's note is exactly right: *"this is the + character."* A coin counter that jitters its width as it ticks 2→10 reads as a webpage; + one that holds its column reads as an instrument. + +Earn hierarchy with size and weight, not tracking. Tracked uppercase micro-labels survive +in exactly one place — the `CAPTAIN / STEAL` plate on the card art. + +### 1.2 Kill the emoji + +There are roughly 40 distinct system emoji doing functional work: 🔇 🔊 in the header, ✅ ❌ +🎭 ⚔ 💀 👑 throughout the action log, 🏆 💀 on the game-over screen, and the full award set. +Apple colour emoji sitting on painterly gouache reads as placeholder art. + +Replace with a small inline-SVG glyph set drawn in one line language. Steal chudopoly's +technical-order rule: **exactly two stroke widths at a 2:1 ratio, square corners always +(`miter` joins, `butt` caps), never a gradient or blur, patterns rather than opacity for +tints.** A 45° dot-grid reads as the halftone screen it is imitating; `fill-opacity` just +reads as faded. + +Roughly 14 glyphs covers it: challenge, block, pass, coup, assassinate, steal, exchange, +examine, convert, embezzle, coin, skull, crown, deck. + +### 1.3 Fix the card frames + +`CardFace.tsx` currently rings every card in a saturated Tailwind border — +`border-purple-500`, `border-green-500`, `border-blue-500`, `border-red-500`. Against the +muted art these read as debug outlines, and at 44px they are a third of the card's visual +weight. + +Replace with a material treatment: a 1px `--card-edge` hairline inset ~2px from the trim, +plus a colour **band** along one edge carrying the character hue at low saturation. The +card announces which character it is with **art + glyph first, colour second**. + +Revealed cards currently get `grayscale(0.6) + opacity(0.5)` plus a red hatch, which is +good. Keep the hatch; make it the hazard material defined in Phase 0 so it matches the +danger language everywhere else. + +### 1.4 Make the cards bigger, and build a table + +Opponent cards are `w-11 h-16` (44×64). Your hand is `w-14 h-20` (56×80). The best asset in +the project is illegible at that size. + +`GameTable.tsx` is `max-w-lg lg:max-w-xl` — a phone column centred in a desktop viewport +with ~65% of the screen unused. And there is no *table*: the deck is 8px of text in the +header, there is no treasury, no discard, no seating. + +Desktop should get its own layout, not a widened phone one: + +- Seats arranged around a felt, not stacked in a grid of identical rectangles. +- A **physical deck pile** whose height tracks `deckCount`, so cards visibly come off it. +- A **discard area** where revealed influences land and stay. +- A **treasury** — Coup's coin pool is finite and in Reformation there is a literal reserve. + Both should be objects on the table. + +This is the largest single item on the plan and the one that makes every motion item in +Phase 2 possible, because flights need somewhere to fly from. + +### 1.5 Texture and depth + +The table background is already doing real work. Add: + +- A grain layer (`feTurbulence` data URI) at low alpha, and a hairline contact shadow under + every card — `0 0 0 1px rgba(0,0,0,.16)` hard against the trim. Paper lying on a surface + has one; it is what separates the sheet from the surface before any value difference does. +- Zero `border: 1px solid` on table furniture. Panels are **debossed into** the surface + (`inset 0 2px 3px rgba(0,0,0,.25), inset 0 -1px 0 rgba(255,255,255,.06)`), not outlined. + Coup currently has ~9 rounded-rect-plus-1px-border panels per screen, which is the single + clearest "this is a web app, not a table" tell. + +--- + +## Phase 2 — Motion + +### 2.1 One clock, one transform contract + +Replace the scattered CSS keyframes in `globals.css` and `tailwind.config.ts` with a single +rAF loop and a fixed transform contract: + +``` +translate(var(--fx), var(--fy)) rotate(var(--tilt)) scale(var(--fs)) +``` + +Translate outermost is load-bearing: adding `dx` to `--fx` moves the card's centroid by +exactly `dx` whatever its tilt or scale, which is what makes a measured FLIP exact rather +than approximately exact. Clamp `dt` to `[0, 1/20]` so a backgrounded tab doesn't teleport +a card. Quantise writes (0.1px / 0.1deg / 0.001 scale) and skip unchanged ones, so a settled +card costs zero writes per frame. + +### 2.2 Easing — take these numbers directly + +```js +const BACK = 0.9; // position/rotation easeOutBack — 2.99% overshoot +const SCALE_BACK = 0.55; // scale easeOutBack — 1.03%, free squash and stretch +``` + +chudopoly's finding: the classic `1.70158` back constant put a card 19px past its slot and +read as a bounce, not a landing. That constant overshoots **exactly 10%**, which is where the +19px comes from — 10% of a 190px travel. `BACK = 0.9` overshoots 2.99% and `SCALE_BACK = 0.55` +overshoots 1.03%. (Both figures were re-derived here: `peak = 1 + kv² − (k+1)v³` at +`v* = 2k/3(k+1)`. An earlier draft of this plan said 4.5% for `BACK`, carried over from the +research notes; it is 2.99%. The constants are unchanged — only the annotation was wrong.) +And putting `easeOutBack` on the **scale** axis costs +nothing and gives every landing a direction — a card growing (deck 26px → hand 62px) +overshoots and settles, a card shrinking (hand → a small slot) undershoots and springs open. +That is squash-and-stretch for one changed easing function. + +### 2.3 Hitstop — best ratio of feel to code in the whole plan + +```js +const HITSTOP_MS = 45; +const HITSTOP_MIN_GAP_MS = 200; +const CONTACT = 1 - BACK / (1 + BACK); // 0.5263 — where easeOutBack crosses 1 +``` + +Freeze the **whole** animation list, not just the landing card — that is the difference +between a card pausing and the table taking a hit. Subtract `HITSTOP_MS` from the flight's +duration at launch so it pays for itself and the timing budget is untouched. `MIN_GAP` +exists so a multi-card exchange is one heavy landing with the rest riding in behind it, +rather than five freezes reading as jank. + +Arm it on: a revealed influence landing face-up, a coup card landing, a challenge resolving. + +### 2.4 The press mechanic + +Coup's buttons have `active:scale-95`; cards have nothing at all. Cards are the thing a +player touches most. + +```css +@property --press-s { syntax: ''; inherits: false; initial-value: 1; } +@property --press-y { syntax: ''; inherits: false; initial-value: 0px; } + +.card.is-pressed { --press-s: .955; --press-y: 2px; + transition: --press-s 80ms cubic-bezier(.3,.8,.4,1), --press-y 80ms cubic-bezier(.3,.8,.4,1); } + +@keyframes card-unpress { + 0% { --press-s: .955; --press-y: 2px; } + 46% { --press-s: 1.028; --press-y: -3px; } + 100% { --press-s: 1; --press-y: 0px; } +} +.card.is-unpressing { animation: card-unpress 190ms cubic-bezier(.33,0,.2,1) both; } +``` + +Three things to keep: the press goes **down** (a card that gets smaller can never be clipped +by an `overflow:hidden` hand container); the release overshoot is stated explicitly rather +than derived from a back-out easing (a spring-back scales its overshoot by the delta, and +0.045 of delta produces a 0.4px pop that is arithmetically present and perceptually absent); +and each state declares its own timing mechanism, because a shared `transition` sits above +`animation` in the cascade and will silently delete the release keyframes. + +### 2.5 Per-verb signatures + +Coup's actions are currently all the same non-event. Give each a gesture: + +| Action | Treatment | +|---|---| +| Assassinate | The action card **lunges 34%** of the way at its target, `dur 300, spin 5°`. 34% is as far as a card can lunge before it reads as a second flight rather than a threat | +| Coup | Slam. Straight, fast, `arc 0`, hitstop armed, the target's seat takes the shake | +| Steal | `delay 120ms` before it leaves — a theft you can see coming — then `speed 1.12`, hero lift | +| Exchange | **Mirrored arcs ±34° with a 60ms offset** so it reads as two cards passing each other, not one blur crossing the felt | +| Challenge | The challenged card comes in fast; the challenger's card is **shoved back** `-0.22`, `dur 280, spin -9°` | +| Block | Same shove, cut dead — no tail | +| Influence loss | Tumble to the discard, `spin ±26°, arc 22` | +| Income / Tax | Deliberately plain. Most beats get nothing | + +### 2.6 Fix the challenge reveal overlay + +`ChallengeRevealOverlay.tsx` is a full-screen `bg-black/70` with a card floating in the +void, on a hardcoded 1500/2500/3500ms timeline. It is the most dramatic moment in Coup and +it is spatially disconnected from the table. + +The card should fly **from the accused player's seat**, land at centre with hitstop, and the +replacement should fly **to their seat from the deck pile**. Same beats, real geography. + +### 2.7 Prompts should transition + +`ActionBar`, `ChallengePrompt`, `BlockPrompt` et al. hard-swap. `.action-choice-enter` +already exists with a stagger var — apply it consistently and add an exit. + +### 2.8 Reduced motion — collapse, never delete + +`globals.css` currently sets `animation-duration: 0.01ms` globally under +`html.reduce-motion`, which is the right instinct. The rule to hold once Phase 3 exists is +chudopoly's: **motion collapses to fades; sound and haptics stay.** A player who asked for +less motion must not also be the one player with no evidence that anything happened. Flights +become a ≤120ms opacity fade *that still fires its landing cue in the same tick* — do not +hang the sound off the back of a cosmetic ramp. + +--- + +## Phase 3 — Impact FX + +Coup has none of this. Build it as `src/app/fx/`, lazily — importing it should cost two +array pushes, and no canvas, node, listener or clock subscriber should exist until the +first effect fires. + +### 3.1 Write the tuning table before the code + +chudopoly's `fx/index.js` opens with the entire cue→effect map as a comment table. Do the +same. A first draft for Coup: + +``` +event condition particles flash trauma haptic +card_landed yours 5–7 dots ø7 + contact ring — .16 land 10ms +card_landed anyone else's contact ring only, α.22 — 0 — +challenge_won you won it ring 12→52 brass + 6 dots — .20 — +challenge_lost AGAINST YOU flare ø62 + ring 10→86 crimson red .30 .30 targeted +influence_lost yours 12 crimson dots + "LOST" float red .30 .34 targeted +influence_lost anyone else's 6 grey puffs — 0 — +coup_landed against you flare + ring, crimson red .38 .45 targeted +assassinate_blocked involving you 4-arm spark cross + "BLOCKED!" steel.16 .30 targeted +coins_changed yours — (float only: +N brass / −N crimson) 0 — +player_eliminated any 14 grey settle puffs — .22 — +game_over you won 380 confetti 3.2s + 130 at +1.5s gold .30 .60 win +game_over someone else 150 confetti, 2.6s gold .12 .26 — +``` + +Two rules carry this table: + +- **Restraint is load-bearing.** Most beats get nothing. Your card landing sparks; + everyone else's does not. Opponents' turns are quiet, and Coup's three loud moments — a + challenge resolved against you, losing an influence, the win — stay loud *because nothing + else has spent the attention.* +- **Red only for the victim.** An assassination aimed at you gets the flash and the shake; + the same assassination aimed at someone else gets a small neutral ring. A table where + every attack flashes red teaches the player nothing; a table where only theirs do is one + they can read out of the corner of their eye. + +### 3.2 Particles — one canvas, pooled + +A win throws ~380 confetti. As DOM that is 380 nodes laid out, painted and composited every +frame on top of the existing card nodes; on a 390×844 DPR3 phone that is the frame budget +gone. One canvas is one composited layer whose cost is fill-rate, which is the thing a phone +GPU has spare. + +- `CAP = 600` preallocated struct-of-arrays (`Float32Array`), zero allocation in + update/draw, death by swap-with-last, spawn on a full pool **dropped not queued**. +- `DPR_CAP = 2`, `getContext('2d', { alpha: true, desynchronized: true })`. +- Sprite atlas drawn once into offscreen canvases. Five kinds: dot, glint, puff, ring, + confetti. +- **Dots need a plateau core, not a gaussian** — chudopoly measured that with a gaussian + shoulder the visible core of an 8px dot was ~2px and the burst simply did not exist + against a textured background. Coup's table art is at least as busy. +- **Rings are a stroked annulus, two strokes not one** — a wide band at 45% alpha carrying + the energy plus a 1.6px filament at full alpha carrying the edge. A soft radial smear + vanishes against any busy surface. +- Two draw passes (`source-over` for confetti/puffs, `lighter` for dots/glints/rings), not + per-particle composite toggling — that cost 0.9ms/frame at 380 pieces. + +### 3.3 Screen shake — trauma model + +```js +const DECAY = 1.5; // trauma/s +const CAP = 0.75; +const MAX_X = 15, MAX_Y = 11, MAX_R = 1.1; // at trauma 1 +const FREQ = 26; // Hz — below ~18 reads as a wobble, above ~34 as noise +const MIN_TRAUMA = 0.141; // sqrt(0.30/15) — refuse anything below the write quantisation +const LAND_CEILING = 0.34; // routine landings must never out-shake the win +``` + +Displacement is **trauma²**, so a small trauma is genuinely small — that is the difference +between juice and nausea. Three decorrelated noise channels with phase offsets `+0/+31.3/ ++77.1`; equal seeds put x and y on the same line and it reads as a single diagonal. + +Apply it to the **table container only** — not the viewport, not the whole game screen. In +Coup the phase banner, the action bar and your hand are siblings of the table, and shaking +them makes a challenge prompt unreadable at the exact moment you have to answer it. + +Remove the transform entirely at trauma 0 rather than setting `none`, so at rest the +stacking tree is identical to a build without FX. + +### 3.4 Flash plate + +One full-viewport div on `mix-blend-mode: screen`. A crimson wash at 0.3 alpha over the dark +teal table is a grey wash; `screen` keeps the table's own value structure and only lifts it. +Radial gradients re-centred on the epicentre of the beat. Envelope: attack in the first 8%, +then a **squared** release — an instant-on flash on a 60Hz panel is one frame of white and +reads as a dropped frame. + +Under reduced motion the flash **stays** at 55% strength and 1.6× duration. It is +opacity-only, so it reads as a light coming up rather than a hit. + +### 3.5 Floating text + +Coup already has `CoinChangeBurst`, which is a decent start but is scoped inside a seat and +gets clipped. Promote it to a real floater layer (DOM, not canvas — it is real text at real +sizes and must stay crisp on DPR3): + +- `RISE = 46px` over `LIFE = 0.9s`, eased `1-(1-u)³`. +- **The fade holds for the first 45%** then falls on `^1.4`. The number has to be *read*, + and a linear fade over 900ms is legible for about 300ms of it. +- **Keep-out band** between the phase banner's bottom and your hand's top. A `−3` that lands + on the coin counter it is describing is worse than no float at all. +- **Anti-collision stacking** at 44px on Y / 120px on X — Coup pays multiple players in one + beat during a Coup or an Embezzle. +- Measure the node's half-width rather than assuming it. Coup's shouts — `CAUGHT BLUFFING!`, + `BLOCKED!` — are long, anchored on a seat, and centred with `translateX(-50%)`. +- Suppress entirely while the game-over overlay is up. + +### 3.6 The two-channel hold + +Cues know *where* (a card landed at x,y). Events know *who* (you were the victim). A +`challenge_resolved` cue cannot know direction, so hold the beat for exactly one event +dispatched in the same synchronous task; if none arrives, the neutral form plays on the next +tick. Coup's `useSoundEffects.ts` already diffs game state for the event side — the cue side +is what's missing. + +--- + +## Phase 4 — Sound + +`SoundEngine.ts` has genuinely good bones: a real master/sfx/music bus, gain ramping, +music ducking, buffer caching, mobile unlock, and three mastered ElevenLabs tracks. What it +lacks is discipline. + +### 4.1 The mix is inverted — fix this first + +Current gains, straight from the source: + +| Sound | Peak gain | Duration | +|---|---|---| +| `influenceLoss` | **0.15** | 0.35s, one sine 300→150Hz | +| `coinsGained` | 0.10 | 0.15s | +| `cardShuffle` | 0.12 | 0.15s noise burst | +| `timerWarning` | 0.12 | 0.06s | + +**Losing an influence — the only irreversible thing that happens in Coup — is 2dB louder +than shuffling the deck and 3.5dB louder than a coin tick.** This is exactly the inversion +chudopoly found and fixed. Adopt the five-tier rule: + +``` +tier 0 the game turned gameOverWin gameOverLose playerEliminated +tier 1 you lost influenceLoss challengeRevealFail block(against you) +tier 2 a play resolved coup challengeRevealSuccess assassinationAlert exchange +tier 3 cards being handled cardShuffle actionDeclared coinsGained coinsLost +tier 4 chrome timerWarning chatMessage reaction yourTurn +``` + +Every routine sound must sit below every loss. Apply the trims in **one place** — a +`MIX_DB` table read by `play()` — so a future offline render and the live mix cannot +disagree. + +### 4.2 Add a compressor and a soft clip + +The bus is currently `sfxGain → masterGain → destination` with no protection. Add: + +```js +comp: { threshold: -14, knee: 6, ratio: 12, attack: 0.004, release: 0.16 } +softClip: softClipCurve(2048, 0.7), oversample: 'none' +``` + +The soft clip is the point: a `WaveShaper` clamps its input to [-1,1] before table lookup, +so output cannot exceed `0.7 + 0.3·tanh(1) ≈ 0.933` = **−0.6 dBFS**. "No clipping" becomes +an invariant of the graph rather than a mixing opinion. `oversample` must stay `'none'` — +oversampling filters ring, and ringing overshoots the table maximum. + +### 4.3 Randomise, or it fatigues + +Every sound in `sounds` is byte-identical every time it fires. Coup plays `coinsGained` and +`actionDeclared` dozens of times a game. + +- Seeded pitch jitter of ±2–3% on the fundamental. +- **Noise slices from a shared buffer at a random offset** rather than a fresh buffer per + call. This is what stops five dealt cards sounding like one click repeated, and it is + free — Coup currently allocates and fills a new `AudioBuffer` on every `noiseBurst`, which + is both louder-sounding and more expensive. + +### 4.4 Mine vs theirs + +One function, the only place the treatment lives: + +``` +mine → unity gain, centred, unfiltered +theirs → -6dB, pan ±0.34 (seeded per player), detune 0.994 (≈ -10 cents), lowpass 5.2kHz +``` + +The lowpass is what makes it sit *behind* yours rather than merely quieter. Resolve "mine" +from the event (`actorId | targetId | blockerId | challengerId === myId`), not from a flag +the caller passes — with the override that for `influenceLoss` and `coinsLost`, only the +**victim** is "mine". + +Right now Coup mostly solves this by playing nothing at all for bystanders, which leaves the +table silent during opponents' turns. Quiet is correct; silent is not. + +### 4.5 Voice budget, rate limits, flam attenuation + +- `MAX_VOICES = 32` weighted units, plus a **priority tier that bypasses the cap** — a win + sting dropped for budget reasons is a bug the player cannot un-hear. +- Rate floors set **under the smallest real animation gap**, not the average one. Once + Phase 2's stagger table exists, derive them from it. +- **Flam attenuation instead of deletion**: repeats within 190ms play at `[0, −2.5, −4.5, + −6]` dB. A two-card exchange must read as two cards, not one card at 2× amplitude. + +### 4.6 Make defeat the inverse of victory + +Coup already ships separate mastered stingers, which is most of the way there. But the synth +fallbacks are both plain arpeggios in the same direction family — `gameOverWin` rises +523/659/784/1047, `gameOverLose` falls 440/370/311. Invert at every joint instead: + +| win | lose | +|---|---| +| rising major | falling minor | +| sawtooth, bright | square through a 620Hz lowpass, muted | +| filter opens on each attack | filter closes through each note | +| notes 90ms apart, urgent | notes 150/170/210ms apart, slowing | +| a fifth ringing over the end | the last note **sags a semitone flat** | + +The sag is the thing that reads as loss: a held pitch that will not stay up. + +### 4.7 Duck on meaning, not activity + +`duckMusic()` is currently triggered from a hardcoded `if/else` chain on sound id. Make it +weight-driven: only priority stings dip the bed, at 3–6dB depending on weight, 25ms attack +and 280–600ms release. A coin tick ducking the music once per 100ms is a pumping bed; the +point is that music yields to *meaning*. + +### 4.8 Loop the music properly + +`velvet-court.mp3` is a 68-second loop with hand-crossfaded handles, which is better than +most. If more tracks land, adopt the method: find the interior loop region by spectral-flux +search, and size the crossfade so `period − xfade` is a **whole number of beats** in the +source. 500ms out is a flam, and a flam is the one artifact a listener names instantly. + +And once there is more than one track: **exclude the previous pick**. A fair coin over two +beds plays the same one twice half the time, which *is* "there is only one track." The same +rule applies to Coup's bot emote pool. + +--- + +## Phase 5 — Haptics + +`src/app/utils/haptic.ts` has two patterns (`haptic` 50ms, `hapticHeavy` [50,70,50]) and is +called from ~24 files — **all of them on your own tap.** Being assassinated, losing a +challenge, and being eliminated produce nothing. + +Build a vocabulary and a priority ladder: + +```js +pickup: 6, // priority 0 +land: 10, // priority 0 +denied: 12, // priority 1 +targeted: [30, 40, 30], // priority 2 — something is being done TO you +goodThing: [20, 30, 20], // priority 3 — same shape, tighter +influenceLost: [50, 70, 50, 70], // priority 4 +win: [40, 60, 40, 60, 200] // priority 5 +``` + +`targeted` and `goodThing` are the same shape at different tightness deliberately — so a +good beat and a bad beat are distinguishable **through a pocket**. + +Then the rule that matters: a 300ms floor that is **priority-aware**. chudopoly's flat +first-one-wins gate collapsed the whole vocabulary — 16 haptics in a 150s game, *all* of +them the 10ms `land`, across 7 set completions and 3 steals, because the cheap tick always +arrived first. A higher-priority pattern must beat the floor and replace what is playing +(`navigator.vibrate` replaces, it does not queue), then re-arm the floor from itself. + +Fire on incoming events: targeted by Coup/Assassinate/Steal/Examine, challenge resolved +against you, influence lost, eliminated, game over. + +--- + +## Phase 6 — Input feel + +### 6.1 Refuse out loud + +Coup disables buttons and adds a `title`. chudopoly measured that the disabled-button +version produced **zero** refusal sounds in a full game. + +Keep controls live and refuse audibly: a `denied` cue, a 320ms shake +(`0/-6/+5/-4/+3/-2/0` px on `cubic-bezier(.36,.07,.19,.97)` — over before the second tap +lands), a 12ms haptic, and **a sentence**. "You need 7 coins to Assassinate" is a sentence; +a greyed button is a shrug. Cards can't shake (their transform is owned by the flight +engine), so cards get a flash instead. + +### 6.2 Mark illegal targets, don't just unmark them + +During target selection Coup lists valid targets and omits the rest. Reformation makes this +worse — same-faction players simply are not offered, with a line of explanatory text below. + +Render every player, and desaturate the ineligible ones to 35% with an explicit +`data-target-illegal` attribute. The illegal half is the half that answers *"why can't I +click there."* + +### 6.3 Acknowledge the press within one frame + +Paint the press pose and fire the pickup cue synchronously inside `pointerdown`, not on +`click`. + +--- + +## Phase 7 — Gates + +The thing that makes all of the above stay true. + +- **Offline audio render in Vitest.** Render every sound through the *same* `buildGraph` via + `OfflineAudioContext` and assert the tier ordering: `influenceLoss` is louder than + `cardShuffle`; `challengeRevealFail` is louder than `coinsGained`; peak ≤ 0 dBFS; no + priority voice was ever dropped. One graph implementation, so the test cannot measure a + mix the player never hears. +- **A cue log.** Under a harness flag, record every cue and haptic instead of playing it. + Then "every started flight resolves" and "no beat fires two haptics" become assertions + rather than hopes. chudopoly found 14 unanswered card-slide sounds per game this way. +- **A reduced-motion pass.** Assert that every cue that fires in the normal path also fires + in the reduced path, in the same tick. + +--- + +## Sequencing + +| Phase | Effort | Unlocks | +|---|---|---| +| 0 — Write the law | 0.5 day | Everything checks against it | +| 1.1 Type + 1.2 emoji | 1–2 days | The largest visible change for the least work | +| 4.1 Mix tiers + 4.2 bus | 0.5 day | Fixes an actively wrong thing | +| 5 Haptics | 0.5 day | Cheap, and currently absent where it matters most | +| 1.3 Card frames | 1 day | | +| 2.1–2.4 Motion core | 3–4 days | Prerequisite for hitstop, FX, per-verb work | +| 3 FX layer | 4–5 days | The single biggest absence | +| 1.4 Table layout | 4–5 days | Largest item; makes flights have somewhere to go | +| 2.5–2.7 Per-verb + reveal | 2–3 days | Needs 2.1 and 1.4 | +| 4.3–4.7 Sound depth | 2–3 days | | +| 6 Input feel | 1–2 days | | +| 7 Gates | 2 days | Keeps it all true | + +**Do the first four rows in one week.** They are cheap, independent, and together they close +most of the perceived gap. + +--- + +## What NOT to copy from chudopoly + +- **Its asset strategy.** chudopoly is zero-binary-assets by policy and its own ship gate + records "custom visuals: 1, target ≥16" and "grayscale: fails." Coup's generated art is a + real advantage. Copy the *method*, not the look. +- **Its exact palette.** Blackened teal and crimson enamel is Coup's world; airfield concrete + and cobalt is chudopoly's. The rule to copy is *colour and material are two independent + channels*, not the specific hues. +- **Animating the recap numbers.** chudopoly's own rule: *"a number that animates claims it + just changed; a record being consulted did not."* `GameOverOverlay`'s stats should deal in + as rows (70ms stagger), but the figures themselves should not count up. +- **Drag-and-drop.** Coup is a tap game. The exponential-smoothing weight recipe is only + worth porting if cards ever become draggable. From 9c8a19fb96eb5e30e35f3117cf430cf54a85b414 Mon Sep 17 00:00:00 2001 From: Hunter M Date: Sun, 9 Aug 2026 01:30:27 -0700 Subject: [PATCH 2/3] feat(engines): motion, impact, audio mix, haptics and the glyph set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four self-contained systems, none of which existed. anim/ — one rAF clock, a transform contract, easeOutBack on position AND scale, 45ms hitstop fired at contact, measured FLIP with the zero-rect guards. Two constants taken from the reference project did not survive arithmetic and are corrected in place: BACK=0.9 overshoots 2.99% (not 4.5%), and the classic 1.70158 overshoots exactly 10.00% — which is where the folklore "19px past a slot" comes from. fx/ — particles (pooled, zero per-frame allocation), a trauma-model shake with a perceptual floor, a screen-blend flash plate, floating text. The restraint rule is a type error, not a convention: a bystander row is typed `FxFlash` where QuietTone excludes crimson. audio/ — the mix was inverted and the first fix was arithmetic on paper. Rendered through the real graph, three of four tier boundaries were still inverted, worst case -8.84 dB, with `yourTurn` 8.6 dB above the deck shuffle. Chrome's DynamicsCompressorNode applies a +6.5 dB makeup gain that ramps in over ~300ms, so every render pre-rolls 1.0s of silence. All four boundaries now measure positive and are gated against committed measurements. haptics — seven patterns behind a priority-aware floor. A flat floor collapses the vocabulary to whichever pattern fires first. glyphs — 27 UI marks plus six character silhouettes, two stroke widths, no gradients. Four of the six were redrawn after viewing them at 16px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LgH9q6E7j2KZp3PCDSMfV --- src/app/anim/clock.ts | 142 ++ src/app/anim/easing.ts | 139 ++ src/app/anim/flight.ts | 768 ++++++++ src/app/anim/flip.ts | 154 ++ src/app/anim/index.ts | 145 ++ src/app/anim/useFlight.ts | 193 ++ src/app/anim/verbs.ts | 486 +++++ src/app/audio/SoundEngine.ts | 1649 ++++++++++++++--- .../icons/glyphs/AmbassadorGlyph.tsx | 33 + .../components/icons/glyphs/AssassinGlyph.tsx | 52 + .../icons/glyphs/AssassinateGlyph.tsx | 19 + .../components/icons/glyphs/BlockGlyph.tsx | 22 + .../components/icons/glyphs/BluffGlyph.tsx | 14 + src/app/components/icons/glyphs/BotGlyph.tsx | 16 + .../components/icons/glyphs/CaptainGlyph.tsx | 38 + .../icons/glyphs/ChallengeGlyph.tsx | 17 + .../components/icons/glyphs/ClaimGlyph.tsx | 13 + src/app/components/icons/glyphs/CoinGlyph.tsx | 13 + .../components/icons/glyphs/ContessaGlyph.tsx | 43 + .../components/icons/glyphs/ConvertGlyph.tsx | 16 + src/app/components/icons/glyphs/CoupGlyph.tsx | 16 + .../components/icons/glyphs/CrownGlyph.tsx | 25 + src/app/components/icons/glyphs/DeckGlyph.tsx | 16 + .../components/icons/glyphs/DeclareGlyph.tsx | 12 + src/app/components/icons/glyphs/DiceGlyph.tsx | 15 + src/app/components/icons/glyphs/DukeGlyph.tsx | 42 + .../components/icons/glyphs/EmbezzleGlyph.tsx | 21 + .../components/icons/glyphs/ExamineGlyph.tsx | 13 + .../components/icons/glyphs/ExchangeGlyph.tsx | 15 + src/app/components/icons/glyphs/ExitGlyph.tsx | 14 + .../icons/glyphs/GameStartGlyph.tsx | 17 + src/app/components/icons/glyphs/GlyphBase.tsx | 100 + .../components/icons/glyphs/GlyphSheet.tsx | 92 + .../icons/glyphs/InquisitorGlyph.tsx | 41 + src/app/components/icons/glyphs/PassGlyph.tsx | 13 + .../components/icons/glyphs/ResolveGlyph.tsx | 16 + .../components/icons/glyphs/SkullGlyph.tsx | 17 + .../components/icons/glyphs/SpeakerGlyph.tsx | 14 + .../icons/glyphs/SpeakerMutedGlyph.tsx | 14 + .../components/icons/glyphs/StealGlyph.tsx | 16 + .../components/icons/glyphs/TargetGlyph.tsx | 17 + .../components/icons/glyphs/TruthGlyph.tsx | 13 + src/app/components/icons/glyphs/TurnGlyph.tsx | 12 + src/app/components/icons/glyphs/index.ts | 84 + src/app/components/icons/index.ts | 38 + src/app/fonts/OFL.txt | 93 + src/app/fonts/Oswald-Variable-latin-ext.woff2 | Bin 0 -> 24048 bytes src/app/fonts/Oswald-Variable-latin.woff2 | Bin 0 -> 28456 bytes src/app/fx/emitters.ts | 279 +++ src/app/fx/flash.ts | 168 ++ src/app/fx/floaters.ts | 358 ++++ src/app/fx/index.ts | 493 +++++ src/app/fx/overlay.ts | 211 +++ src/app/fx/palette.ts | 50 + src/app/fx/particles.ts | 658 +++++++ src/app/fx/shake.ts | 236 +++ src/app/fx/tuning.ts | 624 +++++++ src/app/utils/haptic.ts | 187 +- tests/app/anim/clock.test.ts | 181 ++ tests/app/anim/easing.test.ts | 175 ++ tests/app/anim/fakeDom.ts | 146 ++ tests/app/anim/flight.test.ts | 462 +++++ tests/app/anim/flip.test.ts | 164 ++ tests/app/anim/hiddenTabSettle.test.ts | 145 ++ tests/app/anim/verbGestures.test.ts | 551 ++++++ tests/app/anim/verbs.test.ts | 920 +++++++++ tests/app/audio/analysis.test.ts | 119 ++ tests/app/audio/analysis.ts | 290 +++ tests/app/audio/harness.entry.ts | 223 +++ tests/app/audio/harness.html | 30 + tests/app/audio/measurements.ts | 217 +++ tests/app/audio/mix.test.ts | 297 +++ tests/app/components/characterGlyphs.test.ts | 163 ++ tests/app/fx/cueOrdering.test.ts | 107 ++ tests/app/fx/floaters.test.ts | 226 +++ tests/app/fx/particles.test.ts | 194 ++ tests/app/fx/reducedMotion.test.ts | 226 +++ tests/app/fx/shake.test.ts | 195 ++ tests/app/fx/tuning.test.ts | 234 +++ 79 files changed, 13025 insertions(+), 262 deletions(-) create mode 100644 src/app/anim/clock.ts create mode 100644 src/app/anim/easing.ts create mode 100644 src/app/anim/flight.ts create mode 100644 src/app/anim/flip.ts create mode 100644 src/app/anim/index.ts create mode 100644 src/app/anim/useFlight.ts create mode 100644 src/app/anim/verbs.ts create mode 100644 src/app/components/icons/glyphs/AmbassadorGlyph.tsx create mode 100644 src/app/components/icons/glyphs/AssassinGlyph.tsx create mode 100644 src/app/components/icons/glyphs/AssassinateGlyph.tsx create mode 100644 src/app/components/icons/glyphs/BlockGlyph.tsx create mode 100644 src/app/components/icons/glyphs/BluffGlyph.tsx create mode 100644 src/app/components/icons/glyphs/BotGlyph.tsx create mode 100644 src/app/components/icons/glyphs/CaptainGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ChallengeGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ClaimGlyph.tsx create mode 100644 src/app/components/icons/glyphs/CoinGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ContessaGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ConvertGlyph.tsx create mode 100644 src/app/components/icons/glyphs/CoupGlyph.tsx create mode 100644 src/app/components/icons/glyphs/CrownGlyph.tsx create mode 100644 src/app/components/icons/glyphs/DeckGlyph.tsx create mode 100644 src/app/components/icons/glyphs/DeclareGlyph.tsx create mode 100644 src/app/components/icons/glyphs/DiceGlyph.tsx create mode 100644 src/app/components/icons/glyphs/DukeGlyph.tsx create mode 100644 src/app/components/icons/glyphs/EmbezzleGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ExamineGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ExchangeGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ExitGlyph.tsx create mode 100644 src/app/components/icons/glyphs/GameStartGlyph.tsx create mode 100644 src/app/components/icons/glyphs/GlyphBase.tsx create mode 100644 src/app/components/icons/glyphs/GlyphSheet.tsx create mode 100644 src/app/components/icons/glyphs/InquisitorGlyph.tsx create mode 100644 src/app/components/icons/glyphs/PassGlyph.tsx create mode 100644 src/app/components/icons/glyphs/ResolveGlyph.tsx create mode 100644 src/app/components/icons/glyphs/SkullGlyph.tsx create mode 100644 src/app/components/icons/glyphs/SpeakerGlyph.tsx create mode 100644 src/app/components/icons/glyphs/SpeakerMutedGlyph.tsx create mode 100644 src/app/components/icons/glyphs/StealGlyph.tsx create mode 100644 src/app/components/icons/glyphs/TargetGlyph.tsx create mode 100644 src/app/components/icons/glyphs/TruthGlyph.tsx create mode 100644 src/app/components/icons/glyphs/TurnGlyph.tsx create mode 100644 src/app/components/icons/glyphs/index.ts create mode 100644 src/app/fonts/OFL.txt create mode 100644 src/app/fonts/Oswald-Variable-latin-ext.woff2 create mode 100644 src/app/fonts/Oswald-Variable-latin.woff2 create mode 100644 src/app/fx/emitters.ts create mode 100644 src/app/fx/flash.ts create mode 100644 src/app/fx/floaters.ts create mode 100644 src/app/fx/index.ts create mode 100644 src/app/fx/overlay.ts create mode 100644 src/app/fx/palette.ts create mode 100644 src/app/fx/particles.ts create mode 100644 src/app/fx/shake.ts create mode 100644 src/app/fx/tuning.ts create mode 100644 tests/app/anim/clock.test.ts create mode 100644 tests/app/anim/easing.test.ts create mode 100644 tests/app/anim/fakeDom.ts create mode 100644 tests/app/anim/flight.test.ts create mode 100644 tests/app/anim/flip.test.ts create mode 100644 tests/app/anim/hiddenTabSettle.test.ts create mode 100644 tests/app/anim/verbGestures.test.ts create mode 100644 tests/app/anim/verbs.test.ts create mode 100644 tests/app/audio/analysis.test.ts create mode 100644 tests/app/audio/analysis.ts create mode 100644 tests/app/audio/harness.entry.ts create mode 100644 tests/app/audio/harness.html create mode 100644 tests/app/audio/measurements.ts create mode 100644 tests/app/audio/mix.test.ts create mode 100644 tests/app/components/characterGlyphs.test.ts create mode 100644 tests/app/fx/cueOrdering.test.ts create mode 100644 tests/app/fx/floaters.test.ts create mode 100644 tests/app/fx/particles.test.ts create mode 100644 tests/app/fx/reducedMotion.test.ts create mode 100644 tests/app/fx/shake.test.ts create mode 100644 tests/app/fx/tuning.test.ts diff --git a/src/app/anim/clock.ts b/src/app/anim/clock.ts new file mode 100644 index 0000000..81d644f --- /dev/null +++ b/src/app/anim/clock.ts @@ -0,0 +1,142 @@ +/** + * anim/clock.ts — the ONE clock (GAME-FEEL-PLAN §2.1). + * + * Every animated thing in this app is stepped by this single rAF loop. A second + * timeline (a CSS transition, a WAAPI animation, a `setInterval`) is a second + * clock: it drifts against this one, it cannot be frozen for a hitstop, and it + * cannot be interrupted mid-frame — and a Coup flight must be interruptible, + * because a card can be re-flown by a reconcile while it is still landing. + * + * ALLOCATION. The subscriber list is a plain array walked by index; add/remove + * during a tick go into pending arrays drained between frames, so a frame never + * allocates and never mutates the array it is walking. + * + * SSR. `requestAnimationFrame` is never referenced at module scope, only inside + * `start()`/`stop()` behind a `typeof` guard. Importing this module on the + * server is inert: `subscribe()` records the subscriber and the loop simply + * never runs, so nothing throws and nothing leaks. + */ + +export type ClockSubscriber = (dt: number, elapsed: number) => void; +export type Unsubscribe = () => void; + +/** A 3s tab stall must not teleport a card. */ +const MAX_DT = 1 / 20; + +const subs: ClockSubscriber[] = []; +const pendingAdd: ClockSubscriber[] = []; +const pendingRemove: ClockSubscriber[] = []; + +let running = false; +let ticking = false; +let rafId = 0; +/** Timestamp of the previous frame. NaN means "no previous frame" → dt 0. */ +let last = Number.NaN; +let elapsed = 0; +let frames = 0; + +function hasRaf(): boolean { + return typeof globalThis.requestAnimationFrame === 'function'; +} + +/** + * Register a per-frame callback. Returns its own unsubscribe so a caller never + * has to keep the function identity around. + */ +export function subscribe(fn: ClockSubscriber): Unsubscribe { + if (ticking) pendingAdd.push(fn); + else if (subs.indexOf(fn) < 0) subs.push(fn); + start(); + return () => unsubscribe(fn); +} + +export function unsubscribe(fn: ClockSubscriber): void { + if (ticking) { + pendingRemove.push(fn); + return; + } + const i = subs.indexOf(fn); + if (i >= 0) subs.splice(i, 1); +} + +/** Seconds since the clock first ran, summed from CLAMPED dt. Presentation only. */ +export function now(): number { + return elapsed; +} + +export function frameCount(): number { + return frames; +} + +export function subCount(): number { + return subs.length; +} + +export function isRunning(): boolean { + return running; +} + +function drain(): void { + for (let i = 0; i < pendingAdd.length; i++) { + if (subs.indexOf(pendingAdd[i]) < 0) subs.push(pendingAdd[i]); + } + pendingAdd.length = 0; + for (let i = 0; i < pendingRemove.length; i++) { + const j = subs.indexOf(pendingRemove[i]); + if (j >= 0) subs.splice(j, 1); + } + pendingRemove.length = 0; +} + +function tick(t: number): void { + rafId = globalThis.requestAnimationFrame(tick); + + let dt = Number.isFinite(last) ? (t - last) / 1000 : 0; + last = t; + // Both ends of the clamp are load-bearing: a backwards or NaN timestamp is 0, + // and a long stall is one slow frame rather than a teleport. + if (!(dt > 0)) dt = 0; + else if (dt > MAX_DT) dt = MAX_DT; + elapsed += dt; + frames++; + + ticking = true; + try { + for (let i = 0; i < subs.length; i++) subs[i](dt, elapsed); + } finally { + ticking = false; + drain(); + } + + // The loop stops itself. Nothing animating costs nothing. + if (subs.length === 0) stop(); +} + +export function start(): void { + if (running || !hasRaf()) return; + running = true; + last = Number.NaN; + rafId = globalThis.requestAnimationFrame(tick); +} + +export function stop(): void { + if (!running) return; + running = false; + if (rafId && typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(rafId); + } + rafId = 0; + last = Number.NaN; +} + +/** + * Drop every subscriber and stop. Route teardown and tests — never gameplay. + */ +export function reset(): void { + subs.length = 0; + pendingAdd.length = 0; + pendingRemove.length = 0; + stop(); + elapsed = 0; + frames = 0; +} diff --git a/src/app/anim/easing.ts b/src/app/anim/easing.ts new file mode 100644 index 0000000..ddc7e1e --- /dev/null +++ b/src/app/anim/easing.ts @@ -0,0 +1,139 @@ +/** + * anim/easing.ts — allocation-free easing. No object returns, no DOM, no state. + * + * The two `back` constants below are the whole squash-and-stretch budget of this + * game, so they are documented rather than tuned in place. + */ + +export function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +/** + * Overshoot for POSITION and ROTATION. + * + * ART-DIRECTION §6 and GAME-FEEL-PLAN §2.2 both annotate this constant + * "~4.5% overshoot". The constant is what is ratified; the annotation is a slip. + * The closed form peaks at v = 2k/(3(k+1)) = 0.3158, giving + * `1 + 0.9v² − 1.9v³ = 1.0299` — **2.99%**. (The classic 1.70158 peaks at + * exactly 10%, which is where the "19px past the slot" measurement below comes + * from: 10% of a 190px travel.) Stated here because a wrong number in a comment + * gets copied into the next tuning session. + * + * Not the classic `1.70158`. That constant is derived to overshoot ~10%, which + * on a wide table puts a card roughly 19px past the slot it is landing in + * (chudopoly, measured on a 1280px table) and reads as a *bounce* — a card that + * hit something and came back. 0.9 reads as a *landing*: the card arrives, the + * paper flexes, it settles. Same code, different physics. + */ +export const BACK = 0.9; + +/** + * Overshoot for SCALE — the free squash-and-stretch. + * + * Scale used to be eased with `easeOutCubic`, which is monotonic: a card grew to + * its final size and simply stopped, so every landing read as a teleport with a + * sound on it. `easeOutBack` on the *scale* axis costs nothing and gives the + * landing a direction — a card growing (deck 26px → hand 62px) overshoots and + * settles back, and a card shrinking (hand → a small seat slot) undershoots and + * springs open. That is squash-and-stretch for one changed easing function. + * + * 0.55 rather than 0.9 because scale overshoot is measured in edge travel, not + * in fraction: at 0.9 a hand-sized card's 3% overshoot is ~1.9px of edge + * movement on every side at once, which reads as a bounce again. 0.55 peaks at + * 1.03%. + */ +export const SCALE_BACK = 0.55; + +/** + * Back-out easing. `easeOutBack(0, k) === 0`, `easeOutBack(1, k) === 1`, and it + * exceeds 1 in between — that excursion IS the settle. + * + * Crosses 1 (i.e. the element's centroid is exactly on its mark) at + * `p = 1 − k/(1+k)`; see `CONTACT` in flight.ts, which is the frame the hitstop + * fires on. + */ +export function easeOutBack(p: number, back: number = BACK): number { + // The endpoints are pinned rather than computed: `1 + (k+1)(−1)³ + k` is 0 in + // algebra and 1.1e-16 in floating point, and an element whose flight ends + // 1e-16 off its rest pose is an element that never quite equals its rest pose. + if (p <= 0) return 0; + if (p >= 1) return 1; + const u = p - 1; + return 1 + (back + 1) * u * u * u + back * u * u; +} + +/** Hermite S-curve on [0,1], clamped. Symmetric — no overshoot. */ +export function smoothstep(p: number): number { + const t = clamp01(p); + return t * t * (3 - 2 * t); +} + +export function easeOutCubic(p: number): number { + const u = 1 - clamp01(p); + return 1 - u * u * u; +} + +/* ── the ratified settle curve ───────────────────────────────────────────── + ART-DIRECTION names `cubic-bezier(.22, 1, .36, 1)` by its coefficients, so it + is SOLVED rather than approximated. `easeOutQuint` — the obvious stand-in — + deviates by up to 1.14%, peaking at p≈0.058 where the curve is steepest and + the eye is most sensitive to the release; it is 0.23% off at p=0.15. (The + brief quotes "3.1% off at p=0.15"; measured against the real curve it is + 0.23%, and the honest argument for solving is not the size of that error but + that the coefficients are ratified, so the curve should be the curve.) + + Newton on the x-polynomial, 4 iterations, allocation-free. 4 because the 5th + moved the result by <1e-6 for every p sampled at 1/240s across the curve. */ +const BZ_X1 = 0.22; +const BZ_Y1 = 1; +const BZ_X2 = 0.36; +const BZ_Y2 = 1; + +function bezAxis(t: number, a1: number, a2: number): number { + const u = 1 - t; + return 3 * u * u * t * a1 + 3 * u * t * t * a2 + t * t * t; +} + +function bezSlope(t: number, a1: number, a2: number): number { + const u = 1 - t; + return 3 * u * u * a1 + 6 * u * t * (a2 - a1) + 3 * t * t * (1 - a2); +} + +/** `cubic-bezier(.22, 1, .36, 1)` solved by Newton's method, 4 iterations. */ +export function settle(p: number): number { + if (!(p > 0)) return 0; + if (p >= 1) return 1; + let t = p; + for (let i = 0; i < 4; i++) { + const d = bezSlope(t, BZ_X1, BZ_X2); + if (d < 1e-5) break; + t -= (bezAxis(t, BZ_X1, BZ_X2) - p) / d; + if (t < 0) t = 0; + else if (t > 1) t = 1; + } + return bezAxis(t, BZ_Y1, BZ_Y2); +} + +/** + * Deterministic 1D hash in [0,1). A pure function of `n`, NOT an RNG: the arc + * side of a straight-up flight is picked with it precisely so a screenshot run + * reproduces frame for frame. + */ +export function hash1(n: number): number { + let h = Math.imul(n | 0, 374761393); + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} + +/** FNV-1a so a React key (a string id) can seed `hash1` as stably as a number. */ +export function hashKey(key: number | string | undefined | null): number { + if (key == null) return 0; + if (typeof key === 'number') return key | 0; + let h = 2166136261; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h | 0; +} diff --git a/src/app/anim/flight.ts b/src/app/anim/flight.ts new file mode 100644 index 0000000..8d26a66 --- /dev/null +++ b/src/app/anim/flight.ts @@ -0,0 +1,768 @@ +/** + * anim/flight.ts — the one card-motion engine (ART-DIRECTION §6, §7). + * + * Every element that moves on this table is a record in `live`, stepped by ONE + * `anim/clock` subscriber. No CSS transition and no WAAPI animation touches an + * element while it is flying, for two reasons: a second timeline is a second + * clock, and a flight has to be interruptible at any frame — a card can be + * re-flown by a state reconcile while it is still landing, and the new flight + * must start from where the old one actually got to, not from where CSS thinks + * it is. + * + * ── THE TRANSFORM CONTRACT ──────────────────────────────────────────────── + * + * transform: translate(var(--fx), var(--fy)) rotate(var(--tilt)) scale(var(--fs)); + * + * This module is the ONLY writer of those four custom properties. Translate + * outermost is load-bearing: adding `dx` to `--fx` moves the element's centroid + * by exactly `dx` whatever the tilt or the scale, which is what makes the + * measured FLIP in flip.ts exact rather than approximately exact. Put rotate or + * scale outside the translate and a measured invert lands short by + * `dx·(1−cos θ)` and `dx·(1−s)`, which is how cards end up "almost" on their + * slot and drifting further off with every hop. + * + * ── REST POSES ──────────────────────────────────────────────────────────── + * + * Each element carries a resting pose (x, y, rotation). A discard rests tilted; + * a hand card rests fanned. Flights land ON the rest pose, never on zero, so a + * fan and a flight compose instead of fighting. `setRest()` during a flight + * retargets it in place — the card lands on the NEW pose rather than landing on + * the old one and then jumping. + * + * REACT DIFFERENCE. chudopoly hangs the rest pose off the DOM node as `__rx` + * expandos, because its card nodes are persistent for the life of the game. + * React remounts, so a node is not a stable identity and an expando would + * outlive nothing useful. The poses live in a `WeakMap` here: a remounted node + * is simply a node with no rest pose (0,0,0), which is the correct default, and + * an unmounted one is collected. + * + * ── ALLOCATION ──────────────────────────────────────────────────────────── + * + * Records are pooled; the live list is compacted by swap-remove (`splice` + * returns an array — that is a per-frame allocation). The only per-frame + * allocation left is the string a custom-property write demands, and those are + * guarded on the QUANTISED value: 0.1px, 0.1deg, 0.001 scale. A settled element + * costs 0 writes per frame; a moving one costs at most 4. + * + * ── EVERY STARTED FLIGHT RESOLVES ───────────────────────────────────────── + * + * A flight ends in exactly one of two callbacks: `land` or `abort`. Never both, + * never neither. chudopoly measured 173 slide cues against 159 landing cues over + * a seeded game — 14 swishes that begin and are never answered, because the card + * was superseded mid-air and its record was dropped silently. A sound with no + * consequence is the definition of noise. + */ + +import { subscribe, unsubscribe } from './clock'; +import { BACK, SCALE_BACK, clamp01, easeOutBack, hash1, hashKey } from './easing'; + +/* ── the DOM surface this module needs ─────────────────────────────────── + Structural, not `HTMLElement`, for two reasons: it documents exactly how + little of the DOM the engine touches, and it lets the engine be tested in + vitest's node environment without a jsdom. A real `HTMLElement` satisfies it. */ + +export interface FlightStyle { + setProperty(property: string, value: string): void; + removeProperty(property: string): void; + getPropertyValue(property: string): string; +} + +export interface FlightElement { + readonly isConnected: boolean; + readonly style: FlightStyle; +} + +export type LandCallback = (el: FlightElement) => void; +/** `started` is false when the flight was killed inside its delay — it never + * announced itself, so a caller wiring cues here can stay silent. */ +export type AbortCallback = (el: FlightElement, started: boolean) => void; + +/* ── constants ───────────────────────────────────────────────────────────── */ + +/** Duration comes from distance, clamped. §6's per-verb table lives inside it. */ +export const MS_MIN = 180; +export const MS_MAX = 420; +export const MS_PER_PX = 0.42; + +/** + * A staggered event's LAST element must still be settled this long after the + * event. Late members lose stagger, not duration: a caravan that bunches up + * still reads; a caravan whose last card crawls does not. + */ +export const MAX_EVENT_MS = 600; + +/* ── HITSTOP (ART-DIRECTION §6) ──────────────────────────────────────────── + "freeze the whole animation list for 45ms on contact, min 200ms between + freezes, paid for at launch so the timing budget is untouched." + + CONTACT is where it fires: easeOutBack(p, BACK) crosses 1 at + p = 1 − BACK/(1+BACK) = 1 − 0.9/1.9 = 0.5263 + — the exact frame the element's centroid reaches its destination. After it, + the element is doing its 3% overshoot-and-return, which IS the settle. + Freezing at p=1 instead is worthless: by then a solo landing has nothing left + in the air to freeze, so the table is already still and the beat costs 45ms of + nothing. + + The freeze stops the WHOLE live list, not just the landing element — that is + the difference between a card pausing and the table taking a hit. + + The budget is not spent on it: an armed flight has HITSTOP_MS subtracted from + its duration at launch, so contact arrives 45ms early, the table holds for + 45ms, and total wall time is exactly what it was before. + + MIN_GAP exists because a caravan is not five impacts. A 5-card exchange at + 70ms stagger would otherwise freeze the table five times for 225ms and read + as jank; one hit per 200ms lets the FIRST card land heavy and the rest ride in + behind it. */ +export const HITSTOP_MS = 45; +export const HITSTOP_MIN_GAP_MS = 200; +/** A hop too short to pay for its own freeze would become a stutter with no travel. */ +export const HITSTOP_FLOOR_MS = 90; +export const CONTACT = 1 - BACK / (1 + BACK); + +/** §7: motion collapses to a fade of at most this long. Information does not collapse. */ +export const REDUCED_FADE_MS = 120; + +const K_FLY = 0; +const K_FADE = 1; + +/* ── module state ────────────────────────────────────────────────────────── */ + +interface FlightRecord { + kind: number; + el: FlightElement | null; + t: number; + delay: number; + dur: number; + started: boolean; + resolved: boolean; + x0: number; y0: number; x1: number; y1: number; + s0: number; s1: number; + r0: number; r1: number; + ax: number; ay: number; + env: number; + spin: number; + bump: number; + hit: boolean; + hitDone: boolean; + from: number; to: number; + /** last written quantised values — the write guard */ + wx: number; wy: number; ws: number; wr: number; wo: number; + land: LandCallback | null; + abort: AbortCallback | null; +} + +export interface RestPose { + x: number; + y: number; + rot: number; +} + +const pool: FlightRecord[] = []; +const live: FlightRecord[] = []; +const rests = new WeakMap(); +/** One owner per channel, so a fade and a flight cannot evict each other. */ +const flyOwner = new WeakMap(); +const fadeOwner = new WeakMap(); + +let running = false; +let reducedMotion = false; +/** Seconds of table-freeze left. */ +let freeze = 0; +/** Accumulated dt — the only time source in this file. */ +let clockSec = 0; +let lastHit = -1e9; +let hitCount = 0; + +/** How many hitstops have actually FROZEN the table since load. */ +export function hitstopCount(): number { + return hitCount; +} + +export function liveCount(): number { + return live.length; +} + +export function isFlying(el: FlightElement | null | undefined): boolean { + return !!el && flyOwner.has(el); +} + +/** + * §7. When on, every flight collapses to a ≤120ms opacity fade that fires its + * landing callback in the SAME TICK — the ramp is cosmetic and is exactly what + * the player asked to remove, so nothing informational may hang off its end. + */ +export function setReducedMotion(on: boolean): void { + reducedMotion = on; +} + +export function isReducedMotion(): boolean { + return reducedMotion; +} + +/* ── rest poses ──────────────────────────────────────────────────────────── */ + +const ZERO_REST: RestPose = { x: 0, y: 0, rot: 0 }; + +export function getRest(el: FlightElement | null | undefined): RestPose { + if (!el) return ZERO_REST; + return rests.get(el) ?? ZERO_REST; +} + +/** Set the resting pose. An element already in the air is retargeted in place. */ +export function setRest(el: FlightElement | null | undefined, x: number, y: number, rot = 0): void { + if (!el) return; + const prev = rests.get(el); + if (prev) { + prev.x = x; + prev.y = y; + prev.rot = rot; + } else { + rests.set(el, { x, y, rot }); + } + const r = flyOwner.get(el); + if (r && r.kind === K_FLY) { + r.x1 = x; + r.y1 = y; + r.r1 = rot; + return; + } + writeRest(el); +} + +export function clearRest(el: FlightElement | null | undefined): void { + if (el) rests.delete(el); +} + +/** Write the resting pose now, with no animation. */ +export function writeRest(el: FlightElement | null | undefined): void { + if (!el) return; + const rest = getRest(el); + const s = el.style; + s.setProperty('--fx', fmtPx(rest.x)); + s.setProperty('--fy', fmtPx(rest.y)); + s.setProperty('--tilt', fmtDeg(rest.rot)); + s.setProperty('--fs', '1'); +} + +/** Clear the contract's four properties entirely (element leaves the system). */ +export function clearTransform(el: FlightElement | null | undefined): void { + if (!el) return; + const s = el.style; + s.removeProperty('--fx'); + s.removeProperty('--fy'); + s.removeProperty('--tilt'); + s.removeProperty('--fs'); +} + +/* ── record lifecycle ────────────────────────────────────────────────────── */ + +function take(): FlightRecord { + const r = pool.pop(); + if (r) return r; + return { + kind: K_FLY, el: null, t: 0, delay: 0, dur: 0.3, started: false, resolved: false, + x0: 0, y0: 0, x1: 0, y1: 0, s0: 1, s1: 1, r0: 0, r1: 0, + ax: 0, ay: 0, env: 1, spin: 0, bump: 0, + hit: false, hitDone: false, + from: 0, to: 1, + wx: Number.NaN, wy: Number.NaN, ws: Number.NaN, wr: Number.NaN, wo: Number.NaN, + land: null, abort: null, + }; +} + +function recycle(r: FlightRecord): void { + r.el = null; + r.land = null; + r.abort = null; + r.started = false; + r.resolved = false; + r.bump = 0; + r.spin = 0; + r.ax = 0; + r.ay = 0; + r.env = 1; + r.hit = false; + r.hitDone = false; + r.wx = r.wy = r.ws = r.wr = r.wo = Number.NaN; + if (pool.length < 64) pool.push(r); +} + +function ownerOf(kind: number): WeakMap { + return kind === K_FADE ? fadeOwner : flyOwner; +} + +function attach(r: FlightRecord): void { + const el = r.el; + if (!el) return; + const owners = ownerOf(r.kind); + const prev = owners.get(el); + if (prev && prev !== r) drop(prev, false); + owners.set(el, r); + live.push(r); + if (!running) { + running = true; + subscribe(tick); + } +} + +/** Remove from the live list. `applyEnd` finishes it; otherwise it aborts. */ +function drop(r: FlightRecord, applyEnd: boolean): void { + const i = live.indexOf(r); + if (i >= 0) { + live[i] = live[live.length - 1]; + live.pop(); + } + release(r); + if (applyEnd) finish(r); + else abort(r); + recycle(r); +} + +/** Give the element's channel back, but only if this record still owns it. */ +function release(r: FlightRecord): void { + const el = r.el; + if (!el) return; + const owners = ownerOf(r.kind); + if (owners.get(el) === r) owners.delete(el); +} + +function abort(r: FlightRecord): void { + if (r.resolved) return; + r.resolved = true; + const cb = r.abort; + const el = r.el; + r.abort = null; + r.land = null; + if (cb && el) cb(el, r.started); +} + +function finish(r: FlightRecord): void { + const el = r.el; + if (!el) return; + if (r.kind === K_FLY) { + write(r, el, r.x1, r.y1, r.s1, r.r1); + } else { + // The fade is cosmetic; leave the element at full opacity with no inline + // override, so a later CSS state is not fighting a stale `opacity: 1`. + r.wo = Number.NaN; + el.style.removeProperty('opacity'); + } + if (r.resolved) return; + r.resolved = true; + const cb = r.land; + r.land = null; + r.abort = null; + if (cb) cb(el); +} + +/* ── the tick ────────────────────────────────────────────────────────────── */ + +function tick(dt: number): void { + clockSec += dt; + + // THE FREEZE. Nothing advances — not r.t, not a delay countdown. Game-time + // pacing lives on other clock subscribers and is deliberately NOT frozen: the + // hitstop is presentation. + if (freeze > 0) { + freeze -= dt; + if (freeze > 0) return; + freeze = 0; + } + + for (let i = live.length - 1; i >= 0; i--) { + const r = live[i]; + const el = r.el; + + // A React node can unmount mid-flight. Writing to a detached node is work + // nobody sees, and the flight must resolve as an abort, not a landing. + if (!el || !el.isConnected) { + live[i] = live[live.length - 1]; + live.pop(); + release(r); + abort(r); + recycle(r); + continue; + } + + r.t += dt; + if (r.t < r.delay) continue; + r.started = true; + + const p = r.dur > 0 ? clamp01((r.t - r.delay) / r.dur) : 1; + step(r, el, p); + + if (p >= 1) { + live[i] = live[live.length - 1]; + live.pop(); + release(r); + finish(r); + recycle(r); + } + } + + if (live.length === 0) { + running = false; + unsubscribe(tick); + } +} + +function step(r: FlightRecord, el: FlightElement, p: number): void { + if (r.kind === K_FADE) { + const o = Math.round((r.from + (r.to - r.from) * p) * 100) / 100; + if (o !== r.wo) { + r.wo = o; + el.style.setProperty('opacity', String(o)); + } + return; + } + + const e = easeOutBack(p, BACK); + // The arc rides a sin envelope, so it is exactly 0 at both ends and the + // measured FLIP end state is untouched. `env !== 1` skews the peak earlier, + // which is what makes a shove read as a hit instead of a wobble. + const env = r.env === 1 ? Math.sin(Math.PI * p) : Math.sin(Math.PI * Math.pow(p, r.env)); + + write( + r, + el, + r.x0 + (r.x1 - r.x0) * e + r.ax * env, + r.y0 + (r.y1 - r.y0) * e + r.ay * env, + r.s0 + (r.s1 - r.s0) * easeOutBack(p, SCALE_BACK) + r.bump * env, + r.r0 + (r.r1 - r.r0) * e + r.spin * env, + ); + + // CONTACT — the centroid is on its mark. Take the hit. The counter counts + // FREEZES APPLIED, not contacts detected: gated on HITSTOP_MS > 0 so that + // zeroing the duration zeroes the count. A counter that kept climbing while + // the table never actually froze would wave that regression through. + if (r.hit && !r.hitDone && p >= CONTACT) { + r.hitDone = true; + if (HITSTOP_MS > 0 && clockSec - lastHit >= HITSTOP_MIN_GAP_MS / 1000) { + lastHit = clockSec; + freeze = HITSTOP_MS / 1000; + hitCount++; + } + } +} + +/** Guarded, quantised writes — the whole hot path's DOM cost. */ +function write(r: FlightRecord, el: FlightElement, x: number, y: number, s: number, rot: number): void { + const s_ = el.style; + const qx = Math.round(x * 10) / 10; + if (qx !== r.wx) { + r.wx = qx; + s_.setProperty('--fx', qx + 'px'); + } + const qy = Math.round(y * 10) / 10; + if (qy !== r.wy) { + r.wy = qy; + s_.setProperty('--fy', qy + 'px'); + } + const qs = Math.round(s * 1000) / 1000; + if (qs !== r.ws) { + r.ws = qs; + s_.setProperty('--fs', qs === 1 ? '1' : String(qs)); + } + const qr = Math.round(rot * 10) / 10; + if (qr !== r.wr) { + r.wr = qr; + s_.setProperty('--tilt', qr + 'deg'); + } +} + +function fmtPx(v: number): string { + return (Math.round(v * 10) / 10) + 'px'; +} + +function fmtDeg(v: number): string { + return (Math.round(v * 100) / 100) + 'deg'; +} + +/* ── duration ────────────────────────────────────────────────────────────── */ + +function clampMs(ms: number): number { + return ms < MS_MIN ? MS_MIN : ms > MS_MAX ? MS_MAX : ms; +} + +/** + * Travel time for a distance, in ms. + * + * `speed` scales INSIDE the clamp. Outside it, a 1.12× steal on a long + * cross-table flight measured 470ms and pushed the whole event past its 600ms + * commitment; the deliberate feel of a steal is meant to come from its 120ms + * hold, not from a slower card. Inside the clamp, `speed` is a nudge that can + * never buy time the budget does not have. + */ +export function flightDuration(distancePx: number, speed = 1): number { + return clampMs((MS_MIN + distancePx * MS_PER_PX) * speed); +} + +/** Seconds of delay this record may have and still land inside the budget. */ +function budgetDelay(ms: number, durSec: number): number { + const room = MAX_EVENT_MS - durSec * 1000; + const d = ms || 0; + return (d < room ? d : Math.max(0, room)) / 1000; +} + +/* ── public motion ───────────────────────────────────────────────────────── */ + +export interface FlyOptions { + /** Where the element IS relative to where it belongs — the FLIP invert. */ + dx?: number; + dy?: number; + /** Starting scale relative to the rest scale of 1. */ + scale?: number; + /** Explicit travel time in ms (§6's per-verb table). Still clamped. */ + dur?: number; + /** Multiplies the duration INSIDE the clamp. */ + speed?: number; + /** Hold at the launch pose this long first — a theft you can see coming. */ + delay?: number; + /** Perpendicular lift in px. 0 for a straight slam. Default scales with distance. */ + arc?: number; + /** Explicit bow, used only when `arc` is 0 or the travel is degenerate. */ + arcX?: number; + arcY?: number; + /** Degrees of spin ridden on the arc envelope (returns to the rest tilt). */ + spin?: number; + /** Envelope skew. <1 peaks early: a lunge that recovers slowly. */ + env?: number; + /** Extra scale at mid-flight (hero lift). Exactly 0 at both ends. */ + bump?: number; + /** Start the rotation here instead of at the rest tilt. */ + tiltFrom?: number; + /** Arm the hitstop. §6: a revealed influence, a Coup landing, a challenge resolving. */ + hit?: boolean; + /** Seeds the deterministic arc side. Pass a stable card/player id. */ + key?: number | string; + land?: LandCallback; + abort?: AbortCallback; +} + +/** + * Launch an element from `dx,dy` (where it is) to its rest pose (where it + * belongs). Returns false only if there was nothing to fly. + */ +export function fly(el: FlightElement | null | undefined, o: FlyOptions = {}): boolean { + if (!el) return false; + + const rest = getRest(el); + const rx = rest.x; + const ry = rest.y; + const rt = rest.rot; + const dx = o.dx ?? 0; + const dy = o.dy ?? 0; + + if (reducedMotion) return collapse(el, o); + + const dist = Math.sqrt(dx * dx + dy * dy); + + const r = take(); + r.kind = K_FLY; + r.el = el; + r.t = 0; + r.hit = !!o.hit; + r.hitDone = false; + + const ms = clampMs((o.dur ?? MS_MIN + dist * MS_PER_PX) * (o.speed ?? 1)); + // An armed flight pays for its own hitstop: contact lands HITSTOP_MS early, + // the table then holds for HITSTOP_MS, and the budget is untouched. + r.dur = (r.hit ? Math.max(HITSTOP_FLOOR_MS, ms - HITSTOP_MS) : ms) / 1000; + r.delay = budgetDelay(o.delay ?? 0, r.dur); + r.started = false; + r.resolved = false; + r.x0 = rx + dx; + r.y0 = ry + dy; + r.x1 = rx; + r.y1 = ry; + r.s0 = o.scale ?? 1; + r.s1 = 1; + r.r0 = o.tiltFrom ?? rt; + r.r1 = rt; + r.env = o.env ?? 1; + r.spin = o.spin ?? 0; + r.bump = o.bump ?? 0; + r.land = o.land ?? null; + r.abort = o.abort ?? null; + r.wx = r.wy = r.ws = r.wr = Number.NaN; + + // ARC — a lift PERPENDICULAR to travel, biased upward so an element always + // rises off the felt rather than sliding sideways through it. Travel is + // (−dx, −dy); its normals are ±(dy, −dx)/dist and we take the upward one. + // Straight-up travel has no upward normal, so the side is picked from a + // deterministic hash of the caller's key and is therefore identical in every + // screenshot run — no Math.random anywhere in this file. + const arc = o.arc ?? Math.min(30, Math.max(5, dist * 0.14)); + if (arc !== 0 && dist > 0.5) { + let px = dy / dist; + let py = -dx / dist; + if (py > 0.001) { + px = -px; + py = -py; + } else if (Math.abs(py) <= 0.001) { + const sign = hash1(hashKey(o.key) + 11) < 0.5 ? -1 : 1; + px *= sign; + py *= sign; + } + r.ax = px * arc; + r.ay = py * arc; + } else { + r.ax = o.arcX ?? 0; + r.ay = o.arcY ?? 0; + } + + // Hold at the start of the path through any delay, so a staggered caravan + // does not show its later members sitting at the destination first. + write(r, el, r.x0, r.y0, r.s0, r.r0); + attach(r); + return true; +} + +/** + * A there-and-back shove with no net travel: §6's Refuse verb (the loser's card + * shoved back) and Assassinate's 34% lunge. `env` peaks at ~35% so it lunges out + * fast and recovers slowly — a symmetric sine reads as a wobble, not a hit. + */ +export function punch( + el: FlightElement | null | undefined, + dx: number, + dy: number, + o: FlyOptions = {}, +): boolean { + if (!el) return false; + if (reducedMotion) return collapse(el, o); + + const rest = getRest(el); + const r = take(); + r.kind = K_FLY; + r.el = el; + r.t = 0; + r.hit = !!o.hit; + r.hitDone = false; + const ms = clampMs((o.dur ?? 300) * (o.speed ?? 1)); + r.dur = (r.hit ? Math.max(HITSTOP_FLOOR_MS, ms - HITSTOP_MS) : ms) / 1000; + r.delay = budgetDelay(o.delay ?? 0, r.dur); + r.started = false; + r.resolved = false; + r.x0 = r.x1 = rest.x; + r.y0 = r.y1 = rest.y; + r.s0 = o.scale ?? 1; + r.s1 = 1; + r.r0 = r.r1 = rest.rot; + r.ax = dx; + r.ay = dy; + r.env = o.env ?? 0.62; + r.spin = o.spin ?? 0; + r.bump = o.bump ?? 0; + r.land = o.land ?? null; + r.abort = o.abort ?? null; + r.wx = r.wy = r.ws = r.wr = Number.NaN; + write(r, el, r.x0, r.y0, r.s0, r.r0); + attach(r); + return true; +} + +/** + * §7's collapse. The element is placed at its destination IMMEDIATELY and the + * landing callback fires in this same tick; the fade that follows is decoration + * with nothing hanging off it. + */ +function collapse(el: FlightElement, o: FlyOptions): boolean { + const prev = flyOwner.get(el); + if (prev) drop(prev, false); + writeRest(el); + const land = o.land; + if (land) land(el); + fade(el, REDUCED_FADE_MS); + return true; +} + +/** A ≤120ms opacity ramp. Purely cosmetic: it carries no callback, by design. */ +export function fade(el: FlightElement | null | undefined, ms = REDUCED_FADE_MS, from = 0.35): boolean { + if (!el) return false; + const r = take(); + r.kind = K_FADE; + r.el = el; + r.t = 0; + r.dur = Math.min(REDUCED_FADE_MS, Math.max(40, ms)) / 1000; + r.delay = 0; + r.started = false; + r.resolved = false; + r.from = from; + r.to = 1; + r.land = null; + r.abort = null; + r.wo = Number.NaN; + el.style.setProperty('opacity', String(from)); + attach(r); + return true; +} + +/** + * Kill whatever this element is doing. Live flights fire `abort`, never `land`. + */ +export function cancel(el: FlightElement | null | undefined): void { + if (!el) return; + const f = flyOwner.get(el); + if (f) drop(f, false); + const d = fadeOwner.get(el); + if (d) drop(d, false); +} + +/** + * Milliseconds until nothing is moving. Read straight after an event's + * choreography is scheduled and it is exactly how long the table has committed + * to being in motion. 0 = settled. + */ +export function busyUntil(): number { + let ms = 0; + for (let i = 0; i < live.length; i++) { + const r = live[i]; + // An armed flight has already had HITSTOP_MS taken out of r.dur, so the + // freeze it is going to spend must be added back or this number reports the + // table settling before it does. + const hold = r.hit && !r.hitDone ? HITSTOP_MS : 0; + const left = (r.delay + r.dur - r.t) * 1000 + hold; + if (left > ms) ms = left; + } + return ms + freeze * 1000; +} + +/** + * Snap every live record to its end state and fire its landing callback. A + * reconnect or a fixture load: the state is already true, the motion is not. + */ +export function finishAll(): void { + freeze = 0; + while (live.length) { + const r = live[live.length - 1]; + live.pop(); + release(r); + finish(r); + recycle(r); + } + running = false; + unsubscribe(tick); +} + +/** + * Tear everything down. Live flights ABORT (the contract holds even here) and + * the hitstop bookkeeping resets. Route teardown and tests — never gameplay. + */ +export function resetFlights(): void { + while (live.length) { + const r = live[live.length - 1]; + live.pop(); + release(r); + abort(r); + recycle(r); + } + running = false; + unsubscribe(tick); + freeze = 0; + clockSec = 0; + lastHit = -1e9; + hitCount = 0; + reducedMotion = false; +} diff --git a/src/app/anim/flip.ts b/src/app/anim/flip.ts new file mode 100644 index 0000000..a7bbd4d --- /dev/null +++ b/src/app/anim/flip.ts @@ -0,0 +1,154 @@ +/** + * anim/flip.ts — measured FLIP (First, Last, Invert, Play) for React. + * + * React owns the DOM, so the sequence is not chudopoly's "measure, reparent, + * measure": it is "measure BEFORE the render that moves the node, then invert + * after it has committed". Call `measureFirst()` in the render/effect that knows + * the move is coming, and `invertAndPlay()` in the layout effect after it. + * + * The invert is exact because of flight.ts's transform contract — translate is + * outermost, so adding the measured delta to `--fx` moves the centroid by + * exactly that delta whatever tilt and scale the element carries. + * + * Three guards, all of them paid for in bugs elsewhere: + * + * 1. `first.width === 0` — the element was never rendered. There is no + * previous position to fly from; place it. + * 2. `last.width === 0` — the destination is not laid out (a hidden panel, a + * collapsed seat). INVERTING AGAINST A 0×0 RECT IS WHAT SENDS CARDS FLYING + * TO VIEWPORT (0,0). Place it and skip the beat rather than perform it + * into a corner. + * 3. sub-1px move with no scale change — nothing to animate; a flight here is + * a start cue, a landing cue and 300ms of committed table time for a move + * no one can see. + * + * In cases 1–3 the caller's `land` callback still fires, synchronously. The + * motion is skipped; the information is not (ART-DIRECTION §7). + */ + +import { + cancel, + fly, + writeRest, + type FlightElement, + type FlyOptions, +} from './flight'; + +export interface RectLike { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +export interface MeasurableElement extends FlightElement { + getBoundingClientRect(): RectLike; + /** Layout width — unaffected by an in-flight `scale()`, unlike the rect. */ + readonly offsetWidth?: number; +} + +export interface FlipSnapshot { + cx: number; + cy: number; + width: number; + height: number; + /** `offsetWidth` when available: the scale factor must not be self-referential. */ + layoutWidth: number; + /** Current `--tilt` in degrees, so the rotation continues instead of snapping. */ + tilt: number; +} + +export type FlipResult = + /** A flight was launched. */ + | 'played' + /** No usable geometry either side — the element was placed at rest. */ + | 'placed' + /** The move was below the perceptual floor. */ + | 'skipped' + /** Nothing to do: no element, or no snapshot. */ + | 'ignored'; + +export interface FlipOptions extends FlyOptions { + /** Ignore moves shorter than this many px (default 1). */ + minDistPx?: number; +} + +function layoutWidthOf(el: MeasurableElement, rect: RectLike): number { + const w = el.offsetWidth; + return typeof w === 'number' && w > 0 ? w : rect.width; +} + +function readTilt(el: FlightElement): number { + const raw = el.style.getPropertyValue('--tilt'); + if (!raw) return 0; + const v = parseFloat(raw); + return Number.isFinite(v) ? v : 0; +} + +/** FIRST — snapshot where the element is now, before the DOM moves it. */ +export function measureFirst(el: MeasurableElement | null | undefined): FlipSnapshot | null { + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { + cx: rect.left + rect.width / 2, + cy: rect.top + rect.height / 2, + width: rect.width, + height: rect.height, + layoutWidth: layoutWidthOf(el, rect), + tilt: readTilt(el), + }; +} + +/** + * LAST + INVERT + PLAY. Call after the DOM has moved the element. + * + * Any flight already on the element is cancelled first (firing its `abort`), and + * the element is written to its rest pose so that `last` measures the + * destination rather than wherever the previous flight had reached. + */ +export function invertAndPlay( + el: MeasurableElement | null | undefined, + first: FlipSnapshot | null | undefined, + opts: FlipOptions = {}, +): FlipResult { + if (!el || !first) return 'ignored'; + + cancel(el); + writeRest(el); + + const settleNow = (): void => { + const land = opts.land; + if (land) land(el); + }; + + if (first.width === 0) { + settleNow(); + return 'placed'; + } + + const last = el.getBoundingClientRect(); + if (last.width === 0) { + settleNow(); + return 'placed'; + } + + const dx = first.cx - (last.left + last.width / 2); + const dy = first.cy - (last.top + last.height / 2); + const w1 = layoutWidthOf(el, last); + const scale = w1 > 0 ? first.layoutWidth / w1 : 1; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < (opts.minDistPx ?? 1) && Math.abs(scale - 1) < 0.02) { + settleNow(); + return 'skipped'; + } + + const started = fly(el, { + ...opts, + dx, + dy, + scale: opts.scale ?? scale, + tiltFrom: opts.tiltFrom ?? first.tilt, + }); + return started ? 'played' : 'ignored'; +} diff --git a/src/app/anim/index.ts b/src/app/anim/index.ts new file mode 100644 index 0000000..717b3a0 --- /dev/null +++ b/src/app/anim/index.ts @@ -0,0 +1,145 @@ +/** + * anim/ — the motion engine. One clock, one transform contract: + * + * translate(var(--fx), var(--fy)) rotate(var(--tilt)) scale(var(--fs)) + * + * Pure motion: nothing here imports a component, a store, or the shared game + * types, and nothing touches `window`/`document` at module scope. + */ + +export { + subscribe, + unsubscribe, + now, + start, + stop, + reset as resetClock, + frameCount, + subCount, + isRunning, + type ClockSubscriber, + type Unsubscribe, +} from './clock'; + +export { + BACK, + SCALE_BACK, + clamp01, + easeOutBack, + easeOutCubic, + smoothstep, + settle, + hash1, + hashKey, +} from './easing'; + +export { + MS_MIN, + MS_MAX, + MS_PER_PX, + MAX_EVENT_MS, + HITSTOP_MS, + HITSTOP_MIN_GAP_MS, + HITSTOP_FLOOR_MS, + CONTACT, + REDUCED_FADE_MS, + fly, + punch, + fade, + cancel, + finishAll, + resetFlights, + busyUntil, + liveCount, + isFlying, + hitstopCount, + flightDuration, + setReducedMotion, + isReducedMotion, + getRest, + setRest, + clearRest, + writeRest, + clearTransform, + type FlightElement, + type FlightStyle, + type FlyOptions, + type LandCallback, + type AbortCallback, + type RestPose, +} from './flight'; + +export { + measureFirst, + invertAndPlay, + type FlipOptions, + type FlipResult, + type FlipSnapshot, + type MeasurableElement, + type RectLike, +} from './flip'; + +/** + * §6's per-verb table, in the order the table lists it: deal, swap, strike, + * take-from, refuse, fall. All seven factories are here, not just the two the + * first wave happened to call — a barrel that exports half a table is a table + * whose other half reads as unfinished rather than as unused. + */ +export { + DEAL_ARC, + DEAL_SPIN, + DEAL_DUR, + DEAL_STAGGER_MS, + DEAL_RISE, + DEAL_SCALE, + EXCHANGE_ARC, + EXCHANGE_SPIN, + EXCHANGE_DUR, + EXCHANGE_OFFSET_MS, + STRIKE_DUR, + STRIKE_SPIN, + ASSASSINATE_REACH, + LUNGE_ENV, + COUP_ARC, + STEAL_ARC, + STEAL_SPIN, + STEAL_DUR, + STEAL_DELAY_MS, + STEAL_SPEED, + STEAL_LIFT, + SHOVE_FRACTION, + SHOVE_SPIN, + SHOVE_DUR, + BLOCK_CUT_ENV, + BLOCK_CUT_DUR, + CHALLENGE_ARRIVE_ARC, + CHALLENGE_ARRIVE_SPIN, + FALL_ARC, + FALL_SPIN, + FALL_DUR, + FLIGHT_TRANSFORM, + FLIGHT_TRANSFORM_STYLE, + dealIn, + exchangeSwap, + swapSide, + assassinate, + coupSlam, + steal, + challengeShove, + blockCut, + challengeArrive, + influenceTumble, + type SwapContext, + type TravelContext, + type LungeContext, + type ShoveContext, +} from './verbs'; + +export { + useFlight, + useIsomorphicLayoutEffect, + useReducedMotionSync, + ensureReducedMotionSync, + ensureHiddenTabSettle, + type FlightHandle, +} from './useFlight'; diff --git a/src/app/anim/useFlight.ts b/src/app/anim/useFlight.ts new file mode 100644 index 0000000..7afb8cf --- /dev/null +++ b/src/app/anim/useFlight.ts @@ -0,0 +1,193 @@ +'use client'; + +/** + * anim/useFlight.ts — the React seam. + * + * chudopoly's card nodes live for the whole game, so its engine can hold a node + * reference indefinitely. React's do not: a seat re-renders, a hand re-keys, a + * prompt unmounts mid-beat. The single job of this hook is that a flight whose + * node has gone away ABORTS rather than writing to a detached node — and that it + * aborts at detach time, not whenever the next frame happens to notice. + * + * It uses a callback ref with a cleanup (React 19), so the abort is on the same + * commit as the removal, and it keeps its own node reference rather than reading + * `ref.current` in an effect cleanup, where the ref has already been nulled. + */ + +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { + cancel as cancelFlight, + clearRest, + finishAll, + fly as flyEl, + punch as punchEl, + setRest as setRestPose, + setReducedMotion, + type FlyOptions, +} from './flight'; +import { + invertAndPlay, + measureFirst, + type FlipOptions, + type FlipResult, + type FlipSnapshot, +} from './flip'; + +export interface FlightHandle { + /** Attach to the element you want to animate. */ + ref: (node: T | null) => (() => void) | void; + /** The live node, or null when unmounted. */ + node: () => T | null; + /** FIRST — snapshot the current box before the DOM moves. */ + measure: () => FlipSnapshot | null; + /** LAST/INVERT/PLAY — call after the move has committed. */ + play: (first: FlipSnapshot | null, opts?: FlipOptions) => FlipResult; + /** Launch from an explicit delta. */ + fly: (opts: FlyOptions) => boolean; + /** There-and-back shove. */ + punch: (dx: number, dy: number, opts?: FlyOptions) => boolean; + /** Resting pose this element lands on — a fan offset, a discard tilt. */ + setRest: (x: number, y: number, rot?: number) => void; + /** Kill any live flight; fires its `abort`, never `land`. */ + cancel: () => void; +} + +export function useFlight(): FlightHandle { + const nodeRef = useRef(null); + + const ref = useCallback((node: T | null) => { + nodeRef.current = node; + if (!node) return; + return () => { + // Detach: abort before React drops the node, so nothing writes to it and + // the caller's abort callback fires while its context is still live. + cancelFlight(node); + clearRest(node); + if (nodeRef.current === node) nodeRef.current = null; + }; + }, []); + + const handle = useRef | null>(null); + if (handle.current === null) { + handle.current = { + ref, + node: () => nodeRef.current, + measure: () => measureFirst(nodeRef.current), + play: (first, opts) => invertAndPlay(nodeRef.current, first, opts), + fly: (opts) => flyEl(nodeRef.current, opts), + punch: (dx, dy, opts) => punchEl(nodeRef.current, dx, dy, opts), + setRest: (x, y, rot) => setRestPose(nodeRef.current, x, y, rot), + cancel: () => cancelFlight(nodeRef.current), + }; + } + return handle.current; +} + +/** + * FLIP needs the DOM measured after the commit that moved it and BEFORE the + * browser paints, so the invert is never a visible frame at the destination. + * That is `useLayoutEffect`, which React does not run on the server and warns + * about when a component is server-rendered — which every component in this + * Next app is. The usual dodge, stated once here rather than in each caller. + */ +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +let syncInstalled = false; + +/** + * Keep the engine's reduced-motion flag in step with the two things that can ask + * for it: the OS setting, and the app's own `html.reduce-motion` class (written + * by the settings store — which this module deliberately does not import; the + * class is the contract between them). + * + * IDEMPOTENT AND PERMANENT, so any component may call it without knowing + * whether it is the first. The flag is process-wide state on a module-level + * engine, so its listener has to outlive any one component's mount: a card that + * unmounts must not be able to take the whole app's reduced-motion handling + * with it. One `matchMedia` listener and one `MutationObserver` for the life of + * the page is the correct cost for that. + */ +export function ensureReducedMotionSync(): void { + if (syncInstalled) return; + if (typeof window === 'undefined' || typeof document === 'undefined') return; + syncInstalled = true; + const root = document.documentElement; + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + const apply = (): void => { + setReducedMotion(mq.matches || root.classList.contains('reduce-motion')); + }; + apply(); + mq.addEventListener('change', apply); + new MutationObserver(apply).observe(root, { + attributes: true, + attributeFilter: ['class'], + }); +} + +let hiddenSettleInstalled = false; +let hiddenSettleHandler: (() => void) | null = null; +let hiddenSettleDoc: Document | null = null; + +/** + * SETTLE EVERY FLIGHT WHEN THE TAB GOES AWAY. + * + * `anim/clock.ts` is a bare `requestAnimationFrame` loop, and Chrome does not + * run rAF in a hidden tab. So a card in the air when the player switches tabs + * simply stops: `land` never fires, and anything waiting on that callback waits + * as long as the player is gone. The challenge reveal hit this first and now + * carries its own ceiling — measured at 21 seconds of a stuck full-screen plate + * with the card parked at `--fx: -184px` — but the hazard is the CLOCK's, not + * the reveal's, and every other flight sits on the same loop. + * + * `finishAll()` is the right response rather than pausing. The player did not + * see the motion, so there is nothing to resume: snapping each card onto its + * rest pose and firing `land` leaves the table in exactly the state the flights + * were travelling towards, which is what they should find when they come back. + * Resuming instead would restart a card mid-air seconds after the event that + * launched it — motion with no cause attached. + * + * It also preserves flight.ts's central guarantee: every started flight + * resolves. `finishAll` lands rather than aborts, because those flights did + * arrive; they just arrived instantly and unobserved. + * + * Page-lifetime and idempotent, for the same reason as the reduced-motion sync: + * a card that unmounts must not take the app's visibility handling with it. + */ +export function ensureHiddenTabSettle(): void { + if (hiddenSettleInstalled) return; + if (typeof document === 'undefined') return; + hiddenSettleInstalled = true; + hiddenSettleDoc = document; + hiddenSettleHandler = (): void => { + if (hiddenSettleDoc?.visibilityState === 'hidden') finishAll(); + }; + document.addEventListener('visibilitychange', hiddenSettleHandler); +} + +/** + * Test hook: uninstall the visibility listener and re-arm the guard. Matches + * `__resetHaptics()` / `resetJitter()` elsewhere in the codebase — a + * page-lifetime installer is untestable across cases without one, because the + * second test would silently keep the first test's listener and document. + * Never call this from app code. + */ +export function __resetHiddenTabSettle(): void { + if (hiddenSettleDoc && hiddenSettleHandler) { + hiddenSettleDoc.removeEventListener('visibilitychange', hiddenSettleHandler); + } + hiddenSettleInstalled = false; + hiddenSettleHandler = null; + hiddenSettleDoc = null; +} + +/** + * Mount-time wrapper for the two page-lifetime installers. Safe anywhere, and + * cheap after the first call. + */ +export function useReducedMotionSync(): void { + useEffect(() => { + ensureReducedMotionSync(); + ensureHiddenTabSettle(); + }, []); +} diff --git a/src/app/anim/verbs.ts b/src/app/anim/verbs.ts new file mode 100644 index 0000000..51917d5 --- /dev/null +++ b/src/app/anim/verbs.ts @@ -0,0 +1,486 @@ +/** + * anim/verbs.ts — ART-DIRECTION §6's per-verb table, as parameters. + * + * §6: "Everything else is the same flight engine with different parameters … + * Verbs differ only in arc, spin, duration and delay — and they must differ, or + * a Steal feels like a Tax and the player learns nothing from watching the + * table." This module is where those four numbers live, so a call site reads as + * the verb it is performing rather than as four magic constants. + * + * PURITY. Same rule as the rest of `anim/`: `easing` and the `FlyOptions` type + * are the only imports. No component, no store, no audio bus, no game types, no + * `window` at module scope. A landing CUE is the caller's business — see the + * `land` callback on `FlyOptions` and the note in flight.ts's header. + * + * ── UNITS ───────────────────────────────────────────────────────────────── + * §6's Arc column reads "low, 14" / "18" / "22" / "mirrored ±34°" — the degree + * sign on the last row is a slip. Arc is flight.ts's `arc`: a PERPENDICULAR + * LIFT IN PIXELS. Spin is the only column in degrees, and §6 lists it + * separately for Exchange (±8°). 34 is read here as 34px, in line with the + * 14/18/22 above it. + */ + +import { hash1, hashKey } from './easing'; +import type { FlyOptions } from './flight'; + +/** + * THE TRANSFORM CONTRACT, as the string an element must actually carry. + * + * flight.ts writes the four custom properties; something has to compose them. + * In this app that cannot be a stylesheet rule on `.card-face`, because + * `.card-face` already composes a DIFFERENT transform out of the press + * variables (`--press-y`, `--press-s`, `--card-lift`) and one element cannot + * have two authors of one property. So the flight transform goes on the + * WRAPPER, inline, from here — one string, one place, and the fallbacks make an + * element that has never flown render exactly as it did before. + * + * Translate outermost is load-bearing; see flight.ts's header. + */ +export const FLIGHT_TRANSFORM = + 'translate(var(--fx, 0px), var(--fy, 0px)) rotate(var(--tilt, 0deg)) scale(var(--fs, 1))'; + +/** + * Ready to spread into a React `style` prop. Frozen and module-level so every + * card wrapper in a hand shares one object instead of allocating per render. + */ +export const FLIGHT_TRANSFORM_STYLE: Readonly<{ transform: string }> = + Object.freeze({ transform: FLIGHT_TRANSFORM }); + +/** ±1 from a stable key, so a screenshot run reproduces frame for frame. */ +function sideOf(key: number | string | undefined): number { + return hash1(hashKey(key) + 7) < 0.5 ? -1 : 1; +} + +/* ── DEAL / DRAW (§6 row 1) ─────────────────────────────────────────────── + "Comes off the deck pile, grows into the hand; easeOutBack on scale gives it + a settle." arc 14, spin ±3°, 260ms, 64ms stagger. + + RISE and SCALE are this app's reading of "off the deck pile". A card in a + seat does not know where the deck is rendered and must not go looking — a + component that queries a sibling's geometry is a layout dependency that + breaks the first time the table reflows. So the launch point is stated + relative to the slot: 34px above it at 0.86 scale, which is a card coming + down from somewhere smaller and further away. The horizontal bow is the arc, + and because the travel is straight down flight.ts picks its side from the + key — deterministic, not random. */ +export const DEAL_ARC = 14; +export const DEAL_SPIN = 3; +export const DEAL_DUR = 260; +export const DEAL_STAGGER_MS = 64; +export const DEAL_RISE = 34; +export const DEAL_SCALE = 0.86; + +/** + * A card arriving from the deck into a slot it already occupies in the DOM — + * an exchange result, a challenge replacement. There is no FLIP here because + * there is no previous position: the card did not move, it was *replaced*. + */ +export function dealIn(key: number | string, o: FlyOptions = {}): FlyOptions { + return { + key, + dx: 0, + dy: -DEAL_RISE, + scale: DEAL_SCALE, + arc: DEAL_ARC, + spin: sideOf(key) * DEAL_SPIN, + dur: DEAL_DUR, + ...o, + }; +} + +/* ── SWAP (§6 row 5) ────────────────────────────────────────────────────── + "Mirrored ±34 with a 60ms offset … Two cards passing each other, not one blur + crossing the felt." spin ±8°, 380ms. + + MIRRORING IS THE WHOLE POINT AND IT IS NOT FREE. flight.ts biases every arc + to the UPWARD normal on purpose, so a card always rises off the felt rather + than sliding through it — which means two cards trading places would both + bow the same way and overlap into exactly the blur §6 is ruling out. A + NEGATIVE arc is how you buy the other side: the card that travels left goes + over, the card that travels right goes under, and they pass. The spin sign + follows the arc sign so the two rotate opposite ways as well. */ +export const EXCHANGE_ARC = 34; +export const EXCHANGE_SPIN = 8; +export const EXCHANGE_DUR = 380; +export const EXCHANGE_OFFSET_MS = 60; + +export interface SwapContext { + /** The FLIP invert: where the card IS minus where it belongs. */ + dx: number; + dy: number; + /** + * Force which way this member passes, instead of deriving it from `dx`. + * + * Deriving is right for a hand reorder, where the members trade places and + * therefore travel in opposite directions by construction. It is WRONG the + * moment the two members share an endpoint instead of swapping ends: a + * challenge reveal sends the proven card from the middle of the table to the + * deck and pulls its replacement from the deck out to a seat, and when the + * deck sits between the two the legs travel the SAME way. Derived, both bow + * to the same side and overlap into exactly the blur §6's row rules out. + * Pass ±1 (`swapSide()` on the leader, negated) to mirror off the partner + * rather than off the geometry. + */ + side?: number; + /** The card the player actually touched. It leads; everything else offsets. */ + lead?: boolean; + key?: number | string; +} + +/** + * Which way a swap member passes. `dx > 0` means the card is to the RIGHT of + * its destination and is therefore travelling left, and that one goes over the + * top. + * + * Rows wrap, so a card can move purely vertically; `dy` breaks that tie rather + * than leaving the sign at 0 (an arc of 0 is a straight slide, which is the one + * shape this verb exists to avoid). + */ +export function swapSide(ctx: SwapContext): number { + if (ctx.side) return ctx.side < 0 ? -1 : 1; + return ctx.dx > 0 ? 1 : ctx.dx < 0 ? -1 : ctx.dy >= 0 ? 1 : -1; +} + +/** One member of a swap. */ +export function exchangeSwap(ctx: SwapContext, o: FlyOptions = {}): FlyOptions { + const side = swapSide(ctx); + return { + key: ctx.key, + arc: side * EXCHANGE_ARC, + spin: side * EXCHANGE_SPIN, + dur: EXCHANGE_DUR, + delay: ctx.lead ? 0 : EXCHANGE_OFFSET_MS, + ...o, + }; +} + +/* ── TRAVEL, THE SHARED CONTEXT ─────────────────────────────────────────── + Every verb below that uses `fly()` needs the same three things, and they + mean exactly what `SwapContext.dx/dy` above mean: the FLIP INVERT — where + the element IS minus where it BELONGS. flight.ts launches from + `rest + (dx, dy)` and lands on `rest`, so a positive `dx` is a card sitting + to the RIGHT of its destination and therefore travelling LEFT. + + `punch()` does NOT use this convention — its two arguments are the PEAK + displacement, i.e. the direction the card lunges. The three verbs that shove + (`assassinate`, `challengeShove`, `blockCut`) take contexts with different + field names for exactly that reason: a `dx` that means "start here" and a + `dx` that means "go there" must not be spellable the same way. */ +export interface TravelContext { + /** The FLIP invert: where the element is minus where it belongs. */ + dx: number; + dy: number; + /** Seeds the deterministic spin/arc side. A card or player id. */ + key?: number | string; +} + +/* ── STRIKE (§6 row 3) ──────────────────────────────────────────────────── + "Coup: 0 (straight, fast). Assassinate: lunges 34% of the way at the target + and stops." spin 5°, 300ms. "34% is as far as a card can travel before it + reads as a second flight rather than a threat. Coup arms hitstop; the + target's seat takes the shake." + + THE SPIN IS SHARED, THE PRIMITIVE IS NOT. Coup is a `fly()` — the card + actually crosses the table and lands on the target. Assassinate is a + `punch()` — there is no landing, because the knife does not arrive, it is + *shown*. One verb table row, two engine primitives, and the sign convention + differs between them (see TravelContext above). */ + +/** §6's Strike row. Both strikes, so a Coup and an Assassinate rhyme. */ +export const STRIKE_DUR = 300; +export const STRIKE_SPIN = 5; + +/** + * How far up the vector to the target the knife goes. + * + * §6 fixes this at 34% and gives the reason: past it the card reads as a second + * flight rather than a threat. Held exactly, because it is the one number in + * the table with an argument attached rather than a feel. + */ +export const ASSASSINATE_REACH = 0.34; + +/** + * Where a `punch()` peaks, as a fraction of its duration. + * + * flight.ts's default `env` is 0.62 and its header says the peak is "~35%". + * Computed rather than repeated: the envelope is sin(π·pᵉ), which peaks when + * pᵉ = 0.5, i.e. p = 0.5^(1/0.62) = 0.327. So the lunge is out in the first + * THIRD and spends the other two thirds recovering — which is the asymmetry + * that makes it read as a hit instead of a wobble. Restated here as a named + * constant so a verb that wants a different asymmetry (see `blockCut`) is + * visibly choosing one rather than forgetting the default exists. + */ +export const LUNGE_ENV = 0.62; + +/** The vector a lunge is a fraction OF. Deliberately not named `dx`/`dy`. */ +export interface LungeContext { + /** From the card's current position TO its target, in px. */ + toX: number; + toY: number; + key?: number | string; +} + +/** + * §6's Assassinate: 34% of the way at the target and stops. + * + * Feed the result to `punch()`, not `fly()`: + * + * const o = assassinate({ toX, toY }); + * punch(el, o.dx!, o.dy!, o); + * + * `dx`/`dy` carry the lunge because that is where `punch` reads its peak + * displacement from and because a caller should never have to compute 0.34 × + * anything itself. NO HITSTOP: §6 arms it on exactly three things and an + * assassination is not one of them — the knife can still be blocked by a + * Contessa, and freezing the table for a threat that may evaporate spends one + * of the two world-stopping moments on a maybe. + */ +export function assassinate(ctx: LungeContext, o: FlyOptions = {}): FlyOptions { + return { + key: ctx.key, + dx: ctx.toX * ASSASSINATE_REACH, + dy: ctx.toY * ASSASSINATE_REACH, + dur: STRIKE_DUR, + spin: sideOf(ctx.key) * STRIKE_SPIN, + env: LUNGE_ENV, + ...o, + }; +} + +/** + * §6's Coup: straight, fast, hitstop armed. + * + * `arc: 0` is the whole signature. Every other flight on this table bows, + * because paper thrown across a felt bows; a Coup is the one action nobody can + * challenge or block, and a card that travels in a dead straight line is the + * only way the motion can say "this is already decided". + * + * The 5° spin survives the straight line. Spin rides the sine envelope, which + * flight.ts computes independently of the arc, so `arc: 0` costs the bow and + * keeps the roll — and a card with no rotation at all reads as a UI element + * sliding rather than a printed thing being thrown. + * + * THE SHAKE IS NOT HERE. §6: "the target's seat takes the shake", and fx/ owns + * shake (the `coup_landed` row in fx/tuning.ts). A second shake authored from + * the motion layer would be two clocks displacing one element. + */ +export const COUP_ARC = 0; + +export function coupSlam(ctx: TravelContext, o: FlyOptions = {}): FlyOptions { + return { + key: ctx.key, + dx: ctx.dx, + dy: ctx.dy, + arc: COUP_ARC, + spin: sideOf(ctx.key) * STRIKE_SPIN, + dur: STRIKE_DUR, + hit: true, + ...o, + }; +} + +/* ── TAKE-FROM (§6 row 4) ───────────────────────────────────────────────── + "22, hero lift … ±6° … 340ms after a 120ms delay. The delay is the tell — a + theft you can see coming." */ + +export const STEAL_ARC = 22; +export const STEAL_SPIN = 6; +export const STEAL_DUR = 340; +export const STEAL_DELAY_MS = 120; + +/** + * `speed` MULTIPLIES the duration (flight.ts), so 1.12 makes the steal 12% + * SLOWER, not faster. That is the intent: weight, not haste. + * + * The arithmetic, because §6's "340ms" and this multiplier cannot both be the + * wall time. Wall time is clampMs(340 × 1.12) = 380.8ms, and the whole event — + * delay plus flight — is 120 + 380.8 = 500.8ms, inside flight.ts's 600ms + * MAX_EVENT_MS with 99ms to spare. 340 is the base the verb table names; the + * multiplier is the weight on top of it. Read literally as "the flight lasts + * 340ms" the table is wrong by 41ms, and that is the deviation. + */ +export const STEAL_SPEED = 1.12; + +/** + * §6's "hero lift", as `bump` — extra scale at mid-flight, exactly 0 at both + * ends so the landing is untouched. + * + * 0.06 is bounded from above by the deal: `DEAL_SCALE` is 0.86, a 14% growth + * that is supposed to read as "this card came from somewhere further away". A + * hero lift has to stay well under that or a steal starts reading as a second + * deal, so it takes under half of it. + */ +export const STEAL_LIFT = 0.06; + +/** + * §6's Steal. The delay is the verb: for 120ms nothing happens, and then the + * coins leave. It is the only action in the game the victim can watch coming. + */ +export function steal(ctx: TravelContext, o: FlyOptions = {}): FlyOptions { + return { + key: ctx.key, + dx: ctx.dx, + dy: ctx.dy, + arc: STEAL_ARC, + spin: sideOf(ctx.key) * STEAL_SPIN, + dur: STEAL_DUR, + speed: STEAL_SPEED, + delay: STEAL_DELAY_MS, + bump: STEAL_LIFT, + ...o, + }; +} + +/* ── REFUSE (§6 row 6) ──────────────────────────────────────────────────── + "the challenged card comes in fast; the loser's card is shoved back −0.22 … + −9° … 280ms. Block is the same shove cut dead, no tail." + + ONE ROW, TWO FUNCTIONS, AND THAT IS A DEVIATION WORTH STATING. The row + describes two elements doing two different things at once: a card arriving + (`fly`) and a different card recoiling (`punch`). No single FlyOptions + factory can be both, so the row is split — `challengeArrive` for the card + that comes in and `challengeShove` for the one that is pushed. They share + the row's 280ms so the two halves of the collision are one beat. + + −0.22 IS A FRACTION, NOT A PIXEL COUNT. §6 gives it unsigned by anything: at + 22px it would be a nudge a player cannot see across a 15rem seat, and at + 22% of the travel it would grow with the table. 22% of the card's OWN WIDTH + is the reading that is scale-free and still visible — a card shoved back by + a fifth of itself is unmistakably recoil and unmistakably not travel. On the + 1024px table's 4.25rem deck leaf that is 15px; on a hand card it is more. */ + +export const SHOVE_FRACTION = 0.22; +export const SHOVE_SPIN = -9; +export const SHOVE_DUR = 280; + +/** + * `blockCut`'s envelope. §6: "the same shove cut dead, no tail." + * + * The tail is the RECOVERY, so the knob is the envelope, not the displacement. + * With `LUNGE_ENV` (0.62) the peak is at p = 0.327 and 67% of the beat is the + * card drifting home — that drift is the tail. Inverting the skew moves the + * peak late: sin(π·p^1.6) peaks at p = 0.5^(1/1.6) = 0.649, so the shove takes + * 65% of the beat going out and snaps back in the remaining 35%. + */ +export const BLOCK_CUT_ENV = 1.6; + +/** + * And it is shorter. 220ms against the row's 280ms — "cut dead" is a shorter + * beat as well as a shorter recovery, and a block has nothing after it to wait + * for, where a challenge has a reveal coming. + */ +export const BLOCK_CUT_DUR = 220; + +/** How far the challenged card's arrival travels before it lands. §6: fast. */ +export const CHALLENGE_ARRIVE_ARC = 12; +export const CHALLENGE_ARRIVE_SPIN = 4; + +export interface ShoveContext { + /** The shoved card's own width in px — the displacement is a fraction of it. */ + width: number; + /** Which way it is pushed. Defaults to straight back, i.e. leftwards. */ + dirX?: number; + dirY?: number; + key?: number | string; +} + +function shove(ctx: ShoveContext, dur: number, env: number, o: FlyOptions): FlyOptions { + const dx = ctx.dirX ?? -1; + const dy = ctx.dirY ?? 0; + // Normalise, so a caller passing a raw seat-to-seat vector gets a shove of + // the stated size rather than one scaled by how far apart the seats are. + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const push = SHOVE_FRACTION * ctx.width; + return { + key: ctx.key, + dx: (dx / len) * push, + dy: (dy / len) * push, + dur, + spin: SHOVE_SPIN, + env, + ...o, + }; +} + +/** + * The loser's card, shoved back. Feed to `punch()` — `dx`/`dy` are the PEAK + * displacement, and the card ends exactly where it started. + * + * The spin is −9° unsigned by any key, and that is deliberate where deal and + * strike pick a side from a hash: a shove has a direction the game chose, so + * randomising which way it rolls would put a deterministic gesture on a + * coin-flip. + */ +export function challengeShove(ctx: ShoveContext, o: FlyOptions = {}): FlyOptions { + return shove(ctx, SHOVE_DUR, LUNGE_ENV, o); +} + +/** The same shove with the tail taken off it. §6's Block. */ +export function blockCut(ctx: ShoveContext, o: FlyOptions = {}): FlyOptions { + return shove(ctx, BLOCK_CUT_DUR, BLOCK_CUT_ENV, o); +} + +/** + * The other half of §6's Refuse row: the challenged card coming in fast, to be + * looked at. A `fly()`, and one of the exactly three things §6 arms hitstop + * on — "a revealed influence landing face-up, a Coup card landing, a challenge + * resolving". This is the third. + * + * The arc is 12, below Deal's 14: this is the flattest bow on the table + * because a card thrown down as evidence is thrown, not dealt. + */ +export function challengeArrive(ctx: TravelContext, o: FlyOptions = {}): FlyOptions { + return { + key: ctx.key, + dx: ctx.dx, + dy: ctx.dy, + arc: CHALLENGE_ARRIVE_ARC, + spin: sideOf(ctx.key) * CHALLENGE_ARRIVE_SPIN, + dur: SHOVE_DUR, + hit: true, + ...o, + }; +} + +/* ── FALL (§6 row 7) ────────────────────────────────────────────────────── + "22 … ±26° tumble … 420ms … Lands face-up in the discard and STAYS there." + + 420ms is exactly flight.ts's MS_MAX, so this verb is the slowest thing that + can happen on this table and no distance can make it slower. That is the + right shape for the one irreversible event in Coup (§6's first + world-stopping moment): every other flight speeds up or slows down with the + geometry; a card being lost always takes the same, longest beat. + + Armed. `hit` pulls HITSTOP_MS out of the duration at launch — 420 − 45 = + 375ms of travel, 45ms of frozen table, 420ms of wall clock. */ +export const FALL_ARC = 22; +export const FALL_SPIN = 26; +export const FALL_DUR = 420; + +export function influenceTumble(ctx: TravelContext, o: FlyOptions = {}): FlyOptions { + return { + key: ctx.key, + dx: ctx.dx, + dy: ctx.dy, + arc: FALL_ARC, + spin: sideOf(ctx.key) * FALL_SPIN, + dur: FALL_DUR, + hit: true, + ...o, + }; +} + +/* ── THE QUIET VERBS (§6 row 2) ─────────────────────────────────────────── + "Take (Income, Tax, Foreign Aid, Embezzle) … Deliberately plain. Coins + travel from the treasury to the seat; THE CARD DOES NOT MOVE." + + There is no `income()` and no `tax()` in this file and there must not be. + §6's budget is two world-stopping moments, and it only balances because the + twelve beats around them spend nothing. A card that hops when you take + Income is a card that has taught the player that hopping means nothing, + which is a tax on the Coup that lands three turns later. + + The Take row's 18/±4°/300ms describes the COIN, not the card, and coins are + fx/floaters.ts's `coins_changed` — a different layer with a different + budget. If a verb ever appears here for a Take, one of §6's two moments has + to give something up to pay for it. */ diff --git a/src/app/audio/SoundEngine.ts b/src/app/audio/SoundEngine.ts index 7de03e6..e011c9e 100644 --- a/src/app/audio/SoundEngine.ts +++ b/src/app/audio/SoundEngine.ts @@ -1,3 +1,19 @@ +/* ───────────────────────────────────────────────────────────────────────────── + * SoundEngine — the graph, the mix, and the voice bank. + * + * sfx voices ─┐ + * ├→ preMaster → compressor → softClip → master → destination + * music ──────┘ + * + * Nothing in this module constructs an AudioContext at import time. That is + * structural, not stylistic: this file is imported by React components long + * before the page has a user gesture, and a module that builds a context on + * import is exactly how an autoplay violation ships. The context appears on the + * first getGraph() call, which only happens from unlock() or play(). + * + * Everything is a silent no-op when the context is null or not running. + * ────────────────────────────────────────────────────────────────────────── */ + export type SoundId = | 'yourTurn' | 'actionDeclared' @@ -12,6 +28,7 @@ export type SoundId = | 'coinsGained' | 'coinsLost' | 'timerWarning' + | 'denied' | 'gameOverWin' | 'gameOverLose' | 'playerEliminated' @@ -20,19 +37,1205 @@ export type SoundId = | 'reaction' | 'chatMessage'; +/** Perspective for a cue. `mine` defaults to true so old call sites are unchanged. */ +export interface PlayOptions { + /** False when the event happened to somebody else — see `makeHead()`. */ + mine?: boolean; + /** Seeds which side of the stereo field an opponent's cue sits on. */ + playerId?: string; +} + +export interface SoundStats { + peakVoiceLoad: number; + voiceLoad: number; + droppedVoices: number; + /** Must read 0 after a game — see `take()`. */ + droppedPriority: number; + gatedVoices: number; +} + const MUSIC_URL = '/audio/velvet-court.mp3'; const MUSIC_GAIN = 0.18; -const MUSIC_DUCK_GAIN = 0.055; + +/** + * Mastered stingers, with the synth voices below as fallbacks when the fetch + * fails. These gains are PRE-TRIM: MIX_DB is applied to the whole voice at the + * head gain (the single choke point), and this number sets the clip's level + * RELATIVE TO its synth fallback. + * + * ── WHY THESE TWO NUMBERS ARE MEASURED, NOT CHOSEN ────────────────────────── + * A fallback at a different level from the clip it replaces is a bug nobody + * notices until the fetch fails and the endgame sting arrives 8dB off. At the + * old gains (0.61 / 0.52) the mastered clips rendered 2.07dB and 2.57dB QUIETER + * than their fallbacks. These gains were solved for from the render: the clip + * and the synth now land within 0.08dB of each other on 300ms loudness. + * + * gameOverWin clip −17.15 / synth −17.07 dBFS loud (Δ 0.08) + * gameOverLose clip −17.02 / synth −17.01 dBFS loud (Δ 0.01) + * + * Re-solve them whenever the tier-0 trims move — see docs/AUDIO-MIX.md. + */ const HERO_CLIPS: Partial> = { - gameOverWin: { url: '/audio/court-crowned.mp3', gain: 0.72 }, - gameOverLose: { url: '/audio/plot-unraveled.mp3', gain: 0.82 }, + gameOverWin: { url: '/audio/court-crowned.mp3', gain: 0.808 }, + gameOverLose: { url: '/audio/plot-unraveled.mp3', gain: 0.557 }, +}; + +/* ── pure DSP helpers ─────────────────────────────────────────────────────── */ + +/** AudioParam exponential ramps are undefined at zero; this is "silence". -100dB. */ +const EPS = 1e-5; + +function dbToGain(db: number): number { + return Math.pow(10, db / 20); +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** + * Soft-clip curve for the master safety stage. Identity below `knee`, tanh + * above it. + * + * The bound is the entire point: a WaveShaper clamps its INPUT to [-1,1] before + * the table lookup, so whatever the mix does the output cannot exceed + * curve[last] = knee + (1-knee)·tanh(1) = 0.7 + 0.3 × 0.76159 = 0.9285 at knee + * 0.7 — that is 20·log10(0.9285) = −0.64 dBFS. "No clipping" is therefore a + * property of the graph rather than an opinion about gain staging above it. + * + * `oversample` MUST stay 'none' at the call site: 2x/4x resampling filters ring, + * and ringing overshoots the table maximum, which is the only thing making the + * ceiling a bound. + */ +export function softClipCurve(n = 2048, knee = 0.7) { + const c = new Float32Array(n); + const span = 1 - knee; + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * 2 - 1; + const a = Math.abs(x); + const y = a <= knee ? a : knee + span * Math.tanh((a - knee) / span); + c[i] = x < 0 ? -y : y; + } + return c; +} + +/** Peak magnitude a `softClipCurve` can emit. 0.9285 at the default knee. */ +export function softClipCeiling(knee = 0.7): number { + return knee + (1 - knee) * Math.tanh(1); +} + +/** + * White noise, in place. Uniform rather than gaussian: for short bandpassed + * bursts the difference is inaudible and uniform costs one rng draw per sample. + */ +function fillWhite(data: Float32Array, rng: () => number): Float32Array { + for (let i = 0; i < data.length; i++) data[i] = rng() * 2 - 1; + return data; +} + +/** + * Pink noise (−3dB/octave), in place. Paul Kellet's economy filter: three + * one-poles summed. Pink is the right bed for paper and felt — white reads as + * "hiss", pink reads as "a surface", and every noise in Coup is card stock. + */ +function fillPink(data: Float32Array, rng: () => number): Float32Array { + let b0 = 0; + let b1 = 0; + let b2 = 0; + for (let i = 0; i < data.length; i++) { + const w = rng() * 2 - 1; + b0 = 0.99765 * b0 + w * 0.0990460; + b1 = 0.96300 * b1 + w * 0.2965164; + b2 = 0.57000 * b2 + w * 1.0526913; + data[i] = (b0 + b1 + b2 + w * 0.1848) * 0.28; + } + return data; +} + +type NoiseKind = 'white' | 'pink'; + +/* ── seeded randomness ────────────────────────────────────────────────────── */ + +function xmur3(str: string): () => number { + let h = 1779033703 ^ str.length; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353); + h = (h << 13) | (h >>> 19); + } + return () => { + h = Math.imul(h ^ (h >>> 16), 2246822507); + h = Math.imul(h ^ (h >>> 13), 3266489909); + h ^= h >>> 16; + return h >>> 0; + }; +} + +function sfc32(a: number, b: number, c: number, d: number): () => number { + return () => { + a |= 0; b |= 0; c |= 0; d |= 0; + const t = (((a + b) | 0) + d) | 0; + d = (d + 1) | 0; + a = b ^ (b >>> 9); + b = (c + (c << 3)) | 0; + c = (c << 21) | (c >>> 11); + c = (c + t) | 0; + return (t >>> 0) / 4294967296; + }; +} + +function makeRng(seed: string): () => number { + const h = xmur3(seed); + const rand = sfc32(h(), h(), h(), h()); + for (let i = 0; i < 15; i++) rand(); + return rand; +} + +/** Stable [0,1) for a string — used to pick which side an opponent sits on. */ +function hash01(key: string): number { + const h = xmur3(key)(); + return h / 4294967296; +} + +/** + * ── PITCH JITTER ─────────────────────────────────────────────────────────── + * A counter-based hash rather than a bare `Math.random()` call: the sequence is + * a pure function of `jitterCounter`, so a test can reset the counter and get + * the same run of detunes twice. `resetJitter()` exists for exactly that. + * + * ±2.5% on the fundamental. Coup plays `coinsGained` and `actionDeclared` dozens + * of times a game, and a byte-identical retrigger is what makes a cue read as a + * looped click rather than as a thing happening again. + */ +let jitterCounter = 0; + +export function resetJitter(seed = 0): void { + jitterCounter = seed >>> 0; +} + +function jitter(amount: number): number { + jitterCounter = (jitterCounter + 1) >>> 0; + let h = Math.imul(jitterCounter ^ 0x9e3779b9, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + const u = (h >>> 0) / 4294967296; + return 1 + (u * 2 - 1) * amount; +} + +const JITTER_AMOUNT = 0.025; + +/** The cues that repeat often enough to fatigue. One-shot stings stay exact. */ +const JITTERED: ReadonlySet = new Set([ + 'coinsGained', 'coinsLost', 'actionDeclared', 'cardShuffle', + 'reaction', 'chatMessage', 'block', 'denied', +]); + +/* ── THE MIX TRIM ───────────────────────────────────────────────────────────── + * + * Per-sound level in dB, applied in voiceGain() and NOWHERE else, on a per-sound + * GainNode sitting between the voice and sfxGain. One choke point, and + * renderSoundOffline() reads it through the same function, so the offline render + * and the live mix cannot disagree about what the player hears. + * + * It exists because the mix was INVERTED on the moments that matter. The rule + * the numbers encode: CONSEQUENCE TRACKS LOUDNESS. Every routine sound sits + * below every loss. + * + * tier 0 the game turned gameOverWin gameOverLose playerEliminated + * tier 1 you lost influenceLoss challengeRevealFail block + * tier 2 a play resolved coup challengeRevealSuccess assassinationAlert + * exchange + * tier 3 cards being handled cardShuffle actionDeclared coinsGained coinsLost + * tier 4 chrome timerWarning denied chatMessage reaction + * yourTurn blockOpportunity challengeWindow + * + * ── MEASURED 2026-08-10 ───────────────────────────────────────────────────── + * These trims are no longer estimates. Every figure below is an offline render + * of THIS graph — buildGraph() → startVoice() → OfflineAudioContext, 48kHz, + * Chrome 151, 1s compressor pre-roll — by tests/app/audio/harness.html. The + * numbers are committed as data in tests/app/audio/measurements.ts and gated by + * tests/app/audio/mix.test.ts. Regeneration: docs/AUDIO-MIX.md. + * + * "loud" below is the loudest 300ms sliding-window RMS. THAT is the ordering + * axis, not peak: peak ranks a 150ms noise swish (18dB crest) above a sustained + * sine (7dB crest) that is plainly louder to a listener, and ranking a mix by + * peak is the specific mistake this exercise exists to undo. Peak is gated + * separately — against the ceiling, and for the headline rule that no routine + * cue may STAB above a loss. + * + * id trim dB peak dBFS loud dBFS + * ─────────────────────── ────────── ─────────── ─────────── + * gameOverWin (clip) −2.8 −4.61 −17.15 ┐ tier 0 + * gameOverWin (synth) −2.8 −5.63 −17.07 │ + * gameOverLose (clip) −5.0 −7.23 −17.02 │ + * gameOverLose (synth) −5.0 −8.58 −17.01 │ + * playerEliminated −3.0 −10.46 −17.04 ┘ + * influenceLoss −1.9 −11.86 −18.97 ┐ tier 1 + * challengeRevealFail −2.1 −10.46 −18.99 │ + * block +5.6 −4.13 −21.13 ┘ + * exchange −4.3 −10.18 −23.05 ┐ tier 2 + * assassinationAlert −2.3 −12.75 −23.00 │ + * coup −12.9 −13.08 −22.97 │ + * challengeRevealSuccess −5.6 −13.80 −22.97 ┘ + * coinsGained −0.7 −14.20 −24.97 ┐ tier 3 + * coinsLost −0.4 −14.00 −26.42 │ + * actionDeclared −0.3 −13.93 −29.06 │ + * cardShuffle +1.2 −14.01 −32.58 ┘ + * timerWarning −9.4 −21.67 −34.56 ┐ tier 4 + * chatMessage −7.4 −22.85 −34.57 │ + * reaction −7.6 −21.13 −34.60 │ + * yourTurn −15.9 −25.89 −34.60 │ + * challengeWindow −13.7 −24.10 −34.61 │ + * blockOpportunity −11.2 −26.92 −34.64 │ + * denied −12.0 −21.56 −34.64 ┘ + * + * Tier boundaries on loudness, quietest-above minus loudest-below: + * 0/1 = 1.82dB 1/2 = 1.84dB 2/3 = 1.92dB 3/4 = 1.98dB + * The headline rule on peak: the quietest loss (influenceLoss, −11.86) stabs + * 2.07dB above the hottest routine cue (actionDeclared, −13.93). + * + * `denied` was added in the 2026-08-10 pass and the whole bank was re-rendered + * with it; every other figure above came back byte-identical. It was solved + * onto the FLOOR of tier 4 rather than into the middle of it, because tier 4 + * tops out at −34.56 and the 3/4 margin is only 1.98dB — a new chrome cue that + * landed above `timerWarning` would eat the boundary. −12.0 puts it level with + * `blockOpportunity`, so the margin is exactly what it was. No other trim moved. + * + * ── WHAT WAS ACTUALLY WRONG ───────────────────────────────────────────────── + * The previous trims were hand-derived from summed oscillator gains. Measured, + * three of the four tier boundaries were INVERTED — 0/1 by 5.64dB, 1/2 by + * 7.82dB, 3/4 by 8.84dB — and five cues were riding 3–5dB of limiting, which + * meant the compressor, not MIX_DB, was setting their level. The single worst + * offender was `yourTurn`, tier 4 chrome, sitting 8.8dB LOUDER than `cardShuffle` + * and only 1.3dB under a lost influence. + * + * ── STILL UNMEASURED ──────────────────────────────────────────────────────── + * • The music bed. MUSIC_GAIN is untouched and unmeasured, and the tier ladder + * is now 17.6dB tall, so tier 4 may sit under the bed. Cue-vs-bed is a + * separate measurement this pass did not make. + * • The mine/theirs treatment (−6dB + 5.2kHz lowpass + pan) is rendered only in + * the `mine` form. THEIRS_DB is a flat offset on the same head, so it moves + * the whole ladder together, but the lowpass's effect on loudness is not + * in the table. + * • Pitch jitter (±2.5%) is rendered at the nominal pitch. + * • The flam ladder (FLAM_DB) is rendered at run 0 only. The `denied x2 @90ms` + * pair renders BOTH taps at run 0, so it bounds the real double-tap (whose + * second tap is flammed to −2.5dB) rather than describing it. + * • Safari and Firefox. Their DynamicsCompressor makeup gain is not Chrome's, + * so the absolute dBFS figures are Chrome's. The ORDERING is a property of + * the trims and should survive; that has not been checked. + */ +const MIX_DB: Record = { + // tier 0 — the game turned + gameOverWin: -2.8, + gameOverLose: -5, + playerEliminated: -3, + // tier 1 — you lost + challengeRevealFail: -2.1, + influenceLoss: -1.9, + block: 5.6, + // tier 2 — a play resolved + assassinationAlert: -2.3, + coup: -12.9, + challengeRevealSuccess: -5.6, + exchange: -4.3, + // tier 3 — cards being handled. Down, all of it. + cardShuffle: 1.2, + actionDeclared: -0.3, + coinsGained: -0.7, + coinsLost: -0.4, + // tier 4 — chrome. A HUD countdown must never outrank a lost influence. + yourTurn: -15.9, + challengeWindow: -13.7, + blockOpportunity: -11.2, + timerWarning: -9.4, + denied: -12, + reaction: -7.6, + chatMessage: -7.4, +}; + +/** + * Which tier each cue belongs to. Data, not a comment, because the gate in + * tests/app/audio/mix.test.ts asserts the ordering tier by tier — and a tier + * that lived only in a comment could not be wrong in a way a test could catch. + * Moving a cue between tiers is a change to THIS table. + */ +export type MixTier = 0 | 1 | 2 | 3 | 4; + +const MIX_TIER: Record = { + gameOverWin: 0, + gameOverLose: 0, + playerEliminated: 0, + challengeRevealFail: 1, + influenceLoss: 1, + block: 1, + assassinationAlert: 2, + coup: 2, + challengeRevealSuccess: 2, + exchange: 2, + cardShuffle: 3, + actionDeclared: 3, + coinsGained: 3, + coinsLost: 3, + yourTurn: 4, + challengeWindow: 4, + blockOpportunity: 4, + timerWarning: 4, + denied: 4, + reaction: 4, + chatMessage: 4, }; +/* ── the voice budget ─────────────────────────────────────────────────────── */ + +interface VoiceSpec { + /** Scheduled tail in seconds — how long this voice occupies its budget slot. */ + tail: number; + /** A "voice" is one envelope, not one node: gameOverWin is 8, a coin tick is 1. */ + weight: number; + /** Priority voices bypass MAX_VOICES and duck the music. */ + priority: boolean; +} + +/** + * Hard ceiling on concurrent weighted voices. The worst beat a real Coup game + * produces is a challenge reveal resolving into an influence loss and an + * elimination while the deck shuffles — 4 + 4 + 6 + 2 = 16 weighted units. + * 32 is double that. + */ +const MAX_VOICES = 32; + +/** + * Priority voices bypass MAX_VOICES because a win sting dropped for budget + * reasons is a bug the player cannot un-hear. They do not bypass THIS, which is + * only here so a pathological loop cannot build the graph without limit. + * + * The flag covers tier 0–2 — every once-per-event sting — rather than tier 0 + * alone. A lost influence dropped for budget is the same bug at a smaller + * scale, and tier 3–4 (the coin ticks, the card handling, the chrome) is the + * only layer that can actually produce enough voices to need governing. So the + * 32-unit cap is, in practice, a cap on the routine layer, and 32 units of + * routine noise can never be the reason a consequence goes unheard. + */ +const MAX_VOICES_PRIORITY = 64; + +const VOICE: Record = { + gameOverWin: { tail: 1.30, weight: 8, priority: true }, + gameOverLose: { tail: 1.85, weight: 8, priority: true }, + playerEliminated: { tail: 0.45, weight: 6, priority: true }, + influenceLoss: { tail: 0.40, weight: 4, priority: true }, + challengeRevealFail: { tail: 0.78, weight: 4, priority: true }, + challengeRevealSuccess: { tail: 0.70, weight: 4, priority: true }, + coup: { tail: 0.67, weight: 4, priority: true }, + block: { tail: 0.20, weight: 3, priority: true }, + assassinationAlert: { tail: 0.45, weight: 3, priority: true }, + exchange: { tail: 0.30, weight: 2, priority: true }, + cardShuffle: { tail: 0.20, weight: 2, priority: false }, + challengeWindow: { tail: 0.35, weight: 2, priority: false }, + yourTurn: { tail: 0.30, weight: 2, priority: false }, + actionDeclared: { tail: 0.13, weight: 1, priority: false }, + coinsGained: { tail: 0.20, weight: 1, priority: false }, + coinsLost: { tail: 0.20, weight: 1, priority: false }, + blockOpportunity: { tail: 0.27, weight: 1, priority: false }, + timerWarning: { tail: 0.11, weight: 1, priority: false }, + denied: { tail: 0.12, weight: 1, priority: false }, + reaction: { tail: 0.13, weight: 1, priority: false }, + chatMessage: { tail: 0.17, weight: 1, priority: false }, +}; + +/** + * Which cues step the music back. Tier 0 and tier 1, plus `coup` — the one tier + * 2 event that is always a turn's whole point. + * + * The music yields to MEANING, not to activity. `challengeWindow` and the coin + * ticks used to duck and no longer do: a cue that fires every few seconds + * ducking the bed every few seconds is not sidechaining, it is a pumping bed. + */ +const DUCKS: ReadonlySet = new Set([ + 'gameOverWin', 'gameOverLose', 'playerEliminated', + 'influenceLoss', 'challengeRevealFail', 'block', + 'coup', +]); + +/** + * Never more than one instance of the same sound per 80ms. Two identical voices + * 8ms apart are one voice to a listener and 2x the amplitude to the mix. + * + * Priority stings get a much shorter floor: a challenge reveal can resolve into + * an influence loss and an elimination inside one state broadcast, and dropping + * the second of those is the same class of bug as dropping it for budget. + */ +const RATE_DEFAULT = 0.08; +const RATE_PRIORITY = 0.03; + +/** + * Retrigger attenuation on the tactile layer, in dB, by how many times this + * sound has already fired inside FLAM_WINDOW. Attenuation, not deletion: a + * two-card exchange must read as two cards, not as one card at 2x the + * amplitude, which is what an unattenuated stack sounds like. Resets after + * FLAM_WINDOW of silence. + */ +const FLAM_DB = [0, -2.5, -4.5, -6]; +const FLAM_WINDOW = 0.19; +const FLAM: ReadonlySet = new Set([ + 'cardShuffle', 'actionDeclared', 'coinsGained', 'coinsLost', + 'reaction', 'chatMessage', 'block', 'exchange', 'denied', +]); + +/** Other players' cues: −6dB, off-centre, detuned, and lowpassed. See makeHead. */ +const THEIRS_DB = -6; +const THEIRS_PAN = 0.34; +const THEIRS_DETUNE = 0.994; // ≈ −10 cents +const THEIRS_LP_HZ = 5200; + +/* ── the graph ────────────────────────────────────────────────────────────── */ + +interface Graph { + /** + * `BaseAudioContext`, not `AudioContext`, so the OFFLINE render in + * `renderSoundOffline()` can be handed the same builder. Everything the graph + * itself needs (`createGain`, `currentTime`, `state`, `decodeAudioData`) is on + * the base type; the two AudioContext-only calls the engine makes — `resume()` + * and nothing else — go through `SoundEngine.liveCtx`. + */ + ctx: BaseAudioContext; + master: GainNode; + preMaster: GainNode; + sfxGain: GainNode; + musicGain: GainNode; + musicDuck: GainNode; + noise: Record; + rng: () => number; +} + +/** Per-voice options. `gain` already carries the mix trim, theirs-trim and flam. */ +interface VoiceOptions { + mine: boolean; + gain: number; + pan: number; + /** Frequency multiplier: opponent detune folded together with pitch jitter. */ + pitch: number; +} + +/** + * Noise sample data, cached per (kind, length, sampleRate). Filling two 1.2s + * buffers is ~115k rng draws; without the cache `noiseBurst` did that on EVERY + * call, which is both expensive and — because every call got the same + * deterministic-sounding transient shape at offset zero — the reason repeated + * cues read as one click looped. + */ +const noiseCache = new Map(); + +function noiseBuffer(ctx: BaseAudioContext, kind: NoiseKind, seconds: number): AudioBuffer { + const n = Math.max(1, Math.floor(seconds * ctx.sampleRate)); + const key = `${kind}:${n}:${ctx.sampleRate}`; + let data = noiseCache.get(key); + if (!data) { + const fresh = new Float32Array(n); + data = kind === 'pink' ? fillPink(fresh, makeRng(key)) : fillWhite(fresh, makeRng(key)); + noiseCache.set(key, data); + } + const buf = ctx.createBuffer(1, n, ctx.sampleRate); + buf.getChannelData(0).set(data); + return buf; +} + +/** + * ── THE ONLY PLACE THE MINE/THEIRS TREATMENT LIVES ───────────────────────── + * + * mine → unity gain, dead centre, unfiltered. + * theirs → −6dB (folded into o.gain), pushed to ±0.34 of pan, and a 5.2kHz + * lowpass. The detune (o.pitch) is applied by each voice to its own + * frequencies via f(). + * + * The LOWPASS is the part that matters. −6dB alone makes an opponent's cue + * merely quieter, and a quiet copy of your own sound still competes for the + * same place in the mix. Rolling off above 5.2kHz removes the transient edge + * that pulls a sound forward, so it sits BEHIND yours instead — the same reason + * distance sounds dull in a real room. + * + * The filter and panner are only allocated for other players' cues, so the + * common case costs one GainNode. + */ +/** + * The whole chain, from one `BaseAudioContext`. + * + * sfx voices ─┐ + * ├→ preMaster → compressor → softClip → master → destination + * music ──────┘ + * + * ── ONE GRAPH IMPLEMENTATION ──────────────────────────────────────────────── + * This function is the only place the master chain is built. `getGraph()` calls + * it with a live `AudioContext`; `renderSoundOffline()` calls it with an + * `OfflineAudioContext`. There is no second chain for the test to measure, so a + * measurement can never describe a mix the player does not hear. + * + * Music routes into the SAME compressor and soft clip as the effects: music that + * could push the mix past the WaveShaper's table maximum would turn the ceiling + * back into a mixing opinion instead of a property of the graph. + */ +function buildGraph(ctx: BaseAudioContext, sfxMuted: boolean): Graph { + const master = ctx.createGain(); + master.gain.value = 1; + master.connect(ctx.destination); + + const softClip = ctx.createWaveShaper(); + softClip.curve = softClipCurve(2048, 0.7); + // MUST stay 'none': oversampling filters ring, and ringing overshoots the + // table maximum, which is the only thing making the ceiling a bound. + softClip.oversample = 'none'; + softClip.connect(master); + + // −14dB / 12:1 with a 4ms attack: fast enough to catch an elimination landing + // under a challenge reveal, slow enough not to eat the 2ms card transients + // that make a snap sound like paper. + const comp = ctx.createDynamicsCompressor(); + comp.threshold.value = -14; + comp.knee.value = 6; + comp.ratio.value = 12; + comp.attack.value = 0.004; + comp.release.value = 0.16; + comp.connect(softClip); + + const preMaster = ctx.createGain(); + preMaster.gain.value = 1; + preMaster.connect(comp); + + const sfxGain = ctx.createGain(); + sfxGain.gain.value = sfxMuted ? 0 : 1; + sfxGain.connect(preMaster); + + // Two gains for the music on purpose: musicGain carries the level and the + // fades, musicDuck carries the sidechain. One node doing both means a duck + // that lands mid-fade-in cancels the fade. + const musicDuck = ctx.createGain(); + musicDuck.gain.value = 1; + musicDuck.connect(preMaster); + + const musicGain = ctx.createGain(); + musicGain.gain.value = 0; + musicGain.connect(musicDuck); + + return { + ctx, + master, + preMaster, + sfxGain, + musicGain, + musicDuck, + noise: { + white: noiseBuffer(ctx, 'white', 1.2), + pink: noiseBuffer(ctx, 'pink', 1.2), + }, + rng: makeRng('coup-audio-voices'), + }; +} + +function makeHead(g: Graph, o: VoiceOptions): GainNode { + const h = g.ctx.createGain(); + h.gain.value = o.gain; + if (o.mine) { + h.connect(g.sfxGain); + return h; + } + const lp = g.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = THEIRS_LP_HZ; + lp.Q.value = 0.7; + const p = g.ctx.createStereoPanner(); + p.pan.value = clamp(o.pan, -1, 1); + h.connect(lp); + lp.connect(p); + p.connect(g.sfxGain); + return h; +} + +/* ── envelope + voice primitives ──────────────────────────────────────────── */ + +/** A pitched frequency with the opponent detune and jitter applied. */ +function f(o: VoiceOptions, hz: number): number { + return hz * o.pitch; +} + +/** Percussive AD envelope. Always ends at a hard 0 so the node can sleep. */ +function perc(param: AudioParam, t0: number, peak: number, attack: number, decay: number): void { + const p = Math.max(peak, EPS * 2); + param.setValueAtTime(EPS, t0); + param.exponentialRampToValueAtTime(p, t0 + attack); + param.exponentialRampToValueAtTime(EPS, t0 + attack + decay); + param.setValueAtTime(0, t0 + attack + decay); +} + +/** Linear attack / exponential release — softer than `perc`, for held notes. */ +function swell( + param: AudioParam, t0: number, peak: number, attack: number, hold: number, release: number, +): void { + const p = Math.max(peak, EPS * 2); + param.setValueAtTime(EPS, t0); + param.linearRampToValueAtTime(p, t0 + attack); + param.setValueAtTime(p, t0 + attack + hold); + param.exponentialRampToValueAtTime(EPS, t0 + attack + hold + release); + param.setValueAtTime(0, t0 + attack + hold + release); +} + +/** Exponential glide between two positive values. */ +function glide(param: AudioParam, t0: number, from: number, to: number, dur: number): void { + param.setValueAtTime(Math.max(from, EPS), t0); + param.exponentialRampToValueAtTime(Math.max(to, EPS), t0 + dur); +} + +function osc( + g: Graph, + destination: AudioNode, + t0: number, + type: OscillatorType, + freq: number, + gain: number, + start: number, + stop: number, + freqEnd?: number, +): void { + const o = g.ctx.createOscillator(); + const gn = g.ctx.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t0 + start); + if (freqEnd !== undefined) { + o.frequency.linearRampToValueAtTime(freqEnd, t0 + stop); + } + gn.gain.setValueAtTime(gain, t0 + start); + gn.gain.linearRampToValueAtTime(0, t0 + stop); + o.connect(gn).connect(destination); + o.start(t0 + start); + o.stop(t0 + stop + 0.05); +} + +/** + * A slice of the SHARED noise buffer, taken at a seeded random offset. The + * offset is the whole point: a fixed offset means every burst has the same + * sample-level transient, and five card sounds in a row become one click + * repeated five times. + */ +function noiseSource( + g: Graph, kind: NoiseKind, t0: number, dur: number, rate = 1, +): AudioBufferSourceNode { + const src = g.ctx.createBufferSource(); + const buf = g.noise[kind]; + src.buffer = buf; + src.playbackRate.value = rate; + const maxOffset = Math.max(0, buf.duration - dur * rate - 0.01); + src.start(t0, g.rng() * maxOffset, dur * rate + 0.01); + src.stop(t0 + dur + 0.02); + return src; +} + +function noiseBurst( + g: Graph, + destination: AudioNode, + t0: number, + gain: number, + start: number, + duration: number, + frequency = 3000, + kind: NoiseKind = 'white', +): void { + const src = noiseSource(g, kind, t0 + start, duration); + const bp = g.ctx.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.value = frequency; + bp.Q.value = 0.8; + const gn = g.ctx.createGain(); + gn.gain.setValueAtTime(gain, t0 + start); + gn.gain.linearRampToValueAtTime(0, t0 + start + duration); + src.connect(bp).connect(gn).connect(destination); +} + +/** A body thump: a sine that falls. Timpani, table knocks, dull impacts. */ +function thump( + g: Graph, destination: AudioNode, t0: number, + from: number, to: number, amp: number, dur: number, +): void { + const gn = g.ctx.createGain(); + gn.gain.value = 0; + const o = g.ctx.createOscillator(); + o.type = 'sine'; + o.frequency.setValueAtTime(from, t0); + glide(o.frequency, t0, from, to, dur * 0.8); + o.connect(gn).connect(destination); + perc(gn.gain, t0, amp, 0.004, dur); + o.start(t0); + o.stop(t0 + dur + 0.05); +} + +type SoundDefinition = (g: Graph, out: AudioNode, t0: number, o: VoiceOptions) => void; + +/** + * The head gain a voice carries: the mix trim, the theirs trim and the flam + * attenuation, multiplied. The ONLY place MIX_DB is read. + * + * Pulled out of `play()` so `renderSoundOffline()` can apply the identical trim + * without a second copy of the arithmetic. A measurement that recomputed the + * trim its own way would be measuring its own opinion of the mix. + */ +function voiceGain(id: SoundId, mine: boolean, flamGain: number): number { + return dbToGain(MIX_DB[id]) * (mine ? 1 : dbToGain(THEIRS_DB)) * flamGain; +} + +/* ── the bank ─────────────────────────────────────────────────────────────── */ + +const sounds: Record = { + yourTurn(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 523), 0.15, 0, 0.12); + osc(g, out, t0, 'sine', f(o, 698), 0.15, 0.13, 0.25); + }, + + actionDeclared(g, out, t0, o) { + osc(g, out, t0, 'triangle', f(o, 900), 0.1, 0, 0.08); + }, + + // Layered low impact with a brief card-snap transient. + coup(g, out, t0, o) { + noiseBurst(g, out, t0, 0.16, 0, 0.09, 1100); + osc(g, out, t0, 'sine', f(o, 130), 0.26, 0, 0.42, f(o, 48)); + osc(g, out, t0, 'triangle', f(o, 72), 0.18, 0.03, 0.62, f(o, 38)); + osc(g, out, t0, 'sine', f(o, 680), 0.07, 0.02, 0.16, f(o, 310)); + }, + + // A crisp challenge marker: card snap, rising accusation, low answer. + challengeWindow(g, out, t0, o) { + noiseBurst(g, out, t0, 0.08, 0, 0.045, 2200); + osc(g, out, t0, 'triangle', f(o, 330), 0.11, 0, 0.18, f(o, 660)); + osc(g, out, t0, 'sine', f(o, 165), 0.09, 0.08, 0.3, f(o, 110)); + }, + + blockOpportunity(g, out, t0, o) { + osc(g, out, t0, 'square', f(o, 600), 0.08, 0, 0.1); + osc(g, out, t0, 'square', f(o, 800), 0.08, 0.12, 0.22); + }, + + assassinationAlert(g, out, t0, o) { + osc(g, out, t0, 'sawtooth', f(o, 880), 0.15, 0, 0.1); + osc(g, out, t0, 'sawtooth', f(o, 660), 0.15, 0.12, 0.22); + osc(g, out, t0, 'sawtooth', f(o, 440), 0.15, 0.24, 0.4); + }, + + block(g, out, t0, o) { + osc(g, out, t0, 'triangle', f(o, 1200), 0.12, 0, 0.05); + osc(g, out, t0, 'triangle', f(o, 2400), 0.08, 0, 0.15); + }, + + influenceLoss(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 300), 0.15, 0, 0.35, f(o, 150)); + }, + + challengeRevealSuccess(g, out, t0, o) { + noiseBurst(g, out, t0, 0.065, 0, 0.04, 2600); + osc(g, out, t0, 'sine', f(o, 392), 0.11, 0, 0.18); + osc(g, out, t0, 'triangle', f(o, 523), 0.11, 0.11, 0.31); + osc(g, out, t0, 'sine', f(o, 784), 0.13, 0.23, 0.55); + osc(g, out, t0, 'sine', f(o, 1568), 0.045, 0.25, 0.65); + }, + + challengeRevealFail(g, out, t0, o) { + noiseBurst(g, out, t0, 0.08, 0, 0.06, 900); + osc(g, out, t0, 'sawtooth', f(o, 360), 0.1, 0, 0.24, f(o, 210)); + osc(g, out, t0, 'triangle', f(o, 180), 0.12, 0.13, 0.48, f(o, 82)); + osc(g, out, t0, 'sine', f(o, 92), 0.12, 0.28, 0.72, f(o, 52)); + }, + + coinsGained(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 1200), 0.1, 0, 0.15); + }, + + coinsLost(g, out, t0, o) { + osc(g, out, t0, 'triangle', f(o, 600), 0.1, 0, 0.15); + }, + + timerWarning(g, out, t0, o) { + osc(g, out, t0, 'square', f(o, 880), 0.12, 0, 0.06); + }, + + /** + * DENIED — the move was not legal. NOT a loss. That distinction is the whole + * brief: the cue it replaces (`timerWarning`, standing in) had the right + * weight and the wrong shape, and the two cues it must never be mistaken for + * are the two most consequential sounds a player hears about their own cards. + * + * influenceLoss one bare SINE, 300→150Hz portamento, 346ms + * challengeRevealFail noise + saw 360→210 + tri 180→82 + sine 92→52, 715ms + * denied two SQUARES through a closing lowpass, 320 then 220, + * discrete, 88ms + * + * Four separations, each of them measured — see MEASURED_CONTRAST in + * tests/app/audio/measurements.ts, which is octave-band energy normalised to + * each cue's own total, so it describes TIMBRE independently of the trim: + * + * 1. OVER FAST. 88.1ms of active audio against 346.5 and 714.8 — 3.9x and + * 8.1x shorter — and every envelope lands on a literal 0. There is no + * ring-out sitting in the beat after the tap. A refusal that lingers + * reads as damage already done. + * 2. DISCRETE, NOT GLIDING. Both losses fall by SLIDING, and a pitch that + * sags is the sound of something giving way. This falls in two hard + * steps with silence between them: 320Hz for 38ms, a 7ms gap, then + * 220Hz. A step is a refusal; a slide is a collapse. + * 3. BUZZ, NOT TONE. `influenceLoss` is a bare sine: 100% of its energy in + * one octave band, the next band 32.1dB down. `denied` is a square + * behind a filter and spreads across three — 250 / 500 / 1k Hz at + * −0.97 / −8.79 / −14.16dB. A filtered square and a pure falling tone + * are not the same object even at the same pitch. + * 4. MID, NOT BASS. `challengeRevealFail` puts essentially all of itself + * under 160Hz (−0.30dB of its own total; centroid 105Hz). `denied` puts + * 1.2% there (−19.04dB; centroid 320Hz) — 18.7dB less chest, so no + * dread. And against the `timerWarning` it replaces, the closing + * 1400→760Hz lowpass drops the centroid from 1561Hz to 320Hz: a muted + * buzzer behind a door rather than an alarm in the room. + * + * Tier 4 alongside `timerWarning`: a refusal is chrome, and must never + * outrank a lost influence however distinctive it is. Trim −12.0 puts it at + * −34.64 dBFS loud, level with `blockOpportunity` at the bottom of tier 4. + */ + denied(g, out, t0, o) { + const lp = g.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 1400; + lp.Q.value = 0.5; + lp.connect(out); + // Closing across the pair — the second blip is duller as well as lower. + lp.frequency.setValueAtTime(f(o, 1400), t0); + lp.frequency.exponentialRampToValueAtTime(f(o, 760), t0 + 0.09); + osc(g, lp, t0, 'square', f(o, 320), 0.13, 0, 0.038); + osc(g, lp, t0, 'square', f(o, 220), 0.14, 0.045, 0.088); + }, + + /** + * WIN — a restrained brass fanfare. G3 C4 E4 G4 on the harmonic series, so it + * reads as a bugle call rather than a chord progression. + * + * Brass without samples is one trick: a LOWPASS TRACKING THE ENVELOPE over a + * small sawtooth stack. The cutoff opens 500 → 3000Hz in 60ms on each attack + * and closes back to 900Hz over the note, which is what a blown instrument's + * spectrum actually does; a static filter over the same stack is an organ. + * + * Notes 90ms apart — urgent. The phrase resolves at 270ms, and everything + * after that (the fifth, the last note's tail) is deliberate ring-out. + * + * Fallback only: HERO_CLIPS.gameOverWin is the mastered clip and plays when + * the fetch succeeds. + */ + gameOverWin(g, out, t0, o) { + const notes: [number, number, number][] = [ + [196.00, 0.00, 0.22], + [261.63, 0.09, 0.22], + [329.63, 0.18, 0.22], + [392.00, 0.27, 0.80], + ]; + for (const [hz, at, dur] of notes) { + const t = t0 + at; + const lp = g.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 500; + lp.Q.value = 1.1; + const env = g.ctx.createGain(); + env.gain.value = 0; + lp.connect(env).connect(out); + lp.frequency.setValueAtTime(500, t); + lp.frequency.exponentialRampToValueAtTime(3000, t + 0.06); + lp.frequency.exponentialRampToValueAtTime(900, t + dur); + // Three sawtooths: unison, a 4-cent-sharp double for width, one octave up. + for (const [mult, amp, det] of [[1, 0.34, 1], [1, 0.24, 1.004], [2, 0.10, 1]]) { + const vg = g.ctx.createGain(); + vg.gain.value = amp; + const ov = g.ctx.createOscillator(); + ov.type = 'sawtooth'; + ov.frequency.value = f(o, hz) * mult * det; + ov.connect(vg).connect(lp); + ov.start(t); + ov.stop(t + dur + 0.05); + } + swell(env.gain, t, 0.30, 0.02, dur * 0.45, dur * 0.55); + } + // A fifth over the last note: the only harmony in the piece, and the thing + // still ringing when the game-over overlay opens. + const fg = g.ctx.createGain(); + fg.gain.value = 0; + const fv = g.ctx.createOscillator(); + fv.type = 'triangle'; + fv.frequency.value = f(o, 587.33); + fv.connect(fg).connect(out); + fv.start(t0 + 0.27); + fv.stop(t0 + 1.10); + swell(fg.gain, t0 + 0.27, 0.10, 0.06, 0.32, 0.42); + + thump(g, out, t0, f(o, 110), f(o, 55), 0.20, 0.24); + thump(g, out, t0 + 0.27, f(o, 110), f(o, 55), 0.18, 0.30); + }, + + /** + * LOSE — gameOverWin inverted at every joint, so the two can never be + * confused by timbre alone rather than merely by level: + * + * win lose + * ───────────────────────────── ──────────────────────────────────────── + * G3 C4 E4 G4, rising major G4 Eb4 C4 G3, falling MINOR + * sawtooth, bright square through a 620Hz lowpass, muted + * filter OPENS on each attack filter CLOSES through each note (1500→320) + * notes 90ms apart, urgent notes 150 / 170 / 210ms apart, slowing + * a fifth ringing over the end the last note SAGS a semitone flat + * + * THE SAG is the thing that reads as loss: ×0.944 (one semitone) over the last + * 70% of the final note's length — a held pitch that will not stay up. Two + * squares detuned 0.35% beat against each other at ~1.4Hz through it, so the + * tail wavers instead of ringing. + * + * Fallback only, behind HERO_CLIPS.gameOverLose. + */ + gameOverLose(g, out, t0, o) { + const notes: [number, number, number][] = [ + [392.00, 0.00, 0.30], + [311.13, 0.15, 0.30], + [261.63, 0.32, 0.34], + [196.00, 0.53, 1.05], + ]; + for (let i = 0; i < notes.length; i++) { + const [hz, at, dur] = notes[i]; + const t = t0 + at; + const last = i === notes.length - 1; + const lp = g.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 620; + lp.Q.value = 1.4; + const env = g.ctx.createGain(); + env.gain.value = 0; + lp.connect(env).connect(out); + // Closing, not opening: every note is duller at its end than at its start. + lp.frequency.setValueAtTime(1500, t); + lp.frequency.exponentialRampToValueAtTime(320, t + dur * 0.8); + for (const [amp, det] of [[0.28, 1], [0.20, 1.0035]]) { + const vg = g.ctx.createGain(); + vg.gain.value = amp; + const ov = g.ctx.createOscillator(); + ov.type = 'square'; + const base = f(o, hz) * det; + ov.frequency.setValueAtTime(base, t); + if (last) glide(ov.frequency, t + dur * 0.3, base, base * 0.944, dur * 0.7); + ov.connect(vg).connect(lp); + ov.start(t); + ov.stop(t + dur + 0.05); + } + swell(env.gain, t, 0.28, 0.03, dur * 0.35, dur * 0.62); + } + // One dead thud under the first note — a timpani with the head damped. + thump(g, out, t0, f(o, 98), f(o, 46), 0.26, 0.34); + // …and a long breath of air where the win's ringing fifth would have been. + const ag = g.ctx.createGain(); + ag.gain.value = 0; + const alp = g.ctx.createBiquadFilter(); + alp.type = 'lowpass'; + alp.frequency.value = 700; + alp.Q.value = 0.6; + const an = noiseSource(g, 'pink', t0 + 0.5, 1.3, 0.8); + an.connect(alp).connect(ag).connect(out); + glide(alp.frequency, t0 + 0.5, 700, 220, 1.1); + swell(ag.gain, t0 + 0.5, 0.18, 0.18, 0.2, 0.9); + }, + + playerEliminated(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 200), 0.2, 0, 0.4, f(o, 80)); + }, + + exchange(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 500), 0.08, 0, 0.25); + osc(g, out, t0, 'sine', f(o, 507), 0.08, 0, 0.25); + osc(g, out, t0, 'sine', f(o, 493), 0.08, 0, 0.25); + }, + + // Pink, not white: this is card stock crossing felt, and white reads as hiss. + cardShuffle(g, out, t0) { + noiseBurst(g, out, t0, 0.12, 0, 0.15, 3000, 'pink'); + }, + + reaction(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 800), 0.1, 0, 0.08, f(o, 1200)); + }, + + chatMessage(g, out, t0, o) { + osc(g, out, t0, 'sine', f(o, 660), 0.08, 0, 0.12); + }, +}; + +/** + * ── THE ONLY PLACE A CUE BECOMES SOUND ────────────────────────────────────── + * + * Build the head node, then either start the mastered hero buffer through it or + * run the synth definition into it. `play()` and `renderSoundOffline()` both + * come through here; neither has its own copy of the head, the trim, or the + * clip-vs-fallback choice. + * + * `heroBuffer` null means "synth": either the cue has no hero clip, or the clip + * fetch failed and the fallback is what the player is about to hear. + */ +function startVoice( + g: Graph, + id: SoundId, + t0: number, + o: VoiceOptions, + heroBuffer: AudioBuffer | null, +): GainNode { + const head = makeHead(g, o); + const hero = HERO_CLIPS[id]; + if (hero && heroBuffer) { + const source = g.ctx.createBufferSource(); + const gain = g.ctx.createGain(); + source.buffer = heroBuffer; + gain.gain.value = hero.gain; + source.connect(gain).connect(head); + source.start(t0); + } else { + sounds[id](g, head, t0, o); + } + return head; +} + +/* ── the offline render ───────────────────────────────────────────────────── */ + +/** One cue placed in an offline render. `at` is seconds from the render start. */ +export interface RenderLayer { + id: SoundId; + /** Seconds after t0. Defaults to 0. */ + at?: number; + /** Decoded hero clip for this layer; null/omitted renders the synth voice. */ + heroBuffer?: AudioBuffer | null; + /** Defaults to true. False applies the full opponent treatment. */ + mine?: boolean; +} + +/** + * Silence rendered BEFORE the cue, so the master chain is measured in the state + * the player actually hears it in. + * + * ── WHY A RENDER THAT STARTS AT t=0 LIES ──────────────────────────────────── + * Chrome's DynamicsCompressorNode applies an internal MAKEUP GAIN — for this + * chain's settings (−14 / knee 6 / 12:1) it is +6.5dB — and that gain is not + * present at the first sample of a render. It ramps in over roughly 300ms of + * context time, input or no input. A cue scheduled at t=4ms is therefore + * measured up to 6.5dB quieter than the identical cue scheduled at t=1s, and + * partially so ACROSS the cue, which biases short cues differently from long + * ones. That is a measurement of the render's first 300ms, not of the mix. + * + * The live context runs for the whole session, so the settled state is the real + * one. One second of pre-roll puts every cue in it. Verified stable: 0.5s, 1s + * and 2s of pre-roll give the same figures to 0.02dB. + */ +const RENDER_PRE_ROLL_S = 1.0; + +export interface RenderOptions { + /** Cue length to render AFTER the pre-roll. The whole tail, plus room. */ + seconds?: number; + sampleRate?: number; + /** Override the compressor settling pre-roll. See RENDER_PRE_ROLL_S. */ + preRollSeconds?: number; + /** + * Decoded hero clip for the base cue. Null or omitted renders the synth + * voice, which is what the player gets when the fetch fails — so a hero cue + * is measured twice, once each way, and the two are compared. + */ + heroBuffer?: AudioBuffer | null; + /** Extra cues summed into the same render — the two-cues-at-once check. */ + layers?: readonly RenderLayer[]; + /** + * MEASUREMENT ONLY, and never set by the live path: an extra dB offset on + * every head in the render. + * + * It exists for the linearity probe that answers "is this cue riding the + * limiter?". Render a cue twice, once at 0 and once at −20, and add 20dB back + * to the second: a linear chain gives the same peak both times, and the + * shortfall of the first IS the gain reduction the compressor and soft clip + * are applying. Measuring that with a bypassed chain would mean building a + * second graph, which is the one thing this file will not do. + */ + trimOffsetDb?: number; +} + +/** + * Render one cue (plus any `layers`) through the REAL master chain and return + * the rendered buffer. + * + * ── WHY THIS CANNOT DRIFT FROM THE LIVE MIX ───────────────────────────────── + * It calls `buildGraph()` — the same function `getGraph()` calls, so the + * compressor, the soft clip and the bus topology are literally the same code — + * and it starts the cue with `startVoice()` and `voiceGain()`, the same two + * functions `play()` uses. There is no offline-only graph and no offline-only + * copy of MIX_DB. The only things this deliberately omits are the parts of + * `play()` that are not audio: the rate gate, the voice budget, the music duck, + * and the pitch jitter (measured at the nominal pitch, jitter ±2.5%). + * + * Requires `OfflineAudioContext`, so it is browser-only and never called from + * the app. `tests/app/audio/harness.html` is its one caller. Nothing here runs + * at import time. + */ +export function renderSoundOffline( + id: SoundId, + opts: RenderOptions = {}, +): Promise { + const sampleRate = opts.sampleRate ?? 48000; + const seconds = opts.seconds ?? 4; + const preRoll = opts.preRollSeconds ?? RENDER_PRE_ROLL_S; + const ctx = new OfflineAudioContext( + 2, Math.ceil((preRoll + seconds) * sampleRate), sampleRate, + ); + const g = buildGraph(ctx, false); + const t0 = preRoll + 0.004; + + const all: readonly RenderLayer[] = [ + { id, heroBuffer: opts.heroBuffer ?? null }, + ...(opts.layers ?? []), + ]; + const offset = dbToGain(opts.trimOffsetDb ?? 0); + for (const layer of all) { + const mine = layer.mine !== false; + startVoice( + g, + layer.id, + t0 + (layer.at ?? 0), + { + mine, + gain: voiceGain(layer.id, mine, 1) * offset, + pan: mine ? 0 : THEIRS_PAN, + pitch: mine ? 1 : THEIRS_DETUNE, + }, + layer.heroBuffer ?? null, + ); + } + return ctx.startRendering(); +} + +/** The hero-clip table, so a harness can fetch and decode the same URLs. */ +export function heroClips(): Readonly>> { + return HERO_CLIPS; +} + +/** Every SoundId, for a harness that wants to render the whole bank. */ +export function soundIds(): readonly SoundId[] { + return Object.keys(sounds) as SoundId[]; +} + +/** The mix trim table, read-only. The gate imports this. */ +export const MIX_TRIM_DB: Readonly> = MIX_DB; + +/** The tier table, read-only. The gate imports this. */ +export const MIX_TIER_OF: Readonly> = MIX_TIER; + +/** Pre-trim hero-clip gains, read-only. The gate checks these are re-solved. */ +export const HERO_CLIP_GAIN: Readonly>> = + Object.fromEntries( + Object.entries(HERO_CLIPS).map(([id, clip]) => [id, clip.gain]), + ); + +/* ── the engine ───────────────────────────────────────────────────────────── */ + class SoundEngine { - private ctx: AudioContext | null = null; - private masterGain: GainNode | null = null; - private sfxGain: GainNode | null = null; - private musicGain: GainNode | null = null; + private graph: Graph | null = null; + /** The same object as `graph.ctx`, narrowed. Only `resume()` needs it. */ + private liveCtx: AudioContext | null = null; private musicBuffer: AudioBuffer | null = null; private musicBufferPromise: Promise | null = null; private musicSource: AudioBufferSourceNode | null = null; @@ -41,11 +1244,21 @@ class SoundEngine { private fadingMusicSource: AudioBufferSourceNode | null = null; private musicRequestVersion = 0; private musicStopTimer: ReturnType | null = null; - private duckRestoreTimer: ReturnType | null = null; - private ducked = false; private _muted: boolean; private _musicEnabled: boolean; + // Voice budget, reaped by scheduled end time — see reap(). + private voiceEnd: number[] = []; + private voiceWeight: number[] = []; + private voiceLoad = 0; + private peakVoiceLoad = 0; + private droppedVoices = 0; + private droppedPriority = 0; + private gatedVoices = 0; + private lastAt = new Map(); + private flamAt = new Map(); + private flamRun = new Map(); + constructor() { this._muted = typeof window !== 'undefined' && localStorage.getItem('coup_sound_muted') === 'true'; @@ -65,28 +1278,24 @@ class SoundEngine { return this._musicEnabled; } - private getCtx(): AudioContext | null { + /** + * Build the whole chain, via the shared `buildGraph()`. Called lazily — never + * at import time. + */ + private getGraph(): Graph | null { if (typeof window === 'undefined') return null; - if (!this.ctx) { - this.ctx = new AudioContext(); - this.masterGain = this.ctx.createGain(); - this.sfxGain = this.ctx.createGain(); - this.musicGain = this.ctx.createGain(); - - this.masterGain.gain.value = 1; - this.sfxGain.gain.value = this._muted ? 0 : 1; - this.musicGain.gain.value = 0; - - this.sfxGain.connect(this.masterGain); - this.musicGain.connect(this.masterGain); - this.masterGain.connect(this.ctx.destination); - } - return this.ctx; + if (this.graph) return this.graph; + + const ctx = new AudioContext(); + this.liveCtx = ctx; + this.graph = buildGraph(ctx, this._muted); + return this.graph; } private rampGain(gainNode: GainNode | null, target: number, durationMs: number): void { - if (!this.ctx || !gainNode) return; - const now = this.ctx.currentTime; + const g = this.graph; + if (!g || !gainNode) return; + const now = g.ctx.currentTime; gainNode.gain.cancelScheduledValues(now); gainNode.gain.setValueAtTime(gainNode.gain.value, now); gainNode.gain.linearRampToValueAtTime(target, now + durationMs / 1000); @@ -94,8 +1303,9 @@ class SoundEngine { /** Call from a user gesture to unlock AudioContext on mobile Safari. */ unlock(): void { - const ctx = this.getCtx(); - if (!ctx) return; + const g = this.getGraph(); + if (!g || !this.liveCtx) return; + const ctx = this.liveCtx; if (ctx.state === 'suspended') { void ctx.resume().then(() => { this.preloadHeroClips(ctx); @@ -109,7 +1319,7 @@ class SoundEngine { setMuted(muted: boolean): void { this._muted = muted; - this.rampGain(this.sfxGain, muted ? 0 : 1, 80); + this.rampGain(this.graph?.sfxGain ?? null, muted ? 0 : 1, 80); } setMusicEnabled(enabled: boolean): void { @@ -124,7 +1334,7 @@ class SoundEngine { } } - private async loadMusic(ctx: AudioContext): Promise { + private async loadMusic(ctx: BaseAudioContext): Promise { if (this.musicBuffer) return this.musicBuffer; if (!this.musicBufferPromise) { this.musicBufferPromise = fetch(MUSIC_URL) @@ -145,7 +1355,7 @@ class SoundEngine { return this.musicBufferPromise; } - private loadClip(ctx: AudioContext, url: string): Promise { + private loadClip(ctx: BaseAudioContext, url: string): Promise { const buffer = this.clipBuffers.get(url); if (buffer) return Promise.resolve(buffer); @@ -170,35 +1380,34 @@ class SoundEngine { return promise; } - private preloadHeroClips(ctx: AudioContext): void { + private preloadHeroClips(ctx: BaseAudioContext): void { for (const clip of Object.values(HERO_CLIPS)) { if (clip) void this.loadClip(ctx, clip.url).catch(() => undefined); } } - private playClip( - ctx: AudioContext, - destination: AudioNode, - clip: { url: string; gain: number }, - fallback: SoundDefinition, - ): void { - void this.loadClip(ctx, clip.url).then((buffer) => { - if (this._muted || ctx.state !== 'running') return; - const source = ctx.createBufferSource(); - const gain = ctx.createGain(); - source.buffer = buffer; - gain.gain.value = clip.gain; - source.connect(gain).connect(destination); - source.start(); + /** + * The mastered stinger, with the synth voice as the fallback. Both go through + * `startVoice()`, so both carry the same mix trim and the same mine/theirs + * treatment — the fallback cannot be at a different level than the clip. + */ + private playClip(g: Graph, id: SoundId, o: VoiceOptions): void { + const clip = HERO_CLIPS[id]; + if (!clip) return; + void this.loadClip(g.ctx, clip.url).then((buffer) => { + if (this._muted || g.ctx.state !== 'running') return; + // The scheduled t0 is long gone by the time the fetch resolves. + startVoice(g, id, g.ctx.currentTime + 0.004, o, buffer); }).catch(() => { - if (!this._muted) fallback(ctx, destination); + if (this._muted || g.ctx.state !== 'running') return; + startVoice(g, id, g.ctx.currentTime + 0.004, o, null); }); } startMusic(): void { if (!this._musicEnabled || this.musicSource) return; - const ctx = this.getCtx(); - if (!ctx || ctx.state !== 'running') return; + const g = this.getGraph(); + if (!g || g.ctx.state !== 'running') return; if (this.musicStopTimer) { clearTimeout(this.musicStopTimer); @@ -210,26 +1419,26 @@ class SoundEngine { } const requestVersion = ++this.musicRequestVersion; - void this.loadMusic(ctx).then((buffer) => { + void this.loadMusic(g.ctx).then((buffer) => { if ( requestVersion !== this.musicRequestVersion || !this._musicEnabled || this.musicSource - || ctx.state !== 'running' + || g.ctx.state !== 'running' ) return; - const source = ctx.createBufferSource(); + const source = g.ctx.createBufferSource(); source.buffer = buffer; source.loop = true; - source.connect(this.musicGain!); + source.connect(g.musicGain); source.onended = () => { if (this.musicSource === source) this.musicSource = null; if (this.fadingMusicSource === source) this.fadingMusicSource = null; }; this.musicSource = source; - this.musicGain!.gain.setValueAtTime(0, ctx.currentTime); + g.musicGain.gain.setValueAtTime(0, g.ctx.currentTime); source.start(); - this.rampGain(this.musicGain, this.ducked ? MUSIC_DUCK_GAIN : MUSIC_GAIN, 900); + this.rampGain(g.musicGain, MUSIC_GAIN, 900); }).catch((error: unknown) => { console.warn('Unable to start background music', error); }); @@ -237,17 +1446,18 @@ class SoundEngine { stopMusic(fadeMs = 500): void { this.musicRequestVersion += 1; - if (this.duckRestoreTimer) { - clearTimeout(this.duckRestoreTimer); - this.duckRestoreTimer = null; + const g = this.graph; + if (g) { + const now = g.ctx.currentTime; + g.musicDuck.gain.cancelScheduledValues(now); + g.musicDuck.gain.setValueAtTime(1, now); } - this.ducked = false; const source = this.musicSource; if (!source) return; this.musicSource = null; this.fadingMusicSource = source; - this.rampGain(this.musicGain, 0, fadeMs); + this.rampGain(g?.musicGain ?? null, 0, fadeMs); if (this.musicStopTimer) clearTimeout(this.musicStopTimer); this.musicStopTimer = setTimeout(() => { try { source.stop(); } catch { /* already stopped */ } @@ -256,202 +1466,155 @@ class SoundEngine { }, fadeMs + 50); } - duckMusic(durationMs = 1400): void { - if (!this._musicEnabled || !this.musicSource) return; - this.ducked = true; - this.rampGain(this.musicGain, MUSIC_DUCK_GAIN, 120); - if (this.duckRestoreTimer) clearTimeout(this.duckRestoreTimer); - this.duckRestoreTimer = setTimeout(() => { - this.ducked = false; - this.duckRestoreTimer = null; - if (this._musicEnabled && this.musicSource) { - this.rampGain(this.musicGain, MUSIC_GAIN, 500); - } - }, durationMs); + /** + * Step the music back under a consequence. Depth is by VOICE weight, not by + * sound id: heavier meaning, deeper dip. 0.50 / 0.60 / 0.70 is 6.0 / 4.4 / + * 3.1dB — an unmistakable step back on the moments that matter, and nothing + * at all on the 60% of a game that has no consequence in it. + * + * 25ms attack so the dip is under the transient rather than behind it; 600ms + * release for heavy stings (they have tails to get out of the way of) and + * 280ms for light ones (the bed should be back before the next beat). + */ + duckMusic(weight = 4): void { + const g = this.graph; + if (!g || !this._musicEnabled || !this.musicSource) return; + const depth = weight >= 6 ? 0.50 : weight >= 4 ? 0.60 : 0.70; + const releaseS = weight >= 6 ? 0.6 : 0.28; + const now = g.ctx.currentTime; + const p = g.musicDuck.gain; + p.cancelScheduledValues(now); + p.setValueAtTime(Math.max(p.value, EPS), now); + p.linearRampToValueAtTime(depth, now + 0.025); + p.exponentialRampToValueAtTime(1, now + 0.025 + releaseS); } - play(id: SoundId): void { + /** + * The single entry point, and the single place MIX_DB is applied. + * + * `mine` defaults to true so every pre-existing `play(id)` call site keeps its + * old behaviour exactly. + */ + play(id: SoundId, opts: PlayOptions = {}): void { if (this._muted) return; - const ctx = this.getCtx(); - if (!ctx || !this.sfxGain || ctx.state !== 'running') return; - - if (id === 'coup') this.duckMusic(1200); - else if (id === 'challengeWindow') this.duckMusic(900); - else if (id === 'challengeRevealSuccess' || id === 'challengeRevealFail') this.duckMusic(2400); - else if (id === 'gameOverWin' || id === 'gameOverLose') this.duckMusic(3000); - - const fn = sounds[id]; - if (!fn) return; - const heroClip = HERO_CLIPS[id]; - if (heroClip) { - this.playClip(ctx, this.sfxGain, heroClip, fn); - } else { - fn(ctx, this.sfxGain); - } - } -} + const g = this.getGraph(); + if (!g || g.ctx.state !== 'running') return; -function osc( - ctx: AudioContext, - destination: AudioNode, - type: OscillatorType, - freq: number, - gain: number, - start: number, - stop: number, - freqEnd?: number, -): void { - const o = ctx.createOscillator(); - const g = ctx.createGain(); - o.type = type; - o.frequency.setValueAtTime(freq, ctx.currentTime + start); - if (freqEnd !== undefined) { - o.frequency.linearRampToValueAtTime(freqEnd, ctx.currentTime + stop); - } - g.gain.setValueAtTime(gain, ctx.currentTime + start); - g.gain.linearRampToValueAtTime(0, ctx.currentTime + stop); - o.connect(g).connect(destination); - o.start(ctx.currentTime + start); - o.stop(ctx.currentTime + stop + 0.05); -} - -function noiseBurst( - ctx: AudioContext, - destination: AudioNode, - gain: number, - start: number, - duration: number, - frequency = 3000, -): void { - const sr = ctx.sampleRate; - const len = sr * duration; - const buf = ctx.createBuffer(1, len, sr); - const data = buf.getChannelData(0); - for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1; + if (!sounds[id]) return; + const spec = VOICE[id]; + const t0 = g.ctx.currentTime + 0.004; - const src = ctx.createBufferSource(); - src.buffer = buf; - const bp = ctx.createBiquadFilter(); - bp.type = 'bandpass'; - bp.frequency.value = frequency; - bp.Q.value = 0.8; - const g = ctx.createGain(); - g.gain.setValueAtTime(gain, ctx.currentTime + start); - g.gain.linearRampToValueAtTime(0, ctx.currentTime + start + duration); - src.connect(bp).connect(g).connect(destination); - src.start(ctx.currentTime + start); - src.stop(ctx.currentTime + start + duration + 0.05); -} - -type SoundDefinition = (ctx: AudioContext, destination: AudioNode) => void; - -const sounds: Record = { - yourTurn(ctx, out) { - osc(ctx, out, 'sine', 523, 0.15, 0, 0.12); - osc(ctx, out, 'sine', 698, 0.15, 0.13, 0.25); - }, - - actionDeclared(ctx, out) { - osc(ctx, out, 'triangle', 900, 0.1, 0, 0.08); - }, - - // Layered low impact with a brief card-snap transient. - coup(ctx, out) { - noiseBurst(ctx, out, 0.16, 0, 0.09, 1100); - osc(ctx, out, 'sine', 130, 0.26, 0, 0.42, 48); - osc(ctx, out, 'triangle', 72, 0.18, 0.03, 0.62, 38); - osc(ctx, out, 'sine', 680, 0.07, 0.02, 0.16, 310); - }, - - // A crisp challenge marker: card snap, rising accusation, low answer. - challengeWindow(ctx, out) { - noiseBurst(ctx, out, 0.08, 0, 0.045, 2200); - osc(ctx, out, 'triangle', 330, 0.11, 0, 0.18, 660); - osc(ctx, out, 'sine', 165, 0.09, 0.08, 0.3, 110); - }, - - blockOpportunity(ctx, out) { - osc(ctx, out, 'square', 600, 0.08, 0, 0.1); - osc(ctx, out, 'square', 800, 0.08, 0.12, 0.22); - }, - - assassinationAlert(ctx, out) { - osc(ctx, out, 'sawtooth', 880, 0.15, 0, 0.1); - osc(ctx, out, 'sawtooth', 660, 0.15, 0.12, 0.22); - osc(ctx, out, 'sawtooth', 440, 0.15, 0.24, 0.4); - }, - - block(ctx, out) { - osc(ctx, out, 'triangle', 1200, 0.12, 0, 0.05); - osc(ctx, out, 'triangle', 2400, 0.08, 0, 0.15); - }, - - influenceLoss(ctx, out) { - osc(ctx, out, 'sine', 300, 0.15, 0, 0.35, 150); - }, - - challengeRevealSuccess(ctx, out) { - noiseBurst(ctx, out, 0.065, 0, 0.04, 2600); - osc(ctx, out, 'sine', 392, 0.11, 0, 0.18); - osc(ctx, out, 'triangle', 523, 0.11, 0.11, 0.31); - osc(ctx, out, 'sine', 784, 0.13, 0.23, 0.55); - osc(ctx, out, 'sine', 1568, 0.045, 0.25, 0.65); - }, - - challengeRevealFail(ctx, out) { - noiseBurst(ctx, out, 0.08, 0, 0.06, 900); - osc(ctx, out, 'sawtooth', 360, 0.1, 0, 0.24, 210); - osc(ctx, out, 'triangle', 180, 0.12, 0.13, 0.48, 82); - osc(ctx, out, 'sine', 92, 0.12, 0.28, 0.72, 52); - }, - - coinsGained(ctx, out) { - osc(ctx, out, 'sine', 1200, 0.1, 0, 0.15); - }, + if (!this.gate(id, t0, spec.priority)) { + this.gatedVoices += 1; + return; + } + if (!this.take(t0 + spec.tail, spec.weight, spec.priority)) { + this.droppedVoices += 1; + if (spec.priority) this.droppedPriority += 1; + return; + } + if (DUCKS.has(id)) this.duckMusic(spec.weight); - coinsLost(ctx, out) { - osc(ctx, out, 'triangle', 600, 0.1, 0, 0.15); - }, + const mine = opts.mine !== false; + // MIX_DB, the theirs trim and the flam attenuation all meet in voiceGain() + // and nowhere else. The head GainNode startVoice() builds from this is the + // per-sound gain node between the voice and sfxGain. + const o: VoiceOptions = { + mine, + gain: voiceGain(id, mine, this.flam(id, t0)), + pan: mine ? 0 : this.theirsPan(opts.playerId), + pitch: (mine ? 1 : THEIRS_DETUNE) + * (JITTERED.has(id) ? jitter(JITTER_AMOUNT) : 1), + }; - timerWarning(ctx, out) { - osc(ctx, out, 'square', 880, 0.12, 0, 0.06); - }, + if (HERO_CLIPS[id]) this.playClip(g, id, o); + else startVoice(g, id, t0, o, null); + } - gameOverWin(ctx, out) { - osc(ctx, out, 'sine', 523, 0.15, 0, 0.2); - osc(ctx, out, 'sine', 659, 0.15, 0.15, 0.35); - osc(ctx, out, 'sine', 784, 0.15, 0.3, 0.5); - osc(ctx, out, 'sine', 1047, 0.18, 0.45, 0.75); - }, + /** Voice-budget and rate-limit counters. `droppedPriority` must read 0. */ + stats(): SoundStats { + this.reap(); + return { + peakVoiceLoad: this.peakVoiceLoad, + voiceLoad: this.voiceLoad, + droppedVoices: this.droppedVoices, + droppedPriority: this.droppedPriority, + gatedVoices: this.gatedVoices, + }; + } - gameOverLose(ctx, out) { - osc(ctx, out, 'sine', 440, 0.12, 0, 0.25); - osc(ctx, out, 'sine', 370, 0.12, 0.2, 0.45); - osc(ctx, out, 'sine', 311, 0.12, 0.4, 0.7); - }, + /** + * Which side of the field an opponent sits on. Seeded by player id so the same + * opponent is always on the same side — a cue that jumps between ears is a + * different player to a listener. + */ + private theirsPan(playerId: string | undefined): number { + const r = playerId === undefined ? this.graph?.rng() ?? 0.5 : hash01(playerId); + return r < 0.5 ? -THEIRS_PAN : THEIRS_PAN; + } - playerEliminated(ctx, out) { - osc(ctx, out, 'sine', 200, 0.2, 0, 0.4, 80); - }, + /** + * Reaped by SCHEDULED END TIME rather than `onended`. `onended` fires on the + * main thread whenever it gets round to it, so a budget keyed on it drifts + * behind the graph it is supposed to be describing. + */ + private take(endTime: number, weight: number, priority: boolean): boolean { + this.reap(); + // The two caps ARE the priority scheme: routine voices can never claim past + // MAX_VOICES, so 32 weighted units are permanently reserved for the stings. + // A coin tick therefore cannot be the reason a win fanfare goes unheard. + const cap = priority ? MAX_VOICES_PRIORITY : MAX_VOICES; + if (this.voiceLoad + weight > cap) return false; + this.voiceEnd.push(endTime); + this.voiceWeight.push(weight); + this.voiceLoad += weight; + if (this.voiceLoad > this.peakVoiceLoad) this.peakVoiceLoad = this.voiceLoad; + return true; + } - exchange(ctx, out) { - osc(ctx, out, 'sine', 500, 0.08, 0, 0.25); - osc(ctx, out, 'sine', 507, 0.08, 0, 0.25); - osc(ctx, out, 'sine', 493, 0.08, 0, 0.25); - }, + private reap(): void { + const now = this.graph ? this.graph.ctx.currentTime : 0; + let write = 0; + for (let i = 0; i < this.voiceEnd.length; i++) { + if (this.voiceEnd[i] > now) { + this.voiceEnd[write] = this.voiceEnd[i]; + this.voiceWeight[write] = this.voiceWeight[i]; + write += 1; + } else { + this.voiceLoad -= this.voiceWeight[i]; + } + } + this.voiceEnd.length = write; + this.voiceWeight.length = write; + if (this.voiceLoad < 0) this.voiceLoad = 0; + } - cardShuffle(ctx, out) { - noiseBurst(ctx, out, 0.12, 0, 0.15); - }, + private gate(id: SoundId, now: number, priority: boolean): boolean { + const min = priority ? RATE_PRIORITY : RATE_DEFAULT; + const last = this.lastAt.get(id); + if (last !== undefined && now - last < min) return false; + this.lastAt.set(id, now); + return true; + } - reaction(ctx, out) { - osc(ctx, out, 'sine', 800, 0.1, 0, 0.08, 1200); - }, + /** + * Linear gain for the Nth rapid retrigger. See FLAM_DB: the rate floors are + * short enough to let both cards of an exchange speak, and this is what stops + * the pair from also being twice as loud. + */ + private flam(id: SoundId, now: number): number { + if (!FLAM.has(id)) return 1; + const last = this.flamAt.get(id); + const run = last !== undefined && now - last < FLAM_WINDOW + ? (this.flamRun.get(id) ?? 0) + 1 + : 0; + this.flamAt.set(id, now); + this.flamRun.set(id, run); + return dbToGain(FLAM_DB[Math.min(run, FLAM_DB.length - 1)]); + } +} - chatMessage(ctx, out) { - osc(ctx, out, 'sine', 660, 0.08, 0, 0.12); - }, -}; +export type { SoundEngine }; let instance: SoundEngine | null = null; diff --git a/src/app/components/icons/glyphs/AmbassadorGlyph.tsx b/src/app/components/icons/glyphs/AmbassadorGlyph.tsx new file mode 100644 index 0000000..fe926e5 --- /dev/null +++ b/src/app/components/icons/glyphs/AmbassadorGlyph.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Ambassador — two offset arrows forming an open loop. + * + * ART-DIRECTION.md §1.2: it has to read as *exchange*. The two arrows are 180° + * rotations of each other about the centre, each running one vertical arm and + * one horizontal arm, so together they enclose a broken square — a cycle that + * never closes, which is what an exchange is. + * + * Distinct from `ExchangeGlyph` (the action), which is two straight PARALLEL + * horizontals with chevrons: that mark is a flat back-and-forth, this one is a + * rotation. At 16px the difference is that one is two bars and the other is a + * ring, which is a silhouette-level difference, not a detail-level one. + * + * The heads are deliberately oversized — 10 units of run each way. A 6-unit + * head disappears at 16px, where the whole glyph is 13px wide and the stroke is + * 1.5px; at 10 units it survives as a visible point. + */ +export function AmbassadorGlyph(props: GlyphProps) { + return ( + + {/* up the left, across the top, pointing right */} + + + {/* down the right, across the bottom, pointing left */} + + + + ); +} diff --git a/src/app/components/icons/glyphs/AssassinGlyph.tsx b/src/app/components/icons/glyphs/AssassinGlyph.tsx new file mode 100644 index 0000000..a99f75d --- /dev/null +++ b/src/app/components/icons/glyphs/AssassinGlyph.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Assassin — a blunt wedge, point-down, with one notch. + * + * ART-DIRECTION.md §1.2, and §2.2's standing note that the Assassin's hue is + * cold steel at S15 and "the fix here is the glyph, not the hue". §2.4 records + * why: Assassin 206° and Captain 205° are ONE degree apart, separated only by + * saturation. This silhouette is therefore the whole categorical signal for the + * weakest pair in the palette, and it has to survive being seen next to + * `CaptainGlyph` in monochrome. It does: a downward solid mass against a + * horizontal open line. + * + * Explicitly NOT a dagger. `AssassinateGlyph` is the dagger — that is the + * ACTION, drawn with a crossguard, grip and pommel. The character is the + * abstract instrument: a heavy wedge that stops short of a point (the tip is a + * flat 8 units wide, so it never becomes a needle that vanishes at 16px), with + * a single square bite out of the left edge. The notch is the memorable event + * in an otherwise plain triangle, and it is on one side only — the asymmetry is + * what makes the mark nameable rather than generic. + * + * THE PAIR TO WATCH IS NOT THE ONE THE DOCUMENT NAMES. §2.4 flags Assassin + * against Captain on hue; on SHAPE the near neighbour is `ContessaGlyph` — + * both are solid masses, widest at the top, narrowing downward, and at 16px + * that description is most of what you can see. Three things separate them and + * all three were sized on the proof sheet, not guessed: this taper starts at + * the very top edge where the shield has vertical shoulders for its first + * 24 units; this has the notch and the shield is convex; and this ends in a + * 12-unit BLUNT tip (3px at 16px) where the shield comes to a point. The tip + * was 8 units in the first pass and the blunt/pointed distinction was not + * carrying at 16px. Do not narrow it. + * + * Both of those numbers are proof-sheet results. The first draft sat on 40x46 + * of the grid with a 10x12 notch, and at 16px the notch closed up completely + * and left a generic pennant. At 48x52 with a 14x16 notch the bite survives as + * a visible shoulder at 16px, which is the whole point of drawing one. A + * top-edge V-notch was tried and rejected in the same pass: at 16px it read as + * a plain letter V. + */ +export function AssassinGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/AssassinateGlyph.tsx b/src/app/components/icons/glyphs/AssassinateGlyph.tsx new file mode 100644 index 0000000..ea1b2f7 --- /dev/null +++ b/src/app/components/icons/glyphs/AssassinateGlyph.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Assassinate — the Assassin's paid kill. Replaces the dagger. */ +export function AssassinateGlyph(props: GlyphProps) { + return ( + + {/* blade */} + + {/* crossguard */} + + {/* grip */} + + {/* pommel */} + + + ); +} diff --git a/src/app/components/icons/glyphs/BlockGlyph.tsx b/src/app/components/icons/glyphs/BlockGlyph.tsx new file mode 100644 index 0000000..517a0b1 --- /dev/null +++ b/src/app/components/icons/glyphs/BlockGlyph.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Block — an action was stopped. An octagon with a prohibition slash. + * + * The slash is not decoration. This glyph originally carried the same horizontal + * bar as PassGlyph (`M20 32h24`), and at 16px — the size it renders at in the + * action log — an octagon and a square are not separable, so "blocked" and + * "passed" read as the same mark for two opposite outcomes. The diagonal + * separates them at every size, and a slashed octagon is the prohibition sign it + * is already imitating. Verified on the 16px proof sheet, not in the source. + */ +export function BlockGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/BluffGlyph.tsx b/src/app/components/icons/glyphs/BluffGlyph.tsx new file mode 100644 index 0000000..2911cbc --- /dev/null +++ b/src/app/components/icons/glyphs/BluffGlyph.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { Glyph, GlyphProps, GLYPH_STROKE_LIGHT } from './GlyphBase'; + +/** Bluff — the claim was a lie. Replaces the cross / ballot X. */ +export function BluffGlyph(props: GlyphProps) { + return ( + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/BotGlyph.tsx b/src/app/components/icons/glyphs/BotGlyph.tsx new file mode 100644 index 0000000..ec00686 --- /dev/null +++ b/src/app/components/icons/glyphs/BotGlyph.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Bot — a computer player. Replaces the robot emoji. */ +export function BotGlyph(props: GlyphProps) { + return ( + + + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/CaptainGlyph.tsx b/src/app/components/icons/glyphs/CaptainGlyph.tsx new file mode 100644 index 0000000..fb8ea7f --- /dev/null +++ b/src/app/components/icons/glyphs/CaptainGlyph.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { Glyph, GLYPH_STROKE_LIGHT, GlyphProps } from './GlyphBase'; + +/** + * Captain — a hooked bar, horizontal. A grapple. + * + * ART-DIRECTION.md §1.2: it has to read as *taking*, matching STEAL. The shank + * runs the full width and the hook turns down, back, and up again — five + * orthogonal segments, so it is a hook rather than an "L" at every size. The + * returning tip is the whole difference: an L reads as a corner, a J reads as + * something that has caught. + * + * Distinct from `StealGlyph` (the action) on purpose: that one is a filled coin + * plus a leftward arrow, so its mass is at the RIGHT and it terminates in a + * chevron. This one is all line, its mass is the long horizontal, and it + * terminates in a closed curl. They never collide even at 16px. + * + * The light-weight tick at the left end is the shank eye. It uses + * GLYPH_STROKE_LIGHT because rule 1 reserves the heavy weight for object lines + * and this is secondary detail; at 16px it drops to a hint of a serif, which is + * the correct behaviour — it stops the bar reading as a bare arrow shaft + * without competing with the hook. + * + * The first draft ran the shank at y=32 with a 16-unit hook, i.e. inside a + * 16-unit band of a 64-unit grid, and at 16px that is a 4px-tall mark in a + * 16px box — present but weightless next to five glyphs that use the whole + * grid. The hook now spans 22..54, which is half the grid, and the set reads + * as one family at size. + */ +export function CaptainGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/ChallengeGlyph.tsx b/src/app/components/icons/glyphs/ChallengeGlyph.tsx new file mode 100644 index 0000000..1bd85b3 --- /dev/null +++ b/src/app/components/icons/glyphs/ChallengeGlyph.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Challenge — an accusation is raised. Replaces the log's question mark / crossed swords. */ +export function ChallengeGlyph(props: GlyphProps) { + return ( + + {/* attention lozenge */} + + {/* query bar */} + + {/* query point */} + + + ); +} diff --git a/src/app/components/icons/glyphs/ClaimGlyph.tsx b/src/app/components/icons/glyphs/ClaimGlyph.tsx new file mode 100644 index 0000000..65f1760 --- /dev/null +++ b/src/app/components/icons/glyphs/ClaimGlyph.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Claim — a character is claimed (truthfully or not). Replaces the theatre masks. */ +export function ClaimGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/CoinGlyph.tsx b/src/app/components/icons/glyphs/CoinGlyph.tsx new file mode 100644 index 0000000..4ac8885 --- /dev/null +++ b/src/app/components/icons/glyphs/CoinGlyph.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Glyph, GlyphProps, GLYPH_STROKE_LIGHT } from './GlyphBase'; + +/** Coin — one unit of the treasury. Milled edge, struck centre. */ +export function CoinGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/ContessaGlyph.tsx b/src/app/components/icons/glyphs/ContessaGlyph.tsx new file mode 100644 index 0000000..5f2e512 --- /dev/null +++ b/src/app/components/icons/glyphs/ContessaGlyph.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Contessa — a heraldic shield, flat top, single vertical split. + * + * ART-DIRECTION.md §1.2 gives this glyph a job the other five do not have: it + * is "the only closed convex form in the set — findable by shape alone". That + * is a constraint on the WHOLE SET, not just on this file. Duke is an open + * profile, Assassin a downward wedge, Captain a horizontal line, Ambassador a + * broken ring, Inquisitor a horizontal lens; this is the only mark you can find + * by looking for a solid block with a flat top. Do not fill another character's + * glyph into a convex mass, and do not open this one up. + * + * It is therefore SOLID, not stroked. An outlined shield at 16px is a 9px-wide + * ring with 1.5px walls and almost no interior — it reads as a smudge. Filled, + * it reads as a shield at 16px and as a shield at 48px. + * + * §2.4 also leans on it: `--crimson` (5°) and Contessa (348°) sit 17° apart, + * and the stated mitigation is that "Contessa is only ever a fill inside one, + * carrying the shield glyph". This is that glyph. + * + * The split is knocked out with `evenodd` rather than drawn as a second stroke, + * because a stroke over a fill is a second weight on the same object. It is 6 + * units wide — 1.5px at 16px, exactly one stroke width, so it prints as a + * hairline rather than vanishing — and it deliberately stops 8 units short of + * the top edge and 14 short of the point. On the proof sheet a slot running + * edge to edge split the mark into two halves at 16px, which costs the one + * property §1.2 gives this glyph: being a single convex mass. + */ +export function ContessaGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/ConvertGlyph.tsx b/src/app/components/icons/glyphs/ConvertGlyph.tsx new file mode 100644 index 0000000..edbd2b4 --- /dev/null +++ b/src/app/components/icons/glyphs/ConvertGlyph.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Convert — a player crosses the aisle to the other faction (Reformation). */ +export function ConvertGlyph(props: GlyphProps) { + return ( + + {/* the aisle */} + + {/* crossing it */} + + + + ); +} diff --git a/src/app/components/icons/glyphs/CoupGlyph.tsx b/src/app/components/icons/glyphs/CoupGlyph.tsx new file mode 100644 index 0000000..1e3f1bc --- /dev/null +++ b/src/app/components/icons/glyphs/CoupGlyph.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Coup — the paid, unstoppable strike. Replaces the explosion. */ +export function CoupGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/CrownGlyph.tsx b/src/app/components/icons/glyphs/CrownGlyph.tsx new file mode 100644 index 0000000..448a51e --- /dev/null +++ b/src/app/components/icons/glyphs/CrownGlyph.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Crown — the winner. Replaces the trophy and the crown emoji. + * + * Ratified by the crown RULING in ART-DIRECTION.md §1.2: the crown means victory, + * not the Duke. Keep this form CLOSED, SYMMETRIC and SITTING ON A SOLID BASE — that + * is the entire separation from the Duke's silhouette, which is open-topped, + * asymmetric and baseless. Anyone drawing the Duke mark must diff it against this + * one at 16px on the proof sheet before it lands. + */ +export function CrownGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/DeckGlyph.tsx b/src/app/components/icons/glyphs/DeckGlyph.tsx new file mode 100644 index 0000000..7132c0a --- /dev/null +++ b/src/app/components/icons/glyphs/DeckGlyph.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Deck — the court deck, or a card returned to it. Replaces the playing-card emoji. */ +export function DeckGlyph(props: GlyphProps) { + return ( + + {/* the card behind */} + + {/* the top card */} + + + + ); +} diff --git a/src/app/components/icons/glyphs/DeclareGlyph.tsx b/src/app/components/icons/glyphs/DeclareGlyph.tsx new file mode 100644 index 0000000..83cbd10 --- /dev/null +++ b/src/app/components/icons/glyphs/DeclareGlyph.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Declare — an action is announced to the table. Replaces the loudhailer. */ +export function DeclareGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/DiceGlyph.tsx b/src/app/components/icons/glyphs/DiceGlyph.tsx new file mode 100644 index 0000000..8d94ce9 --- /dev/null +++ b/src/app/components/icons/glyphs/DiceGlyph.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Dice — a gamble that did not pay. Used by the backfired-challenges award. */ +export function DiceGlyph(props: GlyphProps) { + return ( + + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/DukeGlyph.tsx b/src/app/components/icons/glyphs/DukeGlyph.tsx new file mode 100644 index 0000000..b91aad3 --- /dev/null +++ b/src/app/components/icons/glyphs/DukeGlyph.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Duke — a fractured crown reduced to three severe planes. + * + * ART-DIRECTION.md §1.2. THIS GLYPH IS GOVERNED BY THE CROWN RULING; read + * `CrownGlyph.tsx`'s header before touching it. The crown means the WINNER. + * The Duke and the winner are separated by silhouette, not by subject: + * + * Winner (CrownGlyph) Duke (here) + * Form closed, symmetric, base open-topped, asymmetric, no base + * Weight one solid filled mass an open stroked profile + * Register heraldic, a whole object technical-order, a fracture diagram + * + * The load-bearing difference at 16px is MASS vs LINE. CrownGlyph is a single + * filled slab and reads as a dark blob with a jagged top; this is an open + * polyline and reads as a jagged rule. Verified side by side on the 16px proof + * sheet — they do not converge, because one has an interior and one does not. + * If a future edit fills this path, the ruling is broken. + * + * The three planes are FLAT-TOPPED, not pointed — "planes", not spikes, which + * is also what saves it at 16px. THE FIRST DRAFT WAS A TOOTHED ZIGZAG and it + * was mud: three peaks plus two deep valleys plus two end legs is seven + * features across 12.5px, and on the proof sheet it read as a picket fence. A + * stepped profile has no thin valley to close up, so the same three planes + * survive at a quarter of the size. Redrawn, not rescaled. + * + * They sit at three heights (32 / 14 / 26) over three widths (18 / 20 / 14), + * middle plane highest, so no reflection maps the mark onto itself. The two end + * legs fall to different depths (18 / 20 units) and never meet: there is no + * base. Orthographic, stepped, unclosed — a fracture diagram of a crown rather + * than a crown. + */ +export function DukeGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/EmbezzleGlyph.tsx b/src/app/components/icons/glyphs/EmbezzleGlyph.tsx new file mode 100644 index 0000000..89c9f34 --- /dev/null +++ b/src/app/components/icons/glyphs/EmbezzleGlyph.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { Glyph, GlyphProps, HatchPattern, useHatchId } from './GlyphBase'; + +/** + * Embezzle — empty the treasury reserve (Reformation). Replaces the money-bag emoji. + * The strongbox carries a 45-degree line screen: the reserve is full. A coin is off the top. + */ +export function EmbezzleGlyph(props: GlyphProps) { + const hatchId = useHatchId('embezzle-hatch'); + return ( + + + + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/ExamineGlyph.tsx b/src/app/components/icons/glyphs/ExamineGlyph.tsx new file mode 100644 index 0000000..4b5f7b5 --- /dev/null +++ b/src/app/components/icons/glyphs/ExamineGlyph.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Examine — the Inquisitor looks at a card. Replaces the magnifier. */ +export function ExamineGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/ExchangeGlyph.tsx b/src/app/components/icons/glyphs/ExchangeGlyph.tsx new file mode 100644 index 0000000..c653c73 --- /dev/null +++ b/src/app/components/icons/glyphs/ExchangeGlyph.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Exchange — cards swapped with the court deck. Replaces the cycle arrows. */ +export function ExchangeGlyph(props: GlyphProps) { + return ( + + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/ExitGlyph.tsx b/src/app/components/icons/glyphs/ExitGlyph.tsx new file mode 100644 index 0000000..46072ce --- /dev/null +++ b/src/app/components/icons/glyphs/ExitGlyph.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Exit — first out of the game. Used by the quick-exit award. */ +export function ExitGlyph(props: GlyphProps) { + return ( + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/GameStartGlyph.tsx b/src/app/components/icons/glyphs/GameStartGlyph.tsx new file mode 100644 index 0000000..ba409b1 --- /dev/null +++ b/src/app/components/icons/glyphs/GameStartGlyph.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Game start — the split-eclipse civic mark from the brand. Replaces the game-controller emoji. */ +export function GameStartGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/GlyphBase.tsx b/src/app/components/icons/glyphs/GlyphBase.tsx new file mode 100644 index 0000000..1584350 --- /dev/null +++ b/src/app/components/icons/glyphs/GlyphBase.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useId, type ReactNode } from 'react'; + +/** + * Shared chassis for the functional glyph set. + * + * Drawing rules (see ./index.ts for the full statement): + * - exactly two stroke widths, 6 and 3, at a 2:1 ratio + * - miter joins, butt caps, no rounded anything + * - no gradient, no blur, no soft shadow + * - hatch patterns instead of fill-opacity for tints + * - currentColor only + * - 64x64 grid, ~6px margin, legible at 16px + */ + +/** Heavy weight — object lines and the load-bearing mark. */ +export const GLYPH_STROKE_HEAVY = 6; +/** Light weight — secondary detail and frames. Exactly half the heavy weight. */ +export const GLYPH_STROKE_LIGHT = 3; + +export interface GlyphProps { + /** Rendered box in px. The artwork is drawn on a 64px grid and scaled. */ + size?: number; + className?: string; + /** + * Accessible name. When omitted the glyph is decorative (aria-hidden) and the + * surrounding text is expected to carry the meaning. + */ + title?: string; +} + +export function Glyph({ + size = 24, + className, + title, + children, +}: GlyphProps & { children: ReactNode }) { + return ( + + {title ? {title} : null} + {children} + + ); +} + +/** + * A 45-degree line screen. Use this wherever a glyph needs a mid-tone: it reads + * as the halftone screen it is imitating, where fill-opacity just reads as faded. + * Pair with `useHatchId()` so multiple instances on a page do not collide. + */ +export function HatchPattern({ + id, + spacing = 8, + weight = GLYPH_STROKE_LIGHT, +}: { + id: string; + spacing?: number; + weight?: number; +}) { + return ( + + + + ); +} + +/** Stable, collision-free id for a per-instance . */ +export function useHatchId(prefix: string): string { + return `${prefix}-${useId().replace(/:/g, '')}`; +} diff --git a/src/app/components/icons/glyphs/GlyphSheet.tsx b/src/app/components/icons/glyphs/GlyphSheet.tsx new file mode 100644 index 0000000..087387f --- /dev/null +++ b/src/app/components/icons/glyphs/GlyphSheet.tsx @@ -0,0 +1,92 @@ +'use client'; + +import type { GlyphProps } from './GlyphBase'; +import * as G from './index'; + +/** + * Development proof sheet for the glyph set. Renders every glyph at 16/24/48px + * on a dark and a light swatch so the 16px row can be judged by eye. + * + * This is a development aid. It is deliberately not routed — drop it into a + * scratch page temporarily if you need to look at it in the app. + */ + +type GlyphComponent = (props: GlyphProps) => React.JSX.Element; + +/* The six character silhouettes lead the sheet, and `CrownGlyph` is pulled up + * to sit immediately after `DukeGlyph` rather than staying down in "State and + * outcome". That adjacency is not tidiness: the §1.2 crown RULING requires the + * Duke to be diffed against the winner's crown at 16px SIDE BY SIDE before it + * ships, and a diff you have to scroll between is a diff nobody does. */ +const GLYPHS: [string, GlyphComponent][] = [ + ['DukeGlyph', G.DukeGlyph], + ['CrownGlyph *', G.CrownGlyph], + ['AssassinGlyph', G.AssassinGlyph], + ['CaptainGlyph', G.CaptainGlyph], + ['AmbassadorGlyph', G.AmbassadorGlyph], + ['ContessaGlyph', G.ContessaGlyph], + ['InquisitorGlyph', G.InquisitorGlyph], + ['ChallengeGlyph', G.ChallengeGlyph], + ['TruthGlyph', G.TruthGlyph], + ['BluffGlyph', G.BluffGlyph], + ['PassGlyph', G.PassGlyph], + ['BlockGlyph', G.BlockGlyph], + ['CoupGlyph', G.CoupGlyph], + ['AssassinateGlyph', G.AssassinateGlyph], + ['StealGlyph', G.StealGlyph], + ['ExchangeGlyph', G.ExchangeGlyph], + ['ExamineGlyph', G.ExamineGlyph], + ['ConvertGlyph', G.ConvertGlyph], + ['EmbezzleGlyph', G.EmbezzleGlyph], + ['CoinGlyph', G.CoinGlyph], + ['ClaimGlyph', G.ClaimGlyph], + ['DeclareGlyph', G.DeclareGlyph], + ['ResolveGlyph', G.ResolveGlyph], + ['SkullGlyph', G.SkullGlyph], + ['CrownGlyph', G.CrownGlyph], + ['DeckGlyph', G.DeckGlyph], + ['TurnGlyph', G.TurnGlyph], + ['GameStartGlyph', G.GameStartGlyph], + ['BotGlyph', G.BotGlyph], + ['SpeakerGlyph', G.SpeakerGlyph], + ['SpeakerMutedGlyph', G.SpeakerMutedGlyph], + ['TargetGlyph', G.TargetGlyph], + ['DiceGlyph', G.DiceGlyph], + ['ExitGlyph', G.ExitGlyph], +]; + +const SIZES = [16, 24, 48]; + +function Swatch({ background, color, label }: { background: string; color: string; label: string }) { + return ( +
+

+ {label} +

+
+ {GLYPHS.map(([name, Component]) => ( +
+
+ {SIZES.map((size) => ( + + ))} +
+
+ {name.replace('Glyph', '')} +
+
+ ))} +
+
+ ); +} + +export function GlyphSheet() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/components/icons/glyphs/InquisitorGlyph.tsx b/src/app/components/icons/glyphs/InquisitorGlyph.tsx new file mode 100644 index 0000000..c72772a --- /dev/null +++ b/src/app/components/icons/glyphs/InquisitorGlyph.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** + * Inquisitor — the eclipse eye. + * + * ART-DIRECTION.md §1.2 calls this "the one glyph that survives the rewrite": + * `InquisitorIcon.tsx` is already an eclipse eye and the MOTIF is reused here + * rather than reinvented. What could not be reused is the drawing. That file is + * a five-colour illustration with hard-coded teal hex, `strokeLinejoin="round"`, + * `fill-opacity` highlights and three `` elements — it breaks rules 2, + * 3, 4, 5 and 8 of the glyph set, and it is a 24px+ illustration that turns to + * porridge at 16px. `InquisitorIcon.tsx` stays exactly as it is for the places + * that want the illustration; this is the same subject in the line language. + * + * The eclipse is done by KNOCKOUT, not by a second colour: one solid lens with + * the iris punched out of it, so the ground shows through as the occluded disc. + * That is the only way to get a bright-rim/dark-centre eclipse out of a + * single-colour `currentColor` mark, and it is also what a screen-print does. + * + * The lens is quadratic rather than orthogonal because the rest of the rules + * govern JOINS and CAPS (rule 2), not curvature — `ExamineGlyph`, `StealGlyph` + * and `CoinGlyph` are all circles. The iris is r=10 against a 36-unit lens + * opening (y 14..50), which leaves 8 units of rim top and bottom: 2px at 16px, + * more than one stroke width, so the rim never breaks and the knockout never + * bleeds out through the lid. The first draft's 28-unit opening left 1.5px and + * was living on the edge of that. + */ +export function InquisitorGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/PassGlyph.tsx b/src/app/components/icons/glyphs/PassGlyph.tsx new file mode 100644 index 0000000..dac038e --- /dev/null +++ b/src/app/components/icons/glyphs/PassGlyph.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Glyph, GlyphProps, GLYPH_STROKE_LIGHT } from './GlyphBase'; + +/** Pass — declined to act. The null verdict in the same square chassis. */ +export function PassGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/ResolveGlyph.tsx b/src/app/components/icons/glyphs/ResolveGlyph.tsx new file mode 100644 index 0000000..bfcfd19 --- /dev/null +++ b/src/app/components/icons/glyphs/ResolveGlyph.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Resolve — the action stands and the turn closes. Replaces the sparkles. */ +export function ResolveGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/SkullGlyph.tsx b/src/app/components/icons/glyphs/SkullGlyph.tsx new file mode 100644 index 0000000..c0f1b9f --- /dev/null +++ b/src/app/components/icons/glyphs/SkullGlyph.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Skull — an influence lost, or a player eliminated. Replaces the skull emoji. */ +export function SkullGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/SpeakerGlyph.tsx b/src/app/components/icons/glyphs/SpeakerGlyph.tsx new file mode 100644 index 0000000..42af439 --- /dev/null +++ b/src/app/components/icons/glyphs/SpeakerGlyph.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { Glyph, GlyphProps, GLYPH_STROKE_LIGHT } from './GlyphBase'; + +/** Speaker — sound is on. Replaces the loudspeaker emoji in the header. */ +export function SpeakerGlyph(props: GlyphProps) { + return ( + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/SpeakerMutedGlyph.tsx b/src/app/components/icons/glyphs/SpeakerMutedGlyph.tsx new file mode 100644 index 0000000..3eb2d1c --- /dev/null +++ b/src/app/components/icons/glyphs/SpeakerMutedGlyph.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Speaker muted — sound is off. Replaces the muted-speaker emoji in the header. */ +export function SpeakerMutedGlyph(props: GlyphProps) { + return ( + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/StealGlyph.tsx b/src/app/components/icons/glyphs/StealGlyph.tsx new file mode 100644 index 0000000..5e6c7d4 --- /dev/null +++ b/src/app/components/icons/glyphs/StealGlyph.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Steal — coins pulled off a rival. Replaces the flying money. */ +export function StealGlyph(props: GlyphProps) { + return ( + + {/* the coin being taken */} + + {/* the pull */} + + + + ); +} diff --git a/src/app/components/icons/glyphs/TargetGlyph.tsx b/src/app/components/icons/glyphs/TargetGlyph.tsx new file mode 100644 index 0000000..0c99c79 --- /dev/null +++ b/src/app/components/icons/glyphs/TargetGlyph.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Target — accuracy. Used by the challenge-accuracy award. */ +export function TargetGlyph(props: GlyphProps) { + return ( + + + + + + + + + ); +} diff --git a/src/app/components/icons/glyphs/TruthGlyph.tsx b/src/app/components/icons/glyphs/TruthGlyph.tsx new file mode 100644 index 0000000..99966fb --- /dev/null +++ b/src/app/components/icons/glyphs/TruthGlyph.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Glyph, GlyphProps, GLYPH_STROKE_LIGHT } from './GlyphBase'; + +/** Truth — the claim was genuine. Replaces the check mark. */ +export function TruthGlyph(props: GlyphProps) { + return ( + + + + + ); +} diff --git a/src/app/components/icons/glyphs/TurnGlyph.tsx b/src/app/components/icons/glyphs/TurnGlyph.tsx new file mode 100644 index 0000000..dfd3f9a --- /dev/null +++ b/src/app/components/icons/glyphs/TurnGlyph.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { Glyph, GlyphProps } from './GlyphBase'; + +/** Turn — a turn begins. Replaces the play triangle. */ +export function TurnGlyph(props: GlyphProps) { + return ( + + + + ); +} diff --git a/src/app/components/icons/glyphs/index.ts b/src/app/components/icons/glyphs/index.ts new file mode 100644 index 0000000..cb3c698 --- /dev/null +++ b/src/app/components/icons/glyphs/index.ts @@ -0,0 +1,84 @@ +/** + * Functional glyph set. + * + * These replace the system emoji that were doing functional work in the action + * log, the header, the game-over screen and the awards list. They are drawn in + * one line language; if you add a glyph, it must follow all of these: + * + * 1. TWO STROKE WIDTHS ONLY, at a 2:1 ratio — `GLYPH_STROKE_HEAVY` (6) for + * object lines and the load-bearing mark, `GLYPH_STROKE_LIGHT` (3) for + * secondary detail and frames. No third weight, ever. + * 2. SQUARE CORNERS ALWAYS — `stroke-linejoin="miter"`, `stroke-linecap="butt"`. + * No rounded caps or joins anywhere. (`` sets these; don't override.) + * 3. NEVER a gradient, a blur, or a soft shadow. + * 4. PATTERNS, NOT OPACITY, for tints. Use `` + `useHatchId()`: + * a 45-degree line screen reads as the halftone it is imitating, where + * `fill-opacity` just reads as faded. + * 5. `currentColor` for every stroke and fill, so the glyph inherits the + * caller's colour. No hard-coded hex. + * 6. 64x64 viewBox, ~6px margin, coordinates on whole (ideally even) units. + * 7. LEGIBLE AT 16px — that is where most of these render. One strong + * silhouette beats detail. More than ~8 path elements means simplify. + * 8. No `` (it does not survive a font change) and no ``. + * + * `GlyphSheet.tsx` in this directory renders the whole set at 16/24/48 on dark + * and light. It is a development aid — it is not routed and should not be. + */ + +export { Glyph, HatchPattern, useHatchId, GLYPH_STROKE_HEAVY, GLYPH_STROKE_LIGHT } from './GlyphBase'; +export type { GlyphProps } from './GlyphBase'; + +/* Characters (ART-DIRECTION.md §1.2) — one 1-bit silhouette each, and the + * COLOURBLIND CHANNEL for the six character hues. Two of those hues are 1° of + * hue apart (Assassin 206°/S15 vs Captain 205°/S56, §2.4), so these six carry + * the categorical load on their own and are held to a harder standard than the + * rest of the set: no two may share a silhouette CLASS. As drawn they are one + * open profile (Duke), one downward mass (Assassin), one horizontal line + * (Captain), one broken ring (Ambassador), one convex block (Contessa) and one + * horizontal lens (Inquisitor). Consume via `CHARACTER_GLYPHS` in ../index.ts. + * + * `DukeGlyph` is additionally bound by the crown RULING in §1.2 — see its + * header and `CrownGlyph`'s. */ +export { DukeGlyph } from './DukeGlyph'; +export { AssassinGlyph } from './AssassinGlyph'; +export { CaptainGlyph } from './CaptainGlyph'; +export { AmbassadorGlyph } from './AmbassadorGlyph'; +export { ContessaGlyph } from './ContessaGlyph'; +export { InquisitorGlyph } from './InquisitorGlyph'; + +// Verdicts — square/lozenge chassis +export { ChallengeGlyph } from './ChallengeGlyph'; +export { TruthGlyph } from './TruthGlyph'; +export { BluffGlyph } from './BluffGlyph'; +export { PassGlyph } from './PassGlyph'; +export { BlockGlyph } from './BlockGlyph'; + +// Actions +export { CoupGlyph } from './CoupGlyph'; +export { AssassinateGlyph } from './AssassinateGlyph'; +export { StealGlyph } from './StealGlyph'; +export { ExchangeGlyph } from './ExchangeGlyph'; +export { ExamineGlyph } from './ExamineGlyph'; +export { ConvertGlyph } from './ConvertGlyph'; +export { EmbezzleGlyph } from './EmbezzleGlyph'; +export { CoinGlyph } from './CoinGlyph'; +export { ClaimGlyph } from './ClaimGlyph'; +export { DeclareGlyph } from './DeclareGlyph'; +export { ResolveGlyph } from './ResolveGlyph'; + +// State and outcome +export { SkullGlyph } from './SkullGlyph'; +export { CrownGlyph } from './CrownGlyph'; +export { DeckGlyph } from './DeckGlyph'; +export { TurnGlyph } from './TurnGlyph'; +export { GameStartGlyph } from './GameStartGlyph'; +export { BotGlyph } from './BotGlyph'; + +// Chrome +export { SpeakerGlyph } from './SpeakerGlyph'; +export { SpeakerMutedGlyph } from './SpeakerMutedGlyph'; + +// Awards +export { TargetGlyph } from './TargetGlyph'; +export { DiceGlyph } from './DiceGlyph'; +export { ExitGlyph } from './ExitGlyph'; diff --git a/src/app/components/icons/index.ts b/src/app/components/icons/index.ts index ca788a5..c2b4ba5 100644 --- a/src/app/components/icons/index.ts +++ b/src/app/components/icons/index.ts @@ -5,6 +5,15 @@ import { CaptainIcon } from './CaptainIcon'; import { AmbassadorIcon } from './AmbassadorIcon'; import { ContessaIcon } from './ContessaIcon'; import { InquisitorIcon } from './InquisitorIcon'; +import { + AmbassadorGlyph, + AssassinGlyph, + CaptainGlyph, + ContessaGlyph, + DukeGlyph, + InquisitorGlyph, + type GlyphProps, +} from './glyphs'; export { DukeIcon } from './DukeIcon'; export { AssassinIcon } from './AssassinIcon'; @@ -16,6 +25,9 @@ export { CardBack } from './CardBack'; export { CoinIcon } from './CoinIcon'; export { CoupLogo } from './CoupLogo'; +// Functional glyph set (see ./glyphs/index.ts for the drawing rules) +export * from './glyphs'; + export const CHARACTER_SVG_ICONS: Record> = { [Character.Duke]: DukeIcon, [Character.Assassin]: AssassinIcon, @@ -24,3 +36,29 @@ export const CHARACTER_SVG_ICONS: Record> = { + [Character.Duke]: DukeGlyph, + [Character.Assassin]: AssassinGlyph, + [Character.Captain]: CaptainGlyph, + [Character.Ambassador]: AmbassadorGlyph, + [Character.Contessa]: ContessaGlyph, + [Character.Inquisitor]: InquisitorGlyph, +}; diff --git a/src/app/fonts/OFL.txt b/src/app/fonts/OFL.txt new file mode 100644 index 0000000..4d4e319 --- /dev/null +++ b/src/app/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/app/fonts/Oswald-Variable-latin-ext.woff2 b/src/app/fonts/Oswald-Variable-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fae7803f34373ce126d05bfed6d2278f32167e7d GIT binary patch literal 24048 zcmV(*K;FN1Pew8T0RR910A26^6951J0NoG(09}Cq0RR9100000000000000000000 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 Wrp_5Jd?w|iTdHzRq-Kj^1^@u~1j5w- literal 0 HcmV?d00001 diff --git a/src/app/fx/emitters.ts b/src/app/fx/emitters.ts new file mode 100644 index 0000000..c7647c2 --- /dev/null +++ b/src/app/fx/emitters.ts @@ -0,0 +1,279 @@ +/** + * fx/emitters.ts — the emitter vocabulary. Each function is ONE recognisable + * shape; fx/tuning.ts decides which shape a beat gets and how much of it. + * + * Speeds are px/s in viewport space, sizes are CSS px, lives are seconds. + * Gravity is px/s² and is positive DOWN, because this is screen space and the + * table is a table — everything settles. + * + * Every function here fills the module-scope `SP` descriptor and calls + * `spawn()`. Nothing allocates, and a saturated pool simply returns early: the + * confetti cannon stops firing rather than queueing frames you can feel. + */ + +import { + FADE, + KIND, + SP, + rnd, + rndRange, + spDefaults, + spawn, + type FadeIndex, +} from './particles'; +import { COL, type ColorIndex } from './palette'; + +const TAU = Math.PI * 2; + +/* ── sparks ─────────────────────────────────────────────────────────────── */ + +export interface SparkOptions { + speed: number; + life: number; + size: number; + grav: number; + /** Radians. `undefined` → a full circle. */ + spread?: number; + /** Radians. The centre of the fan; only meaningful with `spread`. */ + dir?: number; + alpha?: number; + drag?: number; +} + +/** Spark dots thrown out of a point. The contact material. */ +export function sparks(x: number, y: number, count: number, col: ColorIndex, o: SparkOptions): void { + const spread = o.spread ?? TAU; + const dir = o.dir ?? 0; + const alpha = o.alpha ?? 1; + const drag = o.drag ?? 3.4; + for (let i = 0; i < count; i++) { + spDefaults(); + const a = spread >= TAU ? rnd() * TAU : dir + (rnd() - 0.5) * spread; + const v = o.speed * (0.55 + rnd() * 0.65); + SP.kind = KIND.DOT; + SP.col = col; + SP.x = x + rndRange(-2, 2); + SP.y = y + rndRange(-2, 2); + SP.vx = Math.cos(a) * v; + SP.vy = Math.sin(a) * v; + SP.life = o.life * (0.75 + rnd() * 0.5); + SP.size0 = o.size * (0.7 + rnd() * 0.6); + SP.size1 = SP.size0 * 0.3; + SP.grav = o.grav; + SP.drag = drag; + SP.alpha = alpha; + SP.fade = FADE.SPARK; + if (!spawn()) return; + } +} + +/* ── ring ───────────────────────────────────────────────────────────────── */ + +/** The shockwave. One hard-edged stroked annulus, r0 → r1 over `life`. */ +export function ring( + x: number, + y: number, + r0: number, + r1: number, + col: ColorIndex, + life: number, + lineWidth: number, + alpha: number, +): boolean { + spDefaults(); + SP.kind = KIND.RING; + SP.col = col; + SP.x = x; + SP.y = y; + SP.size0 = r0; + SP.size1 = r1; + SP.life = life; + SP.alpha = alpha; + SP.fade = FADE.RING; + SP.lineW = lineWidth; + SP.drag = 0; + return spawn(); +} + +/* ── flare ──────────────────────────────────────────────────────────────── */ + +/** + * One stationary starburst at the epicentre. A ring plus scattered sparks reads + * as "something happened somewhere near here"; adding a single flare at the + * exact point is what makes it read as "HERE". Only the two beats that must be + * located precisely get one. + */ +export function flare( + x: number, + y: number, + size: number, + col: ColorIndex, + life: number, + alpha: number, +): boolean { + spDefaults(); + SP.kind = KIND.GLINT; + SP.col = col; + SP.x = x; + SP.y = y; + SP.life = life; + SP.size0 = size; + SP.size1 = size * 0.25; + SP.rot = rnd() * TAU; + SP.rotVel = 1.1; + SP.drag = 0; + SP.grav = 0; + SP.alpha = alpha; + SP.fade = FADE.QUAD; + return spawn(); +} + +/* ── puff ───────────────────────────────────────────────────────────────── */ + +/** Dissipating volume — an influence that went away quietly. */ +export function puff( + x: number, + y: number, + count: number, + col: ColorIndex, + life: number, + from: number, + to: number, + alpha: number, +): void { + for (let i = 0; i < count; i++) { + spDefaults(); + const a = rnd() * TAU; + SP.kind = KIND.PUFF; + SP.col = col; + SP.x = x + rndRange(-8, 8); + SP.y = y + rndRange(-6, 6); + SP.vx = Math.cos(a) * rndRange(14, 48); + SP.vy = Math.sin(a) * rndRange(10, 36) - 22; + SP.life = life * (0.75 + rnd() * 0.5); + SP.size0 = from; + SP.size1 = to; + SP.drag = 3.2; + SP.grav = -12; + SP.alpha = alpha; + SP.fade = FADE.QUAD; + if (!spawn()) return; + } +} + +/* ── cross ──────────────────────────────────────────────────────────────── */ + +/** + * A metallic cross — two orthogonal fans. The block signature: two things met + * at right angles and neither of them bent. + */ +export function sparkCross( + x: number, + y: number, + perArm: number, + col: ColorIndex, + speed: number, + size: number, + life: number, + alpha: number, +): void { + const base = Math.PI * 0.25; + for (let arm = 0; arm < 4; arm++) { + sparks(x, y, perArm, col, { + speed, + life, + size, + alpha, + grav: 180, + drag: 6.5, + spread: 0.42, + dir: base + arm * (Math.PI / 2), + }); + } +} + +/* ── confetti ───────────────────────────────────────────────────────────── */ + +/** + * The win cannon. Paper, not gravel: heavy drag, low gravity, a per-piece + * tumble that drives a per-piece sideways scull (see `particles.update`). Fired + * from the top edge across the full width — a cone from one point reads as a + * firework, and this beat is a ceremony, not an explosion. + * + * The spawn stagger is HALF a screen height, not the 1.35 that arithmetic + * suggests: grav 210 against drag 0.85 gives a terminal velocity of ~247px/s, + * so a piece starting 1.35 screen-heights up needs ~3.9s to enter frame against + * a life of 2.3–3.7s and a quarter of the field expires above the fold. At 0.5 + * the arrival spreads over ~1.5s and nothing dies unseen. + * + * @returns how many pieces actually spawned (the pool may saturate). + */ +const CONFETTI_COLS: readonly ColorIndex[] = [ + COL.BRASS, + COL.BRASS_LIT, + COL.BONE, + COL.BRASS_LIT, + COL.ASH, + COL.BRASS, +]; + +export function confetti(w: number, h: number, count: number, life: number): number { + const spread = 0.86; + const x0 = w * (0.5 - spread / 2); + for (let i = 0; i < count; i++) { + spDefaults(); + SP.kind = KIND.CONFETTI; + SP.col = CONFETTI_COLS[(rnd() * CONFETTI_COLS.length) | 0]; + SP.x = x0 + rnd() * w * spread; + SP.y = -rnd() * h * 0.5 - 12; + SP.vx = rndRange(-70, 70); + SP.vy = rndRange(190, 330); + SP.life = life * (0.72 + rnd() * 0.45); + // 5.5–10.5, not 4.5–8.5. The smaller range was measured on a phone-shaped + // viewport; on the 1495px desktop capture a 4.5px rect is a speck and the + // ceremony reads as dust. The piece still has to be small enough that the + // tumble foreshortening is what identifies it as paper, so the top of the + // range is bounded by that rather than by taste. + SP.size0 = rndRange(5.5, 10.5); + SP.size1 = SP.size0; + SP.aspect = rndRange(1.15, 2.0); + SP.grav = 210; + SP.drag = 0.85; + SP.rot = rnd() * TAU; + SP.rotVel = rndRange(-3.4, 3.4); + SP.tumble = rnd() * TAU; + SP.tumVel = rndRange(4.5, 11); + SP.flutter = rndRange(120, 320); + SP.alpha = 1; + SP.fade = FADE.PAPER; + if (!spawn()) return i; + } + return count; +} + +/* ── settle ─────────────────────────────────────────────────────────────── */ + +/** Nothing exploded; a player just stopped being at the table. */ +export function settle(w: number, h: number, count: number, col: ColorIndex): number { + for (let i = 0; i < count; i++) { + spDefaults(); + SP.kind = KIND.PUFF; + SP.col = col; + SP.x = rnd() * w; + SP.y = h * rndRange(0.12, 0.62); + SP.vx = rndRange(-10, 10); + SP.vy = rndRange(6, 26); + SP.life = rndRange(1.0, 1.8); + SP.size0 = rndRange(10, 26); + SP.size1 = SP.size0 * 1.7; + SP.drag = 1.4; + SP.grav = 8; + SP.alpha = 0.14; + SP.fade = FADE.QUAD; + if (!spawn()) return i; + } + return count; +} + +/** Re-exported so the demo harness and the tests can name a curve. */ +export type { FadeIndex }; diff --git a/src/app/fx/flash.ts b/src/app/fx/flash.ts new file mode 100644 index 0000000..3195809 --- /dev/null +++ b/src/app/fx/flash.ts @@ -0,0 +1,168 @@ +/** + * fx/flash.ts — the screen flash plate. + * + * ── WHY `mix-blend-mode: screen` ────────────────────────────────────────── + * + * A crimson wash at 0.3 alpha over the dark teal table (`--ground #090D0E`, + * `--surface #17231F`) is a GREY WASH: normal alpha compositing pulls every + * pixel toward the same colour, so the felt, the cards and the type all lose + * their separation at once and the table looks like it went out of focus. + * `screen` is `1 − (1−a)(1−b)` — it can only lift. The table keeps its own value + * structure and the flash reads as a light being thrown across it, which is + * what a light being thrown across it looks like. + * + * ── THE ENVELOPE ────────────────────────────────────────────────────────── + * + * attack: linear over the first 8% of the duration + * release: SQUARED over the remaining 92% + * + * The attack is not zero because an instant-on flash on a 60Hz panel is one + * frame of full opacity and reads as a dropped frame, not as an impact. 8% of + * 500ms is 40ms — two or three frames, enough for the eye to register a rise. + * The squared release front-loads the decay so the plate is at 25% by the + * halfway mark and out of the way of whatever the player has to read next. + * + * ── REDUCED MOTION (ART-DIRECTION §7) ───────────────────────────────────── + * + * The flash STAYS, at 55% strength and 1.6× duration. It is opacity-only — no + * translation, no scale, nothing that moves — so it reads as a light coming up + * rather than as a hit. Remove it and the player who asked for less motion + * becomes the one player at the table with no non-auditory evidence that a Coup + * landed on them. Motion collapses; information does not. + * + * ── STATE WITHOUT A PLATE ───────────────────────────────────────────────── + * + * The envelope runs whether or not a DOM plate exists. On the server, and in + * the node test environment, `fire()` still arms it and `active()` still + * reports true — which is exactly what the reduced-motion test asserts. The + * plate is a renderer, not the state. + */ + +import type { FxTone } from './tuning'; +import * as overlay from './overlay'; + +/** + * Radial gradients, one per tone. The centre percentages are placeholders that + * `fire()` rewrites to the epicentre of the beat — a flash that always blooms + * from the middle of the screen tells you something happened; one that blooms + * from the seat that was couped tells you where. + */ +const TONE: Readonly> = { + // --brass #D6A12A lifted toward --brass-lit #F2C744 in the core. + brass: + 'radial-gradient(62% 48% at CX CY, rgba(242,199,68,.95) 0%, rgba(214,161,42,.55) 42%, rgba(214,161,42,0) 78%)', + // --crimson #F27366. The only tone a bystander beat may not wear. + crimson: + 'radial-gradient(66% 52% at CX CY, rgba(255,150,138,.9) 0%, rgba(242,115,102,.5) 40%, rgba(242,115,102,0) 76%)', + // --ink #F1EBDE. A refusal, a block: bright and colourless. + bone: 'radial-gradient(60% 46% at CX CY, rgba(241,235,222,.75) 0%, rgba(241,235,222,.3) 44%, rgba(241,235,222,0) 78%)', + // --ink-mute #9FADA6. Steel — cold, and quieter than bone. + ash: 'radial-gradient(60% 46% at CX CY, rgba(214,224,218,.7) 0%, rgba(159,173,166,.32) 44%, rgba(159,173,166,0) 78%)', +}; + +const REDUCED_STRENGTH = 0.55; +const REDUCED_STRETCH = 1.6; + +let peak = 0; +let t = 0; +let dur = 0; +let alpha = 0; +let written = -1; +let reduced = false; +let fired = 0; + +export function setReduced(on: boolean): void { + reduced = on; +} + +/** + * Arm the plate. + * + * @param strength peak opacity, from the tuning table + * @param tone which gradient + * @param durMs duration in ms + * @param x,y epicentre in viewport px; `null` centres the gradient + */ +export function fire( + strength: number, + tone: FxTone, + durMs: number, + x: number | null, + y: number | null, +): void { + if (!(strength > 0) || !(durMs > 0)) return; + fired++; + + const s = reduced ? strength * REDUCED_STRENGTH : strength; + const d = (reduced ? durMs * REDUCED_STRETCH : durMs) / 1000; + + // Two flashes inside one beat take the LOUDER peak and the LONGER remaining + // tail, rather than restarting: a Coup that lands and then costs an influence + // is one event, and re-attacking mid-release would read as a strobe. + peak = peak > s ? peak : s; + const remaining = dur - t; + dur = remaining > d ? remaining : d; + t = 0; + + const plate = overlay.flashEl(); + if (!plate) return; + const w = overlay.width(); + const h = overlay.height(); + let cx = '50%'; + let cy = '46%'; + if (x !== null && y !== null && w > 0 && h > 0) { + cx = `${Math.round((x / w) * 100)}%`; + cy = `${Math.round((y / h) * 100)}%`; + } + plate.style.setProperty('background', TONE[tone].replace('CX', cx).replace('CY', cy)); +} + +export function active(): boolean { + return dur > 0; +} + +/** Current plate opacity. The test reads the envelope through this. */ +export function alphaNow(): number { + return alpha; +} + +export function firedCount(): number { + return fired; +} + +/** @returns whether the flash is still live after this frame. */ +export function tick(dt: number): boolean { + if (dur <= 0) return false; + t += dt; + const u = t / dur; + if (u >= 1) { + dur = 0; + peak = 0; + t = 0; + alpha = 0; + write(0); + return false; + } + const k = u < 0.08 ? u / 0.08 : (1 - (u - 0.08) / 0.92) ** 2; + alpha = Math.round(peak * k * 100) / 100; + write(alpha); + return true; +} + +function write(a: number): void { + if (a === written) return; + written = a; + overlay.flashEl()?.style.setProperty('opacity', String(a)); +} + +export function reset(): void { + dur = 0; + peak = 0; + t = 0; + alpha = 0; + overlay.flashEl()?.style.setProperty('opacity', '0'); + // −1, not 0: the plate may be about to be destroyed and rebuilt, and a fresh + // plate has never been written to whatever `written` remembers. + written = -1; + fired = 0; +} diff --git a/src/app/fx/floaters.ts b/src/app/fx/floaters.ts new file mode 100644 index 0000000..3d96084 --- /dev/null +++ b/src/app/fx/floaters.ts @@ -0,0 +1,358 @@ +/** + * fx/floaters.ts — pooled floating text: coin deltas and the shouts that earn + * one ("CAUGHT BLUFFING!", "BLOCKED!", "LOST", "ELIMINATED"). + * + * DOM, NOT CANVAS. This is real text at real sizes. It has to inherit the app's + * display face, it has to stay crisp on a DPR3 phone (the FX canvas is capped + * at DPR2, which would make a 24px glyph visibly soft), and being real text + * means it survives a browser zoom. Twelve nodes, created on first use and + * recycled forever; a thirteenth simultaneous float takes the oldest. + * + * ── THE FADE HOLDS ──────────────────────────────────────────────────────── + * + * travel: RISE 46px over LIFE 0.9s, eased 1−(1−u)³ + * alpha: ramp in over the first 10%, HOLD AT 1 UNTIL 45%, then fall on ^1.4 + * + * The hold is the whole point. The number has to be READ, and a linear fade + * over 900ms is legible for about 300ms of it — the glyphs spend the back half + * of their life as a smudge that is still costing a composited layer. Holding + * to 45% and then falling steeply gives ~400ms of full-contrast reading and + * ~500ms of getting out of the way. + * + * ── KEEP-OUT ────────────────────────────────────────────────────────────── + * + * A `−3` that lands on top of the coin counter it is describing is worse than + * no float at all. Every spawn is clamped into a caller-supplied band — in Coup, + * between the phase banner's bottom edge and the hand's top edge — and offset + * UPWARD from the beat by the full RISE, so the float ends its life clear of + * the thing it came from rather than starting clear and drifting back over it. + * + * ── ANTI-COLLISION ──────────────────────────────────────────────────────── + * + * Coup pays several players in one beat — an Embezzle empties the reserve, a + * Coup moves 7 coins and an influence at once. Two floats within STACK px on Y + * and 120px on X are stacked instead of overlapped. 44 rather than 30: at + * 1.5rem the glyphs are ~24px tall and 30px of separation still lets a rising + * float clip the one above it. + * + * ── HALF-WIDTH IS MEASURED, NEVER ASSUMED ───────────────────────────────── + * + * Coup's shouts are WORDS, not numbers. "CAUGHT BLUFFING!" at scale 1.35 is + * ~230px wide, it is anchored on a seat that may be at x≈90, and it is centred + * with `translateX(-50%)` — so a fixed 62px guess at the half-width puts its + * first glyph off the left edge of the viewport at the single most dramatic + * beat in the game. The text is written to the node first, then the node's own + * `offsetWidth` is read, then x is clamped. One layout read per spawn, and a + * spawn is a beat. + */ + +const POOL = 12; + +/** px travelled over LIFE. */ +export const RISE = 46; +export const LIFE = 0.9; + +/** Two floats closer than this on Y (and 120px on X) are stacked. */ +export const STACK_Y = 44; +export const STACK_X = 120; + +/** px kept between a float's glyphs and the viewport edge. */ +const EDGE = 8; + +/** The fade holds at full alpha until here. */ +export const HOLD_UNTIL = 0.45; +const RAMP_IN = 0.1; +const FALL_EXP = 1.4; + +/** Under reduced motion the text stays and the rise goes. */ +const REDUCED_LIFE = 0.75; + +export type FloatTone = 'brass' | 'crimson' | 'bone' | 'ash'; + +const TONE_HEX: Readonly> = { + brass: '#F2C744', + crimson: '#F27366', + bone: '#F1EBDE', + ash: '#9FADA6', +}; + +/* ── the DOM surface ────────────────────────────────────────────────────── */ + +export interface FloaterStyle { + setProperty(property: string, value: string): void; + removeProperty(property: string): void; +} + +export interface FloaterNode { + textContent: string | null; + readonly offsetWidth: number; + readonly style: FloaterStyle; + setShout(on: boolean): void; +} + +/** + * Supplies nodes. `null` — the default, and the state in the node test + * environment and on the server — means the layer runs headless: records are + * still created, positioned, stacked and ticked, so every rule in this file is + * testable and `text()` still reports what would have been shown. Only the + * pixels are absent. + */ +export interface FloaterHost { + create(): FloaterNode; +} + +let host: FloaterHost | null = null; + +export function setHost(h: FloaterHost | null): void { + if (h === host) return; + host = h; + // Nodes belong to the host that made them. + for (let i = 0; i < records.length; i++) records[i].node = null; +} + +/* ── the keep-out band ──────────────────────────────────────────────────── */ + +export interface Band { + top: number; + bottom: number; + width: number; +} + +/** Mutated in place by the band provider — one object, no per-spawn garbage. */ +const band: Band = { top: 12, bottom: 400, width: 360 }; + +export type BandProvider = (out: Band) => void; + +let provider: BandProvider | null = null; + +/** + * Supply the keep-out band. Called once per spawn, so the provider may read + * layout — it is a beat, not a frame. + */ +export function setBand(fn: BandProvider | null): void { + provider = fn; +} + +function readBand(): Band { + if (provider) provider(band); + if (!(band.bottom > band.top + 24)) band.bottom = band.top + 24; + return band; +} + +/* ── records ────────────────────────────────────────────────────────────── */ + +interface Record_ { + node: FloaterNode | null; + text: string; + tone: FloatTone; + scale: number; + x: number; + y: number; + t: number; + dur: number; + rise: number; + alpha: number; + /** last written quantised transform-y and alpha */ + wt: number; + wa: number; +} + +const records: Record_[] = []; +const live: Record_[] = []; +const free: Record_[] = []; + +let reduced = false; +let suppressed = false; + +export function setReduced(on: boolean): void { + reduced = on; +} + +/** The game-over overlay owns its moment; a coin delta across it is pure noise. */ +export function setSuppressed(on: boolean): void { + suppressed = on; +} + +export function isSuppressed(): boolean { + return suppressed; +} + +function ensureRecords(): void { + if (records.length) return; + for (let i = 0; i < POOL; i++) { + const rec: Record_ = { + node: null, + text: '', + tone: 'bone', + scale: 1, + x: 0, + y: 0, + t: 0, + dur: LIFE, + rise: RISE, + alpha: 0, + wt: Number.NaN, + wa: Number.NaN, + }; + records.push(rec); + free.push(rec); + } +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** + * @param x,y viewport px — where the beat happened + * @returns false when suppressed, empty, or the pool is exhausted + */ +export function spawn(text: string, x: number, y: number, tone: FloatTone, scale = 1): boolean { + if (!text || suppressed) return false; + ensureRecords(); + + const rec = free.pop() ?? live.shift(); + if (!rec) return false; + + const b = readBand(); + const rise = reduced ? 0 : RISE; + + // A first guess at the half-width, refined below once the glyphs exist. + rec.x = clamp(x, 62, Math.max(64, b.width - 62)); + + // Start low enough that the whole RISE stays inside the band. + let ty = clamp(y - 10, b.top + rise, b.bottom); + for (let guard = 0; guard < POOL; guard++) { + let hit = false; + for (let i = 0; i < live.length; i++) { + const o = live[i]; + if (Math.abs(o.y - ty) < STACK_Y && Math.abs(o.x - rec.x) < STACK_X) { + hit = true; + break; + } + } + if (!hit) break; + ty -= STACK_Y; + // Ran out of headroom going up: wrap to the bottom of the band rather than + // stacking through the phase banner. + if (ty < b.top + rise) { + ty = b.bottom; + break; + } + } + + rec.y = ty; + rec.rise = rise; + rec.dur = reduced ? REDUCED_LIFE : LIFE; + rec.t = 0; + rec.alpha = 0; + rec.wt = Number.NaN; + rec.wa = Number.NaN; + rec.text = text; + rec.tone = tone; + rec.scale = scale; + + if (!rec.node && host) rec.node = host.create(); + const node = rec.node; + if (node) { + node.textContent = text; // text as text, never HTML + node.style.setProperty('color', TONE_HEX[tone]); + if (scale === 1) node.style.removeProperty('font-size'); + else node.style.setProperty('font-size', `${(1.5 * scale).toFixed(2)}rem`); + node.setShout(scale !== 1); + // Now that the glyphs are in, clamp against what they actually MEASURE. + const half = Math.ceil(node.offsetWidth / 2) + EDGE; + if (half > 62) rec.x = clamp(rec.x, half, Math.max(half, b.width - half)); + } + + live.push(rec); + return true; +} + +export function active(): boolean { + return live.length > 0; +} + +export function count(): number { + return live.length; +} + +/** Read-only view of a live float. Tests and the demo harness only. */ +export function peek(i: number): Readonly | null { + return live[i] ?? null; +} + +/** The alpha envelope, as a pure function of normalised life. */ +export function alphaAt(u: number): number { + if (u <= 0) return 0; + if (u >= 1) return 0; + if (u < RAMP_IN) return Math.round((u / RAMP_IN) * 100) / 100; + if (u < HOLD_UNTIL) return 1; + return Math.round((1 - (u - HOLD_UNTIL) / (1 - HOLD_UNTIL)) ** FALL_EXP * 100) / 100; +} + +/** @returns whether any float is still live after this frame. */ +export function tick(dt: number): boolean { + if (live.length === 0) return false; + let i = 0; + while (i < live.length) { + const rec = live[i]; + rec.t += dt; + const u = rec.t / rec.dur; + if (u >= 1) { + rec.alpha = 0; + if (rec.wa !== 0) { + rec.wa = 0; + rec.node?.style.setProperty('opacity', '0'); + } + live.splice(i, 1); + free.push(rec); + continue; + } + // Ease-out travel, 1−(1−u)³ — most of the rise happens while the text is + // still fully opaque, so the movement is what draws the eye to it. + const e = 1 - (1 - u) * (1 - u) * (1 - u); + const dy = Math.round(-rec.rise * e); + const a = alphaAt(u); + rec.alpha = a; + + const node = rec.node; + if (node) { + if (dy !== rec.wt) { + rec.wt = dy; + node.style.setProperty( + 'transform', + `translate3d(${rec.x}px,${rec.y + dy}px,0) translateX(-50%)`, + ); + } + if (a !== rec.wa) { + rec.wa = a; + node.style.setProperty('opacity', String(a)); + } + } else { + rec.wt = dy; + rec.wa = a; + } + i++; + } + return live.length > 0; +} + +export function reset(): void { + while (live.length) { + const rec = live.pop(); + if (!rec) break; + rec.alpha = 0; + rec.wa = 0; + rec.node?.style.setProperty('opacity', '0'); + free.push(rec); + } +} + +/** Drop the pool entirely — `fx.unmount()`, when the nodes are about to go. */ +export function destroy(): void { + reset(); + records.length = 0; + free.length = 0; + live.length = 0; +} diff --git a/src/app/fx/index.ts b/src/app/fx/index.ts new file mode 100644 index 0000000..0bc2bc0 --- /dev/null +++ b/src/app/fx/index.ts @@ -0,0 +1,493 @@ +/** + * fx/ — the impact layer. Coup had none of this: every dramatic beat resolved + * as a text change. + * + * ── THE SEAM ────────────────────────────────────────────────────────────── + * + * fx.mount(rootEl, shakeTargetEl) where the overlay lives, what shakes + * fx.cue(event, opts) fire one beat + * fx.setReducedMotion(bool) + * fx.reset() between games / on a rematch + * fx.stats() what is actually live right now + * fx.unmount() + * + * The whole cue → effect map is DATA in fx/tuning.ts, written before any of the + * rendering code. This file is a switchboard: it resolves a cue to a row, hands + * the row's emitter list to fx/emitters.ts, and pumps the clock. It contains no + * tuning numbers, which is the point — the numbers are in one file where they + * can be argued about. + * + * ── LAZY BY CONSTRUCTION ────────────────────────────────────────────────── + * + * Importing this module costs the module records and two arrays. There is no + * canvas, no DOM node, no stylesheet, no resize listener, no clock subscriber + * and no particle pool until the first cue fires, and all of it except the + * overlay node comes back down when the last effect dies (fx/overlay.ts + * explains the one exception and what `unmount()` does about it). + * + * `mount()` is a pure registration — it stores two element references and + * builds nothing. + * + * ── PURITY ──────────────────────────────────────────────────────────────── + * + * Nothing here imports a component, a store, or `src/shared`. The FX layer does + * not know what a Duke is; it knows about `influence_lost` and whether it + * happened to you. Wiring the game's events onto `cue()` is a separate layer. + * + * ── WHAT IS NOT HERE YET ────────────────────────────────────────────────── + * + * GAME-FEEL-PLAN §3.6's two-channel hold — holding a direction-less cue for one + * synchronous task in case an event arrives to say who the victim was — belongs + * with the game wiring, because only that layer has the event side. Until it + * exists, `condition` defaults to `'theirs'`: an un-directed cue plays its + * QUIET form. Failing quiet is the correct default for a layer whose first rule + * is restraint. + */ + +import { subscribe, unsubscribe } from '../anim/clock'; +import { fireHaptic } from '../utils/haptic'; +import * as emit from './emitters'; +import * as flash from './flash'; +import * as floaters from './floaters'; +import * as overlay from './overlay'; +import * as particles from './particles'; +import * as shake from './shake'; +import { + FX_TABLE, + LAND_CEILING, + QUIET_TRAUMA_CEILING, + rowFor, + type FxCondition, + type FxEmitter, + type FxEvent, + type FxFloatTone, + type FxRow, +} from './tuning'; +import type { FloatTone } from './floaters'; + +export { + FX_EVENTS, + FX_TABLE, + LAND_CEILING, + QUIET_TRAUMA_CEILING, + rowFor, + rowsFor, + type FxCondition, + type FxEvent, + type FxRow, + type LoudRow, + type QuietRow, +} from './tuning'; + +export interface CueOptions { + /** Default `'theirs'` — see the header. An un-directed cue plays quiet. */ + condition?: FxCondition; + /** Epicentre in viewport px. Defaults to the centre of the viewport. */ + x?: number; + y?: number; + /** Travel vector, for the rows whose spray is thrown along the motion. */ + dx?: number; + dy?: number; + /** Text for a float row whose `text` is `null`. */ + text?: string; + /** Signed magnitude, for `coins_changed`: picks the tone and the default text. */ + amount?: number; +} + +export interface FxStats { + particles: number; + peak: number; + dropped: number; + cap: number; + floats: number; + trauma: number; + flash: boolean; + cues: number; + reduced: boolean; + pumping: boolean; + mounted: boolean; +} + +/* ── the cue log (GAME-FEEL-PLAN §7 gate) ───────────────────────────────── */ + +export interface CueRecord { + event: FxEvent; + condition: FxCondition; + matched: boolean; + t: number; +} + +const CUE_LOG_CAP = 400; +const cueLog: CueRecord[] = []; + +export function log(): readonly CueRecord[] { + return cueLog; +} + +/* ── state ──────────────────────────────────────────────────────────────── */ + +let reduced = false; +let pumping = false; +let painted = false; +let mounted = false; +let secondFall: ReturnType | null = null; + +/* ── mount ──────────────────────────────────────────────────────────────── */ + +/** + * Register where the overlay is appended and what takes the shake. + * + * `shakeTargetEl` must be the TABLE container — not the viewport, not the whole + * game screen. fx/shake.ts explains why, and the two hazards of putting a + * transform on an ancestor. + * + * Builds nothing. The first cue builds everything. + */ +export function mount(rootEl: HTMLElement | null, shakeTargetEl: shake.ShakeElement | null): void { + overlay.setHost(rootEl); + shake.setTarget(shakeTargetEl); + mounted = true; +} + +export function unmount(): void { + reset(); + shake.setTarget(null); + floaters.setHost(null); + floaters.destroy(); + overlay.setHost(null); + overlay.destroy(); + mounted = false; +} + +export function isMounted(): boolean { + return mounted; +} + +/* ── reduced motion (ART-DIRECTION §7) ──────────────────────────────────── */ + +/** + * Motion collapses; information does not. + * + * particles — off. Pure decoration. + * shake — off. Pure motion, and the one effect that can make a reader ill. + * flash — STAYS, at 55% strength and 1.6× duration. Opacity-only, so it + * reads as a light coming up rather than a hit, and it is the only + * non-auditory signal that a Coup landed on you. + * floaters — text stays, the 46px rise goes. The word is the information. + * haptics — stay. A haptic is not motion. + */ +export function setReducedMotion(on: boolean): void { + if (reduced === on) return; + reduced = on; + shake.setReduced(on); + flash.setReduced(on); + floaters.setReduced(on); + if (on) particles.clear(); +} + +export function isReducedMotion(): boolean { + return reduced; +} + +/* ── the clock pump ─────────────────────────────────────────────────────── */ + +function busy(): boolean { + return particles.count() > 0 || painted || shake.active() || flash.active() || floaters.active(); +} + +/** + * `overlay.setActive(true)` is OUTSIDE the `pumping` guard on purpose. Inside + * it, a `reset()` that hides the overlay without dropping the subscription + * leaves the next cue finding `pumping === true`, returning early, and painting + * a full confetti cannon into a `display:none` canvas. Showing is idempotent + * and costs a class-list compare. + */ +function pump(): void { + if (overlay.ensure()) { + overlay.setActive(true); + installFloaterHost(); + } + if (pumping) return; + pumping = true; + subscribe(tick); +} + +function tick(dt: number): void { + const g = overlay.ctx(); + const anyParticles = particles.count() > 0; + if (g && (anyParticles || painted)) { + overlay.clear(); + particles.update(dt); + particles.draw(g, overlay.scale()); + painted = particles.count() > 0; + } else if (anyParticles) { + // Headless (SSR, tests, or a cue before the canvas exists): still integrate, + // so the pool drains and the pump stops instead of wedging on. + particles.update(dt); + } + shake.tick(dt); + flash.tick(dt); + floaters.tick(dt); + + if (!busy()) { + pumping = false; + unsubscribe(tick); + overlay.setActive(false); + } +} + +/* ── the DOM floater host ───────────────────────────────────────────────── */ + +let hostInstalled = false; + +function installFloaterHost(): void { + if (hostInstalled) return; + const layer = overlay.floatEl(); + if (!layer) return; + hostInstalled = true; + floaters.setHost({ + create(): floaters.FloaterNode { + const node = document.createElement('div'); + node.className = 'fx-text'; + layer.appendChild(node); + return { + get offsetWidth(): number { + return node.offsetWidth; + }, + get textContent(): string | null { + return node.textContent; + }, + set textContent(v: string | null) { + node.textContent = v; + }, + style: node.style, + setShout(on: boolean): void { + node.classList.toggle('is-shout', on); + }, + }; + }, + }); +} + +/* ── cue ────────────────────────────────────────────────────────────────── */ + +function centreX(): number { + const w = overlay.width(); + return w > 0 ? w / 2 : 0; +} + +function centreY(): number { + const h = overlay.height(); + return h > 0 ? h / 2 : 0; +} + +function floatTone(tone: FxFloatTone, amount: number): FloatTone { + if (tone !== 'signed') return tone; + return amount < 0 ? 'crimson' : 'brass'; +} + +function floatText(spec: string | null, opts: CueOptions): string { + if (spec !== null) return spec; + if (opts.text) return opts.text; + const a = opts.amount ?? 0; + return a > 0 ? `+${a}` : String(a); +} + +/** + * Fire one beat. + * + * @returns whether the cue matched a row. A `false` is not an error — it is the + * table saying this combination gets nothing (see `rowFor`). + */ +export function cue(event: FxEvent, opts: CueOptions = EMPTY): boolean { + const condition = opts.condition ?? 'theirs'; + const row = rowFor(event, condition); + + cueLog.push({ event, condition, matched: row !== null, t: Math.round(nowMs()) }); + if (cueLog.length > CUE_LOG_CAP) cueLog.shift(); + + if (!row) return false; + + const x = opts.x ?? centreX(); + const y = opts.y ?? centreY(); + + // Build the substrate BEFORE dispatching to any channel. + // + // This used to sit at the end of the function, and it cost the first float of + // every session: `pump()` is what calls `installFloaterHost()`, so + // `floaters.spawn()` below ran against a host that did not exist yet and the + // node was created headless and never rendered. Every subsequent float was + // fine, which is exactly why it survived unit tests and only showed up when + // somebody watched a real game's opening beat. + // + // Laziness is unaffected: the `!row` early-return above is what keeps an + // unmatched cue from building anything, not the position of this call. + pump(); + + if (!reduced) { + for (let i = 0; i < row.particles.length; i++) { + throwEmitter(row.particles[i], x, y, opts); + } + if (row.trauma > 0) { + shake.add(row.trauma, row.traumaCeiling ?? shake.CAP); + } + } + + if (row.flash) { + flash.fire(row.flash.strength, row.flash.tone, row.flash.durationMs, x, y); + } + + if (row.float) { + floaters.spawn( + floatText(row.float.text, opts), + x, + y, + floatTone(row.float.tone, opts.amount ?? 0), + row.float.scale, + ); + } + + // Haptics are not motion and survive §7 untouched. `fireHaptic` owns the + // priority-aware 300ms floor — this layer must not second-guess it. + if (row.haptic) fireHaptic(row.haptic); + + return true; +} + +const EMPTY: CueOptions = Object.freeze({}); + +function nowMs(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} + +function throwEmitter(e: FxEmitter, x: number, y: number, opts: CueOptions): void { + switch (e.emit) { + case 'sparks': { + const dx = opts.dx ?? 0; + const dy = opts.dy ?? 0; + // Directional only when the card actually travelled. A 2.0rad fan thrown + // along a zero vector is a fan pointing right, which reads as a wind. + const travelled = dx * dx + dy * dy > 144; + emit.sparks(x, y, e.count, e.color, { + speed: e.speed, + life: e.life, + size: e.size, + grav: e.grav, + alpha: e.alpha, + spread: e.directional && travelled ? e.spread : undefined, + dir: e.directional && travelled ? Math.atan2(dy, dx) : undefined, + }); + return; + } + case 'ring': + emit.ring(x, y, e.from, e.to, e.color, e.life, e.lineWidth, e.alpha); + return; + case 'flare': + emit.flare(x, y, e.size, e.color, e.life, e.alpha); + return; + case 'puff': + emit.puff(x, y, e.count, e.color, e.life, e.from, e.to, e.alpha); + return; + case 'cross': + emit.sparkCross(x, y, e.perArm, e.color, e.speed, e.size, e.life, e.alpha); + return; + case 'confetti': { + const w = overlay.width() || 1; + const h = overlay.height() || 1; + emit.confetti(w, h, e.count, e.life); + if (e.delayMs && e.delayMs > 0) armSecondFall(e.delayMs, Math.round(e.count * 0.34), e.life); + return; + } + case 'settle': { + const w = overlay.width() || 1; + const h = overlay.height() || 1; + emit.settle(w, h, e.count, e.color); + return; + } + } +} + +/** + * A lighter second wave after the win, so the celebration overlaps the + * game-over overlay's own entrance instead of ending underneath it. Cleared by + * `reset()` — a rematch started inside the window must not rain on the new deal. + */ +function armSecondFall(delayMs: number, count: number, life: number): void { + if (secondFall !== null) clearTimeout(secondFall); + secondFall = setTimeout(() => { + secondFall = null; + if (reduced) return; + if (!overlay.ensure()) return; + emit.confetti(overlay.width() || 1, overlay.height() || 1, count, life * 0.9); + pump(); + }, delayMs); +} + +/* ── lifecycle ──────────────────────────────────────────────────────────── */ + +export function stats(): FxStats { + return { + particles: particles.count(), + peak: particles.peakCount(), + dropped: particles.droppedCount(), + cap: particles.CAP, + floats: floaters.count(), + trauma: shake.level(), + flash: flash.active(), + cues: cueLog.length, + reduced, + pumping, + mounted, + }; +} + +/** Between games, on a rematch, on a route change. Keeps the mount. */ +export function reset(): void { + if (secondFall !== null) { + clearTimeout(secondFall); + secondFall = null; + } + particles.reset(); + floaters.reset(); + shake.reset(); + flash.reset(); + painted = false; + cueLog.length = 0; + if (pumping) { + pumping = false; + unsubscribe(tick); + } + overlay.clear(); + overlay.setActive(false); +} + +/** Suppress floating text — the game-over overlay owns its moment. */ +export const setFloatersSuppressed = floaters.setSuppressed; + +export type { FloatTone } from './floaters'; +export type { FxEmitter, FxFlash, FxFloatSpec, FxTone, QuietTone } from './tuning'; +export { COL, HEX, type ColorIndex } from './palette'; +export { CAP as PARTICLE_CAP, DPR_CAP } from './particles'; +export { MIN_TRAUMA, MAX_X, MAX_Y, MAX_R, FREQ, DECAY } from './shake'; +export { RISE, LIFE, STACK_X, STACK_Y, HOLD_UNTIL } from './floaters'; + +/** Namespaced default, so a call site reads `fx.cue(...)`. */ +const fx = { + mount, + unmount, + isMounted, + cue, + reset, + stats, + log, + setReducedMotion, + isReducedMotion, + setFloatersSuppressed, + setBand: floaters.setBand, +}; + +export default fx; +export { fx }; + +/** Re-exported for the tuning tests, which assert on the table's shape. */ +export const TABLE_SIZE = FX_TABLE.length; diff --git a/src/app/fx/overlay.ts b/src/app/fx/overlay.ts new file mode 100644 index 0000000..201fbe8 --- /dev/null +++ b/src/app/fx/overlay.ts @@ -0,0 +1,211 @@ +/** + * fx/overlay.ts — the FX substrate: one canvas, one flash plate, one text layer. + * + * OWNERSHIP. The FX layer may not touch `globals.css`, `tailwind.config.ts` or + * any component, so it builds its own DOM and its own stylesheet at runtime. + * Everything it creates is prefixed `fx-`, and nothing else in the app selects + * on that prefix. + * + * IDLE COST. Nothing exists until the first effect fires: no node, no canvas + * backing store, no resize listener, no clock subscriber. When the last effect + * dies, `setActive(false)` drops the resize listeners and puts the root on + * `display:none` so the compositor drops the layer and the backing store is + * cleared. + * + * The root NODE itself survives an idle period, and that is a deliberate + * exception to "torn down when the last one dies": a full-viewport DPR2 backing + * store on a desktop is ~14MB, and destroying and re-creating it per beat would + * mean a 14MB allocation on every card that lands. Idle is display:none with no + * listeners and no clock subscriber, which is indistinguishable from absent for + * everything except `document.getElementById`. `destroy()` — called by + * `fx.unmount()` and route teardown — removes the node, the stylesheet and + * every listener, and after it the page is byte-identical to one where fx/ was + * never imported. + * + * SSR. `document` appears only inside functions, behind `typeof` guards. On the + * server every accessor returns null/0 and every builder is a no-op. + */ + +import { DPR_CAP } from './particles'; + +/** Above the table and every prompt (max in the app today is 70), because the + * win confetti has to fall in FRONT of the game-over overlay rather than + * behind it. Floating text is suppressed separately while that overlay is up + * (see fx/floaters.ts) — the confetti is a ceremony, a coin delta is noise. */ +const Z_INDEX = 90; + +const CSS = ` +.fx-root{position:fixed;inset:0;z-index:${Z_INDEX};pointer-events:none;display:none; + contain:layout style paint;overflow:hidden} +.fx-root.is-on{display:block} +.fx-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none} +.fx-flash{position:absolute;inset:0;pointer-events:none;opacity:0; + will-change:opacity;mix-blend-mode:screen} +.fx-float{position:absolute;inset:0;pointer-events:none} +.fx-text{position:absolute;left:0;top:0;pointer-events:none;opacity:0; + font:900 1.5rem/1 var(--font-display,ui-sans-serif,system-ui,sans-serif); + letter-spacing:.02em;white-space:nowrap;color:#F1EBDE; + text-shadow:0 1px 2px rgba(0,0,0,.92),0 0 12px rgba(0,0,0,.7); + will-change:transform,opacity;transform:translate3d(0,0,0)} +/* A shout ("CAUGHT BLUFFING!", "BLOCKED!") lands wherever the crime was, which + is usually ON a seat rather than on empty felt, and the drop shadow above is + tuned for the dark table ground. A solid 3px dark ring — four offsets plus a + tight glow — makes the glyphs legible against anything the table can put + under them, which is what a once-a-game shout has to be. */ +.fx-text.is-shout{ + text-shadow: + 0 0 3px rgba(0,0,0,1),0 0 3px rgba(0,0,0,1), + 2px 0 2px rgba(0,0,0,.95),-2px 0 2px rgba(0,0,0,.95), + 0 2px 2px rgba(0,0,0,.95),0 -2px 2px rgba(0,0,0,.95), + 0 0 16px rgba(0,0,0,.85)} +`; + +let styleEl: HTMLStyleElement | null = null; +let root: HTMLDivElement | null = null; +let canvas: HTMLCanvasElement | null = null; +let ctx2d: CanvasRenderingContext2D | null = null; +let flashPlate: HTMLDivElement | null = null; +let floatLayer: HTMLDivElement | null = null; +let host: HTMLElement | null = null; + +let listening = false; +let activeNow = false; +let cssW = 0; +let cssH = 0; +let dpr = 1; + +/** Where the overlay is appended. `null` → `document.body` at build time. */ +export function setHost(node: HTMLElement | null): void { + if (node === host) return; + host = node; + if (root && node) node.appendChild(root); +} + +/** Build the substrate. Called on the first effect and never again. */ +export function ensure(): boolean { + if (root) return true; + if (typeof document === 'undefined') return false; + + styleEl = document.createElement('style'); + styleEl.setAttribute('data-fx', 'true'); + styleEl.textContent = CSS; // text, never HTML + document.head.appendChild(styleEl); + + root = document.createElement('div'); + root.className = 'fx-root'; + root.setAttribute('aria-hidden', 'true'); + + canvas = document.createElement('canvas'); + canvas.className = 'fx-canvas'; + // alpha:true is required — this composites over the table. `desynchronized` + // lets the browser skip a frame of latency on the overlay, which it may + // because nothing ever reads this canvas back. + ctx2d = canvas.getContext('2d', { alpha: true, desynchronized: true }); + + flashPlate = document.createElement('div'); + flashPlate.className = 'fx-flash'; + + floatLayer = document.createElement('div'); + floatLayer.className = 'fx-float'; + + root.appendChild(canvas); + root.appendChild(flashPlate); + root.appendChild(floatLayer); + (host ?? document.body).appendChild(root); + + resize(); + return true; +} + +function resize(): void { + if (!canvas || typeof window === 'undefined') return; + const w = Math.max(1, window.innerWidth | 0); + const h = Math.max(1, window.innerHeight | 0); + const d = Math.min(DPR_CAP, window.devicePixelRatio || 1); + if (w === cssW && h === cssH && d === dpr) return; + cssW = w; + cssH = h; + dpr = d; + canvas.width = Math.round(w * d); + canvas.height = Math.round(h * d); +} + +function listen(on: boolean): void { + if (typeof window === 'undefined' || listening === on) return; + listening = on; + if (on) { + window.addEventListener('resize', resize, { passive: true }); + window.visualViewport?.addEventListener('resize', resize, { passive: true }); + } else { + window.removeEventListener('resize', resize); + window.visualViewport?.removeEventListener('resize', resize); + } +} + +/** + * Show/hide the whole overlay. `display:none` rather than opacity so the + * compositor drops the layer entirely — the point of the idle-cost rule — and + * the resize listeners come and go with it. + */ +export function setActive(on: boolean): void { + if (!root || activeNow === on) return; + activeNow = on; + root.classList.toggle('is-on', on); + listen(on); + if (on) resize(); + else clear(); +} + +export function isActive(): boolean { + return activeNow; +} + +export function ctx(): CanvasRenderingContext2D | null { + return ctx2d; +} + +export function flashEl(): HTMLDivElement | null { + return flashPlate; +} + +export function floatEl(): HTMLDivElement | null { + return floatLayer; +} + +export function width(): number { + return cssW; +} + +export function height(): number { + return cssH; +} + +export function scale(): number { + return dpr; +} + +export function exists(): boolean { + return root !== null; +} + +/** Clear the whole backing store. One call per painted frame. */ +export function clear(): void { + if (ctx2d && canvas) ctx2d.clearRect(0, 0, canvas.width, canvas.height); +} + +/** Remove every node and listener this module ever created. */ +export function destroy(): void { + listen(false); + root?.remove(); + styleEl?.remove(); + root = null; + styleEl = null; + canvas = null; + ctx2d = null; + flashPlate = null; + floatLayer = null; + activeNow = false; + cssW = 0; + cssH = 0; + dpr = 1; +} diff --git a/src/app/fx/palette.ts b/src/app/fx/palette.ts new file mode 100644 index 0000000..14c95c9 --- /dev/null +++ b/src/app/fx/palette.ts @@ -0,0 +1,50 @@ +/** + * fx/palette.ts — the only colours the impact layer may use. + * + * Every value here is a token from ART-DIRECTION §2.1, copied rather than + * invented. The FX layer is the easiest place in a codebase for a seventh gold + * to appear, so the palette is a closed array of six and `ColorIndex` is a + * union of its indices: an emitter cannot name a colour that is not in it. + * + * `--oxblood` is here for completeness of the danger material and is currently + * unused by any emitter — a crimson particle over a dark teal table already + * reads, and an oxblood one does not. + */ + +/** Indices into `HEX` / `RGB`. Stored per particle in a `Uint8Array`. */ +export const COL = { + /** `--brass` #D6A12A — your turn, primary slabs. Rings you earned. */ + BRASS: 0, + /** `--brass-lit` #F2C744 — figures and the treasury. The bright confetti. */ + BRASS_LIT: 1, + /** `--crimson` #F27366 — the danger stripe, and ONLY the danger stripe. */ + CRIMSON: 2, + /** `--oxblood` #5F141C — perimeter enamel. */ + OXBLOOD: 3, + /** `--ink` #F1EBDE — bone. All primary text; here, neutral contact sparks. */ + BONE: 4, + /** `--ink-mute` #9FADA6 — secondary. Grey puffs, bystander rings. */ + ASH: 5, +} as const; + +export type ColorIndex = (typeof COL)[keyof typeof COL]; + +export const PALETTE_SIZE = 6; + +export const HEX: readonly string[] = [ + '#D6A12A', + '#F2C744', + '#F27366', + '#5F141C', + '#F1EBDE', + '#9FADA6', +]; + +export const RGB: readonly (readonly [number, number, number])[] = [ + [0xd6, 0xa1, 0x2a], + [0xf2, 0xc7, 0x44], + [0xf2, 0x73, 0x66], + [0x5f, 0x14, 0x1c], + [0xf1, 0xeb, 0xde], + [0x9f, 0xad, 0xa6], +]; diff --git a/src/app/fx/particles.ts b/src/app/fx/particles.ts new file mode 100644 index 0000000..b139146 --- /dev/null +++ b/src/app/fx/particles.ts @@ -0,0 +1,658 @@ +/** + * fx/particles.ts — ONE pooled particle system for the whole table. + * + * WHY A CANVAS. The win throws 380 confetti. As DOM that is 380 nodes laid out, + * painted and composited every frame on top of a table that already carries a + * dozen card nodes; on a 390×844 DPR3 phone that is the frame budget gone. One + * canvas is one composited layer whose cost is fill-rate, and fill-rate is the + * thing a phone GPU has spare. + * + * ── THE FOUR INVARIANTS ─────────────────────────────────────────────────── + * + * 1. FIXED CAPACITY. `CAP = 600`. A spawn on a full pool is DROPPED, never + * queued. A queue turns a burst you cannot see into frames you can feel. + * 380 for the win leaves 220 spare for whatever lands on top of it. + * + * 2. ZERO ALLOCATION IN update()/draw(). Storage is a struct-of-arrays of + * typed arrays; the spawn descriptor is one module-scope object (`SP`), so + * a spawn passes no object either. Every colour string a frame can need is + * built once in `ensureSprites()`. + * + * 3. DEATH IS SWAP-WITH-LAST, so the live range stays dense at [0, n) and the + * draw loop is a straight walk with no holes and no compaction pass. + * + * 4. INTEGRATION IS A PURE FUNCTION OF dt, handed down by anim/clock — so a + * backgrounded tab freezes the effect instead of teleporting it (the clock + * already clamps dt to 1/20s). + * + * ── LAZINESS ────────────────────────────────────────────────────────────── + * The pool itself is allocated on the FIRST spawn, not at module load, so + * importing fx/ costs nothing but the module records. 21 typed arrays × 600 is + * ~46KB and it is allocated exactly once, before any frame runs — "preallocated" + * means "not during update", not "at import". + * + * ── SSR ─────────────────────────────────────────────────────────────────── + * `document` is referenced only inside `ensureSprites()`, behind a `typeof` + * guard. On the server, `spawn()` and `update()` work and `draw()` is a no-op. + */ + +import { hash1 } from '../anim/easing'; +import { COL, HEX, PALETTE_SIZE, RGB, type ColorIndex } from './palette'; + +const TAU = Math.PI * 2; + +export const CAP = 600; + +/** 3× on a 390×844 phone is 3.0 Mpx of confetti fill for no visible gain — the + * pieces are 6px rects. Consumed by fx/overlay.ts; declared here because it is + * a property of what is being drawn, not of the element it is drawn on. */ +export const DPR_CAP = 2; + +/* ── the sprite vocabulary ──────────────────────────────────────────────── */ + +export const KIND = { DOT: 0, GLINT: 1, PUFF: 2, RING: 3, CONFETTI: 4 } as const; +export type KindIndex = (typeof KIND)[keyof typeof KIND]; + +/** + * Per-shape fade exponents. `alpha = alpha0 × (1 − u)^exp`. + * + * LINEAR 1 — unused by the table; kept because "no fade curve" has to be + * expressible or someone will encode it as 1.0001. + * RING 1.15 — a squared fade puts a ring below 25% alpha at the halfway + * point of a travel whose entire job is to reach the far + * radius visibly. Rings must survive their own expansion. + * SPARK 1.6 — at fade² a spark is under 30% alpha at 45% of its life, so a + * burst meant to read for 300ms reads for 120. 1.6 holds the + * first third and then goes. + * QUAD 2 — puffs and glints. Volume dissipating; the eye expects the + * back half of a puff to be nearly gone. + * PAPER 3 — confetti. Paper is opaque until it is off screen. A cubic + * fade is ~opaque until the last 18% of life, so the field + * reads as paper falling rather than as fog clearing. + */ +export const FADE = { LINEAR: 0, RING: 1, SPARK: 2, QUAD: 3, PAPER: 4 } as const; +export type FadeIndex = (typeof FADE)[keyof typeof FADE]; +const FADE_EXP = [1, 1.15, 1.6, 2, 3]; + +/* ── storage ────────────────────────────────────────────────────────────── */ + +interface Pool { + readonly px: Float32Array; + readonly py: Float32Array; + readonly vx: Float32Array; + readonly vy: Float32Array; + readonly life: Float32Array; + readonly maxLife: Float32Array; + readonly sz0: Float32Array; + readonly sz1: Float32Array; + readonly drag: Float32Array; + readonly grav: Float32Array; + readonly rot: Float32Array; + readonly rotVel: Float32Array; + readonly alpha0: Float32Array; + /** confetti: current tumble angle */ + readonly tumble: Float32Array; + /** confetti: tumble rate, rad/s */ + readonly tumVel: Float32Array; + /** confetti: sideways scull amplitude */ + readonly flut: Float32Array; + /** confetti: height/width */ + readonly aspect: Float32Array; + /** ring only: stroke width at birth */ + readonly lineW: Float32Array; + readonly kind: Uint8Array; + readonly col: Uint8Array; + readonly fade: Uint8Array; +} + +let pool: Pool | null = null; +let n = 0; +let peak = 0; +let dropped = 0; + +function makePool(): Pool { + return { + px: new Float32Array(CAP), + py: new Float32Array(CAP), + vx: new Float32Array(CAP), + vy: new Float32Array(CAP), + life: new Float32Array(CAP), + maxLife: new Float32Array(CAP), + sz0: new Float32Array(CAP), + sz1: new Float32Array(CAP), + drag: new Float32Array(CAP), + grav: new Float32Array(CAP), + rot: new Float32Array(CAP), + rotVel: new Float32Array(CAP), + alpha0: new Float32Array(CAP), + tumble: new Float32Array(CAP), + tumVel: new Float32Array(CAP), + flut: new Float32Array(CAP), + aspect: new Float32Array(CAP), + lineW: new Float32Array(CAP), + kind: new Uint8Array(CAP), + col: new Uint8Array(CAP), + fade: new Uint8Array(CAP), + }; +} + +export function count(): number { + return n; +} + +export function peakCount(): number { + return peak; +} + +export function droppedCount(): number { + return dropped; +} + +export function full(): boolean { + return n >= CAP; +} + +/** True once the backing arrays exist. The laziness assertion hangs off this. */ +export function allocated(): boolean { + return pool !== null; +} + +export function clear(): void { + n = 0; +} + +/** Test/debug read-only view of the live range. Never used by update or draw. */ +export function inspect(): Pool | null { + return pool; +} + +/* ── spawn ──────────────────────────────────────────────────────────────── */ + +/** + * The ONE spawn descriptor. Fill it, call `spawn()`. No arguments, no object + * literal, no garbage — a 380-piece confetti cannon allocates nothing. + */ +export const SP = { + x: 0, + y: 0, + vx: 0, + vy: 0, + life: 0.4, + size0: 3, + size1: 1, + drag: 3, + grav: 0, + rot: 0, + rotVel: 0, + alpha: 1, + fade: FADE.QUAD as FadeIndex, + kind: KIND.DOT as KindIndex, + col: COL.BONE as ColorIndex, + tumble: 0, + tumVel: 0, + flutter: 0, + aspect: 1.6, + lineW: 2.5, +}; + +export function spDefaults(): void { + SP.x = 0; + SP.y = 0; + SP.vx = 0; + SP.vy = 0; + SP.life = 0.4; + SP.size0 = 3; + SP.size1 = 1; + SP.drag = 3; + SP.grav = 0; + SP.rot = 0; + SP.rotVel = 0; + SP.alpha = 1; + SP.fade = FADE.QUAD; + SP.kind = KIND.DOT; + SP.col = COL.BONE; + SP.tumble = 0; + SP.tumVel = 0; + SP.flutter = 0; + SP.aspect = 1.6; + SP.lineW = 2.5; +} + +/** @returns false when the pool is saturated — the spawn is DROPPED. */ +export function spawn(): boolean { + if (n >= CAP) { + dropped++; + return false; + } + const p = pool ?? (pool = makePool()); + const i = n++; + if (n > peak) peak = n; + p.px[i] = SP.x; + p.py[i] = SP.y; + p.vx[i] = SP.vx; + p.vy[i] = SP.vy; + p.life[i] = 0; + p.maxLife[i] = SP.life > 0.001 ? SP.life : 0.001; + p.sz0[i] = SP.size0; + p.sz1[i] = SP.size1; + p.drag[i] = SP.drag; + p.grav[i] = SP.grav; + p.rot[i] = SP.rot; + p.rotVel[i] = SP.rotVel; + p.alpha0[i] = SP.alpha; + p.fade[i] = SP.fade; + p.kind[i] = SP.kind; + p.col[i] = SP.col; + p.tumble[i] = SP.tumble; + p.tumVel[i] = SP.tumVel; + p.flut[i] = SP.flutter; + p.aspect[i] = SP.aspect; + p.lineW[i] = SP.lineW; + return true; +} + +/** Swap-with-last. `splice` returns an array, which is a per-frame allocation. */ +function kill(p: Pool, i: number): void { + const last = --n; + if (i === last) return; + p.px[i] = p.px[last]; + p.py[i] = p.py[last]; + p.vx[i] = p.vx[last]; + p.vy[i] = p.vy[last]; + p.life[i] = p.life[last]; + p.maxLife[i] = p.maxLife[last]; + p.sz0[i] = p.sz0[last]; + p.sz1[i] = p.sz1[last]; + p.drag[i] = p.drag[last]; + p.grav[i] = p.grav[last]; + p.rot[i] = p.rot[last]; + p.rotVel[i] = p.rotVel[last]; + p.alpha0[i] = p.alpha0[last]; + p.tumble[i] = p.tumble[last]; + p.tumVel[i] = p.tumVel[last]; + p.flut[i] = p.flut[last]; + p.aspect[i] = p.aspect[last]; + p.lineW[i] = p.lineW[last]; + p.kind[i] = p.kind[last]; + p.col[i] = p.col[last]; + p.fade[i] = p.fade[last]; +} + +/* ── integrate ──────────────────────────────────────────────────────────── */ + +export function update(dt: number): void { + const p = pool; + if (!p || n === 0) return; + let i = 0; + while (i < n) { + const l = p.life[i] + dt; + if (l >= p.maxLife[i]) { + kill(p, i); + continue; + } + p.life[i] = l; + + let f = 1 - p.drag[i] * dt; + if (f < 0) f = 0; + let ux = p.vx[i] * f; + const uy = (p.vy[i] + p.grav[i] * dt) * f; + + if (p.kind[i] === KIND.CONFETTI) { + // Flutter: paper sculls sideways because the falling face keeps stalling. + // Driving it off the TUMBLE ANGLE rather than an independent sine ties + // the sideways kick to the moment the piece is edge-on, which is what + // makes a field of confetti look like paper instead of like snow. + p.tumble[i] += p.tumVel[i] * dt; + ux += Math.cos(p.tumble[i]) * p.flut[i] * dt; + } + + p.vx[i] = ux; + p.vy[i] = uy; + p.px[i] += ux * dt; + p.py[i] += uy * dt; + p.rot[i] += p.rotVel[i] * dt; + i++; + } +} + +function fadeOf(k: number, idx: number): number { + const e = FADE_EXP[idx] ?? 2; + if (e === 1) return k; + if (e === 2) return k * k; + if (e === 3) return k * k * k; + return Math.pow(k, e); +} + +/* ── sprites ────────────────────────────────────────────────────────────── */ + +/** + * Confetti darkening ramp. The grazing phase of a tumble has to go DARK or the + * piece reads as a glowing chip instead of paper. There is no lighting model + * here, so |N·L| is baked into 8 pre-built strings per colour and indexed by + * the tumble angle. 8 steps: at 6px tall the banding is sub-pixel. + */ +const SHADE_STEPS = 8; +/** + * MEASURED ON COUP'S GROUND, and this is where the number departs from + * chudopoly's 0.34. Coup's table is `--surface #17231F` falling off to + * `--ground #090D0E`; chudopoly's felt is navy and its confetti colours are + * brighter. Brass at 0.34 is rgb(73,55,14), which against #0d1513 is a HOLE + * rather than a piece of paper turned edge-on — the grazing phase does not go + * dark, it goes absent, and roughly a third of the field is missing at any + * instant. On the 1495×812 capture the 380-piece win read visibly thinner than + * the count says it should. 0.55 keeps the turn-over legible (there is still a + * 2.1× swing from grazing to face-on) while never letting a piece drop below + * the ground it is falling in front of. + */ +const SHADE_MIN = 0.55; +const SHADE_MAX = 1.18; +const SHADE: string[][] = []; + +type Sprite = CanvasImageSource; +/** [kind][colour]. Only DOT, GLINT and PUFF have sprites; rings and confetti + * are drawn as geometry, because a stroked circle and a filled rect are + * sharper at every size than any bitmap that has to be scaled to them. */ +let sprites: Sprite[][] | null = null; + +function tintCanvas(size: number, draw: (g: CanvasRenderingContext2D, r: number) => void): Sprite { + const c = document.createElement('canvas'); + c.width = size; + c.height = size; + const g = c.getContext('2d'); + if (g) { + g.translate(size / 2, size / 2); + draw(g, size / 2); + } + return c; +} + +function stops( + grad: CanvasGradient, + list: readonly (readonly [number, number])[], + r: number, + g: number, + b: number, +): CanvasGradient { + for (let i = 0; i < list.length; i++) { + grad.addColorStop(list[i][0], `rgba(${r},${g},${b},${list[i][1]})`); + } + return grad; +} + +/** + * Build every sprite in code, once, on the first effect. 3 shapes × 6 colours = + * 18 offscreen canvases, ≤64px each. + * + * @returns false when there is no `document` (SSR / the vitest node env). + */ +export function ensureSprites(): boolean { + if (sprites) return true; + if (typeof document === 'undefined') return false; + + const built: Sprite[][] = [[], [], []]; + for (let c = 0; c < PALETTE_SIZE; c++) { + const [r, g, b] = RGB[c]; + + // DOT — the spark. A PLATEAU CORE, NOT A GAUSSIAN. With the first shoulder + // at 0.16 the visible core of an 8px dot is ~2px and the burst simply does + // not exist against a textured ground; Coup's table art is gouache with + // grain on it, which is at least as busy as chudopoly's felt. Holding full + // alpha out to 0.30 of the radius is the difference between a spark and a + // smudge. + built[KIND.DOT][c] = tintCanvas(32, (x, rad) => { + x.fillStyle = stops( + x.createRadialGradient(0, 0, 0, 0, 0, rad * 0.98), + [ + [0, 1], + [0.3, 1], + [0.52, 0.58], + [0.8, 0.1], + [1, 0], + ], + r, + g, + b, + ); + x.fillRect(-rad, -rad, rad * 2, rad * 2); + }); + + // GLINT — a card-corner catch of light: a small core with two crossed bars. + // Drawn rotated per particle so a set of glints does not read as a grid of + // plus signs. Bars at 0.20 of the radius, not 0.14: at a 26px draw size the + // thinner bar is under 2 device px and antialiases itself away. + built[KIND.GLINT][c] = tintCanvas(48, (x, rad) => { + x.globalCompositeOperation = 'lighter'; + x.fillStyle = stops( + x.createRadialGradient(0, 0, 0, 0, 0, rad * 0.34), + [ + [0, 1], + [0.3, 0.85], + [1, 0], + ], + r, + g, + b, + ); + x.fillRect(-rad, -rad, rad * 2, rad * 2); + for (let axis = 0; axis < 2; axis++) { + const lg = x.createLinearGradient(-rad, 0, rad, 0); + stops( + lg, + [ + [0, 0], + [0.3, 0.2], + [0.5, 1], + [0.7, 0.2], + [1, 0], + ], + r, + g, + b, + ); + x.save(); + if (axis) x.rotate(Math.PI / 2); + x.fillStyle = lg; + x.fillRect(-rad, -rad * 0.1, rad * 2, rad * 0.2); + x.restore(); + } + }); + + // PUFF — volume, not glow. A flat plateau so a dissipating puff reads as + // smoke rather than as a big soft spark. + built[KIND.PUFF][c] = tintCanvas(64, (x, rad) => { + x.fillStyle = stops( + x.createRadialGradient(0, 0, 0, 0, 0, rad * 0.98), + [ + [0, 0.82], + [0.46, 0.76], + [0.7, 0.38], + [0.9, 0.06], + [1, 0], + ], + r, + g, + b, + ); + x.fillRect(-rad, -rad, rad * 2, rad * 2); + }); + + const ramp = new Array(SHADE_STEPS); + for (let s = 0; s < SHADE_STEPS; s++) { + const k = SHADE_MIN + (SHADE_MAX - SHADE_MIN) * (s / (SHADE_STEPS - 1)); + ramp[s] = + `rgb(${Math.min(255, r * k) | 0},${Math.min(255, g * k) | 0},${Math.min(255, b * k) | 0})`; + } + SHADE[c] = ramp; + } + sprites = built; + return true; +} + +/* ── draw ───────────────────────────────────────────────────────────────── */ + +/** + * The subset of the 2D context this module touches. Structural rather than + * `CanvasRenderingContext2D` for the same reason anim/flight.ts types its + * element structurally: it documents exactly how little of the API is in play. + */ +export type FxContext2D = Pick< + CanvasRenderingContext2D, + | 'setTransform' + | 'globalAlpha' + | 'globalCompositeOperation' + | 'drawImage' + | 'fillStyle' + | 'fillRect' + | 'strokeStyle' + | 'lineWidth' + | 'beginPath' + | 'arc' + | 'stroke' +>; + +/** + * TWO PASSES over the live range, so `globalCompositeOperation` is set exactly + * twice per frame instead of once per particle: opaque things (confetti, puffs) + * under, additive things (dots, glints, rings) over. Toggling the composite op + * per particle costs ~0.9ms/frame at 380 pieces. + * + * @param dpr device pixel ratio the canvas backing store was sized at + */ +export function draw(g: FxContext2D, dpr: number): void { + const p = pool; + if (!p || n === 0) return; + if (!ensureSprites() || !sprites) return; + const d = dpr; + + /* ── pass 1: source-over. Paper and smoke. ── */ + g.setTransform(1, 0, 0, 1, 0, 0); + g.globalCompositeOperation = 'source-over'; + for (let i = 0; i < n; i++) { + const k = p.kind[i]; + if (k !== KIND.CONFETTI && k !== KIND.PUFF) continue; + const u = p.life[i] / p.maxLife[i]; + const a = p.alpha0[i] * fadeOf(1 - u, p.fade[i]); + if (a <= 0.004) continue; + g.globalAlpha = a > 1 ? 1 : a; + + if (k === KIND.PUFF) { + const s = (p.sz0[i] + (p.sz1[i] - p.sz0[i]) * u) * d; + g.drawImage(sprites[KIND.PUFF][p.col[i]], p.px[i] * d - s / 2, p.py[i] * d - s / 2, s, s); + continue; + } + + // CONFETTI is a real fillRect, foreshortened by the tumble angle and SHADED + // by it. Both halves matter: foreshortening alone gives you a rectangle + // that gets thin, which reads as a shrinking chip; shading it dark through + // the grazing pass is what makes it read as a sheet of paper turning over. + const c = Math.cos(p.tumble[i]); + const ac = c < 0 ? -c : c; + const w = p.sz0[i] * d; + const h = w * p.aspect[i] * (0.1 + 0.9 * ac); + const ramp = SHADE[p.col[i]]; + // smoothstep on |cos| — the piece spends most of its tumble bright and + // snaps dark through the grazing pass, which is where the flutter kick is. + const smooth = ac * ac * (3 - 2 * ac); + let si = (smooth * (SHADE_STEPS - 1) + 0.5) | 0; + if (si > SHADE_STEPS - 1) si = SHADE_STEPS - 1; + g.fillStyle = ramp[si]; + const cs = Math.cos(p.rot[i]); + const sn = Math.sin(p.rot[i]); + g.setTransform(cs, sn, -sn, cs, p.px[i] * d, p.py[i] * d); + g.fillRect(-w / 2, -h / 2, w, h); + g.setTransform(1, 0, 0, 1, 0, 0); + } + + /* ── pass 2: lighter. Sparks, glints, shockwaves. ── */ + g.globalCompositeOperation = 'lighter'; + for (let i = 0; i < n; i++) { + const k = p.kind[i]; + if (k === KIND.CONFETTI || k === KIND.PUFF) continue; + const u = p.life[i] / p.maxLife[i]; + const a0 = p.alpha0[i] * fadeOf(1 - u, p.fade[i]); + if (a0 <= 0.004) continue; + const a = a0 > 1 ? 1 : a0; + g.globalAlpha = a; + + if (k === KIND.RING) { + // THE HARD EDGE. A shockwave drawn as a soft radial smear carries ~0.1 + // alpha over a third of its area and vanishes against any busy surface — + // here, painted gouache. A STROKED ANNULUS puts its contrast at the edge, + // which reads on any ground and matches the flat, hard-edged drawing + // language of the cards. + // + // TWO STROKES, NOT ONE. A single 2.5px stroke fading as (1−u)² is + // invisible past the halfway point of its travel. The BAND (wide, 45% + // alpha) carries the energy and the FILAMENT (1.6px, full alpha) carries + // the edge; additively they read as a bright wire inside a glow, which is + // what a shockwave looks like and what a smear does not. + const r = (p.sz0[i] + (p.sz1[i] - p.sz0[i]) * u) * d; + if (r <= 0.5) continue; + g.strokeStyle = HEX[p.col[i]]; + g.globalAlpha = a * 0.45; + g.lineWidth = Math.max(1.5, p.lineW[i] * (1 - 0.35 * u)) * d; + g.beginPath(); + g.arc(p.px[i] * d, p.py[i] * d, r, 0, TAU); + g.stroke(); + g.globalAlpha = a; + g.lineWidth = 1.6 * d; + g.beginPath(); + g.arc(p.px[i] * d, p.py[i] * d, r, 0, TAU); + g.stroke(); + continue; + } + + const s = (p.sz0[i] + (p.sz1[i] - p.sz0[i]) * u) * d; + const sprite = sprites[k][p.col[i]]; + if (k === KIND.GLINT && p.rot[i] !== 0) { + const cs = Math.cos(p.rot[i]); + const sn = Math.sin(p.rot[i]); + g.setTransform(cs, sn, -sn, cs, p.px[i] * d, p.py[i] * d); + g.drawImage(sprite, -s / 2, -s / 2, s, s); + g.setTransform(1, 0, 0, 1, 0, 0); + } else { + g.drawImage(sprite, p.px[i] * d - s / 2, p.py[i] * d - s / 2, s, s); + } + } + + g.globalAlpha = 1; + g.globalCompositeOperation = 'source-over'; +} + +/* ── deterministic jitter ───────────────────────────────────────────────── */ + +/** + * The FX layer's OWN counter-driven hash. + * + * Coup's deck is shuffled with Fisher–Yates off the engine's randomness, and + * the engine's randomness is state that a replay or a seeded bot test has to + * reproduce. A particle that consumed from it would mean a spark changed the + * deck. So fx keeps its own stream: same seed → same burst, screenshots + * reproduce, and the game cannot tell whether the FX layer ran at all. + * + * `hash1` is anim/easing's — one hash implementation in the app, not two. + */ +let seedN = 0; + +export function rnd(): number { + seedN = (seedN + 1) | 0; + return hash1(seedN); +} + +export function rndRange(a: number, b: number): number { + return a + (b - a) * rnd(); +} + +export function resetJitter(): void { + seedN = 0; +} + +/** Full teardown: drop the live set, the counters and the jitter stream. The + * backing arrays and the sprites survive — rebuilding them per burst is the + * allocation this module exists to avoid. */ +export function reset(): void { + n = 0; + peak = 0; + dropped = 0; + resetJitter(); +} diff --git a/src/app/fx/shake.ts b/src/app/fx/shake.ts new file mode 100644 index 0000000..718e328 --- /dev/null +++ b/src/app/fx/shake.ts @@ -0,0 +1,236 @@ +/** + * fx/shake.ts — trauma-model screen shake. + * + * ── THE MODEL ───────────────────────────────────────────────────────────── + * + * `trauma ∈ [0, CAP]`, decaying linearly at `DECAY`/s. Displacement is + * **trauma²**, so a small trauma is genuinely small — that is the entire + * difference between juice and nausea. The squared curve is also what makes the + * small values honestly small: the 0.16 a card landing adds is 0.38px, i.e. + * almost nothing, which is correct — it is there so that a run of landings is + * felt and one is not. + * + * Three DECORRELATED value-noise channels — x, y, rotation. The phase offsets + * `+0 / +31.3 / +77.1` are load-bearing: equal seeds put x and y on the same + * line and the shake reads as a single diagonal slider being dragged rather + * than as a table taking a hit. + * + * ── THE PERCEPTUAL FLOOR ────────────────────────────────────────────────── + * + * `tick()` quantises the write to 0.1px, so a trauma whose peak displacement is + * under ~0.3px is three quantisation steps of nothing: it holds a transform and + * a stacking context, runs the clock, and cannot be seen. chudopoly measured + * SIX of its ten shake triggers below that line — dead code that still held a + * transform. `sqrt(0.30/15) = 0.141`; anything under it is REFUSED here rather + * than silently rendered as stillness, so "this shake does nothing" is a fact + * the call site has to deal with instead of a comment claiming it is deliberate. + * + * Stacking is the one honest exception: if something is already shaking, a + * sub-floor contribution really does add, so it is allowed through. + * + * ── THE TRANSFORM SUBSTRATE, AND ITS TWO HAZARDS ────────────────────────── + * + * The target is supplied by the caller — in Coup it must be the TABLE + * container, not the viewport and not the whole game screen. The phase banner, + * the action bar and your hand are siblings of the table; shaking them makes a + * challenge prompt unreadable at the exact moment you have to answer it. + * + * Writing `transform` on an ancestor does two things that are easy to miss: + * + * 1. IT BECOMES THE CONTAINING BLOCK FOR `position: fixed` DESCENDANTS. A + * fixed modal inside the shake target stops being viewport-fixed and + * starts being target-fixed — and then shakes with it. Keep every fixed + * element (modals, toasts, this layer's own overlay root) OUTSIDE the + * target subtree. + * + * 2. IT CREATES A STACKING CONTEXT, which traps any descendant that was + * relying on a high `z-index` to escape its parent — a dragged or + * hero-lifted card, for instance. + * + * Both are avoided structurally rather than by care: the property is REMOVED + * entirely (not set to `none`) the moment trauma reaches 0, so at rest the + * stacking tree is identical to a build with no FX layer at all. The only + * frames that have a stacking context are frames the table is visibly shaking + * on. `transform: none` would NOT do this — a computed `none` still counts as + * "has a transform" for the containing-block rule in some engines, and it keeps + * the style property alive in the inline style attribute where a reader will + * assume it means something. + * + * FLIP is unaffected as long as this stays translate+rotate with NO SCALE: a + * measure → move → measure pair inside one synchronous task carries the same + * shake offset in both rects and a translate delta cancels exactly. A scale + * delta would not — it would scale the delta. + */ + +import { hash1 } from '../anim/easing'; + +export const DECAY = 1.5; + +/** + * Cap 0.75, not 1.0. trauma² × MAX_X at the cap is 8.4px of translate and 0.62° + * of rotation, and the cap is only reachable by STACKING — the single loudest + * event on its own is the win at 0.60 → 5.4px. + */ +export const CAP = 0.75; + +export const MAX_X = 15; +export const MAX_Y = 11; +export const MAX_R = 1.1; + +/** Hz. Below ~18 it reads as a wobble, above ~34 as noise. */ +export const FREQ = 26; + +/** sqrt(0.30/15) — the write quantisation, expressed as trauma. */ +export const MIN_TRAUMA = 0.141; + +/** Routine landings must never out-shake the win. */ +export const LAND_CEILING = 0.34; + +/* ── the DOM surface ────────────────────────────────────────────────────── */ + +export interface ShakeStyle { + setProperty(property: string, value: string): void; + removeProperty(property: string): void; +} + +export interface ShakeElement { + readonly isConnected: boolean; + readonly style: ShakeStyle; +} + +/* ── state ──────────────────────────────────────────────────────────────── */ + +let trauma = 0; +let clock = 0; +let el: ShakeElement | null = null; +let written = false; +let wx = 0; +let wy = 0; +let wr = 0; +let reduced = false; + +/** Smooth value noise in [0,1). Pure function of t — no allocation, no state. */ +function noise1(t: number): number { + const i = Math.floor(t); + const f = t - i; + const s = f * f * (3 - 2 * f); + const a = hash1(i); + const b = hash1(i + 1); + return a + (b - a) * s; +} + +/** ART-DIRECTION §7: motion collapses. Shake is pure motion, so it goes. */ +export function setReduced(on: boolean): void { + reduced = on; + if (reduced) { + trauma = 0; + release(); + } +} + +export function isReduced(): boolean { + return reduced; +} + +export function setTarget(node: ShakeElement | null): void { + if (node === el) return; + release(); + el = node; +} + +export function target(): ShakeElement | null { + return el; +} + +/** + * Add trauma. + * + * @param amount from the tuning table + * @param ceiling clamp the RESULTING trauma. A caravan of landings capped at + * `LAND_CEILING` cannot stack to the cap and out-shake the win. + * The clamp can never LOWER trauma that is already higher — + * a landing arriving during the win must not cut the win short. + * @returns whether any trauma was actually added. + */ +export function add(amount: number, ceiling: number = CAP): boolean { + if (reduced || !(amount > 0)) return false; + if (amount < MIN_TRAUMA && trauma <= 0) return false; + + const limit = ceiling < CAP ? ceiling : CAP; + let next = trauma + amount; + if (next > limit) next = trauma > limit ? trauma : limit; + if (next > CAP) next = CAP; + if (next <= trauma) return false; + trauma = next; + return true; +} + +export function active(): boolean { + return trauma > 0.0005; +} + +export function level(): number { + return trauma; +} + +function release(): void { + if (el && written) { + el.style.removeProperty('transform'); + el.style.removeProperty('will-change'); + } + written = false; + wx = 0; + wy = 0; + wr = 0; +} + +/** @returns whether the shake is still live after this frame. */ +export function tick(dt: number): boolean { + if (trauma <= 0) { + if (written) release(); + return false; + } + clock += dt; + trauma -= DECAY * dt; + if (trauma <= 0) { + trauma = 0; + release(); + return false; + } + + const node = el; + if (!node || !node.isConnected) return true; + + const tr = trauma * trauma; + const t = clock * FREQ; + const x = Math.round((noise1(t) * 2 - 1) * tr * MAX_X * 10) / 10; + const y = Math.round((noise1(t + 31.3) * 2 - 1) * tr * MAX_Y * 10) / 10; + const r = Math.round((noise1(t + 77.1) * 2 - 1) * tr * MAX_R * 100) / 100; + + if (x !== wx || y !== wy || r !== wr || !written) { + wx = x; + wy = y; + wr = r; + if (!written) { + node.style.setProperty('will-change', 'transform'); + written = true; + } + node.style.setProperty('transform', `translate3d(${x}px,${y}px,0) rotate(${r}deg)`); + } + return true; +} + +/** The last values written, for the tests and the demo harness. */ +export function displacement(): { x: number; y: number; rot: number } { + return { x: wx, y: wy, rot: wr }; +} + +export function hasTransform(): boolean { + return written; +} + +export function reset(): void { + trauma = 0; + clock = 0; + release(); +} diff --git a/src/app/fx/tuning.ts b/src/app/fx/tuning.ts new file mode 100644 index 0000000..e0f3451 --- /dev/null +++ b/src/app/fx/tuning.ts @@ -0,0 +1,624 @@ +/** + * fx/tuning.ts — the cue → effect map, as DATA, written before any rendering + * code existed. (GAME-FEEL-PLAN §3.1; ART-DIRECTION §6.) + * + * ── THE TWO RULES, AND WHY THEY ARE IN THE TYPES ────────────────────────── + * + * 1. RESTRAINT IS LOAD-BEARING. Most beats get nothing. Your card landing + * sparks; everyone else's does not. Opponents' turns are quiet, and Coup's + * three loud moments — a challenge resolved against you, losing an + * influence, and the win — stay loud *because nothing else has spent the + * attention*. `card_landed` fires several times a turn across a 6-player + * table; sparking on all of them makes the felt glitter permanently and + * destroys the signal value of the beats that matter. + * + * 2. RED ONLY FOR THE VICTIM. An attack aimed at you gets the flash and the + * shake; the same attack aimed at someone else gets a small neutral ring. A + * table where every attack flashes red teaches the player nothing; a table + * where only theirs do is one they can read out of the corner of their eye. + * + * Neither rule is left to discipline. `QuietRow` — the row type for a beat + * between other players — declares `flash: FxFlash | null` where + * `QuietTone = Exclude`, and `haptic: null`. A future edit + * that reddens a bystander event, or that buzzes your phone for someone else's + * turn, is a type error before it is a test failure. `rowFor()` carries the + * same rule at lookup time: a `theirs` condition with no row resolves to + * NOTHING, and never escalates to the loud form of the same event. + * + * ── UNITS ───────────────────────────────────────────────────────────────── + * Sizes are CSS px, speeds px/s, lives seconds, ring radii `from → to` px, + * flash strength is a peak opacity on the screen-blend plate, and trauma + * displaces by trauma² × 15px after decaying at 1.5/s (see fx/shake.ts) — so + * .16 is 0.38px, .30 is 1.35px, .45 is 3.0px and .60 is 5.4px. + * + * ── THE TABLE ───────────────────────────────────────────────────────────── + * + * event condition particles flash trauma haptic + * ──────────────────── ──────────── ──────────────────────────────────────── ──────────── ─────── ────────────── + * card_landed mine 5 dots ø7 fan along travel + ring 3→19 — .16 † land + * card_landed theirs ring 3→15 bone α.22, 130ms — 0 — + * challenge_won mine ring 12→52 brass + 6 brass dots — .20 — + * challenge_won theirs ring 12→44 ash α.28 — 0 — + * challenge_lost against_me flare ø62 + ring 10→86 crimson + 12 dots crimson .30 .30 targeted + * challenge_lost theirs ring 12→48 ash α.30 — 0 — + * influence_lost mine 12 crimson dots + ring 8→64, "LOST" crimson .30 .34 influenceLost + * influence_lost theirs 6 ash puffs 14→46 — 0 — + * coup_landed against_me flare ø70 + ring 10→92 crimson + 14 dots crimson .38 .45 targeted + * coup_landed mine ring 12→52 ash + 6 bone dots — .20 — + * coup_landed theirs ring 12→46 ash α.24 — 0 — + * assassinate_blocked against_me 4-arm cross 5/arm bone + flare ø54 ash .16 .30 targeted + * assassinate_blocked theirs same cross, 3/arm at α.5, no flare — 0 — + * coins_changed mine — (float only: +N brass / −N crimson) — 0 — + * coins_changed theirs — (nothing, deliberately) — 0 — + * player_eliminated mine 14 ash settle puffs + "ELIMINATED" — .22 — + * player_eliminated theirs 14 ash settle puffs — .22 — + * game_over mine 380 confetti 3.2s + 130 at +1.5s brass .30 .60 win + * game_over theirs 150 confetti 2.6s brass .12 .26 — + * denied mine 2 crimson dots ø6.5, 220ms — 0 denied + * + * † capped at LAND_CEILING (0.34). Five landings in a caravan must not stack + * to the cap and out-shake the win. + * + * ── THREE ROWS THAT LOOK LIKE OMISSIONS AND ARE NOT ─────────────────────── + * + * • `challenge_won / mine` has NO haptic even though it is a good beat for + * you. `goodThing` sits at priority 3 in utils/haptic.ts, above `targeted` + * at 2 — so buzzing here would arm the 300ms floor at priority 3 and eat the + * `targeted` that the loser's forced influence loss earns milliseconds + * later. The winner of a challenge feels the *loser's* consequence; that is + * the beat worth a pattern. + * + * • `player_eliminated / mine` has no flash and no haptic. The + * `influence_lost` that caused it lit the plate crimson and fired + * `influenceLost` (priority 4) in the same beat. Two crimson washes inside + * 400ms is one long wash, and the haptic floor would drop the second pattern + * anyway — stating that here rather than letting the gate discover it. + * + * • `coins_changed / theirs` is an empty row rather than a missing one. It is + * the difference between "we decided this gets nothing" and "we forgot". + */ + +import type { HapticName } from '../utils/haptic'; +import { COL, type ColorIndex } from './palette'; + +/* ── the axes ───────────────────────────────────────────────────────────── */ + +export type FxEvent = + | 'card_landed' + | 'challenge_won' + | 'challenge_lost' + | 'influence_lost' + | 'coup_landed' + | 'assassinate_blocked' + | 'coins_changed' + | 'player_eliminated' + | 'game_over' + | 'denied'; + +export const FX_EVENTS: readonly FxEvent[] = [ + 'card_landed', + 'challenge_won', + 'challenge_lost', + 'influence_lost', + 'coup_landed', + 'assassinate_blocked', + 'coins_changed', + 'player_eliminated', + 'game_over', + 'denied', +]; + +/** + * Direction, from the local player's point of view. + * + * mine — you did it, or it is yours (your card, your coins, your win). + * against_me — it was done TO you (you were couped, you were caught bluffing). + * theirs — it happened between other players. The quiet form. + * + * `mine` and `against_me` are two shades of "this concerns you" and fall back + * to each other; `theirs` never falls back to either (see `rowFor`). + */ +export type FxCondition = 'mine' | 'against_me' | 'theirs'; + +/** Flash plate tones. Named for the ART-DIRECTION §2.1 token each one is. */ +export type FxTone = 'brass' | 'crimson' | 'bone' | 'ash'; + +/** + * The tones a BYSTANDER beat may wear. `crimson` is structurally absent — rule + * 2 above is a type, not a convention. + */ +export type QuietTone = Exclude; + +/* ── emitters ───────────────────────────────────────────────────────────── */ + +/** + * A declarative emitter. The table says WHAT shape a beat gets; fx/emitters.ts + * knows how to throw it. Keeping this as data is what let the whole map be + * written, argued over and diffed before a single pixel was drawn. + */ +export type FxEmitter = + | { + readonly emit: 'sparks'; + readonly count: number; + readonly color: ColorIndex; + readonly speed: number; + readonly life: number; + readonly size: number; + readonly grav: number; + /** Radians. Omit for a full circle. */ + readonly spread?: number; + /** Throw the fan along the cue's travel vector when one was supplied. */ + readonly directional?: boolean; + readonly alpha?: number; + } + | { + readonly emit: 'ring'; + readonly from: number; + readonly to: number; + readonly color: ColorIndex; + readonly life: number; + readonly lineWidth: number; + readonly alpha: number; + } + | { + readonly emit: 'flare'; + readonly size: number; + readonly color: ColorIndex; + readonly life: number; + readonly alpha: number; + } + | { + readonly emit: 'puff'; + readonly count: number; + readonly color: ColorIndex; + readonly life: number; + readonly from: number; + readonly to: number; + readonly alpha: number; + } + | { + readonly emit: 'cross'; + readonly perArm: number; + readonly color: ColorIndex; + readonly speed: number; + readonly size: number; + readonly life: number; + readonly alpha: number; + } + | { + readonly emit: 'confetti'; + readonly count: number; + readonly life: number; + /** + * A second, lighter fall this many ms later. The peak of a celebration + * must not be the first frame of it — the game-over overlay animates in + * over ~600ms and a single burst finishes underneath it. + */ + readonly delayMs?: number; + } + | { + readonly emit: 'settle'; + readonly count: number; + readonly color: ColorIndex; + }; + +/* ── flash and float ────────────────────────────────────────────────────── */ + +export interface FxFlash { + readonly tone: T; + /** Peak opacity on the screen-blend plate. */ + readonly strength: number; + readonly durationMs: number; +} + +export type FxFloatTone = 'brass' | 'crimson' | 'bone' | 'ash' | 'signed'; + +export interface FxFloatSpec { + /** `null` = the caller supplies the text (a coin delta, a player name). */ + readonly text: string | null; + /** `'signed'` picks brass for a gain and crimson for a loss. */ + readonly tone: FxFloatTone; + /** 1 = the default size. A shout is 1.35 and gets the heavy outline. */ + readonly scale: number; +} + +/* ── rows ───────────────────────────────────────────────────────────────── */ + +interface RowCommon { + readonly event: FxEvent; + readonly particles: readonly FxEmitter[]; + readonly float: FxFloatSpec | null; + /** One line on why this row is tuned the way it is. Not decoration. */ + readonly why: string; +} + +/** A beat that concerns YOU. The only rows allowed to be loud. */ +export interface LoudRow extends RowCommon { + readonly condition: 'mine' | 'against_me'; + readonly flash: FxFlash | null; + readonly trauma: number; + /** Clamp the RESULTING trauma, so a caravan of these cannot stack past it. */ + readonly traumaCeiling: number | null; + readonly haptic: HapticName | null; +} + +/** + * A beat between other players. + * + * Three of the four fields on this type are narrower than `LoudRow`'s, and each + * narrowing is one of the two rules made unwritable: + * flash — `QuietTone`, so it cannot be crimson. + * haptic — `null`, so it cannot touch your hands. + * traumaCeiling — `null`, because nothing quiet is repeated enough to need one. + * `trauma` is left open, because two bystander beats genuinely earn a knock: + * a player being eliminated and somebody else winning are table-level facts, + * not opponents' turns. `QUIET_TRAUMA_CEILING` bounds them. + */ +export interface QuietRow extends RowCommon { + readonly condition: 'theirs'; + readonly flash: FxFlash | null; + readonly trauma: number; + readonly traumaCeiling: null; + readonly haptic: null; +} + +export type FxRow = LoudRow | QuietRow; + +/** + * The most a beat that is not about you may shake the table. .26 is the + * someone-else-won knock; anything above it is an attack, and an attack that is + * not aimed at you does not get to move your screen. + */ +export const QUIET_TRAUMA_CEILING = 0.26; + +/** Routine landings must never out-shake the win. Mirrored in fx/shake.ts. */ +export const LAND_CEILING = 0.34; + +/* ── the table ──────────────────────────────────────────────────────────── */ + +export const FX_TABLE: readonly FxRow[] = [ + /* ── card_landed ─────────────────────────────────────────────────────── */ + { + event: 'card_landed', + condition: 'mine', + // A circular scatter of dots reads as sparkle, and sparkle is what a card + // does when it twinkles, not when it hits a table. The fan is thrown ALONG + // the travel vector — dust pushed ahead of the card — so the eye can still + // read which way it came from after it has stopped. The ring is the only + // element that reads as a surface being struck rather than material leaving. + particles: [ + { + emit: 'sparks', + count: 5, + color: COL.BONE, + speed: 165, + life: 0.3, + size: 7, + grav: 380, + spread: 2.0, + directional: true, + }, + { emit: 'ring', from: 3, to: 19, color: COL.BONE, life: 0.18, lineWidth: 1.8, alpha: 0.36 }, + ], + flash: null, + trauma: 0.16, + traumaCeiling: LAND_CEILING, + haptic: 'land', + float: null, + why: 'Yours sparks. 0.16 is 0.38px — felt, not seen, and capped so a caravan cannot stack.', + }, + { + event: 'card_landed', + condition: 'theirs', + particles: [ + { emit: 'ring', from: 3, to: 15, color: COL.BONE, life: 0.13, lineWidth: 1.4, alpha: 0.22 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'A contact mark so the table stays physical, and nothing else. This fires the most of any row.', + }, + + /* ── challenge_won ───────────────────────────────────────────────────── */ + { + event: 'challenge_won', + condition: 'mine', + particles: [ + { emit: 'ring', from: 12, to: 52, color: COL.BRASS, life: 0.32, lineWidth: 3.0, alpha: 0.85 }, + { emit: 'sparks', count: 6, color: COL.BRASS, speed: 170, life: 0.32, size: 8, grav: 300 }, + ], + flash: null, + trauma: 0.2, + traumaCeiling: null, + haptic: null, + float: null, + why: 'Brass, no plate: being right is a good beat, not a world-stopping one. Haptic deliberately absent — see the header.', + }, + { + event: 'challenge_won', + condition: 'theirs', + particles: [ + { emit: 'ring', from: 12, to: 44, color: COL.ASH, life: 0.28, lineWidth: 2.0, alpha: 0.28 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'Somebody was right about somebody else. A neutral ring says where it happened.', + }, + + /* ── challenge_lost ──────────────────────────────────────────────────── */ + { + event: 'challenge_lost', + condition: 'against_me', + // One of Coup's three loud moments. Harder than the neutral ring in all + // three dimensions a ring has: 5.5px of stroke instead of 2.2, 86px of + // travel instead of 48, and 460ms instead of 280 — it arrives slower and + // stays longer, which is what makes it read as "this one was aimed at you" + // rather than as more sparkle. The flare is what makes it read as HERE. + particles: [ + { emit: 'flare', size: 62, color: COL.CRIMSON, life: 0.3, alpha: 1 }, + { emit: 'ring', from: 10, to: 86, color: COL.CRIMSON, life: 0.46, lineWidth: 5.5, alpha: 1 }, + { emit: 'sparks', count: 12, color: COL.CRIMSON, speed: 250, life: 0.44, size: 9.5, grav: 300 }, + ], + flash: { tone: 'crimson', strength: 0.3, durationMs: 500 }, + trauma: 0.3, + traumaCeiling: null, + haptic: 'targeted', + float: { text: 'CAUGHT BLUFFING!', tone: 'crimson', scale: 1.35 }, + why: 'Loud moment 1 of 3. Everything a bystander does not get: red, shake, buzz, and a shout.', + }, + { + event: 'challenge_lost', + condition: 'theirs', + particles: [ + { emit: 'ring', from: 12, to: 48, color: COL.ASH, life: 0.3, lineWidth: 2.2, alpha: 0.3 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'The same event with the red taken out. This row is the whole point of rule 2.', + }, + + /* ── influence_lost ──────────────────────────────────────────────────── */ + { + event: 'influence_lost', + condition: 'mine', + particles: [ + { emit: 'ring', from: 8, to: 64, color: COL.CRIMSON, life: 0.42, lineWidth: 4.0, alpha: 0.5 }, + { emit: 'sparks', count: 12, color: COL.CRIMSON, speed: 210, life: 0.42, size: 9, grav: 340 }, + ], + flash: { tone: 'crimson', strength: 0.3, durationMs: 520 }, + trauma: 0.34, + traumaCeiling: null, + haptic: 'influenceLost', + float: { text: 'LOST', tone: 'crimson', scale: 1.2 }, + why: 'ART-DIRECTION §6 world-stopping moment 1: the only irreversible thing in this game.', + }, + { + event: 'influence_lost', + condition: 'theirs', + particles: [ + { emit: 'puff', count: 6, color: COL.ASH, life: 0.55, from: 14, to: 46, alpha: 0.3 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'Volume dissipating, not glow. Somebody else got smaller; you did not feel it.', + }, + + /* ── coup_landed ─────────────────────────────────────────────────────── */ + { + event: 'coup_landed', + condition: 'against_me', + particles: [ + { emit: 'flare', size: 70, color: COL.CRIMSON, life: 0.32, alpha: 1 }, + { emit: 'ring', from: 10, to: 92, color: COL.CRIMSON, life: 0.5, lineWidth: 6.0, alpha: 1 }, + { emit: 'sparks', count: 14, color: COL.CRIMSON, speed: 270, life: 0.46, size: 10, grav: 320 }, + ], + flash: { tone: 'crimson', strength: 0.38, durationMs: 560 }, + trauma: 0.45, + traumaCeiling: null, + haptic: 'targeted', + float: null, + // No float: a Coup is always followed by influence_lost/mine, which shouts + // "LOST" ~400ms later. Two shouts in one beat is a stack, not a sentence. + why: 'The loudest non-terminal beat: 3.0px of shake. Unblockable and unbluffable, so it is allowed to be the biggest hit.', + }, + { + event: 'coup_landed', + condition: 'mine', + particles: [ + { emit: 'ring', from: 12, to: 52, color: COL.ASH, life: 0.3, lineWidth: 3.0, alpha: 0.55 }, + { emit: 'sparks', count: 6, color: COL.BONE, speed: 180, life: 0.3, size: 8, grav: 340 }, + ], + flash: null, + trauma: 0.2, + traumaCeiling: null, + haptic: null, + float: null, + why: 'Spending 7 coins is a beat. 0.6px of knock, no red — you are the one holding the hammer.', + }, + { + event: 'coup_landed', + condition: 'theirs', + particles: [ + { emit: 'ring', from: 12, to: 46, color: COL.ASH, life: 0.28, lineWidth: 2.0, alpha: 0.24 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'A Coup across the table is information, not an event on your screen.', + }, + + /* ── assassinate_blocked ─────────────────────────────────────────────── */ + { + event: 'assassinate_blocked', + condition: 'against_me', + // The signature: two things met at right angles and neither of them bent. + // Ash rather than crimson on the plate, because nothing was lost — a block + // is a refusal, and the refusal material is steel, not blood. + particles: [ + { emit: 'cross', perArm: 5, color: COL.BONE, speed: 360, size: 8.5, life: 0.34, alpha: 1 }, + { emit: 'flare', size: 54, color: COL.BONE, life: 0.28, alpha: 0.9 }, + ], + flash: { tone: 'ash', strength: 0.16, durationMs: 380 }, + trauma: 0.3, + traumaCeiling: null, + haptic: 'targeted', + float: { text: 'BLOCKED!', tone: 'bone', scale: 1.35 }, + why: 'A knife stopped by a Contessa is a collision. Loud enough to feel, colourless because nobody bled.', + }, + { + event: 'assassinate_blocked', + condition: 'theirs', + particles: [ + { emit: 'cross', perArm: 3, color: COL.ASH, speed: 320, size: 7.5, life: 0.3, alpha: 0.5 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'The same shape at half the count and half the alpha. Recognisable, ignorable.', + }, + + /* ── coins_changed ───────────────────────────────────────────────────── */ + { + event: 'coins_changed', + condition: 'mine', + particles: [], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: { text: null, tone: 'signed', scale: 1 }, + why: 'A number, and nothing else. Coins change on almost every turn — this is the row that must stay cheap.', + }, + { + event: 'coins_changed', + condition: 'theirs', + particles: [], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: null, + float: null, + why: 'Empty on purpose. Six players taking Income is six floats a turn, which is a scoreboard, not a game.', + }, + + /* ── player_eliminated ───────────────────────────────────────────────── */ + { + event: 'player_eliminated', + condition: 'mine', + particles: [{ emit: 'settle', count: 14, color: COL.ASH }], + flash: null, + trauma: 0.22, + traumaCeiling: null, + haptic: null, + float: { text: 'ELIMINATED', tone: 'crimson', scale: 1.35 }, + why: 'No second flash and no second buzz — see the header. The dust settles and the word lands.', + }, + { + event: 'player_eliminated', + condition: 'theirs', + particles: [{ emit: 'settle', count: 14, color: COL.ASH }], + flash: null, + trauma: 0.22, + traumaCeiling: null, + haptic: null, + float: null, + why: 'The one bystander row that shakes: the table is a player smaller, which is a table-level fact.', + }, + + /* ── game_over ───────────────────────────────────────────────────────── */ + { + event: 'game_over', + condition: 'mine', + particles: [ + { emit: 'confetti', count: 380, life: 3.2, delayMs: 1500 }, + ], + flash: { tone: 'brass', strength: 0.3, durationMs: 700 }, + trauma: 0.6, + traumaCeiling: null, + haptic: 'win', + float: null, + why: 'ART-DIRECTION §6 world-stopping moment 2. 0.60 → 5.4px, the largest number in the table.', + }, + { + event: 'game_over', + condition: 'theirs', + particles: [{ emit: 'confetti', count: 150, life: 2.6 }], + flash: { tone: 'brass', strength: 0.12, durationMs: 600 }, + trauma: 0.26, + traumaCeiling: null, + haptic: null, + float: null, + why: 'Somebody won and it was not you. The ceremony still happens; you are not in it.', + }, + + /* ── denied ──────────────────────────────────────────────────────────── */ + { + event: 'denied', + condition: 'mine', + particles: [ + { emit: 'sparks', count: 2, color: COL.CRIMSON, speed: 120, life: 0.22, size: 6.5, grav: 260 }, + ], + flash: null, + trauma: 0, + traumaCeiling: null, + haptic: 'denied', + float: null, + // GAME-FEEL-PLAN §6.1: keep controls live and refuse out loud. The shake, + // the sentence and the button pose belong to the control; the two red + // chips and the 12ms tick belong here. + why: 'A refusal is always yours — there is no theirs row, so a bystander denied cue resolves to nothing.', + }, +]; + +/* ── lookup ─────────────────────────────────────────────────────────────── */ + +/** + * Resolve a cue to its row. + * + * The fallback chain is itself rule 1: + * + * exact match + * → if the condition was `theirs`, STOP. A bystander beat never escalates to + * the loud form of the same event, even when the loud form is the only row + * that exists (see `denied`). Silence is the correct failure mode for an + * unmapped combination; a crimson wash is not. + * → the other self-condition, because `mine` and `against_me` are two shades + * of "this concerns you" + * → `theirs`, the quiet form + * → null + */ +export function rowFor(event: FxEvent, condition: FxCondition): FxRow | null { + const exact = find(event, condition); + if (exact) return exact; + if (condition === 'theirs') return null; + const other = condition === 'mine' ? 'against_me' : 'mine'; + return find(event, other) ?? find(event, 'theirs'); +} + +function find(event: FxEvent, condition: FxCondition): FxRow | null { + for (let i = 0; i < FX_TABLE.length; i++) { + const row = FX_TABLE[i]; + if (row.event === event && row.condition === condition) return row; + } + return null; +} + +/** Every row for an event, in table order. Used by the tuning tests. */ +export function rowsFor(event: FxEvent): readonly FxRow[] { + return FX_TABLE.filter((row) => row.event === event); +} diff --git a/src/app/utils/haptic.ts b/src/app/utils/haptic.ts index 4627e9c..69c9e6e 100644 --- a/src/app/utils/haptic.ts +++ b/src/app/utils/haptic.ts @@ -1,3 +1,93 @@ +// utils/haptic.ts -- the haptic vocabulary and the three rules that keep it +// from becoming noise. +// +// RULE 1: never more than one pattern per event. Enforced structurally -- +// every call goes through fireHaptic(), which sends at most one pattern. +// +// RULE 2: a 300ms floor between vibrations. A multi-card beat (a challenge +// reveal, a double influence loss) is several events inside ~350ms; without +// the floor that is several motor spin-ups the phone renders as one long +// rattle, and the iOS actuator ignores the tail anyway. +// +// RULE 3: the floor is PRIORITY-AWARE. chudopoly measured a flat 300ms +// first-one-wins gate and the vocabulary collapsed to a single pattern: +// +// live 150s 4-player game, 16 haptics fired, ALL of them `land` (10ms). +// Zero targeted, zero setComplete, zero finalApproach -- across 7 set +// completions and 3 steals. +// +// The cheap tick always arrived first and ate everything that was earned. +// So: a HIGHER-priority pattern beats the floor and REPLACES whatever is +// still playing (navigator.vibrate() replaces, it does not queue). An +// equal-or-lower one inside the window is dropped. The floor is then +// re-armed from the winner, so a win pattern cannot be cut off by a tap. + +/** Names in the vocabulary. */ +export type HapticName = + | 'pickup' + | 'land' + | 'denied' + | 'confirm' + | 'targeted' + | 'goodThing' + | 'influenceLost' + | 'win'; + +export type HapticPattern = number | number[]; + +/** + * The vocabulary. Durations are ms; arrays are on/off/on... runs. + * + * pickup 6 -- the press. The shortest thing an actuator renders, and + * the most frequent event in the game. Priority 0 so the + * floor drops it behind absolutely everything. + * land 10 -- a tick, not a buzz. A tap that did something. + * denied 12 -- a refusal. + * targeted 30/40/30 -- two knocks: something is being done TO you. + * goodThing 20/30/20 -- deliberately THE SAME SHAPE as `targeted`, just + * tighter, so a good beat and a bad beat stay + * distinguishable through a pocket without the player + * having to look at the screen. + * influenceLost 50/70/50/70 -- heavier and longer: you actually lost something. + * win 40/60/40/60/200 -- a roll into a long resolve. + */ +export const HAPTICS: Readonly> = Object.freeze({ + pickup: 6, + land: 10, + denied: 12, + confirm: [30, 40, 30], + targeted: [30, 40, 30], + goodThing: [20, 30, 20], + influenceLost: [50, 70, 50, 70], + win: [40, 60, 40, 60, 200], +}); + +/** + * What may interrupt what. The ladder is "how much of the game this beat is + * worth", not "how loud": losing an influence outranks the tap that lost it, + * and nothing outranks the win. + */ +export const HAPTIC_PRIORITY: Readonly> = Object.freeze({ + pickup: 0, + land: 0, + denied: 1, + // `confirm` shares `targeted`'s PATTERN but not its priority, and the split is + // load-bearing. Pattern is how it feels; priority is how much of the game the + // beat is worth -- and a tap is worth nothing, it is an acknowledgement. Ranking + // your own confirm tap at `targeted`'s level reintroduces the exact bug the + // ladder exists to prevent, one rung up: tapping "Challenge!" arms the floor at + // priority 2, and against local bots the reveal can land inside 300ms, so the + // earned `targeted` for losing that challenge is dropped by the tap that caused + // it. Priority 1 keeps the weight in the hand and out of the ladder. + confirm: 1, + targeted: 2, + goodThing: 3, + influenceLost: 4, + win: 5, +}); + +const FLOOR_MS = 300; + const supportsHaptic = typeof window === 'undefined' ? false @@ -8,17 +98,28 @@ let _hapticEnabled = ? true : localStorage.getItem('coup_haptic_enabled') !== 'false'; +let lastAt = -1e9; +let lastPriority = 0; +let firedCount = 0; +let droppedCount = 0; +const byPattern: Record = {}; + export function setHapticEnabled(enabled: boolean): void { _hapticEnabled = enabled; } -function _haptic() { - try { - if (navigator.vibrate) { - navigator.vibrate(50); - return; - } +function now(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} +/** + * iOS Safari has no navigator.vibrate, but clicking a hidden `` + * produces the system switch haptic. Note that this fallback CANNOT express a + * pattern -- it fires exactly once regardless of the pattern's shape, so a + * `win` roll and a `land` tick feel identical on those devices. + */ +function _fallbackTap(): void { + try { if (!supportsHaptic) return; const labelEl = document.createElement('label'); @@ -38,23 +139,71 @@ function _haptic() { } } -export function haptic(pattern?: number | number[]): void { - if (!_hapticEnabled) return; - if (pattern && navigator.vibrate) { - navigator.vibrate(pattern); - return; +/** + * The single gate every haptic passes through. Applies the priority-aware + * floor, then sends exactly one pattern. + * + * @returns true if the pattern went out, false if the floor dropped it. + */ +export function fireHaptic(name: HapticName, patternOverride?: HapticPattern): boolean { + if (!_hapticEnabled) return false; + + const priority = HAPTIC_PRIORITY[name]; + const t = now(); + + // Priority-aware floor: only a strictly higher priority may interrupt. + if (t - lastAt < FLOOR_MS && priority <= lastPriority) { + droppedCount++; + return false; } - _haptic(); + lastAt = t; + lastPriority = priority; + firedCount++; + byPattern[name] = (byPattern[name] ?? 0) + 1; + + const pattern = patternOverride ?? HAPTICS[name]; + + try { + if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') { + navigator.vibrate(pattern); + return true; + } + } catch { + // Safari throws rather than reporting absence; fall through to the tap. + } + + _fallbackTap(); + return true; } +/** + * Legacy entry point, kept for the ~24 existing tap call sites. Maps onto + * `land` -- a tick acknowledging your own press. A caller-supplied pattern is + * still honoured, but at `land` priority: it is still just a tap. + */ +export function haptic(pattern?: HapticPattern): void { + fireHaptic('land', pattern); +} + +/** + * Legacy entry point for confirm taps (Challenge!, Coup, choosing which influence + * to lose). Maps onto `confirm` -- `targeted`'s pattern at a tap's priority. + */ export function hapticHeavy(): void { - if (!_hapticEnabled) return; - if (navigator.vibrate) { - navigator.vibrate([50, 70, 50]); - return; - } + fireHaptic('confirm'); +} + +/** Stats for later gating -- "did the vocabulary actually survive a real game?" */ +export function hapticStats(): { fired: number; dropped: number; byPattern: Record } { + return { fired: firedCount, dropped: droppedCount, byPattern: { ...byPattern } }; +} - _haptic(); - setTimeout(() => _haptic(), 120); +/** Test hook: clears the floor and the counters. */ +export function __resetHaptics(): void { + lastAt = -1e9; + lastPriority = 0; + firedCount = 0; + droppedCount = 0; + for (const key of Object.keys(byPattern)) delete byPattern[key]; } diff --git a/tests/app/anim/clock.test.ts b/tests/app/anim/clock.test.ts new file mode 100644 index 0000000..9d6ec09 --- /dev/null +++ b/tests/app/anim/clock.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + subscribe, + unsubscribe, + now, + start, + reset, + frameCount, + subCount, + isRunning, + type ClockSubscriber, +} from '@/app/anim/clock'; +import { installRaf, type RafHarness } from './fakeDom'; + +let raf: RafHarness; + +beforeEach(() => { + reset(); + raf = installRaf(); +}); + +afterEach(() => { + reset(); + raf.restore(); +}); + +describe('clock — dt clamping', () => { + it('clamps a stalled tab to 1/20s instead of teleporting', () => { + const seen: number[] = []; + subscribe((dt) => seen.push(dt)); + + raf.frame(16); // first frame after start has no previous timestamp + raf.frame(16); + raf.stall(3000); // three seconds backgrounded + raf.frame(16); + + expect(seen[0]).toBe(0); + expect(seen[1]).toBeCloseTo(0.016, 9); + expect(seen[2]).toBe(1 / 20); + expect(seen[3]).toBeCloseTo(0.016, 9); + }); + + it('clamps a backwards or zero timestamp to 0', () => { + const seen: number[] = []; + subscribe((dt) => seen.push(dt)); + + raf.frame(16); + raf.frame(0); + raf.frame(-500); + raf.frame(8); + + expect(seen).toEqual([0, 0, 0, 0.008]); + }); + + it('now() accumulates the clamped dt, not wall time', () => { + subscribe(() => {}); + raf.frame(16); + raf.frame(40); + raf.stall(5000); + + // 0 + 0.04 + clamp(5) → 0.09, not 5.056 + expect(now()).toBeCloseTo(0.09, 9); + expect(frameCount()).toBe(3); + }); +}); + +describe('clock — lifecycle', () => { + it('starts on the first subscriber and stops when the last one leaves', () => { + expect(isRunning()).toBe(false); + + const fn: ClockSubscriber = () => {}; + subscribe(fn); + expect(isRunning()).toBe(true); + expect(raf.pending()).toBe(1); + + raf.frame(16); + expect(raf.pending()).toBe(1); + + unsubscribe(fn); + expect(subCount()).toBe(0); + + raf.frame(16); // the already-scheduled frame notices the empty list + expect(isRunning()).toBe(false); + expect(raf.pending()).toBe(0); + + // and it stays stopped + raf.frame(16); + expect(frameCount()).toBe(2); + }); + + it('restarts cleanly after stopping, with no dt jump across the gap', () => { + const seen: number[] = []; + const fn: ClockSubscriber = (dt) => seen.push(dt); + subscribe(fn); + raf.frame(16); + unsubscribe(fn); + raf.frame(16); + expect(isRunning()).toBe(false); + + subscribe(fn); + raf.stall(9000); + expect(seen[seen.length - 1]).toBe(0); + }); + + it('the unsubscribe returned by subscribe works', () => { + const fn = vi.fn(); + const off = subscribe(fn); + raf.frame(16); + expect(fn).toHaveBeenCalledTimes(1); + off(); + raf.frame(16); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('start() is idempotent — one loop, not two', () => { + subscribe(() => {}); + start(); + start(); + expect(raf.pending()).toBe(1); + }); +}); + +describe('clock — mutation during a tick', () => { + it('defers adds and removes to between frames', () => { + const order: string[] = []; + const late: ClockSubscriber = () => order.push('late'); + const other: ClockSubscriber = () => order.push('other'); + const first: ClockSubscriber = () => { + order.push('first'); + subscribe(late); + unsubscribe(first); + }; + + subscribe(first); + subscribe(other); + + raf.frame(16); + // `late` was added mid-tick and must not run in the frame that added it; + // `other` must still run even though `first` removed itself mid-walk. + expect(order).toEqual(['first', 'other']); + + order.length = 0; + raf.frame(16); + expect(order).toEqual(['other', 'late']); + expect(subCount()).toBe(2); + }); + + it('a subscriber that throws does not wedge the loop', () => { + const good = vi.fn(); + const bad: ClockSubscriber = () => { + throw new Error('boom'); + }; + subscribe(bad); + subscribe(good); + + expect(() => raf.frame(16)).toThrow('boom'); + // The tick flag was released in a finally, so the next add is not deferred + // forever and the loop is still scheduled. + expect(raf.pending()).toBe(1); + unsubscribe(bad); + raf.frame(16); + expect(good).toHaveBeenCalledTimes(1); + }); +}); + +describe('clock — SSR safety', () => { + it('subscribing with no requestAnimationFrame is inert, not an exception', () => { + raf.restore(); + const g = globalThis as { requestAnimationFrame?: unknown }; + const original = g.requestAnimationFrame; + delete g.requestAnimationFrame; + try { + expect(() => subscribe(() => {})).not.toThrow(); + expect(isRunning()).toBe(false); + expect(subCount()).toBe(1); + } finally { + if (original !== undefined) g.requestAnimationFrame = original; + raf = installRaf(); + } + }); +}); diff --git a/tests/app/anim/easing.test.ts b/tests/app/anim/easing.test.ts new file mode 100644 index 0000000..8444b04 --- /dev/null +++ b/tests/app/anim/easing.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { + BACK, + SCALE_BACK, + clamp01, + easeOutBack, + easeOutCubic, + settle, + smoothstep, + hash1, + hashKey, +} from '@/app/anim/easing'; +import { CONTACT } from '@/app/anim/flight'; + +/** + * An independent reference for cubic-bezier(.22,1,.36,1), solved by bisection + * rather than by Newton, so `settle()` is checked against a different method and + * not against a copy of itself. + */ +function bezierRef(p: number, x1: number, y1: number, x2: number, y2: number): number { + const axis = (t: number, a1: number, a2: number): number => { + const u = 1 - t; + return 3 * u * u * t * a1 + 3 * u * t * t * a2 + t * t * t; + }; + if (p <= 0) return 0; + if (p >= 1) return 1; + let lo = 0; + let hi = 1; + for (let i = 0; i < 80; i++) { + const mid = (lo + hi) / 2; + if (axis(mid, x1, x2) < p) lo = mid; + else hi = mid; + } + return axis((lo + hi) / 2, y1, y2); +} + +describe('easeOutBack', () => { + it('pins both endpoints for both ratified constants', () => { + expect(easeOutBack(0, BACK)).toBe(0); + expect(easeOutBack(1, BACK)).toBe(1); + expect(easeOutBack(0, SCALE_BACK)).toBe(0); + expect(easeOutBack(1, SCALE_BACK)).toBe(1); + }); + + it('actually overshoots — the excursion past 1 is the settle', () => { + let peakBack = 0; + let peakScale = 0; + for (let i = 1; i < 1000; i++) { + const p = i / 1000; + peakBack = Math.max(peakBack, easeOutBack(p, BACK)); + peakScale = Math.max(peakScale, easeOutBack(p, SCALE_BACK)); + } + // 2.99% for position/rotation — see the note on BACK: the ratified docs say + // "~4.5%", the closed form says 2.99%, and this is the arithmetic. + // The classic 1.70158 overshoots exactly 10%, which is the bounce the + // constant exists to remove. + expect(peakBack).toBeGreaterThan(1.025); + expect(peakBack).toBeLessThan(1.035); + const classicPeak = easeOutBack(1 - (2 * 1.70158) / (3 * 2.70158), 1.70158); + expect(classicPeak).toBeGreaterThan(1.09); + // Scale overshoots less: its overshoot is measured in edge pixels. + expect(peakScale).toBeGreaterThan(1.0); + expect(peakScale).toBeLessThan(peakBack); + }); + + it('CONTACT is the p where it first crosses 1', () => { + expect(easeOutBack(CONTACT, BACK)).toBeCloseTo(1, 12); + + let firstCrossing = -1; + for (let i = 1; i <= 100000; i++) { + const p = i / 100000; + if (easeOutBack(p, BACK) >= 1) { + firstCrossing = p; + break; + } + } + expect(firstCrossing).toBeGreaterThan(0); + expect(Math.abs(firstCrossing - CONTACT)).toBeLessThan(1e-3); + // and it is strictly below 1 just before + expect(easeOutBack(CONTACT - 0.01, BACK)).toBeLessThan(1); + }); +}); + +describe('settle — cubic-bezier(.22, 1, .36, 1)', () => { + it('pins its endpoints', () => { + expect(settle(0)).toBe(0); + expect(settle(1)).toBe(1); + expect(settle(-3)).toBe(0); + expect(settle(4)).toBe(1); + }); + + it('is monotonically non-decreasing', () => { + let prev = 0; + for (let i = 0; i <= 2000; i++) { + const v = settle(i / 2000); + expect(v).toBeGreaterThanOrEqual(prev - 1e-12); + prev = v; + } + }); + + it('never overshoots (y2 = 1 is the ceiling)', () => { + for (let i = 0; i <= 1000; i++) { + expect(settle(i / 1000)).toBeLessThanOrEqual(1 + 1e-9); + } + }); + + it('is within 1e-4 of the true curve at every sampled p', () => { + const samples = [0.02, 0.05, 0.1, 0.15, 0.25, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97, 0.995]; + for (const p of samples) { + expect(Math.abs(settle(p) - bezierRef(p, 0.22, 1, 0.36, 1))).toBeLessThan(1e-4); + } + }); + + it('is measurably better than the easeOutQuint approximation', () => { + const quint = (p: number): number => 1 - Math.pow(1 - p, 5); + let worstQuint = 0; + let worstSettle = 0; + for (let i = 1; i < 1000; i++) { + const p = i / 1000; + const truth = bezierRef(p, 0.22, 1, 0.36, 1); + worstQuint = Math.max(worstQuint, Math.abs(quint(p) - truth)); + worstSettle = Math.max(worstSettle, Math.abs(settle(p) - truth)); + } + // Measured, not quoted: the quintic peaks 1.14% off at p≈0.058 — the + // steepest part of the curve, where the release reads. + expect(worstQuint).toBeGreaterThan(0.011); + expect(worstSettle).toBeLessThan(1e-4); + }); +}); + +describe('the small helpers', () => { + it('smoothstep is clamped, symmetric and flat at both ends', () => { + expect(smoothstep(-1)).toBe(0); + expect(smoothstep(0)).toBe(0); + expect(smoothstep(0.5)).toBeCloseTo(0.5, 12); + expect(smoothstep(1)).toBe(1); + expect(smoothstep(2)).toBe(1); + expect(smoothstep(0.25) + smoothstep(0.75)).toBeCloseTo(1, 12); + }); + + it('easeOutCubic is clamped and pins its endpoints', () => { + expect(easeOutCubic(0)).toBe(0); + expect(easeOutCubic(1)).toBe(1); + expect(easeOutCubic(-1)).toBe(0); + expect(easeOutCubic(2)).toBe(1); + expect(easeOutCubic(0.5)).toBeCloseTo(0.875, 12); + }); + + it('clamp01 clamps', () => { + expect(clamp01(-0.2)).toBe(0); + expect(clamp01(0.4)).toBe(0.4); + expect(clamp01(1.7)).toBe(1); + }); + + it('hash1 is deterministic, in range, and spread', () => { + expect(hash1(7)).toBe(hash1(7)); + let below = 0; + for (let i = 0; i < 256; i++) { + const v = hash1(i); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + if (v < 0.5) below++; + } + expect(below).toBeGreaterThan(80); + expect(below).toBeLessThan(176); + }); + + it('hashKey turns a React string id into a stable seed', () => { + expect(hashKey('duke-1')).toBe(hashKey('duke-1')); + expect(hashKey('duke-1')).not.toBe(hashKey('duke-2')); + expect(hashKey(12)).toBe(12); + expect(hashKey(undefined)).toBe(0); + expect(hashKey(null)).toBe(0); + }); +}); diff --git a/tests/app/anim/fakeDom.ts b/tests/app/anim/fakeDom.ts new file mode 100644 index 0000000..9669786 --- /dev/null +++ b/tests/app/anim/fakeDom.ts @@ -0,0 +1,146 @@ +/** + * Test doubles for the motion engine. + * + * The suite runs in vitest's default node environment (no jsdom), which is + * deliberate: `src/app/anim` is typed against the smallest DOM surface it + * actually uses, so it can be driven by plain objects here and by real + * `HTMLElement`s in the app. These fakes also record every write, which is how + * the quantisation tests count them. + */ + +import type { FlightElement, FlightStyle } from '@/app/anim/flight'; +import type { MeasurableElement, RectLike } from '@/app/anim/flip'; + +export interface StyleWrite { + property: string; + value: string | null; +} + +export class FakeStyle implements FlightStyle { + readonly props = new Map(); + readonly writes: StyleWrite[] = []; + + setProperty(property: string, value: string): void { + this.props.set(property, value); + this.writes.push({ property, value }); + } + + removeProperty(property: string): void { + this.props.delete(property); + this.writes.push({ property, value: null }); + } + + getPropertyValue(property: string): string { + return this.props.get(property) ?? ''; + } +} + +export class FakeElement implements MeasurableElement { + isConnected = true; + readonly style = new FakeStyle(); + offsetWidth = 100; + rect: RectLike = { left: 0, top: 0, width: 100, height: 140 }; + + getBoundingClientRect(): RectLike { + return this.rect; + } + + /** Number of property writes since the marker, for the write-budget tests. */ + writeCount(): number { + return this.style.writes.length; + } + + num(property: string): number { + return parseFloat(this.style.getPropertyValue(property)); + } +} + +export function el(): FakeElement { + return new FakeElement(); +} + +export function asFlightElement(e: FakeElement): FlightElement { + return e; +} + +/* ── a rAF the test drives by hand ───────────────────────────────────────── */ + +export interface RafHarness { + /** Advance one frame of `ms`, running whatever the clock scheduled. */ + frame(ms: number): void; + /** Advance `total` ms in `stepMs` slices. */ + run(total: number, stepMs?: number): void; + /** Jump the timestamp without slicing — a backgrounded tab. */ + stall(ms: number): void; + pending(): number; + elapsedMs(): number; + restore(): void; +} + +interface RafGlobals { + requestAnimationFrame?: (cb: (t: number) => void) => number; + cancelAnimationFrame?: (id: number) => void; +} + +/** + * Install a deterministic requestAnimationFrame. Never uses real timers: a test + * that waits on a real frame is a test that flakes on a loaded CI box. + */ +export function installRaf(): RafHarness { + const g = globalThis as RafGlobals; + const prevRaf = g.requestAnimationFrame; + const prevCancel = g.cancelAnimationFrame; + + const scheduled = new Map void>(); + let nextId = 1; + let t = 0; + + g.requestAnimationFrame = (cb: (time: number) => void): number => { + const id = nextId++; + scheduled.set(id, cb); + return id; + }; + g.cancelAnimationFrame = (id: number): void => { + scheduled.delete(id); + }; + + const fire = (): void => { + if (scheduled.size === 0) return; + const due = Array.from(scheduled.entries()); + scheduled.clear(); + for (const [, cb] of due) cb(t); + }; + + return { + frame(ms: number): void { + t += ms; + fire(); + }, + run(total: number, stepMs = 16): void { + let left = total; + while (left > 0) { + const s = Math.min(stepMs, left); + t += s; + left -= s; + fire(); + } + }, + stall(ms: number): void { + t += ms; + fire(); + }, + pending(): number { + return scheduled.size; + }, + elapsedMs(): number { + return t; + }, + restore(): void { + scheduled.clear(); + if (prevRaf) g.requestAnimationFrame = prevRaf; + else delete g.requestAnimationFrame; + if (prevCancel) g.cancelAnimationFrame = prevCancel; + else delete g.cancelAnimationFrame; + }, + }; +} diff --git a/tests/app/anim/flight.test.ts b/tests/app/anim/flight.test.ts new file mode 100644 index 0000000..0b66209 --- /dev/null +++ b/tests/app/anim/flight.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { reset as resetClock } from '@/app/anim/clock'; +import { + CONTACT, + HITSTOP_MS, + MS_MAX, + MS_MIN, + busyUntil, + cancel, + finishAll, + flightDuration, + fly, + hitstopCount, + isFlying, + liveCount, + punch, + resetFlights, + setRest, + setReducedMotion, + writeRest, + type FlightElement, +} from '@/app/anim/flight'; +import { FakeElement, installRaf, type RafHarness } from './fakeDom'; + +let raf: RafHarness; + +beforeEach(() => { + resetFlights(); + resetClock(); + raf = installRaf(); +}); + +afterEach(() => { + resetFlights(); + resetClock(); + raf.restore(); +}); + +/** Drive frames until `predicate` or the budget runs out; returns ms elapsed. */ +function runUntil(predicate: () => boolean, stepMs = 5, budgetMs = 3000): number { + let t = 0; + while (t < budgetMs) { + raf.frame(stepMs); + t += stepMs; + if (predicate()) return t; + } + return -1; +} + +describe('flightDuration', () => { + it('clamps to [MS_MIN, MS_MAX]', () => { + expect(flightDuration(0)).toBe(MS_MIN); + expect(flightDuration(-50)).toBe(MS_MIN); + expect(flightDuration(100)).toBeCloseTo(222, 9); + expect(flightDuration(10000)).toBe(MS_MAX); + }); + + it('applies speed INSIDE the clamp', () => { + // Outside the clamp these would be 111 and 470.2 respectively; a steal that + // buys itself 50ms of extra travel pushes the whole event past its budget. + expect(flightDuration(100, 0.5)).toBe(MS_MIN); + expect(flightDuration(571, 1.12)).toBe(MS_MAX); + // and where the clamp does not bite, speed is a straight multiplier + expect(flightDuration(200, 1.1)).toBeCloseTo(264 * 1.1, 9); + }); + + it('fly() derives the same duration from its delta', () => { + const el = new FakeElement(); + fly(el, { dx: 100, dy: 0 }); + expect(busyUntil()).toBeCloseTo(222, 6); + }); +}); + +describe('flight — a completed flight', () => { + it('fires land exactly once and lands ON the rest pose, not on zero', () => { + const el = new FakeElement(); + const land = vi.fn(); + const abort = vi.fn(); + setRest(el, 10, -4, 3); + + expect(fly(el, { dx: 120, dy: 0, dur: 200, arc: 0, land, abort })).toBe(true); + expect(isFlying(el)).toBe(true); + + // launch pose = rest + delta + expect(el.style.getPropertyValue('--fx')).toBe('130px'); + expect(el.style.getPropertyValue('--fy')).toBe('-4px'); + + const elapsed = runUntil(() => land.mock.calls.length > 0); + expect(elapsed).toBeGreaterThan(190); + expect(elapsed).toBeLessThan(215); + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + + expect(el.style.getPropertyValue('--fx')).toBe('10px'); + expect(el.style.getPropertyValue('--fy')).toBe('-4px'); + expect(el.style.getPropertyValue('--tilt')).toBe('3deg'); + expect(el.style.getPropertyValue('--fs')).toBe('1'); + + // the record is gone, the clock has nothing left to do + expect(liveCount()).toBe(0); + expect(isFlying(el)).toBe(false); + expect(busyUntil()).toBe(0); + + raf.frame(16); + expect(land).toHaveBeenCalledTimes(1); + }); + + it('honours a delay by holding at the launch pose', () => { + const el = new FakeElement(); + const land = vi.fn(); + fly(el, { dx: 100, dy: 0, dur: 200, arc: 0, delay: 120, land }); + expect(busyUntil()).toBeCloseTo(320, 6); + + raf.run(100, 10); + expect(el.style.getPropertyValue('--fx')).toBe('100px'); + expect(land).not.toHaveBeenCalled(); + + const elapsed = runUntil(() => land.mock.calls.length > 0, 5); + expect(elapsed).toBeGreaterThan(200); + }); +}); + +describe('flight — hitstop', () => { + it('is subtracted from the travel budget, not added to it', () => { + const el = new FakeElement(); + const plain = new FakeElement(); + + fly(plain, { dx: 0, dy: 300, dur: 300, arc: 0 }); + const plainCommit = busyUntil(); + cancel(plain); + + const land = vi.fn(); + fly(el, { dx: 0, dy: 300, dur: 300, arc: 0, hit: true, land }); + + // Same wall-clock commitment: 255ms of travel + a 45ms freeze. + expect(busyUntil()).toBeCloseTo(plainCommit, 6); + expect(busyUntil()).toBeCloseTo(300, 6); + + const elapsed = runUntil(() => land.mock.calls.length > 0, 5); + expect(elapsed).toBeGreaterThanOrEqual(295); + expect(elapsed).toBeLessThanOrEqual(310); + expect(hitstopCount()).toBe(1); + }); + + it('freezes the WHOLE live list, not just the landing element', () => { + const hero = new FakeElement(); + const bystander = new FakeElement(); + fly(hero, { dx: 0, dy: 200, dur: 300, arc: 0, hit: true }); + fly(bystander, { dx: 0, dy: 200, dur: 400, arc: 0 }); + + // Run past contact (0.5263 × 255ms ≈ 134ms) and into the freeze. + raf.run(150, 5); + expect(hitstopCount()).toBe(1); + const frozenAt = bystander.style.getPropertyValue('--fy'); + + raf.run(30, 5); // still inside the 45ms freeze + expect(bystander.style.getPropertyValue('--fy')).toBe(frozenAt); + + raf.run(60, 5); // freeze over + expect(bystander.style.getPropertyValue('--fy')).not.toBe(frozenAt); + }); + + it('fires at CONTACT, before the flight ends', () => { + const el = new FakeElement(); + fly(el, { dx: 0, dy: 300, dur: 300, arc: 0, hit: true }); + const travelMs = 300 - HITSTOP_MS; + raf.run(Math.floor(travelMs * CONTACT) - 10, 5); + expect(hitstopCount()).toBe(0); + raf.run(20, 5); + expect(hitstopCount()).toBe(1); + }); + + it('MIN_GAP makes a caravan one heavy landing, not five', () => { + const cards = [new FakeElement(), new FakeElement(), new FakeElement()]; + cards.forEach((c, i) => { + fly(c, { dx: 0, dy: 200, dur: 260, arc: 0, hit: true, delay: i * 40 }); + }); + raf.run(900, 5); + expect(hitstopCount()).toBe(1); + }); + + it('a second impact after MIN_GAP does freeze again', () => { + const a = new FakeElement(); + const b = new FakeElement(); + fly(a, { dx: 0, dy: 200, dur: 260, arc: 0, hit: true }); + raf.run(600, 5); + expect(hitstopCount()).toBe(1); + fly(b, { dx: 0, dy: 200, dur: 260, arc: 0, hit: true }); + raf.run(600, 5); + expect(hitstopCount()).toBe(2); + }); +}); + +describe('flight — every started flight resolves', () => { + it('cancel fires abort exactly once and never land', () => { + const el = new FakeElement(); + const land = vi.fn(); + let startedFlag: boolean | null = null; + const abort = vi.fn((_el: FlightElement, started: boolean) => { + startedFlag = started; + }); + fly(el, { dx: 200, dy: 0, dur: 300, land, abort }); + + raf.run(100, 10); + cancel(el); + + expect(abort).toHaveBeenCalledTimes(1); + expect(startedFlag).toBe(true); // it had started + expect(land).not.toHaveBeenCalled(); + + raf.run(500, 10); + expect(abort).toHaveBeenCalledTimes(1); + expect(land).not.toHaveBeenCalled(); + expect(liveCount()).toBe(0); + }); + + it('a superseded flight aborts — the new one starts from where it is', () => { + const el = new FakeElement(); + const abortA = vi.fn(); + const landA = vi.fn(); + const landB = vi.fn(); + + fly(el, { dx: 200, dy: 0, dur: 300, arc: 0, land: landA, abort: abortA }); + raf.run(100, 10); + fly(el, { dx: 50, dy: 0, dur: 200, arc: 0, land: landB }); + + expect(abortA).toHaveBeenCalledTimes(1); + expect(landA).not.toHaveBeenCalled(); + expect(liveCount()).toBe(1); + + runUntil(() => landB.mock.calls.length > 0, 5); + expect(landB).toHaveBeenCalledTimes(1); + expect(landA).not.toHaveBeenCalled(); + }); + + it('a cancel inside the delay reports started=false', () => { + const el = new FakeElement(); + let startedFlag: boolean | null = null; + const abort = vi.fn((_el: FlightElement, started: boolean) => { + startedFlag = started; + }); + fly(el, { dx: 200, dy: 0, dur: 200, delay: 150, abort }); + raf.run(40, 10); + cancel(el); + expect(abort).toHaveBeenCalledTimes(1); + expect(startedFlag).toBe(false); + }); + + it('an unmounted node aborts instead of being written to', () => { + const el = new FakeElement(); + const land = vi.fn(); + const abort = vi.fn(); + fly(el, { dx: 200, dy: 0, dur: 300, land, abort }); + raf.run(60, 10); + + el.isConnected = false; + const writesAtUnmount = el.writeCount(); + raf.run(300, 10); + + expect(abort).toHaveBeenCalledTimes(1); + expect(land).not.toHaveBeenCalled(); + expect(el.writeCount()).toBe(writesAtUnmount); + expect(liveCount()).toBe(0); + }); + + it('resetFlights aborts everything in the air', () => { + const el = new FakeElement(); + const abort = vi.fn(); + fly(el, { dx: 200, dy: 0, abort }); + raf.run(50, 10); + resetFlights(); + expect(abort).toHaveBeenCalledTimes(1); + expect(liveCount()).toBe(0); + }); + + it('finishAll snaps to the end and lands', () => { + const el = new FakeElement(); + const land = vi.fn(); + setRest(el, 5, 5, 0); + fly(el, { dx: 200, dy: 0, land }); + raf.run(50, 10); + finishAll(); + expect(land).toHaveBeenCalledTimes(1); + expect(el.style.getPropertyValue('--fx')).toBe('5px'); + expect(liveCount()).toBe(0); + }); +}); + +describe('flight — write budget', () => { + it('costs at most 4 writes per frame and 0 when nothing changed', () => { + const el = new FakeElement(); + fly(el, { dx: 300, dy: 120, dur: 400, spin: 8 }); + + for (let i = 0; i < 20; i++) { + const before = el.writeCount(); + raf.frame(8); + expect(el.writeCount() - before).toBeLessThanOrEqual(4); + } + + const before = el.writeCount(); + raf.frame(0); // a frame with dt 0: same quantised pose, nothing to write + expect(el.writeCount()).toBe(before); + }); + + it('quantises to 0.1px / 0.1deg / 0.001 scale', () => { + const el = new FakeElement(); + fly(el, { dx: 137.77777, dy: -41.3331, dur: 400, scale: 0.31313, spin: 3.14159 }); + for (const w of el.style.writes) { + if (w.value === null) continue; + const n = parseFloat(w.value); + if (w.property === '--fs') { + expect(Math.abs(n * 1000 - Math.round(n * 1000))).toBeLessThan(1e-9); + } else { + expect(Math.abs(n * 10 - Math.round(n * 10))).toBeLessThan(1e-9); + } + } + raf.run(400, 7); + for (const w of el.style.writes) { + if (w.value === null) continue; + const n = parseFloat(w.value); + const q = w.property === '--fs' ? 1000 : 10; + expect(Math.abs(n * q - Math.round(n * q))).toBeLessThan(1e-9); + } + }); +}); + +describe('flight — arc', () => { + it('lifts perpendicular to travel, biased upward', () => { + const el = new FakeElement(); + // Travel is leftward: the upward normal is the one that gets picked. + fly(el, { dx: 200, dy: 0, dur: 400, arc: 20 }); + raf.run(200, 10); + expect(el.num('--fy')).toBeLessThan(-10); + }); + + it('picks the side of a straight-up flight deterministically', () => { + const traceFor = (key: string): number => { + // Reset the clock too: an identical flight must be sampled at an identical + // phase, or this measures frame alignment instead of the hash. + resetFlights(); + resetClock(); + const el = new FakeElement(); + fly(el, { dx: 0, dy: 300, dur: 400, arc: 20, key }); + raf.run(200, 10); + return el.num('--fx'); + }; + + // Same key, same pixels — screenshots have to reproduce. + expect(traceFor('duke-1')).toBe(traceFor('duke-1')); + + // and the hash really does pick both sides across ids + const signs = new Set(); + for (const key of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) { + signs.add(Math.sign(traceFor(key))); + } + expect(signs.has(1)).toBe(true); + expect(signs.has(-1)).toBe(true); + }); + + it('returns to exactly the rest pose regardless of arc and spin', () => { + const el = new FakeElement(); + setRest(el, -7, 12, -5); + const land = vi.fn(); + fly(el, { dx: 200, dy: 90, dur: 300, arc: 26, spin: 26, bump: 0.4, land }); + runUntil(() => land.mock.calls.length > 0, 5); + expect(el.style.getPropertyValue('--fx')).toBe('-7px'); + expect(el.style.getPropertyValue('--fy')).toBe('12px'); + expect(el.style.getPropertyValue('--tilt')).toBe('-5deg'); + expect(el.style.getPropertyValue('--fs')).toBe('1'); + }); +}); + +describe('flight — rest poses', () => { + it('retargets a flight in place when the rest pose moves', () => { + const el = new FakeElement(); + const land = vi.fn(); + fly(el, { dx: 200, dy: 0, dur: 300, arc: 0, land }); + raf.run(100, 10); + setRest(el, 40, 0, 0); // the fan reflowed mid-flight + runUntil(() => land.mock.calls.length > 0, 5); + expect(el.style.getPropertyValue('--fx')).toBe('40px'); + }); + + it('writeRest places an element with no animation', () => { + const el = new FakeElement(); + setRest(el, 3.14159, -2.71828, 1.23456); + writeRest(el); + expect(el.style.getPropertyValue('--fx')).toBe('3.1px'); + expect(el.style.getPropertyValue('--fy')).toBe('-2.7px'); + expect(el.style.getPropertyValue('--tilt')).toBe('1.23deg'); + expect(el.style.getPropertyValue('--fs')).toBe('1'); + expect(liveCount()).toBe(0); + }); +}); + +describe('punch', () => { + it('is a there-and-back with no net travel', () => { + const el = new FakeElement(); + const land = vi.fn(); + setRest(el, 0, 0, 0); + punch(el, -30, 0, { dur: 280, spin: -9, land }); + raf.run(90, 10); + expect(el.num('--fx')).toBeLessThan(-10); + runUntil(() => land.mock.calls.length > 0, 5); + expect(el.style.getPropertyValue('--fx')).toBe('0px'); + expect(el.style.getPropertyValue('--tilt')).toBe('0deg'); + }); +}); + +describe('flight — reduced motion (ART-DIRECTION §7)', () => { + it('fires the landing callback in the SAME TICK', () => { + setReducedMotion(true); + const el = new FakeElement(); + const land = vi.fn(); + const abort = vi.fn(); + setRest(el, 8, -3, 2); + + fly(el, { dx: 400, dy: 250, dur: 300, hit: true, land, abort }); + + // No frames have run. The cue is not hanging off the back of a ramp. + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + // and the element is already at its destination — information, not motion + expect(el.style.getPropertyValue('--fx')).toBe('8px'); + expect(el.style.getPropertyValue('--fy')).toBe('-3px'); + expect(el.style.getPropertyValue('--tilt')).toBe('2deg'); + }); + + it('collapses to an opacity fade of at most 120ms that carries no callback', () => { + setReducedMotion(true); + const el = new FakeElement(); + const land = vi.fn(); + fly(el, { dx: 400, dy: 250, land }); + + expect(parseFloat(el.style.getPropertyValue('opacity'))).toBeLessThan(1); + const settled = runUntil(() => liveCount() === 0, 5); + expect(settled).toBeGreaterThan(0); + expect(settled).toBeLessThanOrEqual(130); + expect(el.style.getPropertyValue('opacity')).toBe(''); + expect(land).toHaveBeenCalledTimes(1); + }); + + it('never freezes the table — there is no motion to freeze', () => { + setReducedMotion(true); + const el = new FakeElement(); + fly(el, { dx: 400, dy: 250, hit: true }); + raf.run(400, 5); + expect(hitstopCount()).toBe(0); + }); + + it('supersedes a flight that was already in the air', () => { + const el = new FakeElement(); + const abort = vi.fn(); + fly(el, { dx: 300, dy: 0, dur: 400, abort }); + raf.run(60, 10); + setReducedMotion(true); + const land = vi.fn(); + fly(el, { dx: 300, dy: 0, land }); + expect(abort).toHaveBeenCalledTimes(1); + expect(land).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/app/anim/flip.test.ts b/tests/app/anim/flip.test.ts new file mode 100644 index 0000000..4ab54de --- /dev/null +++ b/tests/app/anim/flip.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { reset as resetClock } from '@/app/anim/clock'; +import { liveCount, resetFlights, setRest } from '@/app/anim/flight'; +import { invertAndPlay, measureFirst } from '@/app/anim/flip'; +import { FakeElement, installRaf, type RafHarness } from './fakeDom'; + +let raf: RafHarness; + +beforeEach(() => { + resetFlights(); + resetClock(); + raf = installRaf(); +}); + +afterEach(() => { + resetFlights(); + resetClock(); + raf.restore(); +}); + +function at(el: FakeElement, left: number, top: number, width = 100, height = 140): void { + el.rect = { left, top, width, height }; + el.offsetWidth = width; +} + +describe('measureFirst', () => { + it('captures the centroid, the layout width and the live tilt', () => { + const el = new FakeElement(); + at(el, 20, 40, 80, 120); + el.style.setProperty('--tilt', '-6.5deg'); + + const first = measureFirst(el); + expect(first).not.toBeNull(); + expect(first?.cx).toBe(60); + expect(first?.cy).toBe(100); + expect(first?.layoutWidth).toBe(80); + expect(first?.tilt).toBe(-6.5); + }); + + it('returns null for no element', () => { + expect(measureFirst(null)).toBeNull(); + }); +}); + +describe('invertAndPlay — the guards', () => { + it('a first rect with width 0 is placed, not flown', () => { + const el = new FakeElement(); + at(el, 0, 0, 0, 0); + const first = measureFirst(el); + at(el, 300, 200); + + const land = vi.fn(); + expect(invertAndPlay(el, first, { land })).toBe('placed'); + expect(liveCount()).toBe(0); + // placed at rest, and the information still fires in the same tick + expect(el.style.getPropertyValue('--fx')).toBe('0px'); + expect(land).toHaveBeenCalledTimes(1); + }); + + it('a last rect with width 0 is placed — never inverted against 0×0', () => { + const el = new FakeElement(); + at(el, 500, 400); + const first = measureFirst(el); + // The destination is a hidden panel: it measures 0×0 at viewport origin. + at(el, 0, 0, 0, 0); + + const land = vi.fn(); + expect(invertAndPlay(el, first, { land })).toBe('placed'); + expect(liveCount()).toBe(0); + // The bug this guard exists for: a 550,470 invert launching the card at the + // top-left corner of the viewport. + expect(el.style.getPropertyValue('--fx')).toBe('0px'); + expect(el.style.getPropertyValue('--fy')).toBe('0px'); + expect(land).toHaveBeenCalledTimes(1); + }); + + it('a sub-pixel move with no scale change is skipped entirely', () => { + const el = new FakeElement(); + at(el, 100, 100); + const first = measureFirst(el); + at(el, 100.4, 100.3); + + const land = vi.fn(); + expect(invertAndPlay(el, first, { land })).toBe('skipped'); + expect(liveCount()).toBe(0); + expect(land).toHaveBeenCalledTimes(1); + }); + + it('a sub-pixel move WITH a scale change is not skipped', () => { + const el = new FakeElement(); + at(el, 100, 100, 200, 280); + const first = measureFirst(el); + at(el, 150, 170, 100, 140); // same centre, half the size + + expect(invertAndPlay(el, first)).toBe('played'); + expect(el.style.getPropertyValue('--fs')).toBe('2'); + }); + + it('ignores a missing element or a missing snapshot', () => { + const el = new FakeElement(); + expect(invertAndPlay(null, measureFirst(el))).toBe('ignored'); + expect(invertAndPlay(el, null)).toBe('ignored'); + }); +}); + +describe('invertAndPlay — a real move', () => { + it('inverts the measured delta exactly and lands on the rest pose', () => { + const el = new FakeElement(); + at(el, 400, 300); + el.style.setProperty('--tilt', '12deg'); + const first = measureFirst(el); + at(el, 100, 300); // moved 300px left + + const land = vi.fn(); + expect(invertAndPlay(el, first, { dur: 200, arc: 0, land })).toBe('played'); + + // INVERT: sitting at +300 on --fx, i.e. visually where it was. + expect(el.style.getPropertyValue('--fx')).toBe('300px'); + expect(el.style.getPropertyValue('--fy')).toBe('0px'); + // the rotation continues from where it was rather than snapping to 0 + expect(el.style.getPropertyValue('--tilt')).toBe('12deg'); + + raf.run(400, 10); + expect(land).toHaveBeenCalledTimes(1); + expect(el.style.getPropertyValue('--fx')).toBe('0px'); + expect(el.style.getPropertyValue('--tilt')).toBe('0deg'); + }); + + it('lands on a non-zero rest pose', () => { + const el = new FakeElement(); + setRest(el, 14, -9, 4); + at(el, 400, 300); + const first = measureFirst(el); + at(el, 100, 300); + + const land = vi.fn(); + expect(invertAndPlay(el, first, { dur: 200, arc: 0, land })).toBe('played'); + expect(el.style.getPropertyValue('--fx')).toBe('314px'); + + raf.run(400, 10); + expect(el.style.getPropertyValue('--fx')).toBe('14px'); + expect(el.style.getPropertyValue('--fy')).toBe('-9px'); + expect(el.style.getPropertyValue('--tilt')).toBe('4deg'); + }); + + it('cancelling the previous flight is part of the invert', () => { + const el = new FakeElement(); + at(el, 400, 300); + const first1 = measureFirst(el); + at(el, 100, 300); + + const abort = vi.fn(); + expect(invertAndPlay(el, first1, { dur: 300, abort })).toBe('played'); + raf.run(60, 10); + + // It moves again before it landed. + const first2 = measureFirst(el); + at(el, 700, 300); + expect(invertAndPlay(el, first2, { dur: 200 })).toBe('played'); + + expect(abort).toHaveBeenCalledTimes(1); + expect(liveCount()).toBe(1); + }); +}); diff --git a/tests/app/anim/hiddenTabSettle.test.ts b/tests/app/anim/hiddenTabSettle.test.ts new file mode 100644 index 0000000..4be13b1 --- /dev/null +++ b/tests/app/anim/hiddenTabSettle.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ensureHiddenTabSettle, __resetHiddenTabSettle } from '@/app/anim/useFlight'; +import { fly, isFlying, setRest } from '@/app/anim/flight'; +import { reset as resetClock } from '@/app/anim/clock'; +import { el, installRaf, type FakeElement, type RafHarness } from './fakeDom'; + +/** + * A HIDDEN TAB MUST NOT LEAVE A CARD IN THE AIR. + * + * `anim/clock.ts` is a bare `requestAnimationFrame` loop and Chrome does not + * run rAF in a hidden tab, so a flight in progress when the player switches + * away simply stops mid-transform and its `land` callback never fires. That was + * measured in a real browser as 21 seconds of a stuck full-screen reveal plate + * with the card parked at `--fx: -184px`. + * + * The reveal has its own ceiling for this, but the hazard belongs to the clock, + * so the fix is page-lifetime: on `visibilitychange` → hidden, `finishAll()` + * snaps every live flight onto its rest pose and fires `land`. LANDS rather than + * aborts — those flights did arrive, just instantly and unobserved — which keeps + * flight.ts's guarantee that every started flight resolves exactly once. + * + * Node has no `document`, so these tests install a minimal fake with a real + * listener registry. That is enough: the whole mechanism is one event. + */ + +interface FakeDoc { + visibilityState: 'visible' | 'hidden'; + addEventListener(type: string, fn: () => void): void; + removeEventListener(type: string, fn: () => void): void; + /** Test hook: flip visibility and notify, the way a browser would. */ + __setHidden(hidden: boolean): void; +} + +let raf: RafHarness; +let node: FakeElement; +let doc: FakeDoc; + +function installDocument(): FakeDoc { + const listeners = new Map void>>(); + const d: FakeDoc = { + visibilityState: 'visible', + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(fn); + }, + removeEventListener(type, fn) { + listeners.get(type)?.delete(fn); + }, + __setHidden(hidden) { + d.visibilityState = hidden ? 'hidden' : 'visible'; + for (const fn of listeners.get('visibilitychange') ?? []) fn(); + }, + }; + (globalThis as { document?: unknown }).document = d; + return d; +} + +beforeEach(() => { + resetClock(); + raf = installRaf(); + node = el(); + doc = installDocument(); + // Re-arm against THIS document. Without the reset the installer keeps the + // first test's listener and every later case silently tests nothing. + __resetHiddenTabSettle(); +}); + +afterEach(() => { + __resetHiddenTabSettle(); + resetClock(); + raf.restore(); + delete (globalThis as { document?: unknown }).document; +}); + +describe('hidden tab settles every flight', () => { + it('lands an in-flight card instead of leaving it parked', () => { + ensureHiddenTabSettle(); + + const land = vi.fn(); + const abort = vi.fn(); + setRest(node, 0, 0, 0); + fly(node, { dx: -184, dy: 0, dur: 400, land, abort }); + + raf.frame(16); + expect(isFlying(node)).toBe(true); + expect(land).not.toHaveBeenCalled(); + + // The player switches tabs. In a browser rAF stops here; nothing else would + // ever move this card. + doc.__setHidden(true); + + expect(isFlying(node)).toBe(false); + expect(land).toHaveBeenCalledTimes(1); + // It ARRIVED. An abort would say the card never got there, and the table + // would be showing a state the game had already moved past. + expect(abort).not.toHaveBeenCalled(); + }); + + it('leaves the card on its rest pose, not wherever it froze', () => { + ensureHiddenTabSettle(); + + setRest(node, 0, 0, 0); + fly(node, { dx: -184, dy: 40, dur: 400 }); + raf.frame(16); + doc.__setHidden(true); + + // Whatever `--fx` read mid-flight, the settled value is the rest pose. + expect(node.style.getPropertyValue('--fx')).toBe('0px'); + expect(node.style.getPropertyValue('--fy')).toBe('0px'); + }); + + it('does nothing when the tab becomes visible again', () => { + ensureHiddenTabSettle(); + + const land = vi.fn(); + setRest(node, 0, 0, 0); + fly(node, { dx: 100, dy: 0, dur: 400, land }); + raf.frame(16); + + doc.__setHidden(true); + expect(land).toHaveBeenCalledTimes(1); + + // Coming back must not re-land anything or restart the loop. + doc.__setHidden(false); + expect(land).toHaveBeenCalledTimes(1); + expect(isFlying(node)).toBe(false); + }); + + it('is idempotent — installing twice arms one listener, not two', () => { + ensureHiddenTabSettle(); + ensureHiddenTabSettle(); + ensureHiddenTabSettle(); + + const land = vi.fn(); + setRest(node, 0, 0, 0); + fly(node, { dx: 100, dy: 0, dur: 400, land }); + raf.frame(16); + doc.__setHidden(true); + + // Three installs, one landing. A second listener would still only see one + // live flight, so this guards the listener count via `finishAll` being a + // no-op the second time rather than via counting handlers directly. + expect(land).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/app/anim/verbGestures.test.ts b/tests/app/anim/verbGestures.test.ts new file mode 100644 index 0000000..cb588d8 --- /dev/null +++ b/tests/app/anim/verbGestures.test.ts @@ -0,0 +1,551 @@ +/** + * THE SIX VERBS THAT HAD NO CALL SITES, AS THE APP NOW CALLS THEM. + * + * `verbs.test.ts` proves the factories carry §6's numbers. This file proves the + * COMPOSITIONS the components perform with them: which primitive each verb is + * fed to, which element ends up moving, that exactly one of `land`/`abort` + * fires per gesture, that a landing cue never rides on an abort, and that §7's + * collapse leaves the cue sequence identical. + * + * Same contract as the rest of this directory: node environment, hand-driven + * rAF, no jsdom and no real frames. The elements are stand-ins for the four + * real ones — the thrown action card (GameTable's `ActionCardFlight`), a seat + * plate (`.table-seat` / `.table-bottom-hand`), a card inside that seat + * (`.card-flip-wrapper`), and a card in the discard. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { reset as resetClock } from '@/app/anim/clock'; +import { + MAX_EVENT_MS, + cancel, + fly, + hitstopCount, + liveCount, + punch, + resetFlights, + setReducedMotion, + setRest, +} from '@/app/anim/flight'; +import { + ASSASSINATE_REACH, + BLOCK_CUT_DUR, + BLOCK_CUT_ENV, + FALL_DUR, + FLIGHT_TRANSFORM, + FALL_SPIN, + LUNGE_ENV, + SHOVE_DUR, + SHOVE_FRACTION, + SHOVE_SPIN, + STEAL_DELAY_MS, + STEAL_DUR, + STEAL_SPEED, + STRIKE_DUR, + assassinate, + blockCut, + challengeShove, + coupSlam, + influenceTumble, + steal, +} from '@/app/anim/verbs'; +import { FLIGHT_VARS_RESET } from '@/app/components/game/CardFace'; +import { FakeElement, installRaf, type RafHarness } from './fakeDom'; + +let raf: RafHarness; + +beforeEach(() => { + resetFlights(); + resetClock(); + raf = installRaf(); +}); + +afterEach(() => { + setReducedMotion(false); + resetFlights(); + resetClock(); + raf.restore(); +}); + +interface Point { + x: number; + y: number; +} + +/** Two seats a table apart: the actor bottom-left, the target top-right. */ +const ACTOR: Point = { x: 220, y: 700 }; +const TARGET: Point = { x: 820, y: 700 }; + +/** + * Exactly the arithmetic `ActionCardFlight` does. The anchor is where the card + * RESTS and `other` is the far end, chosen per verb so that one vector serves + * both `fly`'s FLIP invert and `punch`'s peak displacement. + */ +function vec(anchor: Point, other: Point): { dx: number; dy: number } { + return { dx: other.x - anchor.x, dy: other.y - anchor.y }; +} + +/** The peak absolute value of a custom property over a whole beat. */ +function peakOf(el: FakeElement, prop: string, ms: number, step = 4): { peak: number; at: number } { + let peak = 0; + let at = 0; + for (let t = 0; t < ms; t += step) { + raf.frame(step); + const v = Math.abs(el.num(prop) || 0); + if (v > peak) { + peak = v; + at = (t + step) / ms; + } + } + return { peak, at }; +} + +/* ── the inheritance stop ────────────────────────────────────────────────── */ + +describe('a seat can fly because the cards inside it are insulated from it', () => { + it('FLIGHT_VARS_RESET declares all four contract variables at their identity', () => { + // Custom properties inherit, and `.card-flip-wrapper` reads `var(--fx, 0px)`. + // Without a zero declared on the container, a seat's shove would be read by + // every card in it as its own displacement and each card would move twice. + expect(FLIGHT_VARS_RESET).toEqual({ '--fx': '0px', '--fy': '0px', '--tilt': '0deg', '--fs': '1' }); + expect(Object.isFrozen(FLIGHT_VARS_RESET)).toBe(true); + }); + + it('it stops exactly the variables the transform reads — no more, no fewer', () => { + // Derived from the contract rather than restated, so a fifth variable added + // to FLIGHT_TRANSFORM cannot slip past the inheritance stop. + const read = [...FLIGHT_TRANSFORM.matchAll(/var\((--[a-z]+),/g)].map(m => m[1]).sort(); + expect(Object.keys(FLIGHT_VARS_RESET).sort()).toEqual(read); + // ...and each identity value is genuinely a no-op for that variable. + const reset = FLIGHT_VARS_RESET as Record; + for (const v of read) expect(parseFloat(reset[v])).toBe(v === '--fs' ? 1 : 0); + }); + + it('the shove writes to the seat and to nothing else', () => { + const seat = new FakeElement(); + const cardInSeat = new FakeElement(); + const before = cardInSeat.writeCount(); + + const o = challengeShove({ width: 56, key: 'loser' }); + punch(seat, o.dx ?? 0, o.dy ?? 0, o); + raf.run(SHOVE_DUR + 60, 8); + + expect(seat.writeCount()).toBeGreaterThan(0); + expect(cardInSeat.writeCount()).toBe(before); + }); +}); + +/* ── §6 Strike: the Coup ─────────────────────────────────────────────────── */ + +describe('coupSlam — the action card thrown at the target', () => { + /** The card rests on the TARGET and launches from the actor. */ + function throwCoup(land: () => void, abort: () => void): FakeElement { + const card = new FakeElement(); + const o = coupSlam({ ...vec(TARGET, ACTOR), key: 'coup-1' }); + expect(fly(card, { ...o, land, abort })).toBe(true); + return card; + } + + it('travels the whole way from the actor\'s seat to the target\'s, in a dead straight line', () => { + const land = vi.fn(); + const abort = vi.fn(); + const card = throwCoup(land, abort); + + // It starts at the actor: −600px of --fx relative to its rest on the target. + expect(card.num('--fx')).toBeCloseTo(ACTOR.x - TARGET.x, 0); + + // arc: 0 is the verb's whole signature. The travel is horizontal, so any + // bow at all would show up in --fy. + for (let i = 0; i < 50; i++) { + raf.frame(8); + expect(card.num('--fy')).toBe(0); + } + expect(card.style.getPropertyValue('--fx')).toBe('0px'); + }); + + it('arms the hitstop, and lands exactly once', () => { + const before = hitstopCount(); + const land = vi.fn(); + const abort = vi.fn(); + throwCoup(land, abort); + + raf.run(STRIKE_DUR + 200, 8); + + expect(hitstopCount()).toBe(before + 1); + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + expect(liveCount()).toBe(0); + }); + + it('a Coup removed mid-air is silent — no landing sound for a card nobody saw arrive', () => { + const land = vi.fn(); + const abort = vi.fn(); + const card = throwCoup(land, abort); + raf.run(90, 8); + + // The component's ceiling, and its unmount cleanup, both do exactly this. + cancel(card); + + expect(land).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledTimes(1); + raf.run(STRIKE_DUR, 8); + expect(land).not.toHaveBeenCalled(); + }); +}); + +/* ── §6 Strike: the Assassinate ──────────────────────────────────────────── */ + +describe('assassinate — the action card lunges from the actor and comes back', () => { + /** The card rests on the ACTOR and lunges at the target. */ + function lunge(land: () => void, abort: () => void): FakeElement { + const card = new FakeElement(); + const v = vec(ACTOR, TARGET); + const o = assassinate({ toX: v.dx, toY: v.dy, key: 'kill-1' }); + expect(punch(card, o.dx ?? 0, o.dy ?? 0, { ...o, land, abort })).toBe(true); + return card; + } + + it('reaches 34% of the way and stops, then returns to exactly where it was', () => { + const land = vi.fn(); + const abort = vi.fn(); + const card = lunge(land, abort); + const distance = TARGET.x - ACTOR.x; + + const { peak, at } = peakOf(card, '--fx', STRIKE_DUR); + expect(peak).toBeCloseTo(distance * ASSASSINATE_REACH, 0); + // §6: past 34% it reads as a second flight rather than a threat. + expect(peak / distance).toBeLessThan(0.35); + // sin(π·p^0.62) peaks at 0.327 — out fast, recover slow. A hit, not a wobble. + expect(at).toBeLessThan(0.5); + expect(0.5 ** (1 / LUNGE_ENV)).toBeCloseTo(0.327, 3); + + raf.run(STRIKE_DUR, 8); + expect(card.style.getPropertyValue('--fx')).toBe('0px'); + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + }); + + it('does NOT freeze the table — a knife a Contessa can still stop is not a moment', () => { + const before = hitstopCount(); + lunge(vi.fn(), vi.fn()); + raf.run(STRIKE_DUR + 120, 8); + expect(hitstopCount()).toBe(before); + }); +}); + +/* ── §6 Take-from: the Steal ─────────────────────────────────────────────── */ + +describe('steal — the card rests on the thief and comes off the victim', () => { + function theft(land: () => void, abort: () => void): FakeElement { + const card = new FakeElement(); + // The driver hands the gesture over as victim → thief, so the anchor is + // the thief and the launch point is the victim. + const o = steal({ ...vec(ACTOR, TARGET), key: 'steal-1' }); + expect(fly(card, { ...o, land, abort })).toBe(true); + return card; + } + + it('holds at the victim\'s seat for 120ms before anything moves', () => { + const card = theft(vi.fn(), vi.fn()); + const launch = card.num('--fx'); + expect(launch).toBeCloseTo(TARGET.x - ACTOR.x, 0); + + // The tell: for a fifth of a second the card just sits there. + raf.run(STEAL_DELAY_MS - 20, 5); + expect(card.num('--fx')).toBe(launch); + + raf.run(60, 5); + expect(card.num('--fx')).not.toBe(launch); + }); + + it('carries a hero lift that is exactly nothing at both ends', () => { + const card = theft(vi.fn(), vi.fn()); + raf.run(STEAL_DELAY_MS, 5); + const { peak } = peakOf(card, '--fs', STEAL_DUR * STEAL_SPEED); + // --fs peaks above 1 mid-flight... + expect(peak).toBeGreaterThan(1); + raf.run(200, 8); + // ...and the landing is untouched by it. + expect(card.style.getPropertyValue('--fs')).toBe('1'); + }); + + it('the whole event — hold plus weighted flight — fits the 600ms budget', () => { + const land = vi.fn(); + theft(land, vi.fn()); + raf.run(MAX_EVENT_MS, 8); + expect(land).toHaveBeenCalledTimes(1); + expect(liveCount()).toBe(0); + }); + + it('a theft cut short inside its own delay never announced itself', () => { + const land = vi.fn(); + const abort = vi.fn(); + const card = theft(land, abort); + raf.run(STEAL_DELAY_MS - 40, 5); + cancel(card); + + expect(land).not.toHaveBeenCalled(); + // `started: false` — flight.ts's second abort argument — is how a caller + // knows the gesture never began. + expect(abort).toHaveBeenCalledTimes(1); + expect(abort.mock.calls[0][1]).toBe(false); + }); +}); + +/* ── §6 Refuse: the two shoves, and the difference between them ──────────── */ + +describe('challengeShove and blockCut — one row, two beats', () => { + const WIDTH = 56; + + function shove(kind: 'challenge' | 'block', dir: { dirX?: number; dirY?: number }, cb?: () => void) { + const seat = new FakeElement(); + const ctx = { width: WIDTH, key: 'loser', ...dir }; + const o = kind === 'challenge' ? challengeShove(ctx) : blockCut(ctx); + punch(seat, o.dx ?? 0, o.dy ?? 0, { ...o, land: cb }); + return { seat, o }; + } + + it('both displace the seat by 22% of a card, never of the seat', () => { + const a = shove('challenge', {}); + const b = shove('block', {}); + const expected = SHOVE_FRACTION * WIDTH; + expect(Math.hypot(a.o.dx ?? 0, a.o.dy ?? 0)).toBeCloseTo(expected, 10); + expect(Math.hypot(b.o.dx ?? 0, b.o.dy ?? 0)).toBeCloseTo(expected, 10); + // ~12px on a desktop seat card. A fifth of the SEAT would be 53px. + expect(expected).toBeLessThan(13); + }); + + it('the challenge shove has a tail; the block is cut dead', () => { + const a = shove('challenge', {}); + const peakA = peakOf(a.seat, '--fx', SHOVE_DUR, 4); + raf.run(120, 8); + + const b = shove('block', {}); + const peakB = peakOf(b.seat, '--fx', BLOCK_CUT_DUR, 4); + + // Same displacement... + expect(peakA.peak).toBeCloseTo(peakB.peak, 0); + // ...different shape. The challenge is out in the first third and spends + // two thirds drifting home; the block goes out for two thirds and snaps. + expect(peakA.at).toBeLessThan(0.5); + expect(peakB.at).toBeGreaterThan(0.5); + expect(0.5 ** (1 / BLOCK_CUT_ENV)).toBeCloseTo(0.6484, 3); + // And it is a shorter beat as well as a shorter recovery. + expect(BLOCK_CUT_DUR).toBeLessThan(SHOVE_DUR); + }); + + it('is pushed directly away from whoever pushed it, and lands back on its pose', () => { + // Winner to the left of the loser: the loser goes further right. + const { seat } = shove('challenge', { dirX: 400, dirY: -300 }); + raf.run(SHOVE_DUR / 3, 4); + expect(seat.num('--fx')).toBeGreaterThan(0); + expect(seat.num('--fy')).toBeLessThan(0); + // The −9° roll is unsigned by any key: the game chose the direction, so + // the roll is not a coin flip. + expect(seat.num('--tilt')).toBeLessThan(0); + expect(SHOVE_SPIN).toBe(-9); + + raf.run(SHOVE_DUR, 4); + expect(seat.style.getPropertyValue('--fx')).toBe('0px'); + expect(seat.style.getPropertyValue('--fy')).toBe('0px'); + expect(seat.style.getPropertyValue('--tilt')).toBe('0deg'); + }); + + it('with no winner on screen it recoils straight back, not towards the viewport corner', () => { + const { o } = shove('block', {}); + expect(o.dx).toBeLessThan(0); + expect(o.dy).toBe(0); + }); + + it('a seat shoved twice resolves the first shove as an abort, exactly once', () => { + const seat = new FakeElement(); + const first = vi.fn(); + const firstAbort = vi.fn(); + const second = vi.fn(); + + const a = challengeShove({ width: WIDTH, key: 'x' }); + punch(seat, a.dx ?? 0, a.dy ?? 0, { ...a, land: first, abort: firstAbort }); + raf.run(60, 6); + + const b = blockCut({ width: WIDTH, key: 'x' }); + punch(seat, b.dx ?? 0, b.dy ?? 0, { ...b, land: second }); + + expect(first).not.toHaveBeenCalled(); + expect(firstAbort).toHaveBeenCalledTimes(1); + raf.run(BLOCK_CUT_DUR + 80, 6); + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + expect(liveCount()).toBe(0); + }); +}); + +/* ── §6 Fall: the influence going to the discard ─────────────────────────── */ + +describe('influenceTumble — the discard card, thrown from its owner\'s seat', () => { + /** The card is IN the discard well and belongs there; it fell from the seat. */ + function fall(land: () => void, abort: () => void): FakeElement { + const card = new FakeElement(); + // Its rest pose is the tilt the discard gives it; the flight lands ON that. + setRest(card, 0, 0, 0); + const o = influenceTumble({ dx: ACTOR.x - 640, dy: ACTOR.y - 300, key: 'Duke' }); + expect(fly(card, { ...o, land, abort })).toBe(true); + return card; + } + + it('is the slowest thing on the table and spins on the way down', () => { + const o = influenceTumble({ dx: -400, dy: 300, key: 'Duke' }); + expect(o.dur).toBe(FALL_DUR); + expect(Math.abs(o.spin ?? 0)).toBe(FALL_SPIN); + expect(o.hit).toBe(true); + + const card = fall(vi.fn(), vi.fn()); + const { peak } = peakOf(card, '--tilt', FALL_DUR * 0.8, 6); + expect(peak).toBeGreaterThan(6); + expect(peak).toBeLessThanOrEqual(FALL_SPIN); + }); + + it('lands face-up on its resting tilt and stays there, cueing once', () => { + const land = vi.fn(); + const abort = vi.fn(); + const card = new FakeElement(); + setRest(card, 0, 0, -4); + const o = influenceTumble({ dx: -420, dy: 260, key: 'Contessa' }); + fly(card, { ...o, land, abort }); + + raf.run(FALL_DUR + 200, 8); + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + // ON the rest pose, never on zero: the discard's tilt survives the flight. + expect(card.style.getPropertyValue('--tilt')).toBe('-4deg'); + expect(card.style.getPropertyValue('--fx')).toBe('0px'); + expect(liveCount()).toBe(0); + }); + + it('a card whose node goes away mid-fall aborts rather than landing', () => { + const land = vi.fn(); + const abort = vi.fn(); + const card = fall(land, abort); + raf.run(120, 8); + card.isConnected = false; + raf.run(60, 8); + + expect(land).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledTimes(1); + expect(liveCount()).toBe(0); + }); +}); + +/* ── the six gestures read as six different shapes ───────────────────────── */ + +describe('the six verbs are actually different from each other', () => { + it('no two of them share a duration, an arc and an envelope', () => { + const v = vec(TARGET, ACTOR); + const shape = (o: { dur?: number; arc?: number; env?: number; delay?: number; hit?: boolean }) => + [o.dur ?? 0, o.arc ?? 'auto', o.env ?? 1, o.delay ?? 0, !!o.hit].join('/'); + + const shapes = [ + shape(coupSlam({ ...v, key: 'k' })), + shape(assassinate({ toX: v.dx, toY: v.dy, key: 'k' })), + shape(steal({ ...v, key: 'k' })), + shape(challengeShove({ width: 56 })), + shape(blockCut({ width: 56 })), + shape(influenceTumble({ ...v, key: 'k' })), + ]; + expect(new Set(shapes).size).toBe(shapes.length); + }); + + it('§6\'s hitstop budget is spent on the Coup and the Fall, and nothing else', () => { + const v = vec(TARGET, ACTOR); + expect(coupSlam({ ...v }).hit).toBe(true); + expect(influenceTumble({ ...v }).hit).toBe(true); + expect(assassinate({ toX: v.dx, toY: v.dy }).hit).toBeUndefined(); + expect(steal({ ...v }).hit).toBeUndefined(); + expect(challengeShove({ width: 56 }).hit).toBeUndefined(); + expect(blockCut({ width: 56 }).hit).toBeUndefined(); + }); +}); + +/* ── §7 ──────────────────────────────────────────────────────────────────── */ + +describe('reduced motion — the same cues, in the same order, with no frames', () => { + /** + * A turn's worth of beats, fired one at a time exactly as the game fires + * them: a knife, a theft, a coup, a block, a challenge, a card lost. The + * order is the sequence's, not the durations', because these never overlap. + */ + function playBeats(settle: (ms: number) => void): string[] { + const order: string[] = []; + const cue = (name: string) => () => order.push(name); + const v = vec(TARGET, ACTOR); + + const knife = new FakeElement(); + const ka = assassinate({ toX: -v.dx, toY: -v.dy, key: 'a' }); + punch(knife, ka.dx ?? 0, ka.dy ?? 0, { ...ka, land: cue('assassinate') }); + settle(STRIKE_DUR + 120); + + const purse = new FakeElement(); + fly(purse, steal({ ...v, key: 's' }, { land: cue('steal') })); + settle(MAX_EVENT_MS + 120); + + const coup = new FakeElement(); + fly(coup, coupSlam({ ...v, key: 'c' }, { land: cue('coup') })); + settle(STRIKE_DUR + 220); + + const blocked = new FakeElement(); + const bc = blockCut({ width: 56, key: 'b' }); + punch(blocked, bc.dx ?? 0, bc.dy ?? 0, { ...bc, land: cue('block') }); + settle(BLOCK_CUT_DUR + 120); + + const loser = new FakeElement(); + const cs = challengeShove({ width: 56, key: 'l' }); + punch(loser, cs.dx ?? 0, cs.dy ?? 0, { ...cs, land: cue('challenge') }); + settle(SHOVE_DUR + 120); + + const lost = new FakeElement(); + fly(lost, influenceTumble({ ...v, key: 'd' }, { land: cue('tumble') })); + settle(FALL_DUR + 220); + + return order; + } + + it('the full-motion order is the sequence\'s order', () => { + const order = playBeats(ms => raf.run(ms, 8)); + expect(order).toEqual(['assassinate', 'steal', 'coup', 'block', 'challenge', 'tumble']); + expect(liveCount()).toBe(0); + }); + + it('collapsed, every cue still fires — in the same order, before a single frame', () => { + setReducedMotion(true); + const order = playBeats(() => { + /* no frames at all: §7 requires the landing callback in the same tick */ + }); + expect(order).toEqual(['assassinate', 'steal', 'coup', 'block', 'challenge', 'tumble']); + expect(raf.elapsedMs()).toBe(0); + }); + + it('collapsed, a card is placed at its destination rather than part way to it', () => { + setReducedMotion(true); + const card = new FakeElement(); + setRest(card, 0, 0, -4); + const land = vi.fn(); + fly(card, influenceTumble({ dx: -420, dy: 260, key: 'Duke' }, { land })); + + expect(land).toHaveBeenCalledTimes(1); + expect(card.style.getPropertyValue('--fx')).toBe('0px'); + expect(card.style.getPropertyValue('--fy')).toBe('0px'); + expect(card.style.getPropertyValue('--tilt')).toBe('-4deg'); + }); + + it('collapsed, a shove ends where it began and still resolves exactly once', () => { + setReducedMotion(true); + const seat = new FakeElement(); + const land = vi.fn(); + const abort = vi.fn(); + const o = challengeShove({ width: 56, key: 'x' }); + punch(seat, o.dx ?? 0, o.dy ?? 0, { ...o, land, abort }); + + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + expect(seat.style.getPropertyValue('--fx')).toBe('0px'); + }); +}); diff --git a/tests/app/anim/verbs.test.ts b/tests/app/anim/verbs.test.ts new file mode 100644 index 0000000..a55a63b --- /dev/null +++ b/tests/app/anim/verbs.test.ts @@ -0,0 +1,920 @@ +/** + * The §6 per-verb table, and the wiring of Coup's cards onto it. + * + * Node environment, hand-driven rAF, no jsdom — same contract as the rest of + * this directory. What is asserted here is everything about the wiring that can + * be asserted without a browser: the two transform authors stay disjoint, an + * exchange reorder mirrors, a superseded flight is silent, and reduced motion + * still cues in the same tick. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { reset as resetClock } from '@/app/anim/clock'; +import { + MAX_EVENT_MS, + MS_MAX, + busyUntil, + cancel, + fly, + hitstopCount, + liveCount, + punch, + resetFlights, + setReducedMotion, + setRest, +} from '@/app/anim/flight'; +import { invertAndPlay, measureFirst } from '@/app/anim/flip'; +import { + ASSASSINATE_REACH, + BLOCK_CUT_DUR, + BLOCK_CUT_ENV, + CHALLENGE_ARRIVE_ARC, + CHALLENGE_ARRIVE_SPIN, + COUP_ARC, + DEAL_ARC, + DEAL_DUR, + DEAL_RISE, + DEAL_SCALE, + DEAL_SPIN, + EXCHANGE_ARC, + EXCHANGE_DUR, + EXCHANGE_OFFSET_MS, + EXCHANGE_SPIN, + FALL_ARC, + FALL_DUR, + FALL_SPIN, + FLIGHT_TRANSFORM, + FLIGHT_TRANSFORM_STYLE, + LUNGE_ENV, + SHOVE_DUR, + SHOVE_FRACTION, + SHOVE_SPIN, + STEAL_ARC, + STEAL_DELAY_MS, + STEAL_DUR, + STEAL_LIFT, + STEAL_SPEED, + STEAL_SPIN, + STRIKE_DUR, + STRIKE_SPIN, + assassinate, + blockCut, + challengeArrive, + challengeShove, + coupSlam, + dealIn, + exchangeSwap, + influenceTumble, + steal, +} from '@/app/anim/verbs'; +import { writeRest } from '@/app/anim/flight'; +import { FakeElement, installRaf, type RafHarness } from './fakeDom'; + +/** Exactly what ExchangeView's layout effect does before it measures LAST. */ +function cancelFlightForMeasure(el: FakeElement): void { + cancel(el); + writeRest(el); +} + +let raf: RafHarness; + +beforeEach(() => { + resetFlights(); + resetClock(); + raf = installRaf(); +}); + +afterEach(() => { + resetFlights(); + resetClock(); + raf.restore(); +}); + +function at(el: FakeElement, left: number, top: number, width = 56, height = 80): void { + el.rect = { left, top, width, height }; + el.offsetWidth = width; +} + +/* ── the transform contract, as the string an element actually carries ───── */ + +describe('FLIGHT_TRANSFORM', () => { + it('is the contract from flight.ts, translate outermost', () => { + expect(FLIGHT_TRANSFORM).toBe( + 'translate(var(--fx, 0px), var(--fy, 0px)) rotate(var(--tilt, 0deg)) scale(var(--fs, 1))', + ); + // Order is the whole point: rotate or scale outside the translate and a + // measured invert lands short by dx·(1−cos θ) and dx·(1−s). + const t = FLIGHT_TRANSFORM; + expect(t.indexOf('translate')).toBeLessThan(t.indexOf('rotate')); + expect(t.indexOf('rotate')).toBeLessThan(t.indexOf('scale')); + }); + + it('every variable has a fallback, so a card that never flew still renders', () => { + for (const v of ['--fx', '--fy', '--tilt', '--fs']) { + expect(FLIGHT_TRANSFORM).toContain(`var(${v}, `); + } + }); + + it('is one frozen object, shared by every card wrapper', () => { + expect(Object.isFrozen(FLIGHT_TRANSFORM_STYLE)).toBe(true); + expect(FLIGHT_TRANSFORM_STYLE.transform).toBe(FLIGHT_TRANSFORM); + }); +}); + +/* ── the two transform authors ───────────────────────────────────────────── */ + +describe('the wrapper flies and the face keeps its press pose', () => { + /** globals.css composes `.card-face`'s transform out of exactly these. */ + const PRESS_VARS = ['--press-s', '--press-y', '--card-lift']; + const FLIGHT_VARS = ['--fx', '--fy', '--tilt', '--fs']; + + it('a flight writes only the flight variables — the press set is untouched', () => { + const wrapper = new FakeElement(); + const face = new FakeElement(); + + // The press pose, as globals.css would leave it mid-press. + face.style.setProperty('--press-s', '0.955'); + face.style.setProperty('--press-y', '2px'); + face.style.setProperty('--card-lift', '1.05'); + const faceWritesBefore = face.writeCount(); + + fly(wrapper, dealIn('Duke')); + raf.run(DEAL_DUR + 60, 8); + + // Everything the flight touched is a flight variable... + const touched = new Set(wrapper.style.writes.map(w => w.property)); + for (const p of touched) expect(FLIGHT_VARS).toContain(p); + // ...and it touched all four. + for (const v of FLIGHT_VARS) expect(touched.has(v)).toBe(true); + + // ...and the inner face was never written to at all. + expect(face.writeCount()).toBe(faceWritesBefore); + expect(face.style.getPropertyValue('--press-s')).toBe('0.955'); + expect(face.style.getPropertyValue('--press-y')).toBe('2px'); + expect(face.style.getPropertyValue('--card-lift')).toBe('1.05'); + }); + + it('the two variable sets are disjoint, so one element could never be both', () => { + for (const p of PRESS_VARS) expect(FLIGHT_VARS).not.toContain(p); + // and the composed transform strings reference only their own set + for (const p of PRESS_VARS) expect(FLIGHT_TRANSFORM).not.toContain(p); + }); +}); + +/* ── §6 Deal / draw ──────────────────────────────────────────────────────── */ + +describe('dealIn — the replacement card (§6 "Deal / draw")', () => { + it('carries the table\'s numbers: arc 14, spin ±3°, 260ms', () => { + const o = dealIn('Ambassador'); + expect(o.arc).toBe(DEAL_ARC); + expect(Math.abs(o.spin ?? 0)).toBe(DEAL_SPIN); + expect(o.dur).toBe(DEAL_DUR); + }); + + it('starts above its slot at a smaller scale — off the deck, growing in', () => { + const o = dealIn('Duke'); + expect(o.dx).toBe(0); + expect(o.dy).toBe(-DEAL_RISE); + expect(o.scale).toBe(DEAL_SCALE); + expect(o.scale).toBeLessThan(1); + }); + + it('picks its spin side from the key, not from Math.random', () => { + const a = dealIn('Duke').spin; + const b = dealIn('Duke').spin; + expect(a).toBe(b); + // and the two characters this actually swaps between do not both go the + // same way by accident of one hash + const spins = new Set( + ['Duke', 'Assassin', 'Captain', 'Ambassador', 'Contessa', 'Inquisitor'] + .map(c => Math.sign(dealIn(c).spin ?? 0)), + ); + expect(spins.size).toBe(2); + }); + + it('caller options win — the land cue is not something the verb decides', () => { + const land = vi.fn(); + const o = dealIn('Duke', { land, dur: 999 }); + expect(o.land).toBe(land); + expect(o.dur).toBe(999); + }); + + it('lands on the rest pose and cues exactly once', () => { + const el = new FakeElement(); + const land = vi.fn(); + const abort = vi.fn(); + setRest(el, 6, -2, 4); + + fly(el, dealIn('Captain', { land, abort })); + raf.run(DEAL_DUR + 80, 8); + + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + expect(el.style.getPropertyValue('--fx')).toBe('6px'); + expect(el.style.getPropertyValue('--fy')).toBe('-2px'); + expect(el.style.getPropertyValue('--tilt')).toBe('4deg'); + expect(el.style.getPropertyValue('--fs')).toBe('1'); + }); +}); + +/* ── §6 Swap ─────────────────────────────────────────────────────────────── */ + +describe('exchangeSwap — mirrored arcs (§6 "Swap")', () => { + it('a card travelling left goes over; one travelling right goes under', () => { + // dx is the invert: positive means the card IS to the right of its slot and + // is therefore travelling left. + const goingLeft = exchangeSwap({ dx: 64, dy: 0 }); + const goingRight = exchangeSwap({ dx: -64, dy: 0 }); + + expect(goingLeft.arc).toBe(EXCHANGE_ARC); + expect(goingRight.arc).toBe(-EXCHANGE_ARC); + expect(Math.sign(goingLeft.arc ?? 0)).toBe(-Math.sign(goingRight.arc ?? 0)); + expect(Math.abs(goingLeft.arc ?? 0)).toBe(Math.abs(goingRight.arc ?? 0)); + }); + + it('the spin mirrors with the arc, at ±8°', () => { + expect(exchangeSwap({ dx: 64, dy: 0 }).spin).toBe(EXCHANGE_SPIN); + expect(exchangeSwap({ dx: -64, dy: 0 }).spin).toBe(-EXCHANGE_SPIN); + }); + + it('a purely vertical move (the row wrapped) still picks a side', () => { + expect(exchangeSwap({ dx: 0, dy: 40 }).arc).toBe(EXCHANGE_ARC); + expect(exchangeSwap({ dx: 0, dy: -40 }).arc).toBe(-EXCHANGE_ARC); + // never a straight slide — that is the one shape this verb rules out + expect(exchangeSwap({ dx: 0, dy: 0 }).arc).not.toBe(0); + }); + + it('the touched card leads; the others are offset by 60ms', () => { + expect(exchangeSwap({ dx: 64, dy: 0, lead: true }).delay).toBe(0); + expect(exchangeSwap({ dx: -64, dy: 0 }).delay).toBe(EXCHANGE_OFFSET_MS); + expect(exchangeSwap({ dx: -64, dy: 0, lead: false }).delay).toBe(EXCHANGE_OFFSET_MS); + }); + + it('runs at 380ms, inside the 600ms event budget even with the offset', () => { + expect(EXCHANGE_DUR).toBe(380); + expect(EXCHANGE_DUR + EXCHANGE_OFFSET_MS).toBeLessThanOrEqual(600); + }); +}); + +describe('an exchange reorder — two cards actually passing each other', () => { + /** + * The reorder ExchangeView performs: card B (right) is selected and moves to + * the front, card A (left) shifts right to make room. Two FLIPs, mirrored. + */ + function reorder(): { a: FakeElement; b: FakeElement; land: ReturnType } { + const a = new FakeElement(); + const b = new FakeElement(); + at(a, 0, 0); + at(b, 64, 0); + + const firstA = measureFirst(a); + const firstB = measureFirst(b); + + // the commit: B is now first, A second + at(b, 0, 0); + at(a, 64, 0); + + const land = vi.fn(); + const play = (el: FakeElement, first: typeof firstA, lead: boolean) => { + const rect = el.getBoundingClientRect(); + const dx = (first?.cx ?? 0) - (rect.left + rect.width / 2); + const dy = (first?.cy ?? 0) - (rect.top + rect.height / 2); + return invertAndPlay( + el, + first, + exchangeSwap({ dx, dy, lead, key: lead ? 'b' : 'a' }, lead ? { land } : {}), + ); + }; + + // B is the card the player touched. + expect(play(b, firstB, true)).toBe('played'); + expect(play(a, firstA, false)).toBe('played'); + return { a, b, land }; + } + + it('produces two flights whose arcs have opposite signs', () => { + const { a, b } = reorder(); + expect(liveCount()).toBe(2); + + // Mid-flight for the leader, which is where the arc is at its widest. The + // travel is horizontal, so the entire arc shows up in --fy. + raf.run(EXCHANGE_DUR / 2, 5); + const fyB = b.num('--fy'); + raf.run(EXCHANGE_OFFSET_MS, 5); + const fyA = a.num('--fy'); + + // B travels left → over the top (negative --fy is up). + expect(fyB).toBeLessThan(-1); + // A travels right → under. + expect(fyA).toBeGreaterThan(1); + expect(Math.sign(fyA)).toBe(-Math.sign(fyB)); + + // and the tilts mirror too + expect(Math.sign(a.num('--tilt'))).toBe(-Math.sign(b.num('--tilt'))); + }); + + it('the follower is still held at its launch pose 60ms after the leader left', () => { + const { a, b } = reorder(); + + // 40ms in: inside the follower's delay. The leader has moved off its start; + // the follower has not. + raf.run(40, 5); + expect(b.num('--fx')).not.toBe(64); + expect(a.num('--fx')).toBe(-64); + + // Past the offset, it is under way. + raf.run(EXCHANGE_OFFSET_MS + 40, 5); + expect(a.num('--fx')).not.toBe(-64); + }); + + it('both land on their slots, and exactly one cue is fired for the gesture', () => { + const { a, b, land } = reorder(); + raf.run(EXCHANGE_DUR + EXCHANGE_OFFSET_MS + 120, 8); + + expect(liveCount()).toBe(0); + expect(a.num('--fx')).toBe(0); + expect(a.num('--fy')).toBe(0); + expect(b.num('--fx')).toBe(0); + expect(b.num('--fy')).toBe(0); + // one gesture, one landing cue — not one per card that happened to shift + expect(land).toHaveBeenCalledTimes(1); + }); +}); + +describe('a second tap while the first swap is still in the air', () => { + /** + * Found in a browser, not in review. `invertAndPlay` cancels the live flight + * and writes the rest pose BEFORE taking its own LAST measurement, so a + * caller that measures `last` itself — which ExchangeView must, to know which + * way the card is travelling before it can pick an arc side — has to + * neutralise first or it reads the mid-flight box while the engine reads the + * rest box. Mid-flight the two can have OPPOSITE SIGNS, and the card then + * bows the wrong way over a delta it never travels. + */ + /** + * A FakeElement's rect ignores the transform; a browser's does not, and the + * whole bug lives in that difference. This one folds the live `--fx/--fy` + * into its box, which is what `getBoundingClientRect` actually does. + */ + class TransformedElement extends FakeElement { + override getBoundingClientRect() { + const fx = parseFloat(this.style.getPropertyValue('--fx')) || 0; + const fy = parseFloat(this.style.getPropertyValue('--fy')) || 0; + return { + left: this.rect.left + fx, + top: this.rect.top + fy, + width: this.rect.width, + height: this.rect.height, + }; + } + } + + it('the un-neutralised delta can disagree with the engine\'s about which way the card goes', () => { + const el = new TransformedElement(); + at(el, 100, 0); + + // Tap one: the card is sent from slot 100 to slot 260 and is still in the air. + const firstA = measureFirst(el); + at(el, 260, 0); + invertAndPlay(el, firstA, exchangeSwap({ dx: -160, dy: 0, lead: true })); + raf.run(120, 8); + const liveFx = el.num('--fx'); + expect(liveFx).toBeLessThan(0); + + // Tap two: FIRST is snapshotted (transform included, correctly), then the + // reorder moves the slot. The move is deliberately SHORTER than the + // distance still left in the air — that is the whole window in which the + // two measurements disagree, and it is exactly the window a double-tap + // lands in. + const firstB = measureFirst(el); + at(el, 260 + Math.round(liveFx / 2), 0); + + // What the caller reads if it measures LAST before neutralising… + const naiveRect = el.getBoundingClientRect(); + const naiveDx = (firstB?.cx ?? 0) - (naiveRect.left + naiveRect.width / 2); + // …versus what the engine will actually fly, which is measured after + // cancel + writeRest. + cancel(el); + writeRest(el); + const trueRect = el.getBoundingClientRect(); + const trueDx = (firstB?.cx ?? 0) - (trueRect.left + trueRect.width / 2); + + expect(Math.sign(naiveDx)).not.toBe(Math.sign(trueDx)); + // …so the arc side, which follows the delta, comes out mirrored the wrong + // way over a delta the card never travels. That is the bug the neutralise + // in ExchangeView's layout effect removes. + expect(Math.sign(exchangeSwap({ dx: naiveDx, dy: 0 }).arc ?? 0)) + .not.toBe(Math.sign(exchangeSwap({ dx: trueDx, dy: 0 }).arc ?? 0)); + }); + + it('the interrupted flight aborts, and the replacement flies from where it got to', () => { + const el = new TransformedElement(); + at(el, 100, 0); + + const cue = vi.fn(); + const aborted = vi.fn(); + const firstA = measureFirst(el); + at(el, 260, 0); + invertAndPlay(el, firstA, exchangeSwap({ dx: -160, dy: 0, lead: true }, { land: cue, abort: aborted })); + raf.run(120, 8); + const seenAt = el.getBoundingClientRect().left; + + const firstB = measureFirst(el); + at(el, 180, 0); + cancelFlightForMeasure(el); + const rect = el.getBoundingClientRect(); + const dx = (firstB?.cx ?? 0) - (rect.left + rect.width / 2); + invertAndPlay(el, firstB, exchangeSwap({ dx, dy: 0, lead: true }, { land: cue })); + + // The first flight resolved as an abort — no cue for a card that never landed. + expect(aborted).toHaveBeenCalledTimes(1); + expect(cue).not.toHaveBeenCalled(); + // and the replacement launches from where the card visibly was, not from + // the slot it was originally sent to. + expect(el.getBoundingClientRect().left).toBeCloseTo(seenAt, 0); + + raf.run(EXCHANGE_DUR + 120, 8); + expect(cue).toHaveBeenCalledTimes(1); + expect(el.num('--fx')).toBe(0); + expect(el.num('--fy')).toBe(0); + expect(liveCount()).toBe(0); + }); +}); + +/* ── the cue contract: every started flight resolves, and only one way ───── */ + +describe('a landing cue is never fired by a flight that did not land', () => { + it('a superseded flight aborts silently — chudopoly\'s 173 cues to 159 landings', () => { + const el = new FakeElement(); + const cue = vi.fn(); + const aborted = vi.fn(); + + fly(el, dealIn('Duke', { land: cue, abort: aborted })); + raf.run(80, 8); + + // The reconcile: a second replacement arrives while the first is landing. + const cue2 = vi.fn(); + fly(el, dealIn('Captain', { land: cue2 })); + + expect(cue).not.toHaveBeenCalled(); + expect(aborted).toHaveBeenCalledTimes(1); + + raf.run(DEAL_DUR + 80, 8); + // the survivor lands, and cues, exactly once + expect(cue).not.toHaveBeenCalled(); + expect(cue2).toHaveBeenCalledTimes(1); + }); + + it('an unmounted node aborts silently — the React seam\'s ref cleanup', () => { + const el = new FakeElement(); + const cue = vi.fn(); + const aborted = vi.fn(); + + fly(el, dealIn('Contessa', { land: cue, abort: aborted })); + raf.run(80, 8); + + // useFlight's ref cleanup, and ExchangeView's, both do exactly this. + cancel(el); + + expect(cue).not.toHaveBeenCalled(); + expect(aborted).toHaveBeenCalledTimes(1); + expect(liveCount()).toBe(0); + + raf.run(DEAL_DUR, 8); + expect(cue).not.toHaveBeenCalled(); + }); + + it('a cancelled exchange member is silent even when it was the cued one', () => { + const el = new FakeElement(); + at(el, 0, 0); + const first = measureFirst(el); + at(el, 64, 0); + + const cue = vi.fn(); + invertAndPlay(el, first, exchangeSwap({ dx: -64, dy: 0, lead: true }, { land: cue })); + raf.run(100, 8); + cancel(el); + + expect(cue).not.toHaveBeenCalled(); + }); + + it('cue counts match landing counts across a run of swaps', () => { + const cue = vi.fn(); + let landings = 0; + const els = [new FakeElement(), new FakeElement(), new FakeElement()]; + + for (let round = 0; round < 6; round++) { + for (const el of els) { + fly(el, dealIn(`r${round}`, { land: () => { landings++; cue(); } })); + } + // half the rounds are interrupted before anything lands + raf.run(round % 2 === 0 ? DEAL_DUR + 40 : 60, 10); + } + raf.run(DEAL_DUR + 80, 10); + + expect(cue).toHaveBeenCalledTimes(landings); + expect(liveCount()).toBe(0); + }); +}); + +/* ── §7 ──────────────────────────────────────────────────────────────────── */ + +describe('reduced motion — the cue still fires, in the same tick', () => { + afterEach(() => setReducedMotion(false)); + + it('a deal-in collapses and cues synchronously, with no frame run at all', () => { + setReducedMotion(true); + const el = new FakeElement(); + setRest(el, 5, -3, 2); + const cue = vi.fn(); + + fly(el, dealIn('Duke', { land: cue })); + + // Not "eventually" — before a single frame has been driven. + expect(cue).toHaveBeenCalledTimes(1); + expect(raf.elapsedMs()).toBe(0); + // and the card is at its slot, not part way to it + expect(el.style.getPropertyValue('--fx')).toBe('5px'); + expect(el.style.getPropertyValue('--fy')).toBe('-3px'); + expect(el.style.getPropertyValue('--tilt')).toBe('2deg'); + }); + + it('an exchange swap collapses and still cues once', () => { + setReducedMotion(true); + const el = new FakeElement(); + at(el, 0, 0); + const first = measureFirst(el); + at(el, 64, 0); + + const cue = vi.fn(); + invertAndPlay(el, first, exchangeSwap({ dx: -64, dy: 0, lead: true }, { land: cue })); + + expect(cue).toHaveBeenCalledTimes(1); + expect(el.num('--fx')).toBe(0); + }); + + it('the collapse fades but hangs nothing informational off the fade', () => { + setReducedMotion(true); + const el = new FakeElement(); + const cue = vi.fn(); + fly(el, dealIn('Captain', { land: cue })); + + // The cosmetic ramp is a fade record with no callbacks on it. + expect(liveCount()).toBe(1); + expect(cue).toHaveBeenCalledTimes(1); + + raf.run(200, 10); + expect(liveCount()).toBe(0); + expect(cue).toHaveBeenCalledTimes(1); + // opacity override removed, so no stale inline value fights a CSS state + expect(el.style.getPropertyValue('opacity')).toBe(''); + }); +}); + +/* ── the rest of §6's table ──────────────────────────────────────────────── */ + +describe('assassinate — §6\'s 34% lunge', () => { + it('reaches exactly 34% of the way to the target and no further', () => { + const o = assassinate({ toX: 400, toY: 0 }); + expect(ASSASSINATE_REACH).toBe(0.34); + expect(o.dx).toBeCloseTo(400 * 0.34, 10); + expect(o.dy).toBe(0); + // The claim §6 attaches to the number: past this it reads as a flight. + expect((o.dx ?? 0) / 400).toBeCloseTo(0.34, 10); + }); + + it('carries the Strike row\'s numbers and does NOT arm hitstop', () => { + const o = assassinate({ toX: 0, toY: 300 }); + expect(o.dur).toBe(STRIKE_DUR); + expect(Math.abs(o.spin ?? 0)).toBe(STRIKE_SPIN); + expect(o.env).toBe(LUNGE_ENV); + // §6 arms it on three things and a knife that a Contessa can still stop is + // not one of them. + expect(o.hit).toBeUndefined(); + }); + + it('lunges and comes back to exactly where it started', () => { + const el = new FakeElement(); + setRest(el, 12, -7, 3); + const land = vi.fn(); + + const o = assassinate({ toX: -260, toY: 120, key: 'victim' }); + punch(el, o.dx ?? 0, o.dy ?? 0, { ...o, land }); + + // Out: at the envelope's peak it is displaced towards the target. + raf.run(Math.round(STRIKE_DUR * 0.33), 4); + expect(el.num('--fx')).toBeLessThan(12); + expect(el.num('--fy')).toBeGreaterThan(-7); + + // Back: a punch has no net travel, so it lands on the rest pose it left. + raf.run(STRIKE_DUR, 4); + expect(land).toHaveBeenCalledTimes(1); + expect(el.style.getPropertyValue('--fx')).toBe('12px'); + expect(el.style.getPropertyValue('--fy')).toBe('-7px'); + expect(el.style.getPropertyValue('--tilt')).toBe('3deg'); + expect(liveCount()).toBe(0); + }); + + it('peaks in the first third — a hit, not a wobble', () => { + const el = new FakeElement(); + const o = assassinate({ toX: 300, toY: 0 }); + punch(el, o.dx ?? 0, o.dy ?? 0, o); + + // sin(π·p^0.62) peaks at p = 0.5^(1/0.62) = 0.327. + expect(0.5 ** (1 / LUNGE_ENV)).toBeCloseTo(0.327, 3); + + let peakAt = 0; + let peak = 0; + for (let t = 0; t < STRIKE_DUR; t += 4) { + raf.frame(4); + const v = Math.abs(el.num('--fx')); + if (v > peak) { + peak = v; + peakAt = (t + 4) / STRIKE_DUR; + } + } + expect(peakAt).toBeLessThan(0.5); + expect(peak).toBeCloseTo(300 * ASSASSINATE_REACH, 0); + }); +}); + +describe('coupSlam — straight, fast, armed', () => { + it('has an arc of exactly zero', () => { + expect(coupSlam({ dx: 200, dy: 0 }).arc).toBe(0); + expect(COUP_ARC).toBe(0); + }); + + it('keeps the Strike row\'s 5° roll despite the straight line', () => { + const o = coupSlam({ dx: 200, dy: 0, key: 'target' }); + expect(Math.abs(o.spin ?? 0)).toBe(STRIKE_SPIN); + expect(o.dur).toBe(STRIKE_DUR); + expect(o.hit).toBe(true); + }); + + it('never leaves the line between where it started and where it lands', () => { + const el = new FakeElement(); + setRest(el, 0, 0); + fly(el, coupSlam({ dx: 240, dy: 0, key: 'k' })); + + // Horizontal travel, so any bow at all shows up as a non-zero --fy. + for (let i = 0; i < 40; i++) { + raf.frame(8); + expect(el.num('--fy')).toBe(0); + } + }); + + it('freezes the table on contact and lands on the rest pose', () => { + const before = hitstopCount(); + const el = new FakeElement(); + setRest(el, 4, 9, -2); + const land = vi.fn(); + + fly(el, coupSlam({ dx: 300, dy: -120, key: 'coup' }, { land })); + raf.run(STRIKE_DUR + 200, 8); + + expect(hitstopCount()).toBe(before + 1); + expect(land).toHaveBeenCalledTimes(1); + expect(el.style.getPropertyValue('--fx')).toBe('4px'); + expect(el.style.getPropertyValue('--fy')).toBe('9px'); + expect(el.style.getPropertyValue('--tilt')).toBe('-2deg'); + }); +}); + +describe('steal — the delay is the tell', () => { + it('carries the Take-from row: arc 22, ±6°, 340ms, 120ms delay, hero lift', () => { + const o = steal({ dx: -180, dy: 40, key: 's' }); + expect(o.arc).toBe(STEAL_ARC); + expect(STEAL_ARC).toBe(22); + expect(Math.abs(o.spin ?? 0)).toBe(STEAL_SPIN); + expect(o.dur).toBe(STEAL_DUR); + expect(o.delay).toBe(STEAL_DELAY_MS); + expect(o.speed).toBe(STEAL_SPEED); + expect(o.bump).toBe(STEAL_LIFT); + // The lift must stay well under the deal's 14% growth or a steal reads as + // a second deal. + expect(STEAL_LIFT).toBeLessThan((1 - DEAL_SCALE) / 2); + }); + + it('fits the 600ms event budget with the delay and the 1.12x weight', () => { + // 340 x 1.12 = 380.8ms of flight, which is what the multiplier actually + // costs — §6's "340ms" is the base, not the wall time. + const wall = STEAL_DUR * STEAL_SPEED; + expect(wall).toBeCloseTo(380.8, 6); + expect(STEAL_DELAY_MS + wall).toBeLessThanOrEqual(MAX_EVENT_MS); + + const el = new FakeElement(); + fly(el, steal({ dx: -300, dy: 0, key: 's' })); + // The engine's own accounting agrees, which is the number that matters: + // budgetDelay() would have clipped the delay if it did not fit. + expect(busyUntil()).toBeLessThanOrEqual(MAX_EVENT_MS); + expect(busyUntil()).toBeCloseTo(STEAL_DELAY_MS + wall, 6); + }); + + it('holds at the launch pose for the whole delay, then leaves', () => { + const el = new FakeElement(); + setRest(el, 0, 0); + fly(el, steal({ dx: -300, dy: 0, key: 's' })); + + // Parked at the start of the path — visibly about to happen. + raf.run(STEAL_DELAY_MS - 20, 5); + expect(el.num('--fx')).toBe(-300); + + raf.run(60, 5); + expect(el.num('--fx')).toBeGreaterThan(-300); + }); + + it('lands on the rest pose with the lift spent', () => { + const el = new FakeElement(); + setRest(el, -6, 3, 5); + const land = vi.fn(); + fly(el, steal({ dx: -300, dy: 0, key: 's' }, { land })); + raf.run(MAX_EVENT_MS + 40, 8); + + expect(land).toHaveBeenCalledTimes(1); + expect(el.style.getPropertyValue('--fx')).toBe('-6px'); + expect(el.style.getPropertyValue('--fy')).toBe('3px'); + expect(el.style.getPropertyValue('--tilt')).toBe('5deg'); + // `bump` rides the arc envelope, so it is exactly 0 at both ends. + expect(el.style.getPropertyValue('--fs')).toBe('1'); + }); +}); + +describe('the Refuse row — one row, two elements', () => { + const WIDTH = 112; + + it('shoves back 22% of the card\'s own width', () => { + const o = challengeShove({ width: WIDTH }); + expect(SHOVE_FRACTION).toBe(0.22); + expect(o.dx).toBeCloseTo(-0.22 * WIDTH, 10); + expect(o.dy).toBe(0); + expect(o.dur).toBe(SHOVE_DUR); + expect(o.spin).toBe(SHOVE_SPIN); + expect(SHOVE_SPIN).toBe(-9); + }); + + it('normalises the direction, so the shove is the same size whatever vector it is given', () => { + const near = challengeShove({ width: WIDTH, dirX: 3, dirY: 4 }); + const far = challengeShove({ width: WIDTH, dirX: 300, dirY: 400 }); + const size = (o: { dx?: number; dy?: number }) => Math.hypot(o.dx ?? 0, o.dy ?? 0); + expect(size(near)).toBeCloseTo(SHOVE_FRACTION * WIDTH, 10); + expect(size(far)).toBeCloseTo(SHOVE_FRACTION * WIDTH, 10); + expect(near.dx).toBeCloseTo(far.dx ?? 0, 10); + }); + + it('the spin does not pick a side from a hash — the game chose the direction', () => { + const spins = new Set( + ['a', 'b', 'c', 'd'].map(k => challengeShove({ width: WIDTH, key: k }).spin), + ); + expect(spins).toEqual(new Set([SHOVE_SPIN])); + }); + + it('blockCut is the same shove with the tail taken off', () => { + const shove = challengeShove({ width: WIDTH }); + const cut = blockCut({ width: WIDTH }); + // Same displacement, same roll… + expect(cut.dx).toBe(shove.dx); + expect(cut.spin).toBe(shove.spin); + // …different envelope and a shorter beat. + expect(cut.env).toBe(BLOCK_CUT_ENV); + expect(cut.dur).toBe(BLOCK_CUT_DUR); + expect(BLOCK_CUT_DUR).toBeLessThan(SHOVE_DUR); + }); + + it('the cut peaks late and the shove peaks early — that is what "no tail" means', () => { + function peakFraction(o: ReturnType, dur: number): number { + const el = new FakeElement(); + punch(el, o.dx ?? 0, o.dy ?? 0, o); + let peak = 0; + let at = 0; + for (let t = 0; t < dur; t += 2) { + raf.frame(2); + const v = Math.abs(el.num('--fx')); + if (v > peak) { + peak = v; + at = (t + 2) / dur; + } + } + cancel(el); + return at; + } + + const shoveAt = peakFraction(challengeShove({ width: WIDTH }), SHOVE_DUR); + const cutAt = peakFraction(blockCut({ width: WIDTH }), BLOCK_CUT_DUR); + + // sin(π·p^0.62) peaks at 0.327; sin(π·p^1.6) peaks at 0.649. + expect(shoveAt).toBeCloseTo(0.5 ** (1 / LUNGE_ENV), 1); + expect(cutAt).toBeCloseTo(0.5 ** (1 / BLOCK_CUT_ENV), 1); + expect(cutAt).toBeGreaterThan(shoveAt); + // The recovery is what got cut: two thirds of the beat becomes a third. + expect(1 - cutAt).toBeLessThan((1 - shoveAt) / 1.5); + }); + + it('both shoves end exactly where they began', () => { + for (const o of [challengeShove({ width: WIDTH }), blockCut({ width: WIDTH })]) { + const el = new FakeElement(); + setRest(el, 3, -4, 1); + punch(el, o.dx ?? 0, o.dy ?? 0, o); + raf.run(SHOVE_DUR + 80, 6); + expect(el.style.getPropertyValue('--fx')).toBe('3px'); + expect(el.style.getPropertyValue('--fy')).toBe('-4px'); + expect(el.style.getPropertyValue('--tilt')).toBe('1deg'); + } + expect(liveCount()).toBe(0); + }); + + it('challengeArrive is the other half: flat, fast and armed', () => { + const o = challengeArrive({ dx: 300, dy: -200, key: 'acc' }); + expect(o.arc).toBe(CHALLENGE_ARRIVE_ARC); + // The flattest bow on the table — a card thrown as evidence, not dealt. + expect(CHALLENGE_ARRIVE_ARC).toBeLessThan(DEAL_ARC); + expect(Math.abs(o.spin ?? 0)).toBe(CHALLENGE_ARRIVE_SPIN); + expect(o.dur).toBe(SHOVE_DUR); + expect(o.hit).toBe(true); + }); + + it('challengeArrive lands on the rest pose and cues once', () => { + const el = new FakeElement(); + setRest(el, 0, 0, 0); + const land = vi.fn(); + const abort = vi.fn(); + fly(el, challengeArrive({ dx: -420, dy: 260, key: 'acc' }, { land, abort })); + raf.run(SHOVE_DUR + 200, 8); + + expect(land).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + expect(el.num('--fx')).toBe(0); + expect(el.num('--fy')).toBe(0); + }); +}); + +describe('influenceTumble — §6\'s Fall', () => { + it('carries arc 22, ±26° and the table\'s longest beat', () => { + const o = influenceTumble({ dx: 120, dy: -300, key: 'lost' }); + expect(o.arc).toBe(FALL_ARC); + expect(Math.abs(o.spin ?? 0)).toBe(FALL_SPIN); + expect(FALL_SPIN).toBe(26); + expect(o.dur).toBe(FALL_DUR); + expect(o.hit).toBe(true); + // 420ms is MS_MAX exactly: nothing on this table is slower, and no + // distance can make the one irreversible event slower still. + expect(FALL_DUR).toBe(MS_MAX); + }); + + it('tumbles both ways across the six characters, deterministically', () => { + const spins = new Set( + ['Duke', 'Assassin', 'Captain', 'Ambassador', 'Contessa', 'Inquisitor'] + .map(c => Math.sign(influenceTumble({ dx: 0, dy: -200, key: c }).spin ?? 0)), + ); + expect(spins.size).toBe(2); + expect(influenceTumble({ dx: 0, dy: -200, key: 'Duke' }).spin) + .toBe(influenceTumble({ dx: 0, dy: -200, key: 'Duke' }).spin); + }); + + it('lands face-up on the discard\'s rest tilt and stays there', () => { + const el = new FakeElement(); + // A discard rests tilted — the whole reason flights land ON a rest pose. + setRest(el, 0, 0, -4); + const land = vi.fn(); + fly(el, influenceTumble({ dx: 40, dy: -280, key: 'Contessa' }, { land })); + raf.run(FALL_DUR + 200, 8); + + expect(land).toHaveBeenCalledTimes(1); + expect(el.style.getPropertyValue('--tilt')).toBe('-4deg'); + expect(liveCount()).toBe(0); + + // Nothing moves it afterwards: "stays there" is a property of the engine, + // not of a CSS animation that happens to end on the right frame. + const writes = el.writeCount(); + raf.run(500, 16); + expect(el.writeCount()).toBe(writes); + }); +}); + +/* ── §6's motion budget, as an absence ───────────────────────────────────── */ + +describe('the quiet verbs get nothing', () => { + it('has no verb for Income, Tax, Foreign Aid or Embezzle', async () => { + // §6: "Take … deliberately plain … the card does not move." The budget is + // two world-stopping moments and it only balances because the twelve beats + // around them spend nothing. This is that decision, executable. + const verbs = await import('@/app/anim/verbs'); + for (const name of ['income', 'tax', 'foreignAid', 'embezzle', 'take']) { + expect(verbs).not.toHaveProperty(name); + } + }); + + it('every verb that does exist differs from every other in arc, spin or duration', () => { + // §6: "they must differ, or a Steal feels like a Tax." + const shape = (o: { arc?: number; spin?: number; dur?: number }) => + `${o.arc ?? 'auto'}|${Math.abs(o.spin ?? 0)}|${o.dur ?? 'auto'}`; + const shapes = [ + shape(dealIn('k')), + shape(exchangeSwap({ dx: 10, dy: 0 })), + shape(coupSlam({ dx: 10, dy: 0, key: 'k' })), + shape(steal({ dx: 10, dy: 0, key: 'k' })), + shape(challengeArrive({ dx: 10, dy: 0, key: 'k' })), + shape(influenceTumble({ dx: 10, dy: 0, key: 'k' })), + ]; + expect(new Set(shapes).size).toBe(shapes.length); + }); +}); diff --git a/tests/app/audio/analysis.test.ts b/tests/app/audio/analysis.test.ts new file mode 100644 index 0000000..323ea13 --- /dev/null +++ b/tests/app/audio/analysis.test.ts @@ -0,0 +1,119 @@ +/** + * The spectral half of the mix gate runs on a hand-rolled FFT, and a hand-rolled + * FFT that is wrong produces a confident, plausible, wrong answer — which is the + * one failure mode the whole measured-mix exercise exists to avoid. So it is + * checked against signals whose spectrum is known before the fact. + * + * Level analysis (peak / RMS / short-term RMS) is exercised in mix.test.ts + * against the recorded render; this file covers only the pure DSP. + */ +import { describe, expect, it } from 'vitest'; +import { + LOW_BAND_HZ, + OCTAVE_CENTRES, + fftInPlace, + spectrumOf, +} from './analysis'; + +const SR = 48000; + +/** A steady sine, N samples, at `hz`. */ +function sine(hz: number, n: number, amp = 0.5): Float32Array { + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = amp * Math.sin((2 * Math.PI * hz * i) / SR); + return out; +} + +describe('fftInPlace', () => { + it('puts a bin-centred sinusoid in exactly that bin', () => { + const n = 1024; + const bin = 64; + const re = new Float64Array(n); + const im = new Float64Array(n); + for (let i = 0; i < n; i++) re[i] = Math.cos((2 * Math.PI * bin * i) / n); + fftInPlace(re, im); + const mag = (k: number) => Math.hypot(re[k], im[k]); + expect(mag(bin)).toBeCloseTo(n / 2, 6); + // Everything else is numerical dust. + for (let k = 0; k < n / 2; k++) { + if (k === bin) continue; + expect(mag(k)).toBeLessThan(1e-8); + } + }); + + it('transforms a delta into a flat magnitude spectrum', () => { + const n = 256; + const re = new Float64Array(n); + const im = new Float64Array(n); + re[0] = 1; + fftInPlace(re, im); + for (let k = 0; k < n; k++) expect(Math.hypot(re[k], im[k])).toBeCloseTo(1, 9); + }); + + it('is linear', () => { + const n = 64; + const mk = (f: (i: number) => number) => { + const re = new Float64Array(n); + const im = new Float64Array(n); + for (let i = 0; i < n; i++) re[i] = f(i); + fftInPlace(re, im); + return { re, im }; + }; + const a = (i: number) => Math.sin(i); + const b = (i: number) => Math.cos(i * 0.3); + const A = mk(a); + const B = mk(b); + const S = mk(i => a(i) + 2 * b(i)); + for (let k = 0; k < n; k++) { + expect(S.re[k]).toBeCloseTo(A.re[k] + 2 * B.re[k], 9); + expect(S.im[k]).toBeCloseTo(A.im[k] + 2 * B.im[k], 9); + } + }); +}); + +describe('spectrumOf', () => { + it('lands a pure tone in its own octave band and nowhere else', () => { + const s = spectrumOf([sine(1000, SR / 2)], SR); + const kHz = OCTAVE_CENTRES.indexOf(1000); + expect(s.bandsDb[kHz]).toBeGreaterThan(-0.1); + for (let i = 0; i < s.bandsDb.length; i++) { + if (i === kHz) continue; + expect(s.bandsDb[i], `band ${OCTAVE_CENTRES[i]}Hz`).toBeLessThan(-40); + } + expect(s.centroidHz).toBeCloseTo(1000, -1); + }); + + it('reports the centroid between two equal tones', () => { + const a = sine(400, SR / 2); + const b = sine(1600, SR / 2); + const mix = new Float32Array(a.length); + for (let i = 0; i < a.length; i++) mix[i] = a[i] + b[i]; + // Equal amplitudes → equal power → the power-weighted mean is the + // ARITHMETIC mean, not the geometric one. 1000, not 800. + expect(spectrumOf([mix], SR).centroidHz).toBeCloseTo(1000, -1); + }); + + it('is a shape measurement, not a level measurement', () => { + const loud = spectrumOf([sine(500, SR / 2, 0.8)], SR); + const quiet = spectrumOf([sine(500, SR / 2, 0.008)], SR); + // Bands 100dB down are float32 quantisation dust and do move; every band + // that describes the sound is identical across a 40dB level change. + for (let i = 0; i < loud.bandsDb.length; i++) { + if (loud.bandsDb[i] < -60 && quiet.bandsDb[i] < -60) continue; + expect(loud.bandsDb[i], `band ${OCTAVE_CENTRES[i]}Hz`).toBeCloseTo(quiet.bandsDb[i], 1); + } + expect(loud.centroidHz).toBeCloseTo(quiet.centroidHz, 1); + }); + + it('counts only what is under LOW_BAND_HZ as low', () => { + expect(spectrumOf([sine(60, SR / 2)], SR).lowDb).toBeGreaterThan(-0.1); + expect(spectrumOf([sine(LOW_BAND_HZ * 4, SR / 2)], SR).lowDb).toBeLessThan(-40); + }); + + it('returns silence rather than NaN for an empty render', () => { + const s = spectrumOf([new Float32Array(1024)], SR); + expect(s.lowDb).toBe(-Infinity); + expect(s.centroidHz).toBe(0); + expect(s.bandsDb.every(v => v === -Infinity)).toBe(true); + }); +}); diff --git a/tests/app/audio/analysis.ts b/tests/app/audio/analysis.ts new file mode 100644 index 0000000..74ea7f4 --- /dev/null +++ b/tests/app/audio/analysis.ts @@ -0,0 +1,290 @@ +/** + * Level analysis for the offline audio render. Pure functions over sample data: + * no Web Audio, no DOM, so the same code runs in the browser harness (bundled + * by esbuild) and under vitest. + * + * PEAK ALONE IS MISLEADING. A mastered stinger and a 60ms synth blip can share a + * peak and be 12dB apart in perceived loudness, because the blip is one + * transient and the stinger is a second of sustained energy. chudopoly's gate + * shipped a peak-vs-peak comparison that read PASS on a build whose owner could + * not hear the music; the fix there was to stop comparing peaks. So every cue + * here is reported as BOTH a true peak and an RMS taken over the cue's own + * active window — the second number is the one that tracks what a player hears, + * and the tier ordering is asserted on it as well as on the peak. + */ + +/** dBFS for a linear magnitude. Silence is −Infinity, and that is intentional. */ +export function toDbfs(magnitude: number): number { + return magnitude > 0 ? 20 * Math.log10(magnitude) : -Infinity; +} + +/** Largest absolute sample across all channels. */ +export function peakOf(channels: readonly Float32Array[]): number { + let peak = 0; + for (const ch of channels) { + for (let i = 0; i < ch.length; i++) { + const a = Math.abs(ch[i]); + if (a > peak) peak = a; + } + } + return peak; +} + +/** + * The cue's own active window: [first, last) sample indices whose magnitude + * clears `floorDb` below the cue's peak, on any channel. + * + * A fixed render length would make RMS a function of how much trailing silence + * the harness happened to render, which is a measurement of the harness. −45dB + * below peak is low enough to include a stinger's ring-out and high enough to + * exclude the noise floor of a bandpassed burst. + */ +export function activeWindow( + channels: readonly Float32Array[], + floorDb = -45, +): { start: number; end: number } { + const peak = peakOf(channels); + if (peak <= 0) return { start: 0, end: 0 }; + const threshold = peak * Math.pow(10, floorDb / 20); + const n = channels[0]?.length ?? 0; + let start = -1; + let end = 0; + for (let i = 0; i < n; i++) { + let loud = false; + for (const ch of channels) { + if (Math.abs(ch[i]) >= threshold) { loud = true; break; } + } + if (loud) { + if (start < 0) start = i; + end = i + 1; + } + } + return start < 0 ? { start: 0, end: 0 } : { start, end }; +} + +/** + * RMS over a sample range, summed across channels and divided by the channel + * count — so a mono-in-stereo cue and a hard-panned one are compared on the + * same scale rather than the panned one reading 3dB quiet. + */ +export function rmsOf( + channels: readonly Float32Array[], + start: number, + end: number, +): number { + const span = end - start; + if (span <= 0 || channels.length === 0) return 0; + let sum = 0; + for (const ch of channels) { + for (let i = start; i < end; i++) sum += ch[i] * ch[i]; + } + return Math.sqrt(sum / (span * channels.length)); +} + +/** Window for the short-term loudness figure. See `shortTermRms`. */ +export const SHORT_TERM_MS = 300; + +/** + * The loudest `windowMs` sliding-window RMS in the render. + * + * ── WHY THIS EXISTS ALONGSIDE ACTIVE-WINDOW RMS ───────────────────────────── + * Active-window RMS is a function of how long the cue rings. A 6-second + * mastered stinger with a reverb tail and a 60ms square blip can be equally + * loud where it counts and read 10dB apart, because the stinger's average is + * dragged down by five seconds of decay the blip does not have. That makes + * active-window RMS unusable for comparing cues of different LENGTHS, which is + * exactly what a tier ordering does. + * + * A fixed 300ms window is duration-independent: it asks "at its loudest, how + * much energy does this cue put into the room over the time a listener + * integrates?" — which is the number the tier rule is actually about. The tier + * gate runs on this and on true peak. Active-window RMS stays in the record + * because it is what shows a cue is all transient or all sustain. + */ +export function shortTermRms( + channels: readonly Float32Array[], + sampleRate: number, + windowMs = SHORT_TERM_MS, +): number { + const n = channels[0]?.length ?? 0; + const chCount = channels.length; + if (n === 0 || chCount === 0) return 0; + const w = Math.min(n, Math.max(1, Math.round((windowMs / 1000) * sampleRate))); + + // Prefix sums of the per-sample cross-channel square sum: O(n) rather than + // O(n·w), which matters at 9s × 48kHz × 22 cues. + const prefix = new Float64Array(n + 1); + for (let i = 0; i < n; i++) { + let s = 0; + for (const ch of channels) s += ch[i] * ch[i]; + prefix[i + 1] = prefix[i] + s; + } + let best = 0; + for (let i = 0; i + w <= n; i++) { + const mean = (prefix[i + w] - prefix[i]) / (w * chCount); + if (mean > best) best = mean; + } + return Math.sqrt(best); +} + +/* ── spectrum ─────────────────────────────────────────────────────────────── */ + +/** + * In-place iterative radix-2 Cooley-Tukey FFT. `re`/`im` must be the same + * power-of-two length. + * + * Hand-rolled rather than pulled in, because this file is imported by both the + * esbuild-bundled browser harness and vitest, and a dependency that exists only + * to compare two cues' spectra is a dependency the app now ships a resolution + * for. It is thirty lines. + */ +export function fftInPlace(re: Float64Array, im: Float64Array): void { + const n = re.length; + if (n <= 1) return; + // bit reversal + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + const tr = re[i]; re[i] = re[j]; re[j] = tr; + const ti = im[i]; im[i] = im[j]; im[j] = ti; + } + } + for (let len = 2; len <= n; len <<= 1) { + const ang = (-2 * Math.PI) / len; + const wr = Math.cos(ang); + const wi = Math.sin(ang); + for (let i = 0; i < n; i += len) { + let cr = 1; + let ci = 0; + for (let k = 0; k < len / 2; k++) { + const ur = re[i + k]; + const ui = im[i + k]; + const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; + const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; + re[i + k] = ur + vr; + im[i + k] = ui + vi; + re[i + k + len / 2] = ur - vr; + im[i + k + len / 2] = ui - vi; + const nr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; + cr = nr; + } + } + } +} + +/** + * Octave-band centres, ISO. The bottom two are the ones that matter here: + * `influenceLoss` and `challengeRevealFail` both live down there and `denied` + * deliberately does not. + */ +export const OCTAVE_CENTRES = [63, 125, 250, 500, 1000, 2000, 4000, 8000] as const; + +/** Everything below this is "chest". A refusal must not have any. */ +export const LOW_BAND_HZ = 160; + +export interface Spectrum { + /** Energy per octave band, dB relative to the cue's TOTAL energy. */ + bandsDb: number[]; + /** Energy below LOW_BAND_HZ, dB relative to total. The confusion axis. */ + lowDb: number; + /** Power-weighted mean frequency, Hz. */ + centroidHz: number; +} + +/** + * Octave-band energy over the cue's active window, normalised to the cue's own + * total energy — so this describes SHAPE, not level. Two cues at the same trim + * and two cues 20dB apart give the same answer here, which is the point: the + * tier table already gates level, and this gates timbre. + * + * Summed across channels, Hann-windowed, zero-padded to a power of two. + */ +export function spectrumOf( + channels: readonly Float32Array[], + sampleRate: number, + floorDb = -45, +): Spectrum { + const { start, end } = activeWindow(channels, floorDb); + const span = end - start; + if (span <= 0) return { bandsDb: OCTAVE_CENTRES.map(() => -Infinity), lowDb: -Infinity, centroidHz: 0 }; + let n = 1; + while (n < span) n <<= 1; + const re = new Float64Array(n); + const im = new Float64Array(n); + for (let i = 0; i < span; i++) { + let s = 0; + for (const ch of channels) s += ch[start + i]; + // Hann, so a truncated cue does not smear its own edges across the bands. + re[i] = s * 0.5 * (1 - Math.cos((2 * Math.PI * i) / (span - 1 || 1))); + } + fftInPlace(re, im); + + const half = n >> 1; + const power = new Float64Array(half); + let total = 0; + for (let k = 0; k < half; k++) { + power[k] = re[k] * re[k] + im[k] * im[k]; + total += power[k]; + } + const hzPerBin = sampleRate / n; + const sumRange = (loHz: number, hiHz: number): number => { + let s = 0; + const k0 = Math.max(0, Math.ceil(loHz / hzPerBin)); + const k1 = Math.min(half - 1, Math.floor(hiHz / hzPerBin)); + for (let k = k0; k <= k1; k++) s += power[k]; + return s; + }; + const rel = (v: number): number => (total > 0 && v > 0 ? round2(10 * Math.log10(v / total)) : -Infinity); + + let weighted = 0; + for (let k = 1; k < half; k++) weighted += power[k] * k * hzPerBin; + + return { + bandsDb: OCTAVE_CENTRES.map(c => rel(sumRange(c / Math.SQRT2, c * Math.SQRT2))), + lowDb: rel(sumRange(0, LOW_BAND_HZ)), + centroidHz: total > 0 ? round2(weighted / total) : 0, + }; +} + +export interface CueMeasurement { + /** True peak, dBFS. */ + peakDb: number; + /** RMS over the active window, dBFS. Duration-dependent — see shortTermRms. */ + rmsDb: number; + /** Loudest 300ms sliding-window RMS, dBFS. The tier-ordering loudness axis. */ + stRmsDb: number; + /** Active window length in milliseconds — the window `rmsDb` was taken over. */ + activeMs: number; + /** When the true peak occurs, ms from render start. */ + peakMs: number; +} + +/** Peak, active-window RMS, short-term RMS and window length for one render. */ +export function measure( + channels: readonly Float32Array[], + sampleRate: number, + floorDb = -45, +): CueMeasurement { + const peak = peakOf(channels); + const { start, end } = activeWindow(channels, floorDb); + let peakIndex = 0; + outer: for (let i = 0; i < (channels[0]?.length ?? 0); i++) { + for (const ch of channels) { + if (Math.abs(ch[i]) >= peak) { peakIndex = i; break outer; } + } + } + return { + peakMs: round2((peakIndex / sampleRate) * 1000), + peakDb: round2(toDbfs(peak)), + rmsDb: round2(toDbfs(rmsOf(channels, start, end))), + stRmsDb: round2(toDbfs(shortTermRms(channels, sampleRate))), + activeMs: round2(((end - start) / sampleRate) * 1000), + }; +} + +function round2(v: number): number { + return Number.isFinite(v) ? Math.round(v * 100) / 100 : v; +} diff --git a/tests/app/audio/harness.entry.ts b/tests/app/audio/harness.entry.ts new file mode 100644 index 0000000..a4b5350 --- /dev/null +++ b/tests/app/audio/harness.entry.ts @@ -0,0 +1,223 @@ +/** + * Browser entry point for the offline audio render. + * + * `OfflineAudioContext` does not exist in Node, and node-web-audio-api is a + * DIFFERENT DSP implementation from the one shipping to players — a compressor + * and a WaveShaper measured there are somebody else's compressor and somebody + * else's WaveShaper, and the ceiling this mix is gated against (−0.645 dBFS) + * is a Chrome number. So the render happens in a real Chrome, through + * `renderSoundOffline()`, which is exported from `SoundEngine.ts` and shares + * `buildGraph()`, `startVoice()` and `voiceGain()` with the live `play()` path. + * There is one graph implementation; this file only drives it and does the + * arithmetic. + * + * Bundled and served by the procedure in `docs/AUDIO-MIX.md`. + */ +import { + heroClips, + renderSoundOffline, + softClipCeiling, + soundIds, + MIX_TRIM_DB, + type RenderOptions, + type SoundId, +} from '../../../src/app/audio/SoundEngine'; +import { measure, spectrumOf, toDbfs, type CueMeasurement, type Spectrum } from './analysis'; + +/** Hero clips are ~7s; every synth voice's longest tail is 1.85s. */ +const HERO_SECONDS = 9; +const SYNTH_SECONDS = 4; + +export interface Row extends CueMeasurement { + id: SoundId; + trimDb: number; + /** Hero cues appear twice: once as the mastered clip, once as the fallback. */ + source: 'synth' | 'clip'; + /** + * dB of gain reduction the compressor + soft clip apply to this cue at its + * shipped trim. 0.00 means the master chain is linear here; a large number + * means the cue is riding the limiter and its level is being set by the + * limiter rather than by MIX_DB. See `RenderOptions.trimOffsetDb`. + */ + limiterDb: number; + /** + * The same probe on the 300ms loudness figure. Peak reduction and sustained + * reduction are different numbers — a 4ms attack lets transients through + * while the body of the cue is squashed — and the sustained one is the one + * that moves a cue's place in the tier ordering. + */ + limiterStDb: number; +} + +/** Level offset for the linearity probe. Far enough below the knee to be linear. */ +const PROBE_DB = -20; + +export interface PairRow extends CueMeasurement { + label: string; + limiterDb: number; + limiterStDb: number; +} + +function round2(v: number): number { + return Math.round(v * 100) / 100; +} + +/** + * Timbre, not level. A cue can be correctly levelled and still be the wrong + * SOUND — `timerWarning` stood in for a refusal for exactly that reason, at the + * right weight and the wrong shape. `CONTRAST_IDS` is the set where "these two + * must never be confused" is a design requirement, and this is the evidence. + */ +export interface ContrastRow extends Spectrum { + id: SoundId; + /** Length of the cue's own active window, ms. Duration is half the contrast. */ + activeMs: number; +} + +const CONTRAST_IDS: readonly SoundId[] = [ + 'denied', 'timerWarning', 'influenceLoss', 'challengeRevealFail', +]; + +export interface HarnessReport { + generated: string; + userAgent: string; + sampleRate: number; + ceilingDb: number; + rows: Row[]; + pairs: PairRow[]; + contrast: ContrastRow[]; +} + +interface HarnessApi { + ids(): readonly SoundId[]; + run(): Promise; + /** Ad-hoc single render, for probing one cue from the devtools console. */ + probe(id: SoundId, opts?: RenderOptions): Promise; +} + +declare global { + interface Window { __COUP_AUDIO?: HarnessApi } +} + +const SAMPLE_RATE = 48000; + +/** + * Beats a real Coup game produces, for the do-two-cues-sum-into-the-limiter + * check. Each is a cue plus a second cue at a real offset. + */ +const PAIRS: readonly { label: string; a: SoundId; b: SoundId; gapMs: number }[] = [ + { label: 'challengeRevealFail + cardShuffle @400ms', a: 'challengeRevealFail', b: 'cardShuffle', gapMs: 400 }, + { label: 'challengeRevealFail + influenceLoss @120ms', a: 'challengeRevealFail', b: 'influenceLoss', gapMs: 120 }, + { label: 'influenceLoss + playerEliminated @150ms', a: 'influenceLoss', b: 'playerEliminated', gapMs: 150 }, + { label: 'coup + influenceLoss @250ms', a: 'coup', b: 'influenceLoss', gapMs: 250 }, + { label: 'exchange + cardShuffle @0ms', a: 'exchange', b: 'cardShuffle', gapMs: 0 }, + { label: 'cardShuffle x2 @90ms', a: 'cardShuffle', b: 'cardShuffle', gapMs: 90 }, + { label: 'coinsGained + actionDeclared @60ms', a: 'coinsGained', b: 'actionDeclared', gapMs: 60 }, + // A player tapping a refused control twice. 90ms is the tightest a real + // double-tap can be: RATE_DEFAULT drops a repeat inside 80ms, and 90ms is + // still inside FLAM_WINDOW (190ms), so the live second tap arrives at + // FLAM_DB[1] = −2.5dB. This render gives both taps FULL gain, so it is a + // bound on the real beat rather than a picture of it. + { label: 'denied x2 @90ms', a: 'denied', b: 'denied', gapMs: 90 }, +]; + +const decoded = new Map(); + +/** Decode the mastered stingers once, in a throwaway context. */ +async function loadHeroBuffers(): Promise { + const ctx = new OfflineAudioContext(2, 128, SAMPLE_RATE); + for (const clip of Object.values(heroClips())) { + if (!clip || decoded.has(clip.url)) continue; + const response = await fetch(clip.url); + if (!response.ok) throw new Error(`${clip.url}: ${response.status}`); + decoded.set(clip.url, await ctx.decodeAudioData(await response.arrayBuffer())); + } +} + +function channelsOf(buffer: AudioBuffer): Float32Array[] { + const out: Float32Array[] = []; + for (let c = 0; c < buffer.numberOfChannels; c++) out.push(buffer.getChannelData(c)); + return out; +} + +async function run(): Promise { + await loadHeroBuffers(); + const rows: Row[] = []; + + /** One cue, at its shipped trim, plus how hard it is hitting the limiter. */ + async function row( + id: SoundId, + source: 'synth' | 'clip', + heroBuffer: AudioBuffer | null, + seconds: number, + ): Promise { + const opts = { seconds, sampleRate: SAMPLE_RATE, heroBuffer }; + const shipped = await renderSoundOffline(id, opts); + const probe = await renderSoundOffline(id, { ...opts, trimOffsetDb: PROBE_DB }); + const m = measure(channelsOf(shipped), shipped.sampleRate); + const linear = measure(channelsOf(probe), probe.sampleRate); + return { + id, + trimDb: MIX_TRIM_DB[id], + source, + limiterDb: round2(linear.peakDb - PROBE_DB - m.peakDb), + limiterStDb: round2(linear.stRmsDb - PROBE_DB - m.stRmsDb), + ...m, + }; + } + + for (const id of soundIds()) { + const clip = heroClips()[id]; + const seconds = clip ? HERO_SECONDS : SYNTH_SECONDS; + rows.push(await row(id, 'synth', null, seconds)); + const buffer = clip ? decoded.get(clip.url) : undefined; + if (buffer) rows.push(await row(id, 'clip', buffer, seconds)); + } + + const pairs: PairRow[] = []; + for (const p of PAIRS) { + const opts = { + seconds: SYNTH_SECONDS, + sampleRate: SAMPLE_RATE, + layers: [{ id: p.b, at: p.gapMs / 1000 }], + }; + const rendered = await renderSoundOffline(p.a, opts); + const probe = await renderSoundOffline(p.a, { ...opts, trimOffsetDb: PROBE_DB }); + const m = measure(channelsOf(rendered), rendered.sampleRate); + const linear = measure(channelsOf(probe), probe.sampleRate); + pairs.push({ + label: p.label, + limiterDb: round2(linear.peakDb - PROBE_DB - m.peakDb), + limiterStDb: round2(linear.stRmsDb - PROBE_DB - m.stRmsDb), + ...m, + }); + } + + const contrast: ContrastRow[] = []; + for (const id of CONTRAST_IDS) { + const rendered = await renderSoundOffline(id, { seconds: SYNTH_SECONDS, sampleRate: SAMPLE_RATE }); + const ch = channelsOf(rendered); + contrast.push({ + id, + activeMs: measure(ch, rendered.sampleRate).activeMs, + ...spectrumOf(ch, rendered.sampleRate), + }); + } + + return { + generated: new Date().toISOString(), + userAgent: navigator.userAgent, + sampleRate: SAMPLE_RATE, + ceilingDb: Math.round(toDbfs(softClipCeiling(0.7)) * 1000) / 1000, + rows, + pairs, + contrast, + }; +} + +async function probe(id: SoundId, opts: RenderOptions = {}): Promise { + const rendered = await renderSoundOffline(id, { seconds: SYNTH_SECONDS, sampleRate: SAMPLE_RATE, ...opts }); + return measure(channelsOf(rendered), rendered.sampleRate); +} + +window.__COUP_AUDIO = { ids: soundIds, run, probe }; diff --git a/tests/app/audio/harness.html b/tests/app/audio/harness.html new file mode 100644 index 0000000..0940576 --- /dev/null +++ b/tests/app/audio/harness.html @@ -0,0 +1,30 @@ + + + + +Coup — offline audio mix render + + + +

rendering…

+

+
+
+
diff --git a/tests/app/audio/measurements.ts b/tests/app/audio/measurements.ts
new file mode 100644
index 0000000..4e9b121
--- /dev/null
+++ b/tests/app/audio/measurements.ts
@@ -0,0 +1,217 @@
+/**
+ * ── RECORDED OFFLINE-RENDER MEASUREMENTS ────────────────────────────────────
+ *
+ * Every number in this file came out of `tests/app/audio/harness.html`, which
+ * renders each cue through `renderSoundOffline()` — the same `buildGraph()`,
+ * `startVoice()` and `voiceGain()` the live `play()` path uses. Nothing here was
+ * calculated by hand, and nothing here is an estimate.
+ *
+ *   date          2026-08-10 (re-rendered whole bank when `denied` was added)
+ *   renderer      Chrome 151.0.0.0 / macOS, OfflineAudioContext, 2ch @ 48kHz
+ *   pre-roll      1.0s of silence before each cue, so the master compressor's
+ *                 makeup gain has settled — see RENDER_PRE_ROLL_S. Without it
+ *                 every figure is up to 6.5dB low and short cues are biased
+ *                 differently from long ones.
+ *   perspective   mine = true (centred, unfiltered), nominal pitch, flam run 0
+ *
+ * DO NOT hand-edit a level in this file. Change MIX_DB, re-run the harness,
+ * paste the new table. `MEASURED_TRIM_DB` below exists to enforce exactly that:
+ * the gate fails if MIX_DB has moved since these were taken.
+ *
+ * Regeneration procedure: docs/AUDIO-MIX.md.
+ */
+import type { SoundId } from '../../../src/app/audio/SoundEngine';
+
+export const MEASURED_AT = '2026-08-10';
+export const MEASURED_WITH =
+  'Chrome 151.0.0.0 / macOS · OfflineAudioContext 2ch 48kHz · 1.0s pre-roll';
+
+/**
+ * The soft-clip table maximum, in dBFS: 20·log10(0.7 + 0.3·tanh(1)).
+ * Confirmed by the render — `softClipCeiling(0.7)` reported −0.645 in the
+ * browser, matching the arithmetic.
+ */
+export const SOFT_CLIP_CEILING_DBFS = -0.645;
+
+export interface CueLevels {
+  /** True peak, dBFS. */
+  readonly peakDb: number;
+  /** RMS over the cue's own active window (−45dB below peak), dBFS. */
+  readonly rmsDb: number;
+  /** Loudest 300ms sliding-window RMS, dBFS. The tier-ordering axis. */
+  readonly stRmsDb: number;
+  /** dB of gain reduction the master chain applies to the peak at this trim. */
+  readonly limiterDb: number;
+}
+
+/**
+ * The synth voice for every cue. For `gameOverWin` and `gameOverLose` this is
+ * the FALLBACK — the mastered clip is in `MEASURED_HERO_CLIP`.
+ */
+export const MEASURED: Readonly> = {
+  // tier 0 — the game turned
+  gameOverWin: { peakDb: -5.63, rmsDb: -18.76, stRmsDb: -17.07, limiterDb: 0.42 },
+  gameOverLose: { peakDb: -8.58, rmsDb: -19.42, stRmsDb: -17.01, limiterDb: 0 },
+  playerEliminated: { peakDb: -10.46, rmsDb: -18.17, stRmsDb: -17.04, limiterDb: 0 },
+  // tier 1 — you lost
+  influenceLoss: { peakDb: -11.86, rmsDb: -19.58, stRmsDb: -18.97, limiterDb: 0 },
+  challengeRevealFail: { peakDb: -10.46, rmsDb: -21.49, stRmsDb: -18.99, limiterDb: 0 },
+  block: { peakDb: -4.13, rmsDb: -18.07, stRmsDb: -21.13, limiterDb: 2.11 },
+  // tier 2 — a play resolved
+  exchange: { peakDb: -10.18, rmsDb: -21.99, stRmsDb: -23.05, limiterDb: 0 },
+  assassinationAlert: { peakDb: -12.75, rmsDb: -23.74, stRmsDb: -23.00, limiterDb: 0 },
+  coup: { peakDb: -13.08, rmsDb: -25.79, stRmsDb: -22.97, limiterDb: 0 },
+  challengeRevealSuccess: { peakDb: -13.80, rmsDb: -24.91, stRmsDb: -22.97, limiterDb: 0 },
+  // tier 3 — cards being handled
+  coinsGained: { peakDb: -14.20, rmsDb: -21.93, stRmsDb: -24.97, limiterDb: 0 },
+  coinsLost: { peakDb: -14.00, rmsDb: -23.39, stRmsDb: -26.42, limiterDb: 0 },
+  actionDeclared: { peakDb: -13.93, rmsDb: -23.29, stRmsDb: -29.06, limiterDb: 0 },
+  cardShuffle: { peakDb: -14.01, rmsDb: -29.53, stRmsDb: -32.58, limiterDb: 0 },
+  // tier 4 — chrome
+  timerWarning: { peakDb: -21.67, rmsDb: -27.54, stRmsDb: -34.56, limiterDb: 0 },
+  chatMessage: { peakDb: -22.85, rmsDb: -30.56, stRmsDb: -34.57, limiterDb: 0 },
+  reaction: { peakDb: -21.13, rmsDb: -28.83, stRmsDb: -34.60, limiterDb: 0 },
+  yourTurn: { peakDb: -25.89, rmsDb: -33.79, stRmsDb: -34.60, limiterDb: 0 },
+  challengeWindow: { peakDb: -24.10, rmsDb: -34.58, stRmsDb: -34.61, limiterDb: 0 },
+  blockOpportunity: { peakDb: -26.92, rmsDb: -33.28, stRmsDb: -34.64, limiterDb: 0 },
+  denied: { peakDb: -21.56, rmsDb: -29.32, stRmsDb: -34.64, limiterDb: 0 },
+};
+
+/**
+ * ── TIMBRE, NOT LEVEL ───────────────────────────────────────────────────────
+ *
+ * Octave-band energy NORMALISED TO EACH CUE'S OWN TOTAL, so these numbers say
+ * nothing about how loud a cue is and everything about what it sounds like.
+ * `MEASURED` above already gates level; this gates the other half.
+ *
+ * It exists because `denied` was added into a bank that already contains two
+ * falling low cues about losing, and "correctly levelled" is not the same claim
+ * as "cannot be confused with a lost influence". Getting the level right and
+ * the shape wrong is precisely what `timerWarning` standing in for a refusal
+ * was: tier-4 weight, alarm-clock timbre.
+ *
+ * Bands are the ISO octave centres in `OCTAVE_CENTRES`: 63 125 250 500 1k 2k
+ * 4k 8k Hz. `lowDb` is everything under 160Hz. `centroidHz` is the
+ * power-weighted mean frequency.
+ */
+export interface ContrastLevels {
+  /** Active-window length, ms. */
+  readonly activeMs: number;
+  /** Per-octave energy, dB relative to the cue's own total. */
+  readonly bandsDb: readonly number[];
+  /** Energy under 160Hz, dB relative to total. */
+  readonly lowDb: number;
+  /** Power-weighted mean frequency, Hz. */
+  readonly centroidHz: number;
+}
+
+export const MEASURED_CONTRAST: Readonly> = {
+  denied: {
+    activeMs: 88.10,
+    bandsDb: [-29.38, -16.41, -0.97, -8.79, -14.16, -22.74, -38.29, -56.10],
+    lowDb: -19.04,
+    centroidHz: 320.36,
+  },
+  timerWarning: {
+    activeMs: 59.60,
+    bandsDb: [-89.66, -82.64, -74.82, -65.51, -0.83, -10.37, -14.80, -14.70],
+    lowDb: -82.78,
+    centroidHz: 1561.43,
+  },
+  influenceLoss: {
+    activeMs: 346.46,
+    bandsDb: [-113.15, -32.13, 0, -82.76, -107.86, -104.66, -101.23, -94.78],
+    lowDb: -51.49,
+    centroidHz: 236.57,
+  },
+  challengeRevealFail: {
+    activeMs: 714.81,
+    bandsDb: [-2.09, -4.11, -19.19, -21.99, -29.06, -34.99, -38.56, -41.75],
+    lowDb: -0.30,
+    centroidHz: 105.02,
+  },
+};
+
+/**
+ * The mastered mp3 stingers, rendered through the same head as their fallbacks.
+ * The active-window RMS is far below the synth's because a 6-second stinger
+ * averages in five seconds of ring-out that a 0.9s fanfare does not have — which
+ * is exactly why the parity check runs on `stRmsDb`, not on `rmsDb`.
+ */
+export const MEASURED_HERO_CLIP: Readonly>> = {
+  gameOverWin: { peakDb: -4.61, rmsDb: -20.48, stRmsDb: -17.15, limiterDb: 1.54 },
+  gameOverLose: { peakDb: -7.23, rmsDb: -26.23, stRmsDb: -17.02, limiterDb: 0 },
+};
+
+/**
+ * Beats a real Coup game produces, rendered as one summed pass. `limiterDb` is
+ * how much the master chain pulls the sum down: near zero means the two cues do
+ * not add into the limiter.
+ */
+export interface PairLevels extends CueLevels {
+  readonly label: string;
+}
+
+export const MEASURED_PAIRS: readonly PairLevels[] = [
+  { label: 'challengeRevealFail + cardShuffle @400ms', peakDb: -10.46, rmsDb: -21.35, stRmsDb: -18.88, limiterDb: 0 },
+  { label: 'challengeRevealFail + influenceLoss @120ms', peakDb: -6.79, rmsDb: -19.12, stRmsDb: -16.10, limiterDb: 0.04 },
+  { label: 'influenceLoss + playerEliminated @150ms', peakDb: -7.71, rmsDb: -17.54, stRmsDb: -15.63, limiterDb: 0.01 },
+  { label: 'coup + influenceLoss @250ms', peakDb: -10.30, rmsDb: -20.46, stRmsDb: -18.71, limiterDb: 0 },
+  { label: 'exchange + cardShuffle @0ms', peakDb: -6.92, rmsDb: -21.49, stRmsDb: -22.58, limiterDb: 0.02 },
+  { label: 'cardShuffle x2 @90ms', peakDb: -14.01, rmsDb: -28.74, stRmsDb: -29.74, limiterDb: 0 },
+  { label: 'coinsGained + actionDeclared @60ms', peakDb: -10.25, rmsDb: -20.48, stRmsDb: -23.53, limiterDb: 0 },
+  // The realistic double-tap. RATE_DEFAULT drops a repeat inside 80ms, so 90ms
+  // is the tightest a second tap can physically land; it is still inside
+  // FLAM_WINDOW, so live the second arrives at FLAM_DB[1] = −2.5dB. This render
+  // gives BOTH taps full gain, which makes it an upper bound on the real beat.
+  // Peak is identical to one tap (−21.56): the two do not overlap at all.
+  { label: 'denied x2 @90ms', peakDb: -21.56, rmsDb: -29.37, stRmsDb: -31.63, limiterDb: 0 },
+];
+
+/**
+ * ── THE STALENESS GUARD ─────────────────────────────────────────────────────
+ * MIX_DB as it stood when the table above was rendered. The gate asserts this
+ * still equals the live MIX_DB, so a trim edited without a re-render fails the
+ * build rather than silently invalidating every level in this file. It is the
+ * mechanism that makes "measured" mean measured.
+ */
+export const MEASURED_TRIM_DB: Readonly> = {
+  gameOverWin: -2.8,
+  gameOverLose: -5,
+  playerEliminated: -3,
+  challengeRevealFail: -2.1,
+  influenceLoss: -1.9,
+  block: 5.6,
+  assassinationAlert: -2.3,
+  coup: -12.9,
+  challengeRevealSuccess: -5.6,
+  exchange: -4.3,
+  cardShuffle: 1.2,
+  actionDeclared: -0.3,
+  coinsGained: -0.7,
+  coinsLost: -0.4,
+  yourTurn: -15.9,
+  challengeWindow: -13.7,
+  blockOpportunity: -11.2,
+  timerWarning: -9.4,
+  denied: -12,
+  reaction: -7.6,
+  chatMessage: -7.4,
+};
+
+/** HERO_CLIPS pre-trim gains as they stood for the render above. */
+export const MEASURED_HERO_CLIP_GAIN: Readonly>> = {
+  gameOverWin: 0.808,
+  gameOverLose: 0.557,
+};
+
+function pick(key: 'peakDb' | 'rmsDb' | 'stRmsDb'): Record {
+  const out = {} as Record;
+  for (const id of Object.keys(MEASURED) as SoundId[]) out[id] = MEASURED[id][key];
+  return out;
+}
+
+/** Convenience views over `MEASURED`. Same numbers, one source. */
+export const MEASURED_PEAK_DBFS: Readonly> = pick('peakDb');
+export const MEASURED_RMS_DBFS: Readonly> = pick('rmsDb');
+export const MEASURED_ST_RMS_DBFS: Readonly> = pick('stRmsDb');
diff --git a/tests/app/audio/mix.test.ts b/tests/app/audio/mix.test.ts
new file mode 100644
index 0000000..cc46285
--- /dev/null
+++ b/tests/app/audio/mix.test.ts
@@ -0,0 +1,297 @@
+/**
+ * ── THE MIX GATE ────────────────────────────────────────────────────────────
+ *
+ * Plain vitest. No Web Audio, no jsdom, no AudioContext — this file asserts the
+ * RECORDED offline-render measurements in `measurements.ts` against the trim and
+ * tier tables in `SoundEngine.ts`. The render itself happens in a real browser
+ * (see docs/AUDIO-MIX.md); this is the part that runs on every commit.
+ *
+ * What makes it a gate rather than a snapshot: `MEASURED_TRIM_DB` pins the trims
+ * the levels were taken at. Edit MIX_DB without re-rendering and the first test
+ * fails; re-render and the ordering tests judge the new numbers. There is no
+ * path where a trim changes and nothing checks the result.
+ *
+ * The ordering runs on 300ms loudness, not on peak. Ranking cues by peak is the
+ * mistake this whole exercise exists to undo: a 150ms noise swish carries 18dB
+ * of crest and a sustained sine carries 7dB, so peak puts the swish above a
+ * lost influence that is plainly louder to a listener. Peak is still gated —
+ * against the soft-clip ceiling, and for the headline rule that no routine cue
+ * may stab above any loss.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+  HERO_CLIP_GAIN,
+  MIX_TIER_OF,
+  MIX_TRIM_DB,
+  softClipCeiling,
+  type MixTier,
+  type SoundId,
+} from '@/app/audio/SoundEngine';
+import {
+  MEASURED,
+  MEASURED_AT,
+  MEASURED_CONTRAST,
+  MEASURED_HERO_CLIP,
+  MEASURED_HERO_CLIP_GAIN,
+  MEASURED_PAIRS,
+  MEASURED_PEAK_DBFS,
+  MEASURED_ST_RMS_DBFS,
+  MEASURED_TRIM_DB,
+  SOFT_CLIP_CEILING_DBFS,
+} from './measurements';
+
+/** Minimum gap between adjacent tiers, in dB. Below this it is a coin-flip. */
+const TIER_MARGIN_DB = 1.5;
+
+/** Minimum gap between the quietest loss and the loudest routine cue, on peak. */
+const STAB_MARGIN_DB = 1.5;
+
+/** Clip and fallback must land this close on loudness. */
+const HERO_PARITY_DB = 1.5;
+
+/**
+ * How much gain reduction a single cue may take before it is the LIMITER, not
+ * MIX_DB, setting its level. 3dB is generous; the shipped worst is 2.11dB.
+ */
+const MAX_LIMITER_DB = 3;
+
+/** And for two cues landing in the same beat. */
+const MAX_PAIR_LIMITER_DB = 3;
+
+const IDS = Object.keys(MIX_TRIM_DB) as SoundId[];
+const TIERS: MixTier[] = [0, 1, 2, 3, 4];
+
+function idsInTier(tier: MixTier): SoundId[] {
+  return IDS.filter(id => MIX_TIER_OF[id] === tier);
+}
+
+describe('audio mix — the measurements are current', () => {
+  it(`MIX_DB has not moved since the render on ${MEASURED_AT}`, () => {
+    // If this fails, MIX_DB was edited without re-running the offline render.
+    // Every level in measurements.ts now describes a mix nobody hears.
+    // Fix: re-render (docs/AUDIO-MIX.md) and paste the new table.
+    expect(MIX_TRIM_DB).toEqual(MEASURED_TRIM_DB);
+  });
+
+  it('the hero-clip gains have not moved since the render', () => {
+    expect(HERO_CLIP_GAIN).toEqual(MEASURED_HERO_CLIP_GAIN);
+  });
+
+  it('every cue has a trim, a tier and a measurement', () => {
+    for (const id of IDS) {
+      expect(MIX_TIER_OF[id], `${id} has no tier`).toBeTypeOf('number');
+      expect(MEASURED[id], `${id} has no measurement`).toBeDefined();
+    }
+    expect(Object.keys(MEASURED).sort()).toEqual([...IDS].sort());
+  });
+
+  it('every tier has at least one cue in it', () => {
+    for (const tier of TIERS) expect(idsInTier(tier).length).toBeGreaterThan(0);
+  });
+});
+
+describe('audio mix — consequence tracks loudness', () => {
+  it.each([0, 1, 2, 3] as MixTier[])(
+    'tier %i sits at least 1.5dB above the tier below it',
+    (upper) => {
+      const lower = (upper + 1) as MixTier;
+      const quietestAbove = idsInTier(upper)
+        .reduce((a, id) => Math.min(a, MEASURED_ST_RMS_DBFS[id]), Infinity);
+      const loudestBelow = idsInTier(lower)
+        .reduce((a, id) => Math.max(a, MEASURED_ST_RMS_DBFS[id]), -Infinity);
+      const margin = quietestAbove - loudestBelow;
+      expect(
+        margin,
+        `tier ${upper} bottoms out at ${quietestAbove.toFixed(2)} dBFS loud and `
+        + `tier ${lower} tops out at ${loudestBelow.toFixed(2)} — margin `
+        + `${margin.toFixed(2)}dB, need ${TIER_MARGIN_DB}`,
+      ).toBeGreaterThanOrEqual(TIER_MARGIN_DB);
+    },
+  );
+
+  it('the whole ladder is strictly ordered, tier by tier', () => {
+    const bottoms = TIERS.map(t => idsInTier(t)
+      .reduce((a, id) => Math.min(a, MEASURED_ST_RMS_DBFS[id]), Infinity));
+    const tops = TIERS.map(t => idsInTier(t)
+      .reduce((a, id) => Math.max(a, MEASURED_ST_RMS_DBFS[id]), -Infinity));
+    for (let t = 0; t < TIERS.length - 1; t++) {
+      expect(bottoms[t], `tier ${t} bottom vs tier ${t + 1} top`)
+        .toBeGreaterThan(tops[t + 1]);
+    }
+  });
+
+  it('no routine cue is louder than any loss — the named case', () => {
+    // The inversion the trims exist to fix, spelled out so it cannot be lost
+    // in a refactor of the loop above.
+    expect(MEASURED_ST_RMS_DBFS.influenceLoss)
+      .toBeGreaterThan(MEASURED_ST_RMS_DBFS.cardShuffle + TIER_MARGIN_DB);
+    expect(MEASURED_ST_RMS_DBFS.influenceLoss)
+      .toBeGreaterThan(MEASURED_ST_RMS_DBFS.coinsGained + TIER_MARGIN_DB);
+    expect(MEASURED_ST_RMS_DBFS.challengeRevealFail)
+      .toBeGreaterThan(MEASURED_ST_RMS_DBFS.coinsGained + TIER_MARGIN_DB);
+    expect(MEASURED_ST_RMS_DBFS.influenceLoss)
+      .toBeGreaterThan(MEASURED_ST_RMS_DBFS.yourTurn + TIER_MARGIN_DB);
+    // A refusal is chrome. Losing an influence must stay well above being told
+    // "no" — the refusal is the most frequently fired cue in a fumbled turn.
+    expect(MEASURED_ST_RMS_DBFS.influenceLoss)
+      .toBeGreaterThan(MEASURED_ST_RMS_DBFS.denied + TIER_MARGIN_DB);
+    expect(MEASURED_ST_RMS_DBFS.challengeRevealFail)
+      .toBeGreaterThan(MEASURED_ST_RMS_DBFS.denied + TIER_MARGIN_DB);
+  });
+
+  it('no routine cue STABS above a loss on true peak either', () => {
+    const loss = [...idsInTier(0), ...idsInTier(1)];
+    const routine = [...idsInTier(3), ...idsInTier(4)];
+    const quietestLoss = loss.reduce(
+      (a, id) => (MEASURED_PEAK_DBFS[id] < MEASURED_PEAK_DBFS[a] ? id : a), loss[0],
+    );
+    const hottestRoutine = routine.reduce(
+      (a, id) => (MEASURED_PEAK_DBFS[id] > MEASURED_PEAK_DBFS[a] ? id : a), routine[0],
+    );
+    const margin = MEASURED_PEAK_DBFS[quietestLoss] - MEASURED_PEAK_DBFS[hottestRoutine];
+    expect(
+      margin,
+      `${quietestLoss} peaks at ${MEASURED_PEAK_DBFS[quietestLoss]} dBFS and `
+      + `${hottestRoutine} peaks at ${MEASURED_PEAK_DBFS[hottestRoutine]} — `
+      + `margin ${margin.toFixed(2)}dB, need ${STAB_MARGIN_DB}`,
+    ).toBeGreaterThanOrEqual(STAB_MARGIN_DB);
+  });
+});
+
+describe('audio mix — the ceiling is a property of the graph', () => {
+  it('softClipCeiling matches the recorded ceiling', () => {
+    const db = 20 * Math.log10(softClipCeiling(0.7));
+    expect(db).toBeCloseTo(SOFT_CLIP_CEILING_DBFS, 3);
+  });
+
+  it('no cue reaches the soft-clip ceiling', () => {
+    for (const id of IDS) {
+      expect(MEASURED_PEAK_DBFS[id], `${id} peak`).toBeLessThan(SOFT_CLIP_CEILING_DBFS);
+    }
+    for (const [id, levels] of Object.entries(MEASURED_HERO_CLIP)) {
+      if (levels) expect(levels.peakDb, `${id} clip peak`).toBeLessThan(SOFT_CLIP_CEILING_DBFS);
+    }
+  });
+
+  it('no cue has its level set by the limiter rather than by MIX_DB', () => {
+    for (const id of IDS) {
+      expect(MEASURED[id].limiterDb, `${id} gain reduction`)
+        .toBeLessThanOrEqual(MAX_LIMITER_DB);
+    }
+    for (const [id, levels] of Object.entries(MEASURED_HERO_CLIP)) {
+      if (levels) {
+        expect(levels.limiterDb, `${id} clip gain reduction`)
+          .toBeLessThanOrEqual(MAX_LIMITER_DB);
+      }
+    }
+  });
+
+  it('two cues in one beat do not sum into the limiter', () => {
+    expect(MEASURED_PAIRS.length).toBeGreaterThan(0);
+    for (const pair of MEASURED_PAIRS) {
+      expect(pair.peakDb, `${pair.label} peak`).toBeLessThan(SOFT_CLIP_CEILING_DBFS);
+      expect(pair.limiterDb, `${pair.label} gain reduction`)
+        .toBeLessThanOrEqual(MAX_PAIR_LIMITER_DB);
+    }
+  });
+});
+
+/**
+ * ── THE REFUSAL IS NOT A LOSS ───────────────────────────────────────────────
+ *
+ * Level is only half of "this cue is right". `timerWarning` stood in for a
+ * refusal for a whole release at exactly the correct tier-4 weight and entirely
+ * the wrong shape, and no assertion in this file could have caught that — every
+ * gate above compares dBFS.
+ *
+ * These run on `MEASURED_CONTRAST`, which is octave-band energy normalised to
+ * each cue's own total, so it is a statement about TIMBRE that survives any
+ * future retune of the trims.
+ */
+describe('audio mix — denied cannot be mistaken for a loss', () => {
+  const denied = MEASURED_CONTRAST.denied;
+  const influenceLoss = MEASURED_CONTRAST.influenceLoss;
+  const challengeRevealFail = MEASURED_CONTRAST.challengeRevealFail;
+  const timerWarning = MEASURED_CONTRAST.timerWarning;
+
+  it('is chrome: tier 4, non-priority, and quieter than every tier 0–2 cue', () => {
+    expect(MIX_TIER_OF.denied).toBe(4);
+    const consequential = IDS.filter(id => MIX_TIER_OF[id] <= 2);
+    for (const id of consequential) {
+      expect(
+        MEASURED_ST_RMS_DBFS[id],
+        `denied (${MEASURED_ST_RMS_DBFS.denied}) must sit under ${id}`,
+      ).toBeGreaterThan(MEASURED_ST_RMS_DBFS.denied + TIER_MARGIN_DB);
+    }
+  });
+
+  it('is over before either loss cue is a third done', () => {
+    // 88ms against 346 and 715. A refusal that lingers reads as damage done.
+    expect(denied.activeMs).toBeLessThan(influenceLoss.activeMs / 3);
+    expect(denied.activeMs).toBeLessThan(challengeRevealFail.activeMs / 3);
+  });
+
+  it('is a buzz, not a tone — influenceLoss is one octave band, denied is three', () => {
+    // influenceLoss is a bare sine: everything in one band, the runner-up 32dB
+    // down. denied is a filtered square and spreads. Two objects, not one
+    // object at two pitches.
+    const spread = (c: { bandsDb: readonly number[] }): number => {
+      const sorted = [...c.bandsDb].sort((a, b) => b - a);
+      return sorted[0] - sorted[2];
+    };
+    expect(spread(influenceLoss)).toBeGreaterThan(60);
+    expect(spread(denied)).toBeLessThan(20);
+  });
+
+  it('has no chest — challengeRevealFail is bass, denied is mid', () => {
+    // challengeRevealFail puts essentially all of itself under 160Hz.
+    expect(challengeRevealFail.lowDb).toBeGreaterThan(-3);
+    // denied puts ~1%, and its centroid is an octave and a half higher.
+    expect(denied.lowDb).toBeLessThan(challengeRevealFail.lowDb - 12);
+    expect(denied.centroidHz).toBeGreaterThan(challengeRevealFail.centroidHz * 2);
+  });
+
+  it('is closed, unlike the timerWarning it replaces', () => {
+    // The 1400→760Hz lowpass over the two squares. A muted buzzer behind a
+    // door, not the alarm in the room that was standing in for it.
+    expect(timerWarning.centroidHz).toBeGreaterThan(1200);
+    expect(denied.centroidHz).toBeLessThan(timerWarning.centroidHz / 3);
+  });
+
+  it('a double-tap does not stab into the limiter', () => {
+    // The player taps a refused control twice. RATE_DEFAULT drops anything
+    // inside 80ms, so 90ms is the tightest real beat; the render gives both
+    // taps full gain where the live second one is flammed to −2.5dB.
+    const pair = MEASURED_PAIRS.find(p => p.label === 'denied x2 @90ms');
+    expect(pair, 'denied x2 not in MEASURED_PAIRS').toBeDefined();
+    if (!pair) return;
+    expect(pair.limiterDb).toBeLessThanOrEqual(MAX_PAIR_LIMITER_DB);
+    expect(pair.peakDb).toBeLessThan(SOFT_CLIP_CEILING_DBFS);
+    // And it is still under every loss on loudness even summed.
+    expect(pair.stRmsDb).toBeLessThan(MEASURED_ST_RMS_DBFS.influenceLoss);
+  });
+});
+
+describe('audio mix — the fallback matches the clip it replaces', () => {
+  it.each(Object.keys(MEASURED_HERO_CLIP) as SoundId[])(
+    '%s: mastered clip and synth fallback land within 1.5dB',
+    (id) => {
+      const clip = MEASURED_HERO_CLIP[id];
+      expect(clip, `${id} has no clip measurement`).toBeDefined();
+      if (!clip) return;
+      const delta = Math.abs(clip.stRmsDb - MEASURED[id].stRmsDb);
+      expect(
+        delta,
+        `${id}: clip ${clip.stRmsDb} dBFS loud vs fallback ${MEASURED[id].stRmsDb} — `
+        + `${delta.toFixed(2)}dB apart. A fallback at a different level from the `
+        + 'clip is a bug nobody notices until the fetch fails.',
+      ).toBeLessThanOrEqual(HERO_PARITY_DB);
+    },
+  );
+
+  it('every hero clip in the engine has a recorded measurement', () => {
+    for (const id of Object.keys(HERO_CLIP_GAIN) as SoundId[]) {
+      expect(MEASURED_HERO_CLIP[id], `${id} clip not measured`).toBeDefined();
+    }
+  });
+});
diff --git a/tests/app/components/characterGlyphs.test.ts b/tests/app/components/characterGlyphs.test.ts
new file mode 100644
index 0000000..e7f74a3
--- /dev/null
+++ b/tests/app/components/characterGlyphs.test.ts
@@ -0,0 +1,163 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, it, expect } from 'vitest';
+import { Character } from '@/shared/types';
+import { CHARACTER_GLYPHS } from '@/app/components/icons';
+import { GLYPH_STROKE_HEAVY, GLYPH_STROKE_LIGHT } from '@/app/components/icons/glyphs';
+
+/**
+ * ART-DIRECTION.md §1.2's drawing rules, made executable.
+ *
+ * The eight rules at the top of `src/app/components/icons/glyphs/index.ts` are
+ * prose, and prose does not survive a hurried glyph. Most of them are cheap to
+ * check mechanically against the source, so they are checked here — including
+ * the one rule that is not a style preference but a RULING with a date on it
+ * (the Duke vs the winner's crown, below).
+ *
+ * What this file cannot do is the part §1.2 actually cares about: whether a
+ * mark READS at 16px. `BlockGlyph`'s header records a collision that only ever
+ * appeared on a proof sheet, and the Duke, Assassin, Captain and Contessa marks
+ * in this set were all redrawn after looking at one. Use
+ * `glyphs/GlyphSheet.tsx`; do not mistake a green suite for a legible glyph.
+ */
+
+const GLYPH_DIR = fileURLToPath(new URL('../../../src/app/components/icons/glyphs', import.meta.url));
+
+const read = (file: string) => readFileSync(`${GLYPH_DIR}/${file}`, 'utf8');
+
+/**
+ * The file with its prose removed. These rules govern the MARKUP, and every
+ * glyph header in this set explains itself by naming the thing it is not doing
+ * — `InquisitorGlyph` says in so many words that the illustration it replaces
+ * used `strokeLinejoin="round"` and ``. Matching raw source would
+ * make writing that sentence a test failure, i.e. it would punish exactly the
+ * documentation these rules depend on.
+ */
+const markup = (file: string) =>
+  read(file)
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/^\s*\/\/.*$/gm, '');
+
+const ALL_GLYPH_FILES = readdirSync(GLYPH_DIR).filter(
+  (f) => f.endsWith('Glyph.tsx') && f !== 'GlyphBase.tsx',
+);
+
+/** The six §1.2 character silhouettes. */
+const CHARACTER_GLYPH_FILES: Record = {
+  [Character.Duke]: 'DukeGlyph.tsx',
+  [Character.Assassin]: 'AssassinGlyph.tsx',
+  [Character.Captain]: 'CaptainGlyph.tsx',
+  [Character.Ambassador]: 'AmbassadorGlyph.tsx',
+  [Character.Contessa]: 'ContessaGlyph.tsx',
+  [Character.Inquisitor]: 'InquisitorGlyph.tsx',
+};
+
+describe('§1.2 character glyphs — coverage', () => {
+  it('gives every character exactly one silhouette', () => {
+    for (const character of Object.values(Character)) {
+      expect(CHARACTER_GLYPHS[character], `no glyph for ${character}`).toBeTypeOf('function');
+    }
+  });
+
+  it('never reuses one silhouette for two characters', () => {
+    // §1.2 puts the categorical load on shape, and §2.4 records that Assassin
+    // and Captain are 1 degree of hue apart. Two characters sharing a mark
+    // would leave that pair with no channel at all.
+    const seen = new Map();
+    for (const character of Object.values(Character)) {
+      const glyph = CHARACTER_GLYPHS[character];
+      const clash = seen.get(glyph);
+      expect(clash, `${character} and ${clash} share a silhouette`).toBeUndefined();
+      seen.set(glyph, character);
+    }
+  });
+
+  it('ships each character silhouette as its own file', () => {
+    for (const [character, file] of Object.entries(CHARACTER_GLYPH_FILES)) {
+      expect(ALL_GLYPH_FILES, `${character}: ${file} missing`).toContain(file);
+    }
+  });
+});
+
+describe('§1.2 drawing rules — the whole glyph set', () => {
+  it('uses no third stroke width (rule 1)', () => {
+    // Any literal `strokeWidth={n}` is a third weight unless it is one of the
+    // two constants. The chassis supplies the heavy weight, so a file should
+    // normally only ever name GLYPH_STROKE_LIGHT.
+    for (const file of ALL_GLYPH_FILES) {
+      const literals = [...markup(file).matchAll(/strokeWidth=\{(\d+(?:\.\d+)?)\}/g)].map((m) =>
+        Number(m[1]),
+      );
+      for (const width of literals) {
+        expect([GLYPH_STROKE_HEAVY, GLYPH_STROKE_LIGHT], `${file}: strokeWidth ${width}`).toContain(
+          width,
+        );
+      }
+    }
+  });
+
+  it('never rounds a cap or a join (rule 2)', () => {
+    for (const file of ALL_GLYPH_FILES) {
+      expect(markup(file), `${file} overrides the chassis' square corners`).not.toMatch(
+        /strokeLine(cap|join)=/,
+      );
+    }
+  });
+
+  it('uses no gradient, blur or soft shadow (rule 3)', () => {
+    for (const file of ALL_GLYPH_FILES) {
+      expect(markup(file), file).not.toMatch(/linearGradient|radialGradient|feGaussianBlur|filter=/);
+    }
+  });
+
+  it('tints with a pattern, never with opacity (rule 4)', () => {
+    for (const file of ALL_GLYPH_FILES) {
+      expect(markup(file), `${file} fades instead of screening`).not.toMatch(
+        /fillOpacity|strokeOpacity|\sopacity=/,
+      );
+    }
+  });
+
+  it('hard-codes no colour (rule 5)', () => {
+    // The whole set is `currentColor` so a caller can pass the character hue,
+    // the brass, or the ink. A hex here is a seventh copy of the palette —
+    // exactly what `characterPalette.ts` exists to have deleted.
+    for (const file of ALL_GLYPH_FILES) {
+      expect(markup(file), `${file} carries a literal colour`).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
+    }
+  });
+
+  it('carries no text and no animation (rule 8)', () => {
+    for (const file of ALL_GLYPH_FILES) {
+      expect(markup(file), file).not.toMatch(/ {
+  /**
+   * "The crown means the winner." The Duke keeps the fractured crown and the
+   * two are separated by SILHOUETTE, not by subject: the winner's is closed,
+   * symmetric and sits on a solid base; the Duke's is open-topped, asymmetric,
+   * three planes, no base.
+   *
+   * As drawn, the difference that survives 16px is mass versus line —
+   * `CrownGlyph` is one filled slab with an interior, `DukeGlyph` is an open
+   * stroked profile with none. That is checkable, so it is checked: these two
+   * assertions are what stop a later "tidy-up" from filling the Duke or
+   * outlining the crown and quietly collapsing the pair.
+   *
+   * They are NOT a substitute for the check the ruling actually mandates —
+   * "whoever draws the Duke silhouette must check it against CrownGlyph at
+   * 16px side by side, on the proof sheet, before it lands."
+   */
+  it('keeps the winner a filled mass', () => {
+    expect(markup('CrownGlyph.tsx')).toMatch(/fill="currentColor"/);
+  });
+
+  it('keeps the Duke an open stroked profile with no fill', () => {
+    const duke = markup('DukeGlyph.tsx');
+    expect(duke, 'a filled Duke collapses the ruling separation').not.toMatch(/fill="currentColor"/);
+    expect(duke, 'a closed Duke path is a crown, not a fracture of one').not.toMatch(/\sZ"/);
+  });
+});
diff --git a/tests/app/fx/cueOrdering.test.ts b/tests/app/fx/cueOrdering.test.ts
new file mode 100644
index 0000000..caa07eb
--- /dev/null
+++ b/tests/app/fx/cueOrdering.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fx from '@/app/fx';
+import * as floaters from '@/app/fx/floaters';
+import { reset as resetClock } from '@/app/anim/clock';
+import { __resetHaptics } from '@/app/utils/haptic';
+import { el, installRaf, type FakeElement, type RafHarness } from '../anim/fakeDom';
+
+/**
+ * THE SUBSTRATE EXISTS BEFORE ANY CHANNEL IS DISPATCHED.
+ *
+ * `cue()` used to call `pump()` LAST, after handing work to particles, shake,
+ * flash and floaters. `pump()` is what installs the floater host, and
+ * `floaters.spawn()` only attaches a node when a host is already present:
+ *
+ *     if (!rec.node && host) rec.node = host.create();
+ *
+ * So the very first float of a session was created headless. It never
+ * self-healed either — `setHost()` clears `node` only when the host CHANGES,
+ * and it never back-fills live records — so that record stayed nodeless for its
+ * whole 900ms life and simply did not render. It also skipped the measured
+ * half-width clamp, so even its position was a guess.
+ *
+ * Every SUBSEQUENT float was fine, which is exactly why unit tests and source
+ * review both missed it and it took watching a real game's opening beat to see.
+ *
+ * There is no jsdom here, so no host is ever actually installed. What this file
+ * pins is the ordering itself, observed from inside `spawn()`: the band
+ * provider is called by `readBand()` partway through spawning, so if `pump()`
+ * has already run by then, `stats().pumping` is true at that instant. That is
+ * the invariant, and it is what regressed.
+ */
+
+let raf: RafHarness;
+let table: FakeElement;
+
+beforeEach(() => {
+  resetClock();
+  raf = installRaf();
+  __resetHaptics();
+  table = el();
+  fx.reset();
+  fx.setReducedMotion(false);
+  fx.mount(null, table);
+});
+
+afterEach(() => {
+  fx.reset();
+  fx.setReducedMotion(false);
+  fx.unmount();
+  fx.setBand(null);
+  resetClock();
+  raf.restore();
+});
+
+/** `coins_changed / mine` is float-only — the cheapest row that spawns one. */
+const COINS = { condition: 'mine', x: 200, y: 220, amount: 3 } as const;
+
+describe('cue() builds the substrate before dispatching', () => {
+  it('has already pumped by the time the FIRST float spawns', () => {
+    const pumpingDuringSpawn: boolean[] = [];
+    fx.setBand((out) => {
+      out.top = 60;
+      out.bottom = 320;
+      out.width = 400;
+      pumpingDuringSpawn.push(fx.stats().pumping);
+    });
+
+    // Nothing has run yet: this is the first cue of the "session".
+    expect(fx.stats().pumping).toBe(false);
+
+    fx.cue('coins_changed', COINS);
+
+    // The provider ran (so we really did observe from inside spawn) ...
+    expect(pumpingDuringSpawn).toHaveLength(1);
+    // ... and the pump was already up at that moment. Pre-fix this was `false`.
+    expect(pumpingDuringSpawn[0]).toBe(true);
+  });
+
+  it('spawns the first float rather than dropping it', () => {
+    fx.setBand((out) => {
+      out.top = 60;
+      out.bottom = 320;
+      out.width = 400;
+    });
+
+    expect(floaters.count()).toBe(0);
+    fx.cue('coins_changed', COINS);
+    expect(floaters.count()).toBe(1);
+    expect(floaters.peek(0)?.text).toBe('+3');
+  });
+
+  it('still does not pump for a cue the tuning table has no row for', () => {
+    // The laziness rule is carried by the `!row` early return, NOT by where
+    // `pump()` sits — moving the call earlier must not have cost that.
+    //
+    // `denied / theirs` is the right probe: `rowFor` returns null for it,
+    // because a `theirs` condition never escalates to the loud form and there
+    // is no quiet denial. Note `coins_changed / theirs` is NOT this case — that
+    // row EXISTS and is deliberately empty, so it matches, returns true, and
+    // pumps. It pumped before this change too (the old call site was equally
+    // unconditional once past `!row`), so nothing regressed there.
+    const fired = fx.cue('denied', { condition: 'theirs', x: 10, y: 10 });
+    expect(fired).toBe(false);
+    expect(fx.stats().pumping).toBe(false);
+    expect(floaters.count()).toBe(0);
+  });
+});
diff --git a/tests/app/fx/floaters.test.ts b/tests/app/fx/floaters.test.ts
new file mode 100644
index 0000000..2a7385e
--- /dev/null
+++ b/tests/app/fx/floaters.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as F from '@/app/fx/floaters';
+import { FakeStyle } from '../anim/fakeDom';
+
+/**
+ * The floater layer runs headless when no host is installed — records are still
+ * created, clamped, stacked and ticked. A fake host is only needed for the two
+ * rules that depend on real glyph metrics.
+ */
+
+class FakeFloatNode implements F.FloaterNode {
+  textContent: string | null = null;
+  offsetWidth = 0;
+  readonly style = new FakeStyle();
+  shout = false;
+  setShout(on: boolean): void {
+    this.shout = on;
+  }
+}
+
+const made: FakeFloatNode[] = [];
+/** Width the next created node reports. Set per test. */
+let nodeWidth = 0;
+
+const host: F.FloaterHost = {
+  create(): F.FloaterNode {
+    const n = new FakeFloatNode();
+    n.offsetWidth = nodeWidth;
+    made.push(n);
+    return n;
+  },
+};
+
+/** Roughly Coup's real band: under the phase banner, above the hand. */
+const BAND = { top: 60, bottom: 320, width: 400 };
+
+beforeEach(() => {
+  F.reset();
+  F.setHost(null);
+  F.setReduced(false);
+  F.setSuppressed(false);
+  made.length = 0;
+  nodeWidth = 0;
+  F.setBand((out) => {
+    out.top = BAND.top;
+    out.bottom = BAND.bottom;
+    out.width = BAND.width;
+  });
+});
+
+afterEach(() => {
+  F.reset();
+  F.setBand(null);
+  F.setHost(null);
+});
+
+describe('floaters — the keep-out band', () => {
+  it('clamps a beat below the band up to the band bottom', () => {
+    // A "−3" that lands on the coin counter it describes is worse than no float.
+    expect(F.spawn('-3', 200, 900, 'crimson')).toBe(true);
+    const rec = F.peek(0);
+    expect(rec?.y).toBe(BAND.bottom);
+  });
+
+  it('clamps a beat above the band down to top + RISE', () => {
+    expect(F.spawn('+2', 200, -50, 'brass')).toBe(true);
+    // Offset by the full RISE, so the float ENDS its life inside the band
+    // rather than starting inside it and drifting out through the top.
+    expect(F.peek(0)?.y).toBe(BAND.top + F.RISE);
+  });
+
+  it('leaves a beat inside the band where it is', () => {
+    expect(F.spawn('+1', 200, 220, 'brass')).toBe(true);
+    expect(F.peek(0)?.y).toBe(210); // y − 10
+  });
+
+  it('never rises out through the top of the band', () => {
+    F.spawn('+1', 200, 220, 'brass');
+    for (let i = 0; i < 60; i++) {
+      F.tick(1 / 60);
+      const rec = F.peek(0);
+      if (!rec) break;
+      const drawnY = rec.y - F.RISE;
+      expect(drawnY).toBeGreaterThanOrEqual(BAND.top);
+    }
+  });
+});
+
+describe('floaters — anti-collision stacking', () => {
+  it('stacks two floats that land inside both thresholds', () => {
+    F.spawn('+2', 200, 220, 'brass');
+    F.spawn('+3', 200, 220, 'brass');
+    F.spawn('+4', 200, 220, 'brass');
+    expect(F.count()).toBe(3);
+    expect(F.peek(0)?.y).toBe(210);
+    expect(F.peek(1)?.y).toBe(210 - F.STACK_Y);
+    expect(F.peek(2)?.y).toBe(210 - 2 * F.STACK_Y);
+  });
+
+  it('does NOT stack when the X separation exceeds the threshold', () => {
+    F.spawn('+2', 100, 220, 'brass');
+    F.spawn('+3', 100 + F.STACK_X, 220, 'brass');
+    expect(F.peek(0)?.y).toBe(210);
+    expect(F.peek(1)?.y).toBe(210); // side by side, no stacking
+  });
+
+  it('does NOT stack when the Y separation exceeds the threshold', () => {
+    F.spawn('+2', 200, 130, 'brass'); // → y 120
+    F.spawn('+3', 200, 210, 'brass'); // → y 200, which is 80 away
+    expect(Math.abs((F.peek(0)?.y ?? 0) - (F.peek(1)?.y ?? 0))).toBeGreaterThanOrEqual(F.STACK_Y);
+    expect(F.peek(1)?.y).toBe(200);
+  });
+
+  it('wraps to the bottom of the band rather than stacking through the banner', () => {
+    // Coup pays several players in one beat; a deep enough caravan runs out of
+    // headroom, and the answer is to start again at the bottom.
+    for (let i = 0; i < 6; i++) F.spawn(`+${i}`, 200, 300, 'brass');
+    for (let i = 0; i < F.count(); i++) {
+      const rec = F.peek(i);
+      expect(rec?.y).toBeGreaterThanOrEqual(BAND.top + F.RISE);
+      expect(rec?.y).toBeLessThanOrEqual(BAND.bottom);
+    }
+  });
+});
+
+describe('floaters — the fade holds', () => {
+  it('holds full alpha until 45% and only then falls', () => {
+    expect(F.HOLD_UNTIL).toBe(0.45);
+    expect(F.alphaAt(0.2)).toBe(1);
+    expect(F.alphaAt(0.44)).toBe(1);
+    expect(F.alphaAt(0.449)).toBe(1);
+    // A linear fade over 900ms is legible for about 300ms of it. The hold buys
+    // ~400ms of full contrast, which is what makes the number readable.
+    expect(F.alphaAt(0.5)).toBeLessThan(1);
+    expect(F.alphaAt(0.5)).toBeGreaterThan(0.8);
+    expect(F.alphaAt(0.75)).toBeLessThan(F.alphaAt(0.5));
+    expect(F.alphaAt(0.99)).toBeLessThan(0.05);
+  });
+
+  it('ramps in over the first 10% rather than popping on', () => {
+    expect(F.alphaAt(0)).toBe(0);
+    expect(F.alphaAt(0.05)).toBeCloseTo(0.5, 5);
+    expect(F.alphaAt(0.1)).toBe(1);
+  });
+
+  it('falls on ^1.4, steeper than linear', () => {
+    // Halfway through the fall the linear value would be 0.5; ^1.4 is 0.38.
+    const mid = F.alphaAt(F.HOLD_UNTIL + (1 - F.HOLD_UNTIL) * 0.5);
+    expect(mid).toBeLessThan(0.45);
+    expect(mid).toBeGreaterThan(0.3);
+  });
+
+  it('drives the same envelope through tick()', () => {
+    F.spawn('LOST', 200, 220, 'crimson', 1.2);
+    let t = 0;
+    const at40 = F.LIFE * 0.4;
+    while (t < at40) {
+      F.tick(1 / 60);
+      t += 1 / 60;
+    }
+    expect(F.peek(0)?.alpha).toBe(1);
+    while (F.tick(1 / 60)) {
+      /* run it out */
+    }
+    expect(F.count()).toBe(0);
+  });
+});
+
+describe('floaters — half-width is measured, not assumed', () => {
+  it('clamps a long shout by the width its glyphs actually reported', () => {
+    F.setHost(host);
+    nodeWidth = 240; // "CAUGHT BLUFFING!" at 1.35 scale
+    // Anchored on a seat near the left edge and centred with translateX(-50%).
+    F.spawn('CAUGHT BLUFFING!', 20, 220, 'crimson', 1.35);
+    // half = ceil(240/2) + 8 = 128. A fixed 62px guess would have put the first
+    // glyph 66px off the left edge of the viewport.
+    expect(F.peek(0)?.x).toBe(128);
+    expect(made[0].shout).toBe(true);
+    expect(made[0].textContent).toBe('CAUGHT BLUFFING!');
+  });
+
+  it('clamps a long shout off the right edge too', () => {
+    F.setHost(host);
+    nodeWidth = 240;
+    F.spawn('CAUGHT BLUFFING!', 395, 220, 'crimson', 1.35);
+    expect(F.peek(0)?.x).toBe(BAND.width - 128);
+  });
+
+  it('leaves a short number on its default clamp', () => {
+    F.setHost(host);
+    nodeWidth = 40;
+    F.spawn('+3', 200, 220, 'brass');
+    expect(F.peek(0)?.x).toBe(200);
+    expect(made[0].shout).toBe(false);
+  });
+});
+
+describe('floaters — suppression and reduced motion', () => {
+  it('refuses to spawn while suppressed', () => {
+    F.setSuppressed(true);
+    expect(F.spawn('+3', 200, 220, 'brass')).toBe(false);
+    expect(F.count()).toBe(0);
+  });
+
+  it('keeps the text and loses the rise under reduced motion', () => {
+    F.setReduced(true);
+    F.spawn('LOST', 200, 220, 'crimson', 1.2);
+    const rec = F.peek(0);
+    expect(rec?.text).toBe('LOST');
+    expect(rec?.rise).toBe(0);
+    F.tick(0.3);
+    // Alpha still runs — the word appears and goes. It just does not travel.
+    expect(F.peek(0)?.alpha).toBeGreaterThan(0);
+  });
+});
+
+describe('floaters — the pool', () => {
+  it('recycles the oldest float rather than growing', () => {
+    for (let i = 0; i < 20; i++) F.spawn(`+${i}`, 30 + i * 200, 220, 'brass');
+    expect(F.count()).toBeLessThanOrEqual(12);
+  });
+
+  it('refuses an empty string', () => {
+    expect(F.spawn('', 200, 220, 'brass')).toBe(false);
+  });
+});
diff --git a/tests/app/fx/particles.test.ts b/tests/app/fx/particles.test.ts
new file mode 100644
index 0000000..c5796c8
--- /dev/null
+++ b/tests/app/fx/particles.test.ts
@@ -0,0 +1,194 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as P from '@/app/fx/particles';
+import * as emit from '@/app/fx/emitters';
+import { COL } from '@/app/fx/palette';
+
+/**
+ * The pool is module state, and vitest isolates modules per test FILE, so the
+ * laziness assertion below is only meaningful as the first test in this file.
+ * It is here rather than in a file of its own because everything else it needs
+ * to know about is here too.
+ */
+
+function one(life: number, x: number): boolean {
+  P.spDefaults();
+  P.SP.kind = P.KIND.DOT;
+  P.SP.col = COL.BONE;
+  P.SP.life = life;
+  P.SP.x = x;
+  P.SP.drag = 0;
+  P.SP.grav = 0;
+  return P.spawn();
+}
+
+function livePx(): number[] {
+  const p = P.inspect();
+  if (!p) return [];
+  const out: number[] = [];
+  for (let i = 0; i < P.count(); i++) out.push(p.px[i]);
+  return out;
+}
+
+describe('particles — laziness', () => {
+  it('allocates no backing store until the first spawn', () => {
+    // Importing fx must cost the module records and nothing else. 21 typed
+    // arrays × 600 is ~46KB that an SSR render must never pay for.
+    expect(P.allocated()).toBe(false);
+    expect(P.inspect()).toBeNull();
+    one(1, 0);
+    expect(P.allocated()).toBe(true);
+  });
+});
+
+describe('particles — capacity', () => {
+  beforeEach(() => {
+    P.reset();
+  });
+
+  it('never exceeds CAP', () => {
+    for (let i = 0; i < P.CAP + 200; i++) one(1, i);
+    expect(P.count()).toBe(P.CAP);
+    expect(P.peakCount()).toBe(P.CAP);
+  });
+
+  it('DROPS a spawn on a full pool rather than queueing it', () => {
+    for (let i = 0; i < P.CAP; i++) one(1, i);
+    expect(P.full()).toBe(true);
+    expect(one(1, 9999)).toBe(false);
+    expect(one(1, 9999)).toBe(false);
+    expect(P.droppedCount()).toBe(2);
+    expect(P.count()).toBe(P.CAP);
+
+    // A queue would turn a burst you cannot see into frames you can feel: the
+    // dropped pieces must NEVER reappear once room frees up.
+    P.update(0.5);
+    const after = P.count();
+    P.update(0.001);
+    expect(P.count()).toBeLessThanOrEqual(after);
+    expect(livePx()).not.toContain(9999);
+  });
+
+  it('an emitter stops firing at saturation instead of failing', () => {
+    for (let i = 0; i < P.CAP - 3; i++) one(1, i);
+    // 380 confetti into 3 free slots.
+    const placed = emit.confetti(400, 800, 380, 3.2);
+    expect(placed).toBe(3);
+    expect(P.count()).toBe(P.CAP);
+  });
+});
+
+describe('particles — zero allocation across a frame', () => {
+  beforeEach(() => {
+    P.reset();
+  });
+
+  it('reuses the same typed arrays for every frame of a burst', () => {
+    emit.confetti(400, 800, 300, 3.2);
+    emit.sparks(10, 10, 40, COL.CRIMSON, { speed: 200, life: 0.4, size: 9, grav: 300 });
+    const before = P.inspect();
+    expect(before).not.toBeNull();
+    if (!before) return;
+
+    const identity = { ...before };
+    const startCount = P.count();
+    expect(startCount).toBe(340);
+
+    for (let f = 0; f < 60; f++) P.update(1 / 60);
+
+    const after = P.inspect();
+    expect(after).toBe(before); // the pool object itself
+    for (const key of Object.keys(identity) as (keyof typeof identity)[]) {
+      expect(after?.[key]).toBe(identity[key]); // every backing array
+      expect(after?.[key].length).toBe(P.CAP); // never resized
+    }
+    // Particles really did die during those frames — the invariant is not
+    // holding because nothing happened.
+    expect(P.count()).toBeLessThan(startCount);
+  });
+
+  it('the spawn descriptor is one shared object, not a per-particle literal', () => {
+    const sp = P.SP;
+    emit.sparks(0, 0, 50, COL.BONE, { speed: 100, life: 0.3, size: 7, grav: 0 });
+    expect(P.SP).toBe(sp);
+  });
+});
+
+describe('particles — swap-with-last', () => {
+  beforeEach(() => {
+    P.reset();
+  });
+
+  it('keeps the live range dense and correct when a middle particle dies', () => {
+    one(0.1, 1); // index 0 — dies first
+    one(5.0, 2); // index 1
+    one(5.0, 3); // index 2 — the last, so it swaps into 0
+
+    expect(livePx()).toEqual([1, 2, 3]);
+    P.update(0.2);
+    expect(P.count()).toBe(2);
+    // Swap-with-last, not splice: 3 takes 1's slot. `splice` returns an array,
+    // which would be a per-frame allocation.
+    expect(livePx()).toEqual([3, 2]);
+  });
+
+  it('does not skip the particle swapped into a dead slot', () => {
+    one(0.1, 1);
+    one(0.1, 2);
+    one(5.0, 3);
+    one(0.1, 4);
+    P.update(0.2);
+    // Every short-lived particle must go in the SAME frame, including the one
+    // that was moved into an index the loop had already passed.
+    expect(P.count()).toBe(1);
+    expect(livePx()).toEqual([3]);
+  });
+
+  it('drains to empty', () => {
+    for (let i = 0; i < 100; i++) one(0.2, i);
+    P.update(0.3);
+    expect(P.count()).toBe(0);
+    P.update(0.3);
+    expect(P.count()).toBe(0);
+  });
+});
+
+describe('particles — the FX jitter stream', () => {
+  beforeEach(() => {
+    P.reset();
+  });
+
+  it('is deterministic and independent of any game randomness', () => {
+    // A spark must never consume from the deck's RNG. The counter-driven hash
+    // is reproducible from its own reset and from nothing else.
+    P.resetJitter();
+    const a = [P.rnd(), P.rnd(), P.rnd()];
+    P.resetJitter();
+    const b = [P.rnd(), P.rnd(), P.rnd()];
+    expect(a).toEqual(b);
+    for (const v of a) {
+      expect(v).toBeGreaterThanOrEqual(0);
+      expect(v).toBeLessThan(1);
+    }
+    expect(new Set(a).size).toBe(3);
+  });
+
+  it('gives the same burst from the same seed', () => {
+    P.reset();
+    emit.sparks(100, 100, 12, COL.CRIMSON, { speed: 250, life: 0.4, size: 9, grav: 300 });
+    const first = livePx();
+    P.reset();
+    emit.sparks(100, 100, 12, COL.CRIMSON, { speed: 250, life: 0.4, size: 9, grav: 300 });
+    expect(livePx()).toEqual(first);
+  });
+});
+
+describe('particles — draw is inert without a document', () => {
+  it('reports no sprites in the node environment and never throws', () => {
+    P.reset();
+    one(1, 0);
+    expect(P.ensureSprites()).toBe(false);
+    // `draw` bails on a missing sprite atlas rather than throwing, which is
+    // what keeps an SSR render and this suite honest.
+    expect(() => P.draw({} as never, 2)).not.toThrow();
+  });
+});
diff --git a/tests/app/fx/reducedMotion.test.ts b/tests/app/fx/reducedMotion.test.ts
new file mode 100644
index 0000000..c88571d
--- /dev/null
+++ b/tests/app/fx/reducedMotion.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fx from '@/app/fx';
+import * as floaters from '@/app/fx/floaters';
+import * as flash from '@/app/fx/flash';
+import * as shake from '@/app/fx/shake';
+import { reset as resetClock } from '@/app/anim/clock';
+import { __resetHaptics, hapticStats } from '@/app/utils/haptic';
+import { el, installRaf, type FakeElement, type RafHarness } from '../anim/fakeDom';
+
+/**
+ * The design rule made executable.
+ *
+ * ART-DIRECTION §7: motion collapses to fades; sound and haptics stay. The
+ * consequence that is easy to break and hard to notice is that a player who
+ * asked for less motion must not become the only player at the table with no
+ * evidence that anything happened. So: particles and shake go, the FLASH STAYS
+ * (opacity-only, so it reads as a light coming up rather than a hit), and the
+ * floater keeps its text and loses its 46px rise.
+ *
+ * There is no jsdom here, so the overlay never builds and there is no canvas
+ * and no plate. That is the point: every one of these systems keeps its state
+ * independently of whether a renderer exists, which is why they can be asserted
+ * at all.
+ */
+
+let raf: RafHarness;
+let table: FakeElement;
+
+beforeEach(() => {
+  resetClock();
+  raf = installRaf();
+  __resetHaptics();
+  table = el();
+  fx.reset();
+  fx.setReducedMotion(false);
+  fx.mount(null, table);
+  fx.setBand((out) => {
+    out.top = 60;
+    out.bottom = 320;
+    out.width = 400;
+  });
+});
+
+afterEach(() => {
+  fx.reset();
+  fx.setReducedMotion(false);
+  fx.unmount();
+  fx.setBand(null);
+  resetClock();
+  raf.restore();
+});
+
+const AT = { condition: 'mine', x: 200, y: 220 } as const;
+
+describe('fx — laziness', () => {
+  it('mount() builds nothing and subscribes to nothing', () => {
+    const s = fx.stats();
+    expect(s.mounted).toBe(true);
+    expect(s.pumping).toBe(false);
+    expect(s.particles).toBe(0);
+    expect(raf.pending()).toBe(0);
+  });
+
+  it('the first cue starts the pump and the last death stops it', () => {
+    fx.cue('influence_lost', AT);
+    expect(fx.stats().pumping).toBe(true);
+
+    // Run the beat out: 12 sparks at 0.42s, a 0.42s ring, a 520ms flash and a
+    // 900ms floater.
+    for (let i = 0; i < 120; i++) raf.frame(16);
+    const s = fx.stats();
+    expect(s.particles).toBe(0);
+    expect(s.floats).toBe(0);
+    expect(s.flash).toBe(false);
+    expect(s.trauma).toBe(0);
+    expect(s.pumping).toBe(false);
+  });
+});
+
+describe('fx — the normal path', () => {
+  it('fires particles, shake, flash, float and haptic for your own influence loss', () => {
+    expect(fx.cue('influence_lost', AT)).toBe(true);
+    const s = fx.stats();
+    expect(s.particles).toBeGreaterThan(0);
+    expect(s.trauma).toBeCloseTo(0.34, 6);
+    expect(s.flash).toBe(true);
+    expect(s.floats).toBe(1);
+    expect(floaters.peek(0)?.text).toBe('LOST');
+    expect(floaters.peek(0)?.rise).toBe(46);
+    expect(hapticStats().byPattern.influenceLost).toBe(1);
+  });
+
+  it('keeps the same loss quiet when it happens to somebody else', () => {
+    expect(fx.cue('influence_lost', { condition: 'theirs', x: 200, y: 220 })).toBe(true);
+    const s = fx.stats();
+    expect(s.particles).toBeGreaterThan(0); // grey puffs
+    expect(s.trauma).toBe(0);
+    expect(s.flash).toBe(false);
+    expect(s.floats).toBe(0);
+    expect(hapticStats().fired).toBe(0);
+  });
+});
+
+describe('fx — reduced motion', () => {
+  beforeEach(() => {
+    fx.setReducedMotion(true);
+  });
+
+  it('suppresses particles entirely', () => {
+    fx.cue('influence_lost', AT);
+    fx.cue('game_over', AT);
+    fx.cue('coup_landed', { condition: 'against_me', x: 100, y: 100 });
+    expect(fx.stats().particles).toBe(0);
+  });
+
+  it('suppresses the shake entirely', () => {
+    fx.cue('coup_landed', { condition: 'against_me', x: 100, y: 100 });
+    expect(fx.stats().trauma).toBe(0);
+    expect(shake.hasTransform()).toBe(false);
+    expect(table.style.writes.length).toBe(0);
+  });
+
+  it('STILL FIRES THE FLASH — it is the only non-auditory evidence left', () => {
+    expect(flash.firedCount()).toBe(0);
+    fx.cue('coup_landed', { condition: 'against_me', x: 100, y: 100 });
+    expect(fx.stats().flash).toBe(true);
+    expect(flash.firedCount()).toBe(1);
+  });
+
+  it('softens the flash to 55% and stretches it 1.6×', () => {
+    fx.cue('influence_lost', AT); // crimson .30 over 520ms
+    // Peak lands in the attack window: 8% of 832ms is 67ms.
+    for (let i = 0; i < 5; i++) raf.frame(16);
+    const peak = flash.alphaNow();
+    expect(peak).toBeGreaterThan(0);
+    expect(peak).toBeLessThanOrEqual(0.3 * 0.55 + 0.005);
+
+    // 1.6× duration: the normal path is out by 520ms, this one is not.
+    for (let i = 0; i < 33; i++) raf.frame(16); // ~608ms total
+    expect(fx.stats().flash).toBe(true);
+    for (let i = 0; i < 20; i++) raf.frame(16); // ~928ms total
+    expect(fx.stats().flash).toBe(false);
+  });
+
+  it('keeps the floater TEXT and drops only its rise', () => {
+    fx.cue('influence_lost', AT);
+    expect(fx.stats().floats).toBe(1);
+    const rec = floaters.peek(0);
+    expect(rec?.text).toBe('LOST');
+    expect(rec?.rise).toBe(0);
+    // Still fades in and out — the word appears and goes, it just does not travel.
+    raf.frame(16);
+    raf.frame(16);
+    expect(floaters.peek(0)?.alpha).toBeGreaterThan(0);
+  });
+
+  it('keeps the haptics — a haptic is not motion', () => {
+    fx.cue('influence_lost', AT);
+    expect(hapticStats().byPattern.influenceLost).toBe(1);
+  });
+
+  it('fires every cue the normal path fires, in the same tick', () => {
+    // GAME-FEEL-PLAN §7's reduced-motion gate. The cue LOG must be identical;
+    // only what each cue renders may differ.
+    const beats = [
+      ['card_landed', { condition: 'mine', x: 10, y: 10 }],
+      ['challenge_lost', { condition: 'against_me', x: 20, y: 20 }],
+      ['influence_lost', { condition: 'mine', x: 30, y: 30 }],
+      ['coins_changed', { condition: 'mine', x: 40, y: 40, amount: -3 }],
+      ['player_eliminated', { condition: 'theirs', x: 50, y: 50 }],
+      ['game_over', { condition: 'mine', x: 60, y: 60 }],
+    ] as const;
+
+    fx.setReducedMotion(false);
+    fx.reset();
+    for (const [event, opts] of beats) fx.cue(event, opts);
+    const normal = fx.log().map((r) => `${r.event}/${r.condition}/${r.matched}`);
+
+    fx.setReducedMotion(true);
+    fx.reset();
+    for (const [event, opts] of beats) fx.cue(event, opts);
+    const reduced = fx.log().map((r) => `${r.event}/${r.condition}/${r.matched}`);
+
+    expect(reduced).toEqual(normal);
+    expect(normal.length).toBe(beats.length);
+  });
+});
+
+describe('fx — the signed coin float', () => {
+  it('is brass for a gain and crimson for a loss', () => {
+    fx.cue('coins_changed', { condition: 'mine', x: 200, y: 220, amount: 3 });
+    expect(floaters.peek(0)?.text).toBe('+3');
+    expect(floaters.peek(0)?.tone).toBe('brass');
+    fx.reset();
+    fx.cue('coins_changed', { condition: 'mine', x: 200, y: 220, amount: -3 });
+    expect(floaters.peek(0)?.text).toBe('-3');
+    expect(floaters.peek(0)?.tone).toBe('crimson');
+  });
+
+  it('says nothing at all when somebody else is paid', () => {
+    expect(fx.cue('coins_changed', { condition: 'theirs', x: 200, y: 220, amount: 3 })).toBe(true);
+    expect(fx.stats().floats).toBe(0);
+    expect(fx.stats().particles).toBe(0);
+  });
+});
+
+describe('fx — unmatched cues', () => {
+  it('reports a miss and does nothing', () => {
+    expect(fx.cue('denied', { condition: 'theirs' })).toBe(false);
+    const s = fx.stats();
+    expect(s.particles).toBe(0);
+    expect(s.trauma).toBe(0);
+    expect(s.flash).toBe(false);
+    expect(s.pumping).toBe(false);
+    // The miss is still LOGGED — the Phase 7 gate needs to see it.
+    expect(fx.log().at(-1)).toMatchObject({ event: 'denied', matched: false });
+  });
+
+  it('defaults an un-directed cue to its quiet form', () => {
+    // Until the §3.6 two-channel hold exists, a cue with no direction must fail
+    // QUIET rather than loud.
+    fx.cue('coup_landed', { x: 100, y: 100 });
+    expect(fx.stats().trauma).toBe(0);
+    expect(fx.stats().flash).toBe(false);
+  });
+});
diff --git a/tests/app/fx/shake.test.ts b/tests/app/fx/shake.test.ts
new file mode 100644
index 0000000..1222314
--- /dev/null
+++ b/tests/app/fx/shake.test.ts
@@ -0,0 +1,195 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as shake from '@/app/fx/shake';
+import { el, type FakeElement } from '../anim/fakeDom';
+
+/**
+ * A `FakeElement` from the motion suite satisfies `ShakeElement` — the shake
+ * types its target structurally for exactly this reason, so the transform
+ * bookkeeping can be asserted without a jsdom.
+ */
+
+let node: FakeElement;
+
+beforeEach(() => {
+  shake.reset();
+  shake.setReduced(false);
+  node = el();
+  shake.setTarget(node);
+});
+
+afterEach(() => {
+  shake.reset();
+  shake.setTarget(null);
+});
+
+function tx(): { x: number; y: number; rot: number } {
+  return shake.displacement();
+}
+
+describe('shake — the trauma² curve', () => {
+  it('displaces by trauma SQUARED, not linearly', () => {
+    // Same target, same first frame, same noise phase (reset zeroes the clock),
+    // so the only difference between the two runs is the trauma.
+    const dt = 0.008;
+
+    shake.reset();
+    shake.add(0.6);
+    shake.tick(dt);
+    const loud = Math.abs(tx().x);
+
+    shake.reset();
+    shake.add(0.3);
+    shake.tick(dt);
+    const half = Math.abs(tx().x);
+
+    expect(loud).toBeGreaterThan(0);
+    expect(half).toBeGreaterThan(0);
+
+    // trauma decays by DECAY·dt before the write, so the honest expectation is
+    // ((0.6−0.012)/(0.3−0.012))² = 4.168 — not 4, and emphatically not the 2.04
+    // a linear model would give. This assertion is the difference between juice
+    // and nausea, stated as a number.
+    const ratio = loud / half;
+    expect(ratio).toBeGreaterThan(3.5);
+    expect(ratio).toBeLessThan(5);
+  });
+
+  it('never exceeds trauma² × MAX_X / MAX_Y / MAX_R', () => {
+    shake.add(shake.CAP);
+    for (let i = 0; i < 40 && shake.active(); i++) {
+      shake.tick(1 / 60);
+      const tr = shake.level() * shake.level();
+      const d = tx();
+      // +0.05 / +0.005 is the write quantisation (0.1px, 0.01deg).
+      expect(Math.abs(d.x)).toBeLessThanOrEqual(tr * shake.MAX_X + 0.05);
+      expect(Math.abs(d.y)).toBeLessThanOrEqual(tr * shake.MAX_Y + 0.05);
+      expect(Math.abs(d.rot)).toBeLessThanOrEqual(tr * shake.MAX_R + 0.005);
+    }
+  });
+
+  it('keeps x and y decorrelated — not a single diagonal', () => {
+    shake.add(0.6);
+    shake.tick(0.008);
+    const d = tx();
+    // Equal noise seeds would put y at exactly x·(MAX_Y/MAX_X) = x·0.733 every
+    // frame, and the shake would read as one diagonal slider being dragged.
+    const locked = Math.abs(d.x) * (shake.MAX_Y / shake.MAX_X);
+    expect(Math.abs(Math.abs(d.y) - locked)).toBeGreaterThan(0.5);
+  });
+});
+
+describe('shake — the perceptual floor', () => {
+  it('refuses a sub-floor trauma from rest', () => {
+    expect(shake.MIN_TRAUMA).toBeCloseTo(Math.sqrt(0.3 / shake.MAX_X), 3);
+    expect(shake.add(0.1)).toBe(false);
+    expect(shake.add(shake.MIN_TRAUMA - 0.001)).toBe(false);
+    expect(shake.level()).toBe(0);
+    expect(shake.active()).toBe(false);
+  });
+
+  it('still allows stacking above the floor', () => {
+    expect(shake.add(0.2)).toBe(true);
+    const before = shake.level();
+    // Already shaking: a sub-floor contribution genuinely adds, so it is honest
+    // to let it through. Four blocked actions inside a second should build.
+    expect(shake.add(0.1)).toBe(true);
+    expect(shake.level()).toBeCloseTo(before + 0.1, 6);
+  });
+
+  it('accepts exactly MIN_TRAUMA from rest', () => {
+    expect(shake.add(shake.MIN_TRAUMA)).toBe(true);
+    expect(shake.level()).toBeCloseTo(shake.MIN_TRAUMA, 6);
+  });
+
+  it('writes nothing at all when reduced motion is on', () => {
+    shake.setReduced(true);
+    expect(shake.add(0.6)).toBe(false);
+    expect(shake.level()).toBe(0);
+    expect(shake.hasTransform()).toBe(false);
+  });
+});
+
+describe('shake — LAND_CEILING', () => {
+  it('stops a five-landing caravan out-shaking the win', () => {
+    for (let i = 0; i < 5; i++) shake.add(0.16, shake.LAND_CEILING);
+    expect(shake.level()).toBeCloseTo(shake.LAND_CEILING, 6);
+    // The win is 0.60. A caravan capped at 0.34 can never reach it.
+    expect(shake.level()).toBeLessThan(0.6);
+  });
+
+  it('a landing never LOWERS trauma that is already above the ceiling', () => {
+    shake.add(0.6); // the win
+    expect(shake.add(0.16, shake.LAND_CEILING)).toBe(false);
+    expect(shake.level()).toBeCloseTo(0.6, 6);
+  });
+
+  it('an uncapped add still stacks to CAP and no further', () => {
+    shake.add(0.6);
+    shake.add(0.45);
+    expect(shake.level()).toBeCloseTo(shake.CAP, 6);
+    expect(shake.add(0.45)).toBe(false);
+  });
+});
+
+describe('shake — the transform substrate', () => {
+  it('REMOVES the transform at rest rather than setting it to none', () => {
+    shake.add(0.6);
+    shake.tick(0.016);
+    expect(node.style.getPropertyValue('transform')).toMatch(/translate3d/);
+    expect(node.style.getPropertyValue('will-change')).toBe('transform');
+    expect(shake.hasTransform()).toBe(true);
+
+    // Decay all the way out.
+    for (let i = 0; i < 60 && shake.tick(1 / 60); i++) {
+      /* spin */
+    }
+
+    // The property is GONE, not `none`: an element with `transform: none` is
+    // still a transformed element for the containing-block rule, and it leaves
+    // a value in the inline style that a reader will assume means something.
+    expect(node.style.props.has('transform')).toBe(false);
+    expect(node.style.props.has('will-change')).toBe(false);
+    expect(shake.hasTransform()).toBe(false);
+
+    const last = node.style.writes.filter((w) => w.property === 'transform').pop();
+    expect(last).toEqual({ property: 'transform', value: null });
+  });
+
+  it('decays to exactly zero and stops reporting live', () => {
+    shake.add(0.6);
+    let frames = 0;
+    while (shake.tick(1 / 60)) {
+      frames++;
+      expect(frames).toBeLessThan(300);
+    }
+    expect(shake.level()).toBe(0);
+    expect(shake.active()).toBe(false);
+    // 0.60 at 1.5/s is 0.4s ≈ 24 frames.
+    expect(frames).toBeGreaterThan(15);
+    expect(frames).toBeLessThan(35);
+  });
+
+  it('writes nothing while at rest', () => {
+    const before = node.style.writes.length;
+    expect(shake.tick(1 / 60)).toBe(false);
+    expect(shake.tick(1 / 60)).toBe(false);
+    expect(node.style.writes.length).toBe(before);
+  });
+
+  it('releases the old target when the target changes', () => {
+    shake.add(0.6);
+    shake.tick(0.016);
+    expect(node.style.props.has('transform')).toBe(true);
+    const next = el();
+    shake.setTarget(next);
+    expect(node.style.props.has('transform')).toBe(false);
+    expect(next.style.props.has('transform')).toBe(false);
+  });
+
+  it('does not write to a detached node', () => {
+    node.isConnected = false;
+    shake.add(0.6);
+    expect(shake.tick(0.016)).toBe(true);
+    expect(node.style.writes.length).toBe(0);
+  });
+});
diff --git a/tests/app/fx/tuning.test.ts b/tests/app/fx/tuning.test.ts
new file mode 100644
index 0000000..422735a
--- /dev/null
+++ b/tests/app/fx/tuning.test.ts
@@ -0,0 +1,234 @@
+import { describe, it, expect } from 'vitest';
+import {
+  FX_EVENTS,
+  FX_TABLE,
+  LAND_CEILING,
+  QUIET_TRAUMA_CEILING,
+  rowFor,
+  rowsFor,
+  type FxRow,
+  type QuietRow,
+} from '@/app/fx/tuning';
+import { MIN_TRAUMA, CAP as TRAUMA_CAP } from '@/app/fx/shake';
+import { HEX, PALETTE_SIZE } from '@/app/fx/palette';
+import { HAPTICS, HAPTIC_PRIORITY } from '@/app/utils/haptic';
+
+/**
+ * These are the design rules made executable. `QuietRow` already makes a
+ * reddened bystander event a TYPE error; these assert the same thing at
+ * runtime, so the rule survives a cast, a refactor of the union, or a row
+ * copy-pasted from the loud side of the table.
+ */
+
+const quiet = FX_TABLE.filter((r): r is QuietRow => r.condition === 'theirs');
+const loud = FX_TABLE.filter((r) => r.condition !== 'theirs');
+
+describe('tuning — completeness', () => {
+  it('gives every event at least one row', () => {
+    for (const event of FX_EVENTS) {
+      expect(rowsFor(event).length, `no row for "${event}"`).toBeGreaterThan(0);
+    }
+  });
+
+  it('has no row for an event outside the enum', () => {
+    const known = new Set(FX_EVENTS);
+    for (const row of FX_TABLE) expect(known.has(row.event)).toBe(true);
+  });
+
+  it('never repeats an (event, condition) pair', () => {
+    const seen = new Set();
+    for (const row of FX_TABLE) {
+      const key = `${row.event}/${row.condition}`;
+      expect(seen.has(key), `duplicate row ${key}`).toBe(false);
+      seen.add(key);
+    }
+  });
+
+  it('states a reason on every row', () => {
+    // A tuning number with no reason next to it is a number nobody can change.
+    for (const row of FX_TABLE) {
+      expect(row.why.length, `${row.event}/${row.condition}`).toBeGreaterThan(20);
+    }
+  });
+});
+
+describe('tuning — RED ONLY FOR THE VICTIM', () => {
+  it('never flashes crimson on a beat between other players', () => {
+    // The rule this file exists for. A table where every attack flashes red
+    // teaches the player nothing; a table where only theirs do is one they can
+    // read out of the corner of their eye.
+    for (const row of quiet) {
+      expect(row.flash?.tone, `${row.event}/theirs flashes red`).not.toBe('crimson');
+    }
+  });
+
+  it('never buzzes the phone for a beat between other players', () => {
+    for (const row of quiet) {
+      expect(row.haptic, `${row.event}/theirs fires a haptic`).toBeNull();
+    }
+  });
+
+  it('only ever flashes crimson at the player it happened to', () => {
+    const red = loud.filter((r) => r.flash?.tone === 'crimson');
+    expect(red.length).toBeGreaterThan(0);
+    for (const row of red) {
+      // Losing an influence is written as `mine`; being couped as `against_me`.
+      // Either is "this happened to you"; neither is a bystander.
+      expect(['mine', 'against_me'], row.event).toContain(row.condition);
+      expect(row.haptic, row.event).not.toBeNull();
+    }
+  });
+
+  it('keeps every bystander shake under the quiet ceiling', () => {
+    for (const row of quiet) {
+      expect(row.trauma, `${row.event}/theirs`).toBeLessThanOrEqual(QUIET_TRAUMA_CEILING);
+    }
+  });
+
+  it('gives the loud form of a directed attack strictly more than the quiet form', () => {
+    // challenge_lost, coup_landed and assassinate_blocked all exist in both
+    // forms. In every case the victim's row must be louder on every axis that
+    // matters, or the direction is not being taught.
+    for (const event of ['challenge_lost', 'coup_landed', 'assassinate_blocked'] as const) {
+      const victim = rowFor(event, 'against_me');
+      const bystander = rowFor(event, 'theirs');
+      expect(victim, event).not.toBeNull();
+      expect(bystander, event).not.toBeNull();
+      if (!victim || !bystander) continue;
+      expect(victim.trauma, event).toBeGreaterThan(bystander.trauma);
+      expect(victim.particles.length, event).toBeGreaterThan(bystander.particles.length);
+      expect(bystander.flash, event).toBeNull();
+      expect(victim.flash, event).not.toBeNull();
+    }
+  });
+});
+
+describe('tuning — RESTRAINT', () => {
+  it('makes the quiet form of the commonest beat cost nothing but a ring', () => {
+    // card_landed/theirs fires more than any other row in the table.
+    const row = rowFor('card_landed', 'theirs');
+    expect(row).not.toBeNull();
+    expect(row?.particles.length).toBe(1);
+    expect(row?.particles[0].emit).toBe('ring');
+    expect(row?.trauma).toBe(0);
+    expect(row?.flash).toBeNull();
+    expect(row?.float).toBeNull();
+    expect(row?.haptic).toBeNull();
+  });
+
+  it('caps routine landings so a caravan cannot out-shake the win', () => {
+    const land = rowFor('card_landed', 'mine');
+    const win = rowFor('game_over', 'mine');
+    expect(land?.traumaCeiling).toBe(LAND_CEILING);
+    expect(LAND_CEILING).toBeLessThan(win?.trauma ?? 0);
+  });
+
+  it('keeps the two world-stopping moments the loudest things in the table', () => {
+    // ART-DIRECTION §6: influence lost, and victory. Nothing else may outrank
+    // them, or the reason they land — that nothing else spent the attention —
+    // stops being true.
+    const win = rowFor('game_over', 'mine');
+    const maxTrauma = Math.max(...FX_TABLE.map((r) => r.trauma));
+    expect(win?.trauma).toBe(maxTrauma);
+
+    const lost = rowFor('influence_lost', 'mine');
+    expect(lost?.flash?.tone).toBe('crimson');
+    expect(lost?.haptic).toBe('influenceLost');
+    // Split the vocabulary in two. `land` and `denied` (priority 0 and 1) are
+    // ACKNOWLEDGEMENTS of your own tap and may be frequent; anything at
+    // priority 2 or above is EARNED and must stay rare, or the priority-aware
+    // floor in utils/haptic.ts starts arbitrating between things that all think
+    // they matter.
+    const earned = FX_TABLE.filter(
+      (r) => r.haptic !== null && HAPTIC_PRIORITY[r.haptic] >= 2,
+    );
+    expect(earned.length).toBeLessThanOrEqual(5);
+    expect(earned.every((r) => r.condition !== 'theirs')).toBe(true);
+    expect(FX_TABLE.filter((r) => r.haptic === 'win').length).toBe(1);
+    expect(FX_TABLE.filter((r) => r.haptic === 'influenceLost').length).toBe(1);
+  });
+
+  it('leaves most beats silent', () => {
+    const flashing = FX_TABLE.filter((r) => r.flash !== null).length;
+    const shaking = FX_TABLE.filter((r) => r.trauma > 0).length;
+    expect(flashing).toBeLessThan(FX_TABLE.length / 2);
+    expect(shaking).toBeLessThan(FX_TABLE.length * 0.75);
+  });
+});
+
+describe('tuning — no dead shakes', () => {
+  it('never asks for a trauma the shake system will refuse', () => {
+    // fx/shake.ts refuses anything under MIN_TRAUMA from rest, because it is
+    // below the 0.1px write quantisation. A row asking for less is dead code
+    // that still holds a transform.
+    for (const row of FX_TABLE) {
+      if (row.trauma === 0) continue;
+      expect(row.trauma, `${row.event}/${row.condition}`).toBeGreaterThanOrEqual(MIN_TRAUMA);
+      expect(row.trauma, `${row.event}/${row.condition}`).toBeLessThanOrEqual(TRAUMA_CAP);
+    }
+  });
+
+  it('keeps every flash inside a usable opacity range', () => {
+    for (const row of FX_TABLE) {
+      if (!row.flash) continue;
+      expect(row.flash.strength).toBeGreaterThan(0.05);
+      expect(row.flash.strength).toBeLessThanOrEqual(0.5);
+      expect(row.flash.durationMs).toBeGreaterThanOrEqual(200);
+      expect(row.flash.durationMs).toBeLessThanOrEqual(1200);
+    }
+  });
+
+  it('names only haptics that exist in the vocabulary', () => {
+    for (const row of FX_TABLE) {
+      if (!row.haptic) continue;
+      expect(HAPTICS[row.haptic]).toBeDefined();
+    }
+  });
+
+  it('names only colours that exist in the palette', () => {
+    for (const row of FX_TABLE) {
+      for (const e of row.particles) {
+        if (e.emit === 'confetti') continue;
+        expect(e.color).toBeGreaterThanOrEqual(0);
+        expect(e.color).toBeLessThan(PALETTE_SIZE);
+        expect(HEX[e.color]).toMatch(/^#[0-9A-F]{6}$/);
+      }
+    }
+  });
+});
+
+describe('tuning — rowFor', () => {
+  it('a bystander condition NEVER escalates to the loud form', () => {
+    // `denied` has only a `mine` row. A bystander cue for it must resolve to
+    // nothing — silence is the correct failure mode for an unmapped
+    // combination, and a crimson wash is not.
+    expect(rowFor('denied', 'mine')).not.toBeNull();
+    expect(rowFor('denied', 'theirs')).toBeNull();
+
+    for (const event of FX_EVENTS) {
+      const row = rowFor(event, 'theirs');
+      if (row) expect(row.condition, event).toBe('theirs');
+    }
+  });
+
+  it('treats mine and against_me as two shades of "this concerns you"', () => {
+    // influence_lost is written as `mine`; a caller who says `against_me`
+    // (someone made you lose it) must get the same loud row, not the quiet one.
+    const a = rowFor('influence_lost', 'against_me');
+    const b = rowFor('influence_lost', 'mine');
+    expect(a).toBe(b);
+
+    // challenge_lost is written as `against_me`; the reverse must hold.
+    expect(rowFor('challenge_lost', 'mine')).toBe(rowFor('challenge_lost', 'against_me'));
+  });
+
+  it('prefers an exact match over a fallback', () => {
+    const mine = rowFor('coup_landed', 'mine') as FxRow;
+    const victim = rowFor('coup_landed', 'against_me') as FxRow;
+    expect(mine).not.toBe(victim);
+    expect(mine.condition).toBe('mine');
+    expect(victim.condition).toBe('against_me');
+    // The one you launched is not the one that landed on you.
+    expect(victim.trauma).toBeGreaterThan(mine.trauma);
+  });
+});

From 72f5dc4ee79672a1b4d82e1c2ddc112e0111baa1 Mon Sep 17 00:00:00 2001
From: Hunter M 
Date: Sun, 9 Aug 2026 01:30:44 -0700
Subject: [PATCH 3/3] feat(table): build a real table, wire the engines, and
 answer out loud
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Desktop was a phone column in a 1456px viewport with ~65% unused and no table
at all — the deck was 8px of header text. It now has seats around a felt, a
deck pile tracking deckCount, a discard well and a treasury. One DOM tree at a
1024px breakpoint, not two: a hidden second copy would fight the live one for
the FX seat registry. Mobile is byte-for-byte the grid it was.

Typography: Oswald self-hosted via next/font/local, no CDN — the app is
offline-capable. Functional system emoji 40 -> 0. Five duplicated per-character
palettes collapsed to one. Coins, deck, treasury and timers are monospace with
tabular-nums; they were the only figures that change during play and the only
ones set proportional.

Input feel: controls no longer go `disabled` with a title attribute. A refused
action answers with a cue, a haptic, a 320ms shake and a sentence that reads
its numbers from constants.ts. Ineligible targets are rendered and marked
rather than omitted — the forced-Coup panel could previously render zero
buttons on a faction-split table.

Contrast: text-gray-500 was 2.85:1 on --raised. Swept onto tokens at 5.90:1.

Two bugs that only exist at runtime, both found by watching a real game:
React 19's StrictMode double-invoke aborted every transient action card, so
Coup/Steal/Assassinate flashed for one commit and vanished; and Chrome does
not run rAF in a hidden tab, so a flight in progress froze mid-transform and
its land callback never fired — measured as 21 seconds of a stuck full-screen
reveal plate. Flights now settle on visibilitychange.

Co-Authored-By: Claude Opus 5 
Claude-Session: https://claude.ai/code/session_019LgH9q6E7j2KZp3PCDSMfV
---
 src/app/components/chat/ChatPanel.tsx         |    6 +-
 src/app/components/game/ActionBar.tsx         |  566 ++++++--
 src/app/components/game/ActionLog.tsx         |   82 +-
 src/app/components/game/CardArtwork.tsx       |  123 +-
 src/app/components/game/CardFace.tsx          |  366 ++++-
 src/app/components/game/ChallengePrompt.tsx   |    2 +-
 .../game/ChallengeRevealOverlay.tsx           |  575 ++++++--
 src/app/components/game/CoinChangeBurst.tsx   |    2 +-
 src/app/components/game/ExchangeView.tsx      |  199 ++-
 src/app/components/game/GameCenterTabs.tsx    |   21 +-
 src/app/components/game/GameOverOverlay.tsx   |   80 +-
 src/app/components/game/GameTable.tsx         | 1002 +++++++++++++-
 .../components/game/InfluenceLossPrompt.tsx   |    2 +-
 src/app/components/game/PlayerSeat.tsx        |   76 +-
 src/app/components/game/PracticeCoach.tsx     |   10 +-
 src/app/components/game/ReactionPicker.tsx    |    4 +-
 src/app/components/home/HowToPlay.tsx         |   56 +-
 src/app/components/lobby/AddBotModal.tsx      |    4 +-
 src/app/components/pwa/PWAInstallPrompt.tsx   |    4 +-
 src/app/components/settings/SettingsModal.tsx |   16 +-
 src/app/components/stats/AwardsTab.tsx        |   39 +-
 src/app/components/stats/HistoryTab.tsx       |   12 +-
 src/app/components/stats/OverviewTab.tsx      |    8 +-
 src/app/components/stats/StatsModal.tsx       |   10 +-
 .../tutorial/ReformationTutorial.tsx          |   24 +-
 src/app/components/tutorial/Tutorial.tsx      |   99 +-
 src/app/components/ui/Modal.tsx               |    2 +-
 src/app/components/ui/Timer.tsx               |    2 +-
 src/app/game/[roomCode]/page.tsx              |    2 +-
 src/app/globals.css                           | 1233 ++++++++++++++++-
 src/app/hooks/useFxCues.ts                    |  567 ++++++++
 src/app/hooks/useHapticFeedback.ts            |  168 +++
 src/app/hooks/useSoundEffects.ts              |   79 +-
 src/app/layout.tsx                            |   51 +-
 src/app/lobby/[roomCode]/page.tsx             |   38 +-
 src/app/page.tsx                              |   28 +-
 src/app/stores/gameStore.ts                   |   21 +-
 src/app/utils/characterPalette.ts             |  143 ++
 src/app/utils/gameStats.ts                    |   25 +-
 src/app/utils/logGlyphs.tsx                   |   92 ++
 src/shared/constants.ts                       |   30 -
 src/shared/types.ts                           |   31 +
 tailwind.config.ts                            |   91 +-
 tests/app/components/gameTableSeating.test.ts |   46 +
 tests/app/components/tableMotion.test.ts      |  465 +++++++
 tests/app/hooks/challengeReveal.test.ts       |  722 ++++++++++
 tests/app/hooks/useFxCues.test.ts             |  552 ++++++++
 tests/app/stores/targeting.test.ts            |   64 +
 tests/app/styleBudget.test.ts                 |  149 ++
 49 files changed, 7225 insertions(+), 764 deletions(-)
 create mode 100644 src/app/hooks/useFxCues.ts
 create mode 100644 src/app/hooks/useHapticFeedback.ts
 create mode 100644 src/app/utils/characterPalette.ts
 create mode 100644 src/app/utils/logGlyphs.tsx
 create mode 100644 tests/app/components/gameTableSeating.test.ts
 create mode 100644 tests/app/components/tableMotion.test.ts
 create mode 100644 tests/app/hooks/challengeReveal.test.ts
 create mode 100644 tests/app/hooks/useFxCues.test.ts
 create mode 100644 tests/app/stores/targeting.test.ts
 create mode 100644 tests/app/styleBudget.test.ts

diff --git a/src/app/components/chat/ChatPanel.tsx b/src/app/components/chat/ChatPanel.tsx
index 111e1be..3c86602 100644
--- a/src/app/components/chat/ChatPanel.tsx
+++ b/src/app/components/chat/ChatPanel.tsx
@@ -36,7 +36,7 @@ export function ChatPanel({ messages, myId, onSend }: ChatPanelProps) {
     
{visibleMessages.length === 0 && ( -

No messages yet...

+

No messages yet...

)} {visibleMessages.map((msg) => { const isOwn = msg.playerId === myId; @@ -51,14 +51,14 @@ export function ChatPanel({ messages, myId, onSend }: ChatPanelProps) { })}
-
+ setInput(e.target.value)} maxLength={CHAT_MAX_MESSAGE_LENGTH} placeholder="Type a message..." - className="flex-1 bg-coup-bg border border-gray-700 rounded-lg px-3 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-coup-accent/50" + className="flex-1 bg-coup-bg border border-coup-line rounded-lg px-3 py-1.5 text-xs text-gray-200 placeholder-coup-ink-mute focus:outline-none focus:border-coup-accent/50" /> - {targets.map(t => ( + + {targetOptions.map(({ player, eligible, reason }) => ( + + + ))}
+ {refusalAlert}
); } + const noneEligible = targetOptions.length > 0 && targetOptions.every(o => !o.eligible); + const stealBlocked = selectingTarget === ActionType.Steal && noneEligible + && targetOptions.every(o => o.reason.endsWith('no coins to steal.')); + return (
@@ -257,35 +494,49 @@ export function ActionBar({ gameState }: ActionBarProps) { {actionName} who?

- {availableTargets.map(t => ( - + + ))} - {isFactionRestricted && availableTargets.length === 0 && ( -

{noTargetMessage}

+ {noneEligible && ( +

+ {stealBlocked ? 'No valid Steal targets — nobody has coins.' : SAME_FACTION_REASON} +

)} - {!isFactionRestricted && availableTargets.length === 0 && ( -

{noTargetMessage}

+ {targetOptions.length === 0 && ( +

No valid targets — nobody else is left.

)}
+ {refusalAlert}
); } if (mustCoup) { - const coupTargets = factionTargets; return (
@@ -296,22 +547,30 @@ export function ActionBar({ gameState }: ActionBarProps) { Choose a player to eliminate

- {coupTargets.map(t => ( - + + ))}
+ {refusalAlert}
); } @@ -322,65 +581,86 @@ export function ActionBar({ gameState }: ActionBarProps) {
{actionConfig.map((a, index) => { const def = ACTION_DEFINITIONS[a.type]; - let canAfford = me.coins >= def.cost; - let costReason = def.cost > 0 ? `Need ${coinLabel(def.cost)}` : ''; - // Convert cost is dynamic - if (a.type === ActionType.Convert) { - canAfford = me.coins >= CONVERSION_SELF_COST; - costReason = `Need ${coinLabel(CONVERSION_SELF_COST)} to convert yourself`; - } - // Embezzle requires non-empty reserve - if (a.type === ActionType.Embezzle) { - canAfford = gameState.treasuryReserve > 0; - } - const isFactionAction = [ActionType.Coup, ActionType.Assassinate, ActionType.Steal, ActionType.Examine].includes(a.type); - let relevantTargets = isFactionAction ? factionTargets : targets; - const factionEligibleTargets = relevantTargets; - if (a.type === ActionType.Steal) relevantTargets = relevantTargets.filter(t => t.coins > 0); - const hasTargets = a.type === ActionType.Convert || !def.requiresTarget || relevantTargets.length > 0; - const disabled = !canAfford || !hasTargets || actionPending; - const Icon = a.icon; - // Build a reason string for disabled actions - let disabledReason = ''; - if (actionPending) { - disabledReason = 'Sending action...'; - } else if (!canAfford) { - disabledReason = a.type === ActionType.Embezzle ? 'Reserve empty' : costReason; - } else if (a.type === ActionType.Embezzle && gameState.treasuryReserve === 0) { - disabledReason = 'Reserve empty'; - } else if (a.type === ActionType.Steal && factionEligibleTargets.length > 0 && relevantTargets.length === 0) { - disabledReason = 'No opponents with coins'; - } else if (isFactionAction && factionTargets.length === 0 && targets.length > 0) { - disabledReason = 'No enemy faction targets'; - } else if (!hasTargets && def.requiresTarget) { - disabledReason = 'No valid targets'; - } + /** + * One gate per action, and it produces a SENTENCE rather than a + * boolean plus a tooltip. Order matters: the first true condition is + * the one the player is asked to act on, so the cheapest fix is + * named first (coins) before the structural one (targets). + */ + const gate = ((): { ok: boolean; reason: string } => { + if (actionPending) return { ok: false, reason: PENDING_REASON }; + + if (a.type === ActionType.Convert) { + if (me.coins < CONVERSION_SELF_COST) { + return { ok: false, reason: needCoins(CONVERSION_SELF_COST, 'Convert yourself', me.coins) }; + } + return { ok: true, reason: '' }; + } + + if (a.type === ActionType.Embezzle) { + if (gameState.treasuryReserve === 0) { + return { ok: false, reason: 'The treasury reserve is empty — there is nothing to embezzle.' }; + } + return { ok: true, reason: '' }; + } + + if (def.cost > 0 && me.coins < def.cost) { + return { ok: false, reason: needCoins(def.cost, a.label, me.coins) }; + } + + if (def.requiresTarget) { + const options = buildTargetOptions(a.type, opponents, me, factionsSplit); + if (options.length === 0) { + return { ok: false, reason: `No ${a.label} targets — nobody else is left.` }; + } + if (options.every(o => !o.eligible)) { + if (a.type === ActionType.Steal && options.every(o => o.player.coins === 0)) { + return { ok: false, reason: 'No valid Steal targets — nobody has coins.' }; + } + return { ok: false, reason: SAME_FACTION_REASON }; + } + } + + return { ok: true, reason: '' }; + })(); + + const Icon = a.icon; return ( -
- + + ); })} + {refusalAlert} ); } diff --git a/src/app/components/game/ActionLog.tsx b/src/app/components/game/ActionLog.tsx index 38e942c..35ff26d 100644 --- a/src/app/components/game/ActionLog.tsx +++ b/src/app/components/game/ActionLog.tsx @@ -2,7 +2,8 @@ import { useRef, useEffect, useState } from 'react'; import { LogEntry, TurnPhase } from '@/shared/types'; -import { LOG_EVENT_ICONS, CHARACTER_COLORS } from '@/shared/constants'; +import { CHARACTER_COLORS } from '@/shared/constants'; +import { LOG_EVENT_GLYPHS } from '@/app/utils/logGlyphs'; import { formatLogMessage } from '@/app/utils/logFormat'; import { getLogExplanation } from '@/app/utils/logExplanations'; import { haptic } from '../../utils/haptic'; @@ -17,6 +18,14 @@ interface ActionLogProps { /** Log event types that represent claims (where wasBluff is meaningful) */ const CLAIM_EVENT_TYPES = new Set(['claim_action', 'block']); +/** + * Glyph gutter. The mark is 14px inside a 20px (`text-sm`/`leading-5`) line box + * and is nudged down 3px so it centres against the cap height rather than the + * line box. The column is a fixed-width flex item so it cannot jitter between + * rows regardless of which glyph lands in it. + */ +const LOG_GLYPH_SIZE = 14; + /** Group consecutive entries by turnNumber */ function groupByTurn(entries: LogEntry[]): LogEntry[][] { const groups: LogEntry[][] = []; @@ -76,7 +85,7 @@ export function ActionLog({ log, myName, turnPhase, showExplanations = false }:
{log.length === 0 && ( -

Game starting...

+

Game starting...

)} {turnGroups.map((group, gi) => { const borderColor = getGroupBorderColor(group); @@ -87,7 +96,7 @@ export function ActionLog({ log, myName, turnPhase, showExplanations = false }: style={{ borderLeft: `3px solid ${borderColor}` }} > {group.map((entry, ei) => { - const icon = LOG_EVENT_ICONS[entry.eventType] ?? ''; + const EventGlyph = LOG_EVENT_GLYPHS[entry.eventType]; const isLatestGroup = gi === turnGroups.length - 1; const isLatestEntry = isLatestGroup && ei === group.length - 1; const message = formatLogMessage(entry.message, myName); @@ -99,43 +108,50 @@ export function ActionLog({ log, myName, turnPhase, showExplanations = false }: return (
- {icon} - {message} - {showBluffBadge && ( - - {entry.wasBluff ? 'BLUFF' : 'TRUE'} - - )} - {explanation && ( - - )} + + {EventGlyph && } + + + {message} + {showBluffBadge && ( + + {entry.wasBluff ? 'BLUFF' : 'TRUE'} + + )} + {explanation && ( + + )} +
{explanation && isExpanded && ( -
+
{explanation}
)} diff --git a/src/app/components/game/CardArtwork.tsx b/src/app/components/game/CardArtwork.tsx index e764658..b82a2c1 100644 --- a/src/app/components/game/CardArtwork.tsx +++ b/src/app/components/game/CardArtwork.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { CSSProperties } from 'react'; import { Character } from '@/shared/types'; import { CARD_ART_DIMENSIONS, @@ -8,6 +9,8 @@ import { CHARACTER_CARD_ART, CHARACTER_CARD_FOCUS_ART, } from '../../utils/assets'; +import { CHARACTER_GLYPHS } from '../icons'; +import { characterGlyphVars } from '../../utils/characterPalette'; export const CHARACTER_CARD_BADGES: Record = { [Character.Duke]: { name: 'DUKE', action: 'TAX' }, @@ -67,19 +70,131 @@ export function CardBackArtwork({ className = '', variant = 'full', priority = f ); } +interface CharacterCardGlyphProps { + character: Character; +} + +const CHARACTER_CORNER_CHIP: CSSProperties = { + position: 'absolute', + top: 3, + right: 3, + /* Above `.card-face::before` (z 2, the hue band) so a wide band never clips + it, and below `.card-face::after` (z 5, the trim hairline) so the card's + frame still prints over everything. `.card-label-plate` is z 3 and is at + the other end of the card. */ + zIndex: 4, + width: 'min(42%, 40px)', + aspectRatio: '1 / 1', + display: 'block', + /* The card is clickable on six of its seven surfaces. */ + pointerEvents: 'none', + borderRadius: 3, + backgroundColor: 'rgba(0, 0, 0, 0.86)', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.55)', +}; + +/* Full-bleed: rule 6 already gives every glyph a ~6px margin inside the 64 + grid, so the tile gets its optical padding from the artwork instead of from + a percentage padding — which would have resolved against the CARD's width, + not the tile's, and eaten half the mark on a 44px card. */ +const CHARACTER_CORNER_GLYPH_CLASS = 'block h-full w-full'; + +/** + * THE CORNER INDEX — ART-DIRECTION.md §1.2's character silhouette, printed on + * the card. This is the thing §1.2 asks for when it says a character is + * identified by "glyph silhouette first, colour second". + * + * ── WHY IT HAS TO EXIST AT ALL ─────────────────────────────────────────── + * Until now the only non-textual character signal on a card was the hue band + * down the left edge, and §2.2 deliberately sets the Assassin at 206°/S15 — + * near-achromatic cold steel — noting "the fix here is the glyph, not the hue". + * With no glyph drawn, that band was invisible by design with nothing standing + * in for it: the Assassin was an unmarked card. §2.4 adds that Assassin and + * Captain are ONE degree of hue apart. Colour cannot separate those two, and + * for a colourblind player it cannot separate any of the six. The silhouette is + * the channel that can, so it has to be on the card, not only in the log. + * + * ── WHY TOP-RIGHT ──────────────────────────────────────────────────────── + * The card has exactly two unclaimed regions and this takes the one that + * collides with nothing: + * - the LEFT edge is the printed hue band (`.card-face::before`), which + * "runs the whole exposed height" so a row of seats reads as a colour + * index. Putting the glyph there would cap the band on every card. + * - the BOTTOM 46% is `.card-label-plate`, a near-opaque wash carrying a type + * system tuned across five card sizes (`.card-face-sm/md/lg`, + * `.card-reveal-face`, `.card-preview-face`) with four length-fit classes. + * Nothing goes in there. + * - the TOP-RIGHT is free at every one of those five sizes, and it puts the + * shape channel and the colour channel on opposite edges of the card, so + * they are two reads rather than one crowded one. + * It is also just where a playing card's index goes. + * + * ── WHY IT IS NOT A TRANSFORM AUTHOR ───────────────────────────────────── + * Read the header of `CardFace.tsx`: `.card-face` composes --press-s/--card-lift + * into one transform and `.card-flip-wrapper` carries the flight contract + * --fx/--fy/--tilt/--fs. A third author on either element breaks one of them. + * This element has NO transform, NO animation and NO transition — it is static + * print inside the card, so it inherits both poses for free by being a child. + * + * ── SIZING ─────────────────────────────────────────────────────────────── + * `min(42%, 40px)` of the card's width, not a fixed px and not a pure + * percentage. §1.2 requires legibility at 14x14 and the smallest card is 44x64, + * where 42% lands the glyph at ~14.9px — on spec. A pure percentage would then + * put a 74px chip on the 176px preview card, which is a poster, not an index; + * the 40px cap holds it to a corner mark at presentation sizes. Everything + * scales without touching globals.css, including the `.seat-cards` media query + * that widens `card-face-sm` to 3.5rem on larger screens. + * + * The chip is a near-opaque black tile because the glyph prints over raster + * gouache whose value varies per pixel and per character (§2.3 lists card art + * as an UNMEASURED contrast case). Against the tile the hue is on a known + * ground: the §2.3 figures for these six on `--ground` run 6.85:1 (Assassin) to + * 9.50:1 (Ambassador), so even the weakest clears 4.5:1 with room to spare. The + * 1px keyline is the same device the band uses for the same reason — it gives + * the tile a guaranteed edge over a light passage of art. + */ +export function CharacterCardGlyph({ character }: CharacterCardGlyphProps) { + const CharacterGlyph = CHARACTER_GLYPHS[character]; + + return ( + + ); +} + interface CharacterCardBadgeProps { character: Character; } +/** + * The printed furniture on a character card: the corner silhouette (§1.2's + * shape channel) and the bottom label plate (the type channel). + * + * They ship as one component on purpose. Seven surfaces render a card face — + * the table, the exchange tray, the challenge reveal, the examine prompt, the + * preview modal, How to Play and both tutorials — and every one of them already + * calls this. Emitting the glyph from here is what makes "a character is + * identifiable by shape" true on all seven at once instead of on whichever ones + * someone remembered to update. The two children are absolutely positioned at + * opposite ends of the card and never meet. + */ export function CharacterCardBadge({ character }: CharacterCardBadgeProps) { const details = CHARACTER_CARD_BADGES[character]; const nameFit = details.name.length >= 9 ? 'card-label-extra-long' : details.name.length >= 7 ? 'card-label-medium' : ''; const actionFit = details.action.length >= 8 ? 'card-label-medium' : ''; return ( - - {details.name} - {details.action} - + <> + + + {details.name} + {details.action} + + ); } diff --git a/src/app/components/game/CardFace.tsx b/src/app/components/game/CardFace.tsx index 89bfb8c..62c422d 100644 --- a/src/app/components/game/CardFace.tsx +++ b/src/app/components/game/CardFace.tsx @@ -1,20 +1,30 @@ 'use client'; -import { useState, useEffect, useCallback, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { + useState, + useEffect, + useCallback, + useRef, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from 'react'; import { createPortal } from 'react-dom'; import { Character, ClientInfluence } from '@/shared/types'; import { CHARACTER_DESCRIPTIONS } from '@/shared/constants'; import { useGameStore } from '../../stores/gameStore'; +import { CHARACTER_PALETTE, characterCardVars } from '../../utils/characterPalette'; import { CardArtwork, CardBackArtwork, CharacterCardBadge } from './CardArtwork'; - -const characterColors: Record = { - [Character.Duke]: 'border-purple-500 bg-purple-900/40', - [Character.Assassin]: 'border-gray-500 bg-gray-800/40', - [Character.Captain]: 'border-blue-500 bg-blue-900/40', - [Character.Ambassador]: 'border-green-500 bg-green-900/40', - [Character.Contessa]: 'border-red-500 bg-red-900/40', - [Character.Inquisitor]: 'border-teal-500 bg-teal-900/40', -}; +import { getSoundEngine } from '../../audio/SoundEngine'; +import { + FLIGHT_TRANSFORM_STYLE, + dealIn, + ensureReducedMotionSync, + influenceTumble, + useFlight, + useIsomorphicLayoutEffect, +} from '../../anim'; +import type { Point } from '../../hooks/useFxCues'; type CardSize = 'sm' | 'md' | 'lg'; @@ -24,16 +34,150 @@ const cardSizeClasses: Record = { lg: 'card-face-lg', }; +/* ── THE TWO TRANSFORM AUTHORS, AND WHY THEY DO NOT MEET ─────────────────── + * + * A card here is two elements and each owns exactly one `transform`: + * + * .card-flip-wrapper ← THE FLIGHT. `FLIGHT_TRANSFORM_STYLE`, inline, from + * anim/verbs. flight.ts writes --fx/--fy/--tilt/--fs + * and nothing else writes them. + * .card-face ← THE POSE. globals.css composes --press-y/--press-s/ + * --card-lift, plus the 3D flip keyframes, which write + * `transform` outright. + * + * Two elements, two authors, no collision — which is what makes the flight + * survivable at all: `.card-face`'s transform lives in globals.css and could + * not be extended to carry four more variables without owning that file. + * + * Translate being OUTERMOST on the wrapper is what keeps a measured FLIP exact: + * adding `dx` to `--fx` moves the wrapper's centroid by exactly `dx` whatever + * tilt and scale it carries. See flight.ts's header for the arithmetic on what + * happens when it is not. + * + * ── AND WHY AN ANCESTOR MUST NOT WRITE THEM EITHER ──────────────────────── + * + * Custom properties INHERIT. `.card-flip-wrapper`'s transform reads + * `var(--fx, 0px)`, and it only defines `--fx` itself once flight.ts has + * written one — so any ancestor that flies would push its own `--fx` down into + * every card underneath it and each card would move twice: once because the + * ancestor moved, and again because it read the ancestor's variable as its + * own. That is not hypothetical: GameTable shoves a whole SEAT for §6's Refuse + * verb, and a seat is full of these wrappers. + * + * {@link FLIGHT_VARS_RESET} is the stop. Put it on the container that holds + * cards inside anything that can fly — `.seat-cards`, `.hand-cards` — and the + * inherited value dies one level above the card. It is not needed on cards + * whose ancestors never fly, and it is deliberately not baked into the wrapper + * itself: flight.ts's `clearTransform()` removes the inline properties, and a + * card that had declared its own zeroes would silently start inheriting again + * the moment it left the system. + */ + +/** + * Zeroes for the four transform-contract variables, to be spread onto a + * CONTAINER of cards. Frozen and module-level: React skips a style object it + * is handed by identity, so a shared constant can never re-write a variable + * flight.ts is in the middle of animating. + */ +export const FLIGHT_VARS_RESET: Readonly = Object.freeze({ + '--fx': '0px', + '--fy': '0px', + '--tilt': '0deg', + '--fs': '1', +} as CSSProperties); + +/** + * A landing cue. anim/ deliberately does not import the audio bus — flight.ts + * takes a plain `land` callback precisely so the cue is wired here, at the + * call site that knows what landed. + * + * `cardShuffle` and not `exchange`: `exchange` is the semantic cue for the + * ACTION and useSoundEffects already fires it; `cardShuffle` is the bank's + * paper-and-deck cue, is in `FLAM` (so a second one inside 190ms attenuates + * rather than doubling) and is non-priority, which is the right weight for + * something that happens on every replacement. + * + * `mine` is left at its default true, and that is correct rather than lazy: the + * swap path below only fires on a `character → different character` transition, + * and an opponent's hidden influence is serialised to `{ character: null }`, so + * a card that swaps in front of you is always your own. + */ +function playCardLanding(): void { + getSoundEngine().play('cardShuffle'); +} + +/** + * WHERE A CARD CAME FROM, AS A THUNK RATHER THAN A POINT. + * + * The one caller — the discard pile — knows the answer as "that player's + * seat", and the only place that can be turned into viewport pixels is the FX + * position registry. Resolving it in the PARENT'S render would be a + * `getBoundingClientRect` during render; resolving it in the parent's layout + * effect is too late, because a child's layout effect runs first and the card + * must launch on the frame it mounts. So the parent hands down the question and + * this component asks it at exactly the right moment — inside its own mount + * layout effect, one frame after the DOM has the card in it. + * + * Returning null is a first-class answer and means "no geography": below + * 1024px there is no discard well at all, a spectator has no seat, and an + * unmounted seat measures nothing. It is never coerced to (0, 0) — flip.ts's + * guard has the arithmetic on why a zero rect is a card entering from the + * corner of the viewport. + */ +export type EnterFrom = () => Point | null; + +/** The smallest box worth treating as a rendered card. */ +interface CardBox { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * The FLIP invert for a fall: where the card IS (`from`) minus where it + * BELONGS (the centre of `rect`). + * + * Three ways to answer "do not move", and not one of them is a coordinate: + * + * • `from` is null — the seat it fell from is not on screen. + * • `rect` measures zero — `.felt-centre` is `display: none` below 1024px, + * so on a phone the discard exists in the DOM and nowhere on the screen. A + * zero rect is not a position: subtracting it would turn the delta into an + * absolute viewport coordinate and the card would fly in from the corner. + * • the delta is already zero — a fall of no distance is a spin in place, + * which reads as a glitch rather than as a card being thrown down. + */ +export function fallDelta(rect: CardBox, from: Point | null): { dx: number; dy: number } | null { + if (!from) return null; + if (rect.width === 0 && rect.height === 0) return null; + const dx = from.x - (rect.left + rect.width / 2); + const dy = from.y - (rect.top + rect.height / 2); + if (dx * dx + dy * dy < 1) return null; + return { dx, dy }; +} + +/** One swap event. A fresh object per swap so the effect below fires once. */ +interface SwapCue { + n: number; + character: Character; +} + /** Detect when a card transitions to revealed and trigger a flip animation. */ function useCardFlip(influence: ClientInfluence) { const prevRevealedRef = useRef(influence.revealed); const prevCharRef = useRef(influence.character); const [flipping, setFlipping] = useState(false); - const [swapMotion, setSwapMotion] = useState(false); // Which face to show during the first half of flip (before the midpoint swap) const [flipFront, setFlipFront] = useState<'back' | 'face'>('face'); + // The replacement beat. A COUNTED OBJECT, not a boolean with a timeout: the + // flight engine owns the duration now, so the only thing React has to carry + // is "a swap happened, here is which card arrived" — and it has to be a new + // identity every time so two swaps in a row are two flights. The old + // `setSwapMotion(true)` + 650ms `setTimeout` pair was a second clock keeping + // a CSS class alive; both are gone. + const [swapCue, setSwapCue] = useState(null); const flipTimeoutRef = useRef>(null); - const swapTimeoutRef = useRef>(null); useEffect(() => { const wasRevealed = prevRevealedRef.current; @@ -42,8 +186,6 @@ function useCardFlip(influence: ClientInfluence) { prevCharRef.current = influence.character; if (flipTimeoutRef.current) clearTimeout(flipTimeoutRef.current); - if (swapTimeoutRef.current) clearTimeout(swapTimeoutRef.current); - setSwapMotion(false); // Card just got revealed (hidden→revealed) — flip from face to revealed if (!wasRevealed && influence.revealed && influence.character) { @@ -60,17 +202,90 @@ function useCardFlip(influence: ClientInfluence) { // Known card changed while still hidden to opponents / visible to owner. // This happens after exchanges and challenge replacements. else if (prevChar && influence.character && prevChar !== influence.character && !influence.revealed) { - setSwapMotion(true); - swapTimeoutRef.current = setTimeout(() => setSwapMotion(false), 650); + const arrived = influence.character; + setSwapCue(prev => ({ n: (prev?.n ?? 0) + 1, character: arrived })); } return () => { if (flipTimeoutRef.current) clearTimeout(flipTimeoutRef.current); - if (swapTimeoutRef.current) clearTimeout(swapTimeoutRef.current); }; }, [influence.revealed, influence.character]); - return { flipping, flipFront, swapMotion }; + return { flipping, flipFront, swapCue }; +} + +/** Exactly as long as the `card-unpress` keyframes in globals.css run. */ +const CARD_UNPRESS_MS = 190; + +type PressPhase = 'idle' | 'pressed' | 'unpressing'; + +/** + * The press mechanic (GAME-FEEL-PLAN §2.4). Cards are the thing a player + * touches most and had no press state at all; buttons have had `active:scale-95` + * for months. + * + * The pose itself lives entirely in CSS (`.is-pressed` / `.is-unpressing`); + * this hook only decides *when*. It owns one real piece of logic: + * + * THE FLIP GUARD. `.card-face` composes one transform out of `--press-s` and + * `--card-lift`, but `animate-card-flip-reveal` writes the whole `transform` + * property from its own keyframes. Two authors, one property: mid-flip the + * press would either be invisible (the flip wins on animation order) or would + * fight it. So `enabled` is false while a card is flipping, and a press already + * in progress when a flip starts is dropped rather than left stuck at .955 — a + * card cannot be both turning over and being held down. + * + * THE SWAP NO LONGER NEEDS THE GUARD. It used to: `animate-card-swap-in` was a + * third author on this same element's `transform`. It is a flight now, and a + * flight lives on the WRAPPER — so a card arriving from the deck and a finger + * on that card are two elements' business and compose instead of fighting. + * Narrowing the guard is the point of moving it, not an oversight. + */ +function useCardPress(enabled: boolean) { + const [phase, setPhase] = useState('idle'); + + // Hold `is-unpressing` for exactly the length of the release keyframes, then + // drop back to idle so the class does not re-fire on the next render. + useEffect(() => { + if (phase !== 'unpressing') return; + const timer = setTimeout(() => setPhase('idle'), CARD_UNPRESS_MS); + return () => clearTimeout(timer); + }, [phase]); + + // The guard: a flip starting mid-press cancels the press outright. + useEffect(() => { + if (!enabled) setPhase('idle'); + }, [enabled]); + + const onPointerDown = useCallback((event: ReactPointerEvent) => { + // isPrimary keeps a second finger during a pinch from re-pressing the card. + if (!enabled || !event.isPrimary) return; + setPhase('pressed'); + }, [enabled]); + + // Release: only a real press releases into the overshoot. A pointerup that + // never had a matching pointerdown (drag ended here) must not animate. + const onPointerUp = useCallback(() => { + setPhase(prev => (prev === 'pressed' ? 'unpressing' : prev)); + }, []); + + // Cancelled (scroll took over the gesture) or dragged off: no overshoot, the + // press was never completed. + const onPointerCancel = useCallback(() => setPhase('idle'), []); + const onPointerLeave = useCallback(() => { + setPhase(prev => (prev === 'pressed' ? 'idle' : prev)); + }, []); + + const pressClass = phase === 'pressed' + ? 'is-pressed' + : phase === 'unpressing' + ? 'is-unpressing' + : ''; + + return { + pressClass, + pressHandlers: { onPointerDown, onPointerUp, onPointerCancel, onPointerLeave }, + }; } function CardFaceImage({ character, variant = 'focus', priority = false }: { character: Character; variant?: 'full' | 'focus'; priority?: boolean }) { @@ -101,22 +316,30 @@ function CardPreviewModal({ character, onClose }: { character: Character; onClos return () => window.removeEventListener('keydown', handler); }, [onClose]); + const palette = CHARACTER_PALETTE[character]; + return createPortal(
+ {/* A reference panel about one character, not table furniture — so §1.2 + allows the hue on its hairline. Quiet: 1px at 40%, not a 2px + Tailwind default. */}
e.stopPropagation()} > -
+
-

{character}

-

+

{character}

+

{CHARACTER_DESCRIPTIONS[character]}

- ))} + {order.map(i => { + const char = cards[i]; + return ( + /* Two elements, two transform authors — the same split CardFace + documents. The wrapper flies (`--fx/--fy/--tilt/--fs`); the + `.card-face` button keeps the press/hover pose it composes in + globals.css out of `--press-*` and `--card-lift`. Putting the + flight on the button would have overwritten that transform + outright and taken the hover lift with it. */ +
+ +
+ ); + })}
{latestEntry && ( -
+
- Latest - {LOG_EVENT_ICONS[latestEntry.eventType] ?? ''} + Latest + {(() => { + // Same glyph map as the log below. This ticker sits one line above + // that column, so an emoji here reads as a mismatch against it. + const LatestGlyph = LOG_EVENT_GLYPHS[latestEntry.eventType]; + return LatestGlyph ? : null; + })()} {formatLogMessage(latestEntry.message, myName)}
diff --git a/src/app/components/game/GameOverOverlay.tsx b/src/app/components/game/GameOverOverlay.tsx index c815882..fd41c31 100644 --- a/src/app/components/game/GameOverOverlay.tsx +++ b/src/app/components/game/GameOverOverlay.tsx @@ -6,6 +6,8 @@ import { useGameStore } from '../../stores/gameStore'; import { computeAwards, computeBluffSummary, computeGameRecap, getWinnerFlavorText, getLoserFlavorText } from '../../utils/gameStats'; import type { RecapTone } from '../../utils/gameStats'; import { formatLogMessage } from '../../utils/logFormat'; +import { AWARD_GLYPHS } from '../../utils/logGlyphs'; +import { CrownGlyph, SkullGlyph } from '../icons'; import { haptic } from '../../utils/haptic'; import { useStatsStore } from '../../stores/statsStore'; import { useSettingsStore } from '../../stores/settingsStore'; @@ -23,18 +25,18 @@ function ResultCard({ influence, revealed = true }: { influence: ClientInfluence function recapToneClass(tone: RecapTone): string { switch (tone) { case 'gold': - return 'border-coup-accent/35 bg-coup-accent/10'; + return 'bg-coup-accent/15'; case 'green': - return 'border-green-500/25 bg-green-500/10'; + return 'bg-green-500/15'; case 'red': - return 'border-red-500/25 bg-red-500/10'; + return 'bg-red-500/15'; case 'blue': - return 'border-blue-500/25 bg-blue-500/10'; + return 'bg-blue-500/15'; case 'purple': - return 'border-purple-500/25 bg-purple-500/10'; + return 'bg-purple-500/15'; case 'gray': default: - return 'border-gray-800 bg-coup-bg/60'; + return 'bg-coup-bg/60'; } } @@ -159,17 +161,20 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP return (
-
+
{/* Header */}
-
{isMe ? '🏆' : '💀'}
-

+
+ {isMe ? : } +
+ {/* ART-DIRECTION.md §4: the game-over title is the largest Display use in the app. */} +

{isMe ? 'You Win!' : `${winner?.name} Wins!`}

{isMe ? winnerFlavor : loserFlavor}

-

+

{gameState.turnNumber} turns

@@ -177,10 +182,10 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP {/* Winning hand */} {winner && (
-

Winning Hand

-
+

Winning Hand

+
- 👑 +

{winner.id === gameState.myId ? 'You' : winner.name} @@ -201,9 +206,9 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP {/* Table truth */}

-

Table Truth

+

Table Truth

{!showFullTruth && nonWinnerPlayers.length > 0 ? ( -
+

Revealing the rest of the table...

) : ( -
+
{nonWinnerPlayers.map(p => { const wins = roomPlayers.find(rp => rp.id === p.id)?.wins ?? 0; return ( @@ -223,13 +228,13 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP className="flex items-center px-3 py-2.5 gap-3 animate-fade-in" > {/* Place indicator */} - - {!p.isAlive ? '💀' : ''} + + {!p.isAlive && } {/* Name + win count */} {p.id === gameState.myId ? 'You' : p.name} @@ -255,14 +260,14 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP {/* Final recap */} {showFullTruth && recap.length > 0 && (
-

Final Recap

+

Final Recap

{recap.map(item => (
-

{item.label}

+

{item.label}

{item.value}

{formatLogMessage(item.detail, myName)}

@@ -274,17 +279,20 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP {/* Awards */} {showFullTruth && awards.length > 0 && (
-

Awards

-
- {awards.map((award, i) => ( +

Awards

+
+ {awards.map((award, i) => { + const AwardGlyph = AWARD_GLYPHS[award.glyph]; + return (
- {award.emoji} +

{award.title}

-

{award.playerName} · {award.description}

+

{award.playerName} · {award.description}

- ))} + ); + })}
)} @@ -292,15 +300,15 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP {/* Truth Reveal */} {showFullTruth && totalBluffs > 0 && (
-

Truth Reveal

-
+

Truth Reveal

+
{bluffSummary.map(entry => { const bluffRate = entry.totalClaims > 0 ? Math.round((entry.bluffs / entry.totalClaims) * 100) : 0; return (

{entry.playerName}

-

+

{entry.totalClaims} claim{entry.totalClaims !== 1 ? 's' : ''} {entry.bluffs > 0 && ( · {entry.bluffs} bluff{entry.bluffs !== 1 ? 's' : ''} @@ -342,20 +350,20 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP

-

+

{exportStatus === 'copied' && 'Recap copied'} {exportStatus === 'error' && 'Copy unavailable - download the log instead'}

@@ -366,7 +374,7 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP {showLog ? 'Hide Log' : 'Show Full Log'} {showLog && ( -
+
p.id === gameState.myId)?.name ?? ''} @@ -392,7 +400,7 @@ export function GameOverOverlay({ gameState, isHost, onRematch, isSpectator, isP Play Again ) : ( -

+

Waiting for host to start rematch...

)} diff --git a/src/app/components/game/GameTable.tsx b/src/app/components/game/GameTable.tsx index a5356e1..785f67e 100644 --- a/src/app/components/game/GameTable.tsx +++ b/src/app/components/game/GameTable.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { ChatMessage, ClientGameState, GameMode, TurnPhase } from '@/shared/types'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActionType, ChallengeRevealEvent, Character, ChatMessage, ClientGameState, ClientInfluence, ClientPlayerState, GameMode, TargetingPublication, TurnPhase } from '@/shared/types'; import { PlayerSeat } from './PlayerSeat'; -import { CardFace } from './CardFace'; +import { CardFace, FLIGHT_VARS_RESET, type EnterFrom } from './CardFace'; import { CoinChangeBurst } from './CoinChangeBurst'; -import { CoinIcon } from '../icons'; +import { AssassinateGlyph, CoinIcon, CoupGlyph, SpeakerGlyph, SpeakerMutedGlyph, StealGlyph } from '../icons'; import { ActionBar } from './ActionBar'; import { ChallengePrompt } from './ChallengePrompt'; import { BlockPrompt } from './BlockPrompt'; @@ -25,9 +25,665 @@ import { ReactionPicker } from './ReactionPicker'; import { SettingsModal } from '../settings/SettingsModal'; import { PracticeCoach } from './PracticeCoach'; import { useSoundEffects } from '../../hooks/useSoundEffects'; +import { useHapticFeedback } from '../../hooks/useHapticFeedback'; +import { + fxSeatPoint, + registerFxBanner, + registerFxHand, + registerFxSeat, + useFxCues, + type Point, +} from '../../hooks/useFxCues'; +import { + FLIGHT_TRANSFORM_STYLE, + MAX_EVENT_MS, + assassinate, + blockCut, + cancel as cancelFlight, + challengeShove, + coupSlam, + fly, + hash1, + hashKey, + punch, + steal, + useIsomorphicLayoutEffect, + type ShoveContext, +} from '../../anim'; +import fx from '../../fx'; +import { getSoundEngine } from '../../audio/SoundEngine'; import { useGameStore } from '../../stores/gameStore'; +import { characterCardVars } from '../../utils/characterPalette'; import { haptic } from '../../utils/haptic'; +/* ====================================================================== + THE TABLE — GAME-FEEL-PLAN.md §1.4, ART-DIRECTION.md §3.2. + + One DOM tree serves both layouts, and that is a constraint rather than a + preference: the seats register themselves with the FX position registry by + player id (`registerFxSeat`), so a second, hidden copy of the seats for a + different breakpoint would fight the first for the registry entry and one + of the two would win by mount order. So there is exactly one seat element + per player and the LAYOUT changes underneath it, in globals.css, at 1024px. + + Below 1024px: `.felt-rail` / `.felt-top` are `display: contents`, the seats + fall through as direct grid items of `.table-felt`, and the phone column is + the 2-or-3-column grid it has always been. + + At 1024px and up: the same three wrappers become the left rail, the top rail + and the right rail of a felt with the deck, the treasury and the discard in + the middle of it, and the log moves to a side rail. + ====================================================================== */ + +/** + * How many seats go on each side of the felt, for every opponent count the + * game can produce — 1 to 5 for a player, and up to 6 for a spectator, who has + * no seat of their own and therefore sees every player as an "opponent". + * + * The shape is a ring read clockwise starting at your left hand, so turn order + * runs around the table the way it would if you were sitting at one: up the + * left rail, across the top, down the right rail. Two seats face each other + * rather than huddling at the top; five spread 2/1/2 so the middle of the felt + * stays wide enough for the deck, the treasury and the discard. + */ +export function ringSplit(n: number): { left: number; top: number; right: number } { + if (n <= 1) return { left: 0, top: Math.max(n, 0), right: 0 }; + if (n === 2) return { left: 1, top: 0, right: 1 }; + if (n === 3) return { left: 1, top: 1, right: 1 }; + if (n === 4) return { left: 1, top: 2, right: 1 }; + if (n === 5) return { left: 2, top: 1, right: 2 }; + const side = Math.floor((n - 2) / 2); + return { left: side, top: n - 2 * side, right: side }; +} + +/* ══════════════════════════════════════════════════════════════════════════ + §6's VERB TABLE, AT THE MOMENTS IT DESCRIBES + ========================================================================== + + `anim/verbs.ts` holds the four numbers per verb. It cannot hold the two + things that make a verb happen — WHEN, and to WHAT — because it is pure and + knows nothing about a game. This is the other half. + + WHEN comes from a state diff, the same shape as `fxDiffCues` and for the + same reason: the server is authoritative, so the only honest signal that a + Coup happened is that a Coup appeared in the state. Driving these off the + local player's click would animate one seat's actions and leave the bots' + silent. + + WHAT is decided per verb, and it is the judgement §6's table does not make: + + coup / assassinate / steal an ACTION CARD, thrown across the felt. + There is no card object for a declared action anywhere in this app — + the action exists as a line of log text and a phase banner — so these + three verbs would have had nothing to move. `ActionCardFlight` below + is that object: one transient plate, carrying the same glyph the + action log uses for the same verb, launched between two real seats. + + block / challenge a SEAT, recoiling. + §6's Refuse row is "the loser's card is shoved back". The loser's + cards are in their seat and there are two of them; shoving one and not + the other would read as a card slipping rather than as a person taking + a hit, so the whole plate takes it. The displacement is still a + fraction of a CARD's width, not of the seat's — 22% of a 15rem seat is + a seat sliding across the table, which is a different gesture from the + one the row describes. + + EVERY ONE OF THEM CAN DECLINE. `fxSeatPoint` returns null for a seat that is + not on screen, and a null is never coerced to (0, 0) — see its header, and + flip.ts's guard. No seat, no flight; the log line and the FX cue still land, + which is the information. Below 1024px the deck and the discard do not exist + at all (`.felt-centre` is `display: none`), but SEATS do, so the three + action-card verbs read on a phone exactly as they do on a desktop. Only the + Fall verb is desktop-only, because only the Fall verb needs the discard. + ══════════════════════════════════════════════════════════════════════════ */ + +export type TableMotionKind = 'coup' | 'assassinate' | 'steal' | 'block' | 'challenge'; + +/** + * One gesture, as two seats and a direction. `fromId` is where the gesture + * originates and `toId` is what it is aimed at — for a steal that is the + * VICTIM to the THIEF, because the thing that moves is what was taken. + */ +export interface TableMotion { + readonly kind: TableMotionKind; + readonly fromId: string; + readonly toId: string; + /** Stable identity, so one event produces exactly one gesture. */ + readonly key: string; +} + +/** Everything the diff below needs, and nothing else. */ +export interface TableMotionSnap { + readonly present: boolean; + readonly pendingKey: string | null; + readonly pendingType: ActionType | null; + readonly actorId: string | null; + readonly targetId: string | null; + readonly blockKey: string | null; + readonly blockerId: string | null; + readonly coins: ReadonlyMap; + readonly idByName: ReadonlyMap; + readonly reveal: ChallengeRevealEvent | null; +} + +const EMPTY_MOTION_SNAP: TableMotionSnap = { + present: false, + pendingKey: null, + pendingType: null, + actorId: null, + targetId: null, + blockKey: null, + blockerId: null, + coins: new Map(), + idByName: new Map(), + reveal: null, +}; + +export function tableMotionSnapshot( + gs: ClientGameState | null, + reveal: ChallengeRevealEvent | null, +): TableMotionSnap { + if (!gs) return { ...EMPTY_MOTION_SNAP, reveal }; + + const pa = gs.pendingAction; + const pb = gs.pendingBlock; + const coins = new Map(); + const idByName = new Map(); + for (const p of gs.players) { + coins.set(p.id, p.coins); + idByName.set(p.name, p.id); + } + + return { + present: true, + // Same identity as `fxSnapshot`'s: a re-broadcast of the same pending + // action is not a second declaration. + pendingKey: pa ? `${gs.turnNumber}|${pa.type}|${pa.actorId}|${pa.targetId ?? ''}` : null, + pendingType: pa?.type ?? null, + actorId: pa?.actorId ?? null, + targetId: pa?.targetId ?? null, + blockKey: pb ? `${gs.turnNumber}|${pb.blockerId}|${pa?.type ?? ''}` : null, + blockerId: pb?.blockerId ?? null, + coins, + idByName, + reveal, + }; +} + +export interface TableMotionDriver { + push(gs: ClientGameState | null, reveal: ChallengeRevealEvent | null): TableMotion[]; +} + +/** + * The diff, and the one piece of memory it needs. + * + * A COUP AND AN ASSASSINATE FIRE ON DECLARATION; A STEAL FIRES ON RESOLUTION, + * and the asymmetry is the game's rather than this file's. A Coup can be + * neither challenged nor blocked, so its declaration IS its landing — the same + * reasoning `fxDiffCues` uses to raise `coup_landed` there. An Assassinate is + * §6's lunge precisely because it may yet be stopped: the knife is shown at + * declaration and never arrives. A Steal, though, is the one action whose + * whole gesture is a transfer that may not happen, so animating coins leaving + * a seat that keeps them would be a lie. It is watched instead: remember the + * victim's balance when the Steal is declared, and fly only if the balance has + * actually fallen by the time the action leaves the table. + * + * A CHALLENGE FIRES WHEN THE PLATE COMES DOWN. `ChallengeRevealOverlay` owns + * the challenge for the 1.5–2.4s it is up, behind a `bg-black/70` scrim — a + * seat shoved under that is a seat nobody can see move. The shove is the first + * thing the table does when the plate lifts, which puts it immediately before + * the influence loss it caused. + * + * The first push is skipped, exactly as `createFxCueDriver` skips it: a player + * rejoining mid-game receives a state that already contains a pending Coup, + * and re-throwing it three turns late is worse than not throwing it. + */ +export function createTableMotionDriver(): TableMotionDriver { + let prev: TableMotionSnap | null = null; + let stealWatch: { key: string; actorId: string; targetId: string; coins: number } | null = null; + + return { + push(gs, reveal): TableMotion[] { + const curr = tableMotionSnapshot(gs, reveal); + const before = prev; + prev = curr; + const out: TableMotion[] = []; + if (before === null) return out; + + /* A Steal that was on the table is no longer on it. Checked BEFORE a new + declaration is armed below, so the two can never share a turn and lose + one of themselves. */ + if (stealWatch && curr.pendingKey !== stealWatch.key) { + const now = curr.coins.get(stealWatch.targetId); + const took = stealWatch; + stealWatch = null; + if (now !== undefined && now < took.coins) { + out.push({ + kind: 'steal', + fromId: took.targetId, + toId: took.actorId, + key: `${took.key}|took`, + }); + } + } + + if (curr.present && curr.pendingKey && curr.pendingKey !== before.pendingKey && curr.actorId) { + if (curr.pendingType === ActionType.Coup && curr.targetId) { + out.push({ kind: 'coup', fromId: curr.actorId, toId: curr.targetId, key: curr.pendingKey }); + } else if (curr.pendingType === ActionType.Assassinate && curr.targetId) { + out.push({ kind: 'assassinate', fromId: curr.actorId, toId: curr.targetId, key: curr.pendingKey }); + } else if (curr.pendingType === ActionType.Steal && curr.targetId) { + stealWatch = { + key: curr.pendingKey, + actorId: curr.actorId, + targetId: curr.targetId, + coins: curr.coins.get(curr.targetId) ?? 0, + }; + } + } + + // A block landing: the blocker shoves the actor whose action they stopped. + if (curr.blockKey && curr.blockKey !== before.blockKey && curr.blockerId && curr.actorId) { + out.push({ kind: 'block', fromId: curr.blockerId, toId: curr.actorId, key: curr.blockKey }); + } + + /* The reveal plate has come down. `ChallengeRevealEvent` carries NAMES, + not ids — safe for the same reason `fxDiffCues` and useHapticFeedback + rely on it: RoomManager rejects a join whose name matches a seated + player case-insensitively, so a name is a key. */ + if (before.reveal && !curr.reveal) { + const r = before.reveal; + // wasGenuine = the challenged player held the card, so the CHALLENGER lost. + const loserId = curr.idByName.get(r.wasGenuine ? r.challengerName : r.challengedName); + const winnerId = curr.idByName.get(r.wasGenuine ? r.challengedName : r.challengerName); + if (loserId && winnerId && loserId !== winnerId) { + out.push({ + kind: 'challenge', + fromId: winnerId, + toId: loserId, + key: `challenge|${r.challengerName}|${r.challengedName}|${r.character}`, + }); + } + } + + return out; + }, + }; +} + +/* ── the action card ────────────────────────────────────────────────────── + Its footprint is fixed in px rather than taken from a size class, because + the element is `position: fixed` and centred on a measured point: the + negative margins that centre it have to be numbers this file knows, and a + class whose width changes at a breakpoint would put the card half a card off + its seat on one side of 1024px. */ +const ACTION_CARD_W = 60; +const ACTION_CARD_H = 84; + +/** + * How long past flight.ts's own commitment the card will wait before removing + * itself regardless. `anim/clock` is a bare rAF loop, so a tab backgrounded + * mid-throw stops it dead; `ensureHiddenTabSettle()` now lands those flights, + * and this is the second belt for everything else — the same ceiling, and the + * same reasoning, as `PHASE_CEILING_MS` in ChallengeRevealOverlay. + */ +const ACTION_FLIGHT_CEILING_MS = MAX_EVENT_MS + 400; + +interface ActionFlightState { + /** Fresh per gesture, so two Coups in a row are two flights. */ + readonly id: number; + readonly kind: 'coup' | 'assassinate' | 'steal'; + /** Where the card RESTS: the seat the gesture ends at. */ + readonly anchor: Point; + /** Where it launches from, or lunges at. See below — one vector serves both. */ + readonly other: Point; + readonly key: string; +} + +/** + * The two ends of a thrown gesture, or null when the table cannot supply them. + * + * An Assassinate lunges FROM the actor; a Coup and a Steal land ON their + * destination. Picking the anchor per verb is what lets one element serve both + * `punch` and `fly` from a single `other − anchor` vector. + * + * NULL IS A REAL ANSWER AND THE ONLY SAFE ONE. `at` is `fxSeatPoint`, which + * returns null for a seat that is not on screen — a spectator's own seat, a + * seat that unmounted, a box measuring zero under a `display: none` ancestor. + * Substituting (0, 0) for any of those throws the card from the corner of the + * viewport, which reads as a layout bug rather than as a Coup (flip.ts's + * guard). There is no local fallback gesture for a throw, because a throw with + * no destination is not a throw; the log line and the FX cue still land. + */ +export function actionFlightPoints( + kind: ActionFlightState['kind'], + fromId: string, + toId: string, + at: (id: string) => Point | null, +): { anchor: Point; other: Point } | null { + const anchor = at(kind === 'assassinate' ? fromId : toId); + const other = at(kind === 'assassinate' ? toId : fromId); + if (!anchor || !other) return null; + return { anchor, other }; +} + +/** Enough of a card to be recognised at 60px, and nothing more. */ +function ActionCardBody({ kind }: { kind: ActionFlightState['kind'] }) { + if (kind === 'coup') { + /* A Coup is not a claim — nobody holds a card called Coup — so §1.2 gives + it no character hue, and the reveal overlay's treatment for exactly that + case is the hazard material. Same statement in both places. */ + return ( +
+ + COUP +
+ ); + } + + const character = kind === 'steal' ? Character.Captain : Character.Assassin; + const Mark = kind === 'steal' ? StealGlyph : AssassinateGlyph; + return ( +
+ + + {kind === 'steal' ? 'STEAL' : 'STRIKE'} + +
+ ); +} + +/** + * One thrown action card. Three of §6's verbs, one element, and the sign + * convention is the reason they can share it: + * + * `fly()` takes the FLIP INVERT — where the card IS minus where it BELONGS. + * `punch()` takes the PEAK DISPLACEMENT — the direction it lunges. + * + * Both are `other − anchor` here, because the anchor is chosen per verb to + * make them so: a Coup rests on its TARGET and comes from the actor; a Steal + * rests on the THIEF and comes from the victim; an Assassinate rests on the + * ACTOR and lunges at the target. verbs.ts spells the two conventions + * differently on purpose (`dx/dy` against `toX/toY`) and that is honoured at + * the call below rather than papered over. + */ +function ActionCardFlight({ + flight, + onDone, +}: { + flight: ActionFlightState; + onDone: (id: number) => void; +}) { + const nodeRef = useRef(null); + + useIsomorphicLayoutEffect(() => { + const el = nodeRef.current; + if (!el) { + onDone(flight.id); + return; + } + + const vx = flight.other.x - flight.anchor.x; + const vy = flight.other.y - flight.anchor.y; + let settled = false; + /* The cue hangs off `land` and ONLY off `land`. An aborted throw — a + superseded gesture, an unmount, the ceiling below — removes the card + silently, because nobody saw it arrive. flight.ts guarantees exactly one + of the two fires. */ + const done = (cue: boolean) => { + if (settled) return; + settled = true; + if (cue) getSoundEngine().play('cardShuffle'); + onDone(flight.id); + }; + + const started = flight.kind === 'assassinate' + ? (() => { + const o = assassinate({ toX: vx, toY: vy, key: flight.key }); + return punch(el, o.dx ?? 0, o.dy ?? 0, { + ...o, + // A lunge does not arrive, so its resolution is silent: the knife + // is shown, and `assassinationAlert` has already said so. + land: () => done(false), + abort: () => done(false), + }); + })() + : (() => { + const ctx = { dx: vx, dy: vy, key: flight.key }; + const o = flight.kind === 'coup' ? coupSlam(ctx) : steal(ctx); + return fly(el, { ...o, land: () => done(true), abort: () => done(false) }); + })(); + + if (!started) done(false); + + const ceiling = setTimeout(() => { + cancelFlight(el); + done(false); + }, ACTION_FLIGHT_CEILING_MS); + + return () => { + clearTimeout(ceiling); + /* SETTLE BEFORE CANCELLING, and this line is not defensive padding. + `cancelFlight` fires the flight's `abort`, which is wired to `done` — + so without it a teardown asks the PARENT to remove a card, from inside + the teardown of that very card. React 19's StrictMode runs every effect + mount→cleanup→mount in development, which turned that into: throw the + card, tear it down, tell the parent to drop it, and the second mount + renders into a component already scheduled for removal. Found in a + browser — the Coup, the Steal and the Assassinate each flashed for one + commit and vanished, while the two verbs that move a PERSISTENT element + (a seat, a discard card) animated correctly the whole time. */ + settled = true; + cancelFlight(el); + }; + }, [flight, onDone]); + + return ( + + ); +} + +/* ── the shove ────────────────────────────────────────────────────────────── */ + +/** + * A card's width inside this seat, for §6's −0.22. + * + * The row's fraction is of the shoved thing's OWN width and verbs.ts is + * explicit that it is a CARD's width — 15px on the desktop deck leaf. A seat + * is 15rem, and 22% of that is 53px of plate sliding sideways past its + * neighbours: the same number, the wrong noun. So the seat recoils by a + * fraction of the cards it is holding, which is 12px on a desktop seat and + * 10px on a phone. The fallback is `card-face-sm`'s own 44px, for the moment + * between a seat mounting and its cards measuring. + */ +const SHOVE_CARD_FALLBACK_W = 44; + +export function shoveWidthOf(seat: Element | null): number { + const card = seat?.querySelector('.card-face') ?? null; + const w = card ? card.getBoundingClientRect().width : 0; + return w > 0 ? w : SHOVE_CARD_FALLBACK_W; +} + +/** + * Which way a seat is pushed: directly away from whoever pushed it. + * + * `shove()` normalises, so handing it a raw seat-to-seat vector gives a shove + * of the stated SIZE in the stated DIRECTION rather than one scaled by how far + * apart the two seats happen to sit. With no winner on screen the verb's own + * default stands — straight back, leftwards — which is a recoil with no + * address on it rather than a recoil towards the origin of the viewport. + */ +export function shoveDirection(from: Point | null, to: Point | null): Pick { + if (!from || !to) return {}; + const dx = to.x - from.x; + const dy = to.y - from.y; + if (dx === 0 && dy === 0) return {}; + return { dirX: dx, dirY: dy }; +} + +/** + * The deck as an OBJECT — a pile whose height tracks `deckCount`, sunk into a + * recess in the felt. It was 8px of text in the header, which is the whole + * reason nothing could visibly come off it: §1.4's "flights need somewhere to + * fly from". + * + * The pile is capped at 12 visible leaves for 15 cards, so the stack reads as + * "thick" or "nearly out" rather than counting for you — the figure beside it + * does the counting, in the monospace Figures role (§4). + */ +function DeckPile({ count }: { count: number }) { + const leaves = Math.min(count, DECK_MAX_LEAVES); + + return ( +
+ + + Deck {count} + +
+ ); +} + +/** Coins are circles, and §3.9 says they may stay circles. Everything else on the table is a plate. */ +function CoinStack({ count, label, tone }: { count: number; label: string; tone: 'treasury' | 'reserve' }) { + const discs = count === 0 ? 0 : Math.max(1, Math.min(COIN_MAX_DISCS, Math.round(count / 5))); + + return ( +
+ + + {label} {count} + +
+ ); +} + +/** + * One card in the discard, with the two things §6's Fall verb needs that a + * bare `ClientInfluence` cannot carry: a STABLE IDENTITY and WHOSE it was. + */ +export interface DiscardEntry { + /** `:` — stable for the life of the game. */ + readonly id: string; + readonly ownerId: string; + readonly influence: ClientInfluence; + /** This card has only just arrived, and is the only one allowed to move. */ + readonly fresh: boolean; +} + +/** + * Every influence anyone has lost, in the order it fell, with an identity. + * + * INDEXES WERE NOT IDENTITIES, and that had to be fixed before anything could + * fly out of a seat. The pile is derived by walking `players` in seat order, + * so a loss by an early seat INSERTS into the middle of the list; keyed by + * position, every card after it would take on a different influence, a + * different tilt, and — worst — the newest card would be attributed to + * whichever player happened to end up last. + */ +export function discardEntries(players: readonly ClientPlayerState[], seen: ReadonlySet | null): DiscardEntry[] { + const out: DiscardEntry[] = []; + for (const p of players) { + p.influences.forEach((influence, i) => { + if (!influence.revealed || !influence.character) return; + const id = `${p.id}:${i}`; + // `seen === null` is the very first render: nothing is fresh, so a player + // who joins a game already three influences deep does not watch three + // retroactive cards tumble into the pile. Same skip as the FX driver's. + out.push({ id, ownerId: p.id, influence, fresh: seen !== null && !seen.has(id) }); + }); + } + return out; +} + +/** + * The resting tilt, off the card's identity rather than its position. + * + * The old comment on `DISCARD_TILTS` — "fixed, so a card in the discard does + * not jump when another lands beside it" — was the right intent read off the + * wrong key: a tilt indexed by POSITION changes for every card after an + * insertion, which is exactly the jump it was guarding against. Hashed, a + * card's tilt is decided once, by which card it is. + */ +export function discardTilt(id: string): number { + return DISCARD_TILTS[Math.floor(hash1(hashKey(id)) * DISCARD_TILTS.length) % DISCARD_TILTS.length]; +} + +/** Where a lost influence lands and STAYS (§1.4). Face-up, in the order it fell. */ +function DiscardPile({ entries }: { entries: DiscardEntry[] }) { + return ( +
+
+ {entries.length === 0 ? ( + Nothing lost yet + ) : ( + entries.map(entry => ( + /* The tilt is on a wrapper, never on the card: `.card-flip-wrapper` + owns `transform` for the flight engine (anim/flight.ts) and a + second author there would be overwritten mid-flight. */ + + fxSeatPoint(entry.ownerId)) satisfies EnterFrom : undefined} + /> + + )) + )} +
+ + Discard {entries.length} + +
+ ); +} + +/** Cap on the visible leaves of the deck pile. 15 cards, 12 leaves. */ +const DECK_MAX_LEAVES = 12; +/** Cap on the coin discs in a stack. One disc per ~5 coins. */ +const COIN_MAX_DISCS = 9; +/** The eight resting tilts a discarded card can take. See `discardTilt`. */ +const DISCARD_TILTS = [-4, 3, -2, 5, -5, 2, 4, -3]; + interface GameTableProps { gameState: ClientGameState; chatMessages: ChatMessage[]; @@ -43,14 +699,50 @@ interface GameTableProps { export function GameTable({ gameState, chatMessages, onSendChat, onSendReaction, isHost, onRematch, isSpectator, isPracticeRoom, onExitPractice, onStopSpectating }: GameTableProps) { useSoundEffects(); + useHapticFeedback(); + // Deliberately AFTER useHapticFeedback: that hook picks the single + // highest-priority pattern for an update and arms utils/haptic.ts's 300ms + // floor first, so the haptics fx/ fires from its own tuning rows collide at + // equal priority and are dropped. One buzz per beat, chosen by the layer that + // can see every candidate. See the header of useFxCues.ts. + useFxCues(); + const tableRef = useRef(null); + const challengeReveal = useGameStore(s => s.challengeReveal); + /** + * The seat ELEMENTS, beside the FX registry's seat POINTS. + * + * `useFxCues` deliberately hands out coordinates and never nodes — fx/ must + * not hold a React element. §6's Refuse verb has the opposite requirement: + * it does not want to know where the seat is, it wants to move it. Two maps + * filled from one ref callback is the honest version of that; a registry + * that returned nodes would let anything in the app animate anything. + */ + const seatEls = useRef(new Map()); const isMuted = useGameStore(s => s.isMuted); const setMuted = useGameStore(s => s.setMuted); const reconnecting = useGameStore(s => s.reconnecting); const spectators = useGameStore(s => s.spectators); + /* Target selection, published by the ActionBar (see its header and + `gameStore.targeting`). The seats are the other half of a pick that used + to happen entirely in a list of buttons while the table sat inert. */ + const targeting = useGameStore(s => s.targeting); const [showRules, setShowRules] = useState(false); const [showSettings, setShowSettings] = useState(false); const me = isSpectator ? undefined : gameState.players.find(p => p.id === gameState.myId); - const opponents = isSpectator ? gameState.players : gameState.players.filter(p => p.id !== gameState.myId); + /** + * Seating order: clockwise from your left, so the ring around the felt runs + * in turn order from where YOU sit. `players` is in seat order but always + * starts at seat 0, so it is rotated to start at the player after you — + * which is also the order the phone column falls into, and a better one than + * it had (the player who acts next is now the first seat you read). + */ + const opponents = useMemo(() => { + const players = gameState.players; + if (isSpectator) return players; + const myIndex = players.findIndex(p => p.id === gameState.myId); + if (myIndex < 0) return players.filter(p => p.id !== gameState.myId); + return [...players.slice(myIndex + 1), ...players.slice(0, myIndex)]; + }, [gameState.players, gameState.myId, isSpectator]); const currentPlayerId = gameState.players[gameState.currentPlayerIndex]?.id; const isMyActionTurn = !isSpectator && me?.isAlive @@ -67,6 +759,76 @@ export function GameTable({ gameState, chatMessages, onSendChat, onSendReaction, ? gameState.pendingAction.actorId : currentPlayerId); + /** + * Mount the FX layer. + * + * ROOT is `null` — i.e. `document.body`. The overlay is `position: fixed`, + * and fx/shake.ts's first hazard is that a transform on an ancestor makes a + * fixed descendant target-fixed: parked inside the shake target it would + * shake WITH the table, which is the one thing a full-viewport flash plate + * must never do. `document.body` is the only host guaranteed to stay outside + * the target's subtree no matter how this component is restructured later. + * + * SHAKE TARGET is `tableRef` — the opponents grid plus the log/chat centre, + * and nothing else. The phase banner, the prompts and your hand are siblings + * of it, so a challenge prompt stays readable at the exact moment you have to + * answer it. (ART-DIRECTION §6; fx/shake.ts.) + * + * `mount()` is pure registration: no canvas, no listener, no clock + * subscriber exists until the first cue fires. + */ + useEffect(() => { + fx.mount(null, tableRef.current); + return () => { + fx.unmount(); + }; + }, []); + + /* ── §6's verbs, fired ───────────────────────────────────────────────── + One driver, one slot. The three thrown verbs share a single + `actionFlight` slot because the game cannot produce two of them at once — + a turn has one action on the table — and a slot rather than a list means a + gesture that somehow arrived on top of another supersedes it through the + flight engine's own abort path instead of leaving two cards in the air. */ + const [actionFlight, setActionFlight] = useState(null); + const flightIdRef = useRef(0); + const motionDriver = useRef(null); + if (motionDriver.current === null) motionDriver.current = createTableMotionDriver(); + + const clearActionFlight = useCallback((id: number) => { + // Guarded on the id: a card removing itself must not remove its successor. + setActionFlight(prev => (prev && prev.id === id ? null : prev)); + }, []); + + useEffect(() => { + const motions = motionDriver.current?.push(gameState, challengeReveal) ?? []; + for (const m of motions) { + if (m.kind === 'block' || m.kind === 'challenge') { + const el = seatEls.current.get(m.toId); + // A seat that is not mounted cannot recoil, and there is nothing else + // sensible to shove in its place. + if (!el) continue; + const ctx: ShoveContext = { + width: shoveWidthOf(el), + key: m.toId, + ...shoveDirection(fxSeatPoint(m.fromId), fxSeatPoint(m.toId)), + }; + const o = m.kind === 'challenge' ? challengeShove(ctx) : blockCut(ctx); + // `punch`: dx/dy are the PEAK displacement and the seat ends exactly + // where it started. Nothing hangs off the landing — a block already + // has its own sound and a challenge has just had a whole plate. + punch(el, o.dx ?? 0, o.dy ?? 0, o); + continue; + } + + // Two real seats or no gesture — see `actionFlightPoints`. + const ends = actionFlightPoints(m.kind, m.fromId, m.toId, fxSeatPoint); + if (!ends) continue; + flightIdRef.current += 1; + setActionFlight({ id: flightIdRef.current, kind: m.kind, key: m.key, ...ends }); + } + }, [gameState, challengeReveal]); + useEffect(() => { const titleSuffix = `Coup ${gameState.roomCode}`; let title = titleSuffix; @@ -123,8 +885,95 @@ export function GameTable({ gameState, chatMessages, onSendChat, onSendReaction, }; }, [currentPlayerId, gameState, isSpectator, me]); + /* Which seats the current selection can and cannot take. `action` is null + whenever nothing is being aimed, which is most of the time. */ + const aiming: TargetingPublication | null = targeting && targeting.action !== null ? targeting : null; + const declaredTargetId = gameState.pendingAction?.targetId ?? null; + + const renderSeat = (p: ClientPlayerState) => { + const eligible = !!aiming?.eligibleIds.includes(p.id); + const illegalReason = aiming?.reasons[p.id]; + const inSelection = eligible || !!illegalReason; + /* The hazard material means "in the crosshairs", which covers both halves + of that: a seat you are about to pick, and the seat an action already on + the table is aimed at. Never a red ring — see PlayerSeat's `isTarget`. */ + const isTarget = eligible || p.id === declaredTargetId; + + return ( + /* Two registrations and one cleanup. The cleanup is what makes the seat + safe to fly: a `punch` in progress when the seat unmounts must abort + at detach time rather than whenever the next frame notices a detached + node — the same contract useFlight's ref gives a card. */ +
{ + registerFxSeat(p.id, node); + // React 19 skips the null call entirely once a cleanup is returned, + // so the null branch here is only the type's. + if (!node) return; + seatEls.current.set(p.id, node); + return () => { + registerFxSeat(p.id, null); + if (seatEls.current.get(p.id) === node) seatEls.current.delete(p.id); + cancelFlight(node); + }; + }} + > + + aiming.onSelect(p.id) : undefined} + timerExpiry={p.id === timerPlayerId ? gameState.timerExpiry : null} + /> +
+ ); + }; + + const ring = ringSplit(opponents.length); + const leftSeats = opponents.slice(0, ring.left); + const topSeats = opponents.slice(ring.left, ring.left + ring.top); + const rightSeats = opponents.slice(ring.left + ring.top); + + /* The discard: every influence anyone has lost, in the order it fell. There + is no discard array on the wire — a revealed influence stays in its + owner's hand — so the pile is derived. + + `seenDiscardRef` is what makes §6's Fall verb fire once per loss rather + than once per render: a card is FRESH exactly on the render it first + appears, and the effect below closes the door behind it. Null on the very + first render means "nothing is fresh", so a mid-game join does not replay + the whole pile. */ + const seenDiscardRef = useRef | null>(null); + const discarded = discardEntries(gameState.players, seenDiscardRef.current); + useEffect(() => { + // No dependency array on purpose: this closes the door after EVERY render, + // and it runs after the cards' own layout effects have already launched. + const seen = seenDiscardRef.current ?? new Set(); + seenDiscardRef.current = seen; + for (const entry of discarded) seen.add(entry.id); + }); + return ( -
+ /* `.table-root` carries the width, not `max-w-lg lg:max-w-xl`: the phone + column stays 32rem, and at 1024px the cap comes off entirely so the + desktop layout below can use the room (§3.2 — "not a widened phone"). */ +
{/* Header bar */}
Room: {gameState.roomCode} @@ -135,9 +984,11 @@ export function GameTable({ gameState, chatMessages, onSendChat, onSendReaction, )}
- Deck: {gameState.deckCount} + {/* On the desktop table these two are objects on the felt (the deck + pile and the treasury), so the header stops repeating them. */} + Deck: {gameState.deckCount} {gameState.gameMode === GameMode.Reformation && ( - Reserve: {gameState.treasuryReserve} + Reserve: {gameState.treasuryReserve} )}
)} - {/* Phase status banner */} -
+ {/* Phase status banner — the upper edge of the floater keep-out band, and + deliberately OUTSIDE the shake target. */} +
{ registerFxBanner(node); }}> )} - {/* Opponents */} -
- {opponents.map(p => ( -
- - + {/* + THE TABLE — and the only thing the shake is allowed to move. + + This wrapper did not exist before: the opponents grid and the centre + column were siblings, and the prompts lived INSIDE the centre column. + Shaking either of those would have shaken the action bar and the + challenge prompt with them. The prompts and the hand are siblings of + this ref, so the subtree under it is exactly "the felt and the log". + + THAT IS THE INVARIANT, and it survived the desktop layout: the phase + banner above, the prompt stack and your hand below are all outside, so + a challenge prompt stays still at the exact moment you have to answer + it. Measured after the rebuild by transforming this element and reading + all four rects: only the felt moves. (fx/shake.ts; ART-DIRECTION §6.) + + Its own box lives in globals.css (`.table-shake`) rather than in + utilities here, because a Tailwind utility outranks a components-layer + rule in every media query — `flex` on this element silently beat the + desktop `display: grid` until it moved. + */} +
+ {/* The felt. On a phone this is the seat grid and nothing else; at + 1024px the three rails become the sides of a table with the deck, + treasury and discard sunk into the middle of it. See globals.css + "THE TABLE" and `ringSplit()` above. */} +
+
{leftSeats.map(renderSeat)}
+
{topSeats.map(renderSeat)}
+
{rightSeats.map(renderSeat)}
+ +
+ + + + {gameState.gameMode === GameMode.Reformation && ( + + )}
- ))} -
+
- {/* Center: Log + Interactive area */} -
+ {/* Log / chat: under the felt on a phone, a rail beside it on desktop. */} - - {/* Interactive prompts - only one shows at a time (hidden for spectators) */} - {!isSpectator && ( -
- - - - - - - - - -
- )}
- {/* My hand - pinned to bottom */} + {/* The bottom band: the prompts and your hand. A SIBLING of the table, + never a descendant — the shake target is `tableRef` above, and a + challenge prompt that shakes while you are reading it to decide + whether to challenge is a prompt you cannot answer (fx/shake.ts). + `.table-bottom` only caps its width on desktop, so the hand sits + under the middle of the felt instead of stretching to 1456px. */} +
+ {/* Interactive prompts - only one shows at a time (hidden for spectators). */} + {!isSpectator && ( +
+ + + + + + + + + +
+ )} + + {/* My hand — pinned to bottom, and your own seat for every purpose the + opponents' seats serve: the FX registry, and §6's Refuse verb, which + shoves this plate when the challenge or the block landed on you. */} {me && ( -
+
{ + registerFxHand(me.id, node); + if (!node) return; + seatEls.current.set(me.id, node); + return () => { + registerFxHand(me.id, null); + if (seatEls.current.get(me.id) === node) seatEls.current.delete(me.id); + cancelFlight(node); + }; + }} + > -
)} - + {me.coins}
-
+ {/* `hand-cards`: globals.css steps your own cards from md (56x80) up + to the lg footprint (80x112) at >=1024px. The label-plate scale + steps with them, so the printed strip keeps its proportion + instead of shrinking into a card that grew around it. */} + {/* The inheritance stop, for the same reason PlayerSeat carries one: + this whole plate is a flight element now, and `--fx` inherits. */} +
{me.influences.map((inf, i) => ( ))} @@ -290,6 +1195,7 @@ export function GameTable({ gameState, chatMessages, onSendChat, onSendReaction,
)} +
+ {/* §6's Strike and Take-from rows. `position: fixed` and outside the + shake target: the card is thrown ACROSS a table that is shaking, and + a card carried by the shake is a card that never left the seat. */} + {actionFlight && } setShowRules(false)} /> setShowSettings(false)} />
diff --git a/src/app/components/game/InfluenceLossPrompt.tsx b/src/app/components/game/InfluenceLossPrompt.tsx index 278a4d8..0c40a25 100644 --- a/src/app/components/game/InfluenceLossPrompt.tsx +++ b/src/app/components/game/InfluenceLossPrompt.tsx @@ -40,7 +40,7 @@ export function InfluenceLossPrompt({ gameState }: InfluenceLossPromptProps) {

{loser?.name} must choose an influence to lose.

-

+

{spectatorReason}

diff --git a/src/app/components/game/PlayerSeat.tsx b/src/app/components/game/PlayerSeat.tsx index b39f793..3e51474 100644 --- a/src/app/components/game/PlayerSeat.tsx +++ b/src/app/components/game/PlayerSeat.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { ClientPlayerState, Faction } from '@/shared/types'; -import { CardFace } from './CardFace'; +import { CardFace, FLIGHT_VARS_RESET } from './CardFace'; import { CoinIcon } from '../icons'; import { CoinChangeBurst } from './CoinChangeBurst'; import { useGameStore } from '../../stores/gameStore'; @@ -11,9 +11,24 @@ interface PlayerSeatProps { player: ClientPlayerState; isCurrentTurn: boolean; isMe: boolean; + /** + * This seat is in the crosshairs: a legal target of the action being aimed + * right now, or the declared target of the action on the table. + * + * It draws ART-DIRECTION §1.2's HAZARD MATERIAL, not a red ring, and the + * distinction is not stylistic. `ring-2 ring-red-500` used to live here, and + * `#ef4444` is the exact hex §1.1 row 1 records as the old Contessa border: + * "you are being targeted" and "she holds a Contessa" were the same pixel + * value. The card frames moved to a rose band to open that gap; putting a + * red ring back on the seat would close it again from the other side. + */ isTarget?: boolean; + /** Tap handler. Present whenever the seat takes part in a selection — INCLUDING an illegal one, which must refuse out loud rather than do nothing. */ onSelect?: () => void; + /** A legal choice: brass hover ring (§1.2's selection material) and a pointer. */ selectable?: boolean; + /** Why this seat cannot be chosen. §6.2's "illegal half": marked, not omitted. */ + illegalReason?: string; timerExpiry?: number | null; } @@ -60,6 +75,7 @@ export function PlayerSeat({ isTarget, onSelect, selectable, + illegalReason, timerExpiry, }: PlayerSeatProps) { const mutedPlayerIds = useGameStore(s => s.mutedPlayerIds); @@ -76,20 +92,45 @@ export function PlayerSeat({ ? 'bg-red-500/[0.07]' : ''; + /* A seat in a selection is a control, so it gets a control's affordances — + including the illegal ones. A seat that cannot be chosen still answers the + tap (the ActionBar's `refuse()` fires through `onSelect`), so it must stay + reachable by keyboard too; `aria-disabled` says "refused", where + `disabled` would say "not here", and §3.10 is explicit that refusal is + out loud, not a control quietly going away. */ + const interactive = !!onSelect; + const activate = () => { if (onSelect) onSelect(); }; + return (
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + activate(); + } + } + : undefined} >
- + {/* ART-DIRECTION.md §4: player names are a Display role. */} + {player.name} {isMe && ' (You)'} @@ -131,7 +172,7 @@ export function PlayerSeat({ )} - + {player.coins} @@ -139,12 +180,35 @@ export function PlayerSeat({
-
+ {/* `seat-cards` is the hook the desktop table sizes through: globals.css + steps these from `card-face-sm` (44x64, where the portrait is + illegible) up to the md footprint at >=1024px without CardFace + needing to know what a breakpoint is. The class is also what makes + the cards inert during a selection — a revealed opponent card is + click-to-preview, and a preview opening on top of a target pick is + two answers to one tap. */} + {/* `FLIGHT_VARS_RESET` is the inheritance stop, not decoration. The whole + seat is a flight element — §6's Refuse verb shoves it — and + `--fx/--fy/--tilt/--fs` inherit, so without a zero declared here every + card in the seat would read the seat's displacement as its own and + travel twice as far as the plate it is printed on. See the header of + CardFace.tsx. */} +
{player.influences.map((inf, i) => ( ))}
+ {/* §6.2: the illegal half is the half that answers "why can't I click + there". A sentence on the seat, not a tooltip — a tooltip does not + exist on touch. */} + {illegalReason && ( +

{illegalReason}

+ )} + {timerExpiry && ( )} diff --git a/src/app/components/game/PracticeCoach.tsx b/src/app/components/game/PracticeCoach.tsx index f5809d5..f34f8e1 100644 --- a/src/app/components/game/PracticeCoach.tsx +++ b/src/app/components/game/PracticeCoach.tsx @@ -13,10 +13,10 @@ interface PracticeCoachProps { const HIDDEN_KEY = 'coup_practice_coach_hidden'; const TONE_CLASSES: Record = { - gold: 'border-coup-accent/50 bg-amber-950/75 text-coup-accent', - blue: 'border-blue-500/45 bg-blue-950/75 text-blue-300', - red: 'border-red-500/50 bg-red-950/80 text-red-300', - green: 'border-emerald-500/45 bg-emerald-950/75 text-emerald-300', + gold: 'bg-amber-950/85 text-coup-accent', + blue: 'bg-blue-950/85 text-blue-300', + red: 'bg-red-950/90 text-red-300', + green: 'bg-emerald-950/85 text-emerald-300', }; export function PracticeCoach({ gameState, onOpenRules }: PracticeCoachProps) { @@ -45,7 +45,7 @@ export function PracticeCoach({ gameState, onOpenRules }: PracticeCoachProps) { return (