feedback7 R22-LATE: an open detail card draws its field unit's current attack range — own board pieces in prep, any ally operator / summon in battle or on a scouted board; golden: unchanged
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).
This commit is contained in:
1 parent
a1bc37ed90
commit
0ca2a9c21a
12 files changed
+274
-33
No files matched your search
@@ -0,0 +1,92 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { inspectRange } from '../../public/js/screens/game/range.js';
|
||||
import { renderInfo } from '../../public/js/render/app/info.js';
|
||||
import { rangeTiles } from '../../public/js/ui/facing.js';
|
||||
import { unitStatsEntry } from '../../shared/protocol.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const read = (name) => JSON.parse(readFileSync(new URL(`../../data/${name}.json`, import.meta.url), 'utf8'));
|
||||
const chess = read('chess'), tokens = read('tokens');
|
||||
const getChess = (id) => chess[id];
|
||||
const medic = (id = 'char_613_acmedc', extra = {}) => renderInfo({ id: 71, defId: id, kind: 'token', side: 'ally', x: 5, y: 9, dir: 'UP', ...extra });
|
||||
const args = (unit, extra = {}) => ({ target: { kind: 'unit', unit }, detail: { type: 'token', token: tokens[unit.defId] },
|
||||
getChess, field: { prep: true }, ...extra });
|
||||
|
||||
test('user Given Jessica inspected beside her shield When it expires Then her open range turns back with her', () => {
|
||||
const h = makeBattle({ autoFinish: false, units: [
|
||||
{ uid: 1, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_1034_jesca2', skillIndex: 0 }, row: 10, col: 4 },
|
||||
{ uid: 2, kind: 'token', tokenId: 'token_10032_jesca2_jckshd', ownerUid: 1, row: 11, col: 4 },
|
||||
] });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
const unit = h.b.fieldMeta().units.find((v) => v.id === u.id);
|
||||
const input = { target: { kind: 'unit', unit }, detail: { type: 'chess', chess: u.def.raw, diy: true }, getChess, field: {} };
|
||||
const tiles = () => {
|
||||
const r = inspectRange({ ...input, live: unitStatsEntry(u, u.s), snapshot: h.b.snapshot().units.find((v) => v[0] === u.id) });
|
||||
return rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
};
|
||||
assert.deepEqual(tiles(), [[10, 4], [11, 4], [12, 4]]);
|
||||
h.b.retreat(h.unit(2), { reason: 'expired', permanent: true });
|
||||
h.run(0.5);
|
||||
assert.deepEqual(tiles(), [[10, 4], [10, 5], [10, 6]]);
|
||||
});
|
||||
|
||||
test('user Given strategy medics When inspecting them Then their healing tiles rotate on the field', () => {
|
||||
for (const id of ['char_613_acmedc', 'char_605_cmedic']) {
|
||||
const r = inspectRange(args(medic(id)));
|
||||
assert.equal(r.grid.length, 12);
|
||||
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
assert.equal(tiles.length, 12);
|
||||
assert.ok(tiles.some(([row, col]) => row === 12 && col === 4));
|
||||
assert.ok(!tiles.some(([row]) => row === 8));
|
||||
}
|
||||
});
|
||||
|
||||
test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => {
|
||||
const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } }));
|
||||
assert.equal(r.grid.length, 18);
|
||||
assert.deepEqual([r.row, r.col, r.dir], [10, 8, 'UP']);
|
||||
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
assert.ok(tiles.some(([row, col]) => row === 15 && col === 9));
|
||||
assert.ok(!tiles.some(([, col]) => col === 5));
|
||||
});
|
||||
|
||||
test('user Given an enemy, scouted hand or absent battle unit When inspecting Then no attack tiles appear', () => {
|
||||
for (const extra of [{ side: 'enemy' }, { area: 'hand' }, { area: 'temp' }, { kind: 'device' }]) {
|
||||
assert.equal(inspectRange(args(medic(undefined, extra))), null);
|
||||
}
|
||||
assert.equal(inspectRange(args(medic(), { field: { prep: false } })), null);
|
||||
assert.equal(inspectRange(args(medic(), { field: {}, snapshot: [71, 5, 9, 0, 200] })), null);
|
||||
assert.equal(inspectRange(args(medic(), { showPrep: true })), null);
|
||||
assert.equal(inspectRange(args(medic(), { live: { range: [] } })), null);
|
||||
});
|
||||
|
||||
test('user Given an own board operator When its details open Then its range uses the board direction and clears off board', () => {
|
||||
const piece = { kind: 'chess', id: 'chess_char_1_01_a', uid: 5, dir: 'LEFT' };
|
||||
const input = { target: { kind: 'piece', uid: 5 }, detail: { type: 'chess', chess: chess[piece.id], piece },
|
||||
pieces: new Map([[5, { piece, area: 'board', row: 11, col: 2 }]]), showPrep: true, getChess };
|
||||
const r = inspectRange(input);
|
||||
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
assert.ok(tiles.every(([, col]) => col >= 0 && col <= 2));
|
||||
assert.ok(tiles.some(([, col]) => col === 0));
|
||||
assert.equal(inspectRange({ ...input, pieces: new Map() }), null);
|
||||
assert.equal(inspectRange({ ...input, target: { kind: 'chess', id: piece.id } }), null);
|
||||
assert.equal(inspectRange({ ...input, showPrep: false }), null);
|
||||
});
|
||||
|
||||
test('a scouted prep board (the server\'s m.field): the board operator draws its range, the bench (area hand / temp) none', async () => {
|
||||
const { makeMatch, give, giveItem, legalTileFor, chessOfTier } = await import('../match/harness.js');
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const b = h.ps('p_1');
|
||||
m.handle('p_1', { t: 'g.ready', ready: false });
|
||||
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) if (p.kind === 'chess') b.returnCopies(p);
|
||||
b.board.clear(); b.hand.fill(null); b.temp.fill(null); b.recompute();
|
||||
const [id1, id2, id3] = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
|
||||
const deployed = give(m, b, id1);
|
||||
const [r, c] = legalTileFor(m, b, id1);
|
||||
m.handle('p_1', { t: 'g.move', uid: deployed.uid, to: { area: 'board', row: r, col: c }, dir: 'UP' });
|
||||
const held = give(m, b, id2);
|
||||
while (b.hand.some((x) => x == null)) giveItem(m, b, 'chess_item_6_09_e_a'); // a full hand keeps the temp piece in temp
|
||||
const waiting = give(m, b, id3, 'temp');
|
||||
m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' });
|
||||
const field = h.lastTo('p_0', 'm.field');
|
||||
const range = (piece) => {
|
||||
const unit = renderInfo(field.units.find((u) => u.uid === piece.uid));
|
||||
return inspectRange({ target: { kind: 'unit', unit }, detail: { type: 'chess', chess: chess[piece.id] }, field, getChess });
|
||||
};
|
||||
const own = range(deployed);
|
||||
assert.deepEqual([own.row, own.col, own.dir], [r, c, 'UP']);
|
||||
assert.equal(range(held), null, 'a held operator (area hand) has no field range');
|
||||
assert.equal(range(waiting), null, 'nor one in the 临时整备区 (area temp)');
|
||||
m.dispose();
|
||||
});
|
||||
@@ -23,7 +23,8 @@ globalThis.fetch = async (url) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { TokenDetail, summonDeployHint, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { tokenVariantFor } = await import('../../public/js/ui/gameLogic/loadout.js');
|
||||
const { indexPieces } = await import('../../public/js/ui/gameLogic.js');
|
||||
const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js');
|
||||
const simTokens = await import('../../server/sim/content/tokens.js');
|
||||
|
||||
Reference in new issue
Block a user