mollycool/test/firebreak.test.mjs
m3ultra 77615b23bd Saturation is the firebreak: make bonding matter
The arena ran ten seconds at 116 atoms and formed exactly ZERO bonds.
Valence, the twelve elements, the open sockets and the bond wrestle —
the best idea in the codebase — were all decorative, because building
something had no effect on anything.

A radical now abstracts from an OPEN VALENCE SLOT, so an atom with every
slot spoken for is a wall the wound cannot cross. Bonded H2 is the
cheapest firebreak in the game; a lone carbon with four open sockets is
the best kindling. Three things had to follow:

- The wall must be breakable or one good build wins forever. A blast
  snaps a bond and both halves come away radical; Q breaks every bond in
  radius with 40% going homolytic, so it is finally the igniter the
  design doc describes rather than a free crowbar.
- Walling a radical in kills it in 3.8s and credits you. That is the
  payoff for building, and the only way to kill the LAST radical, since
  clearing needs zero live and terminating needs a pair.
- Two starve timers, far apart. Starving anything with no prey deleted
  the meltdown outright: at 34 atoms over 1280x720 the mean spacing is
  ~130px against a 70px reach, so nearly every radical is momentarily
  alone. Boxed-in is 3.8s, merely adrift is 11s.

Legibility. Radicals wore element colour — the one enemy arrived in
twelve of them and at 30 on screen you could not tell threat from fuel.
They now draw as a dark hole with a white rim. Molly drew BEFORE the
radicals and was buried under thirty additive white glows; she draws
last, over the fire, with a hard dark ring that more white cannot wash
out. Bonded groups render as one silhouette, teal when sealed.

The field grid. The flat lattice is now displaced by the Coulomb field
the sim already computes. 0.41ms/frame at worst case.

The score is the player's. CHAIN counted radical hops, so the one big
number on screen went up only while you were losing. It counts
terminations now, with a streak that stacks and lapses.

Neon was defined but never spawned. Now that saturation stops fire, a
zero-slot atom is a free firebreak — drawn from the spice table the
densest arena rolled 3 to 9 of them, and at 9 it stopped melting down at
all. Fixed per-arena counts; all 8 seeds now melt in 17-32s.

Also: prod-safe module loading (the absolute /v<ts>/ prefix fell through
nginx to the lander's index.html and was served as text/html, a silent
dead title screen), an opt-in F frame-time readout, a deploy script, and
removal of a full O(n^2) scan that ran every frame to angle some 3px
rings.

29 new tests in test/firebreak.test.mjs; 68 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:13:08 +10:00

227 lines
9.0 KiB
JavaScript

