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);