diablo2-web/tests/world-graph.test.ts

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)
})
})