Files
ark-stronghold-protocol/test/content/op_radian.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

310 lines
20 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_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 / 424 / 118, E2 Lv60 993 / 468 / 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, 424, 993, 468]);
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 鼓舞 = 15 % 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.15, def: 0.15, atk: 0.15 });
approx(t.s.atk, t.base.atk + 0.15 * u.s.atk, `${label(f)}: ATK`);
approx(t.s.def, t.base.def + 0.15 * u.s.def, `${label(f)}: DEF`);
approx(t.s.maxHp, t.base.maxHp + 0.15 * 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.15 * 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.15 * 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.15 * 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);
}
});