// 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`
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
${p.name}${p.isBot ? html`AI` : null}${p.playerId === myId ? html`${t('你')}` : null} ${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : '—'}
${lineup.length ? lineup.map((u, i) => html`<${LineupThumb} key=${i} u=${u} gd=${gd} />`) : html`${p.alive === false ? t('阵容已撤离') : 'NO INFO'}`}
${bonds.length ? html`
${bonds.map((b) => html` <${BondGlyph} bondId=${b.bondId} />${b.hideLayers ? null : html`${b.layers ?? 0}`}`)}
` : null}
<${MicroLabel}>ROUNDS${p.roundsPassed} ${p.trophies > 0 || p.reward > 0 ? html`${p.trophies > 0 ? html`<${Icon} name="crown" />+${p.trophies}` : null}${p.reward > 0 ? html`<${Icon} name="shield" />+${p.reward}` : null}` : null}
${titleName ? html`
<${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" />`} /> <${MicroLabel} tone="gold">${t('评语')}${titleName}
` : html``}
${stats.map(([k, label]) => html`
${t(label)}${fmtNum(p.stats[k])}
`)} ${Number.isFinite(p.lp) ? html`
${p.lpShared && !solo ? t('同盟剩余生命') : t('剩余生命')}<${LpTower} value=${p.lp} size="sm" />
` : null}
`; } /** 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`
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}

${r.victory ? t('模拟完成') : t('模拟失败')}

${r.victory ? t('成功卫戍 · 敌方领袖已被击败') : t('防线已被突破')}

${t('通过回合')} ${r.roundsPassed} ${r.roundsPassed <= r.lastRound ? html`/${r.lastRound}` : null}
${boss ? html`
<${Img} src=${enemyIconUrl(gd.m, boss.enemyKey)} />${r.victory ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`} ${t('敌方领袖')}
` : null} ${hidden ? html`
<${Img} src=${enemyIconUrl(gd.m, hidden.enemyKey)} />${r.hiddenCleared ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`} ${t('隐秘核心')}
` : null}
${mins ? html`

${tParts('本局耗时 {n} 分钟', { n: html`${mins}`, mins })}

` : null}
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}

${t('同盟成员')}<${MicroLabel}>ALLIANCE REPORT

${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`

${t('暂无结算数据')}

`}
`; }