fix(scene): 让怪物美术加载失败可观测,不再静默变红方块
三层 catch 把「整个美术系统崩溃」变成「场景就绪 + 全场红方块」,只留一句
console.warn:
- `monster-art.ts` 逐 COF 层的 `catch` 直接丢层,怪物缺头缺臂而无任何日志。
现在每一层失败都产出 `MonsterLayerFailure`(unknown-component / no-member /
decode),`console.error` 输出,并随 `MonsterAnimationSheet.layerFailures`
与 `LoadedMonsterArt.layerFailures` 返回。
- `loadMonsterArtMap` 改为返回 `MonsterArtLoad{ map, missing, errors,
layerFailures }`:整函数 catch 与逐 spec catch 都计数并降级为 console.error,
预烘焙资源 fetch 失败也不再完全无声。
- `ActSceneState` 新增 `missingMonsterArt` / `monsterArtErrors` /
`monsterArtLayerFailures`,由 `recordMonsterArtLoad` 在两处加载点写入;
HUD 追加 `monsterArtStatusText` 片段。
- 红方块绘制点自己上报 id(流式刷怪可能刷出加载时未知的类型)。
新增 tests/monster-art-diagnostics.test.ts(5 例):逐层 no-member / decode
失败的计数与日志,以及 `missingMonsterArtIds` / `monsterArtStatusText`。
TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
952ad00429
commit
d890f558b1
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,6 +238,17 @@ 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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if (typeof window !== 'undefined') window.__d2webAct = state
|
||||
|
||||
|
|
@ -1141,6 +1153,87 @@ 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(' · ')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1246,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 +1295,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 +1334,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 +1360,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 +1384,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(
|
||||
|
|
@ -1791,8 +1900,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
).then((loadedMap) => {
|
||||
monsterArtMap = loadedMap
|
||||
).then((loaded) => {
|
||||
monsterArtMap = loaded.map
|
||||
recordMonsterArtLoad(loaded)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2169,7 +2279,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,6 +2394,13 @@ 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.
|
||||
+ monsterArtStatusText({
|
||||
missing: state.missingMonsterArt,
|
||||
errors: state.monsterArtErrors,
|
||||
layerFailures: state.monsterArtLayerFailures,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -2317,8 +2442,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,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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue