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>
79 lines
2.6 KiB
JavaScript
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;
|
|
}
|