Files
ark-stronghold-protocol/test/sim/feedback1e-healers.test.js
T
sganggs bfd8b1647c feedback1e WQ: healer tests cast every skill while blocking; rule in headers
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.
2026-10-03 05:35:18 +08:00

270 lines
19 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 — 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';
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');
/** 卢西恩 and its 不祥幻影 — the boss that roams the field (patrol) and can be blocked. */
const LUCIEN = /^enemy_201[67]_csph/;
// =====================================================================================================================
// 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);
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.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');
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(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})`);
}
}
});
// =====================================================================================================================
// 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 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, 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));
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++; });
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, 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 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}`);
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.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;
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 调香师 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}`);
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.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})`);
}
}
});
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 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'];
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++;
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; } });
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`);
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.equal(templates, 20);
});