diff --git a/tests/skills/sor/adv-cold-m2-aoe-stress.test.ts b/tests/skills/sor/adv-cold-m2-aoe-stress.test.ts new file mode 100644 index 0000000..4fcac9d --- /dev/null +++ b/tests/skills/sor/adv-cold-m2-aoe-stress.test.ts @@ -0,0 +1,819 @@ +/** + * Adversarial Stress & Boundary Testing Suite — Milestone 2: Cold Projectiles & Cold Mastery + * Commit: 375f329 + * + * Focus Areas: + * 1. Glacial Spike 64px (4 subtiles) Radial Burst Boundary Verification: + * - Distance boundary sweeps: 0px, 30px, 63px, 64px, 64.1px, 65px, 80px, 128px. + * - Strict inequality validation: units at <= 64px receive full cold damage & solid freeze; + * units at > 64px receive 0 splash damage and 0 freeze. + * - Multi-directional radial boundary test across 8 compass axes (0°, 45°, 90°, ..., 315°). + * 2. Obstacle & Wall Collision Detonation: + * - Collision against terrain obstacle (COLLIDE_WALL). + * - Immediate detonation at impact coordinates (hitWallX, hitWallY). + * - Submissile ejection verification: 'glacialspikeejecta' and explosion missile spawned. + * - 64px AoE burst applied to adjacent units from the wall impact point. + * 3. Freeze Application & Immunity: + * - Cannot Be Frozen (cannot_be_frozen > 0): receives cold damage, but freeze state is skipped. + * - Cold Immunity (coldresist >= 100): 0 damage, freeze state is skipped, immune flag set. + * - Standard targets: receive cold damage and 'freeze' state. + * 4. Cold Mastery Resistance Sweeps (-100% to +200% against slvl 1 to 50): + * - Systematic sweep of base cold resistance across [-100%, +200%] vs slvl 1..50. + * - Natural immunity rule: Cold Mastery alone NEVER pierces cold immune targets (res >= 100%). + * - Broken immunity rule: when Conviction/Lower Resist reduces res < 100%, Cold Mastery applies at full power. + * - Strict -100% floor clamp: effective resistance never drops below -100% (max 2.0x multiplier). + * - Differential fuzzing against brute-force mathematical oracle (1000 randomized iterations). + */ + +import { describe, expect, it } from 'vitest' +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 { + MissileEngine, + calculateGlacialSpikeDamage, + calculateGlacialSpikeFreezeLength, + calculateColdMasteryPierce, +} from '../../../src/game/engine/missile-engine.ts' +import { + computeEffectiveResistance, + executeSUnitDmg, + type CombatUnitContext, +} from '../../../src/game/engine/combat-pipeline.ts' +import { COLLIDE_WALL } from '../../../src/game/d2map.ts' + +function createMockCombatUnit( + id: string, + options?: { + hp?: number + coldResist?: number + cannotBeFrozen?: boolean + coldMasterySlvl?: number + allSkills?: 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) + } + + if (options?.allSkills) { + statList.setAllSkillsBonus(options.allSkills) + } + + const stateBus = new StateBus(statList) + + return { + id, + name: id, + statList, + stateBus, + } +} + +describe('Adversarial Stress Suite — Glacial Spike AoE & Cold Mastery (Commit 375f329)', () => { + // ========================================================================= + // Requirement 1: Glacial Spike 64px Radial Burst Verification + // ========================================================================= + describe('Requirement 1: Glacial Spike 64px Radial Burst Boundary Verification', () => { + it('verifies exact distance boundaries: 0px, 30px, 63px, 64px receive hit/freeze; 64.1px, 65px, 80px, 128px receive 0 hit/freeze', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + // Direct target at (0, 0) + const directTarget = createMockCombatUnit('target_0px') + + // Distances to test + const testDistances = [ + { label: 'target_30px', dist: 30, shouldHit: true }, + { label: 'target_63px', dist: 63, shouldHit: true }, + { label: 'target_64px', dist: 64, shouldHit: true }, + { label: 'target_64_1px', dist: 64.1, shouldHit: false }, + { label: 'target_65px', dist: 65, shouldHit: false }, + { label: 'target_80px', dist: 80, shouldHit: false }, + { label: 'target_128px', dist: 128, shouldHit: false }, + ] + + const splashTargets = testDistances.map(td => ({ + ...td, + unit: createMockCombatUnit(td.label), + })) + + const allTargets = [directTarget, ...splashTargets.map(st => st.unit)] + + // Target positions relative to impact point (0, 0) + const targetPositions = new Map() + targetPositions.set('target_0px', { x: 0, y: 0 }) + for (const st of splashTargets) { + targetPositions.set(st.label, { x: st.dist, y: 0 }) + } + + // Spawn missile flying from (-24, 0) straight towards (0, 0) + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 10, + owner: attacker, + startX: -24, + startY: 0, + targetX: 0, + targetY: 0, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + elemDurationFrames: 77, + autoHit: true, + }, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + // Tick engine to resolve collision with direct target at (0, 0) + const outcome = engine.tick(1, allTargets, targetPositions) + + // Direct hit on target_0px + const directHit = outcome.hits.find(h => h.targetId === 'target_0px') + expect(directHit).toBeDefined() + expect(directHit!.damage).toBeGreaterThan(0) + expect(directTarget.stateBus.hasState('freeze')).toBe(true) + + // Verify each splash target + for (const st of splashTargets) { + const hit = outcome.hits.find(h => h.targetId === st.label) + if (st.shouldHit) { + expect(hit, `Target at ${st.dist}px should receive splash hit`).toBeDefined() + expect(hit!.missileName).toContain('splash') + expect(hit!.damage, `Target at ${st.dist}px should receive cold damage`).toBeGreaterThan(0) + expect( + st.unit.stateBus.hasState('freeze'), + `Target at ${st.dist}px should have solid freeze state`, + ).toBe(true) + } else { + expect(hit, `Target at ${st.dist}px should NOT receive splash hit`).toBeUndefined() + expect( + st.unit.stateBus.hasState('freeze'), + `Target at ${st.dist}px should NOT have solid freeze state`, + ).toBe(false) + } + } + }) + + it('verifies 360-degree radial symmetry across 8 directional axes at r=63.9px (hit) vs r=64.1px (miss)', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + // Direct target at (100, 100) + const center = { x: 100, y: 100 } + const directTarget = createMockCombatUnit('center_target') + + const angles = [ + { name: 'E', rad: 0 }, + { name: 'NE', rad: Math.PI / 4 }, + { name: 'N', rad: Math.PI / 2 }, + { name: 'NW', rad: (3 * Math.PI) / 4 }, + { name: 'W', rad: Math.PI }, + { name: 'SW', rad: (5 * Math.PI) / 4 }, + { name: 'S', rad: (3 * Math.PI) / 2 }, + { name: 'SE', rad: (7 * Math.PI) / 4 }, + ] + + const insideUnits: CombatUnitContext[] = [] + const outsideUnits: CombatUnitContext[] = [] + const targetPositions = new Map() + targetPositions.set('center_target', center) + + for (const dir of angles) { + // Inside boundary: r = 63.9 px + const inId = `inside_${dir.name}` + const inUnit = createMockCombatUnit(inId) + insideUnits.push(inUnit) + targetPositions.set(inId, { + x: center.x + 63.9 * Math.cos(dir.rad), + y: center.y + 63.9 * Math.sin(dir.rad), + }) + + // Outside boundary: r = 64.1 px + const outId = `outside_${dir.name}` + const outUnit = createMockCombatUnit(outId) + outsideUnits.push(outUnit) + targetPositions.set(outId, { + x: center.x + 64.1 * Math.cos(dir.rad), + y: center.y + 64.1 * Math.sin(dir.rad), + }) + } + + const allTargets = [directTarget, ...insideUnits, ...outsideUnits] + + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 10, + owner: attacker, + startX: center.x - 24, + startY: center.y, + targetX: center.x, + targetY: center.y, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + elemDurationFrames: 77, + autoHit: true, + }, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + const outcome = engine.tick(1, allTargets, targetPositions) + + // Direct hit passes + expect(outcome.hits.find(h => h.targetId === 'center_target')).toBeDefined() + + // All 8 inside units receive splash hit and freeze + for (const inUnit of insideUnits) { + const hit = outcome.hits.find(h => h.targetId === inUnit.id) + expect(hit, `${inUnit.id} (r=63.9px) must be hit`).toBeDefined() + expect(hit!.damage).toBeGreaterThan(0) + expect(inUnit.stateBus.hasState('freeze'), `${inUnit.id} must be frozen`).toBe(true) + } + + // All 8 outside units receive 0 hit and no freeze + for (const outUnit of outsideUnits) { + const hit = outcome.hits.find(h => h.targetId === outUnit.id) + expect(hit, `${outUnit.id} (r=64.1px) must NOT be hit`).toBeUndefined() + expect(outUnit.stateBus.hasState('freeze'), `${outUnit.id} must NOT be frozen`).toBe(false) + } + }) + + it('handles high-density multi-target clustering inside and outside 64px radius', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + const center = { x: 50, y: 50 } + const directTarget = createMockCombatUnit('cluster_direct') + const targetPositions = new Map() + targetPositions.set('cluster_direct', center) + + // Generate 20 units inside (radius 10..60px) and 20 units outside (radius 70..150px) + const units: CombatUnitContext[] = [directTarget] + const insideIds = new Set() + const outsideIds = new Set() + + for (let i = 0; i < 20; i++) { + const id = `dense_in_${i}` + const radius = 10 + (i / 19) * 53 // 10 to 63 px (strictly inside 64px boundary) + const theta = (i * 2.4) + targetPositions.set(id, { + x: center.x + radius * Math.cos(theta), + y: center.y + radius * Math.sin(theta), + }) + units.push(createMockCombatUnit(id)) + insideIds.add(id) + } + + for (let i = 0; i < 20; i++) { + const id = `dense_out_${i}` + const radius = 64.5 + i * 4 // 64.5 to 140.5 px + const theta = (i * 1.7) + targetPositions.set(id, { + x: center.x + radius * Math.cos(theta), + y: center.y + radius * Math.sin(theta), + }) + units.push(createMockCombatUnit(id)) + outsideIds.add(id) + } + + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 20, + owner: attacker, + startX: center.x - 20, + startY: center.y, + targetX: center.x, + targetY: center.y, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 200 * 256, + elemMax256: 200 * 256, + elemDurationFrames: 107, + autoHit: true, + }, + vx: 20, + vy: 0, + maxRangeTicks: 40, + }) + + const outcome = engine.tick(1, units, targetPositions) + + // Direct target hit + expect(outcome.hits.find(h => h.targetId === 'cluster_direct')).toBeDefined() + + // Exactly all 20 inside units hit + for (const id of insideIds) { + const hit = outcome.hits.find(h => h.targetId === id) + expect(hit, `Inside cluster unit ${id} should be hit`).toBeDefined() + const unit = units.find(u => u.id === id)! + expect(unit.stateBus.hasState('freeze')).toBe(true) + } + + // Exactly all 20 outside units NOT hit + for (const id of outsideIds) { + const hit = outcome.hits.find(h => h.targetId === id) + expect(hit, `Outside cluster unit ${id} should NOT be hit`).toBeUndefined() + const unit = units.find(u => u.id === id)! + expect(unit.stateBus.hasState('freeze')).toBe(false) + } + }) + }) + + // ========================================================================= + // Requirement 2: Obstacle & Wall Collision Detonation + // ========================================================================= + describe('Requirement 2: Obstacle & Wall Collision Detonation', () => { + it('detonates immediately upon hitting a COLLIDE_WALL tile, spawning submissiles and bursting 64px', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + // Wall at x >= 50 + // Collision map using overlap returning COLLIDE_WALL (0x0001) + const wallTerrain = { + overlap: (x: number, _y: number) => (x >= 50 ? COLLIDE_WALL : 0), + } + + // Adjacent monster placed along the wall at (50, 35): + // - Far enough from flight path y=0 that it avoids direct missile body collision (dist > hitRadius 28px) + // - Distance from wall impact point (50, 0) is 35px <= 64px (hit by splash explosion) + const nearMonster = createMockCombatUnit('near_wall_monster') + + // Distant monster at (50, 100): distance from wall impact point (50, 0) is 100px > 64px + const farMonster = createMockCombatUnit('far_wall_monster') + + const targets = [nearMonster, farMonster] + const targetPositions = new Map([ + ['near_wall_monster', { x: 50, y: 35 }], + ['far_wall_monster', { x: 50, y: 100 }], + ]) + + // Spawn projectile starting at (0, 0) moving with vx = 24 towards wall + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 10, + owner: attacker, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 91 * 256, + elemMax256: 103 * 256, + elemDurationFrames: 77, + autoHit: true, + }, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + // Tick 1: moves from 0 to 24 (not blocked) + const tick1 = engine.tick(1, targets, targetPositions, wallTerrain) + expect(tick1.hits.length).toBe(0) + expect(tick1.subMissilesSpawned.length).toBe(0) + + // Tick 2: moves from 24 to 48 (not blocked) + const tick2 = engine.tick(1, targets, targetPositions, wallTerrain) + expect(tick2.hits.length).toBe(0) + expect(tick2.subMissilesSpawned.length).toBe(0) + + // Tick 3: moves from 48 to 72. Crosses wall at x=50. + // Continuous collision stepping stops at wall impact point (50, 0)! + const tick3 = engine.tick(1, targets, targetPositions, wallTerrain) + + // 1. Immediate AoE burst hit on nearMonster + expect(tick3.hits.length).toBe(1) + expect(tick3.hits[0]!.targetId).toBe('near_wall_monster') + expect(tick3.hits[0]!.missileName).toContain('splash') + expect(tick3.hits[0]!.damage).toBeGreaterThan(0) + expect(nearMonster.stateBus.hasState('freeze')).toBe(true) + + // Far monster unaffected + expect(farMonster.stateBus.hasState('freeze')).toBe(false) + + // 2. Submissile ejection: 'glacialspikeejecta' and explosion missile + expect(tick3.subMissilesSpawned).toContain('glacialspikeejecta') + expect(tick3.subMissilesSpawned).toContain('freezeexplode') + + // 3. Projectile expires upon impact + const activeSpikes = engine.getActiveMissiles().filter(m => m.name === 'glacialspike') + expect(activeSpikes.length).toBe(0) + }) + + it('verifies continuous stepping prevents tunneling through thin (<24px) walls and detonates correctly', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + // Thin wall only 8px wide between x=32 and x=40 + const thinWallTerrain = { + overlap: (x: number, _y: number) => (x >= 32 && x <= 40 ? COLLIDE_WALL : 0), + } + + // Unit stationed right next to the thin wall at (35, 15) + const adjacentUnit = createMockCombatUnit('thin_wall_unit') + const targetPositions = new Map([ + ['thin_wall_unit', { x: 35, y: 15 }], + ]) + + // Projectile moves 24px per tick, starting at x=20. + // Next step would land at x=44 (beyond the thin wall), but stepping catches x=32! + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 10, + owner: attacker, + startX: 20, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + elemDurationFrames: 77, + autoHit: true, + }, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + const outcome = engine.tick(1, [adjacentUnit], targetPositions, thinWallTerrain) + + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetId).toBe('thin_wall_unit') + expect(adjacentUnit.stateBus.hasState('freeze')).toBe(true) + expect(outcome.subMissilesSpawned).toContain('glacialspikeejecta') + }) + }) + + // ========================================================================= + // Requirement 3: Freeze Application & Immunity + // ========================================================================= + describe('Requirement 3: Freeze Application & Immunity Verification', () => { + it('damages targets with cannot_be_frozen but strictly skips solid freeze state on direct and splash hits', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + // Direct target with cannot_be_frozen = 1 + const cbfDirect = createMockCombatUnit('cbf_direct', { cannotBeFrozen: true }) + + // Splash target with cannot_be_frozen = 1 + const cbfSplash = createMockCombatUnit('cbf_splash', { cannotBeFrozen: true }) + + // Normal splash target + const normalSplash = createMockCombatUnit('normal_splash', { cannotBeFrozen: false }) + + const targets = [cbfDirect, cbfSplash, normalSplash] + const targetPositions = new Map([ + ['cbf_direct', { x: 0, y: 0 }], + ['cbf_splash', { x: 25, y: 0 }], + ['normal_splash', { x: 0, y: 25 }], + ]) + + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 20, + owner: attacker, + startX: -20, + startY: 0, + targetX: 0, + targetY: 0, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 225 * 256, + elemMax256: 242 * 256, + elemDurationFrames: 107, + autoHit: true, + }, + vx: 20, + vy: 0, + maxRangeTicks: 40, + }) + + const outcome = engine.tick(1, targets, targetPositions) + + // Direct target receives damage, but NO freeze state + const directHit = outcome.hits.find(h => h.targetId === 'cbf_direct') + expect(directHit).toBeDefined() + expect(directHit!.damage).toBeGreaterThan(0) + expect(cbfDirect.stateBus.hasState('freeze')).toBe(false) + + // Splash target with CBF receives splash damage, but NO freeze state + const splashHit = outcome.hits.find(h => h.targetId === 'cbf_splash') + expect(splashHit).toBeDefined() + expect(splashHit!.damage).toBeGreaterThan(0) + expect(cbfSplash.stateBus.hasState('freeze')).toBe(false) + + // Normal splash target receives splash damage AND solid freeze state + const normalHit = outcome.hits.find(h => h.targetId === 'normal_splash') + expect(normalHit).toBeDefined() + expect(normalHit!.damage).toBeGreaterThan(0) + expect(normalSplash.stateBus.hasState('freeze')).toBe(true) + }) + + it('verifies cold immune target (res >= 100%) receives 0 damage, immune flag true, and NO freeze state', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const attacker = createMockCombatUnit('sorceress') + + // Direct target with 120% cold resistance (cold immune) + const immuneDirect = createMockCombatUnit('immune_direct', { coldResist: 120 }) + + // Splash target with 150% cold resistance (cold immune) + const immuneSplash = createMockCombatUnit('immune_splash', { coldResist: 150 }) + + // Splash target with 0% cold resistance (normal) + const normalSplash = createMockCombatUnit('normal_splash', { coldResist: 0 }) + + const targets = [immuneDirect, immuneSplash, normalSplash] + const targetPositions = new Map([ + ['immune_direct', { x: 0, y: 0 }], + ['immune_splash', { x: 20, y: 0 }], + ['normal_splash', { x: 0, y: 20 }], + ]) + + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 20, + owner: attacker, + startX: -20, + startY: 0, + targetX: 0, + targetY: 0, + dmgPacket: { + skillId: 55, + elemType: 'cold', + elemMin256: 225 * 256, + elemMax256: 242 * 256, + elemDurationFrames: 107, + autoHit: true, + }, + vx: 20, + vy: 0, + maxRangeTicks: 40, + }) + + const outcome = engine.tick(1, targets, targetPositions) + + // Direct immune target + const directHit = outcome.hits.find(h => h.targetId === 'immune_direct') + expect(directHit).toBeDefined() + expect(directHit!.immune).toBe(true) + expect(directHit!.damage).toBe(0) + expect(immuneDirect.stateBus.hasState('freeze')).toBe(false) + + // Splash immune target + const splashHit = outcome.hits.find(h => h.targetId === 'immune_splash') + expect(splashHit).toBeDefined() + expect(splashHit!.immune).toBe(true) + expect(splashHit!.damage).toBe(0) + expect(immuneSplash.stateBus.hasState('freeze')).toBe(false) + + // Normal splash target receives full damage and solid freeze + const normalHit = outcome.hits.find(h => h.targetId === 'normal_splash') + expect(normalHit).toBeDefined() + expect(normalHit!.immune).toBe(false) + expect(normalHit!.damage).toBeGreaterThan(0) + expect(normalSplash.stateBus.hasState('freeze')).toBe(true) + }) + }) + + // ========================================================================= + // Requirement 4: Cold Mastery Resistance Sweeps (-100% to +200% vs slvl 1..50) + // ========================================================================= + describe('Requirement 4: Cold Mastery Resistance Sweeps & Combat Calculations', () => { + it('verifies Cold Mastery linear formula across slvl 1 to 50: pierce = 20 + (slvl - 1) * 5', () => { + for (let slvl = 1; slvl <= 50; slvl++) { + const expected = 20 + (slvl - 1) * 5 + expect(calculateColdMasteryPierce(slvl)).toBe(expected) + } + expect(calculateColdMasteryPierce(0)).toBe(0) + expect(calculateColdMasteryPierce(-1)).toBe(0) + }) + + it('adversarial sweep: verifies natural cold immune (res >= 100%) is NEVER pierced or reduced by Cold Mastery alone', () => { + // Test cold resistance from 100% to 200% across slvl 1 to 50 + const testLevels = [1, 5, 10, 15, 20, 25, 30, 40, 50] + for (let baseRes = 100; baseRes <= 200; baseRes += 5) { + for (const slvl of testLevels) { + const pierce = calculateColdMasteryPierce(slvl) + const result = computeEffectiveResistance({ + baseRes, + passiveEnemyPierce: pierce, + }) + + // Invariant: Cold Mastery alone cannot break immunity + expect(result.isImmune, `baseRes ${baseRes}% with slvl ${slvl} CM must remain immune`).toBe(true) + expect(result.effectiveRes, `baseRes ${baseRes}% with slvl ${slvl} CM must have effectiveRes 100`).toBe(100) + expect(result.convictionAndLRApplied).toBe(0) + } + } + }) + + it('adversarial sweep: verifies strict -100% effective resistance floor clamp for all non-immune resistances', () => { + // Sweep base resistance from -100% to 99% against slvl 1 to 50 + const testLevels = [1, 5, 10, 15, 20, 25, 30, 40, 50] + for (let baseRes = -100; baseRes < 100; baseRes += 10) { + for (const slvl of testLevels) { + const pierce = calculateColdMasteryPierce(slvl) + const result = computeEffectiveResistance({ + baseRes, + passiveEnemyPierce: pierce, + }) + + expect(result.isImmune).toBe(false) + const expected = Math.max(-100, Math.min(99, baseRes - pierce)) + expect(result.effectiveRes, `baseRes ${baseRes} with pierce ${pierce}`).toBe(expected) + expect(result.effectiveRes, 'Effective resistance must never drop below -100%').toBeGreaterThanOrEqual(-100) + } + } + }) + + it('adversarial sweep: verifies broken immunity allows Cold Mastery to apply at full 100% effect without 1/5 penalty', () => { + // Test cases where Conviction breaks immunity (baseRes in 100..116, Conviction 85 -> 85/5 = 17 -> res becomes 83..99) + const convictionPierce = 85 // standard slvl 12 Conviction + const testLevels = [1, 5, 10, 20, 30, 50] + + for (let baseRes = 100; baseRes <= 116; baseRes++) { + for (const slvl of testLevels) { + const cmPierce = calculateColdMasteryPierce(slvl) + const result = computeEffectiveResistance({ + baseRes, + convictionPierce, + passiveEnemyPierce: cmPierce, + }) + + // 1. Conviction applied at 1/5th to immune: 85 / 5 = 17 + expect(result.convictionAndLRApplied).toBe(17) + const resAfterConv = baseRes - 17 + expect(result.resAfterConvictionLR).toBe(resAfterConv) + + // 2. Since resAfterConv < 100, target is NO LONGER immune + expect(result.isImmune).toBe(false) + + // 3. Cold Mastery applies at full effect without 1/5 penalty, clamped to -100% + const expectedEffective = Math.max(-100, Math.min(99, resAfterConv - cmPierce)) + expect(result.effectiveRes).toBe(expectedEffective) + } + } + + // Test case where Conviction is NOT enough to break immunity (e.g. baseRes = 120, 120 - 17 = 103 >= 100) + for (const slvl of testLevels) { + const cmPierce = calculateColdMasteryPierce(slvl) + const result = computeEffectiveResistance({ + baseRes: 120, + convictionPierce: 85, + passiveEnemyPierce: cmPierce, + }) + + // Unbroken immunity: target stays immune, effectiveRes stays 100, CM pierce is 0 + expect(result.isImmune).toBe(true) + expect(result.effectiveRes).toBe(100) + } + }) + + it('differential fuzzing: 1000 randomized iterations comparing engine against mathematical oracle', () => { + // Independent Mathematical Oracle + function oracleEffectiveResistance( + baseRes: number, + conviction: number, + lowerResist: number, + passivePierce: number, + ): { isImmune: boolean; effectiveRes: number } { + const rawConvLR = Math.max(0, Math.trunc(conviction + lowerResist)) + let convLRApplied = 0 + if (baseRes >= 100) { + convLRApplied = Math.trunc(rawConvLR / 5) + } else { + convLRApplied = rawConvLR + } + + const resAfter = baseRes - convLRApplied + if (resAfter >= 100) { + return { isImmune: true, effectiveRes: 100 } + } + + const eff = Math.max(-100, Math.min(99, resAfter - Math.max(0, Math.trunc(passivePierce)))) + return { isImmune: false, effectiveRes: eff } + } + + // Seeded deterministic pseudo-random number generator for differential fuzzing + let seed = 0x1337c0de + function nextRand(): number { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 4294967296 + } + + for (let iter = 1; iter <= 1000; iter++) { + // Random base resistance in [-100, 200] + const baseRes = Math.floor(nextRand() * 301) - 100 + // Random Cold Mastery slvl in [0, 50] + const slvl = Math.floor(nextRand() * 51) + const cmPierce = calculateColdMasteryPierce(slvl) + // Random Conviction in [0, 150] + const conviction = Math.floor(nextRand() * 151) + // Random Lower Resist in [0, 75] + const lowerResist = Math.floor(nextRand() * 76) + + const actual = computeEffectiveResistance({ + baseRes, + convictionPierce: conviction, + lowerResistPierce: lowerResist, + passiveEnemyPierce: cmPierce, + }) + + const expected = oracleEffectiveResistance(baseRes, conviction, lowerResist, cmPierce) + + expect(actual.isImmune, `Iter ${iter}: isImmune mismatch for baseRes=${baseRes}, slvl=${slvl}`).toBe( + expected.isImmune, + ) + expect(actual.effectiveRes, `Iter ${iter}: effectiveRes mismatch for baseRes=${baseRes}, slvl=${slvl}`).toBe( + expected.effectiveRes, + ) + } + }) + + it('SUnitDmg pipeline: validates exact damage multiplier clamping at 2.0x (effectiveRes = -100%)', () => { + const attacker = createMockCombatUnit('sorceress_cm', { coldMasterySlvl: 20 }) // 115% pierce + + // Target A: 0% cold res -> with 115% pierce -> effectiveRes = -100% (takes 2.0x damage) + const targetA = createMockCombatUnit('target_0res', { coldResist: 0 }) + const outA = executeSUnitDmg(attacker, targetA, { + skillId: 55, + attackKind: 'spell', + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + autoHit: true, + }) + expect(outA.totalDamage).toBe(200) // 100 * (100 - (-100)) / 100 = 200 + + // Target B: -50% cold res with slvl 50 CM (265% pierce) -> clamped to -100% -> STILL takes 2.0x damage + const superAttacker = createMockCombatUnit('sorceress_super_cm', { coldMasterySlvl: 50 }) + const targetB = createMockCombatUnit('target_neg_res', { coldResist: -50 }) + const outB = executeSUnitDmg(superAttacker, targetB, { + skillId: 55, + attackKind: 'spell', + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + autoHit: true, + }) + expect(outB.totalDamage, 'Damage multiplier must strictly clamp at 2.0x (200 damage)').toBe(200) + + // Target C: 50% cold res -> with 115% pierce -> effectiveRes = -65% -> takes 1.65x damage + const targetC = createMockCombatUnit('target_50res', { coldResist: 50 }) + const outC = executeSUnitDmg(attacker, targetC, { + skillId: 55, + attackKind: 'spell', + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + autoHit: true, + }) + expect(outC.totalDamage).toBe(165) // 100 * (100 - (-65)) / 100 = 165 + }) + }) +}) diff --git a/tests/skills/sor/adv-cold-m2-projectiles-stress.test.ts b/tests/skills/sor/adv-cold-m2-projectiles-stress.test.ts new file mode 100644 index 0000000..4ea059a --- /dev/null +++ b/tests/skills/sor/adv-cold-m2-projectiles-stress.test.ts @@ -0,0 +1,646 @@ +/** + * Adversarial Stress-Testing & Boundary Verification Suite + * Milestone 2: Cold Projectiles & Cold Mastery (Ice Blast #199, Glacial Spike #209, Cold Mastery #219) + * + * Verification Categories: + * 1. Monotonicity & Continuity Sweeps (slvl 1 to 99 across all 5 level progression bands) + * 2. Strict Freeze Duration Monotonicity (slvl 1 to 99) + * 3. Linear Synergy Stress Testing (0 to 60 synergy points, overrides & object structures) + * 4. Extreme Boundaries & Robustness (slvl <= 0, fractional, NaN, negative, huge values > 1000) + * 5. Cold Mastery Resistance Pipeline & Floor Clamping (-100% cap, immunity bypass, broken immunities) + * 6. Differential Oracle Testing (Naive band summation vs closed-form implementations) + * 7. System Integration & Invariant Enforcement (BATCH1_SKILLS length, mana cost curves, 0 cooldown) + */ + +import { describe, expect, it } from 'vitest' +import { + calculateIceBlastDamage, + calculateIceBlastFreezeLength, + calculateGlacialSpikeDamage, + calculateGlacialSpikeFreezeLength, + calculateColdMasteryPierce, +} from '../../../src/game/engine/missile-engine.ts' +import { + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { computeEffectiveResistance } from '../../../src/game/engine/combat-pipeline.ts' + +describe('Adversarial Stress Test: Cold Projectiles & Mastery Milestone 2', () => { + // ========================================================================= + // 1. Monotonicity & Continuity Sweeps (slvl 1 to 99 across 5 Progression Bands) + // ========================================================================= + describe('1. Monotonicity & Continuity Sweeps (slvl 1 to 99)', () => { + it('Ice Blast: min <= max and strict positive monotonicity across all 99 levels', () => { + let prevDamage = calculateIceBlastDamage(1) + expect(prevDamage.min).toBe(8) + expect(prevDamage.max).toBe(12) + expect(prevDamage.min).toBeLessThanOrEqual(prevDamage.max) + + for (let slvl = 2; slvl <= 99; slvl++) { + const current = calculateIceBlastDamage(slvl) + // 1. Min damage must never exceed max damage + expect(current.min).toBeLessThanOrEqual(current.max) + // 2. Strict monotonicity: every single level up MUST increase damage + expect(current.min).toBeGreaterThan(prevDamage.min) + expect(current.max).toBeGreaterThan(prevDamage.max) + prevDamage = current + } + }) + + it('Ice Blast: progression band slope verification across all 5 bands', () => { + // Band 1: slvl 1-8 -> +7.0 min, +7.5 max per level + for (let slvl = 1; slvl <= 7; slvl++) { + const dCurrent = calculateIceBlastDamage(slvl) + const dNext = calculateIceBlastDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(7.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(7.5, 5) + } + + // Band 2: slvl 8-16 -> +14.0 min, +14.5 max per level + for (let slvl = 8; slvl <= 15; slvl++) { + const dCurrent = calculateIceBlastDamage(slvl) + const dNext = calculateIceBlastDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(14.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(14.5, 5) + } + + // Band 3: slvl 16-22 -> +21.0 min, +21.5 max per level + for (let slvl = 16; slvl <= 21; slvl++) { + const dCurrent = calculateIceBlastDamage(slvl) + const dNext = calculateIceBlastDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(21.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(21.5, 5) + } + + // Band 4: slvl 22-28 -> +28.0 min, +28.5 max per level + for (let slvl = 22; slvl <= 27; slvl++) { + const dCurrent = calculateIceBlastDamage(slvl) + const dNext = calculateIceBlastDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(28.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(28.5, 5) + } + + // Band 5: slvl 28-99 -> +35.0 min, +35.5 max per level + for (let slvl = 28; slvl <= 98; slvl++) { + const dCurrent = calculateIceBlastDamage(slvl) + const dNext = calculateIceBlastDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(35.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(35.5, 5) + } + }) + + it('Glacial Spike: min <= max and strict positive monotonicity across all 99 levels', () => { + let prevDamage = calculateGlacialSpikeDamage(1) + expect(prevDamage.min).toBe(16) + expect(prevDamage.max).toBe(24) + expect(prevDamage.min).toBeLessThanOrEqual(prevDamage.max) + + for (let slvl = 2; slvl <= 99; slvl++) { + const current = calculateGlacialSpikeDamage(slvl) + // 1. Min damage must never exceed max damage + expect(current.min).toBeLessThanOrEqual(current.max) + // 2. Strict monotonicity + expect(current.min).toBeGreaterThan(prevDamage.min) + expect(current.max).toBeGreaterThan(prevDamage.max) + prevDamage = current + } + }) + + it('Glacial Spike: progression band slope verification across all 5 bands', () => { + // Band 1: slvl 1-8 -> +7.0 min, +7.5 max per level + for (let slvl = 1; slvl <= 7; slvl++) { + const dCurrent = calculateGlacialSpikeDamage(slvl) + const dNext = calculateGlacialSpikeDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(7.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(7.5, 5) + } + + // Band 2: slvl 8-16 -> +13.0 min, +13.5 max per level + for (let slvl = 8; slvl <= 15; slvl++) { + const dCurrent = calculateGlacialSpikeDamage(slvl) + const dNext = calculateGlacialSpikeDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(13.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(13.5, 5) + } + + // Band 3: slvl 16-22 -> +14.0 min, +14.5 max per level + for (let slvl = 16; slvl <= 21; slvl++) { + const dCurrent = calculateGlacialSpikeDamage(slvl) + const dNext = calculateGlacialSpikeDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(14.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(14.5, 5) + } + + // Band 4: slvl 22-28 -> +15.0 min, +15.5 max per level + for (let slvl = 22; slvl <= 27; slvl++) { + const dCurrent = calculateGlacialSpikeDamage(slvl) + const dNext = calculateGlacialSpikeDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(15.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(15.5, 5) + } + + // Band 5: slvl 28-99 -> +16.0 min, +16.5 max per level + for (let slvl = 28; slvl <= 98; slvl++) { + const dCurrent = calculateGlacialSpikeDamage(slvl) + const dNext = calculateGlacialSpikeDamage(slvl + 1) + expect(dNext.min - dCurrent.min).toBeCloseTo(16.0, 5) + expect(dNext.max - dCurrent.max).toBeCloseTo(16.5, 5) + } + }) + + it('Cold Mastery: strict positive monotonicity across all 99 levels (+5% per level)', () => { + let prevPierce = calculateColdMasteryPierce(1) + expect(prevPierce).toBe(20) + + for (let slvl = 2; slvl <= 99; slvl++) { + const currentPierce = calculateColdMasteryPierce(slvl) + expect(currentPierce).toBeGreaterThan(prevPierce) + expect(currentPierce - prevPierce).toBe(5) + prevPierce = currentPierce + } + + expect(calculateColdMasteryPierce(1)).toBe(20) + expect(calculateColdMasteryPierce(10)).toBe(65) + expect(calculateColdMasteryPierce(20)).toBe(115) + expect(calculateColdMasteryPierce(30)).toBe(165) + expect(calculateColdMasteryPierce(99)).toBe(510) + }) + }) + + // ========================================================================= + // 2. Strict Freeze Duration Monotonicity (slvl 1 to 99) + // ========================================================================= + describe('2. Strict Freeze Duration Monotonicity', () => { + it('Ice Blast: freeze length increases strictly by +5 frames per level across slvl 1 to 99', () => { + let prevLen = calculateIceBlastFreezeLength(1) + expect(prevLen).toBe(75) // 3.0s + + for (let slvl = 2; slvl <= 99; slvl++) { + const currentLen = calculateIceBlastFreezeLength(slvl) + expect(currentLen).toBeGreaterThan(prevLen) + expect(currentLen - prevLen).toBe(5) + prevLen = currentLen + } + + expect(calculateIceBlastFreezeLength(1)).toBe(75) + expect(calculateIceBlastFreezeLength(10)).toBe(120) + expect(calculateIceBlastFreezeLength(20)).toBe(170) + expect(calculateIceBlastFreezeLength(99)).toBe(75 + 98 * 5) + }) + + it('Ice Blast: freeze length strictly monotonic with non-zero synergy across slvl 1 to 99', () => { + for (const syn of [1, 10, 20, 60]) { + let prevLen = calculateIceBlastFreezeLength(1, syn) + for (let slvl = 2; slvl <= 99; slvl++) { + const currentLen = calculateIceBlastFreezeLength(slvl, syn) + expect(currentLen).toBeGreaterThan(prevLen) + prevLen = currentLen + } + } + }) + + it('Glacial Spike: freeze length increases strictly by +3 frames per level across slvl 1 to 99', () => { + let prevLen = calculateGlacialSpikeFreezeLength(1) + expect(prevLen).toBe(50) // 2.0s + + for (let slvl = 2; slvl <= 99; slvl++) { + const currentLen = calculateGlacialSpikeFreezeLength(slvl) + expect(currentLen).toBeGreaterThan(prevLen) + expect(currentLen - prevLen).toBe(3) + prevLen = currentLen + } + + expect(calculateGlacialSpikeFreezeLength(1)).toBe(50) + expect(calculateGlacialSpikeFreezeLength(10)).toBe(77) + expect(calculateGlacialSpikeFreezeLength(20)).toBe(107) + expect(calculateGlacialSpikeFreezeLength(99)).toBe(50 + 98 * 3) + }) + + it('Glacial Spike: freeze length strictly monotonic with non-zero synergy across slvl 1 to 99', () => { + for (const syn of [1, 10, 20, 60]) { + let prevLen = calculateGlacialSpikeFreezeLength(1, syn) + for (let slvl = 2; slvl <= 99; slvl++) { + const currentLen = calculateGlacialSpikeFreezeLength(slvl, syn) + expect(currentLen).toBeGreaterThan(prevLen) + prevLen = currentLen + } + } + }) + }) + + // ========================================================================= + // 3. Synergy Stress Testing (0 to 60 Points, Linearity & Objects) + // ========================================================================= + describe('3. Synergy Stress Testing', () => { + it('Ice Blast: 0 to 60 synergy points scaling (+8% per point) is strictly linear and positive', () => { + const baseDmg = calculateIceBlastDamage(20, 0) + expect(baseDmg).toEqual({ min: 253, max: 266.5 }) + + for (let syn = 0; syn <= 60; syn++) { + const current = calculateIceBlastDamage(20, syn) + const expectedMultiplier = 1.0 + syn * 0.08 + const expectedMin = Math.floor((253 * expectedMultiplier + 1e-9) * 10) / 10 + const expectedMax = Math.floor((266.5 * expectedMultiplier + 1e-9) * 10) / 10 + + expect(current.min).toBeCloseTo(expectedMin, 1) + expect(current.max).toBeCloseTo(expectedMax, 1) + expect(current.min).toBeLessThanOrEqual(current.max) + } + + // Check key milestones + expect(calculateIceBlastDamage(20, 0)).toEqual({ min: 253, max: 266.5 }) + expect(calculateIceBlastDamage(20, 20)).toEqual({ min: 657.8, max: 692.9 }) // (1 + 1.6 = 2.6x) + expect(calculateIceBlastDamage(20, 60)).toEqual({ min: 1467.4, max: 1545.7 }) // (1 + 4.8 = 5.8x) + }) + + it('Ice Blast: multi-skill synergy object decomposition parity', () => { + // 4 synergy skills: Ice Bolt, Blizzard, Frozen Orb, Glacial Spike + const combined = calculateIceBlastDamage(20, { + iceBolt: 15, + blizzard: 15, + frozenOrb: 15, + glacialSpike: 15, + }) + // 15 * 4 = 60 total points -> exactly identical to passing numeric 60 + const numeric = calculateIceBlastDamage(20, 60) + expect(combined).toEqual(numeric) + + // Verify each individual synergy contributes identically (symmetric +8%) + const viaIceBolt = calculateIceBlastDamage(20, { iceBolt: 20 }) + const viaBlizzard = calculateIceBlastDamage(20, { blizzard: 20 }) + const viaFrozenOrb = calculateIceBlastDamage(20, { frozenOrb: 20 }) + const viaGlacialSpike = calculateIceBlastDamage(20, { glacialSpike: 20 }) + + expect(viaIceBolt).toEqual(viaBlizzard) + expect(viaBlizzard).toEqual(viaFrozenOrb) + expect(viaFrozenOrb).toEqual(viaGlacialSpike) + }) + + it('Ice Blast: synergy override options (synPctPerLvl and synMultiplier)', () => { + // Custom % per level override + const customPct = calculateIceBlastDamage(20, 10, { synPctPerLvl: 12 }) + // 10 * 12% = +120% -> 2.2x + expect(customPct.min).toBeCloseTo(253 * 2.2, 1) + expect(customPct.max).toBeCloseTo(266.5 * 2.2, 1) + + // Direct multiplier override + const directMult = calculateIceBlastDamage(20, undefined, { synMultiplier: 3.5 }) + expect(directMult.min).toBeCloseTo(253 * 3.5, 1) + expect(directMult.max).toBeCloseTo(266.5 * 3.5, 1) + }) + + it('Glacial Spike: 0 to 60 synergy points scaling (+5% per point) is strictly linear and positive', () => { + const baseDmg = calculateGlacialSpikeDamage(20, 0) + expect(baseDmg).toEqual({ min: 225, max: 242.5 }) + + for (let syn = 0; syn <= 60; syn++) { + const current = calculateGlacialSpikeDamage(20, syn) + const expectedMultiplier = 1.0 + syn * 0.05 + const expectedMin = Math.floor((225 * expectedMultiplier + 1e-9) * 10) / 10 + const expectedMax = Math.floor((242.5 * expectedMultiplier + 1e-9) * 10) / 10 + + expect(current.min).toBeCloseTo(expectedMin, 1) + expect(current.max).toBeCloseTo(expectedMax, 1) + expect(current.min).toBeLessThanOrEqual(current.max) + } + + // Check key milestones + expect(calculateGlacialSpikeDamage(20, 0)).toEqual({ min: 225, max: 242.5 }) + expect(calculateGlacialSpikeDamage(20, 20)).toEqual({ min: 450, max: 485 }) // (1 + 1.0 = 2.0x) + expect(calculateGlacialSpikeDamage(20, 60)).toEqual({ min: 900, max: 970 }) // (1 + 3.0 = 4.0x) + }) + + it('Glacial Spike: multi-skill synergy object decomposition parity', () => { + // 4 synergy skills: Ice Bolt, Ice Blast, Blizzard, Frozen Orb + const combined = calculateGlacialSpikeDamage(20, { + iceBolt: 15, + iceBlast: 15, + blizzard: 15, + frozenOrb: 15, + }) + // 15 * 4 = 60 total points -> exactly identical to passing numeric 60 + const numeric = calculateGlacialSpikeDamage(20, 60) + expect(combined).toEqual(numeric) + + // Verify each individual synergy contributes identically (symmetric +5%) + const viaIceBolt = calculateGlacialSpikeDamage(20, { iceBolt: 20 }) + const viaIceBlast = calculateGlacialSpikeDamage(20, { iceBlast: 20 }) + const viaBlizzard = calculateGlacialSpikeDamage(20, { blizzard: 20 }) + const viaFrozenOrb = calculateGlacialSpikeDamage(20, { frozenOrb: 20 }) + + expect(viaIceBolt).toEqual(viaIceBlast) + expect(viaIceBlast).toEqual(viaBlizzard) + expect(viaBlizzard).toEqual(viaFrozenOrb) + }) + + it('Glacial Spike: freeze length synergy scaling with Blizzard (+3% per point)', () => { + const baseLen = calculateGlacialSpikeFreezeLength(20, 0) + expect(baseLen).toBe(107) + + for (let syn = 0; syn <= 60; syn++) { + const len = calculateGlacialSpikeFreezeLength(20, syn) + const expected = Math.floor((107 * (100 + syn * 3)) / 100) + expect(len).toBe(expected) + } + + // Object format parity + expect(calculateGlacialSpikeFreezeLength(20, { blizzard: 20 })).toBe( + calculateGlacialSpikeFreezeLength(20, 20), + ) + }) + }) + + // ========================================================================= + // 4. Extreme Boundaries & Robustness Testing + // ========================================================================= + describe('4. Extreme Boundaries & Robustness', () => { + it('handles slvl <= 0 gracefully without exceptions or negative damage', () => { + for (const invalidLvl of [0, -1, -50, -9999]) { + const ibDmg = calculateIceBlastDamage(invalidLvl) + expect(ibDmg).toEqual({ min: 8, max: 12 }) // Clamps to lvl 1 base + + const ibFreeze = calculateIceBlastFreezeLength(invalidLvl) + expect(ibFreeze).toBe(75) // Clamps to lvl 1 base + + const gsDmg = calculateGlacialSpikeDamage(invalidLvl) + expect(gsDmg).toEqual({ min: 16, max: 24 }) // Clamps to lvl 1 base + + const gsFreeze = calculateGlacialSpikeFreezeLength(invalidLvl) + expect(gsFreeze).toBe(50) // Clamps to lvl 1 base + + const cmPierce = calculateColdMasteryPierce(invalidLvl) + expect(cmPierce).toBe(0) // Passive returns 0 when inactive/unlearned + + // Mana costs for invalid levels clamp to level 1 + expect(getSkillManaCost(45, invalidLvl)).toBe(6.0) + expect(getSkillManaCost(55, invalidLvl)).toBe(10.0) + expect(getSkillManaCost(65, invalidLvl)).toBe(0) + } + }) + + it('handles NaN, Infinity, -Infinity, null, and undefined safely', () => { + for (const nonFinite of [NaN, Infinity, -Infinity, undefined as any, null as any]) { + const ibDmg = calculateIceBlastDamage(nonFinite) + expect(Number.isFinite(ibDmg.min)).toBe(true) + expect(Number.isFinite(ibDmg.max)).toBe(true) + expect(ibDmg).toEqual({ min: 8, max: 12 }) + + const ibFreeze = calculateIceBlastFreezeLength(nonFinite) + expect(Number.isFinite(ibFreeze)).toBe(true) + expect(ibFreeze).toBe(75) + + const gsDmg = calculateGlacialSpikeDamage(nonFinite) + expect(Number.isFinite(gsDmg.min)).toBe(true) + expect(Number.isFinite(gsDmg.max)).toBe(true) + expect(gsDmg).toEqual({ min: 16, max: 24 }) + + const gsFreeze = calculateGlacialSpikeFreezeLength(nonFinite) + expect(Number.isFinite(gsFreeze)).toBe(true) + expect(gsFreeze).toBe(50) + + const cmPierce = calculateColdMasteryPierce(nonFinite) + expect(cmPierce).toBe(0) + + expect(getSkillManaCost(45, nonFinite)).toBe(6.0) + expect(getSkillManaCost(55, nonFinite)).toBe(10.0) + expect(getSkillManaCost(65, nonFinite)).toBe(0) + } + }) + + it('handles fractional slvl values by rounding down consistently (Math.floor)', () => { + // 5.2 and 5.9 both evaluate to slvl 5 + expect(calculateIceBlastDamage(5.2)).toEqual(calculateIceBlastDamage(5)) + expect(calculateIceBlastDamage(5.9)).toEqual(calculateIceBlastDamage(5)) + expect(calculateIceBlastFreezeLength(5.2)).toBe(calculateIceBlastFreezeLength(5)) + expect(calculateIceBlastFreezeLength(5.9)).toBe(calculateIceBlastFreezeLength(5)) + + expect(calculateGlacialSpikeDamage(5.2)).toEqual(calculateGlacialSpikeDamage(5)) + expect(calculateGlacialSpikeDamage(5.9)).toEqual(calculateGlacialSpikeDamage(5)) + expect(calculateGlacialSpikeFreezeLength(5.2)).toBe(calculateGlacialSpikeFreezeLength(5)) + expect(calculateGlacialSpikeFreezeLength(5.9)).toBe(calculateGlacialSpikeFreezeLength(5)) + + expect(calculateColdMasteryPierce(5.2)).toBe(calculateColdMasteryPierce(5)) + expect(calculateColdMasteryPierce(5.9)).toBe(calculateColdMasteryPierce(5)) + }) + + it('handles very large values (> 1000) stably without overflow or precision collapse', () => { + const largeLvl = 1000 + const ibDmg = calculateIceBlastDamage(largeLvl) + expect(Number.isFinite(ibDmg.min)).toBe(true) + expect(Number.isFinite(ibDmg.max)).toBe(true) + expect(ibDmg.min).toBeLessThanOrEqual(ibDmg.max) + expect(ibDmg.min).toBeGreaterThan(10000) + + const gsDmg = calculateGlacialSpikeDamage(largeLvl) + expect(Number.isFinite(gsDmg.min)).toBe(true) + expect(Number.isFinite(gsDmg.max)).toBe(true) + expect(gsDmg.min).toBeLessThanOrEqual(gsDmg.max) + expect(gsDmg.min).toBeGreaterThan(10000) + + const cmPierce = calculateColdMasteryPierce(largeLvl) + expect(cmPierce).toBe(20 + 999 * 5) // 5015% pierce + }) + + it('handles adversarial negative synergy values and clamped multiplier overrides', () => { + // Negative synergy numbers clamped to 0 + expect(calculateIceBlastDamage(20, -50)).toEqual(calculateIceBlastDamage(20, 0)) + expect(calculateGlacialSpikeDamage(20, -50)).toEqual(calculateGlacialSpikeDamage(20, 0)) + expect(calculateIceBlastFreezeLength(20, -50)).toBe(calculateIceBlastFreezeLength(20, 0)) + expect(calculateGlacialSpikeFreezeLength(20, -50)).toBe(calculateGlacialSpikeFreezeLength(20, 0)) + + // Negative synergy object members clamped to 0 + expect( + calculateIceBlastDamage(20, { iceBolt: -10, blizzard: -20, frozenOrb: -5 } as any), + ).toEqual(calculateIceBlastDamage(20, 0)) + expect( + calculateGlacialSpikeDamage(20, { iceBolt: -10, blizzard: -20, frozenOrb: -5 } as any), + ).toEqual(calculateGlacialSpikeDamage(20, 0)) + + // Multiplier overrides: negative or zero multiplier clamped to >= 0 + expect(calculateIceBlastDamage(20, undefined, { synMultiplier: -2 })).toEqual({ min: 0, max: 0 }) + expect(calculateIceBlastDamage(20, undefined, { synMultiplier: 0 })).toEqual({ min: 0, max: 0 }) + expect(calculateGlacialSpikeDamage(20, undefined, { synMultiplier: -2 })).toEqual({ min: 0, max: 0 }) + expect(calculateGlacialSpikeDamage(20, undefined, { synMultiplier: 0 })).toEqual({ min: 0, max: 0 }) + }) + }) + + // ========================================================================= + // 5. Cold Mastery Combat Pipeline Adversarial Scenarios + // ========================================================================= + describe('5. Cold Mastery Combat Pipeline Adversarial Scenarios', () => { + it('authentically clamps effective resistance at -100% floor for vulnerable enemies', () => { + // Slvl 20 Cold Mastery: -115% pierce + const cm20 = calculateColdMasteryPierce(20) + expect(cm20).toBe(115) + + // Case 1: Enemy has 0% Cold Res -> 0 - 115 = -115%, but clamped at -100% + const res0 = computeEffectiveResistance({ baseRes: 0, passiveEnemyPierce: cm20 }) + expect(res0.effectiveRes).toBe(-100) + expect(res0.isImmune).toBe(false) + + // Case 2: Enemy has -50% Cold Res -> -50 - 115 = -165%, clamped at -100% + const resNeg50 = computeEffectiveResistance({ baseRes: -50, passiveEnemyPierce: cm20 }) + expect(resNeg50.effectiveRes).toBe(-100) + + // Case 3: Enemy has 50% Cold Res -> 50 - 115 = -65% (within [-100, 99]) + const res50 = computeEffectiveResistance({ baseRes: 50, passiveEnemyPierce: cm20 }) + expect(res50.effectiveRes).toBe(-65) + expect(res50.isImmune).toBe(false) + }) + + it('Cold Mastery alone CANNOT pierce Cold Immune monsters (res >= 100%)', () => { + const cm20 = calculateColdMasteryPierce(20) // 115% pierce + const cm99 = calculateColdMasteryPierce(99) // 510% pierce + + // Target with 100% Cold Res (immune) + const res100 = computeEffectiveResistance({ baseRes: 100, passiveEnemyPierce: cm20 }) + expect(res100.isImmune).toBe(true) + expect(res100.effectiveRes).toBe(100) + + // Target with 140% Cold Res (immune) with slvl 99 Cold Mastery + const res140 = computeEffectiveResistance({ baseRes: 140, passiveEnemyPierce: cm99 }) + expect(res140.isImmune).toBe(true) + expect(res140.effectiveRes).toBe(100) + }) + + it('Broken Cold Immunities receive 100% Cold Mastery pierce without 1/5 penalty', () => { + // Target with 110% Cold Res + // Conviction Level 25: -150% pierce + // When baseRes >= 100%, Conviction applies at 1/5th effectiveness -> -30% + // New resistance = 110 - 30 = 80% (< 100% -> immunity broken!) + // Once broken, Cold Mastery (slvl 20 = -115%) applies at full 100% effectiveness: + // Final effective resistance = 80 - 115 = -35% + const cm20 = calculateColdMasteryPierce(20) + const brokenResult = computeEffectiveResistance({ + baseRes: 110, + convictionPierce: 150, + passiveEnemyPierce: cm20, + }) + expect(brokenResult.isImmune).toBe(false) + expect(brokenResult.convictionAndLRApplied).toBe(30) + expect(brokenResult.resAfterConvictionLR).toBe(80) + expect(brokenResult.effectiveRes).toBe(-35) + }) + + it('Unbreakable Cold Immunities stay immune and bypass Cold Mastery entirely', () => { + // Target with 140% Cold Res + // Conviction Level 25 (-150% -> 1/5 = -30%) + // Res after Conviction = 140 - 30 = 110% (still >= 100% -> immune remains unbroken!) + // Cold Mastery must NOT apply! + const cm20 = calculateColdMasteryPierce(20) + const unbrokenResult = computeEffectiveResistance({ + baseRes: 140, + convictionPierce: 150, + passiveEnemyPierce: cm20, + }) + expect(unbrokenResult.isImmune).toBe(true) + expect(unbrokenResult.convictionAndLRApplied).toBe(30) + expect(unbrokenResult.resAfterConvictionLR).toBe(110) + expect(unbrokenResult.effectiveRes).toBe(100) + }) + }) + + // ========================================================================= + // 6. Differential Oracle Testing (Naive Band Summation vs Implementation) + // ========================================================================= + describe('6. Differential Oracle Testing', () => { + // Naive brute-force simulator for 1.13c 5-band table evaluation + function naiveBandEvaluator( + baseMin: number, + baseMax: number, + minSteps: number[], + maxSteps: number[], + slvl: number, + ): { min: number; max: number } { + const lvl = Math.max(1, Math.floor(slvl)) + let curMin = baseMin + let curMax = baseMax + for (let l = 2; l <= lvl; l++) { + let stepIdx = 0 + if (l >= 2 && l <= 8) stepIdx = 0 + else if (l >= 9 && l <= 16) stepIdx = 1 + else if (l >= 17 && l <= 22) stepIdx = 2 + else if (l >= 23 && l <= 28) stepIdx = 3 + else stepIdx = 4 + curMin += minSteps[stepIdx] + curMax += maxSteps[stepIdx] + } + return { min: curMin / 2, max: curMax / 2 } + } + + it('Ice Blast: matches naive brute-force 5-band oracle across all 99 levels', () => { + const minSteps = [14, 28, 42, 56, 70] + const maxSteps = [15, 29, 43, 57, 71] + + for (let slvl = 1; slvl <= 99; slvl++) { + const oracle = naiveBandEvaluator(16, 24, minSteps, maxSteps, slvl) + const actual = calculateIceBlastDamage(slvl) + expect(actual.min).toBe(oracle.min) + expect(actual.max).toBe(oracle.max) + } + }) + + it('Glacial Spike: matches naive brute-force 5-band oracle across all 99 levels', () => { + const minSteps = [14, 26, 28, 30, 32] + const maxSteps = [15, 27, 29, 31, 33] + + for (let slvl = 1; slvl <= 99; slvl++) { + const oracle = naiveBandEvaluator(32, 48, minSteps, maxSteps, slvl) + const actual = calculateGlacialSpikeDamage(slvl) + expect(actual.min).toBe(oracle.min) + expect(actual.max).toBe(oracle.max) + } + }) + }) + + // ========================================================================= + // 7. System Integration & Invariant Enforcement + // ========================================================================= + describe('7. System Integration & Invariant Enforcement', () => { + it('BATCH1_SKILLS length remains strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('calculateSkillDamage dispatcher cleanly routes skills 45, 55, and 65', () => { + expect(calculateSkillDamage(45, 10, { iceBolt: 5 })).toEqual( + calculateIceBlastDamage(10, { iceBolt: 5 }), + ) + expect(calculateSkillDamage('iceblast', 10, 5)).toEqual( + calculateIceBlastDamage(10, 5), + ) + expect(calculateSkillDamage(55, 10, { blizzard: 5 })).toEqual( + calculateGlacialSpikeDamage(10, { blizzard: 5 }), + ) + expect(calculateSkillDamage('glacialspike', 10, 5)).toEqual( + calculateGlacialSpikeDamage(10, 5), + ) + // Cold Mastery is passive, returns { min: 0, max: 0 } + expect(calculateSkillDamage(65, 20)).toEqual({ min: 0, max: 0 }) + expect(calculateSkillDamage('coldmastery', 20)).toEqual({ min: 0, max: 0 }) + }) + + it('mana costs and cooldown ticks adhere strictly to 1.13c specification', () => { + // Ice Blast: (12 + slvl - 1) * 0.5 + expect(getSkillManaCost(45, 1)).toBe(6.0) + expect(getSkillManaCost(45, 10)).toBe(10.5) + expect(getSkillManaCost(45, 20)).toBe(15.5) + + // Glacial Spike: (20 + slvl - 1) * 0.5 + expect(getSkillManaCost(55, 1)).toBe(10.0) + expect(getSkillManaCost(55, 10)).toBe(14.5) + expect(getSkillManaCost(55, 20)).toBe(19.5) + + // Cold Mastery: passive 0 mana + expect(getSkillManaCost(65, 1)).toBe(0) + expect(getSkillManaCost(65, 20)).toBe(0) + + // 0 cooldown ticks + expect(getSkillCooldownTicks(45)).toBe(0) + expect(getSkillCooldownTicks(55)).toBe(0) + expect(getSkillCooldownTicks(65)).toBe(0) + }) + }) +})