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.
147 lines
7.5 KiB
JavaScript
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>`;
|
|
}
|
|
|