Files
ark-stronghold-protocol/test/content/op_kalts2.test.js
T
sganggs 0451860cf9 feedback5 O24: 自选 kits for 凯尔希·思衡托 (with her 战术锚点) and Mon3tr (with her 重构体)
Two owned 6★ picks get their operator kits (kits/ops/op-kalts2.js, op-monstr.js; OPERATOR_KIT_FILES):
- 凯尔希·思衡托 (char_1052_kalts2, 守望者, no module): heals that pass 凯尔希's Mon3tr; 遗尘守望 (HP / DEF +25 %,
  block +1, 阻挡半径倍率 +0.23, 起飞); 医者丰碑 (operators entering her range: a 1-hit 护盾 and 生命回复速度 +50 / 罗德岛
  +100 for 30 s); S1 (ATK / ASPD, other 起飞 operators' 阻挡半径倍率); S2 (y-11, 10 shots: true damage + 停顿 and a
  heal within 1.5, at an enemy or an injured ally); S3 (ATK, interval −1.55, 2 targets; with her placed anchor: two
  operators withdrawn into 【待投放】 and back, her flight and 【移动】 onto the anchor). The anchor's kit (无敌, never
  attacked); content/tokens.js docks it as a skill's summon.
- Mon3tr (char_4179_monstr, 链愈师, XAH-X): her own chain (x-4 jumps, ×0.75 / ×0.85, free links), the 重构体's kit
  (禁疗 but hers, 流失, ATK aura, its return), 战术协同 ASPD, S1 next heal, S2 (the 重构体 first, a second chain
  0.6 s on), S3 (needs the 重构体: the move onto its tile, true melee, 不死, the 【返回】).
Engine (additive): mod `blockRadiusScale` = 阻挡半径倍率 − 1 (buffs.js, units.js s.blockRadiusScale): the air-block
radius of Battle._checkBlock is 0.8944 × (1 + it) — 0 for every other unit. docs/SIM.md §1.2 / §2 / §3, DESIGN §5.5.
Tests: test/content/op_kalts2.test.js (10), test/content/op_monstr.test.js (10).
Golden: only diy moved — diy-110 … diy-112 are new and diy-106 … diy-109 re-packed (the two kits' configurations
join the family's 12-per-battle packing); roster, bonds, fields, matches and standins are byte-identical.
2026-10-06 05:10:33 +08:00

361 lines
24 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.
// test/content/op_kalts2.test.js — the 自选 operator kit of 凯尔希·思衡托 (char_1052_kalts2, 6★ 守望者; kit
// server/sim/content/kits/ops/op-kalts2.js) and her summon 战术锚点 (token_10068_kalts2_mtship), fielded the production way (a
// DIY slot + its `diy` pick; the anchor as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4)
// and elite (E2 Lv60, rank 7) — she has no module. Every number is read back from data/backups.json (the form of that slot
// status, the token's variant); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_kalts2.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
import { COLS } from '../../server/sim/constants.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const KALTS2 = 'char_1052_kalts2';
const ANCHOR = 'token_10068_kalts2_mtship';
const FORMS = BACKUPS.units[KALTS2].forms;
const TOKEN = BACKUPS.tokens[ANCHOR];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const S1 = 'skchr_kalts2_1', S2 = 'skchr_kalts2_2', S3 = 'skchr_kalts2_3';
const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusKey(tier, elite)];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const talentOf = (tier, elite, i) => formOf(tier, elite).talents.find((t) => t.index === i).bb;
const variantOf = (tier, elite) => TOKEN.variants[`${KALTS2}@${statusKey(tier, elite)}`];
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_biter: dummy('enemy_biter', { atk: 400, range: 1.2, bat: 1 }),
enemy_flybiter: dummy('enemy_flybiter', { atk: 300, range: 1.6, bat: 1, motion: 'FLY' }),
};
/** Every 自选 form: [tier, elite] (no module). */
const FORMS_ALL = [[5, false], [6, false], [5, true], [6, true]];
const label = ([tier, elite]) => `T${tier} ${elite ? 'elite' : 'normal'}`;
const YAK = 'chess_char_1_02_a', TEXAS = 'chess_char_1_08_a', PERFUMER = 'chess_char_2_14_a';
/** A battle with 凯尔希·思衡托 as uid 1 at (10, 4) facing RIGHT (`anchor`: her piece as uid 2), plus `others`. */
function field({ tier = 5, elite = false, skill = 0, anchor = null, others = [], seed = 5, dp = 99 } = {}) {
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: KALTS2, skillIndex: skill }, elite, row: 10, col: 4 }];
if (anchor) units.push({ uid: 2, kind: 'token', tokenId: ANCHOR, ownerUid: 1, row: anchor.row, col: anchor.col, dir: anchor.dir ?? 'RIGHT' });
const log = []; // heal / damaged in the order they happen
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 },
hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'blocked'], captureNoisy: true,
units: [...units, ...others],
setup: (b) => {
b.on('heal', (c) => { if (!c.opts?.regen) log.push({ name: 'heal', ...c }); }, { priority: -1000 });
b.on('damaged', (c) => log.push({ name: 'damaged', ...c }), { priority: -1000 });
},
});
h.step();
return { h, u: h.unit(1), a: anchor ? h.unit(2) : null, log };
}
const heals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.regen);
const inRange = (u, x) => u.rangeKeySet.has(x.tileR * COLS + x.tileC);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('凯尔希·思衡托 in every 自选 form: her operator kit (all three skills authored), the form\'s stats with 遗尘守望, y-6, one heal per attack, blocks 2, 起飞, 罗德岛, no module, no 特质; her 战术锚点: the form\'s token stats', () => {
assert.equal(OPERATOR_KITS[KALTS2], KITS[KALTS2]);
for (const f of FORMS_ALL) {
const [tier, elite] = f;
for (const skill of [0, 1, 2]) {
const { h, u, a } = field({ tier, elite, skill, anchor: { row: 10, col: 9 } });
const form = formOf(tier, elite), t0 = talentOf(tier, elite, 0);
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [KALTS2, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.bat, u.base.cost], [form.stats.maxHp, form.stats.atk, form.stats.def, 2.85, 19], `${label(f)}: stats`);
approx(u.s.maxHp, form.stats.maxHp * (1 + t0.max_hp), `${label(f)}: 生命上限 +25 %`);
approx(u.s.def, form.stats.def * (1 + t0.def), `${label(f)}: 防御力 +25 %`);
assert.deepEqual([u.s.blockCnt, u.s.blockRadiusScale, t0.block_cnt, t0.block_radius_scale], [2, 0.23, 1, 0.23], `${label(f)}: 阻挡数 +1, 阻挡半径倍率 +0.23`);
assert.deepEqual([u.profile.dmgType, u.profile.heal, u.profile.attack, u.def.subProf], ['heal', { mode: 'single' }, 'ranged', 'watchman'], `${label(f)}: 守望者 heal`);
assert.deepEqual([u.s.flags.liftoff, u.s.flags.blockFly], [true, true], `${label(f)}: 起飞`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-6`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens, u.def.raw.nationId, !!u.def.raw.module?.active, form.modules?.length ?? 0],
[BACKUPS.diy.operators[KALTS2].bonds, [], [ANCHOR], 'rhodes', false, 0], `${label(f)}: bonds / 特质 / summon / no module`);
const ts = variantOf(tier, elite).stats;
assert.deepEqual([a.base.maxHp, a.base.atk, a.base.cost, a.base.respawnTime, a.s.blockCnt], [ts.maxHp, 0, 3, 70, 0], `${label(f)}: 战术锚点 stats`);
assert.deepEqual([a.s.flags.invulnerable, a.s.flags.untargetable, a.profile.noAttack, !!a.kit.generic], [true, true, true, false], `${label(f)}: 战术锚点 无敌, never targeted, no attack`);
assert.equal(a.alive, skill === 2, `${label(f)}: the anchor piece takes the field only with S3 (a skill's summon)`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1393 / 408 / 220, E2 Lv60 1543 / 476 / 257; the anchor 2235 HP, 3 DP, 70 s
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/3'].stats.atk], [1393, 408, 1543, 476]);
assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(6, true).stats.cost, TOKEN.placeable], [2235, 3, true]);
});
test('a 自选 pick: 凯尔希·思衡托 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(KALTS2));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(KALTS2), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: KALTS2, skillIndex: 2 } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: KALTS2, skillIndex: 2, uniEquipId: null } } });
});
test('trait: her heals take 凯尔希\'s Mon3tr through its 禁疗 (her selection too); another 禁疗 summon stays out', () => {
const others = [{ uid: 3, diy: { slot: 6, charId: 'char_003_kalts', skillIndex: 0 }, row: 12, col: 2 },
{ uid: 4, kind: 'token', tokenId: 'token_10002_kalts_mon3tr', ownerUid: 3, row: 10, col: 5 }];
const { h, u } = field({ skill: 0, others });
const m = h.unit(4);
assert.ok(m.s.flags.noHeal && inRange(u, m), '凯尔希\'s Mon3tr: 禁疗, in her range');
m.hp = m.s.maxHp * 0.4;
const n0 = heals(h, u).length;
assert.ok(h.runUntil(() => heals(h, u).length > n0, 4), 'a heal');
assert.equal(heals(h, u)[n0].target, m, 'it is her target');
approx(heals(h, u)[n0].amount, u.s.atk, 'a full heal through its 禁疗');
done(h);
});
test('T1 遗尘守望 起飞: no ground enemy targets her or is blocked by her; she blocks a flyer 1.0 tile away (air radius 0.8944 × 1.23), two at most; the mod blockRadiusScale on any blocker', () => {
const { h, u } = field({ skill: 0, others: [{ uid: 3, chessId: YAK, row: 11, col: 7 }] });
const yak = h.unit(3);
const g = h.spawn('enemy_biter', { pos: [10, 5] });
const f1 = h.spawn('enemy_fly', { pos: [10, 5] });
const f2 = h.spawn('enemy_fly', { pos: [11, 4] });
const f3 = h.spawn('enemy_fly', { pos: [9, 4] });
h.run(3);
assert.equal(g.blockedBy, null, 'a ground enemy on the next tile is not blocked');
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u).length, 0, 'nor does it attack her (对地规避)');
assert.equal([f1, f2, f3].filter((e) => e.blockedBy === u).length, 2, 'two of the three flyers 1.0 away: block 2');
assert.ok(f1.blockedBy === u, 'the first one in contact');
// the engine rule on another unit: a blockFly unit blocks a flyer 1.0 away only with 阻挡半径倍率 ≥ 1.118
const f4 = h.spawn('enemy_fly', { pos: [11, 8] });
h.b.addBuff(yak, { key: 'test:air', flags: { blockFly: true } });
h.run(0.5);
assert.equal(f4.blockedBy, null, 'radius 0.8944: out of reach');
h.b.addBuff(yak, { key: 'test:scale', mods: { blockRadiusScale: 0.23 } });
h.run(0.5);
assert.equal(f4.blockedBy, yak, 'radius 0.8944 × 1.23: blocked');
assert.equal(yak.s.blockRadiusScale, 0.23);
done(h);
});
test('T2 医者丰碑: an operator entering her range gets 1 护盾 layer (one damage instance blocked) and 生命回复速度 +50 for 30 s (罗德岛 +100); summons and she get nothing; a new entry refreshes, never a 2nd layer', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
const t1 = talentOf(tier, elite, 1);
assert.deepEqual([t1.buff_duration, t1.hp_recovery_per_sec, t1.rhodes_bonus], [30, 50, 2], label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: PERFUMER, row: 9, col: 5 },
{ uid: 5, chessId: TEXAS, row: 12, col: 8 }, { uid: 6, diy: { slot: 6, charId: 'char_003_kalts', skillIndex: 0 }, row: 12, col: 2 },
{ uid: 7, kind: 'token', tokenId: 'token_10002_kalts_mon3tr', ownerUid: 6, row: 11, col: 5 }];
const { h, u } = field({ tier, elite, skill: 0, others });
const yak = h.unit(3), pf = h.unit(4), tx = h.unit(5), m = h.unit(7);
assert.equal(pf.def.raw.nationId, 'rhodes');
assert.ok(yak.findBuff('talent:kalts2:shield') && yak.findBuff('talent:kalts2:shield').shieldHits === 1, `${label(f)}: 角峰 1 护盾 layer`);
assert.deepEqual([yak.findBuff('talent:kalts2:regen')?.mods, pf.findBuff('talent:kalts2:regen')?.mods], [{ hpRegen: 50 }, { hpRegen: 100 }], `${label(f)}: 50 / 罗德岛 100 HP/s (生命回复速度)`);
approx(yak.findBuff('talent:kalts2:regen').timeLeft, 30, `${label(f)}: 30 s`, 0.01);
assert.ok(!m.findBuff('talent:kalts2:shield') && !m.findBuff('talent:kalts2:regen'), `${label(f)}: no summon`);
assert.ok(!u.findBuff('talent:kalts2:shield') && !u.findBuff('talent:kalts2:regen'), `${label(f)}: not herself`);
assert.ok(!tx.findBuff('talent:kalts2:regen'), `${label(f)}: 德克萨斯 outside her range`);
// the 护盾 blocks one damage instance whole
const hp0 = yak.hp;
assert.equal(h.b.dealDamage(null, yak, { amount: 500, type: 'true', sourceless: true }), 0, `${label(f)}: blocked`);
assert.equal(yak.hp, hp0);
assert.equal(yak.findBuff('talent:kalts2:shield'), null, `${label(f)}: used up`);
assert.ok(h.b.dealDamage(null, yak, { amount: 500, type: 'true', sourceless: true }) > 0, `${label(f)}: the next one lands`);
// still inside: no new entry; out and back in: a new layer and a fresh 30 s
h.run(5);
assert.equal(yak.findBuff('talent:kalts2:shield'), null, `${label(f)}: no entry while it stays`);
approx(yak.findBuff('talent:kalts2:regen').timeLeft, 25, `${label(f)}: ticking`, 0.05);
h.b.relocate(yak, 10, 8);
h.step();
assert.ok(!inRange(u, yak));
h.b.relocate(yak, 10, 5);
h.step();
assert.ok(yak.findBuff('talent:kalts2:shield'), `${label(f)}: re-entry: 护盾 again`);
approx(yak.findBuff('talent:kalts2:regen').timeLeft, 30, `${label(f)}: re-entry: 30 s again`, 0.05);
h.b.relocate(yak, 10, 8); h.step(); h.b.relocate(yak, 10, 5); h.step();
assert.equal(yak.buffs.filter((b) => b.key === 'talent:kalts2:shield').length, 1, `${label(f)}: one layer at most`);
// 30 s on: the regen ends (德克萨斯 entering later gets its own)
const r0 = tx.s.hpRegen; // (调香师's own 熏衣草 regen on everyone)
assert.ok(h.b.relocate(tx, 9, 3));
h.step();
approx(tx.s.hpRegen - r0, 50, `${label(f)}: 德克萨斯 enters`);
assert.deepEqual(tx.findBuff('talent:kalts2:regen')?.mods, { hpRegen: 50 });
done(h);
}
});
test('S1 应急肃正防线 (MANUAL, data DEFAULT — as she is about to heal): 35 s, ATK +60 % / +75 %, ASPD +25 / +35; every other 起飞 operator 阻挡半径倍率 +0.23 meanwhile (not a grounded one, not her twice)', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.attack_speed, sk.bb['attack@block_radius_scale'], sk.spCost, sk.initSp, sk.trigger.rule],
[35, elite ? 0.75 : 0.6, elite ? 35 : 25, 0.23, 35, elite ? 20 : 18, 'DEFAULT'], label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: TEXAS, row: 12, col: 9 }];
const { h, u } = field({ tier, elite, skill: 0, others });
const yak = h.unit(3), tx = h.unit(4);
h.b.addBuff(tx, { key: 'test:liftoff', flags: { liftoff: true } });
assert.equal(u.skill.rule, 'DEFAULT');
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured: no cast`);
yak.hp = yak.s.maxHp * 0.5;
assert.ok(h.runUntil(() => u.skill.active, 4), `${label(f)}: cast as she heals`);
approx(u.skill.timeLeft, 35, `${label(f)}: 35 s`, 0.05);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
assert.equal(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `${label(f)}: ASPD`);
h.run(0.4);
assert.equal(tx.s.blockRadiusScale, 0.23, `${label(f)}: the 起飞 德克萨斯 (anywhere on the field)`);
assert.equal(yak.s.blockRadiusScale, 0, `${label(f)}: 角峰 is not 起飞`);
assert.equal(u.s.blockRadiusScale, 0.23, `${label(f)}: hers stays 0.23 (同名效果取最高)`);
h.runUntil(() => !u.skill.active, 40);
h.run(0.5);
assert.deepEqual([tx.s.blockRadiusScale, u.s.atk, u.s.aspd], [0, u.base.atk, u.base.aspd], `${label(f)}: all back`);
done(h);
}
});
test('S2 保护性拒止 (MANUAL, data ACTIVE_RANGE on its y-11: an injured ally there): 10 shots, range y-11, ATK +; at an enemy (air too): true damage + 5 s 停顿 within 1.5, then the heal there; at an ally (no enemy): heal first, no 停顿', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.bb.atk, sk.bb['attack@atk_scale'], sk.bb['attack@heal_scale'], sk.bb['attack@sluggish'], sk.bb['attack@trigger_time'], sk.rangeId, sk.trigger.rule],
[elite ? 1.25 : 1.1, elite ? 3.5 : 3.1, elite ? 1.7 : 1.35, 5, 10, 'y-11', 'ACTIVE_RANGE'], label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 7 }, { uid: 4, chessId: TEXAS, row: 11, col: 4 }];
const { h, u, log } = field({ tier, elite, skill: 1, others });
const yak = h.unit(3);
const mine = (from) => log.slice(from).filter((c) => c.source === u);
assert.deepEqual([u.skill.rule, u.skill.kind], ['ACTIVE_RANGE', 'ammo'], label(f));
assert.ok(!inRange(u, yak), `${label(f)}: (10,7) is outside her y-6`);
u.skill.gainSp(999);
h.run(1.5);
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured: no cast`);
// an ally shot first: 角峰 injured inside the y-11 only, no enemy she can select (the dummy is beyond it)
const e = h.spawn('enemy_dummy', { pos: [11, 8] }); // √2 from 角峰, outside the y-11 (col +4)
const n0 = log.length;
yak.hp = yak.s.maxHp * 0.3;
assert.ok(h.runUntil(() => u.skill.active, 0.5), `${label(f)}: cast for the y-11`);
assert.deepEqual([u.liveRangeGrid, u.skill.ammoMax], [sk.rangeGrid, 10], `${label(f)}: y-11, 10 shots`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
assert.ok(h.runUntil(() => u.skill.ammoLeft === 9, 3), `${label(f)}: one shot (the cast's attack)`);
const shot = mine(n0);
assert.deepEqual(shot.slice(0, 2).map((c) => [c.name, c.target]), [['heal', yak], ['damaged', e]], `${label(f)}: at an ally — heal first, then the enemy within 1.5`);
approx(shot[0].amount, u.s.atk * sk.bb['attack@heal_scale'], `${label(f)}: heal ${sk.bb['attack@heal_scale'] * 100} % ATK`);
approx(shot[1].amount, u.s.atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale'] * 100} % ATK true`);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u).length, 0, `${label(f)}: no 停顿 from an ally shot`);
// an enemy inside the y-11 is the target from now on — a flyer
const fly = h.spawn('enemy_fly', { pos: [9, 7] }); // 1.0 from 角峰
const n1 = log.length;
assert.ok(h.runUntil(() => mine(n1).some((c) => c.name === 'damaged'), 4), `${label(f)}: another shot lands (a 医疗单元 in flight)`);
const burst = mine(n1);
assert.equal(burst[0].name, 'damaged', `${label(f)}: at an enemy — damage first`);
assert.equal(burst[0].target, fly, `${label(f)}: the flyer (可对空)`);
for (const c of burst.filter((x) => x.name === 'damaged')) assert.deepEqual([c.type, c.dmg.isAttack, c.dmg.isSplash, c.dmg.isSkill], ['true', true, false, true], `${label(f)}: 真实普通伤害`);
assert.ok(burst.some((c) => c.name === 'heal' && c.target === yak), `${label(f)}: then 角峰 (1.0 away) healed`);
assert.ok(!burst.some((c) => c.target === e), `${label(f)}: the dummy (2.2 from the flyer) untouched`);
const slugs = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.ok(slugs.length >= 1 && slugs.every((c) => c.duration === 5 && c.target === fly), `${label(f)}: 停顿 5 s on the enemy struck`);
h.runUntil(() => !u.skill.active, 40);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: back to y-6 after 10 shots`);
done(h);
}
});
test('S3 破梏重生 (MANUAL, data DEFAULT): 35 s, ATK +, attack interval 1.30 s (−1.55 flat), 2 heal targets; with no anchor she stays', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.spCost, sk.initSp, sk.trigger.rule, sk.overrideTokenKey],
[35, elite ? 1.25 : 1.1, -1.55, elite ? 60 : 65, elite ? 30 : 25, 'DEFAULT', ANCHOR], label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: TEXAS, row: 11, col: 5 }];
const { h, u } = field({ tier, elite, skill: 2, others });
const yak = h.unit(3), tx = h.unit(4);
u.skill.gainSp(999);
yak.hp = yak.s.maxHp * 0.3; tx.hp = tx.s.maxHp * 0.4;
const n0 = heals(h, u).length;
assert.ok(h.runUntil(() => u.skill.active, 4), label(f));
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.interval, 2.85 - 1.55, `${label(f)}: 1.30 s`);
h.runUntil(() => heals(h, u).length >= n0 + 2, 3);
const two = heals(h, u).slice(n0, n0 + 2); // the cast's heal (same tick) already heals two
assert.deepEqual(two.map((c) => c.target.id).sort(), [yak.id, tx.id].sort(), `${label(f)}: two targets per heal`);
assert.equal(two[0].t, two[1].t, `${label(f)}: in one attack`);
h.run(2);
assert.deepEqual([u.tileR, u.tileC, u.hidden], [10, 4, false], `${label(f)}: no anchor: no move`);
h.runUntil(() => !u.skill.active, 40);
approx(u.s.interval, 2.85, `${label(f)}: interval back`);
done(h);
}
});
test('S3 with her anchor: 0.1 s after the cast she flies to it (hidden, 无敌, the skill time paused, 3.5 tiles/s; operators within 2.2 of her flight enter T2), the two latest-deployed operators of her range are withdrawn 0.6 s on and back at once; she lands on its tile facing its way; the anchor is withdrawn for the rest of the skill', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
// deployment order (left column first, top row first): (11,5) → (10,5) → (9,5); (12,7) beside her flight
const others = [{ uid: 3, chessId: YAK, row: 11, col: 5 }, { uid: 4, chessId: TEXAS, row: 10, col: 5 }, { uid: 5, chessId: PERFUMER, row: 9, col: 5 },
{ uid: 6, chessId: 'chess_char_2_02_a', row: 12, col: 7 }];
const { h, u, a } = field({ tier, elite, skill: 2, anchor: { row: 10, col: 9, dir: 'UP' }, others });
const yak = h.unit(3), tx = h.unit(4), pf = h.unit(5), sil = h.unit(6);
assert.ok(a.alive && u.mem.k2link === a, `${label(f)}: the anchor deployed at the start, linked to her`);
assert.ok(!inRange(u, a), `${label(f)}: outside her range`);
assert.ok(!sil.findBuff('talent:kalts2:regen'), `${label(f)}: 赫默 (12,7) is outside her range`);
const seq = [yak.deploySeq, tx.deploySeq, pf.deploySeq];
assert.ok(seq[0] < seq[1] && seq[1] < seq[2], label(f));
u.skill.gainSp(999);
yak.hp = yak.s.maxHp * 0.3;
assert.ok(h.runUntil(() => u.skill.active, 4), label(f));
const t0 = h.b.time;
h.runUntil(() => u.hidden, 0.2);
assert.ok(u.hidden && u.mem.k2flight, `${label(f)}: flying`);
approx(h.b.time - t0, 0.1, `${label(f)}: 0.1 s after the cast`, 0.4);
const tl = u.skill.timeLeft;
assert.equal(h.b.dealDamage(null, u, { amount: 5000, type: 'true', sourceless: true }), 0, `${label(f)}: 无敌 in flight`);
const T = 5 / 3.5;
h.run(0.7);
approx(u.skill.timeLeft, tl, `${label(f)}: skill time paused`, 1e-6);
// the two latest deployed (护理 (9,5) and 德克萨斯 (10,5)) withdrawn and back on their tiles; 角峰 stays
for (const x of [pf, tx]) {
assert.ok(h.hooksOf('death').some((c) => c.unit === x && c.reason === 'retreat'), `${label(f)}: ${x.defId} withdrawn`);
assert.ok(x.alive && x.deploySeq > seq[2], `${label(f)}: ${x.defId} back at once`);
}
assert.ok(!h.hooksOf('death').some((c) => c.unit === yak), `${label(f)}: 角峰 (the first deployed) stays`);
assert.deepEqual([tx.tileR, tx.tileC, pf.tileR, pf.tileC], [10, 5, 9, 5], `${label(f)}: on their tiles`);
h.runUntil(() => !u.hidden, T);
assert.ok(!u.hidden, `${label(f)}: landed`);
approx(h.b.time - t0, 0.1 + T, `${label(f)}: after ${T.toFixed(2)} s of flight`, 0.1);
assert.deepEqual([u.tileR, u.tileC, u.dir], [10, 9, 'UP'], `${label(f)}: on the anchor's tile, facing its way`);
assert.ok(!a.alive && !a.removed && a.mem.docked, `${label(f)}: the anchor withdrawn (its piece kept)`);
assert.equal(u.mem.summonStock[ANCHOR], 0, `${label(f)}: stock emptied`);
assert.ok(u.skill.active, label(f));
approx(u.skill.timeLeft, tl, `${label(f)}: the remaining time kept`, 0.1);
assert.equal(sil.findBuff('talent:kalts2:regen')?.mods.hpRegen, 50, `${label(f)}: 赫默, 2.0 from her flight, entered T2`);
h.runUntil(() => !u.skill.active, 40);
h.run(5);
assert.ok(!a.alive, `${label(f)}: no anchor back after the skill (stock 0)`);
done(h);
}
});
test('the anchor link: she leaves the field before her S3 ⇒ the anchor on the field no longer moves her (kalts2_s_3[on_token_born] is lost with her); with S1 / S2 the anchor piece never takes the field', () => {
const { h, u, a } = field({ tier: 6, elite: true, skill: 2, anchor: { row: 10, col: 9 }, others: [{ uid: 3, chessId: YAK, row: 10, col: 5 }] });
const yak = h.unit(3);
assert.equal(u.mem.k2link, a);
h.b.kill(u, null);
h.step();
assert.equal(u.mem.k2link, null, 'link lost');
assert.ok(h.runUntil(() => u.alive, 90), 'she redeploys');
assert.ok(a.alive, 'the anchor still stands');
u.skill.gainSp(999);
yak.hp = yak.s.maxHp * 0.3;
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(3);
assert.deepEqual([u.tileR, u.tileC, u.hidden], [10, 4, false], 'no transport');
done(h);
for (const skill of [0, 1]) {
const r = field({ skill, anchor: { row: 10, col: 9 } });
r.h.run(2);
assert.ok(!r.a.alive && r.a.deferDeploy, `S${skill + 1}: the anchor stays off the field`);
done(r.h);
}
});