Files
ark-stronghold-protocol/test/content/standin_acmedc.test.js
T
sganggs 8b7316d93e feedback5 WL: Touch follows PRTS — S3's extra heal is 30 % of the base with its own recipient's boost, 「低于」 half HP, 攫升 on every heal she outputs, 超脱 on a dying forced exit
The shared code of the 外勤医疗 strategy's Touch and the Touch 补位 stand-in (content/tokens.js touchGospel /
mapCharTalents), against PRTS Touch(卫戍协议) and its 备注 / 修正:
- S3 恳切福音's extra heal: 30 % of the main heal's base (before the ×1.35 / ×1.40), a heal of its own — its recipient
  below half HP gets the boost (and the elite's PHY-X), 备注 「额外治疗可触发技能后半段的治疗量提高效果」; it goes to the
  lowest HP ratio of the main target and its 4 orthogonal neighbours, a full-HP unit included (x-5,
  「可治疗生命值已满单位」). The touchExtra / touchMainHeal block is gone.
- The boost needs HP strictly below half (修正 「低于」, was 「不高于」).
- 攫升: every heal she outputs gives SP — a heal on herself and one that restores nothing too (备注 「无需实际产生治疗量」);
  still no device, no unit without a skill, and not her own HP-regeneration tick.
- 超脱: a forced exit that plays the death animation counts like a knock-out (备注 「如史尔特尔的天赋效果」). Battle.retreat
  takes `dying` and the death hook carries it: 史尔特尔's 余烬 and 骑士戒律 + 竞技旗 set it; planned exits (耀骑士临光 S2,
  伊内丝 S3), the 商人's automatic 撤退 and the 突袭 jump do not. A battle has no player 撤退 command.

