From 7d97b80bfd68cc14a8ff355b75720b2fb4f7dadc Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 11:03:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feedback5=20R19B:=20the=20round-start=20ban?= =?UTF-8?q?ner=20reads=20=E8=A7=82=E6=88=98=E4=B8=AD=20to=20an=20eliminate?= =?UTF-8?q?d=20player=20and=20to=20a=20spectator=20seat=20instead=20of=20?= =?UTF-8?q?=E8=B5=84=E9=87=91=E5=B7=B2=E5=88=B0=E8=B4=A6=20=E2=80=94=20the?= =?UTF-8?q?=20server=20pays=20round=20income=20only=20to=20the=20players?= =?UTF-8?q?=20still=20in=20(startRound=20runs=20ps.startRound=20for=20aliv?= =?UTF-8?q?ePlayers(),=20eliminate()=20zeroes=20funds=20and=20pendingFunds?= =?UTF-8?q?);=20phaseBanner=20takes=20the=20viewer=20{=20alive,=20spectato?= =?UTF-8?q?r=20}=20from=20the=20match=20screen=20(a=20spectator=20seat=20h?= =?UTF-8?q?as=20no=20row=20in=20m.public);=20the=20idea=20from=20PR=20#237?= =?UTF-8?q?=20(not=20merged:=20it=20sits=20on=20the=20#189=20/=20#191=20st?= =?UTF-8?q?ack);=20GitHub=20#236;=20golden:=20unchanged=20(client=20text?= =?UTF-8?q?=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/history/0.2.0.md | 17 +++++++++++++++++ public/js/screens/game.js | 2 +- public/js/ui/gameLogic/phases.js | 15 ++++++++++++--- test/ui/gameLogic.test.js | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5b9f3d..7afbc4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ - 被淘汰后观战(以及观战席)会一直跟着上次查看的玩家,每回合开始不再跳回自己的空棋盘;没查看过时跟随第一位仍在场的玩家,人机也算(社区反馈;思路来自 PR #189,感谢 @2321Robin)。 - 观战领袖回合的休整期时,看到的是领袖战场和站在那里的领袖,不再是没有领袖的普通棋盘(社区反馈)。 - 最终攻势和隐秘核心的休整期,像作战时一样把同组两名玩家放在同一个战场上(队友的一半只能看),从回合开始到作战结束,左侧队伍栏用官方的绿框标出同组两人(社区反馈;参考了 PR #191,感谢 @2321Robin)。 +- 被淘汰后(以及观战席)每回合开始的横幅改为显示「观战中」,不再显示「资金已到账」:回合资金只发给仍在场的玩家(GitHub #236;思路来自 PR #237,感谢 @2321Robin)。 ### 人机 diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index 58e95a4b..0db943f6 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -1856,3 +1856,20 @@ are. intents too (+3 layers, ATK, block). Two NORMAL copies of one item merge (the official rule), so two 空弦 need a normal and an 进阶 set; and the pair counts once toward the 3 members. - Pinned: `test/content/feedback5-cb1-pins.test.js`. + +#### 25.22.2 GitHub #236 「被淘汰后每回合开始横幅仍显示「资金已到账」(观战席同理)」 (@2321Robin, with PR #237): the round-start banner reads 观战中 to an eliminated player and a spectator seat — `ui/gameLogic/phases.js phaseBanner`, `screens/game.js` + +- **Seen** (a real Match with fake battles: three humans and a spectator seat, p_0 out of LP at round 1's settlement): + at the round-2 start p_0 (funds 0, pending 0) and the spectator seat (no seat, no funds) got 「第 2 回合 · 资金已到账」, + the same as p_1 (funds 5). The sub had been fixed text since 0.1.0. +- **Official**: no rule for the banner's text. The money: round income goes only to the players still in. + `startRound` runs `ps.startRound` (income + pending coins) for `alivePlayers()` only, and `eliminate()` zeroes + `funds` / `pendingFunds` (server/match/match/phases.js, player/round.js). An eliminated player keeps watching + (research 09: KEEP_WATCH). +- **Now**: `phaseBanner(phase, pub, { alive, spectator })`. The match screen passes its `alive` and `spectator`. + `alive` is false for an eliminated player and for a spectator seat. A spectator seat has no row in m.public, so `pub` + alone cannot tell. For those viewers ROUND_START's sub is 「观战中」 (en "Spectating", the combat HUD's spectator + caption). A player still in keeps 「资金已到账」. The title and the other phases do not change. PR #237 was read for the + idea only: its fourth `alive` argument sits on top of the #189 / #191 stack and Match.js changes, so it was not merged. +- Golden: unchanged (client text only). Tests: `test/ui/gameLogic.test.js` +1 (a player still in, an eliminated + player, a spectator seat, the screen's call). It fails on the parent: 「资金已到账」 where 「观战中」 is expected. diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 3065e4ef..520493f0 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -576,7 +576,7 @@ function MatchScreen() { const prev = prevPhase.current; prevPhase.current = phase; if (prev === phase) return; - const b = phaseBanner(phase, pub); + const b = phaseBanner(phase, pub, { alive, spectator }); if (b) setBanner({ ...b, key: phaseKey }); if (phase === PHASE.ROUND_START) audio.sfx('roundStart'); else if (phase === PHASE.PREP) audio.sfx('rest', { volume: 0.7 }); diff --git a/public/js/ui/gameLogic/phases.js b/public/js/ui/gameLogic/phases.js index f7afe13d..ddedfaab 100644 --- a/public/js/ui/gameLogic/phases.js +++ b/public/js/ui/gameLogic/phases.js @@ -40,12 +40,21 @@ export const isCombatPhase = (phase) => COMBAT_PHASES.has(phase); export const showDeadPill = (alive, phase) => !alive && !COMBAT_PHASES.has(phase) && phase !== PHASE.SETTLE; export const isBossPhase = (phase) => phase === PHASE.FINAL_ASSAULT || phase === PHASE.HIDDEN_CORE; -/** Banner shown when a phase starts: { title, sub?, tone } or null. */ -export function phaseBanner(phase, pub) { +/** + * Banner shown when a phase starts: { title, sub?, tone } or null. + * @param {string|null|undefined} phase + * @param {any} pub m.public + * @param {{ alive?: boolean, spectator?: boolean }} [viewer] the local viewer (screens/game.js): `alive` false for an + * eliminated player, `spectator` for a spectator seat — it has no row in m.public, so `pub` cannot tell + */ +export function phaseBanner(phase, pub, { alive = true, spectator = false } = {}) { const r = int(pub?.round, 0); switch (phase) { case PHASE.BATTLE_CHECK: return { title: t('协议启动'), micro: 'PROTOCOL START', tone: 'mint', sub: t('模拟即将开始'), duration: 2600 }; - case PHASE.ROUND_START: return { title: t('第 {r} 回合', { r }), micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', sub: t('资金已到账') }; + // round income goes to the seats still in only (server/match/match/phases.js startRound; eliminate() zeroes funds + // and pendingFunds): an eliminated player or a spectator seat reads 观战中, never 资金已到账 (GitHub #236, PR #237) + case PHASE.ROUND_START: return { title: t('第 {r} 回合', { r }), micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', + sub: spectator || alive === false ? t('观战中') : t('资金已到账') }; case PHASE.SP_DRAFT: return { title: t('机变阶段'), micro: 'CONTINGENCY', tone: 'gold', sub: t('依次选择机变') }; case PHASE.PREP: return { title: t('休整期'), micro: `ROUND ${String(r).padStart(2, '0')} // REST`, tone: 'mint', sub: t('部署干员,准备迎敌') }; case PHASE.COMBAT: return { title: t('作战开始'), micro: 'COMBAT', tone: 'orange', sub: t('各自行动阶段') }; diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js index 3d65c8ad..997ace61 100644 --- a/test/ui/gameLogic.test.js +++ b/test/ui/gameLogic.test.js @@ -72,6 +72,29 @@ describe('phases', () => { assert.equal(phaseBanner(PHASE.SETTLE, {}), null); assert.equal(prepCapsuleLabel(PHASE.PREP), '休息一下'); }); + test('the round-start banner says 资金已到账 to a player still in only — 观战中 to an eliminated player and a spectator seat (GitHub #236)', () => { + // round 2 after p_0 ran out of LP: p_1 is still in; the spectator seat s_spec has no row in m.public + const pub = { round: 2, phase: PHASE.ROUND_START, players: [ + { playerId: 'p_0', seat: 0, alive: false }, { playerId: 'p_1', seat: 1, alive: true }, + ] }; + const round = (viewer) => phaseBanner(PHASE.ROUND_START, pub, viewer); + // a player still in (the server paid the round's income) + assert.equal(round({ alive: true, spectator: false }).sub, '资金已到账'); + assert.equal(round().sub, '资金已到账', 'no viewer given: the copy of a player still in'); + // an eliminated player (eliminate() zeroed funds and pendingFunds; startRound pays the seats still in only) + assert.equal(round({ alive: false, spectator: false }).sub, '观战中'); + // a spectator seat (no seat, no funds), also when only `spectator` is given + assert.equal(round({ alive: false, spectator: true }).sub, '观战中'); + assert.equal(round({ spectator: true }).sub, '观战中'); + // the title and the other phases' copy are the same for every viewer + for (const v of [{ alive: true }, { alive: false }, { alive: false, spectator: true }]) { + assert.equal(round(v).title, '第 2 回合'); + assert.equal(phaseBanner(PHASE.PREP, pub, v).sub, phaseBanner(PHASE.PREP, pub).sub); + } + // the match screen hands the banner its viewer (screens/game.js `alive` folds a spectator seat in) + const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + assert.match(src, /phaseBanner\(phase, pub, \{ alive, spectator \}\)/); + }); }); describe('countdown', () => { From b4d5a72a43fe9590020cd2ae6a50540adc60e37b Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 11:13:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feedback5=20R19B:=20=E5=B9=B2=E5=91=98?= =?UTF-8?q?=E8=B0=83=E9=85=8D=20says=20plainly=20when=20its=20game=20data?= =?UTF-8?q?=20did=20not=20load=20=E2=80=94=20useData=20counts=20a=20/data?= =?UTF-8?q?=20file=20that=20never=20arrived=20(404,=20blocked,=20bad=20JSO?= =?UTF-8?q?N:=20'missing')=20as=20settled,=20so=20the=20tab=20drew=20an=20?= =?UTF-8?q?empty=20roster,=20the=20loadout=20import=20blamed=20the=20file?= =?UTF-8?q?=20(=E3=80=8C=E8=BF=99=E4=BB=BD=E6=95=B0=E6=8D=AE=E5=9C=A8?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=89=88=E6=9C=AC=E6=B2=A1=E6=9C=89=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E8=B0=83=E9=85=8D=E3=80=8D)=20and=20the=20?= =?UTF-8?q?=E5=B9=B2=E5=91=98=E6=8C=81=E6=9C=89=20/=20=E8=87=AA=E9=80=89?= =?UTF-8?q?=E7=BC=96=E9=98=9F=20imports=20stored=20an=20empty=20list=20ove?= =?UTF-8?q?r=20the=20saved=20one;=20now=20missingGameData(tab)=20(chess.js?= =?UTF-8?q?on,=20plus=20backups.json=20for=20=E8=87=AA=E9=80=89=E7=BC=96?= =?UTF-8?q?=E9=98=9F)=20shows=20DataMissing=20in=20place=20of=20the=20rost?= =?UTF-8?q?er=20(which=20files,=20saved=20settings=20unaffected,=20reload,?= =?UTF-8?q?=20then=20the=20ad=20blocker=20and=20the=20network)=20and=20imp?= =?UTF-8?q?ortRefusal(kind,=20ready)=20refuses=20every=20import=20before?= =?UTF-8?q?=20anything=20is=20parsed=20or=20applied,=20with=20the=20same?= =?UTF-8?q?=20words;=20three=20UI=20strings=20with=20English;=20GitHub=20#?= =?UTF-8?q?173;=20golden:=20unchanged=20(client=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/history/0.2.0.md | 29 ++++++ public/css/screens/loadout.css | 4 + public/i18n/en.json | 3 + public/js/screens/loadout.js | 49 ++++++++- .../ui/feedback5-loadout-data-missing.test.js | 99 +++++++++++++++++++ 6 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 test/ui/feedback5-loadout-data-missing.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7afbc4e8..797879a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ - 持续的范围特效(如炼金单元)叠在一起时不再越叠越亮(社区反馈)。 - 结算卡不再显示调和、协防干员、独行、绝技的层数:这几个盟约照常累计层数,官方也不显示(社区反馈)。 - 银灰的普通攻击使用自己的命中音效,不再借用三技能的挥砍和命中音效;雷蛇、初雪、圣约送葬人的普通攻击也不再用技能的命中音效(社区反馈)。 +- 游戏数据没能下载时,「干员调配」不再显示空的干员列表,导入也不再提示「这份数据在当前版本没有可用的调配」:现在会直接写明游戏数据没有载入、缺的是哪个文件,并提示刷新页面、检查广告拦截插件和网络。这时三个标签页都不执行导入,已保存的干员持有和自选编队也不会再被清空(GitHub #173,感谢 @sarilendorf)。 ### 联机与大厅 diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index 0db943f6..6c3c00e4 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -1873,3 +1873,32 @@ are. idea only: its fourth `alive` argument sits on top of the #189 / #191 stack and Match.js changes, so it was not merged. - Golden: unchanged (client text only). Tests: `test/ui/gameLogic.test.js` +1 (a player still in, an eliminated player, a spectator seat, the screen's call). It fails on the parent: 「资金已到账」 where 「观战中」 is expected. + +#### 25.22.3 GitHub #173 「bug:网页版游戏文件未加载」 (@sarilendorf): a 干员调配 tab without its game data says so, and every import is refused — `screens/loadout.js missingGameData / importRefusal / DataMissing` + +- **Seen**: on the reporter's own server (Node 24) the operators, bonds, maps and enemies did not load. Servers other + people host worked, and a loadout exported there failed to import locally. The screenshots, as described in the + thread: 干员调配 empty, the import toast 「导入失败:这份数据在当前版本没有可用的调配,未做任何改动」, the `npm start` log + with every check passed, an ad blocker showing one block. Reproduced with every /data/*.json request answering 404. All five files of the screen end + `missing`, the screen counts them as loaded, the roster is empty (「没有符合条件的干员」) and the loadout import gives the + reporter's toast. Two imports did worse: 干员持有 said 「已导入:全部持有」 and stored [] over the saved list, and + 自选编队 said 「已导入:全部名额为空」 and stored {} over the saved picks. With the files served, the same three imports + work. 0.1.3 and 0.1.4 behave the same. +- **Cause**: `useData` (public/js/data.js:489–501) counts a file that never arrived (status `missing`) as settled, so + the UI can carry on without optional files. In screens/loadout.js `ready` (:399) was then true. The tab drew its empty + roster, and the import guard `if (!ready)` (:479) let the import run. It exists to stop an import before chess.json + loads, because such an import would sanitise every entry away. The loadout import kept the loadout but blamed the data + (:501). `applyOwnershipImport` / `applyDiyImport` (ui/loadoutSync.js:93, :113) stored the empty result. The server sync + was already safe: the loadout sync sends nothing without chess.json, and the other two send the stored lists. +- **Official**: no rule involved; the page reads its game data from the server it was opened from (/data/*.json). +- **Now**: `missingGameData(tab)` names the files a tab cannot work without: chess.json, plus backups.json for 自选编队. + When one is `missing`, the tab shows `DataMissing` in place of the roster: 「游戏数据没有载入」, the file paths (e.g. + /data/chess.json), that the saved settings are unaffected, and what to try (reload; then the ad blocker and the + network). `importRefusal(kind, ready)` is the one guard of every import. It refuses while the files load (as before) and + when the data is missing (「导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络」), + before anything is parsed or applied. Three new UI strings, with English. +- [ASSUMED]: the cause on the reporter's machine is still unknown: the ad blocker, a proxy or security software, or a + different address. The new message and the console line `[data] /data/ unavailable ()` are what to ask for. +- Golden: unchanged (client only). Tests: `test/ui/feedback5-loadout-data-missing.test.js` (4, all fail on the parent: + the tab's files, the refusals, the screen's guard order, the message in Chinese and English); loadout, loadout-stats, + module-trait, moduleIcons, diy-ui and standin-ui tests pass unchanged. diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index 9ea7323f..b497800d 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -73,6 +73,10 @@ .lo-note.is-locked .icon { color: var(--amber); } .lo-loading { flex: 1; display: flex; align-items: center; justify-content: center; gap: .14rem; color: var(--text-lo); font-size: max(.18rem, 12px); } +/* the tab's game data did not load (GitHub #173): said in place of the roster */ +.lo-missing { flex-direction: column; gap: .1rem; padding: .3rem; text-align: center; color: var(--amber); } +.lo-missing .icon { width: max(.32rem, 22px); height: max(.32rem, 22px); } +.lo-missing p { margin: 0; max-width: 9rem; color: var(--text-lo); font-size: max(.16rem, 11px); line-height: 1.6; text-wrap: balance; } /* ---- body layout -------------------------------------------------------------------------------------------------- */ .lo-body { diff --git a/public/i18n/en.json b/public/i18n/en.json index 487f74e7..0064ac1d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -295,6 +295,9 @@ "读取文件失败": "Failed to read the file", "干员数据仍在载入,请稍候再导入": "Operator data is still loading, please wait before importing", "导入失败:这份数据在当前版本没有可用的调配,未做任何改动": "Import failed: this data has no loadout usable in the current version. Nothing was changed", + "导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络": "Import failed: the game data did not load. Nothing was changed. Reload the page; if that does not help, check your ad blocker and your network", + "游戏数据没有载入": "The game data did not load", + "{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} could not be downloaded, so this page cannot be shown. Your saved settings are unaffected. Reload the page; if that does not help, check your ad blocker and your network.", "确认本局信息阶段结束前可调整本局配置": "You can adjust this match's loadout until the match briefing ends", "开始模拟前可调整干员携带的技能与模组,干员等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry; Operator levels cannot be changed", "返回 (Esc)": "Back (Esc)", diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index 62994ab5..c98ab8f6 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -24,7 +24,7 @@ import { Img, RichText, UnitThumb } from '../ui/gameComponents.js'; import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js'; import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js'; import { chessLoadout } from '../ui/gameLogic.js'; -import { data, useData, localAsset } from '../data.js'; +import { data, useData, localAsset, DATA_FILES } from '../data.js'; import { useStore } from '../store.js'; import { PHASE } from '../../../shared/constants.js'; import { @@ -394,6 +394,43 @@ const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: [N_('下一局生效'), 'is-warn'] /** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */ const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT }; +/** + * The game-data files a tab cannot work without — its roster and its import's sanitiser: chess.json, plus backups.json + * for 自选编队 (its slots and the picks' forms) — that did not load. useData counts a file that never arrived (a 404, a + * blocked request, bad JSON: status 'missing') as settled like a loaded one, so the tab drew an empty roster and an + * import sanitised every entry away (GitHub #173). Empty while the files load and once they have. + * @param {string} tab 'loadout' | 'ownership' | 'diy' + * @param {(name: string) => string} [status] the data store's status (data.status) + * @returns {string[]} data file names + */ +export function missingGameData(tab, status = data.status) { + return (tab === 'diy' ? ['chess', 'backups'] : ['chess']).filter((name) => status(name) === 'missing'); +} + +/** + * Why an import of `kind` cannot run now, as its toast — the files still loading, or the game data missing — or null. + * Either way nothing is applied: sanitised against absent data, the 干员持有 / 自选编队 imports stored an empty list over + * the saved one (GitHub #173). + * @param {string} kind the dialog's tab @param {boolean} ready the screen's files are settled + * @param {(name: string) => string} [status] + * @returns {{ text: string, tone: 'warn'|'error' } | null} + */ +export function importRefusal(kind, ready, status = data.status) { + if (!ready) return { text: t('干员数据仍在载入,请稍候再导入'), tone: 'warn' }; + if (missingGameData(kind, status).length) return { text: t('导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络'), tone: 'error' }; + return null; +} + +/** In place of a tab whose game data did not load (missingGameData): which files, and what to try (GitHub #173). */ +export function DataMissing({ files }) { + const urls = files.map((name) => `/data/${DATA_FILES[name] || `${name}.json`}`); + return html``; +} + /** The overlay screen. */ function LoadoutScreen({ st }) { const ready = useData('chess', 'bonds', 'assets', 'local', 'backups'); @@ -422,6 +459,7 @@ function LoadoutScreen({ st }) { const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout'; + const lost = ready ? missingGameData(tab) : []; const ownRoster = useOwnershipRoster(ready); const nNotOwned = notOwnedCount(st.notOwned, ownRoster); // 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome) @@ -475,8 +513,10 @@ function LoadoutScreen({ st }) { try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast(t('读取文件失败'), 'error'); } }; const ioApply = () => { - // an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout - if (!ready) { toast(t('干员数据仍在载入,请稍候再导入'), 'warn'); return; } + // an import before chess.json is loaded, or when it never arrived, would sanitise every entry away — refuse instead + // of wiping the loadout + const refused = importRefusal(io.kind, ready); + if (refused) { toast(refused.text, refused.tone); return; } if (ioDiy) { const r = parseDiyImport(ioText); if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; } @@ -588,7 +628,8 @@ function LoadoutScreen({ st }) { : tab === 'ownership' ? html`

