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:
commit
9d568ad0c3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue