697 lines
28 KiB
TypeScript
697 lines
28 KiB
TypeScript
/**
|
|
* Challenger Adversarial Stress Test Suite — Paladin Defensive Auras & Aura Engine (Cohort 4).
|
|
*
|
|
* Covers 5 Core Mechanical Pillars:
|
|
* 1. Aura Pulse Interval & Lingering Cadence (50-frame strict cadence, 55-frame lingering, highest slvl precedence)
|
|
* 2. Holy Shield (Defense %, Duration, Smite flat dmg 5-band ladder, dm56 block chance & FBR analysis)
|
|
* 3. Redemption (50-tick cadence, 16 subtiles 2:1 iso radius, dm34 chance, life/mana restore, town suspension)
|
|
* 4. Cleansing & Meditation Prayer Synergies (free heal pulse, 0 mana drain, hard point blvl enforcement)
|
|
* 5. Vengeance Multi-Elemental Packet (independent Fire/Cold/Ltng damage, independent resistances, chill duration)
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import { AuraScanner, type CorpseContext } from '../../../src/game/engine/aura-scanner.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
computeEffectiveResistance,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import {
|
|
calculatePrayerStats,
|
|
calculateRedemptionStats,
|
|
calculateHolyShieldStats,
|
|
calculateVengeanceStats,
|
|
calculateCleansingStats,
|
|
calculateMeditationStats,
|
|
} from '../../../src/game/skills.ts'
|
|
import { computeDiminishingReturns, compute5BandScaling } from '../../../src/game/engine/calc-ast.ts'
|
|
|
|
describe('Challenger Adversarial Stress — Paladin Defensive Auras & Aura Engine', () => {
|
|
// ==========================================================================
|
|
// Pillar 1: Aura Pulse Interval & Lingering Cadence
|
|
// ==========================================================================
|
|
describe('Pillar 1: Aura Pulse Interval & Lingering Cadence', () => {
|
|
it('1.1 Strict 50-Frame Cadence: verifies periodic pulse triggers exactly every 50 frames across 500 frames', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxmana: 1000 * FIXED_ONE,
|
|
mana: 1000 * FIXED_ONE,
|
|
})
|
|
const pal: CombatUnitContext = {
|
|
id: 'paladin_pulse',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: new StateBus(palStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const enemyStats = new UnitStatList(registry, {
|
|
maxhp: 100000 * FIXED_ONE,
|
|
hitpoints: 100000 * FIXED_ONE,
|
|
})
|
|
const enemy: CombatUnitContext = {
|
|
id: 'pulse_dummy',
|
|
name: 'Dummy',
|
|
statList: enemyStats,
|
|
stateBus: new StateBus(enemyStats, registry),
|
|
unitType: 'monster',
|
|
x: 10,
|
|
y: 10,
|
|
}
|
|
|
|
// Activate Holy Shock (118) with startTick = 0
|
|
const source = scanner.setActiveAura(pal, 118, 10, 0)!
|
|
expect(source.pulseIntervalTicks).toBe(50)
|
|
|
|
const pulseFrames: number[] = []
|
|
let totalDamage = 0
|
|
|
|
for (let tick = 0; tick <= 500; tick++) {
|
|
const outcomes = scanner.tick(tick, [pal], [enemy])
|
|
if (outcomes.length > 0 && outcomes[0]!.pulseDamageDealt > 0) {
|
|
pulseFrames.push(tick)
|
|
totalDamage += outcomes[0]!.pulseDamageDealt
|
|
}
|
|
}
|
|
|
|
// Expected pulse frames: 0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500 (11 total)
|
|
expect(pulseFrames.length).toBe(11)
|
|
expect(pulseFrames).toEqual([0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500])
|
|
expect(totalDamage).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('1.2 Lingering Duration & Boundary Eviction: buff persists for 55 frames (2.2s) when ally leaves radius', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry)
|
|
const pal: CombatUnitContext = {
|
|
id: 'paladin_caster',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: new StateBus(palStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const allyStats = new UnitStatList(registry)
|
|
const allyBus = new StateBus(allyStats, registry)
|
|
const ally: CombatUnitContext = {
|
|
id: 'moving_ally',
|
|
name: 'Ally',
|
|
statList: allyStats,
|
|
stateBus: allyBus,
|
|
unitType: 'party',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
// Might slvl 10 (+130% ED)
|
|
scanner.setActiveAura(pal, 98, 10, 0)
|
|
scanner.tick(0, [pal, ally], [])
|
|
|
|
// Initial state at tick 0
|
|
expect(allyBus.hasState('might')).toBe(true)
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(130)
|
|
|
|
// Ally moves far out of range at tick 1
|
|
;(ally as { x?: number; y?: number }).x = 1000
|
|
;(ally as { x?: number; y?: number }).y = 1000
|
|
|
|
// Intermediate ticks (1 through 54) - ally still has lingering buff
|
|
for (const t of [1, 10, 25, 49, 50, 54]) {
|
|
scanner.tick(t, [pal, ally], [])
|
|
allyBus.tick(t)
|
|
expect(allyBus.hasState('might')).toBe(true)
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(130)
|
|
}
|
|
|
|
// At tick 55 (50 + 5 duration expired), state is evicted
|
|
scanner.tick(55, [pal, ally], [])
|
|
allyBus.tick(55)
|
|
expect(allyBus.hasState('might')).toBe(false)
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(0)
|
|
})
|
|
|
|
it('1.3 Highest Level Precedence: differential testing across slvl pairs guarantees highest level overrides lower', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
// Test 20 distinct slvl pairs (slvlA vs slvlB)
|
|
const testPairs = [
|
|
[1, 10], [10, 1], [5, 20], [20, 5], [15, 15],
|
|
[8, 12], [14, 7], [3, 25], [30, 2], [18, 18],
|
|
[2, 4], [9, 11], [16, 16], [22, 19], [7, 28],
|
|
[13, 6], [21, 23], [4, 17], [29, 29], [12, 24],
|
|
]
|
|
|
|
for (const [slvlA, slvlB] of testPairs) {
|
|
const palAStats = new UnitStatList(registry)
|
|
const palA: CombatUnitContext = {
|
|
id: `palA_${slvlA}_${slvlB}`,
|
|
name: 'Paladin A',
|
|
statList: palAStats,
|
|
stateBus: new StateBus(palAStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const palBStats = new UnitStatList(registry)
|
|
const palB: CombatUnitContext = {
|
|
id: `palB_${slvlA}_${slvlB}`,
|
|
name: 'Paladin B',
|
|
statList: palBStats,
|
|
stateBus: new StateBus(palBStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const allyStats = new UnitStatList(registry)
|
|
const allyBus = new StateBus(allyStats, registry)
|
|
const ally: CombatUnitContext = {
|
|
id: `ally_${slvlA}_${slvlB}`,
|
|
name: 'Ally',
|
|
statList: allyStats,
|
|
stateBus: allyBus,
|
|
unitType: 'party',
|
|
x: 5,
|
|
y: 5,
|
|
}
|
|
|
|
const auraA = scanner.setActiveAura(palA, 98, slvlA!, 0)!
|
|
const auraB = scanner.setActiveAura(palB, 98, slvlB!, 0)!
|
|
|
|
scanner.pulseAura(auraA, 0, [palA, ally], [])
|
|
scanner.pulseAura(auraB, 0, [palB, ally], [])
|
|
|
|
const expectedSlvl = Math.max(slvlA!, slvlB!)
|
|
const expectedEd = 40 + (expectedSlvl - 1) * 10
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(expectedEd)
|
|
}
|
|
})
|
|
|
|
it('1.4 Dynamic Aura Handoff: lower level aura takes over when higher level aura expires', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palAStats = new UnitStatList(registry)
|
|
const palA: CombatUnitContext = { id: 'palA', name: 'Pal A', statList: palAStats, stateBus: new StateBus(palAStats, registry), unitType: 'player', x: 0, y: 0 }
|
|
|
|
const palBStats = new UnitStatList(registry)
|
|
const palB: CombatUnitContext = { id: 'palB', name: 'Pal B', statList: palBStats, stateBus: new StateBus(palBStats, registry), unitType: 'player', x: 0, y: 0 }
|
|
|
|
const allyStats = new UnitStatList(registry)
|
|
const allyBus = new StateBus(allyStats, registry)
|
|
const ally: CombatUnitContext = { id: 'ally', name: 'Ally', statList: allyStats, stateBus: allyBus, unitType: 'party', x: 0, y: 0 }
|
|
|
|
const aA = scanner.setActiveAura(palA, 98, 10, 0)! // slvl 10 (+130%)
|
|
const aB = scanner.setActiveAura(palB, 98, 20, 0)! // slvl 20 (+230%)
|
|
|
|
// Both pulse at tick 0
|
|
scanner.pulseAura(aA, 0, [palA, ally], [])
|
|
scanner.pulseAura(aB, 0, [palB, ally], [])
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(230)
|
|
|
|
// Paladin B leaves at tick 1
|
|
;(palB as { x?: number; y?: number }).x = 2000
|
|
;(palB as { x?: number; y?: number }).y = 2000
|
|
|
|
// Tick 50: Pal A pulses (slvl 10), Pal B does not reach ally
|
|
scanner.pulseAura(aA, 50, [palA, ally], [])
|
|
scanner.pulseAura(aB, 50, [palB, ally], [])
|
|
// Slvl 20 buff is still active until frame 55
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(230)
|
|
|
|
// Tick 55: Pal B slvl 20 buff expires, Pal A slvl 10 (valid until frame 105) seamlessly assumes control
|
|
allyBus.tick(55)
|
|
expect(allyStats.getModifierBonus('damagepercent')).toBe(130)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 2: Holy Shield Mechanics & Parity Probing
|
|
// ==========================================================================
|
|
describe('Pillar 2: Holy Shield Mechanics & Parity Probing', () => {
|
|
it('2.1 Defense Bonus % & Defiance Synergy: strictly follows 25 + (slvl-1)*15 + Defiance.blvl*15', () => {
|
|
// Test matrix of slvl and Defiance blvl
|
|
for (const slvl of [1, 5, 10, 20, 30]) {
|
|
for (const defBlvl of [0, 5, 10, 20]) {
|
|
const stats = calculateHolyShieldStats(slvl, defBlvl)
|
|
const expected = 25 + (slvl - 1) * 15 + defBlvl * 15
|
|
expect(stats.defensePct).toBe(expected)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2.2 Duration Scaling: strictly follows 750 + (slvl - 1) * 625 frames', () => {
|
|
expect(calculateHolyShieldStats(1).durationFrames).toBe(750) // 30s
|
|
expect(calculateHolyShieldStats(2).durationFrames).toBe(1375) // 55s
|
|
expect(calculateHolyShieldStats(10).durationFrames).toBe(6375) // 255s
|
|
expect(calculateHolyShieldStats(20).durationFrames).toBe(12625) // 505s
|
|
expect(calculateHolyShieldStats(30).durationFrames).toBe(18875) // 755s
|
|
})
|
|
|
|
it('2.3 Smite Flat Damage 5-Band Ladder Parity vs Skills.txt', () => {
|
|
// 1.13c Skills.txt: minDam=3, minLev1=2, minLev2=3, minLev3=4, minLev4=4, minLev5=4
|
|
// maxDam=6, maxLev1=2, maxLev2=3, maxLev3=4, maxLev4=4, maxLev5=4
|
|
for (const slvl of [1, 5, 8, 12, 16, 20, 25, 30]) {
|
|
const hs = calculateHolyShieldStats(slvl)
|
|
const expectedMin = compute5BandScaling(slvl, 3, 2, 3, 4, 4, 4)
|
|
const expectedMax = compute5BandScaling(slvl, 6, 2, 3, 4, 4, 4)
|
|
expect(hs.smiteMinFlat).toBe(expectedMin)
|
|
expect(hs.smiteMaxFlat).toBe(expectedMax)
|
|
}
|
|
})
|
|
|
|
it('2.4 Blocking Chance Formula Parity: validates dm56 D2Common ground truth across levels 1..20', () => {
|
|
// D2Common dm56 formula: 10 + Math.trunc(3300 * lvl / (100 * (lvl + 6)))
|
|
// calculateHolyShieldStats formula aligned to authentic computeDiminishingReturns(10, 40, lvl)
|
|
const discrepancies: { slvl: number; hs: number; d2common: number }[] = []
|
|
for (let slvl = 1; slvl <= 20; slvl++) {
|
|
const hs = calculateHolyShieldStats(slvl).blockChancePct
|
|
const d2common = computeDiminishingReturns(10, 40, slvl)
|
|
if (hs !== d2common) {
|
|
discrepancies.push({ slvl, hs, d2common })
|
|
}
|
|
}
|
|
|
|
// Assert 100% parity with zero discrepancies across levels 1..20
|
|
expect(discrepancies.length).toBe(0)
|
|
expect(calculateHolyShieldStats(1).blockChancePct).toBe(14)
|
|
expect(calculateHolyShieldStats(20).blockChancePct).toBe(35)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 3: Redemption Corpse Scanning & Consumption Engine
|
|
// ==========================================================================
|
|
describe('Pillar 3: Redemption Corpse Scanning & Consumption Engine', () => {
|
|
it('3.1 50-Tick Cadence & 2:1 Isometric Spatial Radius (16 subtiles / ~213px)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE, mana: 200 * FIXED_ONE })
|
|
const pal: CombatUnitContext = {
|
|
id: 'paladin_redempt',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: new StateBus(palStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
// Corpse positions in 2:1 isometric ground space: Math.hypot(dx, dy * 2)
|
|
const corpses: CorpseContext[] = [
|
|
{ id: 'c_in_1', consumed: false, x: 100, y: 50 }, // dist = hypot(100, 100) = 141.4 <= 213 (IN)
|
|
{ id: 'c_in_2', consumed: false, x: 0, y: 100 }, // dist = hypot(0, 200) = 200 <= 213 (IN)
|
|
{ id: 'c_out_1', consumed: false, x: 150, y: 100 }, // dist = hypot(150, 200) = 250 > 213 (OUT)
|
|
{ id: 'c_out_2', consumed: false, x: 220, y: 0 }, // dist = hypot(220, 0) = 220 > 213 (OUT)
|
|
]
|
|
|
|
const aura = scanner.setActiveAura(pal, 124, 10, 0)! // pulseIntervalTicks = 50
|
|
expect(aura.pulseIntervalTicks).toBe(50)
|
|
|
|
// Pulse with 100% deterministic success
|
|
const outcome = scanner.pulseAura(aura, 0, [pal], [], corpses, { rollChanceFn: () => true })
|
|
|
|
expect(outcome.corpsesRedeemed).toBe(2)
|
|
expect(corpses[0]!.consumed).toBe(true)
|
|
expect(corpses[1]!.consumed).toBe(true)
|
|
expect(corpses[2]!.consumed).toBe(false)
|
|
expect(corpses[3]!.consumed).toBe(false)
|
|
})
|
|
|
|
it('3.2 dm34 Redemption Chance % & Binomial Convergence over 1,000 trials', async () => {
|
|
const stats1 = calculateRedemptionStats(1)
|
|
expect(stats1.redemptionChancePct).toBe(24) // 24%
|
|
|
|
const stats10 = calculateRedemptionStats(10)
|
|
expect(stats10.redemptionChancePct).toBe(71) // 71%
|
|
|
|
const stats20 = calculateRedemptionStats(20)
|
|
expect(stats20.redemptionChancePct).toBe(86) // 86%
|
|
|
|
// Pseudo-random binomial trial: 1,000 rolls at slvl 10 (71% theoretical)
|
|
let successes = 0
|
|
const totalTrials = 1000
|
|
for (let i = 0; i < totalTrials; i++) {
|
|
if (Math.random() * 100 < stats10.redemptionChancePct) {
|
|
successes++
|
|
}
|
|
}
|
|
const empiricalRate = successes / totalTrials
|
|
// Assert empirical rate is within [66%, 76%] (within 3 standard deviations: sigma = sqrt(1000*0.71*0.29) ~= 14.3)
|
|
expect(empiricalRate).toBeGreaterThan(0.66)
|
|
expect(empiricalRate).toBeLessThan(0.76)
|
|
})
|
|
|
|
it('3.3 Corpse Consumption Lifecycle & Clamping at Max HP/Mana', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 950 * FIXED_ONE, // Missing 50 HP
|
|
maxmana: 500 * FIXED_ONE,
|
|
mana: 450 * FIXED_ONE, // Missing 50 Mana
|
|
})
|
|
const pal: CombatUnitContext = {
|
|
id: 'pal_clamp',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: new StateBus(palStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
// Slvl 20 Redemption restores 120 HP & Mana per corpse
|
|
const corpses: CorpseContext[] = [
|
|
{ id: 'c1', consumed: false, x: 10, y: 10 },
|
|
{ id: 'c2', consumed: false, x: 20, y: 20 },
|
|
]
|
|
|
|
const aura = scanner.setActiveAura(pal, 124, 20, 0)!
|
|
const outcome = scanner.pulseAura(aura, 0, [pal], [], corpses, { rollChanceFn: () => true })
|
|
|
|
expect(outcome.corpsesRedeemed).toBe(2)
|
|
// Clamped to 1000 max HP and 500 max Mana
|
|
expect(palStats.getHp256()).toBe(1000 * FIXED_ONE)
|
|
expect(palStats.getMana256()).toBe(500 * FIXED_ONE)
|
|
expect(outcome.healthRestored).toBe(50)
|
|
expect(outcome.manaRestored).toBe(50)
|
|
|
|
// Pulsing again on the same corpses must yield 0 redeemed
|
|
const secondOutcome = scanner.pulseAura(aura, 50, [pal], [], corpses, { rollChanceFn: () => true })
|
|
expect(secondOutcome.corpsesRedeemed).toBe(0)
|
|
})
|
|
|
|
it('3.4 Town Safety Suspension: Redemption is strictly disabled in town', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
|
const pal: CombatUnitContext = {
|
|
id: 'pal_town',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: new StateBus(palStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const corpses: CorpseContext[] = [{ id: 'town_corpse', consumed: false, x: 5, y: 5 }]
|
|
const aura = scanner.setActiveAura(pal, 124, 20, 0)!
|
|
|
|
const outcome = scanner.pulseAura(aura, 0, [pal], [], corpses, { isTown: true, rollChanceFn: () => true })
|
|
expect(outcome.corpsesRedeemed).toBe(0)
|
|
expect(corpses[0]!.consumed).toBe(false)
|
|
expect(palStats.getHp256()).toBe(500 * FIXED_ONE)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 4: Cleansing & Meditation Prayer Synergies
|
|
// ==========================================================================
|
|
describe('Pillar 4: Cleansing & Meditation Prayer Synergies', () => {
|
|
it('4.1 Cleansing Free Prayer Heal Pulse: heals at Prayer rate with hard points, consumes 0 mana', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxmana: 200 * FIXED_ONE,
|
|
mana: 200 * FIXED_ONE,
|
|
})
|
|
palStats.setBaseSkillLevel(99, 10) // 10 hard points in Prayer -> 11 HP/pulse
|
|
const palBus = new StateBus(palStats, registry)
|
|
const pal: CombatUnitContext = {
|
|
id: 'pal_cleanse',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: palBus,
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
// Activate Cleansing (109)
|
|
scanner.setActiveAura(pal, 109, 1, 0)
|
|
|
|
// Run 100 frames (2 pulses: frame 0 and frame 50)
|
|
for (let t = 0; t <= 100; t++) {
|
|
scanner.tick(t, [pal], [])
|
|
}
|
|
|
|
// 3 pulses executed (tick 0, 50, 100): 3 * 11 = 33 HP healed
|
|
expect(palStats.getHp256()).toBe((500 + 33) * FIXED_ONE)
|
|
// Zero mana consumed!
|
|
expect(palStats.getMana256()).toBe(200 * FIXED_ONE)
|
|
})
|
|
|
|
it('4.2 Meditation Free Prayer Heal Pulse: heals at Prayer rate with hard points, consumes 0 mana', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxmana: 200 * FIXED_ONE,
|
|
mana: 200 * FIXED_ONE,
|
|
})
|
|
palStats.setBaseSkillLevel(99, 20) // 20 hard points in Prayer -> 25 HP/pulse
|
|
const palBus = new StateBus(palStats, registry)
|
|
const pal: CombatUnitContext = {
|
|
id: 'pal_medit',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: palBus,
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
// Activate Meditation (120)
|
|
scanner.setActiveAura(pal, 120, 1, 0)
|
|
|
|
// Pulse at tick 0 and tick 50
|
|
scanner.tick(0, [pal], [])
|
|
scanner.tick(50, [pal], [])
|
|
|
|
// 2 * 25 = 50 HP healed
|
|
expect(palStats.getHp256()).toBe((500 + 50) * FIXED_ONE)
|
|
// Zero mana consumed!
|
|
expect(palStats.getMana256()).toBe(200 * FIXED_ONE)
|
|
})
|
|
|
|
it('4.3 Prayer Continuous Mana Drain & Depletion Deactivation', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
// Start with exactly 10 Mana (2560 in 256-fixed units).
|
|
// Rate is 16 units per frame. 2560 / 16 = 160 frames until depletion!
|
|
const palStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxmana: 100 * FIXED_ONE,
|
|
mana: 10 * FIXED_ONE,
|
|
})
|
|
const palBus = new StateBus(palStats, registry)
|
|
const pal: CombatUnitContext = {
|
|
id: 'pal_prayer_drain',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: palBus,
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
scanner.setActiveAura(pal, 99, 1, 0)
|
|
scanner.tick(0, [pal], [])
|
|
expect(palBus.hasState('prayer')).toBe(true)
|
|
|
|
// Advance clock until mana drops to 0
|
|
for (let t = 1; t <= 165; t++) {
|
|
scanner.tick(t, [pal], [])
|
|
}
|
|
|
|
// Depleted mana and state removed
|
|
expect(palStats.getMana256()).toBe(0)
|
|
expect(palBus.hasState('prayer')).toBe(false)
|
|
})
|
|
|
|
it('4.4 Hard Points Only Synergy Invariant: soft points (+skills) without hard points give 0 synergy heal', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const palStats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 500 * FIXED_ONE,
|
|
})
|
|
// +20 all skills and +10 bonus skills, but 0 hard points!
|
|
palStats.setAllSkillsBonus(20)
|
|
palStats.setBonusSkillLevel(99, 10)
|
|
expect(palStats.getBaseSkillLevel(99)).toBe(0)
|
|
expect(palStats.getEffectiveSkillLevel(99)).toBe(30)
|
|
|
|
const pal: CombatUnitContext = {
|
|
id: 'pal_soft',
|
|
name: 'Paladin',
|
|
statList: palStats,
|
|
stateBus: new StateBus(palStats, registry),
|
|
unitType: 'player',
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const aura = scanner.setActiveAura(pal, 109, 1, 0)! // Cleansing slvl 1
|
|
const outcome = scanner.pulseAura(aura, 0, [pal], [])
|
|
|
|
// Zero health restored because blvl is 0!
|
|
expect(outcome.healthRestored).toBe(0)
|
|
expect(palStats.getHp256()).toBe(500 * FIXED_ONE)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Pillar 5: Vengeance Multi-Elemental Packet
|
|
// ==========================================================================
|
|
describe('Pillar 5: Vengeance Multi-Elemental Packet', () => {
|
|
it('5.1 Multi-Elemental Percentage Scaling with Synergies', () => {
|
|
// Slvl 1: 70% Fire, Cold, Lightning
|
|
const v1 = calculateVengeanceStats(1)
|
|
expect(v1.firePct).toBe(70)
|
|
expect(v1.coldPct).toBe(70)
|
|
expect(v1.ltngPct).toBe(70)
|
|
expect(v1.chillDurationFrames).toBe(30)
|
|
|
|
// Slvl 10 with Resist Fire 10, Resist Cold 5, Salvation 5:
|
|
// Base: 70 + 9*6 = 124%
|
|
// Fire: 124 + 10*10 + 5*2 = 234%
|
|
// Cold: 124 + 5*10 + 5*2 = 184%
|
|
// Ltng: 124 + 0*10 + 5*2 = 134%
|
|
// Chill: 30 + 9*15 = 165 frames
|
|
const v10 = calculateVengeanceStats(10, {
|
|
resistFire: 10,
|
|
resistCold: 5,
|
|
salvation: 5,
|
|
})
|
|
expect(v10.firePct).toBe(234)
|
|
expect(v10.coldPct).toBe(184)
|
|
expect(v10.ltngPct).toBe(134)
|
|
expect(v10.chillDurationFrames).toBe(165)
|
|
})
|
|
|
|
it('5.2 Independent Resistance Calculations against Distinct Elemental Immunities', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attackerStats = new UnitStatList(registry)
|
|
const attacker: CombatUnitContext = {
|
|
id: 'avenger',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
|
|
const mep = {
|
|
firePct: 100,
|
|
coldPct: 100,
|
|
ltngPct: 100,
|
|
chillDurationFrames: 50,
|
|
}
|
|
|
|
// Case A: Fire Immune (120%), 0% Cold, 0% Ltng
|
|
const dFireStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 120, coldresist: 0, lightresist: 0, damageresist: 0 })
|
|
const dFire: CombatUnitContext = { id: 'dFire', name: 'Fire Immune', statList: dFireStats, stateBus: new StateBus(dFireStats, registry), unitType: 'monster' }
|
|
const outA = executeSUnitDmg(attacker, dFire, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
|
|
// Phys: 100, Fire: 0, Cold: 100, Ltng: 100 -> Elem = 200
|
|
expect(outA.physDamage256).toBe(100 * FIXED_ONE)
|
|
expect(outA.elemDamage256).toBe(200 * FIXED_ONE)
|
|
expect(dFire.stateBus.hasState('freeze')).toBe(true)
|
|
|
|
// Case B: Cold Immune (120%), 0% Fire, 0% Ltng
|
|
const dColdStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 0, coldresist: 120, lightresist: 0, damageresist: 0 })
|
|
const dCold: CombatUnitContext = { id: 'dCold', name: 'Cold Immune', statList: dColdStats, stateBus: new StateBus(dColdStats, registry), unitType: 'monster' }
|
|
const outB = executeSUnitDmg(attacker, dCold, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
|
|
// Phys: 100, Fire: 100, Cold: 0, Ltng: 100 -> Elem = 200
|
|
expect(outB.physDamage256).toBe(100 * FIXED_ONE)
|
|
expect(outB.elemDamage256).toBe(200 * FIXED_ONE)
|
|
// Cold immune monster MUST NOT be chilled!
|
|
expect(dCold.stateBus.hasState('freeze')).toBe(false)
|
|
|
|
// Case C: Lightning Immune (120%), 0% Fire, 0% Cold
|
|
const dLtngStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 0, coldresist: 0, lightresist: 120, damageresist: 0 })
|
|
const dLtng: CombatUnitContext = { id: 'dLtng', name: 'Ltng Immune', statList: dLtngStats, stateBus: new StateBus(dLtngStats, registry), unitType: 'monster' }
|
|
const outC = executeSUnitDmg(attacker, dLtng, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
|
|
// Phys: 100, Fire: 100, Cold: 100, Ltng: 0 -> Elem = 200
|
|
expect(outC.physDamage256).toBe(100 * FIXED_ONE)
|
|
expect(outC.elemDamage256).toBe(200 * FIXED_ONE)
|
|
|
|
// Case D: Tri-Resist (Fire 50%, Cold 75%, Ltng 25%)
|
|
const dTriStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 50, coldresist: 75, lightresist: 25, damageresist: 0 })
|
|
const dTri: CombatUnitContext = { id: 'dTri', name: 'Tri Res', statList: dTriStats, stateBus: new StateBus(dTriStats, registry), unitType: 'monster' }
|
|
const outD = executeSUnitDmg(attacker, dTri, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
|
|
// Fire: 100 * 0.5 = 50, Cold: 100 * 0.25 = 25, Ltng: 100 * 0.75 = 75 -> Elem = 150
|
|
expect(outD.physDamage256).toBe(100 * FIXED_ONE)
|
|
expect(outD.elemDamage256).toBe(150 * FIXED_ONE)
|
|
})
|
|
|
|
it('5.3 Cannot Be Frozen Defender Immunity to Vengeance Chill State', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attackerStats = new UnitStatList(registry)
|
|
const attacker: CombatUnitContext = {
|
|
id: 'avenger',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
unitType: 'player',
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
|
|
const cbfStats = new UnitStatList(registry, {
|
|
hitpoints: 10000 * FIXED_ONE,
|
|
coldresist: 0,
|
|
cannot_be_frozen: 1,
|
|
})
|
|
const cbfDefender: CombatUnitContext = {
|
|
id: 'cbf_target',
|
|
name: 'CBF Target',
|
|
statList: cbfStats,
|
|
stateBus: new StateBus(cbfStats, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const out = executeSUnitDmg(attacker, cbfDefender, {
|
|
skillId: 111,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
autoHit: true,
|
|
multiElemPacket: { firePct: 50, coldPct: 50, ltngPct: 50, chillDurationFrames: 100 },
|
|
})
|
|
|
|
// Cold damage is still dealt
|
|
expect(out.elemDamage256).toBe(150 * FIXED_ONE)
|
|
// Freeze state must NOT be applied due to Cannot Be Frozen!
|
|
expect(cbfDefender.stateBus.hasState('freeze')).toBe(false)
|
|
})
|
|
})
|
|
})
|