/** * 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']; // --------------------------------------------------------------------------- // 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 }; }, }; }