586 lines
24 KiB
TypeScript
586 lines
24 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
CANONICAL_SKILL_TREE,
|
|
CANONICAL_SKILL_PARAMS,
|
|
calculateSynergyMultiplier,
|
|
} from '../src/game/skill-tree.ts'
|
|
import type { SkillNode, SkillTreeState, CharacterClassCode } from '../src/game/skill-tree.ts'
|
|
|
|
/**
|
|
* Adversarial Stress Testing Suite for Diablo II (v1.13c) Synergy Engine.
|
|
*
|
|
* Focus areas:
|
|
* 1. All 69 synergy-receiving skills under extreme point allocations (0, 1, 10, 20 hard points).
|
|
* 2. Strict base-point invariant: equipment soft points (slvl) never bleed into synergy calculations.
|
|
* 3. Robust syntax resilience: unclosed parentheses (Fire Wall typo), case insensitivity, malformed formulas, and injection attacks.
|
|
* 4. Multi-synergy exact algebraic ground truth verification.
|
|
* 5. Differential fuzzing against an independent mathematical oracle.
|
|
*/
|
|
|
|
// Filter all canonical skills that receive synergies
|
|
const SYNERGY_SKILLS = CANONICAL_SKILL_TREE.filter(
|
|
s => s.synergyFormulas.physical || s.synergyFormulas.elemental || s.synergyFormulas.duration
|
|
)
|
|
|
|
/**
|
|
* Independent mathematical oracle for differential testing.
|
|
* Implements a separate, clean parsing and evaluation pipeline.
|
|
*/
|
|
function independentOracle(
|
|
tree: readonly SkillNode[],
|
|
state: SkillTreeState,
|
|
skillId: number,
|
|
damageType: 'physical' | 'elemental' | 'duration'
|
|
): number {
|
|
const node = tree.find(s => s.id === skillId)
|
|
if (!node) return 1.0
|
|
|
|
const formula = damageType === 'physical'
|
|
? node.synergyFormulas.physical
|
|
: damageType === 'duration'
|
|
? node.synergyFormulas.duration
|
|
: node.synergyFormulas.elemental
|
|
|
|
if (!formula || formula.trim() === '' || formula.trim() === '-') return 1.0
|
|
|
|
const nameMap = new Map<string, number>()
|
|
for (const s of tree) {
|
|
nameMap.set(s.name.trim().toLowerCase(), s.id)
|
|
}
|
|
|
|
// Parse skill references
|
|
let expr = formula.replace(/skill\(\s*['"]([^'"]+)['"]\s*\.blvl\s*\)/gi, (_, skillName) => {
|
|
const id = nameMap.get(skillName.trim().toLowerCase())
|
|
if (id === undefined) return '0'
|
|
const pts = Math.max(0, Math.min(20, state.hardPoints[id] ?? 0))
|
|
return String(pts)
|
|
})
|
|
|
|
// Parse parameters
|
|
const canon = CANONICAL_SKILL_PARAMS[node.id] || {}
|
|
const p8 = canon.par8 ?? 16
|
|
const p7 = canon.par7 ?? 8
|
|
const p6 = canon.par6 ?? 5
|
|
const p5 = canon.par5 ?? 0
|
|
|
|
expr = expr.replace(/\bpar8\b/gi, String(p8))
|
|
expr = expr.replace(/\bpar7\b/gi, String(p7))
|
|
expr = expr.replace(/\bpar6\b/gi, String(p6))
|
|
expr = expr.replace(/\bpar5\b/gi, String(p5))
|
|
|
|
// Balance parentheses
|
|
const opens = (expr.match(/\(/g) || []).length
|
|
const closes = (expr.match(/\)/g) || []).length
|
|
if (opens > closes) expr += ')'.repeat(opens - closes)
|
|
else if (closes > opens) expr = '('.repeat(closes - opens) + expr
|
|
|
|
if (!/^[-+0-9*/().\s]+$/.test(expr)) return 1.0
|
|
|
|
try {
|
|
const val = Function(`"use strict"; return (${expr})`)()
|
|
if (typeof val !== 'number' || !Number.isFinite(val) || val <= 0) return 1.0
|
|
return 1.0 + val / 100.0
|
|
} catch {
|
|
return 1.0
|
|
}
|
|
}
|
|
|
|
describe('Synergy Engine Adversarial Stress Testing (1.13c)', () => {
|
|
// ==========================================================================
|
|
// 1. All 69 Synergy-Receiving Skills Under Extreme Point Allocations
|
|
// ==========================================================================
|
|
describe('1. 69 Synergy Skills Under Extreme Allocations (0, 1, 10, 20 hard points)', () => {
|
|
it('verifies catalog contains exactly 69 synergy-receiving skills', () => {
|
|
expect(SYNERGY_SKILLS).toHaveLength(69)
|
|
const classBreakdown: Record<CharacterClassCode, number> = {
|
|
ama: 0, sor: 0, nec: 0, pal: 0, bar: 0, dru: 0, ass: 0
|
|
}
|
|
for (const s of SYNERGY_SKILLS) {
|
|
classBreakdown[s.classCode]++
|
|
}
|
|
expect(classBreakdown).toEqual({
|
|
ama: 13,
|
|
sor: 19,
|
|
nec: 6,
|
|
pal: 7,
|
|
bar: 2,
|
|
dru: 12,
|
|
ass: 10,
|
|
})
|
|
})
|
|
|
|
it('returns exactly 1.0 (0% bonus) for all 69 skills when all donor skills have 0 hard points', () => {
|
|
const state: SkillTreeState = { hardPoints: {}, unspentPoints: 100 }
|
|
|
|
for (const skill of SYNERGY_SKILLS) {
|
|
for (const type of ['physical', 'elemental', 'duration'] as const) {
|
|
if (!skill.synergyFormulas[type]) continue
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, skill.id, type)
|
|
expect(mult, `${skill.name} (${type}) with 0 points should be 1.0`).toBe(1.0)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('evaluates all 69 skills across 1, 10, and 20 hard points with monotonic growth', () => {
|
|
for (const skill of SYNERGY_SKILLS) {
|
|
for (const type of ['physical', 'elemental', 'duration'] as const) {
|
|
if (!skill.synergyFormulas[type]) continue
|
|
|
|
const mults: number[] = []
|
|
for (const pts of [0, 1, 10, 20]) {
|
|
const state: SkillTreeState = { hardPoints: {}, unspentPoints: 100 }
|
|
for (const donor of CANONICAL_SKILL_TREE) {
|
|
state.hardPoints[donor.id] = pts
|
|
}
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, skill.id, type)
|
|
const oracle = independentOracle(CANONICAL_SKILL_TREE, state, skill.id, type)
|
|
expect(mult, `${skill.name} (${type}) at ${pts} pts`).toBeCloseTo(oracle, 4)
|
|
mults.push(mult)
|
|
}
|
|
|
|
// Monotonic growth: 0 <= 1 <= 10 <= 20
|
|
expect(mults[0]).toBe(1.0)
|
|
expect(mults[1]).toBeGreaterThan(mults[0])
|
|
expect(mults[2]).toBeGreaterThan(mults[1])
|
|
expect(mults[3]).toBeGreaterThan(mults[2])
|
|
}
|
|
}
|
|
})
|
|
|
|
it('clamps hard points to [0..20] boundary even if state contains adversarial values', () => {
|
|
const stateOver: SkillTreeState = { hardPoints: {}, unspentPoints: 0 }
|
|
const state20: SkillTreeState = { hardPoints: {}, unspentPoints: 0 }
|
|
const stateNeg: SkillTreeState = { hardPoints: {}, unspentPoints: 0 }
|
|
|
|
for (const s of CANONICAL_SKILL_TREE) {
|
|
stateOver.hardPoints[s.id] = 99 // Over-invested
|
|
state20.hardPoints[s.id] = 20 // Legal maximum
|
|
stateNeg.hardPoints[s.id] = -10 // Negative invested
|
|
}
|
|
|
|
for (const skill of SYNERGY_SKILLS) {
|
|
for (const type of ['physical', 'elemental', 'duration'] as const) {
|
|
if (!skill.synergyFormulas[type]) continue
|
|
const multOver = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, stateOver, skill.id, type)
|
|
const mult20 = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state20, skill.id, type)
|
|
const multNeg = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, stateNeg, skill.id, type)
|
|
|
|
// 99 points must clamp to 20 points
|
|
expect(multOver, `${skill.name} (${type}) clamp 99->20`).toBe(mult20)
|
|
// Negative points must clamp to 0 points (1.0 multiplier)
|
|
expect(multNeg, `${skill.name} (${type}) clamp negative->0`).toBe(1.0)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 2. Base-Point Invariant & Equipment Soft-Point Isolation
|
|
// ==========================================================================
|
|
describe('2. Base-Point Invariant & Equipment Soft-Point Isolation', () => {
|
|
it('confirms equipment soft points (slvl) never alter synergy multiplier', () => {
|
|
// Setup Sorceress: Fire Bolt (36) with Fire Ball (47) = 5 hard points, Meteor (56) = 3 hard points
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 47: 5, 56: 3 },
|
|
unspentPoints: 10,
|
|
}
|
|
|
|
// Baseline: (5 + 3) * 16% = +128% => 2.28
|
|
const baseline = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental')
|
|
expect(baseline).toBeCloseTo(2.28, 4)
|
|
|
|
// Simulate character wearing Magefist (+1 Fire Skills), The Oculus (+3 Sorc Skills),
|
|
// Harlequin Crest (+2 All Skills), and 10 Fire Grand Charms (+10 Fire Skills) = +16 soft points.
|
|
// Soft points change effective level from 5 -> 21, but hard points MUST remain 5.
|
|
const stateWithGear: any = {
|
|
...state,
|
|
softPoints: { 47: 16, 56: 16 },
|
|
effectiveLevels: { 47: 21, 56: 19 },
|
|
}
|
|
|
|
const gearMult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, stateWithGear, 36, 'elemental')
|
|
expect(gearMult).toBe(baseline)
|
|
})
|
|
|
|
it('rejects adversarial formulas attempting to reference .slvl or .lvl', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 47: 20 },
|
|
unspentPoints: 0,
|
|
}
|
|
|
|
const maliciousCatalog: SkillNode[] = [
|
|
{
|
|
id: 9901,
|
|
name: 'Slvl Exploit Skill',
|
|
classCode: 'sor',
|
|
tabIndex: 0,
|
|
tabName: 'Fire Spells',
|
|
row: 1,
|
|
col: 1,
|
|
reqlevel: 1,
|
|
reqskills: [],
|
|
dependents: [],
|
|
synergyFormulas: {
|
|
elemental: "skill('Fire Ball'.slvl)*par8",
|
|
},
|
|
},
|
|
{
|
|
id: 9902,
|
|
name: 'Lvl Exploit Skill',
|
|
classCode: 'sor',
|
|
tabIndex: 0,
|
|
tabName: 'Fire Spells',
|
|
row: 1,
|
|
col: 1,
|
|
reqlevel: 1,
|
|
reqskills: [],
|
|
dependents: [],
|
|
synergyFormulas: {
|
|
elemental: "skill('Fire Ball'.lvl)*par8",
|
|
},
|
|
},
|
|
]
|
|
|
|
// Because the engine strictly matches .blvl, .slvl and .lvl are left un-substituted,
|
|
// fail the arithmetic regex, and safely evaluate to 1.0 (no bonus).
|
|
expect(calculateSynergyMultiplier(maliciousCatalog, state, 9901, 'elemental')).toBe(1.0)
|
|
expect(calculateSynergyMultiplier(maliciousCatalog, state, 9902, 'elemental')).toBe(1.0)
|
|
})
|
|
|
|
it('oskills and charges grant 0 synergy bonus to other skills', () => {
|
|
// Paladin with Holy Shield: Holy Shield receives synergy from Defiance (107).
|
|
// If a character has +3 to Defiance from an Exile runeword or CTA (oskills),
|
|
// hard points remain 0.
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 107: 0 }, // 0 hard points
|
|
unspentPoints: 10,
|
|
}
|
|
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 117, 'elemental')
|
|
expect(mult).toBe(1.0)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 3. Formula Syntax Resilience, Unclosed Parens & Adversarial Inputs
|
|
// ==========================================================================
|
|
describe('3. Formula Syntax Resilience, Unclosed Parens & Adversarial Inputs', () => {
|
|
it('correctly parses and balances Blizzard canonical unclosed Fire Wall formula', () => {
|
|
// Canonical Skills.txt row 51: "(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7"
|
|
// Missing closing parenthesis!
|
|
// Warmth par8 = 4, Inferno par7 = 1
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 37: 10, 41: 10 },
|
|
unspentPoints: 0,
|
|
}
|
|
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 51, 'elemental')
|
|
// (10 * 4 + 10 * 1) = +50% => 1.50
|
|
expect(mult).toBeCloseTo(1.50, 4)
|
|
|
|
// Test at 20 hard points: (20 * 4 + 20 * 1) = +100% => 2.00
|
|
state.hardPoints[37] = 20
|
|
state.hardPoints[41] = 20
|
|
expect(calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 51, 'elemental')).toBeCloseTo(2.00, 4)
|
|
})
|
|
|
|
it('handles multiple unclosed or unopened parentheses without throwing', () => {
|
|
const state: SkillTreeState = { hardPoints: { 37: 10, 41: 10 }, unspentPoints: 0 }
|
|
|
|
const variations = [
|
|
"((skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7",
|
|
"(((skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7",
|
|
"(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7))",
|
|
"(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7)))",
|
|
]
|
|
|
|
for (const formula of variations) {
|
|
const tree = CANONICAL_SKILL_TREE.map(s => s.id === 51 ? {
|
|
...s,
|
|
synergyFormulas: { ...s.synergyFormulas, elemental: formula },
|
|
} : s)
|
|
|
|
const mult = calculateSynergyMultiplier(tree, state, 51, 'elemental')
|
|
expect(mult, `Formula: ${formula}`).toBeCloseTo(1.50, 4)
|
|
}
|
|
})
|
|
|
|
it('is case-insensitive for skill names, parameters, and formula keywords', () => {
|
|
const state: SkillTreeState = { hardPoints: { 37: 10 }, unspentPoints: 0 }
|
|
|
|
const variations = [
|
|
"skill('warmth'.blvl)*par8",
|
|
"skill('WARMTH'.blvl)*par8",
|
|
"skill('wArMtH'.blvl)*par8",
|
|
"SKILL('Warmth'.BLVL)*PAR8",
|
|
"sKiLl('Warmth'.bLvL)*pAr8",
|
|
'skill("Warmth".blvl)*par8', // double quotes
|
|
"skill(' Warmth '.blvl)*par8", // spaces inside quotes
|
|
" skill('Warmth'.blvl) * par8 ", // spaces around operators
|
|
"\tskill('Warmth'.blvl)\n* par8\t", // tabs and newlines
|
|
]
|
|
|
|
for (const formula of variations) {
|
|
const tree = CANONICAL_SKILL_TREE.map(s => s.id === 51 ? {
|
|
...s,
|
|
synergyFormulas: { ...s.synergyFormulas, elemental: formula },
|
|
} : s)
|
|
|
|
const mult = calculateSynergyMultiplier(tree, state, 51, 'elemental')
|
|
// 10 * 4% = +40% => 1.40
|
|
expect(mult, `Variation: ${JSON.stringify(formula)}`).toBeCloseTo(1.40, 4)
|
|
}
|
|
})
|
|
|
|
it('documents edge case: intra-token whitespace "skill (" or ". blvl" falls back to 1.0', () => {
|
|
const state: SkillTreeState = { hardPoints: { 37: 10 }, unspentPoints: 0 }
|
|
const tree = CANONICAL_SKILL_TREE.map(s => s.id === 51 ? {
|
|
...s,
|
|
synergyFormulas: { ...s.synergyFormulas, elemental: " skill ( 'Warmth' . blvl ) * par8 " },
|
|
} : s)
|
|
|
|
// Regex requires skill\( and \.blvl, so space between function name and paren or around dot
|
|
// leaves token unparsed, which fails arithmetic validation and safely defaults to 1.0.
|
|
const mult = calculateSynergyMultiplier(tree, state, 51, 'elemental')
|
|
expect(mult).toBe(1.0)
|
|
})
|
|
|
|
it('safely handles nonexistent skills by defaulting their contribution to 0 points', () => {
|
|
const state: SkillTreeState = { hardPoints: { 37: 10 }, unspentPoints: 0 }
|
|
const formula = "skill('NonExistentSkill'.blvl)*par8 + skill('Warmth'.blvl)*par8"
|
|
|
|
const tree = CANONICAL_SKILL_TREE.map(s => s.id === 51 ? {
|
|
...s,
|
|
synergyFormulas: { ...s.synergyFormulas, elemental: formula },
|
|
} : s)
|
|
|
|
const mult = calculateSynergyMultiplier(tree, state, 51, 'elemental')
|
|
// NonExistentSkill is 0; Warmth is 10 * 4% = +40% => 1.40
|
|
expect(mult).toBeCloseTo(1.40, 4)
|
|
})
|
|
|
|
it('rejects malicious JavaScript injection payloads safely with 1.0 fallback', () => {
|
|
const state: SkillTreeState = { hardPoints: { 37: 10 }, unspentPoints: 0 }
|
|
const attacks = [
|
|
"alert('xss')",
|
|
"process.exit(1)",
|
|
"console.log(1)",
|
|
"this.constructor.constructor('return 100')()",
|
|
"globalThis",
|
|
"function(){return 50}()",
|
|
"__proto__",
|
|
"1; while(true){}",
|
|
]
|
|
|
|
for (const attack of attacks) {
|
|
const tree = CANONICAL_SKILL_TREE.map(s => s.id === 51 ? {
|
|
...s,
|
|
synergyFormulas: { ...s.synergyFormulas, elemental: attack },
|
|
} : s)
|
|
|
|
const mult = calculateSynergyMultiplier(tree, state, 51, 'elemental')
|
|
expect(mult, `Attack payload: ${attack}`).toBe(1.0)
|
|
}
|
|
})
|
|
|
|
it('handles empty, whitespace, and arithmetic syntax errors safely', () => {
|
|
const state: SkillTreeState = { hardPoints: { 37: 10 }, unspentPoints: 0 }
|
|
const malformed = [
|
|
"",
|
|
" ",
|
|
"-",
|
|
"+++",
|
|
"*** / ()",
|
|
"()",
|
|
"((()))",
|
|
"-100 * par8", // negative result
|
|
"0", // zero result
|
|
]
|
|
|
|
for (const formula of malformed) {
|
|
const tree = CANONICAL_SKILL_TREE.map(s => s.id === 51 ? {
|
|
...s,
|
|
synergyFormulas: { ...s.synergyFormulas, elemental: formula },
|
|
} : s)
|
|
|
|
const mult = calculateSynergyMultiplier(tree, state, 51, 'elemental')
|
|
expect(mult, `Malformed: ${formula}`).toBe(1.0)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 4. Multi-Synergy Calculations Against Exact 1.13c Ground Truth Formulas
|
|
// ==========================================================================
|
|
describe('4. Multi-Synergy Calculations Against Exact 1.13c Formulas', () => {
|
|
it('Paladin Blessed Hammer (112): Vigor (115) + Blessed Aim (108) @ +14% per point', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 115: 20, 108: 20 },
|
|
unspentPoints: 0,
|
|
}
|
|
// (20 + 20) * 14% = +560% => 6.60 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 112, 'elemental')
|
|
expect(mult).toBeCloseTo(6.60, 4)
|
|
})
|
|
|
|
it('Necromancer Bone Spear (84): Wall (78) + Prison (88) + Teeth (67) + Spirit (93) @ +7% per point', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 78: 20, 88: 20, 67: 20, 93: 20 },
|
|
unspentPoints: 0,
|
|
}
|
|
// (20 + 20 + 20 + 20) * 7% = +560% => 6.60 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 84, 'elemental')
|
|
expect(mult).toBeCloseTo(6.60, 4)
|
|
})
|
|
|
|
it('Necromancer Bone Spirit (93): Wall (78) + Prison (88) + Teeth (67) + Spear (84) @ +6% per point', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 78: 20, 88: 20, 67: 20, 84: 20 },
|
|
unspentPoints: 0,
|
|
}
|
|
// (20 + 20 + 20 + 20) * 6% = +480% => 5.80 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 93, 'elemental')
|
|
expect(mult).toBeCloseTo(5.80, 4)
|
|
})
|
|
|
|
it('Druid Molten Boulder (229): Dual physical (Volcano @ 10%) & elemental (Firestorm @ 8%) synergies', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 244: 20, 225: 20 }, // Volcano (244) = 20, Firestorm (225) = 20
|
|
unspentPoints: 0,
|
|
}
|
|
// Physical: 20 * 10% = +200% => 3.00 multiplier
|
|
const phys = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 229, 'physical')
|
|
expect(phys).toBeCloseTo(3.00, 4)
|
|
|
|
// Elemental: 20 * 8% = +160% => 2.60 multiplier
|
|
const elem = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 229, 'elemental')
|
|
expect(elem).toBeCloseTo(2.60, 4)
|
|
})
|
|
|
|
it('Druid Volcano (244): Dual physical (Molten Boulder @ 12%) & elemental (Eruption + Armageddon @ 12%) synergies', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 229: 20, 234: 20, 249: 20 }, // Molten Boulder (229), Eruption (234), Armageddon (249)
|
|
unspentPoints: 0,
|
|
}
|
|
// Physical: 20 * 12% = +240% => 3.40 multiplier
|
|
const phys = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 244, 'physical')
|
|
expect(phys).toBeCloseTo(3.40, 4)
|
|
|
|
// Elemental: (20 + 20) * 12% = +480% => 5.80 multiplier
|
|
const elem = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 244, 'elemental')
|
|
expect(elem).toBeCloseTo(5.80, 4)
|
|
})
|
|
|
|
it('Paladin Holy Fire (102), Holy Freeze (114), and Holy Shock (118) split-aura synergies', () => {
|
|
// Holy Fire: Resist Fire (100) * 18% + Salvation (125) * 6%
|
|
const stateFire: SkillTreeState = { hardPoints: { 100: 20, 125: 20 }, unspentPoints: 0 }
|
|
const multFire = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, stateFire, 102, 'elemental')
|
|
// 20 * 18 + 20 * 6 = 360 + 120 = +480% => 5.80
|
|
expect(multFire).toBeCloseTo(5.80, 4)
|
|
|
|
// Holy Freeze: Resist Cold (105) * 15% + Salvation (125) * 7%
|
|
const stateFreeze: SkillTreeState = { hardPoints: { 105: 20, 125: 20 }, unspentPoints: 0 }
|
|
const multFreeze = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, stateFreeze, 114, 'elemental')
|
|
// 20 * 15 + 20 * 7 = 300 + 140 = +440% => 5.40
|
|
expect(multFreeze).toBeCloseTo(5.40, 4)
|
|
|
|
// Holy Shock: Resist Lightning (110) * 12% + Salvation (125) * 4%
|
|
const stateShock: SkillTreeState = { hardPoints: { 110: 20, 125: 20 }, unspentPoints: 0 }
|
|
const multShock = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, stateShock, 118, 'elemental')
|
|
// 20 * 12 + 20 * 4 = 240 + 80 = +320% => 4.20
|
|
expect(multShock).toBeCloseTo(4.20, 4)
|
|
})
|
|
|
|
it('Assassin Fire Trauma (251) 6-way trap synergy @ +9% per point', () => {
|
|
// 6 donors: Shock Field (256), Death Sentry (276), Charged Bolt Sentry (261),
|
|
// Lightning Sentry (271), Wake of Fire Sentry (262), Inferno Sentry (272)
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 256: 20, 276: 20, 261: 20, 271: 20, 262: 20, 272: 20 },
|
|
unspentPoints: 0,
|
|
}
|
|
// 6 * 20 * 9% = 120 * 9% = +1080% => 11.80 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 251, 'elemental')
|
|
expect(mult).toBeCloseTo(11.80, 4)
|
|
})
|
|
|
|
it('Sorceress Ice Bolt (39) 5-way cold spell synergy @ +15% per point', () => {
|
|
// 5 donors: Frost Nova (44), Ice Blast (45), Glacial Spike (55), Blizzard (59), Frozen Orb (64)
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 44: 20, 45: 20, 55: 20, 59: 20, 64: 20 },
|
|
unspentPoints: 0,
|
|
}
|
|
// 5 * 20 * 15% = 100 * 15% = +1500% => 16.00 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 39, 'elemental')
|
|
expect(mult).toBeCloseTo(16.00, 4)
|
|
})
|
|
|
|
it('Barbarian War Cry (154) physical damage synergy (+6% per point of Howl/Taunt/Battle Cry)', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 130: 20, 137: 20, 146: 20 }, // Howl (130), Taunt (137), Battle Cry (146)
|
|
unspentPoints: 0,
|
|
}
|
|
// 3 * 20 * 6% = 60 * 6% = +360% => 4.60 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 154, 'physical')
|
|
expect(mult).toBeCloseTo(4.60, 4)
|
|
})
|
|
|
|
it('Druid Tornado (245) physical damage synergy (+9% per point of Cyclone Armor/Twister/Hurricane)', () => {
|
|
const state: SkillTreeState = {
|
|
hardPoints: { 235: 20, 240: 20, 250: 20 }, // Cyclone Armor (235), Twister (240), Hurricane (250)
|
|
unspentPoints: 0,
|
|
}
|
|
// 3 * 20 * 9% = 60 * 9% = +540% => 6.40 multiplier
|
|
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 245, 'physical')
|
|
expect(mult).toBeCloseTo(6.40, 4)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 5. Differential Fuzzing Against Independent Oracle (1,000 Iterations)
|
|
// ==========================================================================
|
|
describe('5. Differential Fuzzing Against Independent Oracle (1,000 Iterations)', () => {
|
|
it('passes 1,000 randomized allocation tests with zero divergence from oracle', () => {
|
|
// Deterministic PRNG seed
|
|
let seed = 123456789
|
|
function rng() {
|
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
|
return seed / 0x100000000
|
|
}
|
|
|
|
for (let i = 0; i < 1000; i++) {
|
|
// Pick random skill from the 69 synergy receivers
|
|
const skillIndex = Math.floor(rng() * SYNERGY_SKILLS.length)
|
|
const skill = SYNERGY_SKILLS[skillIndex]
|
|
|
|
// Pick an available damage type
|
|
const availableTypes = (['physical', 'elemental', 'duration'] as const).filter(
|
|
t => Boolean(skill.synergyFormulas[t])
|
|
)
|
|
const type = availableTypes[Math.floor(rng() * availableTypes.length)]
|
|
|
|
// Randomly assign hard points (0..20) across tree skills
|
|
const state: SkillTreeState = { hardPoints: {}, unspentPoints: 100 }
|
|
for (const s of CANONICAL_SKILL_TREE) {
|
|
// 40% chance of 0, 20% chance of 20, 40% chance of 1..19
|
|
const r = rng()
|
|
if (r < 0.4) {
|
|
state.hardPoints[s.id] = 0
|
|
} else if (r < 0.6) {
|
|
state.hardPoints[s.id] = 20
|
|
} else {
|
|
state.hardPoints[s.id] = Math.floor(rng() * 20) + 1
|
|
}
|
|
}
|
|
|
|
const actual = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, skill.id, type)
|
|
const expected = independentOracle(CANONICAL_SKILL_TREE, state, skill.id, type)
|
|
|
|
expect(
|
|
Math.abs(actual - expected),
|
|
`Fuzz iteration ${i} failed for ${skill.name} (${type})`
|
|
).toBeLessThanOrEqual(1e-4)
|
|
}
|
|
})
|
|
})
|
|
})
|