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/board.ts
/* Minesweeper DX — playable board UI (Phase 2.5 integration).
 *
 * Renders the GameEngine's 10×10 world as a DOM grid and wires input:
 *   left-click / Enter   → reveal
 *   right-click / Space  → flag/anchor toggle (never detonates): places a
 *                          flag on HIDDEN ground, removes it from a flagged
 *                          tile; revealed tiles reject the attempt.
 *                          Long-press toggle is PRIMARY-BUTTON-ONLY: a
 *                          right-hold flags at press (contextmenu) and must
 *                          not be re-toggled at release.
 *   long-press (touch)   → flag/anchor toggle
 *   arrow keys           → move focus
 *
 * Targeted abilities (radar): when `pendingTarget` is armed, the NEXT click
 * (or Enter) on the board fires the ability at the clicked tile instead of
 * revealing — it never fires at the previously focused tile. Right-click or
 * long-press (flagging) cancels the arm.
 *
 * Pure UI shell over GameEngine (chaos + world). It holds no game rules:
 * ice/fire rendering reads GameEngine state, and every mutation goes through
 * engine methods, then refresh() + the onSync callback for HUD/overlays.
 */

import { GRID_SIZE } from './engine.js';
import { GameEngine } from './game.js';
import type { FxEvent } from './juice.js';

export type SyncFn = (game: GameEngine) => void;

/** Snapshot of the board used to derive juice events from engine diffs —
 * the engine stays juice-agnostic; the render sync point observes it. */
interface FxSnapshot {
  world: unknown;
  cells: string;
  mines: string;
  exploded: boolean;
  /** Shifter countdown at last paint — a countdown STEP toward <=3 turns
   * raises the shift-warning beat (ruling 2026-10-28). */
  shiftIn: number;
  /** One char per tile: live proximity digit on REVEALED safe ground, '.'
   * elsewhere. Digit changes between paints pulse that tile (the shift's
   * legible afterimage — mines are hidden, the numbers are not). */
  nums: string;
}

/** B1 cap (audit telegraph_depth6_bug_audit_2026-10-28): max .cell-count-pulse
 * cells per paint. A wide-open board's full shift flips 50–80 proximity digits;
 * the pulse is capped to this many leading-edge tiles (ranked in diffFx).
 * Tests assert against this symbol, never a duplicated literal. */
export const COUNT_PULSE_CAP = 24;

export class Board {
  game: GameEngine;
  readonly root: HTMLElement;
  /** Tile radar and other targeted abilities resolve against this. */
  focusedTile: [number, number] = [0, 0];
  /**
   * Targeted-ability hook: when set, the NEXT board click (or Enter) resolves
   * through this callback with the clicked tile instead of revealing, then
   * clears itself. Armed by the Radar button; cancelled by flagging, rebuild,
   * or re-arming with another ability. Return false to keep the arm (e.g. the
   * ability was unavailable and the player should retarget).
   */
  pendingTarget: ((x: number, y: number) => boolean) | null = null;

  /** Fired after every effective player action (after refresh + onSync):
   * main.ts uses it to check §4.1 run-state transitions (clear → DECISION,
   * death → SUMMARY) at the exact action points. */
  onAction: SyncFn | null = null;

  /** Phase 4 juice: derived engine observations (reveal spread, flags,
   * mine shifts, detonations) fire here from refresh(). Optional — the board
   * works with no listener attached. */
  onFx: ((ev: FxEvent) => void) | null = null;

  /** Last-rendered engine fingerprint (see FxSnapshot); null ⇒ reseed. */
  private fxSnap: FxSnapshot | null = null;
  /** Cells whose proximity digit changed on the observed shift — the paint
   * loop adds .cell-count-pulse to them (ruling 2026-10-28). */
  private pulseCells: number[] = [];
  /** cell key → animation delay (ms) for the flood-fade reveal wave. */
  private revealFx = new Map<string, number>();
  /** P5: last written (classes|wave) signature per cell key. Equal sig ⇒
   * equal textContent/aria-label (all inputs encoded in the classes), so the
   * whole cell paint is skipped. Cleared in rebuild() — fresh elements. */
  private paintCache = new Map<string, string>();
  /** true only inside afterAction(): fx events derive from PLAYER actions.
   * Direct engine pokes + refresh() from QA/tests reseed the fingerprint
   * silently — tests mutate the engine and re-render without ever clicking,
   * and a mid-flight reveal animation would corrupt geometry assertions. */
  private inAction = false;
  /** setFocus()-style chrome refreshes must not cancel a running reveal
   * wave (mousedown focuses the cell BEFORE click fires afterAction, so a
   * seed refresh lands between waves — classes ride through it). */
  private keepJuiceOnce = false;

