feedback5: merge O24 — 自选 kits for 凯尔希·思衡托 (with her 战术锚点) and Mon3tr (with her 重构体); engine (additive): mod blockRadiusScale widens the air-block radius (0 for every other unit); golden: only diy moved — all 71 自选 operators now have kits

This commit is contained in:
sganggs committed 2026-10-06 14:18:10 +08:00
commit a009d77d71
11 files changed
+1695 -177

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
// 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);
}
});
+377
View File
@@ -0,0 +1,377 @@
// test/content/op_monstr.test.js — the 自选 operator kit of Mon3tr (char_4179_monstr, 6★ 链愈师; kit
// server/sim/content/kits/ops/op-monstr.js) and her summon 重构体 (token_10050_monstr_prosts), fielded the production way (a
// DIY slot + its `diy` pick; the 重构体 as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no
// module) and elite (E2 Lv60, rank 7) with no module or XAH-X 记忆存档 at stage 1 (tier 5) / 3 (tier 6). 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_monstr.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';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const MONSTR = 'char_4179_monstr';
const PROSTS = 'token_10050_monstr_prosts';
const FORMS = BACKUPS.units[MONSTR].forms;
const TOKEN = BACKUPS.tokens[PROSTS];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const XAHX = 'uniequip_002_monstr';
const S1 = 'skchr_monstr_1', S2 = 'skchr_monstr_2', S3 = 'skchr_monstr_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 modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const variantOf = (tier, elite) => TOKEN.variants[`${MONSTR}@${statusKey(tier, elite)}`];
/** The trait / T1 blackboards a form fights with (module changes applied). */
const traitOf = (tier, elite, mod) => (elite && mod ? modOf(tier, mod).traitOverride.bb : formOf(tier, elite).trait.bb);
const t0Of = (tier, elite, mod) => (elite && mod && modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0)?.bb) || formOf(tier, elite).talents.find((t) => t.index === 0).bb;
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: 300, range: 1.2, bat: 1 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, XAHX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const YAK = 'chess_char_1_02_a', ESTELLE = 'chess_char_1_12_a', SILENCE = 'chess_char_2_02_a', PERFUMER = 'chess_char_2_14_a';
/**
* A battle with Mon3tr as uid 1 at (10, 4) facing RIGHT (`prosts`: her piece as uid 2), plus `others`; `log` = her heals /
* damage in the order they happen.
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, prosts = null, others = [], seed = 5, dp = 99 } = {}) {
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: MONSTR, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 4 }];
if (prosts) units.push({ uid: 2, kind: 'token', tokenId: PROSTS, ownerUid: 1, row: prosts.row, col: prosts.col });
const log = [];
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 },
hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'death', 'deploy', 'fatal'], captureNoisy: true,
units: [...units, ...others],
setup: (b) => {
b.on('heal', (c) => { if (!c.opts?.regen) log.push({ name: 'heal', ...c, t: b.time }); }, { priority: -1000 });
b.on('damaged', (c) => log.push({ name: 'damaged', ...c, t: b.time }), { priority: -1000 });
},
});
h.step();
return { h, u: h.unit(1), p: prosts ? h.unit(2) : null, log };
}
const healsOf = (log, u, from = 0) => log.slice(from).filter((c) => c.name === 'heal' && c.source === u);
const setHp = (x, r) => { x.hp = x.s.maxHp * r; };
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('Mon3tr in every 自选 form: her operator kit (all three skills authored), stats + module attributes, y-2, one main heal per attack (her own chain), blocks 1, ground-targetable, 罗德岛, no 特质; her 重构体: the form\'s token stats, 禁疗, block 0, taunt −1', () => {
assert.equal(OPERATOR_KITS[MONSTR], KITS[MONSTR]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u, p } = field({ tier, elite, mod, skill, prosts: { row: 10, col: 6 } });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [MONSTR, 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 + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 2.85, 18], `${label(f)}: stats`);
assert.deepEqual([u.profile.dmgType, u.profile.heal, u.profile.attack, u.def.subProf, u.s.blockCnt], ['heal', { mode: 'single' }, 'ranged', 'chainhealer', 1], `${label(f)}: 链愈师`);
assert.deepEqual(u.def.traitBb, traitOf(tier, elite, mod), `${label(f)}: the trait (XAH-X 0.85)`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-2`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens, u.def.raw.nationId], [BACKUPS.diy.operators[MONSTR].bonds, [], [PROSTS], 'rhodes'], `${label(f)}: bonds / 特质 / summon`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
const ts = ((elite && mod && variantOf(tier, elite).byModule?.[mod]) || variantOf(tier, elite)).stats;
assert.deepEqual([p.base.maxHp, p.base.def, p.base.atk, p.base.cost, p.base.respawnTime, p.s.blockCnt, p.s.taunt], [ts.maxHp, ts.def, 0, 3, 15, 0, -1], `${label(f)}: 重构体 stats`);
assert.deepEqual([p.s.flags.noHeal, p.s.flags.noBlock, p.profile.noAttack, !!p.kit.generic, p.alive], [true, true, true, false, true], `${label(f)}: 禁疗, blocks nothing, no attack, on the field`);
done(h);
}
}
// E2 Lv1 1492 / 422 / 176, E2 Lv60 1653 / 492 / 206; XAH-X +40 / +30 → +65 / +40 ATK / DEF; the 重构体 4292 / 181 → 5048 / 208
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/1'].stats.atk], [1492, 422, 1653, 492]);
assert.deepEqual([modOf(5, XAHX).attr, modOf(6, XAHX).attr], [{ atk: 40, def: 30 }, { atk: 65, def: 40 }]);
assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(5, false).stats.def, variantOf(6, true).stats.maxHp], [4292, 181, 5048]);
assert.deepEqual([t0Of(5, true, XAHX).atk, t0Of(6, true, XAHX).atk, t0Of(6, true, XAHX)['attack@chain.extra_cnt'], t0Of(6, false, null).atk], [0.15, 0.25, 1, 0.15]);
});
test('a 自选 pick: Mon3tr is offered at tiers 5 and 6 (she has a kit) and a roster with her and XAH-X passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(MONSTR));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MONSTR), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: MONSTR, skillIndex: 2, uniEquipId: XAHX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: MONSTR, skillIndex: 2, uniEquipId: XAHX } } });
});
test('trait 链愈师: the heal jumps to the lowest-HP-ratio injured ally of the 3×3 around the last one healed, ×0.75 per jump (XAH-X ×0.85), 3 units healed; a full-HP unit and one two tiles away are skipped', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, XAHX], [6, true, XAHX]]) {
const [tier, elite, mod] = f;
const step = traitOf(tier, elite, mod)['attack@chain.atk_scale'];
assert.equal(step, mod ? 0.85 : 0.75, label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: ESTELLE, row: 11, col: 6 }, { uid: 5, chessId: SILENCE, row: 12, col: 7 },
{ uid: 6, chessId: PERFUMER, row: 9, col: 7 }, { uid: 7, chessId: 'chess_char_1_11_a', row: 10, col: 6 }];
const { h, u, log } = field({ tier, elite, mod, skill: 2, others });
const [A, B, C, F, G] = [3, 4, 5, 6, 7].map((i) => h.unit(i));
setHp(A, 0.2); setHp(B, 0.5); setHp(C, 0.6); setHp(F, 0.3);
const n0 = log.length;
assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f));
h.step();
const hs = healsOf(log, u, n0).filter((c) => c.t === healsOf(log, u, n0)[0].t);
assert.deepEqual(hs.map((c) => c.target), [A, B, C], `${label(f)}: A → B → C (G full, F two tiles off)`);
const atk = u.s.atk;
approx(hs[0].amount, atk, `${label(f)}: the main heal`);
approx(hs[1].amount, atk * step, `${label(f)}: ×${step}`);
approx(hs[2].amount, atk * step * step, `${label(f)}: ×${step}²`);
assert.ok(!healsOf(log, u, n0).some((c) => c.target === F || c.target === G), `${label(f)}: F / G not healed`);
done(h);
}
});
test('T1 自我修复 free link: her 重构体 in a chain uses no target of the count and the next jump keeps the amount; XAH-X stage 3: one more jump without decay', () => {
for (const f of [[5, false, null], [5, true, XAHX], [6, true, XAHX]]) {
const [tier, elite, mod] = f;
const step = traitOf(tier, elite, mod)['attack@chain.atk_scale'];
const extra = t0Of(tier, elite, mod)['attack@chain.extra_cnt'] ?? 0;
assert.equal(extra, tier === 6 && mod ? 1 : 0, label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: ESTELLE, row: 11, col: 6 }, { uid: 5, chessId: SILENCE, row: 12, col: 7 }];
const { h, u, p, log } = field({ tier, elite, mod, skill: 2, prosts: { row: 11, col: 5 }, others });
const [A, B, C] = [3, 4, 5].map((i) => h.unit(i));
setHp(A, 0.2); setHp(p, 0.3); setHp(B, 0.5); setHp(C, 0.6);
const n0 = log.length;
assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f));
const hs = healsOf(log, u, n0).filter((c) => c.t === healsOf(log, u, n0)[0].t);
assert.deepEqual(hs.map((c) => c.target), [A, p, B, C], `${label(f)}: A → 重构体 → B → C (4 healed: the 重构体 is not counted)`);
const atk = u.s.atk;
approx(hs[1].amount, atk * step, `${label(f)}: the jump onto it decays`);
approx(hs[2].amount, atk * step, `${label(f)}: the next one does not`);
approx(hs[3].amount, atk * step * (extra ? 1 : step), `${label(f)}: then ${extra ? 'still no decay (下次跳跃也不衰减)' : 'it decays again'}`);
done(h);
}
});
test('T1 自我修复 — the 重构体: only her heals reach it (禁疗), it loses 8 HP every 0.1 s from 1 s after it lands, every ally of its 3×3 ATK +15 % (XAH-X stage 3 +25 %; not 2 tiles away); gone ⇒ back 15 s later for 3 DP while she stands; withdrawn when she leaves, her redeployment readies it', () => {
for (const f of [[5, false, null], [6, true, XAHX], [5, true, XAHX]]) {
const [tier, elite, mod] = f;
const atkUp = t0Of(tier, elite, mod).atk;
const others = [{ uid: 3, chessId: YAK, row: 11, col: 6 }, { uid: 4, chessId: ESTELLE, row: 12, col: 6 }, { uid: 5, chessId: SILENCE, row: 9, col: 3 }];
const { h, u, p } = field({ tier, elite, mod, skill: 0, prosts: { row: 10, col: 6 }, others, dp: 50 });
const yak = h.unit(3), tx = h.unit(4), sil = h.unit(5);
h.run(0.3);
approx(yak.s.atk, yak.base.atk * (1 + atkUp), `${label(f)}: 角峰 next to it ATK +${atkUp * 100} %`);
approx(u.s.atk, u.base.atk, `${label(f)}: Mon3tr (two tiles off) nothing`);
approx(tx.s.atk, tx.base.atk, `${label(f)}: 艾丝黛尔 (two rows off) nothing`);
assert.equal(p.findBuff('talent:monstr:prostsAtk'), null, `${label(f)}: not itself`);
// the 流失: none in the first second, then 80 per second
const hp0 = p.hp;
assert.equal(hp0, p.s.maxHp);
h.run(0.6);
assert.equal(p.hp, hp0, `${label(f)}: nothing before 1 s`);
h.run(1.0);
const lost = hp0 - p.hp;
assert.ok(lost > 60 && lost < 100, `${label(f)}: ≈80 HP in the next second (${lost})`);
assert.ok(h.hooksOf('damaged').some((c) => c.target === p && c.dmg?.tags?.includes('hpLoss')), `${label(f)}: a 流失`);
// 禁疗: 赫默 never heals it; Mon3tr does
setHp(p, 0.3);
h.run(6);
assert.equal(h.hooksOf('heal').filter((c) => c.source === sil && c.target === p).length, 0, `${label(f)}: no heal from 赫默`);
assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.target === p), `${label(f)}: Mon3tr heals it`);
// destroyed: back 15 s later for 3 DP
const dp0 = h.b.players[0].dp;
h.b.kill(p, null);
h.run(14.5);
assert.ok(!p.alive, `${label(f)}: not before 15 s`);
h.run(0.7);
assert.ok(p.alive, `${label(f)}: back`);
assert.deepEqual([p.tileR, p.tileC], [10, 6], `${label(f)}: on its tile`);
approx(h.b.players[0].dp, dp0 - 3, `${label(f)}: 3 DP`);
// she leaves: withdrawn; it comes with her redeployment at once
h.b.kill(u, null);
h.step();
assert.ok(!p.alive && !p.removed, `${label(f)}: withdrawn with her`);
assert.ok(h.runUntil(() => u.alive, 90), `${label(f)}: she redeploys`);
assert.ok(h.runUntil(() => p.alive, 0.3), `${label(f)}: at once`);
done(h);
}
});
test('T2 战术协同: each heal of hers gives its target and her ASPD +20 for 10 s (no stacking: refreshed); under S2 ×1.8 / ×2.3 — the strongest effect stays', () => {
for (const f of [[5, false, null], [6, true, XAHX]]) {
const [tier, elite, mod] = f;
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const s2 = skillOf(tier, elite, S2);
assert.deepEqual([t1.attack_speed, t1.buff_duration, s2.bb.talent_scale], [20, 10, elite ? 2.3 : 1.8], label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }];
const { h, u, log } = field({ tier, elite, mod, skill: 1, others });
const yak = h.unit(3);
setHp(yak, 0.3);
const n0 = log.length;
assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f));
assert.deepEqual([yak.findBuff('talent:monstr:aspd')?.mods, u.findBuff('talent:monstr:aspd')?.mods], [{ aspd: 20 }, { aspd: 20 }], `${label(f)}: target and herself`);
approx(yak.findBuff('talent:monstr:aspd').timeLeft, 10, `${label(f)}: 10 s`, 0.05);
assert.equal(yak.s.aspd, yak.base.aspd + 20, label(f));
// S2 (an enemy in her y-2): ×talent_scale
h.spawn('enemy_dummy', { pos: [11, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2), `${label(f)}: S2`);
setHp(yak, 0.3);
const n1 = log.length;
assert.ok(h.runUntil(() => healsOf(log, u, n1).some((c) => c.target === yak), 6), label(f));
approx(yak.findBuff('talent:monstr:aspd').mods.aspd, 20 * s2.bb.talent_scale, `${label(f)}: ×${s2.bb.talent_scale}`);
approx(u.findBuff('talent:monstr:aspd').mods.aspd, 20 * s2.bb.talent_scale, `${label(f)}: herself too`);
done(h);
}
});
test('S1 策略:超压链接 (AUTO, attack SP 5 / 3, data DEFAULT — the next heal): that heal is 135 % / 170 % ATK and heals 4 units', () => {
for (const f of [[5, false, null], [6, true, XAHX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S1);
const step = traitOf(tier, elite, mod)['attack@chain.atk_scale'];
assert.deepEqual([sk.skillType, sk.spType, sk.spCost, sk.initSp, sk.bb.heal_scale, sk.bb['chain.max_target'], sk.trigger.rule],
['AUTO', 'INCREASE_WHEN_ATTACK', elite ? 3 : 5, 0, elite ? 1.7 : 1.35, 4, 'DEFAULT'], label(f));
const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: ESTELLE, row: 11, col: 6 }, { uid: 5, chessId: SILENCE, row: 12, col: 7 }, { uid: 6, chessId: PERFUMER, row: 12, col: 8 }];
const { h, u, log } = field({ tier, elite, mod, skill: 0, others });
const [A, B, C, D] = [3, 4, 5, 6].map((i) => h.unit(i));
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost], ['DEFAULT', 'attack', sk.spCost], label(f));
u.skill.gainSp(999);
setHp(A, 0.2); setHp(B, 0.4); setHp(C, 0.5); setHp(D, 0.6);
const n0 = log.length;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `${label(f)}: cast as she heals`);
const hs = healsOf(log, u, n0);
assert.deepEqual(hs.slice(0, 4).map((c) => c.target), [A, B, C, D], `${label(f)}: 4 units`);
const base = u.s.atk * sk.bb.heal_scale;
hs.slice(0, 4).forEach((c, i) => approx(c.amount, base * step ** i, `${label(f)}: link ${i}`));
assert.ok(!u.skill.active, `${label(f)}: spent on that heal`);
done(h);
}
});
test('S2 策略:超负荷 (MANUAL, data SKILL_RANGE on y-2: an enemy there; 30 s): her 重构体 in range is her heal target, even at full HP with nobody injured; 0.6 s after her attack healed it a second chain starts on it', () => {
for (const f of [[5, false, null], [6, true, XAHX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.duration, sk.trigger.rule, sk.rangeId, sk.spCost, sk.initSp], [30, 'SKILL_RANGE', 'y-2', 15, elite ? 8 : 7], label(f));
const others = [{ uid: 3, chessId: YAK, row: 11, col: 5 }, { uid: 4, chessId: ESTELLE, row: 12, col: 6 }];
const { h, u, p, log } = field({ tier, elite, mod, skill: 1, prosts: { row: 10, col: 5 }, others });
const yak = h.unit(3), tx = h.unit(4);
assert.equal(u.skill.rule, 'SKILL_RANGE');
u.skill.gainSp(999);
setHp(yak, 0.3);
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: an injured ally, no enemy: no cast`);
h.spawn('enemy_dummy', { pos: [9, 6] });
assert.ok(h.runUntil(() => u.skill.active, 0.5), `${label(f)}: an enemy in her y-2`);
approx(u.skill.timeLeft, 30, `${label(f)}: 30 s`, 0.05);
// 角峰 is lower, the 重构体 is still the target
setHp(p, 0.9); setHp(yak, 0.2);
let n0 = log.length;
assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f));
const first = healsOf(log, u, n0);
assert.equal(first[0].target, p, `${label(f)}: the 重构体 first`);
const t0 = first[0].t;
// the second chain 0.6 s later, on it again
h.run(0.7);
const again = healsOf(log, u, n0).filter((c) => c.t > t0 + 0.5);
assert.ok(again.length > 0, `${label(f)}: a second chain`);
approx(again[0].t - t0, 0.6, `${label(f)}: 0.6 s later`, 0.06);
assert.equal(again[0].target, p, `${label(f)}: starting on the 重构体`);
approx(again[0].amount, u.s.atk, `${label(f)}: her ATK`);
// nobody injured: she heals the full-HP 重构体 anyway
setHp(yak, 1); setHp(tx, 1); setHp(u, 1); p.hp = p.s.maxHp;
n0 = log.length;
h.run(4);
assert.ok(healsOf(log, u, n0).some((c) => c.target === p), `${label(f)}: the full-HP 重构体 healed`);
done(h);
}
});
test('S3 策略:熔毁 (MANUAL, data DEFAULT, 25 s): refused without her 重构体; with it: it is withdrawn and she moves onto its tile — x-4, ATK +, interval 1.35 s, block 3, max HP +5000 (ratio kept), 流失 80/s; true damage on every ground enemy she blocks, a chain heal on herself (0.5 ATK, a free link) each attack; back at the end with SP 0, the 重构体 returns', () => {
for (const f of [[5, false, null], [6, true, XAHX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.block_cnt, sk.bb.max_hp, sk.bb.damage_per_second, sk.bb['attack@heal_scale'], sk.rangeId, sk.trigger.rule],
[25, elite ? 2.8 : 2.5, -1.5, 2, 5000, 80, 0.5, 'x-4', 'DEFAULT'], label(f));
// refused without the 重构体 (destroyed: its piece waits)
const r0 = field({ tier, elite, mod, skill: 2, prosts: { row: 10, col: 6 }, others: [{ uid: 3, chessId: YAK, row: 11, col: 4 }] });
r0.h.b.kill(r0.p, null);
r0.u.skill.gainSp(999);
setHp(r0.h.unit(3), 0.3);
r0.h.run(3);
assert.equal(r0.u.skill.activations, 0, `${label(f)}: no cast without the 重构体`);
assert.ok(r0.u.skill.ready, `${label(f)}: still ready`);
done(r0.h);
const others = [{ uid: 3, chessId: YAK, row: 11, col: 4 }, { uid: 4, chessId: ESTELLE, row: 9, col: 7 }];
const { h, u, p, log } = field({ tier, elite, mod, skill: 2, prosts: { row: 10, col: 6 }, others, dp: 50 });
const yak = h.unit(3), tx = h.unit(4);
const hpRatio = 0.8;
setHp(u, hpRatio);
u.skill.gainSp(999);
setHp(yak, 0.3);
assert.ok(h.runUntil(() => u.skill.active, 4), label(f));
assert.ok(!p.alive && !p.removed, `${label(f)}: the 重构体 withdrawn (its piece waits)`);
assert.deepEqual([u.tileR, u.tileC, u.ground], [10, 6, true], `${label(f)}: on its tile (a ground tile)`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: x-4`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.interval, 2.85 - 1.5, `${label(f)}: 1.35 s`);
assert.equal(u.s.blockCnt, 3, `${label(f)}: block 3`);
approx(u.s.maxHp, u.base.maxHp + 5000, `${label(f)}: max HP +5000`);
approx(u.hpRatio, hpRatio, `${label(f)}: the ratio kept`, 1e-3);
// the 流失
const hpA = u.hp;
h.run(1);
approx(hpA - u.hp, 80, `${label(f)}: 80 per second`, 0.15);
// the blocked: two ground enemies on her tile, a flyer beside her
const g1 = h.spawn('enemy_dummy', { pos: [10, 6] });
const g2 = h.spawn('enemy_dummy', { pos: [10, 6] });
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
h.step();
assert.deepEqual([g1.blockedBy, g2.blockedBy], [u, u], `${label(f)}: blocked`);
const n0 = log.length;
assert.ok(h.runUntil(() => log.slice(n0).some((c) => c.name === 'damaged' && c.source === u && c.target.side === 'enemy'), 3), label(f));
h.step();
const atkHits = log.slice(n0).filter((c) => c.name === 'damaged' && c.source === u && c.dmg?.isAttack);
const id = atkHits[0].dmg.attackId;
const one = atkHits.filter((c) => c.dmg.attackId === id);
assert.deepEqual(one.map((c) => c.target).sort((a, b) => a.id - b.id), [g1, g2].sort((a, b) => a.id - b.id), `${label(f)}: both blocked enemies, not the flyer`);
for (const c of one) { assert.equal(c.type, 'true', `${label(f)}: true damage`); approx(c.amount, u.s.atk, `${label(f)}: 100 % ATK`); }
assert.ok(!log.slice(n0).some((c) => c.target === fly), `${label(f)}: ground only`);
const self = log.slice(n0).find((c) => c.name === 'heal' && c.source === u && c.target === u);
assert.ok(self, `${label(f)}: the attack heals herself`);
approx(self.amount, u.s.atk * 0.5, `${label(f)}: 50 % ATK`);
// the end: back on her tile, SP 0, the 重构体 returns (its 15 s are long past) for 3 DP
const dp0 = h.b.players[0].dp;
h.runUntil(() => !u.skill.active, 30);
assert.deepEqual([u.tileR, u.tileC, u.skill.sp], [10, 4, 0], `${label(f)}: 【返回】, SP emptied`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: y-2 again`);
approx(u.s.maxHp, u.base.maxHp, `${label(f)}: max HP back`);
assert.ok(h.runUntil(() => p.alive, 0.5), `${label(f)}: the 重构体 back`);
assert.deepEqual([p.tileR, p.tileC], [10, 6], label(f));
approx(h.b.players[0].dp, dp0 - 3, `${label(f)}: 3 DP`);
assert.ok(tx.alive, label(f));
done(h);
}
});
test('S3 不死: a fatal blow leaves her at 1 HP (untargetable, blocking nothing), the skill ends on the next tick and she returns at ≤ 1 HP with 1 s of 不死; afterwards a fatal blow knocks her out', () => {
const { h, u, p } = field({ tier: 6, elite: true, mod: XAHX, skill: 2, prosts: { row: 10, col: 6 }, others: [{ uid: 3, chessId: YAK, row: 11, col: 4 }] });
u.skill.gainSp(999);
setHp(h.unit(3), 0.3);
assert.ok(h.runUntil(() => u.skill.active, 4));
assert.deepEqual([u.tileR, u.tileC], [10, 6]);
h.b.dealDamage(null, u, { amount: 1e7, type: 'true', sourceless: true });
assert.deepEqual([u.alive, u.hp, u.s.flags.untargetable, u.s.flags.noBlock, u.skill.active], [true, 1, true, true, true], 'held at 1 HP');
assert.ok(h.hooksOf('fatal').some((c) => c.unit === u && c.prevented));
h.step();
assert.ok(!u.skill.active, 'the skill ended');
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'returned');
assert.ok(!u.s.flags.untargetable, 'targetable again');
h.b.dealDamage(null, u, { amount: 1e7, type: 'true', sourceless: true });
assert.ok(u.alive && u.hp <= 1, '1 s 不死 after the return');
h.run(1.1);
h.b.dealDamage(null, u, { amount: 1e7, type: 'true', sourceless: true });
assert.ok(!u.alive, 'then a knock-out');
assert.ok(!p.alive, 'her 重构体 off the field');
done(h);
});