import {
makeWorld, sim, tick, check, section, report,
CFG, Radicals, Input,
} from './harness.mjs';
const { bond } = await import('../src/particle.js');
const { isTinder } = await import('../src/radicals.js');
const { Arena } = await import('../src/arena.js');
// ============================================================
// SATURATION IS THE FIREBREAK.
//
// This is the rule that makes the bond wrestle load-bearing in the arena
// instead of decorative. Before it, a 116-atom arena ran for ten seconds
// and formed exactly ZERO bonds, because building something had no effect
// on anything. Every check in this file is guarding that.
// ============================================================
section('FIREBREAK — a closed molecule stops the wound');
{
const w = makeWorld(5);
w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] });
// two hydrogens bonded to each other: 1 slot apiece, both spent.
const a = w.spawn({ x: 600, y: 360, el: 'H' });
const b = w.spawn({ x: 613, y: 360, el: 'H' });
bond(a, b);
check('bonded H2 has no open slot', a.freeSlots === 0 && b.freeSlots === 0);
check('a closed molecule is not tinder', !isTinder(a) && !isTinder(b));
// a lone carbon has four
const c = w.spawn({ x: 900, y: 360, el: 'C' });
check('an unbonded carbon is tinder', isTinder(c), `${c.freeSlots} open slots`);
check('a noble is never tinder', !isTinder(w.spawn({ x: 300, y: 360, el: 'Ne' })));
}
section('FIREBREAK — the wound cannot cross it');
{
// A radical parked next to a sealed molecule and NOTHING else. If saturation
// did not block abstraction, the H2 would be taken within one 0.32s tick.
const w = makeWorld(6);
w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] });
const a = w.spawn({ x: 600, y: 300, el: 'H' });
const b = w.spawn({ x: 613, y: 300, el: 'H' });
bond(a, b);
const seed = w.spawn({ x: 645, y: 300, el: 'O' });
Radicals.make(seed);
sim(w, 2.5);
check('a sealed molecule is never taken', !a.radical && !b.radical);
check('the outbreak made no hops at all', w.radicals.chain === 0,
`hops=${w.radicals.chain}`);
// ...and the same radical next to OPEN matter takes it immediately
const w2 = makeWorld(6);
w2.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] });
const open = w2.spawn({ x: 600, y: 300, el: 'H' });
const seed2 = w2.spawn({ x: 645, y: 300, el: 'O' });
Radicals.make(seed2);
sim(w2, 1.0);
check('open matter IS taken, so the wall is the cause', open.radical || w2.radicals.chain > 0,
`hops=${w2.radicals.chain}`);
}
section('FIREBREAK — walling one in kills it, and it scores');
{
// ringed by sealed H2 pairs: matter in reach, none of it available.
const w = makeWorld(8);
w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] });
const seed = w.spawn({ x: 640, y: 360, el: 'O' });
for (let i = 0; i < 6; i++) {
const ang = (i / 6) * Math.PI * 2;
const x = 640 + Math.cos(ang) * 52, y = 360 + Math.sin(ang) * 52;
const p = w.spawn({ x, y, el: 'H' });
const q = w.spawn({ x: x + 12, y, el: 'H' });
bond(p, q);
}
Radicals.make(seed);
tick(w);
check('it starts alive', w.radicals.count === 1);
let died = null;
for (let i = 0; i < 120 * 10 && died === null; i++) {
tick(w);
if (w.radicals.count === 0) died = i / 120;
}
check('a walled-in radical starves out', died !== null,
died ? `at ${died.toFixed(1)}s` : 'never');
check('starving is FASTER than drifting in open water',
died !== null && died < CFG.RAD_DRIFT_TIME,
`${died?.toFixed(1)}s vs ${CFG.RAD_DRIFT_TIME}s adrift`);
check('the player is credited for the cage', w.radicals.kills === 1,
`kills=${w.radicals.kills}`);
}
section('FIREBREAK — the wall is breakable');
{
// ...or one good build would win the arena forever. A blast has to be able
// to snap a bond and let the fire back through.
const w = makeWorld(9);
w.load({ id: 'lab', molly: { x: 60, y: 60 }, particles: [] });
const a = w.spawn({ x: 640, y: 360, el: 'H' });
const b = w.spawn({ x: 653, y: 360, el: 'H' });
bond(a, b);
const bomb = w.spawn({ x: 660, y: 360, el: 'C' });
w.overload.detonate(w, bomb);
check('the blast snapped the bond', a.bonds.length === 0 && b.bonds.length === 0);
check('both fragments came away radical (homolysis)', a.radical && b.radical);
check('and they are tinder again', a.freeSlots > 0 && b.freeSlots > 0);
}
section('FIREBREAK — Q is the igniter, not a crowbar');
{
const w = makeWorld(2);
w.load({ id: 'lab', molly: { x: 640, y: 360 }, particles: [] });
let broken = 0, madeRadicals = 0;
// 40% homolytic is a coin-flip per bond, so this samples it
for (let trial = 0; trial < 40; trial++) {
w.particles.length = 0;
w.radicals.reset();
const a = w.spawn({ x: 640, y: 340, el: 'H' });
const b = w.spawn({ x: 653, y: 340, el: 'H' });
bond(a, b);
w.molly.heatCool = 0;
w.molly.aim.x = 640; w.molly.aim.y = 340;
Input.heat = true;
w.handleVerbs();
Input.heat = false;
if (a.bonds.length === 0) broken++;
if (a.radical || b.radical) madeRadicals++;
}
check('heat breaks every bond it catches', broken === 40, `${broken}/40`);
check('some of those splits go homolytic', madeRadicals > 0, `${madeRadicals}/40 lit a fire`);
check('but not all of them — it stays a gamble', madeRadicals < 40, `${madeRadicals}/40`);
}
section('SCORE — the big number belongs to the player');
{
const w = makeWorld(4);
w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] });
const a = w.spawn({ x: 600, y: 300, el: 'O' });
const b = w.spawn({ x: 640, y: 300, el: 'O' });
Radicals.make(a); Radicals.make(b);
check('score starts at zero', w.radicals.kills === 0);
w.radicals.terminate(w, a, b);
check('a termination scores', w.radicals.kills === 2, `kills=${w.radicals.kills}`);
check('and opens a streak window', w.radicals.streakT > 0 && w.radicals.streak === 2,
`streak=${w.radicals.streak}`);
// land another inside the window and it stacks
const c = w.spawn({ x: 200, y: 600, el: 'O' });
const d = w.spawn({ x: 240, y: 600, el: 'O' });
Radicals.make(c); Radicals.make(d);
w.radicals.terminate(w, c, d);
check('back-to-back kills stack the streak', w.radicals.streak === 4,
`streak=${w.radicals.streak}`);
// let the window lapse
sim(w, CFG.STREAK_WINDOW + 0.5);
check('the streak lapses on its own', w.radicals.streak === 0);
check('but the score does not', w.radicals.kills === 4, `kills=${w.radicals.kills}`);
}
section('NEON — a free firebreak, so it cannot be left to the shuffle');
{
const { ARENAS } = await import('../src/arena.js');
// rolled from the spice table, the densest arena drew anywhere from 3 to 9
// neon, and on a 9 roll it stopped melting down at all. Fixed counts now.
const counts = [1, 2, 3, 4, 5].map(seed => {
makeWorld(seed); // reseeds the shared RNG
const a = new Arena();
return a.room(7).particles.filter(p => p.el === 'Ne').length;
});
check('neon count does not depend on the seed',
new Set(counts).size === 1, `saw ${counts.join(',')}`);
check('and it matches the arena dial', counts[0] === ARENAS[7].ne,
`${counts[0]} vs spec ${ARENAS[7].ne}`);
makeWorld(1);
const early = new Arena().room(0).particles.filter(p => p.el === 'Ne').length;
check('the first arena hands out no free walls', early === 0, `ne=${early}`);
// the lose state must survive every shuffle, not most of them
const melts = [1, 2, 3, 5, 8, 11].map(seed => {
const w = makeWorld(seed); const a = new Arena();
w.load(a.room(7)); a.enter(w, 7);
for (let i = 0; i < 120 * 75; i++) { tick(w); if (a.meltdown) return i / 120; }
return null;
});
check('the densest arena melts down on EVERY seed', melts.every(m => m !== null),
melts.map(m => (m ? m.toFixed(0) + 's' : 'NEVER')).join(' '));
}
section('REGRESSION — the arena still has both ends');
{
// building must not make the arena unlosable, and the new sinks must not
// make it unwinnable. Both directions, on the real arena definitions.
const w = makeWorld(3);
const a = new Arena();
w.load(a.room(7)); a.enter(w, 7);
let melted = null;
for (let i = 0; i < 120 * 60 && melted === null; i++) { tick(w); if (a.meltdown) melted = i / 120; }
check('neglecting the densest arena still melts it down', melted !== null,
melted ? `at ${melted.toFixed(1)}s` : 'never');
// a competent player terminating pairs clears the first arena
const w2 = makeWorld(11);
const a2 = new Arena();
w2.load(a2.room(0)); a2.enter(w2, 0);
let cleared = null;
for (let i = 0; i < 120 * 120 && cleared === null; i++) {
tick(w2);
if (i % 90 === 0) {
const rs = w2.particles.filter(p => p.radical);
if (rs.length >= 2) w2.radicals.terminate(w2, rs[0], rs[1]);
else if (rs.length === 1) w2.radicals.quench(w2, rs[0]);
}
if (a2.cleared) cleared = i / 120;
}
check('and a player who keeps up still clears arena 0', cleared !== null,
cleared ? `at ${cleared.toFixed(1)}s` : 'never');
}
process.exit(report() ? 0 : 1);