Merge branch 'fix/issue-58'

消除地图与怪物生成层的静默降级(Issue #58)。

- UNIMPLEMENTED_PASSES 改为从 MAZE_LEVEL_TYPE_PROFILES 推导,不再谎报零缺口
- 删除 maze-objects.ts isOpenFloor 中恒真的 || true
- MonLvl 缺行时告警并上报 missingScalingLevels,新增严格模式
- 怪物美术逐层失败可观测:missingMonsterArt / monsterArtErrors / monsterArtLayerFailures + HUD
- 删除 DEMO_MONSTERS 假刷怪路径与英文子串判城镇的判据
- 5 处伪造遥测改为实测值

验证:typecheck 零错误;vitest 61 files / 954 tests passed(基线 58 / 923),新增 31 个回归测试。
verify:generators 与 origin/main 逐位一致(19 条既有失败未变)。

Closes #58

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 12:10:56 +00:00
commit 9d568ad0c3
16 changed files with 1162 additions and 99 deletions

View File

@ -730,9 +730,10 @@ function buildSceneLinks(
placedSeams.add(edge.to)
}
// 5. Last resort. Several level types — the Act 2 sewers, the tombs, the
// Act 3 dungeons — have an empty `specials` table in `MAZE_LEVEL_TYPE_PROFILES`
// because their DRLG staircase pass has not been transcribed, so they
// 5. Last resort. Several level types — the Act 2 Harem and Basement, the
// Act 3 Spider Cavern, the Act 5 Infernal Pit — have an empty `specials`
// table in `MAZE_LEVEL_TYPE_PROFILES` because their DRLG staircase pass
// has not been transcribed (`unimplementedPassesFor` names them), so they
// stamp no stair room, and their pieces carry no marker either. Leaving
// those edges unplaced makes a level you can enter and never leave, which
// is a worse lie than an approximate staircase. Each one is spread to a

View File

@ -1,13 +1,12 @@
import type { MonsterStats } from './combat'
import type { ItemBase, Affix } from './items'
import type { SkillDef } from './skills'
import type { NpcDef, QuestDef } from './quests'
export const DEMO_MONSTERS: readonly MonsterStats[] = [
{ id: 'demo-fallen', name: 'Fallen', hp: 12, damage: 3, cooldownTicks: 24, reach: 36, aggroRadius: 220, speed: 80, xp: 8 },
{ id: 'demo-zombie', name: 'Zombie', hp: 30, damage: 6, cooldownTicks: 32, reach: 40, aggroRadius: 170, speed: 46, xp: 15 },
{ id: 'demo-skeleton', name: 'Skeleton', hp: 18, damage: 4, cooldownTicks: 28, reach: 38, aggroRadius: 260, speed: 70, xp: 12 },
]
// `DEMO_MONSTERS` used to live here: three hand-written monsters the act scene
// dropped into any level whose plan came back empty. They stood in for missing
// data, carried invented stats, and — having no `MonStats.txt` ids — rendered as
// red boxes, so a data failure looked like content. A level that plans nothing
// is now empty and says so (issue #58 item 5).
/** Fallback experience thresholds, index 1 = level 1. */
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280]

View File

@ -234,8 +234,12 @@ export function isOpenFloor(cells: readonly (readonly Ds1Cell[])[], cx: number,
const cell = row[cx]
if (!cell) return false
// Must contain floor
const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true))
// Must contain floor *art*. A `Ds1Floor` whose `prop1`, `style` and `sequence`
// are all zero is an empty slot, not a tile: DS1 stores one entry per floor
// layer for every cell, so a one-layer map has a zero-filled entry everywhere
// the level does not paint. The `|| true` that used to close this condition
// made the whole content test vacuous, so objects were placed on void cells.
const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0))
if (!hasFloor) return false
// Must not have a solid, visible wall

View File

