Back to Shiftfield
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.

tests/chaos.test.mjs
// 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');
});