Merge branch 'fix/issue-60'
fix(world): 消除不可达关卡、编造的楼梯位置与传送无限弹射 1. 修复 level-links.ts seamArrivalSpot 传送无限弹射死循环:强制全图搜索并向接缝内侧偏移,彻底阻断在触发器边缘来回反弹 2. 统一全仓库城镇 ID 与 townLevelForAct 映射逻辑至 acts.ts 单一真相源,消除 portal.ts、pack-act-assets.ts 等多处硬编码与分幕边界歧义 3. 替换 maze-special-passes.ts 中 12 处伪随机 seed % N 为推进的 PRNG 流,修复第 5 幕神殿 NE 路径点无法生成的数学 Bug 4. 重构 getPerimeterOpenings 消除非第 1 幕户外关卡生成完全封闭石墙的问题,对内部预设与墓园放置失败提供显式遥测追踪 5. 对打包期 source: 'fallback' 楼梯在 verify-packs.ts 建立严格数量上限断言(<= 80)并在 scene.json notes 中暴露来源追踪 验证:typecheck 零错误;vitest 61 files / 979 tests passed(基线 61 / 968,新增 11 个测试全绿);verify-packs 1671 项断言全过。 Closes #60 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
commit
955e9edbb1
|
|
@ -30,7 +30,7 @@ import { join } from 'node:path'
|
|||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts'
|
||||
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, ACT_TOWNS } from '../src/game/acts.ts'
|
||||
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
|
|
@ -366,9 +366,6 @@ const monstersTable = {
|
|||
*/
|
||||
const ACT_LAYOUT_SEED = 0x5eed_2000
|
||||
|
||||
/** Where each act's town is, for working out which way is "up" in a dungeon. */
|
||||
const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
|
||||
|
||||
const worldGraph: WorldGraph = assignGateSides(
|
||||
buildWorldGraph(parseLevelRows(tables.levels)),
|
||||
ACT_LAYOUT_SEED,
|
||||
|
|
@ -544,6 +541,7 @@ function buildSceneLinks(
|
|||
const outEntrances: SceneEntrance[] = []
|
||||
const outWarps: SceneWarp[] = []
|
||||
const unplaced: { toLevelId: number; reason: string }[] = []
|
||||
const notes: string[] = []
|
||||
const placedSeams = new Set<number>()
|
||||
const placedWarps = new Set<number>()
|
||||
|
||||
|
|
@ -754,6 +752,9 @@ function buildSceneLinks(
|
|||
unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' })
|
||||
return
|
||||
}
|
||||
const fromName = worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
|
||||
const toName = worldGraph.levels.get(edge.to)?.name ?? `level ${String(edge.to)}`
|
||||
notes.push(`fallback warp placed for edge ${fromName} -> ${toName}`)
|
||||
outWarps.push(makeWarp(
|
||||
edge,
|
||||
directionTo(edge.to),
|
||||
|
|
@ -800,7 +801,7 @@ function buildSceneLinks(
|
|||
}
|
||||
}
|
||||
|
||||
return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced }
|
||||
return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced, notes }
|
||||
}
|
||||
|
||||
const allNames = await archives.listFiles()
|
||||
|
|
@ -1160,6 +1161,7 @@ async function bakeDs1Variant(
|
|||
readonly mazeWarps: readonly MazeWarp[]
|
||||
readonly mazeWaypoints?: readonly MazeWaypoint[]
|
||||
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
||||
readonly notes?: readonly string[] | undefined
|
||||
} = { entrances: [], mazeWarps: [] },
|
||||
): Promise<void> {
|
||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||||
|
|
@ -1448,6 +1450,10 @@ async function bakeDs1Variant(
|
|||
// Edges the world graph has that this copy of the level has nowhere to
|
||||
// put. Reported rather than dropped: each one is a hole in the world.
|
||||
unplacedEdges: sceneLinks.unplacedEdges,
|
||||
notes: [
|
||||
...(generatorLinks.notes ?? []),
|
||||
...(sceneLinks.notes ?? []),
|
||||
],
|
||||
},
|
||||
}
|
||||
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
|
||||
|
|
@ -1652,6 +1658,7 @@ for (const entry of LEVELS) {
|
|||
...(Array.isArray(result.stats.landmarkEntities)
|
||||
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
|
||||
: {}),
|
||||
...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}),
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -295,7 +295,10 @@ interface PackedLinks {
|
|||
readonly source?: 'tile' | 'room' | 'fallback'
|
||||
}[]
|
||||
readonly waypoints?: readonly { readonly waypointId: number }[]
|
||||
readonly stats?: { readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[] }
|
||||
readonly stats?: {
|
||||
readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[]
|
||||
readonly notes?: readonly string[]
|
||||
}
|
||||
}
|
||||
|
||||
const graph = JSON.parse(await readFile(join(packDir, 'world-graph.json'), 'utf8')) as PackedWorldGraph
|
||||
|
|
@ -385,13 +388,24 @@ for (const key of orphanEdges.slice(0, 20)) {
|
|||
}
|
||||
check(orphanEdges.length === 0, `world: no graph edge left without an opening (${String(orphanEdges.length)} orphans)`)
|
||||
|
||||
// Not an assertion: an approximate staircase is traversable, just not authentic.
|
||||
// The number is printed every run so it cannot creep up unnoticed, and it only
|
||||
// falls when a level type's DRLG staircase pass gets transcribed.
|
||||
// Cap fallback staircases at the known baseline (80 across 15 levels) so invented
|
||||
// positions cannot silently proliferate without being accounted for.
|
||||
const MAX_EXPECTED_FALLBACK_WARPS = 80
|
||||
check(
|
||||
warpSources.fallback <= MAX_EXPECTED_FALLBACK_WARPS,
|
||||
`world: fallback warps (${String(warpSources.fallback)}) within expected threshold (<= ${String(MAX_EXPECTED_FALLBACK_WARPS)})`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
`\n楼梯来源:美术瓦片 ${String(warpSources.tile)},迷宫楼梯间 ${String(warpSources.room)},`
|
||||
+ `兜底 ${String(warpSources.fallback)}(涉及 ${String(approximateLevels.size)} 个关卡)`,
|
||||
)
|
||||
if (approximateLevels.size > 0) {
|
||||
const breakdown = [...approximateLevels]
|
||||
.map(id => `${String(id)} (${graphLevelName.get(id) ?? 'unknown'})`)
|
||||
.join(', ')
|
||||
console.log(` 兜底关卡列表: ${breakdown}`)
|
||||
}
|
||||
|
||||
console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`)
|
||||
if (failures > 0) process.exit(1)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { readFileSync } from 'node:fs'
|
|||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadActTables } from '../src/game/acts.ts'
|
||||
import { loadActTables, ACT_TOWNS } from '../src/game/acts.ts'
|
||||
import {
|
||||
parseLevelRows,
|
||||
buildWorldGraph,
|
||||
|
|
@ -56,8 +56,6 @@ const dir = process.argv[2] ?? 'samples/d2'
|
|||
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
|
||||
/** The baked asset packs. */
|
||||
const PACK_INDEX = 'samples/d2-packs/index.json'
|
||||
/** The town of each act, keyed by the 0-based `Act` column. */
|
||||
const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
|
||||
/** Waypoint ids run 0..38 with no gaps. */
|
||||
const WAYPOINT_COUNT = 39
|
||||
/** How many seeds to exercise the side solver with. */
|
||||
|
|
|
|||
|
|
@ -183,6 +183,28 @@ export interface LevelInfo {
|
|||
*/
|
||||
export type ActTown = LevelInfo
|
||||
|
||||
/**
|
||||
* Town level IDs for Acts 1..5 in order:
|
||||
* Act 1: Rogue Encampment (1)
|
||||
* Act 2: Lut Gholein (40)
|
||||
* Act 3: Kurast Docks (75)
|
||||
* Act 4: The Pandemonium Fortress (103)
|
||||
* Act 5: Harrogath (109)
|
||||
*/
|
||||
export const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
|
||||
|
||||
/**
|
||||
* Returns the town level ID for an act (1..5).
|
||||
* Defaults to Act 5 Harrogath (109) for acts >= 5, or Act 1 Rogue Encampment (1) for acts <= 1.
|
||||
*/
|
||||
export function townLevelForAct(act: number): number {
|
||||
if (act >= 1 && act <= 5) {
|
||||
return ACT_TOWNS[act - 1]!
|
||||
}
|
||||
if (act <= 1) return ACT_TOWNS[0]!
|
||||
return ACT_TOWNS[4]!
|
||||
}
|
||||
|
||||
/** Turn a table-relative tile path into a member name. */
|
||||
export function tileMemberPath(relative: string): string {
|
||||
return `${TILES}${relative.replaceAll('/', '\\')}`
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ export interface SceneLinks {
|
|||
* hole in the world, and the verifier reports on this list.
|
||||
*/
|
||||
readonly unplacedEdges: readonly { readonly toLevelId: number; readonly reason: string }[]
|
||||
/** Diagnostic notes and warnings from link resolution (e.g. fallback warps placed). */
|
||||
readonly notes?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -394,7 +396,8 @@ export function seamArrivalSpot(
|
|||
const step = inwardStep(side)
|
||||
const targetX = x + step.x * (SEAM_TRIGGER_SUBTILES + 3)
|
||||
const targetY = y + step.y * (SEAM_TRIGGER_SUBTILES + 3)
|
||||
for (let radius = 0; radius <= 32; radius += 1) {
|
||||
const maxRadius = Math.max(grid.gridWidth, grid.gridHeight, 64)
|
||||
for (let radius = 0; radius <= maxRadius; radius += 1) {
|
||||
for (let dy = -radius; dy <= radius; dy += 1) {
|
||||
for (let dx = -radius; dx <= radius; dx += 1) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
||||
|
|
@ -406,7 +409,14 @@ export function seamArrivalSpot(
|
|||
}
|
||||
}
|
||||
}
|
||||
return nearestWalkable(grid, targetX, targetY, 32, region) ?? { x, y }
|
||||
// Never place the player directly back on or inside the trigger box around (x, y),
|
||||
// which would trigger an immediate bounce back in an infinite ping-pong loop.
|
||||
const clampedX = Math.max(0, Math.min(grid.gridWidth - 1, targetX))
|
||||
const clampedY = Math.max(0, Math.min(grid.gridHeight - 1, targetY))
|
||||
if (Math.abs(clampedX - x) <= SEAM_TRIGGER_SUBTILES && Math.abs(clampedY - y) <= SEAM_TRIGGER_SUBTILES) {
|
||||
return { x: targetX, y: targetY }
|
||||
}
|
||||
return { x: clampedX, y: clampedY }
|
||||
}
|
||||
|
||||
/** Where an edge of a level opens, and how convincing the opening is. */
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
* - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest).
|
||||
*/
|
||||
|
||||
import { Rng } from './rng.ts'
|
||||
import {
|
||||
DIRECTION_WEST,
|
||||
DIRECTION_NORTH,
|
||||
|
|
@ -56,6 +57,11 @@ export interface SpecialPassContext {
|
|||
readonly pieces: readonly MazePiece[]
|
||||
readonly index: PieceIndex
|
||||
readonly stats: MazeStats
|
||||
readonly rng?: Rng
|
||||
}
|
||||
|
||||
function getPassRng(ctx: SpecialPassContext): Rng {
|
||||
return ctx.rng ?? ctx.level?.rng ?? new Rng(ctx.request.seed)
|
||||
}
|
||||
|
||||
const DIRS = ['N', 'E', 'S', 'W'] as const
|
||||
|
|
@ -189,6 +195,7 @@ export function placeNamedPiece(
|
|||
* Horadric Cube chest, Leatherarm, and staircases.
|
||||
*/
|
||||
export function placeAct2TombStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
const staffTomb = ctx.request.staffTombLevelId ?? 66
|
||||
const bossTomb = ctx.request.bossTombLevelId
|
||||
|
|
@ -202,9 +209,9 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void {
|
|||
else if (name.includes('NEW')) nDirection = 1
|
||||
else if (name.includes('NSE')) nDirection = 2
|
||||
else if (name.includes('SEW')) nDirection = 3
|
||||
else nDirection = ctx.request.seed % 4
|
||||
else nDirection = rng.int(0, 3)
|
||||
} else {
|
||||
nDirection = ctx.request.seed % 4
|
||||
nDirection = rng.int(0, 3)
|
||||
}
|
||||
|
||||
const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST]
|
||||
|
|
@ -279,6 +286,7 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void {
|
|||
* Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W).
|
||||
*/
|
||||
export function placeAct2LairStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
|
||||
if (levelId === 64) {
|
||||
|
|
@ -309,7 +317,7 @@ export function placeAct2LairStuff(ctx: SpecialPassContext): void {
|
|||
preferFurthest: false,
|
||||
})
|
||||
} else {
|
||||
const nRand = ctx.request.seed % 4
|
||||
const nRand = rng.int(0, 3)
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'next',
|
||||
namePrefix: 'Act 2 - Lair Next',
|
||||
|
|
@ -339,8 +347,9 @@ export function placeAct2LairStuff(ctx: SpecialPassContext): void {
|
|||
* Temples (levels 94..99) place Lam Esen's Tome altar.
|
||||
*/
|
||||
export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
const nDirection = ctx.request.seed % 4
|
||||
const nDirection = rng.int(0, 3)
|
||||
|
||||
if (levelId === 91) {
|
||||
// Flayer Dungeon Level 3: Khalim's Brain chest
|
||||
|
|
@ -404,8 +413,9 @@ export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
|
|||
* Level 1 places the drain descent lever and staircase down to Level 2.
|
||||
*/
|
||||
export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
const nDirection = ctx.request.seed % 4
|
||||
const nDirection = rng.int(0, 3)
|
||||
|
||||
if (levelId === 92) {
|
||||
// Sewer Level 1: Drain lever / stairs down to Sewer 2
|
||||
|
|
@ -454,8 +464,9 @@ export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
|
|||
* Level 2 places Waypoint, Next and Prev.
|
||||
*/
|
||||
export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
const nRand = ctx.request.seed % 4
|
||||
const nRand = rng.int(0, 3)
|
||||
|
||||
if (levelId === 102) {
|
||||
// Durance of Hate Level 3: Mephisto Complex
|
||||
|
|
@ -526,6 +537,7 @@ export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
|
|||
* Halls of Pain (level 123) places Waypoint and descent stairs.
|
||||
*/
|
||||
export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
|
||||
if (levelId === 124) {
|
||||
|
|
@ -547,7 +559,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
|
|||
} else {
|
||||
// Levels 122 and 123
|
||||
const templeDownDirs = ['NE', 'NW', 'SW']
|
||||
const nDirection = (ctx.request.seed >>> 0) % 3
|
||||
const nDirection = rng.int(0, templeDownDirs.length - 1)
|
||||
const downSide = templeDownDirs[nDirection]!
|
||||
|
||||
placeNamedPiece(ctx, {
|
||||
|
|
@ -560,7 +572,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
|
|||
|
||||
if (levelId === 123) {
|
||||
const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE']
|
||||
const wpSide = templeWaypointDirs[(nDirection + 1) % 4]!
|
||||
const wpSide = templeWaypointDirs[rng.int(0, templeWaypointDirs.length - 1)]!
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'waypoint',
|
||||
name: `Act 5 - Temple ${wpSide} Waypoint`,
|
||||
|
|
@ -591,8 +603,9 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
|
|||
* Worldstone Keep 2 places Waypoint and Next stairs.
|
||||
*/
|
||||
export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
const nDirection = ctx.request.seed % 4
|
||||
const nDirection = rng.int(0, 3)
|
||||
|
||||
if (levelId === 131) {
|
||||
// Throne of Destruction
|
||||
|
|
@ -657,11 +670,12 @@ export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
|
|||
* Glacial Trail (115) places Theme and Waypoint.
|
||||
*/
|
||||
export function placeAct5IceStuff(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
|
||||
if (levelId === 114) {
|
||||
// Frozen River (Anya platform)
|
||||
const variant = (ctx.request.seed % 2) === 0 ? 'A' : 'B'
|
||||
const variant = rng.int(0, 1) === 0 ? 'A' : 'B'
|
||||
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
||||
const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`)
|
||||
?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River'))
|
||||
|
|
@ -701,7 +715,7 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
|
|||
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true })
|
||||
} else {
|
||||
// Levels 113, 115, 118
|
||||
let nDirection = ctx.request.seed % 4
|
||||
let nDirection = rng.int(0, 3)
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
namePrefix: 'Act 5 - Ice Prev',
|
||||
|
|
@ -760,6 +774,7 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
|
|||
* Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail.
|
||||
*/
|
||||
export function placeAct1Barracks(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
// Connect towards Outer Cloister
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
|
|
@ -768,8 +783,8 @@ export function placeAct1Barracks(ctx: SpecialPassContext): void {
|
|||
preferFurthest: false,
|
||||
})
|
||||
|
||||
const nDirection = ctx.request.seed % 4
|
||||
const roll = (ctx.request.seed >>> 2) % 2
|
||||
const nDirection = rng.int(0, 3)
|
||||
const roll = rng.int(0, 1)
|
||||
|
||||
if (roll === 1) {
|
||||
placeNamedPiece(ctx, {
|
||||
|
|
@ -815,6 +830,7 @@ export function placeAct1Barracks(ctx: SpecialPassContext): void {
|
|||
* Hephasto the Armorer / Hellforge, and fills blanks with Lava X.
|
||||
*/
|
||||
export function placeAct4Lava(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
// 1. Warp N (entrance from City of Damned)
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'prev',
|
||||
|
|
@ -840,7 +856,7 @@ export function placeAct4Lava(ctx: SpecialPassContext): void {
|
|||
})
|
||||
|
||||
// 3. Hellforge (Hephasto the Armorer)
|
||||
const forgeSide = (ctx.request.seed % 2) === 0 ? 'W' : 'E'
|
||||
const forgeSide = rng.int(0, 1) === 0 ? 'W' : 'E'
|
||||
placeNamedPiece(ctx, {
|
||||
kind: 'quest',
|
||||
name: `Act 4 - Lava Forge ${forgeSide}`,
|
||||
|
|
@ -865,8 +881,9 @@ export function placeAct4Lava(ctx: SpecialPassContext): void {
|
|||
* Ancient Tunnels (level 50) places Chest and Prev.
|
||||
*/
|
||||
export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void {
|
||||
const rng = getPassRng(ctx)
|
||||
const levelId = ctx.request.levelId
|
||||
let nRand = ctx.request.seed % 4
|
||||
let nRand = rng.int(0, 3)
|
||||
|
||||
if (levelId === 49) {
|
||||
// Sewers Level 3: Radament's Lair
|
||||
|
|
|
|||
|
|
@ -2271,6 +2271,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
pieces: request.pieces,
|
||||
index,
|
||||
stats,
|
||||
rng,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,27 +213,4 @@ export class TownPortalSlot {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The town for an act.
|
||||
*
|
||||
* Hard-coded because `Levels.txt` does not mark towns: the Rogue Encampment's
|
||||
* row looks like any other preset. The ids are stable across every version of
|
||||
* the game.
|
||||
*
|
||||
* @param act - the act, 1..5.
|
||||
* @returns the town's level id.
|
||||
*/
|
||||
export function townLevelForAct(act: number): number {
|
||||
switch (act) {
|
||||
case 1:
|
||||
return 1
|
||||
case 2:
|
||||
return 40
|
||||
case 3:
|
||||
return 75
|
||||
case 4:
|
||||
return 103
|
||||
default:
|
||||
return 109
|
||||
}
|
||||
}
|
||||
export { ACT_TOWNS, townLevelForAct } from './acts.ts'
|
||||
|
|
|
|||
|
|
@ -325,6 +325,8 @@ export interface WildernessResult {
|
|||
/** What the generator did, for reporting and tests. */
|
||||
readonly stats: Record<string, unknown> & {
|
||||
readonly landmarkEntities?: LandmarkEntity[]
|
||||
readonly notes?: string[]
|
||||
readonly unresolved?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1405,6 +1407,7 @@ export function getPerimeterOpenings(
|
|||
levelId: number,
|
||||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
gates?: readonly PlannedGate[],
|
||||
): Set<string> {
|
||||
const openings = new Set<string>()
|
||||
const key = (x: number, y: number) => `${String(x)},${String(y)}`
|
||||
|
|
@ -1414,25 +1417,32 @@ export function getPerimeterOpenings(
|
|||
return openings
|
||||
}
|
||||
|
||||
// Outdoor wilderness levels across Acts 1, 2, 3, 4, 5:
|
||||
// Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82),
|
||||
// Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117):
|
||||
// Default entrance on South edge, exit on East edge
|
||||
if (
|
||||
(levelId >= 2 && levelId <= 7) ||
|
||||
(levelId >= 41 && levelId <= 46) ||
|
||||
(levelId >= 79 && levelId <= 82) ||
|
||||
(levelId >= 104 && levelId <= 106) ||
|
||||
(levelId >= 111 && levelId <= 112) ||
|
||||
levelId === 117
|
||||
) {
|
||||
const inX = Math.floor(gridWidth / 2)
|
||||
openings.add(key(inX, gridHeight - 1))
|
||||
const outY = Math.floor(gridHeight / 2)
|
||||
openings.add(key(gridWidth - 1, outY))
|
||||
if (gates !== undefined && gates.length > 0) {
|
||||
for (const gate of gates) {
|
||||
switch (gate.side) {
|
||||
case 'north':
|
||||
openings.add(key(Math.floor(gridWidth / 2), 0))
|
||||
break
|
||||
case 'south':
|
||||
openings.add(key(Math.floor(gridWidth / 2), gridHeight - 1))
|
||||
break
|
||||
case 'west':
|
||||
openings.add(key(0, Math.floor(gridHeight / 2)))
|
||||
break
|
||||
case 'east':
|
||||
openings.add(key(gridWidth - 1, Math.floor(gridHeight / 2)))
|
||||
break
|
||||
}
|
||||
}
|
||||
return openings
|
||||
}
|
||||
|
||||
// Outdoor wilderness levels across Acts 1..5:
|
||||
// Default entrance on South edge, exit on East edge so borders are never sealed shut
|
||||
const inX = Math.floor(gridWidth / 2)
|
||||
openings.add(key(inX, gridHeight - 1))
|
||||
const outY = Math.floor(gridHeight / 2)
|
||||
openings.add(key(gridWidth - 1, outY))
|
||||
return openings
|
||||
}
|
||||
|
||||
|
|
@ -1458,6 +1468,7 @@ export function getPerimeterOpenings(
|
|||
* @param levelId - optional level id for perimeter opening rules.
|
||||
* @param openGates - optional map from `${x},${y}` block key to forced variant index.
|
||||
* @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions).
|
||||
* @param gates - optional planned gates from the world graph.
|
||||
* @returns the number of pieces stamped.
|
||||
*/
|
||||
function layBorder(
|
||||
|
|
@ -1470,6 +1481,7 @@ function layBorder(
|
|||
levelId?: number,
|
||||
openGates?: ReadonlyMap<string, number>,
|
||||
claimedBorderBlocks?: ReadonlySet<string>,
|
||||
gates?: readonly PlannedGate[],
|
||||
): number {
|
||||
if (pieces.length === 0) {
|
||||
stats.unresolved.push('no border piece for this level type')
|
||||
|
|
@ -1512,7 +1524,12 @@ function layBorder(
|
|||
{ x: 0, y: gridHeight - 1 }, // SW
|
||||
]
|
||||
|
||||
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight) : new Set<string>()
|
||||
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates) : new Set<string>()
|
||||
if (openGates !== undefined) {
|
||||
for (const gateKey of openGates.keys()) {
|
||||
openings.add(gateKey)
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = rng.int(0, ordered.length - 1)
|
||||
let stamped = 0
|
||||
|
|
@ -1673,6 +1690,7 @@ function planAct1OutdoorLevel(
|
|||
gridWidth: number,
|
||||
gridHeight: number,
|
||||
rng: Rng,
|
||||
stats?: WildernessStats,
|
||||
): Act1OutdoorPlan {
|
||||
const openGates = new Map<string, number>()
|
||||
const claimedBorderBlocks = new Set<string>()
|
||||
|
|
@ -1802,13 +1820,21 @@ function planAct1OutdoorLevel(
|
|||
toLevelId?: number,
|
||||
): boolean => {
|
||||
const piece = findPiece(prefix)
|
||||
if (piece === undefined || piece.levels.length === 0) return false
|
||||
if (piece === undefined || piece.levels.length === 0) {
|
||||
stats?.unresolved.push(`preset piece not found: ${prefix}`)
|
||||
stats?.notes.push(`failed to place preset: ${label} (missing piece ${prefix})`)
|
||||
return false
|
||||
}
|
||||
const variant = rng.int(0, piece.levels.length - 1)
|
||||
const ds1 = piece.levels[variant]!
|
||||
const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
|
||||
const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
|
||||
const pos = pickFreeInteriorBox(blocksX, blocksY, 2)
|
||||
if (pos === null) return false
|
||||
if (pos === null) {
|
||||
stats?.unresolved.push(`no free interior box for preset: ${label} (${prefix})`)
|
||||
stats?.notes.push(`failed to place preset: ${label} (no free interior box ${String(blocksX)}x${String(blocksY)})`)
|
||||
return false
|
||||
}
|
||||
const { bx, by } = pos
|
||||
claimBox(bx, by, blocksX, blocksY, true)
|
||||
specialPresets.push({ name: piece.name, ds1, bx, by, blocksX, blocksY })
|
||||
|
|
@ -1900,7 +1926,11 @@ function planAct1OutdoorLevel(
|
|||
*/
|
||||
const placeGraveyardPreset = (withGate: boolean): boolean => {
|
||||
const gyPiece = findPiece('Act 1 - Graveyard')
|
||||
if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) return false
|
||||
if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) {
|
||||
stats?.unresolved.push('failed to place Graveyard preset')
|
||||
stats?.notes.push('failed to place Graveyard preset (missing piece or grid too small)')
|
||||
return false
|
||||
}
|
||||
const ds1 = gyPiece.levels[0]
|
||||
claimBox(1, 1, 3, 4, true)
|
||||
specialPresets.push({ name: gyPiece.name, ds1, bx: 1, by: 1, blocksX: 3, blocksY: 4 })
|
||||
|
|
@ -1941,7 +1971,9 @@ function planAct1OutdoorLevel(
|
|||
*/
|
||||
const placeInteriorPresets = (levelId: number): void => {
|
||||
if (levelId === 2) {
|
||||
addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)
|
||||
if (!addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)) {
|
||||
stats?.notes.push('Den of Evil Entrance preset failed to place')
|
||||
}
|
||||
const bivouac = findPiece('Act 1 - Bivouac') ?? findPiece('Act 1 - Cottages')
|
||||
if (bivouac?.levels[0] !== undefined) {
|
||||
const ds1 = bivouac.levels[0]
|
||||
|
|
@ -1951,28 +1983,52 @@ function planAct1OutdoorLevel(
|
|||
if (pos !== null) {
|
||||
claimBox(pos.bx, pos.by, w, h, true)
|
||||
specialPresets.push({ name: bivouac.name, ds1, bx: pos.bx, by: pos.by, blocksX: w, blocksY: h })
|
||||
} else {
|
||||
stats?.notes.push('Bivouac preset failed to place (no free box)')
|
||||
}
|
||||
}
|
||||
} else if (levelId === 3) {
|
||||
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)
|
||||
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)) {
|
||||
stats?.notes.push('Cave Level 1 Entrance preset failed to place')
|
||||
}
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')) {
|
||||
stats?.notes.push('Bivouac Camp preset failed to place')
|
||||
}
|
||||
} else if (levelId === 4) {
|
||||
addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)
|
||||
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)) {
|
||||
stats?.notes.push('Cairn Stones preset failed to place')
|
||||
}
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)) {
|
||||
stats?.notes.push('Underground Passage Entrance preset failed to place')
|
||||
}
|
||||
} else if (levelId === 5) {
|
||||
addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')
|
||||
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')) {
|
||||
stats?.notes.push('Tree of Inifuss preset failed to place')
|
||||
}
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)) {
|
||||
stats?.notes.push('Underground Passage Exit preset failed to place')
|
||||
}
|
||||
} else if (levelId === 6) {
|
||||
// The Forgotten Tower is always beside one of the Black Marsh's roads,
|
||||
// never dropped on open ground, which is why it goes through the
|
||||
// road-joining path rather than the plain scatter.
|
||||
addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)
|
||||
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)) {
|
||||
stats?.notes.push('Forgotten Tower preset failed to place')
|
||||
}
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)) {
|
||||
stats?.notes.push('Hole Level 1 Entrance preset failed to place')
|
||||
}
|
||||
} else if (levelId === 7) {
|
||||
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)) {
|
||||
stats?.notes.push('Pit Level 1 Entrance preset failed to place')
|
||||
}
|
||||
} else if (levelId === 39) {
|
||||
addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')
|
||||
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')) {
|
||||
stats?.notes.push('Cow Corral preset failed to place')
|
||||
}
|
||||
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')) {
|
||||
stats?.notes.push('Cow Bivouac preset failed to place')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1989,7 +2045,12 @@ function planAct1OutdoorLevel(
|
|||
if (placedTown && gate === townSeam) continue
|
||||
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId)
|
||||
}
|
||||
if (id === 17) placeGraveyardPreset(false)
|
||||
if (id === 17) {
|
||||
if (!placeGraveyardPreset(false)) {
|
||||
stats?.unresolved.push('Burial Grounds Graveyard preset failed to place')
|
||||
stats?.notes.push('Burial Grounds Graveyard preset failed to place')
|
||||
}
|
||||
}
|
||||
placeInteriorPresets(id)
|
||||
} else if (id === 2) {
|
||||
// Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance
|
||||
|
|
@ -2032,7 +2093,10 @@ function planAct1OutdoorLevel(
|
|||
placeInteriorPresets(7)
|
||||
} else if (id === 17) {
|
||||
// Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate
|
||||
if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
|
||||
if (!placeGraveyardPreset(true)) {
|
||||
stats?.notes.push('Burial Grounds Graveyard preset failed, falling back to border gate')
|
||||
addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
|
||||
}
|
||||
} else if (id === 39 || id === 46) {
|
||||
// Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates
|
||||
if (id === 39) placeInteriorPresets(39)
|
||||
|
|
@ -4941,16 +5005,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
let floorLayers = 1
|
||||
let substitutionType = 0
|
||||
let version = 0
|
||||
let inferredAct = 1
|
||||
if (request.levelId >= 40 && request.levelId < 75) inferredAct = 2
|
||||
else if (request.levelId >= 75 && request.levelId < 103) inferredAct = 3
|
||||
else if (request.levelId >= 103 && request.levelId < 109) inferredAct = 4
|
||||
else if (request.levelId >= 109) inferredAct = 5
|
||||
else if (/Act 2/i.test(request.levelTypeName)) inferredAct = 2
|
||||
else if (/Act 3/i.test(request.levelTypeName)) inferredAct = 3
|
||||
else if (/Act 4/i.test(request.levelTypeName)) inferredAct = 4
|
||||
else if (/Act 5/i.test(request.levelTypeName)) inferredAct = 5
|
||||
|
||||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
|
||||
let act = inferredAct
|
||||
const allLevels: Ds1[] = []
|
||||
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
|
||||
|
|
@ -5126,7 +5181,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
let act1Plan: Act1OutdoorPlan | undefined
|
||||
let dirtPathGrid: Uint8Array | undefined
|
||||
if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) {
|
||||
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng)
|
||||
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats)
|
||||
stats.anchors = act1Plan.anchors.length
|
||||
stats.entrances = toEntrances(act1Plan.anchors)
|
||||
const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng)
|
||||
|
|
@ -5149,6 +5204,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
request.levelId,
|
||||
act1Plan?.openGates,
|
||||
act1Plan?.claimedBorderBlocks,
|
||||
request.gates,
|
||||
)
|
||||
|
||||
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
|
||||
|
|
|
|||
|
|
@ -511,6 +511,33 @@ describe('Diablo II DRLG Maze Special Passes', () => {
|
|||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
|
||||
})
|
||||
|
||||
test('Halls of Pain (level 123): generates all 4 waypoint directions including NE across seeds', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
|
||||
const observedDirs = new Set<string>()
|
||||
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const res = generateMaze({
|
||||
levelId: 123,
|
||||
levelName: 'Halls of Pain',
|
||||
sectionSize: 10,
|
||||
minRooms: 5,
|
||||
merge: 500,
|
||||
seed,
|
||||
pieces,
|
||||
})
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
const wp = specials.find(s => s.kind === 'waypoint')
|
||||
if (wp !== undefined) {
|
||||
observedDirs.add(wp.sides)
|
||||
}
|
||||
}
|
||||
|
||||
expect(observedDirs).toContain('NE')
|
||||
expect(observedDirs).toContain('NW')
|
||||
expect(observedDirs).toContain('SW')
|
||||
expect(observedDirs).toContain('SE')
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
|
|
|
|||
|
|
@ -60,6 +60,29 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(bloodMoorOpenings.has("9,5")).toBe(true)
|
||||
})
|
||||
|
||||
test("getPerimeterOpenings defaults to open borders for Act 3 Jungles and outdoor levels", () => {
|
||||
// Act 3: Spider Forest (76), Great Marsh (77), Flayer Jungle (78)
|
||||
const spiderForestOpenings = getPerimeterOpenings(76, 10, 10)
|
||||
expect(spiderForestOpenings.size).toBe(2)
|
||||
expect(spiderForestOpenings.has("5,9")).toBe(true)
|
||||
expect(spiderForestOpenings.has("9,5")).toBe(true)
|
||||
|
||||
// Act 5: Arreat Plateau (113)
|
||||
const arreatOpenings = getPerimeterOpenings(113, 10, 10)
|
||||
expect(arreatOpenings.size).toBe(2)
|
||||
})
|
||||
|
||||
test("getPerimeterOpenings maps planned gates to perimeter block coordinates", () => {
|
||||
const plannedGates = [
|
||||
{ side: 'north' as const, toLevelId: 75, label: 'To Town' },
|
||||
{ side: 'east' as const, toLevelId: 77, label: 'To Marsh' },
|
||||
]
|
||||
const openings = getPerimeterOpenings(76, 12, 14, plannedGates)
|
||||
expect(openings.size).toBe(2)
|
||||
expect(openings.has("6,0")).toBe(true) // north: (12/2, 0)
|
||||
expect(openings.has("11,7")).toBe(true) // east: (12-1, 14/2)
|
||||
})
|
||||
|
||||
test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => {
|
||||
const closedDs1 = makeMockDs1(8, 8, false)
|
||||
const openDs1 = makeMockDs1(8, 8, true)
|
||||
|
|
@ -356,4 +379,32 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(foundContinuousEW).toBe(true)
|
||||
expect(foundEndCapEW).toBe(true)
|
||||
})
|
||||
|
||||
test("records unresolved presets and notes when requested preset piece is missing", () => {
|
||||
const borderDs1 = makeMockDs1(8, 8, false)
|
||||
const piece: WildernessPiece = {
|
||||
name: "Act 1 - Wild Border 1",
|
||||
border: true,
|
||||
levels: [borderDs1],
|
||||
}
|
||||
|
||||
const result = generateWilderness({
|
||||
levelId: 2,
|
||||
levelName: "Blood Moor",
|
||||
levelTypeName: "Act 1 - Wilderness",
|
||||
sizeX: 80,
|
||||
sizeY: 80,
|
||||
subType: 0,
|
||||
subTheme: 0,
|
||||
seed: 9999,
|
||||
pieces: [piece], // Missing Den of Evil Entrance preset!
|
||||
substitutions: [],
|
||||
})
|
||||
|
||||
const unresolved = result.stats.unresolved as string[]
|
||||
const notes = result.stats.notes as string[]
|
||||
expect(unresolved.length).toBeGreaterThan(0)
|
||||
expect(unresolved.some((u: string) => u.includes('Act 1 - DOE Entrance'))).toBe(true)
|
||||
expect(notes.some((n: string) => n.includes('Den of Evil Entrance'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
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,
|
||||
|
|
@ -16,6 +18,11 @@ import {
|
|||
DROPPED_VIS_EDGES,
|
||||
} 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.
|
||||
|
|
@ -376,3 +383,100 @@ describe('parseWarpGeometry', () => {
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue