95 lines
5.0 KiB
JavaScript
95 lines
5.0 KiB
JavaScript
// 追凶's first damage instance also carries its arts addition (PRTS 忍冬, talent remarks).
|
|
// Real full-content battles with controlled durable enemies; no HP/SP/ATK writes or forced casts.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
|
|
|
const NORMAL = 'chess_char_3_18_a';
|
|
const ELITE = 'chess_char_3_18_b';
|
|
const ENEMY = 'enemy_vulpis_probe';
|
|
const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} vs ${b}`);
|
|
const hunt = (c) => c.dmg?.tags?.includes('vulpisHunt');
|
|
const finished = (h) => { assert.deepEqual(h.b.errors, []); checkInvariants(h.b); };
|
|
function scenario(entry = {}, enemy = {}) {
|
|
return makeBattle({
|
|
units: [{ chessId: NORMAL, row: 10, col: 4, ...entry }],
|
|
defs: { enemies: { [ENEMY]: enemyRec({ key: ENEMY, hp: 1e7, speed: 0, ...enemy }) } },
|
|
autoFinish: false, timeLimit: 40, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true,
|
|
});
|
|
}
|
|
const variants = [
|
|
{ label: 'normal', chessId: NORMAL },
|
|
{ label: 'elite without module', chessId: ELITE, moduleId: 'none' },
|
|
{ label: 'elite SOL-X', chessId: ELITE, moduleId: 'uniequip_002_vulpis' },
|
|
{ label: 'elite SOL-Y', chessId: ELITE, moduleId: 'uniequip_003_vulpis' },
|
|
];
|
|
|
|
for (const { label, ...entry } of variants) for (const skillIndex of [0, 1, 2]) {
|
|
test(`追凶: first ordinary hit includes one arts addition (${label}, S${skillIndex + 1})`, () => {
|
|
const h = scenario({ ...entry, skillIndex });
|
|
h.step();
|
|
const u = h.unit(entry.chessId);
|
|
if (entry.moduleId) assert.equal(u.def.loadout.moduleId, entry.moduleId, 'the requested module is equipped');
|
|
const e = h.spawn(ENEMY, { pos: [10, 5] });
|
|
const hp = e.hp;
|
|
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u), 1));
|
|
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
|
const main = hits.filter((c) => !hunt(c));
|
|
assert.equal(main.length, 1, 'one ordinary attack');
|
|
assert.equal(main[0].dmg.isSkill, false, 'no skill at the first hit');
|
|
assert.equal(main[0].type, 'phys');
|
|
assert.ok(e.alive && e.hp > 0, 'the first attack did not kill the target');
|
|
const riders = hits.filter(hunt);
|
|
assert.equal(riders.length, 1, 'the very first hit carries 追凶');
|
|
assert.equal(riders[0].t, main[0].t);
|
|
assert.equal(riders[0].type, 'arts');
|
|
assert.ok(riders[0].dmg.tags.includes('addition'));
|
|
approx(riders[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale);
|
|
approx(hp - e.hp, main[0].amount + riders[0].amount);
|
|
assert.equal(h.eventsOf('dmg').filter((c) => c[1] === e.id).length, 2, 'both actual damage events exist');
|
|
finished(h);
|
|
});
|
|
}
|
|
|
|
for (const chessId of [NORMAL, ELITE]) for (const skillIndex of [0, 1, 2]) {
|
|
test(`追凶: first skill damage carries its addition (${chessId}, S${skillIndex + 1})`, () => {
|
|
const h = scenario({ chessId, skillIndex });
|
|
h.run(16); // every selected skill is naturally ready, with no enemies yet
|
|
const u = h.unit(chessId);
|
|
const e = h.spawn(ENEMY, { pos: [10, skillIndex === 1 ? 7 : skillIndex === 2 ? 6 : 5] });
|
|
const hp = e.hp;
|
|
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill), 2));
|
|
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
|
|
const main = hits.filter((c) => !hunt(c));
|
|
assert.ok(main.some((c) => c.dmg.isSkill));
|
|
// S1 has a physical attack and a separate arts skill instance; S2/S3 have one main instance each.
|
|
assert.equal(main.length, skillIndex === 0 ? 2 : 1);
|
|
const riders = hits.filter(hunt);
|
|
assert.equal(riders.length, main.length, 'one addition per qualifying damage instance, including the first');
|
|
for (const c of riders) approx(c.amount, u.s.atk * u.def.talents[0].bb.atk_scale);
|
|
approx(hp - e.hp, hits.reduce((s, c) => s + c.amount, 0));
|
|
assert.ok(e.alive && e.hp > 0);
|
|
finished(h);
|
|
});
|
|
}
|
|
|
|
test('追凶: each enemy starts its own fixed window; repeated damage does not extend it', () => {
|
|
const h = scenario();
|
|
h.step();
|
|
const u = h.unit(NORMAL);
|
|
const e = h.spawn(ENEMY, { pos: [10, 5] });
|
|
h.run(12);
|
|
const main = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !hunt(c));
|
|
const riders = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && hunt(c));
|
|
assert.ok(main.some((c) => c.t > main[0].t + 10));
|
|
assert.equal(riders.length, main.filter((c) => c.t - main[0].t <= 10 + 1e-9).length);
|
|
assert.ok(riders.every((c) => c.t - main[0].t <= 10 + 1e-9));
|
|
const another = h.spawn(ENEMY, { pos: [10, 6] });
|
|
// Naturally ready S3 can reach both enemies; the new target gets its own first-hit addition.
|
|
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === another), 10));
|
|
const first = h.hooksOf('damaged').filter((c) => c.target === another);
|
|
assert.equal(first.filter(hunt).length, 1);
|
|
assert.equal(first.filter(hunt)[0].t, first.find((c) => !hunt(c)).t);
|
|
finished(h);
|
|
});
|