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
This commit is contained in:
sganggs committed 2026-09-30 19:12:36 +08:00
1 parent d4f868cd19
commit 15291c2293
104 files changed
+5539 -662

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
// 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();
}
});
});