feedback5 R2: Match.js → server/match/match/*.js, a pure move — the 167 class members (166 methods and the soloUntimed getter), verbatim with their comments, go to 16 per-concern method containers (platform, infra, messaging, views, watch, intents, pause, phases, spDraft, prep, combat, clientCombat, reports, unitePhase, bossRounds, settle) that Match.js installs on Match.prototype in a fixed order with class-method descriptors (a name defined twice throws); the constants the modules share go to match/common.js (FLOW_TICKER_PRIORITY, DELAYS, BAND_TURN_SECONDS re-exported by Match.js; OK, fail, BOSS_CLOCK_MS), GAME_TYPES to platform.js, the boss-round budgets / rates to bossRounds.js; Match.js keeps the header (+ a code-layout paragraph), the constructor-only constants and the class with its constructor. Section-header comments are replaced by the module headers. docs-consistency reads the method modules (matchText) and pins layerGainRoom in match/settle.js

This commit is contained in:
sganggs committed 2026-10-05 17:10:38 +08:00
1 parent 8ab6d021da
commit 1ca4936f0a
19 files changed
+3105 -2923

No files matched your search

+516
View File
@@ -0,0 +1,516 @@
// server/match/match/bossRounds.js — Match methods: the Final Assault / Hidden Core (the rules:
// server/match/finalAssault.js) — the boss fields and the shared pool; client-side boss fields (plausibility budgets on
// the field clock, cumulative credits, handovers, the boss clock, the end: pool 0 → victory, team LP 0 → defeat);
// b.pool; the merged team LP and its shares; the overtime drain; bounties after a boss field; then the Hidden Core or
// RESULT.
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, GEO } from '../../../shared/constants.js';
import { deriveSeed } from '../../sim/rng.js';
import { buildBossWave, bountySpawns } from '../waves.js';
import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js';
import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.js';
import { CreditPool } from '../finalAssault.js';
import { FLOW_TICKER_PRIORITY, BOSS_CLOCK_MS, DELAYS } from './common.js';
/** b.pool broadcasts at most this often (ms). */
const POOL_MIN_GAP_MS = 250;
/**
* Final Assault / Hidden Core under client-side combat: m.public (boss HP, team LP, field progress) at most this often
* (ms) — b.pool (≤ 4 Hz) already carries the exact pool / team LP, so a boss b.progress never re-marks the whole
* public state.
*/
const BOSS_PUBLIC_MS = 1000;
/** How long the match waits for a boss field's b.result after it forced the end (real ms, × timerScale). */
const BOSS_RESULT_GRACE_MS = 6000;
/**
* Plausibility of a boss field's client reports (b.progress bossDmg / leaks, the b.result damage), on the SERVER's
* field clock — so no single frame decides the Final Assault: the credited pool damage of one field stays ≤ the whole
* pool per BOSS_MIN_CLEAR_GS game seconds (20 % of the pool per game second; the balance model's fastest mean kills
* take ≈ 18–20 s at ≈ 5 %/s per field, docs/BALANCE.md §3), the credited LP cost ≤ BOSS_LP_BURST + BOSS_LP_PER_GS per
* game second (a leader's "扣除所有目标生命" comes after ≥ 200 s). Reports are cumulative: what exceeds the budget is
* credited later as the budget grows (the boss clock re-applies the latest report), never lost — a 'cleared' b.result
* whose report covers the pool waits for it too (`heldResult`; 999-layer kills take 2–4 game s), it is not handed over.
*/
const BOSS_MIN_CLEAR_GS = 5;
const BOSS_LP_BURST = 10;
const BOSS_LP_PER_GS = 1;
export class MatchBoss {
startFinalAssault(hidden) {
const alive = this.alivePlayers();
if (!alive.length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
this.phase = hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT;
this.lastResults = new Map();
if (!hidden) {
this.teamLp = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
for (const ps of alive) ps.lpAtFinal = Math.max(0, ps.lp);
}
const bossId = hidden ? this.hiddenBossId : this.bossId;
// BOSS_HIT tickers ("对敌方领袖造成的伤害超过20% / 50% / 80%"): the player's damage to THIS leader over its pool —
// the pool's own per-player tally, one pool per boss round. stats.bossDamage (the result's 领袖伤害) adds up both
// rounds, so it would credit the Final Assault's damage to the hidden leader ("隐藏boss还没打就出了50%播报").
const hitSteps = new Map();
const pool = new SharedBossPool(bossPoolHp(this.gd, bossId, alive.length), {
onHit: (pid, dmg) => {
const ps = this.players.get(pid);
if (!ps) return;
ps.stats.bossDamage += dmg;
const share = (pool.byPlayer.get(pid) || 0) / pool.maxHp;
const done = hitSteps.get(pid) || 0;
let reached = done;
BOSS_HIT_STEPS.forEach((s, i) => { if (share >= s) reached = Math.max(reached, i + 1); });
if (reached > done) {
hitSteps.set(pid, reached);
this.tickerFor('BOSS_HIT', [ps.name], { playerId: pid, param: String(BOSS_HIT_STEPS[reached - 1]) });
}
},
});
this.bossPool = pool;
const groups = pairPlayers(alive);
const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join());
this.fields = groups.map((g, i) => {
const solo = this.isSolo || g.length === 1;
const wave = reuse ? this.bossWaves[i].wave : buildBossWave(this.gd, this.rngWaves, this.factions, this.round, { bossId, solo });
// one spawn list per field, shared by the field's players' onBattleStart handlers (edit it in place)
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
// bounties with battles left (a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES) follow their player into the
// boss field, on the player's half
g.forEach((ps, j) => {
for (const b of bountySpawns(this.gd, this.round, wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: j === 0 ? 'L' : 'R' })) spawns.push(b);
});
const inputs = g.map((ps, j) => {
const input = ps.battleInput({ side: j === 0 ? 'L' : 'R', colOffset: j === 0 ? 0 : 8 });
input.lpForBoss = this.teamLp;
// `side` + `routes`: the player's half of a pair field (spawn-list edits for one player, e.g. 鸭爵's swap)
const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns, routes: wave.routes, side: g.length > 1 ? (j === 0 ? 'L' : 'R') : null };
this.dispatch(ps, 'onBattleStart', ev);
return ev.input && typeof ev.input === 'object' ? ev.input : input;
});
const fieldId = `b${i + 1}`;
const bopts = {
seed: deriveSeed(this.seed, `${fieldId}:${this.round}`),
kind: hidden ? 'hidden' : 'boss',
modeId: this.modeId,
round: this.round,
stageId: this.stageId,
rect: { ...GEO.BOSS_RECT },
timeLimit: Infinity,
players: inputs,
spawns: this._sanitizeSpawns(spawns),
routes: wave.routes,
sharedBoss: this.bossPool,
flags: { layerGainsEnabled: false, ...this.gd.dp },
fieldId,
enemyOverrides: wave.overrides,
waveId: wave.templateId,
bossId,
};
if (this.clientCombat) return { fieldId, kind: hidden ? 'hidden' : 'boss', players: g.map((p) => p.playerId), opts: bopts, battle: null, live: true };
const battle = this.newBattle(bopts);
try {
battle.on('enemyLeak', (ctx) => this._bossLeak(ctx && ctx.enemy), { priority: -1000, owner: 'match' });
// leader "扣除目标生命" effects (boss_7 Doom, 斥退 …: server/sim/content/bosses.js lpLoss) hit the team pool
battle.on('lpLoss', (ctx) => this._teamLpLoss(ctx && ctx.amount), { priority: -1000, owner: 'match' });
} catch (e) { this.reportError('boss leak hook', e); }
return { fieldId, kind: hidden ? 'hidden' : 'boss', players: g.map((p) => p.playerId), battle, live: true };
});
this.overtimeApplied = 0;
// HUD: the boss level's countdown (maxPlayTime, 120 real s — the battle goes on past it) and the moment the
// overtime drain starts (150 real s), both on the field clock
const onClock = (realS) => this.sched.now() + Math.round(((realS * this.gd.combatTimeScale) / this.gameSpeed) * 1000);
const levelTime = this.gd.bossLevelTime(this.round);
this.deadline = this.sched.instant || !levelTime ? 0 : onClock(levelTime);
this.overtimeAt = this.sched.instant ? 0 : onClock(this.gd.bossOvertimeAfterReal);
if (this.clientCombat) { this._startFinalClient(hidden); return; }
this._defaultWatch();
this.markPublic();
this.runner = new FieldRunner(this, this.fields, {
onTick: (runner) => this._bossTick(runner),
onDone: (runner) => this._finalDone(runner, hidden),
});
this.runner.start();
}
_startFinalClient(hidden) {
const pool = this.bossPool;
const recs = this.fields.map((x) => this._ccField({ fieldId: x.fieldId, kind: x.kind, players: x.players, opts: x.opts, boss: { poolHp: pool.hp, poolMax: pool.maxHp } }));
this._finalEnding = null;
this._bossStartAt = this.sched.now();
this.watchers.clear();
if (!recs.some((f) => this._authorityFor(f))) {
// nobody to simulate on a client: the server runs every boss field (FieldRunner, no streaming)
this.fields = recs;
for (const f of recs) {
f.mode = 'server';
// a CreditPool with nothing acknowledged credits everything; it counts this field's damage for b.pool `acked`
f.credit = new CreditPool(pool);
f.battle = this._specBattle(f.spec, { sharedBoss: f.credit });
try {
f.battle.on('enemyLeak', (ctx) => this._bossLeak(ctx && ctx.enemy), { priority: -1000, owner: 'match' });
f.battle.on('lpLoss', (ctx) => this._teamLpLoss(ctx && ctx.amount), { priority: -1000, owner: 'match' });
} catch (e) { this.reportError('boss leak hook', e); }
}
this._watchBossFields(recs);
this.markPublic();
this.runner = new FieldRunner(this, recs, {
onTick: (runner) => this._bossTick(runner),
onDone: (runner) => this._finalDone(runner, hidden),
emit: false,
});
this.runner.start();
return;
}
this._launch(recs);
this._watchBossFields(recs);
this.markPublic();
this._broadcastPool(true);
this._bossClockOn = true;
this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
}
/** The most shared-pool damage a boss field's client reports may have credited by now (server field clock). */
_bossDmgBudget(f) {
return this.bossPool ? (this.bossPool.maxHp * this._fieldElapsed(f)) / BOSS_MIN_CLEAR_GS : 0;
}
/** The most team LP a boss field's client reports may have cost by now (server field clock). */
_bossLpBudget(f) {
return BOSS_LP_BURST + BOSS_LP_PER_GS * this._fieldElapsed(f);
}
/**
* A boss field's shared-pool damage, as reported cumulatively by its client (`by`: per player) — credited up to the
* plausibility budget of the field clock; the rest is credited by later reports / the boss clock (`bossReported`).
*/
_creditBoss(f, cum, by) {
const pool = this.bossPool;
const reported = Number(cum);
// the team LP ran out first: the run failed at that moment (PRTS "…使目标生命值扣除至0,则无视倒计时直接失败"), so
// damage reported afterwards — in flight, or rounded up in the final b.result — changes nothing (user playtest #6)
if (!pool || !Number.isFinite(reported) || this._finalEnding === 'forced') return;
if (!f.bossReported || reported > f.bossReported.cum) f.bossReported = { cum: reported, by: by && typeof by === 'object' ? { ...by } : null };
const c = Math.min(reported, this._bossDmgBudget(f));
if (!(c > f.bossAcked)) return;
const delta = c - f.bossAcked;
f.bossAcked = c;
let attributed = 0;
if (by && typeof by === 'object') {
for (const pid of f.players) {
const v = Number(by[pid]);
const prev = f.bossBy[pid] || 0;
if (!Number.isFinite(v) || v <= prev) continue;
const d = Math.min(delta - attributed, v - prev);
if (d > 0) { f.bossBy[pid] = prev + d; pool.damage(pid, d); attributed += d; }
}
}
if (delta - attributed > 0) {
const pid = by ? null : f.players[0];
if (pid) f.bossBy[pid] = (f.bossBy[pid] || 0) + (delta - attributed);
pool.damage(pid, delta - attributed);
}
}
/** A boss field's LP cost (leaks × lpr + leader LP effects): `cumulative` totals from its client, deltas from the server run. */
_creditLp(f, amount, cumulative = false) {
let n = Number(amount);
if (!Number.isFinite(n) || n <= 0) return;
if (cumulative) {
// a client's cumulative report: credited up to the field clock's plausibility budget (the rest later)
if (n > f.lpReported) f.lpReported = n;
n = Math.min(n, this._bossLpBudget(f));
if (n <= f.lpAcked) return;
const d = n - f.lpAcked;
f.lpAcked = n;
this._teamLpLoss(d);
return;
}
const before = f.lpCum;
f.lpCum += n;
const credit = Math.max(0, f.lpCum - Math.max(before, f.lpAcked));
if (credit > 0) this._teamLpLoss(credit);
}
/** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */
_bossServerRun(f) {
const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy });
const battle = this._specBattle(f.spec, { sharedBoss: credit });
f.battle = battle;
f.credit = credit;
f.lpCum = 0;
try {
battle.on('enemyLeak', (ctx) => { const e = ctx && ctx.enemy; if (e) this._creditLp(f, Number.isFinite(e.lpr) && e.lpr >= 0 ? e.lpr : 1); }, { priority: -1000, owner: 'match' });
battle.on('lpLoss', (ctx) => this._creditLp(f, ctx && ctx.amount), { priority: -1000, owner: 'match' });
} catch (e) { this.reportError('boss leak hook', e); }
if (!this.pacer) this.pacer = new HeadlessPacer(this);
const entry = this.pacer.add({
battle,
onDone: () => {
if (f.done) return;
let res = null;
try { res = battle.result(); } catch (e) { this.reportError('boss result', e); }
f.result = res && res.perPlayer ? res : syntheticResult(f.players, { bossBy: f.bossBy });
f.resultSource = 'server';
this._fieldDone(f);
this._checkFinalEnd();
},
onTick: () => this._checkFinalEnd(),
});
const gt = this._fieldElapsed(f);
// the fast-forward to the field clock is spread over the pacing intervals on a real host (virtual time: at once)
if (gt > 0 && !entry.done) this.pacer.skipTo(entry, gt, { budgetTicks: this.sched.virtual ? Infinity : CATCHUP_TICKS_PER_INTERVAL });
}
/**
* A boss field's authority left, went silent or sent an implausible result (`demote`: never its authority again):
* its partner (a replica already running) takes over, else the server.
*/
_bossHandover(f, why, { demote = false } = {}) {
if (f.done || f.mode !== 'client' || f.heldResult) return; // a held 'cleared' result is in: nothing left to run
const prev = f.authority;
if (demote && prev) f.demoted.add(prev);
const next = this._authorityFor(f, prev);
if (next) {
f.authority = next;
f.lastProgressAt = this.sched.now();
if (prev) this.sendTo(prev, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason: 'takeover' });
this._sendStart(next, f);
return;
}
this._runOnServer(f, why);
}
_bossClockTick() {
this._bossClock = null;
if (this.phase !== PHASE.FINAL_ASSAULT && this.phase !== PHASE.HIDDEN_CORE) return;
if (this.paused) return; // re-armed by _resume
const now = this.sched.now();
this._applyOvertime(((now - this._bossStartAt) / 1000) * this.gameSpeed);
for (const f of this.fields) {
// reports held back by the plausibility budget are credited as the field clock advances
if (f.cc && !f.done && f.mode === 'client') {
if (f.bossReported && f.bossReported.cum > f.bossAcked) this._creditBoss(f, f.bossReported.cum, f.bossReported.by);
if (f.lpReported > f.lpAcked) this._creditLp(f, f.lpReported, true);
}
if (f.cc && !f.done && f.mode === 'client' && !f.heldResult && now - f.lastProgressAt > BOSS_SILENCE_MS) {
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId}: no progress from ${f.authority} — handing the field over`);
this._bossHandover(f, 'silent');
}
}
this._checkFinalEnd();
this._broadcastPool(false);
if (this.fields.some((f) => f.cc && !f.done)) this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
else this._bossClockOn = false;
}
/** Pool depleted → victory; team LP 0 → defeat: every boss field is ended. */
_checkFinalEnd() {
if (this._finalEnding || !this.bossPool || !this.fields.some((f) => f.cc)) return;
if (this.phase !== PHASE.FINAL_ASSAULT && this.phase !== PHASE.HIDDEN_CORE) return;
if (this.bossPool.hp <= 0) this._endFinal('cleared');
else if (this.teamLp != null && this.teamLp <= 0) this._endFinal('forced');
}
_endFinal(reason) {
if (this._finalEnding) return;
this._finalEnding = reason;
this._broadcastPool(true);
for (const f of this.fields) {
if (!f.cc || f.done) continue;
if (f.mode === 'server' && f.battle) {
try { f.battle.forceEnd('forced'); } catch (e) { this.reportError('boss forceEnd', e); }
let res = null;
try { res = f.battle.result(); } catch (e) { this.reportError('boss result', e); }
f.result = res && res.perPlayer ? res : syntheticResult(f.players, { bossBy: f.bossBy });
f.resultSource = 'server';
continue; // _fieldDone below (after every field was told)
}
for (const pid of this._humansShowing(f)) this.sendTo(pid, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason });
if (f.heldResult) {
// its authority already reported 'cleared' (_onResult): that result completes the field
f.result = f.heldResult;
this._bossResultDamage(f, f.result);
f.resultSource = 'client';
continue; // _fieldDone below
}
f.waitTimer = this.later(this.scaled(BOSS_RESULT_GRACE_MS), () => {
f.waitTimer = null;
if (f.done) return;
f.result = syntheticResult(f.players, { bossBy: f.bossBy, time: this._fieldElapsed(f) });
this._fieldDone(f);
});
}
if (this.pacer) { this.pacer.stop(); this.pacer = null; }
for (const f of this.fields) if (f.cc && !f.done && (f.mode === 'server' || f.heldResult)) this._fieldDone(f);
}
/** b.pool { hp, max, teamLp, acked } to everyone (≤ 4 Hz; `force` skips the dedupe, never the rate). */
_broadcastPool(force) {
if (!this.bossPool || this.disposed) return;
const now = this.sched.now();
if (now - this._lastPoolAt < POOL_MIN_GAP_MS) {
if (!this._poolTimer) {
this._poolTimer = this.later(Math.max(1, POOL_MIN_GAP_MS - (now - this._lastPoolAt)), () => { this._poolTimer = null; this._broadcastPool(force); });
}
return;
}
// exact numbers: a client shows `hp − (its cumulative damage − acked)` and ends its battle as 'cleared' when that
// reaches 0 — rounded values could show 0 while the server's pool still holds a fraction of a point
// a server-run field (no client left, or none at all): what its battle has put into the pool so far — a display
// replica (a reconnecting / watching human) subtracts only its local damage beyond that from the server hp
const acked = {};
for (const f of this.fields) if (f.cc) acked[f.fieldId] = f.mode === 'server' && f.credit ? Math.max(f.bossAcked, f.credit.cum) : f.bossAcked;
const msg = {
t: 'b.pool', hp: Math.max(0, this.bossPool.hp), max: this.bossPool.maxHp,
teamLp: this.teamLp == null ? null : Math.max(0, Math.round(this.teamLp)), acked,
};
const key = JSON.stringify(msg);
if (!force && key === this._lastPoolKey) return;
this._lastPoolKey = key;
this._lastPoolAt = now;
this.broadcast(msg);
}
/**
* Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every
* bounty used one of its battles, exactly like SETTLE does for normal rounds.
*/
_settleBossBounties(ps, pp) {
const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
if (!ps.bounties.length) return;
for (const b of ps.bounties) b.roundsLeft--;
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
ps.dirty();
}
_bossLeak(enemy) {
if (!enemy || this.teamLp == null) return;
// lifePointReduce from data: 0 for harmless enemies (e.g. 装置 / unharmful escorts), 1 when absent
const lpr = Number.isFinite(enemy.lpr) && enemy.lpr >= 0 ? enemy.lpr : 1;
this._teamLpLoss(lpr);
}
_teamLpLoss(amount) {
const n = Number(amount);
if (this.teamLp == null || !Number.isFinite(n) || !(n > 0)) return;
this.teamLp = Math.max(0, this.teamLp - n);
if (this._bossLazyPublic()) { this._bossPublic(); return; }
this._syncTeamLp();
this.markPublic();
}
/** Boss rounds under client-side combat: m.public / the per-player LP shares refresh at ~1 Hz (b.pool is live). */
_bossLazyPublic() {
return this.clientCombat && (this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE) && this.fields.some((f) => f.cc);
}
/** Throttled (BOSS_PUBLIC_MS) refresh of m.public and the players' LP shares during a boss round. */
_bossPublic() {
if (this._bossPubTimer || this.disposed || this.ended) return;
const wait = Math.max(0, BOSS_PUBLIC_MS - (this.sched.now() - this._bossPubAt));
this._bossPubTimer = this.later(wait, () => {
this._bossPubTimer = null;
this._bossPubAt = this.sched.now();
this._syncTeamLp();
this.markPublic();
});
}
/**
* The merged team LP (Final Assault / Hidden Core, research 01 §9) written back to the alive players as shares of
* what each brought in (`lpAtFinal`, largest remainder, ties → seat), so m.public / m.private / m.result never show
* the pre-merge LP of a team that lost LP in the boss fight (Σ alive lp = round(teamLp)).
*/
_syncTeamLp() {
if (this.teamLp == null) return;
const alive = this.alivePlayers().filter((p) => p.lpAtFinal != null);
if (!alive.length) return;
const total = Math.max(0, Math.round(this.teamLp));
const base = alive.reduce((s, p) => s + Math.max(0, p.lpAtFinal), 0);
const rows = alive.map((p) => {
const v = base > 0 ? (total * Math.max(0, p.lpAtFinal)) / base : total / alive.length;
const n = Math.floor(v + 1e-9);
return { p, n, frac: v - n };
});
let left = total - rows.reduce((s, x) => s + x.n, 0);
for (const x of rows.slice().sort((a, b) => b.frac - a.frac || a.p.seat - b.p.seat)) {
if (left <= 0) break;
x.n++;
left--;
}
for (const x of rows) if (x.p.lp !== x.n) { x.p.lp = x.n; x.p.dirty(); }
}
/**
* Overtime drain on the boss field clock (`gt` game seconds): 1 team LP per real second from the 150 real-second mark
* (bossTurnHpReduceTime, gamedata.js bossOvertimeDue).
*/
_applyOvertime(gt) {
const due = this.gd.bossOvertimeDue(gt);
if (due > this.overtimeApplied) {
const loss = due - this.overtimeApplied;
this.overtimeApplied = due;
this._teamLpLoss(loss);
}
}
_bossTick(runner) {
this._applyOvertime(runner.time);
if (this.teamLp <= 0 && this.bossPool.hp > 0) runner.forceAll('forced');
// client-side combat with every boss field on the server: humans that reconnect / watch run display replicas
// whose LocalBossPool follows b.pool
if (this.fields.some((f) => f.cc)) { this._broadcastPool(false); this._bossPublic(); }
else if (runner.ticks % 6 === 0) this.markPublic();
if (runner.ticks % 30 === 0) this.flush();
}
_finalDone(runner, hidden) { this._finishFinal(hidden, (f) => runner.resultOf(f)); }
/** Every boss field is over: stats, bounties, then the Hidden Core or RESULT. */
_finishFinal(hidden, resultOf) {
if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return;
this._stopClientCombat();
for (const f of this.fields) {
const res = resultOf(f);
this._collectSimErrors(f, res);
f.live = false;
for (const pid of f.players) {
const pp = res.perPlayer && res.perPlayer[pid];
const ps = this.players.get(pid);
if (pp) this.lastResults.set(pid, pp);
if (pp && ps) {
ps.stats.dmgDealt += Number(pp.damageDealt) || 0;
ps.stats.kills += Number(pp.killed) || 0;
ps.dirty(); // m.private.stats
this._charDamageTickers(ps, pp);
}
if (pp && ps) this._settleBossBounties(ps, pp);
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
}
}
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
this._syncTeamLp();
this.deadline = 0;
this.overtimeAt = 0;
this.markPublic();
this.runner = null;
if (!hidden) {
const eligible = victory && !!this.hiddenBossId && hiddenEligible(this.gd, { layerSum: this.hiddenLayerSum, teamLp: this.teamLp });
this.later(this.scaled(DELAYS.SETTLE), () => {
if (eligible) {
this.hiddenReached = true;
this.bossPool = null;
this.tickerText('隐秘核心已解锁', FLOW_TICKER_PRIORITY);
this.startRound(this.gd.hiddenRound);
} else {
this.finish({ victory, reason: victory ? 'victory' : 'defeat' });
}
});
} else {
this.later(this.scaled(DELAYS.SETTLE), () => this.finish({ victory: true, hiddenCleared: victory, reason: 'victory' }));
}
}
}