diablo2-web/tests/skills/sor/issue-433-thunder-storm-vis...

362 lines
14 KiB
TypeScript

/**
* Issue #433: Thunder Storm (#57) Feet Ambient Cloud & Lightning Strike
*
* 1.13c Ground Truth Invariants Verification Suite:
* 1. Feet Ambient Cloud: Continuous looping playback of `thunderstormback` state overlay
* (ThunderstormCast_operate, PreDraw = 1, trans = 3) at player feet during buff duration.
* 2. Player Movement Tracking & Expiration: Overlay follows player coordinates and expires
* cleanly when buff duration expires.
* 3. Periodic Strike Cadence & Targeting: 17 subtiles (340px) radius targeting closest live enemy.
* 4. Sky-Strike Lightning Missile: Spawns `thunderstorm1` (CelFile: LightningboltBig, 8 frames ttl)
* at monster location alongside `light_cast_2` ground spark overlay.
* 5. Missile Collision Bypass: `thunderstorm1` is in `isHarmlessEmitter`, surviving its full 8-frame
* animation without premature collision destruction or duplicate damage.
* 6. Dynamic Level & Lightning Mastery Scaling: Accurate duration, cadence, and multiplicative damage.
*/
import { describe, expect, it } from 'vitest'
import { GameEngine, type EngineInput } from '../../../src/game/engine.ts'
import {
CANONICAL_113C_OVERLAYS,
CANONICAL_113C_MISSILES,
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateLightningMasteryBonusPct,
createActiveOverlay,
tickProjectiles,
type Projectile,
} from '../../../src/game/skills.ts'
import {
THUNDERSTORM_BACK_OVERLAY_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
import {
THUNDERSTORM1_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/game/demo-data.ts'
const DEFAULT_INPUT: EngineInput = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
function createTestEngine() {
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
return new GameEngine(terrain, {
spawn: { x: 500, y: 500 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
disableMonsterAggro: true,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 0,
})
}
describe('Issue #433: Thunder Storm (#57) Feet Ambient Cloud & Lightning Strike', () => {
// =========================================================================
// Dimension 1: Canonical 1.13c Registries & Asset Metadata
// =========================================================================
describe('Dimension 1: Canonical 1.13c Registries & Asset Metadata', () => {
it('verifies thunderstormback state overlay registration in CANONICAL_113C_OVERLAYS', () => {
const ov = CANONICAL_113C_OVERLAYS['thunderstormback']
expect(ov).toBeDefined()
expect(ov.filename).toBe('ThunderstormCast_operate')
expect(ov.frames).toBe(19)
expect(ov.preDraw).toBe(true)
expect(ov.trans).toBe(3)
expect(ov.animRate).toBe(16)
})
it('verifies THUNDERSTORM_BACK_OVERLAY_META in OVERLAY_METAS', () => {
expect(THUNDERSTORM_BACK_OVERLAY_META).toBeDefined()
expect(THUNDERSTORM_BACK_OVERLAY_META.name).toBe('thunderstormback')
expect(THUNDERSTORM_BACK_OVERLAY_META.celFile).toBe('ThunderstormCast_operate')
expect(THUNDERSTORM_BACK_OVERLAY_META.frames).toBe(19)
expect(THUNDERSTORM_BACK_OVERLAY_META.preDraw).toBe(true)
expect(THUNDERSTORM_BACK_OVERLAY_META.trans).toBe(3)
expect(OVERLAY_METAS['thunderstormback']).toBe(THUNDERSTORM_BACK_OVERLAY_META)
})
it('verifies thunderstorm1 missile registration in CANONICAL_113C_MISSILES', () => {
const missile = CANONICAL_113C_MISSILES['thunderstorm1']
expect(missile).toBeDefined()
expect(missile.celFile).toBe('LightningboltBig')
expect(missile.animLen).toBe(8)
expect(missile.trans).toBe(1)
})
it('verifies THUNDERSTORM1_META in MISSILE_METAS', () => {
expect(THUNDERSTORM1_META).toBeDefined()
expect(THUNDERSTORM1_META.name).toBe('thunderstorm1')
expect(THUNDERSTORM1_META.celFile).toBe('LightningboltBig')
expect(THUNDERSTORM1_META.framesPerDirection).toBe(8)
expect(THUNDERSTORM1_META.animSpeed).toBe(16)
expect(MISSILE_METAS['thunderstorm1']).toBe(THUNDERSTORM1_META)
})
})
// =========================================================================
// Dimension 2: Feet Ambient Cloud (thunderstormback) Lifecycle
// =========================================================================
describe('Dimension 2: Feet Ambient Cloud Lifecycle & Movement Tracking', () => {
it('spawns thunderstormback with preDraw: true, isStateOverlay: true, and loop: true', () => {
const engine = createTestEngine()
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
const ov = engine.overlays.find(o => o.overlayName === 'thunderstormback')
expect(ov).toBeDefined()
expect(ov?.preDraw).toBe(true)
expect(ov?.isStateOverlay).toBe(true)
expect(ov?.loop).toBe(true)
expect(ov?.casterId).toBe('player')
expect(ov?.x).toBe(500)
expect(ov?.y).toBe(500)
})
it('tracks player coordinates across movement updates', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
engine.world.player.thunderstormTicks = 50
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
// Move player
engine.world.player.x = 560
engine.world.player.y = 530
engine.tick(DEFAULT_INPUT)
const ov = engine.overlays.find(o => o.overlayName === 'thunderstormback')
expect(ov).toBeDefined()
expect(ov?.x).toBe(560)
expect(ov?.y).toBe(530)
})
it('loops animation frames continuously during active buff duration', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
engine.world.player.thunderstormTicks = 100
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
// Tick 35 frames (animRate 16/25 frame per tick -> 35 * 0.64 = 22.4 frames, wraps around 19 maxFrames)
for (let i = 0; i < 35; i++) {
engine.tick(DEFAULT_INPUT)
}
const ov = engine.overlays.find(o => o.overlayName === 'thunderstormback')
expect(ov).toBeDefined()
expect(ov?.expired).toBeFalsy()
expect(ov?.frame).toBeGreaterThanOrEqual(0)
expect(ov?.frame).toBeLessThan(19)
})
it('cleanly removes thunderstormback overlay upon buff expiration', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
engine.world.player.thunderstormTicks = 3
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
// Tick 2 frames -> still active
engine.tick(DEFAULT_INPUT)
engine.tick(DEFAULT_INPUT)
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(true)
// Tick 3rd frame -> thunderstormTicks reaches 0, overlay must be removed
engine.tick(DEFAULT_INPUT)
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(false)
expect(engine.world.player.thunderstormTicks).toBe(0)
})
it('immediately purges thunderstormback when thunderstormTicks is uninitialized or 0', () => {
const engine = createTestEngine()
engine.world.player.thunderstormTicks = 0
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(true)
engine.tick(DEFAULT_INPUT)
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(false)
})
it('refreshes existing thunderstormback on recast without duplicate stacking', () => {
const engine = createTestEngine()
engine.world.player.thunderstormTicks = 100
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
expect(engine.overlays.filter(o => o.overlayName === 'thunderstormback')).toHaveLength(1)
// Recast while active
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
loop: true,
isStateOverlay: true,
})
expect(engine.overlays.filter(o => o.overlayName === 'thunderstormback')).toHaveLength(1)
})
})
// =========================================================================
// Dimension 3: Periodic Sky-Strike Targeting, Projectile & Visuals
// =========================================================================
describe('Dimension 3: Periodic Sky-Strike Spawning & Collision Bypass', () => {
it('spawns thunderstorm1 projectile and light_cast_2 overlay on closest live target within 340px', () => {
const engine = createTestEngine()
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 = 250
engine.world.monsters = [
{ index: 0, id: 'm0', x: 510, y: 500, hp: 0, state: 'dead' } as any,
{ index: 1, id: 'm1', x: 620, y: 500, hp: 600, maxHp: 600, state: 'idle' } as any, // 120px dist
{ index: 2, id: 'm2', x: 750, y: 500, hp: 600, maxHp: 600, state: 'idle' } as any, // 250px dist
{ index: 3, id: 'm3', x: 900, y: 500, hp: 600, maxHp: 600, state: 'idle' } as any, // 400px dist (out of range)
]
engine.tick(DEFAULT_INPUT)
// M1 is struck
expect(engine.world.monsters[1]?.hp).toBe(350)
expect(engine.world.monsters[2]?.hp).toBe(600)
// light_cast_2 ground spark overlay is present
const spark = engine.overlays.find(o => o.overlayName === 'light_cast_2')
expect(spark).toBeDefined()
expect(spark?.x).toBe(620)
expect(spark?.y).toBe(500)
// thunderstorm1 sky-strike projectile is spawned
const proj = engine.projectiles.find(p => p.missileType === 'thunderstorm1')
expect(proj).toBeDefined()
expect(proj?.skillId).toBe('57')
expect(proj?.x).toBe(620)
expect(proj?.y).toBe(500)
expect(proj?.ttl).toBe(8)
expect(proj?.pierce).toBe(true)
expect(proj?.damage).toBe(0)
expect(proj?.fromPlayer).toBe(true)
// Events recorded
expect(engine.world.events.some(e => e.kind === 'monsterHit' && e.amount === 250)).toBe(true)
expect(engine.world.events.some(e => e.kind === 'missileHit' && e.missileType === 'thunderstorm1' && e.amount === 250)).toBe(true)
})
it('survives all 8 frames without premature collision consumption in tickProjectiles', () => {
// Direct simulation of tickProjectiles with a monster sitting on the exact coordinate
const target = { index: 1, x: 620, y: 500, radius: 20, alive: true }
let current: readonly Projectile[] = [
{
skillId: '57',
x: 620,
y: 500,
vx: 0,
vy: 0,
damage: 0,
ttl: 8,
fromPlayer: true,
missileType: 'thunderstorm1',
pierce: true,
ageTicks: 0,
},
]
// Across 8 ticks, missile remains alive, advancing ageTicks and decrementing ttl, without any hits
for (let tick = 1; tick <= 8; tick++) {
const out = tickProjectiles(current, [target], { overlap: () => 0 })
expect(out.alive).toHaveLength(1)
expect(out.alive[0]?.ageTicks).toBe(tick)
expect(out.alive[0]?.ttl).toBe(8 - tick)
expect(out.hits).toHaveLength(0)
current = out.alive
}
// On the 9th tick, ttl is 0, so it expires
const out9 = tickProjectiles(current, [target], { overlap: () => 0 })
expect(out9.alive).toHaveLength(0)
expect(out9.expired).toBe(1)
})
})
// =========================================================================
// Dimension 4: Dynamic Formulas & Lightning Mastery Multiplicative Scaling
// =========================================================================
describe('Dimension 4: 1.13c Mathematical Scaling & Synergies', () => {
it('evaluates exact 1.13c duration formula: 800 + (slvl - 1) * 200', () => {
expect(calculateThunderStormDuration(1)).toBe(800)
expect(calculateThunderStormDuration(5)).toBe(1600)
expect(calculateThunderStormDuration(10)).toBe(2600)
expect(calculateThunderStormDuration(20)).toBe(4600)
})
it('evaluates exact 1.13c hyperbolic cadence formula', () => {
expect(calculateThunderStormInterval(1)).toBe(110)
expect(calculateThunderStormInterval(5)).toBe(75)
expect(calculateThunderStormInterval(10)).toBe(57)
expect(calculateThunderStormInterval(20)).toBe(41)
expect(calculateThunderStormInterval(40)).toBe(30)
expect(calculateThunderStormInterval(60)).toBe(25)
expect(calculateThunderStormInterval(99)).toBe(25)
})
it('integrates Lightning Mastery multiplicative scaling accurately', () => {
// slvl 1: base 1..100
const base = calculateThunderStormDamage(1, { lightningMastery: 0 })
expect(base.min).toBe(1)
expect(base.max).toBe(100)
// slvl 1 with LM lvl 1 (+50%)
const lm1 = calculateThunderStormDamage(1, { lightningMastery: 1 })
expect(lm1.min).toBe(1) // Math.floor(1 * 1.50) = 1
expect(lm1.max).toBe(150) // Math.floor(100 * 1.50) = 150
// slvl 20: base lvl 1=1..100, +7*10=70..70, +8*10=80..80, +4*11=44..44 -> 195..294
const lvl20Base = calculateThunderStormDamage(20, { lightningMastery: 0 })
expect(lvl20Base.min).toBe(195)
expect(lvl20Base.max).toBe(294)
// slvl 20 with LM lvl 20 (50 + 19 * 12 = 278% -> multiplier 3.78)
// 195 * 3.78 = 737.1 -> 737, 294 * 3.78 = 1111.32 -> 1111
const lvl20WithLm = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(lvl20WithLm.min).toBe(737)
expect(lvl20WithLm.max).toBe(1111)
})
})
})