From 2527678c10baa033f81d8e32dbe6e88b7285be7a Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 16 Sep 2026 01:43:11 +0000 Subject: [PATCH] fix(packs): restore full Acts 1..5 index and map/bake all 741 monster IDs across all acts (#33) --- scripts/pack-act-assets.ts | 32 +- scripts/pack-entity-assets.ts | 86 ++- scripts/rebuild-pack-index-and-monsters.ts | 188 +++++ src/game/monster-art.ts | 63 +- src/game/monster-mapping.ts | 800 ++++++++++++++++++++- tests/packed-entity-art.test.ts | 47 +- 6 files changed, 1121 insertions(+), 95 deletions(-) create mode 100644 scripts/rebuild-pack-index-and-monsters.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 3f90ccf..e9848eb 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -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() + for (const item of index.levels as Array<{ label: string }>) { + updatedByLabel.set(item.label, item) + } + const merged: unknown[] = [] + const seenLabels = new Set() + 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}`) diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index c771176..759d042 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -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() - 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 { + 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}`) } diff --git a/scripts/rebuild-pack-index-and-monsters.ts b/scripts/rebuild-pack-index-and-monsters.ts new file mode 100644 index 0000000..e0b74d4 --- /dev/null +++ b/scripts/rebuild-pack-index-and-monsters.ts @@ -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 = {} + 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) +}) diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts index 09c9c20..321f4f7 100644 --- a/src/game/monster-art.ts +++ b/src/game/monster-art.ts @@ -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) diff --git a/src/game/monster-mapping.ts b/src/game/monster-mapping.ts index f0166ed..4c9661e 100644 --- a/src/game/monster-mapping.ts +++ b/src/game/monster-mapping.ts @@ -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 = { - // 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 = { + 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 diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts index 1a6e010..201ef52 100644 --- a/tests/packed-entity-art.test.ts +++ b/tests/packed-entity-art.test.ts @@ -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() - 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)