529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
|
|
import {
|
|
classifyMazePieceName,
|
|
generateMaze,
|
|
SUB_TILES_PER_TILE,
|
|
} from '../src/game/maze.ts'
|
|
import type { MazePiece, MazeRequest } from '../src/game/maze.ts'
|
|
|
|
function createMockDs1(width = 8, height = 8, objects: Ds1Object[] = []): Ds1 {
|
|
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: [{ 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,
|
|
}
|
|
}
|
|
|
|
function createPiece(
|
|
name: string,
|
|
levelTypeName: string,
|
|
ds1Objects: Ds1Object[] = [],
|
|
width = 8,
|
|
height = 8,
|
|
): MazePiece {
|
|
const classified = classifyMazePieceName(name, levelTypeName)
|
|
if (!classified) throw new Error(`Could not classify ${name} for ${levelTypeName}`)
|
|
return {
|
|
name,
|
|
kind: classified.kind,
|
|
sides: classified.sides,
|
|
levels: [createMockDs1(width, height, ds1Objects)],
|
|
}
|
|
}
|
|
|
|
function createCatacombsPieces(wpObjects: Ds1Object[] = []): MazePiece[] {
|
|
const lt = 'Act 1 - Catacombs'
|
|
const sided = [
|
|
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
|
]
|
|
const pieces: MazePiece[] = []
|
|
for (const s of sided) {
|
|
pieces.push(createPiece(`${lt} ${s}`, lt))
|
|
}
|
|
pieces.push(createPiece(`${lt} Prev EW`, lt))
|
|
pieces.push(createPiece(`${lt} Prev NS`, lt))
|
|
for (const s of ['N', 'E', 'S', 'W']) {
|
|
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
function createJailPieces(wpObjects: Ds1Object[] = []): MazePiece[] {
|
|
const lt = 'Act 1 - Jail'
|
|
const sided = [
|
|
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
|
]
|
|
const pieces: MazePiece[] = []
|
|
for (const s of sided) {
|
|
pieces.push(createPiece(`${lt} ${s}`, lt))
|
|
}
|
|
for (const s of ['N', 'E', 'S', 'W']) {
|
|
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
function createKurastPieces(): MazePiece[] {
|
|
const lt = 'Act 3 - Kurast'
|
|
const sided = [
|
|
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
|
]
|
|
const pieces: MazePiece[] = []
|
|
for (const s of sided) {
|
|
pieces.push(createPiece(`${lt} ${s}`, lt))
|
|
}
|
|
for (const s of ['N', 'E', 'S', 'W']) {
|
|
pieces.push(createPiece(`Act 3 - Mephisto Prev ${s}`, lt))
|
|
pieces.push(createPiece(`Act 3 - Mephisto Next ${s}`, lt))
|
|
pieces.push(createPiece(`Act 3 - Mephisto Waypoint ${s}`, lt))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
function createBaalPieces(): MazePiece[] {
|
|
const lt = 'Act 5 - Baal'
|
|
const sided = [
|
|
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
|
]
|
|
const pieces: MazePiece[] = []
|
|
for (const s of sided) {
|
|
pieces.push(createPiece(`${lt} ${s}`, lt))
|
|
}
|
|
// prevChain requires Prev NSE, SEW, NSW, NEW
|
|
for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) {
|
|
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
|
}
|
|
for (const s of ['N', 'E', 'S', 'W']) {
|
|
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
function createSewerPieces(): MazePiece[] {
|
|
const lt = 'Act 2 - Sewer'
|
|
const sided = [
|
|
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
|
]
|
|
const pieces: MazePiece[] = []
|
|
for (const s of sided) {
|
|
pieces.push(createPiece(`${lt} ${s}`, lt))
|
|
}
|
|
for (const s of ['N', 'E', 'S', 'W']) {
|
|
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
function createTombPieces(): MazePiece[] {
|
|
const lt = 'Act 2 - Tomb'
|
|
const sided = [
|
|
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
|
|
]
|
|
const pieces: MazePiece[] = []
|
|
for (const s of sided) {
|
|
pieces.push(createPiece(`${lt} ${s}`, lt))
|
|
}
|
|
for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) {
|
|
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
|
|
}
|
|
for (const s of ['N', 'E', 'S', 'W']) {
|
|
pieces.push(createPiece(`${lt} Next ${s}`, lt))
|
|
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
|
|
}
|
|
return pieces
|
|
}
|
|
|
|
describe('Issue #47: Maze Waypoint preset replacement pass', () => {
|
|
describe('classifyMazePieceName', () => {
|
|
test('classifies waypoint pieces for all acts correctly', () => {
|
|
expect(classifyMazePieceName('Act 1 - Jail Waypoint W', 'Act 1 - Jail')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'W',
|
|
})
|
|
expect(classifyMazePieceName('Act 1 - Catacombs Waypoint N', 'Act 1 - Catacombs')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'N',
|
|
})
|
|
expect(classifyMazePieceName('Act 2 - Sewer Waypoint S', 'Act 2 - Sewer')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'S',
|
|
})
|
|
expect(classifyMazePieceName('Act 2 - Tomb Waypoint E', 'Act 2 - Tomb')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'E',
|
|
})
|
|
expect(classifyMazePieceName('Act 3 - Mephisto Waypoint W', 'Act 3 - Kurast')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'W',
|
|
})
|
|
expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'N',
|
|
})
|
|
expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'NE',
|
|
})
|
|
expect(classifyMazePieceName('Act 5 - Temple SW Waypoint', 'Act 5 - Temple')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'SW',
|
|
})
|
|
expect(classifyMazePieceName('Act 5 - Baal Waypoint N', 'Act 5 - Baal')).toEqual({
|
|
kind: 'waypoint',
|
|
sides: 'N',
|
|
})
|
|
})
|
|
|
|
test('preserves classification of other piece kinds', () => {
|
|
expect(classifyMazePieceName('Act 1 - Jail Prev W', 'Act 1 - Jail')).toEqual({
|
|
kind: 'prev',
|
|
sides: 'W',
|
|
})
|
|
expect(classifyMazePieceName('Act 1 - Catacombs Next S', 'Act 1 - Catacombs')).toEqual({
|
|
kind: 'next',
|
|
sides: 'S',
|
|
})
|
|
expect(classifyMazePieceName('Act 1 - Cave NSEW', 'Act 1 - Cave')).toEqual({
|
|
kind: 'room',
|
|
sides: 'NSEW',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('generateMaze waypoint placement', () => {
|
|
test('places waypoint room in Catacombs 2 (level 35) and records stats', () => {
|
|
const pieces = createCatacombsPieces()
|
|
const req: MazeRequest = {
|
|
levelId: 35,
|
|
levelName: 'Catacombs Level 2',
|
|
levelTypeName: 'Act 1 - Catacombs',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 42,
|
|
pieces,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
const waypoints = result.stats.waypoints as unknown[]
|
|
expect(waypoints).toHaveLength(1)
|
|
|
|
const wp = result.stats.waypoint as {
|
|
room: number
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
centreX: number
|
|
centreY: number
|
|
entityX: number
|
|
entityY: number
|
|
}
|
|
expect(wp).not.toBeNull()
|
|
expect(result.stats.waypointTile).toEqual({
|
|
x: Math.floor(wp.entityX / SUB_TILES_PER_TILE),
|
|
y: Math.floor(wp.entityY / SUB_TILES_PER_TILE),
|
|
})
|
|
|
|
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
|
const wpSpecial = specials.find(s => s.kind === 'waypoint')
|
|
expect(wpSpecial).toBeDefined()
|
|
expect(wpSpecial?.room).toBe(wp.room)
|
|
|
|
// Verify synthesized waypoint entity is present in result.level.objects
|
|
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 119)
|
|
expect(wpObj).toBeDefined()
|
|
expect(wpObj?.x).toBe(wp.entityX)
|
|
expect(wpObj?.y).toBe(wp.entityY)
|
|
})
|
|
|
|
test('does NOT place waypoint room in Catacombs 1 (level 34)', () => {
|
|
const pieces = createCatacombsPieces()
|
|
const req: MazeRequest = {
|
|
levelId: 34,
|
|
levelName: 'Catacombs Level 1',
|
|
levelTypeName: 'Act 1 - Catacombs',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 42,
|
|
pieces,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
expect(result.stats.waypoints).toEqual([])
|
|
expect(result.stats.waypoint).toBeNull()
|
|
expect(result.stats.waypointTile).toBeNull()
|
|
|
|
const specials = result.stats.specialsApplied as { kind: string }[]
|
|
expect(specials.some(s => s.kind === 'waypoint')).toBe(false)
|
|
})
|
|
|
|
test('places waypoint room in Jail 1 (level 29)', () => {
|
|
const pieces = createJailPieces()
|
|
const req: MazeRequest = {
|
|
levelId: 29,
|
|
levelName: 'Jail Level 1',
|
|
levelTypeName: 'Act 1 - Jail',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 101,
|
|
pieces,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
expect(result.stats.waypoints).toHaveLength(1)
|
|
expect(result.stats.waypoint).not.toBeNull()
|
|
})
|
|
|
|
test('honors hasWaypoint override: forces waypoint when true on level without default waypoint', () => {
|
|
const pieces = createCatacombsPieces()
|
|
const req: MazeRequest = {
|
|
levelId: 34,
|
|
levelName: 'Catacombs Level 1',
|
|
levelTypeName: 'Act 1 - Catacombs',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 42,
|
|
pieces,
|
|
hasWaypoint: true,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
expect(result.stats.waypoints).toHaveLength(1)
|
|
expect(result.stats.waypoint).not.toBeNull()
|
|
})
|
|
|
|
test('honors hasWaypoint override: disables waypoint when false on level with default waypoint', () => {
|
|
const pieces = createCatacombsPieces()
|
|
const req: MazeRequest = {
|
|
levelId: 35,
|
|
levelName: 'Catacombs Level 2',
|
|
levelTypeName: 'Act 1 - Catacombs',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 42,
|
|
pieces,
|
|
hasWaypoint: false,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
expect(result.stats.waypoints).toEqual([])
|
|
expect(result.stats.waypoint).toBeNull()
|
|
})
|
|
|
|
test('uses authored DS1 object entity coordinates when present', () => {
|
|
const authoredObj: Ds1Object = {
|
|
type: 2,
|
|
id: 119,
|
|
x: 18,
|
|
y: 22,
|
|
flags: 0,
|
|
}
|
|
const pieces = createCatacombsPieces([authoredObj])
|
|
const req: MazeRequest = {
|
|
levelId: 35,
|
|
levelName: 'Catacombs Level 2',
|
|
levelTypeName: 'Act 1 - Catacombs',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 88,
|
|
pieces,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
const wp = result.stats.waypoint as {
|
|
x: number
|
|
y: number
|
|
entityX: number
|
|
entityY: number
|
|
}
|
|
expect(wp.entityX).toBe(wp.x * SUB_TILES_PER_TILE + 18)
|
|
expect(wp.entityY).toBe(wp.y * SUB_TILES_PER_TILE + 22)
|
|
|
|
const objInLevel = result.level.objects.find(o => o.type === 2 && o.id === 119)
|
|
expect(objInLevel).toBeDefined()
|
|
expect(objInLevel?.x).toBe(wp.entityX)
|
|
expect(objInLevel?.y).toBe(wp.entityY)
|
|
})
|
|
|
|
test('places waypoint in Durance of Hate 2 (101) but not Durance 1 (100)', () => {
|
|
const pieces = createKurastPieces()
|
|
const req101: MazeRequest = {
|
|
levelId: 101,
|
|
levelName: 'Durance of Hate Level 2',
|
|
levelTypeName: 'Act 3 - Kurast',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 777,
|
|
pieces,
|
|
}
|
|
const res101 = generateMaze(req101)
|
|
expect(res101.stats.waypoints).toHaveLength(1)
|
|
expect(res101.stats.waypoint).not.toBeNull()
|
|
|
|
const req100: MazeRequest = {
|
|
levelId: 100,
|
|
levelName: 'Durance of Hate Level 1',
|
|
levelTypeName: 'Act 3 - Kurast',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 777,
|
|
pieces,
|
|
}
|
|
const res100 = generateMaze(req100)
|
|
expect(res100.stats.waypoints).toEqual([])
|
|
expect(res100.stats.waypoint).toBeNull()
|
|
})
|
|
|
|
test('places waypoint in Worldstone Keep 2 (129) but not Worldstone 1 (128)', () => {
|
|
const pieces = createBaalPieces()
|
|
const req129: MazeRequest = {
|
|
levelId: 129,
|
|
levelName: 'Worldstone Keep Level 2',
|
|
levelTypeName: 'Act 5 - Baal',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 999,
|
|
pieces,
|
|
}
|
|
const res129 = generateMaze(req129)
|
|
expect(res129.stats.waypoints).toHaveLength(1)
|
|
expect(res129.stats.waypoint).not.toBeNull()
|
|
|
|
const req128: MazeRequest = {
|
|
levelId: 128,
|
|
levelName: 'Worldstone Keep Level 1',
|
|
levelTypeName: 'Act 5 - Baal',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 999,
|
|
pieces,
|
|
}
|
|
const res128 = generateMaze(req128)
|
|
expect(res128.stats.waypoints).toEqual([])
|
|
expect(res128.stats.waypoint).toBeNull()
|
|
})
|
|
|
|
test('places waypoint in Lut Gholein Sewer 2 (48) but not Sewer 1 (47)', () => {
|
|
const pieces = createSewerPieces()
|
|
const req48: MazeRequest = {
|
|
levelId: 48,
|
|
levelName: 'Sewers Level 2',
|
|
levelTypeName: 'Act 2 - Sewer',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 333,
|
|
pieces,
|
|
}
|
|
const res48 = generateMaze(req48)
|
|
expect(res48.stats.waypoints).toHaveLength(1)
|
|
expect(res48.stats.waypoint).not.toBeNull()
|
|
|
|
const req47: MazeRequest = {
|
|
levelId: 47,
|
|
levelName: 'Sewers Level 1',
|
|
levelTypeName: 'Act 2 - Sewer',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 333,
|
|
pieces,
|
|
}
|
|
const res47 = generateMaze(req47)
|
|
expect(res47.stats.waypoints).toEqual([])
|
|
expect(res47.stats.waypoint).toBeNull()
|
|
})
|
|
|
|
test('places waypoint in Halls of the Dead 2 (57) but not Tomb 1 (56)', () => {
|
|
const pieces = createTombPieces()
|
|
const req57: MazeRequest = {
|
|
levelId: 57,
|
|
levelName: 'Halls of the Dead Level 2',
|
|
levelTypeName: 'Act 2 - Tomb',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 444,
|
|
pieces,
|
|
}
|
|
const res57 = generateMaze(req57)
|
|
expect(res57.stats.waypoints).toHaveLength(1)
|
|
expect(res57.stats.waypoint).not.toBeNull()
|
|
|
|
const req56: MazeRequest = {
|
|
levelId: 56,
|
|
levelName: 'Halls of the Dead Level 1',
|
|
levelTypeName: 'Act 2 - Tomb',
|
|
sectionSize: 8,
|
|
minRooms: 10,
|
|
merge: 100,
|
|
seed: 444,
|
|
pieces,
|
|
}
|
|
const res56 = generateMaze(req56)
|
|
expect(res56.stats.waypoints).toEqual([])
|
|
expect(res56.stats.waypoint).toBeNull()
|
|
})
|
|
|
|
test('places waypoint at a distance from the entrance (isExit distance sorting)', () => {
|
|
const pieces = createCatacombsPieces()
|
|
const req: MazeRequest = {
|
|
levelId: 35,
|
|
levelName: 'Catacombs Level 2',
|
|
levelTypeName: 'Act 1 - Catacombs',
|
|
sectionSize: 8,
|
|
minRooms: 12,
|
|
merge: 0,
|
|
seed: 12345,
|
|
pieces,
|
|
}
|
|
|
|
const result = generateMaze(req)
|
|
const wp = result.stats.waypoint as { room: number }
|
|
const specials = result.stats.specialsApplied as { room: number; kind: string }[]
|
|
const prevSpecial = specials.find(s => s.kind === 'prev')
|
|
if (prevSpecial) {
|
|
// Waypoint room should not be placed in the entrance room
|
|
expect(wp.room).not.toBe(prevSpecial.room)
|
|
}
|
|
})
|
|
})
|
|
})
|