diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 5e2a431..cddcf68 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -2203,7 +2203,7 @@ export function castSkill( player.fireMasterySlvl = fireMasterySlvl player.isSorceress = true - engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: false }) player.cooldown = 0 engine.metrics.casts += 1 diff --git a/tests/skills/sor/issue-438-enchant-oneshot-overlay.test.ts b/tests/skills/sor/issue-438-enchant-oneshot-overlay.test.ts new file mode 100644 index 0000000..7e8fd18 --- /dev/null +++ b/tests/skills/sor/issue-438-enchant-oneshot-overlay.test.ts @@ -0,0 +1,86 @@ +/** + * 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/game/engine.ts' +import { castSkill } from '../../../src/scene/act-scene.ts' +import { DEMO_SKILLS } 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, +} + +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() + }) +})