From 763e224b8d72c2362e70a8f1283a74e644195712 Mon Sep 17 00:00:00 2001 From: shanzhaikabi <31912061+shanzhaikabi@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:20:59 +0800 Subject: [PATCH] =?UTF-8?q?A=20=E5=92=92=E6=84=88=E5=B8=88's=20trait=20hea?= =?UTF-8?q?ls=20on=20every=20damage=20she=20deals;=20=E6=94=B6=E5=89=B2?= =?UTF-8?q?=E8=80=85=20/=20=E6=AD=A6=E8=80=85=20heal=20on=20their=20skill?= =?UTF-8?q?=20hits=20too=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official trait buffs fire on output damage, not only on a normal attack (buff_template_data vendla_tr / reed2_tr / titi_tr ON_AFTER_OUTPUT_DAMAGE; etlchi_trait etc. ON_OUTPUT_DAMAGE, attackType BUFF filtered for 隐德来希). Skill damage now heals: 缇缇's 凝固的时光 ticks, 焰影苇草 S2 fireballs and 刺玫 S2's counter heal the one ally the skill names (DamageInfo.traitAlly); 隐德来希's S2 blood sickles heal her, capped at her block count per instant. PRTS 分支特性信息 §咒愈师: the heal depends only on the damage value. Contributed by @shanzhaikabi. --- docs/SIM.md | 4 +- server/sim/content/kits/tier1.js | 20 +++++++--- server/sim/content/kits/tier6.js | 7 ++-- server/sim/damage.js | 4 ++ server/sim/professions.js | 63 ++++++++++++++++++++++++++++---- test/content/kits_alt_t5.test.js | 56 ++++++++++++++++++++++++++++ test/sim/professions.test.js | 10 +++++ 7 files changed, 146 insertions(+), 18 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 87998083..69863bcb 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1126,13 +1126,13 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles | | healer (流明) | heal ×0.8 (bb heal_scale) beyond 2 tiles | | wandermedic | heal + reduce element gauges by 50 % ATK (bb ep_heal_ratio); also targets uninjured allies with gauge | -| incantationmedic | arts attack; heals the lowest ally in range for 50 % (bb scale) of damage dealt | +| incantationmedic | arts attack; EVERY damage the unit deals heals the lowest ally in range for 50 % (bb scale) of it — the official trait buff (`vendla_tr` / `reed2_tr` / `titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so skill and DoT damage heals too (缇缇's 凝固的时光 ticks, 焰影苇草's S2 fireballs while she is disarmed); a skill that triggers it for one named ally says so ("仅对该角色触发…特性") and the damage instance carries that ally (`DamageInfo.traitAlly`) | | slower | sluggish 0.8 s on hit (bb sluggish) | | bard | no attack; every second heals allies in range 10 % ATK (bb atk_to_hp_recovery_ratio) | | craftsman | melee phys (support devices via kit) | | shotprotector | ranged phys, can hit FLY, blocks 3 | | fortress | melee single target while blocking, ranged 1.0 splash otherwise, ground only (never hits FLY) | -| unyield / musha / reaper | cannot be healed by others; musha heals itself 50 (bb value) per hit; reaper hits every enemy in range and heals 50 × min(hits, block) | +| unyield / musha / reaper | cannot be healed by others; the heal fires on every enemy the unit damages itself — a normal attack (musha 50 / bb value per hit; reaper 50 × min(hits, block)) and any damage it outputs that no buff produced (the official trait's ON_OUTPUT_DAMAGE, which is why 隐德来希's S2 血镰 cuts heal her while she is disarmed), 50 (bb value) per enemy, the reaper's capped at the block count per instant | | centurion / crusher / pusher | hit every blocked enemy at once | | hammer | 50 % splash (bb atk_scale_2) to others within 1 tile | | instructor | ×1.2 (bb atk_scale) vs enemies it doesn't block | diff --git a/server/sim/content/kits/tier1.js b/server/sim/content/kits/tier1.js index acfb21ef..a24da09e 100644 --- a/server/sim/content/kits/tier1.js +++ b/server/sim/content/kits/tier1.js @@ -476,10 +476,17 @@ export default { return { skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(skillBbOf(chess, 'skcom_magic_rage[3]').attack_speed) } } }, trait: { - afterHit(battle, u, target, info) { - if (!(info.dealt > 0)) return; - const ally = protege(u) ?? battle.lowestHpAllyInRange(u); - if (ally) battle.heal(u, ally, info.dealt * (u.profile.healRatio ?? 0.5), { tags: ['incantation'] }); + // 咒愈师 trait: EVERY damage she deals heals an ally for 50 % of it (professions.js `installIncantation`, + // buff_template_data `vendla_tr` = ON_AFTER_OUTPUT_DAMAGE) — while 荆藤庇荫 runs her S2 says "仅对该角色触发刺玫 + // 特性", so her protégé is the target then; otherwise it is the lowest-HP ally in range. + install(battle, u) { + battle.on('damaged', (c) => { + const t = c.target; + if (c.source !== u || !u.alive || !t || t.side !== 'enemy' || !(c.amount > 0)) return; + if (c.type === 'element' || c.type === 'elemental') return; + const ally = (c.dmg && c.dmg.traitAlly) || protege(u) || battle.lowestHpAllyInRange(u); + if (ally) battle.heal(u, ally, c.amount * (u.profile.healRatio ?? 0.5), { tags: ['incantation'] }); + }, { owner: u }); }, }, skill: { @@ -501,9 +508,10 @@ export default { battle.on('damaged', (ctx) => { const p = protege(unit); if (!p || ctx.target !== p || !byEnemyAttack(ctx) || !ctx.source.alive || !unit.canAct) return; - const dealt = battle.dealDamage(unit, ctx.source, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['counter'] }); + // "并仅对该角色触发刺玫特性": this counter damage is healed by the trait (install above) for the protégé — the + // damage instance names her, so no separate heal here (it would double) + battle.dealDamage(unit, ctx.source, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, canDodge: false, tags: ['counter'], traitAlly: p }); battle.fx('counter', { x: ctx.source.x, y: ctx.source.y, id: unit.id }); - if (dealt > 0) battle.heal(unit, p, dealt * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] }); }, { owner: unit }); if (hs !== 1) { let cacheT = -1, top = null; diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 43502699..aff23f24 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -1470,7 +1470,7 @@ function reed2(bb, chess, def) { } }, onTick({ battle, unit, dt }) { - const cd = Math.max(0.1, num(bb.cooldown, 1.5)), ratio = num(unit.profile?.healRatio, num(tb.scale, 0.5)); + const cd = Math.max(0.1, num(bb.cooldown, 1.5)); for (const F of unit.mem.reedFire || []) { if (!live(F.a)) continue; F.acc += dt; @@ -1480,9 +1480,10 @@ function reed2(bb, chess, def) { if (!c.length) c = enemiesIn(battle, unit); if (!c.length) continue; sortEnemyTargets(battle, F.a, c, null); - const dealt = battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'] }); + // "每1.5秒对一名敌人造成…法术伤害并仅对该干员触发焰影苇草特性": the trait heal (professions.js + // installIncantation) runs for this damage and names the carrier instead of the lowest-HP ally in range + battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'], traitAlly: F.a }); battle.fx('strike', { x: c[0].x, y: c[0].y, id: c[0].id, src: F.a.id }); - if (dealt > 0 && ratio > 0) battle.heal(unit, F.a, dealt * ratio, { tags: ['incantation'] }); } }, onEnd({ battle, unit }) { diff --git a/server/sim/damage.js b/server/sim/damage.js index 12f647d5..9021d96f 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -92,6 +92,10 @@ export function makeDamageInfo(d = {}) { ignoreSelect: !!d.ignoreSelect, sourceless: !!d.sourceless, attackId: d.attackId ?? 0, + // 咒愈师 skills whose damage triggers their trait for ONE named ally ("并仅对该角色/干员触发…特性"): the instance + // carries that ally, and the trait hook (professions.js installIncantation) heals it instead of the lowest-HP ally + // in range. null = the ordinary target of the trait. + traitAlly: d.traitAlly ?? null, }; } diff --git a/server/sim/professions.js b/server/sim/professions.js index 28dd687d..c1a3e6a7 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -28,6 +28,7 @@ // (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame. import { toLocal, frontOf } from './dir.js'; +import { isHpLoss } from './damage.js'; import { absoluteRangeKeys } from './targeting.js'; import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js'; import { COLS, CHAIN_RADIUS } from './constants.js'; @@ -50,13 +51,66 @@ export const PROFESSION_DEFAULTS = Object.freeze({ // -------------------------------------------------------------------------------------------------------------- // install helpers (per-unit hooks). All use engine helpers only; tunables come from unit.profile. +/** + * 武者 / 收割者 self-heal ("每次攻击到敌人回复自身50生命"; 收割者 adds "最大生效数等于阻挡数"). Normal attacks arrive as one + * 'attack' event whose `targets` are every enemy hit. + * + * The official trait is a buff ON the operator that fires on ON_OUTPUT_DAMAGE — buff_template_data `etlchi_trait`, + * `excu2_trait`, `utage_trait`, `helage_trait`, `zuole_trait` all react to any damage the unit outputs, and 隐德来希's + * filters the attackType BUFF out (damage produced by a buff/talent — e.g. her own 萃血 DoT). So skill damage heals too: + * 隐德来希's S2 blood sickles restore her life although the skill stops her attacks (`attack: { noAttack: true }`) — the + * sickle's AOEDamage nodes are attackType NORMAL and PRTS 备注 says "伤害来源始终视为隐德来希". Every such hit arrives as + * one 'damaged' event per enemy, so the reaper cap ("最大生效数") is applied per instant here: the official `[heal_fake]` + * window is 0.05 s and its stack count is the block number (`SetStackCountViaBlockNum`). [ASSUMED] the sim uses the same + * `battle.time` as its window, so simultaneous hits (both 血镰, an AoE) share the block-count cap; a normal attack has its + * own event and its own cap, as before. + */ const installSelfHealOnHit = (capByBlock) => (battle, unit) => { + const heal = (n) => { if (n > 0 && unit.alive) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true }); }; battle.on('attack', (ctx) => { if (ctx.attacker !== unit || !unit.alive) return; let n = ctx.targets.length; if (capByBlock) n = Math.min(n, Math.max(1, unit.s.blockCnt)); - if (n > 0) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true }); + heal(n); }, { owner: unit, priority: -10 }); + battle.on('damaged', (c) => { + if (c.source !== unit || !unit.alive || !c.target || c.target.side !== 'enemy') return; + const dmg = c.dmg; + if (!dmg || dmg.isAttack || isHpLoss(dmg)) return; // normal attacks: the 'attack' hook; a 流失 is not damage dealt + const tags = dmg.tags || []; + if (tags.includes('talent') || tags.includes('dot') || tags.includes('periodic')) return; // attackType BUFF + const mem = unit.mem; + if (mem.selfHealAt !== battle.time) { mem.selfHealAt = battle.time; mem.selfHealN = 0; } + if (capByBlock && mem.selfHealN >= Math.max(1, unit.s.blockCnt)) return; + mem.selfHealN++; + heal(1); + }, { owner: unit, priority: -10 }); +}; + +/** + * 咒愈师 (incantationmedic) trait "攻击造成法术伤害,攻击敌人时为攻击范围内一名友方干员治疗相当于50%伤害的生命值". + * + * The official trait is a buff ON the operator that fires on ON_AFTER_OUTPUT_DAMAGE — buff_template_data `vendla_tr`, + * `reed2_tr` and `titi_tr` all are (`IsDamage` → `AssignDamageValueToBlackboard` → heal through an ability selector), + * i.e. the heal follows EVERY damage the operator deals, not only a normal attack. The 咒愈师 skills that damage without + * an attack say so themselves: 焰影苇草 S2 "每1.5秒对一名敌人造成…法术伤害并仅对该干员触发焰影苇草特性", 刺玫 S2 + * "…造成攻击力20%的法术伤害并仅对该角色触发刺玫特性" — the official text only makes sense if damage (not an attack) + * triggers the trait. + * + * The sim used to heal from the attack path only (`profile.afterHit`, ai.js), so 缇缇's per-second 凝固的时光 ticks and + * every other non-attack damage healed nothing. A damage instance may name the one ally it triggers for + * (`DamageInfo.traitAlly`, the skills' "仅对该角色/干员触发特性"): the heal then goes to that operator instead of the + * lowest-HP ally in range. + */ +const installIncantation = (battle, unit) => { + battle.on('damaged', (c) => { + const t = c.target; + if (c.source !== unit || !unit.alive || !t || t.side !== 'enemy' || !(c.amount > 0)) return; + // a gauge fill (元素损伤) removes no HP and is not "伤害" for the heal; a 流失 is not damage dealt either + if (c.type === 'element' || c.type === 'elemental') return; + const ally = (c.dmg && c.dmg.traitAlly) || battle.lowestHpAllyInRange(unit); + if (ally) battle.heal(unit, ally, c.amount * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] }); + }, { owner: unit }); }; const installHpDrain = (battle, unit) => { @@ -415,12 +469,7 @@ export const SUB = Object.freeze({ chainhealer: P({ heal: { mode: 'chain', count: 3, falloff: 0.25 } }), healer: P({ heal: { mode: 'single', farMul: 0.8, nearDist: 2 } }), wandermedic: P({ heal: { mode: 'single', elementHealRatio: 0.5 } }), - incantationmedic: P({ dmgType: 'arts', projectile: 'bolt', heal: null, - afterHit: (battle, unit, target, info) => { - if (!(info.dealt > 0)) return; - const ally = battle.lowestHpAllyInRange(unit); - if (ally) battle.heal(unit, ally, info.dealt * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] }); - } }), + incantationmedic: P({ dmgType: 'arts', projectile: 'bolt', heal: null, install: installIncantation }), // --- SUPPORT slower: P({ onHitStatus: { key: 'sluggish', duration: 0.8 } }), underminer: P({}), diff --git a/test/content/kits_alt_t5.test.js b/test/content/kits_alt_t5.test.js index 86e0332d..58221883 100644 --- a/test/content/kits_alt_t5.test.js +++ b/test/content/kits_alt_t5.test.js @@ -232,6 +232,17 @@ test('缇缇 S2 封护: no attacks; she and the lowest-HP op in range sleep (inv approx(dream.at(-1).amount, u.s.atk * t0.damage_atk_scale * bb.talent_scale, 'T1 ×talent_scale'); assert.ok(h.runUntil(() => !u.skill.active, 30)); assert.equal(during(h, u, attacks(h, u)).length, 0, 'no attacks during the skill'); + // …and every 凝固的时光 tick healed a 咒愈师 ally: the official trait buff (`titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so + // the heal follows ANY damage she deals. The sim ran the trait from the attack path only and she does not attack at + // all while this skill runs, so those ticks healed nothing (the same hook gap that left 隐德来希 S2 without heals). + const dreams = during(h, u, dealt(h, u, (c) => (c.dmg?.tags || []).includes('titiDream'))); + const incHeals = during(h, u, heals(h, u, (c) => (c.opts?.tags || []).includes('incantation'))); + assert.ok(dreams.length > 0, `${dreams.length} 凝固的时光 ticks`); + assert.equal(incHeals.length, dreams.length, 'one trait heal per tick'); + for (const c of incHeals) { + approx(c.amount, dreams[0].amount * u.profile.healRatio, 'heal = 50 % of the tick'); + assert.ok([hurt, fine, u].includes(c.target), 'on an ally in her range'); + } assert.ok(!u.findBuff('titi:ward') && !hurt.findBuff('titi:ward'), 'both wake at the end'); done(h); } @@ -417,6 +428,51 @@ test('隐德来希 S2 绯红壁合: no attacks; blood sickles on her and on the } }); +test('隐德来希 S2 绯红壁合: every 血镰 cut heals the 收割者 trait (每攻击到一个敌人回复自身50生命)', () => { + for (const id of pair('06')) { + const h = run({ + defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, + units: [entry(id, 'skchr_etlchi_2', { row: 10, col: 4 })], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = sel(h, id, 'skchr_etlchi_2'); + h.step(); + cast(h, u); + u.hp = u.s.maxHp * 0.4; // wounded (and above 重盈's 25 %), so every heal shows + const hp0 = u.hp, t0 = h.b.time; + h.run(3); + const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9); + const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9); + assert.ok(cuts.length >= 5, `${cuts.length} cuts`); + assert.equal(healed.length, cuts.length, 'one trait heal per cut (the trait fires on every damage she outputs)'); + for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal = the trait 生命值'); + approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored'); + done(h); + } +}); + +test('隐德来希 S2 绯红壁合: the 收割者 heal is capped by the block count per cut (最大生效数等于阻挡数)', () => { + const h = run({ + defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, + units: [entry('chess_char_5_06_a', 'skchr_etlchi_2', { row: 10, col: 4 })], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [11, 4] }], + }); + const u = sel(h, 'chess_char_5_06_a', 'skchr_etlchi_2'); + h.step(); + cast(h, u); + u.hp = u.s.maxHp * 0.4; + const hp0 = u.hp, t0 = h.b.time; + h.run(3); + const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9); + const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9); + const ticks = cuts.length / 3; // three enemies stand in the sickle's ring + assert.ok(ticks >= 5 && Number.isInteger(ticks), `${cuts.length} cuts over ${ticks} ticks`); + assert.equal(healed.length, ticks * Math.max(1, u.s.blockCnt), `${healed.length} heals (阻挡数 ${u.s.blockCnt})`); + for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal'); + approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored'); + done(h); +}); + test('隐德来希 module REA-Y (玫瑰色故事集): ASPD +12 with ≥ 2 enemies in range', () => { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, diff --git a/test/sim/professions.test.js b/test/sim/professions.test.js index a8da42ee..e6b849b5 100644 --- a/test/sim/professions.test.js +++ b/test/sim/professions.test.js @@ -348,6 +348,16 @@ test('incantation medics heal an ally for 50 % of damage dealt; wandermedics cle h.unit('t_tank').hp = 1000; h.run(0.5); approx(h.unit('t_tank').hp, 1000 + 500, 1e-6); + // …and not only for attacks: the official trait buff is ON_AFTER_OUTPUT_DAMAGE (`vendla_tr` / `reed2_tr` / `titi_tr`), + // so ANY damage the 咒愈师 deals heals. The sim used to run the trait from the attack path (`profile.afterHit`) only, + // which is why 缇缇's 凝固的时光 ticks — and every 咒愈师 skill that damages without an attack — healed nothing. + h.unit('t_tank').hp = 1000; + h.b.dealDamage(h.unit('t_inc'), h.enemy('enemy_dummy'), { amount: 400, type: 'arts', isSkill: true }); + approx(h.unit('t_tank').hp, 1000 + 200, 1e-6, 'skill damage heals 50 %'); + // a gauge fill removes no HP: it is not "伤害" for the trait + h.unit('t_tank').hp = 1000; + h.b.emit?.('damaged', { source: h.unit('t_inc'), target: h.enemy('enemy_dummy'), amount: 400, type: 'element', dmg: null }); + approx(h.unit('t_tank').hp, 1000, 1e-6, 'element 损伤 does not heal'); const wm = chessRec({ id: 't_wm', profession: 'MEDIC', subProfessionId: 'wandermedic', attackKind: 'heal', dmgType: 'heal', rangeGrid: R3, skill: null, stats: { atk: 400 } }); const h2 = makeBattle({ defs: { chess: { t_wm: wm, t_tank: tank } }, units: [{ chessId: 't_wm', row: 10, col: 4 }, { chessId: 't_tank', row: 10, col: 5 }], content: 'none' }); h2.step();