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.
174 lines
12 KiB
JavaScript
174 lines
12 KiB
JavaScript
// Result screen (research 06 §10.6): victory / defeat hero with rounds passed, boss medallions, and a
|
||
// card per player — band, final lineup avatars with elite marks, stats, title (评语) with its icon —
|
||
// plus 返回同盟.
|
||
//
|
||
// Expected m.result (free-form in DESIGN §8.2; fields read tolerantly, see gameLogic.normalizeResult):
|
||
// { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?,
|
||
// players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?,
|
||
// title: 'comment_1' | { id, name? } | null,
|
||
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId],
|
||
// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card
|
||
// draws the operator (gameLogic diyRecordFor) */,
|
||
// standInFor? /* 0.2.0 补位: the replaced operator's charId — the card draws the stand-in
|
||
// with a small 「替补」 mark (gameLogic standInOf; the owner's recall, 2026-10-06) */ }],
|
||
// bonds?: [{ bondId, layers, active }],
|
||
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
|
||
// activatedLayers?, lpLost?, perfectRounds? } }] }
|
||
|
||
import { useEffect } from '../../vendor/hooks.module.js';
|
||
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
|
||
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
|
||
import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js';
|
||
import { data } from '../data.js';
|
||
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
|
||
import { store, useStore, emptyMatch } from '../store.js';
|
||
import { audio } from '../audio.js';
|
||
import { sentText } from '../ui/lang.js';
|
||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||
|
||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||
|
||
// The first seven present are shown (+ remaining LP = a 4×2 grid); the title (评语) stats come first.
|
||
// `gold` is the funds a player SPENT (server/match/player/economy.js spend(); the 挥金如土 title stat).
|
||
const STAT_ROWS = [
|
||
['dmgDealt', N_('造成伤害')], ['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['activatedLayers', N_('盟约层数')],
|
||
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
|
||
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
|
||
];
|
||
|
||
/**
|
||
* The bonds a result card lists (at most 6): the active ones and those holding layers, most layers first. A bond whose
|
||
* layers the game never shows (bonds.json `noStack`: 调和 协防干员 独行 绝技 — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 「部分盟约不会
|
||
* 显示叠加层数,但是叠加层数的特质/策略/装备等效果仍然对其生效」) counts none for the order and carries no number
|
||
* (`hideLayers`), as on the bond strip since 0.1.4 (PR #66) — the card printed them until 0.2.0 (community report
|
||
* 「独行这种盟约是不能叠层的」).
|
||
* @param {any[]} bonds m.result players[].bonds @param {(bondId: string) => any} bondRec bonds.json record lookup
|
||
*/
|
||
export function resultBonds(bonds, bondRec = () => null) {
|
||
const list = (Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string')
|
||
.map((b) => (bondRec(b.bondId)?.noStack ? { ...b, hideLayers: true } : b));
|
||
const shown = (b) => (b.hideLayers ? 0 : b.layers || 0);
|
||
return list.filter((b) => b.active || shown(b) > 0).sort((a, b) => shown(b) - shown(a)).slice(0, 6);
|
||
}
|
||
|
||
/**
|
||
* One unit of a final lineup: a 自选 piece draws its operator; a 补位 piece (`standInFor`) its stand-in, with the small
|
||
* 「替补」 mark — the title names the chess it fielded for.
|
||
*/
|
||
export function LineupThumb({ u, gd }) {
|
||
const chess = u.kind === 'token' ? null : gd.chess(u.id);
|
||
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
|
||
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
|
||
return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
|
||
title=${si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : undefined} />`;
|
||
}
|
||
|
||
function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||
const gd = useGameData();
|
||
const titleRec = p.title ? titles.find((x) => x.id === p.title.id) || null : null;
|
||
// (the server sends the title's Chinese name: the localized config.titles record when it is that record's)
|
||
const rawTitle = p.title ? (data.getRaw('config')?.titles || []).find((x) => x && x.id === p.title.id) || null : null;
|
||
const titleName = sentText(p.title?.name, rawTitle?.name, titleRec?.name) || titleRec?.name || null;
|
||
const stats = STAT_ROWS.filter(([k]) => Number.isFinite(p.stats[k])).slice(0, 7);
|
||
const band = p.bandId ? gd.band(p.bandId) : null;
|
||
const lineup = p.lineup.slice(0, 10);
|
||
const bonds = resultBonds(p.bonds, (id) => gd.bond(id));
|
||
return html`<article class=${cx('rcard', p.playerId === myId && 'is-self', p.alive === false && 'is-dead')}>
|
||
<header class="rcard__head">
|
||
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
|
||
<div class="rcard__who">
|
||
<b class="rcard__name">${p.name}${p.isBot ? html`<span class="rcard__ai">AI</span>` : null}${p.playerId === myId ? html`<span class="rcard__you">${t('你')}</span>` : null}</b>
|
||
<span class="rcard__band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : '—'}</span>
|
||
</div>
|
||
<div class="rcard__mid">
|
||
<div class="rcard__lineup">
|
||
${lineup.length ? lineup.map((u, i) => html`<${LineupThumb} key=${i} u=${u} gd=${gd} />`)
|
||
: html`<span class="rcard__noinfo">${p.alive === false ? t('阵容已撤离') : 'NO INFO'}</span>`}
|
||
</div>
|
||
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
|
||
<${BondGlyph} bondId=${b.bondId} />${b.hideLayers ? null : html`<b class="num">${b.layers ?? 0}</b>`}</span>`)}</div>` : null}
|
||
</div>
|
||
<div class="rcard__round"><${MicroLabel}>ROUNDS</${MicroLabel}><b class="num">${p.roundsPassed}</b>
|
||
${p.trophies > 0 || p.reward > 0 ? html`<span class="rcard__gain">${p.trophies > 0 ? html`<span title=${t('获得奖杯')}><${Icon} name="crown" /><b class="num">+${p.trophies}</b></span>` : null}${p.reward > 0 ? html`<span title=${t('卫戍认证')}><${Icon} name="shield" /><b class="num">+${p.reward}</b></span>` : null}</span>` : null}
|
||
</div>
|
||
${titleName ? html`<div class="rcard__title" title=${titleRec?.text || p.title?.text || ''}>
|
||
<${Img} src=${titleIconUrl(gd.m, titleRec?.picId || p.title?.picId || String(p.title.id || '').replace('comment_', 'comment_icon_'))} class="rcard__ticon" fallback=${html`<${Icon} name="crown" />`} />
|
||
<span><${MicroLabel} tone="gold">${t('评语')}</${MicroLabel}><b>${titleName}</b></span>
|
||
</div>` : html`<span class="rcard__title rcard__title--none" aria-hidden="true"></span>`}
|
||
</header>
|
||
<div class="rcard__stats">
|
||
${stats.map(([k, label]) => html`<div key=${k} class=${cx('rstat', best[k] === p.playerId && 'is-best')}><span>${t(label)}</span><b class="num">${fmtNum(p.stats[k])}</b></div>`)}
|
||
${Number.isFinite(p.lp) ? html`<div class="rstat" title=${p.lpShared && !solo ? t('最终攻势起全队共享目标生命值') : ''}><span>${p.lpShared && !solo ? t('同盟剩余生命') : t('剩余生命')}</span><${LpTower} value=${p.lp} size="sm" /></div>` : null}
|
||
</div>
|
||
</article>`;
|
||
}
|
||
|
||
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
|
||
export function ResultScreen() {
|
||
const res = useStore((s) => s.match.result);
|
||
const pub = useStore((s) => s.match.public);
|
||
const myId = useStore((s) => s.me.playerId);
|
||
const hasRoom = useStore((s) => !!s.room);
|
||
const back = () => store.set({ match: emptyMatch() });
|
||
return html`<${ResultView} res=${res} pub=${pub} myId=${myId} backLabel=${hasRoom ? t('返回同盟') : t('返回大厅')} onBack=${back} />`;
|
||
}
|
||
|
||
/**
|
||
* The settlement view of one m.result payload — the live screen above, and the stats page's re-view of a stored match
|
||
* (screens/stats.js, which hands it the payload recordToResult rebuilt; `quiet`: no settlement jingle).
|
||
* @param {{ res: any, pub?: any, myId?: string|null, backLabel: string, onBack: () => void, quiet?: boolean }} props
|
||
*/
|
||
export function ResultView({ res, pub = null, myId = null, backLabel, onBack, quiet = false }) {
|
||
const gd = useGameData();
|
||
const r = normalizeResult(res, pub);
|
||
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
|
||
const best = {};
|
||
for (const [k] of STAT_ROWS) {
|
||
let top = null;
|
||
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
|
||
if (top && r.players.length > 1) best[k] = top.id;
|
||
}
|
||
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
||
const boss = r.bossId ? gd.boss(r.bossId) : null;
|
||
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
|
||
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
|
||
const bg = uiUrl(gd.m, r.victory ? 'settle/settlemen_teamshow_success' : 'settle/settlemen_teamshow_fail');
|
||
const mins = r.durationMs ? Math.round(r.durationMs / 60000) : null;
|
||
|
||
return html`<div class=${cx('screen', 'result', r.victory ? 'is-win' : 'is-lose')}>
|
||
<div class="result__bg" aria-hidden="true" style=${bg ? `--result-bg:url("${bg}")` : undefined}></div>
|
||
<div class="result__grid" aria-hidden="true"></div>
|
||
<main class="result__main">
|
||
<section class="result__hero">
|
||
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
|
||
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
|
||
<h1 class="result__headline">${r.victory ? t('模拟完成') : t('模拟失败')}</h1>
|
||
<p class="result__sub">${r.victory ? t('成功卫戍 · 敌方领袖已被击败') : t('防线已被突破')}</p>
|
||
<div class="result__rounds">
|
||
<span class="result__rlabel">${t('通过回合')}</span>
|
||
<b class="result__rnum num">${r.roundsPassed}</b>
|
||
${r.roundsPassed <= r.lastRound ? html`<span class="result__rof num">/${r.lastRound}</span>` : null}
|
||
</div>
|
||
<div class="result__medals">
|
||
${boss ? html`<div class=${cx('medal', r.victory && 'is-done')} title=${boss.name}>
|
||
<${Img} src=${enemyIconUrl(gd.m, boss.enemyKey)} /><span class="medal__check">${r.victory ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
|
||
<span class="medal__label">${t('敌方领袖')}</span></div>` : null}
|
||
${hidden ? html`<div class=${cx('medal', 'medal--hidden', r.hiddenCleared && 'is-done')} title=${hidden.name}>
|
||
<${Img} src=${enemyIconUrl(gd.m, hidden.enemyKey)} /><span class="medal__check">${r.hiddenCleared ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
|
||
<span class="medal__label">${t('隐秘核心')}</span></div>` : null}
|
||
</div>
|
||
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
||
<footer class="result__foot">
|
||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
|
||
</footer>
|
||
</section>
|
||
<section class="result__players">
|
||
<h2 class="brief-h"><span>${t('同盟成员')}</span><${MicroLabel}>ALLIANCE REPORT</${MicroLabel}></h2>
|
||
${r.players.length ? r.players.map((p) => html`<${PlayerCard} key=${p.playerId} p=${p} myId=${myId} titles=${titles} best=${best} solo=${r.players.length < 2} />`)
|
||
: html`<p class="t-dim">${t('暂无结算数据')}</p>`}
|
||
</section>
|
||
</main>
|
||
</div>`;
|
||
}
|
||
|