Files
ark-stronghold-protocol/test/content/garrisons_battle.test.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

641 lines
39 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.
// IN_BATTLE 特质 (server/sim/content/garrisons/battle.js). Every garrison id carried by a visible chess (and every id
// such a garrison grants) is exercised with its own blackboard numbers; one normal and one elite id per key are also
// asserted with literal numbers. The last test checks that coverage.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getData } from '../../server/data.js';
import { GRANTED_CAP_OVERRIDE } from '../../server/sim/content/garrisons/battle.js';
const D = getData({ log: { warn() {}, error() {}, info() {} } });
const GR = (gid) => D.garrisons[gid];
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const num = (v, d = 0) => (Number.isFinite(+v) && v !== '' && v != null ? +v : d);
const capOf = (gid) => GRANTED_CAP_OVERRIDE[gid] ?? (num(GR(gid).bb.max_add_count_per_battle) > 0 ? num(GR(gid).bb.max_add_count_per_battle) : Infinity);
/** IN_BATTLE garrison ids carried by visible chess, and the ids those grant. */
const VISIBLE_IDS = new Set();
for (const c of Object.values(D.chess)) if (c.visible) for (const g of c.garrisonIds || []) if (GR(g).eventType === 'IN_BATTLE') VISIBLE_IDS.add(g);
for (const g of [...VISIBLE_IDS]) if (GR(g).bbStr.give_garrison_id) VISIBLE_IDS.add(GR(g).bbStr.give_garrison_id);
const idsOfKey = (key) => [...VISIBLE_IDS].filter((g) => GR(g).effectKey === key).sort();
const COVER = new Set();
const cover = (gid) => { assert.ok(GR(gid), gid); COVER.add(gid); };
const rec = (id, garrisonIds, o = {}) => ({ ...chessRec({ id, ...o }), garrisonIds });
const B = (layers = 0, active = true) => ({ count: active ? 3 : 0, active, tier: active ? 1 : 0, layers });
const dummy = (o = {}) => ({ ...enemyRec({ key: 'e_dummy', hp: 1e7, speed: 0, ...o }), tags: o.tags ?? [] });
const gains = (h, pid = 'p1') => h.result().perPlayer[pid].layerGains;
const approx = (a, b, msg = '') => assert.ok(Math.abs(a - b) < 1e-6, `${msg} ${a} ≈ ${b}`);
/** Battle with synthetic operators: units = [{ id, g: garrisonIds, row, col, bonds?, o? }]. */
function battle({ units, bonds = {}, enemies = [], edefs = {}, kind, seed = 3, players } = {}) {
const chess = {};
for (const u of units) chess[u.id] = rec(u.id, u.g ?? [], { bonds: u.bonds ?? [], ...(u.o || {}) });
const h = makeBattle({
kind, seed, bonds, players,
defs: { chess, enemies: { e_dummy: dummy(), ...edefs } },
units: units.map((u) => ({ chessId: u.id, row: u.row, col: u.col })),
enemies, autoFinish: false, timeLimit: 400,
});
h.step(1);
return h;
}
const skill = (h, u) => h.b.emit('skillStart', { unit: u, skill: u.skill, reason: 'test' });
const alwaysHit = (h) => { h.b.rng = Object.assign(() => 0, h.b.rng); };
/**
* Layer-gain scenario of a garrison: the holder's own bonds, the player's bond states and the expected target bonds.
* bond_by_id listed bonds active (0 layers) + an unrelated active bond that must not move
* bond_self holder bonds 炎 / 谢拉格 (active) / 阿戈尔 (inactive) → 炎, 谢拉格
* bond_actived_maxstack 炎 5, 坚守 9 (active), 阿戈尔 50 (inactive) → 坚守
*/
function scenario(gid) {
const st = GR(gid).bbStr;
if (st.bond_type === 'bond_self') return { unitBonds: ['yanShip', 'kjeragShip', 'egirShip'], bonds: { yanShip: B(0), kjeragShip: B(0), egirShip: B(0, false) }, targets: ['yanShip', 'kjeragShip'] };
if (st.bond_type === 'bond_actived_maxstack') return { unitBonds: [], bonds: { yanShip: B(5), steadShip: B(9), egirShip: B(50, false) }, targets: ['steadShip'] };
const t = ids(st.bond_id);
const bonds = Object.fromEntries(t.map((b) => [b, B(0)]));
const other = ['suntShip', 'emptyShip'].find((b) => !t.includes(b));
bonds[other] = B(0);
return { unitBonds: [], bonds, targets: t };
}
const expectAll = (h, targets, n, msg) => {
const g = gains(h);
for (const b of targets) assert.equal(g[b] ?? 0, n, `${msg} ${b}`);
for (const b of Object.keys(g)) assert.ok(targets.includes(b), `${msg}: unexpected gain on ${b}`);
};
// ---------------------------------------------------------------------------------------------------------------------
// layer events
test('useskill (莎草 42 / 菲莱 43 / 百炼嘉维尔 128 / 塑心 90 / 纯烬艾雅法拉 140): per-id amount per skill and per-battle cap', () => {
for (const gid of idsOfKey('act1autochess_gar_event_useskill')) {
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // granted ids: see the ADD_BOND tests
const g = GR(gid);
const sc = scenario(gid);
const row = g.bbStr.bond_add_type === 'by_charcount_samerow';
const units = [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }];
if (row) units.push({ id: 'm1', row: 10, col: 6 }, { id: 'm2', row: 10, col: 8 }, { id: 'o', row: 11, col: 4 });
const h = battle({ units, bonds: sc.bonds });
const per = row ? 3 * num(g.bb.bond_add_count_multi) : num(g.bb.bond_add_count);
skill(h, h.unit('op'));
expectAll(h, sc.targets, Math.min(per, capOf(gid)), gid);
for (let i = 0; i < 29; i++) skill(h, h.unit('op'));
expectAll(h, sc.targets, Math.min(30 * per, capOf(gid)), `${gid} ×30`);
cover(gid);
}
// literal numbers (normal / elite)
const one = (gid, n, bonds, units = []) => {
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }, ...units], bonds });
for (let i = 0; i < n; i++) skill(h, h.unit('op'));
return gains(h);
};
assert.deepEqual(one('garrison_43_a', 2, { sargonShip: B(0) }), { sargonShip: 12 }, '菲莱 +6 per skill');
assert.deepEqual(one('garrison_43_b', 1, { sargonShip: B(0) }), { sargonShip: 12 }, '菲莱 精锐 +12');
assert.deepEqual(one('garrison_42_a', 3, { sargonShip: B(0) }), { sargonShip: 5 }, '莎草: first skill only');
assert.deepEqual(one('garrison_42_b', 3, { sargonShip: B(0) }), { sargonShip: 10 });
assert.deepEqual(one('garrison_128_a', 1, { sargonShip: B(0) }), { sargonShip: 9 });
assert.deepEqual(one('garrison_128_b', 1, { sargonShip: B(0) }), { sargonShip: 18 });
assert.deepEqual(one('garrison_140_a', 1, { yanShip: B(5), steadShip: B(9) }), { steadShip: 3 });
assert.deepEqual(one('garrison_140_b', 1, { yanShip: B(5), steadShip: B(9) }), { steadShip: 6 });
const mates = [{ id: 'm1', row: 10, col: 6 }];
assert.deepEqual(one('garrison_90_a', 1, { yanShip: B(1) }, mates), { yanShip: 2 }, '塑心: 2 in the row × 1');
assert.deepEqual(one('garrison_90_a', 9, { yanShip: B(1) }, mates), { yanShip: 10 }, 'cap 10');
assert.deepEqual(one('garrison_90_b', 9, { yanShip: B(1) }, mates), { yanShip: 20 }, 'elite: × 2, cap 20');
assert.deepEqual(one('garrison_43_a', 1, { sargonShip: B(0, false) }), {}, 'inactive bond: nothing');
});
test('selfkillenemy (送葬人 / 休谟斯 / 斯卡蒂 / 海霓 / 伺夜 / 风笛 / 荒芜拉普兰德): every check_cnt kills, per-id caps', () => {
for (const gid of idsOfKey('act1autochess_gar_event_selfkillenemy')) {
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // 117_b: granted (ADD_BOND test)
const g = GR(gid);
const sc = scenario(gid);
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }], bonds: sc.bonds, enemies: [{ key: 'e_dummy', pos: [9, 9] }] });
const e = h.enemies()[0];
const per = num(g.bb.bond_add_count), every = Math.max(1, num(g.bb.check_cnt, 1));
for (let k = 1; k <= 12; k++) {
h.b.emit('kill', { killer: h.unit('op'), victim: e });
expectAll(h, sc.targets, Math.min(Math.floor(k / every) * per, capOf(gid)), `${gid} after ${k} kills`);
}
cover(gid);
}
const run = (gid, kills, bonds, victim = 'enemy') => {
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }, { id: 'ally', row: 11, col: 4 }], bonds, enemies: [{ key: 'e_dummy', pos: [9, 9] }] });
for (let i = 0; i < kills; i++) h.b.emit('kill', { killer: h.unit('op'), victim: victim === 'ally' ? h.unit('ally') : h.enemies()[0] });
return gains(h);
};
assert.deepEqual(run('garrison_38_a', 5, { egirShip: B(0), steadShip: B(0), raidShip: B(0) }), { egirShip: 2, steadShip: 2, raidShip: 2 }, '斯卡蒂: every 2 kills +1');
assert.deepEqual(run('garrison_38_b', 4, { egirShip: B(0), steadShip: B(0), raidShip: B(0) }), { egirShip: 4, steadShip: 4, raidShip: 4 });
assert.deepEqual(run('garrison_50_a', 5, { victoriaShip: B(0), visiShip: B(0), indomShip: B(0) }), { victoriaShip: 6, visiShip: 6, indomShip: 6 }, '风笛: first 3 kills');
assert.deepEqual(run('garrison_50_b', 5, { victoriaShip: B(0), visiShip: B(0), indomShip: B(0) }), { victoriaShip: 12, visiShip: 12, indomShip: 12 });
assert.deepEqual(run('garrison_152_a', 5, { siracusaShip: B(0) }), { siracusaShip: 6 }, '伺夜 叙拉古 +2 × 3');
assert.deepEqual(run('garrison_153_b', 5, { miraShip: B(0) }), { miraShip: 6 }, '伺夜 精锐 奇迹 +2 × 3');
assert.deepEqual(run('garrison_131_a', 1, { egirShip: B(0), arcaneShip: B(0) }, 'ally'), { egirShip: 3, arcaneShip: 3 }, '海霓: an ally victim counts');
assert.deepEqual(run('garrison_138_a', 1, { preciShip: B(0), lateranoShip: B(0) }, 'ally'), {}, '送葬人: allies do not count');
assert.deepEqual(run('garrison_138_b', 3, { preciShip: B(0), lateranoShip: B(0) }), { preciShip: 6, lateranoShip: 6 }, 'first kill only');
assert.deepEqual(run('garrison_118_a', 60, { siracusaShip: B(0) }), { siracusaShip: 100 }, '荒芜拉普兰德: +2 per kill, cap 100');
});
test('selfdead (幽灵鲨 46 / 砾 75 / 归溟幽灵鲨 40): per knock-out; 归溟幽灵鲨 also on every substitute ⇄ body swap', () => {
for (const gid of idsOfKey('act1autochess_gar_event_selfdead')) {
const sc = scenario(gid);
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }], bonds: sc.bonds });
h.b.dealDamage(null, h.unit('op'), { amount: 1e9, type: 'true' });
h.step(1);
expectAll(h, sc.targets, num(GR(gid).bb.bond_add_count), gid);
cover(gid);
}
const ko = (gid, bonds) => {
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }], bonds });
const u = h.unit('op');
h.b.retreat(u, { reason: 'test' });
assert.deepEqual(gains(h), {}, `${gid}: a retreat is not a knock-out`);
h.b.redeploy(u, { free: true });
h.step(1);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
h.step(1);
return gains(h);
};
assert.deepEqual(ko('garrison_46_a', { egirShip: B(0) }), { egirShip: 3 });
assert.deepEqual(ko('garrison_46_b', { egirShip: B(0) }), { egirShip: 6 });
assert.deepEqual(ko('garrison_75_a', { indomShip: B(0), kazimierzShip: B(0) }), { indomShip: 2 }, '砾 75: 不屈 only (卡西米尔 is garrison_143)');
assert.deepEqual(ko('garrison_75_b', { indomShip: B(0) }), { indomShip: 4 });
const h = battle({ units: [{ id: 'ghost', g: ['garrison_40_b'], row: 11, col: 4, o: { profession: 'SPECIAL', subProfessionId: 'dollkeeper' } }], bonds: { egirShip: B(0), indomShip: B(0) } });
const g = h.unit('ghost');
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
h.step(1);
assert.ok(g.alive && g.trait.doll, 'substitute');
assert.deepEqual(gains(h), { egirShip: 10, indomShip: 10 }, 'body → substitute');
h.run(21);
assert.ok(g.alive && !g.trait.doll, 'swapped back');
assert.deepEqual(gains(h), { egirShip: 20, indomShip: 20 }, 'substitute → body');
checkInvariants(h.b);
});
test('consume_ammo (莫斯提马 22 x-5 / 圣约送葬人 23+55 / 蕾缪安 24 row of 3): per consume_count ammo, per-id caps', () => {
for (const gid of idsOfKey('act1autochess_gar_event_consume_ammo')) {
const g = GR(gid);
const sc = scenario(gid);
const x5 = g.bbStr.range_id === 'x-5';
const units = [{ id: 'op', g: [gid], row: 10, col: 5, bonds: sc.unitBonds }, { id: 'nb', row: 11, col: 5 }, { id: 'far', row: 12, col: 8 }];
if (g.bbStr.conditionkey === 'character_same_row') units.push({ id: 'r1', row: 10, col: 7 }, { id: 'r2', row: 10, col: 9 });
const h = battle({ units, bonds: sc.bonds });
const shooter = h.unit(x5 ? 'nb' : 'op');
const every = num(g.bb.consume_count, 1), per = num(g.bb.bond_add_count);
for (let i = 0; i < every * 3; i++) h.b.emit('ammoUsed', { unit: h.unit('far') });
expectAll(h, sc.targets, 0, `${gid}: ammo of an operator out of scope`);
for (let k = 1; k <= 12; k++) {
for (let i = 0; i < every; i++) h.b.emit('ammoUsed', { unit: shooter, left: 0 });
expectAll(h, sc.targets, Math.min(k * per, capOf(gid)), `${gid} × ${k}`);
}
cover(gid);
}
const exe = (gids) => {
const h = battle({ units: [{ id: 'exe', g: gids, row: 10, col: 4 }], bonds: { lateranoShip: B(0), visiShip: B(0) } });
const u = h.unit('exe');
const out = [];
for (const n of [7, 70]) { for (let i = 0; i < n; i++) h.b.emit('ammoUsed', { unit: u, left: 0 }); out.push({ ...gains(h) }); }
return out;
};
assert.deepEqual(exe(['garrison_23_a', 'garrison_55_a']), [{ lateranoShip: 7, visiShip: 3 }, { lateranoShip: 49, visiShip: 21 }], '圣约送葬人: 7 triggers max');
assert.deepEqual(exe(['garrison_23_b', 'garrison_55_b']), [{ lateranoShip: 14, visiShip: 6 }, { lateranoShip: 98, visiShip: 42 }]);
const rem = (gid, n) => {
const h = battle({ units: [{ id: 'rem', g: [gid], row: 10, col: 3 }, ...Array.from({ length: n - 1 }, (_, i) => ({ id: `m${i}`, row: 10, col: 5 + i * 2 }))], bonds: { lateranoShip: B(0), preciShip: B(0) } });
for (let i = 0; i < 10; i++) h.b.emit('ammoUsed', { unit: h.unit('rem') });
return gains(h);
};
assert.deepEqual(rem('garrison_24_a', 2), {}, '蕾缪安: 2 in the row');
assert.deepEqual(rem('garrison_24_a', 3), { lateranoShip: 2, preciShip: 2 });
assert.deepEqual(rem('garrison_24_b', 3), { lateranoShip: 4, preciShip: 4 });
const most = (gid) => {
const h = battle({ units: [{ id: 'most', g: [gid], row: 10, col: 5 }, { id: 'nb', row: 10, col: 6 }, { id: 'nb2', row: 9, col: 5 }], bonds: { lateranoShip: B(0) } });
for (let i = 0; i < 3; i++) h.b.emit('ammoUsed', { unit: h.unit('nb') });
for (let i = 0; i < 3; i++) h.b.emit('ammoUsed', { unit: h.unit('nb2') });
return gains(h);
};
assert.deepEqual(most('garrison_22_a'), { lateranoShip: 1 }, '莫斯提马: ammo of the 4 neighbours is pooled');
assert.deepEqual(most('garrison_22_b'), { lateranoShip: 2 });
});
test('enemy_abflag_inrange (初雪 / 银灰 28): entering 冻结 in range × prob; a refreshed freeze is not a new entry', () => {
for (const gid of idsOfKey('act1autochess_gar_event_enemy_abflag_inrange')) {
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // 29: granted by 凛御银灰 (ADD_BOND test)
const h = battle({ units: [{ id: 'sa', g: [gid], row: 10, col: 4 }], bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 5] }, { key: 'e_dummy', pos: [12, 9] }] });
alwaysHit(h);
const near = h.enemies().find((e) => Math.round(e.y) === 10), far = h.enemies().find((e) => Math.round(e.y) === 12);
h.b.applyStatus(far, 'freeze', { duration: 1 });
assert.deepEqual(gains(h), {}, `${gid}: out of range`);
h.b.applyStatus(near, 'freeze', { duration: 1 });
const per = num(GR(gid).bb.bond_add_count);
assert.deepEqual(gains(h), { kjeragShip: per }, gid);
h.b.applyStatus(near, 'freeze', { duration: 2 });
assert.deepEqual(gains(h), { kjeragShip: per }, `${gid}: already frozen`);
h.b.removeStatus(near, 'freeze');
h.b.applyStatus(near, 'freeze', { duration: 1 });
assert.deepEqual(gains(h), { kjeragShip: 2 * per }, `${gid}: frozen again`);
cover(gid);
}
// probability (deterministic battle rng): ≈ 50 % of 40 entries × 2
const h = battle({ units: [{ id: 'sa', g: ['garrison_28_b'], row: 10, col: 4 }], bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 5] }], seed: 11 });
const e = h.enemies()[0];
for (let i = 0; i < 40; i++) { h.b.removeStatus(e, 'freeze'); h.b.applyStatus(e, 'freeze', { duration: 1 }); }
const g = gains(h).kjeragShip;
assert.ok(g > 10 && g < 70 && g % 2 === 0, `≈ 50 % of 40 × 2, got ${g}`);
// cold → cold ⇒ freeze counts as an entry
const c = battle({ units: [{ id: 'sa', g: ['garrison_28_a'], row: 10, col: 4 }], bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 5] }] });
alwaysHit(c);
c.b.applyStatus(c.enemies()[0], 'cold', { duration: 5 });
c.b.applyStatus(c.enemies()[0], 'cold', { duration: 5 });
assert.deepEqual(gains(c), { kjeragShip: 1 });
checkInvariants(c.b);
});
test('onstart (砾 143 / 瑕光 106 / 史尔特尔 107 / 锏 155): every deploy, per-id caps', () => {
for (const gid of idsOfKey('act2autochess_gar_event_onstart')) {
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // 108: granted by 远牙 (ADD_BOND test)
const sc = scenario(gid);
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }], bonds: sc.bonds });
const per = num(GR(gid).bb.bond_add_count);
expectAll(h, sc.targets, Math.min(per, capOf(gid)), `${gid} first deploy`);
const u = h.unit('op');
for (let k = 2; k <= 9; k++) {
h.b.retreat(u, { reason: 'test' });
h.b.redeploy(u, { free: true });
h.step(1);
expectAll(h, sc.targets, Math.min(k * per, capOf(gid)), `${gid} deploy ${k}`);
}
cover(gid);
}
const deploys = (gid, n, bonds, unitBonds = []) => {
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: unitBonds }], bonds });
const u = h.unit('op');
for (let i = 1; i < n; i++) { h.b.retreat(u, { reason: 'test' }); h.b.redeploy(u, { free: true }); h.step(1); }
return gains(h);
};
assert.deepEqual(deploys('garrison_143_a', 3, { kazimierzShip: B(0) }), { kazimierzShip: 3 });
assert.deepEqual(deploys('garrison_143_b', 3, { kazimierzShip: B(0) }), { kazimierzShip: 6 });
assert.deepEqual(deploys('garrison_107_a', 9, { raidShip: B(0) }), { raidShip: 50 }, '史尔特尔 8 × 7 capped at 50');
assert.deepEqual(deploys('garrison_107_b', 9, { raidShip: B(0) }), { raidShip: 100 });
const self = { yanShip: B(0), kjeragShip: B(0, false) };
assert.deepEqual(deploys('garrison_106_a', 5, self, ['yanShip', 'kjeragShip']), { yanShip: 12 }, '瑕光: own active bonds, cap 12');
assert.deepEqual(deploys('garrison_155_b', 5, self, ['yanShip', 'kjeragShip']), { yanShip: 48 }, '锏 精锐: +16, cap 48');
});
test('allyenemy_sleepstun_inrange (缇缇 125): enemies or operators entering 沉睡 / 晕眩 in range; cap 24 / 48', () => {
for (const gid of idsOfKey('act2autochess_gar_event_allyenemy_sleepstun_inrange')) {
const per = num(GR(gid).bb.bond_add_count);
const h = battle({ units: [{ id: 'tt', g: [gid], row: 10, col: 4 }, { id: 'ally', row: 10, col: 5 }], bonds: { sargonShip: B(0), preciShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [12, 9] }, { key: 'e_dummy', pos: [10, 5] }] });
const far = h.enemies().find((e) => Math.round(e.y) === 12), near = h.enemies().find((e) => Math.round(e.y) === 10);
h.b.applyStatus(h.unit('ally'), 'sleep', { duration: 1 });
expectAll(h, ['sargonShip', 'preciShip'], per, `${gid}: an operator in range fell asleep`);
h.b.applyStatus(far, 'stun', { duration: 1 });
expectAll(h, ['sargonShip', 'preciShip'], per, `${gid}: enemy out of range`);
h.b.applyStatus(near, 'stun', { duration: 5 });
h.b.applyStatus(near, 'stun', { duration: 5 });
expectAll(h, ['sargonShip', 'preciShip'], 2 * per, `${gid}: a stun refresh is not a new entry`);
for (let i = 0; i < 40; i++) { h.b.removeStatus(near, 'stun'); h.b.applyStatus(near, 'stun', { duration: 1 }); }
expectAll(h, ['sargonShip', 'preciShip'], capOf(gid), `${gid}: cap`);
cover(gid);
}
assert.equal(capOf('garrison_125_a'), 24);
assert.equal(capOf('garrison_125_b'), 48);
});
// ---------------------------------------------------------------------------------------------------------------------
// ADD_BOND grants
test('ADD_BOND 华法琳 72: the front operator gets garrison_95 (own active bonds +1 / +2), cap overridden to 12 / 24', () => {
for (const [gid, per, cap] of [['garrison_72_a', 1, 12], ['garrison_72_b', 2, 24]]) {
const give = GR(gid).bbStr.give_garrison_id;
const h = battle({
units: [{ id: 'warfarin', g: [gid], row: 10, col: 4 }, { id: 'front', row: 10, col: 5, bonds: ['yanShip', 'kjeragShip', 'egirShip'] }, { id: 'side', row: 11, col: 4, bonds: ['yanShip'] }],
bonds: { yanShip: B(0), kjeragShip: B(0), egirShip: B(0, false) },
});
const f = h.unit('front');
assert.equal(num(GR(give).bb.bond_add_count), per);
skill(h, f);
assert.deepEqual(gains(h), { yanShip: per, kjeragShip: per });
for (let i = 0; i < 30; i++) skill(h, f);
assert.deepEqual(gains(h), { yanShip: cap, kjeragShip: cap }, `${gid}: 12/24 override, not the data's 7/14`);
skill(h, h.unit('warfarin'));
skill(h, h.unit('side'));
assert.equal(gains(h).yanShip, cap, '华法琳 herself / other operators do not carry the trait');
assert.ok(h.eventsOf('fx').some((e) => e[1] === 'garrisonGrant' && e[4].id === f.id && e[4].key === give), 'grant fx');
checkInvariants(h.b);
cover(gid); cover(give);
}
});
test('ADD_BOND 寒芒克洛丝 73 (front → 精准 +1 / +2, cap 10 / 20) and 凛御银灰 126 (front 【谢拉格】 → 60 % freeze trait)', () => {
for (const [gid, per, cap] of [['garrison_73_a', 1, 10], ['garrison_73_b', 2, 20]]) {
const give = GR(gid).bbStr.give_garrison_id;
const h = battle({ units: [{ id: 'cross', g: [gid], row: 10, col: 4 }, { id: 'front', row: 10, col: 5 }], bonds: { preciShip: B(0) } });
skill(h, h.unit('front'));
assert.deepEqual(gains(h), { preciShip: per });
for (let i = 0; i < 30; i++) skill(h, h.unit('front'));
assert.deepEqual(gains(h), { preciShip: cap }, gid);
cover(gid); cover(give);
}
for (const [gid, per] of [['garrison_126_a', 1], ['garrison_126_b', 2]]) {
const give = GR(gid).bbStr.give_garrison_id;
assert.equal(num(GR(give).bb.prob), 0.6);
const h = battle({
units: [{ id: 'svash', g: [gid], row: 10, col: 4 }, { id: 'kj', row: 10, col: 5, bonds: ['kjeragShip'] }],
bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 6] }],
});
alwaysHit(h);
assert.deepEqual(h.eventsOf('fx').filter((e) => e[1] === 'garrisonGrant').map((e) => e[4].key), [give]);
h.b.applyStatus(h.enemies()[0], 'freeze', { duration: 1 });
assert.deepEqual(gains(h), { kjeragShip: per }, `${gid}: the granted trait fires (enemy in the front operator's range)`);
const other = battle({ units: [{ id: 'svash', g: [gid], row: 10, col: 4 }, { id: 'o', row: 10, col: 5, bonds: ['yanShip'] }], bonds: { kjeragShip: B(0) } });
assert.equal(other.eventsOf('fx').filter((e) => e[1] === 'garrisonGrant').length, 0, 'not a 【谢拉格】 operator');
cover(gid); cover(give);
}
});
test('ADD_BOND 荒芜拉普兰德 118_b: every 【叙拉古】 operator (herself included) gets "击倒敌人时 叙拉古 +2" (garrison_117_b)', () => {
const give = GR('garrison_118_b').bbStr.give_garrison_id;
const h = battle({
units: [{ id: 'lap', g: ['garrison_118_b'], row: 10, col: 4, bonds: ['siracusaShip'] }, { id: 'sira', row: 11, col: 6, bonds: ['siracusaShip'] }, { id: 'other', row: 12, col: 6 }],
bonds: { siracusaShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [9, 9] }],
});
const e = h.enemies()[0];
for (const id of ['lap', 'sira', 'other']) h.b.emit('kill', { killer: h.unit(id), victim: e });
assert.deepEqual(gains(h), { siracusaShip: 4 });
assert.equal(capOf(give), 200);
cover('garrison_118_b'); cover(give);
});
test('ADD_BOND 远牙 148: the rightmost operator of the row gets "<部署时>卡西米尔/精准 +4 / +8 (cap 24 / 48)"', () => {
for (const [gid, per, cap] of [['garrison_148_a', 4, 24], ['garrison_148_b', 8, 48]]) {
const give = GR(gid).bbStr.give_garrison_id;
const h = battle({
units: [{ id: 'fang', g: [gid], row: 10, col: 3 }, { id: 'mid', row: 10, col: 5 }, { id: 'right', row: 10, col: 8 }, { id: 'other', row: 11, col: 9 }],
bonds: { kazimierzShip: B(0), preciShip: B(0) },
});
assert.deepEqual(gains(h), { kazimierzShip: per, preciShip: per }, 'initial deployment of the rightmost operator');
const r = h.unit('right');
for (let i = 0; i < 8; i++) { h.b.retreat(r, { reason: 'test' }); h.b.redeploy(r, { free: true }); h.step(1); }
assert.deepEqual(gains(h), { kazimierzShip: cap, preciShip: cap });
const alone = battle({ units: [{ id: 'fang', g: [gid], row: 10, col: 8 }, { id: 'left', row: 10, col: 4 }], bonds: { kazimierzShip: B(0), preciShip: B(0) } });
assert.deepEqual(gains(alone), { kazimierzShip: per, preciShip: per }, '远牙 herself when she is the rightmost');
checkInvariants(h.b);
cover(gid); cover(give);
}
});
test('ADD_BOND 耀骑士临光 145 / 160 (real chess): self + front get 144 (redeploy) and 159 (ASPD) once each', () => {
for (const [id, as, rd] of [['chess_char_6_17_a', 0.5, -0.015], ['chess_char_6_17_b', 1, -0.03]]) {
const sfx = id.slice(-1);
const h = makeBattle({
defs: { chess: { front: rec('front', []) } },
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'front', row: 10, col: 5 }],
bonds: { kazimierzShip: B(9) }, autoFinish: false,
});
h.step(1);
const f = h.unit('front'), n = h.unit(id);
for (const u of [f, n]) {
approx(u.findBuff(`gar:garrison_159_${sfx}`).mods.aspd, 3 * as, '9 layers / 3 × ASPD');
approx(u.findBuff(`gar:garrison_144_${sfx}`).mods.redeployMul, 1 + 3 * rd, 'redeploy');
assert.equal(u.buffs.filter((b) => b.key === `gar:garrison_159_${sfx}`).length, 1);
}
assert.ok(!f.findBuff(`gar:garrison_144_${sfx}`).mods.aspd, 'ASPD comes from 159 only (no double count)');
checkInvariants(h.b);
for (const g of D.chess[id].garrisonIds) cover(g);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 魔王
test('魔王 59: +1 / +2 on trait gains of the operator in front (not counted toward its cap); not on other reasons', () => {
for (const [gid, extra] of [['garrison_59_a', 1], ['garrison_59_b', 2]]) {
assert.equal(num(GR(gid).bb.extra_cnt), extra);
const h = battle({
units: [{ id: 'mw', g: [gid], row: 10, col: 4 }, { id: 'front', g: ['garrison_42_a'], row: 10, col: 5 }, { id: 'side', g: ['garrison_43_a'], row: 11, col: 4 }],
bonds: { sargonShip: B(0) },
});
skill(h, h.unit('front'));
skill(h, h.unit('front'));
assert.equal(gains(h).sargonShip, 5 + extra, 'first skill only (cap 5 not eaten by the extra)');
skill(h, h.unit('side'));
assert.equal(gains(h).sargonShip, 11 + extra, 'side operator: no extra');
h.b.addLayers('p1', 'sargonShip', 3, 'bond', { source: h.unit('front') });
assert.equal(gains(h).sargonShip, 14 + extra, 'non-garrison gain: no extra');
cover(gid);
}
});
test('regression: 魔王 also boosts the "被击倒时" gain of the operator that was knocked out in front of it', () => {
const h = battle({ units: [{ id: 'mw', g: ['garrison_59_a'], row: 10, col: 4 }, { id: 'ghost', g: ['garrison_46_a'], row: 10, col: 5 }], bonds: { egirShip: B(0) } });
h.b.dealDamage(null, h.unit('ghost'), { amount: 1e9, type: 'true' });
h.step(1);
assert.deepEqual(gains(h), { egirShip: 3 + 1 });
const far = battle({ units: [{ id: 'mw', g: ['garrison_59_a'], row: 10, col: 4 }, { id: 'ghost', g: ['garrison_46_a'], row: 11, col: 5 }], bonds: { egirShip: B(0) } });
far.b.dealDamage(null, far.unit('ghost'), { amount: 1e9, type: 'true' });
far.step(1);
assert.deepEqual(gains(far), { egirShip: 3 });
});
test('no IN_BATTLE gains in 联防 / boss fields', () => {
for (const kind of ['unite', 'boss']) {
const h = battle({ kind, units: [{ id: 'surt', g: ['garrison_107_a', 'garrison_43_a'], row: 10, col: 4 }], bonds: { raidShip: B(0), sargonShip: B(0) } });
skill(h, h.unit('surt'));
assert.deepEqual(gains(h), {}, kind);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// stat traits
test('GAIN_BUFF (哈洛德 / 锡人 / 迷迭香 03, 流星 137) and 古米 08: direct multipliers / SP regen from the blackboard', () => {
for (const gid of [...idsOfKey('GAIN_BUFF'), ...idsOfKey('attr_common_global_buff')]) {
const bb = GR(gid).bb;
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }] });
const u = h.unit('op');
if (GR(gid).effectKey === 'GAIN_BUFF') {
approx(u.s.atk, 500 * bb.atk, `${gid} ATK`);
approx(u.s.maxHp, 2000 * bb.max_hp, `${gid} HP`);
} else {
approx(u.s.spRecovery, 1 + bb.sp_recovery_per_sec, `${gid} SP`);
}
cover(gid);
}
const stat = (gid) => { const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }] }); return h.unit('op').s; };
assert.equal(Math.round(stat('garrison_03_a').atk), 600);
assert.equal(Math.round(stat('garrison_03_b').maxHp), 2800);
assert.equal(Math.round(stat('garrison_137_a').atk), 625);
assert.equal(Math.round(stat('garrison_137_b').maxHp), 3000);
approx(stat('garrison_08_a').spRecovery, 1.15);
approx(stat('garrison_08_b').spRecovery, 1.3);
});
test('attrByBond (26 ids): floor(Σ layers of the listed ACTIVE bonds / divide_num) × per-step values; live recompute', () => {
for (const gid of idsOfKey('act1autochess_gar_eff_attrByBond')) {
const { bb, bbStr } = GR(gid);
const list = ids(bbStr.bond_id);
const div = num(bb.divide_num, 1);
const L = 2 * div + 1;
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }], bonds: Object.fromEntries(list.map((b) => [b, B(L)])) });
const k = Math.floor((L * list.length) / div);
const m = h.unit('op').findBuff(`gar:${gid}`).mods;
const want = {};
// "每叠加N层…+X%" is 直接乘算: the additive percentage bucket (support directMods)
if (bb.atk) want.atkPct = bb.atk * k;
if (bb.max_hp) want.hpPct = bb.max_hp * k;
if (bb.def) want.defPct = bb.def * k;
if (bb.attack_speed) want.aspd = bb.attack_speed * k;
if (bb.hp_recovery_per_sec) want.hpRegen = bb.hp_recovery_per_sec * k;
if (bb.sp_recovery_per_sec) want.spRecoveryFlat = bb.sp_recovery_per_sec * k;
assert.deepEqual(Object.keys(m).sort(), Object.keys(want).sort(), gid);
for (const [key, v] of Object.entries(want)) approx(m[key], v, `${gid} ${key}`);
cover(gid);
}
const h = battle({
units: [{ id: 'mz', g: ['garrison_07_a'], row: 9, col: 4 }, { id: 'ur', g: ['garrison_12_b'], row: 9, col: 6 }, { id: 'rs', g: ['garrison_09_b'], row: 12, col: 6 },
{ id: 'yx', g: ['garrison_16_a'], row: 11, col: 4 }, { id: 'yx2', g: ['garrison_16_b'], row: 11, col: 6 }, { id: 'sk', g: ['garrison_84_b'], row: 10, col: 4 }],
bonds: { egirShip: B(25), yanShip: B(6), kjeragShip: B(30, false), lateranoShip: B(10) },
});
approx(h.unit('mz').s.atk, 500 * 1.08, '水月: 25 / 3 = 8 × 1 %');
approx(h.unit('ur').s.atk, 500 * 1.24, '乌尔比安 精锐: 25 / 2 = 12 × 2 %');
approx(h.unit('rs').s.atk, 500 * (1 + 0.02 * 13), '迷迭香 精锐 core bonds: (25 + 6 + 10) / 3 = 13 × 2 %, inactive 谢拉格 ignored');
assert.equal(h.unit('yx').findBuff('gar:garrison_16_a').mods.aspd, 2, '隐现: 10 / 5 × 1');
assert.equal(h.unit('yx2').findBuff('gar:garrison_16_b').mods.aspd, 4);
assert.deepEqual(h.unit('sk').findBuff('gar:garrison_84_b').mods, { hpRegen: 200, spRecoveryFlat: 0.6 }, '浊心斯卡蒂: 2 steps × (100 HP/s, 0.3 SP/s)');
h.b.addLayers('p1', 'egirShip', 2, 'test');
h.step(2);
approx(h.unit('mz').s.atk, 500 * 1.09, 'recomputed after a layer gain');
checkInvariants(h.b);
});
test('respawnTimeByBond 144, attrByBond_add_onstart 115 (玛恩纳, 100 s after each deploy), ab_damageScaleByBond 101 (仇白)', () => {
for (const gid of idsOfKey('act1autochess_gar_eff_respawnTimeByBond')) {
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }], bonds: { kazimierzShip: B(10) } });
approx(h.unit('op').findBuff(`gar:${gid}`).mods.redeployMul, 1 + 3 * GR(gid).bb.respawn_time, gid);
cover(gid);
}
for (const gid of idsOfKey('act2autochess_gar_eff_attrByBond_add_onstart')) {
const bb = GR(gid).bb;
const h = battle({ units: [{ id: 'mn', g: [gid], row: 9, col: 4 }], bonds: { kazimierzShip: B(12) } });
const mn = h.unit('mn');
assert.deepEqual(mn.findBuff(`gar:${gid}`).mods, { atkFlat: 2 * bb.atk, hpFlat: 2 * bb.max_hp }, `${gid}: 12 / 5 = 2 steps`);
h.b.addLayers('p1', 'kazimierzShip', 3, 'test');
h.step(2);
assert.deepEqual(mn.findBuff(`gar:${gid}`).mods, { atkFlat: 3 * bb.atk, hpFlat: 3 * bb.max_hp }, 'live');
const atk = mn.s.atk;
h.run(bb.duration + 1);
assert.equal(mn.findBuff(`gar:${gid}`), null, `expired after ${bb.duration} s`);
assert.ok(mn.s.atk < atk);
h.b.retreat(mn, { reason: 'test' });
h.b.redeploy(mn, { free: true });
h.step(1);
assert.ok(mn.findBuff(`gar:${gid}`), 'again after a redeploy');
cover(gid);
}
const mn = battle({ units: [{ id: 'mn', g: ['garrison_115_a'], row: 9, col: 4 }], bonds: { kazimierzShip: B(10) } }).unit('mn');
assert.deepEqual(mn.findBuff('gar:garrison_115_a').mods, { atkFlat: 20, hpFlat: 100 });
approx(mn.s.atk, 520, '基础攻击力 +20');
for (const [gid, per] of [['garrison_101_a', 0.01], ['garrison_101_b', 0.02]]) {
const h = battle({ units: [{ id: 'qb', g: [gid], row: 12, col: 4 }], enemies: [{ key: 'e_dummy', pos: [12, 9] }], bonds: { yanShip: B(6), raidShip: B(3) } });
const t = h.enemies()[0];
const lost = () => { const hp = t.hp; h.b.dealDamage(h.unit('qb'), t, { amount: 1000, type: 'true' }); return Math.round(hp - t.hp); };
assert.equal(lost(), 1000);
h.b.applyStatus(t, 'bind', { duration: 5 });
assert.equal(lost(), Math.round(1000 * (1 + 3 * per)), `${gid}: (6 + 3) / 3 × ${per}`);
h.b.removeStatus(t, 'bind');
h.b.applyStatus(t, 'sluggish', { duration: 5 });
assert.equal(lost(), Math.round(1000 * (1 + 3 * per)));
cover(gid);
}
});
test('attack_enemy (深巡 18 海怪, 跃跃 19 无人机): ATK ×1.5 / ×2 against tagged enemies only', () => {
for (const gid of idsOfKey('act1autochess_gar_eff_attack_enemy')) {
const { bb, bbStr } = GR(gid);
const h = battle({
units: [{ id: 'op', g: [gid], row: 10, col: 4 }],
edefs: { e_tag: dummy({ key: 'e_tag', tags: [bbStr.check_tag] }) },
enemies: [{ key: 'e_tag', pos: [9, 9] }, { key: 'e_dummy', pos: [12, 9] }],
});
const tag = h.enemies().find((e) => e.defId.endsWith('e_tag')), plain = h.enemies().find((e) => e.defId.endsWith('e_dummy'));
const lost = (e) => { const hp = e.hp; h.b.dealDamage(h.unit('op'), e, { amount: 100, type: 'true' }); return Math.round(hp - e.hp); };
assert.equal(lost(tag), Math.round(100 * bb.atk), gid);
assert.equal(lost(plain), 100);
cover(gid);
}
assert.equal(GR('garrison_18_a').bb.atk, 1.5);
assert.equal(GR('garrison_19_b').bb.atk, 2);
assert.deepEqual([GR('garrison_18_b').bbStr.check_tag, GR('garrison_19_a').bbStr.check_tag], ['seamonster', 'drone']);
assert.ok(Object.values(D.enemies).some((e) => (e.tags || []).includes('seamonster')) && Object.values(D.enemies).some((e) => (e.tags || []).includes('drone')));
});
test('弱点伤害 (宴 / 流星 / 伺夜 garrison_01): phys / arts become whichever deals more; ties keep the type', () => {
for (const gid of idsOfKey('act1autochess_gar_eff_chaos')) {
const h = battle({
units: [{ id: 'op', g: [gid], row: 11, col: 4 }],
edefs: { e_def: dummy({ key: 'e_def', def: 1000 }), e_res: dummy({ key: 'e_res', res: 90 }) },
enemies: [{ key: 'e_def', pos: [10, 9] }, { key: 'e_res', pos: [11, 9] }, { key: 'e_dummy', pos: [12, 9] }],
});
const E = (key) => h.enemies().find((e) => e.defId.endsWith(key));
const lost = (e, dmg) => { const hp = e.hp; h.b.dealDamage(h.unit('op'), e, dmg); return Math.round(hp - e.hp); };
assert.equal(lost(E('e_def'), { amount: 500, type: 'phys' }), 500, `${gid}: DEF 1000 ⇒ arts`);
assert.equal(lost(E('e_res'), { amount: 500, type: 'arts' }), 500, `${gid}: RES 90 ⇒ phys`);
assert.equal(lost(E('e_def'), { amount: 500, type: 'true' }), 500, 'true damage untouched');
cover(gid);
}
});
test('regression: 弱点伤害 keeps the type on a tie, counts the attacker\'s DEF ignore, and runs after ATK multipliers', () => {
const h = battle({
units: [{ id: 'op', g: ['garrison_01_a'], row: 11, col: 4 }, { id: 'sea', g: ['garrison_01_b', 'garrison_18_b'], row: 12, col: 4 }],
edefs: { e_mix: dummy({ key: 'e_mix', def: 300, res: 50 }), e_sea: dummy({ key: 'e_sea', def: 150, res: 50, tags: ['seamonster'] }) },
enemies: [{ key: 'e_dummy', pos: [10, 9] }, { key: 'e_mix', pos: [11, 9] }, { key: 'e_sea', pos: [12, 9] }],
});
const types = [];
h.b.on('damaged', (c) => { if (c.dmg) types.push(c.dmg.type); });
const zero = h.enemies().find((e) => e.defId.endsWith('e_dummy'));
h.b.dealDamage(h.unit('op'), zero, { amount: 500, type: 'arts' });
h.b.dealDamage(h.unit('op'), zero, { amount: 500, type: 'phys' });
assert.deepEqual(types.splice(0), ['arts', 'phys'], 'DEF 0 / RES 0: a tie keeps the original type');
const mix = h.enemies().find((e) => e.defId.endsWith('e_mix'));
h.b.dealDamage(h.unit('op'), mix, { amount: 500, type: 'phys' });
assert.deepEqual(types.splice(0), ['arts'], '500 − 300 = 200 < 250 ⇒ arts');
h.unit('op').s.defIgnoreFlat = 200;
h.b.dealDamage(h.unit('op'), mix, { amount: 500, type: 'arts' });
assert.deepEqual(types.splice(0), ['phys'], 'with 200 DEF ignore: 400 > 250 ⇒ phys');
const sea = h.enemies().find((e) => e.defId.endsWith('e_sea'));
h.b.dealDamage(h.unit('sea'), sea, { amount: 200, type: 'arts' });
assert.deepEqual(types.splice(0), ['phys'], 'decided on the multiplied amount: 400 − 150 = 250 > 200 ⇒ phys (on 200: 100 = 100 → arts)');
});
test('风丸 93 (NONE): no battle effect (the 2-copy merge is a prep rule, see garrisons_meta)', () => {
for (const gid of idsOfKey('NONE')) {
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }] });
assert.equal(h.unit('op').buffs.filter((b) => String(b.key).startsWith('gar:')).length, 0);
assert.deepEqual(h.b.errors, []);
cover(gid);
}
});
test('real data: every owned IN_BATTLE garrison installs without errors on its real chess', () => {
const owners = new Set();
for (const g of Object.values(D.garrisons)) if (g.eventType === 'IN_BATTLE') for (const o of g.owners) owners.add(o);
const list = [...owners].sort();
for (let i = 0; i < list.length; i += 8) {
const units = list.slice(i, i + 8).map((id, k) => ({ chessId: id, row: 9 + (k % 4), col: 3 + 2 * Math.floor(k / 4) }));
const bonds = Object.fromEntries(Object.keys(D.bonds).map((b) => [b, B(12)]));
const h = makeBattle({ units, bonds, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 0, count: 6, interval: 1 }], seed: i + 1, timeLimit: 40 });
h.runToEnd(60);
assert.deepEqual(h.b.errors.filter((e) => /garrison|content:garrisons/.test(JSON.stringify(e))), [], `batch ${i}`);
checkInvariants(h.b);
}
});
test('coverage: every IN_BATTLE garrison id of a visible chess (and every id they grant) was exercised above', () => {
const missing = [...VISIBLE_IDS].filter((g) => !COVER.has(g)).sort();
assert.deepEqual(missing, []);
assert.ok(VISIBLE_IDS.size >= 100, `${VISIBLE_IDS.size} ids`);
});