diff --git a/README.md b/README.md index aeb340b..cb95f94 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..47f0247 --- /dev/null +++ b/deploy/deploy.sh @@ -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 '[^<]*' || 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" diff --git a/index.html b/index.html index 17c7129..82210e9 100644 --- a/index.html +++ b/index.html @@ -59,11 +59,21 @@ diff --git a/src/arena.js b/src/arena.js index 92dabc3..8cdcb04 100644 --- a/src/arena.js +++ b/src/arena.js @@ -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), diff --git a/src/config.js b/src/config.js index ac1535c..f1564a7 100644 --- a/src/config.js +++ b/src/config.js @@ -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 ---- diff --git a/src/input.js b/src/input.js index f2c9a0d..b5120b6 100644 --- a/src/input.js +++ b/src/input.js @@ -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); diff --git a/src/main.js b/src/main.js index ee18c98..1aac1f4 100644 --- a/src/main.js +++ b/src/main.js @@ -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() { diff --git a/src/overload.js b/src/overload.js index b6863c8..0f90482 100644 --- a/src/overload.js +++ b/src/overload.js @@ -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); } } diff --git a/src/radicals.js b/src/radicals.js index 8ec1bd9..fc1dcab 100644 --- a/src/radicals.js +++ b/src/radicals.js @@ -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; diff --git a/src/render.js b/src/render.js index 1b13e48..0d70b8f 100644 --- a/src/render.js +++ b/src/render.js @@ -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(); } diff --git a/src/world.js b/src/world.js index 9b4f36e..60390aa 100644 --- a/src/world.js +++ b/src/world.js @@ -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. diff --git a/test/firebreak.test.mjs b/test/firebreak.test.mjs new file mode 100644 index 0000000..f13df29 --- /dev/null +++ b/test/firebreak.test.mjs @@ -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);