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.

src/game.ts
/* Minesweeper DX — Phase 2 "Chaos" layer.
 *
 * Turns advance on EFFECTIVE player actions only:
 *  - a reveal that clears at least one tile (a flood of 30 still = 1 turn)
 *  - a flag placement OR a flag removal (either toggle is a real state change)
 *  - a successful ability use — exactly ONE turn total, even when the ability
 *    itself flags (seeker) or reveals tiles (radar); the internal write must
 *    never re-commit
 *  - a shield-absorbed detonation (the charge was consumed; run continues)
 * Uneffective attempts cost NOTHING and tick nothing:
 *  - ice-blocked reveal/flag, flag rejected on REVEALED ground, a click on
 *    already-revealed tiles, a failed ability (no Flow / on cooldown)
 * Hazard and shift timers run off the turn counter.
 *
 * First-click safety (classic Minesweeper law): the opening reveal of every
 * board NEVER detonates. A mine under the first tile the player opens moves to
 * another HIDDEN, mine-free, hazard-free tile before the tile is opened. The
 * guarantee is spent only by a reveal that actually opens tiles — ice-blocked
 * attempts, clicks on flagged/opened ground, and radar (intrinsically mine-
 * free) neither spend nor bypass it.
 *
 * Waves = boards (depth).
 *  - depth >= 6:  The Shifter is active (uniform toroidal shifts on a timer)
 *  - depth >= 11: environmental hazards appear (spawns on wave advance)
 *
 * Ice:      blocks interaction on its tile for `iceDuration` turns, then
 *           reverts to HIDDEN. Does NOT block mine shifts.
 * Fire:     spreads on ODD player turns of each board (turn counters 1, 3, 5
 *           ...; a per-board counter, reset by advance()). Each burning tile
 *           randomly ignites UP TO 2 of its eligible 8-neighbors: HIDDEN,
 *           unflagged, not ICE, not already FIRE — and mine tiles ARE valid
 *           ignition targets. RULES (player ruling 2026-10-27, supersedes the
 *           Phase 2 all-8-directions / fireInterval-cadence / adjacency-
 *           lethality behavior):
 *           - Initial spawns (advance, spawnFire) NEVER land on a mine tile;
 *             spawning next to a mine is fine (adjacency is harmless).
 *           - A fire that SPREADS ONTO a tile with a mine under it detonates
 *             that mine: MINE_ON_FIRE (shield may absorb; the ignited fire
 *             tile is extinguished by the absorb).
 *           - Fire merely ADJACENT to a mine: no effect at all. Fire only
 *             stops at revealed/flagged/ice tiles and existing fire.
 *           - Revealing a fire tile extinguishes it — DIRECTLY or via a
 *             flood cascade (ruling 2026-10-28: fire is NOT a flood wall; a
 *             fire tile opens exactly as its plain hidden counterpart would).
 *             Flagging also extinguishes it (a flag then locks the tile as
 *             usual). Fire only exists on HIDDEN tiles.
 *
 * Flow:     +flowPerSafe per player ACTION that clears tiles (a flood-open
 *           of 30 tiles still pays once), +flowPerClear per board clear.
 *           Radar grants nothing — it is a pure Flow sink.
 *           GAINS clamp at the Flow ceiling: 100 base, +100 per Flow
 *           Capacitor Workshop level, max 500 (ruling 2026-10-27,
 *           flow_cap_ruling). Excess over the ceiling is wasted.
 *
 * Abilities: Flow-cost, turn-based cooldown, one player turn each.
 *   radar        — reveals the safe subset of a 3×3 square (50f — steep on
 *                  purpose, rulings 2026-10-27 and 2026-10-29: info is the
 *                  strongest offense and was still over-dominating)
 *   seeker       — flags one mine (50f, same steep rationale)
 *   shields      — NOT an ability any more (ruling 2026-10-27,
 *                  shop_flow_rebalance): the Workshop Shield level is the
 *                  number of charges a RUN STARTS with (max 5). No Flow
 *                  purchase, no recharge — once spent, spent. Each charge
 *                  absorbs the next mine hit:
 *                  MINE_ON_FIRE → extinguish the fire TILE under/ignited-on
 *                  the mine (the exact tile carrying the mine-fire contact,
 *                  whether a spread ignited the mine tile or a shift moved a
 *                  mine onto fire) and clear the explosion. Ruling
 *                  2026-10-27: fire merely ADJACENT to a mine is harmless, so
 *                  the absorb targets the single contact tile, NOT a whole
 *                  "contact front" (the old front-defuse is obsolete —
 *                  adjacency no longer detonates);
 *                  MINE_ON_REVEALED → flag the detonated mine;
 *                  MINE_ON_MINE (shift collision, ruling 2026-10-27) → flag
 *                  the collision tile. The anchor rule then freezes the
 *                  chain: nothing moves out of or into a FLAGGED tile, so the
 *                  collision can never recur while the flag stands.
 *                  Every absorb publishes `absorbed` so the shielded-boom
 *                  fx/sound/status surface (the explosion itself clears
 *                  inside the same commit and is invisible to the UI).
 *   stabilizer   — Workshop-gated (ruling 2026-10-27): pauses the shift
 *                  countdown for stabilizerDurationFor(level) turns —
 *                  Lv1 3 · Lv2 5 · Lv3 7 · Lv4 10. Cooldown stays 10 turns
 *                  at every level, so Lv4 recast-on-cooldown = permanent
 *                  Shifter lockout. Level 0 = LOCKED: using it fails at NO
 *                  cost, like the other Workshop abilities.
 */

