test(skills): persist M3 and M4 adversarial stress test suites on main
This commit is contained in:
parent
a0567c2473
commit
617fdfec51
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,443 @@
|
|||
/**
|
||||
* Adversarial Stress & Correctness Verification Suite — Milestone M4
|
||||
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
|
||||
* Author: reviewer_lightning_m4 (teamwork_preview_reviewer / critic)
|
||||
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../src/game/engine/state-bus.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import {
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateEnergyShieldDuration,
|
||||
calculateEnergyShieldAbsorbPct,
|
||||
calculateEnergyShieldManaRatio,
|
||||
calculateLightningMasteryBonusPct,
|
||||
calculateChargedBoltDamage,
|
||||
calculateNovaDamage,
|
||||
calculateLightningDamage,
|
||||
calculateChainLightningDamage,
|
||||
calculateSkillDamage,
|
||||
BATCH1_SKILLS,
|
||||
CANONICAL_113C_MISSILES,
|
||||
} from '../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../src/scene/act-scene.ts'
|
||||
|
||||
describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => {
|
||||
// =========================================================================
|
||||
// 1. Thunder Storm (57) Adversarial Stress
|
||||
// =========================================================================
|
||||
describe('1. Thunder Storm (57) Stress Testing', () => {
|
||||
it('stresses 5-band piecewise progression across 1..99 levels', () => {
|
||||
// Band 1: lvl 1..8 (+10/+10)
|
||||
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
|
||||
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
|
||||
// Band 2: lvl 9..16 (+10/+10)
|
||||
expect(calculateThunderStormDamage(9)).toEqual({ min: 81, max: 180 })
|
||||
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
|
||||
// Band 3: lvl 17..22 (+11/+11)
|
||||
expect(calculateThunderStormDamage(17)).toEqual({ min: 162, max: 261 })
|
||||
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
|
||||
// Band 4: lvl 23..28 (+11/+11)
|
||||
expect(calculateThunderStormDamage(23)).toEqual({ min: 228, max: 327 })
|
||||
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
|
||||
// Band 5: lvl 29+ (+11/+11)
|
||||
expect(calculateThunderStormDamage(29)).toEqual({ min: 294, max: 393 })
|
||||
expect(calculateThunderStormDamage(40)).toEqual({ min: 415, max: 514 })
|
||||
expect(calculateThunderStormDamage(99)).toEqual({ min: 1064, max: 1163 })
|
||||
})
|
||||
|
||||
it('stresses cadence interval hyperbolic decay and floor clamping at 25 frames', () => {
|
||||
// Interval formula: max(25, 125 - floor(110 * slvl / (slvl + 6)))
|
||||
expect(calculateThunderStormInterval(1)).toBe(110)
|
||||
expect(calculateThunderStormInterval(5)).toBe(75)
|
||||
expect(calculateThunderStormInterval(10)).toBe(57)
|
||||
expect(calculateThunderStormInterval(20)).toBe(41)
|
||||
expect(calculateThunderStormInterval(30)).toBe(34)
|
||||
expect(calculateThunderStormInterval(40)).toBe(30)
|
||||
expect(calculateThunderStormInterval(50)).toBe(27)
|
||||
expect(calculateThunderStormInterval(60)).toBe(25) // exactly reaches 25 floor!
|
||||
expect(calculateThunderStormInterval(70)).toBe(25)
|
||||
expect(calculateThunderStormInterval(100)).toBe(25)
|
||||
expect(calculateThunderStormInterval(999)).toBe(25)
|
||||
})
|
||||
|
||||
it('stresses edge inputs for duration, interval, damage', () => {
|
||||
expect(calculateThunderStormDuration(0)).toBe(800)
|
||||
expect(calculateThunderStormDuration(-99)).toBe(800)
|
||||
expect(calculateThunderStormDuration(NaN as any)).toBe(800)
|
||||
|
||||
expect(calculateThunderStormInterval(0)).toBe(110)
|
||||
expect(calculateThunderStormInterval(-10)).toBe(110)
|
||||
expect(calculateThunderStormInterval(NaN as any)).toBe(110)
|
||||
|
||||
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
|
||||
expect(calculateThunderStormDamage(-10)).toEqual({ min: 1, max: 100 })
|
||||
expect(calculateThunderStormDamage(NaN as any)).toEqual({ min: 1, max: 100 })
|
||||
})
|
||||
|
||||
it('stresses runtime GameEngine strike targeting nearest monster within 340px', () => {
|
||||
const terrain = {
|
||||
widthPx: 1280,
|
||||
heightPx: 960,
|
||||
overlap: (_x: number, _y: number) => 0,
|
||||
isMissileBlocked: () => false,
|
||||
} as any
|
||||
const engine = new GameEngine(terrain, {
|
||||
spawn: { x: 500, y: 500 },
|
||||
monsterCount: 0,
|
||||
stats: [],
|
||||
skills: ['attack'] as any,
|
||||
xpTable: [0, 500],
|
||||
combatOptions: {
|
||||
playerSpeed: 100,
|
||||
playerReach: 40,
|
||||
playerCooldownTicks: 15,
|
||||
playerDamage: 10,
|
||||
playerManaPerAttack: 0,
|
||||
respawnTicks: 75,
|
||||
disableMonsterAggro: true,
|
||||
},
|
||||
npcRingFallback: false,
|
||||
} as any)
|
||||
|
||||
// Player at (500, 500) with active Thunder Storm (interval 1 tick for testing)
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
engine.world.player.thunderstormTicks = 100
|
||||
engine.world.player.thunderstormInterval = 1
|
||||
engine.world.player.thunderstormNextStrike = 1
|
||||
engine.world.player.thunderstormDamage = 200
|
||||
|
||||
// Add three monsters:
|
||||
// M0: dead monster at (510, 500) (dist 10) - must be ignored!
|
||||
// M1: live monster at (600, 500) (dist 100) - closest valid!
|
||||
// M2: live monster at (750, 500) (dist 250) - farther!
|
||||
// M3: live monster at (900, 500) (dist 400) - outside 340px!
|
||||
engine.world.monsters = [
|
||||
{ index: 0, id: 'm0', x: 510, y: 500, hp: 0, maxHp: 500, state: 'dead', stats: { name: 'm0', id: 'm0', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
||||
{ index: 1, id: 'm1', x: 600, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm1', id: 'm1', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
||||
{ index: 2, id: 'm2', x: 750, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm2', id: 'm2', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
||||
{ index: 3, id: 'm3', x: 900, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm3', id: 'm3', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
|
||||
]
|
||||
|
||||
// Tick engine by 1 frame
|
||||
engine.tick({
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
picking: false,
|
||||
dropping: null,
|
||||
digits: [],
|
||||
} as any)
|
||||
|
||||
// M1 (closest alive inside 340px) should take 200 damage!
|
||||
expect(engine.world.monsters[1]?.hp).toBe(300)
|
||||
// M2 should take 0 damage
|
||||
expect(engine.world.monsters[2]?.hp).toBe(500)
|
||||
// M3 should take 0 damage
|
||||
expect(engine.world.monsters[3]?.hp).toBe(500)
|
||||
// Overlay light_cast_2 should spawn on M1
|
||||
const ov = engine.overlays.find(o => o.overlayName === 'light_cast_2')
|
||||
expect(ov).toBeDefined()
|
||||
expect(ov?.x).toBe(600)
|
||||
expect(ov?.y).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 2. Energy Shield (58) Adversarial Stress
|
||||
// =========================================================================
|
||||
describe('2. Energy Shield (58) Stress Testing', () => {
|
||||
it('stresses 95% cap across high skill levels', () => {
|
||||
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(60)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(999)).toBe(95)
|
||||
})
|
||||
|
||||
it('stresses Telekinesis synergy ratio boundaries and soft point immutability', () => {
|
||||
// 0 hard points -> 2.0
|
||||
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
|
||||
// 1..20 hard points -> 1/16 less per point
|
||||
expect(calculateEnergyShieldManaRatio(1)).toBe(31 / 16)
|
||||
expect(calculateEnergyShieldManaRatio(8)).toBe(24 / 16)
|
||||
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
|
||||
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
|
||||
// 21+ points -> strictly clamped at 0.75
|
||||
expect(calculateEnergyShieldManaRatio(21)).toBe(0.75)
|
||||
expect(calculateEnergyShieldManaRatio(40)).toBe(0.75)
|
||||
expect(calculateEnergyShieldManaRatio(99)).toBe(0.75)
|
||||
// Negative points -> strictly clamped at 2.0
|
||||
expect(calculateEnergyShieldManaRatio(-1)).toBe(2.0)
|
||||
expect(calculateEnergyShieldManaRatio(-50)).toBe(2.0)
|
||||
})
|
||||
|
||||
it('stresses pre-resistance vs post-resistance mathematical difference', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 2000 * FIXED_ONE,
|
||||
mana: 2000 * FIXED_ONE,
|
||||
lightresist: 75, // 75% Lightning Resistance
|
||||
})
|
||||
// TK blvl = 16 (1.0 ratio)
|
||||
defenderStats.setBaseSkillLevel(43, 16)
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
defenderBus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
|
||||
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
|
||||
|
||||
// 1000 raw lightning damage
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 49,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'ltng',
|
||||
elemMin256: 1000 * FIXED_ONE,
|
||||
elemMax256: 1000 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Ground truth 1.13c Pre-Resistance order:
|
||||
// 1. Raw: 1000 ltng.
|
||||
// 2. ES absorbs 75% = 750 damage.
|
||||
// 3. Mana drained: 750 * 1.0 = 750 mana.
|
||||
// 4. Remaining raw ltng: 250.
|
||||
// 5. 75% LightRes mitigates remaining 250 down to 62.5 (250 * 0.25).
|
||||
// 6. 256-fixed-point rounding: Math.ceil(16000 / 256) = 63.
|
||||
expect(defenderStats.getMana()).toBe(1250) // 2000 - 750
|
||||
expect(res.totalDamage).toBe(63)
|
||||
expect(defenderStats.getHp()).toBe(938)
|
||||
})
|
||||
|
||||
it('stresses mixed physical and poison damage: physical is absorbed while poison 100% bypasses', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
stats.setBaseSkillLevel(43, 16) // 1.0 ratio
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 100 * FIXED_ONE,
|
||||
elemType: 'pois',
|
||||
isPoison: true,
|
||||
})
|
||||
|
||||
// Physical: 75% of 200 = 150 absorbed, 50 remains
|
||||
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
|
||||
// Poison: 0% absorbed! 100 remains
|
||||
expect(out.absorbedElem256).toBe(0)
|
||||
// Mana drained: only 150 mana (from physical), 0 from poison!
|
||||
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(850)
|
||||
})
|
||||
|
||||
it('stresses extreme overkill damage and shield collapse', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 5000 * FIXED_ONE,
|
||||
hitpoints: 5000 * FIXED_ONE,
|
||||
maxmana: 100 * FIXED_ONE,
|
||||
mana: 10 * FIXED_ONE, // only 10 mana left!
|
||||
})
|
||||
stats.setBaseSkillLevel(43, 0) // 2.0 ratio: 10 mana can only absorb 5 damage
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
// Incoming: 50,000 damage
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 50000 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
// 10 mana / 2.0 = 5 damage absorbed
|
||||
expect(out.absorbedPhys256).toBe(5 * FIXED_ONE)
|
||||
expect(out.manaDrained256).toBe(10 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(0)
|
||||
// Shield collapsed
|
||||
expect(bus.hasState('energyshield')).toBe(false)
|
||||
expect(bus.hasState(60)).toBe(false)
|
||||
})
|
||||
|
||||
it('stresses 0 mana available: 0 absorbed, shield collapses instantly', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 100 * FIXED_ONE,
|
||||
mana: 0, // 0 mana!
|
||||
})
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 500 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
expect(out.absorbedPhys256).toBe(0)
|
||||
expect(out.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(0)
|
||||
expect(bus.hasState('energyshield')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 3. Lightning Mastery (63) Adversarial Stress
|
||||
// =========================================================================
|
||||
describe('3. Lightning Mastery (63) Stress Testing', () => {
|
||||
it('stresses formula across extreme skill levels', () => {
|
||||
expect(calculateLightningMasteryBonusPct(1)).toBe(50)
|
||||
expect(calculateLightningMasteryBonusPct(10)).toBe(158)
|
||||
expect(calculateLightningMasteryBonusPct(20)).toBe(278)
|
||||
expect(calculateLightningMasteryBonusPct(40)).toBe(518)
|
||||
expect(calculateLightningMasteryBonusPct(99)).toBe(50 + 98 * 12) // 1226%
|
||||
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
|
||||
expect(calculateLightningMasteryBonusPct(-5)).toBe(0)
|
||||
})
|
||||
|
||||
it('proves multiplicative scaling is strictly greater than additive scaling for all 5 skills', () => {
|
||||
// 1. Charged Bolt: Base * (1 + 1.20) * (1 + 2.78) vs Base * (1 + 1.20 + 2.78)
|
||||
const cbBase = calculateChargedBoltDamage(20)
|
||||
const cbBoth = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
const cbAdditiveMin = Math.floor(cbBase.min * (1 + 1.20 + 2.78))
|
||||
const cbAdditiveMax = Math.floor(cbBase.max * (1 + 1.20 + 2.78))
|
||||
expect(cbBoth.min).toBeGreaterThan(cbAdditiveMin)
|
||||
expect(cbBoth.max).toBeGreaterThan(cbAdditiveMax)
|
||||
|
||||
// 2. Lightning: Base * (1 + 1.60) * (1 + 2.78) vs Base * (1 + 1.60 + 2.78)
|
||||
const ltBase = calculateLightningDamage(20)
|
||||
const ltBoth = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
|
||||
const ltAdditiveMax = Math.floor(ltBase.max * (1 + 1.60 + 2.78))
|
||||
expect(ltBoth.max).toBeGreaterThan(ltAdditiveMax)
|
||||
|
||||
// 3. Chain Lightning: Base * (1 + 0.80) * (1 + 2.78) vs Base * (1 + 0.80 + 2.78)
|
||||
const clBase = calculateChainLightningDamage(20)
|
||||
const clBoth = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
const clAdditiveMax = Math.floor(clBase.max * (1 + 0.80 + 2.78))
|
||||
expect(clBoth.max).toBeGreaterThan(clAdditiveMax)
|
||||
|
||||
// 4. Nova: Base * (1 + 2.78)
|
||||
const novaBase = calculateNovaDamage(20)
|
||||
const novaMastered = calculateNovaDamage(20, { lightningMastery: 20 })
|
||||
expect(novaMastered.min).toBe(Math.floor(novaBase.min * 3.78))
|
||||
expect(novaMastered.max).toBe(Math.floor(novaBase.max * 3.78))
|
||||
|
||||
// 5. Thunder Storm: Base * (1 + 2.78)
|
||||
const tsBase = calculateThunderStormDamage(20)
|
||||
const tsMastered = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(tsMastered.min).toBe(Math.floor(tsBase.min * 3.78))
|
||||
expect(tsMastered.max).toBe(Math.floor(tsBase.max * 3.78))
|
||||
})
|
||||
|
||||
it('stresses elemental isolation in executeSUnitDmg (Cold, Fire, Poison, Magic untouched)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
|
||||
// Level 40 Lightning Mastery (+518%)
|
||||
attackerStats.setBaseSkillLevel(63, 40)
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
|
||||
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
|
||||
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
|
||||
|
||||
// Fire test
|
||||
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resFire.totalDamage).toBe(100)
|
||||
|
||||
// Cold test
|
||||
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 59,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'cold',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resCold.totalDamage).toBe(100)
|
||||
|
||||
// Poison test
|
||||
const resPois = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 1,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'pois',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resPois.totalDamage).toBe(100)
|
||||
|
||||
// Lightning test -> should be scaled by +518%: 100 * 6.18 = 618!
|
||||
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 49,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'ltng',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resLtng.totalDamage).toBe(618)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 4. Invariants & System Integrity
|
||||
// =========================================================================
|
||||
describe('4. Invariants & System Integrity', () => {
|
||||
it('strictly preserves BATCH1_SKILLS length at 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
|
||||
it('verifies thunderstorm1 canonical missile integrity', () => {
|
||||
const msl = CANONICAL_113C_MISSILES['thunderstorm1']
|
||||
expect(msl).toBeDefined()
|
||||
expect(msl?.id).toBe(166)
|
||||
expect(msl?.celFile).toBe('LightningboltBig')
|
||||
expect(msl?.animLen).toBe(8)
|
||||
expect(msl?.trans).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,897 @@
|
|||
/**
|
||||
* Adversarial Stress & Correctness Verification Suite — Milestone M4
|
||||
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
|
||||
* Author: challenger_lightning_m4 (teamwork_preview_challenger)
|
||||
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
|
||||
*
|
||||
* Test Dimensions:
|
||||
* 1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite:
|
||||
* - Fuzz slvl 1 to 50: verify cadence strictly follows Math.max(25, 125 - Math.floor(110*slvl/(slvl+6))).
|
||||
* - Verify nearest monster acquisition strictly selects closest target <= 17 subtiles (340px) and ignores dead, out-of-range, or invalid targets.
|
||||
* - Verify 0-synergy invariant and multiplicative scaling with Lightning Mastery.
|
||||
* 2. Energy Shield Mechanics & Edge Cases:
|
||||
* - Absorption curve fuzzing: verify 20% at lvl 1, 55% at lvl 8, 71% at lvl 16, 75% at lvl 20, 95% hard cap at lvl 40.
|
||||
* - Telekinesis hard synergy ratio: verify ratio ranges from 2.0 (0 hard points) down to 0.75 (20 hard points). Verify soft points (+allskills) DO NOT decrease ratio.
|
||||
* - Damage absorption order: raw damage absorbed BEFORE elemental/physical resistances are applied to player.
|
||||
* - Poison & Open Wounds bypass: verify poison damage and open wounds deal direct HP damage with 0 mana drain and 0 absorption.
|
||||
* - Mana depletion & collapse: when damage exceeds available mana, verify mana drops to 0, excess unabsorbed damage spills over to HP, and state 'energyshield' / state 60 is immediately removed.
|
||||
* 3. Lightning Mastery Multiplicative Scaling:
|
||||
* - Verify scaling is multiplicative with synergies: Base * (1 + Synergy/100) * (1 + Mastery/100), NOT additive Base * (1 + (Synergy + Mastery)/100).
|
||||
* - Test across Charged Bolt, Lightning, Chain Lightning, Nova, and Thunder Storm.
|
||||
* - Verify UnitStatList accrues passive_ltng_mastery correctly.
|
||||
* 4. Engine & State Bus Integrity:
|
||||
* - Invariant check: BATCH1_SKILLS length strictly 38.
|
||||
* - Clean up any scratch files after testing.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateEnergyShieldDuration,
|
||||
calculateEnergyShieldAbsorbPct,
|
||||
calculateEnergyShieldManaRatio,
|
||||
calculateLightningMasteryBonusPct,
|
||||
calculateChargedBoltDamage,
|
||||
calculateLightningDamage,
|
||||
calculateChainLightningDamage,
|
||||
calculateNovaDamage,
|
||||
calculateSkillDamage,
|
||||
BATCH1_SKILLS,
|
||||
CANONICAL_113C_MISSILES,
|
||||
} from '../src/game/skills.ts'
|
||||
import {
|
||||
executeSUnitDmg,
|
||||
type CombatUnitContext,
|
||||
} from '../src/game/engine/combat-pipeline.ts'
|
||||
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../src/game/engine/state-bus.ts'
|
||||
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts'
|
||||
import { getSkillRange, getSkillMana, castSkill } from '../src/scene/act-scene.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
||||
import { OVERLAY_METAS } from '../src/render/overlays-meta.ts'
|
||||
import { MISSILE_METAS } from '../src/render/missiles-meta.ts'
|
||||
|
||||
const DUMMY_INPUT = {
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
pickingUp: false,
|
||||
talking: false,
|
||||
digits: [],
|
||||
saving: false,
|
||||
loading: false,
|
||||
}
|
||||
|
||||
function createHarnessEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) {
|
||||
const dummyTerrain: WorldMapProvider = {
|
||||
widthPx: 2000,
|
||||
heightPx: 2000,
|
||||
overlap: () => 0,
|
||||
isMissileBlocked: () => false,
|
||||
}
|
||||
|
||||
const dummyOpts: GameEngineOptions = {
|
||||
spawn: { x: 100, y: 100 },
|
||||
stats: [],
|
||||
xpTable: DEMO_EXPERIENCE,
|
||||
skills: DEMO_SKILLS,
|
||||
npcDefs: [],
|
||||
questDefs: DEMO_QUESTS,
|
||||
combatOptions: {
|
||||
playerSpeed: 4,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 10,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 1,
|
||||
respawnTicks: 100,
|
||||
},
|
||||
talkRadius: 50,
|
||||
pickupRadius: 30,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
monsterCount: 0,
|
||||
}
|
||||
|
||||
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
||||
engine.world.player.x = 100
|
||||
engine.world.player.y = 100
|
||||
engine.world.player.hp = 1000
|
||||
engine.world.player.maxHp = 1000
|
||||
engine.world.player.mana = 1000
|
||||
engine.world.player.maxMana = 1000
|
||||
engine.world.player.cooldown = 0
|
||||
|
||||
if (monsters) {
|
||||
engine.world.monsters.length = 0
|
||||
for (let i = 0; i < monsters.length; i++) {
|
||||
const m = monsters[i]!
|
||||
const isUndead = m.isUndead ?? false
|
||||
engine.world.monsters.push({
|
||||
index: i,
|
||||
stats: {
|
||||
id: isUndead ? 'skeleton' : 'fallen',
|
||||
name: m.name ?? (isUndead ? 'Skeleton' : 'Fallen'),
|
||||
hp: m.hp ?? 100,
|
||||
damage: 20,
|
||||
cooldownTicks: 25,
|
||||
reach: 25,
|
||||
aggroRadius: 200,
|
||||
speed: 0,
|
||||
xp: 10,
|
||||
},
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
hp: m.hp ?? 100,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
...(isUndead ? { isUndead: true } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
describe('Milestone M4 Adversarial Stress & Verification Suite (Challenger Lightning M4)', () => {
|
||||
// =========================================================================
|
||||
// 1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite
|
||||
// =========================================================================
|
||||
describe('1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite', () => {
|
||||
it('fuzzes slvl from 1 to 50: cadence strictly follows Math.max(25, 125 - Math.floor(110*slvl/(slvl+6)))', () => {
|
||||
let previousInterval = Infinity
|
||||
for (let slvl = 1; slvl <= 50; slvl++) {
|
||||
const expected = Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6)))
|
||||
const actual = calculateThunderStormInterval(slvl)
|
||||
expect(actual).toBe(expected)
|
||||
// Monotonic decay check: higher slvl must yield shorter or equal interval
|
||||
expect(actual).toBeLessThanOrEqual(previousInterval)
|
||||
previousInterval = actual
|
||||
}
|
||||
|
||||
// Checkpoint sanity checks
|
||||
expect(calculateThunderStormInterval(1)).toBe(110) // 125 - 15 = 110 (4.4s)
|
||||
expect(calculateThunderStormInterval(2)).toBe(98) // 125 - 27 = 98 (3.92s)
|
||||
expect(calculateThunderStormInterval(5)).toBe(75) // 125 - 50 = 75 (3.0s)
|
||||
expect(calculateThunderStormInterval(10)).toBe(57) // 125 - 68 = 57 (2.28s)
|
||||
expect(calculateThunderStormInterval(20)).toBe(41) // 125 - 84 = 41 (1.64s)
|
||||
expect(calculateThunderStormInterval(30)).toBe(34) // 125 - 91 = 34 (1.36s)
|
||||
expect(calculateThunderStormInterval(40)).toBe(30) // 125 - 95 = 30 (1.20s)
|
||||
expect(calculateThunderStormInterval(50)).toBe(27) // 125 - 98 = 27 (1.08s)
|
||||
|
||||
// Extreme boundary clamping (floor 25 frames reached at slvl >= 60)
|
||||
expect(calculateThunderStormInterval(55)).toBe(26) // 125 - 99 = 26
|
||||
expect(calculateThunderStormInterval(60)).toBe(25) // 125 - 100 = 25 (exact floor threshold)
|
||||
expect(calculateThunderStormInterval(99)).toBe(25)
|
||||
expect(calculateThunderStormInterval(500)).toBe(25)
|
||||
expect(calculateThunderStormInterval(0)).toBe(110)
|
||||
expect(calculateThunderStormInterval(-10)).toBe(110)
|
||||
})
|
||||
|
||||
it('fuzzes duration across slvl 1 to 50: strictly follows 800 + (slvl - 1) * 200 frames', () => {
|
||||
for (let slvl = 1; slvl <= 50; slvl++) {
|
||||
const expected = 800 + (slvl - 1) * 200
|
||||
expect(calculateThunderStormDuration(slvl)).toBe(expected)
|
||||
}
|
||||
expect(calculateThunderStormDuration(1)).toBe(800) // 32s
|
||||
expect(calculateThunderStormDuration(10)).toBe(2600) // 104s
|
||||
expect(calculateThunderStormDuration(20)).toBe(4600) // 184s
|
||||
expect(calculateThunderStormDuration(40)).toBe(8600) // 344s
|
||||
expect(calculateThunderStormDuration(0)).toBe(800)
|
||||
expect(calculateThunderStormDuration(-5)).toBe(800)
|
||||
})
|
||||
|
||||
it('verifies 0-synergy invariant on Thunder Storm: synergies provide zero bonus in 1.13c', () => {
|
||||
for (let slvl = 1; slvl <= 30; slvl += 5) {
|
||||
const base = calculateThunderStormDamage(slvl)
|
||||
// Adversarial synergy inputs
|
||||
const withFakeSynergies = calculateThunderStormDamage(slvl, {
|
||||
chargedBolt: 20,
|
||||
lightning: 20,
|
||||
nova: 20,
|
||||
chainLightning: 20,
|
||||
} as any)
|
||||
expect(withFakeSynergies.min).toBe(base.min)
|
||||
expect(withFakeSynergies.max).toBe(base.max)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies 5-band piecewise base damage scaling for Thunder Storm', () => {
|
||||
// Level 1: 1 - 100
|
||||
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
|
||||
// Level 8: +10/lvl (70) -> 71 - 170
|
||||
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
|
||||
// Level 16: +10/lvl (80) -> 151 - 250
|
||||
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
|
||||
// Level 22: +11/lvl (66) -> 217 - 316
|
||||
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
|
||||
// Level 28: +11/lvl (66) -> 283 - 382
|
||||
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
|
||||
// Level 35: +11/lvl (77) -> 360 - 459
|
||||
expect(calculateThunderStormDamage(35)).toEqual({ min: 360, max: 459 })
|
||||
})
|
||||
|
||||
it('verifies multiplicative scaling with Lightning Mastery on Thunder Storm', () => {
|
||||
// Level 20 TS base: 195 - 294
|
||||
const base = calculateThunderStormDamage(20)
|
||||
expect(base).toEqual({ min: 195, max: 294 })
|
||||
|
||||
// With LM 1 (+50%): floor(195 * 1.5) = 292, floor(294 * 1.5) = 441
|
||||
expect(calculateThunderStormDamage(20, { lightningMastery: 1 })).toEqual({ min: 292, max: 441 })
|
||||
|
||||
// With LM 20 (+278%): floor(195 * 3.78) = 737, floor(294 * 3.78) = 1111
|
||||
expect(calculateThunderStormDamage(20, { lightningMastery: 20 })).toEqual({ min: 737, max: 1111 })
|
||||
|
||||
// With LM 40 (+518%): floor(195 * 6.18) = 1205, floor(294 * 6.18) = 1816
|
||||
expect(calculateThunderStormDamage(20, { lightningMastery: 40 })).toEqual({ min: 1205, max: 1816 })
|
||||
|
||||
// Overload check: 3rd parameter
|
||||
expect(calculateThunderStormDamage(20, undefined, 20)).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
|
||||
it('adversarially stress-tests nearest monster acquisition in GameEngine runtime simulation', () => {
|
||||
// Player is at (100, 100)
|
||||
const engine = createHarnessEngine([
|
||||
{ x: 180, y: 100, hp: 500, name: 'CloseMonster' }, // dist 80px <= 340px
|
||||
{ x: 250, y: 100, hp: 500, name: 'MidMonster' }, // dist 150px <= 340px
|
||||
{ x: 380, y: 100, hp: 500, name: 'FarMonster' }, // dist 280px <= 340px
|
||||
{ x: 441, y: 100, hp: 500, name: 'OutOfRangeMonster' }, // dist 341px > 340px
|
||||
])
|
||||
|
||||
const player = engine.world.player
|
||||
player.thunderstormTicks = 1000
|
||||
player.thunderstormInterval = 57 // slvl 10 interval
|
||||
player.thunderstormNextStrike = 10 // strike in 10 ticks
|
||||
player.thunderstormDamage = 150
|
||||
|
||||
const collectedHits: { x: number; y: number; amount: number }[] = []
|
||||
|
||||
// Advance 9 ticks: no strike yet
|
||||
for (let t = 0; t < 9; t++) {
|
||||
engine.tick(DUMMY_INPUT)
|
||||
for (const e of engine.world.events) {
|
||||
if (e.kind === 'monsterHit') collectedHits.push(e as any)
|
||||
}
|
||||
}
|
||||
expect(collectedHits.length).toBe(0)
|
||||
expect(engine.world.monsters[0]!.hp).toBe(500)
|
||||
expect(engine.world.monsters[1]!.hp).toBe(500)
|
||||
expect(engine.world.monsters[2]!.hp).toBe(500)
|
||||
expect(engine.world.monsters[3]!.hp).toBe(500)
|
||||
|
||||
// Tick 10: strike occurs! Must select closest monster (Monster 0 at 80px)
|
||||
engine.tick(DUMMY_INPUT)
|
||||
for (const e of engine.world.events) {
|
||||
if (e.kind === 'monsterHit') collectedHits.push(e as any)
|
||||
}
|
||||
expect(collectedHits.length).toBeGreaterThanOrEqual(1)
|
||||
expect(collectedHits[0]!.x).toBe(180)
|
||||
expect(collectedHits[0]!.amount).toBe(150)
|
||||
expect(engine.world.monsters[0]!.hp).toBe(350) // took 150 damage
|
||||
expect(engine.world.monsters[1]!.hp).toBe(500)
|
||||
expect(engine.world.monsters[2]!.hp).toBe(500)
|
||||
expect(engine.world.monsters[3]!.hp).toBe(500)
|
||||
expect(player.thunderstormNextStrike).toBe(57) // reset to interval
|
||||
|
||||
// Kill Monster 0
|
||||
engine.world.monsters[0]!.hp = 0
|
||||
engine.world.monsters[0]!.state = 'dead'
|
||||
|
||||
// Advance 57 ticks for next strike
|
||||
const hitsBeforeSecondStrike = collectedHits.length
|
||||
for (let t = 0; t < 57; t++) {
|
||||
engine.tick(DUMMY_INPUT)
|
||||
for (const e of engine.world.events) {
|
||||
if (e.kind === 'monsterHit') collectedHits.push(e as any)
|
||||
}
|
||||
}
|
||||
expect(collectedHits.length).toBeGreaterThan(hitsBeforeSecondStrike)
|
||||
// Dead Monster 0 must be ignored, next closest is Monster 1 (150px)
|
||||
expect(collectedHits[hitsBeforeSecondStrike]!.x).toBe(250)
|
||||
expect(engine.world.monsters[1]!.hp).toBe(350) // took 150 damage
|
||||
expect(engine.world.monsters[2]!.hp).toBe(500)
|
||||
expect(engine.world.monsters[3]!.hp).toBe(500)
|
||||
|
||||
// Kill Monster 1 and Monster 2
|
||||
engine.world.monsters[1]!.hp = 0
|
||||
engine.world.monsters[1]!.state = 'dead'
|
||||
engine.world.monsters[2]!.hp = 0
|
||||
engine.world.monsters[2]!.state = 'dead'
|
||||
|
||||
// Advance 57 ticks: Monster 3 is at 341px (out of range, limit is 340px)
|
||||
const hitsBeforeOutOfRange = collectedHits.length
|
||||
for (let t = 0; t < 57; t++) {
|
||||
engine.tick(DUMMY_INPUT)
|
||||
for (const e of engine.world.events) {
|
||||
if (e.kind === 'monsterHit') collectedHits.push(e as any)
|
||||
}
|
||||
}
|
||||
// Out of range monster MUST NOT take damage, no new hit events
|
||||
expect(collectedHits.length).toBe(hitsBeforeOutOfRange)
|
||||
expect(engine.world.monsters[3]!.hp).toBe(500)
|
||||
|
||||
// Add a monster exactly at 340px: x = 440, y = 100
|
||||
engine.world.monsters.push({
|
||||
index: 4,
|
||||
stats: {
|
||||
id: 'fallen',
|
||||
name: 'ExactBoundaryMonster',
|
||||
hp: 200,
|
||||
damage: 10,
|
||||
cooldownTicks: 25,
|
||||
reach: 25,
|
||||
aggroRadius: 200,
|
||||
speed: 0,
|
||||
xp: 10,
|
||||
},
|
||||
x: 440,
|
||||
y: 100,
|
||||
hp: 200,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
})
|
||||
|
||||
// Advance 57 ticks: Monster 4 is within <= 340px boundary and must be struck!
|
||||
for (let t = 0; t < 57; t++) {
|
||||
engine.tick(DUMMY_INPUT)
|
||||
for (const e of engine.world.events) {
|
||||
if (e.kind === 'monsterHit') collectedHits.push(e as any)
|
||||
}
|
||||
}
|
||||
expect(collectedHits.length).toBeGreaterThan(hitsBeforeOutOfRange)
|
||||
expect(collectedHits[collectedHits.length - 1]!.x).toBe(440)
|
||||
expect(engine.world.monsters[4]!.hp).toBe(50) // 200 - 150 = 50
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 2. Energy Shield Mechanics & Edge Cases
|
||||
// =========================================================================
|
||||
describe('2. Energy Shield Mechanics & Edge Cases', () => {
|
||||
it('fuzzes absorption curve slvl 1 to 50: verifies 20% at lvl 1, 55% at lvl 8, 71% at lvl 16, 75% at lvl 20, 95% hard cap at lvl 40', () => {
|
||||
for (let slvl = 1; slvl <= 50; slvl++) {
|
||||
let expected = 20
|
||||
if (slvl > 1) {
|
||||
const b1 = Math.min(slvl - 1, 7)
|
||||
expected += b1 * 5
|
||||
if (slvl > 8) {
|
||||
const b2 = Math.min(slvl - 8, 8)
|
||||
expected += b2 * 2
|
||||
}
|
||||
if (slvl > 16) {
|
||||
const b3 = slvl - 16
|
||||
expected += b3 * 1
|
||||
}
|
||||
}
|
||||
expected = Math.min(95, expected)
|
||||
expect(calculateEnergyShieldAbsorbPct(slvl)).toBe(expected)
|
||||
}
|
||||
|
||||
// Checkpoint validations
|
||||
expect(calculateEnergyShieldAbsorbPct(1)).toBe(20)
|
||||
expect(calculateEnergyShieldAbsorbPct(8)).toBe(55)
|
||||
expect(calculateEnergyShieldAbsorbPct(16)).toBe(71)
|
||||
expect(calculateEnergyShieldAbsorbPct(20)).toBe(75)
|
||||
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
|
||||
// Clamped above 40
|
||||
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(50)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(0)).toBe(20)
|
||||
expect(calculateEnergyShieldAbsorbPct(-5)).toBe(20)
|
||||
})
|
||||
|
||||
it('fuzzes Telekinesis hard synergy ratio: strictly (32 - min(20, blvl))/16 from 2.0 down to 0.75 floor', () => {
|
||||
for (let tk = 0; tk <= 40; tk++) {
|
||||
const expected = (32 - Math.min(20, Math.max(0, tk))) / 16
|
||||
expect(calculateEnergyShieldManaRatio(tk)).toBe(expected)
|
||||
}
|
||||
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
|
||||
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5)
|
||||
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
|
||||
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
|
||||
expect(calculateEnergyShieldManaRatio(30)).toBe(0.75) // clamped to floor 0.75
|
||||
})
|
||||
|
||||
it('verifies soft points (+allskills, +bonusSkill, +classSkills) DO NOT decrease Telekinesis ratio in StateBus', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
// 16 hard points in Telekinesis (43) => authentic ratio = 1.0
|
||||
stats.setBaseSkillLevel(43, 16)
|
||||
// Add heavy soft skills bonuses: +20 all skills, +8 single skill bonus
|
||||
stats.setAllSkillsBonus(20)
|
||||
stats.setBonusSkillLevel(43, 8)
|
||||
// Effective skill level is now 16 + 20 + 8 = 44!
|
||||
expect(stats.getEffectiveSkillLevel(43)).toBe(44)
|
||||
expect(stats.getBaseSkillLevel(43)).toBe(16)
|
||||
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
// Trigger 200 incoming physical damage:
|
||||
// Desired absorb = 75% of 200 = 150 damage.
|
||||
// If soft skills counted (effective lvl 44 -> max clamped 20), ratio would be 0.75, draining 150 * 0.75 = 112.5 mana.
|
||||
// But 1.13c strictly uses hard points (blvl = 16 -> ratio = 1.0), so mana drained MUST be 150!
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
|
||||
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(850) // 1000 - 150 = 850
|
||||
})
|
||||
|
||||
it('empirically proves damage absorption order: raw damage absorbed BEFORE player resistances are applied', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
fireresist: 75, // 75% Fire Resistance
|
||||
})
|
||||
defenderStats.setBaseSkillLevel(43, 16) // ratio = 1.0
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
defenderBus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'attacker-1',
|
||||
name: 'Attacker',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'defender-1',
|
||||
name: 'Sorceress',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// 400 raw Fire Damage spell
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 400 * FIXED_ONE,
|
||||
elemMax256: 400 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Ground truth logic:
|
||||
// 1. Raw Fire = 400.
|
||||
// 2. ES absorbs 75% of raw = 300 damage.
|
||||
// 3. Mana drained: 300 * 1.0 = 300. Mana becomes 700.
|
||||
// 4. Remaining raw fire = 100.
|
||||
// 5. 75% Fire Resistance mitigates 100 to 25.
|
||||
// 6. Net HP loss: 25. Defender HP becomes 975.
|
||||
expect(defenderStats.getMana()).toBe(700)
|
||||
expect(res.totalDamage).toBe(25)
|
||||
expect(defenderStats.getHp()).toBe(975)
|
||||
|
||||
// Contrast with incorrect absorb-after-resistance order:
|
||||
// In incorrect order, 400 would be mitigated to 100 first, then ES absorbs 75, draining only 75 mana.
|
||||
// We verify mana drained is strictly 300!
|
||||
})
|
||||
|
||||
it('verifies pre-resistance absorption with Physical Damage and Damage Resist (DR%)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
damageresist: 50, // 50% Physical Damage Reduction (DR%)
|
||||
})
|
||||
defenderStats.setBaseSkillLevel(43, 20) // ratio = 0.75
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
defenderBus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'attacker-1',
|
||||
name: 'Attacker',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'defender-1',
|
||||
name: 'Sorceress',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// Attack: 400 Physical Damage
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 1, // Normal Attack
|
||||
attackKind: 'melee',
|
||||
autoHit: true,
|
||||
srcDam: 0,
|
||||
flatPhysMin256: 400 * FIXED_ONE,
|
||||
flatPhysMax256: 400 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Ground truth logic:
|
||||
// 1. Raw Physical = 400.
|
||||
// 2. ES absorbs 75% of raw = 300 damage.
|
||||
// 3. Mana drained: 300 * 0.75 = 225 mana.
|
||||
// 4. Remaining raw physical = 100.
|
||||
// 5. 50% DR mitigates remaining 100 to 50 net HP damage.
|
||||
expect(defenderStats.getMana()).toBe(775) // 1000 - 225
|
||||
expect(res.totalDamage).toBe(50)
|
||||
expect(defenderStats.getHp()).toBe(950)
|
||||
})
|
||||
|
||||
it('empirically verifies Poison and Open Wounds strictly bypass Energy Shield directly to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 40, // 95% absorb!
|
||||
})
|
||||
|
||||
// Case 1: elemType === 'pois'
|
||||
const outPois = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 0,
|
||||
incomingElem256: 300 * FIXED_ONE,
|
||||
elemType: 'pois',
|
||||
})
|
||||
expect(outPois.absorbedElem256).toBe(0)
|
||||
expect(outPois.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
|
||||
// Case 2: isPoison flag true
|
||||
const outPoisFlag = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 0,
|
||||
incomingElem256: 300 * FIXED_ONE,
|
||||
isPoison: true,
|
||||
})
|
||||
expect(outPoisFlag.absorbedElem256).toBe(0)
|
||||
expect(outPoisFlag.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
|
||||
// Case 3: isOpenWounds flag true
|
||||
const outOW = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 250 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
isOpenWounds: true,
|
||||
})
|
||||
expect(outOW.absorbedPhys256).toBe(0)
|
||||
expect(outOW.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
|
||||
// Case 4: Composite packet: 200 physical + 100 poison
|
||||
const outComposite = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 100 * FIXED_ONE,
|
||||
elemType: 'pois',
|
||||
isPoison: true,
|
||||
})
|
||||
// Physical: 95% of 200 = 190 absorbed.
|
||||
// Poison: 0 absorbed.
|
||||
expect(outComposite.absorbedPhys256).toBe(190 * FIXED_ONE)
|
||||
expect(outComposite.absorbedElem256).toBe(0)
|
||||
expect(outComposite.manaDrained256).toBe(380 * FIXED_ONE) // 190 * 2.0 ratio (tk = 0)
|
||||
expect(stats.getMana()).toBe(620)
|
||||
})
|
||||
|
||||
it('empirically verifies mana depletion, shield collapse, and spillover to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 100 * FIXED_ONE,
|
||||
mana: 60 * FIXED_ONE, // Only 60 mana available!
|
||||
})
|
||||
stats.setBaseSkillLevel(43, 0) // tk = 0 -> ratio = 2.0 (needs 2 mana per 1 damage absorbed)
|
||||
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
// Incoming: 200 physical damage.
|
||||
// Desired absorb: 150 damage, requiring 300 mana.
|
||||
// Available mana: 60 mana.
|
||||
// Actual absorb: Math.floor(60 / 2.0) = 30 damage.
|
||||
// Mana drained: exactly 60 -> drops to 0.
|
||||
// Remainder unabsorbed damage: 200 - 30 = 170 damage spills over.
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
expect(out.absorbedPhys256).toBe(30 * FIXED_ONE)
|
||||
expect(out.manaDrained256).toBe(60 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(0)
|
||||
|
||||
// State bus MUST remove both 'energyshield' and state 60 immediately
|
||||
expect(bus.hasState('energyshield')).toBe(false)
|
||||
expect(bus.hasState(60)).toBe(false)
|
||||
|
||||
// Follow-up hit after collapse: 200 physical damage
|
||||
const out2 = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
// Since shield is collapsed, 0 is absorbed!
|
||||
expect(out2.absorbedPhys256).toBe(0)
|
||||
expect(out2.manaDrained256).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 3. Lightning Mastery Multiplicative Scaling
|
||||
// =========================================================================
|
||||
describe('3. Lightning Mastery Multiplicative Scaling', () => {
|
||||
it('verifies multiplicative scaling: Base * (1 + Syn/100) * (1 + LM/100) strictly exceeds additive across skills', () => {
|
||||
// 1. Charged Bolt (38)
|
||||
// Level 20 base: 25 - 27
|
||||
const cbBase = calculateChargedBoltDamage(20)
|
||||
const cbSynBonus = 20 * 6 // +120% from 20 hard points in Lightning
|
||||
const cbLmBonus = calculateLightningMasteryBonusPct(20) // +278%
|
||||
const cbMult = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
const expectedCbMin = Math.floor(cbBase.min * (1 + cbSynBonus / 100) * (1 + cbLmBonus / 100))
|
||||
const expectedCbMax = Math.floor(cbBase.max * (1 + cbSynBonus / 100) * (1 + cbLmBonus / 100))
|
||||
expect(cbMult.min).toBe(expectedCbMin)
|
||||
expect(cbMult.max).toBe(expectedCbMax)
|
||||
// Additive counter-check: Base * (1 + 1.20 + 2.78) = Base * 4.98
|
||||
const additiveCbMin = Math.floor(cbBase.min * (1 + (cbSynBonus + cbLmBonus) / 100))
|
||||
expect(cbMult.min).toBeGreaterThan(additiveCbMin)
|
||||
|
||||
// 2. Lightning (49)
|
||||
// Level 20 base: 1 - 272
|
||||
const lBase = calculateLightningDamage(20)
|
||||
const lSynBonus = 20 * 8 // +160% from Chain Lightning
|
||||
const lMult = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
|
||||
const expectedLMin = Math.floor(lBase.min * (1 + lSynBonus / 100) * (1 + cbLmBonus / 100))
|
||||
const expectedLMax = Math.floor(lBase.max * (1 + lSynBonus / 100) * (1 + cbLmBonus / 100))
|
||||
expect(lMult.min).toBe(expectedLMin)
|
||||
expect(lMult.max).toBe(expectedLMax)
|
||||
const additiveLMax = Math.floor(lBase.max * (1 + (lSynBonus + cbLmBonus) / 100))
|
||||
expect(lMult.max).toBeGreaterThan(additiveLMax)
|
||||
|
||||
// 3. Chain Lightning (53)
|
||||
// Level 20 base: 1 - 188
|
||||
const clBase = calculateChainLightningDamage(20)
|
||||
const clSynBonus = 20 * 4 // +80% from Lightning
|
||||
const clMult = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
const expectedClMin = Math.floor(clBase.min * (1 + clSynBonus / 100) * (1 + cbLmBonus / 100))
|
||||
const expectedClMax = Math.floor(clBase.max * (1 + clSynBonus / 100) * (1 + cbLmBonus / 100))
|
||||
expect(clMult.min).toBe(expectedClMin)
|
||||
expect(clMult.max).toBe(expectedClMax)
|
||||
const additiveClMax = Math.floor(clBase.max * (1 + (clSynBonus + cbLmBonus) / 100))
|
||||
expect(clMult.max).toBeGreaterThan(additiveClMax)
|
||||
|
||||
// 4. Nova (48) (0 synergies in 1.13c; LM scales base damage multiplicatively)
|
||||
const nBase = calculateNovaDamage(20)
|
||||
const nMult = calculateNovaDamage(20, { lightningMastery: 20 })
|
||||
expect(nMult.min).toBe(Math.floor(nBase.min * (1 + cbLmBonus / 100)))
|
||||
expect(nMult.max).toBe(Math.floor(nBase.max * (1 + cbLmBonus / 100)))
|
||||
|
||||
// 5. Thunder Storm (57) (0 synergies in 1.13c; LM scales base damage multiplicatively)
|
||||
const tsBase = calculateThunderStormDamage(20)
|
||||
const tsMult = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(tsMult.min).toBe(Math.floor(tsBase.min * (1 + cbLmBonus / 100)))
|
||||
expect(tsMult.max).toBe(Math.floor(tsBase.max * (1 + cbLmBonus / 100)))
|
||||
})
|
||||
|
||||
it('verifies UnitStatList dynamically accrues passive_ltng_mastery with soft skills and isolates other elements', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
|
||||
// Initially 0
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(0)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(0)
|
||||
|
||||
// Level 20 base: +278%
|
||||
stats.setBaseSkillLevel(63, 20)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(278)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(278)
|
||||
|
||||
// +5 all skills -> effective slvl 25 -> 50 + 24*12 = 338%
|
||||
stats.setAllSkillsBonus(5)
|
||||
expect(stats.getEffectiveSkillLevel(63)).toBe(25)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(338)
|
||||
|
||||
// +3 single skill bonus -> effective slvl 28 -> 50 + 27*12 = 374%
|
||||
stats.setBonusSkillLevel(63, 3)
|
||||
expect(stats.getEffectiveSkillLevel(63)).toBe(28)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(374)
|
||||
|
||||
// +2 class skills -> effective slvl 30 -> 50 + 29*12 = 398%
|
||||
stats.setClassSkillsBonus(2)
|
||||
expect(stats.getEffectiveSkillLevel(63)).toBe(30)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(398)
|
||||
|
||||
// Verify fire, cold, and poison masteries remain unaffected
|
||||
expect(stats.getAccruedStat('passive_fire_mastery')).toBe(0)
|
||||
expect(stats.getAccruedStat('passive_cold_mastery')).toBe(0)
|
||||
expect(stats.getAccruedStat('passive_pois_mastery')).toBe(0)
|
||||
})
|
||||
|
||||
it('verifies combat pipeline executeSUnitDmg applies passive_ltng_mastery to lightning attacks only', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, {
|
||||
maxhp: 2000 * FIXED_ONE,
|
||||
hitpoints: 2000 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Attacker has Level 20 Lightning Mastery (+278%)
|
||||
attackerStats.setBaseSkillLevel(63, 20)
|
||||
expect(attackerStats.getAccruedStat('passive_ltng_mastery')).toBe(278)
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'attacker-1',
|
||||
name: 'Sorc',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'defender-1',
|
||||
name: 'Target',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// Attack 1: Lightning Damage 100
|
||||
// 100 * (1 + 2.78) = 378
|
||||
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 49,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'ltng',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resLtng.totalDamage).toBe(378)
|
||||
expect(resLtng.elemDamage256).toBe(378 * FIXED_ONE)
|
||||
|
||||
// Attack 2: Fire Damage 100 (must NOT be scaled by Lightning Mastery)
|
||||
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resFire.totalDamage).toBe(100)
|
||||
|
||||
// Attack 3: Cold Damage 100 (must NOT be scaled by Lightning Mastery)
|
||||
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 59,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'cold',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
expect(resCold.totalDamage).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 4. Engine & State Bus Integrity
|
||||
// =========================================================================
|
||||
describe('4. Engine & State Bus Integrity', () => {
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
const count = Object.keys(BATCH1_SKILLS).length
|
||||
expect(count).toBe(38)
|
||||
// Check representative Batch 1 skills exist
|
||||
expect(BATCH1_SKILLS[36]).toBeDefined() // Fire Bolt
|
||||
expect(BATCH1_SKILLS[47]).toBeDefined() // Fire Ball
|
||||
expect(BATCH1_SKILLS[54]).toBeDefined() // Teleport
|
||||
expect(BATCH1_SKILLS[112]).toBeDefined() // Blessed Hammer
|
||||
})
|
||||
|
||||
it('verifies scene query interfaces and casting mechanics for M4 skills', () => {
|
||||
// Thunder Storm (57)
|
||||
expect(getSkillMana(57)).toBe(19)
|
||||
expect(getSkillRange(57)).toBe(340)
|
||||
|
||||
// Energy Shield (58)
|
||||
expect(getSkillMana(58)).toBe(5)
|
||||
expect(getSkillRange(58)).toBe(0)
|
||||
expect(calculateSkillDamage(58, 20)).toEqual({ min: 0, max: 0 })
|
||||
|
||||
// Lightning Mastery (63)
|
||||
expect(getSkillMana(63)).toBe(0)
|
||||
expect(getSkillRange(63)).toBe(0)
|
||||
expect(calculateSkillDamage(63, 20)).toEqual({ min: 0, max: 0 })
|
||||
})
|
||||
|
||||
it('verifies canonical missile and overlay assets for M4 skills', () => {
|
||||
// thunderstorm1 missile
|
||||
const tsMsl = CANONICAL_113C_MISSILES['thunderstorm1']
|
||||
expect(tsMsl).toBeDefined()
|
||||
expect(tsMsl.id).toBe(166)
|
||||
expect(tsMsl.celFile).toBe('LightningboltBig')
|
||||
expect(MISSILE_METAS['thunderstorm1']).toBeDefined()
|
||||
|
||||
// energyshield overlay
|
||||
expect(OVERLAY_METAS['energyshield']).toBeDefined()
|
||||
expect(OVERLAY_METAS['energyshieldhit0']).toBeDefined()
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBeDefined()
|
||||
})
|
||||
|
||||
it('verifies act-scene castSkill executes cleanly for Thunder Storm and Energy Shield', () => {
|
||||
const engine = createHarnessEngine()
|
||||
const player = engine.world.player
|
||||
|
||||
const context = {
|
||||
engine,
|
||||
runtime: null as any,
|
||||
hudManager: null,
|
||||
status: { textContent: '' } as any,
|
||||
playerAnimator: null as any,
|
||||
character: null as any,
|
||||
}
|
||||
|
||||
// Cast Thunder Storm (57)
|
||||
const tsOk = castSkill(57, 100, 100, context)
|
||||
expect(tsOk).toBe(true)
|
||||
expect(player.thunderstormTicks).toBe(800) // slvl 1
|
||||
expect(player.thunderstormInterval).toBe(110)
|
||||
expect(player.thunderstormNextStrike).toBe(110)
|
||||
|
||||
// Cast Energy Shield (58)
|
||||
const esOk = castSkill(58, 100, 100, context)
|
||||
expect(esOk).toBe(true)
|
||||
expect(player.energyshieldTicks).toBe(3600) // slvl 1
|
||||
expect(player.energyshieldPct).toBe(20)
|
||||
// Check that looping overlay energyshield was spawned
|
||||
const esOv = engine.overlays.find(o => o.overlayName === 'energyshield' && o.casterId === 'player')
|
||||
expect(esOv).toBeDefined()
|
||||
expect(esOv?.loop).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue