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/main.ts
/* Minesweeper DX — game bootstrap + Phase 3 roguelike loop.

 * Implements the master spec §4.1 state machine:
 *   MENU → RUNNING → (clear) DECISION → Bank → SUMMARY → SHOP → MENU/RUNNING
 *                                    ↘ Go Deeper → RUNNING (depth+1)
 *            RUNNING → (explosion)  → SUMMARY (50% run-scrap salvage) → SHOP
 *
 * Meta economy (player rulings 2026-10-24): scrap mints per board clear
 * (10 × 1.1^depth × economy mult), flows into the run ledger; Bank commits it
 * to the vault (safe); death salvages half. Workshop sells Radar/Seeker/
 * Shield/Stabilizer (all start LOCKED at Lv0), Data Ledger (scrap %) and
 * Flow Capacitor (Flow ceiling 100 → 500, ruling 2026-10-27). See
 * src/upgrades.ts + src/save.ts.
 *
 * Persistence: NAP-STORAGE via @napplet/sdk (SaveManager) — NEVER
 * localStorage (AGENTS.md hard boundary). Without an injected storage domain
 * the session keeps progress in memory only and says so on the Menu.
 *
 * The overlay stub from Phase 2.5 is retired: real DECISION / SUMMARY / SHOP
 * / MENU screens now drive the loop.
 */

import './napplet-settings.js';
import { Board, COUNT_PULSE_CAP } from './board.js';
import { Fx } from './juice.js';
import { Sfx } from './sfx.js';
import { ABILITIES, GameEngine, shiftIntervalFor } from './game.ts';
import { runtimeHasDomain } from './domain-availability.js';
import {
  boardAvailable,
  readBoard,
  submitRunEnd,
} from './board-service.js';
import {
  ProgressionManager,
  SaveManager,
} from './save.ts';
import {
  UPGRADE_DEFS,
  SHOP_GROUPS,
  nextCost,
  type UpgradeId,
} from './upgrades.ts';
import './styles.css';

// Optional-domain absence is a normal state (guidance suite). Without the
// STORAGE domain the game is fully playable; meta-progression simply lives
// for the page session only (no durable fallback exists by design).
export const hasStorageDomain = runtimeHasDomain('storage');

// Player-facing wording for engine ExplosionCause codes — the raw enum is a
// debug identifier and must never surface in UI text (player ruling
// 2026-10-27). Keys are the ExplosionCause literals from src/engine.ts.
const ABSORB_LINE: Record<string, string> = {
  MINE_ON_REVEALED: 'Shield charge absorbed the blast',
  MINE_ON_MINE: 'Shield charge absorbed a mine collision',
  MINE_ON_FIRE: 'Shield charge smothered a burning mine',
};
const BOOM_LABEL: Record<string, string> = {
  MINE_ON_REVEALED: 'mine detonated',
  MINE_ON_MINE: 'mines collided',
  MINE_ON_FIRE: 'mine ignited',
};
function absorbLine(cause: string): string {
  return ABSORB_LINE[cause] ?? 'Shield charge absorbed the blast';
}
function boomLabel(cause: string | null): string {
  return (cause && BOOM_LABEL[cause]) || 'fatal blast';
}

// Player ruling 2026-10-27: the death summary title is a random one-liner
// instead of 'Run over'. The pool is exposed on the Game instance
// (`deathTitles`) so the browser smoke test asserts pool membership instead
// of pinning one literal — no single title may ever regress back.
const DEATH_TITLES = [
  'Boom',
  'KA-BLOOEY!',
  "Well, That Wasn't In The Plan",
  'Scrapped!',
  'Mmm... Crispy',
  'That Tile Looked Safe',
  'Oops. All Mines',
  'The Deep Collects Its Due',
  "Should've Banked",
] as const;

function requireElement<T extends HTMLElement>(selector: string): T {
  const el = document.querySelector<T>(selector);
  if (!el) throw new Error(`missing element: ${selector}`);
  return el;
}