import {
  MinesweeperEngine,
  GRID_SIZE,
  type ExplosionCause,
} from './engine.ts';
import {
  ZERO_UPGRADES,
  flowCapFor,
  radarOffsets,
  stabilizerDurationFor,
  type UpgradeLevels,
} from './upgrades.ts';

export const SHIFTS_MIN_DEPTH = 6;
export const HAZARDS_MIN_DEPTH = 11;
/* Hazard spawn counts per NEW board (player ruling 2026-10-27): board #N
 * since hazards began (N = depth - HAZARDS_MIN_DEPTH + 1) starts at 1 ICE +
 * 1 FIRE and grows: ICE +1 every board (cap 15), FIRE +1 every odd board
 * (cap 5). Depth 15 → 3 fire + 5 ice; caps reached at depth 25 (ice) and
 * depth 19 (fire). */
export const HAZARD_ICE_CAP = 15;
export const HAZARD_FIRE_CAP = 5;

export interface ChaosConfig {
  iceDuration: number; // turns ice blocks a tile
  flowPerSafe: number;
  flowPerClear: number;
}

export const DEFAULT_CHAOS: ChaosConfig = {
  iceDuration: 3,
  flowPerSafe: 5,
  flowPerClear: 20,
};

export const ABILITIES = {
  // Radar/Seeker Flow costs raised 5f→30f (ruling 2026-10-27), then again
  // 30f→50f (ruling 2026-10-29, ability_cost_rebalance): at 30f a mid-run
  // board still self-funds several casts; Intel must stay a rare spend.
  // Cooldowns unchanged.
  radar: { cost: 50, cooldown: 2 },
  seeker: { cost: 50, cooldown: 3 },
  // Shield is NO LONGER a Flow ability (ruling 2026-10-27): the Workshop
  // level is the number of charges a run STARTS with — see GameEngine ctor.
  // Stabilizer cooldown is 10 at EVERY Workshop level (ruling 2026-10-27):
  // only the hold duration scales, so Lv4 (10-turn hold) spans the cooldown.
  // Cast cost raised 10f→30f (ruling 2026-10-29, ability_cost_rebalance):
  // at 10f the Shifter threat was trivially rented away.
  stabilizer: { cost: 30, cooldown: 10 },
} as const;

const DIRECTIONS: ReadonlyArray<readonly [number, number]> = [
  [1, 0], [-1, 0], [0, 1], [0, -1],
  [1, 1], [1, -1], [-1, 1], [-1, -1],
];

function pickDirection(rng: () => number): readonly [number, number] {
  return DIRECTIONS[Math.floor(rng() * DIRECTIONS.length)];
}

/** Master spec §5.2, expressed in player turns. */
export function shiftIntervalFor(depth: number): number {
  return Math.max(10, Math.floor(45 / (1 + depth * 0.1)));
}

/** Hard cap on per-board mines (ruling 2026-10-27): 50 = 50% density, hit at depth 175. */
export const MINE_COUNT_CAP = 50;

/** Master spec §5.2 (linear scaling, capped at MINE_COUNT_CAP). */
export function mineCountFor(depth: number): number {
  return Math.min(
    MINE_COUNT_CAP,
    Math.floor(GRID_SIZE * GRID_SIZE * 0.15 + depth * 0.02 * GRID_SIZE),
  );
}

