From ae6e4449964e773f3b9de1a6d74f92124c286ac1 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:33:42 +1000 Subject: [PATCH] Add contracts.js: the shared integration spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types, tuning constants ported from the prototype, a seeded PRNG and Emitter, plus checkContract() — the tripwire that catches a lane drifting from its interface at merge time instead of three lanes later. createStubWind() ports the prototype's gust shape (telegraph 1.5s, ramp 0.8s, hold 1.7s, fade 1.0s) so B and D can develop before Lane C lands weather.js. Its schedule is precomputed at construction, which keeps sample() pure in t — the selftest samples out of order and must get the same answer. Co-Authored-By: Claude Opus 4.8 --- web/world/js/contracts.js | 344 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 web/world/js/contracts.js diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js new file mode 100644 index 0000000..7eaf7ae --- /dev/null +++ b/web/world/js/contracts.js @@ -0,0 +1,344 @@ +/** + * 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 nominal kN. The ABSOLUTE numbers are placeholders inherited from + * the 2D prototype's load scale — Lane B owns retuning them against the 3D + * cloth's real load output. What must survive retuning is the SHAPE: three + * tiers, roughly 1x / 2x / 4.5x strength at 1x / 3x / 6x price, so a mixed rig + * is always the interesting choice and one dodgy corner is always affordable. + */ +export const HARDWARE = [ + { name: 'carabiner', cost: 5, rating: 9, color: 0xe2b04a }, + { name: 'shackle', cost: 15, rating: 19, color: 0xc8d2d8 }, + { name: 'rated shackle', cost: 30, rating: 40, color: 0x7ee0ff }, +]; + +/** Game phases, in loop order. */ +export const PHASES = ['forecast', 'prep', 'storm', 'aftermath']; + +// --------------------------------------------------------------------------- +// 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 {'house'|'tree'|'post'} type + * @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.6–1.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}) => number} coverageOver + * Ground-projected shade over a rect, 0..1. + * @property {Emitter} events Emits 'break' and 'repair' as {type, corner}. + */ + +/** + * @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 + */ + +/** + * 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' }, + player: { pos: 'object', carrying: '*', busy: '*', update: 'function' }, + interact: { register: 'function' }, + camera: { object: 'object', yaw: 'number', update: 'function' }, + game: { phase: 'string', on: 'function' }, +}; + +/** + * @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 }; + }, + }; +}