diff --git a/src/common/units/monsters-data.ts b/src/common/units/monsters-data.ts index 064b235..93bbbde 100644 --- a/src/common/units/monsters-data.ts +++ b/src/common/units/monsters-data.ts @@ -2069,6 +2069,7 @@ function getCanonicalMonsterKinds(): Map { const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS))) const enriched = new Map() for (const [id, kind] of rawKinds) { + if (id.toLowerCase() === 'expansion') continue const ov = CANONICAL_113C_MONSTER_OVERRIDES[id] const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()] const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined @@ -2135,7 +2136,7 @@ export function getMonsterKindByClassId(classId: number): MonsterKind | undefine return getCanonicalMonsterKinds().get(id) } -const cachedMonsterKindsByHcIdx = new Map() +const cachedMonsterKindsByHcIdx = new Map() let cachedSuperUniquesByIndex: readonly SuperUnique[] | null = null function normalizeDifficulty(difficulty: Difficulty | number = 'normal'): Difficulty { @@ -2154,18 +2155,24 @@ export function getMonsterKindByHcIdx( difficulty: Difficulty | number = 'normal', ): MonsterKind | undefined { if (!Number.isInteger(hcIdx) || hcIdx < 0) return undefined + const targetId = MONSTER_HCIDX_TO_ID[hcIdx] + if (!targetId) return undefined const diff = normalizeDifficulty(difficulty) let list = cachedMonsterKindsByHcIdx.get(diff) if (!list) { const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), diff) - const arr: MonsterKind[] = [] + const byId = new Map() for (const [id, kind] of rawKinds) { + if (id.toLowerCase() === 'expansion') continue 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 = { ...kind, enabled: kind.killable || !kind.inTown, - velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5), - runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), + velocity: ov?.velocity ?? canonicalVel ?? (kind.velocity > 0 ? kind.velocity : 5), + runVelocity: ov?.runVelocity ?? canonicalRun ?? (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), @@ -2181,7 +2188,12 @@ export function getMonsterKindByHcIdx( 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 cachedMonsterKindsByHcIdx.set(diff, list) diff --git a/tests/monster-target-bar.test.ts b/tests/monster-target-bar.test.ts index 7f5329a..7cf9afe 100644 --- a/tests/monster-target-bar.test.ts +++ b/tests/monster-target-bar.test.ts @@ -916,7 +916,7 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () = expect(andarielInfo.isDemon).toBe(true) 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({ unitType: 1, id: 106, @@ -927,9 +927,45 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () = bossMods: [8, 9, 17, 18], }) 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.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) const randUniqueUnit = new ClientUnit({ 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.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()) + } + } + }) })