362 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|