381 lines
16 KiB
TypeScript
381 lines
16 KiB
TypeScript
/**
|
|
* Adversarial Stress & Edge Case Verification Suite — Paladin Offensive Auras (10 Skills).
|
|
*
|
|
* 5 Critical Edge Scenarios:
|
|
* 1. Conviction Immunity-Breaking Thresholds (100%, 110%, 140%, 160% resistance; zero poison/magic pierce)
|
|
* 2. Sanctuary vs Physical Immune Undead (0% effective phys res on Undead; living/demons untouched)
|
|
* 3. Thorns Overkill & PvP 1/4th Penalty (10,000 dmg, monster mitigation, PvP 75% cut, 0 on immune)
|
|
* 4. Holy Freeze CBF Bypass & 2:1 Isometric Geometry (dx=100, dy=50 in range vs dx=100, dy=100 out)
|
|
* 5. Fanaticism Asymmetric Party Scaling & Rapid Aura Switching (1 Pal + 3 allies, 100 frame swap loop)
|
|
*/
|
|
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 { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
|
|
import {
|
|
computeEffectiveResistance,
|
|
executeSUnitDmg,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
|
|
describe('Paladin Offensive Auras — Adversarial Stress & Edge Cases', () => {
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 1: Conviction Immunity-Breaking Thresholds
|
|
// --------------------------------------------------------------------------
|
|
it('Scenario 1: Conviction breaking thresholds (100%, 110%, 140%, 160%) and Poison/Magic exclusion', 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',
|
|
}
|
|
|
|
// Slvl 25 Conviction has -150% uncapped elemental resistance reduction
|
|
// Against immunities (baseRes >= 100%), applies at 1/5th effectiveness = -30%
|
|
const pierce25 = 150
|
|
|
|
// 1. Monster with 100% Fire Resistance
|
|
const res100 = computeEffectiveResistance({ baseRes: 100, convictionPierce: pierce25 })
|
|
expect(res100.isImmune).toBe(false)
|
|
expect(res100.effectiveRes).toBe(70) // 100 - 30 = 70%
|
|
|
|
// 2. Monster with 110% Lightning Resistance
|
|
const res110 = computeEffectiveResistance({ baseRes: 110, convictionPierce: pierce25 })
|
|
expect(res110.isImmune).toBe(false)
|
|
expect(res110.effectiveRes).toBe(80) // 110 - 30 = 80%
|
|
|
|
// 3. Monster with 140% Cold Resistance (Unbreakable!)
|
|
const res140 = computeEffectiveResistance({ baseRes: 140, convictionPierce: pierce25 })
|
|
expect(res140.isImmune).toBe(true)
|
|
expect(res140.effectiveRes).toBe(100) // 140 - 30 = 110% >= 100 -> Remains Immune!
|
|
|
|
// 4. Monster with 160% Fire Resistance (Unbreakable!)
|
|
const res160 = computeEffectiveResistance({ baseRes: 160, convictionPierce: pierce25 })
|
|
expect(res160.isImmune).toBe(true)
|
|
expect(res160.effectiveRes).toBe(100)
|
|
|
|
// 5. Conviction NEVER impacts Poison or Magic Resistance
|
|
const monsterStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
poisonresist: 50,
|
|
magicresist: 50,
|
|
})
|
|
const monsterBus = new StateBus(monsterStats, registry)
|
|
monsterBus.applyState({
|
|
stateNameOrId: 'conviction',
|
|
slvl: 25,
|
|
stats: { conviction_pierce: 150, item_armor_percent: -90 },
|
|
})
|
|
const monster: CombatUnitContext = {
|
|
id: 'monster_pois_mag',
|
|
name: 'Target',
|
|
statList: monsterStats,
|
|
stateBus: monsterBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
// Poison attack with Conviction on defender
|
|
const poisOut = executeSUnitDmg(paladin, monster, {
|
|
skillId: 0,
|
|
attackKind: 'melee',
|
|
elemType: 'pois',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
// 50% poison resistance applies in full -> 50 damage taken (NOT pierced by Conviction!)
|
|
expect(poisOut.elemDamage256).toBe(50 * FIXED_ONE)
|
|
|
|
// Magic attack with Conviction on defender
|
|
const magOut = executeSUnitDmg(paladin, monster, {
|
|
skillId: 0,
|
|
attackKind: 'spell',
|
|
elemType: 'mag',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
// 50% magic resistance applies in full -> 50 damage taken (NOT pierced by Conviction!)
|
|
expect(magOut.elemDamage256).toBe(50 * FIXED_ONE)
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 2: Sanctuary vs Physical Immune Undead
|
|
// --------------------------------------------------------------------------
|
|
it('Scenario 2: Sanctuary pierces 100% and 150% Physical Immune Undead while leaving living/demons immune', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
|
|
const palBus = new StateBus(palStats, registry)
|
|
palBus.applyState({ stateNameOrId: 'sanctuary', slvl: 10 }) // +420% ED vs Undead
|
|
|
|
const paladin: CombatUnitContext = {
|
|
id: 'paladin',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: palBus,
|
|
unitType: 'player',
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
|
|
// 1. Ghost (100% Physical Immune Undead)
|
|
const ghostStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 100 })
|
|
const ghost: CombatUnitContext = {
|
|
id: 'ghost_100',
|
|
name: 'Ghost (100% Phys Res)',
|
|
statList: ghostStats,
|
|
stateBus: new StateBus(ghostStats, registry),
|
|
unitType: 'monster',
|
|
isUndead: true,
|
|
}
|
|
|
|
const outGhost100 = executeSUnitDmg(paladin, ghost, { skillId: 0, attackKind: 'melee', autoHit: true })
|
|
expect(outGhost100.immuneToPhys).toBe(false)
|
|
// 100 base * (100 + 420)% = 520 physical damage dealt with 0% resistance!
|
|
expect(outGhost100.physDamage256).toBe(520 * FIXED_ONE)
|
|
|
|
// 2. Stone Skin Ghost (150% Physical Immune Undead)
|
|
const stoneSkinGhostStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 150 })
|
|
const stoneSkinGhost: CombatUnitContext = {
|
|
id: 'ghost_150',
|
|
name: 'Stone Skin Ghost (150% Phys Res)',
|
|
statList: stoneSkinGhostStats,
|
|
stateBus: new StateBus(stoneSkinGhostStats, registry),
|
|
unitType: 'monster',
|
|
isUndead: true,
|
|
}
|
|
|
|
const outGhost150 = executeSUnitDmg(paladin, stoneSkinGhost, { skillId: 0, attackKind: 'melee', autoHit: true })
|
|
expect(outGhost150.immuneToPhys).toBe(false)
|
|
expect(outGhost150.physDamage256).toBe(520 * FIXED_ONE)
|
|
|
|
// 3. Stone Skin Demon (100% Phys Res, isDemon: true, isUndead: false) -> Sanctuary does NOT pierce!
|
|
const demonStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 100 })
|
|
const demon: CombatUnitContext = {
|
|
id: 'demon_100',
|
|
name: 'Demon (100% Phys Res)',
|
|
statList: demonStats,
|
|
stateBus: new StateBus(demonStats, registry),
|
|
unitType: 'monster',
|
|
isDemon: true,
|
|
isUndead: false,
|
|
}
|
|
|
|
const outDemon = executeSUnitDmg(paladin, demon, { skillId: 0, attackKind: 'melee', autoHit: true })
|
|
expect(outDemon.immuneToPhys).toBe(true)
|
|
expect(outDemon.physDamage256).toBe(0)
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 3: Thorns Overkill & PvP 1/4th Penalty
|
|
// --------------------------------------------------------------------------
|
|
it('Scenario 3: Thorns massive overkill reflection, attacker physical resistance, and PvP 1/4th penalty', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
|
|
// Defender with Slvl 20 Thorns (1010% reflection)
|
|
const defStats = new UnitStatList(registry, { maxhp: 50000 * FIXED_ONE, hitpoints: 50000 * FIXED_ONE, damageresist: 0 })
|
|
const defBus = new StateBus(defStats, registry)
|
|
defBus.applyState({ stateNameOrId: 'thorns', slvl: 20 })
|
|
|
|
const paladinDef: CombatUnitContext = {
|
|
id: 'paladin_thorns',
|
|
name: 'Thorns Paladin',
|
|
statList: defStats,
|
|
stateBus: defBus,
|
|
unitType: 'player',
|
|
}
|
|
|
|
// 1. Monster attacks for 10,000 melee physical damage
|
|
const atkStats = new UnitStatList(registry, { maxhp: 200000 * FIXED_ONE, hitpoints: 200000 * FIXED_ONE, damageresist: 0 })
|
|
const monsterAtk: CombatUnitContext = {
|
|
id: 'massive_monster',
|
|
name: 'Massive Monster',
|
|
statList: atkStats,
|
|
stateBus: new StateBus(atkStats, registry),
|
|
unitType: 'monster',
|
|
weaponMinPhys: 10000,
|
|
weaponMaxPhys: 10000,
|
|
}
|
|
|
|
const outMassive = executeSUnitDmg(monsterAtk, paladinDef, { skillId: 0, attackKind: 'melee', autoHit: true })
|
|
// 10,000 * 1010% = 101,000 physical damage reflected!
|
|
expect(outMassive.reflectedPhys256).toBe(101000 * FIXED_ONE)
|
|
expect(monsterAtk.statList.getHp256()).toBe((200000 - 101000) * FIXED_ONE)
|
|
|
|
// 2. PvP Penalty: Player attacker attacks for 10,000 damage
|
|
const playerAtkStats = new UnitStatList(registry, { maxhp: 200000 * FIXED_ONE, hitpoints: 200000 * FIXED_ONE, damageresist: 0 })
|
|
const playerAtk: CombatUnitContext = {
|
|
id: 'player_atk',
|
|
name: 'Player Attacker',
|
|
statList: playerAtkStats,
|
|
stateBus: new StateBus(playerAtkStats, registry),
|
|
unitType: 'player',
|
|
weaponMinPhys: 10000,
|
|
weaponMaxPhys: 10000,
|
|
}
|
|
|
|
const outPvP = executeSUnitDmg(playerAtk, paladinDef, { skillId: 0, attackKind: 'melee', autoHit: true })
|
|
// In PvP, 1/4th penalty applies: Math.trunc(101,000 / 4) = 25,250 reflected!
|
|
expect(outPvP.reflectedPhys256).toBe(25250 * FIXED_ONE)
|
|
|
|
// 3. Physical Immune Attacker: Takes strictly 0 reflected damage!
|
|
const immuneAtkStats = new UnitStatList(registry, { maxhp: 50000 * FIXED_ONE, hitpoints: 50000 * FIXED_ONE, damageresist: 100 })
|
|
const immuneAtk: CombatUnitContext = {
|
|
id: 'immune_atk',
|
|
name: 'Physical Immune Attacker',
|
|
statList: immuneAtkStats,
|
|
stateBus: new StateBus(immuneAtkStats, registry),
|
|
unitType: 'monster',
|
|
weaponMinPhys: 1000,
|
|
weaponMaxPhys: 1000,
|
|
}
|
|
|
|
const outImmune = executeSUnitDmg(immuneAtk, paladinDef, { skillId: 0, attackKind: 'melee', autoHit: true })
|
|
expect(outImmune.reflectedPhys256).toBeUndefined()
|
|
expect(immuneAtk.statList.getHp256()).toBe(50000 * FIXED_ONE)
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 4: Holy Freeze CBF Bypass & 2:1 Isometric Geometry
|
|
// --------------------------------------------------------------------------
|
|
it('Scenario 4: Holy Freeze CBF bypass and 2:1 isometric ground space range check', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
// Paladin at origin (0, 0)
|
|
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',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
// Enemy 1: In range in 2:1 iso ground space: (dx=100, dy=50)
|
|
// Math.hypot(100, 50 * 2) = Math.hypot(100, 100) = 141.42 <= 200
|
|
const e1Stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, cannot_be_frozen: 1 })
|
|
const enemy1: CombatUnitContext = {
|
|
id: 'enemy_in_range',
|
|
name: 'In Range CBF Enemy',
|
|
statList: e1Stats,
|
|
stateBus: new StateBus(e1Stats, registry),
|
|
unitType: 'monster',
|
|
x: 100,
|
|
y: 50,
|
|
}
|
|
|
|
// Enemy 2: Out of range in 2:1 iso ground space: (dx=100, dy=100)
|
|
// Math.hypot(100, 100 * 2) = Math.hypot(100, 200) = 223.6 > 200
|
|
const e2Stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, cannot_be_frozen: 1 })
|
|
const enemy2: CombatUnitContext = {
|
|
id: 'enemy_out_of_range',
|
|
name: 'Out of Range CBF Enemy',
|
|
statList: e2Stats,
|
|
stateBus: new StateBus(e2Stats, registry),
|
|
unitType: 'monster',
|
|
x: 100,
|
|
y: 100,
|
|
}
|
|
|
|
// Slvl 10 Holy Freeze: radiusSubtiles = 15 -> radiusPx = Math.round(15 * 40/3) = 200 px
|
|
const auraSource = scanner.setActiveAura(paladin, 114, 10)!
|
|
expect(auraSource).toBeDefined()
|
|
|
|
scanner.pulseAura(auraSource, 0, [paladin], [enemy1, enemy2])
|
|
|
|
// Enemy 1 is in range and slowed (bypassing CBF!)
|
|
expect(enemy1.stateBus.hasState('holywindcold')).toBe(true)
|
|
expect(enemy1.statList.getModifierBonus('item_slow')).toBe(46)
|
|
|
|
// Enemy 2 is out of range and NOT affected
|
|
expect(enemy2.stateBus.hasState('holywindcold')).toBe(false)
|
|
expect(enemy2.statList.getModifierBonus('item_slow')).toBe(0)
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 5: Fanaticism Asymmetric Party Scaling & Rapid Aura Switching
|
|
// --------------------------------------------------------------------------
|
|
it('Scenario 5: Fanaticism asymmetric party scaling and rapid aura switching stability', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
// Paladin Caster
|
|
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',
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
|
|
// 3 Allies
|
|
const allies: CombatUnitContext[] = [1, 2, 3].map(i => {
|
|
const s = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
|
|
return {
|
|
id: `ally_${i}`,
|
|
name: `Ally ${i}`,
|
|
statList: s,
|
|
stateBus: new StateBus(s, registry),
|
|
unitType: 'party',
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
})
|
|
|
|
// Activate Slvl 20 Fanaticism (Caster: 373% ED, Allies: 186% ED)
|
|
const fanatAura = scanner.setActiveAura(paladin, 122, 20)!
|
|
scanner.pulseAura(fanatAura, 0, [paladin, ...allies], [])
|
|
|
|
// Asserts Paladin receives full %ED (373%) and allies receive exactly half %ED (186%)
|
|
expect(paladin.stateBus.hasState('fanaticism')).toBe(true)
|
|
for (const ally of allies) {
|
|
expect(ally.stateBus.hasState('fanaticism')).toBe(true)
|
|
expect(ally.statList.getModifierBonus('damagepercent')).toBe(186)
|
|
}
|
|
|
|
// Simulate switching between Fanaticism (122), Conviction (123), and Might (98) over 100 consecutive frames
|
|
const auraCycle = [122, 123, 98]
|
|
for (let frame = 1; frame <= 100; frame++) {
|
|
const nextAuraId = auraCycle[frame % 3]!
|
|
scanner.clearUnitAuras(paladin.id)
|
|
paladin.stateBus.removeState('fanaticism')
|
|
paladin.stateBus.removeState('conviction')
|
|
paladin.stateBus.removeState('might')
|
|
for (const ally of allies) {
|
|
ally.stateBus.removeState('fanaticism')
|
|
ally.stateBus.removeState('conviction')
|
|
ally.stateBus.removeState('might')
|
|
}
|
|
|
|
const active = scanner.setActiveAura(paladin, nextAuraId, 10, frame)!
|
|
scanner.pulseAura(active, frame, [paladin, ...allies], [])
|
|
}
|
|
|
|
// Final state check: last frame (100 % 3 = 1 -> Conviction 123)
|
|
// Paladin must have active Conviction visual without debuff
|
|
expect(paladin.stateBus.hasState('conviction')).toBe(true)
|
|
expect(paladin.statList.getModifierBonus('item_armor_percent')).toBe(0)
|
|
expect(paladin.statList.getModifierBonus('conviction_pierce')).toBe(0)
|
|
|
|
// No lingering Fanaticism or Might modifiers remain
|
|
expect(paladin.stateBus.hasState('fanaticism')).toBe(false)
|
|
expect(paladin.stateBus.hasState('might')).toBe(false)
|
|
for (const ally of allies) {
|
|
expect(ally.stateBus.hasState('fanaticism')).toBe(false)
|
|
expect(ally.stateBus.hasState('might')).toBe(false)
|
|
expect(ally.statList.getModifierBonus('damagepercent')).toBe(0)
|
|
}
|
|
})
|
|
})
|