shared/diy.js DIY_EXCLUDED_MODULE_TYPE = /^(?:ISW|SO|RA)-/: modules whose own effect works in
another game mode only ("在【岁的界园志异】中", "在【沉沦者的黑流树海】中", "在生息演算中"; the
excel gates those parts roguelike / sandbox) — 电弧 SO-A / SO-B, 机械师 SO-A, 森蚺 RA-A
[ASSUMED, the ISW-A reason]. validateDiyPicks refuses them, checkDiyPicks drops such a pick,
the picker hides them; the record and the kits still field them. Docs: DATA, PLAYING,
DESIGN §8. Golden: no scenario moved.
310 lines
20 KiB
JavaScript
310 lines
20 KiB
JavaScript
// test/content/op_radian.test.js — the 自选 operator kit of 电弧 (char_4195_radian, 6★ 召唤师; kit
|
||
// server/sim/content/kits/ops/op-radian.js, the summon deck of kits/shared/summoner.js) and of her summons 戴乌 / 赛柯 /
|
||
// 桑特拉, fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the summons as the placed hand pieces of her
|
||
// player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module,
|
||
// SO-A 电弧特勤证章 or SO-B 新起点 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of
|
||
// kits/README.md.
|
||
// Run: node --test test/content/op_radian.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 RAD = 'char_4195_radian';
|
||
const FORMS = BACKUPS.units[RAD].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const SOA = 'uniequip_002_radian', SOB = 'uniequip_003_radian';
|
||
const S1 = 'skchr_radian_1', S2 = 'skchr_radian_2', S3 = 'skchr_radian_3';
|
||
const DIVE = 'token_10051_radian_tower1', CYCLE = 'token_10052_radian_tower2', CENTRE = 'token_10053_radian_tower3';
|
||
const TOWER_OF = [DIVE, CYCLE, CENTRE];
|
||
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
|
||
const formOf = (tier, elite) => FORMS[statusOf(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 tokOf = (tok, tier, elite, skill, mod) => {
|
||
let v = BACKUPS.tokens[tok].variants[`${RAD}@${statusOf(tier, elite)}`];
|
||
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
|
||
if (mod && v.byModule?.[mod]) v = { ...v, ...v.byModule[mod] };
|
||
return v;
|
||
};
|
||
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' }) };
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOA, SOB].map((m) => [t, true, m]))];
|
||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||
|
||
/** 电弧 as uid 1 at (row, col) facing RIGHT; `towers` = [[tokenId, row, col, dir?]] placed pieces (uid 10 +). */
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, towers = [], row = 10, col = 3, seed = 5, dp = 99 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, captureNoisy: true,
|
||
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack'],
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: RAD, skillIndex: skill, uniEquipId: mod }, elite, row, col },
|
||
...towers.map(([tokenId, r, c, dir], i) => ({ uid: 10 + i, kind: 'token', tokenId, ownerUid: 1, row: r, col: c, dir: dir ?? 'RIGHT' }))],
|
||
});
|
||
h.step();
|
||
const u = h.unit(1);
|
||
return { h, u, ts: h.b.allyUnits.filter((a) => a.kind === 'token'), deck: u.mem.summonDeck };
|
||
}
|
||
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
|
||
test('电弧 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-1, ranged arts, hits air, blocks 1, ground-targetable, the data triggers (DEFAULT), 协防, no 特质', () => {
|
||
assert.equal(OPERATOR_KITS[RAD], KITS[RAD]);
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
for (const skill of [0, 1, 2]) {
|
||
const { h, u } = field({ tier, elite, mod, skill });
|
||
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], [RAD, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.maxTargets, u.base.bat], [1, 'ranged', 'arts', true, 1, 1.6], `${label(f)}: 召唤师 (SO-B's 额外攻击一个目标 is 集成战略-only)`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||
assert.deepEqual([u.skill.rule, form.skills[skill].trigger.rule], ['DEFAULT', 'DEFAULT'], `${label(f)}: the data's trigger`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
|
||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||
done(h);
|
||
}
|
||
}
|
||
// the numbers of the forms (zh_CN): E2 Lv1 844 / 400 / 118, E2 Lv60 993 / 444 / 135; SO-A +125 / +27 → +165 / +43 HP / ATK,
|
||
// SO-B +41 → +65 ATK
|
||
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], [844, 400, 993, 444]);
|
||
assert.deepEqual([modOf(5, SOA).attr, modOf(6, SOA).attr, modOf(5, SOB).attr, modOf(6, SOB).attr], [{ maxHp: 125, atk: 27 }, { maxHp: 165, atk: 43 }, { atk: 41 }, { atk: 65 }]);
|
||
});
|
||
|
||
test('电弧\'s skills in every form: the data\'s SP (time SP, spCost, initSp, charges) and her 3-1 range while each runs (none changes it)', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
for (const skill of [0, 1, 2]) {
|
||
const sk = formOf(tier, elite).skills[skill];
|
||
const { h, u } = field({ tier, elite, mod, skill });
|
||
assert.deepEqual([u.skill.spType, u.skill.spCost, u.skill.maxCharges], ['time', sk.spCost, sk.maxChargeTime], `${label(f)} S${skill + 1}: SP`);
|
||
assert.ok(Math.abs(u.skill.sp - sk.initSp) < 0.1, `${label(f)} S${skill + 1}: initSp ${sk.initSp} (${u.skill.sp})`);
|
||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), `${label(f)} S${skill + 1}: cast with an enemy in her range`);
|
||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)} S${skill + 1}: 3-1 while it runs`);
|
||
done(h);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('a 自选 pick: 电弧 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks — without a module: the 岁的界园志异 modules SO-A / SO-B are refused like ISW-A (0.2.0 WE2)', () => {
|
||
const data = { chess: CHESS, backups: BACKUPS };
|
||
assert.ok(KITTED_CHARS.includes(RAD));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(RAD), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId: null } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId: null } } });
|
||
for (const uniEquipId of [SOA, SOB]) {
|
||
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } }, { data, kitted: KITTED_CHARS }).error, 'BAD_TARGET', uniEquipId);
|
||
}
|
||
});
|
||
|
||
test('T1 卡带里的灵感: her skill\'s summon is her piece; the deck — 5 held, cap 5, at most 3 standing; every summon holds 禁疗 and fights with its data (戴乌 0-1 melee physical blocks 3, 赛柯 blocks 2 and fires straight ahead, 桑特拉 3-1 arts hits air)', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
for (const skill of [0, 1, 2]) {
|
||
const tok = TOWER_OF[skill];
|
||
const { h, u, ts, deck } = field({ tier, elite, mod, skill, towers: [[tok, 10, 6]] });
|
||
const t = ts[0], v = tokOf(tok, tier, elite, skill, mod);
|
||
assert.ok(h.b.producesToken(u, tok) && TOWER_OF.filter((x) => x !== tok).every((x) => !h.b.producesToken(u, x)), `${label(f)} S${skill + 1}: only ${tok}`);
|
||
assert.deepEqual([deck.charge, deck.cap, deck.held, deck.maxDeployed], [5, 5, 5, 3], `${label(f)} S${skill + 1}: deck`);
|
||
assert.deepEqual([t.base.maxHp, t.base.atk, t.base.def, t.base.res, t.base.cost, t.base.respawnTime, t.base.bat], [v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.res, v.stats.cost, v.stats.respawnTime, v.stats.bat], `${label(f)}: summon stats (no module change)`);
|
||
assert.deepEqual([t.alive, !!t.s.flags.noHeal, t.kit.skill], [true, true, null], `${label(f)}: deployed, 禁疗, no skill`);
|
||
const want = { [DIVE]: [3, 'melee', 'phys', false, false], [CYCLE]: [2, 'melee', 'phys', false, true], [CENTRE]: [1, 'ranged', 'arts', true, false] }[tok];
|
||
assert.deepEqual([t.s.blockCnt, t.profile.attack, t.profile.dmgType, t.profile.canHitFly, !!t.profile.noAttack], want, `${label(f)}: ${tok}`);
|
||
done(h);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('T2 加油~: her summons on the field get 鼓舞 = 12 % of HER current ATK / DEF / max HP (PRTS 修正 电弧自身), added after their own multipliers, refreshed every second; the strongest 鼓舞 of a kind is kept', () => {
|
||
for (const f of [[5, false, null], [6, true, SOA]]) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u, ts } = field({ tier, elite, mod, skill: 0, towers: [[DIVE, 10, 6]] });
|
||
const t = ts[0];
|
||
assert.deepEqual(formOf(tier, elite).talents.find((x) => x.index === 1).bb, { max_hp: 0.12, def: 0.12, atk: 0.12 });
|
||
approx(t.s.atk, t.base.atk + 0.12 * u.s.atk, `${label(f)}: ATK`);
|
||
approx(t.s.def, t.base.def + 0.12 * u.s.def, `${label(f)}: DEF`);
|
||
approx(t.s.maxHp, t.base.maxHp + 0.12 * u.s.maxHp, `${label(f)}: max HP`);
|
||
// her ATK rises: the 鼓舞 follows within a second
|
||
h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } });
|
||
h.run(1.05);
|
||
approx(t.s.atk, t.base.atk + 0.12 * u.s.atk, 'updated every second');
|
||
// a summon's own ATK +% does not scale it
|
||
h.b.addBuff(t, { key: 'test:tatk', mods: { atkPct: 0.5 } });
|
||
h.run(1.05);
|
||
approx(t.s.atk, t.base.atk * 1.5 + 0.12 * u.s.atk, 'after its multipliers');
|
||
// a stronger 鼓舞 of another source stays
|
||
h.b.addBuff(t, { key: 'inspire', mods: { atkFlat: 5000 }, duration: 30, data: { src: -1, val: 5000 } });
|
||
h.run(1.05);
|
||
assert.equal(t.findBuff('inspire').data.val, 5000, 'the strongest kept');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S1 律动线: +1 held; she and her summons DEF +40 % / +60 % and a 屏障 of 30 % / 70 % of their max HP until it ends (25 s; it stops every type but 元素伤害); a summon deployed during it takes it at its deployment with its 鼓舞 HP', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, SOB]]) {
|
||
const sk = skillOf(tier, elite, S1);
|
||
const { h, u, ts, deck } = field({ tier, elite, mod, skill: 0, towers: [[DIVE, 10, 6], [DIVE, 11, 6]] });
|
||
const [t, late] = ts;
|
||
assert.deepEqual([sk.bb.hp_ratio, sk.bb.def, sk.bb.cnt, sk.duration], [elite ? 0.7 : 0.3, elite ? 0.6 : 0.4, 1, 25]);
|
||
h.b.kill(late, null);
|
||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||
deck.held = 0;
|
||
const def0 = u.s.def;
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
|
||
assert.equal(deck.held, 1, '技能开启时获得1个召唤物');
|
||
approx(u.s.def, def0 * (1 + sk.bb.def), 'her DEF');
|
||
assert.deepEqual(t.findBuff('radian:s1:tower')?.mods, { defPct: sk.bb.def }, 'its DEF');
|
||
const bu = u.findBuff('radian:s1:barrier'), bt = t.findBuff('radian:s1:barrier');
|
||
approx(bu.shield, u.s.maxHp * sk.bb.hp_ratio, 'her 屏障');
|
||
approx(bt.shield, t.s.maxHp * sk.bb.hp_ratio, 'its 屏障');
|
||
assert.deepEqual(bt.shieldType, ['phys', 'arts', 'true'], 'not 元素伤害');
|
||
// the knocked-out one comes back during the skill: its 屏障 counts its 鼓舞 HP
|
||
assert.ok(h.runUntil(() => late.alive, 21), 'back during the skill');
|
||
approx(late.findBuff('radian:s1:barrier').shield, (late.base.maxHp + 0.12 * u.s.maxHp) * sk.bb.hp_ratio, 'with 鼓舞 HP');
|
||
h.runUntil(() => !u.skill.active, 30);
|
||
assert.ok([u, t, late].every((x) => !x.findBuff('radian:s1:barrier')), 'gone at its end');
|
||
assert.equal(t.findBuff('radian:s1:tower'), null, 'DEF back');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('赛柯 (S2\'s summon): picks no target — every attack interval it fires a bullet straight ahead (5 tiles/s, 0.6 s, radius 0.5) at the first ground enemy it touches (100 % ATK physical 普通伤害), none at air units or farther than 3 tiles; stunned: no shot', () => {
|
||
const { h, u, ts } = field({ tier: 5, elite: false, skill: 1, towers: [[CYCLE, 10, 5]] });
|
||
const t = ts[0];
|
||
const near = h.spawn('enemy_dummy', { pos: [10, 7] }), behind = h.spawn('enemy_dummy', { pos: [10, 7.4] });
|
||
const fly = h.spawn('enemy_fly', { pos: [10, 6] }), far = h.spawn('enemy_dummy', { pos: [11, 9] });
|
||
h.run(4);
|
||
const hits = from(h, t);
|
||
assert.equal(hits.length, 3, '3 shots landed in 4 s (one at its deployment, then every 1.8 s; 0.3 s of flight)');
|
||
assert.ok(hits.every((c) => c.target === near), 'the first ground enemy ahead only');
|
||
for (const c of hits) assert.deepEqual([c.type, c.dmg.isAttack, Math.abs(c.amount - t.s.atk) < 1e-6], ['phys', true, true]);
|
||
void behind; void fly; void far;
|
||
// nothing past its 3 tiles
|
||
near.hp = 0; h.b.kill(near, null); behind.hp = 0; h.b.kill(behind, null);
|
||
const out = h.spawn('enemy_dummy', { pos: [10, 8.6] });
|
||
const n0 = from(h, t).length;
|
||
h.run(4);
|
||
assert.equal(from(h, t).length, n0, 'out of reach (3 tiles of flight + 0.5 radius)');
|
||
void out;
|
||
// stunned: no shot
|
||
const close = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
h.b.applyStatus(t, 'stun', { duration: 5, force: true });
|
||
const n1 = from(h, t).length;
|
||
h.run(4);
|
||
assert.equal(from(h, t).length, n1, 'no shot while stunned');
|
||
h.run(3);
|
||
assert.ok(from(h, t).some((c) => c.target === close), 'shoots again');
|
||
void u;
|
||
done(h);
|
||
});
|
||
|
||
test('S2 环形鳞地: +1 held; she and her summons ATK +35 % / +50 % for 25 s; 赛柯 fires three bullets per attack that fly 0.6 / 0.8 s (rank 4 / 7: "子弹飞行距离+1")', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, SOA]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const life = tokOf(CYCLE, tier, elite, 1, mod).skill.bb['attack@projectile_life_time'];
|
||
assert.deepEqual([sk.bb.atk, sk.bb.base_attack_time, life], [elite ? 0.5 : 0.35, 0, elite ? 0.8 : 0.6]);
|
||
const { h, u, ts, deck } = field({ tier, elite, mod, skill: 1, towers: [[CYCLE, 10, 5]] });
|
||
const t = ts[0];
|
||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||
const reach = h.spawn('enemy_dummy', { pos: [10, 5 + 3.8] }); // 3.8 tiles ahead: past 0.6 s + 0.5, inside 0.8 s + 0.5
|
||
deck.held = 0;
|
||
const atk0 = u.s.atk, tatk0 = t.s.atk;
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
|
||
assert.equal(deck.held, 1, '+1 held');
|
||
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
|
||
approx(t.s.atk, tatk0 * (1 + sk.bb.atk), 'its ATK');
|
||
const n0 = from(h, t).length;
|
||
h.run(3.7);
|
||
const hits = from(h, t).slice(n0);
|
||
if (elite) {
|
||
assert.ok(hits.length >= 3 && hits.length % 3 === 0, `three bullets per attack: ${hits.length}`);
|
||
assert.ok(hits.every((c) => c.target === reach), 'they reach 3.8 tiles');
|
||
} else assert.equal(hits.length, 0, 'rank 4: 0.6 s — 3.8 tiles is out of reach');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 手牵手: +1 held; she and her summons ATK +80 % / +110 % for 30 s; before each damage of her attacks and of her summons on an enemy: 停顿 2 s and 法术脆弱 10 % / 20 % 2 s', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, SOB]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
const { h, u, ts, deck } = field({ tier, elite, mod, skill: 2, towers: [[CENTRE, 11, 5]] });
|
||
const t = ts[0];
|
||
assert.deepEqual([sk.bb.atk, sk.bb.sluggish, sk.bb['weak[magic][limit]'], sk.bb.damage_scale, sk.duration], [elite ? 1.1 : 0.8, 2, 2, elite ? 1.2 : 1.1, 30]);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
h.run(3);
|
||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.status === 'artsFragile').length, 0, 'nothing before it');
|
||
deck.held = 0;
|
||
const atk0 = u.s.atk, tatk0 = t.s.atk;
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
|
||
assert.equal(deck.held, 1, '+1 held');
|
||
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
|
||
approx(t.s.atk, tatk0 * (1 + sk.bb.atk), 'its ATK');
|
||
h.run(3);
|
||
const st = h.hooksOf('statusApplied').filter((c) => c.target === e);
|
||
assert.ok(st.some((c) => c.status === 'sluggish' && c.source === u) && st.some((c) => c.status === 'sluggish' && c.source === t), '停顿 from both');
|
||
const frag = st.filter((c) => c.status === 'artsFragile');
|
||
assert.ok(frag.length && frag.every((c) => Math.abs(c.value - (sk.bb.damage_scale - 1)) < 1e-9 && Math.abs(c.duration - 2) < 1e-9), '法术脆弱');
|
||
// before the damage: a hit of hers right after it is ×(1 + fragile)
|
||
const hit = from(h, u).filter((c) => c.dmg.isAttack).at(-1);
|
||
approx(hit.amount, u.s.atk * (1 + sk.bb.damage_scale - 1), 'her hit carries it (RES 0)');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('桑特拉 协同攻击: when one attacks, another of the field with no target of its own, its attack ready and the first inside its range shoots a link that strikes the first one\'s target (100 % of ITS ATK, arts), then asks the others; not one with a target, nor one out of range', () => {
|
||
// a sees e; b has a in its range; c has b (not a) in its range — it answers b's request in turn; d sees none of them
|
||
const { h, ts } = field({ tier: 6, elite: true, skill: 2, towers: [[CENTRE, 10, 5], [CENTRE, 11, 5], [CENTRE, 12, 3], [CENTRE, 12, 9]] });
|
||
const [a, b, c, d] = ts;
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // a's range only
|
||
h.run(4);
|
||
const links = h.hooksOf('damaged').filter((x) => x.dmg?.tags?.includes('radian:link'));
|
||
assert.ok(links.length >= 4, `links: ${links.length}`);
|
||
assert.ok(links.every((x) => x.target === e), 'the first one\'s target');
|
||
assert.ok(links.some((x) => x.source === b), 'b (a inside its range) answers');
|
||
assert.ok(links.some((x) => x.source === c), 'c answers b\'s request (the chain)');
|
||
for (const x of links) assert.deepEqual([x.type, x.dmg.isAttack, Math.abs(x.amount - x.source.s.atk) < 1e-6], ['arts', true, true], 'its own ATK, arts');
|
||
assert.ok(!links.some((x) => x.source === d), 'd (none of them in its range) does not');
|
||
const nb = links.filter((x) => x.source === b).length, na = h.hooksOf('attack').filter((x) => x.attacker === a).length;
|
||
assert.ok(nb <= na && nb >= na - 1, `at most one link per attack of a (b answers when its own attack is ready): ${nb} / ${na}`);
|
||
// b with a target of its own attacks that one instead
|
||
const mine = h.spawn('enemy_dummy', { pos: [12, 6] });
|
||
const n0 = links.length;
|
||
h.run(4);
|
||
const after = h.hooksOf('damaged').filter((x) => x.dmg?.tags?.includes('radian:link')).slice(n0);
|
||
assert.ok(!after.some((x) => x.source === b), 'b busy with its own target');
|
||
assert.ok(from(h, b).some((x) => x.target === mine && !x.dmg.tags?.includes('radian:link')), 'b attacks it');
|
||
done(h);
|
||
});
|
||
|
||
test('modules SO-A / SO-B: only their attributes act — the 集成战略 parts the record carries (SO-A\'s respawn_time / sp, SO-B\'s attack@max_target and the x-5 recall) change nothing: same deck, one target, the summons\' redeploy time and no SP when one leaves', () => {
|
||
for (const mod of [SOA, SOB]) {
|
||
const { h, u, ts, deck } = field({ tier: 6, elite: true, mod, skill: 0, towers: [[DIVE, 10, 4]] });
|
||
const t = ts[0];
|
||
assert.ok(u.def.raw.talents.some((x) => x.index === -1), `${mod}: the record carries its hidden 集成战略 talent`);
|
||
assert.deepEqual([deck.charge, deck.cap, deck.maxDeployed, u.profile.maxTargets, u.s.maxTargets], [5, 5, 3, 1, 0], `${mod}: nothing`);
|
||
assert.ok(t.alive, `${mod}: the summon beside her stays (no x-5 recall)`);
|
||
const sp0 = u.skill.sp;
|
||
h.b.kill(t, null);
|
||
assert.equal(u.skill.sp, sp0, `${mod}: no SP`);
|
||
h.run(19.5);
|
||
assert.equal(t.alive, false, `${mod}: its full redeploy time (20 s)`);
|
||
assert.ok(h.runUntil(() => t.alive, 1), `${mod}: back after 20 s`);
|
||
done(h);
|
||
}
|
||
});
|