From bfd8b1647ca317283794c036742daede4ef85ba1 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 05:35:18 +0800 Subject: [PATCH] feedback1e WQ: healer tests cast every skill while blocking; rule in headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for E2 (not reproduced, no behaviour change): - blockRun fills the skill's SP (and puts the patient back to 5 %) the first tick the healer blocks, and tests 2 and 3 assert the skill is cast or running while it blocks: all 56 healer loadouts now cast in the window (half never did at the default initial SP). - The reported 炎 boss round makes 录武官's skill ready when she first holds 卢西恩 / 不祥幻影 and asserts it is cast or running then. - The boss test says what it covers: 0 healer damage on all 20 boss / hidden fields; healers block 卢西恩 / 不祥幻影 (h07_05) and a patrolling 碎铳之簧 once its shield is broken (h08_02); the other leaders and parts cannot be blocked by a block-1 unit. - SIM.md: the healer qualifier follows the "one-line flip" sentence, so "This replaces..." refers to the every-blocker rule again. - ai.js header, Battle.blockedTargets and targeting.js: the blocked-first rule is for attacks that hit enemies; a heal attack keeps selecting injured allies (PRTS 卫戍协议/帮助). - PLAYING.md: the healer exception names 咒愈师 as the one that hits. --- docs/PLAYING.md | 2 +- docs/SIM.md | 14 +++-- server/sim/Battle.js | 4 +- server/sim/ai.js | 7 ++- server/sim/targeting.js | 3 +- test/sim/feedback1e-healers.test.js | 95 ++++++++++++++++++++++------- 6 files changed, 91 insertions(+), 34 deletions(-) diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 8774fff9..d1f05b98 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -68,7 +68,7 @@ - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。 - **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。 -- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员和「停止攻击」类技能期间除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 +- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 - 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击;**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但它不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。 - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 diff --git a/docs/SIM.md b/docs/SIM.md index 987a3f39..024e67f4 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -189,14 +189,16 @@ front — `Battle.blockedTargets`, used by `ai.js acquireTargets` and the skills `sortEnemyTargets`; "自身这格内" rules name the blocked enemies separately — 瑕光 S2, PRTS 备注). A kit that picks its own targets from the range (`beforeAttack`) adds `battle.blockedTargets` to its candidates (深靛, 迷迭香 S3, 荒芜拉普兰德 S2, 佩佩 S2, 灵知 S3, 远牙 S3); only skill texts that exclude targets keep their rule (普罗旺斯 S2: none above 80 % HP; 寒檀 -S2: icicles on random tiles of her range). The rule is for units whose attack hits enemies: a healer (dmgType `heal` — -医师 / 群愈师 / 疗养师 / 链愈师 / 行医, the map characters 预备干员-医疗 / Touch — and a skill attack turned into a heal: -古米 S1 / S2, 塞雷娅 S1, 波登可 S1) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 -"对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 仇恨 HP_RATIO_NOT_FULL_ASC; `ai.js acquireTargets` returns -before the blocked targets, the heal skills' DEFAULT trigger looks at injured allies — test/sim/feedback1e-healers.test.js); -咒愈师 attack enemies and follow it. This replaces the +S2: icicles on random tiles of her range). This replaces the playtest #5 QA's melee-only reading (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员…无法攻击到这个敌人"); DESIGN §20 keeps the one-line flip (`Battle.blockedTargets` / `sortEnemyTargets` gated on a melee position again). +The every-blocker rule is for units whose attack hits enemies (咒愈师 included: "攻击造成法术伤害"). A healer — dmgType +`heal` (医师 / 群愈师 / 疗养师 / 链愈师 / 行医, the map characters 预备干员-医疗 / Touch) or a skill attack turned into a +heal (古米 S1 / S2, 塞雷娅 S1, 波登可 S1) — selects injured allies only and keeps healing while it blocks; it never targets +the enemy it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 仇恨 +HP_RATIO_NOT_FULL_ASC; PRTS 选择器 adds only the blocked units the selector's side can pick): `ai.js acquireTargets` +returns its heal targets before the blocked targets, and the heal skills' DEFAULT trigger looks at injured allies +(test/sim/feedback1e-healers.test.js). **Enemy attacks:** blocked melee enemies hit their blocker; enemies with `rangeRadius > 0` **and `applyWay` ≠ `MELEE`** attack allies whose collider touches their range circle — centre distance ≤ `rangeRadius` + `ALLY_COLLIDER_RADIUS` 0.25 diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 68187bb4..35eb0106 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1104,7 +1104,9 @@ export class Battle { * or not, whatever its facing ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT * trigger). That holds for a ranged operator on a melee tile too: the user's rule after playtest #6, "阻挡了就一定要能 * 打到" — officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit - * it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction). + * it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction). Only for attacks that hit enemies: a heal + * attack never asks — it keeps selecting injured allies while its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员 + * (咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 选择器 adds only the blocked units its side can pick). */ blockedTargets(u, profile) { const out = []; diff --git a/server/sim/ai.js b/server/sim/ai.js index 2b7ffe0a..620b1c00 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -12,9 +12,10 @@ // but walks the ground. An unblocked enemy touching an ally with free block capacity — within its block radius (0.7071 // ground, 0.8944 air, devices 0.4472; Battle._checkBlock) — is blocked, moving or not, so an enemy overlapping an // operator is taken over once its blocker is gone. Blocked enemies fight their blocker (ranged ones may pick anyone in -// range, blocker first); every blocker — a ranged operator on a melee tile included — may always target the enemies -// it blocks, in range or not, whatever its facing, and targets them first (acquireTargets, Battle.blockedTargets; -// user playtest #6: "阻挡了就一定要能打到"). +// range, blocker first); every blocker whose attack hits enemies — a ranged operator on a melee tile included — may +// always target the enemies it blocks, in range or not, whatever its facing, and targets them first (acquireTargets, +// Battle.blockedTargets; user playtest #6: "阻挡了就一定要能打到"); a heal attack keeps selecting injured allies while +// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"). // 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 diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 96113838..f2f19162 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -6,7 +6,8 @@ // occupies is (body.js; user playtest #5 item 10). `rangeExtend` (ability_range_forward_extend) adds N tiles // past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3). // Operator priority: (1) enemies it blocks (every blocker, ranged ones on melee tiles included — user playtest #6 -// follow-up "阻挡了就一定要能打到"; Battle.blockedTargets), (2) profile priority (fly/lowDef/…), +// follow-up "阻挡了就一定要能打到"; Battle.blockedTargets; a healer's heal attack picks injured allies instead, blocking +// or not — ai.js acquireTargets), (2) profile priority (fly/lowDef/…), // (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units // (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy // priority: sortAllyTargets. diff --git a/test/sim/feedback1e-healers.test.js b/test/sim/feedback1e-healers.test.js index 19466222..d4d5eddc 100644 --- a/test/sim/feedback1e-healers.test.js +++ b/test/sim/feedback1e-healers.test.js @@ -8,10 +8,13 @@ // an enemy keeps healing and never strikes it; the user's playtest #6 rule "阻挡了就一定要能打到" (DESIGN §20.3) is for // the units whose attack hits enemies (咒愈师 included: "攻击造成法术伤害"). // Verified on the real sim: NOT reproduced. Every healer of the pool (医师 / 群愈师 / 疗养师 / 链愈师 / 行医, the map -// characters, normal and elite, every skill) blocking an enemy — on the real boss rounds with the roaming 卢西恩, its -// 不祥幻影 and the 剑 / 锤, under every bond (as a member), with every equipment item — keeps healing and deals no damage from its -// normal attack or its skills; the only damage a pure healer deals is a bond's own effect by its text (卡西米尔: "阻挡敌人 -// 时每2秒对周围敌人造成120%攻击力真实伤害", members of every class). These tests lock that. +// characters, normal and elite, every skill — each skill made ready while the healer blocks, so it is cast then) blocking +// an enemy keeps healing and deals no damage from its normal attack or its skills: under every bond (as a member), with +// every equipment item, and on the 20 boss / hidden fields, where healers block the roaming 卢西恩 and its 不祥幻影 +// (act2 h07_05) and a patrolling 碎铳之簧 once its shield is down (h08_02); the other leaders and parts cannot be blocked +// (自缚 / 无法被阻挡, the 剑 / 锤 anchored, 余音 only by block ≥ 2). The only damage a pure healer deals is a bond's own +// effect by its text (卡西米尔: "阻挡敌人时每2秒对周围敌人造成120%攻击力真实伤害", members of every class). These tests lock +// that. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -25,6 +28,8 @@ const pool = (hp) => ({ hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, th const waves = () => JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8')); /** A bond's own effect on its members by its text (not the unit's attack): 卡西米尔's pulse while blocking. */ const bondEffect = (c) => !c.dmg?.isAttack && (c.dmg?.tags || []).includes('bond:kazimierz'); +/** 卢西恩 and its 不祥幻影 — the boss that roams the field (patrol) and can be blocked. */ +const LUCIEN = /^enemy_201[67]_csph/; // ===================================================================================================================== // the reported case @@ -47,9 +52,17 @@ test('E2 (not reproduced): 录武官 blocking 卢西恩 on the real 炎 boss rou bonds: { yanShip: { count: 9, active: true, tier: 3, layers: 120 } }, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack', 'blocked', 'skillStart'], setup(b) { b.enemyOverrides = tpl.overrides; } }); const M = h.unit(units[0].chessId); - let healsBlocking = 0, othersBlocking = 0; + const boss = () => M.blocking.some((e) => LUCIEN.test(e.defId)); + let healsBlocking = 0, othersBlocking = 0, topped = false, skillOnBoss = 0; h.b.on('heal', (c) => { if (c.source === M && M.blocking.length && c.amount > 0) { healsBlocking++; if (c.target !== M) othersBlocking++; } }); - h.run(120); + h.b.on('attack', (c) => { if (c.attacker === M && c.isSkill && boss()) skillOnBoss++; }); + h.b.on('skillStart', (c) => { if (c.unit === M && boss()) skillOnBoss++; }); + for (let i = 0; i < 120 * 30; i++) { + h.step(); + // the skill becomes ready while she holds 卢西恩 / its 不祥幻影: the report's 强力击 moment + if (!topped && boss()) { topped = true; M.skill.gainSp(M.skill.spCost * M.skill.maxCharges, 'init', true); } + if (boss() && M.skill.active && M.skill.isTimed) skillOnBoss++; // 一点关窍 running while she holds it + } const tag = `${M.def.name}${sfx} S${skillIndex + 1}`; assert.ok(h.hooksOf('blocked').some((c) => c.blocker === M && c.enemy.defId === 'enemy_2016_csphtm'), `${tag}: blocks 卢西恩`); assert.ok(h.b.allyUnits.some((u) => u.defId === 'enemy_9012_acloon' || /yanyou|acloon/.test(u.defId)), '炎佑 on the field'); @@ -57,7 +70,7 @@ test('E2 (not reproduced): 录武官 blocking 卢西恩 on the real 炎 boss rou assert.equal(dmg.length, 0, `${tag}: no damage to enemies (${dmg.map((c) => c.target.def?.name).join(',')})`); const atks = h.hooksOf('attack').filter((c) => c.attacker === M); assert.ok(atks.length > 0 && atks.every((c) => c.targets.every((t) => t.side === 'ally')), `${tag}: every attack heals an ally`); - assert.ok(h.hooksOf('skillStart').some((c) => c.unit === M), `${tag}: casts`); + assert.ok(topped && skillOnBoss > 0, `${tag}: casts / runs its skill while blocking 卢西恩 / 不祥幻影 (${skillOnBoss})`); if (skillIndex === 0) assert.ok(atks.some((c) => c.isSkill), `${tag}: 触类旁通's heal went to allies`); assert.ok(healsBlocking > 0, `${tag}: heals while blocking (${healsBlocking}, others ${othersBlocking})`); } @@ -73,27 +86,46 @@ const DEFS = { enemies: { enemy_fb1e: enemyRec({ key: 'enemy_fb1e', hp: 1e9, atk: 1, speed: 1 }) }, }; /** - * One healer (or token) on (9,6) of the flat normal field blocks a big walking enemy for ~13 s while a patient on (10,6) - * stays injured (华法琳 S2's 不稳定血浆 may drain it out: then she heals herself). Returns the hooks seen; `healsBlocking` - * = heals by the unit (any ally, itself included) while it blocks. + * One healer (or token) on (9,6) of the flat normal field blocks a big walking enemy from ~6.6 s on while a patient on + * (10,6) stays injured (华法琳 S2's 不稳定血浆 may drain it out: then she heals herself). The skill's SP is filled — and + * the patient put back to 5 % — the first tick the unit blocks (`fillSp`, default on: the skill becomes ready while it + * blocks — the report's 强力击 moment; the default initial SP leaves half the skills uncast in the window). Returns the hooks seen; `healsBlocking` = heals by the + * unit (any ally, itself included) while it blocks; `skillBlocking` = casts, skill attacks and ticks of a running skill + * while it blocks. */ -function blockRun(unit, { member = null, bonds = {}, secs = 20, rec = null } = {}) { +function blockRun(unit, { member = null, bonds = {}, secs = 20, rec = null, fillSp = true } = {}) { const defs = member && rec ? { ...DEFS, chess: { ...DEFS.chess, [rec.chessId]: { ...rec, bonds: [member] } } } : DEFS; const h = makeBattle({ defs, timeLimit: 60, seed: 5, autoFinish: false, bonds, captureNoisy: true, hooks: ['damaged', 'attack', 'heal'], units: [{ row: 9, col: 6, dir: 'RIGHT', ...unit }, { chessId: PATIENT, row: 10, col: 6, carryState: { hpPct: 0.05 } }], enemies: [{ key: 'enemy_fb1e', time: 0, route: 0 }] }); const M = h.b.allyUnits.find((u) => u.defId === (unit.chessId ?? unit.tokenId)); - let blocking = 0, healsBlocking = 0; + const P = h.b.allyUnits.find((u) => u.defId === PATIENT); + let blocking = 0, healsBlocking = 0, skillBlocking = 0, filled = !fillSp; h.b.on('heal', (c) => { if (c.source === M && M.blocking.length && c.amount > 0) healsBlocking++; }); - for (let i = 0; i < secs * 30; i++) { h.step(); if (M.blocking.length) blocking++; } + h.b.on('skillStart', (c) => { if (c.unit === M && M.blocking.length) skillBlocking++; }); + h.b.on('attack', (c) => { if (c.attacker === M && c.isSkill && M.blocking.length) skillBlocking++; }); + for (let i = 0; i < secs * 30; i++) { + h.step(); + if (!M.blocking.length) continue; + blocking++; + const sk = M.skill; + if (!filled && sk && !sk.noSkill) { + // the patient back to 5 % (a skill spent before the block may have healed it above 50 %: 华法琳 S1 紧急包扎 fires + // only on a heal target below half HP) + filled = true; + P.hp = P.s.maxHp * 0.05; + sk.gainSp(sk.spCost * sk.maxCharges, 'init', true); + } + if (sk?.active && sk.kind !== 'passive') skillBlocking++; + } return { - M, blocking: blocking / 30, healsBlocking, + M, blocking: blocking / 30, healsBlocking, skillBlocking, dmg: h.hooksOf('damaged').filter((c) => c.source === M && c.target.side === 'enemy'), atks: h.hooksOf('attack').filter((c) => c.attacker === M), }; } -test('E2 audit: every pure healer (医师 / 群愈师 / 疗养师 / 链愈师 / 行医, normal and elite, every skill) keeps healing while it blocks, under every bond as a member, and never hits the enemy', REAL, () => { +test('E2 audit: every pure healer (医师 / 群愈师 / 疗养师 / 链愈师 / 行医, normal and elite, every skill cast while blocking) keeps healing while it blocks, under every bond as a member, and never hits the enemy', REAL, () => { const ds = getDefaultSource(); const healers = Object.values(ds.raw.chess).filter((c) => c.stats && c.dmgType === 'heal' && PURE.has(c.subProfessionId)); assert.ok(healers.length >= 20, `healers ${healers.length}`); @@ -109,6 +141,7 @@ test('E2 audit: every pure healer (医师 / 群愈师 / 疗养师 / 链愈师 / const tag = `${c.name} ${c.chessId} S${si + 1}${member ? ' ' + member : ''}`; runs++; assert.ok(r.blocking > 8, `${tag}: blocks (${r.blocking.toFixed(1)} s)`); + assert.ok(r.skillBlocking > 0, `${tag}: the skill is cast / runs while it blocks`); assert.ok(r.atks.every((a) => a.targets.every((t) => t.side === 'ally')), `${tag}: attacks heal allies only`); const hits = r.dmg.filter((d) => !bondEffect(d)); pulses += r.dmg.length - hits.length; @@ -121,7 +154,7 @@ test('E2 audit: every pure healer (医师 / 群愈师 / 疗养师 / 链愈师 / assert.ok(pulses > 0, 'the member bonds are live (卡西米尔 pulses while blocking)'); }); -test('E2 audit: every equipment item on a blocking healer (录武官 S1, elite 调香师, 流明 S3) — still healing, no hit', REAL, () => { +test('E2 audit: every equipment item on a blocking healer (录武官 S1, elite 调香师 S2, 流明 S3, each skill cast while blocking) — still healing, no hit', REAL, () => { const ITEMS = JSON.parse(fs.readFileSync(new URL('../../data/items.json', import.meta.url), 'utf8')); const equips = Object.values(ITEMS).filter((i) => i && i.itemType === 'EQUIP'); assert.ok(equips.length >= 100, `items ${equips.length}`); @@ -129,6 +162,7 @@ test('E2 audit: every equipment item on a blocking healer (录武官 S1, elite for (const it of equips) { const r = blockRun({ chessId, skillIndex, items: [it.id] }); const tag = `${r.M.def.name} ${chessId} + ${it.name} ${it.id}`; + assert.ok(r.skillBlocking > 0, `${tag}: the skill is cast / runs while it blocks`); assert.ok(r.atks.every((a) => a.targets.every((t) => t.side === 'ally')), `${tag}: attacks heal allies only`); assert.equal(r.dmg.length, 0, `${tag}: no damage to the blocked enemy`); assert.ok(r.healsBlocking >= 2, `${tag}: heals while blocking (${r.healsBlocking})`); @@ -184,11 +218,14 @@ test('E2 audit: 咒愈师 strike the enemy they block and heal by their trait (D // ===================================================================================================================== // every boss round -test('E2 audit: healers on the lane tiles of every boss / hidden field block leaders, parts and their summons, and deal no damage', REAL, () => { +test('E2 audit: healers on the lane tiles of every boss / hidden field deal no damage; they block the roaming 卢西恩 / 不祥幻影 and a patrolling 碎铳之簧 whose shield is down, and keep healing', REAL, () => { const ds = getDefaultSource(); const W = waves(); const HEALERS = ['chess_char_5_23_a', 'chess_char_2_14_a', 'chess_char_6_14_a', 'chess_char_2_06_a', 'chess_char_6_20_a', 'chess_char_4_26_a', 'chess_char_4_21_a', 'chess_char_4_08_a', 'chess_char_2_05_a', 'chess_char_2_02_a']; - let templates = 0, blockedLeaders = 0; + const SPRING = /^enemy_90(18|19|20)_actrp/; + /** The leaders / parts a block-1 operator can block at all (the rest: 自缚 / 无法被阻挡, the 剑 / 锤 anchored, 余音 block ≥ 2). */ + const EXPECT = { act2autochess_h07_05: [LUCIEN], act2autochess_h07_05_s: [LUCIEN], act1autochess_h08_02: [SPRING], act1autochess_h08_02_s: [SPRING] }; + let templates = 0; for (const [key, tpl] of Object.entries(W)) { if (tpl.kind !== 'boss' && tpl.kind !== 'hidden') continue; templates++; @@ -206,13 +243,27 @@ test('E2 audit: healers on the lane tiles of every boss / hidden field block lea const players = (tpl.solo ? ['L'] : ['L', 'R']).map((side, i) => ({ playerId: `p${i + 1}`, seat: i, side, colOffset: 0, units: units.map((u, k) => ({ ...u, uid: k + 1 })), bonds: {}, playerEffects: [] })); const h = makeBattle({ kind: tpl.kind, stageId, waveTemplate: tpl, sharedBoss: pool(5e6), seed: 4, players, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack', 'blocked'], setup(b) { b.enemyOverrides = tpl.overrides; } }); - h.run(120); const healers = h.b.allyUnits.filter((u) => u.def?.dmgType === 'heal'); + const lead = (e) => e.isBoss || e.tag === 'boss' || e.tag === 'part' || LUCIEN.test(e.defId); + let healsOnLead = 0; + h.b.on('heal', (c) => { if (healers.includes(c.source) && c.amount > 0 && c.source.blocking.some(lead)) healsOnLead++; }); + for (let i = 0; i < 120 * 30; i++) { + h.step(); + // operators break the 碎铳之簧 shields (unblockable while shielded): a hit of the kind each shield yields to + for (const e of h.b.enemies) { + if (!e.alive || !SPRING.test(e.defId) || !e.s.flags.unblockable) continue; + if (/9018/.test(e.defId)) h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.02, type: 'arts' }); // 法术屏障 (0.5 % max HP) + else if (/9019/.test(e.defId)) h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 }); // 元素护盾: its burst + else h.b.dealDamage(null, e, { amount: 1, type: 'true' }); // 频次护盾: 5 hits + } + } const dmg = h.hooksOf('damaged').filter((c) => healers.includes(c.source) && c.target.side === 'enemy'); assert.equal(dmg.length, 0, `${key}: healer damage ${dmg.map((c) => `${c.source.def.name}→${c.target.def?.name}`).join(', ')}`); assert.ok(h.hooksOf('attack').filter((c) => healers.includes(c.attacker)).every((c) => c.targets.every((t) => t.side === 'ally')), `${key}: heals only`); - if (h.hooksOf('blocked').some((c) => healers.includes(c.blocker) && (c.enemy.isBoss || c.enemy.tag === 'boss' || /csphtm|csphts|vtswd|vtshld/.test(c.enemy.defId)))) blockedLeaders++; + const leads = h.hooksOf('blocked').filter((c) => healers.includes(c.blocker) && lead(c.enemy)).map((c) => c.enemy.defId); + for (const re of EXPECT[key] ?? []) assert.ok(leads.some((id) => re.test(id)), `${key}: a healer blocks ${re} (${[...new Set(leads)]})`); + if (EXPECT[key]) assert.ok(healsOnLead > 0, `${key}: healers heal while they block it (${healsOnLead})`); + else assert.equal(leads.length, 0, `${key}: its leaders / parts cannot be blocked (${[...new Set(leads)]})`); } - assert.ok(templates >= 20, `templates ${templates}`); - assert.ok(blockedLeaders >= 2, `a healer blocked a leader / part (${blockedLeaders})`); + assert.equal(templates, 20); });