735 lines
25 KiB
TypeScript
735 lines
25 KiB
TypeScript
import { describe, expect, test } from "vitest"
|
|
import {
|
|
generateWilderness,
|
|
OUTDOOR_WAYPOINT_LEVELS,
|
|
actOfLevel,
|
|
hasOutdoorWaypoint,
|
|
ACT_POPULATION_CONFIG,
|
|
createCanvas,
|
|
DRLGOUTDOORS_SpawnAct12Waypoint,
|
|
DRLGOUTDOORS_SpawnAct12Shrines,
|
|
spawnOutdoorWaypoint,
|
|
spawnOutdoorShrines,
|
|
type Canvas,
|
|
type PlacedPresetRect,
|
|
type WildernessPiece,
|
|
type WildernessStats,
|
|
type WildernessSubstitution,
|
|
} from "../src/game/wilderness.ts"
|
|
import {
|
|
actOfLevel as actsActOfLevel,
|
|
hasOutdoorWaypoint as actsHasOutdoorWaypoint,
|
|
type D2Table,
|
|
} from "../src/game/acts.ts"
|
|
import { Rng } from "../src/game/rng.ts"
|
|
import {
|
|
findWaypointSpot,
|
|
type LinkGrid,
|
|
} from "../src/game/level-links.ts"
|
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
|
|
|
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
|
|
const cells: Ds1Cell[][] = []
|
|
for (let y = 0; y < height; y += 1) {
|
|
const row: Ds1Cell[] = []
|
|
for (let x = 0; x < width; x += 1) {
|
|
let isWall: boolean
|
|
if (isOpen) {
|
|
// Clearings have open interior floor with perimeter walls
|
|
isWall = (x === 0 || y === 0 || y === height - 1 || (x === width - 1 && !hasGapInWalls))
|
|
} else {
|
|
isWall = !(hasGapInWalls && x === Math.floor(width / 2))
|
|
}
|
|
row.push({
|
|
walls: isWall ? [{ 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 makeGenericOutdoorPieces(prefix: string, landmarkName?: string): WildernessPiece[] {
|
|
const pieces: WildernessPiece[] = []
|
|
for (let i = 1; i <= 12; i += 1) {
|
|
pieces.push({
|
|
name: `${prefix} Border ${i}`,
|
|
border: true,
|
|
levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)],
|
|
})
|
|
}
|
|
pieces.push({ name: `${prefix} Town Transition East`, border: true, levels: [makeMockDs1(8, 8, true)] })
|
|
pieces.push({ name: `${prefix} Town Transition South`, border: true, levels: [makeMockDs1(8, 8, true)] })
|
|
if (landmarkName) {
|
|
pieces.push({ name: landmarkName, border: false, levels: [makeMockDs1(8, 8, true, true)] })
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
function makeAct3JunglePieces(): WildernessPiece[] {
|
|
const pieces: WildernessPiece[] = [
|
|
{ name: "Act 3 - Jungle Tail", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
|
{ name: "Act 3 - Jungle Head", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
|
{ name: "Act 3 - Clearing Pygmy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
|
{ name: "Act 3 - Clearing Webby E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
|
{ name: "Act 3 - Clearing Boggy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
|
|
{ name: "Act 3 - Jungle NS W", border: false, levels: [makeMockDs1(32, 32, true, false)] },
|
|
]
|
|
return pieces
|
|
}
|
|
|
|
describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () => {
|
|
test("actOfLevel correctly identifies acts from levelId or levelTypeName", () => {
|
|
expect(actOfLevel(3, "Act 1 - Wilderness")).toBe(1)
|
|
expect(actOfLevel(43, "Act 2 - Desert")).toBe(2)
|
|
expect(actOfLevel(78, "Act 3 - Jungle")).toBe(3)
|
|
expect(actOfLevel(106, "Act 4 - Mesa")).toBe(4)
|
|
expect(actOfLevel(112, "Act 5 - Barricade")).toBe(5)
|
|
|
|
// Falls back to numeric levelId ranges when levelTypeName is generic
|
|
expect(actOfLevel(4, undefined)).toBe(1)
|
|
expect(actOfLevel(42, undefined)).toBe(2)
|
|
expect(actOfLevel(76, undefined)).toBe(3)
|
|
expect(actOfLevel(107, undefined)).toBe(4)
|
|
expect(actOfLevel(117, undefined)).toBe(5)
|
|
})
|
|
|
|
test("actOfLevel resolves Act column from Levels.txt table when provided", () => {
|
|
const mockLevelsTable: D2Table = {
|
|
header: ["Id", "Name", "Act"],
|
|
rows: [
|
|
["2", "Blood Moor", "0"],
|
|
["42", "Dry Hills", "1"],
|
|
["76", "Spider Forest", "2"],
|
|
["106", "City of the Damned", "3"],
|
|
["112", "Arreat Plateau", "0"], // Expansion Act 5 row stores 0 in blizzard data
|
|
],
|
|
}
|
|
|
|
expect(actOfLevel(2, undefined, undefined, mockLevelsTable)).toBe(1)
|
|
expect(actOfLevel(42, undefined, undefined, mockLevelsTable)).toBe(2)
|
|
expect(actOfLevel(76, undefined, undefined, mockLevelsTable)).toBe(3)
|
|
expect(actOfLevel(106, undefined, undefined, mockLevelsTable)).toBe(4)
|
|
expect(actOfLevel(112, undefined, undefined, mockLevelsTable)).toBe(5)
|
|
|
|
// Re-export from acts.ts is identical
|
|
expect(actsActOfLevel).toBe(actOfLevel)
|
|
expect(actsHasOutdoorWaypoint).toBe(hasOutdoorWaypoint)
|
|
})
|
|
|
|
test("hasOutdoorWaypoint reads Levels.txt Waypoint column dynamically", () => {
|
|
const mockLevelsTable: D2Table = {
|
|
header: ["Id", "Name", "Act", "DrlgType", "Waypoint"],
|
|
rows: [
|
|
["1", "Rogue Encampment", "0", "1", "0"], // Town level -> false for outdoor waypoint
|
|
["2", "Blood Moor", "0", "3", "255"], // Outdoor, no waypoint (255) -> false
|
|
["3", "Cold Plains", "0", "3", "1"], // Outdoor, waypoint (1) -> true
|
|
["40", "Lut Gholein", "1", "1", "2"], // Town level -> false
|
|
["43", "Far Oasis", "1", "3", "3"], // Outdoor, waypoint -> true
|
|
["200", "Custom Field", "0", "3", "4"], // Custom outdoor level with waypoint -> true
|
|
["201", "Custom Field No WP", "0", "3", "255"], // Custom outdoor level without waypoint -> false
|
|
],
|
|
}
|
|
|
|
// Dynamic resolution via table
|
|
expect(hasOutdoorWaypoint(3, mockLevelsTable)).toBe(true)
|
|
expect(hasOutdoorWaypoint(2, mockLevelsTable)).toBe(false)
|
|
expect(hasOutdoorWaypoint(1, mockLevelsTable)).toBe(false) // Town
|
|
expect(hasOutdoorWaypoint(40, mockLevelsTable)).toBe(false) // Town
|
|
expect(hasOutdoorWaypoint(43, mockLevelsTable)).toBe(true)
|
|
expect(hasOutdoorWaypoint(200, mockLevelsTable)).toBe(true)
|
|
expect(hasOutdoorWaypoint(201, mockLevelsTable)).toBe(false)
|
|
|
|
// Fallback when no table provided
|
|
expect(hasOutdoorWaypoint(3)).toBe(true)
|
|
expect(hasOutdoorWaypoint(2)).toBe(false)
|
|
expect(hasOutdoorWaypoint(43)).toBe(true)
|
|
expect(hasOutdoorWaypoint(41)).toBe(false)
|
|
})
|
|
|
|
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
|
|
// Act 1
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(4)).toBe(true) // Stony Field
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(5)).toBe(true) // Dark Wood
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(6)).toBe(true) // Black Marsh
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(2)).toBe(false) // Blood Moor (no WP)
|
|
|
|
// Act 2
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(42)).toBe(true) // Dry Hills
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(43)).toBe(true) // Far Oasis
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(44)).toBe(true) // Lost City
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(46)).toBe(true) // Canyon of the Magi
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(41)).toBe(false) // Rocky Waste (no WP)
|
|
|
|
// Act 3
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(76)).toBe(true) // Spider Forest
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(77)).toBe(true) // Great Marsh
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(78)).toBe(true) // Flayer Jungle
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(79)).toBe(true) // Lower Kurast
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(80)).toBe(true) // Kurast Bazaar
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(81)).toBe(true) // Upper Kurast
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(83)).toBe(true) // Travincal
|
|
|
|
// Act 4
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(106)).toBe(true) // City of the Damned
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(107)).toBe(true) // River of Flame
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(104)).toBe(false) // Outer Steppes (no WP)
|
|
|
|
// Act 5
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(111)).toBe(true) // Frigid Highlands
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(112)).toBe(true) // Arreat Plateau
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(117)).toBe(true) // Frozen Tundra
|
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(110)).toBe(false) // Bloody Foothills (no WP)
|
|
})
|
|
|
|
test("Act 2 roadless maps (Far Oasis 43, Lost City 44) spawn Waypoint id 10 near oasis/ruins or hub", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Ruin 1")
|
|
const result = generateWilderness({
|
|
levelId: 43,
|
|
levelName: "Far Oasis",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 8,
|
|
subTheme: 0,
|
|
seed: 0x434343,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.stats.waypointsSpawned).toBe(1)
|
|
expect(result.stats.waypointTile).toBeDefined()
|
|
expect(result.stats.waypointTile).not.toBeNull()
|
|
|
|
// Waypoint object for Act 2 is ID 10
|
|
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 10)
|
|
expect(wpObj).toBeDefined()
|
|
|
|
// Shrines in Act 2 are from Act 2 shrine pool (54, 55, 103, 104, 105, 126)
|
|
const act2ShrinePool = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
|
|
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act2ShrinePool.has(o.id))
|
|
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
|
|
|
|
// Well for Act 2 is ID 234
|
|
const wellObj = result.level.objects.find(o => o.type === 2 && o.id === 234)
|
|
expect(wellObj).toBeDefined()
|
|
})
|
|
|
|
test("Act 2 Rocky Waste (level 41) does not spawn waypoint", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
|
const result = generateWilderness({
|
|
levelId: 41,
|
|
levelName: "Rocky Waste",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 8,
|
|
subTheme: 0,
|
|
seed: 0x414141,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.stats.waypointsSpawned).toBe(0)
|
|
expect(result.stats.waypointTile).toBeNull()
|
|
})
|
|
|
|
test("Act 3 Flayer Jungle (level 78) spawns Waypoint id 1 and Act 3 shrines in jungle clearings", () => {
|
|
const pieces = makeAct3JunglePieces()
|
|
const result = generateWilderness({
|
|
levelId: 78,
|
|
levelName: "Flayer Jungle",
|
|
levelTypeName: "Act 3 - Jungle",
|
|
sizeX: 64,
|
|
sizeY: 192,
|
|
subType: 10,
|
|
subTheme: 0,
|
|
seed: 0x787878,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.stats.waypointsSpawned).toBe(1)
|
|
expect(result.stats.waypointTile).toBeDefined()
|
|
expect(result.stats.waypointTile).not.toBeNull()
|
|
|
|
// Waypoint object for Act 3 is ID 1
|
|
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1)
|
|
expect(wpObj).toBeDefined()
|
|
|
|
// Waypoint is inside the level bounds
|
|
const wpTile = result.stats.waypointTile as { x: number; y: number }
|
|
expect(wpTile.x).toBeGreaterThan(0)
|
|
expect(wpTile.x).toBeLessThan(64)
|
|
|
|
// Act 3 shrines spawned
|
|
const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool)
|
|
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act3ShrinePool.has(o.id))
|
|
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
test("Act 4 City of the Damned (level 106) spawns Waypoint id 0 and Act 4 shrines", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 4 - Mesa")
|
|
const result = generateWilderness({
|
|
levelId: 106,
|
|
levelName: "City of the Damned",
|
|
levelTypeName: "Act 4 - Mesa",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 12,
|
|
subTheme: 0,
|
|
seed: 0x106106,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.stats.waypointsSpawned).toBe(1)
|
|
expect(result.stats.waypointTile).toBeDefined()
|
|
|
|
// Act 4 waypoint id is 0
|
|
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 0)
|
|
expect(wpObj).toBeDefined()
|
|
|
|
// Outer Steppes (104) has no waypoint
|
|
const steppesResult = generateWilderness({
|
|
levelId: 104,
|
|
levelName: "Outer Steppes",
|
|
levelTypeName: "Act 4 - Mesa",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 12,
|
|
subTheme: 0,
|
|
seed: 0x104104,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
expect(steppesResult.stats.waypointsSpawned).toBe(0)
|
|
expect(steppesResult.stats.waypointTile).toBeNull()
|
|
})
|
|
|
|
test("Act 5 Arreat Plateau (level 112) spawns Waypoint id 12 near ruins or central hub", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 5 - Barricade", "Act 5 - Ruins 1")
|
|
const result = generateWilderness({
|
|
levelId: 112,
|
|
levelName: "Arreat Plateau",
|
|
levelTypeName: "Act 5 - Barricade",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 14,
|
|
subTheme: 0,
|
|
seed: 0x112112,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.stats.waypointsSpawned).toBe(1)
|
|
expect(result.stats.waypointTile).toBeDefined()
|
|
|
|
// Act 5 waypoint id is 12
|
|
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 12)
|
|
expect(wpObj).toBeDefined()
|
|
|
|
// Act 5 shrines spawned
|
|
const act5ShrinePool = new Set(ACT_POPULATION_CONFIG[5].shrinePool)
|
|
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act5ShrinePool.has(o.id))
|
|
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
|
const result = generateWilderness({
|
|
levelId: 42,
|
|
levelName: "Dry Hills",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 96,
|
|
sizeY: 96,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x8899aabb,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
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
|
|
if (shrines.length >= 2) {
|
|
for (let i = 0; i < shrines.length; i += 1) {
|
|
for (let j = i + 1; j < shrines.length; j += 1) {
|
|
const s1 = shrines[i]!
|
|
const s2 = shrines[j]!
|
|
const dist = Math.hypot(
|
|
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
|
|
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
|
|
)
|
|
expect(dist).toBeGreaterThanOrEqual(7)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
|
// Use multiple seeds to observe companion spawning (60% probability)
|
|
let foundClusteredCompanion = false
|
|
const act2ChestsAndScatter = new Set([
|
|
...ACT_POPULATION_CONFIG[2].chestPool,
|
|
...ACT_POPULATION_CONFIG[2].scatterPool,
|
|
])
|
|
const act2Shrines = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
|
|
|
|
for (let s = 0; s < 10; s += 1) {
|
|
const result = generateWilderness({
|
|
levelId: 42,
|
|
levelName: "Dry Hills",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x5000 + s * 17,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
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)
|
|
const sy = Math.floor(shrine.y / 5)
|
|
for (const comp of companions) {
|
|
const cx = Math.floor(comp.x / 5)
|
|
const cy = Math.floor(comp.y / 5)
|
|
const dist = Math.hypot(cx - sx, cy - sy)
|
|
if (dist >= 1.9 && dist <= 4.5) {
|
|
foundClusteredCompanion = true
|
|
break
|
|
}
|
|
}
|
|
if (foundClusteredCompanion) break
|
|
}
|
|
if (foundClusteredCompanion) break
|
|
}
|
|
|
|
expect(foundClusteredCompanion).toBe(true)
|
|
})
|
|
|
|
test("shrineSubstitutions scales shrine count based on LvlSub max", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
|
|
const mockShrineSub: WildernessSubstitution = {
|
|
name: "Act 2 - Shrines",
|
|
type: 8,
|
|
gridSize: 1,
|
|
bordType: 0,
|
|
dt1Mask: 0,
|
|
prob: [100, 100, 100, 100, 100],
|
|
trials: [1, 1, 1, 1, 1],
|
|
max: [4, 4, 4, 4, 4],
|
|
levels: [],
|
|
}
|
|
|
|
const result = generateWilderness({
|
|
levelId: 43,
|
|
levelName: "Far Oasis",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 96,
|
|
sizeY: 96,
|
|
subType: 8,
|
|
subTheme: 0,
|
|
seed: 0x777777,
|
|
pieces,
|
|
substitutions: [],
|
|
shrineSubstitutions: [mockShrineSub],
|
|
})
|
|
|
|
// With max = 4 plus healing well, shrinesSpawned should scale up (>= 2)
|
|
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
test("Determinism: same seed produces identical waypoint and shrine layouts", () => {
|
|
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Oasis 1")
|
|
const seed = 0xabcdef12
|
|
|
|
const run1 = generateWilderness({
|
|
levelId: 43,
|
|
levelName: "Far Oasis",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 8,
|
|
subTheme: 0,
|
|
seed,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
const run2 = generateWilderness({
|
|
levelId: 43,
|
|
levelName: "Far Oasis",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 8,
|
|
subTheme: 0,
|
|
seed,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(run1.stats.waypointsSpawned).toBe(run2.stats.waypointsSpawned)
|
|
expect(run1.stats.waypointTile).toEqual(run2.stats.waypointTile)
|
|
expect(run1.stats.shrinesSpawned).toBe(run2.stats.shrinesSpawned)
|
|
expect(run1.stats.interactivesSpawned).toBe(run2.stats.interactivesSpawned)
|
|
expect(run1.level.objects).toEqual(run2.level.objects)
|
|
})
|
|
})
|
|
|
|
describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Topological Placement (Issue #68)", () => {
|
|
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,
|
|
}
|
|
}
|
|
|
|
test("Priority 1: DRLGOUTDOORS_SpawnAct12Waypoint prioritizes preset waypoint objects over roadside and campsite", () => {
|
|
const canvas = makeOutdoorCanvas(48, 48)
|
|
// Pre-place a waypoint object at (12, 12) (tile 12 => x = 60, y = 60)
|
|
canvas.objects.push({
|
|
type: 2,
|
|
id: 37, // Act 1 waypoint id
|
|
x: 12 * 5 + 2,
|
|
y: 12 * 5 + 2,
|
|
flags: 0,
|
|
})
|
|
|
|
// Also supply a dirt path road down x = 24
|
|
const dirtPathGrid = new Uint8Array(48 * 48)
|
|
for (let y = 0; y < 48; y += 1) {
|
|
dirtPathGrid[y * 48 + 24] = 1
|
|
}
|
|
|
|
// Also place a campsite preset at (36, 36)
|
|
const placedRects: PlacedPresetRect[] = [
|
|
{ name: "Act 1 - Camp", x: 36, y: 36, w: 8, h: 8 },
|
|
]
|
|
|
|
const stats = makeEmptyStats()
|
|
const rng = new Rng(0x1234)
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toEqual({ x: 12, y: 12 })
|
|
})
|
|
|
|
test("Branch A: DRLGOUTDOORS_SpawnAct12Waypoint uses Cold Plains special placement (0 RNG rolls)", () => {
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
const placedRects: PlacedPresetRect[] = []
|
|
const stats = makeEmptyStats()
|
|
const rng = new Rng(0x5678)
|
|
const initialSeed = rng.seed
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, undefined, placedRects, rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
// Cold Plains special consumes exactly 0 RNG rolls
|
|
expect(rng.seed).toBe(initialSeed)
|
|
})
|
|
|
|
test("Branch B: DRLGOUTDOORS_SpawnAct12Waypoint uses 8x8 cell grid placement avoiding preset obstacles", () => {
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
// Preset obstacle placed in cell (1, 1) -> tiles [8..15, 8..15]
|
|
const placedRects: PlacedPresetRect[] = [
|
|
{ name: "Obstacle", x: 8, y: 8, w: 8, h: 8 },
|
|
]
|
|
|
|
const stats = makeEmptyStats()
|
|
const rng = new Rng(0x9abc)
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 43, 2, undefined, placedRects, rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
expect(stats.waypointTile).toBeDefined()
|
|
const wp = stats.waypointTile!
|
|
// Waypoint should be placed in a valid cell not overlapping the obstacle [8..15, 8..15]
|
|
const inObstacle = wp.x >= 8 && wp.x < 16 && wp.y >= 8 && wp.y < 16
|
|
expect(inObstacle).toBe(false)
|
|
})
|
|
|
|
test("Player landing safety buffer around placed waypoints is obstacle-free", () => {
|
|
const canvas = makeOutdoorCanvas(48, 48)
|
|
// Add walls at specific tiles to test obstacle-free landing buffer
|
|
const obstacleCell = canvas.cells[24]![22]!
|
|
canvas.cells[24]![22] = {
|
|
...obstacleCell,
|
|
walls: [{
|
|
prop1: 2,
|
|
sequence: 0,
|
|
style: 1,
|
|
type: 0,
|
|
unknown1: 0,
|
|
unknown2: 0,
|
|
hidden: false,
|
|
}],
|
|
}
|
|
|
|
const dirtPathGrid = new Uint8Array(48 * 48)
|
|
for (let y = 0; y < 48; y += 1) {
|
|
dirtPathGrid[y * 48 + 16] = 1
|
|
}
|
|
|
|
const stats = makeEmptyStats()
|
|
const rng = new Rng(0x1122)
|
|
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, [], rng, stats)
|
|
|
|
expect(stats.waypointsSpawned).toBe(1)
|
|
const wp = stats.waypointTile!
|
|
|
|
// Verify 2-tile perimeter (5x5 tiles around wp) is 100% obstacle-free
|
|
for (let dy = -2; dy <= 2; dy += 1) {
|
|
for (let dx = -2; dx <= 2; dx += 1) {
|
|
const tx = wp.x + dx
|
|
const ty = wp.y + dy
|
|
const cell = canvas.cells[ty]![tx]!
|
|
// Must have walkable floor
|
|
expect(cell.floors.some(f => !f.hidden && f.prop1 !== 0)).toBe(true)
|
|
// Must have NO walls
|
|
expect(cell.walls.some(w => !w.hidden && w.prop1 !== 0)).toBe(false)
|
|
}
|
|
}
|
|
})
|
|
|
|
test("DRLGOUTDOORS_SpawnAct12Shrines spaces shrines across exploration branches away from main road", () => {
|
|
const canvas = makeOutdoorCanvas(64, 64)
|
|
// Main spline road running vertically at x = 32
|
|
const dirtPathGrid = new Uint8Array(64 * 64)
|
|
for (let y = 0; y < 64; y += 1) {
|
|
dirtPathGrid[y * 64 + 32] = 1
|
|
}
|
|
|
|
const stats = makeEmptyStats()
|
|
const rng = new Rng(0x4455)
|
|
DRLGOUTDOORS_SpawnAct12Shrines(canvas, 3, 1, dirtPathGrid, [], rng, stats)
|
|
|
|
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
|
|
const shrines = canvas.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
|
|
|
|
// Shrines should be spawned along secondary branches (not directly on or within 2 tiles of main road)
|
|
for (const shrine of shrines) {
|
|
const sx = Math.floor(shrine.x / 5)
|
|
expect(Math.abs(sx - 32)).toBeGreaterThanOrEqual(3)
|
|
}
|
|
|
|
// Pairwise distance between shrines must be >= 10 tiles
|
|
for (let i = 0; i < shrines.length; i += 1) {
|
|
for (let j = i + 1; j < shrines.length; j += 1) {
|
|
const s1 = shrines[i]!
|
|
const s2 = shrines[j]!
|
|
const dist = Math.hypot(
|
|
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
|
|
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
|
|
)
|
|
expect(dist).toBeGreaterThanOrEqual(10)
|
|
}
|
|
}
|
|
})
|
|
|
|
test("findWaypointSpot in level-links.ts selects high-clearance topological locations rather than blind center search", () => {
|
|
// 40x40 sub-tiles grid
|
|
const W = 40
|
|
const H = 40
|
|
const blocked = new Uint8Array(W * H).fill(1) // all blocked initially
|
|
|
|
// Create a narrow 1-sub-tile corridor at the exact center (y = 20, x = 15..25)
|
|
for (let x = 15; x <= 25; x += 1) {
|
|
blocked[20 * W + x] = 0
|
|
}
|
|
|
|
// Create a wide open room at (10, 10) with 7x7 clear walkable sub-tiles
|
|
for (let y = 7; y <= 13; y += 1) {
|
|
for (let x = 7; x <= 13; x += 1) {
|
|
blocked[y * W + x] = 0
|
|
}
|
|
}
|
|
|
|
const grid: LinkGrid = {
|
|
cellsX: 8,
|
|
cellsY: 8,
|
|
gridWidth: W,
|
|
gridHeight: H,
|
|
blocked,
|
|
}
|
|
|
|
// The naive BFS from center would pick (20, 20) in the narrow corridor
|
|
// findWaypointSpot evaluates clearance (>= 3x3) and selects inside the open room!
|
|
const spot = findWaypointSpot(grid)
|
|
expect(spot).not.toBeNull()
|
|
expect(spot!.x).toBeGreaterThanOrEqual(8)
|
|
expect(spot!.x).toBeLessThanOrEqual(12)
|
|
expect(spot!.y).toBeGreaterThanOrEqual(8)
|
|
expect(spot!.y).toBeLessThanOrEqual(12)
|
|
})
|
|
})
|