feedback5 O16: two additive engine rules for the 自选 kits of 机械师 and 凯尔希 — a 屏障 limited to a list of damage types, a healer that heals one ally through its 禁疗

- buffs.js makeBuff / damage.js absorbShields: `shieldType` may be a list of damage types — the shield absorbs those only
  (机械师's 屏障 absorb physical and arts damage, not true: buff_template_data mcnist_t_2 / mcnist_s_2_shield BlockDamage
  PHYSICAL_AND_MAGICAL); a single type and an untyped shield work as before.
- damage.js heal / Battle.injuredAlliesInKeys: a healer profile's `healThrough(healer, ally)` names allies its heals reach
  through their 禁疗 (the `noHeal` flag of a summon's 禁疗, the `healFree` flag; a profile `noHeal` — 无法被友方治疗 — still
  stops it), and its heal selection takes them (凯尔希 on her Mon3tr: PRTS Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…
  无视)"; her charpack attack `_ignoreHealFree`); every other healer still skips them.
- docs/SIM.md §3 / §4 / §8, professions.js profile field list.
No content uses either yet: golden unchanged.
Test: test/sim/feedback5-o16-engine.test.js (2).
This commit is contained in:
sganggs committed 2026-10-06 01:33:46 +08:00
1 parent fef4ea3d4b
commit 5888f153ac
6 files changed
+101 -12

No files matched your search

