import { describe, expect, it, vi } from 'vitest' import { RAW_WEAPONS, RAW_ARMOR, RAW_MISC } from '../src/data/canonical-drop-data.ts' import { parseWeaponsTable } from '../src/game/weapons.ts' import { parseArmorTable } from '../src/game/armor.ts' import { parseMiscTable } from '../src/game/misc-items.ts' import { parseTable } from '../src/game/acts.ts' import { GroundItemManager, resolveGroundItemMetadata, } from '../src/game/ground-items.ts' describe('Issue #421: Base Item Drop SFX & Frame (1.13c Parity)', () => { describe('Suite 1: Data Table dropsound & dropsfxframe Column Extraction', () => { it('1.1: extracts dropsound and dropsfxframe for weapons', () => { const weaponsTable = parseWeaponsTable(RAW_WEAPONS) const handAxe = weaponsTable.byCode.get('hax') expect(handAxe).toBeDefined() expect(handAxe!.dropsound).toBe('item_smallmetalweapon') expect(handAxe!.dropsfxframe).toBe(12) const club = weaponsTable.byCode.get('clb') expect(club).toBeDefined() expect(club!.dropsound).toBe('item_staff') expect(club!.dropsfxframe).toBe(12) }) it('1.2: extracts dropsound and dropsfxframe for armor', () => { const d2table = parseTable(new TextEncoder().encode(RAW_ARMOR)) const armorTable = parseArmorTable(d2table) const cap = armorTable.byCode.get('cap') expect(cap).toBeDefined() expect(cap!.dropsound).toBe('item_cap') expect(cap!.dropsfxframe).toBe(12) const leatherArmor = armorTable.byCode.get('lea') expect(leatherArmor).toBeDefined() expect(leatherArmor!.dropsound).toBe('item_lightarmor') expect(leatherArmor!.dropsfxframe).toBe(12) }) it('1.3: extracts dropsound and dropsfxframe for misc items', () => { const d2table = parseTable(new TextEncoder().encode(RAW_MISC)) const miscTable = parseMiscTable(d2table) const healingPotion = miscTable.byCode.get('hp1') expect(healingPotion).toBeDefined() expect(healingPotion!.dropsound).toBe('item_potion') expect(healingPotion!.dropsfxframe).toBe(14) // Potions use frame 14 const townPortalScroll = miscTable.byCode.get('tsc') expect(townPortalScroll).toBeDefined() expect(townPortalScroll!.dropsound).toBe('item_scroll') expect(townPortalScroll!.dropsfxframe).toBe(10) }) }) describe('Suite 2: Ground Item Metadata Resolution', () => { it('2.1: resolves gold to item_gold sound at frame 12', () => { const meta = resolveGroundItemMetadata({ isGold: true, amount: 500, code: 'gld' }) expect(meta.dropSound).toBe('item_gold') expect(meta.dropSfxFrame).toBe(12) }) it('2.2: resolves equipment to base dropsound and dropsfxframe', () => { const item = { name: 'Short Sword', base: { id: 'ssd', dropsound: 'item_sword', dropsfxframe: 12, }, } const meta = resolveGroundItemMetadata(item) expect(meta.dropSound).toBe('item_sword') expect(meta.dropSfxFrame).toBe(12) }) it('2.3: resolves potion to item_potion at frame 14', () => { const item = { name: 'Healing Potion', base: { id: 'hp1', dropsound: 'item_potion', dropsfxframe: 14, }, } const meta = resolveGroundItemMetadata(item) expect(meta.dropSound).toBe('item_potion') expect(meta.dropSfxFrame).toBe(14) }) }) describe('Suite 3: Frame-Accurate Bounce Audio Dispatching', () => { it('3.1: triggers playSfx when bounce animation reaches dropsfxframe (frame * 40ms)', () => { const manager = new GroundItemManager() const startTime = 1000 const entity = manager.add( { name: 'Broad Sword', base: { dropsound: 'item_sword', dropsfxframe: 12 }, }, 10, 10, 200, 200, { bounce: true, now: startTime, durationMs: 600 }, ) expect(entity.dropSound).toBe('item_sword') expect(entity.dropSfxFrame).toBe(12) expect(entity.sfxPlayed).toBe(false) const mockAudioManager = { playSfx: vi.fn(), } // At frame 10 (400ms elapsed), currentFrame = 10 < 12: should not trigger manager.updateBounces(startTime + 400, mockAudioManager) expect(mockAudioManager.playSfx).not.toHaveBeenCalled() expect(entity.sfxPlayed).toBe(false) // At frame 12 (480ms elapsed), currentFrame = 12 >= 12: triggers playSfx const triggered = manager.updateBounces(startTime + 480, mockAudioManager) expect(mockAudioManager.playSfx).toHaveBeenCalledTimes(1) expect(mockAudioManager.playSfx).toHaveBeenCalledWith('item_sword') expect(entity.sfxPlayed).toBe(true) expect(triggered.length).toBe(1) expect(triggered[0]!.sound).toBe('item_sword') // Subsequent ticks should NOT re-trigger playSfx manager.updateBounces(startTime + 520, mockAudioManager) manager.updateBounces(startTime + 600, mockAudioManager) expect(mockAudioManager.playSfx).toHaveBeenCalledTimes(1) }) it('3.2: triggers on bounce landing even if dropSfxFrame was higher than bounce duration', () => { const manager = new GroundItemManager() const startTime = 1000 const entity = manager.add( { name: 'Super Mana Potion', base: { dropsound: 'item_potion', dropsfxframe: 20 }, }, 10, 10, 200, 200, { bounce: true, now: startTime, durationMs: 400 }, // duration 400ms = 10 frames ) const mockAudioManager = { playSfx: vi.fn() } // At landing (400ms), triggers playSfx manager.updateBounces(startTime + 400, mockAudioManager) expect(mockAudioManager.playSfx).toHaveBeenCalledWith('item_potion') expect(entity.sfxPlayed).toBe(true) }) it('3.3: gracefully handles missing or throwing audioManager', () => { const manager = new GroundItemManager() const startTime = 1000 manager.add( { name: 'Gold', isGold: true, amount: 100 }, 10, 10, 200, 200, { bounce: true, now: startTime }, ) // Call without audioManager -> does not throw expect(() => manager.updateBounces(startTime + 500)).not.toThrow() // Call with throwing audioManager -> does not throw const throwingManager = { playSfx: () => { throw new Error('AudioContext suspended') }, } expect(() => manager.updateBounces(startTime + 500, throwingManager)).not.toThrow() }) }) })