fix(packs): restore full Acts 1..5 index and map/bake all 741 monster IDs across all acts (#33)

This commit is contained in:
troytt 2026-09-16 01:43:11 +00:00
parent 270c743aef
commit 2527678c10
6 changed files with 1121 additions and 95 deletions

View File

@ -1078,7 +1078,37 @@ for (const entry of LEVELS) {
}
await mkdir(outDir, { recursive: true })
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
const indexPath = join(outDir, 'index.json')
if (filterLevel !== undefined) {
try {
const { readFile } = await import('node:fs/promises')
const existingRaw = await readFile(indexPath, 'utf8')
const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> }
if (Array.isArray(existing.levels)) {
const updatedByLabel = new Map<string, unknown>()
for (const item of index.levels as Array<{ label: string }>) {
updatedByLabel.set(item.label, item)
}
const merged: unknown[] = []
const seenLabels = new Set<string>()
for (const oldItem of existing.levels) {
if (oldItem.label && updatedByLabel.has(oldItem.label)) {
merged.push(updatedByLabel.get(oldItem.label)!)
seenLabels.add(oldItem.label)
} else {
merged.push(oldItem)
}
}
for (const [lbl, item] of updatedByLabel.entries()) {
if (!seenLabels.has(lbl)) merged.push(item)
}
index.levels = merged
}
} catch {
// No existing index.json or unreadable; write fresh index
}
}
await writeFile(indexPath, JSON.stringify(index, null, 1))
const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir)
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)

View File

@ -15,7 +15,7 @@ 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 { MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
import { ACT_MONSTER_SPECS } from '../src/game/monster-mapping.ts'
import type { SpriteSheet } from '../src/formats/sprite.ts'
import { encodeIndexedPng } from './png.ts'
@ -138,16 +138,19 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
width: null,
}
// 2. Collect unique monster (token, weapon) specs
// 2. Collect unique monster (token, weapon) specs across Acts 1..5
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
for (const spec of Object.values(MONSTER_ART_MAP)) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
}
}
}
// Composite each monster animation once
// Composite each monster animation once (with concurrency limit 6)
const compositedMonsters = new Map<string, {
key: string
token: string
@ -160,32 +163,41 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
sheet: SpriteSheet
}>()
for (const [key, spec] of uniqueSpecs) {
try {
console.log(`Composing monster ${spec.token} (${spec.weapon})...`)
const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
compositeMonsterAnimation(archives, spec.token, 'nu', spec.weapon),
])
const sheet: SpriteSheet = {
groups: [...walk.sheet.groups, ...stand.sheet.groups],
width: null,
const entries = [...uniqueSpecs.entries()]
let cursor = 0
const workers = Array.from({ length: Math.min(6, entries.length) }, async () => {
for (;;) {
const idx = cursor
cursor += 1
if (idx >= entries.length) return
const [key, spec] = entries[idx]!
try {
const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
compositeMonsterAnimation(archives, spec.token, 'nu', spec.weapon),
])
const sheet: SpriteSheet = {
groups: [...walk.sheet.groups, ...stand.sheet.groups],
width: null,
}
compositedMonsters.set(key, {
key,
token: spec.token,
weapon: spec.weapon,
directions: walk.directions,
walkOffset: 0,
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
sheet,
})
} catch (err) {
console.warn(`Failed to composite monster ${key}: ${(err as Error).message}`)
}
compositedMonsters.set(key, {
key,
token: spec.token,
weapon: spec.weapon,
directions: walk.directions,
walkOffset: 0,
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
sheet,
})
} catch (err) {
console.warn(`Failed to composite monster ${key}: ${(err as Error).message}`)
}
}
})
await Promise.all(workers)
console.log(`Composited ${compositedMonsters.size} unique monster specs across Acts 1..5.`)
// 3. Encode indexed PNGs per Act palette
for (let act = 1; act <= 5; act += 1) {
@ -213,8 +225,13 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
}),
)
// Pack monsters
for (const [key, m] of compositedMonsters) {
// Pack monsters used in this act
const actSpecs = ACT_MONSTER_SPECS[act] ?? []
let packedCount = 0
for (const spec of actSpecs) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
const m = compositedMonsters.get(key)
if (!m) continue
const packedMon = packSheetToPng(m.sheet, palette)
const monPngName = `monster-${key}.png`
await writeFile(join(actEntityDir, monPngName), packedMon.png)
@ -234,8 +251,9 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
groups: packedMon.groups,
}),
)
packedCount += 1
}
console.log(`Act ${act}: packed char-so + ${compositedMonsters.size} monster atlases into ${actEntityDir}`)
console.log(`Act ${act}: packed char-so + ${packedCount} monster atlases into ${actEntityDir}`)
} catch (err) {
console.warn(`Act ${act} entity packing skipped: ${(err as Error).message}`)
}

View File