const ui = {
  boardHost: requireElement<HTMLElement>('#boardHost'),
  depth: requireElement<HTMLElement>('#depth'),
  turn: requireElement<HTMLElement>('#turn'),
  flow: requireElement<HTMLElement>('#flow'),
  runScrap: requireElement<HTMLElement>('#runScrap'),
  scrap: requireElement<HTMLElement>('#scrap'),
  shiftStat: requireElement<HTMLElement>('#shiftStat'),
  shiftArrow: requireElement<HTMLElement>('#shiftArrow'),
  shiftIn: requireElement<HTMLElement>('#shiftIn'),
  shifterIntro: requireElement<HTMLElement>('#shifterIntro'),
  shifterIntroOk: requireElement<HTMLButtonElement>('#shifterIntroOk'),
  shieldStat: requireElement<HTMLElement>('#shieldStat'),
  shieldCount: requireElement<HTMLElement>('#shieldCount'),
  status: requireElement<HTMLOutputElement>('#status'),
  sfxToggle: requireElement<HTMLButtonElement>('#sfxToggle'),
  helpToggle: requireElement<HTMLButtonElement>('#helpToggle'),
  abilityButtons: Array.from(
    document.querySelectorAll<HTMLButtonElement>('[data-ability]'),
  ),
  // screens (§4.1)
  menuScreen: requireElement<HTMLElement>('#menuScreen'),
  menuStats: requireElement<HTMLElement>('#menuStats'),
  startRunBtn: requireElement<HTMLButtonElement>('#startRunBtn'),
  menuWorkshopBtn: requireElement<HTMLButtonElement>('#menuWorkshopBtn'),
  menuFieldManualBtn: requireElement<HTMLButtonElement>('#menuFieldManualBtn'),
  menuLeaderboardBtn: requireElement<HTMLButtonElement>('#menuLeaderboardBtn'),
  saveNote: requireElement<HTMLElement>('#saveNote'),
  decisionScreen: requireElement<HTMLElement>('#decisionScreen'),
  decisionTitle: requireElement<HTMLElement>('#decisionTitle'),
  decisionBody: requireElement<HTMLElement>('#decisionBody'),
  bankBtn: requireElement<HTMLButtonElement>('#bankBtn'),
  continueBtn: requireElement<HTMLButtonElement>('#continueBtn'),
  summaryScreen: requireElement<HTMLElement>('#summaryScreen'),
  summaryTitle: requireElement<HTMLElement>('#summaryTitle'),
  summaryCause: requireElement<HTMLElement>('#summaryCause'),
  summaryLedger: requireElement<HTMLElement>('#summaryLedger'),
  playerNameInput: requireElement<HTMLInputElement>('#playerNameInput'),
  nameGate: requireElement<HTMLElement>('#nameGate'),
  nameGateInput: requireElement<HTMLInputElement>('#nameGateInput'),
  nameGateOk: requireElement<HTMLButtonElement>('#nameGateOk'),
  summaryBoardNote: requireElement<HTMLElement>('#summaryBoardNote'),
  summaryWorkshopBtn: requireElement<HTMLButtonElement>('#summaryWorkshopBtn'),
  summaryBoardBtn: requireElement<HTMLButtonElement>('#summaryBoardBtn'),
  summaryMenuBtn: requireElement<HTMLButtonElement>('#summaryMenuBtn'),
  boardScreen: requireElement<HTMLElement>('#boardScreen'),
  boardRows: requireElement<HTMLElement>('#boardRows'),
  boardNote: requireElement<HTMLElement>('#boardNote'),
  boardRefreshBtn: requireElement<HTMLButtonElement>('#boardRefreshBtn'),
  boardCloseBtn: requireElement<HTMLButtonElement>('#boardCloseBtn'),
  shopScreen: requireElement<HTMLElement>('#shopScreen'),
  helpScreen: requireElement<HTMLElement>('#helpScreen'),
  helpTabButtons: {
    basics: requireElement<HTMLButtonElement>('#helpTabBasics'),
    hazards: requireElement<HTMLButtonElement>('#helpTabHazards'),
    tools: requireElement<HTMLButtonElement>('#helpTabTools'),
  },
  helpPanels: {
    basics: requireElement<HTMLElement>('#helpPanelBasics'),
    hazards: requireElement<HTMLElement>('#helpPanelHazards'),
    tools: requireElement<HTMLElement>('#helpPanelTools'),
  },
  helpBackBtn: requireElement<HTMLButtonElement>('#helpBackBtn'),
  shopScrap: requireElement<HTMLElement>('#shopScrap'),
  upgradeList: requireElement<HTMLElement>('#upgradeList'),
  shopStartBtn: requireElement<HTMLButtonElement>('#shopStartBtn'),
  shopBackBtn: requireElement<HTMLButtonElement>('#shopBackBtn'),
};

// Arrow glyph for the shift telegraph: direction the mines will travel.
const ARROWS: Record<string, string> = {
  '1,0': '→', '-1,0': '←', '0,1': '↓', '0,-1': '↑',
  '1,1': '↘', '1,-1': '↗', '-1,1': '↙', '-1,-1': '↖',
};

/** §4.1 states. `menu` / `running` / `decision` / `summary` / `shop`.
 * `decision` is entered by the board clear; `summary` by Bank-exit or death
 * (with salvage). Shop returns to menu or starts a run. */
type Phase = 'menu' | 'running' | 'decision' | 'summary' | 'shop' | 'board';

/** Field Manual tabs (ruling 2026-10-28, how_to_play_menu_ruling): three
 * merged sections — Basics (loop + economy + death), Hazards & The Shift,
 * Tools & Workshop. */
type HelpTab = 'basics' | 'hazards' | 'tools';

class Game {
  readonly board: Board;
  readonly progression = new ProgressionManager();
  readonly saves: SaveManager;
  readonly sfx: Sfx;
  readonly fx: Fx;
  /** Depth-6 Shifter intro modal (ruling 2026-10-28): once per run, and
   * BLOCKING — it only closes via the acknowledge button (ack follow-up). */
  private shifterIntroShown = false;
  phase: Phase = 'menu';
  /** Random death-summary title pool (ruling 2026-10-27). Public so the
   * browser smoke test can assert the shown title is a pool member. */
  readonly deathTitles = DEATH_TITLES;
  /** Where the Shop's Back button returns (summary vs menu). */
  private shopReturn: 'menu' | 'summary' = 'menu';
  /** Leaderboard is an overlay only — Back returns to the screen it opened from. */
  private boardReturn: 'menu' | 'summary' = 'menu';
  private lastBoardReadMs = 0;
  /** Armed run-end submit (1-based score). Set on SUMMARY entry, flushed on
   * the first transition out — the runner-name field lives ON the summary,
   * and the service rejects equal-score resubmits, so the FIRST accepted
   * submit must carry the typed name (unnamed-runner fix, ruling note
   * leaderboard_name_submit_fix_2026-10-29). */
  private pendingPost: number | null = null;
  /** The board clear currently awaiting its DECISION screen; true once the
   * clear has been CREDITED (paid at the decision, before the player picks —
   * ruling 2026-10-24: scrap accrues per board clear, and Bank must include
   * the clear that triggered the screen). Go Deeper just advance()s. */
  private clearCredited = false;

