[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:
parent
2e85ec0622
commit
29cca53675
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
docs/shots/laneF/round2_L2_integrated_full_run.png
Normal file
BIN
docs/shots/laneF/round2_L2_integrated_full_run.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
@ -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 };
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user