635 lines
22 KiB
TypeScript
635 lines
22 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import type { D2Table } from '../src/game/acts.ts'
|
|
import { ACT_TOWNS, townLevelForAct } from '../src/game/acts.ts'
|
|
import { actOfLevel } from '../src/game/wilderness.ts'
|
|
import {
|
|
parseLevelRows,
|
|
buildWorldGraph,
|
|
assignGateSides,
|
|
seamlessPairs,
|
|
reachableFrom,
|
|
edgesFrom,
|
|
oppositeSide,
|
|
parseWarpGeometry,
|
|
findWarpGeometry,
|
|
isClickableWarp,
|
|
SIDES,
|
|
SEAMLESS_ADJACENCY,
|
|
DROPPED_VIS_EDGES,
|
|
resolveWorldOffsets,
|
|
type WorldOffset,
|
|
} from '../src/game/world-graph.ts'
|
|
import type { Side } from '../src/game/world-graph.ts'
|
|
import {
|
|
seamArrivalSpot,
|
|
SEAM_TRIGGER_SUBTILES,
|
|
type LinkGrid,
|
|
} from '../src/game/level-links.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)
|
|
})
|
|
})
|
|
|
|
describe('seamArrivalSpot', () => {
|
|
function makeGrid(width: number, height: number, blockedIndices: Set<number> = new Set()): LinkGrid {
|
|
const blocked = new Uint8Array(width * height)
|
|
for (const idx of blockedIndices) {
|
|
blocked[idx] = 1
|
|
}
|
|
return {
|
|
cellsX: Math.ceil(width / 5),
|
|
cellsY: Math.ceil(height / 5),
|
|
gridWidth: width,
|
|
gridHeight: height,
|
|
blocked,
|
|
}
|
|
}
|
|
|
|
it('places arrival strictly outside trigger box on all 4 sides', () => {
|
|
const grid = makeGrid(50, 50)
|
|
const sides: Side[] = ['north', 'south', 'east', 'west']
|
|
const x = 25
|
|
const y = 25
|
|
|
|
for (const side of sides) {
|
|
const spot = seamArrivalSpot(grid, x, y, side)
|
|
const insideTriggerBox =
|
|
Math.abs(spot.x - x) <= SEAM_TRIGGER_SUBTILES &&
|
|
Math.abs(spot.y - y) <= SEAM_TRIGGER_SUBTILES
|
|
expect(insideTriggerBox).toBe(false)
|
|
expect(spot).not.toEqual({ x, y })
|
|
}
|
|
})
|
|
|
|
it('never bounces back to trigger tile (x, y) even when completely blocked', () => {
|
|
// A grid where every single tile is impassable
|
|
const blockedSet = new Set<number>()
|
|
for (let i = 0; i < 50 * 50; i++) blockedSet.add(i)
|
|
const grid = makeGrid(50, 50, blockedSet)
|
|
|
|
const spot = seamArrivalSpot(grid, 20, 0, 'north')
|
|
// Must never fall back to trigger tile (20, 0)
|
|
expect(spot).not.toEqual({ x: 20, y: 0 })
|
|
const insideTriggerBox =
|
|
Math.abs(spot.x - 20) <= SEAM_TRIGGER_SUBTILES &&
|
|
Math.abs(spot.y - 0) <= SEAM_TRIGGER_SUBTILES
|
|
expect(insideTriggerBox).toBe(false)
|
|
})
|
|
|
|
it('ignores walkable tiles inside trigger radius and finds the nearest valid tile outside', () => {
|
|
// Only the trigger tile itself and its immediate neighbors (radius 2) are walkable
|
|
const blockedSet = new Set<number>()
|
|
for (let i = 0; i < 50 * 50; i++) blockedSet.add(i)
|
|
// Clear trigger tile and one tile far inward
|
|
blockedSet.delete(0 * 50 + 20) // trigger (20, 0)
|
|
blockedSet.delete(1 * 50 + 20) // (20, 1) inside trigger box
|
|
blockedSet.delete(10 * 50 + 20) // (20, 10) valid outside trigger box
|
|
|
|
const grid = makeGrid(50, 50, blockedSet)
|
|
const spot = seamArrivalSpot(grid, 20, 0, 'north')
|
|
|
|
expect(spot).toEqual({ x: 20, y: 10 })
|
|
expect(Math.abs(spot.y - 0)).toBeGreaterThan(SEAM_TRIGGER_SUBTILES)
|
|
})
|
|
})
|
|
|
|
describe('ACT_TOWNS and townLevelForAct single source of truth', () => {
|
|
it('defines the correct canonical town level IDs for Acts 1..5', () => {
|
|
expect(ACT_TOWNS).toEqual([1, 40, 75, 103, 109])
|
|
})
|
|
|
|
it('resolves townLevelForAct for all 5 acts correctly', () => {
|
|
expect(townLevelForAct(1)).toBe(1)
|
|
expect(townLevelForAct(2)).toBe(40)
|
|
expect(townLevelForAct(3)).toBe(75)
|
|
expect(townLevelForAct(4)).toBe(103)
|
|
expect(townLevelForAct(5)).toBe(109)
|
|
})
|
|
|
|
it('handles boundary / out-of-range acts gracefully', () => {
|
|
expect(townLevelForAct(0)).toBe(1)
|
|
expect(townLevelForAct(-5)).toBe(1)
|
|
expect(townLevelForAct(6)).toBe(109)
|
|
expect(townLevelForAct(99)).toBe(109)
|
|
})
|
|
|
|
it('actOfLevel correctly partitions level IDs matching ACT_TOWNS', () => {
|
|
expect(actOfLevel(1)).toBe(1)
|
|
expect(actOfLevel(39)).toBe(1)
|
|
expect(actOfLevel(40)).toBe(2)
|
|
expect(actOfLevel(74)).toBe(2)
|
|
expect(actOfLevel(75)).toBe(3)
|
|
expect(actOfLevel(102)).toBe(3)
|
|
expect(actOfLevel(103)).toBe(4)
|
|
expect(actOfLevel(108)).toBe(4)
|
|
expect(actOfLevel(109)).toBe(5)
|
|
expect(actOfLevel(136)).toBe(5)
|
|
})
|
|
})
|
|
|
|
describe('resolveWorldOffsets', () => {
|
|
function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean {
|
|
return (
|
|
a.offsetX < b.offsetX + b.sizeX &&
|
|
a.offsetX + a.sizeX > b.offsetX &&
|
|
a.offsetY < b.offsetY + b.sizeY &&
|
|
a.offsetY + a.sizeY > b.offsetY
|
|
)
|
|
}
|
|
|
|
it('generates valid non-overlapping coordinates along SEAMLESS_ADJACENCY for Acts 1..5', () => {
|
|
for (const act of [1, 2, 3, 4, 5]) {
|
|
const offsets = resolveWorldOffsets(act, 42)
|
|
expect(offsets.size).toBeGreaterThan(0)
|
|
|
|
// Collect all seamless levels in this act
|
|
const seamlessInAct = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
|
|
const placedSeamlessIds = Array.from(new Set(seamlessInAct.flat()))
|
|
|
|
// Verify all coordinates are positive and non-empty
|
|
for (const id of placedSeamlessIds) {
|
|
const offset = offsets.get(id)!
|
|
expect(offset.offsetX).toBeGreaterThan(0)
|
|
expect(offset.offsetY).toBeGreaterThan(0)
|
|
expect(offset.sizeX).toBeGreaterThan(0)
|
|
expect(offset.sizeY).toBeGreaterThan(0)
|
|
}
|
|
|
|
// Verify no two placed seamless levels have overlapping interiors
|
|
for (let i = 0; i < placedSeamlessIds.length; i++) {
|
|
for (let j = i + 1; j < placedSeamlessIds.length; j++) {
|
|
const a = offsets.get(placedSeamlessIds[i]!)!
|
|
const b = offsets.get(placedSeamlessIds[j]!)!
|
|
expect(doRectanglesOverlap(a, b)).toBe(false)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('aligns Act 5 Levels 110, 111, 112 West edge to 702 matching ka.ma0 Layer 79 automap cluster', () => {
|
|
const offsets = resolveWorldOffsets(5, 42)
|
|
|
|
const l109 = offsets.get(109)!
|
|
const l110 = offsets.get(110)!
|
|
const l111 = offsets.get(111)!
|
|
const l112 = offsets.get(112)!
|
|
|
|
expect(l109).toBeDefined()
|
|
expect(l110).toBeDefined()
|
|
expect(l111).toBeDefined()
|
|
expect(l112).toBeDefined()
|
|
|
|
// Harrogath at (1000, 1000)
|
|
expect(l109.offsetX).toBe(1000)
|
|
expect(l109.offsetY).toBe(1000)
|
|
|
|
// Bloody Foothills at (760, 1000), width 240, ending at X=1000 touching Harrogath
|
|
expect(l110.offsetX).toBe(760)
|
|
expect(l110.offsetY).toBe(1000)
|
|
expect(l110.offsetX + l110.sizeX).toBe(1000)
|
|
|
|
// Level 110 West gate connects West to Level 111
|
|
// With Level 111 derived width 58: offset X = 760 - 58 = 702
|
|
expect(l111.sizeX).toBe(58)
|
|
expect(l111.offsetX).toBe(702)
|
|
expect(l111.offsetX + l111.sizeX).toBe(760) // Zero gap to Level 110
|
|
|
|
// Level 111 connects North to Level 112
|
|
// Both 111 and 112 have West edge aligned to 702
|
|
expect(l112.sizeX).toBe(58)
|
|
expect(l112.offsetX).toBe(702)
|
|
|
|
// Combined bounding box spans X in [702..759] and Y in [717..1026] down to the single tile
|
|
const minX = Math.min(l111.offsetX, l112.offsetX)
|
|
const maxX = Math.max(l111.offsetX + l111.sizeX - 1, l112.offsetX + l112.sizeX - 1)
|
|
const minY = Math.min(l111.offsetY, l112.offsetY)
|
|
const maxY = Math.max(l111.offsetY + l111.sizeY, l112.offsetY + l112.sizeY)
|
|
|
|
expect(minX).toBe(702)
|
|
expect(maxX).toBe(759)
|
|
expect(minY).toBe(717)
|
|
expect(maxY).toBe(1026)
|
|
|
|
// Verify 111 and 112 touch seamlessly without overlap
|
|
expect(l112.offsetY + l112.sizeY).toBe(l111.offsetY)
|
|
expect(doRectanglesOverlap(l111, l112)).toBe(false)
|
|
expect(doRectanglesOverlap(l110, l111)).toBe(false)
|
|
expect(doRectanglesOverlap(l109, l110)).toBe(false)
|
|
})
|
|
|
|
it('preserves static offsets from Levels.txt for isolated levels across acts', () => {
|
|
// Act 1 isolated cave and crypt
|
|
const act1 = resolveWorldOffsets(1, 42)
|
|
expect(act1.get(8)?.offsetX).toBe(1500)
|
|
expect(act1.get(8)?.offsetY).toBe(1000)
|
|
expect(act1.get(18)?.offsetX).toBe(2000)
|
|
expect(act1.get(18)?.offsetY).toBe(1000)
|
|
|
|
// Act 2 isolated Canyon of the Magi
|
|
const act2 = resolveWorldOffsets(2, 42)
|
|
expect(act2.get(46)?.offsetX).toBe(2500)
|
|
expect(act2.get(46)?.offsetY).toBe(1000)
|
|
|
|
// Act 4 isolated Chaos Sanctuary
|
|
const act4 = resolveWorldOffsets(4, 42)
|
|
expect(act4.get(108)?.offsetX).toBe(1500)
|
|
expect(act4.get(108)?.offsetY).toBe(1000)
|
|
|
|
// Act 5 isolated Frozen Tundra
|
|
const act5 = resolveWorldOffsets(5, 42)
|
|
expect(act5.get(117)?.offsetX).toBe(2000)
|
|
expect(act5.get(117)?.offsetY).toBe(1896)
|
|
})
|
|
|
|
it('correctly resolves dependent levels relative to isolated parent levels (Level 33 on 32)', () => {
|
|
const act1 = resolveWorldOffsets(1, 42)
|
|
const l32 = act1.get(32)!
|
|
const l33 = act1.get(33)!
|
|
expect(l32).toBeDefined()
|
|
expect(l33).toBeDefined()
|
|
expect(l32.offsetX).toBe(4000)
|
|
expect(l32.offsetY).toBe(1000)
|
|
expect(l33.offsetX).toBe(3996) // 4000 + (-4)
|
|
expect(l33.offsetY).toBe(966) // 1000 + (-34)
|
|
expect(l33.offsetY + l33.sizeY).toBe(l32.offsetY) // North boundary sharing
|
|
expect(doRectanglesOverlap(l32, l33)).toBe(false)
|
|
})
|
|
|
|
it('supports caller-supplied custom WorldGraph', () => {
|
|
const custom = buildWorldGraph(
|
|
parseLevelRows(levelsTable([
|
|
{ Id: 109, Act: 4, OffsetX: 1000, OffsetY: 1000, SizeX: 40, SizeY: 40 },
|
|
{ Id: 110, Act: 4, OffsetX: 760, OffsetY: 1000, SizeX: 240, SizeY: 48 },
|
|
{ Id: 111, Act: 4, OffsetX: -1, OffsetY: -1, SizeX: -1, SizeY: -1 },
|
|
{ Id: 112, Act: 4, OffsetX: -1, OffsetY: -1, SizeX: -1, SizeY: -1 },
|
|
]))
|
|
)
|
|
const offsets = resolveWorldOffsets(5, 123, custom)
|
|
expect(offsets.get(110)?.offsetX).toBe(760)
|
|
expect(offsets.get(111)?.offsetX).toBe(702)
|
|
expect(offsets.get(112)?.offsetX).toBe(702)
|
|
})
|
|
|
|
it('replays identical offsets deterministically for the same seed', () => {
|
|
const run1 = resolveWorldOffsets(1, 9999)
|
|
const run2 = resolveWorldOffsets(1, 9999)
|
|
expect(Array.from(run1.entries())).toEqual(Array.from(run2.entries()))
|
|
})
|
|
})
|