- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
424 lines
27 KiB
JavaScript
424 lines
27 KiB
JavaScript
// test/sim/playtest5_elements.test.js — user playtest #5 report 3 (enemy side): "海嗣敌人元素损伤效率有点低 — check every
|
||
// enemy's element damage and element gauge against the official game". Encodes the official numbers (enemy_database /
|
||
// PRTS 元素 / 游戏数据基础 / the enemies' PRTS pages / gamedata_const termDescriptionDict) for the gauge pipeline
|
||
// (server/sim/damage.js) and the enemies' element abilities (content/enemies.js):
|
||
// · 海嗣 enemies take element damage at the full rate (损伤抵抗 0, 1000 / leader 2000) and deal ATK × ratio per hit;
|
||
// · 元素损伤倍率 (elemTakenMul) scales the gauge, 元素脆弱 (elementalTakenMul) the 元素伤害 — never each other;
|
||
// · 损伤抵抗 (epResistance) reduces the gauge, 元素抗性 (epDamageResistance) the 元素伤害 (both with the 5 % floor);
|
||
// · bursts deal 无来源 damage (no damage-dealt multiplier / penetration of the unit that filled the gauge, no content hook
|
||
// sees a source — 深巡's 海怪 ×1.5, 精准狙击镜 — while the credit, the kill and damage sharing keep working);
|
||
// · 麻痹免疫 enemies get no 麻痹 from a 神经 burst;
|
||
// · 淤困 spreads 1000, 灼藤's first attack covers 3×3, 节日爵士乐手 channels on one locked target;
|
||
// · target-side 抵挡 (星熊 战术装甲, 拉普兰德 日晷) still negates a 无来源 burst — it has no source condition.
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||
import { ELEMENT, ELEMENT_GAUGE_MAX, ELEMENT_GAUGE_MAX_LEADER } from '../../server/sim/constants.js';
|
||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||
import * as bossesMod from '../../server/sim/content/bosses.js';
|
||
|
||
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
|
||
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||
const WALL = (id, o = {}) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: o.res ?? 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
|
||
const NOATK = () => ({ trait: { noAttack: true } });
|
||
const CHESS = { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_wall3: WALL('t_wall3'), t_wall4: WALL('t_wall4') };
|
||
const KITS = { t_wall: NOATK, t_wall2: NOATK, t_wall3: NOATK, t_wall4: NOATK };
|
||
/** Real enemies with their content (enemies.js / bosses.js) against inert synthetic walls. */
|
||
const arena = (o = {}) => makeBattle({
|
||
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
|
||
defs: { chess: CHESS }, kits: KITS, captureNoisy: true, hooks: ['elementBurst', 'damaged'], ...o,
|
||
});
|
||
/** Spawn a pinned enemy (speed ×0) at a tile. */
|
||
const put = (h, key, pos, mods = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...mods } });
|
||
const elemHits = (h, target, source = null) => h.hooksOf('damaged').filter((c) => c.type === 'element' && c.target === target && (!source || c.source === source));
|
||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, ...o });
|
||
const SEA = Object.values(E).filter((e) => e.tags.includes('seamonster'));
|
||
|
||
test('海嗣 enemies take element damage at the official rate: 损伤抵抗 0, gauge 1000 (leaders 2000), 元素抗性 0 on the burst', () => {
|
||
assert.ok(SEA.length >= 10, `${SEA.length} 海嗣 enemies in data`);
|
||
for (const rec of SEA) {
|
||
assert.equal(rec.stats.elementRes, 0, `${rec.name}: enemy_database epResistance (损伤抵抗) 0`);
|
||
assert.equal(rec.stats.elementDmgRes, 0, `${rec.name}: epDamageResistance (元素抗性) 0`);
|
||
const h = arena();
|
||
h.step();
|
||
const e = put(h, rec.key, [11, 8]);
|
||
assert.equal(e.gaugeMax, rec.rank === 'BOSS' ? ELEMENT_GAUGE_MAX_LEADER : ELEMENT_GAUGE_MAX, rec.name);
|
||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 250 });
|
||
approx(e.elem.burn, 250, 1e-9, `${rec.name}: no hidden reduction`);
|
||
if (rec.rank === 'BOSS' || e.bossPool) continue;
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
|
||
assert.ok(e.findBuff('burnBurst'), `${rec.name} bursts`);
|
||
approx(hp0 - e.hp, Math.min(hp0, ELEMENT.burn.enemy.elemDamage), 1e-9, `${rec.name}: 7000 元素伤害`);
|
||
}
|
||
});
|
||
|
||
test('海嗣 element attackers deal ATK × the official ratio per hit (PRTS talents), with the round\'s ATK multiplier', () => {
|
||
const cases = [
|
||
['enemy_1148_dssbr', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 底海滑动者 普通攻击额外造成攻击力15%的神经损伤
|
||
['enemy_1148_dssbr_2', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 富营养的滑动者 15 %
|
||
['enemy_2021_syfish', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 5]], // 骨海漂流体 攻击附加相当于攻击力20%的侵蚀损伤
|
||
['enemy_2025_syufo', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 7]], // 掠海漂移体 普通攻击附加攻击力50%的侵蚀损伤 (ranged 2.6)
|
||
['enemy_1234_dsubrl', 'neural', 'EpDamage.ep_damage_ratio', [9, 6]], // 深溟巢涌者 每次输出伤害时,再造成攻击力5%的神经损伤
|
||
['enemy_1521_dslily', 'neural', 'epdamage.attack@ep_damage_ratio', null], // 盐风主教昆图斯 攻击力20%的神经损伤
|
||
];
|
||
for (const [key, el, k, pos] of cases) {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||
h.step();
|
||
const e = pos ? put(h, key, pos, { atkMul: 1.331 }) : h.spawn(key, { pos: [9, 7], routeIndex: 0, mods: { speedMul: 0, atkMul: 1.331 } });
|
||
approx(e.s.atk, E[key].stats.atk * 1.331, 1e-9, `${E[key].name} ATK × round multiplier`);
|
||
const w = h.unit('t_wall');
|
||
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 30), `${E[key].name} hits the wall`);
|
||
const first = elemHits(h, w, e)[0];
|
||
assert.equal(first.dmg.element, el);
|
||
approx(first.amount, e.s.atk * E[key].talents.bb[k], 1e-9, `${E[key].name}: ATK × ${E[key].talents.bb[k]}`);
|
||
}
|
||
// 元核孽生者 (season override: ATK 400, 1.ep_damage_ratio 0.05): once hurt, every second, radius 2.5
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||
h.step();
|
||
const e = put(h, 'enemy_1439_dslntf', [9, 7]);
|
||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||
h.run(3.05);
|
||
const w = h.unit('t_wall');
|
||
const hits = elemHits(h, w, e);
|
||
assert.equal(hits.length, 3, 'one pulse per second');
|
||
approx(hits[0].amount, 400 * 0.05, 1e-9);
|
||
});
|
||
|
||
test('the user\'s case in numbers: blocking a 底海滑动者 (险境 R5, ATK ×0.968) bursts 神经 on the 25th hit — the official rate', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||
h.step();
|
||
const e = put(h, 'enemy_1148_dssbr', [9, 5], { atkMul: 0.968 });
|
||
const w = h.unit('t_wall');
|
||
const per = 280 * 0.968 * 0.15;
|
||
const need = Math.ceil(ELEMENT_GAUGE_MAX / per);
|
||
assert.equal(need, 25);
|
||
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').some((c) => c.target === w), 120), 'the wall bursts');
|
||
assert.equal(elemHits(h, w, e).length, need, `${need} hits of ${per.toFixed(1)}`);
|
||
approx(h.b.time, need * E.enemy_1148_dssbr.stats.bat, 0.2, '≈ 25 attacks × 2 s (game s)');
|
||
});
|
||
|
||
test('元素损伤倍率 (elemTakenMul) scales the gauge only; 元素脆弱 (elementalTakenMul) the 元素伤害 only', () => {
|
||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||
h.step();
|
||
const e = h.enemy('enemy_dummy');
|
||
h.b.applyStatus(e, 'elemFragile', { duration: 60, value: 0.2 });
|
||
approx(e.s.elementalTakenMul, 1.2);
|
||
approx(e.s.elemTakenMul, 1, 1e-9, '元素脆弱 ("受到的元素伤害提升") leaves the gauge alone');
|
||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||
approx(e.elem.burn, 500, 1e-9, 'gauge fill not × 元素脆弱');
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage * 1.2, 1e-9, '7000 元素伤害 × 元素脆弱');
|
||
// "受到的元素损伤提高至130%" (淤困) / "…降低12%" (operators' talents): the gauge, not 元素伤害
|
||
const h2 = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||
h2.step();
|
||
const e2 = h2.enemy('enemy_dummy');
|
||
h2.b.addBuff(e2, { key: 't:ep', mods: { elemTakenMul: 1.3 } });
|
||
h2.b.dealDamage(null, e2, { type: 'element', element: 'burn', amount: 500 });
|
||
approx(e2.elem.burn, 650);
|
||
const hp1 = e2.hp;
|
||
h2.b.dealDamage(null, e2, { amount: 100, type: 'elemental', element: 'burn' });
|
||
approx(hp1 - e2.hp, 100, 1e-9, '元素伤害 not × 元素损伤倍率');
|
||
});
|
||
|
||
test('损伤抵抗 (epResistance) reduces the gauge; 元素抗性 (epDamageResistance) reduces 元素伤害 — both with the 5 % floor (PRTS 游戏数据基础)', () => {
|
||
const rec = dummy({ key: 'enemy_res' });
|
||
rec.stats.elementRes = 10;
|
||
rec.stats.elementDmgRes = 30;
|
||
const hard = dummy({ key: 'enemy_hard' });
|
||
hard.stats.elementDmgRes = 100;
|
||
// "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可": max(0.05A, A(1 − 损伤抵抗 %))
|
||
hard.stats.elementRes = 98;
|
||
const h = makeBattle({ defs: { enemies: { enemy_res: rec, enemy_hard: hard } }, enemies: [{ key: 'enemy_res', pos: [11, 8] }, { key: 'enemy_hard', pos: [10, 8] }], content: 'none', autoFinish: false });
|
||
h.step();
|
||
const e = h.enemy('enemy_res');
|
||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||
approx(e.elem.burn, 450, 1e-9, '× (1 − 10 %)');
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(null, e, { amount: 1000, type: 'elemental' });
|
||
approx(hp0 - e.hp, 700, 1e-9, '元素伤害 × (1 − 30 %)');
|
||
const x = h.enemy('enemy_hard');
|
||
const hp1 = x.hp;
|
||
h.b.dealDamage(null, x, { amount: 1000, type: 'elemental' });
|
||
approx(hp1 - x.hp, 50, 1e-9, 'at least 5 %');
|
||
h.b.dealDamage(null, x, { type: 'element', element: 'burn', amount: 100 });
|
||
approx(x.elem.burn, 5, 1e-9, 'the gauge takes at least 5 % too (损伤抵抗 98 → 5, not 2)');
|
||
// content that predicts the pipeline (enemies.js expectedFinal — 吸收屏障 etc.) uses the same 元素伤害 terms
|
||
h.b.addBuff(e, { key: 't:frag', mods: { elementalTakenMul: 1.2 } });
|
||
const d = h.b.makeDamage({ amount: 1000, type: 'elemental' });
|
||
const hp2 = e.hp;
|
||
const f = enemiesMod.expectedFinal(null, e, d);
|
||
approx(f, 1000 * 0.7 * 1.2, 1e-9, 'expectedFinal: 元素抗性 30 and 元素脆弱 20 %');
|
||
h.b.dealDamage(null, e, d);
|
||
approx(hp2 - e.hp, f, 1e-9, '= what the pipeline deals');
|
||
});
|
||
|
||
test('element bursts deal 无来源 damage: the filler\'s damage-dealt multipliers and penetration never apply; it keeps the credit', () => {
|
||
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 50, blockCnt: 0 }, skill: null });
|
||
const h = makeBattle({
|
||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: 't_op', row: 9, col: 5 }],
|
||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false,
|
||
});
|
||
h.step();
|
||
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
|
||
h.b.addBuff(u, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
|
||
h.b.addBuff(e, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
|
||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×2');
|
||
approx(u.stats.dmg, ELEMENT.burn.enemy.elemDamage, 1e-9, 'credited to the operator that filled the gauge');
|
||
const hp1 = u.hp;
|
||
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 1000 });
|
||
approx(hp1 - u.hp, ELEMENT.burn.ally.damage * (1 - (50 - ELEMENT.burn.ally.resDown) / 100), 1e-9, '1200 arts through RES 50 − 20, no penetration, not ×3');
|
||
// apoptosis ticks on enemies too
|
||
h.run(10.1);
|
||
const hp2 = e.hp;
|
||
h.b.dealDamage(u, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||
h.run(3.02);
|
||
approx(hp2 - e.hp, 3 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
|
||
});
|
||
|
||
test('无来源 reaches the content hooks too: `hit` / `damaged` get no source, `credit` and the kill keep the filler (PRTS 伤害分类)', () => {
|
||
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
|
||
const h = makeBattle({
|
||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy(), enemy_small: dummy({ key: 'enemy_small', hp: 5000 }) } },
|
||
units: [{ chessId: 't_op', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_small', pos: [10, 8] }],
|
||
content: 'none', autoFinish: false, hooks: ['hit', 'damaged', 'kill'], captureNoisy: true,
|
||
});
|
||
h.step();
|
||
const u = h.unit('t_op'), e = h.enemy('enemy_dummy'), small = h.enemy('enemy_small');
|
||
// a content hook keyed on the attacker ("攻击…时攻击力提升至150%" style) — it must not see the burst
|
||
h.b.on('hit', (c) => { if (c.source === u && c.target.side === 'enemy') c.dmg.mul *= 1.5; });
|
||
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
|
||
approx(1e6 - e.hp, 1500, 1e-9, 'the hook works on the operator\'s own damage');
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
|
||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×1.5');
|
||
const burstHit = h.hooksOf('hit').find((c) => c.dmg.sourceless && c.target === e);
|
||
assert.equal(burstHit.source, null, 'hit: no source');
|
||
assert.equal(burstHit.credit, u, 'hit: credit');
|
||
const burstDmg = h.hooksOf('damaged').find((c) => c.dmg && c.dmg.sourceless && c.target === e);
|
||
assert.equal(burstDmg.source, null, 'damaged: no source');
|
||
assert.equal(burstDmg.credit, u, 'damaged: credit');
|
||
const gauge = h.hooksOf('damaged').find((c) => c.type === 'element' && c.target === e);
|
||
assert.equal(gauge.source, u, 'the 元素损伤 itself keeps its source');
|
||
// "无来源伤害的击杀也能追溯击杀来源"
|
||
h.b.dealDamage(u, small, { type: 'element', element: 'burn', amount: 1000 });
|
||
assert.equal(small.alive, false);
|
||
assert.equal(h.hooksOf('kill').find((c) => c.victim === small)?.killer, u, 'kill credited to the filler');
|
||
});
|
||
|
||
test('the user\'s pairing: a real 深巡 (garrison "攻击海怪敌人时攻击力提升至150%") bursting a real 海嗣 deals 7000, not 10500', () => {
|
||
const h = makeBattle({ units: [{ chessId: 'chess_char_1_04_a', row: 10, col: 3 }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3 });
|
||
h.step();
|
||
const u = h.allies()[0];
|
||
assert.deepEqual(u.def.raw.garrisonIds, ['garrison_18_a']);
|
||
const e = put(h, 'enemy_1439_dslntf', [11, 8]);
|
||
assert.ok(e.def.tags.includes('seamonster'), '元核孽生者 is a 海怪');
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(u, e, { amount: 100, type: 'true' });
|
||
approx(hp0 - e.hp, 100 * 1.5 * u.s.dmgDealtMul, 1e-9, 'its own damage vs a 海怪: ×1.5');
|
||
const hp1 = e.hp;
|
||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
|
||
assert.ok(e.findBuff('burnBurst'));
|
||
approx(hp1 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, 'the burst is 无来源: 7000');
|
||
});
|
||
|
||
test('精准狙击镜 (×damage_scale at ≥ 3 tiles) boosts its carrier\'s hits, never the burst it causes', () => {
|
||
const op = chessRec({ id: 't_op', profession: 'SNIPER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
|
||
const h = makeBattle({
|
||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 't_op', row: 10, col: 3, items: ['chess_item_3_02_e_a'] }],
|
||
enemies: [{ key: 'enemy_dummy', pos: [10, 8] }], content: 'full', autoFinish: false, timeLimit: 600,
|
||
});
|
||
h.step();
|
||
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
|
||
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
|
||
const boosted = (1e6 - e.hp) / u.s.dmgDealtMul;
|
||
assert.ok(boosted > 1000 + 1e-6, `the scope works at range (${boosted})`);
|
||
const hp0 = e.hp;
|
||
h.b.dealDamage(u, e, { type: 'element', element: 'erosion', amount: 1000 });
|
||
approx(hp0 - e.hp, ELEMENT.erosion.enemy.elemDamage, 1e-9, '5000 侵蚀 burst, not × the scope');
|
||
});
|
||
|
||
test('a 无来源 burst still passes on through content that shares damage: 圣杯 (credited, no source multiplier) and 盲信之誓', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3 }] });
|
||
h.step();
|
||
const w = h.unit('t_wall');
|
||
h.b.addBuff(w, { key: 't:boost', mods: { dmgDealtMul: 2 } });
|
||
const c = put(h, 'enemy_1430_lrrook', [10, 7]);
|
||
const o = put(h, 'enemy_1439_dslntf', [10, 6]);
|
||
const share = E.enemy_1430_lrrook.talents.bb['takeDmg.damage_scale'];
|
||
const c0 = c.hp, o0 = o.hp;
|
||
h.b.dealDamage(w, o, { type: 'element', element: 'burn', amount: o.gaugeMax });
|
||
const burst = ELEMENT.burn.enemy.elemDamage;
|
||
approx(c0 - c.hp, Math.min(c0, burst * share), 1e-9, 'the chalice takes its share of 7000 (not ×2)');
|
||
approx(o0 - o.hp, Math.min(o0, burst * (1 - share)), 1e-9);
|
||
approx(w.stats.dmg, (c0 - c.hp) + (o0 - o.hp), 1e-9, 'both parts credited to the filler');
|
||
// 盲信之誓: a spring passes PART_TRANSFER of what it takes to 假想敌:铳 — a burst too
|
||
const hb = arena({ kind: 'boss', sharedBoss: { hp: 1e6, maxHp: 1e6, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } }, units: [{ chessId: 't_wall', row: 12, col: 3 }] });
|
||
hb.step();
|
||
const g = hb.spawn('enemy_9017_achunt_2', { pos: [3, 18], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
|
||
g.profile.noAttack = true;
|
||
assert.ok(g.bossPool, '铳 on the shared pool');
|
||
const sp = hb.spawn('enemy_9020_actrpc', { pos: [3, 4], routeIndex: 0, mods: { speedMul: 0 }, tag: 'part' });
|
||
const pool0 = hb.b.sharedBoss.hp, sp0 = sp.hp;
|
||
hb.b.dealDamage(hb.unit('t_wall'), sp, { type: 'element', element: 'burn', amount: sp.gaugeMax });
|
||
assert.ok(sp0 - sp.hp > 0, 'the spring takes the burst');
|
||
approx(pool0 - hb.b.sharedBoss.hp, (sp0 - sp.hp) * bossesMod.PART_TRANSFER, 1e-9, '…and passes its share to 铳');
|
||
});
|
||
|
||
test('凋亡 burst on an enemy: 800 元素伤害 per second, the 50 % 虚弱 drops to 50 % × 剩余时间 ÷ 15 s at each tick (PRTS 元素)', () => {
|
||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ atk: 1000 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||
h.step();
|
||
const e = h.enemy('enemy_dummy');
|
||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||
approx(e.s.atk, 500, 1e-9, '50 % at once');
|
||
for (const t of [1, 5, 14]) {
|
||
h.run(t - (h.b.time - 1 / 30) + 0.02);
|
||
const left = ELEMENT.apoptosis.enemy.duration - t;
|
||
assert.ok(Math.abs(e.s.atk - 1000 * (1 - 0.5 * left / ELEMENT.apoptosis.enemy.duration)) < 3, `${t} s: ATK ${e.s.atk.toFixed(1)}`);
|
||
}
|
||
approx(1e6 - e.hp, 14 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
|
||
});
|
||
|
||
test('麻痹免疫 enemies get no 麻痹 from a 神经 burst (PRTS 元素 "若单位不具有麻痹免疫") — still the 6000 元素伤害', () => {
|
||
const imm = dummy({ key: 'enemy_imm', otherImmunities: ['palsy'] });
|
||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy(), enemy_imm: imm } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_imm', pos: [10, 8] }], content: 'none', autoFinish: false });
|
||
h.step();
|
||
const a = h.enemy('enemy_dummy'), b = h.enemy('enemy_imm');
|
||
for (const e of [a, b]) h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 });
|
||
assert.equal(a.findBuff('palsy')?.stacks, ELEMENT.neural.enemy.palsy);
|
||
assert.equal(b.findBuff('palsy'), null, 'immune');
|
||
approx(1e6 - b.hp, ELEMENT.neural.enemy.elemDamage);
|
||
// a real one: 假想敌:铳 (palsyImmune)
|
||
assert.ok(E.enemy_9017_achunt.stats.otherImmunities.includes('palsy'));
|
||
});
|
||
|
||
test('假想敌:淤困: its host\'s burst deals 1000 of the same element to the allies of the 4 tiles around (PRTS)', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 10, col: 6 }, { chessId: 't_wall4', row: 11, col: 6 }] });
|
||
h.step();
|
||
put(h, 'enemy_9007_acelem', [10, 5]);
|
||
h.run(0.5);
|
||
const host = h.unit('t_wall');
|
||
assert.ok(host.findBuff('ab:parasite'), 'parasitised');
|
||
h.b.dealDamage(null, host, { type: 'element', element: 'neural', amount: 1000 });
|
||
const spread = (u) => elemHits(h, u).filter((c) => c.dmg.element === 'neural');
|
||
for (const id of ['t_wall2', 't_wall3']) {
|
||
const s = spread(h.unit(id));
|
||
assert.equal(s.length, 1, `${id} (next to the host) takes one spread`);
|
||
approx(s[0].amount, 1000, 1e-9, `${id}: 1000`);
|
||
assert.ok(h.unit(id).findBuff('neuralBurst'), `${id} bursts`);
|
||
}
|
||
assert.equal(spread(h.unit('t_wall4')).length, 0, 'the diagonal tile is not one of the 4');
|
||
});
|
||
|
||
test('灼藤: its first attack burns and hits every other ally of the target\'s 3×3 (x-4), diagonals included', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 9, col: 5 }, { chessId: 't_wall4', row: 12, col: 5 }] });
|
||
h.step();
|
||
const v = put(h, 'enemy_10067_ftsjc', [10, 5]);
|
||
assert.ok(h.runUntil(() => v.stats.attacks >= 1, 10), 'first attack');
|
||
const atk = elemHits(h, h.unit('t_wall'), v)[0].amount / E.enemy_10067_ftsjc.talents.bb['pow.attack@ep_damage_ratio'];
|
||
for (const id of ['t_wall2', 't_wall3']) {
|
||
const u = h.unit(id);
|
||
approx(elemHits(h, u, v)[0]?.amount ?? 0, atk * 0.25, 1e-6, `${id}: 25 % burn`);
|
||
assert.ok(u.stats.taken > 0, `${id}: 100 % arts splash`);
|
||
}
|
||
assert.equal(elemHits(h, h.unit('t_wall4'), v).length, 0, 'two rows away: outside');
|
||
});
|
||
|
||
test('节日爵士乐手 狂欢式演奏: channels on ONE locked target (20 % arts + 10 % burn every 0.5 s), not on everyone around', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 11, col: 5 }] });
|
||
h.step();
|
||
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
|
||
h.run(6);
|
||
const w = h.unit('t_wall');
|
||
const hits = elemHits(h, w, e);
|
||
assert.ok(hits.length >= 4, `the locked target burns (${hits.length})`);
|
||
approx(hits[0].amount, e.s.atk * 0.1, 1e-9);
|
||
for (const id of ['t_wall2', 't_wall3']) assert.equal(elemHits(h, h.unit(id), e).length, 0, `${id} is not the target`);
|
||
});
|
||
|
||
test('节日爵士乐手: channels never overlap (cooldown 10 s < channel 10.6 s) — 21 ticks each, no normal attack while channelling', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
|
||
h.step();
|
||
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
|
||
const w = h.unit('t_wall');
|
||
const sk = E.enemy_10034_cnvsax.skills.find((x) => x.prefabKey === 'fire');
|
||
const iv = sk.bb.hit_interval, n = Math.floor(sk.bb['enemy_cnvsax[cd].duration'] / iv + 1e-9);
|
||
assert.equal(n, 21, 'floor(10.6 / 0.5)');
|
||
const arts = [], during = [];
|
||
h.b.on('damaged', (c) => {
|
||
if (c.target !== w || c.source !== e) return;
|
||
if (c.type === 'arts') arts.push(h.b.time);
|
||
else if (c.type === 'phys' && e.findBuff('ab:channel')) during.push(h.b.time);
|
||
});
|
||
const skill = e.mem.ab.list.find((a) => a && a.fire && a.cd != null);
|
||
const casts = [];
|
||
for (let i = 0; i < 30 * 30; i++) {
|
||
h.step();
|
||
if ((skill.casts ?? 0) > casts.length) casts.push(h.b.time);
|
||
}
|
||
assert.ok(casts.length >= 2, `cast twice in 30 s (${casts.map((t) => t.toFixed(2))})`);
|
||
for (let i = 1; i < arts.length; i++) assert.ok(arts[i] - arts[i - 1] >= iv - 0.02, `no doubled tick at ${arts[i].toFixed(2)}`);
|
||
for (let i = 0; i + 1 < casts.length; i++) {
|
||
const k = arts.filter((t) => t > casts[i] && t <= casts[i + 1]).length;
|
||
assert.equal(k, n, `cast ${i + 1}: ${n} ticks`);
|
||
assert.ok(casts[i + 1] - casts[i] >= n * iv - 1e-6, 'the next cast waits for the channel');
|
||
}
|
||
assert.deepEqual(during, [], 'no normal attack while a channel runs');
|
||
});
|
||
|
||
test('抵挡 is target-side: 星熊 (战术装甲) and 拉普兰德 S1 日晷 still negate a 无来源 burst on themselves (source null, credit = the enemy)', async () => {
|
||
const { getDefaultSource } = await import('../../server/sim/simdata.js');
|
||
const ds = getDefaultSource();
|
||
const altIdx = (id) => ds.rawChess(id).skills.find((s) => !s.isDefault).index;
|
||
const cases = [
|
||
['chess_char_4_17_a', {}, ['erosion', 'burn']], // 星熊: phys (侵蚀 800) and arts (灼燃 1200)
|
||
['chess_char_2_16_a', { skillIndex: altIdx('chess_char_2_16_a'), carryState: { sp: 999 } }, ['erosion']], // 日晷: phys only
|
||
];
|
||
for (const [id, extra, elements] of cases) {
|
||
for (const roll of [true, false]) {
|
||
for (const element of elements) {
|
||
const h = makeBattle({
|
||
units: [{ chessId: id, row: 9, col: 5, ...extra }], defs: { enemies: { enemy_dummy: dummy() } },
|
||
enemies: [{ key: 'enemy_dummy', pos: [9, 5] }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3, // blocked: in range
|
||
setup: (b) => { b.rng.chance = () => roll; },
|
||
});
|
||
h.step();
|
||
const u = h.unit(id), e = h.enemy('enemy_dummy');
|
||
if (extra.skillIndex != null) assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: 日晷 on`);
|
||
const hp0 = u.hp;
|
||
h.b.dealDamage(e, u, { type: 'element', element, amount: u.gaugeMax });
|
||
assert.ok(u.findBuff(`${element}Burst`), `${id} ${element}: bursts`);
|
||
if (roll) assert.equal(u.hp, hp0, `${id} ${element}: the burst is 抵挡-ed`);
|
||
else assert.ok(u.hp < hp0, `${id} ${element}: a failed roll lands`);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test('卢西恩 / 不祥幻影 in their only stage (boss_5 = act2autochess_h07_05 / _s): the level\'s overrides — ATK 700 (solo 600) / 200, 12 % 神经 per hit', () => {
|
||
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
|
||
// level_act2autochess_h07_05(_s).json enemyDbRefs[5] / [7] overwrittenData: atk 700 (600) / 200,
|
||
// combat.attack@ep_damage_ratio 0.12
|
||
for (const [tpl, key, atk] of [['act2autochess_h07_05', 'enemy_2016_csphtm', 700], ['act2autochess_h07_05_s', 'enemy_2016_csphtm', 600], ['act2autochess_h07_05', 'enemy_2017_csphts', 200]]) {
|
||
const h = arena({
|
||
kind: 'boss', sharedBoss: { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } },
|
||
units: [{ chessId: 't_wall', row: 10, col: 5 }], setup(b) { b.enemyOverrides = W[tpl].overrides; },
|
||
});
|
||
h.step();
|
||
const w = h.unit('t_wall');
|
||
// blocked on the wall's tile: its first normal attack lands before the 5 s initial cooldown of its aoe skill (0.2)
|
||
const e = h.spawn(key, { pos: [w.y, w.x].map(Math.round), routeIndex: 0, mods: { speedMul: 0 }, tag: key === 'enemy_2016_csphtm' ? 'boss' : undefined });
|
||
assert.equal(e.s.atk, atk, `${tpl} ${E[key].name}: ATK`);
|
||
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 4.5), `${E[key].name} hits`);
|
||
const first = elemHits(h, w, e)[0];
|
||
assert.equal(first.dmg.element, 'neural');
|
||
approx(first.amount, atk * 0.12, 1e-9, `${tpl} ${E[key].name}: ATK × 0.12 (base data: ${E[key].stats.atk} × ${E[key].talents.bb['combat.attack@ep_damage_ratio']})`);
|
||
}
|
||
});
|