fix(monsters): 修正 SuperUniques.txt 随从类名转录笔误与打包白名单脱节 (Issue #115)

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 14:58:59 +00:00
parent 370dcc05b1
commit 111a631dc7
6 changed files with 238 additions and 40 deletions

View File

@ -15,7 +15,12 @@ import { MountedArchives } from '../src/mpq/mount.ts'
import { decodePl2 } from '../src/formats/pl2.ts' import { decodePl2 } from '../src/formats/pl2.ts'
import { loadCharacterSheet } from '../src/game/character.ts' import { loadCharacterSheet } from '../src/game/character.ts'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts' import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import { ACT_MONSTER_SPECS } from '../src/game/monster-mapping.ts' import { ACT_MONSTER_SPECS, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts'
import {
CANONICAL_SUPER_UNIQUE_LANDMARKS,
CANONICAL_SUPERUNIQUES_TABLE,
readSuperUniques,
} from '../src/game/monsters.ts'
import type { SpriteSheet } from '../src/formats/sprite.ts' import type { SpriteSheet } from '../src/formats/sprite.ts'
import { encodeIndexedPng } from './png.ts' import { encodeIndexedPng } from './png.ts'
@ -165,6 +170,34 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (let act = 1; act <= 5; act += 1) { for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? [] const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) { for (const spec of specs) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
}
}
}
// Include SuperUnique landmark bosses and minions
const superUniques = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)
const landmarkMinionIds = new Set<string>()
for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
if (landmark.minionMonsterId) {
landmarkMinionIds.add(landmark.minionMonsterId)
}
}
const allSuperUniqueMonsterIds = new Set<string>()
for (const su of superUniques) {
allSuperUniqueMonsterIds.add(su.monsterId)
}
for (const minionId of landmarkMinionIds) {
allSuperUniqueMonsterIds.add(minionId)
}
for (const monsterId of allSuperUniqueMonsterIds) {
const spec = resolveMonsterArtSpec(monsterId)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) { if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec) uniqueSpecs.set(key, spec)
@ -193,6 +226,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
cursor += 1 cursor += 1
if (idx >= entries.length) return if (idx >= entries.length) return
const [key, spec] = entries[idx]! const [key, spec] = entries[idx]!
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
try { try {
const [walk, stand] = await Promise.all([ const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon), compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),

View File

@ -1252,26 +1252,26 @@ export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMappi
{ id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' }, { id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' },
// Act 2 // Act 2
{ id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' }, { id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' },
{ id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'beetle2' }, { id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'scarab2' },
{ id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' }, { id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'zombie5' },
{ id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'huntress2' }, { id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'pantherwoman1' },
{ id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' }, { id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' },
// Act 3 // Act 3
{ id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'spider2' }, { id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'arach4' },
{ id: 'Stormtree', levelIds: [78], landmark: 'Act 3 - Flayer Jungle Entrance', minionMonsterId: 'thornhulk3' }, { id: 'Stormtree', levelIds: [78], landmark: 'Act 3 - Flayer Jungle Entrance', minionMonsterId: 'thornhulk3' },
{ id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'flayer4' }, { id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'fetish4' },
{ id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue8' }, { id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue5' },
// Act 4 // Act 4
{ id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' }, { id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' },
{ id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' }, { id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' },
// Act 5 // Act 5
{ id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'demonimp1' }, { id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'imp3' },
{ id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'enslaved1' }, { id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'minion1' },
{ id: 'Eyeback the Unleashed', levelIds: [111], landmark: 'Act 5 - Barricade Hell Portal N', minionMonsterId: 'bloodlord2' }, { id: 'Eyeback the Unleashed', levelIds: [111], landmark: 'Act 5 - Barricade Hell Portal N', minionMonsterId: 'bloodlord2' },
{ id: 'Thresh Socket', levelIds: [112], landmark: 'Act 5 - Barricade Snow Unique', minionMonsterId: 'siegebeast1' }, { id: 'Thresh Socket', levelIds: [112], landmark: 'Act 5 - Barricade Snow Unique', minionMonsterId: 'siegebeast1' },
{ id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'yeti2' }, { id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'snowyeti4' },
{ id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' }, { id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' },
{ id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'iceprowler2' }, { id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'minion6' },
] ]
/** Canonical SuperUniques TSV extracted from game data for landmark bosses. */ /** Canonical SuperUniques TSV extracted from game data for landmark bosses. */
@ -1282,21 +1282,21 @@ Treehead WoodFist\tTreehead WoodFist\tbrute1\t5\t6\t0\t3\t4\t0\t0\t0\t0\tAct 1 S
The Countess\tThe Countess\tcorruptrogue5\t9\t0\t0\t4\t6\t0\t0\t0\t0\tCountess\tCountess (N)\tCountess (H) The Countess\tThe Countess\tcorruptrogue5\t9\t0\t0\t4\t6\t0\t0\t0\t0\tCountess\tCountess (N)\tCountess (H)
Blood Raven\tBlood Raven\tcorruptrogue1\t9\t6\t0\t6\t8\t0\t0\t0\t0\tBlood Raven\tBlood Raven (N)\tBlood Raven (H) Blood Raven\tBlood Raven\tcorruptrogue1\t9\t6\t0\t6\t8\t0\t0\t0\t0\tBlood Raven\tBlood Raven (N)\tBlood Raven (H)
Creeping Feature\tCreeping Feature\tmummy1\t5\t18\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A Creeping Feature\tCreeping Feature\tmummy1\t5\t18\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Beetleburst\tBeetleburst\tbeetle2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A Beetleburst\tBeetleburst\tscarab2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Dark Elder\tDark Elder\tplaguebearer1\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B Dark Elder\tDark Elder\tzombie5\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Bloodwitch the Wild\tBloodwitch the Wild\thuntress2\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B Bloodwitch the Wild\tBloodwitch the Wild\tpantherwoman1\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Fangskin\tFangskin\tclawviper3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super C\tAct 2 (N) Super C\tAct 2 (H) Super C Fangskin\tFangskin\tclawviper3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super C\tAct 2 (N) Super C\tAct 2 (H) Super C
Sszark the Burning\tSszark the Burning\tspider2\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A Sszark the Burning\tSszark the Burning\tarach4\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A
Stormtree\tStormtree\tthornhulk3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B Stormtree\tStormtree\tthornhulk3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Witch Doctor Endugu\tWitch Doctor Endugu\tflayershaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B Witch Doctor Endugu\tWitch Doctor Endugu\tfetishshaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue8\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue5\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C
Izual\tIzual\tizual\t18\t0\t0\t0\t0\t0\t0\t0\t0\tIzual\tIzual (N)\tIzual (H) Izual\tIzual\tizual\t18\t0\t0\t0\t0\t0\t0\t0\t0\tIzual\tIzual (N)\tIzual (H)
Hephasto the Armorer\tHephasto the Armorer\thephasto\t27\t30\t0\t0\t0\t0\t0\t0\t0\tHephasto\tHephasto (N)\tHephasto (H) Hephasto the Armorer\tHephasto the Armorer\thephasto\t27\t30\t0\t0\t0\t0\t0\t0\t0\tHephasto\tHephasto (N)\tHephasto (H)
Dac Farren\tDac Farren\tdemonimp1\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A Dac Farren\tDac Farren\timp3\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Shenk the Overseer\tShenk the Overseer\toverseer2\t5\t0\t0\t15\t20\t0\t0\t0\t0\tShenk\tShenk (N)\tShenk (H) Shenk the Overseer\tShenk the Overseer\toverseer2\t5\t0\t0\t15\t20\t0\t0\t0\t0\tShenk\tShenk (N)\tShenk (H)
Eyeback the Unleashed\tEyeback the Unleashed\tbloodlord2\t6\t5\t0\t3\t5\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A Eyeback the Unleashed\tEyeback the Unleashed\tbloodlord2\t6\t5\t0\t3\t5\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Thresh Socket\tThresh Socket\tsiegebeast1\t7\t0\t0\t2\t4\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B Thresh Socket\tThresh Socket\tsiegebeast1\t7\t0\t0\t2\t4\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Frozenstein\tFrozenstein\tyeti2\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B Frozenstein\tFrozenstein\tsnowyeti4\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Bonesaw Breaker\tBonesaw Breaker\treanimatedhorde2\t5\t8\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super C\tAct 5 (N) Super C\tAct 5 (H) Super C Bonesaw Breaker\tBonesaw Breaker\treanimatedhorde2\t5\t8\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super C\tAct 5 (N) Super C\tAct 5 (H) Super C
Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)` Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)`

