feedback1 WD: 逐火 embers can be beaten, 转译基底 changes form before it can die

Player reports after 0.1.0:
- #8 深池逐火战士 / 精锐战士 / 护卫: every knock-out is a 1 s 重生 (invulnerable,
  unblockable, immobile), then a walking, disarmed, 隐匿 hit-count ember that a
  blocker can target and beat; it stands up with full HP after 10 s (PRTS talent
  text). The old ember stood still, stealthed and unblockable, so nobody could
  ever target it and every 逐火 enemy leaked. 假想敌:再生's puppet walks on and
  stays unblockable.
- #5 转译基底·α: its original form cancels every damage instance; the 4th
  physical / arts hit or a block starts a 2 s change into 寻仇者 / 特战术师 /
  幽灵 (Mode_*_Passive, 寻仇者 rage below half HP, 特战术师 two targets). The
  armed forms attack through the engine (profile.dmgType). It used to die in its
  first form.
- Render: units.js FORMS switch the models' clip sets (A_Die_B/C/D then B/C/D,
  Die then Idle_2/Move_2/Die_2, Revive), driven by the 'phase' kind or the fx
  `form`; a noMove enemy is drawn standing.
This commit is contained in:
sganggs committed 2026-10-03 01:52:40 +08:00
1 parent 7f134737ce
commit 92282878d3
9 files changed
+676 -112

No files matched your search

