319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import { describe, expect, test } from "vitest"
|
|
import {
|
|
isClosedBorder,
|
|
selectBorderVariant,
|
|
getPerimeterOpenings,
|
|
SPECIAL_PRESETS_BY_LEVEL,
|
|
generateWilderness,
|
|
resolveUnsetSize,
|
|
UnresolvedLevelSizeError,
|
|
} from "../src/game/wilderness.ts"
|
|
import type { WildernessPiece } from "../src/game/wilderness.ts"
|
|
import { Rng } from "../src/game/rng.ts"
|
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
|
|
|
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean): 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: 1, 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,
|
|
}
|
|
}
|
|
|
|
describe("Wilderness Features & Border Rules", () => {
|
|
test("isClosedBorder identifies continuous vs open wall presets", () => {
|
|
const closed = makeMockDs1(8, 8, false)
|
|
const open = makeMockDs1(8, 8, true)
|
|
|
|
expect(isClosedBorder(closed, "south")).toBe(true)
|
|
expect(isClosedBorder(closed, "north")).toBe(true)
|
|
expect(isClosedBorder(open, "south")).toBe(false)
|
|
expect(isClosedBorder(open, "north")).toBe(false)
|
|
})
|
|
|
|
test("getPerimeterOpenings enforces 0 exits for Cow Level and 1 entrance/1 exit for Blood Moor", () => {
|
|
const cowOpenings = getPerimeterOpenings(39, 10, 10)
|
|
expect(cowOpenings.size).toBe(0)
|
|
|
|
const bloodMoorOpenings = getPerimeterOpenings(2, 10, 10)
|
|
expect(bloodMoorOpenings.size).toBe(2)
|
|
expect(bloodMoorOpenings.has("5,9")).toBe(true)
|
|
expect(bloodMoorOpenings.has("9,5")).toBe(true)
|
|
})
|
|
|
|
test("getPerimeterOpenings defaults to open borders for Act 3 Jungles and outdoor levels", () => {
|
|
// Act 3: Spider Forest (76), Great Marsh (77), Flayer Jungle (78)
|
|
const spiderForestOpenings = getPerimeterOpenings(76, 10, 10)
|
|
expect(spiderForestOpenings.size).toBe(2)
|
|
expect(spiderForestOpenings.has("5,9")).toBe(true)
|
|
expect(spiderForestOpenings.has("9,5")).toBe(true)
|
|
|
|
// Act 5: Arreat Plateau (113)
|
|
const arreatOpenings = getPerimeterOpenings(113, 10, 10)
|
|
expect(arreatOpenings.size).toBe(2)
|
|
})
|
|
|
|
test("getPerimeterOpenings maps planned gates to perimeter block coordinates", () => {
|
|
const plannedGates = [
|
|
{ side: 'north' as const, toLevelId: 75, label: 'To Town' },
|
|
{ side: 'east' as const, toLevelId: 77, label: 'To Marsh' },
|
|
]
|
|
const openings = getPerimeterOpenings(76, 12, 14, plannedGates)
|
|
expect(openings.size).toBe(2)
|
|
expect(openings.has("6,0")).toBe(true) // north: (12/2, 0)
|
|
expect(openings.has("11,7")).toBe(true) // east: (12-1, 14/2)
|
|
})
|
|
|
|
test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => {
|
|
const closedDs1 = makeMockDs1(8, 8, false)
|
|
const openDs1 = makeMockDs1(8, 8, true)
|
|
const piece: WildernessPiece = {
|
|
name: "Act 1 - Wild Border 1",
|
|
border: true,
|
|
levels: [closedDs1, openDs1],
|
|
}
|
|
|
|
const rng = new Rng(12345)
|
|
const pickedOpen = selectBorderVariant(piece, "south", true, rng)
|
|
expect(isClosedBorder(pickedOpen, "south")).toBe(false)
|
|
|
|
const pickedClosed = selectBorderVariant(piece, "south", false, rng)
|
|
expect(isClosedBorder(pickedClosed, "south")).toBe(true)
|
|
})
|
|
|
|
test("SPECIAL_PRESETS_BY_LEVEL has no Act I outdoor entries (the DRLG port places those presets)", () => {
|
|
for (const levelId of [2, 3, 4, 5, 6, 7, 17, 39]) {
|
|
expect(SPECIAL_PRESETS_BY_LEVEL[levelId]).toBeUndefined()
|
|
}
|
|
expect(SPECIAL_PRESETS_BY_LEVEL[41]).toContain("Act 2 - Desert Tomb 1")
|
|
})
|
|
|
|
test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => {
|
|
const stripNames = [
|
|
"Act 5 - Siege To Barricade",
|
|
"Act 5 - Siege Strip 13",
|
|
"Act 5 - Siege Strip 12",
|
|
"Act 5 - Siege Strip 11",
|
|
"Act 5 - Siege Strip 10",
|
|
"Act 5 - Siege Strip 9",
|
|
"Act 5 - Siege Strip 8",
|
|
"Act 5 - Siege Strip 7",
|
|
"Act 5 - Siege Strip 6",
|
|
"Act 5 - Siege Strip 5",
|
|
"Act 5 - Siege Strip 4",
|
|
"Act 5 - Siege Strip 3",
|
|
"Act 5 - Siege Strip 2",
|
|
"Act 5 - Siege Strip 1",
|
|
"Act 5 - Siege To Town",
|
|
]
|
|
|
|
const pieces: WildernessPiece[] = stripNames.map((name, idx) => {
|
|
const baseDs1 = makeMockDs1(17, 49, false)
|
|
// Create a copy with tagged floor sequence at (0,0) and one object
|
|
const cells = baseDs1.cells.map((row, y) =>
|
|
row.map((cell, x) =>
|
|
x === 0 && y === 0
|
|
? {
|
|
...cell,
|
|
floors: [{ ...cell.floors[0], sequence: idx + 10 }],
|
|
}
|
|
: cell,
|
|
),
|
|
)
|
|
const ds1: Ds1 = {
|
|
...baseDs1,
|
|
cells,
|
|
objects: [
|
|
{
|
|
type: 2,
|
|
id: 100 + idx,
|
|
x: 5,
|
|
y: 5,
|
|
flags: 0,
|
|
},
|
|
],
|
|
}
|
|
return {
|
|
name,
|
|
border: false,
|
|
levels: [ds1],
|
|
}
|
|
})
|
|
|
|
const result = generateWilderness({
|
|
levelId: 110,
|
|
levelName: "Bloody Foothills",
|
|
levelTypeName: "Act 5 - Siege",
|
|
sizeX: 240,
|
|
sizeY: 48,
|
|
subType: -1,
|
|
subTheme: -1,
|
|
seed: 42,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 })
|
|
expect(result.stats.substitutedClusters).toBe(15)
|
|
expect(result.level.width).toBe(240)
|
|
expect(result.level.height).toBe(48)
|
|
expect(result.level.objects.filter(o => o.type === 2).length).toBe(15)
|
|
expect(result.level.objects.length).toBe(17)
|
|
|
|
// Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224
|
|
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
|
|
expect(result.level.cells[0][224].floors[0].sequence).toBe(24)
|
|
})
|
|
|
|
|
|
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
|
|
const names = [
|
|
"Act 3 - Travincal NW",
|
|
"Act 3 - Travincal N ", // trailing space as in LvlPrest.txt
|
|
"Act 3 - Travincal NE",
|
|
"Act 3 - Travincal SW",
|
|
"Act 3 - Travincal S ", // trailing space as in LvlPrest.txt
|
|
"Act 3 - Travincal SE",
|
|
]
|
|
const pieces: WildernessPiece[] = names.map((name, idx) => {
|
|
const baseDs1 = makeMockDs1(name.includes(" N ") || name.includes(" S ") ? 33 : 17, 33, false)
|
|
const ds1: Ds1 = {
|
|
...baseDs1,
|
|
objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }],
|
|
}
|
|
return { name, border: false, levels: [ds1] }
|
|
})
|
|
|
|
const result = generateWilderness({
|
|
levelId: 83,
|
|
levelName: "Travincal",
|
|
levelTypeName: "Act 3 - Kurast",
|
|
sizeX: 64,
|
|
sizeY: 64,
|
|
subType: -1,
|
|
subTheme: -1,
|
|
seed: 83,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(result.level.width).toBe(64)
|
|
expect(result.level.height).toBe(64)
|
|
expect(result.stats.substitutedClusters).toBe(6)
|
|
expect(result.level.objects.length).toBe(6)
|
|
})
|
|
|
|
test("resolveUnsetSize dynamically derives vertical corridor dimensions for Level 111 and 112", () => {
|
|
const entranceDs1 = makeMockDs1(17, 33, false)
|
|
const exitDs1 = makeMockDs1(17, 33, false)
|
|
const wallDs1 = makeMockDs1(17, 17, false)
|
|
const cliffDs1 = makeMockDs1(17, 17, false)
|
|
|
|
const pieces: WildernessPiece[] = [
|
|
{ name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] },
|
|
{ name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] },
|
|
{ name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] },
|
|
{ name: "Act 5 - Barricade 2", border: false, levels: [wallDs1] },
|
|
{ name: "Act 5 - Barricade Cliff Border 1", border: true, levels: [cliffDs1] },
|
|
]
|
|
|
|
for (const levelId of [111, 112]) {
|
|
const resolved = resolveUnsetSize({
|
|
levelId,
|
|
levelName: `Act 5 - Barricade ${levelId - 110}`,
|
|
levelTypeName: "Act 5 - Barricade",
|
|
sizeX: -1,
|
|
sizeY: -1,
|
|
subType: 10,
|
|
subTheme: 0,
|
|
seed: 12345,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
expect(resolved.sizeX).toBeGreaterThanOrEqual(48)
|
|
expect(resolved.sizeX).toBeLessThanOrEqual(64)
|
|
expect(resolved.sizeY).toBeGreaterThanOrEqual(160)
|
|
expect(resolved.sizeY).toBeLessThanOrEqual(192)
|
|
expect(resolved.sizeY).toBeGreaterThan(resolved.sizeX)
|
|
expect(resolved.sizeY / resolved.sizeX).toBeGreaterThanOrEqual(2.5)
|
|
expect(resolved.source).toBe("LvlPrest Barricade corridor dynamic extent")
|
|
}
|
|
})
|
|
|
|
test("resolveUnsetSize throws UnresolvedLevelSizeError when pieces are absent (anti-silent failure)", () => {
|
|
expect(() => {
|
|
resolveUnsetSize({
|
|
levelId: 111,
|
|
levelName: "Act 5 - Barricade 1",
|
|
levelTypeName: "Act 5 - Barricade",
|
|
sizeX: -1,
|
|
sizeY: -1,
|
|
subType: 10,
|
|
subTheme: 0,
|
|
seed: 12345,
|
|
pieces: [],
|
|
substitutions: [],
|
|
})
|
|
}).toThrow(UnresolvedLevelSizeError)
|
|
})
|
|
|
|
test("resolveUnsetSize throws UnresolvedLevelSizeError when non-barricade level lacks barricade pieces", () => {
|
|
const mockDs1 = makeMockDs1(8, 8, false)
|
|
expect(() => {
|
|
resolveUnsetSize({
|
|
levelId: 2,
|
|
levelName: "Blood Moor",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: -1,
|
|
sizeY: -1,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 12345,
|
|
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [mockDs1] }],
|
|
substitutions: [],
|
|
})
|
|
}).toThrow(UnresolvedLevelSizeError)
|
|
})
|
|
|
|
test("resolveUnsetSize respects sizeOverride when provided", () => {
|
|
const resolved = resolveUnsetSize({
|
|
levelId: 111,
|
|
levelName: "Act 5 - Barricade 1",
|
|
levelTypeName: "Act 5 - Barricade",
|
|
sizeX: -1,
|
|
sizeY: -1,
|
|
sizeOverride: { x: 80, y: 120 },
|
|
subType: 10,
|
|
subTheme: 0,
|
|
seed: 12345,
|
|
pieces: [],
|
|
substitutions: [],
|
|
})
|
|
expect(resolved.sizeX).toBe(80)
|
|
expect(resolved.sizeY).toBe(120)
|
|
expect(resolved.source).toBe("sizeOverride")
|
|
})
|
|
})
|