export class GameEngine {
  world: MinesweeperEngine;
  cfg: ChaosConfig;
  depth: number;
  seed: number;
  /** Workshop ability levels (Phase 3). radar/seeker/shield at 0 = LOCKED. */
  upgrades: UpgradeLevels;
  turn = 0;
  /** Player turns elapsed on the CURRENT board (reset by advance()). Fire
   * spreads on ODD values of this counter — so a fresh board always gets its
   * first spread after its own turn 1, independent of run-wide turn parity. */
  boardTurn = 0;
  flow = 0;
  /** idx → turns remaining until the ice reverts to HIDDEN. */
  ice: Map<number, number> = new Map();
  /** Active fire tiles (always on HIDDEN tiles). */
  fire: Set<number> = new Set();
  /** Turns until the next scheduled shift (0 while shifts disabled). */
  nextShiftIn = 0;
  /** Direction the next shift will use (announced in advance). */
  telegraph: readonly [number, number] | null = null;
  /** Cell index of the most recent shift collision (MINE_ON_MINE), consumed
   * by resolveShield() to flag the collision tile. Reset per board. */
  lastShiftCollision: number | null = null;
  /** Turns remaining on a Stabilizer pause. */
  stabilizedFor = 0;
  /** abilityId → turns remaining until usable. */
  cooldowns: Map<string, number> = new Map();
  /** Shield charges left for THIS run. Set once at construction to the
   * Workshop Shield level (ruling 2026-10-27: a run STARTS with N shields;
   * mine-hit absorbs spend them, nothing recharges them in-run). */
  shieldCharges = 0;
  boardsCleared = 0;
  /**
   * First-click safety (classic Minesweeper law): while true, this board's
   * OPENING reveal cannot detonate — a mine under the first tile the player
   * opens relocates before the tile opens. Spent the moment a reveal truly
   * opens HIDDEN ground; ice-blocked attempts, clicks on flagged or
   * already-open ground open nothing and do NOT spend it. Radar neither
   * spends nor bypasses it (radar is intrinsically mine-free). Re-armed on
   * every new board (advance() / a fresh GameEngine).
   */
  firstClickSafe = true;
  /**
   * Set by resolveShield() the moment a charge absorbs a detonation, consumed
   * by Board.diffFx() (shielded-boom fx + sound + status). The explosion
   * clears inside the same commit(), so the UI has no other way to see it.
   * Reseeds (advance(), newRun, QA engine swaps) clear it silently.
   */
  absorbed: { cause: ExplosionCause; x: number; y: number } | null = null;

  constructor(
    depth = 0,
    cfg: ChaosConfig = DEFAULT_CHAOS,
    seed = 1,
    world?: MinesweeperEngine,
    upgrades: UpgradeLevels = ZERO_UPGRADES,
  ) {
    this.depth = depth;
    this.cfg = cfg;
    this.world = world ?? new MinesweeperEngine(seed, mineCountFor(depth));
    this.seed = seed;
    // SAFE default: an engine knows NOTHING unless levels are explicitly
    // granted (Workshop purchases or a test passing MAX_UPGRADES). Defaulting
    // to MAX was a trap — board.ts builds with defaults, and fresh saves
    // woke with 5 free shield charges (regression pinned in browser suite).
    this.upgrades = upgrades;
    // Ruling 2026-10-27: a run STARTS with exactly `shield_lvl` charges.
    // advance() never refills them — spend them on absorbs and that's it.
    this.shieldCharges = upgrades.shield;
    if (this.depth >= SHIFTS_MIN_DEPTH) {
      this.nextShiftIn = shiftIntervalFor(this.depth);
      this.telegraph = pickDirection(this.world.rng);
    }
  }

  // ------------------------------ helpers --------------------------------

  idx(x: number, y: number): number {
    return this.world.wrapY(y) * this.world.width + this.world.wrapX(x);
  }

  tile(x: number, y: number): ReturnType<MinesweeperEngine['tileAt']> {
    return this.world.tileAt(x, y);
  }

  iceAt(x: number, y: number): boolean {
    return this.ice.has(this.idx(x, y));
  }

  fireAt(x: number, y: number): boolean {
    return this.fire.has(this.idx(x, y));
  }

  canInteract(x: number, y: number): boolean {
    return !this.iceAt(x, y) && this.tile(x, y).state !== 'FLAGGED';
  }

  /** A tile can host ice or fire only if HIDDEN, mine-free, and hazard-free. */
  private eligible(x: number, y: number): boolean {
    const t = this.tile(x, y);
    const c = this.idx(x, y);
    return t.state === 'HIDDEN' && !t.hasMine && !this.ice.has(c) && !this.fire.has(c);
  }

  // --------------------------- player actions ---------------------------

  /**
   * Reveal a tile. Ice blocks the attempt at NO cost (no turn). Revealing
   * a fire tile extinguishes it and counts as a normal safe clear.
   *
   * Flood: a zero-adjacent safe tile cascades open (see engine.reveal). The
   * flood treats ICE tiles as walls (a hazard needing an explicit player
   * action to clear) but NOT FIRE: a fire tile opens exactly as the plain
   * hidden tile underneath it would — zero-adjacent fire conducts, numbered
   * fire opens as border — and opening a fire tile extinguishes it (player
   * ruling 2026-10-28, fire_flood_reveal_ruling_2026-10-28: fire never
   * stops a flood).
   *
   * Turn: +1 only when the action is EFFECTIVE — at least one tile moved
   * HIDDEN→REVEALED, or the detonation was absorbed by a shield charge (the
   * charge was consumed and the run continues). Clicking already-open ground
   * clears nothing: no turn. A fatal, unshielded detonation ends the run:
   * no turn is committed.
   *
   * Flow: +flowPerSafe ONCE per player action that clears at least one tile,
   * regardless of how many tiles the flood opened.
   */
  reveal(x: number, y: number): 'safe' | 'detonated' | 'blocked' {
    const cell = this.idx(x, y);
    if (this.ice.has(cell)) return 'blocked';
    this.protectOpeningReveal(x, y);
    if (this.fire.has(cell)) this.fire.delete(cell); // reveal extinguishes fire
    const cleared = this.floodReveal(x, y);
    if (this.world.explosion) {
      this.resolveShield();
      // Shield absorbed the hit → effective action: exactly ONE turn.
      if (!this.world.explosion) this.commit();
      return 'detonated';
    }
    if (cleared === 0) return 'safe'; // already-open ground: no-op, no turn
    this.addFlow(this.cfg.flowPerSafe);
    this.commit();
    return 'safe';
  }

  /**
   * BFS flood-open sharing the engine's zero-region rule, with ICE as an
   * extra wall. FIRE is NOT a wall (ruling 2026-10-28): a fire tile opens
   * and is extinguished exactly like the plain hidden tile underneath it.
   * The clicked tile is opened directly by the caller's
   * fire/lock bookkeeping; this returns how many tiles moved HIDDEN→REVEALED
   * (0 when the click hit an already-revealed tile).
   */
  private floodReveal(x: number, y: number): number {
    // Clicking an already-REVEALED tile opens nothing and pays nothing.
    if (this.tile(x, y).state === 'REVEALED') return 0;
    const start = this.world.reveal(x, y, { flood: false });
    if (start.result === 'detonated' || start.cleared === 0) return start.cleared;
    if (this.world.adjacentMineCount(x, y) !== 0) return start.cleared;
    let cleared = start.cleared;
    const seen = new Set<number>([this.idx(x, y)]);
    const queue: Array<[number, number]> = [[this.world.wrapX(x), this.world.wrapY(y)]];
    while (queue.length > 0) {
      const [cx, cy] = queue.shift() as [number, number];
      for (const [dx, dy] of DIRECTIONS) {
        const nx = this.world.wrapX(cx + dx);
        const ny = this.world.wrapY(cy + dy);
        const ncell = this.idx(nx, ny);
        if (seen.has(ncell)) continue;
        seen.add(ncell);
        if (this.ice.has(ncell)) continue; // ice: wall
        const r = this.world.reveal(nx, ny, { flood: false });
        if (r.cleared === 0) continue; // flagged neighbor: wall, doesn't conduct
        // Fire is not a flood wall (ruling 2026-10-28): a fire tile the flood
        // reaches opens like any hidden tile, and opening extinguishes it.
        if (this.fire.has(ncell)) this.fire.delete(ncell);
        cleared += r.cleared;
        if (this.world.adjacentMineCount(nx, ny) === 0) queue.push([nx, ny]);
      }
    }
    return cleared;
  }

  /**
   * First-click safety gate, run at the top of every player reveal (before
   * any state write). If this board's opening reveal has not happened yet and
   * THIS click is about to open a HIDDEN tile, spend the guarantee — and if a
   * mine sits under that tile, move it away first so the opening can never
   * detonate. Clicks on FLAGGED or already-REVEALED ground open nothing, so
   * they neither spend nor need the guarantee.
   */
  private protectOpeningReveal(x: number, y: number): void {
    if (!this.firstClickSafe) return;
    const t = this.tile(x, y);
    if (t.state !== 'HIDDEN') return; // opens nothing: guarantee stays armed
    if (t.hasMine) this.relocateMineOff(x, y);
    this.firstClickSafe = false;
  }

