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

350 lines
22 KiB
JavaScript

// test/content/op_mgllan.test.js — the 自选 operator kit of 麦哲伦 (char_248_mgllan, 6★ 召唤师; kit
// server/sim/content/kits/ops/op-mgllan.js, the summon deck of kits/shared/summoner.js) and of her drones 龙腾.F / .L / .A,
// fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the drone as the placed hand piece 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, SUM-X
// 专业无人机操作模块 or SUM-Y 教学用龙腾无人机 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_mgllan.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 { canTargetAlly } from '../../server/sim/targeting.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 MG = 'char_248_mgllan';
const FORMS = BACKUPS.units[MG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SUMX = 'uniequip_002_mgllan', SUMY = 'uniequip_003_mgllan';
const S1 = 'skchr_mgllan_1', S2 = 'skchr_mgllan_2', S3 = 'skchr_mgllan_3';
const F = 'token_10005_mgllan_drone1', L = 'token_10005_mgllan_drone2', A = 'token_10005_mgllan_drone3';
const DRONE_OF = [F, L, A];
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);
/** A drone's variant of a form, with the pick's skill / module (getDiyToken). */
const tokOf = (tok, tier, elite, skill, mod) => {
let v = BACKUPS.tokens[tok].variants[`${MG}@${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' }),
enemy_archer: dummy('enemy_archer', { range: 3, atk: 100 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SUMX, SUMY].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; `drones` = [[tokenId, row, col, dir?]] placed pieces (uid 10 +). */
function field({ tier = 5, elite = false, mod = null, skill = 0, drones = [], row = 10, col = 4, 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: MG, skillIndex: skill, uniEquipId: mod }, elite, row, col },
...drones.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, ds: h.b.allyUnits.filter((a) => a.kind === 'token'), deck: u.mem.summonDeck };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src && c.dmg?.isAttack);
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[MG], KITS[MG]);
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], [MG, 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.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 召唤师`);
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 871 / 426 / 116, E2 Lv60 1025 / 470 / 132; SUM-X +100 / +30 → +150 / +50 HP /
// ATK, SUM-Y +25 / +25 → +40 / +40 ATK / DEF
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], [871, 426, 1025, 470]);
assert.deepEqual([modOf(5, SUMX).attr, modOf(6, SUMX).attr, modOf(5, SUMY).attr, modOf(6, SUMY).attr], [{ maxHp: 100, atk: 30 }, { maxHp: 150, atk: 50 }, { atk: 25, def: 25 }, { atk: 40, def: 40 }]);
});
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, 5] });
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 (she has a kit) and a roster with her passes validateDiyPicks with either module', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(MG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MG), `tier ${t}`);
for (const uniEquipId of [SUMX, SUMY, null]) {
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: MG, skillIndex: 2, uniEquipId } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: MG, skillIndex: 2, uniEquipId } } });
}
});
test('T1 支援无人机·龙腾: her skill\'s drone is her piece (the others are not hers to field); the deck — 5 held at her deployment, cap 5 (8 with SUM-Y, which adds 3 on the card), at most 3 standing (4 at SUM-Y stage 3); every drone holds 禁疗, blocks nothing, has its data and no skill', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const tok = DRONE_OF[skill];
const { h, u, ds, deck } = field({ tier, elite, mod, skill, drones: [[tok, 11, 6]] });
const d = ds[0], v = tokOf(tok, tier, elite, skill, mod);
const y = elite && mod === SUMY;
assert.ok(h.b.producesToken(u, tok) && DRONE_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, y ? 8 : 5, y ? 8 : 5, y && tier === 6 ? 4 : 3], `${label(f)} S${skill + 1}: deck`);
assert.deepEqual([d.alive, d.ownerUnit, d.kit.skill, d.skill.noSkill || !d.kit.skill], [true, u, null, true], `${label(f)}: deployed, hers, no skill`);
assert.deepEqual([d.base.maxHp, d.base.atk, d.base.def, d.base.cost, d.base.respawnTime, d.base.bat], [v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.cost, v.stats.respawnTime, v.stats.bat], `${label(f)}: drone stats`);
assert.deepEqual([d.s.blockCnt, !!d.s.flags.noHeal], [0, true], `${label(f)}: no block, 禁疗`);
assert.deepEqual(d.liveRangeGrid, v.rangeGrid, `${label(f)}: drone range`);
if (tok === F) assert.deepEqual([d.profile.noAttack, d.base.atk], [true, 0], '龙腾.F: no attack (ATK 0)');
if (tok === L) assert.deepEqual([d.profile.attack, d.profile.dmgType, d.profile.canHitFly, d.liveRangeGrid], ['melee', 'arts', false, [[0, 0]]], '龙腾.L: melee arts on its tile');
if (tok === A) assert.deepEqual([d.profile.attack, d.profile.dmgType, d.profile.canHitFly, d.profile.splashRadius], ['ranged', 'phys', true, 0.75], '龙腾.A: shells, air too, blast 0.75');
done(h);
}
}
// the SUM-Y drones: F 2 / L 4 / A 5 DP ("部署费用-1 / -3"); stage 3 F 2100 HP / 250 DEF, L ATK 515, A ATK 749, ASPD +3
assert.deepEqual([F, L, A].map((t, i) => tokOf(t, 5, true, i, SUMY).stats.cost), [2, 4, 5]);
assert.deepEqual([tokOf(F, 6, true, 0, SUMY).stats.maxHp, tokOf(F, 6, true, 0, SUMY).stats.def, tokOf(L, 6, true, 1, SUMY).stats.atk, tokOf(A, 6, true, 2, SUMY).stats.atk, tokOf(A, 6, true, 2, SUMY).stats.aspd], [2100, 250, 515, 749, 103]);
});
test('the deck: a knocked-out drone comes back on its tile its redeploy time (10 s) later, paying its cost and using one held; with none held it stays off; the drones leave with her and come back after her next deployment (+5 held)', () => {
const { h, u, ds, deck } = field({ tier: 6, elite: true, mod: SUMX, skill: 2, drones: [[A, 11, 6], [A, 9, 6]] });
const [d1, d2] = ds;
const p = h.b.players[0];
h.b.kill(d1, null);
assert.deepEqual([d1.alive, d1.removed], [false, false], 'the piece stays');
h.run(9.5);
assert.equal(d1.alive, false, 'not before 10 s');
const dp0 = p.dp;
assert.ok(h.runUntil(() => d1.alive, 1), 'back after 10 s');
assert.deepEqual([d1.tileR, d1.tileC, deck.held], [11, 6, 4], 'on its tile, one held used');
approx(dp0 - p.dp, d1.base.cost, 'its cost paid');
// none held: it stays off
deck.held = 0;
h.b.kill(d1, null);
h.run(15);
assert.equal(d1.alive, false, 'none held');
deck.held = 2;
assert.ok(h.runUntil(() => d1.alive, 1), 'back once one is held');
// she leaves: her drones go with her (no knock-down); back after her redeployment
h.b.retreat(u);
h.step();
assert.deepEqual([d1.alive, d2.alive], [false, false], 'withdrawn with her');
assert.ok(h.hooksOf('death').filter((c) => c.unit === d1 || c.unit === d2).slice(-2).every((c) => c.reason === 'retreat'), 'withdrawn, not knocked out');
h.run(20);
assert.deepEqual([d1.alive, d2.alive], [false, false], 'never while she is off the field');
deck.held = 0;
assert.ok(h.b.redeploy(u), 'she comes back');
assert.ok(h.runUntil(() => d1.alive && d2.alive, 1), 'her drones follow');
assert.equal(deck.held, 3, '+5 at her deployment, two used');
done(h);
});
test('T2 光学折射配件: a drone is 隐匿 for 22 s (28 s with SUM-X stage 3) from each deployment — no ranged enemy targets it meanwhile; SUM-X stage 3: 麦哲伦 beside a drone inside that window is 隐匿 too (not two tiles away, not at stage 1)', () => {
for (const [tier, elite, mod] of [[5, false, null], [5, true, SUMX], [6, true, SUMX], [6, true, SUMY]]) {
const x3 = elite && mod === SUMX && tier === 6;
const want = x3 ? 28 : 22;
const { h, u, ds } = field({ tier, elite, mod, skill: 1, drones: [[L, 10, 5], [L, 11, 7]] });
const [near, far] = ds;
assert.equal(tokOf(L, tier, elite, 1, mod).talents.find((t) => t.bb.hidden_duration != null).bb.hidden_duration, want, `${label([tier, elite, mod])}: data`);
const archer = h.spawn('enemy_archer', { pos: [11, 9] });
assert.ok(near.s.flags.stealth && far.s.flags.stealth, '隐匿 at deployment');
assert.ok(!canTargetAlly(archer, far, true), 'a ranged enemy cannot target it');
assert.equal(!!u.s.flags.stealth, x3, `${label([tier, elite, mod])}: her own 隐匿 beside the drone`);
h.run(want - 0.5);
assert.ok(near.s.flags.stealth, 'still 隐匿');
assert.equal(!!u.s.flags.stealth, x3, 'hers still');
h.run(1);
assert.ok(!near.s.flags.stealth && !far.s.flags.stealth, `over after ${want} s`);
assert.ok(canTargetAlly(archer, far, true), 'targetable again');
assert.ok(!u.s.flags.stealth, 'hers over with it');
// a new deployment: a new window
h.b.kill(far, null);
h.runUntil(() => far.alive, 12);
assert.ok(far.s.flags.stealth, 'again at its next deployment');
done(h);
}
// her 隐匿 needs the drone on one of her four neighbouring tiles
const { h, u } = field({ tier: 6, elite: true, mod: SUMX, skill: 1, drones: [[L, 10, 6]] });
assert.ok(!u.s.flags.stealth, 'two tiles away: no');
done(h);
});
test('S1 高效制冷模块: passive — every 3 s from her deployment 停顿 0.8 / 0.9 s on every enemy of her 3-1 and her 龙腾.F\'s x-4 (air units too), none elsewhere; running (15 s) — 束缚 1.9 / 2.2 s every 3 s from the cast instead; it ends recalling her drones', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ds, deck } = field({ tier, elite, mod, skill: 0, row: 10, col: 3, drones: [[F, 11, 8]] });
const d = ds[0];
assert.deepEqual([sk.bb['attack@interval'], sk.bb['attack@sluggish'], sk.bb['attack@frozen_duration'], sk.duration], [3, elite ? 0.9 : 0.8, elite ? 2.2 : 1.9, 15]);
const at = (pos, key = 'enemy_dummy') => h.spawn(key, { pos });
const her = at([10, 5]), fly = at([11, 4], 'enemy_fly'), droneE = at([12, 9]), out = at([9, 9]);
const st = (status) => h.hooksOf('statusApplied').filter((c) => c.status === status);
h.run(2.9);
assert.equal(st('sluggish').length, 0, 'nothing before 3 s');
h.run(0.2);
const first = st('sluggish');
assert.deepEqual(new Set(first.map((c) => c.target)), new Set([her, fly, droneE]), 'her range + the drone\'s x-4');
for (const c of first) approx(c.duration, sk.bb['attack@sluggish'], 'duration');
assert.equal(first.find((c) => c.target === droneE).source, d, 'the drone\'s own area');
assert.ok(!first.some((c) => c.target === out), 'outside: untouched');
h.run(3);
assert.equal(st('sluggish').length, 6, 'again 3 s later');
// the cast: 束缚 every 3 s instead
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast on an attack');
const t0 = h.b.time, n0 = st('sluggish').length;
h.run(2.9);
assert.equal(st('bind').length, 0, 'no 束缚 before 3 s into it');
h.run(0.2);
assert.deepEqual(new Set(st('bind').map((c) => c.target)), new Set([her, fly, droneE]), '束缚 on the same areas');
for (const c of st('bind')) approx(c.duration, sk.bb['attack@frozen_duration'], '束缚 duration');
h.runUntil(() => !u.skill.active, 15);
assert.equal(st('sluggish').length, n0, 'no 停顿 while it runs');
approx(h.b.time - t0, 15, '15 s', 0.01);
// 技能结束时回收所有无人机
h.step();
assert.deepEqual([d.alive, d.removed, deck.held], [false, false, deck.cap], 'recalled (+1 held, at most the cap)');
assert.ok(h.runUntil(() => d.alive, 10.5), 'back on its tile 10 s later');
done(h);
}
});
test('S2 激光开采模块: she and her drones ASPD +80 / +100 for 15 s; a 龙腾.L strikes every enemy on its tile while it runs (one at a time otherwise); recalled at the end', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMX]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, ds } = field({ tier, elite, mod, skill: 1, row: 10, col: 3, drones: [[L, 10, 6]] });
const d = ds[0];
const a = h.spawn('enemy_dummy', { pos: [10, 6] }), b = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(3);
const before = from(h, d);
assert.ok(before.length >= 2, 'it attacks');
assert.ok(new Set(before.map((c) => c.dmg.attackId)).size === before.length, 'one target per attack');
for (const c of before) assert.deepEqual([c.type, Math.abs(c.amount - Math.max(d.s.atk * 0.05, d.s.atk * (1 - c.target.s.res / 100))) < 1e-6], ['arts', true], 'arts');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.deepEqual(d.findBuff('mgllan:s2:drone')?.mods, { aspd: sk.bb.attack_speed }, 'the drone\'s bonus');
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, 'her ASPD');
assert.equal(d.s.aspd, 100 + sk.bb.attack_speed, 'the drone\'s');
const n0 = from(h, d).length;
h.run(3);
const during = from(h, d).slice(n0);
const byAttack = new Map();
for (const c of during) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
assert.ok([...byAttack.values()].every((ts) => ts.length === 2 && ts.includes(a) && ts.includes(b)), '群体: both enemies of its tile each attack');
// knocked out during it: back 10 s later, still inside the 15 s — with the skill's ASPD
h.b.kill(d, null);
assert.ok(h.runUntil(() => d.alive, 10.5) && u.skill.active, 'back while it runs');
assert.equal(d.s.aspd, 100 + sk.bb.attack_speed, 'a drone deployed during it takes its ASPD');
h.runUntil(() => !u.skill.active, 15);
h.step();
assert.deepEqual([d.alive, u.s.aspd], [false, 100], 'recalled; her ASPD back');
assert.ok(h.runUntil(() => d.alive, 10.5), 'back');
assert.equal(d.findBuff('mgllan:s2:drone'), null, 'no bonus after it');
done(h);
}
});
test('S3 武装打击模块: she and her drones ATK +80 % / +100 % for 15 s; a 龙腾.A\'s blast radius 0.75 → 1.25 while it runs; recalled at the end', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ds } = field({ tier, elite, mod, skill: 2, row: 10, col: 3, drones: [[A, 10, 5]] });
const d = ds[0];
const main = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [10, 8] });
const close = h.spawn('enemy_fly', { pos: [10.6, 7] });
h.spawn('enemy_dummy', { pos: [11, 4] }); // hers (outside the drone's range)
h.run(5);
const before = from(h, d);
assert.ok(before.some((c) => c.target === main) && before.some((c) => c.target === close), 'its target and a flyer 0.6 away');
assert.ok(!before.some((c) => c.target === side), 'one 1.0 away: outside 0.75');
for (const c of before.filter((x) => x.target === main)) approx(c.amount, d.s.atk, 'phys (DEF 0)');
const atk0 = u.s.atk, datk0 = d.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
approx(u.s.atk, atk0 * (1 + sk.bb.atk) / 1, 'her ATK +', 1e-9);
approx(d.s.atk, datk0 * (1 + sk.bb.atk), 'the drone\'s ATK +');
assert.equal(d.profile.splashRadius, 1.25, 'blast 1.25');
const n0 = from(h, d).length;
h.run(5);
assert.ok(from(h, d).slice(n0).some((c) => c.target === side), 'now 1.0 away too');
h.runUntil(() => !u.skill.active, 15);
h.step();
assert.equal(d.alive, false, 'recalled');
assert.ok(h.runUntil(() => d.alive, 10.5), 'back');
assert.deepEqual([d.profile.splashRadius, d.s.atk], [0.75, datk0], 'blast and ATK back');
done(h);
}
});
test('the recall triggers each drone\'s own skill: a stunned or silenced drone stays (PRTS 备注 "技能无法触发的场合不会产生效果"); none when she leaves the field with the skill running (her drones go with her)', () => {
const { h, u, ds } = field({ tier: 6, elite: true, mod: SUMX, skill: 2, row: 10, col: 3, drones: [[A, 10, 5], [A, 11, 5], [A, 9, 5]] });
const [ok, stunned, silenced] = ds;
h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
u.skill.timeLeft = 0.2;
h.b.applyStatus(stunned, 'stun', { duration: 5, force: true });
h.b.applyStatus(silenced, 'silence', { duration: 5, force: true });
h.runUntil(() => !u.skill.active, 1);
h.step();
assert.deepEqual([ok.alive, stunned.alive, silenced.alive], [false, true, true], 'only the free one');
// her leaving with the skill on: withdrawn with her, no recall (no +1 held)
const { h: h2, u: u2, ds: ds2, deck } = field({ tier: 5, elite: false, skill: 2, row: 10, col: 3, drones: [[A, 10, 5]] });
h2.spawn('enemy_dummy', { pos: [10, 5] });
u2.skill.gainSp(999);
h2.runUntil(() => u2.skill.active, 3);
deck.held = 2;
h2.b.kill(u2, null);
h2.step();
assert.deepEqual([ds2[0].alive, deck.held], [false, 2], 'gone with her, nothing held more');
done(h);
done(h2);
});
test('modules: SUM-X\'s trait (首个召唤物部署时不消耗部署位) is a prep deploy-cap rule — the deck is the same as without it; SUM-Y\'s +3 held on the card and cap 8 hold through her redeployment (+5, at most 8)', () => {
const { h, deck } = field({ tier: 6, elite: true, mod: SUMX, skill: 0 });
assert.deepEqual([deck.held, deck.cap, deck.maxDeployed], [5, 5, 3], 'SUM-X: the base deck');
assert.equal(formOf(6, true).modules.find((m) => m.uniEquipId === SUMX).traitOverride.bb.cnt, 1, 'its trait blackboard (unused)');
done(h);
const { h: h2, u, deck: d2 } = field({ tier: 5, elite: true, mod: SUMY, skill: 0 });
assert.deepEqual([d2.held, d2.cap, d2.maxDeployed], [8, 8, 3], 'SUM-Y stage 1: 3 + 5 held, cap 8, 3 standing');
d2.held = 1;
h2.b.retreat(u);
h2.b.redeploy(u);
assert.equal(d2.held, 6, '+5 at her next deployment');
h2.b.retreat(u);
h2.b.redeploy(u);
assert.equal(d2.held, 8, 'at most 8');
done(h2);
});