diff --git a/MAP.md b/MAP.md
index b81a51d..a88b89c 100644
--- a/MAP.md
+++ b/MAP.md
@@ -11,7 +11,7 @@ Vanilla-JS canvas game, no build step. ES modules under `js/`, served statically
- `js/characters.js` — `CHARS` data (stats + visual params) and `updateCharacter()`: per-dog special movement (air dash / charge+pound / grapple constraint / blink / jetpack).
- `js/sim.js` — **authoritative, DOM-free sim**: `createGame`, `step(game, inputs)` (60 Hz), combat (`hurt`: damage% → knockback), mining/items/hazards/KO/respawn/match-end, `snapshot`/`applySnapshot` for netplay.
- `js/input.js` — keyboard → bitmasks; slot 0 = WASD+FGH, slot 1 = arrows+KL;.
-- `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P.
+- `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P. Runs a 2s heartbeat + 8s watchdog (WebRTC close events are unreliable); broker `network` errors are ignored once the data channel is up.
- `js/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners.
- `js/audio.js` — WebAudio-synthesized sfx; `playEvents` maps sim events → sounds.
- `js/main.js` — app state machine: screens, char select, three match drivers (local / host / client) sharing one rAF fixed-timestep loop.
diff --git a/index.html b/index.html
index e430322..a7291f1 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
BARKOUR — dogfights × parkour
+
diff --git a/js/characters.js b/js/characters.js
index 72b0162..74c6690 100644
--- a/js/characters.js
+++ b/js/characters.js
@@ -114,9 +114,11 @@ export function updateCharacter(p, c, inp, pressed, game, H) {
const dx = p.x - st.anchor.x, dy = p.y - st.anchor.y;
const d = Math.hypot(dx, dy) || 1;
if (d > st.len) {
- p.x = st.anchor.x + dx / d * st.len;
- p.y = st.anchor.y + dy / d * st.len;
const nx = dx / d, ny = dy / d;
+ const cx = st.anchor.x + nx * st.len, cy = st.anchor.y + ny * st.len;
+ // the radial projection ignores terrain between here and the taut-rope
+ // position — committing it blindly drags the box through ledge lips
+ if (H.boxFree(game.map, cx, cy, p.w, p.h)) { p.x = cx; p.y = cy; }
const vr = p.vx * nx + p.vy * ny; // strip outward radial velocity → pendulum
if (vr > 0) { p.vx -= vr * nx; p.vy -= vr * ny; }
}
diff --git a/js/input.js b/js/input.js
index a0a0c2f..3ba66b9 100644
--- a/js/input.js
+++ b/js/input.js
@@ -14,6 +14,7 @@ const PREVENT = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Spa
export function initInput() {
window.addEventListener('keydown', (e) => {
+ if (e.target && e.target.tagName === 'INPUT') return; // leave text fields alone
if (PREVENT.has(e.code)) e.preventDefault();
if (down.has(e.code)) return;
down.add(e.code);
diff --git a/js/main.js b/js/main.js
index 1c1c657..230259e 100644
--- a/js/main.js
+++ b/js/main.js
@@ -49,8 +49,10 @@ window.addEventListener('DOMContentLoaded', () => {
startLoop();
});
+window.addEventListener('pointerdown', () => initAudio()); // any gesture unlocks audio
window.addEventListener('keydown', (e) => {
initAudio(); // browsers require a gesture before audio
+ if (e.target && e.target.tagName === 'INPUT') return; // don't eat typing in the room-key field
if (e.code === 'KeyM') {
const m = toggleMute();
$('mute').textContent = m ? '🔇' : '🔊';
@@ -204,6 +206,7 @@ function tryStart() {
// ---- networking ----
function startHost() {
+ if (net) return; // a connection attempt is already in flight
if (!peerAvailable()) { setStatus($('menu-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
mode = 'host';
myPick = null; remotePick = null; peerJoined = false;
@@ -226,6 +229,7 @@ function startHost() {
}
function doJoin() {
+ if (net) return; // a connection attempt is already in flight
if (!peerAvailable()) { setStatus($('join-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
const key = $('join-key').value.toUpperCase().trim();
if (key.length < 4) { setStatus($('join-status'), 'Enter the 5-letter room key.', true); return; }
@@ -267,7 +271,7 @@ function onNetData(msg) {
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
break;
case 'tochars':
- if (mode === 'client') { myPick = null; remotePick = null; show('s-chars'); refreshCharUI(); }
+ if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
break;
case 'bye':
netClosed('Other player left.');
@@ -302,6 +306,7 @@ function doRematch() {
function doChangeDogs() {
sfx('ui');
+ resultShown = false; // lets the host tick() guard idle the finished match
if (mode === 'local') {
picks = [null, null];
show('s-chars'); refreshCharUI();
diff --git a/js/mapgen.js b/js/mapgen.js
index a427c77..96966b1 100644
--- a/js/mapgen.js
+++ b/js/mapgen.js
@@ -38,16 +38,26 @@ export function generateMap(seed) {
}
// --- spawn columns (picked early so hazards can avoid them) ---
+ // The player box is 18px wide (~1.5 tiles), so a valid spawn column needs
+ // its neighbours' ground no higher than its own, or the dog spawns embedded.
const spawnCols = [];
+ const colOk = (x) =>
+ x >= 1 && x < GW - 1 && !spawnCols.includes(x) &&
+ hs[x] <= GH - 2 && hs[x - 1] <= GH - 2 && hs[x + 1] <= GH - 2;
const wanted = [0.12, 0.38, 0.62, 0.88].map(f => Math.floor(GW * f) + r.int(-3, 3));
for (let sx of wanted) {
let x = Math.max(2, Math.min(GW - 3, sx));
let tries = 0;
- while (hs[x] > GH - 2 && tries++ < 20) x = (x + 1) % (GW - 4) + 2; // skip pits
- if (hs[x] <= GH - 2) spawnCols.push(x);
+ while (!colOk(x) && tries++ < 90) x = (x + 1) % (GW - 4) + 2; // skip pits/steps
+ if (colOk(x)) spawnCols.push(x);
}
if (spawnCols.length < 2) spawnCols.push(4, GW - 5); // paranoid fallback
const nearSpawn = (x) => spawnCols.some(sx => Math.abs(sx - x) < 5);
+ // pixel spawn points sit above the highest ground of the 3-column footprint
+ const spawns = spawnCols.map(x => {
+ const surf = Math.min(hs[x - 1] ?? GH, hs[x] <= GH ? hs[x] : GH - 4, hs[x + 1] ?? GH);
+ return { x: x * TILE + TILE / 2, y: surf * TILE - 10 };
+ });
// --- wall-jump towers rising from the ground ---
const nTowers = r.int(2, 3);
@@ -55,8 +65,10 @@ export function generateMap(seed) {
const tx = r.int(8, GW - 9);
if (hs[tx] > GH || nearSpawn(tx)) continue;
const th = r.int(5, 9), tw = r.int(1, 2);
- for (let x = tx; x < tx + tw; x++)
- for (let y = hs[tx] - th; y < hs[tx]; y++) set(x, y, T.SOLID);
+ for (let x = tx; x < tx + tw; x++) {
+ if (hs[x] > GH) continue; // don't hang tower slabs over pit columns
+ for (let y = hs[x] - th; y < hs[x]; y++) set(x, y, T.SOLID);
+ }
}
// --- floating islands ---
@@ -126,25 +138,44 @@ export function generateMap(seed) {
}
// --- rail saws: hazards moving along fixed paths through open air ---
+ // A path is rejected if it grinds through terrain (invisible through-wall
+ // hits) or sweeps within kill radius of a spawn point.
+ const solidT = (t) => t === T.SOLID || t === T.CRYSTAL || t === T.PAD;
+ const sawPathOk = (pts, loop) => {
+ const nSegs = loop ? pts.length : pts.length - 1;
+ for (let s = 0; s < nSegs; s++) {
+ const a = pts[s], b = pts[(s + 1) % pts.length];
+ const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
+ for (let d = 0; d <= len; d += 6) {
+ const px = a.x + (b.x - a.x) * d / len, py = a.y + (b.y - a.y) * d / len;
+ for (const [ox, oy] of [[0, 0], [12, 0], [-12, 0], [0, 12], [0, -12]]) {
+ if (solidT(get(Math.floor((px + ox) / TILE), Math.floor((py + oy) / TILE)))) return false;
+ }
+ for (const sp of spawns) if (Math.hypot(sp.x - px, sp.y - py) < 30) return false;
+ }
+ }
+ return true;
+ };
const saws = [];
const nSaws = r.int(1, 3);
for (let i = 0; i < nSaws; i++) {
- const y = (r.int(10, 26)) * TILE;
- const x0 = r.int(8, GW - 30) * TILE, x1 = x0 + r.int(12, 20) * TILE;
- if (r.chance(0.5)) {
- saws.push({ pts: [{ x: x0, y }, { x: x1, y }], speed: 1.1 + r.f() * 1.1, prog: r.f(), x: x0, y });
- } else {
- const y2 = y + r.int(4, 8) * TILE; // rectangle loop
- saws.push({ pts: [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }], speed: 1.2 + r.f(), prog: r.f(), x: x0, y, loop: true });
+ for (let t = 0; t < 30; t++) {
+ const y = (r.int(10, 26)) * TILE;
+ const x0 = r.int(8, GW - 30) * TILE, x1 = x0 + r.int(12, 20) * TILE;
+ let pts, loop = false;
+ if (r.chance(0.5)) {
+ pts = [{ x: x0, y }, { x: x1, y }];
+ } else {
+ const y2 = y + r.int(4, 8) * TILE; // rectangle loop
+ pts = [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }];
+ loop = true;
+ }
+ if (!sawPathOk(pts, loop)) continue;
+ saws.push({ pts, speed: 1.1 + r.f() * 1.1, prog: r.f(), x: x0, y, loop });
+ break;
}
}
- // --- spawn points (pixel coords, centered above surface) ---
- const spawns = spawnCols.map(x => {
- let surf = hs[x] <= GH ? hs[x] : GH - 4;
- return { x: x * TILE + TILE / 2, y: surf * TILE - 10 };
- });
-
return { seed, tiles, hp, spawns, saws, pal: r.int(0, PALETTES.length - 1) };
}
diff --git a/js/net.js b/js/net.js
index bda4346..51cf294 100644
--- a/js/net.js
+++ b/js/net.js
@@ -25,27 +25,48 @@ function errMsg(e) {
return 'Connection error: ' + (e && e.message || t || 'unknown');
}
+// WebRTC close events are unreliable (a vanished tab often never fires
+// 'close'), so both sides run a heartbeat + watchdog over the data channel.
+const HB_INTERVAL = 2000, HB_TIMEOUT = 8000;
+
+function wireConn(c, cb, die, who) {
+ let lastRecv = performance.now();
+ c.on('data', (d) => {
+ lastRecv = performance.now();
+ if (d && d.t === '_hb') return;
+ cb.data(d);
+ });
+ c.on('close', () => die(`${who} disconnected.`));
+ c.on('error', () => die('Connection lost.'));
+ const timer = setInterval(() => {
+ if (c.open) {
+ try { c.send({ t: '_hb' }); } catch (_) {}
+ if (performance.now() - lastRecv > HB_TIMEOUT) die(`${who} stopped responding.`);
+ }
+ }, HB_INTERVAL);
+ return () => clearInterval(timer);
+}
+
// cb: { open(key), conn(), data(msg), close(reason) }
export function hostRoom(key, cb) {
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
const peer = new window.Peer(PRE + key);
let conn = null;
let dead = false;
- const die = (msg) => { if (!dead) { dead = true; cb.close(msg); } };
+ let linked = false; // data channel established — broker hiccups no longer matter
+ let unwire = null;
+ const die = (msg) => { if (!dead) { dead = true; if (unwire) unwire(); cb.close(msg); } };
peer.on('open', () => cb.open(key));
- peer.on('error', (e) => die(errMsg(e)));
+ peer.on('error', (e) => { if (linked && e && e.type === 'network') return; die(errMsg(e)); });
peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
peer.on('connection', (c) => {
if (conn) { try { c.close(); } catch (_) {} return; } // room is full (2 players)
conn = c;
- c.on('open', () => cb.conn());
- c.on('data', (d) => cb.data(d));
- c.on('close', () => die('Other player disconnected.'));
- c.on('error', () => die('Connection lost.'));
+ c.on('open', () => { linked = true; unwire = wireConn(c, cb, die, 'Other player'); cb.conn(); });
});
return {
send: (o) => { if (conn && conn.open) conn.send(o); },
- destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} },
+ destroy: () => { dead = true; if (unwire) unwire(); try { peer.destroy(); } catch (_) {} },
};
}
@@ -53,18 +74,23 @@ export function joinRoom(key, cb) {
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
const peer = new window.Peer();
let dead = false;
- const die = (msg) => { if (!dead) { dead = true; cb.close(msg); } };
+ let linked = false;
+ let unwire = null;
+ const die = (msg) => { if (!dead) { dead = true; if (unwire) unwire(); cb.close(msg); } };
const api = {
send: () => {},
- destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} },
+ destroy: () => { dead = true; if (unwire) unwire(); try { peer.destroy(); } catch (_) {} },
};
- peer.on('error', (e) => die(errMsg(e)));
+ peer.on('error', (e) => { if (linked && e && e.type === 'network') return; die(errMsg(e)); });
+ peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
peer.on('open', () => {
const c = peer.connect(PRE + key.toUpperCase().trim(), { reliable: true });
- c.on('open', () => { api.send = (o) => { if (c.open) c.send(o); }; cb.conn(); });
- c.on('data', (d) => cb.data(d));
- c.on('close', () => die('Host disconnected.'));
- c.on('error', () => die('Connection lost.'));
+ c.on('open', () => {
+ linked = true;
+ api.send = (o) => { if (c.open) c.send(o); };
+ unwire = wireConn(c, cb, die, 'Host');
+ cb.conn();
+ });
});
return api;
}
diff --git a/js/render.js b/js/render.js
index 5eddf93..83374b5 100644
--- a/js/render.js
+++ b/js/render.js
@@ -237,6 +237,7 @@ function drawItemIcon(x, y, item, r = 5) {
const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?';
ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088';
ctx.fillText(ch, x, y + 0.5);
+ ctx.textBaseline = 'alphabetic'; // don't leak 'middle' into HUD/banner text
}
function drawEnts(game) {
diff --git a/js/sim.js b/js/sim.js
index 50f1b33..0496106 100644
--- a/js/sim.js
+++ b/js/sim.js
@@ -77,7 +77,7 @@ function stepPlayer(g, p, inp, pressed) {
}
const c = CHARS[p.charId], s = c.stats;
- if (p.invuln > 0) p.invuln--;
+ if (p.invuln > 0 && g.freeze <= 0) p.invuln--; // spawn protection shouldn't burn during countdown
if (p.atkCd > 0) p.atkCd--;
if (p.shield > 0) p.shield--;
if (p.boost > 0) p.boost--;
@@ -217,16 +217,20 @@ function breakTile(g, tx, ty, drop) {
}
export function hurt(g, v, dmg, nx, ny, base) {
- if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0) return;
- if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return; }
+ if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0) return false;
+ if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return false; }
v.dmg += dmg;
const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight;
- v.vx = nx * m;
- v.vy = ny * m - 1.4;
+ // collision is single-pass AABB: velocities >= TILE/tick tunnel through walls
+ const cap = TILE - 2.5;
+ v.vx = Math.max(-cap, Math.min(cap, nx * m));
+ v.vy = Math.max(-cap, Math.min(cap, ny * m - 1.4));
v.hitstun = Math.min(40, 9 + m * 1.6);
v.invuln = 14;
v.st.charge = 0; v.st.anchor = null; v.st.pound = false;
+ v.atkT = 0; // a launched dog's bite window ends with it
g.events.push({ t: 'hit', x: v.x, y: v.y, m });
+ return true;
}
function senseTiles(g, p) {
@@ -236,8 +240,9 @@ function senseTiles(g, p) {
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
if (tileAt(g.map, tx, ty) === T.SPIKE) {
const cx = tx * TILE + 6;
- hurt(g, p, 9, Math.sign(p.x - cx) * 0.4 || 0.2, -1, 5.5);
- g.events.push({ t: 'spike', x: p.x, y: p.y });
+ if (hurt(g, p, 9, Math.sign(p.x - cx) * 0.4 || 0.2, -1, 5.5)) {
+ g.events.push({ t: 'spike', x: p.x, y: p.y });
+ }
}
}
// bounce pad: landed on top of a PAD tile
@@ -308,6 +313,7 @@ function stepEnts(g) {
} else if (e.k === 'ball') {
e.age++; e.ttl--;
e.vy += GRAV * 0.45;
+ if (e.vy > MAXFALL) e.vy = MAXFALL;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.ground && pvy > 1) e.vy = -pvy * 0.72;
@@ -327,6 +333,7 @@ function stepEnts(g) {
} else if (e.k === 'bomb') {
e.age++; e.fuse--;
e.vy += GRAV;
+ if (e.vy > MAXFALL) e.vy = MAXFALL;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.ground && pvy > 1) { e.vy = -pvy * 0.45; e.vx *= 0.7; }
@@ -378,8 +385,9 @@ function stepSaws(g) {
const d = Math.hypot(dx, dy);
if (d < 16) {
const n = d || 1;
- hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5);
- g.events.push({ t: 'saw', x: p.x, y: p.y });
+ if (hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5)) {
+ g.events.push({ t: 'saw', x: p.x, y: p.y });
+ }
}
}
}
@@ -392,7 +400,7 @@ function kill(g, p) {
x: Math.max(20, Math.min(W - 20, p.x)),
y: Math.max(20, Math.min(H - 20, p.y)),
});
- p.st = {}; p.item = null; p.ammo = 0;
+ p.st = {}; p.item = null; p.ammo = 0; p.shield = 0; p.boost = 0;
p.vx = 0; p.vy = 0; p.hitstun = 0; p.atkT = 0; p.dmg = 0;
p.x = -1000; p.y = -1000;
p.respawn = p.stocks > 0 ? 80 : 0;
@@ -434,6 +442,7 @@ export function snapshot(g, events) {
x: +p.x.toFixed(1), y: +p.y.toFixed(1),
vx: +p.vx.toFixed(2), vy: +p.vy.toFixed(2),
f: p.face, d: Math.round(p.dmg), s: p.stocks,
+ og: p.onGround ? 1 : 0, lt: p.landT | 0,
r: p.respawn, iv: p.invuln, hs: p.hitstun, a: p.atkT,
it: p.item, am: p.ammo, sh: p.shield, bo: p.boost,
st: {
@@ -456,6 +465,7 @@ export function applySnapshot(g, s) {
if (!p) return;
p.x = sp.x; p.y = sp.y; p.vx = sp.vx; p.vy = sp.vy;
p.face = sp.f; p.dmg = sp.d; p.stocks = sp.s;
+ p.onGround = !!sp.og; p.landT = sp.lt;
p.respawn = sp.r; p.invuln = sp.iv; p.hitstun = sp.hs; p.atkT = sp.a;
p.item = sp.it; p.ammo = sp.am; p.shield = sp.sh; p.boost = sp.bo;
p.st = {
diff --git a/test/smoke.mjs b/test/smoke.mjs
index b44bb66..85a2a35 100644
--- a/test/smoke.mjs
+++ b/test/smoke.mjs
@@ -3,6 +3,7 @@
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
import { generateMap, sawPos } from '../js/mapgen.js';
import { mulberry32 } from '../js/rng.js';
+import { boxFree, solidAtPx } from '../js/physics.js';
import { GW, GH, T, B } from '../js/consts.js';
let failures = 0;
@@ -32,8 +33,22 @@ for (let s = 1; s <= 40; s++) {
const p = sawPos(saw, 0.37);
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) fail(`seed ${seed}: NaN saw pos`);
}
+ // spawn footprints must be clear of solids (review finding: 58% of seeds embedded players)
+ for (const sp of map.spawns) {
+ if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`seed ${seed}: spawn embedded in terrain at (${sp.x},${sp.y})`);
+ }
+ // saw paths must not grind through terrain or sweep spawn points
+ for (const saw of map.saws) {
+ for (let f = 0; f < 1; f += 0.02) {
+ const p = sawPos(saw, f);
+ if (solidAtPx(map, p.x, p.y)) { fail(`seed ${seed}: saw path inside solid at (${p.x | 0},${p.y | 0})`); break; }
+ for (const sp of map.spawns) {
+ if (Math.hypot(sp.x - p.x, sp.y - p.y) < 25) { fail(`seed ${seed}: saw sweeps spawn point`); f = 2; break; }
+ }
+ }
+ }
}
-ok('mapgen: 40 seeds, deterministic, sane geometry');
+ok('mapgen: 40 seeds, deterministic, sane geometry, clear spawns, clean saw paths');
// --- full-match simulation with chaotic inputs ---
const ALL_BITS = [0, B.L, B.R, B.L | B.JUMP | B.U, B.R | B.JUMP | B.U, B.ATK, B.SPC, B.R | B.SPC,
@@ -84,13 +99,16 @@ ok('sim: 12 chaotic matches, all character matchups, finite state throughout');
const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]);
g.freeze = 0;
const p = g.players[0];
+ const sx = p.x, sy = p.y;
const tx = Math.floor(p.x / 12) + 2, ty = Math.floor(p.y / 12);
g.map.tiles[ty * GW + tx] = T.CRYSTAL;
g.map.hp[ty * GW + tx] = 3;
- p.face = 1;
let dropped = false;
for (let i = 0; i < 200 && !dropped; i++) {
- const evs = step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
+ // pin the dog next to the crystal — the bite's forward lunge would otherwise
+ // shove it off terrain steps and out of reach
+ p.x = sx; p.y = sy; p.vx = 0; p.vy = 0; p.face = 1;
+ step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
if (g.ents.some(e => e.k === 'pickup')) dropped = true;
}
if (!dropped) fail('mining a crystal never dropped a pickup');