mollycool/test/sim.test.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

169 lines
6.5 KiB
JavaScript

import {
makeWorld, sim, tick, aimAt, check, section, report,
CFG, Radicals, Input, ROOMS,
} from './harness.mjs';
// ============================================================
// THE GO/NO-GO: does one radical eat a field?
// ============================================================
section('THE RADICAL — outbreak dynamics');
{
const w = makeWorld();
w.load(ROOMS[1]);
w.particles.length = 0;
// a dense-ish field, deterministic layout (no Math.random in the test)
let i = 0;
for (let gx = 0; gx < 16; gx++)
for (let gy = 0; gy < 9; gy++)
w.spawn({ x: 240 + gx * 42, y: 110 + gy * 46, el: ['H', 'C', 'O', 'N'][i++ % 4] });
w.molly.x = 80; w.molly.y = 660;
Radicals.make(w.particles[0]);
tick(w);
const t0 = w.radicals.count;
const trace = [];
for (let s = 0; s < 6; s++) {
sim(w, 1);
trace.push(`t=${s + 1}s n=${w.radicals.count} hops=${w.radicals.chain}`);
}
console.log(' ' + trace.join(' | '));
check('starts with exactly one radical', t0 === 1, `n=${t0}`);
check('the wound MOVES (chain hops)', w.radicals.chain > 5, `hops=${w.radicals.chain}`);
check('branch factor crosses 1 in a dense field', w.radicals.peak > 1, `peak=${w.radicals.peak}`);
check('respects the population cap', w.radicals.peak <= CFG.RAD_CAP, `peak=${w.radicals.peak}`);
}
// ============================================================
// TERMINATION — the wrestle is the kill move
// ============================================================
section('TERMINATION — two radicals annihilate');
{
const w = makeWorld();
w.load(ROOMS[1]);
w.particles.length = 0;
const a = w.spawn({ x: 620, y: 322, el: 'C' });
const b = w.spawn({ x: 620, y: 398, el: 'C' });
Radicals.make(a); Radicals.make(b);
w.molly.x = 620; w.molly.y = 520; // stand clear; the squeeze point is lethal
aimAt(w, 620, 360);
Input.grab = true;
let killT = null;
for (let i = 0; i < 600; i++) {
tick(w);
if (!a.radical && !b.radical) { killT = i / 120; break; }
}
Input.grab = false;
check('two radicals can be terminated', killT !== null, killT ? `in ${killT.toFixed(2)}s` : 'NEVER');
check('termination is FAST enough to be a combat move',
killT !== null && killT < 0.9, `${killT ? killT.toFixed(2) : '-'}s (target ~0.4, must be < 0.9)`);
check('they annihilate rather than bond', !a.isBondedTo(b));
}
// ============================================================
// the barrier multiplier must actually be doing the work
// ============================================================
section('BARRIER — radical pairs are barrierless');
{
const { barrierHump } = await import('../src/physics.js');
const A = { r: 11, radical: false }, B = { r: 11, radical: false };
const lock = (A.r + B.r) * CFG.LOCK_MUL;
const d = lock + CFG.BARRIER_BAND / 2; // peak of the hump
const normal = barrierHump(d, A, B);
const rad = barrierHump(d, { r: 11, radical: true }, { r: 11, radical: true });
check('normal pair hits the full barrier', normal > 0.9, `hump=${normal.toFixed(2)}`);
check('radical pair is ~85% easier', rad < normal * 0.2, `hump=${rad.toFixed(2)}`);
}
// ============================================================
// MOLLY'S LIFE
// ============================================================
section('MOLLY — she can die');
{
const w = makeWorld();
w.load(ROOMS[1]);
w.particles.length = 0;
const r = w.spawn({ x: 400, y: 360, el: 'C' });
r.fixed = true;
Radicals.make(r);
w.molly.x = 400; w.molly.y = 360;
check('starts with 3 electrons', w.molly.electrons === 3, `e=${w.molly.electrons}`);
tick(w);
check('radical contact costs an electron', w.molly.electrons === 2, `e=${w.molly.electrons}`);
check('i-frames engage', w.molly.iframe > 0, `iframe=${w.molly.iframe.toFixed(2)}`);
// i-frames should prevent instant death
for (let i = 0; i < 30; i++) tick(w);
check('i-frames prevent melt-through', w.molly.electrons === 2, `e=${w.molly.electrons}`);
// now run it out — hold her in contact (knockback would otherwise save her)
let deathT = null;
for (let i = 0; i < 1200 && deathT === null; i++) {
w.molly.x = 400; w.molly.y = 360; w.molly.vx = w.molly.vy = 0;
tick(w);
if (w.molly.dead) deathT = i / 120;
}
check('she dies when electrons run out', w.molly.dead, deathT ? `at ${deathT.toFixed(2)}s` : 'survived');
check('death takes long enough to read', deathT === null || deathT > 1.5, `${deathT ? deathT.toFixed(2) : '-'}s`);
}
// ============================================================
// REGRESSION — everything that worked before still works
// ============================================================
section('REGRESSION');
{
let ok = 0;
for (let i = 0; i < ROOMS.length; i++) {
const w = makeWorld();
try { w.load(ROOMS[i]); sim(w, 2); ok++; }
catch (e) { console.log(` room ${i} threw: ${e.message}`); }
}
check('all rooms load + simulate', ok === ROOMS.length, `${ok}/${ROOMS.length}`);
// the wrestle
const w = makeWorld();
w.load(ROOMS[2]);
const a = w.particles[0], b = w.particles[1];
a.x = 620; a.y = 322; a.vx = a.vy = 0;
b.x = 620; b.y = 398; b.vx = b.vy = 0;
w.molly.x = 620; w.molly.y = 360;
aimAt(w, 620, 360);
Input.grab = true;
let t = null;
for (let i = 0; i < 600; i++) { tick(w); if (a.isBondedTo(b)) { t = i / 120; break; } }
Input.grab = false;
check('bond wrestle still snaps', t !== null, t ? `${t.toFixed(2)}s` : 'FAILED');
check('wrestle stayed in its feel window', t !== null && t > 1.0 && t < 2.6, `${t ? t.toFixed(2) : '-'}s`);
check('room 2 exit opens', w.exitOpen);
// the heist
const h = makeWorld();
h.load(ROOMS[5]);
const k = h.find('key');
k.setCharge(0); k.grace = 0; k.zoneTimer = 0;
k.x = 660; k.y = 360; k.vx = 260;
let locked = null;
for (let i = 0; i < 900 && locked === null; i++) { tick(h); if (h.exitOpen) locked = i / 120; }
check('heist still solvable', locked !== null, locked ? `${locked.toFixed(2)}s` : 'FAILED');
// laserhands
const g = makeWorld();
g.load(ROOMS[1]);
g.particles.length = 0;
const target = g.spawn({ x: 900, y: 360, el: 'H' });
target.fixed = true;
g.molly.x = 200; g.molly.y = 360; g.molly.cool = 0;
aimAt(g, 900, 360);
g.bolts.fire(g, 1);
let hit = null;
for (let i = 0; i < 300 && hit === null; i++) { tick(g); if (target.charge === 1) hit = i / 120; }
check('bolt travels and lands on hydrogen', hit !== null, hit ? `${hit.toFixed(2)}s over 700px` : 'MISSED');
check('bolt is not hitscan', hit !== null && hit > 0.3, `${hit ? hit.toFixed(2) : '-'}s`);
}
process.exit(report() ? 0 : 1);