From 94305b03dd44d31178f5cab7c361cd9acb7d0c86 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 14:27:54 +0000 Subject: [PATCH] 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 --- src/game/acts.ts | 78 ++++++++++++++- src/game/wilderness.ts | 110 ++++++++++++--------- tests/wilderness-waypoints-shrines.test.ts | 59 +++++++++++ 3 files changed, 199 insertions(+), 48 deletions(-) diff --git a/src/game/acts.ts b/src/game/acts.ts index 6d3dc41..dd4b9a7 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -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), } } + diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 55e3ba1..744b91b 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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, + 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) { diff --git a/tests/wilderness-waypoints-shrines.test.ts b/tests/wilderness-waypoints-shrines.test.ts index a0e9e80..83fc0e3 100644 --- a/tests/wilderness-waypoints-shrines.test.ts +++ b/tests/wilderness-waypoints-shrines.test.ts @@ -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