Files
ark-stronghold-protocol/server/match/match/unitePhase.js
T
sganggs 5398a0cfc5 feedback5 WI: server toasts and ticker lines carry msgid + params so the client shows them in the player's language
messaging.js: a toast / CUSTOM ticker line is a string (its own msgid) or a shared/i18n.js msg(); frames keep
the Chinese `text` (older clients) and add `msgid` / `params`; config.broadcasts lines carry their `args`
(the client fills the localized broadcast template; the player name {0} is never translated). Templated call
sites send msg(): the grant toast 「X:获得Y」, the summon-range and terrain withdrawals, the 联防 line,
eliminated / left lines, the 远见 / 奇迹 stack payouts and the 维多利亚 hammers; game-data names go as dn().
The protocol stays backward compatible (new fields optional). Tests that captured m.toast's argument render
it with renderMessage. No gameplay change: golden unchanged.
2026-10-05 20:19:58 +08:00

151 lines
7.4 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, uniteStageId } 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';
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);
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 its own map — the escaped
* template's (unite.js uniteStageId; GitHub #41), the round's stage only when the data lacks it. The field meta and the
* client-run spec carry that stageId, so every viewer draws the 联防 map.
*/
_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: uniteStageId(this.gd, plan.helpers.length) ?? 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) {
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : 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();
}
}