diff --git a/docs/SIM.md b/docs/SIM.md index b2c94271..35ae990c 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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 diff --git a/server/sim/battle/queries.js b/server/sim/battle/queries.js index cf0ba4ba..2c3dcd7a 100644 --- a/server/sim/battle/queries.js +++ b/server/sim/battle/queries.js @@ -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); diff --git a/server/sim/buffs.js b/server/sim/buffs.js index abc890d3..d65c7d2a 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -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, diff --git a/server/sim/damage.js b/server/sim/damage.js index 31944a58..73efad2d 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -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) { diff --git a/server/sim/professions.js b/server/sim/professions.js index 2337df4c..8a8f0fae 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -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) diff --git a/test/sim/feedback5-o16-engine.test.js b/test/sim/feedback5-o16-engine.test.js new file mode 100644 index 00000000..6f666d42 --- /dev/null +++ b/test/sim/feedback5-o16-engine.test.js @@ -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); +});