124 lines
5.4 KiB
TypeScript
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()
|
|
})
|
|
})
|