Decision 4 — conform to Lane D's call sites rather than the reverse (D landed first and duck-typed them): repair(i), trim(i, delta) and cornerPos(i). repair takes no hardware argument because prep sells exactly one kind of spare, so it re-rigs at shackle grade — an upgrade on a blown carabiner, a downgrade on a blown rated shackle, which is the prototype's behaviour and a real choice about which corner you run to. cornerPos returns a fresh vector at the live node, so a blown corner's prompt chases the flogging corner instead of sitting on a dead anchor (measured: 13 m off). All three are contract entries now, not PROPOSED comments, so the merge tripwire enforces the seam. Decision 5 — sail.step() takes an optional debris and applies sphere-vs- cloth impulses. The exchange is symmetric: every newton-second the cloth takes out of a crate, the crate loses. Asserted, and it conserves to 0.000% on an interior hit. Pinned corners are the deliberate exception — invMass 0 means a crate off a corner dumps its momentum into the house, which is correct, the anchor is bolted to a wall. Note this leaves debris.js's applyToSail dead: it guards on `sail.nodes`, which never existed on the rig — the cloth stores Float64Arrays. So the debris-vs-sail impulse has been silently doing nothing in the assembled game. Decision 5 puts it on this side; flagged for Lane C in THREADS. The contact radius is swept by the piece's travel because main.js steps the sail before the debris (so piece positions are a frame stale) and a 0.3 m crate at 25 m/s covers 0.42 m per frame — enough to pass clean between cloth nodes. Also: coverageOver() rays now start at heightAt(x,z) rather than y=0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
365 lines
15 KiB
JavaScript
365 lines
15 KiB
JavaScript
/**
|
||
* 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}, 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.
|
||
*/
|
||
|
||
/**
|
||
* @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', repair: 'function', trim: 'function', cornerPos: 'function' },
|
||
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 };
|
||
},
|
||
};
|
||
}
|