/** * Issue #437: Meteor (#56) Ground Fire Duration & Extinction Decay Animation * * 1.13c Ground Truth Invariants: * 1. Duration: meteorfire base duration is 90 ticks (3.6s) + 15 ticks/lvl (90 + (slvl - 1) * 15). * 2. Extinction Shrink & Fade: At life end (ttl <= 12), flames do not abruptly vanish in 1 frame. * Instead, animation steps reverse from frame 11 down to 0, and renderer applies shrinking scale * and opacity fade. * 3. Procedural Fallback: When sprite art is unavailable, ground fire renders scaled and faded * stationary flame patches rather than missile trails. */ import { describe, expect, it, vi } from 'vitest' import { tickProjectiles } from '../../../src/server/skills/skills.ts' import type { Projectile } from '../../../src/server/skills/skills.ts' import { drawMissileProjectile } from '../../../src/client/scene/missile-overlay-renderer.ts' import type { LoadedMissileArt } from '../../../src/client/scene/missile-overlay-renderer.ts' import { METEORFIRE_META } from '../../../src/client/render/missiles-meta.ts' import type { SpriteRenderer, AtlasHandle } from '../../../src/client/render/renderer.ts' describe('Issue #437: Meteor Ground Fire Duration & Extinction Animation', () => { it('enforces 1.13c ground truth duration: 90 ticks base + 15 ticks/lvl', () => { // slvl 1: 90 ticks (3.6s) const meteorCenter1: Projectile = { skillId: '56', missileType: 'meteorcenter', x: 100, y: 100, vx: 0, vy: 0, damage: 100, ttl: 0, fromPlayer: true, slvl: 1, } const outcome1 = tickProjectiles([meteorCenter1], [], { overlap: () => 0 }) const flames1 = outcome1.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] expect(flames1.length).toBe(18) for (const f of flames1) { expect(f.ttl).toBe(90) } // slvl 5: 90 + 4 * 15 = 150 ticks (6.0s) const meteorCenter5: Projectile = { skillId: '56', missileType: 'meteorcenter', x: 100, y: 100, vx: 0, vy: 0, damage: 100, ttl: 0, fromPlayer: true, slvl: 5, } const outcome5 = tickProjectiles([meteorCenter5], [], { overlap: () => 0 }) const flames5 = outcome5.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] expect(flames5.length).toBe(18) for (const f of flames5) { expect(f.ttl).toBe(150) } // slvl 20: 90 + 19 * 15 = 375 ticks (15.0s) const meteorCenter20: Projectile = { skillId: '56', missileType: 'meteorcenter', x: 100, y: 100, vx: 0, vy: 0, damage: 100, ttl: 0, fromPlayer: true, slvl: 20, } const outcome20 = tickProjectiles([meteorCenter20], [], { overlap: () => 0 }) const flames20 = outcome20.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] expect(flames20.length).toBe(18) for (const f of flames20) { expect(f.ttl).toBe(375) } }) it('renders reverse emergence sequence and shrinking scale/alpha during extinction (ttl <= 12)', () => { const drawnCalls: Array<{ frameIdx: number; width?: number; height?: number; tint?: readonly number[] }> = [] const mockRenderer = { draw(frame: any, _x: number, _y: number, options: any = {}) { drawnCalls.push({ frameIdx: frame.frameIdx, width: options.width, height: options.height, tint: options.tint, }) }, drawSolid: vi.fn(), } as unknown as SpriteRenderer const makeIndexedArt = (meta: any): LoadedMissileArt => ({ meta, handle: 1 as unknown as AtlasHandle, frames: [ meta.groups[0].map(([x, y, w, h, ax, ay]: number[], idx: number) => ({ x, y, width: w, height: h, anchorX: ax, anchorY: ay, frameIdx: idx, })), ], }) const fireArt = makeIndexedArt(METEORFIRE_META) // Extinction phase: test ttl = 12 down to ttl = 1 for (let ttl = 12; ttl >= 1; ttl--) { drawnCalls.length = 0 const shot: Projectile = { skillId: '56', missileType: 'meteorfire', x: 100, y: 100, vx: 0, vy: 0, damage: 10, ttl, ageTicks: 90 - ttl, fromPlayer: true, fireDuration: 90, } drawMissileProjectile(mockRenderer, shot, fireArt) expect(drawnCalls.length).toBe(1) const call = drawnCalls[0]! // Frame index should step downwards from 11 to 0 const expectedFrame = Math.max(0, Math.min(11, ttl - 1)) expect(call.frameIdx).toBe(expectedFrame) // Scale must shrink towards ground const originalW = fireArt.frames[0]![expectedFrame]!.width expect(call.width).toBeDefined() expect(call.width!).toBeLessThanOrEqual(originalW) // Opacity tint must fade towards 0 expect(call.tint).toBeDefined() expect(call.tint![3]).toBeCloseTo(ttl / 12, 2) } }) it('does not draw procedural drawSolid fallback when art is not loaded (#530)', () => { const solidCalls: Array<{ x: number; y: number; w: number; h: number; color: readonly number[] }> = [] const mockRenderer = { draw: vi.fn(), drawSolid(x: number, y: number, w: number, h: number, color: any) { solidCalls.push({ x, y, w, h, color }) }, } as unknown as SpriteRenderer // Test extinction at ttl = 6 (50% decay) without loaded art const shotExtinguishing: Projectile = { skillId: '56', missileType: 'meteorfire', x: 200, y: 200, vx: 0, vy: 0, damage: 10, ttl: 6, ageTicks: 84, fromPlayer: true, fireDuration: 90, } drawMissileProjectile(mockRenderer, shotExtinguishing) expect(solidCalls.length).toBe(0) }) })