diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index 9610698..a68125d 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -15,7 +15,12 @@ import { MountedArchives } from '../src/mpq/mount.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { loadCharacterSheet } from '../src/game/character.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 { 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) { const specs = ACT_MONSTER_SPECS[act] ?? [] 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() + for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) { + if (landmark.minionMonsterId) { + landmarkMinionIds.add(landmark.minionMonsterId) + } + } + + const allSuperUniqueMonsterIds = new Set() + 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()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) @@ -193,6 +226,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ cursor += 1 if (idx >= entries.length) return const [key, spec] = entries[idx]! + if (!spec.token || spec.token.toLowerCase() === 'xx') continue try { const [walk, stand] = await Promise.all([ compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon), diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 4e28bdc..11b5c30 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -1252,26 +1252,26 @@ export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMappi { id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' }, // Act 2 { 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: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' }, - { id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'huntress2' }, + { id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'scarab2' }, + { 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: 'pantherwoman1' }, { id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' }, // 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: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'flayer4' }, - { id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue8' }, + { 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: 'corruptrogue5' }, // Act 4 { 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' }, // Act 5 - { id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'demonimp1' }, - { id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'enslaved1' }, + { 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: 'minion1' }, { 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: '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: '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. */ @@ -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) 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 -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 -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 -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 +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\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\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 -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 -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 -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 +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\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) 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) 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 -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 Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)` diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 0918280..54f18e5 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -6635,12 +6635,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 2, levelIds: [45, 58, 60], - monsterId: 'huntress2', + monsterId: 'pantherwoman1', presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i, defaultLandmark: 'Act 2 - Desert Tomb 2', modifiers: ['cursed', 'strong'], minionCount: [4, 6], - minionMonsterId: 'huntress2', + minionMonsterId: 'pantherwoman1', objectId: 76, defaultRelativePos: { x: 0.48, y: 0.52 }, }, @@ -6650,12 +6650,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 2, levelIds: [43], - monsterId: 'beetle2', + monsterId: 'scarab2', presetMatch: /Desert Oasis|Desert Lair/i, defaultLandmark: 'Act 2 - Desert Oasis 1', modifiers: ['magicresistant'], minionCount: [4, 6], - minionMonsterId: 'beetle2', + minionMonsterId: 'scarab2', objectId: 77, defaultRelativePos: { x: 0.52, y: 0.48 }, }, @@ -6665,12 +6665,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 2, levelIds: [44], - monsterId: 'plaguebearer1', + monsterId: 'zombie5', presetMatch: /Desert Ruins Elder/i, defaultLandmark: 'Act 2 - Desert Ruins Elder', modifiers: ['fast', 'magicresistant'], minionCount: [4, 6], - minionMonsterId: 'plaguebearer1', + minionMonsterId: 'zombie5', objectId: 78, defaultRelativePos: { x: 0.5, y: 0.5 }, }, @@ -6711,12 +6711,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 3, levelIds: [76, 84], - monsterId: 'spider2', + monsterId: 'arach4', presetMatch: /Spider Cavern|Spider/i, defaultLandmark: 'Act 3 - Spider Cavern', modifiers: ['strong', 'cursed'], minionCount: [4, 6], - minionMonsterId: 'spider2', + minionMonsterId: 'arach4', objectId: 81, defaultRelativePos: { x: 0.48, y: 0.52 }, }, @@ -6726,12 +6726,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 3, levelIds: [78, 88, 91], - monsterId: 'flayershaman4', + monsterId: 'fetishshaman4', presetMatch: /Flayer Dungeon/i, defaultLandmark: 'Act 3 - Flayer Dungeon', modifiers: ['magicresistant', 'fireenchant'], minionCount: [4, 6], - minionMonsterId: 'flayer4', + minionMonsterId: 'fetish4', objectId: 82, defaultRelativePos: { x: 0.52, y: 0.48 }, }, @@ -6741,12 +6741,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 3, levelIds: [80, 94], - monsterId: 'corruptrogue8', + monsterId: 'corruptrogue5', presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i, defaultLandmark: 'Act 3 - Burbs Temple', modifiers: ['fast', 'spectralhit'], minionCount: [4, 6], - minionMonsterId: 'corruptrogue8', + minionMonsterId: 'corruptrogue5', objectId: 83, defaultRelativePos: { x: 0.5, y: 0.5 }, }, @@ -6803,12 +6803,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 5, levelIds: [110], - monsterId: 'demonimp1', + monsterId: 'imp3', presetMatch: /Barricade Prison 1|Barricade Ruins/i, defaultLandmark: 'Act 5 - Barricade Prison 1', modifiers: ['coldenchant'], minionCount: [4, 6], - minionMonsterId: 'demonimp1', + minionMonsterId: 'imp3', objectId: 87, defaultRelativePos: { x: 0.35, y: 0.5 }, }, @@ -6823,7 +6823,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ defaultLandmark: 'Act 5 - Barricade Building', modifiers: ['strong'], minionCount: [15, 20], - minionMonsterId: 'enslaved1', + minionMonsterId: 'minion1', objectId: 88, defaultRelativePos: { x: 0.75, y: 0.5 }, }, @@ -6878,12 +6878,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ role: 'superunique', act: 5, levelIds: [114], - monsterId: 'yeti2', + monsterId: 'snowyeti4', presetMatch: /Frozen River|Anya/i, defaultLandmark: 'Act 5 - Frozen River', modifiers: ['coldenchant', 'manahit'], minionCount: [4, 6], - minionMonsterId: 'yeti2', + minionMonsterId: 'snowyeti4', objectId: 92, defaultRelativePos: { x: 0.5, y: 0.5 }, }, @@ -6898,7 +6898,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ defaultLandmark: 'Act 5 - Halls of Vaught', modifiers: ['coldenchant'], minionCount: [6, 8], - minionMonsterId: 'iceprowler2', + minionMonsterId: 'minion6', objectId: 93, defaultRelativePos: { x: 0.5, y: 0.5 }, }, diff --git a/tests/act-scene-monster-plan.test.ts b/tests/act-scene-monster-plan.test.ts index ff4d214..a16ea31 100644 --- a/tests/act-scene-monster-plan.test.ts +++ b/tests/act-scene-monster-plan.test.ts @@ -70,10 +70,10 @@ describe('no synthetic monster fallback', () => { const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } 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('enslaved1') - expect(plan.types).toContain('demonimp1') + expect(plan.types).toContain('minion1') + expect(plan.types).toContain('imp3') }) }) diff --git a/tests/superuniques-fidelity.test.ts b/tests/superuniques-fidelity.test.ts new file mode 100644 index 0000000..0e9727e --- /dev/null +++ b/tests/superuniques-fidelity.test.ts @@ -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() + } + } + }) +}) diff --git a/tests/wilderness-superuniques.test.ts b/tests/wilderness-superuniques.test.ts index caab34e..425a159 100644 --- a/tests/wilderness-superuniques.test.ts +++ b/tests/wilderness-superuniques.test.ts @@ -371,6 +371,7 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => { ].join("\n")) const monstatsTxt = mockTable([ "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", "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",