SOURCE / PINNED RELEASE
Made of little things.
Shiftfield
- Release
- 5a170c505f6b…
- Author-recorded commit
- a2e23e0171aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd
Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Minesweeper DX — Phase 2 chaos suite (headless, pure logic).
// Turns advance on EFFECTIVE actions only: a clearing reveal, a flag toggle
// (place or remove), a successful ability (exactly one turn), or a
// shield-absorbed detonation. Blocked/rejected/no-op attempts tick nothing.
import assert from 'node:assert/strict';
import test from 'node:test';
import { GameEngine, MINE_COUNT_CAP, mineCountFor, shiftIntervalFor } from '../src/game.ts';
import { MinesweeperEngine } from '../src/engine.ts';
import { MAX_UPGRADES } from '../src/upgrades.ts';
// Deterministic base: mine-free engine so every reveal is a safe clear.
// MAX_UPGRADES is passed EXPLICITLY (constructor default is locked since the
// 5-free-shields regression): it grants 5 run-start shield charges too —
// this suite opts OUT (shieldCharges = 0) so fatal-detonation laws stay
// testable; absorb tests grant charges explicitly (g.shieldCharges = N).
function fresh(depth, cfg, seed = 1) {
const g = new GameEngine(depth, cfg, seed, new MinesweeperEngine(seed, 0), MAX_UPGRADES);
g.shieldCharges = 0;
return g;
}
// Turn padding that does NOT clear the board: flags commit a turn but never
// flood-reveal (with flood, repeated reveal() would open the whole board on
// turn one). (9,9) is kept clear of the fixtures' mines/fires below.
function padTurns(g, n) {
for (let i = 0; i < n; i++) g.flag(9, 9);
}
// --- shift engine -------------------------------------------------------
test('shift fires after its interval and repicks a telegraph', () => {
const g = fresh(6);
const interval = shiftIntervalFor(6); // 28
const w = g.world;
w.tileAt(3, 3).hasMine = true;
const pre = [...g.telegraph]; // the direction that will fire
padTurns(g, interval - 1);
assert.equal(g.nextShiftIn, 1);
assert.equal(w.tileAt(3, 3).hasMine, true);
const [dx, dy] = pre;
const tx = w.wrapX(3 + dx), ty = w.wrapY(3 + dy);
padTurns(g, 1); // fires the shift
assert.equal(g.nextShiftIn, interval);
assert.equal(w.tileAt(tx, ty).hasMine, true);
if (dx !== 0 || dy !== 0) assert.equal(w.tileAt(3, 3).hasMine, false);
});
test('stabilizer (Lv1) pauses the shift countdown for its duration', () => {
const g = fresh(6);
g.upgrades = { ...g.upgrades, stabilizer: 1 }; // pin the Lv1 3-turn hold
const interval = shiftIntervalFor(6); // 28
const w = g.world;
w.tileAt(3, 3).hasMine = true;
// set flow directly (would otherwise need reveals that tick the shift)
g.flow = 40; // stabilizer costs 30f now (ruling 2026-10-29)
g.stabilizer(); // t1: stabilizerFor 3→2, countdown paused
g.flag(9, 9); // t2: stabilizerFor 2→1 (flag pads turns without flooding)
g.flag(8, 8); // t3: stabilizerFor 1→0
// no tick to the countdown during the pause
assert.equal(g.stabilizedFor, 0);
assert.equal(g.nextShiftIn, interval);
assert.equal(w.tileAt(3, 3).hasMine, true);
// post-pause, counting resumes
g.reveal(0, 0); // t4
assert.equal(g.nextShiftIn, interval - 1);
});
// --- ice hazard ----------------------------------------------------------
test('ice blocks interaction for iceDuration turns, then reverts', () => {
const g = fresh(0);
assert(g.spawnIce(5, 5));
// Blocked attempts are FREE: ice only thaws on effective turns elsewhere.
assert.equal(g.reveal(5, 5), 'blocked');
assert.equal(g.flag(5, 5), 'blocked');
assert.equal(g.reveal(5, 5), 'blocked');
assert.equal(g.turn, 0, 'blocked attempts advance nothing');
g.flag(9, 9); // t1
assert.equal(g.iceCount(), 1);
g.flag(9, 9); // t2 (toggle removes the flag — still an effective turn)
assert.equal(g.iceCount(), 1);
assert.equal(g.reveal(5, 5), 'blocked');
g.flag(9, 9); // t3 → ice expires
assert.equal(g.iceCount(), 0);
assert.equal(g.reveal(5, 5), 'safe');
});
test('ice does not block mine shifts', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(5, 5).hasMine = true;
assert(g.spawnIce(6, 5));
const r = w.shift(1, 0);
assert.equal(r.moved, 1);
assert.equal(w.tileAt(6, 5).hasMine, true);
assert.equal(w.tileAt(5, 5).hasMine, false);
});
// --- fire hazard -----------------------------------------------------------
// Rules re-ruled 2026-10-27 (player): spread fires on ODD per-board turns
// (boardTurn 1, 3, 5...; reset by advance()). Each burning tile randomly
// ignites UP TO 2 eligible HIDDEN neighbors — not ICE, not FLAGGED, not
// already FIRE. Mine tiles ARE valid ignition targets: spreading ONTO a mine
// tile detonates it (MINE_ON_FIRE); mere adjacency is harmless.
test('fire spreads on each odd board turn, up to 2 random neighbors per tile', () => {
const g = fresh(0);
assert(g.spawnFire(5, 5));
padTurns(g, 1); // t1 (odd): the first spread lands after the board's turn 1
assert.equal(g.fireCount(), 3, 'origin + exactly 2 random ignitions (8 candidates)');
for (const cell of g.fire) {
const x = cell % 10;
const y = Math.floor(cell / 10);
assert.ok(Math.abs(x - 5) <= 1 && Math.abs(y - 5) <= 1, 'spread stays 8-neighbor');
}
padTurns(g, 1); // t2 (even): EVEN turns never spread
assert.equal(g.fireCount(), 3, 'no spread on even board turns');
padTurns(g, 1); // t3 (odd): all three burning tiles ignite up to 2 each
assert.ok(g.fireCount() >= 4, 'fire keeps expanding every odd turn');
assert.equal(g.world.explosion, null, 'mine-free board: spread costs nothing');
});
test('revealing a fire tile extinguishes it', () => {
const g = fresh(0);
assert(g.spawnFire(5, 5));
assert.equal(g.reveal(5, 5), 'safe');
assert.equal(g.fireCount(), 0);
});
// --- fire is NOT a flood wall (player ruling 2026-10-28) ------------------
// The flood opens a fire tile whenever it would open the plain hidden tile
// underneath it, and opening extinguishes the fire. Only ICE stays a wall.
test('flood opens fire tiles and extinguishes them (zero fire conducts)', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(9, 9).hasMine = true;
assert(g.spawnFire(0, 5)); // zero-adjacent fire inside the open region
const r = g.reveal(5, 5);
assert.equal(r, 'safe');
assert.equal(w.tileAt(0, 5).state, 'REVEALED', 'the flood opened the fire tile');
assert.equal(g.fireCount(), 0, 'opening it extinguished the fire');
assert.equal(w.explosion, null);
assert.equal(w.revealedCount(), 99, 'fire tile counted like any cleared tile (99 of 100)');
});
test('numbered-border fire opens but does not conduct; ICE stays a wall', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(9, 9).hasMine = true;
assert(g.spawnFire(8, 8)); // fire on the numbered border (adjacent to the mine)
assert(g.spawnIce(0, 5)); // ice on a zero tile — still a hard flood wall
g.reveal(5, 5);
assert.equal(w.tileAt(8, 8).state, 'REVEALED', 'border fire opens like a border tile');
assert.equal(g.fireCount(), 0, 'and is extinguished by the flood');
assert.equal(w.tileAt(0, 5).state, 'HIDDEN', 'ICE is unchanged: still a flood wall');
});
test('flagging a fire tile extinguishes it and locks the ground', () => {
const g = fresh(0);
assert(g.spawnFire(5, 5));
assert.equal(g.flag(5, 5), 'flagged');
assert.equal(g.fireCount(), 0, 'the flag extinguished the fire');
assert.equal(g.tile(5, 5).state, 'FLAGGED');
assert(g.spawnFire(4, 5));
padTurns(g, 1); // t2 (even): no spread
padTurns(g, 1); // t3 (odd): spread from (4,5) — FLAGGED (5,5) is not HIDDEN
assert.equal(g.fireAt(5, 5), false, 'flagged ground blocks spread');
assert.equal(g.world.explosion, null);
});
// --- turn law: turns advance on EFFECTIVE actions only ---------------------
test('turn increments once per clearing reveal, once per flood, not on no-op clicks', () => {
const g = fresh(0);
// Ring mines around (5,5) so the board cannot clear on the first flood.
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
if (!(dx === 0 && dy === 0)) g.world.tileAt(5 + dx, 5 + dy).hasMine = true;
g.reveal(0, 0); // flood: many tiles, ONE turn
assert.equal(g.turn, 1);
assert.ok(g.world.revealedCount() > 20, 'flood opened a chunk');
g.reveal(1, 1); // already-open ground: no-op
assert.equal(g.turn, 1, 'clicking revealed tiles advances nothing');
});
test('turn increments by 1 on flag place, 1 on flag remove', () => {
const g = fresh(0);
g.flag(5, 5);
assert.equal(g.turn, 1);
g.flag(5, 5); // toggle removes
assert.equal(g.turn, 2);
});
test('an ability use is exactly ONE turn, even when it flags (seeker) or reveals (radar)', () => {
const g = fresh(0);
g.world.tileAt(7, 7).hasMine = true; // seeker needs a mine; fresh() is mine-free
g.flow = 130; // seeker 50 + radar 50 + stabilizer 30 (ruling 2026-10-29)
assert.equal(g.seeker(), true);
assert.equal(g.turn, 1, 'seeker + its internal flag = one turn total');
assert.equal(g.world.flaggedCount(), 1, 'seeker placed the flag');
assert.equal(g.radar(0, 0), true);
assert.equal(g.turn, 2, 'radar + its internal reveals = one turn total');
assert.equal(g.stabilizer(), true);
assert.equal(g.turn, 3, 'stabilizer = one turn');
});
test('failed abilities cost nothing: no turn, no flow, no cooldown', () => {
const g = fresh(0);
assert.equal(g.seeker(), false); // no flow
assert.equal(g.radar(5, 5), false);
assert.equal(g.turn, 0);
g.flow = 40; // stabilizer costs 30f (ruling 2026-10-29)
assert.equal(g.stabilizer(), true); // t1
assert.equal(g.stabilizer(), false); // on cooldown
assert.equal(g.turn, 1, 'a refused ability does not tick the counter');
});
test('shield-absorbed detonation is one turn; fatal unshielded detonation ticks none', () => {
const g = fresh(0);
g.world.tileAt(5, 5).hasMine = true;
g.shieldCharges = 1; // run-start grant (Shield is no longer Flow-bought)
g.firstClickSafe = false; // this test is about the shield, not first-click safety
assert.equal(g.reveal(5, 5), 'detonated'); // absorbed → effective
assert.equal(g.world.explosion, null);
assert.equal(g.turn, 1, 'the absorb is free: the reveal itself was the turn');
assert.equal(g.reveal(5, 5), 'safe'); // FLAGGED mine: blocked at no cost
assert.equal(g.turn, 1);
const dead = fresh(0);
dead.world.tileAt(5, 5).hasMine = true;
dead.firstClickSafe = false; // fatal-detonation law under test, not opening safety
assert.equal(dead.reveal(5, 5), 'detonated'); // fatal
assert.equal(dead.turn, 0, 'the run ends; no turn is committed');
});
// --- flag toggle (classic rule: right-click on a flag removes it) --------
test('flagging a flagged tile removes the flag (toggle)', () => {
const g = fresh(0);
assert.equal(g.flag(5, 5), 'flagged');
assert.equal(g.tile(5, 5).state, 'FLAGGED');
assert.equal(g.flag(5, 5), 'unflagged');
assert.equal(g.tile(5, 5).state, 'HIDDEN');
assert.equal(g.world.flaggedCount(), 0);
});
test('flag cannot be placed on a revealed tile', () => {
const g = fresh(0);
assert.equal(g.reveal(5, 5), 'safe');
const turnBefore = g.turn;
assert.equal(g.flag(5, 5), 'rejected');
assert.equal(g.tile(5, 5).state, 'REVEALED');
assert.equal(g.world.flaggedCount(), 0);
assert.equal(g.turn, turnBefore); // nothing changed → no turn
});
test('unflag lets fire spread through again and mines shift again', () => {
const g = fresh(0);
// the flag EXTINGUISHED the fire and locked the ground; unlocking returns
// it to HIDDEN so it can catch fire again on a later spread
assert(g.spawnFire(5, 5));
g.flag(5, 5); // t1: fire extinguished, lock placed
g.flag(5, 5); // t2: toggle removes the lock → HIDDEN again
assert.equal(g.fireAt(5, 5), false);
assert(g.spawnFire(4, 5));
padTurns(g, 1); // t3 (odd): the HIDDEN (5,5) is a spread candidate again
assert.equal(g.fireCount(), 3, 'the (4,5) source ignited exactly 2 candidates');
// a mine locked by a flag moves again once the flag is removed
const gs = fresh(0);
gs.world.tileAt(3, 0).hasMine = true;
gs.flag(4, 0);
assert.equal(gs.world.shift(1, 0).blocked, 1);
gs.flag(4, 0); // toggle removes the lock
assert.equal(gs.world.shift(1, 0).moved, 1);
assert.equal(gs.world.tileAt(4, 0).hasMine, true);
});
test('fire spreading ONTO a mine tile detonates it (MINE_ON_FIRE)', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(6, 5).hasMine = true;
// Force determinism: reveal (no turn cost) every OTHER neighbor of the
// source so the mine tile is the only eligible ignition candidate.
for (const [x, y] of [[4,4],[5,4],[6,4],[4,5],[4,6],[5,6],[6,6]])
w.reveal(x, y, { flood: false });
assert(g.spawnFire(5, 5));
padTurns(g, 1); // t1 (odd): candidates = [(6,5)] → the mine ignites
assert.equal(w.explosion, 'MINE_ON_FIRE');
assert.equal(g.fireAt(6, 5), true, 'fire burns on top of the mine');
});
test('fire merely ADJACENT to a hidden mine is harmless', () => {
const g = fresh(0);
const w = g.world;
assert(g.spawnIce(6, 5)); // ice blocks spread ONTO the mine tile
w.tileAt(6, 5).hasMine = true; // mine under the ice (shift ignores ice)
// ring the mine with fire on all 8 sides — pure adjacency
for (const [x, y] of [[5,4],[6,4],[7,4],[5,5],[7,5],[5,6],[6,6],[7,6]])
assert(g.spawnFire(x, y), `ring fire (${x},${y})`);
g.shieldCharges = 1;
padTurns(g, 2); // t1 (odd): one spread wave; every fire touches the mine
assert.equal(w.explosion, null, 'eight-way adjacency detonates nothing');
assert.equal(g.shieldCharges, 1, 'and bills nothing');
assert.equal(g.fireAt(6, 5), false, 'the iced tile blocked spread');
});
// Shift determinism: the mine tile is iced (blocks spread, NOT shifts), the
// fire sits exactly on the telegraphed destination, and nextShiftIn is forced
// to 1 so the very next turn is the shift — the detonation can only come from
// the mine LANDING ON the fire tile.
test('a shift moving a mine onto a fire tile detonates (MINE_ON_FIRE)', () => {
const g = fresh(6);
const w = g.world;
const [dx, dy] = g.telegraph;
const tx = (4 + dx + 10) % 10, ty = (5 + dy + 10) % 10;
assert(tx !== 4 || ty !== 5, 'telegraph must be a real move');
assert(tx !== 9 || ty !== 9, 'padTurns flags (9,9) — would block the shift');
assert(g.spawnIce(4, 5));
w.tileAt(4, 5).hasMine = true;
assert(g.spawnFire(tx, ty), 'shift destination must carry the fire');
g.nextShiftIn = 1; // shift fires on the very next effective turn
padTurns(g, 1); // t1: spread skips iced (4,5); shift moves mine ONTO fire
assert.equal(w.explosion, 'MINE_ON_FIRE', 'the shift detonated, not a spread');
assert.equal(w.tileAt(tx, ty).hasMine, true, 'mine landed on the fire tile');
});
// --- first-click safety (classic rule: the opening reveal never detonates) --
test('the opening reveal on a mine relocates the mine and opens safely', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(5, 5).hasMine = true;
w.tileAt(2, 7).hasMine = true; // second mine: total must survive the relocation
const r = g.reveal(5, 5);
assert.equal(r, 'safe', 'opening a mine tile is safe');
assert.equal(w.explosion, null);
assert.equal(w.tileAt(5, 5).state, 'REVEALED');
assert.equal(w.tileAt(5, 5).hasMine, false, 'the mine moved off the clicked tile');
assert.equal(w.mineCount(), 2, 'relocation preserves the mine count');
assert.equal(g.turn, 1, 'the safe opening still costs a normal turn');
assert.equal(g.firstClickSafe, false, 'guarantee spent by the opening');
});
test('guarantee is spent by the opening only: a later reveal on a mine detonates', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(5, 5).hasMine = true;
assert.equal(g.reveal(5, 5), 'safe'); // relocated — protection consumed
const mine = w.tiles.find((t) => t.hasMine); // wherever it went
assert.equal(g.reveal(mine.coord[0], mine.coord[1]), 'detonated');
assert.equal(w.explosion, 'MINE_ON_REVEALED');
});
test('attempts that open nothing do not spend the guarantee', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(5, 5).hasMine = true;
// Ice-blocked attempt: 'blocked' costs nothing and must keep the guarantee.
// (Ice cannot sit on the mine tile — spawnIce requires mine-free ground —
// so freeze a different HIDDEN tile; the blocked reveal is still a reveal
// attempt that opens nothing.)
assert(g.spawnIce(3, 3));
assert.equal(g.reveal(3, 3), 'blocked');
assert.equal(g.firstClickSafe, true);
// Click on FLAGGED ground opens nothing: guarantee stays armed.
assert.equal(g.flag(7, 7), 'flagged'); // t1
assert.equal(g.reveal(7, 7), 'safe'); // flagged wall: cleared 0, no commit
assert.equal(g.firstClickSafe, true);
// Click on already-REVEALED ground opens nothing too — but until the board
// opens there is no revealed tile, so the flag case covers the no-op arm.
// The guarantee is untouched: the mine click still gets protected.
assert.equal(g.reveal(5, 5), 'safe'); // guarantee still armed → mine relocates
assert.equal(w.explosion, null);
assert.equal(w.tileAt(5, 5).hasMine, false);
assert.equal(g.firstClickSafe, false);
});
test('relocated mine lands on HIDDEN, mine-free, hazard-free ground', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(5, 5).hasMine = true;
assert(g.spawnIce(0, 0)); // candidate spots that are hazards must be avoided
assert(g.spawnFire(4, 4));
assert.equal(g.reveal(5, 5), 'safe');
for (const t of w.tiles) {
if (!t.hasMine) continue;
const c = t.coord[1] * w.width + t.coord[0];
assert.equal(t.state, 'HIDDEN', 'relocated mine must sit on hidden ground');
assert.equal(g.ice.has(c), false);
assert.equal(g.fire.has(c), false);
}
assert.equal(w.mineCount(), 1);
});
test('radar does not spend the guarantee (it can never open a mine)', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(7, 7).hasMine = true;
g.flow = 60; // radar costs 50f now (ruling 2026-10-29)
assert.equal(g.radar(6, 6), true); // 3×3 touches (7,7); radar skips the mine
assert.equal(g.firstClickSafe, true, 'radar reveals are not the opening reveal');
assert.equal(w.explosion, null);
assert.equal(g.reveal(7, 7), 'safe'); // opening normal reveal still protected
assert.equal(w.tileAt(7, 7).hasMine, false);
});
test('wave advance re-arms first-click protection on the new board', () => {
const g = fresh(0);
// Clear the mine-free board WITHOUT touching GameEngine.reveal (raw engine
// reveals keep the guarantee unspent) so depth 1 starts armed anyway — the
// meaningful check is that the fresh depth-1 board protects its opening.
for (const t of g.world.tiles) if (t.state === 'HIDDEN') g.world.reveal(t.coord[0], t.coord[1]);
assert.equal(g.isBoardCleared, true);
g.advance();
assert.equal(g.depth, 1);
assert.equal(g.firstClickSafe, true);
const w = g.world;
assert.equal(w.mineCount(), mineCountFor(1));
const mine = w.tiles.find((t) => t.hasMine);
assert.equal(g.reveal(mine.coord[0], mine.coord[1]), 'safe', 'depth 1 opening is protected');
assert.equal(w.explosion, null);
});
// flow / abilities ----------------------------------------------------------
test('flow accrues once per clearing ACTION (flood pays once) and per board clear', () => {
const g = fresh(0);
g.reveal(0, 0);
assert.equal(g.flow, 5);
assert.ok(g.world.revealedCount() > 50, 'mine-free flood opens the board');
// Re-clicking opened ground clears nothing and pays nothing.
g.reveal(1, 1);
assert.equal(g.flow, 5);
for (const t of g.world.tiles) if (t.state === 'HIDDEN') g.world.reveal(t.coord[0], t.coord[1]);
const prior = g.flow;
g.advance();
assert.equal(g.flow, prior + 20);
});
test('a flood chunk pays flowPerSafe ONCE regardless of tiles opened', () => {
// Mine ring around (5,5) so the click at (0,0) opens a big region but the
// board cannot clear (nine mine walls + enclosed center stay hidden).
const g = fresh(0);
const ring = [];
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
if (!(dx === 0 && dy === 0)) ring.push([5 + dx, 5 + dy]);
for (const [x, y] of ring) g.world.tileAt(x, y).hasMine = true;
g.reveal(0, 0);
assert.ok(g.world.revealedCount() > 80, 'chunk opened');
assert.equal(g.flow, 5, 'one action = one flowPerSafe, not one per tile');
});
test('abilities are gated by flow cost and cooldown', () => {
const g = fresh(0);
assert.equal(g.radar(0, 0), false);
g.flow = 100; // direct grant (reveals now flood and pay only once)
const ok = g.radar(2, 2); // t1: cost 50 → flow 50 (ruling 2026-10-29)
assert.equal(ok, true);
assert.equal(g.cooldowns.get('radar'), 1); // 2 paid, 1 ticked
assert.equal(g.radar(3, 3), false);
padTurns(g, 1); // t2: cooldown expires
assert.equal(g.cooldowns.get('radar'), 0);
assert.equal(g.radar(4, 4), true); // flow 0 — affordable exactly at cost
});
test('radar is a pure Flow sink: it grants nothing per revealed tile', () => {
const g = fresh(0);
// Seal a 3×3 pocket so radar reveals real ground without flooding:
// mines ring (2,2)..(4,4) at its edge? Simpler: radar on a fresh board
// reveals hidden safe tiles; flow must drop by exactly the cost.
g.flow = 60; // radar costs 50f (ruling 2026-10-29)
const before = g.flow;
assert.equal(g.radar(5, 5), true);
assert.equal(g.flow, before - 50, 'net flow is exactly -cost (50f, ruling 2026-10-29)');
});
test('shield absorbs a MINE_ON_FIRE explosion by extinguishing the fire over the mine', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(6, 5).hasMine = true;
// Deterministic wave: fire at (5,5); the only eligible candidates are the
// mine tile (6,5) and safe (4,5) — both ignite.
for (const [x, y] of [[4,4],[5,4],[6,4],[4,6],[5,6],[6,6]])
w.reveal(x, y, { flood: false });
assert(g.spawnFire(5, 5));
g.shieldCharges = 1;
padTurns(g, 1); // t1 (odd): wave lands, mine ignites, absorb clears it
assert.equal(w.explosion, null, 'the charge consumed the detonation');
assert.equal(w.tileAt(6, 5).hasMine, true, 'the mine survives the absorb');
assert.equal(w.tileAt(6, 5).state, 'FLAGGED', 'the absorb FLAGS the mine — tile locked');
assert.equal(g.shieldCharges, 0, 'exactly ONE charge billed');
assert.equal(g.fireAt(6, 5), false, 'the fire OVER the mine tile was extinguished');
assert.equal(g.fireAt(5, 5), true, 'the source fire keeps burning');
padTurns(g, 1); // t2 (even): quiet — adjacency bills nothing
assert.equal(w.explosion, null, 'no phantom re-detonation from adjacency');
assert.equal(g.shieldCharges, 0, 'no silent charge drain');
padTurns(g, 1); // t3 (odd): spread runs — the FLAGGED mine is no candidate
assert.equal(w.explosion, null, 'flag-locked mine cannot re-ignite');
assert.equal(g.fireAt(6, 5), false, 'no fire re-lands on the flagged mine');
assert.equal(w.tileAt(6, 5).state, 'FLAGGED', 'flag stands after later waves');
});
test('shield absorbs a MINE_ON_REVEALED explosion by flagging the mine', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(5, 5).hasMine = true;
padTurns(g, 1); // t1
padTurns(g, 1); // t2
g.shieldCharges = 1; // run-start grant — storing charges is not a turn
g.firstClickSafe = false; // shield absorption under test; skip opening protection
const r = g.reveal(5, 5); // t3
assert.equal(r, 'detonated');
assert.equal(w.explosion, null);
assert.equal(w.tileAt(5, 5).hasMine, true);
assert.equal(w.tileAt(5, 5).state, 'FLAGGED');
});
test('shield absorbs a MINE_ON_MINE shift collision by flagging the collision tile (ruling 2026-10-27)', () => {
const g = fresh(6); // shifts active from depth 6
const w = g.world;
w.tileAt(3, 3).hasMine = true;
w.tileAt(2, 3).hasMine = true;
w.flag(4, 3); // (3,3)'s east target is locked → (3,3) stays; (2,3) would land on it
g.telegraph = [1, 0];
g.nextShiftIn = 1;
g.shieldCharges = 1;
padTurns(g, 1); // shift fires inside the commit → collision → absorb
assert.equal(w.explosion, null, 'the charge consumed the collision');
assert.equal(g.isDead(), false, 'the run continues');
assert.equal(g.shieldCharges, 0, 'exactly ONE charge billed');
assert.equal(w.tileAt(3, 3).state, 'FLAGGED', 'the absorb FLAGS the collision tile');
assert.equal(w.tileAt(3, 3).hasMine, true, 'the standing mine survives');
assert.equal(w.tileAt(2, 3).hasMine, true, 'the shift aborted: the mover never entered');
// Hazard removed, not just the flag cleared: the flag-anchored chain can
// never re-collide while the flag stands, shielded or not.
g.nextShiftIn = 1;
padTurns(g, 1);
assert.equal(w.explosion, null, 'no repeat collision against the flagged tile');
assert.equal(g.shieldCharges, 0, 'no silent charge drain');
});
test('unshielded MINE_ON_MINE collision is still fatal', () => {
const g = fresh(6);
const w = g.world;
w.tileAt(3, 3).hasMine = true;
w.tileAt(2, 3).hasMine = true;
w.flag(4, 3);
g.telegraph = [1, 0];
g.nextShiftIn = 1;
padTurns(g, 1);
assert.equal(w.explosion, 'MINE_ON_MINE');
assert.equal(g.isDead(), true, 'no charge: the blast ends the run');
});
test('advancing to depth 11 spawns ice and fire and scales density', () => {
const g = fresh(10);
for (const t of g.world.tiles) if (t.state === 'HIDDEN') g.world.reveal(t.coord[0], t.coord[1]);
assert.equal(g.isBoardCleared, true);
g.advance();
assert.equal(g.depth, 11);
assert.equal(g.iceCount(), 1);
assert.equal(g.fireCount(), 1);
assert.equal(g.world.mineCount(), mineCountFor(11));
assert.equal(g.boardsCleared, 1);
});
// Mine cap (player ruling 2026-10-27): mineCountFor scales linearly but is
// capped at MINE_COUNT_CAP = 50 (50% density). The raw formula hits 50 at
// depth 175 and stays pinned forever after.
test('mine count scales linearly then caps at 50 (depth 175+)', () => {
assert.equal(mineCountFor(0), 15);
assert.equal(mineCountFor(174), 49, 'last step below the cap');
assert.equal(mineCountFor(175), MINE_COUNT_CAP, 'cap engaged at depth 175');
assert.equal(mineCountFor(400), MINE_COUNT_CAP, 'stays pinned past the cap');
assert.equal(MINE_COUNT_CAP, 50);
});
// Hazard spawn scaling (player ruling 2026-10-27): board #N since depth 11
// spawns min(N,15) ICE and min(⌈N/2⌉,5) FIRE — ice +1 every board, fire +1
// on every odd board. Depth 15 → 5 ice + 3 fire; fire caps at depth 19,
// ice at depth 25.
test('hazard spawn counts scale per board and cap (ice ≤15, fire ≤5)', () => {
const g = fresh(10);
const want = (d) => [
Math.min(Math.ceil((d - 10) / 2), 5), // fire
Math.min(d - 10, 15), // ice
];
for (let d = 11; d <= 27; d++) {
// clear the previous board (raw reveals bypass hazards; skip mines)
for (const t of [...g.world.tiles])
if (t.state === 'HIDDEN' && !t.hasMine) g.world.reveal(t.coord[0], t.coord[1]);
g.fire.clear(); // raw reveals do not extinguish — authoring reset
g.ice.clear();
assert.equal(g.isBoardCleared, true, `board ${d - 1} clearable`);
g.advance();
assert.equal(g.depth, d);
const [fire, ice] = want(d);
assert.equal(g.fireCount(), fire, `depth ${d}: fire spawns`);
assert.equal(g.iceCount(), ice, `depth ${d}: ice spawns`);
// all spawns obey eligibility: HIDDEN, mine-free, no ice/fire overlap
for (const cell of g.fire) {
const t = g.world.tiles[cell];
assert.equal(t.state, 'HIDDEN');
assert.equal(t.hasMine, false, `depth ${d}: fire spawned ON a mine`);
assert.equal(g.ice.has(cell), false, `depth ${d}: ice under fire`);
}
for (const cell of g.ice.keys()) {
const t = g.world.tiles[cell];
assert.equal(t.state, 'HIDDEN');
assert.equal(t.hasMine, false, `depth ${d}: ice spawned ON a mine`);
}
}
// spot-check the cap plateau: counts stay pinned past their thresholds
assert.deepEqual(want(19), [5, 9]); // fire cap reached here
assert.deepEqual(want(25), [5, 15]); // ice cap reached here
assert.deepEqual(want(27), [5, 15]); // both plateau
});
// --- fire rules re-ruled 2026-10-27 (supersedes the adjacency-lethality model)
// Retired model: fire NEIGHBORING a mine detonated it. Current ruling: only
// fire landing ON the mine's own tile detonates; adjacency is harmless. The
// frozen-spread root cause (early `return` before committing additions) stays
// fixed — asserted below: the whole wave commits before any detonation.
test('a wave that ignites a mine still commits its other ignitions', () => {
const g = fresh(0);
const w = g.world;
w.tileAt(6, 5).hasMine = true;
// candidates: exactly (6,5) mine + (4,5) safe — both ignite in one wave
for (const [x, y] of [[4,4],[5,4],[6,4],[4,6],[5,6],[6,6]])
w.reveal(x, y, { flood: false });
assert(g.spawnFire(5, 5));
padTurns(g, 1); // t1 (odd): both candidates ignite
assert.equal(w.explosion, 'MINE_ON_FIRE');
assert.equal(g.fireAt(4, 5), true, 'the safe ignition committed with the lethal one');
assert.equal(g.fireAt(6, 5), true, 'the mine tile burns — spread may land on mines');
});
test('wave advance spawns fire only on mine-FREE ground (adjacency allowed)', () => {
let adjacencySeen = 0;
for (let seed = 1; seed <= 50; seed++) {
const g = fresh(10, undefined, seed);
for (const t of g.world.tiles)
if (t.state === 'HIDDEN') g.world.reveal(t.coord[0], t.coord[1]);
assert.equal(g.isBoardCleared, true);
g.advance();
assert.equal(g.fireCount(), 1, `seed ${seed}: expected one spawned fire`);
for (const cell of g.fire) {
const x = cell % g.world.width;
const y = Math.floor(cell / g.world.width);
assert.equal(g.world.tileAt(x, y).hasMine, false,
`seed ${seed}: spawned fire sits ON a mine`);
if (g.world.adjacentMineCount(x, y) > 0) adjacencySeen++;
}
}
// adjacency-to-mines for SPAWNS is legal by design — assert it actually
// occurs so a regression to the old mine-neighborhood filter gets caught
assert.ok(adjacencySeen > 0, 'mine-adjacent spawns are expected and allowed');
});