  /**
   * Move the mine at (x, y) to another HIDDEN, mine-free tile. Player ruling
   * 2026-10-27 (rule 2): a first-click-relocated mine may NEVER land on a
   * live fire tile — fire tiles are hard-excluded from every candidate pool.
   * Within the fire-free pool it still prefers ice-free ground (ice is a
   * temporary hazard, not a rule-2 violation). The clicked tile itself is a
   * mine and so can never be its own destination. Direct state writes (not
   * placeMineAt): this is a relocation, not a placement, and MINE_ON_MINE
   * must not fire. Proximity numbers are read live every refresh (board.ts),
   * so moving a mine needs no re-render pass.
   */
  private relocateMineOff(x: number, y: number): void {
    const w = this.world;
    const safeSpots: number[] = []; // HIDDEN, mine-free, fire-free, ice-free
    const iceSpots: number[] = []; // HIDDEN, mine-free, fire-free (ice OK)
    for (const t of w.tiles) {
      if (t.hasMine || t.state !== 'HIDDEN') continue;
      const c = t.coord[1] * w.width + t.coord[0];
      if (this.fire.has(c)) continue; // rule 2: never under fire
      if (this.ice.has(c)) iceSpots.push(c);
      else safeSpots.push(c);
    }
    const pool = safeSpots.length > 0 ? safeSpots : iceSpots;
    if (pool.length === 0) return; // degenerate: nowhere to move (unreachable in play)
    const dest = pool[Math.floor(w.rng() * pool.length)];
    w.tileAt(x, y).hasMine = false;
    w.tiles[dest].hasMine = true;
  }

  /**
   * Flag a tile (lock). Classic Minesweeper toggle: right-clicking an
   * existing flag REMOVES it (back to HIDDEN). Flags may only be placed on
   * HIDDEN tiles — revealed tiles reject the attempt. Flagging a hidden FIRE
   * tile EXTINGUISHES the fire first (player ruling 2026-10-27) and then
   * places the lock.
   *
   * Turn: +1 on either toggle (place OR remove — both are real state changes:
   * a lock placed, a lock released). Ice-blocked and rejected attempts change
   * nothing: no turn.
   */
  flag(x: number, y: number): 'flagged' | 'unflagged' | 'blocked' | 'rejected' {
    const cell = this.idx(x, y);
    if (this.ice.has(cell)) return 'blocked';
    const t = this.tile(x, y);
    if (t.state === 'FLAGGED') {
      this.world.unflag(x, y);
      this.commit();
      return 'unflagged';
    }
    if (t.state !== 'HIDDEN') {
      // REVEALED tiles cannot host a flag. Nothing changed → no turn.
      return 'rejected';
    }
    if (this.fire.has(cell)) this.fire.delete(cell);
    this.world.flag(x, y);
    this.commit();
    return 'flagged';
  }

  // ------------------------------ tick work -----------------------------

  /** One player action => one turn of game time. */
  private commit(): void {
    this.turn += 1;
    this.boardTurn += 1;
    this.tickCooldowns();
    this.tickIce();
    if (this.boardTurn % 2 === 1) this.tickFire(); // spread on ODD turns of each board
    this.tickShift();
    this.resolveShield();
  }

  private tickCooldowns(): void {
    for (const [id, left] of this.cooldowns) {
      if (left <= 0) this.cooldowns.delete(id);
      else this.cooldowns.set(id, left - 1);
    }
  }

  private tickIce(): void {
    const expired: number[] = [];
    for (const [cell, left] of this.ice) {
      const next = left - 1;
      if (next <= 0) expired.push(cell);
      else this.ice.set(cell, next);
    }
    for (const cell of expired) this.ice.delete(cell);
  }

  /** Fire spread (player ruling 2026-10-27): on odd board turns each burning
   * tile randomly ignites UP TO 2 eligible neighbors — HIDDEN, unflagged,
   * not ICE, not already FIRE (mine tiles ARE valid targets). Fire onto a
   * mine tile detonates it (MINE_ON_FIRE); mere adjacency to a mine does
   * nothing. The ENTIRE wave is committed before any detonation it causes is
   * recorded, so the spread never freezes mid-wave against a mine. */
  private tickFire(): void {
    if (this.fire.size === 0) return;
    const additions = new Set<number>();
    let ignitedMine = false;
    for (const cell of this.fire) {
      const x = cell % this.world.width;
      const y = Math.floor(cell / this.world.width);
      const candidates: number[] = [];
      for (const dir of DIRECTIONS) {
        const nx = this.world.wrapX(x + dir[0]);
        const ny = this.world.wrapY(y + dir[1]);
        const target = ny * this.world.width + nx;
        const t = this.world.tileAt(nx, ny);
        if (t.state !== 'HIDDEN') continue; // revealed/flagged: no spread
        if (this.ice.has(target)) continue; // ice blocks fire spread (assumption)
        if (this.fire.has(target) || additions.has(target)) continue;
        candidates.push(target);
      }
      // Pick up to 2 distinct eligible neighbors, uniformly (partial
      // Fisher-Yates over the candidate list, using the world's seeded rng).
      const picks = Math.min(2, candidates.length);
      for (let i = 0; i < picks; i++) {
        const j = i + Math.floor(this.world.rng() * (candidates.length - i));
        const swap = candidates[i];
        candidates[i] = candidates[j];
        candidates[j] = swap;
        const target = candidates[i];
        additions.add(target);
        if (this.world.tiles[target].hasMine) ignitedMine = true;
      }
    }
    for (const cell of additions) this.fire.add(cell);
    if (ignitedMine) this.world.explosion = 'MINE_ON_FIRE';
  }

  /**
   * Shift countdown. A Stabilizer pauses it. When the countdown hits zero the
   * telegraphed shift fires, mines landing on fire detonate (MINE_ON_FIRE), and
   * a new telegraph direction is announced. A shift mine-on-mine COLLISION
   * outranks fire contact: the engine already aborted the shift, so record the
   * collision cell and let resolveShield() see MINE_ON_MINE (it would otherwise
   * be overwritten as MINE_ON_FIRE here).
   *
   * T1 fix (audit 2026-10-28): the countdown re-arm + direction reroll run
   * BEFORE the explosion early-returns. An exploding (or aborted) shift still
   * spent its beat — if the reset were skipped, nextShiftIn would stick at 0
   * and go negative, firing a full shift + pulse storm on EVERY later turn and
   * locking the telegraph forever.
   */
  private tickShift(): void {
    if (this.depth < SHIFTS_MIN_DEPTH) return;
    if (this.stabilizedFor > 0) {
      this.stabilizedFor -= 1;
      return;
    }
    this.nextShiftIn -= 1;
    if (this.nextShiftIn > 0) return;
    const dir = this.telegraph ?? [1, 0];
    const res = this.world.shift(dir[0], dir[1]);
    this.lastShiftCollision = res.collisionCell;
    this.nextShiftIn = shiftIntervalFor(this.depth);
    this.telegraph = pickDirection(this.world.rng);
    if (this.world.explosion) return; // MINE_ON_MINE outranks fire contact
    for (const t of this.world.tiles) {
      const cell = t.coord[1] * this.world.width + t.coord[0];
      if (t.hasMine && this.fire.has(cell)) {
        this.world.explosion = 'MINE_ON_FIRE';
        return;
      }
    }
  }

  /** A stored shield charge absorbs one mine hit. The absorb must remove the
   * HAZARD that caused the hit, not just the explosion flag. Ruling
   * 2026-10-27 (supersedes the earlier "contact front" ruling): fire only
   * endangers a mine by IGNITING the mine's own tile (spread) or a mine
   * shifting ONTO fire — adjacency is harmless. So the absorb extinguishes
   * exactly the fire tile(s) that carry a mine, nothing more; a re-trigger
   * loop is impossible because the remaining fire no longer contacts that
   * mine in a dangerous way. */
  private resolveShield(): void {
    if (!this.world.explosion) return;
    if (this.world.explosion === 'MINE_ON_FIRE' && this.shieldCharges > 0) {
      this.shieldCharges -= 1;
      this.world.explosion = null;
      // Extinguish the fire tile(s) WITH A MINE ON THEM — the exact contact
      // that detonated (spread-ignited mine tile, or shift-landed mine) —
      // and FLAG the mine tile (player ruling 2026-10-27: the absorb locks
      // the mine down like a normal flag would). A FLAGGED tile is not a
      // spread candidate, so the saved mine can never re-ignite while the
      // flag stands; unflagging re-opens it, same as any manual flag.
      let contactCell: number | null = null;
      for (const cell of [...this.fire]) {
        const x = cell % this.world.width;
        const y = Math.floor(cell / this.world.width);
        if (!this.world.tileAt(x, y).hasMine) continue;
        this.fire.delete(cell);
        this.world.flag(x, y);
        contactCell = cell;
      }
      this.absorbed = {
        cause: 'MINE_ON_FIRE',
        x: contactCell === null ? 4 : contactCell % this.world.width,
        y: contactCell === null ? 4 : Math.floor(contactCell / this.world.width),
      };
    } else if (this.world.explosion === 'MINE_ON_REVEALED' && this.shieldCharges > 0) {
      this.shieldCharges -= 1;
      this.world.explosion = null;
      let flagged: [number, number] | null = null;
      for (const t of this.world.tiles) {
        if (t.hasMine && t.state === 'REVEALED') {
          this.world.flag(t.coord[0], t.coord[1]);
          flagged ??= [t.coord[0], t.coord[1]];
        }
      }
      this.absorbed = {
        cause: 'MINE_ON_REVEALED',
        x: flagged ? flagged[0] : 4,
        y: flagged ? flagged[1] : 4,
      };
    } else if (this.world.explosion === 'MINE_ON_MINE' && this.shieldCharges > 0) {
      // Shift collision (player ruling 2026-10-27): the shield stops the
      // blast and FLAGS the collision tile. The stacked pair never exists as
      // two mines on one tile (the engine aborts the whole shift), so the
      // hazard to defuse is the collision SETTING: one mine anchored in place
      // with another lined up behind it. Flagging the standing mine's tile
      // anchors the chain under the §2.2 anchor rule — nothing moves into or
      // out of a FLAGGED tile — so the same collision can never recur while
      // the flag stands (unflagging re-opens it, same as any manual flag).
      this.shieldCharges -= 1;
      this.world.explosion = null;
      const cell = this.lastShiftCollision;
      this.lastShiftCollision = null;
      let flagged: [number, number] | null = null;
      if (cell !== null) {
        const x = cell % this.world.width;
        const y = Math.floor(cell / this.world.width);
        this.world.flag(x, y); // flag NEVER detonates (spec §2.1 law)
        flagged = [x, y];
      }
      this.absorbed = {
        cause: 'MINE_ON_MINE',
        x: flagged ? flagged[0] : 4,
        y: flagged ? flagged[1] : 4,
      };
    }
  }

