898 lines
36 KiB
TypeScript
898 lines
36 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|
|
})
|