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.
// 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);
};
