器物 (the 14 hit-count keys): hits = data × the spawn's hpMul ÷ supplyHpMul, rounded as before (setHits) — 攻坚装备 / II / III leave out only 炎佑, 补给线 / 补给线II also exactly these 14 keys (activity_table aceffect_enemy_2 / 2_2 enemy_exclude, data/effects.json); until now setHits(def.maxHp) overwrote the round's HP (青铜镜 30 hits at every difficulty). build-data writes enemyScale[r].supplyHp = 1.2^(kHp − kAtk) · extra (the HP-only 补给线II steps and 补给线's 1.08; co-op 终极 R5–R15 only — the rebuild changed config.json alone), gamedata returns it as supplyHpMul and the wave / 悬赏 mods carry it (waves.js roundMods; a 频次 enemy's death spawn inherits it). Not taken from the PR: its 1.08 / 1.2 back-calculation (it counted the 补给线II steps as 攻坚装备) and its fractional-HP ceiling. Leader summons (死亡集群's 妖怪, 刺胄之弹, “裂管之音”/“断弦之音”, 不祥幻影): the boss / hidden field carries the round's scale as flags.enemyScale (bossRounds.js, golden fields, tools/balance.mjs) and bosses.js summonMods gives them HP / ATK / speed (死亡集群's summon.hp_ratio on top; 刺胄之弹 / 余音 hit counts follow their HP); the leader's pool stays exempt; 王权号令's invulnerable equipment unchanged; 炎佑 is an ally token, never scaled (no change). Unchanged and open: the 逐火 / 再生 husk counts (talent prop_max_hp). Golden — 器物 counts: roster 37 (002 003 004 005 007–020 022 023 025 026 029–034 036–041 043 045 049: each holds a 器物 extra spawn / 频次 wave / death spawn), bond-soloShip-high, diy 9 (036 040 056 062 068 073 091 114 115), boss-boss_6-solo (俗心's 小说卷轴 escorts); matches 16 (all but solo-ABYSS-2 and coop2-NORMAL-70-diy: 频次 rounds / 悬赏 and the Final Assault summons). Summons: boss-boss_1-pair/solo, hidden-boss_8-pair/solo (妖怪, 刺胄之弹), boss-boss_3-pair/solo, hidden-boss_10-pair/solo (余音), boss-boss_5-pair/solo (不祥幻影). standins unchanged.
517 lines
25 KiB
JavaScript
517 lines
25 KiB
JavaScript
// 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, CreditPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js';
|
||
import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.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,
|
||
// the round's enemy effects for the leaders' mid-fight summons (server/sim/content/bosses.js summonMods)
|
||
flags: { layerGainsEnabled: false, ...this.gd.dp, enemyScale: this.gd.enemyScale(this.round) },
|
||
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' }));
|
||
}
|
||
}
|
||
}
|