diablo2-web/tests/skills/pal/cohort4-pal-adversarial-str...

717 lines
28 KiB
TypeScript

/**
* Cohort 4 — Paladin Combat Skills & Offensive Auras (IDs 96..115)
* Adversarial Stress & Invariant Verification Suite
*
* Authored by: teamwork_preview_challenger (challenger_pal_1)
* Methodology: Pre-submission solution stress testing (Differential testing, Oracles, Adversarial Edge Cases)
*
* Probes the 5 Mission-Critical Mechanics:
* 1. Blessed Hammer 1.13c Magic Resistance Invariant (Undead/Demons do NOT bypass Magic Resistance; Magic Immunes take 0 damage)
* 2. Smite Mechanics (AutoHit vs extreme defense, base shield + Holy Shield flat damage, Leech suppression from items, stun duration formula)
* 3. Concentration Synergy to Blessed Hammer (Strictly 50% of listed %ED)
* 4. Conviction Invariants (-150% floor, 1/5th efficiency vs elemental immunes >= 100%, elemental only - zero effect on poison/magic)
* 5. Sacrifice Self-Damage (8% physical self-damage, caster HP clamped >= 1 HP)
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import {
computeEffectiveResistance,
computeToHitChance,
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import {
calculateSacrificeStats,
calculateSmiteStats,
calculateBlessedHammerDamage,
calculateConcentrationStats,
calculateConvictionStats,
calculateHolyShieldStats,
} from '../../../src/game/skills.ts'
describe('Cohort 4 — Paladin Adversarial Stress Suite (Skills 96..115)', () => {
// ==========================================================================
// 1. Blessed Hammer 1.13c Magic Resistance Invariant
// ==========================================================================
describe('1. Blessed Hammer 1.13c Magic Resistance Invariant', () => {
it('Undead and Demon targets with magic resistance reduce Blessed Hammer damage accordingly', async () => {
const registry = await getSharedDataRegistry()
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
}
// Base packet: 1,000 magic damage
const baseMagicDmg = 1000
// 1. Undead with 0% magic resistance -> gets +50% undead bonus = 1,500 damage
const u0Stats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, magicresist: 0 })
const u0: CombatUnitContext = { id: 'u0', name: 'Undead 0%', statList: u0Stats, stateBus: new StateBus(u0Stats, registry), isUndead: true }
const outU0 = executeSUnitDmg(paladin, u0, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseMagicDmg * FIXED_ONE,
elemMax256: baseMagicDmg * FIXED_ONE,
})
expect(outU0.elemDamage256).toBe(1500 * FIXED_ONE)
// 2. Undead with 50% magic resistance -> 1,500 * (100 - 50)% = 750 damage
const u50Stats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, magicresist: 50 })
const u50: CombatUnitContext = { id: 'u50', name: 'Undead 50%', statList: u50Stats, stateBus: new StateBus(u50Stats, registry), isUndead: true }
const outU50 = executeSUnitDmg(paladin, u50, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseMagicDmg * FIXED_ONE,
elemMax256: baseMagicDmg * FIXED_ONE,
})
expect(outU50.elemDamage256).toBe(750 * FIXED_ONE)
// 3. Demon with 0% magic resistance -> 1,000 damage
const d0Stats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, magicresist: 0 })
const d0: CombatUnitContext = { id: 'd0', name: 'Demon 0%', statList: d0Stats, stateBus: new StateBus(d0Stats, registry), isDemon: true }
const outD0 = executeSUnitDmg(paladin, d0, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseMagicDmg * FIXED_ONE,
elemMax256: baseMagicDmg * FIXED_ONE,
})
expect(outD0.elemDamage256).toBe(1000 * FIXED_ONE)
// 4. Demon with 75% magic resistance -> 1,000 * 25% = 250 damage
const d75Stats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, magicresist: 75 })
const d75: CombatUnitContext = { id: 'd75', name: 'Demon 75%', statList: d75Stats, stateBus: new StateBus(d75Stats, registry), isDemon: true }
const outD75 = executeSUnitDmg(paladin, d75, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseMagicDmg * FIXED_ONE,
elemMax256: baseMagicDmg * FIXED_ONE,
})
expect(outD75.elemDamage256).toBe(250 * FIXED_ONE)
})
it('CRITICAL 1.13c Invariant: Magic Immune targets (Undead, Demon, Living) take strictly 0 damage', async () => {
const registry = await getSharedDataRegistry()
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
}
// Test cases of Magic Immune enemies (baseRes >= 100)
const immuneEnemies = [
{ name: 'Achmel the Cursed (Undead Magic Immune 100%)', isUndead: true, isDemon: false, magicRes: 100 },
{ name: 'Radament Minion (Undead Magic Immune 120%)', isUndead: true, isDemon: false, magicRes: 120 },
{ name: 'Baal Minion (Demon Magic Immune 100%)', isUndead: false, isDemon: true, magicRes: 100 },
{ name: 'Pit Lord (Demon Magic Immune 110%)', isUndead: false, isDemon: true, magicRes: 110 },
{ name: 'Wendingo (Living Magic Immune 100%)', isUndead: false, isDemon: false, magicRes: 100 },
]
for (const enemyDef of immuneEnemies) {
const stats = new UnitStatList(registry, {
maxhp: 10000 * FIXED_ONE,
hitpoints: 10000 * FIXED_ONE,
magicresist: enemyDef.magicRes,
})
const target: CombatUnitContext = {
id: `target_${enemyDef.name}`,
name: enemyDef.name,
statList: stats,
stateBus: new StateBus(stats, registry),
isUndead: enemyDef.isUndead,
isDemon: enemyDef.isDemon,
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, target, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: 2000 * FIXED_ONE,
elemMax256: 2000 * FIXED_ONE,
})
expect(out.immuneToElem, `${enemyDef.name} must be flagged immune`).toBe(true)
expect(out.elemDamage256, `${enemyDef.name} must take strictly 0 damage`).toBe(0)
expect(out.totalDamage, `${enemyDef.name} must take strictly 0 total damage`).toBe(0)
expect(stats.getHp256(), `${enemyDef.name} HP must be completely untouched`).toBe(10000 * FIXED_ONE)
}
})
it('Differential Fuzzing: Fuzz 50 random resistance values [-50..150] across target types', async () => {
const registry = await getSharedDataRegistry()
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
}
for (let seed = 1; seed <= 50; seed++) {
const rawRes = -50 + (seed * 4) // sweeps -46% to 150%
const isUndead = seed % 2 === 0
const isDemon = !isUndead && seed % 3 === 0
const baseDamage = 1000
const targetStats = new UnitStatList(registry, {
maxhp: 50000 * FIXED_ONE,
hitpoints: 50000 * FIXED_ONE,
magicresist: rawRes,
})
const target: CombatUnitContext = {
id: `target_${seed}`,
name: `Fuzz Target ${seed}`,
statList: targetStats,
stateBus: new StateBus(targetStats, registry),
isUndead,
isDemon,
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, target, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseDamage * FIXED_ONE,
elemMax256: baseDamage * FIXED_ONE,
})
// Oracle calculation:
const expectedPreRes = isUndead ? Math.trunc(baseDamage * 1.5) : baseDamage
let expectedDamage = 0
if (rawRes < 100) {
const effRes = Math.max(-100, Math.min(99, rawRes))
expectedDamage = Math.trunc((expectedPreRes * (100 - effRes)) / 100)
}
expect(out.elemDamage256 / FIXED_ONE).toBe(expectedDamage)
if (rawRes >= 100) {
expect(out.immuneToElem).toBe(true)
}
}
})
})
// ==========================================================================
// 2. Smite Mechanics
// ==========================================================================
describe('2. Smite Mechanics', () => {
it('AutoHit Invariant: Smite always hits regardless of attacker AR (0) and defender Defense (10,000,000)', async () => {
const registry = await getSharedDataRegistry()
// Extreme test case: Attacker AR = 0, Defender Defense = 10,000,000, Defender Shield Block = 75%
const attackerStats = new UnitStatList(registry, {
level: 1,
tohit: 0,
})
const attacker: CombatUnitContext = {
id: 'clueless_smiter',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
}
const defenderStats = new UnitStatList(registry, {
level: 99,
armorclass: 10000000, // 10 million defense
toblock: 75, // 75% max block
hitpoints: 50000 * FIXED_ONE,
maxhp: 50000 * FIXED_ONE,
})
const defender: CombatUnitContext = {
id: 'godlike_wall',
name: 'Godlike Defender',
statList: defenderStats,
stateBus: new StateBus(defenderStats, registry),
hasShield: true,
}
// RollToHit formula check directly
const toHit = computeToHitChance({
attackerAr: 0,
defenderDef: 10000000,
attackerLvl: 1,
defenderLvl: 99,
autoHit: true,
})
expect(toHit).toBe(100)
// Test 100 attack rolls: every single roll in [0..99] must hit and never be blocked
for (let roll = 0; roll < 100; roll++) {
const out = executeSUnitDmg(attacker, defender, {
skillId: 97,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 50 * FIXED_ONE,
flatPhysMax256: 50 * FIXED_ONE,
autoHit: true,
unblockable: true,
roll100: roll,
})
expect(out.hit, `Smite must hit at roll ${roll}`).toBe(true)
expect(out.avoidedReason).toBe('none')
expect(out.physDamage256).toBe(50 * FIXED_ONE)
}
})
it('Base Damage Invariant: Shield min/max damage + Holy Shield flat damage bonus applies to Smite', () => {
// 1. Plain shield without Holy Shield: Herald of Zakarum (Gilded Shield: 20..28 smite damage)
const plainSmite = calculateSmiteStats(1, 20, 28)
expect(plainSmite.minDamage).toBe(20)
expect(plainSmite.maxDamage).toBe(28)
expect(plainSmite.damagePct).toBe(15) // Slvl 1: +15% ED
// 2. Slvl 20 Holy Shield: +57 flat min, +60 flat max
const hs20 = calculateHolyShieldStats(20, 0)
expect(hs20.smiteMinFlat).toBe(57)
expect(hs20.smiteMaxFlat).toBe(60)
// 3. Combined Smite + Holy Shield: (20 + 57) .. (28 + 60) = 77 .. 88 base damage
const buffedSmite = calculateSmiteStats(20, 20, 28, { min: hs20.smiteMinFlat, max: hs20.smiteMaxFlat })
expect(buffedSmite.minDamage).toBe(77)
expect(buffedSmite.maxDamage).toBe(88)
expect(buffedSmite.damagePct).toBe(15 + 19 * 15) // +300% ED
})
it('Stun Duration Formula: Stun frames = min(250, 15 + (slvl - 1) * 5)', () => {
// Oracle check across slvls 1 through 60
for (let slvl = 1; slvl <= 60; slvl++) {
const expected = Math.min(250, 15 + (slvl - 1) * 5)
const stats = calculateSmiteStats(slvl, 10, 20)
expect(stats.stunDurationFrames).toBe(expected)
}
// Explicit boundary values:
expect(calculateSmiteStats(1, 10, 20).stunDurationFrames).toBe(15) // 0.6s
expect(calculateSmiteStats(10, 10, 20).stunDurationFrames).toBe(60) // 2.4s
expect(calculateSmiteStats(20, 10, 20).stunDurationFrames).toBe(110) // 4.4s
expect(calculateSmiteStats(47, 10, 20).stunDurationFrames).toBe(245) // 9.8s
expect(calculateSmiteStats(48, 10, 20).stunDurationFrames).toBe(250) // 10.0s (capped)
expect(calculateSmiteStats(50, 10, 20).stunDurationFrames).toBe(250) // 10.0s (capped)
})
it('Leech Suppression: Life leech and mana leech from items are strictly 0 when attacking with Smite', async () => {
const registry = await getSharedDataRegistry()
const initialHp = 500 * FIXED_ONE
const initialMana = 500 * FIXED_ONE
// Attacker is equipped with 20% Life Leech and 20% Mana Leech from items
const attackerStats = new UnitStatList(registry, {
hitpoints: initialHp,
maxhp: 1000 * FIXED_ONE,
mana: initialMana,
maxmana: 1000 * FIXED_ONE,
lifesteal: 20,
manasteal: 20,
})
const attacker: CombatUnitContext = {
id: 'smiter_with_leech_gear',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
unitType: 'player',
}
const defenderStats = new UnitStatList(registry, {
hitpoints: 50000 * FIXED_ONE,
maxhp: 50000 * FIXED_ONE,
damageresist: 0,
})
const defender: CombatUnitContext = {
id: 'target_dummy',
name: 'Training Dummy',
statList: defenderStats,
stateBus: new StateBus(defenderStats, registry),
unitType: 'monster',
}
// Execute Smite dealing 500 physical damage
const out = executeSUnitDmg(attacker, defender, {
skillId: 97,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 500 * FIXED_ONE,
flatPhysMax256: 500 * FIXED_ONE,
autoHit: true,
unblockable: true,
})
expect(out.hit).toBe(true)
expect(out.physDamage256).toBe(500 * FIXED_ONE)
// In Diablo II v1.13c ground truth:
// Smite attacks CANNOT leech life or mana from gear! (Arreat Summit & D2Common/D2Game ground truth)
// Attacker HP and Mana must strictly remain at 500 (0 leeched), NOT 600.
expect(attackerStats.getHp256()).toBe(initialHp)
expect(attackerStats.getMana256()).toBe(initialMana)
})
})
// ==========================================================================
// 3. Concentration Synergy to Blessed Hammer
// ==========================================================================
describe('3. Concentration Synergy to Blessed Hammer', () => {
it('Concentration aura provides exactly 50% of its listed % enhanced damage bonus to Blessed Hammer', () => {
// Test across slvl 1 through 30 of Concentration
for (let slvl = 1; slvl <= 30; slvl++) {
const concStats = calculateConcentrationStats(slvl)
const expectedBonus = Math.trunc(concStats.damagePercent / 2)
expect(concStats.blessedHammerBonusPct).toBe(expectedBonus)
}
// Concrete sample verification:
// slvl 1: 60% ED -> 30% to Hammer
expect(calculateConcentrationStats(1).blessedHammerBonusPct).toBe(30)
// slvl 10: 60 + 9*15 = 195% ED -> 97% to Hammer
expect(calculateConcentrationStats(10).blessedHammerBonusPct).toBe(97)
// slvl 20: 60 + 19*15 = 345% ED -> 172% to Hammer
expect(calculateConcentrationStats(20).blessedHammerBonusPct).toBe(172)
})
it('Runtime executeSUnitDmg scales Blessed Hammer damage by exactly 50% of active Concentration aura %ED', async () => {
const registry = await getSharedDataRegistry()
const baseHammerDamage = 100
// Baseline: Paladin without Concentration
const palNoConcStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const palNoConc: CombatUnitContext = {
id: 'pal_no_conc',
name: 'Paladin',
statList: palNoConcStats,
stateBus: new StateBus(palNoConcStats, registry),
unitType: 'player',
}
const targetStats = new UnitStatList(registry, { maxhp: 50000 * FIXED_ONE, hitpoints: 50000 * FIXED_ONE, magicresist: 0 })
const target: CombatUnitContext = {
id: 'target',
name: 'Monster',
statList: targetStats,
stateBus: new StateBus(targetStats, registry),
unitType: 'monster',
}
const baseOut = executeSUnitDmg(palNoConc, target, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseHammerDamage * FIXED_ONE,
elemMax256: baseHammerDamage * FIXED_ONE,
})
expect(baseOut.elemDamage256).toBe(100 * FIXED_ONE)
// Test with various Concentration levels (e.g. 100%, 200%, 345% listed ED)
const testCases = [
{ listedConcEd: 100, expectedMult: 1.5 }, // +50% -> 150
{ listedConcEd: 200, expectedMult: 2.0 }, // +100% -> 200
{ listedConcEd: 300, expectedMult: 2.5 }, // +150% -> 250
{ listedConcEd: 345, expectedMult: 2.72 }, // +172% -> 272
]
for (const tc of testCases) {
const out = executeSUnitDmg(palNoConc, target, {
skillId: 112,
attackKind: 'spell',
elemType: 'mag',
elemMin256: baseHammerDamage * FIXED_ONE,
elemMax256: baseHammerDamage * FIXED_ONE,
concentrationBonusPct: tc.listedConcEd,
})
const expectedDmg = Math.trunc((baseHammerDamage * (100 + Math.trunc(tc.listedConcEd / 2))) / 100)
expect(out.elemDamage256 / FIXED_ONE).toBe(expectedDmg)
}
})
})
// ==========================================================================
// 4. Conviction Invariants
// ==========================================================================
describe('4. Conviction Invariants', () => {
it('-150% Resistance Floor: Conviction elemental resistance reduction cannot exceed -150% even at extreme slvls', () => {
// Slvl 1 to 25 scales: 30 + (slvl - 1) * 5
expect(calculateConvictionStats(1).resistanceReductionPct).toBe(30)
expect(calculateConvictionStats(10).resistanceReductionPct).toBe(75)
expect(calculateConvictionStats(20).resistanceReductionPct).toBe(125)
expect(calculateConvictionStats(25).resistanceReductionPct).toBe(150)
// Slvl 26 through 99 must strictly cap at 150%
for (let slvl = 26; slvl <= 99; slvl++) {
const stats = calculateConvictionStats(slvl)
expect(stats.resistanceReductionPct, `Slvl ${slvl} must cap at 150`).toBe(150)
expect(stats.immunityBreakingReductionPct, `Slvl ${slvl} 1/5th cap must be 30`).toBe(30)
}
})
it('1/5th Efficiency vs Immunes: Against monsters with base elemental res >= 100%, Conviction applies at 1/5th efficiency', () => {
const convPierce = 150 // Slvl 25 Conviction (-150%)
// Non-immune monster (< 100%): Full 150% applied
const resNonImmune = computeEffectiveResistance({ baseRes: 99, convictionPierce: convPierce })
expect(resNonImmune.convictionAndLRApplied).toBe(150)
expect(resNonImmune.isImmune).toBe(false)
expect(resNonImmune.effectiveRes).toBe(-51) // 99 - 150 = -51%
// Immune monsters (>= 100%): 1/5th efficiency = Math.trunc(150 / 5) = 30% applied
const immuneCases = [
{ baseRes: 100, expectedApplied: 30, expectedRes: 70, expectedImmune: false },
{ baseRes: 110, expectedApplied: 30, expectedRes: 80, expectedImmune: false },
{ baseRes: 125, expectedApplied: 30, expectedRes: 95, expectedImmune: false },
{ baseRes: 129, expectedApplied: 30, expectedRes: 99, expectedImmune: false },
{ baseRes: 130, expectedApplied: 30, expectedRes: 100, expectedImmune: true }, // Unbroken!
{ baseRes: 140, expectedApplied: 30, expectedRes: 100, expectedImmune: true }, // Unbroken!
{ baseRes: 160, expectedApplied: 30, expectedRes: 100, expectedImmune: true }, // Unbroken!
]
for (const tc of immuneCases) {
const res = computeEffectiveResistance({ baseRes: tc.baseRes, convictionPierce: convPierce })
expect(res.convictionAndLRApplied, `baseRes ${tc.baseRes} must have 1/5th efficiency`).toBe(tc.expectedApplied)
expect(res.isImmune, `baseRes ${tc.baseRes} immunity check`).toBe(tc.expectedImmune)
expect(res.effectiveRes, `baseRes ${tc.baseRes} effective resistance`).toBe(tc.expectedRes)
}
})
it('Elemental Only: Conviction does NOT reduce Poison resistance or Magic resistance', async () => {
const registry = await getSharedDataRegistry()
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
}
// Defender with active Conviction state (-150% conviction pierce)
const defStats = new UnitStatList(registry, {
maxhp: 10000 * FIXED_ONE,
hitpoints: 10000 * FIXED_ONE,
fireresist: 50,
poisonresist: 50,
magicresist: 50,
})
const defBus = new StateBus(defStats, registry)
defBus.applyState({
stateNameOrId: 'conviction',
slvl: 25,
stats: { conviction_pierce: 150, item_armor_percent: -90 },
})
const defender: CombatUnitContext = {
id: 'convicted_monster',
name: 'Convicted Monster',
statList: defStats,
stateBus: defBus,
unitType: 'monster',
}
// 1. Fire attack (Fire is pierced by Conviction: 50% - 150% = -100% res -> takes 2x damage)
const fireOut = executeSUnitDmg(paladin, defender, {
skillId: 0,
attackKind: 'melee',
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
})
expect(fireOut.elemDamage256).toBe(200 * FIXED_ONE)
// 2. Poison attack (Conviction does NOT pierce Poison: 50% res remains -> takes 50 damage)
const poisOut = executeSUnitDmg(paladin, defender, {
skillId: 0,
attackKind: 'melee',
elemType: 'pois',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
})
expect(poisOut.elemDamage256).toBe(50 * FIXED_ONE)
// 3. Magic attack (Conviction does NOT pierce Magic: 50% res remains -> takes 50 damage)
const magOut = executeSUnitDmg(paladin, defender, {
skillId: 0,
attackKind: 'spell',
elemType: 'mag',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
})
expect(magOut.elemDamage256).toBe(50 * FIXED_ONE)
})
})
// ==========================================================================
// 5. Sacrifice Self-Damage
// ==========================================================================
describe('5. Sacrifice Self-Damage', () => {
it('Calculates exact 8% physical self-damage per hit', async () => {
const registry = await getSharedDataRegistry()
const initialHp = 10000 * FIXED_ONE
const attackerStats = new UnitStatList(registry, {
hitpoints: initialHp,
maxhp: 10000 * FIXED_ONE,
})
const attacker: CombatUnitContext = {
id: 'sacrificer',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
unitType: 'player',
}
const defenderStats = new UnitStatList(registry, {
hitpoints: 100000 * FIXED_ONE,
maxhp: 100000 * FIXED_ONE,
damageresist: 0,
})
const defender: CombatUnitContext = {
id: 'punching_bag',
name: 'Monster',
statList: defenderStats,
stateBus: new StateBus(defenderStats, registry),
unitType: 'monster',
}
// Deal 2,500 damage -> 8% self-damage = 200 damage
const out = executeSUnitDmg(attacker, defender, {
skillId: 96,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 2500 * FIXED_ONE,
flatPhysMax256: 2500 * FIXED_ONE,
selfDamagePct: 8,
autoHit: true,
})
expect(out.hit).toBe(true)
expect(out.physDamage256).toBe(2500 * FIXED_ONE)
expect(out.selfDamageTaken256).toBe(200 * FIXED_ONE)
expect(attackerStats.getHp256()).toBe((10000 - 200) * FIXED_ONE)
})
it('Suicide Prevention Invariant: Caster HP strictly does NOT drop below 1 from self-damage', async () => {
const registry = await getSharedDataRegistry()
// Case A: Attacker has 10 HP remaining, deals 100,000 damage (8,000 self-damage)
const atkStatsA = new UnitStatList(registry, {
hitpoints: 10 * FIXED_ONE,
maxhp: 1000 * FIXED_ONE,
})
const attackerA: CombatUnitContext = {
id: 'low_hp_pal',
name: 'Paladin',
statList: atkStatsA,
stateBus: new StateBus(atkStatsA, registry),
unitType: 'player',
}
const defStats = new UnitStatList(registry, {
hitpoints: 500000 * FIXED_ONE,
maxhp: 500000 * FIXED_ONE,
damageresist: 0,
})
const defender: CombatUnitContext = {
id: 'target',
name: 'Monster',
statList: defStats,
stateBus: new StateBus(defStats, registry),
unitType: 'monster',
}
const outA = executeSUnitDmg(attackerA, defender, {
skillId: 96,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 100000 * FIXED_ONE,
flatPhysMax256: 100000 * FIXED_ONE,
selfDamagePct: 8,
autoHit: true,
})
expect(outA.hit).toBe(true)
expect(outA.selfDamageTaken256).toBe(8000 * FIXED_ONE)
// Clamped to exactly 1 HP (256 in FIXED_ONE), preventing death
expect(atkStatsA.getHp256()).toBe(FIXED_ONE)
// Case B: Attacker already at 1 HP, deals 50,000 damage
const atkStatsB = new UnitStatList(registry, {
hitpoints: FIXED_ONE,
maxhp: 1000 * FIXED_ONE,
})
const attackerB: CombatUnitContext = {
id: 'one_hp_pal',
name: 'Paladin',
statList: atkStatsB,
stateBus: new StateBus(atkStatsB, registry),
unitType: 'player',
}
const outB = executeSUnitDmg(attackerB, defender, {
skillId: 96,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 50000 * FIXED_ONE,
flatPhysMax256: 50000 * FIXED_ONE,
selfDamagePct: 8,
autoHit: true,
})
expect(outB.hit).toBe(true)
expect(atkStatsB.getHp256()).toBe(FIXED_ONE)
})
it('Physical Immune target: 0 physical damage dealt -> 0 self-damage taken', async () => {
const registry = await getSharedDataRegistry()
const atkStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
const attacker: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: atkStats,
stateBus: new StateBus(atkStats, registry),
unitType: 'player',
}
const defStats = new UnitStatList(registry, {
hitpoints: 10000 * FIXED_ONE,
maxhp: 10000 * FIXED_ONE,
damageresist: 100, // Physical Immune
})
const defender: CombatUnitContext = {
id: 'immune_target',
name: 'Ghost',
statList: defStats,
stateBus: new StateBus(defStats, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(attacker, defender, {
skillId: 96,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 1000 * FIXED_ONE,
flatPhysMax256: 1000 * FIXED_ONE,
selfDamagePct: 8,
autoHit: true,
})
expect(out.hit).toBe(true)
expect(out.physDamage256).toBe(0)
expect(out.selfDamageTaken256).toBeUndefined()
expect(atkStats.getHp256()).toBe(1000 * FIXED_ONE) // No self damage!
})
})
})