689 lines
28 KiB
TypeScript
689 lines
28 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Druid Skills Adversarial Stress Testing Harness
|
|
*
|
|
* Dedicated Challenger Suite for Cohort 6 Iteration 2:
|
|
* 1. Cyclone Armor Poison Leak & Multi-Elemental Stress Testing
|
|
* 2. Shapeshifting Form Exclusivity & Pre-cast Guards Exhaustive Matrix
|
|
* 3. Armageddon & Hurricane Persistent Aura States & Duration Scaling
|
|
* 4. Melee Charge Progression & Contagion Mechanics
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
|
|
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
|
|
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
|
|
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { executeSkill113c, evaluateSkill113c } from '../../../src/game/skills/registry.ts'
|
|
import {
|
|
canCastDruidSkill,
|
|
calculateFeralRageStats,
|
|
calculateMaulStats,
|
|
calculateRabiesStats,
|
|
advanceChargeOrb,
|
|
simulateRabiesContagion,
|
|
DRUID_SHAPESHIFT_SKILL_IDS,
|
|
} from '../../../src/game/skills/druid-shapeshift.ts'
|
|
import {
|
|
calculateArmageddonStats,
|
|
calculateHurricaneStats,
|
|
} from '../../../src/game/skills/druid-elemental.ts'
|
|
|
|
function createTestUnit(
|
|
id: string,
|
|
name: string,
|
|
unitType: 'player' | 'monster',
|
|
maxHp = 2000,
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList()
|
|
statList.setBaseStat('maxhp', maxHp << 8)
|
|
statList.setHp256(maxHp << 8)
|
|
const stateBus = new StateBus(statList)
|
|
return {
|
|
id,
|
|
name,
|
|
unitType,
|
|
statList,
|
|
stateBus,
|
|
}
|
|
}
|
|
|
|
describe('Challenger Adversarial Stress Testing — Druid Elemental & Shapeshifting Invariants', () => {
|
|
// =========================================================================
|
|
// Dimension 1: Cyclone Armor Poison Leak & Multi-Elemental Stress Testing
|
|
// =========================================================================
|
|
describe('Dimension 1: Cyclone Armor Invariants & Differential Fuzzing', () => {
|
|
it('1.1 Cyclone Armor Poison Leak: Poison completely bypasses pool across 50 random damage/pool configurations', () => {
|
|
// Differential property test:
|
|
// For any incoming poison damage and any shield pool size:
|
|
// - Shield pool MUST remain completely untouched.
|
|
// - Defender HP MUST decrease by the exact poison damage amount (net of resistance).
|
|
for (let seed = 1; seed <= 50; seed++) {
|
|
const initialHp = 5000
|
|
const defender = createTestUnit(`def_${seed}`, 'DruidDefender', 'player', initialHp)
|
|
const attacker = createTestUnit(`att_${seed}`, 'Attacker', 'monster')
|
|
|
|
const poolSize = 50 + ((seed * 37) % 500) // 50 .. 549
|
|
const poisonDmg = 10 + ((seed * 61) % 400) // 10 .. 409
|
|
|
|
defender.stateBus.applyState({
|
|
stateNameOrId: 'cyclonearmor',
|
|
slvl: 10,
|
|
stats: { magicarmor: poolSize },
|
|
})
|
|
|
|
const outcome = executeSUnitDmg(attacker, defender, {
|
|
skillId: 92, // Poison Nova
|
|
elemType: 'pois',
|
|
elemMin256: poisonDmg << 8,
|
|
elemMax256: poisonDmg << 8,
|
|
isPoison: true,
|
|
autoHit: true,
|
|
})
|
|
|
|
const armorState = defender.stateBus.getState('cyclonearmor')
|
|
expect(armorState, `Cyclone Armor should still be active for seed ${seed}`).toBeDefined()
|
|
expect(
|
|
armorState?.stats['magicarmor'],
|
|
`Cyclone Armor pool must NOT be depleted by poison damage (seed ${seed})`,
|
|
).toBe(poolSize)
|
|
|
|
const hpLost = initialHp - (defender.statList.getHp256() >> 8)
|
|
expect(hpLost, `Defender HP must take direct poison damage (seed ${seed})`).toBe(poisonDmg)
|
|
expect(outcome.totalDamage).toBe(poisonDmg)
|
|
}
|
|
})
|
|
|
|
it('1.2 Elemental Absorption: Fire, Cold, Lightning damage are absorbed; Physical & Magic bypass', () => {
|
|
const elements: Array<{
|
|
elem: 'fire' | 'cold' | 'ltng' | 'lightning' | 'phys' | 'mag'
|
|
isPoison?: boolean
|
|
shouldAbsorb: boolean
|
|
}> = [
|
|
{ elem: 'fire', shouldAbsorb: true },
|
|
{ elem: 'cold', shouldAbsorb: true },
|
|
{ elem: 'ltng', shouldAbsorb: true },
|
|
{ elem: 'lightning', shouldAbsorb: true },
|
|
{ elem: 'phys', shouldAbsorb: false },
|
|
{ elem: 'mag', shouldAbsorb: false },
|
|
{ elem: 'fire', isPoison: true, shouldAbsorb: false }, // if flagged poison, bypass
|
|
]
|
|
|
|
for (const tc of elements) {
|
|
const initialHp = 2000
|
|
const defender = createTestUnit('def_el', 'DruidDefender', 'player', initialHp)
|
|
const attacker = createTestUnit('att_el', 'Attacker', 'monster')
|
|
|
|
const initialPool = 300
|
|
const incomingDmg = 120
|
|
|
|
defender.stateBus.applyState({
|
|
stateNameOrId: 'cyclonearmor',
|
|
slvl: 10,
|
|
stats: { magicarmor: initialPool },
|
|
})
|
|
|
|
const outcome = executeSUnitDmg(attacker, defender, {
|
|
skillId: tc.elem === 'phys' ? 0 : 50,
|
|
...(tc.elem === 'phys'
|
|
? { flatPhysMin256: incomingDmg << 8, flatPhysMax256: incomingDmg << 8 }
|
|
: {
|
|
elemType: tc.elem,
|
|
elemMin256: incomingDmg << 8,
|
|
elemMax256: incomingDmg << 8,
|
|
...(tc.isPoison ? { isPoison: true } : {}),
|
|
}),
|
|
autoHit: true,
|
|
})
|
|
|
|
const armorState = defender.stateBus.getState('cyclonearmor')
|
|
if (tc.shouldAbsorb) {
|
|
expect(
|
|
armorState?.stats['magicarmor'],
|
|
`${tc.elem} must reduce Cyclone Armor pool`,
|
|
).toBe(initialPool - incomingDmg)
|
|
expect(defender.statList.getHp256() >> 8, `${tc.elem} should NOT reduce HP when fully absorbed`).toBe(initialHp)
|
|
expect(outcome.totalDamage).toBe(0)
|
|
} else {
|
|
expect(
|
|
armorState?.stats['magicarmor'],
|
|
`${tc.elem} must NOT reduce Cyclone Armor pool`,
|
|
).toBe(initialPool)
|
|
expect(defender.statList.getHp256() >> 8, `${tc.elem} must deal direct damage to defender HP`).toBe(initialHp - incomingDmg)
|
|
expect(outcome.totalDamage).toBe(incomingDmg)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('1.3 Overflow Absorption: Elemental damage exceeding pool dissipates shield and leaks overflow to HP', () => {
|
|
const initialHp = 1000
|
|
const defender = createTestUnit('def_ov', 'DruidDefender', 'player', initialHp)
|
|
const attacker = createTestUnit('att_ov', 'Attacker', 'monster')
|
|
|
|
const initialPool = 100
|
|
const incomingFireDmg = 250 // 100 absorbed, 150 overflow
|
|
|
|
defender.stateBus.applyState({
|
|
stateNameOrId: 'cyclonearmor',
|
|
slvl: 10,
|
|
stats: { magicarmor: initialPool },
|
|
})
|
|
|
|
const outcome = executeSUnitDmg(attacker, defender, {
|
|
skillId: 50,
|
|
elemType: 'fire',
|
|
elemMin256: incomingFireDmg << 8,
|
|
elemMax256: incomingFireDmg << 8,
|
|
autoHit: true,
|
|
})
|
|
|
|
// Armor pool depleted to 0 -> state removed!
|
|
expect(defender.stateBus.hasState('cyclonearmor')).toBe(false)
|
|
// Overflow of 150 damage deals HP damage (1000 - 150 = 850)
|
|
expect(defender.statList.getHp256() >> 8).toBe(850)
|
|
expect(outcome.totalDamage).toBe(150)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 2: Shapeshifting Form Exclusivity & Pre-cast Guards
|
|
// =========================================================================
|
|
describe('Dimension 2: Shapeshifting Form Exclusivity & Pre-cast Guards Matrix', () => {
|
|
it('2.1 Exhaustive Matrix: all 18 human-only Druid skills fail-fast in Wolf and Bear forms with invalid_form', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(reg)
|
|
const summonManager = new SummonManager(reg)
|
|
const auraScanner = new AuraScanner(reg)
|
|
|
|
const humanOnlySkills = [
|
|
// 10 Summons
|
|
221, 222, 226, 227, 231, 236, 237, 241, 246, 247,
|
|
// 8 Elemental Spells
|
|
225, 229, 230, 234, 235, 240, 244, 245,
|
|
]
|
|
|
|
for (const skillId of humanOnlySkills) {
|
|
const skill = reg.getSkillById(skillId)!
|
|
|
|
// Wolf form test
|
|
const casterWolf = createTestUnit(`wolf_${skillId}`, 'WolfDruid', 'player')
|
|
casterWolf.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
|
|
const evalWolf = evaluateSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
slvl: 10,
|
|
blvl: 10,
|
|
statList: casterWolf.statList,
|
|
stateBus: casterWolf.stateBus,
|
|
})
|
|
const outWolf = executeSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
evalResult: evalWolf,
|
|
caster: casterWolf,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 0,
|
|
targetX: 10,
|
|
targetY: 10,
|
|
})
|
|
expect(outWolf.executed, `Skill ${skillId} (${skill.name}) must NOT execute in wolf form`).toBe(false)
|
|
expect(outWolf.error, `Skill ${skillId} (${skill.name}) must return error: 'invalid_form' in wolf form`).toBe('invalid_form')
|
|
|
|
// Bear form test
|
|
const casterBear = createTestUnit(`bear_${skillId}`, 'BearDruid', 'player')
|
|
casterBear.stateBus.applyState({ stateNameOrId: 'bear', slvl: 10, stats: {} })
|
|
const evalBear = evaluateSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
slvl: 10,
|
|
blvl: 10,
|
|
statList: casterBear.statList,
|
|
stateBus: casterBear.stateBus,
|
|
})
|
|
const outBear = executeSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
evalResult: evalBear,
|
|
caster: casterBear,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 0,
|
|
targetX: 10,
|
|
targetY: 10,
|
|
})
|
|
expect(outBear.executed, `Skill ${skillId} (${skill.name}) must NOT execute in bear form`).toBe(false)
|
|
expect(outBear.error, `Skill ${skillId} (${skill.name}) must return error: 'invalid_form' in bear form`).toBe('invalid_form')
|
|
|
|
// Human form test: should execute without invalid_form error
|
|
const casterHuman = createTestUnit(`hum_${skillId}`, 'HumanDruid', 'player')
|
|
const evalHuman = evaluateSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
slvl: 10,
|
|
blvl: 10,
|
|
statList: casterHuman.statList,
|
|
stateBus: casterHuman.stateBus,
|
|
})
|
|
const outHuman = executeSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
evalResult: evalHuman,
|
|
caster: casterHuman,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 0,
|
|
targetX: 10,
|
|
targetY: 10,
|
|
})
|
|
expect(outHuman.error, `Skill ${skillId} (${skill.name}) in human form must NOT return invalid_form`).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('2.2 Armageddon (#249) and Hurricane (#250) execute successfully in Wolf, Bear, and Human forms', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(reg)
|
|
const auraScanner = new AuraScanner(reg)
|
|
|
|
const stormSkills = [249, 250]
|
|
const forms: Array<'human' | 'wolf' | 'bear'> = ['human', 'wolf', 'bear']
|
|
|
|
for (const skillId of stormSkills) {
|
|
const skill = reg.getSkillById(skillId)!
|
|
for (const form of forms) {
|
|
const caster = createTestUnit(`caster_${skillId}_${form}`, `Druid_${form}`, 'player')
|
|
if (form !== 'human') {
|
|
caster.stateBus.applyState({ stateNameOrId: form, slvl: 10, stats: {} })
|
|
}
|
|
|
|
const evalRes = evaluateSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
slvl: 10,
|
|
blvl: 10,
|
|
statList: caster.statList,
|
|
stateBus: caster.stateBus,
|
|
})
|
|
const out = executeSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
evalResult: evalRes,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager: null as any,
|
|
currentTick: 0,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(out.executed, `Skill ${skillId} must execute in ${form} form`).toBe(true)
|
|
expect(out.error).toBeUndefined()
|
|
const expectedState = skillId === 249 ? 'armageddon' : 'hurricane'
|
|
expect(caster.stateBus.hasState(expectedState)).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2.3 Form-exclusive attacks: Werewolf attacks blocked in Bear/Human; Werebear attacks blocked in Wolf/Human', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(reg)
|
|
const target = createTestUnit('target', 'Enemy', 'monster')
|
|
|
|
const wolfOnlySkills = [
|
|
DRUID_SHAPESHIFT_SKILL_IDS.FERAL_RAGE, // 232
|
|
DRUID_SHAPESHIFT_SKILL_IDS.RABIES, // 238
|
|
DRUID_SHAPESHIFT_SKILL_IDS.FURY, // 248
|
|
]
|
|
|
|
const bearOnlySkills = [
|
|
DRUID_SHAPESHIFT_SKILL_IDS.MAUL, // 233
|
|
DRUID_SHAPESHIFT_SKILL_IDS.SHOCK_WAVE, // 243
|
|
]
|
|
|
|
// Test wolf-only skills
|
|
for (const skillId of wolfOnlySkills) {
|
|
const skill = reg.getSkillById(skillId)!
|
|
|
|
// Blocked in Human
|
|
const humanCaster = createTestUnit('h', 'Human', 'player')
|
|
const evalH = evaluateSkill113c({ registry: reg, skill, slvl: 10, blvl: 10, statList: humanCaster.statList, stateBus: humanCaster.stateBus })
|
|
const outH = executeSkill113c({
|
|
registry: reg, skill, evalResult: evalH, caster: humanCaster, targets: [target], targetPositions: new Map(), corpses: [],
|
|
missileEngine, auraScanner: null as any, summonManager: null as any, currentTick: 0, targetX: 0, targetY: 0,
|
|
})
|
|
expect(outH.executed).toBe(false)
|
|
expect(outH.error).toBe('invalid_form')
|
|
|
|
// Blocked in Bear
|
|
const bearCaster = createTestUnit('b', 'Bear', 'player')
|
|
bearCaster.stateBus.applyState({ stateNameOrId: 'bear', slvl: 10, stats: {} })
|
|
const evalB = evaluateSkill113c({ registry: reg, skill, slvl: 10, blvl: 10, statList: bearCaster.statList, stateBus: bearCaster.stateBus })
|
|
const outB = executeSkill113c({
|
|
registry: reg, skill, evalResult: evalB, caster: bearCaster, targets: [target], targetPositions: new Map(), corpses: [],
|
|
missileEngine, auraScanner: null as any, summonManager: null as any, currentTick: 0, targetX: 0, targetY: 0,
|
|
})
|
|
expect(outB.executed).toBe(false)
|
|
expect(outB.error).toBe('invalid_form')
|
|
}
|
|
|
|
// Test bear-only skills
|
|
for (const skillId of bearOnlySkills) {
|
|
const skill = reg.getSkillById(skillId)!
|
|
|
|
// Blocked in Human
|
|
const humanCaster = createTestUnit('h', 'Human', 'player')
|
|
const evalH = evaluateSkill113c({ registry: reg, skill, slvl: 10, blvl: 10, statList: humanCaster.statList, stateBus: humanCaster.stateBus })
|
|
const outH = executeSkill113c({
|
|
registry: reg, skill, evalResult: evalH, caster: humanCaster, targets: [target], targetPositions: new Map(), corpses: [],
|
|
missileEngine, auraScanner: null as any, summonManager: null as any, currentTick: 0, targetX: 0, targetY: 0,
|
|
})
|
|
expect(outH.executed).toBe(false)
|
|
expect(outH.error).toBe('invalid_form')
|
|
|
|
// Blocked in Wolf
|
|
const wolfCaster = createTestUnit('w', 'Wolf', 'player')
|
|
wolfCaster.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
|
|
const evalW = evaluateSkill113c({ registry: reg, skill, slvl: 10, blvl: 10, statList: wolfCaster.statList, stateBus: wolfCaster.stateBus })
|
|
const outW = executeSkill113c({
|
|
registry: reg, skill, evalResult: evalW, caster: wolfCaster, targets: [target], targetPositions: new Map(), corpses: [],
|
|
missileEngine, auraScanner: null as any, summonManager: null as any, currentTick: 0, targetX: 0, targetY: 0,
|
|
})
|
|
expect(outW.executed).toBe(false)
|
|
expect(outW.error).toBe('invalid_form')
|
|
}
|
|
})
|
|
|
|
it('2.4 Cross-morphing: Werewolf to Werebear cleanly evicts wolf and applies bear state with bear buffs', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const caster = createTestUnit('dru_morph', 'MorphDruid', 'player')
|
|
|
|
// Start in Werewolf
|
|
const skillWolf = reg.getSkillById(223)!
|
|
const evalWolf = evaluateSkill113c({ registry: reg, skill: skillWolf, slvl: 5, blvl: 5, statList: caster.statList, stateBus: caster.stateBus })
|
|
executeSkill113c({
|
|
registry: reg, skill: skillWolf, evalResult: evalWolf, caster, targets: [], targetPositions: new Map(), corpses: [],
|
|
missileEngine: null as any, auraScanner: null as any, summonManager: null as any, currentTick: 0, targetX: 0, targetY: 0,
|
|
})
|
|
|
|
expect(caster.stateBus.hasState('wolf')).toBe(true)
|
|
expect(caster.stateBus.hasState('bear')).toBe(false)
|
|
|
|
// Cross-morph directly to Werebear (#228)
|
|
const skillBear = reg.getSkillById(228)!
|
|
const evalBear = evaluateSkill113c({ registry: reg, skill: skillBear, slvl: 10, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
|
|
const outBear = executeSkill113c({
|
|
registry: reg, skill: skillBear, evalResult: evalBear, caster, targets: [], targetPositions: new Map(), corpses: [],
|
|
missileEngine: null as any, auraScanner: null as any, summonManager: null as any, currentTick: 100, targetX: 0, targetY: 0,
|
|
})
|
|
|
|
expect(outBear.executed).toBe(true)
|
|
expect(caster.stateBus.hasState('wolf'), 'Wolf state must be evicted').toBe(false)
|
|
expect(caster.stateBus.hasState('bear'), 'Bear state must be active').toBe(true)
|
|
const bearState = caster.stateBus.getState('bear')
|
|
expect(bearState?.stats['damagepercent']).toBe(55 + (10 - 1) * 8)
|
|
expect(bearState?.stats['item_armor_percent']).toBe(25 + (10 - 1) * 6)
|
|
|
|
// Morph back to Werewolf (#223)
|
|
const outWolf2 = executeSkill113c({
|
|
registry: reg, skill: skillWolf, evalResult: evalWolf, caster, targets: [], targetPositions: new Map(), corpses: [],
|
|
missileEngine: null as any, auraScanner: null as any, summonManager: null as any, currentTick: 200, targetX: 0, targetY: 0,
|
|
})
|
|
expect(outWolf2.executed).toBe(true)
|
|
expect(caster.stateBus.hasState('bear'), 'Bear state must be evicted').toBe(false)
|
|
expect(caster.stateBus.hasState('wolf'), 'Wolf state must be active').toBe(true)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 3: Armageddon & Hurricane Persistent Aura States
|
|
// =========================================================================
|
|
describe('Dimension 3: Armageddon & Hurricane Persistent Aura States', () => {
|
|
it('3.1 Armageddon (#249) attaches armageddon state with scaling duration and records in statesApplied', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(reg)
|
|
const auraScanner = new AuraScanner(reg)
|
|
|
|
const caster = createTestUnit('dru_arma', 'ArmaDruid', 'player')
|
|
const skill249 = reg.getSkillById(249)!
|
|
|
|
// Test at slvl 15
|
|
const eval249 = evaluateSkill113c({
|
|
registry: reg,
|
|
skill: skill249,
|
|
slvl: 15,
|
|
blvl: 15,
|
|
statList: caster.statList,
|
|
stateBus: caster.stateBus,
|
|
})
|
|
|
|
const out = executeSkill113c({
|
|
registry: reg,
|
|
skill: skill249,
|
|
evalResult: eval249,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager: null as any,
|
|
currentTick: 50,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(out.executed).toBe(true)
|
|
expect(out.statesApplied).toContain('armageddon')
|
|
expect(caster.stateBus.hasState('armageddon')).toBe(true)
|
|
|
|
const armaState = caster.stateBus.getState('armageddon')!
|
|
expect(armaState.sourceSkillId).toBe(249)
|
|
expect(armaState.durationFrames).toBeGreaterThanOrEqual(250)
|
|
})
|
|
|
|
it('3.2 Hurricane (#250) attaches hurricane state with scaling duration and records in statesApplied', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(reg)
|
|
const auraScanner = new AuraScanner(reg)
|
|
|
|
const caster = createTestUnit('dru_hurr', 'HurrDruid', 'player')
|
|
const skill250 = reg.getSkillById(250)!
|
|
|
|
const eval250 = evaluateSkill113c({
|
|
registry: reg,
|
|
skill: skill250,
|
|
slvl: 20,
|
|
blvl: 20,
|
|
statList: caster.statList,
|
|
stateBus: caster.stateBus,
|
|
})
|
|
|
|
const out = executeSkill113c({
|
|
registry: reg,
|
|
skill: skill250,
|
|
evalResult: eval250,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager: null as any,
|
|
currentTick: 100,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(out.executed).toBe(true)
|
|
expect(out.statesApplied).toContain('hurricane')
|
|
expect(caster.stateBus.hasState('hurricane')).toBe(true)
|
|
|
|
const hurrState = caster.stateBus.getState('hurricane')!
|
|
expect(hurrState.sourceSkillId).toBe(250)
|
|
expect(hurrState.durationFrames).toBeGreaterThanOrEqual(250)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 4: Melee Charge Progression & Contagion
|
|
// =========================================================================
|
|
describe('Dimension 4: Melee Charge Progression & Contagion Mechanics', () => {
|
|
it('4.1 Feral Rage (#232): successive hits advance charges 1..3 with stats and 20.0s decay timer; capped at 3', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const caster = createTestUnit('dru_fr', 'WolfDruid', 'player')
|
|
caster.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
|
|
const target = createTestUnit('target_fr', 'TargetMonster', 'monster', 5000)
|
|
|
|
const skill232 = reg.getSkillById(232)!
|
|
const slvl = 10
|
|
const eval232 = evaluateSkill113c({ registry: reg, skill: skill232, slvl, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
|
|
const frStats = calculateFeralRageStats(slvl)
|
|
|
|
// Expected progression:
|
|
// Hit 1 -> stage 1
|
|
// Hit 2 -> stage 2
|
|
// Hit 3 -> stage 3
|
|
// Hit 4 -> stage 3 (capped)
|
|
for (let hitIndex = 1; hitIndex <= 4; hitIndex++) {
|
|
const tick = hitIndex * 25
|
|
const out = executeSkill113c({
|
|
registry: reg,
|
|
skill: skill232,
|
|
evalResult: eval232,
|
|
caster,
|
|
targets: [target],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: null as any,
|
|
auraScanner: null as any,
|
|
summonManager: null as any,
|
|
currentTick: tick,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(out.executed).toBe(true)
|
|
expect(out.statesApplied).toContain('feralrage')
|
|
|
|
const expectedCharge = Math.min(hitIndex, 3)
|
|
const expectedLeech = Math.round((frStats.lifeLeechPct * expectedCharge) / 3)
|
|
const expectedVelocity = Math.round((frStats.velocityBonusPct * expectedCharge) / 3)
|
|
|
|
const state = caster.stateBus.getState('feralrage')
|
|
expect(state, `Feral rage state must exist after hit ${hitIndex}`).toBeDefined()
|
|
expect(state?.stats['charge']).toBe(expectedCharge)
|
|
expect(state?.stats['progressive_charges']).toBe(expectedCharge)
|
|
expect(state?.stats['life_leech']).toBe(expectedLeech)
|
|
expect(state?.stats['life_leech_pct']).toBe(expectedLeech)
|
|
expect(state?.stats['velocitypercent']).toBe(expectedVelocity)
|
|
expect(state?.durationFrames).toBe(500) // 20.0 seconds
|
|
expect(state?.expireFrame).toBe(tick + 500) // decay timer refreshed
|
|
}
|
|
})
|
|
|
|
it('4.2 Maul (#233): successive hits advance charges 1..5 with damage % and stun duration; capped at 5', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const caster = createTestUnit('dru_maul', 'BearDruid', 'player')
|
|
caster.stateBus.applyState({ stateNameOrId: 'bear', slvl: 10, stats: {} })
|
|
const target = createTestUnit('target_maul', 'TargetMonster', 'monster', 10000)
|
|
|
|
const skill233 = reg.getSkillById(233)!
|
|
const slvl = 10
|
|
const eval233 = evaluateSkill113c({ registry: reg, skill: skill233, slvl, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
|
|
const maulStats = calculateMaulStats(slvl)
|
|
|
|
for (let hitIndex = 1; hitIndex <= 6; hitIndex++) {
|
|
const tick = hitIndex * 25
|
|
const out = executeSkill113c({
|
|
registry: reg,
|
|
skill: skill233,
|
|
evalResult: eval233,
|
|
caster,
|
|
targets: [target],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: null as any,
|
|
auraScanner: null as any,
|
|
summonManager: null as any,
|
|
currentTick: tick,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(out.executed).toBe(true)
|
|
expect(out.statesApplied).toContain('maul')
|
|
|
|
const expectedCharge = Math.min(hitIndex, 5)
|
|
const expectedDmgBonus = expectedCharge * maulStats.damageBonusPerChargePct
|
|
|
|
const state = caster.stateBus.getState('maul')
|
|
expect(state, `Maul state must exist after hit ${hitIndex}`).toBeDefined()
|
|
expect(state?.stats['charge']).toBe(expectedCharge)
|
|
expect(state?.stats['progressive_charges']).toBe(expectedCharge)
|
|
expect(state?.stats['damagepercent']).toBe(expectedDmgBonus)
|
|
expect(state?.stats['stunDurationFrames']).toBe(maulStats.stunDurationFrames)
|
|
expect(state?.durationFrames).toBe(500) // 20.0 seconds
|
|
expect(state?.expireFrame).toBe(tick + 500)
|
|
}
|
|
})
|
|
|
|
it('4.3 Rabies (#238): successful hit infects target with authentic rabies contagion state', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const caster = createTestUnit('dru_rab', 'WolfDruid', 'player')
|
|
caster.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
|
|
const target = createTestUnit('target_rab', 'TargetMonster', 'monster', 2000)
|
|
|
|
const skill238 = reg.getSkillById(238)!
|
|
const slvl = 10
|
|
const eval238 = evaluateSkill113c({ registry: reg, skill: skill238, slvl, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
|
|
const rabiesStats = calculateRabiesStats(slvl, 0)
|
|
|
|
const out = executeSkill113c({
|
|
registry: reg,
|
|
skill: skill238,
|
|
evalResult: eval238,
|
|
caster,
|
|
targets: [target],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: null as any,
|
|
auraScanner: null as any,
|
|
summonManager: null as any,
|
|
currentTick: 0,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(out.executed).toBe(true)
|
|
expect(out.statesApplied).toContain('rabies')
|
|
expect(target.stateBus.hasState('rabies')).toBe(true)
|
|
|
|
const rState = target.stateBus.getState('rabies')!
|
|
expect(rState.sourceSkillId).toBe(238)
|
|
expect(rState.durationFrames).toBe(rabiesStats.poisonDurationFrames)
|
|
expect(rState.stats['min_poison_damage']).toBe(rabiesStats.minPoisonDamage)
|
|
expect(rState.stats['max_poison_damage']).toBe(rabiesStats.maxPoisonDamage)
|
|
expect(rState.stats['contagion_radius_subtiles']).toBe(4)
|
|
expect(rState.stats['contagion_spread_interval']).toBe(4)
|
|
})
|
|
})
|
|
})
|