+6 -3
View File
@@ -516,7 +516,8 @@ mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield,
- `onTick({battle, unit, buff, dt})` every tick, or every `interval` s. `persist: true` survives death/redeploy.
- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated;
`shieldType` ('phys' | 'arts' | 'true' | 'elemental') = a 屏障 that absorbs that damage type only (夜莺 S2 法术护盾 "能吸收…
法术伤害"); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first.
法术伤害"), or a list of them = those types only (机械师's 屏障 `['phys', 'arts']`: BlockDamage PHYSICAL_AND_MAGICAL — true
damage passes); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first.
- `visible: true` emits `['status', id, key, 1/0]` client events. `battle.removeBuff(unit, key|buff)`.
**Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend
@@ -688,7 +689,9 @@ unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `d
`battle.heal(source, target, amount, { overheal=false, self, silent, regen, ignoreHealFree })`: no-op on `noHeal` targets
(unless self — 禁疗 / 孤立 summons carry the flag, §3) and on `healFree` ones, self included (史尔特尔's 余烬), unless `regen`
(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗");
(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗"); a healer whose profile names the target in
`healThrough(healer, target)` heals it through the `noHeal` flag and `healFree` (not a profile's `noHeal`), and its heal
selection (`injuredAlliesInKeys`) takes it — 凯尔希 on her Mon3tr (PRTS "持有禁疗(可被凯尔希…无视)");
× source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; a `regen` tick
— the unit's own 生命回复速度 (`s.hpRegen`, applied in the buffs phase) — is no 治疗 (PRTS 调香师 / 瑕光 / 铃兰 / 锡人 备注
"不受治疗加成和禁疗影响"): no multiplier, and the hook sees it (`opts.regen`) but cannot change its amount. Effects PRTS
@@ -1151,7 +1154,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait
"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never
selects — its attacks, the enemies it blocks and its skill-trigger targets: targeting.js canTargetEnemy; 嵯峨 "不攻击重伤
单位"), `boomerang` (the projectile stays
单位"), `healThrough(healer, ally)` (a healer that selects and heals that ally through its 禁疗 — §4; 凯尔希's Mon3tr), `boomerang` (the projectile stays
`'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its
first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter
row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the
+7 -2
View File
@@ -54,15 +54,20 @@ export class BattleQueries {
return false;
}
/** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */
/**
* Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. A `noHeal` (禁疗) ally
* is left out unless the healer's profile heals it through its 禁疗 (`healThrough(healer, ally)`: 凯尔希's Mon3tr —
* damage.js heal).
*/
injuredAlliesInKeys(keys, healer, includeElement = false) {
const out = [];
if (!keys) return out;
const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys));
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
for (const a of this.allyUnits) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue;
if (!set.has(a.tileR * COLS + a.tileC)) continue;
if (a !== healer && (a.s.flags.noHeal || (a.profile && a.profile.noHeal))) continue;
if (a !== healer && ((a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
const injured = a.hp < a.s.maxHp - 1e-6;
const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0;
if (injured || elem) out.push(a);
+3 -2
View File
@@ -155,8 +155,9 @@ export function makeBuff(b) {
tags: b.tags ?? [],
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
shieldHits: b.shieldHits ?? 0,
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null =
// every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害"
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental', or a list
// of them (机械师's 屏障: ['phys', 'arts']); null = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") —
// 夜莺 S2 法术护盾 "能吸收…法术伤害"
shieldType: b.shieldType ?? null,
persist: !!b.persist,
status: b.status ?? null,
+11 -5
View File
@@ -184,14 +184,16 @@ export function leaderHitCancelled(battle, target, amount) {
/**
* Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障
* "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障").
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害") — or, a list of types, only those (机械师's 屏障:
* BlockDamage PHYSICAL_AND_MAGICAL, buff_template_data mcnist_t_2 / mcnist_s_2_shield: ['phys', 'arts']); one without
* absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order:
* "优先消耗先生成的屏障").
*/
export function absorbShields(battle, target, amount, type = null) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
const absorbs = (b) => !b.shieldType || b.shieldType === type;
const absorbs = (b) => !b.shieldType || (Array.isArray(b.shieldType) ? b.shieldType.includes(type) : b.shieldType === type);
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0 && absorbs(b)) {
@@ -526,13 +528,17 @@ export function reduceElement(target, amount, el = null) {
* with an hpRegen buff, PRTS 备注 "不受治疗加成和禁疗影响") is no 治疗: no 治疗加成 scales it — neither the healing
* multipliers nor a `heal` handler (handlers still see it, with `opts.regen`; a change they make to its amount is
* ignored).
* A healer whose profile names the target in `healThrough(healer, target)` heals it through its 禁疗 — the `noHeal` flag
* a summon's 禁疗 sets and the flag `healFree` (not a profile's `noHeal`, 无法被友方治疗): 凯尔希 on her Mon3tr (PRTS
* Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)"; her heal selection takes it too, Battle.injuredAlliesInKeys).
*/
export function heal(battle, source, target, amount, opts = {}) {
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
const self = source === target || !!opts.self;
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
const through = !!(source && source.profile && typeof source.profile.healThrough === 'function' && source.profile.healThrough(source, target));
if (!self && ((target.s.flags.noHeal && !through) || (target.profile && target.profile.noHeal))) return 0;
const regen = !!opts.regen;
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree) return 0;
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree && !through) return 0;
let amt = regen ? amount : amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
if (battle._hooks.heal) {
+2
View File
@@ -22,6 +22,8 @@
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
// healThrough(healer, ally) → bool (a healer that selects and heals that ally through its 禁疗 — Battle
// injuredAlliesInKeys, damage.js heal; 凯尔希 on her Mon3tr)
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
+72
View File
@@ -0,0 +1,72 @@
// test/sim/feedback5-o16-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 机械师 and 凯尔希 need:
// - a 屏障 limited to several damage types (buff `shieldType` as a list, damage.js absorbShields): 机械师's 屏障 absorb
// physical and arts damage only (buff_template_data mcnist_t_2 / mcnist_s_2_shield: BlockDamage PHYSICAL_AND_MAGICAL);
// - a healer that heals an ally through its 禁疗 (profile `healThrough(healer, ally)`, damage.js heal and
// Battle.injuredAlliesInKeys): 凯尔希 on her Mon3tr (PRTS Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)") — her heal
// selection takes it and her heal reaches it; any other healer still skips it, a profile's `noHeal` (无法被友方治疗)
// still stops her.
// Run: node --test test/sim/feedback5-o16-engine.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
const CHESS = {
test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 300, def: 0, res: 0 } }),
test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProf: 'physician', stats: { maxHp: 2000, atk: 100, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [-1, 0], [-1, 1]] }),
};
test('a 屏障 of several types (shieldType [\'phys\', \'arts\']) absorbs those, not true damage; older shields first as ever', () => {
const h = makeBattle({ defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }] });
h.step();
const u = h.unit(1);
h.b.addBuff(u, { key: 'test:pa', shield: 100, shieldType: ['phys', 'arts'], duration: 30 });
let hp = u.hp;
for (const type of ['true', 'elemental']) {
h.b.dealDamage(null, u, { amount: 10, type, canDodge: false });
hp -= 10;
assert.equal(u.hp, hp, `${type} passes it`);
}
assert.equal(u.findBuff('test:pa').shield, 100);
h.b.dealDamage(null, u, { amount: 30, type: 'phys', canDodge: false });
h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false });
assert.deepEqual([u.hp, u.findBuff('test:pa').shield], [hp, 40], 'physical and arts absorbed');
h.b.dealDamage(null, u, { amount: 50, type: 'arts', canDodge: false });
assert.deepEqual([u.hp, u.findBuff('test:pa')], [hp - 10, null], 'spent: the rest reaches HP');
// a single type still works as before
h.b.addBuff(u, { key: 'test:a', shield: 20, shieldType: 'arts', duration: 30 });
hp = u.hp;
h.b.dealDamage(null, u, { amount: 10, type: 'phys', canDodge: false });
assert.equal(u.hp, hp - 10);
checkInvariants(h.b);
});
test('profile healThrough: the healer selects and heals the ally it names through its 禁疗 (noHeal / healFree); others do not, and a profile noHeal still stops it', () => {
const marked = new Set();
const kits = { test_medic_a: () => ({ trait: { healThrough: (healer, a) => marked.has(a) } }) };
const h = makeBattle({
defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits, hooks: ['heal'], captureNoisy: true,
units: [{ uid: 1, chessId: 'test_medic_a', row: 10, col: 3 }, { uid: 2, chessId: 'test_guard_a', row: 10, col: 4 }, { uid: 3, chessId: 'test_guard_a', row: 11, col: 4 }],
});
h.step();
const medic = h.unit(1), a = h.unit(2), b = h.unit(3);
h.b.addBuff(a, { key: 'test:healFree', flags: { noHeal: true }, persist: true });
h.b.addBuff(b, { key: 'test:healFree', flags: { noHeal: true }, persist: true });
a.hp = 1000; b.hp = 1000;
assert.deepEqual(h.b.injuredAlliesInKeys(medic.rangeKeys, medic), [], '禁疗: no heal target');
assert.equal(h.b.heal(medic, a, 100), 0, 'no heal');
marked.add(a);
assert.deepEqual(h.b.injuredAlliesInKeys(medic.rangeKeys, medic), [a], 'the named ally is a heal target');
assert.equal(h.b.heal(medic, a, 100), 100, 'and is healed');
assert.equal(h.b.heal(medic, b, 100), 0, 'the other 禁疗 ally is not');
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === medic && c.target === a), 5), 'its attack heals it');
assert.ok(!h.hooksOf('heal').some((c) => c.source === medic && c.target === b), 'never the other');
// another unit's heal is still stopped; healFree too passes for the named one; a profile noHeal does not
assert.equal(h.b.heal(b, a, 100), 0, 'another healer: 禁疗 holds');
h.b.addBuff(a, { key: 'test:hf2', flags: { healFree: true }, persist: true });
assert.equal(h.b.heal(medic, a, 50), 50, 'healFree passes for it');
a.profile.noHeal = true;
assert.equal(h.b.heal(medic, a, 50), 0, 'a profile noHeal (无法被友方治疗) stops it');
checkInvariants(h.b);
});