diff --git a/.claude/commands/verify-planet.md b/.claude/commands/verify-planet.md index a98e55d..fe4e70c 100644 --- a/.claude/commands/verify-planet.md +++ b/.claude/commands/verify-planet.md @@ -27,9 +27,12 @@ sections if anything below is ambiguous; it is the source of truth. - **`input` is module-global.** Call `resetInput()` between maneuvers and before every scene restart, or a stuck `right` walks across restarts. - **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). + ~5s life on the astronaut's first **landing** on it — a side clip or underside bonk does not arm + it (on planet-3 the ledge sits on continuous ground, so walking into it never starts the + countdown). So it can be cast early and `platformCount` stays 1 while you cross the sentry band; + never wait for `platformCount` to drop to 0 after a mere bump. Re-cast `summon-platform` only if + `platformCount === 0` (the astronaut landed on it and then died) — a re-cast onto an armed + platform refreshes it rather than being swallowed. - **Driving live physics is the flake source.** Prefer the deterministic `input` seam over synthetic keystrokes. planet-1 is the most robust headless clear. @@ -46,8 +49,9 @@ sections if anything below is ambiguous; it is the source of truth. ## 3. Positive clear (step 3) - `startPlanet('${ARGUMENTS:-planet-1}')`; poll until `sceneKey === 'Planet'`. - Drive `input.right = true`; cast the planet's load-bearing powers at the right beats: - `cast('freeze-stars')` near the sentry, `cast('summon-platform')` at the pit (re-cast if - `platformCount === 0`), `cast('illuminate')` near the hidden ledge; bunny-hop (`input.jump = true`) + `cast('freeze-stars')` near the sentry, `cast('summon-platform')` (it holds until the astronaut + *lands* on it, so it can be cast early; re-cast only if `platformCount === 0`), + `cast('illuminate')` near the hidden ledge; bunny-hop (`input.jump = true`) across. `resetInput()` between distinct maneuvers. - Expect `won === true`, `completed['${ARGUMENTS:-planet-1}'] === true`, and the next planet present in `unlockedPlanets`. diff --git a/BACKLOG.md b/BACKLOG.md index 96ec2e5..04f575f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -55,7 +55,7 @@ _(nothing in flight)_ - **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. +- **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 for a few seconds once they step on" — deliberately unnumbered, since the `lasting-platform` talent makes it 8000ms); `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. diff --git a/Decisions.md b/Decisions.md index 0153f85..acd36b9 100644 --- a/Decisions.md +++ b/Decisions.md @@ -10,3 +10,4 @@ | D6 | Docker slim approach: move `tsx` to deps (simple) vs esbuild-bundle (smallest) | Unresolved | 2026-07-09 | Audit decision #6 (F-60); only matters at deploy time — decide with Phase 6 | | D7 | Stack locked to Phaser 3, React 19, `ws`, Vite, tsx, TypeScript — new dependencies are a real decision, not routine | Approved | pre-2026-07 | CLAUDE.md "Do / don't"; reaffirmed by the audit's "no new dependencies" stance | | D8 | Audit Phase 6 (deploy hardening: F-34, F-37, F-38b, F-39, F-60, F-61) rides the actual public deploy rather than landing standalone | Approved | 2026-07-11 | BACKLOG deploy item note + PR #32; the phase's gate needs the deploy context anyway | +| D9 | A summoned platform's lifetime starts on the astronaut's first **landing**, not at drop time — it holds indefinitely until then, and a re-cast onto an armed platform refreshes it rather than being swallowed | Approved | 2026-08-13 | PR #40; three AI-pilot playtests never crossed planet-1's pit because a ~4.9s-to-earn platform lived 5.0s and had to overlap a 3.0s freeze. Reverses a durable gameplay contract, so recorded here. Knock-on: a never-mounted platform now persists, which forced planet-3's `platformDrop.x` 730 → 750 (adversarial review F5) | \ No newline at end of file diff --git a/HANDOFF.md b/HANDOFF.md index 760f5e9..00e890c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,24 +1,29 @@ # HANDOFF.md -_Last updated: 2026-07-26_ +_Last updated: 2026-08-13_ ## What was just done +- 2026-08-13: Review follow-ups from PR #40 landed (docs + phone copy + a tightened planet-3 test) — `docs/AUTONOMY.md` / `/verify-planet` now say the platform arms on the first **landing**, `Decisions.md` gained D9, this file was refreshed. +- 2026-08-13: Planet-1's pit fixed (PR #40, `8318f82`) — a summoned platform holds indefinitely and starts its 5000ms life on the astronaut's first **landing**, not at drop time. Adversarial review forced two in-loop fixes: the landing gate itself (a side clip used to burn the bridge) and planet-3's `platformDrop.x` 730 → 750 (a never-mounted ledge is now permanent, and at 730 it pinned a blocked astronaut inside the plasma curtain). Suite 204 → 207. - 2026-07-26: Project wiki initialized (PROJECT.md, HANDOFF.md, Sources.md, Decisions.md) via the project-wiki skill. - 2026-07-17: Vendored global Claude Code commands/skills/hooks into the repo via `/claudify-repo` (PR #33) — CLAUDE.md gained the tooling reference section. - 2026-07-11: M13 hardening completed — audit Phases 0–5 all landed (PRs #26–#31); the BACKLOG hardening item was marked Done with Phase 6 explicitly deferred to the deploy (PR #32). ## Where things stand -The game is playable end-to-end (hub + three planets + four powers + talents + rhythm portrait + procedural audio/mute) and freshly hardened: the 2026-07-09 audit's Phases 0–5 closed the relay crash vectors, disconnect blindness, the invisible-particles and freeze-truncation bugs, the doc drift, the test-spine gaps (CI now runs on every PR), and the phone hygiene findings — with StrictMode on and 204 Vitest green. Nothing is in flight. The two remaining BACKLOG items are both gated on Kyle rather than code: the never-run fun-gate playtest and the account-bound public deploy (which carries audit Phase 6 with it). +The game is playable end-to-end (hub + three planets + four powers + talents + rhythm portrait + procedural audio/mute) and freshly hardened: the 2026-07-09 audit's Phases 0–5 closed the relay crash vectors, disconnect blindness, the invisible-particles and freeze-truncation bugs, the doc drift, the test-spine gaps (CI now runs on every PR), and the phone hygiene findings — with StrictMode on and 207 Vitest green. Nothing is in flight. Four BACKLOG items are Open. Two are gated on Kyle rather than code: the never-run fun-gate playtest and the account-bound public deploy (which carries audit Phase 6 with it). Two are new code work surfaced by three AI-pilot playtests (2026-08-11 ×2, 2026-08-12): the support seat is blind to world state, and planet-1 punishes the pair for standing still to coordinate (the pit half of that is already fixed by PR #40; the sentry-band half remains). ## Immediate next move -Run the fun-gate playtest (one full co-op session with the partner). It has never run in the project's life, the whole asymmetric premise lives or dies on it, and the audit's Phase 2 specifically fixed the feel bugs that made running it earlier pointless. +Run the fun-gate playtest (one full co-op session with the partner). It has never run in the project's life, the whole asymmetric premise lives or dies on it, and the pit blocker that made the three AI-pilot runs unwinnable is now fixed. The two pilot-surfaced improvements (support-seat visibility, the sentry-band hold) are the obvious code work if the playtest stays gated — but the playtest should tell us which of them actually matters. ## Open questions / blockers - Audit decisions still open (see Decisions.md): Prettier adoption (D2), jump feel `isDown` vs `JustDown` (D3), PROJECT_GUIDE.md full regen vs the landed banner-only (D5), Docker slim approach (D6 — only matters at deploy). - Playtest is human-gated (partner availability); deploy is account-gated (Fly + itch.io credentials). ## Files touched recently -- `CLAUDE.md` — tooling reference section vendored in PR #33 (last substantive change in the repo) +- `src/game/scenes/Planet.ts` — arm-on-landing platform lifetime, `refreshPlatform()` on re-cast (PR #40) +- `src/game/planets/planet3.ts` + `planet3.test.ts` — `platformDrop.x` 730 → 750 with three clearance tests pinning the narrow window (PR #40) +- `docs/AUTONOMY.md`, `.claude/commands/verify-planet.md` — the arm-on-landing sharp edge, re-documented +- `CLAUDE.md` — tooling reference section vendored in PR #33 - `BACKLOG.md` — hardening item moved to Done with full phase-by-phase record (PRs #31–#32) - `src/phone/**` — audit Phase 5 hygiene (StrictMode, solvedRef guards, palette/touch-target/aria fixes, `solveMs` cap) - `.github/workflows/` — minimal CI gate added in audit Phase 4 (decision D1) diff --git a/docs/AUTONOMY.md b/docs/AUTONOMY.md index c65ba15..6f13d6e 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')` (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`. +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 *lands* on 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 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. +- **Platform lifetime is arm-on-arrival** — a summoned platform holds indefinitely and only starts its 5000ms life (8000ms boosted) on the astronaut's FIRST **landing** on it, then fades. A side clip or an underside bonk does *not* arm it — on planet-3 the ledge sits on continuous ground, so walking into it blocks the astronaut without ever starting the countdown. So `platformCount` stays 1 while the driver is still crossing the sentry band, and a driver waiting for `platformCount` to drop to 0 after a mere bump waits forever. A driver that *lands* on the platform and then dies must re-cast; a re-cast onto an already-armed platform refreshes it back to the waiting state rather than being swallowed. - **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 f68fa2a..5305df1 100644 --- a/src/game/planets/planet3.test.ts +++ b/src/game/planets/planet3.test.ts @@ -142,6 +142,10 @@ describe('planet3Config — ground + bounds', () => { 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) + const ASTRONAUT_H = 48; + // Mirrors PLATFORM_LANDING_EPSILON in src/game/scenes/Planet.ts — armPlatform + // arms only when `astronaut.bottom <= platform.top + epsilon`. + const LANDING_EPSILON = 2; it('leaves a BLOCKED astronaut fully clear of the hazard curtain', () => { const c = planet3Config; @@ -158,8 +162,15 @@ describe('planet3Config — summoned platform leaves no lethal pin', () => { 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); + // Lower bound — assert the RUNTIME gate, not a looser proxy: armPlatform + // rejects the contact only when the feet sit strictly below top + epsilon. + // (`> platformTop` alone would green-light a y that still arms at runtime.) + expect(astronautBottomOnGround).toBeGreaterThan(platformTop + LANDING_EPSILON); + // Upper bound — the ledge must still be in the walking path. Raised above a + // standing astronaut's head it would be walked UNDER, never blocked, which + // would make the curtain-clearance test above assert a scenario that cannot + // occur. + expect(platformTop).toBeGreaterThan(astronautBottomOnGround - ASTRONAUT_H); }); it('stays clear of the dark zone so the ledge is never hidden by it', () => { diff --git a/src/game/planets/planet3.ts b/src/game/planets/planet3.ts index 1458001..4de123d 100644 --- a/src/game/planets/planet3.ts +++ b/src/game/planets/planet3.ts @@ -89,6 +89,17 @@ export const planet3Config: PlanetConfig = { // 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. + // + // A FOURTH interaction is ACCEPTED, not solved (adversarial review F7): the + // hidden platform's collider (x 820..940) is solid ALWAYS — only the dark + // Rectangle is conditional — so an astronaut standing on this ledge at its + // right end (left=788) has right=820 and bumps that invisible wall, feeling + // out the finale without Illuminate. Clean clearance would need + // platformDrop.x + 48 + 32 <= hiddenPlatform.x - 60 → x <= 680 + // which is UNSATISFIABLE inside the 744..757 window the curtain forces. F5 is + // a repeating death loop; this is a bounded erosion of a perceptual gate on an + // optional cast, so F5 wins. Do not move this x without re-deriving BOTH the + // curtain and dark-zone constraints above. 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 }, diff --git a/src/phone/App.tsx b/src/phone/App.tsx index f221b89..c76ad8e 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 waits, then holds 5s once he steps on.' }, + 'summon-platform': { title: 'Cast!', color: '#9a7aff', sub: 'Platform — bridge waits, then holds for a few seconds once they step 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.' }, };