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. 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/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 (