Files
ark-stronghold-protocol/public/js/ui/matchChrome.js
T
sganggs 51e0e60b6e feedback7 stats: a match left right away stays in the history but does not count; bounded storage, corrupt-data handling, the page reworked as 干员调配-style tabs, the settlement re-view without the app store (PR #323 by @2321Robin, owner's decision of 2026-10-08); golden: unchanged
Counting rule (ui/stats.js countsTowardStats, the owner's decision of 2026-10-08: do not count the matches entered and left
right away, but keep them in the history): a settled match (m.result arrived) always counts; a match given up (放弃模拟)
counts once at least one of the player's battles had finished — a round cleared (the server's own rule, platform.js
_quit: leaving while round k runs has passed k - 1, in its settlement k) or already eliminated. Anything earlier is
history only: marked 中途退出 and 不计入统计, dimmed, listed in 对局记录, left out of every aggregate (games, wins, win
rate, strategy table, titles, rounds, time, combat totals); 总览 says how many were left out.
- A leaver gets no m.result, so the page records the match itself: quitMatch (放弃模拟) → recordQuit builds an
  `end: 'quit'` record from the app state and a tracker (observeMatch, installed in main.js) that remembers when the page
  first saw the match and the last round the player was seen alive in. No record for a spectator seat, a match with its
  settlement already there, a match the page never saw; matches the server ended and closed tabs are not recorded.

Defects of PR #323 as submitted, reproduced (scratchpad/w7-stats-notes/repro-pr-bugs.mjs, loads-check*.mjs) and fixed:
- the title screen loaded all 16 game data files (5.3 MB) at boot: <StatsHost/> called useGameData() at mount; now the
  host renders nothing until opened, the overview reads config / bands / assets and the settlement re-view loads the rest
  when opened (the title screen loads the same 3 files as before);
- importing a file put the list oldest first (the page shows the first 20 as the newest and the cap would drop the
  newest); the list is newest first everywhere, also when loaded from storage;
