249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
/**
|
||
* Ground-truth tests for the 1.13c room-seam and doorway rules in `stamp`.
|
||
*
|
||
* Both behaviours are transcribed from Diablo II v1.13c `D2Common.dll`
|
||
* (MD5 `ee1238806ef6d6d9801d12a09d128fe1`), via D2MOO:
|
||
*
|
||
* - `DRLGPRESET_InitPresetRoomGrids` (`DrlgPreset.cpp:1105`) derives
|
||
* `bKillEdgeX`/`bKillEdgeY` from `LvlPrest.KillEdge` and the room's position
|
||
* relative to the map's outer edge, and `DRLGROOMTILE_LoadInitRoomTiles`
|
||
* (`DrlgRoomTile.cpp:449`) turns them into
|
||
* `nTileCountX = nTileWidth + (bKillEdgeX == 0)`.
|
||
* - `DRLGROOMTILE_LoadInitRoomTiles` drops hidden door and exit walls
|
||
* (`DrlgRoomTile.cpp:466`, `479`) and `DRLGROOMTILE_LoadFloorWarpTiles`
|
||
* (`DrlgRoomTile.cpp:706`) backfills a 2×2 threshold patch at `(nX-1, nY-1)`
|
||
* using `gWarpTileOffsets_6FDD1320 = {0,0},{1,0},{0,1},{1,1}`.
|
||
*/
|
||
|
||
import { describe, expect, test } from 'vitest'
|
||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../src/formats/ds1.ts'
|
||
import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze.ts'
|
||
|
||
/** DS1 wall types 1.13c treats as markers rather than artwork. */
|
||
const TYPE_LEFT_DOOR = 8
|
||
const TYPE_LEFT_EXIT = 10
|
||
|
||
/** A floor slot that draws something. */
|
||
function solidFloor(): Ds1Floor {
|
||
return { prop1: 1, sequence: 0, style: 5, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/** The decoder's placeholder floor — what `floorIsBlank` looks for. */
|
||
function blankFloor(): Ds1Floor {
|
||
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/** A plain wall slot. */
|
||
function plainWall(): Ds1Wall {
|
||
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
|
||
}
|
||
|
||
/** Where a marker wall goes inside every piece, and what it looks like. */
|
||
interface MarkerSpec {
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly type: number
|
||
readonly style: number
|
||
readonly hidden: boolean
|
||
/** Blank the 2×2 threshold the backfill is expected to repair. */
|
||
readonly blankThreshold?: boolean
|
||
}
|
||
|
||
/**
|
||
* Build a piece DS1 of the given size, optionally carrying one marker wall.
|
||
*
|
||
* @param size - width and height in cells.
|
||
* @param marker - the marker wall to embed, if any.
|
||
* @returns the map.
|
||
*/
|
||
function pieceDs1(size: number, marker?: MarkerSpec): Ds1 {
|
||
const blanks = new Set<string>()
|
||
if (marker?.blankThreshold === true) {
|
||
for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) {
|
||
blanks.add(`${String(marker.x - 1 + dx)},${String(marker.y - 1 + dy)}`)
|
||
}
|
||
}
|
||
const cells: Ds1Cell[][] = []
|
||
for (let y = 0; y < size; y += 1) {
|
||
const row: Ds1Cell[] = []
|
||
for (let x = 0; x < size; x += 1) {
|
||
const isMarker = marker !== undefined && marker.x === x && marker.y === y
|
||
row.push({
|
||
walls: [isMarker
|
||
? { prop1: 0, sequence: 0, style: marker.style, type: marker.type, unknown1: 0, unknown2: 0, hidden: marker.hidden }
|
||
: plainWall()],
|
||
floors: [blanks.has(`${String(x)},${String(y)}`) ? blankFloor() : solidFloor()],
|
||
shadows: [blankFloor()],
|
||
substitutions: [],
|
||
})
|
||
}
|
||
cells.push(row)
|
||
}
|
||
return {
|
||
version: 18, width: size, height: size, act: 1, substitutionType: 0,
|
||
wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The 15 side shapes plus the entrance and warp pieces `generateMaze` needs.
|
||
*
|
||
* Every piece is the same size and carries the same marker, so whichever piece
|
||
* the generator picks the assertion below still has something to find.
|
||
*
|
||
* @param size - piece size in cells; one larger than the section stride.
|
||
* @param killEdge - the `LvlPrest.KillEdge` value to report.
|
||
* @param marker - the marker wall to embed in every piece.
|
||
* @returns the piece set.
|
||
*/
|
||
function pieceSet(size: number, killEdge: boolean, marker?: MarkerSpec): MazePiece[] {
|
||
const sides = ['N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW', 'NS', 'EW', 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW']
|
||
const make = (name: string, kind: MazePieceKind, side: string): MazePiece =>
|
||
({ name, kind, sides: side, levels: [pieceDs1(size, marker)], killEdge })
|
||
return [
|
||
make('Act 1 - Cave Entrance', 'entrance', ''),
|
||
...sides.map(side => make(`Act 1 - Cave ${side}`, 'room', side)),
|
||
...['N', 'S', 'E', 'W'].flatMap(side => [
|
||
make(`Act 1 - Cave Prev ${side}`, 'prev', side),
|
||
make(`Act 1 - Cave Next ${side}`, 'next', side),
|
||
make(`Act 1 - Cave Down ${side}`, 'down', side),
|
||
make(`Act 1 - Cave Theme ${side}`, 'theme', side),
|
||
]),
|
||
]
|
||
}
|
||
|
||
/** A small deterministic Act 1 Cave request. */
|
||
function request(pieces: readonly MazePiece[], overrides: Partial<MazeRequest> = {}): MazeRequest {
|
||
return {
|
||
levelId: 2,
|
||
levelName: 'Act 1 - Cave 1',
|
||
levelTypeName: 'Act 1 - Cave',
|
||
sectionSize: 7,
|
||
minRooms: 5,
|
||
merge: 0,
|
||
seed: 0x1337,
|
||
pieces,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
/** Every wall slot in a generated map, flattened. */
|
||
function allWalls(level: Ds1): Ds1Wall[] {
|
||
return level.cells.flatMap(row => row.flatMap(cell => [...cell.walls]))
|
||
}
|
||
|
||
describe('1.13c bKillEdge room-seam suppression', () => {
|
||
test('KillEdge trims exactly one column and one row off the composite map', () => {
|
||
// A piece is one cell wider than the 7-cell stride, so without KillEdge the
|
||
// outer room contributes an overhang that has no neighbour to overwrite it.
|
||
const off = generateMaze(request(pieceSet(8, false)))
|
||
const on = generateMaze(request(pieceSet(8, true)))
|
||
|
||
expect(on.level.width).toBe(off.level.width - 1)
|
||
expect(on.level.height).toBe(off.level.height - 1)
|
||
})
|
||
|
||
test('KillEdge is read off the pieces when the request does not override it', () => {
|
||
const derived = generateMaze(request(pieceSet(8, true)))
|
||
const overridden = generateMaze(request(pieceSet(8, true), { killEdge: false }))
|
||
const plain = generateMaze(request(pieceSet(8, false)))
|
||
|
||
// The override wins over the piece rows, and matches the un-trimmed map.
|
||
expect(overridden.level.width).toBe(plain.level.width)
|
||
expect(derived.level.width).toBe(plain.level.width - 1)
|
||
|
||
const notes = derived.stats.notes as string[]
|
||
expect(notes.some(note => note.startsWith('killEdge trimmed'))).toBe(true)
|
||
expect((plain.stats.notes as string[]).some(note => note.startsWith('killEdge trimmed'))).toBe(false)
|
||
})
|
||
|
||
test('trimming never drops interior seams, only the outer edge', () => {
|
||
// With KillEdge on, the map is exactly `(span of origins) + stride`, which
|
||
// is what `pDrlgCoord.nWidth` is in 1.13c. Every interior room therefore
|
||
// still stamps its full 8 cells and keeps overlapping its neighbour.
|
||
const on = generateMaze(request(pieceSet(8, true), { minRooms: 9 }))
|
||
const stride = 7
|
||
expect((on.level.width - stride) % stride).toBe(0)
|
||
expect((on.level.height - stride) % stride).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('1.13c hidden door and exit walls', () => {
|
||
test('a hidden door wall is never emitted as artwork', () => {
|
||
const hidden = generateMaze(request(pieceSet(8, false, {
|
||
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true,
|
||
})))
|
||
const shown = generateMaze(request(pieceSet(8, false, {
|
||
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: false,
|
||
})))
|
||
|
||
expect(allWalls(hidden.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(false)
|
||
// The identical non-hidden door is ordinary artwork and must survive.
|
||
expect(allWalls(shown.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(true)
|
||
})
|
||
|
||
test('an exit tile above the Warp0..7 slot range is dropped outright', () => {
|
||
// `nTileStyle >= 8` is not a warp slot, so 1.13c skips the cell even though
|
||
// the tile is not flagged hidden.
|
||
const tooHigh = generateMaze(request(pieceSet(8, false, {
|
||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 8, hidden: false,
|
||
})))
|
||
const inRange = generateMaze(request(pieceSet(8, false, {
|
||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 7, hidden: false,
|
||
})))
|
||
|
||
expect(allWalls(tooHigh.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(false)
|
||
expect(allWalls(inRange.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(true)
|
||
})
|
||
|
||
test('a hidden exit backfills the 2x2 threshold floor patch', () => {
|
||
const marker: MarkerSpec = {
|
||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true,
|
||
}
|
||
const patched = generateMaze(request(pieceSet(8, false, marker)))
|
||
|
||
const notes = patched.stats.notes as string[]
|
||
const note = notes.find(entry => entry.startsWith('hid '))
|
||
expect(note).toBeDefined()
|
||
|
||
const filled = Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0')
|
||
expect(filled).toBeGreaterThan(0)
|
||
|
||
// Every backfilled cell must draw a floor and be flagged hidden, the way
|
||
// `DRLGROOMTILE_LoadFloorWarpTiles` writes them.
|
||
let hiddenFloors = 0
|
||
for (const row of patched.level.cells) {
|
||
for (const cell of row) {
|
||
const floor = cell.floors[0]
|
||
if (floor !== undefined && floor.hidden && floor.style === 5) hiddenFloors += 1
|
||
}
|
||
}
|
||
expect(hiddenFloors).toBe(filled)
|
||
})
|
||
|
||
test('a hidden door suppresses its wall but gets no warp threshold patch', () => {
|
||
// `LoadFloorWarpTiles` is reached only from the exit branch; the door branch
|
||
// above it spawns a unit and `continue`s without touching the floor.
|
||
const door = generateMaze(request(pieceSet(8, false, {
|
||
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true, blankThreshold: true,
|
||
})))
|
||
const exit = generateMaze(request(pieceSet(8, false, {
|
||
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true,
|
||
})))
|
||
|
||
const backfilled = (result: typeof door): number => {
|
||
const note = (result.stats.notes as string[]).find(entry => entry.startsWith('hid '))
|
||
return Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0')
|
||
}
|
||
|
||
expect(backfilled(door)).toBe(0)
|
||
expect(backfilled(exit)).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('a map with no markers reports no hidden walls and no backfill', () => {
|
||
const plain = generateMaze(request(pieceSet(8, false)))
|
||
expect((plain.stats.notes as string[]).some(note => note.startsWith('hid '))).toBe(false)
|
||
})
|
||
})
|
||
|