// 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); } });