diablo2-web/tests/skills/nec/adversarial-nec-stress.test.ts

749 lines
28 KiB
TypeScript

/**
* Adversarial Challenger Stress & Boundary Suite: Necromancer Summoning & Curses
* Cohort 3 Parity Stress-Testing (IDs 66..75, 86..95)
*
* Ground Truth Invariants Tested:
* 1. Skeleton Max Count: Exact formula across slvl 1..50 (slvl 1->1, 2->2, 3->3, 4->3, 6->4, 9->5, 12->6, 15->7, 18->8, 20->8, 30->12, 50->18).
* Skeleton Mastery: +8 life/lvl, +2 dmg/lvl, +10 def/lvl, +5% revive life/lvl, +10% damage/lvl.
* 2. Skeletal Mages: 4 elemental variants (Fire, Cold, Lightning, Poison) generate correct missile types, damage, and duration scaling.
* 3. Golems:
* - Clay Golem: slow-on-hit percentage formula min(75, floor(11 + 40*(slvl-1)/(slvl+6))), slow attacker when hit.
* - Blood Golem: 70% life steal to player, 30% damage transfer to master.
* - Iron Golem: inherits item properties and stats from targeted metallic item, innate Thorns reflection.
* - Fire Golem: Holy Fire radial aura and 100% fire absorb healing.
* - Golem exclusivity: Exactly 1 active golem allowed across all 4 types.
* 4. Revive: 180.0s (4500 frames) lifetime, +200% life (+5%/lvl SM), +215% damage (+10%/lvl SM), pet cap equals slvl.
* 5. Curse Exclusivity: Casting any cursetype=1 curse cleanly overwrites prior cursetype=1 curse without stat leakage across 100 cycles.
* 6. Attract Immunity: A monster affected by Attract (cursetype=2) CANNOT have Attract overwritten by any other curse.
* 7. Lower Resist Immunity Breaking: When base resistance >= 100%, Lower Resist applies at 1/5th efficiency.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
calculateSkeletonMaxCount,
calculateSkeletalMageMaxCount,
calculateReviveMaxCount,
calculateSkeletonMasteryStats,
calculateGolemMasteryStats,
calculateSummonResistPct,
calculateClayGolemStats,
calculateBloodGolemStats,
calculateIronGolemStats,
calculateFireGolemStats,
calculateReviveStats,
calculateCurseEffect,
calculateCurseDurationFrames,
calculateLowerResistReduction,
computeImmunityBreak,
type IronGolemItemProps,
} from '../../../src/game/skills.ts'
import {
computeEffectiveResistance,
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { SummonManager, type SummonedPetUnit } from '../../../src/game/engine/summon-manager.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
describe('Adversarial Challenger: Necromancer Summoning & Curses Stress Suite', () => {
let registry: any
beforeEach(async () => {
registry = await getSharedDataRegistry()
})
// =========================================================================
// Target 1: Skeleton Max Count Formula (slvl 1..50) & Skeleton Mastery
// =========================================================================
describe('Target 1: Skeleton Max Count & Skeleton Mastery Scaling', () => {
it('verifies exact 1.13c max skeleton count across all skill levels from 1 to 50', () => {
// Oracle: slvl < 4 ? slvl : 2 + Math.floor(slvl / 3)
const expectedMilestones: Record<number, number> = {
1: 1,
2: 2,
3: 3,
4: 3,
5: 3,
6: 4,
7: 4,
8: 4,
9: 5,
10: 5,
11: 5,
12: 6,
13: 6,
14: 6,
15: 7,
16: 7,
17: 7,
18: 8,
19: 8,
20: 8,
21: 9,
24: 10,
27: 11,
30: 12,
40: 15,
50: 18,
}
for (let slvl = 1; slvl <= 50; slvl++) {
const actual = calculateSkeletonMaxCount(slvl)
const expected = slvl < 4 ? slvl : 2 + Math.floor(slvl / 3)
expect(actual).toBe(expected)
if (expectedMilestones[slvl] !== undefined) {
expect(actual).toBe(expectedMilestones[slvl])
}
}
// Edge case: invalid/zero/negative levels
expect(calculateSkeletonMaxCount(0)).toBe(0)
expect(calculateSkeletonMaxCount(-5)).toBe(0)
expect(calculateSkeletonMaxCount(NaN)).toBe(0)
})
it('verifies Skeleton Mastery linear bonuses: +8 life/lvl, +2 dmg/lvl, +10 def/lvl', () => {
for (let slvl = 1; slvl <= 40; slvl++) {
const stats = calculateSkeletonMasteryStats(slvl)
expect(stats.skeletonHp).toBe(8 * slvl)
expect(stats.skeletonDamage).toBe(2 * slvl)
expect(stats.defense).toBe(10 * slvl)
expect(stats.reviveHpPct).toBe(5 * slvl)
expect(stats.damagePct).toBe(10 * slvl)
}
// Edge case: slvl 0 returns zeroed stats
const zero = calculateSkeletonMasteryStats(0)
expect(zero.skeletonHp).toBe(0)
expect(zero.skeletonDamage).toBe(0)
expect(zero.defense).toBe(0)
})
it('stress-tests SummonManager pet cap and Mastery scaling in actual summon creation', () => {
const sm = new SummonManager(registry)
const playerStats = new UnitStatList(registry, {
level: 80,
})
// Give slvl 20 Skeleton Mastery (Skill 69)
playerStats.setBaseSkillLevel(69, 20)
const owner: CombatUnitContext = {
id: 'player-necro',
name: 'Necromancer',
statList: playerStats,
stateBus: new StateBus(playerStats, registry),
}
// Prepare 20 corpses
const corpses = Array.from({ length: 20 }, (_, i) => ({
id: `corpse-${i}`,
consumed: false,
baseMaxHp: 500,
isBoss: false,
}))
// At slvl 20 Raise Skeleton, max count is 8
for (let i = 0; i < 15; i++) {
const outcome = sm.createPet({
owner,
skillId: 70, // Raise Skeleton
slvl: 20,
corpses,
})
expect(outcome.created).toBe(true)
}
const activeSkeletons = sm.getActivePets().filter(p => p.kind === 'skeleton')
expect(activeSkeletons.length).toBe(8)
// Verify each skeleton's HP includes mastery:
// (base (21) + 20*15 + 8*20) * (1 + 10%*20) = (21 + 300 + 160) * 3 = 481 * 3 = 1443
const sample = activeSkeletons[0]!
expect(sample.maxHp).toBeGreaterThanOrEqual(1400)
})
})
// =========================================================================
// Target 2: Skeletal Mages Elemental Variants & Scaling
// =========================================================================
describe('Target 2: Skeletal Mages 4 Elemental Variants & Missiles', () => {
it('verifies 4 elemental variants (Fire, Cold, Lightning, Poison) generate correct missile types', () => {
const sm = new SummonManager(registry)
const playerStats = new UnitStatList(registry, { level: 80 })
const owner: CombatUnitContext = {
id: 'player-necro',
name: 'Necromancer',
statList: playerStats,
stateBus: new StateBus(playerStats, registry),
}
const corpses = Array.from({ length: 16 }, (_, i) => ({
id: `corpse-${i}`,
consumed: false,
baseMaxHp: 500,
isBoss: false,
}))
// Summon 8 mages (slvl 20 max count is 8)
const elementsFound = new Set<string>()
for (let i = 0; i < 8; i++) {
const outcome = sm.createPet({
owner,
skillId: 80, // Raise Skeletal Mage
slvl: 20,
corpses,
})
expect(outcome.created).toBe(true)
if (outcome.pet?.mageElement) {
elementsFound.add(outcome.pet.mageElement)
}
}
// All 4 elements must be represented in rotation
expect(elementsFound.has('fire')).toBe(true)
expect(elementsFound.has('cold')).toBe(true)
expect(elementsFound.has('lightning')).toBe(true)
expect(elementsFound.has('poison')).toBe(true)
const activeMages = sm.getActivePets().filter(p => p.kind === 'skeleton_mage')
expect(activeMages.length).toBe(8)
})
it('verifies tickPets executes the authentic elemental missile for each mage type', () => {
const sm = new SummonManager(registry)
const playerStats = new UnitStatList(registry, { level: 80 })
playerStats.setBaseSkillLevel(69, 10) // Skeleton Mastery slvl 10 (+20 flat dmg)
const owner: CombatUnitContext = {
id: 'player-necro',
name: 'Necromancer',
statList: playerStats,
stateBus: new StateBus(playerStats, registry),
}
const spawnedMissiles: { missile: string; elem: string; min: number; max: number }[] = []
const mockMissileEngine: any = {
spawnMissile: (params: any) => {
spawnedMissiles.push({
missile: params.missileNameOrId,
elem: params.dmgPacket.elemType,
min: params.dmgPacket.elemMin256,
max: params.dmgPacket.elemMax256,
})
},
}
const corpses = Array.from({ length: 8 }, (_, i) => ({
id: `corpse-${i}`,
consumed: false,
baseMaxHp: 500,
isBoss: false,
}))
// Create 4 mages (one of each element)
for (let i = 0; i < 4; i++) {
sm.createPet({ owner, skillId: 80, slvl: 15, corpses })
}
const enemyStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
const enemy: CombatUnitContext = {
id: 'monster-1',
name: 'Zombie',
statList: enemyStats,
stateBus: new StateBus(enemyStats, registry),
}
const enemyPositions = new Map<string, { x: number; y: number }>()
enemyPositions.set('monster-1', { x: 50, y: 120 })
sm.tickPets({
currentTick: 1,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: mockMissileEngine,
auraScanner: {} as any,
})
expect(spawnedMissiles.length).toBe(4)
const missileNames = spawnedMissiles.map(m => m.missile)
expect(missileNames).toContain('firebolt')
expect(missileNames).toContain('icebolt')
expect(missileNames).toContain('chargedbolt')
expect(missileNames).toContain('poisonexplosioncloud')
// Verify elemental damage scaling: (25 + 15*12 + 20) = 225 min, (45 + 15*18 + 20) = 335 max
for (const m of spawnedMissiles) {
expect(m.min).toBe(225 * FIXED_ONE)
expect(m.max).toBe(335 * FIXED_ONE)
}
})
})
// =========================================================================
// Target 3: Golems Deep Mechanics
// =========================================================================
describe('Target 3: Golems Parity & Stress Testing', () => {
it('verifies Clay Golem slow % formula across slvl 1..100 and attacker slow on hit', () => {
// Formula: min(75, floor(11 + 40 * (slvl - 1) / (slvl + 6)))
const checkpoints: Record<number, number> = {
1: 11,
2: 16,
5: 25,
10: 33,
20: 40,
50: 46,
}
for (let slvl = 1; slvl <= 100; slvl++) {
const stats = calculateClayGolemStats(slvl)
const expected = Math.min(75, Math.floor(11 + (40 * (slvl - 1)) / (slvl + 6)))
expect(stats.slowPct).toBe(expected)
expect(stats.slowPct).toBeLessThanOrEqual(75)
if (checkpoints[slvl] !== undefined) {
expect(stats.slowPct).toBe(checkpoints[slvl])
}
}
// Verify attacker is slowed when hitting Clay Golem in melee
const clayStats = new UnitStatList(registry, {
hitpoints: 500 * FIXED_ONE,
maxhp: 500 * FIXED_ONE,
clay_golem_slow: 40, // 40% slow
})
const clayUnit: CombatUnitContext = {
id: 'clay-golem',
name: 'Clay Golem',
statList: clayStats,
stateBus: new StateBus(clayStats, registry),
}
const attackerStats = new UnitStatList(registry, {
hitpoints: 500 * FIXED_ONE,
maxhp: 500 * FIXED_ONE,
})
const attacker: CombatUnitContext = {
id: 'attacker-monster',
name: 'Fallen',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
}
// Attacker hits Clay Golem in melee
const combatOut = executeSUnitDmg(attacker, clayUnit, {
skillId: 0,
attackKind: 'melee',
flatPhysMin256: 30 * FIXED_ONE,
flatPhysMax256: 30 * FIXED_ONE,
autoHit: true,
})
expect(combatOut.attackerStatesApplied).toContain('slowed')
expect(attacker.stateBus.hasState('slowed')).toBe(true)
expect(attacker.statList.getAccruedStat('item_slow')).toBe(40)
})
it('verifies Blood Golem life link: 70% life steal to master, 30% damage transfer', () => {
const stats = calculateBloodGolemStats(20, 10)
expect(stats.lifeLeechPct).toBe(70)
expect(stats.damageSharePct).toBe(30)
// Test life link healing in SummonManager.tickPets
const sm = new SummonManager(registry)
const playerStats = new UnitStatList(registry, {
hitpoints: 200 * FIXED_ONE,
maxhp: 500 * FIXED_ONE,
})
const owner: CombatUnitContext = {
id: 'player-necro',
name: 'Necromancer',
statList: playerStats,
stateBus: new StateBus(playerStats, registry),
}
const outcome = sm.createPet({ owner, skillId: 85, slvl: 10 }) // Blood Golem
expect(outcome.created).toBe(true)
const enemyStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
const enemy: CombatUnitContext = {
id: 'enemy-1',
name: 'Gargoyle',
statList: enemyStats,
stateBus: new StateBus(enemyStats, registry),
}
const enemyPositions = new Map([['enemy-1', { x: 0, y: 60 }]])
// Tick at tick 10 (when melee attack triggers)
sm.tickPets({
currentTick: 10,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: {} as any,
auraScanner: {} as any,
})
// Master should have been healed by 70% of Blood Golem physical damage
const curHp = playerStats.getHp256() / FIXED_ONE
expect(curHp).toBeGreaterThan(200)
})
it('verifies Iron Golem inherits metallic item properties and has innate Thorns', () => {
const item: IronGolemItemProps = {
baseDamage: 85,
defense: 250,
auras: [119], // Meditation
}
const stats = calculateIronGolemStats(15, item, 10)
expect(stats.inheritedDamage).toBe(85)
expect(stats.inheritedDefense).toBe(250)
expect(stats.inheritedAuras).toEqual([119])
// Thorns return pct: 150 + (15 - 1) * 15 = 150 + 210 = 360%
expect(stats.thornsReturnPct).toBe(360)
})
it('verifies Fire Golem Holy Fire pulse damage and 100% Fire Absorb healing', () => {
const fgStats = calculateFireGolemStats(20, 10)
expect(fgStats.fireAbsorbPct).toBe(100)
expect(fgStats.holyFirePulseDmg).toBe(42) // 2 + 20 * 2 = 42
// Verify Fire Absorb in combat pipeline: Fire damage heals instead of damages
const golemStats = new UnitStatList(registry, {
hitpoints: 300 * FIXED_ONE,
maxhp: 600 * FIXED_ONE,
fireresist: 0,
fire_golem_absorb: 100,
})
const fireGolem: CombatUnitContext = {
id: 'fire-golem',
name: 'Fire Golem',
statList: golemStats,
stateBus: new StateBus(golemStats, registry),
}
const fireAttackerStats = new UnitStatList(registry, { hitpoints: 100 * FIXED_ONE })
const fireAttacker: CombatUnitContext = {
id: 'fire-caster',
name: 'Fire Mage',
statList: fireAttackerStats,
stateBus: new StateBus(fireAttackerStats, registry),
}
const out = executeSUnitDmg(fireAttacker, fireGolem, {
skillId: 47, // Fire Ball
attackKind: 'spell',
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
})
expect(golemStats.getHp256()).toBe(400 * FIXED_ONE)
expect(out.totalDamage).toBe(0)
})
it('verifies strict Golem mutual exclusivity (Group 3): exactly 1 active Golem permitted', () => {
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry, { level: 80 })
const owner: CombatUnitContext = {
id: 'player-necro',
name: 'Necromancer',
statList: stats,
stateBus: new StateBus(stats, registry),
}
// Sequentially summon Clay (75), Blood (85), Iron (90), Fire (94)
const r1 = sm.createPet({ owner, skillId: 75, slvl: 1 })
expect(r1.created).toBe(true)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]?.kind).toBe('clay_golem')
const r2 = sm.createPet({ owner, skillId: 85, slvl: 1 })
expect(r2.created).toBe(true)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]?.kind).toBe('blood_golem')
expect(r2.evictedPetUids).toContain(r1.pet!.uid)
const r3 = sm.createPet({ owner, skillId: 90, slvl: 1 })
expect(r3.created).toBe(true)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]?.kind).toBe('iron_golem')
expect(r3.evictedPetUids).toContain(r2.pet!.uid)
const r4 = sm.createPet({ owner, skillId: 94, slvl: 1 })
expect(r4.created).toBe(true)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]?.kind).toBe('fire_golem')
expect(r4.evictedPetUids).toContain(r3.pet!.uid)
})
})
// =========================================================================
// Target 4: Revive 1.13c Invariants
// =========================================================================
describe('Target 4: Revive 1.13c Invariants & Expiration', () => {
it('verifies 180s (4500 frames) lifetime, +200% life, +215% damage, and pet cap = slvl', () => {
for (let slvl = 1; slvl <= 30; slvl++) {
expect(calculateReviveMaxCount(slvl)).toBe(slvl)
const stats0 = calculateReviveStats(slvl, 0)
expect(stats0.durationFrames).toBe(4500)
expect(stats0.hpMultiplier).toBe(3.0) // 1.0 + 200% = 3.0x
expect(stats0.damageMultiplier).toBe(3.15) // 1.0 + 215% = 3.15x
// With Skeleton Mastery slvl 20: +5%/lvl HP (+100%), +10%/lvl DMG (+200%)
const stats20 = calculateReviveStats(slvl, 20)
expect(stats20.hpMultiplier).toBe(4.0) // 3.0 + 20*0.05 = 4.0x
expect(stats20.damageMultiplier).toBe(5.15) // 3.15 + 20*0.10 = 5.15x
}
})
it('verifies Revive despawns when 4500-tick lifetime expires', () => {
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry, { level: 80 })
const owner: CombatUnitContext = {
id: 'player-necro',
name: 'Necromancer',
statList: stats,
stateBus: new StateBus(stats, registry),
}
const corpses = [{ id: 'corpse-revive', consumed: false, baseMaxHp: 800, isBoss: false }]
const outcome = sm.createPet({ owner, skillId: 95, slvl: 5, corpses })
expect(outcome.created).toBe(true)
const pet = outcome.pet!
expect(pet.lifespanTicks).toBe(4500)
// Fast forward lifespan ticks
pet.lifespanTicks = 2
sm.tickPets({
currentTick: 1,
owner,
enemies: [],
enemyPositions: new Map(),
missileEngine: {} as any,
auraScanner: {} as any,
})
expect(sm.getActivePets().length).toBe(1)
expect(pet.lifespanTicks).toBe(1)
// Final tick: should expire and despawn
sm.tickPets({
currentTick: 2,
owner,
enemies: [],
enemyPositions: new Map(),
missileEngine: {} as any,
auraScanner: {} as any,
})
expect(sm.getActivePets().length).toBe(0)
})
})
// =========================================================================
// Target 5: Curse Exclusivity (cursetype=1 Mutual Overwrite Without Stat Leakage)
// =========================================================================
describe('Target 5: Curse Exclusivity Across 100 Alternating Cycles', () => {
it('rapidly alternates across all 8 cursetype=1 curses with clean overwrites and 0 stat leakage', () => {
const monsterStats = new UnitStatList(registry, {
level: 85,
hitpoints: 1000 * FIXED_ONE,
maxhp: 1000 * FIXED_ONE,
damageresist: 50,
fireresist: 50,
coldresist: 50,
lightresist: 50,
poisonresist: 50,
})
const bus = new StateBus(monsterStats, registry)
const curseSequence = [
{ id: 66, name: 'amplifydamage', statKey: 'damageresist', val: -100 },
{ id: 72, name: 'weaken', statKey: 'damagepercent', val: -33 },
{ id: 76, name: 'ironmaiden', statKey: 'ironmaiden_return_pct', val: 200 },
{ id: 82, name: 'lifetap', statKey: 'lifetap_leech_pct', val: 50 },
{ id: 87, name: 'decrepify', statKey: 'item_slow', val: 50 },
{ id: 91, name: 'lowerresist', statKey: 'fireresist', val: -32 },
]
// Run 100 cycles of alternating curse applications
for (let cycle = 0; cycle < 100; cycle++) {
const curse = curseSequence[cycle % curseSequence.length]!
const res = bus.applyState({
stateNameOrId: curse.name,
sourceSkillId: curse.id,
slvl: 1,
durationFrames: 300,
curseTypeOverride: 1,
stats: { [curse.statKey]: curse.val },
})
expect(res.applied).toBe(true)
// Invariant 1: Exactly 1 curse state exists in the stateBus
const activeCurses = bus.getActiveEntries().filter(s => s.curseType === 1)
expect(activeCurses.length).toBe(1)
expect(activeCurses[0]?.stateName).toBe(curse.name)
// Invariant 2: Current curse's stat modifier is active
expect(monsterStats.getAccruedStat(curse.statKey as any)).toBeDefined()
// Invariant 3: Overwritten curse stats do NOT leak into other stats
for (const other of curseSequence) {
if (other.name !== curse.name && other.statKey !== curse.statKey) {
// Modifiers from previous curses must be zero
expect(monsterStats.getModifierBonus(other.statKey as any)).toBe(0)
}
}
}
})
})
// =========================================================================
// Target 6: Attract Overwrite Immunity (cursetype=2)
// =========================================================================
describe('Target 6: Attract Overwrite Immunity', () => {
it('verifies that an Attracted monster cannot have Attract overwritten by any curse', () => {
const monsterStats = new UnitStatList(registry, {
level: 85,
hitpoints: 1000 * FIXED_ONE,
maxhp: 1000 * FIXED_ONE,
damageresist: 0,
})
const bus = new StateBus(monsterStats, registry)
// Apply Attract (Skill 86, cursetype = 2)
const attractRes = bus.applyState({
stateNameOrId: 'attract',
sourceSkillId: 86,
slvl: 5,
durationFrames: 660,
curseTypeOverride: 2,
})
expect(attractRes.applied).toBe(true)
expect(bus.hasState('attract')).toBe(true)
// Attempt to cast competing curses against Attracted target
const competingCurses = [
{ id: 66, name: 'amplifydamage', statKey: 'damageresist', val: -100 },
{ id: 72, name: 'weaken', statKey: 'damagepercent', val: -33 },
{ id: 76, name: 'ironmaiden', statKey: 'ironmaiden_return_pct', val: 200 },
{ id: 82, name: 'lifetap', statKey: 'lifetap_leech_pct', val: 50 },
{ id: 87, name: 'decrepify', statKey: 'item_slow', val: 50 },
{ id: 91, name: 'lowerresist', statKey: 'fireresist', val: -32 },
{ id: 71, name: 'dimvision', statKey: 'item_blind', val: 1 },
{ id: 81, name: 'confuse', statKey: 'item_confuse', val: 1 },
{ id: 86, name: 'attract', statKey: 'attract', val: 1 }, // Re-casting Attract
]
for (const comp of competingCurses) {
const attempt = bus.applyState({
stateNameOrId: comp.name,
sourceSkillId: comp.id,
slvl: 10,
durationFrames: 500,
curseTypeOverride: comp.name === 'attract' ? 2 : 1,
stats: { [comp.statKey]: comp.val },
})
// Must be rejected due to Attract immunity
expect(attempt.applied).toBe(false)
expect(attempt.blockedByAttract).toBe(true)
// Attract state must remain active and undisturbed
expect(bus.hasState('attract')).toBe(true)
expect(bus.getState('attract')?.slvl).toBe(5)
// Zero stat contamination from the rejected curse
if (comp.statKey !== 'damageresist') {
expect(monsterStats.getModifierBonus(comp.statKey as any)).toBe(0)
}
}
})
})
// =========================================================================
// Target 7: Lower Resist 1/5th Immunity Breaking Rule
// =========================================================================
describe('Target 7: Lower Resist Immunity Breaking Rule (1/5th Efficiency)', () => {
it('verifies Lower Resist slvl 20 (-63%) applies at 1/5th (12%) against elemental immunes', () => {
const lrSlvl20 = calculateLowerResistReduction(20)
expect(lrSlvl20).toBe(63) // 63% reduction
// Case 1: Non-immune (baseRes = 99%)
// Full reduction applies: 99 - 63 = 36%
const r99 = computeEffectiveResistance({
baseRes: 99,
lowerResistPierce: lrSlvl20,
})
expect(r99.convictionAndLRApplied).toBe(63)
expect(r99.resAfterConvictionLR).toBe(36)
expect(r99.isImmune).toBe(false)
expect(r99.effectiveRes).toBe(36)
// Case 2: Immune (baseRes = 100%)
// 1/5th rule: floor(63 / 5) = 12%
// 100 - 12 = 88% -> Broken!
const r100 = computeEffectiveResistance({
baseRes: 100,
lowerResistPierce: lrSlvl20,
})
expect(r100.convictionAndLRApplied).toBe(12)
expect(r100.resAfterConvictionLR).toBe(88)
expect(r100.isImmune).toBe(false)
expect(r100.effectiveRes).toBe(88)
// Case 3: Immune (baseRes = 110%)
// 110 - 12 = 98% -> Broken!
const r110 = computeEffectiveResistance({
baseRes: 110,
lowerResistPierce: lrSlvl20,
})
expect(r110.convictionAndLRApplied).toBe(12)
expect(r110.resAfterConvictionLR).toBe(98)
expect(r110.isImmune).toBe(false)
expect(r110.effectiveRes).toBe(98)
// Case 4: Immune unbreakable (baseRes = 120%)
// 120 - 12 = 108% >= 100% -> NOT Broken!
const r120 = computeEffectiveResistance({
baseRes: 120,
lowerResistPierce: lrSlvl20,
})
expect(r120.convictionAndLRApplied).toBe(12)
expect(r120.resAfterConvictionLR).toBe(108)
expect(r120.isImmune).toBe(true)
expect(r120.effectiveRes).toBe(100)
// Case 5: Conviction (-85%) + Lower Resist (-63%) stacking:
// Total pierce = 148% -> 1/5th = 29%
// Against baseRes = 125%: 125 - 29 = 96% -> Broken!
const rStackedBreak = computeEffectiveResistance({
baseRes: 125,
convictionPierce: 85,
lowerResistPierce: lrSlvl20,
})
expect(rStackedBreak.convictionAndLRApplied).toBe(29)
expect(rStackedBreak.resAfterConvictionLR).toBe(96)
expect(rStackedBreak.isImmune).toBe(false)
expect(rStackedBreak.effectiveRes).toBe(96)
// Case 6: Against baseRes = 130%: 130 - 29 = 101% -> Unbreakable!
const rStackedImmune = computeEffectiveResistance({
baseRes: 130,
convictionPierce: 85,
lowerResistPierce: lrSlvl20,
})
expect(rStackedImmune.convictionAndLRApplied).toBe(29)
expect(rStackedImmune.resAfterConvictionLR).toBe(101)
expect(rStackedImmune.isImmune).toBe(true)
expect(rStackedImmune.effectiveRes).toBe(100)
})
})
})