Files
ark-stronghold-protocol/test/content/items.test.js
T
sganggs f8e35d5055 feedback1 WA: Lappland counts her own first refresh; Mutation Cell is not consumed
Player reports after 0.1.0, #1 and #4.

#1 拉普兰德 (garrison_123, SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT) checked the
player's refresh count of the round, so a copy bought after the round's first
manual refresh never stacked that round. Players report the official
behaviour: the first refresh after acquiring her also stacks. Each copy now
counts the manual refreshes it witnessed this round (board or hand) through a
per-piece round counter (PlayerState.pieceRoundCount, ctx.incPieceCounter); an
elite merged this round keeps the highest count of its copies [ASSUMED]. The
"本回合每刷新过1次" multiplier traits keep the player's count.

#4 突变细胞 destroyed itself after one transformation. Official text never
says 销毁 (every consumable's does), PRTS notes "原干员销毁,获得一名高一阶的随机
初始干员", and players re-inject the cell every round. The built-in now replaces
the carrier with a random normal operator one tier higher (max 6) and returns
the carrier's equipment, the cell included, to the hand
(transformChess returnItems); the content override is gone. The item card
shows the rule (items.json note, build-data ITEM_RULES); bots inject their
least valuable single normal operator below tier 6 (bot.js cellTarget).
2026-10-03 01:47:52 +08:00

1107 lines
58 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.
// Equipment (56 EQUIP, normal + golden) and the 3 Arts: battle side (server/sim/content/items/battle.js) with synthetic
// operators / enemies for exact numbers, prep side (items/meta.js + the engine built-ins it keeps) through the real
// registry and dispatcher. Numbers are the data blackboards (research 04 §3–§7); every item key is covered (last test).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeMatch, give, giveItem, DATA } from '../match/harness.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { lendItemEffects, itemGrants, PRIO_REVIVE } from '../../server/sim/content/items/battle.js';
import { unitBonds } from '../../server/sim/content/support/index.js';
const QUIET = { warn() {}, error() {}, info() {} };
const REG = createRegistry({ log: QUIET });
const A = (k) => `chess_item_${k}_e_a`;
const B = (k) => `chess_item_${k}_e_b`;
const COVER = new Set();
const cover = (...ids) => { for (const id of ids) { assert.ok(DATA.items[id], id); COVER.add(id); } };
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} ≠ ${b}`);
// ---------------------------------------------------------------------------------------------------------------
// battle helpers
/** Synthetic operator: 2000 HP, 500 ATK, 200 DEF, 10 RES, ASPD 100, 1 s attacks, 20 s redeploy, time SP 1/s. */
function op(id, o = {}) {
return chessRec({
id, profession: o.profession ?? 'WARRIOR', bonds: o.bonds ?? [], tier: o.tier ?? 1, golden: !!o.golden,
rangeGrid: o.range ?? [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]],
stats: { maxHp: 2000, atk: 500, def: 200, res: 10, aspd: 100, bat: 1, respawnTime: 20, spRecovery: 1, blockCnt: 2, ...(o.stats || {}) },
skill: { spCost: 60, duration: 5, initSp: 0, ...(o.skill || {}) },
});
}
const DUMMY = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
/**
* Battle with synthetic ops (`ops`: { id: record }), units (board coords), static dummies at `foes` [[r, c, key?]…].
*/
function fight({ ops = { t_op: op('t_op') }, units, foes = [], enemies = {}, kits, bonds, players, stageId, round, setup } = {}) {
const defsEnemies = { e_d: DUMMY(), ...enemies };
return makeBattle({
defs: { chess: ops, enemies: defsEnemies }, units, players, stageId, round, kits, bonds, setup,
enemies: foes.map(([r, c, key]) => ({ key: key ?? 'e_d', pos: [r, c] })), timeLimit: 999, autoFinish: false,
});
}
/** Replace `battle.rng()` by a constant (helpers such as rng.pick keep the seeded stream). */
function forceRng(h, v) { const orig = h.b.rng; h.b.rng = Object.assign(() => v, orig); }
const foe = (h, i = 0) => h.b.enemies.filter((e) => e.alive)[i];
/** True damage `amount` from `src` to `tgt`; returns the HP removed. */
const hitTrue = (h, src, tgt, amount) => h.b.dealDamage(src, tgt, { amount, type: 'true', canDodge: false });
// ---------------------------------------------------------------------------------------------------------------
// stats (直接乘算: each equipment % is its own multiplier)
const STAT_CASES = [
// [item id, expected stats (partial), note]
[A('1_01'), { atk: 575 }], [B('1_01'), { atk: 625 }], // 维式重锤 +15 / +25 %
[A('1_02'), { def: 240 }], [B('1_02'), { def: 270 }], // 坚守盾牌 +20 / +35 %
[A('1_05'), { atk: 700 }], [B('1_05'), { atk: 800 }], // 源石溶剂 +40 / +60 %
[A('2_01'), { maxHp: 1400, redeployMul: 0.7 }], [B('2_01'), { maxHp: 1400, redeployMul: 0.5 }], // 不屈弹射器
[A('2_03'), { atk: 600 }], [B('2_03'), { atk: 675 }], // 战栗维式重锤
[A('2_04'), { spRecovery: 1.15 }], [B('2_04'), { spRecovery: 1.25 }], // 萨尔贡浓茶
[A('3_01'), { aspd: 115 }], [B('3_01'), { aspd: 125 }], // 叙拉古正装 (self)
[A('3_03'), { resIgnorePct: 0.25 }], [B('3_03'), { resIgnorePct: 0.45 }], // 激光发射器
[A('3_07'), { atk: 700, aspd: 90 }], [B('3_07'), { atk: 800, aspd: 90 }], // 阿戈尔重刃
[A('3_08'), { res: 30 }], [B('3_08'), { res: 40 }], // 奥术法阵 +20 / +30
[A('3_09'), { atk: 625 }], [B('3_09'), { atk: 725 }], // 坚固维式重锤
[A('3_10'), { atk: 625, aspd: 130 }], [B('3_10'), { atk: 725, aspd: 130 }], // 加速维式重锤 (+30 special)
[A('4_02'), { maxHp: 2800, taunt: 1 }], [B('4_02'), { maxHp: 3200, taunt: 1 }], // 蜂鸣器
[A('4_09'), { atk: 650 }], [B('4_09'), { atk: 750 }], // 灼燃维式重锤
[A('5_03'), { atk: 650 }], [B('5_03'), { atk: 750 }], // 双模机械臂
[A('5_09'), { maxHp: 3000 }], [B('5_09'), { maxHp: 3500 }], // 天马之盔
[A('6_01'), { atk: 700 }], [B('6_01'), { atk: 800 }], // 天马之枪
[A('6_02'), { atk: 700 }], [B('6_02'), { atk: 800 }], // 铳骑之威
[A('6_03'), { maxHp: 2900 }], [B('6_03'), { maxHp: 3400 }], // 天师古鼎
[A('6_05'), { aspd: 135 }], [B('6_05'), { aspd: 155 }], // 蒸汽之心
[A('6_06'), { res: 40 }], [B('6_06'), { res: 60 }], // 耶拉冈德之泪
[A('6_10'), { redeployMul: 0.6, maxHp: 2000 }], [B('6_10'), { redeployMul: 0.4 }], // 骑士戒律
[A('6_11'), { spRecovery: 1.2 }], [B('6_11'), { spRecovery: 1.35 }], // 家族徽章
];
test('stat equipment: percentages are 直接乘算 — additive with each other (normal / golden numbers from data)', () => {
for (const [id, want] of STAT_CASES) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1);
const s = h.unit('t_op').s;
for (const [k, v] of Object.entries(want)) close(s[k], v, `${id} ${k}`);
cover(id);
}
// two items add up (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 values are summed, PRTS 游戏数据基础):
// 维式重锤 +15 % and 阿戈尔重刃 +40 % = +55 %, not ×1.15 × 1.4; a normal + golden copy of one item both apply
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), A('3_07')] }] });
h.step(1);
close(h.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.4), 'stacked');
const h2 = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), B('1_01')] }] });
h2.step(1);
close(h2.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.25), 'normal + golden');
checkInvariants(h2.b);
});
test('源石溶剂: −60 HP per second on the field (流失), ATK +40 %', () => {
for (const id of [A('1_05'), B('1_05')]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1);
const u = h.unit('t_op');
h.run(3.05);
close(u.hp, 2000 - 3 * 60, id);
}
});
test('不屈弹射器 / 骑士戒律: redeploy time ×(1 + respawn_time)', () => {
for (const [id, mul] of [[A('2_01'), 0.7], [B('2_01'), 0.5], [A('6_10'), 0.6], [B('6_10'), 0.4]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1);
const u = h.unit('t_op');
h.b.kill(u);
close(u.respawnAt - h.b.time, 20 * mul, id);
}
});
test('叙拉古正装: allies on the two tiles beside the carrier (perpendicular to its facing) ASPD +10 / +15', () => {
for (const [id, self, side] of [[A('3_01'), 115, 110], [B('3_01'), 125, 115]]) {
const ops = { t_op: op('t_op'), t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') };
const h = fight({ ops, units: [
{ chessId: 't_op', row: 10, col: 5, items: [id] }, { chessId: 't_a', row: 11, col: 5 }, { chessId: 't_b', row: 9, col: 5 }, { chessId: 't_c', row: 10, col: 6 },
] });
h.run(1);
assert.equal(h.unit('t_op').s.aspd, self);
assert.equal(h.unit('t_a').s.aspd, side, 'above');
assert.equal(h.unit('t_b').s.aspd, side, 'below');
assert.equal(h.unit('t_c').s.aspd, 100, 'in front: no');
cover(id);
}
});
test('精准狙击镜: damage ×1.3 / ×1.5 against targets ≥ 3 tiles away', () => {
for (const [id, sc] of [[A('3_02'), 1.3], [B('3_02'), 1.5]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 6], [10, 5]] });
h.step(1);
const u = h.unit('t_op');
const [far, near] = [h.b.enemies.find((e) => e.x === 6), h.b.enemies.find((e) => e.x === 5)];
close(hitTrue(h, u, far, 1000), 1000 * sc, `${id} far`);
close(hitTrue(h, u, near, 1000), 1000, `${id} near`);
cover(id);
}
});
test('激光发射器: attacks ignore 25 % / 45 % of the target RES', () => {
for (const [id, p] of [[A('3_03'), 0.25], [B('3_03'), 0.45]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 9, 'e_r']], enemies: { e_r: DUMMY('e_r', { res: 50 }) } });
h.step(1);
close(h.b.dealDamage(h.unit('t_op'), foe(h), { amount: 1000, type: 'arts', canDodge: false }), 1000 * (1 - 50 * (1 - p) / 100), id);
}
});
test('炎国短刀: each skill activation ATK +5 % / +8 % (one multiplier), at most 10 stacks per battle', () => {
for (const [id, per] of [[A('3_04'), 0.05], [B('3_04'), 0.08]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }] });
h.step(1);
const u = h.unit('t_op');
const cast = (n) => { for (let i = 0; i < n; i++) { u.skill.activate('test', { free: true }); u.skill.end('test'); } };
cast(3);
close(u.s.atk, 500 * (1 + per * 3), `${id} ×3`);
cast(9);
close(u.s.atk, 500 * (1 + per * 10), `${id} capped`);
cover(id);
}
});
test('迅捷作战粮: on deploy SP +3 / +6, plus as much per other operator sharing a bond', () => {
for (const [id, each] of [[A('3_05'), 3], [B('3_05'), 6]]) {
const ops = { t_op: op('t_op', { bonds: ['swiftShip'] }), t_a: op('t_a', { bonds: ['swiftShip'] }), t_b: op('t_b', { bonds: ['yanShip'] }) };
const h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }, { chessId: 't_a', row: 11, col: 3 }, { chessId: 't_b', row: 12, col: 3 }] });
h.step(1);
close(h.unit('t_op').skill.sp, each * 2, id, 0.1); // own + 1 bond-mate (t_b shares nothing); ≤ 1 tick of SP regen
cover(id);
}
});
test('歌利亚头盔: HP +25 % / +45 %, and +15 % / +25 % more when nobody stands on the tile in front at deploy', () => {
for (const [id, base, ex] of [[A('3_06'), 1.25, 1.15], [B('3_06'), 1.45, 1.25]]) {
const ops = { t_op: op('t_op'), t_f: op('t_f') };
const free = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
free.step(1);
close(free.unit('t_op').s.maxHp, 2000 * (base + ex - 1), `${id} free (+25 % and +15 % add up: 直接乘算)`);
close(free.unit('t_op').hp, 2000 * (base + ex - 1), `${id} deployed at full HP`);
const blocked = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }, { chessId: 't_f', row: 10, col: 5 }] });
blocked.step(1);
close(blocked.unit('t_op').s.maxHp, 2000 * base, `${id} front occupied`);
cover(id);
}
});
test('奥术法阵: attacks silence the target (失去特殊能力) for 5 s', () => {
for (const id of [A('3_08'), B('3_08')]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 5]] });
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
const b = foe(h).findBuff('silence');
assert.ok(b, `${id} silenced`);
close(b.timeLeft, 5, `${id} duration`, 0.05);
}
});
test('坚固维式重锤: the first lethal hit leaves the carrier at ≥ 1 HP for 8 s; afterwards it can die', () => {
for (const id of [A('3_09'), B('3_09')]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
const src = foe(h);
hitTrue(h, src, u, 1e6);
assert.ok(u.alive && u.hp >= 1, 'survives');
h.run(7.5);
hitTrue(h, src, u, 1e6);
assert.ok(u.alive, 'still undying inside 8 s');
h.run(0.6);
hitTrue(h, src, u, 1e6);
assert.equal(u.alive, false, `${id}: dies after the window`);
}
});
test('突袭手雷: within 10 s / 15 s after each deploy, attacks stun the target for 2 s', () => {
for (const [id, win] of [[A('3_11'), 10], [B('3_11'), 15]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 5]] });
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
const st = foe(h).findBuff('stun');
assert.ok(st, 'stunned');
close(st.timeLeft, 2, 'stun 2 s', 0.05);
h.run(win + 2.5 - h.b.time);
assert.equal(foe(h).findBuff('stun'), null, `${id}: no stun once the window is over`);
cover(id);
}
});
test('有限加速器: every attack ASPD +1 / +2 for the battle, at most 60 stacks', () => {
for (const [id, per] of [[A('4_03'), 1], [B('4_03'), 2]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 5]] });
h.run(5);
const n = h.hooksOf('attack').length;
assert.ok(n >= 5, 'attacked');
assert.equal(h.unit('t_op').s.aspd, 100 + per * n, `${id} after ${n}`);
h.run(60);
assert.equal(h.unit('t_op').s.aspd, 100 + per * 60, `${id} capped`);
cover(id);
}
});
test('伪装服: the first damage taken gives 隐匿 for 15 s / 25 s, once per battle', () => {
for (const [id, d] of [[A('4_04'), 15], [B('4_04'), 25]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
hitTrue(h, foe(h), u, 10);
assert.ok(u.s.flags.stealth, 'camouflaged');
h.run(d - 0.2);
assert.ok(u.s.flags.stealth, 'still camouflaged');
h.run(0.4);
assert.ok(!u.s.flags.stealth, 'expired');
hitTrue(h, foe(h), u, 10);
assert.ok(!u.s.flags.stealth, `${id}: only the first damage`);
cover(id);
}
});
test('防暴盾: while blocking, damage from units it does not block ×0.6 / ×0.3 (blocked sources and sourceless damage unchanged)', () => {
for (const [id, sc] of [[A('4_05'), 0.6], [B('4_05'), 0.3]]) {
const h = makeBattle({
defs: { chess: { t_op: op('t_op') }, enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e7, speed: 4 }), e_d: DUMMY() } },
units: [{ chessId: 't_op', row: 9, col: 6, items: [id] }], enemies: [{ key: 'e_w', route: 0 }, { key: 'e_d', pos: [11, 9] }],
timeLimit: 999, autoFinish: false,
});
const u = h.unit('t_op');
assert.ok(h.runUntil(() => u.blocking.length > 0, 20), 'blocks the walker');
const walker = u.blocking[0];
const other = h.b.enemies.find((e) => e !== walker);
close(hitTrue(h, other, u, 100), 100 * sc, `${id} unblocked source`);
close(hitTrue(h, walker, u, 100), 100, `${id} blocked source`);
close(hitTrue(h, null, u, 100), 100, `${id} sourceless (regression)`);
cover(id);
}
});
test('休眠子裔: each attacked target heals the carrier 2 % / 4 % of its max HP', () => {
for (const [id, r] of [[A('4_06'), 0.02], [B('4_06'), 0.04]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 5]] });
h.step(1);
const u = h.unit('t_op');
h.b.loseHp(u, 1000);
const n0 = h.hooksOf('attack').length;
h.runUntil(() => h.hooksOf('attack').length > n0, 5);
const heals = h.eventsOf('heal').filter((e) => e[1] === u.id);
assert.ok(heals.length >= 1);
close(heals[0][2], 2000 * r, id, 0.6);
cover(id);
}
});
test('卡西米尔竞技旗: damage ×1.35 / ×1.6 for 15 s after deploying, then −0.04 / −0.07 every 0.5 s down to ×1', () => {
for (const [id, sc, minus] of [[A('4_07'), 1.35, 0.04], [B('4_07'), 1.6, 0.07]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
close(hitTrue(h, u, foe(h), 1000), 1000 * sc, `${id} t≈0`);
h.run(14.5);
close(hitTrue(h, u, foe(h), 1000), 1000 * sc, `${id} t≈14.5`);
h.run(16.8 - h.b.time); // 1.8 s past the window: 4 steps of 0.5 s started
close(hitTrue(h, u, foe(h), 1000), 1000 * (sc - 4 * minus), `${id} t≈16.8`, 1e-3);
h.run(10);
close(hitTrue(h, u, foe(h), 1000), 1000, `${id} decayed`);
cover(id);
}
});
test('拉特兰桥夹: an ammo skill down to 1 bullet regains ceil(40 % / 60 %) of its ammo (prob), ≤ 3 times per deploy', () => {
const kits = { t_op: () => ({ skill: { kind: 'ammo', ammo: 10, duration: 0, initSp: 60 } }) };
for (const [id, add, rngV, shots] of [[A('4_08'), 4, 0, 10 + 3 * 4], [B('4_08'), 6, 0, 10 + 3 * 6], [A('4_08'), 4, 0.99, 10]]) {
const h = fight({ kits, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 5]] });
forceRng(h, rngV);
const u = h.unit('t_op');
h.runUntil(() => u.skill.activations >= 1 && !u.skill.active, 60);
assert.equal(h.hooksOf('ammoUsed').length, shots, `${id} rng ${rngV} (+${add} per proc)`);
cover(id);
}
});
test('灼燃维式重锤: arts damage also deals 10 % of it as 灼燃损伤 (physical does not)', () => {
for (const id of [A('4_09'), B('4_09')]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op'), e = foe(h);
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
close(e.elem.burn, 100, `${id} burn`);
h.b.dealDamage(u, e, { amount: 1000, type: 'phys', canDodge: false });
close(e.elem.burn, 100, `${id} phys adds nothing`);
}
});
test('浓缩嗅盐: above 70 % / 40 % HP immune to stun, freeze, sleep, levitate (寒冷 still applies)', () => {
for (const [id, thr] of [[A('4_10'), 0.7], [B('4_10'), 0.4]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1);
const u = h.unit('t_op');
for (const k of ['stun', 'freeze', 'sleep', 'levitate']) assert.equal(h.b.applyStatus(u, k, { duration: 1 }), false, `${id} ${k}`);
assert.equal(h.b.applyStatus(u, 'cold', { duration: 1 }), true, `${id}: 寒冷 is not a control state (regression)`);
assert.equal(h.b.applyStatus(u, 'cold', { duration: 1 }), true);
assert.equal(u.findBuff('freeze'), null, 'the freeze of a second 寒冷 is cancelled');
h.b.loseHp(u, 2000 * (1 - thr) + 1);
assert.equal(h.b.applyStatus(u, 'stun', { duration: 1 }), true, `${id}: at ≤ ${thr} HP the stun lands`);
cover(id);
}
});
test('护盾无人机: a heal by the carrier gives the target 1 shield layer (prob), at most 1', () => {
for (const [id, rngV, want] of [[A('4_11'), 0, 1], [B('4_11'), 0.14, 1], [A('4_11'), 0.11, 0]]) {
const ops = { t_op: op('t_op'), t_a: op('t_a') };
const h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }, { chessId: 't_a', row: 11, col: 4 }] });
h.step(1);
forceRng(h, rngV);
const a = h.unit('t_a');
h.b.loseHp(a, 500);
h.b.heal(h.unit('t_op'), a, 100);
h.b.heal(h.unit('t_op'), a, 100);
const sh = a.findBuff('item:shield_drone');
assert.equal(sh ? sh.shieldHits : 0, want, `${id} rng ${rngV}`);
cover(id);
}
});
test('M3茧甲: knocked down ⇒ revives at once at full HP, 1 / 2 times per battle; a free skill undying never spends it', () => {
for (const [id, n] of [[A('4_12'), 1], [B('4_12'), 2]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
// a skill-style undying (kits use priority ≥ −60) prevents the knock-down first: the revive is kept
let skillUndying = true;
h.b.on('fatal', (c) => { if (c.unit === u && skillUndying) c.prevented = true; }, { priority: 10 });
hitTrue(h, foe(h), u, 1e6);
assert.ok(u.alive && u.hp < 2, 'kept alive by the skill, no revive');
skillUndying = false;
for (let i = 0; i < n; i++) {
hitTrue(h, foe(h), u, 1e6);
assert.ok(u.alive, `revive ${i + 1}`);
close(u.hp, 2000, 'full HP');
}
hitTrue(h, foe(h), u, 1e6);
assert.equal(u.alive, false, `${id}: no revive left`);
cover(id);
}
assert.ok(PRIO_REVIVE < -60, 'revive items run after every kit / talent death saver');
});
test('催泪瓦斯 / 谢拉格不融冰: attacks proc 麻痹 (3 % / 5 %) and 寒冷 1.5 s (12 % / 20 %)', () => {
for (const [id, rngHit, rngMiss, status] of [
[A('5_01'), 0.029, 0.031, 'palsy'], [B('5_01'), 0.049, 0.051, 'palsy'],
[A('5_02'), 0.119, 0.121, 'cold'], [B('5_02'), 0.199, 0.201, 'cold'],
]) {
for (const [v, want] of [[rngHit, true], [rngMiss, false]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }], foes: [[10, 5]] });
forceRng(h, v);
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
const b = foe(h).findBuff(status);
assert.equal(!!b, want, `${id} rng ${v}`);
if (b && status === 'cold') close(b.timeLeft, 1.5, 'cold 1.5 s', 0.05);
}
cover(id);
}
});
test('双模机械臂: physical / arts damage is dealt as whichever the target resists less (弱点伤害)', () => {
for (const id of [A('5_03'), B('5_03')]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 8, 'e_def'], [10, 9, 'e_res']],
enemies: { e_def: DUMMY('e_def', { def: 900 }), e_res: DUMMY('e_res', { res: 90 }) } });
h.step(1);
const u = h.unit('t_op');
const eDef = h.b.enemies.find((e) => e.x === 8), eRes = h.b.enemies.find((e) => e.x === 9);
close(h.b.dealDamage(u, eDef, { amount: 1000, type: 'phys', canDodge: false }), 1000, `${id} phys → arts vs DEF`);
close(h.b.dealDamage(u, eRes, { amount: 1000, type: 'arts', canDodge: false }), 1000, `${id} arts → phys vs RES`);
}
const plain = fight({ units: [{ chessId: 't_op', row: 10, col: 3 }], foes: [[10, 8, 'e_def']], enemies: { e_def: DUMMY('e_def', { def: 900 }) } });
plain.step(1);
close(plain.b.dealDamage(plain.unit('t_op'), foe(plain), { amount: 1000, type: 'phys', canDodge: false }), 100, 'without the arm');
});
test('天马之盔 + 天马之枪 (either quality): regen 8 % max HP / s and +30 % ATK true damage per damage instance', () => {
for (const [helm, lance] of [[A('5_09'), A('6_01')], [B('5_09'), A('6_01')], [A('5_09'), B('6_01')]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [helm, lance] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
const e = foe(h);
const before = e.hp;
hitTrue(h, u, e, 1000);
close(before - e.hp, 1000 + 0.3 * u.s.atk, `${helm}+${lance} lance proc`);
h.b.loseHp(u, 1000);
const hp0 = u.hp;
h.run(1.02);
close(u.hp - hp0, 0.08 * u.s.maxHp, `${helm}+${lance} regen`, 1);
cover(helm, lance);
}
// alone: neither set part does anything beyond its stat
for (const id of [A('5_09'), A('6_01')]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op'), e = foe(h);
const before = e.hp;
hitTrue(h, u, e, 1000);
close(before - e.hp, 1000, `${id} alone: no proc`);
h.b.loseHp(u, 1000);
const hp0 = u.hp;
h.run(2.05);
close(u.hp, hp0, `${id} alone: no regen`);
}
});
test('铳骑之威 (拉特兰): 35 % per attack an extra bullet of 150 % ATK (300 % with 拉特兰桥夹) at an enemy in range', () => {
const run = (bonds, items, rngV) => {
const ops = { t_op: op('t_op', { bonds }) };
let bullets = [];
const h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items }], foes: [[10, 5]],
setup: (b) => b.on('damaged', (c) => { if (c.dmg.tags && c.dmg.tags.includes('item:gunknight')) bullets.push(c.amount); }) });
forceRng(h, rngV);
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
return { bullets, atk: h.unit('t_op').s.atk };
};
let r = run(['lateranoShip'], [A('6_02')], 0.34);
assert.deepEqual(r.bullets.map(Math.round), [Math.round(1.5 * r.atk)], 'laterano bullet');
r = run(['lateranoShip'], [B('6_02'), A('4_08')], 0.34);
assert.deepEqual(r.bullets.map(Math.round), [Math.round(3 * r.atk)], 'with 拉特兰桥夹');
r = run(['lateranoShip'], [A('6_02')], 0.36);
assert.equal(r.bullets.length, 0, 'prob 35 %');
r = run(['yanShip'], [A('6_02')], 0);
assert.equal(r.bullets.length, 0, 'not 拉特兰: no bullet');
cover(A('6_02'), B('6_02'));
});
test('天师古鼎 (炎): battle start ASPD +25 per operator gained this round (max 3)', () => {
for (const [bonds, gained, aspd] of [[['yanShip'], 2, 150], [['yanShip'], 5, 175], [['yanShip'], 0, 100], [['sargonShip'], 3, 100]]) {
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [],
units: [{ uid: 1, kind: 'chess', chessId: 't_op', row: 10, col: 4, items: [A('6_03')] }],
contentInfo: { roundStats: { gainedChess: gained } } }];
const h = fight({ ops: { t_op: op('t_op', { bonds }) }, players });
h.step(1);
assert.equal(h.unit('t_op').s.aspd, aspd, `${bonds} gained ${gained}`);
}
cover(A('6_03'), B('6_03'));
});
test('海沟实验体: −180 / −300 per damage instance; (阿戈尔) hit taken ⇒ 50 % ATK arts to the source (0.5 s CD), ×2 with 阿戈尔重刃', () => {
for (const [id, dr] of [[A('6_04'), 180], [B('6_04'), 300]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [id] }], foes: [[10, 9]] });
h.step(1);
close(hitTrue(h, foe(h), h.unit('t_op'), 1000), 1000 - dr, `${id} flat DR`);
close(hitTrue(h, foe(h), h.unit('t_op'), 100), 0, `${id} small hits are negated`);
cover(id);
}
const counter = (items) => {
const h = fight({ ops: { t_op: op('t_op', { bonds: ['egirShip'] }) }, units: [{ chessId: 't_op', row: 10, col: 3, items }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op'), e = foe(h);
const hp0 = e.hp;
hitTrue(h, e, u, 500);
const first = hp0 - e.hp;
hitTrue(h, e, u, 500); // inside the 0.5 s lock
const second = hp0 - e.hp;
h.run(0.6);
hitTrue(h, e, u, 500);
return { first, second, third: hp0 - e.hp, atk: u.s.atk };
};
let c = counter([A('6_04')]);
close(c.first, 0.5 * c.atk, 'counter');
close(c.second, c.first, 'CD');
close(c.third, 2 * c.first, 'after the CD');
c = counter([A('6_04'), A('3_07')]);
close(c.first, 2 * 0.5 * c.atk, 'two hits with 阿戈尔重刃');
const other = fight({ units: [{ chessId: 't_op', row: 10, col: 3, items: [A('6_04')] }], foes: [[10, 9]] });
other.step(1);
const e = foe(other), hp0 = e.hp;
hitTrue(other, e, other.unit('t_op'), 500);
close(hp0 - e.hp, 0, 'not 阿戈尔: no counter');
});
test('蒸汽之心 (维多利亚): hammers equipped on the field are granted; the carrier\'s own hammer counts twice', () => {
const vict = { t_op: op('t_op', { bonds: ['victoriaShip'] }), t_a: op('t_a'), t_n: op('t_n') };
// another operator's 加速维式重锤 ⇒ +30 on the carrier; own 加速 + 蒸汽 ⇒ +60
let h = fight({ ops: vict, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('6_05')] }, { chessId: 't_a', row: 11, col: 4, items: [A('3_10')] }] });
h.run(1);
assert.equal(h.unit('t_op').s.aspd, 100 + 35 + 30, 'field hammer');
assert.equal(h.unit('t_a').s.aspd, 130, 'the hammer holder keeps its own');
h = fight({ ops: vict, units: [{ chessId: 't_op', row: 10, col: 4, items: [B('6_05'), A('3_10')] }] });
h.run(1);
assert.equal(h.unit('t_op').s.aspd, 100 + 55 + 60, 'own hammer ×2');
h = fight({ ops: { t_op: op('t_op', { bonds: ['yanShip'] }), t_a: op('t_a') }, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('6_05')] }, { chessId: 't_a', row: 11, col: 4, items: [A('3_10')] }] });
h.run(1);
assert.equal(h.unit('t_op').s.aspd, 135, 'not 维多利亚: stat only');
// 灼燃 ×2 (20 % of arts damage), 坚固 ×2 (16 s) — checked in the first 0.5 s of the battle (regression: the field
// cache filled before the initial deployment hid the field's hammers until its first 0.5 s refresh)
h = fight({ ops: vict, units: [{ chessId: 't_op', row: 10, col: 3, items: [A('6_05'), A('4_09')] }], foes: [[10, 9]] });
h.step(1);
h.b.dealDamage(h.unit('t_op'), foe(h), { amount: 1000, type: 'arts', canDodge: false });
close(foe(h).elem.burn, 200, 'burn ×2');
h = fight({ ops: vict, units: [{ chessId: 't_op', row: 10, col: 3, items: [A('6_05'), A('3_09')] }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
hitTrue(h, foe(h), u, 1e6);
h.run(15.5);
hitTrue(h, foe(h), u, 1e6);
assert.ok(u.alive, 'undying 16 s');
h.run(0.7);
hitTrue(h, foe(h), u, 1e6);
assert.equal(u.alive, false);
// 战栗 ×2: 10 % → 20 % (ground carrier)
for (const [items, rngV, want] of [[[A('2_03')], 0.15, false], [[A('2_03'), A('6_05')], 0.15, true], [[A('2_03')], 0.05, true]]) {
h = fight({ ops: vict, units: [{ chessId: 't_op', row: 10, col: 4, items }], foes: [[10, 5]] });
forceRng(h, rngV);
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
const tr = foe(h).findBuff('tremble');
assert.equal(!!tr, want, `tremble ${items} rng ${rngV}`);
if (tr) close(tr.timeLeft, 2, 'tremble 2 s', 0.05);
}
cover(A('6_05'), B('6_05'), A('2_03'), B('2_03'), A('3_09'), B('3_09'), A('3_10'), B('3_10'), A('4_09'), B('4_09'));
});
test('耶拉冈德之泪 (谢拉格): cold / frozen enemies in range take 30 % ATK arts per second (100 % with 谢拉格不融冰)', () => {
const count = (bonds, items) => {
const ops = { t_op: op('t_op', { bonds }) };
const h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items }], foes: [[10, 5], [10, 6]] });
h.step(1);
const u = h.unit('t_op');
const [cold, warm] = h.b.enemies;
h.b.applyStatus(cold, 'cold', { duration: 100 });
const procs = [];
h.b.on('damaged', (c) => { if (c.dmg.tags && c.dmg.tags.includes('item:tears')) procs.push([c.target === cold, c.amount]); });
h.run(3.05);
return { procs, atk: u.s.atk };
};
let r = count(['kjeragShip'], [A('6_06')]);
assert.ok(r.procs.length >= 3 && r.procs.every(([isCold]) => isCold), 'only the cold one, once per second');
close(r.procs[0][1], 0.3 * r.atk, '30 % ATK');
r = count(['kjeragShip'], [B('6_06'), A('5_02')]);
close(r.procs[0][1], 1.0 * r.atk, '100 % ATK with 不融冰');
r = count(['yanShip'], [A('6_06')]);
assert.equal(r.procs.length, 0, 'not 谢拉格');
cover(A('6_06'), B('6_06'), A('5_02'), B('5_02'));
});
test('黄沙罗盘: initial SP +30 / +50; (萨尔贡) first skill end +30 SP; + 萨尔贡浓茶 each cast ⇒ every 萨尔贡 operator +3 SP', () => {
for (const [id, init] of [[A('6_07'), 30], [B('6_07'), 50]]) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1);
close(h.unit('t_op').skill.sp, init, id, 0.1);
}
const ops = { t_op: op('t_op', { bonds: ['sargonShip'] }), t_a: op('t_a', { bonds: ['sargonShip'] }), t_b: op('t_b') };
const h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('6_07'), A('2_04')] }, { chessId: 't_a', row: 11, col: 4 }, { chessId: 't_b', row: 12, col: 4 }] });
h.step(1);
const u = h.unit('t_op'), a = h.unit('t_a'), b = h.unit('t_b');
const a0 = a.skill.sp, b0 = b.skill.sp;
u.skill.activate('test', { free: true });
close(a.skill.sp - a0, 3, '萨尔贡 ally +3');
close(b.skill.sp - b0, 0, 'non-萨尔贡 ally +0');
u.skill.sp = 0;
u.skill.end('test');
close(u.skill.sp, 30, 'first skill end +30');
u.skill.sp = 0;
u.skill.activate('test', { free: true });
u.skill.end('test');
close(u.skill.sp, 0, 'only the first skill end');
cover(A('6_07'), B('6_07'), A('2_04'), B('2_04'));
});
test('骑士戒律 (卡西米尔): 20 s after a skill starts enemies in range ASPD −35 %, move ×0.65; + 竞技旗: ATK ×2 in the skill, no death until it ends', () => {
const ops = { t_op: op('t_op', { bonds: ['kazimierzShip'] }) };
let h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('6_10')] }], foes: [[10, 6]], enemies: { e_d: DUMMY('e_d', { speed: 1 }) } });
h.step(1);
const u = h.unit('t_op');
u.skill.activate('test', { free: true });
h.run(1);
const e = foe(h);
assert.equal(e.s.aspd, 65, 'enemy ASPD −35 %');
close(e.s.moveSpeed, 0.65, 'move ×0.65');
h.run(20);
assert.equal(e.s.aspd, 100, 'aura over after 20 s');
const h2 = fight({ ops: { t_op: op('t_op', { bonds: ['yanShip'] }) }, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('6_10')] }], foes: [[10, 6]] });
h2.step(1);
h2.unit('t_op').skill.activate('test', { free: true });
h2.run(1);
assert.equal(foe(h2).s.aspd, 100, 'not 卡西米尔: no aura');
// combo with 卡西米尔竞技旗 (either quality)
h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [B('6_10'), B('4_07')] }], foes: [[10, 9]] });
h.step(1);
const k = h.unit('t_op');
k.skill.activate('test', { free: true });
close(k.s.atk, 1000, 'ATK +100 % during the skill');
hitTrue(h, foe(h), k, 1e6);
assert.ok(k.alive && k.hp >= 1, 'lethal damage does not retreat it');
h.run(5.2);
assert.equal(k.alive, false, 'retreats when the skill ends');
assert.equal(k.removeReason, 'retreat');
cover(A('6_10'), B('6_10'), A('4_07'), B('4_07'));
});
test('家族徽章 (叙拉古): ATK +2 %/s while 隐匿 (max +100 %) until the first damage after; + 叙拉古正装 ⇒ that hit +800 % ATK true', () => {
const run = (items) => {
const h = fight({ ops: { t_op: op('t_op', { bonds: ['siracusaShip'], range: [[0, 0]] }) }, units: [{ chessId: 't_op', row: 10, col: 4, items }], foes: [[10, 9]] });
h.step(1);
const u = h.unit('t_op');
const atk0 = u.s.atk;
h.b.applyStatus(u, 'stealth', { duration: 10 });
h.run(10);
const ramp = u.s.atk / atk0;
h.run(0.5);
const e = foe(h);
const hp0 = e.hp;
hitTrue(h, u, e, 100);
return { ramp, dealt: hp0 - e.hp, atkAfter: u.s.atk / atk0, atk0, u };
};
let r = run([A('6_11')]);
close(r.ramp, 1.2, '+2 %/s over 10 s', 0.02);
close(r.dealt, 100, 'no true damage without 叙拉古正装');
close(r.atkAfter, 1, 'bonus consumed by the first damage');
r = run([B('6_11'), A('3_01')]);
const atkAtHit = r.atk0 * 1.2;
close(r.dealt, 100 + 8 * atkAtHit, '+800 % ATK true', atkAtHit * 0.2);
cover(A('6_11'), B('6_11'), A('3_01'), B('3_01'));
});
test('变形同构体: carrier counts as a member of the other item\'s giveBondId bond (battle + prep), never merges', () => {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('6_09'), A('4_08')] }] });
h.step(1);
assert.deepEqual([...unitBonds(h.unit('t_op'))], ['lateranoShip']);
const h2 = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('4_08')] }] });
assert.deepEqual([...unitBonds(h2.unit('t_op'))], [], 'the bond item alone grants nothing');
// every giveBondId item grants its bond (14 bonds)
const grants = Object.values(DATA.items).filter((r) => r.giveBondId && !r.isGolden);
assert.equal(grants.length, 18);
assert.equal(new Set(grants.map((r) => r.giveBondId)).size, 14);
for (const r of grants) {
const hh = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [r.id, A('6_09')] }] });
assert.ok(unitBonds(hh.unit('t_op')).includes(r.giveBondId), r.id);
}
// prep side: the bond count includes the grant
const { m, ps } = setup();
const chess = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && !c.bonds.includes('victoriaShip')).chessId;
const piece = give(m, ps, chess, 'board', [10, 5]);
const before = ps.bonds.victoriaShip ? ps.bonds.victoriaShip.count : 0;
const iso = giveItem(m, ps, A('6_09'));
const ham = giveItem(m, ps, A('1_01'));
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: iso.uid, targetUid: piece.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: ham.uid, targetUid: piece.uid }), { ok: true });
assert.equal(ps.bonds.victoriaShip.count, before + 1);
assert.equal(DATA.items[A('6_09')].upgradeNum, 100);
cover(A('6_09'), B('6_09'), A('1_01'));
});
test('lendItemEffects (萨尔贡 × 娜仁图亚): tier ≤ V battle effects lent for 60 s, then removed', () => {
const ops = { t_op: op('t_op'), t_a: op('t_a') };
const h = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), A('6_01')] }, { chessId: 't_a', row: 11, col: 4 }] });
h.step(1);
const [from, to] = [h.unit('t_op'), h.unit('t_a')];
assert.equal(lendItemEffects(h.b, from, to, { maxTier: 5, duration: 60 }), 1, 'the VI-tier lance is not lent');
close(to.s.atk, 575, 'lent hammer');
assert.deepEqual(itemGrants(h.b, to).map((g) => [g.id, g.lent]), [[A('1_01'), true]]);
h.run(60.1);
close(to.s.atk, 500, 'expired');
});
// ---------------------------------------------------------------------------------------------------------------
// prep side
function setup({ mode = 'solo', humans = 1, seed = 41 } = {}) {
const h = makeMatch({ mode, humans, seed, registry: REG, fake: true }).start();
h.toPrep(1);
const m = h.m;
for (const ps of m.players.values()) {
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.offers.length = 0;
ps.funds = 50; ps.layers = {}; ps.pendingFunds = 0; ps.shop.freeRefreshes = 0;
ps.bandId = null; // no band prep effects
ps.recompute();
}
const ps = h.ps('p_0');
const equip = (item, target, pid = 'p_0') => m.handle(pid, { t: 'g.equip', itemUid: item.uid, targetUid: target.uid });
return { h, m, ps, equip };
}
const handIds = (ps, kind) => [...ps.hand, ...ps.temp].filter((p) => p && (!kind || p.kind === kind)).map((p) => p.id);
/** Visible normal chess whose 特质 are all IN_BATTLE (no prep-side side effects), sorted. */
const plain = (pred = () => true) => Object.values(DATA.chess)
.filter((c) => c.visible && !c.isGolden && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c))
.map((c) => c.chessId).sort();
/** Record the chess granted by item effects (acquireChess with an `item:` source). */
function spyGrants(ps) {
const got = [];
const orig = ps.acquireChess.bind(ps);
ps.acquireChess = (id, o = {}) => { const p = orig(id, o); if (p && String(o.source || '').startsWith('item:')) got.push(id); return p; };
return got;
}
const OK = { ok: true };
/** Pool chess (with a free copy, not banned this match) of tier ≤ maxTier sharing a bond with `cid`. */
const sameBondPool = (m, cid, maxTier) => Object.values(DATA.chess)
.filter((c) => c.visible && !c.isGolden && c.tier <= maxTier && c.bonds.some((b) => DATA.chess[cid].bonds.includes(b)) && m.pool.has(c.chessId) && m.pool.left(c.chessId) > 0)
.map((c) => c.chessId);
/** A plain chess of `tier` whose bonds have ≥ `need` pool candidates of tier ≤ maxTier in this match. */
const targetWithCandidates = (m, tier, maxTier, need, skip = 0) => plain((c) => c.tier === tier && c.bonds.length)
.filter((cid) => sameBondPool(m, cid, maxTier).length >= need)[skip];
test('consume-on-equip items resolve once, are destroyed and never take a slot', () => {
const consume = Object.values(DATA.items).filter((r) => typeof r.kind === 'string' && r.kind.startsWith('consume_on_equip') && r.id !== A('5_06'));
assert.equal(consume.length, 23, '11 consume items ×2 + golden 博士投影');
const { m, ps, equip } = setup({ seed: 11 });
const pool = plain((c) => c.tier === 1 && c.bonds.length);
for (const [k, rec] of consume.entries()) {
ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0; ps.funds = 50;
const t = give(m, ps, pool[k % pool.length], 'hand', 0);
const it = giveItem(m, ps, rec.id);
assert.deepEqual(equip(it, t), OK, rec.id);
assert.ok(!ps.find(it.uid), `${rec.id} destroyed`);
const holder = ps.find(t.uid);
assert.ok(!holder || holder.piece.items.length === 0, `${rec.id} takes no slot`);
}
});
test('盟约之币 +1 / +2, 骑士储蓄罐 1–6 / 2–12 funds (random, both ends reachable)', () => {
for (const [id, lo, hi] of [[A('1_03'), 1, 1], [B('1_03'), 2, 2], [A('3_12'), 1, 6], [B('3_12'), 2, 12]]) {
const { m, ps, equip } = setup({ seed: 100 + lo + hi });
const t = give(m, ps, plain((c) => c.tier === 1)[0], 'hand');
const seen = new Set();
for (let k = 0; k < 60; k++) {
const f0 = ps.funds;
assert.deepEqual(equip(giveItem(m, ps, id), t), OK);
const got = ps.funds - f0;
assert.ok(got >= lo && got <= hi, `${id} ${got}`);
seen.add(got);
}
assert.equal(seen.size, hi - lo + 1, `${id} covers ${lo}..${hi}`);
cover(id);
}
});
test('随身身份牌: every bond of the target +3 / +6 layers, active or not', () => {
for (const [id, n] of [[A('1_04'), 3], [B('1_04'), 6]]) {
const { m, ps, equip } = setup();
const cid = plain((c) => c.bonds.length >= 2)[0];
const t = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, id), t), OK);
for (const b of DATA.chess[cid].bonds) assert.equal(ps.layers[b], n, `${id} ${b}`);
cover(id);
}
});
test('紧急调度券: takes 1 / 2 random shop operators for free (their slots empty); a slot whose chess cannot be granted stays', () => {
for (const [id, n] of [[A('2_02'), 1], [B('2_02'), 2]]) {
const { m, ps, equip } = setup();
const ids = plain((c) => c.tier === 1).slice(0, 4);
ps.shop.slots = ids.map((cid) => ({ kind: 'chess', id: cid, basePrice: 2, frozen: false, sold: false }));
const t = give(m, ps, plain((c) => c.tier === 2)[0], 'hand');
const got = spyGrants(ps);
const f0 = ps.funds;
assert.deepEqual(equip(giveItem(m, ps, id), t), OK);
assert.equal(ps.funds, f0, 'free');
assert.equal(got.length, n, `${id} gained ${n}`);
assert.equal(ps.shop.slots.filter(Boolean).length, 4 - n, 'their slots are empty');
for (const g of got) assert.ok(ids.includes(g) && !ps.shop.slots.some((s) => s && s.id === g));
cover(id);
}
// regression: a shop chess without a free pool copy is not lost — its slot keeps it and another one is taken
const { m, ps, equip } = setup({ seed: 5 });
const [dry, other] = plain((c) => c.tier === 1);
ps.shop.slots = [dry, other].map((cid) => ({ kind: 'chess', id: cid, basePrice: 2, frozen: false, sold: false }));
m.pool.take(m.gd.baseIdOf(dry), m.pool.left(m.gd.baseIdOf(dry)));
const got = spyGrants(ps);
assert.deepEqual(equip(giveItem(m, ps, A('2_02')), give(m, ps, plain((c) => c.tier === 2)[0], 'hand')), OK);
assert.deepEqual(got, [other]);
assert.equal(ps.shop.slots[0] && ps.shop.slots[0].id, dry, 'unavailable chess stays in its slot');
assert.equal(ps.shop.slots[1], null);
});
test('精打细算玩偶 +1 / +2 funds at every later round start; 见钱眼开玩偶 +2 / +4 next round only', () => {
for (const [doll, per, cash, once] of [[A('2_05'), 1, A('2_07'), 2], [B('2_05'), 2, B('2_07'), 4]]) {
const { h, m, ps, equip } = setup();
const t = give(m, ps, plain((c) => c.tier === 1)[0], 'hand');
assert.deepEqual(equip(giveItem(m, ps, doll), t), OK);
assert.deepEqual(equip(giveItem(m, ps, cash), t), OK);
assert.equal(ps.pendingFunds, once);
h.toPrep(2);
assert.equal(ps.funds, m.gd.income(2) + once + per, `R2 ${doll}`);
h.toPrep(3);
assert.equal(ps.funds, m.gd.income(3) + per, `R3 ${doll}`);
cover(doll, cash);
}
});
test('简易通讯机: 1 / 2 random operators sharing a bond with the target, tier ≤ shop level', () => {
for (const [id, n] of [[A('2_06'), 1], [B('2_06'), 2]]) {
for (let k = 0; k < 4; k++) {
const { m, ps, equip } = setup({ seed: 17 + k });
ps.shop.level = 2;
const cid = targetWithCandidates(m, 2, 2, 3, k % 2);
assert.ok(cid, 'a target with candidates');
const t = give(m, ps, cid, 'hand');
const got = spyGrants(ps);
assert.deepEqual(equip(giveItem(m, ps, id), t), OK);
assert.equal(got.length, n, `${id}`);
for (const g of got) {
const c = DATA.chess[g];
assert.ok(c.tier <= 2 && !c.isGolden && c.bonds.some((b) => DATA.chess[cid].bonds.includes(b)), `${g} shares a bond, tier ≤ 2`);
}
}
cover(id);
}
});
test('寻呼模块: a pick-one offer of 3 different same-bond operators (tier ≤ shop level), the pick is free', () => {
const { m, ps, equip } = setup({ seed: 23 });
ps.shop.level = 3;
const cid = targetWithCandidates(m, 1, 3, 4);
const t = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('4_01')), t), OK);
const offer = ps.offers[0];
assert.ok(offer, 'offer');
const ids = offer.slots.map((s) => s.id);
assert.equal(ids.length, 3);
assert.equal(new Set(ids).size, 3, 'distinct');
for (const id of ids) assert.ok(DATA.chess[id].tier <= 3 && DATA.chess[id].bonds.some((b) => DATA.chess[cid].bonds.includes(b)), id);
const f0 = ps.funds;
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), OK);
assert.equal(ps.funds, f0);
assert.ok(handIds(ps, 'chess').includes(ids[1]) || handIds(ps, 'chess').includes(DATA.chess[ids[1]].goldenId));
assert.equal(DATA.items[A('4_01')].upgradeNum, 100, 'never merges');
cover(A('4_01'), B('4_01'));
});
test('信标: target and item destroyed, offer of 2 same-tier operators; co-op: the original goes to the teammate with most of its bonds next round', () => {
const { h, m, equip } = setup({ mode: 'coop', humans: 2, seed: 31 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const cid = plain((c) => c.tier === 3 && c.bonds.length)[0];
const bond = DATA.chess[cid].bonds[0];
const mate = plain((c) => c.bonds.includes(bond) && c.chessId !== cid)[0];
give(m, p1, mate, 'hand');
const t = give(m, p0, cid, 'hand');
assert.deepEqual(equip(giveItem(m, p0, A('5_04')), t), OK);
assert.equal(p0.find(t.uid), null, 'target destroyed');
const offer = p0.offers[0];
assert.equal(offer.slots.length, 2);
for (const s of offer.slots) assert.equal(DATA.chess[s.id].tier, 3);
h.toPrep(2);
assert.ok([...p1.hand, ...p1.temp, ...p1.board.values()].some((p) => p && m.gd.baseIdOf(p.id) === cid), 'gift arrived');
cover(A('5_04'), B('5_04'));
});
test('pick-one chess offers never repeat an operator — 信标, 寻呼模块, any offer list (user playtest #6 item 19)', () => {
let n = 0;
for (let seed = 1; seed <= 24; seed++) {
for (const lvl of [1, 3, 6]) {
// 寻呼模块: same-bond operators up to the shop level — fewer cards when the pool has no other one, never a repeat
let { m, ps, equip } = setup({ seed });
ps.shop.level = lvl;
const pool1 = plain((c) => c.bonds.length && c.tier <= lvl);
const cid = pool1[seed % pool1.length];
const t = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('4_01')), t), OK);
for (const o of ps.offers) { const ids = o.slots.map((x) => x.id); assert.equal(new Set(ids).size, ids.length, `寻呼模块 seed ${seed} lv ${lvl}: ${ids}`); n++; }
// 信标: operators of the target's tier (topped up from the tier below), never a repeat
({ m, ps, equip } = setup({ seed }));
const pool2 = plain((c) => c.tier === lvl && c.bonds.length);
const t2 = give(m, ps, pool2[seed % pool2.length], 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('5_04')), t2), OK);
for (const o of ps.offers) { const ids = o.slots.map((x) => x.id); assert.equal(new Set(ids).size, ids.length, `信标 seed ${seed} tier ${lvl}: ${ids}`); n++; }
}
}
assert.ok(n >= 100, `offers checked (${n})`);
// whoever builds the list, the offer drops a repeated operator
const { ps } = setup({ seed: 5 });
const [a, b] = plain((c) => c.tier === 2);
const offer = ps.pushRewardOffer('effect', { ids: [a, a, b, a] });
assert.deepEqual(offer.slots.map((x) => x.id), [a, b]);
});
test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a random same-bond normal operator', () => {
let { m, ps, equip } = setup({ seed: 9 });
const cid = plain((c) => c.tier === 2 && c.bonds.length)[0];
const t = give(m, ps, cid, 'hand');
give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.ok(handIds(ps, 'chess').includes(DATA.chess[cid].goldenId), 'merged to elite');
({ m, ps, equip } = setup({ seed: 9 }));
const t2 = give(m, ps, cid, 'hand');
const got = spyGrants(ps);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t2), OK);
assert.equal(got.length, 1);
assert.ok(!DATA.chess[got[0]].isGolden && DATA.chess[got[0]].bonds.some((b) => DATA.chess[cid].bonds.includes(b)));
cover(A('5_05'), B('5_05'));
});
test('博士投影: golden promotes at once; normal stays equipped and promotes at the next round start', () => {
const { h, m, ps, equip } = setup();
const cid = plain((c) => c.tier === 2)[0];
const a = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, B('5_06')), a), OK);
assert.ok(m.gd.isGolden(ps.find(a.uid).piece.id), 'elite now');
assert.equal(ps.find(a.uid).piece.items.length, 0, 'golden is consumed');
const cid2 = plain((c) => c.tier === 3)[0];
const b = give(m, ps, cid2, 'hand');
const holo = giveItem(m, ps, A('5_06'));
assert.deepEqual(equip(holo, b), OK);
assert.deepEqual(ps.find(b.uid).piece.items.map((x) => x.id), [A('5_06')], 'occupies a slot');
assert.ok(!m.gd.isGolden(ps.find(b.uid).piece.id));
h.toPrep(2);
const now = ps.find(b.uid).piece;
assert.ok(m.gd.isGolden(now.id), 'promoted at round start');
assert.ok(!now.items.some((x) => x.id === A('5_06')), 'item consumed');
cover(A('5_06'), B('5_06'));
});
test('商业包装方案: every 8 / 7 operators sold ⇒ 1 normal operator sharing a bond with the carrier (≤ shop level)', () => {
for (const [id, n] of [[A('5_07'), 8], [B('5_07'), 7]]) {
const { m, ps, equip } = setup({ seed: 12 });
const cid = plain((c) => c.tier === 1 && c.bonds.length)[0];
const holder = give(m, ps, cid, 'board', [10, 5]);
assert.deepEqual(equip(giveItem(m, ps, id), holder), OK);
const got = spyGrants(ps);
const fodder = plain((c) => c.tier === 1 && c.chessId !== cid);
for (let i = 0; i < n - 1; i++) assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: give(m, ps, fodder[i % fodder.length], 'hand').uid }), OK);
assert.equal(got.length, 0, 'nothing yet');
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: give(m, ps, fodder[0], 'hand').uid }), OK);
assert.equal(got.length, 1, `${id}: 1 after ${n} sells`);
const c = DATA.chess[got[0]];
assert.ok(c.bonds.some((b) => DATA.chess[cid].bonds.includes(b)) && !c.isGolden && c.tier <= ps.shop.level);
cover(id);
}
});
test('突变细胞: after a battle the carrier becomes a random NORMAL tier+1 operator; its equipment, the cell included, returns to the hand', () => {
// PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)"; the cell is not consumed (player
// feedback after 0.1.0 — players re-inject it every round; test/match/feedback1-meta.test.js)
const { m, ps, equip } = setup({ seed: 3 });
const cid = plain((c) => c.tier === 2)[0];
const holder = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('5_08')), holder), OK);
assert.deepEqual(equip(giveItem(m, ps, A('1_01')), holder), OK);
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
const chess = ps.hand.filter((p) => p && p.kind === 'chess');
assert.equal(chess.length, 1);
const p = chess[0];
assert.equal(DATA.chess[p.id].tier, 3);
assert.ok(!DATA.chess[p.id].isGolden);
assert.deepEqual(p.items, [], 'no equipment left on it');
assert.ok(handIds(ps, 'item').includes(A('1_01')), 'other item back in the hand');
assert.ok(handIds(ps, 'item').includes(A('5_08')), 'the cell back in the hand (not consumed)');
assert.equal(DATA.items[A('5_08')].upgradeNum, 100);
// the golden record (same buff) behaves the same
const g = ps.hand.find((x) => x && x.id === A('5_08'));
g.id = B('5_08');
assert.deepEqual(equip(g, p), OK);
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
const q = ps.hand.find((x) => x && x.kind === 'chess');
assert.equal(DATA.chess[q.id].tier, 4);
assert.ok(handIds(ps, 'item').includes(B('5_08')));
cover(A('5_08'), B('5_08'));
});
test('人事部文档: deploy cap becomes 9 (a second copy adds nothing)', () => {
const { m, ps, equip } = setup();
const t = give(m, ps, plain((c) => c.tier === 1)[0], 'hand');
assert.equal(ps.deployCap, 8);
assert.deepEqual(equip(giveItem(m, ps, A('6_08')), t), OK);
assert.equal(ps.deployCap, 9);
assert.deepEqual(equip(giveItem(m, ps, A('6_08')), t), OK);
assert.equal(ps.deployCap, 9);
cover(A('6_08'), B('6_08'));
});
test('天师古鼎 + 炎国短刀 (炎 carrier): +2 funds per operator gained, at most 3 times per round', () => {
const fodder = plain((c) => c.tier <= 2 && !c.bonds.includes('yanShip'));
const { h, m, ps, equip } = setup({ seed: 44 });
const yan = plain((c) => c.bonds.includes('yanShip'))[0];
const holder = give(m, ps, yan, 'board', [10, 5]);
assert.deepEqual(equip(giveItem(m, ps, B('6_03')), holder), OK);
assert.deepEqual(equip(giveItem(m, ps, A('3_04')), holder), OK);
const f0 = ps.funds;
for (let i = 0; i < 5; i++) ps.acquireChess(fodder[i], { source: 'test' });
assert.equal(ps.funds - f0, 6, 'max 3 per round');
h.toPrep(2);
const f1 = ps.funds;
ps.acquireChess(fodder[5], { source: 'test' });
assert.equal(ps.funds - f1, 2, 'new round');
// without 炎国短刀 (another item), or a non-炎 carrier: nothing
for (const [carrier, other] of [[yan, A('1_01')], [fodder[6], A('3_04')]]) {
const s2 = setup({ seed: 44 });
const piece = give(s2.m, s2.ps, carrier, 'board', [10, 5]);
assert.deepEqual(s2.equip(giveItem(s2.m, s2.ps, A('6_03')), piece), OK);
assert.deepEqual(s2.equip(giveItem(s2.m, s2.ps, other), piece), OK);
const g0 = s2.ps.funds;
s2.ps.acquireChess(fodder[0], { source: 'test' });
assert.equal(s2.ps.funds, g0, `${carrier} + ${other}`);
}
cover(A('3_04'), B('3_04'));
});
test('画卷 (Art): copies the operator on the tile / in front with its elite status and equipment into the hand', () => {
const { m, ps } = setup({ seed: 8 });
const cid = plain((c) => c.tier === 2)[0];
const target = give(m, ps, DATA.chess[cid].goldenId, 'board', [10, 6]);
target.items.push(ps.newPiece('item', A('3_03')), ps.newPiece('item', A('6_09')));
ps.recompute();
const art = giveItem(m, ps, 'chess_item_6_02_m');
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: 10, col: 5 }), OK, 'range 1-1: the tile in front');
const copy = ps.hand.find((p) => p && p.kind === 'chess');
assert.equal(copy && copy.id, DATA.chess[cid].goldenId, 'elite copy in the hand');
// the copied 激光发射器 pairs with the original's (items merge "无论是否被装备") ⇒ the golden goes to the hand;
// 变形同构体 never merges ⇒ the copy wears it too
assert.deepEqual(copy.items.map((x) => x.id), [A('6_09')]);
assert.deepEqual(ps.find(target.uid).piece.items.map((x) => x.id), [A('6_09')]);
assert.deepEqual(handIds(ps, 'item'), [B('3_03')], 'merged golden in the hand (regression: was equipped on the copy)');
const art2 = giveItem(m, ps, 'chess_item_6_02_m');
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art2.uid, row: 11, col: 7 }).error, 'BAD_TARGET', 'no operator in range');
cover('chess_item_6_02_m');
});
test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任务中选择一项": extra enemies, coins for a perfect own phase); “神秘顾客”: a band bounty; destroyed ⇒ +1 fund, passes on', () => {
// the 20 战术特训 cards — the ones the 悬赏决策 draft leaves out ("※以下悬赏任务仅由法术教鞭生成")
const training = new Set(DATA.choices.cards.bounty.filter((c) => c.draftExcluded === 'perfect').map((c) => c.effectId));
assert.equal(training.size, 20);
const seen = new Set();
for (let s = 0; s < 6; s++) {
const { m, ps } = setup({ seed: 60 + s });
for (let k = 0; k < 2; k++) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: 10, col: 5 }), OK);
}
assert.equal(ps.bounties.length, 2);
for (const b of ps.bounties) {
assert.ok(training.has(b.card.effectId), `${b.card.effectId} ${b.card.name}: a 战术特训 card (user playtest #6 item 4 review)`);
assert.match(b.card.name, /战术特训/);
assert.equal(b.card.payout, 'perfect', 'pays for a perfect own phase, not per kill');
assert.ok(m.gd.enemy(b.card.enemyKey) && !m.gd.inactiveEnemies.has(b.card.enemyKey), 'its enemy can appear in the mode');
seen.add(b.card.effectId);
}
}
assert.ok(seen.size >= 5, 'random pick');
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 7 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const art = giveItem(m, p0, 'chess_item_6_01_m');
const f0 = p0.funds;
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: art.uid }), OK);
assert.equal(p0.funds, f0 + 1);
assert.ok(p1.hand.some((p) => p && p.id === 'chess_item_6_01_m'), 'passed to the next player');
const passed = p1.hand.find((p) => p && p.id === 'chess_item_6_01_m');
assert.deepEqual(m.handle('p_1', { t: 'g.art', itemUid: passed.uid, row: 10, col: 5 }), OK);
assert.match(p1.bounties[0].card.effectId, /^enemyeffect_b_/);
cover('chess_item_6_03_m', 'chess_item_6_01_m');
});
test('merging: two identical normal items become the golden one (upgradeNum 2); upgradeNum-100 items never merge', () => {
const never = Object.values(DATA.items).filter((r) => r.itemType === 'EQUIP' && !r.isGolden && r.upgradeNum === 100).map((r) => r.name).sort();
assert.deepEqual(never, ['人事部文档', '信标', '变形同构体', '寻呼模块', '拟态物质', '突变细胞'].sort());
const { m, ps } = setup();
ps.acquireItem(A('3_07'));
ps.acquireItem(A('3_07'));
assert.deepEqual(handIds(ps, 'item'), [B('3_07')]);
ps.acquireItem(A('6_09'));
ps.acquireItem(A('6_09'));
assert.equal(handIds(ps, 'item').filter((x) => x === A('6_09')).length, 2);
cover(A('3_07'), B('3_07'));
});
test('coverage: every equipment (normal + golden) and every Art is exercised by a test above', () => {
// items whose numbers are asserted in the stat table, the proc tests or the meta tests
const missing = Object.keys(DATA.items).filter((id) => !COVER.has(id)).sort();
assert.deepEqual(missing, []);
assert.equal(Object.keys(DATA.items).length, 115);
});