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.

tests/board-scenario.mjs
// Board scenario — real submit/read round-trips against soyLI's disposable
// local CVM (depth-v1 ruling 2026-10-28, ratified test tier 2).
// Run: soyli multiplayer tests/board-scenario.mjs --players 2
// Trusted local test code (docs/napplet-backend.md §Repeatable multiplayer
// scenarios). Players are sequential here on purpose: deterministic ranks.
// The sandboxed game exposes the same window.minesweeperDx QA hook the bare
// browser suite uses; clearings are driven through the engine, bankings and
// navigation through real frame clicks.

/** Reveal every safe tile on the current board (browser.test.mjs pattern). */
async function clearBoard(frame) {
  await frame.evaluate(() => {
    const app = window.minesweeperDx;
    const eng = app.board.game;
    for (const t of eng.world.tiles) {
      if (!t.hasMine && t.state === 'HIDDEN') eng.reveal(t.coord[0], t.coord[1]);
    }
    app.board.refresh();
    app.sync(eng);
  });
}

/** Wait for the summary's board note to settle off 'Posting score…'. */
async function settleBoardNote(frame) {
  const note = frame.locator('#summaryBoardNote', {
    hasText: /Deepest|posted|not posted/i,
  });
  await note.waitFor({ state: 'visible', timeout: 15000 });
  // One more beat: the rank line arrives via a follow-up read after submit.
  const text = await note.textContent();
  if (/posting score/i.test(text ?? '')) {
    await note.evaluate((el) =>
      new Promise((resolve) => {
        if (!/posting score/i.test(el.textContent ?? '')) return resolve();
        new MutationObserver((_, obs) => {
          if (!/posting score/i.test(el.textContent ?? '')) {
            obs.disconnect();
            resolve();
          }
        }).observe(el, { childList: true, characterData: true, subtree: true });
      }),
    );
  }
  return (await note.textContent()) ?? '';
}

export default async ({ players, check }) => {
  const [p1, p2] = players.map((p) => p.frame);

  // ---- Player 1: one cleared board, banked (score 1), named by the
  // ONE-TIME first-run gate (first-run name gate ruling 2026-10-29): the
  // FIRST Enter The Depths click must be interrupted by #nameGate; naming
  // there guarantees the first accepted submit carries the handle.
  await p1.locator('#startRunBtn').click();
  check(
    'p1 first start is gated by the one-time runner-name modal',
    await p1.locator('#nameGate').isVisible() &&
      (await p1.locator('#nameGateInput').getAttribute('maxlength')) === '24',
  );
  await p1.locator('#nameGateInput').fill('scen-p1');
  await p1.locator('#nameGateOk').click();
  await clearBoard(p1);
  await p1.locator('#bankBtn').click();
  const armed = await p1.locator('#summaryBoardNote').textContent();
  check(
    'p1 summary arms the post and invites a runner name (no instant submit)',
    /runner name/i.test(armed ?? '') && !/posted|posting score/i.test(armed ?? ''),
  );
  await p1.locator('#playerNameInput').fill('scen-p1');
  await p1.locator('#playerNameInput').press('Enter'); // flushes the armed post
  const note1 = await settleBoardNote(p1);
  check(
    'p1 run-end note confirms a posted personal best',
    /#1 · Deepest Depth 1|Deepest depth 1 posted/.test(note1),
  );

  // ---- Player 2: one cleared board too (score 1, first submit accepted). -
  // Named by their own first-run gate (identical gate in a separate identity).
  await p2.locator('#startRunBtn').click();
  await p2.locator('#nameGateInput').fill('scen-p2');
  await p2.locator('#nameGateInput').press('Enter'); // Enter also confirms
  await clearBoard(p2);
  await p2.locator('#bankBtn').click();
  await p2.locator('#playerNameInput').fill('scen-p2');
  await p2.locator('#playerNameInput').press('Enter');
  const note2 = await settleBoardNote(p2);
  check(
    'p2 run-end note confirms a posted personal best',
    /Deepest [Dd]epth 1|posted/.test(note2),
  );

  // ---- Player 2 renames mid-summary, then beats their best. ---------------
  // A same-score resubmit can NOT rename a row (the service only promotes
  // strictly-better personal bests), so the rename is re-tested with score 2:
  // clearing two boards posts a strictly-better submit carrying the new name.
  await p2.locator('#playerNameInput').fill('scen-p2-renamed');
  await p2.locator('#playerNameInput').press('Tab'); // fires the change persist
  await p2.locator('#summaryMenuBtn').click();
  await p2.locator('#startRunBtn').click();
  check(
    'the name gate never interrupts a second time (name_prompted latched)',
    await p2.locator('#nameGate').isHidden(),
  );
  await clearBoard(p2);
  await p2.locator('#continueBtn').click();
  await clearBoard(p2);
  await p2.locator('#bankBtn').click();
  // The input pre-fills with the persisted name; Enter flushes the armed post
  // with it (the score here is strictly better, so it renames the row).
  await p2.locator('#playerNameInput').press('Enter');
  const note3 = await settleBoardNote(p2);
  check(
    'p2 better score ranks first with the new personal best',
    /#1 · Deepest Depth 2|Deepest depth 2 posted/.test(note3),
  );

  // ---- Player 1 opens the shared leaderboard: both rows, own highlighted. -
  await p1.locator('#summaryBoardBtn').click();
  await p1.locator('#boardRows .ledger-row').first().waitFor({ timeout: 10000 });
  const board = await p1.evaluate(() => ({
    rows: [...document.querySelectorAll('#boardRows .ledger-row')].map((r) => ({
      text: r.textContent,
      own: r.classList.contains('own'),
    })),
    note: document.querySelector('#boardNote').textContent,
  }));
  check('shared board shows both runners', board.rows.length === 2);
  check(
    'top row is p2 with the rename carried by the better score and score 2',
    board.rows[0]?.text.includes('scen-p2-renamed') &&
      board.rows[0]?.text.includes('2'),
  );
  check(
    'p1 own row is highlighted and carries the name typed at first post',
    board.rows.some((r) => r.own && r.text.includes('scen-p1')) &&
      /Top 2 · client-reported/.test(board.note),
  );
  // Regression pin for the 2026-10-29 bug: a row named via the summary field
  // must NEVER fall back to the fallback handle.
  check(
    'no board row shows the unnamed-runner fallback',
    board.rows.every((r) => !r.text.includes('unnamed-runner')),
  );
};