diff --git a/src/game/combat.ts b/src/game/combat.ts index e463a82..7010060 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -239,6 +239,7 @@ export interface CombatPlayer { thunderstormInterval?: number thunderstormNextStrike?: number thunderstormDamage?: number + thunderstormDamageRange?: { readonly min: number; readonly max: number } | undefined thunderstormSlvl?: number /** Energy Shield active remaining ticks. */ energyshieldTicks?: number diff --git a/src/game/engine.ts b/src/game/engine.ts index 244380e..e019a35 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -682,6 +682,7 @@ export class GameEngine { if (player.thunderstormTicks && player.thunderstormTicks > 0) { player.thunderstormTicks -= 1 if (player.thunderstormTicks <= 0) { + player.thunderstormTicks = 0 for (const ov of this.overlays) { if ( (ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') && @@ -709,17 +710,49 @@ export class GameEngine { const dmg = player.thunderstormDamage ?? 50 damageMonster(this.world, nearestMonster.index, dmg) this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`) + this.projectiles.push({ + skillId: '57', + x: nearestMonster.x, + y: nearestMonster.y, + vx: 0, + vy: 0, + damage: 0, + ttl: 8, + fromPlayer: true, + missileType: 'thunderstorm1', + pierce: true, + ageTicks: 0, + }) this.world.events.push({ kind: 'monsterHit', x: nearestMonster.x, y: nearestMonster.y, amount: dmg, }) + this.world.events.push({ + kind: 'missileHit', + x: nearestMonster.x, + y: nearestMonster.y, + missileType: 'thunderstorm1', + skillId: '57', + amount: dmg, + }) } } } } + if (!player.thunderstormTicks || player.thunderstormTicks <= 0) { + for (const ov of this.overlays) { + if ( + (ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') && + ov.casterId === 'player' + ) { + ov.expired = true + } + } + } + // Energy Shield duration tick & overlay management if (player.energyshieldTicks && player.energyshieldTicks > 0) { player.energyshieldTicks -= 1 diff --git a/src/game/skills.ts b/src/game/skills.ts index b158396..f936484 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -5127,6 +5127,8 @@ export function tickProjectiles( projectile.missileType === 'firewallmaker' || projectile.missileType === 'freezeexplode' || projectile.missileType === 'glacialspikeejecta' || + projectile.missileType === 'thunderstorm1' || + projectile.missileType === 'thunderstorm' || isTurretEmitter if (!isHarmlessEmitter) { for (const target of targets) { diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 9507176..e7b3e6f 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount, calculateFistOfTheHeavensDamage } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateThunderStormDamage, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount, calculateFistOfTheHeavensDamage } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1865,16 +1865,18 @@ export function castSkill( if (skillId === 57) { // Thunder Storm (Skill 57): activate thunderstorm buff on player - const slvl = 1 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(57) ?? 1) + const lmSlvl = (hudManager as any)?.getSkillLevel?.(63) ?? 0 const duration = calculateThunderStormDuration(slvl) const interval = calculateThunderStormInterval(slvl) - const dmgRange = calculateSkillDamage(57, slvl) + const dmgRange = calculateThunderStormDamage(slvl, { lightningMastery: lmSlvl }) const tsDmg = Math.round((dmgRange.min + dmgRange.max) / 2) player.thunderstormTicks = duration player.thunderstormInterval = interval player.thunderstormNextStrike = interval player.thunderstormDamage = tsDmg + player.thunderstormDamageRange = dmgRange player.thunderstormSlvl = slvl engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) diff --git a/tests/skills/sor/issue-433-thunder-storm-visuals.test.ts b/tests/skills/sor/issue-433-thunder-storm-visuals.test.ts new file mode 100644 index 0000000..8f9b199 --- /dev/null +++ b/tests/skills/sor/issue-433-thunder-storm-visuals.test.ts @@ -0,0 +1,361 @@ +/** + * 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) + }) + }) +})