diff --git a/docs/LANES/ROUND2_INSTRUCTIONS.md b/docs/LANES/ROUND2_INSTRUCTIONS.md index b8ce3e3..c8dd853 100644 --- a/docs/LANES/ROUND2_INSTRUCTIONS.md +++ b/docs/LANES/ROUND2_INSTRUCTIONS.md @@ -1,5 +1,35 @@ # 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:08–15: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 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 diff --git a/docs/progress/f-progress.md b/docs/progress/f-progress.md index 8df8dfc..c7cb784 100644 --- a/docs/progress/f-progress.md +++ b/docs/progress/f-progress.md @@ -1,5 +1,34 @@ # 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:08–15: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) Reviewed all four lanes; every checked claim held (A's hash cefc4f83 + 8-draw sweep, B's diff --git a/docs/shots/laneF/round2_L2_integrated_full_run.png b/docs/shots/laneF/round2_L2_integrated_full_run.png new file mode 100644 index 0000000..119697a Binary files /dev/null and b/docs/shots/laneF/round2_L2_integrated_full_run.png differ diff --git a/web/js/boot.js b/web/js/boot.js index b8f65b6..3c49cea 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -58,10 +58,46 @@ const player = flags.fly ? 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 -// 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; 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) --- let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6; const keys = new Set(); @@ -116,19 +152,27 @@ addEventListener('resize', () => { renderer.setSize(innerWidth, innerHeight); }); -let last = performance.now(); -function frame(now) { - const dt = Math.min((now - last) / 1000, 0.05); - last = now; +// One deterministic simulation step — the whole game minus rendering. Exported so the +// stepped-sim harness (f-progress documents it; rAF never fires in a hidden pane) exercises +// the REAL loop — pump, run state and all — instead of a hand-rolled copy that drifts. +function step(dt) { if (player) { player.update(dt); // must run first: combat hit-tests this frame's ship position pumpTo(player.state.s); combat.update(dt); + updateRun(dt); world.update(dt, player.state.s); } else { updateCamera(dt); // ?fly=1 noclip / title drift 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); stats.draws = renderer.info.render.calls; stats.tris = renderer.info.render.triangles; @@ -146,4 +190,4 @@ function frame(now) { if (flags.dbg && !flags.shots) dbgEl.style.display = 'block'; requestAnimationFrame(frame); -export { scene, camera, renderer, bus, flags, world, player, combat, assets }; +export { scene, camera, renderer, bus, flags, world, player, combat, assets, step }; diff --git a/web/js/combat/index.js b/web/js/combat/index.js index 7093913..bb85944 100644 --- a/web/js/combat/index.js +++ b/web/js/combat/index.js @@ -1,33 +1,61 @@ -// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons and -// feeds them Lane C's level events. All the wiring, none of the behaviour. +// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons + hazards +// + pickups and feeds them Lane C's level events. All the wiring, none of the behaviour. // // Update order inside a frame is load-bearing: // 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. +// 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 { 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 }) { const enemies = createEnemies({ scene, world, bus, rng, assets }); 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.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 - // `spawn`; checkpoints/hazards/bosses belong to C and E this round. Unknown types are - // ignored on purpose — C may add event kinds without breaking combat. - const offEvent = bus.on('level:event', (ev) => { - if (!ev || ev.type !== 'spawn') return; + // Fan a group of `count` out along s. C's `spread` is the authored span of the group + // ("count 4 spread 120" = a field, not a wall); without one, a tight default fan keeps + // a count from stacking at a single arclength. + const fan = (ev, i) => { const n = ev.count ?? 1; - for (let i = 0; i < n; i++) { - // fan the group out over a short stretch of s so a count of 6 is an encounter, - // not a wall of enemies at one arclength - enemies.spawn(ev.enemy, { s: (ev.s ?? 0) + i * 3.5, theta: ev.theta ?? null, rho: ev.rho ?? null }); + if (n <= 1) return ev.s ?? 0; + const span = ev.spread ?? (n - 1) * 3.5; + return (ev.s ?? 0) + (i / (n - 1)) * span; + }; + // 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++; }); + // 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) { enemies.update(dt, player); weapons.update(dt); + hazards.update(dt); + pickups.update(dt); // combat's slice of the HUD state (E reads player:state for the flight half) bus.emit('combat:state', { heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated, 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 { 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() { - offEvent(); offDie(); - enemies.dispose(); weapons.dispose(); + offEvent(); offDie(); offPickup(); + enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose(); }, }; } diff --git a/web/js/flight/player.js b/web/js/flight/player.js index 2dcac14..c5290b9 100644 --- a/web/js/flight/player.js +++ b/web/js/flight/player.js @@ -48,14 +48,6 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null 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 ------------------------------------------------------------------- // 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 @@ -78,6 +70,30 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null 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 --------------------------------------------------------------------- function update(dt) { 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 // 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 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 // segment), the wave must not brake you — it just stops being relevant. 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; }, damage, drain, + kill, + shove, + refill, 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 }); cam.init = false; aimLagInit = false;