Files
ark-stronghold-protocol/public/js/ui/matchStatus.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

109 lines
5.5 KiB
JavaScript

// Pure match-status helpers of the in-match HUD (no DOM, no Preact — unit-tested in test/ui/leftovers.test.js):
//
// * Boss-round clock (最终攻势 / 隐秘核心, research 00-INDEX §5, 01 §10): m.public.deadline is the level's
// maxPlayTime countdown (120 real s, config modes[…].rounds[r].levelMaxPlayTime) — "计时结束后战斗仍然会继续" —
// and m.public.overtimeAt is when the overtime drain starts (bossTurnHpReduceTime, 150 real s): from then on the
// merged team LP loses config.bossOvertimeDrainPerSec (1) per whole real second (server gamedata.js
// bossOvertimeDue: the first point at overtimeAt + 1 s). `overtimeState` says what the red DOT warning shows.
// * Solo pause (official 独立模拟 battles): C2S g.pause {on}; the server says so in m.public.paused (optionally
// m.public.pausedAt, ms epoch). Only solo battles offer it (`pauseAvailable`); while paused every HUD clock is
// frozen at the pause moment (`frozenNow`).
import { PHASE } from '../../../shared/constants.js';
const isObj = (v) => !!v && typeof v === 'object';
const BOSS_PHASES = new Set([PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** Battle phases a solo player may pause (a solo run has no 联防). */
const PAUSE_PHASES = new Set([PHASE.COMBAT, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** Level time of a boss round when the config does not say (research 01 §3: maxPlayTime 120 s). */
export const BOSS_LEVEL_SECONDS = 120;
/** The pending overtime warning shows this many seconds before the drain even while the level countdown still runs. */
export const OVERTIME_WARN_BEFORE = 30;
/**
* The level's countdown length (s) of a boss round — the gauge total of the 120 s countdown.
* @param {any} pub m.public
* @param {any} config data/config.json
* @returns {number|null} null outside boss rounds
*/
export function bossLevelSeconds(pub, config) {
if (!isObj(pub) || !BOSS_PHASES.has(pub.phase)) return null;
const mode = isObj(config?.modes) ? config.modes[pub.modeId] : null;
const v = mode?.rounds?.[String(pub.round)]?.levelMaxPlayTime;
return Number.isFinite(v) && v > 0 ? v : BOSS_LEVEL_SECONDS;
}
/** Team LP lost per real second of overtime (config.bossOvertimeDrainPerSec, default 1). */
export function overtimeDrainPerSec(config) {
const v = config?.bossOvertimeDrainPerSec;
return Number.isFinite(v) && v >= 0 ? v : 1;
}
/**
* What the red overtime (DOT) warning of a boss round shows at `now` (server-corrected ms epoch):
* null nothing (not a boss round, no overtimeAt, or the drain is still far away)
* { state: 'pending', secs } the drain starts in `secs` s (shown once the level countdown ran out, or
* within OVERTIME_WARN_BEFORE s of the drain)
* { state: 'drain', secs, lost, perSec } draining for `secs` whole seconds: `lost` LP so far, `perSec` LP/s
* @param {any} pub m.public
* @param {number} now
* @param {{ perSec?: number, warnBefore?: number }} [o]
*/
export function overtimeState(pub, now, { perSec = 1, warnBefore = OVERTIME_WARN_BEFORE } = {}) {
if (!isObj(pub) || !BOSS_PHASES.has(pub.phase) || !Number.isFinite(now)) return null;
const at = Number(pub.overtimeAt);
if (!(Number.isFinite(at) && at > 0)) return null;
if (now < at) {
const secs = Math.ceil((at - now) / 1000);
const levelOver = Number.isFinite(pub.deadline) && pub.deadline > 0 && now >= pub.deadline;
if (!levelOver && secs > warnBefore) return null;
return { state: 'pending', secs };
}
const secs = Math.floor((now - at) / 1000);
const rate = Number.isFinite(perSec) && perSec >= 0 ? perSec : 1;
return { state: 'drain', secs, lost: secs * rate, perSec: rate };
}
/**
* Whether the solo pause control is offered: solo runs only (hidden in co-op), while the player's own battle runs
* (server Match.setPause: WRONG_PHASE once no battle is live). While paused the control stays (to resume).
* @param {any} pub m.public
* @param {{ solo?: boolean, alive?: boolean, done?: boolean }} [o] done: the own battle is over
*/
export function pauseAvailable(pub, { solo = false, alive = true, done = false } = {}) {
if (!solo || !isObj(pub) || alive === false) return false;
if (done && !pub.paused) return false;
return PAUSE_PHASES.has(pub.phase);
}
/** Whether m.public says the (solo) match is paused. */
export const isPaused = (pub) => isObj(pub) && !!pub.paused;
/** The pause intent (C2S g.pause {on}). */
export const pauseIntent = (on) => ({ t: 'g.pause', fields: { on: !!on } });
/**
* The clock every HUD countdown reads: the live server time, or — while paused — the moment the pause began
* (m.public.pausedAt when the server sends it, else the first time this client saw `paused`).
* @param {any} pub m.public
* @param {number} now live server-corrected ms epoch
* @param {number|null} seenAt when this client first saw the pause (ms epoch), or null
*/
export function frozenNow(pub, now, seenAt = null) {
if (!isPaused(pub)) return now;
const at = Number(pub.pausedAt);
if (Number.isFinite(at) && at > 0) return Math.min(now, at);
return Number.isFinite(seenAt) ? Math.min(now, seenAt) : now;
}
/**
* Seconds left on a deadline at `now` (ceil, ≥ 0), or null without a deadline.
* @param {number|null|undefined} deadline ms epoch
* @param {number} now ms epoch
*/
export function remainAt(deadline, now) {
if (!(Number.isFinite(deadline) && deadline > 0) || !Number.isFinite(now)) return null;
return Math.max(0, Math.ceil((deadline - now) / 1000));
}