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 — automated browser smoke test.
*
* Boots the BUILT dist/ in a real headless Chromium (the Playwright install
* that @napplet/conformance-cli already uses) and asserts the integrated game
* works end-to-end: engine boots through the real DOM, HUD tracks state,
* reveal/flag/radar work, death shows the overlay, new run resets, wave
* advance increments depth, and hazard/telegraph UI renders at depth 11.
*
* Run: pnpm test:browser (requires pnpm build first; `verify` chains both)
*
* This covers the automated tier. The MANUAL in-browser checklist in
* docs/browser-testing.md (touch gestures, real shell frame, soyli preview)
* still applies before any release — see AGENTS.md "Playability rule".
*/
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { dirname, join, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dist = join(root, 'dist');
const MIME = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
};
async function serveDist() {
const server = createServer(async (req, res) => {
const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
try {
const body = await readFile(join(dist, path.slice(1)));
res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end('not found');
}
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const { port } = server.address();
return { url: `http://127.0.0.1:${port}/`, close: () => server.close() };
}
const service = await serveDist();
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto(service.url, { waitUntil: 'load' });
assert.equal(errors.length, 0, `boot errors: ${errors.join('; ')}`);
// 1. Real game boots: 100 cells and the QA hook exist (no template shell),
// sitting on the Phase-3 MENU screen with no run in flight.
assert.equal(await page.locator('.cell').count(), 100, 'board grid');
assert.ok(await page.evaluate('window.minesweeperDx !== undefined'), 'game hook');
const boot = await page.evaluate(() => ({
menu: !document.querySelector('#menuScreen').hidden,
decision: !document.querySelector('#decisionScreen').hidden,
summary: !document.querySelector('#summaryScreen').hidden,
shop: !document.querySelector('#shopScreen').hidden,
boardControlsHidden:
document.querySelector('#menuLeaderboardBtn').hidden &&
document.querySelector('#summaryBoardBtn').hidden &&
document.querySelector('#boardScreen').hidden,
saveNote: document.querySelector('#saveNote').textContent,
startRunLabel: document.querySelector('#startRunBtn').textContent,
}));
assert.ok(boot.menu, 'game boots into the menu screen');
assert.ok(!boot.decision && !boot.summary && !boot.shop, 'only the menu is visible at boot');
// cvm is OPTIONAL (O tag, ruling D3): the bare host never injected it, so
// every leaderboard entry point must be hidden at boot.
assert.ok(boot.boardControlsHidden, 'bare host hides all leaderboard entry points');
assert.match(boot.saveNote, /storage/i, 'menu states the missing storage domain honestly');
assert.equal(boot.startRunLabel, 'Enter The Depths', 'menu CTA reads Enter The Depths (ruling 2026-10-28, menu_start_cta_rename_ruling_2026-10-28.md)');
assert.doesNotMatch(boot.startRunLabel, /Start Run/, 'old "Start Run" copy stays retired');
await page.click('#startRunBtn');
assert.equal(
await page.evaluate(() => window.minesweeperDx.phase),
'running',
'Enter The Depths starts the run',
);
assert.ok(
await page.evaluate(() => document.querySelector('#nameGate').hidden),
'the one-time name gate NEVER interrupts on a bare host without cvm ' +
'(first-run gate ruling 2026-10-29; positive side pinned in board-scenario)',
);
assert.equal(
await page.evaluate(() => window.minesweeperDx.board.game.shieldCharges),
0,
'fresh save: the run starts with ZERO shields (regression — the old ' +
'MAX_UPGRADES constructor default handed free 5 charges to every new engine)',
);
// 2. Clicking a safe tile flood-opens a chunk and pays Flow ONCE.
const clicked = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
// Seal a mine pocket around (9,9) so no flood can clear the board and
// pop the Sector-clear overlay in the middle of the smoke sequence.
for (const [x, y] of [[8,8],[9,8],[0,8],[8,9],[0,9],[8,0],[9,0],[0,0]])
g.world.tileAt(x, y).hasMine = true;
const t =
g.world.tiles.find((x) => !x.hasMine && x.state === 'HIDDEN' && g.world.adjacentMineCount(x.coord[0], x.coord[1]) === 0) ??
g.world.tiles.find((x) => !x.hasMine && x.state === 'HIDDEN');
document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`).click();
return { turn: g.turn, flow: g.flow, revealed: g.world.revealedCount() };
});
assert.equal(clicked.turn, 1, 'turn ticks');
assert.equal(clicked.flow, 5, 'one action pays flowPerSafe once');
assert.ok(clicked.revealed > 5, `flood opened a chunk (${clicked.revealed} tiles), not one`);
// 2b. Turn law: clicking already-revealed ground is a no-op — it must NOT
// increment the turn counter (regression: every click used to tick it).
const noopClick = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
const open = g.world.tiles.find((t) => t.state === 'REVEALED');
const before = g.turn;
document.querySelector(`.cell[data-x=\"${open.coord[0]}\"][data-y=\"${open.coord[1]}\"]`).click();
document.querySelector(`.cell[data-x=\"${open.coord[0]}\"][data-y=\"${open.coord[1]}\"]`).click();
return { before, after: g.turn, hud: document.querySelector('#turn').textContent };
});
assert.equal(noopClick.after, noopClick.before, 'no-op clicks do not tick the engine turn');
assert.equal(noopClick.hud, String(noopClick.before), 'HUD turn unchanged after no-op clicks');
// 3. Right-click places a flag; right-click AGAIN removes it (classic
// toggle); flags cannot be placed on REVEALED tiles.
const flagClass = () => page.locator('.cell[data-x="0"][data-y="0"]').getAttribute('class');
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
assert.match(await flagClass(), /cell-flagged/, 'right-click places flag');
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
assert.doesNotMatch(await flagClass(), /cell-flagged/, 'right-click again removes flag');
assert.match(await flagClass(), /cell-hidden/, 'removed flag returns tile to hidden');
// Reveal a tile through the UI, then try to flag it: must stay revealed.
const flagReject = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
const t = g.world.tiles.find(
(c) => !c.hasMine && c.state === 'HIDDEN' && !(c.coord[0] === 0 && c.coord[1] === 0),
);
document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`).click();
const turnBefore = g.turn;
const outcome = g.flag(t.coord[0], t.coord[1]);
return {
outcome,
state: g.tile(t.coord[0], t.coord[1]).state,
flags: g.world.flaggedCount(),
turnTicked: g.turn === turnBefore + 1,
};
});
assert.equal(flagReject.outcome, 'rejected', 'flag on revealed tile is rejected');
assert.equal(flagReject.state, 'REVEALED', 'revealed tile keeps its state');
assert.equal(flagReject.flags, 0, 'no flag was created on revealed ground');
assert.ok(!flagReject.turnTicked, 'rejected flag changes nothing: no turn');
// SLOW right-click (press held >400ms, like a deliberate mouse hold): the
// contextmenu flag at press time must SURVIVE — the long-press toggle is
// primary-button only — and the player's NEXT left-click must not be
// swallowed by a stale once-capture click suppressor.
const box00 = await page.locator('.cell[data-x="0"][data-y="0"]').boundingBox();
const hold = { x: box00.x + box00.width / 2, y: box00.y + box00.height / 2 };
await page.mouse.move(hold.x, hold.y);
await page.mouse.down({ button: 'right' });
await page.waitForTimeout(550);
await page.mouse.up({ button: 'right' });
assert.match(await flagClass(), /cell-flagged/, 'slow right-hold keeps the flag placed');
// Next left-click on fresh hidden ground reveals (not swallowed).
const afterHold = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
const t = g.world.tiles.find((c) => c.state === 'HIDDEN' && !c.hasMine);
document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`).click();
return g.tile(t.coord[0], t.coord[1]).state;
});
assert.equal(afterHold, 'REVEALED', 'click after a slow right-hold is not swallowed');
// 3b. MOBILE long-press flag (device-reported bug 2026-10-29): Android
// Chrome / iOS fire a native `contextmenu` MID-HOLD (flag placed at press),
// then pointerup + sometimes a synthesized click land on the SAME tile.
// The old long-press toggle flagged again on release — place→remove meant
// the flag vanished exactly when the finger lifted and two turns burned
// (players learned to slide off the tile to dodge it). Replay that exact
// event order on a fresh hidden tile: exactly ONE flag, exactly ONE turn,
// flag survives release, the trailing click must not reveal anything.
const lp = await page.evaluate(async () => {
const g = window.minesweeperDx.board.game;
const t = g.world.tiles.find((c) => c.state === 'HIDDEN' && !c.hasMine && !c.flagged);
const cell = document.querySelector(`.cell[data-x="${t.coord[0]}"][data-y="${t.coord[1]}"]`);
const before = { flags: g.world.flaggedCount(), turn: g.turn };
const p = (type, up) =>
cell.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true,
button: 0, buttons: up ? 0 : 1, pointerId: 7, pointerType: 'touch', isPrimary: true,
}));
p('pointerdown', false);
await new Promise((r) => setTimeout(r, 450)); // cross the 400ms long-press window
cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
p('pointerup', true);
cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const out = {
flagged: g.tile(t.coord[0], t.coord[1]).state === 'FLAGGED',
domFlag: cell.classList.contains('cell-flagged'),
flagDelta: g.world.flaggedCount() - before.flags,
turnDelta: g.turn - before.turn,
revealed: g.tile(t.coord[0], t.coord[1]).state === 'REVEALED',
};
// Cleanup: long-press AGAIN on the same tile removes the flag (the same
// mobile gesture in reverse — one flag per gesture, both directions) and
// leaves the board unblocked for the board-clear phase further down the
// suite. Same replay shape, contextmenu-less (dedupe latch stays null).
const before2 = g.world.flaggedCount();
cell.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, composed: true,
button: 0, buttons: 1, pointerId: 8, pointerType: 'touch', isPrimary: true,
}));
await new Promise((r) => setTimeout(r, 450));
cell.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, cancelable: true, composed: true,
button: 0, buttons: 0, pointerId: 8, pointerType: 'touch', isPrimary: true,
}));
out.untoggled = g.world.flaggedCount() === before2 - 1;
// Drain the once-capture suppressor this gesture also armed, so the next
// real UI click in the suite is not swallowed.
cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
return out;
});
assert.ok(lp.flagged && lp.domFlag, 'long-press flag SURVIVES the finger release on the same tile');
assert.equal(lp.flagDelta, 1, 'the long-press gesture flags exactly once (one flag per gesture)');
assert.equal(lp.turnDelta, 1, 'one turn per long-press (the double-flag burned two)');
assert.ok(!lp.revealed, 'the synthesized post-long-press click does not reveal');
assert.ok(lp.untoggled, 'a contextmenu-less long-press (dedupe latch idle) still flags on release — toggle removal works both ways');
// 3c. Workshop gating (player rulings 2026-10-24 + 2026-10-27): Radar,
// Seeker and Stabilizer START LOCKED at level 0 — the bar shows
// 🔒 instead of a cost and the buttons are disabled even with Flow to
// spare. Shield left the bar entirely (ruling 2026-10-27,
// shop_flow_rebalance): its Workshop LEVEL grants run-start charges.
// Levels come from the Workshop shop, which check 5b drives with
// real clicks; here the QA hook grants them so the ability-behaviour
// checks below can run.
const gating = await page.evaluate(() => {
const app = window.minesweeperDx;
const g = app.board.game;
const snap = () => ['radar', 'seeker', 'stabilizer'].map((id) => {
const btn = document.querySelector(`[data-ability="${id}"]`);
return {
disabled: btn.disabled,
cost: btn.querySelector('.cost').textContent,
lockedClass: btn.classList.contains('ability-locked'),
};
});
g.flow = 70; // affordability is NOT what should be holding these back (max cost 50f)
app.sync(g);
const before = snap();
app.progression.save.upgrades = { radar: 1, seeker: 1, shield: 1, stabilizer: 1, economy: 0, flow_cap: 0 };
app.applyUpgrades(g);
app.sync(g);
return {
before,
after: snap(),
unlocked: ['radar', 'seeker', 'shield', 'stabilizer'].map((id) => g.isUnlocked(id)),
};
});
for (const b of gating.before) {
assert.ok(b.disabled, 'locked ability is disabled despite full Flow');
assert.equal(b.cost, '🔒', 'locked ability shows the lock glyph, not a cost');
assert.ok(b.lockedClass, 'locked ability carries the ability-locked class');
}
assert.deepEqual(
gating.unlocked,
[true, true, true, true],
'purchased levels unlock the abilities (Stabilizer now gated too, ruling 2026-10-27)',
);
for (const a of gating.after) assert.ok(!a.disabled, 'unlocked ability is usable when affordable');
// 4. Radar is TARGETED: selecting it arms without firing; the NEXT click
// spends it on the clicked tile (not the previously focused one), pays
// Flow cost, and sets cooldown.
const radar = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
// deterministic: aim at a hidden, mine-free tile
// (flood may have opened every safe tile — fall back to any safe tile;
// clicking opened ground is inert, and radar still pays its cost).
const safe =
g.world.tiles.find((t) => t.hasMine === false && t.state === 'HIDDEN') ??
g.world.tiles.find((t) => t.hasMine === false);
// Focus a DIFFERENT safe tile first so a stale-focus bug would fire the
// 3×3 around the wrong place / at the wrong time.
const other = g.world.tiles.find((t) => !t.hasMine && t !== safe) ?? g.world.tiles[0];
document.querySelector(`.cell[data-x="${other.coord[0]}"][data-y="${other.coord[1]}"]`).click();
document.querySelector('[data-ability="radar"]').click(); // arm only
const armed = {
fired: g.cooldowns.get('radar') !== undefined, // engine untouched by arming
flow: g.flow,
pending: window.minesweeperDx.board.pendingTarget !== null,
status: document.querySelector('#status').textContent,
};
const revealedBefore = g.world.revealedCount();
document.querySelector(`.cell[data-x="${safe.coord[0]}"][data-y="${safe.coord[1]}"]`).click(); // fires here
// Radar is a pure sink: net flow change is exactly the cost, whatever it revealed.
return {
armed,
net: armed.flow - g.flow,
gained: g.world.revealedCount() - revealedBefore,
cd: g.cooldowns.get('radar') ?? 0,
pendingAfter: window.minesweeperDx.board.pendingTarget !== null,
// Center tile of the 3×3 must have been revealed (it is safe + was HIDDEN
// or just-clicked safe; either way it is REVEALED after the radar click).
centerRevealed: g.tile(safe.coord[0], safe.coord[1]).state === 'REVEALED',
};
});
assert.equal(radar.armed.pending, true, 'radar arms on select without firing');
assert.equal(radar.armed.fired, false, 'arming radar does not touch the engine');
assert.match(radar.armed.status, /armed/i, 'status prompts for target');
assert.equal(radar.net, 50, 'next click fires radar: costs 50 flow (ruling 2026-10-29), grants nothing');
assert.ok(radar.cd > 0, 'radar on cooldown after use');
assert.ok(radar.centerRevealed, 'radar fired at the CLICKED tile');
assert.equal(radar.pendingAfter, false, 'radar disarms after firing');
// 4b. Seeker through the ability bar: seeker's internal flag must NOT
// double-tick — exactly ONE turn for the whole use (regression).
const seekerTurn = await page.evaluate(() => {
const app = window.minesweeperDx;
const g = app.board.game;
g.flow = 70; // seeker costs 50f now (ruling 2026-10-29) — affordable regardless of test-2 economy
app.sync(g); // recompute ability-bar gating so the button is enabled
const before = g.turn;
const flagsBefore = g.world.flaggedCount();
const btn = document.querySelector('[data-ability="seeker"]');
const wasEnabled = !btn.disabled;
btn.click();
return {
wasEnabled,
delta: g.turn - before,
flagged: g.world.flaggedCount() - flagsBefore,
hudMatches: document.querySelector('#turn').textContent === String(g.turn),
};
});
assert.ok(seekerTurn.wasEnabled, 'seeker button enabled once affordable');
assert.equal(seekerTurn.flagged, 1, 'seeker placed a flag');
assert.equal(seekerTurn.delta, 1, 'seeker (ability + its flag) = exactly 1 turn');
assert.ok(seekerTurn.hudMatches, 'HUD turn matches the engine after seeker');
// 5. Clearing via engine shows the DECISION screen — and the clear PAYS at
// that moment (ruling 2026-10-24: scrap accrues per board clear; the
// vault only moves on Bank/salvage). Go Deeper advances depth without
// crediting the same clear a second time.
const advance = await page.evaluate(() => {
const g = window.minesweeperDx;
const eng = g.board.game;
// test 3 locked (0,0); engine reveal is inert on FLAGGED — unlock first.
const locked = eng.world.tileAt(0, 0);
if (locked.state === 'FLAGGED' && !locked.hasMine) locked.state = 'HIDDEN';
for (const t of eng.world.tiles) {
if (!t.hasMine && t.state === 'HIDDEN') eng.reveal(t.coord[0], t.coord[1]);
}
g.board.refresh();
g.sync(eng);
return {
shown: !document.querySelector('#decisionScreen').hidden,
title: document.querySelector('#decisionTitle').textContent,
body: document.querySelector('#decisionBody').textContent,
runHud: document.querySelector('#runScrap').textContent,
vault: window.minesweeperDx.progression.scrap,
phase: window.minesweeperDx.phase,
};
});
assert.ok(advance.shown, 'board clear shows the decision screen');
assert.equal(advance.phase, 'decision', 'phase moves to decision');
// Player ruling 2026-10-27 (screen_copy_rewrite): Draft A salvage voice.
assert.equal(advance.title, 'Sector 0 Cleared', 'decision title names the sector depth');
assert.match(advance.body, /\+10 scrap salvaged\./, 'depth-0 clear pays 10 scrap');
assert.match(advance.body, /You're hauling \d+ total\./, 'body reports the total haul');
assert.equal(advance.runHud, '10', 'run-scrap HUD reflects the ledger');
assert.equal(advance.vault, 0, 'earned scrap stays unbanked until Bank');
await page.click('#continueBtn');
const deeper = await page.evaluate(() => ({
depth: Number(document.querySelector('#depth').textContent),
run: window.minesweeperDx.progression.runScrap,
phase: window.minesweeperDx.phase,
}));
assert.equal(deeper.depth, 1, 'Go Deeper advances depth');
assert.equal(deeper.phase, 'running', 'Go Deeper resumes the run');
assert.equal(deeper.run, 10, 'the same clear is never credited twice');
// 5b. Depth scaling pays more (10 × 1.1^Depth); Bank & Exit ends the run
// with the ledger SAFE. Then the Workshop: real-click purchases debit
// the vault, gate on affordability, and Enter The Depths enters a fresh run.
await page.evaluate(() => {
const g = window.minesweeperDx;
const eng = g.board.game;
for (const t of eng.world.tiles) {
if (!t.hasMine && t.state === 'HIDDEN') eng.reveal(t.coord[0], t.coord[1]);
}
g.board.refresh();
g.sync(eng);
});
const secondClear = await page.evaluate(() => ({
body: document.querySelector('#decisionBody').textContent,
run: window.minesweeperDx.progression.runScrap,
}));
assert.match(secondClear.body, /\+11 scrap/, 'depth-1 clear pays 11 (10 × 1.1)');
assert.equal(secondClear.run, 21, 'ledger accumulates across boards');
await page.click('#bankBtn');
const banked = await page.evaluate(() => ({
title: document.querySelector('#summaryTitle').textContent,
vault: window.minesweeperDx.progression.scrap,
run: window.minesweeperDx.progression.runScrap,
ledger: document.querySelector('#summaryLedger').textContent,
boardNote: document.querySelector('#summaryBoardNote').textContent,
boardBtnHidden: document.querySelector('#summaryBoardBtn').hidden,
}));
assert.equal(banked.title, 'Banked', 'Bank & Exit shows the run summary');
assert.equal(banked.vault, 21, 'banked scrap is in the vault');
assert.equal(banked.run, 0, 'ledger emptied by the bank');
assert.match(banked.ledger, /\+21 ⛃/, 'summary records the banked amount');
// Degradation site 1 (bank run-end): cvm absent → honest unavailable note,
// no throw, no submit attempted (depth-v1 ruling 2026-10-28).
assert.equal(
banked.boardNote,
'Leaderboard unavailable on this host.',
'bank-end summary states the leaderboard is unavailable on a bare host',
);
assert.ok(banked.boardBtnHidden, 'no summary leaderboard shortcut without cvm');
await page.click('#summaryWorkshopBtn');
assert.ok(
await page.evaluate(() => !document.querySelector('#shopScreen').hidden),
'Workshop opens from the summary screen',
);
// QA-grant vault scrap, then re-enter the shop so the rows re-render
// against the new balance (rows are rebuilt on every open/purchase).
await page.evaluate(() => { window.minesweeperDx.progression.save.scrap += 500; });
await page.click('#shopBackBtn');
await page.click('#summaryWorkshopBtn');
// Grouped-card layout (ruling 2026-10-28, shop_layout_ruling): three
// archetype sections, all six lines, and the footer CTAs must fit inside
// the frame — the unstyled build clipped them below a 437px viewport.
const shopLayout = await page.evaluate(() => {
const inFrame = (sel) => {
const r = document.querySelector(sel).getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight + 1;
};
return {
titles: [...document.querySelectorAll('.shop-group-title')].map((e) => e.textContent),
cards: document.querySelectorAll('.upgrade-row').length,
ctaVisible: inFrame('#shopStartBtn') && inFrame('#shopBackBtn'),
listScrollable: getComputedStyle(document.querySelector('.upgrade-list')).overflowY === 'auto',
pipsVisible: (() => {
const pip = document.querySelector('.upgrade-row[data-upgrade="radar"] .pip');
return pip.getBoundingClientRect().width >= 6 && pip.getBoundingClientRect().height >= 6;
})(),
};
});
assert.deepEqual(shopLayout.titles, ['Intel', 'Survival', 'Economy'], 'shop is grouped by archetype');
assert.equal(shopLayout.cards, 6, 'all six Workshop lines render');
assert.ok(shopLayout.ctaVisible, 'Enter The Depths / Back stay inside the frame');
assert.ok(shopLayout.listScrollable, 'the upgrade list scrolls inside the panel');
assert.ok(shopLayout.pipsVisible, 'level pips render as visible diamonds');
await page.click('.upgrade-row[data-upgrade="radar"] [data-buy]'); // Lv1→2: 400 (521 → 121)
const bought = await page.evaluate(() => ({
scrap: document.querySelector('#shopScrap').textContent,
pips: document.querySelectorAll('.upgrade-row[data-upgrade="radar"] .pip-on').length,
radarState: document.querySelector('.upgrade-row[data-upgrade="radar"]').classList.contains('unaffordable') ? 'unaffordable' : 'other',
economyState: document.querySelector('.upgrade-row[data-upgrade="economy"]').classList.contains('affordable') ? 'affordable' : 'other',
status: document.querySelector('#status').textContent,
seekerDisabled: document.querySelector('.upgrade-row[data-upgrade="seeker"] [data-buy]').disabled,
stabilizerDisabled: document.querySelector('.upgrade-row[data-upgrade="stabilizer"] [data-buy]').disabled,
economyDisabled: document.querySelector('.upgrade-row[data-upgrade="economy"] [data-buy]').disabled,
levels: window.minesweeperDx.progression.levels,
}));
assert.equal(bought.scrap, '121', 'purchase debits the vault');
assert.equal(bought.pips, 2, 'pip row reflects the purchased level');
assert.equal(bought.radarState, 'unaffordable', 'radar card dims when the next level outprices the vault');
assert.equal(bought.economyState, 'affordable', 'unlocked + affordable cards get the amber outline');
assert.match(bought.status, /Radar Array → Lv\.2/, 'purchase confirms in status');
assert.equal(bought.levels.radar, 2);
assert.ok(bought.seekerDisabled, 'Seeker Lv2 (200) is unaffordable at 121');
assert.ok(bought.stabilizerDisabled, 'Stabilizer Lv2 (200) is unaffordable at 121');
assert.ok(!bought.economyDisabled, 'Data Ledger Lv1 (50, ruling 2026-10-27) is affordable');
await page.click('.upgrade-row[data-upgrade="economy"] [data-buy]'); // 121 → 71
assert.equal(
await page.evaluate(() => document.querySelector('#shopScrap').textContent),
'71',
'second purchase debits again',
);
// Flow Capacitor Lv0→1 (ruling 2026-10-27, flow_cap_ruling): 50 scrap,
// the row exists in the generic shop and the level lands on the save.
await page.click('.upgrade-row[data-upgrade="flow_cap"] [data-buy]'); // 71 → 21
const capped = await page.evaluate(() => ({
scrap: document.querySelector('#shopScrap').textContent,
level: window.minesweeperDx.progression.levels.flow_cap,
pips: document.querySelectorAll('.upgrade-row[data-upgrade="flow_cap"] .pip-on').length,
}));
assert.equal(capped.scrap, '21', 'Flow Capacitor Lv1 debits 50');
assert.equal(capped.level, 1, 'flow_cap_lvl persisted on the live save');
assert.equal(capped.pips, 1, 'pip row reflects the purchased ceiling level');
await page.click('#shopStartBtn');
const freshRun = await page.evaluate(() => {
const app = window.minesweeperDx;
return {
phase: app.phase,
depth: app.board.game.depth,
turn: app.board.game.turn,
run: app.progression.runScrap,
radarLevel: app.board.game.upgrades.radar,
shieldCharges: app.board.game.shieldCharges,
flowCap: app.board.game.flowCap(),
hudFlow: document.querySelector('#flow').textContent,
};
});
assert.deepEqual(freshRun, { phase: 'running', depth: 0, turn: 0, run: 0, radarLevel: 2, shieldCharges: 1, flowCap: 200, hudFlow: '0/200' },
'Enter The Depths from the shop: fresh board, fresh ledger, purchased levels live, run-start shield grant applied (shield Lv1 → exactly 1 charge), and the bought Flow ceiling (200) binds the fresh engine and shows in the HUD');
// 6. Death via engine shows the run SUMMARY; nothing was earned on this
// run, so the vault is untouched. Menu → Enter The Depths begins a fresh run
// (depth, turn AND ledger reset).
const death = await page.evaluate(() => {
const g = window.minesweeperDx;
const eng = g.board.game;
// First-click protection is OFF on the fresh run so the engine can be
// detonated deliberately: this check is about the DEATH SUMMARY, the
// opening-reveal rule is check 6c.
eng.firstClickSafe = false;
// Shield Lv1 (granted in 3c, purchased levels persist): the fresh run
// STARTS with one charge (ruling 2026-10-27) — spend it via QA so this
// check exercises the lethal path, not the absorb.
eng.shieldCharges = 0;
const mine = eng.world.tiles.find((t) => t.hasMine && t.state === 'HIDDEN');
eng.reveal(mine.coord[0], mine.coord[1]);
g.board.refresh();
g.sync(eng);
return {
title: document.querySelector('#summaryTitle').textContent,
cause: document.querySelector('#summaryCause').textContent,
status: document.querySelector('#status').textContent,
ledger: document.querySelector('#summaryLedger').textContent,
boardNote: document.querySelector('#summaryBoardNote').textContent,
vault: g.progression.scrap,
phase: g.phase,
titles: [...g.deathTitles],
};
});
// Degradation site 2 (death run-end): same honest unavailable note; the
// death screen must work fully without cvm (depth-v1 ruling 2026-10-28).
assert.equal(
death.boardNote,
'Leaderboard unavailable on this host.',
'death-end summary states the leaderboard is unavailable on a bare host',
);
// Player ruling 2026-10-27: death title is a random pool one-liner —
// assert membership (guards against 'Run over' regressing back).
assert.ok(death.titles.includes(death.title), 'death shows a pool death-title');
assert.notEqual(death.title, 'Run over', "legacy 'Run over' title is retired");
assert.equal(death.phase, 'summary', 'phase moves to summary');
assert.match(death.cause, /Depth 0/, 'summary names the depth where it ended');
// Player ruling 2026-10-27: UI text never shows the raw ExplosionCause enum.
assert.match(death.cause, /Depth 0 · mine detonated\./, 'summary names the cause in player words');
assert.doesNotMatch(death.cause, /MINE_ON_/, 'no engine enum leaks into the summary');
assert.match(death.status, /Boom — mine detonated/, 'death status line uses player words');
assert.match(death.ledger, /Run scrap0 ◈/, 'ledger records zero earned this run');
assert.match(death.ledger, /Destroyed−0/, 'nothing to destroy when nothing was held');
assert.match(death.ledger, /Vault total21/, 'banked scrap survives death untouched (71 − 50 Flow Capacitor, ruling flow_cap_ruling)');
assert.equal(death.vault, 21, 'a zero-earnings death never touches the vault');
await page.click('#summaryMenuBtn');
const menu = await page.evaluate(() => ({
shown: !document.querySelector('#menuScreen').hidden,
stats: document.querySelector('#menuStats').textContent,
}));
assert.ok(menu.shown, 'summary returns to the menu');
assert.match(menu.stats, /⛃ 21 scrap banked/, 'menu shows the vault total (21 after the Flow Capacitor purchase)');
assert.match(menu.stats, /deepest depth 2/, 'menu records the deepest sector cleared');
await page.click('#startRunBtn');
const menuReset = await page.evaluate(() => ({
depth: window.minesweeperDx.board.game.depth,
turn: window.minesweeperDx.board.game.turn,
run: window.minesweeperDx.progression.runScrap,
hudDepth: document.querySelector('#depth').textContent,
}));
assert.deepEqual(menuReset, { depth: 0, turn: 0, run: 0, hudDepth: '0' },
'new run resets depth, turn AND the run ledger');
// 6b. Regression: after a REAL death → New Run, one right-click must place
// exactly ONE flag and tick EXACTLY +1 turn. Input listeners used to
// re-bind on every rebuild() (stacking N sets on the same root), so a
// single right-click ran flag() N times: with even N the flag vanished
// ("flags can no longer be placed after New Run"), with odd N the turn
// jumped +N (user saw +3).
await page.locator('.cell[data-x="3"][data-y="3"]').click({ button: 'right' });
const postRun = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
return {
state: g.tile(3, 3).state,
flags: g.world.flaggedCount(),
fromZero: g.turn, // New Run resets turn: one flag ⇒ exactly 1
hudMatches: document.querySelector('#turn').textContent === String(g.turn),
};
});
assert.equal(postRun.state, 'FLAGGED', 'first right-click after New Run places the flag');
assert.equal(postRun.flags, 1, 'exactly one flag created by one right-click');
assert.equal(postRun.fromZero, 1, 'one flag on a fresh run = exactly +1 turn');
assert.ok(postRun.hudMatches, 'HUD turn matches engine after post-New-Run flag');
// And removal toggles symmetrically: exactly +1 turn, flag gone.
const turnBeforeUnflag = await page.evaluate(() => window.minesweeperDx.board.game.turn);
await page.locator('.cell[data-x="3"][data-y="3"]').click({ button: 'right' });
const postUnflag = await page.evaluate(
() => window.minesweeperDx.board.game.tile(3, 3).state,
);
assert.equal(postUnflag, 'HIDDEN', 'second right-click removes the flag');
const unflagDelta = (await page.evaluate(() => window.minesweeperDx.board.game.turn)) - turnBeforeUnflag;
assert.equal(unflagDelta, 1, 'flag removal is exactly +1 turn');
// 6c. First-click safety through the REAL UI: this fresh run has opened no
// tile yet (flag toggles don't count). A DOM click that LANDS ON A MINE
// must relocate the mine and open the tile safely — classic Minesweeper
// never lets the opening click detonate.
const firstClick = await page.evaluate(() => {
const g = window.minesweeperDx.board.game;
const mine = g.world.tiles.find((t) => t.hasMine && t.state === 'HIDDEN');
const before = { turn: g.turn, mines: g.world.mineCount() };
document.querySelector(`.cell[data-x="${mine.coord[0]}"][data-y="${mine.coord[1]}"]`).click();
return {
armed: g.firstClickSafe,
spent: g.firstClickSafe === false,
exploded: g.isDead(),
state: g.tile(mine.coord[0], mine.coord[1]).state,
stillMine: g.tile(mine.coord[0], mine.coord[1]).hasMine,
mines: g.world.mineCount(),
minesBefore: before.mines,
turnDelta: g.turn - before.turn,
summaryShown: !document.querySelector('#summaryScreen').hidden,
};
});
assert.ok(firstClick.armed === false || firstClick.spent, 'guarantee spent by the opening click');
assert.equal(firstClick.exploded, false, 'first click on a mine never detonates');
assert.equal(firstClick.state, 'REVEALED', 'the clicked tile opened');
assert.equal(firstClick.stillMine, false, 'the mine moved off the clicked tile');
assert.equal(firstClick.mines, firstClick.minesBefore, 'relocation preserves the mine count');
assert.equal(firstClick.turnDelta, 1, 'the protected opening still costs one turn');
assert.equal(firstClick.summaryShown, false, 'no run summary on the protected opening');
// The guarantee is per-board and spent: the SAME run's next mine click dies.
const secondClick = await page.evaluate(() => {
const app = window.minesweeperDx;
const g = app.board.game;
if (g.isBoardCleared) return { skipped: true };
// Shield Lv1 was granted in check 3c — this run STARTS with one charge
// (ruling 2026-10-27). This check is about lethality once the opening
// guarantee is spent, so spend the run's charges via QA first.
g.shieldCharges = 0;
const mine = g.world.tiles.find((t) => t.hasMine && t.state === 'HIDDEN');
document.querySelector(`.cell[data-x=\"${mine.coord[0]}\"][data-y=\"${mine.coord[1]}\"]`).click();
return { skipped: false, exploded: g.isDead(), summaryTitle: document.querySelector('#summaryTitle').textContent, titles: [...window.minesweeperDx.deathTitles] };
});
if (!secondClick.skipped) {
assert.equal(secondClick.exploded, true, 'after the opening, mine clicks are lethal again');
assert.ok(secondClick.titles.includes(secondClick.summaryTitle), 'run summary shows on the second mine hit');
}
// 7. Depth 11 shows the shift telegraph and hazard render classes.
const chaos = await page.evaluate(() => {
const g = window.minesweeperDx;
const Ctor = Object.getPrototypeOf(g.board.game).constructor;
// The Shifter intro modal (blocking, ruling 2026-10-28) is pinned by the
// dedicated 10d block; suppress it here so this fixture's clicks land.
g.shifterIntroShown = true;
document.querySelector('#shifterIntro').hidden = true;
g.board.game = new Ctor(11, undefined, 7);
g.board.rebuild();
let ice = 0, fire = 0;
for (const t of g.board.game.world.tiles) {
if (ice === 0 && g.board.game.spawnIce(t.coord[0], t.coord[1])) { ice++; continue; }
if (fire === 0 && g.board.game.spawnFire(t.coord[0], t.coord[1])) { fire++; break; }
}
g.board.refresh();
g.sync(g.board.game);
return {
telegraph: !document.querySelector('#shiftStat').hidden,
ice: document.querySelectorAll('.cell-ice').length,
fire: document.querySelectorAll('.cell-fire').length,
};
});
assert.ok(chaos.telegraph, 'shift telegraph visible at depth 11');
assert.equal(chaos.ice, 1, 'ice cell rendered');
assert.equal(chaos.fire, 1, 'fire cell rendered');
// 8. Responsive contract: board square, no page overflow at tier sizes.
for (const [width, height] of [[200, 160], [320, 560], [900, 600]]) {
await page.setViewportSize({ width, height });
const fit = await page.evaluate(() => {
// Phase 4 juice: the death boom shakes #app via transform; the layout
// contract measures geometry, so settle transient effects first (QA
// hook: fx.reset() clears particles, shake offset and chroma).
window.minesweeperDx.fx?.reset();
const b = document.querySelector('.board').getBoundingClientRect();
return {
square: Math.abs(b.width - b.height) <= 2,
overflowY: document.documentElement.scrollHeight > innerHeight + 1,
inView: b.top >= 0 && b.bottom <= innerHeight + 1,
};
});
assert.ok(fit.square, `board square at ${width}x${height}`);
assert.ok(!fit.overflowY, `no vertical overflow at ${width}x${height}`);
assert.ok(fit.inView, `board in view at ${width}x${height}`);
}
// 8b. Menu-actions stacking (clipping fix 2026-10-28): the three menu CTAs
// must never share an edge or overlap — the pre-fix inline flow put the
// wrapped Leaderboard button flush against the row above it. Measured with
// the button un-hidden (bare host keeps it hidden; the geometry rule is
// what regresses, so the probe unhides it deliberately).
await page.evaluate(() => {
const g = window.minesweeperDx;
g.showScreen('menu');
document.querySelector('#menuLeaderboardBtn').hidden = false;
});
for (const [width, height] of [[320, 560], [900, 600]]) {
await page.setViewportSize({ width, height });
const menuFit = await page.evaluate(() => {
const rs = ['startRunBtn', 'menuWorkshopBtn', 'menuLeaderboardBtn'].map(
(id) => document.querySelector('#' + id).getBoundingClientRect(),
);
return {
gaps: rs.slice(1).map((r, i) => Math.round(r.top - rs[i].bottom)),
overlaps: rs.some((a, i) =>
rs.some((b, j) => i < j && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom),
),
};
});
assert.ok(!menuFit.overlaps, `menu buttons never overlap at ${width}x${height}`);
assert.ok(menuFit.gaps.every((gp) => gp >= 4), `menu button gaps >=4px at ${width}x${height}: ${menuFit.gaps}`);
}
await page.evaluate(() => {
document.querySelector('#menuLeaderboardBtn').hidden = true; // restore bare-host state
window.minesweeperDx.showScreen('running');
});
// 8c. Field Manual (ruling 2026-10-28, how_to_play_menu_ruling): MENU
// opens Basics; the HUD `?` lands mid-run on Tools & Workshop without
// disturbing the phase; tab clicks swap panels; Back hands the screen
// straight back. Static-copy invariants are pinned here (approved copy,
// ratified explosion words), and engine enums must never leak into the
// manual — same guard family as the ui_status_copy ruling. Geometry: at
// the 200x160 floor the Back CTA stays in view (capped-panel contract).
await page.evaluate(() => window.minesweeperDx.showScreen('menu'));
await page.click('#menuFieldManualBtn');
const menuHelp = await page.evaluate(() => ({
shown: !document.querySelector('#helpScreen').hidden,
heading: document.querySelector('#helpTitle').textContent,
basicsActive: document.querySelector('#helpTabBasics').classList.contains('active'),
toolsActive: document.querySelector('#helpTabTools').classList.contains('active'),
toolsHidden: document.querySelector('#helpPanelTools').hidden,
menuStillShown: !document.querySelector('#menuScreen').hidden,
text: document.querySelector('#helpScreen').textContent,
}));
assert.ok(menuHelp.shown && menuHelp.menuStillShown, 'Field Manual opens over the menu');
assert.equal(menuHelp.heading, 'Field Manual');
assert.ok(menuHelp.basicsActive && !menuHelp.toolsActive && menuHelp.toolsHidden, 'menu entry lands on Basics');
assert.match(menuHelp.text, /Death destroys half of what you carry\. Bank your scrap to protect it\./, 'bank/death copy');
assert.match(menuHelp.text, /wrap is always on/, 'wrap copy');
assert.match(menuHelp.text, /A mine detonated/, 'explosion word 1 (ratified label)');
assert.match(menuHelp.text, /Mines collided/, 'explosion word 2 (ratified label)');
assert.match(menuHelp.text, /A mine ignited/, 'explosion word 3 (ratified label)');
assert.match(menuHelp.text, /50f, 2T/, 'Radar cost/cooldown numbers (ruling 2026-10-29)');
assert.match(menuHelp.text, /50f, 3T/, 'Seeker cost/cooldown numbers (ruling 2026-10-29)');
assert.match(menuHelp.text, /30f, 10T/, 'Stabilizer cost/cooldown numbers (ruling 2026-10-29)');
assert.doesNotMatch(menuHelp.text, /30f, 2T|10f, 10T/, 'old ability prices never survive a re-price');
assert.doesNotMatch(menuHelp.text, /MINE_ON_/, 'engine enums never leak into the manual');
// Player copy revision (2026-10-28, second pass): Basics carries no exact
// economy numbers — no +5/+20 Flow, no ×1.1 scrap math, no capacitor levels.
// Scoped to the Basics panel; Tools keeps its ratified tool costs.
const basicsText = await page.evaluate(
() => document.querySelector('#helpPanelBasics').textContent,
);
assert.match(basicsText, /one sector at a time, and maybe get some loot/, 'revised opening line');
assert.match(basicsText, /A safe move rewards you with/, 'revised left-click line');
assert.match(basicsText, /locks down the tile/, 'revised right-click line');
assert.match(basicsText, /Death destroys half of what you carry\. Bank your scrap to protect it\./, 'revised death line');
assert.equal(basicsText.split('Death destroys half').length - 1, 1, 'death mechanic stated exactly once on Basics');
assert.doesNotMatch(basicsText, /\+5|\+20|×1\.1|First click/, 'no exact economy numbers or cut lines on Basics');
// Hazards tab copy revision (2026-10-28, second pass): ice duration and fire
// spread counts are deliberately unspecific — nothing to drift when hazards
// reprice. The flood-walls closer was cut entirely.
const hazardsText = await page.evaluate(
() => document.querySelector('#helpPanelHazards').textContent,
);
assert.match(hazardsText, /Sectors past depth 10 come with additional hazards/, 'revised hazards opener');
assert.match(hazardsText, /frozen ground that is too hard to dig through for a time/, 'revised ice line');
assert.match(hazardsText, /trigger an explosion unless a shield charge eats it/, 'revised shift-collision line');
assert.doesNotMatch(hazardsText, /3 turns|up to two|whole shift aborts|flood cascade|flooded open/, 'no exact hazard numbers or cut lines on Hazards');
// Tools tab copy revision (2026-10-28, third pass): plainer opener, shield
// line without the 1-5 charge range or "not an ability" aside, and the two
// economy upgrades as their own bullets instead of one lumped paragraph.
const toolsText = await page.evaluate(
() => document.querySelector('#helpPanelTools').textContent,
);
assert.match(toolsText, /buy upgrades in the Workshop to use them/, 'revised tools opener');
assert.match(toolsText, /increases the number of shield charges the run starts with/, 'revised shield line');
assert.match(toolsText, /Charges carry across sectors\./, 'shield carry-across-sectors line');
assert.match(toolsText, /Data Ledger \u2014 \+25% run scrap per level/, 'Data Ledger own bullet');
assert.match(toolsText, /Flow Capacitor \u2014 \+100 Flow ceiling per level/, 'Flow Capacitor own bullet');
assert.doesNotMatch(toolsText, /Workshop Economy line|not an ability|never refill|1\u20135|Lv\.1/, 'cut asides gone from Tools');
await page.click('#helpTabTools');
const switched = await page.evaluate(() => ({
toolsActive: document.querySelector('#helpTabTools').classList.contains('active'),
basicsHidden: document.querySelector('#helpPanelBasics').hidden,
}));
assert.ok(switched.toolsActive && switched.basicsHidden, 'tab click swaps panels');
await page.click('#helpBackBtn');
const closed = await page.evaluate(() => ({
helpHidden: document.querySelector('#helpScreen').hidden,
menuStillShown: !document.querySelector('#menuScreen').hidden,
}));
assert.ok(closed.helpHidden && closed.menuStillShown, 'Back hands the menu straight back');
// Mid-run entry: `?` opens on Tools, phase untouched; Back leaves the run view intact.
// (showScreen only paints screens; phase is set separately, so drive both.)
await page.evaluate(() => {
window.minesweeperDx.phase = 'running';
window.minesweeperDx.showScreen('running');
});
await page.click('#helpToggle');
const runHelp = await page.evaluate(() => ({
toolsActive: document.querySelector('#helpTabTools').classList.contains('active'),
basicsActive: document.querySelector('#helpTabBasics').classList.contains('active'),
phase: window.minesweeperDx.phase,
hudShown: (document.querySelector('header.hud')?.getClientRects().length ?? 0) > 0,
}));
assert.ok(runHelp.toolsActive && !runHelp.basicsActive, 'mid-run `?` lands on Tools & Workshop');
assert.equal(runHelp.phase, 'running', 'Field Manual never changes the phase');
assert.ok(runHelp.hudShown, 'run HUD stays mounted under the overlay');
// 200x160 floor with the manual open: CTA in view, no page-level overflow.
// Settle first: panel-anim's 0.34 s entrance transform was still running
// after the open click, and the probe measures static geometry.
await page.waitForTimeout(400);
await page.setViewportSize({ width: 200, height: 160 });
const helpFit = await page.evaluate(() => {
const r = document.querySelector('#helpBackBtn').getBoundingClientRect();
return {
backInView: r.top >= 0 && r.bottom <= innerHeight + 1,
overflowY: document.documentElement.scrollHeight > innerHeight + 1,
};
});
assert.ok(helpFit.backInView, 'Field Manual Back is in view at 200x160');
assert.ok(!helpFit.overflowY, 'Field Manual never overflows the page at 200x160');
await page.evaluate(() => {
document.querySelector('#helpBackBtn').click();
window.minesweeperDx.fx?.reset();
});
await page.setViewportSize({ width: 900, height: 600 });
// 9. Proximity numbers render on revealed tiles AND update live when
// mines shift (depth >= 6): revealed tiles are never re-rendered by
// the shift itself, so their numbers must reflect the NEW layout on
// the next refresh.
const numbers = await page.evaluate(() => {
const g = window.minesweeperDx;
const Ctor = Object.getPrototypeOf(g.board.game).constructor;
g.board.game = new Ctor(0, undefined, 123);
const eng = g.board.game;
const world = eng.world;
for (const t of world.tiles) t.hasMine = false;
world.tileAt(5, 5).hasMine = true;
// Reveal the mine's neighbors directly (engine reveal, no UI gating) —
// each is a revealed safe tile that must show "1".
for (const [nx, ny] of [[4,4],[5,4],[6,4],[6,6]]) world.reveal(nx, ny, { flood: false });
g.board.rebuild(); // full DOM rebuild from engine
const shown = document.querySelector('.cell[data-x="4"][data-y="4"]').textContent;
// Shift the mine away from that corner and sync the DOM the way an
// action would (shift + refresh).
world.tileAt(5, 5).hasMine = false;
world.tileAt(6, 5).hasMine = true; // now 0,0-adjacent tiles stay 0; (6,4) keeps its neighbor
g.board.refresh();
const updated = {
stale: document.querySelector('.cell[data-x="4"][data-y="4"]').textContent,
still: document.querySelector('.cell[data-x="6"][data-y="4"]').textContent,
};
return { shown, ...updated };
});
assert.equal(numbers.shown, '1', 'revealed tile shows proximity number');
assert.equal(numbers.stale, '', 'number clears when the neighbor mine shifts away');
assert.equal(numbers.still, '1', 'number persists while still adjacent to a mine');
// 10. Numbers never stretch the grid: with digits rendered, all cells
// must stay uniform (1fr tracks are minmax(0,1fr); an `auto` min-
// content would let a digit grow its row/column).
const geometry = await page.evaluate(() => {
const g = window.minesweeperDx;
const world = g.board.game.world;
for (const t of world.tiles) t.hasMine = false;
world.tileAt(5, 5).hasMine = true;
for (const [x, y] of [[4,4],[5,4],[6,4],[4,5],[6,5],[4,6],[5,6],[6,6]])
world.reveal(x, y, { flood: false });
g.board.refresh();
const heights = new Set();
const widths = new Set();
let digitCells = 0;
for (const c of document.querySelectorAll('.cell')) {
const b = c.getBoundingClientRect();
heights.add(Math.round(b.height * 10) / 10);
widths.add(Math.round(b.width * 10) / 10);
if (c.textContent.trim()) digitCells++;
}
return { rowHeights: heights.size, colWidths: widths.size, digitCells };
});
assert.ok(geometry.digitCells > 0, 'digits on screen for geometry check');
assert.equal(geometry.rowHeights, 1, 'all rows equal height with digits shown');
assert.equal(geometry.colWidths, 1, 'all columns equal width with digits shown');
// 10b. Fire-spread + shield-absorb regression (rules re-ruled 2026-10-27:
// spread fires on ODD per-board turns, up to 2 random candidates per
// burning tile; only fire landing ON the mine tile detonates —
// adjacency is harmless; the absorb bills ONE charge, extinguishes
// the fire over the mine, FLAGS the mine to lock it, and the rest of
// the wave commits).
// Fixture: fire at (5,5), mine at (6,5); reveal all neighbors except
// the mine and safe (4,5) → turn 1's wave ignites exactly those two.
await page.evaluate(() => {
const g = window.minesweeperDx;
// A previous death check left the summary overlay up — drop it back to
// the board so real clicks reach the cells (QA-only, mirrors the runtime
// path a player takes on New run).
g.phase = 'running';
g.showScreen('running');
const Ctor = Object.getPrototypeOf(g.board.game).constructor;
g.board.game = new Ctor(0, undefined, 5);
g.board.rebuild();
const eng = g.board.game;
for (const t of eng.world.tiles) t.hasMine = false;
eng.world.tileAt(6, 5).hasMine = true;
for (const [x, y] of [[4,4],[5,4],[6,4],[4,6],[5,6],[6,6]])
eng.world.reveal(x, y, { flood: false }); // leave (4,5)+(6,5) hidden
eng.spawnFire(5, 5);
eng.shieldCharges = 2;
g.board.refresh();
g.sync(eng);
});
// one real right-click toggle → effective turn 1 (odd: the wave lands)
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const absorb = await page.evaluate(() => {
const eng = window.minesweeperDx.board.game;
return {
status: document.querySelector('#status').textContent,
chargeWarn: document.querySelector('#status').className.includes('warn'),
explosion: eng.world.explosion,
dead: eng.isDead(),
charges: eng.shieldCharges,
onMine: eng.fireAt(6, 5), // fire OVER the mine: must be extinguished
mineAlive: eng.world.tileAt(6, 5).hasMine,
survivor: eng.fireAt(4, 5), // the rest of the wave committed and burns
};
});
assert.match(absorb.status, /Shield charge smothered a burning mine/, 'absorb surfaces a status line');
assert.ok(absorb.chargeWarn, 'absorb status uses the warn tone');
assert.equal(absorb.explosion, null, 'charge consumed the detonation');
assert.equal(absorb.dead, false, 'run continues');
assert.equal(absorb.charges, 1, 'exactly ONE charge billed');
assert.equal(absorb.onMine, false, 'the fire over the mine tile was extinguished');
assert.ok(absorb.mineAlive, 'the mine survives the absorb');
assert.ok(absorb.survivor, 'the non-lethal part of the wave ignited (no discarded spread)');
// turn 2 (even): no spread — the surviving fire touching the mine bills
// NOTHING (adjacency is harmless under the current ruling)
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const settle = await page.evaluate(() => {
const eng = window.minesweeperDx.board.game;
return { charges: eng.shieldCharges, explosion: eng.world.explosion, fire: eng.fireCount() };
});
assert.equal(settle.charges, 1, 'even turns: no spread, no drain, no re-detonation');
assert.equal(settle.explosion, null, 'no phantom re-detonation from adjacency');
assert.equal(settle.fire, 2, 'fire holds at {(5,5),(4,5)} across an even turn');
// turn 3 (odd): the absorb FLAGGED the saved mine (ruling 2026-10-27: the
// absorb locks the tile like a normal flag). A flagged tile is not a
// spread candidate — the mine cannot re-ignite and the second charge is
// never touched.
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const lock = await page.evaluate(() => {
const eng = window.minesweeperDx.board.game;
return {
charges: eng.shieldCharges,
explosion: eng.world.explosion,
dead: eng.isDead(),
onMine: eng.fireAt(6, 5),
flag: eng.world.tileAt(6, 5).state,
};
});
assert.equal(lock.flag, 'FLAGGED', 'the absorb flagged the mine — tile locked');
assert.equal(lock.charges, 1, 'no fresh bill: flag-locked mine is no spread candidate');
assert.equal(lock.explosion, null, 'no re-detonation against the flagged mine');
assert.equal(lock.dead, false, 'still alive');
assert.equal(lock.onMine, false, 'no fire re-lands on the flagged mine');
// turn 4 (even): fire keeps burning and expanding from the survivors
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const burn = await page.evaluate(() => {
const eng = window.minesweeperDx.board.game;
return { charges: eng.shieldCharges, explosion: eng.world.explosion, fire: eng.fireCount() };
});
assert.equal(burn.charges, 1, 'the saved second charge stands untouched; even turns bill nothing');
assert.equal(burn.explosion, null);
assert.ok(burn.fire >= 4, 'fire kept spreading on odd turns (stationary-fire regression)');
// 10c. Mine-on-mine shift-collision shield absorb (ruling 2026-10-27): a
// shift collision must be absorbable like any other mine hit — one
// charge billed, the collision tile FLAGGED (anchor rule freezes the
// chain, so it can never re-collide), run survives, status line shows.
// Fixture: depth-6 board, mines at (3,3)+(2,3), (4,3) flagged so the
// shift east strands (3,3) and (2,3) would land on it.
await page.evaluate(() => {
const g = window.minesweeperDx;
g.phase = 'running';
g.showScreen('running');
g.shifterIntroShown = true; // modal pinned in 10d; keep clicks flowing
document.querySelector('#shifterIntro').hidden = true;
const Ctor = Object.getPrototypeOf(g.board.game).constructor;
g.board.game = new Ctor(6, undefined, 5);
g.board.rebuild();
const eng = g.board.game;
for (const t of eng.world.tiles) t.hasMine = false;
eng.world.tileAt(3, 3).hasMine = true;
eng.world.tileAt(2, 3).hasMine = true;
eng.world.flag(4, 3);
eng.telegraph = [1, 0];
eng.nextShiftIn = 1;
eng.shieldCharges = 1;
g.board.refresh();
g.sync(eng);
});
// one real right-click toggle → commit → shift fires → collision → absorb
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const boom = await page.evaluate(() => {
const eng = window.minesweeperDx.board.game;
return {
status: document.querySelector('#status').textContent,
explosion: eng.world.explosion,
dead: eng.isDead(),
charges: eng.shieldCharges,
flag: eng.world.tileAt(3, 3).state,
mine: eng.world.tileAt(3, 3).hasMine,
mover: eng.world.tileAt(2, 3).hasMine,
};
});
assert.match(boom.status, /Shield charge absorbed a mine collision/, 'collision absorb surfaces its status line');
assert.equal(boom.explosion, null, 'charge consumed the collision');
assert.equal(boom.dead, false, 'the run survives the collision');
assert.equal(boom.charges, 0, 'exactly ONE charge billed');
assert.equal(boom.flag, 'FLAGGED', 'the absorb flagged the collision tile');
assert.ok(boom.mine && boom.mover, 'both mines survive; the aborted shift never stacked');
// next shift: the flagged tile anchors the chain — no repeat collision and
// no phantom drain even with zero charges left.
await page.evaluate(() => { window.minesweeperDx.board.game.nextShiftIn = 1; });
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const anchor = await page.evaluate(() => {
const eng = window.minesweeperDx.board.game;
return { explosion: eng.world.explosion, dead: eng.isDead(), charges: eng.shieldCharges };
});
assert.equal(anchor.explosion, null, 'flag-anchored chain never re-collides');
assert.equal(anchor.dead, false, 'still alive with no charges left');
assert.equal(anchor.charges, 0, 'no silent charge drain');
// 10d. Shifter telegraph feedback (ruling 2026-10-28): the arm/shift/hold
// beats must be legible ON the board — wind overlay + depth-6 intro
// banner while armed, a final-3-turns warning beat, a count-pulse
// afterimage once the shift lands, and an ice-blue freeze readout
// while the Stabilizer holds. Fixture: depth-6 board, telegraph west,
// one mine NW of a revealed tile (its digit dies when the mine drifts
// away), countdown set mid-cycle.
await page.evaluate(() => {
const g = window.minesweeperDx;
g.phase = 'running';
g.showScreen('running');
g.shifterIntroShown = false; // simulate a fresh run reaching depth 6
document.querySelector('#shifterIntro').hidden = true;
const Ctor = Object.getPrototypeOf(g.board.game).constructor;
g.board.game = new Ctor(6, undefined, 9);
g.board.rebuild();
const eng = g.board.game;
for (const t of eng.world.tiles) t.hasMine = false;
eng.world.tileAt(2, 2).hasMine = true;
eng.world.tileAt(8, 8).hasMine = true;
eng.world.reveal(3, 3, { flood: false }); // digit '1' from (2,2)
eng.telegraph = [-1, 0]; // mines drift west
eng.nextShiftIn = 3; // the next step enters the warning band
eng.stabilizedFor = 0;
g.board.refresh();
g.sync(eng);
});
const armed = await page.evaluate(() => {
const app = window.minesweeperDx;
return {
wind: app.fx.telegraphWind,
banner: !document.querySelector('#shifterIntro').hidden,
title: document.querySelector('#shifterIntroTitle').textContent,
body: document.querySelector('#shifterIntro .intro-panel span')
.textContent.replace(/\s+/g, ' ').trim(),
};
});
assert.ok(
armed.wind && armed.wind.dx === -1 && armed.wind.dy === 0 && armed.wind.frozen === false,
'wind overlay armed in the telegraph direction',
);
assert.ok(armed.banner, 'depth-6 intro modal shows when the Shifter first arms');
// Ratified modal copy (ruling 2026-10-28 follow-up): pin the exact strings
// so a future rewrite that quietly drops the wrap warning fails loudly.
assert.equal(armed.title, 'You sense a tremble beneath the earth', 'modal heading is the ratified line');
assert.equal(
armed.body,
'The mines are shifting. Watch the telegraphed movement \u2014 ' +
"and know that your actions aren't the only thing that wraps around the board...",
'modal body is the ratified (polished) line',
);
// BLOCKING ack (ruling follow-up 2026-10-28): the run continues only past
// the acknowledge button — and only then does the backdrop release the board.
await page.locator('#shifterIntroOk').click();
const acked = await page.evaluate(() => ({
hidden: document.querySelector('#shifterIntro').hidden,
}));
assert.ok(acked.hidden, 'intro modal closes only on acknowledgement');
// one real right-click → countdown 3\u21922 enters the final-3 band \u2192 warning beat
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const warn = await page.evaluate(() => {
const s = document.querySelector('#status');
return { status: s.textContent, tone: s.className.includes('warn') };
});
assert.match(warn.status, /Mines drift \u2190 in 2 turns/, 'final-3-turn warning beat sets a status line');
assert.ok(warn.tone, 'warning beat uses the warn tone');
// set the shift itself up: one mine drifts out of (3,3)'s neighborhood \u2192 digit dies
await page.evaluate(() => { window.minesweeperDx.board.game.nextShiftIn = 1; });
await page.locator('.cell[data-x="0"][data-y="0"]').click({ button: 'right' });
const pulse = await page.evaluate(() => ({
pulsing: document.querySelectorAll('.cell-count-pulse').length,
digit: document.querySelector('.cell[data-x="3"][data-y="3"]').textContent,
// B1 restyle pin (audit 2026-10-28): the glow must ride a pseudo-element
// overlay animating opacity — a box-shadow/background-color animation on
// the cell itself is paint-only and reopens the paint-storm.
overlay: (() => {
const el = document.querySelector('.cell-count-pulse');
if (!el) return 'none';
const cs = getComputedStyle(el, '::before');
return cs.animationName === 'cell-count-pulse' && cs.backgroundColor !== 'rgba(0, 0, 0, 0)'
? 'composited'
: 'paint-only';
})(),
cap: window.countPulseCap,
}));
assert.ok(pulse.pulsing >= 1, 'changed proximity digits pulse after the shift lands');
assert.equal(pulse.digit, '', 'the drifted-away mine\u2019s number cleared legibly');
assert.equal(pulse.overlay, 'composited', 'count-pulse rides a compositable ::before overlay (B1)');
assert.ok(
pulse.cap > 0 && pulse.pulsing <= pulse.cap,
`count-pulse storm is capped: ${pulse.pulsing} <= window.countPulseCap=${pulse.cap} (B1)`,
);
// D4 (audit 2026-10-28): ONE delegated animationend strips the class when
// the pulse ends — no per-cell listener churn. After the 620ms window,
// zero cells may still carry the class.
await page.waitForTimeout(750);
const stripped = await page.evaluate(() => document.querySelectorAll('.cell-count-pulse').length);
assert.equal(stripped, 0, 'pulsing cells self-strip after the animation (delegated listener, D4)');
// Stabilizer hold reads on the wind layer (G): freeze + ice tint, chip agrees
const frozen = await page.evaluate(() => {
const app = window.minesweeperDx;
app.board.game.stabilizedFor = 4;
app.sync(app.board.game);
const w = app.fx.telegraphWind;
return { frozen: w ? w.frozen : null, chip: document.querySelector('#shiftIn').textContent };
});
assert.equal(frozen.frozen, true, 'Stabilizer hold freezes the wind overlay readout');
assert.match(frozen.chip, /held 4/, 'HUD chip still names the hold');
// T2 hardening (audit 2026-10-28): one exception escaping draw() must halt
// the rAF loop AND clear the handle, and a later fx action must re-arm it —
// juice may never be dead-for-the-run from a dangling non-zero raf id.
await page.evaluate(() => {
const fx = window.minesweeperDx.fx;
fx.draw = () => { throw new Error('qa forced draw throw'); }; // live wind loop hits it within a frame
});
await page.waitForTimeout(150);
const rafAfterThrow = await page.evaluate(() => window.minesweeperDx.fx.raf);
assert.equal(rafAfterThrow, 0, 'draw exception halts the fx loop and clears the raf handle (T2)');
// While halted, wind must NOT keep repainting (proves the chain is really dead):
const haltedSig = await page.evaluate(async () => {
const c = document.querySelector('#fx');
const sig = () => { const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let s = 0; for (let i = 3; i < d.length; i += 4004) s += d[i]; return s; };
const a = sig();
await new Promise((r) => setTimeout(r, 200));
return { a, b: sig() };
});
assert.equal(haltedSig.a, haltedSig.b, 'halted loop leaves the canvas frozen (chain dead, not spinning)');
await page.evaluate(() => { delete window.minesweeperDx.fx.draw; });
await page.evaluate(() => {
const app = window.minesweeperDx;
const w = app.fx.telegraphWind; // QA-only re-arm, never game logic
if (w) app.fx.setTelegraph({ dx: w.dx, dy: w.dy, urgency: w.urgency, frozen: w.frozen });
});
await page.waitForTimeout(150);
const rearmed = await page.evaluate(() => {
const fx = window.minesweeperDx.fx;
return { raf: fx.raf, wind: fx.telegraphWind !== null };
});
assert.notEqual(rearmed.raf, 0, 'next fx action re-arms the loop after a draw exception (T2)');
assert.equal(rearmed.wind, true, 'wind layer reads again after exception recovery (T2)');
// T4 hardening (audit 2026-10-28): canvas context loss must pause painting
// (stale pixels, not a dead loop), and restore must repaint without reload.
const ctxLoss = await page.evaluate(async () => {
const fx = window.minesweeperDx.fx;
const c = document.querySelector('#fx');
const sig = () => { const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let s = 0; for (let i = 3; i < d.length; i += 32) s += d[i]; return s; };
c.dispatchEvent(new Event('contextlost', { cancelable: true }));
const dropped = fx.ctx2d === null;
await new Promise((r) => setTimeout(r, 250));
const a = sig();
await new Promise((r) => setTimeout(r, 250));
const b = sig();
c.dispatchEvent(new Event('contextrestored'));
await new Promise((r) => setTimeout(r, 300));
return { dropped, frozenWithPixels: a > 0 && a === b, restored: fx.ctx2d !== null, sigAfter: sig() };
});
assert.equal(ctxLoss.dropped, true, 'contextlost drops ctx2d + dead-context gradient caches (T4)');
assert.equal(ctxLoss.frozenWithPixels, true, 'painting pauses with the last frame intact while context is lost (T4)');
assert.equal(ctxLoss.restored, true, 'contextrestored re-acquires the 2D context (T4)');
assert.ok(ctxLoss.sigAfter > 0, 'wind repaints after context restore without a page reload (T4)');
// Wrap cue (ruling wrap_cue_ruling_2026-10-28): static amber portal frame
// on the board pseudo — four chevron SVGs + eight bracket arms, all pinned
// with edge-keyword offsets (calc(100% - Npx) in background-position
// DETACHES by Npx: the % anchors against box-minus-image; that was the
// NE/SE/SW corner break), and the border stays solid (dashed seam was
// player-rejected).
const wrapCue = await page.evaluate(() => {
const b = document.querySelector('.board');
const cs = getComputedStyle(b, '::after');
const pos = cs.backgroundPosition.split(/,\s(?![^(]*\))/).map((s) => s.trim());
const arms = pos.slice(0, 8);
const arrows = pos.slice(8);
return {
urls: (cs.backgroundImage.match(/url\(/g) ?? []).length,
grads: (cs.backgroundImage.match(/linear-gradient/g) ?? []).length,
posCount: pos.length,
armsFlush: arms.every((p) => /^((0(px)?|100%)){1,2}(\s((0(px)?|100%)){1,2})?$/.test(p.replace(/\s+/g, ' '))),
arrowsInset: arrows.filter((p) => p.includes('6px')).length,
arrowCalc: arrows.filter((p) => p.includes('calc(100% - 6px)')).length,
border: getComputedStyle(b).borderStyle.split(' ')[0],
};
});
assert.equal(wrapCue.urls, 4, 'wrap cue keeps four portal chevron SVGs');
assert.equal(wrapCue.grads, 8, 'wrap cue keeps eight corner-bracket arms');
assert.equal(wrapCue.posCount, 12, 'wrap cue keeps 12 background layers (8 arms + 4 arrows)');
assert.ok(wrapCue.armsFlush, 'corner-bracket arms ride flush 0/100% anchors (calc-100% corners detach: NE/SE/SW break)');
assert.equal(wrapCue.arrowsInset, 4, 'all four portal arrows pinned at the same 6px inset from the frame');
assert.equal(wrapCue.arrowCalc, 2, 'E/S arrows serialize as calc(100% - 6px) (edge-keyword offsets, not box-%)');
assert.equal(wrapCue.border, 'solid', 'board border stays solid (dashed seam rejected by player)');
// 11. Still zero runtime errors after the whole playthrough.
assert.equal(errors.length, 0, `runtime errors: ${errors.join('; ')}`);
console.log('browser smoke: 38/38 checks passed (boot→menu, start run, name-gate-absent-bare-host, click flood, no-op-clicks-no-turn, flag+rejected-no-turn, mobile-longpress-flag-survives-release, locked-abilities, radar, seeker-one-turn, decision-pays-once, no-double-credit, depth-scaled-payout, bank-safe-exit, workshop-purchases+flow-cap-lv1+hud-ceiling, fresh-run-reset, death-summary+vault-safe, menu-stats, flag-regression, first-click-safe+second-click-lethal, hazards+telegraph, responsive, menu-actions-stacked-no-clip, field-manual-tabs, proximity numbers live-update, digits do not stretch grid, shield-absorb-visible+fire-burns-on, collision-absorb-anchors-chain, shifter-wind+intro-ack-required, shift-warning-beat, count-pulse-afterimage, stabilizer-freeze-readout, raf-exception-recovery, context-loss-recovery, wrap-cue-pseudo-layer, leaderboard-degrades-on-bare-host, error-free)');
} finally {
await browser.close();
await service.close();
}
