HardYards/web/world/js/contracts.js
m3ultra 7e8ffa307c Land decisions 4 and 5: Lane D's rig seam, debris impulses
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>
2026-07-16 23:58:14 +10:00

365 lines
15 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'];
// ---------------------------------------------------------------------------
// 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.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.
*/
/**
* @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 };
},
};
}