Files
ark-stronghold-protocol/server/match/match/unitePhase.js
T
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
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.
2026-10-09 22:55:24 +08:00

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();
}
}