diablo2-web/src/baker/cli.ts

206 lines
7.3 KiB
TypeScript

/**
* Unified Offline Asset & Table Baker CLI (`src/baker/cli.ts`).
*
* Usage:
* npm run bake -- <target>
* npm run bake -- --verify
*
* Supported targets:
* tiles, acts, entities, missiles, overlays, ui, ui-assets, frontend-ui,
* animdata, char-so, characters, cursor, skills, drop-data, tbl, all
*/
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
import {
UNIVERSAL_DT1_PATHS,
validatePackManifest,
validateUniversalDt1Libraries,
} from '../common/pack-contract/index.ts'
import { cell, loadActTables, resolveLevelLibraries } from '../common/world/acts.ts'
import { mountNodeArchives } from './mpq-archive-source.ts'
import { auditDropParity } from './packers/pack-canonical-drop-data.ts'
const TARGET_SCRIPTS: Readonly<Record<string, string>> = {
tiles: 'src/baker/packers/pack-tiles.ts',
acts: 'src/baker/packers/pack-act-assets.ts',
entities: 'src/baker/packers/pack-entity-assets.ts',
missiles: 'src/baker/packers/pack-missiles.ts',
overlays: 'src/baker/packers/pack-overlays.ts',
ui: 'src/baker/packers/pack-ui.ts',
'ui-assets': 'src/baker/packers/pack-ui-assets.ts',
'frontend-ui': 'src/baker/packers/pack-frontend-ui.ts',
animdata: 'src/baker/packers/pack-animdata.ts',
'char-so': 'src/baker/packers/pack-char-so-layers.ts',
characters: 'src/baker/packers/pack-characters.ts',
cursor: 'src/baker/packers/pack-cursor.ts',
skills: 'src/baker/packers/pack-skill-assets.ts',
'drop-data': 'src/baker/packers/pack-canonical-drop-data.ts',
tbl: 'src/baker/packers/pack-canonical-tbl.ts',
manifest: 'src/baker/io/asset-manifest.ts',
}
export interface BakerVerifySummary {
readonly tileManifestVerified: boolean
readonly levelsVerified: number
readonly universalDt1Count: number
readonly dropParityVerified: boolean
readonly entityPacksVerified: boolean
}
/**
* Runs offline contract, universal DT1, 136-level library, drop table, and entity pack verification.
*/
export async function runBakerVerify(rootDir: string = process.cwd()): Promise<BakerVerifySummary> {
console.log('=== Diablo II v1.13c Baker Offline Contract & Pack Verification ===')
// 1. Validate tile catalog manifest (`public/act-packs/tiles/index.json`)
const tileIndexCandidates = [
join(rootDir, 'public', 'act-packs', 'tiles', 'index.json'),
join(rootDir, 'samples', 'd2-packs', 'tiles', 'index.json'),
]
const tileIndexPath = tileIndexCandidates.find(p => existsSync(p))
if (!tileIndexPath) {
throw new Error(`Tile catalog index.json not found in ${tileIndexCandidates.join(' or ')}`)
}
const rawTileIndex = JSON.parse(readFileSync(tileIndexPath, 'utf-8')) as import('../common/pack-contract/index.ts').PackedTileCatalogIndex
const tileCatalog = validatePackManifest(rawTileIndex)
console.log(
`✓ Tile catalog verified (${tileIndexPath}): ${tileCatalog.totalLibraries} libraries, ${tileCatalog.totalTiles} tiles, missingFiles=${tileCatalog.missingFiles.length}, invisWalFloorVerified=${String(tileCatalog.invisWalFloorVerified)}`,
)
// 2. Verify all 136 levels resolve universal DT1s (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`)
const archiveDir = existsSync(join(rootDir, 'samples', 'd2', 'd2data.mpq'))
? join(rootDir, 'samples', 'd2')
: '/usr/local/google/home/taodao/d2-data'
const archives = await mountNodeArchives(archiveDir)
const tables = await loadActTables(archives)
let levelsVerified = 0
for (let levelId = 1; levelId <= 136; levelId += 1) {
const row = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === levelId)
if (!row) {
throw new Error(`Missing level Id=${levelId} in Levels.txt`)
}
const levelTypeId = Number(cell(tables.levels, row, 'LevelType'))
const libs = resolveLevelLibraries(tables, levelTypeId)
validateUniversalDt1Libraries(libs, `levelId=${levelId} (levelType=${levelTypeId})`)
levelsVerified += 1
}
console.log(
`✓ All ${levelsVerified}/136 levels verified to include the ${UNIVERSAL_DT1_PATHS.length} universal DT1s (${UNIVERSAL_DT1_PATHS.join(', ')})`,
)
// 3. Verify 1.13c canonical drop data parity (MonStats, SuperUniques, DifficultyLevels)
const dropAudit = await auditDropParity(
archiveDir,
join(rootDir, 'src', 'common', 'data', 'canonical-drop-data.ts'),
)
if (
!dropAudit.monstatsMatch ||
!dropAudit.superUniquesMatch ||
!dropAudit.difficultyLevelsMatch ||
dropAudit.monsterKindsCount !== 734 ||
dropAudit.superUniquesCount !== 66 ||
dropAudit.difficultyLevelsCount !== 3
) {
throw new Error(`Drop data parity check failed: ${JSON.stringify(dropAudit)}`)
}
console.log(
`✓ Canonical drop data parity verified: ${dropAudit.monsterKindsCount} MonsterKinds, ${dropAudit.superUniquesCount} SuperUniques, ${dropAudit.difficultyLevelsCount} DifficultyLevels`,
)
// 4. Verify Schema 2 entity packs if present
let entityPacksVerified = false
if (existsSync(join(rootDir, 'samples', 'd2-packs', 'entities'))) {
const res = spawnSync(
process.execPath,
['--import', 'tsx', join(rootDir, 'src', 'baker', 'audit', 'verify-entity-packs.ts')],
{ cwd: rootDir, stdio: 'inherit' },
)
if (res.status !== 0) {
throw new Error(`verify-entity-packs.ts failed with status ${String(res.status)}`)
}
entityPacksVerified = true
}
console.log('=== All Baker Offline Verifications Passed (0 errors) ===')
return {
tileManifestVerified: true,
levelsVerified,
universalDt1Count: UNIVERSAL_DT1_PATHS.length,
dropParityVerified: true,
entityPacksVerified,
}
}
function runSubscript(scriptRelPath: string, extraArgs: readonly string[], rootDir: string): void {
const scriptPath = join(rootDir, scriptRelPath)
const res = spawnSync(
process.execPath,
['--import', 'tsx', scriptPath, ...extraArgs],
{ cwd: rootDir, stdio: 'inherit' },
)
if (res.status !== 0) {
throw new Error(`Baker target ${scriptRelPath} exited with code ${String(res.status)}`)
}
}
export async function runBakerCli(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
const rootDir = process.cwd()
if (argv.includes('--verify') || argv[0] === 'verify') {
await runBakerVerify(rootDir)
return
}
const target = argv[0]
const restArgs = argv.slice(1)
if (!target || target === '--help' || target === '-h') {
console.log(
`Usage: npm run bake -- <target|--verify>\nTargets: ${Object.keys(TARGET_SCRIPTS).join(', ')}, all`,
)
return
}
if (target === 'all') {
for (const key of [
'animdata',
'tiles',
'characters',
'entities',
'missiles',
'overlays',
'ui',
'frontend-ui',
'skills',
'drop-data',
'tbl',
'manifest',
]) {
runSubscript(TARGET_SCRIPTS[key]!, restArgs, rootDir)
}
return
}
const scriptRel = TARGET_SCRIPTS[target]
if (!scriptRel) {
throw new Error(
`Unknown bake target "${target}". Expected one of: ${Object.keys(TARGET_SCRIPTS).join(', ')}, all, --verify`,
)
}
runSubscript(scriptRel, restArgs, rootDir)
}
const isMain =
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
if (isMain) {
runBakerCli().catch(err => {
console.error(err)
process.exit(1)
})
}