guts/web/js/world/index.js
jing 3a6dbc30a3 [lane A] Complete the biome texture set (oral + colon) + a provenance gate
Generated on MODELBEAST flux_local, on-device, $0.00. Lane D's pipeline used
unchanged; **D owns these — ratify, retune or bin them.**

Why A did D's job: the six-biome tint contact sheet could only judge 3 of 6
biomes for real (oral/large_intestine/appendix had no pack and fell back to
procedural), and you cannot ratify a tint against a stand-in. All six are now
textured — the set ART_BIBLE §Biome palettes promises.

- wall_oral_a/b, wall_colon_a/b + normals. Seams 6.35/9.30/14.06/6.37 -> 1.01/
  1.08/0.74/1.36 (D's "indistinguishable" band; the shipped pack's weakest is
  2.39). Means normalised to ~0.50 by D's derive_maps, untouched.
- appendix wears the colon pack at its own gold tint (TEXTURE_SHARE in
  world/index.js) — anatomically it IS colon tissue, and it's ART_BIBLE's
  "one texture, two biomes" law for zero bytes. Distinct from the temporary
  slug map; keep it when that dies.
- oral_a took three subjects. v1 named an ORGAN ("tongue") and FLUX drew the
  organ's silhouette — D's "one urchin of villi" in a field's clothing. v2 named
  the tissue but papillae are 3D projections, so perspective gave it a vanishing
  point that tiles into starbursts. v3 asks for squamous cell pavement: flat by
  construction. Rule for the kit: prompt the SURFACE, not the FEATURE.
  Evidence: docs/shots/laneA/round2_oral_prompt_evolution.png

PROVENANCE DEFECT FOUND AND FIXED (-> Lane D):
batch_textures.json recorded, for 4 of 6 walls, the prompt from the framing
experiment D tried and rejected — not the prompt that made the shipped pack.
record() only runs on generation, so reverting a prompt while keeping its image
(exactly what D correctly did: "four winners kept untouched rather than churned")
drifts the record silently. Proved by regenerating esophagus_a at seed 1101 under
both prompts: the code's prompt reproduces the shipped wall, the recorded one
gives the bland wood-grain D described rejecting. Evidence:
docs/shots/laneA/round2_provenance_probe.png. Records re-pointed at the code's
prompt (images untouched — they were never wrong).
New: gen_textures.py --verify-provenance + qa.sh gate 5b, so it cannot rot again.

qa GREEN incl. the new gate; 11/11 provenance verified; all six biomes eyeballed
in engine (docs/shots/laneA/round2_tint_*.png).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:44:58 +10:00

