From 35c642b2c66520c7444a2a4d8c6e44dd40b0d98e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:08:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(m12):=20generator=20spike=20=E2=80=94=20gr?= =?UTF-8?q?ow=20a=20playable=20planet=20from=20the=20pair's=20rhythm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next step of "The Planet That Knows You Two" (docs/ideas): consume the M10 telemetry wedge to grow a PlanetConfig from how this pair actually plays. - New pure src/game/planets/generate.ts: deriveProfile(telemetry) -> RhythmProfile and generatePlanet(profile) -> PlanetConfig. Deterministic (seeded mulberry32; no Date.now/Math.random). Every geometry knob is clamped to a band proven valid against the shared reach budget, so the output is ALWAYS playable for any input. Themeless (default textures, zero Boot churn); planet-3 gate template (Freeze corridor -> Phase Dash curtain -> Illuminate finale). - New generate.test.ts: the safety proof — a property sweep (1000 configs over a profile grid x seeds, plus corrupt/extreme inputs) asserting every generated config satisfies the SAME invariants planet3.test.ts encodes; determinism; and deriveProfile never throws on a hostile telemetry blob. - Hub: a "Grow a planet" node derives from loadProgress().telemetry and launches the generated config via scene-data (Planet.init takes a raw config — no registry mutation). testBridge gains startGeneratedPlanet() for headless verification. Gated on typecheck + 168 Vitest (+12) + build + smoke:relay. Headless-verified on the pre-installed Chromium: startGeneratedPlanet() -> full clear (freeze -> phase -> illuminate -> goal), won=true, 0 respawns, win cue + telemetry recorded. Deliberate spike cuts: themeless (theme selection needs Boot preload textures); one gate template; not a registry/progression node; no protocol/relay/shared/phone/ dependency changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pm5HnAqLxtzGYMo6cFxRrt --- src/game/planets/generate.test.ts | 212 +++++++++++++++++++++++++ src/game/planets/generate.ts | 246 ++++++++++++++++++++++++++++++ src/game/scenes/Hub.ts | 49 +++++- src/game/testBridge.ts | 12 ++ 4 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 src/game/planets/generate.test.ts create mode 100644 src/game/planets/generate.ts diff --git a/src/game/planets/generate.test.ts b/src/game/planets/generate.test.ts new file mode 100644 index 0000000..0e3682b --- /dev/null +++ b/src/game/planets/generate.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import { deriveProfile, generatePlanet, type RhythmProfile } from './generate'; +import type { PlanetConfig } from './planet1'; +import type { PlanetTelemetry, Telemetry } from '../progression/save'; + +/** + * The safety proof for the "Planet That Knows You Two" generator spike. + * + * The crux claim is that `generatePlanet` can NEVER emit an unplayable planet — + * "one bad planet ruins the evening" is the exact risk the vision doc names. So + * this suite sweeps the whole input space (a grid of profiles + edge/extreme + * values) and asserts every generated config satisfies the SAME reach-budget + * invariants `planet3.test.ts` encodes for the hand-authored planet. Pure data + + * pure logic only — no Phaser, no scene. + */ + +// Shared astronaut reach budget (identical to planet3.test.ts / Astronaut.ts). +const GROUND_SURFACE_Y = 500; +const SPRITE_HALF_H = 24; +const JUMP_RISE = 117; +const GROUND_JUMP_SPRITE_TOP = GROUND_SURFACE_Y - SPRITE_HALF_H - JUMP_RISE - SPRITE_HALF_H; // 335 +const CANVAS_W = 960; +const CANVAS_H = 540; + +/** + * Assert a generated config is structurally valid AND playable against the reach + * budget — the same contract planet3.test.ts checks, applied to generator output. + */ +function assertPlayable(c: PlanetConfig): void { + // Every required numeric field is a finite number. + for (const n of [ + c.spawn.x, c.spawn.y, c.goal.x, c.goal.y, + c.pit.startX, c.pit.endX, c.corridor.x, + c.platformDrop.x, c.platformDrop.y, + c.hiddenPlatform.x, c.hiddenPlatform.y, + c.darkZone.x, c.darkZone.y, c.darkZone.width, c.darkZone.height, + c.fallRespawnY, + ]) { + expect(Number.isFinite(n)).toBe(true); + } + expect(c.id.length).toBeGreaterThan(0); + expect(c.name.length).toBeGreaterThan(0); + expect(c.hint.length).toBeGreaterThan(0); + + // Every visible keypoint stays inside the 960×540 canvas. + for (const x of [c.spawn.x, c.goal.x, c.corridor.x, c.platformDrop.x, c.hiddenPlatform.x, c.darkZone.x]) { + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(CANVAS_W); + } + for (const y of [c.spawn.y, c.goal.y, c.platformDrop.y, c.hiddenPlatform.y, c.darkZone.y]) { + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(CANVAS_H); + } + + // The pit is degenerate (continuous ground) OR un-jumpable (≥260) — never a + // half-width pit that would be a reach-math soft-lock. + const pitWidth = c.pit.endX - c.pit.startX; + expect(pitWidth === 0 || pitWidth >= 260).toBe(true); + + // The hazard lane is a FULL-HEIGHT plasma curtain: a genuine Phase Dash gate. + const h = c.hazardLane; + expect(h).toBeDefined(); + if (!h) return; + expect(h.width).toBeGreaterThan(0); + expect(h.y - h.height / 2).toBeLessThan(GROUND_JUMP_SPRITE_TOP); // can't be jumped over + expect(h.y + h.height / 2).toBeGreaterThanOrEqual(GROUND_SURFACE_Y - 10); // can't be walked under + expect(h.x - h.width / 2).toBeGreaterThanOrEqual(0); // in-canvas horizontally + expect(h.x + h.width / 2).toBeLessThanOrEqual(CANVAS_W); + + // Illuminate finale reach math: the goal MISSES a ground jump (hidden ledge is + // load-bearing) but IS reachable from the hidden platform. + const goalBottom = c.goal.y + 14; + expect(GROUND_JUMP_SPRITE_TOP).toBeGreaterThan(goalBottom); // ground jump misses + const platformTop = c.hiddenPlatform.y - 8; + const apexCenter = platformTop - SPRITE_HALF_H - JUMP_RISE; + expect(apexCenter).toBeLessThan(c.goal.y - 14); // platform jump reaches + expect(Math.abs(c.goal.x - c.hiddenPlatform.x)).toBeLessThan(245); // within a running jump +} + +const SIGNALS = [0, 0.25, 0.5, 0.75, 1]; +const EXTREME_SIGNALS = [-1, 2, NaN, Infinity, -Infinity]; +const SEEDS = [0, 1, 2, 42, 1337, -5, 2147483647, -2147483648]; + +describe('generatePlanet — always emits a playable planet', () => { + it('over a full grid of well-formed profiles × seeds', () => { + let count = 0; + for (const solveTendency of SIGNALS) { + for (const forgiveness of SIGNALS) { + for (const exploreTendency of SIGNALS) { + for (const seed of SEEDS) { + assertPlayable(generatePlanet({ solveTendency, forgiveness, exploreTendency, seed })); + count += 1; + } + } + } + } + expect(count).toBe(SIGNALS.length ** 3 * SEEDS.length); + }); + + it('even for out-of-range / non-finite profile signals (clamped defensively)', () => { + for (const bad of EXTREME_SIGNALS) { + // Each signal poisoned in turn, plus an all-poisoned profile. + assertPlayable(generatePlanet({ solveTendency: bad, forgiveness: 0.5, exploreTendency: 0.5, seed: 7 })); + assertPlayable(generatePlanet({ solveTendency: 0.5, forgiveness: bad, exploreTendency: 0.5, seed: 7 })); + assertPlayable(generatePlanet({ solveTendency: 0.5, forgiveness: 0.5, exploreTendency: bad, seed: 7 })); + assertPlayable(generatePlanet({ solveTendency: bad, forgiveness: bad, exploreTendency: bad, seed: bad })); + } + }); + + it('uses the default id and is themeless (default textures, no Boot churn)', () => { + const c = generatePlanet(deriveProfile({})); + expect(c.id).toBe('planet-generated'); + expect(c.theme).toBeUndefined(); + expect(c.puzzleTheme).toBeUndefined(); + expect(c.hazardLane).toBeDefined(); + }); + + it('honors an explicit id override', () => { + expect(generatePlanet(deriveProfile({}), 'planet-xyz').id).toBe('planet-xyz'); + }); +}); + +describe('generatePlanet — deterministic', () => { + it('same profile → byte-identical config', () => { + const profile: RhythmProfile = { solveTendency: 0.3, forgiveness: 0.8, exploreTendency: 0.6, seed: 12345 }; + expect(generatePlanet(profile)).toEqual(generatePlanet(profile)); + }); + + it('different profiles → visibly different geometry', () => { + const a = generatePlanet({ solveTendency: 0.1, forgiveness: 0.1, exploreTendency: 0.1, seed: 11 }); + const b = generatePlanet({ solveTendency: 0.9, forgiveness: 0.9, exploreTendency: 0.9, seed: 99 }); + // At least one gate has visibly moved. + const moved = + a.corridor.x !== b.corridor.x || + a.hazardLane!.x !== b.hazardLane!.x || + a.hiddenPlatform.x !== b.hiddenPlatform.x; + expect(moved).toBe(true); + }); +}); + +// ── deriveProfile ──────────────────────────────────────────────────────────── +function telemetryEntry(over: Partial = {}): PlanetTelemetry { + return { + attempts: 1, + lastClearMs: 60_000, + bestClearMs: 60_000, + lastRespawns: 0, + lastSolveMs: 0, + solves: {}, + ...over, + }; +} + +describe('deriveProfile', () => { + it('empty telemetry → neutral default profile, never throws', () => { + const p = deriveProfile({}); + expect(p).toEqual({ solveTendency: 0.5, forgiveness: 0.5, exploreTendency: 0.5, seed: 1 }); + }); + + it('never throws on corrupt / hostile telemetry, and still yields a playable planet', () => { + // Deliberately malformed values sneaking past the type (as a save blob might). + const corrupt = { + 'planet-1': { + attempts: NaN, + lastClearMs: -1, + bestClearMs: 0, + lastRespawns: Infinity, + lastSolveMs: NaN, + solves: { 'freeze-stars': { count: -3, totalMs: NaN, bestMs: -1 } }, + }, + 'planet-2': null, + junk: 'not an object', + } as unknown as Telemetry; + const p = deriveProfile(corrupt); + for (const v of [p.solveTendency, p.forgiveness, p.exploreTendency]) { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + expect(Number.isFinite(p.seed)).toBe(true); + assertPlayable(generatePlanet(p)); + }); + + it('a fast-solving pair reads as high solveTendency; a slow pair as low', () => { + const fast = deriveProfile({ + 'planet-1': telemetryEntry({ solves: { 'freeze-stars': { count: 3, totalMs: 6_000, bestMs: 1_800 } } }), + }); + const slow = deriveProfile({ + 'planet-1': telemetryEntry({ solves: { 'freeze-stars': { count: 3, totalMs: 33_000, bestMs: 9_000 } } }), + }); + expect(fast.solveTendency).toBeGreaterThan(slow.solveTendency); + }); + + it('a pair that dies a lot reads as more forgiving geometry', () => { + const calm = deriveProfile({ 'planet-1': telemetryEntry({ lastRespawns: 0 }) }); + const crash = deriveProfile({ 'planet-1': telemetryEntry({ lastRespawns: 8 }) }); + expect(crash.forgiveness).toBeGreaterThan(calm.forgiveness); + }); + + it('explore fraction is the clear-minus-solve ratio', () => { + // 60s clear, 6s solving → 90% exploring. + const p = deriveProfile({ + 'planet-1': telemetryEntry({ lastClearMs: 60_000, lastSolveMs: 6_000 }), + }); + expect(p.exploreTendency).toBeCloseTo(0.9, 5); + }); + + it('is deterministic — same telemetry → same seed', () => { + const t: Telemetry = { 'planet-1': telemetryEntry({ attempts: 4, lastClearMs: 42_500 }) }; + expect(deriveProfile(t)).toEqual(deriveProfile(t)); + }); +}); diff --git a/src/game/planets/generate.ts b/src/game/planets/generate.ts new file mode 100644 index 0000000..9409c73 --- /dev/null +++ b/src/game/planets/generate.ts @@ -0,0 +1,246 @@ +import type { PlanetConfig } from './planet1'; +import type { Telemetry } from '../progression/save'; + +/** + * The generator spike for "The Planet That Knows You Two". + * + * Two pure, deterministic functions turn the pair's recorded rhythm + * (`Telemetry`, the M10 wedge) into a playable `PlanetConfig`: + * + * deriveProfile(telemetry) -> RhythmProfile (measured signals, normalized 0..1) + * generatePlanet(profile) -> PlanetConfig (geometry grown from those signals) + * + * The crux of the spike is SAFETY: `generatePlanet` can never emit an unplayable + * planet. Every knob is clamped to a band proven valid against the shared reach + * budget (the same invariants `planet3.test.ts` encodes), so the finale is always + * reachable, the plasma curtain is always a full-height Phase Dash gate, and every + * keypoint stays in-canvas — for ANY input, including corrupt or extreme profiles. + * `generate.test.ts` proves this by sweeping the whole input space. + * + * Determinism is a hard requirement: no `Date.now`, no `Math.random` (both banned + * by the Vitest determinism guards). Within-band variety comes from a seeded + * `mulberry32` PRNG folded from the telemetry, so the same pair always grows the + * same planet and two different pairs visibly differ. + * + * Scope (spike): themeless (default textures, zero Boot churn) and modeled on the + * planet-3 gate template (Freeze corridor -> Phase Dash curtain -> Illuminate + * finale). Theme selection + a library of templates are noted follow-ups. + */ + +// ── Shared reach budget (matches Astronaut.ts + the hand-authored planets) ────── +// Ground surface y=500; 48px sprite (half-height 24); a running jump rises ~117px +// and covers ~245px horizontally. The highest the sprite TOP reaches from a ground +// jump is ~335 — a goal above that misses a ground jump (so Illuminate's hidden +// ledge is load-bearing). Canvas is 960×540. These match planet3.test.ts. + +// ── Fixed finale geometry (kept CONSTANT for a bulletproof reach margin) ───────── +// The Illuminate finale is the tightest constraint in the budget, so its vertical +// geometry is fixed to proven-good values rather than varied: +// goal.y=300 -> goalBottom 314 < 335 (misses a ground jump; ~21px margin). +// hiddenPlatform.y=415 -> apexCenter 266 < goalTop 286 (reachable; ~20px margin). +// Variety on the finale comes from its horizontal placement instead. +const GOAL_Y = 300; +const HIDDEN_PLATFORM_Y = 415; + +// ── Curtain geometry (Phase Dash gate) ─────────────────────────────────────────── +// A full-height plasma curtain: y=300, height=420 -> top 90 (< 335, can't be jumped +// over) and bottom 510 (>= 490, can't be walked under). Only the WIDTH and X vary. +const HAZARD_Y = 300; +const HAZARD_HEIGHT = 420; + +// ── Reference bands for normalizing raw telemetry into 0..1 signals ────────────── +const REF_FAST_SOLVE_MS = 2_000; // a fast pair's mean solve -> solveTendency ~1 +const REF_SLOW_SOLVE_MS = 12_000; // a slow pair's mean solve -> solveTendency ~0 +const REF_MAX_RESPAWNS = 5; // >= this many deaths/clear -> forgiveness ~1 + +/** + * The pair's playstyle, distilled to normalized 0..1 signals plus a stable seed. + * Every field is in [0, 1] except `seed` (a finite integer for the PRNG). + */ +export type RhythmProfile = { + /** Higher = the pair solves puzzles faster (tighter, snappier gate spacing). */ + solveTendency: number; + /** Higher = the pair dies more (gentler geometry: bigger landings, thinner hazard). */ + forgiveness: number; + /** Higher = the pair spends more time exploring vs solving (more horizontal spread). */ + exploreTendency: number; + /** Deterministic PRNG seed folded from the telemetry (stable per pair). */ + seed: number; +}; + +// A sane mid-planet when there is no telemetry to read (fresh save / solo play). +const DEFAULT_PROFILE: RhythmProfile = { + solveTendency: 0.5, + forgiveness: 0.5, + exploreTendency: 0.5, + seed: 1, +}; + +/** Clamp to [0, 1]; non-finite -> 0.5 (a neutral middle, never NaN). */ +function clamp01(n: number): number { + if (!Number.isFinite(n)) return 0.5; + return n < 0 ? 0 : n > 1 ? 1 : n; +} + +/** Clamp to [lo, hi]; non-finite -> lo. */ +function clamp(n: number, lo: number, hi: number): number { + if (!Number.isFinite(n)) return lo; + return n < lo ? lo : n > hi ? hi : n; +} + +/** Linear interpolate lo..hi by t (t is assumed already in [0,1]). */ +function lerp(lo: number, hi: number, t: number): number { + return lo + (hi - lo) * t; +} + +/** + * mulberry32 — a tiny, fast, well-distributed deterministic PRNG. Returns a + * function yielding floats in [0, 1). Seeded so the same pair grows the same + * planet; no `Math.random`, so it is Vitest-determinism-safe. + */ +function mulberry32(seed: number): () => number { + let a = Math.trunc(Number.isFinite(seed) ? seed : 1) | 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Symmetric jitter in [-amp, +amp] from a PRNG draw. */ +function jitter(rand: () => number, amp: number): number { + return (rand() - 0.5) * 2 * amp; +} + +/** + * Aggregate the pair's telemetry across every cleared planet into a RhythmProfile. + * + * PURE, never throws — an empty / solo / corrupt telemetry map yields the neutral + * DEFAULT_PROFILE (matching the never-throws discipline of loadProgress). All raw + * numbers are read defensively so a bad blob can't produce NaN signals. + */ +export function deriveProfile(telemetry: Telemetry): RhythmProfile { + const entries = + telemetry && typeof telemetry === 'object' ? Object.values(telemetry) : []; + if (entries.length === 0) return { ...DEFAULT_PROFILE }; + + // solveTendency ← mean per-power solve time across all planets (faster = higher). + let solveTotalMs = 0; + let solveCount = 0; + // forgiveness ← mean deaths per recorded clear (more deaths = higher). + let respawnSum = 0; + // exploreTendency ← mean explore fraction (explore = clear − solve, as a ratio). + let exploreSum = 0; + let exploreSamples = 0; + // seed ← a stable fold of visit counts + clear times. + let seed = 1; + + let sampleCount = 0; + for (const t of entries) { + // Guard each entry defensively — a hostile/corrupt save blob may hold null or + // non-object values that never passed through normalizeTelemetry. + if (!t || typeof t !== 'object') continue; + sampleCount += 1; + respawnSum += Number.isFinite(t.lastRespawns) ? t.lastRespawns : 0; + for (const stat of Object.values(t.solves ?? {})) { + if (!stat || !Number.isFinite(stat.count) || stat.count <= 0) continue; + solveTotalMs += Number.isFinite(stat.totalMs) ? stat.totalMs : 0; + solveCount += stat.count; + } + const clearMs = Number.isFinite(t.lastClearMs) ? t.lastClearMs : 0; + const solveMs = Number.isFinite(t.lastSolveMs) ? t.lastSolveMs : 0; + if (clearMs > 0) { + exploreSum += Math.max(0, Math.min(1, (clearMs - solveMs) / clearMs)); + exploreSamples += 1; + } + const attempts = Number.isFinite(t.attempts) ? t.attempts : 0; + seed = (Math.imul(seed, 2654435761) + attempts * 40503 + Math.round(clearMs)) | 0; + } + + const meanSolveMs = solveCount > 0 ? solveTotalMs / solveCount : NaN; + const solveTendency = Number.isFinite(meanSolveMs) + ? clamp01((REF_SLOW_SOLVE_MS - meanSolveMs) / (REF_SLOW_SOLVE_MS - REF_FAST_SOLVE_MS)) + : 0.5; + const forgiveness = clamp01(respawnSum / Math.max(1, sampleCount) / REF_MAX_RESPAWNS); + const exploreTendency = exploreSamples > 0 ? clamp01(exploreSum / exploreSamples) : 0.5; + + return { solveTendency, forgiveness, exploreTendency, seed: seed || 1 }; +} + +// A small deterministic name pool so different pairs get a different-feeling +// planet title. Purely cosmetic; the geometry is what actually "knows" them. +const NAMES = [ + 'Kindred Reach', + 'Two-Star Drift', + 'Paired Orbit', + 'Our Quiet Nebula', + 'The Shared Expanse', + 'Twinlight', + 'Comet of Us', + 'Wandering Together', +]; + +/** + * Grow a `PlanetConfig` from a RhythmProfile. Deterministic and SAFE: every knob + * is clamped to a band proven playable against the reach budget, so the result is + * always clearable regardless of the input profile. + * + * Layout (left → right), modeled on planet-3: + * spawn → Freeze corridor (sentry) → Phase Dash plasma curtain → Illuminate + * finale (hidden ledge under a dark zone → high goal). + * + * The profile moves things WITHIN safe bands: fast solvers get tighter spacing; + * explorers get more horizontal spread and a finale pushed further right; a pair + * that dies a lot gets a bigger safe landing and a thinner hazard. The PRNG adds + * within-band jitter so two identical profiles still differ slightly and different + * seeds differ visibly. + */ +export function generatePlanet(profile: RhythmProfile, id = 'planet-generated'): PlanetConfig { + const solveTendency = clamp01(profile.solveTendency); + const forgiveness = clamp01(profile.forgiveness); + const exploreTendency = clamp01(profile.exploreTendency); + const rand = mulberry32(profile.seed); + + // Freeze corridor: explorers get more pre-gate room (pushed right); fast solvers + // pull it left for a snappier opener. Clamped clear of the x=64 spawn. + const corridorX = Math.round( + clamp(lerp(250, 320, exploreTendency) - lerp(0, 20, solveTendency) + jitter(rand, 12), 250, 330), + ); + + // Phase Dash curtain: sits a safe LANDING past the sentry's ±140 patrol. A more + // forgiving pair gets a bigger landing. Clamped in-canvas and before the finale. + const landingGap = lerp(260, 300, forgiveness); + const hazardX = Math.round(clamp(corridorX + landingGap + jitter(rand, 10), 560, 690)); + // Forgiving pairs get a thinner curtain (smaller respawn band = easier to time). + const hazardWidth = Math.round(clamp(lerp(110, 80, forgiveness) + jitter(rand, 4), 80, 110)); + + // Illuminate finale on the far right: explorers push it further out. Vertical + // geometry is fixed (see constants) so the reach math is always satisfied. + const hiddenPlatformX = Math.round(clamp(lerp(840, 905, exploreTendency) + jitter(rand, 10), 840, 905)); + const goalX = Math.round(clamp(hiddenPlatformX + 20, hiddenPlatformX + 10, 950)); + + // Optional flourish (not a gate): a Summon Platform ledge in the open span + // between the curtain and the finale. Clamped clear of both. + const platformDropX = Math.round( + clamp((hazardX + hiddenPlatformX) / 2 + jitter(rand, 12), hazardX + 70, hiddenPlatformX - 70), + ); + + const name = NAMES[Math.floor(rand() * NAMES.length) % NAMES.length]; + + return { + id, + name, + hint: 'Chill the sentry, phase through the plasma curtain, then illuminate the hidden ledge.', + spawn: { x: 64, y: 440 }, + goal: { x: goalX, y: GOAL_Y }, + // Degenerate pit — continuous ground; this planet gates on Phase Dash. + pit: { startX: 480, endX: 480 }, + corridor: { x: corridorX }, + platformDrop: { x: platformDropX, y: 470 }, + hiddenPlatform: { x: hiddenPlatformX, y: HIDDEN_PLATFORM_Y }, + darkZone: { x: hiddenPlatformX, y: HIDDEN_PLATFORM_Y, width: 150, height: 120 }, + fallRespawnY: 600, + hazardLane: { x: hazardX, y: HAZARD_Y, width: hazardWidth, height: HAZARD_HEIGHT }, + }; +} diff --git a/src/game/scenes/Hub.ts b/src/game/scenes/Hub.ts index 653aec3..89be0e4 100644 --- a/src/game/scenes/Hub.ts +++ b/src/game/scenes/Hub.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import type { GameNetClient } from '../net/client'; import { PLANETS, type PlanetRegistryEntry } from '../planets/registry'; +import { deriveProfile, generatePlanet } from '../planets/generate'; import { loadProgress } from '../progression/save'; import { nodeStateFor } from '../progression/nodeStateFor'; import { isTestMode, setBridgeProviders } from '../testBridge'; @@ -111,8 +112,16 @@ export class HubScene extends Phaser.Scene { this.renderNode(entry, index, nodeStateFor(progress, entry.id)); }); + // "Grow a planet" — the generator spike ("The Planet That Knows You Two"). + // Distinct from the authored nodes: it is NOT a registry entry and NOT a + // progression node, so it sits on its own row below and reads as an experiment + // (warm gold vs. the cyan authored chain). Clicking it derives a config from + // THIS pair's recorded telemetry and launches it. + this.renderGrowNode(); + // Test-bridge navigation (no-op unless ?test=1): launch a planet by id, - // mirroring the node-click launch contract for entries that have a config. + // mirroring the node-click launch contract for entries that have a config; + // plus startGeneratedPlanet() so a headless driver can clear a grown planet. if (isTestMode()) { setBridgeProviders({ startPlanet: (id) => { @@ -126,10 +135,48 @@ export class HubScene extends Phaser.Scene { }); } }, + startGeneratedPlanet: () => this.launchGeneratedPlanet(), }); } } + /** + * Grow a PlanetConfig from this pair's recorded rhythm and launch it. Reads the + * same persisted telemetry the portrait card is built from; an empty/solo save + * degrades to a neutral default planet (deriveProfile never throws). Launches + * via scene-data — Planet.init takes a raw config, so no registry mutation. + */ + private launchGeneratedPlanet() { + const config = generatePlanet(deriveProfile(loadProgress().telemetry)); + this.scene.start('Planet', { + net: this.net, + config, + solo: this.solo, + unlockedPlanets: new Set(this.unlockedPlanets), + }); + } + + /** The warm-gold "Grow a planet" experiment node, centered below the chain. */ + private renderGrowNode() { + const node = this.add.circle(480, 418, 34, 0xffd27a); + node.setInteractive({ useHandCursor: true }); + node.on('pointerdown', () => this.launchGeneratedPlanet()); + this.add + .text(480, 460, '✦ Grow a planet', { + fontFamily: 'system-ui, sans-serif', + fontSize: '16px', + color: '#ffd27a', + }) + .setOrigin(0.5); + this.add + .text(480, 482, 'from how you two play', { + fontFamily: 'system-ui, sans-serif', + fontSize: '12px', + color: '#a8b0d8', + }) + .setOrigin(0.5); + } + /** Even horizontal spread across the canvas for N nodes. */ private nodeX(index: number): number { const gap = 960 / (PLANETS.length + 1); diff --git a/src/game/testBridge.ts b/src/game/testBridge.ts index 029eb41..249cb83 100644 --- a/src/game/testBridge.ts +++ b/src/game/testBridge.ts @@ -69,6 +69,11 @@ export type ConstellationBridge = { getState: () => BridgeState; cast: (powerId: PowerId) => void; startPlanet: (id: string) => void; + // Grow a planet from the pair's recorded telemetry and launch it (the "Planet + // That Knows You Two" spike). Unlike startPlanet, this needs no registry entry — + // the config is generated on the fly — so a headless driver can clear a grown + // planet via the usual getState/cast/input loop. + startGeneratedPlanet: () => void; }; declare global { @@ -132,10 +137,12 @@ const providers: { getState: () => BridgeState; cast: (id: PowerId) => void; startPlanet: (id: string) => void; + startGeneratedPlanet: () => void; } = { getState: zeroedState, cast: () => {}, startPlanet: () => {}, + startGeneratedPlanet: () => {}, }; /** @@ -147,12 +154,14 @@ export function setBridgeProviders( getState: () => BridgeState; cast: (id: PowerId) => void; startPlanet: (id: string) => void; + startGeneratedPlanet: () => void; }>, ): void { if (!isTestMode()) return; if (p.getState) providers.getState = p.getState; if (p.cast) providers.cast = p.cast; if (p.startPlanet) providers.startPlanet = p.startPlanet; + if (p.startGeneratedPlanet) providers.startGeneratedPlanet = p.startGeneratedPlanet; } /** @@ -178,5 +187,8 @@ export function ensureBridge(): void { startPlanet(id) { providers.startPlanet(id); }, + startGeneratedPlanet() { + providers.startGeneratedPlanet(); + }, }; }