fix(skill): change Enchant cast overlay to authentic one-shot playback (Fixes #438)

This commit is contained in:
troytt 2026-09-24 16:22:05 +00:00
parent f12fd5674a
commit ebea702572
2 changed files with 87 additions and 1 deletions

View File

@ -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

View File

@ -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()
})
})