feedback1 integrate: WN 灼热/炽焰源石虫 models (local overlay, tinted alias); 暴鸰 bomb drop

# Conflicts:
#	docs/SIM.md
#	public/js/render/app.js
#	public/js/render/spine.js
#	public/js/render/units.js
#	server/sim/content/enemies.js
#	server/sim/snapshot.js
This commit is contained in:
sganggs committed 2026-10-03 06:45:59 +08:00
commit 4b15919185
26 files changed
+1383 -90

No files matched your search

+10 -1
View File
@@ -2,7 +2,8 @@
// data/assets.json. The pure helpers (animation-role resolver, atlas
// normalizer, PNG/WOFF2/audio helpers, plan id sets) are always tested; the
// on-disk checks run only when public/assets exists (it is git-ignored and
// produced by `npm run assets`).
// produced by `npm run assets`); the optional local-client enemy models
// (spineLocal, tools/local-extract) only when data/local-assets.json lists them.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -19,6 +20,7 @@ import { assetToPath, pickUnitSfx, indexAudio } from '../tools/assets/audio.mjs'
import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs';
import { collectEnemyIds, skillIndicesByChar } from '../tools/assets/plan.mjs';
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import { spineEntry } from '../public/js/assets.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC = join(ROOT, 'public');
@@ -583,6 +585,13 @@ describe('generated manifest data/assets.json', () => {
for (const e of Object.values(manifest.enemies)) if (e.spine) models.set(e.spine.skel, e.spine);
for (const t of Object.values(manifest.tokens)) if (t.spine) models.set(t.spine.skel, t.spine);
assert.ok(models.size > 400);
// the official enemy models of the local client, as the client resolves them (DESIGN §13: only when listed)
const localPath = join(ROOT, 'data', 'local-assets.json');
const local = existsSync(localPath) ? JSON.parse(readFileSync(localPath, 'utf8')) : null;
for (const id of Object.keys(manifest.enemies)) {
const s = local ? spineEntry(manifest, id, { local }) : null;
if (s?.local && [s.skel, s.atlas, ...s.textures].every((u) => existsSync(join(PUBLIC, u)))) models.set(s.skel, s);
}
for (const s of models.values()) {
const dir = dirname(s.atlas);
// pixi-spine finds the atlas by swapping the extension of the skel URL
+3 -1
View File
@@ -743,11 +743,13 @@ test(`${nm('enemy_1042_frostd')}: operators within ${tb('enemy_1042_frostd', 'de
test(`${nm('enemy_1040_bombd')}: no normal attack; ONE bomb on the target + its 8 tiles, then move speed ×${skb('enemy_1040_bombd', 'boomb').bb.move_speed}`, () => {
// PRTS 暴鸰: "不进行普通攻击" · 投弹 "对目标及其周围八格的我方单位造成100%物理伤害 … 技能结束后移速最终提升至200% ※此技能仅能触发一次"
// (the bomb leaves on the Attack clip's OnAttack and flies 1 tile at 5 tiles/s; the speed-up when the cast ends —
// feedback D4, test/sim/feedback1d-bombd)
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }] });
h.step();
const e = put(h, 'enemy_1040_bombd', [10, 7], { route: 2 });
const s = skb('enemy_1040_bombd', 'boomb');
h.run(s.initCooldown + 0.2);
h.run(s.initCooldown + enemiesMod.BOMBD_RELEASE + enemiesMod.BOMBD_POST_DELAY + 0.1);
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
approx(w2.stats.taken, e.s.atk, 1e-6, 'the target (latest deployed in range): 100 % ATK');
approx(w1.stats.taken, e.s.atk, 1e-6, 'a tile next to it: splash 100 %');
+1 -1
View File
@@ -232,7 +232,7 @@ test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4
assert.equal(typeof SUB.loopshooter.canAttack, 'function', 'attacks only while holding the boomerang');
assert.match(SIM, /boomerang 15 out,\s*3\.75 back/);
assert.ok(!/next attack waits for the boomerang \(2 × distance \/ 10 s\)/.test(SIM), 'SIM §8: the old lob rule is gone');
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|chain\|chainHeal`/);
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|droneBomb\|chain\|chainHeal`/);
assert.match(DESIGN, /BOOMERANG_RETURN_SPEED/);
// 蕾缪安: one shell every 0.3 s after the skill (PRTS), fx 'bombardShell' then 'bombard' — the kit's constants
const kit = readFileSync(join(ROOT, 'server/sim/content/kits/tier6.js'), 'utf8');
+315
View File
@@ -0,0 +1,315 @@
// test/feedback1d-models.test.js — community report D3 after 0.1.0 ("所有特殊源石虫的模型全表现为普通源石虫"): 灼热源石虫 /
// 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT faction's slugs — up to 10 a round) were drawn with the plain 源石虫
// skeleton because no community dump carries their models (Ark-Models lists them with an empty assetList), so the
// asset plan aliased them to enemy_1007_slime. Their official skeletons only exist in the local client
// (tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, optional and git-ignored), so
// they are an OVERLAY: data/assets.json keeps the alias as the web model (`spine` + `spineAliasOf`) and adds
// `spineLocal` (file names in the data/local-assets.json group + the parsed metadata, from the committed
// tools/assets/local-enemy-spines.json — never from the disk, so the manifest is the same with or without the
// extraction). The client (assets.js spineEntry) draws the official model when data/local-assets.json lists every one
// of its files and falls back to the web model when it fails to load (DESIGN §13: local art is optional).
// Without the extraction the web alias is drawn tinted toward the slug's own colours (render/units.js ALIAS_TINT,
// research 07 §5.6 "a hue shift" [ASSUMED look]), so source installs still tell them apart from the plain slug.
// 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models (checked in headless Chrome).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlan } from '../tools/assets/plan.mjs';
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import { findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE, localEnemySpineGroup } from '../tools/assets/spine.mjs';
import { indexAudio } from '../tools/assets/audio.mjs';
import { createAssets, spineEntry, validSpine } from '../public/js/assets.js';
import { installFakePixi, fakeViewCtx } from './render/fakepixi.js';
import { presetCamera } from '../public/js/render/projection.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ASSETS = path.join(ROOT, 'public/assets');
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
const SLUGS = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2'];
const COMMITTED = readJson(LOCAL_ENEMY_SPINES_FILE).models;
const MANIFEST = readJson('data/assets.json');
/** The real research inputs; Ark-Models carries the plain slug only (its index lists the special ones empty). */
function plan(localEnemySpines) {
const modelsData = { data: { '1007_slime': { assetList: { '.skel': 'enemy_1007_slime.skel', '.atlas': 'enemy_1007_slime.atlas', '.png': 'enemy_1007_slime.png' } },
'1305_mhslim': { assetList: {} }, '1305_mhslim_2': { assetList: {} } } };
return buildPlan({
assets07: readJson('docs/research/07-assets.json'), ops03: readJson('docs/research/03-operators.json'),
enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'),
audio: indexAudio({}), modelsData, extraEnemyIds: SLUGS, localEnemySpines,
});
}
/** A data/local-assets.json listing the extracted files of `ids` (extract.py's group layout). */
function localManifest(ids, drop = null) {
const groups = {};
for (const id of ids) {
const g = groups[localEnemySpineGroup(id)] = {};
const m = COMMITTED[id];
for (const f of [m.skel, m.atlas, ...m.textures]) if (f !== drop) g[f] = { path: `/assets/local/spine/enemy/${id}/${f}`, kind: f.endsWith('.png') ? 'Texture2D' : 'TextAsset' };
}
return { version: 1, source: 'local-client', groups };
}
describe('D3: the official slug models are an optional overlay of the web alias', () => {
test('plan: the web model stays the 源石虫 alias; spineLocal comes from the committed metadata as it is', () => {
const before = plan(undefined);
for (const id of SLUGS) {
assert.equal(before.template.enemies[id].spineAliasOf, 'enemy_1007_slime');
assert.equal(before.template.enemies[id].spineLocal, undefined, 'no metadata → no overlay');
}
const p = plan(COMMITTED);
const slime = { skel: '/assets/spine/enemy/enemy_1007_slime/enemy_1007_slime.skel', atlas: '/assets/spine/enemy/enemy_1007_slime/enemy_1007_slime.atlas', textures: [], anims: {} };
const tmp = mkdtempSync(path.join(tmpdir(), 'sp-plan-'));
try {
const { value } = resolveTemplate({ enemies: Object.fromEntries(SLUGS.map((id) => [id, p.template.enemies[id]])) },
{ root: tmp, spine: new Map([['enemy:enemy_1007_slime', slime]]) });
for (const id of SLUGS) {
const e = value.enemies[id];
assert.equal(e.spineAliasOf, 'enemy_1007_slime', `${id}: the web model (works without the local client)`);
assert.equal(e.spine, slime);
assert.deepEqual(e.spineLocal, { group: `spine/enemy/${id}`, ...COMMITTED[id] }, `${id}: the overlay, null fields kept`);
assert.equal(e.spineLocal.anims.attack.begin, null);
}
} finally { rmSync(tmp, { recursive: true, force: true }); }
assert.ok(!collectLeaves(p.template).some((l) => /spineLocal/.test(l.path)), 'the overlay is no file to download');
assert.ok(p.notes.some((n) => /enemy_1305_mhslim: official Spine from the local client/.test(n)));
});
test('the committed metadata: both ELEMENT slugs, file names of their extract.py group, roles resolved', () => {
assert.deepEqual(Object.keys(COMMITTED).sort(), SLUGS);
for (const id of SLUGS) {
const m = COMMITTED[id];
assert.equal(m.skel, `${id}.skel`);
assert.equal(m.atlas, `${id}.atlas`, 'next to the skeleton (pixi-spine finds the atlas by its name)');
assert.deepEqual(m.textures, [`${id}.png`]);
assert.equal(m.pma, true, 'premultiplied pages (extract.py merge_alpha)');
for (const role of ['idle', 'die']) assert.ok(m.anims[role] in m.animations, `${id} ${role}`);
assert.ok(m.anims.move.loop in m.animations && m.anims.attack.loop in m.animations, `${id} move / attack`);
assert.ok(m.hits.Attack?.length, `${id}: the OnAttack frame`);
}
});
test('data/assets.json never depends on the local extraction (no /assets/local/ URL; the web model always there)', () => {
const urls = [];
const walk = (n) => { if (typeof n === 'string') { if (n.includes('/assets/local/')) urls.push(n); } else if (n && typeof n === 'object') Object.values(n).forEach(walk); };
walk(MANIFEST);
assert.deepEqual(urls, [], 'setup / doctor / the manifest tests count every /assets/ URL as required');
for (const [id, e] of Object.entries(MANIFEST.enemies)) {
if (!e.spineLocal) continue;
assert.ok(SLUGS.includes(id), id);
assert.deepEqual(e.spineLocal, { group: `spine/enemy/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
assert.ok(validSpine(spineEntry(MANIFEST, id)), `${id}: a web model without the local client`);
assert.equal(MANIFEST.enemies[id].spineAliasOf, 'enemy_1007_slime');
assert.deepEqual(spineEntry(MANIFEST, id), MANIFEST.enemies.enemy_1007_slime.spine, `${id}: the plain 源石虫 on the web`);
}
for (const id of SLUGS) assert.ok(MANIFEST.enemies[id].spineLocal, `${id} has its overlay`);
});
test('the committed metadata is what the extracted models parse to', { skip: !SLUGS.every((id) => existsSync(path.join(ASSETS, 'local/spine/enemy', id))) && 'enemy models not extracted (tools/local-extract)' }, async () => {
const found = await findLocalEnemyModels(ASSETS);
const { meta, problems } = await localEnemySpineMeta(ASSETS, found);
assert.deepEqual(problems, []);
for (const id of SLUGS) assert.deepEqual(meta[id], COMMITTED[id], `${id}: re-run node tools/fetch-assets.mjs --local-spines`);
});
test('localEnemySpineMeta parses an extracted model like the pipeline and writes nothing', { skip: !existsSync(path.join(ASSETS, 'spine/enemy/enemy_1007_slime')) && 'public/assets not downloaded' }, async () => {
// the plain slug's fetched files stand in for an extraction (same layout: <id>.skel / .atlas / page PNGs)
const dir = mkdtempSync(path.join(tmpdir(), 'sp-meta-'));
try {
const src = path.join(ASSETS, 'spine/enemy/enemy_1007_slime');
const dst = path.join(dir, 'local/spine/enemy/enemy_9999_slime');
mkdirSync(dst, { recursive: true });
for (const f of ['enemy_1007_slime.skel', 'enemy_1007_slime.atlas', 'enemy_1007_slime.png']) copyFileSync(path.join(src, f), path.join(dst, f));
const atlasBefore = readFileSync(path.join(dst, 'enemy_1007_slime.atlas'));
const mtime = statSync(path.join(dst, 'enemy_1007_slime.atlas')).mtimeMs;
const { meta, problems } = await localEnemySpineMeta(dir, await findLocalEnemyModels(dir));
assert.deepEqual(problems, []);
const web = MANIFEST.enemies.enemy_1007_slime.spine;
assert.deepEqual(meta.enemy_9999_slime, {
skel: 'enemy_1007_slime.skel', atlas: 'enemy_1007_slime.atlas', textures: ['enemy_1007_slime.png'], pma: true,
anims: web.anims, animations: web.animations, events: web.events, hits: web.hits, bounds: web.bounds,
});
assert.ok(readFileSync(path.join(dst, 'enemy_1007_slime.atlas')).equals(atlasBefore), 'read only');
assert.equal(statSync(path.join(dst, 'enemy_1007_slime.atlas')).mtimeMs, mtime);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadLocalEnemySpines: the models of the committed file, {} when it is missing or bad', async () => {
assert.deepEqual(await loadLocalEnemySpines(path.join(ROOT, LOCAL_ENEMY_SPINES_FILE)), COMMITTED);
assert.deepEqual(await loadLocalEnemySpines(path.join(ROOT, 'no-such-file.json')), {});
const dir = mkdtempSync(path.join(tmpdir(), 'sp-lse-'));
try {
writeFileSync(path.join(dir, 'a.json'), '{"models":[1]}');
assert.deepEqual(await loadLocalEnemySpines(path.join(dir, 'a.json')), {});
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('findLocalEnemyModels: skeleton + same-stem atlas + page PNGs under local/spine/enemy/<id>/', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'sp-local-'));
try {
const put = (rel, body = 'x') => { mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); writeFileSync(path.join(dir, rel), body); };
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.skel');
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.atlas');
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.png');
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim_2.png');
put('local/spine/enemy/enemy_x_noatlas/enemy_x_noatlas.skel');
put('local/spine/enemy/enemy_x_nopng/enemy_x_nopng.skel');
put('local/spine/enemy/enemy_x_nopng/enemy_x_nopng.atlas');
put('local/spine/enemy/not-an-enemy/a.skel');
const found = await findLocalEnemyModels(dir);
assert.deepEqual(Object.keys(found), ['enemy_1305_mhslim']);
assert.deepEqual(found.enemy_1305_mhslim, {
dir: 'local/spine/enemy/enemy_1305_mhslim/', skel: 'local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.skel',
atlas: 'local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.atlas',
pngs: ['local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.png', 'local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim_2.png'],
});
assert.deepEqual(await findLocalEnemyModels(path.join(dir, 'missing')), {});
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});
describe('D3 client: the official model only when data/local-assets.json lists it', () => {
const webOf = (id) => MANIFEST.enemies[id].spine; // the plain 源石虫's model (spineAliasOf enemy_1007_slime)
test('assets.js spineEntry: every file listed → the official model (web fallback); anything missing → the web alias', () => {
const local = localManifest(SLUGS);
for (const id of SLUGS) {
const web = webOf(id);
const e = spineEntry(MANIFEST, id, { local });
assert.equal(e.skel, `/assets/local/spine/enemy/${id}/${id}.skel`);
assert.equal(e.atlas, `/assets/local/spine/enemy/${id}/${id}.atlas`);
assert.deepEqual(e.textures, [`/assets/local/spine/enemy/${id}/${id}.png`]);
assert.equal(e.pma, true);
assert.deepEqual(e.anims, COMMITTED[id].anims);
assert.deepEqual(e.hits, COMMITTED[id].hits);
assert.equal(e.fallback, web, 'the plain 源石虫 if the official model fails to load');
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
assert.equal(spineEntry(MANIFEST, id), web, 'no local manifest → the web model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), web, 'not extracted → the web model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], `${id}.png`) }), web, 'a page missing → the web model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], `${id}.atlas`) }), web, 'the atlas missing → the web model');
}
assert.equal(spineEntry(MANIFEST, 'enemy_1007_slime', { local }), webOf('enemy_1007_slime'), 'other enemies unchanged');
// the store hands the local manifest over once local() has it
const store = createAssets({ manifest: MANIFEST, localManifest: local });
assert.equal(store.spineEntry(SLUGS[0]).skel, `/assets/local/spine/enemy/${SLUGS[0]}/${SLUGS[0]}.skel`);
assert.equal(createAssets({ manifest: MANIFEST }).spineEntry(SLUGS[0]), webOf(SLUGS[0]), 'before / without local(): the web model');
});
test('render/app.js waits for the local manifest with the asset manifest before building unit views', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /await withTimeout\(Promise\.all\(\[assets\.ready \? assets\.ready\(\) : null, assets\.local \? assets\.local\(\) : null\]/);
});
describe('UnitView', () => {
let fake, UnitView;
let ALIAS_TINT, UF;
before(async () => {
fake = installFakePixi();
({ UnitView, ALIAS_TINT } = await import('../public/js/render/units.js'));
({ UF } = await import('../shared/constants.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const info = (id) => ({ id: 3, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 1, x: 6, y: 10, maxHp: 1000 });
test('an official model that fails to load falls back to the web model (never the icon diamond)', async () => {
const local = localManifest(SLUGS);
const asked = [], released = [];
const store = (fail) => ({
picture: () => null, image: async () => null,
spineEntry: (id) => spineEntry(MANIFEST, id, { local }),
spine: {
acquire: async (e) => { asked.push(e.skel); if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; },
release: (e) => released.push(e.skel),
},
});
const id = SLUGS[0];
const web = webOf(id);
const ok = new UnitView(fakeViewCtx(fake.P, { assets: store(() => false), cam }), info(id));
await tick(); await tick();
assert.equal(ok.actor?.entry.skel, `/assets/local/spine/enemy/${id}/${id}.skel`, 'the official model');
assert.equal(ok.actor.entry.anims.move.loop, 'Move', 'with its own clips');
asked.length = 0;
const bad = new UnitView(fakeViewCtx(fake.P, { assets: store((e) => e.local), cam }), info(id));
await tick(); await tick(); await tick();
assert.deepEqual(asked, [`/assets/local/spine/enemy/${id}/${id}.skel`, web.skel], 'the official model, then the web one');
assert.ok(released.includes(`/assets/local/spine/enemy/${id}/${id}.skel`), 'the failed acquire is released');
assert.equal(bad.actor?.entry, web, 'drawn with the web model');
assert.equal(bad.entry, web);
});
test('the web alias of a local-only slug is drawn tinted toward its own colours; the official model and the plain 源石虫 are not', async () => {
const store = (local, fail = () => false) => ({
picture: () => null, image: async () => null,
spineEntry: (id) => spineEntry(MANIFEST, id, local ? { local } : undefined),
spine: { acquire: async (e) => { if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; }, release() {} },
});
const local = localManifest(SLUGS);
const mk = (id, st) => new UnitView(fakeViewCtx(fake.P, { assets: st, cam }), info(id));
const web0 = mk(SLUGS[0], store(null)), web1 = mk(SLUGS[1], store(null));
const official = mk(SLUGS[0], store(local)), failed = mk(SLUGS[1], store(local, (e) => e.local));
const plain = mk('enemy_1007_slime', store(local));
for (let i = 0; i < 4; i++) await tick();
const all = [web0, web1, official, failed, plain];
for (const v of all) v.update(1 / 60, cam(), 0);
assert.equal(web0.actor.entry, webOf(SLUGS[0]), 'not extracted: the plain 源石虫 skeleton');
assert.equal(web0.actor.spine.tint, ALIAS_TINT[SLUGS[0]], '灼热源石虫: orange');
assert.equal(web1.actor.spine.tint, ALIAS_TINT[SLUGS[1]], '炽焰源石虫: red-orange');
assert.notEqual(ALIAS_TINT[SLUGS[0]], ALIAS_TINT[SLUGS[1]], 'the two read apart');
assert.ok(official.actor.entry.local);
assert.equal(official.actor.spine.tint, 0xffffff, 'the official model as it is');
assert.equal(failed.actor.entry, webOf(SLUGS[1]));
assert.equal(failed.actor.spine.tint, ALIAS_TINT[SLUGS[1]], 'the web fallback of a failed official model is tinted too');
assert.equal(plain.actor.spine.tint, 0xffffff, 'the plain 源石虫 itself');
web0.flags = UF.FROZEN;
web0.update(1 / 60, cam(), 1 / 60);
assert.equal(web0.actor.spine.tint, 0x9fd4ff, 'a status tint wins (frozen)');
for (const v of all) v.destroy?.();
});
});
});
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
describe('D3: tools/local-extract/extract.py enemy Spine job (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const py = (body) => {
const o = spawnSync(PY, ['-c', `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${body}`], { encoding: 'utf8', env: ENV });
assert.equal(o.status, 0, o.stderr);
return JSON.parse(o.stdout);
};
test('--print-jobs lists the enemy models; container matching and --only selection', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout).enemySpines, { bundles: 'refs/arts/enm_art_*.ab', sub: 'spine/enemy', ids: SLUGS });
const out = py(`W = set(e.ENEMY_SPINES)\n`
+ `print(json.dumps([e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/2/enemy_1305_mhslim_2_SkeletonData.asset', W),`
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_SkeletonData.asset', W),`
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1007_slime/enemy_1007_slime_SkeletonData.asset', W),`
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_Material.mat', W),`
+ ` [e.wants_sub(o, 'spine/enemy') for o in ([], ['spine'], ['spine/enemy'], ['spine/enemy/enemy_1305_mhslim'], ['spine/token_x'], ['map'])]]))`);
assert.deepEqual(out, ['enemy_1305_mhslim_2', 'enemy_1305_mhslim', null, null, [true, true, true, true, false, false]]);
});
test('normalize_atlas: the real page size and pma: true, like the fetched enemy atlases (the client loads it as is)', async () => {
const { normalizeAtlas } = await import('../tools/assets/atlas.mjs');
// the client's atlas text of 灼热源石虫 (leading blank line, size, no pma), shortened
const raw = '\nenemy_1305_mhslim.png\nsize: 256,256\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nC_Body_1\n rotate: false\n xy: 2, 159\n size: 55, 64\n orig: 55, 64\n offset: 0, 0\n index: -1\n';
const sized = py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(raw)}, {'enemy_1305_mhslim.png': (256, 256)})))`);
assert.equal(sized, normalizeAtlas(raw, { pageSize: () => ({ width: 256, height: 256 }), pma: true }).text, 'the same text as tools/assets/atlas.mjs');
assert.match(sized, /repeat: none\npma: true\nC_Body_1\n {2}rotate: false\n {2}xy: 2, 159\n {2}size: 55, 64/, 'region fields untouched');
const noSize = raw.replace('size: 256,256\n', '');
const fixed = py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(noSize)}, {'enemy_1305_mhslim.png': (512, 256)})))`);
assert.match(fixed, /\nenemy_1305_mhslim\.png\nsize: 512,256\nformat: RGBA8888/);
assert.equal(py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(sized)}, {'enemy_1305_mhslim.png': (256, 256)})))`), sized, 'idempotent');
});
});
+115
View File
@@ -0,0 +1,115 @@
// test/render/feedback1d-bombd.test.js — community report D4 after 0.1.0 ("敌方无人机暴鸰的炸弹无法正常投放"), client side:
// 暴鸰's skeleton (enemy_1040_bombd) carries its bomb (slot Bottle) in Idle / Move_Loop / Die and has bomb-less twins
// (Idle_2 / Move_Begin_2 / Move_Loop_2 / Move_End_2 / Die_2: Bottle null) — the official battle prefab switches to them
// after the drop (mode S1, buff bomb_s, replaceAnimPairs Move→Move_2, Idle→Idle_2, Die→Die_2). The view kept the
// bottle under the drone for good and no bomb ever left it. Now: the drop's 'atk' kind 'droneBomb' plays the Attack
// clip once (its OnAttack on the event) and flies a falling bomb (render/style.js PROJ.droneBomb, the sim's
// PROJECTILE_SPEEDS.droneBomb) from the drone to the target; the sim's fx 'phase' { kind: 'bombed' } on the same frame
// switches the view to those clips (render/units.js FORMS) with no fx of its own — they start once the Attack clip is
// over (the official cast's end clip Idle_2), never cutting the drop short.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { ANIM } from '../../shared/constants.js';
import { PROJ } from '../../public/js/render/style.js';
import { PROJECTILE_SPEEDS } from '../../server/sim/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const KEY = 'enemy_1040_bombd';
let fake, UnitView, FORMS, FX;
before(async () => {
fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
function store(id) {
const entry = assets.enemies[id].spine;
const names = Object.keys(entry.animations || {});
return { picture: () => null, image: async () => null, spineEntry: () => entry, spine: { acquire: async () => ({ animations: names.map((name) => ({ name })) }), release() {} } };
}
const sample = (anim = 0) => ({ x: 6, y: 10, hp: 4000, maxHp: 4000, sp: 0, spMax: 0, flags: 0, anim, vx: -0.3 });
describe('D4 暴鸰: the drone lets go of its bomb on screen', () => {
test('the bomb-less clip set exists in the skeleton the manifest lists', () => {
const f = FORMS[KEY]?.bombed;
assert.ok(f, 'FORMS.enemy_1040_bombd.bombed');
const anims = assets.enemies[KEY].spine.animations;
for (const name of [f.roles.idle, f.roles.deploy, f.roles.die, f.roles.move.begin, f.roles.move.loop, f.roles.move.end]) assert.ok(name in anims, name);
assert.ok(!f.change, 'no change clip: the drop itself is the Attack clip (its OnAttack releases the bomb)');
assert.equal(assets.enemies[KEY].spine.anims.move.loop, 'Move_Loop', 'carrying the bomb until then');
});
test('after the drop the drone flies and dies on the *_2 clips; a view built later starts bomb-less', async () => {
const ctx = fakeViewCtx(fake.P, { assets: store(KEY), cam });
const v = new UnitView(ctx, { id: 5, side: 'enemy', kind: 'enemy', defId: KEY, spine: KEY, tier: 2, x: 6, y: 10, maxHp: 4000, motion: 'FLY' });
await tick(); await tick();
v.sync(sample(ANIM.MOVE), 1);
assert.equal(v.actor.current, 'Move_Loop', 'the bomb under the drone');
v.setForm('bombed');
v.sync(sample(ANIM.MOVE), 2);
assert.equal(v.actor.current, 'Move_Loop_2', 'no bomb any more');
v.sync(sample(ANIM.IDLE), 3);
assert.equal(v.actor.current, 'Idle_2');
v.die();
assert.equal(v.actor.current, 'Die_2');
const w = new UnitView(ctx, { id: 6, side: 'enemy', kind: 'enemy', defId: KEY, spine: KEY, tier: 2, x: 6, y: 10, maxHp: 4000, motion: 'FLY', form: 'bombed' });
await tick(); await tick();
w.sync(sample(ANIM.MOVE), 1);
assert.equal(w.actor.current, 'Move_Loop_2');
});
test('the drop plays the Attack clip once at its own speed (its OnAttack on the event); the cast end → Idle_2, then the bomb-less flight', async () => {
// the client's lookupDef: the drone's data BAT 5 s (it never attacks normally; its attack rhythm used to drive the drop)
const ctx = fakeViewCtx(fake.P, { assets: store(KEY), cam, lookupDef: () => ({ stats: { bat: 5, aspd: 100 } }) });
const v = new UnitView(ctx, { id: 5, side: 'enemy', kind: 'enemy', defId: KEY, spine: KEY, tier: 2, x: 6, y: 10, maxHp: 4000, motion: 'FLY' });
await tick(); await tick();
const frames = (n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); };
v.sync(sample(ANIM.MOVE), 1);
assert.equal(v.actor.current, 'Move_Loop');
// the 'atk' droneBomb is 0.2 game s ahead in the buffer: the clip winds up to its OnAttack (0.267 s)
assert.ok(v.windUp(0.2, 'droneBomb'), 'wound up');
assert.equal(v.actor.current, 'Attack');
frames(12); // 0.2 s: the event is rendered
v.onAttack({ x: 5, y: 10 }, 1.2, 'droneBomb');
v.setForm('bombed');
v.sync(sample(ANIM.IDLE), 1.2); // the drone hovers through its cast
assert.equal(v.actor.current, 'Attack', 'the release frame: the mode change does not cut the drop short');
frames(40); // 0.667 s (BOMBD_POST_DELAY): the cast ends, 0.067 s of the clip left
assert.equal(v.actor.current, 'Attack');
frames(6);
assert.equal(v.actor.current, 'Idle_2', 'the cast\'s end clip (official _endAnimKey Idle_2), not a 5 s attack rhythm from its BAT');
v.sync(sample(ANIM.MOVE), 2.2);
frames(30);
assert.equal(v.actor.current, 'Move_Loop_2', 'flying on without the bomb');
assert.equal(v.atkInterval, 5, 'a one-off drop leaves the attack rhythm alone');
});
test('render/app.js keeps UnitInfo.form (a field opened after the drop) and hands the atk kind to the view', async () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
// addInfo goes through renderInfo, which keeps the form (feedback1 WD: every enemy form, 转译基底·α's too)
assert.match(src, /function addInfo\(u\)\s*\{\s*const info = renderInfo\(u\)/);
const { renderInfo } = await import('../../public/js/render/app.js');
assert.equal(renderInfo({ id: 7, kind: 'enemy', side: 'enemy', defId: 'enemy_1040_bombd', form: 'bombed' }).form, 'bombed');
assert.match(src, /src\.onAttack\?\.\(tgt, now, e\[3\]\)/);
assert.match(src, /v\.windUp\(t - upcomingT, e\[3\]\)/);
});
test('the drop draws a falling bomb at the sim\'s speed and the mode change draws nothing of its own', () => {
assert.ok(PROJ.droneBomb, 'PROJ.droneBomb');
assert.equal(PROJ.droneBomb.speed, PROJECTILE_SPEEDS.droneBomb);
assert.equal(PROJ.droneBomb.look, 'shell', 'a bomb with a ground shadow, exploding where it lands');
assert.equal(FX.fxSpec('phase', { kind: 'bombed', id: 5 }).a, 'none');
assert.notEqual(FX.fxSpec('phase', { kind: 'crawl', id: 5 }).a, 'none', '掠海漂移体 keeps its puff');
});
});
+2 -2
View File
@@ -189,8 +189,8 @@ describe('shots', () => {
assert.equal(fx.projs.length, 1, kind);
const pr = fx.projs[0];
fx.update(DT);
assert.equal(pr.shadow.visible, kind === 'bomb' || kind === 'lob' || kind === 'boomerang', `${kind} shadow`);
if (kind === 'bomb') {
assert.equal(pr.shadow.visible, PROJ[kind].look === 'shell' || kind === 'boomerang', `${kind} shadow`);
if (kind === 'bomb' || kind === 'droneBomb') {
run(fx, pr.dur);
assert.ok(fx.rings.some((r) => r.sp.texture === fx.tex.shock), 'bomb explosion shockwave');
}
+191
View File
@@ -0,0 +1,191 @@
// 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');
});
});