428 lines
18 KiB
TypeScript
428 lines
18 KiB
TypeScript
/**
|
|
* Adversarial Empirical Stress Testing Suite for Milestone 3 (Fire Grounds & Streams)
|
|
*
|
|
* Target Skills:
|
|
* - Skill 51: Fire Wall (firewallmaker / firewall)
|
|
* - Skill 46: Blaze (blaze buff / footstep ground fire patch)
|
|
* - Skill 41: Inferno (infernoflame1 / infernoflame2 continuous stream)
|
|
*
|
|
* Test Dimensions:
|
|
* 1. Monotonicity sweeps across slvl 1..99 for damage formulas
|
|
* 2. Synergy scaling & precision underflow/overflow/loss
|
|
* 3. Duration scaling (Blaze player buff and ground patch duration)
|
|
* 4. Inferno range scaling & yard-to-pixel conversion (range * 32)
|
|
* 5. Continuous mana drain in 256-fixed-point arithmetic over extended frames
|
|
* 6. Extreme inputs: slvl 0, 1, 20, 50, 99; synergy levels 0, 20, 50; boundary checks
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
calculateFireWallDamage,
|
|
calculateBlazeDamage,
|
|
calculateInfernoDamage,
|
|
calculateBlazeDuration,
|
|
calculateBlazePatchDuration,
|
|
calculateInfernoRange,
|
|
calculateInfernoRangePx,
|
|
calculateInfernoContinuousMana256,
|
|
calculateFireWallPatches,
|
|
} from '../../../src/game/skills.ts'
|
|
|
|
describe('Milestone 3 Math Adversarial Stress Suite', () => {
|
|
// ==========================================================================
|
|
// Suite 1: Monotonicity Sweeps (slvl 1..99)
|
|
// ==========================================================================
|
|
describe('Suite 1: Monotonicity Sweeps across slvl 1..99', () => {
|
|
it('Fire Wall damage strictly increases monotonically across slvl 1..99', () => {
|
|
let prev = calculateFireWallDamage(1)
|
|
expect(prev.min).toBe(15)
|
|
expect(prev.max).toBe(20)
|
|
expect(prev.min256).toBe(240)
|
|
expect(prev.max256).toBe(320)
|
|
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateFireWallDamage(slvl)
|
|
|
|
// Strict monotonicity checks
|
|
expect(curr.min).toBeGreaterThan(prev.min)
|
|
expect(curr.max).toBeGreaterThan(prev.max)
|
|
expect(curr.min256).toBeGreaterThan(prev.min256)
|
|
expect(curr.max256).toBeGreaterThan(prev.max256)
|
|
expect(curr.perSecMin).toBeGreaterThan(prev.perSecMin)
|
|
expect(curr.perSecMax).toBeGreaterThan(prev.perSecMax)
|
|
|
|
// Min <= Max invariant
|
|
expect(curr.min).toBeLessThanOrEqual(curr.max)
|
|
expect(curr.min256).toBeLessThanOrEqual(curr.max256)
|
|
expect(curr.perSecMin).toBeLessThanOrEqual(curr.perSecMax)
|
|
|
|
prev = curr
|
|
}
|
|
})
|
|
|
|
it('Blaze damage strictly increases monotonically across slvl 1..99', () => {
|
|
let prev = calculateBlazeDamage(1)
|
|
expect(prev.min).toBe(4)
|
|
expect(prev.max).toBe(8)
|
|
expect(prev.min256).toBe(64)
|
|
expect(prev.max256).toBe(128)
|
|
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateBlazeDamage(slvl)
|
|
|
|
// Strict monotonicity checks
|
|
expect(curr.min).toBeGreaterThan(prev.min)
|
|
expect(curr.max).toBeGreaterThan(prev.max)
|
|
expect(curr.min256).toBeGreaterThan(prev.min256)
|
|
expect(curr.max256).toBeGreaterThan(prev.max256)
|
|
expect(curr.perSecMin).toBeGreaterThan(prev.perSecMin)
|
|
expect(curr.perSecMax).toBeGreaterThan(prev.perSecMax)
|
|
|
|
// Min <= Max invariant
|
|
expect(curr.min).toBeLessThanOrEqual(curr.max)
|
|
expect(curr.min256).toBeLessThanOrEqual(curr.max256)
|
|
expect(curr.perSecMin).toBeLessThanOrEqual(curr.perSecMax)
|
|
|
|
prev = curr
|
|
}
|
|
})
|
|
|
|
it('Inferno damage strictly increases monotonically across slvl 1..99', () => {
|
|
let prev = calculateInfernoDamage(1)
|
|
expect(prev.min).toBe(32)
|
|
expect(prev.max).toBe(64)
|
|
expect(prev.min256).toBe(128)
|
|
expect(prev.max256).toBe(256)
|
|
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateInfernoDamage(slvl)
|
|
|
|
// Strict monotonicity checks
|
|
expect(curr.min).toBeGreaterThan(prev.min)
|
|
expect(curr.max).toBeGreaterThan(prev.max)
|
|
expect(curr.min256).toBeGreaterThan(prev.min256)
|
|
expect(curr.max256).toBeGreaterThan(prev.max256)
|
|
expect(curr.perSecMin).toBeGreaterThan(prev.perSecMin)
|
|
expect(curr.perSecMax).toBeGreaterThan(prev.perSecMax)
|
|
|
|
// Min <= Max invariant
|
|
expect(curr.min).toBeLessThanOrEqual(curr.max)
|
|
expect(curr.min256).toBeLessThanOrEqual(curr.max256)
|
|
expect(curr.perSecMin).toBeLessThanOrEqual(curr.perSecMax)
|
|
|
|
prev = curr
|
|
}
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 2: Synergy Scaling & Precision Underflow/Overflow/Loss
|
|
// ==========================================================================
|
|
describe('Suite 2: Synergy Scaling & Precision', () => {
|
|
it('Fire Wall scales linearly with Warmth (+4%/lvl) and Inferno (+1%/lvl) synergies', () => {
|
|
const base = calculateFireWallDamage(20) // raw 274-279, min256=4384, max256=4464
|
|
|
|
// Sweep Warmth 0..20
|
|
let prevWarmthDmg = calculateFireWallDamage(20, { warmth: 0 })
|
|
for (let w = 1; w <= 20; w++) {
|
|
const curr = calculateFireWallDamage(20, { warmth: w })
|
|
expect(curr.min256).toBeGreaterThan(prevWarmthDmg.min256)
|
|
expect(curr.max256).toBeGreaterThan(prevWarmthDmg.max256)
|
|
// Expected multiplier: 1 + 0.04 * w
|
|
const expectedMin256 = Math.floor(274 * 16 * (1 + 0.04 * w))
|
|
expect(curr.min256).toBe(expectedMin256)
|
|
prevWarmthDmg = curr
|
|
}
|
|
|
|
// Sweep Inferno 0..20
|
|
let prevInfernoDmg = calculateFireWallDamage(20, { inferno: 0 })
|
|
for (let inf = 1; inf <= 20; inf++) {
|
|
const curr = calculateFireWallDamage(20, { inferno: inf })
|
|
expect(curr.min256).toBeGreaterThan(prevInfernoDmg.min256)
|
|
expect(curr.max256).toBeGreaterThan(prevInfernoDmg.max256)
|
|
// Expected multiplier: 1 + 0.01 * inf
|
|
const expectedMin256 = Math.floor(274 * 16 * (1 + 0.01 * inf))
|
|
expect(curr.min256).toBe(expectedMin256)
|
|
prevInfernoDmg = curr
|
|
}
|
|
|
|
// Max synergy: Warmth 20 (+80%) + Inferno 20 (+20%) = +100% (2.0x base)
|
|
const maxSyn = calculateFireWallDamage(20, { warmth: 20, inferno: 20 })
|
|
expect(maxSyn.min256).toBe(base.min256 * 2)
|
|
expect(maxSyn.max256).toBe(base.max256 * 2)
|
|
})
|
|
|
|
it('Blaze scales linearly with Warmth (+4%/lvl) and Fire Wall (+1%/lvl) synergies', () => {
|
|
const base = calculateBlazeDamage(20) // raw 58-62, min256=928, max256=992
|
|
|
|
// Max synergy: Warmth 20 (+80%) + Fire Wall 20 (+20%) = +100% (2.0x base)
|
|
const maxSyn = calculateBlazeDamage(20, { warmth: 20, fireWall: 20 })
|
|
expect(maxSyn.min256).toBe(base.min256 * 2)
|
|
expect(maxSyn.max256).toBe(base.max256 * 2)
|
|
|
|
// Extreme synergy: Warmth 50 (+200%) + Fire Wall 50 (+50%) = +250% (3.5x base)
|
|
const extremeSyn = calculateBlazeDamage(20, { warmth: 50, fireWall: 50 })
|
|
const expectedMin256 = Math.floor(58 * 16 * 3.5)
|
|
expect(extremeSyn.min256).toBe(expectedMin256)
|
|
expect(extremeSyn.min256).toBe(3248)
|
|
})
|
|
|
|
it('Inferno scales linearly with Warmth (+13%/lvl) synergy', () => {
|
|
const base = calculateInfernoDamage(20) // raw 520-564, min256=2080, max256=2256
|
|
|
|
// Warmth 20: +260% (3.6x base)
|
|
const syn20 = calculateInfernoDamage(20, { warmth: 20 })
|
|
const expectedMin256_20 = Math.floor(520 * 4 * (1 + 2.6))
|
|
expect(syn20.min256).toBe(expectedMin256_20)
|
|
expect(syn20.min256).toBe(Math.floor(2080 * 3.6))
|
|
|
|
// Warmth 50: +650% (7.5x base)
|
|
const syn50 = calculateInfernoDamage(20, { warmth: 50 })
|
|
const expectedMin256_50 = Math.floor(520 * 4 * (1 + 6.5))
|
|
expect(syn50.min256).toBe(expectedMin256_50)
|
|
expect(syn50.min256).toBe(15600)
|
|
})
|
|
|
|
it('Precision invariant: no underflow, overflow, or NaN at high level and high synergy', () => {
|
|
// slvl 99 with synergy 50 and Fire Mastery 50
|
|
const fw = calculateFireWallDamage(99, { warmth: 50, inferno: 50, fireMastery: 50 })
|
|
expect(Number.isFinite(fw.min)).toBe(true)
|
|
expect(Number.isFinite(fw.max)).toBe(true)
|
|
expect(Number.isFinite(fw.min256)).toBe(true)
|
|
expect(Number.isFinite(fw.max256)).toBe(true)
|
|
expect(fw.min256).toBeGreaterThan(0)
|
|
expect(fw.max256).toBeGreaterThan(fw.min256)
|
|
|
|
const bz = calculateBlazeDamage(99, { warmth: 50, fireWall: 50, fireMastery: 50 })
|
|
expect(Number.isFinite(bz.min256)).toBe(true)
|
|
expect(bz.min256).toBeGreaterThan(0)
|
|
|
|
const inf = calculateInfernoDamage(99, { warmth: 50, fireMastery: 50 })
|
|
expect(Number.isFinite(inf.min256)).toBe(true)
|
|
expect(inf.min256).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 3: Duration Scaling (Blaze Buff & Patch)
|
|
// ==========================================================================
|
|
describe('Suite 3: Duration Scaling', () => {
|
|
it('Blaze ground patch duration strictly increases monotonically by +25 frames per level across slvl 1..99', () => {
|
|
let prev = calculateBlazePatchDuration(1)
|
|
expect(prev).toBe(115) // 90 + 25 = 115 frames (4.6s)
|
|
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateBlazePatchDuration(slvl)
|
|
expect(curr).toBe(prev + 25)
|
|
expect(curr).toBe(90 + slvl * 25)
|
|
prev = curr
|
|
}
|
|
|
|
expect(calculateBlazePatchDuration(10)).toBe(340)
|
|
expect(calculateBlazePatchDuration(20)).toBe(590)
|
|
expect(calculateBlazePatchDuration(99)).toBe(2565)
|
|
})
|
|
|
|
it('Blaze player buff duration scales monotonically (non-decreasing) across slvl 1..99', () => {
|
|
let prev = calculateBlazeDuration(1)
|
|
expect(prev).toBe(120) // floor(110*1*450 / 700) + 50 = 70 + 50 = 120 frames (4.8s)
|
|
|
|
let plateaus = 0
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateBlazeDuration(slvl)
|
|
// Monotonicity (non-decreasing)
|
|
expect(curr).toBeGreaterThanOrEqual(prev)
|
|
if (curr === prev) {
|
|
plateaus++
|
|
}
|
|
prev = curr
|
|
}
|
|
|
|
// Diminishing returns curve asymptotic limit is 495 + 50 = 545 frames.
|
|
// Because of floor of rational curve 495 * x / (x + 6), plateaus occur at very high levels (e.g. 80 -> 81).
|
|
expect(calculateBlazeDuration(10)).toBe(359)
|
|
expect(calculateBlazeDuration(20)).toBe(430)
|
|
expect(calculateBlazeDuration(50)).toBe(491)
|
|
expect(calculateBlazeDuration(80)).toBe(510)
|
|
expect(calculateBlazeDuration(81)).toBe(510) // Empirical plateau observed
|
|
expect(calculateBlazeDuration(99)).toBe(516)
|
|
expect(plateaus).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 4: Inferno Range Scaling & Yard-to-Pixel Conversion
|
|
// ==========================================================================
|
|
describe('Suite 4: Inferno Range Scaling & Yard-to-Pixel Conversion', () => {
|
|
it('Inferno range in yards scales strictly monotonically: ((20 + 3 * (slvl - 1)) / 2)', () => {
|
|
let prev = calculateInfernoRange(1)
|
|
expect(prev).toBe(10) // (20 + 0) / 2 = 10 yards
|
|
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateInfernoRange(slvl)
|
|
expect(curr).toBe(prev + 1.5)
|
|
expect(curr).toBe((20 + 3 * (slvl - 1)) / 2)
|
|
prev = curr
|
|
}
|
|
|
|
expect(calculateInfernoRange(10)).toBe(23.5)
|
|
expect(calculateInfernoRange(20)).toBe(38.5)
|
|
expect(calculateInfernoRange(50)).toBe(83.5)
|
|
expect(calculateInfernoRange(99)).toBe(157.0)
|
|
})
|
|
|
|
it('ADVERSARIAL CHECK: Inferno range converts accurately to screen pixels via 1.13c yard-to-pixel ratio (range * 32)', () => {
|
|
// Diablo II 1.13c ground truth: 1 tile = 5x5 subtiles = 160x80 px -> 1 subtile (yard) = 32 pixels in length.
|
|
// The requirement explicitly mandates: "converts accurately to pixels (range * 32)".
|
|
const expectedPxLvl1 = calculateInfernoRange(1) * 32 // 10.0 * 32 = 320 px
|
|
const actualPxLvl1 = calculateInfernoRangePx(1)
|
|
|
|
const expectedPxLvl10 = calculateInfernoRange(10) * 32 // 23.5 * 32 = 752 px
|
|
const actualPxLvl10 = calculateInfernoRangePx(10)
|
|
|
|
const expectedPxLvl20 = calculateInfernoRange(20) * 32 // 38.5 * 32 = 1232 px
|
|
const actualPxLvl20 = calculateInfernoRangePx(20)
|
|
|
|
const expectedPxLvl99 = calculateInfernoRange(99) * 32 // 157.0 * 32 = 5024 px
|
|
const actualPxLvl99 = calculateInfernoRangePx(99)
|
|
|
|
// EMPIRICAL VERIFICATION:
|
|
// Note: Implementation in src/game/engine/missile-engine.ts uses:
|
|
// `return calculateInfernoRange(slvl) * 15`
|
|
// instead of authentic `calculateInfernoRange(slvl) * 32`.
|
|
expect(actualPxLvl1).toBe(expectedPxLvl1)
|
|
expect(actualPxLvl10).toBe(expectedPxLvl10)
|
|
expect(actualPxLvl20).toBe(expectedPxLvl20)
|
|
expect(actualPxLvl99).toBe(expectedPxLvl99)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 5: Continuous Mana Drain in 256-Fixed-Point
|
|
// ==========================================================================
|
|
describe('Suite 5: Continuous Mana Drain (256-Fixed-Point)', () => {
|
|
it('Inferno continuous mana drain rate in 256ths per frame: (36 + (slvl - 1)) * 4', () => {
|
|
expect(calculateInfernoContinuousMana256(1)).toBe(144) // 144/256 mana/frame
|
|
expect(calculateInfernoContinuousMana256(10)).toBe(180)
|
|
expect(calculateInfernoContinuousMana256(20)).toBe(220)
|
|
expect(calculateInfernoContinuousMana256(50)).toBe(340)
|
|
expect(calculateInfernoContinuousMana256(99)).toBe(536)
|
|
|
|
// Strictly monotonic
|
|
let prev = calculateInfernoContinuousMana256(1)
|
|
for (let slvl = 2; slvl <= 99; slvl++) {
|
|
const curr = calculateInfernoContinuousMana256(slvl)
|
|
expect(curr).toBe(prev + 4)
|
|
prev = curr
|
|
}
|
|
})
|
|
|
|
it('Simulates extended continuous mana depletion without sub-mana precision drift over 750 frames (30s)', () => {
|
|
const slvl = 1
|
|
const drainPerFrame256 = calculateInfernoContinuousMana256(slvl) // 144
|
|
expect(drainPerFrame256).toBe(144)
|
|
|
|
// Starting mana: 500 mana = 500 * 256 = 128,000 units
|
|
const initialMana = 500
|
|
let currentMana256 = initialMana * 256
|
|
|
|
// Drain frame-by-frame for 750 frames (30 seconds at 25 fps)
|
|
const frames = 750
|
|
for (let f = 0; f < frames; f++) {
|
|
currentMana256 -= drainPerFrame256
|
|
}
|
|
|
|
const totalDrained256 = frames * drainPerFrame256 // 750 * 144 = 108,000
|
|
expect(currentMana256).toBe(initialMana * 256 - totalDrained256) // exactly 20,000 units left
|
|
|
|
// Integer mana remaining: floor(20,000 / 256) = 78 mana, with fractional remainder 32/256 (0.125)
|
|
const integerManaLeft = Math.floor(currentMana256 / 256)
|
|
const remainder256 = currentMana256 % 256
|
|
expect(integerManaLeft).toBe(78)
|
|
expect(remainder256).toBe(32)
|
|
|
|
// Compare against naive integer truncation per frame:
|
|
// If an engine truncated Math.floor(144 / 256) each frame, it would drain 0 mana per frame!
|
|
const naiveDrain = Math.floor(drainPerFrame256 / 256) * frames
|
|
expect(naiveDrain).toBe(0) // Demonstrates why 256-fixed-point precision is required
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 6: Extreme Inputs & Boundary Values
|
|
// ==========================================================================
|
|
describe('Suite 6: Extreme Inputs & Boundary Values', () => {
|
|
it('Handles extreme slvl 0 by safe clamping to slvl 1', () => {
|
|
// Fire Wall
|
|
const fw0 = calculateFireWallDamage(0)
|
|
const fw1 = calculateFireWallDamage(1)
|
|
expect(fw0).toEqual(fw1)
|
|
|
|
// Blaze
|
|
const bz0 = calculateBlazeDamage(0)
|
|
const bz1 = calculateBlazeDamage(1)
|
|
expect(bz0).toEqual(bz1)
|
|
|
|
// Inferno
|
|
const inf0 = calculateInfernoDamage(0)
|
|
const inf1 = calculateInfernoDamage(1)
|
|
expect(inf0).toEqual(inf1)
|
|
|
|
// Durations & Ranges
|
|
expect(calculateBlazeDuration(0)).toBe(calculateBlazeDuration(1))
|
|
expect(calculateBlazePatchDuration(0)).toBe(calculateBlazePatchDuration(1))
|
|
expect(calculateInfernoRange(0)).toBe(calculateInfernoRange(1))
|
|
expect(calculateInfernoContinuousMana256(0)).toBe(calculateInfernoContinuousMana256(1))
|
|
})
|
|
|
|
it('Handles negative or NaN slvl inputs gracefully without crashing', () => {
|
|
expect(calculateFireWallDamage(-5).min).toBe(15)
|
|
expect(calculateFireWallDamage(NaN).min).toBe(15)
|
|
expect(calculateBlazeDuration(-1)).toBe(120)
|
|
expect(calculateInfernoRange(-10)).toBe(10)
|
|
expect(calculateInfernoContinuousMana256(-1)).toBe(144)
|
|
})
|
|
|
|
it('Verifies exact discrete values at slvl 1, 20, 50, 99', () => {
|
|
// Level 1
|
|
expect(calculateFireWallDamage(1)).toMatchObject({ min: 15, max: 20, min256: 240, max256: 320 })
|
|
expect(calculateBlazeDamage(1)).toMatchObject({ min: 4, max: 8, min256: 64, max256: 128 })
|
|
expect(calculateInfernoDamage(1)).toMatchObject({ min: 32, max: 64, min256: 128, max256: 256 })
|
|
|
|
// Level 20
|
|
expect(calculateFireWallDamage(20)).toMatchObject({ min: 274, max: 279, min256: 4384, max256: 4464 })
|
|
expect(calculateBlazeDamage(20)).toMatchObject({ min: 58, max: 62, min256: 928, max256: 992 })
|
|
expect(calculateInfernoDamage(20)).toMatchObject({ min: 520, max: 564, min256: 2080, max256: 2256 })
|
|
|
|
// Level 50
|
|
expect(calculateFireWallDamage(50)).toMatchObject({ min: 904, max: 909, min256: 14464, max256: 14544 })
|
|
expect(calculateBlazeDamage(50)).toMatchObject({ min: 300, max: 304, min256: 4800, max256: 4864 })
|
|
expect(calculateInfernoDamage(50)).toMatchObject({ min: 1560, max: 1634, min256: 6240, max256: 6536 })
|
|
|
|
// Level 99
|
|
expect(calculateFireWallDamage(99)).toMatchObject({ min: 1933, max: 1938, min256: 30928, max256: 31008 })
|
|
expect(calculateBlazeDamage(99)).toMatchObject({ min: 741, max: 745, min256: 11856, max256: 11920 })
|
|
expect(calculateInfernoDamage(99)).toMatchObject({ min: 3324, max: 3447, min256: 13296, max256: 13788 })
|
|
})
|
|
|
|
it('Handles extreme synergy values (0, 20, 50, and negative)', () => {
|
|
// Synergy 0
|
|
const fw_s0 = calculateFireWallDamage(20, { warmth: 0, inferno: 0 })
|
|
expect(fw_s0.min256).toBe(4384)
|
|
|
|
// Synergy 20/20
|
|
const fw_s20 = calculateFireWallDamage(20, { warmth: 20, inferno: 20 })
|
|
expect(fw_s20.min256).toBe(8768)
|
|
|
|
// Synergy 50/50
|
|
const fw_s50 = calculateFireWallDamage(20, { warmth: 50, inferno: 50 })
|
|
// 50*4 + 50*1 = 250% bonus -> 3.5x multiplier
|
|
expect(fw_s50.min256).toBe(Math.floor(274 * 16 * 3.5))
|
|
|
|
// Negative synergy should clamp to 0
|
|
const fw_neg = calculateFireWallDamage(20, { warmth: -10, inferno: -5 })
|
|
expect(fw_neg.min256).toBe(4384)
|
|
})
|
|
})
|
|
})
|