// verify-tiles.ts — 验收"每个槽位画的是不是它该有的那种瓦片" // // 这份脚本存在的原因是一次真实事故:地面引用在 `buildIsoMapScene()` 里以 `type = null` 走 // **类型无关**的兜底池,池子里混着地面、墙、柱子、影子、树、屋顶。加上"按 RarityFrameIndex // 加权随机选变体"之后,地面槽位会随机挑到石墙/柱子瓦片,而地面绘制不做墙那套 // `minBlockY + 80` 补偿 —— 于是画面里出现"悬在地面上的暗色方块"(35 关实测 1,918/28,704 // 个地面槽位画错类型,修道院大教堂 28.2%、地下墓穴 4 层 41.5%)。 // // 现在地面按 DT1 `type 0` 解析,这里把当年的普查变成永久断言: // 1. 地面槽位画出的瓦片 `type` 必须是 0; // 2. 墙/屋顶槽位不得画出地面瓦片(type 0); // 3. `looseRefs == 0`(没有任何引用走类型无关兜底)与 `missingTiles == 0`—— // 这两条合起来保证"画出的类型 == 引用的类型"; // 4. 位置自洽:地面 `y == (cx+cy)*40` 且瓦片 `minBlockY == 0`; // 墙 `y == (cx+cy)*40 + minBlockY + 80`;屋顶 `y == (cx+cy)*40 - roofHeight`。 // // node scripts/verify-tiles.ts [--dir=samples/d2] [--limit=35] // // 退出码非 0 表示有断言没过。 export {} import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import { decodeDt1 } from '../src/formats/dt1.ts' import type { Dt1 } from '../src/formats/dt1.ts' import { cell, loadActTables, resolveLevel, resolveLevelLibraries } from '../src/game/acts.ts' import { buildIsoMapScene, levelSeed, ORTHO_CELL_HEIGHT } from '../src/game/d2map.ts' /** 归档目录。 */ const DIR = process.argv.find(argument => argument.startsWith('--dir='))?.slice('--dir='.length) ?? 'samples/d2' /** 最多走多少个预置关卡。 */ const LIMIT = Number(process.argv.find(argument => argument.startsWith('--limit='))?.slice('--limit='.length) ?? '35') /** 断言结果。 */ const checks: { name: string; ok: boolean; detail: string }[] = [] /** * 记录一条断言。 * * @param name - what was checked. * @param ok - whether it held. * @param detail - evidence. */ function check(name: string, ok: boolean, detail: string): void { checks.push({ name, ok, detail }) console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`) } /** * 主流程。 */ async function main(): Promise { const archives = new MountedArchives() for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { archives.add(name, await MpqArchive.open(await fileSource(`${DIR}/${name}`))) } const tables = await loadActTables(archives) const libraryCache = new Map() let levels = 0 let floors = 0 let walls = 0 let roofs = 0 let wrongFloorType = 0 let floorAsWall = 0 let looseRefs = 0 let missingTiles = 0 let positionMismatch = 0 const worst: [number, string][] = [] for (const row of tables.levels.rows) { if (cell(tables.levels, row, 'DrlgType') !== '2') continue const levelId = Number(cell(tables.levels, row, 'Id')) let info try { info = resolveLevel(tables, levelId) } catch { continue } const { dt1Names } = resolveLevelLibraries(tables, levelId) const libs: Dt1[] = [] for (const name of dt1Names) { const cached = libraryCache.get(name) if (cached !== undefined) { libs.push(cached); continue } try { const decoded = decodeDt1(await archives.read(name)) libraryCache.set(name, decoded) libs.push(decoded) } catch { /* 缺库时 verify:acts 会报 */ } } if (libs.length === 0) continue let ds1 try { ds1 = decodeDs1(await archives.read(info.ds1Names[0]!)) } catch { continue } if (levels >= LIMIT) break levels += 1 const seed = levelSeed(info.ds1Names[0]!) const scene = buildIsoMapScene(ds1, libs, seed) looseRefs += scene.looseRefs missingTiles += scene.missingTiles let badHere = 0 for (const draw of scene.floors) { const tile = libs[draw.library]?.tiles[draw.tile] if (tile === undefined) continue floors += 1 // (1) 地面槽位只能是 type 0 的瓦片 if (tile.type !== 0) { wrongFloorType += 1; badHere += 1 } // (4) 地面位置:不带动画块补偿,且地面瓦片本身不该有块位移 if (draw.y - scene.originY !== (draw.cellX + draw.cellY) * ORTHO_CELL_HEIGHT || tile.minBlockY !== 0) positionMismatch += 1 } for (const draw of scene.walls) { const tile = libs[draw.library]?.tiles[draw.tile] if (tile === undefined) continue walls += 1 // (2) 墙槽位不该画出地面瓦片 if (tile.type === 0) floorAsWall += 1 // (4) 墙位置 = 格子基线 + 该瓦片自己的块位移 + 一格高 if (draw.y - scene.originY !== (draw.cellX + draw.cellY) * ORTHO_CELL_HEIGHT + tile.minBlockY + 80) positionMismatch += 1 } for (const draw of scene.roofs) { const tile = libs[draw.library]?.tiles[draw.tile] if (tile === undefined) continue roofs += 1 if (draw.y - scene.originY !== (draw.cellX + draw.cellY) * ORTHO_CELL_HEIGHT - tile.roofHeight) positionMismatch += 1 } if (scene.floors.length > 0) worst.push([100 * badHere / scene.floors.length, info.levelName]) } worst.sort((left, right) => right[0] - left[0]) console.log(`\n走了 ${String(levels)} 个预置关卡:地面 ${String(floors)} 槽、墙 ${String(walls)} 槽、屋顶 ${String(roofs)} 槽`) check('地面槽位画出的瓦片类型都是 0(无错配方块)', wrongFloorType === 0, `${String(wrongFloorType)} 个错类型 / ${String(floors)} 个地面槽`) check('墙/屋顶槽位没有画出地面瓦片', floorAsWall === 0, `${String(floorAsWall)} 个`) check('没有任何引用走类型无关兜底', looseRefs === 0, `looseRefs ${String(looseRefs)}`) check('没有解析不到的引用', missingTiles === 0, `missingTiles ${String(missingTiles)}`) check('地面/墙/屋顶的位置公式自洽', positionMismatch === 0, `${String(positionMismatch)} 个不符`) if (worst.length > 0 && worst[0]![0] > 0) console.log(` 最严重:${worst.slice(0, 5).map(([pct, name]) => `${name} ${pct.toFixed(1)}%`).join(',')}`) const passed = checks.filter(entry => entry.ok).length console.log(`\n${String(passed)}/${String(checks.length)} passed`) if (passed !== checks.length) process.exitCode = 1 } await main()