Files
ark-stronghold-protocol/test/match/hardening.test.js
T
Sukvii 56a7b918a9 feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move
Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md
§2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess
choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The
owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules.
Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 /
险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a
reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without
a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while
it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty
scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback
picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over
a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer
goes with it; invariants.js flags an offer outside its owner's own open prep.
Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits
of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys.
Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs
121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs
121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario
and family is unchanged; node tools/golden.mjs --twice is identical.
Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps
the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before
endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline
player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's
pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js,
unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason,
ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
2026-10-08 20:45:54 +08:00

487 lines
25 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.
// Regression tests for the adversarial review of the match engine: every defect fixed there and every registry
// addition content needs (DESIGN §6.4/§7, docs/META.md). FakeBattle unless noted.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, GEO } from '../../shared/constants.js';
import { MetaRegistry, makeCtx } from '../../server/match/effectsMeta.js';
import { registerBuiltins } from '../../server/match/builtinMeta.js';
import { collectViolations } from '../../server/match/invariants.js';
import { attachAudit } from '../../server/match/audit.js';
import { DeadBattle, FieldRunner, maxTicksPerInterval } from '../../server/match/fields.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
const builtins = () => registerBuiltins(new MetaRegistry(), DATA);
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
/** Solo match in PREP R1 with an empty board/hand and funds. */
function prepSolo({ seed = 900, registry = undefined, funds = 30 } = {}) {
const h = makeMatch({ mode: 'solo', seed, fake: true, registry }).start();
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.funds = funds;
ps.recompute();
return { h, m: h.m, ps };
}
// ---------------------------------------------------------------------------------------------------------------
// defects
test('Final Assault: a harmless leak (lpr 0) costs no team LP; a leak without lpr costs 1; lpr N costs N', () => {
const h = makeMatch({
mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 901, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, duration: Infinity, leakEvents: [{ at: 0.5, lpr: 0 }, { at: 1 }, { at: 1.5, lpr: 3 }, { at: 3, lpr: 0 }] } : {}),
}).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const start = m.teamLp;
// the boss never dies here: stop right after the scripted leaks
const f = bossFields()[0];
for (let i = 0; i < 200 && f.time < 4; i++) f.step();
m._bossLeak({ lpr: undefined });
assert.equal(m.teamLp, start - 1 - 3 - 1, 'lpr 0 → 0, missing → 1, 3 → 3');
m.dispose();
});
test('PREP: a player who un-readies right after everybody was ready keeps the prep open (deadline stays armed)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 902, fake: true }).start();
const m = h.m;
h.toPrep(1);
const deadline = m.deadline;
assert.ok(deadline > 0);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
// the phase ends on the next scheduler turn — p_1 changes their mind before that
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
h.sched.runNext();
assert.equal(m.phase, PHASE.PREP, 'prep still open');
assert.equal(m.deadline, deadline, 'the prep deadline is still armed');
// at the deadline everyone is auto-readied and combat starts
h.run(() => m.phase === PHASE.COMBAT || m.phase === PHASE.SETTLE);
assert.ok(h.sched.now() >= deadline);
m.dispose();
});
test('merge with a full hand and a full temp: the elite takes its deployed copy\'s tile (PRTS) and keeps the returned items', () => {
const { m, ps } = prepSolo({ seed: 903 });
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((c) => m.pool.has(c) && m.gd.mergeCount(c) === 3);
const t1 = legalTileFor(m, ps, id);
const a = give(m, ps, id, 'board', t1);
const t2 = legalTileFor(m, ps, id);
const b = give(m, ps, id, 'board', t2);
const itA = giveItem(m, ps, 'chess_item_1_01_e_a');
const itB = giveItem(m, ps, 'chess_item_2_04_e_a');
ps.hand.fill(null);
a.items.push(itA);
b.items.push(itB);
const fillers = [2, 3, 4].flatMap((t) => chessOfTier(t)).filter((c) => m.pool.has(c) && c !== id);
assert.ok(fillers.length >= 15);
for (let i = 0; i < 10; i++) give(m, ps, fillers[i], 'hand', i);
for (let i = 0; i < 5; i++) give(m, ps, fillers[10 + i], 'temp', i);
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
checkInvariants(m);
// a full hand refuses the purchase, also one that would complete the merge (PRTS 卫戍协议/帮助 §手牌区, GitHub #82);
// a gain (an effect's grant) still completes it
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: 'HAND_FULL' }, 'a full hand refuses the merge-completing purchase');
assert.ok(ps.acquireChess(id, { source: 'grant' }), 'the gained copy completes the merge');
const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(id));
assert.ok(elite, 'the elite stands on a consumed copy\'s tile');
assert.equal(ps.find(elite.uid).key, `${t1[0]},${t1[1]}`, 'the copy that deploys first (legalTileFor walks the top row left to right: on one row, the deploy order)');
assert.equal(elite.poolCopies, 3, 'it holds all three copies');
assert.deepEqual(elite.items.map((x) => x.id).sort(), ['chess_item_1_01_e_a', 'chess_item_2_04_e_a'], 'returned items are kept on it');
checkInvariants(m);
m.dispose();
});
test('联防 that cannot run (synthetic result) charges the leakers their own leaks instead of nothing', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 904, fake: true,
script: (b) => (b.kind === 'unite' ? { throwInCtor: true } : { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 4 : 0])) }),
}).start();
const m = h.m;
h.toPrep(1);
const a = h.ps('p_0');
const lp = a.lp;
h.drive(() => m.phase === PHASE.SETTLE);
assert.ok(m.unitePlan, '联防 was planned');
assert.equal(a.lp, lp - 4, 'the leaker pays its 4 leaks');
assert.equal(h.ps('p_1').lp, h.ps('p_1').lp, 'the helper pays nothing');
assert.ok(m.errorCount >= 1, 'the construction failure was reported');
m.dispose();
});
test('stand-in results are marked synthetic (DeadBattle, unreadable results)', () => {
const dead = new DeadBattle({ fieldId: 'u', kind: 'unite', players: [{ playerId: 'a' }] });
assert.equal(dead.result().synthetic, true);
const runner = new FieldRunner({ reportError() {}, markPublic() {}, sched: { instant: true } }, [], { onDone() {} });
const r = runner.resultOf({ fieldId: 'x', players: ['a'], battle: { result() { throw new Error('boom'); } } });
assert.equal(r.synthetic, true);
assert.equal(r.perPlayer.a.perfect, true);
});
test('the 联防 battle reuses the round template stat overrides (leaked enemies keep their stats)', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 905, fake: true,
script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 2 : 0])) } : {}),
}).start();
const m = h.m;
h.toPrep(1);
m.wave.overrides = { enemy_1007_slime: { stats: { maxHp: 1234 } } };
h.drive(() => m.phase === PHASE.UNITE);
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
assert.deepEqual(u.opts.enemyOverrides, { enemy_1007_slime: { stats: { maxHp: 1234 } } });
m.dispose();
});
test('per-slot frozen flag in m.private follows the freeze toggle', () => {
const { m, ps } = prepSolo({ seed: 906 });
assert.ok(ps.privateView().shop.slots.every((s) => !s || s.frozen === false));
assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true });
assert.ok(ps.privateView().shop.slots.every((s) => !s || s.frozen === true));
m.dispose();
});
test('b.ev and b.snap always carry the game time gt (also on m.field resync); combatSpeed paces faster', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 907, fake: true, instant: false, script: () => ({ duration: 3 }) }).start();
const m = h.m;
h.toPrep(1);
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
h.sched.advance(600);
m.onReconnect('p_0');
h.sched.advance(600);
const frames = h.sent.filter(([pid, msg]) => pid === 'p_0' && (msg.t === 'b.snap' || msg.t === 'b.ev')).map(([, msg]) => msg);
assert.ok(frames.some((f) => f.t === 'b.ev') && frames.some((f) => f.t === 'b.snap'));
for (const f of frames) {
assert.equal(typeof f.gt, 'number', `${f.t} carries gt`);
assert.ok(Number.isFinite(f.gt) && f.gt >= 0);
assert.notEqual(f.t, f.gt);
}
// an event frame always precedes its snapshot with the same gt
for (let i = 0; i < frames.length - 1; i++) if (frames[i].t === 'b.ev') assert.equal(frames[i + 1].gt, frames[i].gt);
m.dispose();
assert.equal(maxTicksPerInterval(2), 8);
assert.ok(maxTicksPerInterval(20) >= 80);
const fast = makeMatch({ mode: 'solo', seed: 908, fake: true, instant: false, script: () => ({ duration: 6 }) });
fast.m.gameSpeed = 30;
fast.start();
fast.toPrep(1);
fast.m.handle('p_0', { t: 'g.ready', ready: true });
fast.run(() => fast.m.phase === PHASE.COMBAT);
const t0 = fast.sched.now();
fast.run(() => fast.m.fields.length > 0 && fast.m.fields.every((f) => !f.live));
assert.ok(fast.sched.now() - t0 < 400, `6 game s at 30× take ${fast.sched.now() - t0} ms`);
fast.m.dispose();
});
// ---------------------------------------------------------------------------------------------------------------
// registry additions (docs/META.md §2)
test('onIncome rewrites the round income (老鲤-style withholding); onSpend reports every completed payment', () => {
const reg = builtins();
const spent = [];
reg.global('lmlee', {
onIncome(ctx, ev) {
if (ev.round <= 2) { ctx.incCounter('held', ev.income); ev.income = 0; }
if (ev.round === 3) { ev.income += ctx.counter('held'); ctx.setCounter('held', 0); }
},
onSpend(ctx, ev) { spent.push([ev.reason, ev.amount, ev.total]); },
});
const h = makeMatch({ mode: 'solo', seed: 910, fake: true, registry: reg }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
assert.equal(ps.funds, 0, 'R1 income withheld');
ps.funds = 20;
const slot = ps.shop.slots.findIndex((s) => s && s.kind === 'chess');
const price = ps.priceOf(ps.shop.slots[slot]);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { ok: true });
const up = ps.shop.upgradePrice;
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
assert.deepEqual(spent, [['buy', price, price], ['refresh', 1, price + 1], ['levelUp', up, price + 1 + up]]);
ps.shop.freeRefreshes = 1;
m.handle('p_0', { t: 'g.refresh' });
assert.equal(spent.length, 3, 'a free refresh pays nothing');
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
assert.equal(ps.funds, m.gd.income(1) + m.gd.income(2) + m.gd.income(3), 'R1+R2 income paid out at R3');
m.dispose();
});
test('ctx.triggerGarrisons re-runs another piece\'s 特质 (asUid copies, 投资人 repeat, depth cap); garrisonsOf lists them', () => {
const reg = builtins();
const runs = [];
reg.garrison('SERVER_ONCE_GOLD', { run(ctx, ev) { runs.push([ctx.hook, ctx.source.piece.uid, ctx.source.trigger === true, ev.source]); } });
const { m, ps } = prepSolo({ seed: 911, registry: reg });
const id = 'chess_char_3_01_a'; // <获得时>使下个休整期额外获得1资金
const gainer = give(m, ps, id);
const other = give(m, ps, chessOfTier(1).find((c) => m.pool.has(c)));
const ctx = makeCtx(m, ps, { kind: 'global', key: 'global:test' }, 'onRoundStart');
const gs = ctx.garrisonsOf(gainer.uid);
assert.ok(gs.some((g) => g.effectKey === 'SERVER_ONCE_GOLD' && g.eventType === 'SERVER_GAIN'));
assert.equal(ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN'), 1);
assert.deepEqual(runs, [['onGain', gainer.uid, true, 'trigger']]);
runs.length = 0;
assert.equal(ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN', { asUid: other.uid }), 1);
assert.equal(runs[0][1], other.uid, 'asUid: the copy runs as the other piece');
assert.equal(ctx.triggerGarrisons(gainer.uid, 'SERVER_PRICE'), 0, 'SERVER_PRICE cannot be triggered');
assert.equal(ctx.triggerGarrisons(999999, 'SERVER_GAIN'), 0);
assert.equal(ctx.triggerGarrisons(gainer.uid, 'NOPE'), 0);
// 投资人 active ⇒ ×2
runs.length = 0;
ps.bonds.investShip = { count: 3, active: true, tier: 1, layers: 0 };
ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN');
assert.equal(runs.length, 2);
ps.recompute();
// a garrison that triggers itself stops at the dispatcher depth cap
let depth = 0;
reg.garrison('SERVER_ONCE_GOLD', { run(c) { depth++; c.triggerGarrisons(c.source.piece.uid, 'SERVER_GAIN'); } });
ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN');
assert.ok(depth > 1 && depth <= 7, `bounded recursion (${depth})`);
m.dispose();
});
test('garrisonHooks widens a garrison to extra hooks ("<进入休整期时><休整期结束时>")', () => {
const reg = builtins();
const hooks = [];
const id = 'chess_char_2_02_a';
const g = DATA.garrisons[DATA.chess[id].garrisonIds.find((x) => DATA.garrisons[x].eventType === 'SERVER_PREP_START')];
reg.garrison(g.effectKey, { garrisonHooks: (gar) => (gar.garrisonId === g.garrisonId ? ['onRoundStart', 'onPrepEnd'] : null), run(ctx) { hooks.push(ctx.hook); } });
const { m, ps } = prepSolo({ seed: 912, registry: reg });
give(m, ps, id, 'board', legalTileFor(m, ps, id));
m.dispatch(ps, 'onRoundStart', { round: 1 });
m.dispatch(ps, 'onPrepEnd', { round: 1 });
m.dispatch(ps, 'onRefresh', {});
assert.deepEqual(hooks, ['onRoundStart', 'onPrepEnd']);
m.dispose();
});
test('ctx.offerItems queues an item offer; g.reward takes the item (a full hand refuses it, also when it would merge)', () => {
const reg = builtins();
const { m, ps } = prepSolo({ seed: 913, registry: reg });
const ctx = makeCtx(m, ps, { kind: 'band', key: 'band:test' }, 'onLevelUp');
assert.equal(ctx.offerItems(['chess_item_1_01_e_a', 'nope', 'chess_item_2_03_e_a']), true);
assert.equal(ctx.offerItems(['nope']), false);
const view = ps.privateView().shop.rewardOffer;
assert.deepEqual(view.slots.map((s) => [s.kind, s.id, s.price]), [['item', 'chess_item_1_01_e_a', 0], ['item', 'chess_item_2_03_e_a', 0]]);
const fillers = chessOfTier(1).filter((c) => m.pool.has(c));
for (let i = 0; i < 9; i++) give(m, ps, fillers[i], 'hand', i);
giveItem(m, ps, 'chess_item_1_01_e_a', 'hand', 9);
assert.equal(ps.completesItemMerge('chess_item_1_01_e_a'), true);
assert.equal(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, 'HAND_FULL', 'refused although it would merge (PRTS 卫戍协议/帮助 §手牌区)');
ps.hand[0] && ps.returnCopies(ps.hand[0]);
ps.hand[0] = null;
ps.recompute();
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_2_03_e_a'));
assert.equal(ps.offers.length, 0);
assert.equal(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, 'BAD_TARGET', 'the offer is gone');
checkInvariants(m);
m.dispose();
});
test('ctx.piece / pieceAt expose board row/col and hand idx; pieceBonds includes 变形同构体 bonds', () => {
const { m, ps } = prepSolo({ seed: 914 });
const id = chessOfTier(2, (c) => c.position === 'RANGED').find((c) => m.pool.has(c) && !c.startsWith('chess_char_2_03'));
const [r, c] = legalTileFor(m, ps, id);
const onBoard = give(m, ps, id, 'board', [r, c]);
const inHand = give(m, ps, chessOfTier(1).find((x) => m.pool.has(x)), 'hand', 3);
const ctx = makeCtx(m, ps, { kind: 'global', key: 'global:t' }, 'onRoundStart');
const v = ctx.piece(onBoard.uid);
assert.equal(v.area, 'board');
assert.equal(v.row, r);
assert.equal(v.col, c);
assert.equal(ctx.piece(inHand.uid).idx, 3);
assert.equal(ctx.pieceAt(r, c).uid, onBoard.uid);
assert.equal(ctx.pieceAt(r, c).row, r);
assert.equal(ctx.pieceAt(0, 0), null);
assert.equal(ctx.pieceAt('x', 1), null);
const own = DATA.chess[id].bonds;
onBoard.items.push(ps.newPiece('item', 'chess_item_6_09_e_a'), ps.newPiece('item', 'chess_item_1_01_e_a'));
ps.recompute();
const bonds = ctx.pieceBonds(onBoard.uid);
for (const b of own) assert.ok(bonds.includes(b));
assert.ok(bonds.includes('victoriaShip'), '变形同构体 + 维式 item grants 维多利亚');
m.dispose();
});
test('ctx.rollPool: equip pools, weighted / listed chess pools, shopEligible filters (tier, minTier, bond, golden)', () => {
const { m, ps } = prepSolo({ seed: 915 });
const ctx = makeCtx(m, ps, { kind: 'global', key: 'global:t' }, 'onRoundStart');
for (let i = 0; i < 30; i++) {
const eq = ctx.rollPool('pool_equip_pepe');
assert.equal(eq.kind, 'item');
assert.ok(['chess_item_1_03_e_a', 'chess_item_2_04_e_a', 'chess_item_6_07_e_a'].includes(eq.id));
const pin = ctx.rollPool('pool_char_pinus');
assert.ok(['chess_char_1_19_a', 'chess_char_2_18_a', 'chess_char_4_20_a'].includes(pin.id));
const gl = ctx.rollPool('pool_chess_glady');
assert.ok(['chess_char_3_05_a', 'chess_char_2_07_a', 'chess_char_1_04_a'].includes(gl.id));
const t1 = ctx.rollPool('pool_chess_shop_1_reward');
assert.equal(m.gd.tierOf(t1.id), 1);
const later = ctx.rollPool('pool_char_later');
if (later) {
assert.equal(later.golden, true);
assert.ok(m.gd.isGolden(later.id));
assert.ok(m.gd.tierOf(later.id) >= 4);
assert.ok(DATA.chess[later.id].bonds.includes('lateranoShip'));
}
}
assert.equal(ctx.rollPool('nope'), null);
assert.equal(ctx.rollPool('constructor'), null);
// a listed chess with no copy left is never rolled
const taken = m.pool.take('chess_char_1_04_a', 99);
for (let i = 0; i < 20; i++) assert.notEqual(ctx.rollPool('pool_chess_glady')?.id, 'chess_char_1_04_a');
m.pool.give('chess_char_1_04_a', taken);
m.dispose();
});
test('onBattleStart ev.spawns: content edits a field\'s spawns; a kill bounty set there keeps paying in 联防', () => {
const reg = builtins();
reg.global('ducklord', {
onBattleStart(ctx, ev) {
if (ev.kind !== 'normal' || ctx.playerId !== 'p_0') return;
ev.spawns[0].enemyKey = 'enemy_1007_slime';
ev.spawns[0].bounty = { coins: 3, ownerPlayerId: 'p_0' };
ev.spawns.push({ enemyKey: 'no_such_enemy', time: 1 });
},
});
const h = makeMatch({
mode: 'coop', humans: 2, seed: 916, fake: true, registry: reg,
script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 1 : 0])) } : {}),
}).start();
const m = h.m;
h.toPrep(1);
h.drive(() => m.phase === PHASE.UNITE);
const nf = FakeBattle.instances.find((b) => b.fieldId === 'n:p_0');
assert.equal(nf.opts.spawns[0].enemyKey, 'enemy_1007_slime');
assert.equal(nf.opts.spawns[0].mods.bountyCoins, 3, 'the bounty rides along in mods');
assert.ok(!nf.opts.spawns.some((s) => s.enemyKey === 'no_such_enemy'), 'malformed spawns are dropped');
assert.ok(nf.opts.spawns.every((s) => s.ownerPlayerId === 'p_0'));
assert.deepEqual(m.unitePlan.leaked[0].bounty, { coins: 3, ownerPlayerId: 'p_0' });
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
assert.deepEqual(u.opts.spawns[0].bounty, { coins: 3, ownerPlayerId: 'p_0' });
const other = FakeBattle.instances.find((b) => b.fieldId === 'n:p_1');
assert.notEqual(other.opts.spawns[0].bounty?.coins, 3, 'only p_0\'s field was edited');
m.dispose();
});
test('registry warns about handler methods that are not hooks (typos never run silently)', () => {
const reg = new MetaRegistry();
reg.band('band_x', { onBuyy() {}, onBuy() {}, run() {}, garrisonHooks() { return []; }, note: 'data is fine' });
assert.equal(reg.warnings.length, 1);
assert.match(reg.warnings[0], /band:band_x: 'onBuyy'/);
});
// ---------------------------------------------------------------------------------------------------------------
// invariants & audit tooling
test('collectViolations reports corrupted state (funds, pool accounting, duplicate uid, stale bonds, illegal tile)', () => {
const { m, ps } = prepSolo({ seed: 920 });
assert.deepEqual(collectViolations(m), []);
const id = chessOfTier(1).find((c) => m.pool.has(c));
const p = give(m, ps, id);
ps.funds = -1;
m.pool.entries.get(id).left += 1;
ps.hand[ps.hand.indexOf(p) === 5 ? 6 : 5] = { ...p };
ps.layers.yanShip = 50;
ps.board.set('12,9', ps.newPiece('chess', id, { poolCopies: 0 }));
const v = collectViolations(m, { limit: 50 }).join('\n');
for (const re of [/funds -1/, /pool .*left .* held/, /duplicate uid/, /stale bonds/, /illegal tile|outside/]) assert.match(v, re);
m.dispose();
});
test('collectViolations: a 教鞭 choice belongs to its owner\'s open prep only', () => {
const { m, ps } = prepSolo({ seed: 922 });
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
assert.deepEqual(collectViolations(m), [], 'an open choice in its own prep is fine');
const pc = ps.personalChoice;
pc.round = m.round - 1;
assert.match(collectViolations(m).join('\n'), /personal choice outside its own prep/, 'a choice left from an earlier round');
pc.round = m.round;
pc.cards = [pc.cards[0], pc.cards[0]];
assert.match(collectViolations(m).join('\n'), /personal choice of 2 cards/, 'the same card twice');
ps.personalChoice = null;
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
test('the rule auditor flags an LP loss that does not match the leaks (engine regression guard)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 921, fake: true, script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, 2])) } : {}) }).start();
const m = h.m;
// simulate an engine bug inside settle: one extra LP lost
const orig = m.settle.bind(m);
m.settle = (plan, res) => { const r = orig(plan, res); h.ps('p_0').lp -= 1; return r; };
const audit = attachAudit(m);
h.toPrep(1);
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.ok(audit.violations.some((x) => /p_0: lost 3 LP, expected min\(10, 2\)/.test(x)), audit.violations.join('\n'));
assert.ok(!audit.violations.some((x) => x.includes('p_1')), 'p_1 is fine');
assert.ok(audit.checks > 10);
m.dispose();
});
test('audited FakeBattle matches (all phases incl. 联防, 机变, Final Assault) report no violations', () => {
for (const [n, seed] of [[1, 930], [2, 931], [3, 932], [4, 933]]) {
const h = makeMatch({
mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: n - 1, seed, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : { leaks: Object.fromEntries(b.players.map((p, i) => [p, (b.round + i + seed) % 3])) }),
});
// AI-played before the start: a single human's briefing is untimed (Match.soloUntimed, user playtest #4 item 3) and
// waits for 准备就绪, which only an autoplaying seat gives by itself
h.autoHumans().start();
const audit = attachAudit(h.m);
for (const ps of h.m.players.values()) ps.lp = 200;
h.runToEnd({ maxSteps: 3e6 });
assert.deepEqual(audit.violations, [], `${n} players:\n${audit.violations.join('\n')}`);
assert.ok(h.m.phase === PHASE.RESULT);
h.m.dispose();
}
});
test('GEO sanity used by the fixes (boss rect, normal rect)', () => {
assert.deepEqual(GEO.BOSS_RECT, { r0: 0, r1: 5, c0: 0, c1: 20 });
});
test('the rule auditor follows the engine: PRTS 联防 helper order (not LP), silent solo presentation steps, a frozen item slot moved right by a level-up', () => {
// co-op: the human (seat 0) plays nothing, the bots field full boards; only the last bot leaks → the helpers are the
// two perfect bots with the most units (unite.js helperOrder), whatever the LP / seat order says
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 3, seed: 934, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.filter((p) => p === 'ai_2').map((p) => [p, 2])) } : {}) }).start();
const m = h.m;
const audit = attachAudit(m);
for (const ps of m.players.values()) ps.lp = 200;
h.drive(() => m.phase === PHASE.UNITE && m.round >= 4, { ready: true });
assert.ok(!m.unitePlan.helpers.some((p) => p.playerId === 'p_0'), `helpers ${m.unitePlan.helpers.map((p) => p.playerId)}: the empty board is never picked first`);
h.autoHumans();
h.runToEnd({ maxSteps: 3e6 });
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
// solo: BATTLE_CHECK / ROUND_START / SETTLE run silently; a frozen item slot follows the chess slots after a level-up
const s = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 935, fake: true }).start();
const sa = attachAudit(s.m);
s.toPrep(1);
const ps = s.ps('p_0');
ps.funds = 50;
const itemAt = ps.shop.slots.findIndex((x) => x && x.kind === 'item');
assert.ok(itemAt >= 0);
const itemId = ps.shop.slots[itemAt].id;
assert.deepEqual(s.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
assert.deepEqual(s.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
s.drive(() => s.m.phase === PHASE.PREP && s.m.round === 2, { ready: true });
const now = ps.shop.slots.findIndex((x) => x && x.kind === 'item');
assert.ok(now > itemAt, `the item slot moved right (${itemAt} → ${now})`);
assert.equal(ps.shop.slots[now].id, itemId, 'the frozen item was kept');
s.drive(() => s.ended != null, { ready: true, maxSteps: 3e6 });
assert.ok(s.ended, 'the solo match ended');
assert.deepEqual(sa.violations, [], sa.violations.join('\n'));
s.m.dispose();
});