feedback7: merge fb7-late — #389 / PR #390 画卷 keeps the items to copy across a merge, #385 / PR #387 spectator bond details count the hand like the own popup, #394 / PR #395 Enter during facing selection follows the focus, #281 the attack range drawn on a tapped operator, #388 引星棘刺 S1 DEF stacks, PR #386 谢拉格 寒风 blows bottom-up (owner)
- Golden union OK (roster-033 / roster-034 moved on both sides: #298 and #388); broad node --test 5598 (5596 pass, 0 fail, 2 skipped); lint 0 errors.
This commit is contained in:
commit
2492966c4f
32 files changed
+913
-138
No files matched your search
@@ -0,0 +1,157 @@
|
||||
// The facing wheel's keyboard in the mock match (headless Chrome, the DOM field — no art / WebGL needed): GitHub #394 /
|
||||
// PR #395 (the drag / Tab flow of this file is the PR's, by @Xinglan233). Enter follows the focus while the wheel is
|
||||
// open — the wheel takes the focus when it opens, so arrows + Enter confirm (also with a HUD button focused before the
|
||||
// drop); Tab reaches ✕ first and Enter on it cancels; Enter on any other button (准备, 设置 …) does nothing; Esc cancels.
|
||||
// Unit side: test/ui/feedback5-hotkeys.test.js (facingEnter).
|
||||
// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/facing.e2e.test.js
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('facing wheel keyboard (mock match, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
async function setup(t) {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.ff-piece');
|
||||
const state = () => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
|
||||
const moves = () => page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([type]) => type === 'g.move'));
|
||||
const sent = () => page.evaluate(() => globalThis.__MOCK__.S().requests.map(([type]) => type));
|
||||
const initial = await state();
|
||||
// room under the deploy cap (sell a board operator through the underframe), then a free legal tile
|
||||
const sell = initial.board.find((p) => p.kind === 'chess' && !p.golden);
|
||||
await page.click(`.ff-piece[data-uid="${sell.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--sell');
|
||||
await page.click('.uframe__btn--sell');
|
||||
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.board.some((p) => p.uid === uid), {}, sell.uid);
|
||||
const ready = await state();
|
||||
const piece = ready.hand.find((p) => p?.kind === 'chess');
|
||||
const tile = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7]].find(([r, c]) => !ready.board.some((p) => p.row === r && p.col === c));
|
||||
assert.ok(piece && tile, 'an operator and a free legal tile exist');
|
||||
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
const open = async () => {
|
||||
const from = await center(`.ff-piece[data-uid="${piece.uid}"]`);
|
||||
const to = await center(`.ff-tile[data-row="${tile[0]}"][data-col="${tile[1]}"]`);
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
await page.mouse.move(from.x + 20, from.y, { steps: 4 }); // a drag also when the piece re-orients on its own tile
|
||||
await page.mouse.move(to.x, to.y, { steps: 12 }); await sleep(80); await page.mouse.up();
|
||||
await page.waitForSelector('.fwheel__dia');
|
||||
await sleep(200);
|
||||
};
|
||||
const preview = async (key) => {
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForSelector(`.fwheel.is-${key.slice(5).toLowerCase()}`);
|
||||
};
|
||||
const active = () => page.evaluate(() => {
|
||||
const a = document.activeElement;
|
||||
return { wheel: !!a?.matches('.fwheel'), cancel: !!a?.closest('.fwheel__cancel'), button: a?.tagName === 'BUTTON' && !a.closest('.fwheel'), cls: a?.className || a?.tagName };
|
||||
});
|
||||
const tabUntil = async (want) => {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
if (want(await active())) return;
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
assert.fail('Tab never reached the wanted element');
|
||||
};
|
||||
const isOpen = () => page.evaluate(() => !!document.querySelector('.fwheel'));
|
||||
const closed = async () => { await page.waitForFunction(() => !document.querySelector('.fwheel')); await sleep(150); };
|
||||
return { page, state, moves, sent, errors, ready, piece, tile, open, preview, active, tabUntil, isOpen, closed };
|
||||
}
|
||||
|
||||
test('the wheel takes the focus: arrows + Enter confirm once in all four directions', async (t) => {
|
||||
const h = await setup(t);
|
||||
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
|
||||
await h.open();
|
||||
assert.equal((await h.active()).wheel, true, 'the wheel has the focus when it opens');
|
||||
await h.page.keyboard.press('Enter');
|
||||
await sleep(150);
|
||||
assert.equal(await h.isOpen(), true, 'Enter with no direction previewed does nothing');
|
||||
await h.preview(`Arrow${dir[0] + dir.slice(1).toLowerCase()}`);
|
||||
const count = (await h.moves()).length;
|
||||
await h.page.keyboard.press('Enter');
|
||||
await h.closed();
|
||||
assert.equal((await h.moves()).length, count + 1, 'one intent per confirmation');
|
||||
assert.deepEqual((await h.moves()).at(-1)[1].dir, dir);
|
||||
const board = (await h.state()).board.filter((p) => p.uid === h.piece.uid);
|
||||
assert.deepEqual([board.length, board[0].row, board[0].col, board[0].dir], [1, ...h.tile, dir]);
|
||||
}
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
|
||||
test('Tab reaches ✕ first and Enter on it cancels; Esc cancels; Space on ✕ stays swallowed', async (t) => {
|
||||
const h = await setup(t);
|
||||
await h.open();
|
||||
await h.preview('ArrowRight');
|
||||
await h.page.keyboard.press('Tab');
|
||||
assert.equal((await h.active()).cancel, true, 'the first Tab from the wheel lands on ✕');
|
||||
await h.page.keyboard.press('Space');
|
||||
await sleep(150);
|
||||
assert.equal(await h.isOpen(), true, 'Space does not activate the focused ✕ (no ready / shop keys while choosing)');
|
||||
await h.page.keyboard.press('Enter');
|
||||
await h.closed();
|
||||
assert.deepEqual(await h.moves(), [], 'cancelling sends no deployment');
|
||||
assert.deepEqual(await h.state(), h.ready, 'the operator stays where it was');
|
||||
await h.open();
|
||||
await h.preview('ArrowLeft');
|
||||
await h.page.keyboard.press('Escape');
|
||||
await h.closed();
|
||||
assert.deepEqual(await h.moves(), []);
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
|
||||
test('Enter on another focused button (准备, 设置 …) does nothing while the wheel is open', async (t) => {
|
||||
const h = await setup(t);
|
||||
await h.open();
|
||||
await h.preview('ArrowRight');
|
||||
const before = await h.sent();
|
||||
// walk the focus past ✕ to the HUD behind the wheel: every button reached gets an Enter
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 80 && seen.size < 6; i++) {
|
||||
await h.page.keyboard.press('Tab');
|
||||
const a = await h.active();
|
||||
if (!a.button || seen.has(a.cls)) continue;
|
||||
seen.add(a.cls);
|
||||
await h.page.keyboard.press('Enter');
|
||||
await sleep(120);
|
||||
assert.equal(await h.isOpen(), true, `Enter on ${a.cls} keeps the wheel open`);
|
||||
assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, `Enter on ${a.cls} opens nothing`);
|
||||
}
|
||||
assert.ok(seen.size >= 2, `Tab reached HUD buttons behind the wheel (${[...seen].join(' | ')})`);
|
||||
assert.ok([...seen].some((c) => /readybtn|gm__gear/.test(c)), '准备 or 设置 among them');
|
||||
assert.deepEqual(await h.sent(), before, 'no intent of any kind (no g.move, no ready, no shop)');
|
||||
assert.equal((await h.state()).ready, h.ready.ready, 'still not ready');
|
||||
await h.page.keyboard.press('Escape');
|
||||
await h.closed();
|
||||
assert.deepEqual(await h.moves(), []);
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
|
||||
test('a HUD button focused before the drop does not swallow the confirming Enter', async (t) => {
|
||||
const h = await setup(t);
|
||||
await h.page.focus('.gm__gear');
|
||||
await h.open();
|
||||
assert.equal((await h.active()).wheel, true);
|
||||
await h.preview('ArrowDown');
|
||||
await h.page.keyboard.press('Enter');
|
||||
await h.closed();
|
||||
assert.equal((await h.moves()).length, 1);
|
||||
assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, 'the settings did not open');
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
|
||||
actionForKey, captureHotkey, facingSwallows, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
|
||||
actionForKey, captureHotkey, facingSwallows, facingEnter, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -201,6 +201,59 @@ describe('the one lookup (actionForKey) and shortcutFor', () => {
|
||||
assert.equal(facingSwallows({ key: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
|
||||
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
|
||||
});
|
||||
|
||||
// GitHub #394 / PR #395: Enter followed the previewed direction whatever had the focus — Tab to 准备 / 设置 / ✕ and
|
||||
// Enter deployed. A tiny element tree whose closest() understands the selector forms facingEnter uses.
|
||||
const el = (tag, attrs = {}, parent = null) => {
|
||||
const self = {
|
||||
tagName: tag.toUpperCase(), attrs, parent,
|
||||
matches(sel) {
|
||||
return sel.split(',').map((s) => s.trim()).some((s) => {
|
||||
const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+(?:="[^"]*")?\])*)$/.exec(s);
|
||||
if (!m) return false;
|
||||
if (m[1] && m[1].toUpperCase() !== self.tagName) return false;
|
||||
const classes = String(attrs.class || '').split(/\s+/);
|
||||
if (m[2] && !m[2].slice(1).split('.').every((c) => classes.includes(c))) return false;
|
||||
for (const [, k, v] of m[3].matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
|
||||
if (!(k in attrs) || (v !== undefined && String(attrs[k]) !== v)) return false;
|
||||
}
|
||||
return !!(m[1] || m[2] || m[3]);
|
||||
});
|
||||
},
|
||||
closest(sel) { for (let n = self; n; n = n.parent) if (n.matches(sel)) return n; return null; },
|
||||
};
|
||||
return self;
|
||||
};
|
||||
test('the facing wheel\'s Enter follows the focus: ✕ cancels, another control does nothing, elsewhere it commits', () => {
|
||||
const body = el('body');
|
||||
const wheel = el('div', { class: 'fwheel', role: 'dialog', tabindex: '-1' }, body);
|
||||
const cancel = el('button', { class: 'fwheel__cancel' }, el('div', { class: 'fwheel__dia' }, wheel));
|
||||
const cancelText = el('span', {}, cancel);
|
||||
const ready = el('button', { class: 'ready' }, el('header', {}, body));
|
||||
const gear = el('button', { class: 'gm__gear' }, body);
|
||||
const card = el('div', { role: 'button', tabindex: '0' }, body);
|
||||
const link = el('a', { href: '#' }, body);
|
||||
const field = el('input', { type: 'text' }, body);
|
||||
for (const chosen of [true, false]) {
|
||||
assert.equal(facingEnter({ key: 'Enter', target: cancel }, chosen), 'cancel', 'Enter on ✕ cancels (a direction previewed or not)');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: cancelText }, chosen), 'cancel');
|
||||
for (const t of [ready, gear, card, link, field]) assert.equal(facingEnter({ key: 'Enter', target: t }, chosen), 'swallow', 'another control does nothing');
|
||||
}
|
||||
assert.equal(facingEnter({ key: 'Enter', target: wheel }, true), 'commit', 'the wheel has the focus when it opens: Enter confirms');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: body }, true), 'commit');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: null }, true), 'commit');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: wheel }, false), 'swallow', 'nothing to confirm yet');
|
||||
assert.equal(facingEnter({ key: ' ', target: cancel }, true), null, 'not Enter: Space stays with facingSwallows');
|
||||
assert.equal(facingEnter({ key: 'ArrowUp', target: body }, true), null);
|
||||
assert.equal(facingEnter(null, true), null);
|
||||
});
|
||||
test('the wheel wires it: Enter through facingEnter, the wheel focusable and focused when it opens', () => {
|
||||
const src = read('public/js/ui/facingWheel.js');
|
||||
assert.match(src, /const enter = facingEnter\(e, !!L\.dir\);/);
|
||||
assert.doesNotMatch(src, /e\.key === 'Enter' && L\.dir/, 'no Enter that ignores the focus');
|
||||
assert.match(src, /<div ref=\$\{rootRef\} tabIndex="-1" class=\$\{cx\('fwheel'/);
|
||||
assert.match(src, /rootRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capturing a new key (the settings wait for a key)', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -281,16 +281,45 @@ describe('§20.15 the bonds: views + live layers', () => {
|
||||
assert.deepEqual(mate.bonds.map((b) => [b.bondId, b.layers]), [['sargonShip', 44], ['kjeragShip', 7]], 'THEIR bonds, THEIR live count');
|
||||
assert.deepEqual(playerBonds({ pub, priv: PRIV, myId: ME, ownerId: 'p3', live }).map((b) => b.layers), [120]);
|
||||
});
|
||||
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list (no hand)', () => {
|
||||
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list; a scout\'s bench goes to hand / temp', () => {
|
||||
const field = { fieldId: 'n:p2', prep: true, units: [
|
||||
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a' },
|
||||
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok' },
|
||||
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a', area: 'board' },
|
||||
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok', area: 'board' },
|
||||
{ id: 3, kind: 'op', side: 'ally', ownerId: 'p3', defId: 'chess_other' },
|
||||
{ id: 4, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'enemy_1' },
|
||||
{ id: 5, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_03_a', area: 'hand' },
|
||||
{ id: 6, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_2_04_a', area: 'temp' },
|
||||
{ id: 7, kind: 'item', side: 'ally', ownerId: 'p2', defId: 'chess_item_1_01_e_a', area: 'hand' },
|
||||
] };
|
||||
assert.deepEqual(ownerBoard(field, 'p2'), { board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [], temp: [] });
|
||||
assert.deepEqual(ownerBoard(field, 'p2'), {
|
||||
board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [{ kind: 'chess', id: 'chess_char_1_03_a' }], temp: [{ kind: 'chess', id: 'chess_char_2_04_a' }],
|
||||
});
|
||||
// a unit without an area (a battle's, an older server's prep scout) is on the board
|
||||
assert.deepEqual(ownerBoard({ units: [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_19_a' }] }, 'p2').board, [{ kind: 'chess', id: 'chess_char_1_19_a' }]);
|
||||
assert.equal(ownerBoard(null, 'p2'), null);
|
||||
});
|
||||
test('GitHub #385: a scouted bench counts in the popup\'s 成员 header as in the player\'s own (hand for 远见 / 奇迹 / 投资人, temp never)', () => {
|
||||
// 远见 (BOARD_AND_DECK, countsHand: the server's 在场 is 2 — 赫默 on the board, 初雪 in the hand; 伊内丝 waits in temp)
|
||||
// 炎 (BOARD: 在场 1 — 小满; 琳琅诗怀雅 in the hand, 烛煌 in temp)
|
||||
const u = (id, defId, area) => ({ id, kind: 'op', side: 'ally', ownerId: 'p2', defId, area });
|
||||
const field = { fieldId: 'n:p2', prep: true, units: [
|
||||
u(1, 'chess_char_2_02_a', 'board'), u(2, 'chess_char_2_04_a', 'board'),
|
||||
u(3, 'chess_char_3_14_a', 'hand'), u(4, 'chess_char_3_04_a', 'hand'),
|
||||
u(5, 'chess_char_4_04_a', 'temp'), u(6, 'chess_char_5_03_a', 'temp'),
|
||||
] };
|
||||
const head = (bondId) => {
|
||||
const v = BondPopup({ bondId, entry: bond(bondId, 0, 0, 0), priv: ownerBoard(field, 'p2'), owner: '阿米娅', onClose() {} });
|
||||
const h4 = [...walk(v)].find((x) => x.type === 'h4' && /成员/.test(textOf(x)));
|
||||
const members = [...walk(v)].filter((x) => hasClass(x, 'bpop__member'));
|
||||
return { head: textOf(h4).replace(/\s+/g, ''), on: members.filter((x) => hasClass(x, 'is-on')).length, owned: members.filter((x) => hasClass(x, 'is-owned')).length };
|
||||
};
|
||||
const visi = head('visiShip');
|
||||
assert.match(visi.head, /^成员2\//, '远见: the board member and the hand member (含整备区), not the temp one');
|
||||
assert.deepEqual([visi.on, visi.owned], [1, 2], 'only 赫默 is 在场; 初雪 and 伊内丝 are owned');
|
||||
const yan = head('yanShip');
|
||||
assert.match(yan.head, /^成员1\//, '炎: the board member only');
|
||||
assert.deepEqual([yan.on, yan.owned], [1, 2]);
|
||||
});
|
||||
test('ownerBoard: under client-side combat the battle on screen\'s operators join (the runner\'s meta predates their deploy)', () => {
|
||||
const meta = { fieldId: 'b1', local: true, units: [{ id: 90, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'boss' }] };
|
||||
const ops = [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_09_a' }, { kind: 'op', ownerId: 'p2', defId: 'chess_char_1_03_a' }];
|
||||
@@ -405,7 +434,7 @@ describe('§20.15 the popup of a card\'s bond chip: the UNIT owner\'s bond (a sh
|
||||
assert.deepEqual([v.entry.bondId, v.entry.count, v.entry.active, v.entry.layers], ['sargonShip', 2, true, 55], 'the partner\'s count / tier, their live layers');
|
||||
assert.notEqual(v.priv, PRIV, 'not the viewer\'s pieces');
|
||||
assert.deepEqual(v.priv.board.map((x) => x.id).sort(), ['chess_char_1_09_a', 'chess_char_1_12_a'], 'the partner\'s operators on the field on screen');
|
||||
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'their hand is never sent');
|
||||
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'a battle field sends no bench');
|
||||
});
|
||||
test('popupView: your own bond (m.private entry with thresholds, your pieces, no name); a bond not listed → no entry; nothing open → null', () => {
|
||||
const own = popupView({ open: { id: 'yanShip', ownerId: ME, from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: null });
|
||||
|
||||
Reference in new issue
Block a user