734 lines
29 KiB
TypeScript
734 lines
29 KiB
TypeScript
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>): 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): 1.13c Skills.txt: mana 32, lvlmana 1, manashift 4 (shift 0.0625) -> (32 + 1*(lvl-1))*0.0625
|
|
expect(getSkillManaCost(101, 1)).toBe(2.0)
|
|
expect(getSkillManaCost(101, 9)).toBe(2.5)
|
|
expect(getSkillManaCost(101, 17)).toBe(3.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): 1.13c Skills.txt: mana 24, lvlmana 1, manashift 5 (shift 0.125) -> (24 + 1*(lvl-1))*0.125
|
|
expect(getSkillManaCost(251, 1)).toBe(3.0)
|
|
expect(getSkillManaCost(251, 2)).toBe(3.125)
|
|
expect(getSkillManaCost(251, 5)).toBe(3.5)
|
|
expect(getSkillManaCost(251, 10)).toBe(4.125)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 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<number, number> = {
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|