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

364 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_aglna2.test.js — the 自选 operator kit of 予愿安洁莉娜 (char_1015_aglna2, 6★ 巡空者; kit
// server/sim/content/kits/ops/op-aglna2.js) and of her S3 marker “一会儿见!” (token_10071_aglna2_agairp), fielded the
// production way (a DIY slot + its `diy` pick, simdata getDiy) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4)
// and elite (E2 Lv60, rank 7) — she has no module. Numbers from data/backups.json; the fidelity checklist of kits/README.md.
// The engine's 缚地 status her S2 brings: test/sim/feedback5-groundbind.test.js.
// Run: node --test test/content/op_aglna2.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 { canTargetAlly } from '../../server/sim/targeting.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 AGL = 'char_1015_aglna2';
const MARKER = 'token_10071_aglna2_agairp';
const FORMS = BACKUPS.units[AGL].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const S1 = 'skchr_aglna2_1', S2 = 'skchr_aglna2_2', S3 = 'skchr_aglna2_3';
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 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_heavy: dummy('enemy_heavy', { mass: 4 }), enemy_heavier: dummy('enemy_heavier', { mass: 5 }),
enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_flyheavy: dummy('enemy_flyheavy', { motion: 'FLY', mass: 4 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0, atk: 100 }),
enemy_flywalk: enemyRec({ key: 'enemy_flywalk', hp: 1e9, speed: 0.6, mass: 0, motion: 'FLY', atk: 100 }),
};
const FORMS_ALL = [[5, false], [6, false], [5, true], [6, true]];
const label = ([tier, elite]) => `T${tier} ${elite ? 'elite' : 'normal'}`;
const ANY_ATK = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const extras = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('aglna2:extra'));
/** 予愿安洁莉娜 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'deploy', 'dodge'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: AGL, skillIndex: skill }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
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, 2-2, melee physical that hits air units, blocks 2, 叙拉古, no 特质; S1 a timed deployment skill, S2 / S3 the data\'s ACTIVE_RANGE', () => {
assert.equal(OPERATOR_KITS[AGL], KITS[AGL]);
for (const f of FORMS_ALL) {
const [tier, elite] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, skill });
const form = formOf(tier, elite);
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [AGL, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp, form.stats.atk, form.stats.def], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [2, 'melee', 'phys', true, 1.5], `${label(f)}: 巡空者`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['siracusaShip'], []], `${label(f)}: bonds / 特质`);
if (skill === 0) {
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.active], ['NEVER', 'duration', true], `${label(f)}: S1 runs from the deployment`);
assert.deepEqual(u.liveRangeGrid, form.skills[0].rangeGrid, `${label(f)}: 3-6 while it runs`);
} else {
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
assert.equal(u.skill.rule, 'ACTIVE_RANGE');
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 2-2`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.blockFly, `${label(f)}: on the ground`);
}
done(h);
}
}
// E2 Lv1 1884 / 661 / 410, E2 Lv60 2216 / 772 / 483
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/3'].stats.atk], [1884, 661, 772, 772]);
assert.deepEqual(FORMS['2/60/7/3'].modules ?? [], []);
});
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(AGL));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(AGL), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: AGL, skillIndex: 2, uniEquipId: null } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: AGL, skillIndex: 2, uniEquipId: null } } });
});
test('trait 巡空者: on the ground she blocks ground enemies, not flyers; airborne (起飞) she blocks flyers, not ground enemies, and no ground enemy selects her; taking off releases the ground enemies, landing the flyers', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
const { h, u } = field({ tier, elite, skill: 1, row: 9, col: 4 });
u.skill.spCostMul = 1000; // (no cast of its own: the test takes off below)
const w = h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => w.blockedBy === u, 30), `${label(f)}: blocks the walker on the ground`);
const fl = h.spawn('enemy_flywalk', { routeIndex: 2 });
assert.ok(h.runUntil(() => fl.x < u.x - 1, 30), `${label(f)}: the flyer passes`);
assert.notEqual(fl.blockedBy, u, `${label(f)}: no air block on the ground`);
h.b.kill(fl, null);
// S2: take off (the walker walks on)
assert.ok(u.skill.activate('test', { free: true }));
assert.ok(u.s.flags.liftoff && u.s.flags.blockFly, `${label(f)}: airborne`);
assert.equal(w.blockedBy, null, `${label(f)}: the ground enemy released`);
assert.equal(canTargetAlly(w, u, true), false, `${label(f)}: 对地规避`);
const fl2 = h.spawn('enemy_flywalk', { routeIndex: 2 });
assert.ok(h.runUntil(() => fl2.blockedBy === u, 30), `${label(f)}: blocks a flyer airborne`);
assert.equal(canTargetAlly(fl2, u, true), true, `${label(f)}: flyers still select her`);
u.skill.end('test');
assert.equal(fl2.blockedBy, null, `${label(f)}: landing releases the flyer`);
assert.ok(!u.s.flags.liftoff, `${label(f)}: landed`);
done(h);
}
});
test('T1 飘浮大地之上: every damage she outputs adds ATK × 45 % arts on a target of weight ≤ 3, else × 30 % — dodged hits too, never on itself; airborne, every enemy of her range is 失重 (weight −1, not with another 失重), gone when she lands', () => {
for (const f of FORMS_ALL) {
const [tier, elite] = f;
const t0 = formOf(tier, elite).talents[0].bb;
assert.deepEqual([t0.atk_scale_lo, t0.atk_scale_hi, t0.mass_level], [0.3, 0.45, 3], label(f));
// on the ground (S3 picked, not cast): weight 0 ⇒ 45 %, weight 4 ⇒ 30 %
{
const { h, u } = field({ tier, elite, skill: 2 });
u.skill.sp = 0;
const a = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => ANY_ATK(h, u).length > 0, 3));
const hit = ANY_ATK(h, u)[0];
const ex = extras(h, u).filter((c) => c.target === a);
assert.equal(ex.length, 1, `${label(f)}: one addition per damage`);
approx(ex[0].amount, u.s.atk * 0.45, `${label(f)}: weight 0`, 1e-6);
assert.equal(ex[0].type, 'arts');
approx(hit.amount, u.s.atk, `${label(f)}: her attack 100 %`, 1e-6);
h.b.kill(a, null);
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
assert.ok(h.runUntil(() => extras(h, u).some((c) => c.target === heavy), 3));
approx(extras(h, u).find((c) => c.target === heavy).amount, u.s.atk * 0.3, `${label(f)}: weight 4 ⇒ 30 %`, 1e-6);
// a dodged hit still adds it
h.b.addBuff(heavy, { key: 'test:dodge', mods: { dodgePhys: 1 } });
const n0 = extras(h, u).length, d0 = h.hooksOf('dodge').length;
assert.ok(h.runUntil(() => h.hooksOf('dodge').length > d0, 3), 'dodged');
assert.equal(extras(h, u).length, n0 + 1, `${label(f)}: an output, dodged or not`);
done(h);
}
// airborne (S1): 失重 in range — the weight-4 enemy counts as 3 ⇒ 45 %
{
const { h, u } = field({ tier, elite, skill: 0 });
const heavy = h.spawn('enemy_heavy', { pos: [11, 6] });
const far = h.spawn('enemy_heavy', { pos: [10, 8] });
const marked = h.spawn('enemy_dummy', { pos: [9, 5] });
h.b.addBuff(marked, { key: 'other:weightless', status: 'weightless', mods: { massFlat: -1 } });
h.step();
assert.equal(heavy.weight, 3, `${label(f)}: 失重 in her range`);
assert.equal(far.weight, 4, `${label(f)}: not outside it`);
assert.equal(marked.buffs.filter((b) => b.status === 'weightless').length, 1, `${label(f)}: 同名效果不可叠加`);
const k0 = extras(h, u).length;
assert.ok(h.runUntil(() => extras(h, u).slice(k0).some((c) => c.target === heavy), 3));
approx(extras(h, u).slice(k0).find((c) => c.target === heavy).amount, u.s.atk * 0.45, `${label(f)}: weight 3 (失重) ⇒ 45 %`, 1e-6);
u.skill.end('test');
h.step();
assert.equal(heavy.weight, 4, `${label(f)}: 失重 gone when she lands`);
done(h);
}
}
});
test('T2 天穹间的舞步: while she is on the field every airborne allied operator (her included) has ATK +18 % and, while it blocks, 生命回复速度 +10 % max HP (hpRegenRatio — no heal, 禁疗 does not stop it); none on the ground', () => {
for (const f of FORMS_ALL) {
const [tier, elite] = f;
const t1 = formOf(tier, elite).talents[1].bb;
assert.deepEqual([t1.atk, t1.hp_recovery_per_sec_by_max_hp_ratio], [0.18, 0.1], label(f));
const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 12, col: 3 }];
const { h, u } = field({ tier, elite, skill: 0, row: 9, col: 4, others });
const yak = h.unit(2);
h.run(0.3);
assert.deepEqual(u.findBuff('talent:aglna2:dance')?.mods, { atkPct: 0.18 }, `${label(f)}: airborne, not blocking`);
approx(u.s.atk, u.base.atk * (1 + skillOf(tier, elite, S1).bb.atk + 0.18), `${label(f)}: ATK`);
assert.equal(yak.findBuff('talent:aglna2:dance'), null, `${label(f)}: 角峰 on the ground: none`);
h.b.addBuff(u, { key: 'test:healFree', flags: { noHeal: true, healFree: true } });
const fl = h.spawn('enemy_flywalk', { routeIndex: 2 });
assert.ok(h.runUntil(() => fl.blockedBy === u, 30), 'she blocks a flyer');
h.run(0.3);
assert.deepEqual(u.findBuff('talent:aglna2:dance')?.mods, { atkPct: 0.18, hpRegenRatio: 0.1 }, `${label(f)}: blocking`);
approx(u.s.hpRegen, 0.1 * u.s.maxHp, `${label(f)}: 10 % max HP / s as 生命回复速度`);
u.hp = u.s.maxHp / 2;
const hp0 = u.hp;
h.run(1);
approx(u.hp - hp0, 0.1 * u.s.maxHp, `${label(f)}: regenerated under 禁疗`, 0.05);
done(h);
}
// S3 picked, before its cast: on the ground — no buff
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
h.run(1);
assert.equal(u.findBuff('talent:aglna2:dance'), null);
done(h);
});
test('S1 极速送达 (被动 ON_DEPLOY): at each deployment she takes off for 56 s — range 3-6, ATK +75 % / +100 %, 2 targets at once — then lands', () => {
for (const f of FORMS_ALL) {
const [tier, elite] = f;
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.skillType, sk.spType, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.rangeId], ['PASSIVE', 'ON_DEPLOY', 56, elite ? 1 : 0.75, 2, '3-6'], label(f));
const { h, u } = field({ tier, elite, skill: 0 });
assert.ok(u.skill.active && u.s.flags.liftoff, `${label(f)}: airborne from the deployment`);
approx(u.skill.timeLeft, 56 - 1 / 30, `${label(f)}: 56 s`, 1e-3);
const a = h.spawn('enemy_dummy', { pos: [9, 6] });
const b = h.spawn('enemy_dummy', { pos: [11, 5] });
assert.ok(h.runUntil(() => ANY_ATK(h, u).length >= 2, 3));
const first = ANY_ATK(h, u)[0];
const same = ANY_ATK(h, u).filter((c) => c.dmg.attackId === first.dmg.attackId);
assert.deepEqual(same.map((c) => c.target).sort((x, y) => x.id - y.id), [a, b].sort((x, y) => x.id - y.id), `${label(f)}: two targets`);
h.runUntil(() => !u.skill.active, 60);
assert.ok(!u.s.flags.liftoff && !u.s.flags.blockFly, `${label(f)}: landed`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: 2-2`);
h.run(0.4); // (her T2 lapses within one refresh)
approx(u.s.atk, u.base.atk, `${label(f)}: ATK back`);
// a redeployment takes off again
h.b.retreat(u);
h.b.redeploy(u, { free: true });
assert.ok(u.skill.active && u.s.flags.liftoff, `${label(f)}: again at the next deployment`);
done(h);
}
});
test('S2 重力自定义 (MANUAL, data ACTIVE_RANGE on 3-10): takes off; 2.5 s 吟唱 (no attack, 无敌, control statuses refused) while the sweep (3.0 wide, 1.3 thick, 6 tiles/s, 4 tiles ahead) 浮空s ground enemies 10 / 12 s and 缚地s flyers 20 / 22 s; then ATK +105 % / +135 %, interval −0.6 / −0.8 s, arts, 4 / 5 targets, range 3-10; 22 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite] = f;
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['attack@max_target'], sk.bb.chant_duration, sk.bb.buff_duration_levitate, sk.bb.buff_duration_ground_bound, sk.spCost, sk.initSp, sk.rangeId],
[22, elite ? 1.35 : 1.05, elite ? -0.8 : -0.6, elite ? 5 : 4, 2.5, elite ? 12 : 10, elite ? 22 : 20, elite ? 29 : 32, elite ? 20 : 17, '3-10'], label(f));
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid);
const { h, u } = field({ tier, elite, skill: 1 });
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`);
const g = h.spawn('enemy_dummy', { pos: [10, 8] }); // 4 ahead: swept
const heavy = h.spawn('enemy_heavier', { pos: [11, 7] }); // swept, weight 5 − her 失重 1 = 4: half
const fl = h.spawn('enemy_fly', { pos: [9, 6] }); // swept flyer
const far = h.spawn('enemy_dummy', { pos: [10, 9] }); // 5 ahead: not swept
const wide = h.spawn('enemy_dummy', { pos: [12, 6] }); // 2 aside: not swept
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast with an enemy in 3-10 (none in her 2-2)`);
assert.ok(u.s.flags.liftoff, `${label(f)}: airborne`);
assert.ok(u.s.flags.disarm && u.s.flags.invulnerable, `${label(f)}: 吟唱`);
assert.equal(h.b.applyStatus(u, 'stun', { duration: 3, source: g }), false, `${label(f)}: control statuses refused while gliding`);
h.run(1);
const st = (e, k) => h.hooksOf('statusApplied').find((c) => c.target === e && c.status === k);
approx(st(g, 'levitate')?.duration, sk.bb.buff_duration_levitate, `${label(f)}: 浮空`);
approx(st(heavy, 'levitate')?.duration, sk.bb.buff_duration_levitate / 2, `${label(f)}: halved on weight > 3`);
approx(st(fl, 'groundbind')?.duration, sk.bb.buff_duration_ground_bound, `${label(f)}: 缚地`);
assert.ok(!st(far, 'levitate') && !st(wide, 'levitate'), `${label(f)}: outside the sweep`);
assert.equal(fl.isFlying, false, `${label(f)}: a 缚地 flyer is a ground unit`);
assert.equal(ANY_ATK(h, u).length, 0, `${label(f)}: no attack while gliding`);
h.run(1.6);
assert.ok(!u.s.flags.disarm && !u.s.flags.invulnerable, `${label(f)}: the 吟唱 over at 2.5 s`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: 3-10`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk + 0.18), `${label(f)}: ATK (+18 % of her T2)`);
approx(u.s.interval, (1.5 + sk.bb.base_attack_time) * 100 / u.s.aspd, `${label(f)}: interval`);
for (const p of [[9, 7], [11, 5], [9, 8], [11, 8]]) h.spawn('enemy_dummy', { pos: p });
const n0 = ANY_ATK(h, u).length;
assert.ok(h.runUntil(() => ANY_ATK(h, u).length > n0, 3));
const a1 = ANY_ATK(h, u)[n0];
const one = ANY_ATK(h, u).filter((c) => c.dmg.attackId === a1.dmg.attackId);
assert.equal(one.length, sk.bb['attack@max_target'], `${label(f)}: ${sk.bb['attack@max_target']} targets`);
assert.ok(one.every((c) => c.type === 'arts'), `${label(f)}: arts`);
h.runUntil(() => !u.skill.active, 30);
approx(h.hooksOf('skillEnd').at(-1).t - h.hooksOf('skillStart').at(-1).t, 22, `${label(f)}: 22 s from the cast`, 0.01);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: 2-2 back`);
assert.ok(!u.s.flags.liftoff, `${label(f)}: landed`);
done(h);
}
});
test('S3 酸橙的心事 (MANUAL, data ACTIVE_RANGE on 3-9): 30 / 31 bullets, ATK +20 % / +30 %, 对空庇护 55 % / 60 %, range 3-9 + the 8 around her; each attack 275 % / 305 % on ≤ 3 ground enemies and flyers up to 4; flyers in range ×0.6 speed', () => {
for (const f of FORMS_ALL) {
const [tier, elite] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.bb['attack@trigger_time'], sk.bb.atk, sk.bb.damage_resistance, sk.bb['attack@atk_scale'], sk.bb['attack@max_target'], sk.bb['attack@max_walk_target'], sk.bb.move_speed, sk.bbStr['attack@attack_range_id'], sk.spCost, sk.initSp],
[elite ? 31 : 30, elite ? 0.3 : 0.2, elite ? 0.6 : 0.55, elite ? 3.05 : 2.75, 4, 3, -0.4, 'x-4', elite ? 36 : 42, elite ? 22 : 19], label(f));
const { h, u } = field({ tier, elite, skill: 2 });
u.skill.gainSp(999);
const grounds = [[10, 8], [10, 5], [11, 6], [9, 6]].map((p) => h.spawn('enemy_dummy', { pos: p }));
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast with an enemy in 3-9`);
assert.equal(u.skill.ammoMax, sk.bb['attack@trigger_time'], `${label(f)}: bullets`);
const range = u.liveRangeGrid.map((p) => p.join(',')).sort();
for (const p of [...sk.rangeGrid, [1, -1], [0, -1], [-1, -1]]) assert.ok(range.includes(p.join(',')), `${label(f)}: ${p} in her range`);
h.run(0.25); // (her T2 refreshes every 0.2 s)
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk + 0.18), `${label(f)}: ATK`);
// 对空庇护
const flyer = h.spawn('enemy_fly', { pos: [12, 4] });
const taken = h.b.dealDamage(flyer, u, { amount: 1000, type: 'arts' });
approx(taken, 1000 * (1 - sk.bb.damage_resistance), `${label(f)}: from an air unit`, 1e-6);
assert.equal(h.b.dealDamage(grounds[0], u, { amount: 1000, type: 'arts' }), 0, `${label(f)}: a ground enemy cannot select her (对地规避)`);
approx(h.b.dealDamage(grounds[0], u, { amount: 1000, type: 'arts', ignoreSelect: true }), 1000, `${label(f)}: a ground source's that reaches her: not reduced`, 1e-6);
h.b.kill(flyer, null);
// ≤ 3 ground + flyers to 4 (she blocks nothing meanwhile: the flyers are on tiles she may not take)
const f1 = h.spawn('enemy_fly', { pos: [10, 7] }), f2 = h.spawn('enemy_fly', { pos: [11, 7] });
h.b.addBuff(f1, { key: 'test:ub', flags: { unblockable: true } });
h.b.addBuff(f2, { key: 'test:ub', flags: { unblockable: true } });
const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).length;
assert.ok(h.runUntil(() => ANY_ATK(h, u).length > n0, 3));
const a1 = ANY_ATK(h, u)[n0];
const one = ANY_ATK(h, u).filter((c) => c.dmg.attackId === a1.dmg.attackId);
assert.equal(one.filter((c) => !c.target.isFlying).length, 3, `${label(f)}: 3 ground`);
assert.equal(one.filter((c) => c.target.isFlying).length, 1, `${label(f)}: + 1 flyer`);
for (const c of one) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale']} × ATK`, 1e-6);
const used = ANY_ATK(h, u).map((c) => c.dmg.attackId).filter((x, i, a) => a.indexOf(x) === i).length;
assert.equal(u.skill.ammoLeft, sk.bb['attack@trigger_time'] - used, `${label(f)}: one bullet per attack`);
// flyers of her range ×0.6
approx(f1.s.moveSpeed, f1.base.moveSpeed * 0.6, `${label(f)}: flyer slowed`);
approx(grounds[0].s.moveSpeed, grounds[0].base.moveSpeed, `${label(f)}: ground enemies not`);
u.skill.addAmmo(0);
h.runUntil(() => !u.skill.active, 120);
assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'ammo', `${label(f)}: ends with its bullets`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: 2-2 back`);
done(h);
}
});
test('S3 move: blocking nothing, she flies (2 tiles/s, 无敌, disarmed) to the tile of an unblocked flyer she can block inside the original 3-9, lands there by a 【移动】 (the skill and its bullets kept) and blocks it; “一会儿见!” marks her deployment tile; her range stays that 3-9 + the 8 around her; at the end she goes back and the marker leaves', () => {
for (const f of [[5, false], [6, true]]) {
const [tier, elite] = f;
const { h, u } = field({ tier, elite, skill: 2 });
u.skill.gainSp(999);
const fl = h.spawn('enemy_fly', { pos: [11, 7] });
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast`);
const ammo0 = u.skill.ammoLeft;
assert.ok(h.runUntil(() => !!u.findBuff('aglna2:move'), 0.5), `${label(f)}: the flight starts`);
assert.ok(u.s.flags.invulnerable && u.s.flags.disarm, `${label(f)}: 无敌, disarmed in flight`);
const t0 = h.b.time;
assert.ok(h.runUntil(() => u.tileR === 11 && u.tileC === 7, 5), `${label(f)}: on the flyer's tile`);
approx(h.b.time - t0, Math.hypot(1, 3) / 2, `${label(f)}: at 2 tiles/s`, 0.05);
assert.ok(u.skill.active && u.skill.ammoLeft <= ammo0, `${label(f)}: the skill kept`);
assert.ok(h.runUntil(() => fl.blockedBy === u, 1), `${label(f)}: she blocks it`);
const m = h.b.allyUnits.find((t) => t.kind === 'token' && t.defId === MARKER && t.alive);
assert.ok(m, `${label(f)}: the marker`);
assert.deepEqual([m.tileR, m.tileC], [10, 4], `${label(f)}: on her deployment tile`);
assert.ok(m.s.flags.invulnerable && m.s.flags.untargetable && m.s.flags.isolated && m.s.flags.noBlock, `${label(f)}: 无敌, 孤立, unselectable, blocks nothing`);
// her range: the 3-9 of (10, 4) facing RIGHT + the 8 around her new tile (11, 7)
const keys = new Set(u.rangeKeys);
assert.ok(keys.has(12 * 21 + 4) && keys.has(9 * 21 + 5), `${label(f)}: the anchored 3-9 ((12, 4), (9, 5))`);
assert.ok(keys.has(12 * 21 + 8) && keys.has(11 * 21 + 8), `${label(f)}: the 8 around her ((12, 8), (11, 8))`);
assert.ok(!keys.has(11 * 21 + 10), `${label(f)}: no 3-9 around her new tile ((11, 10))`);
u.skill.end('test');
assert.deepEqual([u.tileR, u.tileC], [10, 4], `${label(f)}: back home at the end`);
assert.ok(!m.alive, `${label(f)}: the marker left`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: 2-2`);
assert.equal(u.extraRangeKeys, null);
done(h);
}
// blocking something, she stays
{
const { h, u } = field({ tier: 5, skill: 2 });
u.skill.gainSp(999);
const fl = h.spawn('enemy_fly', { pos: [11, 7] });
assert.ok(h.runUntil(() => fl.blockedBy === u, 5), 'she flew to it and blocks it');
const at = [u.tileR, u.tileC];
h.spawn('enemy_fly', { pos: [9, 6] });
h.run(2);
assert.deepEqual([u.tileR, u.tileC], at, 'no move while blocking');
done(h);
}
});