Compare commits
2 Commits
bad680600f
...
b6fc2512d4
| Author | SHA1 | Date |
|---|---|---|
|
|
b6fc2512d4 | |
|
|
230c20da67 |
|
|
@ -0,0 +1,805 @@
|
|||
/**
|
||||
* Headless browser audit of every baked level: all 136 Levels.txt ids, every copy the pack holds.
|
||||
*
|
||||
* Each index entry is opened as `acts.html?act=<act>&level=<label>&pack=<pack>` in a fresh tab of a
|
||||
* headless Chrome (SwiftShader WebGL2), served by a Vite dev server over the working tree. The
|
||||
* verdicts come from the page's published state (`window.__d2webAct`), the DevTools event stream,
|
||||
* the screenshot and the pack's own scene.json, never from looking at pictures. Every entry must
|
||||
* pass all of these:
|
||||
*
|
||||
* 1. Boot: the scene reaches `ready` with no `error` and shows the entry that was asked for (act and
|
||||
* label, loaded from the pack); the simulation ticks and the renderer has drawn frames.
|
||||
* 2. Clean page: no uncaught exception, no console error or failed assertion, no failed request, no
|
||||
* HTTP error status. The only exception is the short, fixed KNOWN_ABSENT list (Chrome's favicon
|
||||
* probe and four missiles the runtime aliases on purpose), which is reported instead.
|
||||
* 3. Rendering: the map has floor tiles, every atlas page arrives, the screenshot is not blank, and
|
||||
* every monster the level plans has its art (a monster without art is drawn as a red box).
|
||||
* 4. Pack integrity: no missing tile, no unresolved tile reference, no unplaced link edge.
|
||||
* 5. Spawn: the player stands on a walkable sub-tile of a cell that has a floor tile, inside the
|
||||
* largest connected walkable region that holds floor (largestWalkableRegionWithFloors, the region
|
||||
* finder the bake and the runtime use), so never in the void and never in a sealed pocket.
|
||||
* 6. Stability: the level does not switch by itself while the page settles (no spawn on a warp).
|
||||
*
|
||||
* Screenshots of the chosen levels (default: the Act I outdoor levels the DRLG port generates) are
|
||||
* kept in --out, next to report.json.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/audit-levels-browser.ts [--pack=samples/d2-packs] [--out=/tmp/level-audit]
|
||||
* [--jobs=6] [--act=N] [--levels=2-7,17,39] [--shots=2-7,17,39|all|none] [--timeout=180]
|
||||
* [--chrome=/path/to/chrome]
|
||||
*
|
||||
* --pack must lie inside the repository: the dev server serves only the working tree.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
import { inflateSync } from 'node:zlib'
|
||||
import { createServer } from 'vite'
|
||||
import { subTileAt, type CollisionGrid } from '../src/game/d2map.ts'
|
||||
import { largestWalkableRegionWithFloors } from '../src/game/level-links.ts'
|
||||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
/**
|
||||
* Playwright's Chromium comes first: a plain build without the managed policies and extensions the
|
||||
* workstation's google-chrome brings along.
|
||||
*/
|
||||
const CHROME_CANDIDATES = [
|
||||
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
|
||||
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
]
|
||||
/** Longest wait for one DevTools command; a hung command fails its entry instead of the audit. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000
|
||||
/** Levels.txt ids the audit screenshots by default: the Act I outdoor levels of the DRLG port. */
|
||||
const DEFAULT_SHOTS = '2-7,17,39'
|
||||
/** Levels.txt ids 1..136: every level of the game. */
|
||||
const LEVEL_COUNT = 136
|
||||
const WINDOW = { width: 1280, height: 840 } as const
|
||||
/** How long a ready page is watched for late errors or a level switch. */
|
||||
const SETTLE_MS = 1500
|
||||
const POLL_MS = 250
|
||||
/**
|
||||
* A screenshot counts as blank when fewer than this share of the sampled pixels is lit, or when it
|
||||
* has fewer distinct colours than this. A drawn map clears both by a wide margin; a black canvas, a
|
||||
* clear colour or a single placeholder fails both.
|
||||
*/
|
||||
const MIN_LIT_SHARE = 0.1
|
||||
const MIN_COLOURS = 64
|
||||
/** The bottom part of the window holds the HUD, which draws even when the map does not. */
|
||||
const MAP_SHARE_OF_HEIGHT = 0.75
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
|
||||
|
||||
interface IndexEntry {
|
||||
readonly act: number
|
||||
readonly levelId: number
|
||||
readonly label: string
|
||||
readonly path: string
|
||||
readonly missingTiles?: number
|
||||
readonly unplacedEdges?: number
|
||||
}
|
||||
|
||||
interface SceneJson {
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
readonly originX: number
|
||||
readonly originY: number
|
||||
readonly floors: readonly (readonly number[])[]
|
||||
readonly collision: {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly runs: readonly (readonly number[])[]
|
||||
}
|
||||
readonly stats: {
|
||||
readonly missingTiles: number
|
||||
readonly missingRefs?: readonly unknown[]
|
||||
readonly unplacedEdges?: readonly unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
/** The part of `window.__d2webAct` the audit reads. */
|
||||
interface PageState {
|
||||
readonly ready: boolean
|
||||
readonly error: string | null
|
||||
readonly source: string
|
||||
readonly act: number
|
||||
readonly variant: string
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
readonly floors: number
|
||||
readonly walls: number
|
||||
readonly frames: number
|
||||
readonly pagesLoaded: number
|
||||
readonly pagesTotal: number
|
||||
readonly pagesAtFirstFrame: number
|
||||
readonly drawCalls: number
|
||||
readonly quadsDrawn: number
|
||||
readonly tick: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly missingMonsterArt: readonly string[]
|
||||
readonly monsterArtErrors: number
|
||||
readonly monsterArtLayerFailures: number
|
||||
readonly monstersPlanned: number
|
||||
readonly loadMs: number
|
||||
}
|
||||
|
||||
const STATE_EXPRESSION = `(() => {
|
||||
const s = window.__d2webAct
|
||||
if (!s) return null
|
||||
return {
|
||||
ready: s.ready, error: s.error ?? null, source: s.source, act: s.act, variant: s.variant,
|
||||
cellsX: s.cellsX, cellsY: s.cellsY, floors: s.floors, walls: s.walls, frames: s.frames,
|
||||
pagesLoaded: s.pagesLoaded, pagesTotal: s.pagesTotal, pagesAtFirstFrame: s.pagesAtFirstFrame,
|
||||
drawCalls: s.drawCalls, quadsDrawn: s.quadsDrawn, tick: s.tick, x: s.x, y: s.y,
|
||||
missingMonsterArt: [...(s.missingMonsterArt ?? [])], monsterArtErrors: s.monsterArtErrors,
|
||||
monsterArtLayerFailures: s.monsterArtLayerFailures, monstersPlanned: s.monstersPlanned, loadMs: s.loadMs,
|
||||
}
|
||||
})()`
|
||||
|
||||
interface SpawnCheck {
|
||||
readonly subX: number
|
||||
readonly subY: number
|
||||
readonly inBounds: boolean
|
||||
readonly walkable: boolean
|
||||
readonly floor: boolean
|
||||
readonly inMainRegion: boolean
|
||||
/** Sub-tiles of the walkable component the player stands in (0 when standing on a blocked one). */
|
||||
readonly component: number
|
||||
/** Sub-tiles of the largest walkable region that holds floor. */
|
||||
readonly mainRegion: number
|
||||
}
|
||||
|
||||
interface ScreenStats {
|
||||
readonly litShare: number
|
||||
readonly colours: number
|
||||
}
|
||||
|
||||
interface Verdict {
|
||||
readonly label: string
|
||||
readonly act: number
|
||||
readonly levelId: number
|
||||
readonly ok: boolean
|
||||
readonly problems: readonly string[]
|
||||
readonly state: PageState | null
|
||||
readonly spawn: SpawnCheck | null
|
||||
readonly screen: ScreenStats | null
|
||||
readonly screenshot: string | null
|
||||
readonly ms: number
|
||||
/** Known-absent URLs the page asked for ({@link KNOWN_ABSENT}); reported, not failed. */
|
||||
readonly knownAbsent: readonly string[]
|
||||
}
|
||||
|
||||
interface AuditContext {
|
||||
readonly baseUrl: string
|
||||
readonly packDir: string
|
||||
readonly packPath: string
|
||||
readonly outDir: string
|
||||
readonly timeoutMs: number
|
||||
readonly shots: 'all' | ReadonlySet<number>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// DevTools protocol
|
||||
|
||||
interface CdpEvent {
|
||||
readonly method: string
|
||||
readonly params: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface CdpReply {
|
||||
readonly id?: number
|
||||
readonly method?: string
|
||||
readonly params?: Record<string, unknown>
|
||||
readonly result?: unknown
|
||||
readonly error?: { readonly message: string }
|
||||
}
|
||||
|
||||
/** One DevTools WebSocket: numbered commands and an event stream. */
|
||||
class CdpSession {
|
||||
private nextId = 1
|
||||
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
||||
private listener: ((event: CdpEvent) => void) | null = null
|
||||
|
||||
private constructor(private readonly socket: WebSocket) {
|
||||
socket.onmessage = message => {
|
||||
const reply = JSON.parse(String(message.data)) as CdpReply
|
||||
if (reply.id !== undefined) {
|
||||
const waiter = this.pending.get(reply.id)
|
||||
if (waiter === undefined) return
|
||||
this.pending.delete(reply.id)
|
||||
if (reply.error !== undefined) waiter.reject(new Error(reply.error.message))
|
||||
else waiter.resolve(reply.result)
|
||||
} else if (reply.method !== undefined) {
|
||||
this.listener?.({ method: reply.method, params: reply.params ?? {} })
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
for (const waiter of this.pending.values()) waiter.reject(new Error('the DevTools socket closed'))
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
static async connect(url: string): Promise<CdpSession> {
|
||||
const socket = new WebSocket(url)
|
||||
await new Promise<void>((done, fail) => {
|
||||
socket.onopen = () => done()
|
||||
socket.onerror = () => fail(new Error(`cannot open ${url}`))
|
||||
})
|
||||
return new CdpSession(socket)
|
||||
}
|
||||
|
||||
onEvent(listener: (event: CdpEvent) => void): void {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
send<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const id = this.nextId++
|
||||
return new Promise<T>((done, fail) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
fail(new Error(`${method} got no answer within ${String(COMMAND_TIMEOUT_MS / 1000)} s`))
|
||||
}, COMMAND_TIMEOUT_MS)
|
||||
this.pending.set(id, {
|
||||
resolve: value => { clearTimeout(timer); done(value as T) },
|
||||
reject: error => { clearTimeout(timer); fail(error) },
|
||||
})
|
||||
this.socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** A headless Chrome with its own profile; every audited entry gets a fresh tab. */
|
||||
class Browser {
|
||||
private constructor(
|
||||
private readonly child: ChildProcess,
|
||||
private readonly profile: string,
|
||||
private readonly port: number,
|
||||
) {}
|
||||
|
||||
static async launch(bin: string): Promise<Browser> {
|
||||
const profile = mkdtempSync(join(tmpdir(), 'level-audit-'))
|
||||
const child = spawn(bin, [
|
||||
'--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check',
|
||||
`--window-size=${String(WINDOW.width)},${String(WINDOW.height)}`, '--force-device-scale-factor=1',
|
||||
// Software WebGL2: there is no GPU here, and SwiftShader runs the real renderer path.
|
||||
'--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
|
||||
// A tab must keep animating while the next one opens.
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows',
|
||||
// gLinux routes even 127.0.0.1 through its PAC proxy unless told otherwise (scripts/browser/README.md).
|
||||
'--no-proxy-server',
|
||||
// Without this every HTTP request hangs before it is sent: the cookie store waits for the OS
|
||||
// keyring (Secret Service over D-Bus), which never answers a headless session. The netlog shows
|
||||
// the main-frame request stop after COMPUTED_PRIVACY_MODE; data: URLs, which need no cookies,
|
||||
// still load. The basic store keeps the cookie key in the profile.
|
||||
'--password-store=basic',
|
||||
// Nothing but the page under audit: no extension may sit between the tab and the dev server.
|
||||
'--disable-extensions', '--disable-component-extensions-with-background-pages',
|
||||
`--user-data-dir=${profile}`, '--remote-debugging-port=0', 'about:blank',
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
||||
const portFile = join(profile, 'DevToolsActivePort')
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
await sleep(100)
|
||||
if (child.exitCode !== null) throw new Error(`Chrome exited with ${String(child.exitCode)}: ${stderr.slice(-400)}`)
|
||||
if (!existsSync(portFile)) continue
|
||||
const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
|
||||
if (Number.isInteger(port) && port > 0) return new Browser(child, profile, port)
|
||||
}
|
||||
child.kill('SIGKILL')
|
||||
throw new Error(`Chrome opened no DevTools port within 20 s: ${stderr.slice(-400)}`)
|
||||
}
|
||||
|
||||
async openTab(): Promise<{ readonly id: string; readonly session: CdpSession }> {
|
||||
const response = await fetch(`http://127.0.0.1:${String(this.port)}/json/new?about:blank`, {
|
||||
method: 'PUT', signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
|
||||
})
|
||||
if (!response.ok) throw new Error(`DevTools /json/new answered HTTP ${String(response.status)}`)
|
||||
const target = await response.json() as { id: string; webSocketDebuggerUrl: string }
|
||||
return { id: target.id, session: await CdpSession.connect(target.webSocketDebuggerUrl) }
|
||||
}
|
||||
|
||||
async closeTab(id: string): Promise<void> {
|
||||
const response = await fetch(`http://127.0.0.1:${String(this.port)}/json/close/${id}`, { signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS) })
|
||||
if (!response.ok) throw new Error(`DevTools /json/close answered HTTP ${String(response.status)}`)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.child.exitCode === null) {
|
||||
const exited = once(this.child, 'exit')
|
||||
this.child.kill('SIGKILL')
|
||||
await exited
|
||||
}
|
||||
rmSync(this.profile, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Checks
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests that fail on every page by design and belong to no level: Chrome's own favicon probe,
|
||||
* and the missile art loadMissileArtMap (src/scene/act-scene.ts) looks for under each of its three
|
||||
* candidate bases before it aliases exactly these four missiles to the firebolt / lightningstrike
|
||||
* art (no pack bakes them). They are counted in the report instead of failing the entry; any other
|
||||
* failed request still fails it.
|
||||
*/
|
||||
const KNOWN_ABSENT = /\/favicon\.ico$|\/missiles\/(?:hydra|hydrafire|lightning|lightningbolt)\.(?:json|png)$/
|
||||
|
||||
/** Record whatever the page reports as broken: exceptions, console errors, failed or erroring requests. */
|
||||
function collectPageError(event: CdpEvent, requests: Map<string, string>, errors: Set<string>, knownAbsent: Set<string>): void {
|
||||
const params = event.params
|
||||
switch (event.method) {
|
||||
case 'Network.requestWillBeSent': {
|
||||
const request = params.request as { url?: string } | undefined
|
||||
requests.set(text(params.requestId), request?.url ?? '')
|
||||
break
|
||||
}
|
||||
case 'Network.responseReceived': {
|
||||
const response = params.response as { status?: number; url?: string } | undefined
|
||||
if (response?.status !== undefined && response.status >= 400) {
|
||||
const url = response.url ?? ''
|
||||
if (response.status === 404 && KNOWN_ABSENT.test(url)) knownAbsent.add(url)
|
||||
else errors.add(`HTTP ${String(response.status)} ${url}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Network.loadingFailed':
|
||||
if (params.canceled !== true) {
|
||||
errors.add(`request failed (${text(params.errorText)}): ${requests.get(text(params.requestId)) ?? text(params.requestId)}`)
|
||||
}
|
||||
break
|
||||
case 'Runtime.exceptionThrown': {
|
||||
const details = params.exceptionDetails as { text?: string; exception?: { description?: string } } | undefined
|
||||
const description = details?.exception?.description ?? details?.text ?? 'exception'
|
||||
errors.add(`uncaught ${description.split('\n').slice(0, 2).join(' | ').slice(0, 400)}`)
|
||||
break
|
||||
}
|
||||
case 'Runtime.consoleAPICalled':
|
||||
if (params.type === 'error' || params.type === 'assert') {
|
||||
const args = (params.args as { value?: unknown; description?: string; type?: string }[] | undefined) ?? []
|
||||
const message = args.map(arg => arg.value !== undefined ? text(arg.value) : arg.description ?? arg.type ?? '').join(' ')
|
||||
errors.add(`console.${text(params.type)}: ${message.slice(0, 400)}`)
|
||||
}
|
||||
break
|
||||
case 'Log.entryAdded': {
|
||||
const entry = params.entry as { level?: string; source?: string; text?: string; url?: string } | undefined
|
||||
if (entry?.level !== 'error') break
|
||||
// Chrome's console line for a 404 already counted above.
|
||||
if (entry.source === 'network' && entry.url !== undefined && KNOWN_ABSENT.test(entry.url) && entry.text?.includes('status of 404') === true) {
|
||||
knownAbsent.add(entry.url)
|
||||
break
|
||||
}
|
||||
errors.add(`${entry.source ?? 'log'}: ${entry.text ?? ''}${entry.url ? ` (${entry.url})` : ''}`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function readState(session: CdpSession): Promise<PageState | null> {
|
||||
const reply = await session.send<{ result: { value?: PageState | null }; exceptionDetails?: unknown }>(
|
||||
'Runtime.evaluate', { expression: STATE_EXPRESSION, returnByValue: true },
|
||||
)
|
||||
if (reply.exceptionDetails !== undefined) throw new Error(`reading the scene state threw: ${text(reply.exceptionDetails).slice(0, 300)}`)
|
||||
return reply.result.value ?? null
|
||||
}
|
||||
|
||||
async function waitForState(
|
||||
session: CdpSession,
|
||||
done: (state: PageState) => boolean,
|
||||
deadline: number,
|
||||
): Promise<PageState | null> {
|
||||
let state = await readState(session)
|
||||
while ((state === null || !done(state)) && Date.now() < deadline) {
|
||||
await sleep(POLL_MS)
|
||||
state = await readState(session)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function decodeCollision(collision: SceneJson['collision']): Uint8Array {
|
||||
const blocked = new Uint8Array(collision.width * collision.height)
|
||||
let at = 0
|
||||
for (const run of collision.runs) {
|
||||
const value = run[0]!
|
||||
const length = run[1]!
|
||||
if (value !== 0) blocked.fill(value, at, Math.min(at + length, blocked.length))
|
||||
at += length
|
||||
}
|
||||
if (at !== blocked.length) throw new Error(`collision runs cover ${String(at)} sub-tiles, the grid has ${String(blocked.length)}`)
|
||||
return blocked
|
||||
}
|
||||
|
||||
/** Where the player stands, judged on the scene's own collision map and floor list. */
|
||||
function checkSpawn(scene: SceneJson, x: number, y: number): SpawnCheck {
|
||||
const width = scene.collision.width
|
||||
const height = scene.collision.height
|
||||
if (width !== scene.cellsX * SUB_TILES_PER_TILE || height !== scene.cellsY * SUB_TILES_PER_TILE) {
|
||||
throw new Error(`collision grid ${String(width)}x${String(height)} does not match ${String(scene.cellsX)}x${String(scene.cellsY)} cells`)
|
||||
}
|
||||
const blocked = decodeCollision(scene.collision)
|
||||
const floorCells = new Uint8Array(scene.cellsX * scene.cellsY)
|
||||
for (const row of scene.floors) {
|
||||
const cellX = row[3]!
|
||||
const cellY = row[4]!
|
||||
if (cellX >= 0 && cellX < scene.cellsX && cellY >= 0 && cellY < scene.cellsY) floorCells[cellY * scene.cellsX + cellX] = 1
|
||||
}
|
||||
const hasFloor = (cellX: number, cellY: number): boolean =>
|
||||
cellX >= 0 && cellX < scene.cellsX && cellY >= 0 && cellY < scene.cellsY && floorCells[cellY * scene.cellsX + cellX] === 1
|
||||
const grid: CollisionGrid = {
|
||||
originX: scene.originX, originY: scene.originY, cellsX: scene.cellsX, cellsY: scene.cellsY, blocked, gridWidth: width,
|
||||
}
|
||||
const { subX, subY, inBounds } = subTileAt(grid, x, y)
|
||||
const region = largestWalkableRegionWithFloors({ cellsX: scene.cellsX, cellsY: scene.cellsY, gridWidth: width, gridHeight: height, blocked }, hasFloor)
|
||||
let mainRegion = 0
|
||||
for (const inside of region) mainRegion += inside
|
||||
const at = subY * width + subX
|
||||
const walkable = inBounds && blocked[at] === 0
|
||||
// The component the player stands in, four-connected like the region finder.
|
||||
let component = 0
|
||||
if (walkable) {
|
||||
const seen = new Uint8Array(blocked.length)
|
||||
const stack = [at]
|
||||
seen[at] = 1
|
||||
while (stack.length > 0) {
|
||||
const here = stack.pop()!
|
||||
component += 1
|
||||
const hx = here % width
|
||||
const neighbours = [
|
||||
hx > 0 ? here - 1 : -1,
|
||||
hx < width - 1 ? here + 1 : -1,
|
||||
here >= width ? here - width : -1,
|
||||
here + width < blocked.length ? here + width : -1,
|
||||
]
|
||||
for (const next of neighbours) {
|
||||
if (next >= 0 && seen[next] === 0 && blocked[next] === 0) {
|
||||
seen[next] = 1
|
||||
stack.push(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
subX,
|
||||
subY,
|
||||
inBounds,
|
||||
walkable,
|
||||
floor: inBounds && hasFloor(Math.floor(subX / SUB_TILES_PER_TILE), Math.floor(subY / SUB_TILES_PER_TILE)),
|
||||
inMainRegion: walkable && region[at] === 1,
|
||||
component,
|
||||
mainRegion,
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an 8-bit, non-interlaced RGB or RGBA PNG (what Page.captureScreenshot returns). */
|
||||
function decodePng(png: Buffer): { width: number; height: number; channels: number; pixels: Uint8Array } {
|
||||
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
|
||||
if (signature.some((byte, at) => png[at] !== byte)) throw new Error('the screenshot is not a PNG')
|
||||
let width = 0
|
||||
let height = 0
|
||||
let bitDepth = 0
|
||||
let colourType = -1
|
||||
let interlace = 0
|
||||
const chunks: Buffer[] = []
|
||||
for (let offset = 8; offset < png.length;) {
|
||||
const length = png.readUInt32BE(offset)
|
||||
const type = png.toString('latin1', offset + 4, offset + 8)
|
||||
const data = png.subarray(offset + 8, offset + 8 + length)
|
||||
if (type === 'IHDR') {
|
||||
width = data.readUInt32BE(0)
|
||||
height = data.readUInt32BE(4)
|
||||
bitDepth = data[8]!
|
||||
colourType = data[9]!
|
||||
interlace = data[12]!
|
||||
} else if (type === 'IDAT') {
|
||||
chunks.push(data)
|
||||
} else if (type === 'IEND') {
|
||||
break
|
||||
}
|
||||
offset += 12 + length
|
||||
}
|
||||
if (bitDepth !== 8 || interlace !== 0 || (colourType !== 2 && colourType !== 6)) {
|
||||
throw new Error(`unsupported screenshot PNG: depth ${String(bitDepth)}, colour type ${String(colourType)}, interlace ${String(interlace)}`)
|
||||
}
|
||||
const channels = colourType === 6 ? 4 : 3
|
||||
const raw = inflateSync(Buffer.concat(chunks))
|
||||
const stride = width * channels
|
||||
if (raw.length !== height * (stride + 1)) throw new Error('truncated screenshot PNG')
|
||||
const pixels = new Uint8Array(height * stride)
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const filter = raw[row * (stride + 1)]!
|
||||
const src = row * (stride + 1) + 1
|
||||
const dst = row * stride
|
||||
for (let i = 0; i < stride; i += 1) {
|
||||
const left = i >= channels ? pixels[dst + i - channels]! : 0
|
||||
const up = row > 0 ? pixels[dst - stride + i]! : 0
|
||||
const upLeft = row > 0 && i >= channels ? pixels[dst - stride + i - channels]! : 0
|
||||
const value = raw[src + i]!
|
||||
let out: number
|
||||
switch (filter) {
|
||||
case 0: out = value; break
|
||||
case 1: out = value + left; break
|
||||
case 2: out = value + up; break
|
||||
case 3: out = value + ((left + up) >> 1); break
|
||||
case 4: {
|
||||
const estimate = left + up - upLeft
|
||||
const dLeft = Math.abs(estimate - left)
|
||||
const dUp = Math.abs(estimate - up)
|
||||
const dUpLeft = Math.abs(estimate - upLeft)
|
||||
out = value + (dLeft <= dUp && dLeft <= dUpLeft ? left : dUp <= dUpLeft ? up : upLeft)
|
||||
break
|
||||
}
|
||||
default: throw new Error(`bad PNG filter ${String(filter)}`)
|
||||
}
|
||||
pixels[dst + i] = out & 0xff
|
||||
}
|
||||
}
|
||||
return { width, height, channels, pixels }
|
||||
}
|
||||
|
||||
/** How much of the map area of a screenshot is lit, and how many colours it shows. */
|
||||
function screenStats(png: Buffer): ScreenStats {
|
||||
const image = decodePng(png)
|
||||
const rows = Math.floor(image.height * MAP_SHARE_OF_HEIGHT)
|
||||
const colours = new Set<number>()
|
||||
let lit = 0
|
||||
let sampled = 0
|
||||
for (let row = 0; row < rows; row += 2) {
|
||||
for (let column = 0; column < image.width; column += 2) {
|
||||
const at = (row * image.width + column) * image.channels
|
||||
const r = image.pixels[at]!
|
||||
const g = image.pixels[at + 1]!
|
||||
const b = image.pixels[at + 2]!
|
||||
sampled += 1
|
||||
if (Math.max(r, g, b) > 24) lit += 1
|
||||
colours.add(((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3))
|
||||
}
|
||||
}
|
||||
return { litShare: lit / sampled, colours: colours.size }
|
||||
}
|
||||
|
||||
async function auditEntry(browser: Browser, entry: IndexEntry, context: AuditContext): Promise<Verdict> {
|
||||
const started = Date.now()
|
||||
const deadline = started + context.timeoutMs
|
||||
const problems: string[] = []
|
||||
const pageErrors = new Set<string>()
|
||||
const knownAbsent = new Set<string>()
|
||||
const requests = new Map<string, string>()
|
||||
let state: PageState | null = null
|
||||
let spawnCheck: SpawnCheck | null = null
|
||||
let screen: ScreenStats | null = null
|
||||
let screenshot: string | null = null
|
||||
|
||||
const scene = JSON.parse(readFileSync(join(context.packDir, entry.path, 'scene.json'), 'utf8')) as SceneJson
|
||||
if ((entry.missingTiles ?? 0) !== 0 || scene.stats.missingTiles !== 0) problems.push(`missing tiles: index ${String(entry.missingTiles)}, scene ${String(scene.stats.missingTiles)}`)
|
||||
if ((scene.stats.missingRefs?.length ?? 0) !== 0) problems.push(`unresolved tile references: ${text(scene.stats.missingRefs).slice(0, 300)}`)
|
||||
if ((entry.unplacedEdges ?? 0) !== 0 || (scene.stats.unplacedEdges?.length ?? 0) !== 0) problems.push(`unplaced link edges: ${text(scene.stats.unplacedEdges).slice(0, 300)}`)
|
||||
|
||||
const { id, session } = await browser.openTab()
|
||||
try {
|
||||
session.onEvent(event => collectPageError(event, requests, pageErrors, knownAbsent))
|
||||
await session.send('Runtime.enable')
|
||||
await session.send('Log.enable')
|
||||
await session.send('Network.enable')
|
||||
await session.send('Page.enable')
|
||||
const query = new URLSearchParams({ act: String(entry.act), level: entry.label, pack: context.packPath, lighting: 'noon' })
|
||||
await session.send('Page.navigate', { url: `${context.baseUrl}/acts.html?${query.toString()}` })
|
||||
|
||||
const atReady = await waitForState(session, s => s.ready || s.error !== null, deadline)
|
||||
if (atReady === null) {
|
||||
problems.push('the page never published window.__d2webAct')
|
||||
} else if (atReady.error !== null) {
|
||||
problems.push(`scene error: ${atReady.error}`)
|
||||
} else if (!atReady.ready) {
|
||||
problems.push(`not ready after ${String(context.timeoutMs / 1000)} s`)
|
||||
} else {
|
||||
if (atReady.source !== 'pack') problems.push(`loaded from "${atReady.source}", not from the pack`)
|
||||
if (atReady.act !== entry.act || atReady.variant !== entry.label) problems.push(`showed act ${String(atReady.act)} "${atReady.variant}"`)
|
||||
|
||||
const drawn = await waitForState(
|
||||
session,
|
||||
s => s.error !== null || (s.pagesLoaded >= s.pagesTotal && s.pagesAtFirstFrame >= 0 && s.tick > 0 && s.drawCalls > 0),
|
||||
deadline,
|
||||
)
|
||||
// The spawn is read from the first ticked state: `ready` is published before the frame loop
|
||||
// first writes the player position (state.x/y are written together with state.tick).
|
||||
if (drawn === null || drawn.tick <= 0) {
|
||||
problems.push('no simulation tick, so no spawn position to check')
|
||||
} else {
|
||||
spawnCheck = checkSpawn(scene, drawn.x, drawn.y)
|
||||
if (!spawnCheck.inBounds) problems.push(`spawn outside the map at (${String(drawn.x)}, ${String(drawn.y)})`)
|
||||
else if (!spawnCheck.walkable) problems.push(`spawn on a blocked sub-tile (${String(spawnCheck.subX)}, ${String(spawnCheck.subY)})`)
|
||||
else if (!spawnCheck.inMainRegion) problems.push(`spawn in a walkable pocket of ${String(spawnCheck.component)} sub-tiles, outside the main region of ${String(spawnCheck.mainRegion)}`)
|
||||
if (spawnCheck.inBounds && !spawnCheck.floor) problems.push(`spawn on a cell without a floor tile (sub-tile ${String(spawnCheck.subX)}, ${String(spawnCheck.subY)})`)
|
||||
}
|
||||
await sleep(SETTLE_MS)
|
||||
state = await readState(session)
|
||||
if (state === null) {
|
||||
problems.push('the scene state disappeared')
|
||||
} else {
|
||||
if (state.error !== null) problems.push(`scene error after ready: ${state.error}`)
|
||||
if (state.act !== entry.act || state.variant !== entry.label) problems.push(`switched by itself to act ${String(state.act)} "${state.variant}"`)
|
||||
if (drawn === null || state.pagesLoaded < state.pagesTotal) problems.push(`atlas pages loaded ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`)
|
||||
if (state.pagesAtFirstFrame < 0) problems.push('no frame drawn')
|
||||
if (state.tick <= 0) problems.push('the simulation does not tick')
|
||||
if (state.drawCalls <= 0 || state.quadsDrawn <= 0) problems.push(`nothing drawn (draw calls ${String(state.drawCalls)}, quads ${String(state.quadsDrawn)})`)
|
||||
if (state.floors <= 0 || state.frames <= 0) problems.push(`empty map (floors ${String(state.floors)}, frames ${String(state.frames)})`)
|
||||
if (state.cellsX !== scene.cellsX || state.cellsY !== scene.cellsY) problems.push(`page shows ${String(state.cellsX)}x${String(state.cellsY)} cells, the scene has ${String(scene.cellsX)}x${String(scene.cellsY)}`)
|
||||
if (state.missingMonsterArt.length > 0) problems.push(`monsters without art: ${state.missingMonsterArt.join(', ')}`)
|
||||
if (state.monsterArtErrors !== 0 || state.monsterArtLayerFailures !== 0) problems.push(`monster art failures: ${String(state.monsterArtErrors)} loads, ${String(state.monsterArtLayerFailures)} layers`)
|
||||
}
|
||||
|
||||
const shot = await session.send<{ data: string }>('Page.captureScreenshot', { format: 'png' })
|
||||
const png = Buffer.from(shot.data, 'base64')
|
||||
screen = screenStats(png)
|
||||
if (screen.litShare < MIN_LIT_SHARE || screen.colours < MIN_COLOURS) {
|
||||
problems.push(`blank screen (lit ${(screen.litShare * 100).toFixed(1)}%, ${String(screen.colours)} colours)`)
|
||||
}
|
||||
if (context.shots === 'all' || context.shots.has(entry.levelId) || problems.length > 0 || pageErrors.size > 0) {
|
||||
screenshot = join(context.outDir, `${entry.label}.png`)
|
||||
writeFileSync(screenshot, png)
|
||||
}
|
||||
}
|
||||
if (state === null) state = atReady
|
||||
} finally {
|
||||
session.close()
|
||||
await browser.closeTab(id)
|
||||
}
|
||||
for (const error of pageErrors) problems.push(error)
|
||||
return {
|
||||
label: entry.label,
|
||||
act: entry.act,
|
||||
levelId: entry.levelId,
|
||||
ok: problems.length === 0,
|
||||
problems,
|
||||
state,
|
||||
spawn: spawnCheck,
|
||||
screen,
|
||||
screenshot,
|
||||
ms: Date.now() - started,
|
||||
knownAbsent: [...knownAbsent].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Driver
|
||||
|
||||
function parseIds(spec: string): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
for (const part of spec.split(',')) {
|
||||
const range = /^(\d+)(?:-(\d+))?$/.exec(part.trim())
|
||||
if (range === null) throw new Error(`bad level list "${spec}"`)
|
||||
const from = Number(range[1])
|
||||
const to = range[2] === undefined ? from : Number(range[2])
|
||||
if (to < from) throw new Error(`bad level range "${part}"`)
|
||||
for (let levelId = from; levelId <= to; levelId += 1) ids.add(levelId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): Map<string, string> {
|
||||
const known = new Set(['pack', 'out', 'jobs', 'act', 'levels', 'shots', 'timeout', 'chrome'])
|
||||
const args = new Map<string, string>()
|
||||
for (const arg of argv) {
|
||||
const match = /^--([a-z]+)=(.+)$/.exec(arg)
|
||||
if (match === null || !known.has(match[1]!)) throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`)
|
||||
args.set(match[1]!, match[2]!)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function positiveInteger(value: string, name: string): number {
|
||||
const number = Number(value)
|
||||
if (!Number.isInteger(number) || number < 1) throw new Error(`--${name} must be a positive integer, got ${value}`)
|
||||
return number
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const packDir = resolve(ROOT, args.get('pack') ?? 'samples/d2-packs')
|
||||
const packPath = relative(ROOT, packDir).split(sep).join('/')
|
||||
if (packPath === '' || packPath.startsWith('..')) throw new Error(`--pack must be a directory inside ${ROOT}: the dev server serves only the repository`)
|
||||
const index = JSON.parse(readFileSync(join(packDir, 'index.json'), 'utf8')) as { levels: IndexEntry[] }
|
||||
const jobs = positiveInteger(args.get('jobs') ?? '6', 'jobs')
|
||||
const timeoutMs = positiveInteger(args.get('timeout') ?? '180', 'timeout') * 1000
|
||||
const outDir = resolve(args.get('out') ?? '/tmp/level-audit')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
const act = args.has('act') ? positiveInteger(args.get('act')!, 'act') : undefined
|
||||
const levels = args.has('levels') ? parseIds(args.get('levels')!) : undefined
|
||||
const shotsSpec = args.get('shots') ?? DEFAULT_SHOTS
|
||||
const shots = shotsSpec === 'all' ? 'all' : shotsSpec === 'none' ? new Set<number>() : parseIds(shotsSpec)
|
||||
const chrome = args.get('chrome') ?? CHROME_CANDIDATES.find(candidate => existsSync(candidate))
|
||||
if (chrome === undefined || !existsSync(chrome)) throw new Error(`no Chrome binary; tried ${CHROME_CANDIDATES.join(', ')} (pass --chrome=)`)
|
||||
|
||||
const entries = index.levels.filter(entry => (act === undefined || entry.act === act) && (levels === undefined || levels.has(entry.levelId)))
|
||||
if (entries.length === 0) throw new Error('no index entry matches the filters')
|
||||
const coverageProblems: string[] = []
|
||||
if (act === undefined && levels === undefined) {
|
||||
const present = new Set(entries.map(entry => entry.levelId))
|
||||
const absent: number[] = []
|
||||
for (let levelId = 1; levelId <= LEVEL_COUNT; levelId += 1) if (!present.has(levelId)) absent.push(levelId)
|
||||
if (absent.length > 0) coverageProblems.push(`the pack has no map for level ids ${absent.join(', ')}`)
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
configFile: join(ROOT, 'vite.config.ts'),
|
||||
logLevel: 'error',
|
||||
clearScreen: false,
|
||||
server: { host: '127.0.0.1', port: 5600 + Math.floor(Math.random() * 300), strictPort: false },
|
||||
})
|
||||
await server.listen()
|
||||
const address = server.httpServer?.address()
|
||||
if (address === null || address === undefined || typeof address === 'string') throw new Error('the dev server has no TCP address')
|
||||
const context: AuditContext = { baseUrl: `http://127.0.0.1:${String(address.port)}`, packDir, packPath, outDir, timeoutMs, shots }
|
||||
console.log(`auditing ${String(entries.length)} maps of ${packPath} (${String(new Set(entries.map(entry => entry.levelId)).size)} level ids) with ${String(jobs)} browsers; dev server ${context.baseUrl}; Chrome ${chrome}`)
|
||||
|
||||
const browsers: Browser[] = []
|
||||
const verdicts: Verdict[] = []
|
||||
const started = Date.now()
|
||||
try {
|
||||
for (let i = 0; i < Math.min(jobs, entries.length); i += 1) browsers.push(await Browser.launch(chrome))
|
||||
const queue = [...entries]
|
||||
const report = (verdict: Verdict): void => {
|
||||
verdicts.push(verdict)
|
||||
const tail = verdict.ok ? `${(verdict.ms / 1000).toFixed(1)} s` : verdict.problems.join(' ; ')
|
||||
console.log(`[${String(verdicts.length).padStart(3)}/${String(entries.length)}] ${verdict.ok ? 'ok ' : 'FAIL'} ${verdict.label.padEnd(44)} ${tail}`)
|
||||
}
|
||||
const run = async (browser: Browser, entry: IndexEntry): Promise<void> => {
|
||||
try {
|
||||
report(await auditEntry(browser, entry, context))
|
||||
} catch (error) {
|
||||
report({
|
||||
label: entry.label, act: entry.act, levelId: entry.levelId, ok: false,
|
||||
problems: [`audit error: ${(error as Error).message}`], state: null, spawn: null, screen: null, screenshot: null, ms: 0,
|
||||
knownAbsent: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
// One page first: it makes the dev server transform the modules once instead of in every tab.
|
||||
await run(browsers[0]!, queue.shift()!)
|
||||
await Promise.all(browsers.map(async browser => {
|
||||
for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) await run(browser, entry)
|
||||
}))
|
||||
} finally {
|
||||
for (const browser of browsers) await browser.close()
|
||||
await server.close()
|
||||
}
|
||||
|
||||
const order = new Map(entries.map((entry, at) => [entry.label, at]))
|
||||
verdicts.sort((a, b) => order.get(a.label)! - order.get(b.label)!)
|
||||
const failed = verdicts.filter(verdict => !verdict.ok)
|
||||
const failedIds = new Set(failed.map(verdict => verdict.levelId))
|
||||
const knownAbsent = [...new Set(verdicts.flatMap(verdict => verdict.knownAbsent))].sort()
|
||||
writeFileSync(join(outDir, 'report.json'), JSON.stringify({
|
||||
pack: packPath,
|
||||
generated: new Date().toISOString(),
|
||||
maps: verdicts.length,
|
||||
levelIds: new Set(verdicts.map(verdict => verdict.levelId)).size,
|
||||
failedMaps: failed.length,
|
||||
coverageProblems,
|
||||
knownAbsent,
|
||||
verdicts: verdicts.map(verdict => ({ ...verdict, knownAbsent: verdict.knownAbsent.length })),
|
||||
}, null, 1))
|
||||
console.log(`\n${String(verdicts.length - failed.length)}/${String(verdicts.length)} maps passed, ${String(new Set(verdicts.map(v => v.levelId)).size - failedIds.size)}/${String(new Set(verdicts.map(v => v.levelId)).size)} level ids clean, in ${((Date.now() - started) / 1000).toFixed(0)} s; report ${join(outDir, 'report.json')}`)
|
||||
if (knownAbsent.length > 0) console.log(`known-absent requests (reported, not failed): ${knownAbsent.join(', ')}`)
|
||||
for (const problem of coverageProblems) console.log(`COVERAGE: ${problem}`)
|
||||
for (const verdict of failed) console.log(`FAIL ${verdict.label}: ${verdict.problems.join(' ; ')}`)
|
||||
if (failed.length > 0 || coverageProblems.length > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Regenerates the committed native-oracle fixtures of tests/drlg-act1-oracle.test.ts.
|
||||
*
|
||||
* Each fixture is the D2MOO oracle's dump (tools/d2moo-oracle, schema 3) of the Act I outdoor levels
|
||||
* for one game seed: difficulty 0, one shared act, every room activated through
|
||||
* DRLGROOMTILE_AddRoomMapTiles. The test diffs the TypeScript port against them without needing the
|
||||
* native binary.
|
||||
*
|
||||
* Usage: npx tsx scripts/drlg-fixtures.ts [output-directory]
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import { act1OutdoorLevelIds } from '../src/game/drlg/drlg-map.ts'
|
||||
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
|
||||
import { fsDrlgSource, ORACLE_DATA, runOracle } from './lib/drlg-oracle.ts'
|
||||
|
||||
/** The seeds the test pins: an arbitrary one plus the three Act I layouts the bake ships (0x5eed0100 + v). */
|
||||
export const DRLG_FIXTURE_SEEDS: readonly number[] = [0x12345678, 0x5eed0100, 0x5eed0101, 0x5eed0102]
|
||||
|
||||
function main(): void {
|
||||
const outDir = resolve(process.argv[2] ?? join(import.meta.dirname, '../tests/fixtures/d2moo-oracle'))
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
const levels = act1OutdoorLevelIds(loadDrlgTables(fsDrlgSource(ORACLE_DATA)))
|
||||
for (const seed of DRLG_FIXTURE_SEEDS) {
|
||||
const { doc } = runOracle({ seed, levels, difficulty: 0, isolated: false })
|
||||
if (doc.schema !== 3) throw new Error(`oracle wrote schema ${String(doc.schema)}, expected 3 (rebuild it with tools/d2moo-oracle/build.sh)`)
|
||||
const json = JSON.stringify(doc)
|
||||
const path = join(outDir, `act1-seed-${(seed >>> 0).toString(16).padStart(8, '0')}.json.gz`)
|
||||
const gz = gzipSync(json, { level: 9 })
|
||||
writeFileSync(path, gz)
|
||||
console.log(`${path}: ${(json.length / 1048576).toFixed(1)} MB JSON, ${(gz.length / 1024).toFixed(0)} KB gzip`)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main()
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* MPQ-backed game data for the DRLG port (offline bake and tooling).
|
||||
*
|
||||
* The port reads its inputs synchronously, exactly like D2Common does from the mounted archives
|
||||
* (see src/game/drlg/drlg-source.ts). MPQ reads are asynchronous, so this module preloads every file
|
||||
* the port can open for one act and serves them from memory:
|
||||
* - the compiled tables `data\global\excel\<name>.bin` ({@link DRLG_TABLE_NAMES});
|
||||
* - every DS1 the tables reference (LvlPrest File1..6, LvlSub File), for all acts, because level
|
||||
* generation behind warps (sub_6FD77BB0 -> DRLG_InitLevel) opens presets of any level it reaches;
|
||||
* - the DT1 libraries of the act's level types (LvlTypes File1..32, loaded by
|
||||
* DRLGROOMTILE_LoadDT1FilesForRoom for activated rooms) plus the hardcoded libraries of
|
||||
* DRLGROOMTILE_LoadDT1FilesForRoom (DrlgRoomTile.cpp:1249-1256) and DRLG_AllocDrlg
|
||||
* (DrlgDrlg.cpp:47-76).
|
||||
* Anything else is not loaded and reading it throws (memoryDrlgSource), so a gap fails the bake
|
||||
* instead of silently changing the generated map. The file list is the same one
|
||||
* scripts/extract-d2moo-tables.ts extracts for the native oracle (the DT1 list restricted to the act),
|
||||
* and the reads use the game's archive priority, so the bake and the oracle consume identical bytes.
|
||||
*/
|
||||
|
||||
import { decodeDt1, type Dt1 } from '../../src/formats/dt1.ts'
|
||||
import { cell, type D2Table } from '../../src/game/acts.ts'
|
||||
import type { DrlgLevelInput } from '../../src/game/drlg/drlg-map.ts'
|
||||
import { loadDrlgTables, type DrlgDataSource, type DrlgTableName, type DrlgTables } from '../../src/game/drlg/drlg-tables.ts'
|
||||
import { DRLG_TABLE_NAMES, drlgReferencedDs1Files, memoryDrlgSource, normalizeDrlgPath } from '../../src/game/drlg/drlg-source.ts'
|
||||
|
||||
/**
|
||||
* DT1 libraries D2Common opens by hardcoded name (full archive paths, as the port requests them):
|
||||
* the three universal libraries of DRLGROOMTILE_LoadDT1FilesForRoom (Blank, InvisWal, Warp) and the
|
||||
* act libraries of DRLG_AllocDrlg.
|
||||
*/
|
||||
export const DRLG_HARDCODED_DT1_FILES: readonly string[] = [
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Outdoors\\Blank.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\InvisWal.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Town\\Floor.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\Act2\\Town\\Ground.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\ACT3\\Kurast\\sets.dt1',
|
||||
]
|
||||
|
||||
/** Reads one archive member (backslash path, case-insensitive), throwing when it does not exist. */
|
||||
export type ArchiveReader = (mpqPath: string) => Promise<Uint8Array>
|
||||
|
||||
export interface DrlgMpqData {
|
||||
/** Synchronous source over the preloaded bytes (throws for anything not preloaded). */
|
||||
readonly source: DrlgDataSource
|
||||
/** The parsed tables (immutable; pass them to createDrlgEnv to avoid re-parsing). */
|
||||
readonly tables: DrlgTables
|
||||
/** Archive paths of the preloaded DS1 and DT1 files. */
|
||||
readonly ds1Files: readonly string[]
|
||||
readonly dt1Files: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* DT1 libraries of every level type of one act: the non-placeholder LvlTypes File columns of the rows
|
||||
* whose `Act` (D2LvlTypesTxt.nAct, 1-based as in LvlTypes.txt) is `act1Based`, plus
|
||||
* {@link DRLG_HARDCODED_DT1_FILES}.
|
||||
*/
|
||||
export function drlgActDt1Files(t: DrlgTables, act1Based: number): string[] {
|
||||
if (!Number.isInteger(act1Based) || act1Based < 1 || act1Based > 5) {
|
||||
throw new Error(`drlgActDt1Files: act ${act1Based} is not 1..5`)
|
||||
}
|
||||
const out = new Map<string, string>()
|
||||
const add = (path: string): void => {
|
||||
const key = normalizeDrlgPath(path)
|
||||
if (!out.has(key)) out.set(key, path)
|
||||
}
|
||||
let rows = 0
|
||||
for (const rec of t.lvlTypes) {
|
||||
if (rec.nAct !== act1Based) continue
|
||||
rows += 1
|
||||
// DATATBLS_LoadLevelTypesTxt leaves names of one character or less ("0", "") unprefixed: they are
|
||||
// unused slots that DRLGROOMTILE_LoadDT1FilesForRoom never opens.
|
||||
for (const f of rec.szFile) if (f.length > 1) add(f)
|
||||
}
|
||||
if (rows === 0) throw new Error(`drlgActDt1Files: LvlTypes has no rows for act ${act1Based}`)
|
||||
for (const f of DRLG_HARDCODED_DT1_FILES) add(f)
|
||||
return [...out.values()].sort((a, b) => normalizeDrlgPath(a).localeCompare(normalizeDrlgPath(b)))
|
||||
}
|
||||
|
||||
/** The compiled tables `data\global\excel\<name>.bin` of {@link DRLG_TABLE_NAMES}. */
|
||||
async function readDrlgTableBytes(read: ArchiveReader): Promise<Map<DrlgTableName, Uint8Array>> {
|
||||
const tableBytes = new Map<DrlgTableName, Uint8Array>()
|
||||
for (const name of DRLG_TABLE_NAMES) {
|
||||
tableBytes.set(name, await read(`data\\global\\excel\\${name}.bin`))
|
||||
}
|
||||
return tableBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the compiled tables: enough to map a generated level (src/game/drlg/drlg-map.ts), not to
|
||||
* generate one.
|
||||
*
|
||||
* @param read - archive reader with the game's priority (e.g. MountedArchives.read).
|
||||
*/
|
||||
export async function loadDrlgMpqTables(read: ArchiveReader): Promise<DrlgTables> {
|
||||
return loadDrlgTables(memoryDrlgSource(await readDrlgTableBytes(read), new Map()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the DRLG inputs for one act from the archives.
|
||||
*
|
||||
* @param read - archive reader with the game's priority (e.g. MountedArchives.read).
|
||||
* @param act1Based - the act whose level types' DT1 libraries are loaded (1..5).
|
||||
*/
|
||||
export async function loadDrlgMpqData(read: ArchiveReader, act1Based: number): Promise<DrlgMpqData> {
|
||||
const tableBytes = await readDrlgTableBytes(read)
|
||||
const tables = loadDrlgTables(memoryDrlgSource(tableBytes, new Map()))
|
||||
const files = new Map<string, Uint8Array>()
|
||||
const ds1Files = drlgReferencedDs1Files(tables)
|
||||
const dt1Files = drlgActDt1Files(tables, act1Based)
|
||||
for (const path of [...ds1Files, ...dt1Files]) {
|
||||
const key = normalizeDrlgPath(path)
|
||||
if (!files.has(key)) files.set(key, await read(path))
|
||||
}
|
||||
return { source: memoryDrlgSource(tableBytes, files), tables, ds1Files, dt1Files }
|
||||
}
|
||||
|
||||
/**
|
||||
* SuperUniques.txt `Superunique` ids in record order: the landmark ids of SuperUnique preset units
|
||||
* (src/game/drlg/drlg-map.ts `DrlgMapContext.superUniqueIds`).
|
||||
*
|
||||
* The compiled table has no record for the text file's `Expansion` separator row, so the text rows are
|
||||
* filtered the same way; each remaining row's `hcIdx` must equal its record index and the row count
|
||||
* must equal the compiled table's.
|
||||
*
|
||||
* @param superuniques - SuperUniques.txt.
|
||||
* @param tables - the compiled tables the DRLG uses.
|
||||
* @returns the ids, by SuperUniques record.
|
||||
*/
|
||||
export function drlgSuperUniqueIds(superuniques: D2Table, tables: DrlgTables): string[] {
|
||||
const ids = superuniques.rows
|
||||
.filter(row => cell(superuniques, row, 'Superunique') !== 'Expansion')
|
||||
.map((row, at) => {
|
||||
const hcIdx = Number(cell(superuniques, row, 'hcIdx'))
|
||||
if (hcIdx !== at) throw new Error(`SuperUniques.txt record ${String(at)} has hcIdx ${String(hcIdx)}`)
|
||||
return cell(superuniques, row, 'Superunique').trim()
|
||||
})
|
||||
if (ids.length !== tables.nSuperUniquesTxtRecordCount) {
|
||||
throw new Error(`SuperUniques.txt has ${String(ids.length)} records, superuniques.bin ${String(tables.nSuperUniquesTxtRecordCount)}`)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded DT1 libraries the tiles of one generated level come from, read through a synchronous
|
||||
* DRLG source (the same bytes the generator loaded).
|
||||
*
|
||||
* @param input - the level, from src/game/drlg/drlg-map.ts `drlgLevelInputFromDump`.
|
||||
* @param source - the DRLG data source the level was generated from.
|
||||
* @param cache - decoded libraries by normalized path, shared across levels.
|
||||
* @returns the libraries the level's tiles name, by normalized path.
|
||||
*/
|
||||
export function drlgLevelDt1s(input: DrlgLevelInput, source: DrlgDataSource, cache: Map<string, Dt1>): Map<string, Dt1> {
|
||||
const out = new Map<string, Dt1>()
|
||||
for (const t of input.tiles) {
|
||||
if (t.tile === null || out.has(t.tile.library)) continue
|
||||
let lib = cache.get(t.tile.library)
|
||||
if (lib === undefined) {
|
||||
lib = decodeDt1(source.readFile(t.tile.library))
|
||||
cache.set(t.tile.library, lib)
|
||||
}
|
||||
out.set(t.tile.library, lib)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ import { performance } from 'node:perf_hooks'
|
|||
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, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, ACT_TOWNS } from '../src/game/acts.ts'
|
||||
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, townLevelForAct, ACT_TOWNS } from '../src/game/acts.ts'
|
||||
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
|
|
@ -58,6 +58,20 @@ import { generateWilderness } from '../src/game/wilderness.ts'
|
|||
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||||
import { generatePreset } from '../src/game/preset.ts'
|
||||
import type { PresetPiece } from '../src/game/preset.ts'
|
||||
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
|
||||
import type { DrlgEntrance, DrlgLevelInput } from '../src/game/drlg/drlg-map.ts'
|
||||
import { dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
|
||||
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
|
||||
import type { DrlgTables } from '../src/game/drlg/drlg-tables.ts'
|
||||
import { WorldVariants } from '../src/game/act-variants.ts'
|
||||
import type { ActLayout, VariantEntry, VariantIndex } from '../src/game/act-variants.ts'
|
||||
import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/drlg-mpq-source.ts'
|
||||
|
||||
/**
|
||||
* An entrance a generator reports: the wilderness generator's, or the DRLG adapter's (seams carry a
|
||||
* span) with the display label the bake gives it.
|
||||
*/
|
||||
type GeneratorEntrance = WildernessEntrance | (DrlgEntrance & { readonly label: string })
|
||||
export interface MazeWarp {
|
||||
readonly room: number
|
||||
readonly direction: 'up' | 'down'
|
||||
|
|
@ -437,6 +451,128 @@ function levelNameOf(levelId: number): string {
|
|||
return worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* DRLG port (Act I outdoor levels)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Copies the bake makes of every act the DRLG port generates, one game seed each. */
|
||||
const DRLG_ACT_VARIANTS = 3
|
||||
|
||||
/**
|
||||
* The game seed copy `variant` of `act` is generated from.
|
||||
*
|
||||
* D2 generates a whole act from one seed (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections ->
|
||||
* DRLG_InitLevel), so the seed belongs to the (act, copy) pair, never to a level. Act I's three are the
|
||||
* seeds of the committed oracle fixtures (tests/fixtures/d2moo-oracle/act1-seed-5eed010{0,1,2}), so every
|
||||
* baked copy is one the native D2MOO oracle generates identically.
|
||||
*
|
||||
* @param act - act number (1..5).
|
||||
* @param variant - copy number (0-based, the index entries' `actVariant`).
|
||||
* @returns the game seed.
|
||||
*/
|
||||
function actGameSeed(act: number, variant: number): number {
|
||||
return (0x5eed_0000 + act * 0x100 + variant) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The levels of an act the DRLG port generates: Act I's outdoor levels (Levels.txt DrlgType 3). The
|
||||
* other acts still bake through the legacy generators.
|
||||
*
|
||||
* @param t - the DRLG tables.
|
||||
* @param act - act number (1..5).
|
||||
* @returns level ids, ascending.
|
||||
*/
|
||||
function drlgLevelsOf(t: DrlgTables, act: number): readonly number[] {
|
||||
return act === 1 ? act1OutdoorLevelIds(t) : []
|
||||
}
|
||||
|
||||
/** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */
|
||||
interface DrlgActCopy {
|
||||
readonly layout: ActLayout
|
||||
readonly inputs: ReadonlyMap<number, DrlgLevelInput>
|
||||
}
|
||||
|
||||
let drlgTablesPromise: Promise<DrlgTables> | undefined
|
||||
/**
|
||||
* The DRLG's compiled tables, loaded once per thread (the adapter maps a level with them).
|
||||
*
|
||||
* @returns the tables.
|
||||
*/
|
||||
function drlgTables(): Promise<DrlgTables> {
|
||||
drlgTablesPromise ??= loadDrlgMpqTables(member => archives.read(member))
|
||||
return drlgTablesPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate every copy of every DRLG act that has a level to bake, on the main thread.
|
||||
*
|
||||
* One DRLG run per copy, as in D2: the act's outdoor levels, their seams and the town's gate come out
|
||||
* of the same run, so the copy is recorded as a whole (`index.json` `actLayouts`) and the runtime uses
|
||||
* one copy per act (src/game/act-variants.ts). The whole act is generated even when a filter asks for a
|
||||
* single level, because no level of it exists without the others.
|
||||
*
|
||||
* @param wanted - whether the bake's filters select a level.
|
||||
* @returns the copies by act; acts without a selected DRLG level are absent.
|
||||
*/
|
||||
async function generateDrlgActs(wanted: (act: number, levelId: number) => boolean): Promise<Map<number, DrlgActCopy[]>> {
|
||||
const out = new Map<number, DrlgActCopy[]>()
|
||||
const t = await drlgTables()
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
const levels = drlgLevelsOf(t, act)
|
||||
if (!levels.some(levelId => wanted(act, levelId))) continue
|
||||
const data = await loadDrlgMpqData(member => archives.read(member), act)
|
||||
const town = townLevelForAct(act)
|
||||
const copies: DrlgActCopy[] = []
|
||||
for (let variant = 0; variant < DRLG_ACT_VARIANTS; variant += 1) {
|
||||
const gameSeed = actGameSeed(act, variant)
|
||||
const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels)
|
||||
const townFile = dump.act.find(level => level.id === town)?.preset?.map?.file
|
||||
if (townFile === undefined || townFile === '') {
|
||||
throw new Error(`act ${String(act)} seed 0x${gameSeed.toString(16)}: the generated act picked no DS1 for its town (level ${String(town)})`)
|
||||
}
|
||||
copies.push({
|
||||
layout: { variant, gameSeed, levels: [...levels], presets: [{ levelId: town, ds1: townFile.replace(/\//g, '\\') }] },
|
||||
inputs: new Map(levels.map(levelId => [levelId, drlgLevelInputFromDump(dump, levelId, data.tables)])),
|
||||
})
|
||||
console.log(`DRLG act ${String(act)} copy ${String(variant)} (seed 0x${gameSeed.toString(16)}): town ${townFile}`)
|
||||
}
|
||||
out.set(act, copies)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that every DRLG act copy in the (merged) index resolves the way the runtime resolves it: each
|
||||
* generated level has exactly one copy per variant, the town file the copy fixed is baked, and no
|
||||
* other copy of a generated level is left over (e.g. from a legacy bake merged in by a filtered run).
|
||||
*
|
||||
* @param index - the index about to be written.
|
||||
*/
|
||||
function validateActLayouts(index: VariantIndex<VariantEntry>): void {
|
||||
for (const [actKey, layouts] of Object.entries(index.actLayouts ?? {})) {
|
||||
const act = Number(actKey)
|
||||
const generated = new Set(layouts.flatMap(layout => layout.levels))
|
||||
for (const entry of index.levels) {
|
||||
if (entry.act === act && entry.levelId !== undefined && generated.has(entry.levelId) && entry.actVariant === undefined) {
|
||||
throw new Error(`index: ${entry.label} is a copy of DRLG level ${String(entry.levelId)} without an actVariant; bake the whole act`)
|
||||
}
|
||||
}
|
||||
for (const layout of layouts) {
|
||||
// A fresh selection per copy, pinned to it the way opening one of its levels pins it.
|
||||
const variants = new WorldVariants(index)
|
||||
const member = index.levels.find(entry => entry.act === act && entry.actVariant === layout.variant)
|
||||
if (member === undefined) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no baked level belongs to it`)
|
||||
variants.pin(member)
|
||||
for (const levelId of [...layout.levels, ...layout.presets.map(preset => preset.levelId)]) {
|
||||
if (variants.entryForLevel(levelId) === null) {
|
||||
throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: level ${String(levelId)} is not baked`)
|
||||
}
|
||||
}
|
||||
if (variants.startEntry(act) === null) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no start level`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The border seams an outdoor level must cut, as the generator wants them.
|
||||
*
|
||||
|
|
@ -471,6 +607,8 @@ function gatesFor(levelId: number): PlannedGate[] {
|
|||
* @param region - the level's main walkable region when there is a meaningful
|
||||
* one, keeping the arrival point out of sealed pockets; undefined for
|
||||
* generated levels, where the unstamped void would be the largest region.
|
||||
* @param subTile - the warp's exact sub-tile when the generator knows it (the
|
||||
* DRLG's UNIT_TILE preset unit); the anchor cell's centre otherwise.
|
||||
* @returns the warp.
|
||||
*/
|
||||
function makeWarp(
|
||||
|
|
@ -481,11 +619,12 @@ function makeWarp(
|
|||
grid: IsoMapScene,
|
||||
source: SceneWarp['source'],
|
||||
region: WalkableRegion | undefined,
|
||||
subTile?: { readonly x: number; readonly y: number },
|
||||
): SceneWarp {
|
||||
const warpId = edge.warps[0] ?? -1
|
||||
const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId)
|
||||
let x = cellToSubTile(cellX)
|
||||
let y = cellToSubTile(cellY)
|
||||
let x = subTile?.x ?? cellToSubTile(cellX)
|
||||
let y = subTile?.y ?? cellToSubTile(cellY)
|
||||
const fullSpan = Math.max(grid.gridWidth, grid.gridHeight)
|
||||
if (region !== undefined && !triggerableFrom(grid, region, x, y, WARP_TRIGGER_SUBTILES)) {
|
||||
const snapped = nearestWalkable(grid, x, y, fullSpan, region)
|
||||
|
|
@ -543,9 +682,16 @@ function makeWarp(
|
|||
* @param kind - how it was built.
|
||||
* @param ds1 - the assembled map, for its warp markers.
|
||||
* @param grid - the baked collision map.
|
||||
* @param entrances - `stats.entrances` from the wilderness generator, if any.
|
||||
* @param entrances - `stats.entrances` from the wilderness generator, or the
|
||||
* DRLG adapter's entrances, if any.
|
||||
* @param mazeWarps - `stats.warps` from the maze generator, if any.
|
||||
* @param waypointCells - waypoint objects found in the artwork, in sub-tiles.
|
||||
* @param mazeWaypoints - `stats.waypoints` from the maze generator, if any.
|
||||
* @param options - `exactLinks`: the generator reports every seam and warp of
|
||||
* the level exactly (the DRLG port). Nothing is then inferred: warp markers,
|
||||
* staircase rooms, border scans and fallback warps are not used, every seam
|
||||
* must carry the stretch of edge it opens on, and any disagreement with the
|
||||
* world graph throws.
|
||||
* @returns the links, plus anything the graph wanted that could not be placed.
|
||||
*/
|
||||
function buildSceneLinks(
|
||||
|
|
@ -553,11 +699,13 @@ function buildSceneLinks(
|
|||
kind: LevelKind,
|
||||
ds1: Ds1,
|
||||
grid: IsoMapScene,
|
||||
entrances: readonly WildernessEntrance[],
|
||||
entrances: readonly GeneratorEntrance[],
|
||||
mazeWarps: readonly MazeWarp[],
|
||||
waypointCells: readonly { x: number; y: number }[],
|
||||
mazeWaypoints?: readonly MazeWaypoint[],
|
||||
options: { readonly exactLinks?: boolean } = {},
|
||||
): SceneLinks {
|
||||
const exactLinks = options.exactLinks === true
|
||||
const outEntrances: SceneEntrance[] = []
|
||||
const outWarps: SceneWarp[] = []
|
||||
const unplaced: { toLevelId: number; reason: string }[] = []
|
||||
|
|
@ -617,8 +765,10 @@ function buildSceneLinks(
|
|||
(townDistance.get(toLevelId) ?? Number.MAX_SAFE_INTEGER) < here ? 'up' : 'down'
|
||||
|
||||
// 1. The staircases the artwork declares. Authoritative for every kind of
|
||||
// level, because the slot identifies the destination outright.
|
||||
for (const tile of findWarpTiles(ds1)) {
|
||||
// level, because the slot identifies the destination outright. Skipped
|
||||
// when the generator reports its warps exactly: the DRLG port hands over
|
||||
// the UNIT_TILE warp units D2 itself creates for them.
|
||||
for (const tile of exactLinks ? [] : findWarpTiles(ds1)) {
|
||||
const edge = warpEdgeBySlot.get(tile.slot)
|
||||
// A marker for a slot this level does not use: the piece was drawn for a
|
||||
// level that connects there and reused here. Ignore it rather than invent
|
||||
|
|
@ -633,6 +783,36 @@ function buildSceneLinks(
|
|||
// 2. Outdoor levels: the generator already cut the seams, and knows where the
|
||||
// interior presets it stamped sit.
|
||||
for (const anchor of entrances) {
|
||||
const span = 'span' in anchor ? anchor.span : undefined
|
||||
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0 && span !== undefined) {
|
||||
// A DRLG seam: the level link names the stretch of this edge that opens
|
||||
// onto the neighbour (the open border block, or the whole shared edge
|
||||
// for the town and the Monastery approach), so the walkable gap is
|
||||
// searched there and nowhere else.
|
||||
const range = { from: span.from * SUB_TILES_PER_TILE, to: span.to * SUB_TILES_PER_TILE }
|
||||
const opening = findBorderOpening(grid, anchor.side, region, undefined, range)
|
||||
if (opening === null) {
|
||||
throw new Error(`level ${String(levelId)}: no reachable opening on the ${anchor.side} edge in tiles `
|
||||
+ `${String(span.from)}..${String(span.to)} for the seam to level ${String(anchor.toLevelId)}`)
|
||||
}
|
||||
if (placedSeams.has(anchor.toLevelId)) {
|
||||
throw new Error(`level ${String(levelId)}: two seams lead to level ${String(anchor.toLevelId)}`)
|
||||
}
|
||||
outEntrances.push({
|
||||
toLevelId: anchor.toLevelId,
|
||||
side: anchor.side,
|
||||
label: anchor.label,
|
||||
x: opening.x,
|
||||
y: opening.y,
|
||||
arriveX: opening.arriveX,
|
||||
arriveY: opening.arriveY,
|
||||
})
|
||||
placedSeams.add(anchor.toLevelId)
|
||||
continue
|
||||
}
|
||||
if (exactLinks && anchor.kind === 'gate') {
|
||||
throw new Error(`level ${String(levelId)}: seam to level ${String(anchor.toLevelId)} has no side and span`)
|
||||
}
|
||||
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) {
|
||||
// The anchor the generator hands over is the border *piece*, and the
|
||||
// centre of that cell is wall; on some outdoor variants the border
|
||||
|
|
@ -657,11 +837,43 @@ function buildSceneLinks(
|
|||
}
|
||||
// A preset's mouth: the cave, tower or crypt that opens off this level.
|
||||
const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId)
|
||||
const warp = 'warp' in anchor ? anchor.warp : undefined
|
||||
if (exactLinks) {
|
||||
if (edge === undefined) {
|
||||
throw new Error(`level ${String(levelId)}: the generator has a warp to level ${String(anchor.toLevelId)}, the world graph does not`)
|
||||
}
|
||||
if (placedWarps.has(edge.to)) {
|
||||
throw new Error(`level ${String(levelId)}: two warps lead to level ${String(anchor.toLevelId)}`)
|
||||
}
|
||||
if (warp === undefined) {
|
||||
throw new Error(`level ${String(levelId)}: warp to level ${String(anchor.toLevelId)} has no warp unit position`)
|
||||
}
|
||||
}
|
||||
if (edge === undefined || placedWarps.has(edge.to)) continue
|
||||
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region))
|
||||
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region,
|
||||
warp === undefined ? undefined : { x: warp.subX, y: warp.subY }))
|
||||
placedWarps.add(anchor.toLevelId)
|
||||
}
|
||||
|
||||
if (exactLinks) {
|
||||
// The generator's links and the world graph are two readings of the same
|
||||
// Levels.txt rows (Vis0..7 / Warp0..7); any difference is a bug in one of
|
||||
// them, never something to paper over with a guessed opening.
|
||||
for (const edge of edgesFrom(worldGraph, levelId)) {
|
||||
if (edge.kind === 'seamless' && !placedSeams.has(edge.to)) {
|
||||
throw new Error(`level ${String(levelId)}: the world graph has a seam to level ${String(edge.to)}, the generator does not`)
|
||||
}
|
||||
if (edge.kind === 'warp' && !placedWarps.has(edge.to)) {
|
||||
throw new Error(`level ${String(levelId)}: the world graph has a warp to level ${String(edge.to)}, the generator does not`)
|
||||
}
|
||||
}
|
||||
for (const to of placedSeams) {
|
||||
if (!edgesFrom(worldGraph, levelId).some(edge => edge.kind === 'seamless' && edge.to === to)) {
|
||||
throw new Error(`level ${String(levelId)}: the generator has a seam to level ${String(to)}, the world graph does not`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mazes: staircase rooms, for pieces whose artwork carries no marker.
|
||||
if (kind === 'maze') {
|
||||
const leftover = [...edgesFrom(worldGraph, levelId)]
|
||||
|
|
@ -799,17 +1011,29 @@ function buildSceneLinks(
|
|||
}
|
||||
}
|
||||
|
||||
// Waypoints. The artwork wins when it has one; otherwise the level gets a
|
||||
// stand-in, because `DRLGOUTDOORS_SpawnAct12Waypoint` is not implemented and
|
||||
// a waypoint the network knows about but the map does not show is worse than
|
||||
// Waypoints. The artwork wins when it has one. The DRLG port's levels always
|
||||
// do (DRLGOUTDOORS_SpawnAct12Waypoint puts the object in the level), so for
|
||||
// them a missing or unreachable waypoint is a bug and throws. Legacy
|
||||
// generators do not run that pass, so their levels get a stand-in: a
|
||||
// waypoint the network knows about but the map does not show is worse than
|
||||
// an approximate position.
|
||||
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
|
||||
const waypoints: SceneWaypoint[] = []
|
||||
if (exactLinks && waypointId === 255 && waypointCells.length > 0) {
|
||||
throw new Error(`level ${String(levelId)}: the generator placed a waypoint, Levels.txt has none`)
|
||||
}
|
||||
if (waypointId !== 255) {
|
||||
if (exactLinks && waypointCells.length !== 1) {
|
||||
throw new Error(`level ${String(levelId)}: the generator placed ${String(waypointCells.length)} waypoints for waypoint ${String(waypointId)}`)
|
||||
}
|
||||
const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined)
|
||||
const spot = fromArt ?? findWaypointSpot(grid, region)
|
||||
if (spot !== null && spot !== undefined) {
|
||||
const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot
|
||||
const reached = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region)
|
||||
if (exactLinks && reached === null) {
|
||||
throw new Error(`level ${String(levelId)}: waypoint at ${String(spot.x)},${String(spot.y)} is not next to the main walkable region`)
|
||||
}
|
||||
const arrive = reached ?? spot
|
||||
waypoints.push({
|
||||
waypointId,
|
||||
x: spot.x,
|
||||
|
|
@ -1054,30 +1278,11 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
|
|||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* The LvlPrest families the legacy outdoor generator draws its pieces from, by level type. Act I is not
|
||||
* here: its outdoor levels bake only through the DRLG port ('drlg' jobs, see generateDrlgActs).
|
||||
*/
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild',
|
||||
'Act 1 - Town 1 Transition',
|
||||
'Act 1 - DOE Entrance',
|
||||
'Act 1 - Cave Entrance',
|
||||
'Act 1 - Corral Fill',
|
||||
'Act 1 - Fence Fill',
|
||||
'Act 1 - River',
|
||||
'Act 1 - Bridge',
|
||||
'Act 1 - Bivouac',
|
||||
'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill',
|
||||
'Act 1 - Stone Fill',
|
||||
'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp',
|
||||
'Act 1 - Camp',
|
||||
'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus',
|
||||
'Act 1 - Tower',
|
||||
'Act 1 - Ruin',
|
||||
'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||
|
|
@ -1158,7 +1363,7 @@ function getWildernessPieces(levelTypeName: string, subType?: number, levelName?
|
|||
}
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name)
|
||||
const isBorder = /border|cliff|ravine/i.test(name)
|
||||
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
|
||||
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
||||
pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) })
|
||||
|
|
@ -1211,11 +1416,14 @@ async function getPalette(paletteName: string): Promise<Uint8Array> {
|
|||
interface SceneJob {
|
||||
readonly jobId: number
|
||||
readonly entry: LevelJob
|
||||
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness'
|
||||
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' | 'drlg'
|
||||
readonly ds1Name?: string
|
||||
/** Copy number: 1-based for the legacy generators, the 0-based act copy (`actVariant`) for 'drlg'. */
|
||||
readonly variant?: number
|
||||
readonly label: string
|
||||
readonly seed: number
|
||||
/** 'drlg' jobs: the level as the main thread's DRLG run of the act copy generated it. */
|
||||
readonly drlg?: { readonly gameSeed: number; readonly input: DrlgLevelInput }
|
||||
}
|
||||
|
||||
interface SceneResult {
|
||||
|
|
@ -1249,12 +1457,20 @@ async function bakeDs1Variant(
|
|||
* from the collision map instead.
|
||||
*/
|
||||
generatorLinks: {
|
||||
readonly entrances: readonly WildernessEntrance[]
|
||||
readonly entrances: readonly GeneratorEntrance[]
|
||||
readonly mazeWarps: readonly MazeWarp[]
|
||||
readonly mazeWaypoints?: readonly MazeWaypoint[]
|
||||
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
||||
readonly notes?: readonly string[] | undefined
|
||||
readonly animSpeed?: number | undefined
|
||||
/** The generator reports every seam and warp exactly (see buildSceneLinks). */
|
||||
readonly exactLinks?: boolean
|
||||
/**
|
||||
* `'layout'`: every room, tile (style, sequence, type, DT1 library) and preset unit is D2's own
|
||||
* (the DRLG port); only the variant within a tile's (style, sequence) is the renderer's pick.
|
||||
* Unset: the generator approximates the level.
|
||||
*/
|
||||
readonly fidelity?: 'layout'
|
||||
} = { entrances: [], mazeWarps: [] },
|
||||
animSpeedOverride?: number,
|
||||
): Promise<SceneResult> {
|
||||
|
|
@ -1296,6 +1512,7 @@ async function bakeDs1Variant(
|
|||
generatorLinks.mazeWarps,
|
||||
waypointCells,
|
||||
generatorLinks.mazeWaypoints,
|
||||
{ exactLinks: generatorLinks.exactLinks === true },
|
||||
)
|
||||
|
||||
const spawn = findIsoSpawn(scene, { warps: sceneLinks.warps, entrances: sceneLinks.entrances })
|
||||
|
|
@ -1593,7 +1810,7 @@ async function bakeDs1Variant(
|
|||
|
||||
const sceneJson = {
|
||||
version: 1,
|
||||
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
|
||||
fidelity: entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation',
|
||||
act: entry.act,
|
||||
levelId: entry.levelId,
|
||||
levelName,
|
||||
|
|
@ -1832,6 +2049,45 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
|
|||
)
|
||||
}
|
||||
|
||||
if (job.type === 'drlg') {
|
||||
const drlg = job.drlg
|
||||
if (drlg === undefined || job.variant === undefined) {
|
||||
throw new Error(`${job.label}: a DRLG job must carry its generated level and act copy`)
|
||||
}
|
||||
const t = await drlgTables()
|
||||
// The libraries of the tiles D2 picked, for their (style, sequence, type) headers.
|
||||
const dt1 = new Map<string, Dt1>()
|
||||
for (const tile of drlg.input.tiles) {
|
||||
if (tile.tile !== null && !dt1.has(tile.tile.library)) dt1.set(tile.tile.library, await libraryOf(tile.tile.library))
|
||||
}
|
||||
const map = buildDrlgLevelMap(drlg.input, { tables: t, dt1, superUniqueIds: drlgSuperUniqueIds(tables.superuniques, t) })
|
||||
const libraries: Dt1[] = []
|
||||
for (const name of map.dt1Names) libraries.push(await libraryOf(name))
|
||||
const entrances: GeneratorEntrance[] = map.entrances.map(entrance => ({
|
||||
...entrance,
|
||||
label: entrance.kind === 'gate' ? `${levelNameOf(entrance.toLevelId)} Seam` : levelNameOf(entrance.toLevelId),
|
||||
}))
|
||||
const s = map.stats
|
||||
const result = await bakeDs1Variant(
|
||||
job.jobId,
|
||||
job.entry, job.entry.name, palette, paletteName, map.dt1Names, libraries,
|
||||
map.ds1, `generated:${job.label}`, job.label, job.seed,
|
||||
{
|
||||
entrances,
|
||||
mazeWarps: [],
|
||||
landmarks: map.landmarks,
|
||||
exactLinks: true,
|
||||
fidelity: 'layout',
|
||||
notes: [
|
||||
`DRLG port: act ${String(job.entry.act)} copy ${String(job.variant)}, game seed 0x${drlg.gameSeed.toString(16)}`,
|
||||
`DRLG tiles: ${String(s.walls)} walls, ${String(s.floors)} floors, ${String(s.shadows)} shadows, `
|
||||
+ `${String(s.withoutTile)} without a DT1 tile; units: ${String(s.objects)} objects, ${String(s.monsters)} monsters`,
|
||||
],
|
||||
},
|
||||
)
|
||||
return { ...result, indexEntry: { ...result.indexEntry, actVariant: job.variant } }
|
||||
}
|
||||
|
||||
if (job.type === 'wilderness') {
|
||||
const type = levelType(levelRow)
|
||||
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||||
|
|
@ -1889,11 +2145,14 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
|
|||
*
|
||||
* @param filterLevels - optional filter set of level IDs.
|
||||
* @param filterAct - optional filter for a specific act.
|
||||
* @param drlgActs - the DRLG-generated act copies ({@link generateDrlgActs}); their levels bake one
|
||||
* 'drlg' job per copy instead of going through the legacy wilderness generator.
|
||||
* @returns ordered array of scene jobs with sequential jobIds (0..N-1).
|
||||
*/
|
||||
async function collectSceneJobs(
|
||||
filterLevels: Set<number> | null,
|
||||
filterAct: number | null,
|
||||
drlgActs: ReadonlyMap<number, readonly DrlgActCopy[]>,
|
||||
): Promise<SceneJob[]> {
|
||||
const jobs: SceneJob[] = []
|
||||
let jobId = 0
|
||||
|
|
@ -1952,6 +2211,25 @@ async function collectSceneJobs(
|
|||
})
|
||||
}
|
||||
} else if (entry.kind === 'wilderness') {
|
||||
const copies = drlgActs.get(entry.act)
|
||||
if (copies !== undefined && copies.some(copy => copy.layout.levels.includes(entry.levelId))) {
|
||||
for (const copy of copies) {
|
||||
const input = copy.inputs.get(entry.levelId)
|
||||
if (input === undefined) {
|
||||
throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`)
|
||||
}
|
||||
jobs.push({
|
||||
jobId: jobId++,
|
||||
entry,
|
||||
type: 'drlg',
|
||||
variant: copy.layout.variant,
|
||||
label: `${entry.slug}-var${String(copy.layout.variant + 1)}`,
|
||||
seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1,
|
||||
drlg: { gameSeed: copy.layout.gameSeed, input },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
const type = levelType(levelRow)
|
||||
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||||
const pieces = await getWildernessPieces(type.name, subType, entry.name)
|
||||
|
|
@ -1972,6 +2250,19 @@ async function collectSceneJobs(
|
|||
}
|
||||
}
|
||||
|
||||
// Every selected level of a generated act must bake from the DRLG copies: a level whose Levels.txt
|
||||
// kind disagreed with the DRLG's DrlgType would otherwise vanish from the act without a word.
|
||||
for (const [act, copies] of drlgActs) {
|
||||
for (const levelId of copies[0]?.layout.levels ?? []) {
|
||||
if (filterLevels !== null && !filterLevels.has(levelId)) continue
|
||||
if (filterAct !== null && act !== filterAct) continue
|
||||
const baked = jobs.filter(job => job.type === 'drlg' && job.entry.levelId === levelId).length
|
||||
if (baked !== copies.length) {
|
||||
throw new Error(`DRLG level ${String(levelId)} of act ${String(act)} has ${String(baked)} bake jobs, expected ${String(copies.length)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jobs
|
||||
}
|
||||
|
||||
|
|
@ -2129,7 +2420,9 @@ async function main(): Promise<void> {
|
|||
await mkdir(join(outDir, `act${String(act)}`), { recursive: true })
|
||||
}
|
||||
|
||||
const sceneJobs = await collectSceneJobs(filterLevels, filterAct)
|
||||
const drlgActs = await generateDrlgActs((act, levelId) =>
|
||||
(filterLevels === null || filterLevels.has(levelId)) && (filterAct === null || act === filterAct))
|
||||
const sceneJobs = await collectSceneJobs(filterLevels, filterAct, drlgActs)
|
||||
if (sceneJobs.length === 0) {
|
||||
console.log('No scenes matched filter criteria.')
|
||||
return
|
||||
|
|
@ -2164,12 +2457,20 @@ async function main(): Promise<void> {
|
|||
skippedMissingArtSpawns += res.skippedMissingArtSpawns
|
||||
}
|
||||
|
||||
// The DRLG act copies the generated levels belong to (src/game/act-variants.ts reads them).
|
||||
const actLayouts: Record<string, ActLayout[]> = {}
|
||||
for (const [act, copies] of drlgActs) actLayouts[String(act)] = copies.map(copy => copy.layout)
|
||||
if (Object.keys(actLayouts).length > 0) index.actLayouts = actLayouts
|
||||
|
||||
const indexPath = join(outDir, 'index.json')
|
||||
if (filterLevel !== undefined || filterAct !== null) {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const existingRaw = await readFile(indexPath, 'utf8')
|
||||
const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> }
|
||||
const existing = JSON.parse(existingRaw) as {
|
||||
levels?: Array<{ label?: string }>
|
||||
actLayouts?: Record<string, ActLayout[]>
|
||||
}
|
||||
if (Array.isArray(existing.levels)) {
|
||||
const updatedByLabel = new Map<string, unknown>()
|
||||
for (const item of index.levels as Array<{ label: string }>) {
|
||||
|
|
@ -2190,11 +2491,16 @@ async function main(): Promise<void> {
|
|||
}
|
||||
index.levels = merged
|
||||
}
|
||||
// An act this run generated replaces its old copies whole; other acts keep theirs.
|
||||
if (existing.actLayouts !== undefined) index.actLayouts = { ...existing.actLayouts, ...actLayouts }
|
||||
} catch {
|
||||
// No existing index.json or unreadable; write fresh index
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the merge's catch-all: an act copy the runtime could not resolve must fail the bake.
|
||||
validateActLayouts(index as unknown as VariantIndex<VariantEntry>)
|
||||
|
||||
// Atomic writes from main thread
|
||||
await writeJsonAtomic(indexPath, index)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,390 @@
|
|||
/**
|
||||
* Side-by-side proof that the baked Act I outdoor maps are the maps D2MOO generates.
|
||||
*
|
||||
* For one game seed, the native D2MOO oracle (tools/d2moo-oracle) and the TypeScript port
|
||||
* (src/game/drlg, fed from the MPQs exactly as scripts/pack-act-assets.ts feeds it) each generate
|
||||
* Act I. Both dumps then take the same road to pixels: drlgLevelInputFromDump -> buildDrlgLevelMap
|
||||
* (the bake's adapter) -> buildIsoMapScene (the bake's scene builder) -> the renderers below. For
|
||||
* every requested level it writes
|
||||
*
|
||||
* <name>_d2moo_blueprint.png / <name>_port_blueprint.png / <name>_xor_blueprint.png
|
||||
* <name>_d2moo_iso.png / <name>_port_iso.png / <name>_xor_iso.png
|
||||
*
|
||||
* and fails (exit 1) unless both XOR images are empty. The iso XOR is computed at full resolution
|
||||
* on palette indices and the shadow layer; the written iso images are downscaled previews (a block
|
||||
* of the XOR preview is lit when any of its pixels differs).
|
||||
*
|
||||
* It also prints the DT1-mask ambiguity audit of every level (auditDrlgDt1Ambiguity: references whose
|
||||
* candidate tiles under their cell's DT1 mask are variants, or span more than one library) and writes
|
||||
* everything to summary.json.
|
||||
*
|
||||
* Blueprint colours (one 3x3-pixel block per sub-tile): walkable sub-tile green, blocked sub-tile
|
||||
* tan, cell without any tile near-black, 8x8-tile room grid dark lines, level links and warps red,
|
||||
* preset monsters (landmarks) yellow.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/render-drlg-compare.ts [--copy=0] [--seed=0x5eed0100] [--levels=2,3,4]
|
||||
* [--out=DIR] [--scale=4]
|
||||
*
|
||||
* --copy picks the bake's act copy (0..2): the game seed of that copy (actGameSeed in
|
||||
* pack-act-assets.ts) and the scene seed the bake gives its levels. --seed overrides the game seed.
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
|
||||
import { decodePal } from '../src/formats/pal.ts'
|
||||
import { cell, parseTable } from '../src/game/acts.ts'
|
||||
import { buildIsoMapScene, type IsoDraw, type IsoMapScene } from '../src/game/d2map.ts'
|
||||
import { dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
|
||||
import { act1OutdoorLevelIds, auditDrlgDt1Ambiguity, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgDt1Ambiguity, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
|
||||
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
|
||||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from './lib/drlg-mpq-source.ts'
|
||||
import { firstDiff, formatDiff, ORACLE_BIN, runOracle } from './lib/drlg-oracle.ts'
|
||||
import { encodeIndexedPng, encodeRgbaPng } from './png.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
const ARCHIVE_DIR = join(ROOT, 'samples/d2')
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
|
||||
const LEVELS_TXT = 'data\\global\\excel\\Levels.txt'
|
||||
const ACT1_PALETTE = 'data\\global\\palette\\act1\\pal.dat'
|
||||
/** Blueprint pixels per sub-tile. */
|
||||
const BLUEPRINT_SCALE = 3
|
||||
/** Tiles per side of an outdoor room (DRLG grid cell), for the blueprint grid lines. */
|
||||
const ROOM_TILES = 8
|
||||
/** Shadow layer darkening in the iso previews. */
|
||||
const SHADOW_FACTOR = 0.5
|
||||
|
||||
/** Blueprint palette indices. */
|
||||
const BP = { void: 0, walkable: 1, blocked: 2, grid: 3, link: 4, landmark: 5 } as const
|
||||
const BLUEPRINT_PALETTE: readonly (readonly [number, number, number])[] = [
|
||||
[12, 16, 22], [36, 78, 44], [198, 158, 86], [24, 40, 30], [240, 70, 70], [255, 226, 90],
|
||||
]
|
||||
|
||||
/** The bake's act-copy game seed (scripts/pack-act-assets.ts actGameSeed, act 1). */
|
||||
function act1GameSeed(copy: number): number {
|
||||
return (0x5eed_0000 + 1 * 0x100 + copy) >>> 0
|
||||
}
|
||||
|
||||
/** The seed the bake gives the scene of a DRLG level copy (scripts/pack-act-assets.ts, job type 'drlg'). */
|
||||
function bakeSceneSeed(levelId: number, copy: number): number {
|
||||
return 0x5eed_1000 + levelId * 10 + copy + 1
|
||||
}
|
||||
|
||||
function slug(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
||||
}
|
||||
|
||||
interface Args {
|
||||
readonly copy: number
|
||||
readonly seed: number
|
||||
readonly levels: readonly number[]
|
||||
readonly out: string
|
||||
readonly scale: number
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): Args {
|
||||
const known = new Set(['copy', 'seed', 'levels', 'out', 'scale'])
|
||||
const values = new Map<string, string>()
|
||||
for (const arg of argv) {
|
||||
const match = /^--([a-z]+)=(.+)$/.exec(arg)
|
||||
if (match === null || !known.has(match[1]!)) throw new Error(`unknown argument ${arg} (options: ${[...known].map(k => `--${k}=`).join(' ')})`)
|
||||
values.set(match[1]!, match[2]!)
|
||||
}
|
||||
const copy = Number(values.get('copy') ?? '0')
|
||||
if (!Number.isInteger(copy) || copy < 0 || copy > 2) throw new Error('--copy must be 0, 1 or 2')
|
||||
const seedText = values.get('seed')
|
||||
const seed = seedText === undefined ? act1GameSeed(copy) : Number(seedText) >>> 0
|
||||
if (seedText !== undefined && !/^(0x[0-9a-f]+|\d+)$/i.test(seedText)) throw new Error(`bad --seed ${seedText}`)
|
||||
const levels = (values.get('levels') ?? '2,3,4').split(',').map(Number)
|
||||
if (levels.some(id => !Number.isInteger(id) || id <= 0)) throw new Error(`bad --levels ${values.get('levels') ?? ''}`)
|
||||
const scale = Number(values.get('scale') ?? '4')
|
||||
if (!Number.isInteger(scale) || scale < 1) throw new Error('--scale must be a positive integer')
|
||||
const out = resolve(values.get('out') ?? '/tmp/drlg-compare')
|
||||
return { copy, seed, levels, out, scale }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Renderers
|
||||
|
||||
interface IsoRender {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
/** Palette index per pixel (0 where nothing was drawn). */
|
||||
readonly index: Uint8Array
|
||||
/** 1 where the shadow layer darkens the pixel. */
|
||||
readonly shade: Uint8Array
|
||||
/** 1 where anything was drawn. */
|
||||
readonly drawn: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint a scene the way the engine layers it: floors, then shadows (darkening), then walls and roofs
|
||||
* in their painter's order. Full resolution, palette indices.
|
||||
*/
|
||||
function renderIso(scene: IsoMapScene): IsoRender {
|
||||
const width = scene.widthPx
|
||||
const height = scene.heightPx
|
||||
const index = new Uint8Array(width * height)
|
||||
const shade = new Uint8Array(width * height)
|
||||
const drawn = new Uint8Array(width * height)
|
||||
const paint = (draws: readonly IsoDraw[], mode: 'opaque' | 'shadow'): void => {
|
||||
for (const draw of draws) {
|
||||
const frame = scene.frames[draw.frameIndex]
|
||||
if (frame === undefined) throw new Error(`draw at cell ${String(draw.cellX)},${String(draw.cellY)} names frame ${String(draw.frameIndex)} of ${String(scene.frames.length)}`)
|
||||
for (let fy = 0; fy < frame.height; fy += 1) {
|
||||
const py = draw.y + fy
|
||||
if (py < 0 || py >= height) continue
|
||||
for (let fx = 0; fx < frame.width; fx += 1) {
|
||||
const px = draw.x + fx
|
||||
if (px < 0 || px >= width) continue
|
||||
const at = fy * frame.width + fx
|
||||
if (frame.mask[at] !== 1) continue
|
||||
const to = py * width + px
|
||||
if (mode === 'shadow') {
|
||||
shade[to] = 1
|
||||
} else {
|
||||
index[to] = frame.indices[at]!
|
||||
shade[to] = 0
|
||||
drawn[to] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
paint(scene.floors, 'opaque')
|
||||
paint(scene.shadows, 'shadow')
|
||||
paint(scene.walls, 'opaque')
|
||||
paint(scene.roofs, 'opaque')
|
||||
return { width, height, index, shade, drawn }
|
||||
}
|
||||
|
||||
/** A downscaled RGBA preview of an iso render (nearest sample of each block). */
|
||||
function isoPreview(render: IsoRender, rgb: Uint8Array, scale: number): Uint8Array {
|
||||
const width = Math.ceil(render.width / scale)
|
||||
const height = Math.ceil(render.height / scale)
|
||||
const pixels = new Uint8Array(width * height * 4)
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const from = (y * scale) * render.width + x * scale
|
||||
const to = (y * width + x) * 4
|
||||
if (render.drawn[from] !== 1) {
|
||||
pixels[to + 3] = 255
|
||||
continue
|
||||
}
|
||||
const factor = render.shade[from] === 1 ? SHADOW_FACTOR : 1
|
||||
const colour = render.index[from]! * 3
|
||||
pixels[to] = Math.round(rgb[colour]! * factor)
|
||||
pixels[to + 1] = Math.round(rgb[colour + 1]! * factor)
|
||||
pixels[to + 2] = Math.round(rgb[colour + 2]! * factor)
|
||||
pixels[to + 3] = 255
|
||||
}
|
||||
}
|
||||
return encodeRgbaPng({ width, height, pixels })
|
||||
}
|
||||
|
||||
/** Differing pixels of two iso renders, and a downscaled mask of them (a block lights up if any pixel in it differs). */
|
||||
function isoXor(a: IsoRender, b: IsoRender, scale: number): { differing: number; png: Uint8Array } {
|
||||
if (a.width !== b.width || a.height !== b.height) {
|
||||
throw new Error(`iso renders differ in size: ${String(a.width)}x${String(a.height)} vs ${String(b.width)}x${String(b.height)}`)
|
||||
}
|
||||
const width = Math.ceil(a.width / scale)
|
||||
const height = Math.ceil(a.height / scale)
|
||||
const pixels = new Uint8Array(width * height)
|
||||
let differing = 0
|
||||
for (let y = 0; y < a.height; y += 1) {
|
||||
for (let x = 0; x < a.width; x += 1) {
|
||||
const at = y * a.width + x
|
||||
if (a.index[at] !== b.index[at] || a.shade[at] !== b.shade[at] || a.drawn[at] !== b.drawn[at]) {
|
||||
differing += 1
|
||||
pixels[Math.floor(y / scale) * width + Math.floor(x / scale)] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const palette = new Uint8Array(256 * 3)
|
||||
palette.set([255, 0, 255], 3)
|
||||
return { differing, png: encodeIndexedPng({ width, height, pixels, palette }) }
|
||||
}
|
||||
|
||||
interface Blueprint {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly pixels: Uint8Array
|
||||
}
|
||||
|
||||
/** The level's collision, cells, room grid, links and landmarks, one block per sub-tile. */
|
||||
function renderBlueprint(map: DrlgLevelMap, scene: IsoMapScene): Blueprint {
|
||||
const { gridWidth, gridHeight } = scene
|
||||
const width = gridWidth * BLUEPRINT_SCALE
|
||||
const height = gridHeight * BLUEPRINT_SCALE
|
||||
const pixels = new Uint8Array(width * height).fill(BP.void)
|
||||
const block = (subX: number, subY: number, colour: number): void => {
|
||||
for (let dy = 0; dy < BLUEPRINT_SCALE; dy += 1) {
|
||||
for (let dx = 0; dx < BLUEPRINT_SCALE; dx += 1) {
|
||||
const px = subX * BLUEPRINT_SCALE + dx
|
||||
const py = subY * BLUEPRINT_SCALE + dy
|
||||
if (px >= 0 && px < width && py >= 0 && py < height) pixels[py * width + px] = colour
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let subY = 0; subY < gridHeight; subY += 1) {
|
||||
for (let subX = 0; subX < gridWidth; subX += 1) {
|
||||
const cell = map.ds1.cells[Math.floor(subY / SUB_TILES_PER_TILE)]?.[Math.floor(subX / SUB_TILES_PER_TILE)]
|
||||
const hasTile = cell !== undefined && (cell.floors.some(f => f.prop1 !== 0) || cell.walls.some(w => w.prop1 !== 0))
|
||||
if (!hasTile) continue
|
||||
block(subX, subY, scene.blocked[subY * gridWidth + subX] === 0 ? BP.walkable : BP.blocked)
|
||||
}
|
||||
}
|
||||
const step = ROOM_TILES * SUB_TILES_PER_TILE * BLUEPRINT_SCALE
|
||||
for (let py = 0; py < height; py += step) pixels.fill(BP.grid, py * width, (py + 1) * width)
|
||||
for (let px = 0; px < width; px += step) for (let py = 0; py < height; py += 1) pixels[py * width + px] = BP.grid
|
||||
const marker = (subX: number, subY: number, colour: number): void => {
|
||||
for (let dy = -1; dy <= 1; dy += 1) for (let dx = -1; dx <= 1; dx += 1) block(subX + dx, subY + dy, colour)
|
||||
}
|
||||
const centre = Math.floor(SUB_TILES_PER_TILE / 2)
|
||||
for (const entrance of map.entrances) {
|
||||
if (entrance.warp !== undefined) marker(entrance.warp.subX, entrance.warp.subY, BP.link)
|
||||
else marker(entrance.x * SUB_TILES_PER_TILE + centre, entrance.y * SUB_TILES_PER_TILE + centre, BP.link)
|
||||
}
|
||||
for (const landmark of map.landmarks) marker(landmark.subX, landmark.subY, BP.landmark)
|
||||
return { width, height, pixels }
|
||||
}
|
||||
|
||||
function blueprintPng(blueprint: Blueprint): Uint8Array {
|
||||
const palette = new Uint8Array(256 * 3)
|
||||
BLUEPRINT_PALETTE.forEach((colour, at) => palette.set(colour, at * 3))
|
||||
return encodeIndexedPng({ width: blueprint.width, height: blueprint.height, pixels: blueprint.pixels, palette })
|
||||
}
|
||||
|
||||
function blueprintXor(a: Blueprint, b: Blueprint): { differing: number; png: Uint8Array } {
|
||||
if (a.width !== b.width || a.height !== b.height) {
|
||||
throw new Error(`blueprints differ in size: ${String(a.width)}x${String(a.height)} vs ${String(b.width)}x${String(b.height)}`)
|
||||
}
|
||||
const pixels = new Uint8Array(a.pixels.length)
|
||||
let differing = 0
|
||||
for (let at = 0; at < pixels.length; at += 1) {
|
||||
if (a.pixels[at] !== b.pixels[at]) {
|
||||
pixels[at] = 1
|
||||
differing += 1
|
||||
}
|
||||
}
|
||||
const palette = new Uint8Array(256 * 3)
|
||||
palette.set([255, 0, 255], 3)
|
||||
return { differing, png: encodeIndexedPng({ width: a.width, height: a.height, pixels, palette }) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Driver
|
||||
|
||||
interface Side {
|
||||
readonly map: DrlgLevelMap
|
||||
readonly scene: IsoMapScene
|
||||
readonly iso: IsoRender
|
||||
readonly blueprint: Blueprint
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (!existsSync(ORACLE_BIN)) throw new Error(`${ORACLE_BIN} is missing: run ./tools/d2moo-oracle/build.sh`)
|
||||
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
|
||||
if (missing.length > 0) throw new Error(`the 1.13c archives are required in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
|
||||
mkdirSync(args.out, { recursive: true })
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
|
||||
const data: DrlgMpqData = await loadDrlgMpqData(member => archives.read(member), 1)
|
||||
const superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
|
||||
const palette = decodePal(await archives.read(ACT1_PALETTE))
|
||||
const levelsTxt = parseTable(await archives.read(LEVELS_TXT))
|
||||
const levelName = (levelId: number): string => {
|
||||
const row = levelsTxt.rows.find(r => Number(cell(levelsTxt, r, 'Id')) === levelId)
|
||||
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
|
||||
return cell(levelsTxt, row, 'LevelName')
|
||||
}
|
||||
// The bake generates the whole act's outdoor list in one run; so do both sides here.
|
||||
const actLevels = act1OutdoorLevelIds(data.tables)
|
||||
for (const levelId of args.levels) {
|
||||
if (!actLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an Act I outdoor level (${actLevels.join(', ')})`)
|
||||
}
|
||||
|
||||
const seedHex = `0x${args.seed.toString(16).padStart(8, '0')}`
|
||||
console.log(`game seed ${seedHex} (act copy ${String(args.copy)}), levels ${args.levels.join(', ')} of [${actLevels.join(', ')}]`)
|
||||
const oracle: DrlgDump = runOracle({ seed: args.seed, levels: actLevels, difficulty: 0, isolated: false }).doc
|
||||
delete oracle.oracle
|
||||
const port: DrlgDump = dumpAct1(createDrlgEnv(data.source, data.tables), args.seed, actLevels, { difficulty: 0, isolated: false })
|
||||
const dumpDiff = firstDiff(oracle, port, '$')
|
||||
console.log(dumpDiff === null ? 'dumps: identical' : `dumps: DIFFER at ${formatDiff(dumpDiff)}`)
|
||||
|
||||
const dt1Cache = new Map<string, Dt1>()
|
||||
const library = (name: string): Dt1 => {
|
||||
let dt1 = dt1Cache.get(name)
|
||||
if (dt1 === undefined) {
|
||||
dt1 = decodeDt1(data.source.readFile(name))
|
||||
dt1Cache.set(name, dt1)
|
||||
}
|
||||
return dt1
|
||||
}
|
||||
const side = (dump: DrlgDump, levelId: number): Side => {
|
||||
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
|
||||
const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
|
||||
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), bakeSceneSeed(levelId, args.copy))
|
||||
return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) }
|
||||
}
|
||||
|
||||
const summary: Record<string, unknown>[] = []
|
||||
const ambiguityTotal = { refs: 0, variantGroups: 0, crossLibrary: 0, crossLibraryLevelWide: 0 }
|
||||
let failed = dumpDiff !== null
|
||||
for (const levelId of args.levels) {
|
||||
const name = slug(levelName(levelId))
|
||||
const d2moo = side(oracle, levelId)
|
||||
const ours = side(port, levelId)
|
||||
const iso = isoXor(d2moo.iso, ours.iso, args.scale)
|
||||
const bp = blueprintXor(d2moo.blueprint, ours.blueprint)
|
||||
const ambiguity: DrlgDt1Ambiguity = auditDrlgDt1Ambiguity(ours.map, ours.map.dt1Names.map(library))
|
||||
ambiguityTotal.refs += ambiguity.refs
|
||||
ambiguityTotal.variantGroups += ambiguity.variantGroups
|
||||
ambiguityTotal.crossLibrary += ambiguity.crossLibrary
|
||||
ambiguityTotal.crossLibraryLevelWide += ambiguity.crossLibraryLevelWide
|
||||
const write = (file: string, png: Uint8Array): void => writeFileSync(join(args.out, `${name}_${file}.png`), png)
|
||||
write('d2moo_blueprint', blueprintPng(d2moo.blueprint))
|
||||
write('port_blueprint', blueprintPng(ours.blueprint))
|
||||
write('xor_blueprint', bp.png)
|
||||
write('d2moo_iso', isoPreview(d2moo.iso, palette.rgb, args.scale))
|
||||
write('port_iso', isoPreview(ours.iso, palette.rgb, args.scale))
|
||||
write('xor_iso', iso.png)
|
||||
const s = ours.map.stats
|
||||
const row = {
|
||||
levelId,
|
||||
name,
|
||||
canvas: `${String(ours.map.ds1.width)}x${String(ours.map.ds1.height)}`,
|
||||
walls: s.walls,
|
||||
floors: s.floors,
|
||||
shadows: s.shadows,
|
||||
objects: s.objects,
|
||||
monsters: s.monsters,
|
||||
entrances: ours.map.entrances.length,
|
||||
landmarks: ours.map.landmarks.length,
|
||||
isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`,
|
||||
isoDiffering: iso.differing,
|
||||
blueprintDiffering: bp.differing,
|
||||
dt1Ambiguity: ambiguity,
|
||||
}
|
||||
summary.push(row)
|
||||
console.log(`L${String(levelId)} ${name}: canvas ${row.canvas}, ${String(s.walls)} walls / ${String(s.floors)} floors / ${String(s.shadows)} shadows, `
|
||||
+ `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`)
|
||||
console.log(` DT1 ambiguity: ${String(ambiguity.libraries)} libraries, ${String(ambiguity.refs)} refs, ${String(ambiguity.variantGroups)} with variants, `
|
||||
+ `${String(ambiguity.crossLibrary)} cross-library under cell masks (${String(ambiguity.crossLibraryLevelWide)} with one level-wide pool)`)
|
||||
if (iso.differing !== 0 || bp.differing !== 0) failed = true
|
||||
}
|
||||
console.log(`DT1 ambiguity total: ${String(ambiguityTotal.refs)} refs, ${String(ambiguityTotal.variantGroups)} with variants, `
|
||||
+ `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool)`)
|
||||
writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: seedHex, copy: args.copy, dumpsIdentical: dumpDiff === null, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1))
|
||||
console.log(failed ? 'FAIL: the D2MOO and port images differ' : `OK: every XOR image is empty; images in ${args.out}`)
|
||||
if (failed) process.exitCode = 1
|
||||
}
|
||||
|
||||
await main()
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
import { readFileSync, writeFileSync, mkdirSync } 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 { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
|
||||
import { decodePal } from '../src/formats/pal.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
|
||||
import { buildIsoMapScene, COLLIDE_WALL, COLLIDE_DOOR } from '../src/game/d2map.ts'
|
||||
import { encodeIndexedPng } from './png.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
type WildernessResult,
|
||||
} from '../src/game/wilderness.ts'
|
||||
|
||||
const d2DataDir = '/usr/local/google/home/taodao/d2-data'
|
||||
const artifactDir = '/usr/local/google/home/taodao/.gemini/jetski/brain/a4f9163c-a011-4161-b169-7f361b1ed57e'
|
||||
mkdirSync(artifactDir, { recursive: true })
|
||||
|
||||
async function main() {
|
||||
const mpqNames = ['Patch_D2.mpq', 'd2exp.mpq', 'd2data.mpq']
|
||||
const archives = new MountedArchives()
|
||||
const rawMpqs: MpqArchive[] = []
|
||||
for (const name of mpqNames) {
|
||||
const archive = await MpqArchive.open(await fileSource(join(d2DataDir, name)))
|
||||
archives.add(name, archive)
|
||||
rawMpqs.push(archive)
|
||||
}
|
||||
|
||||
async function readMpqFile(mpqPath: string): Promise<Uint8Array | null> {
|
||||
const norm = mpqPath.replace(/\//g, '\\')
|
||||
for (const mpq of rawMpqs) {
|
||||
const entry = mpq.find(norm)
|
||||
if (entry !== undefined) {
|
||||
return await mpq.read(entry)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const actTables = await loadActTables(archives)
|
||||
const lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
|
||||
async function loadDs1(relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await archives.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||
const levels: Ds1[] = []
|
||||
for (let slot = 1; slot <= 6; slot += 1) {
|
||||
const val = cell(table, row, `File${String(slot)}`)
|
||||
if (val === '' || val === '0') continue
|
||||
levels.push(await loadDs1(val))
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES = [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
]
|
||||
|
||||
const act1Pieces: WildernessPiece[] = []
|
||||
for (const row of actTables.lvlprest.rows) {
|
||||
const name = cell(actTables.lvlprest, row, 'Name')
|
||||
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
|
||||
const levels = await rowDs1s(actTables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
||||
}
|
||||
|
||||
const act1Subs: WildernessSubstitution[] = []
|
||||
for (const row of lvlsubTable.rows) {
|
||||
const subType = Number(cell(lvlsubTable, row, 'Type'))
|
||||
if (subType !== 0 && subType !== 1 && subType !== 6) continue
|
||||
const file = cell(lvlsubTable, row, 'File')
|
||||
if (file === '' || file === '0') continue
|
||||
const levels = [await loadDs1(file)]
|
||||
act1Subs.push({
|
||||
name: cell(lvlsubTable, row, 'Name'),
|
||||
type: subType,
|
||||
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
||||
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
|
||||
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
|
||||
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
|
||||
levels,
|
||||
})
|
||||
}
|
||||
|
||||
// Load DT1 libraries for Act 1 Wilderness (LevelType = 2) + 3 universal DT1s
|
||||
const dt1Libraries: Dt1[] = []
|
||||
const universalFiles = [
|
||||
'data\\global\\tiles\\act1\\outdoors\\blank.dt1',
|
||||
'data\\global\\tiles\\act1\\outdoors\\inviswal.dt1',
|
||||
'data\\global\\tiles\\act1\\outdoors\\warp.dt1',
|
||||
]
|
||||
for (const uf of universalFiles) {
|
||||
const bytes = await readMpqFile(uf)
|
||||
if (bytes) dt1Libraries.push(decodeDt1(bytes))
|
||||
}
|
||||
|
||||
const lvlTypesBytes = await readMpqFile('data\\global\\excel\\LvlTypes.txt')
|
||||
const lvlTypesLines = new TextDecoder().decode(lvlTypesBytes!).split(/\r?\n/)
|
||||
const lvlTypesHeader = lvlTypesLines[0]!.split('\t')
|
||||
const lvlTypeIdIdx = lvlTypesHeader.indexOf('Id')
|
||||
const fileCols = Array.from({ length: 32 }, (_, i) => lvlTypesHeader.indexOf(`File ${i + 1}`))
|
||||
|
||||
const typeRow = lvlTypesLines.slice(1).map(l => l.split('\t')).find(c => Number(c[lvlTypeIdIdx]) === 2)
|
||||
if (typeRow) {
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const colIdx = fileCols[i]!
|
||||
const fName = (typeRow[colIdx] ?? '').trim()
|
||||
if (!fName || fName === '0') continue
|
||||
const bytes = await readMpqFile(`data\\global\\tiles\\${fName}`)
|
||||
if (bytes) dt1Libraries.push(decodeDt1(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
const seed = 0x12345678
|
||||
console.log(`Generating Blood Moor with new TypeScript wilderness generator (Seed: 0x${seed.toString(16)})...`)
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
dt1Libraries,
|
||||
})
|
||||
|
||||
const ds1 = result.level
|
||||
console.log(`Generated Level DS1: ${ds1.width}x${ds1.height} tiles, ${ds1.objects.length} objects`)
|
||||
|
||||
const scene = buildIsoMapScene(ds1, dt1Libraries, seed)
|
||||
console.log(`Scene built: ${scene.gridWidth}x${scene.gridHeight} subtiles, ${scene.widthPx}x${scene.heightPx} px, ${scene.frames.length} frames`)
|
||||
|
||||
const palBytes = await readMpqFile('data\\global\\palette\\act1\\pal.dat')
|
||||
const palette = decodePal(palBytes!)
|
||||
|
||||
// 1) Render High-Res Blueprint
|
||||
const scale = 3
|
||||
const mapW = scene.gridWidth * scale
|
||||
const mapH = scene.gridHeight * scale
|
||||
const mapPixels = new Uint8Array(mapW * mapH)
|
||||
const mapPal = new Uint8Array(256 * 3)
|
||||
const setColor = (idx: number, r: number, g: number, b: number) => {
|
||||
mapPal[idx * 3] = r
|
||||
mapPal[idx * 3 + 1] = g
|
||||
mapPal[idx * 3 + 2] = b
|
||||
}
|
||||
setColor(1, 12, 16, 22) // Void
|
||||
setColor(2, 42, 68, 46) // Outdoor 8x8 Grid Line
|
||||
setColor(3, 36, 78, 44) // Walkable Grass Moorland
|
||||
setColor(4, 198, 158, 86) // Solid Wall / Cliff / Fence / River Bank
|
||||
setColor(5, 176, 132, 78) // Dirt Road Path
|
||||
setColor(6, 240, 84, 84) // Preset Landmark Outline
|
||||
setColor(7, 88, 166, 255) // River Boundary
|
||||
setColor(8, 255, 230, 100) // DS1 Object
|
||||
|
||||
mapPixels.fill(1)
|
||||
|
||||
for (let sy = 0; sy < scene.gridHeight; sy++) {
|
||||
for (let sx = 0; sx < scene.gridWidth; sx++) {
|
||||
const cellX = Math.floor(sx / 5)
|
||||
const cellY = Math.floor(sy / 5)
|
||||
const c = ds1.cells[cellY]?.[cellX]
|
||||
const hasFloor = c && c.floors.some(f => f.prop1 !== 0)
|
||||
const hasWall = c && c.walls.some(w => w.prop1 !== 0)
|
||||
if (!hasFloor && !hasWall) continue
|
||||
|
||||
const mask = scene.collisionMasks[sy * scene.gridWidth + sx]!
|
||||
let color = 3
|
||||
const isRoad = c?.floors.some(f => f.style === 1 || f.style === 30 || (f.style === 0 && f.sequence > 0))
|
||||
if (isRoad) color = 5
|
||||
if ((mask & COLLIDE_WALL) !== 0) color = 4
|
||||
|
||||
for (let dy = 0; dy < scale; dy++) {
|
||||
for (let dx = 0; dx < scale; dx++) {
|
||||
mapPixels[(sy * scale + dy) * mapW + (sx * scale + dx)] = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw 8x8 Room Grid Lines
|
||||
for (let by = 0; by <= ds1.height; by += 8) {
|
||||
const py = by * 5 * scale
|
||||
if (py >= 0 && py < mapH) {
|
||||
for (let px = 0; px < mapW; px++) {
|
||||
mapPixels[py * mapW + px] = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let bx = 0; bx <= ds1.width; bx += 8) {
|
||||
const px = bx * 5 * scale
|
||||
if (px >= 0 && px < mapW) {
|
||||
for (let py = 0; py < mapH; py++) {
|
||||
mapPixels[py * mapW + px] = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Objects
|
||||
for (const obj of ds1.objects) {
|
||||
const ox = obj.x * scale
|
||||
const oy = obj.y * scale
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const px = ox + dx
|
||||
const py = oy + dy
|
||||
if (px >= 0 && px < mapW && py >= 0 && py < mapH) {
|
||||
mapPixels[py * mapW + px] = 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blueprintOut = join(artifactDir, 'new_ts_blood_moor_blueprint.png')
|
||||
writeFileSync(blueprintOut, encodeIndexedPng({ width: mapW, height: mapH, pixels: mapPixels, palette: mapPal }))
|
||||
console.log(`Saved Blueprint: ${blueprintOut}`)
|
||||
|
||||
// 2) Render Full Isometric DT1 Scene
|
||||
const isoScale = scene.widthPx > 4000 ? 2 : 1
|
||||
const isoW = Math.ceil(scene.widthPx / isoScale)
|
||||
const isoH = Math.ceil(scene.heightPx / isoScale)
|
||||
const isoPixels = new Uint8Array(isoW * isoH)
|
||||
|
||||
const drawPass = (draws: typeof scene.floors) => {
|
||||
for (const d of draws) {
|
||||
const frame = scene.frames[d.frameIndex]
|
||||
if (!frame) continue
|
||||
for (let fy = 0; fy < frame.height; fy += isoScale) {
|
||||
const py = Math.floor((d.y + fy) / isoScale)
|
||||
if (py < 0 || py >= isoH) continue
|
||||
for (let fx = 0; fx < frame.width; fx += isoScale) {
|
||||
const px = Math.floor((d.x + fx) / isoScale)
|
||||
if (px < 0 || px >= isoW) continue
|
||||
const idx = frame.indices[fy * frame.width + fx]!
|
||||
if (idx !== 0) isoPixels[py * isoW + px] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawPass(scene.floors)
|
||||
drawPass(scene.shadows)
|
||||
drawPass(scene.walls)
|
||||
drawPass(scene.roofs)
|
||||
|
||||
const isoOut = join(artifactDir, 'new_ts_blood_moor_iso.png')
|
||||
writeFileSync(isoOut, encodeIndexedPng({ width: isoW, height: isoH, pixels: isoPixels, palette: palette.rgb }))
|
||||
console.log(`Saved Isometric Render: ${isoOut}`)
|
||||
console.log(`Dimensions: ${isoW}x${isoH} px (scale 1/${isoScale})`)
|
||||
}
|
||||
|
||||
void main()
|
||||
|
|
@ -3,10 +3,12 @@
|
|||
* Diablo II Web DRLG Engine: Seed Sensitivity & Determinism Profiler
|
||||
*
|
||||
* Requirements:
|
||||
* 1. Stress-test seed sensitivity across 50 distinct random seeds for all outdoor levels across Acts 1..5.
|
||||
* 2. Assert Act 1 baseline hashes under master seeds 0x301cd095, 0x416d61c5, 0xdeadbeef match baseline table bit-for-bit.
|
||||
* 3. Assert determinism: re-running identical seeds yields 100% identical digests.
|
||||
* 4. Assert zero collision across different seeds for procedural outdoor levels.
|
||||
* 1. Stress-test seed sensitivity across 50 distinct random seeds for all legacy-generator outdoor levels (Acts 2..5).
|
||||
* 2. Assert determinism: re-running identical seeds yields 100% identical digests.
|
||||
* 3. Assert zero collision across different seeds for procedural outdoor levels.
|
||||
*
|
||||
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
|
||||
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
|
||||
*
|
||||
* Archetype: EMPIRICAL CHALLENGER
|
||||
*/
|
||||
|
|
@ -72,22 +74,11 @@ function canonicalHash(level: Ds1): string {
|
|||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Canonical Master Seeds and Act 1 Baseline
|
||||
* Canonical Master Seeds
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
||||
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
||||
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
||||
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 50 Distinct Adversarial & Random Seeds
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
|
@ -123,15 +114,6 @@ function generate50Seeds(): number[] {
|
|||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||
|
|
@ -201,7 +183,7 @@ async function runStressTest(): Promise<void> {
|
|||
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
|
||||
const isBorder = /border|cliff/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
piecesCache.set(levelTypeName, pieces)
|
||||
|
|
@ -251,11 +233,17 @@ async function runStressTest(): Promise<void> {
|
|||
}
|
||||
|
||||
const wildernessLevels: WildernessLevelMeta[] = []
|
||||
const drlgPortLevels: number[] = []
|
||||
for (const row of tables.levels.rows) {
|
||||
if (cell(tables.levels, row, 'DrlgType') !== '3') continue
|
||||
const id = Number(cell(tables.levels, row, 'Id'))
|
||||
const name = cell(tables.levels, row, 'Name')
|
||||
const act = Number(cell(tables.levels, row, 'Act')) + 1
|
||||
// Act I outdoor levels come from the DRLG port (src/game/drlg); generateWilderness throws for them.
|
||||
if (act === 1) {
|
||||
drlgPortLevels.push(id)
|
||||
continue
|
||||
}
|
||||
const typeId = cell(tables.levels, row, 'LevelType')
|
||||
const typeRow = tables.lvltypes.rows.find(r => cell(tables.lvltypes, r, 'Id') === typeId)
|
||||
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
|
||||
|
|
@ -280,9 +268,10 @@ async function runStressTest(): Promise<void> {
|
|||
})
|
||||
}
|
||||
|
||||
console.log(`Discovered ${wildernessLevels.length} outdoor wilderness levels across Acts 1..5:`)
|
||||
console.log(`Discovered ${wildernessLevels.length} legacy-generator outdoor wilderness levels across Acts 2..5:`)
|
||||
console.log(`- Procedural outdoor levels: ${wildernessLevels.filter(l => l.isProcedural).length}`)
|
||||
console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}\n`)
|
||||
console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}`)
|
||||
console.log(`(Act I outdoor levels ${drlgPortLevels.join(', ')} are the DRLG port's; see tests/drlg-act1-*.test.ts)\n`)
|
||||
|
||||
const seeds50 = generate50Seeds()
|
||||
console.log(`Generated 50 distinct test seeds (includes 3 master seeds, 7 edge/boundary seeds, 40 random seeds).`)
|
||||
|
|
@ -305,49 +294,10 @@ async function runStressTest(): Promise<void> {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TEST 1: Act 1 Baseline Hashes under Master Seeds
|
||||
// TEST 1 & 2: Seed Sensitivity & Determinism across 50 seeds for every legacy-generator level
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('--- [Test 1] Act 1 Baseline Hashes under Master Seeds (0x301cd095, 0x416d61c5, 0xdeadbeef) ---')
|
||||
const act1Levels = wildernessLevels.filter(l => l.act === 1)
|
||||
for (const level of act1Levels) {
|
||||
const expected = ACT1_BASELINE_HASHES[level.id]
|
||||
check(expected !== undefined, `Act 1 level ${level.id} (${level.name}) must have a baseline hash table entry`)
|
||||
if (!expected) continue
|
||||
|
||||
const pieces = await getWildernessPieces(level.levelTypeName)
|
||||
const subs = await getSubstitutions(level.subType)
|
||||
const shrineSubs = await getSubstitutions(level.subShrine)
|
||||
|
||||
const actualHashes: string[] = []
|
||||
for (let i = 0; i < MASTER_SEEDS.length; i += 1) {
|
||||
const seed = MASTER_SEEDS[i]
|
||||
const res = generateWilderness({
|
||||
levelId: level.id,
|
||||
levelName: level.name,
|
||||
levelTypeName: level.levelTypeName,
|
||||
sizeX: level.sizeX,
|
||||
sizeY: level.sizeY,
|
||||
subType: level.subType,
|
||||
subTheme: Math.max(0, level.subTheme),
|
||||
seed,
|
||||
pieces,
|
||||
substitutions: subs,
|
||||
shrineSubstitutions: shrineSubs,
|
||||
})
|
||||
const digest = canonicalHash(res.level)
|
||||
actualHashes.push(digest)
|
||||
const expDigest = expected[i]
|
||||
check(digest === expDigest, `Level ${level.id} (${level.name}) seed 0x${seed.toString(16)} hash ${digest} must match baseline ${expDigest}`)
|
||||
}
|
||||
console.log(` Act 1 Level ${String(level.id).padStart(2)}: ${level.name.padEnd(20)} -> [${actualHashes.join(', ')}] (100% match)`)
|
||||
}
|
||||
console.log(' -> Test 1 Complete.\n')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TEST 2 & 3: Seed Sensitivity & Determinism across 50 seeds for all 31 levels
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('--- [Test 2 & 3] 50-Seed Sensitivity & Deterministic Re-run Invariance ---')
|
||||
console.log('Testing all 31 wilderness levels across 50 distinct seeds...\n')
|
||||
console.log('--- [Test 1 & 2] 50-Seed Sensitivity & Deterministic Re-run Invariance ---')
|
||||
console.log(`Testing all ${wildernessLevels.length} wilderness levels across 50 distinct seeds...\n`)
|
||||
|
||||
for (const level of wildernessLevels) {
|
||||
const pieces = await getWildernessPieces(level.levelTypeName)
|
||||
|
|
@ -392,7 +342,7 @@ async function runStressTest(): Promise<void> {
|
|||
const uniqueDigests = new Set(digests)
|
||||
|
||||
if (level.isProcedural) {
|
||||
// Assertion 4: Zero collision across different seeds for procedural outdoor levels
|
||||
// Assertion 3: Zero collision across different seeds for procedural outdoor levels
|
||||
const collisionCount = digests.length - uniqueDigests.size
|
||||
check(
|
||||
collisionCount === 0,
|
||||
|
|
@ -428,11 +378,11 @@ async function runStressTest(): Promise<void> {
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n✅ ALL 4 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:')
|
||||
console.log('1. Seed sensitivity: 50 distinct random seeds tested across all outdoor levels.')
|
||||
console.log('2. Act 1 regression guard: 8/8 levels match baseline table bit-for-bit under 3 master seeds.')
|
||||
console.log('3. Determinism: 1,550 duplicate generation pairs produced 100% identical FNV-1a digests.')
|
||||
console.log('4. Zero collision: 28/28 procedural levels produced 50/50 unique digests (0 collisions).')
|
||||
const proceduralCount = wildernessLevels.filter(l => l.isProcedural).length
|
||||
console.log('\n✅ ALL 3 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:')
|
||||
console.log(`1. Seed sensitivity: ${seeds50.length} distinct random seeds tested across all ${wildernessLevels.length} legacy-generator outdoor levels.`)
|
||||
console.log(`2. Determinism: ${wildernessLevels.length * seeds50.length} duplicate generation pairs produced 100% identical FNV-1a digests.`)
|
||||
console.log(`3. Zero collision: ${proceduralCount}/${proceduralCount} procedural levels produced ${seeds50.length}/${seeds50.length} unique digests (0 collisions).`)
|
||||
console.log('======================================================================\n')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
* - Assert barricades span vertically across Y in [8..H-8] when width >= height with military gate at hubY.
|
||||
* - Assert gates are open (no wall collision mask) and walkable.
|
||||
* - Assert full end-to-end corridor traversability through the barricade gates.
|
||||
* 3. Verify Act 1 baseline hashes in tests/wilderness-seed-sensitivity.test.ts remain 100% bit-identical.
|
||||
*
|
||||
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
|
||||
* tests/drlg-act1-oracle.test.ts (bit-exact against native D2MOO) and tests/drlg-act1-invariants.test.ts
|
||||
* cover them.
|
||||
*
|
||||
* Archetype: EMPIRICAL CHALLENGER
|
||||
*/
|
||||
|
|
@ -20,79 +23,20 @@ import { existsSync } from 'node:fs'
|
|||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
|
||||
import { loadActTables, cell, tileMemberPath } from '../src/game/acts.ts'
|
||||
import type { D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
} from '../src/game/wilderness.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* FNV-1a Canonical Map Hasher
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
class Hasher {
|
||||
private hash = 2166136261
|
||||
|
||||
push(value: number): this {
|
||||
const word = Math.trunc(value) | 0
|
||||
for (let byte = 0; byte < 4; byte += 1) {
|
||||
this.hash ^= (word >>> (byte * 8)) & 0xff
|
||||
this.hash = Math.imul(this.hash, 16777619) >>> 0
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
get digest(): string {
|
||||
return (this.hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalHash(level: Ds1): string {
|
||||
const hasher = new Hasher()
|
||||
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
|
||||
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
|
||||
for (let y = 0; y < level.height; y += 1) {
|
||||
const row = level.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < level.width; x += 1) {
|
||||
const cellRecord = row[x]
|
||||
if (cellRecord === undefined) continue
|
||||
for (const wall of cellRecord.walls) {
|
||||
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
|
||||
}
|
||||
for (const floor of cellRecord.floors) {
|
||||
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
|
||||
}
|
||||
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
|
||||
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
|
||||
}
|
||||
}
|
||||
for (const object of level.objects) {
|
||||
hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
|
||||
}
|
||||
return hasher.digest
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Canonical Master Seeds & Act 1 Baseline
|
||||
* Canonical Master Seeds
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
||||
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
||||
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
||||
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Seed Generation (60 distinct seeds)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
|
@ -169,15 +113,6 @@ function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
|
|||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES: Record<string, string[]> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
|
||||
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
|
||||
'Act 5 - Barricade': ['Act 5 - Barricade'],
|
||||
|
|
@ -213,9 +148,7 @@ async function main(): Promise<void> {
|
|||
// Load MPQ archives if present
|
||||
let mpqPiecesAct4Mesa: WildernessPiece[] | null = null
|
||||
let mpqPiecesAct4Lava: WildernessPiece[] | null = null
|
||||
let mpqPiecesAct1Wilderness: WildernessPiece[] | null = null
|
||||
let d2Tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } | null = null
|
||||
let d2Lvlsub: D2Table | null = null
|
||||
let d2Archives: MountedArchives | null = null
|
||||
|
||||
const samplePath = 'samples/d2/d2data.mpq'
|
||||
|
|
@ -235,7 +168,6 @@ async function main(): Promise<void> {
|
|||
lvltypes: actTables.lvltypes,
|
||||
lvlprest: actTables.lvlprest,
|
||||
}
|
||||
d2Lvlsub = parseTable(await d2Archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
|
||||
|
|
@ -267,7 +199,7 @@ async function main(): Promise<void> {
|
|||
if (familyKey === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||
const levels = await rowDs1s(d2Tables!.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = familyKey === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
|
||||
const isBorder = /border|cliff/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
return pieces
|
||||
|
|
@ -275,8 +207,7 @@ async function main(): Promise<void> {
|
|||
|
||||
mpqPiecesAct4Mesa = await getPieces('Act 4 - Mesa')
|
||||
mpqPiecesAct4Lava = await getPieces('Act 4 - Lava')
|
||||
mpqPiecesAct1Wilderness = await getPieces('Act 1 - Wilderness')
|
||||
console.log(`✓ Loaded MPQ ground-truth pieces: Act 4 Mesa (${String(mpqPiecesAct4Mesa.length)}), Lava (${String(mpqPiecesAct4Lava.length)}), Act 1 (${String(mpqPiecesAct1Wilderness.length)})\n`)
|
||||
console.log(`✓ Loaded MPQ ground-truth pieces: Act 4 Mesa (${String(mpqPiecesAct4Mesa.length)}), Lava (${String(mpqPiecesAct4Lava.length)})\n`)
|
||||
}
|
||||
|
||||
const piecesAct4Mesa = mpqPiecesAct4Mesa ?? makeMockBorderPieces('Act 4')
|
||||
|
|
@ -710,105 +641,6 @@ async function main(): Promise<void> {
|
|||
console.log(` ✓ Horizontal/Square Corridor ${String(dim.sizeX)}x${String(dim.sizeY)}: 100% assertions passed across all ${String(seeds.length)} seeds.`)
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* SECTION 3: Act 1 Baseline Hash Invariance Check
|
||||
* ----------------------------------------------------------------------- */
|
||||
console.log('\n----------------------------------------------------------------------')
|
||||
console.log('🔒 SECTION 3: Act 1 Baseline Hash Invariance Check (Bit-for-Bit)')
|
||||
console.log('----------------------------------------------------------------------')
|
||||
|
||||
if (d2Tables !== null && d2Lvlsub !== null && d2Archives !== null && mpqPiecesAct1Wilderness !== null) {
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
async function loadDs1(relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await d2Archives!.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
|
||||
if (type < 0) return []
|
||||
const rows: WildernessSubstitution[] = []
|
||||
for (const row of d2Lvlsub!.rows) {
|
||||
if (Number(cell(d2Lvlsub!, row, 'Type')) !== type) continue
|
||||
const file = cell(d2Lvlsub!, row, 'File')
|
||||
if (file === '' || file === '0') continue
|
||||
rows.push({
|
||||
name: cell(d2Lvlsub!, row, 'Name'),
|
||||
type,
|
||||
gridSize: Number(cell(d2Lvlsub!, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(d2Lvlsub!, row, 'BordType')),
|
||||
dt1Mask: Number(cell(d2Lvlsub!, row, 'Dt1Mask')) || 0,
|
||||
prob: [0, 1, 2, 3, 4].map(i => Number(cell(d2Lvlsub!, row, `Prob${String(i)}`)) || 0),
|
||||
trials: [0, 1, 2, 3, 4].map(i => Number(cell(d2Lvlsub!, row, `Trials${String(i)}`)) || 0),
|
||||
max: [0, 1, 2, 3, 4].map(i => Number(cell(d2Lvlsub!, row, `Max${String(i)}`)) || 0),
|
||||
levels: [await loadDs1(file)],
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const act1Levels = [
|
||||
{ id: 2, name: 'Blood Moor' },
|
||||
{ id: 3, name: 'Cold Plains' },
|
||||
{ id: 4, name: 'Stony Field' },
|
||||
{ id: 5, name: 'Dark Wood' },
|
||||
{ id: 6, name: 'Black Marsh' },
|
||||
{ id: 7, name: 'Tamoe Highland' },
|
||||
{ id: 17, name: 'Burial Grounds' },
|
||||
{ id: 39, name: 'Secret Cow Level' },
|
||||
]
|
||||
|
||||
for (const lvl of act1Levels) {
|
||||
const expectedHashes = ACT1_BASELINE_HASHES[lvl.id]
|
||||
assert(expectedHashes !== undefined, `Level ${String(lvl.id)} must have expected baseline hashes`)
|
||||
|
||||
const levelRow = d2Tables.levels.rows.find(row => Number(cell(d2Tables!.levels, row, 'Id')) === lvl.id)!
|
||||
const typeId = cell(d2Tables.levels, levelRow, 'LevelType')
|
||||
const typeRow = d2Tables.lvltypes.rows.find(row => cell(d2Tables!.lvltypes, row, 'Id') === typeId)
|
||||
const levelTypeName = typeRow ? cell(d2Tables.lvltypes, typeRow, 'Name') : ''
|
||||
const sizeX = Number(cell(d2Tables.levels, levelRow, 'SizeX'))
|
||||
const sizeY = Number(cell(d2Tables.levels, levelRow, 'SizeY'))
|
||||
const subType = Number(cell(d2Tables.levels, levelRow, 'SubType'))
|
||||
const subShrine = Number(cell(d2Tables.levels, levelRow, 'SubShrine'))
|
||||
const subTheme = Number(cell(d2Tables.levels, levelRow, 'SubTheme'))
|
||||
|
||||
const subs = await buildSubstitutions(subType)
|
||||
const shrineSubs = await buildSubstitutions(subShrine)
|
||||
|
||||
const actualHashes: string[] = []
|
||||
for (let i = 0; i < MASTER_SEEDS.length; i += 1) {
|
||||
const seed = MASTER_SEEDS[i]!
|
||||
const result = generateWilderness({
|
||||
levelId: lvl.id,
|
||||
levelName: lvl.name,
|
||||
levelTypeName,
|
||||
sizeX,
|
||||
sizeY,
|
||||
subType,
|
||||
subTheme: Math.max(0, subTheme),
|
||||
seed,
|
||||
pieces: mpqPiecesAct1Wilderness,
|
||||
substitutions: subs,
|
||||
shrineSubstitutions: shrineSubs,
|
||||
})
|
||||
const hash = canonicalHash(result.level)
|
||||
actualHashes.push(hash)
|
||||
|
||||
const match = hash === expectedHashes![i]
|
||||
assert(
|
||||
match,
|
||||
`Level ${String(lvl.id)} (${lvl.name}) seed 0x${seed.toString(16)}: hash ${hash} must match expected ${expectedHashes![i]}`,
|
||||
)
|
||||
}
|
||||
console.log(` ✓ Act 1 Level ${String(lvl.id).padStart(2, ' ')} (${lvl.name}): hashes [${actualHashes.join(', ')}] match baseline 100% bit-for-bit`)
|
||||
}
|
||||
} else {
|
||||
console.log(' ⚠️ MPQ archive not loaded; skipping bit-for-bit Act 1 baseline check (covered by vitest suite).')
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* SUMMARY
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -7,23 +7,19 @@
|
|||
* - Numerically compute P^2 / A for continuous polar contour and discrete rasterized tile cells.
|
||||
* - Assert P^2 / A > 4*pi * 1.15 holds 100% of the time (zero violations).
|
||||
* 2. Stress-test jitterFrac: sample 10,000 draws and assert all values strictly lie in [base - delta, base + delta].
|
||||
* 3. Verify Act 1 baseline hashes in tests/wilderness-seed-sensitivity.test.ts remain 100% bit-identical.
|
||||
* 4. Verify Act 4 Lava Fissure flood-fill reachability >= 90%.
|
||||
* 5. Verify Act 5 Transverse Barricade palisade orientation and choke-point gate clearance.
|
||||
* 3. Verify Act 4 Lava Fissure flood-fill reachability >= 90%.
|
||||
* 4. Verify Act 5 Transverse Barricade palisade orientation and choke-point gate clearance.
|
||||
*
|
||||
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
|
||||
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
|
||||
*
|
||||
* Archetype: EMPIRICAL CHALLENGER
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
|
||||
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import {
|
||||
act2OasisRadius,
|
||||
|
|
@ -34,70 +30,6 @@ import {
|
|||
} from '../src/game/wilderness-acts.ts'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* FNV-1a Canonical Map Hasher
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
class Hasher {
|
||||
private hash = 2166136261
|
||||
|
||||
push(value: number): this {
|
||||
const word = Math.trunc(value) | 0
|
||||
for (let byte = 0; byte < 4; byte += 1) {
|
||||
this.hash ^= (word >>> (byte * 8)) & 0xff
|
||||
this.hash = Math.imul(this.hash, 16777619) >>> 0
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
get digest(): string {
|
||||
return (this.hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalHash(level: Ds1): string {
|
||||
const hasher = new Hasher()
|
||||
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
|
||||
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
|
||||
for (let y = 0; y < level.height; y += 1) {
|
||||
const row = level.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < level.width; x += 1) {
|
||||
const cellRecord = row[x]
|
||||
if (cellRecord === undefined) continue
|
||||
for (const wall of cellRecord.walls) {
|
||||
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
|
||||
}
|
||||
for (const floor of cellRecord.floors) {
|
||||
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
|
||||
}
|
||||
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
|
||||
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
|
||||
}
|
||||
}
|
||||
for (const object of level.objects) {
|
||||
hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
|
||||
}
|
||||
return hasher.digest
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Canonical Master Seeds and Act 1 Baseline
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
||||
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
||||
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
||||
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Mathematical Helpers: Continuous & Discrete Isoperimetric Quotients
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
|
@ -628,157 +560,6 @@ async function main() {
|
|||
console.log(` - Act 5 transverse barricade audited: ${barricadeChecks} checks (all perpendicular + open gate)`)
|
||||
console.log(` ✓ Real level generation stress tests: 0 violations.\n`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SECTION 5: Act 1 Baseline Hash Invariance (bit-identical)
|
||||
// -------------------------------------------------------------------------
|
||||
console.log('--- SECTION 5: ACT 1 BASELINE HASH INVARIANCE ---')
|
||||
if (existsSync('samples/d2/d2data.mpq')) {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
try {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
} catch {
|
||||
// Ignore missing optional archive
|
||||
}
|
||||
}
|
||||
|
||||
const actTables = await loadActTables(archives)
|
||||
const tables = {
|
||||
levels: actTables.levels,
|
||||
lvltypes: actTables.lvltypes,
|
||||
lvlprest: actTables.lvlprest,
|
||||
}
|
||||
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
|
||||
async function loadDs1(relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await archives.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||
const levels: Ds1[] = []
|
||||
for (let slot = 1; slot <= 6; slot += 1) {
|
||||
const value = cell(table, row, `File${String(slot)}`)
|
||||
if (value === '' || value === '0') continue
|
||||
levels.push(await loadDs1(value))
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
}
|
||||
|
||||
async function buildWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
|
||||
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
|
||||
const pieces: WildernessPiece[] = []
|
||||
for (const row of tables.lvlprest.rows) {
|
||||
const name = cell(tables.lvlprest, row, 'Name')
|
||||
if (!families.some(family => name.startsWith(family))) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = /\bBorder\b/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
|
||||
if (type < 0) return []
|
||||
const rows: WildernessSubstitution[] = []
|
||||
for (const row of lvlsub.rows) {
|
||||
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
|
||||
const file = cell(lvlsub, row, 'File')
|
||||
if (file === '' || file === '0') continue
|
||||
rows.push({
|
||||
name: cell(lvlsub, row, 'Name'),
|
||||
type,
|
||||
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsub, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
|
||||
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0),
|
||||
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0),
|
||||
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0),
|
||||
levels: [await loadDs1(file)],
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async function generateLevelForSeed(levelId: number, seed: number): Promise<Ds1> {
|
||||
const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)!
|
||||
const typeId = cell(tables.levels, levelRow, 'LevelType')
|
||||
const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId)
|
||||
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
|
||||
const name = cell(tables.levels, levelRow, 'Name')
|
||||
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||||
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
|
||||
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||||
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
|
||||
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
|
||||
|
||||
const pieces = await buildWildernessPieces(levelTypeName)
|
||||
const subs = await buildSubstitutions(subType)
|
||||
const shrineSubs = await buildSubstitutions(subShrine)
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId,
|
||||
levelName: name,
|
||||
levelTypeName,
|
||||
sizeX,
|
||||
sizeY,
|
||||
subType,
|
||||
subTheme: Math.max(0, subTheme),
|
||||
seed,
|
||||
pieces,
|
||||
substitutions: subs,
|
||||
shrineSubstitutions: shrineSubs,
|
||||
})
|
||||
return result.level
|
||||
}
|
||||
|
||||
const act1Levels = [
|
||||
{ id: 2, name: 'Blood Moor' },
|
||||
{ id: 3, name: 'Cold Plains' },
|
||||
{ id: 4, name: 'Stony Field' },
|
||||
{ id: 5, name: 'Dark Wood' },
|
||||
{ id: 6, name: 'Black Marsh' },
|
||||
{ id: 7, name: 'Tamoe Highland' },
|
||||
{ id: 17, name: 'Burial Grounds' },
|
||||
{ id: 39, name: 'Secret Cow Level' },
|
||||
]
|
||||
|
||||
for (const lvl of act1Levels) {
|
||||
const baseline = ACT1_BASELINE_HASHES[lvl.id]!
|
||||
for (let sIdx = 0; sIdx < MASTER_SEEDS.length; sIdx += 1) {
|
||||
const seed = MASTER_SEEDS[sIdx]!
|
||||
const expectedHash = baseline[sIdx]!
|
||||
const levelDs1 = await generateLevelForSeed(lvl.id, seed)
|
||||
const digest = canonicalHash(levelDs1)
|
||||
check(
|
||||
digest === expectedHash,
|
||||
`Act 1 Level ${lvl.id} (${lvl.name}) seed 0x${seed.toString(16)} hash mismatch: got ${digest}, expected ${expectedHash}`
|
||||
)
|
||||
}
|
||||
console.log(` Act 1 Level ${String(lvl.id).padStart(2)}: ${lvl.name.padEnd(20)} | 3/3 baseline hashes 100% bit-identical`)
|
||||
}
|
||||
} else {
|
||||
console.warn(' ⚠️ samples/d2/d2data.mpq not found; skipping MPQ Act 1 baseline check.')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FINAL REPORT
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -804,7 +585,6 @@ async function main() {
|
|||
console.log(`4. Full Map Generation (100 seeds): 100% of water/lake bodies satisfy P^2/A > 4pi * 1.15.`)
|
||||
console.log(`5. Act 4 Lava Fissure: 100% of tested maps maintain >= 90% flood-fill reachability.`)
|
||||
console.log(`6. Act 5 Barricades: Transverse direction invariance & open choke-point gates verified.`)
|
||||
console.log('7. Act 1 Regression Guard: 24/24 master seed hashes remain 100% bit-identical.')
|
||||
console.log('======================================================================\n')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
*
|
||||
* 70 levels are randomly generated mazes (`Levels.txt.DrlgType == 1`) and 31 are
|
||||
* wilderness (`== 3`); the other 35 are presets and are covered by
|
||||
* `verify-acts.ts`. This script builds a generator request for each of those 101
|
||||
* `verify-acts.ts`. The 8 Act I wilderness levels are generated by the DRLG port
|
||||
* (`src/game/drlg`) and verified by `tests/drlg-act1-oracle.test.ts` (bit-exact
|
||||
* against native D2MOO) and `tests/drlg-act1-invariants.test.ts`, so they are
|
||||
* skipped here. This script builds a generator request for each of the other 93
|
||||
* levels straight from the shipped tables, generates it with a fixed seed, and
|
||||
* asserts the four things that would otherwise only show up as a broken map in an
|
||||
* asset pack:
|
||||
|
|
@ -395,31 +398,12 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
|
|||
* Request builders
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Which `LvlPrest` name families belong to which wilderness level type. */
|
||||
/**
|
||||
* Which `LvlPrest` name families belong to which wilderness level type. Act I is not here: its outdoor
|
||||
* levels are generated by the DRLG port (src/game/drlg), which this script does not drive (see
|
||||
* `drlgPortLevels` below).
|
||||
*/
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild',
|
||||
'Act 1 - Town 1 Transition',
|
||||
'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance',
|
||||
'Act 1 - Corral Fill',
|
||||
'Act 1 - Fence Fill',
|
||||
'Act 1 - River',
|
||||
'Act 1 - Bridge',
|
||||
'Act 1 - Bivouac',
|
||||
'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill',
|
||||
'Act 1 - Stone Fill',
|
||||
'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp',
|
||||
'Act 1 - Camp',
|
||||
'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus',
|
||||
'Act 1 - Tower',
|
||||
'Act 1 - Ruin',
|
||||
'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||
|
|
@ -481,7 +465,7 @@ async function wildernessPieces(
|
|||
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||
const levels = await rowDs1s(archives, lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
|
||||
const isBorder = /border|cliff/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
return pieces
|
||||
|
|
@ -579,16 +563,20 @@ function mazeRow(levelId: number, levelName: string): readonly string[] | undefi
|
|||
|
||||
const mazeLevels: { id: number; name: string }[] = []
|
||||
const wildLevels: { id: number; name: string }[] = []
|
||||
/** Act I outdoor levels: generated by the D2MOO port (src/game/drlg), not by the legacy generator. */
|
||||
const drlgPortLevels: number[] = []
|
||||
for (const row of tables.levels.rows) {
|
||||
const drlg = cell(tables.levels, row, 'DrlgType')
|
||||
const id = Number(cell(tables.levels, row, 'Id'))
|
||||
const name = cell(tables.levels, row, 'Name')
|
||||
if (drlg === '1') mazeLevels.push({ id, name })
|
||||
else if (drlg === '3' && cell(tables.levels, row, 'Act') === '0') drlgPortLevels.push(id)
|
||||
else if (drlg === '3') wildLevels.push({ id, name })
|
||||
}
|
||||
|
||||
console.log(`== generators ==`)
|
||||
console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(wildLevels.length)} wilderness levels (DrlgType 3)`)
|
||||
console.log(` Act I outdoor levels ${drlgPortLevels.join(', ')}: DRLG port, verified by tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts`)
|
||||
|
||||
let mazePassed = 0
|
||||
let wildPassed = 0
|
||||
|
|
@ -691,33 +679,6 @@ for (const { id, name } of wildLevels) {
|
|||
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
|
||||
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
|
||||
check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`)
|
||||
if (id === 2) {
|
||||
check(scene.walls.length > 500, label, `Blood Moor walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
|
||||
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
|
||||
check(substitutions.some(s => s.name.includes('Corral') || s.name.includes('Fence')), label, 'Blood Moor missing Corral/Fence Fill special preset')
|
||||
}
|
||||
if (id === 3) {
|
||||
check(scene.walls.length > 500, label, `Cold Plains walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
|
||||
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
|
||||
check(substitutions.some(s => s.name.includes('Bishibosh')), label, 'Cold Plains missing Bishibosh preset')
|
||||
}
|
||||
if (id === 5) {
|
||||
check(scene.walls.length > 500, label, `Dark Wood walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
|
||||
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
|
||||
check(substitutions.some(s => s.name.includes('Inifus')), label, 'Dark Wood missing Inifus preset')
|
||||
}
|
||||
if (id === 39) {
|
||||
check(scene.walls.length > 500, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
|
||||
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
|
||||
check(substitutions.some(s => s.name.includes('Corral')), label, 'Moo Moo Farm missing Corral Fill special preset')
|
||||
check(substitutions.some(s => s.name.includes('Fence')), label, 'Moo Moo Farm missing Fence Fill special preset')
|
||||
check(substitutions.some(s => s.name.includes('Bivouac')), label, 'Moo Moo Farm missing Bivouac special preset')
|
||||
check(substitutions.some(s => s.name.includes('Pond')), label, 'Moo Moo Farm missing Pond special preset')
|
||||
}
|
||||
if (type.name === 'Act 1 - Wilderness') {
|
||||
const roadCells = Number(first.stats.roadCells ?? 0)
|
||||
check(roadCells > 0, label, `Act 1 wilderness level must generate dirt roads (roadCells=${String(roadCells)})`)
|
||||
}
|
||||
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1
|
||||
|
||||
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@
|
|||
* 3. Seed sensitivity across 16 arbitrary random seeds (10+ required) across Acts 2..5 outdoor levels:
|
||||
* - 100% collision-free layout digests for procedural outdoor levels.
|
||||
* - Re-run determinism invariance.
|
||||
* 4. Act 1 Bit-Identical Regression Guard:
|
||||
* - All Act 1 outdoor levels remain 100% bit-identical to canonical baseline.
|
||||
*
|
||||
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
|
||||
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
|
|
@ -349,15 +350,6 @@ async function runSeedSensitivityTests(): Promise<void> {
|
|||
}
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||
|
|
@ -376,7 +368,7 @@ async function runSeedSensitivityTests(): Promise<void> {
|
|||
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
|
||||
const isBorder = /border|cliff/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
return pieces
|
||||
|
|
@ -488,44 +480,6 @@ async function runSeedSensitivityTests(): Promise<void> {
|
|||
|
||||
console.log(` ✓ Level ${String(lvl.id).padStart(3, ' ')} (${lvl.name.padEnd(24, ' ')} Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} collision-free digests | determinism OK`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Part 4: Act 1 Bit-Identical Regression Guard
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
console.log('\n=================================================================')
|
||||
console.log('PART 4: Act 1 Bit-Identical Regression Guard')
|
||||
console.log('=================================================================')
|
||||
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
||||
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
||||
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
}
|
||||
const CANONICAL_TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
||||
|
||||
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
|
||||
for (const levelId of act1Levels) {
|
||||
const expected = ACT1_BASELINE_HASHES[levelId]!
|
||||
const actual: string[] = []
|
||||
|
||||
for (const seed of CANONICAL_TEST_SEEDS) {
|
||||
const level = await generateLevel(levelId, seed)
|
||||
actual.push(canonicalHash(level))
|
||||
}
|
||||
|
||||
const matches = JSON.stringify(actual) === JSON.stringify(expected)
|
||||
assert(
|
||||
matches,
|
||||
`Act 1 Level ${levelId} matches baseline table 100% bit-for-bit: [${actual.join(', ')}] === [${expected.join(', ')}]`,
|
||||
)
|
||||
console.log(` ✓ Act 1 Level ${String(levelId).padStart(2, ' ')} bit-identical match: [${actual.join(', ')}]`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
* This script closes that gap by replaying the runtime's own rules against the
|
||||
* pack:
|
||||
*
|
||||
* - the variant picked for a destination is `variantSeed % candidates`, with
|
||||
* `variantSeed = WORLD_VARIANT_SEED + levelId`, exactly as
|
||||
* `loadRuntimeForLevel` does — one variant per level, so re-entering a
|
||||
* level always gives the same map;
|
||||
* - the copy loaded for a destination comes from `WorldVariants`
|
||||
* (`src/game/act-variants.ts`), the same object `loadRuntimeForLevel` asks:
|
||||
* one generated copy per act for DRLG acts (the town file that copy fixed
|
||||
* included), one copy per level otherwise, preferring the copy whose opening
|
||||
* faces the side the player arrives from; the walk starts where a fresh
|
||||
* session starts (`startEntry`);
|
||||
* - the landing spot is the destination's own opening back to where we came
|
||||
* from, exactly as `travel` does;
|
||||
* - a link fires when the player is within `SEAM_TRIGGER_SUBTILES` or
|
||||
|
|
@ -35,7 +37,9 @@
|
|||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { WorldVariants, type ActLayout } from '../src/game/act-variants.ts'
|
||||
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../src/game/level-links.ts'
|
||||
import type { Side } from '../src/game/variants.ts'
|
||||
|
||||
const [packDir = 'samples/d2-packs'] = process.argv.slice(2)
|
||||
|
||||
|
|
@ -49,6 +53,8 @@ interface Link {
|
|||
readonly label: string
|
||||
readonly radius: number
|
||||
readonly what: string
|
||||
/** The edge a seam leaves by (passed on as `fromSide`, as `travel` does); absent for warps. */
|
||||
readonly side?: Side
|
||||
}
|
||||
|
||||
/** The fields of `scene.json` this script reads. */
|
||||
|
|
@ -64,7 +70,7 @@ interface PackedScene {
|
|||
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
|
||||
readonly spawn: readonly number[] | null
|
||||
readonly entrances?: readonly {
|
||||
toLevelId: number; side: string; label: string
|
||||
toLevelId: number; side: Side; label: string
|
||||
x: number; y: number; arriveX: number; arriveY: number
|
||||
}[]
|
||||
readonly warps?: readonly {
|
||||
|
|
@ -81,11 +87,17 @@ interface IndexEntry {
|
|||
readonly levelId: number
|
||||
readonly path: string
|
||||
readonly label: string
|
||||
readonly slug?: string
|
||||
readonly kind?: string
|
||||
/** DS1 member (presets) or generator tag the map was baked from. */
|
||||
readonly ds1?: string
|
||||
/** DRLG act variant of a generated level. */
|
||||
readonly actVariant?: number
|
||||
}
|
||||
|
||||
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
|
||||
levels: readonly IndexEntry[]
|
||||
actLayouts?: Record<string, readonly ActLayout[]>
|
||||
}
|
||||
|
||||
let checks = 0
|
||||
|
|
@ -140,13 +152,19 @@ function blockedOf(scene: PackedScene): Uint8Array {
|
|||
}
|
||||
|
||||
/**
|
||||
* The spawn point, in sub-tiles.
|
||||
* The sub-tile the player stands on at the spawn point.
|
||||
*
|
||||
* `scene.json` stores it in scene pixels, because that is the space the player
|
||||
* entity lives in, while every link is in sub-tiles. This is the same inverse
|
||||
* isometric projection `playerSubTile` applies in the scene: the half-cell is
|
||||
* 16×8 pixels, so `x` and `y` come out of the sum and difference of the pixel
|
||||
* offsets from the map's origin.
|
||||
* `scene.json` stores the spawn in scene pixels, because that is the space the
|
||||
* player entity lives in, while every link is in sub-tiles. The inverse
|
||||
* isometric projection is the one `playerSubTile` applies in the scene: the
|
||||
* half-cell is 16×8 pixels, so `x` and `y` come out of the sum and difference
|
||||
* of the pixel offsets from the map's origin.
|
||||
*
|
||||
* The result is floored, as the collision the player moves against floors it
|
||||
* (`isBlockedAt` in `src/game/d2map.ts`). Spawns sit at sub-tile centres
|
||||
* (`n + 0.5`), where rounding picks the neighbouring sub-tile: the Rogue
|
||||
* Encampment's TownS1 spawn is the centre of the open sub-tile (123,196), while
|
||||
* (124,197) is part of a wall.
|
||||
*
|
||||
* @param scene - the packed scene.
|
||||
* @returns the spawn sub-tile, or null when the map has no spawn.
|
||||
|
|
@ -155,22 +173,21 @@ function spawnSubTile(scene: PackedScene): { x: number; y: number } | null {
|
|||
if (scene.spawn == null) return null
|
||||
const dx = ((scene.spawn[0] ?? 0) - scene.originX) / 16
|
||||
const dy = ((scene.spawn[1] ?? 0) - scene.originY) / 8
|
||||
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
|
||||
return { x: Math.floor((dy + dx) / 2), y: Math.floor((dy - dx) / 2) }
|
||||
}
|
||||
|
||||
/** Mirrors `WORLD_VARIANT_SEED` in `act-scene.ts`. */
|
||||
const WORLD_VARIANT_SEED = 0x5eed_3000
|
||||
/** The runtime's copy chooser, over this pack (`loadRuntimeForLevel` builds the same one). */
|
||||
const variants = new WorldVariants(index)
|
||||
|
||||
/**
|
||||
* Pick the variant the runtime would load.
|
||||
* Pick the copy the runtime would load.
|
||||
*
|
||||
* @param levelId - the level wanted.
|
||||
* @param fromSide - the edge of the previous level the player walked off (seams only).
|
||||
* @returns the index entry, or null when the pack has no such level.
|
||||
*/
|
||||
function variantFor(levelId: number): IndexEntry | null {
|
||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||||
if (candidates.length === 0) return null
|
||||
return candidates[Math.abs(WORLD_VARIANT_SEED + levelId) % candidates.length] ?? null
|
||||
function variantFor(levelId: number, fromSide?: Side): IndexEntry | null {
|
||||
return variants.entryForLevel(levelId, fromSide)
|
||||
}
|
||||
|
||||
/** Every way out of a level, with the radius that fires it. */
|
||||
|
|
@ -278,7 +295,7 @@ const ROUTE: readonly Hop[] = [
|
|||
|
||||
console.log('=== 验收路线 ===')
|
||||
|
||||
let entry = variantFor(1)
|
||||
let entry = variants.startEntry(1)
|
||||
check(entry !== null, '资源包里有罗格营地(关卡 1)')
|
||||
let scene = entry === null ? null : await sceneOf(entry)
|
||||
let here = scene === null ? { x: 0, y: 0 } : spawnSubTile(scene) ?? { x: 0, y: 0 }
|
||||
|
|
@ -312,18 +329,15 @@ for (const hop of ROUTE) {
|
|||
+ `(连通区域 ${String(reach)} 子格)`,
|
||||
)
|
||||
|
||||
const nextEntry = variantFor(hop.to)
|
||||
const nextEntry = variantFor(hop.to, link.side)
|
||||
check(nextEntry !== null, `${hop.note}:资源包里有目的地 ${String(hop.to)}`)
|
||||
if (nextEntry === null) break
|
||||
const nextScene = await sceneOf(nextEntry)
|
||||
const back = linksOf(nextScene).find(candidate => candidate.toLevelId === fromLevelId)
|
||||
check(back !== undefined, `${hop.note}:${nextScene.levelName} 有回到 ${String(fromLevelId)} 的对侧开口`)
|
||||
const landing = back === undefined
|
||||
? {
|
||||
x: Math.floor(nextScene.cellsX * 5 / 2),
|
||||
y: Math.floor(nextScene.cellsY * 5 / 2),
|
||||
}
|
||||
: { x: back.arriveX, y: back.arriveY }
|
||||
// travel() refuses a destination with no way back, so the walk ends here too.
|
||||
if (back === undefined) break
|
||||
const landing = { x: back.arriveX, y: back.arriveY }
|
||||
const nextBlocked = blockedOf(nextScene)
|
||||
const inBounds = landing.x >= 0 && landing.y >= 0
|
||||
&& landing.x < nextScene.collision.width && landing.y < nextScene.collision.height
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Which baked copy of a level a world instance uses.
|
||||
*
|
||||
* Every generated level is baked more than once, and the world graph names none of the copies, so one
|
||||
* has to be chosen at load time. Two rules decide it:
|
||||
*
|
||||
* - **Acts generated by the DRLG port choose per act, not per level.** D2 generates an act from one game
|
||||
* seed: the outdoor levels, their seams and the town's gate all come out of the same run
|
||||
* (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections -> DRLG_InitLevel). The bake does the same
|
||||
* once per variant and records it in `index.json` as `actLayouts`: the generated levels (whose index
|
||||
* entries carry `actVariant`) and the preset levels whose DS1 that run fixed (the Rogue Encampment
|
||||
* file whose gate faces the Blood Moor). Mixing levels of two variants would join seams that were
|
||||
* never generated together, so one variant is chosen per act and every level of the act follows it.
|
||||
* - **Everything else chooses per level** (`WORLD_VARIANT_SEED + levelId`), preferring a copy whose gate
|
||||
* faces the side the player arrives from (`findMatchingVariant`).
|
||||
*
|
||||
* The same code runs in the browser (`act-scene.ts`) and in `scripts/verify-world-walk.ts`, so the
|
||||
* verifier walks exactly the world the player gets.
|
||||
*
|
||||
* Browser-safe: no `node:` imports, no `Math.random`.
|
||||
*/
|
||||
|
||||
import { townLevelForAct } from './acts.ts'
|
||||
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side, type VariantCandidate } from './variants.ts'
|
||||
|
||||
/**
|
||||
* Seed of the per-world choices.
|
||||
*
|
||||
* A copy must never be chosen from the pair of levels being crossed: keying it on the pair, as an
|
||||
* earlier version did, gave Cold Plains a different layout depending on whether you walked in from the
|
||||
* Blood Moor or back from Stony Field, and left waypoints and town portals pointing at coordinates from a
|
||||
* map that was no longer loaded. One copy per level (per act for DRLG acts) per world is the design.
|
||||
*
|
||||
* A real game would derive this from the game seed; until there is one, a constant keeps every session
|
||||
* identical, which is also what the verification scripts assume.
|
||||
*/
|
||||
export const WORLD_VARIANT_SEED = 0x5eed_3000
|
||||
|
||||
/** One DRLG-generated copy of an act, as the bake records it (`index.json` `actLayouts[act]`). */
|
||||
export interface ActLayout {
|
||||
/** Variant number (0-based); the generated levels' index entries carry it as `actVariant`. */
|
||||
readonly variant: number
|
||||
/** The game seed the DRLG port generated this copy of the act from. */
|
||||
readonly gameSeed: number
|
||||
/** Levels generated by the DRLG port in this copy (one index entry each, with this `actVariant`). */
|
||||
readonly levels: readonly number[]
|
||||
/** Preset levels whose DS1 file the generated act fixed, e.g. the town whose gate faces the Blood Moor. */
|
||||
readonly presets: readonly { readonly levelId: number; readonly ds1: string }[]
|
||||
}
|
||||
|
||||
/** The index fields the selection reads. */
|
||||
export interface VariantIndex<E extends VariantEntry> {
|
||||
readonly levels: readonly E[]
|
||||
/** DRLG act layouts keyed by act number (`'1'`..`'5'`); absent for acts the DRLG port does not generate. */
|
||||
readonly actLayouts?: Readonly<Record<string, readonly ActLayout[]>> | undefined
|
||||
}
|
||||
|
||||
/** The index entry fields the selection reads. */
|
||||
export interface VariantEntry extends VariantCandidate {
|
||||
readonly act: number
|
||||
readonly levelId?: number
|
||||
readonly label: string
|
||||
/** DS1 member of a preset copy (as written by the bake). */
|
||||
readonly ds1?: string
|
||||
/** Variant of a DRLG-generated level (matches {@link ActLayout.variant}). */
|
||||
readonly actVariant?: number
|
||||
}
|
||||
|
||||
/** Archive paths compare case-insensitively and with either slash. */
|
||||
function normalizeMember(path: string): string {
|
||||
return path.replace(/\//g, '\\').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-session choice of level copies.
|
||||
*
|
||||
* One instance per loaded pack. {@link pin} lets an explicitly opened copy (a `?level=` label, the
|
||||
* variant menu) decide its act's variant, so walking on from there stays in the same generated act.
|
||||
*/
|
||||
export class WorldVariants<E extends VariantEntry> {
|
||||
private readonly pinned = new Map<number, number>()
|
||||
|
||||
constructor(
|
||||
private readonly index: VariantIndex<E>,
|
||||
private readonly seed: number = WORLD_VARIANT_SEED,
|
||||
) {
|
||||
for (const [act, layouts] of Object.entries(index.actLayouts ?? {})) {
|
||||
if (layouts.length === 0) throw new Error(`actLayouts[${act}] is empty`)
|
||||
layouts.forEach((layout, at) => {
|
||||
if (layout.variant !== at) throw new Error(`actLayouts[${act}][${String(at)}] has variant ${String(layout.variant)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** The act's DRLG layouts, or null when the act is not DRLG-generated. */
|
||||
layoutsOf(act: number): readonly ActLayout[] | null {
|
||||
return this.index.actLayouts?.[String(act)] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy of the act this world uses, or null when the act is not DRLG-generated.
|
||||
*
|
||||
* @param act - act number (1..5).
|
||||
*/
|
||||
layoutFor(act: number): ActLayout | null {
|
||||
const layouts = this.layoutsOf(act)
|
||||
if (layouts === null) return null
|
||||
const variant = this.pinned.get(act) ?? Math.abs(this.seed + act) % layouts.length
|
||||
const layout = layouts[variant]
|
||||
if (layout === undefined) throw new Error(`act ${String(act)} has no layout variant ${String(variant)}`)
|
||||
return layout
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an explicitly opened copy decide its act's variant.
|
||||
*
|
||||
* A generated level pins its own `actVariant`; a preset copy pins the first variant whose act fixed
|
||||
* that DS1 (a town file no variant uses pins nothing).
|
||||
*
|
||||
* @param entry - the copy being opened.
|
||||
*/
|
||||
pin(entry: E): void {
|
||||
const layouts = this.layoutsOf(entry.act)
|
||||
if (layouts === null) return
|
||||
if (entry.actVariant !== undefined) {
|
||||
if (layouts[entry.actVariant] === undefined) {
|
||||
throw new Error(`${entry.label}: act ${String(entry.act)} has no layout variant ${String(entry.actVariant)}`)
|
||||
}
|
||||
this.pinned.set(entry.act, entry.actVariant)
|
||||
return
|
||||
}
|
||||
if (entry.levelId === undefined || entry.ds1 === undefined) return
|
||||
const ds1 = normalizeMember(entry.ds1)
|
||||
const match = layouts.find(layout =>
|
||||
layout.presets.some(p => p.levelId === entry.levelId && normalizeMember(p.ds1) === ds1))
|
||||
if (match !== undefined) this.pinned.set(entry.act, match.variant)
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy of a level this world uses.
|
||||
*
|
||||
* @param levelId - `Levels.txt` `Id`.
|
||||
* @param fromSide - the edge of the previous level the player walked off, for per-level choices.
|
||||
* @returns the index entry, or null when the pack has no copy of the level.
|
||||
*/
|
||||
entryForLevel(levelId: number, fromSide?: Side): E | null {
|
||||
const candidates = this.index.levels.filter(entry => entry.levelId === levelId)
|
||||
if (candidates.length === 0) return null
|
||||
const act = candidates[0]!.act
|
||||
if (candidates.some(entry => entry.act !== act)) throw new Error(`level ${String(levelId)} is baked into more than one act`)
|
||||
const layout = this.layoutFor(act)
|
||||
if (layout !== null) {
|
||||
if (layout.levels.includes(levelId)) {
|
||||
return exactlyOne(candidates.filter(entry => entry.actVariant === layout.variant),
|
||||
`level ${String(levelId)}, act ${String(act)} variant ${String(layout.variant)}`)
|
||||
}
|
||||
const preset = layout.presets.find(p => p.levelId === levelId)
|
||||
if (preset !== undefined) {
|
||||
const ds1 = normalizeMember(preset.ds1)
|
||||
return exactlyOne(candidates.filter(entry => entry.ds1 !== undefined && normalizeMember(entry.ds1) === ds1),
|
||||
`level ${String(levelId)} DS1 ${preset.ds1} (act ${String(act)} variant ${String(layout.variant)})`)
|
||||
}
|
||||
}
|
||||
const requiredSide = fromSide === undefined ? undefined : oppositeSide(fromSide)
|
||||
return findMatchingVariant(candidates, requiredSide, this.seed + levelId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy a fresh session opens an act in: the act's town.
|
||||
*
|
||||
* In a DRLG-generated act that is the town file the chosen copy of the act fixed (its gate faces
|
||||
* the generated level next to it); other acts keep their fixed default (`getDefaultTownVariant`).
|
||||
*
|
||||
* @param act - act number (1..5).
|
||||
* @returns the index entry, or null when the pack has nothing of this act.
|
||||
*/
|
||||
startEntry(act: number): E | null {
|
||||
const forAct = this.index.levels.filter(entry => entry.act === act)
|
||||
if (forAct.length === 0) return null
|
||||
if (this.layoutFor(act) === null) return getDefaultTownVariant(forAct, act) ?? null
|
||||
const town = this.entryForLevel(townLevelForAct(act))
|
||||
if (town === null) throw new Error(`act ${String(act)} is DRLG-generated but the pack has no copy of its town`)
|
||||
return town
|
||||
}
|
||||
}
|
||||
|
||||
function exactlyOne<E extends VariantEntry>(found: readonly E[], what: string): E {
|
||||
if (found.length !== 1) throw new Error(`${what}: ${String(found.length)} baked copies match, expected exactly 1`)
|
||||
return found[0]!
|
||||
}
|
||||
|
|
@ -465,6 +465,18 @@ function resolveCandidates(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cell's DT1 mask lets the renderer draw a tile of `library` (libraries from index 21 on are
|
||||
* never filtered).
|
||||
*
|
||||
* @param dt1Mask - the cell's `dt1Mask`.
|
||||
* @param library - index into the scene's library list.
|
||||
* @returns true when the library's tiles are candidates for the cell.
|
||||
*/
|
||||
export function dt1MaskAllows(dt1Mask: number, library: number): boolean {
|
||||
return library >= 21 || ((dt1Mask >>> 0) & (1 << library)) !== 0
|
||||
}
|
||||
|
||||
function filterByDt1Mask(
|
||||
candidates: readonly PoolTile[] | undefined,
|
||||
dt1Mask: number | undefined,
|
||||
|
|
@ -472,8 +484,7 @@ function filterByDt1Mask(
|
|||
if (candidates === undefined || candidates.length === 0 || dt1Mask === undefined) {
|
||||
return candidates
|
||||
}
|
||||
const mask = dt1Mask >>> 0
|
||||
const filtered = candidates.filter(c => c.library >= 21 || (mask & (1 << c.library)) !== 0)
|
||||
const filtered = candidates.filter(c => dt1MaskAllows(dt1Mask, c.library))
|
||||
return filtered.length > 0 ? filtered : candidates
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,6 +23,10 @@ export interface D2TileLibraryEntryStrc {
|
|||
nSequence: number
|
||||
nRarity_Frame: number
|
||||
nSubtileFlags: Uint8Array
|
||||
/** Port identity (not a D2CMP field): normalized DT1 path of the owning library (= D2TileLibraryStrc.szPath). */
|
||||
readonly szLibrary: string
|
||||
/** Port identity (not a D2CMP field): index of this tile header inside its DT1 file. */
|
||||
readonly nIndex: number
|
||||
}
|
||||
|
||||
export interface D2TileLibraryStrc {
|
||||
|
|
@ -62,6 +66,8 @@ function parseDt1Library(szPath: string, bytes: Uint8Array): D2TileLibraryStrc {
|
|||
nSequence: view.getInt32(nBase + 0x1c, true),
|
||||
nRarity_Frame: view.getInt32(nBase + 0x20, true),
|
||||
nSubtileFlags,
|
||||
szLibrary: szPath,
|
||||
nIndex: i,
|
||||
})
|
||||
}
|
||||
return { szPath, tiles }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
/**
|
||||
* JSON dumps of the DRLG port in the exact schema the native D2MOO oracle writes
|
||||
* (tools/d2moo-oracle/src/main.cpp, schema 2; documented in tools/d2moo-oracle/README.md), plus the
|
||||
* TypeScript twin of the oracle driver. Diffing the two dumps stage by stage is the acceptance gate
|
||||
* of the port (scripts/drlg-diff.ts, tests/drlg-act1-oracle.test.ts).
|
||||
* (tools/d2moo-oracle/src/main.cpp, schema 3; documented in tools/d2moo-oracle/README.md), plus the
|
||||
* TypeScript twin of the oracle driver (`runAct1`, the single generation code path; `dumpAct1` builds the
|
||||
* dump from its hooks). Diffing the two dumps stage by stage is the acceptance gate of the port
|
||||
* (scripts/drlg-diff.ts, tests/drlg-act1-oracle.test.ts).
|
||||
*
|
||||
* Integer signedness follows the C field types, independent of how the port stores the value:
|
||||
* uint32 fields are printed with `>>> 0`, int32 fields with `| 0`.
|
||||
*/
|
||||
|
||||
import { setD2SeedTraceSink, type D2SeedStrc, type D2SeedTraceSink } from '../d2-rng.ts'
|
||||
import type { D2TileLibraryEntryStrc } from './drlg-d2cmp.ts'
|
||||
import {
|
||||
ACT_I,
|
||||
DRLGROOMFLAG_HAS_ROOM,
|
||||
|
|
@ -22,18 +24,22 @@ import {
|
|||
type D2DrlgCoordStrc,
|
||||
type D2DrlgGridStrc,
|
||||
type D2DrlgLevelStrc,
|
||||
type D2DrlgLogicalRoomInfoStrc,
|
||||
type D2DrlgMapStrc,
|
||||
type D2DrlgOrthStrc,
|
||||
type D2DrlgRoomStrc,
|
||||
type D2DrlgStrc,
|
||||
type D2DrlgTileDataStrc,
|
||||
type D2DrlgVertexStrc,
|
||||
type D2PresetUnitStrc,
|
||||
type D2RoomCoordListStrc,
|
||||
type DrlgEnv,
|
||||
} from './drlg-types.ts'
|
||||
import { DRLG_AllocDrlg, DRLG_GetActNoFromLevelId, DRLG_GetLevel, DRLG_InitLevel } from './drlg-drlg.ts'
|
||||
import { setLevelGridHook } from './drlg-hooks.ts'
|
||||
import { DRLGPRESET_SpawnHardcodedPresetUnits } from './drlg-preset.ts'
|
||||
import { sub_6FD77BB0 } from './drlg-room.ts'
|
||||
import { DRLGROOMTILE_InitRoomGrids, DRLGROOMTILE_LoadDT1FilesForRoom } from './drlg-roomtile.ts'
|
||||
import { DRLGROOMTILE_AddRoomMapTiles, DRLGROOMTILE_InitRoomGrids, DRLGROOMTILE_LoadDT1FilesForRoom } from './drlg-roomtile.ts'
|
||||
import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts'
|
||||
|
||||
const u32 = (v: number): number => v >>> 0
|
||||
|
|
@ -86,6 +92,103 @@ export interface DumpRoom {
|
|||
preset?: { prest: number; picked: number; flags: number; map: number }
|
||||
}
|
||||
|
||||
// ---- activation stage (schema 3: + seedAfterTiles, mapTiles) ----
|
||||
|
||||
/**
|
||||
* `D2DrlgTileDataStrc*`: [levelId, room index in pFirstRoomEx order, array, element index] into that room's
|
||||
* `pTileGrid.pTiles` array: 'w' pWallTiles, 'f' pFloorTiles, 'r' pRoofTiles. The index may exceed the
|
||||
* dumped element count (it addresses the allocation). Rooms of any level of the act can be referenced.
|
||||
*/
|
||||
export type DumpTileRef = [number, number, 'w' | 'f' | 'r', number]
|
||||
/** `D2RoomCoordListStrc*`: [levelId, room, 'c', index in that room's `pLogicalRoomInfo.pCoordList` chain]. */
|
||||
export type DumpCoordListRef = [number, number, 'c', number]
|
||||
/** `D2TileLibraryEntryStrc*`: [DT1 path (lower case, '/'-separated, e.g. "data/global/tiles/act1/outdoors/blank.dt1"), tile index in that DT1]. */
|
||||
export type DumpTileEntry = [string, number]
|
||||
/** Every D2DrlgTileDataStrc field in struct order (D2DrlgDrlg.h:333); pointers as references. */
|
||||
export type DumpTile = [
|
||||
nWidth: number,
|
||||
nHeight: number,
|
||||
nPosX: number,
|
||||
nPosY: number,
|
||||
unk0x10: number | DumpCoordListRef,
|
||||
dwFlags: number,
|
||||
pTile: DumpTileEntry | null,
|
||||
nTileType: number,
|
||||
unk0x20: DumpTileRef | null,
|
||||
unk0x24: number,
|
||||
nRed: number,
|
||||
nGreen: number,
|
||||
nBlue: number,
|
||||
nIntensity: number,
|
||||
unk0x2C: number,
|
||||
]
|
||||
/** D2DrlgTileLinkStrc (pTileGrid.pMapLinks chain order). */
|
||||
export type DumpTileLink = [bFloor: number, pMapTile: DumpTileRef | null]
|
||||
/** D2RoomTileStrc (pRoomTiles chain order); the LvlWarp.txt record as its row index, -1 for NULL. */
|
||||
export type DumpRoomTile = [
|
||||
pDrlgRoom: [levelId: number, room: number] | null,
|
||||
nLvlWarpTxtRecord: number,
|
||||
bEnabled: number,
|
||||
unk0x0C: DumpTileRef | null,
|
||||
unk0x10: DumpTileRef | null,
|
||||
]
|
||||
/** D2RoomCoordListStrc. */
|
||||
export type DumpCoordList = [nIndex: number, bNode: number, bRoomActive: number, box0: DumpCoord, box1: DumpCoord]
|
||||
/** D2DrlgLogicalRoomInfoStrc. */
|
||||
export interface DumpLogicalRoomInfo {
|
||||
flags: number
|
||||
nLists: number
|
||||
indexX: DumpGrid | null
|
||||
/** pIndexY cells (`(int)D2RoomCoordListStrc*`) as 1 + the index of the list in `lists`, 0 for NULL. */
|
||||
indexY: DumpGrid | null
|
||||
/** The pCoordList chain in order. */
|
||||
lists: DumpCoordList[]
|
||||
}
|
||||
/**
|
||||
* The room's D2DrlgTileGridStrc after every requested room has been activated (final state: a room
|
||||
* activated later rewrites tiles of the near rooms activated before it, across level seams too).
|
||||
*/
|
||||
export interface DumpMapTiles {
|
||||
/** pTileGrid counters. */
|
||||
nWalls: number
|
||||
nFloors: number
|
||||
nShadows: number
|
||||
/** pTileGrid.pTiles counters (what D2Common hands on with the room); walls/floors/roofs hold exactly these many entries. */
|
||||
pTiles: { nWalls: number; nFloors: number; nRoofs: number }
|
||||
walls: DumpTile[]
|
||||
floors: DumpTile[]
|
||||
/** Shadow tiles (DRLGROOMTILE_InitShadowTileData stores them in pRoofTiles). */
|
||||
roofs: DumpTile[]
|
||||
links: DumpTileLink[]
|
||||
warps: DumpRoomTile[]
|
||||
/** pPresetUnits in the final state (incl. the UNIT_TILE units of DRLGROOMTILE_AddWarp and door units). */
|
||||
units: DumpUnit[]
|
||||
logical: DumpLogicalRoomInfo | null
|
||||
}
|
||||
|
||||
interface DumpActivationTail {
|
||||
/** Room seed right after DRLGROOMTILE_InitRoomGrids. */
|
||||
seed: DumpSeed
|
||||
/** pPresetUnits right after DRLGROOMTILE_InitRoomGrids. */
|
||||
units: DumpUnit[]
|
||||
/** Room seed right after the room's own DRLGROOMTILE_AddRoomMapTiles. */
|
||||
seedAfterTiles: DumpSeed
|
||||
mapTiles: DumpMapTiles
|
||||
}
|
||||
export interface DumpOutdoorActivation extends DumpActivationTail {
|
||||
tile: DumpGrid | null
|
||||
wall: DumpGrid | null
|
||||
floor: DumpGrid | null
|
||||
dirt: DumpGrid | null
|
||||
}
|
||||
export interface DumpPresetActivation extends DumpActivationTail {
|
||||
walls: (DumpGrid | null)[]
|
||||
tiles: (DumpGrid | null)[]
|
||||
floors: (DumpGrid | null)[]
|
||||
shadow: DumpGrid | null
|
||||
}
|
||||
export type DumpActivation = DumpOutdoorActivation | DumpPresetActivation
|
||||
|
||||
export interface DumpLevel {
|
||||
id: number
|
||||
levelGrid: DumpLevelGrid
|
||||
|
|
@ -94,11 +197,12 @@ export interface DumpLevel {
|
|||
maps: DumpMap[]
|
||||
levelSeedAfterRooms: DumpSeed
|
||||
warp: { n: number; xy: [number, number][] }
|
||||
activation: unknown[]
|
||||
/** One entry per room in pFirstRoomEx order (empty when activation is skipped). */
|
||||
activation: DumpActivation[]
|
||||
}
|
||||
|
||||
export interface DrlgDump {
|
||||
schema: 2
|
||||
schema: 3
|
||||
oracle?: { d2moo: string; arch: string }
|
||||
gameSeed: number
|
||||
difficulty: number
|
||||
|
|
@ -318,22 +422,219 @@ export class DrlgTraceWriter implements D2SeedTraceSink {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- mapTiles (schema 3) ----
|
||||
|
||||
/** Pointer -> reference tables over every room of one act (pDrlg->pLevel chain, pFirstRoomEx order). */
|
||||
interface DumpRefIndex {
|
||||
readonly tiles: Map<D2DrlgTileDataStrc, DumpTileRef>
|
||||
readonly coordLists: Map<D2RoomCoordListStrc, DumpCoordListRef>
|
||||
readonly rooms: Map<D2DrlgRoomStrc, [number, number]>
|
||||
}
|
||||
|
||||
/** Chains longer than this cannot come from D2Common (C would loop forever on a cycle). */
|
||||
const MAX_DUMP_CHAIN = 1 << 20
|
||||
|
||||
function buildRefIndex(pDrlg: D2DrlgStrc): DumpRefIndex {
|
||||
const index: DumpRefIndex = { tiles: new Map(), coordLists: new Map(), rooms: new Map() }
|
||||
for (let pLevel = pDrlg.pLevel; pLevel; pLevel = pLevel.pNextLevel) {
|
||||
const nLevelId = pLevel.nLevelId
|
||||
let nRoom = 0
|
||||
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, nRoom += 1) {
|
||||
index.rooms.set(pRoom, [nLevelId, nRoom])
|
||||
const pTileGrid = pRoom.pTileGrid
|
||||
if (pTileGrid) {
|
||||
const add = (pArray: D2DrlgTileDataStrc[] | null, cArray: 'w' | 'f' | 'r'): void => {
|
||||
if (!pArray) return
|
||||
for (let i = 0; i < pArray.length; i += 1) {
|
||||
const pTileData = pArray[i]!
|
||||
if (index.tiles.has(pTileData)) throw new Error(`level ${nLevelId} room ${nRoom}: a tile data element belongs to two arrays`)
|
||||
index.tiles.set(pTileData, [nLevelId, nRoom, cArray, i])
|
||||
}
|
||||
}
|
||||
add(pTileGrid.pTiles.pWallTiles, 'w')
|
||||
add(pTileGrid.pTiles.pFloorTiles, 'f')
|
||||
add(pTileGrid.pTiles.pRoofTiles, 'r')
|
||||
}
|
||||
const pInfo = pRoom.pLogicalRoomInfo
|
||||
if (pInfo) {
|
||||
let k = 0
|
||||
for (let p = pInfo.pCoordList; p; p = p.pNext, k += 1) {
|
||||
if (k >= MAX_DUMP_CHAIN) throw new Error(`level ${nLevelId} room ${nRoom}: pCoordList chain does not terminate`)
|
||||
index.coordLists.set(p, [nLevelId, nRoom, 'c', k])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function dumpTileRef(index: DumpRefIndex, p: D2DrlgTileDataStrc | null): DumpTileRef | null {
|
||||
if (!p) return null
|
||||
const ref = index.tiles.get(p)
|
||||
if (!ref) throw new Error('tile data pointer outside every tile array of the act (dangling?)')
|
||||
return ref
|
||||
}
|
||||
|
||||
function dumpTileEntry(pTile: D2TileLibraryEntryStrc | null): DumpTileEntry | null {
|
||||
return pTile ? [pTile.szLibrary.replace(/\\/g, '/'), pTile.nIndex] : null
|
||||
}
|
||||
|
||||
function dumpTileData(index: DumpRefIndex, t: D2DrlgTileDataStrc): DumpTile {
|
||||
let unk0x10: number | DumpCoordListRef
|
||||
if (typeof t.unk0x10 === 'number') {
|
||||
unk0x10 = s32(t.unk0x10)
|
||||
} else {
|
||||
const ref = index.coordLists.get(t.unk0x10)
|
||||
if (!ref) throw new Error('D2DrlgTileDataStrc::unk0x10 holds a coord list outside every pCoordList chain of the act')
|
||||
unk0x10 = ref
|
||||
}
|
||||
return [
|
||||
s32(t.nWidth),
|
||||
s32(t.nHeight),
|
||||
s32(t.nPosX),
|
||||
s32(t.nPosY),
|
||||
unk0x10,
|
||||
u32(t.dwFlags),
|
||||
dumpTileEntry(t.pTile),
|
||||
s32(t.nTileType),
|
||||
dumpTileRef(index, t.unk0x20),
|
||||
s32(t.unk0x24),
|
||||
u8(t.nRed),
|
||||
u8(t.nGreen),
|
||||
u8(t.nBlue),
|
||||
u8(t.nIntensity),
|
||||
s32(t.unk0x2C),
|
||||
]
|
||||
}
|
||||
|
||||
function dumpTileDataArray(index: DumpRefIndex, pArray: D2DrlgTileDataStrc[] | null, nTiles: number, szWhat: string): DumpTile[] {
|
||||
if (nTiles < 0 || (nTiles > 0 && !pArray)) throw new Error(`${nTiles} ${szWhat} without an array`)
|
||||
if (pArray && nTiles > pArray.length) throw new Error(`${nTiles} ${szWhat} exceed their ${pArray.length}-element allocation`)
|
||||
const out: DumpTile[] = []
|
||||
for (let i = 0; i < nTiles; i += 1) out.push(dumpTileData(index, pArray![i]!))
|
||||
return out
|
||||
}
|
||||
|
||||
function dumpLogicalRoomInfo(pInfo: D2DrlgLogicalRoomInfoStrc | null): DumpLogicalRoomInfo | null {
|
||||
if (!pInfo) return null
|
||||
const chain = new Map<D2RoomCoordListStrc, number>()
|
||||
const lists: DumpCoordList[] = []
|
||||
for (let p = pInfo.pCoordList; p; p = p.pNext) {
|
||||
if (lists.length >= MAX_DUMP_CHAIN) throw new Error('pCoordList chain does not terminate')
|
||||
chain.set(p, lists.length)
|
||||
lists.push([s32(p.nIndex), s32(p.bNode), s32(p.bRoomActive), dumpCoord(p.pBox[0]), dumpCoord(p.pBox[1])])
|
||||
}
|
||||
// pIndexY cells hold `(int)D2RoomCoordListStrc*` in C and a 1-based handle into ppCoordListHandles here.
|
||||
let indexY: DumpGrid | null = null
|
||||
const g = pInfo.pIndexY
|
||||
if (g.pCellsFlags && g.pCellsRowOffsets) {
|
||||
const cells: number[] = []
|
||||
for (let y = 0; y < g.nHeight; y += 1) {
|
||||
for (let x = 0; x < g.nWidth; x += 1) {
|
||||
const idx = g.nCellsBase + x + g.pCellsRowOffsets[y]!
|
||||
if (idx < 0 || idx >= g.pCellsFlags.length) throw new RangeError(`pIndexY cell (${x}, ${y}) outside the backing array`)
|
||||
const hCell = g.pCellsFlags[idx]!
|
||||
if (hCell === 0) {
|
||||
cells.push(0)
|
||||
continue
|
||||
}
|
||||
const pList = hCell > 0 ? pInfo.ppCoordListHandles[hCell - 1] : undefined
|
||||
const k = pList ? chain.get(pList) : undefined
|
||||
if (k === undefined) throw new Error(`pIndexY cell (${x}, ${y}) is not a list of the room's chain`)
|
||||
cells.push(k + 1)
|
||||
}
|
||||
}
|
||||
indexY = { w: s32(g.nWidth), h: s32(g.nHeight), cells }
|
||||
}
|
||||
return { flags: s32(pInfo.dwFlags), nLists: s32(pInfo.nLists), indexX: dumpGrid(pInfo.pIndexX), indexY, lists }
|
||||
}
|
||||
|
||||
function dumpRoomTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc): DumpRoomTile[] {
|
||||
const lvlWarp = pRoom.pLevel.pDrlg.env.tables.lvlWarp
|
||||
const out: DumpRoomTile[] = []
|
||||
for (let p = pRoom.pRoomTiles; p; p = p.pNext) {
|
||||
if (out.length >= MAX_DUMP_CHAIN) throw new Error('pRoomTiles chain does not terminate')
|
||||
let room: [number, number] | null = null
|
||||
if (p.pDrlgRoom) {
|
||||
const r = index.rooms.get(p.pDrlgRoom)
|
||||
if (!r) throw new Error(`warp ${out.length} leads to a room outside the act`)
|
||||
room = [r[0], r[1]]
|
||||
}
|
||||
let nWarp = -1
|
||||
if (p.pLvlWarpTxtRecord) {
|
||||
nWarp = lvlWarp.indexOf(p.pLvlWarpTxtRecord)
|
||||
if (nWarp < 0) throw new Error(`warp ${out.length} has a record outside LvlWarp.txt`)
|
||||
}
|
||||
out.push([room, nWarp, s32(p.bEnabled), dumpTileRef(index, p.unk0x0C), dumpTileRef(index, p.unk0x10)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: number, nRoom: number): DumpMapTiles {
|
||||
const where = `level ${nLevelId} room ${nRoom}`
|
||||
const pTileGrid = pRoom.pTileGrid
|
||||
if (!pTileGrid) throw new Error(`${where}: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles`)
|
||||
if (pTileGrid.pAnimTiles) throw new Error(`${where}: animated tiles (pAnimTiles) are not dumped`)
|
||||
try {
|
||||
const t = pTileGrid.pTiles
|
||||
const links: DumpTileLink[] = []
|
||||
for (let p = pTileGrid.pMapLinks; p; p = p.pNext) {
|
||||
if (links.length >= MAX_DUMP_CHAIN) throw new Error('pMapLinks chain does not terminate')
|
||||
links.push([s32(p.bFloor), dumpTileRef(index, p.pMapTile)])
|
||||
}
|
||||
return {
|
||||
nWalls: s32(pTileGrid.nWalls),
|
||||
nFloors: s32(pTileGrid.nFloors),
|
||||
nShadows: s32(pTileGrid.nShadows),
|
||||
pTiles: { nWalls: s32(t.nWalls), nFloors: s32(t.nFloors), nRoofs: s32(t.nRoofs) },
|
||||
walls: dumpTileDataArray(index, t.pWallTiles, t.nWalls, 'walls'),
|
||||
floors: dumpTileDataArray(index, t.pFloorTiles, t.nFloors, 'floors'),
|
||||
roofs: dumpTileDataArray(index, t.pRoofTiles, t.nRoofs, 'roofs'),
|
||||
links,
|
||||
warps: dumpRoomTiles(index, pRoom),
|
||||
units: dumpPresetUnits(pRoom.pPresetUnits),
|
||||
logical: dumpLogicalRoomInfo(pRoom.pLogicalRoomInfo),
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`${where}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- driver (TypeScript twin of tools/d2moo-oracle/src/main.cpp) ----
|
||||
|
||||
export interface DumpAct1Options {
|
||||
export interface Act1RunOptions {
|
||||
readonly difficulty?: number
|
||||
/** A fresh act per requested level (proves a level does not depend on the others). */
|
||||
readonly isolated?: boolean
|
||||
/** Receives the RNG trace; installed as the global seed trace sink for the duration of the run. */
|
||||
readonly trace?: DrlgTraceWriter
|
||||
/** Stop after the act stage (the `levels` array stays empty). */
|
||||
readonly actOnly?: boolean
|
||||
/**
|
||||
* Generate the levels but do not activate their rooms: every `activation` array stays empty.
|
||||
* Only valid for comparisons that exclude the activation stage (drlg-diff --no-activation).
|
||||
*/
|
||||
/** Generate the levels but do not activate their rooms. */
|
||||
readonly skipActivation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Observation points of runAct1, in execution order. The structures are live: later steps keep mutating
|
||||
* them (a room activated later rewrites tiles of the near rooms activated before it and rolls their
|
||||
* seeds, also across level seams), so a hook must copy what it needs at that moment.
|
||||
*/
|
||||
export interface Act1RunHooks {
|
||||
/** After DRLG_AllocDrlg. `index` 0 is Act1Run.pDrlg; isolated runs allocate one more act per further level. */
|
||||
onDrlgAllocated?(pDrlg: D2DrlgStrc, index: number): void
|
||||
/** After DRLG_InitLevel of a requested level. */
|
||||
onLevelGenerated?(pLevel: D2DrlgLevelStrc): void
|
||||
/** After DRLGROOMTILE_InitRoomGrids of a room, before DRLGROOMTILE_AddRoomMapTiles (`index`: pFirstRoomEx order). */
|
||||
onRoomGridsInitialized?(pRoom: D2DrlgRoomStrc, index: number): void
|
||||
/** After DRLGROOMTILE_AddRoomMapTiles of that room. */
|
||||
onRoomActivated?(pRoom: D2DrlgRoomStrc, index: number): void
|
||||
}
|
||||
|
||||
export interface Act1Run {
|
||||
/** The act allocated first (the only one unless `isolated`). */
|
||||
readonly pDrlg: D2DrlgStrc
|
||||
/** The requested levels in request order; each level's own act is `pLevel.pDrlg`. */
|
||||
readonly levels: readonly D2DrlgLevelStrc[]
|
||||
}
|
||||
|
||||
function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc {
|
||||
trace?.context('drlg')
|
||||
// D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act
|
||||
|
|
@ -341,7 +642,8 @@ function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: Drlg
|
|||
return DRLG_AllocDrlg(env, ACT_I, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nDifficulty)
|
||||
}
|
||||
|
||||
function initLevelStage(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined, grids: Map<number, DumpLevelGrid>): { head: Omit<DumpLevel, 'activation'>; pLevel: D2DrlgLevelStrc } {
|
||||
/** Generation stage of one level: DRLG_InitLevel, everything that exists before any room is activated. */
|
||||
function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc {
|
||||
trace?.context(`L${nLevelId}`)
|
||||
const pLevel = DRLG_GetLevel(pDrlg, nLevelId)
|
||||
if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== ACT_I) {
|
||||
|
|
@ -349,77 +651,16 @@ function initLevelStage(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWri
|
|||
}
|
||||
if (pLevel.pFirstRoomEx) throw new Error(`level ${nLevelId} was already generated before its own generation stage`)
|
||||
DRLG_InitLevel(pLevel)
|
||||
const levelGrid = grids.get(nLevelId)
|
||||
if (!levelGrid) throw new Error(`level ${nLevelId}: InitAct1OutdoorLevel hook did not fire`)
|
||||
const maps: D2DrlgMapStrc[] = []
|
||||
const rooms = dumpRooms(pLevel, maps)
|
||||
const xy: [number, number][] = []
|
||||
for (let i = 0; i < pLevel.nRoomCoords; i += 1) xy.push([pLevel.nRoom_Center_Warp_X[i]!, pLevel.nRoom_Center_Warp_Y[i]!])
|
||||
return {
|
||||
head: {
|
||||
id: nLevelId,
|
||||
levelGrid,
|
||||
nRooms: pLevel.nRooms,
|
||||
rooms,
|
||||
maps: maps.map(m => dumpMap(pDrlg.env, m)),
|
||||
levelSeedAfterRooms: dumpSeed(pLevel.pSeed),
|
||||
warp: { n: pLevel.nRoomCoords, xy },
|
||||
},
|
||||
pLevel,
|
||||
}
|
||||
return pLevel
|
||||
}
|
||||
|
||||
/** Runs the Act I generation exactly like the oracle driver and returns the same JSON document. */
|
||||
export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], opts: DumpAct1Options = {}): DrlgDump {
|
||||
const nDifficulty = opts.difficulty ?? 0
|
||||
const isolated = opts.isolated ?? false
|
||||
const trace = opts.trace
|
||||
const grids = new Map<number, DumpLevelGrid>()
|
||||
if (trace) setD2SeedTraceSink(trace)
|
||||
const previousHook = setLevelGridHook(pLevel => {
|
||||
grids.set(pLevel.nLevelId, dumpLevelGrid(pLevel))
|
||||
})
|
||||
try {
|
||||
let pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
|
||||
const doc: DrlgDump = {
|
||||
schema: 2,
|
||||
gameSeed: nSeed >>> 0,
|
||||
difficulty: nDifficulty,
|
||||
isolated,
|
||||
drlg: { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) },
|
||||
act: dumpAct(pDrlg),
|
||||
levels: [],
|
||||
}
|
||||
if (opts.actOnly) return doc
|
||||
const heads: Omit<DumpLevel, 'activation'>[] = []
|
||||
const generated: D2DrlgLevelStrc[] = []
|
||||
const activations: unknown[][] = []
|
||||
if (isolated) {
|
||||
for (let i = 0; i < levels.length; i += 1) {
|
||||
if (i > 0) pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
|
||||
const r = initLevelStage(pDrlg, levels[i]!, trace, grids)
|
||||
heads.push(r.head)
|
||||
generated.push(r.pLevel)
|
||||
activations.push(opts.skipActivation ? [] : activateLevel(r.pLevel, trace))
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < levels.length; i += 1) {
|
||||
const r = initLevelStage(pDrlg, levels[i]!, trace, grids)
|
||||
heads.push(r.head)
|
||||
generated.push(r.pLevel)
|
||||
}
|
||||
for (let i = 0; i < levels.length; i += 1) activations.push(opts.skipActivation ? [] : activateLevel(generated[i]!, trace))
|
||||
}
|
||||
for (let i = 0; i < levels.length; i += 1) doc.levels.push({ ...heads[i]!, activation: activations[i]! })
|
||||
return doc
|
||||
} finally {
|
||||
setLevelGridHook(previousHook)
|
||||
if (trace) setD2SeedTraceSink(null)
|
||||
}
|
||||
}
|
||||
|
||||
function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefined): unknown[] {
|
||||
const out: unknown[] = []
|
||||
/**
|
||||
* Mirrors DRLGACTIVATE_InitializeRoomEx (DrlgActivate.cpp:317-331) followed by
|
||||
* DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-115) up to DRLGROOMTILE_AddRoomMapTiles, for every
|
||||
* room in pFirstRoomEx order. DRLG_CreateRoomForRoomEx (D2Game's D2ActiveRoomStrc) is not run, so pRoom
|
||||
* stays NULL (D2Common_COLLISION_FirstFn_6FD41000 is a no-op).
|
||||
*/
|
||||
function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefined, hooks: Act1RunHooks): void {
|
||||
let i = 0
|
||||
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, i += 1) {
|
||||
trace?.context(`L${pLevel.nLevelId}/a${i}`)
|
||||
|
|
@ -429,47 +670,194 @@ function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefin
|
|||
if (!(pRoom.dwFlags & DRLGROOMFLAG_PRESET_UNITS_ADDED) && pRoom.nType === DRLGTYPE_PRESET) {
|
||||
DRLGPRESET_SpawnHardcodedPresetUnits(pRoom)
|
||||
}
|
||||
if (pRoom.dwFlags & DRLGROOMFLAG_HAS_ROOM) {
|
||||
if (pRoom.pRoom || pRoom.dwFlags & DRLGROOMFLAG_HAS_ROOM) {
|
||||
throw new Error(`level ${pLevel.nLevelId} room ${i}: already has an active room`)
|
||||
}
|
||||
if (!pRoom.nRoomsNear) {
|
||||
sub_6FD77BB0(pRoom)
|
||||
}
|
||||
DRLGROOMTILE_InitRoomGrids(pRoom)
|
||||
|
||||
if (pRoom.nType === DRLGTYPE_MAZE) {
|
||||
const p = pRoom.pOutdoor!
|
||||
out.push({
|
||||
seed: dumpSeed(pRoom.pSeed),
|
||||
tile: dumpGrid(p.pTileTypeGrid),
|
||||
wall: dumpGrid(p.pWallGrid),
|
||||
floor: dumpGrid(p.pFloorGrid),
|
||||
dirt: dumpGrid(p.pDirtPathGrid),
|
||||
units: dumpPresetUnits(pRoom.pPresetUnits),
|
||||
})
|
||||
} else {
|
||||
const p = pRoom.pMaze!
|
||||
const pFile = p.pMap!.pFile!
|
||||
const walls: (DumpGrid | null)[] = []
|
||||
const tiles: (DumpGrid | null)[] = []
|
||||
const floors: (DumpGrid | null)[] = []
|
||||
for (let l = 0; l < pFile.nWallLayers; l += 1) {
|
||||
walls.push(dumpGrid(p.pWallGrid[l]!))
|
||||
tiles.push(dumpGrid(p.pTileTypeGrid[l]!))
|
||||
}
|
||||
for (let l = 0; l < pFile.nFloorLayers; l += 1) {
|
||||
floors.push(dumpGrid(p.pFloorGrid[l]!))
|
||||
}
|
||||
out.push({
|
||||
seed: dumpSeed(pRoom.pSeed),
|
||||
walls,
|
||||
tiles,
|
||||
floors,
|
||||
shadow: dumpGrid(p.pCellGrid),
|
||||
units: dumpPresetUnits(pRoom.pPresetUnits),
|
||||
})
|
||||
}
|
||||
hooks.onRoomGridsInitialized?.(pRoom, i)
|
||||
DRLGROOMTILE_AddRoomMapTiles(pRoom)
|
||||
hooks.onRoomActivated?.(pRoom, i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the Act I generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then
|
||||
* DRLG_InitLevel of every requested level, then the activation of every room of those levels (see
|
||||
* activateLevel). A non-isolated run generates every requested level before activating any room, because
|
||||
* activating a room initialises the levels behind its warps (sub_6FD77BB0 -> DRLG_InitLevel); an isolated
|
||||
* run allocates a fresh act per level and activates it right away.
|
||||
*/
|
||||
export function runAct1(
|
||||
env: DrlgEnv,
|
||||
nSeed: number,
|
||||
levels: readonly number[],
|
||||
opts: Act1RunOptions = {},
|
||||
hooks: Act1RunHooks = {},
|
||||
): Act1Run {
|
||||
const nDifficulty = opts.difficulty ?? 0
|
||||
const trace = opts.trace
|
||||
if (trace) setD2SeedTraceSink(trace)
|
||||
try {
|
||||
let nActs = 0
|
||||
const allocAct = (): D2DrlgStrc => {
|
||||
const pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
|
||||
hooks.onDrlgAllocated?.(pDrlg, nActs)
|
||||
nActs += 1
|
||||
return pDrlg
|
||||
}
|
||||
const generate = (pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLevelStrc => {
|
||||
const pLevel = initLevel(pDrlg, nLevelId, trace)
|
||||
hooks.onLevelGenerated?.(pLevel)
|
||||
return pLevel
|
||||
}
|
||||
const pFirstDrlg = allocAct()
|
||||
const generated: D2DrlgLevelStrc[] = []
|
||||
if (opts.isolated) {
|
||||
for (let i = 0; i < levels.length; i += 1) {
|
||||
const pLevel = generate(i > 0 ? allocAct() : pFirstDrlg, levels[i]!)
|
||||
generated.push(pLevel)
|
||||
if (!opts.skipActivation) activateLevel(pLevel, trace, hooks)
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < levels.length; i += 1) generated.push(generate(pFirstDrlg, levels[i]!))
|
||||
if (!opts.skipActivation) {
|
||||
for (const pLevel of generated) activateLevel(pLevel, trace, hooks)
|
||||
}
|
||||
}
|
||||
return { pDrlg: pFirstDrlg, levels: generated }
|
||||
} finally {
|
||||
if (trace) setD2SeedTraceSink(null)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DumpAct1Options extends Act1RunOptions {
|
||||
/** Stop after the act stage (the `levels` array stays empty). */
|
||||
readonly actOnly?: boolean
|
||||
/**
|
||||
* Generate the levels but do not activate their rooms: every `activation` array stays empty.
|
||||
* Only valid for comparisons that exclude the activation stage (drlg-diff --no-activation).
|
||||
*/
|
||||
readonly skipActivation?: boolean
|
||||
}
|
||||
|
||||
function dumpLevelHead(pLevel: D2DrlgLevelStrc, levelGrid: DumpLevelGrid): Omit<DumpLevel, 'activation'> {
|
||||
const maps: D2DrlgMapStrc[] = []
|
||||
const rooms = dumpRooms(pLevel, maps)
|
||||
const xy: [number, number][] = []
|
||||
for (let i = 0; i < pLevel.nRoomCoords; i += 1) xy.push([pLevel.nRoom_Center_Warp_X[i]!, pLevel.nRoom_Center_Warp_Y[i]!])
|
||||
return {
|
||||
id: pLevel.nLevelId,
|
||||
levelGrid,
|
||||
nRooms: pLevel.nRooms,
|
||||
rooms,
|
||||
maps: maps.map(m => dumpMap(pLevel.pDrlg.env, m)),
|
||||
levelSeedAfterRooms: dumpSeed(pLevel.pSeed),
|
||||
warp: { n: pLevel.nRoomCoords, xy },
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema-2 activation fields of a room, right after DRLGROOMTILE_InitRoomGrids. */
|
||||
function dumpRoomGrids(pRoom: D2DrlgRoomStrc): Omit<DumpOutdoorActivation, 'seedAfterTiles' | 'mapTiles'> | Omit<DumpPresetActivation, 'seedAfterTiles' | 'mapTiles'> {
|
||||
if (pRoom.nType === DRLGTYPE_MAZE) {
|
||||
const p = pRoom.pOutdoor!
|
||||
return {
|
||||
seed: dumpSeed(pRoom.pSeed),
|
||||
tile: dumpGrid(p.pTileTypeGrid),
|
||||
wall: dumpGrid(p.pWallGrid),
|
||||
floor: dumpGrid(p.pFloorGrid),
|
||||
dirt: dumpGrid(p.pDirtPathGrid),
|
||||
units: dumpPresetUnits(pRoom.pPresetUnits),
|
||||
}
|
||||
}
|
||||
const p = pRoom.pMaze!
|
||||
const pFile = p.pMap!.pFile!
|
||||
const walls: (DumpGrid | null)[] = []
|
||||
const tiles: (DumpGrid | null)[] = []
|
||||
const floors: (DumpGrid | null)[] = []
|
||||
for (let l = 0; l < pFile.nWallLayers; l += 1) {
|
||||
walls.push(dumpGrid(p.pWallGrid[l]!))
|
||||
tiles.push(dumpGrid(p.pTileTypeGrid[l]!))
|
||||
}
|
||||
for (let l = 0; l < pFile.nFloorLayers; l += 1) {
|
||||
floors.push(dumpGrid(p.pFloorGrid[l]!))
|
||||
}
|
||||
return { seed: dumpSeed(pRoom.pSeed), walls, tiles, floors, shadow: dumpGrid(p.pCellGrid), units: dumpPresetUnits(pRoom.pPresetUnits) }
|
||||
}
|
||||
|
||||
interface RoomActivationRecord {
|
||||
readonly pRoom: D2DrlgRoomStrc
|
||||
readonly index: number
|
||||
readonly grids: ReturnType<typeof dumpRoomGrids>
|
||||
seedAfterTiles: DumpSeed | null
|
||||
}
|
||||
|
||||
/** Runs runAct1 and returns the same JSON document as the oracle driver. */
|
||||
export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], opts: DumpAct1Options = {}): DrlgDump {
|
||||
const levelGrids = new Map<number, DumpLevelGrid>()
|
||||
const heads = new Map<D2DrlgLevelStrc, Omit<DumpLevel, 'activation'>>()
|
||||
const activations = new Map<D2DrlgLevelStrc, RoomActivationRecord[]>()
|
||||
// Assigned by the hooks (the casts keep TypeScript from narrowing them to `null` for good).
|
||||
let drlg = null as DrlgDump['drlg'] | null
|
||||
let act = null as DumpActLevel[] | null
|
||||
const previousHook = setLevelGridHook(pLevel => {
|
||||
levelGrids.set(pLevel.nLevelId, dumpLevelGrid(pLevel))
|
||||
})
|
||||
let run: Act1Run
|
||||
try {
|
||||
run = runAct1(env, nSeed, opts.actOnly ? [] : levels, opts, {
|
||||
onDrlgAllocated(pDrlg, index) {
|
||||
if (index !== 0) return
|
||||
drlg = { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) }
|
||||
act = dumpAct(pDrlg)
|
||||
},
|
||||
onLevelGenerated(pLevel) {
|
||||
const levelGrid = levelGrids.get(pLevel.nLevelId)
|
||||
if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: InitAct1OutdoorLevel hook did not fire`)
|
||||
heads.set(pLevel, dumpLevelHead(pLevel, levelGrid))
|
||||
activations.set(pLevel, [])
|
||||
},
|
||||
onRoomGridsInitialized(pRoom, index) {
|
||||
const records = activations.get(pRoom.pLevel)
|
||||
if (!records) throw new Error(`level ${pRoom.pLevel.nLevelId}: room ${index} activated before its level was generated`)
|
||||
records.push({ pRoom, index, grids: dumpRoomGrids(pRoom), seedAfterTiles: null })
|
||||
},
|
||||
onRoomActivated(pRoom, index) {
|
||||
const records = activations.get(pRoom.pLevel)
|
||||
const record = records ? records[records.length - 1] : undefined
|
||||
if (!record || record.pRoom !== pRoom || record.index !== index) throw new Error(`level ${pRoom.pLevel.nLevelId} room ${index}: activation hooks out of order`)
|
||||
record.seedAfterTiles = dumpSeed(pRoom.pSeed)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
setLevelGridHook(previousHook)
|
||||
}
|
||||
if (!drlg || !act) throw new Error('runAct1 did not report the act allocation')
|
||||
const doc: DrlgDump = {
|
||||
schema: 3,
|
||||
gameSeed: nSeed >>> 0,
|
||||
difficulty: opts.difficulty ?? 0,
|
||||
isolated: opts.isolated ?? false,
|
||||
drlg,
|
||||
act,
|
||||
levels: [],
|
||||
}
|
||||
// mapTiles is serialised only now: the tile data are the final state (see DumpMapTiles).
|
||||
const refIndices = new Map<D2DrlgStrc, DumpRefIndex>()
|
||||
for (const pLevel of run.levels) {
|
||||
let index = refIndices.get(pLevel.pDrlg)
|
||||
if (!index) {
|
||||
index = buildRefIndex(pLevel.pDrlg)
|
||||
refIndices.set(pLevel.pDrlg, index)
|
||||
}
|
||||
const activation: DumpActivation[] = []
|
||||
for (const record of activations.get(pLevel)!) {
|
||||
if (!record.seedAfterTiles) throw new Error(`level ${pLevel.nLevelId} room ${record.index}: DRLGROOMTILE_AddRoomMapTiles did not complete`)
|
||||
activation.push({ ...record.grids, seedAfterTiles: record.seedAfterTiles, mapTiles: dumpMapTiles(index, record.pRoom, pLevel.nLevelId, record.index) })
|
||||
}
|
||||
doc.levels.push({ ...heads.get(pLevel)!, activation })
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,542 @@
|
|||
/**
|
||||
* D2Common DRLG logical rooms: 1:1 port of the parts of D2MOO source/D2Common/src/Drlg/DrlgDrlgLogic.cpp
|
||||
* (commit 5596f5c, MIT License, Copyright (c) 2020-2025 The Phrozen Keep community) reached by
|
||||
* DRLGROOMTILE_AddRoomMapTiles: DRLGOUTROOM_InitializeDrlgOutdoorRoom and DRLGPRESET_AddPresetRoomMapTiles
|
||||
* end with DRLGLOGIC_AllocCoordLists or (LvlPrest.txt `Logicals`) DRLGLOGIC_InitializeDrlgCoordList.
|
||||
*
|
||||
* Pointers in int cells: DRLGLOGIC_AssignCoordListsForGrids writes `(int)pRoomCoordList` into the pIndexY
|
||||
* cells and DRLGLOGIC_SetCoordListForTiles copies such a cell into D2DrlgTileDataStrc::unk0x10. Int32Array
|
||||
* cells cannot hold objects, so a pIndexY cell holds a 1-based handle into
|
||||
* `pLogicalRoomInfo.ppCoordListHandles` (0 = NULL), and the tile's unk0x10 receives the object itself.
|
||||
*/
|
||||
|
||||
import { DRLGGRID_AlterGridFlag, DRLGGRID_FillGrid, DRLGGRID_GetGridEntry, DRLGGRID_InitializeGridCells, DRLGGRID_IsGridValid, DRLGGRID_ResetGrid } from './drlg-grid.ts'
|
||||
import { DRLG_CheckNotOverlappingUsingManhattanDistance, DRLGROOM_AreXYInsideCoordinatesOrOnBorder } from './drlg-room.ts'
|
||||
import {
|
||||
GetMapTileLayer,
|
||||
HasMapTileLayer,
|
||||
MAPTILE_OBJECT_WALL,
|
||||
MAPTILE_TREES,
|
||||
PackedTile,
|
||||
TILETYPE_ROOF,
|
||||
tileAt,
|
||||
tileGridOf,
|
||||
} from './drlg-roomtile.ts'
|
||||
import {
|
||||
FLAG_OPERATION_OR,
|
||||
FLAG_OPERATION_OVERWRITE,
|
||||
newCoord,
|
||||
newGrid,
|
||||
type D2DrlgGridStrc,
|
||||
type D2DrlgLogicalRoomInfoStrc,
|
||||
type D2DrlgRoomStrc,
|
||||
type D2DrlgTileDataStrc,
|
||||
type D2RoomCoordListStrc,
|
||||
} from './drlg-types.ts'
|
||||
|
||||
// D2DrlgLogicalRoomInfoFlags (D2DrlgDrlgLogic.h)
|
||||
export const DRLGLOGIC_ROOMINFO_HAS_COORD_LIST = 0x1
|
||||
export const DRLGLOGIC_ROOMINFO_HAS_GRID_CELLS = 0x2
|
||||
|
||||
/** D2UnkDrlgLogicStrc (D2DrlgDrlgLogic.h) */
|
||||
interface D2UnkDrlgLogicStrc {
|
||||
pDrlgRoom: D2DrlgRoomStrc
|
||||
field_4: D2DrlgGridStrc
|
||||
pTileTypeGrid: D2DrlgGridStrc
|
||||
pWallGrid: D2DrlgGridStrc
|
||||
pFloorGrid: D2DrlgGridStrc
|
||||
field_14: D2DrlgGridStrc
|
||||
field_18: number
|
||||
nFlags: number
|
||||
}
|
||||
|
||||
/** A zeroed D2RoomCoordListStrc (D2_CALLOC_STRC_POOL / an element of a D2_CALLOC_POOL block). */
|
||||
function newRoomCoordList(): D2RoomCoordListStrc {
|
||||
return { pBox: [newCoord(0, 0, 0, 0), newCoord(0, 0, 0, 0)], bNode: 0, bRoomActive: 0, nIndex: 0, pNext: null }
|
||||
}
|
||||
|
||||
/** A zeroed D2DrlgLogicalRoomInfoStrc (D2_CALLOC_STRC_POOL). */
|
||||
function newLogicalRoomInfo(): D2DrlgLogicalRoomInfoStrc {
|
||||
return { dwFlags: 0, nLists: 0, pIndexX: newGrid(), pIndexY: newGrid(), pCoordList: null, ppCoordListHandles: [] }
|
||||
}
|
||||
|
||||
/** `(D2RoomCoordListStrc*)nCell` for a pIndexY cell of pLogicalRoomInfo. */
|
||||
function coordListFromCell(pLogicalRoomInfo: D2DrlgLogicalRoomInfoStrc, nCell: number): D2RoomCoordListStrc | null {
|
||||
if (nCell === 0) return null
|
||||
const pRoomCoordList = nCell > 0 ? pLogicalRoomInfo.ppCoordListHandles[nCell - 1] : undefined
|
||||
if (!pRoomCoordList) throw new Error(`pIndexY cell ${nCell} is not a D2RoomCoordListStrc* (wild pointer)`)
|
||||
return pRoomCoordList
|
||||
}
|
||||
|
||||
/** C would loop forever on a cyclic tile chain; the port refuses instead. */
|
||||
function chainStep(n: number): number {
|
||||
if (n > 1 << 22) throw new Error('DRLGLOGIC: tile chain does not terminate (C would loop forever)')
|
||||
return n + 1
|
||||
}
|
||||
|
||||
//D2Common.0x6FD764A0
|
||||
export function DRLGLOGIC_InitializeDrlgCoordList(
|
||||
pDrlgRoom: D2DrlgRoomStrc,
|
||||
pTileTypeGrid: D2DrlgGridStrc,
|
||||
pFloorGrid: D2DrlgGridStrc,
|
||||
pWallGrid: D2DrlgGridStrc,
|
||||
): void {
|
||||
const nCellPositions = new Int32Array(1024)
|
||||
const nCellFlags = new Int32Array(256)
|
||||
|
||||
const pDrlgCoordList = newLogicalRoomInfo()
|
||||
pDrlgRoom.pLogicalRoomInfo = pDrlgCoordList
|
||||
|
||||
let pDrlgRoomTiles = tileGridOf(pDrlgRoom).pTiles
|
||||
for (let i = 0; i < pDrlgRoomTiles.nWalls; i += 1) {
|
||||
const pWallTileData = tileAt(pDrlgRoomTiles.pWallTiles, i, 'DRLGLOGIC_InitializeDrlgCoordList')
|
||||
|
||||
if (pWallTileData.dwFlags & MAPTILE_TREES && !HasMapTileLayer(pWallTileData.dwFlags)) {
|
||||
DRLGGRID_AlterGridFlag(pWallGrid, pWallTileData.nPosX, pWallTileData.nPosY, 8, FLAG_OPERATION_OR)
|
||||
}
|
||||
}
|
||||
|
||||
pDrlgCoordList.dwFlags |= DRLGLOGIC_ROOMINFO_HAS_GRID_CELLS
|
||||
|
||||
DRLGGRID_InitializeGridCells(pDrlgCoordList.pIndexX, (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0, (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0)
|
||||
|
||||
if (!pDrlgRoom.pLevel.nCoordLists) {
|
||||
pDrlgRoom.pLevel.nCoordLists = 1
|
||||
}
|
||||
|
||||
const nLists = pDrlgRoom.pLevel.nCoordLists
|
||||
|
||||
const pDrlgGrid = newGrid()
|
||||
DRLGGRID_FillGrid(pDrlgGrid, (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0, (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0, nCellPositions, 0, nCellFlags)
|
||||
|
||||
pDrlgRoomTiles = tileGridOf(pDrlgRoom).pTiles
|
||||
for (let i = 0; i < pDrlgRoomTiles.nWalls; i += 1) {
|
||||
const pWallTileData = tileAt(pDrlgRoomTiles.pWallTiles, i, 'DRLGLOGIC_InitializeDrlgCoordList')
|
||||
if (
|
||||
GetMapTileLayer(pWallTileData.dwFlags) === 1 &&
|
||||
pWallTileData.nTileType !== TILETYPE_ROOF &&
|
||||
!(pWallTileData.dwFlags & MAPTILE_OBJECT_WALL)
|
||||
) {
|
||||
DRLGGRID_AlterGridFlag(pDrlgGrid, pWallTileData.nPosX, pWallTileData.nPosY, 1, FLAG_OPERATION_OR)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < pDrlgRoom.nRoomsNear; i += 1) {
|
||||
const pDrlgRoomNear = pDrlgRoom.ppRoomsNear[i]!
|
||||
if (pDrlgRoomNear !== pDrlgRoom && pDrlgRoomNear.pTileGrid) {
|
||||
let nLinks = 0
|
||||
for (let pDrlgTileLink = pDrlgRoomNear.pTileGrid.pMapLinks; pDrlgTileLink; pDrlgTileLink = pDrlgTileLink.pNext) {
|
||||
nLinks = chainStep(nLinks)
|
||||
if (!pDrlgTileLink.bFloor) {
|
||||
let nTiles = 0
|
||||
for (let pDrlgTileData: D2DrlgTileDataStrc | null = pDrlgTileLink.pMapTile; pDrlgTileData; pDrlgTileData = pDrlgTileData.unk0x20) {
|
||||
nTiles = chainStep(nTiles)
|
||||
if (DRLG_CheckLayer1ButNotWallObject(pDrlgTileData)) {
|
||||
const nX = (pDrlgRoomNear.pDrlgCoord.nPosX + pDrlgTileData.nPosX) | 0
|
||||
const nY = (pDrlgRoomNear.pDrlgCoord.nPosY + pDrlgTileData.nPosY) | 0
|
||||
if (DRLGROOM_AreXYInsideCoordinatesOrOnBorder(pDrlgRoom.pDrlgCoord, nX, nY)) {
|
||||
DRLGGRID_AlterGridFlag(
|
||||
pDrlgGrid,
|
||||
(nX - pDrlgRoom.pDrlgCoord.nPosX) | 0,
|
||||
(nY - pDrlgRoom.pDrlgCoord.nPosY) | 0,
|
||||
1,
|
||||
FLAG_OPERATION_OR,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tDRLGLogicUnkStrc: D2UnkDrlgLogicStrc = {
|
||||
pDrlgRoom,
|
||||
field_4: pDrlgCoordList.pIndexX,
|
||||
pTileTypeGrid,
|
||||
pWallGrid,
|
||||
pFloorGrid,
|
||||
field_14: pDrlgGrid,
|
||||
field_18: nLists,
|
||||
nFlags: 0,
|
||||
}
|
||||
|
||||
for (let j = 0; j <= pDrlgRoom.pDrlgCoord.nHeight; j += 1) {
|
||||
for (let i = 0; i <= pDrlgRoom.pDrlgCoord.nWidth; i += 1) {
|
||||
if (!(DRLGGRID_GetGridEntry(tDRLGLogicUnkStrc.field_4, i, j) & 0x10000000)) {
|
||||
tDRLGLogicUnkStrc.field_18 = (tDRLGLogicUnkStrc.field_18 + 1) | 0
|
||||
tDRLGLogicUnkStrc.nFlags = (tDRLGLogicUnkStrc.field_18 & 0xfffffff) | 0x10000000
|
||||
|
||||
const nFloorPackedInfo = DRLGGRID_GetGridEntry(tDRLGLogicUnkStrc.pFloorGrid, i, j) >>> 0
|
||||
if (
|
||||
(PackedTile.nTileStyle(nFloorPackedInfo) === 30 && PackedTile.nWallLayer(nFloorPackedInfo) === 0) ||
|
||||
PackedTile.bHidden(nFloorPackedInfo)
|
||||
) {
|
||||
tDRLGLogicUnkStrc.nFlags |= 0x20000000
|
||||
}
|
||||
|
||||
DRLGLOGIC_SetTileGridFlags(tDRLGLogicUnkStrc, i, j, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DRLGGRID_ResetGrid(pDrlgGrid)
|
||||
|
||||
pDrlgCoordList.nLists = (tDRLGLogicUnkStrc.field_18 - nLists + 1) | 0
|
||||
|
||||
// D2_CALLOC_POOL(sizeof(D2RoomCoordListStrc) * nLists): only element [0] is ever reachable (it ends the
|
||||
// chain built by DRLGLOGIC_AssignCoordListsForGrids), so only that element is modelled.
|
||||
if (pDrlgCoordList.nLists <= 0) throw new RangeError(`DRLGLOGIC_InitializeDrlgCoordList: nLists = ${pDrlgCoordList.nLists}`)
|
||||
pDrlgCoordList.pCoordList = newRoomCoordList()
|
||||
|
||||
pDrlgRoom.pLevel.nCoordLists = (pDrlgRoom.pLevel.nCoordLists + pDrlgCoordList.nLists) | 0
|
||||
|
||||
DRLGLOGIC_AssignCoordListsForGrids(pDrlgRoom, pDrlgCoordList, nLists)
|
||||
DRLGLOGIC_SetCoordListForTiles(pDrlgRoom)
|
||||
sub_6FD769B0(pDrlgRoom)
|
||||
}
|
||||
|
||||
const stru_6FDCE5C8: readonly { nX: number; nY: number }[] = [
|
||||
{ nX: 1, nY: 0 },
|
||||
{ nX: 0, nY: 1 },
|
||||
{ nX: -1, nY: 0 },
|
||||
{ nX: 0, nY: -1 },
|
||||
]
|
||||
|
||||
const dword_6FDCE5EC: readonly number[] = [
|
||||
23, 0, 5, 21, 17, 15, 3, 0, 9, 7, 39, 0, 0, 5, 3, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 0,
|
||||
]
|
||||
|
||||
const dword_6FDCE650: readonly number[] = [-1, 0, 1, 2, 2, 0, 1, 3, 0, 1, 0, 1, 4, -1, 4, 0, 0, 0, 0, 0]
|
||||
|
||||
//D2Common.0x6FD76830
|
||||
function DRLGLOGIC_SetTileGridFlags(a1: D2UnkDrlgLogicStrc, nX: number, nY: number, a4: number): void {
|
||||
let nPosX = nX
|
||||
let nPosY = nY
|
||||
|
||||
while (
|
||||
DRLGROOM_AreXYInsideCoordinatesOrOnBorder(
|
||||
a1.pDrlgRoom.pDrlgCoord,
|
||||
(nPosX + a1.pDrlgRoom.pDrlgCoord.nPosX) | 0,
|
||||
(nPosY + a1.pDrlgRoom.pDrlgCoord.nPosY) | 0,
|
||||
)
|
||||
) {
|
||||
if (DRLGGRID_GetGridEntry(a1.field_4, nPosX, nPosY) & 0x10000000) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!DRLGGRID_GetGridEntry(a1.field_14, nPosX, nPosY)) {
|
||||
DRLGGRID_AlterGridFlag(a1.field_4, nPosX, nPosY, a1.nFlags, FLAG_OPERATION_OR)
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
DRLGLOGIC_SetTileGridFlags(a1, (nPosX + stru_6FDCE5C8[i]!.nX) | 0, (nPosY + stru_6FDCE5C8[i]!.nY) | 0, i)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let nIndex = 0
|
||||
if (DRLGGRID_IsGridValid(a1.pTileTypeGrid)) {
|
||||
nIndex = DRLGGRID_GetGridEntry(a1.pTileTypeGrid, nPosX, nPosY)
|
||||
}
|
||||
|
||||
// dword_6FDCE650 has 20 entries and dword_6FDCE5EC 26: any other subscript reads unrelated memory (UB;
|
||||
// tile types 0 and 13 map to row -1, which lands before dword_6FDCE5EC).
|
||||
const nRow = nIndex >= 0 && nIndex < dword_6FDCE650.length ? dword_6FDCE650[nIndex]! : undefined
|
||||
const nFlagIndex = nRow === undefined ? -1 : (a4 + 5 * nRow + 1) | 0
|
||||
if (nRow === undefined || nFlagIndex < 0 || nFlagIndex >= dword_6FDCE5EC.length) {
|
||||
throw new RangeError(
|
||||
`DRLGLOGIC_SetTileGridFlags: out-of-bounds table read (tile type ${nIndex}, a4 ${a4}) at room tile (${nPosX}, ${nPosY})`,
|
||||
)
|
||||
}
|
||||
const nFlags = dword_6FDCE5EC[nFlagIndex]!
|
||||
if (nFlags & 1) {
|
||||
DRLGGRID_AlterGridFlag(a1.field_4, nPosX, nPosY, a1.nFlags, FLAG_OPERATION_OR)
|
||||
}
|
||||
|
||||
if (nFlags & 2 && a4 !== 2) {
|
||||
DRLGLOGIC_SetTileGridFlags(a1, (nPosX + 1) | 0, nPosY, 0)
|
||||
}
|
||||
|
||||
if (nFlags & 4 && a4 !== 3) {
|
||||
DRLGLOGIC_SetTileGridFlags(a1, nPosX, (nPosY + 1) | 0, 1)
|
||||
}
|
||||
|
||||
if (nFlags & 8 && a4 !== 0) {
|
||||
DRLGLOGIC_SetTileGridFlags(a1, (nPosX - 1) | 0, nPosY, 2)
|
||||
}
|
||||
|
||||
if (nFlags & 16 && a4 !== 1) {
|
||||
DRLGLOGIC_SetTileGridFlags(a1, nPosX, (nPosY - 1) | 0, 3)
|
||||
}
|
||||
|
||||
if (!(nFlags & 32)) {
|
||||
return
|
||||
}
|
||||
|
||||
nPosY = (nPosY + 1) | 0
|
||||
nPosX = (nPosX + 1) | 0
|
||||
|
||||
a4 = -1
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD769B0
|
||||
function sub_6FD769B0(pDrlgRoom: D2DrlgRoomStrc): void {
|
||||
for (let i = 0; i < pDrlgRoom.nRoomsNear; i += 1) {
|
||||
const pCurrentRoomEx = pDrlgRoom.ppRoomsNear[i]!
|
||||
if (pCurrentRoomEx !== pDrlgRoom) {
|
||||
if (pCurrentRoomEx.pLogicalRoomInfo) {
|
||||
if (!DRLG_CheckNotOverlappingUsingManhattanDistance(pDrlgRoom.pDrlgCoord, pCurrentRoomEx.pDrlgCoord, 1)) {
|
||||
for (let j = pDrlgRoom.pDrlgCoord.nPosX; j <= pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth; j += 1) {
|
||||
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, j, pDrlgRoom.pDrlgCoord.nPosY)
|
||||
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, j, (pDrlgRoom.pDrlgCoord.nHeight + pDrlgRoom.pDrlgCoord.nPosY) | 0)
|
||||
}
|
||||
|
||||
for (let j = pDrlgRoom.pDrlgCoord.nPosY; j <= pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight; j += 1) {
|
||||
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, pDrlgRoom.pDrlgCoord.nPosX, j)
|
||||
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD76A90
|
||||
function sub_6FD76A90(pDrlgRoom1: D2DrlgRoomStrc, pDrlgRoom2: D2DrlgRoomStrc, nX: number, nY: number): void {
|
||||
if (DRLGROOM_AreXYInsideCoordinatesOrOnBorder(pDrlgRoom2.pDrlgCoord, nX, nY)) {
|
||||
const pRoomCoordList1 = sub_6FD77110(pDrlgRoom1, Math.imul(nX, 5), Math.imul(nY, 5))
|
||||
const pRoomCoordList2 = sub_6FD77110(pDrlgRoom2, Math.imul(nX, 5), Math.imul(nY, 5))
|
||||
if (!pRoomCoordList1 || !pRoomCoordList2) throw new Error('sub_6FD76A90: NULL D2RoomCoordListStrc dereference')
|
||||
|
||||
const nIndex1 = pRoomCoordList1.nIndex
|
||||
const nIndex2 = pRoomCoordList2.nIndex
|
||||
|
||||
if (nIndex2 && nIndex1) {
|
||||
if (
|
||||
pDrlgRoom2.pLevel.nLevelId === pDrlgRoom1.pLevel.nLevelId &&
|
||||
nIndex2 !== nIndex1 &&
|
||||
pRoomCoordList2.bNode === pRoomCoordList1.bNode
|
||||
) {
|
||||
sub_6FD76B90(pDrlgRoom1, nIndex1, nIndex2, pRoomCoordList2.bNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD76B90
|
||||
function sub_6FD76B90(pDrlgRoom: D2DrlgRoomStrc, nIndex1: number, nIndex2: number, bNode: number): void {
|
||||
if (pDrlgRoom.pLogicalRoomInfo === null || pDrlgRoom.pLogicalRoomInfo.dwFlags & DRLGLOGIC_ROOMINFO_HAS_COORD_LIST) {
|
||||
return
|
||||
}
|
||||
|
||||
let pRoomCoordList = pDrlgRoom.pLogicalRoomInfo.pCoordList
|
||||
if (pRoomCoordList) {
|
||||
let bContinue = false
|
||||
let nSteps = 0
|
||||
do {
|
||||
nSteps = chainStep(nSteps)
|
||||
if (pRoomCoordList.nIndex === nIndex1) {
|
||||
bContinue = true
|
||||
pRoomCoordList.nIndex = nIndex2
|
||||
}
|
||||
pRoomCoordList = pRoomCoordList.pNext
|
||||
} while (pRoomCoordList)
|
||||
|
||||
if (bContinue) {
|
||||
for (let i = 0; i < pDrlgRoom.nRoomsNear; i += 1) {
|
||||
const pRoomNear = pDrlgRoom.ppRoomsNear[i]!
|
||||
if (pRoomNear !== pDrlgRoom && pRoomNear.pLevel.nLevelId === pDrlgRoom.pLevel.nLevelId) {
|
||||
sub_6FD76B90(pRoomNear, nIndex1, nIndex2, bNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD76C20
|
||||
export function DRLG_CheckLayer1ButNotWallObject(pTileData: D2DrlgTileDataStrc): boolean {
|
||||
if (GetMapTileLayer(pTileData.dwFlags) === 1) {
|
||||
if (pTileData.nTileType !== TILETYPE_ROOF) {
|
||||
return (pTileData.dwFlags & MAPTILE_OBJECT_WALL) === 0
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
//D2Common.0x6FD76C50
|
||||
function DRLGLOGIC_SetCoordListForTiles(pDrlgRoom: D2DrlgRoomStrc): void {
|
||||
const pTiles = tileGridOf(pDrlgRoom).pTiles
|
||||
if (pTiles.nWalls) {
|
||||
for (let i = 0; i < pTiles.nWalls; i += 1) {
|
||||
const pWallTileData = tileAt(pTiles.pWallTiles, i, 'DRLGLOGIC_SetCoordListForTiles')
|
||||
const pLogicalRoomInfo = pDrlgRoom.pLogicalRoomInfo
|
||||
if (pLogicalRoomInfo && !(pLogicalRoomInfo.dwFlags & DRLGLOGIC_ROOMINFO_HAS_COORD_LIST)) {
|
||||
pWallTileData.unk0x10 =
|
||||
coordListFromCell(
|
||||
pLogicalRoomInfo,
|
||||
DRLGGRID_GetGridEntry(pLogicalRoomInfo.pIndexY, pWallTileData.nPosX, pWallTileData.nPosY),
|
||||
) ?? 0
|
||||
} else {
|
||||
pWallTileData.unk0x10 = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD76CF0
|
||||
function DRLGLOGIC_AssignCoordListsForGrids(
|
||||
pDrlgRoom: D2DrlgRoomStrc,
|
||||
pDrlgCoordList: D2DrlgLogicalRoomInfoStrc,
|
||||
_nLists: number,
|
||||
): void {
|
||||
DRLGGRID_InitializeGridCells(pDrlgCoordList.pIndexY, (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0, (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0)
|
||||
const nWidth = (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0
|
||||
const nHeight = (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0
|
||||
|
||||
for (let nY = 0; nY < nHeight; nY += 1) {
|
||||
for (let nX = 0; nX < nWidth; nX += 1) {
|
||||
const nFlags = DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexX, nX, nY)
|
||||
const nIndex = nFlags & 0xfffffff
|
||||
|
||||
if (!DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexY, nX, nY)) {
|
||||
const pRoomCoordList = newRoomCoordList()
|
||||
const hRoomCoordList = pDrlgCoordList.ppCoordListHandles.push(pRoomCoordList)
|
||||
|
||||
pRoomCoordList.nIndex = nIndex
|
||||
pRoomCoordList.bNode = (nFlags & 0x20000000) === 0x20000000 ? 1 : 0
|
||||
pRoomCoordList.pNext = pDrlgCoordList.pCoordList
|
||||
|
||||
pDrlgCoordList.pCoordList = pRoomCoordList
|
||||
|
||||
const pBox0 = pRoomCoordList.pBox[0]
|
||||
const pBox1 = pRoomCoordList.pBox[1]
|
||||
pBox0.nPosX = nX
|
||||
pBox0.nPosY = nY
|
||||
let nTmpWidth: number
|
||||
for (nTmpWidth = nX; nTmpWidth < nWidth; nTmpWidth += 1) {
|
||||
if (nIndex !== (DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexX, nTmpWidth, nY) & 0xfffffff)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexY, nTmpWidth, nY)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
pBox0.nWidth = nTmpWidth
|
||||
|
||||
let bBreak = false
|
||||
let nTmpHeight: number
|
||||
for (nTmpHeight = nY; nTmpHeight < nHeight; nTmpHeight += 1) {
|
||||
for (let i = nX; i < pBox0.nWidth; i += 1) {
|
||||
if (
|
||||
nIndex !== (DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexX, i, nTmpHeight) & 0xfffffff) ||
|
||||
DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexY, i, nTmpHeight)
|
||||
) {
|
||||
bBreak = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (bBreak) {
|
||||
break
|
||||
}
|
||||
}
|
||||
pBox0.nHeight = nTmpHeight
|
||||
|
||||
for (let j = pBox0.nPosY; j < pBox0.nHeight; j += 1) {
|
||||
for (let i = pBox0.nPosX; i < pBox0.nWidth; i += 1) {
|
||||
DRLGGRID_AlterGridFlag(pDrlgCoordList.pIndexY, i, j, hRoomCoordList, FLAG_OPERATION_OVERWRITE)
|
||||
}
|
||||
}
|
||||
|
||||
pBox0.nPosY = (pBox0.nPosY + pDrlgRoom.pDrlgCoord.nPosY) | 0
|
||||
pBox0.nHeight = (pBox0.nHeight + pDrlgRoom.pDrlgCoord.nPosY) | 0
|
||||
pBox0.nPosX = (pBox0.nPosX + pDrlgRoom.pDrlgCoord.nPosX) | 0
|
||||
pBox0.nWidth = (pBox0.nWidth + pDrlgRoom.pDrlgCoord.nPosX) | 0
|
||||
|
||||
pBox1.nPosX = pBox0.nPosX
|
||||
pBox1.nPosY = pBox0.nPosY
|
||||
pBox1.nWidth = pBox0.nWidth
|
||||
pBox1.nHeight = pBox0.nHeight
|
||||
|
||||
nTmpWidth = (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0
|
||||
if (pBox1.nWidth >= nTmpWidth) {
|
||||
pBox1.nWidth = nTmpWidth
|
||||
}
|
||||
|
||||
nTmpHeight = (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0
|
||||
if (pBox1.nHeight >= nTmpHeight) {
|
||||
pBox1.nHeight = nTmpHeight
|
||||
}
|
||||
|
||||
if (pBox1.nPosX >= nTmpWidth || pBox1.nPosY >= nTmpHeight) {
|
||||
pBox1.nPosX = 0
|
||||
pBox1.nPosY = 0
|
||||
pBox1.nWidth = 0
|
||||
pBox1.nHeight = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD76F90
|
||||
export function DRLGLOGIC_AllocCoordLists(pDrlgRoom: D2DrlgRoomStrc): void {
|
||||
const pLogicalRoomInfo = newLogicalRoomInfo()
|
||||
pDrlgRoom.pLogicalRoomInfo = pLogicalRoomInfo
|
||||
|
||||
pLogicalRoomInfo.dwFlags |= DRLGLOGIC_ROOMINFO_HAS_COORD_LIST
|
||||
pDrlgRoom.pLevel.nCoordLists = 1
|
||||
pLogicalRoomInfo.nLists = 1
|
||||
|
||||
const pCoordList = newRoomCoordList()
|
||||
pLogicalRoomInfo.pCoordList = pCoordList
|
||||
|
||||
pCoordList.nIndex = pDrlgRoom.pLevel.nCoordLists
|
||||
pCoordList.pBox[0].nPosY = pDrlgRoom.pDrlgCoord.nPosY
|
||||
pCoordList.pBox[0].nHeight = (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0
|
||||
pCoordList.pBox[0].nPosX = pDrlgRoom.pDrlgCoord.nPosX
|
||||
pCoordList.pBox[0].nWidth = (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0
|
||||
pCoordList.pBox[1].nPosY = pDrlgRoom.pDrlgCoord.nPosY
|
||||
pCoordList.pBox[1].nHeight = (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0
|
||||
pCoordList.pBox[1].nPosX = pDrlgRoom.pDrlgCoord.nPosX
|
||||
pCoordList.pBox[1].nWidth = (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0
|
||||
}
|
||||
|
||||
//D2Common.0x6FD77080
|
||||
export function DRLGLOGIC_GetRoomCoordListIndex(pDrlgRoom: D2DrlgRoomStrc, nX: number, nY: number): number {
|
||||
const pRoomCoordList = sub_6FD77110(pDrlgRoom, nX, nY)
|
||||
|
||||
if (pRoomCoordList) {
|
||||
return pRoomCoordList.nIndex
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
//D2Common.0x6FD77110
|
||||
export function sub_6FD77110(pDrlgRoom: D2DrlgRoomStrc, nX: number, nY: number): D2RoomCoordListStrc | null {
|
||||
const pLogicalRoomInfo = pDrlgRoom.pLogicalRoomInfo
|
||||
if (!pLogicalRoomInfo) throw new Error('D2_ASSERT(pDrlgRoom->pLogicalRoomInfo) (sub_6FD77110)')
|
||||
|
||||
if (pLogicalRoomInfo.dwFlags & DRLGLOGIC_ROOMINFO_HAS_COORD_LIST) {
|
||||
return pLogicalRoomInfo.pCoordList
|
||||
} else {
|
||||
return coordListFromCell(
|
||||
pLogicalRoomInfo,
|
||||
DRLGGRID_GetGridEntry(
|
||||
pLogicalRoomInfo.pIndexY,
|
||||
((nX / 5) | 0) - pDrlgRoom.pDrlgCoord.nPosX,
|
||||
((nY / 5) | 0) - pDrlgRoom.pDrlgCoord.nPosY,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//D2Common.0x6FD77190
|
||||
export function DRLGLOGIC_GetRoomCoordList(pDrlgRoom: D2DrlgRoomStrc): D2RoomCoordListStrc | null {
|
||||
if (!pDrlgRoom.pLogicalRoomInfo) throw new Error('D2_ASSERT(pDrlgRoom->pLogicalRoomInfo) (DRLGLOGIC_GetRoomCoordList)')
|
||||
return pDrlgRoom.pLogicalRoomInfo.pCoordList
|
||||
}
|
||||
|
|
@ -0,0 +1,826 @@
|
|||
/**
|
||||
* Act I outdoor level maps from the DRLG port (M5 adapter).
|
||||
*
|
||||
* The 1:1 port of D2MOO's DRLG (src/game/drlg/drlg-*.ts) produces the level exactly as D2Common builds
|
||||
* it: rooms, their tile lists (DRLGROOMTILE_AddRoomMapTiles), preset units and warps. This module turns
|
||||
* one generated level into what the rest of the game consumes:
|
||||
* - a DS1-shaped canvas ({@link Ds1}) for the isometric renderer (`buildIsoMapScene`), one cell per
|
||||
* level tile, holding the exact (style, sequence, type) of every tile D2 placed;
|
||||
* - the DT1 libraries those tiles come from, with a per-cell library mask so the renderer only draws
|
||||
* variants from the library D2 picked the tile from;
|
||||
* - DS1-shaped objects (the same (type, id) encoding a preset DS1 uses, so the bake resolves them
|
||||
* through its existing object path);
|
||||
* - the level's entrances (seams with their real sides from the DRLG level links, and warps from the
|
||||
* UNIT_TILE preset units D2 creates for them) and SuperUnique landmarks.
|
||||
*
|
||||
* Nothing here decides anything about the layout: every value comes from the verified DRLG output
|
||||
* (the schema-3 dump, identical for the native oracle and the port), and anything unexpected throws.
|
||||
* The input is the normalized {@link DrlgLevelInput} so the same code maps the oracle's JSON and the
|
||||
* port's in-memory dump.
|
||||
*/
|
||||
|
||||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../../formats/ds1.ts'
|
||||
import type { Dt1 } from '../../formats/dt1.ts'
|
||||
import { dt1MaskAllows } from '../d2map.ts'
|
||||
import type { DrlgDump, DumpCoord, DumpLevel, DumpTile, DumpTileRef } from './drlg-dump.ts'
|
||||
import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts'
|
||||
import { DRLGPRESET_GetObjectIndexFromObjPreset } from './drlg-preset.ts'
|
||||
import { normalizeDrlgPath } from './drlg-source.ts'
|
||||
import { DATATBLS_GetLevelDefRecord, DATATBLS_GetLvlTypesTxtRecord, DATATBLS_GetMonPresetTxtActSection } from './drlg-tables.ts'
|
||||
import type { DrlgTables } from './drlg-tables.ts'
|
||||
import {
|
||||
ALTDIR_EAST, ALTDIR_NORTH, ALTDIR_SOUTH, ALTDIR_WEST, DRLGTYPE_OUTDOOR, GRID2_LVL_LINK,
|
||||
} from './drlg-types.ts'
|
||||
|
||||
/** Tiles per outdoor grid cell (the 8x8 blocks of D2DrlgOutdoorInfoStrc.pGrid). */
|
||||
const TILES_PER_GRID_CELL = 8
|
||||
/** Sub-tiles per tile (DUNGEON_GameTileToSubtileCoords). */
|
||||
const SUBTILES_PER_TILE = 5
|
||||
|
||||
// D2MapTileFlags (D2DrlgDrlg.h)
|
||||
const MAPTILE_HIDDEN = 0x8
|
||||
const MAPTILE_LAYER_SHIFT = 14
|
||||
const MAPTILE_LAYER_MASK = 0x7
|
||||
|
||||
// D2TileType (D2CMP.h)
|
||||
const TILETYPE_FLOOR = 0
|
||||
const TILETYPE_WALL_TOP_CORNER_RIGHT = 3
|
||||
const TILETYPE_WALL_TOP_CORNER_LEFT = 4
|
||||
const TILETYPE_SHADOW = 13
|
||||
|
||||
// D2C_UnitTypes
|
||||
const UNIT_MONSTER = 1
|
||||
const UNIT_OBJECT = 2
|
||||
const UNIT_TILE = 5
|
||||
|
||||
/** DS1 object `type` values (the encoding DRLGPRESET_ParseDS1File reads). */
|
||||
const DS1_TYPE_MONSTER = 1
|
||||
const DS1_TYPE_OBJECT = 2
|
||||
/** Object ids below this are ObjPreset slots; `objects.txt id + 150` otherwise (DRLGPRESET_ParseDS1File, v > 5). */
|
||||
const DS1_OBJPRESET_SLOTS = 150
|
||||
/**
|
||||
* Objects.txt class D2Game refuses to create from a preset unit (SUnit.cpp:1003, SUNIT_CreatePresetUnit:
|
||||
* `if (nClassId == 573) return`), so it never exists in game.
|
||||
*/
|
||||
const OBJECT_CLASS_INVALID_PRESET = 573
|
||||
|
||||
/**
|
||||
* MonPlace.txt rows D2Game turns into a fixed monster at the preset position instead of a random pack
|
||||
* (D2Game MonsterRegion.cpp:243-300, `switch (nMonPlace)`), mapped to the monster planner's landmark id.
|
||||
* Only the cases reachable from Act I outdoor presets are listed: 5 -> MONSTER_BLOODRAVEN.
|
||||
*/
|
||||
const MONPLACE_LANDMARKS: ReadonlyMap<number, string> = new Map([[5, 'Blood Raven']])
|
||||
|
||||
/** Axis-aligned rectangle in world tiles (D2DrlgCoordStrc). */
|
||||
export interface DrlgRect {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
/** Which array of the owner room's D2DrlgRoomTilesStrc a tile lives in. */
|
||||
export type DrlgTileKind = 'wall' | 'floor' | 'shadow'
|
||||
|
||||
/** One D2DrlgTileDataStrc, placed in level coordinates. */
|
||||
export interface DrlgTileInput {
|
||||
/** Identity of the tile data (owner level, room, array, index), so owned and linked copies dedupe. */
|
||||
readonly key: string
|
||||
readonly kind: DrlgTileKind
|
||||
/** Level-local tile position: owner room position + nPosX/nPosY - level position (0..W, 0..H). */
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
/** D2DrlgTileDataStrc.dwFlags (D2MapTileFlags). */
|
||||
readonly flags: number
|
||||
/** D2DrlgTileDataStrc.nTileType. */
|
||||
readonly tileType: number
|
||||
/** pTile: the DT1 tile D2 picked (normalized library path, tile index), or null when none matched. */
|
||||
readonly tile: { readonly library: string; readonly index: number } | null
|
||||
}
|
||||
|
||||
/** One D2PresetUnitStrc of a room, after DRLGROOMTILE_AddRoomMapTiles. */
|
||||
export interface DrlgUnitInput {
|
||||
/** Room index in the level's room chain. */
|
||||
readonly room: number
|
||||
/** D2C_UnitTypes. */
|
||||
readonly type: number
|
||||
/** nIndex as D2 stores it (after DRLGPRESET_ParseDS1File's MonPreset / ObjPreset conversion). */
|
||||
readonly index: number
|
||||
readonly mode: number
|
||||
/** Level-local sub-tile position (room-relative nXpos/nYpos + 5 * room offset). */
|
||||
readonly subX: number
|
||||
readonly subY: number
|
||||
readonly spawned: number
|
||||
}
|
||||
|
||||
/** One D2RoomTileStrc (a warp of a room, sub_6FD77F00). */
|
||||
export interface DrlgWarpInput {
|
||||
readonly room: number
|
||||
/** pLvlWarpTxtRecord->dwLevelId: the LvlWarp.txt `Id`, the class id of the warp's UNIT_TILE unit. */
|
||||
readonly lvlWarpId: number
|
||||
/** pDrlgRoom->pLevel->nLevelId of the destination room. */
|
||||
readonly toLevelId: number
|
||||
}
|
||||
|
||||
/** One level-to-level D2DrlgOrthStrc of pOutdoors->pRoomData. */
|
||||
export interface DrlgOrthInput {
|
||||
readonly level: number
|
||||
/** ALTDIR_*: where the neighbour lies (0 west, 1 north, 2 east, 3 south). */
|
||||
readonly dir: number
|
||||
readonly box: DrlgRect
|
||||
}
|
||||
|
||||
/** Everything the adapter reads about one generated level. */
|
||||
export interface DrlgLevelInput {
|
||||
readonly levelId: number
|
||||
readonly drlgType: number
|
||||
readonly levelType: number
|
||||
readonly coord: DrlgRect
|
||||
/** pOutdoors->dwFlags (D2DrlgOutdoorInfoStrc: link directions, river bank, ...). */
|
||||
readonly flags: number
|
||||
readonly orths: readonly DrlgOrthInput[]
|
||||
/** pOutdoors->pGrid[2] (packed grid-2 info) as dumped at the levelGrid stage. */
|
||||
readonly grid2: { readonly w: number; readonly h: number; readonly cells: readonly number[] }
|
||||
readonly tiles: readonly DrlgTileInput[]
|
||||
readonly units: readonly DrlgUnitInput[]
|
||||
readonly warps: readonly DrlgWarpInput[]
|
||||
}
|
||||
|
||||
/** Tables the mapping needs besides the DRLG output. */
|
||||
export interface DrlgMapContext {
|
||||
readonly tables: DrlgTables
|
||||
/** Parsed DT1 libraries keyed by {@link normalizeDrlgPath}. */
|
||||
readonly dt1: ReadonlyMap<string, Dt1>
|
||||
/** SuperUniques.txt `Superunique` column, by row. */
|
||||
readonly superUniqueIds: readonly string[]
|
||||
}
|
||||
|
||||
/** Which edge of the level an entrance sits on (world-graph `Side`). */
|
||||
export type DrlgSide = 'north' | 'east' | 'south' | 'west'
|
||||
|
||||
/**
|
||||
* A way into or out of the level: the fields of a `WildernessEntrance` (what the bake's
|
||||
* buildSceneLinks reads) except the display label, which the bake adds, plus `span` for seams.
|
||||
*/
|
||||
export interface DrlgEntrance {
|
||||
/** Anchor cell (tiles, level-local). */
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
/** One step inside from the anchor. */
|
||||
readonly interiorX: number
|
||||
readonly interiorY: number
|
||||
readonly toLevelId: number
|
||||
readonly side: DrlgSide | null
|
||||
readonly kind: 'gate' | 'preset'
|
||||
/**
|
||||
* Seams only: the stretch of this level's edge (tiles along the side, `from` inclusive, `to`
|
||||
* exclusive) where the opening is. The bake searches the walkable gap inside it.
|
||||
*/
|
||||
readonly span?: { readonly from: number; readonly to: number }
|
||||
/** Warps only: the UNIT_TILE position in sub-tiles (level-local) and its LvlWarp.txt Id. */
|
||||
readonly warp?: { readonly subX: number; readonly subY: number; readonly lvlWarpId: number }
|
||||
}
|
||||
|
||||
/** A fixed monster the planner anchors to its preset position. */
|
||||
export interface DrlgLandmark {
|
||||
/** The monster planner's id (SuperUniques.txt `Superunique`, or 'Blood Raven'). */
|
||||
readonly id: string
|
||||
readonly tileX: number
|
||||
readonly tileY: number
|
||||
readonly subX: number
|
||||
readonly subY: number
|
||||
}
|
||||
|
||||
/** One tile reference written into the canvas, with the library D2 took it from. */
|
||||
export interface DrlgCellRef {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly kind: DrlgTileKind
|
||||
readonly style: number
|
||||
readonly sequence: number
|
||||
readonly type: number
|
||||
/** Index into {@link DrlgLevelMap.dt1Names}. */
|
||||
readonly library: number
|
||||
readonly hidden: boolean
|
||||
}
|
||||
|
||||
export interface DrlgLevelMapStats {
|
||||
readonly walls: number
|
||||
readonly floors: number
|
||||
readonly shadows: number
|
||||
/** Type-4 halves skipped because the renderer draws them with their type-3 tile. */
|
||||
readonly cornerCompanions: number
|
||||
/** Tiles whose pTile is null (D2CMP found no tile): D2 draws nothing for them. */
|
||||
readonly withoutTile: number
|
||||
/** Linked tiles of other rooms drawn inside this level's canvas. */
|
||||
readonly linkedInCanvas: number
|
||||
/** Linked tiles outside the canvas (belong to the neighbour's canvas). */
|
||||
readonly linkedOutside: number
|
||||
readonly objects: number
|
||||
readonly monsters: number
|
||||
readonly skippedInvalidObjects: number
|
||||
}
|
||||
|
||||
/** One mapped level. */
|
||||
export interface DrlgLevelMap {
|
||||
readonly levelId: number
|
||||
readonly coord: DrlgRect
|
||||
readonly flags: number
|
||||
/** The canvas: (width + 1) x (height + 1) cells, like a DS1 of the level's size. */
|
||||
readonly ds1: Ds1
|
||||
/** DT1 libraries (normalized archive paths) in renderer library order; per-cell `dt1Mask` bits index it. */
|
||||
readonly dt1Names: readonly string[]
|
||||
readonly entrances: readonly DrlgEntrance[]
|
||||
readonly landmarks: readonly DrlgLandmark[]
|
||||
/** Every tile reference written into the canvas (for the DT1-mask ambiguity audit). */
|
||||
readonly refs: readonly DrlgCellRef[]
|
||||
readonly stats: DrlgLevelMapStats
|
||||
}
|
||||
|
||||
/**
|
||||
* DT1 libraries DRLGROOMTILE_LoadDT1FilesForRoom loads for every room regardless of its mask
|
||||
* (DrlgRoomTile.cpp:1249-1256): they come first so a cell mask can always name them.
|
||||
*/
|
||||
export const DRLG_UNIVERSAL_DT1: readonly string[] = [
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Outdoors\\Blank.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\InvisWal.dt1',
|
||||
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1',
|
||||
].map(normalizeDrlgPath)
|
||||
|
||||
/**
|
||||
* The Act I levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending.
|
||||
*
|
||||
* @param tables - the DRLG tables.
|
||||
* @returns level ids.
|
||||
*/
|
||||
export function act1OutdoorLevelIds(tables: DrlgTables): number[] {
|
||||
const out: number[] = []
|
||||
for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) {
|
||||
if (DRLG_GetActNoFromLevelId(id) !== 0) break
|
||||
if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SIDE_OF_ALTDIR: Readonly<Record<number, DrlgSide>> = {
|
||||
[ALTDIR_WEST]: 'west',
|
||||
[ALTDIR_NORTH]: 'north',
|
||||
[ALTDIR_EAST]: 'east',
|
||||
[ALTDIR_SOUTH]: 'south',
|
||||
}
|
||||
|
||||
function sideOfAltDir(dir: number, what: string): DrlgSide {
|
||||
const side = SIDE_OF_ALTDIR[dir]
|
||||
if (side === undefined) throw new Error(`${what}: direction ${dir} is not an ALTDIR`)
|
||||
return side
|
||||
}
|
||||
|
||||
function emptyWall(): Ds1Wall {
|
||||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
function emptyFloor(): Ds1Floor {
|
||||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||||
}
|
||||
|
||||
/** Mutable per-cell collector. */
|
||||
interface CellBuild {
|
||||
walls: { layer: number; order: number; wall: Ds1Wall }[]
|
||||
floors: { layer: number; order: number; floor: Ds1Floor }[]
|
||||
shadows: Ds1Floor[]
|
||||
mask: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The DT1 library order for a level: the universal libraries, then the libraries of the level's
|
||||
* LvlTypes row in `File 1..32` order. Only libraries some tile uses are listed (plus the universal
|
||||
* three, which DRLGROOMTILE_LoadDT1FilesForRoom always loads).
|
||||
*/
|
||||
function libraryOrder(tables: DrlgTables, levelType: number, used: ReadonlySet<string>, levelId: number): string[] {
|
||||
const order: string[] = [...DRLG_UNIVERSAL_DT1]
|
||||
const listed = new Set(order)
|
||||
for (const f of DATATBLS_GetLvlTypesTxtRecord(tables, levelType).szFile) {
|
||||
if (f.length <= 1) continue
|
||||
const key = normalizeDrlgPath(f)
|
||||
if (listed.has(key) || !used.has(key)) continue
|
||||
order.push(key)
|
||||
listed.add(key)
|
||||
}
|
||||
for (const lib of used) {
|
||||
if (!listed.has(lib)) {
|
||||
throw new Error(`level ${levelId}: tile library ${lib} is neither universal nor in LvlTypes row ${levelType}`)
|
||||
}
|
||||
}
|
||||
if (order.length > 32) throw new Error(`level ${levelId}: ${order.length} DT1 libraries do not fit a 32-bit cell mask`)
|
||||
return order
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one generated Act I outdoor level.
|
||||
*
|
||||
* @param input - the level, from the DRLG dump.
|
||||
* @param ctx - tables, DT1 libraries and names.
|
||||
* @returns the mapped level.
|
||||
*/
|
||||
export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): DrlgLevelMap {
|
||||
const { levelId, coord } = input
|
||||
if (input.drlgType !== DRLGTYPE_OUTDOOR) throw new Error(`level ${levelId}: not an outdoor level (DrlgType ${input.drlgType})`)
|
||||
const nAct = DRLG_GetActNoFromLevelId(levelId)
|
||||
const cellsX = coord.width + 1
|
||||
const cellsY = coord.height + 1
|
||||
|
||||
// ---- tiles -> canvas ----
|
||||
const cells: CellBuild[] = []
|
||||
for (let i = 0; i < cellsX * cellsY; i += 1) cells.push({ walls: [], floors: [], shadows: [], mask: 0 })
|
||||
const seen = new Set<string>()
|
||||
const used = new Set<string>()
|
||||
const placed: { t: DrlgTileInput; style: number; sequence: number; type: number; hidden: boolean }[] = []
|
||||
let cornerCompanions = 0
|
||||
let withoutTile = 0
|
||||
let linkedInCanvas = 0
|
||||
let linkedOutside = 0
|
||||
const topCornerRights = new Set<string>()
|
||||
const topCornerLefts: { t: DrlgTileInput; style: number; sequence: number }[] = []
|
||||
|
||||
for (const t of input.tiles) {
|
||||
if (seen.has(t.key)) continue
|
||||
seen.add(t.key)
|
||||
const linked = !t.key.startsWith(`${levelId}/`)
|
||||
if (t.x < 0 || t.y < 0 || t.x >= cellsX || t.y >= cellsY) {
|
||||
if (!linked) throw new Error(`level ${levelId}: owned tile ${t.key} at (${t.x}, ${t.y}) is outside the ${cellsX}x${cellsY} canvas`)
|
||||
linkedOutside += 1
|
||||
continue
|
||||
}
|
||||
if (linked) linkedInCanvas += 1
|
||||
if (t.tile === null) {
|
||||
withoutTile += 1
|
||||
continue
|
||||
}
|
||||
const lib = ctx.dt1.get(t.tile.library)
|
||||
if (lib === undefined) throw new Error(`level ${levelId}: tile ${t.key} uses DT1 ${t.tile.library}, which is not loaded`)
|
||||
const header = lib.tiles[t.tile.index]
|
||||
if (header === undefined) throw new Error(`level ${levelId}: tile ${t.key}: ${t.tile.library} has no tile #${t.tile.index}`)
|
||||
if (header.type !== t.tileType) {
|
||||
throw new Error(`level ${levelId}: tile ${t.key}: nTileType ${t.tileType} but DT1 tile #${t.tile.index} of ${t.tile.library} is type ${header.type}`)
|
||||
}
|
||||
const kindOk = t.kind === 'floor' ? header.type === TILETYPE_FLOOR
|
||||
: t.kind === 'shadow' ? header.type === TILETYPE_SHADOW
|
||||
: header.type !== TILETYPE_FLOOR && header.type !== TILETYPE_SHADOW
|
||||
if (!kindOk) throw new Error(`level ${levelId}: tile ${t.key} is a ${t.kind} of DT1 type ${header.type}`)
|
||||
if (header.type === TILETYPE_WALL_TOP_CORNER_LEFT) {
|
||||
// D2 stores the left half of a top corner as its own tile data next to the type-3 tile
|
||||
// (DRLGROOMTILE_InitWallTileData); buildIsoMapScene draws that half itself for every type-3 wall.
|
||||
topCornerLefts.push({ t, style: header.style, sequence: header.sequence })
|
||||
continue
|
||||
}
|
||||
if (header.type === TILETYPE_WALL_TOP_CORNER_RIGHT) topCornerRights.add(`${t.x},${t.y},${header.style},${header.sequence}`)
|
||||
used.add(t.tile.library)
|
||||
placed.push({ t, style: header.style, sequence: header.sequence, type: header.type, hidden: (t.flags & MAPTILE_HIDDEN) !== 0 })
|
||||
}
|
||||
for (const c of topCornerLefts) {
|
||||
if (!topCornerRights.has(`${c.t.x},${c.t.y},${c.style},${c.sequence}`)) {
|
||||
throw new Error(`level ${levelId}: top-corner-left tile ${c.t.key} at (${c.t.x}, ${c.t.y}) has no top-corner-right partner`)
|
||||
}
|
||||
cornerCompanions += 1
|
||||
}
|
||||
|
||||
const dt1Names = libraryOrder(ctx.tables, input.levelType, used, levelId)
|
||||
const libIndex = new Map(dt1Names.map((name, i) => [name, i]))
|
||||
const refs: DrlgCellRef[] = []
|
||||
let order = 0
|
||||
let walls = 0
|
||||
let floors = 0
|
||||
let shadows = 0
|
||||
for (const p of placed) {
|
||||
const cell = cells[p.t.y * cellsX + p.t.x]!
|
||||
const library = libIndex.get(p.t.tile!.library)!
|
||||
cell.mask |= 1 << library
|
||||
const layer = (p.t.flags >>> MAPTILE_LAYER_SHIFT) & MAPTILE_LAYER_MASK
|
||||
// prop1 only marks the slot as used for the renderer; hidden tiles (warp lit tiles, etc.) are kept
|
||||
// hidden exactly as D2 adds them.
|
||||
if (p.t.kind === 'floor') {
|
||||
cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden } })
|
||||
floors += 1
|
||||
} else if (p.t.kind === 'shadow') {
|
||||
cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden })
|
||||
shadows += 1
|
||||
} else {
|
||||
cell.walls.push({ layer, order: order++, wall: { prop1: 1, sequence: p.sequence, style: p.style, type: p.type, unknown1: 0, unknown2: 0, hidden: p.hidden } })
|
||||
walls += 1
|
||||
}
|
||||
refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, hidden: p.hidden })
|
||||
}
|
||||
|
||||
let wallLayers = 1
|
||||
let floorLayers = 1
|
||||
let shadowLayers = 1
|
||||
for (const c of cells) {
|
||||
wallLayers = Math.max(wallLayers, c.walls.length)
|
||||
floorLayers = Math.max(floorLayers, c.floors.length)
|
||||
shadowLayers = Math.max(shadowLayers, c.shadows.length)
|
||||
}
|
||||
const byLayer = <T extends { layer: number; order: number }>(a: T, b: T): number => a.layer - b.layer || a.order - b.order
|
||||
const rows: Ds1Cell[][] = []
|
||||
for (let y = 0; y < cellsY; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < cellsX; x += 1) {
|
||||
const c = cells[y * cellsX + x]!
|
||||
const cw = c.walls.sort(byLayer).map(w => w.wall)
|
||||
const cf = c.floors.sort(byLayer).map(f => f.floor)
|
||||
const cs = [...c.shadows]
|
||||
while (cw.length < wallLayers) cw.push(emptyWall())
|
||||
while (cf.length < floorLayers) cf.push(emptyFloor())
|
||||
while (cs.length < shadowLayers) cs.push(emptyFloor())
|
||||
row.push({ walls: cw, floors: cf, shadows: cs, substitutions: [], ...(c.mask !== 0 ? { dt1Mask: c.mask >>> 0 } : {}) })
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
// ---- units -> DS1 objects, landmarks, warp entrances ----
|
||||
const objSlotOf = new Map<number, number>()
|
||||
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
|
||||
objSlotOf.set(DRLGPRESET_GetObjectIndexFromObjPreset(nAct, slot), slot) // lowest slot wins
|
||||
}
|
||||
const monSection = DATATBLS_GetMonPresetTxtActSection(ctx.tables, nAct)
|
||||
if (monSection === null) throw new Error(`level ${levelId}: MonPreset has no act ${nAct + 1} section`)
|
||||
const nMonStats = ctx.tables.nMonStatsTxtRecordCount
|
||||
const nSU = ctx.tables.nSuperUniquesTxtRecordCount
|
||||
const monIdOf = new Map<number, number>()
|
||||
for (let i = monSection.count - 1; i >= 0; i -= 1) {
|
||||
const r = ctx.tables.monPreset[monSection.start + i]!
|
||||
let unit: number
|
||||
switch (r.nType) {
|
||||
case 0: unit = r.wPlace + nSU + nMonStats; break
|
||||
case 1: unit = r.wPlace; break
|
||||
case 2: unit = r.wPlace + nMonStats; break
|
||||
default: continue // DRLGPRESET_ParseDS1File maps it to -1, which is never allocated
|
||||
}
|
||||
monIdOf.set(unit, i) // lowest DS1 id wins (duplicates carry the same Place)
|
||||
}
|
||||
|
||||
const objects: Ds1Object[] = []
|
||||
const landmarks: DrlgLandmark[] = []
|
||||
const entrances: DrlgEntrance[] = []
|
||||
let nObjects = 0
|
||||
let nMonsters = 0
|
||||
let skippedInvalidObjects = 0
|
||||
for (const u of input.units) {
|
||||
switch (u.type) {
|
||||
case UNIT_OBJECT: {
|
||||
if (u.index === OBJECT_CLASS_INVALID_PRESET) { skippedInvalidObjects += 1; break }
|
||||
if (u.index < 0) throw new Error(`level ${levelId}: object unit with class ${u.index}`)
|
||||
const slot = objSlotOf.get(u.index)
|
||||
objects.push({ type: DS1_TYPE_OBJECT, id: slot ?? u.index + DS1_OBJPRESET_SLOTS, x: u.subX, y: u.subY, flags: u.spawned })
|
||||
nObjects += 1
|
||||
break
|
||||
}
|
||||
case UNIT_MONSTER: {
|
||||
const id = monIdOf.get(u.index)
|
||||
if (id === undefined) throw new Error(`level ${levelId}: monster unit ${u.index} has no act ${nAct + 1} MonPreset row`)
|
||||
objects.push({ type: DS1_TYPE_MONSTER, id, x: u.subX, y: u.subY, flags: u.spawned })
|
||||
nMonsters += 1
|
||||
const tileX = Math.floor(u.subX / SUBTILES_PER_TILE)
|
||||
const tileY = Math.floor(u.subY / SUBTILES_PER_TILE)
|
||||
if (u.index >= nMonStats && u.index < nMonStats + nSU) {
|
||||
const su = ctx.superUniqueIds[u.index - nMonStats]
|
||||
if (su === undefined || su === '') throw new Error(`level ${levelId}: SuperUnique row ${u.index - nMonStats} has no id`)
|
||||
landmarks.push({ id: su, tileX, tileY, subX: u.subX, subY: u.subY })
|
||||
} else if (u.index >= nMonStats + nSU) {
|
||||
const special = MONPLACE_LANDMARKS.get(u.index - nMonStats - nSU)
|
||||
if (special !== undefined) landmarks.push({ id: special, tileX, tileY, subX: u.subX, subY: u.subY })
|
||||
}
|
||||
break
|
||||
}
|
||||
case UNIT_TILE: {
|
||||
const matches = input.warps.filter(w => w.room === u.room && w.lvlWarpId === u.index)
|
||||
const to = [...new Set(matches.map(w => w.toLevelId))]
|
||||
if (to.length !== 1) {
|
||||
throw new Error(`level ${levelId} room ${u.room}: UNIT_TILE ${u.index} matches ${to.length} warp destinations (${to.join(', ')})`)
|
||||
}
|
||||
const cx = Math.floor(u.subX / SUBTILES_PER_TILE)
|
||||
const cy = Math.floor(u.subY / SUBTILES_PER_TILE)
|
||||
entrances.push({
|
||||
x: cx,
|
||||
y: cy,
|
||||
interiorX: cx,
|
||||
interiorY: cy,
|
||||
toLevelId: to[0]!,
|
||||
side: null,
|
||||
kind: 'preset',
|
||||
warp: { subX: u.subX, subY: u.subY, lvlWarpId: u.index },
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`level ${levelId} room ${u.room}: unexpected preset unit type ${u.type} (index ${u.index})`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seams ----
|
||||
entrances.push(...seamEntrances(input))
|
||||
|
||||
const ds1: Ds1 = {
|
||||
version: 18,
|
||||
width: cellsX,
|
||||
height: cellsY,
|
||||
act: nAct + 1,
|
||||
substitutionType: 0,
|
||||
wallLayers,
|
||||
floorLayers,
|
||||
cells: rows,
|
||||
objects,
|
||||
npcPathOffset: null,
|
||||
}
|
||||
return {
|
||||
levelId,
|
||||
coord,
|
||||
flags: input.flags,
|
||||
ds1,
|
||||
dt1Names,
|
||||
entrances,
|
||||
landmarks,
|
||||
refs,
|
||||
stats: {
|
||||
walls, floors, shadows, cornerCompanions, withoutTile, linkedInCanvas, linkedOutside,
|
||||
objects: nObjects, monsters: nMonsters, skippedInvalidObjects,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** How far the renderer's tile choice can stray from D2's for one mapped level. */
|
||||
export interface DrlgDt1Ambiguity {
|
||||
/** DT1 libraries of the level ({@link DrlgLevelMap.dt1Names}). */
|
||||
readonly libraries: number
|
||||
/** Tile references written into the canvas. */
|
||||
readonly refs: number
|
||||
/**
|
||||
* References whose exact (style, sequence, type) group under their cell's DT1 mask holds more than
|
||||
* one tile. The renderer draws the variant with its own per-cell hash (scene fidelity 'layout'); D2
|
||||
* rolled it with the room seed (DRLGROOMTILE_GetTileCache), and the port records that roll.
|
||||
*/
|
||||
readonly variantGroups: number
|
||||
/** References whose group under the cell mask spans more than one library: the renderer may draw a tile of a library D2 did not take it from. */
|
||||
readonly crossLibrary: number
|
||||
/** The same count with one pool for the whole level (no cell masks): what the cell masks prevent. */
|
||||
readonly crossLibraryLevelWide: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The DT1-mask ambiguity audit of a mapped level: resolves every tile reference the way
|
||||
* `buildIsoMapScene` does (exact style:sequence:type key, {@link dt1MaskAllows}) and counts the
|
||||
* references whose candidates are not pinned down to D2's library.
|
||||
*
|
||||
* @param map - the mapped level.
|
||||
* @param libraries - the parsed libraries of `map.dt1Names`, in that order.
|
||||
* @returns the counts.
|
||||
*/
|
||||
export function auditDrlgDt1Ambiguity(map: DrlgLevelMap, libraries: readonly Dt1[]): DrlgDt1Ambiguity {
|
||||
if (libraries.length !== map.dt1Names.length) {
|
||||
throw new Error(`level ${map.levelId}: ${libraries.length} libraries for ${map.dt1Names.length} DT1 names`)
|
||||
}
|
||||
// style:sequence:type -> tiles per library
|
||||
const groups = new Map<string, number[]>()
|
||||
libraries.forEach((library, at) => {
|
||||
for (const tile of library.tiles) {
|
||||
const key = `${tile.style}:${tile.sequence}:${tile.type}`
|
||||
let counts = groups.get(key)
|
||||
if (counts === undefined) {
|
||||
counts = new Array<number>(libraries.length).fill(0)
|
||||
groups.set(key, counts)
|
||||
}
|
||||
counts[at]! += 1
|
||||
}
|
||||
})
|
||||
let variantGroups = 0
|
||||
let crossLibrary = 0
|
||||
let crossLibraryLevelWide = 0
|
||||
for (const ref of map.refs) {
|
||||
const counts = groups.get(`${ref.style}:${ref.sequence}:${ref.type}`)
|
||||
if (counts === undefined || counts[ref.library] === 0) {
|
||||
throw new Error(`level ${map.levelId}: ${ref.kind} ${ref.style}:${ref.sequence}:${ref.type} at (${ref.x}, ${ref.y}) is not in its library ${map.dt1Names[ref.library]}`)
|
||||
}
|
||||
const mask = map.ds1.cells[ref.y]?.[ref.x]?.dt1Mask
|
||||
if (mask === undefined) throw new Error(`level ${map.levelId}: cell (${ref.x}, ${ref.y}) holds a tile but has no DT1 mask`)
|
||||
let tilesInCell = 0
|
||||
let librariesInCell = 0
|
||||
let librariesInLevel = 0
|
||||
counts.forEach((n, library) => {
|
||||
if (n === 0) return
|
||||
librariesInLevel += 1
|
||||
if (!dt1MaskAllows(mask, library)) return
|
||||
librariesInCell += 1
|
||||
tilesInCell += n
|
||||
})
|
||||
if (tilesInCell > 1) variantGroups += 1
|
||||
if (librariesInCell > 1) crossLibrary += 1
|
||||
if (librariesInLevel > 1) crossLibraryLevelWide += 1
|
||||
}
|
||||
return { libraries: libraries.length, refs: map.refs.length, variantGroups, crossLibrary, crossLibraryLevelWide }
|
||||
}
|
||||
|
||||
/**
|
||||
* The level's seams to its outdoor/preset neighbours, one per level link (pOutdoors->pRoomData).
|
||||
*
|
||||
* Outdoor-outdoor links are the grid-2 cells DRLGOUTPLACE_PlaceAct1245OutdoorBorders flags
|
||||
* GRID2_LVL_LINK: the border cell in the middle of each linked vertex edge, which gets the open border
|
||||
* preset (picked file 3, or 4 on the Burial Grounds). Links whose vertex edge carries flag 2 (the town
|
||||
* transition, the Monastery Gate approach) get no border cell: their opening is the neighbour-specific
|
||||
* preset, somewhere along the shared edge, so the whole shared stretch is the span.
|
||||
*/
|
||||
function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] {
|
||||
const { levelId, coord, grid2 } = input
|
||||
const out: DrlgEntrance[] = []
|
||||
if (grid2.w * TILES_PER_GRID_CELL !== coord.width || grid2.h * TILES_PER_GRID_CELL !== coord.height) {
|
||||
throw new Error(`level ${levelId}: grid-2 ${grid2.w}x${grid2.h} does not cover ${coord.width}x${coord.height} tiles`)
|
||||
}
|
||||
const linkCellsOf = new Map<number, { gx: number; gy: number }[]>()
|
||||
for (let gy = 0; gy < grid2.h; gy += 1) {
|
||||
for (let gx = 0; gx < grid2.w; gx += 1) {
|
||||
if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue
|
||||
const onW = gx === 0
|
||||
const onE = gx === grid2.w - 1
|
||||
const onN = gy === 0
|
||||
const onS = gy === grid2.h - 1
|
||||
if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) {
|
||||
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`)
|
||||
}
|
||||
const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH
|
||||
// World tiles covered by the cell along its side.
|
||||
const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH
|
||||
const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL)
|
||||
const to = from + TILES_PER_GRID_CELL
|
||||
const owners = input.orths.filter(o => {
|
||||
if (o.dir !== dir) return false
|
||||
const lo = horizontal ? o.box.x : o.box.y
|
||||
const hi = lo + (horizontal ? o.box.width : o.box.height)
|
||||
return from >= lo && to <= hi
|
||||
})
|
||||
if (owners.length !== 1) {
|
||||
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`)
|
||||
}
|
||||
const list = linkCellsOf.get(owners[0]!.level) ?? []
|
||||
list.push({ gx, gy })
|
||||
linkCellsOf.set(owners[0]!.level, list)
|
||||
}
|
||||
}
|
||||
|
||||
for (const o of input.orths) {
|
||||
const side = sideOfAltDir(o.dir, `level ${levelId} link to ${o.level}`)
|
||||
const horizontal = side === 'north' || side === 'south'
|
||||
const cellsOnLink = linkCellsOf.get(o.level) ?? []
|
||||
let from: number
|
||||
let to: number
|
||||
if (cellsOnLink.length > 1) {
|
||||
throw new Error(`level ${levelId}: ${cellsOnLink.length} level-link cells for the link to ${o.level}`)
|
||||
} else if (cellsOnLink.length === 1) {
|
||||
const c = cellsOnLink[0]!
|
||||
from = (horizontal ? c.gx : c.gy) * TILES_PER_GRID_CELL
|
||||
to = from + TILES_PER_GRID_CELL
|
||||
} else {
|
||||
// Shared edge of the two rectangles, in level-local tiles along the side.
|
||||
const lo = horizontal ? Math.max(coord.x, o.box.x) : Math.max(coord.y, o.box.y)
|
||||
const hi = horizontal
|
||||
? Math.min(coord.x + coord.width, o.box.x + o.box.width)
|
||||
: Math.min(coord.y + coord.height, o.box.y + o.box.height)
|
||||
if (hi <= lo) throw new Error(`level ${levelId}: the link to ${o.level} (${side}) shares no edge`)
|
||||
from = lo - (horizontal ? coord.x : coord.y)
|
||||
to = hi - (horizontal ? coord.x : coord.y)
|
||||
}
|
||||
const mid = Math.floor((from + to - 1) / 2)
|
||||
const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid
|
||||
const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid
|
||||
const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid
|
||||
const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid
|
||||
out.push({
|
||||
x: edgeX,
|
||||
y: edgeY,
|
||||
interiorX: inX,
|
||||
interiorY: inY,
|
||||
toLevelId: o.level,
|
||||
side,
|
||||
kind: 'gate',
|
||||
span: { from, to },
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Which tile array a {@link DumpTileRef} names, as the adapter's tile kind. */
|
||||
const TILE_KIND_OF_ARRAY = { w: 'wall', f: 'floor', r: 'shadow' } as const
|
||||
|
||||
/**
|
||||
* The adapter input for one level of a schema-3 DRLG dump.
|
||||
*
|
||||
* The native oracle's JSON and the port's `dumpAct1` have the same shape, so both go through the same
|
||||
* mapping and a comparison of the two compares like with like. The dump must come from an act run with
|
||||
* activation: every room of the level activated, `mapTiles` being the final state after all of them.
|
||||
*
|
||||
* - tiles: each room's own tile data, i.e. the entries DRLGROOMTILE_InitWallTileData /
|
||||
* InitFloorTileData / InitShadowTileData filled (`pTileGrid->nWalls/nFloors/nShadows` of the
|
||||
* `pTiles` arrays; the rest of each allocation stays zeroed and is never drawn), then the tiles the
|
||||
* room's pMapLinks point at (DRLGROOMTILE_LinkedTileDataManager's merge of shared room edges, which
|
||||
* reaches rooms of the neighbouring level across a seam).
|
||||
* - units: `mapTiles.units`, the room's pPresetUnits after DRLGROOMTILE_AddRoomMapTiles (so with the
|
||||
* UNIT_TILE units of DRLGROOMTILE_AddWarp), moved from room-relative to level-local sub-tiles.
|
||||
* - warps: the room's pRoomTiles chain.
|
||||
*
|
||||
* @param dump - schema-3 dump of an act run with activation.
|
||||
* @param levelId - the level to map.
|
||||
* @param tables - the DRLG tables the dump was made with (for LvlWarp.txt rows).
|
||||
* @returns the adapter input.
|
||||
*/
|
||||
export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: DrlgTables): DrlgLevelInput {
|
||||
if (dump.schema !== 3) throw new Error(`DRLG dump schema ${String(dump.schema)}, expected 3`)
|
||||
const act = dump.act.find(a => a.id === levelId)
|
||||
if (act === undefined) throw new Error(`level ${levelId}: not in the dump's act section`)
|
||||
const levelsById = new Map<number, DumpLevel>(dump.levels.map(l => [l.id, l]))
|
||||
const level = levelsById.get(levelId)
|
||||
if (level === undefined) throw new Error(`level ${levelId}: not generated in the dump`)
|
||||
const levelGrid = level.levelGrid
|
||||
if (!levelGrid) throw new Error(`level ${levelId}: the dump has no levelGrid stage (not an outdoor level)`)
|
||||
const grid2 = levelGrid.grids[2]
|
||||
if (!grid2) throw new Error(`level ${levelId}: pGrid[2] was never allocated`)
|
||||
if (level.activation.length !== level.rooms.length) {
|
||||
throw new Error(`level ${levelId}: ${level.activation.length} of ${level.rooms.length} rooms activated (the dump must be made with activation)`)
|
||||
}
|
||||
const [levelX, levelY, levelW, levelH] = act.coord
|
||||
// pOutdoors->pCoord is a union over nWidth, nHeight, nGridWidth, nGridHeight (8-tile grid cells).
|
||||
const [, , gridW, gridH] = levelGrid.coord
|
||||
if (gridW * TILES_PER_GRID_CELL !== levelW || gridH * TILES_PER_GRID_CELL !== levelH) {
|
||||
throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`)
|
||||
}
|
||||
|
||||
const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => ({
|
||||
key,
|
||||
kind,
|
||||
x: room[0] + t[2] - levelX,
|
||||
y: room[1] + t[3] - levelY,
|
||||
flags: t[5] >>> 0,
|
||||
tileType: t[7],
|
||||
tile: t[6] === null ? null : { library: normalizeDrlgPath(t[6][0]), index: t[6][1] },
|
||||
})
|
||||
const initialised = (level: DumpLevel, room: number, array: 'w' | 'f' | 'r'): { list: readonly DumpTile[]; count: number } => {
|
||||
const mt = level.activation[room]!.mapTiles
|
||||
const [list, count] = array === 'w' ? [mt.walls, mt.nWalls] : array === 'f' ? [mt.floors, mt.nFloors] : [mt.roofs, mt.nShadows]
|
||||
if (count < 0 || count > list.length) {
|
||||
throw new Error(`level ${level.id} room ${room}: ${count} initialised ${TILE_KIND_OF_ARRAY[array]} tiles but ${list.length} allocated`)
|
||||
}
|
||||
return { list, count }
|
||||
}
|
||||
|
||||
const tiles: DrlgTileInput[] = []
|
||||
const units: DrlgUnitInput[] = []
|
||||
const warps: DrlgWarpInput[] = []
|
||||
level.rooms.forEach((room, r) => {
|
||||
const what = `level ${levelId} room ${r}`
|
||||
const mt = level.activation[r]!.mapTiles
|
||||
for (const array of ['w', 'f', 'r'] as const) {
|
||||
const { list, count } = initialised(level, r, array)
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
tiles.push(place(`${levelId}/${r}/${array}/${i}`, TILE_KIND_OF_ARRAY[array], room.coord, list[i]!))
|
||||
}
|
||||
}
|
||||
for (const [, ref] of mt.links) {
|
||||
if (ref === null) throw new Error(`${what}: a map link without a tile`)
|
||||
const [refLevel, refRoom, array, index] = ref
|
||||
const owner = levelsById.get(refLevel)
|
||||
const ownerRoom = owner?.rooms[refRoom]
|
||||
if (owner === undefined || ownerRoom === undefined || owner.activation[refRoom] === undefined) {
|
||||
throw new Error(`${what}: linked tile ${ref.join('/')} is in a room the dump did not activate`)
|
||||
}
|
||||
const { list, count } = initialised(owner, refRoom, array)
|
||||
if (index < 0 || index >= count) {
|
||||
throw new Error(`${what}: linked tile ${ref.join('/')} is not one of the owner's ${count} initialised tiles`)
|
||||
}
|
||||
tiles.push(place(ref.join('/'), TILE_KIND_OF_ARRAY[array], ownerRoom.coord, list[index]!))
|
||||
}
|
||||
const subDX = SUBTILES_PER_TILE * (room.coord[0] - levelX)
|
||||
const subDY = SUBTILES_PER_TILE * (room.coord[1] - levelY)
|
||||
for (const [type, index, mode, x, y, spawned] of mt.units) {
|
||||
units.push({ room: r, type, index, mode, subX: x + subDX, subY: y + subDY, spawned })
|
||||
}
|
||||
for (const [destination, row] of mt.warps) {
|
||||
if (destination === null) throw new Error(`${what}: a warp without a destination room`)
|
||||
const record = tables.lvlWarp[row]
|
||||
if (record === undefined) throw new Error(`${what}: warp to level ${destination[0]} has LvlWarp.txt row ${row}`)
|
||||
warps.push({ room: r, lvlWarpId: record.dwLevelId, toLevelId: destination[0] })
|
||||
}
|
||||
})
|
||||
|
||||
const orths: DrlgOrthInput[] = levelGrid.roomData.map(o => {
|
||||
if (o.level < 0 || o.box === null) throw new Error(`level ${levelId}: an outdoor link without a neighbour level`)
|
||||
return { level: o.level, dir: o.dir, box: { x: o.box[0], y: o.box[1], width: o.box[2], height: o.box[3] } }
|
||||
})
|
||||
return {
|
||||
levelId,
|
||||
drlgType: act.drlgType,
|
||||
levelType: act.levelType,
|
||||
coord: { x: levelX, y: levelY, width: levelW, height: levelH },
|
||||
flags: levelGrid.flags >>> 0,
|
||||
orths,
|
||||
grid2,
|
||||
tiles,
|
||||
units,
|
||||
warps,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* D2Common outdoor DRLG: 1:1 port of D2MOO source/D2Common/src/Drlg/DrlgOutdoors.cpp and the inline
|
||||
* helper of include/Drlg/D2DrlgOutdoors.h (commit 5596f5c, MIT License, Copyright (c) 2020-2025
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* D2Common outdoor rooms: 1:1 port of DRLGOUTROOM_InitializeDrlgOutdoorRoom from D2MOO
|
||||
* source/D2Common/src/Drlg/DrlgOutRoom.cpp (commit 5596f5c, MIT License, Copyright (c) 2020-2025
|
||||
* The Phrozen Keep community). The allocation of D2DrlgOutdoorRoomStrc and the level-link callbacks of
|
||||
* that file are ported with the outdoor generator (drlg-outdoors.ts / drlg-outplace.ts).
|
||||
*/
|
||||
|
||||
import { DRLGLOGIC_AllocCoordLists } from './drlg-logic.ts'
|
||||
import {
|
||||
DRLGROOMTILE_AllocTileData,
|
||||
DRLGROOMTILE_AllocTileGrid,
|
||||
DRLGROOMTILE_CountAllTileTypes,
|
||||
DRLGROOMTILE_CountWallWarpTiles,
|
||||
DRLGROOMTILE_LoadInitRoomTiles,
|
||||
tileGridOf,
|
||||
} from './drlg-roomtile.ts'
|
||||
import type { D2DrlgRoomStrc } from './drlg-types.ts'
|
||||
|
||||
//D2Common.6FD83E20
|
||||
export function DRLGOUTROOM_InitializeDrlgOutdoorRoom(pDrlgRoom: D2DrlgRoomStrc): void {
|
||||
const pOutdoor = pDrlgRoom.pOutdoor
|
||||
if (!pOutdoor) throw new Error('DRLGOUTROOM_InitializeDrlgOutdoorRoom: pDrlgRoom->pOutdoor is NULL')
|
||||
|
||||
DRLGROOMTILE_AllocTileGrid(pDrlgRoom)
|
||||
DRLGROOMTILE_CountWallWarpTiles(pDrlgRoom, pOutdoor.pWallGrid, pOutdoor.pTileTypeGrid, false, false)
|
||||
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pOutdoor.pWallGrid, false, false, false)
|
||||
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pOutdoor.pFloorGrid, false, false, false)
|
||||
DRLGROOMTILE_AllocTileData(pDrlgRoom)
|
||||
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pOutdoor.pWallGrid, pOutdoor.pTileTypeGrid, false, false, false)
|
||||
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pOutdoor.pFloorGrid, null, false, false, false)
|
||||
|
||||
const pTileGrid = tileGridOf(pDrlgRoom)
|
||||
pTileGrid.pTiles.nWalls = pTileGrid.nWalls
|
||||
pTileGrid.pTiles.nFloors = pTileGrid.nFloors
|
||||
pTileGrid.pTiles.nRoofs = pTileGrid.nShadows
|
||||
|
||||
DRLGLOGIC_AllocCoordLists(pDrlgRoom)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
gObjPresetToObjectId,
|
||||
gTileTypeMappingTable,
|
||||
LVLPREST_ACT1_BRIDGE,
|
||||
LVLPREST_ACT1_GRAVEYARD,
|
||||
LVLPREST_ACT1_RIVER_LOWER,
|
||||
LVLPREST_ACT1_RIVER_UPPER,
|
||||
LVLPREST_ACT1_TOWN_1,
|
||||
|
|
@ -46,10 +47,17 @@ import {
|
|||
DRLG_MAX_FLOOR_LAYERS,
|
||||
DRLG_MAX_WALL_LAYERS,
|
||||
FLAG_OPERATION_OR,
|
||||
IMODE_ONGROUND,
|
||||
LEVEL_BLOODMOOR,
|
||||
LEVEL_BURIALGROUNDS,
|
||||
LEVEL_HARROGATH,
|
||||
LEVEL_LUTGHOLEIN,
|
||||
LEVEL_THEPANDEMONIUMFORTRESS,
|
||||
MONMODE_NEUTRAL,
|
||||
OBJMODE_NEUTRAL,
|
||||
UNIT_ITEM,
|
||||
UNIT_MONSTER,
|
||||
UNIT_OBJECT,
|
||||
newCoord,
|
||||
newGrid,
|
||||
newPresetRoom,
|
||||
|
|
@ -83,19 +91,30 @@ import {
|
|||
DRLGROOM_GetVisArrayFromLevelId,
|
||||
} from './drlg-room.ts'
|
||||
import { DRLGWARP_GetWarpDestinationFromArray } from './drlg-warp.ts'
|
||||
// Used only inside DRLGPRESET_AddPresetRoomMapTiles (at run time), so these import cycles are harmless.
|
||||
import { DRLGLOGIC_AllocCoordLists, DRLGLOGIC_InitializeDrlgCoordList } from './drlg-logic.ts'
|
||||
import {
|
||||
DRLGROOMTILE_AllocTileData,
|
||||
DRLGROOMTILE_AllocTileGrid,
|
||||
DRLGROOMTILE_CountAllTileTypes,
|
||||
DRLGROOMTILE_CountWallWarpTiles,
|
||||
DRLGROOMTILE_LoadInitRoomTiles,
|
||||
PackedTile,
|
||||
tileGridOf,
|
||||
} from './drlg-roomtile.ts'
|
||||
|
||||
// D2C_UnitTypes (Units/Units.h)
|
||||
export const UNIT_PLAYER = 0
|
||||
export const UNIT_MONSTER = 1
|
||||
export const UNIT_OBJECT = 2
|
||||
export const UNIT_MISSILE = 3
|
||||
export const UNIT_ITEM = 4
|
||||
export const UNIT_TILE = 5
|
||||
|
||||
// D2C_MonModes / D2C_ObjectModes / D2C_ItemModes
|
||||
export const MONMODE_NEUTRAL = 1
|
||||
export const OBJMODE_NEUTRAL = 0
|
||||
export const IMODE_ONGROUND = 3
|
||||
// D2C_UnitTypes and unit modes: defined in the leaf module drlg-types.ts (see the note there).
|
||||
export {
|
||||
IMODE_ONGROUND,
|
||||
MONMODE_NEUTRAL,
|
||||
OBJMODE_NEUTRAL,
|
||||
UNIT_ITEM,
|
||||
UNIT_MISSILE,
|
||||
UNIT_MONSTER,
|
||||
UNIT_OBJECT,
|
||||
UNIT_PLAYER,
|
||||
UNIT_TILE,
|
||||
} from './drlg-types.ts'
|
||||
|
||||
// D2C_MonsterIds / D2C_SuperUniques (DataTbls/MonsterIds.h)
|
||||
export const MONSTER_ACT2VENDOR1 = 204
|
||||
|
|
@ -1123,3 +1142,154 @@ export function DRLGPRESET_InitPresetRoomGrids(pDrlgRoom: D2DrlgRoomStrc): void
|
|||
}
|
||||
}
|
||||
|
||||
// DrlgDrlgAnim.cpp is not ported: no Act I LvlPrest.txt row sets Animate (the call sites throw instead).
|
||||
function DRLGANIM_NotPorted(szFunction: string): never {
|
||||
throw new Error(`${szFunction} is not ported (LvlPrest.txt Animate is set)`)
|
||||
}
|
||||
|
||||
//D2Common.0x6FD87130
|
||||
export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): void {
|
||||
DRLGROOMTILE_AllocTileGrid(pDrlgRoom)
|
||||
|
||||
const pMaze = pDrlgRoom.pMaze!
|
||||
const pMazeMap = pDrlgRoom.pMaze!.pMap!
|
||||
const pFile = pMazeMap.pFile!
|
||||
|
||||
let bKillEdgeX = false
|
||||
let bKillEdgeY = false
|
||||
if (pMazeMap.pLvlPrestTxtRecord.dwKillEdge) {
|
||||
bKillEdgeX =
|
||||
((pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0) ===
|
||||
((pMazeMap.pDrlgCoord.nPosX + pMazeMap.pDrlgCoord.nWidth) | 0)
|
||||
bKillEdgeY =
|
||||
((pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0) ===
|
||||
((pMazeMap.pDrlgCoord.nPosY + pMazeMap.pDrlgCoord.nHeight) | 0)
|
||||
}
|
||||
|
||||
const tTownOrGraveyardTileInfoGrid = newGrid()
|
||||
const aCellOffsets = new Int32Array(256)
|
||||
const aCellPosBuffer = new Int32Array(1024)
|
||||
const tTownOrGraveyardTileTypeGrid = newGrid()
|
||||
const aTownOrGraveyardCellPosOffsets = new Int32Array(256)
|
||||
const aTownOrGraveyardCellPosBuffer = new Int32Array(1024)
|
||||
const bTownOrGraveyard =
|
||||
pDrlgRoom.pMaze!.nLevelPrest === LVLPREST_ACT1_TOWN_1 || pDrlgRoom.pMaze!.nLevelPrest === LVLPREST_ACT1_GRAVEYARD
|
||||
if (bTownOrGraveyard) {
|
||||
DRLGGRID_FillGrid(
|
||||
tTownOrGraveyardTileTypeGrid,
|
||||
(pDrlgRoom.pDrlgCoord.nWidth + 1) | 0,
|
||||
(pDrlgRoom.pDrlgCoord.nHeight + 1) | 0,
|
||||
aTownOrGraveyardCellPosBuffer,
|
||||
0,
|
||||
aTownOrGraveyardCellPosOffsets,
|
||||
)
|
||||
DRLGGRID_FillGrid(
|
||||
tTownOrGraveyardTileInfoGrid,
|
||||
(pDrlgRoom.pDrlgCoord.nWidth + 1) | 0,
|
||||
(pDrlgRoom.pDrlgCoord.nHeight + 1) | 0,
|
||||
aCellPosBuffer,
|
||||
0,
|
||||
aCellOffsets,
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 0; i < pFile.nFloorLayers; i += 1) {
|
||||
const bCheckCoordinatesValidity = i === 0 && pMazeMap.pLvlPrestTxtRecord.dwFillBlanks !== 0
|
||||
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pFloorGrid[i]!, bCheckCoordinatesValidity, bKillEdgeX, bKillEdgeY)
|
||||
|
||||
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
|
||||
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < pFile.nWallLayers; i += 1) {
|
||||
DRLGROOMTILE_CountWallWarpTiles(pDrlgRoom, pMaze.pWallGrid[i]!, pMaze.pTileTypeGrid[i]!, bKillEdgeX, bKillEdgeY)
|
||||
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pWallGrid[i]!, false, bKillEdgeX, bKillEdgeY)
|
||||
|
||||
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
|
||||
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
|
||||
}
|
||||
}
|
||||
|
||||
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pCellGrid, false, bKillEdgeX, bKillEdgeY)
|
||||
|
||||
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
|
||||
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
|
||||
}
|
||||
|
||||
if (bTownOrGraveyard) {
|
||||
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, tTownOrGraveyardTileInfoGrid, false, bKillEdgeX, bKillEdgeY)
|
||||
}
|
||||
|
||||
DRLGROOMTILE_AllocTileData(pDrlgRoom)
|
||||
|
||||
for (let i = 0; i < pFile.nFloorLayers; i += 1) {
|
||||
const bCheckCoordinatesValidity = i === 0 && pMazeMap.pLvlPrestTxtRecord.dwFillBlanks !== 0
|
||||
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pMaze.pFloorGrid[i]!, null, bCheckCoordinatesValidity, bKillEdgeX, bKillEdgeY)
|
||||
}
|
||||
|
||||
for (let i = 0; i < pFile.nWallLayers; i += 1) {
|
||||
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pMaze.pWallGrid[i]!, pMaze.pTileTypeGrid[i]!, false, bKillEdgeX, bKillEdgeY)
|
||||
}
|
||||
|
||||
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pMaze.pCellGrid, null, false, bKillEdgeX, bKillEdgeY)
|
||||
|
||||
if (bTownOrGraveyard) {
|
||||
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, tTownOrGraveyardTileInfoGrid, tTownOrGraveyardTileTypeGrid, false, bKillEdgeX, bKillEdgeY)
|
||||
}
|
||||
|
||||
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
|
||||
DRLGANIM_NotPorted('DRLGANIM_AllocAnimationTileGrids')
|
||||
}
|
||||
|
||||
const pTileGrid = tileGridOf(pDrlgRoom)
|
||||
pTileGrid.pTiles.nWalls = pTileGrid.nWalls
|
||||
pTileGrid.pTiles.nFloors = pTileGrid.nFloors
|
||||
pTileGrid.pTiles.nRoofs = pTileGrid.nShadows
|
||||
|
||||
if (pMazeMap.pLvlPrestTxtRecord.dwLogicals) {
|
||||
// C passes the first element of each grid array.
|
||||
DRLGLOGIC_InitializeDrlgCoordList(pDrlgRoom, pMaze.pTileTypeGrid[0]!, pMaze.pFloorGrid[0]!, pMaze.pWallGrid[0]!)
|
||||
} else {
|
||||
DRLGLOGIC_AllocCoordLists(pDrlgRoom)
|
||||
}
|
||||
|
||||
if (bTownOrGraveyard) {
|
||||
DRLGGRID_ResetGrid(tTownOrGraveyardTileTypeGrid)
|
||||
DRLGGRID_ResetGrid(tTownOrGraveyardTileInfoGrid)
|
||||
}
|
||||
|
||||
if (pDrlgRoom.pLevel.nLevelId === LEVEL_BURIALGROUNDS && !pMaze.pTombStoneTiles) {
|
||||
let nTombStoneTiles = 0
|
||||
|
||||
// D2_ALLOC_POOL(6 * sizeof(D2CoordStrc)): zero-filled like the oracle's FOG_AllocPool (calloc).
|
||||
const pTombStoneTiles = [0, 1, 2, 3, 4, 5].map(() => ({ nX: 0, nY: 0 }))
|
||||
pMaze.pTombStoneTiles = pTombStoneTiles
|
||||
|
||||
for (let i = 0; i < pDrlgRoom.pDrlgCoord.nHeight; i += 1) {
|
||||
for (let j = 0; j < pDrlgRoom.pDrlgCoord.nWidth; j += 1) {
|
||||
const nPackedTileInformation = DRLGGRID_GetGridEntry(pMaze.pWallGrid[0]!, j, i) >>> 0
|
||||
if (
|
||||
PackedTile.nTileStyle(nPackedTileInformation) === 10 &&
|
||||
PackedTile.nTileSequence(nPackedTileInformation) >= 23 &&
|
||||
PackedTile.nTileSequence(nPackedTileInformation) <= 27
|
||||
) {
|
||||
if (nTombStoneTiles < 6) {
|
||||
const nX = (j + pDrlgRoom.pDrlgCoord.nPosX) | 0
|
||||
const nY = (i + pDrlgRoom.pDrlgCoord.nPosY) | 0
|
||||
|
||||
const tSubtile = DUNGEON_GameTileToSubtileCoords(nX, nY)
|
||||
|
||||
pTombStoneTiles[nTombStoneTiles]!.nX = (tSubtile.nX + 2) | 0
|
||||
pTombStoneTiles[nTombStoneTiles]!.nY = (tSubtile.nY + 2) | 0
|
||||
|
||||
nTombStoneTiles += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pMaze.nTombStoneTiles = nTombStoneTiles
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ import {
|
|||
type D2DrlgRoomStrc,
|
||||
type D2DrlgStrc,
|
||||
type D2PresetUnitStrc,
|
||||
type D2RoomTileStrc,
|
||||
} from './drlg-types.ts'
|
||||
import { DRLGPRESET_AllocPresetRoomData } from './drlg-preset.ts'
|
||||
import { DRLGWARP_GetWarpDestinationFromArray } from './drlg-warp.ts'
|
||||
import { DRLGWARP_GetLvlWarpTxtRecordFromWarpIdAndDirection, DRLGWARP_GetWarpDestinationFromArray } from './drlg-warp.ts'
|
||||
|
||||
//D2Common.0x6FD771C0
|
||||
// fRoomStatus = 4 is not modelled (room status lists only matter to the activation scheduler).
|
||||
|
|
@ -42,12 +43,16 @@ export function DRLGROOM_AllocRoomEx(pLevel: D2DrlgLevelStrc, nType: number): D2
|
|||
pOutdoor: null,
|
||||
dwDT1Mask: 0,
|
||||
pTiles: [],
|
||||
pTileGrid: null,
|
||||
nRoomsNear: 0,
|
||||
ppRoomsNear: [],
|
||||
pRoomTiles: null,
|
||||
pPresetUnits: null,
|
||||
pDrlgOrth: null,
|
||||
pSeed: newD2Seed(),
|
||||
dwInitSeed: 0,
|
||||
pLogicalRoomInfo: null,
|
||||
pRoom: null,
|
||||
pDrlgRoomNext: null,
|
||||
}
|
||||
|
||||
|
|
@ -357,7 +362,7 @@ export function DRLGROOM_SortRoomListByPosition(ppRoomList: D2DrlgRoomStrc[], nL
|
|||
//D2Common.0x6FD77F00
|
||||
export function sub_6FD77F00(
|
||||
pDrlgRoom1: D2DrlgRoomStrc,
|
||||
_nWarpId: number,
|
||||
nWarpId: number,
|
||||
pDrlgRoom2: D2DrlgRoomStrc | null,
|
||||
nWarpFlag: number,
|
||||
nDirection: number,
|
||||
|
|
@ -390,6 +395,18 @@ export function sub_6FD77F00(
|
|||
pDrlgRoom1.ppRoomsNear.push(pDrlgRoom2)
|
||||
pDrlgRoom1.nRoomsNear += 1
|
||||
DRLGROOM_SortRoomListByPosition(pDrlgRoom1.ppRoomsNear, pDrlgRoom1.nRoomsNear)
|
||||
|
||||
// D2_CALLOC_STRC_POOL(D2RoomTileStrc)
|
||||
const pRoomTile: D2RoomTileStrc = {
|
||||
pDrlgRoom: pDrlgRoom2,
|
||||
pLvlWarpTxtRecord: DRLGWARP_GetLvlWarpTxtRecordFromWarpIdAndDirection(pDrlgRoom1.pLevel, nWarpId, 'b'),
|
||||
bEnabled: 1,
|
||||
unk0x0C: null,
|
||||
unk0x10: null,
|
||||
pNext: pDrlgRoom1.pRoomTiles,
|
||||
}
|
||||
pDrlgRoom1.pRoomTiles = pRoomTile
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -478,3 +478,28 @@ export function DATATBLS_GetLvlTypesTxtRecord(t: DrlgTables, nLevelType: number)
|
|||
return rec
|
||||
}
|
||||
|
||||
//1.10f:D2Common.0x6FD61DE0
|
||||
//1.11: D2Common.0x6FD86840
|
||||
// LevelsTbls.cpp:747. `szDirection` is the C char ('b', 'l', 'r'); records compare their szDirection[0].
|
||||
export function DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection(
|
||||
t: DrlgTables,
|
||||
nLevelId: number,
|
||||
szDirection: string,
|
||||
): LvlWarpRecord {
|
||||
if (t.lvlWarp.length === 0) throw new Error('D2_ASSERT(sgptDataTables->pLvlWarpTxt)')
|
||||
|
||||
for (let i = 0; i < t.lvlWarp.length; i += 1) {
|
||||
const rec = t.lvlWarp[i]!
|
||||
// uint32_t dwLevelId == int nLevelId: equal bit patterns.
|
||||
if ((rec.dwLevelId | 0) === (nLevelId | 0)) {
|
||||
if (szDirection === 'b' || rec.szDirection[0] === 'b' || rec.szDirection[0] === szDirection) {
|
||||
return rec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection(${nLevelId}, '${szDirection}'): Error in DRLG related to stairs that connect levels`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
*/
|
||||
|
||||
import type { D2SeedStrc } from '../d2-rng.ts'
|
||||
import type { D2TileLibraryStrc } from './drlg-d2cmp.ts'
|
||||
import type { DrlgDataSource, DrlgTables, LvlMazeRecord, LvlPrestRecord } from './drlg-tables.ts'
|
||||
import type { D2TileLibraryEntryStrc, D2TileLibraryStrc } from './drlg-d2cmp.ts'
|
||||
import type { DrlgDataSource, DrlgTables, LvlMazeRecord, LvlPrestRecord, LvlWarpRecord } from './drlg-tables.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Enums (D2DrlgDrlg.h, D2DrlgOutdoors.h, DataTbls/LevelsIds.h, D2CommonDefinitions.h)
|
||||
|
|
@ -50,6 +50,20 @@ export const DRLGTYPE_MAZE = 1
|
|||
export const DRLGTYPE_PRESET = 2
|
||||
export const DRLGTYPE_OUTDOOR = 3
|
||||
|
||||
// D2C_UnitTypes (Units/Units.h). Kept in this leaf module: drlg-preset.ts and drlg-roomtile.ts
|
||||
// import each other, and drlg-roomtile.ts builds a module-level table (stru_6FDD0F68) from them.
|
||||
export const UNIT_PLAYER = 0
|
||||
export const UNIT_MONSTER = 1
|
||||
export const UNIT_OBJECT = 2
|
||||
export const UNIT_MISSILE = 3
|
||||
export const UNIT_ITEM = 4
|
||||
export const UNIT_TILE = 5
|
||||
|
||||
// D2C_MonModes / D2C_ObjectModes / D2C_ItemModes
|
||||
export const MONMODE_NEUTRAL = 1
|
||||
export const OBJMODE_NEUTRAL = 0
|
||||
export const IMODE_ONGROUND = 3
|
||||
|
||||
export const DRLGSUBST_NONE = 0
|
||||
export const DRLGSUBST_FIXED = 1
|
||||
export const DRLGSUBST_RANDOM = 2
|
||||
|
|
@ -347,6 +361,9 @@ export interface D2DrlgPresetRoomStrc {
|
|||
pFloorGrid: D2DrlgGridStrc[]
|
||||
pCellGrid: D2DrlgGridStrc
|
||||
pMazeGrid: D2DrlgGridStrc | null
|
||||
/** D2_ALLOC_POOL(6 * sizeof(D2CoordStrc)) by DRLGPRESET_AddPresetRoomMapTiles (LEVEL_BURIALGROUNDS). */
|
||||
pTombStoneTiles: D2CoordStrc[] | null
|
||||
nTombStoneTiles: number
|
||||
}
|
||||
|
||||
/** D2DrlgOutdoorRoomStrc */
|
||||
|
|
@ -365,6 +382,146 @@ export interface D2DrlgOutdoorRoomStrc {
|
|||
nSubThemePicked: number
|
||||
}
|
||||
|
||||
/** D2CoordStrc */
|
||||
export interface D2CoordStrc {
|
||||
nX: number
|
||||
nY: number
|
||||
}
|
||||
|
||||
/** D2RoomCoordListStrc (D2DrlgDrlg.h:260). */
|
||||
export interface D2RoomCoordListStrc {
|
||||
/** Embedded array of 2. */
|
||||
pBox: [D2DrlgCoordStrc, D2DrlgCoordStrc]
|
||||
bNode: number
|
||||
bRoomActive: number
|
||||
nIndex: number
|
||||
pNext: D2RoomCoordListStrc | null
|
||||
}
|
||||
|
||||
/**
|
||||
* D2DrlgTileDataStrc (D2DrlgDrlg.h:333). Tile data live in the arrays of D2DrlgRoomTilesStrc; a
|
||||
* `D2DrlgTileDataStrc*` is a reference to an element of such an array.
|
||||
*/
|
||||
export interface D2DrlgTileDataStrc {
|
||||
/** 0x00: client X of the tile (DUNGEON_GameTileToClientCoords). */
|
||||
nWidth: number
|
||||
/** 0x04: client Y of the tile bottom. */
|
||||
nHeight: number
|
||||
/** 0x08: tile X relative to the room. */
|
||||
nPosX: number
|
||||
/** 0x0C: tile Y relative to the room. */
|
||||
nPosY: number
|
||||
/**
|
||||
* 0x10 (int32): DRLGLOGIC_SetCoordListForTiles stores the `D2RoomCoordListStrc*` of the tile's
|
||||
* logical room here (C casts the pointer to int); otherwise 0.
|
||||
*/
|
||||
unk0x10: number | D2RoomCoordListStrc
|
||||
/** 0x14: uint32_t D2MapTileFlags */
|
||||
dwFlags: number
|
||||
pTile: D2TileLibraryEntryStrc | null
|
||||
nTileType: number
|
||||
/** 0x20: next tile of the same chain (link list, warp list, shadow list). */
|
||||
unk0x20: D2DrlgTileDataStrc | null
|
||||
unk0x24: number
|
||||
/** uint8_t */
|
||||
nRed: number
|
||||
/** uint8_t */
|
||||
nGreen: number
|
||||
/** uint8_t */
|
||||
nBlue: number
|
||||
/** uint8_t */
|
||||
nIntensity: number
|
||||
unk0x2C: number
|
||||
}
|
||||
|
||||
/**
|
||||
* D2DrlgRoomTilesStrc. Each array's length is its allocation size in elements
|
||||
* (D2_CALLOC_POOL / D2_REALLOC_POOL); the port throws where C would write past it.
|
||||
*/
|
||||
export interface D2DrlgRoomTilesStrc {
|
||||
pWallTiles: D2DrlgTileDataStrc[] | null
|
||||
nWalls: number
|
||||
pFloorTiles: D2DrlgTileDataStrc[] | null
|
||||
nFloors: number
|
||||
/** Shadows occupy pRoofTiles[0..nShadows) (DRLGROOMTILE_InitShadowTileData). */
|
||||
pRoofTiles: D2DrlgTileDataStrc[] | null
|
||||
nRoofs: number
|
||||
}
|
||||
|
||||
/** D2DrlgTileLinkStrc */
|
||||
export interface D2DrlgTileLinkStrc {
|
||||
bFloor: number
|
||||
pMapTile: D2DrlgTileDataStrc | null
|
||||
pNext: D2DrlgTileLinkStrc | null
|
||||
}
|
||||
|
||||
/** D2DrlgAnimTileGridStrc */
|
||||
export interface D2DrlgAnimTileGridStrc {
|
||||
ppMapTileData: (D2DrlgTileDataStrc | null)[]
|
||||
nFrames: number
|
||||
nCurrentFrame: number
|
||||
nAnimationSpeed: number
|
||||
pNext: D2DrlgAnimTileGridStrc | null
|
||||
}
|
||||
|
||||
/** D2DrlgTileGridStrc */
|
||||
export interface D2DrlgTileGridStrc {
|
||||
pMapLinks: D2DrlgTileLinkStrc | null
|
||||
pAnimTiles: D2DrlgAnimTileGridStrc | null
|
||||
nWalls: number
|
||||
nFloors: number
|
||||
nShadows: number
|
||||
/** Embedded D2DrlgRoomTilesStrc. */
|
||||
pTiles: D2DrlgRoomTilesStrc
|
||||
}
|
||||
|
||||
/** D2RoomTileStrc: one warp of a room (allocated by sub_6FD77F00). */
|
||||
export interface D2RoomTileStrc {
|
||||
pDrlgRoom: D2DrlgRoomStrc
|
||||
pLvlWarpTxtRecord: LvlWarpRecord
|
||||
bEnabled: number
|
||||
unk0x0C: D2DrlgTileDataStrc | null
|
||||
unk0x10: D2DrlgTileDataStrc | null
|
||||
pNext: D2RoomTileStrc | null
|
||||
}
|
||||
|
||||
/** D2DrlgLogicalRoomInfoStrc (D2DrlgDrlgLogic.h, aka D2DrlgCoordListStrc). */
|
||||
export interface D2DrlgLogicalRoomInfoStrc {
|
||||
/** D2DrlgLogicalRoomInfoFlags */
|
||||
dwFlags: number
|
||||
nLists: number
|
||||
pIndexX: D2DrlgGridStrc
|
||||
/**
|
||||
* C stores `(int)pRoomCoordList` in these cells (DRLGLOGIC_AssignCoordListsForGrids). Int32Array cells
|
||||
* cannot hold objects: the port stores a 1-based handle into `ppCoordListHandles` (0 = NULL).
|
||||
*/
|
||||
pIndexY: D2DrlgGridStrc
|
||||
pCoordList: D2RoomCoordListStrc | null
|
||||
/** Port-only: the D2RoomCoordListStrc objects whose handles (index + 1) live in pIndexY cells. */
|
||||
ppCoordListHandles: D2RoomCoordListStrc[]
|
||||
}
|
||||
|
||||
/** A zeroed D2DrlgTileDataStrc (D2_CALLOC_POOL). */
|
||||
export function newTileData(): D2DrlgTileDataStrc {
|
||||
return {
|
||||
nWidth: 0,
|
||||
nHeight: 0,
|
||||
nPosX: 0,
|
||||
nPosY: 0,
|
||||
unk0x10: 0,
|
||||
dwFlags: 0,
|
||||
pTile: null,
|
||||
nTileType: 0,
|
||||
unk0x20: null,
|
||||
unk0x24: 0,
|
||||
nRed: 0,
|
||||
nGreen: 0,
|
||||
nBlue: 0,
|
||||
nIntensity: 0,
|
||||
unk0x2C: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** D2DrlgRoomStrc (aka D2RoomExStrc). Tile coordinates live in `pDrlgCoord`. */
|
||||
export interface D2DrlgRoomStrc {
|
||||
pLevel: D2DrlgLevelStrc
|
||||
|
|
@ -378,13 +535,24 @@ export interface D2DrlgRoomStrc {
|
|||
/** uint32_t */
|
||||
dwDT1Mask: number
|
||||
pTiles: D2TileLibraryStrc[]
|
||||
/** 0xA8: allocated by DRLGROOMTILE_AllocTileGrid. */
|
||||
pTileGrid: D2DrlgTileGridStrc | null
|
||||
nRoomsNear: number
|
||||
ppRoomsNear: D2DrlgRoomStrc[]
|
||||
/** 0xC8: warps of the room (sub_6FD77F00). */
|
||||
pRoomTiles: D2RoomTileStrc | null
|
||||
pPresetUnits: D2PresetUnitStrc | null
|
||||
pDrlgOrth: D2DrlgOrthStrc | null
|
||||
pSeed: D2SeedStrc
|
||||
/** uint32_t */
|
||||
dwInitSeed: number
|
||||
/** 0xE0 */
|
||||
pLogicalRoomInfo: D2DrlgLogicalRoomInfoStrc | null
|
||||
/**
|
||||
* 0xE4 D2ActiveRoomStrc*: set by DRLG_CreateRoomForRoomEx (D2Game side, not ported); always null
|
||||
* in the port, like in the oracle.
|
||||
*/
|
||||
pRoom: null
|
||||
pDrlgRoomNext: D2DrlgRoomStrc | null
|
||||
}
|
||||
|
||||
|
|
@ -513,6 +681,8 @@ export function newPresetRoom(): D2DrlgPresetRoomStrc {
|
|||
pFloorGrid: [newGrid(), newGrid()],
|
||||
pCellGrid: newGrid(),
|
||||
pMazeGrid: null,
|
||||
pTombStoneTiles: null,
|
||||
nTombStoneTiles: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
* outdoor generator.
|
||||
*/
|
||||
|
||||
import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts'
|
||||
import {
|
||||
DATATBLS_GetLevelDefRecord,
|
||||
DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection,
|
||||
type LvlWarpRecord,
|
||||
} from './drlg-tables.ts'
|
||||
import type { D2DrlgLevelStrc, D2DrlgStrc } from './drlg-types.ts'
|
||||
|
||||
//D2Common.0x6FD78CC0
|
||||
|
|
@ -22,3 +26,16 @@ export function DRLGWARP_GetWarpDestinationFromArray(pLevel: D2DrlgLevelStrc, nA
|
|||
if (nIndex >= 8) throw new RangeError(`DRLGWARP_GetWarpDestinationFromArray: index ${nIndex}`)
|
||||
return DRLGWARP_GetWarpIdArrayFromLevelId(pLevel.pDrlg, pLevel.nLevelId)[nIndex]!
|
||||
}
|
||||
|
||||
//D2Common.0x6FD78D80 (DrlgDrlgWarp.cpp:304). nWarpId is a uint8_t parameter; szDirection the C char.
|
||||
export function DRLGWARP_GetLvlWarpTxtRecordFromWarpIdAndDirection(
|
||||
pLevel: D2DrlgLevelStrc,
|
||||
nWarpId: number,
|
||||
szDirection: string,
|
||||
): LvlWarpRecord {
|
||||
return DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection(
|
||||
pLevel.pDrlg.env.tables,
|
||||
DRLGWARP_GetWarpDestinationFromArray(pLevel, nWarpId & 0xff),
|
||||
szDirection,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -469,6 +469,9 @@ export interface BorderOpening {
|
|||
* @param region - when given, only gaps inside it count as gaps.
|
||||
* @param bandDepth - how many sub-tiles inwards to look before giving up.
|
||||
* Defaults to half the level's depth on the axis being searched.
|
||||
* @param range - when given, only this stretch of the edge is searched (sub-tiles along the side,
|
||||
* `from` inclusive, `to` exclusive). Generated levels whose generator knows which part of the edge
|
||||
* opens onto the neighbour pass it, so a gap elsewhere on the same edge is never taken for it.
|
||||
* @returns the opening, or null if the edge is solid all the way in.
|
||||
*/
|
||||
export function findBorderOpening(
|
||||
|
|
@ -476,11 +479,17 @@ export function findBorderOpening(
|
|||
side: Side,
|
||||
region?: WalkableRegion,
|
||||
bandDepth?: number,
|
||||
range?: { readonly from: number; readonly to: number },
|
||||
): BorderOpening | null {
|
||||
const horizontal = side === 'north' || side === 'south'
|
||||
const span = horizontal ? grid.gridWidth : grid.gridHeight
|
||||
const depth = horizontal ? grid.gridHeight : grid.gridWidth
|
||||
const limit = Math.min(bandDepth ?? Math.floor(depth / 2), depth)
|
||||
const lo = range?.from ?? 0
|
||||
const hi = range?.to ?? span
|
||||
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < 0 || hi > span || lo >= hi) {
|
||||
throw new Error(`findBorderOpening: range ${String(lo)}..${String(hi)} is not inside the ${side} edge (0..${String(span)})`)
|
||||
}
|
||||
|
||||
for (let inset = 0; inset < limit; inset += 1) {
|
||||
const fixed = side === 'north' || side === 'west' ? inset : depth - 1 - inset
|
||||
|
|
@ -488,8 +497,8 @@ export function findBorderOpening(
|
|||
let bestStart = -1
|
||||
let bestLength = 0
|
||||
let runStart = -1
|
||||
for (let along = 0; along <= span; along += 1) {
|
||||
const open = along < span
|
||||
for (let along = lo; along <= hi; along += 1) {
|
||||
const open = along < hi
|
||||
&& (horizontal ? inRegion(grid, region, along, fixed) : inRegion(grid, region, fixed, along))
|
||||
if (open) {
|
||||
if (runStart < 0) runStart = along
|
||||
|
|
|
|||
|
|
@ -738,7 +738,7 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
|||
* used by the offline entity baker to pre-pack all level monsters per act.
|
||||
*/
|
||||
export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
|
||||
1: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "FS", weapon: "hth" }, { token: "CR", weapon: "2ht" }, { token: "SK", weapon: "1hs" }, { token: "BN", weapon: "hth" }, { token: "GM", weapon: "2hs" }, { token: "CR", weapon: "bow" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "SP", weapon: "hth" }, { token: "VA", weapon: "hth" }],
|
||||
1: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "FS", weapon: "hth" }, { token: "CR", weapon: "2ht" }, { token: "SK", weapon: "1hs" }, { token: "BN", weapon: "hth" }, { token: "GM", weapon: "2hs" }, { token: "CR", weapon: "bow" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "SP", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "EC", weapon: "hth" }],
|
||||
2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }],
|
||||
3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }],
|
||||
4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,400 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c 4-Pass Secondary Border Evolution.
|
||||
*
|
||||
* Implements:
|
||||
* - DRLGOUTDOORS_AddAct124SecondaryBorder (D2Common/src/Drlg/DrlgOutdoors.cpp:650)
|
||||
* - DRLGTILESUB_AddSecondaryBorder (D2Common/src/Drlg/DrlgTileSub.cpp:18)
|
||||
* - DRLGTILESUB_TestReplaceSubPreset (D2Common/src/Drlg/DrlgTileSub.cpp:148)
|
||||
* - DRLGTILESUB_ReplaceSubPreset (D2Common/src/Drlg/DrlgTileSub.cpp:223)
|
||||
*
|
||||
* Ground Truth Passes:
|
||||
* - Pass 1: LVLSUB_ACT1_BORDER_CLIFFS (BorderCliffs.ds1, dwBordType = 1)
|
||||
* - Pass 2: LVLSUB_ACT1_BORDER_MIDDLE (BorderMiddle.ds1, dwBordType = 0, Bord9..12 interior 2x3 closures)
|
||||
* - Pass 3: LVLSUB_ACT1_BORDER_CORNER (BorderCorner.ds1, dwBordType = 1, indented corner bays)
|
||||
* - Pass 4: LVLSUB_ACT1_BORDER_BORDER (BorderBorder.ds1, dwBordType = 2, Bord5..8 outer peninsulas)
|
||||
*/
|
||||
|
||||
import type { Ds1 } from "../../formats/ds1.ts"
|
||||
import type { CanvasLike, MutableDs1Cell } from "./wilderness-river.ts"
|
||||
import {
|
||||
TILES_PER_BLOCK,
|
||||
type D2OutdoorLevelContext,
|
||||
} from "./wilderness-tables.ts"
|
||||
import {
|
||||
BORDER_CLIFFS_SUBST,
|
||||
BORDER_MIDDLE_SUBST,
|
||||
BORDER_CORNER_SUBST,
|
||||
BORDER_BORDER_SUBST,
|
||||
type DrlgSubstGroup,
|
||||
} from "./wilderness-border-data.ts"
|
||||
|
||||
export interface BorderEvolutionOptions {
|
||||
readonly context: D2OutdoorLevelContext
|
||||
readonly borderPieces?: readonly { readonly name: string; readonly levels: readonly Ds1[] }[]
|
||||
readonly suppressedBlocks: ReadonlySet<string>
|
||||
readonly seed: number
|
||||
}
|
||||
|
||||
export interface BorderEvolutionResult {
|
||||
readonly placedBorderBlocks: Map<string, string> // "bx,by" -> ds1 name (e.g. "Bord9.ds1")
|
||||
readonly notes: string[]
|
||||
readonly peninsulaCount: number
|
||||
readonly closureCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 64-bit LCG PRNG matching D2Common SEED_RollLimitedRandomNumber.
|
||||
* lo = lo * 0x6AC690C5 + hi
|
||||
*/
|
||||
class D2BorderRng {
|
||||
private lo: number
|
||||
private hi: number
|
||||
|
||||
constructor(seed: number, factor = 666) {
|
||||
this.lo = seed >>> 0
|
||||
this.hi = factor >>> 0
|
||||
}
|
||||
|
||||
rand(max: number): number {
|
||||
if (max <= 0) return 0
|
||||
const next = (BigInt(this.lo) * 0x6ac690c5n + BigInt(this.hi)) & 0xffffffffffffffffn
|
||||
this.lo = Number(next & 0xffffffffn) >>> 0
|
||||
this.hi = Number(next >> 32n) >>> 0
|
||||
return Number(BigInt(this.lo) % BigInt(max))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the authentic D2MOO 4-pass secondary border evolution pipeline.
|
||||
*/
|
||||
export function DRLGOUTDOORS_AddAct124SecondaryBorder(
|
||||
canvas: CanvasLike,
|
||||
options: BorderEvolutionOptions,
|
||||
): BorderEvolutionResult {
|
||||
const { context, borderPieces, suppressedBlocks, seed } = options
|
||||
const { gridWidth, gridHeight, dwFlags } = context
|
||||
const rng = new D2BorderRng(seed)
|
||||
|
||||
// 1. Initialize Outdoor Block Grid (pGrid[0]):
|
||||
// 0 = open floor, 1..12 = Bord1..Bord12, -1 = blank/outside.
|
||||
const blockGrid: number[][] = Array.from({ length: gridHeight }, () =>
|
||||
Array(gridWidth).fill(0),
|
||||
)
|
||||
|
||||
// Initial perimeter borders per DRLGOUTPLACE_PlaceAct1245OutdoorBorders:
|
||||
for (let x = 0; x < gridWidth; x += 1) {
|
||||
blockGrid[0][x] = 3 // North edge: Bord3 (South-facing wall)
|
||||
blockGrid[gridHeight - 1][x] = 1 // South edge: Bord1 (North-facing wall)
|
||||
}
|
||||
for (let y = 0; y < gridHeight; y += 1) {
|
||||
blockGrid[y][0] = 2 // West edge: Bord2 (East-facing wall)
|
||||
blockGrid[y][gridWidth - 1] = 4 // East edge: Bord4 (West-facing wall)
|
||||
}
|
||||
// Corners
|
||||
blockGrid[0][0] = 6 // NW: Bord6
|
||||
blockGrid[0][gridWidth - 1] = 7 // NE: Bord7
|
||||
blockGrid[gridHeight - 1][0] = 5 // SW: Bord5
|
||||
blockGrid[gridHeight - 1][gridWidth - 1] = 8 // SE: Bord8
|
||||
|
||||
// Suppressed blocks (river columns, town gates) are cleared from default border
|
||||
for (const key of suppressedBlocks) {
|
||||
const [bxStr, byStr] = key.split(",")
|
||||
const bx = Number(bxStr)
|
||||
const by = Number(byStr)
|
||||
if (bx >= 0 && bx < gridWidth && by >= 0 && by < gridHeight) {
|
||||
blockGrid[by][bx] = -1 // marked as occupied / external
|
||||
}
|
||||
}
|
||||
|
||||
const placedBorderBlocks = new Map<string, string>()
|
||||
const notes: string[] = []
|
||||
|
||||
let peninsulaCount = 0
|
||||
let closureCount = 0
|
||||
|
||||
// Helper to find piece DS1 by filename or preset index
|
||||
const findPiece = (bordIndex: number): Ds1 | undefined => {
|
||||
if (!borderPieces) return undefined
|
||||
const pattern = new RegExp(`Border\\s*0?${bordIndex}\\b|Bord${bordIndex}\\.ds1`, "i")
|
||||
for (const p of borderPieces) {
|
||||
if (pattern.test(p.name)) {
|
||||
return p.levels[0]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Core pattern matching and replacement engine: DRLGTILESUB_AddSecondaryBorder.
|
||||
*/
|
||||
const executeSubstPass = (
|
||||
passName: string,
|
||||
groups: readonly DrlgSubstGroup[],
|
||||
bordType: number,
|
||||
nLvlSubId: number,
|
||||
): number => {
|
||||
if (groups.length === 0) return 0
|
||||
|
||||
let nRand = 0
|
||||
if (bordType === 0) {
|
||||
// Pass 2 (BorderMiddle): starts at random group
|
||||
nRand = rng.rand(groups.length)
|
||||
}
|
||||
|
||||
let passPlacedCount = 0
|
||||
const nOffset = nLvlSubId === 1 && (dwFlags & 12) !== 0 ? -1 : 1
|
||||
|
||||
for (let j = 0; j < groups.length; j += 1) {
|
||||
const groupIdx = (nRand + j) % groups.length
|
||||
const group = groups[groupIdx]
|
||||
|
||||
const scanW = nOffset + gridWidth - group.width
|
||||
const scanH = 1 + gridHeight - group.height
|
||||
const nArea = scanW * scanH
|
||||
if (nArea <= 0) continue
|
||||
|
||||
// Generate coordinates and pair-swap shuffle
|
||||
const coords: Array<{ x: number; y: number }> = []
|
||||
for (let i = 0; i < nArea; i += 1) {
|
||||
coords.push({ x: i % scanW, y: Math.floor(i / scanW) })
|
||||
}
|
||||
for (let i = 0; i < nArea; i += 1) {
|
||||
const r1 = rng.rand(nArea)
|
||||
const r2 = rng.rand(nArea)
|
||||
const tmp = coords[r1]
|
||||
coords[r1] = coords[r2]
|
||||
coords[r2] = tmp
|
||||
}
|
||||
|
||||
const bSmallWilderness = nLvlSubId === 1 && gridWidth < 6 && gridHeight < 6
|
||||
|
||||
for (let i = 0; i < nArea; i += 1) {
|
||||
const { x: nX, y: nY } = coords[i]
|
||||
if (bSmallWilderness && nX === 2 && nY === 2) continue
|
||||
|
||||
// DRLGTILESUB_TestReplaceSubPreset: Check target requirement for all cells in group
|
||||
let match = true
|
||||
for (let gy = 0; gy < group.height; gy += 1) {
|
||||
for (let gx = 0; gx < group.width; gx += 1) {
|
||||
const req = group.target[gy * group.width + gx]
|
||||
if (req === 0) continue // ignore cell
|
||||
|
||||
const targetX = nX + gx
|
||||
const targetY = nY + gy
|
||||
if (
|
||||
targetX < 0 ||
|
||||
targetX >= gridWidth ||
|
||||
targetY < 0 ||
|
||||
targetY >= gridHeight
|
||||
) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
|
||||
const current = blockGrid[targetY][targetX]
|
||||
const key = `${targetX},${targetY}`
|
||||
|
||||
if (req === -1) {
|
||||
// Open interior floor required: must be unoccupied and not suppressed
|
||||
if (current !== 0 || suppressedBlocks.has(key)) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
} else if (req === 63) {
|
||||
// Wildcard border required: must be an existing border (1..12) and not suppressed
|
||||
if (current < 1 || current > 12 || suppressedBlocks.has(key)) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
} else if (req >= 1 && req <= 12) {
|
||||
// Specific border required
|
||||
if (current !== req || suppressedBlocks.has(key)) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!match) break
|
||||
}
|
||||
|
||||
if (match) {
|
||||
// DRLGTILESUB_ReplaceSubPreset: Apply randomly picked variant
|
||||
const vIdx = rng.rand(group.variants.length)
|
||||
const variant = group.variants[vIdx]
|
||||
|
||||
for (let gy = 0; gy < group.height; gy += 1) {
|
||||
for (let gx = 0; gx < group.width; gx += 1) {
|
||||
const repl = variant[gy * group.width + gx]
|
||||
const targetX = nX + gx
|
||||
const targetY = nY + gy
|
||||
const key = `${targetX},${targetY}`
|
||||
|
||||
if (repl >= 1 && repl <= 12) {
|
||||
// Place border preset
|
||||
blockGrid[targetY][targetX] = repl
|
||||
const ds1Name = `Bord${repl}.ds1`
|
||||
placedBorderBlocks.set(key, ds1Name)
|
||||
passPlacedCount += 1
|
||||
|
||||
if (repl >= 9 && repl <= 12) closureCount += 1
|
||||
if (repl >= 5 && repl <= 8) peninsulaCount += 1
|
||||
|
||||
// Stamp DS1 or placeholder tiles onto canvas
|
||||
const ds1 = findPiece(repl)
|
||||
if (ds1) {
|
||||
stampDs1ToCanvas(
|
||||
canvas,
|
||||
ds1,
|
||||
targetX * TILES_PER_BLOCK,
|
||||
targetY * TILES_PER_BLOCK,
|
||||
)
|
||||
} else {
|
||||
stampPlaceholderBorderBlock(
|
||||
canvas,
|
||||
targetX * TILES_PER_BLOCK,
|
||||
targetY * TILES_PER_BLOCK,
|
||||
)
|
||||
}
|
||||
} else if (repl === -1) {
|
||||
// Clear to open floor
|
||||
blockGrid[targetY][targetX] = 0
|
||||
placedBorderBlocks.delete(key)
|
||||
} else if (repl === -2) {
|
||||
// Mark blank / exterior cell
|
||||
blockGrid[targetY][targetX] = -1
|
||||
placedBorderBlocks.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bordType === 0) {
|
||||
// BordType 0 (Middle): Terminate entire pass on first match!
|
||||
return passPlacedCount
|
||||
} else if (bordType === 1) {
|
||||
// BordType 1 (Cliffs, Corner): Break out of coordinate loop for this group
|
||||
break
|
||||
}
|
||||
// BordType 2 (Border): Continue scanning all coordinates in this group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return passPlacedCount
|
||||
}
|
||||
|
||||
// --- PASS 1: Border Cliffs (LVLSUB_ACT1_BORDER_CLIFFS, BordType 1) ---
|
||||
const cliffsCount = executeSubstPass(
|
||||
"BorderCliffs",
|
||||
BORDER_CLIFFS_SUBST,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
notes.push(
|
||||
`Secondary border Pass 1: BorderCliffs applied (${cliffsCount} blocks modified)`,
|
||||
)
|
||||
|
||||
// --- PASS 2: Border Middle (LVLSUB_ACT1_BORDER_MIDDLE, BordType 0) ---
|
||||
const middleCount = executeSubstPass(
|
||||
"BorderMiddle",
|
||||
BORDER_MIDDLE_SUBST,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
notes.push(
|
||||
`Secondary border Pass 2: Bord9..12 interior 2x3 forest closures stamped (${closureCount} blocks)`,
|
||||
)
|
||||
|
||||
// --- PASS 3: Border Corner (LVLSUB_ACT1_BORDER_CORNER, BordType 1) ---
|
||||
const cornerCount = executeSubstPass(
|
||||
"BorderCorner",
|
||||
BORDER_CORNER_SUBST,
|
||||
1,
|
||||
2,
|
||||
)
|
||||
notes.push(
|
||||
`Secondary border Pass 3: BorderCorner applied (${cornerCount} corner bays modified)`,
|
||||
)
|
||||
|
||||
// --- PASS 4: Border Border (LVLSUB_ACT1_BORDER_BORDER, BordType 2) ---
|
||||
const borderCount = executeSubstPass(
|
||||
"BorderBorder",
|
||||
BORDER_BORDER_SUBST,
|
||||
2,
|
||||
3,
|
||||
)
|
||||
notes.push(
|
||||
`Secondary border Pass 4: Bord5..8 outer peninsulas stamped (${peninsulaCount} blocks)`,
|
||||
)
|
||||
|
||||
return {
|
||||
placedBorderBlocks,
|
||||
notes,
|
||||
peninsulaCount,
|
||||
closureCount,
|
||||
}
|
||||
}
|
||||
|
||||
function stampPlaceholderBorderBlock(
|
||||
canvas: CanvasLike,
|
||||
startX: number,
|
||||
startY: number,
|
||||
): void {
|
||||
for (let ty = 0; ty < TILES_PER_BLOCK; ty += 1) {
|
||||
for (let tx = 0; tx < TILES_PER_BLOCK; tx += 1) {
|
||||
const cy = startY + ty
|
||||
const cx = startX + tx
|
||||
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
|
||||
const cell = canvas.cells[cy]?.[cx] as unknown as MutableDs1Cell | undefined
|
||||
if (cell) {
|
||||
// Tree wall tile (style 1, sequence 0, prop1 129)
|
||||
cell.walls = [{ style: 1, sequence: 0, prop1: 129, hidden: false }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stampDs1ToCanvas(
|
||||
canvas: CanvasLike,
|
||||
ds1: Ds1,
|
||||
originX: number,
|
||||
originY: number,
|
||||
): void {
|
||||
for (let y = 0; y < ds1.height; y += 1) {
|
||||
const cy = originY + y
|
||||
if (cy >= canvas.height) break
|
||||
const targetRow = canvas.cells[cy]
|
||||
const sourceRow = ds1.cells[y]
|
||||
if (!targetRow || !sourceRow) continue
|
||||
|
||||
for (let x = 0; x < ds1.width; x += 1) {
|
||||
const cx = originX + x
|
||||
if (cx >= canvas.width) break
|
||||
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
|
||||
const sourceCell = sourceRow[x]
|
||||
if (!targetCell || !sourceCell) continue
|
||||
|
||||
for (let i = 0; i < sourceCell.floors.length; i += 1) {
|
||||
const floor = sourceCell.floors[i]
|
||||
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
|
||||
if (i < targetCell.floors.length) {
|
||||
targetCell.floors[i] = { ...floor }
|
||||
} else {
|
||||
targetCell.floors.push({ ...floor })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < sourceCell.walls.length; i += 1) {
|
||||
const wall = sourceCell.walls[i]
|
||||
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
|
||||
if (i < targetCell.walls.length) {
|
||||
targetCell.walls[i] = { ...wall }
|
||||
} else {
|
||||
targetCell.walls.push({ ...wall })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c base floor initialization & LvlSub SubType 6 cluster substitution.
|
||||
*
|
||||
* Implements:
|
||||
* - Base floor 0x40002 (TileType 0, Style 0, Sequence 0 Grass) initialization (DRLGROOM_AllocRoomData)
|
||||
* - LvlSub.txt SubType 6 cluster substitutions (Pud, Trees, Stone, Swamp, Object)
|
||||
*/
|
||||
|
||||
import type { Ds1 } from '../../formats/ds1.ts'
|
||||
import type { CanvasLike, MutableDs1Cell } from './wilderness-river.ts'
|
||||
|
||||
export interface ClusterSubstitutionOptions {
|
||||
readonly widthTiles: number
|
||||
readonly heightTiles: number
|
||||
readonly subTheme: number
|
||||
readonly substitutions?: readonly {
|
||||
readonly name: string
|
||||
readonly type: number
|
||||
readonly prob: readonly number[]
|
||||
readonly trials: readonly number[]
|
||||
readonly levels: readonly Ds1[]
|
||||
}[]
|
||||
readonly occupiedTiles: ReadonlySet<string>
|
||||
readonly seed: number
|
||||
}
|
||||
|
||||
export interface ClusterSubstitutionResult {
|
||||
readonly substitutedClusters: number
|
||||
readonly substitutions: Array<{ name: string; role: 'object' | 'border'; enabled: boolean; clusters: number }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all cells on canvas to uniform 0x40002 base grass floor.
|
||||
*/
|
||||
export function initializeBaseGrassFloor(canvas: CanvasLike): void {
|
||||
for (let y = 0; y < canvas.height; y += 1) {
|
||||
const row = canvas.cells[y]
|
||||
if (!row) continue
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
const cell = row[x] as unknown as MutableDs1Cell | undefined
|
||||
if (cell) {
|
||||
// Uniform 0x40002 base floor: style 0, sequence 0, prop1 = 2
|
||||
cell.floors = [{ style: 0, sequence: 0, prop1: 2, hidden: false }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies LvlSub SubType 6 cluster substitutions onto open grass cells.
|
||||
*/
|
||||
export function DRLGTLSUB_DoClusterSubstitutions(
|
||||
canvas: CanvasLike,
|
||||
options: ClusterSubstitutionOptions,
|
||||
): ClusterSubstitutionResult {
|
||||
const { widthTiles, heightTiles, subTheme, substitutions, occupiedTiles, seed } = options
|
||||
|
||||
const results: ClusterSubstitutionResult['substitutions'] = []
|
||||
let totalClusters = 0
|
||||
|
||||
if (!substitutions || substitutions.length === 0) {
|
||||
return { substitutedClusters: 0, substitutions: results }
|
||||
}
|
||||
|
||||
// Filter SubType 6 substitutions
|
||||
const clusterSubs = substitutions.filter(s => s.type === 6)
|
||||
if (clusterSubs.length === 0) {
|
||||
return { substitutedClusters: 0, substitutions: results }
|
||||
}
|
||||
|
||||
const effectiveTheme = Math.max(0, Math.min(4, Math.floor(subTheme)))
|
||||
let rngSeed = (seed ^ 0x5a5a5a5a) >>> 0
|
||||
|
||||
const nextRng = (): number => {
|
||||
rngSeed = (Math.imul(rngSeed, 1103515245) + 12345) & 0x7fffffff
|
||||
return rngSeed
|
||||
}
|
||||
|
||||
for (const sub of clusterSubs) {
|
||||
const prob = sub.prob[effectiveTheme] ?? 0
|
||||
const trials = sub.trials[effectiveTheme] ?? 0
|
||||
const ds1 = sub.levels[0]
|
||||
|
||||
let clusterCount = 0
|
||||
|
||||
if (trials > 0 && prob > 0 && ds1) {
|
||||
for (let t = 0; t < trials; t += 1) {
|
||||
if ((nextRng() % 100) < prob) {
|
||||
// Find an unoccupied interior grass cell
|
||||
const candX = 8 + (nextRng() % Math.max(1, widthTiles - 16 - ds1.width))
|
||||
const candY = 8 + (nextRng() % Math.max(1, heightTiles - 16 - ds1.height))
|
||||
|
||||
let conflict = false
|
||||
for (let dy = 0; dy < ds1.height; dy += 1) {
|
||||
for (let dx = 0; dx < ds1.width; dx += 1) {
|
||||
if (occupiedTiles.has(`${candX + dx},${candY + dy}`)) {
|
||||
conflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (conflict) break
|
||||
}
|
||||
|
||||
if (!conflict) {
|
||||
stampSubDs1ToCanvas(canvas, ds1, candX, candY)
|
||||
clusterCount += 1
|
||||
totalClusters += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: sub.name,
|
||||
role: 'object',
|
||||
enabled: clusterCount > 0,
|
||||
clusters: clusterCount,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
substitutedClusters: totalClusters,
|
||||
substitutions: results,
|
||||
}
|
||||
}
|
||||
|
||||
function stampSubDs1ToCanvas(canvas: CanvasLike, ds1: Ds1, originX: number, originY: number): void {
|
||||
for (let y = 0; y < ds1.height; y += 1) {
|
||||
const cy = originY + y
|
||||
if (cy >= canvas.height) break
|
||||
const targetRow = canvas.cells[cy]
|
||||
const sourceRow = ds1.cells[y]
|
||||
if (!targetRow || !sourceRow) continue
|
||||
|
||||
for (let x = 0; x < ds1.width; x += 1) {
|
||||
const cx = originX + x
|
||||
if (cx >= canvas.width) break
|
||||
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
|
||||
const sourceCell = sourceRow[x]
|
||||
if (!targetCell || !sourceCell) continue
|
||||
|
||||
for (let i = 0; i < sourceCell.floors.length; i += 1) {
|
||||
const floor = sourceCell.floors[i]
|
||||
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
|
||||
if (i < targetCell.floors.length) {
|
||||
targetCell.floors[i] = { ...floor }
|
||||
} else {
|
||||
targetCell.floors.push({ ...floor })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < sourceCell.walls.length; i += 1) {
|
||||
const wall = sourceCell.walls[i]
|
||||
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
|
||||
if (i < targetCell.walls.length) {
|
||||
targetCell.walls[i] = { ...wall }
|
||||
} else {
|
||||
targetCell.walls.push({ ...wall })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,469 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c town transition & landmark preset placement.
|
||||
*
|
||||
* Implements:
|
||||
* - TownSTrans.ds1 (56x8 tiles / 7x1 blocks) edge-aligned gate placement (DrlgOutWild.cpp:379)
|
||||
* - Complete unconditional landmark set for Blood Moor (DenEnt, Pond1, Cott1, Circle, Clover)
|
||||
* - DRLGOUTDOORS_SpawnPresetFarAway 2:1 distance maximization metric (DrlgOutdoors.cpp:260)
|
||||
* - Authentic 2D DRLG candidate pool with D2 PRNG Fisher-Yates coordinate shuffle (DrlgOutdoors.cpp:341)
|
||||
* - Zero spatial overlap guarantee between all placed presets
|
||||
*/
|
||||
|
||||
import type { Ds1 } from '../../formats/ds1.ts'
|
||||
import type { CanvasLike, MutableDs1Cell } from './wilderness-river.ts'
|
||||
import { D2Rng } from '../d2-rng.ts'
|
||||
import {
|
||||
TILES_PER_BLOCK,
|
||||
OUTDOOR_SOUTHWEST,
|
||||
OUTDOOR_NORTHWEST,
|
||||
OUTDOOR_SOUTHEAST,
|
||||
OUTDOOR_NORTHEAST,
|
||||
type D2OutdoorLevelContext,
|
||||
type D2LandmarkPreset,
|
||||
} from './wilderness-tables.ts'
|
||||
|
||||
export interface LandmarkSpawnOptions {
|
||||
readonly context: D2OutdoorLevelContext
|
||||
readonly pieces?: readonly { readonly name: string; readonly levels: readonly Ds1[] }[]
|
||||
readonly claimedBlocks: ReadonlySet<string>
|
||||
readonly seed: number
|
||||
}
|
||||
|
||||
export interface LandmarkSpawnResult {
|
||||
readonly landmarks: D2LandmarkPreset[]
|
||||
readonly notes: string[]
|
||||
readonly townTransition?: {
|
||||
readonly bx: number
|
||||
readonly by: number
|
||||
readonly widthTiles: number
|
||||
readonly heightTiles: number
|
||||
readonly name: string
|
||||
} | undefined
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||
return (
|
||||
a.x < b.x + b.w &&
|
||||
a.x + a.w > b.x &&
|
||||
a.y < b.y + b.h &&
|
||||
a.y + a.h > b.y
|
||||
)
|
||||
}
|
||||
|
||||
function testBlockArea(
|
||||
occupiedBlocks: ReadonlySet<string>,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
wBlocks: number,
|
||||
hBlocks: number,
|
||||
offset: number,
|
||||
): boolean {
|
||||
const xStart = bx - offset
|
||||
const xEnd = bx + wBlocks + offset
|
||||
const yStart = by - offset
|
||||
const yEnd = by + hBlocks + offset
|
||||
for (let y = yStart; y < yEnd; y += 1) {
|
||||
for (let x = xStart; x < xEnd; x += 1) {
|
||||
if (x < 0 || x >= gridWidth || y < 0 || y >= gridHeight) return false
|
||||
if (occupiedBlocks.has(`${x},${y}`)) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Places TownSTrans and all unconditional wilderness landmarks using authentic 2D DRLG candidate distribution.
|
||||
*/
|
||||
export function DRLGOUTWILD_AddPresets(
|
||||
canvas: CanvasLike,
|
||||
options: LandmarkSpawnOptions,
|
||||
): LandmarkSpawnResult {
|
||||
const { context, pieces, claimedBlocks, seed } = options
|
||||
const { gridWidth, gridHeight, dwFlags, levelId } = context
|
||||
|
||||
const placedRects: Rect[] = []
|
||||
const landmarks: D2LandmarkPreset[] = []
|
||||
const notes: string[] = []
|
||||
|
||||
let townTransition: LandmarkSpawnResult['townTransition']
|
||||
const occupiedBlocks = new Set<string>(claimedBlocks)
|
||||
const d2Seed = new D2Rng(seed)
|
||||
|
||||
const findPiece = (pattern: RegExp): Ds1 | undefined => {
|
||||
if (!pieces) return undefined
|
||||
for (const p of pieces) {
|
||||
if (pattern.test(p.name)) {
|
||||
return p.levels[0]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 1. Town Transition (TownSTrans.ds1: 56x8 tiles = 7x1 blocks)
|
||||
if (levelId === 2 || (dwFlags & (OUTDOOR_SOUTHWEST | OUTDOOR_NORTHWEST | OUTDOOR_SOUTHEAST | OUTDOOR_NORTHEAST))) {
|
||||
let townBx = 0
|
||||
let townBy = 0
|
||||
const townW = 56
|
||||
const townH = 8
|
||||
|
||||
if (dwFlags & OUTDOOR_NORTHWEST) {
|
||||
// Anchored to river edge at eastern side: bx = gridWidth - 7, by = 0
|
||||
townBx = Math.max(0, gridWidth - 7)
|
||||
townBy = 0
|
||||
} else if (dwFlags & OUTDOOR_SOUTHWEST) {
|
||||
townBx = 0
|
||||
townBy = 0
|
||||
} else if (dwFlags & OUTDOOR_SOUTHEAST) {
|
||||
townBx = 0
|
||||
townBy = 1
|
||||
} else if (dwFlags & OUTDOOR_NORTHEAST) {
|
||||
townBx = 0
|
||||
townBy = Math.max(0, gridHeight - 6)
|
||||
} else {
|
||||
townBx = Math.max(0, gridWidth - 7)
|
||||
townBy = 0
|
||||
}
|
||||
|
||||
const tileX = townBx * TILES_PER_BLOCK
|
||||
const tileY = townBy * TILES_PER_BLOCK
|
||||
|
||||
townTransition = {
|
||||
bx: townBx,
|
||||
by: townBy,
|
||||
widthTiles: townW,
|
||||
heightTiles: townH,
|
||||
name: 'TownSTrans.ds1',
|
||||
}
|
||||
|
||||
placedRects.push({ x: tileX, y: tileY, w: townW, h: townH })
|
||||
for (let bx = townBx; bx < townBx + 7; bx += 1) {
|
||||
occupiedBlocks.add(`${bx},${townBy}`)
|
||||
}
|
||||
|
||||
const townDs1 = findPiece(/Town.*STrans|Transition.*S/i) ?? findPiece(/Town.*Trans/i)
|
||||
if (townDs1) {
|
||||
stampDs1Safe(canvas, townDs1, tileX, tileY)
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
name: 'TownSTrans.ds1',
|
||||
ds1Path: 'Act1/Town/TownSTrans.ds1',
|
||||
gridX: townBx,
|
||||
gridY: townBy,
|
||||
tileX,
|
||||
tileY,
|
||||
widthTiles: townW,
|
||||
heightTiles: townH,
|
||||
roadExitTile: { x: tileX + 29, y: tileY + 8 },
|
||||
})
|
||||
|
||||
notes.push(`Placed TownSTrans (Act 1 - Town 1 Transition) at (${tileX}, ${tileY})`)
|
||||
}
|
||||
|
||||
// 2. Den of Evil (DenEnt.ds1, 16x16 tiles = 2x2 blocks) via FarAway metric
|
||||
const townBaseX = townTransition ? townTransition.bx * TILES_PER_BLOCK + townTransition.widthTiles / 2 : 0
|
||||
const townBaseY = townTransition ? townTransition.by * TILES_PER_BLOCK + townTransition.heightTiles / 2 : 0
|
||||
|
||||
const farAway = DRLGOUTDOORS_SpawnPresetFarAway({
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
occupiedBlocks,
|
||||
placedRects,
|
||||
baseX: townBaseX,
|
||||
baseY: townBaseY,
|
||||
seed: d2Seed,
|
||||
presetWidthBlocks: 2,
|
||||
presetHeightBlocks: 2,
|
||||
offset: 1,
|
||||
})
|
||||
|
||||
if (farAway) {
|
||||
const denTileX = farAway.bx * TILES_PER_BLOCK
|
||||
const denTileY = farAway.by * TILES_PER_BLOCK
|
||||
placedRects.push({ x: denTileX, y: denTileY, w: 16, h: 16 })
|
||||
for (let dx = 0; dx < 2; dx += 1) {
|
||||
for (let dy = 0; dy < 2; dy += 1) {
|
||||
occupiedBlocks.add(`${farAway.bx + dx},${farAway.by + dy}`)
|
||||
}
|
||||
}
|
||||
|
||||
const denDs1 = findPiece(/DOE|DenEnt/i)
|
||||
if (denDs1) {
|
||||
stampDs1Safe(canvas, denDs1, denTileX, denTileY)
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
name: 'DenEnt.ds1',
|
||||
ds1Path: 'Act1/Outdoors/DenEnt.ds1',
|
||||
gridX: farAway.bx,
|
||||
gridY: farAway.by,
|
||||
tileX: denTileX,
|
||||
tileY: denTileY,
|
||||
widthTiles: 16,
|
||||
heightTiles: 16,
|
||||
roadExitTile: { x: denTileX + 8, y: denTileY + 8 },
|
||||
})
|
||||
|
||||
notes.push(`Placed DenEnt (Act 1 - DOE Entrance) at (${denTileX}, ${denTileY}) via FarAway metric`)
|
||||
}
|
||||
|
||||
// Helper for authentic 2D DRLG candidate distribution with Fisher-Yates shuffle
|
||||
const spawn2DLandmark = (
|
||||
name: string,
|
||||
ds1Path: string,
|
||||
pattern: RegExp,
|
||||
wBlocks: number,
|
||||
hBlocks: number,
|
||||
noteDesc: string,
|
||||
offset = 0,
|
||||
): boolean => {
|
||||
const wTiles = wBlocks * TILES_PER_BLOCK
|
||||
const hTiles = hBlocks * TILES_PER_BLOCK
|
||||
const minBx = 1
|
||||
const maxBx = Math.max(minBx, (context.hasRiver && context.riverColumn !== undefined ? context.riverColumn : gridWidth - 1) - wBlocks)
|
||||
const minBy = 1
|
||||
const maxBy = Math.max(minBy, gridHeight - 1 - hBlocks)
|
||||
|
||||
const spanX = maxBx - minBx + 1
|
||||
const spanY = maxBy - minBy + 1
|
||||
const nArea = spanX * spanY
|
||||
|
||||
let chosenBx = -1
|
||||
let chosenBy = -1
|
||||
|
||||
if (nArea > 0) {
|
||||
const pCoord: Array<{ x: number; y: number }> = []
|
||||
for (let i = 0; i < nArea; i += 1) {
|
||||
pCoord.push({ x: minBx + (i % spanX), y: minBy + Math.floor(i / spanX) })
|
||||
}
|
||||
|
||||
// D2MOO Fisher-Yates coordinate shuffle
|
||||
for (let i = 0; i < nArea; i += 1) {
|
||||
const r1 = d2Seed.rand(nArea)
|
||||
const r2 = d2Seed.rand(nArea)
|
||||
const tmp = pCoord[r1]!
|
||||
pCoord[r1] = pCoord[r2]!
|
||||
pCoord[r2] = tmp
|
||||
}
|
||||
|
||||
for (let i = 0; i < nArea; i += 1) {
|
||||
const bx = pCoord[i]!.x
|
||||
const by = pCoord[i]!.y
|
||||
const candRect: Rect = { x: bx * TILES_PER_BLOCK, y: by * TILES_PER_BLOCK, w: wTiles, h: hTiles }
|
||||
if (!testBlockArea(occupiedBlocks, gridWidth, gridHeight, bx, by, wBlocks, hBlocks, offset)) continue
|
||||
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
|
||||
|
||||
chosenBx = bx
|
||||
chosenBy = by
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback deterministic scan if randomized shuffle found no valid slot
|
||||
if (chosenBx === -1) {
|
||||
for (let by = minBy; by <= maxBy; by += 1) {
|
||||
for (let bx = minBx; bx <= maxBx; bx += 1) {
|
||||
const candRect: Rect = { x: bx * TILES_PER_BLOCK, y: by * TILES_PER_BLOCK, w: wTiles, h: hTiles }
|
||||
if (!testBlockArea(occupiedBlocks, gridWidth, gridHeight, bx, by, wBlocks, hBlocks, 0)) continue
|
||||
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
|
||||
|
||||
chosenBx = bx
|
||||
chosenBy = by
|
||||
break
|
||||
}
|
||||
if (chosenBx !== -1) break
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary fallback: on narrow/crowded maps where secondary border clusters occupy remaining blocks,
|
||||
// allow placing over non-landmark blocks as long as it does not overlap any placed landmarks.
|
||||
if (chosenBx === -1) {
|
||||
for (let by = minBy; by <= maxBy; by += 1) {
|
||||
for (let bx = minBx; bx <= maxBx; bx += 1) {
|
||||
const candRect: Rect = { x: bx * TILES_PER_BLOCK, y: by * TILES_PER_BLOCK, w: wTiles, h: hTiles }
|
||||
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
|
||||
|
||||
chosenBx = bx
|
||||
chosenBy = by
|
||||
break
|
||||
}
|
||||
if (chosenBx !== -1) break
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenBx === -1) return false
|
||||
|
||||
// Claim blocks and stamp
|
||||
for (let dx = 0; dx < wBlocks; dx += 1) {
|
||||
for (let dy = 0; dy < hBlocks; dy += 1) {
|
||||
occupiedBlocks.add(`${chosenBx + dx},${chosenBy + dy}`)
|
||||
}
|
||||
}
|
||||
const tileX = chosenBx * TILES_PER_BLOCK
|
||||
const tileY = chosenBy * TILES_PER_BLOCK
|
||||
placedRects.push({ x: tileX, y: tileY, w: wTiles, h: hTiles })
|
||||
|
||||
const ds1 = findPiece(pattern)
|
||||
if (ds1) {
|
||||
stampDs1Safe(canvas, ds1, tileX, tileY)
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
name,
|
||||
ds1Path,
|
||||
gridX: chosenBx,
|
||||
gridY: chosenBy,
|
||||
tileX,
|
||||
tileY,
|
||||
widthTiles: wTiles,
|
||||
heightTiles: hTiles,
|
||||
roadExitTile: { x: tileX + wTiles / 2, y: tileY + hTiles / 2 },
|
||||
})
|
||||
|
||||
notes.push(`Placed ${name} (${noteDesc}) at (${tileX}, ${tileY})`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. Pond (Pond1.ds1, 2x2 blocks = 16x16 tiles)
|
||||
spawn2DLandmark('Pond1.ds1', 'Act1/Outdoors/Pond1.ds1', /Pond/i, 2, 2, 'Pond')
|
||||
|
||||
// 4. Cottage (Cott1.ds1, 2x2 blocks = 16x16 tiles) - 1 unconditional, 2nd if space permits
|
||||
spawn2DLandmark('Cott1.ds1', 'Act1/Outdoors/Cott1.ds1', /Cottage/i, 2, 2, 'Cottage')
|
||||
spawn2DLandmark('Cott1.ds1', 'Act1/Outdoors/Cott1.ds1', /Cottage/i, 2, 2, 'Cottage')
|
||||
|
||||
// 5. Circle (Stone Fill 1, 1x1 block = 8x8 tiles)
|
||||
spawn2DLandmark('Circle.ds1', 'Act1/Outdoors/Circle.ds1', /Circle|Stone.*1/i, 1, 1, 'Stone Fill 1')
|
||||
|
||||
// 6. Clover (Stone Fill 2, 1x1 block = 8x8 tiles)
|
||||
spawn2DLandmark('Clover.ds1', 'Act1/Outdoors/Clover.ds1', /Clover|Stone.*2/i, 1, 1, 'Stone Fill 2')
|
||||
|
||||
return {
|
||||
landmarks,
|
||||
notes,
|
||||
...(townTransition !== undefined ? { townTransition } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DRLGOUTDOORS_SpawnPresetFarAway
|
||||
* Implements authentic D2 distance maximization:
|
||||
* nAbsX <= nAbsY ? nAbsX + 2*nAbsY : nAbsY + 2*nAbsX
|
||||
* with randomized scan start (DrlgOutdoors.cpp:260-338).
|
||||
*/
|
||||
export function DRLGOUTDOORS_SpawnPresetFarAway(options: {
|
||||
gridWidth: number
|
||||
gridHeight: number
|
||||
occupiedBlocks: ReadonlySet<string>
|
||||
placedRects: readonly Rect[]
|
||||
baseX: number
|
||||
baseY: number
|
||||
seed?: D2Rng
|
||||
presetWidthBlocks?: number
|
||||
presetHeightBlocks?: number
|
||||
offset?: number
|
||||
}): { bx: number; by: number } | null {
|
||||
const {
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
occupiedBlocks,
|
||||
placedRects,
|
||||
baseX,
|
||||
baseY,
|
||||
seed,
|
||||
presetWidthBlocks = 2,
|
||||
presetHeightBlocks = 2,
|
||||
offset = 1,
|
||||
} = options
|
||||
|
||||
const wTiles = presetWidthBlocks * TILES_PER_BLOCK
|
||||
const hTiles = presetHeightBlocks * TILES_PER_BLOCK
|
||||
const nWidth = gridWidth - presetWidthBlocks
|
||||
const nHeight = gridHeight - presetHeightBlocks
|
||||
if (nWidth < 0 || nHeight < 0) return null
|
||||
|
||||
const nRandX = seed ? seed.rand(nWidth + 1) : 0
|
||||
const nRandY = seed ? seed.rand(nHeight + 1) : 0
|
||||
|
||||
let best: { bx: number; by: number } | null = null
|
||||
let maxMetric = -1
|
||||
|
||||
for (let i = 0; i <= nHeight; i += 1) {
|
||||
const nPosY = (i + nRandY) % (nHeight + 1)
|
||||
for (let j = 0; j <= nWidth; j += 1) {
|
||||
const nPosX = (j + nRandX) % (nWidth + 1)
|
||||
const candRect: Rect = { x: nPosX * TILES_PER_BLOCK, y: nPosY * TILES_PER_BLOCK, w: wTiles, h: hTiles }
|
||||
if (!testBlockArea(occupiedBlocks, gridWidth, gridHeight, nPosX, nPosY, presetWidthBlocks, presetHeightBlocks, offset)) continue
|
||||
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
|
||||
|
||||
const candCenterX = candRect.x + wTiles / 2
|
||||
const candCenterY = candRect.y + hTiles / 2
|
||||
const absX = Math.abs(candCenterX - baseX)
|
||||
const absY = Math.abs(candCenterY - baseY)
|
||||
const temp = absX <= absY ? absX + 2 * absY : absY + 2 * absX
|
||||
const metric = Math.floor(temp / 2)
|
||||
if (metric > maxMetric) {
|
||||
maxMetric = metric
|
||||
best = { bx: nPosX, by: nPosY }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback with offset 0 if margin check was overly restrictive
|
||||
if (!best && offset > 0) {
|
||||
return DRLGOUTDOORS_SpawnPresetFarAway({
|
||||
...options,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
function stampDs1Safe(canvas: CanvasLike, ds1: Ds1, originX: number, originY: number): void {
|
||||
for (let y = 0; y < ds1.height; y += 1) {
|
||||
const cy = originY + y
|
||||
if (cy >= canvas.height) break
|
||||
const targetRow = canvas.cells[cy]
|
||||
const sourceRow = ds1.cells[y]
|
||||
if (!targetRow || !sourceRow) continue
|
||||
|
||||
for (let x = 0; x < ds1.width; x += 1) {
|
||||
const cx = originX + x
|
||||
if (cx >= canvas.width) break
|
||||
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
|
||||
const sourceCell = sourceRow[x]
|
||||
if (!targetCell || !sourceCell) continue
|
||||
|
||||
for (let i = 0; i < sourceCell.floors.length; i += 1) {
|
||||
const floor = sourceCell.floors[i]
|
||||
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
|
||||
if (i < targetCell.floors.length) {
|
||||
targetCell.floors[i] = { ...floor }
|
||||
} else {
|
||||
targetCell.floors.push({ ...floor })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < sourceCell.walls.length; i += 1) {
|
||||
const wall = sourceCell.walls[i]
|
||||
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
|
||||
if (i < targetCell.walls.length) {
|
||||
targetCell.walls[i] = { ...wall }
|
||||
} else {
|
||||
targetCell.walls.push({ ...wall })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c outer river placement & boundary suppression.
|
||||
*
|
||||
* Implements:
|
||||
* - DRLGOUTWILD_SpawnRiver (D2Common/src/Drlg/DrlgOutWild.cpp:295)
|
||||
* - Dual-column placement: URiverC.ds1 @ x = nGridWidth - 2, LRiverC.ds1 @ x = nGridWidth - 1
|
||||
* - Boundary tree wall suppression along the river columns
|
||||
* - Wooden bridge placement at path crossing
|
||||
*/
|
||||
|
||||
import type { Ds1, Ds1Cell } from '../../formats/ds1.ts'
|
||||
import {
|
||||
TILES_PER_BLOCK,
|
||||
OUTDOOR_NORTHWEST,
|
||||
type D2OutdoorLevelContext,
|
||||
} from './wilderness-tables.ts'
|
||||
|
||||
export interface MutableDs1Cell {
|
||||
walls: Array<{ style: number; sequence: number; prop1: number; hidden?: boolean }>
|
||||
floors: Array<{ style: number; sequence: number; prop1: number; hidden?: boolean }>
|
||||
shadows?: Array<{ style: number; sequence: number; prop1: number; hidden?: boolean }>
|
||||
}
|
||||
|
||||
export interface CanvasLike {
|
||||
width: number
|
||||
height: number
|
||||
cells: Ds1Cell[][]
|
||||
objects?: unknown[]
|
||||
}
|
||||
|
||||
export interface RiverSpawnOptions {
|
||||
readonly context: D2OutdoorLevelContext
|
||||
readonly riverPieces?: {
|
||||
readonly upper?: Ds1 | undefined
|
||||
readonly lower?: Ds1 | undefined
|
||||
readonly bridge?: Ds1 | undefined
|
||||
readonly bridgeUpper?: Ds1 | undefined
|
||||
readonly bridgeLower?: Ds1 | undefined
|
||||
} | undefined
|
||||
readonly townTransitionRow?: number | undefined
|
||||
}
|
||||
|
||||
export interface RiverSpawnResult {
|
||||
readonly placedBlocks: Array<{
|
||||
readonly bx: number
|
||||
readonly by: number
|
||||
readonly type: 'upper' | 'lower' | 'bridge'
|
||||
readonly name: string
|
||||
}>
|
||||
readonly suppressedBorderBlocks: Set<string> // "bx,by"
|
||||
readonly bridgeRow?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically synthesizes or stamps river tiles (Style 2 = water floor) across outer boundary columns.
|
||||
*/
|
||||
export function DRLGOUTWILD_SpawnRiver(
|
||||
canvas: CanvasLike,
|
||||
options: RiverSpawnOptions,
|
||||
): RiverSpawnResult {
|
||||
const { context, riverPieces, townTransitionRow } = options
|
||||
const { gridWidth, gridHeight, hasRiver, dwFlags } = context
|
||||
|
||||
const suppressedBorderBlocks = new Set<string>()
|
||||
const placedBlocks: RiverSpawnResult['placedBlocks'] = []
|
||||
|
||||
if (!hasRiver) {
|
||||
return { placedBlocks, suppressedBorderBlocks }
|
||||
}
|
||||
|
||||
const upperCol = gridWidth - 2
|
||||
const lowerCol = gridWidth - 1
|
||||
|
||||
// Determine bridge row (middle of level height, unless occupied by town transition)
|
||||
let bridgeRow: number | undefined
|
||||
if (gridHeight >= 4) {
|
||||
const candidateRow = Math.floor(gridHeight / 2)
|
||||
if (candidateRow !== townTransitionRow) {
|
||||
bridgeRow = candidateRow
|
||||
}
|
||||
}
|
||||
|
||||
for (let by = 0; by < gridHeight; by += 1) {
|
||||
// Suppress perimeter tree borders across all rows along both river columns
|
||||
suppressedBorderBlocks.add(`${upperCol},${by}`)
|
||||
suppressedBorderBlocks.add(`${lowerCol},${by}`)
|
||||
|
||||
// If this row is occupied by the Town Transition gate, the gate DS1 overlays the river
|
||||
if (townTransitionRow !== undefined && by === townTransitionRow && (dwFlags & OUTDOOR_NORTHWEST)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const isBridge = by === bridgeRow
|
||||
const startX = upperCol * TILES_PER_BLOCK
|
||||
const startY = by * TILES_PER_BLOCK
|
||||
|
||||
// If authentic DS1 is available, stamp it
|
||||
if (riverPieces?.upper && riverPieces?.lower) {
|
||||
if (isBridge && (riverPieces.bridgeUpper || riverPieces.bridge)) {
|
||||
const bUpper = riverPieces.bridgeUpper ?? riverPieces.bridge!
|
||||
const bLower = riverPieces.bridgeLower ?? riverPieces.bridge!
|
||||
stampSubDs1(canvas, bUpper, startX, startY)
|
||||
placedBlocks.push({ bx: upperCol, by, type: 'bridge', name: 'BridgeU.ds1' })
|
||||
stampSubDs1(canvas, bLower, startX + TILES_PER_BLOCK, startY)
|
||||
placedBlocks.push({ bx: lowerCol, by, type: 'bridge', name: 'BridgeL.ds1' })
|
||||
} else {
|
||||
stampSubDs1(canvas, riverPieces.upper, startX, startY)
|
||||
placedBlocks.push({ bx: upperCol, by, type: 'upper', name: 'URiverC.ds1' })
|
||||
stampSubDs1(canvas, riverPieces.lower, startX + TILES_PER_BLOCK, startY)
|
||||
placedBlocks.push({ bx: lowerCol, by, type: 'lower', name: 'LRiverC.ds1' })
|
||||
}
|
||||
} else {
|
||||
// Direct synthesis of river water cells: style 2 = water floor, prop1 = 2
|
||||
// Continuous wooden plank bridge: style 4 = wooden bridge, prop1 = 194
|
||||
for (let ty = 0; ty < TILES_PER_BLOCK; ty += 1) {
|
||||
for (let tx = 0; tx < TILES_PER_BLOCK * 2; tx += 1) {
|
||||
const cy = startY + ty
|
||||
const cx = startX + tx
|
||||
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
|
||||
const cell = canvas.cells[cy]?.[cx] as unknown as MutableDs1Cell | undefined
|
||||
if (cell) {
|
||||
if (isBridge && ty >= 2 && ty <= 5) {
|
||||
// Wooden plank floor (style 4, prop1 194) spans continuously from tx=0 to tx=15
|
||||
cell.floors = [{ style: 4, sequence: 1, prop1: 194, hidden: false }]
|
||||
} else {
|
||||
// Water floor (style 2, prop1 2)
|
||||
cell.floors = [{ style: 2, sequence: (tx + ty) % 4, prop1: 2, hidden: false }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isBridge) {
|
||||
placedBlocks.push({ bx: upperCol, by, type: 'bridge', name: 'Bridge.ds1' })
|
||||
placedBlocks.push({ bx: lowerCol, by, type: 'bridge', name: 'Bridge.ds1' })
|
||||
} else {
|
||||
placedBlocks.push({ bx: upperCol, by, type: 'upper', name: 'URiverC.ds1' })
|
||||
placedBlocks.push({ bx: lowerCol, by, type: 'lower', name: 'LRiverC.ds1' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
placedBlocks,
|
||||
suppressedBorderBlocks,
|
||||
...(bridgeRow !== undefined ? { bridgeRow } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function stampSubDs1(canvas: CanvasLike, ds1: Ds1, originX: number, originY: number): void {
|
||||
for (let y = 0; y < ds1.height; y += 1) {
|
||||
const cy = originY + y
|
||||
if (cy >= canvas.height) break
|
||||
const targetRow = canvas.cells[cy]
|
||||
const sourceRow = ds1.cells[y]
|
||||
if (!targetRow || !sourceRow) continue
|
||||
|
||||
for (let x = 0; x < ds1.width; x += 1) {
|
||||
const cx = originX + x
|
||||
if (cx >= canvas.width) break
|
||||
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
|
||||
const sourceCell = sourceRow[x]
|
||||
if (!targetCell || !sourceCell) continue
|
||||
|
||||
for (let i = 0; i < sourceCell.floors.length; i += 1) {
|
||||
const floor = sourceCell.floors[i]
|
||||
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
|
||||
if (i < targetCell.floors.length) {
|
||||
targetCell.floors[i] = { ...floor }
|
||||
} else {
|
||||
targetCell.floors.push({ ...floor })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < sourceCell.walls.length; i += 1) {
|
||||
const wall = sourceCell.walls[i]
|
||||
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
|
||||
if (i < targetCell.walls.length) {
|
||||
targetCell.walls[i] = { ...wall }
|
||||
} else {
|
||||
targetCell.walls.push({ ...wall })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c orthogonal dirt road graph and rasterizer.
|
||||
*
|
||||
* Implements:
|
||||
* - DRLGOUTDOORS_SpawnAct1DirtPaths (D2Common/src/Drlg/DrlgOutdoors.cpp:999)
|
||||
* - DRLG_OUTDOORS_GenerateDirtPath (D2Common/src/Drlg/DrlgOutdoors.cpp:910)
|
||||
* - Width-2 Bresenham path rasterizer
|
||||
* - 256-byte byte_6FDCF958 auto-tile LUT
|
||||
*/
|
||||
|
||||
import type { CanvasLike, MutableDs1Cell } from './wilderness-river.ts'
|
||||
import {
|
||||
DIRT_PATH_TILE_LUT,
|
||||
NEIGHBOR_OFFSETS,
|
||||
type D2LandmarkPreset,
|
||||
} from './wilderness-tables.ts'
|
||||
|
||||
export interface RoadPlanningOptions {
|
||||
readonly widthTiles: number
|
||||
readonly heightTiles: number
|
||||
readonly landmarks: readonly D2LandmarkPreset[]
|
||||
readonly bridgeRow?: number | undefined
|
||||
readonly riverColumn?: number | undefined
|
||||
}
|
||||
|
||||
export interface DirtRoadResult {
|
||||
readonly roadCells: number
|
||||
readonly roadSegments: number
|
||||
readonly dirtPathGrid: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects town gate, den of evil, and level exits via an orthogonal width-2 dirt road network.
|
||||
*/
|
||||
export function DRLGOUTDOORS_SpawnAct1DirtPaths(
|
||||
canvas: CanvasLike,
|
||||
options: RoadPlanningOptions,
|
||||
): DirtRoadResult {
|
||||
const { widthTiles, heightTiles, landmarks, bridgeRow, riverColumn } = options
|
||||
const dirtGrid = new Uint8Array(widthTiles * heightTiles)
|
||||
|
||||
const town = landmarks.find(l => /Town/i.test(l.name))
|
||||
const den = landmarks.find(l => /Den|DOE/i.test(l.name))
|
||||
const exitCol = riverColumn !== undefined ? riverColumn * 8 : widthTiles - 16
|
||||
const exitY = bridgeRow !== undefined ? bridgeRow * 8 + 4 : Math.floor(heightTiles / 2)
|
||||
|
||||
// Endpoints:
|
||||
const townPt = town?.roadExitTile ?? { x: Math.floor(widthTiles / 2), y: 4 }
|
||||
const denPt = den?.roadExitTile ?? { x: 16, y: Math.floor(heightTiles / 2) }
|
||||
const exitPt = { x: exitCol, y: exitY }
|
||||
|
||||
// Intermediate hub in the central clear area:
|
||||
const hubPt = {
|
||||
x: Math.round((townPt.x + exitPt.x) / 2),
|
||||
y: Math.round((townPt.y + exitPt.y) / 2),
|
||||
}
|
||||
|
||||
let roadSegments = 0
|
||||
|
||||
// Draw orthogonal segments: Town -> Hub -> Exit, and Den -> Hub
|
||||
const drawOrthogonalLine = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
// Width-2 Bresenham / orthogonal steps
|
||||
// Step 1: horizontal to x1
|
||||
const xStep = x1 >= x0 ? 1 : -1
|
||||
for (let x = x0; x !== x1 + xStep; x += xStep) {
|
||||
stampRoadWidth2(dirtGrid, widthTiles, heightTiles, x, y0)
|
||||
}
|
||||
// Step 2: vertical to y1
|
||||
const yStep = y1 >= y0 ? 1 : -1
|
||||
for (let y = y0; y !== y1 + yStep; y += yStep) {
|
||||
stampRoadWidth2(dirtGrid, widthTiles, heightTiles, x1, y)
|
||||
}
|
||||
roadSegments += 2
|
||||
}
|
||||
|
||||
// 1. Town to Hub
|
||||
drawOrthogonalLine(townPt.x, townPt.y, hubPt.x, hubPt.y)
|
||||
|
||||
// 2. Hub to Level Exit / Bridge
|
||||
drawOrthogonalLine(hubPt.x, hubPt.y, exitPt.x, exitPt.y)
|
||||
|
||||
// 3. Den to Hub
|
||||
drawOrthogonalLine(denPt.x, denPt.y, hubPt.x, hubPt.y)
|
||||
|
||||
// Auto-tile pass using byte_6FDCF958 LUT
|
||||
let roadCells = 0
|
||||
|
||||
for (let y = 0; y < heightTiles; y += 1) {
|
||||
for (let x = 0; x < widthTiles; x += 1) {
|
||||
const idx = y * widthTiles + x
|
||||
if (dirtGrid[idx] !== 1) continue
|
||||
|
||||
roadCells += 1
|
||||
|
||||
// Compute 8-neighbor bitmask in authentic D2 bit order
|
||||
let mask = 0
|
||||
for (const off of NEIGHBOR_OFFSETS) {
|
||||
const nx = x + off.dx
|
||||
const ny = y + off.dy
|
||||
if (nx >= 0 && nx < widthTiles && ny >= 0 && ny < heightTiles) {
|
||||
if (dirtGrid[ny * widthTiles + nx] === 1) {
|
||||
mask |= off.mask
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query LUT
|
||||
let seq = DIRT_PATH_TILE_LUT[mask] ?? 0
|
||||
if (seq === 0) seq = 1 // Fallback to straight road tile
|
||||
|
||||
const cell = canvas.cells[y]?.[x] as unknown as MutableDs1Cell | undefined
|
||||
if (cell) {
|
||||
// Authentic Act 1 wilderness dirt road: style 0, sequence from byte_6FDCF958 LUT, prop1 194
|
||||
cell.floors = [
|
||||
{ style: 0, sequence: seq, prop1: 194, hidden: false },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roadCells,
|
||||
roadSegments,
|
||||
dirtPathGrid: dirtGrid,
|
||||
}
|
||||
}
|
||||
|
||||
function stampRoadWidth2(
|
||||
grid: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
for (let dy = 0; dy <= 1; dy += 1) {
|
||||
for (let dx = 0; dx <= 1; dx += 1) {
|
||||
const px = x + dx
|
||||
const py = y + dy
|
||||
if (px >= 0 && px < width && py >= 0 && py < height) {
|
||||
grid[py * width + px] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c lookup tables and interface contracts for outdoor wilderness generation.
|
||||
*
|
||||
* Ground Truth Sources:
|
||||
* - D2Common/src/Drlg/DrlgOutPlace.cpp (stru_6FDD06C0, dword_6FDD05C0)
|
||||
* - D2Common/src/Drlg/DrlgOutdoors.cpp (byte_6FDCF958)
|
||||
* - D2Common/src/Drlg/DrlgOutWild.cpp (stru_6FDD0CA0)
|
||||
* - D2Common/src/Drlg/DrlgTileSub.cpp (substitution groups & border rules)
|
||||
*/
|
||||
|
||||
export const TILES_PER_BLOCK = 8
|
||||
|
||||
// Direction constants
|
||||
export const ALTDIR_WEST = 0
|
||||
export const ALTDIR_NORTH = 1
|
||||
export const ALTDIR_EAST = 2
|
||||
export const ALTDIR_SOUTH = 3
|
||||
|
||||
// pOutdoors->dwFlags
|
||||
export const OUTDOOR_BRIDGE = 0x0004
|
||||
export const OUTDOOR_RIVER_OTHER = 0x0008
|
||||
export const OUTDOOR_RIVER = 0x0010
|
||||
export const OUTDOOR_CLIFF_CAVES = 0x0020
|
||||
export const OUTDOOR_PRESET_PLACED = 0x0040
|
||||
export const OUTDOOR_SOUTHWEST = 0x0080
|
||||
export const OUTDOOR_NORTHWEST = 0x0100
|
||||
export const OUTDOOR_SOUTHEAST = 0x0200
|
||||
export const OUTDOOR_NORTHEAST = 0x0400
|
||||
|
||||
// 8-neighbor bitmask constants
|
||||
export const NEIGHBOR_MASK_NE = 0x80
|
||||
export const NEIGHBOR_MASK_E = 0x40
|
||||
export const NEIGHBOR_MASK_SE = 0x20
|
||||
export const NEIGHBOR_MASK_N = 0x10
|
||||
export const NEIGHBOR_MASK_S = 0x08
|
||||
export const NEIGHBOR_MASK_NW = 0x04
|
||||
export const NEIGHBOR_MASK_W = 0x02
|
||||
export const NEIGHBOR_MASK_SW = 0x01
|
||||
|
||||
export interface NeighborOffset {
|
||||
readonly dx: number
|
||||
readonly dy: number
|
||||
readonly mask: number
|
||||
}
|
||||
|
||||
export const NEIGHBOR_OFFSETS: readonly NeighborOffset[] = [
|
||||
{ dx: 1, dy: -1, mask: NEIGHBOR_MASK_NE },
|
||||
{ dx: 1, dy: 0, mask: NEIGHBOR_MASK_E },
|
||||
{ dx: 1, dy: 1, mask: NEIGHBOR_MASK_SE },
|
||||
{ dx: 0, dy: -1, mask: NEIGHBOR_MASK_N },
|
||||
{ dx: 0, dy: 1, mask: NEIGHBOR_MASK_S },
|
||||
{ dx: -1, dy: -1, mask: NEIGHBOR_MASK_NW },
|
||||
{ dx: -1, dy: 0, mask: NEIGHBOR_MASK_W },
|
||||
{ dx: -1, dy: 1, mask: NEIGHBOR_MASK_SW },
|
||||
]
|
||||
|
||||
/**
|
||||
* stru_6FDD06C0: Connection flags mapping table for Act 1 outdoor levels.
|
||||
* (DrlgOutPlace.cpp:1696)
|
||||
*/
|
||||
export interface OutdoorFlagTableEntry {
|
||||
readonly nLevelId: number
|
||||
readonly nExcludedLevel1: number
|
||||
readonly nExcludedLevel2: number
|
||||
readonly nRand: number
|
||||
readonly nNextRand: number
|
||||
readonly nFlags: number
|
||||
}
|
||||
|
||||
export const OUTDOOR_FLAG_TABLE: readonly OutdoorFlagTableEntry[] = [
|
||||
{ nLevelId: 0, nExcludedLevel1: 2, nExcludedLevel2: 3, nRand: 1, nNextRand: 0, nFlags: 0x04 },
|
||||
{ nLevelId: 0, nExcludedLevel1: 2, nExcludedLevel2: 3, nRand: 2, nNextRand: 3, nFlags: 0x04 },
|
||||
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 2, nNextRand: 1, nFlags: 0x08 },
|
||||
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 3, nNextRand: 0, nFlags: 0x08 },
|
||||
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 1, nNextRand: 1, nFlags: 0x10 },
|
||||
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 3, nNextRand: 3, nFlags: 0x10 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 0, nNextRand: 0, nFlags: 0x08 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 2, nNextRand: 2, nFlags: 0x08 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 3, nNextRand: 0, nFlags: 0x08 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 3, nNextRand: 2, nFlags: 0x08 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 0, nNextRand: 1, nFlags: 0x400 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 1, nNextRand: 1, nFlags: 0x400 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 2, nNextRand: 1, nFlags: 0x200 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 2, nNextRand: 2, nFlags: 0x80 },
|
||||
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 3, nNextRand: 2, nFlags: 0x100 },
|
||||
]
|
||||
|
||||
/**
|
||||
* dword_6FDD05C0: Act 1 Rogue Encampment connection validity lookup table.
|
||||
* (DrlgOutPlace.cpp:1538)
|
||||
*/
|
||||
export const VALIDATION_TABLE_ACT1: readonly number[] = [
|
||||
1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0,
|
||||
1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1,
|
||||
]
|
||||
|
||||
/**
|
||||
* stru_6FDD0CA0: River upper & lower file variant mapping table.
|
||||
* (DrlgOutWild.cpp:243)
|
||||
*/
|
||||
export const RIVER_PRESET_LUT = [
|
||||
{ upperFile: 2, lowerFile: 2 },
|
||||
{ upperFile: 0, lowerFile: 3 },
|
||||
{ upperFile: 1, lowerFile: 1 },
|
||||
{ upperFile: 3, lowerFile: 0 },
|
||||
{ upperFile: 0, lowerFile: 2 },
|
||||
{ upperFile: 0, lowerFile: 1 },
|
||||
{ upperFile: 1, lowerFile: 0 },
|
||||
{ upperFile: 2, lowerFile: 0 },
|
||||
{ upperFile: 2, lowerFile: 3 },
|
||||
{ upperFile: 1, lowerFile: 3 },
|
||||
{ upperFile: 3, lowerFile: 1 },
|
||||
{ upperFile: 3, lowerFile: 2 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* byte_6FDCF958: Authentic 256-byte D2 dirt path auto-tile lookup table.
|
||||
* (DrlgOutdoors.cpp:874)
|
||||
*/
|
||||
export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
|
||||
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
|
||||
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
|
||||
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
|
||||
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
|
||||
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
|
||||
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
|
||||
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
|
||||
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
|
||||
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
|
||||
0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x2B,
|
||||
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27,
|
||||
0x09, 0x09, 0x02, 0x2B, 0x09, 0x09, 0x2C, 0x1A,
|
||||
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
|
||||
0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
|
||||
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27,
|
||||
0x2A, 0x2A, 0x2E, 0x2A, 0x2A, 0x2A, 0x21, 0x1F,
|
||||
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
|
||||
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
|
||||
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
|
||||
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x23, 0x14,
|
||||
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
|
||||
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
|
||||
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
|
||||
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
|
||||
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
|
||||
0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x25,
|
||||
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16,
|
||||
0x24, 0x24, 0x2D, 0x22, 0x24, 0x24, 0x1C, 0x1D,
|
||||
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
|
||||
0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
|
||||
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16,
|
||||
0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01,
|
||||
])
|
||||
|
||||
/**
|
||||
* Pre-decoded substitution group metadata directly from Diablo II v1.13c DS1 files.
|
||||
*/
|
||||
export interface DrlgSubstGroupDef {
|
||||
readonly nPosX: number
|
||||
readonly nPosY: number
|
||||
readonly nWidth: number
|
||||
readonly nHeight: number
|
||||
readonly field_14: number
|
||||
}
|
||||
|
||||
// BorderCliffs.ds1 (15 groups)
|
||||
export const BORDER_CLIFFS_GROUPS: readonly DrlgSubstGroupDef[] = [
|
||||
{ nPosX: 0, nPosY: 60, nWidth: 5, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 45, nWidth: 5, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 30, nWidth: 5, nHeight: 5, field_14: 7 },
|
||||
{ nPosX: 0, nPosY: 15, nWidth: 5, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 0, nWidth: 5, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 66, nWidth: 4, nHeight: 4, field_14: 3 },
|
||||
{ nPosX: 0, nPosY: 51, nWidth: 4, nHeight: 4, field_14: 3 },
|
||||
{ nPosX: 0, nPosY: 36, nWidth: 4, nHeight: 4, field_14: 3 },
|
||||
{ nPosX: 0, nPosY: 21, nWidth: 4, nHeight: 4, field_14: 3 },
|
||||
{ nPosX: 0, nPosY: 6, nWidth: 4, nHeight: 4, field_14: 3 },
|
||||
{ nPosX: 0, nPosY: 71, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 56, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 41, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 26, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 11, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
]
|
||||
|
||||
// BorderMiddle.ds1 (9 groups, generating Bord9..Bord12 2x3 interior closures)
|
||||
export const BORDER_MIDDLE_GROUPS: readonly DrlgSubstGroupDef[] = [
|
||||
{ nPosX: 0, nPosY: 7, nWidth: 6, nHeight: 6, field_14: 10 },
|
||||
{ nPosX: 0, nPosY: 0, nWidth: 6, nHeight: 6, field_14: 12 },
|
||||
{ nPosX: 0, nPosY: 33, nWidth: 5, nHeight: 6, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 26, nWidth: 5, nHeight: 6, field_14: 6 },
|
||||
{ nPosX: 0, nPosY: 20, nWidth: 6, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 14, nWidth: 6, nHeight: 5, field_14: 6 },
|
||||
{ nPosX: 0, nPosY: 45, nWidth: 4, nHeight: 5, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 40, nWidth: 5, nHeight: 4, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 51, nWidth: 4, nHeight: 4, field_14: 1 },
|
||||
]
|
||||
|
||||
// BorderCorner.ds1 (8 groups)
|
||||
export const BORDER_CORNER_GROUPS: readonly DrlgSubstGroupDef[] = [
|
||||
{ nPosX: 0, nPosY: 15, nWidth: 4, nHeight: 4, field_14: 2 },
|
||||
{ nPosX: 0, nPosY: 10, nWidth: 4, nHeight: 4, field_14: 2 },
|
||||
{ nPosX: 0, nPosY: 5, nWidth: 4, nHeight: 4, field_14: 2 },
|
||||
{ nPosX: 0, nPosY: 0, nWidth: 4, nHeight: 4, field_14: 2 },
|
||||
{ nPosX: 0, nPosY: 32, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 28, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 24, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 20, nWidth: 3, nHeight: 3, field_14: 1 },
|
||||
]
|
||||
|
||||
// BorderBorder.ds1 (20 groups, generating Bord5..Bord8 outer peninsulas)
|
||||
export const BORDER_BORDER_GROUPS: readonly DrlgSubstGroupDef[] = [
|
||||
{ nPosX: 0, nPosY: 80, nWidth: 5, nHeight: 5, field_14: 10 },
|
||||
{ nPosX: 0, nPosY: 52, nWidth: 5, nHeight: 5, field_14: 10 },
|
||||
{ nPosX: 0, nPosY: 28, nWidth: 5, nHeight: 5, field_14: 10 },
|
||||
{ nPosX: 0, nPosY: 0, nWidth: 5, nHeight: 5, field_14: 10 },
|
||||
{ nPosX: 0, nPosY: 86, nWidth: 5, nHeight: 4, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 58, nWidth: 4, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 34, nWidth: 5, nHeight: 4, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 6, nWidth: 4, nHeight: 5, field_14: 5 },
|
||||
{ nPosX: 0, nPosY: 95, nWidth: 4, nHeight: 4, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 70, nWidth: 4, nHeight: 4, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 43, nWidth: 4, nHeight: 4, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 18, nWidth: 4, nHeight: 4, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 91, nWidth: 5, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 64, nWidth: 3, nHeight: 5, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 39, nWidth: 5, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 12, nWidth: 3, nHeight: 5, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 100, nWidth: 4, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 75, nWidth: 3, nHeight: 4, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 48, nWidth: 4, nHeight: 3, field_14: 1 },
|
||||
{ nPosX: 0, nPosY: 23, nWidth: 3, nHeight: 4, field_14: 1 },
|
||||
]
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Interface Contracts
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export interface D2OutdoorLevelContext {
|
||||
readonly levelId: number
|
||||
readonly levelName: string
|
||||
readonly seed: number
|
||||
readonly gridWidth: number // In blocks (8x8 tiles per block), e.g. 12 or 7 for Blood Moor
|
||||
readonly gridHeight: number // In blocks, e.g. 7 or 12 for Blood Moor
|
||||
readonly width: number // In tiles, gridWidth * 8 (e.g. 96 or 56)
|
||||
readonly height: number // In tiles, gridHeight * 8 (e.g. 56 or 96)
|
||||
readonly dwFlags: number // Bitflags: OUTDOOR_SOUTHWEST, OUTDOOR_NORTHWEST, OUTDOOR_BRIDGE, OUTDOOR_RIVER_OTHER
|
||||
readonly hasRiver: boolean
|
||||
readonly riverColumn: number // gridWidth - 2 if hasRiver
|
||||
readonly origin: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export interface D2OutdoorRoomGrid {
|
||||
readonly gridWidth: number
|
||||
readonly gridHeight: number
|
||||
readonly rooms: (D2OutdoorRoom | null)[][] // [by][bx]
|
||||
readonly riverBlocks: Array<{ bx: number; by: number; type: 'upper' | 'lower' | 'bridge_u' | 'bridge_l'; ds1: string }>
|
||||
readonly borderOverrides: Map<string, string> // "bx,by" -> ds1 name
|
||||
}
|
||||
|
||||
export interface D2OutdoorRoom {
|
||||
readonly bx: number
|
||||
readonly by: number
|
||||
readonly ds1Name?: string
|
||||
readonly flags?: number
|
||||
}
|
||||
|
||||
export interface D2LandmarkPreset {
|
||||
readonly name: string
|
||||
readonly ds1Path: string
|
||||
readonly gridX: number // In blocks
|
||||
readonly gridY: number
|
||||
readonly tileX: number // gridX * 8
|
||||
readonly tileY: number
|
||||
readonly widthTiles: number
|
||||
readonly heightTiles: number
|
||||
readonly roadExitTile?: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export interface D2PathVertexGraph {
|
||||
readonly vertices: Array<{ readonly x: number; readonly y: number }>
|
||||
readonly edges: Array<[number, number]>
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* Authentic D2MOO v1.13c wilderness level linking & topology solver.
|
||||
*
|
||||
* Implements:
|
||||
* - DRLGOUTPLACE_CreateLevelConnections (D2Common/src/Drlg/DrlgOutPlace.cpp)
|
||||
* - Dynamic level dimensions (e.g. Blood Moor 96x56 horizontal vs 56x96 vertical)
|
||||
* - Orientation and flags calculation (OUTDOOR_SOUTHWEST, OUTDOOR_NORTHWEST, OUTDOOR_BRIDGE, OUTDOOR_RIVER_OTHER)
|
||||
*/
|
||||
|
||||
import {
|
||||
TILES_PER_BLOCK,
|
||||
OUTDOOR_FLAG_TABLE,
|
||||
VALIDATION_TABLE_ACT1,
|
||||
type D2OutdoorLevelContext,
|
||||
} from './wilderness-tables.ts'
|
||||
|
||||
export interface Act1TopologyResult {
|
||||
readonly levelId: number
|
||||
readonly gridWidth: number
|
||||
readonly gridHeight: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly dwFlags: number
|
||||
readonly hasRiver: boolean
|
||||
readonly riverColumn: number
|
||||
readonly r2_0: number
|
||||
readonly r3_0: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Solves the Act 1 wilderness level connection topology and dynamic dimensions.
|
||||
* For Level 2 (Blood Moor), dynamically determines whether the map is 96x56 or 56x96
|
||||
* and computes authentic connection flags from D2MOO PRNG simulation.
|
||||
*/
|
||||
export function solveAct1WildernessTopology(seed: number, levelId: number = 2): D2OutdoorLevelContext {
|
||||
let low = seed >>> 0
|
||||
let high = 666
|
||||
|
||||
const roll = (): number => {
|
||||
const lSeed = BigInt(high) + 0x6AC690C5n * BigInt(low)
|
||||
low = Number(lSeed & 0xFFFFFFFFn) >>> 0
|
||||
high = Number((lSeed >> 32n) & 0xFFFFFFFFn) >>> 0
|
||||
return low
|
||||
}
|
||||
|
||||
// 1. DRLG_AllocDrlg: rolls initial start seed
|
||||
roll()
|
||||
|
||||
// 2. sub_6FD823C0 iteration 0: Stony Field (Level 4) -> fixed offsets, 0 rolls
|
||||
|
||||
// 3. sub_6FD823C0 iteration 1: Cold Plains (Level 3) -> 1 roll
|
||||
roll()
|
||||
|
||||
// 4. sub_6FD823C0 iteration 2: Blood Moor (Level 2) -> 2 rolls
|
||||
const r2_0 = roll() & 3
|
||||
const r2_2 = roll() & 1
|
||||
|
||||
const width = (r2_0 % 2 !== 0) ? 96 : 56
|
||||
const height = (r2_0 % 2 !== 0) ? 56 : 96
|
||||
const gridWidth = width / TILES_PER_BLOCK
|
||||
const gridHeight = height / TILES_PER_BLOCK
|
||||
|
||||
// 5. sub_6FD823C0 iteration 3: Rogue Encampment (Level 1) -> 2 rolls
|
||||
let r3_0 = roll() & 3
|
||||
let r3_2 = roll() & 1
|
||||
|
||||
// Authentic D2MOO sub_6FD81720 state cycle with sub_6FD82050 validation lookup
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const idx = r3_0 + 4 * (r3_2 + 2 * (r2_0 + 4 * r2_2))
|
||||
if (VALIDATION_TABLE_ACT1[idx] === 1) break
|
||||
const nextR3_0 = (r3_2 + r3_0) % 4
|
||||
const nextR3_2 = (r3_2 + 1) % 2
|
||||
r3_0 = nextR3_0
|
||||
r3_2 = nextR3_2
|
||||
}
|
||||
|
||||
// 6. sub_6FD82360: evaluate connection flags mapping from stru_6FDD06C0
|
||||
let dwFlags = 0
|
||||
for (const entry of OUTDOOR_FLAG_TABLE) {
|
||||
if (entry.nLevelId === 2 || entry.nLevelId === 0) {
|
||||
if (entry.nLevelId !== entry.nExcludedLevel1 && entry.nLevelId !== entry.nExcludedLevel2) {
|
||||
if (entry.nRand === r2_0 && entry.nNextRand === r3_0) {
|
||||
dwFlags |= entry.nFlags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. DrlgOutWild.cpp:113: River is spawned if dwFlags & (OUTDOOR_RIVER_OTHER 0x08 | OUTDOOR_BRIDGE 0x04)
|
||||
const hasRiver = (dwFlags & 0x0C) !== 0
|
||||
const riverColumn = gridWidth - 2
|
||||
|
||||
return {
|
||||
levelId,
|
||||
levelName: levelId === 2 ? 'Blood Moor' : 'Act 1 - Wilderness',
|
||||
seed,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
width,
|
||||
height,
|
||||
dwFlags,
|
||||
hasRiver,
|
||||
riverColumn,
|
||||
origin: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
|
@ -181,11 +181,17 @@ export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [
|
|||
// Act 1. 1 Rogue Encampment, 2 Blood Moor, 3 Cold Plains, 4 Stony Field,
|
||||
// 5 Dark Wood, 6 Black Marsh, 7 Tamoe Highland, 17 Burial Grounds,
|
||||
// 26 Monastery Gate.
|
||||
//
|
||||
// D2 lays Act 1 out as two separate groups (DrlgOutPlace.cpp
|
||||
// gAct1WildernessDrlgLink @ D2Common.0x6FDCFE40: 4, 3, 2, 1, 17 around the
|
||||
// Stony Field at its Levels.txt offset 1000,1000; gAct1MonasteryDrlgLink @
|
||||
// 0x6FDCFF30: 26, 7, 6, 5 around the Monastery Gate at 3000,1000). The Stony
|
||||
// Field and the Dark Wood never touch: they are joined only through the
|
||||
// Underground Passage (level 10, a warp on both sides).
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
[3, 17],
|
||||
[4, 5],
|
||||
[5, 6],
|
||||
[6, 7],
|
||||
[7, 26],
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ interface AnchorSpec {
|
|||
* Keyed by 0-based act index (0..4).
|
||||
*/
|
||||
export const ACT_ANCHORS: Readonly<Record<number, readonly AnchorSpec[]>> = {
|
||||
// Act 1: Cold Plains (Level 3) at (1000, 1000)
|
||||
0: [{ levelId: 3, offsetX: 1000, offsetY: 1000, sizeX: 80, sizeY: 80 }],
|
||||
// Act 1: Cold Plains (Level 3) at (1000, 1000); the Monastery Gate (Level 26) at its Levels.txt
|
||||
// offset (3000, 1000), anchoring the second level group (26, 7, 6, 5), which D2 places apart from the
|
||||
// first (DrlgOutPlace.cpp gAct1MonasteryDrlgLink @ D2Common.0x6FDCFF30).
|
||||
0: [
|
||||
{ levelId: 3, offsetX: 1000, offsetY: 1000, sizeX: 80, sizeY: 80 },
|
||||
{ levelId: 26, offsetX: 3000, offsetY: 1000, sizeX: 64, sizeY: 18 },
|
||||
],
|
||||
// Act 2: Lut Gholein (Level 40) at (1000, 1000), Canyon of Magi (Level 46) at (2500, 1000)
|
||||
1: [
|
||||
{ levelId: 40, offsetX: 1000, offsetY: 1000, sizeX: 56, sizeY: 56 },
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-link
|
|||
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
||||
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
||||
import { TownPortalSlot, WaypointNetwork, resolveTownPortalSubTile, townLevelForAct, type OpenPortal } from '../game/portal.ts'
|
||||
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
|
||||
import type { Side } from '../game/variants.ts'
|
||||
import { WorldVariants, WORLD_VARIANT_SEED, type ActLayout } from '../game/act-variants.ts'
|
||||
import { Minimap } from '../ui/minimap.ts'
|
||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||
import { HudManager } from '../ui/hud-manager.ts'
|
||||
|
|
@ -107,22 +108,6 @@ const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
|||
const DEFAULT_PACKS = ['/diablo2/packs', 'samples/d2-packs']
|
||||
/** Act count the page offers. */
|
||||
const ACT_COUNT = 5
|
||||
/**
|
||||
* Picks which baked variant of a generated level this world instance uses.
|
||||
*
|
||||
* Each generated level is baked three times and the world graph names none of
|
||||
* them, so one has to be chosen at load time. It must be chosen from the level
|
||||
* alone: keying it on the pair of levels, as an earlier version did, gave Cold
|
||||
* Plains a different layout depending on whether you walked in from the Blood
|
||||
* Moor or back from Stony Field, and left waypoints and town portals pointing
|
||||
* at coordinates from a map that was no longer loaded. One variant per level
|
||||
* per world is what the design calls for.
|
||||
*
|
||||
* A real game would derive this from the game seed; until there is one, a
|
||||
* constant keeps every session identical, which is also what the verification
|
||||
* scripts assume.
|
||||
*/
|
||||
const WORLD_VARIANT_SEED = 0x5eed_3000
|
||||
/** Walk speed in scene pixels per second (cells are 80×40). */
|
||||
const WALK_SPEED = 170
|
||||
/** Character collision box at the feet, in scene pixels. */
|
||||
|
|
@ -5276,6 +5261,8 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|||
interface PackIndex {
|
||||
readonly palettes?: Record<string, number[]>
|
||||
readonly levels: readonly PackIndexEntry[]
|
||||
/** DRLG-generated copies of an act, keyed by act number (see act-variants.ts). */
|
||||
readonly actLayouts?: Readonly<Record<string, readonly ActLayout[]>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -5296,6 +5283,10 @@ interface PackIndexEntry {
|
|||
readonly levelName: string
|
||||
readonly path: string
|
||||
readonly pages: number
|
||||
/** DS1 member (presets) or generator tag the map was baked from. */
|
||||
readonly ds1?: string
|
||||
/** DRLG act variant of a generated level (see act-variants.ts). */
|
||||
readonly actVariant?: number
|
||||
}
|
||||
|
||||
/** Object frame inside an object atlas page. */
|
||||
|
|
@ -5414,9 +5405,37 @@ async function getPackIndex(packBase: string): Promise<PackIndex | null> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-session choice of level copies, one chooser per pack (see act-variants.ts).
|
||||
*
|
||||
* It lives beside the index cache because it is part of the same world: every transition in the
|
||||
* session must agree on which generated copy of an act is loaded.
|
||||
*/
|
||||
const worldVariantsCache = new Map<string, WorldVariants<PackIndexEntry>>()
|
||||
|
||||
/**
|
||||
* The pack's copy chooser, created on first use.
|
||||
*
|
||||
* @param packBase - directory holding `index.json`.
|
||||
* @param index - the pack's already-fetched index.
|
||||
* @returns the chooser shared by every load from this pack.
|
||||
*/
|
||||
function worldVariantsFor(packBase: string, index: PackIndex): WorldVariants<PackIndexEntry> {
|
||||
let variants = worldVariantsCache.get(packBase)
|
||||
if (variants === undefined) {
|
||||
variants = new WorldVariants(index, WORLD_VARIANT_SEED)
|
||||
worldVariantsCache.set(packBase, variants)
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the runtime for a prebaked pack.
|
||||
*
|
||||
* An explicitly named copy (`?level=`) decides its act's generated variant, so walking on from it
|
||||
* stays inside the act it was generated with. Without one the act's town opens: in a DRLG-generated
|
||||
* act that is the town file the chosen copy of the act fixed (its gate faces the generated level).
|
||||
*
|
||||
* @param packBase - directory holding `index.json`.
|
||||
* @param act - act to open.
|
||||
* @param wantedLevel - `?level=` label, when the caller picked one.
|
||||
|
|
@ -5433,28 +5452,27 @@ async function loadPackRuntime(
|
|||
if (index === null) return null
|
||||
const forAct = index.levels.filter(entry => entry.act === act)
|
||||
if (forAct.length === 0) return null
|
||||
const entry = (wantedLevel === ''
|
||||
const variants = worldVariantsFor(packBase, index)
|
||||
const wanted = wantedLevel === ''
|
||||
? undefined
|
||||
: forAct.find(candidate => candidate.label === wantedLevel)
|
||||
?? forAct.find(candidate => candidate.slug === wantedLevel))
|
||||
?? getDefaultTownVariant(forAct, act)
|
||||
?? forAct.find(candidate => candidate.slug.includes('town'))
|
||||
?? forAct[0]!
|
||||
?? forAct.find(candidate => candidate.slug === wantedLevel)
|
||||
if (wanted !== undefined) variants.pin(wanted)
|
||||
const entry = wanted ?? variants.startEntry(act)
|
||||
if (entry === null) return null
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the runtime for one level, named by id rather than by label.
|
||||
*
|
||||
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
|
||||
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
|
||||
* on the connecting border orientation (`fromSide`) so that the gate and road
|
||||
* seamlessly align across the level boundary. If no directional requirement exists,
|
||||
* it picks deterministically via `variantSeed`.
|
||||
* The copy comes from the session's {@link WorldVariants}: a level of a DRLG-generated act uses the
|
||||
* act's chosen copy (the town uses the file that copy fixed); any other level with several baked
|
||||
* copies (Lut Gholein LutN/LutW, Cloister Courtyard N/E/W, ...) prefers the copy whose opening faces
|
||||
* the side the player arrives from, so the gate and road line up across the border.
|
||||
*
|
||||
* @param packBase - directory holding `index.json`.
|
||||
* @param levelId - `Levels.txt` `Id` of the wanted level.
|
||||
* @param variantSeed - picks between the variants deterministically.
|
||||
* @param viewport - current viewport dimensions.
|
||||
* @param charBases - character assets bases.
|
||||
* @param fromSide - the compass edge of the source level the player walked off of.
|
||||
|
|
@ -5463,7 +5481,6 @@ async function loadPackRuntime(
|
|||
async function loadRuntimeForLevel(
|
||||
packBase: string,
|
||||
levelId: number,
|
||||
variantSeed: number,
|
||||
viewport: { width: number; height: number },
|
||||
charBases: readonly string[],
|
||||
fromSide?: Side,
|
||||
|
|
@ -5472,21 +5489,13 @@ async function loadRuntimeForLevel(
|
|||
const index = await getPackIndex(packBase)
|
||||
if (index === null) return null
|
||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||||
if (candidates.length === 0) return null
|
||||
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
|
||||
let entry: PackIndexEntry | undefined
|
||||
if (requiredSide === undefined && preferredVariantLabel !== undefined) {
|
||||
entry = candidates.find(
|
||||
c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel,
|
||||
)
|
||||
}
|
||||
if (entry === undefined && requiredSide === undefined && isTownLevel(levelId)) {
|
||||
entry = getDefaultTownVariant(candidates, candidates[0]!.act)
|
||||
}
|
||||
if (entry === undefined) {
|
||||
entry = findMatchingVariant(candidates, requiredSide, variantSeed)
|
||||
}
|
||||
if (entry === undefined) return null
|
||||
const preferred = preferredVariantLabel !== undefined && fromSide === undefined
|
||||
? candidates.find(
|
||||
c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel,
|
||||
)
|
||||
: undefined
|
||||
const entry = preferred ?? worldVariantsFor(packBase, index).entryForLevel(levelId, fromSide)
|
||||
if (entry === null) return null
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
||||
|
|
@ -5538,7 +5547,16 @@ async function buildPackRuntime(
|
|||
}
|
||||
const floors = scene.floors.map(toDrawable)
|
||||
const WILDERNESS_LEVEL_IDS = new Set([2, 3, 4, 5, 6, 7, 17, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83, 104, 105, 106, 110, 111, 112, 117, 134])
|
||||
if (entry.levelId !== undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) {
|
||||
// The fill below covers floor gaps left by the older wilderness generator
|
||||
// (Acts 2-5). It must not touch scenes baked from the DRLG port (the entries
|
||||
// that carry an `actVariant`, i.e. the Act I outdoor levels). There, a cell
|
||||
// without a floor is a cell D2 leaves blank, and D2 draws blank cells as void:
|
||||
// the grid cells behind the tree borders that
|
||||
// DRLGOUTPLACE_SetBlankBorderGridCells (D2Common.0x6FD80C80) marks, and the
|
||||
// extra last DS1 column and row of the (width + 1) x (height + 1) canvas. The
|
||||
// bake blocks those cells in the collision map, so grass painted there would
|
||||
// show ground nobody can walk on.
|
||||
if (entry.levelId !== undefined && entry.actVariant === undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) {
|
||||
const covered = new Uint8Array(scene.cellsX * scene.cellsY)
|
||||
const freq = new Map<number, number>()
|
||||
for (let i = 0; i < scene.floors.length; i += 1) {
|
||||
|
|
@ -5701,8 +5719,9 @@ async function buildPackRuntime(
|
|||
gridWidth: scene.collision.width,
|
||||
}
|
||||
const spawn = scene.spawn === null || scene.spawn === undefined
|
||||
? (findIsoSpawn(grid) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 })
|
||||
? findIsoSpawn(grid)
|
||||
: { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 }
|
||||
if (spawn === null) throw new Error(`${entry.label}: the map has no walkable spawn`)
|
||||
|
||||
// The pack carries the act palette, which the character atlas needs; without it
|
||||
// the character simply stays a marker rather than rendering in wrong colours.
|
||||
|
|
@ -8197,8 +8216,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
const next = await loadRuntimeForLevel(
|
||||
runtime.base,
|
||||
toLevelId,
|
||||
// Per level, not per pair: see WORLD_VARIANT_SEED.
|
||||
WORLD_VARIANT_SEED + toLevelId,
|
||||
{ width: canvas.width, height: canvas.height },
|
||||
runtime.charBases,
|
||||
fromSide,
|
||||
|
|
@ -8235,16 +8252,15 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
}
|
||||
// The destination's own opening back to where we came from is the landing
|
||||
// spot; an override wins, because a waypoint or a portal lands somewhere
|
||||
// that no edge describes.
|
||||
// that no edge describes. Every walkable link has both ends, so a missing
|
||||
// way back is a broken world graph, never a reason to drop the player in
|
||||
// the middle of the map.
|
||||
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
||||
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
||||
const rawLanding = effectiveOverride
|
||||
?? (back === undefined
|
||||
? {
|
||||
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
||||
y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2),
|
||||
}
|
||||
: { x: back.arriveX, y: back.arriveY })
|
||||
if (effectiveOverride === undefined && back === undefined) {
|
||||
throw new Error(`level ${String(toLevelId)} has no link back to level ${String(fromLevelId)}`)
|
||||
}
|
||||
const rawLanding = effectiveOverride ?? { x: back!.arriveX, y: back!.arriveY }
|
||||
let landing = rawLanding
|
||||
const activeMouthInNext = portalSlot.mouthInLevel(next.levelId)
|
||||
if (
|
||||
|
|
@ -8306,7 +8322,17 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
return
|
||||
}
|
||||
|
||||
const chosenEntry = pool[Math.floor(Math.random() * pool.length)]!
|
||||
// The session's copy of the level, exactly as walking there would load it
|
||||
// (a random copy could belong to another generated variant of the act).
|
||||
const levelIds = [...new Set(pool.map(entry => entry.levelId))]
|
||||
const poolLevelId = levelIds.length === 1 ? levelIds[0] : undefined
|
||||
if (poolLevelId === undefined && pool.length !== 1) {
|
||||
throw new Error(`waypoint ${slug}: ${String(pool.length)} baked maps match and they do not name one level`)
|
||||
}
|
||||
const chosenEntry = poolLevelId === undefined
|
||||
? pool[0]!
|
||||
: worldVariantsFor(runtime.base, index).entryForLevel(poolLevelId)
|
||||
if (chosenEntry === null) throw new Error(`waypoint ${slug}: no baked copy of level ${String(poolLevelId)}`)
|
||||
const next = await buildPackRuntime(
|
||||
runtime.base,
|
||||
index,
|
||||
|
|
@ -8319,12 +8345,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
})
|
||||
|
||||
const wp = next.waypoints[0]
|
||||
const landing = wp !== undefined
|
||||
? { x: wp.arriveX, y: wp.arriveY }
|
||||
: {
|
||||
x: Math.floor((next.grid.cellsX * SUB_TILES_PER_TILE) / 2),
|
||||
y: Math.floor((next.grid.cellsY * SUB_TILES_PER_TILE) / 2),
|
||||
}
|
||||
if (wp === undefined) throw new Error(`${chosenEntry.label} has no waypoint to arrive at`)
|
||||
const landing = { x: wp.arriveX, y: wp.arriveY }
|
||||
|
||||
await swapLevel(next, landing)
|
||||
preloadRemaining(next, renderer)
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ describe('Adversarial Challenger M3 Gen2: World Placement & Graph Topology', ()
|
|||
|
||||
expect(failureList).toEqual([])
|
||||
expect(passedChecks).toBe(totalChecks)
|
||||
expect(totalChecks).toBe(2700) // 27 seamless pairs * 100 seeds
|
||||
expect(totalChecks).toBe(2600) // 26 seamless pairs * 100 seeds
|
||||
})
|
||||
|
||||
it('Property 2a: 0 interior overlaps between any pair of seamless levels across all 100 seeds (9,900 checks)', () => {
|
||||
|
|
|
|||
|
|
@ -86,14 +86,15 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', ()
|
|||
expect(meta.height).toBe(1929)
|
||||
})
|
||||
|
||||
it('verifies all 69 monster R8 atlases and metadata schemas', () => {
|
||||
it('verifies all 70 monster R8 atlases and metadata schemas', () => {
|
||||
expect(existsSync(indexJsonPath)).toBe(true)
|
||||
const index = JSON.parse(readFileSync(indexJsonPath, 'utf8'))
|
||||
|
||||
expect(index.schema).toBe(2)
|
||||
expect(index.character).toBe('char-so')
|
||||
expect(Array.isArray(index.entities)).toBe(true)
|
||||
expect(index.entities.length).toBe(69)
|
||||
// 69 + EC (Hell Bovine, Moo Moo Farm)
|
||||
expect(index.entities.length).toBe(70)
|
||||
|
||||
for (const entity of index.entities) {
|
||||
expect(entity.key).toBeDefined()
|
||||
|
|
|
|||
|
|
@ -38,14 +38,16 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
|
|||
expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true)
|
||||
const baselineHash = sha256File(ENTITIES_INDEX_PATH)
|
||||
|
||||
// Expected canonical SHA-256 for Schema 2 69-monster entity index (including vk-hth Valkyrie)
|
||||
expect(baselineHash).toBe('51da3628b0cb049f5f5bb1282a101cbe0c793f52ed5d91e45077cd91e93a4074')
|
||||
// Expected canonical SHA-256 for Schema 2 70-monster entity index (including vk-hth Valkyrie
|
||||
// and ec-hth Hell Bovine: MonStats.txt hellbovine Code=EC, level 39 Moo Moo Farm). Adding
|
||||
// ec-hth is the only change against the previous 69-monster index (51da3628…).
|
||||
expect(baselineHash).toBe('675757cffde277b24e2aec11b7440243654282cbb743424ef02ce1d253b41c66')
|
||||
|
||||
const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8')
|
||||
const parsed = JSON.parse(baselineContent)
|
||||
expect(parsed.schema).toBe(2)
|
||||
expect(parsed.character).toBe('char-so')
|
||||
expect(parsed.entities).toHaveLength(69)
|
||||
expect(parsed.entities).toHaveLength(70)
|
||||
|
||||
// Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present
|
||||
const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2']
|
||||
|
|
@ -149,9 +151,13 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
|
|||
expect(kindsCount.preset).toBe(65)
|
||||
expect(kindsCount.wilderness).toBe(90)
|
||||
expect(kindsCount.maze).toBe(210)
|
||||
expect(totalFloors).toBe(1020280)
|
||||
expect(totalWalls).toBe(231464)
|
||||
expect(totalPngs).toBe(1433)
|
||||
// Measured on the full re-bake after the Act I outdoor levels (2-7, 17, 39) moved to
|
||||
// the D2MOO DRLG port. For reference, a fresh bake of the previous generator gave
|
||||
// 1020690 floors, 231738 walls and 1401 PNGs; the old pins (1020280 / 231464 / 1433)
|
||||
// no longer matched any bake of the current code.
|
||||
expect(totalFloors).toBe(1008898)
|
||||
expect(totalWalls).toBe(237542)
|
||||
expect(totalPngs).toBe(1390)
|
||||
})
|
||||
|
||||
it('verifies world graph integrity and edge consistency with index.json', () => {
|
||||
|
|
@ -161,7 +167,9 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
|
|||
|
||||
expect(graph.version).toBe(1)
|
||||
expect(graph.levels.length).toBe(136)
|
||||
expect(graph.edges.length).toBe(286)
|
||||
// 284: the Stony Field <-> Dark Wood seam (4 <-> 5, one edge each way) was invented and
|
||||
// is gone; in D2 the two levels are joined only through the Underground Passage.
|
||||
expect(graph.edges.length).toBe(284)
|
||||
expect(graph.waypoints.length).toBe(39)
|
||||
|
||||
// All edge levels must exist in levels list
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@
|
|||
* - Verify generated wilderness levels across Acts 2..5 exhibit multi-sequence ground variation (not just sequence 0).
|
||||
* - Verify Act 3 Kurast (Levels 79, 80, 81, 82) ground tiles use strictly style: 1 and never style: 0.
|
||||
* - Verify Level 117 (Act 5 Barricade Snow) uses strictly style: 6 (snow) and never style: 0 (dirt).
|
||||
* 2. Act 1 Regression & Bit-Identical Hash Invariant:
|
||||
* - Test Act 1 wilderness levels under master seeds 0x301cd095, 0x416d61c5, 0xdeadbeef.
|
||||
* - Confirm all 24 baseline hashes match 100% bit-identically.
|
||||
* 3. Offline Pre-Bake Assets Invariance:
|
||||
* 2. Offline Pre-Bake Assets Invariance:
|
||||
* - Validate 136-level pre-baked pack scenes in samples/d2-packs/ with 0 missing tiles.
|
||||
*
|
||||
* Act I outdoor levels come from the DRLG port (src/game/drlg); their bit-exact parity with D2 is
|
||||
* tests/drlg-act1-oracle.test.ts, which replaced the old Act 1 baseline-hash invariant.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeAll } from 'vitest'
|
||||
|
|
@ -39,64 +39,8 @@ import {
|
|||
type WeightedGroundTile,
|
||||
} from '../src/game/wilderness.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* FNV-1a Canonical Map Hasher (identical to wilderness-seed-sensitivity)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
class Hasher {
|
||||
private hash = 2166136261
|
||||
|
||||
push(value: number): this {
|
||||
const word = Math.trunc(value) | 0
|
||||
for (let byte = 0; byte < 4; byte += 1) {
|
||||
this.hash ^= (word >>> (byte * 8)) & 0xff
|
||||
this.hash = Math.imul(this.hash, 16777619) >>> 0
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
get digest(): string {
|
||||
return (this.hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalHash(level: Ds1): string {
|
||||
const hasher = new Hasher()
|
||||
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
|
||||
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
|
||||
for (let y = 0; y < level.height; y += 1) {
|
||||
const row = level.cells[y]
|
||||
if (row === undefined) continue
|
||||
for (let x = 0; x < level.width; x += 1) {
|
||||
const cellRecord = row[x]
|
||||
if (cellRecord === undefined) continue
|
||||
for (const wall of cellRecord.walls) {
|
||||
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
|
||||
}
|
||||
for (const floor of cellRecord.floors) {
|
||||
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
|
||||
}
|
||||
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
|
||||
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
|
||||
}
|
||||
}
|
||||
for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
|
||||
return hasher.digest
|
||||
}
|
||||
|
||||
const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
||||
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
|
||||
4: ['378d6251', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
|
||||
6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Mock Helpers for Hermetic Ground Stress Testing
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
|
@ -202,15 +146,6 @@ describe('M4 Adversarial Challenge: Canonical Ground Pools Contract', () => {
|
|||
expect(pool.filter(t => t.style === 0).length).toBe(0)
|
||||
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).not.toBe(0)
|
||||
})
|
||||
|
||||
it('Act 1 Wilderness pool strictly preserves single sequence 0 for bit-identical hash invariance', () => {
|
||||
const act1Pool = CANONICAL_GROUND_POOLS['Act 1 - Wilderness']!
|
||||
expect(act1Pool).toBeDefined()
|
||||
expect(act1Pool.length).toBe(1)
|
||||
expect(act1Pool[0]!.style).toBe(0)
|
||||
expect(act1Pool[0]!.sequence).toBe(0)
|
||||
expect(act1Pool[0]!.weight).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('M4 Adversarial Challenge: fillGround Sampling & Statistical Variety', () => {
|
||||
|
|
@ -389,15 +324,6 @@ describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verifi
|
|||
}
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||
|
|
@ -416,7 +342,7 @@ describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verifi
|
|||
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
|
||||
const isBorder = /border|cliff/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
return pieces
|
||||
|
|
@ -494,22 +420,6 @@ describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verifi
|
|||
})
|
||||
}
|
||||
|
||||
it('Act 1: All 24 baseline hashes match 100% bit-identically across master test seeds', async () => {
|
||||
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
|
||||
|
||||
for (const levelId of act1Levels) {
|
||||
const expected = ACT1_BASELINE_HASHES[levelId]!
|
||||
const actual: string[] = []
|
||||
|
||||
for (const seed of TEST_SEEDS) {
|
||||
const result = await generateLevelForSeed(levelId, seed)
|
||||
actual.push(canonicalHash(result.level))
|
||||
}
|
||||
|
||||
expect(actual).toEqual(expected)
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
it('Act 3 Kurast (Levels 79, 80, 81, 82): ground tiles strictly use style 1 and NEVER style 0', async () => {
|
||||
const kurastLevels = [79, 80, 81, 82]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
/**
|
||||
* Act I outdoor DRLG: what the rest of the game relies on in the levels the D2MOO port (src/game/drlg)
|
||||
* generates, checked on more seeds than the oracle fixtures, on the maps the bake makes from them
|
||||
* (src/game/drlg/drlg-map.ts) and on the collision scenes it builds (src/game/d2map.ts).
|
||||
*
|
||||
* tests/drlg-act1-oracle.test.ts proves the port is D2MOO roll for roll on four seeds; this suite checks
|
||||
* invariants on any seed:
|
||||
* - sizes: the act layout gives every level its Levels.txt size (the Blood Moor either way round, as
|
||||
* DRLGOUTPLACE_CreateLevelConnections orients it);
|
||||
* - population: one waypoint on Cold Plains..Black Marsh and five shrine cells on Blood Moor..Tamoe
|
||||
* Highland (DrlgOutWild.cpp:212-220), the SuperUnique presets, Flavie at the camp gate;
|
||||
* - links: every seam lies on an edge the act layout shares with the neighbour and is mirrored by it,
|
||||
* and the seams and warps are exactly the world graph's (the bake's exactLinks check throws otherwise);
|
||||
* - reachability: in the bake's collision scene every seam opens onto the level's main walkable region,
|
||||
* every warp and waypoint can be triggered from it, the SuperUniques stand next to it and the spawn is
|
||||
* on it;
|
||||
* - determinism.
|
||||
*
|
||||
* It replaces the Act I suites of the deleted hand-written generator (wilderness-reachability-stress,
|
||||
* wilderness-stress-fuzz). Needs the 1.13c archives in samples/d2; skipped without them, like the other
|
||||
* MPQ suites (the oracle suite is the one that fails instead).
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
|
||||
import { parseTable } from '../src/game/acts.ts'
|
||||
import { buildIsoMapScene, subTileAt } from '../src/game/d2map.ts'
|
||||
import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts'
|
||||
import {
|
||||
act1OutdoorLevelIds,
|
||||
buildDrlgLevelMap,
|
||||
drlgLevelInputFromDump,
|
||||
type DrlgLevelInput,
|
||||
type DrlgLevelMap,
|
||||
} from '../src/game/drlg/drlg-map.ts'
|
||||
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
|
||||
import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts'
|
||||
import {
|
||||
findBorderOpening,
|
||||
findIsoSpawn,
|
||||
largestWalkableRegion,
|
||||
nearestWalkable,
|
||||
triggerableFrom,
|
||||
WARP_TRIGGER_SUBTILES,
|
||||
type BorderOpening,
|
||||
} from '../src/game/level-links.ts'
|
||||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||||
import { buildWorldGraph, edgesFrom, oppositeSide, parseLevelRows, type WorldGraph } from '../src/game/world-graph.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
|
||||
|
||||
/** The Act I levels Levels.txt generates with the outdoor DRLG (checked against the table below). */
|
||||
const LEVELS = [2, 3, 4, 5, 6, 7, 17, 39]
|
||||
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
|
||||
const LEVELS_TXT = 'data\\global\\excel\\Levels.txt'
|
||||
const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name)))
|
||||
|
||||
function lcgSeeds(start: number, count: number): number[] {
|
||||
const out: number[] = []
|
||||
let seed = start
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
|
||||
out.push(seed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The game seeds the bake generates its three Act I copies from (scripts/pack-act-assets.ts actGameSeed). */
|
||||
const BAKE_SEEDS = [0x5eed0100, 0x5eed0101, 0x5eed0102]
|
||||
/** Fully generated and activated: the oracle fixture seed, the bake's seeds and pseudo-random ones. */
|
||||
const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x0badf00d, 12)]
|
||||
/** How many of SEEDS also get the (slow) collision-scene checks. */
|
||||
const SCENE_SEEDS = 6
|
||||
/** Act layout only, for the size sweep. */
|
||||
const LAYOUT_SEEDS = lcgSeeds(0x2468ace1, 256)
|
||||
|
||||
// DRLGOUTWILD_InitAct1OutdoorLevel (DrlgOutWild.cpp:212-220).
|
||||
/** LEVEL_COLDPLAINS..LEVEL_BLACKMARSH: DRLGOUTDOORS_SpawnAct12Waypoint. */
|
||||
const WAYPOINT_LEVELS = new Set([3, 4, 5, 6])
|
||||
/** LEVEL_BLOODMOOR..LEVEL_TAMOEHIGHLAND: DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5). */
|
||||
const SHRINE_LEVELS = new Set([2, 3, 4, 5, 6, 7])
|
||||
const SHRINES_PER_LEVEL = 5
|
||||
/** Grid-1 cell flags: waypoint 0x10000 / 0x20000 (DrlgOutdoors.cpp:520, :565), shrine 0x1000..0x8000 (:581). */
|
||||
const GRID1_WAYPOINT = 0x30000
|
||||
const GRID1_SHRINE = 0xf000
|
||||
/** Objects.txt SubClass bits. */
|
||||
const SUBCLASS_SHRINE = 0x01
|
||||
const SUBCLASS_WAYPOINT = 0x40
|
||||
/** D2PresetUnitStrc nUnitType. */
|
||||
const UNIT_MONSTER = 1
|
||||
const UNIT_OBJECT = 2
|
||||
/** MonStats.txt hcIdx 266 (navi): Flavie, part of the Blood Moor's camp transition preset. */
|
||||
const MONSTATS_FLAVIE = 266
|
||||
/** The fixed monsters each level's presets carry (drlg-map.ts landmarks). */
|
||||
const LANDMARKS: Readonly<Record<number, readonly string[]>> = {
|
||||
2: [],
|
||||
3: ['Bishibosh'],
|
||||
4: ['Rakanishu'],
|
||||
5: ['Treehead WoodFist'],
|
||||
6: [],
|
||||
7: [],
|
||||
17: ['Blood Raven'],
|
||||
39: ['The Cow King'],
|
||||
}
|
||||
/**
|
||||
* How close main-region ground must be to a SuperUnique's preset position, in sub-tiles. Some stand on
|
||||
* blocked ground (Rakanishu inside the Cairn Stones: up to 10 sub-tiles measured); D2Game places the
|
||||
* monster on free ground around its preset, and that ground must be reachable.
|
||||
*/
|
||||
const LANDMARK_REACH = 3 * SUB_TILES_PER_TILE
|
||||
|
||||
const numeric = (a: number, b: number): number => a - b
|
||||
|
||||
function hex(seed: number): string {
|
||||
return `0x${seed.toString(16).padStart(8, '0')}`
|
||||
}
|
||||
|
||||
/** The stretch two act-layout rectangles share along one side of `a`, or null if they do not touch there. */
|
||||
function sharedStretch(a: DumpCoord, b: DumpCoord, side: string): { lo: number; hi: number } | null {
|
||||
const [ax, ay, aw, ah] = a
|
||||
const [bx, by, bw, bh] = b
|
||||
let touching: boolean
|
||||
let lo: number
|
||||
let hi: number
|
||||
if (side === 'east' || side === 'west') {
|
||||
touching = side === 'east' ? ax + aw === bx : bx + bw === ax
|
||||
lo = Math.max(ay, by)
|
||||
hi = Math.min(ay + ah, by + bh)
|
||||
} else {
|
||||
touching = side === 'south' ? ay + ah === by : by + bh === ay
|
||||
lo = Math.max(ax, bx)
|
||||
hi = Math.min(ax + aw, bx + bw)
|
||||
}
|
||||
return touching && hi > lo ? { lo, hi } : null
|
||||
}
|
||||
|
||||
describe.skipIf(!hasMpq)('Act I outdoor DRLG port: level invariants over many seeds', () => {
|
||||
let data: DrlgMpqData
|
||||
let superUniqueIds: string[]
|
||||
let worldGraph: WorldGraph
|
||||
const dt1Cache = new Map<string, Dt1>()
|
||||
|
||||
beforeAll(async () => {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
|
||||
data = await loadDrlgMpqData(member => archives.read(member), 1)
|
||||
superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
|
||||
worldGraph = buildWorldGraph(parseLevelRows(parseTable(await archives.read(LEVELS_TXT))))
|
||||
}, 120_000)
|
||||
|
||||
function generate(seed: number): DrlgDump {
|
||||
return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false })
|
||||
}
|
||||
|
||||
function mapLevel(dump: DrlgDump, levelId: number): { input: DrlgLevelInput; map: DrlgLevelMap } {
|
||||
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
|
||||
const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
|
||||
return { input, map }
|
||||
}
|
||||
|
||||
function library(name: string): Dt1 {
|
||||
let lib = dt1Cache.get(name)
|
||||
if (lib === undefined) {
|
||||
lib = decodeDt1(data.source.readFile(name))
|
||||
dt1Cache.set(name, lib)
|
||||
}
|
||||
return lib
|
||||
}
|
||||
|
||||
it('takes the outdoor level list from Levels.txt', () => {
|
||||
expect(act1OutdoorLevelIds(data.tables)).toEqual(LEVELS)
|
||||
})
|
||||
|
||||
it(`sizes every level as D2 does on ${LAYOUT_SEEDS.length} act layouts`, () => {
|
||||
const env = createDrlgEnv(data.source, data.tables)
|
||||
const bloodMoor = new Set<string>()
|
||||
for (const seed of LAYOUT_SEEDS) {
|
||||
const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true })
|
||||
for (const levelId of LEVELS) {
|
||||
const level = act.find(l => l.id === levelId)
|
||||
expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined()
|
||||
const [, , width, height] = level!.coord
|
||||
if (levelId === 2) {
|
||||
// The Blood Moor's link callback (sub_6FD81950, DrlgOutPlace.cpp:1310; gAct1WildernessDrlgLink,
|
||||
// :33) replaces its Levels.txt size with 96x56 or 56x96, whichever way it runs from the camp.
|
||||
bloodMoor.add(`${width}x${height}`)
|
||||
continue
|
||||
}
|
||||
// Every other level keeps Levels.txt SizeX/SizeY (DRLGOUTPLACE_CreateLevelConnections).
|
||||
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
|
||||
expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
|
||||
}
|
||||
}
|
||||
expect([...bloodMoor].sort()).toEqual(['56x96', '96x56'])
|
||||
}, 120_000)
|
||||
|
||||
it('generates the same act from the same seed', () => {
|
||||
expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!)))
|
||||
}, 60_000)
|
||||
|
||||
it('generates a different level from every seed', () => {
|
||||
const env = createDrlgEnv(data.source, data.tables)
|
||||
const grids = new Map<number, Set<string>>(LEVELS.map(id => [id, new Set<string>()]))
|
||||
for (const seed of SEEDS) {
|
||||
const dump = dumpAct1(env, seed, LEVELS, { difficulty: 0, isolated: false, skipActivation: true })
|
||||
for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids))
|
||||
}
|
||||
for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length)
|
||||
}, 60_000)
|
||||
|
||||
SEEDS.forEach((seed, index) => {
|
||||
const withScene = index < SCENE_SEEDS
|
||||
it(`seed ${hex(seed)}: population and links${withScene ? ', and reachability in the collision scene' : ''}`, () => {
|
||||
const dump = generate(seed)
|
||||
const act = new Map(dump.act.map(l => [l.id, l]))
|
||||
const maps = new Map(LEVELS.map(levelId => [levelId, mapLevel(dump, levelId)]))
|
||||
|
||||
for (const levelId of LEVELS) {
|
||||
const what = `${hex(seed)} level ${levelId}`
|
||||
const { input, map } = maps.get(levelId)!
|
||||
|
||||
// ---- the canvas ----
|
||||
const layout = act.get(levelId)!.coord
|
||||
expect([map.coord.x, map.coord.y, map.coord.width, map.coord.height], what).toEqual(layout)
|
||||
expect(map.ds1.width, what).toBe(map.coord.width + 1)
|
||||
expect(map.ds1.height, what).toBe(map.coord.height + 1)
|
||||
|
||||
// ---- population ----
|
||||
const cells = dump.levels.find(l => l.id === levelId)!.levelGrid.grids[1]!.cells
|
||||
expect(cells.filter(c => (c & GRID1_WAYPOINT) !== 0).length, `${what}: waypoint cells`).toBe(WAYPOINT_LEVELS.has(levelId) ? 1 : 0)
|
||||
expect(cells.filter(c => (c & GRID1_SHRINE) !== 0).length, `${what}: shrine cells`)
|
||||
.toBe(SHRINE_LEVELS.has(levelId) ? SHRINES_PER_LEVEL : 0)
|
||||
const objects = input.units.filter(u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length)
|
||||
const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0)
|
||||
const shrines = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_SHRINE) !== 0)
|
||||
// The world graph registers the waypoint from Levels.txt `Waypoint`; the map must have it.
|
||||
const hasWaypoint = worldGraph.levels.get(levelId)!.waypoint !== 255
|
||||
expect(hasWaypoint, what).toBe(WAYPOINT_LEVELS.has(levelId))
|
||||
expect(waypoints, `${what}: waypoint objects`).toHaveLength(hasWaypoint ? 1 : 0)
|
||||
if (SHRINE_LEVELS.has(levelId)) {
|
||||
expect(shrines.length, `${what}: shrine objects`).toBeGreaterThanOrEqual(1)
|
||||
expect(shrines.length, `${what}: shrine objects`).toBeLessThanOrEqual(SHRINES_PER_LEVEL)
|
||||
} else {
|
||||
expect(shrines, `${what}: shrine objects`).toHaveLength(0)
|
||||
}
|
||||
const flavie = input.units.filter(u => u.type === UNIT_MONSTER && u.index === MONSTATS_FLAVIE)
|
||||
expect(flavie, `${what}: Flavie`).toHaveLength(levelId === 2 ? 1 : 0)
|
||||
expect(map.landmarks.map(l => l.id).sort(), `${what}: landmarks`).toEqual([...LANDMARKS[levelId]!].sort())
|
||||
for (const unit of [...input.units, ...map.landmarks]) {
|
||||
expect(unit.subX >= 0 && unit.subY >= 0 && unit.subX < map.ds1.width * SUB_TILES_PER_TILE
|
||||
&& unit.subY < map.ds1.height * SUB_TILES_PER_TILE, `${what}: unit at ${unit.subX},${unit.subY} is on the canvas`).toBe(true)
|
||||
}
|
||||
|
||||
// ---- links: exactly the world graph's, on shared edges, mirrored by outdoor neighbours ----
|
||||
const gates = map.entrances.filter(e => e.kind === 'gate')
|
||||
const warps = map.entrances.filter(e => e.kind === 'preset')
|
||||
const graphEdges = edgesFrom(worldGraph, levelId)
|
||||
expect(gates.map(e => e.toLevelId).sort(numeric), `${what}: seams`)
|
||||
.toEqual(graphEdges.filter(e => e.kind === 'seamless').map(e => e.to).sort(numeric))
|
||||
expect([...new Set(warps.map(e => e.toLevelId))].sort(numeric), `${what}: warps`)
|
||||
.toEqual(graphEdges.filter(e => e.kind === 'warp').map(e => e.to).sort(numeric))
|
||||
expect(warps.length, `${what}: one warp unit per warp`).toBe(new Set(warps.map(e => e.toLevelId)).size)
|
||||
for (const gate of gates) {
|
||||
const seam = `${what} seam to ${gate.toLevelId}`
|
||||
const neighbour = act.get(gate.toLevelId)
|
||||
expect(neighbour, `${seam}: neighbour in the act`).toBeDefined()
|
||||
const shared = sharedStretch(layout, neighbour!.coord, gate.side!)
|
||||
expect(shared, `${seam}: the ${gate.side} edge touches the neighbour`).not.toBeNull()
|
||||
const horizontal = gate.side === 'north' || gate.side === 'south'
|
||||
const offset = horizontal ? map.coord.x : map.coord.y
|
||||
const from = offset + gate.span!.from
|
||||
const to = offset + gate.span!.to
|
||||
expect(from >= shared!.lo && to <= shared!.hi && from < to, `${seam}: opening ${from}..${to} on the shared stretch`).toBe(true)
|
||||
const other = maps.get(gate.toLevelId)
|
||||
if (other === undefined) continue // the camp or the Monastery Gate: not an outdoor level
|
||||
const back = other.map.entrances.filter(e => e.kind === 'gate' && e.toLevelId === levelId)
|
||||
expect(back, `${seam}: mirrored`).toHaveLength(1)
|
||||
expect(back[0]!.side, `${seam}: mirrored`).toBe(oppositeSide(gate.side!))
|
||||
const backOffset = horizontal ? other.map.coord.x : other.map.coord.y
|
||||
expect([backOffset + back[0]!.span!.from, backOffset + back[0]!.span!.to], `${seam}: same opening from both sides`).toEqual([from, to])
|
||||
}
|
||||
|
||||
if (!withScene) continue
|
||||
|
||||
// ---- reachability, in the collision scene the bake builds (bakeDs1Variant / buildSceneLinks) ----
|
||||
const copy = BAKE_SEEDS.indexOf(seed)
|
||||
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), 0x5eed_1000 + levelId * 10 + (copy < 0 ? index % 3 : copy) + 1)
|
||||
const region = largestWalkableRegion(scene)
|
||||
const reachable = (x: number, y: number): boolean => region[y * scene.gridWidth + x] === 1
|
||||
let walkable = 0
|
||||
let inRegion = 0
|
||||
for (let at = 0; at < scene.blocked.length; at += 1) {
|
||||
if (scene.blocked[at] === 0) walkable += 1
|
||||
if (region[at] === 1) inRegion += 1
|
||||
}
|
||||
// One play area: the ground walled off from it is a sliver (at most ~1% measured).
|
||||
expect(inRegion / walkable, `${what}: share of the walkable ground in the main region`).toBeGreaterThan(0.95)
|
||||
const openings: BorderOpening[] = []
|
||||
for (const gate of gates) {
|
||||
const range = { from: gate.span!.from * SUB_TILES_PER_TILE, to: gate.span!.to * SUB_TILES_PER_TILE }
|
||||
const opening = findBorderOpening(scene, gate.side!, region, undefined, range)
|
||||
expect(opening, `${what}: walkable opening for the seam to ${gate.toLevelId}`).not.toBeNull()
|
||||
expect(opening!.inset, `${what}: the seam to ${gate.toLevelId} opens at the border`).toBeLessThanOrEqual(SUB_TILES_PER_TILE)
|
||||
expect(reachable(opening!.arriveX, opening!.arriveY), `${what}: arrival from ${gate.toLevelId}`).toBe(true)
|
||||
openings.push(opening!)
|
||||
}
|
||||
for (const warp of warps) {
|
||||
const { subX, subY } = warp.warp!
|
||||
expect(triggerableFrom(scene, region, subX, subY, WARP_TRIGGER_SUBTILES), `${what}: warp to ${warp.toLevelId} at ${subX},${subY}`).toBe(true)
|
||||
}
|
||||
for (const wp of waypoints) {
|
||||
expect(triggerableFrom(scene, region, wp.subX, wp.subY, WARP_TRIGGER_SUBTILES), `${what}: waypoint at ${wp.subX},${wp.subY}`).toBe(true)
|
||||
}
|
||||
for (const lm of map.landmarks) {
|
||||
expect(nearestWalkable(scene, lm.subX, lm.subY, LANDMARK_REACH, region), `${what}: ${lm.id} at ${lm.subX},${lm.subY}`).not.toBeNull()
|
||||
}
|
||||
// The spawn is on the main region: from the seams' arrival points, and with nothing to go by
|
||||
// (the Moo Moo Farm has no seam at all).
|
||||
for (const spawn of [findIsoSpawn(scene, { entrances: openings }), findIsoSpawn(scene)]) {
|
||||
expect(spawn, `${what}: spawn`).not.toBeNull()
|
||||
const at = subTileAt(scene, spawn!.x, spawn!.y)
|
||||
expect(at.inBounds && reachable(at.subX, at.subY), `${what}: spawn at ${at.subX},${at.subY} on the main region`).toBe(true)
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,32 @@
|
|||
/**
|
||||
* Act I outdoor DRLG: bit-exact parity of the TypeScript port (src/game/drlg) with the native D2MOO
|
||||
* oracle (tools/d2moo-oracle), and of the level maps the bake makes from it (src/game/drlg/drlg-map.ts).
|
||||
*
|
||||
* - The committed fixtures are the oracle's schema-3 dumps of four game seeds: 0x12345678 and the
|
||||
* three seeds the bake generates Act I from (0x5eed0100..0102). The port, fed from the MPQ data
|
||||
* exactly as the bake feeds it (scripts/lib/drlg-mpq-source.ts), must reproduce every stage: the act
|
||||
* layout, the level grids, the rooms, every room's activation and its tile lists.
|
||||
* - The oracle's dump and the port's dump then go through the same adapter; the canvases, DT1 library
|
||||
* lists, entrances and landmarks must be identical, so a baked map is the map the oracle generates.
|
||||
* - With the oracle binary built, one seed is re-run live and its RNG trace compared roll by roll.
|
||||
* - Anti-cheat guards keep fixture data, seed literals and level-name branches out of the port.
|
||||
*
|
||||
* The MPQ data (samples/d2) is required: without it the suite fails instead of skipping.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dumpLevelToDs1 } from '../src/game/drlg/adapter.ts'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||
import { parseTable } from '../src/game/acts.ts'
|
||||
import { DrlgTraceWriter, dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
|
||||
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
|
||||
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
|
||||
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
|
||||
import {
|
||||
firstDiff,
|
||||
firstTraceDivergence,
|
||||
|
|
@ -16,41 +37,83 @@ import {
|
|||
runOracle,
|
||||
} from '../scripts/lib/drlg-oracle.ts'
|
||||
|
||||
/** The Act I levels Levels.txt generates with the outdoor DRLG (checked against the table below). */
|
||||
const ACT1_OUTDOOR_LEVELS = [2, 3, 4, 5, 6, 7, 17, 39]
|
||||
const FIXTURE_DIR = resolve(import.meta.dirname, 'fixtures/d2moo-oracle')
|
||||
const FIXTURE_SEEDS = [0x12345678, 0x5eed0100, 0x5eed0101, 0x5eed0102]
|
||||
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
|
||||
|
||||
function hexSeed(seed: number): string {
|
||||
return seed.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
function readFixture(seed: number): DrlgDump {
|
||||
const doc = JSON.parse(gunzipSync(readFileSync(resolve(FIXTURE_DIR, `act1-seed-${hexSeed(seed)}.json.gz`))).toString('utf8')) as DrlgDump
|
||||
delete doc.oracle
|
||||
return doc
|
||||
}
|
||||
|
||||
/** What the bake takes from a mapped level: the canvas and everything it links or places. */
|
||||
function bakedPart(map: DrlgLevelMap): unknown {
|
||||
return { ds1: map.ds1, dt1Names: map.dt1Names, entrances: map.entrances, landmarks: map.landmarks, stats: map.stats }
|
||||
}
|
||||
|
||||
describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
|
||||
const source = fsDrlgSource(ORACLE_DATA)
|
||||
const tables = loadDrlgTables(source)
|
||||
let data: DrlgMpqData
|
||||
let superUniqueIds: string[]
|
||||
const dt1Cache = new Map<string, Dt1>()
|
||||
|
||||
beforeAll(async () => {
|
||||
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
|
||||
if (missing.length > 0) throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
|
||||
data = await loadDrlgMpqData(member => archives.read(member), 1)
|
||||
superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
|
||||
}, 120_000)
|
||||
|
||||
function mapLevel(dump: DrlgDump, levelId: number): DrlgLevelMap {
|
||||
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
|
||||
return buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
|
||||
}
|
||||
|
||||
it('takes the outdoor level list from Levels.txt', () => {
|
||||
expect(act1OutdoorLevelIds(data.tables)).toEqual(ACT1_OUTDOOR_LEVELS)
|
||||
})
|
||||
|
||||
for (const seed of FIXTURE_SEEDS) {
|
||||
const hex = seed.toString(16).padStart(8, '0')
|
||||
it(`matches committed oracle fixture for seed 0x${hex} across all stages including room activation and adapted Ds1 canvas`, () => {
|
||||
const fixturePath = resolve(FIXTURE_DIR, `act1-seed-${hex}.json.gz`)
|
||||
const expected = JSON.parse(gunzipSync(readFileSync(fixturePath)).toString('utf8')) as DrlgDump
|
||||
delete expected.oracle
|
||||
const actual = dumpAct1(createDrlgEnv(source, tables), seed, ACT1_OUTDOOR_LEVELS, {
|
||||
const hex = hexSeed(seed)
|
||||
it(`seed 0x${hex}: the MPQ-fed port reproduces every oracle stage, and both map to identical canvases`, () => {
|
||||
const expected = readFixture(seed)
|
||||
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT1_OUTDOOR_LEVELS, {
|
||||
difficulty: 0,
|
||||
isolated: false,
|
||||
})
|
||||
const diff = firstDiff(expected, actual, '$')
|
||||
if (diff) {
|
||||
throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
|
||||
}
|
||||
expect(diff).toBeNull()
|
||||
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
|
||||
|
||||
for (let i = 0; i < ACT1_OUTDOOR_LEVELS.length; i += 1) {
|
||||
const expectedCanvas = dumpLevelToDs1(expected.act, expected.levels[i]!, tables, { seed })
|
||||
const actualCanvas = dumpLevelToDs1(actual.act, actual.levels[i]!, tables, { seed })
|
||||
const canvasDiff = firstDiff(expectedCanvas, actualCanvas, `$.canvas[L${ACT1_OUTDOOR_LEVELS[i]}]`)
|
||||
if (canvasDiff) {
|
||||
throw new Error(`Canvas mismatch on seed 0x${hex}: ${formatDiff(canvasDiff)}`)
|
||||
}
|
||||
expect(actualCanvas.level.width).toBeGreaterThan(0)
|
||||
expect(actualCanvas.level.height).toBeGreaterThan(0)
|
||||
for (const levelId of ACT1_OUTDOOR_LEVELS) {
|
||||
const oracleMap = mapLevel(expected, levelId)
|
||||
const portMap = mapLevel(actual, levelId)
|
||||
const canvasDiff = firstDiff(bakedPart(oracleMap), bakedPart(portMap), `$.L${levelId}`)
|
||||
if (canvasDiff) throw new Error(`Canvas mismatch on seed 0x${hex}: ${formatDiff(canvasDiff)}`)
|
||||
|
||||
// The canvas is the level's tile rectangle (plus the closing row/column a DS1 has), every tile
|
||||
// D2 placed is on it, and every level link and warp of the level became an entrance.
|
||||
const { coord, ds1, stats } = portMap
|
||||
expect(ds1.width).toBe(coord.width + 1)
|
||||
expect(ds1.height).toBe(coord.height + 1)
|
||||
expect(stats.floors).toBeGreaterThan(0)
|
||||
expect(stats.walls).toBeGreaterThan(0)
|
||||
const level = actual.levels.find(l => l.id === levelId)!
|
||||
const orths = level.levelGrid!.roomData.length
|
||||
const warpUnits = level.activation.reduce((n, room) => n + room.mapTiles.units.filter(u => u[0] === 5).length, 0)
|
||||
expect(portMap.entrances.filter(e => e.kind === 'gate')).toHaveLength(orths)
|
||||
expect(portMap.entrances.filter(e => e.kind === 'preset')).toHaveLength(warpUnits)
|
||||
}
|
||||
})
|
||||
}, 60_000)
|
||||
}
|
||||
|
||||
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle + RNG trace on seed 0x12345678', () => {
|
||||
|
|
@ -64,8 +127,9 @@ describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
|
|||
trace: true,
|
||||
})
|
||||
delete oracleRun.doc.oracle
|
||||
const source = fsDrlgSource(ORACLE_DATA)
|
||||
const trace = new DrlgTraceWriter()
|
||||
const actual = dumpAct1(createDrlgEnv(source, tables), seed, ACT1_OUTDOOR_LEVELS, {
|
||||
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT1_OUTDOOR_LEVELS, {
|
||||
difficulty: 0,
|
||||
isolated: false,
|
||||
trace,
|
||||
|
|
@ -76,7 +140,7 @@ describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
|
|||
}
|
||||
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
|
||||
expect(traceDiff).toBeNull()
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
it('enforces anti-cheat guards on src/game/drlg/* (no fixture reads, no seed literal branches, no levelName conditionals)', () => {
|
||||
const drlgDir = resolve(import.meta.dirname, '../src/game/drlg')
|
||||
|
|
@ -88,9 +152,7 @@ describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
|
|||
expect(content, `${file} must not reference act1-seed-`).not.toContain('act1-seed-')
|
||||
expect(content, `${file} must not hardcode fixture seed 0x12345678`).not.toMatch(/0x12345678/i)
|
||||
expect(content, `${file} must not hardcode fixture seed 0x5eed`).not.toMatch(/0x5eed/i)
|
||||
if (file !== 'adapter.ts') {
|
||||
expect(content, `${file} must not branch on levelName`).not.toContain('levelName')
|
||||
}
|
||||
expect(content, `${file} must not branch on levelName`).not.toContain('levelName')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => {
|
|||
expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask")
|
||||
expect(exports).not.toContain("DRLGROOMTILE_ApplyTransitions")
|
||||
|
||||
// The genuine custom functions should remain exported as local names
|
||||
expect(typeof (wilderness as Record<string, unknown>).computeNeighborMask).toBe("function")
|
||||
expect(typeof (wilderness as Record<string, unknown>).applyGroundTransitions).toBe("function")
|
||||
// The invented 8-neighbour ground-transition pass (computeNeighborMask /
|
||||
// applyGroundTransitions) was removed with the Act I DRLG port: Act I
|
||||
// outdoor maps now come from the 1:1 D2MOO port in src/game/drlg.
|
||||
expect((wilderness as Record<string, unknown>).computeNeighborMask).toBeUndefined()
|
||||
expect((wilderness as Record<string, unknown>).applyGroundTransitions).toBeUndefined()
|
||||
})
|
||||
|
||||
test("src/game/wilderness.ts keeps single export of DRLGOUTDOORS_SpawnAct12Waypoint", () => {
|
||||
|
|
@ -71,9 +73,9 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => {
|
|||
const levelLinksSource = readFileSync(resolve(__dirname, "../src/game/level-links.ts"), "utf-8")
|
||||
expect(levelLinksSource).not.toMatch(/export\s+(?:const|function|let|var)\s+DRLGOUTDOORS_SpawnAct12Waypoint\b/)
|
||||
|
||||
// Ensure wilderness.ts has doc comments clarifying custom web port algorithms
|
||||
// The invented (non-Blizzard) ground-transition helpers must not come back.
|
||||
const wildernessSource = readFileSync(resolve(__dirname, "../src/game/wilderness.ts"), "utf-8")
|
||||
expect(wildernessSource).toMatch(/NOT a Blizzard original[\s\S]*?function computeNeighborMask/)
|
||||
expect(wildernessSource).toMatch(/NOT a Blizzard original[\s\S]*?function applyGroundTransitions/)
|
||||
expect(wildernessSource).not.toMatch(/function computeNeighborMask\b/)
|
||||
expect(wildernessSource).not.toMatch(/function applyGroundTransitions\b/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1746,7 +1746,7 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label
|
|||
}
|
||||
})
|
||||
|
||||
it('verifies all 365 baked map packs (495 SceneWarp and 172 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => {
|
||||
it('verifies all 365 baked map packs (495 SceneWarp and 166 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => {
|
||||
const baseDir = path.resolve(process.cwd(), 'samples/d2-packs')
|
||||
const indexPath = path.join(baseDir, 'index.json')
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
|
||||
|
|
@ -1810,7 +1810,12 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label
|
|||
expect(warpCount).toBe(495)
|
||||
expect(nonZeroBoxWarps).toBe(464)
|
||||
expect(zeroBoxWarps).toBe(31)
|
||||
expect(entranceCount).toBe(172)
|
||||
// 166 since the Act I outdoor levels come from the DRLG port: D2 has no
|
||||
// Stony Field <-> Dark Wood seam (DrlgOutPlace.cpp gAct1WildernessDrlgLink /
|
||||
// gAct1MonasteryDrlgLink put them in separate groups, joined only through
|
||||
// the Underground Passage), so the 2 seam entrances the old generator
|
||||
// invented in each of the 3 act copies are gone.
|
||||
expect(entranceCount).toBe(166)
|
||||
})
|
||||
|
||||
it('ignores unresolved warps and entrances (toLevelId <= 0) for overhead badges, hover, left-click, and Telekinesis', () => {
|
||||
|
|
|
|||
|
|
@ -104,14 +104,14 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
|||
const charR8 = readFileSync(charR8Path)
|
||||
expect(charR8.byteLength).toBe(charMeta.width * charMeta.height)
|
||||
|
||||
// Check all 65 unique monster specs exist as global .r8 files
|
||||
// Check all 66 unique monster specs exist as global .r8 files (EC = Hell Bovine, Moo Moo Farm)
|
||||
const uniqueSpecs = new Set<string>()
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
|
||||
uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
expect(uniqueSpecs.size).toBe(65)
|
||||
expect(uniqueSpecs.size).toBe(66)
|
||||
|
||||
for (const key of uniqueSpecs) {
|
||||
const slug = `monster-${key}`
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* to floor tiles, without wall vertical block adjustment (`minBlockY + 80`).
|
||||
* 3. `IsoMapScene` exposes `shadows: readonly IsoDraw[]`, sorted by isometric depth.
|
||||
* 4. Sub-tile collision flags of shadow layers are unioned into `collisionMasks` and `blocked`.
|
||||
* 5. Blood Moor and Act 1 wilderness levels include Tree Fill and shadow-bearing presets.
|
||||
* 5. Real Act 1 presets (Ruin2.ds1) carry shadow tiles that resolve to DT1 type 13.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
|
@ -20,11 +20,6 @@ import {
|
|||
ORTHO_CELL_WIDTH,
|
||||
} from '../src/game/d2map.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
BLOOD_MOOR_RIVER_PRESETS,
|
||||
BLOOD_MOOR_POND_PRESETS,
|
||||
SPECIAL_PRESETS_BY_LEVEL,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
|
|
@ -286,18 +281,6 @@ describe('Shadow Layer (DT1 Type 13) Rendering & Parity', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('Blood Moor and Act 1 wilderness preset lists include shadow-bearing presets (Ruin, Tree Fill)', () => {
|
||||
expect(BLOOD_MOOR_RIVER_PRESETS).toContain('Act 1 - Ruin')
|
||||
expect(BLOOD_MOOR_POND_PRESETS).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[2]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[3]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[4]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[5]).toContain('Act 1 - Tree Fill')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[5]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[6]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[7]).toContain('Act 1 - Ruin')
|
||||
})
|
||||
|
||||
const mpqDir = existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq')
|
||||
? '/usr/local/google/home/taodao/d2-data'
|
||||
: existsSync('samples/d2/d2data.mpq')
|
||||
|
|
|
|||
|
|
@ -189,7 +189,8 @@ describe('Adversarial Stress Test: resolveWorldOffsets across 100 Seeds for Acts
|
|||
}
|
||||
|
||||
expect(failures).toEqual([])
|
||||
expect(pairsChecked).toBe(1900)
|
||||
// 18 pairs (Act 1: 7, Act 2: 5, Act 4: 3, Act 5: 3) x 100 seeds.
|
||||
expect(pairsChecked).toBe(1800)
|
||||
})
|
||||
|
||||
it('Act 3 Seamless Adjacency: contiguous pairs in SEAMLESS_ADJACENCY share exact boundary coordinates without tears or gaps', () => {
|
||||
|
|
|
|||
|
|
@ -1,728 +0,0 @@
|
|||
/**
|
||||
* Wilderness D2MOO Ground Truth Parity Test Suite (Issue #455).
|
||||
*
|
||||
* Verifies that outdoor wilderness level generation adheres with 100% fidelity
|
||||
* to Diablo II v1.13c ground truth as established by D2MOO C++ DRLG engine
|
||||
* (`D2Common/src/Drlg/`) and native binary execution (`d2moo_mapgen`).
|
||||
*
|
||||
* Multi-Tier Parity Coverage:
|
||||
* - Tier 1: Dynamic Level Dimensions (96x56 horizontal vs 56x96 vertical) & Topology (R1)
|
||||
* - Tier 2: Outer River Placement, Dual Columns (x=80, 88), and Boundary Suppression (R2)
|
||||
* - Tier 3: Town Transition (TownSTrans.ds1 56x8) Anchored to Eastern River Edge (R4)
|
||||
* - Tier 4: Unconditional Landmark Presets & FarAway Anti-Overlap Metric (R4)
|
||||
* - Tier 5: 4-Pass Secondary Border Evolution & Interior 2x3 Forest Closures (Bord9..12) (R3)
|
||||
* - Tier 6: Orthogonal Polyline Dirt Paths & Width-2 Bresenham Rasterization (R5)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
type WildernessResult,
|
||||
} from '../src/game/wilderness.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* D2MOO Data Contracts & Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export interface OutdoorCellJson {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly fPacked: number
|
||||
readonly fStyle: number
|
||||
readonly fSeq: number
|
||||
readonly wPacked: number
|
||||
readonly wStyle: number
|
||||
readonly wSeq: number
|
||||
readonly wType: number
|
||||
readonly dirt: number
|
||||
}
|
||||
|
||||
export interface D2MooRoomJson {
|
||||
readonly index: number
|
||||
readonly type: number
|
||||
readonly tileX: number
|
||||
readonly tileY: number
|
||||
readonly tileW: number
|
||||
readonly tileH: number
|
||||
readonly mapPtr: number
|
||||
readonly mapX: number
|
||||
readonly mapY: number
|
||||
readonly mapW: number
|
||||
readonly mapH: number
|
||||
readonly lvlPrestId: number
|
||||
readonly pickedFile: number
|
||||
readonly ds1File: string
|
||||
readonly ds1Width: number
|
||||
readonly ds1Height: number
|
||||
readonly doorMask: number
|
||||
readonly orthCount: number
|
||||
readonly outdoorCells: readonly OutdoorCellJson[]
|
||||
}
|
||||
|
||||
export interface D2MooLevelJson {
|
||||
readonly levelId: number
|
||||
readonly levelName: string
|
||||
readonly act: number
|
||||
readonly drlgType: number
|
||||
readonly levelType: number
|
||||
readonly seed: number
|
||||
readonly posX: number
|
||||
readonly posY: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly roomCount: number
|
||||
readonly rooms: readonly D2MooRoomJson[]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Dual-Mode Execution & Fixture Loader
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const D2MOO_BIN = process.env.D2MOO_BIN ?? '/usr/local/google/home/taodao/tmp/d2moo_runner/d2moo_mapgen'
|
||||
const FIXTURES_DIR = 'tests/fixtures/d2moo'
|
||||
const hasD2Archives = existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
/**
|
||||
* Loads a D2MOO level layout either by executing `d2moo_mapgen` dynamically
|
||||
* or by reading the pre-recorded golden fixture from `tests/fixtures/d2moo/`.
|
||||
*/
|
||||
export function loadOrRunD2MooLevel(levelId: number, seed: number | string): D2MooLevelJson {
|
||||
const seedNum = typeof seed === 'number' ? seed : (seed.startsWith('0x') ? parseInt(seed, 16) : parseInt(seed, 10))
|
||||
const seedHex = `0x${seedNum.toString(16)}`
|
||||
const seedDec = seedNum.toString(10)
|
||||
|
||||
// 1. If native binary is present and not explicitly bypassed, run dynamically
|
||||
if (existsSync(D2MOO_BIN) && process.env.D2MOO_FORCE_FIXTURE !== '1') {
|
||||
try {
|
||||
const tmpOut = join(tmpdir(), `d2moo_parity_${levelId}_${seedNum.toString(16)}.json`)
|
||||
execFileSync(D2MOO_BIN, [String(levelId), seedHex, '0', tmpOut], { stdio: 'ignore' })
|
||||
if (existsSync(tmpOut)) {
|
||||
return JSON.parse(readFileSync(tmpOut, 'utf8')) as D2MooLevelJson
|
||||
}
|
||||
} catch {
|
||||
// Fallback to pre-recorded fixture
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to pre-recorded golden JSON fixture
|
||||
const candidateNames = [
|
||||
`level${levelId}_seed_0x${seedNum.toString(16)}.json`,
|
||||
`level${levelId}_seed_${seedDec}.json`,
|
||||
`level${levelId}_seed_${seedNum.toString(16)}.json`,
|
||||
`level_${levelId}_seed_${seedNum.toString(16)}.json`,
|
||||
]
|
||||
|
||||
for (const name of candidateNames) {
|
||||
const p = join(FIXTURES_DIR, name)
|
||||
if (existsSync(p)) {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as D2MooLevelJson
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Neither executable d2moo_mapgen nor golden fixture found for Level ${levelId} Seed ${seedHex}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts distinct preset stamp instances (deduplicating multi-room presets sharing mapPtr).
|
||||
*/
|
||||
export function getDistinctPresetStamps(level: D2MooLevelJson): Array<{
|
||||
file: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}> {
|
||||
const seenMapPtrs = new Set<number>()
|
||||
const stamps: Array<{ file: string; x: number; y: number; w: number; h: number }> = []
|
||||
|
||||
for (const room of level.rooms) {
|
||||
if (!room.ds1File) continue
|
||||
if (seenMapPtrs.has(room.mapPtr)) continue
|
||||
seenMapPtrs.add(room.mapPtr)
|
||||
|
||||
const file = room.ds1File.split('/').pop() ?? room.ds1File
|
||||
stamps.push({
|
||||
file,
|
||||
x: room.mapX - level.posX,
|
||||
y: room.mapY - level.posY,
|
||||
w: room.mapW,
|
||||
h: room.mapH,
|
||||
})
|
||||
}
|
||||
|
||||
return stamps
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts absolute world tile coordinates of all dirt road cells in the level.
|
||||
*/
|
||||
export function getDirtRoadCells(level: D2MooLevelJson): Array<{ x: number; y: number }> {
|
||||
const cells: Array<{ x: number; y: number }> = []
|
||||
for (const room of level.rooms) {
|
||||
for (const cellRecord of room.outdoorCells) {
|
||||
if (cellRecord.dirt !== 0) {
|
||||
cells.push({
|
||||
x: room.tileX - level.posX + cellRecord.x,
|
||||
y: room.tileY - level.posY + cellRecord.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes connectivity of road cells via 8-way BFS flood-fill.
|
||||
*/
|
||||
export function evaluateRoadConnectivity(cells: Array<{ x: number; y: number }>): {
|
||||
totalCells: number
|
||||
componentCount: number
|
||||
largestComponentSize: number
|
||||
} {
|
||||
const cellSet = new Set(cells.map(c => `${c.x},${c.y}`))
|
||||
const visited = new Set<string>()
|
||||
const components: string[][] = []
|
||||
|
||||
for (const key of cellSet) {
|
||||
if (visited.has(key)) continue
|
||||
const queue = [key]
|
||||
visited.add(key)
|
||||
const comp = [key]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const [cx, cy] = current.split(',').map(Number)
|
||||
for (const [dx, dy] of [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||
]) {
|
||||
const neighbor = `${cx + dx},${cy + dy}`
|
||||
if (cellSet.has(neighbor) && !visited.has(neighbor)) {
|
||||
visited.add(neighbor)
|
||||
queue.push(neighbor)
|
||||
comp.push(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
components.push(comp)
|
||||
}
|
||||
|
||||
return {
|
||||
totalCells: cells.length,
|
||||
componentCount: components.length,
|
||||
largestComponentSize: components[0]?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================= *
|
||||
* SUITE 1: D2MOO v1.13c Ground Truth Oracle & Fixture Invariants
|
||||
* ========================================================================= */
|
||||
|
||||
describe('Part 1: D2MOO Ground Truth Fixture & Runner Verification (Issue #455)', () => {
|
||||
// --- Tier 1: Dimensions & Orientations ---
|
||||
describe('Tier 1: Dynamic Level Dimensions & Topology Invariants (R1)', () => {
|
||||
it('verifies horizontal 96x56 Moor layout for Seed 0x12345678', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
||||
expect(d2moo.width).toBe(96)
|
||||
expect(d2moo.height).toBe(56)
|
||||
expect(d2moo.width / 8).toBe(12)
|
||||
expect(d2moo.height / 8).toBe(7)
|
||||
expect(d2moo.roomCount).toBe(82)
|
||||
})
|
||||
|
||||
it('verifies horizontal 96x56 Moor layout for Seed 12345', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 12345)
|
||||
expect(d2moo.width).toBe(96)
|
||||
expect(d2moo.height).toBe(56)
|
||||
expect(d2moo.width / 8).toBe(12)
|
||||
expect(d2moo.height / 8).toBe(7)
|
||||
expect(d2moo.roomCount).toBe(84)
|
||||
})
|
||||
|
||||
it('verifies vertical 56x96 Moor layout for Seed 1', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 1)
|
||||
expect(d2moo.width).toBe(56)
|
||||
expect(d2moo.height).toBe(96)
|
||||
expect(d2moo.width / 8).toBe(7)
|
||||
expect(d2moo.height / 8).toBe(12)
|
||||
expect(d2moo.roomCount).toBe(79)
|
||||
})
|
||||
|
||||
it('verifies vertical 56x96 Moor layout for Seed 42', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 42)
|
||||
expect(d2moo.width).toBe(56)
|
||||
expect(d2moo.height).toBe(96)
|
||||
expect(d2moo.width / 8).toBe(7)
|
||||
expect(d2moo.height / 8).toBe(12)
|
||||
expect(d2moo.roomCount).toBe(82)
|
||||
})
|
||||
|
||||
it('verifies Cold Plains (Level 3) fixed 80x80 layout for Seed 1', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(3, 1)
|
||||
expect(d2moo.width).toBe(80)
|
||||
expect(d2moo.height).toBe(80)
|
||||
expect(d2moo.roomCount).toBe(97)
|
||||
})
|
||||
|
||||
it('verifies Stony Field (Level 4) fixed 80x80 layout for Seed 1', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(4, 1)
|
||||
expect(d2moo.width).toBe(80)
|
||||
expect(d2moo.height).toBe(80)
|
||||
expect(d2moo.roomCount).toBe(91)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Tier 2: Outer River Placement ---
|
||||
describe('Tier 2: Outer River Placement & Boundary Suppression (R2)', () => {
|
||||
it('spawns dual-column river on eastern boundary (x=80, x=88) for Seed 0x12345678', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
||||
const riverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('River'))
|
||||
|
||||
expect(riverRooms.length).toBeGreaterThanOrEqual(12)
|
||||
|
||||
const uRiverRooms = riverRooms.filter(r => r.ds1File.includes('URiverC'))
|
||||
const lRiverRooms = riverRooms.filter(r => r.ds1File.includes('LRiverC'))
|
||||
|
||||
expect(uRiverRooms.length).toBeGreaterThanOrEqual(6)
|
||||
expect(lRiverRooms.length).toBeGreaterThanOrEqual(6)
|
||||
|
||||
// Column x = 80 (block 10, nGridWidth - 2) for URiverC
|
||||
for (const room of uRiverRooms) {
|
||||
expect(room.mapX - d2moo.posX).toBe(80)
|
||||
}
|
||||
|
||||
// Column x = 88 (block 11, nGridWidth - 1) for LRiverC
|
||||
for (const room of lRiverRooms) {
|
||||
expect(room.mapX - d2moo.posX).toBe(88)
|
||||
}
|
||||
|
||||
// Outer boundary tree wall suppression: no Bord*.ds1 rooms exist at x = 88 where river flows
|
||||
const eastBorders = d2moo.rooms.filter(r => {
|
||||
const file = r.ds1File?.split('/').pop() ?? ''
|
||||
const relX = r.mapX - d2moo.posX
|
||||
return file.startsWith('Bord') && relX === 88
|
||||
})
|
||||
expect(eastBorders.length).toBe(0)
|
||||
})
|
||||
|
||||
it('spawns dual-column river on eastern boundary (x=80, x=88) for Seed 12345', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 12345)
|
||||
const uRiverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('URiverC'))
|
||||
const lRiverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('LRiverC'))
|
||||
|
||||
expect(uRiverRooms.length).toBeGreaterThanOrEqual(6)
|
||||
expect(lRiverRooms.length).toBeGreaterThanOrEqual(6)
|
||||
|
||||
for (const room of uRiverRooms) expect(room.mapX - d2moo.posX).toBe(80)
|
||||
for (const room of lRiverRooms) expect(room.mapX - d2moo.posX).toBe(88)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Tier 3: Town Transition Alignment ---
|
||||
describe('Tier 3: Town Transition Alignment & River Edge Anchoring (R4)', () => {
|
||||
it('places TownSTrans.ds1 (56x8) anchored to East river at (40, 0) for Seed 0x12345678', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
||||
const townRoom = d2moo.rooms.find(r => r.ds1File && r.ds1File.includes('TownSTrans'))
|
||||
|
||||
expect(townRoom).toBeDefined()
|
||||
expect(townRoom!.mapX - d2moo.posX).toBe(40)
|
||||
expect(townRoom!.mapY - d2moo.posY).toBe(0)
|
||||
expect(townRoom!.mapW).toBe(56)
|
||||
expect(townRoom!.mapH).toBe(8)
|
||||
|
||||
// 40 + 56 = 96: transition extends exactly to the eastern river edge (x = 80..96)
|
||||
expect(townRoom!.mapX - d2moo.posX + townRoom!.mapW).toBe(d2moo.width)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Tier 4: Landmark Presets & FarAway Anti-Overlap Metric ---
|
||||
describe('Tier 4: Complete Landmark Presets & FarAway Distribution (R4)', () => {
|
||||
it('unconditionally places all 5 canonical landmark categories in Blood Moor (Seed 0x12345678)', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
||||
const uniquePresets = new Set(d2moo.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
|
||||
|
||||
expect(uniquePresets.has('DenEnt.ds1')).toBe(true)
|
||||
expect(uniquePresets.has('Pond1.ds1')).toBe(true)
|
||||
expect(uniquePresets.has('Cott1.ds1')).toBe(true)
|
||||
expect(uniquePresets.has('Circle.ds1')).toBe(true)
|
||||
expect(uniquePresets.has('Clover.ds1')).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies zero spatial overlap across all distinct preset stamps', () => {
|
||||
for (const [lvl, s] of [
|
||||
[2, 0x12345678],
|
||||
[2, 12345],
|
||||
[2, 1],
|
||||
[2, 42],
|
||||
[3, 1],
|
||||
[4, 1],
|
||||
] as const) {
|
||||
const d2moo = loadOrRunD2MooLevel(lvl, s)
|
||||
const stamps = getDistinctPresetStamps(d2moo)
|
||||
|
||||
for (let i = 0; i < stamps.length; i += 1) {
|
||||
for (let j = i + 1; j < stamps.length; j += 1) {
|
||||
const a = stamps[i]!
|
||||
const b = stamps[j]!
|
||||
|
||||
const xOverlap = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x))
|
||||
const yOverlap = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y))
|
||||
|
||||
expect(
|
||||
xOverlap > 0 && yOverlap > 0,
|
||||
`Overlap detected in Level ${lvl} Seed ${s} between ${a.file} and ${b.file}`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies FarAway metric (|dx| + 2*|dy|) places Den of Evil distant from Town Gate', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
||||
const townRoom = d2moo.rooms.find(r => r.ds1File.includes('TownSTrans'))!
|
||||
const denRoom = d2moo.rooms.find(r => r.ds1File.includes('DenEnt'))!
|
||||
|
||||
const townX = townRoom.mapX - d2moo.posX
|
||||
const townY = townRoom.mapY - d2moo.posY
|
||||
const denX = denRoom.mapX - d2moo.posX
|
||||
const denY = denRoom.mapY - d2moo.posY
|
||||
|
||||
// FarAway Manhattan metric: |dx| + 2*|dy|
|
||||
const distance = Math.abs(denX - townX) + 2 * Math.abs(denY - townY)
|
||||
expect(distance).toBeGreaterThanOrEqual(48)
|
||||
})
|
||||
|
||||
it('verifies canonical presets in Cold Plains (Level 3) and Stony Field (Level 4)', () => {
|
||||
const l3 = loadOrRunD2MooLevel(3, 1)
|
||||
const l3Presets = new Set(l3.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
|
||||
expect(l3Presets.has('CaveDr1.ds1')).toBe(true)
|
||||
expect(l3Presets.has('FallCmpBish.ds1')).toBe(true)
|
||||
expect(l3Presets.has('Cott1A.ds1')).toBe(true)
|
||||
expect(l3Presets.has('Circle.ds1')).toBe(true)
|
||||
expect(l3Presets.has('Clover.ds1')).toBe(true)
|
||||
|
||||
const l4 = loadOrRunD2MooLevel(4, 1)
|
||||
const l4Presets = new Set(l4.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
|
||||
expect(l4Presets.has('cairn2.ds1')).toBe(true)
|
||||
expect(l4Presets.has('clfcave.ds1')).toBe(true)
|
||||
expect(l4Presets.has('tome.ds1')).toBe(true)
|
||||
expect(l4Presets.has('Corral1.ds1')).toBe(true)
|
||||
expect(l4Presets.has('FallCmp.ds1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Tier 5: 4-Pass Secondary Borders & 2x3 Forest Closures ---
|
||||
describe('Tier 5: 4-Pass Secondary Border Evolution & 2x3 Closures (R3)', () => {
|
||||
it('spawns Bord5..8 outer peninsulas and Bord9..12 interior 2x3 forest closures', () => {
|
||||
for (const [lvl, s] of [
|
||||
[2, 0x12345678],
|
||||
[2, 12345],
|
||||
[2, 1],
|
||||
[2, 42],
|
||||
[3, 1],
|
||||
[4, 1],
|
||||
] as const) {
|
||||
const d2moo = loadOrRunD2MooLevel(lvl, s)
|
||||
const files = d2moo.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean) as string[]
|
||||
|
||||
const peninsulas = files.filter(f => /^Bord[5678]\.ds1$/i.test(f))
|
||||
const closures = files.filter(f => /^Bord(9|10|11|12)\.ds1$/i.test(f))
|
||||
|
||||
expect(peninsulas.length, `Level ${lvl} Seed ${s} missing Bord5..8 peninsulas`).toBeGreaterThan(0)
|
||||
expect(closures.length, `Level ${lvl} Seed ${s} missing Bord9..12 closures`).toBeGreaterThanOrEqual(4)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- Tier 6: Dirt Path Connectivity ---
|
||||
describe('Tier 6: Orthogonal Polyline Dirt Paths & Connectivity (R5)', () => {
|
||||
it('generates 100% contiguous dirt road network connecting gate, den, and exits', () => {
|
||||
for (const s of [0x12345678, 12345, 1, 42] as const) {
|
||||
const d2moo = loadOrRunD2MooLevel(2, s)
|
||||
const dirtCells = getDirtRoadCells(d2moo)
|
||||
|
||||
expect(dirtCells.length, `Level 2 Seed ${s} must have dirt road cells`).toBeGreaterThanOrEqual(100)
|
||||
|
||||
const connectivity = evaluateRoadConnectivity(dirtCells)
|
||||
expect(
|
||||
connectivity.componentCount,
|
||||
`Level 2 Seed ${s} road cells should form a single connected component`,
|
||||
).toBe(1)
|
||||
expect(connectivity.largestComponentSize).toBe(dirtCells.length)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* ========================================================================= *
|
||||
* SUITE 2: Wilderness Generator Ground Truth Parity (Issue #455 Milestones)
|
||||
* ========================================================================= */
|
||||
|
||||
describe.skipIf(!hasD2Archives)('Part 2: DRLG Wilderness Generator Parity vs D2MOO (Milestones M1..M6)', () => {
|
||||
let act1Pieces: WildernessPiece[]
|
||||
let act1Subs: WildernessSubstitution[]
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES = [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
try {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
} catch {
|
||||
// Skip optional archive if missing
|
||||
}
|
||||
}
|
||||
|
||||
const actTables = await loadActTables(archives)
|
||||
const lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
|
||||
async function loadDs1(relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await archives.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||
const levels: Ds1[] = []
|
||||
for (let slot = 1; slot <= 6; slot += 1) {
|
||||
const val = cell(table, row, `File${String(slot)}`)
|
||||
if (val === '' || val === '0') continue
|
||||
levels.push(await loadDs1(val))
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
// Load Act 1 wilderness preset pieces
|
||||
act1Pieces = []
|
||||
for (const row of actTables.lvlprest.rows) {
|
||||
const name = cell(actTables.lvlprest, row, 'Name')
|
||||
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
|
||||
const levels = await rowDs1s(actTables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
||||
}
|
||||
|
||||
// Load Act 1 substitutions (SubTypes 0, 1, 6)
|
||||
act1Subs = []
|
||||
for (const row of lvlsubTable.rows) {
|
||||
const subType = Number(cell(lvlsubTable, row, 'Type'))
|
||||
if (subType !== 0 && subType !== 1 && subType !== 6) continue
|
||||
const file = cell(lvlsubTable, row, 'File')
|
||||
if (file === '' || file === '0') continue
|
||||
const levels = [await loadDs1(file)]
|
||||
act1Subs.push({
|
||||
name: cell(lvlsubTable, row, 'Name'),
|
||||
type: subType,
|
||||
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
||||
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
|
||||
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
|
||||
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
|
||||
levels,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// --- Milestone M1: Dynamic Dimensions ---
|
||||
describe('Milestone M1 Parity: Dynamic Level Dimensions (R1)', () => {
|
||||
it('generates 96x56 horizontal Moor for Seed 0x12345678', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(d2moo.width)
|
||||
expect(result.level.height).toBe(d2moo.height)
|
||||
})
|
||||
|
||||
it('generates 56x96 vertical Moor for Seed 1', () => {
|
||||
const d2moo = loadOrRunD2MooLevel(2, 1)
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 1,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(d2moo.width)
|
||||
expect(result.level.height).toBe(d2moo.height)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Milestone M2: Outer River Placement ---
|
||||
describe('Milestone M2 Parity: Outer River Placement & Boundary Replacement (R2)', () => {
|
||||
it('spawns river along eastern boundary replacing outer tree border for Seed 0x12345678', () => {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
// River tiles on east edge: x in [80..95] contains water floors
|
||||
let hasWaterTile = false
|
||||
const maxX = result.level.width
|
||||
const maxY = result.level.height
|
||||
|
||||
for (let y = 8; y < maxY - 8; y += 1) {
|
||||
for (let x = Math.max(0, maxX - 16); x < maxX; x += 1) {
|
||||
const row = result.level.cells[y]
|
||||
if (row?.[x]?.floors.some(f => f.style === 2)) {
|
||||
hasWaterTile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasWaterTile) break
|
||||
}
|
||||
|
||||
expect(hasWaterTile).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Milestone M4: Town Transition Alignment ---
|
||||
describe('Milestone M4 Parity: Town Transition Alignment (R4)', () => {
|
||||
it('aligns TownSTrans gate to river edge for Seed 0x12345678', () => {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const notes = result.stats.notes ?? []
|
||||
const hasTownTrans = notes.some(n => /Town.*Trans/i.test(n))
|
||||
expect(hasTownTrans).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Milestone M4: Unconditional Landmarks ---
|
||||
describe('Milestone M4 Parity: Unconditional Landmark Presets & FarAway (R4)', () => {
|
||||
it('places all 5 canonical landmark categories in Blood Moor (Seed 0x12345678)', () => {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const notes = (result.stats.notes ?? []).join('\n')
|
||||
expect(/DOE|Den/i.test(notes)).toBe(true)
|
||||
expect(/Pond/i.test(notes)).toBe(true)
|
||||
expect(/Cottage|Bivouac/i.test(notes)).toBe(true)
|
||||
expect(/Circle|Stone/i.test(notes)).toBe(true)
|
||||
expect(/Clover/i.test(notes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Milestone M3: Secondary Borders & 2x3 Forest Closures ---
|
||||
describe('Milestone M3 Parity: Secondary Borders & 2x3 Forest Closures (R3)', () => {
|
||||
it('spawns Bord5..8 peninsulas and Bord9..12 interior 2x3 closures for Seed 0x12345678', () => {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const notes = (result.stats.notes ?? []).join('\n')
|
||||
// Must record secondary border peninsulas and interior 2x3 closures
|
||||
expect(/Bord[5678]|peninsula/i.test(notes)).toBe(true)
|
||||
expect(/Bord(9|10|11|12)|2x3|closure/i.test(notes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Milestone M5: Orthogonal Dirt Road Rasterization ---
|
||||
describe('Milestone M5 Parity: Orthogonal Dirt Paths & Width-2 Bresenham (R5)', () => {
|
||||
it('generates orthogonal width-2 dirt road without spline loop deviations', () => {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
// Dirt road floor tiles in Act 1 use style 0 with sequence 1..46 (byte_6FDCF958 LUT)
|
||||
let dirtTileCount = 0
|
||||
for (const row of result.level.cells) {
|
||||
for (const cellRecord of row) {
|
||||
if (cellRecord.floors.some(f => (f.style === 0 && f.sequence >= 1 && f.sequence <= 46) || (f.style === 1 && f.prop1 === 194))) {
|
||||
dirtTileCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parity expectation: road connects town gate to den and exit with width-2 footprint (>= 100 tiles)
|
||||
expect(dirtTileCount).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -102,102 +102,11 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(isClosedBorder(pickedClosed, "south")).toBe(true)
|
||||
})
|
||||
|
||||
test("SPECIAL_PRESETS_BY_LEVEL contains stone walls and river presets", () => {
|
||||
const bloodMoorPresets = SPECIAL_PRESETS_BY_LEVEL[2]
|
||||
expect(bloodMoorPresets).toBeDefined()
|
||||
expect(bloodMoorPresets).toContain("Act 1 - Corral Fill")
|
||||
expect(bloodMoorPresets).toContain("Act 1 - Fence Fill 4")
|
||||
expect(bloodMoorPresets).toContain("Act 1 - River Upper")
|
||||
expect(bloodMoorPresets).toContain("Act 1 - Bridge")
|
||||
|
||||
const cowPresets = SPECIAL_PRESETS_BY_LEVEL[39]
|
||||
expect(cowPresets).toBeDefined()
|
||||
expect(cowPresets).toContain("Act 1 - Corral Fill")
|
||||
expect(cowPresets).toContain("Act 1 - Fence Fill 4")
|
||||
expect(cowPresets).toContain("Act 1 - Fence Fill 5")
|
||||
expect(cowPresets).toContain("Act 1 - Bivouac")
|
||||
expect(cowPresets).toContain("Act 1 - Pond")
|
||||
})
|
||||
|
||||
test("generateWilderness generates Level 2 with river or pond deterministically by seed", () => {
|
||||
const closedDs1 = makeMockDs1(8, 8, false)
|
||||
const piece: WildernessPiece = {
|
||||
name: "Act 1 - Wild Border 1",
|
||||
border: true,
|
||||
levels: [closedDs1],
|
||||
test("SPECIAL_PRESETS_BY_LEVEL has no Act I outdoor entries (the DRLG port places those presets)", () => {
|
||||
for (const levelId of [2, 3, 4, 5, 6, 7, 17, 39]) {
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[levelId]).toBeUndefined()
|
||||
}
|
||||
const riverPiece: WildernessPiece = {
|
||||
name: "Act 1 - River Upper",
|
||||
border: false,
|
||||
levels: [closedDs1],
|
||||
}
|
||||
const pondPiece: WildernessPiece = {
|
||||
name: "Act 1 - Pond",
|
||||
border: false,
|
||||
levels: [closedDs1],
|
||||
}
|
||||
const corralPiece: WildernessPiece = {
|
||||
name: "Act 1 - Corral Fill",
|
||||
border: false,
|
||||
levels: [closedDs1],
|
||||
}
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 12345,
|
||||
pieces: [piece, riverPiece, pondPiece, corralPiece],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.blockGrid).toEqual({ width: 10, height: 10 })
|
||||
expect(result.level.width).toBe(80)
|
||||
expect(result.level.height).toBe(80)
|
||||
})
|
||||
|
||||
test("Act 1 Wilderness generates interior stone walls and dirt road network", () => {
|
||||
const closedDs1 = makeMockDs1(8, 8, false)
|
||||
const piece: WildernessPiece = {
|
||||
name: "Act 1 - Wild Border 1",
|
||||
border: true,
|
||||
levels: [closedDs1],
|
||||
}
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 99999,
|
||||
pieces: [piece],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
let stoneWallCount = 0
|
||||
let dirtRoadCount = 0
|
||||
for (let y = 8; y < 72; y += 1) {
|
||||
for (let x = 8; x < 72; x += 1) {
|
||||
const cell = result.level.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
|
||||
stoneWallCount += 1
|
||||
}
|
||||
if (cell.floors.some(f => (f.style === 0 || f.style === 5) && f.prop1 === 194)) {
|
||||
dirtRoadCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(stoneWallCount).toBe(0)
|
||||
expect(dirtRoadCount).toBeGreaterThan(30)
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[41]).toContain("Act 2 - Desert Tomb 1")
|
||||
})
|
||||
|
||||
test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => {
|
||||
|
|
@ -315,101 +224,6 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(result.level.objects.length).toBe(6)
|
||||
})
|
||||
|
||||
test("Act 1 Blood Moor (Level 2) connects South/East border gates with 3-tile road and uses authentic stone wall sequences", () => {
|
||||
const borderDs1 = makeMockDs1(8, 8, false)
|
||||
const openBorderDs1 = makeMockDs1(8, 8, true)
|
||||
const presetDs1 = makeMockDs1(16, 16, false)
|
||||
const riverDs1 = makeMockDs1(8, 8, false)
|
||||
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
|
||||
{ name: "Act 1 - Wild Border 2", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Wild Border 3", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Wild Border 4", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
|
||||
{ name: "Act 1 - Wild Border 5", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Wild Border 6", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Wild Border 7", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Wild Border 8", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - DOE Entrance", border: false, levels: [presetDs1] },
|
||||
{ name: "Act 1 - River Upper", border: false, levels: [riverDs1, riverDs1, riverDs1] },
|
||||
{ name: "Act 1 - River Lower", border: false, levels: [riverDs1, riverDs1, riverDs1] },
|
||||
{ name: "Act 1 - Bridge", border: false, levels: [riverDs1, riverDs1] },
|
||||
{ name: "Act 1 - Cottages 1", border: false, levels: [presetDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: -1,
|
||||
subTheme: -1,
|
||||
seed: 1234,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
specialPresets: [
|
||||
"Act 1 - DOE Entrance",
|
||||
"Act 1 - River Upper",
|
||||
"Act 1 - River Lower",
|
||||
"Act 1 - Bridge",
|
||||
"Act 1 - Cottages 1",
|
||||
],
|
||||
})
|
||||
|
||||
// 1. Verify 3-tile wide main highway at central crossroads (44, 44)
|
||||
expect(result.level.cells[44]![44]!.floors[0]!.style).toBe(0)
|
||||
expect(result.level.cells[44]![43]!.floors[0]!.style).toBe(0)
|
||||
expect(result.level.cells[44]![45]!.floors[0]!.style).toBe(0)
|
||||
|
||||
// 2. Verify South border gate road connection at (44, 75) and East border gate connection at (75, 44)
|
||||
expect(result.level.cells[75]![44]!.floors[0]!.style).toBe(0)
|
||||
expect(result.level.cells[44]![75]!.floors[0]!.style).toBe(0)
|
||||
|
||||
// 3. Verify no artificial interior stone wall segments (style=2, prop1=129) are stamped (Issue #65)
|
||||
let foundContinuousEW = false
|
||||
let foundEndCapEW = false
|
||||
for (let y = 10; y < 70; y += 1) {
|
||||
for (let x = 10; x < 70; x += 1) {
|
||||
const wall = result.level.cells[y]![x]!.walls.find(w => w.style === 2 && w.prop1 === 129)
|
||||
if (wall && wall.type === 2) {
|
||||
if (wall.sequence === 0) foundContinuousEW = true
|
||||
if (wall.sequence === 1 || wall.sequence === 2) foundEndCapEW = true
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(foundContinuousEW).toBe(false)
|
||||
expect(foundEndCapEW).toBe(false)
|
||||
})
|
||||
|
||||
test("records unresolved presets and notes when requested preset piece is missing", () => {
|
||||
const borderDs1 = makeMockDs1(8, 8, false)
|
||||
const piece: WildernessPiece = {
|
||||
name: "Act 1 - Wild Border 1",
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 9999,
|
||||
pieces: [piece], // Missing Den of Evil Entrance preset!
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const unresolved = result.stats.unresolved as string[]
|
||||
const notes = result.stats.notes as string[]
|
||||
expect(unresolved.length).toBeGreaterThan(0)
|
||||
expect(unresolved.some((u: string) => u.includes('Act 1 - DOE Entrance'))).toBe(true)
|
||||
expect(notes.some((n: string) => n.includes('Den of Evil Entrance'))).toBe(true)
|
||||
})
|
||||
|
||||
test("resolveUnsetSize dynamically derives vertical corridor dimensions for Level 111 and 112", () => {
|
||||
const entranceDs1 = makeMockDs1(17, 33, false)
|
||||
const exitDs1 = makeMockDs1(17, 33, false)
|
||||
|
|
|
|||
|
|
@ -11,31 +11,16 @@ import {
|
|||
generateWilderness,
|
||||
CANONICAL_GROUND_TILES,
|
||||
CANONICAL_GROUND_POOLS,
|
||||
computeNeighborMask,
|
||||
NEIGHBOR_MASK_NE,
|
||||
NEIGHBOR_MASK_E,
|
||||
NEIGHBOR_MASK_SE,
|
||||
NEIGHBOR_MASK_N,
|
||||
NEIGHBOR_MASK_S,
|
||||
NEIGHBOR_MASK_NW,
|
||||
NEIGHBOR_MASK_W,
|
||||
NEIGHBOR_MASK_SW,
|
||||
NEIGHBOR_OFFSETS,
|
||||
DIRT_PATH_TILE_LUT,
|
||||
WATER_EDGE_TILE_LUT,
|
||||
DRLGROOMTILE_GetTileCache,
|
||||
applyGroundTransitions,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import type {
|
||||
WeightedGroundTile,
|
||||
WildernessPiece,
|
||||
TileCacheQuery,
|
||||
TileCacheResult,
|
||||
GroundTransitionOptions,
|
||||
GroundTransitionResult,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
|
||||
import type { Ds1, Ds1Cell, Ds1Floor } from "../src/formats/ds1.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(
|
||||
width: number,
|
||||
|
|
@ -446,9 +431,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
|||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor Blend Test",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 41,
|
||||
levelName: "Rocky Waste Blend Test",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -484,9 +469,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
|||
test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor Single Tile",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 41,
|
||||
levelName: "Rocky Waste Single Tile",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -508,9 +493,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
|||
])
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor DT1 Test",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 41,
|
||||
levelName: "Rocky Waste DT1 Test",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -527,157 +512,6 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
|||
expect(statsTiles[0]!.sequence).toBe(0)
|
||||
expect(statsTiles[1]!.sequence).toBe(1)
|
||||
})
|
||||
|
||||
test("generateWilderness populates groundTransitions stats with 0 missing tiles", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const dt1 = makeMockDt1([
|
||||
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
||||
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||
])
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor Transitions Test",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 888888,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
dt1Libraries: [dt1],
|
||||
})
|
||||
|
||||
expect(result.stats.groundTransitions).toBeDefined()
|
||||
expect(result.stats.groundTransitions?.transitionCells).toBeGreaterThanOrEqual(0)
|
||||
expect(result.stats.groundTransitions?.roadTransitions).toBeGreaterThanOrEqual(0)
|
||||
expect(result.stats.groundTransitions?.waterTransitions).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Verify every floor tile on the canvas is resolvable (0 missing tiles)
|
||||
const available = new Set<string>()
|
||||
for (const t of dt1.tiles) {
|
||||
if (t.type === 0) available.add(`${String(t.style)}:${String(t.sequence)}`)
|
||||
}
|
||||
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const floor = result.level.cells[y]![x]!.floors[0]
|
||||
if (floor && !floor.hidden && floor.prop1 !== 0) {
|
||||
// The floor must either be in available tiles or from stamped border pieces
|
||||
expect(typeof floor.style).toBe("number")
|
||||
expect(typeof floor.sequence).toBe("number")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Neighbor Mask Calculation (Issue #70)", () => {
|
||||
test("calculates bitmask 0 for isolated cell with no matching neighbors", () => {
|
||||
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||
const mask = computeNeighborMask(canvas, 2, 2, () => false)
|
||||
expect(mask).toBe(0)
|
||||
})
|
||||
|
||||
test("calculates individual cardinal neighbor bitmasks (N, S, E, W)", () => {
|
||||
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||
|
||||
// Test North: (2, 1) -> dx=0, dy=-1
|
||||
const maskN = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 1)
|
||||
expect(maskN).toBe(NEIGHBOR_MASK_N)
|
||||
expect(maskN).toBe(0x10)
|
||||
|
||||
// Test South: (2, 3) -> dx=0, dy=+1
|
||||
const maskS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 3)
|
||||
expect(maskS).toBe(NEIGHBOR_MASK_S)
|
||||
expect(maskS).toBe(0x08)
|
||||
|
||||
// Test East: (3, 2) -> dx=+1, dy=0
|
||||
const maskE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 2)
|
||||
expect(maskE).toBe(NEIGHBOR_MASK_E)
|
||||
expect(maskE).toBe(0x40)
|
||||
|
||||
// Test West: (1, 2) -> dx=-1, dy=0
|
||||
const maskW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 2)
|
||||
expect(maskW).toBe(NEIGHBOR_MASK_W)
|
||||
expect(maskW).toBe(0x02)
|
||||
})
|
||||
|
||||
test("calculates individual diagonal corner bitmasks (NE, NW, SE, SW)", () => {
|
||||
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||
|
||||
// Test NE: (3, 1) -> dx=+1, dy=-1
|
||||
const maskNE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 1)
|
||||
expect(maskNE).toBe(NEIGHBOR_MASK_NE)
|
||||
expect(maskNE).toBe(0x80)
|
||||
|
||||
// Test NW: (1, 1) -> dx=-1, dy=-1
|
||||
const maskNW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 1)
|
||||
expect(maskNW).toBe(NEIGHBOR_MASK_NW)
|
||||
expect(maskNW).toBe(0x04)
|
||||
|
||||
// Test SE: (3, 3) -> dx=+1, dy=+1
|
||||
const maskSE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 3)
|
||||
expect(maskSE).toBe(NEIGHBOR_MASK_SE)
|
||||
expect(maskSE).toBe(0x20)
|
||||
|
||||
// Test SW: (1, 3) -> dx=-1, dy=+1
|
||||
const maskSW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 3)
|
||||
expect(maskSW).toBe(NEIGHBOR_MASK_SW)
|
||||
expect(maskSW).toBe(0x01)
|
||||
})
|
||||
|
||||
test("calculates composite neighbor bitmasks correctly", () => {
|
||||
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||
|
||||
// N + S = 0x10 | 0x08 = 0x18
|
||||
const maskNS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 2 && ny === 1) || (nx === 2 && ny === 3))
|
||||
expect(maskNS).toBe(0x18)
|
||||
|
||||
// E + W = 0x40 | 0x02 = 0x42
|
||||
const maskEW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 3 && ny === 2) || (nx === 1 && ny === 2))
|
||||
expect(maskEW).toBe(0x42)
|
||||
|
||||
// NE + N + E = 0x80 | 0x10 | 0x40 = 0xD0
|
||||
const maskCorner = computeNeighborMask(canvas, 2, 2, (_, nx, ny) =>
|
||||
(nx === 3 && ny === 1) || (nx === 2 && ny === 1) || (nx === 3 && ny === 2),
|
||||
)
|
||||
expect(maskCorner).toBe(0xD0)
|
||||
|
||||
// All 8 neighbors = 0xFF (255)
|
||||
const maskAll = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx !== 2 || ny !== 2)
|
||||
expect(maskAll).toBe(0xFF)
|
||||
})
|
||||
|
||||
test("safely handles canvas boundaries without error", () => {
|
||||
const canvas = createCanvas(3, 3, 1, 1, 0)
|
||||
|
||||
// Top-left corner (0, 0): only (1, 0), (0, 1), (1, 1) are valid neighbors
|
||||
const maskTL = computeNeighborMask(canvas, 0, 0, () => true)
|
||||
// dx=+1, dy=0 (E: 0x40)
|
||||
// dx=0, dy=+1 (S: 0x08)
|
||||
// dx=+1, dy=+1 (SE: 0x20)
|
||||
// Expected = 0x40 | 0x08 | 0x20 = 0x68
|
||||
expect(maskTL).toBe(0x68)
|
||||
|
||||
// Bottom-right corner (2, 2): only (1, 2), (2, 1), (1, 1) are valid neighbors
|
||||
const maskBR = computeNeighborMask(canvas, 2, 2, () => true)
|
||||
// dx=-1, dy=0 (W: 0x02)
|
||||
// dx=0, dy=-1 (N: 0x10)
|
||||
// dx=-1, dy=-1 (NW: 0x04)
|
||||
// Expected = 0x02 | 0x10 | 0x04 = 0x16
|
||||
expect(maskBR).toBe(0x16)
|
||||
})
|
||||
|
||||
test("NEIGHBOR_OFFSETS has exactly 8 directions with correct masks", () => {
|
||||
expect(NEIGHBOR_OFFSETS).toHaveLength(8)
|
||||
let combined = 0
|
||||
for (const offset of NEIGHBOR_OFFSETS) {
|
||||
combined |= offset.mask
|
||||
}
|
||||
expect(combined).toBe(0xFF)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => {
|
||||
|
|
@ -783,251 +617,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("applyGroundTransitions Auto-Tiling (Issue #70)", () => {
|
||||
function setFloor(
|
||||
canvas: ReturnType<typeof createCanvas>,
|
||||
x: number,
|
||||
y: number,
|
||||
floor: { style: number; sequence: number; prop1?: number },
|
||||
): void {
|
||||
const cell = canvas.cells[y]?.[x]
|
||||
if (!cell) return
|
||||
;(cell.floors as Ds1Floor[])[0] = {
|
||||
prop1: floor.prop1 ?? 2,
|
||||
style: floor.style,
|
||||
sequence: floor.sequence,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
test("generates road-to-ground edge transitions using DIRT_PATH_TILE_LUT while keeping interior road intact", () => {
|
||||
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||
|
||||
// Initialize canvas with base ground: style 0, sequence 0
|
||||
for (let y = 0; y < 9; y += 1) {
|
||||
for (let x = 0; x < 9; x += 1) {
|
||||
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp a 3x3 road patch at (3, 3) to (5, 5) with style 0, sequence 1
|
||||
for (let ry = 3; ry <= 5; ry += 1) {
|
||||
for (let rx = 3; rx <= 5; rx += 1) {
|
||||
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const roadDt1 = makeMockDt1(
|
||||
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
|
||||
)
|
||||
const res = applyGroundTransitions(canvas, {
|
||||
libraries: [roadDt1],
|
||||
dt1Mask: 1,
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
levelId: 2,
|
||||
})
|
||||
expect(res.roadTransitions).toBeGreaterThan(0)
|
||||
expect(res.waterTransitions).toBe(0)
|
||||
expect(res.transitionCells).toBe(res.roadTransitions)
|
||||
|
||||
// Interior center cell at (4, 4) has all 8 road neighbors (mask = 0xFF)
|
||||
// Interior road sequence in DIRT_PATH_TILE_LUT is sequence 1, so it remains intact!
|
||||
const centerFloor = canvas.cells[4]![4]!.floors[0]!
|
||||
expect(centerFloor.style).toBe(0)
|
||||
expect(centerFloor.sequence).toBe(1)
|
||||
|
||||
// The 8 boundary road cells around (4, 4) must receive transition sequences from DIRT_PATH_TILE_LUT
|
||||
for (let ry = 3; ry <= 5; ry += 1) {
|
||||
for (let rx = 3; rx <= 5; rx += 1) {
|
||||
if (rx === 4 && ry === 4) continue
|
||||
const floor = canvas.cells[ry]![rx]!.floors[0]!
|
||||
expect(floor.style).toBe(0)
|
||||
expect(floor.sequence).toBeGreaterThanOrEqual(1)
|
||||
expect(floor.sequence).toBeLessThanOrEqual(46)
|
||||
}
|
||||
}
|
||||
|
||||
// Ground cells outside road (e.g. (0, 0)) remain sequence 0
|
||||
expect(canvas.cells[0]![0]!.floors[0]!.sequence).toBe(0)
|
||||
})
|
||||
|
||||
test("generates water-to-ground edge transitions using WATER_EDGE_TILE_LUT while keeping interior deep water (24)", () => {
|
||||
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||
|
||||
// Initialize canvas with base ground: style 0, sequence 0
|
||||
for (let y = 0; y < 9; y += 1) {
|
||||
for (let x = 0; x < 9; x += 1) {
|
||||
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp a 3x3 water patch at (3, 3) to (5, 5) with style 2, sequence 0
|
||||
for (let wy = 3; wy <= 5; wy += 1) {
|
||||
for (let wx = 3; wx <= 5; wx += 1) {
|
||||
setFloor(canvas, wx, wy, { style: 2, sequence: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const waterDt1 = makeMockDt1(
|
||||
Array.from({ length: 25 }, (_, i) => ({ type: 0, style: 2, sequence: i })),
|
||||
)
|
||||
const res = applyGroundTransitions(canvas, {
|
||||
libraries: [waterDt1],
|
||||
dt1Mask: 1,
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
levelId: 2,
|
||||
})
|
||||
expect(res.waterTransitions).toBeGreaterThan(0)
|
||||
expect(res.transitionCells).toBe(res.waterTransitions)
|
||||
|
||||
// Center water cell (4, 4) has all 8 water neighbors (mask = 0xFF) -> deep water sequence 24
|
||||
const centerWater = canvas.cells[4]![4]!.floors[0]!
|
||||
expect(centerWater.style).toBe(2)
|
||||
expect(centerWater.sequence).toBe(24)
|
||||
|
||||
// Perimeter water cells receive water edge sequences (0..23)
|
||||
for (let wy = 3; wy <= 5; wy += 1) {
|
||||
for (let wx = 3; wx <= 5; wx += 1) {
|
||||
if (wx === 4 && wy === 4) continue
|
||||
const floor = canvas.cells[wy]![wx]!.floors[0]!
|
||||
expect(floor.style).toBe(2)
|
||||
expect(floor.sequence).toBeLessThan(24)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("returns 0 transitions when dt1Libraries are undefined or empty (Issue #113)", () => {
|
||||
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||
for (let y = 0; y < 9; y += 1) {
|
||||
for (let x = 0; x < 9; x += 1) {
|
||||
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||
}
|
||||
}
|
||||
for (let ry = 3; ry <= 5; ry += 1) {
|
||||
for (let rx = 3; rx <= 5; rx += 1) {
|
||||
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
// No options -> 0 transitions
|
||||
const resNoOptions = applyGroundTransitions(canvas)
|
||||
expect(resNoOptions.transitionCells).toBe(0)
|
||||
expect(resNoOptions.roadTransitions).toBe(0)
|
||||
expect(resNoOptions.waterTransitions).toBe(0)
|
||||
|
||||
// Empty libraries array -> 0 transitions
|
||||
const resEmptyLibs = applyGroundTransitions(canvas, { libraries: [] })
|
||||
expect(resEmptyLibs.transitionCells).toBe(0)
|
||||
expect(resEmptyLibs.roadTransitions).toBe(0)
|
||||
})
|
||||
|
||||
test("does not inject transition tiles into non-Act-1 environments (Issue #113)", () => {
|
||||
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||
for (let y = 0; y < 9; y += 1) {
|
||||
for (let x = 0; x < 9; x += 1) {
|
||||
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||
}
|
||||
}
|
||||
for (let ry = 3; ry <= 5; ry += 1) {
|
||||
for (let rx = 3; rx <= 5; rx += 1) {
|
||||
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const roadDt1 = makeMockDt1(
|
||||
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
|
||||
)
|
||||
|
||||
// Mesa 1 (levelId 104, Act 4 - Mesa)
|
||||
const resMesa = applyGroundTransitions(canvas, {
|
||||
libraries: [roadDt1],
|
||||
dt1Mask: 1,
|
||||
levelTypeName: 'Act 4 - Mesa',
|
||||
levelId: 104,
|
||||
})
|
||||
expect(resMesa.transitionCells).toBe(0)
|
||||
|
||||
// Desert 3 (levelId 43, Act 2 - Desert)
|
||||
const resDesert = applyGroundTransitions(canvas, {
|
||||
libraries: [roadDt1],
|
||||
dt1Mask: 1,
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
levelId: 43,
|
||||
})
|
||||
expect(resDesert.transitionCells).toBe(0)
|
||||
|
||||
// Barricade 2 (levelId 112, Act 5 - Barricade)
|
||||
const resBarricade2 = applyGroundTransitions(canvas, {
|
||||
libraries: [roadDt1],
|
||||
dt1Mask: 1,
|
||||
levelTypeName: 'Act 5 - Barricade',
|
||||
levelId: 112,
|
||||
})
|
||||
expect(resBarricade2.transitionCells).toBe(0)
|
||||
|
||||
// Barricade Snow (levelId 117, Act 5 - Barricade)
|
||||
const resBarricadeSnow = applyGroundTransitions(canvas, {
|
||||
libraries: [roadDt1],
|
||||
dt1Mask: 1,
|
||||
levelTypeName: 'Act 5 - Barricade',
|
||||
levelId: 117,
|
||||
})
|
||||
expect(resBarricadeSnow.transitionCells).toBe(0)
|
||||
})
|
||||
|
||||
test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => {
|
||||
expect(WATER_EDGE_TILE_LUT).toHaveLength(256)
|
||||
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)
|
||||
for (let i = 0; i < 255; i += 1) {
|
||||
expect(WATER_EDGE_TILE_LUT[i]).toBeLessThanOrEqual(23)
|
||||
}
|
||||
})
|
||||
|
||||
test("guarantees 0 missing tiles by strictly respecting supplied DT1 libraries", () => {
|
||||
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||
for (let y = 0; y < 9; y += 1) {
|
||||
for (let x = 0; x < 9; x += 1) {
|
||||
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp road
|
||||
for (let ry = 3; ry <= 5; ry += 1) {
|
||||
for (let rx = 3; rx <= 5; rx += 1) {
|
||||
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
// Mock DT1 that only has style 0, sequence 0 and 1 (no transition sequences 2..46)
|
||||
const restrictedDt1 = makeMockDt1([
|
||||
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
||||
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||
])
|
||||
|
||||
const res = applyGroundTransitions(canvas, {
|
||||
libraries: [restrictedDt1],
|
||||
dt1Mask: 1,
|
||||
})
|
||||
|
||||
// Since transitions 2..46 are not available in DT1, no invalid sequences are assigned
|
||||
expect(res.roadTransitions).toBe(0)
|
||||
|
||||
// Every cell on canvas is guaranteed to be in the restricted DT1
|
||||
for (let y = 0; y < 9; y += 1) {
|
||||
for (let x = 0; x < 9; x += 1) {
|
||||
const floor = canvas.cells[y]![x]!.floors[0]!
|
||||
expect(floor.style === 0 && (floor.sequence === 0 || floor.sequence === 1)).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canonical Ground Pools (Phase D3)', () => {
|
||||
test('contains authentic pools for all wilderness level types', () => {
|
||||
const expectedKeys = [
|
||||
'Act 1 - Wilderness',
|
||||
'Act 2 - Desert',
|
||||
'Act 3 - Jungle',
|
||||
'Act 3 - Kurast',
|
||||
|
|
@ -1047,6 +639,8 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
|||
expect(tile.sequence).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
}
|
||||
// Act I outdoor ground comes from the DRLG port, not from a canonical pool.
|
||||
expect(CANONICAL_GROUND_POOLS['Act 1 - Wilderness']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('Act 3 Kurast canonical ground pool uses authentic style 1 from DarkGrass.dt1', () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, test } from "vitest"
|
||||
import {
|
||||
generateWilderness,
|
||||
UNIMPLEMENTED_PASSES,
|
||||
type WildernessPiece,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import { UNIMPLEMENTED_PASSES } from "../src/game/wilderness.ts"
|
||||
import {
|
||||
rollEliteModifiers,
|
||||
applyEliteModifiers,
|
||||
|
|
@ -13,211 +9,16 @@ import {
|
|||
import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts"
|
||||
import { parseTable } from "../src/game/acts.ts"
|
||||
import { Rng } from "../src/game/rng.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 {
|
||||
const cells: Ds1Cell[][] = []
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row: Ds1Cell[] = []
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const isGap = hasGapInWalls && x === Math.floor(width / 2)
|
||||
row.push({
|
||||
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||
shadows: [],
|
||||
substitutions: [],
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return {
|
||||
version: 18,
|
||||
width,
|
||||
height,
|
||||
act: 1,
|
||||
substitutionType: 0,
|
||||
wallLayers: 1,
|
||||
floorLayers: 1,
|
||||
cells,
|
||||
objects: [],
|
||||
npcPathOffset: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTestPieces(): WildernessPiece[] {
|
||||
return [
|
||||
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||
]
|
||||
}
|
||||
|
||||
// Act I outdoor population (the Act I waypoints, shrines, Flavie and the SuperUniques of Cold Plains,
|
||||
// Stony Field, Dark Wood, Burial Grounds and Moo Moo Farm) comes from the DRLG port and is checked on
|
||||
// its output in tests/drlg-act1-population.test.ts.
|
||||
describe("Wilderness Population Phase (Issue #36)", () => {
|
||||
test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => {
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
|
||||
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines")
|
||||
})
|
||||
|
||||
test("generateWilderness spawns outdoor waypoints on waypoint levels (Cold Plains, Stony Field, Dark Wood, Black Marsh)", () => {
|
||||
const pieces = makeTestPieces()
|
||||
for (const levelId of [3, 4, 5, 6]) {
|
||||
const result = generateWilderness({
|
||||
levelId,
|
||||
levelName: `Act 1 Outdoor ${levelId}`,
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x123456 + levelId,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(1)
|
||||
expect(result.stats.waypointTile).toBeDefined()
|
||||
expect(result.stats.waypointTile).not.toBeNull()
|
||||
|
||||
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 37)
|
||||
expect(wpObj).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("generateWilderness does not spawn outdoor waypoint on Blood Moor (levelId 2)", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x222222,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.waypointsSpawned).toBe(0)
|
||||
expect(result.stats.waypointTile).toBeNull()
|
||||
})
|
||||
|
||||
test("generateWilderness scatters roadside shrines and interactive objects along dirt paths", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x998877,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThan(0)
|
||||
expect(Number(result.stats.interactivesSpawned)).toBeGreaterThan(0)
|
||||
expect(result.level.objects.length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
test("generateWilderness binds Guard Flavie (rogue2) on Blood Moor (2) and Cold Plains (3)", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const bloodMoor = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x1111,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
const coldPlains = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x2222,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const bmLandmarks = bloodMoor.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
|
||||
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
|
||||
|
||||
expect(bmLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
|
||||
expect(cpLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
|
||||
})
|
||||
|
||||
test("generateWilderness binds canonical SuperUniques to their special presets (Bishibosh, Rakanishu, Treehead WoodFist)", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const coldPlains = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x3333,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
const stonyField = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x4444,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
const darkWood = generateWilderness({
|
||||
levelId: 5,
|
||||
levelName: "Dark Wood",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x5555,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; role: string }>
|
||||
const sfLandmarks = stonyField.stats.landmarkEntities as Array<{ id: string; role: string }>
|
||||
const dwLandmarks = darkWood.stats.landmarkEntities as Array<{ id: string; role: string }>
|
||||
|
||||
expect(cpLandmarks.some(e => e.id === "Bishibosh" && e.role === "superunique")).toBe(true)
|
||||
expect(sfLandmarks.some(e => e.id === "Rakanishu" && e.role === "superunique")).toBe(true)
|
||||
expect(dwLandmarks.some(e => e.id === "Treehead WoodFist" && e.role === "superunique")).toBe(true)
|
||||
})
|
||||
|
||||
test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => {
|
||||
const rng = new Rng(0xabcdef)
|
||||
const mods = rollEliteModifiers("unique", rng)
|
||||
|
|
|
|||
|
|
@ -1,675 +0,0 @@
|
|||
/**
|
||||
* Wilderness Topological Reachability & Collision Boundary Stress Test Suite (Issue #455).
|
||||
*
|
||||
* Empirical verification harness for Challenger 2:
|
||||
* 1. Flood-fill reachability from player spawn / Rogue Encampment gate (`TownSTrans`)
|
||||
* to Den of Evil entrance (`DenEnt.ds1`), Cold Plains boundary (river bridge / exit),
|
||||
* and all landmark presets (Pond1, Cott1, Circle, Clover).
|
||||
* 2. Impassable choke point, void trap, and bisecting barrier detection.
|
||||
* 3. Secondary border (`Bord5..8` peninsulas) and 2x3 interior closure (`Bord9..12`) safety.
|
||||
* 4. Multi-seed stress fuzzing across horizontal (96x56) and vertical (56x96) layouts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries, type D2Table, type ActTables } from '../src/game/acts.ts'
|
||||
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
|
||||
import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts'
|
||||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
type WildernessResult,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import { solveAct1WildernessTopology } from '../src/game/wilderness/wilderness-topology.ts'
|
||||
|
||||
const hasD2 = existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
export interface LandmarkTarget {
|
||||
name: string
|
||||
tileX: number
|
||||
tileY: number
|
||||
widthTiles: number
|
||||
heightTiles: number
|
||||
reachable: boolean
|
||||
minSubTileDist: number
|
||||
}
|
||||
|
||||
export interface ReachabilityReport {
|
||||
seed: number
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
widthTiles: number
|
||||
heightTiles: number
|
||||
totalSubTiles: number
|
||||
walkableSubTiles: number
|
||||
largestComponentSize: number
|
||||
connectivityRatio: number
|
||||
townSpawn: { x: number; y: number }
|
||||
denReachable: boolean
|
||||
denDistance: number
|
||||
exitReachable: boolean
|
||||
exitDistance: number
|
||||
landmarks: LandmarkTarget[]
|
||||
minShortestPathClearance: number
|
||||
hasBisectingBarrier: boolean
|
||||
hasVoidTrapAtSpawn: boolean
|
||||
}
|
||||
|
||||
describe.skipIf(!hasD2)('Challenger 2: Wilderness Topological Reachability & Collision Stress Suite', () => {
|
||||
let archives: MountedArchives
|
||||
let tables: ActTables
|
||||
let lvlsubTable: D2Table
|
||||
let act1Pieces: WildernessPiece[]
|
||||
let act1Subs: WildernessSubstitution[]
|
||||
const dt1Cache = new Map<string, Dt1>()
|
||||
let dt1s: Dt1[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
tables = await loadActTables(archives)
|
||||
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
async function loadDs1(relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await archives.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||
const levels: Ds1[] = []
|
||||
for (let slot = 1; slot <= 6; slot += 1) {
|
||||
const val = cell(table, row, `File${String(slot)}`)
|
||||
if (val === '' || val === '0') continue
|
||||
levels.push(await loadDs1(val))
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
const WILDERNESS_FAMILIES = [
|
||||
'Act 1 - Wild',
|
||||
'Act 1 - Town 1 Transition',
|
||||
'Act 1 - DOE Entrance',
|
||||
'Act 1 - Cottages',
|
||||
'Act 1 - Pond',
|
||||
'Act 1 - Stone Fill',
|
||||
'Act 1 - River',
|
||||
'Act 1 - Bridge',
|
||||
'Act 1 - Cave Entrance',
|
||||
]
|
||||
|
||||
act1Pieces = []
|
||||
for (const row of tables.lvlprest.rows) {
|
||||
const name = cell(tables.lvlprest, row, 'Name')
|
||||
if (!WILDERNESS_FAMILIES.some(fam => name.startsWith(fam))) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
||||
}
|
||||
const tPiece = act1Pieces.find(p => /Town.*STrans|Transition.*S/i.test(p.name))?.levels[0]
|
||||
console.log('TownSTrans ds1 size:', tPiece?.width, 'x', tPiece?.height)
|
||||
|
||||
act1Subs = []
|
||||
for (const row of lvlsubTable.rows) {
|
||||
const subType = Number(cell(lvlsubTable, row, 'Type'))
|
||||
if (subType !== 0 && subType !== 1 && subType !== 6) continue
|
||||
const file = cell(lvlsubTable, row, 'File')
|
||||
if (!file || file === '0') continue
|
||||
const levels = [await loadDs1(file)]
|
||||
act1Subs.push({
|
||||
name: cell(lvlsubTable, row, 'Name'),
|
||||
type: subType,
|
||||
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
||||
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
|
||||
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
|
||||
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
|
||||
levels,
|
||||
})
|
||||
}
|
||||
|
||||
const libraries = resolveLevelLibraries(tables, 2)
|
||||
for (const n of libraries.dt1Names) {
|
||||
if (!dt1Cache.has(n)) {
|
||||
try {
|
||||
dt1Cache.set(n, decodeDt1(await archives.read(n)))
|
||||
} catch {
|
||||
// Ignore missing optional DT1s
|
||||
}
|
||||
}
|
||||
}
|
||||
dt1s = libraries.dt1Names.map(n => dt1Cache.get(n)).filter((d): d is Dt1 => d !== undefined)
|
||||
}, 30000)
|
||||
|
||||
/**
|
||||
* Evaluates flood-fill reachability on sub-tile collision grid for a generated level.
|
||||
*/
|
||||
function evaluateReachability(seed: number): ReachabilityReport {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
if (seed === 359) {
|
||||
console.log('Seed 359 stats.notes:', result.stats.notes)
|
||||
}
|
||||
|
||||
const topo = solveAct1WildernessTopology(seed, 2)
|
||||
const orientation = topo.width > topo.height ? 'horizontal' : 'vertical'
|
||||
|
||||
const scene = buildIsoMapScene(result.level, dt1s, levelSeed(`stress_${seed}`))
|
||||
const cellsX = result.level.width
|
||||
const cellsY = result.level.height
|
||||
const gridW = scene.gridWidth ?? cellsX * SUB_TILES_PER_TILE
|
||||
const gridH = scene.gridHeight ?? cellsY * SUB_TILES_PER_TILE
|
||||
const totalSubTiles = gridW * gridH
|
||||
|
||||
// Check walkable sub-tiles
|
||||
let walkableCount = 0
|
||||
for (let i = 0; i < totalSubTiles; i += 1) {
|
||||
if (scene.blocked[i] === 0) walkableCount += 1
|
||||
}
|
||||
|
||||
// Locate town gate / spawn
|
||||
const gateVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.find(v => v.type === 'gate')
|
||||
const denVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.find(v => v.type === 'preset')
|
||||
const exitVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.filter(v => v.type === 'gate')[1]
|
||||
|
||||
// Convert tile coordinates to sub-tile coordinates (center of tile = +2)
|
||||
const spawnTileX = gateVertex?.x ?? Math.floor(cellsX / 2)
|
||||
const spawnTileY = gateVertex?.y ?? 4
|
||||
|
||||
let startSx = spawnTileX * SUB_TILES_PER_TILE + 2
|
||||
let startSy = spawnTileY * SUB_TILES_PER_TILE + 2
|
||||
|
||||
// Search local window for nearest walkable sub-tile if exact center is blocked
|
||||
if (scene.blocked[startSy * gridW + startSx] !== 0) {
|
||||
let found = false
|
||||
for (let r = 1; r <= 20 && !found; r += 1) {
|
||||
for (let dy = -r; dy <= r && !found; dy += 1) {
|
||||
for (let dx = -r; dx <= r && !found; dx += 1) {
|
||||
const sx = startSx + dx
|
||||
const sy = startSy + dy
|
||||
if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) {
|
||||
if (scene.blocked[sy * gridW + sx] === 0) {
|
||||
startSx = sx
|
||||
startSy = sy
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnWalkable = scene.blocked[startSy * gridW + startSx] === 0
|
||||
|
||||
// BFS Flood-fill from start sub-tile
|
||||
const dist = new Int32Array(totalSubTiles).fill(-1)
|
||||
const prev = new Int32Array(totalSubTiles).fill(-1)
|
||||
const queue = new Int32Array(totalSubTiles)
|
||||
let qHead = 0
|
||||
let qTail = 0
|
||||
|
||||
const startIdx = startSy * gridW + startSx
|
||||
dist[startIdx] = 0
|
||||
queue[qTail++] = startIdx
|
||||
|
||||
const DIRS = [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||
]
|
||||
|
||||
while (qHead < qTail) {
|
||||
const curr = queue[qHead++]
|
||||
const cx = curr % gridW
|
||||
const cy = Math.floor(curr / gridW)
|
||||
const d = dist[curr]!
|
||||
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const nx = cx + dx
|
||||
const ny = cy + dy
|
||||
if (nx >= 0 && nx < gridW && ny >= 0 && ny < gridH) {
|
||||
const nidx = ny * gridW + nx
|
||||
if (dist[nidx] === -1 && scene.blocked[nidx] === 0) {
|
||||
dist[nidx] = d + 1
|
||||
prev[nidx] = curr
|
||||
queue[qTail++] = nidx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const largestComponentSize = qTail
|
||||
const connectivityRatio = walkableCount > 0 ? largestComponentSize / walkableCount : 0
|
||||
|
||||
if (seed === 359) {
|
||||
let minTx = 999, maxTx = 0, minTy = 999, maxTy = 0
|
||||
for (let i = 0; i < qTail; i += 1) {
|
||||
const idx = queue[i]!
|
||||
const tx = Math.floor((idx % gridW) / SUB_TILES_PER_TILE)
|
||||
const ty = Math.floor(Math.floor(idx / gridW) / SUB_TILES_PER_TILE)
|
||||
if (tx < minTx) minTx = tx
|
||||
if (tx > maxTx) maxTx = tx
|
||||
if (ty < minTy) minTy = ty
|
||||
if (ty > maxTy) maxTy = ty
|
||||
}
|
||||
console.log(`[Seed 359 Spawn Component Bounds] tx: ${minTx}..${maxTx}, ty: ${minTy}..${maxTy}, spawnTile: (${Math.floor(startSx/5)}, ${Math.floor(startSy/5)})`)
|
||||
console.log('Cell at 8, 44:', JSON.stringify(result.level.cells[8]?.[44]))
|
||||
console.log('Cell at 9, 44:', JSON.stringify(result.level.cells[9]?.[44]))
|
||||
console.log('Cell at 10, 44:', JSON.stringify(result.level.cells[10]?.[44]))
|
||||
for (let ty = 6; ty <= 18; ty += 1) {
|
||||
let rowStr = `ty=${ty.toString().padStart(2)}: `
|
||||
for (let tx = 42; tx <= 48; tx += 1) {
|
||||
let cellBlocked = 0
|
||||
for (let sy = 0; sy < 5; sy += 1) {
|
||||
for (let sx = 0; sx < 5; sx += 1) {
|
||||
if (scene.blocked[(ty * 5 + sy) * gridW + (tx * 5 + sx)] !== 0) cellBlocked += 1
|
||||
}
|
||||
}
|
||||
rowStr += ` tx${tx}:${cellBlocked}/25`
|
||||
}
|
||||
console.log(rowStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all disconnected components
|
||||
const visitedAll = new Uint8Array(totalSubTiles)
|
||||
for (let i = 0; i < totalSubTiles; i += 1) {
|
||||
if (dist[i] !== -1) visitedAll[i] = 1
|
||||
}
|
||||
const otherComponents: Array<{ size: number; sampleTile: { x: number; y: number } }> = []
|
||||
for (let i = 0; i < totalSubTiles; i += 1) {
|
||||
if (scene.blocked[i] === 0 && visitedAll[i] === 0) {
|
||||
// BFS this component
|
||||
let cSize = 0
|
||||
const cQueue = [i]
|
||||
visitedAll[i] = 1
|
||||
const sampleTile = { x: (i % gridW) / SUB_TILES_PER_TILE, y: Math.floor(i / gridW) / SUB_TILES_PER_TILE }
|
||||
while (cQueue.length > 0) {
|
||||
const curr = cQueue.pop()!
|
||||
cSize += 1
|
||||
const cx = curr % gridW
|
||||
const cy = Math.floor(curr / gridW)
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const nx = cx + dx
|
||||
const ny = cy + dy
|
||||
if (nx >= 0 && nx < gridW && ny >= 0 && ny < gridH) {
|
||||
const nidx = ny * gridW + nx
|
||||
if (visitedAll[nidx] === 0 && scene.blocked[nidx] === 0) {
|
||||
visitedAll[nidx] = 1
|
||||
cQueue.push(nidx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
otherComponents.push({ size: cSize, sampleTile })
|
||||
}
|
||||
}
|
||||
otherComponents.sort((a, b) => b.size - a.size)
|
||||
if (seed === 12345 || seed === 42 || seed === 359) {
|
||||
console.log(`[Diagnostic Seed ${seed}] Total walkable: ${walkableCount}, Main component: ${largestComponentSize} (${(connectivityRatio * 100).toFixed(1)}%), Other components: ${otherComponents.length}`)
|
||||
console.log(`Top 5 disconnected components:`, otherComponents.slice(0, 5))
|
||||
}
|
||||
|
||||
// Parse landmark coordinates from stats.notes
|
||||
const landmarks: LandmarkTarget[] = []
|
||||
const notes = (result.stats.notes as string[]) ?? []
|
||||
for (const note of notes) {
|
||||
const match = note.match(/Placed ([\w\.-]+).*at \((\d+),\s*(\d+)\)/)
|
||||
if (match) {
|
||||
const name = match[1]!
|
||||
const tileX = parseInt(match[2]!, 10)
|
||||
const tileY = parseInt(match[3]!, 10)
|
||||
let widthTiles = 16
|
||||
let heightTiles = 16
|
||||
if (/Circle|Clover/i.test(name)) {
|
||||
widthTiles = 8
|
||||
heightTiles = 8
|
||||
} else if (/Town/i.test(name)) {
|
||||
widthTiles = 56
|
||||
heightTiles = 8
|
||||
}
|
||||
|
||||
// Test reachability of landmark perimeter or entrance
|
||||
let minDist = Infinity
|
||||
for (let dy = -2; dy <= heightTiles + 2; dy += 1) {
|
||||
for (let dx = -2; dx <= widthTiles + 2; dx += 1) {
|
||||
const cx = tileX + dx
|
||||
const cy = tileY + dy
|
||||
if (cx < 0 || cx >= cellsX || cy < 0 || cy >= cellsY) continue
|
||||
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
||||
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
||||
const gX = cx * SUB_TILES_PER_TILE + sx
|
||||
const gY = cy * SUB_TILES_PER_TILE + sy
|
||||
const idx = gY * gridW + gX
|
||||
const d = dist[idx]
|
||||
if (d !== undefined && d >= 0 && d < minDist) {
|
||||
minDist = d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
name,
|
||||
tileX,
|
||||
tileY,
|
||||
widthTiles,
|
||||
heightTiles,
|
||||
reachable: minDist !== Infinity,
|
||||
minSubTileDist: minDist === Infinity ? -1 : minDist,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Den of Evil reachability
|
||||
const denTarget = landmarks.find(l => /Den|DOE/i.test(l.name))
|
||||
const denTile = denTarget ? { x: denTarget.tileX + 8, y: denTarget.tileY + 8 } : (denVertex ?? { x: 16, y: Math.floor(cellsY / 2) })
|
||||
let minDenDist = Infinity
|
||||
for (let dy = -3; dy <= 3; dy += 1) {
|
||||
for (let dx = -3; dx <= 3; dx += 1) {
|
||||
const cx = denTile.x + dx
|
||||
const cy = denTile.y + dy
|
||||
if (cx < 0 || cx >= cellsX || cy < 0 || cy >= cellsY) continue
|
||||
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
||||
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
||||
const gX = cx * SUB_TILES_PER_TILE + sx
|
||||
const gY = cy * SUB_TILES_PER_TILE + sy
|
||||
const idx = gY * gridW + gX
|
||||
const d = dist[idx]
|
||||
if (d !== undefined && d >= 0 && d < minDenDist) {
|
||||
minDenDist = d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seed === 359) {
|
||||
console.log('Seed 359 denTarget:', denTarget, 'denTile:', denTile, 'minDenDist:', minDenDist, 'landmarks:', landmarks.map(l => ({ name: l.name, x: l.tileX, y: l.tileY, reachable: l.reachable })))
|
||||
}
|
||||
|
||||
// Cold Plains exit reachability (Bridge or East exit)
|
||||
const exitCol = topo.riverColumn ?? cellsX - 2
|
||||
let minExitDist = Infinity
|
||||
let exitSubTileTarget = -1
|
||||
for (let r = 0; r < cellsY; r += 1) {
|
||||
for (let c = exitCol * 8; c < cellsX; c += 1) {
|
||||
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
||||
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
||||
const gX = c * SUB_TILES_PER_TILE + sx
|
||||
const gY = r * SUB_TILES_PER_TILE + sy
|
||||
const idx = gY * gridW + gX
|
||||
const d = dist[idx]
|
||||
if (d !== undefined && d >= 0 && d < minExitDist) {
|
||||
minExitDist = d
|
||||
exitSubTileTarget = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure bottleneck clearance along shortest path to Exit
|
||||
let minClearance = Infinity
|
||||
if (exitSubTileTarget !== -1) {
|
||||
let pCurr = exitSubTileTarget
|
||||
let steps = 0
|
||||
while (pCurr !== startIdx && pCurr !== -1 && steps < 5000) {
|
||||
steps += 1
|
||||
const px = pCurr % gridW
|
||||
const py = Math.floor(pCurr / gridW)
|
||||
// Measure clearance in radius
|
||||
let localWalkable = 0
|
||||
for (let dy = -2; dy <= 2; dy += 1) {
|
||||
for (let dx = -2; dx <= 2; dx += 1) {
|
||||
const sx = px + dx
|
||||
const sy = py + dy
|
||||
if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) {
|
||||
if (scene.blocked[sy * gridW + sx] === 0) localWalkable += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (localWalkable < minClearance) minClearance = localWalkable
|
||||
pCurr = prev[pCurr]!
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
seed,
|
||||
orientation,
|
||||
widthTiles: cellsX,
|
||||
heightTiles: cellsY,
|
||||
totalSubTiles,
|
||||
walkableSubTiles: walkableCount,
|
||||
largestComponentSize,
|
||||
connectivityRatio,
|
||||
townSpawn: { x: startSx, y: startSy },
|
||||
denReachable: minDenDist !== Infinity,
|
||||
denDistance: minDenDist,
|
||||
exitReachable: minExitDist !== Infinity,
|
||||
exitDistance: minExitDist,
|
||||
landmarks,
|
||||
minShortestPathClearance: minClearance === Infinity ? 0 : minClearance,
|
||||
hasBisectingBarrier: minExitDist === Infinity || minDenDist === Infinity,
|
||||
hasVoidTrapAtSpawn: !spawnWalkable || largestComponentSize < 200,
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================= *
|
||||
* SUITE 1: Canonical Seeds & Dual-Orientation Verification
|
||||
* ======================================================================= */
|
||||
describe('Suite 1: Canonical Seeds & Dual-Orientation Verification', () => {
|
||||
it('asserts 100% reachability on horizontal 96x56 Moor (Seed 0x12345678)', () => {
|
||||
const rep = evaluateReachability(0x12345678)
|
||||
expect(rep.orientation).toBe('horizontal')
|
||||
expect(rep.widthTiles).toBe(96)
|
||||
expect(rep.heightTiles).toBe(56)
|
||||
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
||||
expect(rep.hasBisectingBarrier).toBe(false)
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
expect(rep.connectivityRatio).toBeGreaterThan(0.88)
|
||||
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
||||
expect(rep.minShortestPathClearance).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('asserts 100% reachability on horizontal 96x56 Moor (Seed 12345)', () => {
|
||||
const rep = evaluateReachability(12345)
|
||||
expect(rep.orientation).toBe('horizontal')
|
||||
expect(rep.widthTiles).toBe(96)
|
||||
expect(rep.heightTiles).toBe(56)
|
||||
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
||||
expect(rep.hasBisectingBarrier).toBe(false)
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
expect(rep.connectivityRatio).toBeGreaterThan(0.88)
|
||||
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
||||
})
|
||||
|
||||
it('asserts 100% reachability on vertical 56x96 Moor (Seed 1)', () => {
|
||||
const rep = evaluateReachability(1)
|
||||
expect(rep.orientation).toBe('vertical')
|
||||
expect(rep.widthTiles).toBe(56)
|
||||
expect(rep.heightTiles).toBe(96)
|
||||
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
||||
expect(rep.hasBisectingBarrier).toBe(false)
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
expect(rep.connectivityRatio).toBeGreaterThan(0.78)
|
||||
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
||||
})
|
||||
|
||||
it('asserts 100% reachability on vertical 56x96 Moor (Seed 42)', () => {
|
||||
const rep = evaluateReachability(42)
|
||||
expect(rep.orientation).toBe('vertical')
|
||||
expect(rep.widthTiles).toBe(56)
|
||||
expect(rep.heightTiles).toBe(96)
|
||||
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
||||
expect(rep.hasBisectingBarrier).toBe(false)
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
expect(rep.connectivityRatio).toBeGreaterThan(0.78)
|
||||
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ======================================================================= *
|
||||
* SUITE 2: Edge-Case Seeds (0, 0xFFFFFFFF, 0x7FFFFFFF, 666, 999999)
|
||||
* ======================================================================= */
|
||||
describe('Suite 2: Boundary & Extreme Seed Topologies', () => {
|
||||
const boundarySeeds = [0, 666, 999999, 0x7fffffff, 0x5eed2001, 0xdeadbeef, 359]
|
||||
|
||||
for (const s of boundarySeeds) {
|
||||
it(`verifies zero impassable bottlenecks & reachability on Seed ${s}`, () => {
|
||||
const rep = evaluateReachability(s)
|
||||
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
||||
expect(rep.hasBisectingBarrier).toBe(false)
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
const minRatio = rep.orientation === 'horizontal' ? 0.85 : 0.75
|
||||
expect(rep.connectivityRatio).toBeGreaterThan(minRatio)
|
||||
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/* ======================================================================= *
|
||||
* SUITE 3: 50-Seed Fuzzing Sweep (Topological Invariants)
|
||||
* ======================================================================= */
|
||||
describe('Suite 3: 50-Seed Random Fuzzing Sweep', () => {
|
||||
it('verifies 100% topological reachability across 50 pseudo-random seeds', () => {
|
||||
const testSeeds = Array.from({ length: 50 }, (_, i) => 100 + i * 37)
|
||||
|
||||
let horizontalCount = 0
|
||||
let verticalCount = 0
|
||||
let passedCount = 0
|
||||
|
||||
for (const s of testSeeds) {
|
||||
const rep = evaluateReachability(s)
|
||||
if (rep.orientation === 'horizontal') horizontalCount += 1
|
||||
else verticalCount += 1
|
||||
|
||||
if (rep.hasBisectingBarrier) {
|
||||
console.log(`[Suite 3 Failure] Seed ${s}: orientation=${rep.orientation}, denReachable=${rep.denReachable}, exitReachable=${rep.exitReachable}, denDist=${rep.denDistance}, exitDist=${rep.exitDistance}`)
|
||||
}
|
||||
|
||||
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
||||
expect(rep.hasBisectingBarrier, `Seed ${s} has bisecting barrier (denReachable=${rep.denReachable}, exitReachable=${rep.exitReachable})`).toBe(false)
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
const minRatio = rep.orientation === 'horizontal' ? 0.85 : 0.75
|
||||
expect(rep.connectivityRatio).toBeGreaterThan(minRatio)
|
||||
const unreachableLandmarks = rep.landmarks.filter(l => !l.reachable)
|
||||
if (unreachableLandmarks.length > 0) {
|
||||
console.log(`[Suite 3 Landmark Failure] Seed ${s}:`, unreachableLandmarks.map(l => ({ name: l.name, x: l.tileX, y: l.tileY, w: l.widthTiles, h: l.heightTiles })))
|
||||
}
|
||||
expect(unreachableLandmarks, `Seed ${s} has unreachable landmarks`).toEqual([])
|
||||
|
||||
passedCount += 1
|
||||
}
|
||||
|
||||
expect(passedCount).toBe(50)
|
||||
expect(horizontalCount).toBeGreaterThan(15)
|
||||
expect(verticalCount).toBeGreaterThan(15)
|
||||
})
|
||||
})
|
||||
|
||||
/* ======================================================================= *
|
||||
* SUITE 4: Secondary Borders & 2x3 Closures Boundary Non-Trapping
|
||||
* ======================================================================= */
|
||||
describe('Suite 4: Secondary Borders & 2x3 Interior Closures Non-Trapping', () => {
|
||||
it('asserts 2x3 closures (Bord9..12) do not trap player spawn or block critical paths', () => {
|
||||
// In Bord9..12, interior 2x3 closures sit in block (2,2)..(3,3).
|
||||
// We assert that spawn is outside of (2,2)..(3,3), and distance from Town to Exit is finite.
|
||||
const rep = evaluateReachability(0x12345678)
|
||||
const spawnTileX = Math.floor(rep.townSpawn.x / SUB_TILES_PER_TILE)
|
||||
const spawnTileY = Math.floor(rep.townSpawn.y / SUB_TILES_PER_TILE)
|
||||
|
||||
// Spawn must not be inside the 2x3 closure blocks [16..31, 16..31]
|
||||
const inside2x3Closure = spawnTileX >= 16 && spawnTileX < 32 && spawnTileY >= 16 && spawnTileY < 32
|
||||
expect(inside2x3Closure).toBe(false)
|
||||
|
||||
// Shortest path to exit exists and has minimum corridor clearance
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
expect(rep.exitDistance).toBeGreaterThan(0)
|
||||
expect(rep.minShortestPathClearance).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('asserts secondary peninsulas (Bord5..8) do not sever perimeter or town gate', () => {
|
||||
const rep = evaluateReachability(12345)
|
||||
// Town transition is at top or left edge; peninsulas must not seal it off
|
||||
expect(rep.denReachable).toBe(true)
|
||||
expect(rep.exitReachable).toBe(true)
|
||||
expect(rep.hasBisectingBarrier).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/* ======================================================================= *
|
||||
* SUITE 5: River Bridge Crossing & Dirt Road Walkability
|
||||
* ======================================================================= */
|
||||
describe('Suite 5: River Bridge Crossing & Dirt Road Walkability', () => {
|
||||
it('verifies river bridge creates unbroken walkable corridor connecting mainland to exit', () => {
|
||||
const result: WildernessResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x12345678,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
const scene = buildIsoMapScene(result.level, dt1s, levelSeed('bridge_test'))
|
||||
const gridW = scene.gridWidth ?? result.level.width * SUB_TILES_PER_TILE
|
||||
|
||||
// River columns are x in [80..95] in tiles -> sub-tiles [400..479]
|
||||
// Bridge is placed at bridgeRow. Find bridgeRow from stats.notes
|
||||
const riverColStart = (result.level.width - 2) * 8
|
||||
let bridgeCrossingWalkable = false
|
||||
|
||||
// Check all rows across river columns: at least one row must have a continuous walkable sub-tile path across the river
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
let spanWalkable = true
|
||||
for (let x = riverColStart; x < result.level.width; x += 1) {
|
||||
const sy = y * SUB_TILES_PER_TILE + 2
|
||||
const sx = x * SUB_TILES_PER_TILE + 2
|
||||
if (scene.blocked[sy * gridW + sx] !== 0) {
|
||||
spanWalkable = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (spanWalkable) {
|
||||
bridgeCrossingWalkable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(bridgeCrossingWalkable).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
createCanvas,
|
||||
stampAct1DirtRoads,
|
||||
stampAct1InteriorStoneWalls,
|
||||
stampAct2DesertRoads,
|
||||
stampAct2SandstoneBerms,
|
||||
stampAct3KurastAvenues,
|
||||
|
|
@ -215,14 +213,11 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
|||
expect(countWallTiles(canvas, 2, 129)).toBe(0)
|
||||
})
|
||||
|
||||
it('dispatches Act 1 Dirt Roads based on levelId 3 without artificial cross dividers', () => {
|
||||
it('refuses Act I outdoor levels (levelId 3): they come from the DRLG port (src/game/drlg)', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(123)
|
||||
stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
|
||||
stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
|
||||
|
||||
expect(countFloorTiles(canvas, 0, 194)).toBeGreaterThan(40)
|
||||
expect(countWallTiles(canvas, 2, 129)).toBe(0)
|
||||
expect(() => stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/)
|
||||
expect(() => stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/)
|
||||
})
|
||||
|
||||
it('dispatches based on levelTypeName when levelId is generic', () => {
|
||||
|
|
@ -253,17 +248,11 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
|||
expect(countWallTiles(c2, 1, 129)).toBe(0)
|
||||
})
|
||||
|
||||
it('strictly skips Cow Level (levelId 39)', () => {
|
||||
it('refuses the Cow Level (39): an Act I outdoor level generated by the DRLG port', () => {
|
||||
const canvas = createCanvas(width, height, 1, 1, 0)
|
||||
const rng = new Rng(42)
|
||||
stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
|
||||
stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
|
||||
|
||||
expect(countFloorTiles(canvas, 0, 194)).toBe(0)
|
||||
expect(countFloorTiles(canvas, 1, 194)).toBe(0)
|
||||
expect(countFloorTiles(canvas, 5, 194)).toBe(0)
|
||||
expect(countWallTiles(canvas, 1, 129)).toBe(0)
|
||||
expect(countWallTiles(canvas, 2, 129)).toBe(0)
|
||||
expect(() => stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/)
|
||||
expect(() => stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -375,38 +364,24 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
|||
expect(frozenDivides).toBeGreaterThan(15)
|
||||
})
|
||||
|
||||
it('generates Cow Level (Level 39) without roads or stone divider networks', () => {
|
||||
const cowBorder: WildernessPiece = {
|
||||
it.each([2, 3, 4, 5, 6, 7, 17, 39])('refuses Act I outdoor level %i: it comes from the DRLG port (src/game/drlg)', (levelId) => {
|
||||
const act1Border: WildernessPiece = {
|
||||
name: 'Act 1 - Wild Border 1',
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
const result = generateWilderness({
|
||||
levelId: 39,
|
||||
levelName: 'Moo Moo Farm',
|
||||
expect(() => generateWilderness({
|
||||
levelId,
|
||||
levelName: `Act I level ${String(levelId)}`,
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 12345,
|
||||
pieces: [cowBorder],
|
||||
pieces: [act1Border],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
let roadFloors = 0
|
||||
let dividerWalls = 0
|
||||
for (let y = 8; y < 72; y += 1) {
|
||||
for (let x = 8; x < 72; x += 1) {
|
||||
const cell = result.level.cells[y]?.[x]
|
||||
if (!cell) continue
|
||||
if (cell.floors.some(f => (f.style === 0 || f.style === 1 || f.style === 5) && f.prop1 === 194)) roadFloors += 1
|
||||
if (cell.walls.some(w => (w.style === 1 || w.style === 2) && w.prop1 === 129)) dividerWalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(roadFloors).toBe(0)
|
||||
expect(dividerWalls).toBe(0)
|
||||
})).toThrow(/DRLG port/)
|
||||
})
|
||||
|
||||
it('stampThemedRoads preserves preset walls on the highway spine', () => {
|
||||
|
|
@ -416,7 +391,7 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
|||
const hubY = outY * TILES_PER_BLOCK + 4
|
||||
// Preset directly intersects the road spine
|
||||
const placedRects: PlacedPresetRect[] = [
|
||||
{ name: 'Act 1 - Ruin', x: hubX - 2, y: hubY - 2, w: 5, h: 5 },
|
||||
{ name: 'Protected Preset', x: hubX - 2, y: hubY - 2, w: 5, h: 5 },
|
||||
]
|
||||
// Paint preset wall inside the preset on the highway spine
|
||||
const targetCell = canvas.cells[hubY]?.[hubX]
|
||||
|
|
@ -431,7 +406,8 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
|||
hidden: false,
|
||||
})
|
||||
|
||||
stampAct1DirtRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
// Act 2 caravan routes go through the shared stampThemedRoads rasterizer.
|
||||
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
|
||||
|
||||
// The wall inside the preset must NOT have been wiped
|
||||
expect(targetCell!.walls.some(w => w.style === 99 && w.prop1 === 100)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import * as fs from 'fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts'
|
||||
import type { ActTables, D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||
import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
DIRT_PATH_TILE_LUT,
|
||||
TILES_PER_BLOCK,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||||
|
||||
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1-4)', () => {
|
||||
let archives: MountedArchives
|
||||
let tables: ActTables
|
||||
let lvlsubTable: D2Table
|
||||
let act1Pieces: WildernessPiece[]
|
||||
let act1Subs: WildernessSubstitution[]
|
||||
const dt1Cache = new Map<string, Dt1>()
|
||||
|
||||
beforeAll(async () => {
|
||||
archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
tables = await loadActTables(archives)
|
||||
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
|
||||
const families = [
|
||||
'Act 1 - Wild',
|
||||
'Act 1 - Town 1 Transition',
|
||||
'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance',
|
||||
'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus',
|
||||
'Act 1 - Tower',
|
||||
'Act 1 - Graveyard',
|
||||
'Act 1 - Bivouac',
|
||||
'Act 1 - Fallen Camp',
|
||||
'Act 1 - Cottages',
|
||||
'Act 1 - Pond',
|
||||
'Act 1 - Camp',
|
||||
'Act 1 - Ruin',
|
||||
'Act 1 - Stone Fill',
|
||||
'Act 1 - Corral Fill',
|
||||
'Act 1 - Fence Fill',
|
||||
'Act 1 - Swamp Fill',
|
||||
'Act 1 - Tree Fill',
|
||||
]
|
||||
act1Pieces = []
|
||||
for (const row of tables.lvlprest.rows) {
|
||||
const name = cell(tables.lvlprest, row, 'Name')
|
||||
if (!families.some(f => name.startsWith(f))) continue
|
||||
const levels = []
|
||||
for (let i = 1; i <= 6; i += 1) {
|
||||
const f = cell(tables.lvlprest, row, `File${String(i)}`)
|
||||
if (f && f !== '0') {
|
||||
levels.push(decodeDs1(await archives.read(tileMemberPath(f))))
|
||||
}
|
||||
}
|
||||
if (levels.length > 0) {
|
||||
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
||||
}
|
||||
}
|
||||
|
||||
act1Subs = []
|
||||
for (const row of lvlsubTable.rows) {
|
||||
if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue
|
||||
const file = cell(lvlsubTable, row, 'File')
|
||||
if (!file || file === '0') continue
|
||||
const ds1 = decodeDs1(await archives.read(tileMemberPath(file)))
|
||||
const vals = (prefix: string) =>
|
||||
[0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0)
|
||||
act1Subs.push({
|
||||
name: cell(lvlsubTable, row, 'Name'),
|
||||
type: 0,
|
||||
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
||||
prob: vals('Prob'),
|
||||
trials: vals('Trials'),
|
||||
max: vals('Max'),
|
||||
levels: [ds1],
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-cache DT1 libraries for Act 1 wilderness levels
|
||||
for (const id of [2, 3, 4, 5, 6, 7, 17]) {
|
||||
const libraries = resolveLevelLibraries(tables, id)
|
||||
for (const n of libraries.dt1Names) {
|
||||
if (!dt1Cache.has(n)) {
|
||||
dt1Cache.set(n, decodeDt1(await archives.read(n)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
it('DIRT_PATH_TILE_LUT matches exact D2MOO 8-neighbor bitmask tile sequences', () => {
|
||||
expect(DIRT_PATH_TILE_LUT.length).toBe(256)
|
||||
// Straight horizontal road top edge (West + East + South neighbors = 0x6B -> 0x11 = 17)
|
||||
expect(DIRT_PATH_TILE_LUT[0x6b]).toBe(0x11)
|
||||
// Straight horizontal road bottom edge (West + East + North neighbors = 0xD6 -> 0x16 = 22)
|
||||
expect(DIRT_PATH_TILE_LUT[0xd6]).toBe(0x16)
|
||||
// Straight vertical road left edge (North + South + East neighbors = 0xF8 -> 0x18 = 24)
|
||||
expect(DIRT_PATH_TILE_LUT[0xf8]).toBe(0x18)
|
||||
// Straight vertical road right edge (North + South + West neighbors = 0x1F -> 0x14 = 20)
|
||||
expect(DIRT_PATH_TILE_LUT[0x1f]).toBe(0x14)
|
||||
})
|
||||
|
||||
it('places directional borders (Border 1..8) around the level perimeter', () => {
|
||||
const res = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Wilderness 1',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x123456,
|
||||
pieces: act1Pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
const usage = res.stats.borderUsage as Record<string, number>
|
||||
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR).
|
||||
//
|
||||
// Counted by slot number rather than by exact name. Act 1's north, west and
|
||||
// three of its corners prefer rock cliff pieces over wooden fence ones
|
||||
// (`Act 1 - Wild Cliff Border 6A`), and the cliff family also carries
|
||||
// lettered variants — all of which are still "the NW corner piece". What
|
||||
// must hold is that each corner block is stamped exactly once; stamping
|
||||
// twice is what leaves a corner with two overlapping silhouettes.
|
||||
const slotUsage = (slot: number): number => {
|
||||
const pattern = new RegExp(`\\bBorder\\s*0?${String(slot)}[A-Z]?$`, 'i')
|
||||
let total = 0
|
||||
for (const [name, count] of Object.entries(usage)) {
|
||||
if (pattern.test(name)) total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
expect(slotUsage(5)).toBe(1)
|
||||
expect(slotUsage(6)).toBe(1)
|
||||
expect(slotUsage(7)).toBe(1)
|
||||
expect(slotUsage(8)).toBe(1)
|
||||
// Edges: Border 1 (Bottom), Border 2 (Left), Border 3 (Top), Border 4 (Right)
|
||||
expect(slotUsage(1)).toBeGreaterThan(0)
|
||||
expect(slotUsage(2)).toBeGreaterThan(0)
|
||||
expect(slotUsage(3)).toBeGreaterThan(0)
|
||||
expect(slotUsage(4)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ id: 2, name: 'Act 1 - Wilderness 1', expectedPreset: 'Act 1 - DOE Entrance' },
|
||||
{ id: 3, name: 'Act 1 - Wilderness 2', expectedPreset: 'Act 1 - Cave Entrance' },
|
||||
{ id: 4, name: 'Act 1 - Wilderness 3', expectedPreset: 'Act 1 - Cairn Stones' },
|
||||
{ id: 5, name: 'Act 1 - Wilderness 4', expectedPreset: 'Act 1 - Inifus' },
|
||||
{ id: 6, name: 'Act 1 - Wilderness 5', expectedPreset: 'Act 1 - Tower 1' },
|
||||
{ id: 7, name: 'Act 1 - Wilderness 6', expectedPreset: 'Act 1 - Cave Entrance' },
|
||||
{ id: 17, name: 'Act 1 - Graveyard', expectedPreset: 'Act 1 - Graveyard' },
|
||||
])('generates connected dirt roads and special presets for $name (id=$id)', async ({ id, name, expectedPreset }) => {
|
||||
const levelRow = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === id)!
|
||||
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||||
const subTheme = Math.max(0, Number(cell(tables.levels, levelRow, 'SubTheme')))
|
||||
const subs = subType === 0 ? act1Subs : []
|
||||
|
||||
const res = generateWilderness({
|
||||
levelId: id,
|
||||
levelName: name,
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: Number(cell(tables.levels, levelRow, 'SizeX')),
|
||||
sizeY: Number(cell(tables.levels, levelRow, 'SizeY')),
|
||||
subType,
|
||||
subTheme,
|
||||
seed: 0x5eed_1000 + id,
|
||||
pieces: act1Pieces,
|
||||
substitutions: subs,
|
||||
})
|
||||
|
||||
// Verify road stats
|
||||
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
|
||||
expect(Number(res.stats.roadSegments)).toBeGreaterThan(0)
|
||||
expect(Number(res.stats.anchors)).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify special preset placement
|
||||
const presets = res.stats.specialPresets as string[]
|
||||
expect(presets).toContain(expectedPreset)
|
||||
|
||||
// Count actual dirt road tiles (style 0, sequence 1..46) on canvas floor[0]
|
||||
let dirtTileCount = 0
|
||||
for (let y = 0; y < res.level.height; y += 1) {
|
||||
for (let x = 0; x < res.level.width; x += 1) {
|
||||
const floor = res.level.cells[y]?.[x]?.floors[0]
|
||||
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
|
||||
dirtTileCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(dirtTileCount).toBeGreaterThan(30)
|
||||
|
||||
// Verify DT1 tile resolvability (0 missing tiles)
|
||||
const libraries = resolveLevelLibraries(tables, id)
|
||||
const dt1s: Dt1[] = libraries.dt1Names.map(n => dt1Cache.get(n)!)
|
||||
const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test'))
|
||||
expect(scene.missingTiles).toBe(0)
|
||||
}, 15000)
|
||||
|
||||
it('safely handles out-of-bounds road neighbors without corrupting border bitmasks', () => {
|
||||
const res = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Act 1 - Wilderness 1',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x5eed_2001,
|
||||
pieces: act1Pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
|
||||
let dirtRoadTiles = 0
|
||||
for (let y = 0; y < res.level.height; y += 1) {
|
||||
for (let x = 0; x < res.level.width; x += 1) {
|
||||
const floor = res.level.cells[y]?.[x]?.floors[0]
|
||||
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
|
||||
dirtRoadTiles += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(dirtRoadTiles).toBeGreaterThanOrEqual(Number(res.stats.roadCells))
|
||||
})
|
||||
})
|
||||
|
|
@ -115,121 +115,17 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
})
|
||||
})
|
||||
|
||||
describe("Sequential generation with seam interlocking (Blood Moor -> Cold Plains -> Stony Field)", () => {
|
||||
test("interlocks Blood Moor East exit with Cold Plains West entrance", () => {
|
||||
const pieces = makeTestPieces()
|
||||
|
||||
// Step 1: Generate Blood Moor (level 2)
|
||||
const bloodMoorResult = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x123456,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const bloodMoorOpenings = bloodMoorResult.stats.gateOpenings
|
||||
expect(bloodMoorOpenings).toBeDefined()
|
||||
expect(bloodMoorOpenings!.length).toBeGreaterThan(0)
|
||||
|
||||
// Find East exit to Cold Plains (level 3)
|
||||
const eastGate = bloodMoorOpenings!.find(g => g.side === "east" && g.toLevelId === 3)
|
||||
expect(eastGate).toBeDefined()
|
||||
const sharedYBlock = eastGate!.blockCoord
|
||||
expect(sharedYBlock).toBeGreaterThanOrEqual(1)
|
||||
expect(sharedYBlock).toBeLessThanOrEqual(8)
|
||||
|
||||
// Step 2: Generate Cold Plains (level 3) with seam constraint matching Blood Moor's East exit
|
||||
const coldPlainsResult = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0x654321,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
seamConstraints: [
|
||||
{
|
||||
side: "west",
|
||||
fromLevelId: 2,
|
||||
toLevelId: 3,
|
||||
blockCoord: sharedYBlock,
|
||||
...(eastGate!.tileCoord ? { tileCoord: { x: 0, y: eastGate!.tileCoord.y } } : {}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const coldPlainsOpenings = coldPlainsResult.stats.gateOpenings
|
||||
expect(coldPlainsOpenings).toBeDefined()
|
||||
|
||||
// Find West entrance from Blood Moor (level 2)
|
||||
const westGate = coldPlainsOpenings!.find(g => g.side === "west" && g.toLevelId === 2)
|
||||
expect(westGate).toBeDefined()
|
||||
expect(westGate!.blockCoord).toBe(sharedYBlock)
|
||||
|
||||
// If tileCoord was provided, verify along-seam coordinate matches
|
||||
if (eastGate!.tileCoord && westGate!.tileCoord) {
|
||||
expect(westGate!.tileCoord.y).toBe(eastGate!.tileCoord.y)
|
||||
}
|
||||
|
||||
// Step 3: Cold Plains has exit to Stony Field (level 4) on North
|
||||
const northGate = coldPlainsOpenings!.find(g => g.side === "north" && g.toLevelId === 4)
|
||||
expect(northGate).toBeDefined()
|
||||
const sharedXBlock = northGate!.blockCoord
|
||||
expect(sharedXBlock).toBeGreaterThanOrEqual(1)
|
||||
expect(sharedXBlock).toBeLessThanOrEqual(8)
|
||||
|
||||
// Step 4: Generate Stony Field (level 4) with seam constraint on South matching Cold Plains North exit
|
||||
const stonyFieldResult = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 0xabcdef,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
seamConstraints: [
|
||||
{
|
||||
side: "south",
|
||||
fromLevelId: 3,
|
||||
toLevelId: 4,
|
||||
blockCoord: sharedXBlock,
|
||||
...(northGate!.tileCoord ? { tileCoord: { x: northGate!.tileCoord.x, y: 79 } } : {}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const stonyFieldOpenings = stonyFieldResult.stats.gateOpenings
|
||||
expect(stonyFieldOpenings).toBeDefined()
|
||||
|
||||
const southGate = stonyFieldOpenings!.find(g => g.side === "south" && g.toLevelId === 3)
|
||||
expect(southGate).toBeDefined()
|
||||
expect(southGate!.blockCoord).toBe(sharedXBlock)
|
||||
|
||||
if (northGate!.tileCoord && southGate!.tileCoord) {
|
||||
expect(southGate!.tileCoord.x).toBe(northGate!.tileCoord.x)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Act I outdoor levels come from the DRLG port (src/game/drlg); their seams are checked on the
|
||||
// port's act layout in tests/drlg-act1-population.test.ts. The planned-gate / seam-constraint
|
||||
// machinery below is shared by Acts 2-5 and runs on the Act II desert levels (41 Rocky Waste,
|
||||
// 42 Dry Hills, 43 Far Oasis, 56 Halls of the Dead).
|
||||
describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => {
|
||||
test("honors gates with explicit blockCoord", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -238,9 +134,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
pieces,
|
||||
substitutions: [],
|
||||
gates: [
|
||||
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: 3 },
|
||||
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 6 },
|
||||
{ label: "Underground", side: "east", toLevelId: 10, blockCoord: 7 },
|
||||
{ label: "Rocky Waste", side: "west", toLevelId: 41, blockCoord: 3 },
|
||||
{ label: "Far Oasis", side: "north", toLevelId: 43, blockCoord: 6 },
|
||||
{ label: "Halls of the Dead", side: "east", toLevelId: 56, blockCoord: 7 },
|
||||
],
|
||||
})
|
||||
|
||||
|
|
@ -261,9 +157,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
const pieces = makeTestPieces()
|
||||
const targetTileY = 8 * 4 + 3 // block 4, tile 35
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -272,7 +168,7 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
pieces,
|
||||
substitutions: [],
|
||||
gates: [
|
||||
{ label: "Blood Moor", side: "west", toLevelId: 2, tileCoord: { x: 3, y: targetTileY } },
|
||||
{ label: "Rocky Waste", side: "west", toLevelId: 41, tileCoord: { x: 3, y: targetTileY } },
|
||||
],
|
||||
})
|
||||
|
||||
|
|
@ -290,9 +186,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
const targetSeamY = 5
|
||||
|
||||
const levelA = generateWilderness({
|
||||
levelId: 2,
|
||||
levelId: 41,
|
||||
levelName: "Level A",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -300,13 +196,13 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
seed: 0x111,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
gates: [{ label: "Level B", side: "east", toLevelId: 3, blockCoord: targetSeamY }],
|
||||
gates: [{ label: "Level B", side: "east", toLevelId: 42, blockCoord: targetSeamY }],
|
||||
})
|
||||
|
||||
const levelB = generateWilderness({
|
||||
levelId: 3,
|
||||
levelId: 42,
|
||||
levelName: "Level B",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -314,7 +210,7 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
seed: 0x222,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: targetSeamY }],
|
||||
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: targetSeamY }],
|
||||
})
|
||||
|
||||
const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")!
|
||||
|
|
@ -331,9 +227,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
const targetSeamX = 6
|
||||
|
||||
const levelB = generateWilderness({
|
||||
levelId: 3,
|
||||
levelId: 42,
|
||||
levelName: "Level B",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -341,13 +237,13 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
seed: 0x333,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
gates: [{ label: "Level C", side: "north", toLevelId: 4, blockCoord: targetSeamX }],
|
||||
gates: [{ label: "Level C", side: "north", toLevelId: 43, blockCoord: targetSeamX }],
|
||||
})
|
||||
|
||||
const levelC = generateWilderness({
|
||||
levelId: 4,
|
||||
levelId: 43,
|
||||
levelName: "Level C",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -355,7 +251,10 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
seed: 0x444,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
seamConstraints: [{ side: "south", fromLevelId: 3, toLevelId: 4, blockCoord: targetSeamX }],
|
||||
// Only the west/east gates are defaults; the south seam gate is planned and takes its block
|
||||
// from the seam constraint.
|
||||
gates: [{ label: "Level B", side: "south", toLevelId: 42 }],
|
||||
seamConstraints: [{ side: "south", fromLevelId: 42, toLevelId: 43, blockCoord: targetSeamX }],
|
||||
})
|
||||
|
||||
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")!
|
||||
|
|
@ -372,9 +271,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
test("clamps negative blockCoord to 1 without crashing", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -382,19 +281,19 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
seed: 0x555,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: -10 }],
|
||||
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: -10 }],
|
||||
})
|
||||
|
||||
const west = result.stats.gateOpenings!.find(g => g.side === "west")
|
||||
expect(west?.blockCoord).toBe(1)
|
||||
})
|
||||
|
||||
test("clamps excessive blockCoord to gridWidth - 2 without crashing", () => {
|
||||
test("clamps excessive blockCoord to gridHeight - 2 without crashing", () => {
|
||||
const pieces = makeTestPieces()
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -402,11 +301,11 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
|
|||
seed: 0x666,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
seamConstraints: [{ side: "north", fromLevelId: 4, toLevelId: 3, blockCoord: 999 }],
|
||||
seamConstraints: [{ side: "east", fromLevelId: 43, toLevelId: 42, blockCoord: 999 }],
|
||||
})
|
||||
|
||||
const north = result.stats.gateOpenings!.find(g => g.side === "north")
|
||||
expect(north?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
|
||||
const east = result.stats.gateOpenings!.find(g => g.side === "east")
|
||||
expect(east?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
/**
|
||||
* Wilderness Seed Sensitivity & Act 1 Regression Guard Test Suite (Milestone M1).
|
||||
* Wilderness Seed Sensitivity Test Suite (Milestone M1).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Act 1 outdoor levels remain 100% bit-identical to the pre-recorded baseline table
|
||||
* across seeds 0x301cd095, 0x416d61c5, and 0xdeadbeef.
|
||||
* 2. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5.
|
||||
* 3. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway).
|
||||
* 4. Deterministic re-run invariance holds unconditionally.
|
||||
* 1. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5.
|
||||
* 2. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway).
|
||||
* 3. Deterministic re-run invariance holds unconditionally.
|
||||
*
|
||||
* Act I outdoor levels come from the DRLG port (src/game/drlg) and are covered by
|
||||
* tests/drlg-act1-oracle.test.ts (bit-exact parity with D2) and tests/drlg-act1-population.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeAll } from 'vitest'
|
||||
|
|
@ -125,21 +126,6 @@ function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
|
|||
|
||||
const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
||||
|
||||
/**
|
||||
* Baseline hashes for Act 1 outdoor levels under seeds [0x301cd095, 0x416d61c5, 0xdeadbeef].
|
||||
* These hashes must remain 100% bit-identical to prevent regressions in Act 1.
|
||||
*/
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
|
||||
4: ['378d6251', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
|
||||
6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Test Suite 1: Hermetic API & Strict Contract Safety
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
|
@ -233,7 +219,9 @@ describe('Hermetic Contract & RNG Parameter Safety', () => {
|
|||
|
||||
const hasMpqArchives = existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 1..5', () => {
|
||||
// Act I outdoor levels come from the DRLG port (src/game/drlg): its determinism and seed sensitivity
|
||||
// are checked in tests/drlg-act1-population.test.ts, its parity with D2 in tests/drlg-act1-oracle.test.ts.
|
||||
describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 2..5', () => {
|
||||
let archives: MountedArchives
|
||||
let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table }
|
||||
let lvlsub: D2Table
|
||||
|
|
@ -260,15 +248,6 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
|
|||
}
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||
'Act 1 - Wilderness': [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
],
|
||||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||
|
|
@ -287,7 +266,7 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
|
|||
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||
const levels = await rowDs1s(tables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
|
||||
const isBorder = /border|cliff/i.test(name)
|
||||
pieces.push({ name, levels, border: isBorder })
|
||||
}
|
||||
return pieces
|
||||
|
|
@ -366,22 +345,6 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
|
|||
return result.level
|
||||
}
|
||||
|
||||
it('Act 1 baseline hashes remain 100% bit-identical to baseline table', async () => {
|
||||
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
|
||||
|
||||
for (const levelId of act1Levels) {
|
||||
const expected = ACT1_BASELINE_HASHES[levelId]!
|
||||
const actual: string[] = []
|
||||
|
||||
for (const seed of TEST_SEEDS) {
|
||||
const level = await generateLevelForSeed(levelId, seed)
|
||||
actual.push(canonicalHash(level))
|
||||
}
|
||||
|
||||
expect(actual).toEqual(expected)
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
it('Act 2 procedural outdoor levels produce distinct hashes across seeds', async () => {
|
||||
const act2Levels = [41, 42, 43, 44, 45, 46]
|
||||
|
||||
|
|
@ -435,8 +398,8 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
|
|||
}, 60000)
|
||||
|
||||
it('Wilderness generation determinism invariance holds across repeated runs', async () => {
|
||||
// Spot check one level per Act
|
||||
const sampleLevels = [2, 41, 79, 104, 111]
|
||||
// Spot check one level per Act (Act I: tests/drlg-act1-population.test.ts)
|
||||
const sampleLevels = [41, 79, 104, 111]
|
||||
for (const levelId of sampleLevels) {
|
||||
const seed = TEST_SEEDS[0]
|
||||
const first = await generateLevelForSeed(levelId, seed)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import * as fs from 'fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
|
||||
import type { ActTables } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Rng } from '../src/game/rng.ts'
|
||||
import {
|
||||
catmullRomPoint,
|
||||
|
|
@ -14,22 +7,15 @@ import {
|
|||
DRLGVER_CreateVertices,
|
||||
buildOutdoorRoadPolylines,
|
||||
buildRoadNetworkPolylines,
|
||||
rasterizeDirtPathSegment,
|
||||
generateDirtPaths,
|
||||
createCanvas,
|
||||
generateWilderness,
|
||||
DIRT_PATH_TILE_LUT,
|
||||
TILES_PER_BLOCK,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import type {
|
||||
Act1OutdoorPlan,
|
||||
DrlgVertex,
|
||||
WildernessPiece,
|
||||
WildernessSubstitution,
|
||||
} from '../src/game/wilderness.ts'
|
||||
|
||||
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||
import type { Act1OutdoorPlan } from '../src/game/wilderness.ts'
|
||||
|
||||
// Act I outdoor levels (2-7, 17, 39) come from the 1:1 D2MOO DRLG port in
|
||||
// src/game/drlg (verified by tests/drlg-act1-oracle.test.ts); the Act I dirt
|
||||
// path rasterizer and its end-to-end cases were removed with the invented
|
||||
// generator. The vertex-graph / spline helpers below are still used by the
|
||||
// Acts 2-5 wilderness generator.
|
||||
describe('Control Vertex Graph & Spline Roads (Issue #66)', () => {
|
||||
describe('Catmull-Rom Spline Mathematics & Sampling', () => {
|
||||
it('accurately interpolates start and end control points at t=0 and t=1', () => {
|
||||
|
|
@ -264,228 +250,4 @@ describe('Control Vertex Graph & Spline Roads (Issue #66)', () => {
|
|||
expect(obstacleBlocks[hubBy]![hubBx]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dirt Path Rasterization & Obstacle Clearance', () => {
|
||||
it('rasterizes 2-3 cell wide continuous dirt road onto canvas', () => {
|
||||
const canvas = createCanvas(80, 80, 1, 1, 1)
|
||||
const polylines = [
|
||||
[
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 20, y: 22 },
|
||||
{ x: 30, y: 25 },
|
||||
{ x: 40, y: 30 },
|
||||
{ x: 50, y: 38 },
|
||||
],
|
||||
]
|
||||
|
||||
const roadRes = generateDirtPaths(canvas, polylines)
|
||||
expect(roadRes.roadCells).toBeGreaterThan(20)
|
||||
expect(roadRes.roadSegments).toBe(polylines[0]!.length - 1)
|
||||
|
||||
// Check canvas floors contain road styles (style 0, sequences 1..46)
|
||||
let stampedRoadFloors = 0
|
||||
for (let y = 0; y < 80; y += 1) {
|
||||
for (let x = 0; x < 80; x += 1) {
|
||||
const floor = canvas.cells[y]?.[x]?.floors[0]
|
||||
if (floor && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
|
||||
stampedRoadFloors += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(stampedRoadFloors).toBe(roadRes.roadCells)
|
||||
})
|
||||
|
||||
it('strictly avoids painting dirt road cells inside roadObstacleBlocks', () => {
|
||||
const canvas = createCanvas(80, 80, 1, 1, 1)
|
||||
const roadObstacleBlocks = Array.from({ length: 10 }, () =>
|
||||
Array.from({ length: 10 }, () => false),
|
||||
)
|
||||
// Block block (3, 3) -> cells [24..31, 24..31]
|
||||
roadObstacleBlocks[3]![3] = true
|
||||
|
||||
// Polyline that passes near or through block 3
|
||||
const polylines = [
|
||||
[
|
||||
{ x: 10, y: 28 },
|
||||
{ x: 26, y: 28 },
|
||||
{ x: 45, y: 28 },
|
||||
],
|
||||
]
|
||||
|
||||
generateDirtPaths(canvas, polylines, roadObstacleBlocks)
|
||||
|
||||
// Verify no cell in block (3, 3) was stamped with dirt road
|
||||
for (let y = 24; y < 32; y += 1) {
|
||||
for (let x = 24; x < 32; x += 1) {
|
||||
const floor = canvas.cells[y]?.[x]?.floors[0]
|
||||
expect(floor?.sequence).not.toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('correctly maps 8-neighbor bitmask to DIRT_PATH_TILE_LUT sequence', () => {
|
||||
const grid = new Uint8Array(20 * 20)
|
||||
// Place a cross of dirt road cells:
|
||||
// (10, 10), (10, 9), (10, 11), (9, 10), (11, 10)
|
||||
grid[10 * 20 + 10] = 1
|
||||
grid[9 * 20 + 10] = 1
|
||||
grid[11 * 20 + 10] = 1
|
||||
grid[10 * 20 + 9] = 1
|
||||
grid[10 * 20 + 11] = 1
|
||||
|
||||
// Compute bitmask for center (10, 10):
|
||||
// top (10, 9) = bit 4 (0x10)
|
||||
// bottom (10, 11) = bit 3 (0x08)
|
||||
// left (9, 10) = bit 1 (0x02)
|
||||
// right (11, 10) = bit 6 (0x40)
|
||||
const expectedMask = 0x10 | 0x08 | 0x02 | 0x40
|
||||
const expectedSeq = DIRT_PATH_TILE_LUT[expectedMask]
|
||||
expect(expectedSeq).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasD2)('End-to-End Spline Roads in Act 1 Wilderness Generation', () => {
|
||||
let archives: MountedArchives
|
||||
let tables: ActTables
|
||||
let lvlsubTable: any
|
||||
let act1Pieces: WildernessPiece[]
|
||||
let act1Subs: WildernessSubstitution[]
|
||||
|
||||
beforeAll(async () => {
|
||||
archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
tables = await loadActTables(archives)
|
||||
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
|
||||
const families = [
|
||||
'Act 1 - Wild',
|
||||
'Act 1 - Town 1 Transition',
|
||||
'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance',
|
||||
'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus',
|
||||
'Act 1 - Tower',
|
||||
'Act 1 - Graveyard',
|
||||
'Act 1 - Bivouac',
|
||||
'Act 1 - Fallen Camp',
|
||||
'Act 1 - Cottages',
|
||||
'Act 1 - Pond',
|
||||
'Act 1 - Camp',
|
||||
'Act 1 - Ruin',
|
||||
'Act 1 - Stone Fill',
|
||||
'Act 1 - Corral Fill',
|
||||
'Act 1 - Fence Fill',
|
||||
'Act 1 - Swamp Fill',
|
||||
'Act 1 - Tree Fill',
|
||||
]
|
||||
act1Pieces = []
|
||||
for (const row of tables.lvlprest.rows) {
|
||||
const name = cell(tables.lvlprest, row, 'Name')
|
||||
if (!families.some(f => name.startsWith(f))) continue
|
||||
const levels = []
|
||||
for (let i = 1; i <= 6; i += 1) {
|
||||
const f = cell(tables.lvlprest, row, `File${String(i)}`)
|
||||
if (f && f !== '0') {
|
||||
levels.push(decodeDs1(await archives.read(tileMemberPath(f))))
|
||||
}
|
||||
}
|
||||
if (levels.length > 0) {
|
||||
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
||||
}
|
||||
}
|
||||
|
||||
act1Subs = []
|
||||
for (const row of lvlsubTable.rows) {
|
||||
if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue
|
||||
const file = cell(lvlsubTable, row, 'File')
|
||||
if (!file || file === '0') continue
|
||||
const ds1 = decodeDs1(await archives.read(tileMemberPath(file)))
|
||||
const vals = (prefix: string) =>
|
||||
[0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0)
|
||||
act1Subs.push({
|
||||
name: cell(lvlsubTable, row, 'Name'),
|
||||
type: 0,
|
||||
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
||||
prob: vals('Prob'),
|
||||
trials: vals('Trials'),
|
||||
max: vals('Max'),
|
||||
levels: [ds1],
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
it('populates drlgVertices with gates, hub, and special preset anchors', () => {
|
||||
const res = generateWilderness({
|
||||
levelId: 2, // Blood Moor (Den of Evil)
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x5eed_0002,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
expect(res.stats.drlgVertices).toBeDefined()
|
||||
const vertices = res.stats.drlgVertices as DrlgVertex[]
|
||||
expect(vertices.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
const vertexTypes = vertices.map(v => v.type)
|
||||
expect(vertexTypes).toContain('hub')
|
||||
expect(vertexTypes).toContain('gate')
|
||||
|
||||
// Blood Moor has Den of Evil preset
|
||||
expect(vertexTypes).toContain('preset')
|
||||
|
||||
expect(res.stats.roadPolylines).toBeDefined()
|
||||
const polylines = res.stats.roadPolylines as { x: number; y: number }[][]
|
||||
expect(polylines.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify roadCells and dirt road floor stamping
|
||||
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('generates smooth spline road in Stony Field connecting Cairn Stones to Gates', () => {
|
||||
const res = generateWilderness({
|
||||
levelId: 4, // Stony Field (Cairn Stones)
|
||||
levelName: 'Stony Field',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 0x5eed_0004,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const vertices = res.stats.drlgVertices as DrlgVertex[]
|
||||
expect(vertices).toBeDefined()
|
||||
const cairnVertex = vertices.find(v => v.label?.includes('Cairn Stones') || v.type === 'preset')
|
||||
expect(cairnVertex).toBeDefined()
|
||||
|
||||
const hubVertex = vertices.find(v => v.type === 'hub')
|
||||
expect(hubVertex).toBeDefined()
|
||||
|
||||
// Spline polylines connect to hub
|
||||
const polylines = res.stats.roadPolylines as { x: number; y: number }[][]
|
||||
expect(polylines.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify all polyline points are within map bounds
|
||||
for (const line of polylines) {
|
||||
for (const pt of line) {
|
||||
expect(pt.x).toBeGreaterThanOrEqual(0)
|
||||
expect(pt.x).toBeLessThan(res.level.width)
|
||||
expect(pt.y).toBeGreaterThanOrEqual(0)
|
||||
expect(pt.y).toBeLessThan(res.level.height)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,480 +0,0 @@
|
|||
/**
|
||||
* Wilderness Seed Fuzzing & Stress Testing (Issue #455).
|
||||
*
|
||||
* Empirical Challenger Verification Suite:
|
||||
* - Sweeps >= 100 seeds across boundary values (0, 1, 0xFFFFFFFF, INT32_MAX, etc.),
|
||||
* adversarial inputs, primes, and uniform pseudo-random distributions.
|
||||
* - Asserts 5 critical invariants across ALL seeds:
|
||||
* 1. Dimensions are strictly 96x56 or 56x96 for Blood Moor.
|
||||
* 2. Zero preset overlaps across all placed landmarks.
|
||||
* 3. River position invariant (outer columns x = nGridWidth - 2 when river flag active).
|
||||
* 4. Performance budget (< 50ms per map generation).
|
||||
* 5. Road-to-river bridge connectivity (no water moat severing the road from the bridge).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
|
||||
import {
|
||||
generateWilderness,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
type WildernessResult,
|
||||
} from '../src/game/wilderness.ts'
|
||||
import { solveAct1WildernessTopology } from '../src/game/wilderness/wilderness-topology.ts'
|
||||
|
||||
interface PresetRect {
|
||||
readonly name: string
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly w: number
|
||||
readonly h: number
|
||||
}
|
||||
|
||||
function rectsOverlap(a: PresetRect, b: PresetRect): boolean {
|
||||
return (
|
||||
a.x < b.x + b.w &&
|
||||
a.x + a.w > b.x &&
|
||||
a.y < b.y + b.h &&
|
||||
a.y + a.h > b.y
|
||||
)
|
||||
}
|
||||
|
||||
function extractPlacedPresets(notes: readonly string[]): PresetRect[] {
|
||||
const presets: PresetRect[] = []
|
||||
for (const note of notes) {
|
||||
const m = /Placed ([^ ]+) .*? at \((\d+), (\d+)\)/.exec(note)
|
||||
if (!m) continue
|
||||
const name = m[1]!
|
||||
const x = parseInt(m[2]!, 10)
|
||||
const y = parseInt(m[3]!, 10)
|
||||
let w = 8
|
||||
let h = 8
|
||||
if (/Town.*Trans/i.test(name)) {
|
||||
w = 56
|
||||
h = 8
|
||||
} else if (/Den|DOE/i.test(name)) {
|
||||
w = 16
|
||||
h = 16
|
||||
} else if (/Pond/i.test(name)) {
|
||||
w = 16
|
||||
h = 16
|
||||
} else if (/Cott/i.test(name)) {
|
||||
w = 16
|
||||
h = 16
|
||||
} else if (/Circle/i.test(name)) {
|
||||
w = 8
|
||||
h = 8
|
||||
} else if (/Clover/i.test(name)) {
|
||||
w = 8
|
||||
h = 8
|
||||
}
|
||||
presets.push({ name, x, y, w, h })
|
||||
}
|
||||
return presets
|
||||
}
|
||||
|
||||
function evaluateDirtRoadConnectivity(cells: Array<{ x: number; y: number }>): {
|
||||
totalCells: number
|
||||
componentCount: number
|
||||
largestComponentSize: number
|
||||
} {
|
||||
if (cells.length === 0) {
|
||||
return { totalCells: 0, componentCount: 0, largestComponentSize: 0 }
|
||||
}
|
||||
const cellSet = new Set(cells.map(c => `${c.x},${c.y}`))
|
||||
const visited = new Set<string>()
|
||||
const components: string[][] = []
|
||||
|
||||
for (const key of cellSet) {
|
||||
if (visited.has(key)) continue
|
||||
const queue = [key]
|
||||
visited.add(key)
|
||||
const comp = [key]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const [cx, cy] = current.split(',').map(Number) as [number, number]
|
||||
for (const [dx, dy] of [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||
]) {
|
||||
const neighbor = `${cx + dx},${cy + dy}`
|
||||
if (cellSet.has(neighbor) && !visited.has(neighbor)) {
|
||||
visited.add(neighbor)
|
||||
queue.push(neighbor)
|
||||
comp.push(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
components.push(comp)
|
||||
}
|
||||
|
||||
// Filter out isolated floor patches from landmark presets & borders (< 60 tiles)
|
||||
// so only full road and bridge structures are evaluated
|
||||
const roadComponents = components.filter(c => c.length >= 60)
|
||||
|
||||
return {
|
||||
totalCells: cells.length,
|
||||
componentCount: roadComponents.length,
|
||||
largestComponentSize: roadComponents[0]?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic seed generator for 128 seeds
|
||||
function generateFuzzSeeds(): number[] {
|
||||
const boundarySeeds: number[] = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
42,
|
||||
12345,
|
||||
65535,
|
||||
65536,
|
||||
0x12345678,
|
||||
0x55555555,
|
||||
0x7FFFFFFF,
|
||||
0x80000000,
|
||||
0x80000001,
|
||||
0xAAAAAAAA,
|
||||
0xDEADBEEF,
|
||||
0xCAFEBABE,
|
||||
0xFFFFFFFE,
|
||||
0xFFFFFFFF,
|
||||
7919,
|
||||
104729,
|
||||
]
|
||||
|
||||
let state = 0x455 ^ 0x113c
|
||||
const nextRand = (): number => {
|
||||
let t = (state += 0x6D2B79F5)
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return (t ^ (t >>> 14)) >>> 0
|
||||
}
|
||||
|
||||
const fuzzSeeds = [...boundarySeeds]
|
||||
while (fuzzSeeds.length < 128) {
|
||||
fuzzSeeds.push(nextRand())
|
||||
}
|
||||
|
||||
return fuzzSeeds
|
||||
}
|
||||
|
||||
const hasD2Archives = existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
describe.skipIf(!hasD2Archives)('Challenger 1: Empirical Wilderness Seed Fuzzing & Stress Testing (Issue #455)', () => {
|
||||
let act1Pieces: WildernessPiece[]
|
||||
let act1Subs: WildernessSubstitution[]
|
||||
|
||||
const WILDERNESS_PIECE_FAMILIES = [
|
||||
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
||||
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
||||
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
||||
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
||||
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
||||
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
||||
'Act 1 - Graveyard',
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
try {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
} catch {
|
||||
// Skip optional archive if missing
|
||||
}
|
||||
}
|
||||
|
||||
const actTables = await loadActTables(archives)
|
||||
const lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||
const ds1Cache = new Map<string, Ds1>()
|
||||
|
||||
async function loadDs1(relative: string): Promise<Ds1> {
|
||||
const member = tileMemberPath(relative)
|
||||
const cached = ds1Cache.get(member)
|
||||
if (cached !== undefined) return cached
|
||||
const decoded = decodeDs1(await archives.read(member))
|
||||
ds1Cache.set(member, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||
const levels: Ds1[] = []
|
||||
for (let slot = 1; slot <= 6; slot += 1) {
|
||||
const val = cell(table, row, `File${String(slot)}`)
|
||||
if (val === '' || val === '0') continue
|
||||
levels.push(await loadDs1(val))
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
// Load Act 1 wilderness preset pieces
|
||||
act1Pieces = []
|
||||
for (const row of actTables.lvlprest.rows) {
|
||||
const name = cell(actTables.lvlprest, row, 'Name')
|
||||
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
|
||||
const levels = await rowDs1s(actTables.lvlprest, row)
|
||||
if (levels.length === 0) continue
|
||||
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
||||
}
|
||||
|
||||
// Load Act 1 substitutions (SubTypes 0, 1, 6)
|
||||
act1Subs = []
|
||||
for (const row of lvlsubTable.rows) {
|
||||
const subType = Number(cell(lvlsubTable, row, 'Type'))
|
||||
if (subType !== 0 && subType !== 1 && subType !== 6) continue
|
||||
const file = cell(lvlsubTable, row, 'File')
|
||||
if (file === '' || file === '0') continue
|
||||
const levels = [await loadDs1(file)]
|
||||
act1Subs.push({
|
||||
name: cell(lvlsubTable, row, 'Name'),
|
||||
type: subType,
|
||||
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
||||
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
||||
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
||||
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
|
||||
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
|
||||
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
|
||||
levels,
|
||||
})
|
||||
}
|
||||
|
||||
// Warm-up JIT
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 12345 + i,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const seeds = generateFuzzSeeds()
|
||||
|
||||
it('Stress Test 1: Sizing invariant across 128 seeds (strictly 96x56 or 56x96)', () => {
|
||||
let horizontalCount = 0
|
||||
let verticalCount = 0
|
||||
|
||||
for (const seed of seeds) {
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const isHorizontal = result.level.width === 96 && result.level.height === 56
|
||||
const isVertical = result.level.width === 56 && result.level.height === 96
|
||||
expect(
|
||||
isHorizontal || isVertical,
|
||||
`Seed 0x${(seed >>> 0).toString(16)} generated invalid dimensions ${result.level.width}x${result.level.height}`,
|
||||
).toBe(true)
|
||||
|
||||
if (isHorizontal) horizontalCount += 1
|
||||
if (isVertical) verticalCount += 1
|
||||
}
|
||||
|
||||
expect(horizontalCount + verticalCount).toBe(seeds.length)
|
||||
expect(horizontalCount).toBeGreaterThan(0)
|
||||
expect(verticalCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('Stress Test 2: Landmark preset anti-overlap invariant across 128 seeds (0 collisions)', () => {
|
||||
for (const seed of seeds) {
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const notes = result.stats.notes ?? []
|
||||
const presets = extractPlacedPresets(notes)
|
||||
|
||||
expect(presets.some(p => /Town.*Trans/i.test(p.name)), `Seed 0x${seed.toString(16)} missing TownSTrans`).toBe(true)
|
||||
expect(presets.some(p => /Den|DOE/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Den of Evil`).toBe(true)
|
||||
expect(presets.some(p => /Pond/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Pond1`).toBe(true)
|
||||
expect(presets.some(p => /Cott/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Cottage`).toBe(true)
|
||||
expect(presets.some(p => /Circle/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Circle`).toBe(true)
|
||||
expect(presets.some(p => /Clover/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Clover`).toBe(true)
|
||||
|
||||
for (let a = 0; a < presets.length; a += 1) {
|
||||
const pa = presets[a]!
|
||||
expect(pa.x).toBeGreaterThanOrEqual(0)
|
||||
expect(pa.y).toBeGreaterThanOrEqual(0)
|
||||
expect(pa.x + pa.w).toBeLessThanOrEqual(result.level.width)
|
||||
expect(pa.y + pa.h).toBeLessThanOrEqual(result.level.height)
|
||||
|
||||
for (let b = a + 1; b < presets.length; b += 1) {
|
||||
const pb = presets[b]!
|
||||
const overlap = rectsOverlap(pa, pb)
|
||||
expect(
|
||||
overlap,
|
||||
`Seed 0x${seed.toString(16)} collision between ${pa.name} and ${pb.name}`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Stress Test 3: River outer column position invariant across 128 seeds (x = nGridWidth - 2)', () => {
|
||||
let riverLayouts = 0
|
||||
for (const seed of seeds) {
|
||||
const topo = solveAct1WildernessTopology(seed, 2)
|
||||
if (!topo.hasRiver) continue
|
||||
riverLayouts += 1
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
const riverStartX = topo.riverColumn * 8
|
||||
let waterInRiver = 0
|
||||
let waterOutsideRiver = 0
|
||||
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cellRecord = result.level.cells[y]?.[x]
|
||||
if (cellRecord?.floors.some(f => f.style === 2)) {
|
||||
if (x >= riverStartX) {
|
||||
waterInRiver += 1
|
||||
} else {
|
||||
waterOutsideRiver += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(waterInRiver, `Seed 0x${seed.toString(16)} missing river water tiles`).toBeGreaterThan(0)
|
||||
expect(waterOutsideRiver, `Seed 0x${seed.toString(16)} leaked river water into mainland (x < ${riverStartX})`).toBe(0)
|
||||
}
|
||||
expect(riverLayouts).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('Stress Test 4: Performance budget (< 50ms per map generation) across 128 seeds', () => {
|
||||
const times: number[] = []
|
||||
for (const seed of seeds) {
|
||||
const t0 = performance.now()
|
||||
generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
const elapsed = performance.now() - t0
|
||||
times.push(elapsed)
|
||||
expect(elapsed, `Seed 0x${seed.toString(16)} exceeded budget: ${elapsed.toFixed(2)}ms`).toBeLessThan(75)
|
||||
}
|
||||
|
||||
const sorted = [...times].sort((a, b) => a - b)
|
||||
const min = sorted[0]!
|
||||
const max = sorted[sorted.length - 1]!
|
||||
const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)]!
|
||||
|
||||
expect(avg).toBeLessThan(35)
|
||||
expect(max).toBeLessThan(75)
|
||||
expect(p95).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('Stress Test 5 (ADVERSARIAL CHALLENGE): Road network connectivity to bridge crossing without water moat severance', () => {
|
||||
// Collect all seeds where river and bridge are generated
|
||||
for (const seed of seeds) {
|
||||
const topo = solveAct1WildernessTopology(seed, 2)
|
||||
if (!topo.hasRiver) continue
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Blood Moor',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed,
|
||||
pieces: act1Pieces,
|
||||
substitutions: act1Subs,
|
||||
})
|
||||
|
||||
// Collect all road & bridge tiles across the entire canvas
|
||||
const roadCells: Array<{ x: number; y: number }> = []
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cellRecord = result.level.cells[y]?.[x]
|
||||
if (cellRecord?.floors.some(f => (f.style === 0 && f.sequence >= 1 && f.sequence <= 46 && f.prop1 === 194) || (f.style === 1 && f.prop1 === 194) || (f.style === 4 && f.prop1 === 194))) {
|
||||
roadCells.push({ x, y })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectivity = evaluateDirtRoadConnectivity(roadCells)
|
||||
|
||||
// Bug detected: river bridge wooden planks are stamped with style 1, but separated
|
||||
// from the mainland dirt road by a 2-tile gap of deep water (style 2), fragmenting
|
||||
// the road into 2 disconnected components!
|
||||
expect(
|
||||
connectivity.componentCount,
|
||||
`Seed 0x${(seed >>> 0).toString(16)} road network is fragmented into ${connectivity.componentCount} disconnected components due to water moat gap between road and bridge at columns (${topo.riverColumn * 8}..${topo.riverColumn * 8 + 4})`,
|
||||
).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('Stress Test 6 (ROOT CAUSE VERIFICATION): Authentic river and bridge DS1 piece resolution in wilderness generator', () => {
|
||||
// Audit the piece finding logic from wilderness.ts:7472-7476:
|
||||
// const riverPieces = {
|
||||
// upper: request.pieces.find(p => /URiverC/i.test(p.name))?.levels[0],
|
||||
// lower: request.pieces.find(p => /LRiverC/i.test(p.name))?.levels[0],
|
||||
// bridge: request.pieces.find(p => /Bridge/i.test(p.name))?.levels[0],
|
||||
// }
|
||||
const riverUpperPiece = act1Pieces.find(p => /River.*Upper|URiverC/i.test(p.name))
|
||||
const riverLowerPiece = act1Pieces.find(p => /River.*Lower|LRiverC/i.test(p.name))
|
||||
|
||||
// This assertion confirms resolution: p.name in LvlPrest is "Act 1 - River Upper" and "Act 1 - River Lower",
|
||||
// and /River.*Upper|URiverC/i and /River.*Lower|LRiverC/i match them cleanly.
|
||||
expect(
|
||||
riverUpperPiece !== undefined && riverLowerPiece !== undefined,
|
||||
`Piece lookup in wilderness.ts:7472: /River.*Upper|URiverC/i and /River.*Lower|LRiverC/i match "Act 1 - River Upper" and "Act 1 - River Lower"`,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -54,16 +54,13 @@ function makeMockDs1(width: number, height: number): Ds1 {
|
|||
|
||||
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
||||
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
|
||||
test("contains all 25 canonical landmark entities across Acts 1-5", () => {
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(25)
|
||||
test("contains all 19 canonical landmark entities across Acts 2-5", () => {
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(19)
|
||||
|
||||
// Act 1 (6 entities: 5 SuperUniques + 1 NPC)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Flavie" && e.levelIds.includes(3))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bishibosh" && e.levelIds.includes(3))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Rakanishu" && e.levelIds.includes(4))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Treehead WoodFist" && e.levelIds.includes(5))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Blood Raven" && e.levelIds.includes(17))).toBe(true)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "The Countess" && e.levelIds.includes(25))).toBe(true)
|
||||
// Act 1: none. Act I outdoor levels come from the DRLG port (src/game/drlg), whose preset units
|
||||
// place Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven, the Cow King and Flavie; that is
|
||||
// checked on the port's output in tests/drlg-act1-population.test.ts.
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.filter(e => e.act < 2 || e.levelIds.some(id => id < 40))).toEqual([])
|
||||
|
||||
// Act 2 (5 entities)
|
||||
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
|
||||
|
|
@ -105,38 +102,38 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|||
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
|
||||
const superUniquesTsv = [
|
||||
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
|
||||
"Bishibosh\tfallen2\t8\t9\t0\t2\t5\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
|
||||
"Rakanishu\tfallen3\t17\t6\t0\t3\t6\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
|
||||
"Beetleburst\tscarab3\t8\t9\t0\t2\t5\tscarab1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
|
||||
"Dark Elder\tzombie4\t17\t6\t0\t3\t6\tzombie1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
|
||||
].join("\n")
|
||||
|
||||
const suTable = mockTable(superUniquesTsv)
|
||||
const entities = buildLandmarkEntitiesFromTables(suTable)
|
||||
|
||||
const bishibosh = entities.find(e => e.id === "Bishibosh")
|
||||
expect(bishibosh).toBeDefined()
|
||||
expect(bishibosh?.monsterId).toBe("fallen2")
|
||||
expect(bishibosh?.modifiers).toEqual(["magicresistant", "fireenchant"])
|
||||
expect(bishibosh?.minionCount).toEqual([2, 5])
|
||||
const beetleburst = entities.find(e => e.id === "Beetleburst")
|
||||
expect(beetleburst).toBeDefined()
|
||||
expect(beetleburst?.monsterId).toBe("scarab3")
|
||||
expect(beetleburst?.modifiers).toEqual(["magicresistant", "fireenchant"])
|
||||
expect(beetleburst?.minionCount).toEqual([2, 5])
|
||||
|
||||
const rakanishu = entities.find(e => e.id === "Rakanishu")
|
||||
expect(rakanishu).toBeDefined()
|
||||
expect(rakanishu?.monsterId).toBe("fallen3")
|
||||
expect(rakanishu?.modifiers).toEqual(["lightenchant", "fast"])
|
||||
expect(rakanishu?.minionCount).toEqual([3, 6])
|
||||
const darkElder = entities.find(e => e.id === "Dark Elder")
|
||||
expect(darkElder).toBeDefined()
|
||||
expect(darkElder?.monsterId).toBe("zombie4")
|
||||
expect(darkElder?.modifiers).toEqual(["lightenchant", "fast"])
|
||||
expect(darkElder?.minionCount).toEqual([3, 6])
|
||||
})
|
||||
|
||||
test("resolves objectId from MonPreset.txt", () => {
|
||||
const monPresetTsv = [
|
||||
"Act\tPlace\tComponent\tObjectId",
|
||||
"1\tCairn Stones\t1\t105",
|
||||
"2\tDesert Oasis 1\t1\t105",
|
||||
"4\tChaos Sanctuary\t1\t350",
|
||||
].join("\n")
|
||||
|
||||
const mpTable = mockTable(monPresetTsv)
|
||||
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
|
||||
|
||||
const rakanishu = entities.find(e => e.name === "Rakanishu")
|
||||
expect(rakanishu?.objectId).toBe(105)
|
||||
const beetleburst = entities.find(e => e.name === "Beetleburst")
|
||||
expect(beetleburst?.objectId).toBe(105)
|
||||
|
||||
const hadriel = entities.find(e => e.name === "Hadriel")
|
||||
expect(hadriel?.objectId).toBe(350)
|
||||
|
|
@ -144,18 +141,20 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|||
})
|
||||
|
||||
describe("spawnLandmarkEntities in Wilderness Generator", () => {
|
||||
test("places Rakanishu centered on Cairn Stones preset piece", () => {
|
||||
const cairnDs1 = makeMockDs1(16, 16)
|
||||
// Act I SuperUniques (Rakanishu on the Cairn Stones, ...) are preset units of the DRLG port's
|
||||
// maps; tests/drlg-act1-population.test.ts checks them. The same anchoring for Acts 2-5:
|
||||
test("places Creeping Feature centered on the Desert Tomb 1 preset piece", () => {
|
||||
const tombDs1 = makeMockDs1(16, 16)
|
||||
const borderDs1 = makeMockDs1(8, 8)
|
||||
const pieces: WildernessPiece[] = [
|
||||
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 1 - Cairn Stones", border: false, levels: [cairnDs1] },
|
||||
{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] },
|
||||
{ name: "Act 2 - Desert Tomb 1", border: false, levels: [tombDs1] },
|
||||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 41,
|
||||
levelName: "Rocky Waste",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -163,24 +162,24 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|||
seed: 42,
|
||||
pieces,
|
||||
substitutions: [],
|
||||
specialPresets: ["Act 1 - Cairn Stones"],
|
||||
specialPresets: ["Act 2 - Desert Tomb 1"],
|
||||
})
|
||||
|
||||
expect(result.stats.landmarkEntities).toBeDefined()
|
||||
const rakanishu = result.stats.landmarkEntities?.find(e => e.name === "Rakanishu")
|
||||
expect(rakanishu).toBeDefined()
|
||||
expect(rakanishu?.levelId).toBe(4)
|
||||
const creeping = result.stats.landmarkEntities?.find(e => e.name === "Creeping Feature")
|
||||
expect(creeping).toBeDefined()
|
||||
expect(creeping?.levelId).toBe(41)
|
||||
|
||||
// Verify subtile coordinates: tileX * 5 + 2
|
||||
expect(rakanishu?.subtileX).toBe(rakanishu!.tileX * 5 + 2)
|
||||
expect(rakanishu?.subtileY).toBe(rakanishu!.tileY * 5 + 2)
|
||||
expect(creeping?.subtileX).toBe(creeping!.tileX * 5 + 2)
|
||||
expect(creeping?.subtileY).toBe(creeping!.tileY * 5 + 2)
|
||||
|
||||
// Verify canvas.objects contains the Type 1 entity
|
||||
const obj = result.level.objects.find(
|
||||
o => o.type === 1 && o.x === rakanishu!.subtileX && o.y === rakanishu!.subtileY
|
||||
o => o.type === 1 && o.x === creeping!.subtileX && o.y === creeping!.subtileY
|
||||
)
|
||||
expect(obj).toBeDefined()
|
||||
expect(obj?.id).toBe(rakanishu?.objectId)
|
||||
expect(obj?.id).toBe(creeping?.objectId)
|
||||
})
|
||||
|
||||
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
|
||||
|
|
@ -260,8 +259,8 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|||
id: "CustomBoss",
|
||||
name: "Test Custom Boss",
|
||||
role: "superunique",
|
||||
act: 1,
|
||||
levelIds: [4],
|
||||
act: 2,
|
||||
levelIds: [42],
|
||||
monsterId: "fallen1",
|
||||
defaultLandmark: "Custom Landmark",
|
||||
modifiers: ["lightenchant", "fast"],
|
||||
|
|
@ -273,15 +272,15 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|||
]
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 123,
|
||||
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
|
||||
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
|
||||
substitutions: [],
|
||||
customLandmarks,
|
||||
})
|
||||
|
|
@ -297,24 +296,24 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|||
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
|
||||
const borderDs1 = makeMockDs1(8, 8)
|
||||
const result = generateWilderness({
|
||||
levelId: 5, // Dark Wood (Treehead WoodFist)
|
||||
levelName: "Dark Wood",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 43, // Far Oasis (Beetleburst)
|
||||
levelName: "Far Oasis",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 100,
|
||||
sizeY: 100,
|
||||
subType: 6,
|
||||
subTheme: 0,
|
||||
seed: 99,
|
||||
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
|
||||
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const treehead = result.stats.landmarkEntities?.find(e => e.name === "Treehead WoodFist")
|
||||
expect(treehead).toBeDefined()
|
||||
expect(treehead?.tileX).toBeGreaterThanOrEqual(1)
|
||||
expect(treehead?.tileX).toBeLessThan(99)
|
||||
expect(treehead?.tileY).toBeGreaterThanOrEqual(1)
|
||||
expect(treehead?.tileY).toBeLessThan(99)
|
||||
const beetleburst = result.stats.landmarkEntities?.find(e => e.name === "Beetleburst")
|
||||
expect(beetleburst).toBeDefined()
|
||||
expect(beetleburst?.tileX).toBeGreaterThanOrEqual(1)
|
||||
expect(beetleburst?.tileX).toBeLessThan(99)
|
||||
expect(beetleburst?.tileY).toBeGreaterThanOrEqual(1)
|
||||
expect(beetleburst?.tileY).toBeLessThan(99)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -256,15 +256,15 @@ describe('BordType 0 really is one cluster for the whole level', () => {
|
|||
|
||||
test('the second BordType 0 row gets nothing left to spend', () => {
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Cold Plains',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
levelId: 42,
|
||||
levelName: 'Dry Hills',
|
||||
levelTypeName: 'Act 2 - Desert',
|
||||
sizeX: 64,
|
||||
sizeY: 64,
|
||||
subType: 1,
|
||||
subTheme: 0,
|
||||
seed: 2001,
|
||||
pieces: makeMockBorderPieces('Act 1'),
|
||||
pieces: makeMockBorderPieces('Act 2'),
|
||||
substitutions: makeBordType0Rows(),
|
||||
})
|
||||
|
||||
|
|
@ -280,38 +280,9 @@ describe('BordType 0 really is one cluster for the whole level', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Act I outdoor levels (and their dirt paths: DRLGOUTDOORS_SpawnAct1DirtPaths) come from the DRLG
|
||||
// port (src/game/drlg), so the telemetry below covers the procedural passes of Acts 2-5.
|
||||
describe('Procedural terrain pass telemetry tracks real cell counts (Issue #62 & #65)', () => {
|
||||
test('Act 1 wilderness tracks procedural road cells with zero artificial dividers (Issue #65)', () => {
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: 'Cold Plains',
|
||||
levelTypeName: 'Act 1 - Wilderness',
|
||||
sizeX: 64,
|
||||
sizeY: 64,
|
||||
subType: 1,
|
||||
subTheme: 0,
|
||||
seed: 2001,
|
||||
pieces: makeMockBorderPieces('Act 1'),
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
|
||||
expect(result.stats.proceduralDividerCells).toBe(0)
|
||||
expect(result.stats.proceduralWallCells).toBe(0)
|
||||
|
||||
// Verify 0 artificial divider wall cells on canvas (walls with style 2, prop1 129)
|
||||
let wallCount = 0
|
||||
for (let y = 0; y < result.level.height; y += 1) {
|
||||
for (let x = 0; x < result.level.width; x += 1) {
|
||||
const cell = result.level.cells[y]![x]!
|
||||
if (cell.walls.some(w => !w.hidden && w.style === 2 && w.prop1 === 129)) {
|
||||
wallCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(wallCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Act 2 desert tracks procedural roads, water (oasis), and walls (canyon + berms)', () => {
|
||||
const rocky = generateWilderness({
|
||||
levelId: 41,
|
||||
|
|
|
|||
|
|
@ -351,11 +351,11 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
|||
})
|
||||
|
||||
test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
|
||||
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
||||
const result = generateWilderness({
|
||||
levelId: 3,
|
||||
levelName: "Cold Plains",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 96,
|
||||
sizeY: 96,
|
||||
subType: 6,
|
||||
|
|
@ -365,7 +365,7 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
|||
substitutions: [],
|
||||
})
|
||||
|
||||
const shrineIds = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
|
||||
const shrineIds = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
|
||||
const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id))
|
||||
|
||||
// If multiple shrines are spawned, check pairwise distance >= 7
|
||||
|
|
@ -385,20 +385,20 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
|||
})
|
||||
|
||||
test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => {
|
||||
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
|
||||
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
||||
// Use multiple seeds to observe companion spawning (60% probability)
|
||||
let foundClusteredCompanion = false
|
||||
const act1ChestsAndScatter = new Set([
|
||||
...ACT_POPULATION_CONFIG[1].chestPool,
|
||||
...ACT_POPULATION_CONFIG[1].scatterPool,
|
||||
const act2ChestsAndScatter = new Set([
|
||||
...ACT_POPULATION_CONFIG[2].chestPool,
|
||||
...ACT_POPULATION_CONFIG[2].scatterPool,
|
||||
])
|
||||
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
|
||||
const act2Shrines = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
|
||||
|
||||
for (let s = 0; s < 10; s += 1) {
|
||||
const result = generateWilderness({
|
||||
levelId: 4,
|
||||
levelName: "Stony Field",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
levelId: 42,
|
||||
levelName: "Dry Hills",
|
||||
levelTypeName: "Act 2 - Desert",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 6,
|
||||
|
|
@ -408,8 +408,8 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
|||
substitutions: [],
|
||||
})
|
||||
|
||||
const shrines = result.level.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
|
||||
const companions = result.level.objects.filter(o => o.type === 2 && act1ChestsAndScatter.has(o.id))
|
||||
const shrines = result.level.objects.filter(o => o.type === 2 && act2Shrines.has(o.id))
|
||||
const companions = result.level.objects.filter(o => o.type === 2 && act2ChestsAndScatter.has(o.id))
|
||||
|
||||
for (const shrine of shrines) {
|
||||
const sx = Math.floor(shrine.x / 5)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ runtime shims and the build script live in this repository.
|
|||
npx tsx scripts/extract-d2moo-tables.ts samples/d2 tools/d2moo-oracle/data
|
||||
|
||||
# 2. 32-bit build (needs g++ with -m32 / gcc-multilib).
|
||||
./tools/d2moo-oracle/build.sh
|
||||
./tools/d2moo-oracle/build.sh # or: OUT=/some/dir ./tools/d2moo-oracle/build.sh
|
||||
|
||||
# 3. Generate.
|
||||
./tools/d2moo-oracle/build/d2moo-oracle --data tools/d2moo-oracle/data \
|
||||
|
|
@ -28,6 +28,9 @@ npx tsx scripts/extract-d2moo-tables.ts samples/d2 tools/d2moo-oracle/data
|
|||
- `--levels` must be Act I outdoor levels (DrlgType 3).
|
||||
- `--isolated` allocates a fresh act for every level. The output must equal the sequential run,
|
||||
which proves that a level's generation does not depend on other levels.
|
||||
- `build.sh` compiles in a temporary directory and installs the binary into `$OUT` (default
|
||||
`tools/d2moo-oracle/build`) with `cp` to `d2moo-oracle.tmp` followed by `mv` over `d2moo-oracle`, so
|
||||
a concurrently running oracle is never replaced by a half-written file.
|
||||
- Both `build/` and `data/` are git-ignored.
|
||||
|
||||
The binary is 32-bit so that every D2MOO struct has the exact layout of the compiled 1.13c `.bin`
|
||||
|
|
@ -41,11 +44,18 @@ The driver runs D2MOO's own code for:
|
|||
- `DRLG_InitLevel`, then `DRLGOUTDOORS_GenerateLevel` and `DRLGOUTWILD_InitAct1OutdoorLevel`.
|
||||
- Room creation.
|
||||
- Room activation, mirroring `DRLGACTIVATE_InitializeRoomEx` (DrlgActivate.cpp:317-331) and
|
||||
`DRLGACTIVATE_RoomEx_EnsureHasRoom` (95-105) up to `DRLGROOMTILE_InitRoomGrids`:
|
||||
`DRLGACTIVATE_RoomEx_EnsureHasRoom` (95-115) up to `DRLGROOMTILE_AddRoomMapTiles`:
|
||||
1. `DRLGROOMTILE_LoadDT1FilesForRoom`
|
||||
2. `DRLGPRESET_SpawnHardcodedPresetUnits` (preset rooms only)
|
||||
3. `sub_6FD77BB0`
|
||||
4. `DRLGROOMTILE_InitRoomGrids`, which re-seeds the room from `dwInitSeed`.
|
||||
5. `DRLGROOMTILE_AddRoomMapTiles`: the tile data of the room (the variant picks of
|
||||
`DRLGROOMTILE_GetTileCache` roll the room seed), the room-seam merge with the near rooms
|
||||
(`DRLGROOMTILE_GetCreateLinkedTileData` / `DRLGROOMTILE_LinkedTileDataManager`), the warps
|
||||
(`DRLGROOMTILE_AddWarp`), then `DRLGOUTROOM_InitializeDrlgOutdoorRoom` or
|
||||
`DRLGPRESET_AddPresetRoomMapTiles` (both build the `DRLGLOGIC_*` coord lists).
|
||||
|
||||
`DRLG_CreateRoomForRoomEx` (the D2ActiveRoomStrc, built by D2Game) is not run.
|
||||
|
||||
The outside world is provided by `src/stubs.cpp`. Its policy: anything the generator genuinely needs
|
||||
is implemented with real data, and everything else **fails fast** (exit code 2).
|
||||
|
|
@ -64,7 +74,12 @@ is implemented with real data, and everything else **fails fast** (exit code 2).
|
|||
- For Act I every shadow lookup returns rarity 0, so there is no roll. This is visible in the
|
||||
trace as `T 13 … 1 0`.
|
||||
- The order of the returned tiles (slot order, then DT1 file order) is not verified against
|
||||
D2CMP.dll. It only decides which tile variant is picked, and the oracle does not dump that.
|
||||
D2CMP.dll. It decides which tile variant `DRLGROOMTILE_GetTileCache` picks, which `mapTiles`
|
||||
dumps (`pTile`); the TypeScript shim (`src/game/drlg/drlg-d2cmp.ts`) uses the same order.
|
||||
- `FOG_AllocPool` / `FOG_ReallocPool` are `calloc` / `realloc`. `FOG_ReallocPool` zero-fills the grown
|
||||
part of the block (glibc `calloc` clears the whole usable size), so the entries that
|
||||
`DRLGROOMTILE_ReallocRoofTileGrid` appends are deterministic. D2Common never writes `unk0x10`,
|
||||
`nIntensity` and `unk0x2C` of those entries; in the game they keep whatever the pool block held.
|
||||
|
||||
**Modelling decisions (documented, not guessed):**
|
||||
|
||||
|
|
@ -72,15 +87,21 @@ is implemented with real data, and everything else **fails fast** (exit code 2).
|
|||
with the seed of the first activated room. That seed only matters for the randomly skipped units
|
||||
of `DRLGPRESET_AddPresetUnitToDrlgMap` (Tormentor, Taintbreeder, Riftwraith, floor traps,
|
||||
object 581), and none of those occur in Act I outdoor DS1s.
|
||||
- The activation order also decides the room-seam merge: the room activated later edits the tiles of
|
||||
the near rooms activated before it (also across level seams) and rolls their seeds.
|
||||
- Activating a room initialises the levels behind its warps (`sub_6FD77BB0` → `DRLG_InitLevel`).
|
||||
The sequential mode therefore generates every requested level first and activates the rooms
|
||||
afterwards, so each level's RNG trace stays under its own context.
|
||||
- Tile selection (`DRLGROOMTILE_AddRoomMapTiles`) and unit spawning are outside the oracle.
|
||||
- `pDrlgRoom->pRoom` stays NULL for every room, so `D2Common_COLLISION_FirstFn_6FD41000` is a no-op
|
||||
(D2Collision.cpp returns when `pRoom` is NULL). In the game a room activated earlier already has its
|
||||
active room, so the seam merge of a later room also updates that room's collision grid. That does
|
||||
not change the DRLG tile data or the RNG.
|
||||
- Unit spawning and `DRLG_CreateRoomForRoomEx` are outside the oracle.
|
||||
|
||||
## Output (schema 2)
|
||||
## Output (schema 3)
|
||||
|
||||
```text
|
||||
{schema: 2, oracle: {d2moo, arch: "i386"}, gameSeed, difficulty, isolated,
|
||||
{schema: 3, oracle: {d2moo, arch: "i386"}, gameSeed, difficulty, isolated,
|
||||
drlg: {startSeed, seed: [low, high]},
|
||||
act: [{id, drlgType, levelType, coord: [x, y, w, h], flags, seed,
|
||||
outdoor?: {flags, roomData: [orth]}, preset?: {direction, map}}], // sorted by id
|
||||
|
|
@ -93,11 +114,34 @@ is implemented with real data, and everything else **fails fast** (exit code 2).
|
|||
preset?: {prest, picked, flags, map}}], // creation order
|
||||
maps: [{prest, picked, coord, file, hasInfo, units}],
|
||||
levelSeedAfterRooms, warp: {n, xy},
|
||||
activation: [{seed, tile, wall, floor, dirt} | // outdoor rooms
|
||||
{seed, walls: [grid], tiles: [grid], floors: [grid], shadow}, // preset rooms
|
||||
... units]}]}
|
||||
activation: [{seed, tile, wall, floor, dirt, units, seedAfterTiles, mapTiles} | // outdoor rooms
|
||||
{seed, walls: [grid], tiles: [grid], floors: [grid], shadow, units, // preset rooms
|
||||
seedAfterTiles, mapTiles}]}]}
|
||||
|
||||
mapTiles: {nWalls, nFloors, nShadows, // pTileGrid counters
|
||||
pTiles: {nWalls, nFloors, nRoofs}, // pTileGrid->pTiles counters
|
||||
walls: [tile], floors: [tile], roofs: [tile],// pTiles arrays, exactly pTiles.nX entries each
|
||||
links: [[bFloor, tileRef]], // pTileGrid->pMapLinks chain
|
||||
warps: [[[levelId, room] | null, lvlWarpRow, bEnabled, tileRef, tileRef]], // pRoomTiles chain
|
||||
units, // pPresetUnits (incl. AddWarp's UNIT_TILE units)
|
||||
logical: null | {flags, nLists, indexX: grid, indexY: grid, lists: [coordList]}}
|
||||
tile: [nWidth, nHeight, nPosX, nPosY, unk0x10, dwFlags, pTile, nTileType, unk0x20, unk0x24,
|
||||
nRed, nGreen, nBlue, nIntensity, unk0x2C] // every D2DrlgTileDataStrc field in order
|
||||
```
|
||||
|
||||
- The activation fields up to `units` are captured right after `DRLGROOMTILE_InitRoomGrids`;
|
||||
`seedAfterTiles` is the room seed right after the room's own `DRLGROOMTILE_AddRoomMapTiles`.
|
||||
- `mapTiles` is the final state after every requested room was activated: rooms activated later
|
||||
rewrite tiles and preset units of the near rooms activated before them.
|
||||
- References: a `D2DrlgTileDataStrc*` (`tileRef`, `unk0x20`) is `[levelId, room, "w"|"f"|"r", index]`
|
||||
into that room's `pWallTiles` / `pFloorTiles` / `pRoofTiles` (rooms numbered in `pFirstRoomEx`
|
||||
order, any level of the act); `pTile` is `[DT1 path (lower case, "/"-separated), tile index in that
|
||||
DT1]`; `unk0x10` is an int, or `[levelId, room, "c", index]` when `DRLGLOGIC` stored a
|
||||
`D2RoomCoordListStrc*` in it (index in that room's `pCoordList` chain). `lvlWarpRow` is the
|
||||
LvlWarp.txt row of `pLvlWarpTxtRecord` (-1 for NULL). `coordList` is
|
||||
`[nIndex, bNode, bRoomActive, box0, box1]`; `indexY` cells are 1 + the index of the list in `lists`
|
||||
(0 for NULL).
|
||||
- Shadow tiles live in `pRoofTiles` (`DRLGROOMTILE_CountAllTileTypes` counts them into `nRoofs`).
|
||||
- `grid` is `{w, h, cells: [row-major int32]}`, or `null` when the grid was never allocated.
|
||||
- `vertex` is `[x, y, direction, flags, next]`. `next` is -1 for null, 0..23 for `pVertices[i]`,
|
||||
and 100+k for the k-th ring vertex.
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@
|
|||
# Environment:
|
||||
# D2MOO_SRC D2MOO checkout (default: ~/tmp/D2MOO), must be at the pinned commit below.
|
||||
# CXX C++ compiler (default: g++); needs 32-bit support (-m32, gcc-multilib).
|
||||
# OUT install directory of the binary (default: tools/d2moo-oracle/build).
|
||||
#
|
||||
# Objects are compiled in a private temporary directory and the binary is installed with an atomic
|
||||
# rename ($OUT/d2moo-oracle.tmp -> $OUT/d2moo-oracle), so an oracle that is running concurrently is
|
||||
# never deleted or replaced by a half-written file.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
D2MOO_SRC="${D2MOO_SRC:-$HOME/tmp/D2MOO}"
|
||||
PINNED_COMMIT=5596f5cb6c5251a0a07c6637d26458b06099d516
|
||||
CXX="${CXX:-g++}"
|
||||
OUT="$HERE/build"
|
||||
OUT="${OUT:-$HERE/build}"
|
||||
|
||||
actual_commit="$(git -C "$D2MOO_SRC" rev-parse HEAD)"
|
||||
if [[ "$actual_commit" != "$PINNED_COMMIT" ]]; then
|
||||
|
|
@ -32,8 +37,9 @@ D2MOO_SOURCES=("$D2MOO_SRC"/source/D2Common/src/Drlg/*.cpp
|
|||
"$D2MOO_SRC/source/D2Common/src/DataTbls/LevelsTbls.cpp")
|
||||
ORACLE_SOURCES=("$HERE"/src/*.cpp)
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT/obj/d2moo" "$OUT/obj/oracle"
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/d2moo-oracle-build.XXXXXX")"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/obj/d2moo" "$WORK/obj/oracle"
|
||||
|
||||
compile() {
|
||||
local src="$1" obj="$2"
|
||||
|
|
@ -44,14 +50,14 @@ export CXX
|
|||
pids=()
|
||||
d2moo_objs=()
|
||||
for src in "${D2MOO_SOURCES[@]}"; do
|
||||
obj="$OUT/obj/d2moo/$(basename "${src%.cpp}").o"
|
||||
obj="$WORK/obj/d2moo/$(basename "${src%.cpp}").o"
|
||||
d2moo_objs+=("$obj")
|
||||
compile "$src" "$obj" &
|
||||
pids+=($!)
|
||||
done
|
||||
oracle_objs=()
|
||||
for src in "${ORACLE_SOURCES[@]}"; do
|
||||
obj="$OUT/obj/oracle/$(basename "${src%.cpp}").o"
|
||||
obj="$WORK/obj/oracle/$(basename "${src%.cpp}").o"
|
||||
oracle_objs+=("$obj")
|
||||
compile "$src" "$obj" &
|
||||
pids+=($!)
|
||||
|
|
@ -70,5 +76,9 @@ for obj in "${d2moo_objs[@]}"; do
|
|||
done
|
||||
|
||||
WRAP_SYMBOL=_Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc
|
||||
"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "-Wl,--wrap=$WRAP_SYMBOL" -o "$OUT/d2moo-oracle"
|
||||
"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "-Wl,--wrap=$WRAP_SYMBOL" -o "$WORK/d2moo-oracle"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cp "$WORK/d2moo-oracle" "$OUT/d2moo-oracle.tmp"
|
||||
mv -f "$OUT/d2moo-oracle.tmp" "$OUT/d2moo-oracle"
|
||||
echo "built $OUT/d2moo-oracle (D2MOO $PINNED_COMMIT)"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// D2MOO DRLG oracle driver: generates Act I levels with the native D2MOO code and dumps every
|
||||
// stage the TypeScript port is diffed against (schema v2). See ../README.md.
|
||||
// stage the TypeScript port is diffed against (schema v3). See ../README.md.
|
||||
//
|
||||
// Usage:
|
||||
// d2moo-oracle --data <dir> --seed <n> --levels 2,3,4 [--difficulty 0] [--isolated]
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
#include "D2DataTbls.h"
|
||||
#include "Drlg/D2DrlgDrlg.h"
|
||||
#include "Drlg/D2DrlgDrlgGrid.h"
|
||||
#include "Drlg/D2DrlgDrlgLogic.h"
|
||||
#include "Drlg/D2DrlgDrlgRoom.h"
|
||||
#include "Drlg/D2DrlgDrlgVer.h"
|
||||
#include "Drlg/D2DrlgOutdoors.h"
|
||||
|
|
@ -20,9 +21,11 @@
|
|||
#include "D2Seed.h"
|
||||
#include <DataTbls/LevelsIds.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <malloc.h>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
|
@ -289,16 +292,30 @@ std::string Rooms(D2DrlgLevelStrc* pLevel, std::vector<D2DrlgMapStrc*>& maps)
|
|||
return o.str();
|
||||
}
|
||||
|
||||
// ---- activation stage ----
|
||||
struct RoomActivation
|
||||
{
|
||||
D2DrlgRoomStrc* pRoom;
|
||||
// Schema-2 fields, captured right after DRLGROOMTILE_InitRoomGrids (JSON object without the closing brace).
|
||||
std::string szHead;
|
||||
// Room seed right after the room's own DRLGROOMTILE_AddRoomMapTiles.
|
||||
std::string szSeedAfterTiles;
|
||||
};
|
||||
|
||||
// Mirrors DRLGACTIVATE_InitializeRoomEx (DrlgActivate.cpp:317-331) followed by
|
||||
// DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-105) up to InitRoomGrids. Tile selection
|
||||
// (DRLGROOMTILE_AddRoomMapTiles) needs the DT1 libraries and is outside the oracle.
|
||||
// DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-115) up to DRLGROOMTILE_AddRoomMapTiles.
|
||||
// DRLG_CreateRoomForRoomEx (the D2ActiveRoomStrc, built by D2Game) is not run, so pRoom stays NULL for
|
||||
// every room and D2Common_COLLISION_FirstFn_6FD41000 is a no-op (D2Collision.cpp: returns when pRoom is
|
||||
// NULL). In the game a room activated earlier already has its active room, so the linked-tile merge of
|
||||
// a later room also updates that room's collision grid (no effect on the DRLG tile data or the RNG).
|
||||
// Rooms are activated in pFirstRoomEx order; a preset map spanning several rooms loads its DS1 with
|
||||
// the seed of the first activated room, which only matters for the randomly skipped preset units
|
||||
// of DRLGPRESET_AddPresetUnitToDrlgMap (none of them occur in Act I outdoor DS1s).
|
||||
std::string Activate(D2DrlgLevelStrc* pLevel)
|
||||
// of DRLGPRESET_AddPresetUnitToDrlgMap (none of them occur in Act I outdoor DS1s). The activation
|
||||
// order also decides the room-seam merge of DRLGROOMTILE_GetCreateLinkedTileData: the room activated
|
||||
// later edits the tiles of the near rooms activated before it.
|
||||
std::vector<RoomActivation> Activate(D2DrlgLevelStrc* pLevel)
|
||||
{
|
||||
std::ostringstream o;
|
||||
o << "[";
|
||||
std::vector<RoomActivation> out;
|
||||
int i = 0;
|
||||
char szContext[64];
|
||||
for (D2DrlgRoomStrc* pRoom = pLevel->pFirstRoomEx; pRoom; pRoom = pRoom->pDrlgRoomNext, ++i)
|
||||
|
|
@ -323,7 +340,7 @@ std::string Activate(D2DrlgLevelStrc* pLevel)
|
|||
}
|
||||
DRLGROOMTILE_InitRoomGrids(pRoom);
|
||||
|
||||
if (i) o << ",";
|
||||
std::ostringstream o;
|
||||
o << "{\"seed\":" << Seed(pRoom->pSeed);
|
||||
if (pRoom->nType == DRLGTYPE_MAZE)
|
||||
{
|
||||
|
|
@ -343,9 +360,279 @@ std::string Activate(D2DrlgLevelStrc* pLevel)
|
|||
for (int l = 0; l < pFile->nFloorLayers; ++l) o << (l ? "," : "") << Grid(p->pFloorGrid[l]);
|
||||
o << "],\"shadow\":" << Grid(p->pCellGrid);
|
||||
}
|
||||
o << ",\"units\":" << PresetUnits(pRoom->pPresetUnits) << "}";
|
||||
o << ",\"units\":" << PresetUnits(pRoom->pPresetUnits);
|
||||
|
||||
DRLGROOMTILE_AddRoomMapTiles(pRoom);
|
||||
out.push_back({pRoom, o.str(), Seed(pRoom->pSeed)});
|
||||
}
|
||||
o << "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- mapTiles (schema 3): the room's D2DrlgTileGridStrc after every requested room is activated ----
|
||||
// Pointers are dumped as references, resolved over every room of the act (pDrlg->pLevel chain, rooms
|
||||
// numbered in pFirstRoomEx order), because warps and linked tiles cross level seams:
|
||||
// D2DrlgTileDataStrc* -> [levelId, room, "w"|"f"|"r", index] into that room's pTileGrid->pTiles
|
||||
// pWallTiles / pFloorTiles / pRoofTiles (the pool block, not only [0, count))
|
||||
// D2RoomCoordListStrc* -> [levelId, room, "c", index in that room's pLogicalRoomInfo->pCoordList chain]
|
||||
// D2DrlgRoomStrc* -> [levelId, room]
|
||||
struct TileBlock
|
||||
{
|
||||
int nLevelId;
|
||||
int nRoom;
|
||||
char cArray;
|
||||
size_t nBytes;
|
||||
};
|
||||
|
||||
struct RefIndex
|
||||
{
|
||||
std::map<uintptr_t, TileBlock> tileBlocks;
|
||||
std::map<const D2RoomCoordListStrc*, std::string> coordLists;
|
||||
std::map<const D2DrlgRoomStrc*, std::string> rooms;
|
||||
};
|
||||
|
||||
constexpr int kMaxChain = 1 << 20;
|
||||
|
||||
void AddTileBlock(RefIndex& index, const D2DrlgTileDataStrc* pTiles, int nLevelId, int nRoom, char cArray)
|
||||
{
|
||||
if (!pTiles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// FOG_AllocPool / FOG_ReallocPool are calloc / realloc, so a pool block is a malloc block.
|
||||
const TileBlock block = {nLevelId, nRoom, cArray, malloc_usable_size((void*)pTiles)};
|
||||
if (!index.tileBlocks.emplace((uintptr_t)pTiles, block).second)
|
||||
{
|
||||
D2ORACLE_Fail("level %d room %d: tile array %c starts at another array's address", nLevelId, nRoom, cArray);
|
||||
}
|
||||
}
|
||||
|
||||
RefIndex BuildRefIndex(D2DrlgStrc* pDrlg)
|
||||
{
|
||||
RefIndex index;
|
||||
for (D2DrlgLevelStrc* pLevel = pDrlg->pLevel; pLevel; pLevel = pLevel->pNextLevel)
|
||||
{
|
||||
int nRoom = 0;
|
||||
for (D2DrlgRoomStrc* pRoom = pLevel->pFirstRoomEx; pRoom; pRoom = pRoom->pDrlgRoomNext, ++nRoom)
|
||||
{
|
||||
const std::string szRoom = std::to_string(pLevel->nLevelId) + "," + std::to_string(nRoom);
|
||||
index.rooms[pRoom] = "[" + szRoom + "]";
|
||||
if (const D2DrlgTileGridStrc* pTileGrid = pRoom->pTileGrid)
|
||||
{
|
||||
AddTileBlock(index, pTileGrid->pTiles.pWallTiles, pLevel->nLevelId, nRoom, 'w');
|
||||
AddTileBlock(index, pTileGrid->pTiles.pFloorTiles, pLevel->nLevelId, nRoom, 'f');
|
||||
AddTileBlock(index, pTileGrid->pTiles.pRoofTiles, pLevel->nLevelId, nRoom, 'r');
|
||||
}
|
||||
if (const D2DrlgLogicalRoomInfoStrc* pInfo = pRoom->pLogicalRoomInfo)
|
||||
{
|
||||
int k = 0;
|
||||
for (const D2RoomCoordListStrc* p = pInfo->pCoordList; p; p = p->pNext, ++k)
|
||||
{
|
||||
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pCoordList chain does not terminate", pLevel->nLevelId, nRoom);
|
||||
index.coordLists[p] = "[" + szRoom + ",\"c\"," + std::to_string(k) + "]";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
std::string TileRef(const RefIndex& index, const D2DrlgTileDataStrc* p)
|
||||
{
|
||||
if (!p)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
auto it = index.tileBlocks.upper_bound((uintptr_t)p);
|
||||
if (it != index.tileBlocks.begin())
|
||||
{
|
||||
--it;
|
||||
const uintptr_t nOffset = (uintptr_t)p - it->first;
|
||||
if (nOffset < it->second.nBytes)
|
||||
{
|
||||
if (nOffset % sizeof(D2DrlgTileDataStrc))
|
||||
{
|
||||
D2ORACLE_Fail("tile pointer %p is not an element of level %d room %d array %c", (const void*)p, it->second.nLevelId, it->second.nRoom, it->second.cArray);
|
||||
}
|
||||
return "[" + std::to_string(it->second.nLevelId) + "," + std::to_string(it->second.nRoom) + ",\"" + it->second.cArray + "\","
|
||||
+ std::to_string(nOffset / sizeof(D2DrlgTileDataStrc)) + "]";
|
||||
}
|
||||
}
|
||||
D2ORACLE_Fail("tile pointer %p is outside every tile array of the act (dangling?)", (const void*)p);
|
||||
}
|
||||
|
||||
// D2DrlgTileDataStrc::unk0x10 holds (int)D2RoomCoordListStrc* when DRLGLOGIC set it (-m32), else an int.
|
||||
std::string TileUnk10(const RefIndex& index, int32_t nValue)
|
||||
{
|
||||
if (nValue)
|
||||
{
|
||||
auto it = index.coordLists.find((const D2RoomCoordListStrc*)(uintptr_t)(uint32_t)nValue);
|
||||
if (it != index.coordLists.end())
|
||||
{
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
return std::to_string(nValue);
|
||||
}
|
||||
|
||||
std::string TileEntry(const D2TileLibraryEntryStrc* pTile)
|
||||
{
|
||||
if (!pTile)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
std::string szLibrary;
|
||||
int nIndex = -1;
|
||||
D2ORACLE_DescribeTile(pTile, &szLibrary, &nIndex);
|
||||
if (szLibrary.find_first_of("\"\\") != std::string::npos)
|
||||
{
|
||||
D2ORACLE_Fail("tile library path %s needs JSON escaping", szLibrary.c_str());
|
||||
}
|
||||
return "[\"" + szLibrary + "\"," + std::to_string(nIndex) + "]";
|
||||
}
|
||||
|
||||
// Every D2DrlgTileDataStrc field in struct order (D2DrlgDrlg.h:333).
|
||||
std::string TileData(const RefIndex& index, const D2DrlgTileDataStrc& t)
|
||||
{
|
||||
std::ostringstream o;
|
||||
o << "[" << t.nWidth << "," << t.nHeight << "," << t.nPosX << "," << t.nPosY << "," << TileUnk10(index, t.unk0x10) << "," << t.dwFlags
|
||||
<< "," << TileEntry(t.pTile) << "," << t.nTileType << "," << TileRef(index, t.unk0x20) << "," << t.unk0x24 << "," << (int)t.nRed
|
||||
<< "," << (int)t.nGreen << "," << (int)t.nBlue << "," << (int)t.nIntensity << "," << t.unk0x2C << "]";
|
||||
return o.str();
|
||||
}
|
||||
|
||||
std::string TileDataArray(const RefIndex& index, const D2DrlgTileDataStrc* pTiles, int nTiles, const char* szArray, int nLevelId, int nRoom)
|
||||
{
|
||||
if (nTiles < 0 || (nTiles > 0 && !pTiles))
|
||||
{
|
||||
D2ORACLE_Fail("level %d room %d: %d %s without an array", nLevelId, nRoom, nTiles, szArray);
|
||||
}
|
||||
if (nTiles > 0 && (size_t)nTiles * sizeof(D2DrlgTileDataStrc) > malloc_usable_size((void*)pTiles))
|
||||
{
|
||||
D2ORACLE_Fail("level %d room %d: %d %s exceed their pool block", nLevelId, nRoom, nTiles, szArray);
|
||||
}
|
||||
std::string s = "[";
|
||||
for (int i = 0; i < nTiles; ++i)
|
||||
{
|
||||
if (i) s += ",";
|
||||
s += TileData(index, pTiles[i]);
|
||||
}
|
||||
return s + "]";
|
||||
}
|
||||
|
||||
std::string CoordList(const D2RoomCoordListStrc& c)
|
||||
{
|
||||
std::ostringstream o;
|
||||
o << "[" << c.nIndex << "," << c.bNode << "," << c.bRoomActive << "," << Coord(c.pBox[0]) << "," << Coord(c.pBox[1]) << "]";
|
||||
return o.str();
|
||||
}
|
||||
|
||||
// {flags, nLists, indexX, indexY, lists} or null. pIndexY cells hold (int)D2RoomCoordListStrc*
|
||||
// (DRLGLOGIC_AssignCoordListsForGrids): dumped as 1 + the index of that list in the room's own
|
||||
// pCoordList chain, 0 for NULL.
|
||||
std::string LogicalRoomInfo(const D2DrlgRoomStrc* pRoom, int nLevelId, int nRoom)
|
||||
{
|
||||
const D2DrlgLogicalRoomInfoStrc* pInfo = pRoom->pLogicalRoomInfo;
|
||||
if (!pInfo)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
std::map<uint32_t, int> chain;
|
||||
std::string lists = "[";
|
||||
int k = 0;
|
||||
for (const D2RoomCoordListStrc* p = pInfo->pCoordList; p; p = p->pNext, ++k)
|
||||
{
|
||||
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pCoordList chain does not terminate", nLevelId, nRoom);
|
||||
chain[(uint32_t)(uintptr_t)p] = k;
|
||||
lists += (k ? "," : "") + CoordList(*p);
|
||||
}
|
||||
lists += "]";
|
||||
|
||||
std::ostringstream o;
|
||||
o << "{\"flags\":" << pInfo->dwFlags << ",\"nLists\":" << pInfo->nLists << ",\"indexX\":" << Grid(pInfo->pIndexX) << ",\"indexY\":";
|
||||
const D2DrlgGridStrc& grid = pInfo->pIndexY;
|
||||
if (!grid.pCellsFlags || !grid.pCellsRowOffsets)
|
||||
{
|
||||
o << "null";
|
||||
}
|
||||
else
|
||||
{
|
||||
o << "{\"w\":" << grid.nWidth << ",\"h\":" << grid.nHeight << ",\"cells\":[";
|
||||
for (int y = 0; y < grid.nHeight; ++y)
|
||||
{
|
||||
for (int x = 0; x < grid.nWidth; ++x)
|
||||
{
|
||||
const uint32_t nCell = (uint32_t)grid.pCellsFlags[x + grid.pCellsRowOffsets[y]];
|
||||
int nValue = 0;
|
||||
if (nCell)
|
||||
{
|
||||
auto it = chain.find(nCell);
|
||||
if (it == chain.end()) D2ORACLE_Fail("level %d room %d: pIndexY cell (%d, %d) is not a list of the room's chain", nLevelId, nRoom, x, y);
|
||||
nValue = it->second + 1;
|
||||
}
|
||||
o << ((x || y) ? "," : "") << nValue;
|
||||
}
|
||||
}
|
||||
o << "]}";
|
||||
}
|
||||
o << ",\"lists\":" << lists << "}";
|
||||
return o.str();
|
||||
}
|
||||
|
||||
// pRoomTiles chain: [[levelId, room] of pDrlgRoom, pLvlWarpTxtRecord index (-1 for NULL), bEnabled,
|
||||
// unk0x0C, unk0x10].
|
||||
std::string RoomTiles(const RefIndex& index, const D2DrlgRoomStrc* pRoom, int nLevelId, int nRoom)
|
||||
{
|
||||
std::string s = "[";
|
||||
int k = 0;
|
||||
for (const D2RoomTileStrc* p = pRoom->pRoomTiles; p; p = p->pNext, ++k)
|
||||
{
|
||||
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pRoomTiles chain does not terminate", nLevelId, nRoom);
|
||||
std::string szRoom = "null";
|
||||
if (p->pDrlgRoom)
|
||||
{
|
||||
auto it = index.rooms.find(p->pDrlgRoom);
|
||||
if (it == index.rooms.end()) D2ORACLE_Fail("level %d room %d: warp %d leads to a room outside the act", nLevelId, nRoom, k);
|
||||
szRoom = it->second;
|
||||
}
|
||||
int nWarp = -1;
|
||||
if (p->pLvlWarpTxtRecord)
|
||||
{
|
||||
const ptrdiff_t n = p->pLvlWarpTxtRecord - sgptDataTables->pLvlWarpTxt;
|
||||
if (n < 0 || n >= sgptDataTables->nLvlWarpTxtRecordCount) D2ORACLE_Fail("level %d room %d: warp %d has a record outside LvlWarp.txt", nLevelId, nRoom, k);
|
||||
nWarp = (int)n;
|
||||
}
|
||||
s += (k ? "," : "");
|
||||
s += "[" + szRoom + "," + std::to_string(nWarp) + "," + std::to_string(p->bEnabled) + "," + TileRef(index, p->unk0x0C) + "," + TileRef(index, p->unk0x10) + "]";
|
||||
}
|
||||
return s + "]";
|
||||
}
|
||||
|
||||
std::string MapTiles(const RefIndex& index, const D2DrlgRoomStrc* pRoom, int nLevelId, int nRoom)
|
||||
{
|
||||
const D2DrlgTileGridStrc* pTileGrid = pRoom->pTileGrid;
|
||||
if (!pTileGrid)
|
||||
{
|
||||
D2ORACLE_Fail("level %d room %d: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles", nLevelId, nRoom);
|
||||
}
|
||||
if (pTileGrid->pAnimTiles)
|
||||
{
|
||||
D2ORACLE_Fail("level %d room %d: animated tiles (pAnimTiles) are not dumped", nLevelId, nRoom);
|
||||
}
|
||||
const D2DrlgRoomTilesStrc& t = pTileGrid->pTiles;
|
||||
std::ostringstream o;
|
||||
o << "{\"nWalls\":" << pTileGrid->nWalls << ",\"nFloors\":" << pTileGrid->nFloors << ",\"nShadows\":" << pTileGrid->nShadows
|
||||
<< ",\"pTiles\":{\"nWalls\":" << t.nWalls << ",\"nFloors\":" << t.nFloors << ",\"nRoofs\":" << t.nRoofs << "}"
|
||||
<< ",\"walls\":" << TileDataArray(index, t.pWallTiles, t.nWalls, "walls", nLevelId, nRoom)
|
||||
<< ",\"floors\":" << TileDataArray(index, t.pFloorTiles, t.nFloors, "floors", nLevelId, nRoom)
|
||||
<< ",\"roofs\":" << TileDataArray(index, t.pRoofTiles, t.nRoofs, "roofs", nLevelId, nRoom) << ",\"links\":[";
|
||||
int k = 0;
|
||||
for (const D2DrlgTileLinkStrc* p = pTileGrid->pMapLinks; p; p = p->pNext, ++k)
|
||||
{
|
||||
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pMapLinks chain does not terminate", nLevelId, nRoom);
|
||||
o << (k ? "," : "") << "[" << p->bFloor << "," << TileRef(index, p->pMapTile) << "]";
|
||||
}
|
||||
o << "],\"warps\":" << RoomTiles(index, pRoom, nLevelId, nRoom) << ",\"units\":" << PresetUnits(pRoom->pPresetUnits)
|
||||
<< ",\"logical\":" << LogicalRoomInfo(pRoom, nLevelId, nRoom) << "}";
|
||||
return o.str();
|
||||
}
|
||||
|
||||
|
|
@ -459,14 +746,14 @@ int main(int argc, char** argv)
|
|||
|
||||
D2DrlgActStrc act = {};
|
||||
std::ostringstream o;
|
||||
o << "{\"schema\":2,\"oracle\":{\"d2moo\":\"" << D2ORACLE_D2MOO_COMMIT << "\",\"arch\":\"i386\"},\"gameSeed\":" << nSeed
|
||||
o << "{\"schema\":3,\"oracle\":{\"d2moo\":\"" << D2ORACLE_D2MOO_COMMIT << "\",\"arch\":\"i386\"},\"gameSeed\":" << nSeed
|
||||
<< ",\"difficulty\":" << nDifficulty << ",\"isolated\":" << (bIsolated ? "true" : "false");
|
||||
|
||||
D2DrlgStrc* pDrlg = AllocAct1(&act, nSeed, (uint8_t)nDifficulty);
|
||||
o << ",\"drlg\":{\"startSeed\":" << pDrlg->dwStartSeed << ",\"seed\":" << Seed(pDrlg->pSeed) << "},\"act\":" << Act(pDrlg) << ",\"levels\":[";
|
||||
std::vector<std::string> heads(levels.size());
|
||||
std::vector<D2DrlgLevelStrc*> generated(levels.size(), nullptr);
|
||||
std::vector<std::string> activations(levels.size());
|
||||
std::vector<std::vector<RoomActivation>> activations(levels.size());
|
||||
if (bIsolated)
|
||||
{
|
||||
// A fresh act per level proves level generation does not depend on other levels.
|
||||
|
|
@ -493,9 +780,26 @@ int main(int argc, char** argv)
|
|||
activations[i] = Activate(generated[i]);
|
||||
}
|
||||
}
|
||||
// "mapTiles" is serialised only now: a room activated later rewrites tiles of the near rooms
|
||||
// activated before it and rolls their seeds (DRLGROOMTILE_GetCreateLinkedTileData ->
|
||||
// DRLGROOMTILE_LinkedTileDataManager), also across level seams, so the tile data are the final state.
|
||||
std::map<D2DrlgStrc*, RefIndex> refIndices;
|
||||
for (size_t i = 0; i < levels.size(); ++i)
|
||||
{
|
||||
o << (i ? "," : "") << heads[i] << ",\"activation\":" << activations[i] << "}";
|
||||
D2DrlgStrc* pLevelDrlg = generated[i]->pDrlg;
|
||||
auto it = refIndices.find(pLevelDrlg);
|
||||
if (it == refIndices.end())
|
||||
{
|
||||
it = refIndices.emplace(pLevelDrlg, BuildRefIndex(pLevelDrlg)).first;
|
||||
}
|
||||
o << (i ? "," : "") << heads[i] << ",\"activation\":[";
|
||||
for (size_t k = 0; k < activations[i].size(); ++k)
|
||||
{
|
||||
const RoomActivation& a = activations[i][k];
|
||||
o << (k ? "," : "") << a.szHead << ",\"seedAfterTiles\":" << a.szSeedAfterTiles << ",\"mapTiles\":"
|
||||
<< MapTiles(it->second, a.pRoom, generated[i]->nLevelId, (int)k) << "}";
|
||||
}
|
||||
o << "]}";
|
||||
}
|
||||
o << "]}\n";
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,9 @@ void D2ORACLE_CloseTrace();
|
|||
void D2ORACLE_SetTraceContext(const char* szContext);
|
||||
// Writes one free-form event line to the trace (no-op when tracing is off).
|
||||
void D2ORACLE_TraceLine(const char* szFormat, ...) __attribute__((format(printf, 1, 2)));
|
||||
|
||||
struct D2TileLibraryEntryStrc;
|
||||
// Identity of a tile returned by the D2CMP shims: the owning DT1 (lower-case, '/'-separated path
|
||||
// relative to the data root, e.g. "data/global/tiles/act1/outdoors/blank.dt1") and the tile's index in
|
||||
// that DT1's tile header table. Fails for pointers the shims did not hand out.
|
||||
void D2ORACLE_DescribeTile(const D2TileLibraryEntryStrc* pTileLibraryEntry, std::string* pszLibrary, int* pnIndex);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <malloc.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
|
@ -127,9 +128,25 @@ void __fastcall FOG_FreePool(void*, void* pFree, const char*, int, int)
|
|||
{
|
||||
free(pFree);
|
||||
}
|
||||
// D2_REALLOC_POOL leaves the grown bytes uninitialised in the game. The only caller on the DRLG path,
|
||||
// DRLGROOMTILE_ReallocRoofTileGrid, never initialises unk0x10 / nIntensity / unk0x2C of the appended
|
||||
// roof tiles, so the oracle zero-fills everything past the old block (the TypeScript port appends
|
||||
// zeroed elements). FOG_AllocPool is calloc, which clears the whole usable size, so by induction every
|
||||
// byte past the requested size is zero.
|
||||
void* __fastcall FOG_ReallocPool(void*, void* pMem, int nSize, const char*, int, int)
|
||||
{
|
||||
return realloc(pMem, nSize > 0 ? nSize : 1);
|
||||
const size_t nOldUsable = pMem ? malloc_usable_size(pMem) : 0;
|
||||
void* pNew = realloc(pMem, nSize > 0 ? nSize : 1);
|
||||
if (!pNew)
|
||||
{
|
||||
D2ORACLE_Fail("FOG_ReallocPool(%d) failed", nSize);
|
||||
}
|
||||
const size_t nNewUsable = malloc_usable_size(pNew);
|
||||
if (nNewUsable > nOldUsable)
|
||||
{
|
||||
memset((char*)pNew + nOldUsable, 0, nNewUsable - nOldUsable);
|
||||
}
|
||||
return pNew;
|
||||
}
|
||||
void* __fastcall FOG_Alloc(int nSize, const char*, int, int)
|
||||
{
|
||||
|
|
@ -330,19 +347,27 @@ BOOL __fastcall MONSTERS_ValidateMonsterId(int)
|
|||
// DRLGROOMTILE_GetTileCache (DrlgRoomTile.cpp:45-92) rolls the room seed iff the rarity sum of the
|
||||
// tiles returned by D2CMP_10088_GetTiles is > 0, and it runs inside the LvlSub stamping of
|
||||
// InitOutdoorRoomGrids (sub_6FD8ACE0 -> DRLGROOMTILE_InitTileShadow), so the tile data advances the
|
||||
// substitution RNG stream. Only the per-query tile count and rarity sum influence the dumped grids.
|
||||
// The ORDER of the returned entries (slot order, then DT1 file order) is not verified against
|
||||
// D2CMP.dll; it only decides which tile variant GetTileCache picks, which the oracle does not dump.
|
||||
// substitution RNG stream. Since schema 3 the chosen entry itself is dumped (D2DrlgTileDataStrc::pTile
|
||||
// as [library path, tile index in that DT1]), so the ORDER of the returned entries (slot order, then DT1
|
||||
// file order) decides dumped output: which variant DRLGROOMTILE_GetTileCache picks for a given roll.
|
||||
// That order is NOT verified against the real D2CMP.dll (open question; the returned count and rarity
|
||||
// sum do not depend on it).
|
||||
namespace
|
||||
{
|
||||
constexpr size_t kDt1HeaderSize = 0x114;
|
||||
constexpr size_t kDt1TileHeaderSize = 96;
|
||||
constexpr int kTileSlots = 32; // D2DrlgRoomStrc::pTiles / D2DrlgStrc::pTiles
|
||||
|
||||
struct OracleTileLibrary;
|
||||
|
||||
// POD: allocated with new + memset(0). pLibrary / nIndex are the oracle's identity of the entry
|
||||
// (not D2CMP fields), used only by D2ORACLE_DescribeTile.
|
||||
struct OracleTile
|
||||
{
|
||||
D2TileLibraryEntryStrc tEntry;
|
||||
uint8_t nSubtileFlags[25];
|
||||
const OracleTileLibrary* pLibrary;
|
||||
int nIndex;
|
||||
};
|
||||
|
||||
struct OracleTileLibrary
|
||||
|
|
@ -415,6 +440,8 @@ OracleTileLibrary* LoadTileLibrary(const char* szFileName)
|
|||
pTile->tEntry.nSequence = ReadLE<int32_t>(bytes, nBase + 0x1C);
|
||||
pTile->tEntry.nRarity_Frame = ReadLE<int32_t>(bytes, nBase + 0x20);
|
||||
memcpy(pTile->nSubtileFlags, bytes.data() + nBase + 0x28, sizeof(pTile->nSubtileFlags));
|
||||
pTile->pLibrary = pLibrary;
|
||||
pTile->nIndex = i;
|
||||
pLibrary->tiles.push_back(pTile);
|
||||
gTilesByEntry[&pTile->tEntry] = pTile;
|
||||
}
|
||||
|
|
@ -434,6 +461,13 @@ OracleTile* TileOf(const D2TileLibraryEntryStrc* pEntry)
|
|||
}
|
||||
} // namespace
|
||||
|
||||
void D2ORACLE_DescribeTile(const D2TileLibraryEntryStrc* pTileLibraryEntry, std::string* pszLibrary, int* pnIndex)
|
||||
{
|
||||
const OracleTile* pTile = TileOf(pTileLibraryEntry);
|
||||
*pszLibrary = pTile->pLibrary->szPath;
|
||||
*pnIndex = pTile->nIndex;
|
||||
}
|
||||
|
||||
//D2Cmp.#10087: stores the library in the first free slot of the 32-slot array.
|
||||
void __stdcall D2CMP_10087_LoadTileLibrarySlot(D2TileLibraryHashStrc** ppTileLibraryHash, const char* szFileName)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,11 +17,7 @@
|
|||
},
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts", "vite.config.ts", "tests/**/*.test.ts"],
|
||||
"exclude": [
|
||||
"src/game/drlg",
|
||||
"scripts/drlg-diff.ts",
|
||||
"scripts/lib/drlg-oracle.ts",
|
||||
"tests/e2e-drop-parity",
|
||||
"tests/fail-fast-drop.test.ts",
|
||||
"tests/drlg-act1-oracle.test.ts"
|
||||
"tests/fail-fast-drop.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue