218 lines
14 KiB
JavaScript
218 lines
14 KiB
JavaScript
// GitHub issue #32 item 6 (after 0.1.1): 「隐蔽单位仍会受到溅射伤害」 — a 隐匿 operator still took enemy splash.
|
|
//
|
|
// Official: PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中" (a stealthed operator that blocks an
|
|
// enemy is attacked by it whatever its selectability: "因为“阻挡优先级最高”的效果,敌人会无视一切可选性对该干员进行攻击");
|
|
// §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 选择器 (可选判定): a selector skips 无法选择 units
|
|
// unless the ability "无视可选性" — as PRTS marks 萨卡兹悖谬暴虐兵长's 暴击 "(无视无法选择,无视迷彩)" and 假想敌:淤困's burst
|
|
// spread "(中点判定,无视目标可选性,不受迷彩制约)"; gamedata_const ba.invisible 隐匿 "不阻挡时不成为敌方攻击的目标" vs
|
|
// ba.camou 迷彩 "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)"; PRTS 异常效果 迷彩 "所有光环类能力、以及涉及中点判定/
|
|
// 格子判定的效果均不受迷彩制约"; PRTS 选择器 "所有触发选择器通常不无视迷彩" (an area skill is cast for a target it may attack).
|
|
// Cause: the enemy side's area effects (content/enemies.js, content/bosses.js) took every ally in the area; only the
|
|
// operator side skipped an unblocked 隐匿 enemy (Battle.foesInRadius, 0.1.1).
|
|
// Now: targeting.js areaSelectable / enemies.js areaAllies, areaAlliesInTiles, fieldAllies — no 隐匿 ally unless it blocks
|
|
// the enemy, no untargetable or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is hit (DESIGN §22.12).
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
|
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
|
import { areaSelectable } from '../../server/sim/targeting.js';
|
|
import { areaAllies } from '../../server/sim/content/enemies.js';
|
|
|
|
const REAL = { skip: !hasGeneratedData() };
|
|
const JF = 'chess_char_1_02_a'; // 角峰 (重装, blocks 3)
|
|
const GM = 'chess_char_1_10_a'; // 古米 (重装, blocks 3)
|
|
const HD = 'chess_char_1_05_a'; // 红豆 (先锋)
|
|
const SKULSR = 'enemy_1500_skulsr'; // 碎骨: unblocked, its attacks are grenades that splash the target's surroundings
|
|
const POMPEII = 'enemy_1050_lslime'; // “庞贝”: blocked ⇒ every 10 s a radius-1 arts self-blast
|
|
const LUCIEN = 'enemy_2016_csphtm'; // 卢西恩,“猩红血钻” (boss_5): 【aoe】 radius 2, "需要目标", "该技能伤害无视迷彩"
|
|
const BRUTE = 'enemy_1320_wdrrl_2'; // 萨卡兹悖谬暴虐兵长: first attack's 暴击 splash "无视无法选择,无视迷彩"
|
|
const PARASITE = 'enemy_9007_acelem'; // 假想敌:淤困: parasitises its blocker; the host's burst spreads "无视目标可选性"
|
|
const FAKE_SUIT = 'chess_item_4_04_e_a'; // 伪装服: first damage taken ⇒ 隐匿 15 s
|
|
|
|
const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); };
|
|
const stealth = (h, u) => h.b.applyStatus(u, 'stealth', { duration: 999, source: u });
|
|
const camou = (h, u) => h.b.applyStatus(u, 'camou', { duration: 999, source: u });
|
|
/** Every damage instance the enemy `key` deals: { t, target, attack (a normal attack), skill, amount, tags }. */
|
|
function damageLog(h, key) {
|
|
const log = [];
|
|
h.b.on('damaged', (c) => {
|
|
if (!c.source || c.source.defId !== key) return;
|
|
log.push({ t: h.b.time, target: c.target, attack: !!c.dmg.isAttack, skill: !!c.dmg.isSkill, amount: c.amount, tags: c.dmg.tags || [] });
|
|
}, { priority: 1000 });
|
|
return log;
|
|
}
|
|
function countFx(h, kind) {
|
|
const n = { v: 0 };
|
|
const fx0 = h.b.fx.bind(h.b);
|
|
h.b.fx = (k, p) => { if (k === 'explode' && p && p.kind === kind) n.v++; return fx0(k, p); };
|
|
return n;
|
|
}
|
|
|
|
test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetable / sleeping one, no 起飞 one for a ground source; 迷彩 is selected', () => {
|
|
const h = makeBattle({
|
|
seed: 2, autoFinish: false, timeLimit: 60,
|
|
defs: { enemies: { enemy_g: enemyRec({ key: 'enemy_g', hp: 1e7, speed: 0 }), enemy_f: enemyRec({ key: 'enemy_f', hp: 1e7, speed: 0, motion: 'FLY' }) } },
|
|
units: [{ chessId: JF, row: 9, col: 6 }, { chessId: GM, row: 10, col: 6 }, { chessId: HD, row: 9, col: 7 }],
|
|
enemies: [{ key: 'enemy_g', pos: [9, 6] }, { key: 'enemy_f', pos: [11, 6] }],
|
|
});
|
|
h.step(2);
|
|
const jf = h.unit(JF), gm = h.unit(GM), hd = h.unit(HD);
|
|
const g = h.b.enemies.find((e) => e.defId === 'enemy_g'), f = h.b.enemies.find((e) => e.defId === 'enemy_f');
|
|
assert.equal(g.blockedBy, jf, '角峰 blocks the ground enemy');
|
|
assert.ok(f.isFlying && !g.isFlying);
|
|
for (const u of [jf, gm, hd]) assert.ok(areaSelectable(g, u) && areaSelectable(f, u) && areaSelectable(null, u), `${u.def.name}: plain ⇒ selected`);
|
|
stealth(h, jf); stealth(h, gm);
|
|
assert.equal(areaSelectable(g, jf), true, '隐匿 but blocking the source: selected');
|
|
assert.equal(areaSelectable(f, jf), false, '隐匿, blocking another enemy: not selected');
|
|
assert.equal(areaSelectable(g, gm), false, '隐匿, not blocking: not selected');
|
|
assert.equal(areaSelectable(null, gm), false, '隐匿 vs an effect with no selecting enemy');
|
|
assert.deepEqual(areaAllies(h.b, g, g.x, g.y, 1.01).map((u) => u.def.name).sort(), [jf, hd].map((u) => u.def.name).sort(), 'areaAllies: the 隐匿 bystander is left out');
|
|
camou(h, hd);
|
|
assert.equal(areaSelectable(g, hd), true, '迷彩: still selected by an area effect ("无法躲避溅射类攻击")');
|
|
h.b.addBuff(gm, { key: 'test:liftoff', persist: true, flags: { liftoff: true } });
|
|
h.b.removeBuff(gm, 'stealth');
|
|
assert.equal(areaSelectable(g, gm), false, '起飞 vs a ground source (对地规避)');
|
|
assert.equal(areaSelectable(f, gm), true, '起飞 vs a flying source');
|
|
assert.equal(areaSelectable(null, gm), true, '起飞 vs no source');
|
|
h.b.removeBuff(gm, 'test:liftoff');
|
|
h.b.addBuff(gm, { key: 'test:untargetable', persist: true, flags: { untargetable: true } });
|
|
assert.equal(areaSelectable(f, gm), false, 'untargetable (不可选中)');
|
|
h.b.removeBuff(gm, 'test:untargetable');
|
|
h.b.addBuff(gm, { key: 'test:sleep', persist: true, flags: { sleep: true } });
|
|
assert.equal(areaSelectable(f, gm), false, 'asleep (沉睡 = 无敌)');
|
|
h.b.removeBuff(gm, 'test:sleep');
|
|
h.b.kill(g, null);
|
|
assert.equal(areaSelectable(g, jf), false, 'a dead source (death blast) blocks nobody: its 隐匿 former blocker is spared');
|
|
done(h);
|
|
});
|
|
|
|
/** 碎骨 walks lane row 9 and grenades 角峰 on (10,6); 古米 stands next to it (distance 1 = the splash radius). */
|
|
function grenades({ gm = [10, 7], items = [], status = null, seconds = 25 } = {}) {
|
|
const h = makeBattle({
|
|
seed: 5, timeLimit: 120, autoFinish: false,
|
|
units: [{ chessId: JF, row: 10, col: 6, dir: 'RIGHT' }, { chessId: GM, row: gm[0], col: gm[1], dir: 'RIGHT', items }],
|
|
enemies: [{ key: SKULSR, route: 0, time: 3 }],
|
|
});
|
|
h.step(1);
|
|
const jf = h.unit(JF), g = h.unit(GM);
|
|
if (status) h.b.applyStatus(g, status, { duration: 999, source: g });
|
|
const log = damageLog(h, SKULSR);
|
|
h.run(seconds);
|
|
return { h, jf, g, log, onJf: log.filter((x) => x.target === jf && x.attack), splashOnG: log.filter((x) => x.target === g && !x.attack), attacksOnG: log.filter((x) => x.target === g && x.attack) };
|
|
}
|
|
|
|
test('#32.6 碎骨: a 隐匿 operator next to the grenade\'s target takes no splash; a 迷彩 one still does', REAL, () => {
|
|
const hid = grenades({ status: 'stealth' });
|
|
assert.ok(hid.onJf.length >= 4, `4+ grenades on 角峰 (${hid.onJf.length})`);
|
|
assert.ok(hid.g.s.flags.stealth, '古米 stays 隐匿 (999 s)');
|
|
assert.deepEqual(hid.log.filter((x) => x.target === hid.g).map((x) => `${x.t.toFixed(2)} ${Math.round(x.amount)}`), [], '隐匿, not blocking: nothing reaches it (in 0.1.1: 4 splashes, knocked out)');
|
|
done(hid.h);
|
|
const cam = grenades({ status: 'camou' });
|
|
assert.ok(cam.onJf.length >= 4, `4+ grenades on 角峰 (${cam.onJf.length})`);
|
|
assert.equal(cam.attacksOnG.length, 0, '迷彩: never the grenade\'s target');
|
|
const standing = (t) => cam.g.alive || t <= cam.g.deathAt + 1e-6; // the splashes may knock it out
|
|
assert.ok(cam.splashOnG.length >= 3, `迷彩: splashed (${cam.splashOnG.length})`);
|
|
assert.equal(cam.splashOnG.length, cam.onJf.filter((x) => standing(x.t)).length, '迷彩: splashed by every grenade while it stands (ba.camou "无法躲避溅射类攻击")');
|
|
assert.ok(cam.splashOnG.every((x) => x.skill && x.amount > 0));
|
|
done(cam.h);
|
|
});
|
|
|
|
test('#32.6 碎骨: 伪装服\'s 隐匿 (its carrier\'s first damage) spares the carrier from the following splashes until it ends', REAL, () => {
|
|
// 古米 on (10,5) — deployed before 角峰, so 角峰 is the grenades' target (latest deployed) and 古米 its neighbour
|
|
const r = grenades({ gm: [10, 5], items: [FAKE_SUIT], seconds: 32 });
|
|
const first = r.splashOnG[0];
|
|
assert.ok(first && first.amount > 0, 'the first grenade splashes 古米 — its first damage');
|
|
const end = first.t + 15;
|
|
const during = r.log.filter((x) => x.target === r.g && x.t > first.t + 1e-6 && x.t < end - 1e-6);
|
|
const jfDuring = r.onJf.filter((x) => x.t > first.t + 1e-6 && x.t < end - 1e-6);
|
|
assert.ok(jfDuring.length >= 3, `grenades kept landing next to it (${jfDuring.length})`);
|
|
assert.deepEqual(during.map((x) => `${x.t.toFixed(2)} ${Math.round(x.amount)}`), [], '伪装服 隐匿 (flag `stealth`): no splash for its 15 s');
|
|
assert.ok(r.log.some((x) => x.target === r.g && x.t > end), '…and hit again once its 隐匿 ends');
|
|
done(r.h);
|
|
});
|
|
|
|
test('#32.6 “庞贝”\'s self-blast while blocked hits its 隐匿 blocker and a 迷彩 bystander, not a 隐匿 bystander', REAL, () => {
|
|
const h = makeBattle({
|
|
seed: 3, timeLimit: 120, autoFinish: false,
|
|
units: [{ chessId: JF, row: 9, col: 6, dir: 'RIGHT' }, { chessId: GM, row: 10, col: 6, dir: 'RIGHT' }, { chessId: HD, row: 9, col: 7, dir: 'RIGHT' }],
|
|
enemies: [{ key: POMPEII, pos: [9, 6], time: 1, mods: { hpMul: 1e3, speedMul: 0, atkMul: 0.01 } }],
|
|
});
|
|
h.step(1);
|
|
const jf = h.unit(JF), gm = h.unit(GM), hd = h.unit(HD);
|
|
stealth(h, jf); stealth(h, gm); camou(h, hd);
|
|
const log = damageLog(h, POMPEII), blasts = countFx(h, 'selfBlast');
|
|
h.run(12);
|
|
const p = h.b.enemies.find((e) => e.defId === POMPEII);
|
|
assert.equal(p.blockedBy, jf, 'blocked by the 隐匿 角峰');
|
|
assert.equal(blasts.v, 1, 'one self-blast in 12 s');
|
|
const blast = (u) => log.filter((x) => x.target === u && x.skill && !x.attack).length;
|
|
assert.equal(blast(jf), 1, '隐匿 blocker: hit (a blocked enemy ignores its blocker\'s selectability)');
|
|
assert.equal(blast(hd), 1, '迷彩 bystander: hit');
|
|
assert.equal(blast(gm), 0, '隐匿 bystander (distance 1, not blocking): spared');
|
|
done(h);
|
|
});
|
|
|
|
test('#32.6 boss: 卢西恩\'s 【aoe】 is not cast for 隐匿 / 迷彩 operators alone; cast, it hits the 迷彩 and plain ones, not the 隐匿 one', REAL, () => {
|
|
const run = (plain) => {
|
|
const units = [{ chessId: JF, row: 10, col: 5, dir: 'RIGHT' }, { chessId: GM, row: 11, col: 6, dir: 'RIGHT' }];
|
|
if (plain) units.push({ chessId: HD, row: 9, col: 6, dir: 'RIGHT' });
|
|
const h = makeBattle({ seed: 3, timeLimit: 400, autoFinish: false, units, enemies: [{ key: LUCIEN, pos: [10, 6], time: 1, mods: { hpMul: 1e3, speedMul: 0, atkMul: 0.01 } }] });
|
|
h.step(1);
|
|
stealth(h, h.unit(JF)); camou(h, h.unit(GM));
|
|
const log = damageLog(h, LUCIEN), casts = countFx(h, 'crimsonAoe');
|
|
h.run(30);
|
|
const aoe = (id) => log.filter((x) => x.target === h.unit(id) && x.skill && !x.attack).length;
|
|
return { h, casts: casts.v, aoe };
|
|
};
|
|
const alone = run(false);
|
|
assert.equal(alone.casts, 0, 'only a 隐匿 and a 迷彩 operator within 2: no cast (its trigger selection skips both)');
|
|
done(alone.h);
|
|
const r = run(true);
|
|
assert.ok(r.casts >= 2, `cast with 红豆 in range (${r.casts})`);
|
|
assert.equal(r.aoe(HD), r.casts, 'plain: hit by every cast');
|
|
assert.equal(r.aoe(GM), r.casts, '迷彩: hit by every cast ("该技能伤害无视迷彩")');
|
|
assert.equal(r.aoe(JF), 0, '隐匿, not blocking: never hit');
|
|
done(r.h);
|
|
});
|
|
|
|
test('#32.6 ignoreSelect: 萨卡兹悖谬暴虐兵长\'s 暴击 ("无视无法选择") and 假想敌:淤困\'s burst spread ("无视目标可选性") still reach a 隐匿 operator', REAL, () => {
|
|
{
|
|
const h = makeBattle({
|
|
seed: 3, timeLimit: 120, autoFinish: false,
|
|
units: [{ chessId: JF, row: 9, col: 6, dir: 'RIGHT' }, { chessId: GM, row: 10, col: 6, dir: 'RIGHT' }],
|
|
enemies: [{ key: BRUTE, pos: [9, 6.4], time: 1, mods: { hpMul: 1e3, speedMul: 0 } }],
|
|
});
|
|
h.step(1);
|
|
const gm = h.unit(GM);
|
|
stealth(h, gm);
|
|
const log = damageLog(h, BRUTE);
|
|
h.run(6);
|
|
assert.equal(h.b.enemies.find((e) => e.defId === BRUTE).blockedBy, h.unit(JF), '角峰 (block 3) holds it');
|
|
const splash = log.filter((x) => x.target === gm && x.tags.includes('aoeAttack'));
|
|
assert.equal(splash.length, 1, '暴击 splash on the 隐匿 古米 next to its target (once: "此技能仅能触发一次")');
|
|
assert.ok(splash[0].amount > 0);
|
|
done(h);
|
|
}
|
|
{
|
|
const h = makeBattle({
|
|
seed: 3, timeLimit: 120, autoFinish: false,
|
|
units: [{ chessId: JF, row: 9, col: 6, dir: 'RIGHT' }, { chessId: GM, row: 10, col: 6, dir: 'RIGHT' }, { chessId: HD, row: 9, col: 7, dir: 'RIGHT' }],
|
|
enemies: [{ key: PARASITE, pos: [9, 6.4], time: 1, mods: { hpMul: 1e3, speedMul: 0 } }],
|
|
});
|
|
h.step(1);
|
|
const jf = h.unit(JF), gm = h.unit(GM), hd = h.unit(HD);
|
|
stealth(h, gm);
|
|
h.b.addBuff(hd, { key: 'test:liftoff', persist: true, flags: { liftoff: true, blockFly: true } }); // 起飞 (蒂比's flag)
|
|
h.run(1.5);
|
|
assert.ok(jf.findBuff('ab:parasite'), '淤困 parasitises its blocker 角峰');
|
|
const src = h.b.enemies.find((e) => e.defId === PARASITE);
|
|
h.b.dealDamage(src, jf, { type: 'element', element: 'burn', amount: 5000, tags: ['test'] });
|
|
h.step(1);
|
|
assert.ok(jf.findBuff('burnBurst') || jf.elem.burn >= jf.gaugeMax - 1e-6, 'the host bursts');
|
|
assert.ok(gm.elem.burn > 0, '隐匿 neighbour: 1000 burn spread onto it');
|
|
assert.ok(hd.elem.burn > 0, 'airborne 起飞 neighbour: reached too (until 0.1.2 the pipeline refused a ground enemy\'s fill)');
|
|
done(h);
|
|
}
|
|
});
|