413 lines
15 KiB
TypeScript
413 lines
15 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)
|
|
})
|
|
})
|
|
|
|
describe("Sequential generation with seam interlocking (Blood Moor -> Cold Plains -> Stony Field)", () => {
|
|
test("interlocks Blood Moor East exit with Cold Plains West entrance", () => {
|
|
const pieces = makeTestPieces()
|
|
|
|
// Step 1: Generate Blood Moor (level 2)
|
|
const bloodMoorResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: "Blood Moor",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x123456,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
const bloodMoorOpenings = bloodMoorResult.stats.gateOpenings
|
|
expect(bloodMoorOpenings).toBeDefined()
|
|
expect(bloodMoorOpenings!.length).toBeGreaterThan(0)
|
|
|
|
// Find East exit to Cold Plains (level 3)
|
|
const eastGate = bloodMoorOpenings!.find(g => g.side === "east" && g.toLevelId === 3)
|
|
expect(eastGate).toBeDefined()
|
|
const sharedYBlock = eastGate!.blockCoord
|
|
expect(sharedYBlock).toBeGreaterThanOrEqual(1)
|
|
expect(sharedYBlock).toBeLessThanOrEqual(8)
|
|
|
|
// Step 2: Generate Cold Plains (level 3) with seam constraint matching Blood Moor's East exit
|
|
const coldPlainsResult = generateWilderness({
|
|
levelId: 3,
|
|
levelName: "Cold Plains",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x654321,
|
|
pieces,
|
|
substitutions: [],
|
|
seamConstraints: [
|
|
{
|
|
side: "west",
|
|
fromLevelId: 2,
|
|
toLevelId: 3,
|
|
blockCoord: sharedYBlock,
|
|
...(eastGate!.tileCoord ? { tileCoord: { x: 0, y: eastGate!.tileCoord.y } } : {}),
|
|
},
|
|
],
|
|
})
|
|
|
|
const coldPlainsOpenings = coldPlainsResult.stats.gateOpenings
|
|
expect(coldPlainsOpenings).toBeDefined()
|
|
|
|
// Find West entrance from Blood Moor (level 2)
|
|
const westGate = coldPlainsOpenings!.find(g => g.side === "west" && g.toLevelId === 2)
|
|
expect(westGate).toBeDefined()
|
|
expect(westGate!.blockCoord).toBe(sharedYBlock)
|
|
|
|
// If tileCoord was provided, verify along-seam coordinate matches
|
|
if (eastGate!.tileCoord && westGate!.tileCoord) {
|
|
expect(westGate!.tileCoord.y).toBe(eastGate!.tileCoord.y)
|
|
}
|
|
|
|
// Step 3: Cold Plains has exit to Stony Field (level 4) on North
|
|
const northGate = coldPlainsOpenings!.find(g => g.side === "north" && g.toLevelId === 4)
|
|
expect(northGate).toBeDefined()
|
|
const sharedXBlock = northGate!.blockCoord
|
|
expect(sharedXBlock).toBeGreaterThanOrEqual(1)
|
|
expect(sharedXBlock).toBeLessThanOrEqual(8)
|
|
|
|
// Step 4: Generate Stony Field (level 4) with seam constraint on South matching Cold Plains North exit
|
|
const stonyFieldResult = generateWilderness({
|
|
levelId: 4,
|
|
levelName: "Stony Field",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0xabcdef,
|
|
pieces,
|
|
substitutions: [],
|
|
seamConstraints: [
|
|
{
|
|
side: "south",
|
|
fromLevelId: 3,
|
|
toLevelId: 4,
|
|
blockCoord: sharedXBlock,
|
|
...(northGate!.tileCoord ? { tileCoord: { x: northGate!.tileCoord.x, y: 79 } } : {}),
|
|
},
|
|
],
|
|
})
|
|
|
|
const stonyFieldOpenings = stonyFieldResult.stats.gateOpenings
|
|
expect(stonyFieldOpenings).toBeDefined()
|
|
|
|
const southGate = stonyFieldOpenings!.find(g => g.side === "south" && g.toLevelId === 3)
|
|
expect(southGate).toBeDefined()
|
|
expect(southGate!.blockCoord).toBe(sharedXBlock)
|
|
|
|
if (northGate!.tileCoord && southGate!.tileCoord) {
|
|
expect(southGate!.tileCoord.x).toBe(northGate!.tileCoord.x)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => {
|
|
test("honors gates with explicit blockCoord", () => {
|
|
const pieces = makeTestPieces()
|
|
const result = generateWilderness({
|
|
levelId: 3,
|
|
levelName: "Cold Plains",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x999999,
|
|
pieces,
|
|
substitutions: [],
|
|
gates: [
|
|
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: 3 },
|
|
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 6 },
|
|
{ label: "Underground", side: "east", toLevelId: 10, 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: 3,
|
|
levelName: "Cold Plains",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x777777,
|
|
pieces,
|
|
substitutions: [],
|
|
gates: [
|
|
{ label: "Blood Moor", side: "west", toLevelId: 2, 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: 2,
|
|
levelName: "Level A",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x111,
|
|
pieces,
|
|
substitutions: [],
|
|
gates: [{ label: "Level B", side: "east", toLevelId: 3, blockCoord: targetSeamY }],
|
|
})
|
|
|
|
const levelB = generateWilderness({
|
|
levelId: 3,
|
|
levelName: "Level B",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x222,
|
|
pieces,
|
|
substitutions: [],
|
|
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, 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: 3,
|
|
levelName: "Level B",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x333,
|
|
pieces,
|
|
substitutions: [],
|
|
gates: [{ label: "Level C", side: "north", toLevelId: 4, blockCoord: targetSeamX }],
|
|
})
|
|
|
|
const levelC = generateWilderness({
|
|
levelId: 4,
|
|
levelName: "Level C",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x444,
|
|
pieces,
|
|
substitutions: [],
|
|
seamConstraints: [{ side: "south", fromLevelId: 3, toLevelId: 4, 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: 3,
|
|
levelName: "Cold Plains",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x555,
|
|
pieces,
|
|
substitutions: [],
|
|
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: -10 }],
|
|
})
|
|
|
|
const west = result.stats.gateOpenings!.find(g => g.side === "west")
|
|
expect(west?.blockCoord).toBe(1)
|
|
})
|
|
|
|
test("clamps excessive blockCoord to gridWidth - 2 without crashing", () => {
|
|
const pieces = makeTestPieces()
|
|
const result = generateWilderness({
|
|
levelId: 3,
|
|
levelName: "Cold Plains",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 0x666,
|
|
pieces,
|
|
substitutions: [],
|
|
seamConstraints: [{ side: "north", fromLevelId: 4, toLevelId: 3, blockCoord: 999 }],
|
|
})
|
|
|
|
const north = result.stats.gateOpenings!.find(g => g.side === "north")
|
|
expect(north?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
|
|
})
|
|
})
|
|
})
|