diablo2-web/tests/skills/sor/adv-cold-fire-e2e-integrati...

1277 lines
46 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Sorceress Cross-Tree E2E Integration Stress Test Suite
* Milestone 5: Cold Tree & Fire Tree Cross-Skill Mechanics and Synergies
*
* Ground Truth References:
* - 1.13c Assembly: D2Game.dll (SUnitDmg.cpp, SkillSor.cpp), D2Common.dll (D2StatListStrc)
* - Authentic Data Tables: Skills.txt, Missiles.txt, States.txt
*
* Core Verification Dimensions:
* 1. Simultaneous Buff Stacking:
* - Cold Armors (Frozen Armor #40, Shiver Armor #50, Chilling Armor #60) + Enchant (#52).
* - State Group 1 mutual exclusion among Cold Armors (switching replaces Cold Armor).
* - Independent coexistence: Enchant persists across Cold Armor replacements without stat clobbering.
* 2. Dual-Tree Sorceress Combat:
* - Dual mastery scaling: Cold Mastery (#65) pierces enemy cold resistance.
* - Fire Mastery (#61) amplifies outgoing fire damage without piercing enemy fire resistance.
* - Melee attacks benefit from Enchant + Fire Mastery double-dip, while Glacial Spike cold spell pierces res.
* 3. Dual Immunity Stress:
* - Monsters with natural dual immunities (Cold Resist >= 100, Fire Resist >= 100).
* - Neither Cold Mastery nor Fire Mastery can break immunities on their own.
* - Net elemental damage is strictly 0; physical damage component penetrates normally.
* 4. Resistance Floor Clamping & Immunity-Breaking:
* - Conviction (#123) / Lower Resist (#91) 1/5th efficiency rule against immune targets.
* - Broken immunities allow full Cold Mastery pierce power.
* - Strict -100% resistance floor clamp (effectiveRes >= -100) under extreme reductions.
* 5. Ground & Stream Coexistence:
* - Running with Blaze (#46) footstep trail while Fire Wall (#51) burns on the ground.
* - Ice Blast (#45) or Glacial Spike (#55) cast through active fire patches.
* - Independent projectile kinematics, tick timers, and crowd-control synergy (freeze in fire).
*/
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 {
calculateFrozenArmorDefense,
calculateFrozenArmorDuration,
calculateFrozenArmorFreezeLength,
calculateShiverArmorDefense,
calculateShiverArmorDuration,
calculateShiverArmorDamage,
calculateShiverArmorChillLength,
calculateChillingArmorDefense,
calculateChillingArmorDuration,
calculateChillingArmorDamage,
calculateChillingArmorChillLength,
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
calculateFireWallDamage,
calculateFireWallPatches,
calculateBlazeDamage,
calculateBlazeDuration,
calculateBlazePatchDuration,
calculateEnchantDamage,
calculateEnchantAttackRating,
calculateEnchantDuration,
calculateFireMasteryBonus,
calculateEnchantAttackDamage,
BATCH1_SKILLS,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
type Projectile,
type ProjectileTarget,
} from '../../../src/game/skills.ts'
import {
createWorld,
addPlayer,
tickCombat,
applyEnchantBuff,
type CombatPlayer,
type CombatWorld,
type Monster,
type CombatOptions,
type CombatTerrain,
} from '../../../src/game/combat.ts'
import {
executeSUnitDmg,
computeEffectiveResistance,
type CombatUnitContext,
type SUnitDmgPacket,
} from '../../../src/game/engine/combat-pipeline.ts'
import { GameEngine } from '../../../src/game/engine.ts'
/** Helper to create a fully initialized CombatUnitContext */
function createMockUnit(
id: string,
options?: {
hp?: number
coldResist?: number
fireResist?: number
isSorceress?: boolean
coldMasterySlvl?: number
fireMasterySlvl?: number
hasEnchantState?: boolean
hasFrozenArmorState?: boolean
hasShiverArmorState?: boolean
hasChillingArmorState?: boolean
armorSlvl?: number
weaponMinPhys?: number
weaponMaxPhys?: number
},
): CombatUnitContext {
const statList = new UnitStatList()
const hp = options?.hp ?? 2000
statList.setBaseStat('hp', hp * FIXED_ONE)
statList.setBaseStat('maxhp', hp * FIXED_ONE)
statList.setBaseStat('hitpoints', hp * FIXED_ONE)
statList.setBaseStat('level', 85)
statList.setBaseStat('tohit', 5000)
statList.setBaseStat('armorclass', 200)
if (options?.coldResist !== undefined) {
statList.setBaseStat('coldresist', options.coldResist)
}
if (options?.fireResist !== undefined) {
statList.setBaseStat('fireresist', options.fireResist)
}
if (options?.coldMasterySlvl && options.coldMasterySlvl > 0) {
const pierce = calculateColdMasteryPierce(options.coldMasterySlvl)
statList.addStat('passive_cold_pierce', pierce)
statList.setBaseSkillLevel(65, options.coldMasterySlvl)
}
if (options?.fireMasterySlvl && options.fireMasterySlvl > 0) {
// UnitStatList.getAccruedStat('passive_fire_mastery') calculates Fire Mastery bonus
// automatically from base skill level 61.
statList.setBaseSkillLevel(61, options.fireMasterySlvl)
}
const stateBus = new StateBus(statList)
if (options?.hasEnchantState) {
stateBus.applyState({
stateNameOrId: 'enchant',
sourceSkillId: 52,
durationFrames: 15000,
stats: { tohit_percent: calculateEnchantAttackRating(20) },
})
}
const armorSlvl = options?.armorSlvl ?? 20
if (options?.hasFrozenArmorState) {
stateBus.applyState({
stateNameOrId: 'frozenarmor',
sourceSkillId: 40,
slvl: armorSlvl,
durationFrames: 15000,
stats: { skill_armor_percent: calculateFrozenArmorDefense(armorSlvl) },
})
} else if (options?.hasShiverArmorState) {
stateBus.applyState({
stateNameOrId: 'shiverarmor',
sourceSkillId: 50,
slvl: armorSlvl,
durationFrames: 15000,
stats: { skill_armor_percent: calculateShiverArmorDefense(armorSlvl) },
})
} else if (options?.hasChillingArmorState) {
stateBus.applyState({
stateNameOrId: 'chillingarmor',
sourceSkillId: 60,
slvl: armorSlvl,
durationFrames: 15000,
stats: { skill_armor_percent: calculateChillingArmorDefense(armorSlvl) },
})
}
return {
id,
name: id,
statList,
stateBus,
weaponMinPhys: options?.weaponMinPhys ?? 15,
weaponMaxPhys: options?.weaponMaxPhys ?? 25,
}
}
/** Flat mock terrain */
const flatTerrain: CombatTerrain = {
overlap: () => 0,
}
/** Base combat options for deterministic tests */
const standardOptions: CombatOptions = {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 15,
playerManaPerAttack: 0,
respawnTicks: 100,
}
describe('Diablo II v1.13c Sorceress Cold & Fire Cross-Tree E2E Integration Stress Suite', () => {
// =========================================================================
// Section 1: Simultaneous Buff Stacking & Group 1 Cold Armor Mutual Exclusion
// =========================================================================
describe('Section 1: Simultaneous Buff Stacking & Cold Armor Mutual Exclusion', () => {
it('verifies Frozen Armor (+125% def) and Enchant (+191% AR) stack concurrently on UnitStatList & StateBus', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
armorclass: 100,
tohit: 1000,
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
// Baseline stats before buffs
expect(stats.getAccruedStat('armorclass')).toBe(100)
expect(stats.getAccruedStat('tohit')).toBe(1000)
// 1. Cast Frozen Armor (slvl 20 -> +125% defense)
const frozenArmorDef = calculateFrozenArmorDefense(20)
expect(frozenArmorDef).toBe(125)
bus.applyState({
stateNameOrId: 'frozenarmor',
sourceSkillId: 40,
slvl: 20,
stats: { skill_armor_percent: frozenArmorDef },
})
expect(bus.hasState('frozenarmor')).toBe(true)
expect(stats.getAccruedStat('armorclass')).toBe(225) // 100 * (100 + 125) / 100
expect(stats.getAccruedStat('tohit')).toBe(1000)
// 2. Cast Enchant (slvl 20 -> +191% Attack Rating)
const enchantAr = calculateEnchantAttackRating(20)
expect(enchantAr).toBe(191) // 20 + 19 * 9
bus.applyState({
stateNameOrId: 'enchant',
sourceSkillId: 52,
slvl: 20,
stats: { tohit_percent: enchantAr },
})
// 3. Verify concurrent coexistence
expect(bus.hasState('frozenarmor')).toBe(true)
expect(bus.hasState('enchant')).toBe(true)
expect(stats.getAccruedStat('armorclass')).toBe(225) // Defense remains boosted
expect(stats.getAccruedStat('tohit')).toBe(2910) // 1000 * (100 + 191) / 100
})
it('enforces State Group 1 mutual exclusion: casting Shiver Armor replaces Frozen Armor while preserving Enchant', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
armorclass: 100,
tohit: 1000,
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
// Apply Frozen Armor (slvl 20 -> +125% def) and Enchant (slvl 20 -> +191% AR)
bus.applyState({
stateNameOrId: 'frozenarmor',
sourceSkillId: 40,
slvl: 20,
stats: { skill_armor_percent: calculateFrozenArmorDefense(20) },
})
bus.applyState({
stateNameOrId: 'enchant',
sourceSkillId: 52,
slvl: 20,
stats: { tohit_percent: calculateEnchantAttackRating(20) },
})
expect(bus.hasState('frozenarmor')).toBe(true)
expect(bus.hasState('enchant')).toBe(true)
// Cast Shiver Armor (slvl 10 -> +99% def)
const shiverArmorDef = calculateShiverArmorDefense(10)
expect(shiverArmorDef).toBe(99) // 45 + 9 * 6
const shiverResult = bus.applyState({
stateNameOrId: 'shiverarmor',
sourceSkillId: 50,
slvl: 10,
stats: { skill_armor_percent: shiverArmorDef },
})
// Verify Frozen Armor was evicted due to Group 1 conflict
expect(shiverResult.applied).toBe(true)
expect(shiverResult.evictedState).toBe('frozenarmor')
expect(bus.hasState('frozenarmor')).toBe(false)
expect(bus.hasState('shiverarmor')).toBe(true)
// Defense must be 100 * (100 + 99) / 100 = 199, NOT 100 + 125 + 99 = 324!
expect(stats.getAccruedStat('armorclass')).toBe(199)
expect(stats.getModifierBonus('skill_armor_percent')).toBe(99)
// Enchant MUST remain active and unmolested!
expect(bus.hasState('enchant')).toBe(true)
expect(stats.getAccruedStat('tohit')).toBe(2910)
expect(stats.getModifierBonus('tohit_percent')).toBe(191)
// Now switch to Chilling Armor (slvl 15 -> +115% def)
const chillingArmorDef = calculateChillingArmorDefense(15)
expect(chillingArmorDef).toBe(115) // 45 + 14 * 5
const chillingResult = bus.applyState({
stateNameOrId: 'chillingarmor',
sourceSkillId: 60,
slvl: 15,
stats: { skill_armor_percent: chillingArmorDef },
})
expect(chillingResult.applied).toBe(true)
expect(chillingResult.evictedState).toBe('shiverarmor')
expect(bus.hasState('shiverarmor')).toBe(false)
expect(bus.hasState('chillingarmor')).toBe(true)
expect(stats.getAccruedStat('armorclass')).toBe(215) // 100 * (100 + 115) / 100
// Enchant still completely untouched!
expect(bus.hasState('enchant')).toBe(true)
expect(stats.getAccruedStat('tohit')).toBe(2910)
})
it('verifies CombatPlayer sandbox buff stacking, replacement, and reactive event integrity', () => {
const world: CombatWorld = createWorld(100, 100)
const dummyMonster: Monster = {
index: 0,
stats: {
id: 'dummy_mon',
name: 'Target Dummy',
hp: 10000,
damage: 10,
cooldownTicks: 25,
reach: 25,
aggroRadius: 100,
speed: 50,
xp: 10,
},
x: 110,
y: 100,
hp: 10000,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
}
world.monsters.push(dummyMonster)
// Apply Enchant buff (slvl 20) with Fire Mastery (slvl 20)
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
world.player.isSorceress = true
world.player.fireMasterySlvl = 20
// Apply Shiver Armor (slvl 10)
world.player.coldArmorType = 'shiverarmor'
world.player.coldArmorSlvl = 10
world.player.coldArmorTicks = calculateShiverArmorDuration(10)
expect(world.player.coldArmorType).toBe('shiverarmor')
expect(world.player.coldArmorTicks).toBe(calculateShiverArmorDuration(10))
expect(world.player.enchantTicks).toBe(calculateEnchantDuration(20))
expect(world.player.enchantAttackRatingPercent).toBe(calculateEnchantAttackRating(20))
expect(world.player.enchantDamage).toBeDefined()
// Player attacks monster in melee: deals weapon damage + double-dipped Enchant fire damage
const prevMonsterHp = dummyMonster.hp
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, standardOptions, flatTerrain, [0, 100])
const attackEvent = world.events.find(e => e.kind === 'monsterHit')
expect(attackEvent).toBeDefined()
expect(dummyMonster.hp).toBeLessThan(prevMonsterHp)
// Switch Cold Armor to Frozen Armor (slvl 15)
world.player.coldArmorType = 'frozenarmor'
world.player.coldArmorSlvl = 15
world.player.coldArmorTicks = calculateFrozenArmorDuration(15)
expect(world.player.coldArmorType).toBe('frozenarmor')
expect(world.player.coldArmorTicks).toBe(calculateFrozenArmorDuration(15))
// Enchant buff remains 100% active and un-truncated
expect(world.player.enchantTicks).toBeGreaterThan(0)
expect(world.player.enchantDamage).toBeDefined()
})
it('stress tests 100 rapid cycles of alternating Cold Armors with persistent Enchant', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, { armorclass: 200, tohit: 2000 })
const bus = new StateBus(stats, registry)
// Permanent Enchant buff
bus.applyState({
stateNameOrId: 'enchant',
sourceSkillId: 52,
slvl: 30,
stats: { tohit_percent: calculateEnchantAttackRating(30) },
})
const expectedToHit = Math.trunc((2000 * (100 + calculateEnchantAttackRating(30))) / 100)
const armors: readonly ('frozenarmor' | 'shiverarmor' | 'chillingarmor')[] = [
'frozenarmor',
'shiverarmor',
'chillingarmor',
]
const skillIds = { frozenarmor: 40, shiverarmor: 50, chillingarmor: 60 }
const defFuncs = {
frozenarmor: calculateFrozenArmorDefense,
shiverarmor: calculateShiverArmorDefense,
chillingarmor: calculateChillingArmorDefense,
}
for (let i = 0; i < 100; i++) {
const armor = armors[i % 3]!
const slvl = 1 + (i % 30)
const defPct = defFuncs[armor](slvl)
bus.applyState({
stateNameOrId: armor,
sourceSkillId: skillIds[armor],
slvl,
stats: { skill_armor_percent: defPct },
})
// Exactly one cold armor active
for (const other of armors) {
if (other === armor) {
expect(bus.hasState(other)).toBe(true)
} else {
expect(bus.hasState(other)).toBe(false)
}
}
// Defense matches current armor exactly
const expectedDef = Math.trunc((200 * (100 + defPct)) / 100)
expect(stats.getAccruedStat('armorclass')).toBe(expectedDef)
// Enchant is invariant throughout all 100 cycles
expect(bus.hasState('enchant')).toBe(true)
expect(stats.getAccruedStat('tohit')).toBe(expectedToHit)
}
})
})
// =========================================================================
// Section 2: Dual-Tree Sorceress Combat: Cold Mastery vs Fire Mastery
// =========================================================================
describe('Section 2: Dual-Tree Sorceress Combat (Mastery Mechanics Asymmetry)', () => {
it('verifies Cold Mastery pierces enemy cold res while Fire Mastery increases fire damage without piercing fire res', () => {
const sorceress = createMockUnit('dual_sorceress', {
isSorceress: true,
coldMasterySlvl: 20, // 115% cold pierce
fireMasterySlvl: 20, // +163% fire damage
})
// Target with 50% Cold Resist and 50% Fire Resist
const defender = createMockUnit('dual_res_enemy', {
coldResist: 50,
fireResist: 50,
})
// 1. Cold Spell Attack: Glacial Spike (55)
// Base cold damage in 256ths: 100.0 cold damage = 25600
const baseCold256 = 100 * FIXED_ONE
const coldPacket: SUnitDmgPacket = {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseCold256,
elemMax256: baseCold256,
autoHit: true,
}
const coldOutcome = executeSUnitDmg(sorceress, defender, coldPacket)
expect(coldOutcome.hit).toBe(true)
expect(coldOutcome.immuneToElem).toBe(false)
// Cold Mastery pierces 50% res down to 50 - 115 = -65%!
// Multiplier = (100 - (-65)) / 100 = 1.65x
// Expected damage = Math.trunc(25600 * 165 / 100) = 42240 (165 cold damage)
expect(coldOutcome.elemDamage256).toBe(Math.trunc((baseCold256 * 165) / 100))
expect(coldOutcome.totalDamage).toBe(165)
// 2. Fire Attack: Enchant Fire Damage (52)
// Base fire damage in 256ths: 100.0 fire damage = 25600
const baseFire256 = 100 * FIXED_ONE
const firePacket: SUnitDmgPacket = {
skillId: 52,
attackKind: 'melee',
isEnchant: true,
elemType: 'fire',
elemMin256: baseFire256,
elemMax256: baseFire256,
flatPhysMin256: 0,
flatPhysMax256: 0,
autoHit: true,
}
const fireOutcome = executeSUnitDmg(sorceress, defender, firePacket)
expect(fireOutcome.hit).toBe(true)
expect(fireOutcome.immuneToElem).toBe(false)
// Fire Mastery scales fire damage by +163%: 25600 * 2.63 = 67328
// CRITICAL: Fire Mastery does NOT pierce Fire Resistance!
// Defender has 50% Fire Resist -> takes (100 - 50) / 100 = 50% of the scaled damage:
// Math.trunc(67328 * 50 / 100) = 33664
const scaledFire256 = Math.trunc((baseFire256 * (100 + 163)) / 100)
const expectedFireDamage256 = Math.trunc((scaledFire256 * (100 - 50)) / 100)
expect(fireOutcome.elemDamage256).toBe(expectedFireDamage256)
// totalDamage is Math.ceil((finalPhys256 + finalElem256) / 256)
expect(fireOutcome.totalDamage).toBe(Math.ceil((fireOutcome.physDamage256 + fireOutcome.elemDamage256) / FIXED_ONE))
})
it('proves Cold Mastery does NOT affect fire damage and Fire Mastery does NOT affect cold damage', () => {
const pureColdSorc = createMockUnit('cold_sorc', { coldMasterySlvl: 20 })
const pureFireSorc = createMockUnit('fire_sorc', { fireMasterySlvl: 20 })
const dualSorc = createMockUnit('dual_sorc', { coldMasterySlvl: 20, fireMasterySlvl: 20 })
const enemy = createMockUnit('neutral_enemy', { coldResist: 25, fireResist: 25 })
const baseDmg256 = 80 * FIXED_ONE
// Cold attack: dual sorc deals identical damage to pure cold sorc
const coldPacket: SUnitDmgPacket = {
skillId: 45, // Ice Blast
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseDmg256,
elemMax256: baseDmg256,
autoHit: true,
}
const pureColdOutcome = executeSUnitDmg(pureColdSorc, enemy, coldPacket)
const dualColdOutcome = executeSUnitDmg(dualSorc, enemy, coldPacket)
expect(dualColdOutcome.elemDamage256).toBe(pureColdOutcome.elemDamage256)
// Fire attack: dual sorc deals identical damage to pure fire sorc
const firePacket: SUnitDmgPacket = {
skillId: 52,
attackKind: 'melee',
isEnchant: true,
elemType: 'fire',
elemMin256: baseDmg256,
elemMax256: baseDmg256,
flatPhysMin256: 0,
flatPhysMax256: 0,
autoHit: true,
}
const pureFireOutcome = executeSUnitDmg(pureFireSorc, enemy, firePacket)
const dualFireOutcome = executeSUnitDmg(dualSorc, enemy, firePacket)
expect(dualFireOutcome.elemDamage256).toBe(pureFireOutcome.elemDamage256)
})
it('verifies quantitative cross-tree asymmetry across resistance spectrum [0%, 25%, 50%, 75%]', () => {
const sorceress = createMockUnit('sorceress', {
coldMasterySlvl: 10, // 65% pierce
fireMasterySlvl: 10, // +93% damage
})
const baseDmg256 = 100 * FIXED_ONE
for (const res of [0, 25, 50, 75]) {
const enemy = createMockUnit(`enemy_${res}`, { coldResist: res, fireResist: res })
// Cold calculation: effectiveRes = max(-100, res - 65)
const expectedEffectiveColdRes = Math.max(-100, res - 65)
const expectedColdDmg256 = Math.trunc((baseDmg256 * (100 - expectedEffectiveColdRes)) / 100)
const coldOutcome = executeSUnitDmg(sorceress, enemy, {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseDmg256,
elemMax256: baseDmg256,
autoHit: true,
})
expect(coldOutcome.elemDamage256).toBe(expectedColdDmg256)
// Fire calculation: base * 1.93 * (100 - res) / 100
const scaledFire256 = Math.trunc((baseDmg256 * (100 + 93)) / 100)
const expectedFireDmg256 = Math.trunc((scaledFire256 * (100 - res)) / 100)
const fireOutcome = executeSUnitDmg(sorceress, enemy, {
skillId: 52,
attackKind: 'melee',
isEnchant: true,
elemType: 'fire',
elemMin256: baseDmg256,
elemMax256: baseDmg256,
flatPhysMin256: 0,
flatPhysMax256: 0,
autoHit: true,
})
expect(fireOutcome.elemDamage256).toBe(expectedFireDmg256)
}
})
})
// =========================================================================
// Section 3: Dual Immunity Stress (Cold Res >= 100 & Fire Res >= 100)
// =========================================================================
describe('Section 3: Dual Immunity Stress (Natural Immunities Cannot Be Broken)', () => {
it('verifies monsters with dual cold & fire immunity (Res >= 100) take strictly 0 elemental damage', () => {
const sorceress = createMockUnit('max_sorceress', {
isSorceress: true,
coldMasterySlvl: 40, // 215% pierce
fireMasterySlvl: 40, // +303% damage
})
// Dual immune boss monster (e.g. Cold Res 110%, Fire Res 110%)
const dualImmune = createMockUnit('dual_immune_boss', {
coldResist: 110,
fireResist: 110,
hp: 5000,
})
const baseCold256 = 500 * FIXED_ONE
const baseFire256 = 500 * FIXED_ONE
// 1. Cold attack alone
const coldOutcome = executeSUnitDmg(sorceress, dualImmune, {
skillId: 55, // Glacial Spike
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseCold256,
elemMax256: baseCold256,
autoHit: true,
})
expect(coldOutcome.immuneToElem).toBe(true)
expect(coldOutcome.elemDamage256).toBe(0)
expect(coldOutcome.totalDamage).toBe(0)
// 2. Fire attack alone
const fireOutcome = executeSUnitDmg(sorceress, dualImmune, {
skillId: 52, // Enchant
attackKind: 'melee',
isEnchant: true,
elemType: 'fire',
elemMin256: baseFire256,
elemMax256: baseFire256,
flatPhysMin256: 0,
flatPhysMax256: 0,
autoHit: true,
})
expect(fireOutcome.immuneToElem).toBe(true)
expect(fireOutcome.elemDamage256).toBe(0)
// Physical portion (20) still connects
expect(fireOutcome.physDamage256).toBe(20 * FIXED_ONE)
expect(fireOutcome.totalDamage).toBe(20)
// 3. Combined attack: physical + fire + cold
const dualOutcome = executeSUnitDmg(sorceress, dualImmune, {
skillId: 52,
attackKind: 'melee',
elemType: 'fire',
elemMin256: baseFire256,
elemMax256: baseFire256,
flatPhysMin256: 25 * FIXED_ONE,
flatPhysMax256: 25 * FIXED_ONE,
autoHit: true,
})
expect(dualOutcome.immuneToElem).toBe(true)
expect(dualOutcome.elemDamage256).toBe(0)
// Only physical damage (45 total = 20 weapon + 25 flat) penetrates
expect(dualOutcome.totalDamage).toBe(45)
})
it('verifies sharp immunity cliff at the exact 100% boundary (99% takes massive damage, 100% takes 0)', () => {
const sorceress = createMockUnit('sorceress', { coldMasterySlvl: 20 }) // 115% pierce
const baseCold256 = 100 * FIXED_ONE
// Case A: 99% Cold Resistance (NOT immune)
const nearImmune = createMockUnit('near_immune', { coldResist: 99 })
const nearOutcome = executeSUnitDmg(sorceress, nearImmune, {
skillId: 45,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseCold256,
elemMax256: baseCold256,
autoHit: true,
})
expect(nearOutcome.immuneToElem).toBe(false)
// 99 - 115 = -16% effective resistance -> takes 1.16x damage!
expect(nearOutcome.totalDamage).toBe(116)
// Case B: 100% Cold Resistance (STRICT IMMUNITY)
const exactImmune = createMockUnit('exact_immune', { coldResist: 100 })
const exactOutcome = executeSUnitDmg(sorceress, exactImmune, {
skillId: 45,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseCold256,
elemMax256: baseCold256,
autoHit: true,
})
expect(exactOutcome.immuneToElem).toBe(true)
expect(exactOutcome.totalDamage).toBe(0)
// Case C: 101% Cold Resistance (IMMUNE)
const overImmune = createMockUnit('over_immune', { coldResist: 101 })
const overOutcome = executeSUnitDmg(sorceress, overImmune, {
skillId: 45,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseCold256,
elemMax256: baseCold256,
autoHit: true,
})
expect(overOutcome.immuneToElem).toBe(true)
expect(overOutcome.totalDamage).toBe(0)
})
it('simulates 100 consecutive attacks against extreme dual-immune targets (Res 100% to 200%) verifying net elemental damage = 0', () => {
for (let i = 0; i < 100; i++) {
const coldRes = 100 + (i % 50)
const fireRes = 100 + ((i * 3) % 50)
const coldResRec = computeEffectiveResistance({
baseRes: coldRes,
passiveEnemyPierce: calculateColdMasteryPierce(30),
})
expect(coldResRec.isImmune).toBe(true)
expect(coldResRec.effectiveRes).toBe(100)
const fireResRec = computeEffectiveResistance({
baseRes: fireRes,
passiveEnemyPierce: 0,
})
expect(fireResRec.isImmune).toBe(true)
expect(fireResRec.effectiveRes).toBe(100)
}
})
})
// =========================================================================
// Section 4: Resistance Floor Clamping (-100% Floor Cap) & Immunity Breaking
// =========================================================================
describe('Section 4: Resistance Floor Clamping (-100% Floor Cap) & Immunity-Breaking Interactions', () => {
it('enforces strict -100% resistance floor clamp under extreme resistance reduction (-375% raw -> -100% clamped)', () => {
// Enemy with negative base resistance -50%
// Conviction 150% + Lower Resist 60% + Cold Mastery 115%
// Raw: -50 - 150 - 60 - 115 = -375%
const resResult = computeEffectiveResistance({
baseRes: -50,
convictionPierce: 150,
lowerResistPierce: 60,
passiveEnemyPierce: 115,
})
expect(resResult.isImmune).toBe(false)
// Clamped strictly to -100% (2.0x damage cap)
expect(resResult.effectiveRes).toBe(-100)
// Verify combat pipeline damage calculation respects the 2.0x cap
const attacker = createMockUnit('attacker')
const defender = createMockUnit('defender', { coldResist: -50 })
const baseDmg256 = 100 * FIXED_ONE
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 45,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseDmg256,
elemMax256: baseDmg256,
convictionPierce: 150,
lowerResistPierce: 60,
passiveEnemyPierce: 115,
autoHit: true,
})
expect(outcome.immuneToElem).toBe(false)
// (100 - (-100)) / 100 = 200% -> exactly 200 total damage!
expect(outcome.elemDamage256).toBe(200 * FIXED_ONE)
expect(outcome.totalDamage).toBe(200)
})
it('verifies Conviction breaking cold immunity allows Cold Mastery to apply at full 100% power', () => {
// Monster with 110% Cold Resistance (Immune)
// Conviction slvl 25 has 150% pierce.
// 1.13c Rule: 1/5th efficiency against immune -> 150 / 5 = 30%
// Res after Conviction: 110 - 30 = 80% (< 100%, IMMUNITY BROKEN!)
// Now Cold Mastery (slvl 20: 115% pierce) applies at 100% full effectiveness:
// Effective res: 80 - 115 = -35%
const resResult = computeEffectiveResistance({
baseRes: 110,
convictionPierce: 150,
passiveEnemyPierce: calculateColdMasteryPierce(20), // 115
})
expect(resResult.isImmune).toBe(false)
expect(resResult.convictionAndLRApplied).toBe(30)
expect(resResult.resAfterConvictionLR).toBe(80)
expect(resResult.effectiveRes).toBe(-35)
// Damage multiplier is (100 - (-35)) / 100 = 135% (1.35x)
const attacker = createMockUnit('attacker', { coldMasterySlvl: 20 })
const defender = createMockUnit('defender', { coldResist: 110 })
const baseDmg256 = 100 * FIXED_ONE
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: baseDmg256,
elemMax256: baseDmg256,
convictionPierce: 150,
autoHit: true,
})
expect(outcome.immuneToElem).toBe(false)
expect(outcome.totalDamage).toBe(135)
})
it('verifies Conviction breaking fire immunity: Fire Mastery scales base damage but cannot pierce the remaining resistance', () => {
// Monster with 110% Fire Resistance (Immune)
// Conviction slvl 25 has 150% pierce -> 1/5th = 30%.
// Res after Conviction: 110 - 30 = 80% (< 100%, BROKEN!).
// Fire Mastery (slvl 20: +163% damage) scales base damage, but has 0% pierce:
// Effective res remains 80%.
const resResult = computeEffectiveResistance({
baseRes: 110,
convictionPierce: 150,
passiveEnemyPierce: 0,
})
expect(resResult.isImmune).toBe(false)
expect(resResult.resAfterConvictionLR).toBe(80)
expect(resResult.effectiveRes).toBe(80)
const attacker = createMockUnit('attacker', { fireMasterySlvl: 20 })
const defender = createMockUnit('defender', { fireResist: 110 })
const baseFire256 = 100 * FIXED_ONE
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 52,
attackKind: 'melee',
isEnchant: true,
elemType: 'fire',
elemMin256: baseFire256,
elemMax256: baseFire256,
flatPhysMin256: 0,
flatPhysMax256: 0,
convictionPierce: 150,
autoHit: true,
})
expect(outcome.immuneToElem).toBe(false)
// Scaled fire damage: 100 * 2.63 = 263
// Mitigated by 80% resistance: 263 * (100 - 80) / 100 = 52.6 -> 52 (in 256ths: 13465)
const scaledFire256 = Math.trunc((baseFire256 * (100 + 163)) / 100)
const expectedFireDmg256 = Math.trunc((scaledFire256 * (100 - 80)) / 100)
expect(outcome.elemDamage256).toBe(expectedFireDmg256)
expect(outcome.totalDamage).toBe(Math.ceil((outcome.physDamage256 + outcome.elemDamage256) / FIXED_ONE))
})
it('verifies unbreakable cold immunity (Res 160% > 150%/5 = 30% reduction) remains 100% immune', () => {
// Monster with 160% Cold Resistance
// Conviction 150% (30% effective) -> 160 - 30 = 130% (>= 100%, UNBROKEN)
const resResult = computeEffectiveResistance({
baseRes: 160,
convictionPierce: 150,
passiveEnemyPierce: calculateColdMasteryPierce(40), // 215% pierce
})
expect(resResult.isImmune).toBe(true)
expect(resResult.effectiveRes).toBe(100)
const attacker = createMockUnit('attacker', { coldMasterySlvl: 40 })
const defender = createMockUnit('defender', { coldResist: 160 })
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
convictionPierce: 150,
autoHit: true,
})
expect(outcome.immuneToElem).toBe(true)
expect(outcome.elemDamage256).toBe(0)
})
it('differential fuzzing oracle: 500 randomized parameter sets verify effectiveRes >= -100 and correct immunity logic', () => {
for (let seed = 1; seed <= 500; seed++) {
// Pseudo-random generation based on seed
const baseRes = -100 + (seed * 17) % 300 // range [-100..199]
const convictionPierce = (seed * 31) % 160 // range [0..159]
const lowerResistPierce = (seed * 13) % 70 // range [0..69]
const passivePierce = (seed * 23) % 220 // range [0..219]
const actual = computeEffectiveResistance({
baseRes,
convictionPierce,
lowerResistPierce,
passiveEnemyPierce: passivePierce,
})
// Oracle calculation
const rawConvLR = convictionPierce + lowerResistPierce
const convLRApplied = baseRes >= 100 ? Math.trunc(rawConvLR / 5) : rawConvLR
const resAfter = baseRes - convLRApplied
const expectedImmune = resAfter >= 100
const expectedEffRes = expectedImmune ? 100 : Math.max(-100, Math.min(99, resAfter - passivePierce))
expect(actual.isImmune).toBe(expectedImmune)
expect(actual.effectiveRes).toBe(expectedEffRes)
expect(actual.effectiveRes).toBeGreaterThanOrEqual(-100)
expect(actual.effectiveRes).toBeLessThanOrEqual(100)
}
})
})
// =========================================================================
// Section 5: Ground & Stream Coexistence (Blaze + Fire Wall + Cold Projectiles)
// =========================================================================
describe('Section 5: Ground & Stream Coexistence (Blaze + Fire Wall + Cold Projectiles)', () => {
it('verifies multi-missile simulation coexistence: Fire Wall + Blaze + Ice Blast + Glacial Spike tick independently', () => {
// 1. Generate 15 Fire Wall patches centered at (200, 200)
const fwPatches = calculateFireWallPatches(100, 200, 200, 200, { count: 15, spacing: 24 })
expect(fwPatches).toHaveLength(15)
const activeProjectiles: Projectile[] = []
// Add Fire Wall patches (stationary ground fire)
for (const p of fwPatches) {
activeProjectiles.push({
skillId: '51',
x: p.x,
y: p.y,
vx: 0,
vy: 0,
damage: 50,
ttl: 90,
fromPlayer: true,
missileType: 'firewall',
pierce: true,
aoeRadius: 24,
})
}
// Add 4 Blaze footstep patches at player footsteps
const blazeCoords = [
{ x: 100, y: 150 },
{ x: 125, y: 150 },
{ x: 150, y: 150 },
{ x: 175, y: 150 },
]
for (const b of blazeCoords) {
activeProjectiles.push({
skillId: '46',
x: b.x,
y: b.y,
vx: 0,
vy: 0,
damage: 30,
ttl: 115,
fromPlayer: true,
missileType: 'blaze',
pierce: true,
aoeRadius: 24,
})
}
// Add 1 Ice Blast moving horizontally across the screen (vx = 12, vy = 0)
activeProjectiles.push({
skillId: '45',
x: 50,
y: 200,
vx: 12,
vy: 0,
damage: 100,
ttl: 50,
fromPlayer: true,
missileType: 'iceblast',
statusEffect: 'freeze',
statusDuration: 100,
})
// Add 1 Glacial Spike moving vertically across the screen (vx = 0, vy = 16)
activeProjectiles.push({
skillId: '55',
x: 200,
y: 50,
vx: 0,
vy: 16,
damage: 150,
ttl: 40,
fromPlayer: true,
missileType: 'glacialspike',
aoeRadius: 64,
statusEffect: 'freeze',
statusDuration: 80,
})
expect(activeProjectiles).toHaveLength(15 + 4 + 1 + 1) // 21 projectiles
// Step simulation for 5 ticks with no obstacles
let currentProjectiles: readonly Projectile[] = activeProjectiles
for (let tick = 1; tick <= 5; tick++) {
const outcome = tickProjectiles(currentProjectiles, [], { overlap: () => 0 })
currentProjectiles = outcome.alive
}
expect(currentProjectiles).toHaveLength(21) // All still alive after 5 ticks
// Verify Fire Wall patches: stationary, ttl decremented by 5 (90 -> 85)
const fwAlive = currentProjectiles.filter(p => p.missileType === 'firewall')
expect(fwAlive).toHaveLength(15)
for (const p of fwAlive) {
expect(p.ttl).toBe(85)
expect(p.vx).toBe(0)
expect(p.vy).toBe(0)
}
// Verify Blaze patches: stationary, ttl decremented by 5 (115 -> 110)
const blazeAlive = currentProjectiles.filter(p => p.missileType === 'blaze')
expect(blazeAlive).toHaveLength(4)
for (const p of blazeAlive) {
expect(p.ttl).toBe(110)
expect(p.vx).toBe(0)
expect(p.vy).toBe(0)
}
// Verify Ice Blast: moved 5 * 12 = 60 pixels on X (50 + 60 = 110), ttl = 45
const iceBlastAlive = currentProjectiles.find(p => p.missileType === 'iceblast')!
expect(iceBlastAlive).toBeDefined()
expect(iceBlastAlive.x).toBe(110)
expect(iceBlastAlive.y).toBe(200)
expect(iceBlastAlive.ttl).toBe(45)
// Verify Glacial Spike: moved 5 * 16 = 80 pixels on Y (50 + 80 = 130), ttl = 35
const glacialSpikeAlive = currentProjectiles.find(p => p.missileType === 'glacialspike')!
expect(glacialSpikeAlive).toBeDefined()
expect(glacialSpikeAlive.x).toBe(200)
expect(glacialSpikeAlive.y).toBe(130)
expect(glacialSpikeAlive.ttl).toBe(35)
})
it('verifies Ice Blast traversing cleanly through an active Fire Wall field to hit a distant target', () => {
// Fire Wall patch at x = 100, y = 100
const fwPatch: Projectile = {
skillId: '51',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 40,
ttl: 90,
fromPlayer: true,
missileType: 'firewall',
pierce: true,
aoeRadius: 24,
}
// Ice Blast fired from (50, 100) toward target at (150, 100)
const iceBlast: Projectile = {
skillId: '45',
x: 50,
y: 100,
vx: 25, // 25 px per tick -> will reach 75, 100, 125, 150
vy: 0,
damage: 120,
ttl: 10,
fromPlayer: true,
missileType: 'iceblast',
statusEffect: 'freeze',
statusDuration: 100,
}
// Monster standing past the Fire Wall at (150, 100)
const monster: ProjectileTarget = {
index: 1,
alive: true,
x: 150,
y: 100,
radius: 16,
}
let projs: readonly Projectile[] = [fwPatch, iceBlast]
let hitTarget: { readonly targetIndex: number; readonly damage: number; readonly statusEffect?: string | undefined } | undefined
// Tick 1: Ice Blast moves to 75, Fire Wall remains at 100
let out = tickProjectiles(projs, [monster], { overlap: () => 0 })
expect(out.hits).toHaveLength(0)
projs = out.alive
// Tick 2: Ice Blast moves to 100 (passes through Fire Wall patch without collision)
out = tickProjectiles(projs, [monster], { overlap: () => 0 })
expect(out.hits).toHaveLength(0)
expect(out.alive.some(p => p.missileType === 'iceblast')).toBe(true)
expect(out.alive.some(p => p.missileType === 'firewall')).toBe(true)
projs = out.alive
// Tick 3: Ice Blast moves to 125
out = tickProjectiles(projs, [monster], { overlap: () => 0 })
expect(out.hits).toHaveLength(0)
projs = out.alive
// Tick 4: Ice Blast moves to 150 and impacts monster!
out = tickProjectiles(projs, [monster], { overlap: () => 0 })
expect(out.hits).toHaveLength(1)
hitTarget = out.hits[0]
expect(hitTarget?.targetIndex).toBe(1)
expect(hitTarget?.damage).toBe(120)
expect(hitTarget?.statusEffect).toBe('freeze')
// Ice Blast expired on hit, while Fire Wall is STILL burning!
const remainingFw = out.alive.find(p => p.missileType === 'firewall')
expect(remainingFw).toBeDefined()
expect(remainingFw?.ttl).toBe(86) // 90 - 4 ticks
})
it('demonstrates Glacial Spike freezing an enemy on top of an active Fire Wall field for continuous burn ticks', () => {
// Monster at (120, 120)
const monster: ProjectileTarget = {
index: 1,
alive: true,
x: 120,
y: 120,
radius: 16,
}
// Fire Wall patch at (120, 120) directly under the monster
const fwPatch: Projectile = {
skillId: '51',
x: 120,
y: 120,
vx: 0,
vy: 0,
damage: 50,
ttl: 30,
fromPlayer: true,
missileType: 'firewall',
pierce: true,
aoeRadius: 24,
}
// Glacial Spike impacting on tick 1
const glacialSpike: Projectile = {
skillId: '55',
x: 110,
y: 120,
vx: 10,
vy: 0,
damage: 80,
ttl: 10,
fromPlayer: true,
missileType: 'glacialspike',
aoeRadius: 64,
statusEffect: 'freeze',
statusDuration: 50,
}
let projs: readonly Projectile[] = [fwPatch, glacialSpike]
let totalFireDamage = 0
let coldDamageDealt = 0
let freezeDurationApplied = 0
for (let tick = 1; tick <= 5; tick++) {
const out = tickProjectiles(projs, [monster], { overlap: () => 0 })
for (const hit of out.hits) {
if (hit.missileType === 'firewall') {
totalFireDamage += hit.damage
} else if (hit.missileType === 'glacialspike') {
coldDamageDealt += hit.damage
freezeDurationApplied = hit.statusDuration ?? 0
}
}
projs = out.alive
}
// Glacial Spike struck once for 80 damage and 50 ticks of freeze
expect(coldDamageDealt).toBe(80)
expect(freezeDurationApplied).toBe(50)
// Fire Wall struck 5 times (once per tick): 5 * 50 = 250 fire damage!
expect(totalFireDamage).toBe(250)
// Fire Wall patch is still burning (30 - 5 = 25 ttl)
expect(projs).toHaveLength(1)
expect(projs[0]!.ttl).toBe(25)
})
it('verifies player running with Blaze active generates footstep patches while casting Glacial Spike with zero self-damage', () => {
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
const engineOpts = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 40,
playerCooldownTicks: 12,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 50,
disableMonsterAggro: false,
},
lootSeed: 1,
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(terrain as any, engineOpts as any)
const idleInput = {
movement: { x: 0, y: 0 },
attacking: false,
digits: [],
pickingUp: false,
talking: false,
saving: false,
loading: false,
}
// Activate Blaze on player
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.blazeTicks = 200
engine.world.player.blazeDamage = 50
engine.world.player.blazePatchDuration = 115
// Tick 1: Spawns initial Blaze patch at (100, 100)
engine.tick(idleInput)
expect(engine.projectiles).toHaveLength(1)
expect(engine.projectiles[0]!.missileType).toBe('blaze')
const playerInitialHp = engine.world.player.hp
// Player casts Glacial Spike projectile while standing over Blaze patch
const spikeProj: Projectile = {
skillId: '55',
x: engine.world.player.x,
y: engine.world.player.y,
vx: 16,
vy: 0,
damage: 120,
ttl: 30,
fromPlayer: true,
missileType: 'glacialspike',
aoeRadius: 64,
statusEffect: 'freeze',
statusDuration: 50,
}
engine.projectiles.push(spikeProj)
expect(engine.projectiles).toHaveLength(2)
// Move player 30px (>= 24px) to (130, 100)
engine.world.player.x = 130
engine.world.player.y = 100
engine.tick(idleInput)
// Spawns 2nd Blaze patch at (130, 100); Glacial Spike advances by 16px to 116px
expect(engine.projectiles.length).toBeGreaterThanOrEqual(2)
// Invariant: Player takes ZERO self-damage from Blaze patches or from their own Glacial Spike!
expect(engine.world.player.hp).toBe(playerInitialHp)
expect(engine.world.player.alive).toBe(true)
// The Glacial Spike moved independently without being affected by Blaze ground patches
const spike = engine.projectiles.find(p => p.missileType === 'glacialspike')
expect(spike).toBeDefined()
expect(spike!.x).toBe(116)
expect(spike!.y).toBe(100)
expect(spike!.ttl).toBe(29)
})
})
// =========================================================================
// Section 6: Architectural Invariants
// =========================================================================
describe('Section 6: Architectural Invariants', () => {
it('strictly preserves BATCH1_SKILLS length at exactly 38 skills', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
})
})