  private readonly onSync: SyncFn;
  /** Engine-init hook: called with EVERY engine Board creates (constructor +
   * newRun) so the host can push Workshop levels and hook clear-credits onto
   * a fresh engine before its first DOM render. */
  private readonly onEngine: SyncFn;

  constructor(container: HTMLElement, onSync: SyncFn = () => {}, onEngine: SyncFn = () => {}) {
    this.onSync = onSync;
    this.onEngine = onEngine;
    this.game = new GameEngine(0, undefined, randomSeed());
    this.onEngine(this.game);
    this.root = document.createElement('div');
    this.root.className = 'board';
    container.append(this.root);
    this.rebuild();
    // Input listeners are DELEGATED on this.root and resolve cells from the
    // event target, so they survive rebuild()'s innerHTML swap. Bind ONCE,
    // here: binding inside rebuild() stacked a second set per rebuild, making
    // every right-click fire flag() N times (place→remove = flag vanishes,
    // N turns) — N grew with each advance()/newRun().
    this.bindInput();
    // B1/D4 (audit 2026-10-28): ONE delegated animationend on the root strips
    // .cell-count-pulse when the pulse ends — the old per-cell `once` listeners
    // attached one new listener per pulsing cell per shift (the
    // stacked_listener_fix.md lesson). The animationName guard is load-bearing:
    // reveal waves, the mine-reveal stagger, and ice/fire glows also bubble
    // animationend to this root. Delegation survives rebuild()'s innerHTML
    // swap; bound ONCE, here.
    this.root.addEventListener('animationend', (e) => {
      if (e.animationName === 'cell-count-pulse') {
        (e.target as HTMLElement).classList.remove('cell-count-pulse');
      }
    });
  }

  // ------------------------------- start / reset ---------------------------

  /** Fresh run at depth 0 (after death or manual reset). */
  newRun(): void {
    this.game = new GameEngine(0, undefined, randomSeed());
    this.onEngine(this.game);
    this.rebuild();
  }

  /** Rebuild the DOM grid from the current engine (also after advance()).
   * Input stays bound: listeners delegate on this.root (see constructor). */
  rebuild(): void {
    this.focusedTile = [0, 0];
    this.pendingTarget = null;
    this.fxSnap = null; // fresh DOM grid ⇒ reseed the fingerprint silently
    this.revealFx.clear();
    this.paintCache.clear(); // P5: fresh elements ⇒ nothing written yet
    this.root.innerHTML = this.buildGrid();
    this.refresh();
  }

  clear(): void {
    this.root.remove();
  }

  // ------------------------------- grid build ------------------------------

  private buildGrid(): string {
    const cells: string[] = [];
    for (let y = 0; y < GRID_SIZE; y++) {
      for (let x = 0; x < GRID_SIZE; x++) {
        cells.push(
          `<span class="cell" role="button" tabindex="-1" data-x="${x}" data-y="${y}" data-cell="${x},${y}"></span>`,
        );
      }
    }
    return cells.join('');
  }

  private cellAt(x: number, y: number): HTMLElement | null {
    return this.root.querySelector<HTMLElement>(`.cell[data-x="${x}"][data-y="${y}"]`);
  }

  // ------------------------------ input wiring -----------------------------

