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') + }) }) +