⚽ Play MECH CUP 2026How to play · 玩法指南
🌐 This changelog is English only. 本更新日志仅提供英文 — 玩法指南 (中文).
Every pull request that shaped the game, newest work first. The project began on 2026-06-12 as The Electric Dream Cup and was rebranded to MECH CUP 2026 (the first space robot football final, on the Moon). It ships as one self-contained index.html and deploys to mechcup.com (Cloudflare Pages).
Every PR went through the same pipeline before going live: build (minify) → headless Chrome smoke-test gate → Cloudflare Pages deploy → verify (/version.json == commit). The smoke suite grew from a handful of checks to 300+ assertions + a build gate, one (or more) added per feature/fix so bugs can't silently return.
Each entry: #PR · merge date · merge-sha — what changed.
MECH CUP · mechcup.com centre-spot lockup on the turf (like a competition logo, reads for the broadcast camera). Like real stadium LEDs the boards + footer cycle through BRAND.texts (['Zuqiu.com','mechcup.com']) every BRAND.rotSec (6s), in sync; reduced-motion holds the primary message. Palette "Broadcast Gold" (owner-picked from a 4-way live comparison): cyan-white wordmark on a teal-navy LED panel (#07202e), a gold baseline stripe + gold jumbotron footer — the scene's own cyan+gold trophy pairing (retires the old mint-green footer that clashed with the cyan jumbotron). To change the messages, cadence, or colours, edit the one BRAND config. A far-stand banner alternate is built and ready, shipped OFF (one flag to flip on). Kept separate from MONETIZE. Design rationale: docs/plans/2026-07-10-stadium-branding-design.md.d5aa4e8 — Grand Circuit — a sequential Moon→Mars→Earth landmark (Phase 3.5, CFG.grandCircuit). A goal for the 300-hour player, beyond the Triple Crown. Where the Triple Crown is "champion on all three worlds in any order," the Grand Circuit is the harder sequential feat: win a cup on Moon, then Mars, then Earth, in that order. A passive leg-cursor (CAREER.circuit={leg:0..3}): a real championship on your next required leg advances the cursor; an out-of-order win keeps progress (no advance, no reset — friendly). Completing Earth (leg 2→3) fires once — a 🌐 honour on the champion-screen #endHonour ceremony strip (same surface as the Triple Crown), a +200 Cup Rating landmark (separate from the triple's +250), and a Trophy-Room progress path (Moon ✓ → Mars ● → Earth ○, a 🌐 badge once complete). Bounded meta-layer — no new mode, no auto-advance, no cup-flow or in-match change; folded into applyCupResult under the same real-championship gate as the worlds/triple tracking (exhibition / daily / demo bank nothing). Coexists with the Triple Crown (both honours can fire the same match; triple first). A first-time career carries the cursor from match 1; a corrupt/imported value is clamped to 0–3. Deferred to fast-follows: carried form, prestige re-run. Flag OFF = no field, no honour, no rating bonus, no UI — byte-identical. Smoke +1 (9 assertions), validated-to-FAIL. Size baseline 360000→365000 (3.8 + 3.7 + 3.5 since).f498b99 — Rivalry heat — a per-opponent head-to-head beat on the walkout (Phase 3.7, CFG.rivalryHeat). Turn every cup match into a story. A new lifetime per-opponent-nation W/L ledger (dreamcup-rivalry, id-keyed {w,l,gf,ga} — distinct from the dreamcup-h2h challenge counter) banks once per real recorded match, gated !demo && !exhibition exactly like recordCup (co-located with the existing rivalry block), so exhibition / daily / quick / free / demo bank nothing. The pre-match walkout card gains a one-line head-to-head beat vs this opponent — "First meeting" / "You lead USA 3–1" / "USA lead 2–1 — time for revenge" / "All square, 2–2" — rendered on the cup lane only (record-free lanes show nothing). Meta/presentation only: no AI/gameplay touch. The ledger write rides the 3.8 LS.setJSON envelope; the line uses a textContent sink (XSS-safe) and a |0-coerced formatter (a corrupt ledger degrades to 0, never NaN). Flag OFF = no write, no line (#woRivalry empty+hidden → display:none), no new key — byte-identical. Smoke +1 (8 assertions), validated-to-FAIL. No size-baseline bump.9651005 — LS envelope — surface silent storage-write failures (Phase 3.8, CFG.lsEnvelope). The game has ~60 localStorage sites, every one a try{…}catch(e){} that swallows the error — so a quota-exceeded / Safari-private / disabled-storage failure on a career/trophy write is silent: a Legend cup can vanish with zero signal. This routes the five data-loss-if-silent writes — saveCareer, saveCup, saveStreak, saveLedger, and the title-clip _cinemaSave — through a thin LS envelope. On a caught write failure it surfaces the existing #actFx toast (+ the SR live region) once per session — "⚠ Storage is full — your progress may not save. Free up space." (en+zh) — so the loss is visible, while still attempting the write. It also stamps a schema version (dreamcup-v) for future migrations. Degrades safely when no Game exists (boot writes stay silent without consuming the once-guard, so the first in-play loss still toasts). The other ~55 preference / one-time-flag / read sites are low-harm and left for incremental adoption. Flag OFF = the exact inline try{setItem}catch{} at every site, no toast, no stamp — byte-identical. Smoke +1 (8 assertions, validated-to-FAIL). No size-baseline bump.a3557f0 — Unlocks v2 — earned goal-splash palettes (Phase 3.4, CFG.goalPalettes). The unlock economy v1 (ball trails) exhausted in ~2 sessions and paid Legends nothing. This adds a second, DEEPER earnable cosmetic: the centered GOAL splash (#msg .big) recolors its ink + outer glow to a chosen earned palette. Seven themes gate on real career landmarks — 5 / 25 / 50 career goals (sunfire · violet · emerald), a first cup (crown), a Legend-difficulty title (legend), and the Icon 2500-rating tier (icon) — so veterans keep earning. Picker lives in the Trophy Room, reusing the trail .cosSwatch grid (honest locked-with-requirement chips, never a purchase); the pick persists (localStorage 'dreamcup-splash'). classic == today's exact gold splash, and flag-OFF or a locked pick falls back to it byte-identical. Because the splash is a DOM overlay (never in the WebGL clip/PNG per clipCard), it's replay/clip/share-stable for free. The shared .cosSwatch handler is disambiguated by [data-skin] / [data-splash]. i18n en+zh. Size baseline 356000→360000 (feature earns ~3.9KB).c431fb4 — Auto-clip on every goal (Phase 3.2, CFG.goalClips). "The clip is the ad" — but clipping was full-time-reel-only. Each goal's replay samples already buffer per-goal (_clips), so the result screen now shows a per-goal picker (a chip per goal — scoring flag + minute). Tapping a chip replays that goal while recording (the same proven, feature-detected MediaRecorder path as the reel), then returns to the result screen with the Save button relabelled "🎬 Save 23′ goal" and a -goal-23.mp4 filename. A reach lever that pairs with D-1 (grid + clip = the two viral artifacts). Two-step (tap = prepare, tap Save = export) keeps the export on a fresh user gesture (mobile). Reuses the whole pipeline (shared _clipCapable() gates the picker + recorder identically, so a chip never appears where clips don't work); result-screen only (no mid-play disruption). Portrait (Shorts/TikTok) is a device-gated fast-follow. Flag-off byte-identical. Smoke +1 (flow validated-to-FAIL; the video itself is device-verified out of band). en+zh. Owner-picked Phase-3 item.ac2b340 — Rating-delta ceremony (T4-1, CFG.ratingDelta). The Cup Rating (careerRating) already existed but only in the Trophy Room, so a match's rating change was invisible — the master plan's "cheapest retention win". Now every RECORDED result shows the change: +15 CUP RATING (gold count-up) + the current tier badge + a progress bar toward the next tier ("185 to Contender"). Snapshots the rating at startMatch (before the first goal), diffs at endMatch after every career write; the count-up + bar-fill ride the result-screen stagger (reduced-motion → instant). New pure ratingProgress() helper; reuses careerRating/careerTier. Shows only on a recorded result that moved (!demo && !exhibition && !champ && d>0) — hidden on friendly/daily/exhibition and on the champion screen (already the trophy moment + the tightest fold; the beat shows on every other recorded result, where it's the only progression signal). Pure/derived over CAREER (no new persistence); flag-off byte-identical. Smoke +1 (validated-to-FAIL). en+zh. Owner-picked Phase-3 item.c3bc550 — Daily share grid — the Wordle viral hook (D-1, CFG.dailyShareGrid). The daily was 80% built (lane copy, one-shot, result-streak) but its viral hook — the shareable emoji grid — was never built. A daily result now offers a one-tap 🔗 Share your grid that copies (or Web-Shares) a Wordle-style TEXT block: MECH CUP ⚽ Daily · DATE / CHN 3–1 USA ✅ / 🟩🟩🟩🟥 (goals scored/conceded) / 🔥 N-day streak / a "same draw today — beat my score" line / the challengeURL() link (?daily=…&r= score-to-beat rides along). It's a reach lever that works today on mechcup.com and primes the eventual portal launch — the mechanism that made Wordle spread. Daily-only (#dailyShareBtn shows only on _mode==='daily'&&!champ, cleared on every non-daily result — no bleed); distinct from the existing "Share result" canvas card; clipboard TEXT only (no innerHTML sink); flag-off byte-identical (#dailyShareBtn.hidden{display:none}). Smoke +1 (validated-to-FAIL). en+zh. Owner-picked next-direction (master-plan §C Phase 3.1 residual).ba2e018 — Keeper Duel heartbeat goes live (W2-d, CFG.keeperDuel → default ON). After the OFF-for-audition ship (#352), the owner approved the shootout tension heartbeat → default flipped to ON. A one-flag default change: the wiring, the graph-asserted SFX, and the flag-off byte-identity (today's fixed sudden-death thump) are unchanged and still smoke-covered. Instant revert if ever wanted off.926241f — Instancing spike — distant mountains (W2-f, CFG.instMountains, ships OFF). The 11 static, fog-hidden distant mountains — the largest fully-static, single-material, unlit, never-animated repeated mesh set — collapse into one THREE.InstancedMesh: 11 draw calls → 1. Ships OFF: the draw-call reduction is headless-verifiable, but the frame-time win is view-dependent and only shows on a draw-call-bound mobile GPU (Mali/Adreno), so the owner enables + measures on-device before flipping it on. Byte-identical flag-off (both branches share the identical rand() stream so the rest of the env is unchanged; the unit-cone×scale reproduces the legacy cone profile exactly). Smoke +2 (validated-to-FAIL): a −10 renderer.info.render.calls delta, and a seeded-RNG parity check proving the instanced transforms sit at the exact legacy positions/dims (so flag-on is pixel-identical). Size baseline 345000→346000 (conscious; ~300B for the method, cuts runtime draws).503b647 — Front-door gravity pills (W2-b, CFG.gravPills). Gravity is the game's differentiator but was buried in the Customize panel. The Quick Match hero's promise subline turns its static world token ("· 🌕 Moon") into an inline Moon / Mars / Earth pill group — a first-timer picks gravity right from the front door, no Customize open, at zero added fold height (the pills ride the existing subline slot). A pill routes through a shared selectWorld() (extracted from the Customize world selector) so it flips world + gravity + persists and repaints both the pill and the hidden Customize row. Real-GPU-verified: the pills add only ~3px vs the static text (one line, no wrap) at 1280×720 and ~500px, and are display:none at the short 844×390 fold — no fold regression; the click flips gravity and the Customize row tracks; zh relabels. Flag OFF ⇒ the exact static world text, byte-identical. Smoke +1 (validated-to-FAIL). en+zh.3bcb24b — Keeper Duel 2.0 — tension heartbeat (W2-d, CFG.keeperDuel, ships OFF pending audition). Today the tensest kicks — a level score at 4/5, a match-point regulation kick — get the same dead-quiet stare-down as kick 1, and the sudden-death heartbeat is a flat metronome. Now soTension() (kick-number progress + sudden-death + score-closeness) drives the heartbeat: beat interval 0.95s→0.45s and pitch 48→68Hz track the tension, and it underlies any tense stare-down (not only sudden death), going frantic in sudden death — you feel the shootout tightening. Audio-only (no physics/camera/DOM; RMOTION-agnostic). Ships OFF — audio can't be hear-tested headless, so like the Tier-3 audio it's wired + graph-asserted but inert until auditioned; the else-branch reproduces today's fixed thump verbatim (byte-identical). Smoke +1 (sfx-graph, validated-to-FAIL).3ce3136 — Share card v2 (W2-c, CFG.shareCardV2). The result share card is the top reach lever, but at feed-thumbnail scale v1 was monochrome — a flat white CHN 3 – 1 FRA, so every card looked identical in a feed. v2 redraws the hero as a team-colour scorebug: bright team codes left/right, a glow-colour identity bar under each (gold for CHN, blue for USA…), the score isolated in gold at centre — colour = identity = scannable. The dim footer becomes a cyan ▶ Play free · mechcup.com call-to-action. Only the hero scoreline + footer change (brand, world/gravity tagline, champion seal, result label, recap all verbatim); flag-off is the v1 card, byte-identical. Real-GPU-verified en + a zh champion/pens densest card (no crowding, 3-char zh codes fit). Smoke +1 (validated-to-FAIL; a pixel probe of the identity bar). en+zh.1f4d4ef — Daily post-game streak (W2-e, CFG.dailyResultStreak). The daily streak ticks at the final whistle but was only ever shown on the menu the player had already left — so the Wordle-style post-game "stats" beat, at the exact moment that earns a return, was missing. A daily result now surfaces the freshly-ticked 🔥 N-day streak · next daily in H:MM on the result screen (new #endDaily line), reusing the existing streak/countdown data + i18n with no new persistence. It's the non-overlapping product-surface residual of the daily work after #347 (which shipped the single-match mechanics + "Run it back" CTA). Populates on a daily result, clears on every non-daily result (no bleed onto cup/friendly screens); an explicit #endDaily.hidden{display:none} (there's no global .hidden rule — cf. #342's #bracketBtn) keeps flag-off byte-identical. Smoke +1 (validated-to-FAIL; incl. a computed-display guard). en+zh reused.f9d6485 — Tier-3 audio goes live (owner auditioned + approved). The two taste-gated audio features shipped OFF for the owner's ear; after audition, both now default ON: CFG.theme (T3-1, the ambient menu/result pad+arp theme) and CFG.clutchDrone (T3-2, the clutch/golden/shootout tension drone). A two-flag default flip — the wiring, mute/volume routing, and flag-off byte-identity are unchanged and still smoke-covered. Instant-revert if ever wanted off.676e05d — Gravity made visible: the ball comet-trail reaches further at low gravity (CFG.gravTrail). The game's differentiator is gravity, but shots looked the same on every world. Now the comet trail's reach scales with 1/gravity — a long floaty Moon streak vs a short snappy Earth one — by spreading the same 16 trail verts over more frames of ball history (stride = 60/CFG.gravity, clamped 1–4: Moon g20→3, Mars g30→2, Earth g42→1). Real-GPU measured: the streak spans Moon 8.1m · Mars 5.8m · Earth 3.4m on the low-g hang-time the game already advertises. Near-zero cost (one integer + a changed sample index, no new object); rides the existing CFG.ballTrail + !RMOTION. A stale-read guard (_trailN > 16×stride) keeps the strided trail hidden until enough history has accrued (no streak-to-origin in a match's first 0.8s). This is a first master-plan Week-2 item (2.3 "gravity made visible") — the per-world ambient signature (Mars dust / Earth rain / Moon motes) already ships as CFG.weather, so this is the non-redundant complement. Flag OFF ⇒ stride 1 ⇒ byte-identical. Smoke 302→303, validated-to-FAIL. A/B-able / instant revert.16a5827 — Clutch tension drone (T3-2, CFG.clutchDrone — ships OFF, taste gate). A low filtered drone (a detuned osc pair → lowpass → gain → master) whose gain + cutoff swell with match tension: the final minute of a level/one-goal game, golden goal (climbing as sudden-death runs out), and the shootout (peak). Game._clutchLevel() computes a 0..1 level from score-delta + clock + golden/shootout; a per-frame sfx.tension(level) ramps the drone (throttled, like the crowd bed). The drone node is built lazily on the first tension() call, so with the flag OFF it is never created → byte-identical; the per-frame hook self-silences it (level 0) outside PLAY/SHOOT. Distinct from CFG.clutch (the visual "FINAL MOMENTS!" sting). Default OFF — a taste gate; audio can't be hear-tested headless, so the smoke asserts the level compute + that tension() raises the drone gain, tension(0) silences it, and flag-off is inert. Smoke 301→302, validated-to-FAIL. This completes Phase-5 Tier 3.b563e85 — Menu/result procedural theme (T3-1, CFG.theme — ships OFF, taste gate). A short ambient theme — a held pad chord (chantVoice) + the nation's motif as a gentle arp (blipAt), seeded per nation from motifNotes — plays on the result reveal + on return-to-menu (guarded on sfx.on, so never on the gesture-less cold-boot menu). A finite ~3.4s one-shot, self-terminating, routed through the master gain (mute/volume honoured). Default OFF — a deliberate taste gate for the owner to audition before enabling (audio can't be hear-tested headless, so the smoke asserts the note-scheduling graph: 3 pad voices + 8 arp notes when on, silent when off). Flag OFF = byte-identical. Smoke 300→301, validated-to-FAIL. A/B-able / instant revert.af1feca — Daily/exhibition identity leaks fixed (MP-6). A daily challenge and a friendly/exhibition used to wear cup clothes: a cup-round HUD/walkout tag, a "run ends here" / "Into the Semi-final" result headline, a "someone lifts the cup" line, cup-road dots + a View Bracket button, and an "Out · Quarter-final" share label. Copy-only, routed by match lane through a new _cupLane() predicate on top of the exhibition choke point (ADR 0005), extended to the daily lane. Now a friendly/daily shows its own identity (✦ Daily Challenge ✦ HUD, "Daily Challenge · Full time" headline, empty road, no View Bracket, "Daily Challenge · Won/Lost" card); a genuine champion (real cup / Beat-my-Final / a daily title) still keeps the trophy + road. Also fixed a latent bug the QA caught: #bracketBtn (a .bigBtn) had no .hidden CSS rule, so the F3/MP-6 classList.toggle('hidden') never actually hid View Bracket — added the rule so it truly display:nones (guarded by a computed-style smoke check). Adversarially verified (2 lenses): champion reconciliation is clean (a real cup / Beat-my-Final / daily title still keeps its trophy + road), and the review caught a lane divergence — a Quick Match with a ?daily= deep-link still armed showed "Friendly" in the HUD but "Daily Challenge" on the result — now gated exhibition-first everywhere (guard smoke added). en+zh (6 new keys). Smoke 299→300, validated-to-FAIL. Known follow-ups (out of the copy-only remit): the daily is still mechanically a recorded advancing cup, and the result-screen primary CTA still reads "Start the Semi-final" on a non-cup WIN (friendly and daily) via the shared _adaptiveCta base — deciding the daily's single-match-vs-cup shape and routing that CTA by lane are a separate owner call.156cda8 — Scorer identity beat (MP-5, CFG.scorerId). Goals were anonymous. A ~1s lower-third now names the finisher — flag + call-sign + № (e.g. 🇨🇳 ⚽ ACE-9 · №10) — rising just AFTER the GOOOOAL flash (celebT ≥ 1.0, above #goalLower, below the splash, no overlap). It shows the human's typed call-sign when you score, a procedural star call-sign for a scoring star, else a bare №; own goals show nothing. The typed name is esc()'d at the innerHTML sink (stored-XSS class, #188) — smoke-tested with a hostile <img src=x> payload. en+zh (zh: ⚽ {name} · {num}号). Flag OFF = today's anonymous goal (the compact #glScorer band untouched). Smoke 298→299, validated-to-FAIL. A/B-able / instant revert.3f86c24 — Bicycle-kick celebration assembly (MP-4, CFG.aerialCam). The game's most spectacular verb used to render as "a distant twitch." A USER bicycle strike now earns a proper beat: a ~0.34s scaled hit-stop (reuses _hitT → timeScale 0.12), a camera push-in (dollies the target toward the striker), and a ~0.5s slow-mo tail easing time back to full (the fov punch rides the existing screamer block). Presentation only — kick physics untouched; a scoring bike already feeds the goal reel via _lastShot.bike. USER-only (player.isUser excludes the AI tryAerial path), !demo (a genuine human strike, not the attract loop), and RMOTION-gated. Real-GPU verified: the camera pushes 9.6m→7.0m (no clip), fov→67 (under cap), hit-stop then eases to full by ~0.45s. Adversarially verified (goal-overlap + determinism); the review caught a state-bleed — a scoring bike left _aerialCamT>0 leaking slow-mo into the post-goal kickoff — now scrubbed on every teardown (_clearGoalFx + startMatch), with a guard smoke. Smoke 294→298, validated-to-FAIL. Flag OFF = today's twitch. A/B-able / instant revert.5698686 — Shader prewarm under the splash (MP-3, CFG.prewarm). One renderer.compile(scene, camera) runs at boot while #splash is still up — scene.traverse ignores frustum culling, so it precompiles the full in-use GL-program set (~21) up front, converting the frame-1 compile stall (worst on Mali/Adreno) into deterministic work paid behind the ball spinner (a compositor-accelerated animation, so no frozen frame). The match-only robots/nets/particles reuse the menu env's programs, so the first PLAY frame is covered too. Measured compileMs ≈ 102 ms on an M3 Max; records DreamCupPerf.compileMs + game._prewarmed. Flag OFF = today's lazy-compile path, byte-identical (one gated statement). Adversarially verified (boot-safety · flag-off/determinism · perf) — renderer.compile consumes zero Math.random so the seeded A/B stream is untouched; the redundant per-match compile the first draft added was dropped (all kits share one program). Smoke 291→294, validated-to-FAIL.abf28af — Guide + doc staleness pass (MP-2). Copy/docs only, no game code: the /guide Quick-start no longer tells players to "hit START" (there's no such button — the doors are ⚡ Quick Match and ⚑ Cup Run), en+zh; the <script> header comment now names the real engine (r149, was a stale r134); CLAUDE.md says ~8,400 lines (was ~3,000); and BUILD.md's minify/wire numbers are refreshed to the current build (index.html 669,427→354,189 B, script 567,844→275,731 B, brotli ~183→92 KB q11 / ~209→104 KB q5; the "131 KB inline script" note → ~275 KB). Four fs-based smoke gates (validated-to-FAIL) keep the stale strings from returning.90c2671 — Friction batch #1 (MP-1): six live-audit papercuts. From the owner's five-lens master plan, presentation/copy only (no CFG flags — no meaningful "off" state), each with its own validated-to-FAIL smoke assertion (277→286): (a) arming the Daily Challenge now scrolls the "Start Daily Challenge" CTA into view so it isn't lost below the fold; (b) a Watch/attract demo loops back to the title at full-time (never a result screen with rematch CTAs for a match nobody started), tab-hide no longer shows a "Paused" screen during a demo, and a subtle DEMO watermark rides the whole demo — all keyed on this.demo, so the shipped Watch demo is fixed too; (c) undecided semi/final bracket slots render a localized TBD chip instead of a bare · dot (en TBD / zh 待定, the existing key); (d) at ≤390px the score pill no longer overlaps the ? help button (the corner cluster reflows to 30px — it overlapped by ~32px before); (e) (no code change — verified false positive) the NOVA trail already shows its "Play 5 matches" caption via the shared unlockNeed path (the auditor saw it unlocked, where every trail's caption is blank by design); a regression guard now locks that in; (f) the minimap (and the touch pads / corner buttons) respect env(safe-area-inset-*) via viewport-fit=cover, so they clear the home indicator + notch on phones.6e5e47c — Pause photo & share cards save to disk on desktop (real "Save" dialog). The 📸 pause photo and the result/career cards were opening the macOS SHARE sheet (AirDrop/Mail, no save-to-disk) — modern desktop Chrome now answers canShare({files}) true, so _deliverFile took the share branch. It now matches the clip: desktop → a real "Save as" dialog with the guaranteed filename, share sheet stays for touch devices (where it IS the save/send), classic download for Safari/Firefox/portals.f9a926d — Photo & share-card filenames match the clips: unique tie + date. The pause photo and the result share card now save as mechcup-chn-usa-20260709-photo.png / -result.png (one shared _shareName helper); the career card saves from the menu — no live match — so it stays date-only (mechcup-20260709-career.png), never a fake matchup.9a7aaec — Highlights clips get unique, shareable filenames. The saved clip is now named by the tie and the day — mechcup-chn-usa-20260709-highlights.mp4 — instead of one-name-fits-all, so multiple saved clips never collide and the file says which match it holds (owner ask).11e9cdb — Mobile environment diet (T2-9): fewer lights + clouds where phones feel it. On a TOUCH device the DreamEnvironment now drops the 4 stadium-orb point-lights (the additive halos + hemisphere + sun carry the look) and cuts the cloud sprites 38→14 — each cloud is its own transparent draw call, so that's a real per-fragment-lighting + draw-call saving on mobile GPUs. Desktop (and flag-off) build the full env, byte-identical. Behind CFG.envDiet (instant revert), records env._envStats for the smoke. Verified: desktop = 4 lights + 38 clouds (277/277, +1 validated-to-FAIL); an emulated touch profile = 0 lights + 14 clouds. This completes Phase-5 Tier 2 (spectacle).fd3a858 — Radial speed blur on the fov-kick (T2-7). When the camera punches — a hard shot or a big impact spikes this.fovKick — the bloom composite now streaks the 3D scene radially toward centre (5 extra taps along the radial, stronger at the edges, averaged with the base), a zoom-blur that swells with the kick (blurAmt = min(1, fovKick/11)) and decays with it. It's one branch in the existing mComp composite (the T2-2 goal-fx path) — zero extra passes — so it's desktop / bloom-eligible only and RMOTION-gated like the fov itself; the DOM HUD stays crisp (only the WebGL scene blurs). Behind CFG.speedBlur (instant revert; blurAmt=0 → the plain composite, byte-identical, the B2 grade check still holds). Smoke +1 validated-to-FAIL (blurAmt swells on a fov-kick / 0 flag-off) → 276/276. Live-QA'd on real GPU.9e4a03c — Shader-side crowd (T2-5): the terraces animate on the GPU. The crowd's per-soul animation moves off the CPU — an onBeforeCompile injection on the crowd PointsMaterial (with new aAng/aPhase attributes) drives the terrace wave (the same gaussian bump, now from a uWave uniform instead of the JS loop that rewrote the whole position buffer every frame), a subtle idle bounce, and goal phone-lights (seats twinkle brighter while the crowd boost is hot). JS now sets 3 uniforms/frame instead of N positions. Reduced-motion freezes it. Behind CFG.crowdShader (instant revert to the JS wave loop; off = no attributes/hook, byte-identical). Smoke: the existing wave test now drives the JS path explicitly + a new validated-to-FAIL check (flag-on wave writes zero CPU positions) → 275/275. Live-QA'd on real GPU (terraces render as a glowing ring, phone-lights twinkle, no breakage).1d837d3 — Highlights clip: a real Save dialog, and MP4 everywhere it's possible. The desktop clip save now opens a proper "Save as" dialog (File System Access API) with the filename guaranteed — the old invisible anchor download could silently lose its name (owner repro: a bare-UUID, extensionless mystery file in the downloads bubble). Safari/Firefox/portal iframes keep the classic download; a closed dialog is a choice, not an error. And the recorder now walks a candidate LADDER (AAC-MP4 → browser-choice MP4 → opus-MP4 → webm): a candidate that passes isTypeSupported but throws at construction falls to the NEXT one instead of dropping straight to a webm default — .webm now only exists where the browser literally cannot mux MP4 (Firefox, codec-less Chromium), and it stays truthfully labeled there.5d9ac59 — Procedural robot envMap (T2-4): metals reflect the sky instead of going black. A lazily-built 64px procedural CUBE env (moon-night palette, cached on ROBO like the shared geo) is set on the robot body/dark/joint materials so their metal reflects the night sky — which lets the metalness lift (body .3→.55, dark .7→.85, joint .45→.65) for a real mech sheen, resolving the long-standing metalness-black-at-night gotcha by giving the metals something to reflect. Gated to desktop hardware GPU (!TOUCH && !roboSoftGL()) — the IBL shader is too heavy for software WebGL (SwiftShader/llvmpipe) + mobile, which keep the safe low-metal look (spectacle is desktop-class, like bloom). Behind CFG.envMap (instant revert; off = envMap null + intensity 1 = the material defaults, byte-identical). Smoke +1 validated-to-FAIL (on-hardware sets env + lifts metal / software gated off / flag-off original); size baseline bumped 332000→345000 (the budgeted Tier-2 spectacle growth). Live-QA'd on real GPU (robots read metallic, not black). → 273/273.c70f77a — Kawase mip-chain bloom (T2-3): a wider, softer, cinematic glow. The bloom's single half-res 5-tap gaussian blur is replaced by a dual-filter Kawase mip chain — 3 energy-preserving downsamples + 3 tent upsamples spread the glow far wider and softer (a filmic halo on the stadium lights, orbs, net rims, star trails) at ~the same cost. Rides CFG.bloom (same linear scene RT / ACES composite — only the blur stage changes), so it stays desktop-only + degrade-gated. Behind CFG.kawaseBloom (instant revert to the tight gaussian; flag-off builds no mip RTs, byte-identical). The T5-1 render tier proves it still ADDS light (no darkening — the B2 detector holds) and never blanks a frame; smoke +1 validated-to-FAIL (mip chain builds on / gaussian off) → 272/272. Live-QA'd on real-GPU Chrome (before/after screenshots).696a7d0 — Daily Challenge CTA hint names the button. The daily banner's sub-hint no longer reads the generic "press the highlighted button below" — it now NAMES the target so a first-timer knows exactly what to tap: en press "Start Daily Challenge" below, zh 点击下方"开始每日挑战"开赛 (queue row 50, from the Codex copy review + the review's "make the Daily CTA more direct" note). Copy-only, en+zh parity held by the I18N gate; no logic change, no new assertion. First item of the resumed loop.ab10b14 — Live-review follow-ups: friendlies end as friendlies, watching writes nothing. From the 2026-07-09 live playtest review: a lost one-tap Quick Match no longer reads "Your run ends here · OUT" (a friendly now ends "Full time — {opp} edge it" / 「全场结束——憾负{对手}」, wins mirror it, the fake-bracket champion line and View Bracket no longer leak into exhibitions); the attract/Watch demo is a true spectator (no more dreamcup-played/cup-save writes that turned an idle first-timer into a "returning player", and it plays at Medium so the showcase isn't a 7–0 blowout); the What's-New card announces the FINAL 8 wave; bandless nations (USA) get kit-color crowd ends + fireworks everywhere via one shared kitBand helper; and China's shirt deepens to true red (the flag band stays exact). Smoke: two new validated-to-FAIL assertions (demo-writes-nothing; friendly-copy + no-bracket) — 271/271.CFG.commDepth) gains three things: (1) per-match no-repeat memory — the ticker now cycles a beat's whole pool before any line repeats (verified: firing the save beat 8× yields 8 distinct lines), killing the "same line twice in a row" immersion break; (2) score-aware goal lines — _goalCtx reads the scoreline the goal just wrote and picks a matching sub-pool, so a goal reads "FRANCE hit the front!" (took the lead) / "…level it! Game on." (equalise) / "…stretch the lead." (extend); (3) ~3× deeper pools across every beat (goals · saves · woodwork · counters · clutch · golden · shootout), en+zh. Presentation-only — one Math.random per line, so the seeded-sim RNG stream is unchanged; flag-off = today's pure-random pick. Smoke 260→262 (no-repeat cycle · _goalCtx mapping · score-aware render · flag-off no-memory), validated-to-FAIL. (Loop judgment: took this higher-confidence content row via the Tier 2/3 interleave mandate; the render-pipeline rows — T2-3 Kawase bloom etc. — stay queued for a focused cycle.)CFG.cupRunTile) with a subtitle "the tournament · your record" / "锦标赛 · 记录战绩" — so the two lanes read clearly: Quick Match = instant & record-free, Cup Run = the committed, recorded tournament. Label-only: clicking Cup Run still runs startCup() (a normal recorded career — verified exhibition:false live). The round you'd enter still shows on the matchup card + bracket; a daily / armed ?round= still names its scenario. The two round-label smoke tests now pin cupRunTile=false (they assert the flag-off round-naming copy rule). Subtitle drops on very short screens (fold intact). i18n en+zh; flag-off = the round label. Smoke 260→261, validated-to-FAIL. 6·A — the whole one-tap front door (hero · subline · resume chip · Cup Run tile) — is now shipped. Next: Phase 5 Tiers 3–5.CFG.resumeSetup, team · world · difficulty). The picks were already persisted individually (dreamcup-team/-world/-diff), so there's no redundant blob — the chip just surfaces what one tap will play, and tapping it opens Customize (already prefilled) to tweak. It shares the under-hero slot with the promise subline: a returning player (dreamcup-played) sees the chip, a first-timer sees the identity subline — so they never stack and the fold budget is unchanged. Repaints on team/world/diff/language change; i18n en+zh (↻ 法国 · 月球 · 简单); flag-off = no chip. Also fixed a pre-existing 844×390 fold flake (present on main too): the meta row gained a 4th entry when Watch moved in at step-1 and wrapped to two rows, nudging Trophy below the fold on some runs — tightened the meta buttons to stay one row (margin now 72px, was ~−4) and dropped the offer strip on that tiny landscape screen like the title/subline. Smoke 259→260 (chip for returning / subline for first-timer · names the persisted setup · tap opens Customize · zh · flag-off), validated-to-FAIL. Next 6·A: the full Cup-Run tile reframe (step 4).CFG.promiseSub) that says what the game is in a glance: "5v5 robot football · 🌕 Moon" / "5v5 机甲足球 · 🌕 月球". It's world-aware — the world (emoji + name) follows your gravity pick, so a Mars selection reads "· 🔴 Mars" and it never Moon-pins the multi-world franchise (per the brand voice). Repainted on language + world change (syncPromiseSub()); shown only alongside the hero, dropped on very short screens like the title/tagline (so the fold budget is untouched). i18n en+zh; flag-off = no subline. Smoke 258→259 (world-aware Moon→Mars · en+zh · hero-off + flag-off hidden), validated-to-FAIL. Next 6·A: the resume-last-setup chip, then the full Cup-Run tile reframe.CFG.quickMatch): one tap → a live exhibition on smart defaults (a virgin = France · Moon · Easy), zero further clicks — writes no record (rides B-3 this.exhibition; opponents play for real, unlike Free Play). It routes through startMatch(demo=false), so a first-ever Quick Match still arms the whole first-timer spine (_firstMatch/_moonKick/trainer/press-ease — verified live on a cleared profile: FRA vs PAR, moonKick:true). The career "Start the …" button demotes to a secondary (ghost) tile — it keeps its round-naming label (the labeled "Cup Run" reframe is a follow-up) — and Watch moves into the meta row so the taller stack still clears the fold (verified 1280×720 · 844×390 · 375px). i18n en+zh; flag-off = no hero, START stays primary. Smoke 258 (one-tap→PLAY · exhibition · a real onGoal leaks nothing · first-ever arms · flag-off), validated-to-FAIL. Next 6·A: the promise subline, the resume-last-setup chip, the full Cup-Run tile reframe.#hint). Now on keyboard in state==='PLAY' the ? calls a new toggleHint() that flips the legend in place; manually showing it pins it past the ~9-second intro auto-fade (new hintPinned, checked in the fade guard), and hiding it un-pins so the fade resumes. On the menu (or on touch, where #hint is display:none) the ? still opens the full overlay. Gated by CFG.helpTogglesHint (flag-off = the modal in every state, as before); the existing "?" assertion pins the legacy path, a new assertion covers the toggle + pin + auto-fade, validated-to-FAIL. Smoke 255 → 256.#worldFact) sat outside the collapsible setup grid, so it lingered on the clean one-tap front door describing a GRAVITY selector that was hidden. Relocated it inside #setupRows, directly under the GRAVITY pills — now it collapses/expands with the selector it annotates (hidden on the virgin front door, appears when Customize opens). Dropped its home-divider top border for a caption look. No new flag — it rides the existing CFG.customizeCollapse (flag-off still shows the grid + hint, as before). The A-2 smoke assertion gained a gravHintInside clause (symmetric to langOutside), validated-to-FAIL against the old sibling placement.lenSel/CFG.matchMin fallbacks + the menu's default-selected pill + the static clock placeholder) so a first-timer's opening match is a quick, low-commitment taste — a longer 3/5-min pick is still remembered across visits. The published playing guide (guide.html) now documents the always-visible corner buttons in both en + 中文: ? (opens How-to-Play), 🎥 (camera), 🔊 (mute), ⏸ (pause), plus the in-match 👁 Watch button (hand the match to the AI, tap again to take back). Smoke's existing default-length assertion updated to sel=1, matchLen=60 (validated-to-FAIL against the old 180).CFG.gravFlip): the one mid-match interaction — scoring your first-ever goal floats a tappable "⚡ Tap to feel Earth →"; tap it and the world flips to Earth live (gravity 20→42) so the next shot drops flat, "Same kick. Different world." One-time, auto-dismissing, never armed mid-charge. B-BET2 · "Beat my Final" (CFG.beatFinal): winning a Cup makes your share link seed a friend straight into the exhibition Final vs the same opponent (&round=<final>&foe=<opp>, record-free) — built on the shipped ?round= + seal. Both flag-gated, validated-to-FAIL.CFG.offerStrip, Decision #2): a first-visit-only, passive, dismissible "🌱 New here? Try a 60-second warm-up →" below the hero → a Free Play warm-up; never gates the one-tap door; retires forever on tap/dismiss. How-to-Play overlay (CFG.howtoOverlay, C-1 fuller scope, Decision #3): the home "How to play" now opens an in-game modal mirroring the live controls (full guide one click away) instead of a new tab — keeps the player in the game. Both i18n en+zh, flag-gated, validated-to-FAIL. The entire Phase 6 NPE roadmap is now shipped.CFG.assist): under Simple controls (the default), the controlled player auto-switches to the teammate nearest the ball (off-ball, debounced, user-team only — never an opponent/keeper) and shots get a minimum corner-snap (_assistSnap floors D.snap at CFG.assistSnap) so a placed finish doesn't depend on the tier. PES / flag-off = byte-identical. Directly answers the play-test finding that a beginner loses the ball / can't manage switching. - A-4 · attract (6·A, CFG.attract): after 18s of true home idle (no input, no modal) the game auto-starts a Watch demo — any input takes over. The idle timer resets on every interaction, so the one-tap door is never disrupted; flag-off = today's static menu. - Guide: a bilingual "New here? Start simple" section (the in-match Trainer, Simple = assist, Free Play, the gentle first match, pause-for-controls) + the Champion seal in the cup/career list. - Smoke 248 → 250 (both feel rows guard-checked: PES/flag-off byte-identical, never fires with a modal). Both were built behind their flags + held as draft PRs, then merged on the owner's OK.CFG.sandbox). A "🎮 Free Play" home button → a record-free practice match where the opponent outfielders stand down (a one-line updateAI gate, desired=0) while the keeper still tends goal — so a beginner learns move/aim/shoot and practises finishing without being pressed. Rides B-3's this.exhibition (writes nothing — no cup / career / rivalry) + this.sandbox (the passive-opponent gate, which never reaches the chaser / aiTackle path). A rematch stays sandbox; any fresh START clears it. Flag-gated button, i18n en+zh, fires track('sandbox_start'). Smoke verifies the button gate, record-free start, frozen outfielders (keeper source-exempt), and a surviving sentinel cup; validated-to-FAIL. Completes the 6·C tail.CFG.funnel). Extends the existing funnel (menu_view / first_goal / match_start / _end / share) with the first-minute steps the NPE is optimising: time_to_first_input (the "one tap to play" primary metric, ms), trainer_shown / trainer_learned (B-1 efficacy — did the Trainer surface a lesson, did one land), and customize_opened (which door a beginner takes). All via funnelOnce (per-match dedup) except customize_opened (per user-open), forwarded to window.gtag + the dreamCupStats counters. No PII, no UX change. Flag off = the new steps don't fire. Smoke drives each trigger + asserts its counter bumps (silent when flag-off), validated-to-FAIL.CFG.champSeal). The one reserved prestige cosmetic: winning any cup once unlocks a gold "CHAMPION" seal on the share card + a home line — derived from CAREER.cupsWon, no new gameplay lock (everything else stays open day-one). Fixes the reward Catch-22 (a prize can't motivate after it's earned) with a progress hint shown before completion — "🏆 Win a Cup to earn the Champion seal" — that flips to a gold "Champion · N won" badge after the first cup. Card gets a gold rosette (★ + CHAMPION) top-left; the home line (#champSeal) repaints via syncCareerTeaser. Flag off = no seal, no line. i18n en+zh. Smoke flips hint→badge across zero→one cup + probes the card's rosette for gold pixels (present only once won), validated-to-FAIL.CFG.trainer). The retention centerpiece — teaches in-match, not on a drop-off-prone tutorial screen. On the first-ever match it surfaces move / pass / shoot / tackle prompts contextually with a priority order (opponent carrying near you → tackle > you near goal → shoot > you carrying → pass > away/idle → move), one at a time (~2.5s min-dwell), each auto-retiring the instant you perform it (dreamcup-taught-* → silent once learned, forever). Runs the whole first match (a goal can't kill it); touch profiles get the touch copy. Subsumes the legacy 3-step tut0/1/2 (the call site gates on CFG.trainer; flag-off = the old tutorial). A-★1's Moon-Shot volley stays beat one — the Trainer holds ~2.6s, then takes over. Reuses showMsg()/#msg; ~90% net-new (context detection + prompt queue + touch variants). i18n en+zh. Smoke drives updateTrainer end-to-end (priority + retire + touch copy + silent-when-taught + flag-off legacy), validated-to-FAIL.CFG.chalExhib). A shared friend challenge (?team/?world/?diff/?r=, not the daily) drew a fresh cup and saveCup'd it on START — silently clobbering the player's in-progress saved run. It now rides B-3's this.exhibition and plays record-free: the live cup is never overwritten. The daily still records its streak (never exhibition), and the score-to-beat ledger (dreamcup-h2h) still updates — a separate store, and the whole point of ?r=. Builds directly on B-3 (#267). Smoke is guard-sensitive via a sentinel saved cup: a friend challenge preserves it while flag-off reproduces the clobber (verified by removing the A-3 line → the sentinel gets overwritten); validated-to-FAIL on origin/main.CFG.exhibition). A ?round=N deep-link plays that single round as a one-off EXHIBITION — a transient bracket at the round's opponent + difficulty that writes no record: no saved cup (never overwrites a live run), no career trophy, no rivalry/milestone. The enabler A-3 needs so a friend's challenge link can't clobber your run. this.exhibition gates the single persistence chokepoint (saveCup() early-returns → covers every call site) + recordCup / rivalry / cinema / milestone / daily-streak. Consumed one-shot (a rematch replays record-free; a fresh START is a normal recorded run). A menu banner names it ("🎪 Exhibition · Final · nothing is recorded") and the matchup card + START label preview the armed round's foe, not a stale Round-of-16. Out-of-range / flag-off ⇒ ignored. i18n en+zh. Smoke is guard-sensitive: an exhibition writes nothing while a normal match alongside DOES write (a removed guard flips a check to FAIL — verified by pulling the saveCup guard); validated-to-FAIL on origin/main.CFG.pauseHelp). The Pause overlay showed only Resume / Restart / Quit; the only "How to play" was a menu-only link that opens a new tab and drops the match. A beginner who forgot the keys mid-match had no in-match recourse. Pause now carries a live, style-aware CONTROLS key-map (#pauseControls) — reusing the existing hint copy (hintHTML / hintHTMLpes), repainted by applyLang so it always matches the current Simple/PES scheme + language (en+zh). The copy is keyboard-only, so it's hidden on touch (the on-screen ⚡/joystick are self-evident there); flag off = today's Pause. The doc's "safety net now." Smoke locks the reference inside the pause overlay (WASD-bearing + labeled, en and zh) and that flag-off removes it entirely.#setupRows so it can't regress.CFG.customizeCollapse). The home showed all six setup rows (difficulty / length / controls / tactics / gravity / language) at once — a wall of options for a beginner. Now #setupRows tucks behind a "⚙ Customize ▸" toggle: the clean one-tap door by default (team hero → START), every option one tap away. Adaptive — a virgin sees it collapsed; a returning customiser (dreamcup-customize) or a challenge/deep-link arrival auto-expands so their settings show. aria-expanded/aria-controls wired, state persisted, chevron rotates. Since #setupRows is display:contents, collapse = display:none (no layout risk). Flag OFF = the grid always shown (today's home). i18n en+zh. Smoke 238→239 (virgin collapsed / saved + deep-link expanded / toggle round-trips + persists / flag-off shows the grid), validated to FAIL. Live QA: the clean-door home renders + Customize reveals the full grid.CFG.easyFirst, Decision #8). The code default for a first-time visitor (no dreamcup-diff) was mid-tier Medium (Dream) — but "losing badly in match one is the biggest first-session bounce cause; a beginner must not meet a mid-strength opponent." Now a virgin boots at Easy (Doze); a returning player's saved pick is untouched (only the no-storage case changes). Routed through firstMatchDiff() so it's testable; flag OFF = the old Medium. Smoke 237→238 (firstMatchDiff virgin=0 / flag-off=1 + a source check the boot line routes the null-storage case through it), validated to FAIL. Live QA: a fresh profile boots with the Easy pill lit.CFG.adaptiveCta). The result screen's primary button becomes one context-picked action, all else demoted (builds on the T2-8 result-moment reveal, doesn't fight it). Priority: a friend's ?r= arrival → a return volley — "⚔ Send X your 3–1" (share it back; reads the A-★4 call-sign) — the viral-loop keystone; a bad loss → "↺ Try again — a touch easier" (drops a difficulty tier, floored at Easy); a run-over blowout win → "Ready for a real challenge? →" (bumps a tier); else the cup's next-round / new-cup default. Removes the "what now?" at every session boundary and rubber-bands difficulty (closes both the lose-badly bounce and the win-easy boredom). _adaptiveCta(chal) takes the challenge as an arg for testability; _setDiff shifts the tier clamped + persists + syncs the menu pills. Flag OFF = today's fixed CTA (the new branches only fire under the flag). i18n en+zh. Smoke 236→237 (every branch + the floor/clamp + flag-off default), validated to FAIL. Live QA: arriving via ?r= and winning 4–1 shows "⚔ SEND FOE YOUR 4–1" as the one glowing CTA over the confetti.CFG.nameAsk). encodeResult/_shareText/challengeURL already threaded a name, but nothing ever captured one (no dreamcup-name) — so every "⚔ Beat X's 3–1" banner read "Beat 's 3–1." Now one skippable prompt (#nameAsk) at the first Share moment (never on the home, never before a kick), prefilled with a procedural starName() call-sign ("SONIC-18"); Save or Skip both persist dreamcup-name and proceed to share (the tap is the gesture that carries navigator.share). challengeURL + the share card then thread the player's call-sign — a challenge link now renders "Beat ACE-7's 2–0." Security (the name is untrusted → an innerHTML sink + localStorage = stored-XSS class, same as the #188 career-import fix): _setName strips <> + control chars and clamps to 12 on capture; the banner render already esc()s; the card is fillText (inert). i18n en+zh. Smoke 235→236 (default call-sign; capture strips+clamps; esc neutralizes tags + the banner escapes; challengeURL threads &r=; a named user skips the modal), validated to FAIL. Live QA on a cleared profile: the modal shows + saves, and a challenge link reads the real name. Flag OFF = the old team-short fallback, no prompt.CFG.pitchWear). The pitch never showed a match — now a transparent decal layer just above the grass accumulates dark scuff marks where tackles + shots churn the turf, fading over ~20s. One extra plane in DreamEnvironment (a CanvasTexture): stampWear(x,z,r,strength) maps a world position to the playable-pitch UVs and draws a soft dark smudge; the update() fade thins every mark on a ~0.4s cadence. A TTL (re-armed to 24s per stamp) gates all cost — the fade + texture upload only run while marks are live, then the layer clears (zero idle cost); depthWrite:false so robots/ball occlude it. Hooks: a plant divot on shots, studs on won tackles (user + AI). No Math.random in the wear system → the seeded A/B sim is bit-identical (pure presentation). Smoke 232→233 (reads the wear-canvas alpha: a stamp darkens + arms the TTL, the fade thins + clears on expiry, off-pitch ignored), validated to FAIL. Self-review caught a real bug first — the plane was built in Field while stampWear/fade live in DreamEnvironment; moved it so they share one instance. Live QA in real GPU Chrome: scuffs churn the turf, heavier at both goalmouths.CFG.resultMoment). The result screen was "a form, not a moment" — it appeared all at once. Now the content fades in staggered (a ~150ms cadence, top-down) and a win/championship rains confetti (46 pieces in gold + both kits + white/cyan). A loss staggers but stays confetti-free. Pure DOM/CSS over the existing #endScreen (a new #endConfetti overlay); reduced-motion + flag-off both revert to the instant reveal. The trophy is excluded from the stagger so its idle bob survives (the reveal's animation shorthand would otherwise freeze it — caught in self-review). Math.random is decorative (END state only, never touches the sim/replay RNG). Smoke 231→232 (DOM state, no animation-timing dependence: win → .reveal + .rvl rows with stepped delays + confetti, trophy keeps its bob; loss → stagger only; flag-off clears all three), validated to FAIL feature-absent. Live QA in real GPU Chrome: staggered content + a gold/kit confetti rain.CFG.awayAnthem). anthem(a,b) used to ignore its b (away) argument; now, after the home nation's 8-note motif lands, the away nation answers with a quieter 4-note phrase of its own motif — a reply, not a duet (~half the home volume, starting a beat after the home sting). Reuses motifNotes + blipAt, mirroring goalReprise; needs CFG.motifs. Flag OFF (or no away nation) = the home sting only, unchanged. Smoke 230→231 (via blipAt call-counting: the home sting is 16 blips, the reply adds 8; none when the flag's off or there's no away side), validated to FAIL feature-absent. Audio isn't capturable in headless QA, so the graph assertion runs against the built bundle; the sound is flag-revertible on a live listen. Presentation-only → no A/B gate.CFG.replayCut). The in-GOAL replay (celebT 1→3, previously a single glide beside the ghost ball) becomes a two-shot sequence: angle 1 from behind the goal the ball entered (looking back at the strike), then a hard cut at the halfway point to angle 2, the low sideline glide. Reuses the replay ghost/line and the same window — it just re-frames the camera and snaps once per angle change (a switch, not a swoop). onGoal records which goal the ball entered (_goalGoalX) and resets the cut tracker; reduced-motion keeps the single sideline angle (no cut); flag OFF is byte-identical to today's single angle. Doesn't touch the shareable HILITE/cinema camera. Smoke 229→230 (deterministic: angle 1 snaps beyond the goal line, the cut jumps beside the ghost with the shot index 1→2, flag-off/RMOTION never go behind), validated to FAIL (feature-absent trips a clean guard). Live QA in real GPU Chrome: angle 1 behind the net looking down-pitch, angle 2 low side-on. Presentation-only → no A/B gate.CFG.goalFx). For ~0.45 celeb-seconds after a goal the bloom composite adds a radial chromatic split + a vignette tinted with the scorer's kit colour, decaying to nothing so open play is untouched — the broadcast "GOAL!" punch the moment was missing. Rides the existing bloom quad: one branch in the mComp fragment shader (new fxAmt/fxCol uniforms), zero extra passes, so fxAmt=0 is byte-identical to the plain composite (the T5-1 bloom-adds check still passes). Desktop/bloom-eligible only (that's where the quad composites) and reduced-motion-gated like the flash/shake; _goalFxAmt() is 1 at the strike → 0 by celebT 0.45, zero in open play / flag-off / RMOTION. onGoal tints the vignette with nations[scorerIdx].shirt. Smoke 228→229 (the amount curve + kit tint + a render check that the composite visibly shifts at the edges), validated to FAIL twice (shader ignoring fxAmt collapses the render diff to 0; feature-absent trips a clean guard). Live QA in real GPU Chrome: chromatic fringing + tinted vignette render (fxAmt 0.87, France kit #1d2f8f). Presentation-only → no A/B gate. Flag OFF = today's render.test/screenshot-lib.mjs). Two blocking checks, both relative/in-env so there's no cross-machine golden to flake: (1) selective bloom must ADD light — a same-frame on-vs-off luma delta (settle once, then update(0) toggling CFG.bloom so only the composite changes; observed ≈ +12, a broken/fallen-back composite collapses it to ≈0), and (2) frames must render real structure (luma variance; a blank canvas is sd 0 vs ≈45). Plus a non-blocking golden diff vs committed test/golden/*.json, logged per-frame — it ships non-blocking because the renderer's own run-to-run drift (~15–25 luma/cell) plus dev↔CI differences would flake a pixel gate; node test/capture-goldens.mjs re-bakes them and prints the self-diff floor, so the diff can be tightened to a gate once goldens are CI-captured. Both blocking checks validated to FAIL (bloom→fallback collapses the delta to 0; a GL-cleared canvas trips not-blank). Smoke 225→228. First of the two Tier-2 prep rows landing together with T5-5.index.html change — the build is byte-identical to prod.78311bf — Guide: phone-orientation + iPhone-fullscreen tips (en + zh). The playing guide gains a 📱 On your phone / 手机上玩 section: MECH CUP plays in both portrait and landscape (landscape is roomier — rotate for width, Portrait Orientation Lock off), and — because iOS Safari/Chrome can't always hide their bars over a canvas that doesn't scroll — the three-step Add to Home Screen / 添加到主屏幕 route to a chromeless, app-like fullscreen, plus use Safari over iOS Chrome and an Android Install note. +1 build gate (build-ga.mjs asserts the phone-orientation + iPhone-fullscreen copy in both languages). Authored as #245 (closed); the diff had already ridden onto main via #246's merge 78311bf (that branch was based on this commit), so this backfills the changelog line the change never got.kickBall's shot gate, the user's aim-at-goal check and two AI shoot decisions — now single sources of truth CFG.shotRange / CFG.shotGate / CFG.aiShootDist. Deliberately left alone (near the shot logic but not the gate — folding them in would move the balance): the 0.5 dink power (chosen above the 0.45 gate), the finesse-curler distGoal<16, the power>0.4 release-flash FX cue, the GK-defensive 24 and box 20. D4: the _mom rubber-band step (2+ goals up/down → the opponent digs in / the crowd rallies) was computed in updateHUD (presentation) even though it feeds the opponent's tackle odds + decision tempo — moved to the top of updateAI (the sim step) so it can't freeze during a GOAL ceremony's HUD churn. updateAI is PLAY-only and _mom is consumed only there (_momTackle / _momDwell), so the move is behavior-neutral. Equal-behavior proof: the T5-2 deterministic seeded demo sim is bit-identical old-vs-new across 5 seeds × 10 800 steps (ball x/z to 4 dp, score, passes, shots all match) — the refactor changes nothing observable. Smoke 224→225 (consts hold their exact pre-refactor values + the gate still gates: inside-firm = shot, outside/weak = no shot; _mom computed by updateAI from the scoreline), validated to FAIL on a pre-refactor build. Tier 1 complete — all 8 feel rows now live on the deterministic A/B gate.CFG.stamina). Outfield running speed now sags ~5% over 4 goalless minutes (linear, clamped at 0.95) and resets on every goal — a grind gets heavier and a fresh burst follows a strike, making clutch/momentum land. Symmetric — the AI's run speed + the user's sprint fade the same; the keeper is excluded (its dive math is separately calibrated). _stamT0 is stamped at kickoff and each goal. Two A/B-driven tuning calls: excluding the GK flipped a tired-keeper goal inflation (1.27×) into a grind (0.69× at −8%), and softening −8%→−5% landed it as a genuinely light arc — passes 0.86×, goals 0.98× (flat), poss skew Δ0.3 pp → HOLD. Smoke 223→224 (staminaMul fades 1.0 → 0.975 → 0.95, clamps, resets on a goal; flat flag-off); validated to FAIL pre-fix. A/B-able / instant revert.CFG.slotStyle). Every outfield slot now carries a subtle nation-seeded character — a ±5% pace spread + a ±4% finishing lean — hashed from nation.id + shirt number (slotBias, the same FNV pattern as the star call-signs), so a nation's №4 differs from its №8 and it stays stable across replays / shares / dailies (never Math.random). The fastest outfield slot per side is flagged ⚡ on the walkout lineup (the star already carries ★). Symmetric — both teams. Pace lands in setTeam (maxSpeed = baseSpeed × (1 + pace·0.05); the controlled player's speed is untouched — setUser re-derives it after); finishing lands in kickBall (like the star's +6% venom). Deterministic A/B: HOLD — passes 0.97×, goals 0.82× (a mild leveling within tolerance), poss skew Δ-1.6 pp (more balanced). Smoke 222→223 (slotBias stable per slot, differs by slot + nation, bounded; maxSpeed moves by slot flag-on, = baseSpeed flag-off); validated to FAIL pre-fix. (Completes T1-6 with the run variety from #242.) A/B-able / instant revert.CFG.runVariety). A striker's forward run was always "forward + opposite wing" — one predictable pattern. Now at arm time it commits to one of three runs by carrier geometry (deterministic, so it doesn't jitter type mid-flight and replays stay stable): a near-post dart (carrier wide → attack the near post), a pull-wide (carrier central/advanced → stretch to the far touchline), or a third-man (a straighter central run in behind). Deterministic A/B: HOLD, and a clean win — passes 1.21×, goals 1.20× (varied runs create more attacking options + chances), shots 1.01×, poss skew Δ2.3 pp; no tune needed. Smoke 221→222 (an armed run targets three distinct channels flag-on, one legacy target off); validated to FAIL pre-fix. (T1-6 is split — per-slot nation identity ships next as T1-6b.) A/B-able / instant revert.CFG.gkParry). A keeper saving a fresh shot used to just body-bounce it (a dead reflection). Now a slow shot (<11 m/s inbound) is caught clean (gathered, dead), but a fast shot is parried out + wide into a scatter zone — a loose 50/50 in front of goal → the rebound chaos the highlights reel already films. The direct save/score outcome is unchanged (a saved shot is still saved), so the calibrated GK-balance gate holds (corners still score, weak/central still saved, Legend gates intact). Deterministic A/B caught v1 parrying only 2.5–5 m out — onto the attackers' feet — for a possession skew of Δ5.3 pp (REGRESS); clearing it far up the pitch + wide (6–10 m) made it a real 50/50: passes 0.94×, goals 1.00×, shots 1.14× (more chances), poss skew Δ4.0 pp → HOLD. Smoke 220→221 (fast shot leaves out + wide, slow shot caught dead, vs the flag-off body-bounce); validated to FAIL pre-fix. Live QA: fast shot → loose ball at 9.2 m/s out+wide, slow shot → dead catch. A/B-able / instant revert.CFG.diffTackle). Your core tackle-win chance was a flat 0.5 regardless of difficulty, while the opponent's already scaled 0.12→0.42 across tiers. Add a per-tier DIFFS.tackleU (0.6 Doze → 0.52 Dream → 0.46 Fever → 0.42 Legend) and read it in userTackle, so winning the ball is harder on Legend and more forgiving on Doze — symmetric with the opponent. Flag OFF = the flat CFG.tackleUser. User-only verb, so the deterministic A/B harness confirmed a trivial HOLD (off/on identical, zero AI collateral). Smoke 219→220: force 400 midfield tackles per tier (seeded via T5-2) — flag ON gives Doze ~0.6 wins vs Legend ~0.42 (scales), flag OFF flat; validated to FAIL pre-fix. Live QA: Doze 0.593 vs Legend 0.403 flag-on, flat 0.468 both flag-off. A/B-able / instant revert.CFG.boxTackleGate). A whiffed committed tackle in your own box used to be a guaranteed penalty — which taught players to never challenge in the box (the AI only concedes off a rare desperate lunge; the old "symmetric" comment wasn't). Now it's probability-gated (boxFoulP ≈ 0.35): defending is viable while a mistimed lunge still has real teeth. Flag OFF = the old deterministic spot-kick. User-only verb (userTackle), so it doesn't touch AI-vs-AI — the deterministic A/B harness confirmed a trivial HOLD (off/on identical, 1.00× across passes/goals/shots, Δ0.0 pp), i.e. zero collateral. Smoke 218→219: force a whiff in the box 400× (seeded via T5-2 for a deterministic count) — flag OFF concedes ~always, flag ON ~35%, clearly gated; validated to FAIL pre-fix. Live QA: 157/400 spot-kicks flag-on (~39%) vs 400/400 flag-off. A/B-able / instant revert.CFG.plantTurn). Robots used to snap-reverse at full speed like they were weightless. Now a robot sprinting hard (>4.5 m/s) that demands a >120° reversal plants the feet for ~0.13 s — decelerates and reorients slowly — before committing to the new direction. Sells mech weight on the dramatic beat; a snap-cut is no longer free, so it touches dribble evasion. *A broad speed-proportional yaw cap on all fast turns was tried first — the deterministic A/B gate (#237) showed it dampened every player's receiving/reorientation and cut passing 0.74× / goals 0.77× (REGRESS); dropping it and keeping only the rare hard-reversal plant HOLDS: passes 0.88×, goals 0.82×, possession Δ-3.1 pp (more balanced) — reproducibly. Smoke 217→218* (a fast 180° reversal reverses slower + engages the plant timer vs the flag-off snap; validated to FAIL pre-fix). Live QA (real WebGL): the plant fires (velX −3.83 vs −5.36), a demo match runs finite. A/B-able / instant revert.seedRNG/unseedRNG install a deterministic mulberry32 PRNG as Math.random (the whole sim's randomness flows through it). Full determinism also needs seeding before construction (Players randomise phase/etc. in their constructors) and freezing the attract loop at boot (an uncontrolled, wall-clock-dependent number of frames would advance the sim first) — a ?abseed=N URL param does both. Production is completely untouched: no param → native RNG + the normal live loop. The A/B harness (test/ab-harness.mjs) was rewritten around it with common random numbers (each seed played flag-off and flag-on) + a determinism self-check — it converges in ~10 seeds instead of ~20 noisy runs. What it caught: the now-deterministic gate re-scored the live #236 soft-touch at passes 0.68× — where its noisy 0.92× HOLD had been one draw of a high-variance distribution. The sole AI-side lever is the cushion (the kick floor is decoupled at 0.3, bestPassMate is user-only), and a dead-stopped cushioned ball sat contestable at the receiver's feet. T1-1 tune: the cushion now plays the ball a step into the receiver's stride (+2.6 m/s along their heading, deaden 0.5) so their close-control magnet collects it before it's nicked — a more realistic first touch. Re-gated deterministically: passes 0.68×→1.06×, goals 1.24×, shots 1.08×, possession Δ2.9pp → HOLD (reproducible, not a lucky draw). Smoke 216→217 (seeded-RNG-stream contract; the cushion test now also asserts the ball carries into the receiver's heading — validates-to-FAIL vs the old dead-stop). Live QA: no ?abseed → game boots/plays normally, native RNG; the cushion plays into stride live.CFG.softTouch; first A/B-gated feel row). Every touch used to be a ≥8.5 m/s ping — no soft touch, no cushioned receive. Three mechanics: (1) the kick floor scales 4→8.5 m/s with power (saturating at 0.3 — the AI's pass-power clamp — so AI passing and every shot are untouched; only genuinely light touches / short passes / give-and-gos soften); (2) a cushioned first touch — a legitimate receiver (the intended pass target / carrier / user) facing an inbound ball deadens it ~55% so it settles at the boots instead of caroming away; (3) bestPassMate near-floor 2→1.2 m so a give-and-go inside 2 m has a target. Shipped through the Tier-1 A/B gate: a new pooled harness (test/ab-harness.mjs, reusable for every feel row) runs flag-ON vs baseline AI-vs-AI and compares pass volume / possession / goals. The first run REGRESSED (passes 0.54×) — an unrestricted cushion turned lane clips into free interceptions — so the cushion was gated to a real receiver; the decisive 20-run / 4000-sim-second pool then HELD: passes 0.92×, goals 1.25×, shots 1.07×, possession Δ-0.6 pp, finite, clean. Smoke 213 → 216 (kick-floor softness + high-power identity; cushion deadens a facing receiver's inbound; bestPassMate 1.5 m target), each validated to FAIL pre-fix. Live QA: a power-0 kick leaves the boot at 3.68 m/s vs 7.82 hard, full-power shots identical, a demo match runs finite. A/B-able / instant revert.endMatch then sits ~1.6s of dead air on the celebrating players before the highlights reel / result panel reveal fires — with nothing on screen naming the moment. Fill it with a brief broadcast "FULL TIME" / "全场结束" card over the shared #msg slot (the same showMsg overlay path the countdown / corner / penalty tableaus use — no new DOM node). dur 1400 < the 1600ms reveal timer, so it clears before the reel/result and can't collide with them. Overlay-only (no gameplay/state change), once per match, Watch mode included, behind CFG.fulltime (A/B-able / instant revert). New i18n key fulltime in both en + zh, rendered via T() at call time. Smoke 212 → 213 (the key resolves in both langs; endMatch calls showMsg with the localized FULL TIME text flag-on and stays silent flag-off; endMatch runs clean with no console error); validated to FAIL pre-fix via negative control. Live QA (real WebGL): endMatch → #msg carries "FULL TIME" with the show class (asserted on the DOM text — a mid-dead-air screenshot races the auto-advance to the next round, so the DOM assertion is the reliable proof)._nanSweep() strides every 8th particle ~1×/s, so a blow-up anywhere is caught within ~1s (the cheap per-frame particle-40 guard stays as the fast common case). (b) Every robot built its own selection-ring TorusGeometry + direction-cone ConeGeometry (10 identical geometries/match) — hoisted both to the shared ROBO.mark cache (roboMark(), same lazy pattern as roboGeo); materials stay per-instance (so setUser still recolors only the controlled robot's ring), and the shared geo is never disposed per-player. (c) whistle() scheduled its peep sequence with setTimeout, which drifts/bunches under main-thread load — every peep now rides the AudioContext clock via blipAt (which gained an optional trailing slideTo to keep the whistle's chirp; anthem callers unaffected). Live QA (real WebGL): ring renders on the controlled robot with shared geometry, net self-heals, whistle(3) schedules 3 peeps with no setTimeout. Smoke 209 → 212 (net self-heals from a non-40 NaN with the solver neutralised; all robots share one ring + one cone geometry by identity but keep distinct materials; whistle routes through blipAt); all three validated to FAIL pre-fix via negative control.challengeURL() built its team/world/diff params — and the &r score payload's team name — from whatever's selected now (userTeam/worldSel/diffSel), not from the match that produced the result. So win 3–1 as Spain on Mars / Hard, flip the menu to Moon / Easy, hit share, and the link challenged a friend to team=…&world=moon&diff=0 — a scenario that never happened. Fix: freeze the scenario at full time — endMatch's _lastResult snapshot now also captures diff:diffSel (it already froze you/world), and challengeURL derives team/world/diff (and the &r short-name) from _lastResult when a result exists, falling back to the current menu prefs only for pre-match sharing. Live QA (real WebGL): result Mars/Hard/Spain, mutate to Moon/Easy, link stays team=ESP&world=mars&diff=2&r=3-1-ESP. Smoke 208 → 209 (play to a result, mutate prefs via real menu clicks, assert the link still encodes the result's scenario + &r decodes to 3–1); validated to FAIL pre-fix via negative control.releaseKick checked userAerialEligible before the tap-pass branch, and userAerialKick floors power at 0.56 toward goal — so a light tap meant as a pass near an airborne ball became a committed goal-bound volley, with no way to bring the ball down. New userHeaderPass(): on a low release (the same <0.38-power, not-aim-at-goal gate as the grounded tap-pass) it plays a cushioned header/lay-off toward bestPassMate (pass speed + a soft loft; power < 0.5 so it can't bike); a full-power release still volleys/bicycles. Pure input-intent routing — it adds the ability to pass an airborne ball, doesn't change the volley. Live QA: light tap → 15.5 m/s pass, full release → 28.3 m/s volley. Smoke 207 → 208 (low release passes toward a mate with no volley marker; full release keeps the volley marker; flag-off unchanged); validated to FAIL pre-fix.DIFFS.aiDwell for both teams, but that's an opponent-side dial — so your own outfielders were sluggish on Doze (mean 0.5s before a kick) and sharp on Legend (0.12s), contradicting the documented "your mates always play competently" contract. New _dwellBase(p): your outfield uses a fixed competent 0.14s dwell regardless of tier; the opponent keeps its DIFFS value; Watch mode (AI vs AI) is unchanged. Owner note: this is a your-team-tempo change on the easier tiers — a contract restoration, and 0.14 is an easily-tunable judgment call (competent, ≈ the sharp end, never a cheat). Smoke 206 → 207 (your dwell constant across tiers, opponent's varies); GK-balance/nets-sleep/possession assertions unaffected; validated to FAIL pre-fix.MC1. career code did m = {...inc}, taking the incoming milestone set verbatim and dropping this device's earned milestones. Threshold badges (10/25/50/100 goals, cups) re-derive on the next check, but leg1 (Legend champion) is event-only and unrecoverable — a Trophy-Room import silently wiped it (and briefly dropped Cup Rating). Every other career field was carefully merged; miles was the one omission. Now unions both sides, and only real MILESTONES keys survive (a crafted code can't smuggle a junk badge into the honours strip). Smoke 205 → 206 (import-lacking-leg1 keeps the device's leg1; device g10 kept; incoming g25 merged; hostile key dropped); validated to FAIL against the pre-fix build.NoToneMapping (restored on success AND the catch path) and graded once in the composite with three's exact chunk math (Hill ACES fit incl. the /0.6 exposure factor + real LinearTosRGB). Same-frame QA: bloom now adds +7.6 luma (the glow) instead of darkening. (2) adaptPerf's DPR rung now frees the bloom render targets (~20–30MB previously held on exactly the machines that degrade; lazily rebuilt after heal). (3) The resize handler re-clamps setPixelRatio when the window moves monitors / browser-zooms (respecting an active degrade hold). Smoke 204 → 205 (shader grade shape, toneMapping restore on success+throw, dispose-on-degrade, resize re-clamp ± degrade hold); validated to FAIL against the pre-fix build on exactly the three functional checks.ICULTY…) and the last pill off the right (Leger / Attackin), with no horizontal scroll to recover them. The row-wrap breakpoint was only max-width:340px; raised to 480px so the whole phone class wraps + uses the compact pills. Landscape phones (width 844) keep their 2-up grid. Regression: a 390px setup-row clip assertion (the old 320px test didn't cover the broken 341–480 range). Smoke 203 → 204.MONETIZE.feedbackUrl). Set the dormant feedback hook live: the menu-footer Send feedback / 反馈建议 link now opens the new public repo GutaInc/mechcup-feedback (issues on, bilingual README + template) in a new tab (target=_blank rel=noopener). Keeps the game's own source private. No in-app text capture, no backend — just a link. (Inert since #220.)CFG.jumbotron). On a phone the single-line scoreline (FRA 2 – 1 ARG) overflowed the panel and clipped to RA 2 – 1 AR. Split it into two lines — tri-codes (FRA – ARG) then the score (2 : 1, large) — and every line now draws with a maxWidth so the canvas text auto-scales to the panel and can never clip, on any screen or tri-code width. Also reverted #223 (portal languages pt-BR + es) at the owner's request — no native reviewer available yet; the translations stay in git history for when one is.CFG.bloom). A self-contained screen-space bloom: the scene renders to a target, a luminance threshold pulls the bright emissive highlights (goal flare, star trails, jumbotron, net + pitch-line glow), a separable gaussian blur softens them at half-res, and an additive composite lays them back — re-applying the renderer's ACES tonemap + exposure + sRGB so the base image matches the direct-render path exactly (no colour shift). r149 ships no UMD post-processing addons, so it's inlined with plain THREE — no new files, so no CSP or bundle change beyond the shader strings. Hard perf-gated: desktop-class only (!TOUCH) and only while the autoscaler hasn't degraded (!dprLow && degraded===0) — phones and struggling machines never pay; lazy-built after boot (time-to-playable unaffected), RTs rebuild on resize, and any init/render failure falls back to the plain renderer. prefers-reduced-motion safe (static, no pulsing). Flag OFF = today's direct render. Size baseline bumped 290700 → 304400 consciously (this ships the accumulated Phase-4 growth + the bloom shaders). Smoke 202 → 203 (the gate is desktop-class + non-degraded only, off on dprLow/degraded/flag; the addon-free composer builds and renderBloom is a safe boolean). Live-verified: bloom glows the pitch lines / net / text with correct colours, and the base image is unchanged with it off (with/without screenshots). The look (threshold/strength/radius) is a taste call flagged for owner review.CFG.unlocks). An honest, cosmetic-only progression — no pay-gating, no loot boxes, no dark patterns. A five-colour ball-comet-trail palette that unlocks by playing: Aqua is free; Gold (5 career goals), Nova (5 matches), Ion (20 goals) and Ember (1 cup win) gate on the metrics the game already tracks (CAREER + dreamCupStats — no new grind). The picker lives in the Trophy Room; locked swatches show their honest requirement ("Score 20 goals"), never a purchase. The pick persists (localStorage) and recolors the CFG.ballTrail comet in-match (visible in shares) by rewriting the existing vertex-colour buffer — no new assets. i18n en+zh. Flag OFF = the default aqua trail, no picker. Smoke 201 → 202 (default is free; others gate on the right thresholds; selecting an earned trail through the real picker applies it + persists; flag-off = aqua); validated to FAIL against the pre-feature build. Live-verified: unlocking Gold via career goals then selecting it recolors the comet head to exactly the gold RGB. The economy design (which trails, thresholds) is a taste call flagged for owner review.MONETIZE.feedbackUrl). A bilingual "Send feedback" link in the menu footer, shipped INERT: it stays hidden until the owner sets a destination URL (a Google Form / GitHub issues / mailto:), mirroring the existing dormant MONETIZE hooks (tip / hire-me / QR). When configured, applyMonetize renders the link (+ its separator) opening the URL in a new tab (target=_blank rel=noopener). No in-app free-text, no new endpoint, no PII — it's just a link to an owner-provided destination. i18n en+zh (Send feedback / 反馈建议). Empty URL = nothing renders. Smoke 200 → 201 (hidden when the URL is empty; renders with the right href + target/rel + a localized label when set); validated to FAIL against the pre-feature build. Live-verified: inert by default, appears pointing at a test URL when configured, re-hides when cleared.CFG.funnel). Completes the drop-off funnel on top of the existing track() instrumentation: adds menu_view (once per menu visit) and first_goal (your first goal in a match, once) to the already-fired match_start → match_end → rematch / share. Each per-match step is deduped (fires at most once — a second goal or an opponent goal never re-fires first_goal; demo/Watch is excluded), forwarded to window.gtag and the dreamCupStats localStorage counters exactly as today. No PII, no new endpoint, no UX change — pure instrumentation. Flag OFF = the two new steps stay silent (existing events unchanged). Smoke 199 → 200 (first_goal fires once per match and not on an opponent goal; menu_view once per menu visit; both bump the counters + a stubbed gtag; flag-off silent); validated to FAIL against the pre-feature build. Live-verified: two goals → one first_goal, gtag receives both.CFG.jumbotron). A big screen floats above each goal showing the live scoreline (e.g. "FRA 2 – 1 ARG") via a CanvasTexture — so the score rides along in every screenshot / clip / photo / share, not just the HUD. Two unlit planes (one behind each goal, sharing one texture) sit within the down-tilted play cameras' cone so whichever end you're attacking has the board in frame; unlit so they read day + night. The texture is redrawn only when the scoreline changes (a dirty-key guard driven from syncScore — no per-frame cost, no boot/DreamCupPerf regression). Built once in DreamEnvironment; _buildJumbo self-gates on the flag. Score/tri-codes/number only (no tournament-brand copy). Flag OFF = no jumbotron. Smoke 197 → 198 (the pure scoreline formatter is correct; the screen object exists on the env flag-on; the texture redraws only on a change, not on an unchanged re-set; construction is flag-gated); validated to FAIL against the pre-feature build. Live-verified in isolated WebGL Chrome: the board shows the scoreline above the goal and updates on a goal (screenshot captured).CFG.knockOn). While carrying, tap G (or the gamepad d-pad down) to poke the ball into the space ahead and burst onto it — a knock-and-chase to beat a jockeying defender. It reuses kickBall's just-kicked free window (so the ball runs past your own cylinder instead of sticking to your boots) and the skill-move burst (_burstT — real pace + tackles slide off via _evadeMul), on a 2.2s cooldown so it's not spammy or a free teleport — a mistimed one runs the ball to a defender, so it's a risk-reward move, not a cheat. Normal dribbling, shooting, and the existing binds are untouched; the new key doesn't collide. en+zh (Knock-on! / 趟球过人!) with a line in the advanced control legend. Flag OFF = no knock-on. Smoke 196 → 197 (fires while carrying — ball pushed forward + burst set — cooldown-gates an immediate second call, flag-off = no knock-on + ball untouched); validated to FAIL against the pre-feature build. Live-verified: the ball pushes forward, the burst arms, 40 frames run clean.CFG.userAerial). You can finally meet an airborne ball first-time, like the AI already could: release a shot while a genuine dropping ball is in leaping range and it becomes a first-time volley/header, or a BICYCLE KICK on a high one. userAerialEligible() mirrors the AI tryAerial gate (strike height, in range, not a screamer) so it's satisfying but not spammy, and the strike is routed through kickBall's existing CFG.aerial pose branch (backflip / header lunge). Checked before the grounded y<2 gate, so grounded shooting, passing, and the goal trigger are untouched. Works across keyboard / touch / gamepad (all release the same shot). Flag OFF = today (no user aerial). Smoke 195 → 196 (eligible false for a grounded/at-rest or out-of-range ball, true for an in-range dropping ball; a shot on an eligible high ball starts the bicycle pose; flag-off = no aerial + grounded shot unaffected); validated to FAIL against the pre-feature build. Live-verified: the bicycle pose fires, the ball launches, 40 frames run clean.CFG.natStyle). Each nation now plays the opponent side with a light, distinct lean — forward-run intent (runFreq), press aggression (press), and carry directness (tempo) — keyed by stable nation id in a NAT_STYLES table (a recognisable subset flavoured; every unlisted nation gets the neutral _default). The multipliers are subtle and bounded (~0.9–1.15) and layer on top of the DIFFS row — they don't replace difficulty. Read through oppStyleFor(p), so your team and every keeper always get the neutral default: it reshapes the AI opponent only and never touches GK balance. A Brazil now presses and attacks differently from a Morocco. Flag OFF = today's uniform behaviour. Smoke 194 → 195 (bounded map + neutral default incl. unlisted ids; oppStyleFor flavours the opponent outfield only, distinct by nation, your team + GK neutral, flag-off uniform); validated to FAIL against the pre-feature build. Live-verified: 40 frames of the flavour path run clean.CFG.stances). A Tactics row on the menu (Defensive · Balanced · Attacking), picked and persisted, reshapes only your outfield AI: Attacking pushes the line up + more and longer forward runs, Defensive drops deeper + fewer runs, Balanced = today's behaviour. Pure per-stance multipliers (push/depth/run) read through teamStance(p) — the keeper and the opponent are never reshaped (the opponent still plays its DIFFS row), so it's a self-side identity, not a difficulty cheat. en+zh. The menu height tiers were tightened for the 6th setting row so the CTA stack still clears the fold at 1280×720 and 844×390. Flag OFF = balanced/today. Smoke 193 → 194 (STANCES ordered def<bal<atk with an inverse depth + neutral balanced, teamStance reshapes your outfield only, the pick persists); validated to FAIL against the pre-feature build. Live-verified: dial applies + persists on click, reshapes your outfield only, opponent/GK stay balanced.CFG.rumble). The pad now feels the match: a firm buzz on a goal (0.9 × 260 ms), a short tap on a tackle, a light bump on woodwork, and a tick on the full-time whistle — routed through one feature-detected rumble(strength, ms) helper that pulses the Gamepad vibrationActuator (dual-rumble) and clamps the magnitude to [0,1]. It's a safe no-op end to end — no flag, no getGamepads, no actuator, or no pad all just return false — so the game boots and plays identically without a controller. Called at the same event sites as the SFX. Flag OFF = no haptics. Smoke 192 → 193 (the helper fires the actuator with sane clamped args when flag-on + an actuator is stubbed, and is a no-op flag-off / no-actuator); validated to FAIL against the pre-fix build. Live-verified: boots clean, no-op with no pad, fires with a stubbed actuator.CFG.scoreToBeat). Your result now rides the shared challenge link: challengeURL() appends &r=A-B-name, and a friend who opens it sees a "⚔ Beat X's 3–1" banner on the daily/friend card — a reason to one-up the score. Beating or missing the target updates a local head-to-head ledger (localStorage) and prints a note on the end screen (⚔ Target beaten! / ⚔ Fell short). Built on the existing challengeURL()/parseChallenge() — the &r= param is optional and pure-encoded/decoded, so old links are untouched (beatsTarget ranks by goal-difference, then goals). i18n en+zh. Flag OFF emits no &r and shows no banner. Smoke 191 → 192 (encode/decode round-trip, beatsTarget ranking, ledger persistence, &r emitted only flag-on + a result); validated to FAIL against the pre-fix build. Live-verified: ?…&r=3-1-BRA renders "⚔ Beat BRA's 3–1". Size baseline consciously bumped 275390 → 290700 B — the accumulated Phase-2/3 features (locomotion, trail, spatial audio, blob shadows, celebrations, streak, this) crossed the old cap; this PR is where it tipped.CFG.dailyStreak). The daily challenge now builds a habit: playing today's daily keeps a consecutive-day streak alive (localStorage), and the daily banner shows a 🔥 N-day streak chip plus a "next daily in HH:MM" countdown to local midnight. It also fires track('daily_start') for the engagement funnel. The streak math is a pure function — a consecutive day is +1, a gap resets to 1, replaying the same day is idempotent — and it also keeps a best streak, distinct days, and wins. Data + a small banner chip, i18n en+zh, no RAF cost. Flag OFF restores the prior behavior. Smoke 190 → 191 (streak math across consecutive/gap/ same-day, localStorage round-trip, flag-off inert, HH:MM countdown); validated to FAIL against the pre-fix build. Live-verified: the banner renders "🔥 5-day streak · next daily in …". First Phase-3 (depth/retention) row.CFG.celebrations, completes Phase 2). The most-watched 3.8 seconds in the game — the moment photo and share cards capture — was a single sine-loop bounce. Now the scorer plays one of 4 procedural celebrations (arms-up sprint · knee-slide · spin · fist-pump) picked deterministically from (shirt number, minute) so a replay or a re-opened share card always shows the same one; the scorer's teammates converge and mob them; and the conceding keeper slumps. All on the existing celebrating flag + pivot rig, in the GOAL celebT window — no new state. Flag OFF restores the single legacy bounce. Smoke 189 → 190 — the pick is stable and varied (0..3), teammates' distance to the scorer shrinks over the celebration, the keeper's slump timer arms, and flag-off assigns no roles; validated to FAIL against the pre-fix build. Live-verified: scorer celebration index 3, keeper _slumpT 2.23, mob distance 73.8 → 69.8, screenshot of the scorer mid-celebration under the GOOOOAL! banner. This is the last of the 5 Phase-2 production-values features (#204–#208); Phase 2 is complete.CFG.blobShadow). A soft dark radial sprite now sits on the grass under every robot and the ball, tracking its xz so the entities read as grounded instead of hovering. Crucially it's independent of the real shadow map, so it survives the quality autoscaler: when adaptPerf turns shadowMap.enabled off on a struggling phone (the F-E ladder), the blobs stay — grounding is preserved on exactly the devices that lose the mapped shadows. Cheap: one shared radial texture + flat geometry + material, one lightweight mesh per entity, built once. Flag OFF removes them. Smoke 188 → 189 — each robot's blob tracks its position, stays visible after shadowMap.enabled=false, and vanishes with the flag off; validated to FAIL against the pre-fix build. Live-verified: 10 player blobs + the ball's, tracking, all 10 still visible with the shadow map disabled.CFG.spatialSfx). The WebAudio synth bus gains a sense of space. Positional voices (kicks, tackles, ball/net hits) route through a shared StereoPanner set each frame to the ball's pitch-x — so a play on the right of the pitch is heard on the right (the follow-cam looks down the pitch, so pitch-x ≈ screen L/R). And the crowd murmur now feeds a procedural convolver stadium room (a generated decaying-noise impulse response) for a subtle reverberant tail. Cheap (one panner + one convolver, built once at audio init); flag OFF restores the prior dry mono mix, and it boots fine with no AudioContext (headless). Smoke 187 → 188 — a pure pan map (+x→right, −x→left, clamped) plus a graph/routing check that runs where a real AudioContext exists; validated to FAIL against the pre-fix build. Live-verified: panner + convolver wired, pan ±0.66 for a ball at ±25 m, spatial-on routes through the panner / flag-off stays dry, no console errors.CFG.ballTrail: a driven ball (>12 m/s — shots and zipped passes) now leaves a short comet streak, sampled straight from the existing goal-replay ring buffer (_trailBuf, ~zero new state), colours fading head→tail via additive vertex colours; it hides on a slow ball and clears on a restart. (2) CFG.broadcastFx: the replay/HILITE reel gets cinematic letterbox bars that slide in while it plays (retract when it ends) plus a wipe stinger on each clip cut. Both are prefers-reduced-motion-gated (reduced motion skips the trail and the bars/wipe) and flag-off restores the prior look. Pure per-frame reuse + DOM/CSS, nothing heavy in the RAF loop. Smoke 186 → 187 (fast ball → trail visible; slow/flag-off → hidden; reel → letterbox on + wipe on cut, off on flag/end); validated to FAIL against the pre-fix build. Live-verified: comet spans ~4.5 m on a 22 m/s ball; letterbox + wipe active over the reel.CFG.locoLife). Three motion-quality touches on the existing pivot-limb mech rig, the biggest feel-per-byte jump in the plan. (1) Head look-at: the head now sits on a neck pivot and tracks the ball (capped ~±50° so the neck doesn't over-swivel) — the robots watch the play instead of staring straight ahead. (2) Bank into turns: the body leans into a turn, roll proportional to smoothed angular velocity × speed. (3) Distance-locked stride: the step cadence now advances with ground distance travelled, not elapsed time — so a stationary or slow robot no longer moonwalks (feet cycling without moving). Pure per-frame rig math, no new allocations; the neck pivot reparents the head cluster at identical world positions, so flag-off is byte-for-byte the prior motion. Smoke 185 → 186 (head yaw tracks the ball's side; a stationary robot's stride stays frozen under the flag but advances with it off); validated to FAIL against the pre-fix build. Live-verified: head −0.90 / +0.90 rad toward a ball on the left / right, body bank 0.15 through a turn.RUN TO THE BALL, etc. — rendered with dur=0) share the single #msg slot with every transient toast, so any interrupting toast (a counter callout, a woodwork clang) cleared the tutorial for good — _tutShown stayed true, so updateTutorial never re-showed it. Now when a transient toast expires mid-tutorial (steps 0–1) it re-arms the live step, so the prompt comes right back. (2) The Simple control legend hid that Space (⚡ on touch) near a carrier is a committed tackle — a mistimed one in the box concedes a penalty. Added that row to the Simple keyboard and touch legends, en + zh (it was only in the Advanced/PES legend). No CFG flag (a fix). Smoke 184 → 185; validated to FAIL against the pre-fix build (tutorial stayed gone, legend rows absent).#ff6b5e/#9fb4ff (red/blue) that inverted the default France-vs-Paraguay kits and had no color-blind redundancy — the two dot colors now derive from the active kits at startMatch (after the kit-clash swap, so they match what's on the pitch), plus a shape cue (home = filled dot, away = hollow ring) so the sides read without relying on color. No CFG flag (a fix). Smoke 183 → 184; validated to FAIL against the pre-fix build (zhPor:"葡萄", miniTracksHome:false).role="dialog" + aria-modal + aria-labelledby (pointing at their existing bilingual titles). (2) Focus moves INTO a dialog on open (it used to drop to <body> because the opener goes inert) — a new _focusInto() moves focus to the Close button, working around the .panel inert-observer + the display/visibility restore that both land a microtask later. (3) Action outcomes are announced: #actFx is aria-hidden (decorative), so mute, gamepad-connected, and career import ✓/✗ now also write to a new role="status" live region (#srStatus) — decorative flashes (SWITCHED, TACKLE!) stay silent. (4) Removed the stale aria-pressed="true" on #cardA (it opens a dialog, it's not a toggle). (5) The full-screen goal flash() now respects prefers-reduced-motion, matching the already-gated shake/FOV. No CFG flag (a11y fixes). Smoke 182 → 183; validated to FAIL against the pre-fix build. Focus-into-dialog verified in a real browser (headless focus is unreliable, so the smoke probes the target).index.html. (1) Ten feature-flag doc comments (photo, intro, setPieces, momentum, userKeeper, weather, cinema, motifs, highlights, clutch) had been concatenated onto a single 3,890-char line next to commentary, while their actual flag declarations sat bare — each comment is now back on its own flag's line, so the CFG block reads cleanly and diffs sanely. (2) Removed the stale "TODO ideas for the next dreamer" block — nearly all of it (penalty shootout, gamepad, player switching, through-pass, cup persistence, per-world themes) shipped long ago; the live backlog is docs/plans/. Zero behavior change, proven: the minified bundle is byte-identical before and after (terser strips comments). 182/182 green. This is the last of the 8 UltraCode Phase-1 fixes (#188 XSS, #189 CSP, #191 Legend shootout, #192 input lifecycle, #193 autoscaler, #194 share delivery, #195 gameplay state, #196–#200 harness/CI/hygiene).deploy.yml had no permissions: block (inheriting the repo default) — pinned to contents: read (it only reads the repo and deploys to Cloudflare via a token, never writes back to GitHub). (2) persist-credentials: false on both checkouts, so the GITHUB_TOKEN isn't left in .git/config for a later step to exfiltrate (nothing pushes back; the preview comment uses the token via env). (3) Verify PRODUCTION, not the build we just uploaded: the deploy's verify step polled the immutable per-deploy pages.dev URL — tautologically the artifact just pushed, proving nothing about the alias players hit. It now polls mechcup.com/version.json (90s budget for propagation), so a deploy is only "green" once production actually serves the commit. (4) Size gate measured a stale build/: the growth-debt gate read the committed build/index.html, which a source edit without a rebuild leaves stale (bloat slips through). It now measures a fresh temp build (deterministic, and never clobbers the deployable build/). (5) npm run check ended in || true, swallowing syntax-check failures — removed, so a broken bundle now fails the script. YAML validated; the size-gate + check fixes negative-controlled (a fresh 279KB build measured; a broken bundle now exits non-zero). The workflow changes validate on this PR's own merge.onGoal → celebration → the golden branch calls endMatch (stubbed like the shootout test) with golden still true and the scorer ahead. (2) Soak timeScale-leak class: the soak asserted termination + finite physics but not that a dive/celebration slow-mo restores — a leak would run the sim slowed forever. The soak now tracks the longest continuous slow streak and fails if it exceeds 400 frames (legit slow-mo ≤ ~234; a leak is unbounded). (3) teamAI flake: the contested- loose-ball guard asserted the worst of 6 random seeds clears > 8m, but a cleared ball's healthy spread is 5–24m+ and it flaked at 7m on a deploy; lowered to > 4.5m (the freeze bug parks at ~2.7m, so 4.5 cleanly separates freeze from clear without failing on variance). Smoke 181 → 182; the two new assertions validated to FAIL (golden ended:false with the branch broken; soak maxSlow 14400 with a forced leak).true instead of its captured original — which, if a feature's default were ever flipped to false to A/B-revert it, would silently force it back on and mask the revert (and test the wrong config). Rather than hand-edit 25 restores, this adds a single suite-level enforcement: snapshot CFG at boot, and assert at the end of the run that it is byte-identical — any test that leaks a flag (hardcoded restore disagreeing with the real default, or a forgotten restore) now trips the gate with a precise flag:before->after diagnostic. Self-enforcing forever, strictly stronger than trusting 25 hand-written restores. Smoke 180 → 181; validated to FAIL by leaking a flag after the tests (leaked: [motm:true->false]). No parity gap surfaced — CFG is clean.I18N under both en and zh; a one-language key renders undefined for half the players, and the suite had no gate. Exposes I18N on DreamCupTest and asserts the two key sets are identical (240 keys each — already in parity, so preventive). Smoke 179 → 180; validated to FAIL by injecting an en-only key.soAIKick reset _soKeeperCommitted, wiping a human keeper's pre-kick commit — so you could dive early and get a second late reflex stab (two saves for one kick). It now preserves a committed dive. (2) Aim latch couldn't re-center: the shootout aim only updated on |stick|>0.12, so once flicked it stuck — you couldn't bring the aim back to center. A touch stick (finger down) now tracks continuously incl. back to center; pad/mouse keep the flick-and-charge latch (the pad regression test kept me honest). (3) Phantom corner: an un-consumed byline ball._cornerSig survived into the next kickoff and fired a ghost corner; ball.reset() now clears it. (4) Tackle-pop assists: a tackle turnover set lastKicker to the tackler, who was then credited an assist if a teammate scored — contradicting #164 ("a tackle-pop is no pass"). A tackle now voids the assist chain (_noAssist), cleared by the next real kick. (5) Per-frame writes: the charge powerLbl textContent and the crowd-gain setTargetAtTime ramp were rewritten every frame; both are now change-gated. No CFG flag (bug fixes). Smoke 174 → 179 (one assertion per sub-fix); all validated to FAIL against the pre-fix build. Live-verified on build + preview.savePhoto and shareCareerCard had re-grown the silent-no-op toBlob bug that CHANGELOG #57 already fixed for shareResult: both waited on canvas.toBlob(cb), and when that callback never fires (it happens), the button did nothing — no share, no download, no toast. Both now route through one _deliverFile() core: a synchronous toDataURL capture (no toBlob dependency), a sync file-share probe (mobile → OS share sheet; desktop → immediate download), and an always-visible toast — the delivery can't silently vanish. savePhoto still returns the real PNG Blob (the photo test asserts a >10KB frame). No CFG flag (a bug fix). Smoke 173 → 174 — sabotages toBlob to never call back and asserts career + photo still toast and return a real blob; validated to FAIL against the pre-fix build (every delivery signal false). Live-verified on build + preview.CFG.autoQuality) used one degraded counter for all three fidelity rungs (DPR → shadows → nets). Two bugs fell out. (1) DPR permanently locked out after a restart. A restart keeps shadows off (re-enabling forces a material recompile hitch on exactly the machines that struggled), so the counter was pinned high — and the next slow spell jumped straight to the shadow/net branch, never dropping DPR again. Phones that had degraded once then ran full-res forever, nerfing net quality instead of the fill-rate that actually hurt. Fix: DPR is now its own rung (this.dprLow), decoupled from the shadow/net stage — so a shadows-off restart revives DPR and keeps it reachable. (2) No heal hysteresis — DPR could heal → drop → heal repeatedly (visible resolution ping-pong). Fix: the DPR rung heals once per match (_dprHealed), reset at startMatch. CFG.autoQuality (unchanged gate). Smoke rewritten (the old contract locked in the bug by asserting degraded===2 after restart) → now asserts the decoupled rung, one-heal-per-match, and DPR-reachable-after-restart; validated to FAIL against the pre-fix build. Live-verified on build + preview: DPR drops (degraded:0), heals once, no ping-pong, reachable again after restart.touchcancel anywhere: when the OS steals a touch (an incoming call, a notification, a system edge-gesture) it fires touchcancel, not touchend — so the joystick stuck full-tilt and a held kick-button charge never released. Added touchcancel on the window (mirrors the joystick release) and on the kick button (aborts the charge — a cancelled press must never fire a phantom shot). (2) Window blur cleared jockey/shield but left held keys set (your robot kept running when you tabbed back) and a live charge ticking; blur now also clears keys and aborts the charge. (3) PLAY chargeT was uncapped — a lost keyup climbed it forever (measured 10.0 after a 10s hold); now bounded at 2.2 (the CFG.ballFeel overcharge already saturates by 2.15, so the feel is unchanged while the runaway is stopped). New abortCharge() cancels a charge without firing. No CFG flag (a bug fix). Smoke 169 → 173 — abort-without-fire, blur-clears-keys+charge, touchcancel-frees-joystick, chargeT-bounded; all validated to FAIL against the pre-fix build (joystick stuck, chargeT climbed to 10.0). Live-verified on build + preview.this.diff===DIFFS[2] identity, so Legend (the hardest tier, DIFFS[3]) matched neither branch and fell to the Doze else-case — the weakest keeper (dive reach 1.7, catch radius 0.95, 38% side-read) and the least-accurate takers. The result: the game's hardest difficulty had its easiest shootout. Fixed to DIFFS.indexOf(this.diff)>=2 in both soAIKick and soKeeper so Fever and Legend share the sharpest keeper/taker. Also: a saved Legend pick with the tier flag off clamped to Medium at startup (line 1853) while the challenge-link path (1874) clamps to Hard — the pick now lands on Hard either way. No CFG flag (a bug fix). Smoke 168 → 169 — drives the AI keeper's real commit per tier and asserts Legend's catch radius equals Fever's (1.1) and beats Doze's; validated to FAIL against the pre-fix build ({doze:0.95,fever:1.1,legend:0.95}). Live-verified on the build + preview: Legend keeper catchR 1.1.CFG.careerRating): one transparent score computed from the whole career — cups (each worth more on the difficulty it was won on: a Legend cup = 220, a Doze cup = 100), career goals, assists, best streak, worlds and nations conquered, revenges, milestones, and the Triple Crown. It headlines the Trophy Room with a tier badge (Rookie → Contender → Star → Legend → Icon) and is stamped on the shareable career card, so players are rankable and comparable — share your rating, beat mine — without a backend (a true global leaderboard would need a server, which the single-file premise rules out). Pure and derived: it recomputes from the career, nothing new is persisted, so an imported career yields the right number. (2) Export career code now confirms visibly: the "copied" toast was firing into #actFx (z-index 23) behind the Trophy Room modal (z-index 50) — invisible, so export looked dead. The confirmation now shows on the button itself ("Copied ✓"), inside the modal. Smoke 167 → 168 — difficulty-weighted score, tier ladder, room + card render, in-modal copy confirmation, flag-off absent; validated to FAIL against the pre-feature build. Live-verified: a 4-cup career rated 1361 · ★ Legend, export button reads "Copied ✓"._headers now ships a Content-Security-Policy on every route plus X-Content-Type-Options: nosniff and a Referrer-Policy. The CSP is defense-in-depth behind the source-level XSS fix (#188): object-src 'none' (no plugin execution), base-uri 'none' (no <base> hijack), form-action 'none', a script/connect allowlist scoped to exactly the three Three.js CDNs + the Cloudflare/GA analytics endpoints, and — deliberately — a frame-ancestors allowlist, not DENY: the distribution plan iframes the game into itch/CrazyGames/Poki, so clickjacking is controlled without breaking the portals. 'unsafe-inline' stays for scripts because the game is a single inline <script> (hashing it would rotate every build). A new 14th build gate asserts the CSP shape (locked directives, CDN allowlist, frame-ancestors never DENY/'none', nosniff), validated to FAIL against the pre-CSP _headers. Preview-verified: the game boots and plays under the live CSP with no policy violations.innerHTML. A crafted MC1. career code (untrusted, shareable, persisted to localStorage) could smuggle "><img src=x onerror=…> in a trophy date/k or a boot/assist key, and it would execute every time the room opened. Prior hardening had closed only the numeric attack surface. Fixed defense-in-depth: a new esc() HTML-escapes every value at the sink, AND importCareerCode now shape-validates — trophies need a YYYY-MM-DD date and an [a-z0-9] key, counter maps keep only /^n\d+$/ keys (assists was merging unfiltered via the object spread). Smoke 166 → 167 — a crafted code renders inert (no injected node, no raw markup, no handler fires, hostile keys dropped); validated to FAIL against the pre-fix build (raw <img onerror> + live node). Live-verified in a real Trophy Room: 0 injected nodes, 0 executions. (A follow-up Phase-1 row adds a CSP backstop.).panel inert observer added after a previous review already covers it. Smoke 164 → 165; validated to FAIL against the pre-fix build (all five behaviors, precisely)..webm label. On iPhone (WebKit records MP4) that produced an MP4 wrongly named .webm that iOS can't open; on Mac Safari a strict recorder constructor could throw and the silent catch killed the save button entirely. Now: MP4 is preferred wherever it records (the clip exists to be shared, and phones open MP4), the constructor survives lying isTypeSupported implementations, and unlabeled WebKit blobs are labeled truthfully. Smoke 163 → 164 — MP4-first preference · fallback construction · truthful labeling → .mp4 filename; validated to FAIL against the old recorder. Live-verified: Chrome now records video/mp4;codecs=avc1 → mechcup-highlights.mp4.CFG.followCup). Elimination used to end the story mid-sentence — now the tournament finishes without you: your conqueror inherits your path, every later tie resolves by the draw's own better-seed rule, and the elimination screen closes the tale — "🇵🇾 PARAGUAY lift the cup." Deterministic and pure (cupChampion: cup save in, champion out; −1 mid-run or when the champion is you). Smoke 161 → 162 — the pure rule (argmin original slot across conqueror + path) · mid-run/champ guards · the elimination line · flag-off classic; validated to FAIL with the flag off. Live-verified on a real elimination screen.CFG.careerCard). The Trophy Room's honours get the reach treatment: a 📣 Share career button renders your whole career into the house 1200×630 card — cups, best streak, career goals and assists, the 👢 Golden Boot and 🎯 Top-assists lines, and your latest milestones — shared via the OS sheet where files-share exists, else downloaded. Smoke 160 → 161 — the frame renders the seeded honours (spied at the canvas level) · the button is flag-gated · flag-off builder returns null; validated to FAIL with the flag off. Live-verified: a 4-cup, 28-goal career rendered clean.CFG.skillHint). The stepover burst was undiscoverable in-game: now a carrier who has never burst gets one mini nudge per match ("⚡ DOUBLE-TAP a direction — stepover burst!" / "⚡ 双击方向键——爆发突破!") after ~6 s of cumulative carrying — and the moment a real burst fires, the hint retires forever. Keyboard players only (touch has no burst input); Watch mode never nags. Smoke 159 → 160 — one nudge at the threshold · once per match · a real burst retires it · flag-off silent (showMsg spied so tutorial prompts can't mask the signal); validated to FAIL with the flag off. Live-verified on screen mid-carry.CFG.whatsNew). A night of 29 features is invisible to a returning player — so the menu now greets each content wave once: a small dismissible card (three curated highlights + a changelog link), keyed to a hand-bumped wave id in localStorage, hidden on small viewports and fixed-position so the hard-won menu fold gates never move. Live QA caught the classic boot-order bug pre-ship: the constructor's sync ran before the game global existed, so first-time visitors — the whole audience — never saw it; fixed and pinned with a fresh-boot assertion. Smoke 157 → 159; validated to FAIL with the flag off.CFG.starNames). Every №10 finally has a name: a deterministic call-sign hashed from the nation id ("VOLT-83 · FRANCE", "COMET-18 · PARAGUAY") — latin in both languages — shown on the walkout lineup strip, on the goal lower-third when the star scores, and on the Player-of-the-Match line. Live QA caught a real bug before ship: a signed >> in the hash sent top-bit ids negative ("COMET-2") — fixed with an unsigned shift and a multi-id shape test. Smoke 156 → 157; validated to FAIL with the flag off. Live-verified on the walkout.CFG.wave). Forty quiet seconds — no big moment — and the terraces entertain themselves: a gaussian bump sweeps one full lap of the ring in ~2.6 s, souls standing as it passes, with a soft murmur swell underneath. Any real moment (a shot, a save, a goal) resets the lull, the heights restore exactly when the lap completes, and reduced-motion users never see it. One-time cached per-soul angles keep the sweep cheap. Smoke 155 → 156 — big moments reset the lull · the crest fires one lap · souls lift · exact restore · flag-off inert; validated to FAIL with the flag off (which also taught the test to fail gracefully instead of crashing the harness). Live-verified: 390 souls on their feet mid-lap.CFG.kitClash). Switzerland red against Egypt red was unreadable: when the two shirts sit too close in RGB, the away XI re-kits in its most distant declared alternate — a flag-band stripe, the shorts, or the GK color, whichever reads best against the home shirt. Names, flags, and motifs are untouched (only the robots' materials change, under a fresh cached kit id). The loop also skipped its own queued item this iteration: "keeper distribution" turned out to be an experiment the repo records as A/B-tested and reverted pre-loop (it conceded more) — re-opening it needs owner sign-off, not loop initiative; the queue documents the skip. Smoke 154 → 155 — the pure picker chooses a declared alternate on red-on-red (and null on distinct shirts) · an EGY–SUI tie re-kits the away XI · a clean tie doesn't · flag-off inert; validated to FAIL with the flag off. Live-verified: red vs white on the pitch.CFG.milestones). The landmarks your career already tracks now get their moment: 10/25/50/100 career goals, 10/25 assists, 5/10 cups, and a 👑 Legend-difficulty title each fire exactly once when crossed — a golden line under the result-screen honours plus a toast — and live on as a Trophy Room footnote (the latest four). Table-driven and pure (checkMilestones is exported for tests), hands-on matches only. Smoke 153 → 154 — below-the-line silent · fires once, never twice · the Legend extra · in-match result note + room footnote · Watch-safe · flag-off inert; validated to FAIL with the flag off. Live-verified: the tenth goal raised "⚽ 10 career goals" on the result screen.CFG.volume). The game finally has more than M-mute: the pause screen gains a 🔊 slider — a user multiplier over the synth's master gain, persisted across sessions, clamped 0..1, and fully orthogonal to mute (muting and unmuting lands you back at YOUR level, not a hardcoded one). Bilingual label; an audible tick confirms the new level as you drag. Smoke 152 → 153 — slider sets + persists · clamps both ends · mute-safe · a flag-off build hides the row at boot (the negative control); validated to FAIL. Live-verified on a real AudioContext: 40% → gain 0.2, mute → 0, unmute → back to the set level.CFG.recap). When a cup run ENDS — champion or eliminated — the shareable result card stops being a single-match snapshot and carries the whole road: one strip with every round's tie (✓/✗ · opponent · score), straight from the cup save, under the champion/eliminated framing. Mid-run cards are unchanged (the key-goal line yields only on the finished-run card). Smoke 151 → 152 — a real elimination snapshot carries the road · the finished card draws it (spied at the canvas level) · mid-run stays classic · flag-off stays classic; validated to FAIL with the flag off. Live-verified: a champion card rendered "✓ PAR 2–0 · ✓ ESP 1–0 · ✓ NOR 3–1 · ✓ ARG 2–1".CFG.motm). The end-screen spotlight stops being a plain top-scorer line: it now scores goals ×3 + assists ×2 — so the playmaker who laid on two can beat a lone poacher — breaks ties toward the №10 star, and shows the working ("№4 🇫🇷 · 1⚽ 2🎯"). Flag off keeps the legacy scorer-only line. Smoke 150 → 151 — the playmaker wins with the tally shown · star tiebreak · fresh-match reset of both tallies · flag-off legacy; validated to FAIL with the flag off. Live-verified on a real end screen.CFG.gamepad). The last keyboard-only gap: the pad stick now aims your penalty (latched, like the touch stick, so a flick holds while your other thumb charges) and steers the keeper's pre-lean on opposing kicks — A already charged and committed. Priority is touch stick → pad → mouse. The fix pass also hardened the whole suite's flag hygiene: gated tests now restore the shipped CFG value instead of a hardcoded true (a flag-off build previously slipped past two checks via a runtime re-enable — caught while validating this very assertion). Smoke 149 → 150; three checks now fail a gamepad-off build. Live-verified: a stubbed pad aimed, charged, and buried a penalty on real frames.CFG.assists). Goals stop being solo acts: the last different same-team kicker before a strike is credited — on the broadcast lower-third ("№10 · assist №7"), on the robot's match tally, and in the career (number-keyed beside the Golden Boot, with a 🎯 Top assists honour in the Trophy Room). Strictly kicks: a tackle-pop is not a pass, an opponent's touch breaks the chain, and a restart voids it — the first touch after any kickoff/corner starts a new chain, so a pre-restart passer can never be mis-credited (the smoke suite caught exactly that stale-chain bug mid-build). Watch mode can't farm it. Smoke 148 → 149; validated to FAIL with the flag off. Live-verified: "№10 · assist №4" on the lower third with the career map updated.CFG.gotm). Every captured goal clip is now stamped with its shot speed as it crossed the line, and when the FULL TIME reel holds two or more goals, the fastest one plays under a "⭐ GOAL OF THE MATCH / 全场最佳进球" banner. A lone goal is never bannered — an award needs competition. Presentation-only, riding the existing highlights engine. Smoke 147 → 148 — real captures stamp the speed · the fastest of three gets the star (its neighbours don't) · lone-goal and flag-off never banner; validated to FAIL with the flag off. Live-verified: a 14 m/s tap-in and a 27 m/s screamer — the reel starred the screamer.CFG.cornerVar). The AI's corner kick grows a real repertoire: per the taker's passSkill it now picks a near-post whip (in-swinging), a far-post hang, or the classic central ball — scuffed low-skill deliveries stay central, so Doze keepers aren't suddenly ambushed. Runners already attack both posts, and you already aim your own corners freely — this closes the gap on the AI side. Smoke 146 → 147 — near/far/central all reachable under a deterministic roll · the cross flies · flag-off is always the classic central delivery; validated to FAIL with the flag off. Live-verified: six live corners produced a genuine near/central mix.CFG.careerCode). Honours no longer die with a cleared browser. The Trophy Room gains ⇪ Export career code (a compact MC1. code, copied to the clipboard — or a selectable prompt where the clipboard API is missing) and ⇩ Import code — shown even on an empty room, because a fresh browser importing its honours back is the whole point. Import validates and merges: trophies union by their unique key, counters and the Golden Boot pool as per-key maxima (two devices can pool a career), and every imported trophy is re-anchored by its stable nation id through the same sanitizer that guards roster changes — a crafted code can't smuggle bad indices. Smoke 145 → 146 — export shape · wipe → import restores · merge pools not clobbers · junk rejected safely · the room row is flag-gated; validated to FAIL with the flag off. Live-verified with real clicks (export → wipe → import → honours back on screen).CFG.autoQuality). The silent perf ratchet grew into a real feature. It was already there (sustained >28 ms frames step pixel ratio → 1, then shadows off, then a softer net solver) — now it's flag-gated, it says so once (a subtle "⚙ AUTO QUALITY" toast on the first step, bilingual), and the pixel-ratio step heals mid-match: five seconds of consistently fast frames hand the crispness back (shadows stay off once dropped — re-enabling forces a shader recompile hitch on exactly the machines that struggled; they return at the next match). And the ladder finally has tests. Smoke 144 → 145 — step order · one-time toast · mid-match heal · full march to shadows/nets · fresh-match restore · flag-off inert; validated to FAIL with the flag off. Live-verified on a real renderer: 1.8 → 1.0 → 1.8.CFG.halftime). Regulation finally has a midway moment: at the 50% mark a broadcast stats card drops in over a whistle and crowd swell — the scoreline, live possession / shots / saves from the real tallies, and an "▲ ON TOP" line naming who's running the game (possession tilt). Overlay only — dream-league halves don't stop play — 2.8 s, once per match, Watch mode included, bilingual. Smoke 143 → 144 — fires once at the mark with the real numbers · auto-hide armed · no refire · fresh-match re-arm · flag-off silent; validated to FAIL with the flag off. Live-verified (FRA 1–0 PAR · 37%/63% · "▲ ON TOP: PAR" mid-match).CFG.director). A fourth C-key camera mode that cuts like a broadcast: an elevated TV sideline pans with the buildup, and the moment play enters the attacking third the shot cuts to a low rail beside the box — real snap cuts (with hysteresis and a 1.5 s cooldown so the boundary can't strobe), not glides. Watch mode auto-engages it — the menu's hands-off match now plays like a real broadcast — and taking over hands your own camera straight back. Bilingual chip (Director / 导播), persisted like the other modes. Smoke 142 → 143 — 4-mode cycle + chip · sideline↔rail zones · the cut lands the same frame · hysteresis holds · Watch auto-engage + takeover restore · flag-off keeps the 3-mode cycle; validated to FAIL with the flag off. Live-verified: a watched match's camera-height trace read 12.5 → 2.4 → 12.5 as the attack came and went.CFG.replayBtn). The pause screen gains "⟲ Replay the last goal" — the most recent goal plays back through the HILITE engine on demand, mid-match, under a bilingual "⟲ LAST GOAL · 22′" banner, instead of waiting for the full-time reel. The frozen match is snapshotted (ball, spin, all ten robots' positions and headings) and restored to the exact frame when the replay ends — you resume precisely where you paused. The button appears only once a goal exists. Smoke 141 → 142 — hidden pre-goal · replays under the banner · returns to pause with transforms restored · resume works · flag-off is a hard no-op; validated to FAIL with the flag off. (One self-inflicted bug caught by the suite mid-build: an sfx.init() in the new path flipped audio on headless — the audio-off gate refused it.) Live-verified with a real click.CFG.skillMoves). Hands-on depth for the crowd the Legend tier invites. Double-tap a move key for a stepover burst — half a second of +35% acceleration where tackles slide off (odds ×0.3), on a 3-second cooldown, and only with the ball at your boots. Hold both mouse buttons (or LT+RT on a pad) while carrying to shield — planted at 0.55× pace, but challenges bite half as hard. Both moves read through the same speed and tackle chains the AI already uses — no parallel physics. Smoke 140 → 141 — double-tap arms (a single tap doesn't) · the burst is a real speed jump in the movement chain · cooldown holds · shield slows + halves tackle bite · pad LT+RT reads as shield · flag-off inert; validated to FAIL with the flag off. Live-verified on real frames: the same 400 ms window covered 1.89× the ground under a burst.CFG.rivalry). The career now carries a grudge: an elimination brands the nation that ended your run (stored by stable id, so roster changes can't mislabel it). Meet them again and the walkout reads ⚔ REVENGE MATCH / 复仇之战 and the house treats it like a final — stakes floor 0.85, so the stands pack and the pre-kick fireworks fly. Beat them and the score is settled: the result screen says so, the brand clears, and the Trophy Room gains a "⚔ Revenge wins × N" honour. Hands-on matches only — Watch mode can neither brand nor settle. Story built entirely from data the game already keeps. Smoke 139 → 140 — brand on elimination · rematch framing (tag + stakes) · settle (honour + cleared brand) · Watch-safe · stale-id normalization · flag-off inert; validated to FAIL with the flag off. Live-verified (FRA–PAR: "Round of 16 · ⚔ REVENGE MATCH" on the walkout card).CFG.legend). A fourth tier above Fever, for the veterans: faster reads (vision 28 → 34), tighter marks (tackle odds +31%), cleaner passes (skill .9 → .97), a livelier line (+7% pace, full send) — but the keeper keeps Fever's calibrated numbers: the wall is the team, not a psychic GK, so the shot-balance gates hold untouched. Legend is explicit-pick only — the cup's round-climb still tops out at Fever for every lower base — and shared links accept diff=legend (clamping to Hard if the tier is off). The menu gains a bilingual Legend / 传奇 pill; compact-landscape menus tightened so four pills still clear the fold (the responsive gate caught the wrap). Smoke 138 → 139 — tier shape · Fever-GK identity · pill picks + persists · explicit-only climb · GK gates re-run at Legend (corners score, weak shots saved) · flag-off caps at Hard; validated to FAIL with the flag off. Live-verified.CFG.goldenBoot). Your goals now build a career: every open-play goal your team scores hands-on (Watch mode can't farm it, opponent goals don't count) is credited to its scorer's shirt number in the persistent career store — number-keyed, so roster reshuffles can't mislabel it. The Trophy Room gains a Career goals stat and a 👢 Golden Boot honour line naming your top scorer ("№10 · 23 goals" / "10号 · 23 球") — shown even before your first cup, because a scorer's record is an honour too. Old saves normalize cleanly on load. Smoke 137 → 138 — credit + persistence · opponent/Watch ignored · Trophy Room line (cup-less included) · pre-boot save normalization · flag-off silent; validated to FAIL with the flag off. Live-verified ("👢 Golden Boot — №10 · 1 goal" rendered after a real goal).057f52c · 2026-07-05 — Gamepad support (CFG.gamepad). (shipped as a direct commit — no PR) Plug in a controller and play: left stick moves (pin it to the rim to sprint, same as the touch stick), A passes / holds to shoot, B plays the through ball, Y the lofted pass, X holds the finesse curler, LB switches player, LT holds the jockey stance, RB/RT sprint, Start pauses. The pad is polled and translated into the same verbs the keyboard fires — no parallel input path to drift. A bilingual "🎮 GAMEPAD READY" toast names the mapping on connect. Smoke 136 → 137 via a stubbed getGamepads pad — stick/deadzone/rim-sprint · A arms/releases the kick on button edges · watching + stick takes over · flag-off makes the pad invisible; validated to FAIL with the flag off. Live-verified (a stubbed stick ran the №10 6.9 m across real frames).CFG.crowdID). The floating ring is no longer a neutral galaxy: each nation's fans take an end — the crowd souls tint to the two flags' band colors, with neutrals on the sides — and the house fills as the cup deepens (R16 ~70% seated → the Final packed, the murmur up to +35% louder). Semi-final and Final nights open under pre-kick fireworks in both nations' colors during the countdown. Quitting to the menu (or the flag) restores the exact neutral house. Smoke 135 → 136 — data-driven end mapping · density rises R16→Final · fireworks only at stakes ≥ SF · exact neutral restore; validated to FAIL with the flag off. Live-verified (FRA–BRA Final: tinted ring + bursts on screen).AAA loop restarted at the owner's direction ("make this a top-notch AAA game — keep the loop going"); item 11 of the new queue (docs/plans/aaa-loop-queue.md).
CFG.commentary). A broadcast lower third now voices the big beats: goals with the real minute ("36′ — FRANCE strike. The arena erupts."), big saves, woodwork, counter-attacks, clutch time, the golden goal, and the shootout — each from a small bilingual variant pool so lines don't repeat verbatim. A 6-second cooldown keeps it a voice, not a log — and a goal always speaks (the hook zeroes the cooldown). The pill sits above the touch zone, never competing with the center splash, and a fresh match silences the booth. Smoke 134 → 135 — deterministic variant · cooldown holds · goal overrides it · zh pool switches with the language · flag-off is silent; validated to FAIL with the flag off. Live-verified in WebGL (the real-minute goal line on screen mid-celebration)..credit hiding prevented). The links now sit in flow after the button stack — they can never overlap it; at tight folds they slide below (auxiliary links, the scrollable panel is the backstop) while the CTAs stay above the fold (gates re-verified). The visibility assertion now also requires no overlap (links.top ≥ trophyBtn.bottom). Smoke stays 134..credit class — which the menu-fold tiers display:none at short windows and on all phones, and whose position:absolute;bottom:3.5vh made the two footer lines overlap. Caught by the post-deploy live check ("is it actually on the home page?"): the links existed in the DOM but rendered nowhere for most real users. They now have their own .infolinks slot — absolute (zero fold cost), below the credit line, visible at every size except ultra-short landscape (≤480px tall). Smoke 133 → 134 — the links must be visible (non-zero rects) at desktop AND a 390px phone; this assertion fails against the #145 markup./guide.html + /changelog.html. The site gains a bilingual playing guide — quick start, full controls (keyboard + touch), the defending/finishing verbs, shootouts + your keeper, set pieces, the three gravity worlds, the cup/career/sharing loop — as one standalone brand-styled page with an English/中文 toggle that shares the game's language preference (dreamcup-lang), so it opens in your language. And this changelog is now published: build.mjs converts CHANGELOG.md → changelog.html on every build (a small purpose-built markdown converter — paragraphs and list items are buffered whole so bold/code pairs survive line wraps), meaning the public page can never go stale. Both pages are linked from the game's menu footer (bilingual), added to the sitemap, and gated in CI: the build test now asserts the guide ships bilingual and the changelog is generated, converted, and current (5 → 7 build checks).AAA loop item 10 (docs/plans/aaa-loop-queue.md) — the roadmap's #1 priority (the game is polished but invisible) gets everything code-side; the submissions themselves are owner actions.
npm run build:zip + a dormant portal-SDK hook + the handoff doc. build:zip produces mechcup-portal.zip — the standard HTML5-game upload (minified game + self-hosted Three.js vendor/ + meta files, index.html at the zip root as itch/CrazyGames expect; ~1.3MB, gitignored). A new MONETIZE.portalSDK slot + loadPortalSDK() give CrazyGames/Poki their SDK seam: wired at boot, dormant unless configured — the game never phones a portal on its own (track() was already designed as the event seam). docs/DISTRIBUTION.md hands the owner the itch → CrazyGames → Poki steps, including the strict-portal note (flip the CDN order to vendor-first for no-external builds). Smoke 132 → 133 — the hook is dormant by default (no tag, empty config), injects exactly one tagged script when explicitly configured, and an unconfigured call is a no-op; validated to FAIL with the dormancy guard stripped. Live-proven the way a portal would run it: the game boots fully from the extracted zip (FRANCE menu, 16-team cup, hook dormant).AAA loop item 9 (docs/plans/aaa-loop-queue.md).
CFG.photo). The pause screen's free orbit camera (drag to frame, scroll to zoom) gains a 📸 Save a photo: one clean frame rendered on demand and captured to PNG — the WebGL canvas carries no HUD, so the shot is already poster-clean — then Web Share with files on mobile, a plain download elsewhere, with a "Photo saved" toast. Render + capture happen in one task (the drawing buffer isn't preserved between frames). The button is .endlink-sized (fold-safe) and hidden when the flag is off. Smoke 131 → 132 — the pause 📸 yields a real image/png blob >10KB (an actual rendered frame, not a blank) with the download side-effect stubbed; validated to FAIL with the flag off. Live-verified in WebGL: a 2MB PNG from a real paused match, toast shown, share-sheet route taken.AAA loop item 8 (docs/plans/aaa-loop-queue.md) — closing the trophy cinema's noted v1 gap.
setTeam re-kit path) for the length of the playback, and hands the slots their real kits back on exit — so a title won as France against Brazil replays in blue and gold, whatever you're wearing at the menu today. If either id no longer resolves (a future roster drop), the swap is skipped and the clip plays in current kits as before. Smoke: the cinema assertion now proves the swap (menu re-kitted to two OTHER nations → playback wears the clip's ids → exit restores), validated to FAIL with the swap stripped (131/131 held). Live-proven end-to-end through the real ▶ click: CRO/AUT at the menu → FRA/BRA during the clip → CRO/AUT after.AAA loop item 7 — the first loop-proposed item after the approved queue completed (docs/plans/aaa-loop-queue.md).
CFG.intro). The walkout was already half a broadcast open (anthem motif, matchup card, geography fact); this adds the other half. A slow camera dolly sweeps across the pitch for the length of the walkout (skipped under prefers-reduced-motion), and the card gains a lineup strip — both fives as number chips with each side's №10 star flagged ★ in gold, teaching the star system at a glance. Tap or Space skips straight to the countdown (the intro must never cost a returning player time). With the FULL TIME reel (#132), a match now opens and closes like a broadcast. Smoke 130 → 131 — lineup strip (10 chips, exactly 2 stars) · the dolly moves the camera through the walkout inside its height band · Space skips to COUNT · flag-off leaves the card bare; validated to FAIL with the flag off. Live-verified in WebGL (🇫🇷 1 4 5 8 10★ · 🇵🇾 1 4 5 8 10★ on the card, dolly mid-sweep).AAA loop item 6 — the last of the user-approved queue (docs/plans/aaa-loop-queue.md).
CFG.setPieces). Both are staged TABLEAUS inside PLAY (whistle → place → hold via settleT → release) — no new state machine, and the dream walls still keep open play flowing (no throw-ins, no midfield fouls). Corners: a defender turning the ball over his own byline concedes one — "CORNER!", the taker walks to the flag (you take your own; the AI's crosses lofted into the box after a beat, where the aerial system can meet it), runners attack the spot, markers drop in. Penalties: a committed lunge that misses in the box is a spot kick, symmetric by design — deep in its own box the AI sometimes risks a desperate lunge (+15% win bonus, penalty if it misses, ~1 a match by construction), and your own whiffed box tackle concedes one too (the spot kick replaces the stumble — the penalty IS the price). "PENALTY!" stages ball-on-spot, taker vs keeper, everyone else outside the box; the goal/save resolves through the normal engine (swept trigger, GK dive) — no special resolution code. A goal or restart voids any staged piece. One knock-on absorbed: the wall-pocket scrum can now legitimately resolve into a corner, so that gate's bound moved 6s → 9s (it's a staged self-clear now). Smoke 129 → 130 — defender-byline stages the corner (ball at the flag, you take your own, the user's strike releases it) · attacker exits stay normal bounces · the AI corner crosses lofted · both penalty directions stage on pinned randoms (ball on the spot) · flag-off keeps plain bounces; validated to FAIL with the flag off. Live-verified in WebGL (the CORNER! tableau with №10 at the arc).AAA loop item 5 (docs/plans/aaa-loop-queue.md).
CFG.momentum). Fall 2+ goals behind and the crowd rallies — "🔥 THE CROWD RALLIES BEHIND YOU" — while the opponent eases: challenge odds ×0.85, decisions 20% slower. Go 2+ up and "😤 THEY DIG IN": ×1.15 / 20% quicker. Deliberately honest and bounded: a step at ±2 (no hidden gradient), announced once per swing, consumed at exactly two opponent-only sites (aiTackle odds · aiDwell tempo — the same consume-a-scalar pattern as knockout caginess), the GK untouched (balance gates stay green), and zeroed in Watch mode (no human side to tilt toward). Smoke 128 → 129 — score-step + swing-announcement + consumer mapping + a deterministic integration proof (a random pinned between the eased 0.17 and base 0.20 thresholds flips a real aiTackle outcome) + demo neutrality; validated to FAIL with the flag off. Live-verified in WebGL (0–2 down → the rally callout with ×0.85/×1.2 live).AAA loop item 4 (docs/plans/aaa-loop-queue.md).
CFG.userKeeper). On the opponent's shootout kicks you're no longer a spectator: a "🧤 YOU'RE IN GOAL — aim, SPACE dives" sting opens the kick, your keeper pre-leans live with your aim (mouse/stick, screen-mapped to the keeper's left/right), and Space/click COMMITS a full-reach dive (reach ≈ the Fever AI's 2.6) — commit before the strike and it's a real guess, exactly like penalties. Stay passive and all you get is a late central reflex stab (reach 1.2, tt<0.3) — corners beat a keeper who doesn't play. Your own kicks, Watch mode, and the flag-off path keep today's behavior; the dive slow-mo only plays on mid-flight commits (pre-kick guesses stay real-time). Found & fixed a real lifecycle bug the feature exposed: _soKeeperCommitted only reset when a kick fired, so it sat stale-true through every wind-up — pre-kick reads were impossible; it now resets per soSetup. Smoke 127 → 128 — pre-lean · committed-dive (full reach, no charge) · passive-reflex (late + short) · own-kick-charges · flag-off-inert; validated to FAIL with the flag off. Live-verified in WebGL (hint splash, lean −0.94 → committed dive −2.21 with the ball mid-flight, dive slow-mo engaged).AAA loop item 3 (docs/plans/aaa-loop-queue.md).
SFX.MOTIFS). All 16 live R16 qualifiers now carry a hand-composed signature theme — original 8-note micro-motifs with a regional accent (FRA lydian elegance · BRA syncopated pentatonic · ARG clipped tango minor · ESP phrygian flourish · EGY hijaz color · SUI alphorn leaps · NOR northern-morning minor · MEX paired thirds · POR fado lift · …), never a quote of a real anthem (licensing + taste). Same contract as the generator (tonic start, octave resolve), so the synth voices and the goal-reprise slice work unchanged; every non-override id (and any future roster) still gets its deterministic hash motif — the table is pure data + a two-line lookup in motifNotes. Smoke: the motif assertion now proves all 16 overrides are sane, mutually distinct, and table-sourced (FRA's exact note row), with the hash fallback exercised via an eliminated nation; validated to FAIL with the lookup stripped (127/127 held). Live: the walkout scheduled the composed FRA theme frequency-for-frequency (16/16 against an independent recompute).AAA loop item 2 (docs/plans/aaa-loop-queue.md).
CFG.weather). Each Gravity Lab world now has ambient presence: Mars drifts warm dust with a periodic gust front sweeping the pitch (+ a soft whoosh); Earth plays in a gentle drizzle — and on a wet pitch: ground friction ×0.72, so rollers run ~30% longer (the one gameplay touch, read live by the ball from game._wet); the Moon gets slow-rising dust motes in the low-g stillness. All of it rides the existing pooled ParticleSystem (a few particles per tick — the pool's idle-skip keeps it ~free) and is skipped entirely under prefers-reduced-motion. GK-balance gates untouched (they run on the default Moon; the wet flag is Earth-only). Smoke 126 → 127 — wet-roll distance (Earth > dry ×1.12), _wet wiring per world, per-world particles in the air via the isolated weather path, and flag-off stillness; validated to FAIL with the flag off. Live-verified in WebGL on Mars (dust + a forced gust, 46 live particles) and Earth (drizzle, 75 live, wet:true).First feature out of the AAA improvement loop (docs/plans/aaa-loop-queue.md — idea → code → test → merge → live-verify, repeat).
CFG.cinema). Winning a cup now banks the title-winning goal with the trophy: the last clip YOUR side scored in the final — the 30Hz sample recording (~18KB), not video — is stored in localStorage (capped at the 8 newest championships) keyed to the trophy's new unique k. In the Trophy Room, rows with footage grow a gold ▶: it replays the goal through the HILITE playback engine over the live arena — "🏆 TROPHY CINEMA · 🇫🇷 v 🇧🇷 · 1′" banner, tap/Space to finish — and returns to the menu. A pens-only final with no user goal simply banks nothing (no broken button). v1 plays with current kits (slot re-skin noted as a follow-up); the caption carries the real teams by stable id, so a roster reorder can't mislabel it. Smoke 125 → 126 — a full-lifecycle assertion (championship banks the clip · the ▶ renders · the real click plays it over the menu with sample-driven motion · skip returns to the MENU · storage caps at 8), validated to FAIL with the flag off. Live-verified in WebGL (the ▶ click path, the banner, the goal replaying in period kits) — which also caught a splash-lingering parity gap with the FT reel, fixed (playCinema clears any in-flight message).Verifying #133 on the live site surfaced a real failure mode: attaching an audio track from a suspended AudioContext (no user gesture yet, backgrounded tab, portal embeds) starves MediaRecorder's muxer — the recorder finishes with zero chunks and the entire highlights clip is silently lost, not just its sound.
_clipRecStart now checks sfx.c.state==='running' before wiring the synth bus into the stream — a silent clip beats no clip. The full matrix was proven in a real browser against the built game: running context → 569KB vp9,opus (sound in the file) · suspended context, fixed → 490KB vp9 video-only that decodes (1980×1193) · suspended context, old code → no clip (the bug) · fully hidden window → no painted frames, correctly discarded by the ≥1.5s gate (the tab-switch edge was already graceful). The smoke's share-clip assertion now also checks that a suspended context contributes no track (conditional — the Playwright context may start running). Found because the verification decoded the blob in a <video> element instead of trusting its size.Round four of the AAA push, stacked on the #132 reel. Virality + audio identity.
CFG.shareClip) · nation motifs (CFG.motifs). Share clip — while the FULL TIME reel plays, MediaRecorder captures the canvas stream (+ the WebAudio synth bus, so the clip has crowd sound) into a webm/mp4 video; the result screen then offers a compact "🎬 Save the highlights clip" link (Web Share with files on mobile share sheets, else a download). Feature-detected end to end — no MediaRecorder/captureStream, no button; an instant skip (<1.5s) records nothing worth sharing and correctly discards. Live-proofed: a real 2.4s reel produced a 582KB VP9 webm and the button lit up. The button is .endlink-sized so the result screen's fold budget is untouched. Nation motifs — every nation id derives a deterministic signature leitmotif (mode · contour · rhythm from an FNV hash; always tonic→octave, so it stays triumphant): the walkout anthem sting now plays YOUR nation's motif, and when a nation scores, ITS motif reprises bright under the roar — "that's their tune". Pure function (sfx.motifNotes) so it's headless-testable without an audio context; the old arpeggio remains the flag-off fallback. Smoke 123 → 125 — a recorder-lifecycle assertion (recording during HILITE · stopped after · button only with a clip · resets per match; validated to FAIL flag-off) and a motif assertion (deterministic per id · distinct across GER/BRA/FRA · sane tonic→octave shape).Round three of the AAA push. Audio identity was already deep (anthem sting, terrace chant, roar, groan, excitement-driven crowd bed), so this round ships the other broadcast staple: match highlights.
CFG.highlights). During play a rolling ring records the whole stage at 30Hz — ball (x,y,z) + all 10 robots (x,z,yaw), 33 floats/sample, a 6s window. Every goal freezes the last ~4.5s into a clip (capped at 5). At FULL TIME the reel plays back before the result screen: a gold ⚽ MATCH HIGHLIGHTS banner (clip counter when >1), a low side-on broadcast camera glued to the recorded ball, robots animated straight from the samples (their velocity feeds the existing leg-swing rig — no physics, no goal-trigger re-fires), 0.9× broadcast pace, a flash + crowd swell as each strike lands. Tap / Space / Enter skips a clip; a goalless match (or prefers-reduced-motion) goes straight to the result as before. Two edges caught in live QA and locked by the assertion: a last-second goal's splash could linger over the reel (cleared on reel start), and the banner reuses the in-match GOAL-replay node — its text is handed back on reel end so the next match's replay label can't say "HIGHLIGHTS". Smoke 122 → 123 — a full lifecycle assertion (ring fills in play · a goal freezes a clip · FULL TIME routes into the reel with the result screen held back · samples drive the ball with physics off · skip reveals the result + restores the label · a rematch resets the reel), validated to FAIL with the flag off. Live-verified in WebGL (banner + broadcast camera + sample-driven robots + skip tag on screen; a saved-shot goalless match goes straight to the result).Round two of the AAA push — after the human-feel verbs (#130), the remaining "left for later" items that read on screen: the tackle's body language, per-team identity, and last-minutes drama.
defTools) — the tackle verb finally LOOKS like one: a 0.42s slide pose (sit back, both legs thrust at the ball, arms trailing) on every committed challenge — the human's attempt AND the AI's won aiTackle — plus turf-dust particles. The lean pivots about the body centre (the #126 bicycle-kick lesson — live QA caught the first cut sinking the torso under the pitch, exactly the feet-origin mistake again; the smoke assertion now locks the pivot geometry). Stars (CFG.stars) — each team's №10/ST is its star: +7% pace (+5% under user control), strikes carry +6% power (applied before the shot gate, so a star's borderline effort still counts as a shot), and a gold boot-trail marks the danger man at full sprint. Symmetric — the opponent's star is a real matchup, and yours is worth protecting. GK-balance gate verified green with the boost live. Clutch (CFG.clutch) — the last 20s of a LEVEL (≤1 goal) regulation match fire ONE "FINAL MOMENTS!" sting: the crowd swells and the clock pulses gold (the global reduced-motion rule kills the pulse for prefers-reduced-motion). Golden goal + the shootout keep their own existing drama; a 2+ goal blowout stays calm. Smoke 121 → 122 — a slide/stars/clutch assertion (pose plays with the centre-pivot z-shift + self-recovers · both №10s star + the same player's identical strike measurably faster · clutch fires once, re-arms per match, respects the blowout gap), validated to FAIL with the flags/pose off. Live-verified in WebGL (the fixed pose sits ABOVE the turf; the gold clutch clock renders).Every prior gameplay phase upgraded the AI/team half (teamAI passing, counters, aerial, knockout caginess) — the HUMAN's verbs hadn't grown since the through-ball. Biggest gap: the player literally had no way to win the ball (only the AI's aiTackle could dispossess a carrier). This ships the planned PES P2/P3.
CFG.defTools) — Space near an opposing carrier = a committed TACKLE lunge (win ≈ tackleUser, the same loose-ball pop + hit-stop beat as the AI's tackle; whiff = a stumble — risk buys the reward; a cooldown swallows repeat presses so a duel can't accidentally start a shot charge; touch ⚡ inherits the context, out-of-range Space still charges as always). Hold right-mouse = JOCKEY (contain stance: ×0.62 speed, tackles bite +jockeyBonus; right-button-up can't fire a mid-charge shot). Finish (CFG.finesse) — hold E = the finesse curler: power capped at 0.78, aim bent toward the corner the keeper ISN'T, and a big Magnus bend does the rest (spin sign locked by a fly-the-ball smoke assertion); a light shot at a RUSHED keeper auto-DINKS over him (no new key — the natural answer to the sweeper-keeper), and the AI makes the same read in Watch mode. Weight (CFG.ballFeel) — sprint turns carry momentum (the run direction blends over ~⅓s instead of snapping; walking stays instant close control) and overcharging a shot (>1.35s) snatches at it (power bleeds, aim skews) so release timing matters. Bilingual PES legend rows for all three verbs + TACKLE!/Dinked!/Curler! action flashes. Smoke 119 → 121 (a defend/feel assertion + a finishing assertion incl. the curl-direction fly-test; both validated to FAIL with the flags off; the GK-balance/teamAI/counters gates all stay green). Live-verified in a WebGL browser (tackle clears the carrier + flashes, the curler bends to the far corner, the dink lofts at vy≈11, both legends). Fixes folded: two latent smoke-harness leaks (a stubbed input.axis and a stuck touch-joystick state bled into later tests). Behind an adversarial diff review (releaseKick/pass-path/SHOOT isolation/spin math all confirmed clean; two real finds fixed: dinks were invisible to shot/save stats — kicked below the 0.45 shot gate, now 0.5 — and the whiff-stumble was dodgeable via Q-switch — it now lives on the Player and decays in Player.update; + a frame-rate-independent momentum blend and a jockey-unstick on window blur). Design: docs/plans/2026-06-15-pes-controls-design.md (P2/P3 now marked shipped).The Round of 32 was played (2026-07-04), so the cup advances to the real Round of 16: the 16 winners — including the upsets (Paraguay over Germany, Canada over South Africa, Norway over Ivory Coast, Egypt over Australia) — with Germany, the old default team, eliminated.
TEAMS reordered so slots 0–15 are the 16 R32 winners in real bracket-slot order; CUP_SIZE 32 → 16 and CUP_SEED16 becomes the identity (replacing the old thematic seeding), so drawCup's pairs are the actual R16 ties: PAR–FRA · CAN–MAR · POR–ESP · USA–BEL · BRA–NOR · MEX–ENG · ARG–EGY · SUI–COL. The 16 eliminated sides stay in TEAMS (slots 16–31) for geography facts + career/trophy history but sit outside the cup — unpickable. New default team: FRANCE (a new DEFAULT_TEAM const drives every fallback; resolveTeamPref now also rejects a saved pick that got knocked out — a persisted GER resolves but is outside CUP_SIZE → default, no picker/draw desync). Static placeholders + the cupFormat line follow (16-team · win 4). ROSTER_SIG changes with the reorder, so stale cups are rejected and careers re-anchor by id automatically (the #119 machinery doing its job on its first real roster change). Live-verified in a WebGL browser: menu FRANCE vs PARAGUAY · Round of 16, picker = 16 flags (no Germany), the full tree renders the exact 8-tie sheet above, zh CTA 出战16强赛. Smoke stays 119 — five 32-team assertions retargeted to the R16 world (cup size/rounds, roster + identity pairs + unpickable losers + default-France, bracket path QF-NEXT, pref fallbacks incl. the new knocked-out case, trophy badge ·N/16); the identity-seeding and default-France checks validated to FAIL against the old values.Three low/nit findings from the deep review, grouped as the "hardcoded strings & year coupling" theme.
MECH CUP 2026 title with a 机甲杯 · Chinese subtitle for all players → an English share mixed scripts. The title is now localized (机甲杯 2026 in zh, MECH CUP 2026 in en — both build-stamped) and the subtitle carries only the world + gravity, so the brand appears once, in the reader's language. (#9) The smoke copy-check asserted a literal MECH CUP 2026; a documented CUP_YEAR bump would have red-gated a correct build. Now MECH CUP \d{4}. (#11) build.mjs guarded only the English brand before the year-stamp replace; added a symmetric 机甲杯 \d{4} guard so a dropped zh brand can't silently no-op the zh year stamp. Smoke → +1 — a canvas-fillText-spy assertion (English card all-Latin, no 机甲杯; Chinese card 机甲杯 title), validated to FAIL against the old hardcoded brand.Three medium findings from the deep review, grouped as the "renders/computes wrong" cluster.
p.x (the clamp fires when a fast ball over-travels the net plane in one frame) while interpolating z/y on the full step — a mismatched fraction that pushed the computed crossing z outside the post, ghost-saving a real near-post shot at low FPS (the exact class the swept trigger exists to prevent). Now the raw stepped position is captured before any ground/wall/net clamp and the trigger interpolates x and z/y on that one consistent segment. (#4) During a penalty shootout the regulation scoreboard #hud (score + comp tag, top-center) was never hidden, so it bled through the shootout tally (same anchor); startShootout now hides it (it stays hidden through the result screen; the next startMatch re-shows it). (#5) At 320px (iPhone SE) the setup rows (label + 3 pills) overflowed one flex line and clipped with no h-scroll; a @media (max-width:340px) tier wraps them and tightens the pills (the 320px-portrait Start CTA then relies on the panel's overflow-y:auto — documented). Smoke → +3: a 20-FPS diagonal-shot-scores assertion, a shootout-#hud-hidden assertion, and a 320px no-clip/no-overflow assertion; all three validated to FAIL without their fix.The deep review found the global keydown handler called e.preventDefault() on Space / Tab / arrows in every state, before any state check — so a keyboard-only user could never Tab off <body> to a control, nor Space-activate a focused button, on the menu, bracket, nation picker, Trophy Room, pause overlay, or end screen. The entire DOM UI was keyboard-dead (mouse/touch were unaffected, which is why it went unnoticed).
{PLAY,COUNT,WALKOUT,GOAL,SHOOT} set togglePause engages from. Outside it (menu · pause overlay · end screen) those keys keep their native behavior, so Tab traverses and Space/Enter activate the DOM buttons. Pause (P/Esc) and mute (M) stay live in every state, so a match is still pausable and resumable from its own overlay. The shootout keeps its Space kick (SHOOT is in the in-play set). Smoke 100 → 101 — a synthetic-keydown assertion (defaultPrevented false for Tab/Space/arrows on the menu, true in-match), validated to FAIL with the old unconditional preventDefault.A deep multi-lens review found the highest-leverage latent bug: cup, career, and team-pick state were all keyed by raw TEAMS index with no fingerprint. The #117 roster reorder therefore silently relabeled returning players' state — a Korea pick became South Africa, a resumed cup rendered a scrambled bracket, and the Trophy Room lit the wrong flags — because every stale index still passed validCup's range check.
ROSTER_SIG (FNV-1a hash of TEAMS.map(t=>t.id)) is stamped into every drawn cup and career record. validCup now requires a matching sig — a stale or pre-fingerprint save is rejected and startCup draws fresh, instead of resuming into a mislabeled bracket. The team pick persists by stable id (dreamcup-team stores "GER", not 5), so a reorder or a dropped nation resolves to the right team or cleanly defaults (resolveTeamPref; a legacy numeric index is adopted when in range so the id-migration doesn't gratuitously reset a returning player's pick — a regression-review fix). loadCareer re-anchors trophies by id on a detected roster change (dropping the unmappable, rebuilding the per-nation map, keeping index-independent totals — cups/streak/worlds); a career predating the fingerprint adopts the current roster as its baseline. Also (#8 from the review) validCup now rejects non-numeric results, closing a tampered-save → raw-string-into-innerHTML path (self-XSS, defense-in-depth). Smoke 100 → 101 — a persistence assertion (stale/absent sig rejected · non-numeric results rejected · team pick id-keyed · career trophies re-anchored by id, aggregates kept), validated to FAIL without the sig check.The 32-team bracket already borrowed almost everything portable from the published real-tournament bracket (the round tree + connectors, score/pens cards, progressive TBD reveal, a gold path highlight). The one real gap — flagged by a Codex note and the project's own live review — was the phone: a 16-tie Round-of-32 column is dense even zoomed-to-fit. This is the deferred "v2 your-path view".
max-width:760px) and Full Bracket on desktop. A NEXT marker now flags your immediate match in both views — a gold corner badge on the full tree (an ::after, so the 16-tie column height is unchanged) and a gold card + NEXT badge in My Path — so your next game pops out of a wall of nations. The path view honours the same progressive-reveal ceiling as the full tree (ceiling=(done&&!champ)?played:played+1) — unreached rounds stay TBD, never a fabricated foe. No engine/data changes — pure presentation over the existing cup model; cupPathHTML + a showBracket→_renderBracket refactor. Live-verified in a WebGL browser on the real roster (GER 2–1 PAR won → R16 NEXT GER vs FRA → QF/SF/Final/Champion TBD; the full tree's one gold NEXT tie; mobile 390px with no overflow). Smoke 100 → 102: a toggle/NEXT/reveal-ceiling assertion (path stacks 6 rounds, marks exactly one NEXT tie in each view — validated to FAIL when the NEXT flag is neutralized) and a 390px phone-overflow guard. Explicitly NOT done (decided with the user): a center-converging two-halves layout (denser + worse on mobile), per-match venues (the whole cup is on one world), a neon-green path (gold ties to the trophy). Behind a 2-lens review (correctness/i18n · CSS/responsive/test): both ship. Design: docs/plans/2026-06-28-bracket-path-view-design.md. A follow-up deep review then caught two defects in this PR's own new code, fixed here: the My Path "vs" separator was a hardcoded English literal (now T('vsLabel') → zh 对阵), and the My Path | Full Bracket tabs never set aria-selected (now mirrored to the visual .sel in _renderBracket, seeded in the static markup + aria-controls). Smoke 102 → 103.The group stage locked (2026-06-28), so the provisional 32 became the actual 2026 tournament Round-of-32 field. Per a pure-authenticity call, China and Korea are out (they didn't qualify); the default team is now Germany, the top-left seed.
TEAMS is now stored in real bracket-slot order, so CUP_SEED32 is the identity [0..31] and drawCup's pairs are the actual R32 ties: GER–PAR · FRA–SWE · RSA–CAN · NED–MAR · POR–CRO · ESP–AUT · USA–BIH · BEL–SEN · BRA–JPN · CIV–NOR · MEX–ECU · ENG–COD · ARG–Cape Verde · AUS–EGY · SUI–ALG · COL–GHA. Live-verified in a WebGL browser (default GER vs PAR; the picker shows the 32 real flags + zh names; the bracket renders the real tie sheet). No engine code touched — exactly the data swap the design doc planned. Also: the walkout "Did you know?" card now names the country (Did you know? PARAGUAY — …) so it's clear which of the two flags on screen the fun fact is about (en + zh); and stale China references were cleaned up (the dead flagTexture China branch, the HUD + default-card placeholders → Germany, unused USA/JPN bands). Smoke 97 → 99: a "Roster = real 2026 Round of 32" assertion (full 16-pair identity bracket + China/Korea/Italy absent + default Germany) and a walkout-fact-names-the-country assertion — both validated to FAIL pre-change. Behind a 2-lens review (content accuracy · reorder/engine): content zero defects (all capitals · endonyms · zh · facts · kits web-verified), engine ship (no index-hardcoding broke; saves stay structurally valid).A self-driven live end-to-end review (build 061bf03) found the game polished and bug-free across the menu, picker, a full match to a golden-goal result, share, bracket, Daily Challenge, Trophy Room, accessibility, and en/zh parity — with one real usability issue: on a 1280×720 browser window (~633px usable after chrome) and landscape phones, the retention CTAs (Watch · Daily Challenge · Trophy Room) sat below the fold — only the gold START was visible.
@media (max-height:700px) tier drops the two most expendable menu lines (the gravity "did you know" fact + the controls legend — both covered by the in-field hint and the visible YOU card) and tightens the vertical rhythm, so the full START · Watch · Daily · Trophy stack clears a ~630px fold without scrolling — live-verified at a real 1280×720 window (all four visible, menuScrolls:false, brand intact). The shared .bigBtn shrink is safe: the result screen is protected by #endScreen .bigBtn specificity (always co-active since 700≤840) and pause is content-light (confirmed by a focused diff review). Smoke 97 → 98 — a realistic-window check at 1280×640 (the existing 720 check uses a true viewport with no browser chrome, which is exactly why the live below-the-fold CTAs slipped the gate); validated to FAIL without the fix. Follow-up noted: the champion result screen has the same realistic-height fold (its last action drops below 640) — a separate, pre-existing layout pass.An 8.0/10 review on the 32-team build. Most of its findings verified as artifacts (hidden controls are inert from load via the panel observer; the landscape clip is covered by a passing fits test) — but two were real, one of them twice-flagged.
togglePause was the only writer of #pauseBtn's aria, so quitting, restarting, or advancing to the next round left a stale "Resume match" / aria-pressed=true into the menu or the next match. Extracted _syncPauseBtn() (keyed off the overlay's visibility, robust to call timing) and called it from resetToMenu + startMatch, which also now hide a lingering pause overlay. (real, a 32-team regression) the nation picker's Close is reachable again — 32 nations made it 7 rows tall and pushed Close below the fold at 1280×720; the grid now caps at 56vh and scrolls internally so the title + Close stay put (live-verified). Smoke 93 → 95 (pause aria + overlay clear after quit AND restart — validated to FAIL on the pre-fix restart path; + picker Close in-viewport at 1280×720). Copy nits (PLAY ROUND OF 32, 开始32强赛, BACK TO SETUP) stay parked, and a proposed 🏆 Trophy icon was dropped — the diff review caught that the button already renders a mech-cup SVG (PR #111), so an emoji would double it. The Continue-vs-Change-Setup redesign is tracked separately.Expands the cup from 16 teams (R16 → Final, 4 rounds) to 32 teams (Round of 32 → Final, 5 rounds), themed on the real 2026 knockout stage (48 → a 32-team R32). The engine was already parameterized (CUP_ROUNDS = log2(CUP_SIZE)), so this is mostly content + a responsive bracket. The roster is provisional — a plausible 32 now, swapped for the real qualifiers when the group stage locks (≈ 2026-06-28); the swap procedure (edit three marked blocks, no engine changes) is in the design note.
CUP_SIZE 16 → 32, CUP_ROUNDS 5; roundName gains Round of 32 / 32强 (the CTA renders 出战32强赛 via the #102 强-rule); cupFormat auto-reads "win 5 matches"; a swappable CUP_SEED32 fixes the R32 pairings. Responsive bracket (.b32): 6 columns, the 16-tie R32 column caps at 62vh and scrolls internally so the caption + Close never overlap — live-verified in a WebGL browser at desktop + the path highlight intact. Saved 16-team cups migrate cleanly (rejected by validCup → fresh draw). Behind a 3-lens review (content accuracy · engine regressions · bracket/tests), all ship: capitals/endonyms/zh/facts fact-checked (Mexico's chocolate line softened for precision; NZ flag gets its Union-flag note). Smoke 82 → 83 (a 32-team assertion that also guards the swap from silently downsizing the cup if a nation is dropped). Design + swap steps: docs/plans/2026-06-25-32-team-cup-design.md.The primary "begin this match" button read Enter the Round of 16 / 进入16强, which can be misread as qualifying for the stage (especially zh 进入16强 = "reached the last 16," a result you achieved) rather than playing it now. Switched to an unambiguous action verb, with a native-Chinese review pass.
enterRound → en ✦ Start the ${r} ✦; zh ✦ 出战${r}${r 以"强"结尾→补"赛"} ✦, rendering 出战16强赛 / 出战四分之一决赛 / 出战半决赛 / 出战决赛 — all parallel, all ending in 赛. The 赛 is appended only on the CTA for 强-ending rounds; the round name itself stays 16强 in bracket headers / scoreline / result labels (a stage name vs a verb+match object — the correct linguistic split, confirmed by the zh-native review). One change fixes the menu start button and the end-screen "next round" button (shared enterRound), plus the static/fallback … the Knockout. Smoke 81 → 82: a new assertion drives the live #startBtn through a real en↔zh language switch and asserts Start the Round of 16/出战16强赛 (no Enter/进入) — validated to FAIL on the pre-change wording; also retired a stale fallback sentinel left toothless by the rename.A tenth Codex review (8.5/10) on build 43221d9 — which confirmed #100's Change-Setup fix held. Its one High finding (third review in a row to flag it): tapping Share copied a bare setup link (?team&world&diff), so a paste shared "play this scenario," not "I won 1–0." The result lived only in the downloaded card image, which isn't what lands in a paste. Earlier rounds under-weighted this as an automation artifact; this round fixed the payload. Shipped behind a 4-lens diff review (all ship) that upgraded the share text to name the outcome and broadened the test across result shapes.
_shareText(R) builds one line — CHN 1–0 USA · Won the Round of 16 · 🌙 Moon (Out·/CHAMPION 🏆 and a (5–4 pens) clause when relevant, via the existing _resultLabel) — now used for both the clipboard and the native-share text, with the playable ?team= URL appended so a paste both brags and links. Previously the clipboard got only challengeURL(). Copy: Share result card… → Share result; "… link copied" → "… result copied"; Opening share card… → Sharing result… (en + zh). The native-share image/text/url payload is unchanged in shape (it already carried the card image). Smoke 80 → 81 — one assertion spies the clipboard across win / loss / shootout / champion and asserts scoreline + outcome word + a playable link; validated to FAIL on the pre-fix bare URL (and on a regression that drops the outcome word).A ninth Codex review (8.4/10) on the live build 20e51a7. One real HIGH bug (verified against the code) plus a cheap clarity win; the share-flow flag was confirmed an automation artifact and dropped. Shipped behind an adversarial 4-lens review of the diff, which caught — and forced the rewrite of — a vacuous first draft of the new test.
Change Setup / Quit now re-sync the menu. resetToMenu() never re-ran syncTeamSel(), so after a win the matchup card + start button stayed frozen on the round you just played while the saved cup had already advanced — the menu disagreed with what a reload restored. It now repaints (syncTeamSel();syncChallengeUI();), so the card shows the round you'll enter NEXT, matching the persisted run (an active daily challenge is preserved across the repaint, not cleared). (low) a muted "Rival results are simulated." caption (en + zh) under the bracket, shown only once you've played a round, so the known AI scorelines don't read as a pre-scripted outcome. The share-flow "promised a card, copied a link" finding was an automation artifact — the native-share toast already fires only on the genuine OS-sheet path — so no change there. Smoke 78 → 80 (the Change-Setup re-sync assertion was validated to FAIL on the pre-fix build before being accepted, after the diff review flagged a first draft that passed vacuously).From the live QA review's "make it better" follow-up — the two cheapest, universal, client-side wins. Investigation found the game was already juicy (goal slow-mo/shake/flash/roar, shot FOV-kick, crowd bed) and already had a 3-step first-match tutorial + a first-match opponent slowdown, so these ENHANCE rather than rebuild.
ball.pos.y>1.1 && vel.lengthSq()>90, reuses sparkles.spawn), leaning into the low-gravity hang; plus a crowd swell on a shot at goal (bumps the existing sfx.excite). (onboarding) a gentler first kickoff — opponents ease to 70% for the opening 5s of match #1 (then the existing 85%) via the updateAI speed multiplier, so a newcomer isn't scored on while finding their striker (the opening prompt was already the tut0/1/2 tutorial). No physics touched; smoke 78/78 (GK-balance + ball-moves green). Design note: docs/plans/2026-06-20-juice-onboarding-design.md.A Codex-review round plus polish and a CI guard landed between #93 and the game-feel work above but weren't logged at the time; reconstructed here from the PRs. Together they took the smoke suite 73 → 78.
main don't race the Cloudflare Pages deploy.langFromNav (zh* any region/script → Chinese, everything else incl. empty/null → English; the PRIMARY navigator.language only) used by the LANG init and exposed on DreamCupTest; a smoke assertion locks the full mapping + a fresh en-US boot → English + the boot landing on a valid I18N key, so a silent refactor of the detector now trips the gate. Design + rationale (primary-only signal, a remembered toggle override that wins, validity guard) captured as a reusable pattern in docs/language-detection.md. No behavior change — the detection logic is unchanged, just made testable. Smoke 72 → 73.A self-driven end-to-end live review of build 8b149e7 across five lenses (QA · gameplay · UI · localization · marketing), then an adversarial multi-agent pass over the fix diff before shipping. Most of the build verified sound — low-gravity ball physics (Moon apex 2.31 m / 0.87 s hang vs Earth 1.25 m / 0.40 s), fair GK balance (corners score, central saved), progressive bracket reveal, en/zh parity, the watched-shootout, and the 1200×630 share card all checked out. The fixes below close the first-impression and reach gaps the review surfaced. (Several plausible "bugs" were ruled out as harness artifacts before reaching the report — a shootout "hang", a share card "NaN", a validCup scare.)
-2.5vh, viewport-relative so it struck through the cards + "YOU · №10" at every width, worst on mobile) is neutralised to 0; the 1280×720 button-fold still fits (11px headroom). (high) a <link rel="canonical"> → mechcup.com so the four mirror domains stop splitting duplicate-content signals. (med) Daily Challenge applies in place — no full page reload / "booting the lunar arena" splash; it sets the seed, syncs the pills/cards/banner, and history.replaceStates the shareable ?daily= URL (changing a setting still opts out + strips it). (med) real robots.txt + sitemap.xml (copied into the build) so those paths stop soft-404ing to the SPA and crawlers get the canonical URL. (low) case-insensitive endonym dedup — "ARGENTINA (Argentina)" → "ARGENTINA" (also France/England/Portugal), while "CHINA (中国)" / "USA (United States)" keep the informative endonym. (low) a denser scrim (.92) scoped to the overlay modals (bracket/trophy/nation-picker) so the busy menu doesn't bleed through where backdrop-filter is unsupported — the base menu/pause/end panels keep the lighter .62 atmosphere. Copy: daily hint "…gold button to start" → "…gold button below to start". Smoke 68 → 71 (helper-overlap geometry, in-place daily no-reload, endonym dedup).An eighth Codex review (7.5/10) on build a4ea098. Two real bugs + one hardening + one layout; the recurring trio (pause logic, hidden-controls focusable, landscape "no scroll") stays declined as verified artifacts of the reviewer's throttled/automation session.
canFiles branch now also copies the link so the clipboard is never empty if the native sheet doesn't surface; clearChallenge() strips ?daily=/?team= from the URL so changing a setting + reloading no longer snaps the daily scenario back. (hardening) #pauseOverlay hides instantly on resume (no fade) — retires the recurring "stuck overlay" artifact + crisper resume. (layout) a compact landscape: a #setupRows wrapper (display:contents → 2-up grid at ≤520px) + tighter CTAs + dropped world-fact line so the full menu stack fits 844×390 without scrolling. Copy: "press the gold button to play" → "…to start". Smoke 67 → 68.A seventh Codex review (7.2/10) on build ff90591. Each claim verified live: 3 real bugs (2 reproduced) + a share-robustness gap + a stale toast; the pause-toggle, hidden-controls, and landscape-scroll claims were re-confirmed non-issues (throttled/automation session).
ceiling = (done&&!champ) ? played : played+1).c.champ, which left the Final-loss case showing a decided Final (your loss) over a blank Champion. The Champion now shows iff the Final was played (champId = (played===ROUNDS-1) ? level[0] : null) and is its real winner — you if you lifted it, the side that beat you if you lost it; still TBD if you went out before the final.share() hangs) and only claims "link copied" once the clipboard write resolves. (low) the stale "SWITCHED" action toast is cleared on match-end + quit. Smoke 65 → 67.A sixth Codex review (7.0/10) on build debd150, run in a heavily-throttled session with un-clearable stale state (which manufactured several symptoms). Each claim was verified live: 2 real bugs (1 critical) + 1 real share gap + 2 polish; the pause-toggle and hidden-controls claims were re-confirmed non-issues.
_clearGoalFx() (called from startMatch + resetToMenu, reused at celebration end) tears it all down. (real) the in-match HUD scorebug + corner camera/pause/mute controls now retire on the result screen (they sat behind the translucent panel, showing through + staying focusable); a successful Web-Share now shows a "Shared!" toast (the canFiles path was a silent no-op when the OS sheet didn't surface). (polish) the advance CTA names the round ("✦ Enter the Quarter-final ✦"); zh "Advanced" 实况 → 进阶. Smoke 63 → 65.A fifth Codex review (7.0/10) on build 037a8a4. Every claim was verified against the live code first: 2 were real, 2 fair polish, and 2 didn't hold up (the pause toggle is a correct single-source toggle — the "stuck overlay" was a throttled-CSS-fade automation artifact; hidden panel controls are already inert via #50).
endMatch clears the live "WATCHING" badge + take-over hint so a watched match no longer leaves them on the result screen; the setup option groups expose aria-pressed (mirrors .sel, tracks clicks) so a screen reader announces the active language/difficulty/length/control/world. (polish) a @media(max-height:780px) squeeze so the full menu stack (Enter · Watch · Daily · Trophy) clears the 1280×720 fold without scrolling; a prominent, announced share toast (role=status/aria-live, gold top banner) alongside the synchronous button relabel. Smoke 60 → 63..panel inert/fade machinery, role="dialog", en+zh. Smoke 59 → 60.CUP_SIZE_SETTING, one of 2 | 4 | 8 | 16 (≤ the nation pool, invalid → 8), shipped at 16 — not a user/menu toggle; everything (draw, seeding, bracket, roundName, daily seed, Trophy Room grid) derives from it. All 16 nations are freely selectable (no unlock gating); the 8 added (Argentina, Netherlands, Croatia, England, Portugal, Morocco, Senegal, Poland) already had full colours/flags + native/capital/facts. The bracket now scrolls horizontally (reads R16 → Final) instead of wrapping on narrow screens. Old 8-team saves self-invalidate and redraw. Smoke 58 → 59 (cup-size config contract).A multi-agent adversarial review of the two new features confirmed 10 issues (3 refuted).
loadCareer sanitizes the trophy log like validCup sanitizes a saved cup, so a hand-edited / migrated record can't crash the Trophy Room; a daily/friend link now starts its fixed scenario fresh (resume is gated on !CHALLENGE) instead of silently resuming a coincidentally-matching saved cup mid-bracket; the challenge banner re-localizes on a language switch (applyLang → syncChallengeUI). (P2) Daily + Trophy Room share one centered, wrap-friendly row so the menu stack is shorter. (P3) dropped the write-only career.diffs map and the unused challengeCta key. Smoke 54 → 58 (trophy sanitization, the recordCup cup.done gate, world-aware champion copy, and a ?daily=DATE reload).The retention pillar: a persistent record of every cup, so there's a reason to come back. localStorage only — no backend, accounts, or payments.
dreamcup-career record tracks cups won/played, the current + best win streak, cups won per nation / world / difficulty, and a trophy log. A pure core (applyCupResult) folds each finished cup in — a win adds a trophy and extends the streak, a loss ends it — and returns the new honours earned (first champion on a world, first cup with a nation, Triple Crown across Moon + Mars + Earth, a new best streak), surfaced as a gold end-screen callout. A 🏆 Trophy Room menu panel shows the stats, three-world medals (+ Triple Crown badge), a nation flag grid (X / 8), and recent trophies. Champion copy is now world-aware (names Moon / Mars / Earth, not a hardcoded "Moon"). en+zh. Smoke 53 → 54.The first "make players return / make results worth sharing" bundle. Both pieces lean on what already exists — the deterministic cup draw + the share card — so neither needs a backend, accounts, or payments (localStorage only).
?daily=YYYY-MM-DD seeds a fixed {nation, world, difficulty} (FNV-hash of the date) so everyone playing today gets the same scenario; ?team=BRA&world=mars&diff=hard presets the menu from any link — and the in-game share card now points here, so "beat this?" lands a friend on the exact same matchup. A gold banner shows the active challenge; changing any setting opts out into a custom game (and the share URL follows). A 🎯 Daily Challenge menu button loads today's seed. Resume-aware: a challenge never wipes a saved cup. en+zh. Smoke 51 → 53.og:image). Shared links now render as a card, not bare text: scripts/gen-og.mjs renders a brand-matched 1200×630 og.png (committed, copied into build/) and the <head> carries absolute og:image/twitter:image + summary_large_image. A smoke assertion checks the tags point at og.png and the file is served.A fourth review (8.1/10) on build e32c7ec. Each concrete claim was verified on the live build before fixing; "Cup"/"nation" and the round-framed CTA were kept as deliberate calls.
952c127 — Possession readability. The ball-locator ring glows gold when you control the ball (cyan otherwise), tying the ball to your player in a cluster; the selected-player overhead marker gains a lock-on scale pulse.08f047a — Responsive fit. Beyond #54's scroll-safety, the primary actions now sit above the fold without scrolling: a width-based mobile compaction (the ≤840px-height rule missed 844px phones) brings the portrait CTA above the fold; the result screen fits all four actions at 720 even for the champion layout; landscape drops the menu title + shrinks so the Enter CTA fits.86da71e — Fix: Share button silent no-op. The whole share action (even the desktop download) was nested in canvas.toBlob, whose callback doesn't fire in throttled tabs / where navigator.share is absent → clicking did nothing. Now file-share is probed synchronously; otherwise the card downloads via the synchronous toDataURL + link-copy, always landing on a visible state ("Image saved · link copied"). Label → "Share result card…".A third review (8.0/10) on the post-fix build — all polish, no broken gameplay. Two PRs; its three concrete HIGH claims were each verified against the live build before fixing.
f8bec37 — UX/copy. End-screen primary "New Cup" → "Play Again" (the deterministic draw means it replays the same bracket, not a new cup); "Share result…" (the ellipsis signals it opens the OS share sheet / a download); and brief action-feedback toasts — Switched / Through ball / Lofted pass — for the explicit verbs that had no text confirmation.27b76f3 — Responsiveness + a11y. Full-screen panels are now justify-content:safe center + overflow-y:auto (centered when they fit, top-aligned + scrollable when they overflow), and the end screen is compacted at ≤840px height — so all four result buttons fit at 1280×720 and mobile-landscape menu cards no longer clip. Hidden panels also drop to display:none after their fade-out (no ghost layout bounds), completing the inert a11y fix from #50.A follow-up review (on the copy/UX build) found polish-level issues — no broken gameplay, no console errors. Four PRs (the share hook + the "scripted bracket" were already shipped):
536c769 — Ball-locator ring + persistent camera chip. A cyan ring tracks the ball on the pitch so it stays findable when robots cluster; a small "📷 Follow / Ball / Wide" label under the corner buttons shows the current camera mode at all times (not just the switch toast).b9f1193 — EN localization + mobile CTA. English mode no longer mixes scripts on the geography line — the endonym rides the name in parens ("JAPAN (日本)" + "Tokyo"), the capital line is always the UI language. The start CTA's wide tracking is tightened on phones so it no longer wraps to a tall block at 390px.ff2baab — A11y: inert inactive panels. inert now mirrors the .hidden class on every full-screen panel (menu / pause / end / bracket), so hidden panels are out of the focus + accessibility tree; an open modal also inerts the screen behind it.991ea5f — Bracket: future rounds stay TBD. Follow-up to #42 — a round's team slots now appear only once the round feeding it has been played, so a fresh bracket shows just round 1 (later rounds are TBD), not predicted advancers.A Codex playtest review flagged control clarity + pause/tournament-state reliability. The "score changed while paused" report turned out not to be a simulation leak (pause fully freezes the sim) — it was a pause that silently never engaged. Five PRs:
5353422 — Copy/UX pass. Brand line consolidated to "…Football Cup" everywhere (right on Mars/Earth, not just the Moon); controls "PES" → Advanced; bracket kicker → "The Road to the Cup"; format line "win N matches"; an informative pause subtitle ("Time, AI and ball physics are frozen…"). Presentation: helper text is sentence-case now (titles + CTAs keep caps), decorative ✦ trimmed from secondary text, and the in-field hint regrouped into Move / Attack / Match (with the C camera key). A11y: icon-button tooltips.95b24e4 — Camera options. Three in-match modes via a 🎥 HUD button + the C key: Follow (default), Ball (keeps the ball framed near the box), and Wide (a high, pulled-back tactical view of the half-pitch + goal). Persisted; default Follow leaves the existing feel unchanged.62055d6 — Progressive bracket. Scores + a decided winner now appear only for rounds you've played; future rounds show the pairing alone (no pre-filled AI scores, no pre-crowned champion), so the tournament reads live instead of scripted.e3e79e7 — Control clarity. Stronger selected-player ring + larger overhead marker with a high opacity floor; a possession cue (the ring brightens + swells when the ball's in range); and the first-match control prompt auto-dismisses so it never lingers over the field.1759e2a — Pause + in-match menu. Pause now engages from any in-match state (countdown, goal celebration, shootout) instead of being a silent no-op outside PLAY, and the pause overlay gained Resume · Restart match · Quit to Setup.2e1af69 — Gravity Lab P2: per-world environment grade. Each world now looks as distinct as it plays — DreamEnvironment.applyWorld() re-grades fog, the two lights, the sky tint, and the regolith, and hides the Earthrise on Earth. Mars warms to a dusty rust, Earth cools to a deep-blue "home" sky; Moon is a pixel-identity (defaults unchanged, GK-balance tests untouched). Colours/visibility only — no geometry, no recompile.5699acb — Shareable result card (P1). A Share button on the end screen composes a dependency-free 1200×630 PNG on an offscreen canvas (title · world + gravity · scoreline · Champion 🏆/round result · best-goal minute · mechcup.com), then routes to navigator.share({files}) on mobile or download + copy-link on desktop. Client-side only — the game uploads nothing. (Goal clip P2 + challenge-link P3 stay phased.)622af3d — Geography "Did you know?" cards. All 16 nations gained a bilingual facts{capital, did, flag} + a native endonym; the pre-match walkout now shows the endonym · capital under each flag and a fun fact about your opponent. Ambient, no quiz. (A smoke assertion caught a merge that would have clobbered the team's emoji flag — fixed.)f6f9065 — Gravity Lab. A 🌕 Moon / 🔴 Mars / 🌍 Earth WORLD selector flips the game's lone gravity constant (CFG.gravity) per match — lower g = higher, hangier arcs. Gameplay g is compressed for playability (Moon stays the baseline, so live play + the GK-balance tests are unchanged) while the fact card carries the real m/s². The "Moon 2026 · Mars 2030" tagline, made playable.ecf6870 — Docs: land the gravity/geography/shareability design trio on main (design-only markdown + a CLAUDE.md pointer), verified against the live code.d5bf54d — Bracket viewable from home + your first opponent on the right card. The bracket view existed but only the end-screen opened it; now the Cup card and the gold format line both open it from the menu (a pure entryCup() previews the deterministic draw without touching a saved run). Because the draw is deterministic, the right card now shows your actual first-round opponent (flag · name · round, e.g. Quarter-final) instead of a generic 🏆, updating live as you cycle nation — the menu "vs" is a real matchup, with the full draw one tap away. New smoke: cardB shows an opponent (not 🏆), updates on nation change, and opens the bracket.heading vector, trophy-dust + dream-mote scratch reuse, mote drift staggered to ~30 Hz, particle pools sweep/draw only a live high-water span, HUD clock writes only on change); fire-and-forget timers (end-screen reveal + goal chant) now tracked and cleared on a fast restart; i18n/a11y (translate the through-ball aria-label + the menu "vs", drop untranslated icon tooltips, de-interactive the inert Cup card); test/build hardening (smoke wraps cleanup in try/finally + checks console errors across the whole run with a tightened benign-analytics filter; build.mjs throws if the version-stamp marker is missing; the Lighthouse job drops its unused checkout + blind sleep); and removed the dead shootout intro phase. No behaviour change for players.9e78950 — Google Analytics 4 (build-time gtag injection). Injects the gtag loader at a <!--GA4--> marker only when GA_MEASUREMENT_ID is set (unset = no tag); track() forwards engagement events to window.gtag. Coexists with Cloudflare Web Analytics + the localStorage counters. New test/build-ga.mjs regression.f8f224f — Fix the code-review high + medium findings. Sudden death no longer ends a round early (the lead guards are regulation-only); validCup range-checks stored nation indices; the RAF loop stops + surfaces fatal() on a persistent crash instead of spamming forever; the tautological pass/tackle smoke assertion now flickers cross-team so it genuinely tests the debounce.e223298 — Docs: add CHANGELOG.md (every PR, #1–#26) + a README link.c28685b — Configurable knockout (4 / 8 / 16) + a WC-style results bracket + menu framing. CUP_SIZE now drives a power-of-two bracket with fixed (deterministic, seed-based) draws; added nations 8–15 for a 16-team option (dormant at the live size 8). The end-screen View Bracket shows every tie with scorelines, penalty scores in parens, the winner bolded and your path lit gold. The menu now frames the knockout up front: a gold format line that sizes with CUP_SIZE and a start button that names the entry round. (Browser-session review caught + fixed a smoke typo — T.n→T.nationCount — before merge; game code was correct.)94de619 — Extra-time countdown + a discoverable "Watch" toggle. Golden-goal extra time now shows a gold countdown to the shootout in the clock (90'+ · ET 0:29) instead of an invisible deadline. Added a persistent 👁 Watch button (+ V key), shown only while hands-on, that hands the match back to the AI — the inverse of demo-mode takeover, so watching ↔ playing is a clear round-trip both ways.9a150e9 — Watch / auto-play demo mode. A ✦ Watch ✦ menu button lets the AI play both sides while you watch (a "● WATCHING" chip + hint, controls hidden); any input instantly takes over into the player nearest the ball. Demo-only liveliness tuning keeps it from stalemating.2d2ca30 — Pre-match walkout + anthem. A new WALKOUT beat before the 3·2·1: a glassy card (both flags · "vs" · the round) over a short synth anthem, the crowd hushing then swelling on the kickoff whistle.0033c0e — Crowd goal chant + near-miss groan + build-up swell. Synth-only additions to the existing crowd bed: a pitched terrace chant a beat after the goal roar, an "ooooh" on a near-miss, and a murmur that rises into a lined-up shot.8d7ec2c — Goal lower-third. A TV-style scoreline band slides in on a goal (🇧🇷 BRA 2 – 1 USA 🇺🇸 + minute + scorer), riding the existing replay/slow-mo path; distinct from the centered GOAL splash, which keeps its no-paintable-background guard.f5d31f9 — Mobile "switch player" button (⇄). A touch button (above the joystick) for switchPlayer(), which was keyboard-only (Q/Tab) — touch players were stuck on the kickoff striker.38422ba — Make penalties obvious. Added an on-field aim guide (arrow + goal-line target ring), touch aiming via the movement stick, a real charge that builds power during the shootout (it previously fired at min power), and on-screen instructions + a corner-aim tip. Fixed a keeper so well-placed corners actually score.a4c7411 — Penalty shootout (#4B). A tied knockout goes to 30s golden-goal extra time, then a self-contained best-of-five (then sudden-death) shootout with a read-and-commit keeper, a bilingual tally HUD, and a (4–3 pens) end-screen scoreline.c7bd686 — Bump deploy actions off the deprecated Node 20 runtime (checkout@v7, setup-node@v6, wrangler-action@v4, build Node 22) — clears GitHub's Node-20 deprecation warnings.ebcdf6f — Fix the build's $& footgun. Inlining the minified script used String.replace(str, str), so a $& in minified JS was treated as a match-substitution pattern and corrupted the bundle ("Unexpected token '<'" on boot). Now uses a function replacement.e1232e7 — Analytics: point the Cloudflare Web Analytics beacon at the mechcup.com site.de1e992 — Docs sweep to MECH CUP 2026, preserving the load-bearing internal identifiers (window.DreamCup/DreamCupPerf, dreamCupStats(), DreamEnvironment, the dream-cup Pages project + dream-cup.pages.dev).ca2dff3 — Lunar visual pass: cratered regolith ground, a bigger Earth in the sky, cooler crowd / fainter aurora.d702aaa — Rebrand: The Electric Dream Cup → MECH CUP 2026 / 机甲杯 2026 — the first space robot football final, set on the Moon (copy, theme, head metadata).34803cd — Integrate the parallel session's work into the Cup: trademark-safe naming (no tournament-brand names), Easy/Medium/Hard difficulty labels, dormant monetization hooks, and PES-style controls (through ball / lofted pass / Simple⇄PES toggle).7c4dae7 — #4C: full 8-team bracket view on the end screen (rebased onto the MECH CUP main).9fb390b — Smoke: skip the WebGL draw in the harness (__norender) — fixes a post-gameplay page wedge that stalled later assertions.09a48ab — Fix the deploy gate with a per-step smoke timeout (a job-level limit wrongly counted variable Chrome-install time against the smoke budget).c60842e — Cup persistence (#4A): resume an in-progress run across reloads.e61928e — Re-add Cup test coverage via the pure drawCup function (asserts bracket invariants without wedging the headless harness).aef19e2 — Remove a Cup smoke block that wedged the headless harness, to green the gate (coverage restored in #6).b835ddc — Speed up the smoke gate: cache the number textures + lighten the Cup test.8f0c58a — Fix a smoke-test hang from a stale .team.attack (→ NaN) + add a CI timeout.0505ab8 — Fix a setTeam crash: a number-mesh skin-key mismatch in the per-round re-kit path.75fcaf9 — Add The Cup: an 8-nation single-elimination knockout (Quarter-final → Semi-final → Final) with a per-round difficulty ramp — pick a nation and play through to the title.init — Initial release of The Electric Dream Cup (pre-PR, direct commit): the single-file Three.js arcade robot-football match that everything above builds on.