192 lines
12 KiB
JavaScript
192 lines
12 KiB
JavaScript
// test/sim/feedback1d-bombd.test.js — community report D4 after 0.1.0 ("敌方无人机暴鸰的炸弹无法正常投放"). 暴鸰
|
||
// (enemy_1040_bombd, only from the 悬赏·飞行II / 战术特训·飞行II bounties) did trigger its one bomb, but the damage and the
|
||
// blast hit the operator in the very tick of the trigger while the drone flew on with the bomb still hanging under it
|
||
// for the rest of its life: nothing ever left the drone. Official (PRTS 暴鸰; the client's battle prefab
|
||
// enemy_1040_bombd + projectile_bombd): the cast plays the Attack clip, the bomb leaves on its OnAttack event (0.267 s,
|
||
// `_waitForAttackEvent`), flies to the target as a projectile (`_speed` 5, homing, `_ignoreCamouflage`) and explodes
|
||
// there on the target and the 8 tiles around it; the cast ends once the bomb has landed, at least 0.667 s after the
|
||
// release (`_fireAttackFinishWhenProjectileInvalid`, `_minPostDelayWhenProjectileInvalid`), with the bomb-less Idle_2 as
|
||
// its end clip, and its buff bomb_s switches the drone to its bomb-less mode (S1: the *_2 clips) at ×2 speed
|
||
// ("技能结束后移速最终提升至200%"). Now (content/enemies.js kitBombd): the drone hovers through the cast; 'atk' kind
|
||
// 'droneBomb' + fx 'phase' { kind: 'bombed' } at the release (the view starts the *_2 clips after the Attack clip),
|
||
// damage on arrival, the speed-up when the cast ends; a stun before the release interrupts the cast (Boomb
|
||
// `_immuneStunWhenAffecting` 0: no bomb leaves a stunned drone; it casts again once free). Also UnitInfo.form for
|
||
// 掠海漂移体's crawl.
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeMatch, give, legalTileFor, chessOfTier, DATA } from '../match/harness.js';
|
||
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
|
||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||
import { PROJECTILE_SPEEDS, TICK } from '../../server/sim/constants.js';
|
||
|
||
const KEY = 'enemy_1040_bombd';
|
||
const RELEASE = enemiesMod.BOMBD_RELEASE;
|
||
const POST = enemiesMod.BOMBD_POST_DELAY;
|
||
const ability = (e) => e.mem.ab.list.find((a) => a && a.fire);
|
||
|
||
/** A real round: solo 绝境 match, real stage and wave, seven real operators, the 悬赏·飞行II bounty (one 暴鸰). */
|
||
function realRound(seed = 3, round = 4) {
|
||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed, fake: false });
|
||
h.start();
|
||
h.toPrep(round);
|
||
const m = h.m, ps = h.ps('p_0');
|
||
let placed = 0;
|
||
for (const id of [...chessOfTier(1), ...chessOfTier(2)]) {
|
||
if (placed >= 7) break;
|
||
const t = legalTileFor(m, ps, id);
|
||
if (!t) continue;
|
||
give(m, ps, id, 'board', t);
|
||
placed++;
|
||
}
|
||
m.addBounty(ps, DATA.choices.cards.bounty.find((c) => c.effectId === 'enemyeffect_10_5'));
|
||
const opts = m._normalOpts(ps);
|
||
assert.ok(opts.spawns.some((s) => s.enemyKey === KEY), 'the bounty adds a 暴鸰 to the real wave');
|
||
return { h, m, b: m.newBattle(opts) };
|
||
}
|
||
|
||
describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
|
||
test('real wave: trigger (hover) → release on the OnAttack frame (atk droneBomb, bomb-less mode) → damage on arrival → cast end: ×2 speed', () => {
|
||
const { m, b } = realRound();
|
||
const log = [];
|
||
let drone = null, castAt = null, hitAt = null, firstHit = null, castPos = null, endPos = null, runAt = null, prev = null;
|
||
const hits = [];
|
||
b.on('damaged', (c) => {
|
||
if (c.source !== drone || !drone) return;
|
||
hits.push({ t: b.time, target: c.target, amount: c.amount ?? c.dmg?.amount, splash: (c.dmg?.tags || []).includes('splash') });
|
||
if (hitAt == null) { hitAt = b.time; firstHit = c.target; }
|
||
});
|
||
while (!b.finished && b.time < 200) {
|
||
prev = drone ? { x: drone.x, y: drone.y } : null; // where it is when the next tick starts
|
||
b.step();
|
||
if (!drone) drone = b.enemies.find((x) => x.defId === KEY) || null;
|
||
if (drone && castAt == null && (ability(drone)?.casts ?? 0) > 0) {
|
||
castAt = b.time;
|
||
castPos = { x: drone.x, y: drone.y };
|
||
assert.equal(hits.length, 0, 'nothing is hit in the tick of the trigger');
|
||
}
|
||
for (const ev of b.drainEvents()) if (drone && ((ev[0] === 'atk' && ev[1] === drone.id) || (ev[0] === 'fx' && ev[4]?.id === drone.id))) log.push([b.time, ev]);
|
||
if (castAt != null && runAt == null && drone.findBuff('ab:bombRun')) { runAt = b.time; endPos = prev; }
|
||
if (runAt != null && b.time > runAt + 1) break;
|
||
if (castAt != null && b.time > castAt + 5) break;
|
||
}
|
||
assert.ok(drone, 'the drone spawned');
|
||
assert.ok(castAt != null, 'it triggered its bomb near the operators');
|
||
const atk = log.filter(([, ev]) => ev[0] === 'atk');
|
||
assert.equal(atk.length, 1, 'exactly one drop (and never a normal attack)');
|
||
const [relT, relEv] = atk[0];
|
||
assert.equal(relEv[3], 'droneBomb', 'drawn as the drone\'s bomb');
|
||
assert.ok(Math.abs(relT - castAt - RELEASE) < 1e-6, `released ${(relT - castAt).toFixed(3)} s after the trigger (OnAttack, frame 8 of the Attack clip)`);
|
||
const phase = log.find(([, ev]) => ev[0] === 'fx' && ev[1] === 'phase');
|
||
assert.ok(phase && phase[1][4].kind === 'bombed' && Math.abs(phase[0] - relT) < 1e-9, 'the bomb has left the drone: its bomb-less mode from the release');
|
||
if (drone.alive) assert.equal(b.fieldMeta().units.find((u) => u.id === drone.id).form, 'bombed', 'a field opened later draws it bomb-less');
|
||
assert.ok(hitAt != null, 'the bomb lands');
|
||
assert.equal(firstHit.id, relEv[2], 'on the operator it was dropped on');
|
||
assert.ok(hitAt - relT > TICK, `then flies (${(hitAt - relT).toFixed(3)} s at ${PROJECTILE_SPEEDS.droneBomb} tiles/s)`);
|
||
assert.ok(hitAt - relT < 1.2, 'and lands promptly');
|
||
const main = hits.filter((x) => !x.splash);
|
||
assert.equal(main.length, 1, 'one main hit');
|
||
for (const x of hits.filter((y) => y.splash)) {
|
||
assert.ok(Math.max(Math.abs(x.target.tileR - firstHit.tileR), Math.abs(x.target.tileC - firstHit.tileC)) <= 1, 'splash only on the 8 tiles around the target');
|
||
}
|
||
assert.ok(drone.alive, 'the drone outlives its cast in this round');
|
||
const run = drone.findBuff('ab:bombRun');
|
||
assert.equal(run?.mods?.moveMul, DATA.enemies[KEY].skills[0].bb.move_speed, '技能结束后移速最终提升至200%');
|
||
const castEnd = Math.max(hitAt, relT + POST);
|
||
assert.ok(Math.abs(runAt - castEnd) <= TICK + 1e-9, `when the cast ends (${(runAt - castAt).toFixed(3)} s after the trigger; landed ${(hitAt - castAt).toFixed(3)})`);
|
||
assert.ok(Math.hypot(endPos.x - castPos.x, endPos.y - castPos.y) < 1e-9, 'hovering in place through the cast (its Attack clip)');
|
||
assert.ok(Math.hypot(drone.x - endPos.x, drone.y - endPos.y) > 0.5, 'then flies on');
|
||
assert.equal(drone.stats.attacks, 0, 'no normal attack');
|
||
m.dispose();
|
||
});
|
||
|
||
const WALL = (id) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
|
||
const arena = (units) => makeBattle({
|
||
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 600,
|
||
defs: { chess: { t_a: WALL('t_a'), t_b: WALL('t_b'), t_c: WALL('t_c') } },
|
||
kits: { t_a: () => ({ trait: { noAttack: true } }), t_b: () => ({ trait: { noAttack: true } }), t_c: () => ({ trait: { noAttack: true } }) },
|
||
units,
|
||
});
|
||
const put = (h, pos) => h.spawn(KEY, { pos, routeIndex: 0, mods: { speedMul: 0 }, route: 2 });
|
||
|
||
test('a target gone mid-flight: the bomb still lands where it was and splashes its neighbours', () => {
|
||
const h = arena([{ chessId: 't_a', row: 10, col: 5 }, { chessId: 't_b', row: 11, col: 5 }]);
|
||
h.step();
|
||
const e = put(h, [10, 7]);
|
||
const [a, bb] = [h.unit('t_a'), h.unit('t_b')];
|
||
h.runUntil(() => (ability(e)?.casts ?? 0) > 0, 5);
|
||
h.run(RELEASE + TICK);
|
||
const rel = h.eventsOf('atk').find((ev) => ev[1] === e.id);
|
||
assert.ok(rel, 'released');
|
||
const tgt = [a, bb].find((u) => u.id === rel[2]);
|
||
const other = tgt === a ? bb : a;
|
||
h.b.retreat(tgt, { reason: 'test', permanent: true });
|
||
h.run(1.5);
|
||
assert.equal(tgt.stats.taken, 0, 'the withdrawn target is not hit');
|
||
assert.ok(other.stats.taken > 0, 'its neighbour takes the splash at the landing point');
|
||
});
|
||
|
||
test('the drone killed after the release: the bomb in the air still lands (projectile_bombd keeps flying)', () => {
|
||
const h = arena([{ chessId: 't_a', row: 10, col: 5 }]);
|
||
h.step();
|
||
const e = put(h, [10, 7]);
|
||
h.runUntil(() => (ability(e)?.casts ?? 0) > 0, 5);
|
||
h.run(RELEASE + TICK);
|
||
assert.ok(h.eventsOf('atk').some((ev) => ev[1] === e.id), 'released');
|
||
h.b.kill(e, null);
|
||
h.run(1.5);
|
||
assert.ok(Math.abs(h.unit('t_a').stats.taken - e.s.atk) < 1e-6, 'hit for the ATK it had at the release');
|
||
assert.ok(!e.findBuff('ab:bombRun'), 'a dead drone gets no speed-up');
|
||
});
|
||
|
||
test('a stun before the release interrupts the cast: nothing leaves the drone; it casts again once free and drops its one bomb', () => {
|
||
const h = arena([{ chessId: 't_a', row: 10, col: 6 }]);
|
||
h.step();
|
||
const e = put(h, [10, 7]);
|
||
h.runUntil(() => (ability(e)?.casts ?? 0) > 0, 5);
|
||
const castAt = h.b.time;
|
||
assert.ok(h.b.applyStatus(e, 'stun', { duration: 1.5, source: null }), 'stunned mid-cast');
|
||
h.run(1.4);
|
||
const drops = () => h.events.filter((ev) => ev[0] === 'atk' && ev[1] === e.id);
|
||
assert.equal(drops().length, 0, 'no drop from a stunned drone (Boomb `_immuneStunWhenAffecting` 0)');
|
||
assert.ok(!h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'phase' && ev[4]?.id === e.id), 'it keeps its bomb');
|
||
assert.equal(h.unit('t_a').stats.taken, 0);
|
||
assert.ok(!e.findBuff('ab:bombRun'), 'no speed-up');
|
||
h.runUntil(() => drops().length > 0, 3);
|
||
assert.equal(ability(e).casts, 2, 'cast again once the stun is over');
|
||
assert.ok(h.b.time >= castAt + 1.5 + RELEASE - 1e-6, 'after the stun');
|
||
h.run(2);
|
||
assert.equal(drops().length, 1, 'still one bomb in all');
|
||
assert.ok(Math.abs(h.unit('t_a').stats.taken - e.s.atk) < 1e-6, 'and it lands');
|
||
assert.ok(e.findBuff('ab:bombRun'), 'then the speed-up');
|
||
});
|
||
|
||
test('the drone killed before the release: no bomb, no mode change', () => {
|
||
const h = arena([{ chessId: 't_a', row: 10, col: 6 }]);
|
||
h.step();
|
||
const e = put(h, [10, 7]);
|
||
h.runUntil(() => (ability(e)?.casts ?? 0) > 0, 5);
|
||
h.b.kill(e, null);
|
||
h.run(2);
|
||
assert.equal(h.unit('t_a').stats.taken, 0);
|
||
assert.ok(!h.events.some((ev) => ev[0] === 'atk' && ev[1] === e.id), 'no drop');
|
||
assert.ok(!h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'phase' && ev[4]?.id === e.id), 'no bomb-less mode');
|
||
});
|
||
});
|
||
|
||
describe('UnitInfo.form: every mode the renderer draws is recorded (a field opened later)', () => {
|
||
test('掠海漂移体 stunned → 爬行模式: UnitInfo.form \'crawl\' (render/units.js FORMS starts a later view on the *_02 clips)', () => {
|
||
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 60, captureNoisy: true,
|
||
defs: { chess: { t_a: chessRec({ id: 't_a', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
|
||
kits: { t_a: () => ({ trait: { noAttack: true } }) }, units: [{ chessId: 't_a', row: 10, col: 3 }] });
|
||
h.step();
|
||
const e = h.spawn('enemy_2025_syufo', { pos: [10, 6], routeIndex: 0, mods: { speedMul: 0, atkMul: 0 } });
|
||
const form = () => h.b.fieldMeta().units.find((u) => u.id === e.id)?.form;
|
||
assert.equal(form(), undefined, 'hovering');
|
||
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.2, source: null }));
|
||
h.step();
|
||
assert.ok(h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'phase' && ev[4]?.kind === 'crawl'), 'it dropped');
|
||
assert.equal(form(), 'crawl');
|
||
});
|
||
});
|