// 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 pair alive players by seat, 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 { pairPlayers, 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 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 groups = pairPlayers(alive).map((g) => g.map((p) => p.playerId).join(',')); const fields = m.fields.map((f) => f.players.join(',')); if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != seat 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; }