  // ------------------------------- abilities ------------------------------

  /** Workshop gate: an ability whose level is 0 was never purchased.
   * Stabilizer has no Workshop track (always available). */
  isUnlocked(id: keyof UpgradeLevels): boolean {
    return this.upgrades[id] > 0;
  }

  /** Current Flow ceiling (ruling 2026-10-27, flow_cap_ruling): 100 base,
   * +100 per Flow Capacitor Workshop level, max 500. Read live from the
   * Workshop levels so a mid-run purchase takes effect on the next run's
   * engine (levels are passed at construction). */
  flowCap(): number {
    return flowCapFor(this.upgrades.flow_cap);
  }

  /** The ONLY sanctioned Flow gain path: clamps at the ceiling so the
   * Capacitor purchase can actually bind (ruling 2026-10-27). Spending
   * (pay()) and test grants keep touching `flow` directly. */
  private addFlow(amount: number): void {
    this.flow = Math.min(this.flowCap(), this.flow + amount);
  }

  private canUse(id: string, cost: number): boolean {
    if (this.world.explosion) return false;
    if (id in this.upgrades && !this.isUnlocked(id as keyof UpgradeLevels)) return false;
    return this.flow >= cost && (this.cooldowns.get(id) ?? 0) <= 0;
  }

  private pay(id: string, cost: number, cooldown: number): boolean {
    if (!this.canUse(id, cost)) return false;
    this.flow -= cost;
    this.cooldowns.set(id, cooldown);
    return true;
  }

  /** Radar Pulse: reveals the safe tiles of the level's sweep footprint
   * (Lv1 3×3 · Lv2 5×5-circle · Lv3 full 5×5 — src/upgrades.ts). Pure
   * expenditure — radar reveals WITHOUT a flood (it promises a literal
   * shape) and grants NO Flow: it is a Flow sink, not a Flow source. */
  radar(cx: number, cy: number, spec: { cost: number; cooldown: number } = ABILITIES.radar): boolean {
    if (!this.pay('radar', spec.cost, spec.cooldown)) return false;
    for (const [dx, dy] of radarOffsets(this.upgrades.radar)) {
      const x = this.world.wrapX(cx + dx);
      const y = this.world.wrapY(cy + dy);
      const t = this.tile(x, y);
      if (t.state !== 'HIDDEN' || t.hasMine) continue;
      if (this.iceAt(x, y) || this.fireAt(x, y)) continue;
      this.world.reveal(x, y, { flood: false });
    }
    this.commit();
    return true;
  }

  /** Seeker: flags up to `seeker_lvl` distinct unfound mines (one turn total). */
  seeker(spec: { cost: number; cooldown: number } = ABILITIES.seeker): boolean {
    const count = this.upgrades.seeker;
    const mines = this.world.tiles.filter((t) => t.hasMine && t.state !== 'FLAGGED');
    if (count <= 0 || mines.length === 0) return false;
    if (!this.pay('seeker', spec.cost, spec.cooldown)) return false;
    // Partial Fisher-Yates: pick up to `count` distinct mines. The swapped-IN
    // element at index i is the pick (flagging the pre-swap element would
    // double-flag whenever j > i).
    const picks = Math.min(count, mines.length);
    for (let i = 0; i < picks; i++) {
      const j = i + Math.floor(this.world.rng() * (mines.length - i));
      const swap = mines[i];
      mines[i] = mines[j];
      mines[j] = swap;
      this.world.flag(mines[i].coord[0], mines[i].coord[1]);
    }
    this.commit();
    return true;
  }

  /** Stabilizer (Workshop-gated, ruling 2026-10-27): pauses the shift
   * countdown for stabilizerDurationFor(level) turns — Lv1 3 · Lv2 5 ·
   * Lv3 7 · Lv4 10. Level 0 fails through canUse like the other gated
   * abilities (no flow, no cooldown, no turn). */
  stabilizer(spec: { cost: number; cooldown: number } = ABILITIES.stabilizer): boolean {
    if (!this.pay('stabilizer', spec.cost, spec.cooldown)) return false;
    this.stabilizedFor = stabilizerDurationFor(this.upgrades.stabilizer);
    this.commit();
    return true;
  }

