diff --git a/.claude/commands/verify-planet.md b/.claude/commands/verify-planet.md index bd8a0a6..a98e55d 100644 --- a/.claude/commands/verify-planet.md +++ b/.claude/commands/verify-planet.md @@ -26,8 +26,10 @@ sections if anything below is ambiguous; it is the source of truth. `sceneKey === 'Planet'` before reading any scene field. - **`input` is module-global.** Call `resetInput()` between maneuvers and before every scene restart, or a stuck `right` walks across restarts. -- **Re-cast the platform.** Summoned platforms fade after ~5s; if a maneuver stalls and - `platformCount === 0`, re-cast `summon-platform`. +- **The platform is arm-on-arrival.** A summoned platform holds indefinitely and only starts its + ~5s life on the astronaut's first contact — so it can be cast early and `platformCount` stays 1 + while you cross the sentry band. Re-cast `summon-platform` only if `platformCount === 0` (the + astronaut touched it and then died). - **Driving live physics is the flake source.** Prefer the deterministic `input` seam over synthetic keystrokes. planet-1 is the most robust headless clear. diff --git a/BACKLOG.md b/BACKLOG.md index f30ae04..96ec2e5 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,6 +13,19 @@ Pick items with the `project-backlog` skill in Claude Code. ## Open +### [Improvement] Make the support seat able to see world state +- **Why:** Three AI-pilot playtests (2026-08-11, 2026-08-11 run 2, 2026-08-12; reports in the pi fork at `.pi/playtest/PILOT-*.md`) each independently reported the same thing: the phone player is **blind to the world**. The Spellbook shows four tiles and nothing else — not where the astronaut is, not whether a freeze is still running, not whether a platform is already banked. Every pilot pair coordinated by describing the screen to each other in chat, which is exactly the coordination tax the asymmetric premise is supposed to make *fun*, not *necessary*. Related: pilots misattributed deaths to the wrong obstacle because the phone had no signal — a death at x≈377 (sentry band) drew "should I cast Illuminate?", 570px away from the dark zone at x=920. +- **Acceptance:** The phone shows some minimal, cozy read on world state between casts — enough to answer "where is he / is my last cast still up". Deliberately NOT a second camera: the laptop stays the shared screen. Candidate cut: a thin progress rail (spawn → goal) with the astronaut's position, plus a live "freeze up / platform waiting" indicator on the matching Spellbook tiles. Needs a game→phone signal (the phone currently only knows which power it tapped), so it is a vertical slice like `planet-started` was in M9 — new wire message, `relayForward` rule, both clients in the same commit. +- **Size:** M +- **Added:** 2026-08-13 + +### [Improvement] Stop punishing the pause — the level kills you for standing still +- **Why:** Same three pilot reports. Deaths cluster in the **gaps between commands**, while the pair is coordinating. The co-op loop structurally requires waiting (one player solves a ~5s puzzle while the other holds position), but planet-1 has no safe place to hold: the sentry patrols the band, the pit is a one-way commitment, and an idle astronaut in the wrong 100px is dead. The asymmetric design asks for a conversation and the level charges you for having one. +- **Acceptance:** A pair that stops moving to coordinate does not die for it. Candidate levers (pick one, don't stack): a safe pocket the sentry doesn't reach between the band and the pit lip; a sentry that only kills on *its* forward sweep; or shortening the band so the hold happens outside it. Planet-1 must stay clearable-only-with-both-powers, and Freeze Stars must stay load-bearing. +- **Size:** M +- **Added:** 2026-08-13 +- **Note:** The pit half of this problem is already fixed — see "Platform arms on arrival" in Done. This item is the remaining sentry-band half. + ### [Exploration] Playtest the shipped game with girlfriend — the "is it fun?" gate - **Why:** The fun-gate playtest has genuinely never run. Originally framed as the M2 gate "before any M3 work", but the game has since grown into a hub + 3 planets + 4 powers + talents + the rhythm portrait without ever being tested on the only audience that matters. The whole asymmetric premise lives or dies here. (Audit Phase 2 fixed the feel bugs — invisible bursts, truncated freezes — so the playtest is finally worth running.) - **Acceptance:** Play at least one full co-op session (handshake → hub → clear planets 1–3 with real phone casts → spend stardust in the talent tree → read the portrait card). Write down: did casts feel rewarding? Was puzzle difficulty right? Did the freeze/platform/phase windows feel tight or generous? Did the pairing feel meaningful — and did the portrait feel true to how you two played? @@ -36,6 +49,14 @@ _(nothing in flight)_ ## Done +### [Bug] Planet-1's pit is uncrossable in co-op — the platform arms on arrival now +- **Why:** Three AI-pilot co-op playtests (reports in the pi fork at `.pi/playtest/PILOT-2026-08-11.md`, `-run2`, `PILOT-2026-08-12.md`) never once crossed the pit at x=660–880, and the corrected death table pinned the cause precisely. The sentry band (x≈80–560) is fine — pre-committed moves cross it in ~2.26s including driver reaction against a 3.0s freeze, 0.74s of slack, confirmed twice. The blocker was the **platform's earn-vs-life ratio**: it cost ~4.9s to earn (4835–4869ms, n=5), lived 5.0s, and dropped at a fixed x=770 immediately after the band — so the pair had to hold a 3.0s freeze and a 5.0s platform open **simultaneously** from two puzzles with a ~7× solve-time spread (freeze 672–676ms, n=5). No pair managed the overlap; every death clustered at the pit lip (x≈659, 665). One armed move landed 0.2s after the platform expired. +- **Acceptance:** A pair that solves the freeze puzzle at any point during the platform's availability window can cross the pit — no frame-perfect overlap — and planet-1 stays unclearable without **both** powers. +- **Size:** S +- **Added:** 2026-08-13 +- **Completed:** 2026-08-13 +- **Note:** One lever, minimal diff, exactly as the report recommended: `summonPlatform()` no longer schedules the fade at drop time; it stamps `lifetimeMs` on the sprite and the platform **holds indefinitely**. The astronaut↔platforms collider now doubles as the "reached it" signal, calling a new `armPlatform()` that starts the same 5000ms (8000ms boosted) fade/destroy on the astronaut's first **landing** and no-ops thereafter via an `armed` sprite-data flag. The landing gate is load-bearing: the collider fires on any contact on any face, so without it an undershoot that clips the platform's side on the way into the pit would burn the whole bridge (adversarial review F1). The lifetime is carried on the sprite, not in a scene field, so a scene restart can't strand a countdown. The re-cast branch was reworked to match: `PLATFORM HOLDS!` now fires only for an *unarmed* platform (which a re-cast can't improve on), while a re-cast onto an armed one calls a new `refreshPlatform()` that cancels the pending expiry and any fade in flight — otherwise a burnt platform silently cost the pair a whole ~4.9s solve. The freeze timer, the patrol band, the earn cost and the pit geometry are untouched, and both powers stay mandatory (the pit is still uncrossable without a platform, the band still unrunnable without a freeze). The turn is now the *phone's* to sequence: bank the platform first (~4.9s), then earn the freeze (~0.7s), instead of racing them. Phone copy follows the behaviour ("bridge waits, then holds 5s once he steps on"); `docs/AUTONOMY.md` and `/verify-planet` re-document the sharp edge as arm-on-arrival (`platformCount` now stays 1 while the driver crosses the band). One knock-on: because a never-mounted platform now persists, planet-3's `platformDrop` had to move 730 → 750 — its ledge sits on continuous ground, and at 730 an astronaut *blocked* by it stood 14px inside the plasma curtain and died the moment the Phase Dash window closed, forever (adversarial review F5). Three colocated tests pin the new clearance. Gated on typecheck ×2 · Vitest · build · smoke:relay, and **live-verified headlessly** at `?solo=1&test=1` — see the PR for the run. No protocol, relay, dependency, or puzzle-difficulty changes. + ### [Improvement] Hardening pass — fix the 2026-07-09 audit findings - **Why:** A full-project audit ([`docs/AUDIT-2026-07-09.md`](docs/AUDIT-2026-07-09.md)) found two remotely triggerable relay crashes, disconnect-blindness on both clients (the couch-playtest killer), two bugs that undermine shipped features (freeze re-cast truncates the freeze; **every particle burst renders off-screen**), a protocol-guard hook that auto-approves the edits it should guard, and onboarding docs stuck at M2/M4 that misdirect every fresh session. - **Acceptance:** Phases 0–5 of the audit's fix plan landed with their per-phase gates green (phase 6 rides the actual public deploy). Findings F-01…F-59 closed or explicitly deferred with a note. diff --git a/docs/AUTONOMY.md b/docs/AUTONOMY.md index 3b692ff..c65ba15 100644 --- a/docs/AUTONOMY.md +++ b/docs/AUTONOMY.md @@ -135,7 +135,7 @@ reaching y=600, `won` staying false, `maxX` never crossing the pit. 1. **Boot** → navigate; `waitForFunction(() => !!window.__constellation)`; assert the 6 keys. 2. **Clean slate** → `localStorage.removeItem('constellation:progress')`, reload. -3. **Positive clear** → `startPlanet(id)`; poll `getState()`; set `input.right=true`; `cast('freeze-stars')` near the sentry, `cast('summon-platform')` (re-cast if `platformCount===0`, the platform expires after 5s), `cast('illuminate')` near the ledge; bunny-hop (`input.jump=true`) across; expect `won===true`, `completed[id]===true`, the next planet in `unlockedPlanets`. +3. **Positive clear** → `startPlanet(id)`; poll `getState()`; set `input.right=true`; `cast('freeze-stars')` near the sentry, `cast('summon-platform')` (the platform holds until the astronaut touches it, so it can be cast early), `cast('illuminate')` near the ledge; bunny-hop (`input.jump=true`) across; expect `won===true`, `completed[id]===true`, the next planet in `unlockedPlanets`. 4. **Negative — omit Freeze** → drive right only; expect `respawnCount` rises, `won` false, `astronautX` stuck before the corridor. 5. **Negative — omit Platform** → freeze past the sentry, then drive right with no platform; expect `respawnCount` rises (fall into the pit), `won` false. 6. **Illuminate (perceptual)** → assert `darkZonePresent` `true → false` on cast (not an omit test). @@ -151,7 +151,7 @@ reaching y=600, `won` staying false, `maxX` never crossing the pit. - **Hub `getState` is zeroed** — always wait for `sceneKey === 'Planet'` before reading scene fields. - **`input` is module-global** — `resetInput()` between maneuvers, or a stuck `right` walks across restarts. -- **Platform lifetime** — summoned platforms fade after 5000ms; a slow driver must re-cast when `platformCount===0`. +- **Platform lifetime is arm-on-arrival** — a summoned platform holds indefinitely and only starts its 5000ms life (8000ms boosted) on the astronaut's FIRST contact, then fades. So `platformCount` stays 1 while the driver is still crossing the sentry band, and a driver that touches the platform and then dies must re-cast. - **Follow camera (M5)** — the planet camera now lerp-follows the astronaut horizontally (vertical is locked; `showWin()` recentres the frame for the end-card). `astronautX/Y` are **world** coords and are unaffected, but this is one more reason to assert on `won`/state, never on-screen pixels. The widened bounds are the *camera*'s only — physics world bounds (and thus reach-math) are unchanged. - **Driving live physics is the flake source** — prefer the deterministic `input` seam over synthetic keystrokes, poll state rather than sleeping fixed times, and assert on `won`/state, not pixels. (Mounting a stepping-stone platform headlessly is genuinely fiddly; the simplest robust positive clear is planet-1.) - The committed driver is an **MCP/Playwright playbook**, not a re-runnable in-repo suite (the stack is locked — no Playwright dependency). The durable, CI-able assertions live in Vitest (`*.test.ts`). diff --git a/src/game/planets/planet3.test.ts b/src/game/planets/planet3.test.ts index d65a8b3..f68fa2a 100644 --- a/src/game/planets/planet3.test.ts +++ b/src/game/planets/planet3.test.ts @@ -128,6 +128,47 @@ describe('planet3Config — ground + bounds', () => { }); }); +/** + * Summoned-platform placement (adversarial review F5). + * + * Planet-3's ground is continuous, so the summoned platform is a knee-high + * ledge ON the walking path rather than a bridge over a pit: an astronaut + * walking right is BLOCKED by its left face instead of landing on it. Because a + * platform's lifetime only starts on a LANDING, a never-mounted one persists for + * the rest of the scene — so wherever it leaves a blocked astronaut standing, it + * leaves them standing there indefinitely. That spot must not be inside the + * plasma curtain, which kills the moment the Phase Dash window closes. + */ +describe('planet3Config — summoned platform leaves no lethal pin', () => { + const PLATFORM_HALF_W = 48; // 96px platform texture (Boot.ts) + const ASTRONAUT_W = 32; // 32×48 sprite (Boot.ts / Astronaut.ts) + + it('leaves a BLOCKED astronaut fully clear of the hazard curtain', () => { + const c = planet3Config; + const hazard = c.hazardLane!; + const curtainRight = hazard.x + hazard.width / 2; + const platformLeft = c.platformDrop.x - PLATFORM_HALF_W; + // Walking right into the ledge parks the astronaut's RIGHT edge on the + // platform's left face, so its left edge is one body-width further back. + const blockedAstronautLeft = platformLeft - ASTRONAUT_W; + expect(blockedAstronautLeft).toBeGreaterThanOrEqual(curtainRight); + }); + + it('sits at knee height on continuous ground (blocked, not landed, when walked into)', () => { + const c = planet3Config; + const platformTop = c.platformDrop.y - 7; // 14px platform texture + const astronautBottomOnGround = GROUND_SURFACE_Y; + // Feet below the ledge's top surface => a horizontal block, never a landing. + expect(astronautBottomOnGround).toBeGreaterThan(platformTop); + }); + + it('stays clear of the dark zone so the ledge is never hidden by it', () => { + const c = planet3Config; + const darkZoneLeft = c.darkZone.x - c.darkZone.width / 2; + expect(c.platformDrop.x + PLATFORM_HALF_W).toBeLessThanOrEqual(darkZoneLeft); + }); +}); + describe('planet3 progression', () => { it('is the last planet in the chain and is registered with a config', () => { const last = PLANETS[PLANETS.length - 1]; diff --git a/src/game/planets/planet3.ts b/src/game/planets/planet3.ts index 694217f..1458001 100644 --- a/src/game/planets/planet3.ts +++ b/src/game/planets/planet3.ts @@ -76,9 +76,20 @@ export const planet3Config: PlanetConfig = { // Optional flourish: casting Summon Platform drops a small ledge here. Not a // gate (the ground is continuous), so it is purely a player's choice. Placed // in the open span PAST the curtain (right edge 664) and BEFORE the dark zone - // (left edge 805) — the 96px platform (632..728 if centered at 680) would - // otherwise overlap the curtain's respawn band, so center it at 730 (682..778). - platformDrop: { x: 730, y: 470 }, + // (left edge 805). + // + // The centre is pinned by a THIRD constraint (adversarial review F5): the + // platform sits knee-high on continuous ground, so an astronaut walking right + // is BLOCKED by its left face rather than landing on it — and a summoned + // platform only starts its life on a landing, so a never-mounted one persists. + // At the old x=730 (span 682..778) a blocked 32px-wide astronaut sat at + // left=650, i.e. 14px INSIDE the curtain's 576..664 band, so it died the + // instant the Phase Dash window closed — on every attempt, forever. + // blocked astronaut left = (x - 48) - 32 must be >= curtain right 664 → x >= 744 + // platform right = x + 48 must be <= dark-zone left 805 → x <= 757 + // x=750 sits mid-window: blocked left = 670 (6px clear of the curtain), right + // edge 798 (7px clear of the dark zone). Asserted in planet3.test.ts. + platformDrop: { x: 750, y: 470 }, hiddenPlatform: { x: HIDDEN_PLATFORM_X, y: HIDDEN_PLATFORM_Y }, darkZone: { x: HIDDEN_PLATFORM_X, y: HIDDEN_PLATFORM_Y, width: 150, height: 120 }, fallRespawnY: 600, diff --git a/src/game/scenes/Planet.ts b/src/game/scenes/Planet.ts index d66c99e..bbe6c6a 100644 --- a/src/game/scenes/Planet.ts +++ b/src/game/scenes/Planet.ts @@ -17,6 +17,9 @@ import { addMuteButton } from '../juice/muteButton'; const FREEZE_DURATION_MS = 3000; const PLATFORM_LIFETIME_MS = 5000; const PLATFORM_FADE_OUT_MS = 800; +// Slop allowance when deciding "the astronaut LANDED on this platform" rather +// than clipped its side — see armPlatform(). +const PLATFORM_LANDING_EPSILON = 2; const DARK_ZONE_FADE_MS = 800; // Phase Dash: an invulnerability WINDOW vs. the hazard lane (the load-bearing // part — a calm walk-through, not a reaction), plus a brief dash speed boost so @@ -262,7 +265,11 @@ export class PlanetScene extends Phaser.Scene { this.astronaut = new Astronaut(this, this.config.spawn.x, this.config.spawn.y); this.physics.add.collider(this.astronaut.sprite, ground); this.physics.add.collider(this.astronaut.sprite, ceiling); - this.physics.add.collider(this.astronaut.sprite, this.platforms); + // The summoned platform's lifetime starts on CONTACT, not on drop, so the + // collider doubles as the "astronaut reached it" signal (see armPlatform). + this.physics.add.collider(this.astronaut.sprite, this.platforms, (_astronaut, platform) => { + this.armPlatform(platform as Phaser.Physics.Arcade.Sprite); + }); this.physics.add.collider(this.astronaut.sprite, this.hiddenPlatforms); // Cozy follow camera. Widen ONLY the camera bounds into flat-colour margin @@ -395,18 +402,28 @@ export class PlanetScene extends Phaser.Scene { this.juice.trigger('freeze', this.enemy.sprite.x, this.enemy.sprite.y, boosted); this.flashBanner(boosted ? 'DEEP FREEZE!' : 'FREEZE!', '#7ad8ff'); break; - case 'summon-platform': - if (this.platforms.getChildren().length > 0) { - // Re-cast while one is alive: the platform stays as-is, but the phone - // player solved a whole puzzle — show that the cast arrived (F-19), - // mirroring illuminate's banner-only re-cast. + case 'summon-platform': { + const live = this.platforms.getChildren()[0] as Phaser.Physics.Arcade.Sprite | undefined; + if (live && !live.getData('armed')) { + // Re-cast while an UNARMED platform waits: it holds until stepped on, + // so a re-cast cannot improve on it — show that the cast arrived + // (F-19), mirroring illuminate's banner-only re-cast. this.flashBanner('PLATFORM HOLDS!', '#9a7aff'); break; } - this.summonPlatform(boosted ? PLATFORM_BOOSTED_MS : PLATFORM_LIFETIME_MS); + if (live) { + // Re-cast onto an ARMED platform (already counting down): the phone + // player just spent another ~5s solve, so hand the bridge a fresh + // window instead of swallowing the cast under a banner that claims + // it is fine — the swallow is what made a burnt platform terminal. + this.refreshPlatform(live, boosted ? PLATFORM_BOOSTED_MS : PLATFORM_LIFETIME_MS); + } else { + this.summonPlatform(boosted ? PLATFORM_BOOSTED_MS : PLATFORM_LIFETIME_MS); + } this.juice.trigger('platform', this.config.platformDrop.x, this.config.platformDrop.y, boosted); this.flashBanner(boosted ? 'LASTING PLATFORM!' : 'PLATFORM!', '#9a7aff'); break; + } case 'illuminate': this.illuminate(); break; @@ -462,12 +479,57 @@ export class PlanetScene extends Phaser.Scene { }); } + /** + * Drop the bridge platform, ARMED BUT NOT COUNTING DOWN. The lifetime is not + * started here — see armPlatform(), which starts it on the astronaut's first + * contact. + * + * The playtest finding this answers: the pit at x=660–880 sits immediately + * after the sentry band, so a drop-time countdown forced the pair to hold a + * 3s freeze and a 5s platform open SIMULTANEOUSLY from two puzzles with a ~7× + * solve-time spread (freeze ~0.7s, platform ~4.9s). No pair ever managed the + * overlap; every death clustered at the pit lip. Holding the platform until + * it is stepped on makes the pit a coordination problem instead of a + * stopwatch problem — the phone can bank the platform first, then earn the + * freeze — while keeping BOTH powers mandatory: the pit is still uncrossable + * without a platform and the band is still unrunnable without a freeze. + */ private summonPlatform(lifetimeMs: number) { const sprite = this.platforms.create(this.config.platformDrop.x, this.config.platformDrop.y, this.tex('platform')) as Phaser.Physics.Arcade.Sprite; sprite.setAlpha(0); sprite.refreshBody(); + // Carried on the sprite rather than in a scene field so a scene restart + // (which rebuilds the group) can't leave a stale countdown behind. + sprite.setData('lifetimeMs', lifetimeMs); this.tweens.add({ targets: sprite, alpha: 1, duration: 200 }); - this.time.delayedCall(lifetimeMs - PLATFORM_FADE_OUT_MS, () => { + } + + /** + * Start a platform's expiry countdown when the astronaut LANDS on it. Fired + * from the astronaut↔platforms collider, so it runs every frame the astronaut + * rests on it — the `armed` flag makes all but the first call a no-op, and the + * fade/destroy is the same one summonPlatform() used to schedule at drop time. + * + * The landing gate is load-bearing, not decoration: the collider fires on ANY + * resolved contact, on any face. Without it, a jump that undershoots and clips + * the platform's SIDE on the way into the pit would burn the whole bridge on a + * platform nobody stood on — the astronaut respawns back at spawn.x needing a + * fresh freeze to re-cross the sentry band, and cannot possibly return in + * time. On planet-3 it is worse than an edge case: the pit there is degenerate + * (continuous ground) and the platform is a knee-high optional ledge, so a + * horizontal body-bump at ground level is its ONLY possible first contact. + */ + private armPlatform(sprite: Phaser.Physics.Arcade.Sprite) { + if (sprite.getData('armed')) return; + const astronaut = this.astronaut.sprite.body as Phaser.Physics.Arcade.Body; + const platform = sprite.body as Phaser.Physics.Arcade.StaticBody; + // Landed => arcade separation has parked the astronaut's feet on the + // platform's top face. A side clip leaves the feet well below it; a bonk on + // the underside leaves them below it too. 2px absorbs separation slop. + if (astronaut.bottom > platform.top + PLATFORM_LANDING_EPSILON) return; + sprite.setData('armed', true); + const lifetimeMs = sprite.getData('lifetimeMs') as number; + const timer = this.time.delayedCall(lifetimeMs - PLATFORM_FADE_OUT_MS, () => { this.tweens.add({ targets: sprite, alpha: 0, @@ -475,6 +537,24 @@ export class PlanetScene extends Phaser.Scene { onComplete: () => sprite.destroy(), }); }); + // Kept so a re-cast can cancel the countdown rather than be swallowed. + sprite.setData('expiryTimer', timer); + } + + /** + * Hand an already-counting-down platform a fresh window: cancel the pending + * expiry, undo any fade in flight, and put it back into the "waiting to be + * stepped on" state. An astronaut still standing on it re-arms next frame (a + * full new lifetime); one that has fallen off gets the same indefinite hold a + * first cast would have given. + */ + private refreshPlatform(sprite: Phaser.Physics.Arcade.Sprite, lifetimeMs: number) { + const timer = sprite.getData('expiryTimer') as Phaser.Time.TimerEvent | undefined; + timer?.remove(); + this.tweens.killTweensOf(sprite); + sprite.setAlpha(1); + sprite.setData('lifetimeMs', lifetimeMs); + sprite.setData('armed', false); } /** diff --git a/src/phone/App.tsx b/src/phone/App.tsx index 932f535..f221b89 100644 --- a/src/phone/App.tsx +++ b/src/phone/App.tsx @@ -45,7 +45,7 @@ const MIN_VIEWPORT_HEIGHT = const FEEDBACK: Record = { 'freeze-stars': { title: 'Cast!', color: '#7ad8ff', sub: 'Freeze Stars — enemies cold for 3s.' }, - 'summon-platform': { title: 'Cast!', color: '#9a7aff', sub: 'Platform — bridge holds for 5s.' }, + 'summon-platform': { title: 'Cast!', color: '#9a7aff', sub: 'Platform — bridge waits, then holds 5s once he steps on.' }, 'illuminate': { title: 'Cast!', color: '#f6c971', sub: 'Illuminate — dark zone revealed.' }, 'phase-dash': { title: 'Cast!', color: '#5eead4', sub: 'Phase Dash — slip through the plasma for 2.5s.' }, };