Merge firebreak: saturation stops the fire, and the score is the player's

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-25 23:13:21 +10:00
commit 83ed074175
12 changed files with 869 additions and 143 deletions

View File

@ -19,7 +19,7 @@ No dependencies. No build step. Vanilla JS + Canvas 2D.
| **left click** | proton bolt — pushes charge in (**+**) |
| **right click** | vacuum bolt — rips charge out (****) |
| **space** (hold) | grab two things and squeeze them together |
| **Q** | heat — area shove, breaks bonds |
| **Q** | heat — area shove, breaks every bond in radius (40% go homolytic) |
| **R** | retry |
| **shift+A** | jump straight to CASCADE |
@ -34,6 +34,16 @@ slip through a membrane.
and you're dry — the left hand always works, for free.
- **Bonds can't form on their own.** Every pair has an activation barrier that
parks them at arm's length. Your hands are the only way over it.
- **Saturation is the firebreak.** A radical takes an atom by grabbing an
*open valence slot*. Close every slot on something and the fire can't cross
it. A bonded pair of hydrogens is the cheapest wall in the game; a lone
carbon has four open sockets and is the best kindling. Wall a radical in
with nothing left to take and it goes out on its own in a few seconds —
which is the only way to kill the last one, since terminating needs a pair.
- **Nothing you build is permanent.** A detonation snaps a bond and *both*
halves come away radical. **Q breaks every bond it touches** and 40% of
those splits go homolytic — two new radicals. The button that unsticks a
jam is the button that opens a hole in your own wall.
- **The membrane blocks anything charged and ignores anything neutral.** It has
no guard and no keyhole. You get through by becoming the right kind of thing.
- **Sour air keeps shoving protons onto everything.** Including you.
@ -55,6 +65,9 @@ the fire out and it clears. Let it burn and the field melts down.
Density is the difficulty dial. Everything else follows from it.
The one number on screen is **terminations**, and back-to-back kills stack a
streak that lapses after three quiet seconds. No label, no praise, no record.
## Layout
```
@ -76,6 +89,7 @@ test/*.test.mjs headless sim tests — `node test/sim.test.mjs`
node test/sim.test.mjs core physics, radicals, molly, regression
node test/arena.test.mjs arena waves, win/lose, difficulty ramp
node test/overload.test.mjs fuses, cascade, juice budget, perf
node test/firebreak.test.mjs saturation, homolysis, the score, arena both-ends
```
The sim is seedable (`seedRandom`), so runs are reproducible. Tests drive the
@ -90,10 +104,13 @@ The three knobs that matter, in order:
two numbers.
3. **`SNAP_TRAUMA` / `HITSTOP`** — the payoff.
Two constants have non-obvious constraints, documented where they live:
Some constants have non-obvious constraints, documented where they live:
`BARRIER_FORCE` must stay above `MAX_CHARGE_FORCE` (or atoms bond themselves
and the wrestle is skipped), and `RAD_SPLIT_R` must stay above the field's
natural packing distance (or outbreaks never bloom).
and the wrestle is skipped); `RAD_SPLIT_R` must stay above the field's natural
packing distance (or outbreaks never bloom); and `RAD_STARVE_TIME` /
`RAD_DRIFT_TIME` must stay far apart — a radical walled in by matter you built
should die fast, but one merely adrift in a sparse early arena must not, or
the outbreak evaporates unattended and the meltdown lose state disappears.
## Dev note

51
deploy/deploy.sh Executable file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Deploy Molly Cool to monsterrobot.games/mollycool
#
# The lander lives on the botchat/games VPS and forum-nginx serves it from a
# bind mount of /home/humanjing/monsterrobot.games. That mount is READ-ONLY
# inside the container, so `docker cp` fails silently — always write the HOST
# path. (Same trap as beyondmorp V2.)
#
# monsterrobot.games -> /home/humanjing/monsterrobot.games (nginx root)
# monsterrobot.games/mollycool -> games/mollycool via a root symlink
#
# nginx needs no config change: the server block's `location /` try_files
# resolves the symlink, which is the same convention not-tonight uses.
set -euo pipefail
HOST="humanjing@100.71.119.27"
LANDER="/home/humanjing/monsterrobot.games"
DEST="$LANDER/games/mollycool"
URL="https://monsterrobot.games/mollycool/"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "==> shipping $SRC -> $HOST:$DEST"
# Static game only. serve.py is the dev server, test/ is the headless suite,
# and neither belongs on a public web root.
rsync -az --delete \
--include='index.html' \
--include='src/' --include='src/**' \
--exclude='*' \
"$SRC/" "$HOST:$DEST/"
echo "==> ensuring the /mollycool route"
ssh "$HOST" "ln -sfn games/mollycool '$LANDER/mollycool' && ls -ld '$LANDER/mollycool'"
echo "==> verifying live"
STAMP="$(date +%s)"
code=$(curl -s -o /dev/null -w '%{http_code}' "$URL?cb=$STAMP")
title=$(curl -s "$URL?cb=$STAMP" | grep -o '<title>[^<]*</title>' || true)
# The real test is the module: a broken subpath serves the LANDER's html here
# with content-type text/html, which the browser silently refuses to execute.
mime=$(curl -s -o /dev/null -w '%{content_type}' "${URL}src/main.js?cb=$STAMP")
mcode=$(curl -s -o /dev/null -w '%{http_code}' "${URL}src/main.js?cb=$STAMP")
echo " page $code $title"
echo " module $mcode $mime"
if [ "$code" != "200" ] || [ "$mcode" != "200" ]; then
echo "!! NOT DEPLOYED (non-200)"; exit 1
fi
case "$title" in *"Molly Cool"*) ;; *) echo "!! wrong page served — check the symlink"; exit 1;; esac
case "$mime" in *javascript*) ;; *) echo "!! module served as '$mime', not javascript"; exit 1;; esac
echo "==> OK $URL"

View File

