Files
ark-stronghold-protocol/server/match/match/settle.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

135 lines
6.9 KiB
JavaScript

// server/match/match/settle.js — Match methods: SETTLE (the LP loss — a leaker's 联防 survivors —, stats, bounty coins,
// the IN_BATTLE layer gains clamped by layerGainRoom, CHAR_DAMAGE tickers, eliminations) and RESULT (finish: m.result
// to every human still here, onEnd).
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, layerGainRoom } from '../../../shared/constants.js';
import { uniteSurvivors } from '../unite.js';
import { buildResult } from '../results.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
export class MatchSettle {
settle(plan, uniteResult) {
this.phase = PHASE.SETTLE;
this.runner = null;
this._stopClientCombat();
// the pending in-battle gains the views showed become persistent below (alive players) or lapse (DESIGN §20.15)
for (const ps of this.order) if (ps.pendingLayerGains) { ps.pendingLayerGains = null; ps.dirty(); }
const cap = this.gd.lpCapPerRound;
// a 联防 battle that could not run at all (synthetic result) must not wipe the leakers' losses: charge their own leaks
const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic);
const survivors = uniteRan ? uniteSurvivors(plan, uniteResult) : null;
const alive = this.alivePlayers();
for (const ps of alive) {
const r = this.lastResults.get(ps.playerId) || { leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 };
const counted = (r.leaked || []).filter((l) => l && l.counted !== false).length;
const loss = uniteRan && plan.leakers.includes(ps) ? Math.min(cap, survivors.get(ps.playerId) || 0) : Math.min(cap, counted);
ps.lp -= loss;
ps.stats.lpLost += loss;
ps.stats.leaks += counted;
ps.stats.kills += Number(r.killed) || 0;
ps.stats.dmgDealt += Number(r.damageDealt) || 0;
ps.stats.healing += Number(r.healingDone) || 0;
if (r.perfect !== false && counted === 0) ps.stats.perfectRounds++;
// bounty coins (own battle + unite kills) are credited to the next prep
let coins = Math.max(0, Math.trunc(Number(r.coins) || 0));
const up = uniteResult && uniteResult.perPlayer && uniteResult.perPlayer[ps.playerId];
if (up) {
coins += Math.max(0, Math.trunc(Number(up.coins) || 0));
ps.stats.dmgDealt += Number(up.damageDealt) || 0;
ps.stats.kills += Number(up.killed) || 0;
}
// perfect-payout bounties (战术特训): own phase perfect
for (const b of ps.bounties) if (b.card.payout === 'perfect' && counted === 0 && r.perfect !== false) coins += b.card.coin;
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
for (const b of ps.bounties) b.roundsLeft--;
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
// IN_BATTLE layer gains (normal battles only), at most the room left under BOND_LAYER_CAP (999, as the battle's
// live copy: Battle.addLayers); a bond at the cap gains nothing and dispatches nothing
for (const [bondId, n] of Object.entries(r.layerGains || {})) {
if (!this.gd.bond(bondId) || !(n > 0)) continue;
const before = ps.layers[bondId] || 0;
const add = layerGainRoom(before, Math.floor(n));
if (!(add > 0)) continue;
ps.layers[bondId] = before + add;
this.dispatch(ps, 'onLayers', { bondId, from: before, to: ps.layers[bondId], reason: 'battle' });
}
this._charDamageTickers(ps, r);
this.dispatch(ps, 'onBattleResult', { result: r, lpLoss: loss, perfect: counted === 0 && r.perfect !== false, unite: uniteResult || null });
ps.recompute();
}
for (const ps of alive) {
if (ps.lp <= 0) {
ps.lp = 0;
ps.eliminate(this.round);
this.toast(ps, 'error', '你的目标生命值耗尽,已被淘汰');
this.tickerText(msg('{name}博士的目标生命值已耗尽', { name: ps.name }), FLOW_TICKER_PRIORITY);
}
}
this.fields = [];
this.watchers.clear();
this.markPublic();
this.setDeadline(DELAYS.SETTLE / 1000, () => this.afterSettle(), { silent: this.soloUntimed });
}
/**
* CHAR_DAMAGE tickers: one per board unit of the battle (its highest threshold); units created in battle (summons,
* no board uid) count once per unit type (the highest of them) — a result can never announce more units than the
* lineup has kinds of.
*/
_charDamageTickers(ps, r) {
const steps = (Array.isArray(this.gd.config.broadcasts) ? this.gd.config.broadcasts : []).filter((b) => b.type === 'CHAR_DAMAGE' && Array.isArray(b.params)).map((b) => Number(b.params[0])).filter((n) => n > 0).sort((a, b) => b - a);
if (!steps.length) return;
const board = new Set();
for (const p of ps.board.values()) board.add(p.uid);
const best = new Map();
for (const u of r.unitStats || []) {
if (!u) continue;
const key = Number.isInteger(u.uid) && board.has(u.uid) ? `uid:${u.uid}` : `def:${u.defId}`;
const cur = best.get(key);
if (!cur || (Number(u.dmg) || 0) > (Number(cur.dmg) || 0)) best.set(key, u);
}
for (const u of best.values()) {
const hit = steps.find((s) => (u.dmg || 0) >= s);
if (hit) this.tickerFor('CHAR_DAMAGE', [ps.name, u.name || u.defId, String(hit)], { playerId: ps.playerId, param: String(hit) });
}
}
afterSettle() {
if (!this.alivePlayers().length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
this.startRound(this.round + 1);
}
finish({ victory, hiddenCleared = false, reason = 'defeat' }) {
if (this.ended || this.disposed) return;
this.ended = true;
if (this.runner) { try { this.runner.stop(); } catch { /* ignore */ } this.runner = null; }
this._stopClientCombat();
this.cancel(this._phaseTimer);
this.cancel(this._turnTimer);
for (const h of this._timers) { try { this.sched.clearTimeout(h); } catch { /* ignore */ } }
this._timers.clear();
this.phase = PHASE.RESULT;
this.deadline = 0;
for (const f of this.fields) f.live = false;
this.outcome = { victory: !!victory, hiddenReached: this.hiddenReached, hiddenCleared: !!hiddenCleared, reason };
let result;
try {
result = buildResult(this, this.outcome);
} catch (e) {
this.reportError('buildResult', e);
result = { t: 'm.result', victory: !!victory, roundsPassed: 0, reason, modeId: this.modeId, difficulty: this.difficulty, players: [] };
}
this.lastResultMsg = result;
this.markPublic();
try { this.flush(true); } catch (e) { this.reportError('flush', e); }
// every human still here gets the settlement — the spectator seats the same public rows (none of them their own)
for (const ps of this._viewers()) this.sendTo(ps.playerId, { ...result, playerId: ps.playerId });
const { t, ...summary } = result;
void t;
summary.errors = this.errorCount;
try { this.onEndFn(summary); } catch (e) { this.reportError('onEnd', e); }
}
}