fix(units): align getMonsterKindByHcIdx with MONSTER_HCIDX_TO_ID for hcIdx 0..733 (Closes #713)
This commit is contained in:
parent
948a586432
commit
25fdf81aa2
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue