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
This commit is contained in:
troytt 2026-09-17 12:36:42 +00:00
parent 9d568ad0c3
commit 04d562a99c
3 changed files with 64 additions and 6 deletions

View File

@ -1567,10 +1567,27 @@ export function planLevelMonsters(
packs.unshift(...suPacks) 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 { return {
levelId, levelId,
levelName: plan.levelName, levelName: plan.levelName,
types: types.map(kind => kind.id), types: allMonsterTypes,
budget, budget,
packs, packs,
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length, elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,

View File

@ -760,13 +760,16 @@ async function buildPackRuntime(
spawn, spawn,
npcs: scene.npcs ?? [], npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [], 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, objects: scene.objects.length,
frames: scene.frames.length, frames: scene.frames.length,
notes: [ notes: [
`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`, `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
...(scene.monsters?.packs && scene.monsters.packs.length > 0 ...(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, pages,
@ -977,7 +980,10 @@ async function loadLiveRuntime(
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [], npcs: [],
monsterPacks: population.packs, 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, objects: level.objects.length,
frames: scene.frames.length, frames: scene.frames.length,
notes, notes,
@ -1899,7 +1905,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
const paletteForEntities = next.palette const paletteForEntities = next.palette
const packEntityBase = next.source === 'pack' ? next.base : undefined const packEntityBase = next.source === 'pack' ? next.base : undefined
const packEntityAct = next.source === 'pack' ? next.act : 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) { if (activeMonsterIds.length > 0) {
void loadMonsterArtMap( void loadMonsterArtMap(
DEFAULT_BASES, DEFAULT_BASES,
@ -2440,7 +2447,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
// Collect monster types to load // 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) { if (activeMonsterIds.length > 0) {
void loadMonsterArtMap( void loadMonsterArtMap(

View File

@ -11,6 +11,14 @@ import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { monsterPlanStatusText } from '../src/scene/act-scene.ts' import { monsterPlanStatusText } from '../src/scene/act-scene.ts'
import * as demoData from '../src/game/demo-data.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') 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(0)).toContain('怪物 0')
expect(monsterPlanStatusText(37)).toBe(' · 怪物 37') 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')
})
}) })