Files
ark-stronghold-protocol/test/content/op_kalts2.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +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, 17], `${label(f)}: stats`);
approx(u.s.maxHp, form.stats.maxHp * (1 + t0.max_hp), `${label(f)}: 生命上限 +30 %`);
approx(u.s.def, form.stats.def * (1 + t0.def), `${label(f)}: 防御力 +30 %`);
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 / 433 / 220, E2 Lv60 1543 / 501 / 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, 433, 1543, 501]);
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 生命回复速度 +60 for 30 s (罗德岛 +120); 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, 60, 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: 60 }, { hpRegen: 120 }], `${label(f)}: 60 / 罗德岛 120 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, 60, `${label(f)}: 德克萨斯 enters`);
assert.deepEqual(tx.findBuff('talent:kalts2:regen')?.mods, { hpRegen: 60 });
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, 60, `${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);
}
});