From 04d562a99c429a0ed8d2019d13ccd5c7aeac7e3c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 12:36:42 +0000 Subject: [PATCH 1/4] fix(monsters): include superunique and minion monster IDs in PlannedLevel types Ensure monster art is loaded for all superunique bosses and their minions to prevent red placeholder boxes when they spawn. TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monsters.ts | 19 +++++++++++++++- src/scene/act-scene.ts | 18 ++++++++++----- tests/act-scene-monster-plan.test.ts | 33 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index ed687bd..458dcb5 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -1567,10 +1567,27 @@ export function planLevelMonsters( packs.unshift(...suPacks) } + const allMonsterTypes = [...types.map(kind => kind.id)] + for (const pack of packs) { + for (const member of pack.members) { + if (!allMonsterTypes.includes(member.id)) { + allMonsterTypes.push(member.id) + } + } + } + for (const suSpec of suSpecs) { + if (!allMonsterTypes.includes(suSpec.monsterId)) { + allMonsterTypes.push(suSpec.monsterId) + } + if (!allMonsterTypes.includes(suSpec.minionMonsterId)) { + allMonsterTypes.push(suSpec.minionMonsterId) + } + } + return { levelId, levelName: plan.levelName, - types: types.map(kind => kind.id), + types: allMonsterTypes, budget, packs, elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 1768c82..674cf16 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -760,13 +760,16 @@ async function buildPackRuntime( spawn, npcs: scene.npcs ?? [], monsterPacks: scene.monsters?.packs ?? [], - monsterTypes: scene.monsters?.types ?? [], + monsterTypes: Array.from(new Set([ + ...(scene.monsters?.types ?? []), + ...(scene.monsters?.packs ?? []).flatMap(p => p.members.map(m => m.id)), + ])), objects: scene.objects.length, frames: scene.frames.length, notes: [ `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`, ...(scene.monsters?.packs && scene.monsters.packs.length > 0 - ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${scene.monsters.types.join('、')})`] + ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join('、')})`] : []), ], pages, @@ -977,7 +980,10 @@ async function loadLiveRuntime( spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, npcs: [], monsterPacks: population.packs, - monsterTypes: population.types, + monsterTypes: Array.from(new Set([ + ...population.types, + ...population.packs.flatMap(p => p.members.map(m => m.id)), + ])), objects: level.objects.length, frames: scene.frames.length, notes, @@ -1899,7 +1905,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: const paletteForEntities = next.palette const packEntityBase = next.source === 'pack' ? next.base : undefined const packEntityAct = next.source === 'pack' ? next.act : undefined - const activeMonsterIds = next.monsterTypes + const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id)) + const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds])) if (activeMonsterIds.length > 0) { void loadMonsterArtMap( DEFAULT_BASES, @@ -2440,7 +2447,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined // Collect monster types to load - const activeMonsterIds = runtime.monsterTypes + const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id)) + const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds])) if (activeMonsterIds.length > 0) { void loadMonsterArtMap( diff --git a/tests/act-scene-monster-plan.test.ts b/tests/act-scene-monster-plan.test.ts index f0a9010..ff4d214 100644 --- a/tests/act-scene-monster-plan.test.ts +++ b/tests/act-scene-monster-plan.test.ts @@ -11,6 +11,14 @@ import { describe, expect, it } from 'vitest' import { readFileSync } from 'node:fs' import { monsterPlanStatusText } from '../src/scene/act-scene.ts' import * as demoData from '../src/game/demo-data.ts' +import { planLevelMonsters } from '../src/game/monsters.ts' +import { parseTable } from '../src/game/acts.ts' +import type { D2Table } from '../src/game/acts.ts' + +function mockTable(tsv: string): D2Table { + const encoder = new TextEncoder() + return parseTable(encoder.encode(tsv.trim())) +} const ACT_SCENE_SOURCE = readFileSync(new URL('../src/scene/act-scene.ts', import.meta.url), 'utf8') @@ -43,4 +51,29 @@ describe('no synthetic monster fallback', () => { expect(monsterPlanStatusText(0)).toContain('怪物 0') expect(monsterPlanStatusText(37)).toBe(' · 怪物 37') }) + + it('includes superunique boss and minion monster IDs in PlannedLevel.types', () => { + const levelsTxt = mockTable([ + 'Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3', + '110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1', + ].join('\n')) + const monstatsTxt = mockTable([ + 'Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee', + 'demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1', + 'overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1', + 'siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1', + ].join('\n')) + const monlvlTxt = mockTable([ + 'Level\tAC\tTH\tHP\tDM\tXP', + '35\t100\t100\t100\t100\t100', + ].join('\n')) + const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } + + const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, 'normal') + // overseer2 is Shenk's boss id, and enslaved1 is his minion id, neither in m1..m3 (siegebeast1, demonimp1, enflayer1) + expect(plan.types).toContain('overseer2') + expect(plan.types).toContain('enslaved1') + expect(plan.types).toContain('demonimp1') + }) }) + From 2e73d7b02fd06b2fdf9c5cffdbf222b19150a2cb Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 12:42:44 +0000 Subject: [PATCH 2/4] fix(monster-mapping): update hellbovine token to EC, remove XX tokens, and eliminate startsWith fallback - Update hellbovine token from CW to EC in MONSTER_ART_MAP - Remove invalid XX cut monster entries from MONSTER_ART_MAP and ACT_MONSTER_SPECS - Replace startsWith iteration in resolveMonsterArtSpec with deterministic O(1) exact and base lookup - Prevent XX tokens from leaking into art requests TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monster-mapping.ts | 39 ++++++++------------------------- tests/monster-mapping.test.ts | 27 +++++++++++++++++++++-- tests/packed-entity-art.test.ts | 4 ++-- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/game/monster-mapping.ts b/src/game/monster-mapping.ts index 4c9661e..236f66d 100644 --- a/src/game/monster-mapping.ts +++ b/src/game/monster-mapping.ts @@ -109,11 +109,6 @@ export const MONSTER_ART_MAP: Record = { "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" }, @@ -212,11 +207,6 @@ export const MONSTER_ART_MAP: Record = { "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" }, @@ -345,7 +335,7 @@ export const MONSTER_ART_MAP: Record = { "griswold": { token: "GZ", weapon: "hth" }, "halbu": { token: "20", weapon: "hth" }, "heartofwolverine": { token: "X3", weapon: "hth" }, - "hellbovine": { token: "CW", weapon: "hth" }, + "hellbovine": { token: "EC", weapon: "hth" }, "hellmeteor": { token: "K9", weapon: "hth" }, "hephasto": { token: "5P", weapon: "hth" }, "hratli": { token: "HR", weapon: "hth" }, @@ -375,7 +365,6 @@ export const MONSTER_ART_MAP: Record = { "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" }, @@ -409,17 +398,6 @@ export const MONSTER_ART_MAP: Record = { "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" }, @@ -609,7 +587,6 @@ export const MONSTER_ART_MAP: Record = { "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" }, @@ -765,7 +742,7 @@ export const ACT_MONSTER_SPECS: Record = { 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" }], + 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: "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" }], } /** @@ -773,13 +750,15 @@ export const ACT_MONSTER_SPECS: Record = { */ export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null { const lower = id.toLowerCase() - if (MONSTER_ART_MAP[lower]) { - return MONSTER_ART_MAP[lower] + const direct = MONSTER_ART_MAP[lower] + if (direct && direct.token !== 'XX' && direct.token.trim() !== '') { + return direct } const base = lower.replace(/\d+$/, '') - for (const [key, spec] of Object.entries(MONSTER_ART_MAP)) { - if (key.startsWith(base)) { - return spec + if (base !== lower) { + const baseSpec = MONSTER_ART_MAP[base] + if (baseSpec && baseSpec.token !== 'XX' && baseSpec.token.trim() !== '') { + return baseSpec } } return null diff --git a/tests/monster-mapping.test.ts b/tests/monster-mapping.test.ts index 5c38e11..a72546a 100644 --- a/tests/monster-mapping.test.ts +++ b/tests/monster-mapping.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { resolveMonsterArtSpec, MONSTER_ART_MAP } from '../src/game/monster-mapping.ts' +import { resolveMonsterArtSpec, MONSTER_ART_MAP, ACT_MONSTER_SPECS } from '../src/game/monster-mapping.ts' describe('monster-mapping', () => { it('resolves standard Act 1 monsters', () => { @@ -10,13 +10,36 @@ describe('monster-mapping', () => { expect(resolveMonsterArtSpec('cr_archer1')).toEqual({ token: 'CR', weapon: 'bow' }) }) - it('resolves numbered variants through prefix fallback', () => { + it('resolves numbered variants through exact or base lookup without prefix iteration order', () => { expect(resolveMonsterArtSpec('fallen3')?.token).toBe('FA') expect(resolveMonsterArtSpec('zombie5')?.token).toBe('ZM') expect(resolveMonsterArtSpec('quillrat6')?.token).toBe('SI') + // Suffix with arbitrary letters must not prefix-match + expect(resolveMonsterArtSpec('fallen_fake')).toBeNull() + }) + + it('resolves hellbovine to EC token instead of CW', () => { + expect(resolveMonsterArtSpec('hellbovine')).toEqual({ token: 'EC', weapon: 'hth' }) + expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' }) + }) + + it('does not leak XX tokens or crash on cut monsters', () => { + const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'minion1', 'spiritmummy'] + for (const id of cutMonsters) { + expect(resolveMonsterArtSpec(id)).toBeNull() + } + }) + + it('ACT_MONSTER_SPECS does not contain any XX tokens in any act', () => { + for (const [act, specs] of Object.entries(ACT_MONSTER_SPECS)) { + for (const spec of specs) { + expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX') + } + } }) it('returns null for unknown monsters', () => { expect(resolveMonsterArtSpec('non_existent_monster_xyz')).toBeNull() }) }) + diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts index fd52739..52b8e78 100644 --- a/tests/packed-entity-art.test.ts +++ b/tests/packed-entity-art.test.ts @@ -11,7 +11,7 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 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.hellbovine).toEqual({ token: 'EC', weapon: 'hth' }) expect(MONSTER_ART_MAP.skeleton).toEqual({ token: 'SK', weapon: 'hth' }) }) @@ -103,7 +103,7 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`) } } - expect(uniqueSpecs.size).toBe(66) + expect(uniqueSpecs.size).toBe(65) for (const key of uniqueSpecs) { const slug = `monster-${key}` From fd0f48c391142e4200af552cf6a2aa73116b41f0 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 12:54:09 +0000 Subject: [PATCH 3/4] fix(monsters): derive superunique specs from SuperUniques table and eliminate fallen1 fallback - Replace static CANONICAL_SUPER_UNIQUES_BY_LEVEL mapping with buildSuperUniqueLandmarkSpecs - Parse canonical SuperUniques from game data table CANONICAL_SUPERUNIQUES_TABLE - Export lookupSuperUnique helper to search SuperUniques by id or nameKey - Support optional tables.superuniques in planLevelMonsters - Remove dangerous fallback to fallen1/first monster, gracefully skipping missing boss kinds TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monsters.ts | 513 ++++++++------------------ tests/wilderness-superuniques.test.ts | 90 +++++ 2 files changed, 254 insertions(+), 349 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 458dcb5..b62a896 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -71,7 +71,7 @@ * case on that basis would be guessing; the act bosses get their own pass. */ import type { D2Table } from './acts.ts' -import { cell } from './acts.ts' +import { cell, parseTable } from './acts.ts' import type { MonsterPack, MonsterStats } from './combat.ts' import { Rng } from './rng.ts' @@ -1069,351 +1069,158 @@ export interface SuperUniqueLandmarkSpec { readonly minionMonsterId: string } -/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */ -export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly> = { - // Act 1 - 3: { - id: 'Bishibosh', - name: 'Bishibosh', - monsterId: 'fallenshaman1', - landmark: 'Act 1 - Fallen Camp Bishibosh', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 5, - maxMinions: 7, - minionMonsterId: 'fallen1', - }, - 4: { - id: 'Rakanishu', - name: 'Rakanishu', - monsterId: 'fallen1', - landmark: 'Act 1 - Cairn Stones', - modifiers: ['lightenchant', 'fast'], - minMinions: 6, - maxMinions: 8, - minionMonsterId: 'fallen1', - }, - 5: { - id: 'Treehead WoodFist', - name: 'Treehead WoodFist', - monsterId: 'brute1', - landmark: 'Act 1 - Inifus', - modifiers: ['strong', 'fast'], - minMinions: 3, - maxMinions: 4, - minionMonsterId: 'brute1', - }, - 6: { - id: 'The Countess', - name: 'The Countess', - monsterId: 'corruptrogue5', - landmark: 'Act 1 - Tower 1', - modifiers: ['fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue5', - }, - 17: { - id: 'Blood Raven', - name: 'Blood Raven', - monsterId: 'corruptrogue1', - landmark: 'Act 1 - Graveyard', - modifiers: ['fireenchant', 'fast'], - minMinions: 6, - maxMinions: 8, - minionMonsterId: 'zombie1', - }, - 25: { - id: 'The Countess', - name: 'The Countess', - monsterId: 'corruptrogue5', - landmark: 'Act 1 - Tower 1', - modifiers: ['fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue5', - }, - // Act 2 - 41: { - id: 'Creeping Feature', - name: 'Creeping Feature', - monsterId: 'mummy1', - landmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'mummy1', - }, - 43: { - id: 'Beetleburst', - name: 'Beetleburst', - monsterId: 'beetle2', - landmark: 'Act 2 - Desert Oasis 1', - modifiers: ['magicresistant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'beetle2', - }, - 44: { - id: 'Dark Elder', - name: 'Dark Elder', - monsterId: 'plaguebearer1', - landmark: 'Act 2 - Desert Ruins Elder', - modifiers: ['fast', 'magicresistant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'plaguebearer1', - }, - 45: [ - { - id: 'Bloodwitch the Wild', - name: 'Bloodwitch the Wild', - monsterId: 'huntress2', - landmark: 'Act 2 - Desert Tomb 2', - modifiers: ['cursed', 'strong'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'huntress2', - }, - { - id: 'Fangskin', - name: 'Fangskin', - monsterId: 'clawviper3', - landmark: 'Act 2 - Desert Valley Ruin 2', - modifiers: ['lightenchant', 'fast'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'clawviper3', - }, - ], - 55: { - id: 'Creeping Feature', - name: 'Creeping Feature', - monsterId: 'mummy1', - landmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'mummy1', - }, - 59: { - id: 'Creeping Feature', - name: 'Creeping Feature', - monsterId: 'mummy1', - landmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'mummy1', - }, - 60: { - id: 'Bloodwitch the Wild', - name: 'Bloodwitch the Wild', - monsterId: 'huntress2', - landmark: 'Act 2 - Desert Tomb 2', - modifiers: ['cursed', 'strong'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'huntress2', - }, - 61: { - id: 'Fangskin', - name: 'Fangskin', - monsterId: 'clawviper3', - landmark: 'Act 2 - Desert Valley Ruin 2', - modifiers: ['lightenchant', 'fast'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'clawviper3', - }, - // Act 3 - 76: { - id: 'Sszark the Burning', - name: 'Sszark the Burning', - monsterId: 'spider2', - landmark: 'Act 3 - Spider Cavern', - modifiers: ['strong', 'cursed'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'spider2', - }, - 78: [ - { - id: 'Stormtree', - name: 'Stormtree', - monsterId: 'thornhulk3', - landmark: 'Act 3 - Flayer Jungle Entrance', - modifiers: ['lightenchant', 'fast'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'thornhulk3', - }, - { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - monsterId: 'flayershaman4', - landmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'flayer4', - }, - ], - 80: { - id: 'Battlemaid Sarina', - name: 'Battlemaid Sarina', - monsterId: 'corruptrogue8', - landmark: 'Act 3 - Burbs Temple', - modifiers: ['fast', 'spectralhit'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue8', - }, - 84: { - id: 'Sszark the Burning', - name: 'Sszark the Burning', - monsterId: 'spider2', - landmark: 'Act 3 - Spider Cavern', - modifiers: ['strong', 'cursed'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'spider2', - }, - 88: { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - monsterId: 'flayershaman4', - landmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'flayer4', - }, - 91: { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - monsterId: 'flayershaman4', - landmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'flayer4', - }, - 94: { - id: 'Battlemaid Sarina', - name: 'Battlemaid Sarina', - monsterId: 'corruptrogue8', - landmark: 'Act 3 - Burbs Temple', - modifiers: ['fast', 'spectralhit'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue8', - }, - // Act 4 - 105: { - id: 'Izual', - name: 'Izual', - monsterId: 'izual', - landmark: 'Act 4 - Plains of Despair', - modifiers: ['coldenchant'], - minMinions: 0, - maxMinions: 0, - minionMonsterId: 'izual', - }, - 106: { - id: 'Hephasto the Armorer', - name: 'Hephasto the Armorer', - monsterId: 'hephasto', - landmark: 'Act 4 - Hellforge', - modifiers: ['spectralhit', 'aura'], - minMinions: 0, - maxMinions: 0, - minionMonsterId: 'hephasto', - }, - 107: { - id: 'Hephasto the Armorer', - name: 'Hephasto the Armorer', - monsterId: 'hephasto', - landmark: 'Act 4 - Hellforge', - modifiers: ['spectralhit', 'aura'], - minMinions: 0, - maxMinions: 0, - minionMonsterId: 'hephasto', - }, - // Act 5 - 110: [ - { - id: 'Dac Farren', - name: 'Dac Farren', - monsterId: 'demonimp1', - landmark: 'Act 5 - Barricade Prison 1', - modifiers: ['coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'demonimp1', - }, - { - id: 'Shenk the Overseer', - name: 'Shenk the Overseer', - monsterId: 'overseer2', - landmark: 'Act 5 - Barricade Building', - modifiers: ['strong'], - minMinions: 15, - maxMinions: 20, - minionMonsterId: 'enslaved1', - }, - ], - 111: { - id: 'Eyeback the Unleashed', - name: 'Eyeback the Unleashed', - monsterId: 'bloodlord2', - landmark: 'Act 5 - Barricade Hell Portal N', - modifiers: ['fast', 'strong'], - minMinions: 3, - maxMinions: 5, - minionMonsterId: 'bloodlord2', - }, - 112: { - id: 'Thresh Socket', - name: 'Thresh Socket', - monsterId: 'siegebeast1', - landmark: 'Act 5 - Barricade Snow Unique', - modifiers: ['cursed'], - minMinions: 2, - maxMinions: 4, - minionMonsterId: 'siegebeast1', - }, - 114: { - id: 'Frozenstein', - name: 'Frozenstein', - monsterId: 'yeti2', - landmark: 'Act 5 - Frozen River', - modifiers: ['coldenchant', 'manahit'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'yeti2', - }, - 115: { - id: 'Bonesaw Breaker', - name: 'Bonesaw Breaker', - monsterId: 'reanimatedhorde2', - landmark: 'Act 5 - Glacial Trail', - modifiers: ['strong', 'magicresistant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'reanimatedhorde2', - }, - 124: { - id: 'Nihlathak', - name: 'Nihlathak', - monsterId: 'nihlathakboss', - landmark: 'Act 5 - Halls of Vaught', - modifiers: ['coldenchant'], - minMinions: 6, - maxMinions: 8, - minionMonsterId: 'iceprowler2', - }, +/** Landmark and minion mapping for canonical outdoor and landmark SuperUniques. */ +export interface SuperUniqueLandmarkMapping { + readonly id: string + readonly levelIds: readonly number[] + readonly landmark: string + readonly minionMonsterId?: string } +export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMapping[] = [ + // Act 1 + { id: 'Bishibosh', levelIds: [3], landmark: 'Act 1 - Fallen Camp Bishibosh', minionMonsterId: 'fallen1' }, + { id: 'Rakanishu', levelIds: [4], landmark: 'Act 1 - Cairn Stones', minionMonsterId: 'fallen1' }, + { id: 'Treehead WoodFist', levelIds: [5], landmark: 'Act 1 - Inifus', minionMonsterId: 'brute1' }, + { id: 'The Countess', levelIds: [6, 25], landmark: 'Act 1 - Tower 1', minionMonsterId: 'corruptrogue5' }, + { id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' }, + // Act 2 + { id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' }, + { id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'beetle2' }, + { id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' }, + { id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'huntress2' }, + { id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' }, + // Act 3 + { id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'spider2' }, + { id: 'Stormtree', levelIds: [78], landmark: 'Act 3 - Flayer Jungle Entrance', minionMonsterId: 'thornhulk3' }, + { id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'flayer4' }, + { id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue8' }, + // Act 4 + { id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' }, + { id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' }, + // Act 5 + { id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'demonimp1' }, + { id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'enslaved1' }, + { id: 'Eyeback the Unleashed', levelIds: [111], landmark: 'Act 5 - Barricade Hell Portal N', minionMonsterId: 'bloodlord2' }, + { id: 'Thresh Socket', levelIds: [112], landmark: 'Act 5 - Barricade Snow Unique', minionMonsterId: 'siegebeast1' }, + { id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'yeti2' }, + { id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' }, + { id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'iceprowler2' }, +] + +/** Canonical SuperUniques TSV extracted from game data for landmark bosses. */ +export const CANONICAL_SUPERUNIQUES_TSV = `Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H) +Bishibosh\tBishibosh\tfallenshaman1\t8\t9\t0\t5\t7\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A +Rakanishu\tRakanishu\tfallen1\t17\t6\t0\t6\t8\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A +Treehead WoodFist\tTreehead WoodFist\tbrute1\t5\t6\t0\t3\t4\t0\t0\t0\t0\tAct 1 Super B\tAct 1 (N) Super B\tAct 1 (H) Super B +The Countess\tThe Countess\tcorruptrogue5\t9\t0\t0\t4\t6\t0\t0\t0\t0\tCountess\tCountess (N)\tCountess (H) +Blood Raven\tBlood Raven\tcorruptrogue1\t9\t6\t0\t6\t8\t0\t0\t0\t0\tBlood Raven\tBlood Raven (N)\tBlood Raven (H) +Creeping Feature\tCreeping Feature\tmummy1\t5\t18\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A +Beetleburst\tBeetleburst\tbeetle2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A +Dark Elder\tDark Elder\tplaguebearer1\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B +Bloodwitch the Wild\tBloodwitch the Wild\thuntress2\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B +Fangskin\tFangskin\tclawviper3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super C\tAct 2 (N) Super C\tAct 2 (H) Super C +Sszark the Burning\tSszark the Burning\tspider2\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A +Stormtree\tStormtree\tthornhulk3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B +Witch Doctor Endugu\tWitch Doctor Endugu\tflayershaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B +Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue8\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C +Izual\tIzual\tizual\t18\t0\t0\t0\t0\t0\t0\t0\t0\tIzual\tIzual (N)\tIzual (H) +Hephasto the Armorer\tHephasto the Armorer\thephasto\t27\t30\t0\t0\t0\t0\t0\t0\t0\tHephasto\tHephasto (N)\tHephasto (H) +Dac Farren\tDac Farren\tdemonimp1\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A +Shenk the Overseer\tShenk the Overseer\toverseer2\t5\t0\t0\t15\t20\t0\t0\t0\t0\tShenk\tShenk (N)\tShenk (H) +Eyeback the Unleashed\tEyeback the Unleashed\tbloodlord2\t6\t5\t0\t3\t5\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A +Thresh Socket\tThresh Socket\tsiegebeast1\t7\t0\t0\t2\t4\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B +Frozenstein\tFrozenstein\tyeti2\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B +Bonesaw Breaker\tBonesaw Breaker\treanimatedhorde2\t5\t8\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super C\tAct 5 (N) Super C\tAct 5 (H) Super C +Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)` + +/** Canonical SuperUniques table parsed from canonical game TSV. */ +export const CANONICAL_SUPERUNIQUES_TABLE: D2Table = parseTable( + new TextEncoder().encode(CANONICAL_SUPERUNIQUES_TSV.trim()), +) + +/** + * Look up a SuperUnique boss by id or name from a parsed `SuperUniques.txt` table. + * + * @param table - the parsed `SuperUniques.txt`. + * @param id - the superunique ID or name (case-insensitive). + * @param difficulty - which difficulty's treasure class to read. + * @returns the matched SuperUnique, or undefined if not found. + */ +export function lookupSuperUnique( + table: D2Table, + id: string, + difficulty: Difficulty = 'normal', +): SuperUnique | undefined { + const suList = readSuperUniques(table, difficulty) + const target = id.toLowerCase() + return suList.find(su => su.id.toLowerCase() === target || su.nameKey.toLowerCase() === target) +} + +/** + * Build landmark specifications for canonical SuperUniques from a `SuperUniques.txt` table. + * + * @param table - the parsed `SuperUniques.txt` (defaults to `CANONICAL_SUPERUNIQUES_TABLE`). + * @param difficulty - which difficulty's modifiers and treasure class to read. + * @returns map of levelId to SuperUniqueLandmarkSpec or array of specs. + */ +export function buildSuperUniqueLandmarkSpecs( + table: D2Table = CANONICAL_SUPERUNIQUES_TABLE, + difficulty: Difficulty = 'normal', +): Record { + const suList = readSuperUniques(table, difficulty) + const suMap = new Map() + for (const su of suList) { + suMap.set(su.id.toLowerCase(), su) + if (su.nameKey) { + suMap.set(su.nameKey.toLowerCase(), su) + } + } + + const modNameById = new Map() + for (const m of CANONICAL_ELITE_MODIFIERS) { + modNameById.set(m.id, m.name) + } + + const byLevel: Record = {} + + for (const mapping of CANONICAL_SUPER_UNIQUE_LANDMARKS) { + const su = suMap.get(mapping.id.toLowerCase()) + if (!su) continue + const modifiers = su.modifiers + .map(id => modNameById.get(id)) + .filter((name): name is string => name !== undefined) + const spec: SuperUniqueLandmarkSpec = { + id: su.id, + name: su.nameKey || su.id, + monsterId: su.monsterId, + landmark: mapping.landmark, + modifiers, + minMinions: su.minMinions, + maxMinions: su.maxMinions, + minionMonsterId: mapping.minionMonsterId ?? su.monsterId, + } + for (const levelId of mapping.levelIds) { + if (!byLevel[levelId]) { + byLevel[levelId] = [] + } + byLevel[levelId].push(spec) + } + } + + const result: Record = {} + for (const [lvlStr, specs] of Object.entries(byLevel)) { + const lvl = Number(lvlStr) + result[lvl] = specs.length === 1 ? specs[0]! : specs + } + return result +} + +/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */ +export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly< + Record +> = buildSuperUniqueLandmarkSpecs() + /** What one level's population came out as. */ export interface PlannedLevel { /** `Levels.txt` id. */ @@ -1463,7 +1270,12 @@ export interface PlannedLevel { * @returns the population, empty for a town or an unknown level. */ export function planLevelMonsters( - tables: { readonly levels: D2Table; readonly monstats: D2Table; readonly monlvl: D2Table }, + tables: { + readonly levels: D2Table + readonly monstats: D2Table + readonly monlvl: D2Table + readonly superuniques?: D2Table + }, levelId: number, cells: number, seed: number, @@ -1520,7 +1332,10 @@ export function planLevelMonsters( }) // Check if this level has fixed SuperUnique boss(es) - const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId] + const suMap = tables.superuniques + ? buildSuperUniqueLandmarkSpecs(tables.superuniques, difficulty) + : CANONICAL_SUPER_UNIQUES_BY_LEVEL + const suRaw = suMap[levelId] const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw) ? suRaw : (suRaw !== undefined ? [suRaw] : []) @@ -1530,9 +1345,9 @@ export function planLevelMonsters( for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) { const suSpec = suSpecs[suIdx]! const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f)) - const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value! - const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind - const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind + const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1')) + if (!bossKind) continue + const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3) const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel)) const bossScaled: MonsterStats = { diff --git a/tests/wilderness-superuniques.test.ts b/tests/wilderness-superuniques.test.ts index 7d9cf42..caab34e 100644 --- a/tests/wilderness-superuniques.test.ts +++ b/tests/wilderness-superuniques.test.ts @@ -11,6 +11,9 @@ import { planLevelMonsters, CANONICAL_SUPER_UNIQUES_BY_LEVEL, CANONICAL_ELITE_MODIFIERS, + lookupSuperUnique, + buildSuperUniqueLandmarkSpecs, + CANONICAL_SUPERUNIQUES_TABLE, } from "../src/game/monsters.ts" import { parseTable } from "../src/game/acts.ts" import type { D2Table } from "../src/game/acts.ts" @@ -393,5 +396,92 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => { expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true) expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true) }) + + test("lookupSuperUnique retrieves canonical superuniques by id or name", () => { + const bishibosh = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "Bishibosh") + expect(bishibosh).toBeDefined() + expect(bishibosh?.monsterId).toBe("fallenshaman1") + expect(bishibosh?.minMinions).toBe(5) + expect(bishibosh?.maxMinions).toBe(7) + + const caseInsensitive = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "rakanishu") + expect(caseInsensitive).toBeDefined() + expect(caseInsensitive?.id).toBe("Rakanishu") + expect(caseInsensitive?.monsterId).toBe("fallen1") + + expect(lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "nonexistent")).toBeUndefined() + }) + + test("buildSuperUniqueLandmarkSpecs builds specs from custom SuperUniques table", () => { + const customSuTable = mockTable([ + "Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)", + "Bishibosh\tBishibosh Custom\tfallenshaman2\t6\t28\t0\t8\t10\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A", + ].join("\n")) + + const specsByLevel = buildSuperUniqueLandmarkSpecs(customSuTable) + const bishiboshSpec = specsByLevel[3] + expect(bishiboshSpec).toBeDefined() + expect(Array.isArray(bishiboshSpec)).toBe(false) + const spec = bishiboshSpec as import("../src/game/monsters.ts").SuperUniqueLandmarkSpec + expect(spec.name).toBe("Bishibosh Custom") + expect(spec.monsterId).toBe("fallenshaman2") + expect(spec.minMinions).toBe(8) + expect(spec.maxMinions).toBe(10) + expect(spec.modifiers).toEqual(["fast", "stoneskin"]) + }) + + test("planLevelMonsters skips boss without falling back to fallen1 when boss monster is missing", () => { + const levelsTxt = mockTable([ + "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", + "4\tStony Field\t600\t2\t4\t7\tzombie1\tquillrat1\tbrute1", + ].join("\n")) + // Only define zombie1 in monstats - Rakanishu is fallen1, which is absent + const monstatsTxt = mockTable([ + "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", + "zombie1\tzombie1\tZombie\t7\t1\t2\t4\t6\t10\t20\t10\t20\t2\t5\t20\t1\t1\t1\t1", + ].join("\n")) + const monlvlTxt = mockTable([ + "Level\tAC\tTH\tHP\tDM\tXP", + "7\t100\t100\t100\t100\t100", + ].join("\n")) + const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } + + const plan = planLevelMonsters(tables, 4, 6400, 12345, 3.5, "normal") + // Rakanishu should NOT be spawned as zombie1 or fallen1 (fallen1 fallback was removed) + expect(plan.superUniques?.includes("Rakanishu")).toBeFalsy() + const rakanishuPack = plan.packs.find(p => p.superUniqueId === "Rakanishu") + expect(rakanishuPack).toBeUndefined() + }) + + test("planLevelMonsters uses tables.superuniques when provided", () => { + const levelsTxt = mockTable([ + "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", + "3\tCold Plains\t600\t2\t4\t5\tfallenshaman1\tfallen1\tzombie1", + ].join("\n")) + const monstatsTxt = mockTable([ + "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", + "fallenshaman1\tfallenshaman1\tFallen Shaman\t5\t1\t1\t6\t9\t10\t20\t15\t25\t2\t4\t20\t1\t1\t1\t1", + "fallen1\tfallen1\tFallen\t5\t3\t5\t8\t12\t5\t10\t5\t10\t1\t2\t15\t1\t1\t1\t1", + ].join("\n")) + const monlvlTxt = mockTable([ + "Level\tAC\tTH\tHP\tDM\tXP", + "5\t100\t100\t100\t100\t100", + ].join("\n")) + const customSuTable = mockTable([ + "Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)", + "Bishibosh\tBishibosh Ultra\tfallenshaman1\t6\t0\t0\t3\t3\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A", + ].join("\n")) + + const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt, superuniques: customSuTable } + const plan = planLevelMonsters(tables, 3, 6400, 54321, 3.5, "normal") + + expect(plan.superUniques).toContain("Bishibosh") + const bishiboshPack = plan.packs.find(p => p.superUniqueId === "Bishibosh") + expect(bishiboshPack).toBeDefined() + expect(bishiboshPack?.members[0]?.name).toBe("Bishibosh Ultra") + expect(bishiboshPack?.members[0]?.modifiers).toEqual(["fast"]) + // 1 boss + 3 minions = 4 members + expect(bishiboshPack?.members.length).toBe(4) + }) }) }) From bf101dae66c1ac355ece887c77b1f14a452a8fbc Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:03:21 +0000 Subject: [PATCH 4/4] fix(monsters): wire meleeRange from MonsterArt into reach and reference MonUMod constants TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monsters.ts | 73 ++++++++++++++++++++++++++++++++++------ tests/monsters.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index b62a896..e92e0b2 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -238,6 +238,8 @@ export interface MonsterKind { readonly minions: readonly string[] /** `SetBoss` — the pack leader becomes a unique. */ readonly setBoss: boolean + /** `MeleeRng` — melee reach in sub-tiles from `MonStats2.txt`. */ + readonly meleeRange?: number } /** @@ -751,6 +753,47 @@ export function readEliteModifiers(table: D2Table): EliteModifier[] { return out } +/** + * Read the parameters table embedded in `MonUMod.txt`. + * + * The last two columns of `MonUMod.txt` ('constants' and '*constant desc') form + * a parameters table controlling general properties of champions, uniques, and minions. + * + * @param table - the parsed `MonUMod.txt`. + * @returns map of constant description to numeric value. + */ +export function readMonUModConstants(table: D2Table): Map { + const constants = new Map() + for (const row of table.rows) { + const desc = cell(table, row, '*constant desc').trim() + const val = num(table, row, 'constants', NaN) + if (desc !== '' && !Number.isNaN(val)) { + constants.set(desc, val) + } + } + return constants +} + +/** Canonical constants extracted from `MonUMod.txt`. */ +export const MONUMOD_CONSTANTS = { + championChance: 20, + minionHpPct: 100, + minionHpPctNightmare: 75, + minionHpPctHell: 50, + championHpPct: 200, + championHpPctNightmare: 150, + championHpPctHell: 100, + uniqueHpPct: 300, + uniqueHpPctNightmare: 200, + uniqueHpPctHell: 100, + championToHitPct: 75, + championDmgPct: 100, + minionToHitPct: 50, + uniqueToHitPct: 100, + minionDmgPctStrong: 75, + uniqueDmgPctStrong: 150, +} as const + /** What a monster is: ordinary, or promoted by the level generator. */ export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion' @@ -811,7 +854,7 @@ export function applyEliteModifiers( let damage = base.damage let speed = base.speed if (modifiers.includes('strong')) { - damage = Math.max(1, Math.round(damage * 1.5)) + damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100))) } if (modifiers.includes('fast')) { speed = Math.round(speed * 1.4 * 100) / 100 @@ -944,7 +987,7 @@ export function planMonsterGroups( const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup))) // `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite // packs are champions rather than a unique with minions. - const rank: Exclude = rng.next() < 0.2 ? 'champion' : 'unique' + const rank: Exclude = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) ? 'champion' : 'unique' const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2) const modifiers = rollEliteModifiers(rank, new Rng(modSeed)) groups.push({ kind, count, rank, modifiers }) @@ -977,7 +1020,7 @@ const PLAYER_WALK_VELOCITY = 6 * Most rows have `MeleeRng` 0, meaning "touching". A cell is 80×40, so half a * cell's width is about as close as two bodies get. */ -const DEFAULT_REACH_PX = 40 +export const DEFAULT_REACH_PX = 40 /** * Ticks between monster attacks. @@ -1023,6 +1066,7 @@ export function monsterStatsOf( rng: Rng, walkSpeedPx: number, scale: MonsterScale = UNSCALED, + art?: MonsterArt | { readonly meleeRange?: number } | null, ): MonsterStats { const velocity = kind.velocity > 0 ? kind.velocity : 1 // Roll first, scale second. Scaling the bounds and rolling between them @@ -1032,13 +1076,17 @@ export function monsterStatsOf( // each end is rounded first. const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp)) const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2 + const meleeRng = art?.meleeRange ?? kind.meleeRange + const reach = (meleeRng !== undefined && meleeRng > 0) + ? Math.max(DEFAULT_REACH_PX, meleeRng * PIXELS_PER_SUBTILE) + : DEFAULT_REACH_PX return { id: kind.id, name: kind.nameKey === '' ? kind.id : kind.nameKey, hp: Math.max(1, Math.round((rolled * scale.health) / 100)), damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)), cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS, - reach: DEFAULT_REACH_PX, + reach, aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX, speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)), xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)), @@ -1053,8 +1101,8 @@ export function monsterStatsOf( */ export const ELITE_HEALTH_MULTIPLIER: Readonly, number>> = { normal: 1, - champion: 3, - unique: 4, + champion: 1 + MONUMOD_CONSTANTS.championHpPct / 100, + unique: 1 + MONUMOD_CONSTANTS.uniqueHpPct / 100, } /** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */ @@ -1274,6 +1322,7 @@ export function planLevelMonsters( readonly levels: D2Table readonly monstats: D2Table readonly monlvl: D2Table + readonly monstats2?: D2Table readonly superuniques?: D2Table }, levelId: number, @@ -1297,6 +1346,7 @@ export function planLevelMonsters( if (plan === null) return empty const kinds = readMonsterKinds(tables.monstats, difficulty) + const artMap = tables.monstats2 ? readMonsterArt(tables.monstats2, kinds) : undefined const scaling = readMonsterScaling(tables.monlvl, difficulty) // Every scale lookup goes through here so a missing `MonLvl.txt` row reaches // the caller as data (`missingScalingLevels`) as well as the console. @@ -1316,9 +1366,10 @@ export function planLevelMonsters( const packs: MonsterPack[] = groups.map(group => { const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank] const members: MonsterStats[] = [] + const groupArt = artMap?.get(group.kind.id) for (let i = 0; i < group.count; i += 1) { const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel) - const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level)) + const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt) // An elite pack is one leader plus its minions: the leader carries the // rank's full bonus, the minions a smaller one, which is why a champion // pack reads as "a tough one and its friends" rather than as a wall. @@ -1348,8 +1399,9 @@ export function planLevelMonsters( const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1')) if (!bossKind) continue const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind + const bossArt = artMap?.get(bossKind.id) const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3) - const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel)) + const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt) const bossScaled: MonsterStats = { ...bossBase, id: suSpec.monsterId, @@ -1359,13 +1411,14 @@ export function planLevelMonsters( const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id) const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions) const suMembers: MonsterStats[] = [bossStats] + const minionArt = artMap?.get(minionKind.id) for (let m = 0; m < minionCount; m += 1) { const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel) - const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel)) + const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt) const mScaled: MonsterStats = { ...mBase, id: suSpec.minionMonsterId, - hp: Math.max(1, Math.round(mBase.hp * 2)), + hp: Math.max(1, Math.round(mBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))), } suMembers.push(applyEliteModifiers(mScaled, 'minion', [])) } diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 625980f..12edbc0 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -11,11 +11,15 @@ import { describe, expect, it, vi } from 'vitest' import type { D2Table } from '../src/game/acts.ts' import { DENSITY_CELLS_PER_MONSTER, + DEFAULT_REACH_PX, + ELITE_HEALTH_MULTIPLIER, + MONUMOD_CONSTANTS, monsterBudget, monsterStatsOf, planMonsterGroups, readEliteModifiers, readLevelMonsterPlan, + readMonUModConstants, planLevelMonsters, monsterLevelFor, monsterScaleFor, monsterScaleLookup, @@ -530,6 +534,59 @@ describe('monsterStatsOf', () => { const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' } expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1') }) + + it('calculates reach from art meleeRange with DEFAULT_REACH_PX floor', () => { + const fallen = kinds.get('fallen1')! + expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 0 }).reach).toBe(DEFAULT_REACH_PX) + expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 1 }).reach).toBe(DEFAULT_REACH_PX) + expect(monsterStatsOf(fallen, new Rng(1), WALK, undefined, { meleeRange: 4 }).reach).toBe(64) + }) + + it('falls back to kind.meleeRange when art is omitted', () => { + const withReach: MonsterKind = { ...kinds.get('fallen1')!, meleeRange: 5 } + expect(monsterStatsOf(withReach, new Rng(1), WALK).reach).toBe(80) + const base = kinds.get('fallen1')! + expect(monsterStatsOf(base, new Rng(1), WALK).reach).toBe(DEFAULT_REACH_PX) + }) +}) + +describe('readMonUModConstants and MonUMod constants', () => { + it('reads constant desc and numeric constant values from MonUMod table', () => { + const tableData = table( + ['uniquemod', 'id', 'constants', '*constant desc'], + [ + ['', '', '20', 'champion chance'], + ['', '', '100', 'minion +hp%'], + ['', '', '200', 'champion +hp%'], + ['', '', '300', 'unique +hp%'], + ['', '', '150', 'unique +dmg% (strong)'], + ['', '', '', 'empty row'], + ], + ) + const constants = readMonUModConstants(tableData) + expect(constants.get('champion chance')).toBe(20) + expect(constants.get('minion +hp%')).toBe(100) + expect(constants.get('champion +hp%')).toBe(200) + expect(constants.get('unique +hp%')).toBe(300) + expect(constants.get('unique +dmg% (strong)')).toBe(150) + expect(constants.has('empty row')).toBe(false) + }) + + it('provides canonical MONUMOD_CONSTANTS with expected values', () => { + expect(MONUMOD_CONSTANTS.championChance).toBe(20) + expect(MONUMOD_CONSTANTS.minionHpPct).toBe(100) + expect(MONUMOD_CONSTANTS.championHpPct).toBe(200) + expect(MONUMOD_CONSTANTS.uniqueHpPct).toBe(300) + expect(MONUMOD_CONSTANTS.uniqueDmgPctStrong).toBe(150) + }) + + it('calculates ELITE_HEALTH_MULTIPLIER using MonUMod constants', () => { + expect(ELITE_HEALTH_MULTIPLIER.normal).toBe(1) + expect(ELITE_HEALTH_MULTIPLIER.champion).toBe(1 + MONUMOD_CONSTANTS.championHpPct / 100) + expect(ELITE_HEALTH_MULTIPLIER.unique).toBe(1 + MONUMOD_CONSTANTS.uniqueHpPct / 100) + expect(ELITE_HEALTH_MULTIPLIER.champion).toBe(3) + expect(ELITE_HEALTH_MULTIPLIER.unique).toBe(4) + }) }) describe('readMonsterScaling', () => { @@ -649,6 +706,25 @@ describe('planLevelMonsters reports missing MonLvl rows', () => { }) }) +describe('planLevelMonsters with monstats2 table', () => { + it('wires monster art into monster stats reach', () => { + const monstats2WithReach = table( + ['Id', 'SizeX', 'SizeY', 'pixHeight', 'MeleeRng', 'BaseW', 'TotalPieces', 'dDT'], + [ + ['fallen1', '2', '2', '64', '4', 'hth', '6', '8'], + ['zombie1', '2', '2', '80', '0', 'hth', '1', '8'], + ['quillrat1', '2', '2', '48', '1', 'hth', '1', '8'], + ['quillrat6', '2', '2', '48', '1', 'hth', '1', '8'], + ], + ) + const tables = { levels: LEVELS, monstats: MONSTATS, monlvl: MONLVL, monstats2: monstats2WithReach } + const plan = planLevelMonsters(tables, 2, 6400, 1234, 170) + const fallenMember = plan.packs.flatMap(p => p.members).find(m => m.id === 'fallen1') + expect(fallenMember).toBeDefined() + expect(fallenMember?.reach).toBe(64) + }) +}) + describe('monsterStatsOf with MonLvl scaling', () => { const kinds = readMonsterKinds(MONSTATS) const scaling = readMonsterScaling(MONLVL)