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

350 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_jesca2.test.js — the 自选 operator kit of 涤火杰西卡 (char_1034_jesca2, 6★ 哨戒铁卫; kit
// server/sim/content/kits/ops/op-jesca2.js) and her summon 机动盾牌 (token_10032_jesca2_jckshd), fielded the production way (a
// DIY slot + its `diy` pick; the shield as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4,
// no module) and elite (E2 Lv60, rank 7) with no module, SPT-X “私家英雄” or SPT-Y 未曾风化 at stage 1 (tier 5) / 3 (tier 6).
// Every number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity
// checklist of kits/README.md item by item.
// Run: node --test test/content/op_jesca2.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 JESCA = 'char_1034_jesca2';
const SHIELD = 'token_10032_jesca2_jckshd';
const FORMS = BACKUPS.units[JESCA].forms;
const TOKEN = BACKUPS.tokens[SHIELD];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SPTX = 'uniequip_002_jesca2', SPTY = 'uniequip_003_jesca2';
const S1 = 'skchr_jesca2_1', S2 = 'skchr_jesca2_2', S3 = 'skchr_jesca2_3';
const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusKey(tier, elite)];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const variantOf = (tier, elite) => TOKEN.variants[`${JESCA}@${statusKey(tier, elite)}`];
const tokenStats = (tier, elite, mod) => (elite && mod && variantOf(tier, elite).byModule?.[mod]?.stats) || variantOf(tier, elite).stats;
const tokenTalent = (tier, elite, mod) => ((elite && mod && variantOf(tier, elite).byModule?.[mod]?.talents) || variantOf(tier, elite).talents).find((t) => t.bb.duration != null).bb;
const talentOf = (tier, elite, mod, i) => {
const m = elite ? modOf(tier, mod) : null;
return m?.talentChanges?.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_shooter: dummy('enemy_shooter', { atk: 200, range: 2, bat: 0.5 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SPTX, SPTY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const K = (r, c) => r * 21 + c;
const sortN = (a) => [...a].sort((x, y) => x - y);
/** Her shield above her (she turns UP), beside her (the tile right of her: in front, no turn), behind her (she turns LEFT). */
const ABOVE = { row: 11, col: 4 }, FRONT = { row: 10, col: 5 }, BEHIND = { row: 10, col: 3 };
/** A battle with 涤火杰西卡 as uid 1 at (10, 4) facing RIGHT (`shield`: her piece as uid 2), plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, shield = ABOVE, others = [], seed = 5, dp = 99 } = {}) {
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: JESCA, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 4 }];
if (shield) units.push({ uid: 2, kind: 'token', tokenId: SHIELD, ownerUid: 1, row: shield.row, col: shield.col });
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'spGain', 'ammoUsed'], captureNoisy: true,
units: [...units, ...others],
});
h.step();
return { h, u: h.unit(1), t: shield ? h.unit(2) : null };
}
const bombHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('jesca2:bomb'));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('涤火杰西卡 in every 自选 form: her operator kit (all three skills authored), stats + module attributes, 2-2 (SPT-Y: +1), blocks 3, ranged physical, hits air, 维多利亚, no 特质; her 机动盾牌 piece: the form\'s token stats, 禁疗, taunt 1, no attack, blocks 2', () => {
assert.equal(OPERATOR_KITS[JESCA], KITS[JESCA]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u, t } = field({ tier, elite, mod, skill, shield: FRONT });
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], [JESCA, 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], [3, 'ranged', 'phys', true, 1.2], `${label(f)}: 哨戒铁卫`);
const ext = mod === SPTY ? 1 : 0;
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: 2-2${ext ? ' + 1' : ''}`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [['victoriaShip'], [], [SHIELD]], `${label(f)}: bonds / 特质 / summon`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
const ts = tokenStats(tier, elite, mod);
assert.deepEqual([t.base.maxHp, t.base.def, t.base.atk, t.s.blockCnt, t.base.cost, t.base.respawnTime], [ts.maxHp, ts.def, 0, 2, 5, 30], `${label(f)}: shield stats`);
assert.deepEqual([t.s.flags.noHeal, t.s.taunt, t.profile.noAttack, t.alive], [true, tokenTalent(tier, elite, mod).taunt_level, true, true], `${label(f)}: 禁疗 / taunt / no attack`);
done(h);
}
}
// E2 Lv1 2589 / 433 / 522, E2 Lv60 3265 / 492 / 651; SPT-X +180 / +50 / +25 → +250 / +65 / +37, SPT-Y +180 / +52 → +325 / +83;
// the shield 3041 / 619, 3545 / 709, SPT-X stage 3 3925 / 788 and 60 s
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.def], [2589, 433, 3265, 651]);
assert.deepEqual([modOf(5, SPTX).attr, modOf(6, SPTX).attr, modOf(5, SPTY).attr, modOf(6, SPTY).attr], [{ maxHp: 180, atk: 50, def: 25 }, { maxHp: 250, atk: 65, def: 37 }, { maxHp: 180, atk: 52 }, { maxHp: 325, atk: 83 }]);
assert.deepEqual([tokenStats(5, false).maxHp, tokenStats(5, true).maxHp, tokenStats(6, true, SPTX).maxHp, tokenStats(6, true, SPTX).def, tokenTalent(6, true, SPTX).duration, tokenTalent(5, true, SPTX).duration], [3041, 3545, 3925, 788, 60, 50]);
});
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(JESCA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(JESCA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: JESCA, skillIndex: 2, uniEquipId: SPTY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: JESCA, skillIndex: 2, uniEquipId: SPTY } } });
});
test('T1 灵活应变: she turns to face her shield (her range turns with her) and back once it leaves; while it stands next to her she and the unit on the tile behind her DEF +15 % (孤立 too), nobody else', () => {
for (const f of [[5, false, null], [6, true, SPTX], [6, true, SPTY]]) {
const [tier, elite, mod] = f;
// shield above (11, 4): she faces UP; behind her is (9, 4); 德克萨斯 at (10, 3) was behind her RIGHT facing only
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 10, col: 3 }];
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE, others });
const yak = h.unit(3), texas = h.unit(4);
const d = talentOf(tier, elite, mod, 0).def;
assert.equal(d, 0.15);
assert.deepEqual([u.dir, t.dir], ['UP', 'UP'], `${label(f)}: she faces her shield, it faces away from her`);
const ext = mod === SPTY ? 1 : 0;
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10 + i, 4))), `${label(f)}: her range turned UP`);
assert.deepEqual(u.findBuff('talent:jesca2:def')?.mods, { defPct: d }, `${label(f)}: herself`);
assert.deepEqual(yak.findBuff('talent:jesca2:def')?.mods, { defPct: d }, `${label(f)}: 角峰 behind her`);
assert.equal(texas.findBuff('talent:jesca2:def'), null, `${label(f)}: 德克萨斯 beside her`);
approx(u.s.def, u.base.def * (1 + d), `${label(f)}: her DEF`);
// 孤立 does not keep it off (ignoreAllyTargetFree)
h.b.addBuff(yak, { key: 'test:isolated', flags: { isolated: true } });
h.run(0.5);
assert.ok(yak.findBuff('talent:jesca2:def'), `${label(f)}: a 孤立 unit behind her`);
// the shield leaves: she turns back to RIGHT, the aura ends
h.b.retreat(t, { reason: 'expired', permanent: true });
h.run(0.5);
assert.deepEqual([u.dir, !!u.findBuff('talent:jesca2:def'), !!yak.findBuff('talent:jesca2:def')], ['RIGHT', false, false], `${label(f)}: back to RIGHT`);
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: her range back`);
done(h);
}
// behind her: she turns LEFT; in front: no turn; on a tile not next to her: no turn, no aura (the prep's placement rule)
const b = field({ shield: BEHIND });
assert.equal(b.u.dir, 'LEFT');
assert.deepEqual(sortN(b.u.rangeKeys), sortN([K(10, 4), K(10, 3), K(10, 2)]), 'her range faces LEFT');
done(b.h);
const fr = field({ shield: FRONT });
assert.deepEqual([fr.u.dir, !!fr.u.findBuff('talent:jesca2:def')], ['RIGHT', true]);
done(fr.h);
const far = field({ shield: { row: 12, col: 4 } });
assert.deepEqual([far.u.dir, !!far.u.findBuff('talent:jesca2:def')], ['RIGHT', false], 'a shield two tiles away');
done(far.h);
});
test('T1 灵活应变: the shield lasts 50 s (SPT-X stage 3: 60 s), comes back on its tile 30 s after it left paying 5 DP — only while she stands; it falls with her and her redeployment readies it at once', () => {
for (const f of [[5, false, null], [6, true, SPTX], [5, true, SPTX]]) {
const [tier, elite, mod] = f;
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE });
const life = tokenTalent(tier, elite, mod).duration;
assert.equal(life, tier === 6 && mod === SPTX ? 60 : 50, label(f));
h.run(life - 0.6);
assert.ok(t.alive, `${label(f)}: still there before ${life} s`);
h.run(0.7);
assert.ok(!t.alive && !t.removed, `${label(f)}: gone at ${life} s (the piece stays)`);
assert.equal(u.dir, 'RIGHT');
const dp0 = h.b.players[0].dp;
h.run(29.5);
assert.ok(!t.alive, `${label(f)}: not before 30 s`);
h.run(0.7);
assert.ok(t.alive, `${label(f)}: back after 30 s`);
assert.deepEqual([t.tileR, t.tileC, u.dir], [11, 4, 'UP'], `${label(f)}: on its tile, she faces it again`);
approx(h.b.players[0].dp, dp0 - 5, `${label(f)}: 5 DP`);
// she falls ⇒ it falls; waiting while she is down; her redeployment brings it at once
h.b.kill(u, null);
h.step();
assert.ok(!t.alive && !t.removed, `${label(f)}: falls with her`);
assert.equal(u.dir, 'RIGHT', `${label(f)}: her facing restored for her redeployment`);
h.run(40);
assert.ok(!t.alive, `${label(f)}: no shield while she is down`);
assert.ok(h.runUntil(() => u.alive, 60), `${label(f)}: she redeploys`);
const back = h.b.time;
assert.ok(h.runUntil(() => t.alive, 0.2), `${label(f)}: the shield with her (charge_token[born])`);
assert.ok(h.b.time - back < 0.15, `${label(f)}: at once`);
assert.equal(u.dir, 'UP');
done(h);
}
});
test('T2 蓄能释放: each damage instance on the shield (next to her) gives her 1 SP with 50 % (SPT-Y stage 3: 60 % and its next redeploy −1 s per success, ≤ 14 s); a 流失 does not count', () => {
for (const f of [[5, false, null], [6, true, SPTY], [5, true, SPTY]]) {
const [tier, elite, mod] = f;
const tb = talentOf(tier, elite, mod, 1);
const y3 = tier === 6 && mod === SPTY;
assert.deepEqual([tb.prob, tb.sp, tb.respawn_time ?? 0, tb.respawn_time_max ?? 0], y3 ? [0.6, 1, 1, 14] : [0.5, 1, 0, 0], label(f));
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE, seed: 17 });
// a shooter beside the shield and out of her range: it picks the shield (taunt 1), she cannot reach it
h.spawn('enemy_shooter', { pos: [12, 5] });
h.run(40);
const hits = h.hooksOf('damaged').filter((c) => c.target === t && c.amount > 0).length;
const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
assert.ok(hits > 60, `${label(f)}: ${hits} hits`);
assert.ok(gains.every((c) => c.amount === 1), `${label(f)}: 1 SP each`);
const share = gains.length / hits;
assert.ok(Math.abs(share - tb.prob) < 0.12, `${label(f)}: ${gains.length} / ${hits} ≈ ${tb.prob}`);
assert.equal(t.mem.respawnCut, y3 ? Math.min(14, gains.length) : 0, `${label(f)}: redeploy cut`);
const n = gains.length;
h.b.loseHp(t, 10, { source: null });
h.step();
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent').length, n, `${label(f)}: a 流失 gives nothing`);
if (y3) {
h.b.retreat(t, { reason: 'expired', permanent: true });
approx(t.mem.readyAt, h.b.time + 30 - 14, `${label(f)}: back 30 − 14 s later`, 1e-6);
}
done(h);
}
});
test('S1 坚守阵线 (AUTO, SP_FULL): at full SP with no enemy, 持续时间无限; ATK / DEF +40 % / +55 %; her shield DEF +40 % / +55 % and +20 s of life once per deployment', () => {
for (const f of [[5, false, null], [6, true, SPTX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S1);
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE });
assert.deepEqual([u.skill.rule, sk.trigger.rule, u.skill.kind, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.duration], ['SP_FULL', 'DEFAULT', 'toggle', elite ? 90 : 100, elite ? 0.55 : 0.4, elite ? 0.55 : 0.4, 20], label(f));
const life = tokenTalent(tier, elite, mod).duration;
assert.ok(h.runUntil(() => u.skill.active, sk.spCost + 1), `${label(f)}: cast at ${sk.spCost} s with no enemy`);
approx(h.b.time, sk.spCost, `${label(f)}: at full SP`, 0.02);
// the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now
assert.ok(t.alive);
h.step();
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def + 0.15), `${label(f)}: DEF (+15 % T1)`);
approx(t.s.def, t.base.def * (1 + sk.bb.def), `${label(f)}: shield DEF`);
approx(t.mem.lifeEnd - t.deployedAt, life + 20, `${label(f)}: shield life +20 s`);
h.run(200);
assert.ok(u.skill.active, `${label(f)}: still on 200 s later`);
done(h);
}
});
test('S2 掩蔽护卫 (MANUAL, data ACTIVE_RANGE — rawRule TAKE_DAMAGE): casts with an enemy on its 2-5 that her 2-2 does not reach; 15 s, range 2-5 (SPT-Y does not grow it), ATK +45 % / +60 %, interval 0.3 s, 75 % physical and arts dodge', () => {
for (const f of [[5, false, null], [6, true, SPTY], [5, true, SPTY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod, skill: 1, shield: null });
assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.prob], ['ACTIVE_RANGE', 'ACTIVE_RANGE', 'TAKE_DAMAGE', 15, elite ? 0.6 : 0.45, -0.9, 0.75], label(f));
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`);
// (11, 6): on the 2-5 (local [1, 2]), not on her 2-2 (+1)
h.spawn('enemy_dummy', { pos: [11, 6] });
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast for an enemy only the skill range reaches`);
approx(u.skill.timeLeft, 15, label(f), 0.01);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: 2-5 exactly`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.interval, 0.3, `${label(f)}: 1.2 − 0.9 s`);
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.75, 0.75], `${label(f)}: dodge`);
h.runUntil(() => !u.skill.active, 20);
const ext = mod === SPTY ? 1 : 0;
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: back to her own range`);
assert.deepEqual([u.s.dodgePhys, u.s.interval], [0, 1.2], `${label(f)}: all back`);
done(h);
}
});
test('S3 饱和迸射 (MANUAL, data ACTIVE_RANGE on 2-2 + 1): 20 bullets, range +1 (SPT-Y: +2), interval 1.8 s, ATK +240 % / +270 %, DEF +50 % / +65 %, shield DEF +100 % / +135 %; casts with an enemy only the larger range reaches', () => {
for (const f of [[5, false, null], [6, true, SPTY], [6, true, SPTX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
const { h, u, t } = field({ tier, elite, mod, skill: 2, shield: BEHIND });
assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, u.skill.kind, u.skill.ammo], ['ACTIVE_RANGE', 'ACTIVE_RANGE', 'TAKE_DAMAGE', 'ammo', 20], label(f));
assert.equal(u.dir, 'LEFT');
const ext = mod === SPTY ? 1 : 0;
u.skill.gainSp(999);
// facing LEFT from (10, 4): her own range reaches col 4 − 2 − ext; one more tile only with the skill
h.spawn('enemy_dummy', { pos: [10, 1 - ext] });
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast`);
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(4 + ext).keys()].map((i) => K(10, 4 - i))), `${label(f)}: +1 tile (${ext ? 'and SPT-Y\'s' : 'no module'})`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb['jesca2_s_3[def].def'] + 0.15), `${label(f)}: DEF (+15 % T1)`);
approx(u.s.interval, 1.8, `${label(f)}: 1.2 + 0.6 s`);
approx(t.s.def, t.base.def * (1 + sk.bb['jesca2_s_3_token[def].def']), `${label(f)}: shield DEF`);
assert.deepEqual([sk.bb.atk, sk.bb['jesca2_s_3[def].def'], sk.bb['jesca2_s_3_token[def].def']], elite ? [2.7, 0.65, 1.35] : [2.4, 0.5, 1], label(f));
h.runUntil(() => !u.skill.active, 60);
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 20, `${label(f)}: 20 bullets`);
assert.equal(t.findBuff('skill:jesca2:shield'), null, `${label(f)}: shield DEF back`);
done(h);
}
});
test('S3 shell: with her shield next to her at the cast it flies ahead at 5 tiles / s, bursts on the first enemy it touches: every enemy within 1.7 (air units too) takes 250 % ATK physical and a 5 s stun; no bullet spent; none without a shield, one when a shield comes during the skill, held while she is disarmed', () => {
for (const f of [[5, false, null], [6, true, SPTY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.bb['attack@extrabomb.atk_scale'], sk.bb['attack@extrabomb.stun'], sk.bb['attack@extrabomb.projectile_range']], [2.5, 5, 1.7], label(f));
const { h, u, t } = field({ tier, elite, mod, skill: 2, shield: FRONT });
const near = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [11, 7] });
const fly = h.spawn('enemy_fly', { pos: [11, 6] }), far = h.spawn('enemy_dummy', { pos: [12, 8] });
u.skill.gainSp(999);
u.skill.activate('test');
const atk = u.s.atk;
assert.ok(h.runUntil(() => bombHits(h, u).length > 0, 2), `${label(f)}: the shell lands`);
// from (10, 4) to the enemy at (10, 7): it touches it 0.5 before, at x 6.5, i.e. 2.5 tiles in 0.5 s
approx(h.hooksOf('damaged').find((c) => c.dmg?.tags?.includes('jesca2:bomb')).t, 0.5 + h.hooksOf('skillStart').find((c) => c.unit === u).t, `${label(f)}: flight time`, 0.1);
const hits = bombHits(h, u);
assert.deepEqual(sortN(hits.map((c) => c.target.id)), sortN([near.id, side.id, fly.id]), `${label(f)}: within 1.7, the flyer too`);
for (const c of hits) {
approx(c.amount, atk * 2.5, `${label(f)}: 250 % ATK`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true], label(f));
}
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.deepEqual(sortN(stuns.map((c) => c.target.id)), sortN([near.id, side.id, fly.id]), `${label(f)}: stunned`);
for (const c of stuns) approx(c.duration, 5, `${label(f)}: 5 s`);
assert.ok(!far.findBuff('stun'), `${label(f)}: out of the burst`);
assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 20, `${label(f)}: the shell spends no bullet`);
h.run(3);
assert.equal(bombHits(h, u).length, hits.length, `${label(f)}: one shell per cast`);
// a shield coming during the skill fires another (her shield back at once)
h.b.retreat(t, { reason: 'expired', permanent: true });
t.mem.readyAt = h.b.time;
assert.ok(h.runUntil(() => t.alive, 1), label(f));
assert.ok(h.runUntil(() => bombHits(h, u).length > hits.length, 2), `${label(f)}: a second shell`);
done(h);
}
// no shield: no shell; disarmed: it waits
const a = field({ skill: 2, shield: null });
a.h.spawn('enemy_dummy', { pos: [10, 6] });
a.u.skill.gainSp(999);
a.u.skill.activate('test');
a.h.run(3);
assert.equal(bombHits(a.h, a.u).length, 0, 'no shield: no shell');
done(a.h);
const b = field({ skill: 2, shield: FRONT });
b.h.spawn('enemy_dummy', { pos: [10, 7] });
b.h.b.addBuff(b.u, { key: 'test:disarm', flags: { disarm: true }, duration: 2 });
b.u.skill.gainSp(999);
b.u.skill.activate('test');
b.h.run(1.5);
assert.equal(bombHits(b.h, b.u).length, 0, 'disarmed: held');
assert.ok(b.h.runUntil(() => bombHits(b.h, b.u).length > 0, 2), 'fired once the disarm is gone');
done(b.h);
});
test('modules: SPT-X reveals the stealthed enemies of her range (stages 1 and 3); SPT-Y is a permanent +1 range (her initial range, S3 on top, never S2); with no module neither', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, shield: null });
const inR = h.spawn('enemy_dummy', { pos: [10, 6] }), outR = h.spawn('enemy_dummy', { pos: [10, 9] });
for (const e of [inR, outR]) h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
h.run(0.5);
assert.equal(!!inR.s.flags.reveal, elite && mod === SPTX, `${label(f)}: reveal in range`);
assert.equal(!!outR.s.flags.reveal, false, `${label(f)}: never out of range`);
const ext = elite && mod === SPTY ? 1 : 0;
assert.equal(u.s.baseRangeExtend, ext, `${label(f)}: permanent extension`);
assert.deepEqual(u.liveRangeGrid.length, 3 + ext, `${label(f)}: initial range`);
if (elite && mod) {
const m = modOf(tier, mod);
assert.ok(mod === SPTX ? /隐匿/.test(m.traitOverride.moduleDesc) : m.traitOverride.bb.ability_range_forward_extend === 1, `${label(f)}: the data`);
}
done(h);
}
});