<${Icon} name="info" />${ownText}

` : html`

<${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}

`} - ${!ready ? html`
<${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}
` : tab === 'diy' + ${!ready ? html`
<${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}
` + : lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy' ? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />` : tab === 'ownership' ? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />` diff --git a/test/ui/feedback5-loadout-data-missing.test.js b/test/ui/feedback5-loadout-data-missing.test.js new file mode 100644 index 00000000..b2a52dc6 --- /dev/null +++ b/test/ui/feedback5-loadout-data-missing.test.js @@ -0,0 +1,99 @@ +// GitHub #173 「网页版游戏文件未加载」: the page runs but its game data never arrives (every /data/*.json request fails — +// a blocked request, a proxy, a server without its data folder). useData counts a file that failed ('missing') as +// settled like a loaded one, so 干员调配 drew an empty roster (「没有符合条件的干员」) and the 导入 of a loadout said the data +// had no loadout usable in this version; the 干员持有 / 自选编队 imports, sanitised against the absent data, stored an +// empty list over the saved one. Now the tab says that the game data did not load (which files, what to try) and every +// import is refused with the same words, before anything is parsed or applied — the saved settings stay as they are. +import { test } 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)), '../..'); +// the reporter's case: every data request of the page fails (the screen reads the browser data store singleton) +globalThis.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) }); +const warn = console.warn; +console.warn = (...a) => { if (!String(a[0]).startsWith('[data]')) warn(...a); }; + +const { data } = await import('../../public/js/data.js'); +const { missingGameData, importRefusal, DataMissing } = await import('../../public/js/screens/loadout.js'); +const { setLang, setMessages } = await import('../../shared/i18n.js'); + +const TABS = ['loadout', 'ownership', 'diy']; +const statusOf = (map, dflt = 'ready') => (name) => map[name] ?? dflt; +const NOT_LOADED = '导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络'; +const LOADING = '干员数据仍在载入,请稍候再导入'; + +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + yield* walk(v.props?.children); +} +const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])) + .filter((x) => typeof x === 'string').map((s) => s.trim()).filter(Boolean).join(' | '); + +test('a data file that never arrived is settled like a loaded one: the tab names the files it cannot work without', async () => { + await data.loadAll('chess', 'bonds', 'assets', 'local', 'backups'); // the screen's useData(...) files + for (const name of ['chess', 'bonds', 'assets', 'local', 'backups']) assert.equal(data.status(name), 'missing', name); + // (default: the browser store) chess.json for every tab, backups.json too for 自选编队 (its slots and picks' forms) + assert.deepEqual(missingGameData('loadout'), ['chess']); + assert.deepEqual(missingGameData('ownership'), ['chess']); + assert.deepEqual(missingGameData('diy'), ['chess', 'backups']); + for (const tab of TABS) { + assert.deepEqual(missingGameData(tab, statusOf({})), [], `${tab}: loaded`); + assert.deepEqual(missingGameData(tab, statusOf({}, 'loading')), [], `${tab}: still loading (the spinner, not this message)`); + } + // only the art / bond files missing: every tab still works + for (const tab of TABS) assert.deepEqual(missingGameData(tab, statusOf({ bonds: 'missing', assets: 'missing', local: 'missing' })), []); + const noBackups = statusOf({ backups: 'missing' }); + assert.deepEqual(TABS.map((tab) => missingGameData(tab, noBackups)), [[], [], ['backups']]); +}); + +test('every import is refused while the files load or when the game data did not load — no other reason', () => { + const missing = statusOf({ chess: 'missing', backups: 'missing' }); + for (const kind of TABS) { + assert.deepEqual(importRefusal(kind, false, statusOf({})), { text: LOADING, tone: 'warn' }, `${kind}: loading`); + assert.deepEqual(importRefusal(kind, true, missing), { text: NOT_LOADED, tone: 'error' }, `${kind}: not loaded`); + assert.deepEqual(importRefusal(kind, true), { text: NOT_LOADED, tone: 'error' }, `${kind}: the browser store`); + assert.equal(importRefusal(kind, true, statusOf({})), null, `${kind}: loaded`); + } + const noBackups = statusOf({ backups: 'missing' }); + assert.equal(importRefusal('loadout', true, noBackups), null); + assert.equal(importRefusal('ownership', true, noBackups), null); + assert.deepEqual(importRefusal('diy', true, noBackups), { text: NOT_LOADED, tone: 'error' }); +}); + +test('the screen: the import asks importRefusal before parsing or applying anything; a tab without its data shows DataMissing', () => { + const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8'); + const at = src.indexOf('const ioApply = () => {'); + assert.ok(at > 0, 'ioApply'); + const body = src.slice(at, src.indexOf('\n };\n', at)); + const guard = body.indexOf('const refused = importRefusal(io.kind, ready);'); + assert.ok(guard > 0, 'the guard'); + assert.match(body.slice(guard), /^const refused = importRefusal\(io\.kind, ready\);\s*if \(refused\) \{ toast\(refused\.text, refused\.tone\); return; \}/); + for (const call of ['parseDiyImport(', 'applyDiyImport(', 'parseOwnershipImport(', 'applyOwnershipImport(', 'parseImport(', 'applyLoadoutEntries(']) { + assert.ok(body.indexOf(call) > guard, `${call} only after the guard`); + } + assert.ok(!/if \(!ready\)/.test(body), 'one guard for both cases'); + assert.match(src, /const lost = ready \? missingGameData\(tab\) : \[\];/); + assert.match(src, /: lost\.length \? html`<\$\{DataMissing\} files=\$\{lost\} \/>` : tab === 'diy'/); +}); + +test('the message names the files and what to try, in Chinese and in English', () => { + const one = DataMissing({ files: ['chess'] }); + assert.equal(one.props.role, 'alert'); + assert.equal(textOf(one), '游戏数据没有载入 | /data/chess.json 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。'); + assert.match(textOf(DataMissing({ files: ['chess', 'backups'] })), /\/data\/chess\.json、\/data\/backups\.json 没有下载成功/); + const en = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8')); + setMessages('en', en); + setLang('en'); + try { + assert.equal(textOf(DataMissing({ files: ['chess', 'backups'] })), 'The game data did not load | /data/chess.json, /data/backups.json could not be downloaded, ' + + 'so this page cannot be shown. Your saved settings are unaffected. Reload the page; if that does not help, check your ad blocker and your network.'); + assert.equal(importRefusal('loadout', true).text, 'Import failed: the game data did not load. Nothing was changed. Reload the page; if that does not help, check your ad blocker and your network'); + } finally { + setLang('zh'); + } +});