@ -594,8 +594,11 @@ function entries(plainSides: readonly string[] = NESW): Omit<SpecialStep, 'kind'
* `specials` covers the replacement tables that live inline in that function.
* The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`,
* `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest —
* are **not** reproduced; they are listed in `stats.unimplementedPasses` so the
* omission is visible rather than silent.
* live in `maze-special-passes.ts` and are named by a row's `specialPass`; each
* one that runs is listed in `stats.specialPassesApplied`. A row with an empty
* `specials` table has no transcribed staircase pass at all, and
* {@link unimplementedPassesFor} reports that gap in `stats.unimplementedPasses`
* so the omission is visible rather than silent.
*/
export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{
@ -850,10 +853,47 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
/**
* Per-act decoration passes D2MOO runs after the layout.
* All 11 core dungeon passes are now implemented in `maze-special-passes.ts`.
* The gaps one level type still has, derived from its own profile row.
*
* This used to be a module constant hardcoded to `[]` with a comment claiming
* every pass was implemented, which made `stats.unimplementedPasses` report
* "no gaps" for a level that stamps no staircase at all. The list is now
* *measured* from {@link MAZE_LEVEL_TYPE_PROFILES}, so it cannot drift away
* from the table it describes:
*
* - **no profile row** — `generateMaze` cannot build the type at all.
* - **an empty `specials` table** — the `Prev`/`Next`/quest replacement rows
* that `DRLGMAZE_GenerateLevel` runs inline have not been transcribed, so the
* level stamps no staircase room and the packer has to invent a fallback warp
* (see `scripts/pack-act-assets.ts`). A `singleRoom` type is exempt: it is one
* fixed `LvlPrest.txt` row whose stairs are part of the artwork.
*
* The 11 per-act decoration passes named in D2MOO (`DRLGMAZE_PlaceAct2TombStuff`
* and the rest) *are* reproduced, in `maze-special-passes.ts`; a type that runs
* one names it in `stats.specialPassesApplied` instead.
*
* @param levelTypeName - the `LvlTypes.txt` name.
* @returns the gaps, empty when the type is fully transcribed.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = []
export function unimplementedPassesFor(levelTypeName: string): readonly string[] {
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
if (profile === undefined) return [`DRLGMAZE_GenerateLevel profile (${levelTypeName})`]
const gaps: string[] = []
if (profile.specials.length === 0 && profile.singleRoom === undefined) {
gaps.push(`DRLGMAZE_GenerateLevel specials table (${levelTypeName})`)
}
return gaps
}
/**
* Every gap {@link unimplementedPassesFor} can name, across all level types.
*
* Non-empty while any level type is still missing its replacement table. Prefer
* the per-level-type function: a caller that reports this constant for one level
* is reporting other levels' gaps as its own.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = MAZE_LEVEL_TYPE_PROFILES
.flatMap(profile => unimplementedPassesFor(profile.levelTypeName))
/* ------------------------------------------------------------------------- *
* Geometry
@ -2327,7 +2367,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
}
: null,
unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: UNIMPLEMENTED_PASSES,
unimplementedPasses: unimplementedPassesFor(levelTypeName),
maxCellsX,
maxCellsY,
boundsRejected: stats.boundsRejected ?? 0,

View File

@ -84,6 +84,33 @@ function blit(
}
}
/**
* One COF layer that did not make it into the composite.
*
* A dropped layer is not a missing monster — it is a monster rendered without
* its head, or its weapon arm. That used to happen behind a bare `catch`, so
* the only evidence was the picture. Every drop is now described here and
* logged at error level.
*/
export interface MonsterLayerFailure {
/** The monster token, e.g. `fa`. */
readonly token: string
/** The animation the layer belonged to, e.g. `wl`. */
readonly animation: string
/** The composite component folder, e.g. `hd`; `null` for a layer type outside {@link MONSTER_COMPONENTS}. */
readonly component: string | null
/** The DCC member that failed, when one was found. */
readonly member: string | null
/**
* - `unknown-component` — the COF names a layer type this port does not map.
* - `no-member` — no DCC in the archives matches the layer's naming convention.
* - `decode` — the DCC was found but could not be read.
*/
readonly reason: 'unknown-component' | 'no-member' | 'decode'
/** The decoder's message, for `decode`. */
readonly message?: string
}
/**
* Result of compositing a monster animation.
*/
@ -93,6 +120,8 @@ export interface MonsterAnimationSheet {
readonly framesPerDirection: number
readonly layers: number
readonly decodedMembers: readonly string[]
/** Layers that were dropped; empty when the monster composited whole. */
readonly layerFailures: readonly MonsterLayerFailure[]
}
/**
@ -141,7 +170,13 @@ 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 }
if (component === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
}
}
const member = findMonsterLayerSprite(
names,
root,
@ -151,17 +186,44 @@ export async function compositeMonsterAnimation(
component,
layer.weaponClass,
)
if (member === undefined) return { sprite: null, member: undefined }
if (member === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
}
}
try {
const bytes = await archives.read(member)
return { sprite: decodeDcc(bytes), member }
} catch {
return { sprite: null, member: undefined }
return { sprite: decodeDcc(bytes), member, failure: undefined }
} catch (err) {
return {
sprite: null,
member: undefined,
failure: {
token: lower,
animation: usedAnim,
component,
member,
reason: 'decode',
message: (err as Error).message,
} as MonsterLayerFailure,
}
}
}))
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
// A monster missing a limb is a data failure, not a cosmetic one, so it is
// said out loud even though the composite still returns a usable sheet.
for (const failure of layerFailures) {
console.error(
`monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) `
+ `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}`
+ `${failure.message !== undefined ? `: ${failure.message}` : ''}`,
)
}
// Determine maximum bounding box across all layer directions
let minX = 0
@ -222,6 +284,7 @@ export async function compositeMonsterAnimation(
framesPerDirection: cof.framesPerDirection,
layers: cof.layers.length,
decodedMembers,
layerFailures,
}
}
@ -242,6 +305,13 @@ export interface LoadedMonsterArt {
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
/**
* COF layers that were dropped while compositing this monster.
*
* Non-empty means the sprite on screen is missing a body part. Pre-baked pack
* art has no layers of its own, so its loader reports an empty list.
*/
readonly layerFailures: readonly MonsterLayerFailure[]
}
/**
@ -276,5 +346,6 @@ export async function loadMonsterAtlas(
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
layerFailures: [...walk.layerFailures, ...stand.layerFailures],
}
}

View File

@ -482,15 +482,85 @@ export function monsterLevelFor(kind: MonsterKind, difficulty: Difficulty, areaL
return areaLevel > 0 ? areaLevel : kind.level[tier]
}
/**
* Whether a missing `MonLvl.txt` row is fatal.
*
* Off by default, because a hand-built fixture legitimately ships a two-row
* table. Turn it on in a bake or a verification script, where a missing row is a
* broken input rather than a deliberately small one.
*/
let scalingStrict = false
/**
* Levels already warned about, so one missing row is one line and not one line
* per monster rolled at that level.
*/
const warnedScaleLevels = new Set<number>()
/**
* Make a missing `MonLvl.txt` row throw instead of warn.
*
* @param strict - true to throw.
*/
export function setMonsterScalingStrict(strict: boolean): void {
scalingStrict = strict
if (!strict) warnedScaleLevels.clear()
}
/** The levels {@link monsterScaleFor} has had to fall back to {@link UNSCALED} for. */
export function monsterScalingMisses(): readonly number[] {
return [...warnedScaleLevels].sort((a, b) => a - b)
}
/** A scale lookup that says whether it found a row. */
export interface MonsterScaleLookup {
readonly scale: MonsterScale
/** True when the level had no `MonLvl.txt` row and {@link UNSCALED} was used. */
readonly missing: boolean
}
/**
* The multipliers for a level, with the miss reported rather than hidden.
*
* The caller needs this distinction because of what {@link UNSCALED} means: the
* formula is `raw × scale ÷ 100`, so falling back to 100 across the board uses
* the *raw table values* as game values. A Fallen then arrives with `MonStats`'
* 21–61 HP instead of the ~3 HP the level-1 row scales it down to, which reads
* as a balance problem and is in fact a missing row.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param level - the level, from {@link monsterLevelFor}.
* @returns the scale and whether the row was missing.
*/
export function monsterScaleLookup(scaling: ReadonlyMap<number, MonsterScale>, level: number): MonsterScaleLookup {
const found = scaling.get(level)
if (found !== undefined) return { scale: found, missing: false }
return { scale: UNSCALED, missing: true }
}
/**
* The multipliers for a level.
*
* A level with no row used to return {@link UNSCALED} silently; it now warns
* once per level, and throws when {@link setMonsterScalingStrict} is on. Callers
* that need to react rather than read the console use {@link monsterScaleLookup}.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param level - the level, from {@link monsterLevelFor}.
* @returns the scale, or {@link UNSCALED} when the level has no row.
* @throws when strict mode is on and the level has no row.
*/
export function monsterScaleFor(scaling: ReadonlyMap<number, MonsterScale>, level: number): MonsterScale {
return scaling.get(level) ?? UNSCALED
const lookup = monsterScaleLookup(scaling, level)
if (!lookup.missing) return lookup.scale
const message = `MonLvl.txt has no row for level ${String(level)}: `
+ `monsters there keep their raw MonStats values (about 20x too strong)`
if (scalingStrict) throw new Error(message)
if (!warnedScaleLevels.has(level)) {
warnedScaleLevels.add(level)
console.warn(message)
}
return lookup.scale
}
/** What `Levels.txt` says should live on one level. */
@ -1358,6 +1428,15 @@ export interface PlannedLevel {
readonly packs: readonly MonsterPack[]
/** How many packs are champion or unique. */
readonly elitePacks: number
/**
* Levels whose `MonLvl.txt` row was missing while rolling this population.
*
* Non-empty means some of {@link PlannedLevel.packs} carry raw `MonStats.txt`
* numbers rather than scaled ones — roughly 20x too strong — because
* {@link monsterScaleFor} had to fall back to {@link UNSCALED}. Empty is the
* healthy case, and the only one a bake should accept.
*/
readonly missingScalingLevels: readonly number[]
/** Fixed SuperUnique bosses planned for this level (e.g. Bishibosh, Rakanishu). */
readonly superUniques?: readonly string[]
}
@ -1399,12 +1478,21 @@ export function planLevelMonsters(
}[]
},
): PlannedLevel {
const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 }
const empty: PlannedLevel = {
levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0, missingScalingLevels: [],
}
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty)
if (plan === null) return empty
const kinds = readMonsterKinds(tables.monstats, difficulty)
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.
const missingScaling = new Set<number>()
const scaleAt = (level: number): MonsterScale => {
if (monsterScaleLookup(scaling, level).missing) missingScaling.add(level)
return monsterScaleFor(scaling, level)
}
// Three independent streams off the one seed. Sharing a single stream would
// make the type choice depend on how many monsters the density happened to
// ask for, so a level would change its monsters when its size changed.
@ -1418,7 +1506,7 @@ export function planLevelMonsters(
const members: MonsterStats[] = []
for (let i = 0; i < group.count; i += 1) {
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, monsterScaleFor(scaling, level))
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level))
// 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.
@ -1446,7 +1534,7 @@ export function planLevelMonsters(
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel))
const bossScaled: MonsterStats = {
...bossBase,
id: suSpec.monsterId,
@ -1458,7 +1546,7 @@ export function planLevelMonsters(
const suMembers: MonsterStats[] = [bossStats]
for (let m = 0; m < minionCount; m += 1) {
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel))
const mScaled: MonsterStats = {
...mBase,
id: suSpec.minionMonsterId,
@ -1486,6 +1574,7 @@ export function planLevelMonsters(
budget,
packs,
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
missingScalingLevels: [...missingScaling].sort((a, b) => a - b),
...(superUniques.length > 0 ? { superUniques } : {}),
}
}

View File

@ -766,14 +766,25 @@ export function initAct4OutdoorLevel(
}
}
} else {
// Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus
// Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus.
//
// Not written yet. This loop walks a 4-cell stride grid looking for room,
// and it used to add every free sample to `plateauCells` without painting
// anything — so the stats claimed hundreds of plateau cells on a map that
// had none. Count the sites as candidates and say so, rather than counting
// the survey as work done.
let candidateSites = 0
for (let y = 12; y < height - 12; y += 4) {
for (let x = 12; x < width - 12; x += 4) {
if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) {
plateauCells += 1
candidateSites += 1
}
}
}
stats.notes.push(
`Act 4 basalt plateaus not implemented for level ${String(request.levelId)}: ` +
`${String(candidateSites)} candidate sites left unpainted`,
)
}
stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells }

View File

@ -282,6 +282,50 @@ function synthesizeBridgePlanks(
}
}
/**
* Measure what the jungle pass actually painted, cell by cell.
*
* The counters used to be literals (`junglePathCells: 100`), so a pass that
* stamped nothing — no clearing pieces in the MPQ, a canvas too small for the
* macro-grid — still reported a healthy-looking jungle. Counting the finished
* canvas means an empty level reports zeros and the gap is visible.
*
* A cell counts as painted when it carries a visible, non-blank floor. Bridge
* planks (style 4, prop1 2) are skipped here: they are decks laid *over* the
* water and are reported as bridges, not as water or as ground.
*
* @param canvas - the level being stamped.
* @param grid - the macro-grid that decided which 32x32 block is river.
* @returns the painted ground and water cell counts.
*/
function measureJungleTopography(
canvas: Canvas,
grid: readonly (readonly MacroGridCell[])[],
): { canalCells: number; junglePathCells: number } {
let canalCells = 0
let junglePathCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
const gridRow = grid[Math.floor(y / 32)]
if (gridRow === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell === undefined) continue
const floor = cell.floors.find(
f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0),
)
if (floor === undefined) continue
if (floor.style === 4 && floor.prop1 === 2) continue
const gridCell = gridRow[Math.floor(x / 32)]
if (gridCell === undefined) continue
if (gridCell.type === 'river') canalCells += 1
else junglePathCells += 1
}
}
return { canalCells, junglePathCells }
}
/**
* `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm.
*
@ -588,7 +632,9 @@ export function DRLG_GenerateJungles(
topology,
}
// Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography
let canonicalCrossings = 0
for (const by of [78, 142]) {
let plankCells = 0
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) {
@ -602,18 +648,29 @@ export function DRLG_GenerateJungles(
unknown2: 0,
hidden: false,
}
plankCells += 1
}
}
}
}
// A crossing off the bottom of a short canvas paints nothing, so it is not
// a crossing. Counting the attempt is how `riverBridges` used to claim 2.
if (plankCells > 0) canonicalCrossings += 1
}
const measured = measureJungleTopography(canvas, grid)
stats.act3Topography = {
// Causeways are a Kurast feature; the jungle pass carves none, and 0 here
// is a measurement, not a placeholder.
causewayCells: 0,
riverBridges: Math.max(2, bridges.length),
canalCells: 0,
junglePathCells: 100,
riverBridges: bridges.length + canonicalCrossings,
canalCells: measured.canalCells,
junglePathCells: measured.junglePathCells,
}
stats.notes.push(
`Act 3 Jungle Topography: ${String(bridges.length + canonicalCrossings)} river bridges, ` +
`${String(measured.junglePathCells)} jungle ground cells, ${String(measured.canalCells)} river water cells`,
)
stats.bridgesPlaced = bridges.length
stats.riverIslands = islands.length

View File

@ -2434,7 +2434,14 @@ function applySubstitutions(
dirtPathGrid?: Uint8Array,
): number {
let total = 0
let unlimitedBudget = Number.POSITIVE_INFINITY
/**
* `BordType` 0 rows share one cluster across the whole level.
*
* This started at `Infinity`, so `Math.min(1, budget)` was always 1 and the
* subtraction below never bit: every `BordType` 0 row got its own cluster,
* which is the `BordType` 1 rule. Starting at 1 makes the cap real.
*/
let sharedBudget = 1
for (const row of rows) {
const role = classifySubstitutionRole(row.name, row.type)
const chance = row.prob[themeIndex] ?? 0
@ -2442,14 +2449,14 @@ function applySubstitutions(
let clusters = 0
if (enabled) {
// `BordType` 0 = one cluster for the whole level, 1 = one per row.
const allowance = row.bordType === 0 ? Math.min(1, unlimitedBudget) : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY
const allowance = row.bordType === 0 ? sharedBudget : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY
const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
const wanted = Math.min(rowMax, allowance)
if (wanted > 0) {
const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid)
clusters = Math.min(wanted, single)
if (row.bordType !== 0 && row.bordType !== 1) clusters = single
if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters)
if (row.bordType === 0) sharedBudget = Math.max(0, sharedBudget - clusters)
total += clusters
}
}
@ -5046,7 +5053,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: 0,
// The special-preset path clamps `SubTheme` exactly like the main path
// does; it used to report a flat 0, so every special level looked like
// theme 0 in the verification reports.
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
groundTile: primaryGroundTile,
groundTiles: groundTiles ?? undefined,
groundCells: stats.groundCells,

