diablo2-web/tests/wilderness-seams.test.ts

312 lines
12 KiB
TypeScript

import { describe, expect, test } from "vitest"
import {
generateWilderness,
getPerimeterOpenings,
type WildernessPiece,
type SeamConstraint,
type GateOpening,
type PlannedGate,
} from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ 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 makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
]
}
describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", () => {
describe("getPerimeterOpenings unit tests", () => {
test("uses forced gate.blockCoord directly", () => {
const gates: PlannedGate[] = [
{ label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 5 },
]
const openings = getPerimeterOpenings(2, 10, 10, gates)
// East side is at x = 9, y = 5
expect(openings.has("9,5")).toBe(true)
})
test("clamps gate.blockCoord away from corners [1, size - 2]", () => {
const gates: PlannedGate[] = [
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 0 }, // Out of bounds / corner -> clamp to 1
{ label: "Dark Wood", side: "south", toLevelId: 5, blockCoord: 9 }, // Out of bounds / corner -> clamp to 8
]
const openings = getPerimeterOpenings(3, 10, 10, gates)
// North opening at x = 1, y = 0
expect(openings.has("1,0")).toBe(true)
// South opening at x = 8, y = 9
expect(openings.has("8,9")).toBe(true)
})
test("matches seamConstraints by side and level ID", () => {
const gates: PlannedGate[] = [
{ label: "Blood Moor", side: "west", toLevelId: 2 },
]
const seamConstraints: SeamConstraint[] = [
{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: 4 },
]
const openings = getPerimeterOpenings(3, 10, 10, gates, seamConstraints)
// West opening at x = 0, y = 4
expect(openings.has("0,4")).toBe(true)
})
test("converts tileCoord to blockCoord when blockCoord is not specified", () => {
const gates: PlannedGate[] = [
{ label: "Stony Field", side: "north", toLevelId: 4, tileCoord: { x: 48, y: 0 } },
]
// tile x: 48 -> block x: 48 / 8 = 6
const openings = getPerimeterOpenings(3, 10, 10, gates)
expect(openings.has("6,0")).toBe(true)
})
test("handles extreme out-of-bounds coordinates safely", () => {
const gates: PlannedGate[] = [
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: -100 },
{ label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 1000 },
]
const openings = getPerimeterOpenings(2, 10, 10, gates)
expect(openings.has("0,1")).toBe(true)
expect(openings.has("9,8")).toBe(true)
})
})
// Act I outdoor levels come from the DRLG port (src/game/drlg); their seams are checked on the
// port's act layout in tests/drlg-act1-population.test.ts. The planned-gate / seam-constraint
// machinery below is shared by Acts 2-5 and runs on the Act II desert levels (41 Rocky Waste,
// 42 Dry Hills, 43 Far Oasis, 56 Halls of the Dead).
describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => {
test("honors gates with explicit blockCoord", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x999999,
pieces,
substitutions: [],
gates: [
{ label: "Rocky Waste", side: "west", toLevelId: 41, blockCoord: 3 },
{ label: "Far Oasis", side: "north", toLevelId: 43, blockCoord: 6 },
{ label: "Halls of the Dead", side: "east", toLevelId: 56, blockCoord: 7 },
],
})
const openings = result.stats.gateOpenings
expect(openings).toBeDefined()
const west = openings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(3)
const north = openings!.find(g => g.side === "north")
expect(north?.blockCoord).toBe(6)
const east = openings!.find(g => g.side === "east")
expect(east?.blockCoord).toBe(7)
})
test("honors gates with explicit tileCoord and positions road anchors", () => {
const pieces = makeTestPieces()
const targetTileY = 8 * 4 + 3 // block 4, tile 35
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x777777,
pieces,
substitutions: [],
gates: [
{ label: "Rocky Waste", side: "west", toLevelId: 41, tileCoord: { x: 3, y: targetTileY } },
],
})
const openings = result.stats.gateOpenings
expect(openings).toBeDefined()
const west = openings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(4)
expect(west?.tileCoord).toEqual({ x: 3, y: targetTileY })
})
})
describe("Bidirectional level links & seam alignment consistency", () => {
test("level A (East) and level B (West) have matching seam and opening block", () => {
const pieces = makeTestPieces()
const targetSeamY = 5
const levelA = generateWilderness({
levelId: 41,
levelName: "Level A",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x111,
pieces,
substitutions: [],
gates: [{ label: "Level B", side: "east", toLevelId: 42, blockCoord: targetSeamY }],
})
const levelB = generateWilderness({
levelId: 42,
levelName: "Level B",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x222,
pieces,
substitutions: [],
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: targetSeamY }],
})
const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")!
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "west")!
expect(gateA.blockCoord).toBe(targetSeamY)
expect(gateB.blockCoord).toBe(targetSeamY)
// Along-seam tile coordinate: Y coordinate matches
expect(gateA.tileCoord!.y).toBe(gateB.tileCoord!.y)
})
test("level B (North) and level C (South) have matching seam and opening block", () => {
const pieces = makeTestPieces()
const targetSeamX = 6
const levelB = generateWilderness({
levelId: 42,
levelName: "Level B",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x333,
pieces,
substitutions: [],
gates: [{ label: "Level C", side: "north", toLevelId: 43, blockCoord: targetSeamX }],
})
const levelC = generateWilderness({
levelId: 43,
levelName: "Level C",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x444,
pieces,
substitutions: [],
// Only the west/east gates are defaults; the south seam gate is planned and takes its block
// from the seam constraint.
gates: [{ label: "Level B", side: "south", toLevelId: 42 }],
seamConstraints: [{ side: "south", fromLevelId: 42, toLevelId: 43, blockCoord: targetSeamX }],
})
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")!
const gateC = levelC.stats.gateOpenings!.find(g => g.side === "south")!
expect(gateB.blockCoord).toBe(targetSeamX)
expect(gateC.blockCoord).toBe(targetSeamX)
// Along-seam tile coordinate: X coordinate matches
expect(gateB.tileCoord!.x).toBe(gateC.tileCoord!.x)
})
})
describe("Out of bounds clamping gracefully preserves valid playable borders", () => {
test("clamps negative blockCoord to 1 without crashing", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x555,
pieces,
substitutions: [],
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: -10 }],
})
const west = result.stats.gateOpenings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(1)
})
test("clamps excessive blockCoord to gridHeight - 2 without crashing", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x666,
pieces,
substitutions: [],
seamConstraints: [{ side: "east", fromLevelId: 43, toLevelId: 42, blockCoord: 999 }],
})
const east = result.stats.gateOpenings!.find(g => g.side === "east")
expect(east?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
})
})
})