diablo2-web/tests/wilderness-waypoints-shrine...

430 lines
15 KiB
TypeScript

import { describe, expect, test } from "vitest"
import {
generateWilderness,
OUTDOOR_WAYPOINT_LEVELS,
actOfLevel,
ACT_POPULATION_CONFIG,
type WildernessPiece,
type WildernessSubstitution,
} from "../src/game/wilderness.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("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 clearing column (x < 32)
const wpTile = result.stats.waypointTile as { x: number; y: number }
expect(wpTile.x).toBeLessThan(32)
// 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 1 - Wild")
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 96,
sizeY: 96,
subType: 6,
subTheme: 0,
seed: 0x8899aabb,
pieces,
substitutions: [],
})
const shrineIds = new Set(ACT_POPULATION_CONFIG[1].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 1 - Wild")
// 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 act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
for (let s = 0; s < 10; s += 1) {
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x5000 + s * 17,
pieces,
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))
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)
})
})