diablo2-web/tests/e2e-m11-adversarial.test.ts

757 lines
31 KiB
TypeScript

/**
* 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>): 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)
})
})
})