@ -59,11 +59,21 @@
</div>
<script type="module">
// Load the module graph under a fresh /v<timestamp>/ prefix each time.
// DEV: load the module graph under a fresh /v<timestamp>/ prefix each time.
// Browsers cache the *parsed* module by URL, so without this you can edit a
// file, hard-reload, and still be running yesterday's code. serve.py strips
// the prefix back off. Costs nothing in production (drop the prefix).
import(`/v${Date.now()}/src/main.js`);
// the prefix back off.
//
// PROD: that prefix is ABSOLUTE and nothing upstream strips it, so under
// monsterrobot.games/mollycool/ the browser asked for /v<ts>/src/main.js,
// fell through nginx's try_files to the lander's index.html, and got served
// HTML with `content-type: text/html` for a module — which the browser
// refuses to execute. Silent dead title screen, no console error worth the
// name. Ship a RELATIVE specifier instead: it resolves against the document
// URL, so the game works at any subpath, and nginx's ETag/Last-Modified
// revalidation covers the cache-busting the prefix was there to do.
const DEV = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname);
import(DEV ? `/v${Date.now()}/src/main.js` : './src/main.js');
</script>
</body>
</html>

View File

@ -18,17 +18,25 @@ import { Radicals } from './radicals.js';
const FUEL = ['H', 'H', 'C', 'O', 'N', 'C', 'O', 'H'];
const SPICE = ['Na', 'Cl', 'S', 'Mg', 'Cu', 'Fe', 'K'];
// Ne is placed by COUNT, never rolled from SPICE. Zero slots means a radical
// can never take it and it can never be bonded, so every neon is a permanent
// firebreak the player didn't have to build — somewhere to herd a wound.
// That makes it far too strong to leave to chance: drawn randomly, the densest
// arena rolled anywhere from 3 to 9 of them, and at 9 it stopped melting down
// at all. The lose state is not allowed to depend on the shuffle.
// waves = how many ignition events. A wave has a beginning and an end, so
// the arena is a fight you can finish rather than a faucet you stand under.
// ne = fixed neon count: permanent unburnable holes in the fuel map.
export const ARENAS = [
{ n: 34, spice: 0.00, born: 1, every: 8.0, waves: 3, name: 'ignition' },
{ n: 44, spice: 0.08, born: 1, every: 7.0, waves: 4 },
{ n: 54, spice: 0.14, born: 2, every: 6.5, waves: 4 },
{ n: 64, spice: 0.20, born: 2, every: 6.0, waves: 5 },
{ n: 76, spice: 0.26, born: 2, every: 5.5, waves: 5 },
{ n: 88, spice: 0.32, born: 3, every: 5.0, waves: 6 },
{ n: 100, spice: 0.38, born: 3, every: 4.5, waves: 6 },
{ n: 116, spice: 0.44, born: 4, every: 4.0, waves: 7, name: 'critical' },
{ n: 34, spice: 0.00, born: 1, every: 8.0, waves: 3, ne: 0, name: 'ignition' },
{ n: 44, spice: 0.08, born: 1, every: 7.0, waves: 4, ne: 0 },
{ n: 54, spice: 0.14, born: 2, every: 6.5, waves: 4, ne: 1 },
{ n: 64, spice: 0.20, born: 2, every: 6.0, waves: 5, ne: 1 },
{ n: 76, spice: 0.26, born: 2, every: 5.5, waves: 5, ne: 2 },
{ n: 88, spice: 0.32, born: 3, every: 5.0, waves: 6, ne: 2 },
{ n: 100, spice: 0.38, born: 3, every: 4.5, waves: 6, ne: 3 },
{ n: 116, spice: 0.44, born: 4, every: 4.0, waves: 7, ne: 4, name: 'critical' },
];
// You lose by NEGLECT, not by bad luck. The outbreak has to stay above this
@ -75,6 +83,12 @@ export class Arena {
particles.push({ x, y, el });
}
// the neon, spread out on purpose — clustered firebreaks are one big wall,
// scattered ones are several places to drive a wound into
for (let k = 0; k < (s.ne || 0) && k < particles.length; k++) {
particles[Math.floor((k + 0.5) * particles.length / s.ne)].el = 'Ne';
}
return {
id: `arena-${i}`,
ambient: 'neutral',
@ -127,7 +141,9 @@ export class Arena {
this.spawnT += dt;
const live = world.radicals.count;
this.best = Math.max(this.best, world.radicals.chain);
// best STREAK, not the outbreak's hop count — the run's number is the
// player's, everywhere it's tracked.
this.best = Math.max(this.best, world.radicals.best);
// ---- the curve ----
// ignite quickly the first time (nobody wants to stare at a quiet room),

View File

@ -130,8 +130,23 @@ export const CFG = {
RAD_KILL_HITSTOP: 130,
RAD_KILL_TRAUMA: 0.7,
RAD_QUENCH_R: 150, // annihilation puts out everything in this radius
// seconds with NO open valence slot in reach before a radical goes out on
// its own. This is the payoff for walling one in — and the reason a single
// cornered radical can't hold an arena open forever (clearing needs zero
// live radicals, terminating needs a pair).
RAD_STARVE_TIME: 3.8,
// ...and how long one drifting in genuinely empty water lasts. Much longer:
// this one isn't a play the player made, it's just the sparse early arenas,
// and making it as fast as a real cage deletes the meltdown lose state.
RAD_DRIFT_TIME: 11.0,
RAD_BARRIER_MUL: 0.15, // radical-radical is barrierless -> fast wrestle
// ---- the score is YOURS ----
// CHAIN used to count how far the OUTBREAK had spread, which meant the one
// big number on screen only went up while you were losing. It counts
// terminations now, and back-to-back kills build a streak.
STREAK_WINDOW: 3.0, // seconds to land the next kill and keep the run
// ---- molly's life ----
MOLLY_ELECTRONS: 3,
MOLLY_IFRAME: 1.1,
@ -147,6 +162,9 @@ export const CFG = {
HEAT_JITTER_SPIKE: 2.4,
HEAT_TRAUMA: 0.34,
HEAT_COOLDOWN: 0.5,
// Q breaks every bond it catches. This fraction come apart HOMOLYTICALLY —
// two radicals instead of a clean split. Q is the igniter, not a crowbar.
HEAT_HOMOLYTIC: 0.4,
LIGHT_COOLDOWN: 0.35,
// ---- camera / juice ----

View File

@ -10,6 +10,7 @@ export const Input = {
grab: false, // held
heat: false, light: false, // edge-triggered
retry: false,
perf: false, // F — dev frame-time readout
usingGamepad: false,
_mouse: { x: CFG.W / 2, y: CFG.H / 2 },
_keys: new Set(),
@ -24,7 +25,7 @@ const KEYMAP = {
KeyD: 'right', ArrowRight: 'right',
};
let edge = { give: false, take: false, heat: false, light: false, retry: false };
let edge = { give: false, take: false, heat: false, light: false, retry: false, perf: false };
export function initInput(canvas, camera) {
addEventListener('keydown', (e) => {
@ -33,6 +34,7 @@ export function initInput(canvas, camera) {
if (e.code === 'KeyQ') edge.heat = true;
if (e.code === 'KeyE') edge.light = true;
if (e.code === 'KeyR') edge.retry = true;
if (e.code === 'KeyF') edge.perf = true;
if (e.code === 'Space') e.preventDefault();
});
addEventListener('keyup', (e) => Input._keys.delete(e.code));
@ -93,8 +95,8 @@ export function pollInput(camera) {
Input.give = edge.give; Input.take = edge.take;
Input.heat = edge.heat; Input.light = edge.light;
Input.retry = edge.retry;
edge = { give: false, take: false, heat: false, light: false, retry: false };
Input.retry = edge.retry; Input.perf = edge.perf;
edge = { give: false, take: false, heat: false, light: false, retry: false, perf: false };
// aim resolved in molly.js (needs her position for gamepad-relative aim)
Input.mouseWorld = camera.screenToWorld(Input._mouse.x, Input._mouse.y);

View File

@ -85,12 +85,47 @@ function simulate(dt) {
world.update(dt);
}
// ---- perf readout: OFF by default, F toggles ----------------------------
// The zero-text rule is about the game, not about the developer. This never
// draws unless you ask for it, and it reports the frame interval the browser
// actually delivered — measuring it from a headless/backgrounded tab is
// useless because requestAnimationFrame throttles the moment the page isn't
// visible, so the only honest number comes from a real window.
const perf = { on: false, times: [], fps: 0, p95: 0, t: 0 };
function perfSample(dt) {
perf.times.push(dt * 1000);
if (perf.times.length > 120) perf.times.shift();
perf.t += dt;
if (perf.t < 0.25 || perf.times.length < 10) return;
perf.t = 0;
const s = [...perf.times].sort((a, b) => a - b);
perf.fps = 1000 / (s.reduce((a, b) => a + b, 0) / s.length);
perf.p95 = s[Math.floor(s.length * 0.95)];
}
function drawPerf() {
ctx.save();
ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0);
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'left';
const bad = perf.fps < 55 || perf.p95 > 20;
ctx.fillStyle = bad ? 'rgba(255,90,110,0.9)' : 'rgba(127,227,196,0.75)';
const n = world.particles.length;
const r = world.radicals.count;
ctx.fillText(
`${perf.fps.toFixed(0)} fps p95 ${perf.p95.toFixed(1)}ms ${n} atoms ${r} rad`,
12, CFG.H - 12);
ctx.restore();
}
function frame(now) {
requestAnimationFrame(frame);
let dt = (now - last) / 1000;
last = now;
dt = Math.min(dt, CFG.MAX_FRAME);
if (perf.on) perfSample(dt);
if (state === 'title') {
ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0);
@ -101,6 +136,8 @@ function frame(now) {
pollInput(camera);
if (Input.perf) { perf.on = !perf.on; perf.times.length = 0; perf.t = 0; }
if (Input.retry) {
if (state === 'arena' || state === 'arenaClear') loadArena(arenaIndex);
else if (state === 'play') loadRoom(roomIndex);
@ -179,15 +216,11 @@ function frame(now) {
}
}
// hitstop/sim runs for arena states too
if (state === 'arena' || state === 'arenaClear') {
// (already stepped above via the shared sim block)
}
ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0);
render(ctx, world, camera);
if (state === 'end' && world._t > 6.4) drawEndCard();
if (perf.on) drawPerf();
}
function drawEndCard() {

View File

@ -4,6 +4,7 @@ import { FX } from './fx.js';
import { Audio } from './audio.js';
import { Juice } from './juice.js';
import { Radicals } from './radicals.js';
import { unbond } from './particle.js';
// ============================================================
// OVERLOAD.
@ -85,9 +86,18 @@ export class Overload {
q.fuse = CFG.FUSE_TIME * CFG.CHAIN_FUSE_MUL;
q.fuseMax = q.fuse;
}
// a blast rips bonds apart homolytically — free radicals, your problem now
// ---- HOMOLYSIS: the blast rips a bond in half ----
// This is the pressure valve on "saturation is the firebreak". A closed
// molecule is a wall the wound cannot cross — so the wall has to be
// breakable, or one good build wins the arena forever. A blast snaps the
// bond and BOTH fragments come away radical, each with a slot open again.
// Your firebreak is exactly as permanent as your fire discipline.
if (q.bonds.length && f > 0.55) {
const partner = q.bonds[q.bonds.length - 1];
unbond(q, partner);
FX.burst((q.x + partner.x) / 2, (q.y + partner.y) / 2, 10, '#FFFFFF', 320, 0.4);
Radicals.make(q);
Radicals.make(partner);
}
}

View File

@ -8,10 +8,17 @@ import { Juice } from './juice.js';
// One enemy. One boolean. No bestiary, no AI, no pathing, no plan.
//
// An unpaired electron. Every RAD_TICK seconds it grabs the nearest
// atom, completes itself, and hands the wound to its victim.
// atom WITH AN OPEN SLOT, completes itself, and hands the wound on.
// Population is conserved per hop — but the wound MOVES, and in a
// dense pocket it takes two at once and the branch factor crosses 1.
//
// SATURATION IS THE FIREBREAK. A radical abstracts from an open
// valence slot; an atom whose slots are all spoken for has nothing
// to give and the wound cannot jump into it. That one rule is what
// makes the bond wrestle load-bearing in the arena instead of
// decorative: every molecule you close is a wall the fire can't
// cross, and a blast that snaps that molecule open lets it back in.
//
// You cannot shoot it dead. You TERMINATE it: two radicals wrestled
// together annihilate, because radical-radical recombination is
// barrierless. The mechanic you already built is the kill move.
@ -19,21 +26,37 @@ import { Juice } from './juice.js';
// #FFFFFF is reserved for radicals and nothing else, ever.
// ============================================================
// can a radical take this body? the one predicate, used by the hunt,
// by the telegraph thread, and by the renderer's "is this tinder" tell.
// keeping it in one place is what stops the read and the rule drifting apart.
export function isTinder(q) {
return !q.radical && !q.noble && !q.fixed && q.freeSlots > 0;
}
export class Radicals {
constructor() {
this.tick = 0;
this.count = 0;
this.chain = 0; // how many hops this outbreak has made
this.peak = 0;
// ---- the player's number ----
this.kills = 0; // radicals terminated this run
this.streak = 0; // terminations back to back
this.streakT = 0; // time left to keep it alive
this.best = 0; // best streak this run
}
reset() { this.tick = 0; this.count = 0; this.chain = 0; this.peak = 0; }
reset() {
this.tick = 0; this.count = 0; this.chain = 0; this.peak = 0;
this.kills = 0; this.streak = 0; this.streakT = 0; this.best = 0;
}
static make(p) {
if (p.radical || p.noble) return false;
p.radical = true;
p.radTele = 0;
p.radTarget = null;
p.radHunt = null;
p.radStarved = false;
FX.burst(p.x, p.y, 10, '#FFFFFF', 300, 0.4);
FX.ring(p.x, p.y, 2, 40, '#FFFFFF', 0.3, 2);
return true;
@ -42,11 +65,27 @@ export class Radicals {
static clear(p) {
p.radical = false;
p.radTele = 0;
p.radTarget = null;
p.radHunt = null;
p.radStarved = false;
}
all(world) { return world.particles.filter(p => p.radical); }
// a single radical going out — starved, or caught in someone else's quench.
// it scores, because boxing one in is a play you have to set up.
quench(world, p) {
Radicals.clear(p);
p.radStarveT = 0;
FX.burst(p.x, p.y, 14, '#7FE3C4', 260, 0.45);
FX.ring(p.x, p.y, 2, 54, '#7FE3C4', 0.4, 2);
Audio.pop(false);
this.streak = (this.streakT > 0 ? this.streak : 0) + 1;
this.streakT = CFG.STREAK_WINDOW;
this.best = Math.max(this.best, this.streak);
this.kills++;
world.kills = (world.kills || 0) + 1;
}
// radical + radical = annihilation. barrierless, so the wrestle is FAST.
terminate(world, a, b) {
const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
@ -60,20 +99,34 @@ export class Radicals {
FX.ring(cx, cy, 4, CFG.RAD_QUENCH_R, '#FFFFFF', 0.5, 5);
FX.ring(cx, cy, 2, CFG.RAD_QUENCH_R * 0.6, '#7FE3C4', 0.4, 3);
let got = 2;
// the quench ring puts out everything it touches
for (const p of world.particles) {
if (!p.radical) continue;
if (Math.hypot(p.x - cx, p.y - cy) < CFG.RAD_QUENCH_R) {
Radicals.clear(p);
got++;
FX.burst(p.x, p.y, 8, '#7FE3C4', 220, 0.35);
}
}
world.kills = (world.kills || 0) + 2;
// ---- the score is YOURS. it counts what you put out, not what spread. ----
// a kill inside the streak window extends it; let the window lapse and it
// resets. quenching a big knot in one go is worth more than mopping up.
this.streak = (this.streakT > 0 ? this.streak : 0) + got;
this.streakT = CFG.STREAK_WINDOW;
this.best = Math.max(this.best, this.streak);
this.kills += got;
world.kills = (world.kills || 0) + got;
}
update(world, dt) {
const ps = world.particles;
this.tick += dt;
if (this.streakT > 0) {
this.streakT -= dt;
if (this.streakT <= 0) this.streak = 0;
}
let n = 0;
for (const p of ps) if (p.radical) n++;
@ -121,14 +174,48 @@ export class Radicals {
if (live >= CFG.RAD_CAP) break; // live count, not the stale one
// ---- find prey ----
// SATURATION IS THE FIREBREAK: isTinder() rejects anything with no
// open slot, so a closed molecule is a wall the wound cannot cross.
// `walled` counts SATURATED matter in reach — closed molecules and
// nobles, the things the player builds. Other radicals deliberately
// don't count: a knot of radicals packed together is a fire, not a
// cage, and letting it read as one had the outbreak smothering itself
// and quietly deleting the meltdown.
const prey = [];
let walled = 0;
for (const q of ps) {
if (q === r || q.radical || q.noble || q.fixed) continue;
if (q === r) continue;
const d = Math.hypot(q.x - r.x, q.y - r.y);
if (d < CFG.RAD_REACH) prey.push({ q, d });
if (d >= CFG.RAD_REACH) continue;
if (isTinder(q)) prey.push({ q, d });
else if (!q.radical) walled++;
}
if (!prey.length) continue;
if (!prey.length) {
// BOXED IN — matter in reach and every slot of it spoken for. This is
// the payoff for building: wall a radical in and it goes out fast, so
// the bond wrestle is a way to WIN and not just a way to survive.
//
// ISOLATED — nothing in reach at all. That's the sparse early arenas,
// not a play, so it burns down far more slowly. Making both cases fast
// deleted the lose state outright: at 34 atoms over 1280x720 the mean
// spacing is ~130px against a 70px reach, so almost every radical is
// momentarily alone and the whole outbreak evaporated unattended.
//
// Both eventually end, and they have to: clearing needs zero live
// radicals and terminating needs a PAIR, so without a single-radical
// sink one last one holds the room open forever.
r.radStarved = walled > 0;
r.radHunt = null;
r.radStarveT = (r.radStarveT || 0) + CFG.RAD_TICK;
const limit = walled > 0 ? CFG.RAD_STARVE_TIME : CFG.RAD_DRIFT_TIME;
if (r.radStarveT >= limit) this.quench(world, r);
continue;
}
r.radStarved = false;
r.radStarveT = 0;
prey.sort((a, b) => a.d - b.d);
r.radHunt = prey[0].q;
// In a genuinely CROWDED pocket it takes TWO and the branch factor
// crosses 1.0. Requiring three close neighbours (not two) keeps this
@ -175,14 +262,11 @@ export class Radicals {
for (const p of world.particles) {
if (!p.radical) continue;
// the hunt-thread: you can SEE what it's about to take
let best = null, bestD = CFG.RAD_REACH;
for (const q of world.particles) {
if (q === p || q.radical || q.noble || q.fixed) continue;
const d = Math.hypot(q.x - p.x, q.y - p.y);
if (d < bestD) { bestD = d; best = q; }
}
if (best) {
// the hunt-thread: you can SEE what it's about to take.
// the target was chosen on the last tick and cached — re-scanning every
// radical against every atom every frame is an O(n*m) tax for a line.
const best = p.radHunt;
if (best && isTinder(best)) {
const urgency = 1 - (this.tick / CFG.RAD_TICK);
ctx.strokeStyle = `rgba(255,60,60,${0.12 + urgency * 0.4})`;
ctx.lineWidth = 1 + urgency * 1.2;
@ -193,8 +277,27 @@ export class Radicals {
ctx.setLineDash([]);
}
// jagged white corona
const flick = p.radTele > 0 ? 1 : 0.72 + Math.sin(t * 26 + p.id) * 0.28;
// STARVED: nothing left with an open slot in reach. It still kills on
// contact, but it has nowhere to go — and it must LOOK boxed in, or
// walling one off feels like nothing happened.
if (p.radStarved) {
// a closing teal collar. It TIGHTENS as the starve timer runs out, so
// "I boxed that one in and it is dying" is legible from across the
// room without a bar or a number.
const k = Math.min(1, (p.radStarveT || 0) / CFG.RAD_STARVE_TIME);
const pulse = 0.5 + Math.sin(t * 5 + p.id) * 0.5;
ctx.strokeStyle = `rgba(127,227,196,${0.35 + k * 0.45 + pulse * 0.12})`;
ctx.lineWidth = 1.4 + k * 1.6;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.arc(p.x, p.y, p.r + 22 - k * 12, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// jagged white corona — dimmer once it's cornered
const starve = p.radStarved ? 0.55 : 1;
const flick = (p.radTele > 0 ? 1 : 0.72 + Math.sin(t * 26 + p.id) * 0.28) * starve;
const R = p.r + 8;
ctx.beginPath();
const spikes = 11;

View File

@ -91,23 +91,203 @@ function drawBody(ctx, x, y, r, dc, spin, col, fill) {
}
}
// ---- THE FIELD GRID ---------------------------------------------------
// The lattice was a flat sheet of graph paper — decoration, and the thing
// that made the room read as a void with objects in it rather than a charged
// medium. Now every node is displaced by the summed Coulomb field, which the
// sim already computes for free: positives bulge it, negatives dent it, a
// detonation sends a visible ripple across the whole room. Nodes sitting in
// real potential also get a tinted dot, so you can see charge pooling in a
// corner before anything in it has moved.
const FIELD_STEP = 40;
const FIELD_REACH = 230;
const FIELD_K = 9000;
const FIELD_MAX = 15; // px — past this the warp reads as noise
let _fCols = -1, _fRows = -1, _fx = null, _fy = null, _fp = null;
function drawField(ctx, world) {
const B = world.bounds;
const cols = Math.ceil(B.w / FIELD_STEP), rows = Math.ceil(B.h / FIELD_STEP);
const n = (cols + 1) * (rows + 1);
if (cols !== _fCols || rows !== _fRows) {
_fCols = cols; _fRows = rows;
_fx = new Float32Array(n); _fy = new Float32Array(n); _fp = new Float32Array(n);
}
const src = [];
for (const p of world.particles) if (p.charge) src.push(p);
if (world.molly.charge) src.push(world.molly);
const R2 = FIELD_REACH * FIELD_REACH;
for (let j = 0; j <= rows; j++) {
for (let i = 0; i <= cols; i++) {
const gx = B.x + i * FIELD_STEP, gy = B.y + j * FIELD_STEP;
let ex = 0, ey = 0, pot = 0;
for (const p of src) {
const dx = gx - p.x, dy = gy - p.y;
const d2 = dx * dx + dy * dy;
if (d2 > R2) continue;
const d = Math.sqrt(d2) || 1;
const dd = d < CFG.D_MIN ? CFG.D_MIN : d;
const f = (FIELD_K * p.charge) / (dd * dd);
ex += (dx / d) * f; ey += (dy / d) * f;
pot += p.charge * (1 - d / FIELD_REACH);
}
const mag = Math.hypot(ex, ey);
if (mag > FIELD_MAX) { const k = FIELD_MAX / mag; ex *= k; ey *= k; }
const k = j * (cols + 1) + i;
_fx[k] = gx + ex; _fy[k] = gy + ey; _fp[k] = pot;
}
}
// one stroke call for the whole warped lattice
ctx.strokeStyle = CFG.COL_GRID;
ctx.lineWidth = 1;
ctx.beginPath();
for (let j = 0; j <= rows; j++)
for (let i = 0; i <= cols; i++) {
const k = j * (cols + 1) + i;
i === 0 ? ctx.moveTo(_fx[k], _fy[k]) : ctx.lineTo(_fx[k], _fy[k]);
}
for (let i = 0; i <= cols; i++)
for (let j = 0; j <= rows; j++) {
const k = j * (cols + 1) + i;
j === 0 ? ctx.moveTo(_fx[k], _fy[k]) : ctx.lineTo(_fx[k], _fy[k]);
}
ctx.stroke();
// charge pooling, batched into one path per sign
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const sign of [1, -1]) {
ctx.beginPath();
let any = false;
for (let k = 0; k < n; k++) {
const v = _fp[k] * sign;
if (v < 0.25) continue;
const r = Math.min(2.6, 0.7 + v * 0.9);
ctx.moveTo(_fx[k] + r, _fy[k]);
ctx.arc(_fx[k], _fy[k], r, 0, Math.PI * 2);
any = true;
}
if (!any) continue;
ctx.fillStyle = rgba(sign > 0 ? CFG.COL_POS : CFG.COL_NEG, 0.22);
ctx.fill();
}
ctx.restore();
}
// A radical's body: element hue stripped out, so radicals.draw()'s white
// corona and core sit on a dark hole instead of on top of a coloured atom.
const RAD_FILL = '#241F2E';
function drawFlash(ctx, p) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = rgba('#ffffff', p.flash * 0.5);
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + p.flash), 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
// ---- MOLECULES RENDER AS ONE OBJECT ----------------------------------
// Water is a thing, not three circles near each other. Flood-fill the bond
// adjacency, then lay a shared aura under the whole group: fat round-capped
// strokes along every bond plus a disc at every atom, drawn twice at
// decreasing width. Cheap (one pass over bonds), no offscreen buffer, and it
// gives the field the one thing it was missing — silhouettes that differ.
//
// It also has a JOB: a closed molecule is a firebreak, so "which of these
// blobs is solid" has to be answerable at a glance, mid-cascade. A group with
// every slot spoken for gets the calm teal seal; one still holding an open
// socket stays dim and warm, and reads as unfinished work.
function drawMolecules(ctx, world) {
const seen = new Set();
for (const root of world.particles) {
if (seen.has(root) || !root.bonds.length) continue;
// flood-fill this connected group
const group = [];
const stack = [root];
seen.add(root);
while (stack.length) {
const p = stack.pop();
group.push(p);
for (const q of p.bonds) if (!seen.has(q)) { seen.add(q); stack.push(q); }
}
if (group.length < 2) continue;
let open = 0, rad = false;
for (const p of group) { open += p.freeSlots; if (p.radical) rad = true; }
const sealed = open === 0 && !rad;
// sealed molecules are FIREBREAKS and must look like it
const aura = rad ? '#FFFFFF' : sealed ? '#7FE3C4' : '#8A7FB5';
const base = sealed ? 0.16 : 0.09;
// One path per layer, filled ONCE with nonzero winding, so the discs and
// the bond capsules merge into a single silhouette instead of stacking
// alpha at every joint. That means the quads have to wind the same way
// the arcs do (clockwise in screen space) or overlaps punch holes.
ctx.save();
for (const [pad, a] of [[9, base], [4, base * 1.6]]) {
ctx.beginPath();
for (const p of group) {
ctx.moveTo(p.x + p.r + pad, p.y);
ctx.arc(p.x, p.y, p.r + pad, 0, Math.PI * 2);
}
for (const p of group) {
for (const q of p.bonds) {
if (p.id > q.id) continue;
const dx = q.x - p.x, dy = q.y - p.y;
const L = Math.hypot(dx, dy) || 1;
const w = Math.min(p.r, q.r) + pad;
const nx = (-dy / L) * w, ny = (dx / L) * w;
ctx.moveTo(p.x + nx, p.y + ny);
ctx.lineTo(p.x - nx, p.y - ny);
ctx.lineTo(q.x - nx, q.y - ny);
ctx.lineTo(q.x + nx, q.y + ny);
ctx.closePath();
}
}
ctx.fillStyle = rgba(aura, a);
ctx.fill('nonzero');
}
ctx.restore();
}
}
// OPEN SOCKETS: free valence as rings with dark centres, pulsing at 2Hz and
// leaning toward the nearest compatible partner. Carbon with four open sockets
// reads as visibly HUNGRY; completed methane reads as SATISFIED.
// This is valence made legible in peripheral vision, at speed, with no text.
function drawSlots(ctx, p, world, t) {
// Which partner each open-slotted atom leans toward. Computed ONCE per frame
// into a map, not rescanned per atom inside the draw loop — that was a full
// O(n^2) sweep every frame purely to angle some 3px rings, 13k distance checks
// at arena 7 before a single pixel was drawn.
const _lean = new Map();
function buildLeanMap(world) {
_lean.clear();
const ps = world.particles;
const best = new Float64Array(ps.length).fill(120 * 120);
for (let i = 0; i < ps.length; i++) {
const a = ps[i];
if (a.noble || a.freeSlots <= 0) continue;
for (let j = i + 1; j < ps.length; j++) {
const b = ps[j];
if (b.noble || b.freeSlots <= 0 || a.isBondedTo(b)) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy;
if (d2 < best[i]) { best[i] = d2; _lean.set(a, b); }
if (d2 < best[j]) { best[j] = d2; _lean.set(b, a); }
}
}
}
function drawSlots(ctx, p, t) {
const free = p.freeSlots;
if (free <= 0 || p.noble) return;
// find the nearest thing worth leaning toward
let lean = null, bestD = 120;
if (world) {
for (const q of world.particles) {
if (q === p || q.freeSlots <= 0 || q.noble || p.isBondedTo(q)) continue;
const d = Math.hypot(q.x - p.x, q.y - p.y);
if (d < bestD) { bestD = d; lean = q; }
}
}
const lean = _lean.get(p);
const leanA = lean ? Math.atan2(lean.y - p.y, lean.x - p.x) : null;
const pulse = 0.5 + Math.sin(t * 12.6) * 0.5; // 2Hz
const R = p.r + 7;
@ -129,6 +309,90 @@ function drawSlots(ctx, p, world, t) {
}
}
// ---- MOLLY ------------------------------------------------------------
// In a 116-atom field with the room on fire you must find yourself INSTANTLY.
// She gets three things nothing else in the game has, so the eye locks on
// without her having to out-shine thirty radicals:
// 1. a wide warm pool
// 2. a counter-rotating sweep — MOTION reads through clutter that
// brightness does not, and everything else in the field drifts
// 3. a hard dark ring right at her edge. Every glow in this game is
// additive, so the one thing that cannot be washed out by more white
// is a hole punched in it.
function drawMolly(ctx, world, t) {
const m = world.molly;
const col = m.charge === 0 ? CFG.COL_MOLLY : chargeColor(m.displayCharge);
// the hole: drawn straight, NOT additive, so brightness around her can't eat it
ctx.save();
const dg = ctx.createRadialGradient(m.x, m.y, m.r + 4, m.x, m.y, m.r + 30);
dg.addColorStop(0, 'rgba(6,4,14,0.85)');
dg.addColorStop(1, 'rgba(6,4,14,0)');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.arc(m.x, m.y, m.r + 30, 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const R = m.r * 5.2;
const g = ctx.createRadialGradient(m.x, m.y, 1, m.x, m.y, R);
g.addColorStop(0, rgba(CFG.COL_MOLLY, 0.30));
g.addColorStop(0.35, rgba(CFG.COL_MOLLY, 0.10));
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(m.x, m.y, R, 0, Math.PI * 2); ctx.fill();
// two arcs sweeping one way, two the other — motion the eye catches in chaos
ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.55);
ctx.lineWidth = 1.8;
for (const [rr, sp, w] of [[m.r + 20, 1.1, 1.1], [m.r + 27, -0.7, 0.6]]) {
const sweep = t * sp;
ctx.beginPath(); ctx.arc(m.x, m.y, rr, sweep, sweep + w); ctx.stroke();
ctx.beginPath(); ctx.arc(m.x, m.y, rr, sweep + Math.PI, sweep + Math.PI + w); ctx.stroke();
}
ctx.restore();
drawBody(ctx, m.x, m.y, m.r, m.displayCharge, m.spin * 1.6, col, CFG.COL_MOLLY);
// her core mark
ctx.beginPath();
ctx.arc(m.x, m.y, 4.2, 0, Math.PI * 2);
ctx.fillStyle = m.charge === 0 ? CFG.COL_MOLLY : '#ffffff';
ctx.fill();
// ---- her electrons: the only HUD in the game, and it orbits her ----
for (let i = 0; i < CFG.MOLLY_ELECTRONS; i++) {
const a = -t * 1.6 + (i / CFG.MOLLY_ELECTRONS) * Math.PI * 2;
const R2 = m.r + 13;
const x = m.x + Math.cos(a) * R2, y = m.y + Math.sin(a) * R2;
const alive = i < m.electrons;
if (alive) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const g2 = ctx.createRadialGradient(x, y, 0, x, y, 7);
g2.addColorStop(0, rgba('#7FE3C4', 0.95));
g2.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g2;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
ctx.beginPath();
ctx.arc(x, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = alive ? '#BFFFE9' : 'rgba(120,140,155,0.22)';
ctx.fill();
}
// i-frames: she strobes after a hit
if (m.iframe > 0 && Math.floor(t * 22) % 2 === 0) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = rgba('#FF3355', 0.3);
ctx.beginPath(); ctx.arc(m.x, m.y, m.r * 1.7, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
function drawMembrane(ctx, m, t) {
const dx = m.b.x - m.a.x, dy = m.b.y - m.a.y;
const L = Math.hypot(dx, dy) || 1;
@ -174,16 +438,8 @@ export function render(ctx, world, camera) {
ctx.save();
camera.apply(ctx);
// ---- the ground is a MEDIUM, not a void: graph lattice ----
{
const B = world.bounds, S = CFG.GRID_STEP;
ctx.strokeStyle = CFG.COL_GRID;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = B.x; x <= B.x + B.w; x += S) { ctx.moveTo(x, B.y); ctx.lineTo(x, B.y + B.h); }
for (let y = B.y; y <= B.y + B.h; y += S) { ctx.moveTo(B.x, y); ctx.lineTo(B.x + B.w, y); }
ctx.stroke();
}
// ---- the ground is a MEDIUM, not a void: the field grid ----
drawField(ctx, world);
// ---- zone washes ----
for (const z of world.zones) {
@ -271,11 +527,25 @@ export function render(ctx, world, camera) {
ctx.restore();
}
// ---- molecule hulls: a bonded group is ONE object ----
drawMolecules(ctx, world);
buildLeanMap(world);
// ---- particles ----
for (const p of world.particles) {
// A RADICAL IS NOT AN ELEMENT ANY MORE. It keeps its size and its place
// in the field, but it loses its hue and its charge rim — otherwise the
// one enemy in the game arrives in twelve colours and at 30 of them on
// screen you cannot tell threat from fuel. Element owns the fill, except
// here: #FFFFFF is reserved for radicals, and that cuts both ways.
if (p.radical) {
drawBody(ctx, p.x, p.y, p.r, 0, p.spin, 'rgba(255,255,255,0.85)', RAD_FILL);
if (p.flash > 0.01) drawFlash(ctx, p);
continue;
}
const col = chargeColor(p.displayCharge);
drawBody(ctx, p.x, p.y, p.r, p.displayCharge, p.spin, col, p.col);
drawSlots(ctx, p, world, t);
drawSlots(ctx, p, t);
if (p.flash > 0.01) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
@ -285,75 +555,6 @@ export function render(ctx, world, camera) {
}
}
// ---- molly ----
// In a 116-atom field you must find yourself INSTANTLY. She gets a wide
// warm pool nothing else has, plus a slow sweep ring, so the eye locks on
// her without her having to be the brightest thing on screen.
{
const col = m.charge === 0 ? CFG.COL_MOLLY : chargeColor(m.displayCharge);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const R = m.r * 5.2;
const g = ctx.createRadialGradient(m.x, m.y, 1, m.x, m.y, R);
g.addColorStop(0, rgba(CFG.COL_MOLLY, 0.30));
g.addColorStop(0.35, rgba(CFG.COL_MOLLY, 0.10));
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(m.x, m.y, R, 0, Math.PI * 2); ctx.fill();
// a slow sweeping arc — motion the eye catches even in chaos
const sweep = t * 1.1;
ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.5);
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(m.x, m.y, m.r + 20, sweep, sweep + 1.1);
ctx.stroke();
ctx.beginPath();
ctx.arc(m.x, m.y, m.r + 20, sweep + Math.PI, sweep + Math.PI + 1.1);
ctx.stroke();
ctx.restore();
drawBody(ctx, m.x, m.y, m.r, m.displayCharge, m.spin * 1.6, col, CFG.COL_MOLLY);
// her core mark
ctx.beginPath();
ctx.arc(m.x, m.y, 4.2, 0, Math.PI * 2);
ctx.fillStyle = m.charge === 0 ? CFG.COL_MOLLY : '#ffffff';
ctx.fill();
// ---- her electrons: the only HUD in the game, and it orbits her ----
for (let i = 0; i < CFG.MOLLY_ELECTRONS; i++) {
const a = -t * 1.6 + (i / CFG.MOLLY_ELECTRONS) * Math.PI * 2;
const R = m.r + 13;
const x = m.x + Math.cos(a) * R, y = m.y + Math.sin(a) * R;
const alive = i < m.electrons;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
if (alive) {
const g = ctx.createRadialGradient(x, y, 0, x, y, 7);
g.addColorStop(0, rgba('#7FE3C4', 0.95));
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
ctx.beginPath();
ctx.arc(x, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = alive ? '#BFFFE9' : 'rgba(120,140,155,0.22)';
ctx.fill();
}
// i-frames: she strobes after a hit
if (m.iframe > 0 && Math.floor(t * 22) % 2 === 0) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = rgba('#FF3355', 0.3);
ctx.beginPath(); ctx.arc(m.x, m.y, m.r * 1.7, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
// ---- reticle ----
{
const a = m.aim;
@ -376,6 +577,13 @@ export function render(ctx, world, camera) {
world.overload.draw(ctx, world);
world.radicals.draw(ctx, world);
// MOLLY DRAWS LAST, ON TOP OF THE FIRE. She used to be painted before the
// radicals, so at 30 of them her warm pool was buried under thirty additive
// white glows and you genuinely could not find yourself. Nothing outranks
// knowing where you are.
drawMolly(ctx, world, t);
world.bolts.draw(ctx, world);
FX.draw(ctx);
@ -401,17 +609,28 @@ export function render(ctx, world, camera) {
ctx.restore();
}
// chain length, big and quiet, top centre. the only number in the game.
const chain = world.radicals.chain;
if (chain > 0) {
// ---- the only number in the game, and it is YOURS ----
// It used to show the outbreak's hop count, so the one big readout on
// screen went UP while you were losing and sat still while you were
// winning. It counts terminations now. Back-to-back kills stack a streak
// that leans on the number and fades when the window lapses — the arcade
// hook, with no label and no praise.
const R = world.radicals;
if (R.kills > 0) {
const hot = R.streakT > 0 ? R.streakT / CFG.STREAK_WINDOW : 0;
ctx.save();
ctx.textAlign = 'center';
ctx.font = '700 34px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.fillStyle = rgba('#FFFFFF', 0.10 + heat * 0.25);
ctx.fillText(String(chain), CFG.W / 2, 56);
ctx.font = '10px ui-monospace, Menlo, monospace';
ctx.fillStyle = rgba('#FFFFFF', 0.16);
ctx.fillText('CHAIN', CFG.W / 2, 72);
ctx.fillStyle = rgba('#FFFFFF', 0.14 + hot * 0.5 + heat * 0.15);
ctx.fillText(String(R.kills), CFG.W / 2, 56);
if (R.streak > 1 && hot > 0) {
ctx.font = '700 13px ui-monospace, Menlo, monospace';
ctx.fillStyle = rgba('#7FE3C4', 0.35 + hot * 0.55);
ctx.fillText(`×${R.streak}`, CFG.W / 2 + 42, 52);
// the window draining, as a bar under the number. no digits.
ctx.fillStyle = rgba('#7FE3C4', 0.20 + hot * 0.4);
ctx.fillRect(CFG.W / 2 - 26 * hot, 64, 52 * hot, 2);
}
ctx.restore();
}

View File

@ -1,12 +1,12 @@
import { CFG } from './config.js';
import { pointInRect } from './util.js';
import { pointInRect, random } from './util.js';
import { Molly } from './molly.js';
import { BondsSystem } from './bonds.js';
import { Bolts } from './bolts.js';
import { Juice } from './juice.js';
import { Radicals } from './radicals.js';
import { Overload } from './overload.js';
import { Particle } from './particle.js';
import { Particle, unbond } from './particle.js';
import { FX } from './fx.js';
import { Audio } from './audio.js';
import { Input } from './input.js';
@ -145,6 +145,27 @@ export class World {
p.jitterMul = CFG.HEAT_JITTER_SPIKE;
p._jitterDecay = 0.9;
}
// ---- Q IS THE IGNITER, NOT A CROWBAR ----
// Heat snaps bonds in the blast, and HEAT_HOMOLYTIC of them come apart
// into two radicals instead of separating cleanly. There is no free
// action in this game: the button that unsticks a jam is also the button
// that opens a hole in the wall you spent ten seconds building.
const near = this.particles.filter(p =>
Math.hypot(p.x - m.aim.x, p.y - m.aim.y) < CFG.HEAT_RADIUS);
for (const p of near) {
for (const q of [...p.bonds]) {
if (p.id > q.id) continue; // handle each bond once
unbond(p, q);
const cx = (p.x + q.x) / 2, cy = (p.y + q.y) / 2;
if (random() < CFG.HEAT_HOMOLYTIC) {
Radicals.make(p); Radicals.make(q);
FX.burst(cx, cy, 12, '#FFFFFF', 340, 0.45);
} else {
FX.burst(cx, cy, 6, CFG.COL_NEU, 200, 0.3);
}
}
}
}
// LIGHT — the scalpel. one target, flips its mood.

226
test/firebreak.test.mjs Normal file
View File

@ -0,0 +1,226 @@
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);