Files
ark-stronghold-protocol/test/ui/playtest2.test.js
T
siruimei07 030d0a2938 feedback7 R22-HUD: a renderer that loses the 12 s race no longer mounts its canvas late — the attempt is aborted, a view that still comes back is destroyed, the host is no longer wiped (PR #302 by @siruimei07); golden: unchanged
Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency, no
shipped eval). The leak was real on feedback7: mountFieldView raced createFieldView against a timeout and, on failure,
removed every child of the host while the factory kept running — Pixi finished later and appended its canvas over the
simplified view (taking its clicks); the host wipe could also delete a newer view mounted by the same hook.

- ui/fieldHost.js: mountFieldView(host, { signal }) owns an AbortController; the timeout, a failed import or an invalid
  view aborts it, a view a factory returns after that is destroyed, an outer abort (useFieldView's cleanup) rejects
  without a fallback; nothing clears the host's children any more.
- render/app.js createFieldView takes the signal: the Pixi load / manifest / font waits end on abort, the synchronous
  build registers its disposables (setupCleanup) so a throw releases the app, tiles, fx, impostors, listeners and
  observers it made, the board-art and 3D-board waits moved after the view exists (aborting them is view.destroy(),
  idempotent), destroy() also removes the canvas. render/app/host.js withTimeout(p, ms, signal).
- Tests: test/ui/fieldHost-lifecycle.test.js (5, node vm, fake timers: all fail on the parent) and the opt-in real-browser
  test/render/initialization.browser.test.js (6 scenarios in headless Chrome, 5 fail on the parent: a late Pixi load
  covering the fallback — with a factory that honours the signal and one that ignores it —, art timeout, a setup
  exception, an unmount racing a newer mount); test/ui/playtest2.test.js's source pin takes the new indentation.
  fieldview / browser suites in Chrome unchanged (the two 4× throttled fps tests flake when two Chrome files run
  at once, pass alone).
- The createFieldView body stays unindented inside its new try (the PR's choice, to keep the diff reviewable).
- Docs: design/client.md §9. Golden: unchanged (client lifecycle only).
2026-10-08 19:22:08 +08:00

360 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Regression tests (Node) of user playtest #2, UI items 6–10 + the loadout display (DESIGN §16):
// 6 the enemy preview pen belongs to the pen camera only: bandFor / boardArea / penShown of render/app.js
// 8 the detail card docks on the side away from a selected unit's underframe — for every bench / temp / board tile
// at the official prep / Final Assault prep cameras of 10 viewports (16:9, 16:10, 21:9, 32:13.4, 4:3, 1280×720,
// 1366×768, iPad landscape, 1024×768, 1440×900) the chosen slot never overlaps the underframe
// 10 the boss round's prep camera is the own half of the boss field — solo (R9 / R15) and co-op (L / R), and a lone
// player's boss battle frames its own half
// §16 chessLoadout: the skill / module a chess fights with under m.private.loadout
// (browser counterparts: test/ui/playtest2.e2e.test.js, test/ui/playtest2.real.e2e.test.js)
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { presetCamera, OFFICIAL_PARAMS, parseCameraParam } from '../../public/js/render/projection.js';
import { bandFor, boardArea, penShown, viewKind } from '../../public/js/render/app.js';
import { AREAS } from '../../public/js/render/board3d/layout.js';
import { underframeRect } from '../../public/js/ui/underframe.js';
import { panelSide, panelSlots, chessLoadout, prepCamera, bondPopupPlace, BPOP } from '../../public/js/ui/gameLogic.js';
import { checkButtons } from '../../public/js/ui/hud.js';
import { DATA } from '../match/harness.js';
import { skillRecordIconUrl } from '../../public/js/ui/assetUrls.js';
const maxRow = (areas) => Math.max(...areas.map((a) => a.r1));
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
describe('6: the enemy pen only with the pen camera', () => {
test('prep / battle / 联防 cameras draw no pen rows and build no pen area; the pen camera does', () => {
for (const k of ['prep', 'normal', 'unite']) {
assert.ok(bandFor(k)[1] <= 13, `${k}: 2D rows ${bandFor(k)}`);
// the field and its separator rows (row 13 carries devices blowing into the field, user playtest #5 item 6)
assert.ok(maxRow(boardArea(k)) <= 13, `${k}: 3D area rows ≤ 13 (no pen rows 14–18)`);
assert.equal(penShown(k), false, `${k}: pen figures hidden`);
}
for (const k of ['boss', 'hidden', 'bossPrep']) {
assert.deepEqual(bandFor(k), [0, 13], k);
assert.ok(maxRow(boardArea(viewKind(k))) <= 6, `${k}: boss field only`);
assert.equal(penShown(viewKind(k)), false);
}
assert.deepEqual(bandFor('pen'), [6, 18]);
assert.equal(maxRow(boardArea('pen')), 18);
assert.equal(boardArea('pen'), AREAS.normal, 'the pen view keeps the field + pen block');
assert.equal(penShown('pen'), true);
// a camera flight to / from the pen shows it while it lasts
assert.equal(penShown('prep', 'pen'), true);
assert.equal(penShown('pen', 'prep'), true);
});
test('the field areas are the official ones minus the pen block', () => {
assert.deepEqual(boardArea('normal'), AREAS.normal.filter((a) => a.r1 <= 13));
assert.deepEqual(boardArea('unite'), AREAS.unite.filter((a) => a.r1 <= 13));
assert.ok(boardArea('normal').some((a) => a.r0 <= 6 && a.r1 >= 12 && a.c0 === 0 && a.c1 >= 10), 'own field + bench');
});
test('the 🔍◀◀ back button of the pen view is the one labelled 返回战场', () => {
const b = checkButtons({ pen: true, penAvail: true, infoOpen: false });
assert.equal(b.left.tip, '返回战场');
assert.equal(b.left.back, true);
});
});
describe('8: the detail card never covers the selected unit\'s underframe', () => {
const SIZES = [[1920, 1080], [1680, 1050], [2560, 1080], [3440, 1440], [1600, 1200], [1280, 720], [1366, 768], [1180, 820], [1024, 768], [1440, 900]];
const remOf = (W, H) => Math.max(40, Math.min(W / 19.2, H / 10.8, 240)); // css/theme.css html font-size
const tiles = () => {
const out = [];
for (let c = 0; c < 10; c++) out.push([7, c, 'bench']);
for (let c = 4; c <= 8; c++) out.push([8, c, 'temp']);
for (let r = 9; r <= 12; r++) for (let c = 2; c <= 10; c++) out.push([r, c, 'board']);
return out;
};
test('panelSide: left unless the underframe overlaps it and the right slot is clear', () => {
const slots = { left: { left: 230, right: 720, top: 210, bottom: 978 }, right: { left: 1340, right: 1830, top: 210, bottom: 702 } };
assert.equal(panelSide(null, slots), 'left');
assert.equal(panelSide({ left: 800, right: 1000, top: 400, bottom: 600 }, slots), 'left');
assert.equal(panelSide({ left: 300, right: 600, top: 500, bottom: 760 }, slots), 'right');
assert.equal(panelSide({ left: 300, right: 1500, top: 500, bottom: 690 }, slots), 'left', 'both covered: the default (the underframe is drawn above)');
assert.equal(panelSide({ left: 1400, right: 1600, top: 400, bottom: 600 }, slots), 'left');
});
test('panelSlots follow the css (left 2.3rem / 4.9rem wide; right .9rem from the edge, above the open shop)', () => {
const s = panelSlots({ width: 1920, height: 1080 }, 100, { shopOpen: true });
const round = (a) => a.map((v) => Math.round(v));
assert.deepEqual(round([s.left.left, s.left.right, s.left.top]), [230, 720, 210]);
assert.deepEqual(round([s.right.left, s.right.right, s.right.bottom]), [1920 - 580, 1830, 1080 - 378]);
assert.equal(panelSlots({ width: 1920, height: 1080 }, 100, { shopOpen: false }).right.bottom, 1080 - 102);
});
test('safe-area insets: the slots are laid out in the HUD layer (its client offset and size)', () => {
// a notched phone in landscape: the HUD sits 47 px in from both sides and 12 px from the top (css/devices.css)
const hud = { left: 47, top: 12, width: 844 - 94, height: 390 - 12 - 21 };
const R = 40;
const s = panelSlots(hud, R, { shopOpen: true });
const z = panelSlots({ width: hud.width, height: hud.height }, R, { shopOpen: true });
for (const k of ['left', 'right']) {
assert.deepEqual([s[k].left, s[k].right], [z[k].left + 47, z[k].right + 47], `${k}: shifted by the left inset`);
assert.deepEqual([s[k].top, s[k].bottom], [z[k].top + 12, z[k].bottom + 12], `${k}: shifted by the top inset`);
}
assert.ok(Math.abs(s.right.right - (844 - 47 - R * 0.9)) < 1e-9, 'the right slot keeps .9rem from the HUD\'s right edge');
// an underframe just right of the left card's client edge: covered only once the inset is counted
const uf = { left: 47 + R * 7.1, right: 47 + R * 9, top: 150, bottom: 260 };
assert.equal(panelSide(uf, z), 'left', 'viewport frame: looks clear');
assert.equal(panelSide(uf, s), 'right', 'HUD frame: the card would cover it');
// bond popup candidates shift the same way
const ufB = { left: 47 + R * 2.4, right: 47 + R * 3.4, top: 150, bottom: 260 };
assert.equal(bondPopupPlace(ufB, { width: hud.width, height: hud.height }, R, null), 'right');
assert.equal(bondPopupPlace(ufB, hud, R, null), 'right');
assert.equal(bondPopupPlace({ left: R * 2.35, right: R * 2.5, top: 150, bottom: 260 }, hud, R, null), 'left', 'left of the inset-shifted popup');
});
test('css: only the bond discs take the pointer over the board; the underframe undoes the HUD safe-area offset', async () => {
const { readFileSync } = await import('node:fs');
const css = readFileSync(new URL('../../public/css/screens/game.css', import.meta.url), 'utf8');
assert.match(css, /\.gm__bonds > \*, \.gm__bonds \.bstrip \{ pointer-events: none; \}/, 'the strip box lets taps through');
assert.match(css, /\.gm__bonds \.bslot, \.gm__bonds \.bstrip__more \{ pointer-events: auto; \}/, 'the discs stay clickable');
assert.doesNotMatch(css, /\.gm__bonds > \* \{ pointer-events: auto; \}/);
const dev = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
assert.match(dev, /\.gm__hud > \.uframe \{ margin-left: calc\(-1 \* var\(--sa-l\)\); margin-top: calc\(-1 \* var\(--sa-t\)\); \}/);
const panels = readFileSync(new URL('../../public/css/screens/game-panels.css', import.meta.url), 'utf8');
assert.match(panels, /\.uframe__label\s*\{[^}]*white-space:\s*nowrap\s*;/, 'shortcut labels stay on one line');
});
// a finger's tap near a disc: the browser's touch adjustment moved it onto the nearest element that responds to clicks
// (the canvas's pointer listeners do not count), so with PR #149's 收起 toggle pushing the discs over the back row a tap
// on the row-12 unit at 844×390 opened the bond popup (test/render/models.browser.test.js #4.1 touch)
test('the field canvas is a click target of its own, so a tap on the board stays on the tile under the finger', async () => {
const { readFileSync } = await import('node:fs');
const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8');
assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners');
assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events');
});
test('underframeRect covers the diamond and its buttons', () => {
const r = underframeRect({ x: 500, y: 600, s: 100 }, 100);
assert.ok(r.left <= 500 - 105 && r.right >= 500 + 105 && r.bottom === 600 + 105);
assert.ok(r.top < 600 - 105, 'buttons above the diamond');
assert.equal(underframeRect(null, 100), null);
});
for (const [kind, side] of [['prep', 'L'], ['bossPrep', 'L'], ['bossPrep', 'R']]) {
test(`${kind}${kind === 'bossPrep' ? ` ${side}` : ''}: every bench / temp / board tile at 10 viewports`, () => {
let docked = 0;
for (const [W, H] of SIZES) {
const rem = remOf(W, H);
const cam = kind === 'prep'
? presetCamera('prep', { width: W, height: H }, { rect: { r0: 7, r1: 12, c0: 0, c1: 10 }, side: 'L' })
: presetCamera('bossPrep', { width: W, height: H }, { side });
for (const shopOpen of [true, false]) {
const slots = panelSlots({ width: W, height: H }, rem, { shopOpen });
for (const [r, c, area] of tiles()) {
// where the view draws the tile (render/prepfield.js: FA prep rows − 7, right half mirrored)
const p = kind === 'prep' ? cam.project(c, r, 0) : cam.project(side === 'R' ? 20 - c : c, r - 7, 0);
assert.ok(p.x > 0 && p.x < W && p.y > 0 && p.y < H, `${W}×${H} ${area} ${r},${c} on screen`);
const uf = underframeRect({ x: p.x, y: p.y, s: p.s }, rem);
const chosen = panelSide(uf, slots);
if (chosen === 'right') docked++;
assert.equal(hit(uf, slots[chosen]), false, `${W}×${H} ${area} ${r},${c}: the ${chosen} card covers the underframe`);
}
}
}
assert.ok(docked > 0, 'some units dock the card on the right');
});
}
});
describe('8 / 9: the bond popup opened from the card keeps clear of the underframe (and of the card if it can)', () => {
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
const rectOf = (place, W, H, R) => {
const left = place === 'left' ? R * BPOP.left : place === 'beside' ? R * BPOP.beside
: place === 'besideR' ? W - R * (BPOP.besideR + BPOP.width) : W - R * (BPOP.right + BPOP.width);
return { left, right: left + R * BPOP.width, top: R * BPOP.top, bottom: H - R * BPOP.bottomGap };
};
test('next to the card first, then the free side, then over the card; no card → left', () => {
const vp = { width: 1920, height: 1080 };
assert.equal(bondPopupPlace(null, vp, 100, null), 'left');
assert.equal(bondPopupPlace(null, vp, 100, 'left'), 'beside');
assert.equal(bondPopupPlace(null, vp, 100, 'right'), 'besideR');
// the playtest screenshot: a bench unit on the left, card docked right, underframe at x 560–840 — both the place
// left of the card and the left place cover it: the popup goes over the card
assert.equal(bondPopupPlace({ left: 560, right: 840, top: 400, bottom: 700 }, vp, 100, 'right'), 'right');
assert.equal(bondPopupPlace({ left: 900, right: 1100, top: 400, bottom: 700 }, vp, 100, 'right'), 'left');
assert.equal(bondPopupPlace({ left: 300, right: 500, top: 400, bottom: 700 }, vp, 100, 'right'), 'besideR');
assert.equal(bondPopupPlace({ left: 800, right: 1000, top: 400, bottom: 700 }, vp, 100, 'left'), 'right');
assert.equal(bondPopupPlace({ left: 0, right: 1920, top: 400, bottom: 700 }, vp, 100, 'left'), 'beside', 'all covered: the first');
});
test('an underframe anywhere on screen: never covered when some place is free; the card only covered as the last resort', () => {
for (const [W, H] of [[1920, 1080], [1280, 720], [2560, 1080], [1600, 1200], [1180, 820]]) {
const R = Math.max(40, Math.min(W / 19.2, H / 10.8, 240));
const slots = panelSlots({ width: W, height: H }, R, { shopOpen: true });
for (const card of ['left', 'right', null]) {
const all = card === 'left' ? ['beside', 'right', 'left'] : card === 'right' ? ['besideR', 'left', 'right'] : ['left', 'right'];
for (let x = 0; x < W; x += 40) {
const uf = { left: x, right: x + R * 2.2, top: H * 0.4, bottom: H * 0.6 };
const place = bondPopupPlace(uf, { width: W, height: H }, R, card);
const free = all.filter((p) => !hit(uf, rectOf(p, W, H, R)));
if (free.length) assert.equal(place, free[0], `${W}×${H} card ${card} uf@${x}`);
else assert.equal(place, all[0]);
if (card && hit(rectOf(place, W, H, R), slots[card])) assert.equal(place, card, 'over the card only as the last resort');
}
}
}
});
});
describe('10: the boss round prep shows the own half of the boss field', () => {
const pl = (playerId, seat, alive = true) => ({ playerId, seat, alive });
test('solo 标准 R9 / 险境 R15 and co-op pairs (L / R); eliminated players keep the normal board', () => {
assert.deepEqual(prepCamera({ round: 9, bossRound: 9, hiddenRound: null, players: [pl('me', 0)] }, 'me'), { kind: 'bossPrep', opts: { side: 'L' } });
assert.deepEqual(prepCamera({ round: 15, bossRound: 14, hiddenRound: 15, players: [pl('me', 0)] }, 'me'), { kind: 'bossPrep', opts: { side: 'L' } });
const coop = { round: 14, bossRound: 14, hiddenRound: 15, players: [pl('a', 0), pl('b', 1), pl('c', 2), pl('d', 3, false)] };
assert.equal(prepCamera(coop, 'a').opts.side, 'L');
assert.equal(prepCamera(coop, 'b').opts.side, 'R');
assert.equal(prepCamera(coop, 'c').opts.side, 'L', 'the odd player alone on the second field');
assert.equal(prepCamera(coop, 'd').kind, 'prep');
assert.equal(prepCamera({ ...coop, round: 13 }, 'a').kind, 'prep');
});
test('the boss prep camera is the official left / right boss prep camera and shows rows 0–5 of its half', () => {
for (const side of ['L', 'R']) {
const cam = presetCamera('bossPrep', { width: 1920, height: 1080 }, { side });
const key = side === 'R' ? 'right_boss_shop_camera_param' : 'left_boss_shop_camera_param';
const p = parseCameraParam(OFFICIAL_PARAMS[key]);
assert.ok(Math.abs(cam.tx - (p[0] + 10)) < 1e-6, `${side}: official x`);
for (const [r, c] of [[2, 2], [2, 10], [5, 10], [0, 0], [1, 4]]) {
const q = cam.project(side === 'R' ? 20 - c : c, r, 0);
assert.ok(q.x > 0 && q.x < 1920 && q.y > 0 && q.y < 1080, `${side}: boss tile ${r},${c} on screen`);
}
}
});
test('a lone player\'s boss battle can be framed on its half (the ‹ › half cameras)', () => {
const half = presetCamera('boss', { width: 1920, height: 1080 }, { rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, side: 'L', half: true });
const all = presetCamera('boss', { width: 1920, height: 1080 }, { rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, side: 'L' });
assert.ok(half.tx < all.tx, 'the left half camera looks left of 全景');
for (const [r, c] of [[2, 2], [2, 10], [5, 10]]) {
const q = half.project(c, r, 0);
assert.ok(q.x > 0 && q.x < 1920 && q.y > 0 && q.y < 1080, `left half: ${r},${c} on screen (gates / objective)`);
}
});
});
describe('§16: chessLoadout (m.private.loadout → skill / module shown)', () => {
const sk = (index, isDefault = false) => ({ index, skillId: `sk_${index}`, iconId: `sk_${index}`, name: `技能${index}`, isDefault });
const base = { chessId: 'c_a', baseId: 'c_a', goldenId: 'c_b', isGolden: false, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)] };
const gold = { chessId: 'c_b', baseId: 'c_a', isGolden: true, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)],
module: { id: 'uniequip_x', name: '模组X', type: 'XYZ-X', active: true },
modules: [{ uniEquipId: 'uniequip_x', name: '模组X', typeName: 'XYZ-X', isDefault: true }, { uniEquipId: 'uniequip_y', name: '模组Y', typeName: 'XYZ-Y' }] };
const get = (id) => ({ c_a: base, c_b: gold }[id] || null);
test('defaults without a loadout', () => {
const r = chessLoadout(base, null, get);
assert.equal(r.skill.index, 2);
assert.equal(r.defaultSkill, true);
assert.equal(r.module, null, 'normal chess have no module');
assert.equal(r.changed, false);
const g = chessLoadout(gold, {}, get);
assert.equal(g.module.id, 'uniequip_x');
assert.equal(g.defaultModule, true);
});
test('a chosen skill / module / 不装备 (keyed by the base chess id, for the elite too)', () => {
const lo = { c_a: { skill: 0, module: 'none' } };
const r = chessLoadout(base, lo, get);
assert.equal(r.skill.skillId, 'sk_0');
assert.equal(r.defaultSkill, false);
assert.equal(r.changed, true);
const g = chessLoadout(gold, lo, get);
assert.equal(g.skill.skillId, 'sk_0');
assert.equal(g.module.none, true);
assert.equal(g.module.name, '未装备模组');
assert.equal(g.defaultModule, false);
const y = chessLoadout(gold, { c_a: { skill: 2, module: 'uniequip_y' } }, get);
assert.deepEqual([y.module.id, y.module.name, y.module.typeName, y.defaultSkill], ['uniequip_y', '模组Y', 'XYZ-Y', true]);
});
test('illegal choices fall back to the defaults; data without skills[] keeps the default skill', () => {
assert.equal(chessLoadout(base, { c_a: { skill: 7 } }, get).skill.index, 2);
assert.equal(chessLoadout(gold, { c_a: { skill: 1, module: 'nope' } }, get).module.id, 'uniequip_x');
const old = { chessId: 'o_a', baseId: 'o_a', isGolden: false, visible: true, skill: sk(1, true) };
const r = chessLoadout(old, { o_a: { skill: 0 } }, (id) => (id === 'o_a' ? old : null));
assert.equal(r.skill.index, 1);
assert.equal(chessLoadout(null, null), null);
});
test('a chosen skill without an icon in the manifest: null with { empty: false } (the card draws S1–S3)', () => {
const m = { skills: { sk_a: '/assets/skill/sk_a.png' }, skillsById: {}, ui: {} };
assert.equal(skillRecordIconUrl(m, { skillId: 'sk_a', iconId: 'sk_a' }, { empty: false }), '/assets/skill/sk_a.png');
assert.equal(skillRecordIconUrl(m, { skillId: 'sk_b', iconId: 'sk_b' }, { empty: false }), null);
assert.notEqual(skillRecordIconUrl(m, { skillId: 'sk_b' }), '/assets/skill/sk_a.png', 'default: the empty sprite (or null without one)');
});
test('real data: every visible chess resolves a skill (with or without skills[])', () => {
const get2 = (id) => DATA.chess[id] || null;
for (const c of Object.values(DATA.chess)) {
if (!c.visible) continue;
const r = chessLoadout(c, null, get2);
if (c.skill) assert.ok(r.skill && r.skill.name, `${c.chessId}: default skill`);
assert.equal(r.defaultSkill, true, `${c.chessId}: no loadout = default`);
}
});
test('integration: the detail card record = what the battle fights with (stats / 特性 / talents of the chosen module or 不装备)', async () => {
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const ds = getDefaultSource();
const get2 = (id) => DATA.chess[id] || null;
// 野鬃 elite on 不装备: base ATK / ASPD and the no-module trait (the default module: ATK +40, ASPD +3, "获得2点")
const g = DATA.chess.chess_char_1_19_b;
const none = chessLoadout(g, { chess_char_1_19_a: { skill: 0, module: 'none' } }, get2);
assert.equal(none.record.stats.atk, g.statsBase.atk);
assert.equal(none.record.stats.aspd, g.statsBase.aspd);
assert.notEqual(none.record.stats.atk, g.stats.atk, 'differs from the default-module card');
assert.match(none.record.trait.desc, /获得1点部署费用/);
assert.equal(chessLoadout(g, null, get2).record, g, 'default loadout: the data record itself');
// every elite × every module choice: the card's record equals the sim's unit def (stats, trait, talents)
let n = 0;
for (const c of Object.values(DATA.chess)) {
if (!c.visible || !c.isGolden || !Array.isArray(c.modules)) continue;
for (const moduleId of ['none', ...c.modules.map((x) => x.uniEquipId)]) {
const lo = { [c.baseId]: { module: moduleId } };
const rec = chessLoadout(c, lo, get2).record;
const def = ds.getChess(c.chessId, { moduleId });
for (const k of ['maxHp', 'atk', 'def', 'res', 'aspd', 'cost', 'blockCnt', 'respawnTime']) {
if (rec.stats[k] != null && def.raw.stats[k] != null) assert.equal(rec.stats[k], def.raw.stats[k], `${c.chessId} ${moduleId} ${k}`);
}
assert.equal(rec.trait?.desc ?? null, def.raw.trait?.desc ?? null, `${c.chessId} ${moduleId} trait`);
assert.deepEqual((rec.talents || []).map((t) => t.name), (def.raw.talents || []).map((t) => t.name), `${c.chessId} ${moduleId} talents`);
n++;
}
}
assert.ok(n > 100, `${n} elite × module choices`);
});
test('integration: the card / wheel range = the battle unit\'s range (an equipped "攻击范围扩大" module grid)', async () => {
const { makeBattle } = await import('../helpers/battleHarness.js');
const { attackRangeGrid } = await import('../../shared/loadoutRecord.js');
const { previewGrid } = await import('../../public/js/ui/facing.js');
const get2 = (id) => DATA.chess[id] || null;
const key = (g) => g.map(([r, c]) => `${r},${c}`).sort().join(' ');
let changed = 0;
for (const c of Object.values(DATA.chess)) {
if (!c.visible || !c.isGolden || !Array.isArray(c.modules)) continue;
for (const moduleId of ['none', ...c.modules.map((x) => x.uniEquipId)]) {
const lo = { [c.baseId]: { module: moduleId } };
const rec = chessLoadout(c, lo, get2).record;
const g = attackRangeGrid(rec);
if (key(g) === key(c.rangeGrid) && moduleId === 'none') continue;
const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 3, moduleId }], enemies: [], autoFinish: false, timeLimit: 5 });
h.step();
const u = h.unit(c.chessId);
// the range it starts with: its grid + a module's permanent 攻击距离 (信仰搅拌机 SPT-Y; community report E1 review)
assert.equal(key(g), key(u.liveRangeGrid), `${c.chessId} ${moduleId}: card range = battle range`);
const wheel = previewGrid({ getChess: get2, chessRecord: (r) => chessLoadout(r, lo, get2).record }, { kind: 'chess', id: c.chessId });
assert.equal(key(wheel), key(u.liveRangeGrid), `${c.chessId} ${moduleId}: wheel preview = battle range`);
if (key(g) !== key(c.rangeGrid)) changed++;
}
}
assert.ok(changed >= 4, `${changed} module choices change the range (莫斯提马 SPC-X, 莱恩哈特 / 白面鸮 / 夕 defaults)`);
});
});