Files
ark-stronghold-protocol/server/match/audit.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

522 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/match/audit.js — rule auditor for sweeps and tests (tools/matchrun.mjs --check, test/match/fullmatch.test.js).
// (i18n-ignore-file: developer reports in English with the game's terms, never shown to players — docs/I18N.md)
//
// attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine
// is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
// temp (临时整备区) every piece entering a temp slot (PlayerState._putTemp) is due at the deadline of the first
// prep in which its player could act on it: the current prep when it arrived in PREP before Ready,
// the next one when it arrived after Ready / at the prep end, else (COMBAT, SETTLE, ROUND_START, 机变)
// the next prep to end; a piece that got into temp by other means counts as due at the prep in
// progress when it is first seen at a prep end. No temp piece may outlive its due prep.
// prep end leftover funds lost (carry bands excepted), temp resolved (only pieces due at a later prep stay),
// reward offers expired
// round start income = config income(r) (= min(3 + r, 12)) + pending funds, upgrade price −1 (floor 0) from R2,
// temp not wiped (nothing overdue), offers earned after the last prep kept, frozen slots kept in
// place (same id; chess slots keep their index, the item slot(s) follow the chess slots, so a
// level-up moves them right), everything else rerolled with tier ≤ shop level from the unbanned pool,
// freeze toggle released
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
// merges a merge consuming a deployed copy puts the elite on that copy's tile (of several, the first in deploy
// order legal for it — board.js mergeTile; a 突变细胞 carrier destroyed before the gain is no copy) with
// its facing, else into the hand / temp; the deploy count never grows (PRTS 卫戍协议/帮助, user playtest
// #6 follow-up)
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
// one field per alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
// maps consistent, 6 (co-op) / 3 (solo) cards
// 联防 decided after the COMBAT_END pause from the players still in: runs iff co-op with ≥ 1 leaker and
// ≥ 1 perfect player; helpers = unite.js helperOrder (PRTS: units > active bond > layers > standing
// units > seat, research 08 §5); leakers = players with counted leaks
// settle no unite ⇒ loss = min(cap, counted leaks); after 联防 a leaker loses ≤ cap (its leaked enemies'
// offspring count too), everybody else ≤ min(cap, own counted leaks); LP ≤ 0 ⇔ eliminated
// final assault fields follow the saved per-match pairs, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
// after a win when hiddenEligible() holds
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
// Σ alive players' LP = the merged team LP after the Final Assault
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale; the co-op
// strategy draft has one countdown: the deadline is the current turn's (Match.BAND_TURN_SECONDS). A match
// with a single human (solo, or a 同盟 room with AI teammates only: Match.soloUntimed) times nothing
// outside its battles — no INFO_CHECK / draft / 机变 / prep deadline, BATTLE_CHECK / ROUND_START / SETTLE
// silent (deadline 0)
// Checks never throw into the match: an exception inside a check is itself recorded as a violation.
import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js';
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
import { bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js';
/**
* @param {import('./Match.js').Match} m
* @param {{ invariants?: boolean, limit?: number }} [opts] invariants: also run collectViolations at phase changes
*/
export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const gd = m.gd;
const audit = {
violations: [],
checks: 0,
/** shop tier histogram of rolled chess slots: level → { tier: n } */
odds: {},
phases: 0,
};
const where = () => `${m.phase} R${m.round}`;
const fail = (msg) => { if (audit.violations.length < limit) audit.violations.push(`[${where()}] ${msg}`); };
const check = (label, fn) => {
audit.checks++;
try { fn(); } catch (e) { fail(`audit ${label} threw: ${e && e.message}`); }
};
const carries = (ps) => gd.leftoverKeptBands.includes(ps.bandId);
const wrap = (obj, name, around) => {
const orig = obj[name];
if (typeof orig !== 'function') return;
obj[name] = function wrapped(...args) { return around.call(this, orig.bind(this), ...args); };
};
const expectDeadline = (seconds, label, { silentSolo = false } = {}) => check(`deadline ${label}`, () => {
// a solo match's presentation steps run silently (no countdown, Match.soloUntimed)
const want = seconds > 0 && !(silentSolo && m.soloUntimed) ? Math.max(0, Math.round(seconds * 1000 * m.timerScale)) : 0;
const got = m.deadline ? m.deadline - m.sched.now() : 0;
if (Math.abs(got - want) > 1) fail(`${label}: deadline in ${got} ms, expected ${want} ms`);
});
const runInvariants = () => {
if (!invariants) return;
audit.phases++;
for (const v of collectViolations(m, { limit: 10 })) fail(`invariant: ${v}`);
};
// ---- per-player prep handlers and round start --------------------------------------------------------------
const incomeEv = new Map();
wrap(m, 'dispatch', function (orig, ps, hook, ev, opts) {
if (hook === 'onIncome' && ev && ps) incomeEv.set(ps, { initial: { income: ev.income, pending: ev.pending }, ev });
return orig(ps, hook, ev, opts);
});
// temp arrivals: ps → Map(uid → index of the prep whose deadline resolves it); ps → preps ended (own count)
const tempDue = new Map();
const prepsEnded = new Map();
const endedOf = (ps) => prepsEnded.get(ps) || 0;
const dueOf = (ps) => { let d = tempDue.get(ps); if (!d) tempDue.set(ps, (d = new Map())); return d; };
const overdue = (ps, label) => {
const d = dueOf(ps);
for (const p of ps.temp) {
if (!p) continue;
const due = d.get(p.uid);
if (due != null && due < endedOf(ps)) fail(`${ps.playerId}: temp piece ${p.id} (due at prep ${due}) survived ${label} (${endedOf(ps)} preps ended)`);
}
};
for (const ps of m.players.values()) {
wrap(ps, '_putTemp', function (orig, i, piece) {
const res = orig(i, piece);
// after Ready / at the prep end the player cannot act on it any more: due at the next prep
if (piece) dueOf(ps).set(piece.uid, endedOf(ps) + (m.phase === PHASE.PREP && ps.ready ? 1 : 0));
return res;
});
wrap(ps, 'setReady', function (orig, on) {
const was = ps.ready;
const res = orig(on);
// un-ready: the player can act on what arrived while it was ready — due at this prep again
if (res && res.ok && was && !on) for (const p of ps.temp) if (p && (dueOf(ps).get(p.uid) ?? 0) > endedOf(ps)) dueOf(ps).set(p.uid, endedOf(ps));
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
return res;
});
wrap(ps, '_rollChessSlot', function (orig) {
const s = orig();
if (s) {
const t = gd.tierOf(s.id);
const row = (audit.odds[ps.shop.level] ||= {});
row[t] = (row[t] || 0) + 1;
check('shop roll', () => {
const base = gd.baseIdOf(s.id);
if (t > ps.shop.level) fail(`${ps.playerId}: rolled tier ${t} at shop level ${ps.shop.level}`);
// (a slotted 自选 piece comes from the player's own stock, 0.2.0 player/diy.js — once the 调度中心 is at its level)
const diy = ps.diyStock && ps.diyStock.has(base) ? ps.diyStock.entries.get(base) : null;
if (!m.pool.has(base) && !diy) fail(`${ps.playerId}: rolled ${s.id} outside the match pool (banned/hidden)`);
if (diy && ps.shop.level < diy.shopLevel) fail(`${ps.playerId}: rolled 自选 ${s.id} at shop level ${ps.shop.level} < ${diy.shopLevel}`);
if (s.basePrice !== gd.chessPrice(s.id)) fail(`${ps.playerId}: ${s.id} basePrice ${s.basePrice} != ${gd.chessPrice(s.id)}`);
});
}
return s;
});
wrap(ps, 'startRound', function (orig, r) {
const f0 = ps.funds;
const p0 = ps.pendingFunds;
const up0 = ps.shop.upgradePrice;
const kept = ps.shop.slots.map((s, i) => (s ? { i, kind: s.kind, id: s.id, frozen: !!s.frozen, sold: !!s.sold } : null)).filter(Boolean);
const offers0 = ps.offers.slice();
const layout0 = ps.shop.layout || { chess: ps.shop.slots.length, item: 0 };
incomeEv.delete(ps);
const res = orig(r);
check('round start', () => {
const id = ps.playerId;
const inc = incomeEv.get(ps);
const want = gd.income(r);
if (want !== Math.min(3 + r, 12) && !gd.economy.income) fail(`income(${r}) ${want}`);
if (!inc) fail(`${id}: no onIncome dispatch`);
else {
if (inc.initial.income !== want || inc.initial.pending !== p0) fail(`${id}: onIncome started with ${inc.initial.income}+${inc.initial.pending}, expected ${want}+${p0}`);
const nn = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
const credited = nn(inc.ev.income) + nn(inc.ev.pending);
if (ps.funds - f0 !== credited) fail(`${id}: funds ${f0} → ${ps.funds}, credited ${credited}`);
}
const upWant = r > 1 ? Math.max(0, up0 - 1) : up0;
if (ps.shop.upgradePrice !== upWant) fail(`${id}: upgrade price ${up0} → ${ps.shop.upgradePrice}, expected ${upWant}`);
if (ps.pendingFunds !== 0) fail(`${id}: pending funds not cleared`);
// temp is not wiped here: what overflowed after the last prep's deadline is shown in this prep
overdue(ps, 'the round start');
// offers of the last prep expired at its end; the ones queued after it (SETTLE merges) wait for this prep
if (offers0.some((o) => !ps.offers.includes(o))) fail(`${id}: a reward offer earned after the prep was dropped at the round start`);
if (ps.shop.frozen) fail(`${id}: freeze toggle still on after the round start`);
const { chess, item } = gd.shopSlots(ps.shop.level);
if (ps.shop.slots.length !== chess + item) fail(`${id}: ${ps.shop.slots.length} shop slots at level ${ps.shop.level}, expected ${chess}+${item}`);
for (const k of kept) {
if (!k.frozen || k.sold) { fail(`${id}: slot ${k.i} (${k.id}) survived combat unfrozen/sold`); continue; }
// chess slots keep their index; item slots keep their place after the chess slots (whose count may have grown)
const at = k.i < layout0.chess ? k.i : chess + (k.i - layout0.chess);
const s = ps.shop.slots[at];
if (!s || s.id !== k.id || s.kind !== k.kind) fail(`${id}: frozen slot ${k.i} (${k.id}) not kept in place (slot ${at} now ${s ? s.id : 'empty'})`);
}
for (const s of ps.shop.slots) if (s && s.frozen) fail(`${id}: slot ${s.id} still frozen after the round start`);
});
return res;
});
// exact payment checks are skipped when content could pay out / refund around the action (checked before & after)
wrap(ps, 'buy', function (orig, slotIdx) {
const slot = ps.shop.slots[slotIdx];
const price = slot && !slot.sold ? ps.priceOf(slot) : null;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const res = orig(slotIdx);
if (res && res.ok) check('buy', () => {
if (!slot || !slot.sold) fail(`${ps.playerId}: bought slot ${slotIdx} is not marked sold`);
if (ps.funds !== f0 - price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: buy paid ${f0 - ps.funds}, price ${price}`);
});
return res;
});
wrap(ps, 'sell', function (orig, uid) {
const loc = ps.find(uid);
const gain = loc && loc.piece.kind === 'chess' ? gd.sellPrice(loc.piece.id) : null;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const res = orig(uid);
if (res && res.ok) check('sell', () => {
if (ps.funds - f0 !== gain && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: sell paid ${ps.funds - f0}, expected ${gain}`);
if (ps.find(uid)) fail(`${ps.playerId}: sold piece ${uid} still owned`);
});
return res;
});
wrap(ps, 'refresh', function (orig) {
const free = ps.shop.freeRefreshes > 0;
const f0 = ps.funds;
const fr0 = ps.shop.freeRefreshes;
const fx = hasSpendEffects(m, ps);
const res = orig();
if (res && res.ok && !fx && !hasSpendEffects(m, ps)) check('refresh', () => {
const paid = f0 - ps.funds;
if (free ? paid !== 0 || ps.shop.freeRefreshes !== fr0 - 1 : paid !== gd.refreshPrice) fail(`${ps.playerId}: refresh (free ${free}) paid ${paid}`);
});
return res;
});
wrap(ps, 'levelUp', function (orig) {
const lv = ps.shop.level;
const price = ps.shop.upgradePrice;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const slots0 = ps.shop.slots.slice();
const layout0 = ps.shop.layout || { chess: slots0.length, item: 0 };
const res = orig();
if (res && res.ok) check('levelUp', () => {
if (ps.shop.level !== lv + 1) fail(`${ps.playerId}: level ${lv} → ${ps.shop.level}`);
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
const next = gd.upgradeBase(ps.shop.level) ?? 0;
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
// the new level's extra slots open at once, empty (GitHub #332 / PR #333); the cards shown before stay in place
const { chess, item } = gd.shopSlots(ps.shop.level);
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
const layout = ps.shop.layout || layout0;
slots0.forEach((s, i) => {
const at = i < layout0.chess ? i : layout.chess + (i - layout0.chess);
if (ps.shop.slots[at] !== s) fail(`${ps.playerId}: shop slot ${i} changed by the level-up`);
});
});
return res;
});
// merges (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", user playtest #6 follow-up): with a
// deployed copy among the consumed ones the elite stands on the first such tile in deploy order that is legal for
// it, with that copy's facing; else in the hand / temp (the incoming copy is never deployed: a 突变细胞
// transformation destroyed its carrier before the gain, so that freed tile is no copy's). A merge never grows the
// deploy count. Every owned normal copy is consumed (merges are immediate).
wrap(ps, '_mergeChess', function (orig, baseId, incoming) {
const tiles = new Map(); // tile key → facing of the copy standing there
for (const [k, p] of ps.board) if (p.kind === 'chess' && !gd.isGolden(p.id) && gd.baseIdOf(p.id) === baseId) tiles.set(k, pieceDir(p));
const deployed0 = ps.deployCount;
const elite = orig(baseId, incoming);
if (elite) check('merge', () => {
const id = ps.playerId;
const loc = ps.find(elite.uid);
if (!loc) { fail(`${id}: the elite of ${baseId} is not owned after its merge`); return; }
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
const pos = placeClass(ps, (ps.gd || gd).chess(elite.id));
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
if (want) {
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
else if (pieceDir(elite) !== tiles.get(want.key)) fail(`${id}: the elite of ${baseId} faces ${pieceDir(elite)}, its copy faced ${tiles.get(want.key)}`);
} else if (loc.area === 'board' && (!tiles.has(loc.key) || ps.hand.some((x) => x == null) || ps.temp.some((x) => x == null))) {
// only the no-room fallback (hand and temp full, no deployed tile legal for it) leaves it on a copy's tile
fail(`${id}: the elite of ${baseId} took tile ${loc.key} although no consumed copy stood on a legal tile`);
}
});
return elite;
});
wrap(ps, 'endPrep', function (orig) {
// a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now
for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps));
const res = orig();
prepsEnded.set(ps, endedOf(ps) + 1);
check('prep end', () => {
// leftover funds are lost at prep end (carry bands excepted); gains after this (SETTLE effects) are kept
if (ps.funds !== 0 && !carries(ps)) fail(`${ps.playerId}: kept ${ps.funds} funds past the prep end without a carry band`);
overdue(ps, 'its prep end');
if (ps.offers.length) fail(`${ps.playerId}: reward offer survived the prep end`);
});
return res;
});
}
// ---- phases ------------------------------------------------------------------------------------------------
wrap(m, 'enterInfoCheck', function (orig) {
const r = orig();
runInvariants();
if (m.phase === PHASE.INFO_CHECK && m.soloUntimed) check('briefing', () => { if (m.deadline) fail('untimed briefing is timed'); });
else if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
return r;
});
wrap(m, 'enterBandDraft', function (orig) {
const r = orig();
runInvariants();
check('band draft', () => {
if (m.phase !== PHASE.BAND_DRAFT) return;
const d = m.draft;
const ids = m.order.map((p) => p.playerId).sort();
if (JSON.stringify(d.order.slice().sort()) !== JSON.stringify(ids)) fail(`draft order ${d.order} != seats ${ids}`);
// one countdown (user playtest #4 item 4): the step's deadline IS the current turn's, BAND_TURN_SECONDS long
if (m.soloUntimed) { if (m.deadline || d.turnDeadline) fail('untimed band draft is timed'); } else {
if (m.deadline !== d.turnDeadline) fail(`BAND_DRAFT: deadline ${m.deadline} is not the turn's ${d.turnDeadline}`);
expectDeadline(BAND_TURN_SECONDS, 'BAND_DRAFT turn');
}
});
return r;
});
wrap(m, 'enterBattleCheck', function (orig) {
const r = orig();
runInvariants();
check('bands', () => {
for (const ps of m.order) {
if (!ps.bandId || !gd.bandAllowed(ps.bandId)) fail(`${ps.playerId}: band ${ps.bandId} not allowed`);
if (ps.lp !== gd.startLp(ps.bandId)) fail(`${ps.playerId}: LP ${ps.lp} != totalHp ${gd.startLp(ps.bandId)} of ${ps.bandId}`);
}
expectDeadline(gd.timer('battleCheck'), 'BATTLE_CHECK', { silentSolo: true });
});
return r;
});
wrap(m, 'startRound', function (orig, rr) {
const res = orig(rr);
runInvariants();
check('round start phase', () => {
if (m.phase !== PHASE.ROUND_START) return;
expectDeadline(2, 'ROUND_START', { silentSolo: true });
const isBoss = rr === gd.bossRound || rr === gd.hiddenRound;
if (isBoss ? !m.bossWaves : !m.wave) fail('round without its wave');
});
return res;
});
wrap(m, 'startSpTurn', function (orig) {
const res = orig();
check('sp turn', () => {
if (m.phase !== PHASE.SP_DRAFT || !m.sp) return;
const s = m.sp;
if (s.idx >= s.order.length) return;
if (m.soloUntimed) { if (m.deadline) fail('untimed 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
});
return res;
});
wrap(m, 'finishSpDraft', function (orig) {
const s = m.sp;
if (m.phase === PHASE.SP_DRAFT && s) check('sp draft', () => {
const alive = m.alivePlayers().map((p) => p.playerId);
const want = m.isSolo ? 3 : 6;
if (s.cards.length > want) fail(`${s.cards.length} 机变 cards (max ${want})`);
if (s.order.length !== alive.length) fail(`机变 order ${s.order.length} for ${alive.length} alive`);
for (const pid of alive) {
const idx = s.picks[pid];
if (idx == null) fail(`${pid} ends 机变 without a card`);
else if (s.taken[idx] !== pid) fail(`${pid} picked card ${idx} held by ${s.taken[idx]}`);
}
const holders = Object.values(s.taken);
if (new Set(holders).size !== holders.length) fail('a player took two 机变 cards');
});
return orig();
});
wrap(m, 'enterPrep', function (orig) {
const res = orig();
runInvariants();
check('prep', () => {
if (m.phase !== PHASE.PREP) return;
if (m.soloUntimed) { if (m.deadline) fail('untimed prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
});
return res;
});
wrap(m, 'startCombat', function (orig) {
const res = orig();
runInvariants();
check('combat start', () => {
for (const ps of m.alivePlayers()) {
const id = ps.playerId;
overdue(ps, 'into combat');
if (!ps.ready) fail(`${id}: not ready at combat start`);
if (ps.funds !== 0 && !carries(ps)) fail(`${id}: kept ${ps.funds} funds into combat`);
if (ps.offers.length) fail(`${id}: reward offer survived the prep`);
for (const s of ps.shop.slots) if (s && (!s.frozen || s.sold)) fail(`${id}: unfrozen/sold slot ${s.id} survived into combat`);
}
});
return res;
});
let expectUnite = null;
// the 联防 decision (both combat modes): made after the COMBAT_END pause from the players still in
wrap(m, '_afterCombat', function (orig) {
if (m.phase === PHASE.COMBAT) check('unite trigger', () => {
const counted = (pid) => ((m.lastResults.get(pid) || {}).leaked || []).filter((l) => l && l.counted !== false).length;
const alive = m.alivePlayers();
const leak = alive.some((p) => counted(p.playerId) > 0);
const perfect = alive.some((p) => counted(p.playerId) === 0);
expectUnite = { round: m.round, expect: !m.isSolo && leak && perfect };
});
return orig();
});
wrap(m, 'startUnite', function (orig, plan) {
check('unite plan', () => {
const res = m.lastResults;
const counted = (pid) => ((res.get(pid) || {}).leaked || []).filter((l) => l && l.counted !== false).length;
const alive = m.alivePlayers();
const leakers = alive.filter((p) => counted(p.playerId) > 0).map((p) => p.playerId).sort();
const perfect = alive.filter((p) => counted(p.playerId) === 0);
const helpers = helperOrder(m, perfect, res).map((p) => p.playerId);
if (helpers.length > gd.unite.maxHelpers) fail(`${helpers.length} 联防 helpers (max ${gd.unite.maxHelpers})`);
if (plan.helpers.some((p) => !p.alive || p.left)) fail(`联防 helper eliminated / departed: ${plan.helpers.filter((p) => !p.alive || p.left).map((p) => p.playerId)}`);
if (m.isSolo) fail('联防 in solo');
if (JSON.stringify(plan.leakers.map((p) => p.playerId).sort()) !== JSON.stringify(leakers)) fail(`联防 leakers ${plan.leakers.map((p) => p.playerId)} != ${leakers}`);
if (JSON.stringify(plan.helpers.map((p) => p.playerId)) !== JSON.stringify(helpers)) fail(`联防 helpers ${plan.helpers.map((p) => p.playerId)} != ${helpers}`);
});
const r = orig(plan);
runInvariants();
return r;
});
wrap(m, 'settle', function (orig, plan, uniteResult) {
check('unite trigger', () => {
if (expectUnite && expectUnite.round === m.round && expectUnite.expect !== !!plan) fail(`联防 ${plan ? 'ran' : 'skipped'} but ${expectUnite.expect ? '≥ 1 leaker and ≥ 1 perfect player' : 'not both a leaker and a perfect player'}`);
expectUnite = null;
});
const before = new Map(m.alivePlayers().map((ps) => [ps, ps.lp]));
const res = orig(plan, uniteResult);
check('settle', () => {
const cap = gd.lpCapPerRound;
const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic);
for (const [ps, lp0] of before) {
const r = m.lastResults.get(ps.playerId) || { leaked: [] };
const counted = (r.leaked || []).filter((l) => l && l.counted !== false).length;
// after 联防 a leaker pays for every surviving enemy of its source — enemies spawned by its leaked enemies
// (splitters, summoners) included — so only the cap bounds it; everybody else never exceeds own leaks
const max = uniteRan && plan.leakers.includes(ps) ? cap : Math.min(cap, counted);
if (ps.alive) {
const loss = lp0 - ps.lp;
if (loss < 0) fail(`${ps.playerId}: LP rose in settle ${lp0} → ${ps.lp}`);
if (loss > max) fail(`${ps.playerId}: lost ${loss} LP with ${counted} counted leaks (cap ${cap})`);
if (!uniteRan && loss !== max) fail(`${ps.playerId}: lost ${loss} LP, expected min(${cap}, ${counted})`);
if (ps.lp <= 0) fail(`${ps.playerId}: alive with LP ${ps.lp}`);
} else {
// eliminated: LP is clamped to 0, so the loss was ≥ lp0 and still ≤ min(cap, counted)
if (lp0 > max) fail(`${ps.playerId}: eliminated from ${lp0} LP with only ${counted} counted leaks`);
if (ps.eliminatedRound !== m.round) fail(`${ps.playerId}: eliminated round ${ps.eliminatedRound} != ${m.round}`);
if (ps.lp !== 0) fail(`${ps.playerId}: eliminated with LP ${ps.lp}`);
}
}
expectDeadline(3, 'SETTLE', { silentSolo: true });
});
runInvariants();
return res;
});
wrap(m, 'startFinalAssault', function (orig, hidden) {
const alive = m.alivePlayers();
const before = m.bossPairs?.map(g => g.slice()) ?? null;
const lpSum = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
const teamLp0 = m.teamLp;
const res = orig(hidden);
runInvariants();
check('final assault', () => {
if (!alive.length) return;
const pairs = m.bossPairs || [];
const memberIds = pairs.flat();
if (pairs.length !== Math.ceil(alive.length / 2) || pairs.some(g => g.length < 1 || g.length > 2)
|| new Set(memberIds).size !== memberIds.length
|| JSON.stringify([...memberIds].sort()) !== JSON.stringify(alive.map(p => p.playerId).sort())) fail('boss pairing does not cover every alive player once');
if (before && before.flat().every(id => alive.some(p => p.playerId === id)) && before.flat().length === alive.length
&& JSON.stringify(before) !== JSON.stringify(pairs)) fail('boss pairing changed after preparation');
const groups = pairs.map(g => g.join(','));
const fields = m.fields.map((f) => f.players.join(','));
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != saved pairs ${groups.join(' | ')}`);
m.fields.forEach((f, i) => { if (f.fieldId !== `b${i + 1}`) fail(`boss field id ${f.fieldId}`); });
if (!hidden && m.teamLp !== lpSum) fail(`team LP ${m.teamLp} != Σ alive LP ${lpSum}`);
if (hidden && m.teamLp !== teamLp0) fail(`hidden core changed team LP ${teamLp0} → ${m.teamLp}`);
const want = bossPoolHp(gd, hidden ? m.hiddenBossId : m.bossId, alive.length);
if (!m.bossPool || m.bossPool.maxHp !== want) fail(`boss pool ${m.bossPool && m.bossPool.maxHp} != ${want}`);
if (hidden && !hiddenEligible(gd, { layerSum: m.hiddenLayerSum, teamLp: m.teamLp })) fail('hidden core entered while not eligible');
if (hidden && gd.difficulty === 'FUNNY') fail('hidden core on FUNNY');
});
return res;
});
wrap(m, 'finish', function (orig, outcome) {
const res = orig(outcome);
check('result', () => {
const r = m.lastResultMsg;
if (!r) { fail('no m.result'); return; }
const titles = r.players.map((p) => p.title && p.title.id).filter(Boolean);
if (new Set(titles).size !== titles.length) fail(`a title was given twice: ${titles}`);
const cfg = Array.isArray(gd.config.titles) ? gd.config.titles : [];
for (const p of r.players) {
const t = p.title && cfg.find((x) => x.id === p.title.id);
if (t && t.onlyOnWin && !r.victory) fail(`${p.playerId}: win-only title ${t.id} on a defeat`);
const ps = m.players.get(p.playerId);
const want = !ps.alive && ps.eliminatedRound != null ? Math.max(0, ps.eliminatedRound - 1) : r.victory ? gd.bossRound + (r.hiddenCleared ? 1 : 0) : Math.max(0, Math.min(m.round, gd.bossRound) - 1);
if (p.roundsPassed !== want) fail(`${p.playerId}: roundsPassed ${p.roundsPassed}, expected ${want}`);
}
if (r.hiddenReached && !m.hiddenReached) fail('hiddenReached mismatch');
// after the Final Assault the alive players' LP are their shares of the merged team pool
if (r.teamLp != null && m.alivePlayers().some((p) => p.lpAtFinal != null)) {
const sum = r.players.filter((p) => p.alive).reduce((s, p) => s + p.lp, 0);
if (sum !== r.teamLp) fail(`Σ alive result LP ${sum} != team LP ${r.teamLp}`);
}
});
return res;
});
return audit;
}
/** Content that may pay out or refund around a purchase (then exact price checks are skipped). */
function hasSpendEffects(m, ps) {
const reg = m.registry;
const keys = ['onBuy', 'onSpend', 'onSold', 'onRefresh', 'onLevelUp', 'onGain', 'onLayers'];
const any = (h) => h && keys.some((k) => typeof h[k] === 'function');
if (reg.globals().some(([, h]) => any(h))) return true;
if (ps.bandId && any(reg.get(`band:${ps.bandId}`))) return true;
for (const id of m.gd.bondIds) if (any(reg.get(`bond:${id}`))) return true;
for (const e of ps.effects) if (e && typeof e.key === 'string' && any(reg.get(e.key))) return true;
for (const p of ps.allChess()) {
const rec = m.gd.chess(p.id);
for (const gid of (rec && rec.garrisonIds) || []) { const g = m.gd.garrison(gid); if (g && reg.get(`garrison:${g.effectKey}`)) return true; }
for (const it of p.items || []) if (any(reg.get(`item:${String(it.id).replace(/_[ab]$/, '')}`))) return true;
}
return false;
}