View File

@ -35,7 +35,7 @@ import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts'
import { spawnMonsterPacks } from '../game/combat.ts'
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
import type { StreamingRoomDef } from '../game/monster-streaming.ts'
@ -44,7 +44,7 @@ import type { MapRoom } from '../game/monster-rooms.ts'
import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import { DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import type { NpcDef } from '../game/quests.ts'
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
@ -103,15 +103,6 @@ const PAGE_CONCURRENCY = 3
/** Placeholder marker size, in scene pixels. */
const MARKER_WIDTH = 16
const MARKER_HEIGHT = 26
/**
* Monsters placed per level, until `Levels.txt` supplies the real number.
*
* `MonDen` (density) and `NumMon` (how many kinds) are both real columns that
* #22 will read; 12 is only large enough that a walk across a level runs into
* something, which is what M6 needs to demonstrate that the level → spawn path
* works at all.
*/
const PLACEHOLDER_MONSTER_COUNT = 12
/**
* Side of a streaming room, in cells.
*
@ -238,6 +229,26 @@ export interface ActSceneState {
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
/**
* Monster ids the art loader could not dress, lower case.
*
* Each one renders as a red placeholder box. The scene stays `ready` — it can
* still be walked — but it is not showing the level it claims to.
*/
missingMonsterArt: string[]
/** Monster-art loads that threw: a whole level, or one monster type. */
monsterArtErrors: number
/** COF layers dropped inside art that did load: monsters missing body parts. */
monsterArtLayerFailures: number
/**
* Monsters the level's own plan produced.
*
* Zero is a real answer — a town has none — and it is now the *only* answer
* for a level nothing planned for. The scene used to drop 12 hand-written
* `DEMO_MONSTERS` into that case, which also rendered as red boxes because
* their ids are in no art table.
*/
monstersPlanned: number
}
declare global {
@ -257,6 +268,7 @@ const state: ActSceneState = {
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
}
if (typeof window !== 'undefined') window.__d2webAct = state
@ -332,25 +344,14 @@ interface MapRuntime {
* `MonStats.txt`.
*
* Empty when the level has none — a town — and empty for a source that has
* not planned one, which is what {@link ActRuntime.monsterStats} below is
* still there for.
* not planned one. There is no fallback: a level nothing planned for stays
* empty and says so, rather than being filled with invented monsters.
*/
readonly monsterPacks: readonly MonsterPack[]
/**
* The level's monster types, by `MonStats.txt` id, for the status line.
*/
readonly monsterTypes: readonly string[]
/**
* Fallback monster definitions, used only when the level produced no packs.
*
* A level with no `Levels.txt` row — a hand-built fixture, or a pack baked
* before the monster columns were read — would otherwise be empty. Spawning a
* few placeholders there keeps the page useful to poke at, and the status
* line says which of the two happened.
*/
readonly monsterStats: readonly MonsterStats[]
/** How many of {@link ActRuntime.monsterStats} to place in that fallback. */
readonly monsterCount: number
readonly objects: number
readonly frames: number
/** Whether the real character art is in use. */
@ -760,8 +761,6 @@ async function buildPackRuntime(
npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [],
monsterTypes: scene.monsters?.types ?? [],
monsterStats: DEMO_MONSTERS,
monsterCount: (entry.slug.includes('town') || scene.levelName.toLowerCase().includes('town') || (scene.monsters?.packs && scene.monsters.packs.length > 0) || scene.monsters !== undefined) ? 0 : PLACEHOLDER_MONSTER_COUNT,
objects: scene.objects.length,
frames: scene.frames.length,
notes: [
@ -979,8 +978,6 @@ async function loadLiveRuntime(
npcs: [],
monsterPacks: population.packs,
monsterTypes: population.types,
monsterStats: DEMO_MONSTERS,
monsterCount: (town.levelName.toLowerCase().includes('town') || population.packs.length > 0 || population.budget === 0) ? 0 : PLACEHOLDER_MONSTER_COUNT,
objects: level.objects.length,
frames: scene.frames.length,
notes,
@ -1141,6 +1138,110 @@ interface PackedCharJson {
readonly groups: readonly (readonly [number, number, number, number])[][]
}
/**
* What a monster-art load produced, including what it failed to produce.
*
* The three failure channels used to be a `console.warn` and an empty `Map`,
* which the render loop turns into a red box per monster while the scene still
* reports `ready`. They are counted here so {@link ActSceneState} — and through
* it the HUD — can say so.
*/
export interface MonsterArtLoad {
/** Monster id (lower case) to its atlas. */
readonly map: Map<string, LoadedMonsterArt>
/** Monster ids left with no atlas: every one of these draws as a red box. */
readonly missing: string[]
/** Loads that threw — a per-spec failure, or the whole function. */
readonly errors: number
/** COF layers dropped inside the art that *did* load: monsters missing limbs. */
readonly layerFailures: number
}
/**
* The monster ids that got no atlas.
*
* @param monsterIds - the ids the level asked for.
* @param loaded - the ids that loaded, lower case.
* @returns the missing ids, lower case and deduplicated, in request order.
*/
export function missingMonsterArtIds(
monsterIds: readonly string[],
loaded: ReadonlySet<string>,
): string[] {
const missing: string[] = []
for (const id of monsterIds) {
const key = id.toLowerCase()
if (!loaded.has(key) && !missing.includes(key)) missing.push(key)
}
return missing
}
/**
* The HUD fragment describing a monster-art load, empty when it was clean.
*
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterArtStatusText(load: {
readonly missing: readonly string[]
readonly errors: number
readonly layerFailures: number
}): string {
const parts: string[] = []
if (load.missing.length > 0) {
const shown = load.missing.slice(0, 3).join('、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
parts.push(`怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`)
}
if (load.errors > 0) parts.push(`加载错误 ${String(load.errors)}`)
if (load.layerFailures > 0) parts.push(`缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
/**
* The HUD fragment describing a level's planned population.
*
* @param planned - how many monsters the plan produced.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterPlanStatusText(planned: number): string {
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
}
/**
* Publish a level's planned population into {@link ActSceneState}.
*
* @param runtime - the level being entered.
*/
function reportMonsterPlan(runtime: MapRuntime): void {
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
state.monstersPlanned = planned
if (planned === 0) {
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
}
}
/**
* Publish a load's failures into {@link ActSceneState}.
*
* Both the boot path and the level-change path call the loader, and both used to
* drop everything but the `Map` on the floor.
*
* @param load - what the loader produced.
*/
function recordMonsterArtLoad(load: MonsterArtLoad): void {
state.missingMonsterArt = [...load.missing]
state.monsterArtErrors = load.errors
state.monsterArtLayerFailures = load.layerFailures
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
console.error(
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
+ `${String(load.layerFailures)} dropped layer(s)`,
)
}
}
/**
* Load monster atlases on-demand for all monster types present in the scene.
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
@ -1153,9 +1254,11 @@ async function loadMonsterArtMap(
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<Map<string, LoadedMonsterArt>> {
): Promise<MonsterArtLoad> {
const map = new Map<string, LoadedMonsterArt>()
if (monsterIds.length === 0) return map
let errors = 0
let layerFailures = 0
if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures }
try {
// Resolve unique (token, weapon) specs needed
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
@ -1200,11 +1303,15 @@ async function loadMonsterArtMap(
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
// Pre-baked art is already composited: there are no COF layers
// left to drop at load time.
layerFailures: [],
})
return
}
} catch {
// fall through to per-act PNG fallback
} catch (err) {
// Fall through to the per-act PNG, but do not pretend it did not happen.
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
}
// 2. Fallback to per-act PNG if available
@ -1235,11 +1342,13 @@ async function loadMonsterArtMap(
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
layerFailures: [],
})
return
}
} catch {
// fall back to MPQ loading below
} catch (err) {
// Fall back to MPQ loading below, with the reason on the record.
console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`)
}
}
fallbackSpecs.push([key, spec])
@ -1259,13 +1368,18 @@ async function loadMonsterArtMap(
const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer)
loadedBySpecKey.set(key, loaded)
} catch (err) {
console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
// Every monster of this type will be a red box; say which type and
// count it so the scene cannot claim to be fully dressed.
errors += 1
console.error(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
}
})
)
}
}
for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length
// Map monster id to loaded art
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
@ -1278,9 +1392,12 @@ async function loadMonsterArtMap(
}
}
} catch (err) {
console.warn(`monster art loading error: ${(err as Error).message}`)
// This `catch` wraps the whole function, so landing here means *no* monster
// in the level has art. That is a broken scene, not a warning.
errors += 1
console.error(`monster art loading error: ${(err as Error).message}`)
}
return map
return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures }
}
async function loadCharacterArt(
@ -1576,7 +1693,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
}),
{
spawn: runtime.spawn,
stats: runtime.monsterStats,
// The scene plans its own population; there is no synthetic roster to fall
// back on, so the engine's frame-0 spawner has nothing to draw from.
stats: [],
xpTable: DEMO_EXPERIENCE,
itemBases: DEMO_BASES,
prefixAffixes: DEMO_AFFIXES,
@ -1602,10 +1721,10 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
// `monsterCount` / `monsterPacks` are the fallback for levels the baker
// gave no pack plan: with rooms present the engine skips frame-0 spawning
// entirely and lets the streaming manager do it room by room.
monsterCount: runtime.monsterCount,
// With rooms present the engine skips frame-0 spawning entirely and lets
// the streaming manager do it room by room; with none it places the
// planned packs at once. A level that planned nothing gets nothing.
monsterCount: 0,
monsterPacks: resolvedPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
roomPacks: streamingRooms,
@ -1765,24 +1884,22 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
},
)
} else {
// Levels the baker gave no pack plan keep the old behaviour: there is
// nothing to stream, so the handful of placeholders go down at once.
// Nothing to stream: the planned packs go down at once. A level the baker
// gave no plan for stays empty — and the status line says so, rather than
// 12 invented monsters standing in for the data that is missing.
engine.streamingManager = undefined
if (nextResolvedPacks.length > 0) {
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
} else if (next.monsterCount > 0) {
spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain)
}
}
reportMonsterPlan(next)
if (next.palette !== null) {
renderer.setPalette(next.palette.rgb)
const paletteForEntities = next.palette
const packEntityBase = next.source === 'pack' ? next.base : undefined
const packEntityAct = next.source === 'pack' ? next.act : undefined
const activeMonsterIds = next.monsterTypes.length > 0
? next.monsterTypes
: (next.monsterCount > 0 ? next.monsterStats.map(s => s.id) : [])
const activeMonsterIds = next.monsterTypes
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(
DEFAULT_BASES,
@ -1791,8 +1908,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
).then((loaded) => {
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
})
}
}
@ -2169,7 +2287,15 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
return
}
}
// Fallback to placeholder solid box if art not loaded or frame missing
// Fallback to placeholder solid box if art not loaded or frame missing.
// Streaming can spawn a type the art load never saw (a super unique's
// minions, say), so the box itself reports — otherwise the only record
// of it is the screen.
const missingId = monster.stats.id.toLowerCase()
if (!state.missingMonsterArt.includes(missingId)) {
state.missingMonsterArt.push(missingId)
console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`)
}
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1])
})
}
@ -2276,10 +2402,19 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
+ ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
// A level whose monsters are all red boxes used to look exactly like a
// healthy one here.
+ monsterPlanStatusText(state.monstersPlanned)
+ monsterArtStatusText({
missing: state.missingMonsterArt,
errors: state.monsterArtErrors,
layerFailures: state.monsterArtLayerFailures,
})
}
},
})
loop.start()
reportMonsterPlan(runtime)
state.ready = true
state.loadMs = Math.round(performance.now() - started)
// Only the live path fills the selectors here: the pack path already built them from
@ -2305,9 +2440,7 @@ 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.length > 0
? runtime.monsterTypes
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
const activeMonsterIds = runtime.monsterTypes
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(
@ -2317,8 +2450,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
).then((loaded) => {
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
})
}

View File

@ -0,0 +1,46 @@
/**
* The act scene must not invent monsters (issue #58 item 5).
*
* `DEMO_MONSTERS` was three hand-written monsters the scene spawned 12 of
* whenever a level's plan came back empty — including in a town whose name did
* not happen to contain the English substring "town". They also rendered as red
* boxes, because `demo-*` ids are in no art table, so a data failure looked like
* content twice over.
*/
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'
const ACT_SCENE_SOURCE = readFileSync(new URL('../src/scene/act-scene.ts', import.meta.url), 'utf8')
/**
* Comments are stripped before scanning: the docstrings deliberately still name
* the deleted fallback to explain why it is gone, and that is not a code path.
*/
const ACT_SCENE_CODE = ACT_SCENE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
describe('no synthetic monster fallback', () => {
it('demo-data no longer exports a monster roster', () => {
expect(Object.keys(demoData)).not.toContain('DEMO_MONSTERS')
// The other demo tables are still used by the scene's engine options.
expect(Object.keys(demoData)).toContain('DEMO_SKILLS')
})
it('the act scene has no placeholder spawn path left', () => {
expect(ACT_SCENE_CODE).not.toContain('DEMO_MONSTERS')
expect(ACT_SCENE_CODE).not.toContain('PLACEHOLDER_MONSTER_COUNT')
// The spawn gate was an English substring match on the *display* name, so a
// renamed or translated town spawned 12 fake monsters in the square. (The
// pack index's `slug.includes('town')` level lookup is a different thing
// and is still there.)
expect(ACT_SCENE_CODE).not.toContain("toLowerCase().includes('town')")
expect(ACT_SCENE_CODE).not.toContain('spawnMonsters(')
})
it('reports an empty plan rather than hiding it', () => {
expect(monsterPlanStatusText(0)).toContain('本关未规划到怪物')
expect(monsterPlanStatusText(0)).toContain('怪物 0')
expect(monsterPlanStatusText(37)).toBe(' · 怪物 37')
})
})

View File

@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
import {
populateDungeonObjects,
isOpenFloor,
CANONICAL_SHRINES,
CHEST_IDS_BY_ACT,
RACK_IDS_BY_ACT,
@ -482,3 +483,56 @@ describe('generateMaze end-to-end dynamic object integration', () => {
expect(foundStatic).toBe(true)
})
})
/**
* Regression cover for the `|| true` that used to close `isOpenFloor`'s content
* test (issue #58 item 2). With it, every `Ds1Floor` entry counted as floor art,
* so objects were seeded on cells the level never paints.
*/
describe('isOpenFloor content check', () => {
/** One cell grid with the given floor entries and no walls. */
function grid(floors: Ds1Cell['floors']): Ds1Cell[][] {
return [[{ walls: [], floors, shadows: [], substitutions: [] }]]
}
const painted = { prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
const empty = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
test('a painted floor cell is open floor', () => {
expect(isOpenFloor(grid([painted]), 0, 0)).toBe(true)
})
test('a cell whose only floor entry is all-zero is not open floor', () => {
expect(isOpenFloor(grid([empty]), 0, 0)).toBe(false)
})
test('style or sequence alone still counts as art', () => {
expect(isOpenFloor(grid([{ ...empty, style: 3 }]), 0, 0)).toBe(true)
expect(isOpenFloor(grid([{ ...empty, sequence: 7 }]), 0, 0)).toBe(true)
})
test('a hidden painted floor does not rescue an otherwise empty cell', () => {
expect(isOpenFloor(grid([{ ...painted, hidden: true }]), 0, 0)).toBe(false)
})
test('a multi-layer cell is open floor when any layer carries art', () => {
expect(isOpenFloor(grid([empty, painted]), 0, 0)).toBe(true)
expect(isOpenFloor(grid([empty, empty]), 0, 0)).toBe(false)
})
test('objects are not seeded into a level whose floors are all empty slots', () => {
const { level, rooms } = createSyntheticDungeon()
for (const row of level.cells) {
for (const cell of row) {
;(cell.floors as Ds1Cell['floors'][number][]).splice(
0,
cell.floors.length,
{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false },
)
}
}
const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { density: 1.0 }, new Rng(7))
expect(result.placedObjects.length).toBe(0)
})
})

View File

@ -13,7 +13,8 @@
* 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect)
* 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers)
* 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint)
* 12. Full generateMaze integration tests verifying specialPassesApplied and UNIMPLEMENTED_PASSES == [].
* 12. Full generateMaze integration tests verifying specialPassesApplied and the
* per-level-type `unimplementedPasses` gap list.
*/
import { describe, expect, test } from 'vitest'
@ -21,7 +22,9 @@ import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
import {
generateMaze,
classifyMazePieceName,
MAZE_LEVEL_TYPE_PROFILES,
UNIMPLEMENTED_PASSES,
unimplementedPassesFor,
type MazePiece,
type MazePieceKind,
type MazeRequest,
@ -106,11 +109,64 @@ function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[]
}
describe('Diablo II DRLG Maze Special Passes', () => {
test('UNIMPLEMENTED_PASSES is completely empty', () => {
expect(UNIMPLEMENTED_PASSES).toEqual([])
expect(UNIMPLEMENTED_PASSES.length).toBe(0)
// Was `expect(UNIMPLEMENTED_PASSES).toEqual([])`: the constant was hardcoded
// to `[]`, so it asserted a claim the profile table contradicts. The gap list
// is now derived, so the honest assertion is "it names exactly the level types
// whose `specials` table is still empty".
test('UNIMPLEMENTED_PASSES names every level type with no transcribed specials table', () => {
const emptySpecials = MAZE_LEVEL_TYPE_PROFILES
.filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined)
.map(profile => profile.levelTypeName)
expect(emptySpecials.length).toBeGreaterThan(0)
expect(UNIMPLEMENTED_PASSES).toEqual(
emptySpecials.map(name => `DRLGMAZE_GenerateLevel specials table (${name})`),
)
})
test('unimplementedPassesFor is empty for a fully transcribed level type', () => {
expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([])
expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([])
})
test('unimplementedPassesFor reports the gap for a level type with no specials', () => {
expect(unimplementedPassesFor('Act 3 - Spider'))
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
})
test('unimplementedPassesFor reports a level type that has no profile at all', () => {
expect(unimplementedPassesFor('Act 9 - Nowhere'))
.toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)'])
})
test('generateMaze reports the level type gap, not a global constant', () => {
const spider = generateMaze({
levelId: 84,
levelName: 'Spider Cavern',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 8484,
pieces: createStandardPieceSet('Act 3 - Spider'),
})
expect(spider.stats.unimplementedPasses)
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
// The other two diagnostic channels must travel with it.
expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true)
expect(Array.isArray(spider.stats.notes)).toBe(true)
const cave = generateMaze({
levelId: 8,
levelName: 'Den of Evil',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 8484,
pieces: createStandardPieceSet('Act 1 - Cave'),
})
expect(cave.stats.unimplementedPasses).toEqual([])
})
/* ----------------------------------------------------------------------- *
* 1. Act 2 - Tomb
* ----------------------------------------------------------------------- */

View File

@ -0,0 +1,138 @@
/**
* Monster art failures must be observable (issue #58 item 3).
*
* Three `catch` blocks used to turn "the monster art system is broken" into "the
* scene is ready and every monster is a red box", with at most one
* `console.warn`. These tests pin the two halves of the fix:
*
* - `compositeMonsterAnimation` reports every COF layer it drops, rather than
* rendering a monster without its head and saying nothing.
* - `act-scene` can tell which monster ids ended up with no art, and says so in
* the HUD.
*/
import { describe, expect, it, vi } from 'vitest'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import type { MountedArchives } from '../src/mpq/mount.ts'
import { missingMonsterArtIds, monsterArtStatusText } from '../src/scene/act-scene.ts'
/**
* A COF with `layerCount` layers, one frame, one direction.
*
* Mirrors `decodeCof`'s reader: a 28-byte header followed by one 9-byte layer
* record each, then the frame-duration table and the per-direction layer order.
*/
function makeCof(layerCount: number): Uint8Array {
const directions = 1
const frames = 1
const size = 28 + layerCount * 9 + frames * 4 + directions * frames * layerCount
const bytes = new Uint8Array(size)
const view = new DataView(bytes.buffer)
bytes[0] = layerCount
bytes[1] = frames
bytes[2] = directions
let at = 28
for (let layer = 0; layer < layerCount; layer += 1) {
bytes[at] = layer // composite type: 0 = hd, 1 = tr, ...
bytes[at + 1] = 0 // shadow
bytes[at + 2] = 0 // selectable
bytes[at + 3] = 0 // override transparency
// weapon class, 4 characters, 'hth\0'
bytes[at + 4] = 0x68
bytes[at + 5] = 0x74
bytes[at + 6] = 0x68
bytes[at + 7] = 0
at += 9
}
for (let frame = 0; frame < frames; frame += 1) {
view.setInt32(at, 256, true)
at += 4
}
for (let i = 0; i < directions * frames * layerCount; i += 1) {
bytes[at] = i % layerCount
at += 1
}
return bytes
}
/** An archive holding only the COF: every DCC layer will fail to resolve. */
function archivesWithOnlyCof(cof: Uint8Array): MountedArchives {
const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof'
return {
listFiles: async () => [cofName],
read: async (name: string) => {
if (name === cofName) return cof
throw new Error(`not found: ${name}`)
},
} as unknown as MountedArchives
}
describe('compositeMonsterAnimation layer failures', () => {
it('reports and logs every dropped COF layer instead of swallowing it', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const sheet = await compositeMonsterAnimation(archivesWithOnlyCof(makeCof(3)), 'fa', 'wl', 'hth')
// Three layers, no DCC members in the archive: three honest failures.
expect(sheet.layers).toBe(3)
expect(sheet.layerFailures.length).toBe(3)
expect(sheet.layerFailures.map(f => f.component)).toEqual(['hd', 'tr', 'lg'])
expect(sheet.layerFailures.every(f => f.reason === 'no-member')).toBe(true)
expect(sheet.decodedMembers).toEqual([])
// Silence was the bug; the log is part of the fix.
expect(error).toHaveBeenCalledTimes(3)
} finally {
error.mockRestore()
}
})
it('reports a layer whose DCC is present but undecodable', async () => {
const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof'
const dccName = 'data\\global\\monsters\\fa\\hd\\fahdlitwlhth.dcc'
const cof = makeCof(1)
const archives = {
listFiles: async () => [cofName, dccName],
read: async (name: string) => {
if (name === cofName) return cof
// A truncated DCC: found, but `decodeDcc` cannot read it.
if (name === dccName) return new Uint8Array([0, 0, 0])
throw new Error(`not found: ${name}`)
},
} as unknown as MountedArchives
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const sheet = await compositeMonsterAnimation(archives, 'fa', 'wl', 'hth')
expect(sheet.layerFailures.length).toBe(1)
expect(sheet.layerFailures[0]?.reason).toBe('decode')
expect(sheet.layerFailures[0]?.member).toBe(dccName)
expect(error).toHaveBeenCalledTimes(1)
} finally {
error.mockRestore()
}
})
})
describe('act scene monster art diagnostics', () => {
it('names the monster ids that will draw as red boxes', () => {
expect(missingMonsterArtIds(['Fallen1', 'zombie1'], new Set(['fallen1'])))
.toEqual(['zombie1'])
expect(missingMonsterArtIds(['fallen1', 'fallen1'], new Set()))
.toEqual(['fallen1'])
expect(missingMonsterArtIds(['fallen1'], new Set(['fallen1']))).toEqual([])
})
it('says nothing in the HUD when the load was clean', () => {
expect(monsterArtStatusText({ missing: [], errors: 0, layerFailures: 0 })).toBe('')
})
it('shows missing art, load errors and dropped layers in the HUD', () => {
const text = monsterArtStatusText({
missing: ['fallen1', 'zombie1', 'skeleton1', 'quillrat1'],
errors: 2,
layerFailures: 5,
})
expect(text).toContain('怪物美术缺失 4')
expect(text).toContain('fallen1、zombie1、skeleton1…+1')
expect(text).toContain('加载错误 2')
expect(text).toContain('缺层 5')
})
})