  constructor() {
    this.board = new Board(
      ui.boardHost,
      (game) => this.sync(game),
      (game) => {
        // Fresh engine (menu placeholder / newRun): Workshop levels land
        // AFTER construction, so the run-start shield grant must be RE-APPLIED
        // here — the constructor saw the MAX_UPGRADES test default, not the
        // player's save (bug: brand-new players woke with 5 shields).
        // Ruling 2026-10-27: charges = shield_lvl at RUN START only; this
        // hook never fires on advance() ("Go Deeper"), so charges carry
        // across boards untouched, and shop purchases use applyUpgrades
        // directly (never the grant) so the live run keeps its spends.
        this.applyUpgrades(game);
        game.shieldCharges = game.upgrades.shield;
      },
    );
    this.saves = SaveManager.fromRuntime();
    // Phase 4 juice: effects canvas + procedural sound. The canvas overlays
    // #app (pointer-events:none, below the .overlay screens); #app is also
    // the screen-shake layer. Sound rides the same event stream.
    this.sfx = new Sfx(ui.sfxToggle);
    this.sfx.onChanged = (muted) => {
      void this.saves.saveSettings({ muted });
    };
    this.fx = new Fx(
      requireElement<HTMLElement>('#app'),
      (cell) => {
        const el = document.querySelector<HTMLElement>(
          `.cell[data-cell="${cell}"]`,
        );
        if (!el) return null;
        const r = el.getBoundingClientRect();
        return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
      },
      () => document.querySelector<HTMLElement>('.board'),
      this.sfx,
      // FATAL booms only ever fire through Board.afterAction (the board's
      // fx diff is gated to player actions), so this tap marks exactly the
      // clicked-your-own-death summary — direct QA detonations (sync()
      // without afterAction) stay instant for the layout contract tests.
      (ev) => {
        if (ev.kind === 'boom' && !ev.shielded) {
          ui.summaryScreen
            .querySelector('.panel-anim')
            ?.classList.add('death-blast');
        }
      },
    );
    this.board.onFx = (ev) => {
      this.fx.event(ev);
      // A shield absorb clears the explosion inside the same engine commit,
      // so without an explicit status line it reads as charges silently
      // vanishing (the fire-never-spread bug 2026-10-27 hid behind exactly
      // this silence).
      if (ev.kind === 'boom' && ev.shielded)
        this.setStatus(absorbLine(ev.cause), 'warn');
      // Final-3-turns of a shift cycle say so in words, not just a chip
      // (ruling 2026-10-28). The fx event already fired sfx + board pulse.
      if (ev.kind === 'shift-warning') {
        const arrow = ARROWS[`${ev.dx},${ev.dy}`] ?? '?';
        this.setStatus(
          `⚠ Mines drift ${arrow} in ${ev.turns} turn${ev.turns === 1 ? '' : 's'}`,
          'warn',
        );
      }
    };
    ui.shifterIntroOk.addEventListener('click', () => {
      ui.shifterIntro.hidden = true;
    });
    this.bindAbilities();
    this.bindScreens();
    this.board.onAction = (game) => this.checkTransition(game);
    void this.boot();
  }

  private async boot(): Promise<void> {
    const saved = await this.saves.load();
    this.progression.save = saved;
    const settings = await this.saves.loadSettings();
    this.sfx.setMuted(settings.muted);
    this.showScreen('menu');
    this.renderMenu();
    if (!this.saves.enabled) {
      ui.saveNote.hidden = false;
      ui.saveNote.textContent =
        'No storage domain from the host — progress lasts for this page only.';
    }
    // cvm is an OPTIONAL capability (O tag, ruling D3): a host that did not
    // grant it never sees a leaderboard control — postRunEnd/loadBoard carry
    // their own guards for the paths that stay reachable.
    const hasBoard = boardAvailable();
    ui.menuLeaderboardBtn.hidden = !hasBoard;
    ui.summaryBoardBtn.hidden = !hasBoard;
    ui.playerNameInput.value = this.progression.save.name;
  }

  // ------------------------------ engine wiring ---------------------------

  /** Push Workshop levels onto a (new) engine + wire its clear-credit hook. */
  private applyUpgrades(game: GameEngine): void {
    game.upgrades = this.progression.abilityLevels();
    game.onBoardCleared = (depth) => this.progression.creditClear(depth);
  }

