Files
ark-stronghold-protocol/test/sim/feedback1e-healers.test.js
T
sganggs 6be059b34e feedback1e WQ: healers that block keep healing (E2 not reproduced, locked by tests)
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).
2026-10-03 05:03:28 +08:00

219 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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})`);
});