From be9710f31fe0f55f940a602f4c5c1781aaebb29e Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 17:14:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feedback4:=20a=20tap=20on=20the=20board=20s?= =?UTF-8?q?tays=20on=20the=20tile=20under=20the=20finger=20=E2=80=94=20the?= =?UTF-8?q?=20field=20canvas=20is=20a=20click=20target=20of=20its=20own=20?= =?UTF-8?q?(a=20no-op=20click=20listener,=20render/app.js=20onTapTarget),?= =?UTF-8?q?=20so=20the=20browser's=20touch=20adjustment=20no=20longer=20mo?= =?UTF-8?q?ves=20a=20tap=20on=20the=20back=20row=20onto=20a=20bond=20disc:?= =?UTF-8?q?=20PR=20#149's=20=E6=94=B6=E8=B5=B7=20toggle=20pushed=20the=20d?= =?UTF-8?q?iscs=20one=20button=20(=E2=89=88=2055=20px)=20to=20the=20right,?= =?UTF-8?q?=20and=20at=20844=C3=97390=20a=20tap=20on=20the=20row-12=20unit?= =?UTF-8?q?=20opened=20the=20bond=20popup=20instead=20of=20selecting=20it?= =?UTF-8?q?=20(test/render/models.browser.test.js=20#4.1=20touch,=200.1.4?= =?UTF-8?q?=20release=20candidate;=20passes=20with=20#149's=20client=20dif?= =?UTF-8?q?f=20reverted,=20fails=20with=20it);=20DESIGN=20=C2=A718.1=20Tou?= =?UTF-8?q?ch;=20test/ui/playtest2.test.js;=20golden=20unchanged=20(client?= =?UTF-8?q?=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 2 +- public/js/render/app.js | 8 ++++++++ test/ui/playtest2.test.js | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f13fa96b..6162ba33 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -662,7 +662,7 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si - **One rule**: the ground tile under the pointer decides (`render/projection.js pickTile`, raised tops first). Prep presses / clicks / right-click / long-press / drag starts / hover (`app.js pieceAt`) and the enemy preview pen (`penUnitAt`) take the unit standing on that tile (board, bench row 7, temp row 8; `pick.js pickOnTile`). An empty tile selects nothing; a press on a unit's head drawn over the tile behind it is a press on that tile; a raised tile in front hides the near 0.17–0.24 tile of the one behind it, and a press there is on the raised tile — what is drawn. Several units on one tile (the pen's clusters, a piece held for a moment): the nearest to the pointer's point on the tile, ties → the front-most. - **Battle** (`app.js battleUnitAt` → `pickBattle`): an ally on the tile under the pointer — a knocked-out operator lying on its tile (§18.3) included. An enemy within `ENEMY_REACH` 0.6 tile [ASSUMED], using whichever is nearer: its ground position from the pointer's point on the ground, or its drawn body on screen (the upright line from its feet up to its head, in its px per tile) — so a press on a tall enemy's or the boss's torso or head picks it; a flying enemy uses its drawn body only. When an ally tile and an enemy both qualify the nearer (in tiles) wins; ties → the ally, then the front-most. Dead and dying units are not picked (a knocked-out operator waiting on its tile is). - **Drag**: the dragged unit is held under the pointer, mouse and touch alike — its drawn (lifted) feet `DRAG_HOLD_TILES` = 0.45 tile (in its own px per tile) below the pointer [ASSUMED: the v2.1 offset], so the pointer rests on the chibi; an item plate is centred on the pointer. The drop target, its highlight, `tileHover` and the direction wheel's tile are the tile under the pointer. An item dropped on a tile equips the unit on that tile (`ui/gameLogic.js canPlace` / `dropIntent`; an empty tile says 请将装备拖拽至干员身上). A release over DOM covering the canvas (the shop bar) is `'outside'`: the piece goes back. -- **Touch**: the field canvas cancels `touchend` (`app.js onTouchEnd`, non-passive). A tap's compatibility mouse events and click came after `touchend`, hit-tested at the finger again — on the underframe the tap had just opened over the unit's tile (clamped under the top bar for rows 11–12 at 844×390), which pressed 撤退 / 出售. +- **Touch**: the field canvas cancels `touchend` (`app.js onTouchEnd`, non-passive). A tap's compatibility mouse events and click came after `touchend`, hit-tested at the finger again — on the underframe the tap had just opened over the unit's tile (clamped under the top bar for rows 11–12 at 844×390), which pressed 撤退 / 出售. The canvas is also a click target of its own (a no-op `click` listener, `app.js onTapTarget`; 0.1.4): the browser's touch adjustment moves a tap onto a nearby element that responds to clicks (pointer listeners do not count) when the finger's contact area reaches one, and once the 收起 toggle of PR #149 (§10) pushed the bond discs one button to the right, a tap on the row-12 unit at 844×390 opened the bond popup. Holding the finger's point, the canvas wins: a tap on the board stays on its tile. - **Notched phones**: the shop bar keeps its place at the bottom edge instead of rising by the bottom safe-area inset (`css/devices.css .gm__hud > .shopbar`): raised by it the bar covered ~3/4 of bench pads 2–9 at 844×390 with the iPhone insets, and bench pieces are pressed and dropped on by their tile. (The v2.3 figures — 23 % of those tiles free before, 71 % after — are superseded by §19.8: the prep camera now clears the HUD, every bench pad 100 % free on phones.) - Removed: the pixel probe (`drawnAt`, `hoverProbeAt`), body shapes (`pickShape`, `PROBE_MARGIN`, `BODY_H`), `TOUCH_LIFT_TILES`, the drag `dropPoint` hook, `pieceDragOver`, `ui/gameLogic.js pickPieceAt` / `equipRetarget`. `UnitView.bounds()` is a plain body rectangle again (tooltips and `pieceScreenRect` only). - Tests: `test/render/pick.test.js`, `drag.test.js`, `unitview.test.js`, `test/ui/gameLogic.test.js`, `test/ui/playtest4.test.js` (shop bar); browser: `test/render/models.browser.test.js` (every tile of every board, high ground, bench and temp; battle allies and enemies incl. torso and head; the model under the pointer at every drag step; the wheel on the pointer's tile; equipment by tile; phone touch incl. the ghost click and the shop-bar release), `test/render/downelem.browser.test.js` (a knocked-out operator picked on its tile), `test/ui/playtest2.e2e.test.js` (the top-row tile under the bond strip; the notched phone's bench). diff --git a/public/js/render/app.js b/public/js/render/app.js index 83d7b249..0e9aeaa2 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1404,6 +1404,12 @@ export async function createFieldView(host, options = {}) { // selects it and its underframe opens over the tile (clamped under the top bar on a phone), and the click pressed // 撤退 / 出售 (user playtest #4 item 1 on a phone). Cancelling touchend drops them. const onTouchEnd = (e) => { if (e.cancelable) e.preventDefault(); }; + // The canvas is a click target too (a no-op listener). The browser's touch adjustment moves a tap onto a nearby + // element that responds to clicks (click / mousedown listeners, buttons, links; pointer listeners do not count) when the + // finger's contact area reaches one, so a tap on the back row right under the bond strip's discs (row 12 at 844×390 once + // the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the + // unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it. + const onTapTarget = () => {}; canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); @@ -1411,6 +1417,7 @@ export async function createFieldView(host, options = {}) { canvas.addEventListener('pointerleave', onPointerLeave); canvas.addEventListener('contextmenu', onContext); canvas.addEventListener('touchend', onTouchEnd, { passive: false }); + canvas.addEventListener('click', onTapTarget); // ---- battle --------------------------------------------------------------------------------------------- @@ -2006,6 +2013,7 @@ export async function createFieldView(host, options = {}) { canvas.removeEventListener('pointerleave', onPointerLeave); canvas.removeEventListener('contextmenu', onContext); canvas.removeEventListener('touchend', onTouchEnd); + canvas.removeEventListener('click', onTapTarget); app.ticker.remove(frame); app.ticker.remove(preRender); app.ticker.remove(postRender); diff --git a/test/ui/playtest2.test.js b/test/ui/playtest2.test.js index ef9d8c2f..8b5e2f16 100644 --- a/test/ui/playtest2.test.js +++ b/test/ui/playtest2.test.js @@ -119,6 +119,17 @@ describe('8: the detail card never covers the selected unit\'s underframe', () = assert.match(panels, /\.uframe__label\s*\{[^}]*white-space:\s*nowrap\s*;/, 'shortcut labels stay on one line'); }); + // a finger's tap near a disc: the browser's touch adjustment moved it onto the nearest element that responds to clicks + // (the canvas's pointer listeners do not count), so with PR #149's 收起 toggle pushing the discs over the back row a tap + // on the row-12 unit at 844×390 opened the bond popup (test/render/models.browser.test.js #4.1 touch) + test('the field canvas is a click target of its own, so a tap on the board stays on the tile under the finger', async () => { + const { readFileSync } = await import('node:fs'); + const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8'); + assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners'); + assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); + assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events'); + }); + 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); From 5a66f48ad2773fe6599cd315453f4c5d05ad8ea9 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 17:31:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feedback4:=20the=20real-server=20suites'=20?= =?UTF-8?q?long=20"own=20battle=20over"=20waits=20poll=20in=20slices=20und?= =?UTF-8?q?er=20the=20browser's=20CDP=20protocol=20timeout=20(test/e2e/cli?= =?UTF-8?q?ent.mjs=20waitForFunctionLong,=20PROTOCOL=5FTIMEOUT=5FMS=20=3D?= =?UTF-8?q?=20the=20Client's=2090=20s)=20=E2=80=94=20puppeteer=20runs=20a?= =?UTF-8?q?=20page.waitForFunction's=20whole=20poll=20inside=20one=20Runti?= =?UTF-8?q?me.callFunctionOn,=20so=20watch-bonds.e2e=20subtest=203's=20240?= =?UTF-8?q?=20s=20wait=20(line=20489)=20died=20with=20a=20bare=20"Waiting?= =?UTF-8?q?=20failed"=20(cause:=20Runtime.callFunctionOn=20timed=20out)=20?= =?UTF-8?q?whenever=20the=20own=20battle=20at=201=C3=97=20outlasted=2090?= =?UTF-8?q?=20s=20(the=200.1.4=20candidate=20rerun:=20152=20s=20=E2=89=88?= =?UTF-8?q?=2062=20s=20to=20COMBAT=20+=2090=20s;=2065=E2=80=9367=20s=20in?= =?UTF-8?q?=20isolated=20runs);=20reproduced=20at=20the=20same=20line=20wi?= =?UTF-8?q?th=20a=2030=20s=20protocol=20timeout,=20which=20passes=20with?= =?UTF-8?q?=20the=20slices;=20playtest2.real's=20120=20s=20wait=20had=20th?= =?UTF-8?q?e=20same=20gap=20(a=20silent=20'timeout'=20round);=20test/e2e/c?= =?UTF-8?q?lient-wait.test.js=20(slices=20until=20the=20deadline,=20other?= =?UTF-8?q?=20errors=20not=20retried,=20no=20Client=20suite=20with=20a=20p?= =?UTF-8?q?lain=20wait=20as=20long=20as=20the=20protocol=20timeout);=20a?= =?UTF-8?q?=20test-harness=20timing=20flake,=20no=20product=20change;=20go?= =?UTF-8?q?lden=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/e2e/client-wait.test.js | 86 ++++++++++++++++++++++++++++++ test/e2e/client.mjs | 32 ++++++++++- test/ui/playtest2.real.e2e.test.js | 6 +-- test/ui/watch-bonds.e2e.test.js | 7 +-- 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 test/e2e/client-wait.test.js diff --git a/test/e2e/client-wait.test.js b/test/e2e/client-wait.test.js new file mode 100644 index 00000000..09fec6ec --- /dev/null +++ b/test/e2e/client-wait.test.js @@ -0,0 +1,86 @@ +// test/e2e/client.mjs waitForFunctionLong (Node, no browser): a long wait of the real-server browser suites polls in +// slices shorter than the CDP protocol timeout. A plain page.waitForFunction runs its whole poll inside one +// Runtime.callFunctionOn, which the 90 s protocol timeout cut off: watch-bonds.e2e's "own battle over" wait (240 s) failed +// with a bare "Waiting failed" whenever the own battle at 1× lasted longer than 90 s (0.1.4 release candidate). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { ROOT, PROTOCOL_TIMEOUT_MS, waitForFunctionLong } from './client.mjs'; + +const timeoutError = (ms) => Object.assign(new Error(`Waiting failed: ${ms}ms exceeded`), { name: 'TimeoutError' }); + +/** A page whose predicate turns true after `trueAfter` ms; each waitForFunction slice behaves like puppeteer's. */ +function fakePage(trueAfter) { + const t0 = Date.now(); + const calls = []; + return { + calls, + waitForFunction(fn, opts, ...args) { + calls.push({ fn, opts, args }); + const left = t0 + trueAfter - Date.now(); + return new Promise((resolve, reject) => { + if (left <= opts.timeout) setTimeout(() => resolve({ handle: 'ok', args }), Math.max(0, left)); + else setTimeout(() => reject(timeoutError(opts.timeout)), opts.timeout); + }); + }, + }; +} + +test('a wait longer than one slice keeps polling the same predicate until it holds', async () => { + const page = fakePage(95); + const fn = () => true; + const got = await waitForFunctionLong(page, fn, { timeout: 1000, polling: 200, slice: 30 }, 'a', 2); + assert.deepEqual(got, { handle: 'ok', args: ['a', 2] }); + assert.ok(page.calls.length >= 4, `sliced (${page.calls.length} calls)`); + for (const c of page.calls) { + assert.equal(c.fn, fn); + assert.deepEqual(c.args, ['a', 2]); + assert.equal(c.opts.polling, 200); + assert.ok(c.opts.timeout > 0 && c.opts.timeout <= 30, `slice ${c.opts.timeout}`); + } +}); + +test('the overall timeout still ends the wait with a TimeoutError naming it; the last slice is the remainder', async () => { + const page = fakePage(10_000); + const t0 = Date.now(); + await assert.rejects(waitForFunctionLong(page, () => false, { timeout: 70, slice: 30 }), (e) => e.name === 'TimeoutError' && /70ms exceeded/.test(e.message)); + assert.ok(Date.now() - t0 >= 65, 'not before the deadline'); + assert.ok(page.calls.at(-1).opts.timeout <= 30); + assert.ok(page.calls.reduce((n, c) => n + c.opts.timeout, 0) <= 75, 'the slices add up to the timeout'); +}); + +test('any other failure is not retried (a page error, a detached frame, the protocol timeout itself)', async () => { + for (const err of [new Error('Waiting failed'), Object.assign(new Error('boom'), { name: 'ProtocolError' })]) { + let n = 0; + const page = { waitForFunction: () => { n++; return Promise.reject(err); } }; + await assert.rejects(waitForFunctionLong(page, () => true, { timeout: 1000, slice: 30 }), (e) => e === err); + assert.equal(n, 1); + } +}); + +test('the default slice stays well under the protocol timeout every Client launches with', async () => { + const page = fakePage(0); + await waitForFunctionLong(page, () => true, { timeout: 240000 }); + assert.ok(page.calls[0].opts.timeout < PROTOCOL_TIMEOUT_MS / 2, `slice ${page.calls[0].opts.timeout}`); + const client = readFileSync(path.join(ROOT, 'test/e2e/client.mjs'), 'utf8'); + assert.match(client, /protocolTimeout: PROTOCOL_TIMEOUT_MS \}/, 'Client.open launches with the exported constant'); +}); + +test('no Client suite has a plain page.waitForFunction as long as the protocol timeout (use waitForFunctionLong)', () => { + const files = []; + for (const dir of ['test/ui', 'test/e2e']) { + for (const f of readdirSync(path.join(ROOT, dir))) if (/\.(m?js)$/.test(f)) files.push(path.join(dir, f)); + } + const long = []; + for (const f of files) { + const src = readFileSync(path.join(ROOT, f), 'utf8'); + if (!/from '\.\.\/e2e\/client\.mjs'|from '\.\/client\.mjs'/.test(src)) continue; + for (let i = src.indexOf('.waitForFunction('); i >= 0; i = src.indexOf('.waitForFunction(', i + 1)) { + const next = src.indexOf('.waitForFunction(', i + 1); + const m = src.slice(i, next < 0 ? undefined : next).match(/\{ timeout: ([0-9_]+)/); + if (m && Number(m[1].replace(/_/g, '')) >= PROTOCOL_TIMEOUT_MS) long.push(`${f}:${src.slice(0, i).split('\n').length} (${m[1]} ms)`); + } + } + assert.deepEqual(long, []); +}); diff --git a/test/e2e/client.mjs b/test/e2e/client.mjs index d425176f..a3ef4c1d 100644 --- a/test/e2e/client.mjs +++ b/test/e2e/client.mjs @@ -16,6 +16,36 @@ export const hasChrome = () => existsSync(CHROME); const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--mute-audio', '--force-device-scale-factor=1']; +/** The CDP protocol timeout of every Client's browser: one DevTools call that takes longer fails (a hung page fails fast). */ +export const PROTOCOL_TIMEOUT_MS = 90000; + +/** + * `page.waitForFunction` for a wait that may outlast PROTOCOL_TIMEOUT_MS. Puppeteer awaits the whole poll inside ONE + * `Runtime.callFunctionOn`, so a predicate that is still false when the protocol timeout strikes rejects the wait with the + * bare "Waiting failed" (cause: "Runtime.callFunctionOn timed out") long before the wait's own `timeout` — e.g. an own + * battle at 1× that lasts longer than 90 s. This polls the same predicate in slices shorter than the protocol timeout until + * `timeout`: the condition and the deadline stay the same. Resolves to the predicate's JSHandle, rejects with a + * TimeoutError once `timeout` is over and at once with any other error. + * @param {{ waitForFunction: Function }} page + * @param {Function|string} fn + * @param {{ timeout?: number, polling?: number|string, slice?: number }} [opts] + * @param {...any} args passed to `fn` as with page.waitForFunction + */ +export async function waitForFunctionLong(page, fn, { timeout = 30000, polling = 250, slice = PROTOCOL_TIMEOUT_MS / 3 } = {}, ...args) { + const deadline = Date.now() + timeout; + for (;;) { + try { + return await page.waitForFunction(fn, { timeout: Math.max(1, Math.min(slice, deadline - Date.now())), polling }, ...args); + } catch (e) { + if (e?.name !== 'TimeoutError') throw e; + if (Date.now() >= deadline) { + const err = new Error(`Waiting failed: ${timeout}ms exceeded`, { cause: e }); + err.name = 'TimeoutError'; + throw err; + } + } + } +} /** A free TCP port on 127.0.0.1. */ export function freePort() { @@ -96,7 +126,7 @@ export class Client { async open(query = '') { mkdirSync(OUT, { recursive: true }); - this.browser = await this.puppeteer.launch({ executablePath: CHROME, headless: true, args: CHROME_ARGS, protocolTimeout: 90000 }); + this.browser = await this.puppeteer.launch({ executablePath: CHROME, headless: true, args: CHROME_ARGS, protocolTimeout: PROTOCOL_TIMEOUT_MS }); const [first] = await this.browser.pages(); const page = first || await this.browser.newPage(); this.page = page; diff --git a/test/ui/playtest2.real.e2e.test.js b/test/ui/playtest2.real.e2e.test.js index a5c79bf2..377ee9b0 100644 --- a/test/ui/playtest2.real.e2e.test.js +++ b/test/ui/playtest2.real.e2e.test.js @@ -20,7 +20,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import path from 'node:path'; -import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs'; +import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf, waitForFunctionLong } from '../e2e/client.mjs'; const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets')); const ONLY = process.env.SP_P2_REAL || ''; @@ -252,8 +252,8 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000); await sleep(1500); assertNoPen(await battleViewState(c), `R${s0.round} own battle`); - // own battle over while the AI's field still runs - const target = await c.page.waitForFunction(() => { + // own battle over while the AI's field still runs (sliced: longer than the browser's 90 s protocol timeout) + const target = await waitForFunctionLong(c.page, () => { const s = globalThis.__SP__.store.get(); const r = globalThis.__SP_RUNNER__?.state?.(); const pub = s.match.public; diff --git a/test/ui/watch-bonds.e2e.test.js b/test/ui/watch-bonds.e2e.test.js index 3fb40ca9..40ee4225 100644 --- a/test/ui/watch-bonds.e2e.test.js +++ b/test/ui/watch-bonds.e2e.test.js @@ -30,7 +30,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import path from 'node:path'; -import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs'; +import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf, waitForFunctionLong } from '../e2e/client.mjs'; const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets')); const HOST_KIT = ['chess_char_1_10_a', 'chess_char_1_12_a']; // 古米 (坚守), 艾丝黛尔 (萨尔贡) @@ -485,8 +485,9 @@ describe('DESIGN §20.15 — the bond strip shows the watched teammate\'s bonds await sleep(1500); for (const c of [host, guest]) if (!(await c.st()).ready) await c.click('.readybtn'); await host.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000); - // the own battle over while a teammate's still runs - const target = await host.page.waitForFunction(() => { + // the own battle over while a teammate's still runs (at 1× it may last longer than the browser's 90 s protocol + // timeout, which cut a plain page.waitForFunction off with a bare "Waiting failed") + const target = await waitForFunctionLong(host.page, () => { const s = globalThis.__SP__.store.get(); const pub = s.match.public; if (pub?.phase !== 'COMBAT') return { gone: true };