import { describe, it, expect } from 'vitest' 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, } from '../src/game/skill-tree.ts' import type { SkillNode, SkillTreeState, CharacterClassCode } from '../src/game/skill-tree.ts' // ============================================================================ // Helper Utilities for Graph Traversal & Invariant Verification // ============================================================================ /** Computes the full set of transitive prerequisite ancestor IDs for a skill. */ function getTransitivePrerequisites(skillId: number): Set { const ancestors = new Set() const queue = [...(CANONICAL_SKILL_MAP.get(skillId)?.reqskills ?? [])] while (queue.length > 0) { const id = queue.shift()! if (ancestors.has(id)) continue ancestors.add(id) const node = CANONICAL_SKILL_MAP.get(id) if (node) { queue.push(...node.reqskills) } } return ancestors } /** Computes the full set of transitive dependent descendant IDs for a skill. */ function getTransitiveDependents(skillId: number): Set { const descendants = new Set() const queue = [...(CANONICAL_SKILL_MAP.get(skillId)?.dependents ?? [])] while (queue.length > 0) { const id = queue.shift()! if (descendants.has(id)) continue descendants.add(id) const node = CANONICAL_SKILL_MAP.get(id) if (node) { queue.push(...node.dependents) } } return descendants } /** Computes the maximum depth of a skill in the prerequisite DAG (1 = root skill). */ function getSkillDepth(skillId: number): number { const node = CANONICAL_SKILL_MAP.get(skillId) if (!node || node.reqskills.length === 0) return 1 return 1 + Math.max(...node.reqskills.map(getSkillDepth)) } /** Simple pseudo-random number generator with seed for deterministic fuzzing. */ function createPrng(seed: number) { let s = seed return () => { s = (s * 1664525 + 1013904223) % 4294967296 return s / 4294967296 } } /** Asserts all fundamental SkillTreeState invariants hold strictly. */ function assertStateInvariants( state: SkillTreeState, initialOrGrantedPoints: number, playerLevel: number ) { // 1. Unspent points must never underflow expect(state.unspentPoints).toBeGreaterThanOrEqual(0) let totalHardPoints = 0 for (const [key, pts] of Object.entries(state.hardPoints)) { const skillId = Number(key) expect(Number.isInteger(skillId)).toBe(true) // 2. Hard points per skill must be in 0..20 expect(pts).toBeGreaterThanOrEqual(0) expect(pts).toBeLessThanOrEqual(20) if (pts > 0) { totalHardPoints += pts const node = CANONICAL_SKILL_MAP.get(skillId) expect(node).toBeDefined() if (node) { // 3. Prerequisite Invariant: all direct prerequisites must have >= 1 point for (const reqId of node.reqskills) { const reqPts = state.hardPoints[reqId] ?? 0 expect(reqPts).toBeGreaterThanOrEqual(1) } // 4. Transitive Prerequisite Invariant: all ancestors must have >= 1 point const ancestors = getTransitivePrerequisites(skillId) for (const ancId of ancestors) { const ancPts = state.hardPoints[ancId] ?? 0 expect(ancPts).toBeGreaterThanOrEqual(1) } } } } // 5. Total point conservation expect(totalHardPoints + state.unspentPoints).toBe(initialOrGrantedPoints) } // ============================================================================ // Adversarial Stress Testing Suite // ============================================================================ describe('Adversarial Stress Testing: Skill Tree DAG & State Management (Milestone M2)', () => { // ========================================================================== // Track 1: Exhaustive DAG Architecture & Bidirectional Invariants (210 Skills) // ========================================================================== describe('Track 1: Exhaustive DAG Architecture & Bidirectional Invariants', () => { it('verifies exact bidirectional symmetry between reqskills and dependents across all 210 skills', () => { let forwardEdgeCount = 0 let backwardEdgeCount = 0 for (const node of CANONICAL_SKILL_TREE) { forwardEdgeCount += node.reqskills.length backwardEdgeCount += node.dependents.length // Forward check: for every prerequisite P of node N, N must be in P.dependents for (const reqId of node.reqskills) { const parent = CANONICAL_SKILL_MAP.get(reqId) expect(parent).toBeDefined() expect(parent!.dependents).toContain(node.id) } // Backward check: for every dependent D of node N, N must be in D.reqskills for (const depId of node.dependents) { const child = CANONICAL_SKILL_MAP.get(depId) expect(child).toBeDefined() expect(child!.reqskills).toContain(node.id) } } // Total directed edges in 1.13c skills DAG is exactly 172 expect(forwardEdgeCount).toBe(172) expect(backwardEdgeCount).toBe(172) }) it('proves strict class and tab isolation for all prerequisite relationships', () => { for (const node of CANONICAL_SKILL_TREE) { for (const reqId of node.reqskills) { const parent = CANONICAL_SKILL_MAP.get(reqId)! expect(parent.classCode).toBe(node.classCode) expect(parent.tabIndex).toBe(node.tabIndex) expect(parent.tabName).toBe(node.tabName) } } }) it('proves strict level monotonicity across all 172 edges (0 level inversions)', () => { for (const node of CANONICAL_SKILL_TREE) { for (const reqId of node.reqskills) { const parent = CANONICAL_SKILL_MAP.get(reqId)! expect(parent.reqlevel).toBeLessThanOrEqual(node.reqlevel) } } }) it('proves transitive closure disjointness (ancestors ∩ descendants = ∅) and 0 self-loops for all 210 skills', () => { for (const node of CANONICAL_SKILL_TREE) { const ancestors = getTransitivePrerequisites(node.id) const descendants = getTransitiveDependents(node.id) // No self dependency expect(ancestors.has(node.id)).toBe(false) expect(descendants.has(node.id)).toBe(false) // Disjoint sets (guarantees acyclicity for any graph path) for (const anc of ancestors) { expect(descendants.has(anc)).toBe(false) } } }) it('audits topological sort and depth distribution across all 210 skills', () => { const depthCounts: Record = {} for (const node of CANONICAL_SKILL_TREE) { const depth = getSkillDepth(node.id) depthCounts[depth] = (depthCounts[depth] || 0) + 1 } // In Diablo II 1.13c: // Depth 1 (Roots): skills with no prerequisites // Max Depth is 6 (Druid Armageddon) expect(depthCounts[1]).toBeGreaterThanOrEqual(50) expect(depthCounts[6]).toBe(1) // Exactly Armageddon (249) expect(depthCounts[5]).toBe(6) // Frozen Orb, Lower Resist, Fist of the Heavens, Conviction, Hurricane, Death Sentry }) }) // ========================================================================== // Track 2: Randomized Allocation & Deallocation Fuzzing Harness // ========================================================================== describe('Track 2: Randomized Allocation & Deallocation Fuzzing Harness', () => { it('executes 2,500 randomized operations across multiple seeds with 0 invariant violations', () => { const seeds = [42, 1337, 98765, 55555, 314159] for (const seed of seeds) { const rand = createPrng(seed) const initialPoints = 50 + Math.floor(rand() * 50) let totalGranted = initialPoints const state = createSkillTreeState(initialPoints) let playerLevel = 30 + Math.floor(rand() * 70) // Level 30..99 for (let op = 0; op < 500; op++) { const actionRoll = rand() if (actionRoll < 0.50) { // Action 1: ALLOCATE // Bias: 80% valid skills, 10% invalid IDs, 10% random class focus let skillId: number if (rand() < 0.10) { skillId = [-999, 0, 9999, 500][Math.floor(rand() * 4)] } else { const idx = Math.floor(rand() * CANONICAL_SKILL_TREE.length) skillId = CANONICAL_SKILL_TREE[idx].id } const can = canAllocate(CANONICAL_SKILL_TREE, state, skillId, playerLevel) const ptsBefore = state.hardPoints[skillId] ?? 0 const unspentBefore = state.unspentPoints const success = allocatePoint(CANONICAL_SKILL_TREE, state, skillId, playerLevel) expect(success).toBe(can) if (success) { expect(state.hardPoints[skillId]).toBe(ptsBefore + 1) expect(state.unspentPoints).toBe(unspentBefore - 1) } else { expect(state.hardPoints[skillId] ?? 0).toBe(ptsBefore) expect(state.unspentPoints).toBe(unspentBefore) } } else if (actionRoll < 0.85) { // Action 2: DEALLOCATE // Bias: 60% pick from already allocated skills, 40% pick random skill const allocatedKeys = Object.entries(state.hardPoints) .filter(([_, pts]) => pts > 0) .map(([id, _]) => Number(id)) let skillId: number if (allocatedKeys.length > 0 && rand() < 0.60) { skillId = allocatedKeys[Math.floor(rand() * allocatedKeys.length)] } else { const idx = Math.floor(rand() * CANONICAL_SKILL_TREE.length) skillId = CANONICAL_SKILL_TREE[idx].id } const can = canDeallocate(CANONICAL_SKILL_TREE, state, skillId) const ptsBefore = state.hardPoints[skillId] ?? 0 const unspentBefore = state.unspentPoints const success = deallocatePoint(CANONICAL_SKILL_TREE, state, skillId) expect(success).toBe(can) if (success) { expect(state.hardPoints[skillId]).toBe(ptsBefore - 1) expect(state.unspentPoints).toBe(unspentBefore + 1) } else { expect(state.hardPoints[skillId] ?? 0).toBe(ptsBefore) expect(state.unspentPoints).toBe(unspentBefore) } } else if (actionRoll < 0.95) { // Action 3: GRANT MORE POINTS or LEVEL UP if (rand() < 0.5) { const add = 1 + Math.floor(rand() * 5) state.unspentPoints += add totalGranted += add } else { playerLevel = Math.min(99, playerLevel + Math.floor(rand() * 10)) } } else { // Action 4: RESPEC const refunded = respec(state) expect(refunded).toBeGreaterThanOrEqual(0) // Post-respec check: all skills must be 0 for (const pts of Object.values(state.hardPoints)) { expect(pts).toBe(0) } } // Strict invariant verification after EVERY single step assertStateInvariants(state, totalGranted, playerLevel) } } }) it('rejects 100% of adversarial allocation attempts violating prerequisite orders', () => { const state = createSkillTreeState(50) // Test all 172 skills that have prerequisites: none can be allocated on a clean state for (const node of CANONICAL_SKILL_TREE) { if (node.reqskills.length > 0) { expect(canAllocate(CANONICAL_SKILL_TREE, state, node.id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, node.id, 99)).toBe(false) expect(state.hardPoints[node.id] ?? 0).toBe(0) expect(state.unspentPoints).toBe(50) } } }) }) // ========================================================================== // Track 3: Deep Multi-Level Dependency Chains & Deallocation Blocking // ========================================================================== describe('Track 3: Deep Multi-Level Dependency Chains & Deallocation Blocking', () => { it('stress tests the 6-level deep dual-branch Druid Armageddon tree', () => { const state = createSkillTreeState(100) const lvl = 99 // Fire branch: Firestorm (225) -> Molten Boulder (229) -> Eruption (234) -> Volcano (244) // Wind branch: Arctic Blast (230) -> Cyclone Armor (235) -> Twister (240) -> Tornado (245) -> Hurricane (250) // Terminal: Armageddon (249) const fireBranch = [225, 229, 234, 244] const windBranch = [230, 235, 240, 245, 250] for (const id of fireBranch) { expect(allocatePoint(CANONICAL_SKILL_TREE, state, id, lvl)).toBe(true) } for (const id of windBranch) { expect(allocatePoint(CANONICAL_SKILL_TREE, state, id, lvl)).toBe(true) } // Allocate Armageddon expect(allocatePoint(CANONICAL_SKILL_TREE, state, 249, lvl)).toBe(true) // Verify: While Armageddon has 1 point, NOT A SINGLE PREDECESSOR IN EITHER BRANCH can be deallocated to 0 const allPredecessors = [...fireBranch, ...windBranch] for (const id of allPredecessors) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, id)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, id)).toBe(false) expect(state.hardPoints[id]).toBe(1) } // Add extra points to Firestorm (225): 1 -> 5 points for (let i = 0; i < 4; i++) { expect(allocatePoint(CANONICAL_SKILL_TREE, state, 225, lvl)).toBe(true) } expect(state.hardPoints[225]).toBe(5) // Deallocating Firestorm from 5 down to 1 MUST succeed for (let expected = 4; expected >= 1; expected--) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, 225)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 225)).toBe(true) expect(state.hardPoints[225]).toBe(expected) } // Deallocating Firestorm from 1 to 0 MUST fail because Armageddon (and Boulder/Eruption/Volcano) depend on it expect(canDeallocate(CANONICAL_SKILL_TREE, state, 225)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 225)).toBe(false) // Deallocate Armageddon (249 -> 0) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 249)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 249)).toBe(true) expect(state.hardPoints[249]).toBe(0) // Now Volcano (244) and Hurricane (250) can be deallocated expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 244)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 250)).toBe(true) // But Molten Boulder (229) still cannot be deallocated because Eruption (234) is still allocated! expect(canDeallocate(CANONICAL_SKILL_TREE, state, 229)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 234)).toBe(true) // Now Molten Boulder can be deallocated expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 229)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 225)).toBe(true) expect(state.hardPoints[225]).toBe(0) }) it('stress tests the 5-level deep Sorceress Frozen Orb tree and convergence at Blizzard', () => { const state = createSkillTreeState(50) const lvl = 99 // Chain: // Ice Bolt (39, lvl 1) -> Ice Blast (45, lvl 6) -> Glacial Spike (55, lvl 18) // Frost Nova (44, lvl 6) // Blizzard (59, lvl 24) requires [Frost Nova (44), Glacial Spike (55)] // Frozen Orb (64, lvl 30) requires [Blizzard (59)] allocatePoint(CANONICAL_SKILL_TREE, state, 39, lvl) allocatePoint(CANONICAL_SKILL_TREE, state, 45, lvl) allocatePoint(CANONICAL_SKILL_TREE, state, 55, lvl) allocatePoint(CANONICAL_SKILL_TREE, state, 44, lvl) allocatePoint(CANONICAL_SKILL_TREE, state, 59, lvl) allocatePoint(CANONICAL_SKILL_TREE, state, 64, lvl) // Frozen Orb active: none of 39, 45, 55, 44, 59 can be deallocated for (const id of [39, 45, 55, 44, 59]) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, id)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, id)).toBe(false) } // Peel back Frozen Orb expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 64)).toBe(true) // Blizzard active: Frost Nova (44) and Glacial Spike (55) still blocked expect(canDeallocate(CANONICAL_SKILL_TREE, state, 44)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 55)).toBe(false) // Peel back Blizzard expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 59)).toBe(true) // Now Frost Nova has no active dependents expect(canDeallocate(CANONICAL_SKILL_TREE, state, 44)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 44)).toBe(true) // Glacial Spike (55) can now be deallocated, but Ice Blast (45) cannot yet expect(canDeallocate(CANONICAL_SKILL_TREE, state, 45)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 55)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 45)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 39)).toBe(true) expect(state.hardPoints[39]).toBe(0) }) it('stress tests the 5-level deep Assassin Traps tree (Fire Trauma to Death Sentry)', () => { const state = createSkillTreeState(50) const lvl = 99 // Fire Trauma (251) -> Shock Field (256) -> Charged Bolt Sentry (261) -> Lightning Sentry (271) -> Death Sentry (276) const trapChain = [251, 256, 261, 271, 276] for (const id of trapChain) { expect(allocatePoint(CANONICAL_SKILL_TREE, state, id, lvl)).toBe(true) } for (let i = 0; i < trapChain.length - 1; i++) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, trapChain[i])).toBe(false) } // Peel back in exact reverse order for (let i = trapChain.length - 1; i >= 0; i--) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, trapChain[i])).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, trapChain[i])).toBe(true) } for (const id of trapChain) { expect(state.hardPoints[id]).toBe(0) } }) }) // ========================================================================== // Track 4: Multi-Prerequisite Convergence & Diamond Dependency Stress Testing // ========================================================================== describe('Track 4: Multi-Prerequisite Convergence & Diamond Dependency Stress Testing', () => { it('validates all 26 multi-prerequisite skills require 100% of prerequisites before allocation', () => { const multiPrereqSkills = CANONICAL_SKILL_TREE.filter(s => s.reqskills.length > 1) expect(multiPrereqSkills).toHaveLength(26) for (const skill of multiPrereqSkills) { // Test 1: Empty state -> cannot allocate const state = createSkillTreeState(50) expect(canAllocate(CANONICAL_SKILL_TREE, state, skill.id, 99)).toBe(false) // Test 2: Allocate all prerequisites EXCEPT one -> cannot allocate for (let missingIdx = 0; missingIdx < skill.reqskills.length; missingIdx++) { const partialState = createSkillTreeState(50) for (let i = 0; i < skill.reqskills.length; i++) { if (i !== missingIdx) { const reqId = skill.reqskills[i] // Allocate prereqs of prereq if needed const ancestors = getTransitivePrerequisites(reqId) for (const ancId of ancestors) { partialState.hardPoints[ancId] = 1 } partialState.hardPoints[reqId] = 1 } } expect(canAllocate(CANONICAL_SKILL_TREE, partialState, skill.id, 99)).toBe(false) } // Test 3: Allocate ALL prerequisites -> now CAN allocate const completeState = createSkillTreeState(50) for (const reqId of skill.reqskills) { const ancestors = getTransitivePrerequisites(reqId) for (const ancId of ancestors) { completeState.hardPoints[ancId] = 1 } completeState.hardPoints[reqId] = 1 } expect(canAllocate(CANONICAL_SKILL_TREE, completeState, skill.id, 99)).toBe(true) expect(allocatePoint(CANONICAL_SKILL_TREE, completeState, skill.id, 99)).toBe(true) // Test 4: With child skill allocated, NONE of the prerequisites can drop from 1 to 0 for (const reqId of skill.reqskills) { expect(canDeallocate(CANONICAL_SKILL_TREE, completeState, reqId)).toBe(false) } } }) it('stress tests Paladin Holy Shield (ID 117) with 4-way prerequisite convergence', () => { // Holy Shield (117) requires: // Charge (107) [which requires Smite (97)] // Blessed Hammer (112) [which requires Holy Bolt (101), which requires Sacrifice (96) wait, let's verify exact prereqs] const hsNode = CANONICAL_SKILL_MAP.get(117)! expect(hsNode.reqskills).toEqual([107, 112]) const state = createSkillTreeState(50) // Allocate Smite (97) -> Charge (107) allocatePoint(CANONICAL_SKILL_TREE, state, 97, 30) allocatePoint(CANONICAL_SKILL_TREE, state, 107, 30) // Only 1 of 2 prereqs met expect(canAllocate(CANONICAL_SKILL_TREE, state, 117, 30)).toBe(false) // Allocate Holy Bolt (101) -> Blessed Hammer (112) allocatePoint(CANONICAL_SKILL_TREE, state, 101, 30) allocatePoint(CANONICAL_SKILL_TREE, state, 112, 30) // Both prereqs met expect(canAllocate(CANONICAL_SKILL_TREE, state, 117, 30)).toBe(true) expect(allocatePoint(CANONICAL_SKILL_TREE, state, 117, 30)).toBe(true) // Neither Charge (107) nor Blessed Hammer (112) nor Smite (97) nor Holy Bolt (101) can deallocate to 0 expect(canDeallocate(CANONICAL_SKILL_TREE, state, 107)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 112)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 97)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 101)).toBe(false) }) }) // ========================================================================== // Track 5: Point Deallocation with Multiple Hard Points (>1 Points) // ========================================================================== describe('Track 5: Point Deallocation with Multiple Hard Points (>1 Points)', () => { it('allows deallocation down to 1 point while active dependents exist, blocking only 1 to 0', () => { const state = createSkillTreeState(100) // Fire Bolt (36) -> Fire Ball (47) allocatePoint(CANONICAL_SKILL_TREE, state, 36, 12) allocatePoint(CANONICAL_SKILL_TREE, state, 47, 12) // Invest 19 more points into Fire Bolt (total = 20) for (let i = 0; i < 19; i++) { expect(allocatePoint(CANONICAL_SKILL_TREE, state, 36, 12)).toBe(true) } expect(state.hardPoints[36]).toBe(20) // Invest 10 points into Fire Ball (total = 11) for (let i = 0; i < 10; i++) { expect(allocatePoint(CANONICAL_SKILL_TREE, state, 47, 12)).toBe(true) } expect(state.hardPoints[47]).toBe(11) // Deallocate Fire Bolt 19 times (20 -> 1): all must succeed despite Fire Ball having 11 points for (let expected = 19; expected >= 1; expected--) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 36)).toBe(true) expect(state.hardPoints[36]).toBe(expected) } // Now Fire Bolt is at 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 down to 0 for (let expected = 10; expected >= 0; expected--) { expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 47)).toBe(true) expect(state.hardPoints[47]).toBe(expected) } // Now Fire Bolt can safely drop from 1 to 0 expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 36)).toBe(true) expect(state.hardPoints[36]).toBe(0) }) }) // ========================================================================== // Track 6: Character Respec Invariant & Downstream Locking // ========================================================================== describe('Track 6: Character Respec Invariant & Downstream Locking', () => { it('proves exact 100% point refund and total downstream re-locking on random build configurations', () => { for (let trial = 0; trial < 20; trial++) { const rand = createPrng(trial * 777 + 1) const initialUnspent = 10 + Math.floor(rand() * 20) const state = createSkillTreeState(initialUnspent) let totalInvested = 0 // Allocate a valid multi-skill build const roots = CANONICAL_SKILL_TREE.filter(s => s.reqskills.length === 0) for (let step = 0; step < 40; step++) { // Give more unspent points to allow large trees state.unspentPoints += 5 const available = CANONICAL_SKILL_TREE.filter(s => canAllocate(CANONICAL_SKILL_TREE, state, s.id, 99)) if (available.length > 0) { const pick = available[Math.floor(rand() * available.length)] allocatePoint(CANONICAL_SKILL_TREE, state, pick.id, 99) totalInvested += 1 } } const unspentBeforeRespec = state.unspentPoints const refunded = respec(state) // Verification 1: Refund equals total invested points expect(refunded).toBe(totalInvested) expect(state.unspentPoints).toBe(unspentBeforeRespec + totalInvested) // Verification 2: Every skill is reset to 0 for (const [id, pts] of Object.entries(state.hardPoints)) { expect(pts).toBe(0) } // Verification 3: Total downstream re-locking for (const node of CANONICAL_SKILL_TREE) { if (node.reqskills.length > 0) { // Cannot allocate any skill with prerequisites expect(canAllocate(CANONICAL_SKILL_TREE, state, node.id, 99)).toBe(false) } else { // Root skills are eligible expect(canAllocate(CANONICAL_SKILL_TREE, state, node.id, 99)).toBe(true) } } // Verification 4: Idempotent respec const secondRefund = respec(state) expect(secondRefund).toBe(0) } }) it('handles respec on empty state with 0 points invested cleanly', () => { const state = createSkillTreeState(10) expect(respec(state)).toBe(0) expect(state.unspentPoints).toBe(10) }) }) // ========================================================================== // Track 7: Edge Cases, Boundary Values, and Adversarial Input Stress Testing // ========================================================================== describe('Track 7: Edge Cases, Boundary Values, and Adversarial Input Stress Testing', () => { it('enforces playerLevel boundaries for valid numeric inputs (reqlevel - 1, 0, negative)', () => { const state = createSkillTreeState(100) for (const node of CANONICAL_SKILL_TREE) { // reqlevel - 1 must ALWAYS fail if (node.reqlevel > 1) { expect(canAllocate(CANONICAL_SKILL_TREE, state, node.id, node.reqlevel - 1)).toBe(false) } // Level 0, negative levels must ALWAYS fail (all skills have reqlevel >= 1) expect(canAllocate(CANONICAL_SKILL_TREE, state, node.id, 0)).toBe(false) expect(canAllocate(CANONICAL_SKILL_TREE, state, node.id, -10)).toBe(false) } }) it('enforces NaN playerLevel is rejected and does not bypass reqlevel check', () => { const state = createSkillTreeState(10) const rootSkill = CANONICAL_SKILL_TREE.find(s => s.reqskills.length === 0)! const allowedWithNaN = canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, NaN) expect(allowedWithNaN).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, NaN)).toBe(false) }) it('enforces NaN unspentPoints is safely normalized to 0 and blocks allocation', () => { const state = createSkillTreeState(NaN) expect(Number.isNaN(state.unspentPoints)).toBe(false) expect(state.unspentPoints).toBe(0) const rootSkill = CANONICAL_SKILL_TREE.find(s => s.reqskills.length === 0)! expect(canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(state.hardPoints[rootSkill.id] ?? 0).toBe(0) }) it('enforces NaN hardPoints is rejected by canDeallocate and deallocatePoint', () => { const state = createSkillTreeState(0) const rootSkill = CANONICAL_SKILL_TREE.find(s => s.reqskills.length === 0)! state.hardPoints[rootSkill.id] = NaN expect(canDeallocate(CANONICAL_SKILL_TREE, state, rootSkill.id)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id)).toBe(false) expect(state.unspentPoints).toBe(0) }) it('enforces same-row prerequisite order at player level = 1', () => { const state = createSkillTreeState(10) // Necromancer: Raise Skeleton (70, row 1, reqlevel 1) -> Skeleton Mastery (69, row 1, reqlevel 1) expect(canAllocate(CANONICAL_SKILL_TREE, state, 69, 1)).toBe(false) // Mastery blocked expect(allocatePoint(CANONICAL_SKILL_TREE, state, 70, 1)).toBe(true) // Raise Skeleton allocated expect(canAllocate(CANONICAL_SKILL_TREE, state, 69, 1)).toBe(true) // Mastery now eligible expect(allocatePoint(CANONICAL_SKILL_TREE, state, 69, 1)).toBe(true) // Druid: Werewolf (223, row 1, reqlevel 1) -> Lycanthropy (224, row 1, reqlevel 1) const druidState = createSkillTreeState(10) expect(canAllocate(CANONICAL_SKILL_TREE, druidState, 224, 1)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, druidState, 223, 1)).toBe(true) expect(canAllocate(CANONICAL_SKILL_TREE, druidState, 224, 1)).toBe(true) }) it('enforces strict 20 hard points cap per skill across all 210 skills', () => { const state = createSkillTreeState(50) const rootSkill = CANONICAL_SKILL_TREE.find(s => s.reqskills.length === 0)! for (let i = 1; i <= 20; i++) { expect(canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(true) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(true) expect(state.hardPoints[rootSkill.id]).toBe(i) } // 21st point allocation must be rejected expect(canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(state.hardPoints[rootSkill.id]).toBe(20) expect(state.unspentPoints).toBe(30) // Pre-corrupted state with > 20 points must also be rejected state.hardPoints[rootSkill.id] = 25 expect(canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(state.hardPoints[rootSkill.id]).toBe(25) }) it('enforces unspentPoints underflow protection (0, negative, corrupted)', () => { const state = createSkillTreeState(0) const rootSkill = CANONICAL_SKILL_TREE.find(s => s.reqskills.length === 0)! expect(canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(state.unspentPoints).toBe(0) // Negative unspent points state.unspentPoints = -5 expect(canAllocate(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, rootSkill.id, 99)).toBe(false) expect(state.unspentPoints).toBe(-5) // createSkillTreeState with negative input clamps to 0 const clamped = createSkillTreeState(-10) expect(clamped.unspentPoints).toBe(0) }) it('gracefully rejects unknown / non-canonical skill IDs without throwing', () => { const state = createSkillTreeState(10) const invalidIds = [-999, 0, 9999, NaN, 1.5, Infinity, -Infinity] for (const id of invalidIds) { expect(canAllocate(CANONICAL_SKILL_TREE, state, id, 99)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, id, 99)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, id)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, id)).toBe(false) } }) }) // ========================================================================== // Track 8: Synergy Engine Adversarial & Boundary Stress Testing // ========================================================================== describe('Track 8: Synergy Engine Adversarial & Boundary Stress Testing', () => { it('clamps hard points strictly between 0 and 20 during synergy evaluation', () => { const state = createSkillTreeState(50) // Sorceress Fire Bolt (36) synergizes with Fire Ball (47) at par8 = 16% per hard point // Inject hard points = 30 (corrupted/overflow) state.hardPoints[47] = 30 // Invariant: base points are clamped to 20: 20 * 16% = +320% => 4.20 const multClamped = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental') expect(multClamped).toBeCloseTo(4.20, 2) // Inject hard points = -10 state.hardPoints[47] = -10 // Invariant: clamped to 0: 0 * 16% = 0% => 1.00 const multZero = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental') expect(multZero).toBe(1.0) }) it('safely handles adversarial or injected formula strings without code execution or crashing', () => { const state = createSkillTreeState(50) state.hardPoints[47] = 10 // Construct a mock node with adversarial formulas const adversarialNode: SkillNode = { id: 9991, name: 'Adversarial Skill 1', classCode: 'sor', tabIndex: 0, tabName: 'Fire Spells', row: 1, col: 1, reqlevel: 1, reqskills: [], dependents: [], synergyFormulas: { elemental: "process.exit(1)", // Attempt code injection physical: "console.log('pwned')", duration: "require('fs')", }, } const catalog = [adversarialNode, ...CANONICAL_SKILL_TREE] expect(calculateSynergyMultiplier(catalog, state, 9991, 'elemental')).toBe(1.0) expect(calculateSynergyMultiplier(catalog, state, 9991, 'physical')).toBe(1.0) expect(calculateSynergyMultiplier(catalog, state, 9991, 'duration')).toBe(1.0) // Adversarial math: unbalanced brackets, divide by zero, whitespace const badMathNode: SkillNode = { id: 9992, name: 'Bad Math Skill', classCode: 'sor', tabIndex: 0, tabName: 'Fire Spells', row: 1, col: 1, reqlevel: 1, reqskills: [], dependents: [], synergyFormulas: { elemental: "((((((skill('Fire Ball'.blvl)*10", physical: "1 / 0", duration: " ", }, } const catalog2 = [badMathNode, ...CANONICAL_SKILL_TREE] // Unclosed brackets balanced: (10 * 10) = 100% => 2.0 expect(calculateSynergyMultiplier(catalog2, state, 9992, 'elemental')).toBeCloseTo(2.0, 2) // Empty whitespace falls back to 1.0 expect(calculateSynergyMultiplier(catalog2, state, 9992, 'duration')).toBe(1.0) }) it('evaluates synergy multipliers across all 210 skills at 20 hard points without NaN or Infinity', () => { const state = createSkillTreeState(1000) for (const node of CANONICAL_SKILL_TREE) { state.hardPoints[node.id] = 20 } for (const node of CANONICAL_SKILL_TREE) { const elem = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, node.id, 'elemental') const phys = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, node.id, 'physical') const dur = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, node.id, 'duration') expect(Number.isFinite(elem)).toBe(true) expect(elem).toBeGreaterThanOrEqual(1.0) expect(Number.isFinite(phys)).toBe(true) expect(phys).toBeGreaterThanOrEqual(1.0) expect(Number.isFinite(dur)).toBe(true) expect(dur).toBeGreaterThanOrEqual(1.0) } }) }) // ========================================================================== // Track 9: Performance & Scalability Benchmark (Playbook Target: <= 50% limit) // ========================================================================== describe('Track 9: Performance & Scalability Benchmark', () => { it('executes 10,000 state operations in under 500ms (fast execution target)', () => { const state = createSkillTreeState(1000) const startTime = performance.now() for (let i = 0; i < 2000; i++) { // Amazon Bow skills linear chain: 6 -> 12 -> 22 -> 27 canAllocate(CANONICAL_SKILL_TREE, state, 6, 30) allocatePoint(CANONICAL_SKILL_TREE, state, 6, 30) canAllocate(CANONICAL_SKILL_TREE, state, 12, 30) allocatePoint(CANONICAL_SKILL_TREE, state, 12, 30) canDeallocate(CANONICAL_SKILL_TREE, state, 6) deallocatePoint(CANONICAL_SKILL_TREE, state, 12) deallocatePoint(CANONICAL_SKILL_TREE, state, 6) } const elapsed = performance.now() - startTime // 10,000 operations should take < 500ms expect(elapsed).toBeLessThan(500) }) }) })