Add special-target activation without bypassing attack cadence, cooperative card preferences and emotes, skill sound banks, per-operator voice choices, text sizing, install prompts, closed-tab recovery and unanimous setup rerolls. Retain late deployment elapsed time and resolve echo/counter pulses without recursive hook overflow. Independent final review remains pending. Golden: roster-016, roster-035, roster-036, roster-045; boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair, hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence. diy-132: Necras extra activation targets. coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes. hidden-boss_10-solo: echo pulse FIFO resolution. See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
155 lines
7.8 KiB
JavaScript
155 lines
7.8 KiB
JavaScript
// server/match/match/unitePhase.js — Match methods: 联防 glue (the rules: server/match/unite.js) — the UNITE phase in
|
|
// both modes (the 联防 field from the helpers' carried state and the leakers' enemies), its end, and the leakers' live
|
|
// counts (_uniteLeft, user playtest #6 item 7; _uniteTick in the server-run mode).
|
|
// 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 { uniteBattleOpts, uniteSurvivors } from '../unite.js';
|
|
import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
|
|
import { uniteLeft } from '../../sim/spec.js';
|
|
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
|
|
import { msg } from '../../../shared/i18n.js';
|
|
import { onStartUnite } from '../botEmotes.js';
|
|
|
|
export class MatchUnite {
|
|
startUnite(plan) {
|
|
if (this.clientCombat) { this._startUniteClient(plan); return; }
|
|
this.phase = PHASE.UNITE;
|
|
this.unitePlan = plan;
|
|
const limit = this.wave ? this.wave.timeLimit : 60;
|
|
const battle = this.newBattle(this._uniteOpts(plan, limit));
|
|
this.fields = [{ fieldId: 'u', kind: 'unite', players: plan.helpers.map((p) => p.playerId), battle, live: true }];
|
|
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
|
this._defaultWatch();
|
|
this.markPublic();
|
|
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
|
|
onStartUnite(this, plan); // an AI helper says "合作愉快" once on the open of a 联防 (enabled by default; SP_BOT_EMOTES=0 silences it)
|
|
this._uniteLeftKey = null;
|
|
this.runner = new FieldRunner(this, this.fields, {
|
|
onTick: (runner) => this._uniteTick(runner),
|
|
onDone: (runner) => {
|
|
if (this.phase !== PHASE.UNITE) return;
|
|
const res = runner.resultOf(this.fields[0]);
|
|
this._collectSimErrors(this.fields[0], res);
|
|
this.fields[0].live = false;
|
|
this.deadline = 0;
|
|
this.markPublic();
|
|
this.later(this.scaled(DELAYS.COMBAT_END), () => this.settle(plan, res));
|
|
},
|
|
});
|
|
this.runner.start();
|
|
}
|
|
|
|
/**
|
|
* Battle options of the 联防 field (helpers' carried end state, the leakers' enemies) on the round's battlefield, its
|
|
* terrain, crates, water, devices and runes included (unite.js header; the owner's decision of 2026-10-07 — 0.2.0's
|
|
* escaped-level map is withdrawn). The field meta and the client-run spec carry the match stageId, so every viewer
|
|
* draws the battlefield the boards stand on.
|
|
*/
|
|
_uniteOpts(plan, limit) {
|
|
const { wave, players } = uniteBattleOpts(this, plan, limit);
|
|
return {
|
|
seed: deriveSeed(this.seed, `u:${this.round}`),
|
|
kind: 'unite',
|
|
modeId: this.modeId,
|
|
round: this.round,
|
|
stageId: this.stageId,
|
|
rect: { ...GEO.UNITE_RECT },
|
|
timeLimit: limit,
|
|
players,
|
|
spawns: this._sanitizeSpawns(wave.spawns),
|
|
routes: wave.routes,
|
|
sharedBoss: null,
|
|
flags: { layerGainsEnabled: false, ...this.gd.dp },
|
|
fieldId: 'u',
|
|
// leaked enemies re-enter with the stats they had: the round template's stat overrides apply again
|
|
enemyOverrides: this.wave && this.wave.overrides ? this.wave.overrides : {},
|
|
waveId: wave.templateId,
|
|
};
|
|
}
|
|
|
|
_startUniteClient(plan) {
|
|
this.phase = PHASE.UNITE;
|
|
this.unitePlan = plan;
|
|
const limit = this.wave ? this.wave.timeLimit : 60;
|
|
const f = this._ccField({ fieldId: 'u', kind: 'unite', players: plan.helpers.map((p) => p.playerId), opts: this._uniteOpts(plan, limit) });
|
|
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
|
this.watchers.clear();
|
|
this._launch([f]);
|
|
// helpers and everyone else (as observers, spectator seats included) simulate the same 联防 spec locally
|
|
for (const ps of this._viewers()) {
|
|
this.watchers.set(ps.playerId, 'u');
|
|
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
|
|
}
|
|
this.markPublic();
|
|
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
|
|
}
|
|
|
|
_finishUniteClient() {
|
|
if (this.phase !== PHASE.UNITE) return;
|
|
const f = this.fields[0];
|
|
const res = f.result;
|
|
this._collectSimErrors(f, res);
|
|
this._stopClientCombat();
|
|
f.live = false;
|
|
this.deadline = 0;
|
|
this.markPublic();
|
|
const plan = this.unitePlan;
|
|
this.later(this.scaled(DELAYS.COMBAT_END), () => this.settle(plan, res));
|
|
}
|
|
|
|
/**
|
|
* 联防 (user playtest #6 item 7; PRTS 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): how many of
|
|
* a leaker's enemies are still standing on the 联防 field — not spawned yet, alive, or through the objective again —
|
|
* plus its leaks that could not re-enter: what settle() charges it (before the per-round cap) if the 联防 ended now.
|
|
* It falls as the helpers strike them down and rises when one splits or summons (the children carry the leaker).
|
|
* Live from the field (client run: the authority's b.progress `left`; server run: the headless timeline on the field
|
|
* clock, or the streamed battle itself), clamped to what settlement can bill that leaker (fields.js uniteBillBounds:
|
|
* sent in + the offspring bound, validateClientResult's budget); exact once the field has its result (unite.js
|
|
* uniteSurvivors; a synthetic result charges the own leaks, as settle()). null for anyone but a leaker of the running
|
|
* 联防.
|
|
* @returns {number|null}
|
|
*/
|
|
_uniteLeft(ps) {
|
|
const plan = this.unitePlan;
|
|
if (this.phase !== PHASE.UNITE || !plan || !ps || !plan.leakers.includes(ps)) return null;
|
|
const pid = ps.playerId;
|
|
const f = this.fields.find((x) => x && x.kind === 'unite') || null;
|
|
let res = null;
|
|
if (f && f.cc) res = f.done ? f.result : null;
|
|
else if (f && f.battle && f.battle.finished) { try { res = f.battle.result(); } catch { res = null; } }
|
|
if (res && res.synthetic) {
|
|
const own = this.lastResults.get(pid);
|
|
return own && Array.isArray(own.leaked) ? own.leaked.filter((l) => l && l.counted !== false).length : 0;
|
|
}
|
|
if (res) return uniteSurvivors(plan, res).get(pid) || 0;
|
|
const sent = plan.leaked.filter((l) => l.sourcePlayerId === pid).length;
|
|
let live = null;
|
|
if (f && f.cc) {
|
|
if (f.mode === 'server' && f.timeline) {
|
|
// the sample is [gt, killed, total, resolved(, left)]: a 联防 sample's `left` is its 5th element
|
|
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
|
live = sample && sample[4] && typeof sample[4] === 'object' ? sample[4] : null;
|
|
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
|
} else if (f && f.battle) {
|
|
try { live = uniteLeft(f.battle); } catch { live = null; }
|
|
}
|
|
if (!this._uniteBounds || this._uniteBounds.plan !== plan) this._uniteBounds = { plan, bounds: uniteBillBounds(plan.leaked, this.gd) };
|
|
const bound = this._uniteBounds.bounds.get(pid) ?? sent;
|
|
const standing = live ? Math.min(bound, Math.max(0, Math.trunc(Number(live[pid]) || 0))) : sent;
|
|
return standing + (plan.notReentered.get(pid) || 0);
|
|
}
|
|
|
|
/** Server-run 联防 (streaming mode): refresh m.public about once a game second when a leaker's count moved. */
|
|
_uniteTick(runner) {
|
|
const f = runner && runner.fields ? runner.fields[0] : null;
|
|
if (!f || !f.battle || runner.ticks % 30 !== 0) return;
|
|
let key = '';
|
|
try { key = JSON.stringify(uniteLeft(f.battle)); } catch { key = ''; }
|
|
if (key === this._uniteLeftKey) return;
|
|
this._uniteLeftKey = key;
|
|
this.markPublic();
|
|
}
|
|
}
|