- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
1283 lines
67 KiB
JavaScript
1283 lines
67 KiB
JavaScript
// 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, legalTileFor } 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 (无来源真实伤害, not 流失 — feedback1d-solvent.test.js), 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('炎国短刀: timed deployment skill adds one stack per deployment, including carry.skillActive', () => {
|
||
const id = 'chess_char_1_19_a';
|
||
for (const [item, per] of [[A('3_04'), 0.05], [B('3_04'), 0.08]]) {
|
||
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, skillIndex: 0, items: [item], carryState: { skillActive: true } }] });
|
||
h.b.start();
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.activations, 1);
|
||
close(u.s.atk, u.base.atk * (1 + per), 'one deployment stack');
|
||
h.run(u.skill.duration + 1);
|
||
close(u.s.atk, u.base.atk * (1 + per), 'no extra stack on end');
|
||
h.b.retreat(u);
|
||
assert.ok(h.b.redeploy(u, { free: true }));
|
||
assert.equal(u.skill.activations, 2);
|
||
close(u.s.atk, u.base.atk * (1 + per * 2), 'one more on redeployment');
|
||
checkInvariants(h.b);
|
||
}
|
||
});
|
||
|
||
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, 3, 'only the first skill end gives 30; this cast still gives its own 3 through the lock');
|
||
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). */
|
||
/** The warn toasts sent to the match's players from now on: [{ msgid, who, name }] (server msg() objects). */
|
||
function spyWarns(m) {
|
||
const got = [];
|
||
const orig = m.toast.bind(m);
|
||
m.toast = (ps, kind, text) => {
|
||
if (kind === 'warn') got.push({ msgid: text?.msgid ?? text, who: text?.params?.who?.dn ?? null, name: text?.params?.name?.dn ?? null });
|
||
return orig(ps, kind, text);
|
||
};
|
||
return got;
|
||
}
|
||
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('拟态物质 grants the third copy even when stock is empty; the gain still merges and occupies stock (measured pool rule)', () => {
|
||
// the report: an elite 溯光星源 (3 of the Ⅵ阶's 5 copies) and 2 normal ones — the pool is empty, the item text's 否则
|
||
// (a random same-bond operator) is only for fewer than 2 owned
|
||
const cid = 'chess_char_6_16_a';
|
||
const elite = DATA.chess[cid].goldenId;
|
||
const chessIn = (ps) => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'chess').map((p) => p.id).sort();
|
||
for (const item of [A('5_05'), B('5_05')]) {
|
||
const { m, ps, equip } = setup({ seed: 9 });
|
||
give(m, ps, elite, 'hand');
|
||
const t = give(m, ps, cid, 'hand');
|
||
give(m, ps, cid, 'hand');
|
||
assert.equal(m.pool.left(cid), 0, 'elite 3 + 2 normal = the pool cap 5');
|
||
const got = spyGrants(ps);
|
||
const warns = spyWarns(m);
|
||
const it = giveItem(m, ps, item);
|
||
assert.deepEqual(equip(it, t), OK, item);
|
||
assert.deepEqual(got, [cid], `${item}: same operator granted despite empty stock`);
|
||
assert.deepEqual(chessIn(ps), [elite, elite], `${item}: the third normal copy merges`);
|
||
assert.ok(!ps.find(it.uid), `${item}: consumed`);
|
||
assert.deepEqual(warns, [], `${item}: a successful grant must not report an empty-pool failure`);
|
||
assert.equal(m.pool.entries.get(cid).left, -1, 'the sixth owned copy still occupies the pool');
|
||
}
|
||
// 2 normal copies, no elite, the pool drained by other players: the third copy is still granted.
|
||
{
|
||
const { m, ps, equip } = setup({ seed: 9 });
|
||
const t = give(m, ps, cid, 'hand');
|
||
give(m, ps, cid, 'hand');
|
||
m.pool.take(cid, m.pool.left(cid));
|
||
const got = spyGrants(ps);
|
||
const warns = spyWarns(m);
|
||
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
|
||
assert.deepEqual(got, [cid]);
|
||
assert.deepEqual(chessIn(ps), [elite]);
|
||
assert.deepEqual(warns, []);
|
||
}
|
||
// 2 normal copies with copies left: the 3rd merges into the elite
|
||
{
|
||
const { m, ps, equip } = setup({ seed: 9 });
|
||
const t = give(m, ps, cid, 'hand');
|
||
give(m, ps, cid, 'hand');
|
||
assert.equal(m.pool.left(cid), 3);
|
||
const warns = spyWarns(m);
|
||
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
|
||
assert.deepEqual(chessIn(ps), [elite]);
|
||
assert.deepEqual(warns, [], 'a grant: no warn toast');
|
||
assert.equal(m.pool.left(cid), 2, 'the elite holds 3 copies');
|
||
}
|
||
});
|
||
|
||
test('拟态物质 with fewer than 2 copies samples eligible identities independently of remaining stock', () => {
|
||
// 缪尔赛思's only bond is 调和 and she is its only member: one copy owned, the rest of her pool taken by others
|
||
const cid = 'chess_char_6_11_a';
|
||
const { m, ps, equip } = setup({ seed: 9 });
|
||
const t = give(m, ps, cid, 'hand');
|
||
m.pool.take(cid, m.pool.left(cid));
|
||
const got = spyGrants(ps);
|
||
const warns = spyWarns(m);
|
||
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
|
||
assert.deepEqual(got, [cid], 'the only eligible identity remains available even with no stock');
|
||
assert.deepEqual(warns, []);
|
||
});
|
||
|
||
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('突变细胞: a DEPLOYED carrier leaves the field — the new operator is gained into the 整备区, the deploy slot comes back (PR #2)', () => {
|
||
// official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR): the tile is empty at the next prep and the new operator waits
|
||
// on the bench; the case added by the closed PR #2, on the destroy-then-gain rule (DESIGN §21.1)
|
||
const { m, ps, equip } = setup({ seed: 3 });
|
||
const cid = plain((c) => c.tier === 2)[0];
|
||
const holder = give(m, ps, cid, 'board', legalTileFor(m, ps, cid));
|
||
assert.deepEqual(equip(giveItem(m, ps, A('5_08')), holder), OK);
|
||
assert.equal(ps.deployCount, 1, 'the carrier is deployed');
|
||
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||
assert.equal(ps.board.size, 0, 'the carrier left the field');
|
||
assert.equal(ps.deployCount, 0, 'the deploy slot came back');
|
||
const chess = ps.hand.filter((p) => p && p.kind === 'chess');
|
||
assert.equal(chess.length, 1, 'the new operator waits in the 整备区');
|
||
assert.equal(DATA.chess[chess[0].id].tier, 3, 'the random tier+1 operator');
|
||
assert.equal(DATA.chess[chess[0].id].isGolden, false);
|
||
assert.ok(handIds(ps, 'item').includes(A('5_08')), 'the cell back in the hand');
|
||
});
|
||
|
||
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 ⇒ its copy waits in the hand too, unequipped (PRTS 画卷 备注 "获得的装备为未装备状态";
|
||
// until 0.2.2 the copy wore it)
|
||
assert.deepEqual(copy.items.map((x) => x.id), [], 'the copy wears nothing');
|
||
assert.deepEqual(ps.find(target.uid).piece.items.map((x) => x.id), [A('6_09')]);
|
||
assert.deepEqual(handIds(ps, 'item').slice().sort(), [B('3_03'), A('6_09')].sort(), 'the merged golden and the copied 变形同构体 in the hand');
|
||
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');
|
||
const deadline = m.deadline;
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: 10, col: 5 }), OK);
|
||
const pending = ps.personalChoice;
|
||
assert.equal(ps.bounties.length, k, 'offering cards does not add a bounty');
|
||
assert.equal(pending.cards.length, 3);
|
||
assert.equal(new Set(pending.cards.map((c) => c.effectId)).size, 3);
|
||
assert.equal(pending.round, m.round);
|
||
assert.equal(pending.sourceItemId, art.id);
|
||
assert.equal(m.deadline, deadline, 'uses the existing PREP clock');
|
||
assert.equal(ps.find(art.uid), null);
|
||
assert.equal(ps.round.arts, k + 1, 'consumed when offered');
|
||
for (const c of pending.cards) assert.ok(training.has(c.effectId) && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: pending.id }), OK);
|
||
assert.equal(ps.personalChoice, null);
|
||
assert.equal(ps.bounties[k].card.effectId, pending.cards[1].effectId, 'the submitted card is applied');
|
||
assert.equal(ps.round.arts, k + 1, 'picking does not consume another Art');
|
||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: pending.id }).error, 'BAD_TARGET');
|
||
}
|
||
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, 'different offered cards across seeds');
|
||
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_/);
|
||
assert.equal(p1.personalChoice, null, '神秘顾客 still applies its random bounty immediately');
|
||
cover('chess_item_6_03_m', 'chess_item_6_01_m');
|
||
});
|
||
|
||
test('教鞭: pending and empty-pool failures keep the Art, Arts count and RNG; short offers use actual cards', () => {
|
||
const { m, ps } = setup();
|
||
const first = giveItem(m, ps, 'chess_item_6_03_m');
|
||
const second = giveItem(m, ps, 'chess_item_6_03_m', 'temp');
|
||
assert.deepEqual(ps.useArt(first.uid, 10, 5), OK);
|
||
const pending = ps.personalChoice;
|
||
const rng = m.rngMeta.state(), uid = m.uidSeq;
|
||
assert.deepEqual(ps.useArt(second.uid, 10, 5), { error: 'BAD_TARGET', detail: '请先完成当前教鞭选择' });
|
||
assert.equal(ps.personalChoice, pending);
|
||
assert.ok(ps.find(second.uid));
|
||
assert.equal(ps.round.arts, 1);
|
||
assert.equal(m.rngMeta.state(), rng);
|
||
assert.equal(m.uidSeq, uid);
|
||
assert.equal(m.pickPersonalChoice(ps, -1, pending.id).error, 'BAD_TARGET');
|
||
assert.equal(m.pickPersonalChoice(ps, 0.5, pending.id).error, 'BAD_TARGET');
|
||
assert.equal(m.pickPersonalChoice(ps, 3, pending.id).error, 'BAD_TARGET');
|
||
assert.equal(ps.personalChoice, pending);
|
||
assert.deepEqual(m.pickPersonalChoice(ps, 0, pending.id), OK);
|
||
const training = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect');
|
||
m.gd.inactiveEnemies = new Set(training.map((c) => c.enemyKey));
|
||
assert.deepEqual(ps.useArt(second.uid, 10, 5), { error: 'BAD_TARGET', detail: '当前没有可用的战术特训' });
|
||
assert.ok(ps.find(second.uid));
|
||
assert.equal(ps.round.arts, 1);
|
||
assert.equal(ps.personalChoice, null);
|
||
assert.equal(m.rngMeta.state(), rng);
|
||
const allowed = training.find((c) => c.multiRound && m.gd.enemy(c.enemyKey));
|
||
m.gd.inactiveEnemies.delete(allowed.enemyKey);
|
||
assert.deepEqual(ps.useArt(second.uid, 10, 5), OK);
|
||
assert.ok(ps.personalChoice.cards.length > 0 && ps.personalChoice.cards.length < 3);
|
||
assert.equal(new Set(ps.personalChoice.cards.map((c) => c.effectId)).size, ps.personalChoice.cards.length);
|
||
assert.equal(ps.find(second.uid), null, 'Arts in temp are consumed too');
|
||
const view = ps.privateView().personalChoice;
|
||
assert.ok(view.cards.every((c) => c.kind === 'bounty' && c.rounds === 2));
|
||
assert.ok(view.cards.every((c) => !/每场/.test(c.descRaw || c.desc)), 'multi-round candidates use the same two-battle text as applied bounties');
|
||
m.dispose();
|
||
});
|
||
|
||
test('教鞭: an open choice goes with a player who is eliminated, and nothing of it is left in its views', () => {
|
||
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 8 });
|
||
const a = h.ps('p_0');
|
||
const art = giveItem(m, a, 'chess_item_6_03_m');
|
||
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
|
||
assert.equal(a.privateView().personalChoice.cards.length, 3);
|
||
a.eliminate(m.round);
|
||
assert.equal(a.personalChoice, null);
|
||
assert.equal(a.privateView().personalChoice, null);
|
||
assert.equal(a.privateView().canReady, false);
|
||
m.dispose();
|
||
});
|
||
|
||
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);
|
||
});
|