182 lines
6.5 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|