Compare commits
5 Commits
cd87c2c5fa
...
e180520ef5
| Author | SHA1 | Date |
|---|---|---|
|
|
e180520ef5 | |
|
|
3cd4549332 | |
|
|
451816d627 | |
|
|
ad7ced6760 | |
|
|
68c7890eee |
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue