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] ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
|
@ -304,7 +379,7 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
|
|||
}
|
||||
|
||||
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'),
|
||||
levelId,
|
||||
paletteIndex,
|
||||
|
|
@ -425,3 +500,4 @@ export async function loadLevel(
|
|||
cells: levels.reduce((sum, level) => sum + level.width * level.height, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,9 @@ import {
|
|||
stampAct5SiegeStrips,
|
||||
type SiegeTopographyStats,
|
||||
} from './wilderness-siege.ts'
|
||||
import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } 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 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.
|
||||
*/
|
||||
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.
|
||||
* Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending.
|
||||
|
|
@ -3656,35 +3660,47 @@ export function stampOutdoorRoads(
|
|||
rng: Rng,
|
||||
levelTypeName?: string,
|
||||
openGates?: ReadonlyMap<string, number>,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (levelId === 39) return // Cow Level has no roads
|
||||
|
||||
// Level ID takes highest precedence
|
||||
if (levelId >= 41 && levelId <= 46) {
|
||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelId >= 79 && levelId <= 82) {
|
||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelId >= 2 && levelId <= 7) {
|
||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
// Level type name takes second precedence
|
||||
} else if (levelTypeName === 'Act 2 - Desert') {
|
||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
// Act number takes fallback precedence
|
||||
} else if (act === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unified actOfLevel fallback
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
if (effectiveAct === 2) {
|
||||
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)
|
||||
} else if (act === 5) {
|
||||
} else if (effectiveAct === 5) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -3703,35 +3719,49 @@ export function stampOutdoorDividers(
|
|||
placedRects: readonly PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
levelTypeName?: string,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (levelId === 39) return // Cow Level has no dividers
|
||||
|
||||
// Level ID takes highest precedence
|
||||
if (levelId >= 41 && levelId <= 46) {
|
||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelId >= 79 && levelId <= 82) {
|
||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelId >= 2 && levelId <= 7) {
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
// Act number takes fallback precedence
|
||||
} else if (act === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unified actOfLevel fallback
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
if (effectiveAct === 2) {
|
||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
} else if (act === 3) {
|
||||
} else if (effectiveAct === 3) {
|
||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
} else if (act === 5) {
|
||||
} else if (effectiveAct === 5) {
|
||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
} else if (act === 1) {
|
||||
} else if (effectiveAct === 1) {
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
}
|
||||
}
|
||||
|
|
@ -3741,33 +3771,6 @@ export function stampOutdoorDividers(
|
|||
* (`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 {
|
||||
readonly waypointId: number
|
||||
readonly shrinePool: readonly number[]
|
||||
|
|
@ -3885,8 +3888,9 @@ export function spawnOutdoorWaypoint(
|
|||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
levelTypeName?: string,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
|
||||
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
|
||||
|
||||
// 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])
|
||||
|
|
@ -3902,7 +3906,7 @@ export function spawnOutdoorWaypoint(
|
|||
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 wpId = config.waypointId
|
||||
|
||||
|
|
@ -4024,19 +4028,29 @@ export function spawnOutdoorShrines(
|
|||
shrineSubstitutions?: readonly WildernessSubstitution[],
|
||||
subTheme = 0,
|
||||
levelTypeName?: string,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
||||
|
||||
const isOutdoorLevel =
|
||||
let isOutdoorLevel =
|
||||
(levelId >= 2 && levelId <= 7) || levelId === 39 ||
|
||||
(levelId >= 41 && levelId <= 46) ||
|
||||
(levelId >= 76 && levelId <= 83) ||
|
||||
(levelId >= 104 && levelId <= 107) ||
|
||||
(levelId >= 111 && levelId <= 112) || levelId === 117 ||
|
||||
(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
|
||||
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||
|
||||
const isJungle = levelId >= 76 && levelId <= 78
|
||||
|
|
@ -5005,7 +5019,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
let floorLayers = 1
|
||||
let substitutionType = 0
|
||||
let version = 0
|
||||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
|
||||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName, undefined, request.levelsTable)
|
||||
let act = inferredAct
|
||||
const allLevels: Ds1[] = []
|
||||
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) {
|
||||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
|
||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName)
|
||||
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, 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, request.levelsTable)
|
||||
} else {
|
||||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
|
||||
}
|
||||
|
|
@ -5345,6 +5359,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
rng,
|
||||
request.levelTypeName,
|
||||
act1Plan?.openGates,
|
||||
request.levelsTable,
|
||||
)
|
||||
stampOutdoorDividers(
|
||||
canvas,
|
||||
|
|
@ -5357,6 +5372,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
allPlacedRects,
|
||||
rng,
|
||||
request.levelTypeName,
|
||||
request.levelsTable,
|
||||
)
|
||||
|
||||
// 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)
|
||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName)
|
||||
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, 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, request.levelsTable)
|
||||
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
|
||||
|
||||
for (const piece of fillPieces) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ import {
|
|||
generateWilderness,
|
||||
OUTDOOR_WAYPOINT_LEVELS,
|
||||
actOfLevel,
|
||||
hasOutdoorWaypoint,
|
||||
ACT_POPULATION_CONFIG,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
} 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"
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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", () => {
|
||||
// Act 1
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
|
||||
|
|
|
|||
Loading…
Reference in New Issue