View File

@ -6625,12 +6625,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 2, act: 2,
levelIds: [45, 58, 60], levelIds: [45, 58, 60],
monsterId: 'huntress2', monsterId: 'pantherwoman1',
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i, presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
defaultLandmark: 'Act 2 - Desert Tomb 2', defaultLandmark: 'Act 2 - Desert Tomb 2',
modifiers: ['cursed', 'strong'], modifiers: ['cursed', 'strong'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'huntress2', minionMonsterId: 'pantherwoman1',
objectId: 76, objectId: 76,
defaultRelativePos: { x: 0.48, y: 0.52 }, defaultRelativePos: { x: 0.48, y: 0.52 },
}, },
@ -6640,12 +6640,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 2, act: 2,
levelIds: [43], levelIds: [43],
monsterId: 'beetle2', monsterId: 'scarab2',
presetMatch: /Desert Oasis|Desert Lair/i, presetMatch: /Desert Oasis|Desert Lair/i,
defaultLandmark: 'Act 2 - Desert Oasis 1', defaultLandmark: 'Act 2 - Desert Oasis 1',
modifiers: ['magicresistant'], modifiers: ['magicresistant'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'beetle2', minionMonsterId: 'scarab2',
objectId: 77, objectId: 77,
defaultRelativePos: { x: 0.52, y: 0.48 }, defaultRelativePos: { x: 0.52, y: 0.48 },
}, },
@ -6655,12 +6655,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 2, act: 2,
levelIds: [44], levelIds: [44],
monsterId: 'plaguebearer1', monsterId: 'zombie5',
presetMatch: /Desert Ruins Elder/i, presetMatch: /Desert Ruins Elder/i,
defaultLandmark: 'Act 2 - Desert Ruins Elder', defaultLandmark: 'Act 2 - Desert Ruins Elder',
modifiers: ['fast', 'magicresistant'], modifiers: ['fast', 'magicresistant'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'plaguebearer1', minionMonsterId: 'zombie5',
objectId: 78, objectId: 78,
defaultRelativePos: { x: 0.5, y: 0.5 }, defaultRelativePos: { x: 0.5, y: 0.5 },
}, },
@ -6701,12 +6701,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 3, act: 3,
levelIds: [76, 84], levelIds: [76, 84],
monsterId: 'spider2', monsterId: 'arach4',
presetMatch: /Spider Cavern|Spider/i, presetMatch: /Spider Cavern|Spider/i,
defaultLandmark: 'Act 3 - Spider Cavern', defaultLandmark: 'Act 3 - Spider Cavern',
modifiers: ['strong', 'cursed'], modifiers: ['strong', 'cursed'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'spider2', minionMonsterId: 'arach4',
objectId: 81, objectId: 81,
defaultRelativePos: { x: 0.48, y: 0.52 }, defaultRelativePos: { x: 0.48, y: 0.52 },
}, },
@ -6716,12 +6716,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 3, act: 3,
levelIds: [78, 88, 91], levelIds: [78, 88, 91],
monsterId: 'flayershaman4', monsterId: 'fetishshaman4',
presetMatch: /Flayer Dungeon/i, presetMatch: /Flayer Dungeon/i,
defaultLandmark: 'Act 3 - Flayer Dungeon', defaultLandmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'], modifiers: ['magicresistant', 'fireenchant'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'flayer4', minionMonsterId: 'fetish4',
objectId: 82, objectId: 82,
defaultRelativePos: { x: 0.52, y: 0.48 }, defaultRelativePos: { x: 0.52, y: 0.48 },
}, },
@ -6731,12 +6731,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 3, act: 3,
levelIds: [80, 94], levelIds: [80, 94],
monsterId: 'corruptrogue8', monsterId: 'corruptrogue5',
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i, presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
defaultLandmark: 'Act 3 - Burbs Temple', defaultLandmark: 'Act 3 - Burbs Temple',
modifiers: ['fast', 'spectralhit'], modifiers: ['fast', 'spectralhit'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'corruptrogue8', minionMonsterId: 'corruptrogue5',
objectId: 83, objectId: 83,
defaultRelativePos: { x: 0.5, y: 0.5 }, defaultRelativePos: { x: 0.5, y: 0.5 },
}, },
@ -6793,12 +6793,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 5, act: 5,
levelIds: [110], levelIds: [110],
monsterId: 'demonimp1', monsterId: 'imp3',
presetMatch: /Barricade Prison 1|Barricade Ruins/i, presetMatch: /Barricade Prison 1|Barricade Ruins/i,
defaultLandmark: 'Act 5 - Barricade Prison 1', defaultLandmark: 'Act 5 - Barricade Prison 1',
modifiers: ['coldenchant'], modifiers: ['coldenchant'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'demonimp1', minionMonsterId: 'imp3',
objectId: 87, objectId: 87,
defaultRelativePos: { x: 0.35, y: 0.5 }, defaultRelativePos: { x: 0.35, y: 0.5 },
}, },
@ -6813,7 +6813,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
defaultLandmark: 'Act 5 - Barricade Building', defaultLandmark: 'Act 5 - Barricade Building',
modifiers: ['strong'], modifiers: ['strong'],
minionCount: [15, 20], minionCount: [15, 20],
minionMonsterId: 'enslaved1', minionMonsterId: 'minion1',
objectId: 88, objectId: 88,
defaultRelativePos: { x: 0.75, y: 0.5 }, defaultRelativePos: { x: 0.75, y: 0.5 },
}, },
@ -6868,12 +6868,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique', role: 'superunique',
act: 5, act: 5,
levelIds: [114], levelIds: [114],
monsterId: 'yeti2', monsterId: 'snowyeti4',
presetMatch: /Frozen River|Anya/i, presetMatch: /Frozen River|Anya/i,
defaultLandmark: 'Act 5 - Frozen River', defaultLandmark: 'Act 5 - Frozen River',
modifiers: ['coldenchant', 'manahit'], modifiers: ['coldenchant', 'manahit'],
minionCount: [4, 6], minionCount: [4, 6],
minionMonsterId: 'yeti2', minionMonsterId: 'snowyeti4',
objectId: 92, objectId: 92,
defaultRelativePos: { x: 0.5, y: 0.5 }, defaultRelativePos: { x: 0.5, y: 0.5 },
}, },
@ -6888,7 +6888,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
defaultLandmark: 'Act 5 - Halls of Vaught', defaultLandmark: 'Act 5 - Halls of Vaught',
modifiers: ['coldenchant'], modifiers: ['coldenchant'],
minionCount: [6, 8], minionCount: [6, 8],
minionMonsterId: 'iceprowler2', minionMonsterId: 'minion6',
objectId: 93, objectId: 93,
defaultRelativePos: { x: 0.5, y: 0.5 }, defaultRelativePos: { x: 0.5, y: 0.5 },
}, },