+22 -10
View File
@@ -15,6 +15,7 @@ const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.m
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
const { KITS, STATS_ONLY, EROSION, EROSION_BURST } = enemiesMod;
const { BOSS_KITS, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE, droneLinkBase } = bossesMod;
const { HUSK_REBIRTH, TRANSLATOR_CHANGE } = enemiesMod;
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
@@ -213,7 +214,7 @@ for (const key of ['enemy_1200_msfjin', 'enemy_1204_msfhu', 'enemy_1288_duskls']
h.step();
const e = put(h, key, [10, 7]);
const g = h.unit('t_mage');
if (/duskls/.test(key)) { killed(h, e, g); assert.ok(e.alive, 'ember'); }
if (/duskls/.test(key)) { killed(h, e, g); assert.ok(e.alive, 'ember'); h.run(HUSK_REBIRTH + 0.05); }
const hits = e.s.maxHp;
assert.ok(hits > 1 && hits <= 30);
let n = 0;
@@ -307,7 +308,7 @@ for (const key of ['enemy_1203_sfhu', 'enemy_1203_sfhu_2']) {
}
for (const key of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) {
test(`${nm(key)}: knocked out ⇒ stealthed ember needing ${tb(key, 'Revive[Trigger].prop_max_hp')} hits; revives at full HP after ${tb(key, 'Revive[Trigger].interval')} s`, () => {
test(`${nm(key)}: knocked out ⇒ ${HUSK_REBIRTH} s 重生, then a walking, disarmed, 隐匿 ember needing ${tb(key, 'Revive[Trigger].prop_max_hp')} hits; full HP again ${tb(key, 'Revive[Trigger].interval')} s later`, () => {
const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }] });
h.step();
const e = put(h, key, [10, 7]);
@@ -315,19 +316,23 @@ for (const key of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskl
killed(h, e, h.unit('t_gun'));
assert.ok(e.alive);
assert.equal(e.s.maxHp, tb(key, 'Revive[Trigger].prop_max_hp'));
assert.ok(e.s.flags.stealth && e.profile.noAttack);
h.run(tb(key, 'Revive[Trigger].interval') + 0.1);
assert.ok(e.s.flags.invulnerable && e.s.flags.unblockable && e.s.flags.noMove, '重生');
h.run(HUSK_REBIRTH + 0.05);
assert.ok(e.s.flags.stealth && e.s.flags.disarm && e.profile.noAttack, '余烬: 隐匿, 缴械');
assert.ok(!e.s.flags.unblockable && !e.s.flags.noMove, 'blockable, walks');
h.run(tb(key, 'Revive[Trigger].interval'));
assert.equal(e.s.maxHp, max);
assert.equal(e.hp, max);
assert.ok(!e.s.flags.stealth);
// an ember hit N times dies for good
killed(h, e, null);
h.run(HUSK_REBIRTH + 0.05);
for (let i = 0; i < tb(key, 'Revive[Trigger].prop_max_hp'); i++) h.b.dealDamage(null, e, { amount: 1, type: 'arts' });
assert.ok(!e.alive);
});
}
test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ 15-hit regeneration husk that revives, and nearby enemies get a 5-hit shield`, () => {
test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ an unblockable 15-hit 傀儡 that walks on and revives, and nearby enemies get a 5-hit shield`, () => {
const h = arena();
h.step();
const e = put(h, 'enemy_9010_acpupp', [10, 7]);
@@ -335,6 +340,8 @@ test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ 15-hit regeneration husk that
killed(h, e, null);
assert.ok(e.alive);
assert.equal(e.s.maxHp, tb('enemy_9010_acpupp', 'Revive[Trigger].prop_max_hp'));
h.run(HUSK_REBIRTH + 0.05);
assert.ok(e.s.flags.unblockable && e.s.flags.disarm && !e.s.flags.stealth && !e.s.flags.noMove, '傀儡: 不可被阻挡, not stealthed');
const hp = o.hp;
for (let i = 0; i < tb('enemy_9010_acpupp', 'Aura.max_damage_block_cnt'); i++) h.b.dealDamage(null, o, { amount: 100, type: 'phys' });
assert.equal(o.hp, hp, 'shield negates the hits');
@@ -342,6 +349,7 @@ test(`${nm('enemy_9010_acpupp')}: knocked out ⇒ 15-hit regeneration husk that
assert.ok(o.hp < hp);
h.run(tb('enemy_9010_acpupp', 'Revive[Trigger].interval') + 0.1);
assert.equal(e.hp, E.enemy_9010_acpupp.stats.maxHp);
assert.ok(!e.s.flags.unblockable);
});
// ---------------------------------------------------------------------------------------------------------------
@@ -1831,30 +1839,34 @@ test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.dama
assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), true, 'stunnable in the second form');
});
test(`${nm('enemy_10081_mpplai')}: no attack until 4 physical hits (复仇: melee), 4 arts hits (术士: ranged arts) or being blocked (幽灵: unblockable)`, () => {
test(`${nm('enemy_10081_mpplai')}: original form cancels every damage instance; 4th physical hit ⇒ 寻仇者 (melee), 4th arts hit ⇒ 特战术师 (ranged arts), blocked ⇒ 幽灵 (unblockable) — each after a ${TRANSLATOR_CHANGE} s change`, () => {
const mk = () => { const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }, { chessId: 't_mage', row: 12, col: 4 }], kits: { t_gun: NOATK, t_mage: NOATK } }); h.step(); return h; };
let h = mk();
let e = put(h, 'enemy_10081_mpplai', [10, 7]);
for (let i = 0; i < 4; i++) h.b.dealDamage(h.unit('t_gun'), e, { amount: 10, type: 'phys' });
const hp = e.hp;
for (let i = 0; i < 4; i++) assert.equal(h.b.dealDamage(h.unit('t_gun'), e, { amount: 1e6, type: 'phys' }), 0);
assert.equal(e.hp, hp, 'no damage lands in the original form');
h.run(TRANSLATOR_CHANGE + 0.05);
approx(e.s.atk, E.enemy_10081_mpplai.stats.atk + tb('enemy_10081_mpplai', 'Mode_Fuchou_Passive.atk'));
approx(e.s.maxHp, E.enemy_10081_mpplai.stats.maxHp + tb('enemy_10081_mpplai', 'Mode_Fuchou_Passive.max_hp'));
const h2 = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
h2.step();
const f = put(h2, 'enemy_10081_mpplai', [10, 7]);
for (let i = 0; i < 4; i++) h2.b.dealDamage(null, f, { amount: 1, type: 'arts' });
assert.equal(f.mem.ab.list.length, 3);
// arts hits from nobody (terrain) do not count
h2.run(TRANSLATOR_CHANGE + 0.05);
assert.equal(f.findBuff('ab:form'), null);
h = mk();
e = put(h, 'enemy_10081_mpplai', [11, 5]);
for (let i = 0; i < 4; i++) h.b.dealDamage(h.unit('t_mage'), e, { amount: 10, type: 'arts' });
h.run(TRANSLATOR_CHANGE + 0.05);
approx(e.s.res, E.enemy_10081_mpplai.stats.res + tb('enemy_10081_mpplai', 'Mode_Shushi_Passive.magic_resistance'));
h.run(3);
assert.ok(e.stats.attacks > 0 && h.unit('t_gun').stats.taken + h.unit('t_mage').stats.taken > 0, '术士 attacks in range');
assert.ok(e.stats.attacks > 0 && h.unit('t_gun').stats.taken + h.unit('t_mage').stats.taken > 0, '特战术师 attacks in range');
const h3 = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h3.step();
const g = put(h3, 'enemy_10081_mpplai', [9, 5]);
h3.run(0.5);
h3.run(TRANSLATOR_CHANGE + 0.3);
assert.ok(g.findBuff('ab:form') && g.s.flags.unblockable, '幽灵');
assert.equal(g.stats.attacks, 0);
});
+98 -2
View File
@@ -3,6 +3,10 @@
// melee operators block and hit it, so its model must stop hovering: render/units.js FORMS switches the view to the
// skeleton's crawl clips (*_02) after its 'Change' clip (render/spine.js SpineActor.setForm), also for a view built
// after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js).
// Player reports after 0.1.0 (#5, #8): 转译基底·α changes on its 2 s A_Die_B / _C / _D clip into the 寻仇者 B_*, 幽灵 C_* or
// 特战术师 D_* set (it used to stay on A_Idle / A_Move and die on B_Die — "加载变身动画然后就没了"); a knocked-out 逐火 plays
// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' brings the warrior back
// (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -116,7 +120,99 @@ describe('掠海漂移体 爬行模式 (FORMS)', () => {
});
});
test('render/app.js hands the sim\'s fx \'phase\' to the view and keeps the mode on the unit info', () => {
/** A real-manifest enemy view (any skeleton). */
async function enemy(id, info = {}) {
const ctx = fakeViewCtx(fake.P, { assets: store(id), cam });
const v = new UnitView(ctx, { id: 9, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 2, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
await tick(); await tick();
assert.ok(v.actor, 'Spine model built');
return v;
}
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
const TR = 'enemy_10081_mpplai';
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {
const anims = assets.enemies[TR].spine.animations;
for (const [kind, f] of Object.entries(FORMS[TR])) {
for (const name of [f.change, f.roles.idle, f.roles.die, f.roles.move.loop]) assert.ok(name in anims, `${kind}: ${name}`);
assert.equal(anims[f.change], 2, `${kind}: ${f.change}`);
}
assert.equal(FORMS[TR].translator_youling.roles.attack, null, '幽灵 never attacks');
assert.equal(assets.enemies[TR].spine.anims.idle, 'A_Idle');
assert.equal(assets.enemies[TR].spine.anims.move.loop, 'A_Move');
});
for (const [kind, set, attack] of [['translator_fuchou', 'B', 'B_Attack'], ['translator_shushi', 'D', 'D_Attack'], ['translator_youling', 'C', null]]) {
test(`${kind}: the original form walks on A_*, the change clip plays out (2 s, moving or not), then the ${set}_* set`, async () => {
const v = await enemy(TR);
v.sync(sample(0, ANIM.MOVE), 1);
assert.equal(clip(v), 'A_Move');
v.setForm(kind);
assert.equal(clip(v), FORMS[TR][kind].change);
frames(v, 60);
v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), FORMS[TR][kind].change, 'not cut short');
frames(v, 70);
assert.equal(clip(v), `${set}_Move`);
v.sync(sample(0, ANIM.IDLE), 3);
assert.equal(clip(v), `${set}_Idle`);
v.onAttack(null, 3.2);
assert.equal(clip(v), attack ?? `${set}_Idle`, attack ? 'its attack clip' : 'no attack clip');
v.die();
assert.equal(clip(v), `${set}_Die`);
});
}
test('a view built after the change starts on the form\'s clips', async () => {
const v = await enemy(TR, { form: 'translator_shushi' });
v.sync(sample(), 1);
assert.equal(clip(v), 'D_Idle');
});
});
describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () => {
for (const id of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) {
test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; revived ⇒ 'Revive', then the warrior's clips`, async () => {
const anims = assets.enemies[id].spine.animations;
for (const name of ['Die', 'Idle_2', 'Move_2', 'Die_2', 'Revive']) assert.ok(name in anims, name);
const v = await enemy(id);
v.sync(sample(0, ANIM.MOVE), 1);
assert.equal(clip(v), 'Move');
v.setForm('husk');
assert.equal(clip(v), 'Die', 'the knock-out (its 1 s 重生)');
frames(v, 65);
assert.equal(clip(v), 'Move_2', 'the ember walks');
v.sync(sample(UF.STEALTH, ANIM.IDLE), 2);
assert.equal(clip(v), 'Idle_2');
v.setForm('revived');
assert.equal(clip(v), 'Revive');
frames(v, 80);
assert.equal(clip(v), 'Idle', 'the warrior again');
v.onAttack(null, 4);
assert.equal(clip(v), 'Attack');
v.setForm('husk');
frames(v, 65);
v.die();
assert.equal(clip(v), 'Die_2', 'the ember dies on its own clip');
});
}
test('假想敌:再生: A_Die, the 傀儡 on B_Idle / B_Move / B_Die; B_Revive back to the A_* clips', async () => {
const v = await enemy('enemy_9010_acpupp');
v.setForm('husk');
assert.equal(clip(v), 'A_Die');
frames(v, 65);
assert.equal(clip(v), 'B_Idle');
v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), 'B_Move');
v.setForm('revived');
assert.equal(clip(v), 'B_Revive');
frames(v, 65);
assert.equal(clip(v), 'A_Move');
});
});
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /e\[1\] === 'phase'[\s\S]{0,300}inf\.form = [\s\S]{0,200}setForm\?\.\(e\[4\]\.kind\)/);
assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/);
});
+360
View File
@@ -0,0 +1,360 @@
// test/sim/feedback1-transform.test.js — player reports after the public 0.1.0 (workstream WD: enemies that change form
// when "killed"), real enemy data in the real sim:
// #5 转译基底·α "不会变身,原血量打死就加载变身动画然后就没了": its original form cancels every damage instance (PRTS 转译基底·α
// 天赋 "受到伤害时取消此伤害"); the 4th physical / 4th arts instance or being blocked starts a 2 s change into 寻仇者 /
// 特战术师 / 幽灵 (Mode_*_Passive) — only the changed form can die. v0.1.0 let damage through: a sniper + caster lineup
// killed it in its first form without any change, and its model (still in that form) played the 寻仇者's B_Die.
// #8 深池逐火战士 / 精锐战士 / 护卫 "被击倒后变的怨恨的余烬无法被击杀": every knock-out ⇒ 1 s 重生 (无敌 + 无法阻挡, immobile) ⇒
// a 隐匿, disarmed ember of 5 (护卫 10) hits that walks its route on and can be blocked — blocked it is targetable and
// dies for good after that many damage instances; still standing after 10 s it is the warrior again with full HP
// (PRTS 深池逐火战士 天赋). v0.1.0's ember stood still, stealthed AND unblockable: nobody could ever target it, so
// every 逐火 enemy of a round leaked.
// The real-round tests build the battle like Match._normalOpts: an official wave (waves.js buildNormalWave, a 悬赏 card
// through withBounties), the real stage 战场#01, real chess, full content, buildBattleSpec → createBattleFromSpec.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { buildNormalWave, withBounties } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { GEO } from '../../shared/constants.js';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
import { HUSK_REBIRTH, TRANSLATOR_CHANGE } from '../../server/sim/content/enemies.js';
const QUIET = { warn() {}, error() {}, info() {} };
const DATA = getData({ log: QUIET });
const E = DATA.enemies;
const MODE = 'mode_multi_hard';
const STAGE = 'act2autochess_m01';
const gd = new GameData(DATA, MODE);
const tb = (key, k) => E[key].talents.bb[k];
const TR = 'enemy_10081_mpplai';
const EMBERS = ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld'];
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
/** A player's normal field as Match._normalOpts builds it (real wave, real stage, full content). */
function realBattle({ round, wave, spawns, units, seed = 77 }) {
const spec = buildBattleSpec({
battleId: 'fb1.wd', fieldId: 'n:p1', kind: 'normal', seed, modeId: MODE, round, stageId: STAGE, rect: { ...GEO.NORMAL_RECT },
timeLimit: wave.timeLimit, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units, bonds: {}, playerEffects: [] }],
spawns: (spawns ?? wave.spawns).map((s) => ({ ...s, ownerPlayerId: 'p1' })), routes: wave.routes,
flags: { layerGainsEnabled: true, ...gd.dp }, enemyOverrides: wave.overrides, waveId: wave.templateId,
});
return createBattleFromSpec(spec, getDefaultSource(), { quiet: true });
}
const chess = (uid, chessId, row, col, dir = 'RIGHT') => ({ uid, kind: 'chess', chessId, row, col, dir });
// synthetic operators for the rule tests (real enemies, real stage and routes): a blocker that never attacks, an
// instant long-range phys gun and arts mage that reach the whole field
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
const CHESS = {
t_wall: chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }),
t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null }),
t_mage: chessRec({ id: 't_mage', profession: 'CASTER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null }),
};
const NOATK = () => ({ trait: { noAttack: true } });
/** Real stage + the real routes of an official wave template (route 0 = 战场#01's lower lane (9,10) → (9,2)). */
const ROUTES = DATA.waves.act1autochess_03.routes;
function arena(o = {}) {
const { kits = {}, ...rest } = o;
return makeBattle({
stageId: STAGE, routes: ROUTES, content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: MODE, round: 3,
defs: { chess: CHESS }, kits: { t_wall: NOATK, ...kits }, ...rest,
});
}
const put = (h, key, o = {}) => h.spawn(key, { routeIndex: 0, ...o });
/** Synthetic attackers that never attack (the test deals every hit itself). */
const QUIET_GUNS = { t_gun: NOATK, t_mage: NOATK };
// ---------------------------------------------------------------------------------------------------------------
// #8 逐火 embers
describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker can beat', () => {
test('a real TIMES round (R3, 深池逐火精锐战士 pick) on 战场#01: every knocked-out 逐火 becomes an ember, the blocked embers die for good — cleared, nobody leaks, each enemy dies once', () => {
let wave = null;
for (let seed = 1; seed < 500 && !wave; seed++) {
const w = buildNormalWave(gd, createRng(seed), ['TIMES', 'INVISIBLE', 'FLY'], 3);
if (w.pick && w.pick.key === 'enemy_1288_duskls_2') wave = w;
}
assert.ok(wave, 'fixture: an official R3 wave with the 深池逐火 TIMES entry');
assert.ok(wave.spawns.some((s) => EMBERS.includes(s.enemyKey)), 'the round sends 逐火 enemies');
const b = realBattle({
round: 3, wave, units: [
chess(1, 'chess_char_4_23_b', 9, 8), // 百炼嘉维尔 blocks the lower lane
chess(2, 'chess_char_4_20_b', 10, 4), // 远牙
chess(3, 'chess_char_4_02_b', 11, 4), // 莫斯提马
chess(4, 'chess_char_4_17_b', 12, 6), // 星熊 blocks the upper lane's way down
],
});
const ember = new Map(); // id → { u, at, x, y, attacks } of its current ember
let walked = 0; // the longest walk of an ember past its 重生
const deaths = new Map();
const bad = new Set(); // rule details that did not hold (asserted after the outcome)
const check = (ok, what) => { if (!ok) bad.add(what); };
let n = 0;
while (!b.finished && n++ < 30 * 300) {
b.step();
for (const ev of b.drainEvents()) {
if (ev[0] === 'fx' && ev[1] === 'ember') {
const u = b.units.find((x) => x.id === ev[4].id);
check(u && u.alive, 'a knock-out never removes the unit');
check(ev[4].form === 'husk', 'the ember fx carries the clip set of the model (form: husk)');
ember.set(u.id, { u, at: b.time, x: u.x, y: u.y, attacks: u.stats.attacks });
}
if (ev[0] === 'die') {
const u = b.units.find((x) => x.id === ev[1]);
if (u && u.side === 'enemy') {
deaths.set(u.id, (deaths.get(u.id) ?? 0) + 1);
check(ev[2] === 'killed', 'enemies only die by being killed');
check(ember.has(u.id) && u.mem.ab.list[0].state === 'husk', 'a 逐火 dies only as an ember');
check(u.s.maxHp === tb(u.defId, 'Revive[Trigger].prop_max_hp'), 'killed through its hit counter');
}
}
}
for (const [, m] of ember) {
const u = m.u;
if (!u.alive || u.mem.ab.list[0].state !== 'husk') continue;
if (b.time < m.at + HUSK_REBIRTH - 1e-6) {
// (its blocker lets go at the next enemy update)
check(u.s.flags.invulnerable && u.s.flags.unblockable && (b.time - m.at < 0.05 || !u.blockedBy), '重生: 无敌 + 无法阻挡');
check(u.x === m.x && u.y === m.y, '重生: immobile');
} else {
check(u.s.flags.stealth && u.s.flags.disarm && !u.s.flags.unblockable, '余烬: 隐匿 + 缴械, blockable');
walked = Math.max(walked, Math.hypot(u.x - m.x, u.y - m.y));
}
check(u.stats.attacks === m.attacks, 'an ember never attacks');
}
}
const r = b.result();
const dusk = b.units.filter((u) => u.side === 'enemy' && EMBERS.includes(u.defId));
assert.ok(dusk.length >= 5);
assert.deepEqual({ reason: r.reason, leaked: r.perPlayer.p1.leaked.length, killed: r.perPlayer.p1.killed },
{ reason: 'cleared', leaked: 0, killed: r.perPlayer.p1.total }, 'the round is cleared: every enemy killed, no 逐火 walks out');
for (const u of dusk) assert.equal(deaths.get(u.id), 1, `${u.defId} #${u.id} dies exactly once`);
assert.equal(ember.size, dusk.length, 'every 逐火 went through its ember');
assert.deepEqual([...bad], [], 'ember rules');
assert.ok(walked > 1, `an ember whose blocker fell walks on (${walked.toFixed(2)} tiles)`);
});
for (const key of EMBERS) {
const hits = tb(key, 'Revive[Trigger].prop_max_hp'), delay = tb(key, 'Revive[Trigger].interval');
test(`${key} ${E[key].name}: 1 s 重生, then a walking 隐匿 ember of ${hits} hits — unblocked no ranged unit can target it, blocked it dies after ${hits} instances; ${delay} s later it would stand up again`, () => {
// unblocked: the gun cannot target it while it walks on; it revives at full HP, and the next knock-out repeats
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const e = put(h, key, { mods: { speedMul: 1 } });
const gun = h.unit('t_gun');
h.run(2);
const full = e.s.maxHp;
const kills = () => h.hooksOf('kill').filter((c) => c.victim === e).length;
h.b.kill(e, h.unit('t_gun'));
assert.ok(e.alive, 'not removed');
assert.equal(kills(), 0, 'a knock-out is no kill for anyone (credit, bounty, bond kill triggers)');
assert.equal(h.b.killed, 0, 'and no kill count');
assert.equal(e.s.maxHp, hits);
assert.equal(e.hp, hits);
const x0 = e.x;
assert.equal(h.b.dealDamage(h.unit('t_gun'), e, { amount: 1e6, type: 'true' }), 0, '重生 is invulnerable');
h.run(HUSK_REBIRTH - 0.05);
approx(e.x, x0, 1e-9, 'immobile during the 重生');
h.run(0.1);
assert.ok(e.s.flags.stealth && e.s.flags.disarm && e.profile.noAttack && !e.s.flags.unblockable);
assert.equal(canTargetEnemy(gun, e, gun.profile), false, 'unblocked and 隐匿: no ranged operator can target it');
h.run(3);
assert.ok(Math.abs(e.x - x0) > 0.5, 'walks its route on');
assert.equal(e.hp, hits);
h.run(delay - 3.15);
assert.ok(e.s.flags.stealth, `still an ember just before ${delay} s`);
h.run(0.2);
assert.equal(e.s.maxHp, full, 'the warrior again');
assert.equal(e.hp, full, 'with full HP');
assert.ok(!e.s.flags.stealth && !e.s.flags.disarm && !e.profile.noAttack);
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'revive' && ev[4].id === e.id && ev[4].form === 'revived'));
h.b.kill(e, null);
assert.ok(e.alive && e.s.maxHp === hits, 'every knock-out of the warrior starts it again');
// blocked: the blocker lifts its 隐匿, so the gun hits it: `hits` instances end it for good
const h2 = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }] });
h2.step();
const f = put(h2, key, { mods: { speedMul: 1 } });
assert.ok(h2.runUntil(() => !!f.blockedBy, 20), 'blocked');
h2.b.kill(f, null);
h2.run(HUSK_REBIRTH + 0.1);
assert.ok(f.blockedBy, 'blocked again once the 重生 is over');
assert.ok(canTargetEnemy(h2.unit('t_gun'), f, h2.unit('t_gun').profile), 'blocked: targetable in spite of its 隐匿');
assert.ok(h2.runUntil(() => !f.alive, 3 * hits), `beaten by ${hits} gun shots`);
assert.equal(f.removeReason, 'killed');
assert.equal(h2.hooksOf('kill').filter((c) => c.victim === f).length, 1, 'one kill: the real death');
assert.equal(h2.b.killed, 1);
assert.equal(h2.eventsOf('die').filter((ev) => ev[1] === f.id).length, 1, 'one die event');
checkInvariants(h2.b);
});
}
test('an ember counts every damage instance as one hit — physical, arts, true, 元素伤害 (AoE / DoT ticks alike); element 损伤 does not', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const e = put(h, 'enemy_1292_duskld', { mods: { speedMul: 0 } });
const g = h.unit('t_gun');
h.b.kill(e, g);
h.run(HUSK_REBIRTH + 0.05);
const n = e.hp;
for (const type of ['phys', 'arts', 'true', 'elemental']) h.b.dealDamage(g, e, { amount: 1e6, type, tags: ['aoe'] });
h.b.dealDamage(g, e, { type: 'element', element: 'burn', amount: 300 });
assert.equal(e.hp, n - 4);
assert.ok(e.alive);
});
});
// ---------------------------------------------------------------------------------------------------------------
// #5 转译基底·α
describe('#5 转译基底·α: damage is cancelled until its form change; only the changed form can die', () => {
test('悬赏·特异I in a real round (R4) on 战场#01, a sniper + caster + late blocker: no HP lost before the change, the 4th physical hit ⇒ 2 s change ⇒ 寻仇者, then killed for the bounty coin', () => {
const wave = buildNormalWave(gd, createRng(5), ['TIMES', 'INVISIBLE', 'FLY'], 4);
const card = DATA.choices.cards.bounty.find((c) => c.effectId === 'enemyeffect_16_12');
assert.equal(card.enemyKey, TR, 'fixture: the 悬赏 card adds a 转译基底·α');
const spawns = withBounties(gd, 4, wave, [{ id: 'b1', card }], 'p1');
assert.equal(spawns.filter((s) => s.enemyKey === TR && s.tag === 'bounty').length, 1);
const b = realBattle({
round: 4, wave, spawns, units: [
chess(1, 'chess_char_4_23_b', 9, 3), // 百炼嘉维尔 near the goal
chess(2, 'chess_char_6_01_b', 10, 4), // 蕾缪安 (physical)
chess(3, 'chess_char_4_02_b', 11, 4), // 莫斯提马 (arts)
],
});
let tr = null, phaseAt = null, kind = null, diedAt = null, formAt = null, maxBefore = null, firstFormLoss = null, earlyAttack = false;
let n = 0;
while (!b.finished && n++ < 30 * 300) {
b.step();
tr = tr ?? b.enemies.find((e) => e.defId === TR) ?? null;
for (const ev of b.drainEvents()) {
if (!tr) continue;
if (ev[0] === 'fx' && ev[1] === 'phase' && ev[4].id === tr.id) { phaseAt = b.time; kind = ev[4].kind; }
if (ev[0] === 'die' && ev[1] === tr.id) { diedAt = b.time; assert.equal(ev[2], 'killed'); }
if (ev[0] === 'atk' && ev[1] === tr.id && formAt == null) earlyAttack = true;
}
if (!tr || !tr.alive) continue;
if (formAt == null && tr.findBuff('ab:form')) formAt = b.time;
if (formAt == null) {
maxBefore = maxBefore ?? tr.s.maxHp;
if (tr.hp !== maxBefore && firstFormLoss == null) firstFormLoss = { t: +b.time.toFixed(2), hp: Math.round(tr.hp), max: Math.round(maxBefore) };
}
}
assert.ok(diedAt != null, 'killed');
assert.ok(phaseAt != null && diedAt > phaseAt + TRANSLATOR_CHANGE - 0.05, 'it changed form before it died — never killed in its first form');
assert.equal(firstFormLoss, null, 'its first form loses no HP');
assert.equal(kind, 'translator_fuchou', '4 physical hits first ⇒ 寻仇者');
approx(formAt - phaseAt, TRANSLATOR_CHANGE, 0.02, 'the change lasts 2 s');
assert.ok(!earlyAttack, 'no attack before its change');
const r = b.result();
assert.equal(r.perPlayer.p1.coins, card.coin, 'the bounty coin is paid to the player');
assert.equal(r.perPlayer.p1.killed, r.perPlayer.p1.total);
assert.equal(r.perPlayer.p1.leaked.length, 0);
});
test('original form: every damage instance cancelled; 3 physical + 3 arts change nothing; the 4th physical ⇒ a 2 s change (immobile, still cancelled, immune) ⇒ 寻仇者 stats, melee physical attacks through the engine, ATK +100 % below half HP', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_mage', row: 11, col: 4 }], kits: QUIET_GUNS });
h.step();
const e = put(h, TR, { mods: { speedMul: 1 } });
const g = h.unit('t_gun'), m = h.unit('t_mage');
const base = { hp: e.s.maxHp, atk: e.s.atk, def: e.s.def, res: e.s.res, move: e.s.moveSpeed, bat: e.base.bat, mass: e.s.massLevel };
for (let i = 0; i < 3; i++) {
assert.equal(h.b.dealDamage(g, e, { amount: 1e6, type: 'phys' }), 0);
assert.equal(h.b.dealDamage(m, e, { amount: 1e6, type: 'arts' }), 0);
}
assert.equal(h.b.dealDamage(g, e, { amount: 1e6, type: 'true' }), 0, 'true damage too');
assert.equal(e.hp, base.hp);
assert.ok(!e.findBuff('ab:change'), '3 + 3 hits: no change yet');
for (const s of ['stun', 'sleep', 'cold', 'freeze', 'levitate', 'fear']) assert.equal(h.b.applyStatus(e, s, { duration: 3, source: g }), false, `immune to ${s}`);
assert.ok(e.s.flags.noDisplace, '失衡免疫');
h.b.dealDamage(g, e, { amount: 10, type: 'phys' });
assert.ok(e.findBuff('ab:change'), 'the 4th physical hit starts the change');
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'phase' && ev[4].id === e.id && ev[4].kind === 'translator_fuchou'));
const x0 = e.x;
h.run(TRANSLATOR_CHANGE - 0.1);
approx(e.x, x0, 1e-9, 'stands still while changing');
assert.equal(h.b.dealDamage(g, e, { amount: 1e6, type: 'phys' }), 0, 'still cancelled while changing');
assert.ok(!e.findBuff('ab:form'));
h.run(0.15);
const t = (k) => tb(TR, `Mode_Fuchou_Passive.${k}`);
approx(e.s.maxHp, base.hp + t('max_hp'));
approx(e.hp, e.s.maxHp, 1e-6, 'full HP');
approx(e.s.atk, base.atk + t('atk'));
approx(e.s.def, base.def + t('def'));
approx(e.s.res, base.res + t('magic_resistance'));
approx(e.s.moveSpeed, base.move * (1 + t('move_speed')));
approx(e.s.interval, base.bat + t('base_attack_time'));
assert.equal(e.s.massLevel, base.mass + t('mass_level'));
assert.ok(!e.s.flags.noDisplace, 'the original form\'s immunities are gone');
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.5, source: g }));
assert.ok(h.b.dealDamage(g, e, { amount: 100, type: 'true' }) > 0, 'damage lands now');
// the 4th arts hit / a block no longer change anything
for (let i = 0; i < 4; i++) h.b.dealDamage(m, e, { amount: 1, type: 'arts' });
assert.equal(e.findBuff('ab:form').mods.resFlat, t('magic_resistance'), 'one change only');
// melee physical attacks on its blocker, through the engine ('atk' event, the attack hooks)
const h2 = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }], kits: QUIET_GUNS, hooks: ['damaged'], captureNoisy: true });
h2.step();
const f = put(h2, TR, { mods: { speedMul: 1 } });
for (let i = 0; i < 4; i++) h2.b.dealDamage(h2.unit('t_gun'), f, { amount: 1, type: 'phys' });
assert.ok(h2.runUntil(() => f.stats.attacks >= 1, 20));
const w = h2.unit('t_wall');
assert.ok(h2.eventsOf('atk').some((ev) => ev[1] === f.id && ev[2] === w.id && ev[3] === 'none'), 'a melee attack on its blocker');
const hit = h2.hooksOf('damaged').find((c) => c.source === f);
assert.equal(hit.dmg.type, 'phys');
const atk = f.s.atk;
h2.b.dealDamage(h2.unit('t_gun'), f, { amount: f.s.maxHp * 0.6, type: 'true' });
h2.step();
approx(f.s.atk, atk * (1 + tb(TR, 'Mode_Fuchou_Anger.atk')), 1e-6, 'ATK +100 % below half HP');
h2.b.heal(null, f, f.s.maxHp);
h2.step();
approx(f.s.atk, atk, 1e-6, 'and back above half');
checkInvariants(h2.b);
});
test('the 4th arts hit ⇒ 特战术师: its stats, ranged arts attacks on 2 targets at once', () => {
const h = arena({ units: [{ chessId: 't_mage', row: 11, col: 6 }, { chessId: 't_gun', row: 10, col: 7 }], kits: QUIET_GUNS, hooks: ['damaged'], captureNoisy: true });
h.step();
const e = put(h, TR, { pos: [9, 7], mods: { speedMul: 0 } });
const base = { hp: e.s.maxHp, res: e.s.res, bat: e.base.bat };
for (let i = 0; i < 4; i++) h.b.dealDamage(h.unit('t_mage'), e, { amount: 1, type: 'arts' });
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'phase' && ev[4].id === e.id && ev[4].kind === 'translator_shushi'));
h.run(TRANSLATOR_CHANGE + 0.1);
const t = (k) => tb(TR, `Mode_Shushi_Passive.${k}`);
approx(e.s.maxHp, base.hp + t('max_hp'));
approx(e.s.res, base.res + t('magic_resistance'));
approx(e.s.interval, base.bat + t('base_attack_time'));
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10));
assert.equal(h.eventsOf('atk').filter((ev) => ev[1] === e.id).length, 2, 'one attack, two shots');
h.run(0.6); // the shots land
const hits = h.hooksOf('damaged').filter((c) => c.source === e);
assert.equal(new Set(hits.map((c) => c.target.id)).size, 2, 'two targets at once');
assert.ok(hits.every((c) => c.dmg.type === 'arts'));
});
test('blocked first ⇒ 幽灵: the 2 s change held by its blocker, then unblockable, walks on, never attacks, takes damage', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }], kits: QUIET_GUNS });
h.step();
const e = put(h, TR, { mods: { speedMul: 1 } });
assert.ok(h.runUntil(() => !!e.findBuff('ab:change'), 20), 'the block starts the change');
assert.ok(h.eventsOf('fx').some((ev) => ev[1] === 'phase' && ev[4].id === e.id && ev[4].kind === 'translator_youling'));
assert.ok(e.blockedBy);
const hp = e.hp;
h.run(TRANSLATOR_CHANGE - 0.1);
assert.equal(h.b.dealDamage(h.unit('t_gun'), e, { amount: 1e6, type: 'phys' }), 0, 'damage cancelled while changing');
assert.equal(e.hp, hp);
h.run(0.3);
assert.ok(e.s.flags.unblockable && !e.blockedBy, '幽灵: unblockable, its blocker lets go');
const x = e.x;
h.run(3);
assert.ok(Math.hypot(e.x - x, e.y - 9) > 0.5, 'walks on');
assert.ok(h.b.dealDamage(h.unit('t_gun'), e, { amount: 100, type: 'phys' }) > 0, 'damage lands now');
assert.equal(e.stats.attacks, 0, '幽灵 never attacks');
assert.equal(e.mem.ab.list.length, 1);
});
});