HardYards/web/world/js/contracts.js
m3ultra 18099c8e6f Align sail lane to contracts.js; free blown corners so they flog
Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.

Contract fixes:
  - anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
    I had it adding sway to pos, which would have flung every
    tree-anchored corner to double its coordinates).
  - events is an Emitter emitting {type, corner}, not a drained array.
  - coverageOver() rects are centre+size, matching world.gardenBed. It
    consumes world.sunDir directly: a hit along sunDir means shaded.
  - START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
    rather than being redeclared here.

Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.

Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.

HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:37 +10:00

354 lines
14 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}) => 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 };
},
};
}