fix: 高延迟下连按「刷新」只发送一次请求(GitHub #501)
This commit is contained in:
1 parent
bc50cac01f
commit
289a454fa1
10 files changed
+186
-8
No files matched your search
@@ -59,6 +59,7 @@
|
|||||||
## 3. 招募、整备区与晋升
|
## 3. 招募、整备区与晋升
|
||||||
|
|
||||||
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。
|
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。
|
||||||
|
- **刷新在途不重复**:按下「刷新」后按钮会变暗,直到本次结果返回前,再点按钮或按快捷键都不会再次发送——网络延迟高时防止连点扣掉多次资金(GitHub #501)。
|
||||||
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
|
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
|
||||||
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
||||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||||
|
|||||||
@@ -707,6 +707,7 @@
|
|||||||
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
|
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
|
||||||
"解冻": "Unfreeze",
|
"解冻": "Unfreeze",
|
||||||
"刷新商店 · {key}": "Refresh Shop · {key}",
|
"刷新商店 · {key}": "Refresh Shop · {key}",
|
||||||
|
"正在刷新…": "Refreshing…",
|
||||||
"免费 ×{free}": "Free ×{free}",
|
"免费 ×{free}": "Free ×{free}",
|
||||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||||
"与你在同一战场": "On your field",
|
"与你在同一战场": "On your field",
|
||||||
|
|||||||
@@ -708,6 +708,7 @@
|
|||||||
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
|
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
|
||||||
"解冻": "解除",
|
"解冻": "解除",
|
||||||
"刷新商店 · {key}": "管理センターを更新 · {key}",
|
"刷新商店 · {key}": "管理センターを更新 · {key}",
|
||||||
|
"正在刷新…": "更新中…",
|
||||||
"免费 ×{free}": "無料 ×{free}",
|
"免费 ×{free}": "無料 ×{free}",
|
||||||
"查看 {name} 的战场": "{name}の戦場を見る",
|
"查看 {name} 的战场": "{name}の戦場を見る",
|
||||||
"与你在同一战场": "あなたと同じ戦場",
|
"与你在同一战场": "あなたと同じ戦場",
|
||||||
|
|||||||
@@ -704,6 +704,7 @@
|
|||||||
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
|
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
|
||||||
"解冻": "동결 해제",
|
"解冻": "동결 해제",
|
||||||
"刷新商店 · {key}": "상점 갱신 · {key}",
|
"刷新商店 · {key}": "상점 갱신 · {key}",
|
||||||
|
"正在刷新…": "갱신 중…",
|
||||||
"免费 ×{free}": "무료 ×{free}",
|
"免费 ×{free}": "무료 ×{free}",
|
||||||
"查看 {name} 的战场": "{name}의 전장 보기",
|
"查看 {name} 的战场": "{name}의 전장 보기",
|
||||||
"与你在同一战场": "같은 전장",
|
"与你在同一战场": "같은 전장",
|
||||||
|
|||||||
@@ -706,6 +706,7 @@
|
|||||||
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
|
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
|
||||||
"解冻": "解凍",
|
"解冻": "解凍",
|
||||||
"刷新商店 · {key}": "更新商店 · {key}",
|
"刷新商店 · {key}": "更新商店 · {key}",
|
||||||
|
"正在刷新…": "正在更新…",
|
||||||
"免费 ×{free}": "免費 ×{free}",
|
"免费 ×{free}": "免費 ×{free}",
|
||||||
"查看 {name} 的战场": "查看 {name} 的戰場",
|
"查看 {name} 的战场": "查看 {name} 的戰場",
|
||||||
"与你在同一战场": "與你在同一戰場",
|
"与你在同一战场": "與你在同一戰場",
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
|
|||||||
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
||||||
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||||
import { openGuide } from '../ui/guide.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 { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||||
import { StandInTags } from './game/standInTags.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)
|
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||||
const cardTap = useRef(0);
|
const cardTap = useRef(0);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
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 [rewardMin, setRewardMin] = useState(false);
|
||||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = 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('返回自己')}<//>`}
|
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
|
||||||
</div>` : null}
|
</div>` : 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}
|
barRef=${barRef} offBonds=${offBonds}
|
||||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
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 })}
|
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||||
|
|||||||
@@ -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). */
|
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
||||||
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
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).
|
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
||||||
* @param {string} t
|
* @param {string} t
|
||||||
@@ -55,7 +70,13 @@ export const actions = {
|
|||||||
band: (bandId) => act('g.band', { bandId }),
|
band: (bandId) => act('g.band', { bandId }),
|
||||||
bandSkip: () => act('g.bandSkip'),
|
bandSkip: () => act('g.bandSkip'),
|
||||||
buy: (slot) => act('g.buy', { slot }),
|
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'),
|
freeze: () => act('g.freeze'),
|
||||||
levelUp: () => act('g.levelUp'),
|
levelUp: () => act('g.levelUp'),
|
||||||
sell: (uid) => act('g.sell', { uid }),
|
sell: (uid) => act('g.sell', { uid }),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
|
// 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
|
// 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
|
// 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
|
// 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);
|
// 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
|
// 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.
|
* 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,
|
* 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,
|
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
|
||||||
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
|
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
|
||||||
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
|
* 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 }) {
|
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
|
||||||
const shop = priv?.shop || {};
|
const shop = priv?.shop || {};
|
||||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
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" />`} />
|
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||||||
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
|
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
|
||||||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||||||
<span>${t('刷新')}</span>
|
<span>${t('刷新')}</span>
|
||||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
|
||||||
<kbd>${hk.refresh}</kbd>
|
<kbd>${hk.refresh}</kbd>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user