/** * Issue #438: Enchant (#52) One-Shot Visual Playback Verification Suite * * 1.13c Ground Truth Invariants: * 1. One-Shot Visual: Enchant cast overlay (`enchant`, FireEnchant.dc6) plays 17 frames once (`loop: false`). * 2. Natural Dissolution: After 17 animation frames (~27 logic ticks), the overlay naturally expires and * is removed from `engine.overlays`. * 3. Buff Persistence: Player's enchant buff (`player.enchantTicks`, `player.enchantDamage`, * `player.enchantAttackRatingPercent`) remains active for its full duration (e.g. 3600+ ticks) * even after the visual overlay has expired. */ import { describe, expect, it } from 'vitest' import { GameEngine, type EngineInput } from '../../../src/server/engine/engine.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' import { DEMO_SKILLS } 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, } describe('Issue #438: Enchant (#52) One-Shot Visual Overlay', () => { it('spawns enchant overlay with loop: false which expires after 17 frames while buff remains active', () => { const engine = new GameEngine( { widthPx: 1000, heightPx: 1000, overlap: () => 0 }, { spawn: { x: 300, y: 300 }, stats: [], questDefs: [], npcDefs: [], skills: DEMO_SKILLS } as any, ) const player = engine.world.player player.x = 300 player.y = 300 player.mana = 500 player.cooldown = 0 const statusEl = { textContent: '' } as HTMLElement const dummyRuntime = { walkable: { isWalkable: () => true }, collision: { isWalkable: () => true }, } as any const castOk = castSkill(52, 300, 300, { engine, runtime: dummyRuntime, hudManager: null, status: statusEl, }) expect(castOk).toBe(true) // Verify buff is active on player expect(player.enchantTicks).toBeGreaterThan(1000) expect(player.enchantDamage).toBeDefined() expect(player.enchantAttackRatingPercent).toBeDefined() // Verify overlay was spawned with loop: false const enchantOverlay = engine.overlays.find(ov => ov.overlayName === 'enchant') expect(enchantOverlay).toBeDefined() expect(enchantOverlay!.loop).toBe(false) expect(enchantOverlay!.maxFrames).toBe(17) // Tick engine for 20 ticks: overlay is still playing (< 17 frames) for (let t = 0; t < 20; t++) { engine.tick(DEFAULT_INPUT) } expect(enchantOverlay!.expired).toBe(false) expect(engine.overlays.some(ov => ov.overlayName === 'enchant')).toBe(true) // Tick engine for another 15 ticks (total 35 ticks > 27 ticks required for 17 frames at 16/25 frame rate) for (let t = 0; t < 15; t++) { engine.tick(DEFAULT_INPUT) } // Verify overlay is now naturally expired and cleaned up from engine.overlays expect(enchantOverlay!.expired).toBe(true) const activeEnchantOverlay = engine.overlays.find(ov => ov.overlayName === 'enchant' && !ov.expired) expect(activeEnchantOverlay).toBeUndefined() // Verify player buff is STILL ACTIVE and unexpired! expect(player.enchantTicks).toBeGreaterThan(1000) expect(player.enchantDamage).toBeDefined() expect(player.enchantAttackRatingPercent).toBeDefined() }) })