Files
ark-stronghold-protocol/test/ui/playtest2.e2e.test.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

442 lines
29 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.
// Browser regressions of user playtest #2 (UI items 6–9 + the loadout display of item 1), through the mock harness
// (public/dev/game-mock.html) in headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/playtest2.e2e.test.js
// SP_E2E=1 SP_P2_ONLY=pen|sell|reach|bonds|loadout node --test test/ui/playtest2.e2e.test.js
//
// 6 the enemy preview pen is shown only by the pen camera: never with the prep board (after 🔍◀◀ "返回战场" too) nor in
// any battle camera (normal / 联防 / 最终攻势 / 隐秘核心): no pen figures, no pen rows drawn (2D), no pen area built (3D)
// 7 the underframe's 出售 / 销毁 buttons are coloured plates (amber / red), not a white block — 3D engine and DOM fallback
// 8 at 16:9, 16:10, 21:9 (2560×1080 and 3440×1440), 4:3, 1280×720, 1366×768 and iPad landscape every bench / temp /
// board unit's underframe buttons are the topmost element under their centre (clickable) and the detail card
// never overlaps them; a real click on 出售 sells
// 9 the detail card shows the unit's bonds (icon, name, count / threshold, tier pips) in the header's right column
// (under name / tier, beside the portrait), visible without scrolling; the bond popup lists its facts and the
// current effect before the long description and keeps clear of the selected unit's underframe
// §16 the loadout's skill / module: shop card skill badge (mint when not the default; S1–S3 when the manifest has no
// icon for the chosen skill), detail card skill + 已调配, an elite with its module unequipped shows 未装备模组
// Screenshots: test/e2e/out/fix-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const ONLY = process.env.SP_P2_ONLY || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const skipUnless = (k) => (ONLY && ONLY !== k ? `SP_P2_ONLY=${ONLY}` : false);
/** Viewports of item 8 (name, w, h). */
const SIZES = [
['16x9', 1920, 1080], ['16x10', 1680, 1050], ['21x9', 2560, 1080], ['uw', 3440, 1440], ['4x3', 1600, 1200],
['720p', 1280, 720], ['1366', 1366, 768], ['ipad', 1180, 820],
];
describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
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 });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w = 1920, h = 1080, render = 'engine' } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&render=${render}&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
await sleep(render === 'engine' ? 1600 : 700);
return { page, problems };
}
/** Mean RGB of a client rect of the page (screenshot decoded by the page itself). */
async function meanColor(page, r) {
const clip = { x: Math.max(0, r.x), y: Math.max(0, r.y), width: Math.max(1, r.w), height: Math.max(1, r.h) };
const b64 = await page.screenshot({ clip, encoding: 'base64' });
return page.evaluate(async (src) => {
const img = new Image();
img.src = `data:image/png;base64,${src}`;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const g = cv.getContext('2d');
g.drawImage(img, 0, 0);
const d = g.getImageData(0, 0, cv.width, cv.height).data;
let R = 0, G = 0, B = 0, n = 0;
for (let i = 0; i < d.length; i += 4) { R += d[i]; G += d[i + 1]; B += d[i + 2]; n++; }
return { r: R / n, g: G / n, b: B / n };
}, b64);
}
/** Client rect of a prep piece (plain object: the DOM fallback returns a DOMRect). */
const pieceRect = (page, uid) => page.evaluate((uid) => {
const r = globalThis.__SP_VIEW__.pieceScreenRect(uid);
return r ? { left: r.left, top: r.top, width: r.width, height: r.height } : null;
}, uid);
/**
* Tap an own prep piece (real mouse) until ITS underframe shows (a unit standing in front can take a low tap: the
* next try is higher up the sprite). Pieces without an underframe (bench summons) just get one tap.
*/
async function tapPiece(page, uid, { underframe = true } = {}) {
for (const at of [0.72, 0.4, 0.18, 0.88]) {
const r = await pieceRect(page, uid);
assert.ok(r && r.width > 0, `piece ${uid} on screen`);
await page.mouse.click(r.left + r.width / 2, r.top + r.height * at);
await sleep(350);
if (!underframe) return true;
if (await page.$(`.uframe[data-uid="${uid}"]`)) return true;
await page.keyboard.press('Escape');
await page.mouse.click(5, Math.round((await page.evaluate(() => innerHeight)) / 2)); // deselect (field press)
await sleep(150);
}
return false;
}
/** Every underframe button: centre, topmost-ness, overlap with the detail card. */
const underframeState = (page) => page.evaluate(() => {
const panel = document.querySelector('.dpanel');
const pr = panel ? panel.getBoundingClientRect() : null;
const out = [];
for (const b of document.querySelectorAll('.uframe__btn')) {
const r = b.getBoundingClientRect();
const x = r.left + r.width / 2; const y = r.top + r.height * 0.35;
const top = document.elementFromPoint(x, y);
const overlap = !!pr && r.left < pr.right && r.right > pr.left && r.top < pr.bottom && r.bottom > pr.top;
out.push({ cls: b.className, x, y, topmost: !!top && b.contains(top), inView: x > 0 && y > 0 && x < innerWidth && y < innerHeight, overlap });
}
return { buttons: out, side: panel?.dataset.side ?? null };
});
// ---- 7 -------------------------------------------------------------------------------------------------------------
for (const render of ['engine', 'fallback']) {
test(`7: 出售 / 销毁 are amber / red plates, not a white block (${render})`, { skip: skipUnless('sell') }, async () => {
const { page, problems } = await open('phase=PREP', { render });
const uids = await page.evaluate(() => {
const p = globalThis.__MOCK__.S().priv;
return { chess: p.hand.find((x) => x && x.kind === 'chess')?.uid, item: p.hand.find((x) => x && x.kind === 'item')?.uid };
});
await tapPiece(page, uids.chess);
await page.waitForSelector('.uframe__btn--sell .uframe__plate', { timeout: 3000 });
const plate = await page.$eval('.uframe__btn--sell .uframe__plate', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.2, y: r.top + r.height * 0.2, w: r.width * 0.6, h: r.height * 0.6 }; });
const c = await meanColor(page, plate);
assert.ok(!(c.r > 225 && c.g > 225 && c.b > 225), `sell plate is not white (${JSON.stringify(c)})`);
assert.ok(c.r > c.b + 50 && c.g > c.b + 10, `sell plate reads amber (${JSON.stringify(c)})`);
await page.screenshot({ path: path.join(OUT, `fix-sell-${render}.png`) });
await page.keyboard.press('Escape');
await tapPiece(page, uids.item);
await page.waitForSelector('.uframe__btn--destroy .uframe__plate', { timeout: 3000 });
const dp = await page.$eval('.uframe__btn--destroy .uframe__plate', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.2, y: r.top + r.height * 0.2, w: r.width * 0.6, h: r.height * 0.6 }; });
const d = await meanColor(page, dp);
assert.ok(!(d.r > 225 && d.g > 225 && d.b > 225), `destroy plate is not white (${JSON.stringify(d)})`);
assert.ok(d.r > d.g + 60 && d.r > d.b + 60, `destroy plate reads red (${JSON.stringify(d)})`);
await page.screenshot({ path: path.join(OUT, `fix-destroy-${render}.png`) });
assert.deepEqual(problems, []);
await page.close();
});
}
// ---- 8 -------------------------------------------------------------------------------------------------------------
for (const render of ['engine', 'fallback']) {
test(`8: every bench / temp / board underframe stays clickable at 8 viewports (${render})`, { skip: skipUnless('reach'), timeout: 12 * 60 * 1000 }, async () => {
for (const [name, w, h] of SIZES) {
const { page, problems } = await open('phase=PREP', { w, h, render });
// a full bench and a full temp row (chess + one item each)
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
const pool = S.pool;
let k = 0;
let uid = 9000;
const chess = () => { const c = pool[(k += 3) % pool.length]; return { uid: ++uid, kind: 'chess', id: c.chessId, golden: false, tier: c.tier, items: [] }; };
for (let i = 0; i < S.priv.hand.length; i++) if (!S.priv.hand[i] || S.priv.hand[i].kind === 'token') S.priv.hand[i] = chess();
for (let i = 0; i < S.priv.temp.length; i++) S.priv.temp[i] = chess();
S.priv.temp[4] = { uid: ++uid, kind: 'item', id: S.priv.hand.find((x) => x && x.kind === 'item')?.id, golden: false, tier: 1 };
}));
await sleep(600);
const pieces = await page.evaluate(() => {
const p = globalThis.__MOCK__.S().priv;
return [
...p.hand.map((x, i) => x && { uid: x.uid, where: `hand${i}` }),
...p.temp.map((x, i) => x && { uid: x.uid, where: `temp${i}` }),
...p.board.map((x) => ({ uid: x.uid, where: `board${x.row},${x.col}` })),
].filter(Boolean);
});
let checked = 0;
let rightDocked = 0;
const board = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.map((p) => ({ uid: p.uid, row: p.row, col: p.col })));
for (const pc of pieces) {
const tapped = await tapPiece(page, pc.uid);
// a board unit standing right behind others in its column can be hidden by their sprites (as in the game)
const b = board.find((x) => x.uid === pc.uid);
if (!tapped && b && board.some((o) => o.col === b.col && o.row < b.row && b.row - o.row <= 2)) continue;
assert.ok(tapped, `${name}/${render}: ${pc.where} shows its underframe`);
const st2 = await underframeState(page);
assert.ok(st2.buttons.length, `${name}/${render}: ${pc.where} underframe buttons`);
for (const b of st2.buttons) {
assert.ok(b.inView, `${name}/${render}: ${pc.where} ${b.cls} inside the viewport`);
assert.ok(b.topmost, `${name}/${render}: ${pc.where} ${b.cls} is the topmost element (clickable)`);
assert.ok(!b.overlap, `${name}/${render}: ${pc.where} ${b.cls} not under the detail card (${st2.side})`);
}
if (st2.side === 'right') rightDocked++;
checked++;
if (pc.where === 'hand0' || pc.where === 'hand1') await page.screenshot({ path: path.join(OUT, `fix-reach-${name}-${render}-${pc.where}.png`) });
await page.keyboard.press('Escape'); // closes the card (the next tap selects the next unit)
await sleep(120);
}
assert.ok(checked >= pieces.length - 2, `${name}/${render}: checked ${checked}/${pieces.length}`);
// the leftmost bench unit's card docks right whenever the left card would cover its underframe
// a real click on 出售 of the leftmost bench unit sells it
const first = pieces[0];
await tapPiece(page, first.uid);
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
const sell = await page.$eval('.uframe__btn--sell', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height * 0.35 }; });
await page.mouse.click(sell.x, sell.y);
await sleep(400);
const after = await page.evaluate((uid) => ({ funds: globalThis.__MOCK__.S().priv.funds, gone: !globalThis.__MOCK__.S().priv.hand.some((x) => x && x.uid === uid) }), first.uid);
assert.ok(after.gone && after.funds > funds0, `${name}/${render}: 出售 of ${first.where} sold it (${JSON.stringify(after)})`);
assert.deepEqual(problems, [], `${name}/${render}`);
await page.close();
void rightDocked;
}
});
}
// review: the top board row under the bond strip, and a notched phone's safe-area insets
test('8: a top-row unit under the bond strip is tappable; on a notched phone the underframe stays on its unit', { skip: skipUnless('reach'), timeout: 5 * 60 * 1000 }, async () => {
for (const [name, w, h] of [['16x9', 1920, 1080], ['21x9', 2560, 1080], ['720p', 1280, 720], ['phone', 844, 390]]) {
const { page, problems } = await open('phase=PREP', { w, h });
// the mock's row-12 unit (col 4, behind the row-11 one): its body reaches under the bond strip at these sizes; a
// press on its tile selects it (picking is by tile, user playtest #4 item 1), so the tile must be free of the strip
const top = await page.evaluate(() => {
const b = globalThis.__MOCK__.S().priv.board.filter((p) => p.row === 12).sort((a, c) => a.col - c.col)[0];
const r = globalThis.__SP_VIEW__.pieceScreenRect(b.uid);
const t = globalThis.__SP_VIEW__.raw.tileScreen(b.row, b.col);
const strip = document.querySelector('.gm__bonds').getBoundingClientRect();
const pts = [-0.3, 0, 0.3].map((dx) => ({ x: t.x + dx * t.s, y: t.y }));
const hits = pts.map((p) => { const el = document.elementFromPoint(p.x, p.y); return el.tagName === 'CANVAS' ? 'canvas' : el.closest('.bslot') ? 'disc' : `${el.tagName}.${el.className}`; });
return { uid: b.uid, pts, hits, bodyUnderStrip: r.top < strip.bottom };
});
assert.ok(top.bodyUnderStrip, `${name}: the row-12 unit's body reaches under the bond strip (test premise)`);
for (const hit of top.hits) assert.equal(hit, 'canvas', `${name}: the row-12 unit's tile is free of the bond strip`);
await page.mouse.click(top.pts[1].x, top.pts[1].y);
await sleep(350);
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's tile selects it`);
await page.keyboard.press('Escape');
await page.mouse.click(5, Math.round(h / 2));
await sleep(150);
// notched phone in landscape: the HUD layer sits inside the safe-area insets (css/devices.css)
await page.evaluate(() => { const s = document.documentElement.style; s.setProperty('--sa-l', '47px'); s.setProperty('--sa-r', '47px'); s.setProperty('--sa-t', '12px'); s.setProperty('--sa-b', '21px'); });
await sleep(300);
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.map((x, i) => x && x.kind === 'chess' && { uid: x.uid, i }).filter(Boolean).slice(0, 2));
for (const pc of hand) {
assert.ok(await tapPiece(page, pc.uid), `${name} (safe area): hand${pc.i} selected`);
const st = await page.evaluate((pc) => {
const uf = document.querySelector(`.uframe[data-uid="${pc.uid}"]`).getBoundingClientRect();
const t = globalThis.__SP_VIEW__.raw.tileScreen(7, pc.i);
return { dx: uf.left + uf.width / 2 - t.x, dy: uf.top + uf.height / 2 - t.y };
}, pc);
assert.ok(Math.abs(st.dx) < 2 && Math.abs(st.dy) < 2, `${name} (safe area): hand${pc.i} underframe centred on its tile (${JSON.stringify(st)})`);
const s2 = await underframeState(page);
for (const b of s2.buttons) {
assert.ok(b.topmost && b.inView, `${name} (safe area): hand${pc.i} ${b.cls} clickable`);
assert.ok(!b.overlap, `${name} (safe area): hand${pc.i} ${b.cls} not under the detail card (${s2.side})`);
}
await page.keyboard.press('Escape');
await page.mouse.click(5, Math.round(h / 2)); // deselect (a field press)
await sleep(150);
}
if (name === '16x9') await page.screenshot({ path: path.join(OUT, 'fix-reach-safearea.png') });
assert.deepEqual(problems, [], name);
await page.close();
}
});
// ---- 9 -------------------------------------------------------------------------------------------------------------
test('9: the detail card shows the unit\'s bonds under the header, visible without scrolling; the bond popup leads with its facts', { skip: skipUnless('bonds') }, async () => {
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
const { page, problems } = await open('phase=PREP', { w, h });
// a front-row unit (row 9: nothing stands in front of it)
const uid = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.find((p) => p.row === 9).uid);
assert.ok(await tapPiece(page, uid), 'unit selected');
await page.waitForSelector('.dpanel .dbonds--top', { timeout: 3000 });
const r = await page.evaluate(() => {
const panel = document.querySelector('.dpanel__scroll');
const pr = panel.getBoundingClientRect();
const art = document.querySelector('.dpanel .dhead__art').getBoundingClientRect();
const name = document.querySelector('.dpanel .dhead__name').getBoundingClientRect();
const chips = [...document.querySelectorAll('.dpanel .dbonds--top .dbond')];
const kids = [...panel.children].map((el) => el.className);
return {
first: kids[0], inInfo: !!document.querySelector('.dpanel .dhead__info > .dbonds--top'),
art: { right: art.right, top: art.top, bottom: art.bottom }, nameBottom: name.bottom,
chips: chips.map((el) => { const r = el.getBoundingClientRect(); return { text: el.textContent.trim(), left: r.left, bottom: r.bottom, top: r.top, icon: !!el.querySelector('.bglyph'), count: el.querySelector('.dbond__count')?.textContent.trim() || '' }; }),
visibleBottom: pr.bottom, scrollTop: panel.scrollTop,
bonds: (globalThis.__SP__?.store || globalThis.__MOCK__.store).get().match.private.bonds.length,
};
});
assert.equal(r.first, 'dhead');
assert.ok(r.inInfo, 'bonds in the header\'s right column (under name / tier)');
assert.ok(r.chips.length >= 1, 'bond chips');
for (const c of r.chips) {
assert.ok(c.icon, `chip ${c.text} has its icon`);
assert.match(c.count, /^\d+(\/\d+)?$/, `chip ${c.text} shows count/threshold`);
assert.ok(c.bottom <= r.visibleBottom && r.scrollTop === 0, `chip ${c.text} visible without scrolling`);
assert.ok(c.left >= r.art.right - 1, `chip ${c.text} right of the portrait (top right)`);
assert.ok(c.top >= r.nameBottom - 1 && c.top < r.art.bottom, `chip ${c.text} under the name, beside the portrait`);
}
await page.screenshot({ path: path.join(OUT, `fix-bonds-${w}.png`) });
// the chip opens the bond popup; facts + current effect come before the long description
await page.click('.dpanel .dbonds--top .dbond');
await page.waitForSelector('.bpop', { timeout: 3000 });
const pop = await page.evaluate(() => {
const secs = [...document.querySelectorAll('.bpop .bpop__sec')].map((s) => s.querySelector('h4')?.textContent || '');
const facts = document.querySelector('.bpop__facts').getBoundingClientRect();
const box = document.querySelector('.bpop').getBoundingClientRect();
const btn = [...document.querySelectorAll('.uframe__btn')].map((b) => { const r = b.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height * 0.35); return !!t && b.contains(t); });
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
const card = document.querySelector('.dpanel').getBoundingClientRect();
const pop = document.querySelector('.bpop');
return {
secs, factsIn: facts.bottom <= box.bottom, btnTop: btn, place: pop.dataset.place, over: pop.classList.contains('is-over'),
coversButtons: [...document.querySelectorAll('.uframe__btn')].some((b) => hit(b.getBoundingClientRect(), box)),
coversCard: hit(card, box),
};
});
// the playtest's case (a unit left of the board, card docked right) — the popup keeps clear of the underframe
assert.equal(pop.coversButtons, false, `the bond popup (${pop.place}) keeps clear of the underframe buttons`);
assert.ok(!pop.coversCard || pop.over, `the popup covers the card only when placed over it (${pop.place})`);
const iNow = pop.secs.findIndex((t) => t.includes('当前效果'));
const iDesc = pop.secs.findIndex((t) => t.includes('盟约效果'));
if (iNow >= 0) assert.ok(iNow < iDesc, `current effect before the description (${pop.secs})`);
assert.ok(pop.factsIn);
assert.ok(pop.btnTop.every(Boolean), 'the underframe stays above the bond popup');
await sleep(450); // the pop-in animation
await page.screenshot({ path: path.join(OUT, `fix-bondpop-${w}.png`) });
assert.deepEqual(problems, []);
await page.close();
}
});
// ---- §16 loadout ---------------------------------------------------------------------------------------------------
test('loadout (DESIGN §16): shop card skill badge, detail card skill + 已调配, elite module 未装备模组', { skip: skipUnless('loadout') }, async () => {
const { page, problems } = await open('phase=PREP&variant=loadout');
const lo = await page.evaluate(() => globalThis.__MOCK__.S().priv.loadout);
assert.ok(lo && Object.keys(lo).length >= 1, 'the mock sends m.private.loadout');
const badges = await page.$$eval('.scard__skill', (els) => els.map((el) => ({ custom: el.classList.contains('is-custom'), title: el.getAttribute('title') || '' })));
assert.ok(badges.length >= 3, 'every operator card shows its skill');
assert.ok(badges.some((b) => b.custom && b.title.includes('已调配')), `a card with a non-default skill (${JSON.stringify(badges)})`);
assert.ok(badges.some((b) => !b.custom), 'default skills stay plain');
// a chosen skill is never a blank square: its icon, or its slot letter when the manifest has no icon for it
const marks = await page.$$eval('.scard__skill.is-custom', (els) => els.map((el) => ({ img: el.querySelector('img')?.getAttribute('src') || null, glyph: el.querySelector('.scard__sglyph')?.textContent || null })));
for (const g of marks) assert.ok((g.img && !/empty/i.test(g.img)) || /^S[1-3]$/.test(g.glyph || ''), `custom skill badge shows an icon or S1–S3 (${JSON.stringify(g)})`);
await page.screenshot({ path: path.join(OUT, 'fix-loadout-shop.png') });
const b0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.board[0].uid);
await tapPiece(page, b0);
await page.waitForSelector('.dpanel .dskill', { timeout: 3000 });
const d = await page.evaluate(() => ({ name: document.querySelector('.dpanel .dskill__name')?.textContent || '', tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout') }));
assert.ok(d.tag, `detail card marks the loadout skill (${d.name})`);
const icon = await page.evaluate(() => { const el = document.querySelector('.dpanel .dskill > .dskill__icon'); return { img: el?.tagName === 'IMG' ? el.getAttribute('src') : null, glyph: el?.querySelector('b')?.textContent || null }; });
assert.ok((icon.img && !/empty/i.test(icon.img)) || /^S[1-3]$/.test(icon.glyph || ''), `detail skill icon or S1–S3 (${JSON.stringify(icon)})`);
await page.evaluate(() => { const el = document.querySelector('.dpanel .dsec--skill'); el?.scrollIntoView({ block: 'center' }); });
await sleep(200);
await page.screenshot({ path: path.join(OUT, 'fix-loadout-detail.png') });
await page.keyboard.press('Escape');
const g = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.find((p) => p.golden)?.uid);
if (g) {
assert.ok(await tapPiece(page, g), 'the elite is selected');
await page.waitForSelector('.dpanel .dsec--module', { timeout: 3000 });
const mod = await page.$eval('.dpanel .dmodule', (el) => ({ none: el.classList.contains('is-none'), text: el.textContent }));
assert.ok(mod.none && mod.text.includes('未装备模组'), `elite module unequipped (${mod.text})`);
}
assert.deepEqual(problems, []);
await page.close();
});
// ---- 6 -------------------------------------------------------------------------------------------------------------
/** Pen / board-area state of the engine view. */
const penState = (page) => page.evaluate(() => {
const raw = globalThis.__SP_VIEW__.raw;
const d = raw.debug;
const pens = [...d.penViews.values()];
const area = d.board3d ? d.board3d.area : null;
return {
camera: document.querySelector('.gm')?.dataset.camera, kind: d.camKind,
pen: pens.length, penVisible: pens.filter((v) => v.root && v.root.visible !== false).length,
band: d.tiles.band, penRowsDrawn: d.tiles.grid.some((row) => row.some((t) => t.r >= 14 && t.drawn)),
area3d: area ? Math.max(...area.map((a) => a.r1)) : null, board3d: !!d.board3d,
};
});
// The field's 3D area ends at its separator row 13 — the row-13 devices blow into the field (act2 m01's blowers, user
// playtest #5 item 6: render/app.js boardArea) — and the enemy preview pen starts at row 14.
const NO_PEN_R1 = 13;
test('6: the enemy pen shows only in the pen view — never with the prep board (after 返回战场) or any battle', { skip: skipUnless('pen'), timeout: 5 * 60 * 1000 }, async () => {
const { page, problems } = await open('phase=PREP', { w: 1920, h: 1080 });
let s = await penState(page);
assert.equal(s.camera, 'prep');
assert.ok(s.pen > 0, 'the pen holds the next round\'s enemies');
assert.equal(s.penVisible, 0, 'prep board: no pen figure shown');
assert.equal(s.penRowsDrawn, false, 'prep board: pen rows not drawn');
if (s.board3d) assert.ok(s.area3d <= NO_PEN_R1, `prep board: 3D area without the pen (${s.area3d})`);
await page.screenshot({ path: path.join(OUT, 'fix-pen-prep.png') });
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
await sleep(700);
s = await penState(page);
assert.ok(s.penVisible > 0, 'pen view: figures shown');
assert.ok(s.penRowsDrawn, 'pen view: pen rows drawn');
if (s.board3d) assert.ok(s.area3d >= 18, 'pen view: 3D pen area built');
await page.screenshot({ path: path.join(OUT, 'fix-pen-view.png') });
// 🔍◀◀ (tooltip 返回战场) back to the board: the pen is gone again
await page.click('.gtop__iconbtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
await sleep(900);
s = await penState(page);
assert.equal(s.penVisible, 0, 'back on the board (返回战场): no pen figure');
assert.equal(s.penRowsDrawn, false, 'back on the board: pen rows not drawn');
if (s.board3d) assert.ok(s.area3d <= NO_PEN_R1, `back on the board: no pen area (${s.area3d})`);
await page.screenshot({ path: path.join(OUT, 'fix-pen-back.png') });
assert.deepEqual(problems, []);
await page.close();
// every battle camera
for (const phase of ['COMBAT', 'UNITE', 'FINAL_ASSAULT', 'HIDDEN_CORE']) {
const o = await open(`phase=${phase}`, { w: 1920, h: 1080 });
await sleep(1200);
const b = await penState(o.page);
assert.equal(b.penVisible, 0, `${phase}: no pen figure`);
assert.equal(b.penRowsDrawn, false, `${phase}: pen rows not drawn`);
if (b.board3d) assert.ok(b.area3d <= NO_PEN_R1, `${phase}: no pen area (${b.area3d})`);
await o.page.screenshot({ path: path.join(OUT, `fix-pen-${phase.toLowerCase()}.png`) });
assert.deepEqual(o.problems, [], phase);
await o.page.close();
}
});
});