diff --git a/server/sim/content/kits/ops/chess_char_1_10-sunbr.js b/server/sim/content/kits/ops/chess_char_1_10-sunbr.js index edde385b..3ffc758b 100644 --- a/server/sim/content/kits/ops/chess_char_1_10-sunbr.js +++ b/server/sim/content/kits/ops/chess_char_1_10-sunbr.js @@ -51,7 +51,8 @@ export default { install(battle, unit) { if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return; // GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands - // in the grid), never her natural / self regeneration + // in the grid), never her natural / self regeneration; strictly below hp_ratio (the client's sunbr_e_trait is + // set_heal_scale_by_hpratio: FilterByTargetHpRatio LT) battle.on('heal', (ctx) => { if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return; if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1); diff --git a/server/sim/content/kits/ops/chess_char_3_12-blemsh.js b/server/sim/content/kits/ops/chess_char_3_12-blemsh.js index 866530ca..2d685a3c 100644 --- a/server/sim/content/kits/ops/chess_char_3_12-blemsh.js +++ b/server/sim/content/kits/ops/chess_char_3_12-blemsh.js @@ -136,7 +136,8 @@ export default { if (num(tb.damage_resistance) > 0) { kit.install = (battle, unit) => battle.addBuff(unit, { key: 'trait:blemsh_guard', mods: { dmgTakenMul: 1 - num(tb.damage_resistance) }, persist: true, allowDead: true }); } else if (num(tb.heal_scale) > 0) { - // GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal) + // GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal; strictly below — the client's blemsh_e_trait + // is set_heal_scale_by_hpratio, FilterByTargetHpRatio LT, unlike 黍's heal_scale_up[hpratio][LE]) kit.install = (battle, unit) => battle.on('heal', (ctx) => { if (ctx.source === unit && ctx.target && ctx.target.hpRatio < num(tb.hp_ratio, 0.5)) ctx.amount *= num(tb.heal_scale, 1); }, { owner: unit }); diff --git a/server/sim/content/kits/ops/chess_char_4_15-reckpr.js b/server/sim/content/kits/ops/chess_char_4_15-reckpr.js index dc83483e..18f89a5f 100644 --- a/server/sim/content/kits/ops/chess_char_4_15-reckpr.js +++ b/server/sim/content/kits/ops/chess_char_4_15-reckpr.js @@ -34,7 +34,7 @@ export default withDefaults({ if (t.side !== 'ally' || !t.alive || !(c.amount > 0) || isHpLoss(c.dmg) || !t.findBuff(`reckpr:guard:${unit.id}`)) return; battle.heal(unit, t, num(bb['attack@fixed_heal_value'], 80)); }, { owner: unit }); - installLowHpHealBonus(battle, unit, tb); + installLowHpHealBonus(battle, unit, tb); // PHY-X: reckpr_e_002_tr filters LT (strictly below) }, }; }, diff --git a/server/sim/content/kits/ops/chess_char_4_26-bldsk.js b/server/sim/content/kits/ops/chess_char_4_26-bldsk.js index 34305991..654c09ca 100644 --- a/server/sim/content/kits/ops/chess_char_4_26-bldsk.js +++ b/server/sim/content/kits/ops/chess_char_4_26-bldsk.js @@ -43,7 +43,7 @@ export default withDefaults({ }, { owner: unit }); } }], install(battle, unit) { - installLowHpHealBonus(battle, unit, tb); + installLowHpHealBonus(battle, unit, tb, { atOrBelow: true }); // PHY-X: heal_scale_up[hpratio][LE] (≤) if (!S1) return; battle.on('beforeAttack', (c) => { if (c.attacker !== unit) return; diff --git a/server/sim/content/kits/ops/chess_char_5_04-bldsk.js b/server/sim/content/kits/ops/chess_char_5_04-bldsk.js index ec98490f..42bc469f 100644 --- a/server/sim/content/kits/ops/chess_char_5_04-bldsk.js +++ b/server/sim/content/kits/ops/chess_char_5_04-bldsk.js @@ -52,7 +52,7 @@ export default { c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1); battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 }); }, { owner: unit, priority: 10 }); - lowHpHealUp(battle, unit, tb); + lowHpHealUp(battle, unit, tb, { atOrBelow: true }); // PHY-X: heal_scale_up[hpratio][LE] (≤) }, }; }, diff --git a/server/sim/content/kits/ops/chess_char_5_11-demkni.js b/server/sim/content/kits/ops/chess_char_5_11-demkni.js index 9565e9e9..0cc5b803 100644 --- a/server/sim/content/kits/ops/chess_char_5_11-demkni.js +++ b/server/sim/content/kits/ops/chess_char_5_11-demkni.js @@ -103,7 +103,7 @@ export default { install(battle, unit) { // S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6)); - lowHpHealUp(battle, unit, tb); + lowHpHealUp(battle, unit, tb); // GUA-X: set_heal_scale_by_hpratio filters LT (strictly below) if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) }); }, }; diff --git a/server/sim/content/kits/ops/chess_char_5_23-reckpr.js b/server/sim/content/kits/ops/chess_char_5_23-reckpr.js index b265d8c6..c88d6672 100644 --- a/server/sim/content/kits/ops/chess_char_5_23-reckpr.js +++ b/server/sim/content/kits/ops/chess_char_5_23-reckpr.js @@ -43,7 +43,7 @@ export default { const b = a.findBuff('reckpr:guard'); if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value)); }, { owner: unit }); - lowHpHealUp(battle, unit, tb); + lowHpHealUp(battle, unit, tb); // PHY-X: reckpr_e_002_tr filters LT (strictly below) }, }; }, diff --git a/server/sim/content/kits/ops/op-shining.js b/server/sim/content/kits/ops/op-shining.js index d9b57619..eb66040b 100644 --- a/server/sim/content/kits/ops/op-shining.js +++ b/server/sim/content/kits/ops/op-shining.js @@ -10,7 +10,8 @@ // - Trait (医师) "恢复友方单位生命": the profession default (one heal on the lowest-HP% injured ally of her 3-3 range). // Module PHY-Y 干枯剑鞘 "治疗地面单位时治疗量提升15%" (trait bb heal_scale): ×heal_scale on a heal whose target stands on // a 地面 tile (unit.ground, as 赫默's PHY-Y); PHY-X “使徒” "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale, -// hp_ratio): ×heal_scale when the target is below hp_ratio of its max HP before the heal (as 录武官's / 华法琳's PHY-X). +// hp_ratio): ×heal_scale when the target is at or below hp_ratio of its max HP before the heal — the client's +// shining_e_003_tr is heal_scale_up[hpratio][LE] (as 华法琳's PHY-X; 录武官's reckpr_e_002_tr is LT, strictly below). // - T1 黑恶魔的庇护 "攻击范围内的友方单位防御力+60": every ally (operators and summons: 友方单位) standing in her current // attack range — her own tile included — DEF +def (flat). PHY-Y stage 2+ "防御力+100,地面单位防御力额外+40" (talent // change def / def_lowland): def_lowland more on a 地面 tile. Refreshed every AURA s; two sources keep the strongest. @@ -130,7 +131,7 @@ export default { // module traits: PHY-Y heals on 地面 units, PHY-X heals on allies below hp_ratio, ×heal_scale const hs = num(tb.heal_scale, 1); if (!mod || hs === 1) return; - const boosted = mod === PHY_X ? (t) => t.hpRatio < num(tb.hp_ratio) : mod === PHY_Y ? (t) => !!t.ground : null; + const boosted = mod === PHY_X ? (t) => t.hpRatio <= num(tb.hp_ratio) + 1e-9 : mod === PHY_Y ? (t) => !!t.ground : null; if (!boosted) return; battle.on('heal', (ctx) => { if (ctx.source !== unit || ctx.opts?.regen || !ctx.target || !boosted(ctx.target)) return; diff --git a/server/sim/content/kits/ops/standin-acmedc.js b/server/sim/content/kits/ops/standin-acmedc.js index 5b047fdc..d2799b82 100644 --- a/server/sim/content/kits/ops/standin-acmedc.js +++ b/server/sim/content/kits/ops/standin-acmedc.js @@ -25,7 +25,7 @@ export default { // Talents 攫升 / 超脱: content/tokens.js mapCharTalents (the strategy's Touch too); the module's 超脱 upgrade (8 SP at // module Lv3) comes with the record's talents. // Module PHY-X (elite, trait addition): "治疗生命值低于50%的友方单位时治疗量提升15%" — every heal of hers on an ally - // below `hp_ratio` HP (strictly: "低于") ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it + // below `hp_ratio` HP (strictly: "低于"; the client's acmedc_e_tr filters LT) ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it // multiplies (×1.61 at Lv7): both are `heal_scale` blackboards, the 治疗倍率, and PRTS 游戏数据基础 §倍率 says "同种 // 倍率间叠乘" — no Touch-specific note or buff template exists. S3's extra heal is a heal of hers like any other: its // recipient below `hp_ratio` gets the ×1.15 too (its base is taken before either bonus). diff --git a/server/sim/content/kits/shared/tier4.js b/server/sim/content/kits/shared/tier4.js index 586930e1..b88b7ccc 100644 --- a/server/sim/content/kits/shared/tier4.js +++ b/server/sim/content/kits/shared/tier4.js @@ -146,10 +146,16 @@ function pullToFront(battle, unit, e, force) { } /** Physician module trait (录武官, 华法琳 elites): heals on allies below hp_ratio are ×heal_scale. */ -function installLowHpHealBonus(battle, unit, tb) { +/** + * The module trait "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale, hp_ratio): the client's module buff filters the + * target's HP before the heal with its own comparison — `atOrBelow` for the template heal_scale_up[hpratio][LE] (华法琳's + * PHY-X), strictly below otherwise (录武官's reckpr_e_002_tr: LT). [ASSUMED as before: not on herself] + */ +function installLowHpHealBonus(battle, unit, tb, { atOrBelow = false } = {}) { const s = num(tb.heal_scale, 0), r = num(tb.hp_ratio, 0); if (!(s > 0) || !(r > 0)) return; - battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && c.target.hpRatio < r) c.amount *= s; }, { owner: unit }); + const low = atOrBelow ? (t) => t.hpRatio <= r + 1e-9 : (t) => t.hpRatio < r; + battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && low(c.target)) c.amount *= s; }, { owner: unit }); } // DESIGN §5.6 documents `(bb, chess)`; content/index.js also passes the normalised def — rebuild it when absent. diff --git a/server/sim/content/kits/shared/tier5.js b/server/sim/content/kits/shared/tier5.js index 2684ad29..4227cbb2 100644 --- a/server/sim/content/kits/shared/tier5.js +++ b/server/sim/content/kits/shared/tier5.js @@ -150,13 +150,18 @@ function burstDamageUp(battle, unit, mul) { }, { owner: unit }); } -/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */ -function lowHpHealUp(battle, unit, tb) { +/** + * Modules PHY-X / GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%": heals on allies below hp_ratio ×heal_scale. The client's + * module buff picks its comparison: `atOrBelow` for heal_scale_up[hpratio][LE] (华法琳's PHY-X), strictly below for 塞雷娅's + * GUA-X (set_heal_scale_by_hpratio: LT) and 录武官's PHY-X (reckpr_e_002_tr: LT). + */ +function lowHpHealUp(battle, unit, tb, { atOrBelow = false } = {}) { const mul = num(tb.heal_scale), thr = num(tb.hp_ratio); if (!(mul > 1) || !(thr > 0)) return; + const low = atOrBelow ? (t) => t.hpRatio <= thr + 1e-9 : (t) => t.hpRatio < thr; battle.on('heal', (c) => { if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return; - if (c.target.hpRatio < thr) c.amount *= mul; + if (low(c.target)) c.amount *= mul; }, { owner: unit }); } diff --git a/test/content/feedback5-lowhp-heal.test.js b/test/content/feedback5-lowhp-heal.test.js new file mode 100644 index 00000000..4b72ca02 --- /dev/null +++ b/test/content/feedback5-lowhp-heal.test.js @@ -0,0 +1,43 @@ +// test/content/feedback5-lowhp-heal.test.js — the module trait "治疗生命值低于50%的友方单位时治疗量提升15%" (PHY-X / GUA-X) compares the +// target's HP the way the client's module buff does (follow-up 19): FilterByTargetHpRatio LT — strictly below — for 瑕光 / +// 古米 / 塞雷娅's GUA-X (set_heal_scale_by_hpratio) and 录武官's PHY-X (reckpr_e_002_tr); LE — at or below — for the template +// heal_scale_up[hpratio][LE] of 华法琳's and 闪灵's PHY-X (and 黍's GUA-X, 凯尔希's PHY-X, already so). The text's 「低于」 is the +// display; the buff templates decide (scratchpad extraction of [uc]equips.ab + buff_template_data, 2026-10-06). +// Run: node --test test/content/feedback5-lowhp-heal.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; + +const WALL = chessRec({ id: 't_wall', stats: { maxHp: 1e6, atk: 0, def: 0 }, skill: null }); + +/** The bonus factor `healer` gives a heal on an ally at `ratio` of its max HP (1 = none). */ +function factorAt(unitSpec, ratio) { + const h = makeBattle({ defs: { chess: { t_wall: WALL } }, units: [unitSpec, { chessId: 't_wall', row: 11, col: 5 }], autoFinish: false, timeLimit: 30, seed: 3 }); + h.step(); + const healer = h.b.allyUnits.find((u) => u.uid === 1), t = h.unit('t_wall'); + t.hp = t.s.maxHp * ratio; + const before = t.hp; + const base = 100 * healer.s.healingDealtMul * t.s.healingTakenMul; + h.b.heal(healer, t, 100); + checkInvariants(h.b); + return (t.hp - before) / base; +} +const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-9, `${msg}: ${a} vs ${b}`); + +test('LE (heal_scale_up[hpratio][LE]): 华法琳 (tier 4 and 5) and 闪灵 PHY-X heal an ally at exactly 50 % HP ×1.15', () => { + for (const id of ['chess_char_4_26_b', 'chess_char_5_04_b']) { + near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.5), 1.15, `${id} at 50 %`); + near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.6), 1, `${id} at 60 %`); + } + const shining = { uid: 1, diy: { slot: 6, charId: 'char_147_shining', skillIndex: 0, uniEquipId: 'uniequip_003_shining' }, elite: true, row: 10, col: 5 }; + near(factorAt(shining, 0.5), 1.15, '闪灵 PHY-X at 50 %'); + near(factorAt(shining, 0.6), 1, '闪灵 PHY-X at 60 %'); +}); + +test('LT (set_heal_scale_by_hpratio, reckpr_e_002_tr): 塞雷娅 GUA-X and 录武官 PHY-X (tier 4 and 5) give nothing at exactly 50 %, ×1.15 below', () => { + for (const id of ['chess_char_5_11_b', 'chess_char_4_15_b', 'chess_char_5_23_b']) { + near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.5), 1, `${id} at 50 %`); + near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.49), 1.15, `${id} at 49 %`); + } +});