diablo2-web/tests/p0-524-parity.test.ts

124 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getMissileTxtData } from '../src/game/skills.ts'
import { loadAutoMagic, parseAutoMagicTable } from '../src/game/automagic.ts'
import { filterByDt1Mask } from '../src/game/d2map.ts'
import { loadDt1sForLevel, resolveLevel, resolveLevelLibraries, type ActTables, type D2Table } from '../src/game/acts.ts'
import { discoverSkillModules, listRegisteredSkillModules } from '../src/game/skills/registry.ts'
import type { SkillModule } from '../src/game/skills/types.ts'
import { MONSTER_TABLE, monsterStatsOf } from '../src/game/monsters.ts'
import { parseNetMonsterStats } from '../src/scene/net-scene.ts'
import { RAW_MONSTATS } from '../src/data/canonical-drop-data.ts'
import { Rng } from '../src/game/d2-rng.ts'
import type { MountedArchives } from '../src/mpq/mount.ts'
import {
getSharedMountedArchives,
getSharedDataRegistry,
resetSharedDataRegistryCache,
} from '../src/game/engine/data-registry.ts'
describe('P0 #524 — 1.13c Parity & Fail-Fast Invariants', () => {
it('throws an explicit Error when getMissileTxtData is called with an unknown missile name', () => {
expect(() => getMissileTxtData('nonexistent_missile_xyz')).toThrow(
/Unknown missile "nonexistent_missile_xyz" in Missiles\.txt/,
)
const icebolt = getMissileTxtData('icebolt')
expect(icebolt.vel).toBeGreaterThan(0)
})
it('throws an explicit Error when AutoMagic.txt fails to load or has malformed header', async () => {
expect(() => parseAutoMagicTable('not\ta\tvalid\theader\n1\t2\t3\t4')).toThrow(
/Invalid AutoMagic\.txt header/,
)
const brokenArchives = {
has: () => true,
read: async () => {
throw new Error('corrupt MPQ sector')
},
} as unknown as MountedArchives
await expect(loadAutoMagic(brokenArchives)).rejects.toThrow(/corrupt MPQ sector/)
})
it('filterByDt1Mask returns an empty array instead of falling back to unmasked candidates', () => {
const candidates = [
{ library: 0, tileIndex: 0 } as any,
{ library: 1, tileIndex: 1 } as any,
]
// Mask bit 2 (0b100 = 4) excludes library 0 and library 1
const filtered = filterByDt1Mask(candidates, 0b100)
expect(filtered).toEqual([])
})
it('loadDt1sForLevel and resolveLevel record the actual loaded DT1 bitmask in Level.dt1Mask', () => {
const emptyTable: D2Table = { header: [], rows: [] }
const tables: ActTables = {
levels: {
header: ['Id', 'Name', 'Pal', 'LevelType', 'DrlgType', 'SizeX', 'SizeY', 'Act', 'Waypoint', 'Position', 'Portal'],
rows: [['1', 'Act 1 - Town', '0', '1', '2', '56', '40', '0', '255', '0', '0']],
},
lvltypes: {
header: ['Id', 'Name', 'File 1', 'File 2', 'File 3', 'File 4'],
// File 1 (bit 0) and File 3 (bit 2) are populated; File 2 is '0' and File 4 is ''
rows: [['1', 'Act 1 - Town', 'Act1/Town/Floor.dt1', '0', 'Act1/Town/Objects.dt1', '']],
},
lvlprest: {
header: ['Def', 'LevelId', 'File1', 'Dt1Mask', 'Animate'],
// Preset mask has bits 0, 1, 2, 3 set (15), but only slots 1 and 3 exist in LvlTypes.txt -> loaded mask = 0b0101 = 5
rows: [['1', '1', 'TownN1.ds1', '15', '80']],
},
monstats: emptyTable,
monpreset: emptyTable,
monstats2: emptyTable,
monlvl: emptyTable,
montype: emptyTable,
monumod: emptyTable,
superuniques: emptyTable,
lvlwarp: emptyTable,
}
const loaded = loadDt1sForLevel(tables, 1)
expect(loaded.dt1Mask).toBe(0b0101)
const level = resolveLevel(tables, 1)
expect(level.dt1Mask).toBe(0b0101)
const libs = resolveLevelLibraries(tables, 1)
expect(libs.dt1Mask).toBe(0b0101)
})
it('discoverSkillModules fails fast when 0 skill modules are discovered', () => {
expect(listRegisteredSkillModules().length).toBeGreaterThanOrEqual(221)
const emptyMap = new Map<number, SkillModule>()
expect(() => discoverSkillModules(() => ({}), emptyMap)).toThrow(
/zero skill modules discovered/,
)
})
it('MONSTER_TABLE and parseNetMonsterStats read 255-column MonStats.txt velocity and stats accurately', () => {
const fallen = MONSTER_TABLE.get('fallen1') ?? MONSTER_TABLE.fallen1
expect(fallen).toBeDefined()
expect(fallen!.velocity).toBe(5)
expect(fallen!.runVelocity).toBe(5)
const zombie = MONSTER_TABLE.get('zombie1') ?? MONSTER_TABLE.zombie1
expect(zombie).toBeDefined()
expect(zombie!.velocity).toBe(1)
expect(zombie!.runVelocity).toBe(3)
const stats = monsterStatsOf(fallen!, new Rng(12345), 180)
expect(stats.speed).toBeGreaterThan(0)
const netStats = parseNetMonsterStats(RAW_MONSTATS)
expect(netStats.length).toBeGreaterThan(100)
const netFallen = netStats.find(m => m.id === 'fallen1')
expect(netFallen).toBeDefined()
expect(netFallen!.hp).toBeGreaterThanOrEqual(21)
})
it('resets cachedMountedPromise and cachedRegistryPromise on rejection in data-registry.ts', async () => {
resetSharedDataRegistryCache()
await expect(getSharedMountedArchives('/nonexistent/d2/mpq/path/xyz')).rejects.toThrow()
await expect(getSharedDataRegistry('/nonexistent/d2/mpq/path/xyz')).rejects.toThrow()
// Verify a subsequent call is not stuck on a stale rejected promise with a different path
await expect(getSharedMountedArchives('/another/nonexistent/path/abc')).rejects.toThrow(/abc/)
resetSharedDataRegistryCache()
})
})