diff --git a/docs/ASSETS.md b/docs/ASSETS.md index d63bef74..85595d2f 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -204,8 +204,10 @@ The resolver's full precedence list is in the header of `tools/assets/anim-roles 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' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`; a `change` clip plays -once first, an `end` clip is timed from the fx's `dur` to finish as that state ends): +'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`, which +`render/app.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's +`dur` to finish as that state ends). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the stealth bit only +while its 隐匿 is on: - 掠海漂移体's crawl (`Change`, then `*_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` ends as it stands up) and 假想敌:再生's puppet diff --git a/docs/PLAYING.md b/docs/PLAYING.md index ae780089..518e9258 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 -- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(范围伤害也能打到),10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 +- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。 ## 5. 联防(同盟) diff --git a/docs/SIM.md b/docs/SIM.md index d1fc8ef8..38a2b951 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -229,17 +229,23 @@ 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 +wait for the real death, the only one with a `die` event. Every 重生 (`reborn()`, `husk()`, `statue()`) clears what +operators put on the enemy — the buffs with an ally source and source-less catalogue statuses (PRTS 特殊机制 §重生 +"清空自身身上除白名单外所有Buff"; `rebirthCleanse`) — and keeps [ASSUMED: the whitelist] its `persist` talents, what it or +another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst +locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable, immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `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 by operators (area -damage still reaches it); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk +walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (the +engine's radius area damage — `enemiesInRadius`: profession splash, skill circles — still reaches an unblocked one, a +known deviation: officially an AoE judges only the enemies it can select, PRTS 作战机制 §AOE伤害判定); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), 转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched -teammate's field, 联防 observers, a reconnect — starts in it) and the fx announces it: the `kind` of a form 'phase' fx +teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the +fx announces it: the `kind` of a form 'phase' fx (crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms. @@ -423,7 +429,7 @@ it, so the two players of a pair field compete for one instance instead of multi | `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – | | `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 | | `disarm` | no normal attacks | – | -| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth | – | +| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); radius splash (`enemiesInRadius`) ignores it [known deviation, PRTS 作战机制 §AOE伤害判定] | – | | `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – | | `invulnerable` | ignores damage | – | | `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – | @@ -977,7 +983,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set) (`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); +- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6). --- diff --git a/public/js/render/app.js b/public/js/render/app.js index d827a837..e9e01acc 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -12,7 +12,9 @@ // new board piece flashes (fx.deploy); a merge's elite — on the tile of // the deployed copy it replaced, or on its bench slot — gets the // promotion cue instead (render/promote.js, fx.promote) -// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } +// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each +// unit through renderInfo(UnitInfo) (an enemy's `form`: a view built +// mid-battle starts in the current model form) // view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in // `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings) // — 100 ms interpolation buffer; b.snap `down` keeps knocked-out @@ -243,6 +245,32 @@ export const FORCED_EXIT = 'forcedExit'; */ export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device'; +/** + * The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js + * unitInfo), sanitised; null for a malformed entry. `form` — an enemy's current model form (content/enemies.js setForm: + * 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl) — makes a view built mid-battle (a teammate's + * field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that + * clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5). + */ +export function renderInfo(u) { + if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null; + return { + id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null, + defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null, + avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1, + maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion, + // deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the + // ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge + dir: typeof u.dir === 'string' ? u.dir : undefined, + // an enemy's current model form (UnitInfo.form): the view starts in it (UnitView reads info.form) + form: typeof u.form === 'string' ? u.form : undefined, + // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a + // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) + skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, + moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, + }; +} + /** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */ export function boardPreference(opt) { let q = null; @@ -1283,21 +1311,8 @@ export async function createFieldView(host, options = {}) { } function addInfo(u) { - if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null; - const info = { - id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null, - defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null, - avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1, - maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion, - // deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the - // ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge - dir: typeof u.dir === 'string' ? u.dir : undefined, - // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a - // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) - skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, - moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, - }; - infos.set(info.id, info); + const info = renderInfo(u); + if (info) infos.set(info.id, info); return info; } diff --git a/public/js/render/units.js b/public/js/render/units.js index 27aa58bf..54bc5e4f 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -27,7 +27,7 @@ // Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式 // (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 转译基底's forms, the 逐火 // embers, 再生's puppet, the leaders' 重生 and 守墓石像 likewise (user report after 0.1.0) — FORMS. A view built later -// (UnitInfo `form`, the sim's current form) starts in the mode. +// (`info.form` = UnitInfo `form`, the sim's current form, through render/app.js renderInfo) starts in the mode. // Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型 // 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row // right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 75dfe8bf..4ef112b3 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -25,7 +25,9 @@ // (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); // 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 +// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); every 重生 +// (reborn / husk / statue) clears the enemy's statuses and the buffs allies gave it (rebirthCleanse, PRTS 特殊机制 +// §重生 "清空自身身上除白名单外所有Buff"); 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 @@ -43,7 +45,10 @@ // gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害"). // DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域), // bleeding (removed by healing), pulsing auras. -// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits). +// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). Radius area damage +// (Battle.enemiesInRadius: profession splash around a struck target, many skills' circles) still hits +// an unblocked one — a known deviation: officially an AoE only judges the enemies it can select (PRTS +// 作战机制 §AOE伤害判定; ba.invisible vs ba.camou); tile selectors (enemiesInKeys) already skip it. // REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced // (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on. // SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below. @@ -704,9 +709,26 @@ function freeAllPrisoners(b) { for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x); } +/** + * 重生 "清空自身身上除白名单外所有Buff" (PRTS 特殊机制 §重生): at a 重生 (reborn(), husk(), statue()) the enemy loses every + * buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … — + * buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy + * gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's + * state buffs without a source (terrain, airflow — re-applied by position — and element burst locks). Without it a 逐火 + * knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — as an + * ember until the fear ran out. + */ +function rebirthCleanse(b, e) { + for (const x of e.buffs.slice()) { + if (x.persist || (x.source && x.source.side === 'enemy')) continue; + if (x.source || x.status) b.removeBuff(e, x); + } +} + /** * "首次被击倒后重生 / 进入第二形态": the first knock-out is hidden (kill credit and bounty wait for the real death) and starts - * a rebirth of `dur` s — invulnerable, untargetable, released by its blocker, inert (no move / attack / skill). + * a rebirth of `dur` s — statuses cleared (rebirthCleanse), invulnerable, untargetable, released by its blocker, inert + * (no move / attack / skill). * `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the * enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow. * `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive' @@ -731,6 +753,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu a.state = 'reborn'; a.t0 = b.time; a.noAtk = e.profile.noAttack; + rebirthCleanse(b, e); e.hp = Math.min(1, e.s.maxHp); if (onKo) safe(b, e, () => onKo(b, e, a)); if (!(dur > 0)) { finish(b, e, a); return true; } @@ -811,11 +834,12 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { /** * "被击倒时暂时变为…,一段时间后重生" (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) + * no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — statuses cleared + * (rebirthCleanse: a feared warrior is no unblockable ember), 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; drawn solid while blocked) * 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 ("一次又一次地站起"). * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). @@ -848,6 +872,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke a.rebornUntil = b.time + HUSK_REBIRTH; a.noAtk = e.profile.noAttack; a.max = a.max ?? e.base.maxHp; + rebirthCleanse(b, e); e.profile.noAttack = true; e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior setHits(e, hits); @@ -864,9 +889,10 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke } /** - * 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 to 100 % HP - * into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune to 浮空, DEF +stone.def, RES - * +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer (失衡免疫) whose ranged attacks (the + * 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 (statuses + * cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune + * to 浮空, DEF +stone.def, RES +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer + * (失衡免疫) whose ranged attacks (the * data's 1.6 radius) deal arts damage and never target flyers; the HP is not refilled again. fx forms 'stone' / 'fly' * (its Sleep and *_2 clips, render/units.js FORMS). <破碎支柱> (an event device) has no counterpart in this mode. */ @@ -878,6 +904,7 @@ function statue(ab) { if (a.done || !(dur > 0)) return false; a.done = true; a.noAtk = e.profile.noAttack; + rebirthCleanse(b, e); // "进行重生。重生瞬间完成" e.profile.noAttack = true; e.hp = e.s.maxHp; ab2.immune = new Set([...(ab2.immune || []), 'levitate']); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 00a8592c..805275d7 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -4,7 +4,8 @@ // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, // form?, skillIndex?, moduleId? } (form = an enemy's current model form, content/enemies.js setForm) // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). -// flags bits & anim codes come from shared/constants.js (UF / ANIM). +// flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it +// is blocked or revealed), an ally's = 隐匿 / 迷彩 whatever it blocks. import { UF, ANIM } from '../../shared/constants.js'; import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js'; @@ -52,8 +53,10 @@ export function flagsOf(u) { if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED; if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED; if (f.freeze) bits |= UF.FROZEN; - // 隐匿 or 迷彩 (buffs.js camou): both shown the see-through way, blocking or not (targeting.js canTargetAlly) - if (f.stealth || f.camou) bits |= UF.STEALTH; + // 隐匿 or 迷彩 (buffs.js camou), shown the see-through way. An ally keeps it while blocking (targeting.js + // canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed (PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉从而 + // 失去隐匿"; targeting.js canTargetEnemy): a blocked 逐火 余烬 is drawn solid while the team beats it + if (u.side === 'enemy' ? (f.stealth && !f.reveal && !u.blockedBy) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH; if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL; if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD; if (f.invulnerable) bits |= UF.INVULN; diff --git a/test/render/feedback1-forms.browser.test.js b/test/render/feedback1-forms.browser.test.js index 98044fbc..9098c6d4 100644 --- a/test/render/feedback1-forms.browser.test.js +++ b/test/render/feedback1-forms.browser.test.js @@ -4,7 +4,10 @@ // #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on // as 寻仇者 (B_*), dying on B_Die — it used to die in its first form, on B_Die ("加载变身动画然后就没了"); // #8 a 深池逐火战士 knocked out in front of 百炼嘉维尔 plays 'Die' (its 1 s 重生), stands there as the 隐匿 ember on -// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever. +// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever; +// a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's +// field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js +// renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering. // // Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js // Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png. @@ -76,6 +79,48 @@ async function runInPage(page, port, s, enemyKey, secs, actSrc) { }, s, enemyKey, secs, actSrc); } +/** + * The late view: `spec` runs silently in the page (events drained and dropped, like the client runner's catch-up) while + * `setupSrc(b, ally)` changes the enemies' forms; at `atSecs` the field view enters the battle from `b.fieldMeta()` and + * follows the feed. Returns { [name]: { clip, form, alive } } of the views of the enemies `setupSrc` returns by name. + */ +async function lateViewInPage(page, port, s, atSecs, setupSrc) { + await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`); + await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); + return page.evaluate(async (spec, atSecs, setupSrc) => { + const setup = new Function('b', 'ally', setupSrc); + const { loadBrowserSim } = await import('/js/battle/runner.js'); + const { data } = await import('/js/data.js'); + const { spec: S, ds } = await loadBrowserSim(); + const b = S.createBattleFromSpec(spec, ds, { quiet: true }); + const v = window.__demo.view; + v.setStage(data.lookup('stages', spec.stageId)); + b.step(); + const named = setup(b, b.allyUnits[0]); + while (b.time < atSecs) { b.step(); b.drainEvents(); } + const meta = b.fieldMeta(); + v.enterBattle(meta); + v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true }); + v.setLocalFeed({ on: true, speed: 2 }); + const raf = () => new Promise((r) => requestAnimationFrame(() => r())); + const read = () => Object.fromEntries(Object.entries(named).map(([k, u]) => { + const view = v.debug.views.get(u.id); + return [k, { clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: u.alive, simForm: u.form }]; + })); + for (let i = 0; i < 30; i++) { + b.step(); + if (i % 3 === 2) { + const ev = b.drainEvents(); + if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev }); + v.pushSnapshot(b.snapshot()); + await raf(); + } + if (i === 15) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load + } + return read(); + }, s, atSecs, setupSrc); +} + describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => { let srv, browser; before(async () => { @@ -151,4 +196,33 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea await p.close(); } }); + + test('a view built mid-battle (enterBattle(fieldMeta) after a silent catch-up) starts in the current forms: 特战术师 D_*, 幽灵 C_*, the ember Idle_2 / Move_2, 掠海漂移体 *_02', async () => { + const { p, problems } = await page(); + try { + const got = await lateViewInPage(p, srv.port, spec('enemy_1007_slime', 'late', 9, 8), 7.5, ` + ally.base.atk = 0; ally.markDirty(); // 百炼嘉维尔 only blocks + const still = { mods: { speedMul: 0 } }; + const shushi = b.spawnEnemy('enemy_10081_mpplai', { pos: [11, 9], ...still }); + const youling = b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8.4], ...still }); // blocked ⇒ 幽灵 + const ember = b.spawnEnemy('enemy_1288_duskls', { pos: [12, 9], ...still }); + const drift = b.spawnEnemy('enemy_2025_syufo', { pos: [11, 6], ...still }); + for (let i = 0; i < 4; i++) b.dealDamage(ally, shushi, { amount: 1, type: 'arts' }); + b.kill(ember, ally); + b.applyStatus(drift, 'stun', { duration: 1, source: ally }); + return { shushi, youling, ember, drift }; + `); + await p.screenshot({ path: path.join(OUT, 'feedback1-lateview.png') }); + assert.deepEqual(Object.fromEntries(Object.entries(got).map(([k, x]) => [k, x.simForm])), + { shushi: 'translator_shushi', youling: 'translator_youling', ember: 'husk', drift: 'crawl' }, 'the sim\'s forms'); + for (const [k, x] of Object.entries(got)) assert.ok(x.spine && x.alive && x.form === x.simForm, `${k}: a live Spine view in the sim's form (${JSON.stringify(x)})`); + assert.match(got.shushi.clip, /^D_/, `特战术师 (${got.shushi.clip})`); + assert.match(got.youling.clip, /^C_/, `幽灵 (${got.youling.clip})`); + assert.match(got.ember.clip, /^(Idle|Move)_2$/, `the ember (${got.ember.clip})`); + assert.match(got.drift.clip, /_02$/, `crawling (${got.drift.clip})`); + assert.deepEqual(problems, []); + } finally { + await p.close(); + } + }); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 22c6f5a9..81934202 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -8,6 +8,7 @@ // 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember' // fx's `dur` to end as it stands up — brings the warrior back (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); // 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive; the leaders' 重生 close on their last clip as the 重生 ends. +// A view built mid-battle gets the current form from UnitInfo `form` through render/app.js renderInfo (enterBattle). import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -23,10 +24,11 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); const SYUFO = 'enemy_2025_syufo'; -let fake, UnitView, FORMS; +let fake, UnitView, FORMS, renderInfo; before(async () => { fake = installFakePixi(); ({ UnitView, FORMS } = await import('../../public/js/render/units.js')); + ({ renderInfo } = await import('../../public/js/render/app.js')); }); after(() => fake.restore()); @@ -297,26 +299,32 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af }); }); -test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up) starts in the current form: the changed 转译基底·α on D_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle', async () => { +test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up), through render/app.js renderInfo — what enterBattle(meta) hands the views — starts in the current form: the changed 转译基底·α on D_Idle / C_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle, 掠海漂移体 crawling', async () => { const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null }); + const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }); + const quiet = () => ({ trait: { noAttack: true } }); const h = makeBattle({ stageId: 'act2autochess_m01', content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: 'mode_multi_hard', round: 3, - defs: { chess: { t_mage: mage } }, units: [{ chessId: 't_mage', row: 12, col: 2 }], kits: { t_mage: () => ({ trait: { noAttack: true } }) }, + defs: { chess: { t_mage: mage, t_wall: wall } }, units: [{ chessId: 't_mage', row: 12, col: 2 }, { chessId: 't_wall', row: 9, col: 4 }], + kits: { t_mage: quiet, t_wall: quiet }, }); h.step(); const still = { mods: { speedMul: 0 } }; - const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8], ...still }); - const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [10, 8], ...still }); - const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [11, 8], ...still }); + const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [10, 8], ...still }); + const ghost = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 4.4], ...still }); // walks into the wall: blocked ⇒ 幽灵 + const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [11, 8], ...still }); + const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [12, 8], ...still }); + const drift = h.b.spawnEnemy(SYUFO, { pos: [11, 10], ...still }); const m = h.unit('t_mage'); for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); // the 4th arts hit ⇒ 特战术师 h.b.kill(ember, m); h.b.kill(mace, m); + h.b.applyStatus(drift, 'stun', { duration: 1, source: m }); // 掠海漂移体 drops to 爬行模式 const until = h.b.time + 6; while (h.b.time < until) { h.b.step(); h.b.drainEvents(); } const meta = h.b.fieldMeta(); const view = async (u) => { - const info = meta.units.find((x) => x.id === u.id); + const info = renderInfo(meta.units.find((x) => x.id === u.id)); const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam }); const v = new UnitView(ctx, info); await tick(); await tick(); @@ -324,8 +332,20 @@ test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a si return v; }; assert.equal(clip(await view(tr)), 'D_Idle', '特战术师, not the A model'); - assert.equal(clip(await view(ember)), 'Idle_2', 'the ember, not the warrior'); + assert.equal(clip(await view(ghost)), 'C_Idle', '幽灵 (blocked first), not the A model'); + const e = await view(ember); + assert.equal(clip(e), 'Idle_2', 'the ember, not the warrior'); + e.die(); + assert.equal(clip(e), 'Die_2', 'and it dies on the ember\'s clip'); assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form'); + assert.equal(clip(await view(drift)), 'Idle_02', '掠海漂移体 crawls (DESIGN §19.1: a reload used to hover)'); +}); + +test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views built from fieldMeta drew the first form)', () => { + assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls', form: 'husk' }).form, 'husk'); + assert.equal(renderInfo({ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1288_duskls' }).form, undefined); + assert.equal(renderInfo({ id: 3, form: 7 }).form, undefined, 'only a string'); + assert.equal(renderInfo({ kind: 'enemy' }), null, 'no id: no info'); }); test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — to the view and keeps the mode on the unit info', () => { diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index db003d52..8e876b94 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -492,3 +492,81 @@ test('every enemy starts with the same profile fields and `moving` / `form` (one } assert.equal(shapes.size, 1, 'one key order for every enemy'); }); + +// --------------------------------------------------------------------------------------------------------------- +// second review: a 重生 clears the statuses; a blocked 隐匿 enemy is drawn solid + +describe('a 重生 clears what operators put on the enemy (PRTS 特殊机制 §重生 "清空自身身上除白名单外所有Buff")', () => { + test('a 深池逐火战士 knocked out while feared and slowed (叙拉古-style 恐惧 makes it unblockable) is an ember without them: blocked again, beaten by the gun — its own talents stay', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_wall', row: 9, col: 7 }] }); + h.step(); + const e = put(h, 'enemy_1288_duskls', { mods: { speedMul: 1 } }); + const gun = h.unit('t_gun'); + assert.ok(h.runUntil(() => !!e.blockedBy, 20), 'blocked by the wall'); + assert.ok(h.b.applyStatus(e, 'fear', { duration: 30, source: gun }), 'feared'); + assert.ok(h.b.applyStatus(e, 'slow', { duration: 30, source: gun, value: 0.5 }), 'slowed'); + h.b.addBuff(e, { key: 'test:opDebuff', duration: 30, mods: { defFlat: -50 }, source: gun }); // a skill's debuff + assert.ok(e.s.flags.fear && e.s.flags.unblockable && !e.blockedBy, 'the fear releases it'); + h.b.kill(e, gun); + assert.ok(e.alive && e.form === 'husk', 'knocked out: its 重生'); + for (const k of ['fear', 'slow', 'test:opDebuff']) assert.equal(e.findBuff(k), null, `${k} cleared by the 重生`); + assert.ok(e.findBuff('ab:ember') && e.findBuff('ab:hitCount'), 'the ember\'s own buffs'); + assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'fear' && ev[3] === 0), 'the fear icon goes'); + h.run(HUSK_REBIRTH + 0.1); + assert.ok(!e.s.flags.fear && !e.s.flags.unblockable && e.s.flags.stealth, 'a 隐匿 ember that can be blocked'); + assert.ok(h.runUntil(() => !e.alive, 8), 'blocked, it is beaten before it could stand up'); + assert.equal(e.removeReason, 'killed'); + assert.ok(h.b.time < 2 + HUSK_REBIRTH + tb('enemy_1288_duskls', 'Revive[Trigger].interval') + 10); + }); + + test('锏\'s 重生 clears an operator\'s slow and 脆弱 but keeps its 抵抗 (a self-applied talent status); 守墓石像\'s instant 重生 too', () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS }); + h.step(); + const still = { mods: { speedMul: 0 } }; + const mace = put(h, 'enemy_1525_blkswb', { pos: [10, 7], ...still }); + const stone = put(h, 'enemy_1172_dugago', { pos: [11, 7], ...still }); + const gun = h.unit('t_gun'); + assert.ok(mace.findBuff('resist'), '锏 has its 抵抗'); + for (const u of [mace, stone]) { + assert.ok(h.b.applyStatus(u, 'slow', { duration: 30, source: gun, value: 0.5 })); + assert.ok(h.b.applyStatus(u, 'fragile', { duration: 30, source: gun, value: 0.3 })); + h.b.kill(u, gun); + assert.ok(u.alive, 'a 重生, not a death'); + assert.equal(u.findBuff('slow'), null, `${u.defId}: slow cleared`); + assert.equal(u.findBuff('fragile'), null, `${u.defId}: 脆弱 cleared`); + } + assert.ok(mace.findBuff('resist'), '锏 keeps its 抵抗'); + assert.ok(stone.findBuff('ab:stone'), 'the statue form starts'); + }); +}); + +describe('a blocked 隐匿 enemy is drawn solid (b.snap stealth bit: PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉")', () => { + test('the 逐火 ember: see-through while it walks unblocked, solid while blocked or revealed; an ally keeps its 迷彩 / 隐匿 look while blocking', async () => { + const { flagsOf } = await import('../../server/sim/snapshot.js'); + const { UF } = await import('../../shared/constants.js'); + const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 7 }] }); + h.step(); + const e = put(h, 'enemy_1288_duskls', { mods: { speedMul: 1 } }); + h.run(0.5); + h.b.kill(e, null); + h.run(HUSK_REBIRTH + 0.1); + assert.ok(e.s.flags.stealth && !e.blockedBy); + assert.ok(flagsOf(e) & UF.STEALTH, 'unblocked: see-through'); + const tuple = () => h.b.snapshot().units.find((t) => t[0] === e.id); + assert.ok(tuple()[7] & UF.STEALTH, 'in b.snap too'); + assert.ok(h.runUntil(() => !!e.blockedBy, 10), 'walks into the wall'); + assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns once the block ends)'); + assert.equal(flagsOf(e) & UF.STEALTH, 0, 'blocked: drawn solid'); + assert.equal(tuple()[7] & UF.STEALTH, 0); + assert.ok(flagsOf(e) & UF.BLOCKED); + const g = put(h, 'enemy_1288_duskls', { pos: [11, 9], mods: { speedMul: 0 } }); + h.b.kill(g, null); + h.run(HUSK_REBIRTH + 0.1); + assert.ok(flagsOf(g) & UF.STEALTH); + h.b.addBuff(g, { key: 'test:reveal', duration: 5, flags: { reveal: true } }); + assert.equal(flagsOf(g) & UF.STEALTH, 0, 'revealed: drawn solid'); + const wall = h.unit('t_wall'); + h.b.addBuff(wall, { key: 'test:stealth', duration: 5, flags: { stealth: true } }); + assert.ok(wall.blocking.length > 0 && flagsOf(wall) & UF.STEALTH, 'an ally keeps its 隐匿 look while blocking'); + }); +});