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)
This commit is contained in:
troytt 2026-09-14 15:37:32 +00:00
parent 451816d627
commit 3cd4549332
5 changed files with 160 additions and 25 deletions

View File

@ -271,8 +271,34 @@ for (const name of MOUNTS) {
}
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)
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) continue
let list = presetPlaceByAct.get(act)
if (list === undefined) {
list = []
presetPlaceByAct.set(act, list)
}
list.push(place)
}
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()
{
@ -389,7 +415,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()
@ -584,6 +610,7 @@ 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,
@ -660,21 +687,22 @@ async function bakeDs1Variant(
for (const object of level.objects) {
let resolved
try {
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, {
preset: tables.monpreset,
stats: tables.monstats
})
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++
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
@ -987,4 +1015,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}`)
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

@ -67,7 +67,7 @@ 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. */
@ -438,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.
*
@ -456,37 +467,30 @@ 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.
*/
export interface MonstersTable {
/** Parsed monstats.txt. */
readonly stats: D2Table
/** Parsed MonPreset.txt. */
readonly preset: D2Table
}
export function resolveDs1Object(
tables: ObjectsTable,
act: number,
objectType: number,
objectId: number,
monsters?: MonstersTable
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 === 1 && monsters) {
const actStr = String(act)
const presets = monsters.preset.rows.filter(row => cell(monsters.preset, row, 'Act') === actStr)
if (objectId >= 0 && objectId < presets.length) {
const place = cell(monsters.preset, presets[objectId]!, 'Place')
const statRow = monsters.stats.rows.find(row => cell(monsters.stats, row, 'Id') === place)
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)
const token = statRow ? cell(monsters.stats, statRow, 'Code') : ''
const nameStr = statRow ? cell(monsters.stats, statRow, 'NameStr') : ''
return {
entry: null,
row: null,
@ -494,7 +498,7 @@ export function resolveDs1Object(
mode: 'NU',
artless: token.trim() === '',
kind: 'npc',
name: nameStr || place
name: nameStr || place,
}
}
}

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

@ -0,0 +1,101 @@
import { describe, expect, test } from 'vitest';
import { resolveDs1Object, OBJECT_ROOT } from '../src/game/objects';
import type { ObjectsTable, MonstersTable } from '../src/game/objects';
import { OBJECT_TYPE_MONSTER, OBJECT_TYPE_OBJECT } from '../src/game/object-lookup';
describe('resolveDs1Object (type 1 rules)', () => {
const emptyObjectsTable: ObjectsTable = {
table: { header: [], rows: [] },
rows: [],
byId: new Map()
};
const mockMonsters = (): MonstersTable => {
// Let's create a preset map and stats map.
// We need Act 1, Id 0 -> 'dummyplace'
// Act 1, Id 1 -> 'warriv1' -> has code WA
// Act 2, Id 0 -> 'warriv1' -> has code NO_WA
// Act 1, Id 2 -> 'place_nothing' -> has no row
// We only need to populate `presetPlaceByAct` for Act 1 and 2.
const presetPlaceByAct = new Map<string, string[]>();
presetPlaceByAct.set('1', ['dummyplace', 'warriv1', 'place_nothing']);
presetPlaceByAct.set('2', ['warriv1']); // same id but diff act
const statsById = new Map<string, string[]>();
// Assume columns are 0: Id, 8: Code, 5: NameStr? (Whatever cell reading does, wait, cell reads by column header offset!)
// So we need proper D2Table for `monsters.stats`.
const statsHeader = ['Id', 'NameStr', 'Code'];
const statsRows = [
['warriv1', 'Warriv', 'WA'],
['dummyplace', '', 'DM'], // has place and art
// place_nothing is absent
];
statsById.set('warriv1', statsRows[0]);
statsById.set('dummyplace', statsRows[1]);
const statsTable = { header: statsHeader, rows: statsRows };
const presetTable = { header: ['Act', 'Place'], rows: [] }; // The array wasn't used in code if presetPlaceByAct is there, but cell needs headers!
return {
stats: statsTable,
preset: presetTable,
presetPlaceByAct,
statsById
};
};
test('resolves named NPC with code and name', () => {
const monsters = mockMonsters();
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 1, monsters);
expect(res.kind).toBe('npc');
expect(res.token).toBe('WA');
expect(res.name).toBe('Warriv');
expect(res.artless).toBe(false);
});
test('resolves place with no stats row as artless monster/npc', () => {
const monsters = mockMonsters();
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 2, monsters);
// place_nothing is not in statsById -> code is '' -> artless is true
expect(res.kind).toBe('npc');
expect(res.token).toBe('');
expect(res.name).toBe('place_nothing');
expect(res.artless).toBe(true);
});
test('returns default monster if objectId out of range', () => {
const monsters = mockMonsters();
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 999, monsters);
expect(res.kind).toBe('monster');
expect(res.token).toBe('');
expect(res.artless).toBe(true);
});
test('act filtering produces different results for same id', () => {
const monsters = mockMonsters();
// In Act 1, Id 0 -> dummyplace (token DM)
const res1 = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 0, monsters);
expect(res1.token).toBe('DM');
// In Act 2, Id 0 -> warriv1 (token WA)
const res2 = resolveDs1Object(emptyObjectsTable, 2, OBJECT_TYPE_MONSTER, 0, monsters);
expect(res2.token).toBe('WA');
});
test('omitted monsters parameter restores legacy behavior (dropping type 1)', () => {
// If monsters is undefined, type 1 falls back to artless monster
const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 1, undefined);
expect(res.kind).toBe('monster');
expect(res.token).toBe('');
expect(res.artless).toBe(true);
});
});