696 lines
26 KiB
TypeScript
696 lines
26 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Adversarial Challenger Verification Suite: Iteration 2
|
|
* Focus: Paladin Skills (IDs 96..125) Mechanics Remediation & Ground Truth Verification
|
|
*
|
|
* Adversarially probes:
|
|
* 1. Smite Equipment Leech Suppression:
|
|
* - Gear life leech (lifesteal, life_leech, item_parasite, life_leech_pct) is strictly 0.
|
|
* - Gear mana leech (manasteal, mana_leech, item_parasitemana, mana_leech_pct) is strictly 0.
|
|
* - Contrast: non-smite physical melee attacks correctly leech life and mana.
|
|
* 2. Life Tap Curse Healing Preservation:
|
|
* - Smite against a target cursed with Life Tap restores exactly 50% of dealt physical damage.
|
|
* - Smite with BOTH Life Tap on defender AND 50% gear leech on attacker heals strictly 50% (not 100%).
|
|
* - Smite against Physical Immune target with Life Tap heals strictly 0 HP.
|
|
* - Smite against 50% Physical Resistance target heals 50% of the mitigated damage.
|
|
* 3. Holy Shield Blocking Chance Parity:
|
|
* - dm56 formula parity across all levels 1..50: 10 + Math.floor((3300 * lvl) / (100 * (lvl + 6))).
|
|
* - slvl 1 evaluates to exactly 14% (not 13%).
|
|
* - slvl 20 evaluates to exactly 35% (not 36%).
|
|
* - Runtime state application toblock bonus matches across slvls.
|
|
* 4. SrvSt Pre-Cast Equipment Guards:
|
|
* - hasShield === false rejects Smite (#97) and Holy Shield (#117) with 0 mana spent.
|
|
* - weaponItemType === 'bow' or 'crossbow' or isRanged === true rejects Sacrifice (#96) and Zeal (#106).
|
|
* - undefined/omitted equipment properties retain permissive fallback for minimal fixtures.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import {
|
|
calculateHolyShieldStats,
|
|
validatePaladinSkillEquipment,
|
|
} from '../../../src/game/skills/paladin-combat.ts'
|
|
import { computeDiminishingReturns } from '../../../src/game/engine/calc-ast.ts'
|
|
import { executeSkillCore113c, evaluateSkill113c } from '../../../src/game/skills/registry.ts'
|
|
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
|
|
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
|
|
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
|
|
import type { SkillExecContext } from '../../../src/game/skills/types.ts'
|
|
|
|
function createExecContext(
|
|
registry: any,
|
|
skillId: number,
|
|
slvl: number,
|
|
caster: CombatUnitContext,
|
|
targets: CombatUnitContext[] = [],
|
|
): SkillExecContext {
|
|
const skill = registry.getSkillById(skillId)!
|
|
const evalResult = evaluateSkill113c({
|
|
registry,
|
|
skill,
|
|
slvl,
|
|
blvl: slvl,
|
|
statList: caster.statList,
|
|
})
|
|
return {
|
|
registry,
|
|
skill,
|
|
evalResult,
|
|
caster,
|
|
targets,
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: new MissileEngine(registry),
|
|
auraScanner: new AuraScanner(registry),
|
|
summonManager: new SummonManager(registry),
|
|
currentTick: 0,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
}
|
|
}
|
|
|
|
describe('Adversarial Challenger Verification Suite — Paladin Iteration 2', () => {
|
|
// ==========================================================================
|
|
// Pillar 1: Smite Equipment Leech Suppression
|
|
// ==========================================================================
|
|
describe('Pillar 1: Smite Equipment Leech Suppression', () => {
|
|
it('suppresses all gear life leech and mana leech keys during Smite physical attack', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const initialHp = 500 * FIXED_ONE
|
|
const initialMana = 300 * FIXED_ONE
|
|
|
|
// Test multiple leech keys simultaneously
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 2000 * FIXED_ONE,
|
|
mana: initialMana,
|
|
maxmana: 1000 * FIXED_ONE,
|
|
lifesteal: 20,
|
|
life_leech: 15,
|
|
item_parasite: 10,
|
|
manasteal: 25,
|
|
mana_leech: 10,
|
|
item_parasitemana: 15,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'pal_smiter',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 50000 * FIXED_ONE,
|
|
maxhp: 50000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const defender: CombatUnitContext = {
|
|
id: 'target_dummy',
|
|
name: 'Target Dummy',
|
|
statList: defenderStats,
|
|
stateBus: new StateBus(defenderStats, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
// Smite attack dealing 1000 physical damage (srcDam: 0 for weapon physical damage)
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 1000 * FIXED_ONE,
|
|
flatPhysMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
unblockable: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.physDamage256).toBe(1000 * FIXED_ONE)
|
|
expect(out.attackerHealed256 ?? 0).toBe(0)
|
|
expect(out.attackerManaLeeched256 ?? 0).toBe(0)
|
|
expect(attackerStats.getHp256()).toBe(initialHp)
|
|
expect(attackerStats.getMana256()).toBe(initialMana)
|
|
})
|
|
|
|
it('contrast test: regular non-Smite attack leeches life and mana normally with same gear', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const initialHp = 500 * FIXED_ONE
|
|
const initialMana = 300 * FIXED_ONE
|
|
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 2000 * FIXED_ONE,
|
|
mana: initialMana,
|
|
maxmana: 1000 * FIXED_ONE,
|
|
lifesteal: 20, // 20% life leech
|
|
manasteal: 10, // 10% mana leech
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'pal_standard_attacker',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
weaponMinPhys: 0,
|
|
weaponMaxPhys: 0,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 50000 * FIXED_ONE,
|
|
maxhp: 50000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const defender: CombatUnitContext = {
|
|
id: 'target_dummy',
|
|
name: 'Target Dummy',
|
|
statList: defenderStats,
|
|
stateBus: new StateBus(defenderStats, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
// Standard melee attack dealing 500 physical damage
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 0, // Standard Attack
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
flatPhysMin256: 500 * FIXED_ONE,
|
|
flatPhysMax256: 500 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.physDamage256).toBe(500 * FIXED_ONE)
|
|
// 20% of 500 = 100 HP healed
|
|
expect(out.attackerHealed256).toBe(100 * FIXED_ONE)
|
|
// 10% of 500 = 50 Mana leeched
|
|
expect(out.attackerManaLeeched256).toBe(50 * FIXED_ONE)
|
|
expect(attackerStats.getHp256()).toBe(initialHp + 100 * FIXED_ONE)
|
|
expect(attackerStats.getMana256()).toBe(initialMana + 50 * FIXED_ONE)
|
|
})
|
|
|
|
it('suppresses gear leech across an adversarial sweep of high leech percentages (1% to 200%)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const testPcts = [1, 5, 10, 25, 50, 100, 200]
|
|
|
|
for (const pct of testPcts) {
|
|
const initialHp = 1000 * FIXED_ONE
|
|
const initialMana = 500 * FIXED_ONE
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 10000 * FIXED_ONE,
|
|
mana: initialMana,
|
|
maxmana: 5000 * FIXED_ONE,
|
|
lifesteal: pct,
|
|
manasteal: pct,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: `pal_sweep_${pct}`,
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 20000 * FIXED_ONE,
|
|
maxhp: 20000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const defender: CombatUnitContext = {
|
|
id: `dummy_${pct}`,
|
|
name: 'Dummy',
|
|
statList: defenderStats,
|
|
stateBus: new StateBus(defenderStats, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 800 * FIXED_ONE,
|
|
flatPhysMax256: 800 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.attackerHealed256 ?? 0).toBe(0)
|
|
expect(out.attackerManaLeeched256 ?? 0).toBe(0)
|
|
expect(attackerStats.getHp256()).toBe(initialHp)
|
|
expect(attackerStats.getMana256()).toBe(initialMana)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 2: Life Tap Curse Healing Preservation on Smite
|
|
// ==========================================================================
|
|
describe('Pillar 2: Life Tap Curse Healing Preservation on Smite', () => {
|
|
it('restores exactly 50% of physical damage dealt as HP when defender is cursed with Life Tap', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const initialHp = 300 * FIXED_ONE
|
|
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 2000 * FIXED_ONE,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'pal_smiter_lt',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 10000 * FIXED_ONE,
|
|
maxhp: 10000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const defenderBus = new StateBus(defenderStats, registry)
|
|
defenderBus.applyState({ stateNameOrId: 'lifetap', slvl: 1, durationFrames: 500 })
|
|
|
|
const defender: CombatUnitContext = {
|
|
id: 'dummy_lt',
|
|
name: 'Dummy',
|
|
statList: defenderStats,
|
|
stateBus: defenderBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 600 * FIXED_ONE,
|
|
flatPhysMax256: 600 * FIXED_ONE,
|
|
autoHit: true,
|
|
unblockable: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.physDamage256).toBe(600 * FIXED_ONE)
|
|
// 50% of 600 = 300 HP healed
|
|
expect(out.attackerHealed256).toBe(300 * FIXED_ONE)
|
|
expect(attackerStats.getHp256()).toBe(initialHp + 300 * FIXED_ONE)
|
|
})
|
|
|
|
it('when attacker has BOTH gear leech (50%) AND target has Life Tap, heals strictly 50% from Life Tap (gear leech is 0)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const initialHp = 400 * FIXED_ONE
|
|
const initialMana = 200 * FIXED_ONE
|
|
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 3000 * FIXED_ONE,
|
|
mana: initialMana,
|
|
maxmana: 1000 * FIXED_ONE,
|
|
lifesteal: 50, // 50% gear life leech
|
|
manasteal: 50, // 50% gear mana leech
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'pal_smiter_both',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 10000 * FIXED_ONE,
|
|
maxhp: 10000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const defenderBus = new StateBus(defenderStats, registry)
|
|
defenderBus.applyState({ stateNameOrId: 'lifetap', slvl: 5, durationFrames: 500 })
|
|
|
|
const defender: CombatUnitContext = {
|
|
id: 'dummy_both',
|
|
name: 'Dummy',
|
|
statList: defenderStats,
|
|
stateBus: defenderBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 800 * FIXED_ONE,
|
|
flatPhysMax256: 800 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.physDamage256).toBe(800 * FIXED_ONE)
|
|
// Must heal ONLY 50% (400), NOT 50% + 50% (800)
|
|
expect(out.attackerHealed256).toBe(400 * FIXED_ONE)
|
|
// Mana leech must remain strictly 0
|
|
expect(out.attackerManaLeeched256 ?? 0).toBe(0)
|
|
expect(attackerStats.getHp256()).toBe(initialHp + 400 * FIXED_ONE)
|
|
expect(attackerStats.getMana256()).toBe(initialMana)
|
|
})
|
|
|
|
it('heals 0 HP when striking a Physical Immune target cursed with Life Tap (damage dealt = 0)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const initialHp = 500 * FIXED_ONE
|
|
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 2000 * FIXED_ONE,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'pal_smiter_pi',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 10000 * FIXED_ONE,
|
|
maxhp: 10000 * FIXED_ONE,
|
|
damageresist: 100, // Physical Immune
|
|
})
|
|
const defenderBus = new StateBus(defenderStats, registry)
|
|
defenderBus.applyState({ stateNameOrId: 'lifetap', slvl: 1, durationFrames: 500 })
|
|
|
|
const defender: CombatUnitContext = {
|
|
id: 'dummy_pi',
|
|
name: 'Dummy PI',
|
|
statList: defenderStats,
|
|
stateBus: defenderBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 1000 * FIXED_ONE,
|
|
flatPhysMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.physDamage256).toBe(0)
|
|
expect(out.immuneToPhys).toBe(true)
|
|
expect(out.attackerHealed256 ?? 0).toBe(0)
|
|
expect(attackerStats.getHp256()).toBe(initialHp)
|
|
})
|
|
|
|
it('heals exactly 50% of reduced physical damage when target has 50% Physical Resistance', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const initialHp = 500 * FIXED_ONE
|
|
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: initialHp,
|
|
maxhp: 2000 * FIXED_ONE,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'pal_smiter_res',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 10000 * FIXED_ONE,
|
|
maxhp: 10000 * FIXED_ONE,
|
|
damageresist: 50, // 50% Physical Resistance
|
|
})
|
|
const defenderBus = new StateBus(defenderStats, registry)
|
|
defenderBus.applyState({ stateNameOrId: 'lifetap', slvl: 1, durationFrames: 500 })
|
|
|
|
const defender: CombatUnitContext = {
|
|
id: 'dummy_res',
|
|
name: 'Dummy 50% DR',
|
|
statList: defenderStats,
|
|
stateBus: defenderBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 1000 * FIXED_ONE,
|
|
flatPhysMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
// 1000 raw -> 500 final phys damage
|
|
expect(out.physDamage256).toBe(500 * FIXED_ONE)
|
|
// 50% of 500 = 250 HP healed
|
|
expect(out.attackerHealed256).toBe(250 * FIXED_ONE)
|
|
expect(attackerStats.getHp256()).toBe(initialHp + 250 * FIXED_ONE)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 3: Holy Shield Blocking Chance Parity (dm56)
|
|
// ==========================================================================
|
|
describe('Pillar 3: Holy Shield Blocking Chance Parity (dm56)', () => {
|
|
// Exact D2 1.13c dm56 oracle
|
|
function dm56Oracle(lvl: number): number {
|
|
const l = Math.max(1, lvl)
|
|
return 10 + Math.floor((3300 * l) / (100 * (l + 6)))
|
|
}
|
|
|
|
it('verifies 100% exact parity with dm56 oracle across slvls 1 through 50', () => {
|
|
const mismatches: { slvl: number; hs: number; oracle: number }[] = []
|
|
for (let slvl = 1; slvl <= 50; slvl++) {
|
|
const hs = calculateHolyShieldStats(slvl).blockChancePct
|
|
const oracle = dm56Oracle(slvl)
|
|
const calcAst = computeDiminishingReturns(10, 40, slvl)
|
|
if (hs !== oracle || hs !== calcAst) {
|
|
mismatches.push({ slvl, hs, oracle })
|
|
}
|
|
}
|
|
expect(mismatches).toEqual([])
|
|
})
|
|
|
|
it('specifically validates critical anchor levels: slvl 1 = 14% and slvl 20 = 35%', () => {
|
|
// slvl 1: 10 + Math.floor(3300*1 / 700) = 10 + 4 = 14% (NOT 13%)
|
|
expect(calculateHolyShieldStats(1).blockChancePct).toBe(14)
|
|
// slvl 20: 10 + Math.floor(3300*20 / 2600) = 10 + Math.floor(66000 / 2600) = 10 + 25 = 35% (NOT 36%)
|
|
expect(calculateHolyShieldStats(20).blockChancePct).toBe(35)
|
|
})
|
|
|
|
it('verifies runtime executeSkillCore113c applies correct toblock stat to caster', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, {})
|
|
const casterBus = new StateBus(stats, registry)
|
|
const caster: CombatUnitContext = {
|
|
id: 'hs_pal',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: casterBus,
|
|
unitType: 'player',
|
|
hasShield: true,
|
|
}
|
|
|
|
// Cast Holy Shield slvl 1
|
|
const ctx1 = createExecContext(registry, 117, 1, caster)
|
|
const res1 = executeSkillCore113c(ctx1)
|
|
expect(res1.executed).toBe(true)
|
|
expect(stats.getModifierBonus('toblock')).toBe(14)
|
|
|
|
// Cast Holy Shield slvl 20
|
|
const ctx20 = createExecContext(registry, 117, 20, caster)
|
|
const res20 = executeSkillCore113c(ctx20)
|
|
expect(res20.executed).toBe(true)
|
|
expect(stats.getModifierBonus('toblock')).toBe(35)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 4: SrvSt Pre-Cast Equipment Guards & Tri-State Semantics
|
|
// ==========================================================================
|
|
describe('Pillar 4: SrvSt Pre-Cast Equipment Guards & Tri-State Semantics', () => {
|
|
it('Smite (#97) and Holy Shield (#117) fail fast when hasShield === false', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, { mana: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE })
|
|
const unshieldedCaster: CombatUnitContext = {
|
|
id: 'pal_no_shield',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'player',
|
|
hasShield: false,
|
|
}
|
|
|
|
// 1. Holy Shield (#117)
|
|
const hsCtx = createExecContext(registry, 117, 1, unshieldedCaster)
|
|
const hsRes = executeSkillCore113c(hsCtx)
|
|
expect(hsRes.executed).toBe(false)
|
|
expect(hsRes.manaSpent256).toBe(0)
|
|
expect(hsRes.notes).toContain('fail: requires equipped shield')
|
|
|
|
// 2. Smite (#97) via executeSkillCore113c
|
|
const smiteCtx = createExecContext(registry, 97, 1, unshieldedCaster)
|
|
const smiteCoreRes = executeSkillCore113c(smiteCtx)
|
|
expect(smiteCoreRes.executed).toBe(false)
|
|
expect(smiteCoreRes.manaSpent256).toBe(0)
|
|
expect(smiteCoreRes.notes).toContain('fail: requires equipped shield')
|
|
|
|
// 3. Smite (#97) via executeSUnitDmg
|
|
const dummyDef: CombatUnitContext = {
|
|
id: 'dummy',
|
|
name: 'Dummy',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'monster',
|
|
}
|
|
const smiteDmgRes = executeSUnitDmg(unshieldedCaster, dummyDef, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(smiteDmgRes.hit).toBe(false)
|
|
expect(smiteDmgRes.avoidedReason).toBe('miss')
|
|
expect(smiteDmgRes.totalDamage256).toBe(0)
|
|
})
|
|
|
|
it('Sacrifice (#96) and Zeal (#106) fail fast when weaponItemType is bow or crossbow or isRanged is true', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, { mana: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE })
|
|
|
|
const dummyDef: CombatUnitContext = {
|
|
id: 'dummy',
|
|
name: 'Dummy',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const invalidRangedAttackers: CombatUnitContext[] = [
|
|
{
|
|
id: 'bow_pal',
|
|
name: 'Paladin Bow',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'player',
|
|
weaponItemType: 'bow',
|
|
},
|
|
{
|
|
id: 'xbow_pal',
|
|
name: 'Paladin Xbow',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'player',
|
|
weaponItemType: 'crossbow',
|
|
},
|
|
{
|
|
id: 'ranged_pal',
|
|
name: 'Paladin Ranged Flag',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'player',
|
|
isRanged: true,
|
|
},
|
|
]
|
|
|
|
for (const attacker of invalidRangedAttackers) {
|
|
// Sacrifice (#96) in registry
|
|
const sacCtx = createExecContext(registry, 96, 1, attacker)
|
|
const sacRes = executeSkillCore113c(sacCtx)
|
|
expect(sacRes.executed).toBe(false)
|
|
expect(sacRes.manaSpent256).toBe(0)
|
|
expect(sacRes.notes).toContain('fail: requires melee weapon')
|
|
|
|
// Zeal (#106) in registry
|
|
const zealCtx = createExecContext(registry, 106, 1, attacker)
|
|
const zealRes = executeSkillCore113c(zealCtx)
|
|
expect(zealRes.executed).toBe(false)
|
|
expect(zealRes.manaSpent256).toBe(0)
|
|
expect(zealRes.notes).toContain('fail: requires melee weapon')
|
|
|
|
// Sacrifice (#96) in combat pipeline
|
|
const sacDmgRes = executeSUnitDmg(attacker, dummyDef, {
|
|
skillId: 96,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(sacDmgRes.hit).toBe(false)
|
|
expect(sacDmgRes.avoidedReason).toBe('miss')
|
|
|
|
// Zeal (#106) in combat pipeline
|
|
const zealDmgRes = executeSUnitDmg(attacker, dummyDef, {
|
|
skillId: 106,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
})
|
|
expect(zealDmgRes.hit).toBe(false)
|
|
expect(zealDmgRes.avoidedReason).toBe('miss')
|
|
}
|
|
})
|
|
|
|
it('permissive fallback: undefined/omitted properties permit execution for legacy test fixtures', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, { mana: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE })
|
|
const bareAttacker: CombatUnitContext = {
|
|
id: 'bare_pal',
|
|
name: 'Paladin Bare',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'player',
|
|
// hasShield is undefined, weaponItemType is undefined
|
|
}
|
|
|
|
// Smite (#97) executes when hasShield is undefined
|
|
const smiteCtx = createExecContext(registry, 97, 1, bareAttacker)
|
|
const smiteRes = executeSkillCore113c(smiteCtx)
|
|
expect(smiteRes.executed).toBe(true)
|
|
|
|
// Holy Shield (#117) executes when hasShield is undefined
|
|
const hsCtx = createExecContext(registry, 117, 1, bareAttacker)
|
|
const hsRes = executeSkillCore113c(hsCtx)
|
|
expect(hsRes.executed).toBe(true)
|
|
|
|
// Sacrifice (#96) executes when weaponItemType is undefined
|
|
const sacCtx = createExecContext(registry, 96, 1, bareAttacker)
|
|
const sacRes = executeSkillCore113c(sacCtx)
|
|
expect(sacRes.executed).toBe(true)
|
|
|
|
// Zeal (#106) executes when weaponItemType is undefined
|
|
const zealCtx = createExecContext(registry, 106, 1, bareAttacker)
|
|
const zealRes = executeSkillCore113c(zealCtx)
|
|
expect(zealRes.executed).toBe(true)
|
|
|
|
// validatePaladinSkillEquipment helper truth table
|
|
expect(validatePaladinSkillEquipment(97, undefined).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(117, undefined).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(96, undefined).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(106, undefined).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(97, { hasShield: true }).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(97, { hasShield: false }).valid).toBe(false)
|
|
expect(validatePaladinSkillEquipment(117, { hasShield: true }).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(117, { hasShield: false }).valid).toBe(false)
|
|
expect(validatePaladinSkillEquipment(96, { weaponItemType: 'sword' }).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(96, { weaponItemType: 'bow' }).valid).toBe(false)
|
|
expect(validatePaladinSkillEquipment(106, { weaponItemType: 'axe' }).valid).toBe(true)
|
|
expect(validatePaladinSkillEquipment(106, { weaponItemType: 'bow' }).valid).toBe(false)
|
|
})
|
|
})
|
|
})
|