View File

@ -7,7 +7,7 @@
* the expected answer exactly. `scripts/verify-monsters.ts` covers the other
* half, that the column names match the real 255-column file.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { D2Table } from '../src/game/acts.ts'
import {
DENSITY_CELLS_PER_MONSTER,
@ -16,7 +16,10 @@ import {
planMonsterGroups,
readEliteModifiers,
readLevelMonsterPlan,
planLevelMonsters,
monsterLevelFor, monsterScaleFor,
monsterScaleLookup,
setMonsterScalingStrict,
readMonsterArt,
readMonsterKinds,
readMonsterScaling,
@ -570,7 +573,79 @@ describe('monsterScaleFor', () => {
// Zero multipliers would silently produce a monster with one health and one
// damage, which looks like a working monster rather than like a gap.
const offTable: MonsterKind = { ...kinds.get('fallen1')!, level: [7, 7, 7] }
expect(monsterScaleFor(scaling, offTable.level[0])).toBe(UNSCALED)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(monsterScaleFor(scaling, offTable.level[0])).toBe(UNSCALED)
} finally {
warn.mockRestore()
}
})
it('warns instead of silently substituting UNSCALED', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
// 4242 has never been warned about, so the once-per-level guard cannot
// swallow this one.
monsterScaleFor(scaling, 4242)
expect(warn).toHaveBeenCalledTimes(1)
expect(String(warn.mock.calls[0]?.[0])).toContain('MonLvl.txt has no row for level 4242')
// Once per level, not once per monster rolled at that level.
monsterScaleFor(scaling, 4242)
expect(warn).toHaveBeenCalledTimes(1)
} finally {
warn.mockRestore()
}
})
it('throws in strict mode', () => {
setMonsterScalingStrict(true)
try {
expect(() => monsterScaleFor(scaling, 4243)).toThrow(/MonLvl\.txt has no row for level 4243/)
// A level the table does carry is still fine.
expect(monsterScaleFor(scaling, 1)).toEqual(scaling.get(1))
} finally {
setMonsterScalingStrict(false)
}
})
it('reports the miss to a caller that asks, without touching the console', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(monsterScaleLookup(scaling, 1)).toEqual({ scale: scaling.get(1), missing: false })
expect(monsterScaleLookup(scaling, 4244)).toEqual({ scale: UNSCALED, missing: true })
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
})
describe('planLevelMonsters reports missing MonLvl rows', () => {
const tables = { levels: LEVELS, monstats: MONSTATS, monlvl: MONLVL }
it('reports nothing when every rolled level has a row', () => {
const plan = planLevelMonsters(tables, 2, 6400, 1234, 170)
expect(plan.packs.length).toBeGreaterThan(0)
expect(plan.missingScalingLevels).toEqual([])
})
it('names the level whose row is missing instead of pretending it scaled', () => {
// Same table with the level-1 and level-2 rows taken out: the Blood Moor's
// monsters are all `Level` 1, so every one of them now rolls unscaled.
const thin = table(
['Level', 'AC', 'AC(N)', 'TH', 'TH(N)', 'HP', 'HP(N)', 'DM', 'DM(N)', 'XP', 'XP(N)'],
[['30', '180', '190', '309', '320', '133', '300', '19', '52', '660', '1600']],
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const plan = planLevelMonsters({ ...tables, monlvl: thin }, 2, 6400, 1234, 170)
expect(plan.packs.length).toBeGreaterThan(0)
expect(plan.missingScalingLevels).toEqual([1])
expect(warn).toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
})

View File

@ -0,0 +1,278 @@
/**
* Wilderness telemetry must be measured, not invented (issue #58 item 6).
*
* Every number in `stats` is read by `npm run verify:generators` and by the
* in-game HUD to decide whether a level generated correctly. Four of them were
* literals or no-op arithmetic, so a level that produced nothing still reported
* a plausible-looking map:
*
* - `wilderness-jungles.ts` reported `junglePathCells: 100` and at least 2
* `riverBridges`, whatever was painted.
* - `wilderness-acts.ts` counted Act 4 plateau cells in a loop that painted
* nothing for levels 104 and 106.
* - `wilderness.ts` reported `subTheme: 0` on the special-preset return path.
* - `applySubstitutions` capped `BordType` 0 rows with `Math.min(1, Infinity)`,
* a budget that never constrained anything.
*/
import { describe, expect, test } from 'vitest'
import { createCanvas, generateWilderness, stampDs1 } from '../src/game/wilderness.ts'
import type {
SubstitutionReport,
WildernessPiece,
WildernessStats,
WildernessSubstitution,
} from '../src/game/wilderness.ts'
import type { Act3TopographyStats, Act4TopographyStats } from '../src/game/wilderness-acts.ts'
import { DRLG_GenerateJungles } from '../src/game/wilderness-jungles.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
/** A fresh, empty report collector. */
function makeStats(): WildernessStats {
return {
substitutions: [],
borderPieces: {},
unresolved: [],
notes: [],
borderStamped: 0,
groundCells: 0,
groundTile: null,
sizeSource: 'test',
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
}
}
/** A solid block of floor, so stamped pieces are measurable. */
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 3,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8)
return Array.from({ length: 8 }, (_unused, i) => ({
name: `${actPrefix} - Border ${String(i + 1)}`,
border: true,
levels: [borderDs1],
}))
}
describe('Act 3 jungle topography is counted, not assumed', () => {
test('a jungle with no presets reports zero ground and zero water', () => {
const canvas = createCanvas(64, 192, 1, 1, 1)
const stats = makeStats()
DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1)
const topo = stats.act3Topography as Act3TopographyStats
// Nothing was stamped, so there is no jungle ground and no river water.
// This used to report 100 path cells regardless.
expect(topo.junglePathCells).toBe(0)
expect(topo.canalCells).toBe(0)
// The bridges that exist are the plank crossings the pass paints itself:
// the island/main-channel bridges plus both canonical crossings.
expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 2)
// Causeways belong to Kurast, and the jungle pass paints none.
expect(topo.causewayCells).toBe(0)
})
test('a canvas too short for the second crossing does not count it', () => {
const canvas = createCanvas(64, 96, 1, 1, 1)
const stats = makeStats()
DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1)
const topo = stats.act3Topography as Act3TopographyStats
// The crossing at y = 142 falls off a 96-cell canvas and paints nothing.
// `Math.max(2, bridges.length)` used to report it anyway.
expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 1)
})
test('ground and water counts add up to what is on the canvas', () => {
const canvas = createCanvas(64, 192, 1, 1, 1)
const stats = makeStats()
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] },
]
DRLG_GenerateJungles(canvas, 76, pieces, new Rng(76001), stats, stampDs1)
let painted = 0
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const cell = canvas.cells[y]![x]!
const floor = cell.floors.find(
f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0),
)
if (floor === undefined) continue
// Bridge planks are decks over the water, reported as bridges.
if (floor.style === 4 && floor.prop1 === 2) continue
painted += 1
}
}
const topo = stats.act3Topography as Act3TopographyStats
expect(painted).toBeGreaterThan(0)
expect(topo.junglePathCells + topo.canalCells).toBe(painted)
expect(topo.canalCells).toBeGreaterThan(0)
})
})
describe('Act 4 plateaus are only counted where they are painted', () => {
test('Outer Steppes (104) reports no plateau cells and names the gap', () => {
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 104001,
pieces: makeMockBorderPieces('Act 4'),
substitutions: [],
})
const topo = result.stats.act4Topography as Act4TopographyStats
// The 104/106 branch surveys a stride grid and paints nothing; it used to
// count every sample as a plateau cell.
expect(topo.plateauCells).toBe(0)
const notes = result.stats.notes as readonly string[]
expect(notes.some(note => note.includes('Act 4 basalt plateaus not implemented'))).toBe(true)
})
test('Plains of Despair (105) still counts the prison plateau it paints', () => {
const result = generateWilderness({
levelId: 105,
levelName: 'Plains of Despair',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 105001,
pieces: makeMockBorderPieces('Act 4'),
substitutions: [],
})
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.plateauCells).toBeGreaterThan(20)
})
})
describe('the special-preset path reports the real SubTheme', () => {
test('a jungle level keeps the SubTheme it was asked for', () => {
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 3,
seed: 76001,
pieces,
substitutions: [],
})
// This return path hardcoded 0, so every special level looked like theme 0.
expect(result.stats.subTheme).toBe(3)
})
test('an out-of-range SubTheme is clamped, like the main path clamps it', () => {
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 9,
seed: 76002,
pieces,
substitutions: [],
})
expect(result.stats.subTheme).toBe(4)
})
})
describe('BordType 0 really is one cluster for the whole level', () => {
/** Two clutter rows that both want a whole-level cluster. */
function makeBordType0Rows(): WildernessSubstitution[] {
return ['Trees', 'Rocks'].map(name => ({
name,
type: 1,
gridSize: 1,
bordType: 0,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [-1, -1, -1, -1, -1],
max: [5, 5, 5, 5, 5],
levels: [makeMockDs1(4, 4)],
}))
}
test('the second BordType 0 row gets nothing left to spend', () => {
const result = generateWilderness({
levelId: 2,
levelName: 'Cold Plains',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 1'),
substitutions: makeBordType0Rows(),
})
const reported = result.stats.substitutions as readonly SubstitutionReport[]
const rows = reported.filter(row => row.name === 'Trees' || row.name === 'Rocks')
expect(rows).toHaveLength(2)
const spent = rows.reduce((sum, row) => sum + row.clusters, 0)
// `Math.min(1, Infinity)` handed each row its own cluster, so this used to
// be 2: a per-row cap wearing a whole-level cap's name.
expect(spent).toBeLessThanOrEqual(1)
expect(rows[0]!.clusters).toBe(1)
expect(rows[1]!.clusters).toBe(0)
})
})