SOURCE / PINNED RELEASE
Made of little things.
Shiftfield
- Release
- 5a170c505f6b…
- Author-recorded commit
- a2e23e0171aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd
Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/* Minesweeper DX — 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;
