mollycool/test/harness.mjs
type-two 6263672134 Molly Cool: arcade science-action vertical slice
A nanoscale arcade game. Shoot charge across the room, wrestle atoms
together until they snap, and contain a chain reaction before it eats
the field. Vanilla JS + Canvas 2D, no dependencies, no build step.

Core systems:
- Charge: give/take as the only two verbs. Opposites attract, likes repel.
- Laserhands: ranged proton/vacuum bolts with real travel time. Bolts are
  charged bodies, so they curve through the field — bank shots are real.
  Give spends from an 8-proton ring, take earns them back.
- The wrestle: bonds can't form on their own. Every pair has an activation
  barrier that parks them at arm's length; Molly's hands are the only way
  over it. Hold, squeeze, release -> SNAP.
- Radicals: the enemy, with no mind. Every 0.32s one grabs its nearest
  neighbour, completes itself, and hands the wound on. Can't be shot dead —
  terminated by wrestling two together (barrierless, so 0.38s vs 1.56s).
  Two that drift into contact self-quench, which gives outbreaks a natural
  equilibrium instead of unbounded growth.
- Overload: keep feeding one atom and it goes critical. The blast tips its
  neighbours, so density decides whether you get one pop (34 atoms) or 84
  detonations from a single seed (116 atoms).
- CASCADE: eight arenas of escalating density with finite ignition waves.
  Clear the field to advance; sustained neglect melts it down.
- Membrane, sour/soapy zones, and the seven wordless tutorial rooms.

Non-obvious constraints, documented at each constant:
- BARRIER_FORCE must exceed MAX_CHARGE_FORCE, or atoms bond themselves and
  the wrestle (the whole game) gets skipped.
- Bond lock distance scales with atom radius. A flat value meant two carbons
  could never bond at all, since collision held them further apart than the
  lock — invisible because hydrogen worked fine.
- RAD_SPLIT_R must exceed the field's natural packing distance (set by the
  barrier), or outbreaks can only crawl and never bloom.

Testing: 39 headless sim tests run the real physics in Node — no browser,
no canvas. The sim draws all randomness from a seeded RNG so runs are
reproducible. Perf is 0.32ms/sim-step at 116 atoms.

Dev server strips a /v<timestamp>/ path prefix that index.html adds to its
entry import, because browsers cache the *parsed* ES module by URL and
no-cache headers alone don't touch it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:36:54 +10:00

79 lines
2.6 KiB
JavaScript

// Headless sim harness. Runs the real physics in Node — no browser, no
// canvas, no module-cache games. Deterministic and fast.
//
// node test/harness.mjs
//
// Stubs only the things that touch the DOM (audio + gamepad rumble).
import { CFG } from '../src/config.js';
// ---- stub the browser bits before anything imports them ----
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
// node 26 has a getter-only navigator — patch the method onto it instead
if (!globalThis.navigator) {
Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true });
}
if (!globalThis.navigator.getGamepads) {
try { globalThis.navigator.getGamepads = () => []; }
catch { Object.defineProperty(globalThis, 'navigator', { value: { getGamepads: () => [] }, configurable: true }); }
}
const { World } = await import('../src/world.js');
const { Camera } = await import('../src/camera.js');
const { step } = await import('../src/physics.js');
const { Radicals } = await import('../src/radicals.js');
const { Input } = await import('../src/input.js');
const { ROOMS } = await import('../src/rooms.data.js');
const { seedRandom } = await import('../src/util.js');
export function makeWorld(seed = 1234) {
seedRandom(seed); // reproducible runs — the storm is seeded
const cam = new Camera();
const w = new World(cam);
Input.usingGamepad = false;
Input.grab = false;
Input.moveX = Input.moveY = 0;
Input.mouseWorld = { x: -9999, y: -9999 };
return w;
}
export function sim(w, seconds) {
const n = Math.round(seconds / CFG.DT);
for (let i = 0; i < n; i++) tick(w);
}
export function tick(w) {
w.molly.resolveAim();
w.handleVerbs();
w.molly.move(CFG.DT);
step(w, CFG.DT);
w.update(CFG.DT);
}
export function aimAt(w, x, y) {
Input.mouseWorld = { x, y };
w.molly.resolveAim();
}
export { CFG, World, Camera, Radicals, Input, ROOMS, step, seedRandom };
// ---------------------------------------------------------------
// tiny assert lib
// ---------------------------------------------------------------
let pass = 0, fail = 0;
const fails = [];
export function check(name, cond, detail = '') {
if (cond) { pass++; console.log(` \x1b[32m✓\x1b[0m ${name}${detail ? ' ' + detail : ''}`); }
else { fail++; fails.push(name); console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ' ' + detail : ''}`); }
}
export function section(t) { console.log(`\n\x1b[1m${t}\x1b[0m`); }
export function report() {
console.log(`\n${fail === 0 ? '\x1b[32m' : '\x1b[31m'}${pass} passed, ${fail} failed\x1b[0m`);
if (fails.length) console.log('failed: ' + fails.join(', '));
return fail === 0;
}