269 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// world/index.js (Lane A) — createWorld(): THE WORLD CONTRACT (docs/TECH.md §THE WORLD
// CONTRACT), implemented for real. Drop-in replacement for js/stub/world_stub.js: same
// surface, same units, same semantics, so anything Lane B built against the stub keeps
// working. Structure:
//
// spline.js the math (no three.js — headless-testable, runs in qa.sh)
// biomes.js palette/param registry (ART_BIBLE values)
// wall_material.js the synthetic-scanner shader (tube AND arena wear it)
// tube.js chunked extrusion + streaming window
// arena.js displaced icosphere shells, v0
// flycam.js ?fly=1 noclip inspection camera (offered to F for boot wiring)
import * as THREE from 'three';
import { createRng } from '../core/rng.js';
import { buildSpline, OMEGA, CREST_FACTOR } from './spline.js';
import { biome as biomeOf } from './biomes.js';
import { createWallMaterial } from './wall_material.js';
import { createTube } from './tube.js';
import { createArena } from './arena.js';
const SKIN = 0.6; // collision safety margin (units) — matches the stub
/**
* Fill each segment's `wave` from its biome so the spline only ever sees numbers. C's schema v2
* `segments[].wave: { amp }` overrides the biome (ruling #8) — the diaphragmatic hiatus is a
* fixed muscular ring: tight AND calm. Returns a shallow copy; `world.level` stays C's object.
*/
function normalizeLevel(levelData) {
return {
...levelData,
segments: levelData.segments.map((seg) => {
const b = biomeOf(seg.biome);
return { ...seg, wave: { amp: seg.wave?.amp ?? b.wave.amp, breathe: seg.wave?.breathe ?? b.wave.breathe } };
}),
};
}
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
if (!levelData || !Array.isArray(levelData.segments)) throw new Error('[world] level.segments is required');
const spline = buildSpline(normalizeLevel(levelData), R);
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
// A miss is not an error: the wall shader's procedural SEM path is the fallback, and it's
// the look we ship until D's pack lands.
//
// Two entry points, deliberately. TECH.md names `get(category, name)` as THE contract, but
// it returns the raw manifest entry — JSON, no THREE.Texture — so a shader can't eat it
// without re-implementing D's loader. `assets.texture(name)` is the one that returns
// {map, normalMap, tile}. We prefer it and keep `get` duck-typed as the documented floor.
// (D and I independently landed on the same `tile` semantics — [repeats around theta, units
// of s per repeat] — both read off the stub's uv. Noted for F to fold into TECH.md.)
//
// NB: tiling is applied in-shader from `tile`, NOT via texture.repeat — a raw ShaderMaterial
// has no uvTransform, so D's pre-applied repeat is inert here. Don't "fix" it by removing
// the uTile math.
// Texture naming. D's round-1 manifest keys are wall_<slug>_a, and the slugs are NOT the
// biome ids: `small_intestine` ships as `wall_smallint_a`. Because assets are optional, a
// wrong key doesn't throw — it silently falls back to the procedural wall forever, which is
// the worst kind of bug. Explicit map, so a mismatch is visible in one place.
// -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D).
// TEMPORARY (ruling #3): D's round-1 keys aren't the biome ids — `small_intestine` ships as
// `wall_smallint_a`. D renames to biome ids early this round and pings in NOTES; **delete
// this map and the ?? fallback the moment they do.** D's assets.js now has a miss ledger
// (`assets.misses()`), so a drifted slug announces itself instead of silently falling back
// forever — which is what made this dangerous in round 1.
const TEXTURE_SLUG = { small_intestine: 'smallint', large_intestine: 'colon' };
// NOT temporary, and not the same thing as the slug map above: this is the ART_BIBLE's
// "one texture can serve two biomes at different tints" law, used deliberately. The appendix
// is anatomically an outpouching of the colon — it is the same tissue — so it wears the colon
// pack under its own gold tint and costs zero bytes. Keep this when the slug map dies.
const TEXTURE_SHARE = { appendix: 'large_intestine' };
const slug = (biomeId) => {
const b = TEXTURE_SHARE[biomeId] ?? biomeId;
return TEXTURE_SLUG[b] ?? b;
};
/** One wall's full texture set, all optional and independently so. */
function texturesFor(biomeId) {
const out = { detail: null, detailB: null, normalMap: null, matcap: null, repeat: null, repeatB: null };
if (!assets || typeof assets.texture !== 'function') return out;
try {
const a = assets.texture(`wall_${slug(biomeId)}_a`);
if (a?.map) { out.detail = a.map; out.repeat = a.repeat ?? null; out.normalMap = a.normalMap ?? null; }
const b = assets.texture(`wall_${slug(biomeId)}_b`);
if (b?.map) { out.detailB = b.map; out.repeatB = b.repeat ?? null; }
if (typeof assets.matcap === 'function') out.matcap = assets.matcap('tissue_wet') ?? null;
} catch (err) {
console.warn(`[world] assets lookup failed for ${biomeId}, using procedural wall —`, err.message);
}
return out;
}
// --- materials -------------------------------------------------------------------------
// Tube: one material per biome, cached. Chunks never straddle a biome join, so live chunks
// share a material and batch cleanly — this is why the draw count is ~7 and not ~700.
const owned = []; // every material we create, for update() + dispose()
const tubeMaterials = new Map();
const radiusHintFor = (biomeId) => {
const span = spline.spans.find((sp) => sp.seg.biome === biomeId);
return span ? spline.paramAt((span.s0 + span.s1) / 2, 'base') : 10;
};
function makeMaterial(biomeId, side, fog) {
const m = createWallMaterial({
biome: biomeOf(biomeId), omega: OMEGA, side, fog,
radiusHint: radiusHintFor(biomeId),
...texturesFor(biomeId),
});
owned.push(m);
return m;
}
function tubeMaterialFor(biomeId) {
if (!tubeMaterials.has(biomeId)) tubeMaterials.set(biomeId, makeMaterial(biomeId, THREE.FrontSide));
return tubeMaterials.get(biomeId);
}
/**
* Arenas get their OWN material per room, because fog has to be sized to the room.
* Biome fog is tuned for a corridor (legible to ~100 units, and it hides the streaming
* edge). Reuse it in C's 360-unit-wide acid sea and the far wall sits at 86% fog — the room
* renders as a black rectangle. Measured, not theorised: that's what the first arena shot
* was. So: fade the far wall (2*radius) to ~70% void, and never fog *more* than the biome
* would. Small lairs keep the biome's murk; big rooms open up.
*/
const arenaFog = (radius) => 1.09 / Math.max(1, radius); // ~70% void at the far wall
function arenaMaterialFor(spec) {
return makeMaterial(spec.biome, THREE.BackSide, Math.min(biomeOf(spec.biome).fog, arenaFog(spec.radius)));
}
// --- geometry -------------------------------------------------------------------------
const group = new THREE.Group();
group.name = 'world';
const tube = createTube({
spline,
materialFor: tubeMaterialFor,
quality,
// An arena owns its whole s-span: skip the tube there or the corridor renders *inside* the
// room and collide() would disagree with what you can see.
skipSpans: arenaSpecs.map((a) => [a.at - a.radius, a.at + a.radius]),
});
group.add(tube.group);
const arenas = arenaSpecs.map((spec) => {
const a = createArena({
spec, spline, rng: R, quality,
material: arenaMaterialFor(spec), // shell viewed from inside
waveAmpDefault: biomeOf(spec.biome).wave.amp, // the room's biome, not the segment's
});
group.add(a.mesh);
return a;
});
tube.prime(0);
// --- contract -------------------------------------------------------------------------
let time = 0;
const biomeIdAt = (s) => spline.segmentAt(s).biome;
// Prefers the per-segment override (schema v2) over the biome default, via the blended
// schedule — so wallRho tracks C's calm hiatus instead of the biome's loudest wave.
const waveMaxAt = (s) => spline.waveAmpAt(s) + biomeOf(biomeIdAt(s)).wave.breathe;
const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null;
/**
* Contract v1.2: `sample(s)` allocates ~6 Vector3s and it's B's hot path (~60 calls/frame at
* 55 enemies = ~350 allocations/frame of GC churn — LANE_B_NOTES → A #3). Pass a caller-owned
* frame as `out` and nothing allocates. The allocating form stays as sugar.
*/
function sample(s, out) {
const f = spline.frameAt(s);
const o = out || { pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3() };
o.pos.set(f.pos.x, f.pos.y, f.pos.z);
o.tan.set(f.tan.x, f.tan.y, f.tan.z);
o.nor.set(f.nor.x, f.nor.y, f.nor.z);
o.bin.set(f.bin.x, f.bin.y, f.bin.z);
o.radius = f.radius;
return o;
}
/** A reusable frame, for callers who want the fast path without owning the boilerplate. */
sample.frame = () => ({ pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3(), radius: 0 });
const world = {
level: levelData,
length: spline.length,
group,
sample,
/** @param {THREE.Vector3} v @param {number} [hint] previous s — free accuracy for B */
project: (v, hint) => spline.project(v, hint),
/** Conservative: geometry radius minus the wave's full amplitude. Time-independent on
* purpose — a collision surface that breathed would have B's ship fighting the wall. */
wallRho: (s, _theta) => spline.radiusAt(s) - waveMaxAt(s) - SKIN,
collide(v, r = 0) {
const a = arenaSpatial(v);
if (a) {
const rel = v.clone().sub(a.center);
const d = rel.length();
const max = a.innerRadius - r;
if (d <= max) return null;
return { push: rel.normalize().multiplyScalar(max - d), kind: 'wall', biome: a.spec.biome };
}
const { s, theta, rho } = spline.project(v);
const max = world.wallRho(s, theta) - r;
if (rho <= max) return null;
const f = spline.frameAt(s);
const ct = Math.cos(theta), st = Math.sin(theta);
const dir = new THREE.Vector3(
f.nor.x * ct + f.bin.x * st,
f.nor.y * ct + f.bin.y * st,
f.nor.z * ct + f.bin.z * st,
);
return { push: dir.multiplyScalar(max - rho), kind: 'wall', biome: biomeIdAt(s) };
},
biomeAt(s) {
const b = biomeOf(biomeIdAt(s));
// flow is read through the blended segment schedule, not the biome default: C tunes it
// per segment and B's controller wants it continuous across joins.
return { id: b.id, palette: b.palette, fog: b.fog, flow: spline.paramAt(s, 'flow'), coatDrain: b.coatDrain };
},
modeAt: (s) => (arenas.some((a) => a.covers(s)) ? 'arena' : 'tube'),
arenaAt(s) {
const a = arenas.find((x) => x.covers(s));
return a ? { center: a.center, radius: a.radius } : null;
},
/** JS mirror of the vertex shader's wave, exact (0 = trough, 1 = crest). E: pulse the mix. */
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
/** Crest-speed law (TECH v1.1): u/s the crest travels == CREST_FACTOR × flow(s). B
* speed-locks to this while surfing; it outruns throttleMax by design (1.6 > 1.4). */
crestSpeed: (s) => spline.crestSpeedAt(s),
crestFactor: CREST_FACTOR,
update(dt, playerS = 0) {
time += dt;
for (const m of owned) m.uniforms.uTime.value = time;
tube.update(playerS);
},
hash: () => spline.hash(),
stats: () => ({ ...spline.stats(), chunks: tube.stats.live, built: tube.stats.built, disposed: tube.stats.disposed }),
dispose() {
tube.dispose();
for (const a of arenas) { group.remove(a.mesh); a.dispose(); }
for (const m of owned) m.dispose(); // D owns their textures; not ours to free
owned.length = 0;
tubeMaterials.clear();
},
// not contract, but handy and cheap to expose
spline,
get time() { return time; },
};
return world;
}