[lane F] Mid-round-2 recovery: the glue that makes L2 a game

All round-2 lane sessions were cut off 15:08-15:16 before NOTES/commits; the three prior
commits preserve their work verbatim. This commit is the wiring B was cut off inside of,
plus the run-state boot owes per ruling #4:

- combat/index.js: fiction-id -> archetype resolve via C's getEnemy() (the round's declared
  ONE blocker), theta-array + spread fan-out for group spawns, hazards + pickups constructed
  and updated (order: enemies -> weapons -> hazards -> pickups, so a torpedo can neutralize
  a surge the frame it detonates), pickup score/samples onto the combat:state scoreboard,
  combat.reset(fromS) for respawn.
- flight/player.js: kill/shove/refill — the API B's new modules call; kill ignores iframes
  by design (boost-dodging through C's lethal acid wall is not a read). Deleted the
  crestSpeed fallback guard per its own comment — A's law landed.
- boot.js: run state. checkpoint crossed -> remembered; death -> 2s -> respawn at it +
  combat.reset() re-arms hazard timelines (pump deliberately does NOT rewind: no
  double-spawns, collected stays collected); gate -> level:complete {stats, par} for E's
  medal card. Exported step(dt) so the stepped-sim harness drives the REAL loop.
- ROUND2_INSTRUCTIONS: MID-ROUND STATUS box for the resuming lanes; f-progress entry.

Verified (deterministic stepped sim via boot.step, :8140): full L2 run fires all 47 events
with ZERO console warns (was: 11 spawn no-ops), all 5 hazard telegraphs, 2400 proximity
ticks, 14 pickups collected, finish 157s vs par 180 while surfing 94s of it. Death path:
throttle 0.7 into the finale -> killed by acid s=3386 -> respawn at Cardia Approach 2980 ->
surge re-arms -> second attempt escapes -> level:complete {deaths: 1}. Survivable AND
losable. Evidence: docs/shots/laneF/round2_L2_integrated_full_run.png. qa.sh GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-16 16:39:44 +10:00
parent 2e85ec0622
commit 29cca53675
6 changed files with 195 additions and 32 deletions

View File

@ -1,5 +1,35 @@
# GUTS — Round 2 lane instructions (from Fable, integrator) # GUTS — Round 2 lane instructions (from Fable, integrator)
> ## ⚡ MID-ROUND STATUS (F, 2026-07-16 evening) — read this first if you are resuming
>
> All round-2 sessions were **cut off 15:0815:16** before NOTES or commits. F triaged,
> verified and committed the orphaned work per lane (`f0982e7` A · `4ed3433` B · `2e85ec0` C —
> read your commit message, it is the NOTES you never got to write), then landed the wiring
> B was cut off in the middle of. **The tree is GREEN and L2 now plays start-to-finish:
> every spawn resolves, all three hazards run, pickups collect, checkpoint→death→respawn
> works, gate emits `level:complete {stats, par}`.** Proof: f-progress + laneF shots.
>
> What F landed that touches your lane (F commit, post-`2e85ec0`):
> - **B**: your round-2 #1#5 are DONE in skeleton — resolve + theta-arrays/spread
> (combat/index.js), hazards+pickups wired, `player.kill/shove/refill` added,
> respawn/`level:complete` glue in boot. Your crestSpeed fallback guard is deleted (A
> landed the law). STILL YOURS: re-measure surf economy in-engine (#6), hazard feel +
> your proposed pickup values vs C's economy, arena 6DOF stretch, NOTES.
> - **A**: nothing of yours changed. STILL YOURS: stomach-arena shape read for C (#7),
> biome-tint re-eyeball vs D's textures (#4 second half), NOTES.
> - **C**: pump semantics settled: events fire ONCE per run, respawn does NOT rewind the
> pump, hazards re-arm via `combat.reset(fromS)`. `boot.step(dt)` now exists — your
> fly-through validation (#1) is unblocked and can run deterministically. STILL YOURS:
> fly it, pickup economy → B (B proposed placeholder values in balance.js §pickups),
> L1 design doc (#5), GDD boss folding (#4), NOTES.
> - **D/E**: no round-2 files reached the tree before the cut. Your lists below stand
> unchanged. E: the `level:complete` payload for your medal card is live and verified.
> - Ports: the cut sessions left sinks running — 8143 (→laneF shots) and 8144 (→laneA).
> Reuse them or pick a fresh port.
>
> Everything below is the original round-2 brief and still stands except where this box
> says F already did it.
Date: 2026-07-16 · Written after reviewing all four round-1 lanes (A/B/C/D — E did not run Date: 2026-07-16 · Written after reviewing all four round-1 lanes (A/B/C/D — E did not run
and joins this round) and integrating their offered wiring. Every round-1 claim I checked and joins this round) and integrating their offered wiring. Every round-1 claim I checked
held up: A's hash `cefc4f83` and 8-draw sweep, B's `node --check` ESM no-op (reproduced on a held up: A's hash `cefc4f83` and 8-draw sweep, B's `node --check` ESM no-op (reproduced on a

View File

@ -1,5 +1,34 @@
# Lane F (Fable) — progress # Lane F (Fable) — progress
## 2026-07-16 (evening) — Mid-round-2 recovery: cut-off triage + the glue that makes L2 a game
All round-2 lane sessions (A/B/C + one more that left no files) were cut off 15:0815:16,
mid-work, before NOTES/progress/commits. Reviewed the orphaned tree file by file, verified it
(qa GREEN, both selfchecks, 60s stepped sim clean), then committed each lane's work path-scoped
on their behalf: A `f0982e7` (crest law, taper, schema-v2 wave, TBN walls — essentially done),
B `4ed3433` (surf speed-lock + hazards/pickups modules — written but UNWIRED), C `2e85ec0`
(schema v2, par retune, chase analysis, --probe — done). Details in each commit message.
Then landed the integration glue as F (B's unfinished wiring was the round's critical path):
fiction-id → archetype resolve via C's `getEnemy()` + theta-array/spread fan-out
(combat/index.js), hazards + pickups constructed and updated there too, `player.kill/shove/
refill` (the API B's new modules call — it didn't exist), boot run-state (checkpoint → death →
2s → respawn at last checkpoint + `combat.reset()` re-arms hazard timelines; gate →
`level:complete {stats, par}`), deleted B's crestSpeed fallback guard (A landed the real one),
and exported `boot.step(dt)` so the stepped-sim harness drives the REAL loop, pump included.
Verified on :8140, deterministic stepped sim through boot.step:
- Full run at throttle 1.38: **all 47 L2 events fire, 0 console warns** (the round-2 blocker
— 11 spawn no-ops — is gone), all 5 hazard telegraphs, 2400 hazard:proximity ticks,
14 pickups collected, gate → level:complete {time 157s vs par 180, score 2650, kills 15}.
- Death path: dawdle at 0.7 into the finale → killed by acid at s=3386 → respawns at
checkpoint 2980 (Cardia Approach, exactly as C authored) → surge re-arms → second attempt
escapes → level:complete {deaths: 1}. **L2 is survivable AND losable.**
Evidence: docs/shots/laneF/round2_L2_integrated_full_run.png (posted via the 8143 sink).
Still open for the resumed lanes: see ROUND2_INSTRUCTIONS §MID-ROUND STATUS. Port note:
8143 (laneF sink) and 8144 (laneA sink) are still held by the cut-off sessions' processes.
## 2026-07-16 — Round 1 review + integration (round 2 opened) ## 2026-07-16 — Round 1 review + integration (round 2 opened)
Reviewed all four lanes; every checked claim held (A's hash cefc4f83 + 8-draw sweep, B's Reviewed all four lanes; every checked claim held (A's hash cefc4f83 + 8-draw sweep, B's

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

View File

@ -58,10 +58,46 @@ const player = flags.fly ? null
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null; const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null;
// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the // --- level-event pump (owned by boot per round-2 ruling): emits C's events as the
// player crosses their s. Checkpoint/respawn replay semantics: revisit with B in round 2. // player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump
// (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(),
// which is the one replay a death actually needs (the thing that killed you works again).
let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0; let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0;
const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); }; const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); };
// --- run state: checkpoint -> death -> respawn, gate -> level:complete ------------------
// C's authoring laws carry this: checkpoints sit outside hazard zones and <=30s apart, so
// "respawn at the last checkpoint crossed" is safe by construction. Stats accumulate across
// deaths (a run's story includes its deaths); E renders the medal card off level:complete.
const run = { t: 0, deaths: 0, checkpointS: player?.state.s ?? 0, respawnT: 0, done: false };
bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint') {
run.checkpointS = ev.s;
bus.emit('audio:cue', { name: 'checkpoint' });
} else if (ev.type === 'gate' && !run.done) {
run.done = true;
bus.emit('level:complete', {
id: world.level?.id ?? null, to: ev.to ?? null, gate: ev.id ?? null,
stats: {
time: run.t, deaths: run.deaths,
score: combat?.score.value ?? 0, kills: combat?.score.kills ?? 0,
samples: combat?.score.samples ?? 0,
},
par: world.level?.par ?? null,
});
}
});
bus.on('player:death', () => { run.deaths++; run.respawnT = 2.0; }); // 2s of feed-drop, then back
function updateRun(dt) {
if (!run.done) run.t += dt;
if (run.respawnT > 0 && player) {
run.respawnT -= dt;
if (run.respawnT <= 0) {
combat?.reset(run.checkpointS); // re-arm hazards at/ahead of the checkpoint
player.respawn(run.checkpointS);
}
}
}
// --- debug drift/fly camera (?fly=1, or fallback when B's player is absent) --- // --- debug drift/fly camera (?fly=1, or fallback when B's player is absent) ---
let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6; let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6;
const keys = new Set(); const keys = new Set();
@ -116,19 +152,27 @@ addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight); renderer.setSize(innerWidth, innerHeight);
}); });
let last = performance.now(); // One deterministic simulation step — the whole game minus rendering. Exported so the
function frame(now) { // stepped-sim harness (f-progress documents it; rAF never fires in a hidden pane) exercises
const dt = Math.min((now - last) / 1000, 0.05); // the REAL loop — pump, run state and all — instead of a hand-rolled copy that drifts.
last = now; function step(dt) {
if (player) { if (player) {
player.update(dt); // must run first: combat hit-tests this frame's ship position player.update(dt); // must run first: combat hit-tests this frame's ship position
pumpTo(player.state.s); pumpTo(player.state.s);
combat.update(dt); combat.update(dt);
updateRun(dt);
world.update(dt, player.state.s); world.update(dt, player.state.s);
} else { } else {
updateCamera(dt); // ?fly=1 noclip / title drift updateCamera(dt); // ?fly=1 noclip / title drift
world.update(dt, sCam); world.update(dt, sCam);
} }
}
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
step(dt);
renderer.render(scene, camera); renderer.render(scene, camera);
stats.draws = renderer.info.render.calls; stats.draws = renderer.info.render.calls;
stats.tris = renderer.info.render.triangles; stats.tris = renderer.info.render.triangles;
@ -146,4 +190,4 @@ function frame(now) {
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block'; if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
requestAnimationFrame(frame); requestAnimationFrame(frame);
export { scene, camera, renderer, bus, flags, world, player, combat, assets }; export { scene, camera, renderer, bus, flags, world, player, combat, assets, step };

View File

@ -1,33 +1,61 @@
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons and // combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons + hazards
// feeds them Lane C's level events. All the wiring, none of the behaviour. // + pickups and feeds them Lane C's level events. All the wiring, none of the behaviour.
// //
// Update order inside a frame is load-bearing: // Update order inside a frame is load-bearing:
// boot: player.update(dt) -> combat.update(dt) // boot: player.update(dt) -> combat.update(dt)
// here: enemies.update(dt) -> weapons.update(dt) // here: enemies.update(dt) -> weapons.update(dt) -> hazards.update(dt) -> pickups.update(dt)
// Enemies move first so weapons hit-test against this frame's positions, not last frame's. // Enemies move first so weapons hit-test against this frame's positions, not last frame's.
// Hazards after weapons so a torpedo fired THIS frame can neutralize a surge THIS frame
// (weapons emits level:neutralize on detonation; hazards listens).
import { createEnemies } from './enemies.js'; import { createEnemies } from './enemies.js';
import { createWeapons } from './weapons.js'; import { createWeapons } from './weapons.js';
import { createHazards } from './hazards.js';
import { createPickups } from './pickups.js';
import { getEnemy } from '../levels/enemies.js';
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) { export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
const enemies = createEnemies({ scene, world, bus, rng, assets }); const enemies = createEnemies({ scene, world, bus, rng, assets });
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies }); const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
const hazards = createHazards({ scene, world, bus, rng, player });
const pickups = createPickups({ scene, world, bus, rng, player, weapons });
enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur)); enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3)); enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
const score = { value: 0, kills: 0 }; const score = { value: 0, kills: 0, samples: 0 };
// Lane C's events arrive on the bus as the player crosses their `s`. We only care about // Fan a group of `count` out along s. C's `spread` is the authored span of the group
// `spawn`; checkpoints/hazards/bosses belong to C and E this round. Unknown types are // ("count 4 spread 120" = a field, not a wall); without one, a tight default fan keeps
// ignored on purpose — C may add event kinds without breaking combat. // a count from stacking at a single arclength.
const offEvent = bus.on('level:event', (ev) => { const fan = (ev, i) => {
if (!ev || ev.type !== 'spawn') return;
const n = ev.count ?? 1; const n = ev.count ?? 1;
for (let i = 0; i < n; i++) { if (n <= 1) return ev.s ?? 0;
// fan the group out over a short stretch of s so a count of 6 is an encounter, const span = ev.spread ?? (n - 1) * 3.5;
// not a wall of enemies at one arclength return (ev.s ?? 0) + (i / (n - 1)) * span;
enemies.spawn(ev.enemy, { s: (ev.s ?? 0) + i * 3.5, theta: ev.theta ?? null, rho: ev.rho ?? null }); };
// C authors `theta` as a scalar (whole group) or an array (per-member — "two blooms
// covering opposite arcs"). Placement is composition, so C's word is law here.
const thetaFor = (ev, i) =>
(Array.isArray(ev.theta) ? ev.theta[i % ev.theta.length] : (ev.theta ?? null));
// Lane C's events arrive on the bus as the player crosses their `s`. Spawns and pickups are
// consumed here; hazards run their own timeline off world.level.events (they need lookahead
// for the warn telegraph — see hazards.js header); checkpoints/gates are boot's (the pump
// owns respawn/complete semantics); unknown types are ignored on purpose so C can add event
// kinds without breaking combat.
const offEvent = bus.on('level:event', (ev) => {
if (!ev) return;
if (ev.type === 'spawn') {
// Levels name enemies in fiction ("bolus_chunk"); pools are keyed by archetype
// ("floater"). levels/enemies.js is the join table (C's catalogue, round-2 task #1).
const spec = getEnemy(ev.enemy);
if (!spec) { console.warn(`[combat] spawn "${ev.enemy}" is not in levels/enemies.js — skipped`); return; }
for (let i = 0; i < (ev.count ?? 1); i++)
enemies.spawn(spec.archetype, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} else if (ev.type === 'pickup') {
for (let i = 0; i < (ev.count ?? 1); i++)
pickups.spawn(ev.kind, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} }
}); });
@ -36,23 +64,36 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
score.kills++; score.kills++;
}); });
// pickups.js applies the effect (refill/ammo) and announces; the score lives here so
// combat:state stays the one scoreboard E reads.
const offPickup = bus.on('pickup', (e) => {
score.value += e.score ?? 0;
score.samples += e.sample ?? 0;
});
function update(dt) { function update(dt) {
enemies.update(dt, player); enemies.update(dt, player);
weapons.update(dt); weapons.update(dt);
hazards.update(dt);
pickups.update(dt);
// combat's slice of the HUD state (E reads player:state for the flight half) // combat's slice of the HUD state (E reads player:state for the flight half)
bus.emit('combat:state', { bus.emit('combat:state', {
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated, heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
ammo: weapons.ammo, ammoMax: weapons.ammoMax, ammo: weapons.ammo, ammoMax: weapons.ammoMax,
score: score.value, kills: score.kills, enemies: enemies.count, score: score.value, kills: score.kills, samples: score.samples, enemies: enemies.count,
}); });
} }
return { return {
update, update,
enemies, weapons, score, enemies, weapons, hazards, pickups, score,
// Respawn support (boot calls this from its death->checkpoint glue): re-arm hazard
// timelines at/ahead of the respawn s. Enemies and uncollected pickups persist — the
// pump never re-fires past events, so nothing double-spawns.
reset(fromS = 0) { hazards.reset(fromS); },
dispose() { dispose() {
offEvent(); offDie(); offEvent(); offDie(); offPickup();
enemies.dispose(); weapons.dispose(); enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
}, },
}; };
} }

