From 6be059b34e79891506c7626978134ed2dd51bdd0 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 05:03:28 +0800 Subject: [PATCH] feedback1e WQ: healers that block keep healing (E2 not reproduced, locked by tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A community report said a medic blocking a roaming boss in a 炎 build stops healing and strikes the boss, its 强力击-type skill included. The real sim does not do that: every pure healer (医师 / 群愈师 / 疗养师 / 链愈师 / 行医, the map characters, normal and elite, every skill) blocking an enemy keeps healing and never targets it, under every bond as a member, with every equipment item and on every boss / hidden field (卢西恩 and its phantom, the 剑 / 锤). Official: PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外), 攻击目标为需要治疗的单位". The only damage a pure healer deals is a bond's own effect by its text (卡西米尔's pulse while blocking). No behaviour change: the rule is spelled out in SIM.md and ai.js acquireTargets, and test/sim/feedback1e-healers.test.js locks it (the reported 炎 boss round, every healer x bond, items, heal-mode skills of 古米 / 塞雷娅 / 波登可, 咒愈师 and 吟游者, every boss template). --- docs/SIM.md | 7 +- server/sim/ai.js | 3 + test/sim/feedback1e-healers.test.js | 218 ++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 test/sim/feedback1e-healers.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..987a3f39 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -189,7 +189,12 @@ 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). This replaces the +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 playtest #5 QA's melee-only reading (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员…无法攻击到这个敌人"); DESIGN §20 keeps the one-line flip (`Battle.blockedTargets` / `sortEnemyTargets` gated on a melee position again). diff --git a/server/sim/ai.js b/server/sim/ai.js index da6089ac..2b7ffe0a 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -102,6 +102,9 @@ export function enforceBlockCapacity(b, u) { /** Collect targets for an ally with profile `prof`. */ export function acquireTargets(b, u, prof) { + // a heal attack (医师 / 群愈师 / 疗养师 / 链愈师 / 行医, a skill attack turned into a heal) selects injured allies only, + // never the enemies its unit blocks — a blocking healer keeps healing: PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外), + // 攻击目标为需要治疗的单位" (the blocked-first rule below is for attackers of enemies; community feedback after 0.1.0, E2) if (prof.heal && prof.dmgType === 'heal') { let cands = b.injuredAlliesInKeys(u.rangeKeys, u, !!prof.heal.elementHealRatio); // a heal restricted to allies at or below an HP ratio (塞雷娅 S1 急救 "血量小于等于一半") diff --git a/test/sim/feedback1e-healers.test.js b/test/sim/feedback1e-healers.test.js new file mode 100644 index 00000000..19466222 --- /dev/null +++ b/test/sim/feedback1e-healers.test.js @@ -0,0 +1,218 @@ +// Community feedback after the 0.1.0 release, batch 5 (feedback1e), workstream WQ — report E2: +// "走炎盟约的时候,如果是那种会到处跑的BOSS,被医疗阻挡之后,医疗不奶人了,会输出BOSS而且如果是那种强力击类型的技能也会在BOSS +// 身上打出正确数值的伤害" — and the user: "也看下其他的奶,不同的盟约会不会也有相同问题". +// Official (PRTS 卫戍协议/帮助 §作战阶段 技能操作, the note of the basic strategy): "对于医疗干员(咒愈师分支除外),攻击目标为 +// 需要治疗的单位"; PRTS 仇恨 §仇恨过滤器: a medic's normal heal selects with HP_RATIO_NOT_FULL_ASC ("排除所有满生命值单位… +// 典型使用者:各类医疗干员的普通治疗"); PRTS 选择器: the blocked-first selector (BlockedOrAdvancedSelector) belongs to the +// units that attack what they block — a medic's selector picks allies. So a medic standing on a melee tile that blocks +// 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. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; +import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; +const PURE = new Set(['physician', 'ringhealer', 'healer', 'chainhealer', 'wandermedic']); +const pool = (hp) => ({ hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } }); +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'); + +// ===================================================================================================================== +// the reported case + +test('E2 (not reproduced): 录武官 blocking 卢西恩 on the real 炎 boss round keeps healing and never hits it — normal attack or 触类旁通', REAL, () => { + const tpl = waves().act2autochess_h07_05; + for (const sfx of ['_a', '_b']) { + for (const skillIndex of [0, 1]) { + const g = (id) => id.replace(/_a$/, sfx); + // nine distinct 炎 operators (炎 at tier 3: 炎佑 ×2), 录武官 on the lower lane's melee tile of the boss field + // (board (9,4) → boss (2,4)), where the patrolling 卢西恩 walks into her + const units = [ + { chessId: g('chess_char_5_23_a'), row: 9, col: 4, dir: 'RIGHT', skillIndex }, + { chessId: g('chess_char_6_03_a'), row: 12, col: 6 }, { chessId: g('chess_char_4_17_a'), row: 12, col: 7 }, + { chessId: g('chess_char_3_03_a'), row: 12, col: 5 }, { chessId: g('chess_char_6_15_a'), row: 9, col: 8 }, + { chessId: g('chess_char_1_03_a'), row: 10, col: 5 }, { chessId: g('chess_char_2_04_a'), row: 11, col: 5 }, + { chessId: g('chess_char_5_12_a'), row: 11, col: 7 }, { chessId: g('chess_char_5_03_a'), row: 10, col: 7 }, + ]; + const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m01', waveTemplate: tpl, sharedBoss: pool(3e6), seed: 9, units, + 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; + h.b.on('heal', (c) => { if (c.source === M && M.blocking.length && c.amount > 0) { healsBlocking++; if (c.target !== M) othersBlocking++; } }); + h.run(120); + 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'); + const dmg = h.hooksOf('damaged').filter((c) => c.source === M && c.target.side === 'enemy'); + 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`); + 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})`); + } + } +}); + +// ===================================================================================================================== +// every healer × every bond + +const PATIENT = 'fb1e_patient'; +const DEFS = { + chess: { [PATIENT]: chessRec({ id: PATIENT, stats: { atk: 0, maxHp: 1e7, blockCnt: 0, def: 0 }, rangeGrid: [[0, 0]], skill: null }) }, + 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. + */ +function blockRun(unit, { member = null, bonds = {}, secs = 20, rec = null } = {}) { + 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; + 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++; } + return { + M, blocking: blocking / 30, healsBlocking, + 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, () => { + 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}`); + const BONDS = JSON.parse(fs.readFileSync(new URL('../../data/bonds.json', import.meta.url), 'utf8')); + const bonds = Object.keys(BONDS); + assert.ok(bonds.length >= 23); + let runs = 0, pulses = 0; + for (const c of healers) { + for (let si = 0; si < (c.skills || []).length; si++) { + for (const member of [null, ...bonds]) { + const tiers = member ? (BONDS[member].thresholds ?? [1]).length : 0; + const r = blockRun({ chessId: c.chessId, skillIndex: si }, member ? { member, rec: c, bonds: { [member]: { count: 12, active: true, tier: Math.max(1, tiers), layers: 60 } } } : {}); + 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.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; + assert.equal(hits.length, 0, `${tag}: no damage to the blocked enemy (${hits.map((d) => d.dmg?.tags?.join('/')).join(' ')})`); + assert.ok(r.healsBlocking >= 2, `${tag}: heals while blocking (${r.healsBlocking})`); + } + } + } + assert.ok(runs >= 1000, `runs ${runs}`); + 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, () => { + 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}`); + for (const [chessId, skillIndex] of [['chess_char_5_23_a', 0], ['chess_char_2_14_b', 1], ['chess_char_6_14_a', 2]]) { + 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.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})`); + } + } +}); + +test('E2 audit: the map characters 预备干员-医疗 / Touch heal while they block and never hit the enemy', REAL, () => { + for (const tokenId of ['char_605_cmedic', 'char_613_acmedc']) { + const r = blockRun({ kind: 'token', tokenId }); + assert.ok(r.blocking > 8, `${tokenId} blocks`); + assert.equal(r.dmg.length, 0, tokenId); + assert.ok(r.atks.length > 0 && r.atks.every((a) => a.targets.every((t) => t.side === 'ally')), tokenId); + assert.ok(r.healsBlocking >= 2, `${tokenId}: heals while blocking (${r.healsBlocking})`); + } +}); + +test('E2 audit: skills that turn attacks into heals (古米 S1 / S2, 塞雷娅 S1, 波登可 S1) heal allies while blocking; 瑕光 S1 "造成…伤害,且恢复" still strikes', REAL, () => { + const cases = [ + ['chess_char_1_10', 0, 'heal'], ['chess_char_1_10', 1, 'heal'], ['chess_char_5_11', 0, 'heal'], ['chess_char_1_13', 0, 'heal'], + ['chess_char_3_12', 0, 'strike'], + ]; + for (const [base, si, kind] of cases) { + for (const sfx of ['_a', '_b']) { + const r = blockRun({ chessId: base + sfx, skillIndex: si }, { secs: 45 }); + const tag = `${r.M.def.name}${sfx} S${si + 1}`; + const skillAtks = r.atks.filter((a) => a.isSkill); + assert.ok(r.blocking > 30 && skillAtks.length > 0, `${tag}: blocks and casts (${skillAtks.length})`); + if (kind === 'heal') { + assert.ok(skillAtks.every((a) => a.targets.every((t) => t.side === 'ally')), `${tag}: the heal-mode attacks heal allies`); + assert.equal(r.dmg.filter((d) => d.dmg?.isSkill).length, 0, `${tag}: no skill damage to the blocked enemy`); + assert.ok(r.healsBlocking >= 2, `${tag}: heals while blocking (${r.healsBlocking})`); + } else { + assert.ok(skillAtks.every((a) => a.targets.some((t) => t.side === 'enemy')), `${tag}: strikes the enemy it blocks`); + assert.ok(r.healsBlocking >= 2, `${tag}: and heals (${r.healsBlocking})`); + } + } + } +}); + +test('E2 audit: 咒愈师 strike the enemy they block and heal by their trait (DESIGN §20.3); 吟游者 make no normal attack', REAL, () => { + for (const id of ['chess_char_1_06_a', 'chess_char_5_02_a', 'chess_char_6_08_a']) { + const r = blockRun({ chessId: id, skillIndex: 0 }); + assert.ok(r.dmg.some((d) => d.dmg?.isAttack), `${r.M.def.name}: hits the enemy it blocks`); + assert.ok(r.healsBlocking > 0, `${r.M.def.name}: heals an ally ("为攻击范围内一名友方干员治疗相当于50%伤害")`); + } + for (const id of ['chess_char_4_25_a', 'chess_char_6_04_a']) { + const r = blockRun({ chessId: id, skillIndex: 0 }); + assert.equal(r.dmg.filter((d) => d.dmg?.isAttack).length, 0, `${r.M.def.name}: 不攻击`); + } +}); + +// ===================================================================================================================== +// 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, () => { + 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; + for (const [key, tpl] of Object.entries(W)) { + if (tpl.kind !== 'boss' && tpl.kind !== 'hidden') continue; + templates++; + const stageId = key.startsWith('act2') ? 'act2autochess_m01' : 'act1autochess_m01'; + const rows = ds.getStage(stageId).rows; + const tiles = []; + for (let r = 1; r <= 5; r++) for (let c = 3; c <= 9; c++) if (rows[r][c] === 'r') tiles.push([r, c]); + const units = []; + HEALERS.forEach((id, i) => { + const t = tiles[(i * 3) % tiles.length]; + // board coordinates (row + 7 = the boss row): the Battle maps them onto the boss field + if (!units.some((u) => u.row === t[0] + 7 && u.col === t[1])) units.push({ chessId: id, row: t[0] + 7, col: t[1], dir: 'RIGHT' }); + }); + // two players on the co-op fields (the right one mirrored), one on the solo ones + 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 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++; + } + assert.ok(templates >= 20, `templates ${templates}`); + assert.ok(blockedLeaders >= 2, `a healer blocked a leader / part (${blockedLeaders})`); +});