/** * Tier 5 Adversarial Coverage Hardening Suite: Milestone M11 E2E Verification * * Ground Truth Invariants: * - Blizzard Diablo II v1.13c MPQ data tables (CharStats.txt, Skills.txt, SkillDesc.txt) * - Decompiled binary assembly (D2Common.dll, D2Game.dll) * * Adversarial Coverage Dimensions: * 1. Multi-class sequential progression and respec cycling across all 7 playable classes * 2. Max-level point allocation (110 points at Level 99) and respec point conservation * 3. Extreme combat simulations (zero mana, exact fractional mana thresholds, simultaneous cooldown locks) * 4. Reviewer hardening checks (weaponDamage NaN guards, caster.mana NaN guards, unspentPoints respec integrity) */ import { describe, it, expect } from 'vitest' import { resolveCharacterClass, getAllCharacterClasses, calculateVitalsAtLevel, type CharacterClassCode, type CharacterSpriteToken, type CharacterClassStats, CANONICAL_CHARSTATS, TOKEN_ALIASES, } from '../src/game/classes.ts' import { CANONICAL_SKILL_TREE, CANONICAL_SKILL_MAP, CANONICAL_SKILL_TABS, createSkillTreeState, canAllocate, allocatePoint, canDeallocate, deallocatePoint, respec, calculateSynergyMultiplier, validatePrerequisiteDAG, type SkillNode, type SkillTreeState, } from '../src/game/skill-tree.ts' import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getSkillCooldownTicks, calculateSkillDamage, executeSkill, type SkillExecutionContext, type Batch1SkillDef, type SkillExecutionResult, } from '../src/game/skills.ts' import type { CombatPlayer } from '../src/game/combat.ts' // ============================================================================ // Test Utilities & Factory Functions // ============================================================================ function makeTestPlayer(overrides?: Partial): 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, } } describe('Tier 5 Adversarial Coverage Hardening Suite (E2E Cross-System Integration)', () => { // ========================================================================== // Suite 1: Multi-Class Sequential Progression & Respec Cycling (All 7 Classes) // ========================================================================== describe('Suite 1: Multi-Class Sequential Progression & Respec Cycling Across All 7 Classes', () => { interface ClassProgressionScenario { readonly code: CharacterClassCode readonly name: string readonly token: CharacterSpriteToken readonly primaryBranch: readonly { id: number; name: string; reqlevel: number; points: number }[] readonly secondaryBranch: readonly { id: number; name: string; reqlevel: number; points: number }[] readonly illegalSkillsFromOtherClass: readonly number[] } const scenarios: readonly ClassProgressionScenario[] = [ { code: 'ama', name: 'Amazon', token: 'am', primaryBranch: [ { id: 6, name: 'Magic Arrow', reqlevel: 1, points: 1 }, { id: 11, name: 'Cold Arrow', reqlevel: 6, points: 1 }, { id: 12, name: 'Multiple Shot', reqlevel: 6, points: 5 }, { id: 22, name: 'Guided Arrow', reqlevel: 18, points: 5 }, { id: 26, name: 'Strafe', reqlevel: 24, points: 8 }, ], secondaryBranch: [ { id: 10, name: 'Jab', reqlevel: 1, points: 5 }, { id: 14, name: 'Power Strike', reqlevel: 6, points: 5 }, { id: 15, name: 'Poison Javelin', reqlevel: 6, points: 1 }, { id: 20, name: 'Lightning Bolt', reqlevel: 12, points: 4 }, { id: 24, name: 'Charged Strike', reqlevel: 18, points: 5 }, ], illegalSkillsFromOtherClass: [36, 66, 96, 126, 221, 251], }, { code: 'sor', name: 'Sorceress', token: 'so', primaryBranch: [ { id: 36, name: 'Fire Bolt', reqlevel: 1, points: 5 }, { id: 47, name: 'Fire Ball', reqlevel: 12, points: 5 }, { id: 41, name: 'Inferno', reqlevel: 6, points: 1 }, { id: 46, name: 'Blaze', reqlevel: 12, points: 1 }, { id: 51, name: 'Fire Wall', reqlevel: 18, points: 1 }, { id: 56, name: 'Meteor', reqlevel: 24, points: 5 }, { id: 61, name: 'Fire Mastery', reqlevel: 30, points: 2 }, ], secondaryBranch: [ { id: 39, name: 'Ice Bolt', reqlevel: 1, points: 5 }, { id: 45, name: 'Ice Blast', reqlevel: 6, points: 5 }, { id: 55, name: 'Glacial Spike', reqlevel: 18, points: 5 }, { id: 44, name: 'Frost Nova', reqlevel: 6, points: 1 }, { id: 59, name: 'Blizzard', reqlevel: 24, points: 4 }, ], illegalSkillsFromOtherClass: [6, 67, 101, 133, 225, 254], }, { code: 'nec', name: 'Necromancer', token: 'ne', primaryBranch: [ { id: 67, name: 'Teeth', reqlevel: 1, points: 5 }, { id: 74, name: 'Corpse Explosion', reqlevel: 6, points: 5 }, { id: 84, name: 'Bone Spear', reqlevel: 18, points: 5 }, { id: 93, name: 'Bone Spirit', reqlevel: 30, points: 5 }, ], secondaryBranch: [ { id: 70, name: 'Raise Skeleton', reqlevel: 1, points: 10 }, { id: 69, name: 'Skeleton Mastery', reqlevel: 1, points: 8 }, { id: 75, name: 'Clay Golem', reqlevel: 6, points: 2 }, ], illegalSkillsFromOtherClass: [7, 39, 106, 138, 223, 258], }, { code: 'pal', name: 'Paladin', token: 'pa', primaryBranch: [ { id: 96, name: 'Sacrifice', reqlevel: 1, points: 5 }, { id: 106, name: 'Zeal', reqlevel: 12, points: 5 }, { id: 111, name: 'Vengeance', reqlevel: 18, points: 5 }, { id: 98, name: 'Might', reqlevel: 1, points: 1 }, { id: 108, name: 'Blessed Aim', reqlevel: 12, points: 1 }, { id: 113, name: 'Concentration', reqlevel: 18, points: 1 }, { id: 122, name: 'Fanaticism', reqlevel: 30, points: 2 }, ], secondaryBranch: [ { id: 101, name: 'Holy Bolt', reqlevel: 6, points: 5 }, { id: 112, name: 'Blessed Hammer', reqlevel: 18, points: 5 }, { id: 104, name: 'Defiance', reqlevel: 6, points: 1 }, { id: 99, name: 'Prayer', reqlevel: 1, points: 1 }, { id: 109, name: 'Cleansing', reqlevel: 12, points: 1 }, { id: 115, name: 'Vigor', reqlevel: 18, points: 7 }, ], illegalSkillsFromOtherClass: [11, 47, 70, 143, 229, 261], }, { code: 'bar', name: 'Barbarian', token: 'ba', primaryBranch: [ { id: 126, name: 'Bash', reqlevel: 1, points: 5 }, { id: 133, name: 'Double Swing', reqlevel: 6, points: 5 }, { id: 140, name: 'Double Throw', reqlevel: 12, points: 5 }, { id: 147, name: 'Frenzy', reqlevel: 24, points: 5 }, ], secondaryBranch: [ { id: 130, name: 'Howl', reqlevel: 1, points: 5 }, { id: 138, name: 'Shout', reqlevel: 6, points: 5 }, { id: 149, name: 'Battle Orders', reqlevel: 24, points: 5 }, { id: 155, name: 'Battle Command', reqlevel: 30, points: 5 }, ], illegalSkillsFromOtherClass: [14, 54, 74, 112, 226, 252], }, { code: 'dru', name: 'Druid', token: 'dz', primaryBranch: [ { id: 223, name: 'Wearwolf', reqlevel: 1, points: 5 }, { id: 224, name: 'Shape Shifting', reqlevel: 1, points: 5 }, { id: 232, name: 'Feral Rage', reqlevel: 12, points: 5 }, { id: 238, name: 'Rabies', reqlevel: 18, points: 5 }, ], secondaryBranch: [ { id: 225, name: 'Firestorm', reqlevel: 1, points: 5 }, { id: 229, name: 'Molten Boulder', reqlevel: 6, points: 5 }, { id: 234, name: 'Eruption', reqlevel: 12, points: 5 }, { id: 244, name: 'Volcano', reqlevel: 24, points: 5 }, ], illegalSkillsFromOtherClass: [6, 36, 68, 97, 130, 276], }, { code: 'ass', name: 'Assassin', token: 'ai', primaryBranch: [ { id: 251, name: 'Fire Trauma', reqlevel: 1, points: 5 }, { id: 256, name: 'Shock Field', reqlevel: 6, points: 5 }, { id: 261, name: 'Charged Bolt Sentry', reqlevel: 12, points: 5 }, { id: 271, name: 'Lightning Sentry', reqlevel: 24, points: 5 }, ], secondaryBranch: [ { id: 252, name: 'Claw Mastery', reqlevel: 1, points: 5 }, { id: 258, name: 'Quickness', reqlevel: 6, points: 5 }, { id: 267, name: 'Fade', reqlevel: 18, points: 5 }, { id: 278, name: 'Venom', reqlevel: 30, points: 5 }, ], illegalSkillsFromOtherClass: [10, 44, 84, 117, 151, 245], }, ] for (const scenario of scenarios) { it(`executes full progression, cross-class isolation, and respec cycling for ${scenario.name} (${scenario.code})`, () => { // 1. Resolve character class through all forms const byName = resolveCharacterClass(scenario.name) const byCode = resolveCharacterClass(scenario.code) const byToken = resolveCharacterClass(scenario.token) expect(byName.code).toBe(scenario.code) expect(byCode.code).toBe(scenario.code) expect(byToken.code).toBe(scenario.code) // If Assassin, verify 'as' token alias if (scenario.code === 'ass') { const byAlias = resolveCharacterClass('as') expect(byAlias.code).toBe('ass') expect(byAlias.token).toBe('ai') expect(TOKEN_ALIASES['as']).toBe('ai') } // 2. Validate Level 1 stats and Level 30 vitals progression const lvl1Vitals = calculateVitalsAtLevel(byName, 1) expect(lvl1Vitals.life).toBeGreaterThanOrEqual(40) expect(lvl1Vitals.mana).toBeGreaterThanOrEqual(10) expect(lvl1Vitals.stamina).toBeGreaterThanOrEqual(50) const lvl30Vitals = calculateVitalsAtLevel(byName, 30, { vitality: 50, energy: 20 }) expect(lvl30Vitals.life).toBeGreaterThan(lvl1Vitals.life) expect(lvl30Vitals.mana).toBeGreaterThan(lvl1Vitals.mana) // 3. Initialize Skill Tree State at Level 30 with 20 points const state = createSkillTreeState(20) const initialUnspent = state.unspentPoints expect(initialUnspent).toBe(20) // 4. Allocate Primary Branch sequentially let allocatedPrimaryPoints = 0 for (const step of scenario.primaryBranch) { // Cannot allocate if player level is below reqlevel if (step.reqlevel > 1) { expect(canAllocate(CANONICAL_SKILL_TREE, state, step.id, step.reqlevel - 1)).toBe(false) } for (let p = 0; p < step.points; p++) { const ok = allocatePoint(CANONICAL_SKILL_TREE, state, step.id, 30) expect(ok).toBe(true) allocatedPrimaryPoints++ } expect(state.hardPoints[step.id]).toBe(step.points) } expect(allocatedPrimaryPoints).toBe(20) expect(state.unspentPoints).toBe(0) // 5. Verify illegal skill allocation (from another class or unspent pool empty) for (const foreignId of scenario.illegalSkillsFromOtherClass) { expect(canAllocate(CANONICAL_SKILL_TREE, state, foreignId, 30)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, foreignId, 30)).toBe(false) } // 6. Execute Respec: Verify complete refund and re-locking const refunded = respec(state) expect(refunded).toBe(20) expect(state.unspentPoints).toBe(20) // All allocated skills in primary branch must be reset to 0 for (const step of scenario.primaryBranch) { expect(state.hardPoints[step.id]).toBe(0) } // Downstream skills must now be re-locked for (const step of scenario.primaryBranch) { const node = CANONICAL_SKILL_MAP.get(step.id)! if (node.reqskills.length > 0) { expect(canAllocate(CANONICAL_SKILL_TREE, state, step.id, 30)).toBe(false) } } // 7. Re-allocate Secondary Branch to prove complete respec cycling let allocatedSecondaryPoints = 0 for (const step of scenario.secondaryBranch) { for (let p = 0; p < step.points; p++) { const ok = allocatePoint(CANONICAL_SKILL_TREE, state, step.id, 30) expect(ok).toBe(true) allocatedSecondaryPoints++ } expect(state.hardPoints[step.id]).toBe(step.points) } expect(allocatedSecondaryPoints).toBe(20) expect(state.unspentPoints).toBe(0) // 8. Respec second time: Verify 100% point conservation again const secondRefund = respec(state) expect(secondRefund).toBe(20) expect(state.unspentPoints).toBe(20) }) } }) // ========================================================================== // Suite 2: Max-Level 99 Point Allocation (110 Points) & Respec Conservation // ========================================================================== describe('Suite 2: Max-Level 99 Point Allocation (110 Points) & Respec Point Conservation', () => { it('allocates 110 max skill points for Sorceress, enforces 20-pt cap, rejects 111th point, and conserves all 110 points across respec', () => { const state = createSkillTreeState(110) const playerLevel = 99 // Sorceress 110-point build: // Fire Bolt (36): 20 // Inferno (41): 1 // Blaze (46): 1 // Fire Wall (51): 1 // Fire Ball (47): 20 // Meteor (56): 20 // Fire Mastery (61): 20 // Warmth (37): 20 // Telekinesis (43): 1 // Teleport (54): 6 // Total = 20 + 1 + 1 + 1 + 20 + 20 + 20 + 20 + 1 + 6 = 110 points const build: readonly { id: number; count: number }[] = [ { id: 36, count: 20 }, { id: 41, count: 1 }, { id: 46, count: 1 }, { id: 51, count: 1 }, { id: 47, count: 20 }, { id: 56, count: 20 }, { id: 61, count: 20 }, { id: 37, count: 20 }, { id: 43, count: 1 }, { id: 54, count: 6 }, ] let allocated = 0 for (const step of build) { for (let i = 0; i < step.count; i++) { const ok = allocatePoint(CANONICAL_SKILL_TREE, state, step.id, playerLevel) expect(ok).toBe(true) allocated++ // Invariant: allocated + unspent === 110 at every single allocation step expect(allocated + state.unspentPoints).toBe(110) } expect(state.hardPoints[step.id]).toBe(step.count) // If skill reached 20 points, verify 21st point is strictly rejected if (step.count === 20) { expect(canAllocate(CANONICAL_SKILL_TREE, state, step.id, playerLevel)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, step.id, playerLevel)).toBe(false) } } expect(allocated).toBe(110) expect(state.unspentPoints).toBe(0) // When unspent pool is 0, attempting any 111th point must be rejected expect(canAllocate(CANONICAL_SKILL_TREE, state, 54, playerLevel)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, 54, playerLevel)).toBe(false) expect(state.hardPoints[54]).toBe(6) // Respec: 100% point conservation const refunded = respec(state) expect(refunded).toBe(110) expect(state.unspentPoints).toBe(110) // All skills reset to 0 for (const step of build) { expect(state.hardPoints[step.id]).toBe(0) } }) it('allocates 110 max skill points for Paladin, verifies prerequisite DAG compliance, and preserves integrity over multiple respec cycles', () => { const state = createSkillTreeState(110) const playerLevel = 99 // Paladin 110-point build: // Sacrifice (96): 20 // Zeal (106): 20 // Might (98): 1 // Blessed Aim (108): 20 // Concentration (113): 1 // Fanaticism (122): 20 // Holy Bolt (101): 1 // Blessed Hammer (112): 20 // Smite (97): 1 // Charge (107): 1 // Holy Shield (117): 5 // Total = 20 + 20 + 1 + 20 + 1 + 20 + 1 + 20 + 1 + 1 + 5 = 110 points const palBuild: readonly { id: number; count: number }[] = [ { id: 96, count: 20 }, { id: 106, count: 20 }, { id: 98, count: 1 }, { id: 108, count: 20 }, { id: 113, count: 1 }, { id: 122, count: 20 }, { id: 101, count: 1 }, { id: 112, count: 20 }, { id: 97, count: 1 }, { id: 107, count: 1 }, { id: 117, count: 5 }, ] // Staggered allocation cycle 1: Allocate partial (45 points) -> respec for (let i = 0; i < 20; i++) allocatePoint(CANONICAL_SKILL_TREE, state, 96, playerLevel) for (let i = 0; i < 20; i++) allocatePoint(CANONICAL_SKILL_TREE, state, 106, playerLevel) for (let i = 0; i < 5; i++) allocatePoint(CANONICAL_SKILL_TREE, state, 97, playerLevel) expect(state.unspentPoints).toBe(110 - 45) expect(respec(state)).toBe(45) expect(state.unspentPoints).toBe(110) // Staggered allocation cycle 2: Allocate all 110 points let totalAllocated = 0 for (const item of palBuild) { for (let i = 0; i < item.count; i++) { const ok = allocatePoint(CANONICAL_SKILL_TREE, state, item.id, playerLevel) expect(ok).toBe(true) totalAllocated++ expect(totalAllocated + state.unspentPoints).toBe(110) } } expect(totalAllocated).toBe(110) expect(state.unspentPoints).toBe(0) // Deallocation safety check: cannot deallocate Smite (97) or Charge (107) while Holy Shield (117) has points expect(canDeallocate(CANONICAL_SKILL_TREE, state, 97)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 107)).toBe(false) expect(canDeallocate(CANONICAL_SKILL_TREE, state, 117)).toBe(true) // Holy Shield has 5 points // Deallocate Holy Shield points down to 0 for (let i = 0; i < 5; i++) { expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 117)).toBe(true) } expect(state.hardPoints[117]).toBe(0) expect(state.unspentPoints).toBe(5) // Now Charge (107) has 1 point and 0 downstream allocated dependents -> can deallocate! expect(canDeallocate(CANONICAL_SKILL_TREE, state, 107)).toBe(true) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, 107)).toBe(true) expect(state.unspentPoints).toBe(6) // Respec remainder: refunds remaining 104 allocated points const remainingRefunded = respec(state) expect(remainingRefunded).toBe(104) expect(state.unspentPoints).toBe(110) }) }) // ========================================================================== // Suite 3: Extreme Combat Simulations & Boundary Resource Conditions // ========================================================================== describe('Suite 3: Extreme Combat Simulations & Boundary Resource Conditions', () => { it('handles zero mana condition across all 38 Batch 1 skills without crashing', () => { for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) { const skillId = Number(idStr) const player = makeTestPlayer({ mana: 0, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } const cost = getSkillManaCost(skillId, 1) const result = executeSkill(skillId, ctx) if (cost > 0) { expect(result.kind).toBe('mana') if (result.kind === 'mana') { expect(result.cost).toBe(cost) expect(result.currentMana).toBe(0) } expect(player.mana).toBe(0) // Mana never drops below 0 } else { // Zero mana cost skills succeed cleanly at 0 mana expect(result.kind).not.toBe('mana') expect(player.mana).toBe(0) } } }) it('validates exact fractional mana thresholds and precision boundary checks', () => { // Magic Arrow (ID 6): L1 cost is exactly 1.5 mana (manashift = 5: 12 * 0.125 = 1.5) const cost = getSkillManaCost(6, 1) expect(cost).toBe(1.5) // 1. Mana is strictly less than cost (1.4999 mana) -> REJECTED const p1 = makeTestPlayer({ mana: 1.4999 }) const res1 = executeSkill(6, { caster: p1, skillLevel: 1 }) expect(res1.kind).toBe('mana') expect(p1.mana).toBe(1.4999) // Untouched // 2. Mana is EXACTLY equal to cost (1.5 mana) -> SUCCESS, leaves exactly 0.0 const p2 = makeTestPlayer({ mana: 1.5 }) const res2 = executeSkill(6, { caster: p2, skillLevel: 1 }) expect(res2.kind).toBe('projectile') expect(p2.mana).toBe(0) // 3. Mana is cost + 0.25 (1.75 mana) -> SUCCESS, leaves exactly 0.25 const p3 = makeTestPlayer({ mana: 1.75 }) const res3 = executeSkill(6, { caster: p3, skillLevel: 1 }) expect(res3.kind).toBe('projectile') expect(p3.mana).toBeCloseTo(0.25, 5) // Fire Bolt (ID 36): L1 cost is exactly 2.5 mana (manashift = 7: 5 * 0.5 = 2.5) const fbCost = getSkillManaCost(36, 1) expect(fbCost).toBe(2.5) const pFbLow = makeTestPlayer({ mana: 2.499 }) expect(executeSkill(36, { caster: pFbLow, skillLevel: 1 }).kind).toBe('mana') expect(pFbLow.mana).toBe(2.499) const pFbExact = makeTestPlayer({ mana: 2.5 }) expect(executeSkill(36, { caster: pFbExact, skillLevel: 1 }).kind).toBe('projectile') expect(pFbExact.mana).toBe(0) }) it('enforces simultaneous cooldown locks across distinct delay skills and allows non-delay skills', () => { // Werewolf (223): delay 25 frames (1.0s) // Firestorm (225): delay 15 frames (0.6s) // Molten Boulder (229): delay 50 frames (2.0s) const player = makeTestPlayer({ mana: 500, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Cast Molten Boulder: sets player.cooldown to 50 ticks const initialCast = executeSkill(229, ctx) expect(initialCast.kind).toBe('projectile') expect(player.cooldown).toBe(50) // While cooldown is 50, all three delay skills are locked out const lockedWW = executeSkill(223, ctx) expect(lockedWW.kind).toBe('cooldown') if (lockedWW.kind === 'cooldown') expect(lockedWW.ticksLeft).toBe(50) const lockedFS = executeSkill(225, ctx) expect(lockedFS.kind).toBe('cooldown') if (lockedFS.kind === 'cooldown') expect(lockedFS.ticksLeft).toBe(50) const lockedMB = executeSkill(229, ctx) expect(lockedMB.kind).toBe('cooldown') if (lockedMB.kind === 'cooldown') expect(lockedMB.ticksLeft).toBe(50) // Meanwhile, non-delay combat skills (e.g. Fire Bolt, Zeal, Blessed Hammer, Bash) MUST NOT BE LOCKED const nonDelay1 = executeSkill(36, ctx) expect(nonDelay1.kind).toBe('projectile') const nonDelay2 = executeSkill(106, ctx) expect(nonDelay2.kind).toBe('melee') const nonDelay3 = executeSkill(112, ctx) expect(nonDelay3.kind).toBe('area') // Tick down 35 frames: remaining cooldown is 15 player.cooldown = 15 expect(executeSkill(225, ctx).kind).toBe('cooldown') // Firestorm still locked expect(executeSkill(223, ctx).kind).toBe('cooldown') // Werewolf still locked // Tick down remaining 15 frames: remaining cooldown is 0 player.cooldown = 0 // Now Firestorm can be cast immediately const fsSuccess = executeSkill(225, ctx) expect(fsSuccess.kind).toBe('area') expect(player.cooldown).toBe(15) // Sets new cooldown to 15 }) it('simulates rapid cast sequence of 100 consecutive casts with monotonic mana depletion', () => { // 100 casts at 2.5 mana = exactly 250 mana const player = makeTestPlayer({ mana: 250 }) const costPerCast = getSkillManaCost(36, 1) // 2.5 mana let successfulCasts = 0 for (let i = 0; i < 100; i++) { const prevMana = player.mana const res = executeSkill(36, { caster: player, skillLevel: 1 }) if (res.kind === 'projectile') { successfulCasts++ expect(player.mana).toBeCloseTo(prevMana - costPerCast, 5) } else if (res.kind === 'mana') { // Ran out of mana expect(player.mana).toBeLessThan(costPerCast) break } } // Initial 250 mana / 2.5 cost = exactly 100 casts! expect(successfulCasts).toBe(100) expect(player.mana).toBeCloseTo(0, 5) // 101st cast must fail due to 0 mana const res101 = executeSkill(36, { caster: player, skillLevel: 1 }) expect(res101.kind).toBe('mana') }) }) // ========================================================================== // Suite 4: Reviewer Hardening Checks & Adversarial Robustness // ========================================================================== describe('Suite 4: Reviewer Hardening Checks & Adversarial Robustness', () => { it('Reviewer F1: guards weaponDamage against NaN and invalid numbers in calculateSkillDamage', () => { // 1. Passing NaN min/max to weapon-dependent skill (Jab, ID 10) const nanDamage1 = calculateSkillDamage(10, 1, 1.0, { min: NaN, max: NaN }) expect(Number.isFinite(nanDamage1.min)).toBe(true) expect(Number.isFinite(nanDamage1.max)).toBe(true) expect(Number.isNaN(nanDamage1.min)).toBe(false) expect(Number.isNaN(nanDamage1.max)).toBe(false) expect(nanDamage1.min).toBeGreaterThanOrEqual(0) expect(nanDamage1.max).toBeGreaterThanOrEqual(0) // 2. Passing partial NaN (e.g. min: NaN, max: 20) to Sacrifice (ID 96) const nanDamage2 = calculateSkillDamage(96, 1, 1.0, { min: NaN, max: 20 }) expect(nanDamage2.min).toBe(0) expect(nanDamage2.max).toBe(20) // 3. Passing non-existent skill ID with NaN weapon damage const nanDamage3 = calculateSkillDamage(99999, 1, 1.0, { min: NaN, max: NaN }) expect(nanDamage3).toEqual({ min: 0, max: 0 }) // 4. Passing negative weaponDamage const negDamage = calculateSkillDamage(10, 1, 1.0, { min: -100, max: -50 }) expect(negDamage.min).toBe(0) expect(negDamage.max).toBe(0) // 5. Passing infinite weaponDamage const infDamage = calculateSkillDamage(10, 1, 1.0, { min: Infinity, max: -Infinity }) expect(Number.isFinite(infDamage.min)).toBe(true) expect(Number.isFinite(infDamage.max)).toBe(true) }) it('Reviewer F2: guards caster.mana against NaN and non-finite values in executeSkill', () => { // 1. Caster mana is NaN for a skill with manaCost > 0 (Fire Bolt: 2.5 mana) const pNaN = makeTestPlayer({ mana: NaN }) const resNaN = executeSkill(36, { caster: pNaN, skillLevel: 1 }) expect(resNaN.kind).toBe('mana') if (resNaN.kind === 'mana') { expect(resNaN.cost).toBe(2.5) expect(resNaN.currentMana).toBe(0) // Sanitized safely to 0 } // 2. Caster with mana = NaN casting a 0-cost skill (Sacrifice ID 96): // Must not propagate NaN into player.mana! const pNaNZeroCost = makeTestPlayer({ mana: NaN }) const resZero = executeSkill(96, { caster: pNaNZeroCost, skillLevel: 1 }) expect(resZero.kind).toBe('melee') expect(Number.isNaN(pNaNZeroCost.mana)).toBe(false) expect(pNaNZeroCost.mana).toBe(0) // Safely normalized to 0, not NaN! // 3. Caster mana is negative (-50) const pNeg = makeTestPlayer({ mana: -50 }) const resNeg = executeSkill(36, { caster: pNeg, skillLevel: 1 }) expect(resNeg.kind).toBe('mana') if (resNeg.kind === 'mana') { expect(resNeg.currentMana).toBe(-50) } // 4. Caster mana is undefined const pUndef = makeTestPlayer() ;(pUndef as any).mana = undefined const resUndef = executeSkill(36, { caster: pUndef, skillLevel: 1 }) expect(resUndef.kind).toBe('mana') if (resUndef.kind === 'mana') { expect(resUndef.currentMana).toBe(0) } }) it('Reviewer F3: guarantees unspentPoints integrity in respec even under corrupted initial states', () => { // 1. State with unspentPoints = NaN const corruptedState1: SkillTreeState = { hardPoints: { 36: 10, 47: 5 }, unspentPoints: NaN, } const ref1 = respec(corruptedState1) expect(ref1).toBe(15) expect(Number.isFinite(corruptedState1.unspentPoints)).toBe(true) expect(Number.isNaN(corruptedState1.unspentPoints)).toBe(false) expect(corruptedState1.unspentPoints).toBe(15) // 2. State with unspentPoints = -20 (corrupted negative pool) const corruptedState2: SkillTreeState = { hardPoints: { 106: 4, 112: 1 }, unspentPoints: -20, } const ref2 = respec(corruptedState2) expect(ref2).toBe(5) expect(corruptedState2.unspentPoints).toBe(5) // Normalized to 0 + 5 = 5 // 3. State with fractional float unspentPoints (e.g. 2.7) const floatState: SkillTreeState = { hardPoints: { 67: 3 }, unspentPoints: 2.7, } const ref3 = respec(floatState) expect(ref3).toBe(3) expect(floatState.unspentPoints).toBe(5) // Math.floor(2.7) + 3 = 5 }) it('verifies cross-system synergy invariant under max 20-hard-point synergies and equipment soft-point isolation', () => { const state = createSkillTreeState(100) // Allocate prerequisites for Meteor (41 Inferno -> 46 Blaze -> 51 Fire Wall) allocatePoint(CANONICAL_SKILL_TREE, state, 41, 99) allocatePoint(CANONICAL_SKILL_TREE, state, 46, 99) allocatePoint(CANONICAL_SKILL_TREE, state, 51, 99) // Max out Fire Bolt (36) and its synergies: Fire Ball (47) = 20 blvl, Meteor (56) = 20 blvl for (let i = 0; i < 20; i++) allocatePoint(CANONICAL_SKILL_TREE, state, 36, 99) for (let i = 0; i < 20; i++) allocatePoint(CANONICAL_SKILL_TREE, state, 47, 99) for (let i = 0; i < 20; i++) allocatePoint(CANONICAL_SKILL_TREE, state, 56, 99) // Formula: (skill('Fire Ball'.blvl) + skill('Meteor'.blvl)) * par8 (par8 = 16) // (20 + 20) * 16% = +640% bonus -> 7.40 multiplier const maxSynergy = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental') expect(maxSynergy).toBeCloseTo(7.40, 2) // Damage calculation with 20 base points (slvl = 20): const dmgL20NoGear = calculateSkillDamage(36, 20, maxSynergy) expect(dmgL20NoGear.min).toBeGreaterThan(0) expect(dmgL20NoGear.max).toBeGreaterThan(dmgL20NoGear.min) // Character equips +20 All Skills gear (slvl becomes 40): const effectiveSlvlWithGear = 40 // INVARIANT: Synergy multiplier MUST REMAIN EXACTLY 7.40! // Soft points (+skills) must not inflate the 640% synergy bonus const gearSynergy = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, 'elemental') expect(gearSynergy).toBe(maxSynergy) const dmgL40WithGear = calculateSkillDamage(36, effectiveSlvlWithGear, gearSynergy) // Higher slvl causes base damage to scale dramatically, but multiplier remains based strictly on blvl expect(dmgL40WithGear.min).toBeGreaterThan(dmgL20NoGear.min) expect(dmgL40WithGear.max).toBeGreaterThan(dmgL20NoGear.max) }) }) })