Back to Squad Table
SOURCE / PINNED RELEASE

Made of little things.

Squad Table

Release
d36201f8ed4f…
Author-recorded commit
7b9bb681d6ba…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d

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

tests/hotseat.mjs
// Hotseat walkthrough in the real host: setup, locked phases, a Prep Fire
// attack through the Actions menu, and the Prep Fire movement restriction.
// Run: soyli build && soyli multiplayer tests/hotseat.mjs --timeout 120
// Set SHOTS=<dir> to save screenshots.

const SHOTS = process.env.SHOTS;

async function tapHex(frame, id) {
  const pos = await frame.evaluate((hexId) => {
    const view = document.querySelector('svg.board > g');
    const [, tx, ty, s] = /translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\)/.exec(view.getAttribute('transform')).map(Number);
    const m = /^([A-Z]+)(\d+)$/.exec(hexId);
    const c = m[1].charCodeAt(0) - 65;
    const r = Number(m[2]);
    return { x: (60 * c + 40) * s + tx, y: (Math.sqrt(3) * 40 * (r - 1 + 0.5 * (c & 1)) + Math.sqrt(3) * 20) * s + ty };
  }, id);
  await frame.locator('svg.board').click({ position: pos });
}

async function endPhase(frame) {
  const before = await frame.locator('#phaseLabel').innerText();
  const turn = await frame.locator('#turnLabel').innerText();
  await frame.locator('#endPhase').click();
  await frame.locator('#endPhase').click(); // confirm
  await frame.waitForFunction(
    ([p, t]) => document.querySelector('#phaseLabel').textContent !== p || document.querySelector('#turnLabel').textContent !== t,
    [before, turn],
  );
}

export default async ({ players, check }) => {
  const p = players[0];
  const f = p.frame;
  await p.page.setViewportSize({ width: 1300, height: 1100 });
  await p.page.evaluate(() => {
    const fr = document.querySelector('iframe');
    fr.style.cssText += ';width:1100px !important;height:720px !important;min-height:0 !important;max-width:none !important;flex:none !important;';
    fr.scrollIntoView();
  });
  const shot = async (name) => SHOTS && p.page.locator('iframe').screenshot({ path: `${SHOTS}/${name}.png` });
  const errors = [];
  p.page.on('pageerror', (e) => errors.push(e.message));

  await f.getByRole('button', { name: 'Hotseat on this device', exact: true }).click();
  await f.locator('svg.board [data-hex]').first().waitFor();

  // Setup: put a squad, a leader and an LMG in H4.
  await f.getByRole('tab', { name: 'Stack' }).click();
  await f.locator('[data-box="pool-a"]').click();
  await f.locator('[data-act="pick-all"]').click(); // pick none
  for (const name of ['6-6-6', '9-1', 'LMG']) await f.locator('.stack-item .pick', { hasText: name }).first().click();
  await f.locator('[data-act="move"]').click();
  await tapHex(f, 'H4');
  await f.locator('svg.board [data-hex="H4"][data-count="3"]').waitFor();
  check('setup placement is free', true);

  check('no way to go back a phase', (await f.locator('#prevPhase').count()) === 0);
  await endPhase(f); // Setup → Rally
  const label = `${await f.locator('#turnLabel').innerText()} ${await f.locator('#phaseLabel').innerText()}`;
  check(`setup ends into turn 1 Rally (${label})`, /T1\/6 Rally/i.test(label));
  await endPhase(f); // Rally → Prep Fire
  check('then Prep Fire', /prep fire/i.test(await f.locator('#phaseLabel').innerText()));

  // Fire through the Actions menu.
  await f.getByRole('tab', { name: 'Actions' }).click();
  // H4 may already be selected from setup; otherwise pick it from the hex chips.
  if (await f.locator('[data-goto="H4"]').count()) await f.locator('[data-goto="H4"]').click();
  await shot('fire-menu');
  await f.locator('[data-act="fire-start"]').click();
  check('valid targets are highlighted', (await f.locator('svg.board .target-label').count()) > 0);
  await shot('fire-targets');
  // J4's building blocks the view of K4; K6 is in sight.
  await tapHex(f, 'K4');
  check('a blocked target is refused', (await f.locator('.fire-card').count()) === 0);
  await tapHex(f, 'K6');
  await f.locator('.fire-card').waitFor();
  const card = await f.locator('.fire-card').innerText();
  check(`fire card shows firepower and DRMs (${card.replace(/\s+/g, ' ').slice(0, 120)})`, /Total/.test(card) && /column/.test(card) && /Stone building/.test(card) && /Leader/.test(card));
  await shot('fire-card');
  await f.locator('#zoomIn').click();
  await f.locator('#zoomIn').click();
  await f.locator('[data-act="fire-roll"]').click();
  await new Promise((r) => setTimeout(r, 200));
  await shot('fire-effect-0');
  await new Promise((r) => setTimeout(r, 250));
  await shot('fire-effect-1');
  check('shooting effects play', (await f.locator('svg.board .fx > *').count()) > 0);
  await new Promise((r) => setTimeout(r, 900));
  await shot('fire-effect-2');
  check('result labels float over the target', (await f.locator('svg.board .fx-label').count()) > 0);
  await f.locator('.result-card').first().waitFor();
  const result = await f.locator('.result-card').first().innerText();
  check(`result applied automatically (${result.replace(/\s+/g, ' ').slice(0, 140)})`, /Prep Fire H4 → K6/.test(result));
  check('dice shown', !(await f.locator('#diceTray').isHidden()));
  check('a Prep Fire marker is placed on the firing stack', (await f.locator('svg.board [data-hex="H4"] [data-marker="mk-prep"]').count()) === 1);
  await new Promise((r) => setTimeout(r, 2500));
  check('effects clean themselves up', (await f.locator('svg.board .fx > *').count()) === 0);
  await shot('fire-result');

  // Prep-fired units cannot move.
  await endPhase(f); // → Movement
  await f.getByRole('tab', { name: 'Stack' }).click();
  await tapHex(f, 'P10');
  await tapHex(f, 'H4');
  await f.locator('[data-act="move"]').click();
  await f.locator('#modeText', { hasText: 'Prep Fire' }).waitFor({ timeout: 5000 });
  check('prep-fired units are blocked from moving', true);
  await f.getByRole('button', { name: 'Cancel', exact: true }).click();

  // Defensive Fire switches the hotseat to the defender.
  await endPhase(f); // → Defensive Fire
  check('defensive fire hands control to the defender', /GE/.test(await f.locator('#seatChip').innerText()));
  await f.getByRole('tab', { name: 'Actions' }).click();
  await shot('defensive-fire');
  check('no page errors', errors.length === 0);
};