From 7bea774f012e7854726d1b9166e36e1128e4c691 Mon Sep 17 00:00:00 2001 From: saltfish Date: Mon, 5 Oct 2026 14:20:08 +0800 Subject: [PATCH] =?UTF-8?q?The=20=E8=A7=82=E6=88=98=20and=20=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=20buttons=20send=20the=20typed=20key,=20not=20the=20c?= =?UTF-8?q?lick=20event=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preact hands the click event to onClick handlers, so the 0.1.3 spectate button normalised an element into the key "OBJE" and every spectate request failed. codeArg() takes only a string key and otherwise falls back to the input field; spectate errors now say what went wrong; a recent-key chip fills the field instead of joining at once. Contributed by @salt-fishes. --- public/js/screens/lobby.js | 44 ++++++++++++++++++++++++++++++++------ test/client-static.test.js | 22 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js index 096cc3e7..9e3afb26 100644 --- a/public/js/screens/lobby.js +++ b/public/js/screens/lobby.js @@ -115,6 +115,20 @@ export function normalizeCode(v) { return s.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, ROOM_CODE_LEN); } +/** + * A handler that Preact binds as `onClick=${fn}` receives the click EVENT as its first argument, and a default + * parameter only applies to `undefined` — so `fn(c = code)` would normalise the event target into a nonsense code + * (`String(el)` → `"[object HTMLElement]"` → "OBJE"). Only a string is ever a code; anything else falls back to the + * input field. Returns null when neither yields a well-formed code. + * @param {unknown} arg the argument a handler was called with + * @param {string} field the current input-field value + * @returns {string|null} + */ +export function codeArg(arg, field) { + const k = normalizeCode(typeof arg === 'string' ? arg : field); + return CODE_RE.test(k) ? k : null; +} + /** * Room code from a deep link query string (`?room=CODE`), or null when absent/malformed. * Accepts ROOM_CODE_LEN..ROOM_CODE_LEN+2 alphanumerics (the protocol's join limit). @@ -248,15 +262,30 @@ export function LobbyScreen() { }; const create = () => run('create', () => net.request('room.create', { mode: roomMode, difficulty })); const join = (c = code) => { - const k = normalizeCode(c); - if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; } + // `onClick=${join}` hands the click EVENT as the first argument, and a default parameter only applies to + // `undefined` — codeArg keeps an event target out of the key and falls back to the input field + const k = codeArg(c, code); + if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; } run('join', () => net.request('room.join', { code: k })); }; // a spectator seat: no player seat taken, nothing to do but watch (also a match already running) - const spectate = () => { - const k = normalizeCode(code); - if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; } - run('spectate', () => net.request('room.spectate', { code: k })); + const spectate = (c = code) => { + // same guard as join: `onClick=${spectate}` passes the click event, not a code + const k = codeArg(c, code); + if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; } + run('spectate', () => net.request('room.spectate', { code: k }).catch((err) => { + // Clearer than the bare ERR_TEXT: the usual cause is a code that is not the host's (a remembered one from an + // earlier room, or another machine's) — the server can only answer "no such room". + if (err?.code === ERR.ROOM_NOT_FOUND) { + toast(`没有找到密钥 ${k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)`, 'warn'); + return; + } + if (err?.code === ERR.ALREADY) { + toast('你已经是该同盟的博士:先离开同盟,才能以观战身份进入', 'warn'); + return; + } + throw err; + })); }; const backToTitle = () => { identity.setEntered(false); @@ -305,7 +334,8 @@ export function LobbyScreen() {
${recent.length ? html`最近的同盟 - ${recent.map((c) => html``)}` + ${recent.map((c) => html``)}` : html`向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接`}
diff --git a/test/client-static.test.js b/test/client-static.test.js index 98231b7a..aef24b20 100644 --- a/test/client-static.test.js +++ b/test/client-static.test.js @@ -992,6 +992,28 @@ describe('screen helpers', () => { assert.equal(difficultyInfo('coop', 'BOGUS').rounds, 14); }); + // Regression: `onClick=${handler}` hands Preact's click EVENT as the first argument, and a default parameter only + // applies to `undefined` — so `normalizeCode(eventTarget)` produced "[object HTMLElement]" → "OBJE", and every + // 观战 click sent the same nonsense key no matter what was typed in the field. codeArg() is the guard for that. + test('lobby: a click event is never mistaken for an alliance key (codeArg)', async () => { + const { codeArg, normalizeCode } = await mod('screens/lobby.js'); + // a DOM element stringifies to "[object HTMLElement]" — the shape that caused the bug + const element = { toString: () => '[object HTMLElement]' }; + assert.equal(normalizeCode(element), 'OBJE', 'the old bug: the element normalises into a fake 4-letter code'); + assert.equal(codeArg(element, 'NJBU'), 'NJBU', 'a non-string argument falls back to the input field'); + // plain event-like objects and other non-strings behave the same + for (const weird of [{}, [], 42, true, null, undefined, Symbol('x'), () => {}]) { + assert.equal(codeArg(weird, 'NJBU'), 'NJBU', `falls back for ${String(weird)}`); + } + // an explicit string always wins (the full-room prompt passes the code it asked for) + assert.equal(codeArg('ab cd', 'NJBU'), 'ABCD'); + // no usable code at all + assert.equal(codeArg(element, ''), null); + assert.equal(codeArg(element, 'AB'), null); + assert.equal(codeArg(undefined, undefined), null); + assert.equal(codeArg('ZZZ QQQ', ''), 'ZZZQ', 'a real string is still truncated to ROOM_CODE_LEN'); + }); + test('lobby: battlefield note per difficulty (标准 fixed 战场#01, 险境 8 / 绝境·终极 7 random) matches config.json modes[].stages', async () => { const { difficultyInfo, stageNote, stageLabel, STAGE_POOL } = await mod('screens/lobby.js'); const cfg = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8'));