222 lines
7.5 KiB
TypeScript
222 lines
7.5 KiB
TypeScript
/**
|
|
* scripts/pack-canonical-drop-data.ts
|
|
*
|
|
* Offline extraction & packaging script for Diablo II v1.13c canonical drop tables:
|
|
* - MonStats.txt: Extracts 19 essential drop & level columns across 3 difficulties
|
|
* - SuperUniques.txt: Extracts 21 columns including multi-difficulty TC columns (TC, TC(N), TC(H))
|
|
*
|
|
* Aligns offline extraction with runtime contracts in `src/data/canonical-drop-data.ts`
|
|
* and `src/game/embedded-drop-tables.ts` per Diablo II 1.13c ground truth parity rules.
|
|
*/
|
|
|
|
import { existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
import { parseTable, cell } from '../src/game/acts.ts'
|
|
import { readMonsterKinds, readSuperUniques } from '../src/game/monsters.ts'
|
|
import { RAW_MONSTATS, RAW_SUPERUNIQUES } from '../src/data/canonical-drop-data.ts'
|
|
|
|
export const MONSTATS_DROP_COLUMNS = [
|
|
'Id',
|
|
'BaseId',
|
|
'NameStr',
|
|
'boss',
|
|
'noRatio',
|
|
'Level',
|
|
'Level(N)',
|
|
'Level(H)',
|
|
'TreasureClass1',
|
|
'TreasureClass2',
|
|
'TreasureClass3',
|
|
'TreasureClass4',
|
|
'TreasureClass1(N)',
|
|
'TreasureClass2(N)',
|
|
'TreasureClass3(N)',
|
|
'TreasureClass4(N)',
|
|
'TreasureClass1(H)',
|
|
'TreasureClass2(H)',
|
|
'TreasureClass3(H)',
|
|
'TreasureClass4(H)',
|
|
] as const
|
|
|
|
export const SUPERUNIQUES_DROP_COLUMNS = [
|
|
'Superunique',
|
|
'Name',
|
|
'Class',
|
|
'hcIdx',
|
|
'MonSound',
|
|
'Mod1',
|
|
'Mod2',
|
|
'Mod3',
|
|
'MinGrp',
|
|
'MaxGrp',
|
|
'EClass',
|
|
'AutoPos',
|
|
'Stacks',
|
|
'Replaceable',
|
|
'Utrans',
|
|
'Utrans(N)',
|
|
'Utrans(H)',
|
|
'TC',
|
|
'TC(N)',
|
|
'TC(H)',
|
|
'*eol',
|
|
] as const
|
|
|
|
/**
|
|
* Mounts standard 1.13c MPQ archives for table extraction.
|
|
*/
|
|
export async function openDropDataArchives(baseDir = 'samples/d2'): Promise<MountedArchives> {
|
|
const archives = new MountedArchives()
|
|
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|
const fullPath = join(baseDir, name)
|
|
if (!existsSync(fullPath)) {
|
|
throw new Error(`Required MPQ archive not found: ${fullPath}`)
|
|
}
|
|
archives.add(name, await MpqArchive.open(await fileSource(fullPath)))
|
|
}
|
|
return archives
|
|
}
|
|
|
|
/**
|
|
* Extracts canonical MonStats drop columns into TSV format matching RAW_MONSTATS.
|
|
*/
|
|
export async function extractCanonicalMonStats(archives: MountedArchives): Promise<string> {
|
|
const bytes = await archives.read('data\\global\\excel\\monstats.txt')
|
|
const table = parseTable(bytes)
|
|
|
|
let tsv = MONSTATS_DROP_COLUMNS.join('\t') + '\r\n'
|
|
for (const row of table.rows) {
|
|
const values = MONSTATS_DROP_COLUMNS.map(col => cell(table, row, col))
|
|
tsv += values.join('\t') + '\r\n'
|
|
}
|
|
return tsv
|
|
}
|
|
|
|
/**
|
|
* Extracts canonical SuperUniques columns into TSV format matching RAW_SUPERUNIQUES.
|
|
*/
|
|
export async function extractCanonicalSuperUniques(archives: MountedArchives): Promise<string> {
|
|
const bytes = await archives.read('data\\global\\excel\\SuperUniques.txt')
|
|
const table = parseTable(bytes)
|
|
|
|
let tsv = SUPERUNIQUES_DROP_COLUMNS.join('\t') + '\r\n'
|
|
for (const row of table.rows) {
|
|
const values = SUPERUNIQUES_DROP_COLUMNS.map(col => cell(table, row, col))
|
|
tsv += values.join('\t') + '\r\n'
|
|
}
|
|
return tsv
|
|
}
|
|
|
|
/**
|
|
* Packs extracted canonical drop data into src/data/canonical-drop-data.ts
|
|
*/
|
|
export async function packCanonicalDropData(options: {
|
|
baseDir?: string
|
|
targetFile?: string
|
|
} = {}): Promise<{ monstatsBytes: number; superUniquesBytes: number }> {
|
|
const { readFileSync, writeFileSync } = await import('node:fs')
|
|
const baseDir = options.baseDir ?? 'samples/d2'
|
|
const targetFile = options.targetFile ?? join(process.cwd(), 'src/data/canonical-drop-data.ts')
|
|
|
|
const archives = await openDropDataArchives(baseDir)
|
|
const monstatsTsv = await extractCanonicalMonStats(archives)
|
|
const superUniquesTsv = await extractCanonicalSuperUniques(archives)
|
|
|
|
let content = readFileSync(targetFile, 'utf-8')
|
|
// Update RAW_MONSTATS export
|
|
const monstatsPattern = /export const RAW_MONSTATS: string = "[\s\S]*?"\r?\n/
|
|
const newMonstatsExport = `export const RAW_MONSTATS: string = ${JSON.stringify(monstatsTsv)}\r\n`
|
|
if (monstatsPattern.test(content)) {
|
|
content = content.replace(monstatsPattern, newMonstatsExport)
|
|
} else {
|
|
content += `\r\n${newMonstatsExport}`
|
|
}
|
|
|
|
// Update RAW_SUPERUNIQUES export
|
|
const suPattern = /export const RAW_SUPERUNIQUES: string = "[\s\S]*?"\r?\n/
|
|
const newSuExport = `export const RAW_SUPERUNIQUES: string = ${JSON.stringify(superUniquesTsv)}\r\n`
|
|
if (suPattern.test(content)) {
|
|
content = content.replace(suPattern, newSuExport)
|
|
} else {
|
|
content += `\r\n${newSuExport}`
|
|
}
|
|
|
|
writeFileSync(targetFile, content, 'utf-8')
|
|
return {
|
|
monstatsBytes: Buffer.byteLength(monstatsTsv, 'utf-8'),
|
|
superUniquesBytes: Buffer.byteLength(superUniquesTsv, 'utf-8'),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Audits 1.13c ground truth parity between MPQ archives and embedded constants.
|
|
*/
|
|
export async function auditDropParity(
|
|
baseDir = 'samples/d2',
|
|
targetFile = join(process.cwd(), 'src/data/canonical-drop-data.ts'),
|
|
): Promise<{
|
|
monstatsMatch: boolean
|
|
superUniquesMatch: boolean
|
|
monsterKindsCount: number
|
|
superUniquesCount: number
|
|
}> {
|
|
const archives = await openDropDataArchives(baseDir)
|
|
const extractedMon = await extractCanonicalMonStats(archives)
|
|
const extractedSu = await extractCanonicalSuperUniques(archives)
|
|
|
|
const { readFileSync } = await import('node:fs')
|
|
let currentMon = RAW_MONSTATS
|
|
let currentSu = RAW_SUPERUNIQUES
|
|
try {
|
|
const content = readFileSync(targetFile, 'utf-8')
|
|
const monMatch = content.match(/export const RAW_MONSTATS: string = ("[\s\S]*?")\r?\n/)
|
|
if (monMatch) currentMon = JSON.parse(monMatch[1]!)
|
|
const suMatch = content.match(/export const RAW_SUPERUNIQUES: string = ("[\s\S]*?")\r?\n/)
|
|
if (suMatch) currentSu = JSON.parse(suMatch[1]!)
|
|
} catch {
|
|
// fallback to statically imported constants
|
|
}
|
|
|
|
const monstatsMatch = extractedMon === currentMon
|
|
const superUniquesMatch = extractedSu === currentSu
|
|
|
|
const enc = new TextEncoder()
|
|
const monKinds = readMonsterKinds(parseTable(enc.encode(currentMon)))
|
|
const suList = readSuperUniques(parseTable(enc.encode(currentSu)))
|
|
|
|
return {
|
|
monstatsMatch,
|
|
superUniquesMatch,
|
|
monsterKindsCount: monKinds.size,
|
|
superUniquesCount: suList.length,
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && process.argv[1].endsWith('pack-canonical-drop-data.ts')) {
|
|
const shouldPack = process.argv.includes('--pack') || process.argv.includes('--write')
|
|
const run = async () => {
|
|
if (shouldPack) {
|
|
console.log('Packing canonical drop data into src/data/canonical-drop-data.ts...')
|
|
const res = await packCanonicalDropData()
|
|
console.log(`Packed MonStats (${res.monstatsBytes} bytes) and SuperUniques (${res.superUniquesBytes} bytes).`)
|
|
}
|
|
const result = await auditDropParity()
|
|
console.log('=== Diablo II v1.13c Drop Data Parity Audit ===')
|
|
console.log(`MonStats extraction matches RAW_MONSTATS: ${result.monstatsMatch ? 'PASS' : 'FAIL'}`)
|
|
console.log(`SuperUniques extraction matches RAW_SUPERUNIQUES: ${result.superUniquesMatch ? 'PASS' : 'FAIL'}`)
|
|
console.log(`Monster kinds hydrated: ${result.monsterKindsCount} (expected: 734)`)
|
|
console.log(`SuperUniques hydrated: ${result.superUniquesCount} (expected: 66)`)
|
|
if (!result.monstatsMatch || !result.superUniquesMatch || result.monsterKindsCount !== 734 || result.superUniquesCount !== 66) {
|
|
process.exit(1)
|
|
}
|
|
console.log('All drop data contracts verified with 100% 1.13c parity.')
|
|
}
|
|
run().catch(err => {
|
|
console.error('Operation failed:', err)
|
|
process.exit(1)
|
|
})
|
|
}
|