/** * 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. */ export const ANCHOR_TYPE = Object.freeze([ 'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola', ]); // --------------------------------------------------------------------------- // 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.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}, 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.85–1.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) => 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 }; }, }; }