From 51acb0effa3e14f0f4d00841ca02f7e39480fd0a Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 04:28:37 +0000 Subject: [PATCH] test(skills): persist M4 fire and M5 cross-tree adversarial stress test suites on main --- .../sor/adv-cold-fire-boundaries.test.ts | 1022 +++++++++++++ .../sor/adv-cold-fire-e2e-integration.test.ts | 1276 +++++++++++++++++ .../sor/adv-fire-m4-combat-stress.test.ts | 922 ++++++++++++ .../sor/adv-fire-m4-math-stress.test.ts | 527 +++++++ 4 files changed, 3747 insertions(+) create mode 100644 tests/skills/sor/adv-cold-fire-boundaries.test.ts create mode 100644 tests/skills/sor/adv-cold-fire-e2e-integration.test.ts create mode 100644 tests/skills/sor/adv-fire-m4-combat-stress.test.ts create mode 100644 tests/skills/sor/adv-fire-m4-math-stress.test.ts diff --git a/tests/skills/sor/adv-cold-fire-boundaries.test.ts b/tests/skills/sor/adv-cold-fire-boundaries.test.ts new file mode 100644 index 0000000..a0a1c6a --- /dev/null +++ b/tests/skills/sor/adv-cold-fire-boundaries.test.ts @@ -0,0 +1,1022 @@ +/** + * Adversarial Edge-Case Stress Testing & Coverage Boundary Verification Suite + * Focus: Sorceress Cold & Fire Trees (All 11 Skills & 16 Calculation Functions) + * + * Test Dimensions: + * 1. Zero, Negative, and Extreme Skill Levels across all 16 calculation functions: + * - calculateFrozenArmorDefense, calculateShiverArmorDamage, calculateChillingArmorDamage + * - calculateIceBlastDamage, calculateIceBlastFreezeLength + * - calculateGlacialSpikeDamage, calculateGlacialSpikeFreezeLength + * - calculateColdMasteryPierce, calculateFireWallDamage, calculateBlazeDamage + * - calculateInfernoDamage, calculateInfernoContinuousMana256 + * - calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration + * - calculateFireMasteryBonus + * Tested with: slvl = 0, slvl = -1, slvl = 99, slvl = 1000, NaN, undefined, fractions, infinities. + * 2. Mana Depletion and Channeling Exhaustion: + * - Inferno channeled stream mid-stream depletion to 0 mana (halt, state reset, event logging). + * - Casting rejection with insufficient mana (mana < cost) with zero state corruption. + * 3. Cooldown & Cast Delay Integrity: + * - Fire Wall (35 frames), Meteor (30 frames), Blizzard (45 frames), Hydra (40 frames), Frozen Orb (25 frames). + * - Global cast delay group locking: blocks other delayed spells while allowing non-delayed spells. + * 4. Screen & Spatial Collision Boundaries: + * - Extreme coordinates (-9999, +9999) & sub-pixel fractions for Glacial Spike burst & Ice Blast. + * - Collision raycasting (hasLineOfSight) deadlock-freedom and boundary precision. + */ + +import { describe, expect, it } from 'vitest' +import { + calculateFrozenArmorDefense, + calculateShiverArmorDamage, + calculateChillingArmorDamage, + calculateIceBlastDamage, + calculateIceBlastFreezeLength, + calculateGlacialSpikeDamage, + calculateGlacialSpikeFreezeLength, + calculateColdMasteryPierce, + calculateFireWallDamage, + calculateBlazeDamage, + calculateInfernoDamage, + calculateInfernoContinuousMana256, + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateFireWallPatches, + hasLineOfSight, + MissileEngine, +} from '../../../src/game/engine/missile-engine.ts' +import { + BATCH1_SKILLS, + getSkillManaCost, + getSkillCooldownTicks, + calculateSkillDamage, + executeSkill, + type SkillExecutionContext, +} from '../../../src/game/skills.ts' +import { castSkill, getSkillMana } from '../../../src/scene/act-scene.ts' +import { createPlayer } from '../../../src/game/combat.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts' + +function createMockCombatUnit( + id: string, + options?: { + hp?: number + coldResist?: number + cannotBeFrozen?: boolean + coldMasterySlvl?: number + }, +): CombatUnitContext { + const statList = new UnitStatList() + const hp = options?.hp ?? 500 + statList.setBaseStat('hp', hp * 256) + statList.setBaseStat('maxhp', hp * 256) + statList.setBaseStat('hitpoints', hp * 256) + + if (options?.coldResist !== undefined) { + statList.setBaseStat('coldresist', options.coldResist) + } + if (options?.cannotBeFrozen) { + statList.setBaseStat('cannot_be_frozen', 1) + } + if (options?.coldMasterySlvl !== undefined && options.coldMasterySlvl > 0) { + const pierce = calculateColdMasteryPierce(options.coldMasterySlvl) + statList.setBaseStat('passive_cold_pierce', pierce) + } + + const stateBus = new StateBus(statList) + return { + id, + name: id, + statList, + stateBus, + } +} + +describe('Adversarial Boundary Stress Suite — Cold & Fire Skills', () => { + // ========================================================================== + // Dimension 1: Zero, Negative, and Extreme Skill Levels + // ========================================================================== + describe('Dimension 1: Zero, Negative, and Extreme Skill Levels', () => { + const testLevels: Array = [ + 0, + -1, + -100, + -Infinity, + NaN, + undefined, + 1, + 20, + 99, + 1000, + 10000, + 0.5, + 15.7, + ] + + describe('Defense, Buff & Passive Calculations', () => { + it('calculateFrozenArmorDefense: safely handles all degenerate and extreme slvl inputs', () => { + for (const slvl of testLevels) { + expect(() => calculateFrozenArmorDefense(slvl)).not.toThrow() + const def = calculateFrozenArmorDefense(slvl) + expect(Number.isFinite(def)).toBe(true) + expect(Number.isNaN(def)).toBe(false) + expect(def).toBeGreaterThanOrEqual(30) // Minimum level 1 defense bonus + } + // Specific boundary assertions + expect(calculateFrozenArmorDefense(0)).toBe(30) + expect(calculateFrozenArmorDefense(-1)).toBe(30) + expect(calculateFrozenArmorDefense(NaN)).toBe(30) + expect(calculateFrozenArmorDefense(undefined as any)).toBe(30) + expect(calculateFrozenArmorDefense(99)).toBe(30 + 98 * 5) // 520% + expect(calculateFrozenArmorDefense(1000)).toBe(30 + 999 * 5) // 5025% + }) + + it('calculateColdMasteryPierce: clamps to 0 for non-positive or non-finite inputs, scales monotonically', () => { + for (const slvl of testLevels) { + expect(() => calculateColdMasteryPierce(slvl)).not.toThrow() + const pierce = calculateColdMasteryPierce(slvl) + expect(Number.isFinite(pierce)).toBe(true) + expect(Number.isNaN(pierce)).toBe(false) + expect(pierce).toBeGreaterThanOrEqual(0) + } + expect(calculateColdMasteryPierce(0)).toBe(0) + expect(calculateColdMasteryPierce(-1)).toBe(0) + expect(calculateColdMasteryPierce(-100)).toBe(0) + expect(calculateColdMasteryPierce(NaN)).toBe(0) + expect(calculateColdMasteryPierce(undefined as any)).toBe(0) + expect(calculateColdMasteryPierce(1)).toBe(20) + expect(calculateColdMasteryPierce(20)).toBe(20 + 19 * 5) // 115% + expect(calculateColdMasteryPierce(99)).toBe(20 + 98 * 5) // 510% + expect(calculateColdMasteryPierce(1000)).toBe(20 + 999 * 5) // 5015% + }) + + it('calculateFireMasteryBonus: clamps to 0 for non-positive or non-finite inputs, scales monotonically', () => { + for (const slvl of testLevels) { + expect(() => calculateFireMasteryBonus(slvl)).not.toThrow() + const bonus = calculateFireMasteryBonus(slvl) + expect(Number.isFinite(bonus)).toBe(true) + expect(Number.isNaN(bonus)).toBe(false) + expect(bonus).toBeGreaterThanOrEqual(0) + } + expect(calculateFireMasteryBonus(0)).toBe(0) + expect(calculateFireMasteryBonus(-1)).toBe(0) + expect(calculateFireMasteryBonus(NaN)).toBe(0) + expect(calculateFireMasteryBonus(undefined as any)).toBe(0) + expect(calculateFireMasteryBonus(1)).toBe(30) + expect(calculateFireMasteryBonus(20)).toBe(30 + 19 * 7) // 163% + expect(calculateFireMasteryBonus(99)).toBe(30 + 98 * 7) // 716% + expect(calculateFireMasteryBonus(1000)).toBe(30 + 999 * 7) // 7023% + }) + + it('calculateEnchantAttackRating: clamps to 0 for non-positive or non-finite inputs', () => { + for (const slvl of testLevels) { + expect(() => calculateEnchantAttackRating(slvl)).not.toThrow() + const ar = calculateEnchantAttackRating(slvl) + expect(Number.isFinite(ar)).toBe(true) + expect(Number.isNaN(ar)).toBe(false) + expect(ar).toBeGreaterThanOrEqual(0) + } + expect(calculateEnchantAttackRating(0)).toBe(0) + expect(calculateEnchantAttackRating(-1)).toBe(0) + expect(calculateEnchantAttackRating(NaN)).toBe(0) + expect(calculateEnchantAttackRating(undefined as any)).toBe(0) + expect(calculateEnchantAttackRating(1)).toBe(20) + expect(calculateEnchantAttackRating(20)).toBe(20 + 19 * 9) // 191% + expect(calculateEnchantAttackRating(99)).toBe(20 + 98 * 9) // 902% + expect(calculateEnchantAttackRating(1000)).toBe(20 + 999 * 9) // 9011% + }) + + it('calculateEnchantDuration: clamps to 0 for non-positive or non-finite inputs', () => { + for (const slvl of testLevels) { + expect(() => calculateEnchantDuration(slvl)).not.toThrow() + const dur = calculateEnchantDuration(slvl) + expect(Number.isFinite(dur)).toBe(true) + expect(Number.isNaN(dur)).toBe(false) + expect(dur).toBeGreaterThanOrEqual(0) + } + expect(calculateEnchantDuration(0)).toBe(0) + expect(calculateEnchantDuration(-1)).toBe(0) + expect(calculateEnchantDuration(NaN)).toBe(0) + expect(calculateEnchantDuration(undefined as any)).toBe(0) + expect(calculateEnchantDuration(1)).toBe(3600) // 144s + expect(calculateEnchantDuration(20)).toBe(3600 + 19 * 600) // 15000 frames + expect(calculateEnchantDuration(99)).toBe(3600 + 98 * 600) // 62400 frames + expect(calculateEnchantDuration(1000)).toBe(3600 + 999 * 600) // 603000 frames + }) + + it('calculateInfernoContinuousMana256: safely handles degenerate and extreme inputs', () => { + for (const slvl of testLevels) { + expect(() => calculateInfernoContinuousMana256(slvl)).not.toThrow() + const mana256 = calculateInfernoContinuousMana256(slvl) + expect(Number.isFinite(mana256)).toBe(true) + expect(Number.isNaN(mana256)).toBe(false) + expect(mana256).toBeGreaterThanOrEqual(144) // Level 1 drain + } + expect(calculateInfernoContinuousMana256(0)).toBe(144) + expect(calculateInfernoContinuousMana256(-1)).toBe(144) + expect(calculateInfernoContinuousMana256(NaN)).toBe(144) + expect(calculateInfernoContinuousMana256(undefined as any)).toBe(144) + expect(calculateInfernoContinuousMana256(1)).toBe(144) + expect(calculateInfernoContinuousMana256(20)).toBe((36 + 19) * 4) // 220 + expect(calculateInfernoContinuousMana256(99)).toBe((36 + 98) * 4) // 536 + expect(calculateInfernoContinuousMana256(1000)).toBe((36 + 999) * 4) // 4140 + }) + }) + + describe('Cold Armor & Cold Projectile Damage and Freeze Calculations', () => { + it('calculateShiverArmorDamage: safe fallbacks, finite ranges, min <= max', () => { + for (const slvl of testLevels) { + expect(() => calculateShiverArmorDamage(slvl)).not.toThrow() + const dmg = calculateShiverArmorDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min).toBeGreaterThanOrEqual(6) + } + expect(calculateShiverArmorDamage(0)).toEqual({ min: 6, max: 8 }) + expect(calculateShiverArmorDamage(-1)).toEqual({ min: 6, max: 8 }) + expect(calculateShiverArmorDamage(NaN)).toEqual({ min: 6, max: 8 }) + expect(calculateShiverArmorDamage(undefined as any)).toEqual({ min: 6, max: 8 }) + expect(calculateShiverArmorDamage(99).min).toBeGreaterThan(calculateShiverArmorDamage(20).min) + expect(calculateShiverArmorDamage(1000).min).toBeGreaterThan(calculateShiverArmorDamage(99).min) + }) + + it('calculateChillingArmorDamage: safe fallbacks, finite ranges, min <= max', () => { + for (const slvl of testLevels) { + expect(() => calculateChillingArmorDamage(slvl)).not.toThrow() + const dmg = calculateChillingArmorDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min).toBeGreaterThanOrEqual(4) + } + expect(calculateChillingArmorDamage(0)).toEqual({ min: 4, max: 6 }) + expect(calculateChillingArmorDamage(-1)).toEqual({ min: 4, max: 6 }) + expect(calculateChillingArmorDamage(NaN)).toEqual({ min: 4, max: 6 }) + expect(calculateChillingArmorDamage(undefined as any)).toEqual({ min: 4, max: 6 }) + expect(calculateChillingArmorDamage(99).min).toBeGreaterThan(calculateChillingArmorDamage(20).min) + expect(calculateChillingArmorDamage(1000).min).toBeGreaterThan(calculateChillingArmorDamage(99).min) + }) + + it('calculateIceBlastDamage: safe fallbacks, finite ranges, min <= max', () => { + for (const slvl of testLevels) { + expect(() => calculateIceBlastDamage(slvl)).not.toThrow() + const dmg = calculateIceBlastDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min).toBeGreaterThanOrEqual(8) + } + expect(calculateIceBlastDamage(0)).toEqual({ min: 8, max: 12 }) + expect(calculateIceBlastDamage(-1)).toEqual({ min: 8, max: 12 }) + expect(calculateIceBlastDamage(NaN)).toEqual({ min: 8, max: 12 }) + expect(calculateIceBlastDamage(undefined as any)).toEqual({ min: 8, max: 12 }) + expect(calculateIceBlastDamage(99).min).toBeGreaterThan(calculateIceBlastDamage(20).min) + expect(calculateIceBlastDamage(1000).min).toBeGreaterThan(calculateIceBlastDamage(99).min) + }) + + it('calculateIceBlastFreezeLength: safe fallbacks, positive finite frames', () => { + for (const slvl of testLevels) { + expect(() => calculateIceBlastFreezeLength(slvl)).not.toThrow() + const frz = calculateIceBlastFreezeLength(slvl) + expect(Number.isFinite(frz)).toBe(true) + expect(frz).toBeGreaterThanOrEqual(75) // Level 1 freeze length + } + expect(calculateIceBlastFreezeLength(0)).toBe(75) + expect(calculateIceBlastFreezeLength(-1)).toBe(75) + expect(calculateIceBlastFreezeLength(NaN)).toBe(75) + expect(calculateIceBlastFreezeLength(undefined as any)).toBe(75) + expect(calculateIceBlastFreezeLength(99)).toBe(75 + 98 * 5) // 565 frames + expect(calculateIceBlastFreezeLength(1000)).toBe(75 + 999 * 5) // 5070 frames + }) + + it('calculateGlacialSpikeDamage: safe fallbacks, finite ranges, min <= max', () => { + for (const slvl of testLevels) { + expect(() => calculateGlacialSpikeDamage(slvl)).not.toThrow() + const dmg = calculateGlacialSpikeDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min).toBeGreaterThanOrEqual(16) + } + expect(calculateGlacialSpikeDamage(0)).toEqual({ min: 16, max: 24 }) + expect(calculateGlacialSpikeDamage(-1)).toEqual({ min: 16, max: 24 }) + expect(calculateGlacialSpikeDamage(NaN)).toEqual({ min: 16, max: 24 }) + expect(calculateGlacialSpikeDamage(undefined as any)).toEqual({ min: 16, max: 24 }) + expect(calculateGlacialSpikeDamage(99).min).toBeGreaterThan(calculateGlacialSpikeDamage(20).min) + expect(calculateGlacialSpikeDamage(1000).min).toBeGreaterThan(calculateGlacialSpikeDamage(99).min) + }) + + it('calculateGlacialSpikeFreezeLength: safe fallbacks, positive finite frames', () => { + for (const slvl of testLevels) { + expect(() => calculateGlacialSpikeFreezeLength(slvl)).not.toThrow() + const frz = calculateGlacialSpikeFreezeLength(slvl) + expect(Number.isFinite(frz)).toBe(true) + expect(frz).toBeGreaterThanOrEqual(50) // Level 1 freeze length + } + expect(calculateGlacialSpikeFreezeLength(0)).toBe(50) + expect(calculateGlacialSpikeFreezeLength(-1)).toBe(50) + expect(calculateGlacialSpikeFreezeLength(NaN)).toBe(50) + expect(calculateGlacialSpikeFreezeLength(undefined as any)).toBe(50) + expect(calculateGlacialSpikeFreezeLength(99)).toBe(50 + 98 * 3) // 344 frames + expect(calculateGlacialSpikeFreezeLength(1000)).toBe(50 + 999 * 3) // 3047 frames + }) + }) + + describe('Fire Ground, Stream & Buff Calculations', () => { + it('calculateFireWallDamage: safe fallbacks, finite ranges and 256ths precision', () => { + for (const slvl of testLevels) { + expect(() => calculateFireWallDamage(slvl)).not.toThrow() + const dmg = calculateFireWallDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(Number.isFinite(dmg.min256)).toBe(true) + expect(Number.isFinite(dmg.max256)).toBe(true) + expect(Number.isFinite(dmg.perSecMin)).toBe(true) + expect(Number.isFinite(dmg.perSecMax)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min256).toBeLessThanOrEqual(dmg.max256) + expect(dmg.perSecMin).toBeLessThanOrEqual(dmg.perSecMax) + } + const fallback = calculateFireWallDamage(0) + expect(fallback.min).toBe(15) + expect(fallback.max).toBe(20) + expect(fallback.min256).toBe(240) + expect(fallback.max256).toBe(320) + expect(calculateFireWallDamage(-1)).toEqual(fallback) + expect(calculateFireWallDamage(NaN)).toEqual(fallback) + expect(calculateFireWallDamage(undefined as any)).toEqual(fallback) + }) + + it('calculateBlazeDamage: safe fallbacks, finite ranges and 256ths precision', () => { + for (const slvl of testLevels) { + expect(() => calculateBlazeDamage(slvl)).not.toThrow() + const dmg = calculateBlazeDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(Number.isFinite(dmg.min256)).toBe(true) + expect(Number.isFinite(dmg.max256)).toBe(true) + expect(Number.isFinite(dmg.perSecMin)).toBe(true) + expect(Number.isFinite(dmg.perSecMax)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min256).toBeLessThanOrEqual(dmg.max256) + expect(dmg.perSecMin).toBeLessThanOrEqual(dmg.perSecMax) + } + const fallback = calculateBlazeDamage(0) + expect(fallback.min).toBe(4) + expect(fallback.max).toBe(8) + expect(fallback.min256).toBe(64) + expect(fallback.max256).toBe(128) + expect(calculateBlazeDamage(-1)).toEqual(fallback) + expect(calculateBlazeDamage(NaN)).toEqual(fallback) + expect(calculateBlazeDamage(undefined as any)).toEqual(fallback) + }) + + it('calculateInfernoDamage: safe fallbacks, finite ranges and 256ths precision', () => { + for (const slvl of testLevels) { + expect(() => calculateInfernoDamage(slvl)).not.toThrow() + const dmg = calculateInfernoDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(Number.isFinite(dmg.min256)).toBe(true) + expect(Number.isFinite(dmg.max256)).toBe(true) + expect(Number.isFinite(dmg.perSecMin)).toBe(true) + expect(Number.isFinite(dmg.perSecMax)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min256).toBeLessThanOrEqual(dmg.max256) + expect(dmg.perSecMin).toBeLessThanOrEqual(dmg.perSecMax) + } + const fallback = calculateInfernoDamage(0) + expect(fallback.min).toBe(32) + expect(fallback.max).toBe(64) + expect(fallback.min256).toBe(128) + expect(fallback.max256).toBe(256) + expect(calculateInfernoDamage(-1)).toEqual(fallback) + expect(calculateInfernoDamage(NaN)).toEqual(fallback) + expect(calculateInfernoDamage(undefined as any)).toEqual(fallback) + }) + + it('calculateEnchantDamage: safe fallbacks, finite ranges and 256ths precision', () => { + for (const slvl of testLevels) { + expect(() => calculateEnchantDamage(slvl)).not.toThrow() + const dmg = calculateEnchantDamage(slvl) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(Number.isFinite(dmg.min256)).toBe(true) + expect(Number.isFinite(dmg.max256)).toBe(true) + expect(dmg.min).toBeLessThanOrEqual(dmg.max) + expect(dmg.min256).toBeLessThanOrEqual(dmg.max256) + } + const fallback = calculateEnchantDamage(0) + expect(fallback.min).toBe(8) + expect(fallback.max).toBe(10) + expect(fallback.min256).toBe(2048) + expect(fallback.max256).toBe(2560) + expect(calculateEnchantDamage(-1)).toEqual(fallback) + expect(calculateEnchantDamage(NaN)).toEqual(fallback) + expect(calculateEnchantDamage(undefined as any)).toEqual(fallback) + }) + }) + + describe('Synergy Degenerate Inputs Fuzzing', () => { + it('handles negative, null, empty object, and partial synergies without throwing or propagating NaN', () => { + const degenerateSynergies = [ + null, + undefined, + 0, + -5, + -999, + {}, + { warmth: -10, fireMastery: -20 }, + { frozenArmor: -1, chillingArmor: -5 }, + { iceBolt: -5, blizzard: 0, frozenOrb: -10, glacialSpike: undefined }, + { synMultiplier: 0.5 }, + { synMultiplier: 1.0 }, + ] + + for (const syn of degenerateSynergies) { + expect(() => calculateShiverArmorDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateShiverArmorDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateChillingArmorDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateChillingArmorDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateIceBlastDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateIceBlastDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateIceBlastFreezeLength(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateIceBlastFreezeLength(10, syn as any))).toBe(false) + + expect(() => calculateGlacialSpikeDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateGlacialSpikeDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateGlacialSpikeFreezeLength(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateGlacialSpikeFreezeLength(10, syn as any))).toBe(false) + + expect(() => calculateFireWallDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateFireWallDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateBlazeDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateBlazeDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateInfernoDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateInfernoDamage(10, syn as any).min)).toBe(false) + + expect(() => calculateEnchantDamage(10, syn as any)).not.toThrow() + expect(Number.isNaN(calculateEnchantDamage(10, syn as any).min)).toBe(false) + } + }) + }) + }) + + // ========================================================================== + // Dimension 2: Mana Depletion and Channeling Exhaustion + // ========================================================================== + describe('Dimension 2: Mana Depletion and Channeling Exhaustion', () => { + it('Inferno channeled stream: halts immediately when mana reaches 0 mid-stream and cleans up resources', () => { + const player = { + x: 100, + y: 100, + facing: 0, + cooldown: 0, + mana: 10, + maxMana: 100, + infernoChanneling: false, + infernoManaRemainder256: 0, + } + const engine: any = { + world: { + player, + events: [], + }, + projectiles: [], + spawnOverlay: () => {}, + metrics: { casts: 0 }, + } + const status = { textContent: '' } as any + const context = { + engine, + runtime: {} as any, + hudManager: null, + status, + } + + // Step 1: Initial cast consumes 6 mana + const cast1 = castSkill(41, 200, 100, context) + expect(cast1).toBe(true) + expect(player.mana).toBe(4) // 10 - 6 = 4 + expect(player.infernoChanneling).toBe(true) + expect(player.infernoManaRemainder256).toBe(0) + expect(engine.projectiles).toHaveLength(1) + expect(engine.metrics.casts).toBe(1) + + // Step 2: Continuous channeling frames (slvl 1 drains 144 / 256 per frame) + // Call 2: frame 1: accumulator 144, wholeMana 0, mana remains 4 + const cast2 = castSkill(41, 200, 100, context) + expect(cast2).toBe(true) + expect(player.mana).toBe(4) + expect(player.infernoManaRemainder256).toBe(144) + expect(engine.projectiles).toHaveLength(2) + + // Call 3: frame 2: accumulator 144+144 = 288, wholeMana 1, remainder 32, mana becomes 3 + const cast3 = castSkill(41, 200, 100, context) + expect(cast3).toBe(true) + expect(player.mana).toBe(3) + expect(player.infernoManaRemainder256).toBe(32) + expect(engine.projectiles).toHaveLength(3) + + // Step 3: Deplete player mana to 0 mid-stream + player.mana = 0 + player.infernoManaRemainder256 = 100 + + // Call 4: Next frame while mana is 0 -> MUST halt stream and reject cast + const castEmpty = castSkill(41, 200, 100, context) + expect(castEmpty).toBe(false) + expect(status.textContent).toBe('法力不足!') + expect(player.infernoChanneling).toBe(false) + expect(player.infernoManaRemainder256).toBe(0) + // Ensure no new projectile was spawned when mana exhausted + expect(engine.projectiles).toHaveLength(3) + expect(engine.world.events).toContainEqual( + expect.objectContaining({ kind: 'noMana' }), + ) + + // Step 4: Subsequent call while exhausted continues to reject cleanly + const castSubsequent = castSkill(41, 200, 100, context) + expect(castSubsequent).toBe(false) + expect(player.infernoChanneling).toBe(false) + expect(engine.projectiles).toHaveLength(3) + }) + + it('Inferno: starting cast is rejected immediately when mana < 6 without setting channeling state', () => { + const player = { + x: 100, + y: 100, + facing: 0, + cooldown: 0, + mana: 5, // Insufficient (needs 6) + maxMana: 100, + infernoChanneling: false, + infernoManaRemainder256: 0, + } + const engine: any = { + world: { + player, + events: [], + }, + projectiles: [], + spawnOverlay: () => {}, + metrics: { casts: 0 }, + } + const status = { textContent: '' } as any + const context = { + engine, + runtime: {} as any, + hudManager: null, + status, + } + + const res = castSkill(41, 200, 100, context) + expect(res).toBe(false) + expect(player.mana).toBe(5) // Untouched + expect(player.infernoChanneling).toBe(false) + expect(player.infernoManaRemainder256).toBe(0) + expect(engine.projectiles).toHaveLength(0) + expect(engine.world.events).toContainEqual( + expect.objectContaining({ kind: 'noMana' }), + ) + }) + + it('rejects casting spells when mana < cost across all Cold and Fire skills without state corruption', () => { + const skillsToTest = [ + { id: 40, name: 'Frozen Armor' }, + { id: 50, name: 'Shiver Armor' }, + { id: 60, name: 'Chilling Armor' }, + { id: 45, name: 'Ice Blast' }, + { id: 55, name: 'Glacial Spike' }, + { id: 51, name: 'Fire Wall' }, + { id: 46, name: 'Blaze' }, + { id: 41, name: 'Inferno' }, + { id: 52, name: 'Enchant' }, + { id: 59, name: 'Blizzard' }, + { id: 56, name: 'Meteor' }, + ] + + for (const skill of skillsToTest) { + const manaCost = getSkillMana(skill.id, null) + const insufficientMana = Math.max(0, manaCost - 1) + const player = { + x: 100, + y: 100, + facing: 0, + cooldown: 0, + mana: insufficientMana, + maxMana: 100, + infernoChanneling: false, + infernoManaRemainder256: 0, + blazeActive: false, + enchantActive: false, + } + const engine: any = { + world: { + player, + events: [], + }, + projectiles: [], + overlays: [], + spawnOverlay: () => {}, + metrics: { casts: 0 }, + } + const status = { textContent: '' } as any + const context = { + engine, + runtime: {} as any, + hudManager: null, + status, + } + + const success = castSkill(skill.id, 200, 100, context) + expect(success).toBe(false) + // INVARIANTS: Zero resource/state corruption + expect(player.mana).toBe(insufficientMana) + expect(player.cooldown).toBe(0) + expect(engine.metrics.casts).toBe(0) + expect(engine.projectiles).toHaveLength(0) + expect(engine.overlays).toHaveLength(0) + expect(status.textContent).toBe('法力不足!') + expect(engine.world.events).toContainEqual( + expect.objectContaining({ kind: 'noMana' }), + ) + } + }) + }) + + // ========================================================================== + // Dimension 3: Cooldown & Cast Delay Integrity + // ========================================================================== + describe('Dimension 3: Cooldown & Cast Delay Integrity', () => { + it('verifies exact 1.13c cast delay values across delayed skills and zero cooldown for non-delayed skills', () => { + // Delayed skills: + expect(getSkillCooldownTicks(51)).toBe(35) // Fire Wall: 35 frames (1.40s) + expect(getSkillCooldownTicks(56)).toBe(30) // Meteor: 30 frames (1.20s) + expect(getSkillCooldownTicks(59)).toBe(45) // Blizzard: 45 frames (1.80s) + expect(getSkillCooldownTicks(62)).toBe(40) // Hydra: 40 frames (1.60s) + expect(getSkillCooldownTicks(64)).toBe(25) // Frozen Orb: 25 frames (1.00s) + + // Non-delayed Cold & Fire skills (strictly 0 frames): + const nonDelayedColdFireSkills = [ + 40, // Frozen Armor + 50, // Shiver Armor + 60, // Chilling Armor + 45, // Ice Blast + 55, // Glacial Spike + 65, // Cold Mastery + 36, // Fire Bolt + 47, // Fire Ball + 37, // Warmth + 41, // Inferno + 46, // Blaze + 52, // Enchant + 61, // Fire Mastery + ] + for (const skillId of nonDelayedColdFireSkills) { + expect(getSkillCooldownTicks(skillId)).toBe(0) + } + }) + + it('executeSkill: cast delay locks global cast delay group while allowing non-delayed spells', () => { + const caster = createPlayer(100, 100, 100, 100) + const context: SkillExecutionContext = { + caster, + skillLevel: 1, + } + + // Step 1: Simulate caster on Blizzard cast delay (45 ticks remaining) + caster.cooldown = 45 + + // Step 2: Attempting to cast any other delayed spell MUST be rejected by cooldown check + // Werewolf (223, delay 25) + const delayedCast1 = executeSkill(223, context) + expect(delayedCast1.kind).toBe('cooldown') + if (delayedCast1.kind === 'cooldown') { + expect(delayedCast1.ticksLeft).toBe(45) + } + + // Firestorm (225, delay 15) + const delayedCast2 = executeSkill(225, context) + expect(delayedCast2.kind).toBe('cooldown') + if (delayedCast2.kind === 'cooldown') { + expect(delayedCast2.ticksLeft).toBe(45) + } + + // Molten Boulder (229, delay 50) + const delayedCast3 = executeSkill(229, context) + expect(delayedCast3.kind).toBe('cooldown') + if (delayedCast3.kind === 'cooldown') { + expect(delayedCast3.ticksLeft).toBe(45) + } + + // Step 3: Under the exact same cooldown (cooldown = 45), casting non-delayed spells MUST succeed! + // Ice Bolt (39, delay 0) + const nonDelayed1 = executeSkill(39, context) + expect(nonDelayed1.kind).toBe('projectile') + if (nonDelayed1.kind === 'projectile') { + expect(nonDelayed1.cooldownTicks).toBe(0) + } + + // Fire Bolt (36, delay 0) + const nonDelayed2 = executeSkill(36, context) + expect(nonDelayed2.kind).toBe('projectile') + if (nonDelayed2.kind === 'projectile') { + expect(nonDelayed2.cooldownTicks).toBe(0) + } + + // Fire Ball (47, delay 0) + const nonDelayed3 = executeSkill(47, context) + expect(nonDelayed3.kind).toBe('projectile') + if (nonDelayed3.kind === 'projectile') { + expect(nonDelayed3.cooldownTicks).toBe(0) + } + + // Frost Nova (44, delay 0) + const nonDelayed4 = executeSkill(44, context) + expect(nonDelayed4.kind).toBe('area') + if (nonDelayed4.kind === 'area') { + expect(nonDelayed4.cooldownTicks).toBe(0) + } + + // Teleport (54, delay 0) + const nonDelayed5 = executeSkill(54, context) + expect(nonDelayed5.kind).toBe('movement') + if (nonDelayed5.kind === 'movement') { + expect(nonDelayed5.cooldownTicks).toBe(0) + } + }) + + it('castSkill: verifies cooldown duration application and tick countdown unlocking', () => { + const player = { + x: 100, + y: 100, + facing: 0, + cooldown: 0, + mana: 100, + maxMana: 100, + infernoChanneling: false, + infernoManaRemainder256: 0, + } + const engine: any = { + world: { + player, + events: [], + }, + projectiles: [], + spawnOverlay: () => {}, + metrics: { casts: 0 }, + } + const status = { textContent: '' } as any + const context = { + engine, + runtime: {} as any, + hudManager: null, + status, + } + + // Cast Fire Wall (51): sets cooldown to exactly 35 + const fwCast = castSkill(51, 200, 100, context) + expect(fwCast).toBe(true) + expect(player.cooldown).toBe(35) + + // While cooldown > 0, immediate second cast is blocked + const blockedCast = castSkill(51, 200, 100, context) + expect(blockedCast).toBe(false) + + // Countdown ticks from 35 down to 1 + for (let t = 35; t > 0; t--) { + expect(player.cooldown).toBe(t) + const rejected = castSkill(51, 200, 100, context) + expect(rejected).toBe(false) + player.cooldown -= 1 + } + + // Once at 0, casting succeeds again + expect(player.cooldown).toBe(0) + const unblockedCast = castSkill(51, 200, 100, context) + expect(unblockedCast).toBe(true) + expect(player.cooldown).toBe(35) + + // Meteor (56): sets cooldown to exactly 30 + player.cooldown = 0 + const meteorCast = castSkill(56, 200, 100, context) + expect(meteorCast).toBe(true) + expect(player.cooldown).toBe(30) + + // Blizzard (59): sets cooldown to exactly 45 + player.cooldown = 0 + const blizzardCast = castSkill(59, 200, 100, context) + expect(blizzardCast).toBe(true) + expect(player.cooldown).toBe(45) + }) + }) + + // ========================================================================== + // Dimension 4: Screen & Spatial Collision Boundaries + // ========================================================================== + describe('Dimension 4: Screen & Spatial Collision Boundaries', () => { + describe('Raycasting (hasLineOfSight) Boundary & Stress Tests', () => { + it('hasLineOfSight: handles extreme coordinates (-9999 to +9999) without deadlocks, NaN or stack overflows', () => { + const extremeVectors = [ + { x0: -9999, y0: -9999, x1: 9999, y1: 9999 }, + { x0: 9999, y0: -9999, x1: -9999, y1: 9999 }, + { x0: -9999, y0: 0, x1: 9999, y1: 0 }, + { x0: 0, y0: -9999, x1: 0, y1: 9999 }, + { x0: -9999, y0: 9999, x1: 9999, y1: -9999 }, + ] + + // Open terrain (no obstacles) -> completes rapidly + for (const vec of extremeVectors) { + const t0 = Date.now() + const result = hasLineOfSight(vec.x0, vec.y0, vec.x1, vec.y1, undefined) + const elapsed = Date.now() - t0 + expect(result).toBe(true) + expect(elapsed).toBeLessThan(100) // Must not hang/deadlock + } + + // Obstacle in path at (0, 0) + const wallTerrain = (x: number, y: number) => Math.hypot(x, y) < 16 + expect(hasLineOfSight(-100, 0, 100, 0, wallTerrain)).toBe(false) + // Clear trajectory above obstacle + expect(hasLineOfSight(-100, 50, 100, 50, wallTerrain)).toBe(true) + }) + + it('hasLineOfSight: handles sub-pixel fractions and degenerate zero distance', () => { + // Zero distance (x0 === x1, y0 === y1) + expect(hasLineOfSight(500, 500, 500, 500)).toBe(true) + expect(hasLineOfSight(-9999.123, 9999.456, -9999.123, 9999.456)).toBe(true) + + // Sub-pixel micro increments + expect(hasLineOfSight(100.123456, 200.654321, 100.123457, 200.654322)).toBe(true) + }) + }) + + describe('MissileEngine Projectile & Collision Stress', () => { + it('spawns and steps Glacial Spike & Ice Blast at extreme coordinates without NaN or crashes', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + const target = createMockCombatUnit('far_target') + + // Spawn Glacial Spike across extreme boundary: start (-9999, -9999), target (+9999, +9999) + const mGlacial = engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 20, + owner: attacker, + startX: -9999, + startY: -9999, + targetX: 9999, + targetY: 9999, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 1000 * 256, + elemMax256: 1000 * 256, + elemDurationFrames: 100, + }, + }) + expect(mGlacial).not.toBeNull() + expect(Number.isFinite(mGlacial!.vx)).toBe(true) + expect(Number.isFinite(mGlacial!.vy)).toBe(true) + expect(Number.isNaN(mGlacial!.vx)).toBe(false) + expect(Number.isNaN(mGlacial!.vy)).toBe(false) + + // Spawn Ice Blast at reverse extreme coordinates (+9999, -9999) -> (-9999, +9999) + const mBlast = engine.spawnMissile({ + missileNameOrId: 'iceblast', + sourceSkillId: 45, + slvl: 20, + owner: attacker, + startX: 9999, + startY: -9999, + targetX: -9999, + targetY: 9999, + dmgPacket: { + skillId: 45, + elemType: 'cold', + elemMin256: 1000 * 256, + elemMax256: 1000 * 256, + elemDurationFrames: 100, + }, + }) + expect(mBlast).not.toBeNull() + expect(Number.isFinite(mBlast!.vx)).toBe(true) + expect(Number.isFinite(mBlast!.vy)).toBe(true) + + // Update loop with target at extreme coordinate + const targetPositions = new Map() + targetPositions.set('far_target', { x: 9999, y: 9999 }) + expect(() => engine.tick(1, [target], targetPositions)).not.toThrow() + }) + + it('Glacial Spike AoE burst: strictly respects 64px radial boundary on wall collision at sub-pixel coordinates', async () => { + const registry = await getSharedDataRegistry() + const attacker = createMockCombatUnit('sorceress') + + // Target exactly at wall impact point (0px offset) + const tDirect = createMockCombatUnit('t_direct') + // Target at 64.0px radius (inside burst boundary) + const tEdgeIn = createMockCombatUnit('t_64px') + // Target at 64.1px radius (outside burst boundary) + const tEdgeOut = createMockCombatUnit('t_64_1px') + // Target at 100px radius (far outside) + const tFar = createMockCombatUnit('t_100px') + + const targets = [tDirect, tEdgeIn, tEdgeOut, tFar] + const targetPositions = new Map() + + // Sub-pixel wall impact coordinate: (100.25, 200.75) + const impactX = 100.25 + const impactY = 200.75 + + targetPositions.set(tDirect.id, { x: impactX, y: impactY }) + targetPositions.set(tEdgeIn.id, { x: impactX, y: impactY + 64.0 }) + targetPositions.set(tEdgeOut.id, { x: impactX, y: impactY + 64.1 }) + targetPositions.set(tFar.id, { x: impactX, y: impactY + 100.0 }) + + // Terrain with blocking obstacle at impactX + const terrain = { + isMissileBlocked: (x: number) => x >= impactX, + } + + const engine = new MissileEngine(registry, terrain) + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 20, + owner: attacker, + startX: impactX - 24, + startY: impactY, + targetX: impactX + 24, + targetY: impactY, + vx: 24, + vy: 0, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 500 * 256, + elemMax256: 500 * 256, + elemDurationFrames: 100, + }, + }) + + const updateResult = engine.tick(1, targets, targetPositions, terrain) + + // Sub-missiles spawned on detonation + expect(updateResult.subMissilesSpawned).toContain('glacialspikeejecta') + + // Verify splash hits: tDirect and tEdgeIn hit, tEdgeOut and tFar NOT hit + const hitTargetIds = updateResult.hits.map(h => h.targetId) + expect(hitTargetIds).toContain(tDirect.id) + expect(hitTargetIds).toContain(tEdgeIn.id) + expect(hitTargetIds).not.toContain(tEdgeOut.id) + expect(hitTargetIds).not.toContain(tFar.id) + + // Freeze state applied to units within 64px + expect(tDirect.stateBus.hasState('freeze')).toBe(true) + expect(tEdgeIn.stateBus.hasState('freeze')).toBe(true) + expect(tEdgeOut.stateBus.hasState('freeze')).toBe(false) + expect(tFar.stateBus.hasState('freeze')).toBe(false) + }) + + it('calculateFireWallPatches: produces robust integer layouts at extreme coordinates and zero distance', () => { + // Extreme coordinates + const patchesExtreme = calculateFireWallPatches(-9999, -9999, 9999, 9999) + expect(patchesExtreme).toHaveLength(15) + for (const p of patchesExtreme) { + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + expect(Number.isNaN(p.x)).toBe(false) + expect(Number.isNaN(p.y)).toBe(false) + } + + // Degenerate zero distance: caster === target + const patchesZero = calculateFireWallPatches(150, 150, 150, 150) + expect(patchesZero).toHaveLength(15) + for (const p of patchesZero) { + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + } + + // Sub-pixel positions + const patchesSub = calculateFireWallPatches(10.123, 20.456, 30.789, 40.012) + expect(patchesSub).toHaveLength(15) + for (const p of patchesSub) { + expect(Number.isInteger(p.x)).toBe(true) + expect(Number.isInteger(p.y)).toBe(true) + } + }) + }) + }) +}) diff --git a/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts b/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts new file mode 100644 index 0000000..3e96092 --- /dev/null +++ b/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts @@ -0,0 +1,1276 @@ +/** + * 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) + }) + }) +}) diff --git a/tests/skills/sor/adv-fire-m4-combat-stress.test.ts b/tests/skills/sor/adv-fire-m4-combat-stress.test.ts new file mode 100644 index 0000000..2bdef12 --- /dev/null +++ b/tests/skills/sor/adv-fire-m4-combat-stress.test.ts @@ -0,0 +1,922 @@ +/** + * Adversarial Empirical Stress Testing Suite for Milestone 4: + * Sorceress Fire Combat Mechanics, Weapon Scaling, and Double-Dipping + * + * Target Systems: + * - Skill 52: Enchant (weapon fire damage buff, AR bonus, duration) + * - Skill 61: Fire Mastery (passive fire damage percentage scaling) + * - SUnitDmg Combat Pipeline (executeSUnitDmg, computeEffectiveResistance) + * - Combat Sandbox Simulation (CombatWorld, tickPlayer, tickCombat, tickCombatMulti) + * + * Key Verification Dimensions: + * 1. Ranged Weapon Penalty: strictly 33% of Enchant fire damage bonus on ranged weapons (bow/crossbow/throwing) + * vs 100% on melee weapons. + * 2. Sorceress Melee Double-Dip: + * - Cast time: Enchant damage scaled by Fire Mastery (+163% at slvl 20 -> 2.63x). + * - Melee strike: Sorceress applies Fire Mastery (+163% -> 2.63x) again upon hit: base * (1 + 1.63)^2 = 473.8 - 619.1! + * - Ranged strike: Sorceress attacks at range -> second Fire Mastery is NOT applied. + * 3. Non-Sorceress Buff Recipient (Allies / Pets): + * - Allies receive single-multiplied buff damage on melee (no double-dip). + * - Allies receive 33% on ranged. + * 4. Fire Immunity Preservation: + * - Target Fire Resist = 100%, 110%, 150%: strictly 0 fire damage dealt. + * - Confirms Fire Mastery has zero resistance pierce and cannot break immunities. + * - Target Fire Resist = 50% and 0%: verified exact proportional mitigation. + * 5. Resistance Format Interoperability & Lifecycle Boundary Edge Cases. + */ + +import { describe, expect, it } from 'vitest' +import { + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateEnchantAttackDamage, +} 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 { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' + +/** Helper to create a fully initialized CombatUnitContext */ +function createCombatUnit( + id: string, + options?: { + hp?: number + isSorceress?: boolean + fireMasterySlvl?: number + fireResist?: number + hasEnchantState?: boolean + }, +): CombatUnitContext { + const statList = new UnitStatList() + const hp = options?.hp ?? 1000 + statList.setBaseStat('hp', hp * FIXED_ONE) + statList.setBaseStat('maxhp', hp * FIXED_ONE) + statList.setBaseStat('hitpoints', hp * FIXED_ONE) + statList.setBaseStat('level', 80) + statList.setBaseStat('tohit', 10000) // guarantee 95% or autohit + statList.setBaseStat('armorclass', 100) + + if (options?.fireMasterySlvl && options.fireMasterySlvl > 0) { + statList.setBaseSkillLevel(61, options.fireMasterySlvl) + } + + if (options?.fireResist !== undefined) { + statList.setBaseStat('fireresist', options.fireResist) + } + + const stateBus = new StateBus(statList) + if (options?.hasEnchantState) { + stateBus.applyState({ stateNameOrId: 'enchant', durationFrames: 15000 }) + } + + return { + id, + name: id, + statList, + stateBus, + weaponMinPhys: 10, + weaponMaxPhys: 10, + } +} + +/** Flat mock terrain */ +const mockTerrain: CombatTerrain = { + overlap: () => 0, +} + +/** Standard combat options for deterministic tests */ +const baseOptions: CombatOptions = { + playerSpeed: 100, + playerReach: 50, + playerCooldownTicks: 10, + playerDamage: 10, // Base physical damage = 10 + playerManaPerAttack: 0, + respawnTicks: 100, +} + +describe('Milestone 4 Adversarial Empirical Stress Test Suite (Combat, Scaling, Double-Dip)', () => { + // ========================================================================== + // Suite 1: Ranged Weapon Penalty (33% on Ranged vs 100% on Melee) + // ========================================================================== + describe('Suite 1: Ranged Weapon Penalty (33% Ranged vs 100% Melee)', () => { + it('verifies calculateEnchantAttackDamage strictly scales ranged attacks by 33% across slvl 1..40', () => { + for (const slvl of [1, 5, 10, 15, 20, 25, 30, 35, 40]) { + const baseDmg = calculateEnchantDamage(slvl) + + // Melee non-sorceress attack (100% buff damage) + const meleeRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: false, + isSorceress: false, + }) + expect(meleeRes.min).toBe(baseDmg.min) + expect(meleeRes.max).toBe(baseDmg.max) + + // Ranged non-sorceress attack (strictly 33% of buff damage) + const rangedRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: true, + isSorceress: false, + }) + + const expectedRangedMin = Math.round(baseDmg.min * 0.33 * 10) / 10 + const expectedRangedMax = Math.round(baseDmg.max * 0.33 * 10) / 10 + + expect(rangedRes.min).toBe(expectedRangedMin) + expect(rangedRes.max).toBe(expectedRangedMax) + expect(rangedRes.average).toBe(Math.round(((expectedRangedMin + expectedRangedMax) / 2) * 10) / 10) + + // Verify ratio is within rounding precision of 33% + const ratio = rangedRes.average / meleeRes.average + expect(ratio).toBeGreaterThan(0.32) + expect(ratio).toBeLessThan(0.34) + } + }) + + it('verifies combat pipeline executeSUnitDmg applies 33% penalty to missile fire damage while physical damage is untouched', () => { + const attacker = createCombatUnit('attacker_unit', { hasEnchantState: true }) + const defender = createCombatUnit('defender_unit', { fireResist: 0 }) + + // Base Enchant fire damage in 256ths: 100.0 fire damage = 25600 + const enchantFire256 = 100 * FIXED_ONE + + // 1. Melee attack: full 100% fire damage + const meleePacket: SUnitDmgPacket = { + skillId: 52, + attackKind: 'melee', + isEnchant: true, + elemType: 'fire', + elemMin256: enchantFire256, + elemMax256: enchantFire256, + flatPhysMin256: 0, + flatPhysMax256: 0, + autoHit: true, + } + const meleeOutcome = executeSUnitDmg(attacker, defender, meleePacket) + expect(meleeOutcome.hit).toBe(true) + expect(meleeOutcome.physDamage256).toBe(10 * FIXED_ONE) + expect(meleeOutcome.elemDamage256).toBe(enchantFire256) + + // 2. Missile attack: strictly 33% fire damage, physical damage remains 10 * FIXED_ONE + const rangedPacket: SUnitDmgPacket = { + skillId: 52, + attackKind: 'missile', + isEnchant: true, + elemType: 'fire', + elemMin256: enchantFire256, + elemMax256: enchantFire256, + flatPhysMin256: 0, + flatPhysMax256: 0, + autoHit: true, + } + const rangedOutcome = executeSUnitDmg(attacker, defender, rangedPacket) + expect(rangedOutcome.hit).toBe(true) + // Physical damage is completely unaffected by the 33% elemental ranged penalty! + expect(rangedOutcome.physDamage256).toBe(10 * FIXED_ONE) + // Elemental fire damage is strictly Math.trunc(25600 * 33 / 100) = 8448 (= 33.0 fire damage) + const expectedRangedFire256 = Math.trunc((enchantFire256 * 33) / 100) + expect(rangedOutcome.elemDamage256).toBe(expectedRangedFire256) + expect(rangedOutcome.elemDamage256 / FIXED_ONE).toBe(33) + }) + + it('verifies CombatWorld sandbox tickPlayer correctly differentiates melee (100%) vs ranged (33%) weapons', () => { + const world: CombatWorld = createWorld(100, 100) + const targetMonster: Monster = { + index: 0, + stats: { + id: 'test_zombie', + name: 'Zombie', + hp: 2000, + damage: 0, + cooldownTicks: 25, + reach: 20, + aggroRadius: 0, + speed: 0, + xp: 50, + }, + x: 120, + y: 100, + hp: 2000, + cooldown: 0, + state: 'idle', + facing: 0, + hitFlash: 0, + corpseTicks: 0, + } + world.monsters.push(targetMonster) + + // 1. Melee attack with Enchant slvl 20 (base 68.5 - 89.5, average 79) + const baseDmg20 = calculateEnchantDamage(20) + world.player.enchantTicks = 5000 + world.player.enchantDamage = { min: baseDmg20.min, max: baseDmg20.max } + world.player.isRanged = false + world.player.isSorceress = false // non-sorc for pure 100% vs 33% check + + const prevHp = targetMonster.hp + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + + const meleeHitEvent = world.events.find(e => e.kind === 'monsterHit') + expect(meleeHitEvent).toBeDefined() + // total = baseOptions.playerDamage (10) + Math.round(79) = 89 + const meleeTotalDamage = meleeHitEvent!.amount! + expect(meleeTotalDamage).toBe(10 + Math.round((baseDmg20.min + baseDmg20.max) / 2)) + expect(targetMonster.hp).toBe(prevHp - meleeTotalDamage) + + // Reset cooldown and switch to ranged weapon (bow/throwing) + world.player.cooldown = 0 + world.player.isRanged = true + const beforeRangedHp = targetMonster.hp + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + + const rangedHitEvent = world.events.find(e => e.kind === 'monsterHit') + expect(rangedHitEvent).toBeDefined() + const rangedTotalDamage = rangedHitEvent!.amount! + + const expectedRangedFire = Math.round(calculateEnchantAttackDamage({ + enchantDamage: baseDmg20, + isRanged: true, + isSorceress: false, + }).average) + + expect(rangedTotalDamage).toBe(10 + expectedRangedFire) + expect(targetMonster.hp).toBe(beforeRangedHp - rangedTotalDamage) + + // Confirm Enchant fire component on ranged is strictly 33% of melee fire component + const meleeFireComponent = meleeTotalDamage - 10 + const rangedFireComponent = rangedTotalDamage - 10 + expect(rangedFireComponent / meleeFireComponent).toBeCloseTo(0.33, 1) + }) + + it('verifies dynamic weapon swapping mid-buff alternates between 100% melee double-dip and 33% ranged penalty', () => { + const world: CombatWorld = createWorld(100, 100) + const dummy: Monster = { + index: 0, + stats: { id: 'd', name: 'D', hp: 10000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 10 }, + x: 120, y: 100, hp: 10000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + world.monsters.push(dummy) + + applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 }) + world.player.isSorceress = true + world.player.fireMasterySlvl = 20 + + // Strike 1: Melee -> deals 10 physical + 547 fire = 557 + world.player.isRanged = false + world.player.cooldown = 0 + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(557) + + // Strike 2: Swap to bow (isRanged: true) -> deals 10 physical + 69 fire = 79 + world.events = [] + world.player.isRanged = true + world.player.cooldown = 0 + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(79) + + // Strike 3: Swap back to melee weapon -> deals 557 again + world.events = [] + world.player.isRanged = false + world.player.cooldown = 0 + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(557) + }) + }) + + // ========================================================================== + // Suite 2: Sorceress Melee Double-Dip Mechanics + // ========================================================================== + describe('Suite 2: Sorceress Melee Double-Dip Mechanics', () => { + it('verifies exact mathematical double-dip formula at slvl 20: base * (1 + 1.63)^2 = 473.8 - 619.1 fire damage', () => { + const slvl = 20 + const fmSlvl = 20 + const fmBonus = calculateFireMasteryBonus(fmSlvl) // +163% + expect(fmBonus).toBe(163) + + const fmMultiplier = 1.0 + fmBonus / 100 // 2.63 + + // Base Enchant slvl 20 damage (no FM): 68.5 - 89.5 + const baseDmg = calculateEnchantDamage(slvl) + expect(baseDmg.min).toBe(68.5) + expect(baseDmg.max).toBe(89.5) + + // 1. Cast phase: Enchant damage is amplified by Fire Mastery upon casting + const castDmg = calculateEnchantDamage(slvl, undefined, fmSlvl) + expect(castDmg.min).toBe(180.2) // Math.round(68.5 * 2.63 * 10) / 10 = 180.2 + expect(castDmg.max).toBe(235.4) // Math.round(89.5 * 2.63 * 10) / 10 = 235.4 + + // 2. Melee strike phase: Sorceress hits in melee, applying Fire Mastery (+163%) AGAIN + const meleeStrike = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + + // Exact double-dip bounds: + // min = Math.round(180.2 * 2.63 * 10) / 10 = 473.9 + // max = Math.round(235.4 * 2.63 * 10) / 10 = 619.1 + expect(meleeStrike.min).toBe(473.9) + expect(meleeStrike.max).toBe(619.1) + expect(meleeStrike.average).toBe(546.5) + + // Raw unrounded base * (1 + 1.63)^2: + // 68.5 * 2.63^2 = 473.80775 + // 89.5 * 2.63^2 = 619.06275 + expect(meleeStrike.min).toBeCloseTo(baseDmg.min * fmMultiplier * fmMultiplier, 0) + expect(meleeStrike.max).toBeCloseTo(baseDmg.max * fmMultiplier * fmMultiplier, 0) + }) + + it('verifies Sorceress attacking at range strictly does NOT receive the second Fire Mastery application', () => { + const slvl = 20 + const fmSlvl = 20 + const castDmg = calculateEnchantDamage(slvl, undefined, fmSlvl) // 180.2 - 235.4 + + const rangedStrike = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: true, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + + // Strictly 33% of cast damage: + // min = Math.round(180.2 * 0.33 * 10) / 10 = 59.5 + // max = Math.round(235.4 * 0.33 * 10) / 10 = 77.7 + expect(rangedStrike.min).toBe(59.5) + expect(rangedStrike.max).toBe(77.7) + expect(rangedStrike.average).toBe(68.6) + + // Prove that if second FM had been erroneously applied, it would have been: + // 473.9 * 0.33 = 156.4, which is 2.63x higher! + expect(rangedStrike.min).toBeLessThan(100) + expect(rangedStrike.max).toBeLessThan(100) + }) + + it('verifies double-dip scaling across full Fire Mastery progression (slvl 1, 10, 20, 30, 40)', () => { + for (const fmSlvl of [1, 10, 20, 30, 40]) { + const fmBonus = calculateFireMasteryBonus(fmSlvl) + const fmMul = 1.0 + fmBonus / 100 + const expectedDoubleMultiplier = fmMul * fmMul + + const baseDmg = calculateEnchantDamage(20) // base 68.5 - 89.5 + const castDmg = calculateEnchantDamage(20, undefined, fmSlvl) + + const meleeHit = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + + // Verify that melee damage scales as (1 + fmBonus/100)^2 + const actualRatioMin = meleeHit.min / baseDmg.min + const actualRatioMax = meleeHit.max / baseDmg.max + + expect(actualRatioMin).toBeCloseTo(expectedDoubleMultiplier, 1) + expect(actualRatioMax).toBeCloseTo(expectedDoubleMultiplier, 1) + } + }) + + it('verifies interaction of Warmth synergy (+180% = 2.8x) and Fire Mastery double-dip ((2.63)^2 = 6.9169x)', () => { + // Warmth 20 hard points (+180%), Fire Mastery 20 (+163%) + const warmthPts = 20 + const fmSlvl = 20 + const baseDmg = calculateEnchantDamage(20) // 68.5 - 89.5 + + const buffedCastDmg = calculateEnchantDamage(20, { warmth: warmthPts }, fmSlvl) + // Warmth multiplier = 1 + 20*0.09 = 2.8 + // FM multiplier = 1 + 1.63 = 2.63 + // Total cast multiplier = 2.8 * 2.63 = 7.364 + // Min: 68.5 * 7.364 = 504.434 -> 504.4 + // Max: 89.5 * 7.364 = 659.078 -> 659.1 + expect(buffedCastDmg.min).toBe(504.4) + expect(buffedCastDmg.max).toBe(659.1) + + // Melee double dip: applies FM 2.63 again! + // Total multiplier = 2.8 * (2.63)^2 = 19.36732x! + const meleeHit = calculateEnchantAttackDamage({ + enchantDamage: buffedCastDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + + // Min: 504.4 * 2.63 = 1326.572 -> 1326.6 + // Max: 659.1 * 2.63 = 1733.433 -> 1733.4 + expect(meleeHit.min).toBe(1326.6) + expect(meleeHit.max).toBe(1733.4) + expect(meleeHit.average).toBe(1530.0) + + // Ranged hit: 33% penalty and NO second FM + const rangedHit = calculateEnchantAttackDamage({ + enchantDamage: buffedCastDmg, + isRanged: true, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + expect(rangedHit.min).toBe(166.5) // 504.4 * 0.33 = 166.452 -> 166.5 + expect(rangedHit.max).toBe(217.5) // 659.1 * 0.33 = 217.503 -> 217.5 + expect(rangedHit.average).toBe(192.0) + }) + + it('verifies extreme skill level scaling (slvl 60 Enchant + slvl 60 Fire Mastery) maintains numerical stability', () => { + const slvl = 60 + const fmBonus = calculateFireMasteryBonus(60) // 30 + 59 * 7 = 443% -> 5.43x + expect(fmBonus).toBe(443) + + const baseDmg = calculateEnchantDamage(60) + expect(baseDmg.min).toBeGreaterThan(0) + expect(baseDmg.max).toBeGreaterThan(baseDmg.min) + expect(Number.isFinite(baseDmg.min)).toBe(true) + + const castDmg = calculateEnchantDamage(60, undefined, 60) + expect(castDmg.min).toBeCloseTo(baseDmg.min * 5.43, 0) + + const meleeDmg = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 60, + }) + // Double dip = base * (5.43)^2 + expect(meleeDmg.min).toBeCloseTo(baseDmg.min * 5.43 * 5.43, 0) + expect(Number.isFinite(meleeDmg.average)).toBe(true) + }) + }) + + // ========================================================================== + // Suite 3: Non-Sorceress Buff Recipient (Allies / Pets) + // ========================================================================== + describe('Suite 3: Non-Sorceress Buff Recipient (Allies / Pets)', () => { + it('verifies non-Sorceress melee ally receives single-multiplied buff damage without second Fire Mastery', () => { + // Sorceress with slvl 20 Enchant and slvl 20 Fire Mastery casts on an ally + const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4 + + // Ally (isSorceress: false, no Fire Mastery) strikes in melee + const allyMelee = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: false, + }) + + // Damage must match the single-multiplied cast buff exactly: + expect(allyMelee.min).toBe(180.2) + expect(allyMelee.max).toBe(235.4) + expect(allyMelee.average).toBe(207.8) + + // Contrast with Sorceress attacking: + const sorcMelee = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + }) + expect(sorcMelee.average).toBe(546.5) + expect(sorcMelee.average).toBeCloseTo(allyMelee.average * 2.63, 0) + }) + + it('verifies non-Sorceress ranged ally receives strictly 33% of single-multiplied buff damage', () => { + const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4 + + // Rogue mercenary or bow Amazon strikes at range + const allyRanged = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: true, + isSorceress: false, + }) + + expect(allyRanged.min).toBe(59.5) // 180.2 * 0.33 = 59.466 -> 59.5 + expect(allyRanged.max).toBe(77.7) // 235.4 * 0.33 = 77.682 -> 77.7 + expect(allyRanged.average).toBe(68.6) + }) + + it('verifies Party Sorceress 2 with lower Fire Mastery applies her own Fire Mastery upon melee strike', () => { + // Sorc 1 (slvl 20 Enchant, slvl 20 FM) casts on Sorc 2 (who has slvl 10 FM = +93% / 1.93x) + const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4 + + // Sorc 2 attacks in melee with her own FM slvl 10: + const sorc2Melee = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 10, + }) + + // Min: 180.2 * 1.93 = 347.786 -> 347.8 + // Max: 235.4 * 1.93 = 454.322 -> 454.3 + expect(sorc2Melee.min).toBe(347.8) + expect(sorc2Melee.max).toBe(454.3) + expect(sorc2Melee.average).toBe(401.1) + + // When Sorc 2 attacks at range, her FM is not applied, strictly 33% of cast damage: + const sorc2Ranged = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: true, + isSorceress: true, + fireMasterySlvl: 10, + }) + expect(sorc2Ranged.min).toBe(59.5) + expect(sorc2Ranged.max).toBe(77.7) + }) + + it('verifies CombatWorld multi-player sandbox: Sorceress vs Barbarian ally dealing Enchant damage', () => { + const world: CombatWorld = createWorld(100, 100) // Player 0: Sorceress + const barbarianAlly = addPlayer(world, 105, 100) // Player 1: Barbarian + + const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4 + + // Apply buff to both + applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 }) + world.player.isSorceress = true + world.player.fireMasterySlvl = 20 + world.player.isRanged = false + + applyEnchantBuff(barbarianAlly, 20, { casterFireMasterySlvl: 20 }) + barbarianAlly.isSorceress = false + barbarianAlly.fireMasterySlvl = 0 + barbarianAlly.isRanged = false + + // Spawn 2 target dummies + const dummySorc: Monster = { + index: 0, + stats: { id: 'dummy_s', name: 'Dummy Sorc', hp: 2000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 10 }, + x: 120, y: 100, hp: 2000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + const dummyBarb: Monster = { + index: 1, + stats: { id: 'dummy_b', name: 'Dummy Barb', hp: 2000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 10 }, + x: 105, y: 120, hp: 2000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + world.monsters.push(dummySorc, dummyBarb) + + // Tick player 0 (Sorceress melee attack) + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + const sorcEvent = world.events.find(e => e.kind === 'monsterHit') + expect(sorcEvent).toBeDefined() + // Sorc damage = base (10) + double-dip fire average (546.5 -> 547) = 557 + expect(sorcEvent!.amount).toBe(10 + Math.round(546.5)) + + // Now tick barbarian ally melee attack + world.events = [] + // Let barbarian attack + barbarianAlly.cooldown = 0 + // We simulate barbarian's tick via executeSUnitDmg directly or checking calculation + const barbHit = calculateEnchantAttackDamage({ + enchantDamage: barbarianAlly.enchantDamage!, + isRanged: barbarianAlly.isRanged, + isSorceress: barbarianAlly.isSorceress, + fireMasterySlvl: barbarianAlly.fireMasterySlvl, + }) + // Barbarian damage is single-dipped: 207.8 -> 208 + expect(barbHit.average).toBe(207.8) + expect(sorcEvent!.amount! - 10).toBeGreaterThan(barbHit.average * 2.5) + }) + }) + + // ========================================================================== + // Suite 4: Fire Immunity Preservation & Resistance Handling + // ========================================================================== + describe('Suite 4: Fire Immunity Preservation & Resistance Handling', () => { + it('strictly preserves fire immunity at 100%, 110%, and 150% Fire Resist (0 fire damage dealt)', () => { + const castDmg = calculateEnchantDamage(20, undefined, 20) + + for (const res of [100, 110, 125, 150, 200]) { + // Melee Sorceress with slvl 20 Fire Mastery vs immune monster + const immuneMelee = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: res, + }) + expect(immuneMelee.min).toBe(0) + expect(immuneMelee.max).toBe(0) + expect(immuneMelee.average).toBe(0) + + // Ranged Sorceress vs immune monster + const immuneRanged = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: true, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: res, + }) + expect(immuneRanged.min).toBe(0) + expect(immuneRanged.max).toBe(0) + expect(immuneRanged.average).toBe(0) + + // Extreme Fire Mastery slvl 40 (+303%) vs immune monster + const extremeFmMelee = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 40, + targetFireResist: res, + }) + expect(extremeFmMelee.min).toBe(0) + expect(extremeFmMelee.max).toBe(0) + expect(extremeFmMelee.average).toBe(0) + } + }) + + it('verifies combat pipeline executeSUnitDmg flags immuneToElem = true and elemDamage256 = 0 for res >= 100', () => { + const attacker = createCombatUnit('attacker_fm40', { + fireMasterySlvl: 40, // +303% FM! + hasEnchantState: true, + }) + + for (const res of [100, 110, 150]) { + const defender = createCombatUnit(`defender_res_${res}`, { fireResist: res }) + const packet: SUnitDmgPacket = { + skillId: 52, + attackKind: 'melee', + isEnchant: true, + elemType: 'fire', + elemMin256: 50000, + elemMax256: 50000, + flatPhysMin256: 0, + flatPhysMax256: 0, + autoHit: true, + } + + const outcome = executeSUnitDmg(attacker, defender, packet) + expect(outcome.hit).toBe(true) + expect(outcome.immuneToElem).toBe(true) + expect(outcome.elemDamage256).toBe(0) + // Physical damage is applied normally (weapon base 10) + expect(outcome.physDamage256).toBe(10 * FIXED_ONE) + expect(outcome.totalDamage).toBe(10) + } + }) + + it('verifies proportional mitigation at 50% and 0% Fire Resist without floor clamp violations', () => { + const castDmg = calculateEnchantDamage(20, undefined, 20) + + // 0% Fire Resist: 100% damage unmitigated + const res0 = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 0, + }) + expect(res0.min).toBe(473.9) + expect(res0.max).toBe(619.1) + expect(res0.average).toBe(546.5) + + // 50% Fire Resist: exactly 50% reduction + const res50 = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 50, + }) + expect(res50.min).toBe(Math.round(473.9 * 0.5 * 10) / 10) // 237.0 + expect(res50.max).toBe(Math.round(619.1 * 0.5 * 10) / 10) // 309.6 + expect(res50.average).toBe(Math.round(546.5 * 0.5 * 10) / 10) // 273.3 + + // 75% Fire Resist: exactly 75% reduction (25% remaining) + const res75 = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 75, + }) + expect(res75.min).toBe(118.5) + expect(res75.max).toBe(154.8) + expect(res75.average).toBe(136.7) + + // 99% Fire Resist: non-immune boundary, exactly 1% remaining + const res99 = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 99, + }) + expect(res99.min).toBeGreaterThan(0) + expect(res99.average).toBe(Math.round(546.5 * 0.01 * 10) / 10) // 5.5 + }) + + it('verifies computeEffectiveResistance 1.13c immunity breaking and -100% floor clamp rules', () => { + // 1. Natural immunity without Conviction / Lower Resist cannot be broken by passive pierce + const immuneRes = computeEffectiveResistance({ + baseRes: 100, + passiveEnemyPierce: 50, // e.g. facets / gear + }) + expect(immuneRes.isImmune).toBe(true) + expect(immuneRes.effectiveRes).toBe(100) + + // 2. Negative resistance cannot go below authentic 1.13c -100% cap + const extremeNegRes = computeEffectiveResistance({ + baseRes: -80, + convictionPierce: 50, + }) + // -80 - 50 = -130, clamped to -100! + expect(extremeNegRes.effectiveRes).toBe(-100) + }) + + it('verifies CombatWorld sandbox tickCombat against immune monster (res=100) deals only physical damage', () => { + const world: CombatWorld = createWorld(100, 100) + const immuneMonster: Monster = { + index: 0, + stats: { id: 'fire_immune_pitlord', name: 'Pit Lord', hp: 500, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 50 }, + x: 120, y: 100, hp: 500, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + ;(immuneMonster as any).fireResist = 100 // Set fire resist to 100% + world.monsters.push(immuneMonster) + + // Buff player with massive Enchant + Fire Mastery + applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 }) + world.player.isSorceress = true + world.player.fireMasterySlvl = 20 + world.player.isRanged = false + + // Player attacks + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + + const hitEvent = world.events.find(e => e.kind === 'monsterHit') + expect(hitEvent).toBeDefined() + // Because monster is fire immune, only baseOptions.playerDamage (10) is dealt! + expect(hitEvent!.amount).toBe(10) + expect(immuneMonster.hp).toBe(490) + }) + + it('ADVERSARIAL GAP CHECK: verifies whether tickPlayer handles monster.stats.resistances?.fire', () => { + const world: CombatWorld = createWorld(100, 100) + const monsterWithResistancesBag: Monster = { + index: 0, + stats: { + id: 'typed_res_monster', + name: 'Resistant Monster', + hp: 500, + damage: 0, + cooldownTicks: 25, + reach: 20, + aggroRadius: 0, + speed: 0, + xp: 50, + resistances: { fire: 100 }, + resists: { fire: 100 }, + }, + x: 120, y: 100, hp: 500, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + world.monsters.push(monsterWithResistancesBag) + + applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 }) + world.player.isSorceress = true + world.player.fireMasterySlvl = 20 + world.player.isRanged = false + + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + const hitEvent = world.events.find(e => e.kind === 'monsterHit') + expect(hitEvent).toBeDefined() + + // In combat.ts line 895: targetFireResist: (target as any).fireResist ?? (target.stats as any).fireResist + // Because target.stats.resistances?.fire is NOT inspected, targetFireResist resolves to undefined! + // This results in full unmitigated fire damage (557 instead of 10): + const actualDmg = hitEvent!.amount! + expect(actualDmg).toBe(557) // EMPIRICAL PROOF OF GAP: resistances bag ignored by combat.ts:895 + }) + + it('proves empirically that Fire Mastery provides 0% enemy fire resistance pierce (unlike Cold Mastery)', () => { + // Monster with 50% fire resist + const castDmg = calculateEnchantDamage(20, undefined, 20) + + // With FM slvl 20 (+163% damage bonus) + const withFm = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 50, + }) + + // Without FM (e.g. ally) + const withoutFm = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: false, + targetFireResist: 50, + }) + + // Unresisted baselines + const unresistedWithFm = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 0, + }) + const unresistedWithoutFm = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: false, + targetFireResist: 0, + }) + + // The damage reduction ratio for both is EXACTLY (100 - 50)/100 = 0.5! + // This proves Fire Mastery does not pierce enemy resistance by even 1%! + expect(withFm.average / unresistedWithFm.average).toBeCloseTo(0.5, 2) + expect(withoutFm.average / unresistedWithoutFm.average).toBeCloseTo(0.5, 2) + }) + }) + + // ========================================================================== + // Suite 5: Adversarial Boundary & Lifecycle Stress Tests + // ========================================================================== + describe('Suite 5: Adversarial Boundary & Lifecycle Stress Tests', () => { + it('verifies buff expiration countdown: damage drops to 0 fire bonus after enchantTicks reaches 0', () => { + const world: CombatWorld = createWorld(100, 100) + const monster: Monster = { + index: 0, + stats: { id: 'dummy', name: 'Dummy', hp: 2000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 50 }, + x: 120, y: 100, hp: 2000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + world.monsters.push(monster) + + applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 }) + world.player.isSorceress = true + world.player.fireMasterySlvl = 20 + // Set ticks to exactly 2 ticks left + world.player.enchantTicks = 2 + + // Tick 1: buff still active (enchantTicks decreases to 1) + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(10 + Math.round(546.5)) + expect(world.player.enchantTicks).toBe(1) + + // Tick 2: buff expires (enchantTicks decrements to 0, enchantDamage cleared) + world.player.cooldown = 0 + world.events = [] + tickCombat(world, { movement: { x: 0, y: 0 }, attack: false }, baseOptions, mockTerrain, [0, 100]) + expect(world.player.enchantTicks).toBe(0) + expect(world.player.enchantDamage).toBeUndefined() + + // Tick 3: player attacks with expired buff -> strictly physical damage only (10) + world.player.cooldown = 0 + world.events = [] + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(10) + }) + + it('verifies Attack Rating bonus scaling across slvl 1..40 and non-positive inputs', () => { + expect(calculateEnchantAttackRating(1)).toBe(20) + expect(calculateEnchantAttackRating(10)).toBe(101) + expect(calculateEnchantAttackRating(20)).toBe(191) + expect(calculateEnchantAttackRating(30)).toBe(281) + expect(calculateEnchantAttackRating(40)).toBe(371) + + expect(calculateEnchantAttackRating(0)).toBe(0) + expect(calculateEnchantAttackRating(-5)).toBe(0) + expect(calculateEnchantAttackRating(NaN)).toBe(0) + }) + + it('verifies Enchant duration scaling across slvl 1..40 and non-positive inputs', () => { + expect(calculateEnchantDuration(1)).toBe(3600) // 144s + expect(calculateEnchantDuration(10)).toBe(9000) // 360s + expect(calculateEnchantDuration(20)).toBe(15000) // 600s + expect(calculateEnchantDuration(30)).toBe(21000) // 840s + expect(calculateEnchantDuration(40)).toBe(27000) // 1080s + + expect(calculateEnchantDuration(0)).toBe(0) + expect(calculateEnchantDuration(-1)).toBe(0) + expect(calculateEnchantDuration(NaN)).toBe(0) + }) + + it('verifies whiff event when attacking while no monster is within playerReach', () => { + const world: CombatWorld = createWorld(100, 100) + const distantMonster: Monster = { + index: 0, + stats: { id: 'far_monster', name: 'Far', hp: 100, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 50 }, + x: 300, y: 300, hp: 100, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, + } + world.monsters.push(distantMonster) + applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 }) + + tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100]) + const whiffEvent = world.events.find(e => e.kind === 'playerHit' && e.text === 'whiff') + expect(whiffEvent).toBeDefined() + expect(whiffEvent!.amount).toBe(0) + expect(distantMonster.hp).toBe(100) // unaffected + }) + }) +}) diff --git a/tests/skills/sor/adv-fire-m4-math-stress.test.ts b/tests/skills/sor/adv-fire-m4-math-stress.test.ts new file mode 100644 index 0000000..16ccdcc --- /dev/null +++ b/tests/skills/sor/adv-fire-m4-math-stress.test.ts @@ -0,0 +1,527 @@ +/** + * Adversarial Empirical Stress Testing Suite for Milestone 4: + * Sorceress Fire Tree — Enchant (Skill 52) & Fire Mastery (Skill 61) Math & Mechanics + * + * 1.13c Ground Truth Sources: + * - Skills.txt (row 52 Enchant, row 61 Fire Mastery, row 37 Warmth) + * - D2Common.dll (HitShift 7 256-fixed-point, 5-band damage progression, ln12 duration) + * - D2Game.dll (Enchant buff attachment, ranged 33% penalty, Sorceress melee double-dip) + * + * Test Dimensions: + * 1. Monotonicity sweeps across slvl 1..99 for Enchant damage, AR bonus %, duration, and Fire Mastery % + * 2. Synergy scaling: Warmth synergy (+9%/lvl) linear scaling without overflow/underflow at syn 0, 20, 50, 99 + * 3. Fire Mastery cast multiplier: (1 + calculateFireMasteryBonus(fmSlvl) / 100) base buff scaling & melee double-dip + * 4. Discrete values verification: exact 1.13c values at slvl 1, 10, 20 + * 5. Extreme inputs: slvl 0, negative, and NaN inputs safely clamped + * 6. Differential testing against independent brute-force oracle (1000+ randomized iterations) + */ + +import { describe, it, expect } from 'vitest' +import { + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateEnchantAttackDamage, +} from '../../../src/game/skills.ts' + +// ============================================================================ +// Independent Brute-Force Oracles for Differential Testing +// ============================================================================ + +/** Independent Oracle for raw 5-band minimum fire damage */ +function oracleRawMin(lvl: number): number { + const l = Number.isFinite(lvl) ? Math.max(1, Math.floor(lvl)) : 1 + let min = 16 + for (let i = 2; i <= l; i++) { + if (i <= 8) min += 3 + else if (i <= 16) min += 7 + else if (i <= 22) min += 11 + else if (i <= 28) min += 15 + else min += 19 + } + return min +} + +/** Independent Oracle for raw 5-band maximum fire damage */ +function oracleRawMax(lvl: number): number { + const l = Number.isFinite(lvl) ? Math.max(1, Math.floor(lvl)) : 1 + let max = 20 + for (let i = 2; i <= l; i++) { + if (i <= 8) max += 5 + else if (i <= 16) max += 9 + else if (i <= 22) max += 13 + else if (i <= 28) max += 17 + else max += 21 + } + return max +} + +/** Independent Oracle for Attack Rating bonus % */ +function oracleEnchantAR(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 20 + (lvl - 1) * 9 +} + +/** Independent Oracle for Duration in frames */ +function oracleEnchantDuration(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 3600 + (lvl - 1) * 600 +} + +/** Independent Oracle for Fire Mastery bonus % */ +function oracleFireMasteryBonus(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 30 + (lvl - 1) * 7 +} + +/** Independent Oracle for complete Enchant damage */ +function oracleEnchantDamage(slvl: number, warmth: number = 0, fireMastery: number = 0) { + const rawMin = oracleRawMin(slvl) + const rawMax = oracleRawMax(slvl) + const wPts = Number.isFinite(warmth) ? Math.max(0, warmth) : 0 + const fmPts = Number.isFinite(fireMastery) ? Math.max(0, fireMastery) : 0 + + const warmthMul = 1.0 + (wPts * 9) / 100 + const fmMul = 1.0 + oracleFireMasteryBonus(fmPts) / 100 + const totalMul = warmthMul * fmMul + + const min256 = Math.floor(rawMin * 128 * totalMul) + const max256 = Math.floor(rawMax * 128 * totalMul) + const min = Math.round((rawMin * 0.5 * totalMul) * 10) / 10 + const max = Math.round((rawMax * 0.5 * totalMul) * 10) / 10 + + return { min, max, min256, max256 } +} + +describe('Milestone 4 Math Adversarial Stress Suite (Enchant & Fire Mastery)', () => { + // ========================================================================== + // Suite 1: Monotonicity Sweeps (slvl 1..99) + // ========================================================================== + describe('Suite 1: Monotonicity Sweeps across slvl 1..99', () => { + it('Enchant fire damage strictly increases monotonically across slvl 1..99', () => { + let prev = calculateEnchantDamage(1) + expect(prev.min).toBe(8.0) + expect(prev.max).toBe(10.0) + expect(prev.min256).toBe(2048) + expect(prev.max256).toBe(2560) + + for (let slvl = 2; slvl <= 99; slvl++) { + const curr = calculateEnchantDamage(slvl) + + // Strict monotonicity + expect(curr.min).toBeGreaterThan(prev.min) + expect(curr.max).toBeGreaterThan(prev.max) + expect(curr.min256).toBeGreaterThan(prev.min256) + expect(curr.max256).toBeGreaterThan(prev.max256) + + // Invariant: min <= max + expect(curr.min).toBeLessThanOrEqual(curr.max) + expect(curr.min256).toBeLessThanOrEqual(curr.max256) + + prev = curr + } + }) + + it('Enchant Attack Rating bonus % strictly increases monotonically across slvl 1..99', () => { + let prev = calculateEnchantAttackRating(1) + expect(prev).toBe(20) + + for (let slvl = 2; slvl <= 99; slvl++) { + const curr = calculateEnchantAttackRating(slvl) + // Strict linear increase of exactly +9% per level + expect(curr).toBe(prev + 9) + expect(curr).toBe(20 + (slvl - 1) * 9) + prev = curr + } + + expect(calculateEnchantAttackRating(99)).toBe(902) + }) + + it('Enchant duration in frames strictly increases monotonically across slvl 1..99', () => { + let prev = calculateEnchantDuration(1) + expect(prev).toBe(3600) // 144 seconds + + for (let slvl = 2; slvl <= 99; slvl++) { + const curr = calculateEnchantDuration(slvl) + // Strict linear increase of exactly +600 frames (24 seconds) per level + expect(curr).toBe(prev + 600) + expect(curr).toBe(3600 + (slvl - 1) * 600) + prev = curr + } + + expect(calculateEnchantDuration(99)).toBe(62400) // 2496s = 41.6 minutes + }) + + it('Fire Mastery fire damage bonus % strictly increases monotonically across slvl 1..99', () => { + let prev = calculateFireMasteryBonus(1) + expect(prev).toBe(30) // +30% + + for (let slvl = 2; slvl <= 99; slvl++) { + const curr = calculateFireMasteryBonus(slvl) + // Strict linear increase of exactly +7% per level + expect(curr).toBe(prev + 7) + expect(curr).toBe(30 + (slvl - 1) * 7) + prev = curr + } + + expect(calculateFireMasteryBonus(99)).toBe(716) // +716% + }) + }) + + // ========================================================================== + // Suite 2: Synergy Scaling & Linear Progression (Warmth +9%/lvl) + // ========================================================================== + describe('Suite 2: Synergy Scaling & Precision (Warmth +9%/lvl)', () => { + it('Warmth synergy scales linearly at slvl 1, 10, 20, 50, 99 with syn 0, 20, 50, 99', () => { + const testLevels = [1, 10, 20, 50, 99] + const testSynergies = [0, 20, 50, 99] + + for (const slvl of testLevels) { + const base = calculateEnchantDamage(slvl, 0) + + for (const syn of testSynergies) { + const withSyn = calculateEnchantDamage(slvl, syn) + const expectedMultiplier = 1.0 + (syn * 9) / 100 + + // Check finiteness and positivity + expect(Number.isFinite(withSyn.min)).toBe(true) + expect(Number.isFinite(withSyn.max)).toBe(true) + expect(Number.isFinite(withSyn.min256)).toBe(true) + expect(Number.isFinite(withSyn.max256)).toBe(true) + expect(withSyn.min).toBeGreaterThan(0) + expect(withSyn.max).toBeGreaterThanOrEqual(withSyn.min) + + // Exact 256-fixed-point integer parity + const rawMin = oracleRawMin(slvl) + const rawMax = oracleRawMax(slvl) + expect(withSyn.min256).toBe(Math.floor(rawMin * 128 * expectedMultiplier)) + expect(withSyn.max256).toBe(Math.floor(rawMax * 128 * expectedMultiplier)) + + // Floating point 0.5 step parity rounded to 1 decimal + expect(withSyn.min).toBe(Math.round((rawMin * 0.5 * expectedMultiplier) * 10) / 10) + expect(withSyn.max).toBe(Math.round((rawMax * 0.5 * expectedMultiplier) * 10) / 10) + } + } + }) + + it('verifies exact step increments per Warmth hard point across syn 0..20', () => { + const slvl = 20 + const rawMin = 137 + const rawMax = 179 + + let prev = calculateEnchantDamage(slvl, 0) + for (let w = 1; w <= 20; w++) { + const curr = calculateEnchantDamage(slvl, w) + + // Strict monotonicity per point of synergy + expect(curr.min256).toBeGreaterThan(prev.min256) + expect(curr.max256).toBeGreaterThan(prev.max256) + + // Multiplier check: 1 + 0.09 * w + const mul = 1 + 0.09 * w + expect(curr.min256).toBe(Math.floor(rawMin * 128 * mul)) + expect(curr.max256).toBe(Math.floor(rawMax * 128 * mul)) + + prev = curr + } + + // At Warmth 20: +180% synergy bonus (2.8x base) + const maxWarmth = calculateEnchantDamage(20, 20) + expect(maxWarmth.min256).toBe(Math.floor(137 * 128 * 2.8)) // 49100 + expect(maxWarmth.max256).toBe(Math.floor(179 * 128 * 2.8)) // 64153 + }) + + it('handles negative or invalid synergy values by safe clamping to 0', () => { + const base = calculateEnchantDamage(20, 0) + const negSyn = calculateEnchantDamage(20, -10) + expect(negSyn).toEqual(base) + + const nanSyn = calculateEnchantDamage(20, NaN) + expect(nanSyn).toEqual(base) + }) + }) + + // ========================================================================== + // Suite 3: Fire Mastery Cast Multiplier & Interaction + // ========================================================================== + describe('Suite 3: Fire Mastery Cast Multiplier & Combat Integration', () => { + it('scales Enchant base buff damage by (1 + calculateFireMasteryBonus(fmSlvl) / 100) at cast time', () => { + const slvls = [1, 10, 20, 50, 99] + const fmLvls = [0, 1, 10, 20, 50, 99] + + for (const slvl of slvls) { + const base = calculateEnchantDamage(slvl) + + for (const fmSlvl of fmLvls) { + const fmBonus = calculateFireMasteryBonus(fmSlvl) + const fmMul = 1.0 + fmBonus / 100 + + const castDmg = calculateEnchantDamage(slvl, 0, fmSlvl) + const expectedMin = Math.round(base.min * fmMul * 10) / 10 + const expectedMax = Math.round(base.max * fmMul * 10) / 10 + + expect(castDmg.min).toBe(expectedMin) + expect(castDmg.max).toBe(expectedMax) + } + } + }) + + it('supports synergies object argument { warmth, fireMastery } and positional arguments equivalently', () => { + const slvl = 20 + const warmth = 20 + const fmSlvl = 20 + + const viaObj = calculateEnchantDamage(slvl, { warmth, fireMastery: fmSlvl }) + const viaPos = calculateEnchantDamage(slvl, warmth, fmSlvl) + + expect(viaObj).toEqual(viaPos) + expect(viaObj.min).toBe(504.4) + expect(viaObj.max).toBe(659.1) + }) + + it('verifies compound scaling: Warmth synergy combined with Fire Mastery multiplier', () => { + // slvl 20 Enchant: rawMin=137, rawMax=179 + // Warmth 20 (+180% -> 2.80x) + // Fire Mastery 20 (+163% -> 2.63x) + // Total multiplier = 2.80 * 2.63 = 7.364x + const buffed = calculateEnchantDamage(20, { warmth: 20, fireMastery: 20 }) + const expectedMin256 = Math.floor(137 * 128 * 2.8 * 2.63) + const expectedMax256 = Math.floor(179 * 128 * 2.8 * 2.63) + expect(buffed.min256).toBe(expectedMin256) + expect(buffed.max256).toBe(expectedMax256) + + const expectedMin = Math.round((137 * 0.5 * 2.8 * 2.63) * 10) / 10 + const expectedMax = Math.round((179 * 0.5 * 2.8 * 2.63) * 10) / 10 + expect(buffed.min).toBe(expectedMin) + expect(buffed.max).toBe(expectedMax) + }) + + it('verifies Sorceress melee double-dip vs ranged 33% penalty via calculateEnchantAttackDamage', () => { + const castDmg = calculateEnchantDamage(20, 0, 20) // min: 180.2, max: 235.4 + const fmBonus = calculateFireMasteryBonus(20) // 163% -> 2.63x + + // 1. Sorceress Melee: Double-Dip multiplies fire damage again! + const meleeSorc = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + }) + const expectedMeleeMin = Math.round(castDmg.min * (1 + fmBonus / 100) * 10) / 10 // 473.9 + const expectedMeleeMax = Math.round(castDmg.max * (1 + fmBonus / 100) * 10) / 10 // 619.1 + expect(meleeSorc.min).toBe(expectedMeleeMin) + expect(meleeSorc.max).toBe(expectedMeleeMax) + + // 2. Ranged attack: 33% penalty, zero second Fire Mastery multiplier + const rangedSorc = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: true, + isSorceress: true, + fireMasterySlvl: 20, + }) + const expectedRangedMin = Math.round(castDmg.min * 0.33 * 10) / 10 // 59.5 + const expectedRangedMax = Math.round(castDmg.max * 0.33 * 10) / 10 // 77.7 + expect(rangedSorc.min).toBe(expectedRangedMin) + expect(rangedSorc.max).toBe(expectedRangedMax) + + // 3. Non-Sorceress ally in melee: 100% buff damage, no secondary multiplier + const allyMelee = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: false, + }) + expect(allyMelee.min).toBe(castDmg.min) + expect(allyMelee.max).toBe(castDmg.max) + + // 4. Fire immunity barrier: enemies with >= 100% resistance take 0 damage + const immune = calculateEnchantAttackDamage({ + enchantDamage: castDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 100, + }) + expect(immune.min).toBe(0) + expect(immune.max).toBe(0) + expect(immune.average).toBe(0) + }) + }) + + // ========================================================================== + // Suite 4: Exact 1.13c Discrete Values Verification (slvl 1, 10, 20) + // ========================================================================== + describe('Suite 4: Exact 1.13c Discrete Values Verification (slvl 1, 10, 20)', () => { + it('verifies exact discrete values at slvl 1', () => { + // slvl 1: 8.0-10.0 fire dmg, +20% AR, 3600 frames duration (144s), +30% Fire Mastery + const dmg = calculateEnchantDamage(1) + expect(dmg.min).toBe(8.0) + expect(dmg.max).toBe(10.0) + expect(dmg.min256).toBe(2048) + expect(dmg.max256).toBe(2560) + + expect(calculateEnchantAttackRating(1)).toBe(20) + expect(calculateEnchantDuration(1)).toBe(3600) + expect(calculateFireMasteryBonus(1)).toBe(30) + }) + + it('verifies exact discrete values at slvl 10', () => { + // slvl 10: 25.5-36.5 fire dmg, +101% AR, 9000 frames duration (360s), +93% Fire Mastery + const dmg = calculateEnchantDamage(10) + expect(dmg.min).toBe(25.5) + expect(dmg.max).toBe(36.5) + expect(dmg.min256).toBe(6528) + expect(dmg.max256).toBe(9344) + + expect(calculateEnchantAttackRating(10)).toBe(101) + expect(calculateEnchantDuration(10)).toBe(9000) + expect(calculateFireMasteryBonus(10)).toBe(93) + }) + + it('verifies exact discrete values at slvl 20', () => { + // slvl 20: 68.5-89.5 fire dmg, +191% AR, 15000 frames duration (600s), +163% Fire Mastery + const dmg = calculateEnchantDamage(20) + expect(dmg.min).toBe(68.5) + expect(dmg.max).toBe(89.5) + expect(dmg.min256).toBe(17536) + expect(dmg.max256).toBe(22912) + + expect(calculateEnchantAttackRating(20)).toBe(191) + expect(calculateEnchantDuration(20)).toBe(15000) + expect(calculateFireMasteryBonus(20)).toBe(163) + }) + + it('verifies 5-band breakpoint transition step deltas across slvl 2..99', () => { + for (let lvl = 2; lvl <= 99; lvl++) { + const prev = calculateEnchantDamage(lvl - 1) + const curr = calculateEnchantDamage(lvl) + const dMin256 = curr.min256 - prev.min256 + const dMax256 = curr.max256 - prev.max256 + + let expDMin = 3 * 128 + let expDMax = 5 * 128 + if (lvl >= 9 && lvl <= 16) { + expDMin = 7 * 128 + expDMax = 9 * 128 + } else if (lvl >= 17 && lvl <= 22) { + expDMin = 11 * 128 + expDMax = 13 * 128 + } else if (lvl >= 23 && lvl <= 28) { + expDMin = 15 * 128 + expDMax = 17 * 128 + } else if (lvl >= 29) { + expDMin = 19 * 128 + expDMax = 21 * 128 + } + + expect(dMin256).toBe(expDMin) + expect(dMax256).toBe(expDMax) + } + }) + }) + + // ========================================================================== + // Suite 5: Extreme Inputs & Boundary Values Clamping + // ========================================================================== + describe('Suite 5: Extreme Inputs & Boundary Values Clamping', () => { + it('safely clamps slvl 0 to valid baselines', () => { + // Enchant damage clamps slvl <= 0 to slvl 1 + const dmg0 = calculateEnchantDamage(0) + const dmg1 = calculateEnchantDamage(1) + expect(dmg0).toEqual(dmg1) + + // AR, Duration, and Fire Mastery clamp slvl <= 0 to 0 + expect(calculateEnchantAttackRating(0)).toBe(0) + expect(calculateEnchantDuration(0)).toBe(0) + expect(calculateFireMasteryBonus(0)).toBe(0) + }) + + it('safely clamps negative slvl inputs (-1, -10, -99)', () => { + const dmgNeg = calculateEnchantDamage(-10) + const dmg1 = calculateEnchantDamage(1) + expect(dmgNeg).toEqual(dmg1) + + expect(calculateEnchantAttackRating(-5)).toBe(0) + expect(calculateEnchantDuration(-50)).toBe(0) + expect(calculateFireMasteryBonus(-99)).toBe(0) + }) + + it('safely handles NaN and non-finite inputs', () => { + const dmgNaN = calculateEnchantDamage(NaN) + const dmg1 = calculateEnchantDamage(1) + expect(dmgNaN).toEqual(dmg1) + + expect(calculateEnchantAttackRating(NaN)).toBe(0) + expect(calculateEnchantDuration(NaN)).toBe(0) + expect(calculateFireMasteryBonus(NaN)).toBe(0) + + expect(calculateEnchantAttackRating(Infinity)).toBe(0) + expect(calculateEnchantDuration(-Infinity)).toBe(0) + expect(calculateFireMasteryBonus(Infinity)).toBe(0) + }) + + it('correctly truncates / floors floating-point skill levels', () => { + expect(calculateEnchantAttackRating(10.9)).toBe(calculateEnchantAttackRating(10)) + expect(calculateEnchantDuration(15.7)).toBe(calculateEnchantDuration(15)) + expect(calculateFireMasteryBonus(20.3)).toBe(calculateFireMasteryBonus(20)) + expect(calculateEnchantDamage(20.8)).toEqual(calculateEnchantDamage(20)) + }) + }) + + // ========================================================================== + // Suite 6: Differential Testing against Brute-Force Oracle + // ========================================================================== + describe('Suite 6: Differential Testing against Brute-Force Oracle (1000+ Iterations)', () => { + it('matches independent brute-force oracle across 1000 randomized (slvl, warmth, fireMastery) inputs', () => { + // Deterministic pseudorandom generator (LCG) + let seed = 42 + function rand() { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 4294967296 + } + + const ITERATIONS = 1000 + + for (let i = 0; i < ITERATIONS; i++) { + // Biased input generation: mix of boundary values and uniform randoms + let slvl: number + let warmth: number + let fireMastery: number + + if (i < 50) { + // Boundary testing phase + slvl = (i % 30) + 1 + warmth = i % 25 + fireMastery = i % 25 + } else if (i < 100) { + // High level and extreme synergy phase + slvl = 80 + Math.floor(rand() * 20) + warmth = Math.floor(rand() * 100) + fireMastery = Math.floor(rand() * 100) + } else { + // General distribution 1..99 + slvl = Math.floor(rand() * 99) + 1 + warmth = Math.floor(rand() * 50) + fireMastery = Math.floor(rand() * 50) + } + + const actual = calculateEnchantDamage(slvl, warmth, fireMastery) + const expected = oracleEnchantDamage(slvl, warmth, fireMastery) + + // Strict differential checks against oracle + expect(actual.min).toBe(expected.min) + expect(actual.max).toBe(expected.max) + expect(actual.min256).toBe(expected.min256) + expect(actual.max256).toBe(expected.max256) + + // AR and Duration oracle differential checks + expect(calculateEnchantAttackRating(slvl)).toBe(oracleEnchantAR(slvl)) + expect(calculateEnchantDuration(slvl)).toBe(oracleEnchantDuration(slvl)) + expect(calculateFireMasteryBonus(fireMastery)).toBe(oracleFireMasteryBonus(fireMastery)) + } + }) + }) +})