diff --git a/docs/SIM.md b/docs/SIM.md index 0f66de58..55b384f4 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -213,12 +213,13 @@ never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no damage — the content's `attack` handler resolves it (帝国炮火先兆者's shells landing 3 s later, `content/enemies.js kitShell`); `enemy.profile.shot` = the `'atk'` event's projectile kind (`'mortar'`: no projectile drawn). 暴鸰 (`kitBombd`, -no normal attack) drops its one bomb as a projectile: the cast (trigger: an ally within its range 2) releases it -`BOMBD_RELEASE` (0.267 s, the Attack clip's OnAttack) later — `'atk'` kind `'droneBomb'` and fx `'phase'` {kind -`'bombed'`}: the model flies on without it — and it hits on arrival (target 100 % ATK, the other allies of the 8 tiles -around its landing tile 100 % splash, camouflage ignored; a target gone mid-flight: it lands where it was), then the -drone's speed ×2 (feedback D4 after 0.1.0: the damage used to land in the trigger tick with the bomb still on the -drone). Crates (stage devices with +no normal attack) drops its one bomb as a projectile: the cast (trigger: an ally within its range 2; the drone hovers +through it) releases it `BOMBD_RELEASE` (0.267 s, the Attack clip's OnAttack) later — `'atk'` kind `'droneBomb'` — and +it hits on arrival (target 100 % ATK, the other allies of the 8 tiles around its landing tile 100 % splash, camouflage +ignored; a target gone mid-flight: it lands where it was). The cast ends once the bomb has landed and at least +`BOMBD_POST_DELAY` (0.667 s) after the release: fx `'phase'` {kind `'bombed'`} (the model flies on without it), the +drone's speed ×2, and it moves again (feedback D4 after 0.1.0: the damage used to land in the trigger tick with the +bomb still on the drone). Crates (stage devices with role `crate`, 100 HP) are ground obstacles; an enemy forced through one is blocked by it and destroys it. A device is present when data/stages.json says `active: true` (this wins over the level file's `hidden`: act1 m02's crates); research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are @@ -963,7 +964,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex?, form? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's mode recorded by content as `unit.form` — 暴鸰 `'bombed'` after its drop — for views built later) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex?, form? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's mode recorded by content as `unit.form` — 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop — for views built later) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index b456a5fa..b15282ca 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -52,7 +52,8 @@ // fx kinds emitted (battle.fx(kind, {x, y, …})): 'explode' {r, kind} · 'zone' {r, dur, kind} · 'telegraph' {r, dur, // kind, tiles?} (delayed strikes, charges, 'reborn') · 'beam' {from, to, kind} · 'summon' {id, key} · 'ember'/'revive'/ // 'stone' {id} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' {id} · 'liberate' {id} -// · 'phase' {id, kind} (form / barrier changes) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. +// · 'phase' {id, kind} (form / barrier changes; a mode with its own clip set — render/units.js FORMS: 掠海漂移体 +// 'crawl', 暴鸰 'bombed' — is also kept in `e.form` → UnitInfo.form) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS } from '../constants.js'; @@ -88,6 +89,11 @@ const BOMB_REACH = 1; * animKey Attack, `_waitForAttackEvent`; the skeleton's OnAttack is at 0.267 s — data/assets.json hits.Attack). It then * flies as projectile_bombd (`_speed` 5 = PROJECTILE_SPEEDS.droneBomb, homing, `_ignoreCamouflage`). */ export const BOMBD_RELEASE = 0.267; +/** 暴鸰 投弹: the cast ends once the bomb has landed and no sooner than this after the release (the Boomb ability: + * `_fireAttackFinishWhenProjectileInvalid` 1, `_minPostDelayWhenProjectileInvalid` 0.667, `_waitForAnimEndWhenProjectileInvalid` + * 0). Then its buff bomb_s (template switch_mode_restart_fsm: mode S1 + the move-speed modifier) — PRTS "技能结束后移速最终 + * 提升至200%"; the cast's end clip is already the bomb-less Idle_2 (`_endAnimKey`). */ +export const BOMBD_POST_DELAY = 0.667; /** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力 * 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */ const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2; @@ -1082,6 +1088,7 @@ function kitSyufo(ab) { setFloat(b, e, false); e.profile.melee = true; b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null }); + e.form = 'crawl'; // UnitInfo.form: a view built later draws it crawling b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'crawl' }); }, }]; @@ -1473,12 +1480,17 @@ function kitRoar(ab) { * projectile: the cast plays the Attack clip, the bomb leaves on its OnAttack event (BOMBD_RELEASE) and flies to the * target (projectile_bombd), and the drone switches to its bomb-less mode (S1, buff bomb_s: the *_2 clips, no bottle). * User feedback after 0.1.0 (D4 "炸弹无法正常投放"): the damage used to land in the tick of the trigger while the drone - * kept its bomb on screen. Now: at the release an 'atk' event of kind 'droneBomb' (the client winds the Attack clip up - * to it and flies the bomb) and fx 'phase' {kind: 'bombed'} (render FORMS: the *_2 clips); on arrival the target (the - * ranged target by engine priority) takes 100 % ATK and every other ally of the 8 tiles around where it lands 100 % ATK - * splash (camouflage ignored); then move speed ×boomb.move_speed. A target gone mid-flight: the bomb lands where it was. - * [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the speed-up when the bomb lands; - * the drone keeps flying during the cast. + * kept its bomb on screen. Now: the drone hovers through its cast; at the release an 'atk' event of kind 'droneBomb' + * (the client winds the Attack clip up to it and flies the bomb) and fx 'phase' {kind: 'bombed'} (render FORMS: the + * *_2 clips, which the view starts once the Attack clip is over — the official end clip Idle_2; `e.form` → + * UnitInfo.form); on arrival the target (the ranged target by engine priority) takes 100 % ATK and every other ally of + * the 8 tiles around where it lands 100 % ATK splash (camouflage ignored); a target gone mid-flight: the bomb lands + * where it was. The cast ends once the bomb has landed, at least BOMBD_POST_DELAY after the release: move speed + * ×boomb.move_speed, and it flies on. + * [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (the + * engine pauses an enemy for its attack clip, ATTACK_PAUSE; the drop is the drone's only attack-like cast); the + * bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick + * before the clip ends — the client would draw the bomb back for a frame). */ function kitBombd(ab) { const s = ab.sk.boomb; @@ -1489,7 +1501,12 @@ function kitBombd(ab) { b.fx('explode', { x, y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box' }); if (t) hurt(b, e, t, atk, 'phys'); for (const u of alliesInTiles(b, r, c, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, atk, 'phys', { tags: ['splash'] }); - if (ms > 0 && e.alive) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); // 移速最终提升至200% + }; + // the end of the cast (bomb_s): 移速最终提升至200%; it flies on + const finish = (b, e) => { + if (!e.alive) return; + e.pauseUntil = b.time; + if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); }; return [ { spawn(b, e) { e.profile.noAttack = true; } }, @@ -1498,14 +1515,19 @@ function kitBombd(ab) { if (!t) return; a.cd = Infinity; a.left = Infinity; // 仅能触发一次 e.skillAnimUntil = -1; // the cast is drawn through its 'atk' event: the client winds the Attack clip up to it + // hovers through the cast, until `finish` (bounded: the bomb lands within its projectile's maxAge, 10 s) + e.pauseUntil = Math.max(e.pauseUntil, b.time + BOMBD_RELEASE + 10 + BOMBD_POST_DELAY); + let landed = false, waited = false; + const done = () => { if (landed && waited) finish(b, e); }; b.after(BOMBD_RELEASE, () => { if (!e.alive) return; const atk = e.s.atk; b._ev(['atk', e.id, t.id, 'droneBomb']); - e.form = 'bombed'; // UnitInfo.form: a view built later draws it bomb-less + e.form = 'bombed'; // UnitInfo.form: a view built later draws it bomb-less b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'bombed' }); b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.droneBomb, visual: 'droneBomb', source: e, hitDead: true, - onHit: (c) => land(b, e, atk, c.target, c.x, c.y) }); + onHit: (c) => { land(b, e, atk, c.target, c.x, c.y); landed = true; done(); } }); + b.after(BOMBD_POST_DELAY, () => { waited = true; done(); }, { owner: e }); }, { owner: e }); }, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }), ]; diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index 2120105d..eb500ef5 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -719,12 +719,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 — feedback D4, test/sim/feedback1d-bombd) + // (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 + enemiesMod.BOMBD_RELEASE + 1 / 5 + 0.1); + 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 %'); diff --git a/test/render/feedback1d-bombd.test.js b/test/render/feedback1d-bombd.test.js index cb937404..a617b834 100644 --- a/test/render/feedback1d-bombd.test.js +++ b/test/render/feedback1d-bombd.test.js @@ -2,9 +2,11 @@ // 暴鸰'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 sim's fx 'phase' { kind: 'bombed' } switches the -// view to those clips (render/units.js FORMS) with no fx of its own, and the drop's 'atk' kind 'droneBomb' flies a -// falling bomb (render/style.js PROJ.droneBomb, the sim's PROJECTILE_SPEEDS.droneBomb) from the drone to the target. +// 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'; @@ -67,7 +69,7 @@ describe('D4 暴鸰: the drone lets go of its bomb on screen', () => { 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), then the bomb-less flight', async () => { + 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' }); @@ -81,10 +83,15 @@ describe('D4 暴鸰: the drone lets go of its bomb on screen', () => { 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.MOVE), 1.2); - assert.equal(v.actor.current, 'Attack', 'the release frame'); - frames(50); // the rest of the 1 s clip at speed 1 (0.733 s) and a little - assert.equal(v.actor.current, 'Move_Loop_2', 'back to flying, without the bomb (not a 5 s attack rhythm from its BAT)'); + 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'); }); diff --git a/test/sim/feedback1d-bombd.test.js b/test/sim/feedback1d-bombd.test.js index 4402895c..6e9c6b9d 100644 --- a/test/sim/feedback1d-bombd.test.js +++ b/test/sim/feedback1d-bombd.test.js @@ -4,9 +4,12 @@ // 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 drone switches to its bomb-less mode (S1 `bomb_s`: the *_2 -// clips) and flies on at ×2. Now (content/enemies.js kitBombd): 'atk' kind 'droneBomb' + fx 'phase' { kind: 'bombed' } -// at the release, damage on arrival, the speed-up after the blast. +// 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. Also UnitInfo.form for 掠海漂移体's crawl. import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; @@ -17,6 +20,7 @@ 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 暴鸰). */ @@ -40,10 +44,10 @@ function realRound(seed = 3, round = 4) { } describe('D4 暴鸰: the bomb leaves the drone and lands', () => { - test('real wave: trigger → release on the OnAttack frame (atk droneBomb + bomb-less mode) → damage on arrival → ×2 speed', () => { + 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; + 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; @@ -51,15 +55,18 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => { 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'); - assert.ok(!drone.findBuff('ab:bombRun'), 'no speed-up before the bomb went off'); } 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 (hitAt != null && b.time > hitAt + 1) break; + 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'); @@ -69,7 +76,7 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => { assert.equal(relEv[3], 'droneBomb', 'drawn as the drone\'s bomb'); assert.ok(Math.abs(relT - castAt - RELEASE) <= TICK + 1e-9, `released ${(relT - castAt).toFixed(3)} s after the trigger (OnAttack ${RELEASE})`); 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 model drops to its bomb-less mode at the release'); + 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'); @@ -80,8 +87,13 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => { 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'); - if (drone.alive) assert.equal(run?.mods?.moveMul, DATA.enemies[KEY].skills[0].bb.move_speed, '移速最终提升至200% after the blast'); + 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(); }); @@ -122,6 +134,7 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => { 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('the drone killed before the release: no bomb, no mode change', () => { @@ -136,3 +149,19 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => { 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'); + }); +});