Files
ark-stronghold-protocol/test/content/op_kalts.test.js
T
sganggs d7a1d92d40 feedback5 O16: 自选 operator kits 涤火杰西卡 (+ her 机动盾牌), 机械师 (+ her 结构性原理) and 凯尔希 (+ her Mon3tr) — every skill, talent, trait and module (stage 1 / 3) at every form
- 涤火杰西卡 (ops/op-jesca2.js): the shield piece lasts 50 s (SPT-X stage 3: 60 s), holds 禁疗 and taunt 1, she turns to face
  it and back when it leaves, she and the unit behind her DEF +15 % (孤立 too), it falls with her and comes back 30 s later
  for 5 DP; T2 +1 SP per shield hit at 50 % (SPT-Y stage 3: 60 % and its redeploy −1 s per success, ≤ 14 s); S1 SP_FULL
  toggle ATK / DEF +40 / +55 % and the shield's DEF and +20 s life; S2 ACTIVE_RANGE 2-5 (not grown by 攻击距离), 0.3 s,
  75 % dodge; S3 ACTIVE_RANGE 20 bullets, +1 range, 1.8 s, the shell (5 tiles / s, 1.7 burst, 250 % ATK, 5 s stun) while
  the shield stands next to her; SPT-X reveal, SPT-Y +1 range.
- 机械师 (ops/op-mcnist.js): 结构性原理 30 s, 禁疗, back 40 s later for 10 DP; T2 30 % 屏障 (physical / arts, 超额防护); S1
  3 bullets of 5 strikes (1.1, air too); S2 10 % 屏障 whose break bursts (200 % arts, 战栗 2 s) and buys a new one per bullet;
  S3 the 0.8 s cross (x-6, 240 % arts), the token's charge (300 % physical, redeployed on the stop tile) and its decaying
  weaken aura; SO-A: attributes only (its trait / talent are 集成战略-only).
- 凯尔希 (ops/op-kalts.js): heal order herself / Mon3tr first (with no module the operator Mon3tr too), her heals through
  Mon3tr's 禁疗 (profile healThrough); Mon3tr DEF ×0 off her range (PHY-Y stage 3: ASPD +20 / DEF +20 % inside), withdrawn
  with her, back 25 s later for 10 DP; T2 1200 true + 3 s stun on its 3×3 when knocked out (PHY-X stage 3: 1500 / 3.5 s and
  the first time it falls to half HP); S1 DEF + 物理格挡; S2 / S3 bound to Mon3tr (绑定): Mon3tr hits every blocked enemy /
  true damage with an ATK bonus decaying every second and the 50 % 流失 without a kill; PHY-X / PHY-Y heal ×1.15; ISW-A
  attributes only.
- Triggers from the data: the 重装 exception (S2 / S3 ACTIVE_RANGE for 涤火杰西卡, DEFAULT for 机械师 — rawRule TAKE_DAMAGE),
  凯尔希's MANUAL skills DEFAULT (heal skills); 涤火杰西卡 S1 (AUTO, self / shield) SP_FULL by the owner's AUTO rule.
- OPERATOR_KIT_FILES: op-jesca2.js, op-mcnist.js, op-kalts.js.
Golden: only diy moved — diy-049 … diy-052 take the new pieces into their twelve, diy-053 … diy-058 are new (58 scenarios);
roster 49, bonds 46, fields 22, matches 17, standins 10 unchanged.
Tests: test/content/op_jesca2.test.js (10), op_mcnist.test.js (9), op_kalts.test.js (9).
2026-10-06 01:34:10 +08:00

