717 lines
28 KiB
TypeScript
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!
|
|
})
|
|
})
|
|
})
|