diff --git a/docs/SIM.md b/docs/SIM.md index 67b576a8..b89b7187 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -678,7 +678,9 @@ PRTS 异常效果: 无法选择 effects "仅在选择时生效"); checked before with `ceil(final) ≥ BOSS_HIT_LIMIT` (300000, shared/constants.js) is cancelled whole: returns 0 before shields (阿利斯泰尔's `boss:vest` barrier stays untouched; a `hit`-step block such as 假想敌:再生's aura acts earlier) [ASSUMED order], no HP / pool loss, no credit or stats, no `dmg` event, no `damaged` / `fatal` / kill; an fx `hitCap` `{ id, n }` marks it and -draws nothing; research 11) → shields → HP loss +draws nothing; research 11) → shields → **`hpDamage`** (what passed the shields; a handler may only lower `amount` — the +伤判效果 that act after a barrier: 煌's 紧急除颤 HP floor, PRTS 备注 "该伤害减少(伤害值-煌当前生命值+煌最大生命值×50%)点"; +左乐's 庇护 re-applied after his 行险 barrier; never for a 流失) → HP loss (boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`. @@ -735,6 +737,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `hit` | `{ source, target, dmg, credit }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain; 无来源 `dmg.sourceless` bursts, whose `credit` names the unit credited) | | `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` | | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | +| `hpDamage` | `{ source, target, amount, dmg, credit }` | a damage instance after shields, before the HP loss (§4; not a 流失): lower `amount` only (a raise is ignored) — HP floors / reductions ordered after a barrier (kits/ops/op-huang.js, op-zuole.js) | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | | `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | | `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` | diff --git a/server/sim/damage.js b/server/sim/damage.js index 66d64343..27bec030 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -5,8 +5,9 @@ // → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none) // → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul // → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled) -// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook -// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill. +// → shields (hit-negating barriers first, then HP shields) → 'hpDamage' hook (what passed the shields; handlers may +// only lower `amount`: the 伤判效果 that act after a barrier — 煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied after his +// 行险 barrier) → HP loss (boss pool routing) → 'damaged' hook → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill. // Damage-dealt stats (the source's `stats.dmg`, the player's `damageDealt`) count only HP removed from the other side: // self and friendly damage (a 源石溶剂 drain, an operator's own 流失) is the target's `taken` and keeps the kill credit. // Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with @@ -268,6 +269,12 @@ export function dealDamage(battle, source, target, dmgIn) { // ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens if (final > 0 && leaderHitCancelled(battle, target, final)) return 0; final = absorbShields(battle, target, final); + // 伤判效果 after the barriers (header): a handler may lower what reaches the HP — never raise it (a 流失 skips this) + if (final > 0 && battle._hooks.hpDamage) { + const hctx = { source: hs, target, amount: final, dmg, credit: source }; + battle.emit('hpDamage', hctx); + if (Number.isFinite(hctx.amount)) final = Math.max(0, Math.min(final, hctx.amount)); + } return applyHpLoss(battle, source, target, final, dmg); } diff --git a/test/sim/feedback5-hpdamage-hook.test.js b/test/sim/feedback5-hpdamage-hook.test.js new file mode 100644 index 00000000..7443e494 --- /dev/null +++ b/test/sim/feedback5-hpdamage-hook.test.js @@ -0,0 +1,55 @@ +// test/sim/feedback5-hpdamage-hook.test.js — the `hpDamage` hook of damage.js dealDamage (0.2.0, the 自选 operator kits): +// what passed the shields of a damage instance, before the HP loss; a handler may only lower it — the 伤判效果 that act +// after a barrier (煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied behind his 行险 barrier). A 流失 (Battle.loseHp) never +// reaches it, a hit the shields absorb whole neither. docs/SIM.md §4 / §5. +// Run: node --test test/sim/feedback5-hpdamage-hook.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +function arena() { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, mass: 0 }) } }, + units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }], timeLimit: 60, autoFinish: false, + }); + h.step(); + const u = h.unit(1), e = h.spawn('enemy_dummy', { pos: [10, 8] }); + return { h, u, e }; +} + +test('hpDamage: after the shields, before the HP loss — a handler lowers what reaches the HP; a raise is ignored', () => { + const { h, u, e } = arena(); + const seen = []; + const off = h.b.on('hpDamage', (c) => { if (c.target === u) { seen.push([c.amount, c.source, c.dmg.type]); c.amount = Math.min(c.amount, 100); } }); + h.b.addBuff(u, { key: 'test:shield', shield: 300 }); + const hp = u.hp; + const dealt = h.b.dealDamage(e, u, { amount: 1000, type: 'true' }); + assert.deepEqual(seen, [[700, e, 'true']], 'the 300 shield first, then the hook sees the 700 that passed'); + assert.equal(u.findBuff('test:shield'), null, 'the shield was spent'); + assert.deepEqual([hp - u.hp, dealt], [100, 100], 'HP loss and the returned amount follow the lowered value'); + h.b.off(off); + h.b.on('hpDamage', (c) => { if (c.target === u) c.amount = 1e9; }); + const hp2 = u.hp; + h.b.dealDamage(e, u, { amount: 50, type: 'true' }); + assert.equal(hp2 - u.hp, 50, 'a raise is ignored'); + checkInvariants(h.b); +}); + +test('hpDamage: no call for a 流失 nor for a hit the shields take whole; without a handler nothing changes', () => { + const { h, u, e } = arena(); + let n = 0; + h.b.on('hpDamage', (c) => { if (c.target === u) n++; }); + h.b.loseHp(u, 100, { source: e }); + assert.equal(n, 0, 'a 流失 skips it (no 伤判效果)'); + h.b.addBuff(u, { key: 'test:shield', shield: 500 }); + h.b.dealDamage(e, u, { amount: 200, type: 'true' }); + assert.equal(n, 0, 'absorbed whole: nothing reaches the HP'); + h.b.dealDamage(e, u, { amount: 400, type: 'true' }); + assert.equal(n, 1, 'the 100 that passed'); + const r = arena(); + const hp = r.u.hp; + r.h.b.dealDamage(r.e, r.u, { amount: 250, type: 'true' }); + assert.equal(hp - r.u.hp, 250, 'no handler: the plain pipeline'); + checkInvariants(h.b); +});