Files
ark-stronghold-protocol/server/match/fields.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

931 lines
48 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/match/fields.js — runs the battles of one combat phase (DESIGN §4, §11, §14).
//
// Server-run combat (legacy streaming mode, SP_COMBAT=server; and the Final Assault when no field has a connected human):
// FieldRunner steps every live field in lockstep:
// * real-time pacing (RealScheduler, or VirtualScheduler with instantCombat=false): an interval every 1000/30 ms
// accumulates elapsed real time × the match's game speed (forced 2×; `opts.combatSpeed` in tests/tools) and
// steps floor(acc / TICK) ticks, never more than maxTicksPerInterval(speed) (8 at 2×) per interval — the
// remainder is dropped so a stalled server never spirals.
// * instant (VirtualScheduler default): every field is stepped to completion synchronously.
// * a solo pause (Match.paused, g.pause) skips the intervals: the field clock stands still (HeadlessPacer too).
// Every SNAP_EVERY (3) ticks of a field its events are drained; watchers of that field get `b.ev` then `b.snap`, both
// carrying the field's game time `gt` (`emit: false` skips the streaming: server-run fields under client-side combat).
// Per-field isolation: an exception from step() force-ends that field as a timeout (and, if even that throws, the
// field is closed with a synthetic result). A hard cap (HARD_CAP_SECONDS of game time) force-ends anything left.
// Results that did not come from a finished battle carry `synthetic: true` (the match never charges LP for them).
//
// Client-side combat (DESIGN §14) helpers:
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
// under a real scheduler) or at once (verification, virtual time) — and keep a
// progress timeline [[gt, killed, total, resolved]] for the teammates' waiting UI
// (联防: a 5th element — left = spec.js uniteLeft, the leakers'
// enemies still standing, for their live counter; user playtest #6 item 7).
// `resolved` = the HUD capsule's numerator (Battle.resolved), sampled on the
// field clock: a server-run / bot field has no authority reporting b.progress
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
// (boss fields that share the pool while other fields run on clients); a
// takeover's fast-forward is spread over the pacing intervals
// specBounds(spec, gd) / validateClientResult(spec, result, { gd })
// a client's b.result is accepted only when it is plausible for its spec;
// the returned result is rebuilt from whitelisted fields (never the raw object)
// uniteBillBounds(spawns, gd) 联防: per leaker, the most survivors settlement can bill (sent in + the
// offspring bound) — the live counter's clamp
// syntheticResult(players, progress) stand-in when a boss field's client never reported
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js';
import { diyTokenOwner, diyRecordOf } from '../../shared/diy.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
export const GAME_SPEED = 2;
export const HARD_CAP_SECONDS = 3700;
const SNAP_EVERY = Number.isInteger(SNAPSHOT_EVERY) && SNAPSHOT_EVERY > 0 ? SNAPSHOT_EVERY : 3;
/** Catch-up cap per pacing interval: 8 ticks at the normal 2× speed, proportionally more when sped up. */
export function maxTicksPerInterval(speed) {
return Math.max(MAX_TICKS_PER_INTERVAL, Math.ceil((Number(speed) || GAME_SPEED) * 4));
}
/**
* Wire frame of a snapshot. Every frame is `{ t: '<type>', …payload }`, so the snapshot's game time (DESIGN §8.2)
* travels as `gt` (game seconds) — the `t` key is the frame type 'b.snap'. `b.ev` frames carry the same `gt`.
*/
export function snapFrame(fieldId, snap) {
const { t: gt, ...rest } = snap || {};
return { ...rest, t: 'b.snap', fieldId, gt: typeof gt === 'number' && Number.isFinite(gt) ? gt : 0 };
}
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
export function emptyPerPlayer() {
return { killed: 0, total: 0, resolved: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
}
/** A finished stand-in for a battle that failed to construct. */
export class DeadBattle {
constructor(opts, reason = 'forced') {
this.fieldId = opts.fieldId ?? null;
this.kind = opts.kind ?? 'normal';
this.rect = opts.rect ?? null;
this.stageId = opts.stageId ?? null;
this.finished = true;
this.time = 0;
this.tickCount = 0;
this.errorCount = 1;
const perPlayer = {};
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
this.errors = [];
}
step() {}
forceEnd() {}
result() { return this._result; }
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0, resolved: 0 }; }
drainEvents() { return []; }
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
on() { return null; }
off() {}
}
export class FieldRunner {
/**
* @param {import('./Match.js').Match} m
* @param {Array<{ fieldId: string, kind: string, players: string[], battle: any, live?: boolean }>} fields
* @param {{ onTick?: (runner: FieldRunner) => void, onDone: (runner: FieldRunner) => void }} hooks
*/
constructor(m, fields, { onTick = null, onDone, emit = true }) {
this.m = m;
this.fields = fields;
this.emit = emit !== false;
for (const f of fields) f.live = !f.battle.finished;
this.onTick = onTick;
this.onDone = onDone;
this.ticks = 0;
this.acc = 0;
this.last = 0;
this.interval = null;
this.done = false;
this.stopped = false;
this.crashes = 0;
}
/** Game seconds elapsed on the master clock (all fields step in lockstep). */
get time() { return this.ticks * TICK; }
start() {
if (this.fields.every((f) => !f.live)) { this._finish(); return; }
if (this.m.sched.instant) {
// one scheduler callback: run everything now (virtual time / tools)
this.m.later(0, () => this._runInstant());
return;
}
this.last = this.m.sched.now();
this.interval = this.m.sched.setInterval(() => this.m.guard(() => this._pump()), INTERVAL_MS);
}
stop() {
this.stopped = true;
if (this.interval) { this.m.sched.clearInterval(this.interval); this.interval = null; }
}
_runInstant() {
const cap = Math.ceil(HARD_CAP_SECONDS / TICK);
while (!this.stopped && !this.done && this.ticks < cap) {
this._tick();
if (this.fields.every((f) => !f.live)) break;
}
if (!this.done && !this.stopped) this._forceAll('timeout');
this._checkDone();
}
_pump() {
if (this.stopped || this.done) return;
const now = this.m.sched.now();
const dt = Math.max(0, now - this.last);
this.last = now;
if (this.m.paused) return; // solo pause (Match.setPause): the field clock stands still
const speed = Number.isFinite(this.m.gameSpeed) && this.m.gameSpeed > 0 ? this.m.gameSpeed : GAME_SPEED;
this.acc += (dt / 1000) * speed;
const cap = maxTicksPerInterval(speed);
let n = Math.floor(this.acc / TICK + 1e-9);
if (n > cap) { n = cap; this.acc = 0; } else this.acc -= n * TICK;
for (let i = 0; i < n && !this.done && !this.stopped; i++) {
this._tick();
if (this.fields.every((f) => !f.live)) break;
}
if (this.time >= HARD_CAP_SECONDS) this._forceAll('timeout');
this._checkDone();
}
_tick() {
this.ticks++;
for (const f of this.fields) {
if (!f.live) continue;
const b = f.battle;
try {
b.step();
} catch (e) {
this.crashes++;
this.m.reportError(`field ${f.fieldId} step`, e);
this._forceField(f, 'timeout');
}
if (b.finished) { f.live = false; this.m.markPublic(); }
if (this.ticks % SNAP_EVERY === 0 || !f.live) this._emit(f);
}
if (this.onTick) {
try { this.onTick(this); } catch (e) { this.m.reportError('field onTick', e); }
}
for (const f of this.fields) if (f.live && f.battle.finished) { f.live = false; this.m.markPublic(); this._emit(f); }
}
_forceField(f, reason) {
try {
f.battle.forceEnd(reason);
} catch (e) {
this.m.reportError(`field ${f.fieldId} forceEnd`, e);
}
if (!f.battle.finished) {
// the battle object is unusable: replace it by a finished stand-in (players keep a clean result)
const dead = new DeadBattle({ fieldId: f.fieldId, kind: f.kind, players: f.players.map((playerId) => ({ playerId })) }, 'forced');
f.battle = dead;
}
f.live = false;
this.m.markPublic();
}
/** Force-end every live field (team LP 0, hard cap). */
_forceAll(reason) {
for (const f of this.fields) if (f.live || !f.battle.finished) this._forceField(f, reason);
}
forceAll(reason = 'forced') { this._forceAll(reason); this._checkDone(); }
_emit(f) {
let ev = [];
try { ev = f.battle.drainEvents() || []; } catch (e) { this.m.reportError(`field ${f.fieldId} drainEvents`, e); }
if (!this.emit) return;
const watchers = this.m.watchersOf(f.fieldId);
if (!watchers.length) return;
let snapMsg = null;
try { snapMsg = snapFrame(f.fieldId, f.battle.snapshot()); } catch (e) { this.m.reportError(`field ${f.fieldId} snapshot`, e); }
const time = Number(f.battle.time);
const gt = snapMsg ? snapMsg.gt : Number.isFinite(time) ? time : 0;
const evMsg = ev.length ? { t: 'b.ev', fieldId: f.fieldId, gt, ev } : null;
for (const pid of watchers) {
if (evMsg) this.m.sendTo(pid, evMsg);
if (snapMsg) this.m.sendTo(pid, snapMsg);
}
}
_checkDone() {
if (this.done || this.stopped) return;
if (this.fields.some((f) => f.live && !f.battle.finished)) return;
this._finish();
}
_finish() {
if (this.done) return;
this.done = true;
if (this.interval) { this.m.sched.clearInterval(this.interval); this.interval = null; }
this.onDone(this);
}
/** Result of a field (never throws). */
resultOf(f) {
try {
const r = f.battle.result();
if (r && typeof r === 'object' && r.perPlayer) return r;
} catch (e) {
this.m.reportError(`field ${f.fieldId} result`, e);
}
const perPlayer = {};
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
}
}
// =====================================================================================================================
// client-side combat (DESIGN §14)
/** Real-time grace after a battle's time limit before the server takes a silent client's field over (ms). */
export const RESULT_GRACE_MS = 15_000;
/** A boss field whose authoritative client sent no b.progress for this long is handed over (ms). */
export const BOSS_SILENCE_MS = 12_000;
/** Game-time spacing of the progress timeline samples of a server-run field (s). */
export const TIMELINE_EVERY = 1;
/** Wall-clock budget of one headless slice under a real scheduler (ms): the host's event loop stays responsive. */
export const HEADLESS_SLICE_MS = 8;
/** Extra ticks per pacing interval while a server-paced boss field catches up to its clock (takeover). */
export const CATCHUP_TICKS_PER_INTERVAL = 240;
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/**
* A timeline sample of a battle: [gt, killed, total, resolved], plus — 联防 — the leakers' enemies still standing at
* index 4 (spec.js uniteLeft; omitted when unknown). `resolved` is the HUD capsule's numerator of that game second
* (Battle.resolved: the field's own scheduled enemies knocked out or leaked) — the server-run / bot fields have no
* authority that reports b.progress, so the teammates' capsule reads it from here, never from `progress.leaks`.
*/
export function timelineSample(b) {
const total = Number(b && b.total) || 0;
const resolved = Number.isFinite(b && b.resolved) ? Math.max(0, Math.min(total, b.resolved)) : null;
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, total, resolved];
if (b && b.kind === 'unite') {
let left = null;
try { left = uniteLeft(b); } catch { left = null; }
if (left) s.push(left);
}
return s;
}
/**
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
* runs (timelineSample: [gt, killed, total, resolved(, left)] every TIMELINE_EVERY game seconds, then the final state);
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
*/
export class HeadlessJob {
constructor(battle, { onError = null, players = [] } = {}) {
this.battle = battle;
this.onError = onError;
this.players = players;
this.timeline = [timelineSample(battle)];
this.every = Math.max(1, Math.round(TIMELINE_EVERY / TICK));
this.cap = Math.ceil(HARD_CAP_SECONDS / TICK);
this.n = 0;
this.crashed = false;
this.done = false;
this._out = null;
}
run(budgetMs = Infinity, now = perfNow) {
if (this.done) return true;
const timed = Number.isFinite(budgetMs);
const t0 = timed ? now() : 0;
let b = this.battle;
try {
let k = 0;
while (!b.finished && this.n < this.cap) {
b.step();
this.n++;
k++;
if (this.n % this.every === 0) this.timeline.push(timelineSample(b));
if (timed && (k & 15) === 0 && !b.finished && now() - t0 >= budgetMs) return false;
}
if (!b.finished) b.forceEnd('timeout');
} catch (e) {
this.crashed = true;
if (this.onError) this.onError(e);
try { b.forceEnd('timeout'); } catch { /* replaced below */ }
if (!b.finished) b = this.battle = new DeadBattle({ fieldId: b.fieldId, kind: b.kind, players: this.players.map((playerId) => ({ playerId })) }, 'forced');
}
this._finish();
return true;
}
_finish() {
const b = this.battle;
let result = null;
try { result = b.result(); } catch (e) { if (this.onError) this.onError(e); }
if (!result || typeof result !== 'object' || !result.perPlayer) {
const perPlayer = {};
for (const pid of this.players) perPlayer[pid] = emptyPerPlayer();
result = { time: Number(b.time) || 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
}
this.timeline.push(timelineSample(b));
this.done = true;
this._out = { battle: b, result, timeline: this.timeline, crashed: this.crashed };
}
output() { return this._out; }
}
/**
* Step a battle to its end right now (HeadlessJob in one go). Returns `{ battle, result, timeline, crashed }`.
*/
export function runHeadless(battle, opts = {}) {
const job = new HeadlessJob(battle, opts);
job.run(Infinity);
return job.output();
}
/** Timeline sample at game time `gt`: [gt, killed, total, resolved(, left)] of the last sample ≤ gt. */
export function timelineAt(timeline, gt) {
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0, null];
let lo = 0, hi = timeline.length - 1;
if (gt >= timeline[hi][0]) return timeline[hi];
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (timeline[mid][0] <= gt) lo = mid; else hi = mid - 1;
}
return timeline[lo];
}
/**
* Real-time pacing of server-run battles without snapshots (DESIGN §14: boss fields that share the pool while other
* fields run on clients). `add({ battle, onDone(entry), onTick?(entry) })`; `skipTo(entry, gt)` fast-forwards a newly
* added battle silently (takeover). Same accumulator / cap rules as FieldRunner.
*/
export class HeadlessPacer {
constructor(m) {
this.m = m;
/** @type {Set<{ battle: any, onDone: Function, onTick?: Function, done?: boolean }>} */
this.entries = new Set();
this.interval = null;
this.acc = 0;
this.last = 0;
this.stopped = false;
}
add(entry) {
if (this.stopped || !entry || !entry.battle) return entry;
this.entries.add(entry);
if (!this.interval) {
this.last = this.m.sched.now();
this.acc = 0;
this.interval = this.m.sched.setInterval(() => this.m.guard(() => this._pump()), INTERVAL_MS);
}
return entry;
}
/**
* Fast-forward an entry's battle silently until its game time reaches `gt` (bounded). `budgetTicks` (default: all at
* once) spreads it over the pacing intervals instead — at most that many extra ticks per interval on top of the
* normal pacing — so a takeover late in a boss fight never stalls the host's event loop.
*/
skipTo(entry, gt, { budgetTicks = Infinity } = {}) {
const cap = Math.ceil(Math.min(Number(gt) || 0, HARD_CAP_SECONDS) / TICK);
if (Number.isFinite(budgetTicks) && budgetTicks > 0) {
entry.skipTicks = cap;
entry.skipBudget = Math.max(1, Math.floor(budgetTicks));
entry.skipped = 0;
return;
}
this._skip(entry, cap, Infinity);
}
/** Silent steps toward `cap` ticks (at most `max`); returns the steps taken. */
_skip(entry, cap, max) {
const b = entry.battle;
let k = 0;
try {
while (!b.finished && b.tickCount < cap && k < max) { b.step(); k++; }
} catch (e) {
this.m.reportError('pacer skip', e);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
try { b.drainEvents?.(); } catch { /* ignore */ }
if (b.finished) this._finish(entry);
return k;
}
remove(entry) { this.entries.delete(entry); if (!this.entries.size) this._clear(); }
stop() { this.stopped = true; this._clear(); this.entries.clear(); }
_clear() { if (this.interval) { this.m.sched.clearInterval(this.interval); this.interval = null; } }
_pump() {
if (this.stopped) return;
const now = this.m.sched.now();
const dt = Math.max(0, now - this.last);
this.last = now;
if (this.m.paused) return; // solo pause (Match.setPause): the field clock stands still
const speed = Number.isFinite(this.m.gameSpeed) && this.m.gameSpeed > 0 ? this.m.gameSpeed : GAME_SPEED;
this.acc += (dt / 1000) * speed;
const cap = maxTicksPerInterval(speed);
let n = Math.floor(this.acc / TICK + 1e-9);
if (n > cap) { n = cap; this.acc = 0; } else this.acc -= n * TICK;
for (const e of [...this.entries]) {
if (this.stopped) break;
if (e.done) continue;
const b = e.battle;
if (e.skipTicks != null) {
// sliced fast-forward (takeover): the field clock keeps running, so the target moves with the pacing
e.skipTicks += n;
e.skipped += this._skip(e, e.skipTicks, e.skipBudget + n);
if (e.done) continue;
if (b.tickCount >= e.skipTicks) e.skipTicks = null;
if (e.onTick) { try { e.onTick(e); } catch (err) { this.m.reportError('pacer onTick', err); } }
continue;
}
for (let i = 0; i < n && !b.finished; i++) {
try { b.step(); } catch (err) {
this.m.reportError('pacer step', err);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
break;
}
if (b.time >= HARD_CAP_SECONDS) { try { b.forceEnd('timeout'); } catch { /* ignore */ } }
}
if (e.onTick) { try { e.onTick(e); } catch (err) { this.m.reportError('pacer onTick', err); } }
if (b.finished) this._finish(e);
}
}
_finish(e) {
if (e.done) return;
e.done = true;
this.entries.delete(e);
if (!this.entries.size) this._clear();
try { e.onDone(e); } catch (err) { this.m.reportError('pacer onDone', err); }
}
}
/** Synthetic per-player results of a field whose client never reported (boss fields only; no LP relevance). */
export function syntheticResult(players, { bossBy = {}, time = 0 } = {}) {
const perPlayer = {};
for (const pid of players) perPlayer[pid] = { ...emptyPerPlayer(), bossDamage: Math.max(0, Number(bossBy[pid]) || 0) };
return { time, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 0, synthetic: true };
}
// ---- result validation ------------------------------------------------------------------------------------------
const derivedCache = new WeakMap();
const ENEMY_KEY_RE = /enemy_[A-Za-z0-9_]+/g;
/** Enemy keys a spawned enemy's data record mentions (summons, splits, transformations): content may spawn them. */
function derivedKeys(gd, key) {
if (!gd || typeof gd.enemy !== 'function') return [];
let m = derivedCache.get(gd);
if (!m) { m = new Map(); derivedCache.set(gd, m); }
if (m.has(key)) return m.get(key);
const out = new Set();
const rec = gd.enemy(key);
if (rec) {
let text = '';
try { text = JSON.stringify(rec); } catch { text = ''; }
for (const k of text.match(ENEMY_KEY_RE) || []) if (k !== key && gd.enemy(k)) out.add(k);
}
const list = [...out];
m.set(key, list);
return list;
}
/**
* How many `child` enemies one `parent` can leave behind, when its data says so: a talent naming the child
* (`<X>.enemy_key`) with a count (`<X>.cnt`, e.g. 磨砻 DeadSpawn 2, 烹泉 4, 沉沙's unconsumed blades ≤ 4) or without one
* (DeathRattle: the 术师 of a 术师快艇 = 1) — the sim's kitDeathSpawn / kitBlades / DeathRattle kits. null when the data
* gives no such bound (periodic summoners such as 枯朽萃聚使徒's BornBugs, keys a record merely mentions).
*/
function offspringPerParent(gd, parent, child) {
const rec = gd && typeof gd.enemy === 'function' ? gd.enemy(parent) : null;
const t = rec && rec.talents && typeof rec.talents === 'object' ? rec.talents : null;
if (!t || !t.bbStr || typeof t.bbStr !== 'object') return null;
let n = null;
for (const [k, v] of Object.entries(t.bbStr)) {
if (v !== child || !k.endsWith('.enemy_key')) continue;
const prefix = k.slice(0, -'.enemy_key'.length);
const cnt = Number(t.bb && t.bb[`${prefix}.cnt`]);
if (Number.isFinite(cnt) && cnt > 0) n = (n || 0) + Math.max(1, Math.ceil(cnt));
else if (prefix === 'DeathRattle') n = (n || 0) + 1;
else return null; // a countless summon talent (a boss's endless 余音): no bound from data
}
return n;
}
const summonCache = new WeakMap();
function summonIndex(gd) {
let idx = summonCache.get(gd);
if (idx) return idx;
idx = { byOwner: new Map(), ownerless: [] };
const tokens = gd.raw && gd.raw.tokens && typeof gd.raw.tokens === 'object' ? gd.raw.tokens : {};
for (const [id, t] of Object.entries(tokens)) {
const owners = t && Array.isArray(t.owners) ? t.owners : [];
if (!owners.length) idx.ownerless.push(id);
for (const o of owners) { if (!idx.byOwner.has(o)) idx.byOwner.set(o, []); idx.byOwner.get(o).push(id); }
}
summonCache.set(gd, idx);
return idx;
}
/** Summon (token) ids a chess (normal or elite) can create: its data `tokens` + tokens naming it an owner. */
function summonsOf(gd, chessId) {
if (typeof gd.chess !== 'function' || typeof chessId !== 'string') return [];
const rec = gd.chess(chessId);
const out = new Set(rec && Array.isArray(rec.tokens) ? rec.tokens : []);
const idx = summonIndex(gd);
for (const id of [chessId, rec && rec.baseId, rec && rec.goldenId]) for (const t of (id && idx.byOwner.get(id)) || []) out.add(t);
return [...out];
}
/**
* Summon ids a 自选 piece (0.2.0: a DIY slot `slotId` with its `pick`) can create: data/backups.json tokens whose owners
* name the pick's owner form (`<charId>@<statusKey>`, shared/diy.js diyTokenOwner).
*/
function diySummonsOf(gd, slotId, pick) {
const tokens = gd.raw && gd.raw.backups && gd.raw.backups.tokens && typeof gd.raw.backups.tokens === 'object' ? gd.raw.backups.tokens : null;
const slot = typeof gd.chess === 'function' && typeof slotId === 'string' ? gd.chess(slotId) : null;
if (!tokens || !slot || !pick || typeof pick.charId !== 'string') return [];
const owner = diyTokenOwner(pick.charId, slot.status);
return Object.keys(tokens).filter((id) => Array.isArray(tokens[id] && tokens[id].owners) && tokens[id].owners.includes(owner));
}
/** Summons no chess owns (bond / band units every player may field: 炎佑, 预备干员-医疗, Touch). */
function ownerlessSummons(gd) { return summonIndex(gd).ownerless; }
const WORD_RE = /[A-Za-z][A-Za-z0-9_]*/g;
const recJson = (rec) => { try { return rec ? JSON.stringify(rec) : ''; } catch { return ''; } };
/**
* Bonds an IN_BATTLE layer gain of this player can name: its bond snapshot (every bond its lineup counts), plus bonds
* its band, its effects (机变 cards, 驻守 …), its units and their items mention (content grants layers to those —
* including referenced bonds outside the lineup snapshot). Anything else is a forged gain.
*/
function layerBondsOf(p, gd) {
const out = new Set(Object.keys(p.bonds && typeof p.bonds === 'object' ? p.bonds : {}));
if (typeof gd.bond !== 'function') return out;
const texts = [recJson(p.playerEffects), recJson(p.bonds)];
const band = p.bandId && typeof gd.band === 'function' ? gd.band(p.bandId) : null;
if (band) {
texts.push(recJson(band));
if (band.effectId && typeof gd.effect === 'function') texts.push(recJson(gd.effect(band.effectId)));
}
for (const u of Array.isArray(p.units) ? p.units : []) {
if (!u) continue;
if (u.kind === 'token') { if (typeof gd.token === 'function') texts.push(recJson(gd.token(u.tokenId))); continue; }
if (typeof gd.chess === 'function') texts.push(recJson(gd.chess(u.chessId)));
for (const it of Array.isArray(u.items) ? u.items : []) if (typeof gd.item === 'function') texts.push(recJson(gd.item(it)));
}
for (const t of texts) for (const w of t.match(WORD_RE) || []) if (!out.has(w) && gd.bond(w)) out.add(w);
return out;
}
/**
* What the player's IN_BATTLE layer 特质 can add to each bond in one battle on top of the flat 60 + 4·round (DESIGN
* §21.26): the traits of its units (garrisons with `bond_add_count` / `bond_add_count_multi`) and the ones their ADD_BOND
* traits hand out (`give_garrison_id`, counted for every operator of the player — "所有【X】" reaches them all), each on
* the bonds it names (`bond_by_id` ids; `bond_self` / `bond_actived_maxstack`: every bond of the player's snapshot and
* units), up to the per-battle cap the sim applies (content/garrisons/battle.js: `max_add_count_per_battle` — a handed-out
* trait's too, 华法琳's 7 / 14, GitHub #175). A trait the data gives no cap (初雪 / 银灰's freeze trait, 菲莱 / 百炼嘉维尔's per-skill
* 萨尔贡, 斯卡蒂's per-kill …) — or a 魔王, whose +extra on every trait gain counts toward no cap — leaves its bonds bounded
* only by the room under 999 (Infinity here): such boards legitimately gain hundreds of layers a battle.
* @returns {Map<string, number>} bondId → extra allowance (Infinity: uncapped)
*/
function layerAllowanceOf(p, gd) {
const out = new Map();
if (typeof gd.garrison !== 'function' || typeof gd.chess !== 'function' || typeof gd.bond !== 'function') return out;
const units = (Array.isArray(p.units) ? p.units : []).filter((u) => u && u.kind !== 'token' && typeof u.chessId === 'string');
const lineup = new Set(Object.keys(p.bonds && typeof p.bonds === 'object' ? p.bonds : {}));
for (const u of units) for (const b of gd.chess(u.chessId)?.bonds || []) lineup.add(b);
let extra = false;
const credit = (g, times) => {
const bb = g.bb || {};
if (Number.isFinite(bb.extra_cnt)) { extra = true; return; }
if (!Number.isFinite(bb.bond_add_count) && !Number.isFinite(bb.bond_add_count_multi)) return;
const s = g.bbStr || {};
const bonds = s.bond_type === 'bond_by_id' ? String(s.bond_id ?? '').split(',').map((x) => x.trim()).filter((x) => gd.bond(x)) : [...lineup];
const cap = Number(bb.max_add_count_per_battle) > 0 ? Number(bb.max_add_count_per_battle) : Infinity;
for (const b of bonds) out.set(b, (out.get(b) || 0) + cap * times);
};
for (const u of units) {
for (const gid of gd.chess(u.chessId)?.garrisonIds || []) {
const g = gd.garrison(gid);
if (!g || g.eventType !== 'IN_BATTLE') continue;
if (g.effectKey !== 'ADD_BOND') { credit(g, 1); continue; }
const given = gd.garrison(g.bbStr?.give_garrison_id);
if (given && given.eventType === 'IN_BATTLE') credit(given, units.length);
}
}
if (extra) for (const b of out.keys()) out.set(b, Infinity);
return out;
}
/**
* Bounds of a battle derived from its spec: spawn counts per enemy key, keys content may add, bounty coins, the
* player ids and each player's unit uids.
*/
export function specBounds(spec, gd = null) {
const keyCounts = new Map();
/** `${enemyKey}|${sourcePlayerId}` → scheduled count (联防: whose leak re-enters) */
const keySourceCounts = new Map();
/** spawn keys whose schedule entries may leak without costing LP (countInTotal false, boss / part tags) */
const uncountedKeys = new Set();
let spawnCount = 0;
let bountyCoins = 0;
const sources = new Set();
for (const s of Array.isArray(spec && spec.spawns) ? spec.spawns : []) {
if (!s || typeof s.enemyKey !== 'string') continue;
const n = Math.max(1, Math.min(10_000, Math.floor(Number(s.count) || 1)));
spawnCount += n;
keyCounts.set(s.enemyKey, (keyCounts.get(s.enemyKey) || 0) + n);
const ks = `${s.enemyKey}|${typeof s.sourcePlayerId === 'string' ? s.sourcePlayerId : ''}`;
keySourceCounts.set(ks, (keySourceCounts.get(ks) || 0) + n);
if (s.countInTotal === false || s.tag === 'boss' || s.tag === 'part') uncountedKeys.add(s.enemyKey);
const coins = Math.max(Number(s.bounty && s.bounty.coins) || 0, Number(s.mods && s.mods.bountyCoins) || 0);
if (coins > 0) bountyCoins += coins * n;
if (typeof s.sourcePlayerId === 'string') sources.add(s.sourcePlayerId);
}
const derived = new Set();
for (const k of keyCounts.keys()) for (const d of derivedKeys(gd, k)) derived.add(d);
const maxTotal = spawnCount * 4 + 100;
// 联防: `${childKey}|${sourcePlayerId}` → how many content-spawned children the enemies that leaker sent in can leave
// (offspringPerParent per parent; maxTotal when the data gives no bound) — a survivor of a split / summon is billed
// only to a leaker whose own enemies can have produced it
const derivedSourceCounts = new Map();
for (const s of Array.isArray(spec && spec.spawns) ? spec.spawns : []) {
if (!s || typeof s.enemyKey !== 'string' || typeof s.sourcePlayerId !== 'string') continue;
const n = Math.max(1, Math.min(10_000, Math.floor(Number(s.count) || 1)));
for (const d of derivedKeys(gd, s.enemyKey)) {
const per = offspringPerParent(gd, s.enemyKey, d);
const ks = `${d}|${s.sourcePlayerId}`;
derivedSourceCounts.set(ks, Math.min(maxTotal, (derivedSourceCounts.get(ks) || 0) + (per == null ? maxTotal : n * per)));
}
}
for (const k of keyCounts.keys()) {
const rec = gd && typeof gd.enemy === 'function' ? gd.enemy(k) : null;
if (rec && rec.notCountInTotal) uncountedKeys.add(k);
}
const players = new Map();
for (const p of Array.isArray(spec && spec.players) ? spec.players : []) {
if (!p || typeof p.playerId !== 'string') continue;
const chess = new Map();
const all = new Map();
const names = new Map();
// unit types this player can field: its board's chess / tokens, their summons, and the ownerless summons of
// bonds / bands (炎佑, 预备干员) — the only names a statistic of a unit created in battle (no board uid) may carry
const defIds = new Set(gd ? ownerlessSummons(gd) : []);
for (const u of Array.isArray(p.units) ? p.units : []) {
if (!u || !Number.isInteger(u.uid)) continue;
const defId = u.kind === 'token' ? u.tokenId : u.chessId;
all.set(u.uid, defId);
if (gd) {
const base = gd.chess?.(defId) || gd.token?.(defId);
const rec = u.standIn === true ? gd.standIn?.(defId) : u.diy && base?.isDiy ? diyRecordOf(base, u.diy, gd.raw) : base;
if (typeof rec?.name === 'string') names.set(u.uid, rec.name);
}
if (typeof defId === 'string') defIds.add(defId);
if (u.kind !== 'token') {
chess.set(u.uid, defId);
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t);
if (gd && u.diy) for (const t of diySummonsOf(gd, defId, u.diy)) defIds.add(t);
}
}
// the layers each bond starts the battle with (PlayerBattleInput.bonds): a gain never passes BOND_LAYER_CAP
const startLayers = new Map();
for (const [id, b] of Object.entries(p.bonds && typeof p.bonds === 'object' ? p.bonds : {})) {
const v = Number(b && b.layers);
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
}
players.set(p.playerId, { chess, all, names, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
}
const round = Number(spec && spec.round) || 0;
return {
keyCounts, keySourceCounts, derivedSourceCounts, uncountedKeys, derived, spawnCount, bountyCoins, sources, players,
// content spawns (splits, summons, boss minions) can add enemies beyond the schedule
maxTotal,
layerCap: 60 + 4 * round,
maxTime: spec && spec.timeLimit > 0 ? spec.timeLimit + 5 : HARD_CAP_SECONDS,
};
}
/**
* 联防: the most enemies settlement can bill each leaker — what it sent in plus what those enemies can leave behind
* (splits / summons within offspringPerParent, maxTotal without a data bound): validateClientResult's (key, leaker)
* budgets summed per leaker. Bounds the live counter (Match._uniteLeft; user playtest #6 item 7).
* @param {object[]} spawns the 联防 spawns (unite.js plan.leaked: { enemyKey, sourcePlayerId, … })
* @returns {Map<string, number>} leaker id → bound
*/
export function uniteBillBounds(spawns, gd = null) {
const B = specBounds({ spawns: Array.isArray(spawns) ? spawns : [] }, gd);
const out = new Map();
for (const [ks, n] of [...B.keySourceCounts, ...B.derivedSourceCounts]) {
const pid = ks.slice(ks.lastIndexOf('|') + 1);
if (pid) out.set(pid, (out.get(pid) || 0) + n);
}
return out;
}
const finiteIn = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
const sameMods = (a, b) => {
const ka = a && typeof a === 'object' ? Object.keys(a).filter((k) => a[k] !== undefined).sort() : [];
const kb = b && typeof b === 'object' ? Object.keys(b).filter((k) => b[k] !== undefined).sort() : [];
if (ka.length !== kb.length) return false;
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i] || a[ka[i]] !== b[kb[i]]) return false;
return true;
};
/**
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
* nobody else; killed ≤ bound and total ≤ bound (maxTotal: `killed` counts every counted knock-out — runtime splits
* and summons included — and may exceed `total`, which counts only the round's own scheduled enemies; PR #157 capsule
* semantics); each leaked enemy key exists in the spawns (multiset bound; keys content may
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* perfect consistent with the counted leaks; unit states only for the player's own units, within range. `resolved` (the
* HUD capsule's numerator): the result's own — the FIELD's, what the client's capsule showed — is kept as reported,
* clamped to the field's `total`, and is what m.public publishes (never the sum of the players': an enemy that crosses
* the midline of a two-half field is billed to one player's `total` and to the other's leak, so their own numbers
* clamp it to 0); the players' own (each clamped to their `total`) are the fallback of a result with none; absent when
* neither is complete.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
export function validateClientResult(spec, raw, { gd = null } = {}) {
const bad = (reason) => ({ ok: false, reason });
try {
if (!spec || !raw || typeof raw !== 'object' || !raw.perPlayer || typeof raw.perPlayer !== 'object') return bad('shape');
const B = specBounds(spec, gd);
const bossLike = spec.kind === 'boss' || spec.kind === 'hidden';
if (!['cleared', 'timeout', 'forced'].includes(raw.reason)) return bad('reason');
if (!finiteIn(raw.time, 0, B.maxTime)) return bad('time');
const pids = Object.keys(raw.perPlayer);
if (pids.length !== B.players.size || pids.some((pid) => !B.players.has(pid))) return bad('players');
const spawnMods = new Map();
for (const s of spec.spawns || []) {
if (!s || typeof s.enemyKey !== 'string') continue;
if (!spawnMods.has(s.enemyKey)) spawnMods.set(s.enemyKey, []);
spawnMods.get(s.enemyKey).push(s);
}
const leakCount = new Map();
// 联防: (enemyKey, leaker) budget shared by the helpers' leaks and the never-spawned re-entries
const sourceLeft = new Map(B.keySourceCounts);
const derivedLeft = new Map(B.derivedSourceCounts);
const takeFrom = (left, key, src) => {
const ks = `${key}|${src || ''}`;
const n = left.get(ks) || 0;
if (n <= 0) return false;
left.set(ks, n - 1);
return true;
};
const takeSource = (key, src) => takeFrom(sourceLeft, key, src);
// a content-spawned enemy (split, summon): billed only to a leaker who sent in a parent, within its offspring bound
const takeDerived = (key, src) => B.derived.has(key) && takeFrom(derivedLeft, key, src);
let coinsSum = 0;
const perPlayer = {};
for (const pid of pids) {
const p = raw.perPlayer[pid];
if (!p || typeof p !== 'object') return bad('player');
const own = B.players.get(pid);
// `killed` counts every counted knock-out — a runtime split child / summon too — while `total` counts only the
// enemies the round scheduled, so `killed ≤ total` no longer holds (PR #157 capsule semantics): bound it by
// maxTotal (the spec's content-spawn allowance) instead, exactly like `total`
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > B.maxTotal || p.total > B.maxTotal) return bad('counts');
const leaked = [];
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
const key = l.enemyKey;
if (gd && typeof gd.enemy === 'function' && !gd.enemy(key) && !B.keyCounts.has(key)) return bad('leak key');
const counted = l.counted !== false;
if (!bossLike) {
if (!B.keyCounts.has(key) && !B.derived.has(key)) return bad('leak key');
const n = (leakCount.get(key) || 0) + 1;
leakCount.set(key, n);
const bound = (B.keyCounts.get(key) || 0) + (B.derived.has(key) ? B.maxTotal : 0);
if (n > bound) return bad('leak multiset');
// a leak costs LP unless the enemy never counts (data notCountInTotal, countInTotal false, boss / part
// entries); content-spawned keys (summons, splits) may carry their own flag
if (!counted && !B.uncountedKeys.has(key) && !B.derived.has(key)) return bad('uncounted leak');
}
// mods / tag / source come from the spawn schedule (a leak re-enters 联防 with them). An enemy content spawned
// (a split, a summon) carries its parent's mods — the round multipliers, a bounty id — so the leak's mods may
// equal another schedule entry's: keep those (dropping them would re-enter it weaker and lose its bounty)
const cands = spawnMods.get(key) || [];
const match = cands.find((s) => sameMods(s.mods ?? null, l.mods ?? null)) || null;
const src = match || cands[0] || null;
const parent = match ? null : (spec.spawns || []).find((s) => s && typeof s.enemyKey === 'string' && sameMods(s.mods ?? null, l.mods ?? null)) || null;
const modsOf = (s) => (s && s.mods ? { ...s.mods } : null);
const mods = match ? modsOf(match) : parent ? modsOf(parent) : modsOf(src);
let sourcePlayerId = typeof l.sourcePlayerId === 'string' ? l.sourcePlayerId : null;
if (spec.kind === 'unite') {
if (!sourcePlayerId || !B.sources.has(sourcePlayerId)) sourcePlayerId = src && typeof src.sourcePlayerId === 'string' ? src.sourcePlayerId : null;
// whose LP a surviving enemy costs: never more of (key, leaker) than that leaker sent into the 联防 — or, for a
// split / summon, than the enemies that leaker sent in can leave behind
if (!takeSource(key, sourcePlayerId) && !takeDerived(key, sourcePlayerId)) return bad('leak source');
} else if (sourcePlayerId !== pid && !B.players.has(sourcePlayerId)) sourcePlayerId = pid;
const lpr = finiteIn(l.lpr, 0, 1000) ? l.lpr : 1;
const e = { enemyKey: key, mods, lpr, sourcePlayerId, tag: src ? (src.tag ?? null) : (typeof l.tag === 'string' ? l.tag : null), counted, spawned: true };
if (l.boss) e.boss = true;
leaked.push(e);
}
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
// `total` is the round's own scheduled enemies; the leaks may include content-spawned children (a split / summon),
// which the spec bounds by maxTotal (the same allowance `killed` is checked against above)
if (!bossLike && countedLeaks > p.total + B.maxTotal) return bad('leaks > total');
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
// ≤ 60 + 4·round + what the player's layer 特质 can add to the bond (layerAllowanceOf; uncapped ones: no flat
// bound), and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
const bound = Math.min(B.layerCap + (own.layerAllow.get(bondId) || 0), layerGainRoom(own.startLayers.get(bondId) || 0, Infinity));
if (!finiteIn(n, 0, bound)) return bad('layer bound');
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
if (n > 0) layerGains[bondId] = n;
}
const coins = Number(p.coins) || 0;
if (!finiteIn(coins, 0, B.bountyCoins + 1e-6)) return bad('coins');
coinsSum += coins;
const unitsEnd = [];
const seen = new Set();
for (const u of Array.isArray(p.unitsEnd) ? p.unitsEnd : []) {
// units the sim created during the battle (no board uid) or not on this player's board carry nothing the match uses
// (the board's operators and summon pieces: 联防 carries an operator's HP ratio and SP, a summon's SP — unite.js)
if (!u || !Number.isInteger(u.uid) || !own.all.has(u.uid) || seen.has(u.uid)) continue;
seen.add(u.uid);
if (!finiteIn(u.hpPct, 0, 1) || !finiteIn(u.sp, 0, 1e5)) return bad('unit state');
unitsEnd.push({ uid: u.uid, defId: own.all.get(u.uid), hpPct: u.hpPct, sp: u.sp, skillActive: !!u.skillActive, alive: !!u.alive && u.hpPct > 0 });
}
const unitStats = [];
for (const u of Array.isArray(p.unitStats) ? p.unitStats : []) {
if (!u) continue;
const onBoard = Number.isInteger(u.uid) && own.all.has(u.uid);
const defId = onBoard ? own.all.get(u.uid) : (typeof u.defId === 'string' ? u.defId : null);
if (!defId) continue;
// a unit created in battle (summon) is one this player's lineup can field — never another player's operator
if (!onBoard && gd && !own.defIds.has(defId)) continue;
const rec = gd ? (gd.chess?.(defId) || gd.token?.(defId)) : null;
if (gd && !rec) continue;
unitStats.push({
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: (onBoard && own.names.get(u.uid)) || (typeof rec?.name === 'string' ? rec.name : defId), kind: u.kind === 'token' ? 'token' : 'op',
dmg: Math.max(0, Number(u.dmg) || 0), kills: Math.max(0, Math.trunc(Number(u.kills) || 0)), heal: Math.max(0, Number(u.heal) || 0),
taken: Math.max(0, Number(u.taken) || 0), attacks: Math.max(0, Math.trunc(Number(u.attacks) || 0)),
});
}
const stat = (v) => (finiteIn(v, 0, 1e13) ? v : 0);
perPlayer[pid] = {
killed: p.killed, total: p.total, leaked, perfect: countedLeaks === 0, layerGains, coins,
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
unitsEnd, unitStats,
};
// the HUD capsule's numerator: kept only when the client reported one (an absent value must stay absent — the
// teammate UI then falls back to `killed`, never to a fabricated 0); clamped to the denominator
if (Number.isInteger(p.resolved) && p.resolved >= 0) perPlayer[pid].resolved = Math.min(p.total, p.resolved);
}
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
let ownResolved = 0, ownKnown = pids.length > 0;
for (const pid of pids) {
result.killed += perPlayer[pid].killed;
result.total += perPlayer[pid].total;
if (Number.isInteger(perPlayer[pid].resolved)) ownResolved += perPlayer[pid].resolved; else ownKnown = false;
}
// The FIELD's capsule numerator (Battle.resolved, what the client's own capsule showed), kept as reported and clamped
// to the field's total — NOT the sum of the players' own numbers: an enemy that spawns on one half and leaks on the
// other (the 联防 lane, the boss pair's crossing routes) is billed to one player's `total` and to the other's
// `leakedInTotal`, so each player's own min(total, …) clamps it away (0 and 0 for an enemy the field resolved). The sum
// is only the fallback of a result with no field-level number; none of either stays absent (the teammate UI then
// falls back to `killed`, never to a fabricated 0).
if (Number.isInteger(raw.resolved) && raw.resolved >= 0) result.resolved = Math.min(result.total, raw.resolved);
else if (ownKnown) result.resolved = Math.min(result.total, ownResolved);
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
const unspawned = [];
for (const u of raw.unspawned) {
if (!u || typeof u.enemyKey !== 'string' || !B.keyCounts.has(u.enemyKey)) return bad('unspawned');
const src = typeof u.sourcePlayerId === 'string' && B.sources.has(u.sourcePlayerId) ? u.sourcePlayerId : null;
if (!takeSource(u.enemyKey, src)) return bad('unspawned source');
unspawned.push({ enemyKey: u.enemyKey, sourcePlayerId: src, tag: typeof u.tag === 'string' ? u.tag : null, time: Number(u.time) || 0 });
}
if (unspawned.length > B.spawnCount) return bad('unspawned');
if (unspawned.length) result.unspawned = unspawned;
}
return { ok: true, result };
} catch (e) {
return bad(`exception: ${e && e.message}`);
}
}