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

85 lines
3.1 KiB
JavaScript

import { makeWorld, tick, sim, check, section, report, CFG, Input } from './harness.mjs';
const { Arena, ARENAS } = await import('../src/arena.js');
section('CASCADE — the arena');
{
const w = makeWorld(99);
const arena = new Arena();
const def = arena.room(0);
check('arena builds a field', def.particles.length > 20, `${def.particles.length} atoms`);
// minimum separation respected (field must not explode on frame 1)
let tooClose = 0;
for (let i = 0; i < def.particles.length; i++)
for (let j = i + 1; j < def.particles.length; j++) {
const a = def.particles[i], b = def.particles[j];
if (Math.hypot(a.x - b.x, a.y - b.y) < 50) tooClose++;
}
check('field starts at natural packing', tooClose === 0, `${tooClose} overlaps`);
w.load(def);
arena.enter(w, 0);
sim(w, 3);
check('arena ignites quickly (no dead air)', w.radicals.count > 0, `n=${w.radicals.count} at 3s`);
// survivability: does an unattended arena reach a plateau, not a wipe?
const trace = [];
for (let s = 0; s < 8; s++) { sim(w, 5); trace.push(`${(s+1)*5}s:${w.radicals.count}`); }
console.log(' unattended 40s -> ' + trace.join(' '));
check('unattended arena does not fill the screen',
w.radicals.count < 60, `n=${w.radicals.count}`);
check('waves are finite (a fight you can finish)', arena.wave <= arena.spec().waves,
`${arena.wave}/${arena.spec().waves} waves`);
}
section('CASCADE — difficulty actually ramps');
{
const counts = ARENAS.map((s, i) => {
const w = makeWorld(7);
const a = new Arena();
const def = a.room(i);
w.load(def); a.enter(w, i);
sim(w, 20);
return { i, atoms: def.particles.length, n: w.radicals.count };
});
console.log(' ' + counts.map(c => `a${c.i}:${c.atoms}atoms/${c.n}rad`).join(' '));
check('later arenas are denser', counts[7].atoms > counts[0].atoms,
`${counts[0].atoms} -> ${counts[7].atoms}`);
check('later arenas are hotter at 20s', counts[7].n >= counts[0].n,
`${counts[0].n} -> ${counts[7].n}`);
}
section('CASCADE — win and lose conditions');
{
// LOSE: a hyper-dense arena left unattended must melt down
const w = makeWorld(3);
const a = new Arena();
const def = a.room(7); // the densest
w.load(def); a.enter(w, 7);
let melted = null;
for (let i = 0; i < 120 * 90 && melted === null; i++) { tick(w); if (a.meltdown) melted = i / 120; }
check('unattended late arena MELTS DOWN (a real lose state)', melted !== null,
melted ? `at ${melted.toFixed(1)}s` : 'never melted');
// WIN: quench everything by hand and the arena clears
const w2 = makeWorld(11);
const a2 = new Arena();
const d2 = a2.room(0);
w2.load(d2); a2.enter(w2, 0);
let cleared = null;
for (let i = 0; i < 120 * 120 && cleared === null; i++) {
tick(w2);
// stand-in for a competent player: quench one radical per half second
if (i % 60 === 0) {
const r = w2.particles.find(p => p.radical);
if (r) { r.radical = false; }
}
if (a2.cleared) cleared = i / 120;
}
check('arena CLEARS when the player keeps up', cleared !== null,
cleared ? `at ${cleared.toFixed(1)}s` : 'never cleared');
}
process.exit(report() ? 0 : 1);