  private bindInput(): void {
    this.root.addEventListener('click', (e) => {
      e.preventDefault();
      const cell = this.cellFrom(e.target);
      if (!cell) return;
      const x = Number(cell.dataset.x);
      const y = Number(cell.dataset.y);
      this.setFocus(cell);
      if (this.pendingTarget) {
        // A targeted ability is armed: spend it on THIS tile, not the last
        // focused one. Resolving never reveals the tile.
        if (this.pendingTarget(x, y)) this.pendingTarget = null;
      } else {
        this.game.reveal(x, y);
      }
      this.afterAction();
    });

    // Long press (touch) → flag; a quick tap falls through to click.
    // PRIMARY BUTTON ONLY: a right-button press already flagged the tile via
    // contextmenu at press time — running the long-press toggle on its release
    // would REMOVE that flag (and arm the click suppressor below, eating the
    // player's next reveal). Track the button and gate on it.
    let pressStart: number | null = null;
    let pressCell: HTMLElement | null = null;
    let pressPrimary = false;
    // THE ONE FLAG PER GESTURE latch (mobile long-press fix 2026-10-29):
    // mobile browsers (Android Chrome, iOS Safari, WebViews) fire a NATIVE
    // `contextmenu` in the MIDDLE of a touch long-hold — the contextmenu
    // handler below already placed the flag at press time. The pointerup
    // long-press toggle must recognize that commit and NOT flag again: a
    // second flag() was the classic place→remove toggle, so the flag vanished
    // exactly when the finger released and two turns burned (player-reported
    // on device; workaround was sliding off the tile). `committedFlag` holds
    // the tile flagged by THIS gesture (either path); both paths consult it,
    // so the dedupe also covers platforms that fire contextmenu late or never.
    let committedFlag: { x: number; y: number } | null = null;
    this.root.addEventListener('pointerdown', (e) => {
      pressPrimary = e.button === 0;
      pressStart = performance.now();
      pressCell = this.cellFrom(e.target);
      committedFlag = null; // fresh gesture: clean slate
    });
    this.root.addEventListener('pointercancel', () => {
      // Gesture stolen (scroll, pull-to-refresh, system menu): nothing pending.
      pressStart = null;
      pressCell = null;
    });
    this.root.addEventListener('pointerup', (e) => {
      if (pressStart === null || pressCell === null) return;
      const primary = pressPrimary && e.button === 0;
      const dt = performance.now() - pressStart;
      const upCell = this.cellFrom(e.target);
      const sameCell =
        upCell !== null &&
        upCell.dataset.x === pressCell.dataset.x &&
        upCell.dataset.y === pressCell.dataset.y; // compare BOTH axes (x-only let a same-column release toggle another row)
      pressStart = null;
      if (!primary || !sameCell || dt < 400) return;
      const x = Number(pressCell.dataset.x);
      const y = Number(pressCell.dataset.y);
      pressCell = null;
      const spent = committedFlag !== null && committedFlag.x === x && committedFlag.y === y;
      committedFlag = null;
      if (!spent) {
        this.setFocus(this.cellAt(x, y));
        // Flagging is a different intent than targeting: disarm.
        this.pendingTarget = null;
        this.game.flag(x, y);
        this.afterAction();
      }
      // The gesture is spent either way: a synthesized post-long-press click
      // must never reveal. (Android Chrome usually skips that click entirely;
      // a `once` suppressor that never fires is harmless.)
      const suppress = (ev: MouseEvent) => {
        ev.stopPropagation();
        ev.preventDefault();
      };
      this.root.addEventListener('click', suppress, { once: true, capture: true });
    });

    // Right-click → flag (desktop input mapping) AND the mobile long-press
    // flag: mobile fires this natively mid-hold, so the flag lands at press
    // time — the long one flag per gesture latch below makes the eventual
    // pointerup recognize it instead of toggling the flag off again.
    this.root.addEventListener('contextmenu', (e) => {
      e.preventDefault();
      const cell = this.cellFrom(e.target);
      if (!cell) return;
      const x = Number(cell.dataset.x);
      const y = Number(cell.dataset.y);
      if (committedFlag !== null && committedFlag.x === x && committedFlag.y === y) return;
      committedFlag = { x, y };
      this.setFocus(cell);
      this.pendingTarget = null;
      this.game.flag(x, y);
      this.afterAction();
    });

    // Keyboard: arrows move focus; Enter reveals; Space flags.
    this.root.addEventListener('keydown', (e) => {
      const cell = this.cellFrom(e.target) ?? this.cellAt(...this.focusedTile);
      if (!cell) return;
      const x = Number(cell.dataset.x);
      const y = Number(cell.dataset.y);
      const move: Record<string, [number, number]> = {
        ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1],
      };
      if (move[e.key]) {
        e.preventDefault();
        const g = this.game.world;
        const nx = g.wrapX(x + move[e.key][0]);
        const ny = g.wrapY(y + move[e.key][1]);
        this.setFocus(this.cellAt(nx, ny));
      } else if (e.key === 'Escape') {
        e.preventDefault();
        if (this.pendingTarget) {
          this.pendingTarget = null;
          this.refresh();
          this.onSync(this.game);
        }
      } else if (e.key === 'Enter') {
        e.preventDefault();
        if (this.pendingTarget) {
          if (this.pendingTarget(x, y)) this.pendingTarget = null;
        } else {
          this.game.reveal(x, y);
        }
        this.afterAction();
      } else if (e.key === ' ') {
        e.preventDefault();
        this.pendingTarget = null;
        this.game.flag(x, y);
        this.afterAction();
      }
    });
  }

  private cellFrom(target: EventTarget | null): HTMLElement | null {
    const el = target as HTMLElement | null;
    return el?.dataset && el.dataset.x !== undefined ? el : null;
  }

  private setFocus(cell: HTMLElement | null): void {
    if (!cell) return;
    this.focusedTile = [Number(cell.dataset.x), Number(cell.dataset.y)];
    cell.focus({ preventScroll: true });
    this.keepJuiceOnce = true;
    this.refresh();
  }

  private afterAction(): void {
    this.inAction = true;
    try {
      this.refresh();
    } finally {
      this.inAction = false;
    }
    this.onSync(this.game);
    this.onAction?.(this.game);
  }

  // ------------------------------- render refresh --------------------------

  refresh(): void {
    const g = this.game;
    const dead = g.isDead();
    // Phase 4: derive juice events + the flood-fade stagger BEFORE painting,
    // then fire them after the DOM is in sync (particle origins need live
    // cell rects, and the animation classes must ride the new classNames).
    const events = this.inAction ? this.diffFx() : (this.seedFx(), []);
    const keepJuice = this.keepJuiceOnce;
    this.keepJuiceOnce = false;
    this.root.classList.toggle('board-targeting', this.pendingTarget !== null);
    for (const child of Array.from(this.root.children) as HTMLElement[]) {
      const x = Number(child.dataset.x);
      const y = Number(child.dataset.y);
      const t = g.tile(x, y);
      const cls = ['cell', `cell-${t.state.toLowerCase()}`];
      if (g.iceAt(x, y)) cls.push('cell-ice');
      if (g.fireAt(x, y)) cls.push('cell-fire');
      if (x === this.focusedTile[0] && y === this.focusedTile[1]) cls.push('cell-focused');
      if (dead && t.hasMine) cls.push('cell-mine-reveal');
      const key = `${x},${y}`;
      let wave = this.revealFx.get(key);
      if (wave === undefined && keepJuice && child.style.getPropertyValue('--juice-delay')) {
        // carry the running wave through this chrome-only refresh
        wave = Number(child.style.getPropertyValue('--juice-delay').replace('ms', '')) || 0;
      }
      if (wave !== undefined) {
        cls.push('juice-reveal');
        child.style.setProperty('--juice-delay', `${wave}ms`);
      } else if (!keepJuice && child.style.getPropertyValue('--juice-delay')) {
        child.style.removeProperty('--juice-delay');
      }
      // Proximity number: REVEALED safe tiles show their live 8-neighbor
      // (toroidal) mine count. Read from the engine every refresh — never
      // cached — so mine shifts at depth >= 6 update the numbers on tiles
      // that were revealed long ago. Flagged/revealed-mine cells keep their
      // glyph overlays (::after/::before) and show no number.
      let count = 0;
      if (t.state === 'REVEALED' && !t.hasMine) {
        count = g.world.adjacentMineCount(x, y);
        if (count > 0) cls.push(`cell-num-${count}`);
      }
      // P5 perf: state/ice/fire/count are all encoded in the class string,
      // so an identical (classes|wave) signature ⇒ identical textContent and
      // aria-label too — skip all three DOM writes (100-cell full-board
      // className rewrite per action was invalidating the whole grid for
      // 1-2 changed tiles). rebuild() clears the cache (fresh elements).
      const sig = `${cls.join(' ')}|${wave ?? ''}`;
      if (this.paintCache.get(key) !== sig) {
        this.paintCache.set(key, sig);
        child.className = cls.join(' ');
        child.textContent = count > 0 ? String(count) : '';
        child.setAttribute(
          'aria-label',
          `${t.state}${g.iceAt(x, y) ? ' iced' : ''}${g.fireAt(x, y) ? ' burning' : ''}` +
            `${count > 0 ? ` ${count} adjacent mines` : ''} ${x} ${y}`,
        );
      }
    }
    this.revealFx.clear();
    // Count-pulse (ruling 2026-10-28): highlight tiles whose proximity digit
    // changed under an already-revealed tile — the shift's readable afterimage
    // without ever exposing a hidden mine. B1/D4 (audit 2026-10-28): the list
    // is capped + ranked in diffFx (COUNT_PULSE_CAP), the glow rides a
    // compositable ::before overlay (styles.css), and ONE delegated
    // animationend bound in the constructor strips the class — no per-cell
    // listeners. A later className repaint simply drops it, which is harmless.
    if (this.pulseCells.length > 0) {
      for (const i of this.pulseCells) {
        const el = this.root.children[i] as HTMLElement | undefined;
        if (el) el.classList.add('cell-count-pulse');
      }
      this.pulseCells = [];
    }
    if (this.onFx) {
      const emit = this.onFx;
      for (const ev of events) {
        try {
          emit(ev);
        } catch {
          /* juice is decoration — a listener failure never breaks the board */
        }
      }
    }
  }

  // ------------------------------ fx diffing --------------------------------

  /** Quietly (re)capture the fingerprint without producing events. */
  private seedFx(): void {
    const g = this.game;
    g.absorbed = null; // a reseed must not leak an absorbed-blast fx later
    this.pulseCells = [];
    let cells = '';
    let ids = '';
    let nums = '';
    for (let i = 0; i < g.world.tiles.length; i++) {
      const t = g.world.tiles[i];
      cells +=
        t.state === 'REVEALED' ? 'r' : t.state === 'FLAGGED' ? 'f' : t.hasMine ? 'M' : 'h';
      nums +=
        t.state === 'REVEALED' && !t.hasMine ? String(g.world.adjacentMineCount(t.coord[0], t.coord[1])) : '.';
      if (t.hasMine) ids += `${i},`;
    }
    this.fxSnap = {
      world: g.world, cells, mines: ids, exploded: g.isDead(),
      shiftIn: g.nextShiftIn, nums,
    };
    this.revealFx.clear();
  }

  /** Compare the live engine against the last-rendered fingerprint and turn
   * the delta into juice events + reveal-wave stagger delays. A world-object
   * swap (advance(), a QA-injected engine) is a RESEED, never a diff — a new
   * board must not fire fake shift/boom events. */
  private diffFx(): FxEvent[] {
    const g = this.game;
    const tiles = g.world.tiles;
    const W = g.world.width;
    let cells = '';
    let mines = '';
    let nums = '';
    for (let i = 0; i < tiles.length; i++) {
      const t = tiles[i];
      cells +=
        t.state === 'REVEALED' ? 'r' : t.state === 'FLAGGED' ? 'f' : t.hasMine ? 'M' : 'h';
      nums +=
        t.state === 'REVEALED' && !t.hasMine ? String(g.world.adjacentMineCount(t.coord[0], t.coord[1])) : '.';
      if (t.hasMine) mines += `${i},`;
    }
    const snap = this.fxSnap;
    const next: FxSnapshot = {
      world: g.world,
      cells,
      mines,
      exploded: g.isDead(),
      shiftIn: g.nextShiftIn,
      nums,
    };
    if (!snap || snap.world !== g.world) {
      this.fxSnap = next;
      return [];
    }
    const events: FxEvent[] = [];
    // cell state transitions
    if (snap.cells !== cells) {
      const newRevealed: number[] = [];
      let flagAdd = -1;
      let flagMine = false;
      let flagRemove = -1;
      for (let i = 0; i < cells.length; i++) {
        const a = snap.cells[i];
        const b = cells[i];
        if (a === b) continue;
        if (b === 'r' && (a === 'h' || a === 'M')) newRevealed.push(i);
        else if (b === 'f' && flagAdd < 0) {
          flagAdd = i;
          flagMine = tiles[i].hasMine;
        } else if (a === 'f' && b !== 'f' && flagRemove < 0) flagRemove = i;
      }
      if (newRevealed.length > 0) {
        // stagger by toroidal Chebyshev distance from the EARLIEST opened
        // tile (the flood origin) — the wave visibly radiates from the click.
        const o = newRevealed[0];
        const ox = o % W;
        const oy = (o - ox) / W;
        const H = g.world.height;
        this.revealFx.clear();
        for (const i of newRevealed) {
          const x = i % W;
          const y = (i - x) / W;
          const dx = Math.min(Math.abs(x - ox), W - Math.abs(x - ox));
          const dy = Math.min(Math.abs(y - oy), H - Math.abs(y - oy));
          this.revealFx.set(`${x},${y}`, Math.min(480, dx * 50 + dy * 50));
        }
        const cx = o % W;
        events.push({
          kind: 'reveal',
          cell: `${cx},${oy}`,
          count: newRevealed.length,
        });
      }
      if (flagAdd >= 0) {
        const x = flagAdd % W;
        events.push({ kind: 'flag', cell: `${x},${(flagAdd - x) / W}`, mine: flagMine });
      }
      if (flagRemove >= 0) {
        const x = flagRemove % W;
        events.push({ kind: 'unflag', cell: `${x},${(flagRemove - x) / W}` });
      }
    }
    // mine layout: a UNIFORM displacement of every mine = a toroidal shift
    const shift = snap.mines !== next.mines ? this.shiftVector(snap.mines, next.mines) : null;
    if (shift) events.push({ kind: 'shift', dx: shift[0], dy: shift[1] });
    // Count-pulse cells: proximity digits that CHANGED while the tile itself
    // stayed revealed (ruling 2026-10-28) — the shift's legible afterimage.
    // B1 cap (audit 2026-10-28): a wide-open board's full shift flips 50–80
    // digits; one pulsing paint-animated cell per flip reintroduced the P3
    // paint-storm pattern. Keep the leading-edge subset — the CAP cells
    // furthest along the wind, where mines ARRIVE, are the most informative
    // afterimage; paint cost stays bounded. Ties resolve by index.
    this.pulseCells = [];
    if (snap.nums !== next.nums) {
      const changed: number[] = [];
      for (let i = 0; i < next.nums.length; i++) {
        if (
          snap.nums[i] !== next.nums[i] &&
          snap.cells[i] === 'r' && cells[i] === 'r'
        ) changed.push(i);
      }
      if (changed.length > COUNT_PULSE_CAP) {
        const [sdx, sdy] = shift ?? [0, 0];
        const proj = (i: number) => (i % W) * sdx + Math.floor(i / W) * sdy;
        changed.sort((a, b) => proj(b) - proj(a) || a - b);
        changed.length = COUNT_PULSE_CAP;
      }
      this.pulseCells = changed;
    }
    // Shift-warning beat: the countdown STEPPED into its final 3 turns
    // (only a live telegraph, never while the Stabilizer holds it).
    if (
      g.telegraph && g.stabilizedFor === 0 &&
      next.shiftIn > 0 && next.shiftIn <= 3 && next.shiftIn !== snap.shiftIn
    ) {
      events.push({
        kind: 'shift-warning',
        dx: g.telegraph[0],
        dy: g.telegraph[1],
        turns: next.shiftIn,
      });
    }
    // first explosion observation (a shielded absorb clears explosion in the
    // same commit, so `exploded` only latches on a FATAL blast)
    if (!snap.exploded && next.exploded) {
      let at = '4,4';
      for (let i = 0; i < tiles.length; i++) {
        if (tiles[i].hasMine && tiles[i].state === 'REVEALED') {
          const x = i % W;
          at = `${x},${(i - x) / W}`;
          break;
        }
      }
      events.push({ kind: 'boom', cell: at, cause: String(g.explosionCause()), shielded: false });
    }
    // Shield-ABSORBED detonations: the explosion clears inside the same
    // commit(), so `exploded` never latches and no boom diff surfaces — the
    // engine hands us the record explicitly so the shielded boom (fx ring,
    // muted sound, status line) still fires. Without this the absorb was
    // invisible: fire sat frozen and charges vanished with zero feedback.
    if (g.absorbed) {
      events.push({
        kind: 'boom',
        cell: `${g.absorbed.x},${g.absorbed.y}`,
        cause: String(g.absorbed.cause),
        shielded: true,
      });
      g.absorbed = null;
    }
    this.fxSnap = next;
    return events;
  }

  /** Old vs new mine index sets → the uniform shift vector, or null when the
   * change is not a clean whole-field slide (e.g. first-click relocation). */
  private shiftVector(prev: string, next: string): [number, number] | null {
    const g = this.game;
    const W = g.world.width;
    const H = g.world.height;
    const parse = (s: string): number[] =>
      s ? s.slice(0, -1).split(',').map(Number) : [];
    const a = parse(prev);
    const bSet = new Set(parse(next));
    if (a.length === 0 || a.length !== bSet.size) return null;
    for (let dx = -1; dx <= 1; dx++) {
      for (let dy = -1; dy <= 1; dy++) {
        if (dx === 0 && dy === 0) continue;
        let ok = true;
        for (const i of a) {
          const x = i % W;
          const y = (i - x) / W;
          const nx = (x + dx + W) % W;
          const ny = (y + dy + H) % H;
          if (!bSet.has(ny * W + nx)) {
            ok = false;
            break;
          }
        }
        if (ok) return [dx, dy];
      }
    }
    return null;
  }
}

function randomSeed(): number {
  return Math.floor(Math.random() * 0x1_0000_0000);
}