diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index db6a043..d808f65 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -1657,7 +1657,6 @@ export function applyGroundTransitions( * the gap is visible rather than implied. */ export const UNIMPLEMENTED_PASSES: readonly string[] = [ - 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', 'DRLGOUTDOORS_SpawnAct3Mephisto', @@ -2928,11 +2927,15 @@ export function planAct1OutdoorLevel( } export interface DrlgVertex { - readonly x: number - readonly y: number - readonly type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' - readonly label?: string | undefined - readonly id?: number | undefined + x: number + y: number + type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' | 'corner' + label?: string | undefined + id?: number | undefined + direction?: number | undefined + dwFlags?: number | undefined + next?: DrlgVertex | null | undefined + prev?: DrlgVertex | null | undefined } export interface DrlgRoadNetwork { @@ -2944,6 +2947,8 @@ export type DrlgVertexResult = DrlgVertex[] & { readonly vertices: DrlgVertex[] readonly polylines: { x: number; y: number }[][] readonly lines: { x: number; y: number }[][] + readonly cyclicVertices?: DrlgVertex[] | undefined + readonly head?: DrlgVertex | undefined } /** @@ -3183,89 +3188,528 @@ function findClearBlockCorridor( return path } +/** Direction constants in D2MOO D2AltDirections order. */ +export const ALTDIR_WEST = 0 +export const ALTDIR_NORTH = 1 +export const ALTDIR_EAST = 2 +export const ALTDIR_SOUTH = 3 + /** - * Build macro control vertex graph and smooth Catmull-Rom spline polylines - * connecting anchors to a central hub (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`). + * Bresenham line algorithm connecting two points on integer grid coordinates (0 RNG rolls). */ -export function createDrlgVertices( +export function bresenhamLine( + x0: number, + y0: number, + x1: number, + y1: number, +): { x: number; y: number }[] { + const points: { x: number; y: number }[] = [] + let x = Math.round(x0) + let y = Math.round(y0) + const targetX = Math.round(x1) + const targetY = Math.round(y1) + + const dx = Math.abs(targetX - x) + const dy = Math.abs(targetY - y) + const sx = x < targetX ? 1 : -1 + const sy = y < targetY ? 1 : -1 + let err = dx - dy + + while (true) { + points.push({ x, y }) + if (x === targetX && y === targetY) break + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + x += sx + } + if (e2 < dx) { + err += dx + y += sy + } + } + + return points +} + +/** + * Check whether an outdoor grid cell is valid for spawning / hub placement + * (`DRLGOUTDOORS_TestGridCellSpawnValid`, mask 0x1B81). + */ +export function DRLGOUTDOORS_TestGridCellSpawnValid( + x: number, + y: number, + gridOrPlan?: { readonly roadObstacleBlocks?: boolean[][]; readonly gridFlags?: number[][] } | number[][], +): boolean { + if (!gridOrPlan) return true + if (Array.isArray(gridOrPlan)) { + const val = gridOrPlan[y]?.[x] ?? 0 + return (val & 0x1B81) === 0 + } + if (gridOrPlan.gridFlags) { + const val = gridOrPlan.gridFlags[y]?.[x] ?? 0 + if ((val & 0x1B81) !== 0) return false + } + if (gridOrPlan.roadObstacleBlocks) { + if (gridOrPlan.roadObstacleBlocks[y]?.[x] === true) return false + } + return true +} + +/** + * Snap vertex to 8-cell grid based on border direction (`DRLGOUTDOORS_CalculatePathCoordinates`, + * D2Common.0x6FD7F500). + * + * Supports both TypeScript `(vertex1, origin)` and D2MOO `(level, vertex1, vertex2)` call styles. + */ +export function DRLGOUTDOORS_CalculatePathCoordinates( + arg1: any, + arg2?: any, + arg3?: any, +): { x: number; y: number } { + let origin = { x: 0, y: 0 } + let v1: any + let v2: any = null + + if (arg3 !== undefined || (arg2 !== undefined && typeof arg2 === 'object' && ('direction' in arg2 || 'nDirection' in arg2))) { + const origX = arg1?.nPosX ?? arg1?.x ?? 0 + const origY = arg1?.nPosY ?? arg1?.y ?? 0 + origin = { x: origX, y: origY } + v1 = arg2 + v2 = arg3 + } else { + v1 = arg1 + if (arg2 && typeof arg2 === 'object') { + origin = { x: arg2.x ?? arg2.nPosX ?? 0, y: arg2.y ?? arg2.nPosY ?? 0 } + } + } + + const v1X = v1.nPosX ?? v1.x ?? 0 + const v1Y = v1.nPosY ?? v1.y ?? 0 + const dir = v1.nDirection ?? v1.direction ?? -1 + + let relX = v1X - origin.x + let relY = v1Y - origin.y + + switch (dir) { + case ALTDIR_WEST: + relX = 8 * Math.trunc(relX / 8) + 11 + break + case ALTDIR_NORTH: + relY = 8 * Math.trunc(relY / 8) + 11 + break + case ALTDIR_EAST: + relX = 8 * Math.trunc(relX / 8) - 5 + break + case ALTDIR_SOUTH: + relY = 8 * Math.trunc(relY / 8) - 5 + break + default: + break + } + + const result = { + x: relX + origin.x, + y: relY + origin.y, + } + + if (v2 && typeof v2 === 'object') { + v2.x = result.x + v2.y = result.y + v2.nPosX = result.x + v2.nPosY = result.y + } + + return result +} + +/** + * Calculate central crossroads hub coordinate (`sub_6FD7F5B0`, D2Common.0x6FD7F5B0). + * + * If bridge (dwFlags & 0x10): anchor to bridge coordinates. + * Else if nVertices === 1: hub = (Math.floor(nGridWidth / 2), Math.floor(nGridHeight / 2)). + * Else: + * avgX = Math.floor(sumX / (8 * nVertices)) + * avgY = Math.floor(sumY / (8 * nVertices)) + * Spiral outward with radius 0..7 and 4 directions: [-1, 0], [0, 1], [0, -1], [1, 0]. + * Pick first cell passing TestGridCellSpawnValid (cell & 0x1B81 === 0). + * Consumes 0 RNG rolls! + */ +export function DRLGOUTDOORS_CalculateHubCoordinates( plan: Act1OutdoorPlan, gridWidth: number, gridHeight: number, - rng: Rng, -): DrlgVertexResult { - if (plan.anchors.length < 2) { - const empty: DrlgVertex[] = [] - return Object.assign(empty, { - vertices: empty, - polylines: [], - lines: [], - }) as DrlgVertexResult + origin: { x: number; y: number } = { x: 0, y: 0 }, +): { x: number; y: number; bx: number; by: number } { + const dwFlags = (plan as any).dwFlags ?? 0 + const hasBridge = (dwFlags & 0x10) !== 0 || (plan as any).hasBridge === true || + plan.specialPresets.some(p => /bridge/i.test(p.name)) + const bridgePreset = plan.specialPresets.find(p => /bridge/i.test(p.name)) + const bridgeCoords = (plan as any).bridgeCoords ?? (bridgePreset ? { x: bridgePreset.bx, y: bridgePreset.by } : undefined) + + if (hasBridge && bridgeCoords && bridgeCoords.x !== -1) { + const bx = bridgeCoords.x + const by = bridgeCoords.y + return { + x: 8 * bx + 3 + origin.x, + y: 8 * by + 3 + origin.y, + bx, + by, + } } - // 1. Central Crossroads Hub - let sumBx = 0 - let sumBy = 0 + const nVertices = plan.anchors.length + if (nVertices === 1) { + const bx = Math.floor(gridWidth / 2) + const by = Math.floor(gridHeight / 2) + return { + x: bx * TILES_PER_BLOCK + 4 + origin.x, + y: by * TILES_PER_BLOCK + 4 + origin.y, + bx, + by, + } + } + + let sumX = 0 + let sumY = 0 for (const anchor of plan.anchors) { - sumBx += Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK) - sumBy += Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK) + sumX += anchor.anchorCell.x - origin.x + sumY += anchor.anchorCell.y - origin.y } - let hubBx = Math.max(2, Math.min(gridWidth - 3, Math.round(sumBx / plan.anchors.length) + rng.int(-1, 1))) - let hubBy = Math.max(2, Math.min(gridHeight - 3, Math.round(sumBy / plan.anchors.length) + rng.int(-1, 1))) - if (plan.roadObstacleBlocks[hubBy]?.[hubBx] === true) { - let found = false - for (let r = 1; r < Math.max(gridWidth, gridHeight) && !found; r += 1) { - for (let dy = -r; dy <= r && !found; dy += 1) { - for (let dx = -r; dx <= r && !found; dx += 1) { - const nx = hubBx + dx - const ny = hubBy + dy - if (nx >= 1 && nx < gridWidth - 1 && ny >= 1 && ny < gridHeight - 1) { - if (plan.roadObstacleBlocks[ny]?.[nx] !== true) { - hubBx = nx - hubBy = ny - found = true - } + const count = Math.max(1, nVertices) + let avgX = Math.floor(sumX / (8 * count)) + let avgY = Math.floor(sumY / (8 * count)) + avgX = Math.max(0, Math.min(gridWidth - 1, avgX)) + avgY = Math.max(0, Math.min(gridHeight - 1, avgY)) + + const DIR_OFFSETS = [ + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, + { x: 1, y: 0 }, + ] + + let chosenBx = avgX + let chosenBy = avgY + let found = false + + for (let r = 0; r < 8 && !found; r += 1) { + for (const dir of DIR_OFFSETS) { + const testX = avgX + r * dir.x + const testY = avgY + r * dir.y + if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) { + if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) { + chosenBx = testX + chosenBy = testY + found = true + break + } + } + } + } + + if (!found) { + for (let r = 8; r < Math.max(gridWidth, gridHeight) && !found; r += 1) { + for (const dir of DIR_OFFSETS) { + const testX = avgX + r * dir.x + const testY = avgY + r * dir.y + if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) { + if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) { + chosenBx = testX + chosenBy = testY + found = true + break } } } } } - const hubCell = { x: hubBx * TILES_PER_BLOCK + 4, y: hubBy * TILES_PER_BLOCK + 4 } + return { + x: chosenBx * TILES_PER_BLOCK + 4 + origin.x, + y: chosenBy * TILES_PER_BLOCK + 4 + origin.y, + bx: chosenBx, + by: chosenBy, + } +} + +/** + * Constructs level bounding box closed cyclic linked list and deterministic grid-level paths + * (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_CalculatePathCoordinates`, D2MOO DrlgDrlgVer.cpp:15-226). + * + * 1. Constructs level bounding box closed cyclic linked list (4 corner vertices: (0,0), (w,0), (w,h), (0,h)). + * 2. For each orthogonal connection, inserts 1-2 vertices into that edge at connection span endpoints (v15/v16). + * 3. Flags: inserted vertex `dwFlags |= 1` (connection); if preset room `dwFlags |= 2`. + * 4. Coordinates: subtract level origin to convert to relative coordinates. + * 5. Consumes 0 RNG rolls! + */ +export function createDrlgVertices( + plan: Act1OutdoorPlan, + gridWidth: number, + gridHeight: number, + rng?: Rng, + origin: { x: number; y: number } = { x: 0, y: 0 }, +): DrlgVertexResult { + const tileW = gridWidth <= 32 ? gridWidth * TILES_PER_BLOCK : gridWidth + const tileH = gridHeight <= 32 ? gridHeight * TILES_PER_BLOCK : gridHeight + const gridW = gridWidth <= 32 ? gridWidth : Math.floor(gridWidth / TILES_PER_BLOCK) + const gridH = gridHeight <= 32 ? gridHeight : Math.floor(gridHeight / TILES_PER_BLOCK) + + // 1. Level bounding box closed cyclic linked list 4 corner vertices in clockwise order: + // V0: (0, 0) + // V1: (width, 0) + // V2: (width, height) + // V3: (0, height) + const v0: DrlgVertex = { + x: 0, + y: 0, + direction: ALTDIR_NORTH, + dwFlags: 0, + type: 'corner', + label: 'Corner V0 (NW)', + } + const v1: DrlgVertex = { + x: tileW, + y: 0, + direction: ALTDIR_EAST, + dwFlags: 0, + type: 'corner', + label: 'Corner V1 (NE)', + } + const v2: DrlgVertex = { + x: tileW, + y: tileH, + direction: ALTDIR_SOUTH, + dwFlags: 0, + type: 'corner', + label: 'Corner V2 (SE)', + } + const v3: DrlgVertex = { + x: 0, + y: tileH, + direction: ALTDIR_WEST, + dwFlags: 0, + type: 'corner', + label: 'Corner V3 (SW)', + } + + // 2. For each exterior connection / gate / orth connection on each of the 4 borders: + // Compute connection entry/exit span on that edge. + // Insert 1-2 vertices into that edge at connection span endpoints (v15/v16). + // dwFlags |= 1 (connection); if preset room dwFlags |= 2. + // Coordinates: subtract level origin to convert to relative coordinates. + const northVertices: DrlgVertex[] = [] + const eastVertices: DrlgVertex[] = [] + const southVertices: DrlgVertex[] = [] + const westVertices: DrlgVertex[] = [] + + const borderConnections = (plan as any).connections ?? plan.anchors ?? [] + for (const conn of borderConnections) { + const rawX = (conn.anchorCell ? conn.anchorCell.x : (conn.x ?? 0)) - origin.x + const rawY = (conn.anchorCell ? conn.anchorCell.y : (conn.y ?? 0)) - origin.y + const side = conn.side ?? ( + rawY <= 0 ? 'north' : + rawX >= tileW - 1 ? 'east' : + rawY >= tileH - 1 ? 'south' : + rawX <= 0 ? 'west' : undefined + ) + if (!side) continue + + const isPreset = conn.kind === 'preset' || (conn as any).isPreset === true || (conn.label && /preset/i.test(conn.label)) + const dwFlags = 1 | (isPreset ? 2 : 0) + const vType = isPreset ? 'preset' : 'gate' + const label = conn.label ?? (isPreset ? 'Preset Connection' : 'Gate Connection') + const id = conn.toLevelId ?? conn.id + + const span = (conn as any).span ?? ( + ((conn as any).v15 !== undefined && (conn as any).v16 !== undefined) + ? [(conn as any).v15, (conn as any).v16] + : undefined + ) + + if (side === 'north') { + if (span && span[0] !== span[1]) { + northVertices.push({ + x: Math.max(0, Math.min(tileW, span[0] - origin.x)), + y: 0, + direction: ALTDIR_NORTH, + dwFlags, + type: vType, + label: `${label} (Start)`, + id, + }) + northVertices.push({ + x: Math.max(0, Math.min(tileW, span[1] - origin.x)), + y: 0, + direction: ALTDIR_NORTH, + dwFlags, + type: vType, + label: `${label} (End)`, + id, + }) + } else { + const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX)) + northVertices.push({ + x, + y: 0, + direction: ALTDIR_NORTH, + dwFlags, + type: vType, + label, + id, + }) + } + } else if (side === 'east') { + if (span && span[0] !== span[1]) { + eastVertices.push({ + x: tileW, + y: Math.max(0, Math.min(tileH, span[0] - origin.y)), + direction: ALTDIR_EAST, + dwFlags, + type: vType, + label: `${label} (Start)`, + id, + }) + eastVertices.push({ + x: tileW, + y: Math.max(0, Math.min(tileH, span[1] - origin.y)), + direction: ALTDIR_EAST, + dwFlags, + type: vType, + label: `${label} (End)`, + id, + }) + } else { + const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY)) + eastVertices.push({ + x: tileW, + y, + direction: ALTDIR_EAST, + dwFlags, + type: vType, + label, + id, + }) + } + } else if (side === 'south') { + if (span && span[0] !== span[1]) { + southVertices.push({ + x: Math.max(0, Math.min(tileW, span[0] - origin.x)), + y: tileH, + direction: ALTDIR_SOUTH, + dwFlags, + type: vType, + label: `${label} (Start)`, + id, + }) + southVertices.push({ + x: Math.max(0, Math.min(tileW, span[1] - origin.x)), + y: tileH, + direction: ALTDIR_SOUTH, + dwFlags, + type: vType, + label: `${label} (End)`, + id, + }) + } else { + const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX)) + southVertices.push({ + x, + y: tileH, + direction: ALTDIR_SOUTH, + dwFlags, + type: vType, + label, + id, + }) + } + } else if (side === 'west') { + if (span && span[0] !== span[1]) { + westVertices.push({ + x: 0, + y: Math.max(0, Math.min(tileH, span[0] - origin.y)), + direction: ALTDIR_WEST, + dwFlags, + type: vType, + label: `${label} (Start)`, + id, + }) + westVertices.push({ + x: 0, + y: Math.max(0, Math.min(tileH, span[1] - origin.y)), + direction: ALTDIR_WEST, + dwFlags, + type: vType, + label: `${label} (End)`, + id, + }) + } else { + const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY)) + westVertices.push({ + x: 0, + y, + direction: ALTDIR_WEST, + dwFlags, + type: vType, + label, + id, + }) + } + } + } + + // Sort edges in clockwise order: + // North edge (V0 -> V1): X ascending + northVertices.sort((a, b) => a.x - b.x) + // East edge (V1 -> V2): Y ascending + eastVertices.sort((a, b) => a.y - b.y) + // South edge (V2 -> V3): X descending + southVertices.sort((a, b) => b.x - a.x) + // West edge (V3 -> V0): Y descending + westVertices.sort((a, b) => b.y - a.y) + + // Construct cyclic doubly linked list in clockwise order: + const cyclicVertices: DrlgVertex[] = [ + v0, + ...northVertices, + v1, + ...eastVertices, + v2, + ...southVertices, + v3, + ...westVertices, + ] + + for (let i = 0; i < cyclicVertices.length; i += 1) { + const curr = cyclicVertices[i]! + const next = cyclicVertices[(i + 1) % cyclicVertices.length]! + const prev = cyclicVertices[(i - 1 + cyclicVertices.length) % cyclicVertices.length]! + curr.next = next + curr.prev = prev + } + + // 3. Central Hub calculation (0 RNG rolls) + const hubInfo = DRLGOUTDOORS_CalculateHubCoordinates(plan, gridW, gridH, origin) + const hubCell = { x: hubInfo.x, y: hubInfo.y } const hubVertex: DrlgVertex = { x: hubCell.x, y: hubCell.y, type: 'hub', label: 'Central Hub', + direction: 4, + dwFlags: 0, } - const allVertices: DrlgVertex[] = [hubVertex] - - // 2. Add anchors and special presets - for (const anchor of plan.anchors) { - let vType: DrlgVertex['type'] = 'gate' - if (/waypoint/i.test(anchor.label)) { - vType = 'waypoint' - } else if (anchor.kind === 'preset') { - vType = 'preset' - } - allVertices.push({ - x: anchor.interiorCell.x, - y: anchor.interiorCell.y, - type: vType, - label: anchor.label, - id: anchor.toLevelId, - }) - } + // 4. Collect all vertices: cyclic boundary vertices + hub + interior presets + const allVertices: DrlgVertex[] = [...cyclicVertices, hubVertex] + // Add interior presets (if not already on the border) for (const preset of plan.specialPresets) { - const covered = plan.anchors.some(a => - a.label.toLowerCase().includes(preset.name.toLowerCase()) || - preset.name.toLowerCase().includes(a.label.toLowerCase()), - ) - if (!covered) { - const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) - const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) + const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) + origin.x + const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) + origin.y + const alreadyPresent = allVertices.some(v => v.label === preset.name || Math.hypot(v.x - px, v.y - py) < 4) + if (!alreadyPresent) { allVertices.push({ x: px, y: py, @@ -3275,114 +3719,111 @@ export function createDrlgVertices( } } - // 3. Connect each anchor to the central hub via Catmull-Rom spline with meander and obstacle steering + for (const anchor of plan.anchors) { + if (anchor.kind === 'preset') { + const px = anchor.interiorCell.x - origin.x + const py = anchor.interiorCell.y - origin.y + const alreadyPresent = allVertices.some(v => Math.hypot(v.x - px, v.y - py) < 4) + if (!alreadyPresent) { + allVertices.push({ + x: px, + y: py, + type: /waypoint/i.test(anchor.label) ? 'waypoint' : 'preset', + label: anchor.label, + id: anchor.toLevelId, + }) + } + } + } + + // 5. Grid Path Connection (replacing sampleCatmullRomSpline, 0 RNG): const polylines: { x: number; y: number }[][] = [] - const canvasBounds = { width: gridWidth * TILES_PER_BLOCK, height: gridHeight * TILES_PER_BLOCK } for (const anchor of plan.anchors) { - const startCell = anchor.interiorCell - const startBx = Math.floor(startCell.x / TILES_PER_BLOCK) - const startBy = Math.floor(startCell.y / TILES_PER_BLOCK) + // Snap border vertex to 8-cell grid: + const dir = anchor.side === 'west' ? ALTDIR_WEST : + anchor.side === 'north' ? ALTDIR_NORTH : + anchor.side === 'east' ? ALTDIR_EAST : + anchor.side === 'south' ? ALTDIR_SOUTH : undefined + let snappedStart = anchor.interiorCell + if (dir !== undefined) { + snappedStart = DRLGOUTDOORS_CalculatePathCoordinates({ + x: anchor.anchorCell.x, + y: anchor.anchorCell.y, + direction: dir, + }, origin) + } + + const startBx = Math.floor(snappedStart.x / TILES_PER_BLOCK) + const startBy = Math.floor(snappedStart.y / TILES_PER_BLOCK) + + // Deterministic A* block corridor avoiding obstacles const blockPath = findClearBlockCorridor( startBx, startBy, - hubBx, - hubBy, - gridWidth, - gridHeight, + hubInfo.bx, + hubInfo.by, + gridW, + gridH, plan.roadObstacleBlocks, ) - const controlPoints: { x: number; y: number }[] = [ + const waypoints: { x: number; y: number }[] = [ anchor.anchorCell, - anchor.interiorCell, + snappedStart, ] if (blockPath.length > 2) { - let lastDirX = blockPath[1]!.bx - blockPath[0]!.bx - let lastDirY = blockPath[1]!.by - blockPath[0]!.by - let blocksSinceControl = 1 - for (let i = 1; i < blockPath.length - 1; i += 1) { - const curr = blockPath[i]! - const next = blockPath[i + 1]! - const dirX = next.bx - curr.bx - const dirY = next.by - curr.by - const isTurn = dirX !== lastDirX || dirY !== lastDirY - blocksSinceControl += 1 - - if (isTurn || blocksSinceControl >= 3) { - const mx = rng.int(-1, 1) - const my = rng.int(-1, 1) - let cx = curr.bx * TILES_PER_BLOCK + 4 + mx - let cy = curr.by * TILES_PER_BLOCK + 4 + my - if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) { - cx = curr.bx * TILES_PER_BLOCK + 4 - cy = curr.by * TILES_PER_BLOCK + 4 - } - const inflectionVertex: DrlgVertex = { - x: cx, - y: cy, - type: 'inflection', - label: `Inflection (${anchor.label})`, - } - allVertices.push(inflectionVertex) - controlPoints.push({ x: cx, y: cy }) - lastDirX = dirX - lastDirY = dirY - blocksSinceControl = 0 - } + const b = blockPath[i]! + waypoints.push({ + x: b.bx * TILES_PER_BLOCK + 4 + origin.x, + y: b.by * TILES_PER_BLOCK + 4 + origin.y, + }) } - } else { - const dist = Math.hypot(hubCell.x - startCell.x, hubCell.y - startCell.y) - if (dist >= 14) { - const midX = Math.round((startCell.x + hubCell.x) / 2) - const midY = Math.round((startCell.y + hubCell.y) / 2) - const perpX = -(hubCell.y - startCell.y) / dist - const perpY = (hubCell.x - startCell.x) / dist - const meanderMag = rng.int(-2, 2) - let cx = Math.round(midX + perpX * meanderMag) - let cy = Math.round(midY + perpY * meanderMag) - if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) { - cx = midX - cy = midY + } + waypoints.push(hubCell) + + // Rasterize waypoints with Bresenham lines into a continuous grid-level line (0 RNG) + const pathLine: { x: number; y: number }[] = [] + for (let i = 0; i < waypoints.length - 1; i += 1) { + const p0 = waypoints[i]! + const p1 = waypoints[i + 1]! + const seg = bresenhamLine(p0.x, p0.y, p1.x, p1.y) + for (const pt of seg) { + const clampedX = Math.max(0, Math.min(tileW - 1, pt.x)) + const clampedY = Math.max(0, Math.min(tileH - 1, pt.y)) + const last = pathLine[pathLine.length - 1] + if (!last || last.x !== clampedX || last.y !== clampedY) { + pathLine.push({ x: clampedX, y: clampedY }) } - const inflectionVertex: DrlgVertex = { - x: cx, - y: cy, - type: 'inflection', - label: `Inflection (${anchor.label})`, - } - allVertices.push(inflectionVertex) - controlPoints.push({ x: cx, y: cy }) } } - controlPoints.push(hubCell) - - const splineLine = sampleCatmullRomSpline(controlPoints, canvasBounds, plan.roadObstacleBlocks) - polylines.push(splineLine) + polylines.push(pathLine) } return Object.assign([...allVertices], { vertices: allVertices, polylines, lines: polylines, + cyclicVertices, + head: v0, }) as DrlgVertexResult } export const DRLGVER_CreateVertices = createDrlgVertices /** - * Build smooth Catmull-Rom spline road polylines connecting all anchors to a central hub + * Build deterministic road polylines connecting all anchors to a central hub * (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`). */ export function buildOutdoorRoadPolylines( plan: Act1OutdoorPlan, gridWidth: number, gridHeight: number, - rng: Rng, + rng?: Rng, ): { x: number; y: number }[][] { return createDrlgVertices(plan, gridWidth, gridHeight, rng).polylines } diff --git a/tests/outdoor-vertices-path.test.ts b/tests/outdoor-vertices-path.test.ts new file mode 100644 index 0000000..15d380f --- /dev/null +++ b/tests/outdoor-vertices-path.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect } from 'vitest' +import { Rng } from '../src/game/rng.ts' +import { + ALTDIR_WEST, + ALTDIR_NORTH, + ALTDIR_EAST, + ALTDIR_SOUTH, + DRLGVER_CreateVertices, + DRLGOUTDOORS_CalculatePathCoordinates, + DRLGOUTDOORS_CalculateHubCoordinates, + DRLGOUTDOORS_TestGridCellSpawnValid, + bresenhamLine, + UNIMPLEMENTED_PASSES, + TILES_PER_BLOCK, +} from '../src/game/wilderness.ts' +import type { Act1OutdoorPlan, DrlgVertex } from '../src/game/wilderness.ts' + +describe('Outdoor Boundary Vertices & Grid Road Path (Issue #82)', () => { + function createMockPlan(overrides: Partial = {}): Act1OutdoorPlan { + const gridW = 4 + const gridH = 4 + return { + openGates: new Map(), + claimedBorderBlocks: new Set(), + claimedBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)), + roadObstacleBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)), + specialPresets: [], + gateOpenings: [], + anchors: [ + { + side: 'west', + anchorCell: { x: 0, y: 16 }, + interiorCell: { x: 4, y: 16 }, + label: 'Rogue Encampment Gate', + toLevelId: 1, + kind: 'gate', + }, + { + side: 'east', + anchorCell: { x: 31, y: 16 }, + interiorCell: { x: 27, y: 16 }, + label: 'Cold Plains Gate', + toLevelId: 3, + kind: 'gate', + }, + ], + ...overrides, + } + } + + describe('Unimplemented Passes Audit', () => { + it('verifies DRLGVER_CreateVertices is removed from UNIMPLEMENTED_PASSES', () => { + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices') + }) + }) + + describe('DRLGVER_CreateVertices: 0 RNG Rolls Guarantee', () => { + it('consumes exactly 0 RNG rolls during vertex and path construction', () => { + const rng = new Rng(0x12345678) + const initialSeed = rng.seed + const plan = createMockPlan() + + const result = DRLGVER_CreateVertices(plan, 4, 4, rng) + + expect(rng.seed).toBe(initialSeed) + expect(result.vertices.length).toBeGreaterThan(0) + expect(result.polylines.length).toBe(plan.anchors.length) + }) + }) + + describe('DRLGVER_CreateVertices: Closed Cyclic Linked List', () => { + it('constructs 4 corner vertices in clockwise order with relative coordinates', () => { + const plan = createMockPlan({ anchors: [] }) + const gridW = 4 + const gridH = 4 + const tileW = gridW * TILES_PER_BLOCK + const tileH = gridH * TILES_PER_BLOCK + const origin = { x: 100, y: 200 } + + const result = DRLGVER_CreateVertices(plan, gridW, gridH, undefined, origin) + const cyclic = result.cyclicVertices! + + expect(cyclic).toBeDefined() + expect(cyclic.length).toBe(4) + + const [v0, v1, v2, v3] = cyclic + expect(v0).toMatchObject({ x: 0, y: 0, direction: ALTDIR_NORTH, dwFlags: 0, type: 'corner' }) + expect(v1).toMatchObject({ x: tileW, y: 0, direction: ALTDIR_EAST, dwFlags: 0, type: 'corner' }) + expect(v2).toMatchObject({ x: tileW, y: tileH, direction: ALTDIR_SOUTH, dwFlags: 0, type: 'corner' }) + expect(v3).toMatchObject({ x: 0, y: tileH, direction: ALTDIR_WEST, dwFlags: 0, type: 'corner' }) + + // Check next pointers (clockwise) + expect(v0.next).toBe(v1) + expect(v1.next).toBe(v2) + expect(v2.next).toBe(v3) + expect(v3.next).toBe(v0) + + // Check prev pointers (counter-clockwise) + expect(v0.prev).toBe(v3) + expect(v3.prev).toBe(v2) + expect(v2.prev).toBe(v1) + expect(v1.prev).toBe(v0) + }) + + it('inserts border connection vertices into appropriate edges with correct dwFlags and order', () => { + const plan = createMockPlan({ + anchors: [ + { + side: 'north', + anchorCell: { x: 16, y: 0 }, + interiorCell: { x: 16, y: 4 }, + label: 'North Gate', + toLevelId: 4, + kind: 'gate', + }, + { + side: 'east', + anchorCell: { x: 32, y: 20 }, + interiorCell: { x: 28, y: 20 }, + label: 'East Preset', + toLevelId: 5, + kind: 'preset', + }, + { + side: 'south', + anchorCell: { x: 24, y: 32 }, + interiorCell: { x: 24, y: 28 }, + label: 'South Gate', + toLevelId: 6, + kind: 'gate', + }, + { + side: 'west', + anchorCell: { x: 0, y: 12 }, + interiorCell: { x: 4, y: 12 }, + label: 'West Gate', + toLevelId: 7, + kind: 'gate', + }, + ], + }) + + const result = DRLGVER_CreateVertices(plan, 4, 4) + const cyclic = result.cyclicVertices! + + // 4 corners + 4 inserted border vertices = 8 vertices + expect(cyclic.length).toBe(8) + + // Verify full cycle traversal via next + let curr = result.head! + const visitedForward: DrlgVertex[] = [] + for (let i = 0; i < cyclic.length; i += 1) { + visitedForward.push(curr) + curr = curr.next! + } + expect(curr).toBe(result.head!) + expect(visitedForward.length).toBe(8) + + // Verify full cycle traversal via prev + let prevNode = result.head! + const visitedBackward: DrlgVertex[] = [] + for (let i = 0; i < cyclic.length; i += 1) { + visitedBackward.push(prevNode) + prevNode = prevNode.prev! + } + expect(prevNode).toBe(result.head!) + + // Find inserted vertices and verify flags: + // Gate: dwFlags = 1 (connection) + // Preset: dwFlags = 3 (1 = connection | 2 = preset) + const northV = cyclic.find(v => v.direction === ALTDIR_NORTH && v.type === 'gate') + expect(northV).toBeDefined() + expect(northV!.dwFlags).toBe(1) + expect(northV!.x).toBe(16) + expect(northV!.y).toBe(0) + + const eastV = cyclic.find(v => v.direction === ALTDIR_EAST && v.type === 'preset') + expect(eastV).toBeDefined() + expect(eastV!.dwFlags).toBe(3) // dwFlags |= 1 | 2 + expect(eastV!.x).toBe(32) + expect(eastV!.y).toBe(20) + + const southV = cyclic.find(v => v.direction === ALTDIR_SOUTH && v.type === 'gate') + expect(southV).toBeDefined() + expect(southV!.dwFlags).toBe(1) + expect(southV!.x).toBe(24) + expect(southV!.y).toBe(32) + + const westV = cyclic.find(v => v.direction === ALTDIR_WEST && v.type === 'gate') + expect(westV).toBeDefined() + expect(westV!.dwFlags).toBe(1) + expect(westV!.x).toBe(0) + expect(westV!.y).toBe(12) + }) + + it('inserts span endpoint vertices [v15, v16] when provided for wide connections', () => { + const plan = createMockPlan({ + anchors: [ + { + side: 'north', + anchorCell: { x: 16, y: 0 }, + interiorCell: { x: 16, y: 4 }, + label: 'North River Span', + toLevelId: 4, + kind: 'preset', + span: [12, 20], + } as any, + ], + }) + + const result = DRLGVER_CreateVertices(plan, 4, 4) + const cyclic = result.cyclicVertices! + + // 4 corners + 2 span vertices = 6 + expect(cyclic.length).toBe(6) + + const northSpans = cyclic.filter(v => v.direction === ALTDIR_NORTH && v.type === 'preset') + expect(northSpans.length).toBe(2) + expect(northSpans[0]!.x).toBe(12) + expect(northSpans[1]!.x).toBe(20) + expect(northSpans[0]!.label).toContain('(Start)') + expect(northSpans[1]!.label).toContain('(End)') + }) + }) + + describe('DRLGOUTDOORS_CalculatePathCoordinates', () => { + it('correctly snaps coordinates according to 8-cell grid rules for all 4 directions', () => { + const origin = { x: 0, y: 0 } + + // ALTDIR_WEST (0): relX = 8 * Math.trunc(relX / 8) + 11 + const westSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ + x: 0, + y: 16, + direction: ALTDIR_WEST, + }, origin) + expect(westSnapped.x).toBe(8 * Math.trunc(0 / 8) + 11) // 11 + expect(westSnapped.y).toBe(16) + + // ALTDIR_NORTH (1): relY = 8 * Math.trunc(relY / 8) + 11 + const northSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ + x: 16, + y: 0, + direction: ALTDIR_NORTH, + }, origin) + expect(northSnapped.x).toBe(16) + expect(northSnapped.y).toBe(8 * Math.trunc(0 / 8) + 11) // 11 + + // ALTDIR_EAST (2): relX = 8 * Math.trunc(relX / 8) - 5 + const eastSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ + x: 32, + y: 16, + direction: ALTDIR_EAST, + }, origin) + expect(eastSnapped.x).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27 + expect(eastSnapped.y).toBe(16) + + // ALTDIR_SOUTH (3): relY = 8 * Math.trunc(relY / 8) - 5 + const southSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ + x: 16, + y: 32, + direction: ALTDIR_SOUTH, + }, origin) + expect(southSnapped.x).toBe(16) + expect(southSnapped.y).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27 + }) + + it('supports D2MOO C++ style (pLevel, pVertex1, pVertex2) signature and populates pVertex2', () => { + const mockLevel = { nPosX: 10, nPosY: 20 } + const pVertex1 = { nPosX: 42, nPosY: 20, nDirection: ALTDIR_EAST } + const pVertex2: { nPosX?: number; nPosY?: number; x?: number; y?: number } = {} + + const res = DRLGOUTDOORS_CalculatePathCoordinates(mockLevel, pVertex1, pVertex2) + + // relX = 42 - 10 = 32 -> 8 * trunc(32 / 8) - 5 = 27 -> 27 + 10 = 37 + // relY = 20 - 20 = 0 -> 0 + 20 = 20 + expect(res.x).toBe(37) + expect(res.y).toBe(20) + expect(pVertex2.x).toBe(37) + expect(pVertex2.y).toBe(20) + expect(pVertex2.nPosX).toBe(37) + expect(pVertex2.nPosY).toBe(20) + }) + }) + + describe('DRLGOUTDOORS_TestGridCellSpawnValid', () => { + it('validates cell bitmask 0x1B81 and road obstacle blocks', () => { + const plan = { + gridFlags: [ + [0, 0x0001], + [0x0080, 0x1000], + ], + roadObstacleBlocks: [ + [false, false], + [false, true], + ], + } + + // (0,0) has 0 -> valid + expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 0, plan)).toBe(true) + // (1,0) has 0x0001 (bit 0 in 0x1B81) -> invalid + expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 0, plan)).toBe(false) + // (0,1) has 0x0080 (bit 7 in 0x1B81) -> invalid + expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 1, plan)).toBe(false) + // (1,1) has roadObstacleBlocks = true -> invalid + expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 1, plan)).toBe(false) + }) + }) + + describe('DRLGOUTDOORS_CalculateHubCoordinates', () => { + it('returns center of level when nVertices === 1', () => { + const plan = createMockPlan({ + anchors: [ + { + side: 'west', + anchorCell: { x: 0, y: 16 }, + interiorCell: { x: 4, y: 16 }, + label: 'Single Gate', + kind: 'gate', + }, + ], + }) + + const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6) + expect(hub.bx).toBe(3) // floor(6 / 2) + expect(hub.by).toBe(3) // floor(6 / 2) + expect(hub.x).toBe(3 * TILES_PER_BLOCK + 4) + expect(hub.y).toBe(3 * TILES_PER_BLOCK + 4) + }) + + it('anchors to bridge coordinates when bridge flag is set', () => { + const plan = createMockPlan({ + dwFlags: 0x10, + specialPresets: [ + { + name: 'Blood Moor Bridge', + bx: 2, + by: 3, + blocksX: 1, + blocksY: 1, + ds1: {} as any, + }, + ], + } as any) + + const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6) + expect(hub.bx).toBe(2) + expect(hub.by).toBe(3) + expect(hub.x).toBe(8 * 2 + 3) // 19 + expect(hub.y).toBe(8 * 3 + 3) // 27 + }) + + it('computes average coordinates and spirals outward radius 0..7 avoiding obstacles', () => { + // 2 anchors on west and east borders + // anchor 1: cell (0, 8) -> bx = 0, by = 1 + // anchor 2: cell (32, 24) -> bx = 4, by = 3 + // sumX = 0 + 32 = 32 -> avgX = floor(32 / 16) = 2 + // sumY = 8 + 24 = 32 -> avgY = floor(32 / 16) = 2 + // If (2, 2) is blocked by road obstacle, spiral radius 1 tests [-1,0] -> (1, 2) + const obstacleBlocks = Array.from({ length: 5 }, () => Array(5).fill(false)) + obstacleBlocks[2]![2] = true // Block (2, 2) + + const plan = createMockPlan({ + anchors: [ + { + side: 'west', + anchorCell: { x: 0, y: 8 }, + interiorCell: { x: 4, y: 8 }, + label: 'West', + kind: 'gate', + }, + { + side: 'east', + anchorCell: { x: 32, y: 24 }, + interiorCell: { x: 28, y: 24 }, + label: 'East', + kind: 'gate', + }, + ], + roadObstacleBlocks: obstacleBlocks, + }) + + const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 5, 5) + // Radius 1 first direction is { x: -1, y: 0 } -> testX = 2 - 1 = 1, testY = 2 + expect(hub.bx).toBe(1) + expect(hub.by).toBe(2) + }) + }) + + describe('Bresenham Grid Line Rasterization', () => { + it('produces deterministic contiguous grid line without diagonal gaps', () => { + const points = bresenhamLine(2, 3, 7, 6) + + expect(points.length).toBeGreaterThan(0) + expect(points[0]).toEqual({ x: 2, y: 3 }) + expect(points[points.length - 1]).toEqual({ x: 7, y: 6 }) + + for (let i = 1; i < points.length; i += 1) { + const pPrev = points[i - 1]! + const pCurr = points[i]! + const dx = Math.abs(pCurr.x - pPrev.x) + const dy = Math.abs(pCurr.y - pPrev.y) + expect(dx).toBeLessThanOrEqual(1) + expect(dy).toBeLessThanOrEqual(1) + expect(dx + dy).toBeGreaterThan(0) + } + }) + + it('guarantees deterministic identical output across runs with 0 RNG', () => { + const line1 = bresenhamLine(10, 20, 50, 80) + const line2 = bresenhamLine(10, 20, 50, 80) + expect(line1).toEqual(line2) + }) + }) +}) diff --git a/tests/wilderness-acts.test.ts b/tests/wilderness-acts.test.ts index a9c30eb..5b50c50 100644 --- a/tests/wilderness-acts.test.ts +++ b/tests/wilderness-acts.test.ts @@ -70,8 +70,8 @@ describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => { expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel') expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel') expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles') + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices') expect(UNIMPLEMENTED_PASSES).toEqual([ - 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', 'DRLGOUTDOORS_SpawnAct3Mephisto', ])