356 lines
23 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_kalts.test.js — the 自选 operator kit of 凯尔希 (char_003_kalts, 6★ 医师; kit
// server/sim/content/kits/ops/op-kalts.js) and her summon Mon3tr (token_10002_kalts_mon3tr), fielded the production way (a
// DIY slot + its `diy` pick; Mon3tr 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, PHY-X Mon2tr, PHY-Y 医者 or ISW-A 凯尔希特限证章 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_kalts.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 KALTS = 'char_003_kalts';
const MON3TR = 'token_10002_kalts_mon3tr';
const FORMS = BACKUPS.units[KALTS].forms;
const TOKEN = BACKUPS.tokens[MON3TR];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PHYX = 'uniequip_002_kalts', PHYY = 'uniequip_003_kalts', ISWA = 'uniequip_004_kalts';
const S1 = 'skchr_kalts_1', S2 = 'skchr_kalts_2', S3 = 'skchr_kalts_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[`${KALTS}@${statusKey(tier, elite)}`];
const rattleOf = (tier, elite, mod) => ((elite && mod && variantOf(tier, elite).byModule?.[mod]?.talents) || variantOf(tier, elite).talents).find((t) => t.bb.value != null).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: 600, range: 1.2, bat: 1 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0, atk: 200, bat: 1 }),
enemy_weak: enemyRec({ key: 'enemy_weak', hp: 50, speed: 0, mass: 0 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PHYX, PHYY, ISWA].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 凯尔希 as uid 1 at (10, 2) facing RIGHT (`mon3tr`: her piece as uid 2), plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, mon3tr = { row: 10, col: 4 }, others = [], seed = 5, dp = 99 } = {}) {
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: KALTS, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 2 }];
if (mon3tr) units.push({ uid: 2, kind: 'token', tokenId: MON3TR, ownerUid: 1, row: mon3tr.row, col: mon3tr.col });
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', 'kill'], captureNoisy: true,
units: [...units, ...others],
});
h.step();
return { h, u: h.unit(1), m: mon3tr ? h.unit(2) : null };
}
const heals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.regen);
const rattles = (h, m) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes('kalts:rattle') && (c.source === m || c.credit === m));
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), stats + module attributes, 3-10, one heal per attack, blocks 1, 罗德岛, no 特质; her Mon3tr: the form\'s token stats, 禁疗, 1-1, blocks 3, melee ground-only physical', () => {
assert.equal(OPERATOR_KITS[KALTS], KITS[KALTS]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u, m } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), md = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [KALTS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd],
[form.stats.maxHp + (md?.attr.maxHp ?? 0), form.stats.atk + (md?.attr.atk ?? 0), form.stats.def + (md?.attr.def ?? 0), form.stats.aspd + (md?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.dmgType, u.profile.heal, u.s.blockCnt, u.base.bat], ['heal', { mode: 'single' }, 1, 2.85], `${label(f)}: 医师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-10`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [BACKUPS.diy.operators[KALTS].bonds, [], [MON3TR]], `${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 = variantOf(tier, elite).stats;
assert.deepEqual([m.base.maxHp, m.base.atk, m.base.def, m.s.blockCnt, m.base.bat, m.base.cost, m.base.respawnTime], [ts.maxHp, ts.atk, ts.def, 3, 2, 10, 25], `${label(f)}: Mon3tr stats`);
assert.deepEqual([m.s.flags.noHeal, m.profile.attack, m.profile.dmgType, m.profile.canHitFly, m.liveRangeGrid], [true, 'melee', 'phys', false, [[0, 0], [0, 1]]], `${label(f)}: Mon3tr 禁疗 / melee ground-only`);
done(h);
}
}
// E2 Lv1 1469 / 392 / 172, E2 Lv60 1578 / 457 / 201; PHY-X +35 / +15 → +60 / +25 ATK / DEF, PHY-Y +115 / +5 → +195 / +7
// HP / ASPD, ISW-A +24 / +5 → +50 / +7 ATK / ASPD; Mon3tr 4292 / 1149 / 336 → 5048 / 1317 / 382
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], [1469, 392, 1578, 457]);
assert.deepEqual([modOf(5, PHYX).attr, modOf(6, PHYX).attr, modOf(5, PHYY).attr, modOf(6, PHYY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
[{ atk: 35, def: 15 }, { atk: 60, def: 25 }, { maxHp: 115, aspd: 5 }, { maxHp: 195, aspd: 7 }, { atk: 24, aspd: 5 }, { atk: 50, aspd: 7 }]);
assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(5, false).stats.atk, variantOf(6, true).stats.maxHp], [4292, 1149, 5048]);
});
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(KALTS));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(KALTS), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: KALTS, skillIndex: 1, uniEquipId: PHYX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: KALTS, skillIndex: 1, uniEquipId: PHYX } } });
});
test('T1 heal order: herself and her Mon3tr first when injured (the lower HP ratio; ties: herself), else the lowest ratio; her heals pass Mon3tr\'s 禁疗, another medic never heals it; with no module an operator Mon3tr is first too', () => {
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 5 }, { uid: 4, chessId: 'chess_char_2_02_a', row: 11, col: 3 }];
const { h, u, m } = field({ skill: 0, others });
const yak = h.unit(3), silent = h.unit(4);
const at = (x, r) => { x.hp = x.s.maxHp * r; };
at(yak, 0.3); at(m, 0.6);
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, 'Mon3tr (60 %) before 角峰 (30 %)');
approx(heals(h, u)[n0].amount, u.s.atk, 'a full heal through its 禁疗');
assert.equal(h.hooksOf('heal').filter((c) => c.source === silent && c.target === m).length, 0, '赫默 never heals Mon3tr');
// herself at the same ratio: herself
at(m, 0.5); at(u, 0.5);
const n1 = heals(h, u).length;
assert.ok(h.runUntil(() => heals(h, u).length > n1, 4));
assert.equal(heals(h, u)[n1].target, u, 'a tie: herself');
// both full: the lowest ratio of the rest
at(m, 1); at(u, 1); at(yak, 0.2);
const n2 = heals(h, u).length;
assert.ok(h.runUntil(() => heals(h, u).length > n2, 4));
assert.equal(heals(h, u)[n2].target, yak, 'then 角峰');
done(h);
// the operator Mon3tr (char_4179_monstr): first with no module, an ordinary target with one
for (const [elite, mod, first] of [[false, null, true], [true, PHYY, false]]) {
const opM = [{ uid: 3, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_4179_monstr', skillIndex: 0 }, row: 9, col: 4, dir: 'LEFT' },
{ uid: 4, chessId: 'chess_char_1_02_a', row: 11, col: 4 }];
const r = field({ tier: 6, elite, mod, skill: 0, mon3tr: null, others: opM });
const om = r.h.unit(3), y = r.h.unit(4);
assert.equal(om.def.charId, 'char_4179_monstr');
om.hp = om.s.maxHp * 0.7; y.hp = y.s.maxHp * 0.4;
const k0 = heals(r.h, r.u).length;
assert.ok(r.h.runUntil(() => heals(r.h, r.u).length > k0, 4));
assert.equal(heals(r.h, r.u)[k0].target, first ? om : y, `${mod ?? 'no module'}: ${first ? 'the operator Mon3tr' : '角峰'} first`);
done(r.h);
}
});
test('T1 Mon3tr: DEF ×0 off her range (PHY-Y stage 3: inside ASPD +20, DEF +20 %); withdrawn when she leaves (no blast), back 25 s after it left paying 10 DP, only while she stands; her redeployment readies it at once', () => {
for (const f of [[5, false, null], [6, true, PHYY], [5, true, PHYY]]) {
const [tier, elite, mod] = f;
const t0 = (elite && mod && modOf(tier, mod).talentChanges.find((x) => x.talentIndex === 0)?.bb) || formOf(tier, elite).talents.find((x) => x.index === 0).bb;
const y3 = tier === 6 && mod === PHYY;
assert.deepEqual([t0.def, t0.attack_speed], y3 ? [0.2, 20] : [0, 0], label(f));
const { h, u, m } = field({ tier, elite, mod, skill: 0 });
approx(m.s.def, m.base.def * (1 + t0.def), `${label(f)}: in her range`);
approx(m.s.aspd, m.base.aspd + t0.attack_speed, `${label(f)}: ASPD`);
const off = field({ tier, elite, mod, skill: 0, mon3tr: { row: 10, col: 1 } }); // behind her: off her 3-10
assert.equal(off.m.s.def, 0, `${label(f)}: DEF 0 off her range`);
approx(off.m.s.aspd, off.m.base.aspd, `${label(f)}: no bonus off her range`);
done(off.h);
// she leaves: it is withdrawn (no 不毁重构), then waits for her
h.spawn('enemy_dummy', { pos: [10, 5] });
h.b.kill(u, null);
h.step();
assert.ok(!m.alive && !m.removed, `${label(f)}: withdrawn with her`);
assert.equal(h.hooksOf('death').find((c) => c.unit === m)?.reason, 'retreat', label(f));
assert.equal(rattles(h, m).length, 0, `${label(f)}: no blast on a withdrawal`);
assert.ok(h.runUntil(() => u.alive, 90), `${label(f)}: she redeploys`);
const back = h.b.time;
assert.ok(h.runUntil(() => m.alive, 0.2), `${label(f)}: Mon3tr at once`);
assert.ok(h.b.time - back < 0.15, label(f));
// killed: back 25 s later paying 10 DP
const dp0 = h.b.players[0].dp;
h.b.kill(m, null);
h.run(24.5);
assert.ok(!m.alive, `${label(f)}: not before 25 s`);
h.run(0.7);
assert.ok(m.alive, `${label(f)}: back`);
approx(h.b.players[0].dp, dp0 - 10, `${label(f)}: 10 DP`);
done(h);
}
});
test('T2 不毁重构: Mon3tr knocked out ⇒ every enemy of its 3×3 (air units too) takes 1200 true damage (溅射) and a 3 s stun; PHY-X stage 3: 1500 / 3.5 s, and once per deployment when a hit leaves it at or below half HP', () => {
for (const f of [[5, false, null], [6, true, PHYX], [5, true, PHYX], [6, true, PHYY]]) {
const [tier, elite, mod] = f;
const rb = rattleOf(tier, elite, mod);
const x3 = tier === 6 && mod === PHYX;
assert.deepEqual([rb.value, rb.stun, rb.hp_ratio ?? 0], x3 ? [1500, 3.5, 0.5] : [1200, 3, 0], label(f));
const { h, m } = field({ tier, elite, mod, skill: 0 });
const e1 = h.spawn('enemy_dummy', { pos: [11, 5] }), e2 = h.spawn('enemy_fly', { pos: [9, 3] }), far = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step(); // the enemies on the tile index
if (x3) {
// a hit to 40 %: the half-HP blast, once
h.b.dealDamage(null, m, { amount: m.s.maxHp * 0.6, type: 'true' });
assert.equal(rattles(h, m).length, 2, `${label(f)}: the half-HP blast`);
h.b.dealDamage(null, m, { amount: 1, type: 'true' });
assert.equal(rattles(h, m).length, 2, `${label(f)}: once per deployment`);
}
const n0 = rattles(h, m).length;
h.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
assert.ok(!m.alive, label(f));
const r = rattles(h, m).slice(n0);
assert.deepEqual(r.map((c) => c.target.id).sort(), [e1.id, e2.id].sort(), `${label(f)}: its 3×3, the flyer too`);
for (const c of r) { approx(c.amount, rb.value, `${label(f)}: ${rb.value} true`); assert.deepEqual([c.type, c.dmg.isSplash], ['true', true]); }
const st = h.hooksOf('statusApplied').filter((c) => c.status === 'stun' && c.target !== m);
assert.ok(st.length >= 2 && st.every((c) => Math.abs(c.duration - rb.stun) < 1e-6), `${label(f)}: ${rb.stun} s`);
assert.ok(!far.findBuff('stun'), `${label(f)}: two tiles away`);
done(h);
}
});
test('S1 指令:结构加固 (MANUAL, data DEFAULT — cast as she is about to heal): 33 / 36 s, her DEF +90 % / +120 % and Mon3tr\'s; 物理格挡 30 % / 40 %: a physical instance blocked whole (arts never)', () => {
for (const f of [[5, false, null], [6, true, PHYX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.trigger.rule, sk.duration, sk.bb.def, sk.bb['attack@def'], sk.bb.prob], ['DEFAULT', elite ? 36 : 33, elite ? 1.2 : 0.9, elite ? 1.2 : 0.9, elite ? 0.4 : 0.3], label(f));
const { h, u, m } = field({ tier, elite, mod, skill: 0, seed: 23 });
u.skill.gainSp(999);
h.run(3);
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured, no cast`);
m.hp = m.s.maxHp * 0.5;
assert.ok(h.runUntil(() => u.skill.active, 4), `${label(f)}: cast on her heal`);
approx(u.skill.timeLeft, sk.duration, label(f), 0.01);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `${label(f)}: her DEF`);
h.step();
approx(m.s.def, m.base.def * (1 + sk.bb['attack@def']), `${label(f)}: Mon3tr DEF`);
let blocked = 0;
const N = 400;
for (let i = 0; i < N; i++) {
if (h.b.dealDamage(null, u, { amount: 1000, type: 'phys', canDodge: false }) === 0) blocked++;
u.hp = u.s.maxHp;
}
assert.ok(Math.abs(blocked / N - sk.bb.prob) < 0.07, `${label(f)}: ${blocked} / ${N} ≈ ${sk.bb.prob}`);
let artsBlocked = 0;
for (let i = 0; i < 100; i++) { if (h.b.dealDamage(null, u, { amount: 10, type: 'arts', canDodge: false }) === 0) artsBlocked++; u.hp = u.s.maxHp; }
assert.equal(artsBlocked, 0, `${label(f)}: arts never`);
h.runUntil(() => !u.skill.active, 40);
h.step();
approx(m.s.def, m.base.def, `${label(f)}: Mon3tr DEF back`);
done(h);
}
});
test('S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +45 / +60, Mon3tr ATK +35 % / +50 % and each attack strikes every enemy it blocks; no Mon3tr: no cast, SP emptied and frozen; it leaving ends the skill', () => {
for (const f of [[5, false, null], [6, true, PHYY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.trigger.rule, sk.duration, sk.bb.attack_speed, sk.bb['attack@atk']], ['DEFAULT', elite ? 17 : 16, elite ? 60 : 45, elite ? 0.5 : 0.35], label(f));
const { h, u, m } = field({ tier, elite, mod, skill: 1, mon3tr: { row: 9, col: 5 } });
const w1 = h.spawn('enemy_walk', { pos: [9, 6], routeIndex: 0 }), w2 = h.spawn('enemy_walk', { pos: [9, 7], routeIndex: 0 });
assert.ok(h.runUntil(() => m.blocking.length === 2, 20), `${label(f)}: Mon3tr blocks both`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 6), `${label(f)}: cast on a heal of Mon3tr`);
approx(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `${label(f)}: her ASPD`);
h.step();
approx(m.s.atk, m.base.atk * (1 + sk.bb['attack@atk']), `${label(f)}: Mon3tr ATK`);
const n0 = h.hooksOf('damaged').length;
h.run(4.5);
const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === m && c.dmg?.isAttack);
const ids = new Map();
for (const c of hits) ids.set(c.dmg.attackId, (ids.get(c.dmg.attackId) ?? new Set()).add(c.target.id));
assert.ok(ids.size >= 2 && [...ids.values()].every((s) => s.size === 2 && s.has(w1.id) && s.has(w2.id)), `${label(f)}: every attack on both`);
// Mon3tr killed: the skill ends within 0.1 s, the SP is emptied and recovers no more until it is back
h.b.kill(m, null);
h.run(0.15);
assert.ok(!u.skill.active, `${label(f)}: ended (绑定)`);
assert.equal(u.skill.sp, 0, label(f));
h.run(5);
assert.equal(u.skill.sp, 0, `${label(f)}: no SP without Mon3tr`);
assert.ok(u.findBuff('kalts:binding'), label(f));
m.mem.readyAt = h.b.time;
assert.ok(h.runUntil(() => m.alive, 1), label(f));
h.run(2);
assert.ok(u.skill.sp > 1.5, `${label(f)}: SP again with Mon3tr back`);
done(h);
}
// no Mon3tr piece at all: S2 never gains SP, never casts
const { h, u } = field({ skill: 1, mon3tr: null, others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }] });
h.unit(3).hp = 100;
h.run(30);
assert.deepEqual([u.skill.sp, u.skill.activations], [0, 0], 'no Mon3tr: no SP');
done(h);
});
test('S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +120 % / +140 %, ATK +160 % / +190 % down by 1/18 every second, true damage; no kill ⇒ 50 % max HP 流失 at the end (it may knock it out: 不毁重构); a kill ⇒ none', () => {
for (const f of [[5, false, null], [6, true, ISWA]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.trigger.rule, sk.duration, sk.bb['attack@atk'], sk.bb['attack@def'], sk.bb['attack@hp_ratio']], ['DEFAULT', 18, elite ? 1.9 : 1.6, elite ? 1.4 : 1.2, 0.5], label(f));
const { h, u, m } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_biter', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 6), `${label(f)}: cast`);
const cast = h.hooksOf('skillStart').find((c) => c.unit === u).t;
h.step();
approx(m.s.def, m.base.def * (1 + sk.bb['attack@def']), `${label(f)}: DEF`);
approx(m.s.atk, m.base.atk * (1 + sk.bb['attack@atk']), `${label(f)}: ATK at the cast`);
h.run(cast + 5.5 - h.b.time);
approx(m.s.atk, m.base.atk * (1 + sk.bb['attack@atk'] * (1 - 5 / 18)), `${label(f)}: 5 s on`);
const hits = h.hooksOf('damaged').filter((c) => c.source === m && c.target === e && c.dmg?.isAttack && c.t >= cast);
assert.ok(hits.length > 0 && hits.every((c) => c.type === 'true'), `${label(f)}: true damage`);
assert.ok(h.runUntil(() => !u.skill.active, 15), label(f));
assert.equal(h.hooksOf('skillEnd').find((c) => c.unit === u)?.reason, 'duration', label(f));
const loss = h.hooksOf('damaged').filter((c) => c.target === m && c.dmg?.tags?.includes('kalts:meltdown'));
assert.equal(loss.length, 1, `${label(f)}: the 流失 (it killed nothing: the biter has 1e9 HP)`);
approx(loss[0].amount, m.s.maxHp * sk.bb['attack@hp_ratio'], `${label(f)}: 50 % max HP`);
assert.ok(loss[0].dmg.tags.includes('hpLoss'), `${label(f)}: a 流失`);
h.step();
approx(m.s.atk, m.base.atk, `${label(f)}: ATK back`);
done(h);
}
// the loss knocks it out: 不毁重构
{
const { h, u, m } = field({ skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 5] });
const near = h.spawn('enemy_dummy', { pos: [11, 4] });
m.hp = m.s.maxHp * 0.3;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 6));
// her heals keep it up meanwhile: set it low just before the end
assert.ok(h.runUntil(() => u.skill.timeLeft < 0.05, 20));
m.hp = m.s.maxHp * 0.3;
assert.ok(h.runUntil(() => !u.skill.active, 1));
assert.ok(!m.alive, 'knocked out by the 流失');
assert.equal(h.hooksOf('death').find((c) => c.unit === m)?.reason, 'killed');
assert.ok(rattles(h, m).some((c) => c.target === near), '不毁重构');
done(h);
}
// a kill keeps its HP
{
const { h, u, m } = field({ skill: 2 });
h.spawn('enemy_weak', { pos: [10, 5] });
m.hp = m.s.maxHp * 0.9;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 6));
assert.ok(h.runUntil(() => h.hooksOf('kill').some((c) => c.killer === m), 10), 'Mon3tr kills');
assert.ok(h.runUntil(() => !u.skill.active, 20));
assert.equal(h.hooksOf('damaged').filter((c) => c.target === m && c.dmg?.tags?.includes('kalts:meltdown')).length, 0, 'no 流失 after a kill');
done(h);
}
});
test('modules: PHY-X ×1.15 on a heal of a target at or below 50 %; PHY-Y ×1.15 on a heal of a target on a 地面 tile; ISW-A (集成战略-only trait) and no module: plain heals', () => {
for (const f of [[5, true, PHYX], [6, true, PHYX], [5, true, PHYY], [6, true, PHYY], [6, true, ISWA], [6, true, null]]) {
const [tier, elite, mod] = f;
const md = modOf(tier, mod);
if (md) assert.deepEqual([md.traitOverride.bb.heal_scale, md.traitOverride.bb.hp_ratio ?? null], [1.15, mod === PHYY ? null : 0.5], label(f));
// 角峰 on a 地面 tile (10, 4), 德克萨斯 on high ground (11, 2)
const { h, u } = field({ tier, elite, mod, skill: 0, mon3tr: null, others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 11, col: 2 }] });
const yak = h.unit(3), tex = h.unit(4);
assert.deepEqual([yak.ground, tex.ground], [true, false]);
const healOf = (x, r) => {
for (const o of [yak, tex]) o.hp = o.s.maxHp;
x.hp = x.s.maxHp * r;
const n = heals(h, u).length;
assert.ok(h.runUntil(() => heals(h, u).length > n, 4), label(f));
return heals(h, u)[n];
};
const a = healOf(yak, 0.45), b = healOf(yak, 0.8), c = healOf(tex, 0.45);
const atk = u.s.atk;
const want = (t, r) => atk * ((mod === PHYX && r <= 0.5) || (mod === PHYY && t.ground) ? 1.15 : 1);
approx(a.amount, want(yak, 0.45), `${label(f)}: 地面, 45 %`);
approx(b.amount, want(yak, 0.8), `${label(f)}: 地面, 80 %`);
approx(c.amount, want(tex, 0.45), `${label(f)}: high ground, 45 %`);
done(h);
}
});