  /** First engine of a run: fresh board AND fresh ledger. newRun() replaces
   * the engine (a dead one after SUMMARY, the menu's placeholder otherwise)
   * and the onEngine hook applies Workshop levels + the clear-credit hook. */
  private startRun(): void {
    // One-time runner-name gate (ruling 2026-10-29, player-proposed): the
    // FIRST run asks for the board handle before play, so the very first
    // accepted submit carries a real name — the summary field can only act
    // after the summary exists. boardAvailable() keeps the gate invisible on
    // hosts without cvm (optional-domain degradation rule); save.name_prompted
    // latches once ever (blank confirm = run unnamed, still never re-asks).
    if (boardAvailable() && !this.progression.save.name_prompted) {
      ui.nameGateInput.value = this.progression.save.name;
      ui.nameGate.hidden = false;
      ui.nameGateInput.focus();
      return;
    }
    this.progression.startRun();
    this.clearCredited = false;
    this.shifterIntroShown = false;
    ui.shifterIntro.hidden = true;
    this.fx.reset();
    this.sfx.play('ui');
    this.board.newRun();
    this.showScreen('running');
    this.phase = 'running';
    this.setStatus('Run started — clear the sector', 'ok');
    this.sync(this.board.game);
  }

  private persist(): void {
    void this.saves.save(this.progression.save);
  }

  /** Confirm the one-time gate: store the handle (blank = unnamed is a valid
   *  player choice), latch `name_prompted` so it never interrupts again, then
   *  continue the blocked startRun(). */
  private commitNameGate(): void {
    this.progression.save.name = ui.nameGateInput.value.trim().slice(0, 24).trim();
    this.progression.save.name_prompted = true;
    this.persist();
    ui.nameGate.hidden = true;
    ui.playerNameInput.value = this.progression.save.name;
    this.startRun();
  }

  // --------------------------------- screens -------------------------------

  private showScreen(phase: Phase): void {
    // Leaving the summary flushes any armed run-end post (name is read at
    // flush time). Entering it never flushes (pending is armed after entry).
    if (this.pendingPost !== null && phase !== 'summary') this.flushPendingPost();
    if (phase !== 'summary') {
      ui.summaryScreen
        .querySelector('.panel-anim')
        ?.classList.remove('death-blast');
    }
    ui.menuScreen.hidden = phase !== 'menu';
    ui.decisionScreen.hidden = phase !== 'decision';
    ui.summaryScreen.hidden = phase !== 'summary';
    ui.shopScreen.hidden = phase !== 'shop';
    ui.boardScreen.hidden = phase !== 'board';
  }

  private renderMenu(): void {
    const p = this.progression;
    ui.menuStats.textContent =
      `⛃ ${p.scrap} scrap banked · deepest depth ${p.save.depth}`;
    ui.startRunBtn.textContent = 'Enter The Depths';
  }

  private renderSummary(
    title: string,
    cause: string,
    rows: Array<[string, string]>,
  ): void {
    ui.summaryTitle.textContent = title;
    ui.summaryCause.textContent = cause;
    ui.summaryLedger.innerHTML = rows
      .map(
        ([k, v]) =>
          `<div class="ledger-row"><dt>${k}</dt><dd>${v}</dd></div>`,
      )
      .join('');
  }

  // ------------------------------ leaderboard ------------------------------
  // depth-v1 shared board (ruling 2026-10-28). Score = 1-based deepest CLEARED
  // board: Progression.runDepth is the 0-based engine depth of the last clear,
  // so +1 matches the Menu's "deepest depth N". One submit per run end, armed
  // at SUMMARY entry (Bank and death both call this right after
  // showScreen('summary')) and flushed at the single chokepoint where the
  // typed name EXISTS: leaving the summary, or Enter in the name field. The
  // service never throws — every failure lands in the summary note.

  private postRunEnd(): void {
    ui.playerNameInput.value = this.progression.save.name;
    this.pendingPost = null;
    if (!boardAvailable()) {
      ui.summaryBoardNote.hidden = false;
      ui.summaryBoardNote.textContent = 'Leaderboard unavailable on this host.';
      return;
    }
    if (this.progression.runClears < 1) {
      // Zero-clear run: nothing above the board minimum to post (ruling D2).
      // runClears counts credited clears; engine.boardsCleared counts
      // ADVANCES, so banking the very first clear would wrongly skip there.
      ui.summaryBoardNote.hidden = true;
      return;
    }
    // ARM, do not submit: #playerNameInput lives on this summary. Submitting
    // at entry always posted the stale (empty) save name, and the service
    // only promotes strictly-better personal bests — so the accepted row was
    // stuck at 'unnamed-runner' forever. The post now waits for the name.
    this.pendingPost = this.progression.runDepth + 1;
    ui.summaryBoardNote.hidden = false;
    ui.summaryBoardNote.textContent =
      'Add a runner name — posts when you leave, or press Enter to post now.';
  }

  /** THE one submit per run end. Reads the name field synchronously at flush
   * time (Enter can fire before the change listener persists it), clears the
   * arm, and posts through the service. */
  private flushPendingPost(): void {
    const score = this.pendingPost;
    this.pendingPost = null;
    if (score === null || !boardAvailable()) return;
    this.progression.save.name = ui.playerNameInput.value.trim().slice(0, 24).trim();
    ui.playerNameInput.value = this.progression.save.name;
    this.persist();
    ui.summaryBoardNote.hidden = false;
    ui.summaryBoardNote.textContent = 'Posting score…';
    void submitRunEnd(score, this.progression.save.name).then(async (r) => {
      if (!r.ok || this.phase !== 'summary') {
        if (r.ok === false && this.phase === 'summary')
          ui.summaryBoardNote.textContent = `Score not posted (${r.reason}).`;
        return;
      }
      // Confirm rank honestly via the server's own per-actor row.
      const board = await readBoard(20);
      if ('ok' in board || this.phase !== 'summary') {
        ui.summaryBoardNote.textContent = `Deepest depth ${score} posted.`;
        return;
      }
      const index = board.rows.findIndex((row) => row.actor === board.own?.actor);
      ui.summaryBoardNote.textContent =
        index >= 0
          ? `#${index + 1} · Deepest Depth ${board.own?.score ?? score}`
          : `Deepest depth ${score} posted.`;
    });
  }

