/** * 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/server/engine/engine.ts' import { CANONICAL_113C_OVERLAYS, CANONICAL_113C_MISSILES, calculateThunderStormDuration, calculateThunderStormInterval, calculateThunderStormDamage, calculateLightningMasteryBonusPct, createActiveOverlay, tickProjectiles, type Projectile, } from '../../../src/server/skills/skills.ts' import { THUNDERSTORM_BACK_OVERLAY_META, OVERLAY_METAS, } from '../../../src/client/render/overlays-meta.ts' import { THUNDERSTORM1_META, MISSILE_METAS, } from '../../../src/client/render/missiles-meta.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/common/data/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) }) }) })