Files
ark-stronghold-protocol/test/ui/playtest2.test.js
T
sganggs a657f809f1 feedback1e WP: review round 3 — 烛煌 S3 last bolt keeps the burst bonus, splash fx; prep overlay and wheel show the deployed range
- 烛煌 S3: the burn-burst bonus is gated on the attack's own isSkill (captured when the bolt was fired), not on the live
  skill, so the last bullet (it lands after end('ammo')) and bolts in flight when the skill ends early still carry it.
- 烛煌 S3: every landed bolt emits fx 'splash' (r 1.7, burn) at the main target, so the splash shows on screen.
- shared/loadoutRecord.js attackRangeGrid: the range a unit is deployed with — a selected passive range skill
  (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1; not 空弦 ISW-A's 集成战略-only one) —
  so the board overlay, the deploy wheel and the card agree with the prep live range. extendedGrid moves there
  (re-exported by targeting.js).
- runner.js header: unitStatsEntry carries the ally's live range.
2026-10-03 06:25:12 +08:00

347 lines
22 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\)\); \}/);
});
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)`);
});
});