- recordResult wrote over the envelope of a NEWER build (a downgrade, against the PR's own rule); it refuses now, as
  saveStats does;
- 1000 four-player records are ~7.2 M characters (the PR's ~1.5 kB/record is a solo match) against a ~2.5 M-character
  quota: MAX_RECORDS 1000 stays, plus a 800 000-character budget (the oldest go first), and only the newest 30 records
  keep their rows (lineup, bonds, teammates' names; the replay needs them) — older ones are compacted to the player's own
  summary row (~620 characters), a refused write retries with half the history; unknown fields and oversized values are
  dropped (a strict schema), import files are bounded (8 MB);
- the re-view clobbered the app store (match.public / match.result, the route, the music); ResultScreen is split into
  ResultScreen (the store) and ResultView (props: result, myId, back label), so the stats page renders a stored match
  itself; a 自选 pick and a 补位 stand-in were dropped from the stored lineup (the replayed card drew the plain
  operator); DIFFICULTY_NAMES were not translated; a band cell was a display:flex <td>; the overlay stayed open when a
  co-op match started; the 清空 two-step blink was replaced by the shared confirm dialog.
- Corrupt storage: unreadable data is moved to sp.pref.stats.damaged and the page says so; damaged rows are dropped and
  counted; duplicates, an oversized or unsorted list are repaired on load; a pre-`end` (PR-era) envelope reads as settled.

UI: a full-screen overlay in the grammar of 干员调配 (top bar with 返回 / four tabs 总览 · 策略 · 战斗累计 · 对局记录 /
导出 导入 清空, a note line saying the data stays in this browser and that teammates' names are stored too), text sizes
with max(.xxrem, N px) for phones in landscape, a 50-row history page with 显示更多, a pass-rate bar, a per-difficulty
table in order; the entries use a new `chart` icon (not the ping bars), the title screen's sits beside the language menu
(the connection row has no room: it squeezed the gear and wrapped the status text), the lobby / room buttons show the
icon alone below 700 px (the room's title ran under the label at 640 px). docs/PLAYING.md §12, README row; 41 + 25 UI
strings in en / ja / ko / zh-TW (the PR's four unused ones removed).

Tests: test/ui/stats.test.js 44 / 44 (counting rule per phase, quit records, bounds, corrupt / old / newer storage,
import order, aggregation, the replay payload); test/ui/stats.e2e.test.js (opt-in SP_E2E=1, real server, headless Chrome:
seeded history on 1920×1080 and 844×390, the re-view, export / import / clear, corrupt and newer storage, and the real
flows: a solo match given up in the briefing is history only, one given up after round 1 counts, a settled match is
recorded once and not again after a reload) 6 / 6; lint 0 errors, i18n extract 0 / check --all --strict clean,
check:imports, typecheck, docs and version tests, golden unchanged.
2026-10-08 20:36:41 +08:00

147 lines
7.5 KiB
JavaScript

// Chrome shared by every in-match screen: the step header of the pre-game screens (exit + ping +
// difficulty | "1/2 确认本局信息" | countdown) and the exit flow (confirm with 暂离 / 放弃, AI 托管 overlay).
//
// 暂离 (co-op): turns on AI 托管 (g.autoplay on) so the seat keeps playing; the overlay's 返回模拟 turns it
// off. 放弃模拟: g.leave then room.leave (the platform treats it as a quit), back to the lobby.
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } from './components.js';
import { actions } from './gameActions.js';
import { toastError } from './toasts.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js';
import { recordQuit } from './stats.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
/**
* Exit dialog lines. 放弃模拟 (g.leave → room.leave) ends the run on the server as 'abandoned' and returns to the
* lobby straight away — no settlement screen follows, so the solo text must not promise one.
*/
export const EXIT_TEXT = Object.freeze({
soloRest: N_('独立模拟的休整期没有时间限制,你可以随时继续。'),
soloQuit: N_('放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。'),
});
/** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */
export const awayStore = createStore({ away: false });
/**
* Whether a store change ends the match the 暂离 flag belongs to: its state was cleared (result → 返回同盟, 放弃模拟, a
* new match starting, the room closing, a new server session) or its settlement arrived. The next match must never
* open under the previous one's "AI 托管中" overlay (the server starts every match with autoplay off).
* @param {any} s store state
* @param {any} prev previous store state
*/
export const awayEnds = (s, prev) => (!!prev?.match?.public && !s?.match?.public) || (!!s?.match?.result && !prev?.match?.result);
store.subscribe((s, prev) => { if (awayStore.get().away && awayEnds(s, prev)) awayStore.set({ away: false }); });
/**
* Leave the match for good (g.leave → room.leave), then drop local room/match state. The server sends a leaver no
* settlement, so the page records the match itself before it is cleared (本机统计, ui/stats.js recordQuit: a 中途退出
* entry of the history; it counts only when a round had been cleared) — once the leave went through.
* @returns {Promise<void>}
*/
export async function quitMatch() {
const before = store.get();
let left = false;
try {
try { await net.request('g.leave', {}); } catch (err) {
if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'WRONG_PHASE' && err?.code !== 'OFFLINE') throw err;
}
try { await net.request('room.leave', {}); } catch (err) { if (err?.code !== 'NOT_IN_ROOM' && err?.code !== 'OFFLINE') throw err; }
left = true;
} catch (err) {
toastError(err);
} finally {
// (a settlement that arrived while the leave was in flight is the match's record already)
if (left && !store.get().match.result) {
try { recordQuit(before); } catch (err) { console.warn('[stats] quit record failed', err); }
}
store.set({ room: null, match: emptyMatch() });
}
}
/**
* Exit confirmation modal.
* @param {{ open: boolean, onClose: Function, solo: boolean, onAway?: Function }} props
*/
export function ExitModal({ open, onClose, solo, onAway }) {
const [busy, setBusy] = useState(null);
// a spectator seat (community report #26) only leaves its seat: nothing of the match is given up
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
const quit = async () => {
setBusy('quit');
await quitMatch();
setBusy(null);
onClose();
};
if (spectator) {
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title=${t('离开观战')} micro="LEAVE SPECTATING" width="6.8rem"
actions=${html`<${Button} variant="secondary" onClick=${onClose}>${t('取消')}<//>
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>${t('离开观战')}<//>`}>
<div class="exitm"><p>${t('离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。')}</p></div>
<//>`;
}
const away = async () => {
setBusy('away');
const ok = await actions.autoplay(true);
setBusy(null);
onClose();
if (ok) { awayStore.set({ away: true }); onAway?.(); }
};
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title=${t('离开模拟')} micro="LEAVE SIMULATION" width="6.8rem"
actions=${html`
<${Button} variant="secondary" onClick=${onClose}>${t('取消')}<//>
${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>${t('暂离(AI 托管)')}<//>` : null}
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>${t('放弃模拟')}<//>`}>
<div class="exitm">
${solo
? html`<p>${t(EXIT_TEXT.soloRest)}</p><p class="t-lo">${t(EXIT_TEXT.soloQuit)}</p>`
: html`<p>${tParts('{away}:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。', { away: html`<b class="t-ice">${t('暂离')}</b>` })}</p>
<p>${tParts('{quit}:离开后无法返回本局,你的干员将回到共享卡池。', { quit: html`<b class="t-red">${t('放弃模拟')}</b>` })}</p>`}
</div>
<//>`;
}
/** Full-screen "AI 托管中" overlay with 返回模拟. */
export function AwayOverlay({ onBack = () => {} }) {
const [busy, setBusy] = useState(false);
return html`<div class="awayov" role="dialog" aria-label=${t('AI 托管中')}>
<div class="awayov__box brackets">
<${GIcon} name="robot" class="awayov__icon" />
<${MicroLabel} tone="mint">${t('AUTOPILOT // AI 托管')}</${MicroLabel}>
<h2>${t('AI 托管中')}</h2>
<p class="t-lo">${t('AI 正在代为操作你的席位')}</p>
<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${async () => { setBusy(true); const ok = await actions.autoplay(false); setBusy(false); if (ok) { awayStore.set({ away: false }); onBack(); } }}>${t('返回模拟')}<//>
</div>
</div>`;
}
/**
* Pre-game step header.
* @param {{ step: number, of: number, title: string, micro: string, pub: any, total?: number|null, onExit: Function }} props
*/
export function StepHeader({ step, of, title, micro, pub, total, onExit }) {
const conn = useStore((s) => s.connection, shallowEqual);
return html`<header class="stephead">
<div class="stephead__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开')} />
<div class="stephead__meta">
<${PingPill} ms=${conn.ping} online=${conn.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null}
</div>
</div>
<div class="stephead__center">
<${MicroLabel} tone="mint">${micro}</${MicroLabel}>
<h1 class="stephead__title"><b class="num">${step}</b><span class="num stephead__of">/${of}</span>${title}</h1>
</div>
<div class="stephead__right">
<${GuideButton} class="stephead__guide" variant="secondary" />
<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />
</div>
</header>`;
}