diablo2-web/scripts/rebuild-pack-index-and-mons...

189 lines
6.0 KiB
TypeScript

import { readdir, readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
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 { cell, loadActTables } from '../src/game/acts.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { planLevelMonsters } from '../src/game/monsters.ts'
import { bakeEntities } from './pack-entity-assets.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PLAYER_WALK_SPEED_PX = 170
function levelSeed(ds1Name: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < ds1Name.length; i += 1) {
hash ^= ds1Name.charCodeAt(i)
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash
}
async function main() {
const archiveDir = 'samples/d2'
const outDir = 'samples/d2-packs'
const archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const tables = await loadActTables(archives)
const palettes: Record<string, number[]> = {}
for (let act = 1; act <= 5; act += 1) {
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
palettes[`act${String(act)}`] = [...pl2.rgb]
}
const levelsList: Array<{
act: number
levelId: number
kind: string
slug: string
label: string
levelName: string
ds1: string
path: string
cells: string
frames: number
objects: number
npcs: number
pages: number
objectPages: number
missingTiles: number
bytes: number
}> = []
let updatedMonstersCount = 0
for (let act = 1; act <= 5; act += 1) {
const actDir = join(outDir, `act${String(act)}`)
let entries: string[] = []
try {
entries = await readdir(actDir)
} catch {
continue
}
for (const label of entries.sort()) {
if (label === 'entities') continue
const dirPath = join(actDir, label)
const scenePath = join(dirPath, 'scene.json')
let raw: string
try {
raw = await readFile(scenePath, 'utf8')
} catch {
continue
}
const scene = JSON.parse(raw)
const levelId = Number(scene.levelId ?? 1)
const levelRow = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === levelId)
const drlg = levelRow ? Number(cell(tables.levels, levelRow, 'DrlgType')) : 2
const kind = drlg === 2 ? 'preset' : drlg === 1 ? 'maze' : 'wilderness'
// Determine seed matching pack-act-assets.ts
let seed = levelSeed(scene.ds1 ?? label)
const varMatch = label.match(/-var(\d+)$/)
if (varMatch) {
const v = Number(varMatch[1])
seed = (kind === 'maze' ? 0x5eed_0000 : 0x5eed_1000) + levelId * 10 + v
}
// Re-plan monsters using latest planLevelMonsters
const population = planLevelMonsters(
tables,
levelId,
(scene.cellsX ?? 80) * (scene.cellsY ?? 80),
seed,
PLAYER_WALK_SPEED_PX,
)
scene.monsters = {
types: population.types,
budget: population.budget,
packs: population.packs,
}
if (scene.stats) {
scene.stats.monsters = population.packs.reduce((sum, pack) => sum + pack.members.length, 0)
scene.stats.monsterPacks = population.packs.length
}
updatedMonstersCount += 1
const sceneBytes = new TextEncoder().encode(JSON.stringify(scene))
await writeFile(scenePath, sceneBytes)
// Update manifest.json if present
const manifestPath = join(dirPath, 'manifest.json')
try {
const mRaw = await readFile(manifestPath, 'utf8')
const manifest = JSON.parse(mRaw)
manifest.sceneBytes = sceneBytes.byteLength
manifest.sceneSha256 = createHash('sha256').update(sceneBytes).digest('hex')
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
} catch {
// ignore if no manifest
}
// Determine slug from levelId + levelName
const cleanName = String(scene.levelName ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const slug = `${String(levelId)}-${cleanName}`
// Compute total bytes (scene.json + PNG pages)
let pngBytes = 0
const filesInDir = await readdir(dirPath)
for (const f of filesInDir) {
if (f.endsWith('.png')) {
const st = await readFile(join(dirPath, f))
pngBytes += st.byteLength
}
}
levelsList.push({
act: scene.act ?? act,
levelId,
kind,
slug,
label,
levelName: scene.levelName,
ds1: scene.ds1 ?? label,
path: `act${String(act)}/${label}`,
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
frames: Array.isArray(scene.frames) ? scene.frames.length : 0,
objects: Array.isArray(scene.objects) ? scene.objects.length : 0,
npcs: Array.isArray(scene.npcs) ? scene.npcs.length : 0,
pages: Array.isArray(scene.pages) ? scene.pages.length : 0,
objectPages: Array.isArray(scene.objectPages) ? scene.objectPages.length : 0,
missingTiles: scene.missingTiles ?? 0,
bytes: pngBytes + sceneBytes.byteLength,
})
}
}
levelsList.sort((a, b) => a.act - b.act || a.levelId - b.levelId || a.label.localeCompare(b.label))
const index = {
version: 1,
generated: new Date().toISOString(),
archiveDir,
pageSize: 2048,
palettes,
levels: levelsList,
}
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
console.log(`Rebuilt samples/d2-packs/index.json with ${levelsList.length} map variants across Acts 1..5, updated ${updatedMonstersCount} scene monster plans.`)
// Now bake all entity atlases for Acts 1..5
await bakeEntities(archiveDir, outDir)
}
main().catch(err => {
console.error(err)
process.exit(1)
})