diff --git a/docs/ASSETS.md b/docs/ASSETS.md index bbad32e8..1702ac31 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -202,6 +202,12 @@ A skill clip may also come from directional-only animations when a model has no The resolver's full precedence list is in the header of `tools/assets/anim-roles.mjs`. +The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from +`public/js/render/units.js FORMS` (keyed by Spine id, switched by the sim's 'phase' fx kind or the `form` of its +'ember' / 'revive' fx; a `change` clip plays once first): 掠海漂移体's crawl (`Change`, `*_02`), 转译基底·α's three forms +(`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师), the 深池逐火 embers (`Die`, then +`Idle_2` / `Move_2` / `Die_2`; `Revive` back) and 假想敌:再生's puppet (`A_Die`, `B_*`, `B_Revive`). + Other renderer rules from research 07 §5.4–5.5: - **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up. - **Attack speed:** set the attack `timeScale` to `duration / attackInterval`. diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..cb23f25d 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -209,7 +209,8 @@ a stealthed ally (隐匿, 排气格栅) only for the enemy it blocks — our ope "不阻挡时不成为敌方普通攻击的目标") likewise (PRTS 异常效果: neither anomaly is "阻挡时解除") [ASSUMED: enemy skills and splash selectors skip it too] and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies -never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Content can take over an +never attack — unless content arms them through `enemy.profile` (`noAttack: false`, `melee`, `dmgType`, `maxTargets`: +转译基底·α's 寻仇者 / 特战术师 forms, which then attack like any enemy); `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. A `noMove` enemy stands (not `moving`, drawn idle). Content can take over an 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). Crates (stage devices with @@ -226,6 +227,16 @@ excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击, 同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n. +**Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the +enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all +wait for the real death, the only one with a `die` event. 重生 / form changes: `reborn()` (first knock-out → second +form), `husk()` (talent Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that +walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted and beaten; 假想敌: +再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), +转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s +change). The model's clip set follows a 'phase' fx kind or the `form` of the 'ember' / 'revive' fx (render/units.js +FORMS). + **Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is recorded for the player whose half contains the goal it reached, with `sourcePlayerId` preserved for unite LP. diff --git a/public/js/render/app.js b/public/js/render/app.js index 7d87e9a2..67847013 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1420,12 +1420,15 @@ export async function createFieldView(host, options = {}) { consumedIds.add(e[4].id); if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value); } - // an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1): its view switches clip set - // (UnitView.setForm); the info keeps it for a view built later - if (e[1] === 'phase' && e[4] && typeof e[4] === 'object' && e[4].id != null) { - const inf = infos.get(e[4].id); - if (inf) inf.form = typeof e[4].kind === 'string' ? e[4].kind : null; - views.get(e[4].id)?.setForm?.(e[4].kind); + // an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1; 转译基底's forms, a 逐火 ember and its + // revival — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, switches the view's + // clip set (UnitView.setForm); the info keeps it for a view built later + const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null; + const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined; + if (form !== undefined) { + const inf = infos.get(ex4.id); + if (inf) inf.form = typeof form === 'string' ? form : null; + views.get(ex4.id)?.setForm?.(form); } fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); break; diff --git a/public/js/render/units.js b/public/js/render/units.js index c2dcccb5..d698a863 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -100,21 +100,42 @@ export const DOWN_LOOK = Object.freeze({ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 }); /** - * Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine - * id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's - * roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already. + * Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind, or the `form` of another sim fx + * → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays + * once first: + * - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'; + * - 转译基底·α (user report after 0.1.0, #5): its 2 s change clip A_Die_B / _C / _D, then 寻仇者 B_*, 幽灵 C_* (no attack + * clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move; + * - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on + * Die_2; standing up again → 'Revive', then the warrior's manifest clips; + * - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips. + * 吉兆飞鳞's 晕眩模式 is its Stun clip already. */ +const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null }); +const clipSet = (idle, move, die, attack = null) => Object.freeze({ + idle, deploy: idle, die, move: loop(move), + attack: attack ? loop(attack, 'attackAny') : null, + skill: attack ? Object.freeze({ begin: null, loop: attack, end: null, via: 'attack', index: 0, idle: null }) : null, +}); +const EMBER = Object.freeze({ + husk: Object.freeze({ change: 'Die', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }), + revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }), +}); export const FORMS = Object.freeze({ enemy_2025_syufo: Object.freeze({ - crawl: Object.freeze({ - change: 'Change', - roles: Object.freeze({ - idle: 'Idle_02', deploy: 'Idle_02', die: 'Die_02', - move: Object.freeze({ begin: null, loop: 'Move_02', end: null }), - attack: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attackAny' }), - skill: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attack', index: 0, idle: null }), - }), - }), + crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }), + }), + enemy_10081_mpplai: Object.freeze({ + translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }), + translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }), + translator_shushi: Object.freeze({ change: 'A_Die_D', roles: clipSet('D_Idle', 'D_Move', 'D_Die', 'D_Attack') }), + }), + enemy_1288_duskls: EMBER, + enemy_1288_duskls_2: EMBER, + enemy_1292_duskld: EMBER, + enemy_9010_acpupp: Object.freeze({ + husk: Object.freeze({ change: 'A_Die', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }), + revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }), }), }); diff --git a/server/sim/ai.js b/server/sim/ai.js index da6089ac..373d95b1 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -17,7 +17,8 @@ // user playtest #6: "阻挡了就一定要能打到"). // Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the // candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed -// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the +// (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content arms it (`e.profile.dmgType`: +// 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the // enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear // flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted); // both re-plan the route when released (恐惧 outranks 诱导). @@ -456,7 +457,7 @@ export function updateEnemy(b, e, dt) { } if (!e.hidden && b._checkBlock(e)) return; if (b.time < e.pauseUntil) return; - if (e.s.flags.noMove) return; + if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking // 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from // its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; } @@ -570,11 +571,12 @@ function advanceRoute(b, e, dt, R) { function enemyAttack(b, e) { const def = e.def; if (e.profile && e.profile.noAttack) return; - if (def.dmgType === 'none' || e.s.atk <= 0) return; + const dmgType = (e.profile && e.profile.dmgType) || def.dmgType; + if (dmgType === 'none' || e.s.atk <= 0) return; if (e.s.flags.fear || e.s.flags.disarm) return; if (e.s.flags.tremble && e.blockedBy) return; // 战栗: 被阻挡后无法进行普通攻击 if (e.atkCd > 0) return; - if (def.dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; } + if (dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; } // applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5, // 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content). // Content may flip it with `e.profile.melee = false`. @@ -618,7 +620,7 @@ function enemyAttack(b, e) { e.lastAttackAt = b.time; e.stats.attacks++; const attackId = ++b._attackSeq; - const type = def.dmgType === 'heal' ? 'arts' : def.dmgType; + const type = dmgType === 'heal' ? 'arts' : dmgType; const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1); // content-resolved attacks (`e.profile.deferHit`: 帝国炮火先兆者's shell landing 3 s later): the attack itself happens // — the 'atk' event (kind `e.profile.shot`, drawn by the content's own fx), cooldown, pause, the 'attack' hook — and diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 6b4bc47a..285da740 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -24,7 +24,10 @@ // rebirth of Reborn.duration s, untargetable and inert, then the new form); artsBarrier() — "吸收法术伤害的屏障" // (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd); // unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook); -// attackLoop() — normal attacks for data-unarmed enemies once a form arms them; float() — 近地悬浮 (an air unit that +// husk() — "被击倒时暂时变为…,一段时间后重生" (Revive[Trigger]: every knock-out of the first form ⇒ 1 s 重生 ⇒ a walking +// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); a data-unarmed +// enemy armed by a form (转译基底) sets `e.profile` noAttack / melee / dmgType / maxTargets and attacks through the +// engine (ai.js enemyAttack); float() — 近地悬浮 (an air unit that // keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the // enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a // special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed @@ -51,11 +54,12 @@ // // 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'. +// 'stone' {id, form?} · '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'. A 'phase' kind, or +// the `form` of any other fx, is the model's clip set from then on (render/app.js → UnitView.setForm, units.js FORMS). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. -import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE } from '../constants.js'; +import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js'; import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js'; import { mitigate } from '../damage.js'; @@ -69,6 +73,13 @@ export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDo /** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时 * …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */ const PARASITE_SPREAD = 1000; +/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 "被击倒后重生,持续1s"; the same talent template on + * 假想敌:再生, whose PRTS page gives no duration — 1 s there too [ASSUMED]; both models' knock-out clips last 1 s). */ +export const HUSK_REBIRTH = 1; +/** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's + * immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */ +export const TRANSLATOR_CHANGE = 2; +const TRANSLATOR_IMMUNE = Object.freeze(['stun', 'sleep', 'cold', 'freeze', 'levitate', 'fear']); /** Death-explosion radius when the enemy has no official radius [ASSUMED]. */ const BOOM_RADIUS = 1.25; /** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7, @@ -720,33 +731,6 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu }; } -/** - * A normal-attack loop for enemies whose data never attacks (dmgType none) once content arms them (转译基底 forms): - * the blocker first, else (ranged) the engine priority inside `radius`; `hits` instances of ATK per attack. - * Disabled until `a.on`. - */ -function attackLoop({ type = 'phys', melee = true, radius = 0, hits = 1 } = {}) { - return { - spawn(b, e, a) { a.cd = 0; a.on = false; }, - tick(b, e, a, dt) { - if (!a.on || !e.alive || e.hidden) return; - const f = e.s.flags; - if (f.stun) return; - if (a.cd > 0) { a.cd -= dt; if (a.cd > 1e-9) return; } - if (f.disarm || f.fear || (f.tremble && e.blockedBy)) return; - let t = e.blockedBy && e.blockedBy.alive ? e.blockedBy : null; - if (!t && !melee && radius > 0) t = byPriority(e, targetsNear(b, e, radius))[0] ?? null; - if (!t) return; - a.cd = e.s.interval; - e.lastAttackAt = b.time; - e.stats.attacks++; - if (!e.blockedBy) e.pauseUntil = b.time + ATTACK_PAUSE; - if (!melee && t !== e.blockedBy) b.fx('beam', { x: e.x, y: e.y, from: e.id, to: t.id, kind: 'enemyShot' }); - for (let i = 0; i < hits && t.alive; i++) b.dealDamage(e, t, { amount: e.s.atk, type, isAttack: true }); - }, - }; -} - /** 正面减伤: phys/arts damage from allies in front of the enemy (`a.facing` side) × (1 − cut); `face` updates a.facing. */ function frontGuard(cut, face) { return { @@ -809,33 +793,53 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { }; } -/** Revive as a stealthed 频次 husk (逐火 embers, 假想敌:再生). */ -function husk({ hits, delay, stealthy = true, onHusk = null, key = 'ab:ember' }) { +/** + * "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is + * no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — invulnerable, + * untargetable, unblockable, immobile, 失衡免疫 (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有 + * 无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) — then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every + * damage instance removes 1 — PRTS 特殊机制 §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — + * 隐匿 (`stealthy`: targetable only while blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten) + * and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing + * after `delay` s stands up in its first form with full HP, and every later knock-out starts it again ("一次又一次地站起"). + * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. + * fx: 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands up (the + * renderer switches the model's clip set: render/units.js FORMS). + */ +function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, key = 'ab:ember' }) { + const revive = (b, e, a) => { + if (!e.alive || a.state !== 'husk') return; + a.state = null; + hitCount(b, e, false); + e.base.maxHp = a.max; + e.markDirty(); + void e.s; + e.hp = e.s.maxHp; + e.profile.noAttack = a.noAtk; + b.removeBuff(e, key); + b.removeBuff(e, `${key}:reborn`); + if (e.route) e.route.pts = null; + e.atkCd = 0; + b.fx('revive', { x: e.x, y: e.y, id: e.id, form: 'revived' }); + }; return { - killed(c, b, e, a, ab) { - if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; + killed(c, b, e, a) { + // an HP loss (流失 bypasses 无敌) during the 1 s 重生 cannot cut it short + if (a.state === 'husk' && b.time < a.rebornUntil) { e.hp = e.s.maxHp; return true; } + if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; // the husk's knock-out is the real death a.state = 'husk'; + a.rebornUntil = b.time + HUSK_REBIRTH; a.noAtk = e.profile.noAttack; a.max = a.max ?? e.base.maxHp; e.profile.noAttack = true; setHits(e, hits); hitCount(b, e, true); - b.addBuff(e, { key, visible: true, flags: { noMove: true, unblockable: true, ...(stealthy ? { stealth: true } : {}) } }); - b.fx('ember', { x: e.x, y: e.y, id: e.id, hits, dur: delay }); + b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); + b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); + if (e.route) e.route.pts = null; + b.fx('ember', { x: e.x, y: e.y, id: e.id, hits, dur: HUSK_REBIRTH + delay, form: 'husk' }); if (onHusk) onHusk(b, e); - b.after(delay, () => { - if (!e.alive || a.state !== 'husk') return; - a.state = null; - hitCount(b, e, false); - e.base.maxHp = a.max; - e.markDirty(); - void e.s; - e.hp = e.s.maxHp; - e.profile.noAttack = a.noAtk; - b.removeBuff(e, key); - if (e.route) e.route.pts = null; - b.fx('revive', { x: e.x, y: e.y, id: e.id }); - }, { owner: e }); + b.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e }); return true; }, }; @@ -948,6 +952,9 @@ const kitPrisoner = (freeAll = false) => (ab) => [prisoner(ab, { freeAll })]; const kitDeathSpawn = (extra = []) => (ab) => [deathSpawn(ab.tS['DeadSpawn.enemy_key'], T(ab, 'DeadSpawn.cnt') ?? 0), ...extra]; const kitStun3 = (ab) => [nthAttackStatus(nthOf(ab.sk.stuncombat), 'stun', (ab.sk.stuncombat && ab.sk.stuncombat.bb.stun) || 0, true)]; const kitSelfFear = (ab) => [selfFear(ab)]; +/** 深池逐火战士 / 精锐战士 / 护卫: knock-out ⇒ 1 s 重生 ⇒ a walking, 隐匿, disarmed 余烬 / 火灰 of prop_max_hp hits for `interval` s + * (PRTS 深池逐火战士 天赋: "基础最大生命值临时变为5…具有特殊生命值机制,不进行攻击,获得隐匿、缴械,10s后若未被击倒则变回战士形态并恢复所有 + * 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it. */ const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })]; const kitPolluted = (ab) => [{ death(c, b, e) { @@ -1420,10 +1427,13 @@ function kitBlackCloud(ab, e) { ]; } +/** 假想敌:再生 · knock-out ⇒ a 傀儡 of prop_max_hp hits for `interval` s that "不可被阻挡" (data description) and walks on — its + * model has a 傀儡 walk cycle (B_Move); an immobile unit would not need to be unblockable [ASSUMED from both] — plus + * max_damage_block_cnt-hit shields on the enemies around it. */ function kitRegen(ab) { const hits = T(ab, 'Revive[Trigger].prop_max_hp'), delay = T(ab, 'Revive[Trigger].interval'), block = T(ab, 'Aura.max_damage_block_cnt') ?? 0; return [husk({ - hits, delay, stealthy: false, key: 'ab:regen', + hits, delay, stealthy: false, unblock: true, key: 'ab:regen', onHusk(b, e) { // 被击倒后…使周围一定距离内其他敌人获得可以抵挡物理及法术伤害的护盾 b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); @@ -2048,32 +2058,75 @@ function kitWolfLord(ab) { })]; } -/** 转译基底·α · never attacks at first; 4 physical hits ⇒ 复仇 form (melee, heavy), 4 arts hits ⇒ 术士 form (ranged arts), - * blocked first ⇒ 幽灵 form (no stat change; unblockable [ASSUMED]). Stats from Mode_*_Passive. */ +/** + * 转译基底·α (PRTS 转译基底·α 天赋; enemy_database talents Passive / Mode_*_Passive / Mode_Fuchou_Anger; skills ChangeToB/C/D): + * 原始形态 — no attack, every damage instance is cancelled ("受到伤害时取消此伤害"), 失衡免疫, immune to 晕眩/沉睡/寒冷/冻结/浮空/ + * 恐惧. The 4th physical damage instance taken ⇒ 寻仇者, the 4th arts one ⇒ 特战术师, being blocked ⇒ 幽灵 — once ("仅可变化一次"); + * the change takes TRANSLATOR_CHANGE (2) s, standing still and still in the original form [ASSUMED: immobile, damage + * still cancelled]; then the form's flat stat changes apply (move speed relative to the data's 1.0, attack interval + * +N s, 重量等级 +N): + * 寻仇者 (form B): melee only, physical; ATK +100 % while HP < 50 % (Mode_Fuchou_Anger.atk); + * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once; + * 幽灵 (form C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). + * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, + * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed by damage at all — + * user report after 0.1.0 (#5): the v2.5 kit let damage through, so it died in its first form, its model still in that + * form playing the 寻仇者's B_Die. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s + * change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C, + * the only clip set without an attack, is the non-attacking 幽灵. + */ function kitTranslator(ab, e) { const t = (k) => T(ab, k) ?? 0; - const P = { phys: 0, arts: 0, form: null }; - const mods = (pre) => ({ hpFlat: t(`${pre}.max_hp`), atkFlat: t(`${pre}.atk`), defFlat: t(`${pre}.def`), resFlat: t(`${pre}.magic_resistance`), - moveMul: Math.max(0, 1 + t(`${pre}.move_speed`)), batPct: t(`${pre}.base_attack_time`) / (e.base.bat || 1) }); - const melee = attackLoop({ type: 'phys', melee: true }); - const caster = attackLoop({ type: 'arts', melee: false, radius: e.base.rangeRadius || (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.4 }); - const change = (b, e2, form) => { - if (P.form) return; - P.form = form; - const pre = form === 'fuchou' ? 'Mode_Fuchou_Passive' : form === 'shushi' ? 'Mode_Shushi_Passive' : 'Mode_Youling_Passive'; - b.addBuff(e2, { key: 'ab:form', persist: true, visible: true, mods: mods(pre), flags: form === 'youling' ? { unblockable: true } : null }); - if (form === 'fuchou') melee.on = true; - if (form === 'shushi') caster.on = true; - b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: `translator_${form}` }); + const P = { phys: 0, arts: 0, form: null, done: false, anger: false }; + const PRE = { fuchou: 'Mode_Fuchou_Passive', shushi: 'Mode_Shushi_Passive', youling: 'Mode_Youling_Passive' }; + const mods = (pre) => ({ + hpFlat: t(`${pre}.max_hp`), atkFlat: t(`${pre}.atk`), defFlat: t(`${pre}.def`), resFlat: t(`${pre}.magic_resistance`), + moveMul: Math.max(0, 1 + t(`${pre}.move_speed`) / (e.def.moveSpeed || 1)), batPct: t(`${pre}.base_attack_time`) / (e.base.bat || 1), + massFlat: t(`${pre}.mass_level`), + }); + const finish = (b, e2) => { + if (!e2.alive || P.done) return; + P.done = true; + b.removeBuff(e2, 'ab:origin'); + ab.immune = null; + b.addBuff(e2, { key: 'ab:form', persist: true, visible: true, mods: mods(PRE[P.form]), flags: P.form === 'youling' ? { unblockable: true } : null }); + if (P.form !== 'youling') { + const melee = P.form === 'fuchou'; + Object.assign(e2.profile, { noAttack: false, melee, dmgType: melee ? 'phys' : 'arts', maxTargets: melee ? 1 : 2 }); + e2.atkCd = 0; + } + if (e2.route) e2.route.pts = null; }; - return [melee, caster, { - taken(c, b, e2) { - const s = c.source; - if (P.form || !s || s.side !== 'ally') return; - if (c.dmg.type === 'phys' && ++P.phys >= (t('Passive.phy_max_count') || 4)) change(b, e2, 'fuchou'); - else if (c.dmg.type === 'arts' && ++P.arts >= (t('Passive.magic_max_count') || 4)) change(b, e2, 'shushi'); + const change = (b, e2, form) => { + if (P.form || !e2.alive) return; + P.form = form; + b.addBuff(e2, { key: 'ab:change', duration: TRANSLATOR_CHANGE, persist: true, flags: { noMove: true }, onExpire: ({ battle }) => finish(battle, e2) }); + b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: `translator_${form}`, dur: TRANSLATOR_CHANGE }); + }; + return [{ + spawn(b, e2) { + b.addBuff(e2, { key: 'ab:origin', persist: true, flags: { noDisplace: true } }); + ab.immune = new Set(TRANSLATOR_IMMUNE); + }, + hitIn(c, b, e2) { + if (P.done) return; + const s = c.source || c.credit, ty = c.dmg.type; + if (!P.form && s && s.side === 'ally') { + if (ty === 'phys' && ++P.phys >= (t('Passive.phy_max_count') || 4)) change(b, e2, 'fuchou'); + else if (ty === 'arts' && ++P.arts >= (t('Passive.magic_max_count') || 4)) change(b, e2, 'shushi'); + } + c.dmg.cancel = true; }, blocked(c, b, e2) { change(b, e2, 'youling'); }, + tick(b, e2) { + // 寻仇者: "生命值低于50%时,攻击力+100%" + if (P.form !== 'fuchou' || !P.done) return; + const on = e2.hpRatio < 0.5; + if (on === P.anger) return; + P.anger = on; + if (on) b.addBuff(e2, { key: 'ab:anger', persist: true, visible: true, mods: { atkPct: t('Mode_Fuchou_Anger.atk') } }); + else b.removeBuff(e2, 'ab:anger'); + }, }]; } @@ -2154,10 +2207,10 @@ export const KITS = Object.freeze({ enemy_1209_sfden_2: kitInvisShield, // 堂皇 · same (铁灯盘) enemy_1203_sfhu: kitTeapot, // 烹泉 · death: 4 青瓷茶器 + ASPD-down arts blast zone enemy_1203_sfhu_2: kitTeapot, // 沏虹 · death: 4 彩瓷茶器 + blast zone - enemy_1288_duskls: kitEmber, // 深池逐火战士 · on KO: stealthed 5-hit ember, revives after 10 s + enemy_1288_duskls: kitEmber, // 深池逐火战士 · every KO: 1 s 重生 ⇒ walking 隐匿 5-hit ember (block it to hit it), back after 10 s enemy_1288_duskls_2: kitEmber, // 深池逐火精锐战士 · same - enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit ember - enemy_9010_acpupp: kitRegen, // 假想敌:再生 · 15-hit regen husk (15 s) + 5-hit shields on nearby enemies + enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰 + enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields nearby // --- ELEMENT 元素 enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit @@ -2466,7 +2519,7 @@ export const KITS = Object.freeze({ enemy_1517_xi: kitXi, // “自在” · cross arts, 破桎而出 barrier blast ⇒ form 2 (double hits, 2 crosses) enemy_1525_blkswb: kitMace, // 锏 · 抵抗, DEF pen, 速杀 blink, AoE ⇒ form 2 (stealth, double hits, SP clear) enemy_1535_wlfmster: kitWolfLord, // 扎罗 · −30 % damage, 溶血骇惧 ⇒ 远古威慑 rebirth ⇒ ranged double hits - enemy_10081_mpplai: kitTranslator, // 转译基底·α · 4 phys / 4 arts hits or being blocked ⇒ one of three forms + enemy_10081_mpplai: kitTranslator, // 转译基底·α · damage cancelled; 4th phys / 4th arts hit or blocked ⇒ 2 s change ⇒ 寻仇者 / 特战术师 / 幽灵 enemy_10144_xdelk_2: kitElk, // 乌顶巨角卢鲁 · 角力对决 charge on its blocker (push fails ⇒ damage + stun) enemy_2085_skzjxd: (ab) => [frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // 圆仔 · faces the bigger crowd, front damage −80 % enemy_2085_skzjxd_2: (ab) => [frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // 圆仔 · same diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index 36ef0d71..fd0257e4 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -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); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 35af0d74..a1bbbcb0 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -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\)/); }); diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js new file mode 100644 index 00000000..35db61ef --- /dev/null +++ b/test/sim/feedback1-transform.test.js @@ -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); + }); +});