Resolve outdoor waypoints and act mapping dynamically from Levels.txt
- Add dynamic hasOutdoorWaypoint(levelId, levelsTable) reading Levels.txt Waypoint column (!= 255) - Unify actOfLevel in acts.ts as canonical source of truth supporting Levels.txt Act column - Support levelsTable in WildernessRequest, spawnOutdoorWaypoint, spawnOutdoorShrines, stampOutdoorRoads, stampOutdoorDividers - Re-export actOfLevel, hasOutdoorWaypoint, and OUTDOOR_WAYPOINT_LEVELS cleanly without circular dependencies - Add unit tests verifying dynamic Levels.txt resolution and fallbacks TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
7efdad4693
commit
94305b03dd
|
|
@ -90,6 +90,81 @@ export function cell(table: D2Table, row: readonly string[], column: string): st
|
||||||
return index === -1 ? '' : (row[index] ?? '')
|
return index === -1 ? '' : (row[index] ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical set of outdoor levels that contain a Waypoint across Acts 1..5.
|
||||||
|
* Used as a fast fallback when the complete `Levels.txt` table is not loaded.
|
||||||
|
*/
|
||||||
|
export const OUTDOOR_WAYPOINT_LEVELS = new Set([
|
||||||
|
3, 4, 5, 6, 27, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh, Outer Cloister
|
||||||
|
42, 43, 44, 46, 48, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi, Arcane Sanctuary
|
||||||
|
76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal
|
||||||
|
106, 107, // Act 4: City of the Damned, River of Flame
|
||||||
|
111, 112, 113, 115, 117, // Act 5: Frigid Highlands, Arreat Plateau, Crystalline Passage, Glacial Trail, Ancients' Way
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether an outdoor level carries a Waypoint.
|
||||||
|
* Reads `Levels.txt Waypoint != 255` when levels table is provided, with cached fallback matching canonical levels.
|
||||||
|
*/
|
||||||
|
export function hasOutdoorWaypoint(levelId: number, levelsTable?: D2Table): boolean {
|
||||||
|
if (levelsTable) {
|
||||||
|
const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId)
|
||||||
|
if (row) {
|
||||||
|
const wp = cell(levelsTable, row, 'Waypoint')
|
||||||
|
const wpNum = wp === '' ? 255 : Number(wp)
|
||||||
|
const drlgType = cell(levelsTable, row, 'DrlgType')
|
||||||
|
const isTown = [1, 40, 75, 103, 109].includes(levelId)
|
||||||
|
const isOutdoor = drlgType === '3' || (!isTown && OUTDOOR_WAYPOINT_LEVELS.has(levelId))
|
||||||
|
return isOutdoor && wpNum !== 255 && !isNaN(wpNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OUTDOOR_WAYPOINT_LEVELS.has(levelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the numeric Act (1..5) for a given levelId, levelTypeName, and/or levels table.
|
||||||
|
* Reads `Levels.txt Act` column when table is provided, with fallback to levelId / levelTypeName / fallbackAct.
|
||||||
|
*/
|
||||||
|
export function actOfLevel(
|
||||||
|
levelId: number,
|
||||||
|
levelTypeNameOrTable?: string | D2Table,
|
||||||
|
fallbackActOrTable?: number | D2Table,
|
||||||
|
maybeTable?: D2Table,
|
||||||
|
): number {
|
||||||
|
const table =
|
||||||
|
(levelTypeNameOrTable && typeof levelTypeNameOrTable === 'object' && 'rows' in levelTypeNameOrTable ? levelTypeNameOrTable : undefined) ??
|
||||||
|
(fallbackActOrTable && typeof fallbackActOrTable === 'object' && 'rows' in fallbackActOrTable ? fallbackActOrTable : undefined) ??
|
||||||
|
maybeTable
|
||||||
|
|
||||||
|
const levelTypeName = typeof levelTypeNameOrTable === 'string' ? levelTypeNameOrTable : undefined
|
||||||
|
const fallbackAct = typeof fallbackActOrTable === 'number' ? fallbackActOrTable : undefined
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const row = table.rows.find(candidate => Number(cell(table, candidate, 'Id')) === levelId)
|
||||||
|
if (row) {
|
||||||
|
if (levelId >= 109 && levelId <= 136) return 5
|
||||||
|
const actVal = cell(table, row, 'Act')
|
||||||
|
if (actVal !== '') {
|
||||||
|
const parsed = Number(actVal) + 1
|
||||||
|
if (parsed >= 1 && parsed <= 5) return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelTypeName) {
|
||||||
|
const m = /^Act\s*([1-5])/i.exec(levelTypeName)
|
||||||
|
if (m && m[1]) return Number(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelId >= 1 && levelId <= 39) return 1
|
||||||
|
if (levelId >= 40 && levelId <= 74) return 2
|
||||||
|
if (levelId >= 75 && levelId <= 102) return 3
|
||||||
|
if (levelId >= 103 && levelId <= 108) return 4
|
||||||
|
if (levelId >= 109 && levelId <= 136) return 5
|
||||||
|
if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tables a level needs to be resolved and populated.
|
* The tables a level needs to be resolved and populated.
|
||||||
*
|
*
|
||||||
|
|
@ -304,7 +379,7 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
|
act: act ?? actOfLevel(levelId, cell(tables.lvltypes, typeRow, 'Name'), undefined, tables.levels),
|
||||||
levelName: cell(tables.levels, row, 'Name'),
|
levelName: cell(tables.levels, row, 'Name'),
|
||||||
levelId,
|
levelId,
|
||||||
paletteIndex,
|
paletteIndex,
|
||||||
|
|
@ -425,3 +500,4 @@ export async function loadLevel(
|
||||||
cells: levels.reduce((sum, level) => sum + level.width * level.height, 0),
|
cells: levels.reduce((sum, level) => sum + level.width * level.height, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,9 @@ import {
|
||||||
stampAct5SiegeStrips,
|
stampAct5SiegeStrips,
|
||||||
type SiegeTopographyStats,
|
type SiegeTopographyStats,
|
||||||
} from './wilderness-siege.ts'
|
} from './wilderness-siege.ts'
|
||||||
|
import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
|
||||||
import type { D2Table } from './acts.ts'
|
import type { D2Table } from './acts.ts'
|
||||||
|
export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
|
||||||
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
|
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
|
||||||
import type { SuperUnique } from './monsters.ts'
|
import type { SuperUnique } from './monsters.ts'
|
||||||
|
|
||||||
|
|
@ -242,6 +244,8 @@ export interface WildernessRequest {
|
||||||
* When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID.
|
* When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID.
|
||||||
*/
|
*/
|
||||||
readonly specialPresets?: readonly string[]
|
readonly specialPresets?: readonly string[]
|
||||||
|
/** Optional loaded Levels.txt table to drive act resolution, waypoints, and object queries. */
|
||||||
|
readonly levelsTable?: D2Table
|
||||||
/**
|
/**
|
||||||
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
|
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
|
||||||
* Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending.
|
* Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending.
|
||||||
|
|
@ -3656,35 +3660,47 @@ export function stampOutdoorRoads(
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
levelTypeName?: string,
|
levelTypeName?: string,
|
||||||
openGates?: ReadonlyMap<string, number>,
|
openGates?: ReadonlyMap<string, number>,
|
||||||
|
levelsTable?: D2Table,
|
||||||
): void {
|
): void {
|
||||||
if (levelId === 39) return // Cow Level has no roads
|
if (levelId === 39) return // Cow Level has no roads
|
||||||
|
|
||||||
// Level ID takes highest precedence
|
// Level ID takes highest precedence
|
||||||
if (levelId >= 41 && levelId <= 46) {
|
if (levelId >= 41 && levelId <= 46) {
|
||||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
} else if (levelId >= 79 && levelId <= 82) {
|
} else if (levelId >= 79 && levelId <= 82) {
|
||||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
} else if (levelId >= 2 && levelId <= 7) {
|
} else if (levelId >= 2 && levelId <= 7) {
|
||||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
// Level type name takes second precedence
|
// Level type name takes second precedence
|
||||||
} else if (levelTypeName === 'Act 2 - Desert') {
|
} else if (levelTypeName === 'Act 2 - Desert') {
|
||||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
|
return
|
||||||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
// Act number takes fallback precedence
|
return
|
||||||
} else if (act === 2) {
|
}
|
||||||
|
|
||||||
|
// Unified actOfLevel fallback
|
||||||
|
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||||
|
if (effectiveAct === 2) {
|
||||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
} else if (act === 3) {
|
} else if (effectiveAct === 3) {
|
||||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
} else if (act === 5) {
|
} else if (effectiveAct === 5) {
|
||||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
} else if (act === 1) {
|
} else if (effectiveAct === 1) {
|
||||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3703,35 +3719,49 @@ export function stampOutdoorDividers(
|
||||||
placedRects: readonly PlacedPresetRect[],
|
placedRects: readonly PlacedPresetRect[],
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
levelTypeName?: string,
|
levelTypeName?: string,
|
||||||
|
levelsTable?: D2Table,
|
||||||
): void {
|
): void {
|
||||||
if (levelId === 39) return // Cow Level has no dividers
|
if (levelId === 39) return // Cow Level has no dividers
|
||||||
|
|
||||||
// Level ID takes highest precedence
|
// Level ID takes highest precedence
|
||||||
if (levelId >= 41 && levelId <= 46) {
|
if (levelId >= 41 && levelId <= 46) {
|
||||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
} else if (levelId >= 79 && levelId <= 82) {
|
} else if (levelId >= 79 && levelId <= 82) {
|
||||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
} else if (levelId >= 2 && levelId <= 7) {
|
} else if (levelId >= 2 && levelId <= 7) {
|
||||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Level type name takes second precedence
|
// Level type name takes second precedence
|
||||||
} else if (levelTypeName === 'Act 2 - Desert') {
|
if (levelTypeName === 'Act 2 - Desert') {
|
||||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
|
return
|
||||||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
// Act number takes fallback precedence
|
return
|
||||||
} else if (act === 2) {
|
}
|
||||||
|
|
||||||
|
// Unified actOfLevel fallback
|
||||||
|
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||||
|
if (effectiveAct === 2) {
|
||||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
} else if (act === 3) {
|
} else if (effectiveAct === 3) {
|
||||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
} else if (act === 5) {
|
} else if (effectiveAct === 5) {
|
||||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
} else if (act === 1) {
|
} else if (effectiveAct === 1) {
|
||||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3741,33 +3771,6 @@ export function stampOutdoorDividers(
|
||||||
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/** Outdoor levels in Acts 1..5 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
|
|
||||||
export const OUTDOOR_WAYPOINT_LEVELS = new Set([
|
|
||||||
3, 4, 5, 6, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh
|
|
||||||
42, 43, 44, 46, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi
|
|
||||||
76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal
|
|
||||||
106, 107, // Act 4: City of the Damned, River of Flame
|
|
||||||
111, 112, 117, // Act 5: Frigid Highlands, Arreat Plateau, Frozen Tundra
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the numeric Act (1..5) for a given levelId and/or levelTypeName.
|
|
||||||
* Handles cases where DS1 preset levels report act = 1 by default.
|
|
||||||
*/
|
|
||||||
export function actOfLevel(levelId: number, levelTypeName?: string, fallbackAct?: number): number {
|
|
||||||
if (levelTypeName) {
|
|
||||||
const m = /^Act\s*([1-5])/i.exec(levelTypeName)
|
|
||||||
if (m && m[1]) return Number(m[1])
|
|
||||||
}
|
|
||||||
if (levelId >= 1 && levelId <= 39) return 1
|
|
||||||
if (levelId >= 40 && levelId <= 74) return 2
|
|
||||||
if (levelId >= 75 && levelId <= 102) return 3
|
|
||||||
if (levelId >= 103 && levelId <= 108) return 4
|
|
||||||
if (levelId >= 109 && levelId <= 136) return 5
|
|
||||||
if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActPopulationConfig {
|
export interface ActPopulationConfig {
|
||||||
readonly waypointId: number
|
readonly waypointId: number
|
||||||
readonly shrinePool: readonly number[]
|
readonly shrinePool: readonly number[]
|
||||||
|
|
@ -3885,8 +3888,9 @@ export function spawnOutdoorWaypoint(
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
levelTypeName?: string,
|
levelTypeName?: string,
|
||||||
|
levelsTable?: D2Table,
|
||||||
): void {
|
): void {
|
||||||
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
|
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
|
||||||
|
|
||||||
// Check if a preset already placed a Waypoint object
|
// Check if a preset already placed a Waypoint object
|
||||||
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
|
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
|
||||||
|
|
@ -3902,7 +3906,7 @@ export function spawnOutdoorWaypoint(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||||
const wpId = config.waypointId
|
const wpId = config.waypointId
|
||||||
|
|
||||||
|
|
@ -4024,19 +4028,29 @@ export function spawnOutdoorShrines(
|
||||||
shrineSubstitutions?: readonly WildernessSubstitution[],
|
shrineSubstitutions?: readonly WildernessSubstitution[],
|
||||||
subTheme = 0,
|
subTheme = 0,
|
||||||
levelTypeName?: string,
|
levelTypeName?: string,
|
||||||
|
levelsTable?: D2Table,
|
||||||
): void {
|
): void {
|
||||||
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
||||||
|
|
||||||
const isOutdoorLevel =
|
let isOutdoorLevel =
|
||||||
(levelId >= 2 && levelId <= 7) || levelId === 39 ||
|
(levelId >= 2 && levelId <= 7) || levelId === 39 ||
|
||||||
(levelId >= 41 && levelId <= 46) ||
|
(levelId >= 41 && levelId <= 46) ||
|
||||||
(levelId >= 76 && levelId <= 83) ||
|
(levelId >= 76 && levelId <= 83) ||
|
||||||
(levelId >= 104 && levelId <= 107) ||
|
(levelId >= 104 && levelId <= 107) ||
|
||||||
(levelId >= 111 && levelId <= 112) || levelId === 117 ||
|
(levelId >= 111 && levelId <= 112) || levelId === 117 ||
|
||||||
(levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName))
|
(levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName))
|
||||||
|
|
||||||
|
if (levelsTable) {
|
||||||
|
const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId)
|
||||||
|
if (row && cell(levelsTable, row, 'DrlgType') === '3') {
|
||||||
|
const isTown = [1, 40, 75, 103, 109].includes(levelId)
|
||||||
|
if (!isTown) isOutdoorLevel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isOutdoorLevel) return
|
if (!isOutdoorLevel) return
|
||||||
|
|
||||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||||
|
|
||||||
const isJungle = levelId >= 76 && levelId <= 78
|
const isJungle = levelId >= 76 && levelId <= 78
|
||||||
|
|
@ -5005,7 +5019,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
let floorLayers = 1
|
let floorLayers = 1
|
||||||
let substitutionType = 0
|
let substitutionType = 0
|
||||||
let version = 0
|
let version = 0
|
||||||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
|
const inferredAct = actOfLevel(request.levelId, request.levelTypeName, undefined, request.levelsTable)
|
||||||
let act = inferredAct
|
let act = inferredAct
|
||||||
const allLevels: Ds1[] = []
|
const allLevels: Ds1[] = []
|
||||||
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
|
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
|
||||||
|
|
@ -5058,8 +5072,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
||||||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
|
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
|
||||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||||
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName)
|
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName, request.levelsTable)
|
||||||
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName)
|
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName, request.levelsTable)
|
||||||
} else {
|
} else {
|
||||||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
|
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
|
||||||
}
|
}
|
||||||
|
|
@ -5345,6 +5359,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
rng,
|
rng,
|
||||||
request.levelTypeName,
|
request.levelTypeName,
|
||||||
act1Plan?.openGates,
|
act1Plan?.openGates,
|
||||||
|
request.levelsTable,
|
||||||
)
|
)
|
||||||
stampOutdoorDividers(
|
stampOutdoorDividers(
|
||||||
canvas,
|
canvas,
|
||||||
|
|
@ -5357,6 +5372,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
allPlacedRects,
|
allPlacedRects,
|
||||||
rng,
|
rng,
|
||||||
request.levelTypeName,
|
request.levelTypeName,
|
||||||
|
request.levelsTable,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
|
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
|
||||||
|
|
@ -5394,8 +5410,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
|
|
||||||
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
||||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||||
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName)
|
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable)
|
||||||
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName)
|
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName, request.levelsTable)
|
||||||
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
|
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
|
||||||
|
|
||||||
for (const piece of fillPieces) {
|
for (const piece of fillPieces) {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,16 @@ import {
|
||||||
generateWilderness,
|
generateWilderness,
|
||||||
OUTDOOR_WAYPOINT_LEVELS,
|
OUTDOOR_WAYPOINT_LEVELS,
|
||||||
actOfLevel,
|
actOfLevel,
|
||||||
|
hasOutdoorWaypoint,
|
||||||
ACT_POPULATION_CONFIG,
|
ACT_POPULATION_CONFIG,
|
||||||
type WildernessPiece,
|
type WildernessPiece,
|
||||||
type WildernessSubstitution,
|
type WildernessSubstitution,
|
||||||
} from "../src/game/wilderness.ts"
|
} from "../src/game/wilderness.ts"
|
||||||
|
import {
|
||||||
|
actOfLevel as actsActOfLevel,
|
||||||
|
hasOutdoorWaypoint as actsHasOutdoorWaypoint,
|
||||||
|
type D2Table,
|
||||||
|
} from "../src/game/acts.ts"
|
||||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||||
|
|
||||||
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
|
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
|
||||||
|
|
@ -89,6 +95,59 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
||||||
expect(actOfLevel(117, undefined)).toBe(5)
|
expect(actOfLevel(117, undefined)).toBe(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("actOfLevel resolves Act column from Levels.txt table when provided", () => {
|
||||||
|
const mockLevelsTable: D2Table = {
|
||||||
|
header: ["Id", "Name", "Act"],
|
||||||
|
rows: [
|
||||||
|
["2", "Blood Moor", "0"],
|
||||||
|
["42", "Dry Hills", "1"],
|
||||||
|
["76", "Spider Forest", "2"],
|
||||||
|
["106", "City of the Damned", "3"],
|
||||||
|
["112", "Arreat Plateau", "0"], // Expansion Act 5 row stores 0 in blizzard data
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(actOfLevel(2, undefined, undefined, mockLevelsTable)).toBe(1)
|
||||||
|
expect(actOfLevel(42, undefined, undefined, mockLevelsTable)).toBe(2)
|
||||||
|
expect(actOfLevel(76, undefined, undefined, mockLevelsTable)).toBe(3)
|
||||||
|
expect(actOfLevel(106, undefined, undefined, mockLevelsTable)).toBe(4)
|
||||||
|
expect(actOfLevel(112, undefined, undefined, mockLevelsTable)).toBe(5)
|
||||||
|
|
||||||
|
// Re-export from acts.ts is identical
|
||||||
|
expect(actsActOfLevel).toBe(actOfLevel)
|
||||||
|
expect(actsHasOutdoorWaypoint).toBe(hasOutdoorWaypoint)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hasOutdoorWaypoint reads Levels.txt Waypoint column dynamically", () => {
|
||||||
|
const mockLevelsTable: D2Table = {
|
||||||
|
header: ["Id", "Name", "Act", "DrlgType", "Waypoint"],
|
||||||
|
rows: [
|
||||||
|
["1", "Rogue Encampment", "0", "1", "0"], // Town level -> false for outdoor waypoint
|
||||||
|
["2", "Blood Moor", "0", "3", "255"], // Outdoor, no waypoint (255) -> false
|
||||||
|
["3", "Cold Plains", "0", "3", "1"], // Outdoor, waypoint (1) -> true
|
||||||
|
["40", "Lut Gholein", "1", "1", "2"], // Town level -> false
|
||||||
|
["43", "Far Oasis", "1", "3", "3"], // Outdoor, waypoint -> true
|
||||||
|
["200", "Custom Field", "0", "3", "4"], // Custom outdoor level with waypoint -> true
|
||||||
|
["201", "Custom Field No WP", "0", "3", "255"], // Custom outdoor level without waypoint -> false
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic resolution via table
|
||||||
|
expect(hasOutdoorWaypoint(3, mockLevelsTable)).toBe(true)
|
||||||
|
expect(hasOutdoorWaypoint(2, mockLevelsTable)).toBe(false)
|
||||||
|
expect(hasOutdoorWaypoint(1, mockLevelsTable)).toBe(false) // Town
|
||||||
|
expect(hasOutdoorWaypoint(40, mockLevelsTable)).toBe(false) // Town
|
||||||
|
expect(hasOutdoorWaypoint(43, mockLevelsTable)).toBe(true)
|
||||||
|
expect(hasOutdoorWaypoint(200, mockLevelsTable)).toBe(true)
|
||||||
|
expect(hasOutdoorWaypoint(201, mockLevelsTable)).toBe(false)
|
||||||
|
|
||||||
|
// Fallback when no table provided
|
||||||
|
expect(hasOutdoorWaypoint(3)).toBe(true)
|
||||||
|
expect(hasOutdoorWaypoint(2)).toBe(false)
|
||||||
|
expect(hasOutdoorWaypoint(43)).toBe(true)
|
||||||
|
expect(hasOutdoorWaypoint(41)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
|
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
|
||||||
// Act 1
|
// Act 1
|
||||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
|
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue