Files
ark-stronghold-protocol/test/content/kits_alt_t4.test.js
T
sganggs 94a99a3b1a feedback1 integrate: no status on a 无敌 + 无法选中 unit; 余 S2 skips 自缚 units; 信仰搅拌机 S2 blocks with too few bullets
- Battle.applyStatus: a unit both invulnerable and untargetable takes no status from the
  other side (PRTS 无敌 "无法被不同阵营选中"), so the status carried by the hit that knocks an
  enemy out (妮芙 S2's fear, a 叙拉古 proc) no longer lands after its 重生 cleanse.
- Flag selfBound (自缚: 守墓石像's 转换模式, the 自缚 leaders, the 胄 parts' anchor): 余 S2
  never teleports such a unit (PRTS 余 S2 备注 "持有自缚的单位不视为可达目标").
- 信仰搅拌机 S2: with fewer than ammo_cost bullets the lethal hit is still blocked, every
  bullet goes and the skill ends; it steps aside while 坚固维式重锤's 不死 window holds her
  (PRTS 备注), replacing the [ASSUMED] no-guard rule.
2026-10-03 11:52:52 +08:00

1222 lines
65 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.
// Tier-4 operator loadouts (DESIGN §16): every selectable NON-default skill of every visible tier-4 chess has a
// hand-authored spec in server/sim/content/kits/tier4.js (`skills[skillId]`), proven here by its signature effect for
// the normal (Lv4) and the elite (Lv7) chess; non-default modules (and 'none') change what their text says. Numbers
// are read back from the data blackboards (the SELECTED skill's `bb`), real battles through the harness.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { loadoutOptions } from '../../shared/protocol.js';
import { skillSpecSource } from '../../server/sim/content/index.js';
import { kitCoverage } from '../../tools/kit-coverage.mjs';
import { canTargetAlly } from '../../server/sim/targeting.js';
import { aggregateMods } from '../../server/sim/buffs.js';
const ds = getDefaultSource();
const C = ds.raw.chess;
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, speed: 0, ...o });
const gold = (id) => id.replace(/_a$/, '_b');
/** Both chess of a base id: [normal, elite]. */
const pair = (base) => [base, gold(base)];
/** Resolved def of a chess for a loadout. */
const D = (id, skillIndex, moduleId) => ds.getChess(id, { skillIndex, ...(moduleId ? { moduleId } : {}) });
/** Harness unit entry with a loadout. */
const U = (chessId, row, col, skillIndex = null, moduleId = null, extra = {}) =>
({ chessId, row, col, ...(skillIndex != null ? { skillIndex } : {}), ...(moduleId ? { moduleId } : {}), ...extra });
const dmgBy = (h, u, pred = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && pred(c));
const tagged = (tag) => (c) => (c.dmg?.tags || []).includes(tag);
const T4 = Object.values(C).filter((c) => c.tier === 4 && !c.isGolden && c.visible).map((c) => c.chessId).sort();
function battle(units, o = {}) {
return makeBattle({
defs: { enemies: { enemy_dummy: dummy(), ...(o.enemies || {}) } },
units, timeLimit: o.timeLimit ?? 300, hooks: o.hooks ?? ['damaged', 'attack', 'skillStart', 'skillEnd', 'heal', 'death', 'deploy', 'dodge', 'spGain'],
captureNoisy: true, seed: o.seed ?? 3, flags: o.flags, ...(o.extra || {}),
});
}
// ---------------------------------------------------------------------------------------------------------------
// coverage + smoke
test('tier4 loadouts: every selectable skill of every visible chess is hand-authored (normal + elite)', () => {
const rep = kitCoverage({ tier: 4 });
assert.equal(rep.summary.chess, 22);
assert.equal(rep.summary.covered, rep.summary.skills, JSON.stringify(rep.chess.flatMap((r) => r.skills.filter((s) => !s.covered).map((s) => `${r.name} S${s.index + 1}`))));
for (const base of T4) {
const { skills } = loadoutOptions(C[base], C[gold(base)]);
for (const i of skills) {
if (C[base].skills.find((s) => s.index === i).isDefault) continue;
for (const id of pair(base)) assert.equal(skillSpecSource(D(id, i)), 'skills', `${id} S${i + 1}`);
}
}
});
test('tier4 loadouts: every chess × selectable skill × module fights a mixed wave without content errors', () => {
const runs = [];
for (const base of T4) {
const { skills } = loadoutOptions(C[base], C[gold(base)]);
const mods = [null, 'none', ...(C[gold(base)].modules || []).filter((m) => !m.isDefault).map((m) => m.uniEquipId)];
for (const i of skills) {
runs.push([base, i, null]);
for (const m of mods) runs.push([gold(base), i, m]);
}
}
for (const [id, i, m] of runs) {
const h = makeBattle({
defs: { enemies: {
e_walk: enemyRec({ key: 'e_walk', hp: 6000, atk: 300, def: 100, res: 20, speed: 1, bat: 1.5 }),
e_fly: enemyRec({ key: 'e_fly', hp: 3000, atk: 150, motion: 'FLY', speed: 1, range: 1.5 }),
e_elite: enemyRec({ key: 'e_elite', rank: 'ELITE', hp: 9000, atk: 400, def: 200, speed: 0.8, bat: 2 }),
} },
units: [U(id, 9, 6, i, m), U('chess_char_1_02_a', 10, 6), U('chess_char_1_01_a', 11, 5), U('chess_char_4_17_a', 12, 7)],
enemies: [{ key: 'e_walk', route: 0, count: 6, interval: 3 }, { key: 'e_walk', route: 1, count: 4, interval: 4 },
{ key: 'e_fly', route: 2, count: 3, interval: 5 }, { key: 'e_elite', route: 0, time: 8 }],
timeLimit: 70, seed: 5, hooks: [],
});
const u = h.unit(id);
assert.equal(u.def.loadout?.skillIndex ?? u.def.skill.index, i, `${id} S${i + 1} selected`);
u.skill.gainSp?.(1000, 'test');
h.runToEnd(80);
assert.equal(h.b.errorCount, 0, `${id} S${i + 1} ${m}: ${JSON.stringify(h.b.errors.map((e) => e.label + ' ' + e.message))}`);
checkInvariants(h.b);
}
});
// ---------------------------------------------------------------------------------------------------------------
// 信仰搅拌机
test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fires with the next attack); next attack = 3 hits × atk_scale; reloads one adjacent 拉特兰 ammo skill', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 9, 5, 0, gold(id) === id ? 'none' : null), U('chess_char_1_01_a', 10, 4)], {
enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 50, bat: 1 }) },
});
h.step();
const u = h.unit(id), ins = h.unit('chess_char_1_01_a');
assert.equal(u.skill.id, 'skchr_rmixer_1');
assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)');
assert.ok(ins.skill.activate('test', { free: true }));
u.skill.gainSp(1000);
const ammo0 = ins.skill.ammoLeft;
h.hooksOf('attack').length = 0;
h.spawn('enemy_hitter', { pos: [9, 5] });
assert.ok(h.runUntil(() => h.hooksOf('skillStart').some((c) => c.unit === u), 10), `${id} S1 cast with the next attack`);
assert.ok(h.runUntil(() => h.hooksOf('skillEnd').some((c) => c.unit === u), 10), 'the armed attack happened');
const skillHits = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.equal(skillHits.length, 3, `${id}: triple hit`);
assert.equal(new Set(skillHits.map((c) => c.dmg.attackId)).size, 1, 'one attack');
for (const c of skillHits) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, `${id} hit`);
const shots = h.hooksOf('attack').filter((c) => c.attacker === ins).length;
assert.equal(ins.skill.ammoLeft, ammo0 - shots + bb.charge, `${id} reloaded ${bb.charge} bullet(s)`);
checkInvariants(h.b);
}
});
test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit is negated for ammo_cost bullets; with fewer it is still negated, every bullet goes and the skill ends (PRTS 备注)', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 1).skill.bb;
for (const enough of [true, false]) {
const h = battle([U(id, 9, 5, 1)], { enemies: { enemy_killer: dummy({ key: 'enemy_killer', atk: 1e6, bat: 2 }) } });
h.step();
const u = h.unit(id);
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.skill.kind, 'ammo');
assert.equal(u.skill.ammoLeft, bb['attack@trigger_time']);
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, 'ATK');
approx(u.s.def, u.base.def * (1 + bb.def), 1e-6, 'DEF');
if (!enough) u.skill.ammoLeft = bb.ammo_cost - 1;
u.hp = 500;
const before = u.skill.ammoLeft;
h.spawn('enemy_killer', { pos: [9, 5] });
h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === u) || !u.alive, 10);
if (enough) {
assert.ok(u.alive, `${id}: lethal hit negated`);
approx(u.hp, 500, 1e-9, 'HP kept');
const shots = h.hooksOf('attack').filter((c) => c.attacker === u).length;
assert.equal(u.skill.ammoLeft, before - bb.ammo_cost - shots, `${bb.ammo_cost} bullets spent`);
} else {
// PRTS 备注 "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态" (0.1.0: [ASSUMED] no guard)
assert.ok(u.alive, `${id}: fewer than ${bb.ammo_cost} bullets still block the lethal hit`);
approx(u.hp, 500, 1e-9, 'HP kept');
assert.equal(u.skill.ammoLeft, 0, 'every bullet spent');
assert.equal(u.skill.active, false, 'the skill ends');
}
}
}
});
test('信仰搅拌机 modules: SPT-Y 老朋友 = range +1 and no reveal; SPT-X (default) reveals', () => {
const size = (m) => {
const h = battle([U('chess_char_4_01_b', 10, 5, null, m)]);
h.step();
const u = h.unit('chess_char_4_01_b');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.applyStatus(e, 'stealth', { duration: 30 });
h.run(0.5);
return { n: u.rangeKeys.length, reveal: !!e.s.flags.reveal };
};
const def = size(null), y = size('uniequip_003_rmixer'), none = size('none');
assert.ok(y.n > def.n, `range ${def.n} → ${y.n}`);
assert.equal(def.n, none.n);
assert.deepEqual([def.reveal, y.reveal, none.reveal], [true, false, false]);
});
// ---------------------------------------------------------------------------------------------------------------
// 莫斯提马
test('莫斯提马 S1 攻击力强化·γ型: ATK +45 %/+60 %; talent 2 slow is NOT tripled (S3 only)', () => {
for (const id of pair('chess_char_4_02_a')) {
const bb = D(id, 0).skill.bb, t1 = D(id, 0).talents[1].bb;
const h = battle([U(id, 10, 3, 0)]);
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(u.skill.activate('test', { free: true }));
h.run(0.5);
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, `${id} ATK`);
approx(e.findBuff(`mostma:slow:${u.id}`).mods.moveMul, 1 + t1.move_speed, 1e-9, 'plain talent slow');
approx(u.skill.timeLeft, D(id, 0).skill.duration - 0.5, 0.05);
}
});
test('莫斯提马 S2 荒时之锁: every enemy in range stunned until the skill ends, atk_scale arts per second', () => {
for (const id of pair('chess_char_4_02_a')) {
const sk = D(id, 1).skill, bb = sk.bb;
const h = battle([U(id, 10, 3, 1)]);
h.step();
const u = h.unit(id);
const es = [h.spawn('enemy_dummy', { pos: [10, 4] }), h.spawn('enemy_dummy', { pos: [9, 5] }), h.spawn('enemy_dummy', { pos: [11, 5] })];
const far = h.spawn('enemy_dummy', { pos: [10, 9] });
assert.ok(u.skill.activate('test', { free: true }));
h.run(0.2);
for (const e of es) assert.ok(e.s.flags.stun, `${id}: enemy in range stunned`);
assert.ok(!far.s.flags.stun, 'enemy out of range not stunned');
h.runUntil(() => !u.skill.active, 20);
const lock = dmgBy(h, u, tagged('timeLock'));
for (const e of es) {
const mine = lock.filter((c) => c.target === e);
assert.equal(mine.length, Math.floor(sk.duration + 1e-6), `${id}: one tick per second (${sk.duration} s)`);
approx(mine[0].amount, u.s.atk * bb.atk_scale, 1e-6, 'arts tick');
}
assert.ok(!lock.some((c) => c.target === far));
h.run(0.2);
for (const e of es) assert.ok(!e.s.flags.stun, 'stun ends with the skill');
checkInvariants(h.b);
}
});
test('莫斯提马 module SPC-X 资深万国信使定制斗篷: 攻击范围扩大 (default SPC-Y and none do not)', () => {
const n = (m) => { const h = battle([U('chess_char_4_02_b', 10, 3, null, m)]); h.step(); return h.unit('chess_char_4_02_b').rangeKeys.length; };
assert.ok(n('uniequip_003_mostma') > n(null));
assert.equal(n(null), n('none'));
});
test('莫斯提马 SPC-X: the range becomes the module grid (only the centre tile [0,3] added, no rangeExtend); S3 keeps its own range', () => {
const id = 'chess_char_4_02_b';
const modGrid = C[id].modules.find((m) => m.uniEquipId === 'uniequip_003_mostma').talentChanges.find((t) => t.rangeGrid).rangeGrid;
const rel = (u) => u.rangeKeys.map((k) => `${Math.floor(k / 21) - u.tileR},${(k % 21) - u.tileC}`).sort();
const run = (m) => {
const h = battle([U(id, 10, 3, null, m)]);
h.step();
const u = h.unit(id);
const base = rel(u), initial = u.baseRangeKeys.length, ext = u.s.rangeExtend;
assert.ok(u.skill.activate('test', { free: true }));
h.step();
return { base, initial, ext, skill: rel(u) };
};
const x = run('uniequip_003_mostma'), def = run(null);
assert.deepEqual(x.base, modGrid.map(([r, c]) => `${r},${c}`).sort(), 'own range = module grid');
assert.equal(x.base.length, C[id].rangeGrid.length + 1, 'exactly one tile more');
assert.ok(x.base.includes('0,3') && !x.base.includes('1,3') && !x.base.includes('-1,3'));
assert.equal(x.initial, x.base.length, 'DEFAULT trigger range follows');
assert.equal(x.ext, 0, 'no forward extension');
assert.deepEqual(x.skill, def.skill, 'S3 range unchanged by the module');
assert.equal(x.skill.length, D(id, 2).skill.rangeGrid.length);
});
// ---------------------------------------------------------------------------------------------------------------
// 伊内丝
test('伊内丝 S1 淬影突袭: next attack +2 DP and a 3 s non-stacking arts DoT of 40 %/55 % ATK per second', () => {
for (const id of pair('chess_char_4_04_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 } });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(1.5); // first damage: 影织 binds + steals ATK (her ATK is stable afterwards)
const dp0 = h.b.getPlayer('p1').dp;
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.run(0.4); // the bolt lands
assert.equal(h.b.getPlayer('p1').dp, dp0 + bb.cost, `${id}: +${bb.cost} DP`);
assert.ok(e.findBuff(`ines:bleed:${u.id}`), 'DoT applied');
h.run(bb.bleed_duration + 0.5);
const dot = dmgBy(h, u, tagged('dot'));
assert.equal(dot.length, bb.bleed_duration, `${id}: ${bb.bleed_duration} ticks`);
for (const c of dot) approx(c.amount, u.s.atk * bb.bleed_atk_scale, 1e-6, 'arts per second');
assert.ok(e.buffs.filter((b) => b.key === `ines:bleed:${u.id}`).length <= 1, '不叠加');
}
});
test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats; the redeploy recalls it through ≤ 4/5 enemies; ATK up; +1 DP per damage', () => {
for (const id of pair('chess_char_4_04_a')) {
const sk = D(id, 2).skill, bb = sk.bb;
const h = battle([U(id, 10, 5, 2)], { flags: { dpInit: 99, dpPerSec: 0 } });
const u = h.unit(id);
const near = [];
for (let i = 0; i < 3; i++) near.push(h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] }));
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step(3);
const left = h.hooksOf('death').find((c) => c.unit === u);
assert.ok(left && left.reason === 'retreat', `${id}: leaves right after the first deployment`);
assert.ok(h.runUntil(() => u.alive && u.deployed, 2), 'redeploy timer refreshed: back at once');
const buff = u.findBuff('ines:s3');
assert.ok(buff, 'passive buff on the redeploy');
approx(buff.mods.atkPct, bb.atk, 1e-9);
approx(buff.timeLeft, sk.duration, 0.1);
const recall = dmgBy(h, u, tagged('sentryRecall'));
assert.equal(recall.length, bb.max_target, `${id}: recall hits ≤ ${bb.max_target} of 6`);
assert.ok(recall.every((c) => near.includes(c.target)) && !recall.some((c) => c.target === far));
for (const c of recall) approx(c.amount, u.base.atk * (1 + bb.atk) * bb.atk_scale, 1e-6, 'recall damage');
assert.equal(u.mem.sentry, null, 'sentry recalled');
const dpAt = h.b.getPlayer('p1').dp, tAt = h.b.time;
h.run(3);
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.t >= tAt - 1e-9 && c.amount > 0 && c.target.side === 'enemy').length;
assert.ok(hits > 0);
assert.equal(h.b.getPlayer('p1').dp, dpAt + hits * bb.cost, 'one DP per damage while the passive lasts');
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'only the first deployment leaves');
checkInvariants(h.b);
}
});
test('伊内丝 module none: the first retreat keeps its full redeploy time (AGE-Y shortens it by 35 %)', () => {
const wait = (m) => {
const h = battle([U('chess_char_4_04_b', 10, 5, null, m)]);
h.step();
const u = h.unit('chess_char_4_04_b');
h.b.retreat(u);
return u.respawnAt - u.deathAt;
};
approx(wait(null), wait('none') * (1 + C.chess_char_4_04_b.modules[0].traitOverride.bb.respawn_time), 1e-6);
});
// ---------------------------------------------------------------------------------------------------------------
// 寒芒克洛丝 / 风笛
test('寒芒克洛丝 S1 无痕: ATK up, every attack is a double shot, 迷彩 (ranged enemies cannot target her); no S2 hit counting', () => {
for (const id of pair('chess_char_4_06_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.skill.kind, 'duration');
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, `${id} ATK`);
assert.ok(u.s.flags.camou && !u.s.flags.stealth, '迷彩 (not 隐匿)');
assert.equal(canTargetAlly(e, u, true), false, 'not a ranged target');
h.run(3);
const shots = dmgBy(h, u, (c) => c.dmg.isAttack);
const per = new Map();
for (const c of shots) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1);
assert.ok(per.size >= 2 && [...per.values()].every((n) => n === 2), `${id}: 2 shots per attack`);
assert.equal(u.mem.kroosHits ?? 0, 0, 'S2 hit counter untouched');
h.runUntil(() => !u.skill.active, 30);
assert.ok(!u.s.flags.camou);
}
});
test('风笛 S1 迅捷打击·γ型 (ATK/ASPD) and S3 闭膛连发 (BAT +0.7 s, block +1, ATK/DEF up, triple hits)', () => {
for (const id of pair('chess_char_4_07_a')) {
const b1 = D(id, 0).skill.bb, b3 = D(id, 2).skill.bb;
let h = battle([U(id, 10, 4, 0)]);
h.step();
let u = h.unit(id);
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + b1.atk), 1e-6, 'S1 ATK');
assert.equal(u.s.aspd, u.base.aspd + b1.attack_speed);
h = battle([U(id, 10, 4, 2)]);
h.step();
u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + b3.atk), 1e-6, 'S3 ATK');
approx(u.s.def, u.base.def * (1 + b3.def), 1e-6, 'S3 DEF');
assert.equal(u.s.blockCnt, u.base.blockCnt + b3.block_cnt);
approx(u.s.interval, (u.base.bat + b3.base_attack_time) * 100 / u.s.aspd, 1e-6, 'BAT +0.7 s');
h.run(4);
const per = new Map();
for (const c of dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill)) per.set(`${c.dmg.attackId}:${c.target.id}`, (per.get(`${c.dmg.attackId}:${c.target.id}`) ?? 0) + 1);
assert.ok(per.size >= 2 && [...per.values()].every((n) => n === 3), `${id}: three hits per target per attack`);
}
});
test('风笛 module CHG-Y “棍棒与口袋”: attacks on enemies below 40 % HP use 115 % ATK (default / none do not)', () => {
const minHit = (m, low) => {
const h = battle([U('chess_char_4_07_b', 10, 4, 0, m)]);
h.step();
const u = h.unit('chess_char_4_07_b');
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
if (low) e.hp = e.s.maxHp * 0.3;
h.run(6);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e).map((c) => c.amount / u.s.atk);
assert.ok(hits.length >= 3);
return Math.min(...hits);
};
const tb = C.chess_char_4_07_b.modules.find((m) => m.uniEquipId === 'uniequip_003_bpipe').traitOverride.bb;
approx(minHit('uniequip_003_bpipe', true), tb.atk_scale, 1e-6, 'low HP ×1.15');
approx(minHit('uniequip_003_bpipe', false), 1, 1e-6, 'healthy target ×1');
approx(minHit(null, true), 1, 1e-6, 'default module: no bonus');
});
// ---------------------------------------------------------------------------------------------------------------
// 水月 / 阿罗玛 / 凯瑟琳
test('水月 S1 唤醒: charges; the next attack hits every enemy in range for atk_scale and talent 1 deals ×talent_scale', () => {
for (const id of pair('chess_char_4_09_a')) {
const d = D(id, 0), bb = d.skill.bb, t0 = d.talents[0].bb;
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
assert.equal(u.skill.maxCharges, bb.cnt);
const es = [h.spawn('enemy_dummy', { pos: [10, 5] }), h.spawn('enemy_dummy', { pos: [9, 5] })];
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(h.runUntil(() => !u.skill.active, 10));
const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.deepEqual(new Set(sk.map((c) => c.target)), new Set(es), `${id}: every enemy in range`);
for (const c of sk) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, 'skill hit');
const tal = dmgBy(h, u, tagged('talent'));
assert.equal(tal.length, 1);
approx(tal[0].amount, u.s.atk * t0['attack@mizuki_t_1.atk_scale'] * bb.talent_scale, 1e-6, 'talent ×2');
h.run(8);
for (const c of dmgBy(h, u, tagged('talent')).slice(1)) approx(c.amount, u.s.atk * t0['attack@mizuki_t_1.atk_scale'], 1e-6, 'plain talent afterwards');
}
});
test('水月 S3 镜花水月: wider range, ATK up, talent 1 +2 targets with stun; an attack hitting < 3 enemies costs 15 % max HP', () => {
for (const id of pair('chess_char_4_09_a')) {
const bb = D(id, 2).skill.bb;
for (const n of [1, 3]) {
const h = battle([U(id, 10, 4, 2)]);
h.step();
const u = h.unit(id);
const base = u.rangeKeys.length;
const es = [];
for (let i = 0; i < n; i++) es.push(h.spawn('enemy_dummy', { pos: [10 + (i === 1 ? -1 : i === 2 ? 1 : 0), 5] }));
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(u.rangeKeys.length > base, 'range widened');
approx(u.s.atk, u.base.atk * (1 + bb.atk) * (u.findBuff('mizuki:t2') ? 1 + D(id, 2).talents[1].bb.atk : 1), 1e-6);
const hp0 = u.hp;
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 10));
const tal = dmgBy(h, u, tagged('talent'));
assert.equal(tal.length, Math.min(n, 1 + bb['attack@max_target']), `${id}: talent hits ${Math.min(n, 3)}`);
for (const c of tal) assert.ok(c.target.s.flags.stun, 'talent stun');
if (n < 3) approx(hp0 - u.hp, u.s.maxHp * bb['attack@hp_ratio'], 1e-6, 'HP loss');
else assert.equal(u.hp, hp0, 'no loss with ≥ 3 enemies hit');
}
}
});
test('水月 module AMB-Y 深蓝之籽: 65 % physical and arts dodge (default AMB-X: 50 % + slow aura)', () => {
const dodge = (m) => { const h = battle([U('chess_char_4_09_b', 10, 4, null, m)]); h.step(); return h.unit('chess_char_4_09_b').s.dodgePhys; };
approx(dodge('uniequip_003_mizuki'), 0.65, 1e-9);
approx(dodge(null), 0.5, 1e-9);
});
test('阿罗玛 S1 强效清洁: charges; next blast atk_scale arts, flying victims take +atk_scale_to_fly', () => {
for (const id of pair('chess_char_4_10_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 3, 0, gold(id) === id ? 'none' : null)], { enemies: { enemy_flyer: dummy({ key: 'enemy_flyer', motion: 'FLY' }) } });
h.step();
const u = h.unit(id);
const g = h.spawn('enemy_dummy', { pos: [10, 6] }), f = h.spawn('enemy_flyer', { pos: [10, 6] });
h.run(4); // the talent's first-hit bonus / levitation are spent
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.run(1);
const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.deepEqual(new Set(sk.map((c) => c.target)), new Set([g, f]));
for (const c of sk) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, `${id} blast`);
const aa = dmgBy(h, u, tagged('antiAir'));
assert.equal(aa.length, 1);
assert.equal(aa[0].target, f, 'only the flyer');
approx(aa[0].amount, u.s.atk * bb.atk_scale_to_fly, 1e-6, 'anti-air bonus');
}
});
test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator holding a device shield; the hand card is her deploy limit', () => {
for (const id of pair('chess_char_4_11_a')) {
const bb = D(id, 0).skill.bb;
// her device is a placed piece (user playtest #6): facing 4_17 on its right
const dev = { kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 5, dir: 'RIGHT' };
const h = battle([U(id, 10, 4, 0, null, { uid: 1 }), U('chess_char_4_17_a', 10, 6), U('chess_char_1_01_a', 12, 3), dev]);
h.run(1);
const u = h.unit(id), hs = h.unit('chess_char_4_17_a'), ins = h.unit('chess_char_1_01_a');
assert.equal(u.skill.kind, 'passive');
approx(u.s.atk, u.base.atk * (1 + bb.s1_atk), 1e-6, `${id} own ATK`);
approx(u.s.def, u.base.def * (1 + bb.s1_def), 1e-6, 'own DEF');
for (const a of [hs, ins]) {
const f = a.findBuff(`cathy:forge:${u.id}`);
assert.equal(!!f, !!a.findBuff('cathy:shield'), `${a.name}: forge buff iff device shield`);
if (f) approx(f.mods.atkPct, bb.s1_atk, 1e-9);
}
assert.ok(hs.findBuff(`cathy:forge:${u.id}`), 'the shielded blocker gets it');
}
// the module changes how many devices she carries (none 3, CRA-X 4); the hand gets the deploy limit (2) either way
// (PRTS 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区")
assert.deepEqual([C.chess_char_4_11_b.talentsBase[0].bb.cnt, C.chess_char_4_11_b.talents[0].bb.cnt], [3, 4]);
for (const id of pair('chess_char_4_11_a')) assert.equal(ds.raw.tokens.token_10041_cathy_catsld.variants[id].stats.deployLimit, 2, `${id}: deploy limit`);
});
// ---------------------------------------------------------------------------------------------------------------
// 歌蕾蒂娅 / 灵知 / 莱恩哈特
test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1.3 vs weight ≤ 3) and pulls the target to her front', () => {
for (const id of pair('chess_char_4_12_a')) {
const d = D(id, 0), bb = d.skill.bb, t1 = d.talents[1].bb;
const h = battle([U(id, 10, 3, 0)]);
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const x0 = e.x;
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.run(0.5);
const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.equal(sk.length, 1);
approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`);
assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`);
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it
approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`);
}
});
test('歌蕾蒂娅 S2 缺水的掌握怒海: BAT +0.5 s, wider range, 2 targets at attack@atk_scale, both pulled to her front', () => {
for (const id of pair('chess_char_4_12_a')) {
const d = D(id, 1), bb = d.skill.bb, t1 = d.talents[1].bb;
const h = battle([U(id, 10, 3, 1)]);
h.step();
const u = h.unit(id);
const es = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [11, 6] }), h.spawn('enemy_dummy', { pos: [9, 6] })];
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 1e-6, 'BAT +0.5 s');
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 10));
const atk = h.hooksOf('attack').find((c) => c.attacker === u);
assert.equal(atk.targets.length, bb['attack@max_target'], `${id}: two targets`);
h.run(0.6);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.attackId === dmgBy(h, u)[0].dmg.attackId);
assert.equal(hits.length, bb['attack@max_target']);
for (const c of hits) approx(c.amount, u.s.atk * bb['attack@atk_scale'] * t1.atk_scale, 1e-6);
for (const e of atk.targets) assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`);
}
});
test('歌蕾蒂娅 module HOK-Y 淡金坠饰: pulling a far enemy (> 2.5 tiles) to herself is one force level stronger', () => {
// a weight-2 enemy: 中力 (1) − 2 = 受力等级 −1 ⇒ 35 % of the way; +1 level (HOK-Y) ⇒ 0 ⇒ all the way (PRTS 推与拉)
const moved = (m) => {
const h = battle([U('chess_char_4_12_b', 10, 3, 0, m)], { enemies: { enemy_heavy: dummy({ key: 'enemy_heavy', mass: 2 }) } });
h.step();
const u = h.unit('chess_char_4_12_b');
const e = h.spawn('enemy_heavy', { pos: [10, 6] });
assert.ok(u.skill.activate('test', { free: true }));
h.runUntil(() => !u.skill.active, 10);
h.run(0.5);
return 6 - e.x;
};
const y = moved('uniequip_003_glady'), x = moved(null);
assert.ok(y > x + 0.2, `HOK-Y ${y} > HOK-X ${x}`);
approx(x, moved('none'), 1e-9);
});
test('灵知 S1 高速思考: next attack hits twice for atk_scale arts', () => {
for (const id of pair('chess_char_4_13_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.run(0.5);
const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.equal(sk.length, 2, `${id}: two hits`);
assert.equal(sk[0].dmg.attackId, sk[1].dmg.attackId);
for (const c of sk) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * bb.atk_scale, 1e-6); }
}
});
test('灵知 S3 失温症: ASPD up, 2 targets (unfrozen first), freezes held until the end, then atk_scale arts on the frozen and thaw', () => {
for (const id of pair('chess_char_4_13_a')) {
const sk = D(id, 2).skill, bb = sk.bb;
const h = battle([U(id, 10, 4, 2)]);
h.step();
const u = h.unit(id);
const frozenOne = h.spawn('enemy_dummy', { pos: [10, 5] });
const others = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [9, 5] })];
h.b.applyStatus(frozenOne, 'freeze', { duration: 1 });
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.s.aspd, u.base.aspd + bb.attack_speed);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5));
const first = h.hooksOf('attack').find((c) => c.attacker === u);
assert.equal(first.targets.length, bb.max_target);
assert.ok(!first.targets.includes(frozenOne), 'unfrozen targets first');
h.run(2);
assert.ok(frozenOne.findBuff('freeze'), `${id}: freeze held past its own 1 s`);
approx(frozenOne.findBuff('freeze').timeLeft, u.skill.timeLeft, 0.1, 'until the skill ends');
const atkEnd = u.s.atk;
assert.ok(h.runUntil(() => !u.skill.active, 20));
const shatter = dmgBy(h, u, tagged('shatter'));
assert.ok(shatter.some((c) => c.target === frozenOne), 'end burst on the frozen enemy');
for (const c of shatter) assert.ok(c.amount >= atkEnd * bb.atk_scale - 1e-6, 'atk_scale arts (× fragile)');
for (const c of shatter) assert.ok(!c.target.findBuff('freeze'), 'thawed');
}
});
test('灵知 module UMD-Y 一号项目模型: +0.25 SP/s while an elite enemy is in range (default UMD-X: weaken on hit)', () => {
const rate = (m, rank) => {
const h = battle([U('chess_char_4_13_b', 10, 4, null, m)], { enemies: { enemy_r: dummy({ key: 'enemy_r', rank }) } });
h.step();
h.spawn('enemy_r', { pos: [10, 6] });
h.run(0.5);
return h.unit('chess_char_4_13_b').s.spRecovery;
};
const tb = C.chess_char_4_13_b.modules.find((m) => m.uniEquipId === 'uniequip_003_gnosis').traitOverride.bb;
approx(rate('uniequip_003_gnosis', 'ELITE'), 1 + tb.sp_recovery_per_sec, 1e-9);
approx(rate('uniequip_003_gnosis', 'NORMAL'), 1, 1e-9);
approx(rate(null, 'ELITE'), 1, 1e-9);
});
test('莱恩哈特 S1 攻击力强化·γ型: ATK up for 30 s; module none: no range bonus', () => {
for (const id of pair('chess_char_4_14_a')) {
const d = D(id, 0);
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + d.skill.bb.atk), 1e-6);
approx(u.skill.timeLeft, d.skill.duration, 1e-6);
}
const n = (m) => { const h = battle([U('chess_char_4_14_b', 10, 4, null, m)]); h.step(); return h.unit('chess_char_4_14_b').rangeKeys.length; };
assert.ok(n(null) > n('none'));
});
// ---------------------------------------------------------------------------------------------------------------
// 缄默德克萨斯 / 星熊 / 泥岩
test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits silence 5 s/8 s with a fixed arts DoT per second', () => {
for (const id of pair('chess_char_4_16_a')) {
const sk = D(id, 0).skill, bb = sk.bb;
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
const buff = u.findBuff('texas2:drizzle');
assert.ok(buff, `${id}: passive active on deployment`);
approx(buff.mods.atkPct, bb.atk, 1e-9);
assert.ok(u.findBuff('texas2:rainAtk'), 'talent 德克萨斯传统 ATK during the passive');
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(3);
assert.ok(e.s.flags.silence, 'hit target silenced (失去特殊能力)');
approx(e.findBuff('silence').timeLeft, bb['attack@silence'], 1.5);
const dot = dmgBy(h, u, tagged('dot'));
assert.ok(dot.length >= 2, `a DoT tick every second although she re-hits faster (${dot.length})`);
for (const c of dot) approx(c.amount, bb['attack@texas2_s_1[dot].dot_damage'], 1e-9, 'fixed arts per second (RES 0)');
h.run(sk.duration);
assert.ok(!u.findBuff('texas2:drizzle'), 'ends with the skill duration');
h.run(bb['attack@silence'] + 1);
assert.ok(!e.s.flags.silence && !e.findBuff(`texas2:drizzleDot:${u.id}`), 'no new silence / DoT after the passive');
}
});
test('缄默德克萨斯 S2: two copies cut the RES of an enemy around both once (同名效果取最高, DESIGN §20.10)', () => {
const id = 'chess_char_4_16_a', bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 4, 1), U(id, 11, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } });
h.step();
const two = h.b.allyUnits.filter((u) => u.def.id === id);
assert.equal(two.length, 2);
const e = h.spawn('enemy_res', { pos: [10, 5] });
for (const u of two) { h.b.retreat(u); assert.ok(h.b.redeploy(u)); }
assert.equal(dmgBy(h, two[0], tagged('burst')).length + dmgBy(h, two[1], tagged('burst')).length, 2, 'both bursts hit it');
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('texas2:resDown')).length, 1, 'one RES cut');
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'once, not twice');
});
test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down around her; ATK up; attacks become arts double hits', () => {
for (const id of pair('chess_char_4_16_a')) {
const sk = D(id, 1).skill, bb = sk.bb;
const h = battle([U(id, 10, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_res', { pos: [10, 5] });
const far = h.spawn('enemy_res', { pos: [10, 8] });
h.b.retreat(u);
assert.ok(h.b.redeploy(u));
const burst = dmgBy(h, u, tagged('burst'));
assert.deepEqual(burst.map((c) => c.target), [e], `${id}: only enemies around her`);
approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6, 'arts burst vs RES 50');
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'RES down');
assert.equal(far.s.res, 50);
approx(u.findBuff('texas2:shower').mods.atkPct, bb.atk, 1e-9);
approx(u.findBuff('texas2:shower').timeLeft, sk.duration, 1e-6);
h.run(2);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack);
assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), 'arts attacks');
const per = new Map();
for (const c of hits) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1);
assert.ok([...per.values()].every((v) => v === 2), 'double hits');
h.run(sk.duration);
const n0 = dmgBy(h, u, (c) => c.dmg.isAttack).length;
h.run(3);
const late = dmgBy(h, u, (c) => c.dmg.isAttack).slice(n0);
assert.ok(late.length >= 1 && late.every((c) => c.type === 'phys'), 'back to single phys hits after the duration');
}
});
test('缄默德克萨斯 module none: no ATK +10 % when alone (EXE-Y default has it)', () => {
const has = (m) => { const h = battle([U('chess_char_4_16_b', 10, 4, null, m)]); h.run(0.5); return !!h.unit('chess_char_4_16_b').findBuff('texas2:module'); };
assert.equal(has(null), true);
assert.equal(has('none'), false);
assert.equal(has('uniequip_003_texas2'), false);
});
test('星熊 S1 战意 (TAKE_DAMAGE: DEF/ATK up, no thorns) and S3 力之锯 (ATK/DEF up, cuts every enemy in front)', () => {
for (const id of pair('chess_char_4_17_a')) {
const b1 = D(id, 0).skill.bb, b3 = D(id, 2).skill.bb;
let h = battle([U(id, 10, 4, 0)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 1 }) } });
h.step();
let u = h.unit(id);
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
u.skill.gainSp(1000);
h.spawn('enemy_hitter', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.active, 5), `${id}: armed by a hit`);
approx(u.s.def, u.base.def * (1 + b1.def + (u.findBuff('hsguma:def') ? D(id, 0).talents[1].bb.def : 0)), 1e-6, 'DEF');
approx(u.s.atk, u.base.atk * (1 + b1.atk), 1e-6, 'ATK');
h.run(3);
assert.equal(dmgBy(h, u, tagged('counter')).length, 0, 'thorns belong to S2 only');
h = battle([U(id, 10, 4, 2)]);
h.step();
u = h.unit(id);
const es = [h.spawn('enemy_dummy', { pos: [10, 5] }), h.spawn('enemy_dummy', { pos: [10, 5] }), h.spawn('enemy_dummy', { pos: [10, 5] })];
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + b3.atk), 1e-6, 'S3 ATK');
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5));
assert.equal(h.hooksOf('attack').find((c) => c.attacker === u).targets.length, es.length, 'all enemies in front');
}
});
test('星熊 module PRO-X 护身符: DEF +20 % while blocking', () => {
const def = (m, block) => {
const h = battle([U('chess_char_4_17_b', 10, 4, null, m)]);
h.step();
const u = h.unit('chess_char_4_17_b');
if (block) h.spawn('enemy_dummy', { pos: [10, 4] });
h.run(0.5);
return u.s.def / u.base.def;
};
const tb = C.chess_char_4_17_b.modules.find((m) => m.uniEquipId === 'uniequip_003_hsguma').traitOverride.bb;
approx(def('uniequip_003_hsguma', true) - def('uniequip_003_hsguma', false), tb.def, 1e-9);
approx(def(null, true), def(null, false), 1e-9);
});
test('泥岩 S1 防御力强化·γ型 (TAKE_DAMAGE) and S3 秽壤的血脉 (dormant & invulnerable & blocking, slow; then stun, buffs, hits all blocked)', () => {
for (const id of pair('chess_char_4_18_a')) {
const b1 = D(id, 0).skill.bb, sk = D(id, 2).skill, b3 = sk.bb;
let h = battle([U(id, 10, 4, 0)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 1 }) } });
h.step();
let u = h.unit(id);
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
u.skill.gainSp(1000);
h.spawn('enemy_hitter', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.def, u.base.def * (1 + b1.def), 1e-6);
h = battle([U(id, 9, 5, 2)], { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e8, atk: 500, speed: 1, bat: 1 }) } });
for (let i = 0; i < 3; i++) h.spawn('e_walk', { routeIndex: 0 });
u = h.unit(id);
assert.ok(h.runUntil(() => u.blocking.length === 3, 30), 'blocks three walkers');
const hp0 = u.hp;
assert.ok(u.skill.activate('test', { free: true }));
const t0 = h.b.time;
h.run(b3.sleep - 1);
assert.equal(u.hp >= hp0 - 1e-6, true, `${id}: no damage while dormant`);
assert.equal(u.blocking.length, 3, 'keeps blocking');
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length, 0, 'cannot act');
assert.ok(u.blocking.every((e) => e.findBuff(`mudrok:slow:${u.id}`)), 'enemies around slowed');
h.run(1.2);
assert.ok(u.findBuff('mudrok:awake'), 'awake');
for (const e of u.blocking) assert.ok(e.s.flags.stun, 'ground enemies around stunned');
approx(u.s.atk, u.base.atk * (1 + b3.atk) * (u.findBuff('mudrok:module') ? 1 + (u.def.traitBb.atk ?? 0) : 1), 1e-6, 'ATK');
approx(u.s.interval, (u.base.bat + b3.base_attack_time) * 100 / u.s.aspd, 1e-6, 'BAT −0.3 s');
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.t > t0), 5));
assert.equal(h.hooksOf('attack').find((c) => c.attacker === u && c.t > t0).targets.length, 3, 'attacks every blocked enemy');
h.runUntil(() => !u.skill.active, sk.duration);
assert.ok(!u.findBuff('mudrok:awake'));
checkInvariants(h.b);
}
});
test('泥岩 module UNY-Y 沃土的愿景: ATK/DEF +8 % with no ally on the 8 tiles around', () => {
const buffed = (m, near) => {
const h = battle([U('chess_char_4_18_b', 10, 4, null, m), ...(near ? [U('chess_char_1_01_a', 11, 5)] : [])]);
h.run(0.5);
return !!h.unit('chess_char_4_18_b').findBuff('mudrok:module');
};
assert.deepEqual([buffed('uniequip_003_mudrok', false), buffed('uniequip_003_mudrok', true), buffed(null, false)], [true, false, false]);
});
// ---------------------------------------------------------------------------------------------------------------
// 焰尾 / 远牙 / 白面鸮 / 银灰
test('焰尾 S1 迅敏直觉: +6 DP and the next physical attack on her is dodged (feeding 前锋剑术)', () => {
for (const id of pair('chess_char_4_19_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 }, enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 1 }) } });
h.step();
const u = h.unit(id);
const dp0 = h.b.getPlayer('p1').dp;
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(h.b.getPlayer('p1').dp, dp0 + bb.cost, `${id}: +${bb.cost} DP`);
assert.ok(u.findBuff('flamtl:evade'));
h.spawn('enemy_hitter', { pos: [10, 4] });
assert.ok(h.runUntil(() => h.hooksOf('dodge').some((c) => c.target === u), 5), 'dodged');
assert.ok(!u.findBuff('flamtl:evade'), 'only the next attack');
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source?.side === 'enemy').length, 0, 'no damage from the dodged hit');
assert.ok(u.mem.riposte || u.mem.riposteNow || dmgBy(h, u, tagged('riposte')).length > 0, '前锋剑术 armed');
}
});
test('焰尾 S1 迅敏直觉 (AUTO): fires as soon as SP is full — no enemy, no attack needed (like 伺夜 S1; playtest #6 review)', () => {
for (const id of pair('chess_char_4_19_a')) {
const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 } });
h.step();
const u = h.unit(id);
assert.equal(u.skill.rule, 'SP_FULL');
const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery;
assert.ok(h.runUntil(() => u.skill.activations === 1, 40), `${id}: cast with no enemy on the field`);
const st = h.hooksOf('skillStart').find((c) => c.unit === u);
assert.ok(Math.abs(st.t - due) <= 0.05, `${id}: at full SP (${st.t} vs ${due})`);
}
});
test('焰尾 S2 “红松林”: +11/12 DP; ≤ 6 enemies around: 2 × atk_scale phys + stun; allies around +40 %/45 % phys dodge 10 s', () => {
for (const id of pair('chess_char_4_19_a')) {
const bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 5, 1), U('chess_char_1_01_a', 10, 4)], { flags: { dpPerSec: 0 } });
h.step();
const u = h.unit(id), ally = h.unit('chess_char_1_01_a');
for (let i = 0; i < 7; i++) h.spawn('enemy_dummy', { pos: [10, 6] });
const dp0 = h.b.getPlayer('p1').dp;
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(h.b.getPlayer('p1').dp, dp0 + bb.cost);
const hits = dmgBy(h, u, tagged('redPine'));
const victims = new Set(hits.map((c) => c.target));
assert.equal(victims.size, bb.max_target, `${id}: ≤ ${bb.max_target} enemies`);
assert.equal(hits.length, 2 * bb.max_target, 'two hits each');
for (const c of hits) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6);
for (const e of victims) assert.ok(e.s.flags.stun);
for (const a of [u, ally]) {
const b = a.findBuff('flamtl:redPine');
assert.ok(b, `${a.name} dodge buff`);
approx(b.mods.dodgePhys, bb['flamtl_s_2.prob'], 1e-9);
approx(b.timeLeft, bb['flamtl_s_2.duration'], 1e-6);
}
}
});
test('焰尾 module SOL-X 她们的未来: ATK/DEF +8 % while blocking', () => {
const on = (m, block) => {
const h = battle([U('chess_char_4_19_b', 10, 4, null, m)]);
h.step();
if (block) h.spawn('enemy_dummy', { pos: [10, 4] });
h.run(0.5);
return !!h.unit('chess_char_4_19_b').findBuff('flamtl:module');
};
assert.deepEqual([on('uniequip_003_flamtl', true), on('uniequip_003_flamtl', false), on(null, true)], [true, false, false]);
});
test('远牙 S1 迅捷打击·γ型 / S2 同盟支援: taunt −1 with any skill (屏息); S2 reaches enemies blocked anywhere on the field', () => {
for (const id of pair('chess_char_4_20_a')) {
const b1 = D(id, 0).skill.bb, b2 = D(id, 1).skill.bb;
let h = battle([U(id, 12, 3, 0)]);
h.step();
let u = h.unit(id);
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + b1.atk + (u.findBuff('fartth:focus') ? D(id, 0).talents[0].bb.atk : 0)), 1e-6, 'S1 ATK (+ 凝神)');
assert.equal(u.s.aspd, u.base.aspd + b1.attack_speed);
assert.equal(u.s.taunt, -1, '屏息');
h = battle([U(id, 12, 3, 1), U('chess_char_4_17_a', 9, 9)], { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e8, atk: 10, speed: 1, bat: 5 }) } });
u = h.unit(id);
h.spawn('e_walk', { routeIndex: 0 });
const e = h.enemies()[0];
assert.ok(h.runUntil(() => e.blockedBy && e.blockedBy.defId === 'chess_char_4_17_a', 20), 'blocked by 星熊 far away');
h.run(2);
assert.equal(dmgBy(h, u, (c) => c.target === e).length, 0, 'out of her range without S2');
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.s.aspd, u.base.aspd + b2.attack_speed);
assert.equal(u.s.taunt, -1);
h.run(4);
assert.ok(dmgBy(h, u, (c) => c.target === e).length > 0, `${id}: S2 hits the blocked enemy`);
h.runUntil(() => !u.skill.active, 40);
assert.equal(u.extraRangeKeys, null, 'extra range cleared');
}
});
test('远牙 S2 同盟支援: an UNBLOCKED enemy on a blocked enemy\'s tile outside her range is never a target', () => {
for (const id of pair('chess_char_4_20_a')) {
const h = battle([U(id, 12, 3, 1), U('chess_char_4_17_a', 9, 9)], { enemies: {
e_walk: enemyRec({ key: 'e_walk', hp: 1e8, atk: 10, def: 800, speed: 1, bat: 5 }),
e_fly: dummy({ key: 'e_fly', motion: 'FLY', def: 0 }),
} });
const u = h.unit(id);
h.spawn('e_walk', { routeIndex: 0 });
const e = h.enemies()[0];
assert.ok(h.runUntil(() => e.blockedBy && e.blockedBy.defId === 'chess_char_4_17_a', 20), 'blocked by 星熊 far away');
const fly = h.spawn('e_fly', { pos: [Math.round(e.y), Math.round(e.x)] });
assert.ok(fly.isFlying && !fly.blockedBy, 'setup: unblocked flyer (lower DEF: her lowDef priority would pick it)');
assert.ok(u.skill.activate('test', { free: true }));
h.run(4);
assert.ok(dmgBy(h, u, (c) => c.target === e).length > 0, `${id}: S2 hits the blocked enemy`);
assert.equal(dmgBy(h, u, (c) => c.target === fly).length, 0, `${id}: not the unblocked flyer on its tile`);
checkInvariants(h.b);
}
});
test('远牙 module DEA-Y 支持者来信: +1 SP whenever the attacked enemy survives', () => {
const gains = (m) => {
const h = battle([U('chess_char_4_20_b', 10, 3, null, m)]);
h.step();
h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(8);
return h.hooksOf('spGain').filter((c) => c.reason === 'module').length;
};
assert.ok(gains('uniequip_003_fartth') >= 2);
assert.equal(gains(null), 0);
});
test('远牙 module DEA-Y: the hit that defeats its target gives no SP (未被击倒时 only; `damaged` precedes the kill)', () => {
const id = 'chess_char_4_20_b';
const h = battle([U(id, 10, 3, null, 'uniequip_003_fartth')], { enemies: { enemy_weak: dummy({ key: 'enemy_weak', hp: 1 }) } });
h.step();
const u = h.unit(id);
const weak = [h.spawn('enemy_weak', { pos: [10, 5] }), h.spawn('enemy_weak', { pos: [10, 6] })];
assert.ok(h.runUntil(() => weak.every((e) => !e.alive), 10), 'both one-HP enemies fall');
assert.ok(dmgBy(h, u, (c) => c.dmg?.isAttack && weak.includes(c.target)).length >= 2, 'killed by her attacks');
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module').length, 0, 'no SP for a killing hit');
checkInvariants(h.b);
});
test('白面鸮 S1 治疗强化·γ型: ATK up while healing; module none: no range bonus', () => {
for (const id of pair('chess_char_4_21_a')) {
const d = D(id, 0);
const h = battle([U(id, 10, 3, 0), U('chess_char_4_17_a', 10, 4)]);
h.step();
const u = h.unit(id), hs = h.unit('chess_char_4_17_a');
hs.hp = hs.s.maxHp * 0.3;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5), 'heal skill fires on an injured ally');
approx(u.s.atk, u.base.atk * (1 + d.skill.bb.atk), 1e-6);
}
const n = (m) => { const h = battle([U('chess_char_4_21_b', 10, 3, null, m)]); h.step(); return h.unit('chess_char_4_21_b').rangeKeys.length; };
assert.ok(n(null) > n('none'));
});
test('银灰 S1 强力击·γ型: every 4th attack deals atk_scale; S2 雪境生存法则: toggled on for good — small range, DEF up, HP regen', () => {
for (const id of pair('chess_char_4_22_a')) {
const b1 = D(id, 0).skill.bb, s2 = D(id, 1).skill;
let h = battle([U(id, 10, 4, 0, gold(id) === id ? 'none' : null)]);
h.step();
let u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(12);
const atks = dmgBy(h, u, (c) => c.dmg.isAttack);
const sk = atks.filter((c) => c.dmg.isSkill);
assert.ok(sk.length >= 2, `${id}: S1 fired`);
for (const c of sk) approx(c.amount, u.s.atk * b1.atk_scale, 1e-6);
for (const c of atks.filter((x) => !x.dmg.isSkill)) approx(c.amount, u.s.atk, 1e-6);
const i1 = atks.indexOf(sk[0]), i2 = atks.indexOf(sk[1]);
assert.equal(i2 - i1, D(id, 0).skill.spCost + 1, 'cost-3 attack SP: every 4th attack');
h = battle([U(id, 10, 4, 1)]);
h.step();
u = h.unit(id);
const n0 = u.rangeKeys.length;
h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.kind, 'toggle');
assert.equal(u.rangeKeys.length, s2.rangeGrid.length, 'smaller range');
assert.ok(n0 > u.rangeKeys.length);
approx(u.s.def, u.base.def * (1 + s2.bb.def), 1e-6);
approx(u.s.hpRegen, u.s.maxHp * s2.bb.hp_recovery_per_sec_by_max_hp_ratio, 1e-6);
h.run(60);
assert.ok(u.skill.active, 'stays on');
}
});
test('银灰 module none: no 10 % ATK arts rider on attacks (LOR-X default has it)', () => {
const rider = (m) => {
const h = battle([U('chess_char_4_22_b', 10, 4, 1, m)]);
h.step();
h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(4);
return dmgBy(h, h.unit('chess_char_4_22_b'), tagged('module')).length;
};
assert.ok(rider(null) > 0);
assert.equal(rider('none'), 0);
});
// ---------------------------------------------------------------------------------------------------------------
// 百炼嘉维尔 / 卡涅利安 / 魔王 / 华法琳
test('百炼嘉维尔 S1 精准痛击: ATK up, heals herself heal_scale of the damage dealt (× 医学背景); no S3 damage deferral', () => {
for (const id of pair('chess_char_4_23_a')) {
const d = D(id, 0), bb = d.skill.bb, t1 = d.talents[1].bb;
const h = battle([U(id, 10, 4, 0)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 400, bat: 1 }) } });
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => e.blockedBy === u, 2));
assert.ok(u.skill.activate('test', { free: true }));
u.hp = u.s.maxHp * 0.3;
const hp0 = u.hp, t0 = h.b.time;
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.t >= t0), 5));
const dealt = dmgBy(h, u, (c) => c.dmg.isAttack && c.t >= t0).reduce((a, c) => a + c.amount, 0);
assert.ok(dealt > 0);
approx(u.hp - hp0, dealt * bb.heal_scale * t1.heal_scale_2, 1e-6, `${id}: self heal`);
const hit = h.spawn('enemy_hitter', { pos: [10, 4] });
h.run(2);
assert.equal(u.mem.gvialDebt ?? 0, 0, 'no S3 deferral');
assert.ok(hit.alive);
}
});
test('百炼嘉维尔 S2 链锯强袭: wider range, ATK/DEF up, unblocked enemies it hits are pulled to her front', () => {
for (const id of pair('chess_char_4_23_a')) {
const bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 4, 1)]);
h.step();
const u = h.unit(id);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(0.2);
assert.equal(dmgBy(h, u).length, 0, 'out of the normal range');
assert.ok(u.skill.activate('test', { free: true }));
approx(u.s.atk, u.base.atk * (1 + bb.atk + D(id, 1).talents[0].bb.atk), 1e-6, 'ATK (+ 战地巨斧)');
assert.ok(h.runUntil(() => dmgBy(h, u).length > 0, 5), `${id}: reaches 2 tiles ahead`);
assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`);
}
});
test('百炼嘉维尔 module CEN-Y 好锯多磨: −20 % physical damage taken above 50 % HP', () => {
const taken = (m, ratio) => {
const h = battle([U('chess_char_4_23_b', 10, 4, 0, m)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 3000, bat: 1 }) } });
h.step();
const u = h.unit('chess_char_4_23_b');
u.hp = u.s.maxHp * ratio;
h.spawn('enemy_hitter', { pos: [10, 4] });
h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === u && c.amount > 0), 5);
return h.hooksOf('damaged').find((c) => c.target === u && c.amount > 0).amount;
};
const tb = C.chess_char_4_23_b.modules.find((m) => m.uniEquipId === 'uniequip_003_gvial2').traitOverride.bb;
approx(taken('uniequip_003_gvial2', 0.9) / taken('uniequip_003_gvial2', 0.4), 1 - tb.damage_resistance, 1e-6, 'reduced above half HP');
approx(taken(null, 0.9), taken(null, 0.4), 1e-6, 'default CEN-X: no reduction');
});
test('卡涅利安 S1 沙暴守卫: SEARCH; ATK/DEF up; charged keeps the trait guard; 生命之餐 heals 40 %/80 %', () => {
for (const id of pair('chess_char_4_24_a')) {
const d = D(id, 0), bb = d.skill.bb, t0 = d.talents[0].bb;
for (const charged of [false, true]) {
const h = battle([U(id, 10, 4, 0, gold(id) === id ? 'none' : null)]);
h.step();
const u = h.unit(id);
assert.equal(u.skill.rule, 'SEARCH');
u.hp = u.s.maxHp * 0.1;
u.skill.addCharge(charged ? 2 : 1);
assert.ok(u.skill.activate('test'));
assert.equal(!!u.findBuff('billro:s1guard'), charged, `${id}: guard kept iff charged`);
const guard = charged ? u.profile.guardDef : 0;
approx(u.s.def, u.base.def * (1 + bb.def + guard), 1e-6, 'DEF');
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, 'ATK');
approx(u.hp, u.s.maxHp * (0.1 + (charged ? t0['billro_t_1[enhance].heal_scale'] : t0.heal_scale)), 1e-6, '生命之餐');
h.runUntil(() => !u.skill.active, 30);
assert.ok(!u.findBuff('billro:s1guard'));
}
}
});
test('卡涅利安 S3 食噬之印: wider range, ATK ramps to +atk over the skill; charged hits mark (+20 %/mark, ≤ 5) until it ends', () => {
for (const id of pair('chess_char_4_24_a')) {
const sk = D(id, 2).skill, bb = sk.bb;
const h = battle([U(id, 10, 4, 2, gold(id) === id ? 'none' : null)]);
h.step();
const u = h.unit(id);
const n0 = u.rangeKeys.length;
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.addCharge(2);
assert.ok(u.skill.activate('test'));
u.skill.charges = 0; // (the spare charge would re-cast right after the end)
u.skill.sp = 0;
assert.ok(u.rangeKeys.length > n0);
h.run(sk.duration / 2);
approx(u.findBuff('billro:s3atk').mods.atkPct, bb.atk * 0.5, 0.02, `${id}: half-way ramp`);
const m = e.findBuff('billro:mark');
assert.ok(m && m.stacks === 5 && m.source === u, `${id}: marks capped at 5 (${m?.stacks})`);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e);
const last = hits[hits.length - 1];
approx(last.amount / (u.s.atk), 1 + bb['attack@damage_scale'] * 5, 0.05, '+100 % with 5 marks');
h.runUntil(() => !u.skill.active, sk.duration);
assert.ok(!e.findBuff('billro:mark'), 'marks end with the skill');
assert.ok(!u.findBuff('billro:s3atk'));
}
});
test('卡涅利安 module PLX-Y 乡音: +3 % damage per enemy in range (≤ 5); PLX-X keeps part of the guard during any skill', () => {
const ratio = (m) => {
const h = battle([U('chess_char_4_24_b', 10, 4, 1, m)]);
h.step();
const u = h.unit('chess_char_4_24_b');
for (let i = 0; i < 3; i++) h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.addCharge(1);
assert.ok(u.skill.activate('test'));
h.runUntil(() => dmgBy(h, u, (c) => c.dmg.isAttack).length > 0, 5);
const c = dmgBy(h, u, (c) => c.dmg.isAttack)[0];
return { r: c.amount / u.s.atk, keep: !!u.findBuff('billro:keep') };
};
const mb = C.chess_char_4_24_b.modules.find((m) => m.uniEquipId === 'uniequip_003_billro').talentChanges[0].bb;
const y = ratio('uniequip_003_billro'), x = ratio(null), none = ratio('none');
approx(y.r, 1 + mb.damage_scale * 3, 1e-6, 'three enemies');
approx(none.r, 1, 1e-6);
assert.deepEqual([x.keep, y.keep, none.keep], [true, false, false]);
const s1keep = (charges) => { const h = battle([U('chess_char_4_24_b', 10, 4, 0)]); h.step(); const u = h.unit('chess_char_4_24_b'); u.skill.addCharge(charges); u.skill.activate('test'); return !!u.findBuff('billro:keep'); };
assert.equal(s1keep(1), true, 'uncharged S1 keeps the PLX-X part');
assert.equal(s1keep(2), false, 'charged S1 keeps the whole guard instead');
});
test('魔王 S1 往昔萦绕身旁: SP_FULL toggle — trait ratio raised for good, motes come back after talent_cool_down', () => {
for (const id of pair('chess_char_4_25_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0), U('chess_char_4_17_a', 10, 5)]);
h.step();
const u = h.unit(id), hs = h.unit('chess_char_4_17_a');
assert.equal(u.skill.rule, 'SP_FULL');
u.skill.gainSp(1000);
h.step();
assert.ok(u.skill.active && u.skill.kind === 'toggle');
assert.equal(u.profile.auraRatio, bb['attack@atk_to_hp_recovery_ratio']);
hs.hp = hs.s.maxHp * 0.5;
h.run(0.5);
const t = Math.max(...u.mem.motes.filter((x) => Number.isFinite(x)));
assert.ok(Number.isFinite(t), 'a mote touched the operator');
assert.ok(t - h.b.time <= bb.talent_cool_down + 1e-6, `${id}: faster mote respawn`);
h.run(40);
assert.ok(u.skill.active, 'duration unlimited');
}
});
test('魔王 S2 明日渺远不及: 6 motes that hit enemies within 2 tiles (true damage + bind), not operators; 鼓舞 ATK to allies in range', () => {
for (const id of pair('chess_char_4_25_a')) {
const bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 4, 1), U('chess_char_4_17_a', 10, 5)]);
h.step();
const u = h.unit(id), hs = h.unit('chess_char_4_17_a');
const es = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [11, 5] })];
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.mem.motes.length, D(id, 1).talents[0].bb.cnt + 3, '上限+3');
h.run(1);
const hits = dmgBy(h, u, tagged('mote'));
assert.ok(es.every((e) => hits.some((c) => c.target === e)), `${id}: both enemies hit`);
for (const c of hits) { assert.equal(c.type, 'true'); approx(c.amount, u.s.atk * bb.atk_scale, 1e-6); }
for (const e of es) assert.ok(e.s.flags.bind, 'bound');
assert.ok(!hs.findBuff(`cetsyr:mote:${u.id}`), 'motes no longer touch operators');
const insp = hs.findBuff('inspire');
assert.ok(insp, '鼓舞');
approx(insp.data.val, u.s.atk * bb['attack@atk'], 1e-6);
// 鼓舞 is added on top of the target's ATK multipliers: an ATK +100 % buff does not double it
h.b.addBuff(hs, { key: 'test:atkUp', mods: { atkPct: 1 } });
h.step();
const { add, mul } = aggregateMods(hs.buffs.filter((x) => x.key !== 'inspire'));
const own = (hs.base.atk + (add.atkFlat ?? 0)) * (1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1);
approx(hs.s.atk, own + u.s.atk * bb['attack@atk'], 1e-6, `${id}: final ATK = own ATK + 鼓舞`);
h.b.removeBuff(hs, 'test:atkUp');
h.runUntil(() => !u.skill.active, 40);
assert.equal(u.mem.motes.length, D(id, 1).talents[0].bb.cnt, 'cap back to normal');
}
});
test('魔王 鼓舞: strongest source wins (never summed), a bard never receives it (自身不受鼓舞影响); S3 HP 鼓舞 on top of HP%', () => {
const [na, el] = pair('chess_char_4_25_a');
const h = battle([U(na, 10, 4, 1), U(el, 10, 5, 1), U('chess_char_4_17_a', 11, 4)]);
h.step();
const a = h.unit(na), b = h.unit(el), hs = h.unit('chess_char_4_17_a');
assert.ok(a.skill.activate('test', { free: true }) && b.skill.activate('test', { free: true }));
h.run(0.5);
assert.ok(!a.findBuff('inspire') && !b.findBuff('inspire'), 'bards get no 鼓舞 from each other');
const inRangeOfBoth = h.b.alliesInGrid(a).includes(hs) && h.b.alliesInGrid(b).includes(hs);
assert.ok(inRangeOfBoth, 'setup: 星熊 inside both ranges');
const va = a.s.atk * D(na, 1).skill.bb['attack@atk'], vb = b.s.atk * D(el, 1).skill.bb['attack@atk'];
assert.equal(hs.buffs.filter((x) => x.key === 'inspire' || x.key.startsWith('cetsyr:inspire')).length, 1, 'one 鼓舞 buff');
approx(hs.findBuff('inspire').data.val, Math.max(va, vb), 1e-6, 'the stronger one');
// S3 (default): max-HP 鼓舞 is not scaled by the target's HP% buffs
const h3 = battle([U(el, 10, 4), U('chess_char_4_17_a', 10, 5)]);
h3.step();
const m = h3.unit(el), t = h3.unit('chess_char_4_17_a');
h3.b.addBuff(t, { key: 'test:hpUp', mods: { hpPct: 0.5 } });
assert.ok(m.skill.activate('test', { free: true }));
h3.run(0.3);
const { add, mul } = aggregateMods(t.buffs.filter((x) => x.key !== 'inspire:hp'));
approx(t.s.maxHp, (t.base.maxHp + (add.hpFlat ?? 0)) * (1 + (add.hpPct ?? 0)) * (mul.hpMul ?? 1) + m.s.maxHp * D(el).skill.bb.max_hp, 1e-6, 'max HP + 鼓舞');
checkInvariants(h.b);
});
test('华法琳 S2 不稳定血浆: she and one random ally in range: ATK up, 3 % max HP lost per second for 15 s; no S1 bandage', () => {
for (const id of pair('chess_char_4_26_a')) {
const bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 3, 1), U('chess_char_4_17_a', 10, 4)]);
h.step();
const u = h.unit(id), hs = h.unit('chess_char_4_17_a');
hs.hp = hs.s.maxHp * 0.4;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'fires on an injured ally in range');
for (const a of [u, hs]) {
const b = a.findBuff('bldsk:plasma');
assert.ok(b, `${a.name}: plasma`);
approx(b.mods.atkPct, bb.atk, 1e-9);
}
h.run(3.05);
const loss = h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('plasma'));
assert.equal(loss.length, 3, `${id}: one loss per second`);
for (const c of loss) approx(c.amount, u.s.maxHp * bb.hp_ratio, 1e-6);
assert.equal(u.mem.bandage ?? null, null, 'S1 logic not installed');
}
});
test('module none on single-module elites removes the module effect (寒芒克洛丝 / 阿罗玛 / 魔王 / 华法琳)', () => {
const unit = (id, m, extra = []) => { const h = battle([U(id, 10, 3, null, m), ...extra]); h.run(0.5); return { h, u: h.unit(id) }; };
// 寒芒克洛丝 MAR-X: 攻击空中单位时攻击力提升至110%
assert.equal(unit('chess_char_4_06_b', null).u.profile.flyScale, C.chess_char_4_06_b.trait.bb.atk_scale);
assert.equal(unit('chess_char_4_06_b', 'none').u.profile.flyScale, 1);
// 阿罗玛 BLA-X: farther targets take more damage
const blast = (m) => {
const { h, u } = unit('chess_char_4_10_b', m);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(6);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e).map((c) => c.amount / u.s.atk);
return Math.min(...hits);
};
assert.ok(blast(null) > blast('none') + 0.05, 'distance bonus only with the module');
approx(blast('none'), 1, 1e-6);
// 魔王 BAR-X: ATK +8 % with ≥ 2 other operators in range
const ops = [U('chess_char_4_17_a', 10, 4), U('chess_char_1_01_a', 11, 3)];
assert.ok(unit('chess_char_4_25_b', null, ops).u.findBuff('cetsyr:module'));
assert.ok(!unit('chess_char_4_25_b', 'none', ops).u.findBuff('cetsyr:module'));
// 华法琳 PHY-X: heals on allies below 50 % HP +15 %
const heal = (m) => {
const { h, u } = unit('chess_char_4_26_b', m, [U('chess_char_4_17_a', 10, 4)]);
const hs = h.unit('chess_char_4_17_a');
hs.hp = hs.s.maxHp * 0.2;
u.skill.charges = 0; u.skill.sp = 0;
const n0 = h.hooksOf('heal').length;
h.runUntil(() => h.hooksOf('heal').slice(n0).some((c) => c.source === u && c.target === hs), 5);
return h.hooksOf('heal').slice(n0).find((c) => c.source === u && c.target === hs).amount / u.s.atk;
};
approx(heal(null) / heal('none'), C.chess_char_4_26_b.trait.bb.heal_scale, 1e-6);
});