Golden moved: roster-041 (the strategy's Touch heals differently: S3 extra heal / boost), standin-03, standin-04,
standin-08, standin-09, standin-10 (Touch standing in for 塞雷娅 / 塑心 / 纯烬艾雅法拉), coop2-NORMAL-14-standins (the
human seat plays 外勤医疗, so its Touch takes part). Docs: SIM.md (death hook `dying`, retreat option, the forced-exit
paragraph).
2026-10-05 20:47:06 +08:00

314 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.
// test/content/standin_acmedc.test.js — the Touch (char_613_acmedc) 补位 stand-in kit (kits/ops/standin-acmedc.js) on every
// chess she replaces: 5 塞雷娅, 6 塑心 / 纯烬艾雅法拉 (all S3, module PHY-X on the elite); S1 / S2 through the harness
// `standIn: { skillIndex }`. S3 and the talents are content/tokens.js touchGospel / mapCharTalents, the implementation the
// 外勤医疗 strategy's Touch runs (test/content/tokens_devices.test.js). Triggers: S1 DEFAULT, S2 / S3 ACTIVE_RANGE (the
// owner's rule of 2026-10-05, a heal skill: an injured ally inside the running range).
// Run: node --test test/content/standin_acmedc.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
import { touchGospel } from '../../server/sim/content/tokens.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CHAR = 'char_613_acmedc';
const S1 = 'skchr_acmedc_1', S2 = 'skchr_acmedc_2', S3 = 'skchr_acmedc_3';
const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
const DEFS = {
chess: {
test_tank_a: chessRec({ id: 'test_tank_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }),
test_tank2_a: chessRec({ id: 'test_tank2_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }),
// SP only from 攫升: an attack-SP skill and no enemy to attack
test_sp_a: chessRec({ id: 'test_sp_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 1000, initSp: 0 } }),
test_sp2_a: chessRec({ id: 'test_sp2_a', profession: 'TANK', stats: { maxHp: 1000, atk: 1, def: 0 }, skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 1000, initSp: 0 } }),
test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', stats: { atk: 300 }, skill: null, rangeGrid: [[0, 0]] }),
},
};
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status);
const skillOf = (id, skillId) => formOf(id).skills.find((s) => s.skillId === skillId);
const moduleOf = (id) => (CHESS[id].backup.uniEquipId && CHESS[id].status.equipLevel > 0 ? formOf(id).modules.find((m) => m.uniEquipId === CHESS[id].backup.uniEquipId) : null);
/**
* Touch (unit 1) on (10,3) facing right — her 3-3 covers columns 3–6, S2's 3-10 columns 3–7, S3's 5-2 columns 3–8 — and
* `allies` [[chessId, row, col, hpRatio]] (injured at once). h.heals: { t, tgt, uid, amount (final, before the HP cap), ratio }.
*/
function battle(touch, allies, opts = {}) {
const heals = [];
const h = makeBattle({
defs: DEFS, timeLimit: 300, autoFinish: false, seed: opts.seed ?? 5, flags: { dpPerSec: 0 },
units: [{ chessId: touch.id, row: 10, col: 3, standIn: touch.standIn ?? true, ...(touch.carry ? { carryState: touch.carry } : null) },
...allies.map(([chessId, row, col]) => ({ chessId, row, col }))],
setup(b) {
b.on('heal', (c) => { if (c.source?.uid === 1) heals.push({ t: b.time, tgt: c.target.id, uid: c.target.uid, amount: c.amount, ratio: c.target.hpRatio }); }, { priority: -999 });
},
});
h.heals = heals;
h.step();
allies.forEach(([, , , ratio], i) => { const a = h.unit(i + 2); if (ratio < 1) h.b.loseHp(a, a.hp * (1 - ratio)); });
return h;
}
// ---------------------------------------------------------------------------------------------------------------
test('Touch fields on all 6 records she replaces: her body (+ PHY-X attributes on the elites), S3 恳切福音 everywhere, her own kit, ACTIVE_RANGE on the 5-2', () => {
assert.equal(RECORDS.length, 6);
for (const id of RECORDS) {
const c = CHESS[id];
const form = formOf(id);
const mod = moduleOf(id);
const h = battle({ id }, []);
const u = h.unit(1);
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, c.backup.skillIndex], [CHAR, c.charId, S3, 2], id);
assert.ok(!u.kit.generic && u.kit.skillSource === 'skills', `${id}: her kit's own spec`);
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
assert.equal(!!mod, c.isGolden, `${id}: PHY-X on every elite`);
assert.deepEqual([u.base.atk, u.base.maxHp], [form.stats.atk + (mod?.attr.atk ?? 0), form.stats.maxHp + (mod?.attr.maxHp ?? 0)], `${id}: stats`);
assert.equal(u.skill.rule, 'ACTIVE_RANGE', `${id}: trigger`);
assert.equal(u.skill.triggerGrid.length, 18, `${id}: the 5-2 running range`);
assert.equal(u.profile.dmgType, 'heal');
// S3 is the strategy's touchGospel spec
const g = touchGospel(skillOf(id, S3).bb, skillOf(id, S3));
const k = u.kit.skill;
assert.deepEqual([k.kind, k.heal, k.mods, k.targeting], [g.skill.kind, g.skill.heal, g.skill.mods, g.skill.targeting], `${id}: touchGospel`);
assert.equal(h.b.errors.length, 0);
}
});
test('S3 恳切福音: cast with an injured ally inside the 5-2 only (no heal before); 40 s on the 5-2, 2 targets, ×1.35 / ×1.40 below half HP, +30 % extra heal', () => {
for (const [id, atk, boost] of [['chess_char_5_11_a', 0.2, 1.35], ['chess_char_6_20_a', 0.2, 1.35], ['chess_char_6_09_b', 0.3, 1.4]]) {
const phy = moduleOf(id) ? 1.15 : 1;
const sk = skillOf(id, S3);
assert.deepEqual([sk.bb.atk, sk.bb.heal_scale, sk.bb['attack@addition_heal_scale'], sk.duration], [atk, boost, 0.3, 40]);
// (10,8): outside her 3-3 and S2's 3-10, inside the 5-2; (10,5) full HP
const h = battle({ id }, [['test_tank_a', 10, 8, 0.3], ['test_tank2_a', 9, 5, 1]]);
const u = h.unit(1), near = h.unit(3);
let cast = null;
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
assert.equal(u.rangeKeys.length, 12);
assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`);
close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01);
assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: no heal before the cast — it was out of her range`);
assert.equal(u.rangeKeys.length, 18, `${id}: the 5-2`);
close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
h.run(0.5);
const first = h.heals.filter((x) => x.t === h.heals[0].t);
assert.ok(first[0].t >= cast, `${id}: the first heal comes with the cast`);
assert.deepEqual(first.map((x) => x.uid), [2, 2], `${id}: the main heal on the far ally, then the extra on it (the lowest HP ratio of it and its neighbours)`);
close(first[0].amount, u.s.atk * phy * boost, `${id}: main heal ×${boost}${phy > 1 ? ' ×1.15 PHY-X' : ''}`);
close(first[1].amount, u.s.atk * 0.3 * phy * boost, `${id}: extra heal = 30 % of the main heal's base, its still-low recipient's bonuses on it`);
// two injured allies: both healed per action
h.b.loseHp(near, near.hp * 0.2);
const n0 = h.heals.length;
h.runUntil(() => h.heals.length > n0, 5);
h.step();
const action = h.heals.slice(n0);
assert.deepEqual(new Set(action.map((x) => x.uid)), new Set([2, 3]), `${id}: 2 targets`);
close(action.find((x) => x.uid === 3).amount, u.s.atk * 1, `${id}: no bonus above half HP`);
h.runUntil(() => !u.skill.active, 45);
assert.equal(u.rangeKeys.length, 12, `${id}: back to the 3-3`);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
}
// none: no cast however long, and none for an injured ally beyond the 5-2
for (const allies of [[['test_tank_a', 10, 8, 1]], [['test_tank_a', 10, 9, 0.3]]]) {
const h = battle({ id: 'chess_char_5_11_a', carry: { sp: 999 } }, allies);
h.run(5);
assert.equal(h.unit(1).skill.activations, 0, JSON.stringify(allies));
}
});
test('S3 extra heal (PRTS 备注): 30 % of the main heal\'s base, its own recipient\'s ×heal_scale (and PHY-X); the lowest HP ratio of the main target and its 4 neighbours; exactly half HP is not 「低于」', () => {
for (const id of ['chess_char_5_11_a', 'chess_char_6_09_b']) {
const phy = moduleOf(id) ? 1.15 : 1;
const boost = skillOf(id, S3).bb.heal_scale;
// the main target on (10,8) (inside the 5-2, outside her 3-3); its neighbour (10,9) is beyond the 5-2 (never a main target)
const firstAction = (mainRatio, nearRatio) => {
const h = battle({ id, carry: { sp: 999 } }, [['test_tank_a', 10, 8, mainRatio], ['test_tank2_a', 10, 9, nearRatio]]);
const u = h.unit(1);
assert.ok(h.runUntil(() => h.heals.length >= 2, 5), `${id}: healed`);
assert.ok(u.skill.active, `${id}: S3 running`);
const t0 = h.heals[0].t;
const act = h.heals.filter((x) => x.t === t0);
assert.equal(act.length, 2, `${id}: one main heal and one extra heal`);
checkInvariants(h.b);
return { u, main: act[0], extra: act[1] };
};
// main above half: no bonus; the extra goes to the neighbour below half (lower ratio), with both bonuses
let r = firstAction(0.55, 0.45);
assert.deepEqual([r.main.uid, r.extra.uid], [2, 3], `${id}: the extra on the lower-ratio neighbour`);
close(r.main.amount, r.u.s.atk, `${id}: no bonus at 55 %`);
close(r.extra.amount, r.u.s.atk * 0.3 * boost * phy, `${id}: the extra takes its own recipient's ×${boost}`);
// main at exactly half: "低于" — no ×heal_scale, no PHY-X; the extra on it (its neighbour is full), no bonus either
r = firstAction(0.5, 1);
assert.deepEqual([r.main.uid, r.extra.uid], [2, 2], `${id}: the full neighbour has the higher ratio`);
close(r.main.amount, r.u.s.atk, `${id}: exactly 50 % is not below half`);
close(r.extra.amount, r.u.s.atk * 0.3, `${id}: extra = 30 % of the base`);
// main just below half: boosted; the extra lands on it after it crossed half — 30 % of the base, not of the boosted heal
r = firstAction(0.499, 1);
assert.deepEqual([r.main.uid, r.extra.uid], [2, 2]);
close(r.main.amount, r.u.s.atk * boost * phy, `${id}: ×${boost} below half`);
assert.ok(r.extra.ratio >= 0.5, `${id}: its recipient is above half now`);
close(r.extra.amount, r.u.s.atk * 0.3, `${id}: the extra's base excludes the main heal's bonuses`);
}
});
test('S1 慨赠: cast on an injured ally in her range; 20 s of ATK +40 % / +50 %, each heal a 40 % / 50 % chance of a second heal of the same ally', () => {
for (const [id, atk, p] of [['chess_char_5_11_a', 0.4, 0.4], ['chess_char_5_11_b', 0.5, 0.5]]) {
assert.deepEqual([skillOf(id, S1).bb.atk, skillOf(id, S1).bb['attack@prob'], skillOf(id, S1).duration], [atk, p, 20]);
let actions = 0, doubles = 0;
for (let seed = 1; seed <= 6; seed++) {
// the ally stays above half HP (no PHY-X bonus), inside her 3-3
const h = battle({ id, standIn: { skillIndex: 0 }, carry: { sp: 999 } }, [['test_tank_a', 10, 5, 0.7]], { seed });
const u = h.unit(1);
assert.equal(u.skill.id, S1);
assert.equal(u.skill.rule, 'DEFAULT');
let cast = null;
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
assert.ok(h.runUntil(() => cast != null, 5));
h.step();
close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
h.runUntil(() => !u.skill.active, 25);
close(h.b.time - cast, 20, `${id}: 20 s`, 0.01);
const during = h.heals.filter((x) => x.t >= cast && x.t < cast + 20);
const byT = new Map();
for (const x of during) byT.set(x.t, [...(byT.get(x.t) ?? []), x]);
for (const list of byT.values()) {
actions++;
assert.ok(list.length <= 2 && list.every((x) => x.uid === 2), `${id}: at most one extra heal, on the same ally`);
if (list.length === 2) { doubles++; close(list[1].amount, list[0].amount, `${id}: the same amount`); }
}
checkInvariants(h.b);
}
assert.ok(actions >= 30, `${id}: ${actions} heals`);
assert.ok(Math.abs(doubles / actions - p) < 0.2, `${id}: ${doubles} / ${actions} extra heals for p = ${p}`);
}
});
test('S2 宛如天启: cast with an injured ally inside the 3-10 only; 30 s on the 3-10, 2 targets, every 医疗 operator of hers ATK +21 % / +30 % (herself too)', () => {
for (const [id, v] of [['chess_char_6_09_a', 0.21], ['chess_char_6_09_b', 0.3]]) {
const sk = skillOf(id, S2);
assert.deepEqual([sk.bb['attack@atk'], sk.bb['attack@max_target'], sk.duration, sk.trigger.rule], [v, 2, 30, 'ACTIVE_RANGE']);
const h = battle({ id, standIn: { skillIndex: 1 } }, [['test_tank_a', 10, 7, 0.6], ['test_medic_a', 12, 2, 1], ['test_tank2_a', 9, 4, 1]]);
const u = h.unit(1), tank = h.unit(2), medic = h.unit(3), tank2 = h.unit(4);
assert.equal(u.skill.id, S2);
let cast = null;
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`);
close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01);
assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: (10,7) is outside her 3-3`);
h.step();
assert.equal(u.rangeKeys.length, 15, `${id}: the 3-10`);
close(u.s.atk, u.base.atk * (1 + v), `${id}: her ATK`);
close(medic.s.atk, medic.base.atk * (1 + v), `${id}: another medic's ATK`);
close(tank.s.atk, tank.base.atk, `${id}: not a non-medic`);
h.b.loseHp(tank2, tank2.hp * 0.3);
const n0 = h.heals.length;
h.runUntil(() => h.heals.length > n0, 5);
assert.deepEqual(new Set(h.heals.slice(n0).map((x) => x.uid)), new Set([2, 4]), `${id}: 2 targets`);
h.runUntil(() => !u.skill.active, 35);
h.step();
assert.equal(u.rangeKeys.length, 12);
close(medic.s.atk, medic.base.atk, `${id}: the aura ends with the skill`);
close(u.s.atk, u.base.atk, `${id}: hers too`);
checkInvariants(h.b);
}
// an injured ally on (10,8) is beyond the 3-10: no cast
const h = battle({ id: 'chess_char_6_09_a', standIn: { skillIndex: 1 }, carry: { sp: 999 } }, [['test_tank_a', 10, 8, 0.3]]);
h.run(5);
assert.equal(h.unit(1).skill.activations, 0);
});
test('攫升 / 超脱 (the strategy\'s mapCharTalents): a healed unit +3 SP; an operator knocked out — or forced out — in her range +5 SP (+8 with PHY-X Lv3)', () => {
for (const [id, sp] of [['chess_char_5_11_a', 5], ['chess_char_5_11_b', 5], ['chess_char_6_20_b', 8]]) {
const h = battle({ id }, [['test_sp_a', 10, 5, 0.6], ['test_tank_a', 9, 4, 1], ['test_tank2_a', 9, 10, 1], ['test_tank_a', 11, 4, 1], ['test_tank2_a', 11, 5, 1], ['test_tank_a', 9, 5, 1], ['test_tank2_a', 11, 6, 1]]);
const u = h.unit(1), ally = h.unit(2);
const gains = [];
h.b.on('spGain', (c) => { if (c.unit === ally) gains.push([c.reason, c.amount]); });
h.runUntil(() => h.heals.length > 0, 5);
h.step();
assert.deepEqual(gains, [['talent', 3]], `${id}: 攫升`);
assert.equal(u.def.talents[1].bb.sp, sp);
const before = u.skill.sp;
h.b.kill(h.unit(3)); // (9,4): in her range
close(u.skill.sp, before + sp, `${id}: 超脱`);
const mid = u.skill.sp;
h.b.kill(h.unit(4)); // (9,10): out of it
close(u.skill.sp, mid, `${id}: not out of range`);
// PRTS 备注: a forced exit with a death animation (史尔特尔's 余烬: a `dying` retreat) counts; not a skill's planned
// retreat (耀骑士临光 S2, 伊内丝 S3: a plain 'retreat'), the 商人's automatic 撤退 ('merchant') or the 突袭 jump ('raid')
h.b.retreat(h.unit(5), { reason: 'retreat', dying: true }); // (11,4)
close(u.skill.sp, mid + sp, `${id}: 超脱 on a dying forced exit`);
h.b.retreat(h.unit(6), { reason: 'retreat' }); // (11,5)
h.b.retreat(h.unit(7), { reason: 'merchant' }); // (9,5)
h.b.retreat(h.unit(8), { reason: 'raid' }); // (11,6)
close(u.skill.sp, mid + sp, `${id}: not on a planned, a 商人's or a 突袭 retreat`);
checkInvariants(h.b);
}
});
test('超脱 on 史尔特尔\'s 余烬 exit (PRTS 备注 "如史尔特尔的天赋效果"): her forced exit 8 s after the lethal blow gives Touch the SP', () => {
const id = 'chess_char_5_11_a', sp = 5;
const h = battle({ id }, [['chess_char_5_07_a', 9, 4, 1]]);
const u = h.unit(1), surtr = h.unit(2);
const exits = [];
h.b.on('death', (c) => { if (c.unit === surtr) exits.push([c.reason, c.dying]); });
h.b.dealDamage(null, surtr, { amount: 1e7, type: 'true' });
assert.ok(surtr.alive && surtr.findBuff('surtr:ember'), '余烬: kept alive');
const before = u.skill.sp;
const gains = [];
h.b.on('spGain', (c) => { if (c.unit === u && c.reason === 'talent') gains.push(c.amount); });
assert.ok(h.runUntil(() => !surtr.alive, 12), 'she withdraws');
assert.deepEqual(exits, [['retreat', true]], 'a dying forced exit');
assert.deepEqual(gains, [sp], '超脱');
assert.ok(u.skill.sp >= before + sp - 1e-6);
checkInvariants(h.b);
});
test('攫升 (PRTS 备注 "只需Touch输出治疗便能触发(无需实际产生治疗量)"): her heal on herself and a heal that restores nothing (S3\'s extra heal on a full unit) give SP too', () => {
for (const id of ['chess_char_5_11_a', 'chess_char_6_09_b']) {
// herself: injured, she heals herself (her 3-3 holds her own tile)
let h = battle({ id }, []);
let u = h.unit(1);
h.b.loseHp(u, u.hp * 0.4);
const own = [];
h.b.on('spGain', (c) => { if (c.unit === u && c.reason === 'talent') own.push(c.amount); });
assert.ok(h.runUntil(() => h.heals.length > 0, 5), `${id}: a self-heal`);
h.step();
assert.equal(h.heals[0].uid, 1);
assert.deepEqual(own, [3], `${id}: 攫升 on her own heal`);
checkInvariants(h.b);
// S3: the main heal fills a 1000-HP unit at 90 %, then the extra heal lands on it at full HP — still +3 SP
h = battle({ id, carry: { sp: 999 } }, [['test_sp2_a', 10, 8, 0.9]]);
u = h.unit(1);
const tgt = h.unit(2);
const got = [];
h.b.on('spGain', (c) => { if (c.unit === tgt && c.reason === 'talent') got.push(c.amount); });
assert.ok(h.runUntil(() => h.heals.length >= 2, 5), `${id}: S3 heals`);
h.step();
assert.ok(u.skill.active);
assert.deepEqual(h.heals.slice(0, 2).map((x) => [x.uid, x.ratio === 1]), [[2, false], [2, true]], `${id}: the extra heal on the now full unit`);
assert.deepEqual(got, [3, 3], `${id}: 攫升 for the main heal and for the extra heal that restored nothing`);
checkInvariants(h.b);
}
});
test('module PHY-X (elites): every heal of hers on an ally below 50 % HP ×1.15 — none at or above it, none on the normal chess', () => {
for (const id of ['chess_char_5_11_a', 'chess_char_5_11_b', 'chess_char_6_09_b']) {
const mod = moduleOf(id);
if (mod) assert.deepEqual([mod.traitOverride.bb.heal_scale, mod.traitOverride.bb.hp_ratio], [1.15, 0.5]);
for (const [ratio, mul] of [[0.3, mod ? 1.15 : 1], [0.5, 1], [0.8, 1]]) {
const h = battle({ id }, [['test_tank_a', 10, 5, ratio]]);
const u = h.unit(1);
h.runUntil(() => h.heals.length >= 1, 10);
assert.equal(u.skill.active, false, `${id}: a normal heal`);
close(h.heals[0].amount, u.s.atk * mul, `${id}: at ${ratio} HP`);
checkInvariants(h.b);
}
}
});