351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
import { describe, expect, test } from "vitest"
|
|
import {
|
|
type Canvas,
|
|
DRLG_GRID_FLAG_WAYPOINT,
|
|
DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS,
|
|
DRLG_GRID_SPAWN_INVALID_MASK,
|
|
DRLGOUTDOORS_SpawnAct12Waypoint,
|
|
type PlacedPresetRect,
|
|
rollLimited,
|
|
type SeamConstraint,
|
|
TestGridCellSpawnValid,
|
|
type WildernessStats,
|
|
} from "../src/game/wilderness.ts"
|
|
import { Rng } from "../src/game/rng.ts"
|
|
import type { Ds1Cell } from "../src/formats/ds1.ts"
|
|
|
|
class TrackingRng extends Rng {
|
|
rollCount = 0
|
|
override next(): number {
|
|
this.rollCount += 1
|
|
return super.next()
|
|
}
|
|
}
|
|
|
|
function makeOutdoorCanvas(width: number, height: number): Canvas {
|
|
const cells: Ds1Cell[][] = []
|
|
for (let y = 0; y < height; y += 1) {
|
|
const row: Ds1Cell[] = []
|
|
for (let x = 0; x < width; x += 1) {
|
|
row.push({
|
|
walls: [],
|
|
floors: [{
|
|
prop1: 2,
|
|
sequence: 0,
|
|
style: 0,
|
|
unknown1: 0,
|
|
unknown2: 0,
|
|
hidden: false,
|
|
}],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
}
|
|
cells.push(row)
|
|
}
|
|
return {
|
|
width,
|
|
height,
|
|
cells,
|
|
wallLayers: 1,
|
|
floorLayers: 1,
|
|
substitutionLayers: 1,
|
|
objects: [],
|
|
}
|
|
}
|
|
|
|
function makeEmptyStats(): WildernessStats {
|
|
return {
|
|
substitutions: [],
|
|
borderPieces: {},
|
|
unresolved: [],
|
|
notes: [],
|
|
borderStamped: 0,
|
|
groundCells: 0,
|
|
groundTile: null,
|
|
sizeSource: "default",
|
|
roadCells: 0,
|
|
roadSegments: 0,
|
|
anchors: 0,
|
|
specialPresets: [],
|
|
entrances: [],
|
|
waypointsSpawned: 0,
|
|
shrinesSpawned: 0,
|
|
interactivesSpawned: 0,
|
|
}
|
|
}
|
|
|
|
describe("DRLGOUTDOORS_SpawnAct12Waypoint 1.13c Parity (Issue #80)", () => {
|
|
describe("TestGridCellSpawnValid and DRLG_GRID_SPAWN_INVALID_MASK", () => {
|
|
test("DRLG_GRID_SPAWN_INVALID_MASK matches 1.13c disassembly 0x1B81", () => {
|
|
expect(DRLG_GRID_SPAWN_INVALID_MASK).toBe(0x1b81)
|
|
})
|
|
|
|
test("cell flags with no 0x1B81 bits pass validation", () => {
|
|
const validFlags = [
|
|
0,
|
|
0x0002,
|
|
0x0004,
|
|
0x0008,
|
|
0x0010,
|
|
0x0020,
|
|
0x0040,
|
|
0x0400,
|
|
0x2000,
|
|
0x4000,
|
|
0x8000,
|
|
DRLG_GRID_FLAG_WAYPOINT, // 0x10000
|
|
DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS, // 0x20000
|
|
0x0002 | 0x0010 | 0x20000,
|
|
]
|
|
|
|
for (const flag of validFlags) {
|
|
const grid2D = [[flag]]
|
|
expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(true)
|
|
|
|
const grid1D = [flag]
|
|
expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(true)
|
|
|
|
const gridU32 = new Uint32Array([flag])
|
|
expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(true)
|
|
|
|
const gridObj = { width: 1, height: 1, flags: [flag] }
|
|
expect(TestGridCellSpawnValid(gridObj, 0, 0)).toBe(true)
|
|
}
|
|
})
|
|
|
|
test("cell flags with any 0x1B81 bits fail validation", () => {
|
|
const invalidFlags = [
|
|
0x0001, // preset occupied
|
|
0x0080, // border cell
|
|
0x0100, // collision bit
|
|
0x0200, // collision bit
|
|
0x0800, // collision bit
|
|
0x1000, // walls / impassable
|
|
0x1b81, // full mask
|
|
0x1000 | 0x0002, // mixed
|
|
0x0001 | DRLG_GRID_FLAG_WAYPOINT,
|
|
]
|
|
|
|
for (const flag of invalidFlags) {
|
|
const grid2D = [[flag]]
|
|
expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(false)
|
|
|
|
const grid1D = [flag]
|
|
expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(false)
|
|
|
|
const gridU32 = new Uint32Array([flag])
|
|
expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(false)
|
|
}
|
|
})
|
|
|
|
test("out of bounds coordinates safely return false", () => {
|
|
const grid = [[0, 0], [0, 0]]
|
|
expect(TestGridCellSpawnValid(grid, -1, 0)).toBe(false)
|
|
expect(TestGridCellSpawnValid(grid, 0, -1)).toBe(false)
|
|
expect(TestGridCellSpawnValid(grid, 2, 0)).toBe(false)
|
|
expect(TestGridCellSpawnValid(grid, 0, 2)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("rollLimited helper", () => {
|
|
test("rolls within [0, max - 1] and consumes exactly 1 roll per call", () => {
|
|
const rng = new TrackingRng(0x12345678)
|
|
const max = 36
|
|
for (let i = 0; i < 100; i += 1) {
|
|
const prevRolls = rng.rollCount
|
|
const val = rollLimited(rng, max)
|
|
expect(val).toBeGreaterThanOrEqual(0)
|
|
expect(val).toBeLessThan(max)
|
|
expect(rng.rollCount).toBe(prevRolls + 1)
|
|
}
|
|
})
|
|
|
|
test("handles edge case max <= 0 without consuming or erroring", () => {
|
|
const rng = new TrackingRng(0x12345678)
|
|
expect(rollLimited(rng, 0)).toBe(0)
|
|
expect(rollLimited(rng, -5)).toBe(0)
|
|
expect(rng.rollCount).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("Branch A: Cold Plains Special (levelId === 3)", () => {
|
|
test("consumes exactly 0 RNG rolls", () => {
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
const stats = makeEmptyStats()
|
|
const rng = new TrackingRng(0xabcdef)
|
|
const seamConstraints: SeamConstraint[] = [
|
|
{ fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 },
|
|
]
|
|
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(
|
|
canvas,
|
|
3,
|
|
1,
|
|
undefined,
|
|
[],
|
|
rng,
|
|
stats,
|
|
"Act 1 - Wilderness",
|
|
undefined,
|
|
seamConstraints,
|
|
)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
// Exactly 0 RNG rolls consumed!
|
|
expect(rng.rollCount).toBe(0)
|
|
})
|
|
|
|
test("clamps connection to Blood Moor on all 4 boundary sides and sets 0x20000 flag", () => {
|
|
const testCases: { side: "west" | "east" | "north" | "south"; expectedBlockX: number; expectedBlockY: number }[] = [
|
|
{ side: "west", expectedBlockX: 1, expectedBlockY: 4 }, // j=0 clamped to 1, i=4
|
|
{ side: "east", expectedBlockX: 6, expectedBlockY: 4 }, // j=7 clamped to 6, i=4
|
|
{ side: "north", expectedBlockX: 4, expectedBlockY: 1 }, // i=0 clamped to 1, j=4
|
|
{ side: "south", expectedBlockX: 4, expectedBlockY: 6 }, // i=7 clamped to 6, j=4
|
|
]
|
|
|
|
for (const tc of testCases) {
|
|
const canvas = makeOutdoorCanvas(64, 64) // 8x8 blocks (0..7)
|
|
const stats = makeEmptyStats()
|
|
const rng = new TrackingRng(0x9999)
|
|
const seamConstraints: SeamConstraint[] = [
|
|
{ fromLevelId: 3, toLevelId: 2, side: tc.side, blockCoord: 4 },
|
|
]
|
|
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(
|
|
canvas,
|
|
3,
|
|
1,
|
|
undefined,
|
|
[],
|
|
rng,
|
|
stats,
|
|
"Act 1 - Wilderness",
|
|
undefined,
|
|
seamConstraints,
|
|
)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
const wpTile = stats.waypointTile!
|
|
const blockX = Math.floor(wpTile.x / 8)
|
|
const blockY = Math.floor(wpTile.y / 8)
|
|
expect(blockX).toBe(tc.expectedBlockX)
|
|
expect(blockY).toBe(tc.expectedBlockY)
|
|
|
|
const note = stats.notes.find(n => n.includes("Cold Plains special grid cell"))
|
|
expect(note).toBeDefined()
|
|
expect(note).toContain(`(${tc.expectedBlockX}, ${tc.expectedBlockY})`)
|
|
|
|
expect(rng.rollCount).toBe(0)
|
|
}
|
|
})
|
|
|
|
test("clamps extreme boundary corners (e.g. j=0 clamped to 1, i=0 clamped to 1)", () => {
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
const stats = makeEmptyStats()
|
|
const rng = new TrackingRng(0x4242)
|
|
// West gate at extreme top (blockCoord 0)
|
|
const seamConstraints: SeamConstraint[] = [
|
|
{ fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 },
|
|
]
|
|
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(
|
|
canvas,
|
|
3,
|
|
1,
|
|
undefined,
|
|
[],
|
|
rng,
|
|
stats,
|
|
"Act 1 - Wilderness",
|
|
undefined,
|
|
seamConstraints,
|
|
)
|
|
|
|
expect(stats.waypointTile).toBeDefined()
|
|
const blockX = Math.floor(stats.waypointTile!.x / 8)
|
|
const blockY = Math.floor(stats.waypointTile!.y / 8)
|
|
expect(blockX).toBe(1)
|
|
expect(blockY).toBe(1)
|
|
expect(rng.rollCount).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("Branch B: General Path (e.g. Stony Field, levelId === 4)", () => {
|
|
test("consumes exactly 2 * nArea RNG rolls for coordinate shuffle", () => {
|
|
// 64x64 tiles -> 8x8 blocks of 8x8 tiles
|
|
// nGridWidth = 8, nGridHeight = 8
|
|
// nWidth = 8 - 2 = 6, nHeight = 8 - 2 = 6
|
|
// nArea = 6 * 6 = 36
|
|
// Exactly 2 * nArea = 72 rolls for shuffle, and 0 rolls during placement search
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
const stats = makeEmptyStats()
|
|
const rng = new TrackingRng(0x778899)
|
|
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
const expectedRolls = 2 * 36
|
|
expect(rng.rollCount).toBe(expectedRolls)
|
|
})
|
|
|
|
test("consumes exactly 2 * nArea rolls for larger map (80x80 tiles -> 10x10 blocks -> 8x8 area = 64 cells -> 128 rolls)", () => {
|
|
const canvas = makeOutdoorCanvas(80, 80)
|
|
const stats = makeEmptyStats()
|
|
const rng = new TrackingRng(0x112233)
|
|
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
// nWidth = 10 - 2 = 8, nHeight = 10 - 2 = 8, nArea = 64
|
|
// 2 * 64 = 128 rolls
|
|
expect(rng.rollCount).toBe(128)
|
|
})
|
|
|
|
test("skips cells occupied by preset rectangles (obstacle-free placement via TestGridCellSpawnValid)", () => {
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
const stats = makeEmptyStats()
|
|
const rng = new TrackingRng(0x5555)
|
|
|
|
// Mark preset covering block (1, 1) -> tiles [8..15, 8..15]
|
|
const placedPresets: PlacedPresetRect[] = [
|
|
{ name: "PresetObstacle", x: 8, y: 8, w: 8, h: 8 },
|
|
]
|
|
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, placedPresets, rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
const wp = stats.waypointTile!
|
|
const blockX = Math.floor(wp.x / 8)
|
|
const blockY = Math.floor(wp.y / 8)
|
|
// Block (1, 1) had an obstacle so waypoint must not be in (1, 1)
|
|
expect(blockX === 1 && blockY === 1).toBe(false)
|
|
// Must be within inner bounds [1..6, 1..6]
|
|
expect(blockX).toBeGreaterThanOrEqual(1)
|
|
expect(blockX).toBeLessThanOrEqual(6)
|
|
expect(blockY).toBeGreaterThanOrEqual(1)
|
|
expect(blockY).toBeLessThanOrEqual(6)
|
|
})
|
|
|
|
test("is deterministic: identical seeds produce identical waypoint coordinates", () => {
|
|
const canvas1 = makeOutdoorCanvas(64, 64)
|
|
const stats1 = makeEmptyStats()
|
|
const rng1 = new TrackingRng(0xfeedbeef)
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas1, 4, 1, undefined, [], rng1, stats1)
|
|
|
|
const canvas2 = makeOutdoorCanvas(64, 64)
|
|
const stats2 = makeEmptyStats()
|
|
const rng2 = new TrackingRng(0xfeedbeef)
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas2, 4, 1, undefined, [], rng2, stats2)
|
|
|
|
expect(stats1.waypointTile).toEqual(stats2.waypointTile)
|
|
expect(rng1.rollCount).toBe(rng2.rollCount)
|
|
})
|
|
})
|
|
})
|