  // ----------------------------- hazards ----------------------------------

  /** Authoring/tests: place an ICE hazard on an eligible tile. */
  spawnIce(x: number, y: number): boolean {
    if (!this.eligible(x, y)) return false;
    this.ice.set(this.idx(x, y), this.cfg.iceDuration);
    return true;
  }

  /** Authoring/tests: place a FIRE hazard on an eligible tile. */
  spawnFire(x: number, y: number): boolean {
    if (!this.eligible(x, y)) return false;
    this.fire.add(this.idx(x, y));
    return true;
  }

  // --------------------------- progression (wave advance) ------------------

  /** "All non-mine tiles cleared": every non-mine tile REVEALED and no live
   * fire (fire can only be cleared by revealing, which makes it REVEALED).
   * Ice reverts automatically; while active it is still HIDDEN → not cleared. */
  get isBoardCleared(): boolean {
    return this.world.isBoardCleared() && this.fire.size === 0;
  }

  /**
   * Progression hook (Phase 3): fired by advance() AT the moment a board is
   * cleared, BEFORE the depth increment — the cleared board is depth
   * `depth`. main.ts wires this to ProgressionManager.creditClear() so run
   * scrap mints exactly once per clear, including clears driven by tests or
   * the QA hook. Returns the scrap minted for this clear.
   */
  onBoardCleared: ((depth: number) => number) | null = null;

  /** Advance to the next wave: fires the scrap hook, advances depth, rebuilds
   * the board at the depth-scaled mine density, resets hazards, and spawns
   * scaling hazards at depth >= 11 (ICE = min(N,15), FIRE = min(⌈N/2⌉,5),
   * N = hazard-board number). */
  advance(): void {
    if (!this.isBoardCleared) return;
    this.onBoardCleared?.(this.depth);
    this.addFlow(this.cfg.flowPerClear);
    this.depth += 1;
    this.boardsCleared += 1;
    this.seed = (this.seed + 0x9e3779b9) >>> 0;
    this.world = new MinesweeperEngine(this.seed, mineCountFor(this.depth));
    this.firstClickSafe = true; // new board ⇒ opening protection re-arms
    this.ice.clear();
    this.fire.clear();
    this.stabilizedFor = 0;
    this.lastShiftCollision = null;
    this.nextShiftIn = this.depth >= SHIFTS_MIN_DEPTH ? shiftIntervalFor(this.depth) : 0;
    this.telegraph = this.depth >= SHIFTS_MIN_DEPTH ? pickDirection(this.world.rng) : null;
    if (this.depth >= HAZARDS_MIN_DEPTH) {
      const coords = this.world.tiles.map((t) => t.coord as readonly [number, number]);
      // Spawn counts scale per new board (ruling 2026-10-27): board #N gets
      // min(N, 15) ICE and min(ceil(N/2), 5) FIRE — ice +1 every board,
      // fire +1 on every ODD board (depth 15 → 5 ice + 3 fire).
      const boardNumber = this.depth - HAZARDS_MIN_DEPTH + 1;
      const iceTarget = Math.min(boardNumber, HAZARD_ICE_CAP);
      const fireTarget = Math.min(Math.ceil(boardNumber / 2), HAZARD_FIRE_CAP);
      for (let i = 0; i < iceTarget; i++) {
        // recompute each pass: a fresh spawn makes its tile ineligible
        const pool = coords.filter(([x, y]) => this.eligible(x, y));
        if (pool.length === 0) break; // board too dense for all spawns
        const [x, y] = pool[Math.floor(this.world.rng() * pool.length)];
        this.spawnIce(x, y);
      }
      // Fire spawn NEVER lands ON a mine tile (eligible() enforces it; player
      // ruling 2026-10-27). Spawning in a mine's neighborhood is allowed —
      // adjacency is harmless under the current fire rules; only SPREADING
      // onto the mine tile detonates. (The old "no mine in the 8-neighborhood"
      // spawn filter belonged to the retired adjacency-lethality model.)
      for (let i = 0; i < fireTarget; i++) {
        const pool = coords.filter(([x, y]) => this.eligible(x, y));
        if (pool.length === 0) break;
        const [x, y] = pool[Math.floor(this.world.rng() * pool.length)];
        this.spawnFire(x, y);
      }
    }
  }

  // ------------------------------- queries -----------------------------------

  isDead(): boolean {
    return this.world.explosion !== null;
  }

  explosionCause(): ExplosionCause | null {
    return this.world.explosion;
  }

  iceCount(): number {
    return this.ice.size;
  }

  fireCount(): number {
    return this.fire.size;
  }
}