Compare commits

...

5 Commits

Author SHA1 Message Date
troytt e180520ef5 fix(npc): 补齐城镇 NPC 解析的收尾问题并加上回归护栏 (refs #6)
承接前两个提交。上一版把 type-1 条目接了进来,但收尾有几处缺口,
逐项补齐:

1. 恢复被误删的注释
   `objectsTableTyped` 上方 `/** Same table, through the typed loader ... */`
   在重构中被删掉了,与改动无关,还原。

2. token 大小写统一
   `monstats.txt` 的 `Code` 列拼写不统一——同一批数据里既有 `K9` 又有
   `k9`,`ja` / `6z` / `7i` / `7j` 也是小写。而 `monsterMembers` 的索引
   键一律大写,于是 `map.get(token)` 直接落空,Jamella 和三个受伤的野蛮人
   被以「无美术」的形式烤进了 scene.json。上一版在查表处补了
   `.toUpperCase()`,这里进一步在**产出源头**归一化:同一个怪物不会再以
   两种拼写出现,调用方也不必猜大小写。

3. 空 Place 由静默跳过改为显式报错
   `presetPlaceByAct` 的数组下标**就是** DS1 的 type-1 id,数组必须保持
   稠密。原先的 `if (!place) continue` 一旦遇到空格子,会让其后每一个 NPC
   都错位认领邻居的身份——正是 `22` vs 21 那个缺陷的同一类。实测 1.13c
   五个 act 共 0 个空 Place,所以这条守卫永远不会触发;它的意义是万一数据
   变了要**响亮地失败**,而不是悄悄错号。

4. 补两层回归测试
   - `tests/resolve-type1.test.ts`:清掉两个未使用的 import、补回项目惯用的
     `.ts` 后缀、把边写边想的注释换成「这条用例为什么存在」,并新增一条
     覆盖小写 `Code` 归一化的用例。
   - `tests/packed-npc-art.test.ts`(新增):数据层不变式——凡是进入
     scene.json 的 NPC 必须同时具备 `member` 与 `frame`;同一 token 不得
     以两种拼写出现。这是之前唯一能抓住那 4 个哑火 NPC 的层次,单元测试
     抓不到,因为缺陷在打包脚本的查表里。
     守卫锚定在它真正读取的 `samples/d2-packs/index.json` 上,而不是
     `samples/d2` 目录。

验证(全部本地实跑):
  tsc --noEmit              0 error
  vitest                    23 文件通过 / 1 跳过,429 用例通过 / 2 跳过
  pack:data                 365 张地图
  verify:packs              1302/1302 断言通过
  NPC 实例                  889,未解析 0,token 拼写不一致 0

变异测试:
  - 把 `presets[objectId]` 改成 `presets[0]` → resolve-type1 2 条转红
  - 还原 `map.get(token.toUpperCase())` 为 `map.get(token)` → 全量套件
    仍然全绿,证明此前该修复毫无回归保护;本提交的 packed-npc-art 补上了
    这一层(改动前该用例对现有 pack 报 `K9 -> K9, k9`,重新打包后转绿)。
2026-09-14 15:58:51 +00:00
troytt 3cd4549332 fix: 修复非基底对象 NPC 丢失及其资源检索区分大小写的问题
在前一次提交的基础上修复并强化了 Type 1 对象(怪兽及 NPC 原型)解析与打包的正确性。

1. 修正大写回退遗漏问题(解决失联的 6 个特定角色如 Act 4 Jamella 等):`pickObjectMember` 中需要使用大写查找 token,而原来直接代入了小写 token 导致失败,修复后所有角色均成功合图。
2. 添加安全跳过统计 (`skippedMissingArtSpawns`),使得未匹配上图片的隐藏对象能干净跳过。
3. 增加 `tests/resolve-type1.test.ts` 提供核心覆盖率。
4. 应用性能重构与规范修复,将二次查询提速为内置的 `Map` 遍历,同时补齐了 `OBJECT_TYPE_MONSTER`、Typescript interface、尾随空格与逗号修复。
5. 后向保留了省略 tables 时旧版逻辑的注解。

(refs #6)
2026-09-14 15:37:32 +00:00
troytt 451816d627 fix: 修复非基底对象 NPC 丢失问题
Diablo 2 城镇中的部分 NPC(如 Gheed, Charsi, Akara, Kashya, Warriv)没有定义在
Objects.txt 作为 Type 2 对象,而是通过 MonPreset.txt 定义为 Type 1(怪兽生成点)。
以前此类对象被一律视为 kind: 'monster' 并且放弃生成(silently dropped)。

此修复:
1. `src/game/acts.ts`: 在 `loadActTables` 中增加了 `MonPreset.txt` 和 `monstats.txt` 解析。
2. `src/game/objects.ts`: `resolveDs1Object` 现在接受 `monsters` 表以处理 `type === 1` 对象查询:
   `MonPreset` 通过 Act + `id` => `Place`;
   `monstats.txt` 的第一列 (Id, 恰好对应 Place) => `Code` (对应资源 token) 和 `NameStr` (角色名)。
3. `scripts/pack-act-assets.ts`: 将解析后的新表传递给 resolver。对真实存在艺术资源的 `kind: 'npc'` 进行打包;对于确实无资源的占位对象(如 place_nothing, trap-*)现在会干净地跳过并做数量统计 (Skipped artless spawns),不再将它们报告为艺术丢失或引发异常。
4. 抽象统一 `MONSTER_ROOT` 并移除所有写死的 'data\\global\\monsters\\' 字面量。
5. `src/game/npc.ts`: 扩展 `NPC_NAME_FALLBACKS` 清单,解决部分名称未定义的显示为短码的问题(例如 RG 替换为 Rogue, CW 为 Cow 等)。

在保证现有 Type 2 行为完全不变和所有测试继续通过的前提下,总共正确重新召回了 67 个原先丢失的 Type 1 镇民、卫兵遗漏和活物 (Cow, Chicken等) 实例。

(refs #6)
2026-09-14 15:14:15 +00:00
troytt ad7ced6760 test: 修复三处跳过守卫,它们只在真实资源到位时才暴露
这三个测试都犯了同一个错误:**守卫检查的资源,和实际读取的资源不是同
一个**。在 `samples/d2` 缺席时它们一律静默跳过,看起来是绿的;真实 MPQ
一旦放进来,守卫放行,然后立刻炸在一个完全无关的缺失文件上。

- `tests/dcc.test.ts`
  守卫看 `samples/d2`,扫描目标却硬编码成 `samples/fixtures`——那里没有
  `d2char.mpq`。于是这个套件**从来没有真正跑过**:没资源时跳过,有资源时
  `TypeError: Cannot read properties of null (reading 'listFiles')`。
  目标改为 `samples/d2`;打开归档失败时由"打日志后继续"改为 `throw`
  并带上归档名,避免把"文件缺失"伪装成 100 行之外的空指针;
  `let characters: any` 收紧为 `MpqArchive | null`。

- `tests/widths.test.ts`
  守卫看 `samples/d2`,真实输入却是一个空成员名和
  `scripts/browser/checks/map-widths.txt`——后者在仓库里从未存在过。
  改为按实际输入判断。顺带补 `: string` 标注,否则空字符串字面量会被
  收窄成 `never`。

- `tests/formats-malformed.test.ts`
  守卫看 `samples/d2` 目录是否存在,却去读其中一个散落的 `.cof`。但
  `samples/d2/` 放的是 MPQ **归档**,不是解包后的目录树,目录存在完全
  不能说明那个文件存在。改为直接判断该文件路径。

修复后,在真实 MPQ 就位的情况下:
21 passed | 1 skipped (22 files),421 passed | 2 skipped (423),tsc 0 error。
其中 `dcc.test.ts` 从"0 个测试"变成真正执行。
2026-09-14 14:51:50 +00:00
troytt 68c7890eee fix(packer): 修正 monsters 前缀长度,恢复全部 NPC 美术解析 (refs #6)
打包器在切分 MPQ 成员名时,objects 走 `OBJECT_PREFIX.length`,
monsters 却硬编码了一个手数的 `22`。`data\global\monsters\` 的真实
长度是 **21**,于是每个怪物 token 都被多切掉一个字符:

    slice(21) -> "DC"   (正确)
    slice(22) -> "C"    (实际发生的)

`monsterMembers` 因此以 `C`/`E`/`R`/`S`/`J` 这些残缺 token 建索引,
而查表时用的是正确的 `DC`/`JE`/`XR`/`XS`/`0J`,导致**每一次 NPC 美术
查找都必然落空**。由于代码里对"找不到"是静默跳过,这个缺陷不会报错,
只会让 NPC 全部变成没有贴图的空对象——在真实 MPQ 到位之前完全无法察觉。

改为引入 `MONSTER_PREFIX` 常量并由 `.length` 推导,消除手数魔数。

实测(覆盖全部 365 个 scene.json):

    NPC 实例总数            103     103
    其中解析出 COF 成员       0 ->  103
    其中解析出精灵帧          0 ->  103

按 token 分解,修复后全部满额:
0J 82/82、DC 4/4、HR 2/2、JE 4/4、XR 2/2、XS 9/9。

抽查:Deckard Cain 现在解析到
`data\global\monsters\DC\TR\DCTRLITNUHTH.dcc`,帧 43x75,偏移 (-45,-54)。
普通场景物件仍走 `objects\`,未受影响。
2026-09-14 14:51:49 +00:00
11 changed files with 330 additions and 21 deletions

View File

@ -39,7 +39,7 @@ import type { Dt1 } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import type { IsoMapScene } from '../src/game/d2map.ts'
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts'
@ -64,6 +64,7 @@ const PAGE_SIZE = 2048
const HOT_RADIUS_CELLS = 8
/** Where object art lives. */
const OBJECT_PREFIX = 'data\\global\\objects\\'
/** Where monster and town-NPC art lives. */
/**
* Mode directories tried in order when picking an object's art.
*
@ -272,6 +273,48 @@ const tables: ActTables = await loadActTables(archives)
const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt'))
/** Same table, through the typed loader the object resolution expects. */
const objectsTableTyped = await loadObjectsTable(archives)
/**
* `MonPreset.txt` `Place` values grouped by act.
*
* The position within an act **is** the DS1 type-1 `id`, so this array must stay
* dense: dropping a row would renumber every entry after it and silently hand
* each NPC its neighbour's identity. Measured against the 1.13c drop there are
* zero empty `Place` cells in all five acts, so the guard below never fires —
* it exists to fail loudly rather than quietly misnumber if that ever changes.
*/
const presetPlaceByAct = new Map<string, string[]>()
for (const row of tables.monpreset.rows) {
const act = cell(tables.monpreset, row, 'Act')
const place = cell(tables.monpreset, row, 'Place')
if (!place) {
throw new Error(
`MonPreset.txt act ${act} has an empty Place cell; the row index is the DS1 id, `
+ 'so skipping it would shift every later NPC onto the wrong monster',
)
}
let list = presetPlaceByAct.get(act)
if (list === undefined) {
list = []
presetPlaceByAct.set(act, list)
}
list.push(place)
}
/** `monstats.txt` rows indexed by `Id` (the column `MonPreset.Place` points at). */
const statsById = new Map<string, readonly string[]>()
for (const row of tables.monstats.rows) {
const id = cell(tables.monstats, row, 'Id')
if (id) statsById.set(id, row)
}
const monstersTable = {
preset: tables.monpreset,
stats: tables.monstats,
presetPlaceByAct,
statsById,
}
const allNames = await archives.listFiles()
{
@ -347,9 +390,12 @@ const objectMembers = new Map<string, Map<string, string[]>>()
const monsterMembers = new Map<string, Map<string, string[]>>()
for (const name of allNames) {
const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase())
const isMon = name.toLowerCase().startsWith('data\\global\\monsters\\')
const isMon = name.toLowerCase().startsWith(MONSTER_ROOT.toLowerCase())
if (!isObj && !isMon) continue
const prefixLen = isObj ? OBJECT_PREFIX.length : 22 // 'data\\global\\monsters\\'.length
// Derive the length; a hand-counted magic number here was off by one (22 vs
// the real 21), which sliced the first letter off every monster token and
// silently broke all NPC art lookups.
const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_ROOT.length
const rest = name.slice(prefixLen).split('\\')
if (rest.length < 3) continue
const token = (rest[0] ?? '').toUpperCase()
@ -385,7 +431,7 @@ function compRank(path: string): number {
*/
function pickObjectMember(token: string, modeToken: string, baseIsMonsters = false): { member: string; candidates: number } | null {
const map = baseIsMonsters ? monsterMembers : objectMembers
const dirs = map.get(token)
const dirs = map.get(token.toUpperCase())
if (dirs === undefined) return null
const all = [...dirs.values()].flat().filter(name => {
const lower = name.toLowerCase()
@ -579,6 +625,8 @@ for (let act = 1; act <= 5; act += 1) {
let totalPngBytes = 0
let totalLevels = 0
let skippedArtlessSpawns = 0
let skippedMissingArtSpawns = 0
async function bakeDs1Variant(
entry: LevelJob,
@ -655,14 +703,22 @@ async function bakeDs1Variant(
for (const object of level.objects) {
let resolved
try {
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
} catch (err) {
missingObjects.push((err as Error).message)
continue
}
if (resolved.kind === 'monster') continue
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
skippedArtlessSpawns += 1
continue
}
const row = resolved.row
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc')
if (pick === null && resolved.kind === 'npc' && object.type === 1) {
skippedMissingArtSpawns += 1
continue
}
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
@ -734,7 +790,7 @@ async function bakeDs1Variant(
list.push({
id: object.id,
type: object.type,
name: row?.name ?? resolved.token,
name: resolved.name ?? row?.name ?? resolved.token,
token: resolved.token,
mode: resolved.mode === '' ? 'NU' : resolved.mode,
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
@ -975,3 +1031,4 @@ for (const entry of LEVELS) {
await mkdir(outDir, { recursive: true })
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)

View File

@ -334,6 +334,7 @@ async function walkPresetObjects(
tally.objects += 1
let resolved
try {
// Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour.
resolved = resolveDs1Object(tables, info.act, object.type, object.id)
} catch (err) {
tally.undefinedRows += 1

View File

@ -219,6 +219,7 @@ for (const entry of index.levels) {
const expected: { object: (typeof level.objects)[number]; token: string; mode: string }[] = []
for (const object of level.objects) {
try {
// Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour.
const resolved = resolveDs1Object(objectsTable, packed.act, object.type, object.id)
if (resolved.kind === 'object') expected.push({ object, token: resolved.token, mode: resolved.mode })
} catch {

View File

@ -95,6 +95,8 @@ export interface ActTables {
readonly levels: D2Table
readonly lvltypes: D2Table
readonly lvlprest: D2Table
readonly monstats: D2Table
readonly monpreset: D2Table
}
/** Everything needed to place and render one level. */
@ -135,7 +137,7 @@ export function tileMemberPath(relative: string): string {
}
/**
* Load the three tables from a mounted stack.
* Load the tables from a mounted stack.
*
* @param archives - the mounted archives.
* @returns the parsed tables.
@ -146,6 +148,8 @@ export async function loadActTables(archives: MountedArchives): Promise<ActTable
levels: await read('levels.txt'),
lvltypes: await read('lvltypes.txt'),
lvlprest: await read('lvlprest.txt'),
monstats: await read('monstats.txt'),
monpreset: await read('MonPreset.txt'),
}
}

View File

@ -7,6 +7,9 @@ const NPC_NAME_FALLBACKS: Record<string, string> = {
'XR': 'Larzuk',
'XS': 'Drehya',
'0J': 'Nihlathak',
'RG': 'Rogue',
'CK': 'Chicken',
'CW': 'Cow',
}
export function resolveNpcName(token: string, fallbackName: string): string {

View File

@ -67,13 +67,15 @@ import { decodeDcc } from '../formats/dcc.ts'
import type { DccFile } from '../formats/dcc.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import type { MountedArchives } from '../mpq/mount.ts'
import { OBJECT_TYPE_OBJECT, lookupObject } from './object-lookup.ts'
import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts'
import type { ObjectLookupEntry } from './object-lookup.ts'
/** `Objects.txt` inside the archives. */
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
/** Prefix every object composition and sprite lives under (`szPathPrefix`). */
const OBJECT_ROOT = 'data\\global\\objects\\'
export const OBJECT_ROOT = 'data\\global\\objects\\'
/** Prefix every monster composition and sprite lives under. */
export const MONSTER_ROOT = 'data\\global\\monsters\\'
/** Weapon-class token the engine hard-codes for objects (`COMPOSIT_GetWeaponClassCode`). */
const OBJECT_WEAPON = 'hth'
/** Armor-class token objects always use; characters vary it (`lit`/`med`/`hvy`). */
@ -436,6 +438,17 @@ export async function loadObjectsTable(archives: MountedArchives): Promise<Objec
return { table, rows, byId }
}
export interface MonstersTable {
/** Parsed monstats.txt. */
readonly stats: D2Table
/** Parsed MonPreset.txt. */
readonly preset: D2Table
/** Pre-filtered MonPreset Place column values, grouped by String(act). */
readonly presetPlaceByAct: ReadonlyMap<string, readonly string[]>
/** Pre-indexed monstats.txt rows by Id (column 0). */
readonly statsById: ReadonlyMap<string, readonly string[]>
}
/**
* Resolve a DS1 object entry to the art the engine would use for it.
*
@ -454,6 +467,7 @@ export async function loadObjectsTable(archives: MountedArchives): Promise<Objec
* @param act - the level's act, 1..5.
* @param objectType - the DS1 object's `type` (2 = object, 1 = monster spawn).
* @param objectId - the DS1 object's `id`.
* @param monsters - precomputed monster lookup tables. Omitting this parameter intentionally restores the legacy behavior of dropping all non-object (type !== 2) DS1 entries.
* @returns the entry, the optional metadata row, and the token/mode to draw with.
* @throws when the object type is an object but the table has no such id, because a
* silent fallback would bake the wrong token into the pack under the right id.
@ -463,11 +477,35 @@ export function resolveDs1Object(
act: number,
objectType: number,
objectId: number,
monsters?: MonstersTable,
): ResolvedDs1Object {
if (!Number.isFinite(objectId)) {
throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`)
}
if (objectType !== OBJECT_TYPE_OBJECT) {
if (objectType === OBJECT_TYPE_MONSTER && monsters !== undefined) {
const presets = monsters.presetPlaceByAct.get(String(act))
if (presets !== undefined && objectId >= 0 && objectId < presets.length) {
const place = presets[objectId]!
const statRow = monsters.statsById.get(place)
// `Code` is spelled inconsistently in the shipped table — `K9` on one row,
// `k9` on another, `ja` lower-cased — while the art directories are indexed
// upper-cased. Canonicalise here so one monster is never emitted under two
// different tokens, and so callers never have to guess the casing.
const token = statRow === undefined ? '' : cell(monsters.stats, statRow, 'Code').trim().toUpperCase()
const nameStr = statRow === undefined ? '' : cell(monsters.stats, statRow, 'NameStr')
return {
entry: null,
row: null,
token,
mode: 'NU',
artless: token === '',
kind: 'npc',
name: nameStr || place,
}
}
}
return { entry: null, row: null, token: '', mode: '', artless: true, kind: 'monster' }
}
const entry = lookupObject(act, objectType, objectId)
@ -513,6 +551,8 @@ export interface ResolvedDs1Object {
readonly artless: boolean
/** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named NPC. */
readonly kind: 'object' | 'monster' | 'npc'
/** The name resolved for this object (like the name of the NPC), if any. */
readonly name?: string
}
/** What {@link resolveObjectArt} is asked to resolve. */
@ -568,7 +608,7 @@ export interface ObjectArt {
export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string {
const name = token.trim().toLowerCase()
const mode = modeToken(modeIndex).toLowerCase()
const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT
const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
return `${root}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof`
}
@ -590,7 +630,7 @@ export function objectSpriteMember(token: string, component: string, modeIndex:
const name = token.trim().toLowerCase()
const part = component.toLowerCase()
const mode = modeToken(modeIndex).toLowerCase()
const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT
const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
return `${root}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc`
}
@ -721,7 +761,7 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
if (member === null) {
for (const index of order) {
const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu'
const root = request.baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT
const root = request.baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
const prefix = `${root}${token.toLowerCase()}\\`
const suffix = `${mode}${OBJECT_WEAPON}.dcc`
const hits = members

View File

@ -136,7 +136,11 @@ interface DccResult {
readonly dcc: DccFile
}
const args = ['samples/fixtures']
// This sweep needs the original character/object archives, so it must target
// the real drop — `samples/fixtures` has no `d2char.mpq`. Keep this in step
// with `isSkip` above: guarding on one directory while reading another is what
// previously made this suite silently unrunnable.
const args = ['samples/d2']
const dir = args.find((a) => !a.startsWith('--')) ?? 'samples/d2'
const quick = args.includes('--quick')
@ -610,12 +614,14 @@ async function sweepSorceress(archive: MpqArchive, names: readonly string[]): Pr
console.log(`== cof + dcc verification over ${dir}${quick ? ' (quick)' : ''} ==`)
const characterPath = `${dir}/${CHARACTER_ARCHIVE}`
let characters: any = null;
let characters: MpqArchive | null = null;
try {
characters = await MpqArchive.open(await fileSource(characterPath))
} catch (err) {
console.log(`cannot open ${characterPath}: ${messageOf(err)}`)
// disabled exit: 2)
// Do not fall through: the old code logged here and then dereferenced
// `characters`, turning "archive missing" into an opaque TypeError 100 lines
// away from the cause.
throw new Error(`cannot open ${characterPath}: ${messageOf(err)}`)
}
const characterNames = (await characters.listFiles()).map(normalize)
console.log(`${CHARACTER_ARCHIVE}: ${String(characterNames.length)} members listed`)

View File

@ -132,9 +132,14 @@ describe('Malformed binary formats fail loudly', () => {
});
describe('cof', () => {
const hasD2Drop = fs.existsSync(path.join(__dirname, '../samples/d2'));
test.skipIf(!hasD2Drop)('valid decodes successfully on real asset', () => {
const validCof = fs.readFileSync(path.join(__dirname, '../samples/d2/data/global/chars/so/co/sowaxlbh.cof'));
// `samples/d2/` holds the original MPQ *archives*, not an extracted tree,
// so the presence of that directory says nothing about whether this loose
// .cof exists. Guard on the file we actually open, otherwise dropping in
// real assets turns a silent skip into a hard ENOENT.
const realCofPath = path.join(__dirname, '../samples/d2/data/global/chars/so/co/sowaxlbh.cof');
const hasRealCof = fs.existsSync(realCofPath);
test.skipIf(!hasRealCof)('valid decodes successfully on real asset', () => {
const validCof = fs.readFileSync(realCofPath);
const out = decodeCof(validCof);
expect(out.numberOfLayers).toBe(16);
});

View File

@ -0,0 +1,82 @@
/**
* Every NPC baked into a pack must carry usable art.
*
* This is the guard that was missing when four NPCs — Jamella and the three
* Injured Barbarians — were written into `scene.json` with a null `member`. The
* unit tests could not catch it: the failure lived in the packer's art lookup,
* where `monsterMembers` is keyed upper-cased but `monstats.txt` hands back
* `ja` / `6z` / `7i` / `7j` in lower case, so `map.get(token)` missed and the
* entry was baked art-less instead of failing.
*
* The packer now refuses to emit an NPC without art (it counts them as
* `Skipped no-art NPCs` instead), so the invariant below is: whatever reaches a
* `scene.json` is drawable.
*/
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'vitest'
/**
* Guarded on the index file this suite actually opens, not on the `samples/d2`
* archive directory: those are different resources, and keying the guard on the
* wrong one is what previously left three suites silently unrunnable.
*/
const PACK_ROOT = 'samples/d2-packs'
const PACK_INDEX = join(PACK_ROOT, 'index.json')
const hasPacks = existsSync(PACK_INDEX)
interface PackedNpc {
readonly token?: string
readonly name?: string
readonly member?: string | null
readonly frame?: { readonly page: number } | null
}
interface PackedScene { readonly npcs?: readonly PackedNpc[] }
interface PackIndex { readonly levels: readonly { readonly act: number; readonly path: string }[] }
describe('packed NPC art', () => {
test.skipIf(!hasPacks)('every baked NPC has both a COF member and a sprite frame', () => {
const index = JSON.parse(readFileSync(PACK_INDEX, 'utf8')) as PackIndex
const broken: string[] = []
let total = 0
for (const level of index.levels) {
const scenePath = join(PACK_ROOT, level.path, 'scene.json')
if (!existsSync(scenePath)) continue
const scene = JSON.parse(readFileSync(scenePath, 'utf8')) as PackedScene
for (const npc of scene.npcs ?? []) {
total += 1
if (npc.member === undefined || npc.member === null || npc.frame === undefined || npc.frame === null) {
broken.push(`${level.path}: token=${npc.token ?? '?'} name=${npc.name ?? '?'}`)
}
}
}
// Named so a failure reports which NPCs lost their art, not just a count.
expect(broken).toEqual([])
expect(total).toBeGreaterThan(0)
})
test.skipIf(!hasPacks)('an NPC token is never emitted under two different spellings', () => {
const index = JSON.parse(readFileSync(PACK_INDEX, 'utf8')) as PackIndex
const spellings = new Map<string, Set<string>>()
for (const level of index.levels) {
const scenePath = join(PACK_ROOT, level.path, 'scene.json')
if (!existsSync(scenePath)) continue
const scene = JSON.parse(readFileSync(scenePath, 'utf8')) as PackedScene
for (const npc of scene.npcs ?? []) {
const token = npc.token ?? ''
if (token === '') continue
const bucket = spellings.get(token.toUpperCase()) ?? new Set<string>()
bucket.add(token)
spellings.set(token.toUpperCase(), bucket)
}
}
const inconsistent = [...spellings.entries()]
.filter(([, seen]) => seen.size > 1)
.map(([canonical, seen]) => `${canonical} -> ${[...seen].join(', ')}`)
expect(inconsistent).toEqual([])
})
})

102
tests/resolve-type1.test.ts Normal file
View File

@ -0,0 +1,102 @@
/**
* Type-1 (DS1 monster/NPC spawn) resolution.
*
* DS1 entries come in two flavours: `type` 2 is an `Objects.txt` object, `type` 1
* is a spawn whose `id` indexes `MonPreset.txt` filtered by act. Until this path
* existed the packer dropped every type-1 entry, which is why Act 1's town showed
* only Deckard Cain (a type-2 object) and none of Warriv, Charsi, Kashya, Akara
* or Gheed.
*/
import { describe, expect, test } from 'vitest'
import { resolveDs1Object } from '../src/game/objects.ts'
import type { MonstersTable, ObjectsTable } from '../src/game/objects.ts'
import { OBJECT_TYPE_MONSTER } from '../src/game/object-lookup.ts'
/** The type-1 path never consults `Objects.txt`, so an empty table is sufficient. */
const emptyObjectsTable: ObjectsTable = {
table: { header: [], rows: [] },
rows: [],
byId: new Map(),
}
/**
* A stand-in for the two shipped tables.
*
* Act 1 is `[dummyplace, warriv1, place_nothing]` and act 2 is `[warriv1]`, so the
* same `id` resolves differently per act — that is the property the act filter has
* to preserve. `place_nothing` deliberately has no `monstats` row, mirroring the
* real spawners (`place_group25`, `trap-*`) that legitimately carry no art.
* `lowercase1` carries a lower-cased `Code`, which is how `ja`/`6z`/`k9` actually
* appear in the shipped table.
*/
function mockMonsters(): MonstersTable {
const presetPlaceByAct = new Map<string, readonly string[]>([
['1', ['dummyplace', 'warriv1', 'place_nothing', 'lowercase1']],
['2', ['warriv1']],
])
const stats = {
header: ['Id', 'NameStr', 'Code'],
rows: [
['warriv1', 'Warriv', 'WA'],
['dummyplace', '', 'DM'],
['lowercase1', 'Jamella', 'ja'],
],
}
const statsById = new Map<string, readonly string[]>([
['warriv1', stats.rows[0]!],
['dummyplace', stats.rows[1]!],
['lowercase1', stats.rows[2]!],
])
return { stats, preset: { header: ['Act', 'Place'], rows: [] }, presetPlaceByAct, statsById }
}
describe('resolveDs1Object, type-1 spawns', () => {
test('resolves a named NPC to its art token and name', () => {
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 1, mockMonsters())
expect(res.kind).toBe('npc')
expect(res.token).toBe('WA')
expect(res.name).toBe('Warriv')
expect(res.artless).toBe(false)
})
test('a Place with no monstats row is artless, not a guess', () => {
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 2, mockMonsters())
expect(res.kind).toBe('npc')
expect(res.token).toBe('')
expect(res.name).toBe('place_nothing')
expect(res.artless).toBe(true)
})
test('an out-of-range id falls back to a plain monster spawn', () => {
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 999, mockMonsters())
expect(res.kind).toBe('monster')
expect(res.token).toBe('')
expect(res.artless).toBe(true)
})
test('the same id resolves differently per act', () => {
const monsters = mockMonsters()
expect(resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 0, monsters).token).toBe('DM')
expect(resolveDs1Object(emptyObjectsTable, 2, OBJECT_TYPE_MONSTER, 0, monsters).token).toBe('WA')
})
test('omitting the monsters table restores the legacy drop behaviour', () => {
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 1, undefined)
expect(res.kind).toBe('monster')
expect(res.token).toBe('')
expect(res.artless).toBe(true)
})
test('a lower-cased Code is canonicalised to upper case', () => {
// Regression guard: `monsterMembers` is keyed upper-cased, so a token left as
// `ja` missed the map entirely and baked Jamella and the three Injured
// Barbarians into the pack with no art at all.
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 3, mockMonsters())
expect(res.token).toBe('JA')
expect(res.artless).toBe(false)
expect(res.name).toBe('Jamella')
})
})

View File

@ -16,7 +16,15 @@ import { readFile } from 'node:fs/promises'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { decodeSpriteFile } from '../src/formats/cel.ts'
const isSkip = true && !fs.existsSync('samples/d2');
const WIDTHS_ARCHIVE: string = 'samples/fixtures'
const WIDTHS_MEMBER: string = ''
const WIDTHS_PATH: string = 'scripts/browser/checks/map-widths.txt'
// This check needs a per-frame widths sidecar and a named member. Neither has
// ever been present in the repo, and neither is supplied by `samples/d2` — so
// guarding on that directory (as this file used to) meant the suite stayed
// silently skipped until real assets appeared, then failed on an unrelated
// missing file. Guard on the inputs actually opened below.
const isSkip = WIDTHS_MEMBER === '' || !fs.existsSync(WIDTHS_PATH);
const _results: any[] = [];
let suiteCompleted = false;
let problems: string[] = [];
@ -31,7 +39,7 @@ function check(nameOrOk: any, okOrMessage: any, detail?: string) {
}
if (!isSkip) {
const [path, member, widthsPath] = ['samples/fixtures', '', 'scripts/browser/checks/map-widths.txt']
const [path, member, widthsPath] = [WIDTHS_ARCHIVE, WIDTHS_MEMBER, WIDTHS_PATH]
if (path === undefined || member === undefined || widthsPath === undefined) {
console.error('usage: node scripts/verify-widths.ts <archive> <member> <widths-file>')
// disabled exit: 2)