diablo2-web/tests/skill-tree.test.ts

596 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest'
import * as fs from 'fs'
import {
CANONICAL_SKILL_TREE,
CANONICAL_SKILL_MAP,
CANONICAL_SKILL_TABS,
CANONICAL_SKILL_PARAMS,
asCatalogMap,
getSkillNode,
getSkillsForClass,
getSkillsForTab,
createSkillTreeState,
canAllocate,
allocatePoint,
canDeallocate,
deallocatePoint,
respec,
calculateSynergyMultiplier,
validatePrerequisiteDAG,
parseSkillTreeTable,
} from '../src/game/skill-tree.ts'
import type { SkillNode, SkillTreeState, CharacterClassCode } from '../src/game/skill-tree.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables } from '../src/game/acts.ts'
const hasRealData = fs.existsSync('samples/d2/d2data.mpq') && fs.existsSync('samples/d2/Patch_D2.mpq')
describe('Diablo II 210-Skill Tree Catalog, Prerequisites DAG, and Synergies (Milestone M2)', () => {
// ==========================================================================
// 1. 210-Skill Tree Catalog Completeness & Grid Layout
// ==========================================================================
describe('210-Skill Tree Catalog Completeness & Grid Layout', () => {
it('contains exactly 210 canonical skills in CANONICAL_SKILL_TREE', () => {
expect(CANONICAL_SKILL_TREE).toHaveLength(210)
expect(CANONICAL_SKILL_MAP.size).toBe(210)
})
it('maps exactly 30 skills per class across all 7 canonical classes', () => {
const classes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
const counts: Record<string, number> = {}
for (const skill of CANONICAL_SKILL_TREE) {
counts[skill.classCode] = (counts[skill.classCode] || 0) + 1
}
for (const code of classes) {
expect(counts[code]).toBe(30)
const classSkills = getSkillsForClass(CANONICAL_SKILL_TREE, code)
expect(classSkills).toHaveLength(30)
}
})
it('maps exactly 10 skills per tab across all 21 tabs (7 classes × 3 tabs)', () => {
const classes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
for (const code of classes) {
for (let tab = 0; tab < 3; tab++) {
const tabSkills = getSkillsForTab(CANONICAL_SKILL_TREE, code, tab)
expect(tabSkills).toHaveLength(10)
const expectedTabName = CANONICAL_SKILL_TABS[code][tab]
for (const s of tabSkills) {
expect(s.tabIndex).toBe(tab)
expect(s.tabName).toBe(expectedTabName)
expect(s.classCode).toBe(code)
}
}
}
})
it('conforms to canonical Blizzard skill ID blocks for each class', () => {
const classIdRanges: Record<CharacterClassCode, [number, number]> = {
ama: [6, 35],
sor: [36, 65],
nec: [66, 95],
pal: [96, 125],
bar: [126, 155],
dru: [221, 250],
ass: [251, 280],
}
for (const [code, [minId, maxId]] of Object.entries(classIdRanges)) {
const skills = getSkillsForClass(CANONICAL_SKILL_TREE, code as CharacterClassCode)
const ids = skills.map(s => s.id)
expect(Math.min(...ids)).toBe(minId)
expect(Math.max(...ids)).toBe(maxId)
expect(ids).toHaveLength(maxId - minId + 1)
}
})
it('enforces valid UI grid placement (row 1..6, col 1..3) and valid reqlevel', () => {
const validReqLevels = new Set([1, 6, 12, 18, 24, 30])
for (const skill of CANONICAL_SKILL_TREE) {
expect(skill.row).toBeGreaterThanOrEqual(1)
expect(skill.row).toBeLessThanOrEqual(6)
expect(skill.col).toBeGreaterThanOrEqual(1)
expect(skill.col).toBeLessThanOrEqual(3)
expect(validReqLevels.has(skill.reqlevel)).toBe(true)
// Row and reqlevel correspondence
if (skill.row === 1) expect([1, 6]).toContain(skill.reqlevel)
if (skill.row === 2) expect([6, 12]).toContain(skill.reqlevel)
if (skill.row === 3) expect([12, 18]).toContain(skill.reqlevel)
if (skill.row === 4) expect([18, 24]).toContain(skill.reqlevel)
if (skill.row === 5) expect([24, 30]).toContain(skill.reqlevel)
if (skill.row === 6) expect(skill.reqlevel).toBe(30)
}
})
it('verifies helper lookup functions getSkillNode and asCatalogMap', () => {
const node36 = getSkillNode(CANONICAL_SKILL_TREE, 36)
expect(node36).toBeDefined()
expect(node36?.name).toBe('Fire Bolt')
expect(node36?.classCode).toBe('sor')
const nodeUnknown = getSkillNode(CANONICAL_SKILL_TREE, 9999)
expect(nodeUnknown).toBeUndefined()
// Record catalog lookup
const recordCatalog: Record<number, SkillNode> = { 36: node36! }
expect(getSkillNode(recordCatalog, 36)?.name).toBe('Fire Bolt')
// Map catalog lookup
const map = asCatalogMap(recordCatalog)
expect(map.get(36)?.name).toBe('Fire Bolt')
})
})
// ==========================================================================
// 2. Prerequisite DAG Validation (0 Cycles, 0 Level Inversions)
// ==========================================================================
describe('Prerequisite DAG Validation (0 Cycles, 0 Level Inversions)', () => {
it('passes automated prerequisite DAG validation with 0 cycles, 0 inversions, 0 unresolved', () => {
const report = validatePrerequisiteDAG(CANONICAL_SKILL_TREE)
expect(report.valid).toBe(true)
expect(report.cycles).toHaveLength(0)
expect(report.inversions).toHaveLength(0)
expect(report.unresolved).toHaveLength(0)
})
it('proves strictly acyclic graph via topological sorting over all 210 nodes', () => {
const inDegree = new Map<number, number>()
const adj = new Map<number, number[]>()
for (const node of CANONICAL_SKILL_TREE) {
inDegree.set(node.id, node.reqskills.length)
adj.set(node.id, [])
}
for (const node of CANONICAL_SKILL_TREE) {
for (const reqId of node.reqskills) {
adj.get(reqId)!.push(node.id)
}
}
const queue: number[] = []
for (const [id, deg] of inDegree.entries()) {
if (deg === 0) queue.push(id)
}
let count = 0
while (queue.length > 0) {
const u = queue.shift()!
count++
for (const v of adj.get(u)!) {
const d = inDegree.get(v)! - 1
inDegree.set(v, d)
if (d === 0) queue.push(v)
}
}
expect(count).toBe(210) // All 210 nodes processed in topological order
})
it('verifies 0 level inversions across all 172 prerequisite edges', () => {
let edgeCount = 0
for (const node of CANONICAL_SKILL_TREE) {
for (const reqId of node.reqskills) {
edgeCount++
const parent = CANONICAL_SKILL_MAP.get(reqId)
expect(parent).toBeDefined()
if (parent) {
expect(parent.reqlevel).toBeLessThanOrEqual(node.reqlevel)
expect(parent.classCode).toBe(node.classCode)
expect(parent.tabIndex).toBe(node.tabIndex)
}
}
}
expect(edgeCount).toBe(172)
})
it('verifies exact reverse dependent mapping consistency for all skills', () => {
for (const node of CANONICAL_SKILL_TREE) {
// Every skill in node.reqskills must have node.id in its dependents
for (const reqId of node.reqskills) {
const parent = CANONICAL_SKILL_MAP.get(reqId)!
expect(parent.dependents).toContain(node.id)
}
// Every skill in node.dependents must have node.id in its reqskills
for (const depId of node.dependents) {
const child = CANONICAL_SKILL_MAP.get(depId)!
expect(child.reqskills).toContain(node.id)
}
}
})
it('validates canonical multi-prerequisite convergence skills', () => {
// Exploding Arrow (16) requires Fire Arrow (7) and Multiple Shot (12)
const explArrow = CANONICAL_SKILL_MAP.get(16)!
expect(explArrow.reqskills).toEqual([7, 12])
// Meteor (56) requires Fire Ball (47) and Fire Wall (51)
const meteor = CANONICAL_SKILL_MAP.get(56)!
expect(meteor.reqskills).toEqual([47, 51])
// Armageddon (249) requires Volcano (244) and Hurricane (250)
const armageddon = CANONICAL_SKILL_MAP.get(249)!
expect(armageddon.reqskills).toEqual([244, 250])
// Lower Resist (91) requires Life Tap (82) and Decrepify (87)
const lowerResist = CANONICAL_SKILL_MAP.get(91)!
expect(lowerResist.reqskills).toEqual([82, 87])
// Whirlwind (151) requires Leap Attack (143) and Concentrate (144)
const ww = CANONICAL_SKILL_MAP.get(151)!
expect(ww.reqskills).toEqual([143, 144])
})
it('validates canonical same-row prerequisite skills', () => {
// Skeleton Mastery (ID 69, row 1, reqlevel 1) requires Raise Skeleton (ID 70, row 1, reqlevel 1)
const skelMastery = CANONICAL_SKILL_MAP.get(69)!
expect(skelMastery.reqlevel).toBe(1)
expect(skelMastery.reqskills).toEqual([70])
// Lycanthropy / Shape Shifting (ID 224, row 1, reqlevel 1) requires Werewolf (ID 223, row 1, reqlevel 1)
const lycanthropy = CANONICAL_SKILL_MAP.get(224)!
expect(lycanthropy.reqlevel).toBe(1)
expect(lycanthropy.reqskills).toEqual([223])
})
})
// ==========================================================================
// 3. Point Allocation State Management
// ==========================================================================
describe('Point Allocation State Management', () => {
it('creates skill tree state with specified unspent points', () => {
const state0 = createSkillTreeState()
expect(state0.unspentPoints).toBe(0)
expect(state0.hardPoints).toEqual({})
const state10 = createSkillTreeState(10)
expect(state10.unspentPoints).toBe(10)
})
it('blocks allocation when unspent points pool is zero', () => {
const state = createSkillTreeState(0)
expect(canAllocate(CANONICAL_SKILL_TREE, state, 36, 1)).toBe(false)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 36, 1)).toBe(false)
expect(state.hardPoints[36]).toBeUndefined()
})
it('blocks allocation when player level < reqlevel', () => {
const state = createSkillTreeState(10)
// Fire Ball requires level 12
expect(canAllocate(CANONICAL_SKILL_TREE, state, 47, 11)).toBe(false)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 47, 11)).toBe(false)
})
it('blocks allocation when direct prerequisites have 0 points', () => {
const state = createSkillTreeState(10)
// Level 20 character, but Fire Bolt (prereq) has 0 points
expect(canAllocate(CANONICAL_SKILL_TREE, state, 47, 20)).toBe(false)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 47, 20)).toBe(false)
// Allocate Fire Bolt
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 36, 20)).toBe(true)
expect(state.hardPoints[36]).toBe(1)
expect(state.unspentPoints).toBe(9)
// Now Fire Ball can be allocated
expect(canAllocate(CANONICAL_SKILL_TREE, state, 47, 20)).toBe(true)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 47, 20)).toBe(true)
expect(state.hardPoints[47]).toBe(1)
expect(state.unspentPoints).toBe(8)
})
it('enforces strict 20 hard points cap per skill', () => {
const state = createSkillTreeState(50)
for (let i = 0; i < 20; i++) {
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 36, 1)).toBe(true)
}
expect(state.hardPoints[36]).toBe(20)
expect(state.unspentPoints).toBe(30)
// 21st point allocation must be rejected
expect(canAllocate(CANONICAL_SKILL_TREE, state, 36, 1)).toBe(false)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 36, 1)).toBe(false)
expect(state.hardPoints[36]).toBe(20)
expect(state.unspentPoints).toBe(30)
})
it('handles same-row prerequisite allocation flow (Necromancer Skeleton Mastery)', () => {
const state = createSkillTreeState(5)
// Level 1 Necromancer cannot learn Skeleton Mastery without Raise Skeleton
expect(canAllocate(CANONICAL_SKILL_TREE, state, 69, 1)).toBe(false)
// Allocate Raise Skeleton (ID 70)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 70, 1)).toBe(true)
// Now Skeleton Mastery (ID 69) is available
expect(canAllocate(CANONICAL_SKILL_TREE, state, 69, 1)).toBe(true)
expect(allocatePoint(CANONICAL_SKILL_TREE, state, 69, 1)).toBe(true)
expect(state.hardPoints[69]).toBe(1)
expect(state.hardPoints[70]).toBe(1)
})
})
// ==========================================================================
// 4. Point Deallocation & Respec Mechanics
// ==========================================================================
describe('Point Deallocation & Respec Mechanics', () => {
it('cannot deallocate skill with 0 invested points', () => {
const state = createSkillTreeState(5)
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(false)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 36)).toBe(false)
})
it('can deallocate when skill has > 1 points even with allocated dependents', () => {
const state = createSkillTreeState(10)
allocatePoint(CANONICAL_SKILL_TREE, state, 36, 12) // Fire Bolt point 1
allocatePoint(CANONICAL_SKILL_TREE, state, 36, 12) // Fire Bolt point 2
allocatePoint(CANONICAL_SKILL_TREE, state, 47, 12) // Fire Ball point 1 (dependent)
// Fire Bolt has 2 points: dropping to 1 is allowed
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 36)).toBe(true)
expect(state.hardPoints[36]).toBe(1)
expect(state.unspentPoints).toBe(8)
})
it('blocks deallocating 1 to 0 points when a direct dependent has invested points', () => {
const state = createSkillTreeState(10)
allocatePoint(CANONICAL_SKILL_TREE, state, 36, 12) // Fire Bolt point 1
allocatePoint(CANONICAL_SKILL_TREE, state, 47, 12) // Fire Ball point 1
// Fire Bolt has 1 point: dropping to 0 must be blocked
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(false)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 36)).toBe(false)
expect(state.hardPoints[36]).toBe(1)
// Deallocate Fire Ball first
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 47)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 47)).toBe(true)
expect(state.hardPoints[47]).toBe(0)
// Now Fire Bolt can be deallocated
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 36)).toBe(true)
expect(state.hardPoints[36]).toBe(0)
})
it('blocks deallocating 1 to 0 points when a deep transitive dependent has invested points', () => {
const state = createSkillTreeState(10)
// Chain: Ice Bolt (39) -> Ice Blast (45) -> Glacial Spike (55) & Frost Nova (44) -> Blizzard (59)
allocatePoint(CANONICAL_SKILL_TREE, state, 39, 24)
allocatePoint(CANONICAL_SKILL_TREE, state, 45, 24)
allocatePoint(CANONICAL_SKILL_TREE, state, 44, 24)
allocatePoint(CANONICAL_SKILL_TREE, state, 55, 24)
allocatePoint(CANONICAL_SKILL_TREE, state, 59, 24) // Deep dependent
// Ice Bolt cannot be deallocated while Blizzard is active
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 39)).toBe(false)
// Ice Blast cannot be deallocated while Blizzard is active
expect(canDeallocate(CANONICAL_SKILL_TREE, state, 45)).toBe(false)
// Deallocate Blizzard, then Glacial Spike, then Ice Blast, then Frost Nova, then Ice Bolt
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 59)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 55)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 45)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 44)).toBe(true)
expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 39)).toBe(true)
expect(state.hardPoints[39]).toBe(0)
expect(state.hardPoints[45]).toBe(0)
expect(state.hardPoints[55]).toBe(0)
expect(state.hardPoints[59]).toBe(0)
})
it('respec refunds 100% invested points and resets all skills to 0', () => {
const state = createSkillTreeState(20)
allocatePoint(CANONICAL_SKILL_TREE, state, 96, 12) // Sacrifice: 1
allocatePoint(CANONICAL_SKILL_TREE, state, 106, 12) // Zeal: 1
allocatePoint(CANONICAL_SKILL_TREE, state, 106, 12) // Zeal: 2
allocatePoint(CANONICAL_SKILL_TREE, state, 98, 12) // Might: 1
allocatePoint(CANONICAL_SKILL_TREE, state, 102, 12) // Holy Fire: 1
expect(state.unspentPoints).toBe(15)
const refunded = respec(state)
expect(refunded).toBe(5)
expect(state.unspentPoints).toBe(20)
expect(state.hardPoints[96]).toBe(0)
expect(state.hardPoints[106]).toBe(0)
expect(state.hardPoints[98]).toBe(0)
expect(state.hardPoints[102]).toBe(0)
// Downstream skills are now re-locked
expect(canAllocate(CANONICAL_SKILL_TREE, state, 106, 30)).toBe(false) // Zeal requires Sacrifice
expect(canAllocate(CANONICAL_SKILL_TREE, state, 102, 30)).toBe(false) // Holy Fire requires Might
expect(canAllocate(CANONICAL_SKILL_TREE, state, 96, 30)).toBe(true) // Root skill is eligible
})
})
// ==========================================================================
// 5. 1.13c Synergy Engine & Base Points Invariant
// ==========================================================================
describe('1.13c Synergy Engine & Base Points Invariant', () => {
it('proves synergy multiplier strictly scales with hard points (blvl), not gear soft points', () => {
const state = createSkillTreeState(50)
// Sorceress: Fire Bolt (ID 36)
// Synergy formula: (skill('Fire Ball'.blvl)+skill('Meteor'.blvl))*par8
// par8 for Fire Bolt = 16% per hard point
state.hardPoints[47] = 5 // Fire Ball: 5 base points
state.hardPoints[56] = 3 // Meteor: 3 base points
// Baseline synergy multiplier: (5 + 3) * 16% = +128% => 2.28
const baseMult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental')
expect(baseMult).toBeCloseTo(2.28, 2)
// Simulate character equipping "+5 to All Skills" or "+3 Fire Skills" gear:
// In D2 1.13c, gear adds to effective level (slvl), NEVER to blvl.
// Therefore, state.hardPoints remains 5 and 3.
const gearMult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental')
expect(gearMult).toBe(baseMult)
})
it('evaluates Paladin Blessed Hammer synergies (+14% per hard point of Vigor and Blessed Aim)', () => {
const state = createSkillTreeState(50)
// Blessed Hammer (ID 112)
// Formula: (skill('Vigor'.blvl)+skill('Blessed Aim'.blvl))*par8
// par8 = 14
state.hardPoints[115] = 10 // Vigor: 10 hard points
state.hardPoints[108] = 5 // Blessed Aim: 5 hard points
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 112, 'elemental')
// (10 + 5) * 14% = +210% => 3.10 multiplier
expect(mult).toBeCloseTo(3.10, 2)
})
it('evaluates Necromancer Bone Spear synergies (+7% per hard point of Wall, Prison, Teeth, Spirit)', () => {
const state = createSkillTreeState(50)
// Bone Spear (ID 84)
// par8 = 7
state.hardPoints[78] = 10 // Bone Wall
state.hardPoints[88] = 10 // Bone Prison
state.hardPoints[67] = 10 // Teeth
state.hardPoints[93] = 10 // Bone Spirit
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 84, 'elemental')
// (10 + 10 + 10 + 10) * 7% = +280% => 3.80 multiplier
expect(mult).toBeCloseTo(3.80, 2)
})
it('tolerates Blizzard unclosed parenthesis in Fire Wall formula without crashing', () => {
const state = createSkillTreeState(50)
// Fire Wall (ID 51) has EDmgSymPerCalc:
// "(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7"
// Note the unclosed open parenthesis in canonical Skills.txt!
// par8 = 4 (Warmth), par7 = 1 (Inferno)
state.hardPoints[37] = 10 // Warmth: 10 hard points
state.hardPoints[41] = 10 // Inferno: 10 hard points
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 51, 'elemental')
// (10 * 4 + 10 * 1) = +50% => 1.50 multiplier
expect(mult).toBeCloseTo(1.50, 2)
})
it('evaluates dual physical and elemental synergies (Druid Molten Boulder & Volcano)', () => {
const state = createSkillTreeState(50)
// Molten Boulder (ID 229)
// Physical: skill('Volcano'.blvl)*par7 (par7 = 10)
// Elemental: skill('Firestorm'.blvl)*par8 (par8 = 8)
state.hardPoints[244] = 6 // Volcano: 6 hard points
state.hardPoints[225] = 5 // Firestorm: 5 hard points
const physMult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 229, 'physical')
// 6 * 10% = +60% => 1.60 multiplier
expect(physMult).toBeCloseTo(1.60, 2)
const elemMult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 229, 'elemental')
// 5 * 8% = +40% => 1.40 multiplier
expect(elemMult).toBeCloseTo(1.40, 2)
})
it('evaluates Barbarian War Cry physical damage synergy (+6% per hard point of Howl/Taunt/Battle Cry)', () => {
const state = createSkillTreeState(50)
// War Cry (ID 154): physical synergy
state.hardPoints[130] = 5 // Howl
state.hardPoints[137] = 5 // Taunt
state.hardPoints[146] = 5 // Battle Cry
const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 154, 'physical')
// (5 + 5 + 5) * 6% = +90% => 1.90 multiplier
expect(mult).toBeCloseTo(1.90, 2)
})
it('returns 1.0 (no bonus) for skills with no synergy formulas or 0 hard points invested', () => {
const state = createSkillTreeState(10)
// Teleport (ID 54): no synergies
expect(calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 54, 'elemental')).toBe(1.0)
expect(calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 54, 'physical')).toBe(1.0)
// Fire Bolt (ID 36) with 0 points in Fire Ball / Meteor
expect(calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental')).toBe(1.0)
})
it('gracefully handles malformed or adversarial formulas by returning 1.0 without crashing', () => {
const state = createSkillTreeState(10)
state.hardPoints[47] = 5
const customCatalog: SkillNode[] = [
{
id: 9991,
name: 'Invalid Syntax Skill',
classCode: 'sor',
tabIndex: 0,
tabName: 'Fire Spells',
row: 1,
col: 1,
reqlevel: 1,
reqskills: [],
dependents: [],
synergyFormulas: {
elemental: 'invalid +++ syntax // 123',
},
},
{
id: 9992,
name: 'Negative Result Skill',
classCode: 'sor',
tabIndex: 0,
tabName: 'Fire Spells',
row: 1,
col: 1,
reqlevel: 1,
reqskills: [],
dependents: [],
synergyFormulas: {
elemental: '-50 * par8',
},
},
]
expect(calculateSynergyMultiplier(customCatalog, state, 9991, 'elemental')).toBe(1.0)
expect(calculateSynergyMultiplier(customCatalog, state, 9992, 'elemental')).toBe(1.0)
expect(calculateSynergyMultiplier(customCatalog, state, 99999, 'elemental')).toBe(1.0)
})
})
// ==========================================================================
// 6. Dual-Track MPQ Data Parity Verification (when real MPQ files exist)
// ==========================================================================
describe('Dual-Track MPQ Data Parity Verification', () => {
it.runIf(hasRealData)('verifies parseSkillTreeTable reproduces CANONICAL_SKILL_TREE from MPQ tables', async () => {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
const tables = await loadActTables(archives)
expect(tables.skills).toBeDefined()
expect(tables.skilldesc).toBeDefined()
const parsedNodes = parseSkillTreeTable(tables.skills!, tables.skilldesc!)
expect(parsedNodes).toHaveLength(210)
const parsedMap = new Map(parsedNodes.map(n => [n.id, n]))
for (const canonical of CANONICAL_SKILL_TREE) {
const parsed = parsedMap.get(canonical.id)
expect(parsed).toBeDefined()
expect(parsed?.name).toBe(canonical.name)
expect(parsed?.classCode).toBe(canonical.classCode)
expect(parsed?.tabIndex).toBe(canonical.tabIndex)
expect(parsed?.row).toBe(canonical.row)
expect(parsed?.col).toBe(canonical.col)
expect(parsed?.reqlevel).toBe(canonical.reqlevel)
expect(parsed?.reqskills).toEqual(canonical.reqskills)
expect(parsed?.dependents).toEqual(canonical.dependents)
}
})
})
})