- #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
141 lines
8.1 KiB
JavaScript
141 lines
8.1 KiB
JavaScript
// test/content/playtest5_followups.test.js — user playtest #5, cross-workstream follow-ups of the integration
|
||
// (docs/DESIGN.md §19): the element pipeline rules the workstreams left to the integrator, 塑心 S2's timing and
|
||
// 纯烬艾雅法拉's per-source 氤氲.
|
||
// * damage.js applyElement refuses a target with no HP left (hasHp — one shared helper): a rider in a `damaged` hook
|
||
// of a killing blow cannot burst the corpse, whatever content adds it.
|
||
// * 脆弱 (dmgTakenMul) does not scale 元素伤害 — gamedata_const ba.fragile "受到的物理、法术、真实伤害提升"; 元素脆弱
|
||
// (elementalTakenMul) does.
|
||
// * element healing lowers EVERY element type by the amount, each on its own — PRTS 菲莱 备注: "清除元素损伤" = "一次等同
|
||
// 于自身最大元素值(通常为1000)的全类型元素损伤治疗".
|
||
// * 塑心 S2 安魂的弥撒: "…凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" (PRTS 备注) — the 凋亡 lands
|
||
// before the partner's damage, so a killing blow still carries it.
|
||
// * 纯烬艾雅法拉 氤氲 is keyed per 纯烬 (two of them keep their own stacks and heal source).
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||
import { hasHp } from '../../server/sim/damage.js';
|
||
|
||
const SRC = getDefaultSource();
|
||
const raw = (id) => SRC.rawChess(id);
|
||
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} ≠ ${b}`);
|
||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||
const plain = (id, o = {}) => chessRec({ id, skill: null, stats: { maxHp: 1e6, ...(o.stats || {}) }, ...o });
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// damage.js
|
||
|
||
test('applyElement refuses a target with no HP left: a rider on a killing blow bursts nothing (pipeline guard)', () => {
|
||
for (const lethal of [true, false]) {
|
||
let riders = 0;
|
||
const h = makeBattle({
|
||
content: 'none', autoFinish: false, timeLimit: 30,
|
||
defs: { enemies: { enemy_fu: dummy('enemy_fu', { hp: 1000 }) } },
|
||
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
|
||
// a content rider with no HP check of its own: 1000 灼燃 on every damage it sees
|
||
setup: (b) => b.on('damaged', (c) => {
|
||
if (c.type === 'element' || c.type === 'elemental' || c.target.side !== 'enemy') return;
|
||
riders++;
|
||
b.dealDamage(null, c.target, { type: 'element', element: 'burn', amount: 1000 });
|
||
}),
|
||
});
|
||
h.step();
|
||
const e = h.enemy('enemy_fu');
|
||
h.b.dealDamage(null, e, { amount: lethal ? 5000 : 10, type: 'true' });
|
||
assert.equal(riders, 1, 'the rider ran');
|
||
if (lethal) {
|
||
assert.equal(e.alive, false);
|
||
assert.equal(e.elem.burn, 0, 'no fill on the corpse');
|
||
assert.equal(h.hooksOf('elementBurst').length, 0, 'no burst on the corpse');
|
||
assert.equal(h.hooksOf('fatal').length, 1, 'one fatal');
|
||
assert.equal(h.hooksOf('kill').length, 1, 'one kill');
|
||
} else {
|
||
assert.equal(h.hooksOf('elementBurst').length, 1, 'a living target still bursts');
|
||
}
|
||
checkInvariants(h.b);
|
||
}
|
||
assert.equal(hasHp(null), false);
|
||
assert.equal(hasHp({ alive: true, hp: 0 }), false);
|
||
assert.equal(hasHp({ alive: true, hp: 0, bossPool: { hp: 5 } }), true, 'a boss: its pool');
|
||
assert.equal(hasHp({ alive: false, hp: 5 }), false);
|
||
});
|
||
|
||
test('脆弱 does not scale 元素伤害 (ba.fragile: 物理、法术、真实); 元素脆弱 does', () => {
|
||
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { enemies: { enemy_fu: dummy('enemy_fu') } }, enemies: [{ key: 'enemy_fu', pos: [10, 6] }] });
|
||
h.step();
|
||
const e = h.enemy('enemy_fu');
|
||
const loss = (d) => { const hp0 = e.hp; h.b.dealDamage(null, e, d); return hp0 - e.hp; };
|
||
h.b.applyStatus(e, 'fragile', { duration: 10, value: 0.5 });
|
||
approx(loss({ amount: 1000, type: 'true' }), 1500, '脆弱 on true damage');
|
||
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1000, '脆弱 skips 元素伤害');
|
||
h.b.applyStatus(e, 'elemFragile', { duration: 10, value: 0.2 });
|
||
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1200, '元素脆弱');
|
||
approx(loss({ amount: 1000, type: 'true' }), 1500, '元素脆弱 skips true damage');
|
||
});
|
||
|
||
test('element healing lowers every element type by the amount, each on its own (PRTS 菲莱 备注)', () => {
|
||
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { chess: { g: plain('g') } }, units: [{ chessId: 'g', row: 10, col: 4 }] });
|
||
h.step();
|
||
const g = h.unit('g');
|
||
Object.assign(g.elem, { burn: 300, neural: 500, apoptosis: 0 });
|
||
approx(h.b.reduceElement(g, 400), 700, 'total removed');
|
||
assert.deepEqual([g.elem.burn, g.elem.neural, g.elem.apoptosis], [0, 100, 0], 'each type −400 (was: 400 in total)');
|
||
Object.assign(g.elem, { burn: 300, neural: 500 });
|
||
approx(h.b.reduceElement(g, 200, 'neural'), 200, 'one element');
|
||
assert.deepEqual([g.elem.burn, g.elem.neural], [300, 300]);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// 塑心 S2
|
||
|
||
test('塑心 S2 安魂的弥撒: the partner\'s 凋亡 lands before its damage (PRTS 备注) — a killing blow still carries it', () => {
|
||
const id = 'chess_char_6_09_a', sid = 'skchr_cello_2';
|
||
const s2 = raw(id).skills.find((s) => s.skillId === sid);
|
||
for (const hp of [1e7, 50]) {
|
||
const seq = [];
|
||
const h = makeBattle({
|
||
seed: 7, autoFinish: false, timeLimit: 60,
|
||
defs: { chess: { big: plain('big', { stats: { atk: 2000 } }) }, enemies: { enemy_fu: dummy('enemy_fu', { hp }) } },
|
||
units: [{ chessId: id, row: 10, col: 4, skillIndex: s2.index, carryState: { sp: 999 } }, { chessId: 'big', row: 10, col: 5 }],
|
||
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
|
||
setup: (b) => {
|
||
b.on('elementHit', (c) => { if (c.dmg.element === 'apoptosis' && c.target.side === 'enemy') seq.push(['el', c.source?.defId, c.target.hp]); });
|
||
b.on('damaged', (c) => { if (c.source?.defId === 'big' && c.type === 'phys') seq.push(['dmg', 'big', c.target.hp]); });
|
||
},
|
||
});
|
||
const u = h.unit(id), big = h.unit('big');
|
||
assert.ok(h.runUntil(() => u.skill.active && u.mem.celloPartner === big, 5), 'S2 running, partner picked');
|
||
assert.ok(h.runUntil(() => seq.some((x) => x[0] === 'dmg'), 10), 'the partner hits');
|
||
const i = seq.findIndex((x) => x[0] === 'dmg');
|
||
assert.ok(i > 0 && seq[i - 1][0] === 'el' && seq[i - 1][1] === id, `the 凋亡 (source 塑心) right before the damage: ${JSON.stringify(seq.slice(0, i + 1))}`);
|
||
assert.ok(seq[i - 1][2] > 0, 'the target still had its HP when the 凋亡 landed');
|
||
if (hp < 1000) assert.equal(h.enemy('enemy_fu')?.alive ?? false, false, 'the killing blow landed after it');
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// 纯烬艾雅法拉 氤氲
|
||
|
||
test('纯烬艾雅法拉 氤氲 is keyed per 纯烬: two of them keep their own stacks and each heals as itself', () => {
|
||
const id = 'chess_char_6_20_a';
|
||
const h = makeBattle({
|
||
seed: 5, autoFinish: false, timeLimit: 60,
|
||
defs: { chess: { g: plain('g') } },
|
||
// the ward stands outside both ranges: only the manual heals below reach it
|
||
units: [{ chessId: id, uid: 1, row: 12, col: 2 }, { chessId: id, uid: 2, row: 12, col: 3 }, { chessId: 'g', uid: 3, row: 9, col: 10 }],
|
||
});
|
||
h.step();
|
||
const a = h.unit(1), b = h.unit(2), g = h.unit(3);
|
||
h.b.heal(a, g, 1);
|
||
h.b.heal(b, g, 1);
|
||
h.b.heal(b, g, 1);
|
||
const ma = g.findBuff(`agoat2:mist:${a.id}`), mb = g.findBuff(`agoat2:mist:${b.id}`);
|
||
assert.ok(ma && mb, 'one 氤氲 per 纯烬');
|
||
assert.deepEqual([ma.stacks, mb.stacks], [1, 2]);
|
||
assert.equal(ma.source, a);
|
||
assert.equal(mb.source, b);
|
||
});
|