View File

@ -70,10 +70,10 @@ describe('no synthetic monster fallback', () => {
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, 'normal') const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, 'normal')
// overseer2 is Shenk's boss id, and enslaved1 is his minion id, neither in m1..m3 (siegebeast1, demonimp1, enflayer1) // overseer2 is Shenk's boss id, and minion1 is his minion id, neither in m1..m3 (siegebeast1, demonimp1, enflayer1)
expect(plan.types).toContain('overseer2') expect(plan.types).toContain('overseer2')
expect(plan.types).toContain('enslaved1') expect(plan.types).toContain('minion1')
expect(plan.types).toContain('demonimp1') expect(plan.types).toContain('imp3')
}) })
}) })

View File

@ -0,0 +1,163 @@
import { describe, expect, test } from 'vitest'
import {
readSuperUniques,
CANONICAL_SUPERUNIQUES_TABLE,
CANONICAL_SUPER_UNIQUE_LANDMARKS,
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
} from '../src/game/monsters.ts'
import {
MONSTER_ART_MAP,
resolveMonsterArtSpec,
} from '../src/game/monster-mapping.ts'
import { CANONICAL_LANDMARK_ENTITIES } from '../src/game/wilderness.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { parseTable } from '../src/game/acts.ts'
describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => {
const d2DataDir = '/usr/local/google/home/taodao/d2-data'
test('CANONICAL_SUPER_UNIQUE_LANDMARKS contains valid and corrected minion IDs', () => {
// 10 previously mistyped minions must match correct IDs
const landmarkMap = new Map(CANONICAL_SUPER_UNIQUE_LANDMARKS.map(l => [l.id, l]))
expect(landmarkMap.get('Beetleburst')?.minionMonsterId).toBe('scarab2')
expect(landmarkMap.get('Dark Elder')?.minionMonsterId).toBe('zombie5')
expect(landmarkMap.get('Bloodwitch the Wild')?.minionMonsterId).toBe('pantherwoman1')
expect(landmarkMap.get('Sszark the Burning')?.minionMonsterId).toBe('arach4')
expect(landmarkMap.get('Witch Doctor Endugu')?.minionMonsterId).toBe('fetish4')
expect(landmarkMap.get('Battlemaid Sarina')?.minionMonsterId).toBe('corruptrogue5')
expect(landmarkMap.get('Dac Farren')?.minionMonsterId).toBe('imp3')
expect(landmarkMap.get('Shenk the Overseer')?.minionMonsterId).toBe('minion1')
expect(landmarkMap.get('Frozenstein')?.minionMonsterId).toBe('snowyeti4')
expect(landmarkMap.get('Nihlathak')?.minionMonsterId).toBe('minion6')
})
test('CANONICAL_LANDMARK_ENTITIES contains aligned boss and minion IDs', () => {
const entityMap = new Map(CANONICAL_LANDMARK_ENTITIES.map(e => [e.id, e]))
expect(entityMap.get('Beetleburst')?.monsterId).toBe('scarab2')
expect(entityMap.get('Beetleburst')?.minionMonsterId).toBe('scarab2')
expect(entityMap.get('Dark Elder')?.monsterId).toBe('zombie5')
expect(entityMap.get('Dark Elder')?.minionMonsterId).toBe('zombie5')
expect(entityMap.get('Bloodwitch the Wild')?.monsterId).toBe('pantherwoman1')
expect(entityMap.get('Bloodwitch the Wild')?.minionMonsterId).toBe('pantherwoman1')
expect(entityMap.get('Sszark the Burning')?.monsterId).toBe('arach4')
expect(entityMap.get('Sszark the Burning')?.minionMonsterId).toBe('arach4')
expect(entityMap.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
expect(entityMap.get('Witch Doctor Endugu')?.minionMonsterId).toBe('fetish4')
expect(entityMap.get('Battlemaid Sarina')?.monsterId).toBe('corruptrogue5')
expect(entityMap.get('Battlemaid Sarina')?.minionMonsterId).toBe('corruptrogue5')
expect(entityMap.get('Dac Farren')?.monsterId).toBe('imp3')
expect(entityMap.get('Dac Farren')?.minionMonsterId).toBe('imp3')
expect(entityMap.get('Shenk the Overseer')?.minionMonsterId).toBe('minion1')
expect(entityMap.get('Frozenstein')?.monsterId).toBe('snowyeti4')
expect(entityMap.get('Frozenstein')?.minionMonsterId).toBe('snowyeti4')
expect(entityMap.get('Nihlathak')?.minionMonsterId).toBe('minion6')
})
test('CANONICAL_SUPERUNIQUES_TABLE parses with corrected Class entries', () => {
const parsed = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)
const map = new Map(parsed.map(p => [p.id, p]))
expect(map.get('Beetleburst')?.monsterId).toBe('scarab2')
expect(map.get('Dark Elder')?.monsterId).toBe('zombie5')
expect(map.get('Bloodwitch the Wild')?.monsterId).toBe('pantherwoman1')
expect(map.get('Sszark the Burning')?.monsterId).toBe('arach4')
expect(map.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
expect(map.get('Battlemaid Sarina')?.monsterId).toBe('corruptrogue5')
expect(map.get('Dac Farren')?.monsterId).toBe('imp3')
expect(map.get('Frozenstein')?.monsterId).toBe('snowyeti4')
})
test('MONSTER_ART_MAP correctly maps critical SuperUnique boss tokens for each Act', () => {
// Act 1: Griswold (GZ), The Smith (5P)
expect(MONSTER_ART_MAP.griswold?.token).toBe('GZ')
expect(MONSTER_ART_MAP.smith?.token).toBe('5P')
// Act 2: Radament (RD), Coldworm (MQ), The Summoner (SU)
expect(MONSTER_ART_MAP.radament?.token).toBe('RD')
expect(MONSTER_ART_MAP.maggotqueen1?.token).toBe('MQ')
expect(MONSTER_ART_MAP.summoner?.token).toBe('SU')
// Act 3: High Council members (HP)
expect(MONSTER_ART_MAP.councilmember1?.token).toBe('HP')
expect(MONSTER_ART_MAP.councilmember2?.token).toBe('HP')
expect(MONSTER_ART_MAP.councilmember3?.token).toBe('HP')
// Act 4: Hephasto the Armorer (5P)
expect(MONSTER_ART_MAP.hephasto?.token).toBe('5P')
// Act 5: Nihlathak (XU), Baal Subject 5 (43)
expect(MONSTER_ART_MAP.nihlathakboss?.token).toBe('XU')
expect(MONSTER_ART_MAP.baalminion1?.token).toBe('43')
})
test('All 66 SuperUniques in game data verify boss class and minion resolution', async () => {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
const archive = await MpqArchive.open(await fileSource(`${d2DataDir}/${name}`))
archives.add(name, archive)
} catch {
// If data dir unavailable in test runner, skip archive loading portion
return
}
}
const suData = await archives.read('data\\global\\excel\\SuperUniques.txt')
const suTable = parseTable(suData)
const superUniques = readSuperUniques(suTable)
expect(superUniques.length).toBe(66)
for (const su of superUniques) {
expect(su.id).toBeTruthy()
expect(su.monsterId).toBeTruthy()
// Boss class must resolve to an art spec or return null for dummy 'xx' codes gracefully
const bossArt = resolveMonsterArtSpec(su.monsterId)
if (su.monsterId === 'minion1') {
// minion1 has Code="xx" in MonStats.txt and resolveMonsterArtSpec returns null
expect(bossArt).toBeNull()
} else {
expect(bossArt).not.toBeNull()
expect(bossArt?.token).toBeTruthy()
expect(bossArt?.token.toLowerCase()).not.toBe('xx')
}
}
})
test('Dummy code xx monsters resolve to null and do not throw in resolveMonsterArtSpec', () => {
// minion1..minion11 have Code="xx" in MonStats
for (let i = 1; i <= 11; i += 1) {
const art = resolveMonsterArtSpec(`minion${i}`)
expect(art).toBeNull()
}
// Unknown or dummy
expect(resolveMonsterArtSpec('xx')).toBeNull()
expect(resolveMonsterArtSpec('')).toBeNull()
})
test('CANONICAL_SUPER_UNIQUES_BY_LEVEL builds without undefined errors', () => {
expect(Object.keys(CANONICAL_SUPER_UNIQUES_BY_LEVEL).length).toBeGreaterThan(0)
for (const [lvl, specOrSpecs] of Object.entries(CANONICAL_SUPER_UNIQUES_BY_LEVEL)) {
const specs = Array.isArray(specOrSpecs) ? specOrSpecs : [specOrSpecs]
for (const s of specs) {
expect(s.id).toBeTruthy()
expect(s.monsterId).toBeTruthy()
expect(s.minionMonsterId).toBeTruthy()
}
}
})
})

View File

@ -371,6 +371,7 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
].join("\n")) ].join("\n"))
const monstatsTxt = mockTable([ const monstatsTxt = mockTable([
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"imp1\timp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1", "demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1", "overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1", "siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",