  /** One read per open; Refresh throttled to the service's 2 s minimum poll. */
  private loadBoard(throttled = true): void {
    const now = Date.now();
    if (throttled && now - this.lastBoardReadMs < 2000) return;
    this.lastBoardReadMs = now;
    ui.boardNote.textContent = 'Loading…';
    ui.boardRows.textContent = '';
    void readBoard(20).then((board) => {
      if (ui.boardScreen.hidden) return; // left while loading
      if ('ok' in board) {
        ui.boardNote.textContent =
          board.reason === 'unavailable'
            ? 'Leaderboard unavailable on this host.'
            : 'Board unreachable — check the connection, then Refresh.';
        return;
      }
      if (board.rows.length === 0) {
        ui.boardNote.textContent = 'No scores yet — be the first down here.';
        return;
      }
      ui.boardNote.textContent = `Top ${board.rows.length} · ${board.trust}`;
      // Names are untrusted public data: build rows with textContent only.
      ui.boardRows.textContent = '';
      board.rows.forEach((row, i) => {
        const line = document.createElement('div');
        line.className = 'ledger-row';
        if (board.own && row.actor === board.own.actor) line.classList.add('own');
        const dt = document.createElement('dt');
        dt.textContent = `#${i + 1} ${row.name || 'unnamed-runner'}`;
        const dd = document.createElement('dd');
        dd.textContent = String(row.score);
        line.append(dt, dd);
        ui.boardRows.append(line);
      });
    });
  }

  private openBoard(from: 'menu' | 'summary'): void {
    this.boardReturn = from;
    this.showScreen('board');
    this.loadBoard(false);
  }

  // ------------------------------ field manual ------------------------------

  /** Field Manual (ruling 2026-10-28, how_to_play_menu_ruling): a pure
   * overlay over whatever screen is showing — it never touches `phase` or
   * calls showScreen, so closing it hands the underlying screen straight
   * back (mid-run, the run view and board are untouched). Copy is static
   * HTML; nothing here reads the engine. Tab switching is a class/hidden
   * toggle only. Entry points: MENU button → Basics; HUD `?` → Tools &
   * Workshop (mid-run ability reference). */
  private openHelp(tab: HelpTab): void {
    this.setHelpTab(tab);
    ui.helpScreen.hidden = false;
    this.sfx.play('ui');
  }

  private setHelpTab(tab: HelpTab): void {
    for (const id of ['basics', 'hazards', 'tools'] as const) {
      const active = id === tab;
      ui.helpTabButtons[id].classList.toggle('active', active);
      ui.helpTabButtons[id].setAttribute('aria-selected', String(active));
      ui.helpPanels[id].hidden = !active;
    }
  }

  // --------------------------------- buttons -------------------------------

  private bindScreens(): void {
    ui.startRunBtn.addEventListener('click', () => this.startRun());
    ui.menuWorkshopBtn.addEventListener('click', () => this.openShop('menu'));
    // Field Manual entry points (ruling 2026-10-28): MENU button opens
    // Basics; the HUD `?` opens it mid-run landed on Tools & Workshop.
    // Back hides the overlay — the underlying screen was never replaced,
    // so nothing else needs re-rendering.
    ui.menuFieldManualBtn.addEventListener('click', () => this.openHelp('basics'));
    ui.helpToggle.addEventListener('click', () => this.openHelp('tools'));
    for (const id of ['basics', 'hazards', 'tools'] as const) {
      ui.helpTabButtons[id].addEventListener('click', () => this.setHelpTab(id));
    }
    ui.helpBackBtn.addEventListener('click', () => {
      ui.helpScreen.hidden = true;
    });
    ui.summaryWorkshopBtn.addEventListener('click', () => this.openShop('summary'));
    ui.summaryMenuBtn.addEventListener('click', () => {
      this.phase = 'menu';
      this.showScreen('menu');
      this.renderMenu();
    });

    // Leaderboard entry points (Menu + summary shortcut).
    ui.menuLeaderboardBtn.addEventListener('click', () => this.openBoard('menu'));
    ui.summaryBoardBtn.addEventListener('click', () => this.openBoard('summary'));
    ui.boardRefreshBtn.addEventListener('click', () => this.loadBoard());
    ui.boardCloseBtn.addEventListener('click', () => {
      this.showScreen(this.boardReturn);
      if (this.boardReturn === 'menu') this.renderMenu();
    });
    // Runner name persists on change (ruling D4): trimmed, ≤24 by maxlength.
    ui.playerNameInput.addEventListener('change', () => {
      this.progression.save.name = ui.playerNameInput.value.trim().slice(0, 24).trim();
      ui.playerNameInput.value = this.progression.save.name;
      this.persist();
    });
    // Enter in the name field posts the armed score immediately with that name
    // (unnamed-runner fix 2026-10-29); leaving the summary flushes otherwise.
    ui.playerNameInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') this.flushPendingPost();
    });

    ui.bankBtn.addEventListener('click', () => {
      // Bank & Exit: run ends safely — ledger moves to the vault whole.
      const banked = this.progression.bank();
      this.persist();
      this.renderSummary(
        'Banked',
        `You walked out of depth ${this.board.game.depth} with the scrap.`,
        [
          ['Boards cleared', String(this.progression.runClears)],
          ['Scrap banked', `+${banked} ⛃`],
          ['Vault total', `${this.progression.scrap} ⛃`],
        ],
      );
      this.phase = 'summary';
      this.showScreen('summary');
      this.postRunEnd();
      this.setStatus(`Banked ${banked} scrap`, 'ok');
    });

    ui.continueBtn.addEventListener('click', () => {
      // Go Deeper: same run, one deeper. The clear already paid at the
      // decision screen — detach the engine's credit hook across advance()
      // so advance() cannot credit the same clear a second time, then
      // re-arm it for the next board.
      const game = this.board.game;
      game.onBoardCleared = null;
      game.advance();
      this.applyUpgrades(game);
      this.clearCredited = false;
      this.persist();
      this.phase = 'running';
      this.showScreen('running');
      this.setStatus(`Depth ${game.depth} — mines ahead`, 'idle');
      this.board.rebuild();
      this.sync(game);
    });

    ui.shopStartBtn.addEventListener('click', () => this.startRun());
    // Gate confirm: button click or Enter in the gate input (blocking modal —
    // the backdrop swallows everything else, same contract as shifterIntro).
    ui.nameGateOk.addEventListener('click', () => this.commitNameGate());
    ui.nameGateInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') this.commitNameGate();
    });
    ui.shopBackBtn.addEventListener('click', () => {
      this.phase = this.shopReturn;
      this.showScreen(this.shopReturn);
      if (this.shopReturn === 'menu') this.renderMenu();
    });
  }

  private openShop(from: 'menu' | 'summary'): void {
    this.shopReturn = from;
    this.phase = 'shop';
    this.showScreen('shop');
    this.renderShop();
  }

  /** Build the Workshop as grouped cards (ruling 2026-10-28,
   *  shop_layout_ruling): Intel / Survival / Economy sections, each a card
   *  grid that collapses to one column on narrow frames and to one-line
   *  ledger rows on short frames. Re-rendered on every purchase so costs,
   *  levels and affordability stay in sync with the vault. Harness
   *  selectors stay: .upgrade-row[data-upgrade], [data-buy], .pip/.pip-on,
   *  #shopScrap. */
  private renderShop(): void {
    ui.shopScrap.textContent = String(this.progression.scrap);
    ui.upgradeList.innerHTML = SHOP_GROUPS.map(({ group, ids }) => {
      const cards = ids
        .map((id) => this.renderShopCard(id))
        .join('');
      return `<section class="shop-group" data-group="${group}">
        <h3 class="shop-group-title">${group}</h3>
        <div class="shop-cards">${cards}</div>
      </section>`;
    }).join('');
    for (const btn of ui.upgradeList.querySelectorAll<HTMLButtonElement>('[data-buy]')) {
      btn.addEventListener('click', () => {
        const id = btn.dataset.buy as UpgradeId;
        const result = this.progression.purchase(id);
        if (result.ok) {
          // Level changes apply live. Shield level sets the NEXT run's
          // starting charges (ruling 2026-10-27) — the current run's charges
          // are untouched either way.
          this.applyUpgrades(this.board.game);
          this.persist();
          this.setStatus(`${UPGRADE_DEFS[id].name} → Lv.${result.level}`, 'ok');
          this.renderShop();
        }
      });
    }
  }

  /** One Workshop card: name + level pips, micro-format effect line, and a
   *  buy button whose label is the next cost (or MAX). The card carries a
   *  `locked` marker (level 0) PLUS an affordability state class
   *  (`affordable` / `unaffordable` / `maxed`) — a locked-but-affordable
   *  line is the main CTA for a fresh save, so it still gets the amber
   *  outline. CSS dims/outlines without markup changes. */
  private renderShopCard(id: UpgradeId): string {
    const def = UPGRADE_DEFS[id];
    const level = this.progression.levels[id];
    const cost = nextCost(id, level);
    const maxed = cost === null;
    const affordable = cost !== null && this.progression.scrap >= cost;
    const locked = level === 0 ? 'locked' : 'unlocked';
    const state = maxed ? 'maxed' : affordable ? 'affordable' : 'unaffordable';
    const pips = Array.from(
      { length: def.maxLevel },
      (_, i) => `<span class="pip${i < level ? ' pip-on' : ''}"></span>`,
    ).join('');
    return `<div class="upgrade-row ${locked} ${state}" data-upgrade="${id}">
      <div class="upgrade-info">
        <div class="upgrade-name">
          <span class="upgrade-title">${def.name}</span>
          <span class="upgrade-lvl">Lv.${level}</span>
          <span class="upgrade-pips" aria-hidden="true">${pips}</span>
        </div>
        <div class="upgrade-desc">${def.describe(level)}</div>
      </div>
      <button type="button" class="buy-btn" data-buy="${id}"
        ${maxed || !affordable ? 'disabled' : ''}
        aria-label="${maxed ? `${def.name} maxed` : `Buy ${def.name} level ${level + 1} for ${cost} scrap`}">
        ${maxed ? 'MAX' : `Buy ${cost} ⛃`}
      </button>
    </div>`;
  }

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

  private bindAbilities(): void {
    for (const btn of ui.abilityButtons) {
      btn.addEventListener('click', () => {
        const id = btn.dataset.ability as keyof typeof ABILITIES | undefined;
        if (!id || this.phase !== 'running') return;
        const game = this.board.game;

        // Radar is targeted: arm it, then the NEXT board click spends it on
        // the clicked tile. Clicking Radar again cancels the arm.
        if (id === 'radar') {
          if (this.board.pendingTarget) {
            this.board.pendingTarget = null;
            this.setStatus('Radar cancelled', 'idle');
          } else {
            const spec = ABILITIES.radar;
            const cd = game.cooldowns.get('radar') ?? 0;
            if (!game.isUnlocked('radar')) {
              this.setStatus('Radar locked — buy it in the Workshop', 'warn');
            } else if (game.flow < spec.cost || cd > 0) {
              this.setStatus('Ability unavailable (Flow or cooldown)', 'warn');
              this.fx.event({ kind: 'deny' });
            } else {
              this.board.pendingTarget = (x, y) => {
                const ok = game.radar(x, y);
                if (!ok) this.setStatus('Ability unavailable (Flow or cooldown)', 'warn');
                else this.setStatus(`Radar fired at ${x},${y}`, 'ok');
                return ok;
              };
              this.setStatus('Radar armed — click a tile to target', 'ok');
            }
          }
          this.board.refresh();
          this.sync(game);
          return;
        }

        let used = false;
        if (id === 'seeker') used = game.seeker();
        else if (id === 'stabilizer') used = game.stabilizer();
        if (!used) {
          if (!game.isUnlocked(id)) {
            this.setStatus(
              `${id[0]?.toUpperCase()}${id.slice(1)} locked — buy it in the Workshop`,
              'warn',
            );
          } else {
            this.setStatus('Ability unavailable (Flow or cooldown)', 'warn');
            this.fx.event({ kind: 'deny' });
          }
        } else {
          this.sfx.play('ability');
        }
        this.board.refresh();
        this.sync(game);
      });
    }
  }

  // ------------------------------ run flow (§4.1) --------------------------

  /** After any effective action: clear → DECISION screen, death → SUMMARY. */
  private checkTransition(game: GameEngine): boolean {
    if (this.phase !== 'running') return false;
    if (game.isDead()) {
      this.phase = 'summary';
      this.setStatus(`Boom — ${boomLabel(game.explosionCause())}`, 'error');
      const { earned, salvaged, destroyed } = this.progression.endRunOnDeath();
      this.persist();
      this.renderSummary(
        DEATH_TITLES[Math.floor(Math.random() * DEATH_TITLES.length)],
        `Depth ${game.depth} · ${boomLabel(game.explosionCause())}. Half the unbanked scrap was destroyed in the blast.`,
        [
          ['Boards cleared', String(this.progression.runClears)],
          ['Run scrap', `${earned} ◈`],
          ['Salvaged (−50%)', `+${salvaged} ⛃`],
          ['Destroyed', `−${destroyed} ◈`],
          ['Vault total', `${this.progression.scrap} ⛃`],
        ],
      );
      this.showScreen('summary');
      this.postRunEnd();
      return true;
    }
    if (game.isBoardCleared && !this.clearCredited) {
      // sector-clear celebration before the (CSS-delayed) DECISION panel
      const cells = game.world.tiles
        .filter((t) => t.state === 'REVEALED')
        .map((t) => `${t.coord[0]},${t.coord[1]}`);
      this.fx.celebrate(cells);
      // The clear PAYS at the decision screen (ruling 2026-10-24: scrap
      // accrues per board clear) — both DECISION exits carry it: Bank banks
      // it, death on a later board salvages half of it. Go Deeper only
      // advances; the engine hook is detached around advance() so the same
      // clear never credits twice.
      this.clearCredited = true;
      this.phase = 'decision';
      const paid = this.progression.creditClear(game.depth);
      this.persist();
      this.setStatus(`Sector ${game.depth} clear`, 'ok');
      // Player ruling 2026-10-27 (screen_copy_rewrite): Draft A salvage-report
      // voice — depth folded into the title, body reports the haul.
      ui.decisionTitle.textContent = `Sector ${game.depth} Cleared`;
      ui.decisionBody.textContent =
        `+${paid} scrap salvaged. ` +
        `You're hauling ${this.progression.runScrap} total. ` +
        `The deeper sectors run richer — and more dangerous.`;
      this.sync(game);
      this.showScreen('decision');
      return true;
    }
    return false;
  }

  private setStatus(text: string, kind: 'idle' | 'ok' | 'warn' | 'error'): void {
    ui.status.textContent = text;
    ui.status.className = `status status-${kind}`;
  }

  /** Write a HUD value; flash it when it actually changed (cheap juice). */
  private hudBump(el: HTMLElement, value: string): void {
    if (el.textContent !== value) {
      el.textContent = value;
      // C1 (audit 2026-10-29): the old `void el.offsetWidth` forced a
      // synchronous reflow per bumped stat (up to 5x per turn-sync).
      // Restart instead by removing now and re-adding one frame LATER:
      // a single rAF is not enough (the rAF callback runs before that
      // frame's style recalc, so remove+add would coalesce into a no-op
      // restart), hence the double rAF. Two frames (~32 ms) is invisible
      // against the 320 ms pop, and the CSS class keeps the
      // prefers-reduced-motion kill switch intact.
      el.classList.remove('stat-pop');
      requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('stat-pop')));
    }
  }

  /** Single render-sync point after any game mutation. */
  sync(game: GameEngine): void {
    this.hudBump(ui.depth, String(game.depth));
    this.hudBump(ui.turn, String(game.turn));
    this.hudBump(ui.flow, `${game.flow}/${game.flowCap()}`);
    this.hudBump(ui.runScrap, String(this.progression.runScrap));
    this.hudBump(ui.scrap, String(this.progression.scrap));

    // Shift telegraph
    if (game.telegraph) {
      ui.shiftStat.hidden = false;
      ui.shiftArrow.textContent = ARROWS[`${game.telegraph[0]},${game.telegraph[1]}`] ?? '?';
      // D1 (audit 2026-10-28): chip never prints raw negatives. T1 made
      // nextShiftIn ≥ 1 at every render, but the pre-T1 repro showed this
      // line printing "0"/"-1"/"-2" while the countdown leaked — clamp at 0
      // so any future regression surfaces as "0", not "-2".
      ui.shiftIn.textContent = game.stabilizedFor > 0 ? `held ${game.stabilizedFor}` : String(Math.max(0, game.nextShiftIn));
    } else {
      ui.shiftStat.hidden = true;
    }

    // On-board wind (ruling 2026-10-28): the telegraph lives ON the field.
    // Urgency ramps across the countdown; a Stabilizer hold freezes the
    // drift and tints it ice-blue — the pause is felt where the eye already is.
    if (game.telegraph && this.phase === 'running') {
      // A4 fairness fallback (audit 2026-10-29): the armed-shift class is
      // kept on the board for the WHOLE telegraph window; styles surface it
      // only while juice's watchdog has flagged the fx canvas dead
      // (html.fx-dead) — so when the canvas telegraph goes blind, the
      // amber edge glow + HUD arrow chip carry the warning without canvas.
      this.board.root.classList.add('board-shift-telegraph');
      const interval = shiftIntervalFor(game.depth);
      const urgency =
        game.stabilizedFor > 0
          ? 1
          : Math.min(1, Math.max(0, 1 - (game.nextShiftIn - 1) / Math.max(1, interval - 1))); // D1: clamp band
      this.fx.setTelegraph({
        dx: game.telegraph[0],
        dy: game.telegraph[1],
        urgency,
        frozen: game.stabilizedFor > 0,
      });
      // Silent awakening fix: the first armed board of a run introduces the
      // Shifter once, naming the wrap rule.
      if (!this.shifterIntroShown) {
        this.shifterIntroShown = true;
        // BLOCKING: the modal's full-viewport backdrop swallows board
        // clicks; only #shifterIntroOk (bound in the ctor) closes it.
        ui.shifterIntro.hidden = false;
        ui.shifterIntroOk.focus();
        // Warning beat (user ruling 2026-10-28): the awakening is heard and
        // felt, not just read — low ominous rumble + one subtle screen shake.
        this.sfx.play('awaken');
        this.fx.introRumble();
      }
    } else {
      this.fx.setTelegraph(null);
      this.board.root.classList.remove('board-shift-telegraph');
    }

    ui.shieldStat.hidden = game.shieldCharges === 0;
    ui.shieldCount.textContent = `${game.shieldCharges}/${game.upgrades.shield}`;

    // Ability gating: unlocked by the Workshop, affordable, off cooldown.
    // Locked abilities show 🔒 instead of a cost — visible but never clickable.
    for (const btn of ui.abilityButtons) {
      const id = btn.dataset.ability as keyof typeof ABILITIES;
      const spec = ABILITIES[id];
      const cd = game.cooldowns.get(id) ?? 0;
      const badge = btn.querySelector<HTMLElement>(`.cd[data-cd="${id}"]`);
      if (badge) badge.textContent = cd > 0 ? `⏳${cd}` : '';
      const locked = !game.isUnlocked(id);
      const costEl = btn.querySelector<HTMLElement>('.cost');
      if (costEl) costEl.textContent = locked ? '🔒' : `${spec.cost}f`;
      btn.classList.toggle('ability-locked', locked);
      btn.disabled =
        this.phase !== 'running' || locked ||
        game.flow < spec.cost || cd > 0;
      // Visual armed state while a targeted ability awaits its click.
      btn.classList.toggle('armed', id === 'radar' && this.board.pendingTarget !== null);
    }

    // Run-state transitions (§4.1) driven from action points too (sync may
    // fire after engine mutations outside Board, e.g. the QA hook).
    this.checkTransition(game);
  }
}

const game = new Game();
// QA/debug hook: lets automated browser smoke tests (and the console) drive
// the live engine deterministically, e.g. reveal only safe tiles to reach the
// DECISION screen. Harmless in production; not part of the game surface.
(window as unknown as { minesweeperDx?: Game }).minesweeperDx = game;
// QA surface (B1, audit 2026-10-28): expose the pulse-storm cap so the
// browser suite can assert the live pulse count against the SYMBOL instead
// of a duplicated literal. Never read by game logic.
(window as unknown as { countPulseCap?: number }).countPulseCap = COUNT_PULSE_CAP;