@ -0,0 +1,188 @@
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables } from '../src/game/acts.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { planLevelMonsters } from '../src/game/monsters.ts'
import { bakeEntities } from './pack-entity-assets.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PLAYER_WALK_SPEED_PX = 170
function levelSeed(ds1Name: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < ds1Name.length; i += 1) {
hash ^= ds1Name.charCodeAt(i)
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash
}
async function main() {
const archiveDir = 'samples/d2'
const outDir = 'samples/d2-packs'
const archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const tables = await loadActTables(archives)
const palettes: Record<string, number[]> = {}
for (let act = 1; act <= 5; act += 1) {
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
palettes[`act${String(act)}`] = [...pl2.rgb]
}
const levelsList: Array<{
act: number
levelId: number
kind: string
slug: string
label: string
levelName: string
ds1: string
path: string
cells: string
frames: number
objects: number
npcs: number
pages: number
objectPages: number
missingTiles: number
bytes: number
}> = []
let updatedMonstersCount = 0
for (let act = 1; act <= 5; act += 1) {
const actDir = join(outDir, `act${String(act)}`)
let entries: string[] = []
try {
entries = await readdir(actDir)
} catch {
continue
}
for (const label of entries.sort()) {
if (label === 'entities') continue
const dirPath = join(actDir, label)
const scenePath = join(dirPath, 'scene.json')
let raw: string
try {
raw = await readFile(scenePath, 'utf8')
} catch {
continue
}
const scene = JSON.parse(raw)
const levelId = Number(scene.levelId ?? 1)
const levelRow = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === levelId)
const drlg = levelRow ? Number(cell(tables.levels, levelRow, 'DrlgType')) : 2
const kind = drlg === 2 ? 'preset' : drlg === 1 ? 'maze' : 'wilderness'
// Determine seed matching pack-act-assets.ts
let seed = levelSeed(scene.ds1 ?? label)
const varMatch = label.match(/-var(\d+)$/)
if (varMatch) {
const v = Number(varMatch[1])
seed = (kind === 'maze' ? 0x5eed_0000 : 0x5eed_1000) + levelId * 10 + v
}
// Re-plan monsters using latest planLevelMonsters
const population = planLevelMonsters(
tables,
levelId,
(scene.cellsX ?? 80) * (scene.cellsY ?? 80),
seed,
PLAYER_WALK_SPEED_PX,
)
scene.monsters = {
types: population.types,
budget: population.budget,
packs: population.packs,
}
if (scene.stats) {
scene.stats.monsters = population.packs.reduce((sum, pack) => sum + pack.members.length, 0)
scene.stats.monsterPacks = population.packs.length
}
updatedMonstersCount += 1
const sceneBytes = new TextEncoder().encode(JSON.stringify(scene))
await writeFile(scenePath, sceneBytes)
// Update manifest.json if present
const manifestPath = join(dirPath, 'manifest.json')
try {
const mRaw = await readFile(manifestPath, 'utf8')
const manifest = JSON.parse(mRaw)
manifest.sceneBytes = sceneBytes.byteLength
manifest.sceneSha256 = createHash('sha256').update(sceneBytes).digest('hex')
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
} catch {
// ignore if no manifest
}
// Determine slug from levelId + levelName
const cleanName = String(scene.levelName ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const slug = `${String(levelId)}-${cleanName}`
// Compute total bytes (scene.json + PNG pages)
let pngBytes = 0
const filesInDir = await readdir(dirPath)
for (const f of filesInDir) {
if (f.endsWith('.png')) {
const st = await readFile(join(dirPath, f))
pngBytes += st.byteLength
}
}
levelsList.push({
act: scene.act ?? act,
levelId,
kind,
slug,
label,
levelName: scene.levelName,
ds1: scene.ds1 ?? label,
path: `act${String(act)}/${label}`,
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
frames: Array.isArray(scene.frames) ? scene.frames.length : 0,
objects: Array.isArray(scene.objects) ? scene.objects.length : 0,
npcs: Array.isArray(scene.npcs) ? scene.npcs.length : 0,
pages: Array.isArray(scene.pages) ? scene.pages.length : 0,
objectPages: Array.isArray(scene.objectPages) ? scene.objectPages.length : 0,
missingTiles: scene.missingTiles ?? 0,
bytes: pngBytes + sceneBytes.byteLength,
})
}
}
levelsList.sort((a, b) => a.act - b.act || a.levelId - b.levelId || a.label.localeCompare(b.label))
const index = {
version: 1,
generated: new Date().toISOString(),
archiveDir,
pageSize: 2048,
palettes,
levels: levelsList,
}
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
console.log(`Rebuilt samples/d2-packs/index.json with ${levelsList.length} map variants across Acts 1..5, updated ${updatedMonstersCount} scene monster plans.`)
// Now bake all entity atlases for Acts 1..5
await bakeEntities(archiveDir, outDir)
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -38,15 +38,26 @@ export function findMonsterLayerSprite(
animation: string,
weapon: string,
component: string,
layerWeaponClass?: string,
): string | undefined {
const prefix = `${root}${component}\\${token}${component}`
const suffix = `${animation}${weapon}.dcc`
const hits = names.filter(name =>
name.toLowerCase().startsWith(prefix.toLowerCase()) &&
name.toLowerCase().endsWith(suffix.toLowerCase())
)
hits.sort()
return hits[0]
const prefix = `${root}${component}\\${token}${component}`.toLowerCase()
const weapons = Array.from(new Set([layerWeaponClass, weapon, 'hth'].filter((w): w is string => Boolean(w))))
const anims = animation.toLowerCase() === 'wl' ? ['wl', 'nu'] : [animation.toLowerCase()]
for (const anim of anims) {
for (const w of weapons) {
const suffix = `${anim}${w.toLowerCase()}.dcc`
const hits = names.filter(name => {
const lowerName = name.toLowerCase()
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
})
if (hits.length > 0) {
hits.sort()
return hits[0]
}
}
}
return undefined
}
/**
@ -98,9 +109,31 @@ export async function compositeMonsterAnimation(
const cofMember = `${root}cof\\${lower}${animation}${weapon}.cof`
const names = await archives.listFiles()
const matchedCof = names.find(n => n.toLowerCase() === cofMember.toLowerCase())
let usedAnim = animation.toLowerCase()
let usedWeapon = weapon.toLowerCase()
const candidates = [
`${root}cof\\${lower}${usedAnim}${usedWeapon}.cof`,
`${root}cof\\${lower}${usedAnim}hth.cof`,
...(usedAnim === 'wl'
? [
`${root}cof\\${lower}nu${usedWeapon}.cof`,
`${root}cof\\${lower}nuhth.cof`,
]
: []),
]
let matchedCof: string | undefined
for (const cand of candidates) {
matchedCof = names.find(n => n.toLowerCase() === cand.toLowerCase())
if (matchedCof) {
if (cand.includes('nu')) usedAnim = 'nu'
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
break
}
}
if (!matchedCof) {
throw new Error(`Monster COF not found: ${cofMember}`)
throw new Error(`Monster COF not found: ${candidates[0]}`)
}
const cofBytes = await archives.read(matchedCof)
@ -109,7 +142,15 @@ export async function compositeMonsterAnimation(
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
const component = MONSTER_COMPONENTS[layer.type]
if (component === undefined) return { sprite: null, member: undefined }
const member = findMonsterLayerSprite(names, root, lower, animation, weapon, component)
const member = findMonsterLayerSprite(
names,
root,
lower,
usedAnim,
usedWeapon,
component,
layer.weaponClass,
)
if (member === undefined) return { sprite: null, member: undefined }
try {
const bytes = await archives.read(member)

View File

@ -1,51 +1,785 @@
/**
* Resolving monster ID to art token (Code) and weapon class (BaseW).
* Mapping between MonStats.txt monster IDs (and demo monster IDs)
* to their visual DCC 2-character token (`Code`) and weapon class (`BaseW`).
* Auto-derived from MonStats.txt + MonStats2.txt across Acts 1..5.
*/
export interface MonsterArtSpec {
readonly token: string
readonly weapon: string
}
/**
* Built-in art mapping for standard Diablo II monsters.
* Keyed by monsterId (e.g. 'fallen1', 'zombie1').
* Complete monster ID -> art spec table for Acts 1..5.
*/
export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
// Act 1 Wilderness / Caves
'fallen1': { token: 'FA', weapon: 'hth' },
'fallen2': { token: 'FA', weapon: 'hth' },
'fallenshaman1': { token: 'FS', weapon: 'hth' },
'zombie1': { token: 'ZM', weapon: 'hth' },
'zombie2': { token: 'ZM', weapon: 'hth' },
'quillrat1': { token: 'SI', weapon: 'hth' },
'quillrat2': { token: 'SI', weapon: 'hth' },
'brute1': { token: 'YE', weapon: 'hth' },
'brute2': { token: 'YE', weapon: 'hth' },
'skeleton1': { token: 'SK', weapon: '1hs' },
'skeleton2': { token: 'SK', weapon: '1hs' },
'corruptrogue1': { token: 'CR', weapon: '1hs' },
'cr_archer1': { token: 'CR', weapon: 'bow' },
'cr_lancer1': { token: 'CR', weapon: '2ht' },
'goatman1': { token: 'GM', weapon: '2hs' },
'bloodraven': { token: 'CR', weapon: 'bow' },
'hellbovine': { token: 'CW', weapon: 'hth' },
'cow': { token: 'CW', weapon: 'hth' },
'demo-fallen': { token: 'FA', weapon: 'hth' },
'demo-zombie': { token: 'ZM', weapon: 'hth' },
'demo-skeleton': { token: 'SK', weapon: '1hs' },
"act2child": { token: "2C", weapon: "hth" },
"act2female": { token: "2F", weapon: "hth" },
"act2guard1": { token: "GU", weapon: "hth" },
"act2guard2": { token: "GU", weapon: "hth" },
"act2guard3": { token: "SK", weapon: "hth" },
"act2guard4": { token: "GU", weapon: "hth" },
"act2guard5": { token: "GU", weapon: "hth" },
"act2hire": { token: "GU", weapon: "hth" },
"act2male": { token: "2M", weapon: "hth" },
"act2vendor1": { token: "M1", weapon: "hth" },
"act2vendor2": { token: "M2", weapon: "hth" },
"act3female": { token: "N3", weapon: "hth" },
"act3hire": { token: "IW", weapon: "1hs" },
"act3male": { token: "N4", weapon: "hth" },
"act5barb1": { token: "0A", weapon: "1hs" },
"act5barb2": { token: "0A", weapon: "2hs" },
"act5barb3": { token: "0A", weapon: "1hs" },
"act5barb4": { token: "0A", weapon: "2hs" },
"act5hire1": { token: "0A", weapon: "1hs" },
"act5hire2": { token: "0A", weapon: "2hs" },
"act5pow": { token: "0A", weapon: "hth" },
"akara": { token: "PS", weapon: "hth" },
"alkor": { token: "AL", weapon: "hth" },
"ancientbarb1": { token: "0D", weapon: "hth" },
"ancientbarb2": { token: "0F", weapon: "hth" },
"ancientbarb3": { token: "0E", weapon: "hth" },
"ancientstatue1": { token: "0G", weapon: "hth" },
"ancientstatue2": { token: "0H", weapon: "hth" },
"ancientstatue3": { token: "0I", weapon: "hth" },
"andariel": { token: "AN", weapon: "hth" },
"arach1": { token: "SP", weapon: "hth" },
"arach2": { token: "SP", weapon: "hth" },
"arach3": { token: "SP", weapon: "hth" },
"arach4": { token: "SP", weapon: "hth" },
"arach5": { token: "SP", weapon: "hth" },
"arach6": { token: "SP", weapon: "hth" },
"arach7": { token: "SP", weapon: "hth" },
"asheara": { token: "AH", weapon: "hth" },
"atma": { token: "AS", weapon: "hth" },
"baalclone": { token: "42", weapon: "hth" },
"baalcrab": { token: "42", weapon: "hth" },
"baalcrabstairs": { token: "42", weapon: "hth" },
"baalhighpriest": { token: "HP", weapon: "hth" },
"baalminion1": { token: "43", weapon: "hth" },
"baalminion2": { token: "43", weapon: "hth" },
"baalminion3": { token: "43", weapon: "hth" },
"baaltaunt": { token: "K9", weapon: "hth" },
"baaltentacle1": { token: "44", weapon: "hth" },
"baaltentacle2": { token: "44", weapon: "hth" },
"baaltentacle3": { token: "44", weapon: "hth" },
"baaltentacle4": { token: "44", weapon: "hth" },
"baaltentacle5": { token: "44", weapon: "hth" },
"baalthrone": { token: "41", weapon: "hth" },
"baboon1": { token: "BB", weapon: "hth" },
"baboon2": { token: "BB", weapon: "hth" },
"baboon3": { token: "BB", weapon: "hth" },
"baboon4": { token: "BB", weapon: "hth" },
"baboon5": { token: "BB", weapon: "hth" },
"baboon6": { token: "BB", weapon: "hth" },
"baboon7": { token: "BB", weapon: "hth" },
"baboon8": { token: "BB", weapon: "hth" },
"barricadedoor1": { token: "AJ", weapon: "hth" },
"barricadedoor2": { token: "AG", weapon: "hth" },
"barricadetower": { token: "AC", weapon: "hth" },
"barricadewall1": { token: "A6", weapon: "hth" },
"barricadewall2": { token: "AK", weapon: "hth" },
"bat": { token: "B9", weapon: "hth" },
"batdemon1": { token: "BT", weapon: "hth" },
"batdemon2": { token: "BT", weapon: "hth" },
"batdemon3": { token: "BT", weapon: "hth" },
"batdemon4": { token: "BT", weapon: "hth" },
"batdemon5": { token: "BT", weapon: "hth" },
"batdemon6": { token: "BT", weapon: "hth" },
"batdemon7": { token: "BT", weapon: "hth" },
"bear": { token: "TG", weapon: "hth" },
"bighead1": { token: "BH", weapon: "hth" },
"bighead10": { token: "BH", weapon: "hth" },
"bighead2": { token: "BH", weapon: "hth" },
"bighead3": { token: "BH", weapon: "hth" },
"bighead4": { token: "BH", weapon: "hth" },
"bighead5": { token: "BH", weapon: "hth" },
"bighead6": { token: "BH", weapon: "hth" },
"bighead7": { token: "BH", weapon: "hth" },
"bighead8": { token: "BH", weapon: "hth" },
"bighead9": { token: "BH", weapon: "hth" },
"bird1": { token: "BS", weapon: "hth" },
"bird2": { token: "BL", weapon: "hth" },
"bladecreeper": { token: "B8", weapon: "hth" },
"bloodgolem": { token: "G2", weapon: "hth" },
"bloodlord1": { token: "L3", weapon: "hth" },
"bloodlord2": { token: "L3", weapon: "hth" },
"bloodlord3": { token: "L3", weapon: "hth" },
"bloodlord4": { token: "L3", weapon: "hth" },
"bloodlord5": { token: "L3", weapon: "hth" },
"bloodlord6": { token: "L3", weapon: "hth" },
"bloodlord7": { token: "L3", weapon: "hth" },
"bloodmage1": { token: "XX", weapon: "hth" },
"bloodmage2": { token: "XX", weapon: "hth" },
"bloodmage3": { token: "XX", weapon: "hth" },
"bloodmage4": { token: "XX", weapon: "hth" },
"bloodmage5": { token: "XX", weapon: "hth" },
"bloodraven": { token: "CR", weapon: "bow" },
"blunderbore1": { token: "PN", weapon: "hth" },
"blunderbore2": { token: "PN", weapon: "hth" },
"blunderbore3": { token: "PN", weapon: "hth" },
"blunderbore4": { token: "PN", weapon: "hth" },
"blunderbore5": { token: "PN", weapon: "hth" },
"blunderbore6": { token: "PN", weapon: "hth" },
"bonefetish1": { token: "FK", weapon: "1hs" },
"bonefetish2": { token: "FK", weapon: "1hs" },
"bonefetish3": { token: "FK", weapon: "1hs" },
"bonefetish4": { token: "FK", weapon: "1hs" },
"bonefetish5": { token: "FK", weapon: "1hs" },
"bonefetish6": { token: "FK", weapon: "1hs" },
"bonefetish7": { token: "FK", weapon: "1hs" },
"boneprison1": { token: "67", weapon: "hth" },
"boneprison2": { token: "66", weapon: "hth" },
"boneprison3": { token: "69", weapon: "hth" },
"boneprison4": { token: "68", weapon: "hth" },
"bonewall": { token: "BW", weapon: "hth" },
"brute": { token: "YE", weapon: "hth" },
"brute1": { token: "YE", weapon: "hth" },
"brute2": { token: "YE", weapon: "hth" },
"brute3": { token: "YE", weapon: "hth" },
"brute4": { token: "YE", weapon: "hth" },
"brute5": { token: "YE", weapon: "hth" },
"bug": { token: "BG", weapon: "hth" },
"bunny": { token: "48", weapon: "hth" },
"cain1": { token: "DC", weapon: "hth" },
"cain2": { token: "DC", weapon: "hth" },
"cain3": { token: "DC", weapon: "hth" },
"cain4": { token: "DC", weapon: "hth" },
"cain5": { token: "1D", weapon: "hth" },
"cain6": { token: "DC", weapon: "hth" },
"camel": { token: "CM", weapon: "hth" },
"cantor1": { token: "ZP", weapon: "hth" },
"cantor2": { token: "ZP", weapon: "hth" },
"cantor3": { token: "ZP", weapon: "hth" },
"cantor4": { token: "ZP", weapon: "hth" },
"cantor5": { token: "ZP", weapon: "hth" },
"cantor6": { token: "ZP", weapon: "hth" },
"catapult1": { token: "65", weapon: "hth" },
"catapult2": { token: "64", weapon: "hth" },
"catapult3": { token: "64", weapon: "hth" },
"catapult4": { token: "UA", weapon: "hth" },
"catapultspotter1": { token: "K9", weapon: "hth" },
"catapultspotter2": { token: "K9", weapon: "hth" },
"catapultspotter3": { token: "K9", weapon: "hth" },
"catapultspotter4": { token: "K9", weapon: "hth" },
"chaoshorde1": { token: "CH", weapon: "hth" },
"chaoshorde2": { token: "CH", weapon: "hth" },
"chaoshorde3": { token: "CH", weapon: "hth" },
"chaoshorde4": { token: "CH", weapon: "hth" },
"chargeboltsentry": { token: "LG", weapon: "hth" },
"charsi": { token: "CI", weapon: "hth" },
"chicken": { token: "CK", weapon: "hth" },
"clawviper1": { token: "SD", weapon: "hth" },
"clawviper10": { token: "SD", weapon: "hth" },
"clawviper2": { token: "SD", weapon: "hth" },
"clawviper3": { token: "SD", weapon: "hth" },
"clawviper4": { token: "SD", weapon: "hth" },
"clawviper5": { token: "SD", weapon: "hth" },
"clawviper6": { token: "SD", weapon: "hth" },
"clawviper7": { token: "SD", weapon: "hth" },
"clawviper8": { token: "SD", weapon: "hth" },
"clawviper9": { token: "SD", weapon: "hth" },
"claygolem": { token: "G1", weapon: "hth" },
"compellingorb": { token: "9A", weapon: "hth" },
"corruptrogue1": { token: "CR", weapon: "1hs" },
"corruptrogue2": { token: "CR", weapon: "1hs" },
"corruptrogue3": { token: "CR", weapon: "1hs" },
"corruptrogue4": { token: "CR", weapon: "1hs" },
"corruptrogue5": { token: "CR", weapon: "1hs" },
"corruptrogue6": { token: "CR", weapon: "1hs" },
"councilmember1": { token: "HP", weapon: "hth" },
"councilmember2": { token: "HP", weapon: "hth" },
"councilmember3": { token: "HP", weapon: "hth" },
"cow": { token: "CW", weapon: "hth" },
"cr_archer1": { token: "CR", weapon: "bow" },
"cr_archer2": { token: "CR", weapon: "bow" },
"cr_archer3": { token: "CR", weapon: "bow" },
"cr_archer4": { token: "CR", weapon: "bow" },
"cr_archer5": { token: "CR", weapon: "bow" },
"cr_archer6": { token: "CR", weapon: "bow" },
"cr_archer7": { token: "CR", weapon: "bow" },
"cr_archer8": { token: "CR", weapon: "bow" },
"cr_lancer1": { token: "CR", weapon: "2ht" },
"cr_lancer2": { token: "CR", weapon: "2ht" },
"cr_lancer3": { token: "CR", weapon: "2ht" },
"cr_lancer4": { token: "CR", weapon: "2ht" },
"cr_lancer5": { token: "CR", weapon: "2ht" },
"cr_lancer6": { token: "CR", weapon: "2ht" },
"cr_lancer7": { token: "CR", weapon: "2ht" },
"cr_lancer8": { token: "CR", weapon: "2ht" },
"crownest1": { token: "BN", weapon: "hth" },
"crownest2": { token: "BN", weapon: "hth" },
"crownest3": { token: "BN", weapon: "hth" },
"crownest4": { token: "BN", weapon: "hth" },
"cycleoflife": { token: "K9", weapon: "hth" },
"darkguard1": { token: "XX", weapon: "hth" },
"darkguard2": { token: "XX", weapon: "hth" },
"darkguard3": { token: "XX", weapon: "hth" },
"darkguard4": { token: "XX", weapon: "hth" },
"darkguard5": { token: "XX", weapon: "hth" },
"darkwanderer": { token: "1Z", weapon: "hth" },
"deathmauler1": { token: "M5", weapon: "hth" },
"deathmauler2": { token: "M5", weapon: "hth" },
"deathmauler3": { token: "M5", weapon: "hth" },
"deathmauler4": { token: "M5", weapon: "hth" },
"deathmauler5": { token: "M5", weapon: "hth" },
"deathmauler6": { token: "M5", weapon: "hth" },
"deathsentry": { token: "LG", weapon: "hth" },
"demonhole": { token: "EH", weapon: "hth" },
"demonspawner": { token: "XA", weapon: "hth" },
"diablo": { token: "DI", weapon: "hth" },
"diabloclone": { token: "DI", weapon: "hth" },
"dkfig1": { token: "UM", weapon: "fgt" },
"dkfig2": { token: "UM", weapon: "fgt" },
"dkmag1": { token: "UM", weapon: "mgk" },
"dkmag2": { token: "UM", weapon: "mgk" },
"dkmag3": { token: "UM", weapon: "mgk" },
"doomknight1": { token: "UM", weapon: "fgt" },
"doomknight2": { token: "UM", weapon: "hth" },
"doomknight3": { token: "UM", weapon: "mgk" },
"dopplezon": { token: "VK", weapon: "hth" },
"drehya": { token: "XS", weapon: "hth" },
"drehyaiced": { token: "XS", weapon: "hth" },
"drognan": { token: "DR", weapon: "hth" },
"druidbear": { token: "B7", weapon: "hth" },
"druidhawk": { token: "HK", weapon: "hth" },
"duriel": { token: "DU", weapon: "hth" },
"eagle": { token: "EG", weapon: "hth" },
"elzix": { token: "EL", weapon: "hth" },
"evilhole1": { token: "EH", weapon: "hth" },
"evilhole2": { token: "EH", weapon: "hth" },
"evilhole3": { token: "EH", weapon: "hth" },
"evilhole4": { token: "EH", weapon: "hth" },
"evilhole5": { token: "EH", weapon: "hth" },
"evilhut": { token: "2T", weapon: "hth" },
"fallen": { token: "FA", weapon: "hth" },
"fallen1": { token: "FA", weapon: "hth" },
"fallen2": { token: "FA", weapon: "hth" },
"fallen3": { token: "FA", weapon: "hth" },
"fallen4": { token: "FA", weapon: "hth" },
"fallen5": { token: "FA", weapon: "hth" },
"fallen6": { token: "FA", weapon: "hth" },
"fallen7": { token: "FA", weapon: "hth" },
"fallen8": { token: "FA", weapon: "hth" },
"fallenshaman": { token: "FS", weapon: "hth" },
"fallenshaman1": { token: "FS", weapon: "hth" },
"fallenshaman2": { token: "FS", weapon: "hth" },
"fallenshaman3": { token: "FS", weapon: "hth" },
"fallenshaman4": { token: "FS", weapon: "hth" },
"fallenshaman5": { token: "FS", weapon: "hth" },
"fallenshaman6": { token: "FS", weapon: "hth" },
"fallenshaman7": { token: "FS", weapon: "hth" },
"fallenshaman8": { token: "FS", weapon: "hth" },
"familiar": { token: "FI", weapon: "hth" },
"fara": { token: "OF", weapon: "hth" },
"fenris": { token: "WF", weapon: "hth" },
"fetish1": { token: "FE", weapon: "1hs" },
"fetish11": { token: "FE", weapon: "1hs" },
"fetish2": { token: "FE", weapon: "1hs" },
"fetish3": { token: "FE", weapon: "1hs" },
"fetish4": { token: "FE", weapon: "1hs" },
"fetish5": { token: "FE", weapon: "1hs" },
"fetish6": { token: "FE", weapon: "1hs" },
"fetish7": { token: "FE", weapon: "1hs" },
"fetish8": { token: "FE", weapon: "1hs" },
"fetishblow1": { token: "FC", weapon: "hth" },
"fetishblow2": { token: "FC", weapon: "hth" },
"fetishblow3": { token: "FC", weapon: "hth" },
"fetishblow4": { token: "FC", weapon: "hth" },
"fetishblow5": { token: "FC", weapon: "hth" },
"fetishblow6": { token: "FC", weapon: "hth" },
"fetishblow7": { token: "FC", weapon: "hth" },
"fetishblow8": { token: "FC", weapon: "hth" },
"fetishshaman1": { token: "FW", weapon: "hth" },
"fetishshaman2": { token: "FW", weapon: "hth" },
"fetishshaman3": { token: "FW", weapon: "hth" },
"fetishshaman4": { token: "FW", weapon: "hth" },
"fetishshaman5": { token: "FW", weapon: "hth" },
"fetishshaman6": { token: "FW", weapon: "hth" },
"fetishshaman7": { token: "FW", weapon: "hth" },
"fetishshaman8": { token: "FW", weapon: "hth" },
"fingermage1": { token: "FR", weapon: "hth" },
"fingermage2": { token: "FR", weapon: "hth" },
"fingermage3": { token: "FR", weapon: "hth" },
"fingermage4": { token: "FR", weapon: "hth" },
"fingermage5": { token: "FR", weapon: "hth" },
"firebeast": { token: "FM", weapon: "hth" },
"firegolem": { token: "G3", weapon: "hth" },
"firetower": { token: "PB", weapon: "hth" },
"fish": { token: "FJ", weapon: "hth" },
"flyingscimitar": { token: "ST", weapon: "hth" },
"foulcrow1": { token: "BK", weapon: "hth" },
"foulcrow2": { token: "BK", weapon: "hth" },
"foulcrow3": { token: "BK", weapon: "hth" },
"foulcrow4": { token: "BK", weapon: "hth" },
"foulcrow5": { token: "BK", weapon: "hth" },
"foulcrow6": { token: "BK", weapon: "hth" },
"foulcrow7": { token: "BK", weapon: "hth" },
"foulcrow8": { token: "BK", weapon: "hth" },
"frogdemon1": { token: "FD", weapon: "hth" },
"frogdemon2": { token: "FD", weapon: "hth" },
"frogdemon3": { token: "FD", weapon: "hth" },
"frozenhorror1": { token: "F0", weapon: "hth" },
"frozenhorror2": { token: "F0", weapon: "hth" },
"frozenhorror3": { token: "F0", weapon: "hth" },
"frozenhorror4": { token: "F0", weapon: "hth" },
"frozenhorror5": { token: "F0", weapon: "hth" },
"gargoyletrap": { token: "GT", weapon: "hth" },
"geglash": { token: "GE", weapon: "hth" },
"gheed": { token: "GH", weapon: "hth" },
"goatman1": { token: "GM", weapon: "2hs" },
"goatman10": { token: "GM", weapon: "2hs" },
"goatman2": { token: "GM", weapon: "2hs" },
"goatman3": { token: "GM", weapon: "2hs" },
"goatman4": { token: "GM", weapon: "2hs" },
"goatman5": { token: "GM", weapon: "2hs" },
"goatman6": { token: "GM", weapon: "2hs" },
"goatman7": { token: "GM", weapon: "2hs" },
"goatman8": { token: "GM", weapon: "2hs" },
"goatman9": { token: "GM", weapon: "2hs" },
"gorgon1": { token: "GO", weapon: "hth" },
"gorgon2": { token: "GO", weapon: "hth" },
"gorgon3": { token: "GO", weapon: "hth" },
"gorgon4": { token: "GO", weapon: "hth" },
"greiz": { token: "GR", weapon: "hth" },
"griswold": { token: "GZ", weapon: "hth" },
"halbu": { token: "20", weapon: "hth" },
"heartofwolverine": { token: "X3", weapon: "hth" },
"hellbovine": { token: "CW", weapon: "hth" },
"hellmeteor": { token: "K9", weapon: "hth" },
"hephasto": { token: "5P", weapon: "hth" },
"hratli": { token: "HR", weapon: "hth" },
"hydra1": { token: "HX", weapon: "hth" },
"hydra2": { token: "21", weapon: "hth" },
"hydra3": { token: "HZ", weapon: "hth" },
"iceglobe": { token: "IM", weapon: "hth" },
"imp1": { token: "IP", weapon: "hth" },
"imp2": { token: "IP", weapon: "hth" },
"imp3": { token: "IP", weapon: "hth" },
"imp4": { token: "IP", weapon: "hth" },
"imp5": { token: "IP", weapon: "hth" },
"imp6": { token: "IP", weapon: "hth" },
"imp7": { token: "IP", weapon: "hth" },
"imp8": { token: "IP", weapon: "hth" },
"infernosentry": { token: "E9", weapon: "hth" },
"injuredbarb1": { token: "6Z", weapon: "hth" },
"injuredbarb2": { token: "7J", weapon: "hth" },
"injuredbarb3": { token: "7I", weapon: "hth" },
"invisopet": { token: "K9", weapon: "hth" },
"invisospawner": { token: "K9", weapon: "hth" },
"irongolem": { token: "G4", weapon: "hth" },
"izual": { token: "22", weapon: "hth" },
"izualghost": { token: "17", weapon: "hth" },
"jamella": { token: "JA", weapon: "hth" },
"jerhyn": { token: "JE", weapon: "hth" },
"kashya": { token: "RC", weapon: "hth" },
"larva": { token: "LV", weapon: "hth" },
"larzuk": { token: "XR", weapon: "hth" },
"lightningbeast": { token: "XX", weapon: "hth" },
"lightningsentry": { token: "LG", weapon: "hth" },
"lightningspire": { token: "AE", weapon: "hth" },
"lysander": { token: "LY", weapon: "hth" },
"maggot": { token: "MA", weapon: "hth" },
"maggotbaby1": { token: "SB", weapon: "hth" },
"maggotbaby2": { token: "SB", weapon: "hth" },
"maggotbaby3": { token: "SB", weapon: "hth" },
"maggotbaby4": { token: "SB", weapon: "hth" },
"maggotbaby5": { token: "SB", weapon: "hth" },
"maggotbaby6": { token: "SB", weapon: "hth" },
"maggotegg1": { token: "SE", weapon: "hth" },
"maggotegg2": { token: "SE", weapon: "hth" },
"maggotegg3": { token: "SE", weapon: "hth" },
"maggotegg4": { token: "SE", weapon: "hth" },
"maggotegg5": { token: "SE", weapon: "hth" },
"maggotegg6": { token: "SE", weapon: "hth" },
"maggotqueen1": { token: "MQ", weapon: "hth" },
"maggotqueen2": { token: "MQ", weapon: "hth" },
"maggotqueen3": { token: "MQ", weapon: "hth" },
"maggotqueen4": { token: "MQ", weapon: "hth" },
"maggotqueen5": { token: "MQ", weapon: "hth" },
"malachai": { token: "36", weapon: "hth" },
"malah": { token: "XT", weapon: "hth" },
"megademon1": { token: "DM", weapon: "hth" },
"megademon2": { token: "DM", weapon: "hth" },
"megademon3": { token: "DM", weapon: "hth" },
"megademon4": { token: "DM", weapon: "hth" },
"megademon5": { token: "DM", weapon: "hth" },
"megademon6": { token: "DM", weapon: "hth" },
"mephisto": { token: "MP", weapon: "hth" },
"mephistospirit": { token: "M6", weapon: "hth" },
"meshif1": { token: "MS", weapon: "hth" },
"meshif2": { token: "M3", weapon: "hth" },
"minion1": { token: "XX", weapon: "hth" },
"minion10": { token: "XX", weapon: "hth" },
"minion11": { token: "XX", weapon: "hth" },
"minion2": { token: "XX", weapon: "hth" },
"minion3": { token: "XX", weapon: "hth" },
"minion4": { token: "XX", weapon: "hth" },
"minion5": { token: "XX", weapon: "hth" },
"minion6": { token: "XX", weapon: "hth" },
"minion7": { token: "XX", weapon: "hth" },
"minion8": { token: "XX", weapon: "hth" },
"minion9": { token: "XX", weapon: "hth" },
"minionspawner1": { token: "XA", weapon: "hth" },
"minionspawner2": { token: "XA", weapon: "hth" },
"minionspawner3": { token: "XA", weapon: "hth" },
"minionspawner4": { token: "XA", weapon: "hth" },
"minionspawner5": { token: "XA", weapon: "hth" },
"minionspawner6": { token: "XA", weapon: "hth" },
"minionspawner7": { token: "XA", weapon: "hth" },
"minionspawner8": { token: "XA", weapon: "hth" },
"minispider": { token: "LS", weapon: "hth" },
"mosquito1": { token: "MO", weapon: "hth" },
"mosquito2": { token: "MO", weapon: "hth" },
"mosquito3": { token: "MO", weapon: "hth" },
"mosquito4": { token: "MO", weapon: "hth" },
"mummy1": { token: "MM", weapon: "hth" },
"mummy2": { token: "MM", weapon: "hth" },
"mummy3": { token: "MM", weapon: "hth" },
"mummy4": { token: "MM", weapon: "hth" },
"mummy5": { token: "MM", weapon: "hth" },
"mummy6": { token: "MM", weapon: "hth" },
"natalya": { token: "TZ", weapon: "hth" },
"navi": { token: "RG", weapon: "hth" },
"necromage": { token: "SK", weapon: "hth" },
"necroskeleton": { token: "SK", weapon: "1hs" },
"nihlathak": { token: "0J", weapon: "hth" },
"nihlathakboss": { token: "XU", weapon: "hth" },
"oaksage": { token: "XW", weapon: "hth" },
"ormus": { token: "OR", weapon: "hth" },
"overseer1": { token: "OS", weapon: "hth" },
"overseer2": { token: "OS", weapon: "hth" },
"overseer3": { token: "OS", weapon: "hth" },
"overseer4": { token: "OS", weapon: "hth" },
"overseer5": { token: "OS", weapon: "hth" },
"overseer6": { token: "OS", weapon: "hth" },
"painworm1": { token: "46", weapon: "hth" },
"painworm2": { token: "46", weapon: "hth" },
"painworm3": { token: "46", weapon: "hth" },
"painworm4": { token: "46", weapon: "hth" },
"painworm5": { token: "46", weapon: "hth" },
"pantherwoman1": { token: "PW", weapon: "hth" },
"pantherwoman2": { token: "PW", weapon: "hth" },
"pantherwoman3": { token: "PW", weapon: "hth" },
"pantherwoman4": { token: "PW", weapon: "hth" },
"pantherwoman5": { token: "PW", weapon: "hth" },
"pantherwoman6": { token: "PW", weapon: "hth" },
"parrot": { token: "PR", weapon: "hth" },
"plaguepoppy": { token: "K9", weapon: "hth" },
"poisonorb": { token: "PM", weapon: "hth" },
"prisondoor": { token: "2Q", weapon: "hth" },
"putriddefiler1": { token: "45", weapon: "hth" },
"putriddefiler2": { token: "45", weapon: "hth" },
"putriddefiler3": { token: "45", weapon: "hth" },
"putriddefiler4": { token: "45", weapon: "hth" },
"putriddefiler5": { token: "45", weapon: "hth" },
"qual-kehk": { token: "XV", weapon: "hth" },
"quill": { token: "SI", weapon: "hth" },
"quillbear1": { token: "S7", weapon: "hth" },
"quillbear2": { token: "S7", weapon: "hth" },
"quillbear3": { token: "S7", weapon: "hth" },
"quillbear4": { token: "S7", weapon: "hth" },
"quillbear5": { token: "S7", weapon: "hth" },
"quillrat": { token: "SI", weapon: "hth" },
"quillrat1": { token: "SI", weapon: "hth" },
"quillrat2": { token: "SI", weapon: "hth" },
"quillrat3": { token: "SI", weapon: "hth" },
"quillrat4": { token: "SI", weapon: "hth" },
"quillrat5": { token: "SI", weapon: "hth" },
"quillrat6": { token: "SI", weapon: "hth" },
"quillrat7": { token: "SI", weapon: "hth" },
"quillrat8": { token: "SI", weapon: "hth" },
"radament": { token: "RD", weapon: "hth" },
"rat": { token: "RT", weapon: "hth" },
"reanimatedhorde1": { token: "RE", weapon: "hth" },
"reanimatedhorde2": { token: "RE", weapon: "hth" },
"reanimatedhorde3": { token: "RE", weapon: "hth" },
"reanimatedhorde4": { token: "RE", weapon: "hth" },
"reanimatedhorde5": { token: "RE", weapon: "hth" },
"reanimatedhorde6": { token: "RE", weapon: "hth" },
"regurgitator1": { token: "CS", weapon: "hth" },
"regurgitator2": { token: "CS", weapon: "hth" },
"regurgitator3": { token: "CS", weapon: "hth" },
"regurgitator4": { token: "CS", weapon: "hth" },
"rogue1": { token: "RG", weapon: "hth" },
"rogue2": { token: "RG", weapon: "hth" },
"rogue3": { token: "RG", weapon: "hth" },
"roguehire": { token: "RG", weapon: "hth" },
"sandleaper1": { token: "SL", weapon: "hth" },
"sandleaper2": { token: "SL", weapon: "hth" },
"sandleaper3": { token: "SL", weapon: "hth" },
"sandleaper4": { token: "SL", weapon: "hth" },
"sandleaper5": { token: "SL", weapon: "hth" },
"sandleaper6": { token: "SL", weapon: "hth" },
"sandleaper7": { token: "SL", weapon: "hth" },
"sandmaggot1": { token: "SM", weapon: "hth" },
"sandmaggot2": { token: "SM", weapon: "hth" },
"sandmaggot3": { token: "SM", weapon: "hth" },
"sandmaggot4": { token: "SM", weapon: "hth" },
"sandmaggot5": { token: "SM", weapon: "hth" },
"sandmaggot6": { token: "SM", weapon: "hth" },
"sandmaggot7": { token: "SM", weapon: "hth" },
"sandraider1": { token: "SR", weapon: "hth" },
"sandraider10": { token: "SR", weapon: "hth" },
"sandraider2": { token: "SR", weapon: "hth" },
"sandraider3": { token: "SR", weapon: "hth" },
"sandraider4": { token: "SR", weapon: "hth" },
"sandraider5": { token: "SR", weapon: "hth" },
"sandraider6": { token: "SR", weapon: "hth" },
"sandraider7": { token: "SR", weapon: "hth" },
"sandraider8": { token: "SR", weapon: "hth" },
"sandraider9": { token: "SR", weapon: "hth" },
"sarcophagus": { token: "MG", weapon: "hth" },
"scarab1": { token: "SC", weapon: "hth" },
"scarab2": { token: "SC", weapon: "hth" },
"scarab3": { token: "SC", weapon: "hth" },
"scarab4": { token: "SC", weapon: "hth" },
"scarab5": { token: "SC", weapon: "hth" },
"scarab6": { token: "SC", weapon: "hth" },
"scarab7": { token: "SC", weapon: "hth" },
"scarab8": { token: "SC", weapon: "hth" },
"scorpion": { token: "DS", weapon: "hth" },
"seventombs": { token: "9A", weapon: "hth" },
"shadowmaster": { token: "K9", weapon: "hth" },
"shadowwarrior": { token: "K9", weapon: "hth" },
"siegebeast1": { token: "OX", weapon: "hth" },
"siegebeast2": { token: "OX", weapon: "hth" },
"siegebeast3": { token: "OX", weapon: "hth" },
"siegebeast4": { token: "OX", weapon: "hth" },
"siegebeast5": { token: "OX", weapon: "hth" },
"sk_archer1": { token: "SK", weapon: "bow" },
"sk_archer10": { token: "SK", weapon: "bow" },
"sk_archer11": { token: "SK", weapon: "bow" },
"sk_archer2": { token: "SK", weapon: "bow" },
"sk_archer3": { token: "SK", weapon: "bow" },
"sk_archer4": { token: "SK", weapon: "bow" },
"sk_archer5": { token: "SK", weapon: "bow" },
"sk_archer6": { token: "SK", weapon: "bow" },
"sk_archer7": { token: "SK", weapon: "bow" },
"sk_archer8": { token: "SK", weapon: "bow" },
"sk_archer9": { token: "SK", weapon: "bow" },
"skeleton": { token: "SK", weapon: "hth" },
"skeleton1": { token: "SK", weapon: "1hs" },
"skeleton2": { token: "SK", weapon: "1hs" },
"skeleton3": { token: "SK", weapon: "1hs" },
"skeleton4": { token: "SK", weapon: "1hs" },
"skeleton5": { token: "SK", weapon: "1hs" },
"skeleton6": { token: "SK", weapon: "1hs" },
"skeleton7": { token: "SK", weapon: "1hs" },
"skeleton8": { token: "SK", weapon: "1hs" },
"skmage_cold1": { token: "SK", weapon: "hth" },
"skmage_cold2": { token: "SK", weapon: "hth" },
"skmage_cold3": { token: "SK", weapon: "hth" },
"skmage_cold4": { token: "SK", weapon: "hth" },
"skmage_cold5": { token: "SK", weapon: "hth" },
"skmage_cold6": { token: "SK", weapon: "hth" },
"skmage_fire1": { token: "SK", weapon: "hth" },
"skmage_fire2": { token: "SK", weapon: "hth" },
"skmage_fire3": { token: "SK", weapon: "hth" },
"skmage_fire4": { token: "SK", weapon: "hth" },
"skmage_fire5": { token: "SK", weapon: "hth" },
"skmage_fire6": { token: "SK", weapon: "hth" },
"skmage_fire7": { token: "SK", weapon: "hth" },
"skmage_ltng1": { token: "SK", weapon: "hth" },
"skmage_ltng2": { token: "SK", weapon: "hth" },
"skmage_ltng3": { token: "SK", weapon: "hth" },
"skmage_ltng4": { token: "SK", weapon: "hth" },
"skmage_ltng5": { token: "SK", weapon: "hth" },
"skmage_ltng6": { token: "SK", weapon: "hth" },
"skmage_ltng7": { token: "SK", weapon: "hth" },
"skmage_pois1": { token: "SK", weapon: "hth" },
"skmage_pois2": { token: "SK", weapon: "hth" },
"skmage_pois3": { token: "SK", weapon: "hth" },
"skmage_pois4": { token: "SK", weapon: "hth" },
"skmage_pois5": { token: "SK", weapon: "hth" },
"skmage_pois6": { token: "SK", weapon: "hth" },
"skmage_pois7": { token: "SK", weapon: "hth" },
"slinger1": { token: "PW", weapon: "1ht" },
"slinger2": { token: "PW", weapon: "1ht" },
"slinger3": { token: "PW", weapon: "1ht" },
"slinger4": { token: "PW", weapon: "1ht" },
"slinger5": { token: "PW", weapon: "1ht" },
"slinger6": { token: "PW", weapon: "1ht" },
"slinger7": { token: "PW", weapon: "1ht" },
"slinger8": { token: "PW", weapon: "1ht" },
"slinger9": { token: "PW", weapon: "1ht" },
"smith": { token: "5P", weapon: "hth" },
"snake": { token: "CO", weapon: "hth" },
"snowyeti1": { token: "IO", weapon: "hth" },
"snowyeti2": { token: "IO", weapon: "hth" },
"snowyeti3": { token: "IO", weapon: "hth" },
"snowyeti4": { token: "IO", weapon: "hth" },
"spiritmummy": { token: "XX", weapon: "hth" },
"spiritofbarbs": { token: "X4", weapon: "hth" },
"spiritwolf": { token: "WF", weapon: "hth" },
"succubus1": { token: "0B", weapon: "hth" },
"succubus2": { token: "0B", weapon: "hth" },
"succubus3": { token: "0B", weapon: "hth" },
"succubus4": { token: "0B", weapon: "hth" },
"succubus5": { token: "0B", weapon: "hth" },
"succubus6": { token: "0B", weapon: "hth" },
"succubus7": { token: "0B", weapon: "hth" },
"succubus8": { token: "0B", weapon: "hth" },
"succubuswitch1": { token: "0C", weapon: "hth" },
"succubuswitch2": { token: "0C", weapon: "hth" },
"succubuswitch3": { token: "0C", weapon: "hth" },
"succubuswitch4": { token: "0C", weapon: "hth" },
"succubuswitch5": { token: "0C", weapon: "hth" },
"succubuswitch6": { token: "0C", weapon: "hth" },
"succubuswitch7": { token: "0C", weapon: "hth" },
"succubuswitch8": { token: "0C", weapon: "hth" },
"succubuswitch9": { token: "0C", weapon: "hth" },
"suckernest1": { token: "DH", weapon: "hth" },
"suckernest2": { token: "DH", weapon: "hth" },
"suckernest3": { token: "DH", weapon: "hth" },
"suckernest4": { token: "DH", weapon: "hth" },
"suicideminion1": { token: "XY", weapon: "hth" },
"suicideminion2": { token: "XY", weapon: "hth" },
"suicideminion3": { token: "XY", weapon: "hth" },
"suicideminion4": { token: "XY", weapon: "hth" },
"suicideminion5": { token: "XY", weapon: "hth" },
"suicideminion6": { token: "XY", weapon: "hth" },
"suicideminion7": { token: "XY", weapon: "hth" },
"suicideminion8": { token: "XY", weapon: "hth" },
"summoner": { token: "SU", weapon: "hth" },
"swarm1": { token: "SW", weapon: "hth" },
"swarm2": { token: "SW", weapon: "hth" },
"swarm3": { token: "SW", weapon: "hth" },
"swarm4": { token: "SW", weapon: "hth" },
"swarm5": { token: "SW", weapon: "hth" },
"tentacle1": { token: "TN", weapon: "hth" },
"tentacle2": { token: "TN", weapon: "hth" },
"tentacle3": { token: "TN", weapon: "hth" },
"tentaclehead1": { token: "TE", weapon: "hth" },
"tentaclehead2": { token: "TE", weapon: "hth" },
"tentaclehead3": { token: "TE", weapon: "hth" },
"thornhulk1": { token: "TH", weapon: "hth" },
"thornhulk2": { token: "TH", weapon: "hth" },
"thornhulk3": { token: "TH", weapon: "hth" },
"thornhulk4": { token: "TH", weapon: "hth" },
"thornhulk5": { token: "TH", weapon: "hth" },
"trap-firebolt": { token: "9A", weapon: "hth" },
"trap-horzmissile": { token: "9A", weapon: "hth" },
"trap-lightning": { token: "9A", weapon: "hth" },
"trap-melee": { token: "M4", weapon: "hth" },
"trap-nova": { token: "9A", weapon: "hth" },
"trap-poisoncloud": { token: "9A", weapon: "hth" },
"trap-vertmissile": { token: "9A", weapon: "hth" },
"trappedsoul1": { token: "10", weapon: "hth" },
"trappedsoul2": { token: "13", weapon: "hth" },
"turret1": { token: "PB", weapon: "hth" },
"turret2": { token: "PB", weapon: "hth" },
"turret3": { token: "PB", weapon: "hth" },
"tyrael1": { token: "TX", weapon: "hth" },
"tyrael2": { token: "TY", weapon: "hth" },
"tyrael3": { token: "TY", weapon: "hth" },
"uberandariel": { token: "AN", weapon: "hth" },
"uberbaal": { token: "42", weapon: "hth" },
"uberdiablo": { token: "DI", weapon: "hth" },
"uberduriel": { token: "DU", weapon: "hth" },
"uberizual": { token: "22", weapon: "hth" },
"ubermephisto": { token: "MP", weapon: "hth" },
"unraveler1": { token: "GY", weapon: "hth" },
"unraveler2": { token: "GY", weapon: "hth" },
"unraveler3": { token: "GY", weapon: "hth" },
"unraveler4": { token: "GY", weapon: "hth" },
"unraveler5": { token: "GY", weapon: "hth" },
"unraveler6": { token: "GY", weapon: "hth" },
"unraveler7": { token: "GY", weapon: "hth" },
"unraveler8": { token: "GY", weapon: "hth" },
"unraveler9": { token: "GY", weapon: "hth" },
"valkyrie": { token: "VK", weapon: "hth" },
"vampire1": { token: "VA", weapon: "hth" },
"vampire2": { token: "VA", weapon: "hth" },
"vampire3": { token: "VA", weapon: "hth" },
"vampire4": { token: "VA", weapon: "hth" },
"vampire5": { token: "VA", weapon: "hth" },
"vampire6": { token: "VA", weapon: "hth" },
"vampire7": { token: "VA", weapon: "hth" },
"vampire8": { token: "VA", weapon: "hth" },
"vampire9": { token: "VA", weapon: "hth" },
"venomlord": { token: "DM", weapon: "hth" },
"vilechild1": { token: "VC", weapon: "hth" },
"vilechild2": { token: "VC", weapon: "hth" },
"vilechild3": { token: "VC", weapon: "hth" },
"vilechild4": { token: "VC", weapon: "hth" },
"vilechild5": { token: "VC", weapon: "hth" },
"vilemother1": { token: "VM", weapon: "hth" },
"vilemother2": { token: "VM", weapon: "hth" },
"vilemother3": { token: "VM", weapon: "hth" },
"vilemother4": { token: "VM", weapon: "hth" },
"vilemother5": { token: "VM", weapon: "hth" },
"vinecreature": { token: "K9", weapon: "hth" },
"vulture1": { token: "VD", weapon: "hth" },
"vulture2": { token: "VD", weapon: "hth" },
"vulture3": { token: "VD", weapon: "hth" },
"vulture4": { token: "VD", weapon: "hth" },
"vulture5": { token: "VD", weapon: "hth" },
"wakeofdestruction": { token: "E9", weapon: "hth" },
"warriv1": { token: "WA", weapon: "hth" },
"warriv2": { token: "WX", weapon: "hth" },
"willowisp1": { token: "WW", weapon: "hth" },
"willowisp2": { token: "WW", weapon: "hth" },
"willowisp3": { token: "WW", weapon: "hth" },
"willowisp4": { token: "WW", weapon: "hth" },
"willowisp5": { token: "WW", weapon: "hth" },
"willowisp6": { token: "WW", weapon: "hth" },
"willowisp7": { token: "WW", weapon: "hth" },
"willowisp8": { token: "WW", weapon: "hth" },
"window1": { token: "VH", weapon: "hth" },
"window2": { token: "VJ", weapon: "hth" },
"wolf": { token: "40", weapon: "hth" },
"wolfrider1": { token: "WR", weapon: "hth" },
"wolfrider2": { token: "WR", weapon: "hth" },
"wolfrider3": { token: "WR", weapon: "hth" },
"worldstoneeffect": { token: "K9", weapon: "hth" },
"wraith1": { token: "WR", weapon: "hth" },
"wraith2": { token: "WR", weapon: "hth" },
"wraith3": { token: "WR", weapon: "hth" },
"wraith4": { token: "WR", weapon: "hth" },
"wraith5": { token: "WR", weapon: "hth" },
"wraith6": { token: "WR", weapon: "hth" },
"wraith7": { token: "WR", weapon: "hth" },
"wraith8": { token: "WR", weapon: "hth" },
"wraith9": { token: "WR", weapon: "hth" },
"yeti": { token: "YE", weapon: "hth" },
"zealot1": { token: "ZZ", weapon: "hth" },
"zealot2": { token: "ZZ", weapon: "hth" },
"zealot3": { token: "ZZ", weapon: "hth" },
"zealot4": { token: "ZZ", weapon: "hth" },
"zealot5": { token: "ZZ", weapon: "hth" },
"zombie": { token: "ZM", weapon: "hth" },
"zombie1": { token: "ZM", weapon: "hth" },
"zombie2": { token: "ZM", weapon: "hth" },
"zombie3": { token: "ZM", weapon: "hth" },
"zombie4": { token: "ZM", weapon: "hth" },
"zombie5": { token: "ZM", weapon: "hth" },
}
/**
* Resolve monster id to token and weapon.
* Unique monster (token, weapon) specs appearing in each Act (1..5),
* used by the offline entity baker to pre-pack all level monsters per act.
*/
export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
1: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "FS", weapon: "hth" }, { token: "CR", weapon: "2ht" }, { token: "SK", weapon: "1hs" }, { token: "BN", weapon: "hth" }, { token: "GM", weapon: "2hs" }, { token: "CR", weapon: "bow" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "SP", weapon: "hth" }, { token: "VA", weapon: "hth" }],
2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }],
3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }],
4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }],
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "XX", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }],
}
/**
* Resolve the visual token and weapon class for a given monster ID.
*/
export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
const spec = MONSTER_ART_MAP[id.toLowerCase()]
if (spec !== undefined) return spec
// Generic fallback if id starts with known prefixes
for (const [key, val] of Object.entries(MONSTER_ART_MAP)) {
const prefix = key.replace(/\d+$/, '')
if (id.toLowerCase().startsWith(prefix)) {
return val
const lower = id.toLowerCase()
if (MONSTER_ART_MAP[lower]) {
return MONSTER_ART_MAP[lower]
}
const base = lower.replace(/\d+$/, '')
for (const [key, spec] of Object.entries(MONSTER_ART_MAP)) {
if (key.startsWith(base)) {
return spec
}
}
return null

View File

@ -1,11 +1,32 @@
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
import { ACT_MONSTER_SPECS, MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
describe('offline pre-baked entity atlases (Issue #30)', () => {
describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #30 & #33)', () => {
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
const hasPacks = existsSync(join(packRoot, 'act1', 'entities', 'char-so.json'))
const indexJsonPath = join(packRoot, 'index.json')
const hasPacks = existsSync(indexJsonPath) && existsSync(join(packRoot, 'act1', 'entities', 'char-so.json'))
it('maps 700+ monster IDs across Acts 1..5 in MONSTER_ART_MAP', () => {
expect(Object.keys(MONSTER_ART_MAP).length).toBeGreaterThanOrEqual(700)
expect(MONSTER_ART_MAP.fallen).toEqual({ token: 'FA', weapon: 'hth' })
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'CW', weapon: 'hth' })
expect(MONSTER_ART_MAP.skeleton).toEqual({ token: 'SK', weapon: 'hth' })
})
it.skipIf(!hasPacks)('includes all 5 acts and 350+ map variants in samples/d2-packs/index.json (Issue #33)', () => {
const index = JSON.parse(readFileSync(indexJsonPath, 'utf8'))
expect(Array.isArray(index.levels)).toBe(true)
expect(index.levels.length).toBeGreaterThanOrEqual(350)
const actsPresent = new Set(index.levels.map((l: { act: number }) => l.act))
expect(actsPresent.has(1)).toBe(true)
expect(actsPresent.has(2)).toBe(true)
expect(actsPresent.has(3)).toBe(true)
expect(actsPresent.has(4)).toBe(true)
expect(actsPresent.has(5)).toBe(true)
})
it.skipIf(!hasPacks)('bakes Sorceress char-so PNG + JSON for Acts 1..5', () => {
for (let act = 1; act <= 5; act += 1) {
@ -24,28 +45,22 @@ describe('offline pre-baked entity atlases (Issue #30)', () => {
expect(meta.groups.length).toBe(32) // 16 walk + 16 stand
const png = readFileSync(pngPath)
// Check PNG magic header
expect(png[0]).toBe(137)
expect(png[1]).toBe(80) // P
expect(png[2]).toBe(78) // N
expect(png[3]).toBe(71) // G
// Read IHDR width and height
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
expect(view.getUint32(16)).toBe(meta.width)
expect(view.getUint32(20)).toBe(meta.height)
}
})
it.skipIf(!hasPacks)('bakes monster PNG + JSON atlases for all mapped monster specs across Acts 1..5', () => {
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
for (const spec of Object.values(MONSTER_ART_MAP)) {
const key = `${spec.token}-${spec.weapon}`
if (!uniqueSpecs.has(key)) uniqueSpecs.set(key, spec)
}
it.skipIf(!hasPacks)('bakes monster PNG + JSON atlases for all per-Act monster specs across Acts 1..5', () => {
for (let act = 1; act <= 5; act += 1) {
const dir = join(packRoot, `act${act}`, 'entities')
for (const spec of uniqueSpecs.values()) {
const specs = ACT_MONSTER_SPECS[act] ?? []
expect(specs.length).toBeGreaterThan(0)
for (const spec of specs) {
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
const jsonPath = join(dir, `${slug}.json`)
const pngPath = join(dir, `${slug}.png`)
@ -55,10 +70,10 @@ describe('offline pre-baked entity atlases (Issue #30)', () => {
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
expect(meta.token).toBe(spec.token)
expect(meta.weapon).toBe(spec.weapon)
expect(meta.directions).toBe(8)
expect([1, 4, 8, 16]).toContain(meta.directions)
expect(meta.walkOffset).toBe(0)
expect(meta.standOffset).toBe(8)
expect(meta.groups.length).toBe(16) // 8 walk + 8 stand
expect(meta.standOffset).toBe(meta.directions)
expect(meta.groups.length).toBe(meta.directions * 2)
const png = readFileSync(pngPath)
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)