Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/commands/verify-planet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/AUTONOMY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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`).
Expand Down
41 changes: 41 additions & 0 deletions src/game/planets/planet3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
17 changes: 14 additions & 3 deletions src/game/planets/planet3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading