Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
345 lines
22 KiB
JavaScript
345 lines
22 KiB
JavaScript
// 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)}`);
|
||
assert.ok(maxRow(boardArea(k)) <= 12, `${k}: 3D area rows ≤ 12`);
|
||
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 <= 12));
|
||
assert.deepEqual(boardArea('unite'), AREAS.unite.filter((a) => a.r1 <= 12));
|
||
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);
|
||
assert.equal(key(g), key(u.rangeGrid), `${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.rangeGrid), `${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)`);
|
||
});
|
||
});
|