Back to Shiftfield
SOURCE / PINNED RELEASE

Made of little things.

Shiftfield

Release
5a170c505f6b…
Author-recorded commit
a2e23e0171aa…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd

Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

scripts/capture-cover.mjs
/* Cover capture: stages a characteristic depth-11 frame with ZERO turns spent
 * (turns at depth 11 tick hazards: fire ignites adjacent mines and can end the
 * run, so mutate game state directly then re-render, never click).
 * Frame: diagonal lit frontier with numbers, 4 flags, ice + fire hazards,
 * shift telegraph 3 turns out (wind streaks ramp), flow/scrap HUD story.
 * Usage: node scripts/capture-cover.mjs <out.png> [seed]
 */
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { dirname, join, extname, resolve } 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 out = resolve(process.argv[2] ?? 'preview-cover.png');
const SEED = Number(process.argv[3] ?? 7);

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));
  return { url: `http://127.0.0.1:${server.address().port}/`, close: () => server.close() };
}

const service = await serveDist();
const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 760, height: 800 }, deviceScaleFactor: 2 });
  const errors = [];
  page.on('pageerror', (e) => errors.push(String(e)));
  await page.goto(service.url, { waitUntil: 'load' });
  await page.waitForSelector('#menuScreen:not([hidden])');
  await page.click('#startRunBtn');
  await page.waitForTimeout(300);

  const stage = await page.evaluate(({ seed }) => {
    const app = window.minesweeperDx;
    const Ctor = Object.getPrototypeOf(app.board.game).constructor;
    app.shifterIntroShown = true;
    document.querySelector('#shifterIntro').hidden = true;
    app.board.game = new Ctor(11, undefined, seed);
    const g = app.board.game;

    // Hazards on hidden safe tiles (spawn only; no turn spent).
    let ice = 0, fire = 0;
    for (const t of g.world.tiles) {
      if (t.state !== 'HIDDEN' || t.hasMine) continue;
      if (ice < 2 && g.spawnIce(t.coord[0], t.coord[1])) { ice++; continue; }
      if (fire < 2 && g.spawnFire(t.coord[0], t.coord[1])) { fire++; }
    }

    // Diagonal lit frontier: reveal safe tiles in the upper-left wedge, keep the
    // lower-right dark with mines hidden. Numbers render from adjacency, so this
    // paints a fully consistent board after rebuild().
    const hazardAt = new Set();
    for (const t of g.world.tiles) {
      if (t.ice || t.fire) hazardAt.add(t.coord.join(','));
    }
    for (const t of g.world.tiles) {
      if (t.state !== 'HIDDEN' || t.hasMine) continue;
      const [x, y] = t.coord;
      if (x + y <= 10 && !hazardAt.has(`${x},${y}`)) t.state = 'REVEALED';
    }

    // Flags on hidden safe tiles hugging the frontier.
    let flags = 0;
    for (const t of g.world.tiles) {
      if (flags >= 4) break;
      if (t.state !== 'HIDDEN' || t.hasMine || hazardAt.has(t.coord.join(','))) continue;
      const [x, y] = t.coord;
      const nearFrontier = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => {
        const n = g.world.tileAt(x + dx, y + dy);
        return n && n.state === 'REVEALED';
      });
      if (nearFrontier) { t.state = 'FLAGGED'; flags++; }
    }

    // HUD story + tension. runScrap is a plain field; banked scrap is a getter
    // over save.scrap; telegraph forced horizontal (diagonal arrows are missing
    // glyphs in the headless font and would render tofu in the cover).
    g.flow = 84;
    g.turn = 14;
    g.nextShiftIn = 3;
    g.telegraph = [1, 0];
    app.progression.runScrap = 480;
    app.progression.save.scrap = 1250;
    app.board.rebuild();
    app.sync(g);
    return {
      depth: g.depth, ice, fire, flags,
      revealed: g.world.tiles.filter((t) => t.state === 'REVEALED').length,
      hiddenMines: g.world.tiles.filter((t) => t.hasMine && t.state === 'HIDDEN').length,
      turn: g.turn,
    };
  }, { seed: SEED });

  await page.waitForTimeout(1100); // wind streaks paint a few frames at shift-urgency
  // Replace the fresh-run toast with in-run status copy for the frame, and
  // swap the chip's leading ⇄ glyph (missing in the headless capture font).
  await page.evaluate(() => {
    const s = document.querySelector('#status');
    s.textContent = 'Hauling 480 — the seams are glimmering.';
    s.className = 'status status-warn';
    const chip = document.querySelector('#shiftStat');
    for (const n of chip.childNodes) {
      if (n.nodeType === Node.TEXT_NODE && n.textContent.includes('⇄')) n.textContent = '\u00bb ';
    }
  });
  await page.waitForTimeout(400);
  const stats = await page.evaluate(() => ({
    phase: window.minesweeperDx.phase,
    revealed: document.querySelectorAll('.cell-revealed').length,
    flagged: document.querySelectorAll('.cell-flagged').length,
    ice: document.querySelectorAll('.cell-ice').length,
    fire: document.querySelectorAll('.cell-fire').length,
    telegraph: !document.querySelector('#shiftStat').hidden,
  }));
  console.log(JSON.stringify({ stage, stats }));
  if (stats.phase !== 'running') throw new Error(`run not live at capture (phase=${stats.phase})`);
  if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
  await page.screenshot({ path: out });
  console.log('wrote', out);
} finally {
  await browser.close();
  service.close();
}