Files
ark-stronghold-protocol/server/sim/spec.js
T
sganggs ec0a2fc669 feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged
A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.

- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
  (shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
  a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
  the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
  per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
  ×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
  盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
  the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
  多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
  unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
  the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
  numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
  potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
  chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
2026-10-08 20:38:08 +08:00

428 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/spec.js — BattleSpec: the JSON description of one battle, built by the server and simulated identically
// by the server (headless / takeover / verification) and by browsers (the authoritative client and display replicas).
// DESIGN §14. Pure ESM (served at /sim/spec.js): no Node API.
//
// const spec = buildBattleSpec({ battleId, fieldId, kind, seed, modeId, round, stageId, rect, timeLimit, players,
// spawns, routes, flags, enemyOverrides, waveId, bossId, content, boss })
// const battle = createBattleFromSpec(spec, dataSource, { sharedBoss?, BattleClass?, logger?, recordEvents?, quiet? })
//
// A spec is JSON-safe by construction (it IS the JSON round trip of its inputs: `undefined` / functions vanish,
// Infinity / NaN become null), so the server builds its own battles from exactly what the clients receive. Object
// references (stage records, the shared boss pool, the data source) never travel: `stageId` names the stage, `boss`
// carries the pool numbers at battle start and the side that runs the battle attaches a pool object:
// * server: the match's SharedBossPool (or a crediting wrapper on takeover, server/match/finalAssault.js),
// * client: a LocalBossPool (below) reconciled with the server's `b.pool` broadcasts.
// Determinism: the sim uses no wall clock and no Math.random; the same spec + the same data give the same result on
// every machine (test/match/clientCombat.test.js compares result digests).
//
// Operator loadouts (DESIGN §16): every operator entry of `players[].units[]` may carry `skillIndex` (character skill
// slot, 0-based) and `moduleId` (uniEquipId or 'none'); buildBattleSpec keeps them when well-formed (else drops them =
// the default). createBattleFromSpec hands the Battle a per-battle data view (withUnitLoadouts) that resolves each
// operator def — and its summons — for the loadout of its chess (simdata getChess(id, loadout) / getToken(id, owner,
// ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup.
// 补位 (DATA.md §18): an operator entry with `standIn: true` (kept only when exactly `true`) is fielded as its chess's
// stand-in (simdata getChess(id, { standIn: true }); its skill / module are the chess's backup selection).
// 自选 (DATA.md §18): an operator entry of a DIY slot (`chessId` its `_a` / `_b` id) carries `diy: { charId, skillIndex?,
// uniEquipId? }` (kept when well-formed; the data layer checks legality — shared/diy.js checkDiyPick) and is fielded as
// simdata getChess(id, { diy }).
// Potential and 练度 (0.2.2, shared/potential.js — the player's 干员调配 settings, the owner's decision of 2026-10-08): an
// operator entry may carry `potential` (潜能 1–6: the def at that potential, its summons' talents at it) and
// `cultivate` (练度 0–3: the effects.json CHAR_MAP aceffect_char_1…4 — a ×ATK / ×DEF / ×max HP of the unit, its own
// multiplier); each kept only when well-formed and never on a stand-in. Absent: full potential and no 练度 (a raw
// unit — the match always states both for an owned operator: PlayerState.battleInput).
import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js';
import { isPotential, isCultivate } from '../../shared/potential.js';
import { BOSS_POOL_MIN_HP } from './constants.js';
export const SPEC_VERSION = 1;
/** Deep JSON round trip (exactly what a spec becomes on the wire). */
export function jsonClone(v) {
if (v === undefined) return undefined;
return JSON.parse(JSON.stringify(v));
}
// JSON has no Infinity: ±Infinity inputs become ±1e308 (still "larger than anything" for the sim's comparisons and
// caps) instead of null (which the sim would read as 0); NaN becomes null (the sim treats both as "missing").
const INF = 1e308;
const specReplacer = (k, v) => (typeof v === 'number' && !Number.isFinite(v) ? (Number.isNaN(v) ? null : v > 0 ? INF : -INF) : v);
/**
* Build the JSON BattleSpec of one field (DESIGN §14). Inputs are the Battle options the match computed (DESIGN §5.1)
* minus object references: `stageId` instead of a stage object, `boss: { poolHp, poolMax }` instead of a pool.
* @returns {object} a fresh JSON-safe object
*/
export function buildBattleSpec(o = {}) {
const bossLike = o.kind === 'boss' || o.kind === 'hidden';
const tl = Number(o.timeLimit);
const spec = {
v: SPEC_VERSION,
battleId: o.battleId ?? null,
fieldId: o.fieldId ?? null,
kind: o.kind ?? 'normal',
seed: (Number(o.seed) >>> 0) || 1,
modeId: o.modeId ?? null,
round: Number.isInteger(o.round) ? o.round : 0,
stageId: o.stageId ?? null,
rect: o.rect ?? null,
// boss / hidden fields end by the shared pool or the match (null = no limit)
timeLimit: !bossLike && tl > 0 && Number.isFinite(tl) ? tl : null,
players: Array.isArray(o.players) ? o.players : [],
// a spawn at time Infinity never happens and is not counted (Battle._queueSpawn): leave it out
spawns: (Array.isArray(o.spawns) ? o.spawns : []).filter((x) => !(x && Number(x.time) === Infinity)),
routes: Array.isArray(o.routes) ? o.routes : [],
flags: o.flags ?? {},
enemyOverrides: o.enemyOverrides ?? {},
waveId: o.waveId ?? null,
bossId: o.bossId ?? null,
content: o.content ?? 'full',
boss: bossLike && o.boss ? { poolHp: Number(o.boss.poolHp) || 0, poolMax: Number(o.boss.poolMax) || 1 } : null,
};
const out = JSON.parse(JSON.stringify(spec, specReplacer));
for (const p of out.players) for (const u of (p && Array.isArray(p.units) ? p.units : [])) if (u && typeof u === 'object') sanitizeUnitLoadout(u);
return out;
}
const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/;
/**
* A well-formed 自选 pick `{ charId, skillIndex?, uniEquipId? }` reduced to those fields (skill 0–9 or null, module a
* loadout id or null), or null.
*/
function cleanDiyPick(d) {
if (!d || typeof d !== 'object' || Array.isArray(d) || typeof d.charId !== 'string' || !LOADOUT_ID.test(d.charId)) return null;
const out = { charId: d.charId };
if (d.skillIndex != null) {
if (!(Number.isInteger(d.skillIndex) && d.skillIndex >= 0 && d.skillIndex <= 9)) return null;
out.skillIndex = d.skillIndex;
}
if (d.uniEquipId != null) {
if (!(typeof d.uniEquipId === 'string' && LOADOUT_ID.test(d.uniEquipId))) return null;
out.uniEquipId = d.uniEquipId;
}
return out;
}
/**
* Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality), `standIn` only when
* exactly `true` (补位), `diy` only as a well-formed pick (自选), `potential` (1–6) / `cultivate` (0–3) only when
* well-formed and not on a stand-in. Mutates `u`.
*/
export function sanitizeUnitLoadout(u) {
if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex;
if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId;
if ('standIn' in u && u.standIn !== true) delete u.standIn;
if ('diy' in u) { const d = cleanDiyPick(u.diy); if (d) u.diy = d; else delete u.diy; }
if ('potential' in u && !isPotential(u.potential)) delete u.potential;
if ('cultivate' in u && !isCultivate(u.cultivate)) delete u.cultivate;
if (u.standIn === true) { delete u.potential; delete u.cultivate; }
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; delete u.diy; delete u.potential; delete u.cultivate; }
return u;
}
// withUnitLoadouts(ds, players) — the per-battle loadout data view — lives in simdata.js (content/index.js
// installContent applies it to every Battle, however constructed); re-exported here for spec users.
export { withUnitLoadouts };
/**
* Construct the Battle a spec describes. The spec is deep-copied (one spec may build several battles: a display
* replica, a takeover re-simulation, a verification run).
* @param {object} spec BattleSpec
* @param {any} dataSource DataSource (or raw data maps; default: the sim's default source)
* @param {{ sharedBoss?: object|null, BattleClass?: Function, logger?: object, recordEvents?: boolean, quiet?: boolean,
* content?: string }} [opts]
*/
export function createBattleFromSpec(spec, dataSource, opts = {}) {
if (!spec || typeof spec !== 'object') throw new TypeError('createBattleFromSpec: spec required');
const s = jsonClone(spec);
const bossLike = s.kind === 'boss' || s.kind === 'hidden';
let sharedBoss = opts.sharedBoss ?? null;
if (!sharedBoss && bossLike && s.boss) sharedBoss = new LocalBossPool(s.boss.poolMax, s.boss.poolHp);
const BattleClass = typeof opts.BattleClass === 'function' ? opts.BattleClass : Battle;
const battleOpts = {
seed: s.seed,
kind: s.kind,
modeId: s.modeId,
round: s.round,
stageId: s.stageId,
rect: s.rect ?? undefined,
timeLimit: s.timeLimit == null ? (bossLike ? Infinity : undefined) : s.timeLimit,
players: s.players ?? [],
spawns: s.spawns ?? [],
routes: s.routes ?? [],
sharedBoss,
flags: s.flags ?? {},
fieldId: s.fieldId,
enemyOverrides: s.enemyOverrides ?? {},
waveId: s.waveId ?? null,
data: BattleClass === Battle ? withUnitLoadouts(toDataSource(dataSource), s.players) : dataSource,
content: opts.content ?? s.content ?? 'full',
};
if (s.bossId != null) battleOpts.bossId = s.bossId;
if (opts.logger) battleOpts.logger = opts.logger;
if (opts.recordEvents === false) battleOpts.recordEvents = false;
if (opts.quiet) battleOpts.quiet = true;
const b = new BattleClass(battleOpts);
b.battleId = s.battleId ?? null;
return b;
}
/**
* Client-side view of the shared boss HP pool (DESIGN §14 b.pool): the server owns the pool; this field adds its own
* damage locally and shows `server hp − local damage the server has not acknowledged yet`.
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp; the hit that
* would leave less than BOSS_POOL_MIN_HP (1) takes the rest)
* sync(serverHp, ackedCum) a b.pool broadcast: the server's hp and how much of THIS field's cumulative damage
* (`cum`) it has already counted
* cum / byPlayer cumulative damage of this field (reported as b.progress.bossDmg / .by)
* `hp` below 1 reads 0 (the leader is down; user playtest #6 item 5): the difference of the server's float hp and the
* local counters can leave dust (3.6e-12) smaller than half an ulp of `cum`, which no hit could remove — `cum += dust`
* changes nothing — so the leader stood at "0 HP" and the field never ended.
*/
export class LocalBossPool {
constructor(maxHp, hp = maxHp) {
this.maxHp = Math.max(1, Number(maxHp) || 1);
const h = Number(hp);
this.serverHp = Number.isFinite(h) ? Math.max(0, Math.min(this.maxHp, h)) : this.maxHp;
this.cum = 0;
this.acked = 0;
/** @type {Record<string, number>} */
this.byPlayer = {};
}
get hp() {
const h = this.serverHp - Math.max(0, this.cum - this.acked);
return h < BOSS_POOL_MIN_HP ? 0 : h;
}
/** Only used by the sim's fallback path when damage() throws. */
set hp(v) {
const n = Number(v);
if (Number.isFinite(n)) this.serverHp = Math.max(0, n) + Math.max(0, this.cum - this.acked);
}
damage(playerId, amount) {
const a = Number(amount);
const left = this.hp;
if (!Number.isFinite(a) || a <= 0 || left <= 0) return 0;
const dealt = left - a < BOSS_POOL_MIN_HP ? left : a;
this.cum += dealt;
if (playerId != null) this.byPlayer[playerId] = (this.byPlayer[playerId] || 0) + dealt;
return dealt;
}
sync(serverHp, ackedCum) {
const h = Number(serverHp);
if (Number.isFinite(h)) this.serverHp = Math.max(0, Math.min(this.maxHp, h));
const a = Number(ackedCum);
if (Number.isFinite(a) && a >= 0) this.acked = a;
}
}
/**
* LP meter of a battle: cumulative LP the field cost its team so far — enemy leaks weighted by `lpr` (data
* lifePointReduce; 0 for harmless units, 1 when absent) plus leader "扣除目标生命" effects (the sim's 'lpLoss' hook).
* Boss / hidden fields report it as b.progress.leaks; the server charges the team LP with the deltas.
* @returns {{ lp: number, detach: () => void }}
*/
export function attachLpMeter(battle) {
const meter = { lp: 0, detach() {} };
if (!battle || typeof battle.on !== 'function') return meter;
const h1 = battle.on('enemyLeak', (ctx) => {
const e = ctx && ctx.enemy;
if (!e) return;
const lpr = Number.isFinite(e.lpr) && e.lpr >= 0 ? e.lpr : 1;
meter.lp += lpr;
}, { priority: -1000, owner: 'lpMeter' });
const h2 = battle.on('lpLoss', (ctx) => {
const n = Number(ctx && ctx.amount);
if (Number.isFinite(n) && n > 0) meter.lp += n;
}, { priority: -1000, owner: 'lpMeter' });
meter.detach = () => { try { battle.off?.(h1); battle.off?.(h2); } catch { /* ignore */ } };
return meter;
}
/**
* 联防 (user playtest #6 item 7): each source player's enemies still standing on a unite field — not spawned yet,
* alive, or already through the objective again — i.e. what settlement would charge them if the battle ended now
* (server/match/unite.js uniteSurvivors: the unite result's counted leaks + unspawned entries by `sourcePlayerId`;
* alive enemies become leaks on a timeout, keyed like Battle._recordLeak). It falls as the helpers strike them down and
* rises when one splits or summons (content-spawned children inherit the parent's sourcePlayerId).
* `{ [playerId]: n }` (a player with none left is absent), or null for a battle without the sim's state (a stand-in).
* @returns {Record<string, number> | null}
*/
export function uniteLeft(battle) {
if (!battle) return null;
const out = {};
const add = (pid) => { if (typeof pid === 'string' && pid) out[pid] = (out[pid] || 0) + 1; };
if (battle.finished && typeof battle.result === 'function') {
const r = battle.result();
if (!r || !r.perPlayer) return null;
for (const pp of Object.values(r.perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
for (const u of r.unspawned || []) if (u) add(u.sourcePlayerId);
return out;
}
if (!battle._perPlayer || !Array.isArray(battle._pending) || !Array.isArray(battle.enemies)) return null;
for (const p of battle._pending) if (p) add(p.sourcePlayerId);
for (const e of battle.enemies) if (e && e.alive && e.counted) add(e.sourcePlayerId ?? e.ownerId);
for (const pp of Object.values(battle._perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
return out;
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
* still standing).
*/
export function battleProgress(battle) {
const r = battle && typeof battle.result === 'function' && battle.finished ? battle.result() : null;
let leaks = 0;
const pp = battle && battle._perPlayer ? battle._perPlayer : (r && r.perPlayer) || {};
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
const pool = battle && battle.sharedBoss;
const gt = Number(battle && battle.time) || 0;
const out = {
gt: Math.round(gt * 1000) / 1000,
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
leaks,
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
done: !!(battle && battle.finished),
};
if (battle && battle.kind === 'unite') {
const left = uniteLeft(battle);
if (left) out.left = left;
}
return out;
}
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
const modsKey = (m) => (m && typeof m === 'object' ? JSON.stringify(Object.keys(m).filter((k) => m[k] !== undefined).sort().map((k) => [k, m[k]])) : '');
/**
* Canonical summary of a BattleResult (everything the match consumes): used to compare a client's result with the
* server's re-simulation (SP_VERIFY) and by the determinism tests. Returns `{ json, hash }`.
*/
export function resultDigest(result) {
const res = result && typeof result === 'object' ? result : {};
const per = res.perPlayer && typeof res.perPlayer === 'object' ? res.perPlayer : {};
const players = Object.keys(per).sort().map((pid) => {
const p = per[pid] || {};
const lg = p.layerGains && typeof p.layerGains === 'object' ? p.layerGains : {};
return [
pid,
p.killed | 0, p.total | 0, !!p.perfect, r4(p.coins),
Object.keys(lg).sort().map((k) => [k, r4(lg[k])]),
// mods included: a leak re-enters the 联防 with them (round multipliers, bounty id)
(Array.isArray(p.leaked) ? p.leaked : []).map((l) => `${l && l.enemyKey}|${l && l.counted === false ? 0 : 1}|${(l && l.sourcePlayerId) || ''}|${modsKey(l && l.mods)}`).sort(),
Math.round(Number(p.damageDealt) || 0), Math.round(Number(p.bossDamage) || 0),
(Array.isArray(p.unitsEnd) ? p.unitsEnd : []).map((u) => [u && u.uid, r4(u && u.hpPct), r4(u && u.sp), !!(u && u.alive)]),
];
});
const json = JSON.stringify([res.reason ?? null, r4(res.time), res.killed | 0, res.total | 0, players]);
let h = 2166136261;
for (let i = 0; i < json.length; i++) { h ^= json.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
return { json, hash: h.toString(16).padStart(8, '0') };
}
const fnum = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null);
const keyOr = (v) => (isKey(v) ? v : null);
function compactMods(m) {
if (!m || typeof m !== 'object') return null;
const out = {};
let n = 0;
for (const k of Object.keys(m)) {
if (n >= 16 || k.length > 32) break;
const v = m[k];
if ((typeof v === 'number' && Number.isFinite(v)) || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 64) || v === null) { out[k] = v; n++; }
}
return out;
}
/**
* The part of a BattleResult the server consumes, in the b.result wire shape (shared/protocol.js isBattleResult):
* numbers only where the match reads them, list sizes capped, names dropped (the server names units from its data).
*/
export function compactResult(res) {
const r = res && typeof res === 'object' ? res : {};
const perPlayer = {};
for (const pid of Object.keys(r.perPlayer || {}).slice(0, 4)) {
const p = r.perPlayer[pid] || {};
const layerGains = {};
for (const [k, v] of Object.entries(p.layerGains || {}).slice(0, 40)) if (isKey(k) && fnum(v) > 0) layerGains[k] = Math.min(1e4, fnum(v));
const total = Math.max(0, Math.trunc(fnum(p.total)));
perPlayer[pid] = {
killed: Math.min(total, Math.max(0, Math.trunc(fnum(p.killed)))),
total,
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => {
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
if (l.boss) o.boss = true;
if (l.spawned) o.spawned = true;
return o;
}),
perfect: !!p.perfect,
layerGains,
coins: Math.max(0, fnum(p.coins)),
damageDealt: Math.max(0, Math.round(fnum(p.damageDealt))),
bossDamage: Math.max(0, Math.round(fnum(p.bossDamage))),
healingDone: Math.max(0, Math.round(fnum(p.healingDone))),
deaths: Math.max(0, Math.trunc(fnum(p.deaths))),
unitsEnd: cap(p.unitsEnd, 64).filter(Boolean).map((u) => ({
uid: uidOr(u.uid), defId: keyOr(u.defId), hpPct: Math.max(0, Math.min(1, fnum(u.hpPct))), sp: Math.max(0, Math.min(1e5, fnum(u.sp))),
skillActive: !!u.skillActive, alive: !!u.alive,
})),
unitStats: cap(p.unitStats, 160).filter(Boolean).map((u) => ({
uid: uidOr(u.uid), defId: keyOr(u.defId), kind: typeof u.kind === 'string' && u.kind.length <= 16 ? u.kind : 'op',
dmg: Math.max(0, Math.round(fnum(u.dmg))), kills: Math.max(0, Math.trunc(fnum(u.kills))), heal: Math.max(0, Math.round(fnum(u.heal))),
taken: Math.max(0, Math.round(fnum(u.taken))), attacks: Math.max(0, Math.trunc(fnum(u.attacks))),
})),
};
}
const out = {
reason: ['cleared', 'timeout', 'forced'].includes(r.reason) ? r.reason : 'forced',
time: Math.max(0, Math.min(1e5, fnum(r.time))),
killed: Math.max(0, Math.trunc(fnum(r.killed))),
total: Math.max(0, Math.trunc(fnum(r.total))),
perPlayer,
errors: Math.max(0, Math.min(1e9, Math.trunc(fnum(r.errors)))),
};
if (out.killed > out.total) out.killed = out.total;
if (Array.isArray(r.unspawned) && r.unspawned.length) {
out.unspawned = cap(r.unspawned, 400).filter((u) => u && isKey(u.enemyKey)).map((u) => ({
enemyKey: u.enemyKey, sourcePlayerId: keyOr(u.sourcePlayerId), tag: typeof u.tag === 'string' && u.tag.length <= 16 ? u.tag : null,
time: Math.max(0, Math.min(1e6, fnum(u.time))),
}));
}
return out;
}
/** Bytes a b.result frame may use (the socket's inbound limit is 64 KB; headroom for the envelope). */
export const RESULT_FRAME_BUDGET = 60 * 1024;
/**
* Keep a compact result under the frame budget (a larger frame closes the socket: the server would take the field over
* at the very end). Never touches what settles LP / funds / layers of a normal field. In order: drop the per-unit
* statistics, drop the leaks' `mods` (the server rebuilds them from the spec), and — boss fields only, whose leaks cost
* team LP through b.progress — drop leak entries. Returns the (possibly) trimmed copy.
* @param {object} result compactResult(...) output
* @param {{ bossLike?: boolean, budget?: number, battleId?: string }} [o]
*/
export function fitResult(result, { bossLike = false, budget = RESULT_FRAME_BUDGET, battleId = '' } = {}) {
const size = (r) => JSON.stringify({ t: 'b.result', battleId, result: r, rid: 2147483647 }).length;
if (!result || typeof result !== 'object' || size(result) <= budget) return result;
const r = { ...result, perPlayer: {} };
for (const [pid, p] of Object.entries(result.perPlayer || {})) r.perPlayer[pid] = { ...p, unitStats: [] };
if (size(r) <= budget) return r;
for (const p of Object.values(r.perPlayer)) p.leaked = (p.leaked || []).map((l) => ({ ...l, mods: null }));
if (size(r) <= budget || !bossLike) return r;
for (const p of Object.values(r.perPlayer)) p.leaked = [];
if (Array.isArray(r.unspawned)) r.unspawned = [];
return r;
}