HardYards/web/world/js/contracts.js
type-two a180fce8c3 WIP Lane E S17 gate 3.1: corroded tier (PREDECESSOR STATE — negative control M1 still applied)
Preserved verbatim from the interrupted session. NOTE: build_sail_post_corroded
still carries the M1 mutation (clean galvanised steel, DROOP=0), so the two GLBs
in this commit are the UNTELLED post, not the shipped asset. Restored in the
next commit after the red-check is witnessed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:18:44 +10:00

481 lines
21 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.

/**
* SHADES — shared contracts. THE integration spine.
*
* Owner: Lane A. This file changes ONLY by agreement — if you need a new shape,
* post the need in THREADS.md and let Lane A land it. Everything else in the
* game talks through the types and constants here.
*
* House rules encoded in this file:
* - Units are meters, +Y is up, world origin is yard centre on the ground.
* North is -Z (the house edge), south is +Z, east is +X.
* - Sim modules (sail.js, weather.js, debris.js) take (dt, t) and are pure in
* those: no Date.now(), no Math.random(), no rAF. selftest.html fast-forwards
* storms by calling step() in a tight fixed-dt loop, which only works if the
* sim is deterministic. Use rng() below when you need randomness.
*/
import * as THREE from '../vendor/three.module.js';
// ---------------------------------------------------------------------------
// Units & tuning constants
// ---------------------------------------------------------------------------
/** Sim tick. Every sim module steps at exactly this dt. */
export const FIXED_DT = 1 / 60;
/** Yard footprint in meters. x spans ±WIDTH/2, z spans ±DEPTH/2. */
export const YARD = { width: 30, depth: 20 };
/** Storm length in seconds (prototype value — storm JSON may override). */
export const STORM_LEN = 90;
/** Prep budget in dollars (prototype value). */
export const START_BUDGET = 80;
/** Cost of one spare shackle carried into the storm. */
export const SPARE_COST = 15;
/**
* Hardware tiers, ported from prototype/game.js.
*
* `rating` is a working load limit in NEWTONS — retuned by Lane B against the
* 3D cloth's real load output, per the standing note that Lane B owns these
* numbers. Costs are the prototype's, untouched.
*
* The 2D prototype's 9/19/40 were on an arbitrary scale. The 3D cloth reports
* real newtons (a 5x5 m sail pulls ~1-4 kN per corner in a 34 m/s storm), so
* these are real WLLs: a cheap carabiner really does let go around 1.2 kN, a
* rated 8 mm shackle really does hold 6.5 kN. That is the DESIGN.md "Kerbal
* trick" — leave the game able to size real hardware.
*
* The SHAPE that had to survive retuning, and did: three tiers at 1x / 3x / 6x
* price, where $80 buys rated hardware on at most two of four corners. A mixed
* rig stays the interesting choice and you are always picking which corner to
* leave dodgy. Asserted in js/tests/b.test.js.
*/
export const HARDWARE = [
{ name: 'carabiner', cost: 5, rating: 1200, color: 0xe2b04a },
{ name: 'shackle', cost: 15, rating: 3200, color: 0xc8d2d8 },
{ name: 'rated shackle', cost: 30, rating: 6500, color: 0x7ee0ff },
];
/** Game phases, in loop order. */
export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
/**
* Every anchor type a site may declare (SPRINT11, D's flag).
*
* These are the strings Lane E bakes as `anchor_type` in the GLBs — kept
* identical on purpose, so an asset and a site say the same word for the same
* steel. The list is OPEN to new sites: adding a type here and in the asset is
* the intended way to grow, which is the whole reason the carport didn't fit.
*
* The only behaviour keyed on a type string is the tree wind-shadow filter
* (main.js: `type === 'tree'`). Ladder work is `work`; hardware rating is E's
* `rating_hint`. Keep it that way — a rule keyed on a type a future site
* doesn't fit is the exact shape of the bug this widening fixed.
*
* `swing_frame` (SPRINT14, E): a swing set's apex junction. It is NOT a `post`
* and the difference is the whole reason it got its own word — the type string
* is what the player reads before they commit, and "post" promises 4 m of
* concreted steel. This is a welded joint on a frame standing on grass: good
* steel, no footing, rating_hint 0.45. Same lesson as the carport, which was
* also nearly smuggled in as a post.
*
* `pergola` (SPRINT16 gate 5, E): a pergola's bolted post-beam corner — the
* ladder's middle rung (0.65), D-endorsed in S15. Not a `post` for the same
* reason the swing frame isn't: the word promises the footing, and this one's
* footing is a DECK — real bolts into real joists, so better than
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
* concrete would notice (1.00). One rating for the whole type, per the
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
*
* `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty — the pool
* yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`,
* and this time the word isn't about the footing (the footing is fine —
* concrete, same as ever): it's about the STEEL. "post" promises sound
* galvanised tube; this is eaten tube whose visible bloom is a floor on the
* damage, not a ceiling. rating_hint 0.55 — above the swing frame (footed
* beats loose), below the pergola (sound flexing timber beats steel you
* can't inspect the inside of). One rating for the whole type, per the
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
* string is the player's pre-rig read of the same fact.
*/
export const ANCHOR_TYPE = Object.freeze([
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
'corroded_post',
]);
// ---------------------------------------------------------------------------
// Determinism helpers
// ---------------------------------------------------------------------------
/**
* mulberry32 — small, fast, seeded PRNG. Use this instead of Math.random() in
* any sim code so selftest runs reproduce byte-for-byte.
* @param {number} seed
* @returns {() => number} next float in [0,1)
*/
export function rng(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Tiny event emitter. Backs `game.on()` and `sailRig.events`. */
export class Emitter {
#handlers = new Map();
/**
* @param {string} type
* @param {(payload:any) => void} fn
* @returns {() => void} unsubscribe
*/
on(type, fn) {
if (!this.#handlers.has(type)) this.#handlers.set(type, new Set());
this.#handlers.get(type).add(fn);
return () => this.off(type, fn);
}
off(type, fn) {
this.#handlers.get(type)?.delete(fn);
}
emit(type, payload) {
for (const fn of this.#handlers.get(type) ?? []) fn(payload);
}
}
// ---------------------------------------------------------------------------
// The contracts themselves
// ---------------------------------------------------------------------------
/**
* WIND — the shared primitive. weather.js (Lane C) implements it; world, sail,
* player and debris all consume it. Everything that moves in this game moves
* because of a number that came out of wind.sample().
*
* @typedef {object} Wind
* @property {(pos: THREE.Vector3, t: number) => THREE.Vector3} sample
* Wind velocity in m/s at a world position and time. Includes base curve,
* gusts, direction change, spatial noise and local effects (tree wind
* shadows). MUST be pure in (pos, t). Returns a vector the caller may not
* mutate — treat it as read-only, or clone before writing.
* @property {(t: number) => ({eta:number, dir:number, power:number}|null)} gustTelegraph
* The gust that is coming but has not hit yet, or null. `eta` is seconds
* until the ramp starts, `dir` is radians in the XZ plane, `power` is the
* peak speed in m/s the gust will add. Drives the HUD warning, the grass
* wave and the audio cue. Contract: eta is never less than 1.2 s when the
* telegraph first appears — the player must always have time to react.
*/
/**
* WORLD — the yard. Lane A implements; everyone consumes.
*
* @typedef {object} World
* @property {Anchor[]} anchors Every riggable point in the yard.
* @property {(x:number, z:number) => number} heightAt
* Ground height in meters at a world XZ. Pure, cheap, matches the terrain
* mesh. Lane D clamps the player to it; Lane C bounces debris off it.
* @property {{x:number, z:number, w:number, d:number}} gardenBed
* The thing you are protecting: an axis-aligned ground rect, centre (x,z),
* size (w,d) in meters. Pass it to sailRig.coverageOver().
* @property {THREE.Vector3} sunDir
* Unit vector pointing FROM the ground TOWARD the sun. For a shade test,
* raycast origin=groundPoint direction=sunDir: a hit means shaded.
* @property {THREE.Object3D[]} solids
* Obstacle meshes the camera and player collide against: house, trunks,
* posts, fence. The GROUND IS NOT IN HERE — use heightAt() for that. It is
* exact, it can't be tunnelled through, and it costs nothing.
* @property {(dt:number, t:number) => void} update
*/
/**
* ANCHOR — a tower slot. Where a sail corner can be attached.
*
* @typedef {object} Anchor
* @property {string} id Stable, e.g. 'h1', 't1', 'p2'.
* @property {THREE.Vector3} pos REST position. Does not move.
* @property {ANCHOR_TYPE[number]} type
* What the anchor IS. Widened in SPRINT11 on D's flag: site_02's carport was
* typed 'post' to fit the old closed enum, which quietly enrolled it in the
* sail-post family that C's venturi and B's audit both read. The types now
* match the strings Lane E bakes into the GLB, so `type` can be believed.
* NOT the ladder's field — that's `work`, the mechanism (D, SPRINT10).
* @property {(t:number) => THREE.Vector3} sway
* ABSOLUTE world position at time t, including wind sway. NOT an offset —
* this is the value you pin a cloth corner to:
* node.copy(anchor.sway(t))
* For house and post anchors this equals `pos`. For tree anchors it wanders,
* and that wander is dynamic load — the reason tree anchors are dangerous.
* Pure in t (given wind is pure in t). Returns a shared vector: clone before
* storing.
*/
/**
* SAIL RIG — Lane B implements.
*
* @typedef {object} SailRig
* @property {Corner[]} corners
* @property {(anchorIds: string[], hwChoices: object[], tension: number) => void} attach
* anchorIds has exactly 4 entries; the rig re-orders them into ring order by
* angle around their centroid. tension scales spring rest lengths, 0.61.4
* (low = loose and floggy, high = drum tight and shock-loaded).
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
* @property {(rect: {x:number,z:number,w:number,d:number}, sunDir?: THREE.Vector3, heightAt?: (x:number,z:number)=>number) => number} coverageOver
* Ground-projected shade over a rect, 0..1. Pass world.sunDir and
* world.heightAt so the rays start at the real ground and point at the real
* sun; the defaults (overhead sun, flat y=0) are only for tests.
* @property {Emitter} events Emits 'break' and 'repair' as {type, corner}.
* @property {(i: number) => void} repair
* Re-rig corner i with the carried spare (shackle grade — the only kind prep
* sells). No-op if the corner isn't broken. Lane D's 2.5 s hold-E.
* @property {(i: number, delta: number) => void} trim
* Per-corner turnbuckle; delta is ±, clamped to 0.851.15. Lane D's 1.2 s hold.
* @property {(i: number) => (THREE.Vector3|null)} cornerPos
* LIVE world position of corner i, as a fresh vector safe to keep. A blown
* corner's node is flying, so an interaction prompt anchored to this chases
* the flogging corner instead of sitting on the dead anchor. null if unrigged.
* @property {() => number} pondMass
* Kilograms of rainwater pooled on the sail (SPRINT5 ponding). Lane A's HUD
* warning threshold and "SAIL PONDING — get the broom" ticker read this.
* @property {() => ({x:number,y:number,z:number,mass:number,node:number}|null)} pondCentroid
* Where the pond sits in world space, its mass, and the heaviest grid node —
* or null if there's nothing worth pointing at. Lane D walks the player to
* this; Lane E draws the water here.
* @property {(node:number, dt:number, radius?:number) => number} drainPondAt
* Lane D's broom: poke node `node` (from pondCentroid().node) for one frame of
* the ~1.5 s hold; drains a radius around it and RETURNS the kg shed this call.
* Sum over the hold = what lands on the player's head. Emits 'pondDump'.
*/
/**
* @typedef {object} Corner
* @property {string} anchorId
* @property {{name:string, cost:number, rating:number}} hw
* @property {number} load Smoothed, same units as hw.rating.
* @property {boolean} broken
*/
/**
* DEBRIS — Lane C implements. Lane B consumes `pieces` inside sail.step().
*
* SPRINT2 decision 5: the sail reads the pieces and applies its own impulses,
* rather than debris.js reaching into the cloth. Momentum bookkeeping stays in
* the one integrator that owns the nodes. That makes `pieces` a real contract
* surface, so it is **frozen** here: fields below are what Lane B may rely on.
*
* @typedef {object} Debris
* @property {DebrisPiece[]} pieces
* Live pieces, newest last. The ARRAY IS MUTATED IN PLACE each step — pieces
* are spliced out when they leave the yard, so don't hold a reference to it
* across frames, and don't hold a piece past the step it despawned in. Read it
* fresh inside step(). Order is not stable.
* @property {(dt:number, t:number, world?:object) => void} step Fixed dt. Deterministic.
* @property {(ev:object, t:number) => DebrisPiece} spawn
* @property {(map:Object<string,THREE.Object3D>) => Debris} setModels
* @property {() => void} clear
*/
/**
* One airborne object. Frozen shape — Lane C will not remove or repurpose these.
*
* The collision volume is a SPHERE of radius `r` centred on (x,y,z): a crate is
* boxy, but a sphere is what you can afford to test against every cloth node,
* every frame. Everything is SI — metres, m/s, kg — so `mass * v` is a real
* momentum you can subtract from.
*
* @typedef {object} DebrisPiece
* @property {number} x
* @property {number} y Centre, not base. Rests at heightAt(x,z) + r.
* @property {number} z
* @property {number} vx
* @property {number} vy
* @property {number} vz
* @property {number} r Collision sphere radius, m.
* @property {number} mass kg. Crate 9, tub 5, bin 14.
* @property {string} model Key into models/debris/, e.g. 'BlueCrate_v2'.
* @property {boolean} hitPlayer Already knocked the player down once.
* @property {THREE.Object3D|null} mesh Render instance. Lane C drives it; don't move it.
*/
/**
* PLAYER — Lane D implements.
*
* @typedef {object} Player
* @property {THREE.Vector3} pos
* @property {object|null} carrying One item, or null. Hands-full rule.
* @property {boolean} busy True during a hold-E action or knockdown.
* @property {(dt:number, t:number) => void} update
* Driven by main.js's fixed-dt loop. (Lane A addition to PLAN3D §4, which
* listed only the read-only fields — main.js has to step the player from
* somewhere. Logged in THREADS.md.)
*/
/**
* INTERACT — Lane D implements. Lane B and E register the touchable things.
*
* @typedef {object} Interact
* @property {(spec: InteractSpec) => (() => void)} register Returns an unregister fn.
*/
/**
* @typedef {object} InteractSpec
* @property {string} id
* @property {THREE.Vector3} pos
* @property {number} radius Meters.
* @property {number} holdSecs 0 for instant.
* @property {string} label Shown in the prompt, e.g. 're-rig corner'.
* @property {() => boolean} canUse Gate on spares, busy, carrying.
* @property {() => void} onDone
*/
/**
* CAMERA — Lane A implements. Lane D needs `yaw` for camera-relative movement.
*
* @typedef {object} CameraRig
* @property {THREE.PerspectiveCamera} object
* @property {number} yaw Radians. WASD is relative to this.
* @property {(dt:number, targetPos:THREE.Vector3) => void} update
*/
/**
* GAME — Lane A implements.
*
* @typedef {object} Game
* @property {'forecast'|'prep'|'storm'|'aftermath'} phase
* @property {(type:'phaseChange', fn:(p:{from:string,to:string}) => void) => (() => void)} on
* NOTE: PLAN3D §4 writes this as `game.on(phaseChange)`. It is implemented as
* Emitter-style `on(type, fn)` with type 'phaseChange', for consistency with
* sailRig.events. (Lane A clarification, logged in THREADS.md.)
*/
// ---------------------------------------------------------------------------
// Shape checking — used by selftest to catch contract drift at merge time
// ---------------------------------------------------------------------------
/**
* Required members per contract, as data. `'*'` means "present, any type".
* Lane A asserts these in selftest after every merge; that is how we find out
* a lane's module drifted before it breaks someone else's lane.
*/
export const CONTRACT = {
wind: { sample: 'function', gustTelegraph: 'function' },
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' },
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function', pondMass: 'function', pondCentroid: 'function', drainPondAt: 'function' },
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
interact: { register: 'function' },
camera: { object: 'object', yaw: 'number', update: 'function' },
game: { phase: 'string', on: 'function' },
debris: { pieces: 'object', step: 'function', spawn: 'function', setModels: 'function', clear: 'function' },
};
/**
* The frozen DebrisPiece fields (SPRINT2 decision 5). Lane B's sail.step() reads
* these off `debris.pieces` and applies impulses from them, so renaming one is a
* breaking change to someone else's integrator, not a local tidy-up. Asserted
* against live pieces in c.test.js — if this table and debris.js disagree, the
* selftest says so before Lane B's cloth does.
*/
export const DEBRIS_PIECE_FIELDS = {
x: 'number', y: 'number', z: 'number',
vx: 'number', vy: 'number', vz: 'number',
r: 'number', mass: 'number', model: 'string',
};
/**
* @param {string} name A key of CONTRACT.
* @param {object} obj The implementation to check.
* @returns {string[]} Human-readable problems; empty means it conforms.
*/
export function checkContract(name, obj) {
const spec = CONTRACT[name];
if (!spec) return [`unknown contract '${name}'`];
if (!obj) return [`${name}: implementation is ${obj}`];
const problems = [];
for (const [key, want] of Object.entries(spec)) {
const got = typeof obj[key];
if (got === 'undefined') problems.push(`${name}.${key} missing`);
else if (want !== '*' && got !== want) problems.push(`${name}.${key} is ${got}, want ${want}`);
}
return problems;
}
// ---------------------------------------------------------------------------
// Stub wind — so B, D and the HUD can run before Lane C lands weather.js
// ---------------------------------------------------------------------------
/**
* A deterministic stand-in for weather.js that satisfies the Wind contract.
* It ports the prototype's gust shape (telegraph 1.5s → ramp 0.8s → hold 1.7s
* → fade 1.0s) and its base ramp, but is uniform in space and has no direction
* change, no tree shadows and no noise. Lane C replaces it wholesale; nobody
* should tune against it.
*
* @param {object} [opts]
* @param {number} [opts.seed=1]
* @param {number} [opts.stormLen=STORM_LEN]
* @param {boolean} [opts.calm=false] Calm-day breeze only — no storm ramp, no gusts.
* @returns {Wind}
*/
export function createStubWind(opts = {}) {
const { seed = 1, stormLen = STORM_LEN, calm = false } = opts;
const TELEGRAPH = 1.5, RAMP = 0.8, HOLD = 1.7, FADE = 1.0;
const CYCLE = TELEGRAPH + RAMP + HOLD + FADE; // 5.0 s, matches prototype
// Precompute the whole gust schedule up front: that keeps sample() pure in t
// (no hidden state advancing per call) so selftest can sample out of order.
const rand = rng(seed);
const gusts = [];
for (let t = 3; t < stormLen + CYCLE; ) {
const p = Math.min(1, t / stormLen);
gusts.push({ start: t, power: 12 + rand() * 16 + 10 * p });
t += CYCLE + 5 + rand() * 7;
}
const dirAt = (t) => 0.9 + 0.25 * Math.sin(t * 0.13);
/** Gust contribution in m/s at time t, plus the gust in flight. */
function gustAt(t) {
for (const g of gusts) {
const gt = t - g.start;
if (gt < 0 || gt >= CYCLE) continue;
let mag;
if (gt < TELEGRAPH) mag = 0;
else if (gt < TELEGRAPH + RAMP) mag = g.power * (gt - TELEGRAPH) / RAMP;
else if (gt < TELEGRAPH + RAMP + HOLD) mag = g.power;
else mag = g.power * (CYCLE - gt) / FADE;
return { mag, g, gt };
}
return { mag: 0, g: null, gt: 0 };
}
const out = new THREE.Vector3();
return {
sample(_pos, t) {
if (calm) {
const dir = dirAt(t);
const s = 4 + 0.6 * Math.sin(t * 0.7);
return out.set(Math.cos(dir) * s, 0, Math.sin(dir) * s);
}
const base = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
const speed = base + gustAt(t).mag;
const dir = dirAt(t);
return out.set(Math.cos(dir) * speed, 0, Math.sin(dir) * speed);
},
gustTelegraph(t) {
if (calm) return null;
const { g, gt } = gustAt(t);
if (!g || gt >= TELEGRAPH) return null; // already landed: not a warning any more
return { eta: TELEGRAPH - gt, dir: dirAt(t), power: g.power };
},
};
}