From 617fdfec51b9f2c935e0e6354d36d7c3efdfa938 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 21:00:53 +0000 Subject: [PATCH] test(skills): persist M3 and M4 adversarial stress test suites on main --- ...m3-charged-bolt-telekinesis-stress.test.ts | 1054 +++++++++++++++++ ...hunder-storm-energy-shield-mastery.test.ts | 443 +++++++ ...rstorm-energyshield-mastery-stress.test.ts | 897 ++++++++++++++ 3 files changed, 2394 insertions(+) create mode 100644 tests/challenger-m3-charged-bolt-telekinesis-stress.test.ts create mode 100644 tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts create mode 100644 tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts diff --git a/tests/challenger-m3-charged-bolt-telekinesis-stress.test.ts b/tests/challenger-m3-charged-bolt-telekinesis-stress.test.ts new file mode 100644 index 0000000..9838f34 --- /dev/null +++ b/tests/challenger-m3-charged-bolt-telekinesis-stress.test.ts @@ -0,0 +1,1054 @@ +/** + * Adversarial Stress & Correctness Verification Suite — Milestone M3 + * Skills: Charged Bolt (#038) & Telekinesis (#043), Engine Fix (#192, #197) + * Author: challenger_lightning_m3 (teamwork_preview_challenger) + * Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt) + * + * Test Dimensions: + * 1. Engine Fix: Verify spawned projectiles do NOT duplicate in this.projectiles across 1,000 game frames. + * 2. Charged Bolt Shotgun Effect: 24 bolts at point-blank range register 24x damage without NextDelay lockout. + * 3. Jitter Table Traversal & Trajectory Spread: Verify all 32 entries of GAT_CHARGED_BOLT_OFFSETS and seed variance. + * 4. Fuzz 1,000+ Combinations: slvl (1-99) x Lightning synergy (0-20) against 5-band table and +6% multiplier. + * 5. Obstacle Collision: Verify bolts terminate at solid tiles/walls. + * 6. Telekinesis Range Boundary: 599px (valid) vs 601px (rejected). + * 7. Item Pickup Whitelist: Fuzz allowed (potions, scrolls, gold, ammo, keys) vs rejected (weapons, armor, jewelry, charms). + * 8. Object Interaction Whitelist: chests, waypoints, stash, portals, shrines, doors, wells, urns. + * 9. Monster Combat: 1-2 damage base (+1/level), 35% knockback, 35 stun ticks. + * 10. Energy Shield Ratio Synergy: tkBlvl from 0 to 40 (ratio 2.0 down to 0.75 floor, soft point immutability). + */ + +import { describe, expect, it } from 'vitest' +import { + tickProjectiles, + calculateChargedBoltCount, + calculateChargedBoltSynergyMultiplier, + calculateChargedBoltDamage, + calculateTelekinesisDamage, + calculateEnergyShieldManaRatio, + isTelekinesisItemAllowed, + isTelekinesisObjectAllowed, + GAT_CHARGED_BOLT_OFFSETS, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + CANONICAL_113C_MISSILES, + type Projectile, +} from '../src/game/skills.ts' +import { + MissileEngine, +} from '../src/game/engine/missile-engine.ts' +import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts' +import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts' +import { UnitStatList } from '../src/game/engine/stat-list.ts' +import { StateBus } from '../src/game/engine/state-bus.ts' +import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts' +import { castSkill } from '../src/scene/act-scene.ts' +import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts' + +const DUMMY_INPUT = { + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, +} + +const OPEN_TERRAIN = { + overlap: () => 0, +} + +function createHarnessEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) { + const dummyTerrain: WorldMapProvider = { + widthPx: 2000, + heightPx: 2000, + overlap: (x, y) => { + // Wall barrier between x = 400 and x = 420 + if (x >= 400 && x <= 420 && y >= 0 && y <= 1000) return 1 + return 0 + }, + isMissileBlocked: (x, y) => { + if (x >= 400 && x <= 420 && y >= 0 && y <= 1000) return true + return 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.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: 5, + cooldownTicks: 25, + reach: 25, + aggroRadius: 200, + speed: 100, + 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 M3 Adversarial Stress & Verification Suite', () => { + + // ========================================================================= + // 1. Engine Fix Verification: No Projectile Duplication Across 1,000 Frames + // ========================================================================= + describe('1. Engine Fix: Projectile Deduplication Across 1,000 Frames', () => { + it('verifies spawned projectiles do not duplicate in this.projectiles across 1,000 game frames', () => { + const engine = createHarnessEngine([ + { x: 300, y: 100, hp: 50000 }, + { x: 350, y: 120, hp: 50000 }, + ]) + + // Seed initial projectiles that continuously emit sub-projectiles (e.g. blizzardcenter emitting shards) + engine.projectiles.push({ + skillId: 'blizzard', + x: 300, + y: 100, + vx: 0, + vy: 0, + damage: 15, + ttl: 1000, + fromPlayer: true, + missileType: 'blizzardcenter', + }) + + // Also seed an active chain lightning projectile bouncing between monsters + engine.projectiles.push({ + skillId: 'chainlightning', + x: 100, + y: 100, + vx: 15, + vy: 0, + damage: 25, + ttl: 100, + fromPlayer: true, + missileType: 'chainlightning', + chainRemaining: 10, + nextHit: 1, + nextDelay: 4, + }) + + let duplicateDetections = 0 + let totalProjectilesObserved = 0 + + for (let frame = 1; frame <= 1000; frame++) { + // Every 50 frames, periodically cast Charged Bolt to add more active missiles + if (frame % 50 === 0) { + const status = { textContent: '' } as HTMLElement + castSkill(38, 300, 100, { engine, runtime: {} as any, hudManager: null, status }) + } + + engine.tick(DUMMY_INPUT) + + // Check for any duplicate object references in this.projectiles + const projSet = new Set(engine.projectiles) + if (projSet.size !== engine.projectiles.length) { + duplicateDetections++ + } + totalProjectilesObserved += engine.projectiles.length + } + + // Assert zero duplicate object instances occurred across all 1,000 frames + expect(duplicateDetections).toBe(0) + expect(totalProjectilesObserved).toBeGreaterThan(0) + }) + + it('demonstrates pre-fix duplication defect vs post-fix deduplication on spawned projectiles', () => { + // blizzardcenter emits a shard when (ageTicks + 1) % 4 === 0. Set ageTicks = 3 to trigger emission on frame 1. + const testProjectiles: Projectile[] = [ + { + skillId: 'blizzard', + x: 300, + y: 100, + vx: 0, + vy: 0, + damage: 15, + ttl: 20, + ageTicks: 3, + fromPlayer: true, + missileType: 'blizzardcenter', + }, + ] + + const targets = [{ index: 0, x: 300, y: 100, radius: 20, alive: true }] + const outcome = tickProjectiles(testProjectiles, targets, OPEN_TERRAIN) + expect(outcome.spawnedProjectiles?.length).toBe(1) + + // With pre-fix logic: outcome.alive + outcome.spawnedProjectiles + const buggyList: Projectile[] = [] + buggyList.push(...outcome.alive) + if (outcome.spawnedProjectiles && outcome.spawnedProjectiles.length > 0) { + buggyList.push(...outcome.spawnedProjectiles) + } + + // Buggy list has duplicate instances of spawned projectiles (2 instances of the shard) + const buggySet = new Set(buggyList) + expect(buggySet.size).toBeLessThan(buggyList.length) + expect(buggyList.length - buggySet.size).toBe(outcome.spawnedProjectiles!.length) + + // With post-fix logic: outcome.alive only (1 instance of emitter + 1 instance of shard) + const fixedList: Projectile[] = [] + fixedList.push(...outcome.alive) + const fixedSet = new Set(fixedList) + expect(fixedSet.size).toBe(fixedList.length) + }) + }) + + // ========================================================================= + // 2. Charged Bolt Shotgun Effect: NextDelay = 0 Point-Blank 24x Damage + // ========================================================================= + describe('2. Charged Bolt Shotgun Effect (NextDelay = 0)', () => { + it('registers 24x damage across 24 simultaneous bolts at point-blank range without lockout in tickProjectiles', () => { + // Monster placed right in front of the caster + const monsterTarget = { index: 0, x: 105, y: 100, radius: 20, alive: true } + const targets = [monsterTarget] + + // Spawn 24 point-blank charged bolts aiming at the monster + const bolts: Projectile[] = [] + for (let i = 0; i < 24; i++) { + bolts.push({ + skillId: '38', + x: 100, + y: 100, + vx: 12, + vy: 0, + damage: 10, + ttl: 98, + fromPlayer: true, + missileType: 'chargedbolt', + nextDelay: 0, // Authentic 1.13c Missiles.txt NextDelay = 0 + pierce: false, + seed: i, + jitterIndex: i % 32, + }) + } + + const outcome = tickProjectiles(bolts, targets, { ...OPEN_TERRAIN, currentTick: 10 }) + + // All 24 bolts must register hits against the target on the exact same frame + expect(outcome.hits.length).toBe(24) + for (const hit of outcome.hits) { + expect(hit.targetIndex).toBe(0) + expect(hit.damage).toBe(10) + } + + // Total shotgun damage is exactly 24x single bolt damage + const totalDmg = outcome.hits.reduce((acc, h) => acc + h.damage, 0) + expect(totalDmg).toBe(240) + + // Since pierce is false, all 24 bolts are consumed upon hitting the monster + expect(outcome.alive.length).toBe(0) + }) + + it('registers 24 simultaneous hits in MissileEngine without NextDelay lockout', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const ownerStats = new UnitStatList() + const owner = { + id: 'player', + x: 100, + y: 100, + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + const targetStats = new UnitStatList() + targetStats.setHp256(10000 * 256) + const targetUnit: CombatUnitContext = { + id: 'boss_target', + name: 'Boss Target', + unitType: 'monster', + statList: targetStats, + stateBus: new StateBus(targetStats), + } + const targetPos = new Map([['boss_target', { x: 105, y: 100 }]]) + + // Spawn 24 chargedbolt missiles at point blank + for (let i = 0; i < 24; i++) { + engine.spawnMissile({ + missileNameOrId: 'chargedbolt', + sourceSkillId: 38, + slvl: 20, + owner, + startX: 100, + startY: 100, + targetX: 105, + targetY: 100, + dmgPacket: { minDamage: 10, maxDamage: 10, damageType: 'lightning' } as any, + vx: 12, + vy: 0, + maxRangeTicks: 98, + seed: i, + jitterIndex: i % 32, + }) + } + + expect(engine.getActiveMissiles().length).toBe(24) + + const res = engine.tick(1, [targetUnit], targetPos) + // All 24 missiles register hits + expect(res.hits.length).toBe(24) + for (const h of res.hits) { + expect(h.missileName).toBe('chargedbolt') + expect(h.targetId).toBe('boss_target') + } + }) + + it('contrasts with NextDelay > 0 skill (Nova / Chain Lightning) which restricts to 1 hit per window', () => { + const monsterTarget = { index: 0, x: 105, y: 100, radius: 20, alive: true } + const targets = [monsterTarget] + + // Spawn 24 Nova bolts with NextDelay = 4 + const novaBolts: Projectile[] = [] + for (let i = 0; i < 24; i++) { + novaBolts.push({ + skillId: '48', + x: 100, + y: 100, + vx: 24, + vy: 0, + damage: 50, + ttl: 13, + fromPlayer: true, + missileType: 'electricnova', + nextDelay: 4, + pierce: false, + }) + } + + const outcome = tickProjectiles(novaBolts, targets, { ...OPEN_TERRAIN, currentTick: 10 }) + + // Due to NextDelay = 4, only 1 hit registers despite 24 bolts overlapping! + expect(outcome.hits.length).toBe(1) + }) + }) + + // ========================================================================= + // 3. Jitter Table Traversal & Trajectory Spread + // ========================================================================= + describe('3. Jitter Table Traversal & Trajectory Spread', () => { + it('verifies GAT_CHARGED_BOLT_OFFSETS has exactly 32 entries matching 1.13c D2Common.dll', () => { + expect(GAT_CHARGED_BOLT_OFFSETS.length).toBe(32) + expect(Object.isFrozen(GAT_CHARGED_BOLT_OFFSETS)).toBe(true) + + const expected = [ + -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, + 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 1, + ] + expect(Array.from(GAT_CHARGED_BOLT_OFFSETS)).toEqual(expected) + + // Frequency distribution check: + const counts = { '-1': 0, '0': 0, '1': 0 } + for (const val of GAT_CHARGED_BOLT_OFFSETS) { + counts[String(val) as '-1' | '0' | '1']++ + } + expect(counts['-1']).toBe(11) + expect(counts['0']).toBe(10) + expect(counts['1']).toBe(11) + }) + + it('verifies all 32 entries are sequentially traversed over 32 frames in tickProjectiles', () => { + let projectile: Projectile = { + skillId: '38', + x: 100, + y: 100, + vx: 12, + vy: 0, + damage: 10, + ttl: 98, + fromPlayer: true, + missileType: 'chargedbolt', + jitter: true, + jitterIndex: 0, + } + + const indicesTraversed: number[] = [] + + for (let frame = 0; frame < 32; frame++) { + indicesTraversed.push(projectile.jitterIndex ?? 0) + const outcome = tickProjectiles([projectile], [], OPEN_TERRAIN) + expect(outcome.alive.length).toBe(1) + projectile = outcome.alive[0]! + } + + // Verify that all indices 0..31 were sequentially traversed + expect(indicesTraversed).toEqual(Array.from({ length: 32 }, (_, i) => i)) + // After 32 ticks, index wraps back to 0 + expect(projectile.jitterIndex).toBe(0) + }) + + it('verifies trajectory variance and cone dispersion when casting Charged Bolt', () => { + const engine = createHarnessEngine() + + const status = { textContent: '' } as HTMLElement + castSkill(38, 500, 100, { engine, runtime: {} as any, hudManager: null, status }) + + // Default slvl 1 spawns min(24, 1 + 2) = 3 bolts + expect(engine.projectiles.length).toBe(3) + + // Step 20 frames forward + for (let i = 0; i < 20; i++) { + engine.tick(DUMMY_INPUT) + } + + expect(engine.projectiles.length).toBe(3) + + // Collect final positions across all 3 bolts + const yCoords = engine.projectiles.map(b => b.y) + const uniqueY = new Set(yCoords.map(y => Math.round(y))) + + // All 3 bolts fan out with distinct Y coordinates across the dispersion cone + expect(uniqueY.size).toBe(3) + + const minY = Math.min(...yCoords) + const maxY = Math.max(...yCoords) + const spreadY = maxY - minY + expect(spreadY).toBeGreaterThan(15) // cone spread > 15px + }) + }) + + // ========================================================================= + // 4. Fuzz 1,000+ Combinations: slvl (1-99) x Synergy (0-20) + // ========================================================================= + describe('4. Fuzz 1,000+ Combinations: slvl (1-99) x Synergy (0-20)', () => { + // Independent Ground Truth Reference Oracle for 1.13c Charged Bolt (Skills.txt row 40) + function oracleChargedBoltDamage(slvl: number, synergyPoints: number): { min: number; max: number } { + const lvl = Math.max(1, Math.floor(slvl)) + let min = 2 + let max = 4 + for (let l = 2; l <= lvl; l++) { + let add = 1 + if (l >= 29) add = 4 + else if (l >= 23) add = 3 + else if (l >= 17) add = 2 + else if (l >= 9) add = 1 + else if (l >= 2) add = 1 + min += add + max += add + } + const mult = 1.0 + synergyPoints * 0.06 + return { + min: Math.floor(min * mult), + max: Math.floor(max * mult), + } + } + + it('fuzzes 2,079 combinations of slvl (1-99) and Lightning synergy (0-20) with 100% oracle match', () => { + let combinationsTested = 0 + let mismatches = 0 + + for (let slvl = 1; slvl <= 99; slvl++) { + for (let syn = 0; syn <= 20; syn++) { + combinationsTested++ + const expected = oracleChargedBoltDamage(slvl, syn) + const actual1 = calculateChargedBoltDamage(slvl, { lightning: syn }) + const actual2 = calculateChargedBoltDamage(slvl, { lightningPoints: syn }) + const actual3 = calculateSkillDamage(38, slvl, { lightning: syn } as any) + + if ( + actual1.min !== expected.min || + actual1.max !== expected.max || + actual2.min !== expected.min || + actual2.max !== expected.max || + actual3.min !== expected.min || + actual3.max !== expected.max + ) { + mismatches++ + } + + expect(actual1.min).toBeLessThanOrEqual(actual1.max) + expect(actual1.min).toBeGreaterThanOrEqual(2) + } + } + + expect(combinationsTested).toBe(99 * 21) // 2,079 combinations + expect(mismatches).toBe(0) + }) + + it('verifies exact 5-band boundaries for base damage (synergy = 0)', () => { + // Band 1: lvl 1-8 (+1/+1) + expect(calculateChargedBoltDamage(1, 0)).toEqual({ min: 2, max: 4 }) + expect(calculateChargedBoltDamage(8, 0)).toEqual({ min: 9, max: 11 }) + // Band 2: lvl 9-16 (+1/+1) + expect(calculateChargedBoltDamage(9, 0)).toEqual({ min: 10, max: 12 }) + expect(calculateChargedBoltDamage(16, 0)).toEqual({ min: 17, max: 19 }) + // Band 3: lvl 17-22 (+2/+2) + expect(calculateChargedBoltDamage(17, 0)).toEqual({ min: 19, max: 21 }) + expect(calculateChargedBoltDamage(22, 0)).toEqual({ min: 29, max: 31 }) + // Band 4: lvl 23-28 (+3/+3) + expect(calculateChargedBoltDamage(23, 0)).toEqual({ min: 32, max: 34 }) + expect(calculateChargedBoltDamage(28, 0)).toEqual({ min: 47, max: 49 }) + // Band 5: lvl 29+ (+4/+4) + expect(calculateChargedBoltDamage(29, 0)).toEqual({ min: 51, max: 53 }) + expect(calculateChargedBoltDamage(30, 0)).toEqual({ min: 55, max: 57 }) + expect(calculateChargedBoltDamage(99, 0)).toEqual({ min: 331, max: 333 }) + }) + + it('fuzzes bolt count formula across all 99 levels against min(24, slvl + 2)', () => { + for (let slvl = 1; slvl <= 99; slvl++) { + const expected = Math.min(24, slvl + 2) + expect(calculateChargedBoltCount(slvl)).toBe(expected) + } + expect(calculateChargedBoltCount(1)).toBe(3) + expect(calculateChargedBoltCount(21)).toBe(23) + expect(calculateChargedBoltCount(22)).toBe(24) + expect(calculateChargedBoltCount(23)).toBe(24) + expect(calculateChargedBoltCount(99)).toBe(24) + }) + + it('fuzzes mana cost across slvl 1 to 99: 3 + (slvl - 1) * 0.5', () => { + for (let slvl = 1; slvl <= 99; slvl++) { + const expected = 3 + (slvl - 1) * 0.5 + expect(getSkillManaCost(38, slvl)).toBe(expected) + } + }) + }) + + // ========================================================================= + // 5. Obstacle Collision & Wall Termination + // ========================================================================= + describe('5. Obstacle Collision & Wall Termination', () => { + it('verifies charged bolts terminate upon hitting solid walls in tickProjectiles', () => { + // Wall barrier at x >= 200 + const wallTerrain = { + overlap: (x: number) => (x >= 200 ? 1 : 0), + isMissileBlocked: (x: number) => x >= 200, + } + + const bolt: Projectile = { + skillId: '38', + x: 180, + y: 100, + vx: 12, + vy: 0, + damage: 10, + ttl: 98, + fromPlayer: true, + missileType: 'chargedbolt', + jitter: false, + } + + // Tick 1: moves from 180 to 192 (before wall) -> survives + const step1 = tickProjectiles([bolt], [], wallTerrain) + expect(step1.alive.length).toBe(1) + expect(step1.alive[0]!.x).toBe(192) + + // Tick 2: moves from 192 to 204 (hits wall at 200) -> dies + const step2 = tickProjectiles(step1.alive, [], wallTerrain) + expect(step2.alive.length).toBe(0) + expect(step2.wallHits).toBe(1) + }) + + it('verifies MissileEngine terminates chargedbolt on terrain collision', async () => { + const registry = await getSharedDataRegistry() + const terrainCollision = { + overlap: (x: number) => (x >= 200 ? 1 : 0), + isMissileBlocked: (x: number) => x >= 200, + } + const engine = new MissileEngine(registry, terrainCollision) + const ownerStats = new UnitStatList() + const owner = { + id: 'player', + x: 185, + y: 100, + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + engine.spawnMissile({ + missileNameOrId: 'chargedbolt', + sourceSkillId: 38, + slvl: 10, + owner, + startX: 185, + startY: 100, + targetX: 300, + targetY: 100, + dmgPacket: { minDamage: 10, maxDamage: 10, damageType: 'lightning' } as any, + vx: 12, + vy: 0, + maxRangeTicks: 98, + }) + + // Tick 1: reaches 197 + engine.tick(1, [], new Map()) + expect(engine.getActiveMissiles().length).toBe(1) + + // Tick 2: hits wall at >= 200 + engine.tick(2, [], new Map()) + expect(engine.getActiveMissiles().length).toBe(0) + }) + }) + + // ========================================================================= + // 6. Telekinesis Range Boundary: 599px (valid) vs 601px (rejected) + // ========================================================================= + describe('6. Telekinesis Range Boundary (599px vs 601px)', () => { + it('accepts targets at 599px and rejects targets at 601px', () => { + const engine = createHarnessEngine() + + // Add a monster at 599px along X axis: (100 + 599, 100) = (699, 100) + engine.world.monsters.push({ + index: 0, + stats: { id: 'm1', name: 'Fallen', hp: 100, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 }, + x: 699, + y: 100, + hp: 100, + cooldown: 0, + state: 'idle', + facing: 0, + hitFlash: 0, + corpseTicks: 0, + }) + + // Add another monster at 601px along X axis: (100 + 601, 100) = (701, 100) + engine.world.monsters.push({ + index: 1, + stats: { id: 'm2', name: 'Zombie', hp: 100, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 }, + x: 701, + y: 100, + hp: 100, + cooldown: 0, + state: 'idle', + facing: 0, + hitFlash: 0, + corpseTicks: 0, + }) + + const status = { textContent: '' } as HTMLElement + + // Target at 599px (valid boundary test) + const res599 = castSkill(43, 699, 100, { engine, runtime: {} as any, hudManager: null, status }) + expect(res599).toBe(true) + expect(status.textContent).toContain('心灵传动命中') + + // Target at 601px (out-of-range boundary test) + const res601 = castSkill(43, 701, 100, { engine, runtime: {} as any, hudManager: null, status }) + expect(res601).toBe(false) + expect(status.textContent).toBe('目标超出心灵传动范围!') + + // Exact boundary at 600px + const res600 = castSkill(43, 700, 100, { engine, runtime: {} as any, hudManager: null, status }) + expect(res600).toBe(true) + + // Diagonal distance: dx = 424, dy = 424 -> sqrt(424^2 + 424^2) = 599.62px <= 600px + const resDiagValid = castSkill(43, 100 + 424, 100 + 424, { engine, runtime: {} as any, hudManager: null, status }) + expect(resDiagValid).toBe(true) + + // Diagonal distance: dx = 426, dy = 426 -> sqrt(426^2 + 426^2) = 602.45px > 600px + const resDiagInvalid = castSkill(43, 100 + 426, 100 + 426, { engine, runtime: {} as any, hudManager: null, status }) + expect(resDiagInvalid).toBe(false) + expect(status.textContent).toBe('目标超出心灵传动范围!') + }) + }) + + // ========================================================================= + // 7. Item Pickup Whitelist: Allowed vs Forbidden Categories + // ========================================================================= + describe('7. Item Pickup Whitelist Fuzzing', () => { + it('accepts all authentic allowed items (potions, scrolls, gold, ammo, keys)', () => { + const allowedItems = [ + // Potions by code + { code: 'hp1', name: 'Minor Healing Potion' }, + { code: 'hp2', name: 'Light Healing Potion' }, + { code: 'hp3', name: 'Healing Potion' }, + { code: 'hp4', name: 'Greater Healing Potion' }, + { code: 'hp5', name: 'Super Healing Potion' }, + { code: 'mp1', name: 'Minor Mana Potion' }, + { code: 'mp2', name: 'Light Mana Potion' }, + { code: 'mp3', name: 'Mana Potion' }, + { code: 'mp4', name: 'Greater Mana Potion' }, + { code: 'mp5', name: 'Super Mana Potion' }, + { code: 'rvs', name: 'Rejuvenation Potion' }, + { code: 'rvl', name: 'Full Rejuvenation Potion' }, + { code: 'vps', name: 'Stamina Potion' }, + { code: 'yps', name: 'Antidote Potion' }, + { code: 'wms', name: 'Thawing Potion' }, + { code: 'ant', name: 'Antidote' }, + { code: 'thw', name: 'Thawing' }, + { code: 'sta', name: 'Stamina' }, + // Potions by localized name + { nameZh: '轻微治疗药剂' }, + { nameZh: '超强法力药剂' }, + { nameZh: '完全活力药剂' }, + { nameZh: '解毒药剂' }, + { nameZh: '解冻药剂' }, + // Scrolls & Tomes + { code: 'tsc', name: 'Scroll of Town Portal' }, + { code: 'isc', name: 'Scroll of Identify' }, + { code: 'tbk', name: 'Tome of Town Portal' }, + { code: 'ibk', name: 'Tome of Identify' }, + { nameZh: '城镇传送卷轴' }, + { nameZh: '辨识之书' }, + // Gold + { isGold: true, amount: 100 }, + { code: 'gld', amount: 500 }, + { code: 'gold', amount: 250 }, + // Ammo + { code: 'aqv', name: 'Arrows' }, + { code: 'cqv', name: 'Crossbow Bolts' }, + { nameZh: '箭矢' }, + { nameZh: '十字弓弹' }, + // Keys + { code: 'key', name: 'Skeleton Key' }, + { nameZh: '钥匙' }, + // Explicit types + { type: 'potion' }, + { type: 'scroll' }, + { type: 'gold' }, + { type: 'ammo' }, + { type: 'key' }, + ] + + for (const item of allowedItems) { + expect(isTelekinesisItemAllowed(item)).toBe(true) + } + }) + + it('strictly rejects forbidden equipment (weapons, armors, shields, jewelry, charms)', () => { + const forbiddenItems = [ + // Weapons + { code: 'swor', name: 'Short Sword' }, + { code: 'axe', name: 'Hand Axe' }, + { code: 'bow', name: 'Short Bow' }, + { code: 'wand', name: 'Wand' }, + { code: 'staff', name: 'Short Staff' }, + { code: 'bsw', name: 'Broad Sword' }, + { code: 'gsc', name: 'Gladius' }, + { code: 'clb', name: 'Club' }, + { code: 'wsp', name: 'War Scepter' }, + { code: 'flc', name: 'Flail' }, + { code: 'crs', name: 'Crystal Sword' }, + { nameZh: '水晶剑' }, + { nameZh: '巨剑' }, + { nameZh: '短弓' }, + // Armors / Helms / Shields / Gloves / Boots / Belts + { code: 'cap', name: 'Cap' }, + { code: 'qui', name: 'Quilted Armor' }, + { code: 'lea', name: 'Leather Armor' }, + { code: 'hla', name: 'Hard Leather Armor' }, + { code: 'fld', name: 'Field Plate' }, + { code: 'plt', name: 'Plate Mail' }, + { code: 'sml', name: 'Small Shield' }, + { code: 'lgl', name: 'Leather Gloves' }, + { code: 'lbt', name: 'Leather Boots' }, + { code: 'mbl', name: 'Mesh Belt' }, + { code: 'crn', name: 'Crown' }, + { nameZh: '哥特战甲' }, + { nameZh: '圆盾' }, + // Jewelry & Charms + { code: 'ring', name: 'Ring' }, + { code: 'amu', name: 'Amulet' }, + { code: 'cm1', name: 'Small Charm' }, + { code: 'cm2', name: 'Large Charm' }, + { code: 'cm3', name: 'Grand Charm' }, + { code: 'jew', name: 'Jewel' }, + { nameZh: '戒指' }, + { nameZh: '护身符' }, + { nameZh: '超大护身符' }, + { nameZh: '珠宝' }, + // Empty / Invalid + null, + undefined, + {}, + { code: '' }, + ] + + for (const item of forbiddenItems) { + expect(isTelekinesisItemAllowed(item)).toBe(false) + } + }) + }) + + // ========================================================================= + // 8. Object Interaction Whitelist + // ========================================================================= + describe('8. Object Interaction Whitelist', () => { + it('whitelists chests, waypoints, stash, portals, shrines, doors, wells, urns', () => { + const allowedObjects = [ + 'chest', + 'locked chest', + 'chest_gold', + 'waypoint', + 'rogue_encampment_waypoint', + 'stash', + 'player_stash', + 'portal', + 'town_portal', + 'red_portal', + 'shrine', + 'armor_shrine', + 'combat_shrine', + 'door', + 'wood_door', + 'jail_door', + 'well', + 'healing_well', + 'urn', + 'casket', + 'sarcophagus', + 'tomb', + 'barrel', + ] + + for (const obj of allowedObjects) { + expect(isTelekinesisObjectAllowed(obj)).toBe(true) + expect(isTelekinesisObjectAllowed(obj.toUpperCase())).toBe(true) + expect(isTelekinesisObjectAllowed(` ${obj} `)).toBe(true) + } + + const rejectedObjects = [ + 'monster', + 'fallen', + 'zombie', + 'trap', + 'fire', + 'light', + 'torch', + 'tree', + 'rock', + 'fence', + 'wall', + 'floor', + '', + null as any, + ] + + for (const obj of rejectedObjects) { + expect(isTelekinesisObjectAllowed(obj)).toBe(false) + } + }) + + it('triggers remote waypoint and stash interaction within 600px range', () => { + const engine = createHarnessEngine() + + let waypointActivated = false + let stashOpened = false + + const runtime: any = { + waypoints: [{ arriveX: 300, arriveY: 100, waypointId: 1 }], + stashes: [{ x: 500, y: 100 }], // Placed sufficiently distant from waypoint (dist > 80px) + level: 'Rogue Encampment', + levelId: 1, + act: 1, + } + + const context: any = { + waypointNetwork: { + activate: (id: number) => { + if (id === 1) waypointActivated = true + return true + }, + }, + openStash: () => { + stashOpened = true + }, + } + + const status = { textContent: '' } as HTMLElement + + // Click waypoint at (300, 100) (dist = 200px <= 600px) + const wpResult = castSkill(43, 300, 100, { + engine, + runtime, + hudManager: null, + status, + waypointNetwork: context.waypointNetwork, + openStash: context.openStash, + }) + expect(wpResult).toBe(true) + expect(waypointActivated).toBe(true) + + // Click stash at (500, 100) (dist = 400px <= 600px) + const stashResult = castSkill(43, 500, 100, { + engine, + runtime, + hudManager: null, + status, + waypointNetwork: context.waypointNetwork, + openStash: context.openStash, + }) + expect(stashResult).toBe(true) + expect(stashOpened).toBe(true) + }) + }) + + // ========================================================================= + // 9. Monster Combat: Damage, 35% Knockback & 35 Stun Ticks + // ========================================================================= + describe('9. Monster Combat: Damage, 35% Knockback & 35 Stun Ticks', () => { + it('verifies combat damage scaling: slvl to slvl + 1 across slvl 1 to 99', () => { + for (let slvl = 1; slvl <= 99; slvl++) { + const dmg = calculateTelekinesisDamage(slvl) + expect(dmg.min).toBe(slvl) + expect(dmg.max).toBe(slvl + 1) + } + expect(calculateTelekinesisDamage(1)).toEqual({ min: 1, max: 2 }) + expect(calculateTelekinesisDamage(10)).toEqual({ min: 10, max: 11 }) + expect(calculateTelekinesisDamage(20)).toEqual({ min: 20, max: 21 }) + }) + + it('verifies 35 stun ticks applied to hit monster in castSkill', () => { + const engine = createHarnessEngine() + + const targetMonster = { + index: 0, + stats: { id: 'm1', name: 'Zombie', hp: 1000, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 }, + x: 150, + y: 100, + hp: 1000, + cooldown: 0, + state: 'idle' as const, + facing: 0, + hitFlash: 0, + corpseTicks: 0, + stunTicks: 0, + } + engine.world.monsters.push(targetMonster) + + const status = { textContent: '' } as HTMLElement + castSkill(43, 150, 100, { engine, runtime: {} as any, hudManager: null, status }) + + expect(targetMonster.stunTicks).toBe(35) + }) + + it('empirically verifies 35% knockback probability across 2,000 trials', () => { + const engine = createHarnessEngine() + + let knockbacks = 0 + const totalTrials = 2000 + + for (let i = 0; i < totalTrials; i++) { + const initialX = 150 + const initialY = 100 + const targetMonster = { + index: 0, + stats: { id: 'm1', name: 'Target', hp: 100000, damage: 0, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 }, + x: initialX, + y: initialY, + hp: 100000, + cooldown: 0, + state: 'idle' as const, + facing: 0, + hitFlash: 0, + corpseTicks: 0, + } + engine.world.monsters = [targetMonster] + engine.world.player.cooldown = 0 + engine.world.player.mana = 1000 + + const status = { textContent: '' } as HTMLElement + castSkill(43, initialX, initialY, { engine, runtime: {} as any, hudManager: null, status }) + + // Knockback displaces monster away from player + if (targetMonster.x > initialX) { + knockbacks++ + } + } + + const observedRate = knockbacks / totalTrials + // Expected rate is 35%. With n = 2000, standard error is sqrt(0.35 * 0.65 / 2000) = 0.0107 (1.07%). + // Accept within [31.5%, 38.5%] (3.5 standard deviations, 99.9% confidence) + expect(observedRate).toBeGreaterThanOrEqual(0.315) + expect(observedRate).toBeLessThanOrEqual(0.385) + }) + }) + + // ========================================================================= + // 10. Energy Shield Mana Ratio Synergy + // ========================================================================= + describe('10. Energy Shield Mana Ratio Synergy', () => { + it('verifies calculateEnergyShieldManaRatio across tkBlvl from 0 to 40 with 0.75 floor', () => { + // 1.13c formula: (32 - min(20, tkBlvl)) / 16 + expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 200% mana + expect(calculateEnergyShieldManaRatio(1)).toBe(1.9375) // 31/16 + expect(calculateEnergyShieldManaRatio(2)).toBe(1.875) // 30/16 + expect(calculateEnergyShieldManaRatio(4)).toBe(1.75) // 28/16 + expect(calculateEnergyShieldManaRatio(8)).toBe(1.5) // 24/16 = 150% mana + expect(calculateEnergyShieldManaRatio(12)).toBe(1.25) // 20/16 + expect(calculateEnergyShieldManaRatio(16)).toBe(1.0) // 16/16 = 100% mana + expect(calculateEnergyShieldManaRatio(18)).toBe(0.875) // 14/16 + expect(calculateEnergyShieldManaRatio(20)).toBe(0.75) // 12/16 = 75% floor + // Hard cap at 20 points: further points do not lower ratio below 0.75 + expect(calculateEnergyShieldManaRatio(21)).toBe(0.75) + expect(calculateEnergyShieldManaRatio(25)).toBe(0.75) + expect(calculateEnergyShieldManaRatio(30)).toBe(0.75) + expect(calculateEnergyShieldManaRatio(40)).toBe(0.75) + expect(calculateEnergyShieldManaRatio(99)).toBe(0.75) + }) + + it('enforces 1.13c blvl synergy rule: soft points (+skills, charges) do NOT reduce ratio', () => { + // Character has 8 base hard points, but +12 from gear/soft skills -> effective level 20 + const basePoints = 8 + const softPoints = 12 + const effectiveLevel = basePoints + softPoints + + // Authentic 1.13c rule: synergy uses blvl only! + const ratioWithBase = calculateEnergyShieldManaRatio(basePoints) + const ratioWithEffective = calculateEnergyShieldManaRatio(effectiveLevel) + + // The authentic ratio is 1.50, NOT 0.75 + expect(ratioWithBase).toBe(1.5) + expect(ratioWithEffective).toBe(0.75) + // Soft points must not be passed into calculateEnergyShieldManaRatio + expect(ratioWithBase).not.toBe(ratioWithEffective) + }) + + it('handles negative or invalid input values gracefully by defaulting to ratio 2.0', () => { + expect(calculateEnergyShieldManaRatio(-5)).toBe(2.0) + expect(calculateEnergyShieldManaRatio(NaN)).toBe(2.0) + expect(calculateEnergyShieldManaRatio(null as any)).toBe(2.0) + expect(calculateEnergyShieldManaRatio(undefined as any)).toBe(2.0) + }) + }) +}) diff --git a/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts b/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts new file mode 100644 index 0000000..ca186fd --- /dev/null +++ b/tests/challenger-m4-thunder-storm-energy-shield-mastery.test.ts @@ -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) + }) + }) +}) diff --git a/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts b/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts new file mode 100644 index 0000000..4fb588c --- /dev/null +++ b/tests/challenger-m4-thunderstorm-energyshield-mastery-stress.test.ts @@ -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) + }) + }) +})