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

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