View File

@ -48,14 +48,6 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length); const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);
// `world.crestSpeed(s)` is world contract v1.1, but Lane A's world had not exposed it yet
// when this was written (their round-2 task #1, in flight — the stub already complies). The
// fallback mirrors TECH §Crest-speed law rather than inventing a number, so surf behaves
// identically the moment A lands theirs, and this guard can then be deleted.
const CREST_FACTOR = 1.6;
const crestSpeedAt = (s, biome) =>
(world.crestSpeed ? world.crestSpeed(s) : biome.flow * CREST_FACTOR);
// --- durability ------------------------------------------------------------------- // --- durability -------------------------------------------------------------------
// Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does // Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
// NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E // NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
@ -78,6 +70,30 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
st.coat = Math.max(0, st.coat - amount); st.coat = Math.max(0, st.coat - amount);
} }
// Unconditional death — lethal hazards (C's reflux finale) call this. Deliberately ignores
// iframes: boost-dodging THROUGH an acid wall isn't a read, it's a bug C's design forbids.
function kill(kind = 'hazard') {
if (!st.alive) return;
st.coat = 0; st.hull = 0; st.alive = false;
bus.emit('player:death', { s: st.s, kind });
bus.emit('audio:cue', { name: 'death' });
}
// Disc-velocity impulse (u/s) — hazards push the ship around with this (squeeze/gate).
function shove(dx, dy) {
if (!st.alive) return;
st.vx += dx; st.vy += dy;
}
// Pickup economy (balance.js §pickups): coat/hull points, boost = seconds shaved off the
// cooldown (0.6 off a 2.4s cd — a recharge nudge, not a free boost).
function refill({ coat = 0, hull = 0, boost = 0 } = {}) {
if (!st.alive) return;
st.coat = Math.min(T.coat.max, st.coat + coat);
st.hull = Math.min(T.hull.max, st.hull + hull);
st.boostCd = Math.max(0, st.boostCd - boost);
}
// --- the loop --------------------------------------------------------------------- // --- the loop ---------------------------------------------------------------------
function update(dt) { function update(dt) {
const intent = input.sample(); const intent = input.sample();
@ -123,7 +139,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
// (crestSpeed = 1.6 × flow > throttleMax 1.4), so riding means being *carried at the // (crestSpeed = 1.6 × flow > throttleMax 1.4), so riding means being *carried at the
// wave's own speed* — which is also why you stay on it instead of shooting off the front. // wave's own speed* — which is also why you stay on it instead of shooting off the front.
const flowSpeed = biome.flow * st.throttle; const flowSpeed = biome.flow * st.throttle;
const crest = crestSpeedAt(st.s, biome); const crest = world.crestSpeed(st.s); // contract v1.1 law: CREST_FACTOR × flow(s)
// Only ever accelerates. If you're already faster than the crest (boosting, or a fast // Only ever accelerates. If you're already faster than the crest (boosting, or a fast
// segment), the wave must not brake you — it just stops being relevant. // segment), the wave must not brake you — it just stops being relevant.
const carried = crest > flowSpeed const carried = crest > flowSpeed
@ -283,6 +299,9 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
get forward() { return world.sample(st.s).tan; }, get forward() { return world.sample(st.s).tan; },
damage, damage,
drain, drain,
kill,
shove,
refill,
respawn(s = 0) { respawn(s = 0) {
Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true }); Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true });
cam.init = false; aimLagInit = false; cam.init = false; aimLagInit = false;