757 lines
31 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|