fix(units): align getMonsterKindByHcIdx with MONSTER_HCIDX_TO_ID for hcIdx 0..733 (Closes #713)

This commit is contained in:
troytt 2026-10-03 06:01:43 +00:00
parent 948a586432
commit 25fdf81aa2
2 changed files with 140 additions and 6 deletions

View File

@ -2069,6 +2069,7 @@ function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS))) const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)))
const enriched = new Map<string, MonsterKind>() const enriched = new Map<string, MonsterKind>()
for (const [id, kind] of rawKinds) { for (const [id, kind] of rawKinds) {
if (id.toLowerCase() === 'expansion') continue
const ov = CANONICAL_113C_MONSTER_OVERRIDES[id] const ov = CANONICAL_113C_MONSTER_OVERRIDES[id]
const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()] const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()]
const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined
@ -2135,7 +2136,7 @@ export function getMonsterKindByClassId(classId: number): MonsterKind | undefine
return getCanonicalMonsterKinds().get(id) return getCanonicalMonsterKinds().get(id)
} }
const cachedMonsterKindsByHcIdx = new Map<Difficulty, readonly MonsterKind[]>() const cachedMonsterKindsByHcIdx = new Map<Difficulty, readonly (MonsterKind | undefined)[]>()
let cachedSuperUniquesByIndex: readonly SuperUnique[] | null = null let cachedSuperUniquesByIndex: readonly SuperUnique[] | null = null
function normalizeDifficulty(difficulty: Difficulty | number = 'normal'): Difficulty { function normalizeDifficulty(difficulty: Difficulty | number = 'normal'): Difficulty {
@ -2154,18 +2155,24 @@ export function getMonsterKindByHcIdx(
difficulty: Difficulty | number = 'normal', difficulty: Difficulty | number = 'normal',
): MonsterKind | undefined { ): MonsterKind | undefined {
if (!Number.isInteger(hcIdx) || hcIdx < 0) return undefined if (!Number.isInteger(hcIdx) || hcIdx < 0) return undefined
const targetId = MONSTER_HCIDX_TO_ID[hcIdx]
if (!targetId) return undefined
const diff = normalizeDifficulty(difficulty) const diff = normalizeDifficulty(difficulty)
let list = cachedMonsterKindsByHcIdx.get(diff) let list = cachedMonsterKindsByHcIdx.get(diff)
if (!list) { if (!list) {
const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), diff) const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), diff)
const arr: MonsterKind[] = [] const byId = new Map<string, MonsterKind>()
for (const [id, kind] of rawKinds) { for (const [id, kind] of rawKinds) {
if (id.toLowerCase() === 'expansion') continue
const ov = diff === 'normal' ? CANONICAL_113C_MONSTER_OVERRIDES[id] : undefined const ov = diff === 'normal' ? CANONICAL_113C_MONSTER_OVERRIDES[id] : undefined
const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()]
const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined
const canonicalRun = canonicalSpeed && canonicalSpeed[1] > 0 ? canonicalSpeed[1] : undefined
const enrichedKind: MonsterKind = { const enrichedKind: MonsterKind = {
...kind, ...kind,
enabled: kind.killable || !kind.inTown, enabled: kind.killable || !kind.inTown,
velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5), velocity: ov?.velocity ?? canonicalVel ?? (kind.velocity > 0 ? kind.velocity : 5),
runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), runVelocity: ov?.runVelocity ?? canonicalRun ?? (kind.runVelocity > 0 ? kind.runVelocity : 5),
minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21), minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21),
maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61), maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61),
armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84), armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84),
@ -2181,7 +2188,12 @@ export function getMonsterKindByHcIdx(
writable: true, writable: true,
}) })
} }
arr.push(enrichedKind) byId.set(id, enrichedKind)
}
const arr: (MonsterKind | undefined)[] = []
for (const [idxStr, mappedId] of Object.entries(MONSTER_HCIDX_TO_ID)) {
const idx = Number(idxStr)
arr[idx] = byId.get(mappedId) ?? byId.get(mappedId.toLowerCase())
} }
list = arr list = arr
cachedMonsterKindsByHcIdx.set(diff, list) cachedMonsterKindsByHcIdx.set(diff, list)

View File

