guts/web/js/stub/world_stub.js
jing 1b5ec767cd [lane C] Round 1: schema v1, levels registry + selfcheck + pacing sim, L2 vertical slice
Data spine:
- Schema v1 ratified (TECH.md, C-owned). Additive over v0: schema/par/next, segment
  name, cross-section placement (theta/rho as a FRACTION of safe radius), spread, span,
  pickup+gate event types, hazard params. STUB_LEVEL synced (that const only, per
  ROUND1 §Lane C.1) — world.hash() unchanged (1bdf001), geometry provably untouched.
- levels/index.js: registry + loader (node+browser, no THREE), real schema selfcheck
  (qa's --selfcheck hook is now live) and a pacing simulator (--sim). Consumes the other
  lanes' contracts live rather than restating them: A's biomes.js for ids and
  wave.amp+breathe, A's SKIN, B's tuning.js hull radius, B's balance.js for scores.
- levels/enemies.js (fiction id -> archetype join table), levels/difficulty.js.

L2_esophagus — the vertical slice, complete: 3600 units, 7 beats, 47 events, 65 enemies,
10 checkpoints, 3 samples, reflux-surge finale. L1/L3 skeletons (shape only).
Anatomy is the level designer: the three real esophageal constrictions are the three
setpieces, and all three enemies are real esophageal pathology (bolus chunk, eosinophilic
esophagitis, candida esophagitis).

Measured (node web/js/levels/index.js --selfcheck|--sim; qa GREEN):
- selfcheck 3/3 valid, exit 0.
- pressure curve 0.27 -> 2.14 -> 4.35 -> 0.69 -> 4.53 -> 6.35 -> 4.40 (breather/pressure/
  spike/breather/pressure/peak/finale).
- par 3:30 vs measured 2:47 speedrun / 3:19 par-pace / 4:22 timid; par.score 9000 of a
  9400 kill budget (95%), computed from B's scores.
- all 10 checkpoint gaps within the <=30s death-cost law (max 28.8s); min clearance 2.73.

Two contract collisions found and resolved (LANE_C_NOTES):
- Biome ids: authored mouth/pharynx/cardia/pylorus; A's registry has six canonical ids and
  rejected all four. A is right — biome = look, segments[].name = anatomy. Conformed.
- Balance: my first enemies.js restated hp/size/speed/score that B owns in balance.js.
  Cut to a join table; index.js reads B's scores via scoreFor().

Caught live: A raised esophagus wave.amp 0.9 -> 1.4 mid-session, making the diaphragmatic
hiatus unflyable (2.33 vs the 2.5 floor). Widened 6.5 -> 6.8. The invariant also failed my
own aortic constriction's first draft (radius 7, symmetric squeeze) -> radius 9, directional.

No in-engine flythrough: ?stub=1&lvl= does not compose (snippet offered to F) and the stub
is single-segment, so it could only render beat 1. The pacing evidence is a kinematic model,
not a playtest — it proves L2 is flyable, paced and survivable, not that it feels good.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:53:34 +10:00

187 lines
8.3 KiB
JavaScript

// stub/world_stub.js (Lane F) — executable spec of THE WORLD CONTRACT (docs/TECH.md).
// A straight-ish pulsing esophagus tube, analytic everywhere. Lane A replaces this with
// js/world/index.js; until then every lane builds against this. Keep contract-identical.
import * as THREE from 'three';
// Sample level — kept in sync with LEVEL SCHEMA v1 by Lane C (round 1, authorised by
// ROUND1_INSTRUCTIONS §Lane C.1). Schema of record: docs/TECH.md §Level data schema v1.
// This is the schema's shop window: if you are implementing against level data, this is the
// smallest complete example of it.
//
// Two deliberate restraints, both so this stays a stub and not a level:
// - It keeps the BARE archetype ids ('floater'/'seeker'/'turret') rather than the fiction
// ids that real levels use ('bolus_chunk'...). Lane B's pools are keyed by archetype and
// B's dev harness (flight/dev.js) reads STUB_LEVEL directly, so renaming here would break
// a live lane for nothing. levels/enemies.js resolves both (passthrough entries).
// - Turrets are authored one-per-event with a scalar `theta`. Real levels may use a
// `theta: [a, b]` array for count > 1; that form needs one line in B's spawn loop first
// (snippet in LANE_C_NOTES.md §→ Lane B), so it is not demonstrated here yet.
export const STUB_LEVEL = {
schema: 1,
id: 'STUB', name: 'Stub Esophagus', seed: 20260716,
par: { time: 30, score: 900, samples: 0 },
segments: [{
biome: 'esophagus', name: 'Stub Esophagus',
length: 400, radius: { base: 10, wobble: 0.15 }, curviness: 0.3, flow: 14,
}],
arenas: [],
events: [
{ s: 60, type: 'spawn', enemy: 'floater', count: 4, spread: 60 },
{ s: 140, type: 'spawn', enemy: 'seeker', count: 3, spread: 40 },
{ s: 200, type: 'checkpoint', name: 'Stub Checkpoint' },
{ s: 260, type: 'spawn', enemy: 'turret', count: 1, theta: 0 },
{ s: 280, type: 'spawn', enemy: 'turret', count: 1, theta: 3.1416 },
{ s: 320, type: 'pickup', kind: 'nutrient_orb', count: 3, spread: 40 },
],
};
const BIOME = { // mirrors ART_BIBLE esophagus row; A's biomes.js is the real registry
id: 'esophagus', flow: 14, coatDrain: 0.5,
palette: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d },
fog: 0.028,
};
const WAVE_A = 0.9; // peristalsis displacement amplitude (units, inward)
const WAVE_K = 0.22; // spatial frequency (rad/unit along s)
const WAVE_W = 3.0; // angular speed (rad/s)
const SKIN = 0.6; // collision safety margin
export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) {
const seg = levelData.segments[0];
const LEN = seg.length, R = seg.radius.base;
const AX = seg.curviness * 8, AY = seg.curviness * 5; // gentle analytic centreline
const KX = 0.020, KY = 0.031;
// --- centreline & frames (analytic; s ≈ arclength for these gentle amplitudes) ---
const centre = (s) => new THREE.Vector3(AX * Math.sin(s * KX), AY * Math.sin(s * KY), -s);
const tangent = (s) => new THREE.Vector3(AX * KX * Math.cos(s * KX), AY * KY * Math.cos(s * KY), -1).normalize();
function sample(s) {
const tan = tangent(s);
const up = new THREE.Vector3(0, 1, 0);
const nor = up.clone().addScaledVector(tan, -up.dot(tan)).normalize(); // Gram-Schmidt (stable: tube never goes vertical)
const bin = new THREE.Vector3().crossVectors(tan, nor);
return { pos: centre(s), tan, nor, bin, radius: radiusAt(s) };
}
const radiusAt = (s) => R * (1 + seg.radius.wobble * Math.sin(s * 0.05) * Math.sin(s * 0.013 + 2.1));
const flowPulse = (s, t) => Math.pow(Math.max(0, Math.sin(WAVE_K * s - WAVE_W * t)), 3);
// --- geometry: rings along s, uv = (theta/2pi, s), inward-facing ---
// seam ring vertex is duplicated (RADIAL+1 columns) so uv.x runs 0..1 without wrapping
// backwards across the last quad (visible streak otherwise).
const RADIAL = 64, COLS = RADIAL + 1, STEP = 0.5;
const rings = Math.floor(LEN / STEP) + 1;
const pos = new Float32Array(rings * COLS * 3);
const inward = new Float32Array(rings * COLS * 3);
const uv = new Float32Array(rings * COLS * 2);
let p = 0, q = 0;
for (let i = 0; i < rings; i++) {
const s = i * STEP, f = sample(s);
for (let j = 0; j < COLS; j++) {
const th = (j / RADIAL) * Math.PI * 2;
const dir = f.nor.clone().multiplyScalar(Math.cos(th)).addScaledVector(f.bin, Math.sin(th));
const v = f.pos.clone().addScaledVector(dir, f.radius);
pos[p] = v.x; pos[p + 1] = v.y; pos[p + 2] = v.z;
inward[p] = -dir.x; inward[p + 1] = -dir.y; inward[p + 2] = -dir.z;
p += 3;
uv[q++] = j / RADIAL; uv[q++] = s;
}
}
const idx = [];
for (let i = 0; i < rings - 1; i++)
for (let j = 0; j < RADIAL; j++) {
const a = i * COLS + j, b = i * COLS + j + 1, c = a + COLS, d = b + COLS;
idx.push(a, c, b, b, c, d); // wound to face inward
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('aInward', new THREE.BufferAttribute(inward, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
geo.setIndex(idx);
const mat = new THREE.ShaderMaterial({
side: THREE.FrontSide,
uniforms: {
uTime: { value: 0 },
uTint: { value: new THREE.Color(BIOME.palette.tint) },
uRim: { value: new THREE.Color(BIOME.palette.rim) },
uVoid: { value: new THREE.Color(BIOME.palette.void) },
uFog: { value: BIOME.fog },
},
vertexShader: /* glsl */`
attribute vec3 aInward;
varying vec2 vUv; varying vec3 vN; varying vec3 vView;
uniform float uTime;
void main() {
vUv = uv;
float pulse = pow(max(0.0, sin(${WAVE_K.toFixed(3)} * uv.y - ${WAVE_W.toFixed(2)} * uTime)), 3.0);
float breathe = 0.15 * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831 * 3.0);
vec3 disp = position + aInward * (${WAVE_A.toFixed(2)} * pulse + breathe);
vec4 mv = modelViewMatrix * vec4(disp, 1.0);
vN = normalize(normalMatrix * aInward);
vView = -mv.xyz;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
varying vec2 vUv; varying vec3 vN; varying vec3 vView;
uniform vec3 uTint; uniform vec3 uRim; uniform vec3 uVoid; uniform float uFog; uniform float uTime;
void main() {
// fake SEM detail until Lane D textures land: ridged folds along theta + s striation
float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831 * 9.0 + sin(vUv.y * 0.9) * 2.0);
float stria = 0.85 + 0.15 * sin(vUv.y * 2.2);
vec3 base = uTint * folds * stria * 0.8;
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), 2.2);
vec3 col = base + uRim * fres * 0.9;
float d = length(vView);
col = mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55));
gl_FragColor = vec4(col, 1.0);
}`,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = false; // one chunk; A's real world streams + culls properly
const group = new THREE.Group();
group.name = 'world';
group.add(mesh);
let time = 0;
const world = {
level: levelData,
length: LEN,
group,
sample,
flowPulse: (s, t = time) => flowPulse(s, t),
project(v) {
const s = THREE.MathUtils.clamp(-v.z, 0, LEN);
const f = sample(s);
const rel = v.clone().sub(f.pos);
return { s, theta: Math.atan2(rel.dot(f.bin), rel.dot(f.nor)), rho: rel.length() };
},
wallRho: (s, _theta) => radiusAt(s) - WAVE_A - SKIN,
collide(v, r = 0) {
const { s, theta, rho } = world.project(v);
const max = world.wallRho(s, theta) - r;
if (rho <= max) return null;
const f = sample(s);
const dir = f.nor.clone().multiplyScalar(Math.cos(theta)).addScaledVector(f.bin, Math.sin(theta));
return { push: dir.multiplyScalar(max - rho), kind: 'wall', biome: BIOME.id };
},
biomeAt: (_s) => BIOME,
modeAt: (_s) => 'tube',
arenaAt: (_s) => null,
update(dt, _playerS) { time += dt; mat.uniforms.uTime.value = time; },
hash() {
let h = 2166136261 >>> 0;
for (let s = 0; s <= LEN; s += 10) {
const v = sample(s).pos;
for (const x of [v.x, v.y, v.z, radiusAt(s)]) {
h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619);
}
}
return (h >>> 0).toString(16);
},
dispose() { geo.dispose(); mat.dispose(); },
};
return world;
}