859 lines
33 KiB
TypeScript
859 lines
33 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
BATCH1_SKILLS,
|
||
getBatch1SkillDef,
|
||
isBatch1Skill,
|
||
getSkillManaCost,
|
||
getSkillCooldownTicks,
|
||
calculateSkillDamage,
|
||
executeSkill,
|
||
tickProjectiles,
|
||
type SkillExecutionContext,
|
||
type Batch1SkillDef,
|
||
type Projectile,
|
||
type ProjectileTarget,
|
||
} from '../src/game/skills.ts'
|
||
import {
|
||
CANONICAL_SKILL_TREE,
|
||
createSkillTreeState,
|
||
allocatePoint,
|
||
calculateSynergyMultiplier,
|
||
} from '../src/game/skill-tree.ts'
|
||
import type { CombatPlayer } from '../src/game/combat.ts'
|
||
|
||
/**
|
||
* Helper to construct a standard combat player for testing.
|
||
*/
|
||
function createPlayer(overrides?: Partial<CombatPlayer>): CombatPlayer {
|
||
return {
|
||
x: 100,
|
||
y: 100,
|
||
facing: 0,
|
||
hp: 100,
|
||
maxHp: 100,
|
||
mana: 100,
|
||
maxMana: 100,
|
||
level: 30,
|
||
xp: 0,
|
||
cooldown: 0,
|
||
alive: true,
|
||
respawnIn: 0,
|
||
swingTicks: 0,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Independent mathematical ground-truth oracle for D2 1.13c damage calculations.
|
||
* Used for differential testing against calculateSkillDamage.
|
||
*/
|
||
function computeDamageOracle(
|
||
def: Batch1SkillDef,
|
||
effectiveLevel: number,
|
||
synergyMultiplier: number = 1.0,
|
||
weaponDamage?: { min: number; max: number }
|
||
): { min: number; max: number } {
|
||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||
|
||
let min = def.emin
|
||
let max = def.emax
|
||
|
||
if (lvl > 1) {
|
||
const b1 = Math.min(lvl - 1, 7)
|
||
min += b1 * def.elev1
|
||
max += b1 * def.elev1
|
||
|
||
if (lvl > 8) {
|
||
const b2 = Math.min(lvl - 8, 8)
|
||
min += b2 * def.elev2
|
||
max += b2 * def.elev2
|
||
}
|
||
if (lvl > 16) {
|
||
const b3 = Math.min(lvl - 16, 6)
|
||
min += b3 * def.elev3
|
||
max += b3 * def.elev3
|
||
}
|
||
if (lvl > 22) {
|
||
const b4 = Math.min(lvl - 22, 6)
|
||
min += b4 * def.elev4
|
||
max += b4 * def.elev4
|
||
}
|
||
if (lvl > 28) {
|
||
const b5 = lvl - 28
|
||
min += b5 * def.elev5
|
||
max += b5 * def.elev5
|
||
}
|
||
}
|
||
|
||
const hitShift = def.hitshift ?? 8
|
||
const shiftMultiplier = 2 ** (hitShift - 8)
|
||
min *= shiftMultiplier
|
||
max *= shiftMultiplier
|
||
|
||
const syn = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 1.0
|
||
min = Math.floor(min * syn)
|
||
max = Math.floor(max * syn)
|
||
|
||
if (weaponDamage && def.srcDamage > 0) {
|
||
const wpnMult = def.srcDamage / 128.0
|
||
min += Math.floor(Math.max(0, weaponDamage.min) * wpnMult)
|
||
max += Math.floor(Math.max(0, weaponDamage.max) * wpnMult)
|
||
}
|
||
|
||
return { min, max }
|
||
}
|
||
|
||
/**
|
||
* Computes unfloored base damage (after hitshift) to verify exact fixed-point synergy scaling.
|
||
*/
|
||
function computeUnflooredBaseDamage(def: Batch1SkillDef, effectiveLevel: number): { min: number; max: number } {
|
||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||
let min = def.emin
|
||
let max = def.emax
|
||
|
||
if (lvl > 1) {
|
||
const b1 = Math.min(lvl - 1, 7)
|
||
min += b1 * def.elev1
|
||
max += b1 * def.elev1
|
||
if (lvl > 8) {
|
||
const b2 = Math.min(lvl - 8, 8)
|
||
min += b2 * def.elev2
|
||
max += b2 * def.elev2
|
||
}
|
||
if (lvl > 16) {
|
||
const b3 = Math.min(lvl - 16, 6)
|
||
min += b3 * def.elev3
|
||
max += b3 * def.elev3
|
||
}
|
||
if (lvl > 22) {
|
||
const b4 = Math.min(lvl - 22, 6)
|
||
min += b4 * def.elev4
|
||
max += b4 * def.elev4
|
||
}
|
||
if (lvl > 28) {
|
||
const b5 = lvl - 28
|
||
min += b5 * def.elev5
|
||
max += b5 * def.elev5
|
||
}
|
||
}
|
||
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
return { min: min * shift, max: max * shift }
|
||
}
|
||
|
||
describe('M3 Adversarial Stress Testing: Combat Engine & Damage Mathematics', () => {
|
||
// ==========================================================================
|
||
// Suite 1: Full-Spectrum 38 Skills × Levels 1..99 Level-Band Progressions
|
||
// ==========================================================================
|
||
describe('1. Full-Spectrum 38 Skills × Levels 1..99 Level-Band Progressions', () => {
|
||
it('exhaustively matches oracle across all 38 skills at all levels 1..99 (3,762 combinations)', () => {
|
||
const skillIds = Object.keys(BATCH1_SKILLS).map(Number)
|
||
expect(skillIds.length).toBe(38)
|
||
|
||
for (const skillId of skillIds) {
|
||
const def = getBatch1SkillDef(skillId)!
|
||
for (let lvl = 1; lvl <= 99; lvl++) {
|
||
const actual = calculateSkillDamage(skillId, lvl)
|
||
const expected = computeDamageOracle(def, lvl)
|
||
expect(actual.min).toBe(expected.min)
|
||
expect(actual.max).toBe(expected.max)
|
||
expect(actual.min).toBeLessThanOrEqual(actual.max)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('rigorously tests exact 5-tier level-band transition boundaries (levels 1, 2, 8, 9, 16, 17, 22, 23, 28, 29, 50, 99)', () => {
|
||
const testLevels = [1, 2, 8, 9, 16, 17, 22, 23, 28, 29, 50, 99]
|
||
|
||
for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) {
|
||
const skillId = Number(idStr)
|
||
for (const lvl of testLevels) {
|
||
const res = calculateSkillDamage(skillId, lvl)
|
||
const oracle = computeDamageOracle(def, lvl)
|
||
expect(res.min).toBe(oracle.min)
|
||
expect(res.max).toBe(oracle.max)
|
||
|
||
// Explicit validation of level band step calculation
|
||
if (lvl === 1) {
|
||
// Level 1: exactly emin/emax shifted
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor(def.emin * shift))
|
||
expect(res.max).toBe(Math.floor(def.emax * shift))
|
||
} else if (lvl === 2) {
|
||
// Level 2: +1 * elev1
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + def.elev1) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + def.elev1) * shift))
|
||
} else if (lvl === 8) {
|
||
// Level 8: +7 * elev1
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1) * shift))
|
||
} else if (lvl === 9) {
|
||
// Level 9: +7 * elev1 + 1 * elev2
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + def.elev2) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + def.elev2) * shift))
|
||
} else if (lvl === 16) {
|
||
// Level 16: +7 * elev1 + 8 * elev2
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2) * shift))
|
||
} else if (lvl === 17) {
|
||
// Level 17: +7 * elev1 + 8 * elev2 + 1 * elev3
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + def.elev3) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + def.elev3) * shift))
|
||
} else if (lvl === 22) {
|
||
// Level 22: +7 * elev1 + 8 * elev2 + 6 * elev3
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3) * shift))
|
||
} else if (lvl === 23) {
|
||
// Level 23: +7 * elev1 + 8 * elev2 + 6 * elev3 + 1 * elev4
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + def.elev4) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + def.elev4) * shift))
|
||
} else if (lvl === 28) {
|
||
// Level 28: +7 * elev1 + 8 * elev2 + 6 * elev3 + 6 * elev4
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4) * shift))
|
||
} else if (lvl === 29) {
|
||
// Level 29: +7 * elev1 + 8 * elev2 + 6 * elev3 + 6 * elev4 + 1 * elev5
|
||
const shift = 2 ** (def.hitshift - 8)
|
||
expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4 + def.elev5) * shift))
|
||
expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4 + def.elev5) * shift))
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
it('enforces monotonic damage progression for all growing skills across levels 1..99', () => {
|
||
for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) {
|
||
const skillId = Number(idStr)
|
||
if (def.elev1 === 0 && def.elev2 === 0 && def.elev3 === 0 && def.elev4 === 0 && def.elev5 === 0) {
|
||
continue
|
||
}
|
||
for (let lvl = 1; lvl < 99; lvl++) {
|
||
const dCurr = calculateSkillDamage(skillId, lvl)
|
||
const dNext = calculateSkillDamage(skillId, lvl + 1)
|
||
expect(dNext.min).toBeGreaterThanOrEqual(dCurr.min)
|
||
expect(dNext.max).toBeGreaterThanOrEqual(dCurr.max)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
// ==========================================================================
|
||
// Suite 2: HitShift Precision Scaling (Shifts 6, 7, 8)
|
||
// ==========================================================================
|
||
describe('2. HitShift Precision Scaling (Shifts 6, 7, 8)', () => {
|
||
it('verifies HitShift 8 (1.0x identity multiplier) preserves exact unattenuated values', () => {
|
||
// Blessed Hammer (112): hitshift 8, emin 12, emax 16, elev1 8
|
||
const bh1 = calculateSkillDamage(112, 1)
|
||
expect(bh1.min).toBe(12)
|
||
expect(bh1.max).toBe(16)
|
||
|
||
const bh2 = calculateSkillDamage(112, 2)
|
||
expect(bh2.min).toBe(20) // 12 + 8
|
||
expect(bh2.max).toBe(24) // 16 + 8
|
||
|
||
// Holy Bolt (101): hitshift 8, emin 8, emax 16, elev1 4
|
||
const hb1 = calculateSkillDamage(101, 1)
|
||
expect(hb1.min).toBe(8)
|
||
expect(hb1.max).toBe(16)
|
||
|
||
// Fire Blast (251): hitshift 8, emin 3, emax 4
|
||
const fb1 = calculateSkillDamage(251, 1)
|
||
expect(fb1.min).toBe(3)
|
||
expect(fb1.max).toBe(4)
|
||
})
|
||
|
||
it('verifies HitShift 7 (0.5x half multiplier) exactly halves raw values', () => {
|
||
// Fire Bolt (36): hitshift 7, emin 6, emax 12 -> 3..6
|
||
const fb1 = calculateSkillDamage(36, 1)
|
||
expect(fb1.min).toBe(3) // 6 * 0.5
|
||
expect(fb1.max).toBe(6) // 12 * 0.5
|
||
|
||
// Ice Bolt (39): hitshift 7, emin 6, emax 10 -> 3..5
|
||
const ib1 = calculateSkillDamage(39, 1)
|
||
expect(ib1.min).toBe(3) // 6 * 0.5
|
||
expect(ib1.max).toBe(5) // 10 * 0.5
|
||
|
||
// Fire Ball (47): hitshift 7, emin 12, emax 28 -> 6..14
|
||
const fball1 = calculateSkillDamage(47, 1)
|
||
expect(fball1.min).toBe(6) // 12 * 0.5
|
||
expect(fball1.max).toBe(14) // 28 * 0.5
|
||
|
||
// Teeth (67): hitshift 7, emin 4, emax 8 -> 2..4
|
||
const teeth1 = calculateSkillDamage(67, 1)
|
||
expect(teeth1.min).toBe(2) // 4 * 0.5
|
||
expect(teeth1.max).toBe(4) // 8 * 0.5
|
||
})
|
||
|
||
it('verifies HitShift 6 (0.25x quarter multiplier) precision behavior', () => {
|
||
// Test the mathematical precision invariant:
|
||
// hitshift 6 yields 0.25, hitshift 7 yields 0.5, hitshift 8 yields 1.0
|
||
const hitShiftFactors = [6, 7, 8].map(s => 2 ** (s - 8))
|
||
expect(hitShiftFactors[0]).toBe(0.25)
|
||
expect(hitShiftFactors[1]).toBe(0.5)
|
||
expect(hitShiftFactors[2]).toBe(1.0)
|
||
|
||
// Ratio comparisons: Shift 6 is exactly half of Shift 7, which is half of Shift 8
|
||
expect(hitShiftFactors[0] * 2).toBe(hitShiftFactors[1])
|
||
expect(hitShiftFactors[1] * 2).toBe(hitShiftFactors[2])
|
||
|
||
// Differential oracle verification on synthetic HitShift 6 definition
|
||
const syntheticShift6Def: Batch1SkillDef = {
|
||
id: 996,
|
||
name: 'Shift 6 Prober',
|
||
classCode: 'sor',
|
||
charClass: 'sorceress',
|
||
type: 'projectile',
|
||
mana: 10,
|
||
lvlmana: 1,
|
||
minmana: 1,
|
||
manashift: 8,
|
||
delay: 0,
|
||
hitshift: 6,
|
||
emin: 40,
|
||
emax: 80,
|
||
elev1: 8,
|
||
elev2: 12,
|
||
elev3: 16,
|
||
elev4: 20,
|
||
elev5: 24,
|
||
srcDamage: 0,
|
||
}
|
||
const oracleL1 = computeDamageOracle(syntheticShift6Def, 1)
|
||
expect(oracleL1.min).toBe(10) // 40 * 0.25
|
||
expect(oracleL1.max).toBe(20) // 80 * 0.25
|
||
})
|
||
})
|
||
|
||
// ==========================================================================
|
||
// Suite 3: Synergy Scaling Across Multipliers (1.0, 1.5, 2.0, 5.0, 10.0)
|
||
// ==========================================================================
|
||
describe('3. Synergy Scaling Across Multipliers (1.0, 1.5, 2.0, 5.0, 10.0)', () => {
|
||
const testMultipliers = [1.0, 1.5, 2.0, 5.0, 10.0]
|
||
|
||
it('strictly scales linearly with synergy multiplier and floors properly for all 38 skills', () => {
|
||
for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) {
|
||
const skillId = Number(idStr)
|
||
for (const mult of testMultipliers) {
|
||
for (const lvl of [1, 10, 20, 30]) {
|
||
const synergized = calculateSkillDamage(skillId, lvl, mult)
|
||
const unfloored = computeUnflooredBaseDamage(def, lvl)
|
||
|
||
// D2 1.13c ground truth: Synergy scales the 256ths precision unfloored base, then floors
|
||
expect(synergized.min).toBe(Math.floor(unfloored.min * mult))
|
||
expect(synergized.max).toBe(Math.floor(unfloored.max * mult))
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
it('enforces exact floor behavior on fractional results', () => {
|
||
// Fire Bolt (36) at L1 has min 3, max 6
|
||
// With mult = 1.5:
|
||
// min = floor(3 * 1.5) = floor(4.5) = 4 (NOT 5!)
|
||
// max = floor(6 * 1.5) = floor(9.0) = 9
|
||
const fb1_5 = calculateSkillDamage(36, 1, 1.5)
|
||
expect(fb1_5.min).toBe(4)
|
||
expect(fb1_5.max).toBe(9)
|
||
|
||
// Firestorm (225) at L1 has min 1, max 3
|
||
// With mult = 1.5:
|
||
// min = floor(1 * 1.5) = 1
|
||
// max = floor(3 * 1.5) = 4
|
||
const fs1_5 = calculateSkillDamage(225, 1, 1.5)
|
||
expect(fs1_5.min).toBe(1)
|
||
expect(fs1_5.max).toBe(4)
|
||
|
||
// Teeth (67) at L10 has raw min 15, unfloored min 7.5
|
||
// With mult = 1.5: floor(7.5 * 1.5) = floor(11.25) = 11
|
||
// With mult = 2.0: floor(7.5 * 2.0) = 15
|
||
const teethL10_1_5 = calculateSkillDamage(67, 10, 1.5)
|
||
expect(teethL10_1_5.min).toBe(11)
|
||
|
||
const teethL10_2_0 = calculateSkillDamage(67, 10, 2.0)
|
||
expect(teethL10_2_0.min).toBe(15)
|
||
|
||
// Blessed Hammer (112) at L1 has min 12, max 16
|
||
// mult = 1.5: min = 18, max = 24
|
||
// mult = 2.0: min = 24, max = 32
|
||
// mult = 5.0: min = 60, max = 80
|
||
// mult = 10.0: min = 120, max = 160
|
||
const bh1_0 = calculateSkillDamage(112, 1, 1.0)
|
||
const bh1_5 = calculateSkillDamage(112, 1, 1.5)
|
||
const bh2_0 = calculateSkillDamage(112, 1, 2.0)
|
||
const bh5_0 = calculateSkillDamage(112, 1, 5.0)
|
||
const bh10_0 = calculateSkillDamage(112, 1, 10.0)
|
||
|
||
expect(bh1_0).toEqual({ min: 12, max: 16 })
|
||
expect(bh1_5).toEqual({ min: 18, max: 24 })
|
||
expect(bh2_0).toEqual({ min: 24, max: 32 })
|
||
expect(bh5_0).toEqual({ min: 60, max: 80 })
|
||
expect(bh10_0).toEqual({ min: 120, max: 160 })
|
||
})
|
||
|
||
it('integrates with live skill tree allocations and verifies base-point invariant', () => {
|
||
// Sorceress Fire Ball (47) receives +14% per hard point from Fire Bolt (36) and Meteor (56)
|
||
const state = createSkillTreeState(40)
|
||
for (let i = 0; i < 20; i++) {
|
||
allocatePoint(CANONICAL_SKILL_TREE, state, 36, 1) // 20 pts in Fire Bolt
|
||
}
|
||
expect(state.hardPoints[36]).toBe(20)
|
||
|
||
const synMult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 47, 'elemental')
|
||
// 20 * 14% = +280% -> multiplier = 3.80
|
||
expect(synMult).toBeCloseTo(3.80, 5)
|
||
|
||
const fbBaseL20 = calculateSkillDamage(47, 20, 1.0)
|
||
const fbSynL20 = calculateSkillDamage(47, 20, synMult)
|
||
|
||
expect(fbSynL20.min).toBe(Math.floor(fbBaseL20.min * synMult))
|
||
expect(fbSynL20.max).toBe(Math.floor(fbBaseL20.max * synMult))
|
||
expect(fbSynL20.min).toBeGreaterThan(fbBaseL20.min * 3)
|
||
})
|
||
})
|
||
|
||
// ==========================================================================
|
||
// Suite 4: Weapon Damage Integration & SrcDamage Scaling
|
||
// ==========================================================================
|
||
describe('4. Weapon Damage Integration & SrcDamage Scaling', () => {
|
||
const testWeapon = { min: 25, max: 80 }
|
||
|
||
it('integrates 100% weapon damage (srcDamage = 128) for pure weapon melee skills', () => {
|
||
const pureWeaponSkills = [
|
||
10, // Jab
|
||
126, // Bash
|
||
96, // Sacrifice
|
||
106, // Zeal
|
||
133, // Double Swing
|
||
143, // Leap Attack
|
||
223, // Werewolf
|
||
254, // Tiger Strike
|
||
]
|
||
|
||
for (const skillId of pureWeaponSkills) {
|
||
const dmg = calculateSkillDamage(skillId, 1, 1.0, testWeapon)
|
||
// Base skill damage is 0, so result is exactly 100% of weapon damage
|
||
expect(dmg.min).toBe(25)
|
||
expect(dmg.max).toBe(80)
|
||
}
|
||
})
|
||
|
||
it('correctly combines weapon damage with flat elemental spell additions on hybrid skills', () => {
|
||
// Fire Arrow (7): srcDamage 128, fire damage at L1 is 1..4
|
||
const fa = calculateSkillDamage(7, 1, 1.0, testWeapon)
|
||
expect(fa.min).toBe(1 + 25) // 26
|
||
expect(fa.max).toBe(4 + 80) // 84
|
||
|
||
// Cold Arrow (11): srcDamage 128, cold damage at L1 is 3..4
|
||
const ca = calculateSkillDamage(11, 1, 1.0, testWeapon)
|
||
expect(ca.min).toBe(3 + 25) // 28
|
||
expect(ca.max).toBe(4 + 80) // 84
|
||
|
||
// Power Strike (14): srcDamage 128, lightning damage at L1 is 1..16
|
||
const ps = calculateSkillDamage(14, 1, 1.0, testWeapon)
|
||
expect(ps.min).toBe(1 + 25) // 26
|
||
expect(ps.max).toBe(16 + 80) // 96
|
||
})
|
||
|
||
it('strictly isolates weapon damage from synergy multipliers (synergy applies ONLY to spell damage)', () => {
|
||
// Fire Arrow (7) with synergy multiplier 2.0
|
||
// Base fire: 1..4. Synergized fire: floor(1 * 2) = 2, floor(4 * 2) = 8
|
||
// Weapon: 25..80 (must NOT be multiplied by 2.0!)
|
||
// Total expected: min = 2 + 25 = 27, max = 8 + 80 = 88
|
||
const faSynergized = calculateSkillDamage(7, 1, 2.0, testWeapon)
|
||
expect(faSynergized.min).toBe(27)
|
||
expect(faSynergized.max).toBe(88)
|
||
|
||
// If an engine erroneously multiplied weapon damage by 2.0, min would be 52 and max would be 168.
|
||
expect(faSynergized.min).not.toBe(Math.floor((1 + 25) * 2.0))
|
||
})
|
||
|
||
it('strictly ignores weapon damage for spell skills (srcDamage = 0)', () => {
|
||
const pureSpellSkills = [
|
||
36, // Fire Bolt
|
||
39, // Ice Bolt
|
||
47, // Fire Ball
|
||
67, // Teeth
|
||
101, // Holy Bolt
|
||
112, // Blessed Hammer
|
||
225, // Firestorm
|
||
229, // Molten Boulder
|
||
251, // Fire Blast
|
||
]
|
||
|
||
for (const skillId of pureSpellSkills) {
|
||
const withoutWeapon = calculateSkillDamage(skillId, 5, 1.0)
|
||
const withWeapon = calculateSkillDamage(skillId, 5, 1.0, testWeapon)
|
||
expect(withWeapon.min).toBe(withoutWeapon.min)
|
||
expect(withWeapon.max).toBe(withoutWeapon.max)
|
||
}
|
||
})
|
||
|
||
it('handles Smite (97) srcDamage = 0 invariant', () => {
|
||
// Smite uses shield damage, NOT weapon damage
|
||
const smiteWithWeapon = calculateSkillDamage(97, 1, 1.0, testWeapon)
|
||
expect(smiteWithWeapon.min).toBe(0)
|
||
expect(smiteWithWeapon.max).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ==========================================================================
|
||
// Suite 5: Combat Archetype Execution Stress Testing
|
||
// ==========================================================================
|
||
describe('5. Combat Archetype Execution Stress Testing', () => {
|
||
describe('Melee Multi-Hits, Knockback, Auto-Hit, and Recoil', () => {
|
||
it('Jab (10): strikes exactly 3 times across all levels 1..30', () => {
|
||
for (let lvl = 1; lvl <= 30; lvl++) {
|
||
const player = createPlayer({ mana: 1000 })
|
||
const res = executeSkill(10, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.hits).toBe(3)
|
||
expect(res.knockback).toBe(false)
|
||
expect(res.autoHit).toBe(false)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Zeal (106): strikes min(lvl + 1, 5) times, capping strictly at 5 hits', () => {
|
||
const player = createPlayer({ mana: 1000 })
|
||
|
||
const testZeal = (lvl: number, expectedHits: number) => {
|
||
const res = executeSkill(106, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.hits).toBe(expectedHits)
|
||
expect(res.knockback).toBe(false)
|
||
expect(res.autoHit).toBe(false)
|
||
}
|
||
}
|
||
|
||
testZeal(1, 2)
|
||
testZeal(2, 3)
|
||
testZeal(3, 4)
|
||
testZeal(4, 5)
|
||
testZeal(5, 5)
|
||
testZeal(10, 5)
|
||
testZeal(20, 5)
|
||
testZeal(99, 5)
|
||
})
|
||
|
||
it('Double Swing (133): strikes exactly 2 times across all levels 1..30', () => {
|
||
for (let lvl = 1; lvl <= 30; lvl++) {
|
||
const player = createPlayer({ mana: 1000 })
|
||
const res = executeSkill(133, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.hits).toBe(2)
|
||
expect(res.knockback).toBe(false)
|
||
expect(res.autoHit).toBe(false)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Bash (126): triggers knockback flag without autoHit', () => {
|
||
const player = createPlayer()
|
||
const res = executeSkill(126, { caster: player, skillLevel: 1 })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.knockback).toBe(true)
|
||
expect(res.autoHit).toBe(false)
|
||
expect(res.hits).toBe(1)
|
||
}
|
||
})
|
||
|
||
it('Smite (97): triggers auto-hit (100% hit rate) and knockback flags', () => {
|
||
const player = createPlayer()
|
||
const res = executeSkill(97, { caster: player, skillLevel: 1 })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.autoHit).toBe(true)
|
||
expect(res.knockback).toBe(true)
|
||
expect(res.hits).toBe(1)
|
||
}
|
||
})
|
||
|
||
it('Sacrifice (96): inflicts exactly 8% recoil self-damage', () => {
|
||
const player = createPlayer()
|
||
const res = executeSkill(96, { caster: player, skillLevel: 1 })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.selfDamagePct).toBe(8)
|
||
expect(res.knockback).toBe(false)
|
||
expect(res.autoHit).toBe(false)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('Radial Area Effects & Scaling', () => {
|
||
it('Frost Nova (44): maintains static calibrated radius of 240 pixels', () => {
|
||
for (const lvl of [1, 5, 10, 20]) {
|
||
const player = createPlayer({ mana: 1000 })
|
||
const res = executeSkill(44, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('area')
|
||
if (res.kind === 'area') {
|
||
expect(res.radius).toBe(240)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Corpse Explosion (74): dynamically expands radius via 80 + 20 * (lvl - 1)', () => {
|
||
const expectations = [
|
||
{ lvl: 1, radius: 80 },
|
||
{ lvl: 2, radius: 100 },
|
||
{ lvl: 5, radius: 160 },
|
||
{ lvl: 10, radius: 260 },
|
||
{ lvl: 20, radius: 460 },
|
||
]
|
||
for (const { lvl, radius } of expectations) {
|
||
const player = createPlayer({ mana: 1000 })
|
||
const res = executeSkill(74, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('area')
|
||
if (res.kind === 'area') {
|
||
expect(res.radius).toBe(radius)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('Projectile Trajectories, Speeds, and Zero-Distance Target Handling', () => {
|
||
it('spawns projectiles with correct directional velocity towards target', () => {
|
||
const player = createPlayer({ x: 100, y: 100 })
|
||
const target = { x: 300, y: 100 } // Directly east
|
||
|
||
const res = executeSkill(36, { caster: player, target, skillLevel: 1 })
|
||
expect(res.kind).toBe('projectile')
|
||
if (res.kind === 'projectile') {
|
||
expect(res.projectile.x).toBe(100)
|
||
expect(res.projectile.y).toBe(100)
|
||
expect(res.projectile.vx).toBe(500 / 25) // 20 px/tick (1.13c Missiles.txt Vel: 20)
|
||
expect(res.projectile.vy).toBeCloseTo(0, 5)
|
||
expect(res.projectile.ttl).toBe(Math.round(1000 / 20)) // 50 ticks (1.13c Range: 50)
|
||
}
|
||
})
|
||
|
||
it('spawns diagonal projectiles with normalized unit vector', () => {
|
||
const player = createPlayer({ x: 100, y: 100 })
|
||
const target = { x: 200, y: 200 } // 45 degrees southeast
|
||
|
||
const res = executeSkill(36, { caster: player, target, skillLevel: 1 })
|
||
expect(res.kind).toBe('projectile')
|
||
if (res.kind === 'projectile') {
|
||
const speedPerTick = 500 / 25
|
||
const expectedV = speedPerTick / Math.SQRT2
|
||
expect(res.projectile.vx).toBeCloseTo(expectedV, 4)
|
||
expect(res.projectile.vy).toBeCloseTo(expectedV, 4)
|
||
}
|
||
})
|
||
|
||
it('handles zero-distance target vector (target == caster) without producing NaN or zero velocity', () => {
|
||
const player = createPlayer({ x: 150, y: 250, facing: 0 }) // Facing south (0, 1)
|
||
const target = { x: 150, y: 250 } // Identical position
|
||
|
||
const res = executeSkill(36, { caster: player, target, skillLevel: 1 })
|
||
expect(res.kind).toBe('projectile')
|
||
if (res.kind === 'projectile') {
|
||
expect(Number.isFinite(res.projectile.vx)).toBe(true)
|
||
expect(Number.isFinite(res.projectile.vy)).toBe(true)
|
||
expect(res.projectile.vx).toBeCloseTo(0, 5)
|
||
expect(res.projectile.vy).toBe(500 / 25) // Falls back to facing south! (1.13c Vel: 20)
|
||
}
|
||
})
|
||
|
||
it('simulates projectile flight and hit collision via tickProjectiles', () => {
|
||
const proj: Projectile = {
|
||
skillId: '36',
|
||
x: 100,
|
||
y: 100,
|
||
vx: 20,
|
||
vy: 0,
|
||
damage: 15,
|
||
ttl: 10,
|
||
fromPlayer: true,
|
||
}
|
||
const targets: ProjectileTarget[] = [
|
||
{ index: 1, x: 150, y: 100, radius: 10, alive: true },
|
||
]
|
||
|
||
// Tick 1: moves to (120, 100), distance to target = 30 > 18
|
||
const step1 = tickProjectiles([proj], targets, { overlap: () => 0 })
|
||
expect(step1.alive.length).toBe(1)
|
||
expect(step1.hits.length).toBe(0)
|
||
expect(step1.alive[0]!.x).toBe(120)
|
||
|
||
// Tick 2: moves to (140, 100), distance to target = 10 <= 10 + 8
|
||
const step2 = tickProjectiles(step1.alive, targets, { overlap: () => 0 })
|
||
expect(step2.alive.length).toBe(0)
|
||
expect(step2.hits.length).toBe(1)
|
||
expect(step2.hits[0]!.targetIndex).toBe(1)
|
||
expect(step2.hits[0]!.damage).toBe(15)
|
||
})
|
||
})
|
||
|
||
describe('Buffs, Curses, Summons, Auras, and Passives', () => {
|
||
it('Bone Armor (68): absorbs 20 + 10 * (lvl - 1) physical damage', () => {
|
||
const player = createPlayer()
|
||
for (const lvl of [1, 5, 10, 20]) {
|
||
const res = executeSkill(68, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('buff')
|
||
if (res.kind === 'buff') {
|
||
expect(res.buffEffect.damageAbsorption).toBe(20 + 10 * (lvl - 1))
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Shout (138): boosts defense by 100 + 10 * (lvl - 1)%', () => {
|
||
const player = createPlayer()
|
||
for (const lvl of [1, 5, 10]) {
|
||
const res = executeSkill(138, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('buff')
|
||
if (res.kind === 'buff') {
|
||
expect(res.buffEffect.defensePct).toBe(100 + 10 * (lvl - 1))
|
||
expect(res.durationSeconds).toBe(16 + 4 * (lvl - 1))
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Burst of Speed (258): boosts attack and walk/run speed', () => {
|
||
const player = createPlayer()
|
||
const res = executeSkill(258, { caster: player, skillLevel: 10 }, { deductResources: false })
|
||
expect(res.kind).toBe('buff')
|
||
if (res.kind === 'buff') {
|
||
expect(res.buffEffect.attackSpeedPct).toBe(20 + 2 * (10 - 1)) // 38%
|
||
expect(res.buffEffect.walkRunSpeedPct).toBe(20 + 3 * (10 - 1)) // 47%
|
||
expect(res.durationSeconds).toBe(120 + 12 * (10 - 1)) // 228s
|
||
}
|
||
})
|
||
|
||
it('Amplify Damage (66): inflicts -100% physical resistance curse', () => {
|
||
const player = createPlayer()
|
||
const res = executeSkill(66, { caster: player, skillLevel: 5 }, { deductResources: false })
|
||
expect(res.kind).toBe('curse')
|
||
if (res.kind === 'curse') {
|
||
expect(res.debuffEffect.physicalResistPenaltyPct).toBe(100)
|
||
expect(res.radius).toBe(80 + 20 * (5 - 1)) // 160 px
|
||
expect(res.durationSeconds).toBe(8 + 3 * (5 - 1)) // 20s
|
||
}
|
||
})
|
||
|
||
it('Raise Skeleton (70): scales minion count via 1 + floor(lvl / 3)', () => {
|
||
const expectations = [
|
||
{ lvl: 1, count: 1 },
|
||
{ lvl: 2, count: 1 },
|
||
{ lvl: 3, count: 2 },
|
||
{ lvl: 5, count: 2 },
|
||
{ lvl: 6, count: 3 },
|
||
{ lvl: 9, count: 4 },
|
||
{ lvl: 20, count: 7 }, // 1 + floor(20/3) = 1 + 6 = 7
|
||
]
|
||
for (const { lvl, count } of expectations) {
|
||
const player = createPlayer({ mana: 1000 })
|
||
const res = executeSkill(70, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('summon')
|
||
if (res.kind === 'summon') {
|
||
expect(res.summonType).toBe('skeleton')
|
||
expect(res.count).toBe(count)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Might (98): aura scales bonus damage via 40 + 10 * (lvl - 1)%', () => {
|
||
const player = createPlayer()
|
||
for (const lvl of [1, 5, 10, 20]) {
|
||
const res = executeSkill(98, { caster: player, skillLevel: lvl }, { deductResources: false })
|
||
expect(res.kind).toBe('aura')
|
||
if (res.kind === 'aura') {
|
||
expect(res.auraBonusPct).toBe(40 + 10 * (lvl - 1))
|
||
expect(res.manaCost).toBe(0)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('Warmth (37), Lycanthropy (224), and Claw Mastery (252) passive effects', () => {
|
||
const player = createPlayer()
|
||
|
||
const w = executeSkill(37, { caster: player, skillLevel: 10 }, { deductResources: false })
|
||
expect(w.kind).toBe('passive')
|
||
if (w.kind === 'passive') {
|
||
expect(w.passiveBonus.manaRegenPct).toBe(30 + 12 * (10 - 1)) // 138%
|
||
}
|
||
|
||
const lyc = executeSkill(224, { caster: player, skillLevel: 5 }, { deductResources: false })
|
||
expect(lyc.kind).toBe('passive')
|
||
if (lyc.kind === 'passive') {
|
||
expect(lyc.passiveBonus.shapeshiftLifePct).toBe(20 + 5 * (5 - 1)) // 40%
|
||
}
|
||
|
||
const cm = executeSkill(252, { caster: player, skillLevel: 5 }, { deductResources: false })
|
||
expect(cm.kind).toBe('passive')
|
||
if (cm.kind === 'passive') {
|
||
expect(cm.passiveBonus.clawDamagePct).toBe(35 + 8 * (5 - 1)) // 67%
|
||
}
|
||
})
|
||
})
|
||
})
|
||
|
||
// ==========================================================================
|
||
// Suite 6: Adversarial Boundary Fuzzing & Resilience
|
||
// ==========================================================================
|
||
describe('6. Adversarial Boundary Fuzzing & Resilience', () => {
|
||
it('handles negative, zero, and extreme levels safely without throwing or NaN', () => {
|
||
const extremeLevels = [-999, -1, 0, NaN, Infinity, -Infinity, 1.75, 999999]
|
||
for (const lvl of extremeLevels) {
|
||
const dmg = calculateSkillDamage(36, lvl)
|
||
expect(Number.isFinite(dmg.min)).toBe(true)
|
||
expect(Number.isFinite(dmg.max)).toBe(true)
|
||
expect(dmg.min).toBeGreaterThanOrEqual(0)
|
||
expect(dmg.max).toBeGreaterThanOrEqual(dmg.min)
|
||
|
||
const mana = getSkillManaCost(36, lvl)
|
||
expect(Number.isFinite(mana)).toBe(true)
|
||
expect(mana).toBeGreaterThanOrEqual(0)
|
||
}
|
||
})
|
||
|
||
it('handles adversarial synergy inputs (NaN, negative, zero, Infinity)', () => {
|
||
const adversarialSyns = [NaN, -5.0, 0, -Infinity, Infinity]
|
||
for (const syn of adversarialSyns) {
|
||
const dmg = calculateSkillDamage(47, 20, syn)
|
||
expect(Number.isFinite(dmg.min)).toBe(true)
|
||
expect(Number.isFinite(dmg.max)).toBe(true)
|
||
expect(dmg.min).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('handles negative and malformed weapon damage structures gracefully', () => {
|
||
const negativeWeapon = { min: -100, max: -50 }
|
||
const dmg = calculateSkillDamage(10, 1, 1.0, negativeWeapon)
|
||
expect(dmg.min).toBe(0)
|
||
expect(dmg.max).toBe(0)
|
||
})
|
||
|
||
it('handles unknown skill IDs gracefully returning safe zeroed results', () => {
|
||
const unknownIds = [-1, 0, 99999]
|
||
for (const id of unknownIds) {
|
||
expect(isBatch1Skill(id)).toBe(false)
|
||
expect(getBatch1SkillDef(id)).toBeUndefined()
|
||
expect(getSkillManaCost(id, 1)).toBe(0)
|
||
expect(getSkillCooldownTicks(id)).toBe(0)
|
||
|
||
const dmg = calculateSkillDamage(id, 1)
|
||
expect(dmg).toEqual({ min: 0, max: 0 })
|
||
|
||
const res = executeSkill(id, { caster: createPlayer(), skillLevel: 1 })
|
||
expect(res.kind).toBe('melee')
|
||
if (res.kind === 'melee') {
|
||
expect(res.skillName).toBe('Unknown')
|
||
}
|
||
}
|
||
})
|
||
})
|
||
})
|