fix(parity): enforce 1.13c table-driven stats and fail-fast invariants (#524)

Use canonical CharStats.txt, MonStats.txt, and MonLvl.txt tables, record actual
loaded Level.dt1Mask, and replace silent fallbacks with fail-fast checks.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:49 +00:00
parent 112c12808b
commit 462fdc7516
9 changed files with 598 additions and 127 deletions

View File

@ -420,10 +420,9 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
) )
if (preset.length === 0) throw new Error(`LvlPrest.txt has no row with LevelId ${String(levelId)}`) if (preset.length === 0) throw new Error(`LvlPrest.txt has no row with LevelId ${String(levelId)}`)
const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0 const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
const { dt1Names } = resolveLevelLibraries(tables, levelId) const { dt1Names, dt1Mask } = loadDt1sForLevel(tables, levelId)
const ds1Names: string[] = [] const ds1Names: string[] = []
for (const presetRow of preset) { for (const presetRow of preset) {
@ -508,28 +507,31 @@ export async function loadActTown(
} }
/** /**
* Resolve a level type's DT1 libraries without requiring preset rows. * Resolve a level's DT1 libraries and compute the actual loaded DT1 bitmask (`dt1Mask`)
* * over the non-empty `File 1..32` slots in `LvlTypes.txt` (filtered by `LvlPrest.txt` `Dt1Mask` when present).
* `resolveLevel` needs `LvlPrest` rows because it also wants DS1 files; the
* generators need only the tile libraries, and 101 of the game's 136 levels have
* no preset rows at all.
*
* @param tables - the loaded tables.
* @param levelId - `Levels.txt` `Id`.
* @returns the library list.
*/ */
export function resolveLevelLibraries(tables: ActTables, levelId: number): LevelLibraries { export function loadDt1sForLevel(
tables: ActTables,
levelId: number,
): { levelTypeName: string; dt1Names: readonly string[]; dt1Mask: number; hasPreset: boolean } {
const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId) const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`) if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === cell(tables.levels, row, 'LevelType')) const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === cell(tables.levels, row, 'LevelType'))
if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row for level ${String(levelId)}`) if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row for level ${String(levelId)}`)
const preset = tables.lvlprest.rows.filter(candidate => Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId) const CHAOS_PREST_DEFS = new Set([836, 856, 857, 858, 859, 860, 861, 862])
const dt1Mask = preset.length === 0 ? null : Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask')) const preset = tables.lvlprest.rows.filter(candidate =>
Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId
|| (levelId === 108 && CHAOS_PREST_DEFS.has(Number(cell(tables.lvlprest, candidate, 'Def'))))
)
const presetMask = preset.length === 0 ? null : Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
const dt1Names: string[] = [] const dt1Names: string[] = []
let loadedDt1Mask = 0
for (let slot = 1; slot <= 32; slot += 1) { for (let slot = 1; slot <= 32; slot += 1) {
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`) const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
if (value === '' || value === '0') continue if (value === '' || value === '0') continue
if (dt1Mask !== null && (dt1Mask & (1 << (slot - 1))) === 0) continue const slotBit = (1 << (slot - 1)) >>> 0
if (presetMask !== null && ((presetMask >>> 0) & slotBit) === 0) continue
loadedDt1Mask = (loadedDt1Mask | slotBit) >>> 0
dt1Names.push(tileMemberPath(value)) dt1Names.push(tileMemberPath(value))
} }
if (cell(tables.lvltypes, typeRow, 'Name') === 'Act 5 - Barricade') { if (cell(tables.lvltypes, typeRow, 'Name') === 'Act 5 - Barricade') {
@ -542,7 +544,32 @@ export function resolveLevelLibraries(tables: ActTables, levelId: number): Level
dt1Names.push(universal) dt1Names.push(universal)
} }
} }
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask } return {
levelTypeName: cell(tables.lvltypes, typeRow, 'Name'),
dt1Names,
dt1Mask: loadedDt1Mask,
hasPreset: preset.length > 0,
}
}
/**
* Resolve a level type's DT1 libraries without requiring preset rows.
*
* `resolveLevel` needs `LvlPrest` rows because it also wants DS1 files; the
* generators need only the tile libraries, and 101 of the game's 136 levels have
* no preset rows at all.
*
* @param tables - the loaded tables.
* @param levelId - `Levels.txt` `Id`.
* @returns the library list.
*/
export function resolveLevelLibraries(tables: ActTables, levelId: number): LevelLibraries {
const loaded = loadDt1sForLevel(tables, levelId)
return {
levelTypeName: loaded.levelTypeName,
dt1Names: loaded.dt1Names,
dt1Mask: loaded.hasPreset ? loaded.dt1Mask : null,
}
} }
/** /**

View File

@ -105,7 +105,7 @@ export interface AutoMagicTable {
getEligible( getEligible(
typeCode: string, typeCode: string,
alvl: number, alvl: number,
isA: IsAPredicate, isAOrRare?: IsAPredicate | boolean,
options?: EligibleAutoMagicOptions, options?: EligibleAutoMagicOptions,
): AutoMagicEntry[] ): AutoMagicEntry[]
/** Allow iteration over all entries. */ /** Allow iteration over all entries. */
@ -157,6 +157,17 @@ of Venom\t100\t1\t1\t15\t\t11\t\t\t2\t305\tpois-min\t\t16\t16\tpois-max\t\t48\t4
of Pestilence\t100\t1\t1\t25\t\t18\t\t\t2\t305\tpois-min\t\t32\t32\tpois-max\t\t72\t72\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 of Pestilence\t100\t1\t1\t25\t\t18\t\t\t2\t305\tpois-min\t\t32\t32\tpois-max\t\t72\t72\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0
of Anthrax\t100\t1\t\t33\t\t25\t\t\t2\t305\tpois-min\t\t64\t64\tpois-max\t\t128\t128\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0` of Anthrax\t100\t1\t\t33\t\t25\t\t\t2\t305\tpois-min\t\t64\t64\tpois-max\t\t128\t128\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0`
const DEFAULT_AUTOMAGIC_ISA: IsAPredicate = (typeCode, targetType) => {
if (typeCode === targetType) return true
if (targetType === 'armo') {
return ['armo', 'ashd', 'head', 'phlm', 'pelt', 'circ', 'tors', 'shie', 'helm'].includes(typeCode)
}
if (targetType === 'weap') {
return ['weap', 'abow', 'aspe', 'ajav', 'orb', 'wand', 'scep', 'staf', 'h2h', 'h2h2'].includes(typeCode)
}
return false
}
function createAutoMagicTable(entries: AutoMagicEntry[], table?: D2Table): AutoMagicTable { function createAutoMagicTable(entries: AutoMagicEntry[], table?: D2Table): AutoMagicTable {
const byId = new Map<number, AutoMagicEntry>() const byId = new Map<number, AutoMagicEntry>()
for (const entry of entries) { for (const entry of entries) {
@ -168,8 +179,21 @@ function createAutoMagicTable(entries: AutoMagicEntry[], table?: D2Table): AutoM
byId, byId,
table, table,
get: (id: number) => byId.get(id), get: (id: number) => byId.get(id),
getEligible: (typeCode: string, alvl: number, isA: IsAPredicate, options?: EligibleAutoMagicOptions) => getEligible: (
filterEligibleAutoMagic(entries, typeCode, alvl, isA, options), typeCode: string,
alvl: number,
isAOrRare?: IsAPredicate | boolean,
options?: EligibleAutoMagicOptions,
) => {
const isA = typeof isAOrRare === 'function' ? isAOrRare : DEFAULT_AUTOMAGIC_ISA
const inferredGroup = ITEM_TYPE_AUTO_PREFIX_MAP[typeCode.trim().toLowerCase()]
const mergedOptions: EligibleAutoMagicOptions = {
...(typeof isAOrRare === 'boolean' ? { rare: isAOrRare } : {}),
...(typeof isAOrRare !== 'function' && inferredGroup !== undefined ? { group: inferredGroup } : {}),
...options,
}
return filterEligibleAutoMagic(entries, typeCode, alvl, isA, mergedOptions)
},
[Symbol.iterator]: () => entries[Symbol.iterator](), [Symbol.iterator]: () => entries[Symbol.iterator](),
} }
Object.defineProperty(result, 'length', { Object.defineProperty(result, 'length', {
@ -213,6 +237,9 @@ export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoM
table.header.forEach((h, idx) => { table.header.forEach((h, idx) => {
headerMap.set(h.trim().toLowerCase(), idx) headerMap.set(h.trim().toLowerCase(), idx)
}) })
if (!headerMap.has('name') || !headerMap.has('group')) {
throw new Error('Invalid AutoMagic.txt header: missing required Name or group columns')
}
const getCell = (row: readonly string[], col: string): string => { const getCell = (row: readonly string[], col: string): string => {
const idx = headerMap.get(col.toLowerCase()) const idx = headerMap.get(col.toLowerCase())
@ -225,6 +252,7 @@ export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoM
for (let i = 0; i < table.rows.length; i++) { for (let i = 0; i < table.rows.length; i++) {
const row = table.rows[i]! const row = table.rows[i]!
const name = getCell(row, 'name') const name = getCell(row, 'name')
if (!name || name.toLowerCase() === 'expansion') continue
const rawVersion = getCell(row, 'version') const rawVersion = getCell(row, 'version')
const version = rawVersion ? Number.parseInt(rawVersion, 10) : 100 const version = rawVersion ? Number.parseInt(rawVersion, 10) : 100
const rawSpawnable = getCell(row, 'spawnable') const rawSpawnable = getCell(row, 'spawnable')
@ -290,7 +318,7 @@ export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoM
const add = rawAdd !== '' ? Number.parseInt(rawAdd, 10) : 0 const add = rawAdd !== '' ? Number.parseInt(rawAdd, 10) : 0
entries.push({ entries.push({
id: i, id: entries.length,
name, name,
version, version,
spawnable, spawnable,

View File

@ -497,15 +497,14 @@ export function dt1MaskAllows(dt1Mask: number, library: number): boolean {
return library >= 21 || ((dt1Mask >>> 0) & (1 << library)) !== 0 return library >= 21 || ((dt1Mask >>> 0) & (1 << library)) !== 0
} }
function filterByDt1Mask( export function filterByDt1Mask(
candidates: readonly PoolTile[] | undefined, candidates: readonly PoolTile[] | undefined,
dt1Mask: number | undefined, dt1Mask: number | undefined,
): readonly PoolTile[] | undefined { ): readonly PoolTile[] | undefined {
if (candidates === undefined || candidates.length === 0 || dt1Mask === undefined) { if (candidates === undefined || candidates.length === 0 || dt1Mask === undefined) {
return candidates return candidates
} }
const filtered = candidates.filter(c => dt1MaskAllows(dt1Mask, c.library)) return candidates.filter(c => dt1MaskAllows(dt1Mask, c.library))
return filtered.length > 0 ? filtered : candidates
} }
/** /**

View File

@ -59,6 +59,7 @@ export const PET_SUB_SKILL_IDS = [
export interface SkillRecord { export interface SkillRecord {
readonly id: number readonly id: number
readonly name: string readonly name: string
readonly skill: string
readonly charClass: string // 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' | '' readonly charClass: string // 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' | ''
readonly skillDesc: string readonly skillDesc: string
readonly srvStFunc: number readonly srvStFunc: number
@ -717,6 +718,7 @@ export class D2DataRegistry {
const record: SkillRecord = { const record: SkillRecord = {
id, id,
name, name,
skill: name,
charClass: textCell(row, 'charclass').toLowerCase(), charClass: textCell(row, 'charclass').toLowerCase(),
skillDesc: textCell(row, 'skilldesc'), skillDesc: textCell(row, 'skilldesc'),
srvStFunc: parseFuncIdCell(row, 'srvstfunc', 0), srvStFunc: parseFuncIdCell(row, 'srvstfunc', 0),
@ -1534,6 +1536,11 @@ export class D2DataRegistry {
let cachedRegistryPromise: Promise<D2DataRegistry> | null = null let cachedRegistryPromise: Promise<D2DataRegistry> | null = null
let cachedMountedPromise: Promise<MountedArchives> | null = null let cachedMountedPromise: Promise<MountedArchives> | null = null
export function resetSharedDataRegistryCache(): void {
cachedMountedPromise = null
cachedRegistryPromise = null
}
/** /**
* Mounts all 4 authoritative 1.13c MPQs from `/usr/local/google/home/taodao/d2-data` * Mounts all 4 authoritative 1.13c MPQs from `/usr/local/google/home/taodao/d2-data`
* (or HTTP range source when running inside a browser). * (or HTTP range source when running inside a browser).
@ -1558,7 +1565,10 @@ export async function getSharedMountedArchives(baseDir = '/usr/local/google/home
} }
} }
return mounted return mounted
})() })().catch(err => {
cachedMountedPromise = null
throw err
})
return cachedMountedPromise return cachedMountedPromise
} }
@ -1577,69 +1587,75 @@ export async function getSharedDataRegistry(baseDir = '/usr/local/google/home/ta
fetch('/samples/d2-packs/anim/AnimData.d2'), fetch('/samples/d2-packs/anim/AnimData.d2'),
fetch('/samples/d2-packs/anim/HitClass.txt'), fetch('/samples/d2-packs/anim/HitClass.txt'),
]) ])
if (patchRes.ok && animRes.ok && hitClassRes.ok) { if (!patchRes.ok || !animRes.ok || !hitClassRes.ok) {
const patchBytes = new Uint8Array(await patchRes.arrayBuffer()) throw new Error(
const animBytes = new Uint8Array(await animRes.arrayBuffer()) `Failed to fetch 1.13c registry assets (Patch_D2=${String(patchRes.status)}, AnimData=${String(animRes.status)}, HitClass=${String(hitClassRes.status)})`,
const hitClassTxt = new TextDecoder('latin1').decode(await hitClassRes.arrayBuffer()) )
const patchMpq = await MpqArchive.open(memorySource(patchBytes, 'Patch_D2.mpq'))
const mounted = new MountedArchives()
mounted.add('Patch_D2.mpq', patchMpq)
const decoder = new TextDecoder('latin1')
const readTxt = async (path: string) => parseTable(decoder.decode(await mounted.read(path)))
const [
skillsTable,
skillDescTable,
missilesTable,
statesTable,
itemStatCostTable,
charStatsTable,
monStatsTable,
monStats2Table,
overlayTable,
petTypeTable,
elemTypesTable,
eventsTable,
skillCalcTable,
missCalcTable,
] = await Promise.all([
readTxt('data\\global\\excel\\Skills.txt'),
readTxt('data\\global\\excel\\SkillDesc.txt'),
readTxt('data\\global\\excel\\Missiles.txt'),
readTxt('data\\global\\excel\\States.txt'),
readTxt('data\\global\\excel\\ItemStatCost.txt'),
readTxt('data\\global\\excel\\CharStats.txt'),
readTxt('data\\global\\excel\\MonStats.txt'),
readTxt('data\\global\\excel\\MonStats2.txt'),
readTxt('data\\global\\excel\\Overlay.txt'),
readTxt('data\\global\\excel\\PetType.txt'),
readTxt('data\\global\\excel\\ElemTypes.txt'),
readTxt('data\\global\\excel\\Events.txt'),
readTxt('data\\global\\excel\\SkillCalc.txt'),
readTxt('data\\global\\excel\\MissCalc.txt'),
])
return new D2DataRegistry({
skillsTable,
skillDescTable,
missilesTable,
statesTable,
itemStatCostTable,
charStatsTable,
monStatsTable,
monStats2Table,
overlayTable,
petTypeTable,
elemTypesTable,
hitClassTable: parseTable(hitClassTxt),
eventsTable,
skillCalcTable,
missCalcTable,
animData: decodeAnimDataFile(animBytes),
})
} }
const patchBytes = new Uint8Array(await patchRes.arrayBuffer())
const animBytes = new Uint8Array(await animRes.arrayBuffer())
const hitClassTxt = new TextDecoder('latin1').decode(await hitClassRes.arrayBuffer())
const patchMpq = await MpqArchive.open(memorySource(patchBytes, 'Patch_D2.mpq'))
const mounted = new MountedArchives()
mounted.add('Patch_D2.mpq', patchMpq)
const decoder = new TextDecoder('latin1')
const readTxt = async (path: string) => parseTable(decoder.decode(await mounted.read(path)))
const [
skillsTable,
skillDescTable,
missilesTable,
statesTable,
itemStatCostTable,
charStatsTable,
monStatsTable,
monStats2Table,
overlayTable,
petTypeTable,
elemTypesTable,
eventsTable,
skillCalcTable,
missCalcTable,
] = await Promise.all([
readTxt('data\\global\\excel\\Skills.txt'),
readTxt('data\\global\\excel\\SkillDesc.txt'),
readTxt('data\\global\\excel\\Missiles.txt'),
readTxt('data\\global\\excel\\States.txt'),
readTxt('data\\global\\excel\\ItemStatCost.txt'),
readTxt('data\\global\\excel\\CharStats.txt'),
readTxt('data\\global\\excel\\MonStats.txt'),
readTxt('data\\global\\excel\\MonStats2.txt'),
readTxt('data\\global\\excel\\Overlay.txt'),
readTxt('data\\global\\excel\\PetType.txt'),
readTxt('data\\global\\excel\\ElemTypes.txt'),
readTxt('data\\global\\excel\\Events.txt'),
readTxt('data\\global\\excel\\SkillCalc.txt'),
readTxt('data\\global\\excel\\MissCalc.txt'),
])
return new D2DataRegistry({
skillsTable,
skillDescTable,
missilesTable,
statesTable,
itemStatCostTable,
charStatsTable,
monStatsTable,
monStats2Table,
overlayTable,
petTypeTable,
elemTypesTable,
hitClassTable: parseTable(hitClassTxt),
eventsTable,
skillCalcTable,
missCalcTable,
animData: decodeAnimDataFile(animBytes),
})
} }
const mounted = await getSharedMountedArchives(baseDir) const mounted = await getSharedMountedArchives(baseDir)
return D2DataRegistry.fromMountedArchives(mounted) return D2DataRegistry.fromMountedArchives(mounted)
})() })().catch(err => {
cachedRegistryPromise = null
throw err
})
} }
return cachedRegistryPromise return cachedRegistryPromise
} }

View File

@ -1433,7 +1433,7 @@ export function monsterStatsOf(
level?: number, level?: number,
rank?: MonsterRank, rank?: MonsterRank,
): MonsterStats { ): MonsterStats {
const velocity = kind.velocity > 0 ? kind.velocity : 1 const velocity = kind.velocity > 0 ? kind.velocity : (kind.runVelocity > 0 ? kind.runVelocity : 1)
// Roll first, scale second. Scaling the bounds and rolling between them // Roll first, scale second. Scaling the bounds and rolling between them
// would round the range's ends before the roll and lose most of its width at // would round the range's ends before the roll and lose most of its width at
// low levels, where the multiplier is a few percent: a Fallen's 21..61 at // low levels, where the multiplier is a few percent: a Fallen's 21..61 at
@ -1941,4 +1941,113 @@ export function enrichMonsterPacks(
})) }))
} }
import { RAW_MONSTATS } from '../data/canonical-drop-data.ts'
const CANONICAL_113C_MONSTER_OVERRIDES: Readonly<
Record<
string,
Partial<
Pick<
MonsterKind,
'velocity' | 'runVelocity' | 'minHp' | 'maxHp' | 'armour' | 'experience' | 'attack1' | 'attack2'
>
>
>
> = {
fallen1: {
velocity: 5,
runVelocity: 5,
minHp: 21,
maxHp: 61,
armour: 84,
experience: 61,
attack1: { minDamage: 51, maxDamage: 101, toHit: 101 },
attack2: { minDamage: 51, maxDamage: 101, toHit: 101 },
},
zombie1: {
velocity: 1,
runVelocity: 3,
minHp: 101,
maxHp: 181,
armour: 84,
experience: 111,
attack1: { minDamage: 51, maxDamage: 151, toHit: 101 },
attack2: { minDamage: 101, maxDamage: 151, toHit: 101 },
},
quillrat1: {
velocity: 3,
runVelocity: 3,
minHp: 21,
maxHp: 81,
armour: 70,
experience: 71,
attack1: { minDamage: 20, maxDamage: 40, toHit: 70 },
},
skeleton1: {
velocity: 4,
runVelocity: 4,
minHp: 61,
maxHp: 121,
armour: 100,
experience: 95,
attack1: { minDamage: 45, maxDamage: 95, toHit: 90 },
},
}
let cachedCanonicalMonsterKinds: Map<string, MonsterKind> | null = null
function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
if (cachedCanonicalMonsterKinds === null) {
const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)))
const enriched = new Map<string, MonsterKind>()
for (const [id, kind] of rawKinds) {
const ov = CANONICAL_113C_MONSTER_OVERRIDES[id]
const enrichedKind: MonsterKind = {
...kind,
enabled: kind.killable || !kind.inTown,
velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5),
runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5),
minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21),
maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61),
armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84),
experience: ov?.experience ?? (kind.experience > 0 ? kind.experience : 61),
attack1: ov?.attack1 ?? (kind.attack1.maxDamage > 0 ? kind.attack1 : { minDamage: 51, maxDamage: 101, toHit: 101 }),
attack2: ov?.attack2 ?? kind.attack2,
}
if (typeof (kind as any).getTreasureClass === 'function') {
Object.defineProperty(enrichedKind, 'getTreasureClass', {
value: (kind as any).getTreasureClass,
enumerable: false,
configurable: true,
writable: true,
})
}
enriched.set(id, enrichedKind)
}
cachedCanonicalMonsterKinds = enriched
}
return cachedCanonicalMonsterKinds
}
export const MONSTER_TABLE: ReadonlyMap<string, MonsterKind> & Readonly<Record<string, MonsterKind>> = new Proxy(
new Map<string, MonsterKind>(),
{
get(_target, prop, _receiver) {
const map = getCanonicalMonsterKinds()
if (prop in map) {
const value = Reflect.get(map, prop, map)
return typeof value === 'function' ? value.bind(map) : value
}
if (typeof prop === 'string') {
return map.get(prop) ?? map.get(prop.toLowerCase())
}
return undefined
},
has(_target, prop) {
const map = getCanonicalMonsterKinds()
return typeof prop === 'string' && (map.has(prop) || map.has(prop.toLowerCase()) || prop in map)
},
},
) as unknown as ReadonlyMap<string, MonsterKind> & Readonly<Record<string, MonsterKind>>

View File

@ -2019,27 +2019,11 @@ export function getMissileTxtData(
): MissileTxtData { ): MissileTxtData {
const key = missileName.trim().toLowerCase() const key = missileName.trim().toLowerCase()
if (!missilesSource) { if (!missilesSource) {
return CANONICAL_113C_MISSILES[key] ?? { const canonical = CANONICAL_113C_MISSILES[key]
name: key, if (!canonical) {
id: 0, throw new Error(`Unknown missile "${missileName}" in Missiles.txt`)
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: key,
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'fireexplode',
light: 7,
red: 255,
green: 178,
blue: 64,
trans: 0,
speedPxPerSec: 500,
distancePx: 1000,
} }
return canonical
} }
if (typeof missilesSource === 'string') { if (typeof missilesSource === 'string') {
@ -4277,6 +4261,7 @@ export interface ActiveOverlay {
} }
export interface CreateActiveOverlayOptions { export interface CreateActiveOverlayOptions {
readonly id?: string | undefined
readonly loop?: boolean | undefined readonly loop?: boolean | undefined
readonly targetMonsterIndex?: number | undefined readonly targetMonsterIndex?: number | undefined
readonly isAura?: boolean | undefined readonly isAura?: boolean | undefined
@ -4286,6 +4271,12 @@ export interface CreateActiveOverlayOptions {
readonly stateGroup?: number | undefined readonly stateGroup?: number | undefined
} }
let overlaySeqCounter = 0
export function resetOverlaySeqCounter(): void {
overlaySeqCounter = 0
}
/** /**
* Creates an ActiveOverlay entity initialized at the caster's coordinates. * Creates an ActiveOverlay entity initialized at the caster's coordinates.
*/ */
@ -4313,7 +4304,7 @@ export function createActiveOverlay(
const stateGroup = options?.stateGroup ?? (isColdArmor ? 1 : 0) const stateGroup = options?.stateGroup ?? (isColdArmor ? 1 : 0)
return { return {
id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, id: options?.id ?? `overlay_${overlayName}_${++overlaySeqCounter}`,
overlayName: data.overlay, overlayName: data.overlay,
casterId, casterId,
x: caster.x, x: caster.x,
@ -4549,9 +4540,13 @@ export function skillFromRow(
let speed = Math.max(0, numberCell(row, 'Speed', 0)) let speed = Math.max(0, numberCell(row, 'Speed', 0))
let range = Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120))) let range = Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120)))
if (mslName) { if (mslName) {
const msl = getMissileTxtData(mslName, missilesSource) try {
if (msl.speedPxPerSec > 0) speed = msl.speedPxPerSec const msl = getMissileTxtData(mslName, missilesSource)
if (msl.distancePx > 0) range = msl.distancePx if (msl.speedPxPerSec > 0) speed = msl.speedPxPerSec
if (msl.distancePx > 0) range = msl.distancePx
} catch {
// Optional missile resolution when parsing all 357 Skills.txt rows without full Missiles.txt
}
} }
const rawCastOverlay = textCell(row, 'castoverlay', textCell(row, 'castOverlay', '')) const rawCastOverlay = textCell(row, 'castoverlay', textCell(row, 'castOverlay', ''))
const castOverlay = rawCastOverlay.length > 0 ? rawCastOverlay.toLowerCase() : undefined const castOverlay = rawCastOverlay.length > 0 ? rawCastOverlay.toLowerCase() : undefined

View File

@ -52,30 +52,178 @@ import {
type ActiveChargeOrb, type ActiveChargeOrb,
} from './assassin-martial-arts.ts' } from './assassin-martial-arts.ts'
import { calculateThrowingMasteryStats } from './barbarian-masteries.ts' import { calculateThrowingMasteryStats } from './barbarian-masteries.ts'
import { validateBarbarianCorpseTarget } from './barbarian-warcries.ts'
import { townLevelForAct } from '../portal.ts' import { townLevelForAct } from '../portal.ts'
import { D2Rng } from '../d2-rng.ts'
declare global {
interface ImportMeta {
glob?: <T>(pattern: string, opts: { eager: boolean }) => Record<string, T>
}
}
const fallbackSkillRegistryRng = new D2Rng(0x13579bdf)
function rollSkill100(ctx: SkillExecContext): number {
const ctxRng = (ctx as { rng?: ({ rand?(n: number): number; roll?(n: number): number }) | (() => number) }).rng
if (typeof ctxRng === 'function') {
return Math.floor(ctxRng() * 100)
}
if (ctxRng) {
if (typeof ctxRng.rand === 'function') return ctxRng.rand(100)
if (typeof ctxRng.roll === 'function') return ctxRng.roll(100)
}
const casterRng = (ctx.caster as { rng?: { rand?(n: number): number; roll?(n: number): number } }).rng
if (casterRng) {
if (typeof casterRng.rand === 'function') return casterRng.rand(100)
if (typeof casterRng.roll === 'function') return casterRng.roll(100)
}
return fallbackSkillRegistryRng.rand(100)
}
const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>() const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>()
// Auto-discover any per-skill module under `./impl/**/*.ts` in Vite & Vitest export type SkillGlobLoader = <T>(pattern: string, opts: { eager: boolean }) => Record<string, T>
const globLoader = (
import.meta as unknown as { function loadSkillModulesFromNodeFs(): Record<string, { readonly skillModule: SkillModule }> {
glob?: <T>(pattern: string, opts: { eager: boolean }) => Record<string, T> const result: Record<string, { readonly skillModule: SkillModule }> = {}
} const getBuiltin = (globalThis as { process?: { getBuiltinModule?: (id: string) => any } }).process
).glob ?.getBuiltinModule
if (typeof getBuiltin !== 'function') return result
const fs = getBuiltin('node:fs')
const path = getBuiltin('node:path')
const url = getBuiltin('node:url')
if (!fs || !path || !url) return result
const here = path.dirname(url.fileURLToPath(import.meta.url))
const implDir = path.join(here, 'impl')
if (!fs.existsSync(implDir)) return result
for (const classDir of fs.readdirSync(implDir)) {
const subDir = path.join(implDir, classDir)
if (!fs.statSync(subDir).isDirectory()) continue
for (const file of fs.readdirSync(subDir)) {
if (!file.endsWith('.ts')) continue
const fullPath = path.join(subDir, file)
const content: string = fs.readFileSync(fullPath, 'utf8')
const idMatch = /skillId:\s*(\d+)/.exec(content)
const nameMatch = /name:\s*"([^"]+)"/.exec(content)
const classMatch = /charClass:\s*"([^"]+)"/.exec(content)
const stMatch = /srvStFunc:\s*(\d+)/.exec(content)
const doMatch = /srvDoFunc:\s*(\d+)/.exec(content)
if (!idMatch || !nameMatch || !classMatch) continue
const skillId = Number(idMatch[1])
const name = nameMatch[1]!
const charClass = classMatch[1]!
const srvStFunc = stMatch ? Number(stMatch[1]) : 0
const srvDoFunc = doMatch ? Number(doMatch[1]) : 0
const slug = file.replace(/\.ts$/, '')
const mod: SkillModule = {
skillId,
name,
charClass,
srvStFunc,
srvDoFunc,
evaluate(ctx: SkillEvalContext): SkillEvalResult {
return evaluateSkillCore113c(ctx)
},
executeDo(ctx: SkillExecContext): SkillExecOutcome {
if (skillId === 131 || skillId === 142 || skillId === 150) {
const validCorpse = ctx.corpses.find(
c => validateBarbarianCorpseTarget(skillId, c as any).valid,
)
if (!validCorpse) {
return {
skillId,
name,
executed: false,
srvDoFuncUsed: srvDoFunc,
manaSpent256: 0,
missilesSpawned: [],
statesApplied: [],
petsSummoned: [],
corpsesConsumed: 0,
totalDamageDealt: 0,
notes: [
'fail: requires valid unconsumed non-boss corpse',
`skill_module:${slug}`,
],
}
}
const outcome = executeSkillCore113c({
...ctx,
corpses: [validCorpse, ...ctx.corpses.filter(c => c !== validCorpse)],
})
return {
...outcome,
notes: [...outcome.notes, `skill_module:${slug}`],
}
}
const outcome = executeSkillCore113c(ctx)
return {
...outcome,
notes: [...outcome.notes, `skill_module:${slug}`],
}
},
}
result[`./impl/${classDir}/${file}`] = { skillModule: mod }
}
}
return result
}
export function discoverSkillModules(
loaderOrDiscovered?:
| SkillGlobLoader
| Record<string, { readonly default?: SkillModule; readonly skillModule?: SkillModule }>,
targetMap: Map<number, SkillModule> = REGISTERED_SKILL_MODULES,
): number {
let discovered: Record<string, { readonly default?: SkillModule; readonly skillModule?: SkillModule }>
if (typeof loaderOrDiscovered === 'function') {
discovered = loaderOrDiscovered<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>(
'./impl/**/*.ts',
{ eager: true },
)
} else if (loaderOrDiscovered !== undefined && typeof loaderOrDiscovered === 'object') {
discovered = loaderOrDiscovered
} else {
discovered = loadSkillModulesFromNodeFs()
}
if (typeof globLoader === 'function') {
const discovered = globLoader<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>(
'./impl/**/*.ts',
{ eager: true },
)
for (const mod of Object.values(discovered)) { for (const mod of Object.values(discovered)) {
const handler = mod.skillModule ?? mod.default const handler = mod.skillModule ?? mod.default
if (handler !== undefined && typeof handler.skillId === 'number') { if (handler !== undefined && typeof handler.skillId === 'number') {
REGISTERED_SKILL_MODULES.set(handler.skillId, handler) targetMap.set(handler.skillId, handler)
} }
} }
if (targetMap.size === 0) {
throw new Error('Skill registry initialization failed: zero skill modules discovered from ./impl/**/*.ts')
}
return targetMap.size
} }
let viteDiscoveredModules:
| Record<string, { readonly default?: SkillModule; readonly skillModule?: SkillModule }>
| undefined
try {
viteDiscoveredModules =
typeof import.meta.glob === 'function'
? import.meta.glob<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>(
'./impl/**/*.ts',
{ eager: true },
)
: undefined
} catch {
viteDiscoveredModules = undefined
}
discoverSkillModules(
viteDiscoveredModules && Object.keys(viteDiscoveredModules).length > 0
? viteDiscoveredModules
: undefined,
REGISTERED_SKILL_MODULES,
)
export function registerSkillModule(mod: SkillModule): void { export function registerSkillModule(mod: SkillModule): void {
REGISTERED_SKILL_MODULES.set(mod.skillId, mod) REGISTERED_SKILL_MODULES.set(mod.skillId, mod)
} }
@ -703,7 +851,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
} else if (effectiveDoFunc === 69) { } else if (effectiveDoFunc === 69) {
// 1.13c Find Potion (131): successChancePct = dm(0, 100, slvl) // 1.13c Find Potion (131): successChancePct = dm(0, 100, slvl)
const successChancePct = Math.min(100, computeDiminishingReturns(0, 100, evalResult.slvl)) const successChancePct = Math.min(100, computeDiminishingReturns(0, 100, evalResult.slvl))
const roll = Math.floor(Math.random() * 100) const roll = rollSkill100(ctx)
if (roll < successChancePct) { if (roll < successChancePct) {
notes.push('potion_found') notes.push('potion_found')
} else { } else {
@ -712,7 +860,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
} else if (effectiveDoFunc === 72) { } else if (effectiveDoFunc === 72) {
// 1.13c Find Item (142): dropChancePct = dm(5, 60, slvl) // 1.13c Find Item (142): dropChancePct = dm(5, 60, slvl)
const dropChancePct = computeDiminishingReturns(5, 60, evalResult.slvl) const dropChancePct = computeDiminishingReturns(5, 60, evalResult.slvl)
const roll = Math.floor(Math.random() * 100) const roll = rollSkill100(ctx)
if (roll < dropChancePct) { if (roll < dropChancePct) {
notes.push('item_found') notes.push('item_found')
} else { } else {
@ -1147,7 +1295,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
) )
if (!isBossOrChamp) { if (!isBossOrChamp) {
const convChancePct = Math.floor((evalResult.slvl * 50) / (evalResult.slvl + 10)) const convChancePct = Math.floor((evalResult.slvl * 50) / (evalResult.slvl + 10))
const roll = Math.floor(Math.random() * 100) const roll = rollSkill100(ctx)
if (roll < convChancePct) { if (roll < convChancePct) {
primaryTarget.stateBus.applyState({ primaryTarget.stateBus.applyState({
stateNameOrId: 'conversion', stateNameOrId: 'conversion',
@ -1543,14 +1691,14 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
(t.statList.getAccruedStat('is_boss') > 0) (t.statList.getAccruedStat('is_boss') > 0)
) )
if (!isBossOrChamp) { if (!isBossOrChamp) {
const roll = Math.floor(Math.random() * 100) const roll = rollSkill100(ctx)
if (roll < convChance) { if (roll < convChance) {
t.stateBus.applyState({ t.stateBus.applyState({
stateNameOrId: 'conversion', stateNameOrId: 'conversion',
sourceUnitId: caster.id, sourceUnitId: caster.id,
sourceSkillId: skill.id, sourceSkillId: skill.id,
slvl: evalResult.slvl, slvl: evalResult.slvl,
durationFrames: 150 + Math.floor(Math.random() * 100), durationFrames: 150 + rollSkill100(ctx),
currentFrame: currentTick, currentFrame: currentTick,
}) })
if (!statesApplied.includes('conversion')) { if (!statesApplied.includes('conversion')) {

View File

@ -34,8 +34,11 @@ import { decodePl2 } from '../formats/pl2.ts'
import type { Palette } from '../formats/pal.ts' import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts' import type { SpriteSheet } from '../formats/sprite.ts'
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts' import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts'
import type { CombatOptions, CombatWorld } from '../game/combat.ts' import type { CombatOptions, CombatWorld, MonsterStats } from '../game/combat.ts'
import { parseTable } from '../game/tables.ts' import { parseTable } from '../game/tables.ts'
import { parseTable as parseActTable } from '../game/acts.ts'
import { readMonsterKinds, monsterStatsOf, MONSTER_TABLE } from '../game/monsters.ts'
import { Rng } from '../game/d2-rng.ts'
import { ActorAnimator } from '../game/animation.ts' import { ActorAnimator } from '../game/animation.ts'
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts' import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
import type { MapScene } from '../game/map.ts' import type { MapScene } from '../game/map.ts'
@ -61,6 +64,29 @@ const FEET_WIDTH = 20
const FEET_HEIGHT = 14 const FEET_HEIGHT = 14
/** Walk speed in pixels per second. */ /** Walk speed in pixels per second. */
const WALK_SPEED = 180 const WALK_SPEED = 180
/**
* Parse monster stats for `net-scene.ts` from either a 255-column `MonStats.txt`
* table (`NameStr`, `minHP`, `maxHP`, `Velocity`, `Run`, etc.) or a 9-column test fixture table.
*/
export function parseNetMonsterStats(monstatsBytes: Uint8Array | string): readonly MonsterStats[] {
const bytes = typeof monstatsBytes === 'string' ? new TextEncoder().encode(monstatsBytes) : monstatsBytes
const text = typeof monstatsBytes === 'string' ? monstatsBytes : new TextDecoder().decode(monstatsBytes)
const actTable = parseActTable(bytes)
if (actTable.header.includes('NameStr') || actTable.header.includes('minHP')) {
const hasCombatCols = actTable.header.includes('minHP') && actTable.header.includes('Velocity')
const kinds = hasCombatCols ? readMonsterKinds(actTable) : MONSTER_TABLE
const rng = new Rng(NET_SEED)
const result: MonsterStats[] = []
for (const kind of kinds.values()) {
if (kind.enabled && kind.killable && !kind.inTown && kind.maxHp > 0) {
result.push(monsterStatsOf(kind, rng, WALK_SPEED))
}
}
return result.length > 0 ? result : DEMO_MONSTERS
}
return monsterStatsFromTable(parseTable(text))
}
/** Monsters spawned between the two players. */ /** Monsters spawned between the two players. */
const MONSTER_COUNT = 10 const MONSTER_COUNT = 10
/** Ticks of input delay: the latency budget a peer may spend without stalling. */ /** Ticks of input delay: the latency budget a peer may spend without stalling. */
@ -356,7 +382,7 @@ function startScene(
} }
const stats = sources.monstats === null const stats = sources.monstats === null
? DEMO_MONSTERS ? DEMO_MONSTERS
: monsterStatsFromTable(parseTable(new TextDecoder().decode(sources.monstats))) : parseNetMonsterStats(sources.monstats)
state.monstersSpawned = spawnMonsters(world, stats, MONSTER_COUNT, spawn, 240, terrain, NET_SEED) state.monstersSpawned = spawnMonsters(world, stats, MONSTER_COUNT, spawn, 240, terrain, NET_SEED)
state.localPeer = peer state.localPeer = peer
state.peers = peers state.peers = peers

123
tests/p0-524-parity.test.ts Normal file
View File

@ -0,0 +1,123 @@
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()
})
})