import { describe, it, expect } from 'vitest' import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getSkillCooldownTicks, calculateSkillDamage, executeSkill, type SkillExecutionContext, type Batch1SkillDef, type SkillType, } from '../src/game/skills.ts' import { CANONICAL_SKILL_TREE, CANONICAL_SKILL_MAP, createSkillTreeState, canAllocate, allocatePoint, canDeallocate, deallocatePoint, respec, calculateSynergyMultiplier, type SkillTreeState, } from '../src/game/skill-tree.ts' import type { CombatPlayer } from '../src/game/combat.ts' 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('Adversarial Stress Testing: M3 Skills, Resources, Cooldowns & State', () => { // ========================================================================== // Suite 1: Mana Edge Cases Across All 38 Skills // ========================================================================== describe('Suite 1: Mana Edge Cases Across All 38 Skills', () => { const all38SkillIds = Object.keys(BATCH1_SKILLS).map(Number) const testLevels = [-10, 0, 1, 5, 10, 20, 50, 99, NaN, Infinity, -Infinity] it('verifies catalog contains exactly 38 skills across all 7 classes', () => { expect(all38SkillIds.length).toBe(38) for (const id of all38SkillIds) { expect(isBatch1Skill(id)).toBe(true) const def = getBatch1SkillDef(id) expect(def).toBeDefined() expect(def!.id).toBe(id) } }) it('verifies mana cost is always finite, non-negative, and never NaN across all 38 skills and edge levels', () => { for (const skillId of all38SkillIds) { const def = getBatch1SkillDef(skillId)! const lvl1Cost = getSkillManaCost(skillId, 1) expect(lvl1Cost).toBeGreaterThanOrEqual(0) expect(Number.isFinite(lvl1Cost)).toBe(true) for (const lvl of testLevels) { const cost = getSkillManaCost(skillId, lvl) // Invariants expect(typeof cost).toBe('number') expect(Number.isNaN(cost)).toBe(false) expect(Number.isFinite(cost)).toBe(true) expect(cost).toBeGreaterThanOrEqual(0) // For invalid, negative, or non-finite levels, cost must normalize safely to level 1 cost if (Number.isNaN(lvl) || !Number.isFinite(lvl) || lvl <= 0) { expect(cost).toBe(lvl1Cost) } // Must never drop below defined minmana const minmana = def.minmana ?? 0 expect(cost).toBeGreaterThanOrEqual(minmana) } } }) it('returns 0 mana for non-batch1 skill IDs and non-numeric skill IDs', () => { const invalidSkillIds = [0, -1, -999, 9999, NaN, Infinity, -Infinity, 999999] for (const id of invalidSkillIds) { expect(getSkillManaCost(id, 1)).toBe(0) expect(getSkillManaCost(id, 20)).toBe(0) expect(getSkillManaCost(id, NaN)).toBe(0) } }) it('validates authentic 1.13c mana costs for selected archetypes at specific levels', () => { // Fire Bolt (36): mana 5, lvlmana 0, manashift 7 (shift 0.5) -> constant 2.5 expect(getSkillManaCost(36, 1)).toBe(2.5) expect(getSkillManaCost(36, 10)).toBe(2.5) expect(getSkillManaCost(36, 99)).toBe(2.5) // Teeth (67): mana 6, lvlmana 1, manashift 7 (shift 0.5) -> (6 + 1*(lvl-1))*0.5 expect(getSkillManaCost(67, 1)).toBe(3.0) expect(getSkillManaCost(67, 2)).toBe(3.5) expect(getSkillManaCost(67, 10)).toBe(7.5) expect(getSkillManaCost(67, 20)).toBe(12.5) // Holy Bolt (101): mana 16, lvlmana 1, manashift 5 (shift 0.125) -> (16 + 1*(lvl-1))*0.125 expect(getSkillManaCost(101, 1)).toBe(2.0) expect(getSkillManaCost(101, 9)).toBe(3.0) expect(getSkillManaCost(101, 17)).toBe(4.0) // Blessed Hammer (112): mana 20, lvlmana 1, manashift 6 (shift 0.25) -> (20 + 1*(lvl-1))*0.25 expect(getSkillManaCost(112, 1)).toBe(5.0) expect(getSkillManaCost(112, 5)).toBe(6.0) expect(getSkillManaCost(112, 21)).toBe(10.0) // Fire Blast (251): mana 24, lvlmana 4, manashift 5 (shift 0.125) -> (24 + 4*(lvl-1))*0.125 = 3 + 0.5*(lvl-1) expect(getSkillManaCost(251, 1)).toBe(3.0) expect(getSkillManaCost(251, 2)).toBe(3.5) expect(getSkillManaCost(251, 5)).toBe(5.0) expect(getSkillManaCost(251, 10)).toBe(7.5) }) }) // ========================================================================== // Suite 2: Decreasing Mana Cost Floor Invariant (Magic Arrow & Double Swing) // ========================================================================== describe('Suite 2: Decreasing Mana Never Drops Below 0 (Magic Arrow, Double Swing)', () => { it('verifies Magic Arrow (id: 6) decreasing mana floors strictly at 0.0 for levels 1..99', () => { // Magic Arrow: mana 12, lvlmana -1, minmana 0, manashift 5 (shift factor 0.125) // Level 1: 12 * 0.125 = 1.50 // Level 5: (12 - 4) * 0.125 = 1.00 // Level 9: (12 - 8) * 0.125 = 0.50 // Level 13: (12 - 12) * 0.125 = 0.00 // Level 14+: raw < 0 clamped strictly to minmana = 0 expect(getSkillManaCost(6, 1)).toBe(1.5) expect(getSkillManaCost(6, 2)).toBe(1.375) expect(getSkillManaCost(6, 5)).toBe(1.0) expect(getSkillManaCost(6, 9)).toBe(0.5) expect(getSkillManaCost(6, 12)).toBe(0.125) expect(getSkillManaCost(6, 13)).toBe(0.0) for (let lvl = 14; lvl <= 99; lvl++) { const cost = getSkillManaCost(6, lvl) expect(cost).toBe(0.0) } }) it('verifies Double Swing (id: 133) decreasing mana floors strictly at 0.0 for levels 1..99', () => { // Double Swing: mana 8, lvlmana -1, minmana 0, manashift 5 (shift factor 0.125) // Level 1: 8 * 0.125 = 1.00 // Level 5: (8 - 4) * 0.125 = 0.50 // Level 9: (8 - 8) * 0.125 = 0.00 // Level 10+: raw < 0 clamped strictly to minmana = 0 expect(getSkillManaCost(133, 1)).toBe(1.0) expect(getSkillManaCost(133, 2)).toBe(0.875) expect(getSkillManaCost(133, 5)).toBe(0.5) expect(getSkillManaCost(133, 8)).toBe(0.125) expect(getSkillManaCost(133, 9)).toBe(0.0) for (let lvl = 10; lvl <= 99; lvl++) { const cost = getSkillManaCost(133, lvl) expect(cost).toBe(0.0) } }) }) // ========================================================================== // Suite 3: Minimum Mana Clamping Invariant (Teleport & Other Minmana Skills) // ========================================================================== describe('Suite 3: Minmana Clamping (Teleport Never Drops Below 1)', () => { it('verifies Teleport (id: 54) clamps strictly at minmana = 1 for levels 1..99', () => { // Teleport: mana 24, lvlmana -1, minmana 1, manashift 8 (shift factor 1.0) // Level 1: 24 // Level 10: 15 // Level 20: 5 // Level 23: 2 // Level 24: 1 (reaches minmana) // Level 25..99: raw cost drops below 1, clamped strictly to minmana = 1 expect(getSkillManaCost(54, 1)).toBe(24) expect(getSkillManaCost(54, 10)).toBe(15) expect(getSkillManaCost(54, 20)).toBe(5) expect(getSkillManaCost(54, 23)).toBe(2) expect(getSkillManaCost(54, 24)).toBe(1) expect(getSkillManaCost(54, 25)).toBe(1) for (let lvl = 26; lvl <= 99; lvl++) { const cost = getSkillManaCost(54, lvl) expect(cost).toBe(1) } }) it('verifies all skills with minmana > 0 never violate their minmana clamp', () => { for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) { const skillId = Number(idStr) if (def.minmana > 0) { for (let lvl = 1; lvl <= 99; lvl++) { const cost = getSkillManaCost(skillId, lvl) expect(cost).toBeGreaterThanOrEqual(def.minmana) } } } }) }) // ========================================================================== // Suite 4: Fractional Mana Accumulation & Low-Mana Rapid Casting // ========================================================================== describe('Suite 4: Fractional Mana Accumulation and Rapid Casting Under Low-Mana Conditions', () => { it('rejects casting when current mana is strictly less than fractional cost', () => { // Fire Bolt costs 2.5 mana const player = makeTestPlayer({ mana: 2.49 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } const res = executeSkill(36, ctx) expect(res.kind).toBe('mana') if (res.kind === 'mana') { expect(res.cost).toBe(2.5) expect(res.currentMana).toBe(2.49) } // Mana is preserved without deductions expect(player.mana).toBe(2.49) }) it('allows casting immediately once fractional mana accumulator crosses the threshold', () => { const player = makeTestPlayer({ mana: 2.49 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Insufficient: 2.49 < 2.5 expect(executeSkill(36, ctx).kind).toBe('mana') // Accumulate +0.02 mana -> 2.51 player.mana += 0.02 expect(player.mana).toBeCloseTo(2.51, 5) // Now sufficient: 2.51 >= 2.5 const res = executeSkill(36, ctx) expect(res.kind).toBe('projectile') expect(player.mana).toBeCloseTo(0.01, 5) }) it('simulates rapid sequential casting under low-mana until exhausted', () => { // Start with exactly 5.0 mana (2 casts of Fire Bolt at 2.5 each) const player = makeTestPlayer({ mana: 5.0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Cast 1 const c1 = executeSkill(36, ctx) expect(c1.kind).toBe('projectile') expect(player.mana).toBeCloseTo(2.5, 5) // Cast 2 const c2 = executeSkill(36, ctx) expect(c2.kind).toBe('projectile') expect(player.mana).toBeCloseTo(0.0, 5) // Cast 3: Mana exhausted const c3 = executeSkill(36, ctx) expect(c3.kind).toBe('mana') if (c3.kind === 'mana') { expect(c3.cost).toBe(2.5) expect(c3.currentMana).toBeCloseTo(0.0, 5) } expect(player.mana).toBeCloseTo(0.0, 5) }) it('chains rapid casting across different skills with heterogeneous fractional costs', () => { // Start with 3.0 mana const player = makeTestPlayer({ mana: 3.0 }) // 1. Magic Arrow L1 (1.5 mana) -> 1.5 remaining const r1 = executeSkill(6, { caster: player, skillLevel: 1 }) expect(r1.kind).toBe('projectile') expect(player.mana).toBeCloseTo(1.5, 5) // 2. Fire Bolt L1 (2.5 mana) -> fails (1.5 < 2.5), mana preserved const r2 = executeSkill(36, { caster: player, skillLevel: 1 }) expect(r2.kind).toBe('mana') expect(player.mana).toBeCloseTo(1.5, 5) // 3. Double Swing L1 (1.0 mana) -> succeeds, 0.5 remaining const r3 = executeSkill(133, { caster: player, skillLevel: 1 }) expect(r3.kind).toBe('melee') expect(player.mana).toBeCloseTo(0.5, 5) // 4. Magic Arrow L9 (0.5 mana) -> succeeds, 0.0 remaining const r4 = executeSkill(6, { caster: player, skillLevel: 9 }) expect(r4.kind).toBe('projectile') expect(player.mana).toBeCloseTo(0.0, 5) // 5. Magic Arrow L13 (0.0 mana) -> succeeds even with 0.0 mana! const r5 = executeSkill(6, { caster: player, skillLevel: 13 }) expect(r5.kind).toBe('projectile') expect(player.mana).toBeCloseTo(0.0, 5) }) it('successfully executes 0-cost skills when player mana is exactly 0.0', () => { const zeroCostSkills: readonly { readonly id: number; readonly level: number; readonly expectedKind: string }[] = [ { id: 6, level: 13, expectedKind: 'projectile' }, // Magic Arrow L13 { id: 6, level: 20, expectedKind: 'projectile' }, // Magic Arrow L20 { id: 133, level: 9, expectedKind: 'melee' }, // Double Swing L9 { id: 133, level: 20, expectedKind: 'melee' }, // Double Swing L20 { id: 96, level: 1, expectedKind: 'melee' }, // Sacrifice { id: 98, level: 1, expectedKind: 'aura' }, // Might { id: 37, level: 1, expectedKind: 'passive' }, // Warmth { id: 224, level: 1, expectedKind: 'passive' }, // Lycanthropy { id: 252, level: 1, expectedKind: 'passive' }, // Claw Mastery ] for (const item of zeroCostSkills) { const player = makeTestPlayer({ mana: 0 }) const res = executeSkill(item.id, { caster: player, skillLevel: item.level }) expect(res.kind).toBe(item.expectedKind) expect(player.mana).toBe(0) } }) }) // ========================================================================== // Suite 5: deductResources: false Behavior // ========================================================================== describe('Suite 5: deductResources: false Behavior', () => { it('executes skills across all archetypes without mutating caster mana or cooldown when deductResources: false', () => { const testCases: readonly { readonly id: number; readonly expectedKind: SkillType }[] = [ { id: 36, expectedKind: 'projectile' }, // Fire Bolt { id: 10, expectedKind: 'melee' }, // Jab { id: 44, expectedKind: 'area' }, // Frost Nova { id: 68, expectedKind: 'buff' }, // Bone Armor { id: 66, expectedKind: 'curse' }, // Amplify Damage { id: 54, expectedKind: 'movement' }, // Teleport { id: 70, expectedKind: 'summon' }, // Raise Skeleton { id: 98, expectedKind: 'aura' }, // Might { id: 37, expectedKind: 'passive' }, // Warmth { id: 223, expectedKind: 'buff' }, // Werewolf (has 25 cooldown) { id: 225, expectedKind: 'area' }, // Firestorm (has 15 cooldown) { id: 229, expectedKind: 'projectile' },// Molten Boulder (has 50 cooldown) ] for (const tc of testCases) { const player = makeTestPlayer({ mana: 100, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } const res = executeSkill(tc.id, ctx, { deductResources: false }) expect(res.kind).toBe(tc.expectedKind) // Invariant: mana and cooldown must NOT be mutated expect(player.mana).toBe(100) expect(player.cooldown).toBe(0) } }) it('still enforces resource requirements even when deductResources: false is supplied', () => { // 1. Mana requirement check: Player with insufficient mana cannot cast even with deductResources: false const playerLowMana = makeTestPlayer({ mana: 10 }) // Teleport needs 24 mana const ctxMana: SkillExecutionContext = { caster: playerLowMana, skillLevel: 1 } const resMana = executeSkill(54, ctxMana, { deductResources: false }) expect(resMana.kind).toBe('mana') if (resMana.kind === 'mana') { expect(resMana.cost).toBe(24) expect(resMana.currentMana).toBe(10) } expect(playerLowMana.mana).toBe(10) // 2. Cooldown requirement check: Player with active cooldown cannot cast delay skill even with deductResources: false const playerOnCd = makeTestPlayer({ mana: 100, cooldown: 10 }) const ctxCd: SkillExecutionContext = { caster: playerOnCd, skillLevel: 1 } const resCd = executeSkill(223, ctxCd, { deductResources: false }) // Werewolf delay 25 expect(resCd.kind).toBe('cooldown') if (resCd.kind === 'cooldown') { expect(resCd.ticksLeft).toBe(10) } expect(playerOnCd.cooldown).toBe(10) }) it('mutates player mana and cooldown when deductResources is omitted or true', () => { const player1 = makeTestPlayer({ mana: 100, cooldown: 0 }) const res1 = executeSkill(223, { caster: player1, skillLevel: 1 }) // Werewolf (15 mana, 25 cooldown) expect(res1.kind).toBe('buff') expect(player1.mana).toBe(85) expect(player1.cooldown).toBe(25) const player2 = makeTestPlayer({ mana: 100, cooldown: 0 }) const res2 = executeSkill(223, { caster: player2, skillLevel: 1 }, { deductResources: true }) expect(res2.kind).toBe('buff') expect(player2.mana).toBe(85) expect(player2.cooldown).toBe(25) }) }) // ========================================================================== // Suite 6: Cooldown Timing & Casting Lockout Simulation (25fps Engine Clock) // ========================================================================== describe('Suite 6: Cooldown Timing: Exact 25fps Ticks and Casting Lockout', () => { it('verifies exact 1.13c casting delay frame ticks for all 38 skills', () => { const expectedDelays: Record = { 223: 25, // Werewolf: 25 frames (1.0 sec) 225: 15, // Firestorm: 15 frames (0.6 sec) 229: 50, // Molten Boulder: 50 frames (2.0 sec) } for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) { const id = Number(idStr) const expected = expectedDelays[id] ?? 0 expect(getSkillCooldownTicks(id)).toBe(expected) expect(def.delay).toBe(expected) } }) it('simulates full 25fps tick-down lifecycle for Werewolf (25 frames)', () => { const player = makeTestPlayer({ mana: 100, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Frame 0: Cast Werewolf -> sets cooldown = 25 const initialCast = executeSkill(223, ctx) expect(initialCast.kind).toBe('buff') expect(player.cooldown).toBe(25) // Simulate 25 individual game loop frames at 25 fps for (let remaining = 25; remaining >= 1; remaining--) { expect(player.cooldown).toBe(remaining) // Attempting to cast Werewolf while cooldown > 0 must be locked out const blocked = executeSkill(223, ctx) expect(blocked.kind).toBe('cooldown') if (blocked.kind === 'cooldown') { expect(blocked.ticksLeft).toBe(remaining) } // Non-cooldown skills (Fire Bolt, Double Swing, Teleport) must NOT be locked out! const nonCd = executeSkill(36, ctx) expect(nonCd.kind).toBe('projectile') // Engine tick decrements cooldown by 1 player.cooldown = Math.max(0, player.cooldown - 1) } // At frame 25 after 25 decrements: cooldown is exactly 0 expect(player.cooldown).toBe(0) // Now Werewolf can be cast again successfully const secondCast = executeSkill(223, ctx) expect(secondCast.kind).toBe('buff') expect(player.cooldown).toBe(25) }) it('simulates full 25fps tick-down lifecycle for Firestorm (15 frames)', () => { const player = makeTestPlayer({ mana: 100, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } const cast = executeSkill(225, ctx) expect(cast.kind).toBe('area') expect(player.cooldown).toBe(15) for (let remaining = 15; remaining >= 1; remaining--) { expect(player.cooldown).toBe(remaining) const blocked = executeSkill(225, ctx) expect(blocked.kind).toBe('cooldown') if (blocked.kind === 'cooldown') { expect(blocked.ticksLeft).toBe(remaining) } player.cooldown = Math.max(0, player.cooldown - 1) } expect(player.cooldown).toBe(0) expect(executeSkill(225, ctx).kind).toBe('area') }) it('simulates full 25fps tick-down lifecycle for Molten Boulder (50 frames)', () => { const player = makeTestPlayer({ mana: 100, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } const cast = executeSkill(229, ctx) expect(cast.kind).toBe('projectile') expect(player.cooldown).toBe(50) for (let remaining = 50; remaining >= 1; remaining--) { expect(player.cooldown).toBe(remaining) const blocked = executeSkill(229, ctx) expect(blocked.kind).toBe('cooldown') if (blocked.kind === 'cooldown') { expect(blocked.ticksLeft).toBe(remaining) } player.cooldown = Math.max(0, player.cooldown - 1) } expect(player.cooldown).toBe(0) expect(executeSkill(229, ctx).kind).toBe('projectile') }) it('enforces shared casting delay lockout across different delay skills', () => { const player = makeTestPlayer({ mana: 100, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Cast Firestorm (15 frame delay) executeSkill(225, ctx) expect(player.cooldown).toBe(15) // Werewolf (delay 25) and Molten Boulder (delay 50) must BOTH be locked out by the 15-frame active cooldown const ww = executeSkill(223, ctx) expect(ww.kind).toBe('cooldown') if (ww.kind === 'cooldown') expect(ww.ticksLeft).toBe(15) const mb = executeSkill(229, ctx) expect(mb.kind).toBe('cooldown') if (mb.kind === 'cooldown') expect(mb.ticksLeft).toBe(15) // Meanwhile, non-cooldown skills can still be cast const zeal = executeSkill(106, ctx) expect(zeal.kind).toBe('melee') }) }) // ========================================================================== // Suite 7: Comprehensive Input Hardening across skill-tree & skills // ========================================================================== describe('Suite 7: Input Hardening Against NaN, null, undefined, negatives, and infinities', () => { const adversarialInputs = [ NaN, null as any, undefined as any, -1, -100, Infinity, -Infinity, 'invalid' as any, {}, [], ] describe('createSkillTreeState hardening', () => { it('safely normalizes any adversarial input to a non-negative integer pool', () => { for (const input of adversarialInputs) { const state = createSkillTreeState(input) expect(Number.isInteger(state.unspentPoints)).toBe(true) expect(Number.isFinite(state.unspentPoints)).toBe(true) expect(state.unspentPoints).toBeGreaterThanOrEqual(0) expect(state.hardPoints).toEqual({}) } // Float points are floored expect(createSkillTreeState(5.9).unspentPoints).toBe(5) expect(createSkillTreeState(0.9).unspentPoints).toBe(0) }) }) describe('canAllocate & allocatePoint hardening', () => { it('safely rejects invalid skillIds without throwing', () => { const state = createSkillTreeState(10) for (const input of adversarialInputs) { expect(canAllocate(CANONICAL_SKILL_TREE, state, input, 30)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, input, 30)).toBe(false) expect(state.unspentPoints).toBe(10) } }) it('safely rejects invalid playerLevel values without throwing', () => { const state = createSkillTreeState(10) const invalidLevels = [NaN, null as any, undefined as any, -1, -50, -Infinity, 'abc' as any] for (const lvl of invalidLevels) { expect(canAllocate(CANONICAL_SKILL_TREE, state, 36, lvl)).toBe(false) expect(allocatePoint(CANONICAL_SKILL_TREE, state, 36, lvl)).toBe(false) expect(state.unspentPoints).toBe(10) } }) it('safely rejects when state unspentPoints is corrupted', () => { const corruptedState1: SkillTreeState = { hardPoints: {}, unspentPoints: NaN } expect(canAllocate(CANONICAL_SKILL_TREE, corruptedState1, 36, 30)).toBe(false) const corruptedState2: SkillTreeState = { hardPoints: {}, unspentPoints: -5 } expect(canAllocate(CANONICAL_SKILL_TREE, corruptedState2, 36, 30)).toBe(false) const corruptedState3: SkillTreeState = { hardPoints: {}, unspentPoints: null as any } expect(canAllocate(CANONICAL_SKILL_TREE, corruptedState3, 36, 30)).toBe(false) }) }) describe('canDeallocate & deallocatePoint hardening', () => { it('safely rejects invalid skillIds without throwing', () => { const state = createSkillTreeState(10) state.hardPoints[36] = 5 for (const input of adversarialInputs) { expect(canDeallocate(CANONICAL_SKILL_TREE, state, input)).toBe(false) expect(deallocatePoint(CANONICAL_SKILL_TREE, state, input)).toBe(false) } }) it('safely rejects corrupted hardPoints values without throwing', () => { const state = createSkillTreeState(10) state.hardPoints[36] = NaN as any expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(false) state.hardPoints[36] = -1 expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(false) state.hardPoints[36] = null as any expect(canDeallocate(CANONICAL_SKILL_TREE, state, 36)).toBe(false) }) }) describe('respec hardening', () => { it('resets allocated hardPoints and refunds points cleanly', () => { const state = createSkillTreeState(5) state.hardPoints[36] = 10 state.hardPoints[47] = 5 const refunded = respec(state) expect(refunded).toBe(15) expect(state.unspentPoints).toBe(20) expect(state.hardPoints[36]).toBe(0) expect(state.hardPoints[47]).toBe(0) }) }) describe('calculateSynergyMultiplier hardening', () => { it('returns 1.0 (no bonus) for invalid skillIds and damageTypes without throwing', () => { const state = createSkillTreeState(10) for (const input of adversarialInputs) { expect(calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, input)).toBe(1.0) expect(calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36, input)).toBe(1.0) } }) it('returns finite multiplier >= 1.0 even when hardPoints contains corrupted entries', () => { const state = createSkillTreeState(10) state.hardPoints[47] = NaN as any state.hardPoints[56] = -10 as any const mult = calculateSynergyMultiplier(CANONICAL_SKILL_TREE, state, 36) expect(typeof mult).toBe('number') expect(Number.isFinite(mult)).toBe(true) expect(mult).toBeGreaterThanOrEqual(1.0) }) }) describe('getSkillManaCost hardening', () => { it('handles adversarial skillIds and effectiveLevels safely', () => { for (const input of adversarialInputs) { const c1 = getSkillManaCost(input, 1) expect(c1).toBe(0) const c2 = getSkillManaCost(36, input) expect(typeof c2).toBe('number') expect(Number.isFinite(c2)).toBe(true) expect(c2).toBeGreaterThanOrEqual(0) } }) }) describe('getSkillCooldownTicks hardening', () => { it('handles adversarial skillIds safely', () => { for (const input of adversarialInputs) { expect(getSkillCooldownTicks(input)).toBe(0) } }) }) describe('calculateSkillDamage hardening', () => { it('handles adversarial skillIds, levels, and synergy multipliers safely', () => { for (const input of adversarialInputs) { const d1 = calculateSkillDamage(input, 1) expect(d1).toEqual({ min: 0, max: 0 }) const d2 = calculateSkillDamage(36, input) expect(Number.isFinite(d2.min)).toBe(true) expect(Number.isFinite(d2.max)).toBe(true) expect(d2.min).toBeGreaterThanOrEqual(0) expect(d2.max).toBeGreaterThanOrEqual(0) const d3 = calculateSkillDamage(36, 1, input) expect(Number.isFinite(d3.min)).toBe(true) expect(Number.isFinite(d3.max)).toBe(true) expect(d3.min).toBeGreaterThanOrEqual(0) expect(d3.max).toBeGreaterThanOrEqual(0) } }) it('safely handles negative weapon damage without producing negative output', () => { const dmg = calculateSkillDamage(10, 1, 1.0, { min: -50, max: -20 }) expect(dmg.min).toBeGreaterThanOrEqual(0) expect(dmg.max).toBeGreaterThanOrEqual(0) }) }) describe('executeSkill hardening', () => { it('safely handles unknown skill IDs without throwing', () => { const player = makeTestPlayer() for (const input of adversarialInputs) { const res = executeSkill(input, { caster: player, skillLevel: 1 }) expect(res).toBeDefined() expect(res.kind).toBe('melee') } }) it('safely handles zero-distance target without generating NaN velocities', () => { const player = makeTestPlayer({ x: 250, y: 350 }) const res = executeSkill(36, { caster: player, target: { x: 250, y: 350 }, // Exact same spot skillLevel: 1, }) expect(res.kind).toBe('projectile') if (res.kind === 'projectile') { expect(Number.isFinite(res.projectile.vx)).toBe(true) expect(Number.isFinite(res.projectile.vy)).toBe(true) expect(Number.isNaN(res.projectile.vx)).toBe(false) expect(Number.isNaN(res.projectile.vy)).toBe(false) } }) it('safely handles invalid caster facing without throwing', () => { const invalidFacings = [-1, 8, 99, NaN as any] for (const facing of invalidFacings) { const player = makeTestPlayer({ facing }) const res = executeSkill(36, { caster: player, skillLevel: 1 }) expect(res.kind).toBe('projectile') if (res.kind === 'projectile') { expect(Number.isFinite(res.projectile.vx)).toBe(true) expect(Number.isFinite(res.projectile.vy)).toBe(true) } } }) }) }) })