feedback5: merge R19C — GitHub #235: the official 「作战结束」 result box (全员无伤! / 生命值减少 −N) and the three battle-over sounds at settlement, PR #112 by @Convey123 ported onto the 0.2.0 layout (test/ui/gameLogic.test.js: both streams' tests kept); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 11:31:55 +08:00
commit 93a45f733f
21 files changed
+705 -14

No files matched your search

+111
View File
@@ -730,6 +730,117 @@
@keyframes pb-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes pb-sweep { 0% { left: -30%; opacity: 1; } 100% { left: 120%; opacity: 0; } }
/* ---- result box (ResultDialog: the round's 作战结束 dialog; GitHub #235, PR #112) ---------------------------------- */
/* A centred framed plate in the tone's colour — four corner ticks, chevrons either side of the title, one light sweep.
Under the phase banner (z-index) so a phase change and a result never fight over the same pixels; pointer-events:
none — the settlement stays clickable. */
.rdialog {
--rd: var(--mint-500);
--rd-glow: rgba(23, 249, 183, .35);
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-banner) - 1);
pointer-events: none;
}
.rdialog--orange { --rd: var(--orange-hi); --rd-glow: rgba(255, 122, 51, .38); }
.rdialog--red { --rd: var(--red-premium); --rd-glow: rgba(255, 84, 84, .38); }
.rdialog__box {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: .1rem;
min-width: 5.6rem;
max-width: 88vw;
padding: .34rem .5rem .3rem;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .022) 0 1px, transparent 1px 3px),
linear-gradient(180deg, rgba(9, 13, 12, .96), rgba(6, 9, 8, .97));
border: 1px solid var(--rd);
box-shadow: 0 0 .5rem var(--rd-glow), inset 0 0 .7rem rgba(0, 0, 0, .8), 0 .1rem .5rem rgba(0, 0, 0, .6);
overflow: hidden;
animation: rd-open 320ms var(--ease-out) backwards;
}
.rdialog__box::before, .rdialog__box::after {
/* hairlines inside the frame, so the plate reads as a plate and not a card */
content: '';
position: absolute;
left: 6%;
right: 6%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--rd) 20%, var(--rd) 80%, transparent);
opacity: .8;
}
.rdialog__box::before { top: 4px; }
.rdialog__box::after { bottom: 4px; }
.rdialog__tick { position: absolute; width: .16rem; height: .16rem; border: 2px solid var(--rd); opacity: .9; }
.rdialog__tick--tl { top: 8px; left: 8px; border-right: 0; border-bottom: 0; }
.rdialog__tick--tr { top: 8px; right: 8px; border-left: 0; border-bottom: 0; }
.rdialog__tick--bl { bottom: 8px; left: 8px; border-right: 0; border-top: 0; }
.rdialog__tick--br { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
.rdialog__micro {
font-family: var(--font-display);
font-size: .13rem;
letter-spacing: .48em;
padding-left: .48em;
color: var(--rd);
animation: rd-wipe 300ms 60ms var(--ease-out) backwards;
}
.rdialog__row {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: .22rem;
animation: rd-wipe 340ms 110ms var(--ease-out) backwards;
}
.rdialog__row::after {
/* one-shot light sweep across the title */
content: '';
position: absolute;
top: -30%;
bottom: -30%;
left: -25%;
width: 18%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .26), transparent);
transform: skewX(-20deg);
opacity: 0;
animation: rd-sweep 780ms 300ms var(--ease-out) forwards;
}
.rdialog__title {
font-size: .62rem;
font-weight: 900;
letter-spacing: .14em;
padding-left: .14em;
line-height: 1.05;
color: #fff;
text-shadow: 0 0 .26rem var(--rd-glow), 0 2px 0 rgba(0, 0, 0, .55);
}
.rdialog__chev { --ch-h: .46rem; height: .46rem; flex: none; }
.rdialog__sub {
font-size: .18rem;
letter-spacing: .1em;
color: var(--text-lo);
text-align: center;
animation: rd-wipe 340ms 180ms var(--ease-out) backwards;
}
.rdialog.is-leaving .rdialog__box { animation: rd-close 260ms var(--ease-in-out) forwards; }
@keyframes rd-open { from { transform: scale(.9, .78); opacity: 0; } }
@keyframes rd-close { to { transform: scale(.96, .86); opacity: 0; } }
@keyframes rd-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes rd-sweep { 0% { left: -25%; opacity: 1; } 100% { left: 115%; opacity: 0; } }
@media (max-width: 720px) {
.rdialog__box { min-width: 0; width: 92vw; padding: .26rem .3rem .22rem; }
.rdialog__title { font-size: .42rem; }
.rdialog__chev { --ch-h: .34rem; height: .34rem; }
}
@media (prefers-reduced-motion: reduce) {
.rdialog__box, .rdialog__row, .rdialog__row::after, .rdialog__micro, .rdialog__sub { animation: none; }
}
/* ---- ping pill ------------------------------------------------------------------------------------- */
.ping {
--pc: var(--mint-400);
+1
View File
@@ -6,6 +6,7 @@
URL params: ?phase=PREP|INFO_CHECK|BAND_DRAFT|BATTLE_CHECK|SP_DRAFT|COMBAT|UNITE|SETTLE|FINAL_ASSAULT|HIDDEN_CORE|RESULT
&variant=reward|bounty|supply|shop|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|leaker (联防: you let enemies through — the ×N counter)
|unite[,through][,helper|,dead] (SETTLE: the result box after a 联防 — your charge from m.public.uniteResult)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
|funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
+14
View File
@@ -394,6 +394,20 @@ function startCombat(phase) {
pub.unite = leakMock ? { helpers: ['ai_2', 'p3'], leakers: ['p1', 'p4'] } : { helpers: ['p1', 'ai_2'], leakers: ['p3'] };
if (leakMock) { leftOf('p1', 13); leftOf('p4', 3); pub.players[0].status = 'done'; pub.players[2].status = 'helping'; } else leftOf('p3', 12);
}
// the result box after a 联防 (server settle → m.public.uniteResult, ui/gameLogic/phases.js uniteResultBox; GitHub #235):
// ?phase=SETTLE&variant=unite — you (p1) leaked and the helpers stopped every enemy: nobody paid → 全员无伤!;
// `unite,through` — 3 got through and you were charged 3 → 生命值减少 −3; `unite,through,helper` — you HELPED, a
// teammate leaked and paid → the official title alone (全员无伤! would be false for that teammate); `unite,dead` — you
// were eliminated before the round, so `losses` (alive players only, like the server) does not list you → no box.
// Without `unite` the SETTLE view has no uniteResult: the round's own battle result box takes over (battleResultBox).
if (phase === PHASE.SETTLE && VARIANTS.has('unite')) {
const helpers = ['ai_2', 'p3'], leakers = ['p1', 'p4'];
pub.uniteResult = VARIANTS.has('through')
? { through: 3, helpers, leakers, losses: { p1: 3, p4: 0, ai_2: 0, p3: 0 } }
: { through: 0, helpers, leakers, losses: { p1: 0, p4: 0, ai_2: 0, p3: 0 } };
if (VARIANTS.has('helper')) pub.uniteResult = { through: 3, helpers: ['p1', 'ai_2'], leakers: ['p3'], losses: { p1: 0, ai_2: 0, p3: 4, p4: 0 } };
if (VARIANTS.has('dead')) delete pub.uniteResult.losses.p1;
}
const countKill = () => {
if (phase !== PHASE.UNITE) return;
const pid = leakMock ? 'p1' : 'p3';
+2
View File
@@ -397,6 +397,8 @@
"队友作战进度": "Teammate battle progress",
"作战结束": "Battle over",
"作战结束,等待队友完成作战": "Battle over. Waiting for teammates to finish",
"全员无伤!": "All unharmed!",
"生命值减少 −{n}": "LP reduced by {n}",
"你已被淘汰,正在观战": "You have been eliminated. Spectating",
"上一个战场": "Previous battlefield",
"下一个战场": "Next battlefield",
+39 -2
View File
@@ -6,7 +6,8 @@
// with the DOM HUD layered on top: top bar, bond strip + popup, team panel (click = g.watch), effects,
// shop bar (prep), merge-reward and 机变 overlays, detail panel, enemy / match-info drawer, combat HUD
// (DP, "作战结束,等待队友完成作战" + teammates' progress, observing pill, 联防 / 最终攻势 ‹ › camera halves),
// phase banners, ticker, emote wheel + bubbles,
// phase banners, the round's result box at SETTLE (官方「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 and the BATTLEOVER sound;
// GitHub #235, PR #112), ticker, emote wheel + bubbles,
// settings, exit flow. Drag & drop from the view: pieceDrop on a board tile (also the piece's own tile) opens the
// direction wheel (ui/facingWheel.js, research 09 §1.2) and its release sends g.move {uid, to, dir} (g.art {…, dir}
// for 画卷); pieceDrop on a bench slot → g.move; items → g.equip (confirm when replacing) / g.art. Equipment
@@ -65,7 +66,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js';
import { html, Spinner, PhaseBanner, Icon, Button, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js';
import { html, Spinner, PhaseBanner, ResultDialog, Icon, Button, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js';
import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js';
@@ -93,6 +94,7 @@ import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
terrainInfo,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
@@ -179,6 +181,7 @@ function MatchScreen() {
const [holdSeq, setHoldSeq] = useState(0); // bumped when a held piece is released (re-apply the prep state)
const [hud, setHud] = useState(null);
const [banner, setBanner] = useState(null);
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
@@ -233,6 +236,23 @@ function MatchScreen() {
uniteLeft: leaker ? uniteRemaining(localLeft, meP?.uniteLeft) : null,
});
lpBaseRef.current = liveLpNow.base;
// what this round's battle cost me, kept while it runs for the result box and the 战斗结束 sound at SETTLE (GitHub
// #235, PR #112 by @Convey123): by then the settlement has landed (Match.flush sends m.private before the SETTLE
// m.public) and the live count reads 0. A per-round MAXIMUM — leaks only grow within a round, and a frame that lands
// after the settlement must not wipe a real count — and only for a player in the round (a spectator seat or an
// eliminated player fights nothing and gets no box, ui/gameLogic/phases.js battleResultBox). `unite`: a 联防 ran.
const roundLossRef = useRef(null);
if (isCombatPhase(phase) && alive) {
const prev = roundLossRef.current;
const same = !!prev && prev.round === pub?.round;
const leaks = ownLeaks(localLeaks, meP?.pendingLp);
roundLossRef.current = {
round: pub?.round,
leaks: same ? Math.max(prev.leaks, leaks) : leaks,
cap: gd.config?.lpCapPerRound,
unite: phase === PHASE.UNITE || (same && prev.unite),
};
}
// latest values for event handlers bound once
const live = useRef({});
@@ -572,6 +592,7 @@ function MatchScreen() {
// phase changes: banners, sounds, resets
const phaseKey = `${phase}:${pub?.round}`;
const prevPhase = useRef(null);
const resultSeq = useRef(0); // re-keys the result box (ResultDialog) at every SETTLE
useEffect(() => {
const prev = prevPhase.current;
prevPhase.current = phase;
@@ -585,6 +606,19 @@ function MatchScreen() {
else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam');
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
else if (phase === PHASE.SETTLE) {
// the official 战斗结束 sound and result box (GitHub #235, PR #112 by @Convey123; ui/gameLogic/phases.js): the LP this
// round cost ME — after a 联防 the authority's own charge (m.public.uniteResult.losses: a leaker pays for the
// survivors, not for its own battle's leaks), else the own battle's as kept while it ran (roundLossRef)
const kept = roundLossRef.current;
roundLossRef.current = null;
const cost = kept && kept.round === pub?.round ? kept : null;
const sfx = battleOverSfx(cost, pub?.uniteResult?.losses?.[myId]);
if (sfx) audio.sfx(sfx);
const box = uniteResultBox(pub?.uniteResult, myId) || battleResultBox(cost);
resultSeq.current += 1;
if (box) setResultBox({ ...box, key: `result:${pub?.round}:${resultSeq.current}` });
}
// the server resets every watcher to its own field on phase changes — but an eliminated player / spectator seat
// keeps the prep board it follows from 回合开始 through 机变 and 休整期 (Match._followScout re-points it only at the
// round start): resetting here would fly the camera home and back at every prep phase (item 56)
@@ -1373,6 +1407,9 @@ function MatchScreen() {
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
${resultBox ? html`<${ResultDialog} key=${resultBox.key} title=${resultBox.title} sub=${resultBox.sub} micro=${resultBox.micro}
tone=${resultBox.tone} duration=${resultBox.duration} onDone=${() => setResultBox(null)} />` : null}
${facing && view ? html`<${FacingWheel} key=${`${facing.uid}:${facing.row},${facing.col}`} view=${view} row=${facing.row} col=${facing.col}
grid=${facing.grid} name=${facing.name} onPreview=${previewFacing} onCommit=${commitFacing} onCancel=${cancelFacing} />` : null}
+34 -1
View File
@@ -2,7 +2,7 @@
//
// Exports: html (bound htm), Icon, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip,
// BondDisc, SevenSeg, Countdown, Modal, confirmDialog/alertDialog + DialogHost, Tooltip +
// TooltipLayer, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, PingPill, DifficultyTag,
// TooltipLayer, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, ResultDialog, PingPill, DifficultyTag,
// DifficultyIcon, TextField, UiHosts (mount once: dialogs + tooltips), useTicker, secondsLeft/hasDeadline,
// roman(), doctorNo().
//
@@ -732,6 +732,39 @@ export function PhaseBanner({ title, sub, micro, tone = 'mint', mode = 'inline',
</div>`;
}
/**
* The round's result box (the official round result dialog, shown at settlement; GitHub #235, PR #112 by @Convey123):
* a centred framed plate — tone-coloured frame and corner ticks, chevrons either side of the title — that opens, holds
* `duration` ms and closes by itself. Purely presentational: ui/gameLogic/phases.js (roundResultBox / uniteResultBox /
* battleResultBox) picks the words, screens/game.js the moment (SETTLE). `pointer-events: none`: it never takes a click.
* @param {{ title: any, sub?: any, micro?: string, tone?: 'mint'|'orange'|'red', duration?: number, onDone?: Function }} props
* duration (ms) > 0 auto-hides then calls onDone. Re-key to replay.
*/
export function ResultDialog({ title, sub, micro, tone = 'mint', duration = 2800, onDone }) {
const [leaving, setLeaving] = useState(false);
const [gone, setGone] = useState(false);
useEffect(() => {
if (!(duration > 0)) return undefined;
const t1 = setTimeout(() => setLeaving(true), duration);
const t2 = setTimeout(() => { setGone(true); onDone?.(); }, duration + 300);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [duration]);
if (gone) return null;
return html`<div class=${cx('rdialog', `rdialog--${tone}`, leaving && 'is-leaving')} role="status" aria-live="polite">
<div class="rdialog__box">
<span class="rdialog__tick rdialog__tick--tl"></span><span class="rdialog__tick rdialog__tick--tr"></span>
<span class="rdialog__tick rdialog__tick--bl"></span><span class="rdialog__tick rdialog__tick--br"></span>
${micro ? html`<span class="rdialog__micro">${micro}</span>` : null}
<div class="rdialog__row">
<${Chevrons} count=${3} tone=${tone} class="rdialog__chev" />
<span class="rdialog__title">${title}</span>
<${Chevrons} count=${3} tone=${tone} dir="left" class="rdialog__chev" />
</div>
${sub ? html`<span class="rdialog__sub">${sub}</span>` : null}
</div>
</div>`;
}
// ---- domain helpers ----------------------------------------------------------------------------
/**
+1
View File
@@ -28,6 +28,7 @@ export { clamp, tileKey } from './gameLogic/shared.js';
export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js';
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } from './gameLogic/camera.js';
export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js';
export { RESULT_BOX_MS, ownRoundLoss, battleOverSfx, roundResultBox, uniteResultBox, battleResultBox } from './gameLogic/phases.js';
export { STATUS_META } from './gameLogic/format.js';
export { sortedPlayers } from './gameLogic/shared.js';
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
+99 -1
View File
@@ -1,4 +1,5 @@
// ui/gameLogic/phases.js — phase families, banners and countdowns. Re-exported from ../gameLogic.js.
// ui/gameLogic/phases.js — phase families, banners, the round's result box and 战斗结束 sound, countdowns. Re-exported
// from ../gameLogic.js.
import { PHASE } from '../../../../shared/constants.js';
import { bossLevelSeconds } from '../matchStatus.js';
@@ -70,6 +71,103 @@ export function phaseBanner(phase, pub, { alive = true, spectator = false } = {}
}
}
// ---- the round's result box and 战斗结束 sound (GitHub #235; PR #112 by @Convey123) ------------------------------------
// The official round result dialog (research 09 §3.1 "Round result dialog"): 「作战结束」 with 「全员无伤!」, or
// 「生命值减少」 and the LP lost — official words only (the owner's review of PR #112, 2026-10-05: the
// remake's own 「联防成功 / 联防失败:还有 N 只突破防线」 line is not in the official dialog). screens/game.js shows it
// (ui/components.js ResultDialog) and plays the matching BATTLEOVER sound when SETTLE starts — the moment is [ASSUMED]:
// the sources do not time the dialog, and a 联防 leaker's number exists only after the 联防. Boss rounds have no SETTLE
// (最终攻势 / 隐秘核心 go to the result screen or the Hidden Core round), so they get neither [ASSUMED].
/** How long a result box (ui/components.js ResultDialog) stays up, ms — inside SETTLE's 3 s (server DELAYS.SETTLE). */
export const RESULT_BOX_MS = 2800;
/**
* What this round's own battle cost the viewer: `min(cap, leaks)`, exactly what settlement charges outside a 联防.
* `cost` is what screens/game.js `roundLossRef` kept while the battle ran — `leaks` = counted enemies that got through
* (the highest the round showed: the settled state reads 0 again and must not wipe it), `cap` = the per-round LP cap.
* @param {{ leaks?: number, cap?: number } | null | undefined} cost
* @returns {number|null} null when this round's battle was never seen (not in the round, a reconnect landing on SETTLE)
*/
export function ownRoundLoss(cost) {
if (!cost) return null;
const leaks = Number.isFinite(cost.leaks) ? Math.max(0, Math.trunc(cost.leaks)) : null;
if (leaks == null) return null;
const cap = Number.isFinite(cost.cap) && cost.cap > 0 ? Math.trunc(cost.cap) : 10; // no cap known → the official 10
return Math.min(cap, leaks);
}
/**
* 战斗结束: one of the official `BATTLEOVER_*` sounds (all three in data/assets.json audio.sfx.ui) — `battleOverReduce`
* when the round cost the viewer LP, `battleOverNoReduce` when a 联防 ran and the viewer was charged nothing,
* `battleOverNormal` otherwise; null when this round's battle was never seen. The client data names the three but not
* their triggers: the mapping is [ASSUMED] (PR #112; the owner's review called the three sounds right).
* The loss is the authority's per-player charge when a 联防 resolved (`uniteLoss`, m.public.uniteResult.losses — a
* leaker whose enemies the helpers stopped paid 0), else the own battle's (`ownRoundLoss`).
* @param {{ leaks?: number, cap?: number, unite?: boolean } | null | undefined} cost
* @param {number|null} [uniteLoss]
* @returns {'battleOverReduce'|'battleOverNoReduce'|'battleOverNormal'|null}
*/
export function battleOverSfx(cost, uniteLoss = null) {
if (!cost) return null;
// `uniteLoss == null` means "no 联防 figure" (Number(null) would read as 0)
const authority = uniteLoss == null ? NaN : Number(uniteLoss);
const loss = Number.isFinite(authority) ? Math.max(0, Math.trunc(authority)) : ownRoundLoss(cost);
if (loss == null) return null;
if (loss > 0) return 'battleOverReduce';
return cost.unite ? 'battleOverNoReduce' : 'battleOverNormal';
}
/**
* The official round result dialog for the viewer's own LP charge `loss`: 「作战结束」 + 「全员无伤!」 (mint) at 0, else
* 「生命值减少 −N」 (red).
* @param {number} loss
* @returns {{ title: string, micro: string, tone: string, sub: string, duration: number }}
*/
export function roundResultBox(loss) {
const n = Math.max(0, Math.trunc(Number(loss) || 0));
return n === 0
? { title: t('作战结束'), micro: 'BATTLE OVER', tone: 'mint', sub: t('全员无伤!'), duration: RESULT_BOX_MS }
: { title: t('作战结束'), micro: 'BATTLE OVER', tone: 'red', sub: t('生命值减少 −{n}', { n }), duration: RESULT_BOX_MS };
}
/**
* The result box of a round whose 联防 resolved: the same official dialog, its number the authority's charge for the
* viewer (m.public.uniteResult.losses — in a 联防 a leaker pays for the survivors, not for its own battle's leaks, so the
* client cannot work it out). The official words only, and only when they are true:
* 生命值减少 −N the round charged the viewer N (red);
* 全员无伤! nothing got through (`through` 0): nobody paid, true for every participant (mint);
* title alone the 联防 leaked but the viewer was not charged (a helper, a spared leaker — 全员无伤! would claim the
* teammate who paid was unharmed too, and the official dialog has no other line; orange). Also the
* reading of a view without `losses`, which never falls back to the own battle's leaks.
* A viewer `losses` does not list — a spectator seat, a player eliminated before the round — gets no box, as in a
* round without 联防, where a viewer without a battle gets none (and no sound) [ASSUMED].
* @param {{ through?: number, losses?: Record<string, number> } | null | undefined} res m.public.uniteResult
* @param {string|null|undefined} selfId the viewer's playerId
* @returns {{ title: string, micro: string, tone: string, sub: string, duration: number } | null} null: no 联防 resolved, or not in the round
*/
export function uniteResultBox(res, selfId) {
const through = Number(res?.through);
if (!isObj(res) || !Number.isFinite(through) || through < 0) return null;
const losses = isObj(res.losses) ? res.losses : null;
if (losses && (selfId == null || !Object.hasOwn(losses, selfId))) return null;
const raw = losses ? Number(losses[selfId]) : NaN;
const loss = Number.isFinite(raw) ? Math.max(0, Math.trunc(raw)) : null;
if (loss != null && loss > 0) return roundResultBox(loss);
if (loss != null && through === 0) return roundResultBox(0);
return { title: t('作战结束'), micro: 'BATTLE OVER', tone: 'orange', sub: '', duration: RESULT_BOX_MS };
}
/**
* The result box of a round without 联防: the own battle's cost as kept while it ran (`ownRoundLoss`; the live pending
* value is 0 again by the time SETTLE renders). null without a battle seen this round.
* @param {{ leaks?: number, cap?: number } | null | undefined} cost
*/
export function battleResultBox(cost) {
const loss = ownRoundLoss(cost);
return loss == null ? null : roundResultBox(loss);
}
/** Label of the prep capsule ("休息一下" in the original). */
export function prepCapsuleLabel(phase) {
if (phase === PHASE.SP_DRAFT) return t('机变阶段');