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:
1 parent
d4f868cd19
commit
15291c2293
104 files changed
+5539
-662
No files matched your search
@@ -392,6 +392,10 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
|
||||
};
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -399,7 +403,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
|
||||
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 <= 12, `prep board: 3D area without the pen (${s.area3d})`);
|
||||
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 });
|
||||
@@ -416,7 +420,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
|
||||
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 <= 12, 'back on the board: no pen area');
|
||||
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();
|
||||
@@ -428,7 +432,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
|
||||
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 <= 12, `${phase}: no pen area (${b.area3d})`);
|
||||
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();
|
||||
|
||||
@@ -201,12 +201,16 @@ async function untilPrep(c, pred, what, timeout = 90000) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
function assertNoPen(v, tag) {
|
||||
assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`);
|
||||
assert.equal(v.penVisible, 0, `${tag}: no pen figure`);
|
||||
assert.equal(v.penRowsDrawn, false, `${tag}: no pen rows drawn (${v.band})`);
|
||||
assert.ok(v.band && v.band[1] <= 13, `${tag}: drawn rows ${v.band}`);
|
||||
if (v.area3d != null) assert.ok(v.area3d <= 12, `${tag}: 3D area without the pen block (${v.area3d})`);
|
||||
if (v.area3d != null) assert.ok(v.area3d <= NO_PEN_R1, `${tag}: 3D area without the pen block (${v.area3d})`);
|
||||
}
|
||||
|
||||
describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
|
||||
@@ -26,7 +26,8 @@ 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`);
|
||||
// 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']) {
|
||||
@@ -44,8 +45,8 @@ describe('6: the enemy pen only with the pen camera', () => {
|
||||
});
|
||||
|
||||
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.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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// Regression tests (Node) of user playtest #5, UI items (workstream W5):
|
||||
// 8 the settings button's gear (bottom-left corner, next to 交流 / 📖 / ⛶) looked deformed: the hand-written path had
|
||||
// teeth of different sizes on a rim that was not round. It is now a regular 8-tooth gear (ui/gameComponents.js
|
||||
// gearPath): every vertex on the tip / root circle, 8-fold rotational and mirror symmetric, a centred round hole.
|
||||
// On phones the corner glyphs keep half the grown button (css/devices.css).
|
||||
// 9 on phones in landscape the shop bar covered the lower part of the bench (整备区, row 7): the prep camera keeps
|
||||
// the bench / temp rows and the field's back row between the HUD bands (ui/fieldHost.js hudBands →
|
||||
// render/app.js → render/projection.js clearHud; the camera math itself: test/render/projection.test.js). The
|
||||
// bands mirror the CSS; here: the CSS rules they depend on, the wiring, and every bench / temp / back-row tile at
|
||||
// the phone viewports through the real hudBands. Browser measurement: test/ui/playtest5-ui.e2e.test.js.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
const { GLYPHS, gearPath, GIcon } = await import('../../public/js/ui/gameComponents.js');
|
||||
const { hudBands, HUD_REM } = await import('../../public/js/ui/fieldHost.js');
|
||||
const { presetCamera, tileQuad } = await import('../../public/js/render/projection.js');
|
||||
|
||||
// ---- 8: the gear ----------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Outer-contour vertices (endpoints of M / L / A commands before the first Z) and the arcs' radii of a gear path. */
|
||||
function contour(d) {
|
||||
const outer = d.slice(0, d.indexOf('Z'));
|
||||
const pts = [];
|
||||
const radii = [];
|
||||
for (const m of outer.matchAll(/([MLA])([^MLAZ]+)/g)) {
|
||||
const n = m[2].trim().split(/[\s,]+/).map(Number);
|
||||
if (m[1] === 'A') { radii.push(n[0], n[1]); pts.push([n[5], n[6]]); } else pts.push([n[0], n[1]]);
|
||||
}
|
||||
return { pts, radii };
|
||||
}
|
||||
|
||||
/** A regular gear: vertices on two circles about (12, 12), invariant under a 1/teeth turn and the mirror x → 24 − x. */
|
||||
function regularGear(d, teeth = 8, eps = 0.03) {
|
||||
// absolute commands only (the old hand-written path used relative ones — not a generated gear at all)
|
||||
if (/[a-z]/.test(d.replace(/e-?\d/g, ''))) return false;
|
||||
const { pts } = contour(d);
|
||||
const r = pts.map(([x, y]) => Math.hypot(x - 12, y - 12));
|
||||
const rs = [...new Set(r.map((v) => v.toFixed(1)))];
|
||||
if (rs.length !== 2) return false;
|
||||
const has = ([x, y]) => pts.some(([u, v]) => Math.abs(u - x) < eps && Math.abs(v - y) < eps);
|
||||
const a = (2 * Math.PI) / teeth;
|
||||
for (const [x, y] of pts) {
|
||||
const dx = x - 12, dy = y - 12;
|
||||
if (!has([12 + dx * Math.cos(a) - dy * Math.sin(a), 12 + dx * Math.sin(a) + dy * Math.cos(a)])) return false;
|
||||
if (!has([24 - x, y])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('8: the settings gear is a clean, regular icon', () => {
|
||||
const OLD = 'M10.3 2h3.4l.5 2.6c.6.2 1.2.5 1.7.9l2.5-.9 1.7 2.9-2 1.8c.1.6.1 1.2 0 1.8l2 1.8-1.7 2.9-2.5-.9c-.5.4-1.1.7-1.7.9l-.5 2.6h-3.4l-.5-2.6c-.6-.2-1.2-.5-1.7-.9l-2.5.9L2 14.9l2-1.8a6 6 0 0 1 0-1.8L2 9.5l1.7-2.9 2.5.9c.5-.4 1.1-.7 1.7-.9zM12 8.8a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4z';
|
||||
|
||||
test('reproduction: the v2.3 path was not a regular gear', () => {
|
||||
assert.equal(regularGear(OLD), false);
|
||||
});
|
||||
|
||||
test('the gear glyph: 8 teeth, every vertex on the tip (r 10) or root (r 7.4) circle, rotational + mirror symmetric', () => {
|
||||
assert.equal(GLYPHS.gear, gearPath());
|
||||
assert.ok(regularGear(GLYPHS.gear), GLYPHS.gear);
|
||||
const { pts, radii } = contour(GLYPHS.gear);
|
||||
assert.equal(pts.length, 1 + 8 * 4, 'start + 4 vertices per tooth');
|
||||
const rs = pts.map(([x, y]) => Math.hypot(x - 12, y - 12));
|
||||
assert.ok(Math.min(...rs) > 7.38 && Math.max(...rs) < 10.02, `${Math.min(...rs)}..${Math.max(...rs)}`);
|
||||
assert.deepEqual([...new Set(radii)].sort(), ['10', '7.4'].map(Number).sort());
|
||||
// inside the 24 × 24 box, centred: the same extent as the 📖 glyph next to it (x 2–22)
|
||||
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
||||
assert.ok(Math.min(...xs) >= 2 && Math.max(...xs) <= 22 && Math.min(...ys) >= 2 && Math.max(...ys) <= 22);
|
||||
assert.ok(Math.abs(Math.min(...xs) + Math.max(...xs) - 24) < 0.02 && Math.abs(Math.min(...ys) + Math.max(...ys) - 24) < 0.02);
|
||||
// the hole: a circle of r 3.3 about the centre (even-odd)
|
||||
assert.match(GLYPHS.gear, /ZM12 8\.7A3\.3 3\.3 0 1 0 12 15\.3A3\.3 3\.3 0 1 0 12 8\.7Z$/);
|
||||
// other gear shapes stay regular too (the generator, not one lucky constant)
|
||||
assert.ok(regularGear(gearPath({ teeth: 6, tipW: 3.6, rootW: 5 }), 6));
|
||||
});
|
||||
|
||||
test('GIcon draws it as an even-odd 24 × 24 SVG path', () => {
|
||||
const v = GIcon({ name: 'gear' });
|
||||
assert.equal(v.type, 'svg');
|
||||
assert.equal(v.props.viewBox, '0 0 24 24');
|
||||
const p = [v.props.children].flat(3).find((c) => c && c.type === 'path');
|
||||
assert.equal(p.props.d, GLYPHS.gear);
|
||||
assert.equal(p.props['fill-rule'], 'evenodd');
|
||||
});
|
||||
|
||||
test('phones: the corner glyphs keep half of the grown 34 px buttons (not an 11 px glyph)', () => {
|
||||
const css = read('public/css/devices.css');
|
||||
const phone = css.slice(css.indexOf('@media (max-height: 600px) and (pointer: coarse)'));
|
||||
const block = phone.slice(0, phone.indexOf('\n}\n'));
|
||||
assert.match(block, /\.gm__gear \{ width: max\(\.56rem, 34px\); height: max\(\.56rem, 34px\); \}/);
|
||||
assert.match(block, /\.gm__corner \.gm__gear \.icon \{ width: max\(\.28rem, 17px\); height: max\(\.28rem, 17px\); \}/);
|
||||
// desktop: .28rem in a .56rem button — the same half
|
||||
assert.match(read('public/css/screens/game.css'), /\.gm__gear \.icon \{ width: \.28rem; height: \.28rem; \}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 9: the bench stays clear of the shop bar on phones -------------------------------------------------------------
|
||||
|
||||
/** Root font size of css/theme.css: clamp(40px, min(100vw / 19.2, 100vh / 10.8), 240px). */
|
||||
const remAt = (w, h) => Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
|
||||
|
||||
/** Run `fn` with a stubbed DOM: the root font size, and the HUD layer's top (safe-area inset). */
|
||||
function withDom(rem, hudTop, fn) {
|
||||
const g = globalThis;
|
||||
const saved = { document: g.document, getComputedStyle: g.getComputedStyle };
|
||||
g.getComputedStyle = () => ({ fontSize: `${rem}px` });
|
||||
g.document = { documentElement: {}, querySelector: (s) => (s === '.gm__hud' ? { getBoundingClientRect: () => ({ top: hudTop }) } : null) };
|
||||
try { return fn(); } finally { g.document = saved.document; g.getComputedStyle = saved.getComputedStyle; }
|
||||
}
|
||||
|
||||
describe('9: the prep camera keeps the bench clear of the shop bar on phones in landscape', () => {
|
||||
test('HUD_REM mirrors the CSS it measures (bond strip bottom, shop bar top, the notched-phone rule)', () => {
|
||||
const game = read('public/css/screens/game.css');
|
||||
const shop = read('public/css/screens/game-shop.css');
|
||||
// bond strip: top 1.36rem + a .52rem disc and its name line → measured 2.14–2.15rem in Chrome; 2.16rem kept
|
||||
assert.match(game, /\.gm__bonds \{ position: absolute; left: 1\.56rem; top: 1\.36rem;/);
|
||||
assert.match(game, /\.bslot \.bond \{ --disc: \.52rem; \}/);
|
||||
assert.equal(HUD_REM.bondStripBottom, 2.16);
|
||||
// shop bar: bottom .2rem + row padding .1rem × 2 + 2.24rem cards (level / operator / item) + 2 px + 1 px borders
|
||||
assert.match(shop, /\.shopbar \{\n {2}position: absolute; right: \.26rem; bottom: \.2rem;/);
|
||||
assert.match(shop, /\.shopbar__row \{\n {2}position: relative; display: flex; align-items: stretch; gap: \.08rem; padding: \.1rem;\n[^}]*border: 1px solid var\(--line-2\); border-top: 2px solid var\(--mint-700\);/);
|
||||
assert.match(shop, /\.lvcard \{\n {2}position: relative; width: 1\.24rem; height: 2\.24rem;/);
|
||||
assert.match(shop, /\.scard \{\n {2}--tc: var\(--tier-1\);\n {2}position: relative; width: 1\.56rem; height: 2\.24rem;/);
|
||||
assert.equal(HUD_REM.shopBarTop, 0.2 + 0.1 * 2 + 2.24);
|
||||
assert.equal(HUD_REM.shopBarBorderPx, 3);
|
||||
// the bar stays on the viewport's bottom edge on a notched phone (DESIGN §18.1): no bottom inset to add
|
||||
assert.match(read('public/css/devices.css'), /\.gm__hud > \.shopbar \{ bottom: calc\(\.2rem - var\(--sa-b\)\); \}/);
|
||||
});
|
||||
|
||||
test('hudBands: prep / Final Assault prep only; rem-scaled; below the top safe-area inset; clamped', () => {
|
||||
withDom(40, 0, () => {
|
||||
for (const k of ['normal', 'unite', 'boss', 'hidden', 'pen']) assert.equal(hudBands(k, { width: 844, height: 390 }), null, k);
|
||||
for (const k of ['prep', 'bossPrep']) {
|
||||
const b = hudBands(k, { width: 844, height: 390 });
|
||||
assert.ok(Math.abs(b.top - 86.4) < 1e-9 && Math.abs(b.bottom - 108.6) < 1e-9, `${k} ${JSON.stringify(b)}`);
|
||||
}
|
||||
});
|
||||
withDom(100, 0, () => assert.deepEqual(hudBands('prep', { width: 1920, height: 1080 }), { top: 216, bottom: 267 }));
|
||||
withDom(40, 12, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: 390 }).top - 98.4) < 1e-9, 'top inset'));
|
||||
withDom(40, 0, () => assert.deepEqual(hudBands('prep', { width: 640, height: 200 }), { top: 80, bottom: 80 }), 'clamped at 40 % of the height');
|
||||
});
|
||||
|
||||
test('wiring: the game hands hudBands to the view, the view to the prep cameras', () => {
|
||||
assert.match(read('public/js/ui/fieldHost.js'), /padding: hudPadding, hud: hudBands \}/);
|
||||
const app = read('public/js/render/app.js');
|
||||
assert.match(app, /hud: hudBands\(vk, sz\),/);
|
||||
assert.match(app, /presetCamera\('prep', \{ width: s0\.width, height: s0\.height, padding: defaultPadding\('prep', s0\) \}, \{ hud: hudBands\('prep', s0\) \}\)/);
|
||||
});
|
||||
|
||||
// phones in landscape (CSS px) incl. the user's Android: its 2772×1272 px screenshot shows the page right of a 141 px
|
||||
// black cutout band, 2631×1272 px at DPR ≈ 3.48 = 756×366 (798×366 had the page covered the cutout too)
|
||||
const PHONES = [[844, 390], [932, 430], [915, 412], [914, 411], [800, 360], [960, 411], [756, 366], [798, 366], [924, 424]];
|
||||
const stage = JSON.parse(read('data/stages.json')).act2autochess_m01;
|
||||
const H = { h: 0.42, '#': 0.3, X: 0.55, a: 0.16, A: 0.16 }; // render/style.js TILE_H by glyph
|
||||
const heightAt = (r, c) => H[stage.rows[r]?.[c]] ?? 0; // rows[0] = the bottom row (DATA.md)
|
||||
|
||||
/** Screen y extent of the bench (row 7 / boss row 0), temp (8 / 1) and back row (12 / 5) tile tops. */
|
||||
function extents(cam, rows) {
|
||||
const ys = (r, c0, c1) => { const out = []; for (let c = c0; c <= c1; c++) for (const p of tileQuad(cam, r, c, heightAt(r, c))) out.push(p.y); return out; };
|
||||
return { benchBottom: Math.max(...ys(rows.bench, 0, 9)), tempBottom: Math.max(...ys(rows.temp, 4, 8)), backTop: Math.min(...ys(rows.back, 4, 10)) };
|
||||
}
|
||||
|
||||
test('reproduction + fix: at every phone viewport every bench / temp tile ends above the shop bar, the back row below the bond strip', () => {
|
||||
assert.ok(stage, 'stage act2autochess_m01');
|
||||
for (const [w, h] of PHONES) {
|
||||
const rem = remAt(w, h);
|
||||
const shopTop = h - (HUD_REM.shopBarTop * rem + 3);
|
||||
for (const [kind, rows] of [['prep', { bench: 7, temp: 8, back: 12 }], ['bossPrep', { bench: 0, temp: 1, back: 5 }]]) {
|
||||
const hud = withDom(rem, 0, () => hudBands(kind, { width: w, height: h }));
|
||||
const before = extents(presetCamera(kind, { width: w, height: h }), rows);
|
||||
const after = extents(presetCamera(kind, { width: w, height: h }, { hud }), rows);
|
||||
const tag = `${kind} ${w}×${h}`;
|
||||
if (w <= 844) assert.ok(before.benchBottom > shopTop + 5, `${tag}: v2.3 bench under the shop bar (${before.benchBottom} vs ${shopTop})`);
|
||||
assert.ok(after.benchBottom <= shopTop + 1e-6, `${tag}: bench ${after.benchBottom} ≤ shop bar ${shopTop}`);
|
||||
assert.ok(after.tempBottom < after.benchBottom, `${tag}: temp row above the bench`);
|
||||
assert.ok(after.backTop >= hud.top - 1e-6, `${tag}: back row ${after.backTop} ≥ bond strip ${hud.top}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user