@ -916,7 +916,7 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
expect(andarielInfo.isDemon).toBe(true) expect(andarielInfo.isDemon).toBe(true)
expect(resolveMonsterTitleAndColor(andarielInfo, 'en').titleColor).toBe('gold') expect(resolveMonsterTitleAndColor(andarielInfo, 'en').titleColor).toBe('gold')
// 6. Uber Diablo (hcIdx = 705 uberdiablo) suppresses modifiers and immunities (0x6facfedb & 0x6fb6fca0) // 6. Uber Diablo (hcIdx = 705 uberdiablo) and Uber Mephisto (hcIdx = 704 ubermephisto) suppress modifiers and immunities (0x6facfedb & 0x6fb6fca0)
const uberUnit = new ClientUnit({ const uberUnit = new ClientUnit({
unitType: 1, unitType: 1,
id: 106, id: 106,
@ -927,9 +927,45 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
bossMods: [8, 9, 17, 18], bossMods: [8, 9, 17, 18],
}) })
const uberInfo = clientUnitToMonsterTargetInfo(uberUnit, 2) const uberInfo = clientUnitToMonsterTargetInfo(uberUnit, 2)
expect(uberInfo.name).toBe('Diablo')
expect(uberInfo.nameKey).toBe('Diablo')
expect(uberInfo.isBoss).toBe(true)
expect(uberInfo.isDemon).toBe(true)
expect(uberInfo.modifiers).toEqual([]) expect(uberInfo.modifiers).toEqual([])
expect(uberInfo.resistances).toBeUndefined() expect(uberInfo.resistances).toBeUndefined()
const uberMephUnit = new ClientUnit({
unitType: 1,
id: 108,
classId: 704,
x: 5055,
y: 5055,
lifePct: 128,
bossMods: [8, 9],
})
const uberMephInfo = clientUnitToMonsterTargetInfo(uberMephUnit, 2)
expect(uberMephInfo.name).toBe('Mephisto')
expect(uberMephInfo.nameKey).toBe('Mephisto')
expect(uberMephInfo.isBoss).toBe(true)
expect(uberMephInfo.isDemon).toBe(true)
expect(uberMephInfo.modifiers).toEqual([])
expect(uberMephInfo.resistances).toBeUndefined()
// Baal (hcIdx = 544 baalcrab) resolves NameStr 'Baal Crab' -> "Baal", not baalthrone
const baalUnit = new ClientUnit({
unitType: 1,
id: 109,
classId: 544,
x: 5058,
y: 5058,
lifePct: 128,
})
const baalInfo = clientUnitToMonsterTargetInfo(baalUnit, 2)
expect(baalInfo.nameKey).toBe('Baal Crab')
expect(baalInfo.name).toBe('Baal')
expect(baalInfo.isBoss).toBe(true)
expect(baalInfo.isDemon).toBe(true)
// 7. Random Unique Fallen (hcIdx = 19, flags = 0x08, nameSeed = 0x1234 -> wNameSeed = 0x34) // 7. Random Unique Fallen (hcIdx = 19, flags = 0x08, nameSeed = 0x1234 -> wNameSeed = 0x34)
const randUniqueUnit = new ClientUnit({ const randUniqueUnit = new ClientUnit({
unitType: 1, unitType: 1,
@ -954,6 +990,92 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
expect(zhTitle.titleColor).toBe('gold') expect(zhTitle.titleColor).toBe('gold')
expect(zhTitle.displayName).not.toBe('沉淪魔') expect(zhTitle.displayName).not.toBe('沉淪魔')
}) })
it('Issue #713: getMonsterKindByHcIdx aligns 0..733 with MONSTER_HCIDX_TO_ID and 1.13c MonStats.txt with 0 discrepancies across all difficulties', async () => {
const fs = await import('node:fs')
const { cell } = await import('../src/common/world/acts.ts')
const { MONSTER_HCIDX_TO_ID } = await import('../src/common/units/monster-mapping.ts')
const { getMonsterKindByHcIdx, getMonsterKindByClassId, MONSTER_TABLE } = await import(
'../src/common/units/monsters-data.ts'
)
// 1. Canonical boundary checks for Expansion shift & duplicate cr_lancer8
expect(getMonsterKindByHcIdx(409, 'normal')?.id).toBe('hephasto')
expect(getMonsterKindByHcIdx(410, 'normal')?.id).toBe('wakeofdestruction')
expect(getMonsterKindByHcIdx(543, 'normal')?.id).toBe('baalthrone')
expect(getMonsterKindByHcIdx(544, 'normal')?.id).toBe('baalcrab')
expect(getMonsterKindByHcIdx(617, 'normal')?.id).toBe('cr_lancer8')
expect(getMonsterKindByHcIdx(703, 'normal')?.id).toBe('mummy6')
expect(getMonsterKindByHcIdx(704, 'normal')?.id).toBe('ubermephisto')
expect(getMonsterKindByHcIdx(705, 'normal')?.id).toBe('uberdiablo')
expect(getMonsterKindByHcIdx(723, 'normal')?.id).toBe('cr_lancer8')
expect(getMonsterKindByHcIdx(733, 'normal')?.id).toBe('willowisp8')
// Out-of-range and non-integer inputs return undefined; Expansion separator never leaks
expect(getMonsterKindByHcIdx(-1, 'normal')).toBeUndefined()
expect(getMonsterKindByHcIdx(734, 'normal')).toBeUndefined()
expect(getMonsterKindByHcIdx(1.5, 'normal')).toBeUndefined()
expect(getMonsterKindByHcIdx(Number.NaN, 'normal')).toBeUndefined()
expect(MONSTER_TABLE.get('Expansion')).toBeUndefined()
// 2. Full 0..733 spectrum parity across all difficulties ('normal'|'nightmare'|'hell' and 0|1|2)
const rawTable = parseTable(new TextEncoder().encode(RAW_MONSTATS))
const nonExpansionRows = rawTable.rows.filter(r => {
const id = cell(rawTable, r, 'Id').trim()
return id !== '' && id.toLowerCase() !== 'expansion'
})
expect(nonExpansionRows).toHaveLength(734)
for (let hcIdx = 0; hcIdx <= 733; hcIdx += 1) {
const expectedId = MONSTER_HCIDX_TO_ID[hcIdx]
expect(expectedId, `MONSTER_HCIDX_TO_ID[${hcIdx}] must be defined`).toBeDefined()
const row = nonExpansionRows[hcIdx]!
expect(cell(rawTable, row, 'Id').trim()).toBe(expectedId)
const byClassId = getMonsterKindByClassId(hcIdx)
expect(byClassId?.id).toBe(expectedId)
for (const [diffStr, diffNum] of [
['normal', 0],
['nightmare', 1],
['hell', 2],
] as const) {
const kindStr = getMonsterKindByHcIdx(hcIdx, diffStr)
const kindNum = getMonsterKindByHcIdx(hcIdx, diffNum)
expect(kindStr?.id, `hcIdx=${hcIdx} diff=${diffStr}`).toBe(expectedId)
expect(kindNum?.id, `hcIdx=${hcIdx} diff=${diffNum}`).toBe(expectedId)
expect(kindStr?.nameKey).toBe(cell(rawTable, row, 'NameStr').trim())
}
}
// 3. Direct row-by-row comparison against Patch_D2.mpq MonStats.txt when local MPQ is present
const mpqPath = fs.existsSync('samples/d2/Patch_D2.mpq')
? 'samples/d2/Patch_D2.mpq'
: fs.existsSync('/usr/local/google/home/taodao/d2-data/Patch_D2.mpq')
? '/usr/local/google/home/taodao/d2-data/Patch_D2.mpq'
: null
if (mpqPath) {
const { MpqArchive } = await import('../src/common/mpq/archive.ts')
const { fileSource } = await import('../src/baker/io/file-source.ts')
const archive = await MpqArchive.open(await fileSource(mpqPath))
const monstatsBytes = await archive.read(archive.find('data\\global\\excel\\MonStats.txt')!)
const mpqTable = parseTable(monstatsBytes)
const mpqRows = mpqTable.rows.filter(r => {
const id = cell(mpqTable, r, 'Id').trim()
return id !== '' && id.toLowerCase() !== 'expansion'
})
expect(mpqRows).toHaveLength(734)
for (let hcIdx = 0; hcIdx <= 733; hcIdx += 1) {
const mpqRow = mpqRows[hcIdx]!
expect(Number(cell(mpqTable, mpqRow, 'hcIdx').trim())).toBe(hcIdx)
const mpqId = cell(mpqTable, mpqRow, 'Id').trim()
const kind = getMonsterKindByHcIdx(hcIdx, 'hell')
expect(kind?.id).toBe(mpqId)
expect(kind?.nameKey).toBe(cell(mpqTable, mpqRow, 'NameStr').trim())
}
}
})
}) })