GitHub PR #281 by @IceCodeNew (this commit is substantially the PR, ported onto 0.2.2-dev; the field-detail range idea after @BBleae's fork commits c6437f1 / b451bad, credited here instead of the PR's Co-authored-by trailers). The unofficial skill-range preview the PR first had is gone (the owner's reply of 2026-10-07: only official-like behaviour); what remains is the range the unit fights with now. screens/game/range.js inspectRange resolves the open card's unit: an own board piece in prep (its loadout or stand-in record, its facing — this replaces the prep selection's range effect); in battle or on a scouted board an ally op / token on the field, at the snapshot's tile, with the live stats' range when there is one; never an enemy, a dead or undeployed unit, or a scouted bench unit (UnitInfo `area` hand / temp — the tag the server sends since the #385 fix; renderInfo now keeps it). shared/protocol.js unitStatsEntry gains an optional `dir` (the unit's facing now: the snapshot tuples carry none and UnitInfo.dir is the facing at send time). tokenVariantFor moves to gameLogic/loadout.js. Drag, the wheel and the pen clear the overlay. Added on top of the PR: a test from the server's own scout m.field (board operator ranged, hand / temp none), docs (PLAYING.md, design/client.md, design/network.md), comments. Tests: test/ui/inspect-range.test.js (6), test/ui/playtest6_summons.test.js, test/match/unitStats.test.js; test/ui/inspect-range.e2e.test.js (SP_E2E + CHROME_PATH: run here, 1/1). Golden unchanged (display; m.unitStats only).
93 lines
5.3 KiB
JavaScript
93 lines
5.3 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync } from 'node:fs';
|
|
import { startServer } from '../../server/index.js';
|
|
import { makeBattle } from '../helpers/battleHarness.js';
|
|
import { spawnMapChar } from '../../server/sim/content/tokens.js';
|
|
|
|
const chrome = process.env.CHROME_PATH;
|
|
const enabled = process.env.SP_E2E === '1' && chrome && existsSync(chrome);
|
|
|
|
test('user Given a real battle with strategy medics When clicking units and closing details Then attack tiles follow selection',
|
|
{ skip: !enabled && 'Set SP_E2E=1 and CHROME_PATH', timeout: 60000 }, async () => {
|
|
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
|
const puppeteer = (await import('puppeteer-core')).default;
|
|
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'] });
|
|
try {
|
|
for (const medicId of ['char_613_acmedc', 'char_605_cmedic']) {
|
|
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], autoFinish: false });
|
|
h.step();
|
|
assert.ok(spawnMapChar(h.b, h.b.players[0].playerId, medicId));
|
|
h.step();
|
|
const field = h.b.fieldMeta();
|
|
const snapshot = h.b.snapshot();
|
|
assert.equal(field.units.length, 2);
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
|
|
const errors = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
await page.goto(`http://127.0.0.1:${server.port}/dev/game-mock.html?render=fallback&shot=1&phase=PREP`);
|
|
await page.waitForSelector('.ff-piece');
|
|
await page.evaluate(async ({ field, snapshot }) => {
|
|
const { store } = await import('/js/store.js');
|
|
const { net } = await import('/js/net.js');
|
|
const pub = store.get().match.public;
|
|
store.patch('match', { public: { ...pub, phase: 'COMBAT', fields: [{ fieldId: field.fieldId, players: [pub.players[0].playerId] }] }, field });
|
|
// The local runner emits the snapshot synchronously, before the screen's field-entry effect.
|
|
net._emit('b.snap', snapshot);
|
|
}, { field, snapshot });
|
|
await page.waitForSelector('.ff-unit');
|
|
for (let i = 0; i < field.units.length; i++) {
|
|
const units = await page.$$('.ff-unit');
|
|
await units[i].click();
|
|
await page.waitForSelector('.dpanel');
|
|
await page.waitForSelector('.ff-tile.is-range');
|
|
const lit = await page.$$eval('.ff-tile.is-range', (els) => els.map((e) => [Number(e.dataset.row), Number(e.dataset.col)]));
|
|
const u = field.units[i];
|
|
if (u.defId === medicId) {
|
|
assert.ok(lit.some(([r, c]) => r === 10 && c === 5), 'medic reaches three columns right from the stage tile');
|
|
assert.ok(!lit.some(([, c]) => c === 6), 'inactive skill does not extend the base range');
|
|
}
|
|
// A new wire snapshot must refresh the open overlay, not just the unit sprite.
|
|
await page.evaluate(async ({ snapshot, id }) => {
|
|
const { net } = await import('/js/net.js');
|
|
net._emit('b.snap', { ...snapshot, units: snapshot.units.map((v) => v[0] === id ? [v[0], v[1] + 1, ...v.slice(2)] : v) });
|
|
}, { snapshot, id: u.id });
|
|
await page.waitForFunction((before) => {
|
|
const now = [...document.querySelectorAll('.ff-tile.is-range')].map((e) => [Number(e.dataset.row), Number(e.dataset.col)]);
|
|
return now.length === before.length && now.every(([r, c]) => before.some(([br, bc]) => br === r && bc + 1 === c));
|
|
}, {}, lit);
|
|
await page.evaluate(async () => {
|
|
const { store } = await import('/js/store.js');
|
|
store.patch('match', { public: { ...store.get().match.public, phase: 'SETTLE' } });
|
|
// Allow Preact's after-paint phase effects to run before asserting retained UI.
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
});
|
|
assert.ok(await page.$('.dpanel'), 'same-field settlement keeps the inspected card');
|
|
assert.ok(await page.$('.ff-tile.is-range'), 'same-field settlement keeps its range');
|
|
await page.click('.dpanel__close');
|
|
await page.waitForFunction(() => !document.querySelector('.ff-tile.is-range'));
|
|
await page.evaluate(async (snapshot) => {
|
|
const { net } = await import('/js/net.js');
|
|
net._emit('b.snap', snapshot);
|
|
}, snapshot);
|
|
}
|
|
await (await page.$$('.ff-unit'))[0].click();
|
|
await page.waitForSelector('.ff-tile.is-range');
|
|
await page.evaluate(async ({ field, snapshot }) => {
|
|
const { store } = await import('/js/store.js');
|
|
const { net } = await import('/js/net.js');
|
|
const fieldId = `${field.fieldId}:next`;
|
|
store.patch('match', { field: { ...field, fieldId } });
|
|
net._emit('b.snap', { ...snapshot, fieldId });
|
|
}, { field, snapshot });
|
|
await page.waitForFunction(() => !document.querySelector('.dpanel') && !document.querySelector('.ff-tile.is-range'));
|
|
assert.deepEqual(errors, []);
|
|
await page.close();
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
await server.close();
|
|
}
|
|
});
|