Files
ark-stronghold-protocol/test/ui/playtest5-ui.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

203 lines
12 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 #5, UI items 8 / 9, 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/playtest5-ui.e2e.test.js
//
// 9 during prep no bench (row 7) / temp (row 8) tile is covered by DOM overlays (the shop bar, the corner buttons, the
// tools) and the field's back row is free of the bond strip — own board and Final Assault half, at the phone
// landscape viewports (844×390 and 932×430 with and without the iPhone insets, 915×412, 800×360, 914×411,
// 960×411, 924×424, and the user's Android: 756×366 — its 2772×1272 px screenshot shows the page right of a
// 141 px black cutout band, 2631×1272 px at DPR ≈ 3.48, the exit and 准备就绪 buttons matching to ±1 px — and
// 798×366 with a 41 px left inset had the page covered the cutout) and at desktop 16:9 / 16:10 / 3:2 / 21:9, where
// the official shop camera is unchanged (122 px per bench tile at 1920×1080). Each tile top's projected quad is
// sampled on a 24 × 24 grid with elementFromPoint (every HUD element hit-testable); a sample counts as covered
// when the element there paints (background, border, text, image) — v2.3 left 71 % of bench pads 4–9 free at
// 844×390, 49 % at 800×360 and 54 % at 756×366 (the user's screenshot: pads 3–9 about half covered).
// 9 an armed shop card (first tap of the two-tap buy) at 756×366 rises ≤ 5 px above the bar and covers ≤ 4 px of
// the bench pads' near corners (accepted, ui/fieldHost.js hudBands)
// 8 the corner's ⚙ is the regular gear glyph (ui/gameComponents.js gearPath), square, as large as 📖 / ⛶ (half the
// grown button on phones)
// Screenshots: test/e2e/out/p5-*.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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const IPH = (s) => ({ l: s, r: s, t: 0, b: 21 }); // iPhone landscape safe-area insets (px)
/** name, width, height, touch, insets */
const VIEWS = [
['844x390', 844, 390, true, null], ['844x390-notch', 844, 390, true, IPH(47)], ['932x430', 932, 430, true, null],
['932x430-notch', 932, 430, true, IPH(59)], ['915x412', 915, 412, true, null], ['800x360', 800, 360, true, null],
['914x411', 914, 411, true, null], ['960x411', 960, 411, true, null], ['924x424', 924, 424, true, null],
['756x366', 756, 366, true, null], ['798x366-inset41', 798, 366, true, { l: 41, r: 0, t: 0, b: 0 }],
['1920x1080', 1920, 1080, false, null], ['1680x1050', 1680, 1050, false, null], ['1560x1040', 1560, 1040, false, null],
['2560x1080', 2560, 1080, false, null],
];
const view = (name) => VIEWS.find((v) => v[0] === name);
describe('user playtest #5 — UI items 8 / 9 (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'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w, h, touch, insets }) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
if (insets) {
await page.evaluate((i) => {
const s = document.documentElement.style;
s.setProperty('--sa-l', `${i.l}px`); s.setProperty('--sa-r', `${i.r}px`); s.setProperty('--sa-t', `${i.t}px`); s.setProperty('--sa-b', `${i.b}px`);
}, insets);
await page.evaluate(() => globalThis.__SP_VIEW__.raw.resize());
}
await sleep(1800);
return { page, problems };
}
/** Free fraction of each tile top of the listed board tiles (board coordinates; the boss prep maps them itself). */
const freeFractions = (page, tiles) => page.evaluate((tiles) => {
const view = globalThis.__SP_VIEW__.raw;
const st = document.createElement('style');
st.textContent = '.gm__hud, .gm__hud * { pointer-events: auto !important; }';
document.head.appendChild(st);
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c);
const paints = (el) => {
if (!el || el.tagName === 'CANVAS' || el.closest('.gm__field')) return false;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
if (['IMG', 'svg', 'path', 'B', 'SPAN', 'KBD', 'I', 'P', 'LABEL'].includes(el.tagName)) return true;
if (!clear(cs.backgroundColor) || cs.backgroundImage !== 'none') return true;
if (['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && !clear(cs[`border${s}Color`]))) return true;
return [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
};
const inside = (poly, x, y) => {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
}
return c;
};
const out = tiles.map(([r, c]) => {
const t = view.tileScreen(r, c);
if (!t) return { r, c, free: -1 };
const xs = t.poly.map((p) => p[0]), ys = t.poly.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
let n = 0, free = 0;
const N = 24;
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
const x = x0 + (x1 - x0) * (i + 0.5) / N, y = y0 + (y1 - y0) * (j + 0.5) / N;
if (!inside(t.poly, x, y)) continue;
n++;
if (x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && !paints(document.elementFromPoint(x, y))) free++;
}
return { r, c, free: n ? free / n : 0, s: t.s };
});
st.remove();
return out;
}, tiles);
const BENCH = Array.from({ length: 10 }, (_, c) => [7, c]);
const TEMP = [4, 5, 6, 7, 8].map((c) => [8, c]);
const BACK = [4, 5, 6, 7, 8, 9, 10].map((c) => [12, c]); // the playable back row of 战场#01 (cols 0–3: forbidden)
test('9: bench, temp and the back row are free of the HUD in prep and in the Final Assault prep', { timeout: 10 * 60 * 1000 }, async () => {
const report = [];
for (const [name, w, h, touch, insets] of VIEWS) {
for (const variant of ['', 'boss']) {
const { page, problems } = await open(`phase=PREP${variant ? `&variant=${variant}` : ''}`, { w, h, touch, insets });
const tag = `${name}${variant ? ' (Final Assault prep)' : ''}`;
const bench = await freeFractions(page, BENCH);
const temp = await freeFractions(page, TEMP);
const back = await freeFractions(page, BACK);
for (const t of [...bench, ...temp]) assert.ok(t.free >= 0.99, `${tag}: ${t.r === 7 ? 'bench' : 'temp'} ${t.c} ${Math.round(t.free * 100)} % free`);
for (const t of back) assert.ok(t.free >= 0.99, `${tag}: back row ${t.c} ${Math.round(t.free * 100)} % free`);
// desktop 16:9 keeps the official shop camera
if (name === '1920x1080' && !variant) assert.ok(Math.abs(bench[5].s - 122) < 1, `1920×1080 px per bench tile ${bench[5].s}`);
report.push(`${tag}: bench ${Math.round(Math.min(...bench.map((t) => t.free)) * 100)} %, ${Math.round(bench[5].s)} px/tile`);
if (['844x390', '756x366', '800x360', '1920x1080'].includes(name)) await page.screenshot({ path: path.join(OUT, `p5-bench-${name}${variant ? '-boss' : ''}.png`) });
assert.deepEqual(problems, [], tag);
await page.close();
}
}
console.log(report.join('\n'));
});
test('9: an armed shop card (two-tap buy) barely reaches the bench on the user\'s phone', async () => {
// accepted overlap (ui/fieldHost.js hudBands): the armed card rises 4 px above the bar at 1rem = 40 px and covers
// the pads' near corners by ≈ 3 px — less than under the official camera at 1920×1080 (13 px / ≈ 11 px)
const [, w, h, touch] = view('756x366');
const { page, problems } = await open('phase=PREP', { w, h, touch });
const cards = await page.$$('.scard:not(.scard--sold)');
assert.ok(cards.length > 0, 'a shop card to arm');
await cards[0].tap();
await sleep(400);
const r = await page.evaluate(() => {
const a = document.querySelector('.scard.is-armed');
if (!a) return null;
const b = a.getBoundingClientRect(), row = document.querySelector('.shopbar__row').getBoundingClientRect();
const view = globalThis.__SP_VIEW__.raw;
let over = 0;
for (let c = 0; c < 10; c++) {
const t = view.tileScreen(7, c);
const xs = t.poly.map((p) => p[0]), y1 = Math.max(...t.poly.map((p) => p[1]));
if (Math.max(...xs) > b.left && Math.min(...xs) < b.right) over = Math.max(over, y1 - b.top);
}
return { lift: row.top - b.top, over };
});
assert.ok(r, 'the first tap arms the card');
assert.ok(r.lift <= 5, `armed card ${r.lift} px above the bar`);
assert.ok(r.over <= 4, `armed card over the bench pads by ${r.over} px`);
assert.deepEqual(problems, []);
await page.close();
});
test('8: the corner gear is the regular glyph, square and as large as its neighbours', async () => {
const { GLYPHS } = await import('../../public/js/ui/gameComponents.js');
for (const [name, w, h, touch] of [view('844x390'), view('756x366'), view('1920x1080')]) {
const { page, problems } = await open('phase=PREP', { w, h, touch });
const r = await page.evaluate(() => [...document.querySelectorAll('.gm__corner .gm__gear')].map((b) => {
const svg = b.querySelector('svg.icon');
const s = svg.getBoundingClientRect(), br = b.getBoundingClientRect();
return { cls: b.className, w: s.width, h: s.height, bw: br.width, d: svg.querySelector('path')?.getAttribute('d') };
}));
assert.equal(r.length, 3, `${name}: ⚙ 📖 ⛶`);
assert.equal(r[0].d, GLYPHS.gear, `${name}: the gear glyph`);
for (const b of r) {
assert.ok(Math.abs(b.w - b.h) < 0.01, `${name}: ${b.cls} square (${b.w}×${b.h})`);
assert.ok(b.w >= 0.45 * b.bw && b.w <= 0.55 * b.bw, `${name}: ${b.cls} icon ${b.w} in a ${b.bw} button`);
}
await page.screenshot({ path: path.join(OUT, `p5-corner-${name}.png`), clip: { x: 0, y: h - 90, width: 420, height: 90 } });
assert.deepEqual(problems, [], name);
await page.close();
}
});
});