diablo2-web/tests/drop-sfx-parity.test.ts

182 lines
6.5 KiB
TypeScript

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