diablo2-web/tests/killedge-113c.test.ts

249 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
})
})