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>[], ): 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 = { 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() 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>() 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() 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) }) })