From 289a454fa1acc48655476d31eacf42b2d074e18f Mon Sep 17 00:00:00 2001 From: robll-v1 <3922295484@qq.com> Date: Sun, 11 Oct 2026 16:43:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=AB=98=E5=BB=B6=E8=BF=9F=E4=B8=8B?= =?UTF-8?q?=E8=BF=9E=E6=8C=89=E3=80=8C=E5=88=B7=E6=96=B0=E3=80=8D=E5=8F=AA?= =?UTF-8?q?=E5=8F=91=E9=80=81=E4=B8=80=E6=AC=A1=E8=AF=B7=E6=B1=82=EF=BC=88?= =?UTF-8?q?GitHub=20#501=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PLAYING.md | 1 + public/i18n/en.json | 1 + public/i18n/ja.json | 1 + public/i18n/ko.json | 1 + public/i18n/zh-TW.json | 1 + public/js/screens/game.js | 8 +++- public/js/ui/gameActions.js | 23 ++++++++- public/js/ui/shopBar.js | 12 +++-- test/ui/refresh-tap.e2e.test.js | 84 +++++++++++++++++++++++++++++++++ test/ui/refresh-tap.test.js | 62 ++++++++++++++++++++++++ 10 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 test/ui/refresh-tap.e2e.test.js create mode 100644 test/ui/refresh-tap.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index c779af26..91828539 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -59,6 +59,7 @@ ## 3. 招募、整备区与晋升 - **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。 +- **刷新在途不重复**:按下「刷新」后按钮会变暗,直到本次结果返回前,再点按钮或按快捷键都不会再次发送——网络延迟高时防止连点扣掉多次资金(GitHub #501)。 - **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。 - **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。 - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。 diff --git a/public/i18n/en.json b/public/i18n/en.json index 1d41ad8b..7d34d07e 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -707,6 +707,7 @@ "冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}", "解冻": "Unfreeze", "刷新商店 · {key}": "Refresh Shop · {key}", + "正在刷新…": "Refreshing…", "免费 ×{free}": "Free ×{free}", "查看 {name} 的战场": "View {name}'s Battlefield", "与你在同一战场": "On your field", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index a2fc1ec2..cc1e8000 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -708,6 +708,7 @@ "冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}", "解冻": "解除", "刷新商店 · {key}": "管理センターを更新 · {key}", + "正在刷新…": "更新中…", "免费 ×{free}": "無料 ×{free}", "查看 {name} 的战场": "{name}の戦場を見る", "与你在同一战场": "あなたと同じ戦場", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index db47842c..db6b1f8c 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -704,6 +704,7 @@ "冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}", "解冻": "동결 해제", "刷新商店 · {key}": "상점 갱신 · {key}", + "正在刷新…": "갱신 중…", "免费 ×{free}": "무료 ×{free}", "查看 {name} 的战场": "{name}의 전장 보기", "与你在同一战场": "같은 전장", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 88ed42d3..4a483c41 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -706,6 +706,7 @@ "冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}", "解冻": "解凍", "刷新商店 · {key}": "更新商店 · {key}", + "正在刷新…": "正在更新…", "免费 ×{free}": "免費 ×{free}", "查看 {name} 的战场": "查看 {name} 的戰場", "与你在同一战场": "與你在同一戰場", diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 165eb512..33b13497 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js'; import { SettingsModal, settingsStore } from '../ui/settings.js'; import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js'; import { openGuide } from '../ui/guide.js'; -import { actions } from '../ui/gameActions.js'; +import { actions, onRefreshBusy } from '../ui/gameActions.js'; import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js'; import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js'; import { StandInTags } from './game/standInTags.js'; @@ -183,6 +183,10 @@ function MatchScreen() { // deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey) const cardTap = useRef(0); const [collapsed, setCollapsed] = useState(false); + // GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js + // single-flights the intent; the R hotkey is dropped by the same guard) + const [refreshBusy, setRefreshBusy] = useState(false); + useEffect(() => onRefreshBusy(setRefreshBusy), []); const [rewardMin, setRewardMin] = useState(false); const [emoteOpen, setEmoteOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); @@ -1468,7 +1472,7 @@ function MatchScreen() { ${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}`} ` : null} - ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} + ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} onCollapse=${setCollapsed} barRef=${barRef} offBonds=${offBonds} onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()} onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })} diff --git a/public/js/ui/gameActions.js b/public/js/ui/gameActions.js index f8e39bed..ee982b77 100644 --- a/public/js/ui/gameActions.js +++ b/public/js/ui/gameActions.js @@ -20,6 +20,21 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh /** Subscribe to the number of in-flight intents (for subtle busy indicators). */ export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); } +// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the +// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh). +// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both +// call actions.refresh) until the reply lands. ui/shopBar.js subscribes to onRefreshBusy for the dim. +let refreshPending = false; +const refreshBusyListeners = new Set(); +const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } }; + +/** + * Subscribe to the shop refresh's in-flight state — fires once with the current state. + * @param {(busy: boolean) => void} fn + * @returns {() => void} unsubscribe + */ +export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); } + /** * Send an intent. Resolves true on `ok`, false on error (already toasted). * @param {string} t @@ -55,7 +70,13 @@ export const actions = { band: (bandId) => act('g.band', { bandId }), bandSkip: () => act('g.bandSkip'), buy: (slot) => act('g.buy', { slot }), - refresh: () => act('g.refresh'), + // a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above + refresh: () => { + if (refreshPending) return Promise.resolve(false); + refreshPending = true; + emitRefreshBusy(); + return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); }); + }, freeze: () => act('g.freeze'), levelUp: () => act('g.levelUp'), sell: (uid) => act('g.sell', { uid }), diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index 9c154819..04211312 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -10,7 +10,9 @@ // pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible // 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the // detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The -// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped +// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows +// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js +// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped // unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by // the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill); // a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an @@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, /** * The bar. - * @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void, + * @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void, * onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void, * onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any, * reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set|null }} props — offBonds: * the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards */ -export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef, +export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef, reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) { const shop = priv?.shop || {}; const slots = Array.isArray(shop.slots) ? shop.slots : []; @@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, <${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} /> ${frozen ? t('解冻') : t('冻结')}${hk.freeze} - diff --git a/test/ui/refresh-tap.e2e.test.js b/test/ui/refresh-tap.e2e.test.js new file mode 100644 index 00000000..0cf3aaff --- /dev/null +++ b/test/ui/refresh-tap.e2e.test.js @@ -0,0 +1,84 @@ +// test/ui/refresh-tap.e2e.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」只发送一次请求. Through the mock +// harness (public/dev/game-mock.html) in headless Chrome, with the mock's reply to g.refresh delayed: the first tap +// sends one request and dims the button, a spam tap and the R key while it is on the wire send nothing, the shop pays +// once, and the next tap after the reply refreshes again. Opt-in: SP_E2E=1 (needs Chrome; CHROME_PATH overrides). +// +// SP_E2E=1 node --test test/ui/refresh-tap.e2e.test.js + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; + +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 BTN = '.shopbar__tools .toolbtn--amber'; + +describe('the shop refresh single-flights (GitHub #501, 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: true, args: ['--no-sandbox'] }); + }); + + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + test('a tap under a lagging shop pays once: the button dims, the extra tap / R key are dropped, the next tap works', async () => { + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 }); + await page.goto(`${base}/dev/game-mock.html?shot=1&phase=PREP&render=fallback`, { waitUntil: 'networkidle0' }); + await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 }); + // the mock answers instantly; give g.refresh a high RTT, and count what the client actually sends (gameActions.js + // looks net.request up at call time; the mock's own request record only sees the request after the delay) + await page.evaluate(async () => { + const { net } = await import('/js/net.js'); + const orig = net.request.bind(net); + globalThis.__REFRESHES__ = 0; + net.request = (t, ...rest) => { + if (t === 'g.refresh') globalThis.__REFRESHES__ += 1; + return new Promise((res, rej) => { + setTimeout(() => orig(t, ...rest).then(res, rej), t === 'g.refresh' ? 700 : 0); + }); + }; + }); + const btnState = () => page.evaluate(() => { + const b = document.querySelector('.shopbar__tools .toolbtn--amber'); + return { disabled: !!b?.disabled, title: b?.title || null }; + }); + const refreshes = () => page.evaluate(() => globalThis.__REFRESHES__); + const funds = () => page.evaluate(() => globalThis.__MOCK__.S().priv.funds); + + assert.equal((await btnState()).disabled, false, 'the 刷新 button starts enabled'); + const n0 = await refreshes(); + const f0 = await funds(); + await page.click(BTN); + await sleep(150); + assert.equal(await refreshes(), n0 + 1, 'the tap sent one g.refresh'); + assert.deepEqual(await btnState(), { disabled: true, title: '正在刷新…' }, 'the button dims and says it is waiting'); + // the spam tap (the disabled button swallows clicks) and the R hotkey while pending: neither reaches the socket + await page.evaluate(() => document.querySelector('.shopbar__tools .toolbtn--amber')?.click()); + await page.keyboard.press('r'); + await sleep(200); + assert.equal(await refreshes(), n0 + 1, 'nothing else was sent'); + await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 }); + const back = await btnState(); + assert.equal(back.disabled, false, 'the button is enabled again after the reply'); + assert.match(back.title, /刷新商店/, 'the tooltip is back to the hotkey hint'); + assert.equal(await funds(), f0 - 1, 'the shop paid exactly one refresh'); + await page.click(BTN); + await sleep(150); + assert.equal(await refreshes(), n0 + 2, 'a refresh after the reply is sent again'); + await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 }); + assert.equal(await funds(), f0 - 2, 'and paid once more'); + await page.close(); + }); +}); diff --git a/test/ui/refresh-tap.test.js b/test/ui/refresh-tap.test.js new file mode 100644 index 00000000..ef84949d --- /dev/null +++ b/test/ui/refresh-tap.test.js @@ -0,0 +1,62 @@ +// test/ui/refresh-tap.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」会重复扣费. The intent layer now +// single-flights it (ui/gameActions.js): a tap while the previous g.refresh is still on the wire is dropped — the +// button dims through onRefreshBusy (game.js → ui/shopBar.js) and the R hotkey (the same actions.refresh) stays inert +// until the reply lands. The browser run of the same case is test/ui/refresh-tap.e2e.test.js (SP_E2E=1). + +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 { net } = await import('../../public/js/net.js'); +const { actions, onRefreshBusy } = await import('../../public/js/ui/gameActions.js'); + +describe('the shop refresh single-flights (GitHub #501)', () => { + test('a refresh while the previous one is on the wire sends nothing; the busy signal brackets the request; a later one sends again', async () => { + const sent = []; + let release; + const prev = net.request; + net.request = (t) => { sent.push(t); return new Promise((r) => { release = () => r({ ok: true }); }); }; + const seen = []; + const off = onRefreshBusy((b) => seen.push(b)); + try { + assert.deepEqual(seen, [false], 'subscribing fires once with the current (idle) state'); + const first = actions.refresh(); + assert.equal(sent.length, 1); + assert.equal(seen.at(-1), true, 'busy from the moment of the send'); + assert.equal(await actions.refresh(), false, 'the duplicate resolves false without waiting'); + assert.equal(sent.length, 1, 'and never reaches the socket'); + release(); + assert.equal(await first, true); + assert.deepEqual(seen, [false, true, false], 'the reply clears the busy state'); + const again = actions.refresh(); + assert.equal(sent.length, 2, 'a refresh after the reply is sent again'); + release(); + assert.equal(await again, true); + assert.deepEqual(seen, [false, true, false, true, false]); + } finally { + off(); + net.request = prev; + } + }); + + test('an unsubscribe stops the updates', () => { + const seen = []; + const off = onRefreshBusy((b) => seen.push(b)); + off(); + assert.deepEqual(seen, [false]); + }); + + test('the wiring: game.js subscribes and hands the state to the bar; the button dims and says what it waits for', () => { + const game = read('public/js/screens/game.js'); + assert.match(game, /onRefreshBusy\(setRefreshBusy\)/, 'the game screen subscribes'); + assert.match(game, /<\$\{ShopBar\}[\s\S]*?refreshBusy=\$\{refreshBusy\}/, 'and the bar receives it'); + const bar = read('public/js/ui/shopBar.js'); + assert.match(bar, /disabled=\$\{!!refReason \|\| refreshBusy\}/, 'busy disables the 刷新 button'); + assert.match(bar, /t\('正在刷新…'\)/, 'and its tooltip says what it is waiting for'); + }); +});