379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import type { D2Table } from '../src/game/acts.ts'
|
|
import {
|
|
parseLevelRows,
|
|
buildWorldGraph,
|
|
assignGateSides,
|
|
seamlessPairs,
|
|
reachableFrom,
|
|
edgesFrom,
|
|
oppositeSide,
|
|
parseWarpGeometry,
|
|
findWarpGeometry,
|
|
isClickableWarp,
|
|
SIDES,
|
|
SEAMLESS_ADJACENCY,
|
|
DROPPED_VIS_EDGES,
|
|
} from '../src/game/world-graph.ts'
|
|
import type { Side } from '../src/game/world-graph.ts'
|
|
|
|
/**
|
|
* Build a `Levels.txt`-shaped table from sparse overrides.
|
|
*
|
|
* Hand-written rather than read out of the MPQs so these tests exercise the
|
|
* graph logic on cases the shipped data does not contain — an asymmetric edge,
|
|
* a slot that repeats, a level with no connections at all.
|
|
*/
|
|
function levelsTable(
|
|
rows: readonly Partial<Record<string, string | number>>[],
|
|
): D2Table {
|
|
const header = [
|
|
'Name',
|
|
'Id',
|
|
'Act',
|
|
'LevelType',
|
|
'DrlgType',
|
|
'SizeX',
|
|
'SizeY',
|
|
'OffsetX',
|
|
'OffsetY',
|
|
'Depend',
|
|
'Waypoint',
|
|
'Portal',
|
|
'Position',
|
|
...Array.from({ length: 8 }, (_unused, slot) => `Vis${String(slot)}`),
|
|
...Array.from({ length: 8 }, (_unused, slot) => `Warp${String(slot)}`),
|
|
]
|
|
const defaults: Record<string, string> = {
|
|
Name: '',
|
|
Id: '0',
|
|
Act: '0',
|
|
LevelType: '0',
|
|
DrlgType: '3',
|
|
SizeX: '80',
|
|
SizeY: '80',
|
|
OffsetX: '-1',
|
|
OffsetY: '-1',
|
|
Depend: '0',
|
|
Waypoint: '255',
|
|
Portal: '0',
|
|
Position: '0',
|
|
}
|
|
for (let slot = 0; slot < 8; slot += 1) {
|
|
defaults[`Vis${String(slot)}`] = '0'
|
|
defaults[`Warp${String(slot)}`] = '-1'
|
|
}
|
|
return {
|
|
header,
|
|
rows: rows.map(row =>
|
|
header.map(column => {
|
|
const value = row[column]
|
|
return value === undefined ? (defaults[column] ?? '') : String(value)
|
|
}),
|
|
),
|
|
}
|
|
}
|
|
|
|
describe('parseLevelRows', () => {
|
|
it('drops the Null placeholder row', () => {
|
|
const rows = parseLevelRows(levelsTable([{ Id: 0, Name: 'Null' }, { Id: 1, Name: 'Town' }]))
|
|
expect(rows.map(row => row.id)).toEqual([1])
|
|
})
|
|
|
|
it('reads all eight Vis and Warp slots', () => {
|
|
const rows = parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0, Vis6: 8, Warp6: 3 }]))
|
|
expect(rows[0]?.vis).toEqual([0, 0, 0, 8, 0, 0, 8, 0])
|
|
expect(rows[0]?.warp).toEqual([-1, -1, -1, 0, -1, -1, 3, -1])
|
|
})
|
|
|
|
it('defaults a missing waypoint to 255 rather than 0', () => {
|
|
// Zero is a real waypoint id (the Rogue Encampment), so a sloppy default
|
|
// would invent a second waypoint 0 on every level in the game.
|
|
const rows = parseLevelRows(levelsTable([{ Id: 5 }]))
|
|
expect(rows[0]?.waypoint).toBe(255)
|
|
})
|
|
})
|
|
|
|
describe('buildWorldGraph', () => {
|
|
it('collapses repeated slots into one edge that keeps every warp id', () => {
|
|
// A cave mouth occupies four slots, one per orientation.
|
|
const graph = buildWorldGraph(
|
|
parseLevelRows(
|
|
levelsTable([
|
|
{ Id: 2, Vis3: 8, Warp3: 0, Vis4: 8, Warp4: 1, Vis5: 8, Warp5: 2, Vis6: 8, Warp6: 3 },
|
|
{ Id: 8, Vis0: 2, Warp0: 4 },
|
|
]),
|
|
),
|
|
)
|
|
const out = edgesFrom(graph, 2)
|
|
expect(out).toHaveLength(1)
|
|
expect(out[0]?.to).toBe(8)
|
|
expect(out[0]?.warps).toEqual([0, 1, 2, 3])
|
|
})
|
|
|
|
it('classifies a Vis with no Warp tile as a seamless walk-through', () => {
|
|
// The monastery gate, the barracks gateway, the cathedral steps and the
|
|
// Chaos Sanctuary entrance are all openings inside a preset: no click, no
|
|
// loading screen.
|
|
const graph = buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 26, Vis1: 27 }, { Id: 27, Vis0: 26 }])),
|
|
)
|
|
expect(edgesFrom(graph, 26)[0]?.kind).toBe('seamless')
|
|
expect(edgesFrom(graph, 27)[0]?.kind).toBe('seamless')
|
|
})
|
|
|
|
it('classifies a Vis with a Warp tile as a warp', () => {
|
|
const graph = buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])),
|
|
)
|
|
expect(edgesFrom(graph, 2)[0]?.kind).toBe('warp')
|
|
})
|
|
|
|
it('ignores a Vis pointing at a level that does not exist', () => {
|
|
const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 2, Vis0: 999, Warp0: 4 }])))
|
|
expect(graph.edges).toHaveLength(0)
|
|
})
|
|
|
|
it('never emits an edge to or from the Null row', () => {
|
|
const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 0 }, { Id: 2, Vis0: 0 }])))
|
|
expect(graph.edges).toHaveLength(0)
|
|
})
|
|
|
|
it('collects waypoints and skips the 255 sentinel', () => {
|
|
const graph = buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 1, Waypoint: 0 }, { Id: 2 }, { Id: 3, Waypoint: 1 }])),
|
|
)
|
|
expect(graph.waypoints.get(0)).toBe(1)
|
|
expect(graph.waypoints.get(1)).toBe(3)
|
|
expect(graph.waypoints.size).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('the hard-coded adjacency table', () => {
|
|
it('supplies the connections Levels.txt omits', () => {
|
|
// Act 1 town really does have every Vis at 0 and every Warp at -1: built
|
|
// from the table alone, the player could never leave the Rogue Encampment.
|
|
const table = levelsTable([{ Id: 1, Name: 'Act 1 - Town' }, { Id: 2, Name: 'Act 1 - Wilderness 1' }])
|
|
const rows = parseLevelRows(table)
|
|
expect(rows.every(row => row.vis.every(value => value === 0))).toBe(true)
|
|
|
|
const graph = buildWorldGraph(rows)
|
|
expect(edgesFrom(graph, 1).map(edge => edge.to)).toEqual([2])
|
|
expect(edgesFrom(graph, 2).map(edge => edge.to)).toEqual([1])
|
|
expect(edgesFrom(graph, 1)[0]?.kind).toBe('seamless')
|
|
})
|
|
|
|
it('lists every pair only once and never as a self-loop', () => {
|
|
const seen = new Set<string>()
|
|
for (const [a, b] of SEAMLESS_ADJACENCY) {
|
|
const key = a < b ? `${String(a)}:${String(b)}` : `${String(b)}:${String(a)}`
|
|
expect(seen.has(key)).toBe(false)
|
|
expect(a).not.toBe(b)
|
|
seen.add(key)
|
|
}
|
|
})
|
|
|
|
it('outranks a Vis edge describing the same pair', () => {
|
|
const graph = buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 1, Vis0: 2, Warp0: 7 }, { Id: 2, Vis0: 1, Warp0: 7 }])),
|
|
)
|
|
expect(edgesFrom(graph, 1)[0]?.kind).toBe('seamless')
|
|
expect(edgesFrom(graph, 1)[0]?.source).toBe('adjacency')
|
|
})
|
|
})
|
|
|
|
describe('dropped Vis edges', () => {
|
|
it('discards the Pandemonium-to-Graveyard copy-paste artifact', () => {
|
|
// Row 133 is LevelType 4 — the Act 1 crypt type — and its whole Vis/Warp
|
|
// block is a duplicate of row 18. Level 17 does not point back.
|
|
expect(DROPPED_VIS_EDGES).toContainEqual([133, 17])
|
|
const graph = buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 133, Vis0: 17, Warp0: 8 }, { Id: 17 }])),
|
|
)
|
|
expect(graph.edges).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('oppositeSide', () => {
|
|
it('pairs the four sides', () => {
|
|
expect(oppositeSide('north')).toBe('south')
|
|
expect(oppositeSide('south')).toBe('north')
|
|
expect(oppositeSide('east')).toBe('west')
|
|
expect(oppositeSide('west')).toBe('east')
|
|
})
|
|
|
|
it('is an involution on every side', () => {
|
|
for (const side of SIDES) expect(oppositeSide(oppositeSide(side))).toBe(side)
|
|
})
|
|
})
|
|
|
|
describe('assignGateSides', () => {
|
|
/** A chain of five outdoor levels plus a branch, as Act 1 is shaped. */
|
|
const chain = buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }, { Id: 4 }, { Id: 5 }, { Id: 17 }])),
|
|
)
|
|
|
|
it('finds a side for every seam', () => {
|
|
const graph = assignGateSides(chain, 7)
|
|
for (const edge of graph.edges) {
|
|
if (edge.kind !== 'seamless') continue
|
|
expect(edge.sideFrom).not.toBeNull()
|
|
expect(edge.sideTo).not.toBeNull()
|
|
}
|
|
})
|
|
|
|
it('always puts the two ends of a seam on opposite sides', () => {
|
|
for (let seed = 0; seed < 50; seed += 1) {
|
|
const graph = assignGateSides(chain, seed)
|
|
for (const edge of graph.edges) {
|
|
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
|
|
expect(edge.sideTo).toBe(oppositeSide(edge.sideFrom))
|
|
}
|
|
}
|
|
})
|
|
|
|
it('never puts two of a level\u2019s seams on the same side', () => {
|
|
// The documented constraint: in the Cold Plains the Blood Moor entrance
|
|
// and the two exits may not share an edge.
|
|
for (let seed = 0; seed < 50; seed += 1) {
|
|
const graph = assignGateSides(chain, seed)
|
|
const used = new Map<number, Set<Side>>()
|
|
for (const edge of graph.edges) {
|
|
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
|
|
let sides = used.get(edge.from)
|
|
if (sides === undefined) {
|
|
sides = new Set<Side>()
|
|
used.set(edge.from, sides)
|
|
}
|
|
expect(sides.has(edge.sideFrom)).toBe(false)
|
|
sides.add(edge.sideFrom)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('replays the same layout for the same seed', () => {
|
|
const render = (seed: number): string =>
|
|
assignGateSides(chain, seed)
|
|
.edges.filter(edge => edge.kind === 'seamless')
|
|
.map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`)
|
|
.sort()
|
|
.join('|')
|
|
expect(render(4242)).toBe(render(4242))
|
|
})
|
|
|
|
it('produces different layouts for different seeds', () => {
|
|
// Side choice is per seed in the real game; pinning it would be stating a
|
|
// coincidence as a law.
|
|
const render = (seed: number): string =>
|
|
assignGateSides(chain, seed)
|
|
.edges.filter(edge => edge.kind === 'seamless')
|
|
.map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`)
|
|
.sort()
|
|
.join('|')
|
|
const layouts = new Set(Array.from({ length: 20 }, (_unused, seed) => render(seed)))
|
|
expect(layouts.size).toBeGreaterThan(1)
|
|
})
|
|
|
|
it('honours the preset-anchored pin between town and the Blood Moor', () => {
|
|
// The Rogue Encampment's gate is part of a hand-authored preset: it faces
|
|
// south and cannot be re-rolled.
|
|
for (let seed = 0; seed < 20; seed += 1) {
|
|
const graph = assignGateSides(chain, seed)
|
|
const gate = graph.edges.find(edge => edge.from === 1 && edge.to === 2)
|
|
expect(gate?.sideFrom).toBe('south')
|
|
expect(gate?.sideTo).toBe('north')
|
|
}
|
|
})
|
|
|
|
it('leaves warp edges without a side', () => {
|
|
const graph = assignGateSides(
|
|
buildWorldGraph(
|
|
parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])),
|
|
),
|
|
1,
|
|
)
|
|
const warp = graph.edges.find(edge => edge.kind === 'warp')
|
|
expect(warp?.sideFrom).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('seamlessPairs', () => {
|
|
it('returns each seam once, low id first, in a stable order', () => {
|
|
const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }])))
|
|
const pairs = seamlessPairs(graph)
|
|
expect(pairs).toEqual([
|
|
[1, 2],
|
|
[2, 3],
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('reachableFrom', () => {
|
|
it('walks the chain', () => {
|
|
const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }, { Id: 4 }])))
|
|
expect([...reachableFrom(graph, 1)].sort((a, b) => a - b)).toEqual([1, 2, 3, 4])
|
|
})
|
|
|
|
it('reports an island as unreachable', () => {
|
|
const graph = buildWorldGraph(parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 46 }])))
|
|
expect(reachableFrom(graph, 1).has(46)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('parseWarpGeometry', () => {
|
|
const lvlwarp: D2Table = {
|
|
header: [
|
|
'Name',
|
|
'Id',
|
|
'SelectX',
|
|
'SelectY',
|
|
'SelectDX',
|
|
'SelectDY',
|
|
'ExitWalkX',
|
|
'ExitWalkY',
|
|
'OffsetX',
|
|
'OffsetY',
|
|
'LitVersion',
|
|
'Tiles',
|
|
'Direction',
|
|
'Beta',
|
|
],
|
|
rows: [
|
|
['Act 1 Wilderness', '0', '-90', '-100', '90', '110', '5', '0', '1', '-1', '1', '2', 'b', '1'],
|
|
['Act 5 Barricade L', '71', '-50', '-110', '110', '150', '0', '5', '5', '1', '1', '4', 'l', '0'],
|
|
['Act 5 Barricade R', '71', '-40', '-80', '210', '110', '0', '5', '-1', '3', '1', '4', 'r', '0'],
|
|
['Walk in', '19', '0', '0', '0', '0', '0', '0', '0', '0', '0', '2', 'b', '1'],
|
|
['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', ''],
|
|
],
|
|
}
|
|
|
|
it('keys duplicate ids on their direction', () => {
|
|
// Ids 71, 73, 74, 81 and 82 each appear twice for the Act 5 barricades;
|
|
// keying on the id alone silently loses one of each pair.
|
|
const geometry = parseWarpGeometry(lvlwarp)
|
|
expect(findWarpGeometry(geometry, 71, 'l')?.selectDX).toBe(110)
|
|
expect(findWarpGeometry(geometry, 71, 'r')?.selectDX).toBe(210)
|
|
})
|
|
|
|
it('skips the Expansion separator row', () => {
|
|
expect(parseWarpGeometry(lvlwarp).size).toBe(4)
|
|
})
|
|
|
|
it('keeps a negative arrival offset', () => {
|
|
// Negative is deliberate: landing on the warp tile would re-trigger it.
|
|
expect(findWarpGeometry(parseWarpGeometry(lvlwarp), 0)?.offsetY).toBe(-1)
|
|
})
|
|
|
|
it('treats a zero-area hitbox as not clickable', () => {
|
|
const geometry = parseWarpGeometry(lvlwarp)
|
|
const walkIn = findWarpGeometry(geometry, 19)
|
|
const clickable = findWarpGeometry(geometry, 0)
|
|
expect(walkIn === undefined ? null : isClickableWarp(walkIn)).toBe(false)
|
|
expect(clickable === undefined ? null : isClickableWarp(clickable)).toBe(true)
|
|
})
|
|
|
|
it('falls back to any direction when none is asked for', () => {
|
|
expect(findWarpGeometry(parseWarpGeometry(lvlwarp), 71)?.id).toBe(71)
|
|
})
|
|
})
|