From 9c2e3ca81c401bc6c72033957ac1746e4d212ca6 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sun, 27 Sep 2026 06:09:01 +0000 Subject: [PATCH] fix(drop): prevent items and gold from dropping into player-unreachable locations (Fixes #492) --- src/game/engine.ts | 19 + src/game/ground-items.ts | 723 +++++++++++++++++++++--- src/scene/net-scene.ts | 21 + tests/ground-items-reachability.test.ts | 324 +++++++++++ 4 files changed, 1016 insertions(+), 71 deletions(-) create mode 100644 tests/ground-items-reachability.test.ts diff --git a/src/game/engine.ts b/src/game/engine.ts index e1b6625..fd8c724 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -1452,12 +1452,19 @@ export class GameEngine { const currentSimTime = this.world.tick * 40 const spawned: GroundItemEntity[] = [] + const reachableFrom = { x: this.world.player.x, y: this.world.player.y } + for (const drop of droppedItems) { const dropPos = findIsometricDropPosition( pending.x, pending.y, burstOccupied, this.terrain, + undefined, + undefined, + undefined, + undefined, + reachableFrom, ) burstOccupied.push(dropPos) const dropCell = worldToCell(this.terrain, dropPos.x, dropPos.y) @@ -1486,11 +1493,17 @@ export class GameEngine { dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity { const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y + const reachableFrom = { x: this.world.player.x, y: this.world.player.y } const dropPos = findIsometricDropPosition( rawX, rawY, this.groundItems.all, this.terrain, + undefined, + undefined, + undefined, + undefined, + reachableFrom, ) const finalX = dropPos.x const finalY = dropPos.y @@ -1518,11 +1531,17 @@ export class GameEngine { if (amount <= 0) return null const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y + const reachableFrom = { x: this.world.player.x, y: this.world.player.y } const dropPos = findIsometricDropPosition( rawX, rawY, this.groundItems.all, this.terrain, + undefined, + undefined, + undefined, + undefined, + reachableFrom, ) const finalX = dropPos.x const finalY = dropPos.y diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index 8d72c78..5ffce18 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -15,7 +15,10 @@ import type { Item } from './items.ts' import { COLLIDE_WALL, COLLIDE_BLANK, + COLLIDE_WATER, + COLLIDE_NO_PATH, COLLIDE_MASK_INVALID, + COLLIDE_MASK_PLAYER_PATH, COLLIDE_NONE, COLLIDE_ITEM, COLLIDE_MASK_SPAWN, @@ -24,9 +27,11 @@ import { ORTHO_CELL_HEIGHT, ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, + getCollisionMask, getCollisionMaskAt, isBlockedAt, rayTraceScene, + subTileAt, } from './d2map.ts' import type { CollisionGrid } from './d2map.ts' import { @@ -38,6 +43,21 @@ import { import { isRuneCode } from './gems-data.ts' import { getEmbeddedDropTables } from './embedded-drop-tables.ts' +/** + * Composite collision mask for ground item & gold drops. + * Combines D2Common.dll `COLLISION_GetFreeCoordinatesImpl` (`COLLIDE_MASK_SPAWN = 0x3E01`) + * with player walkability masks (`COLLIDE_MASK_PLAYER_PATH`, `COLLIDE_BLANK`, `COLLIDE_WATER`, + * `COLLIDE_MASK_INVALID`, `COLLIDE_NO_PATH`) so items never land on tiles a player cannot walk onto. + */ +export const COLLIDE_MASK_DROP_BLOCK = + COLLIDE_MASK_SPAWN | + COLLIDE_MASK_PLAYER_PATH | + COLLIDE_BLANK | + COLLIDE_WATER | + COLLIDE_MASK_INVALID | + COLLIDE_NO_PATH + + export interface GroundItemBounceState { startTime: number durationMs: number @@ -383,7 +403,7 @@ export function triggerFlippyBounce( /** * Concentric quadrant spiral neighbor search around origin (D2Common.dll ITEM_Drop / COLLISION_GetFreeCoordinatesImpl parity). * Searches radius 0, 1, 2, ... up to maxRadius in authentic 1.13c quadrant order for the first walkable cell - * not blocked by COLLIDE_WALL, COLLIDE_BLANK, or COLLIDE_MASK_INVALID, and not occupied by existing items. + * not blocked by COLLIDE_MASK_DROP_BLOCK or player overlap, 4-connected to the drop/player room, and not occupied by existing items. */ export function findSafeDropPosition( grid: any, @@ -391,22 +411,23 @@ export function findSafeDropPosition( originCellY: number, maxRadius = 3, occupied?: readonly { cellX?: number; cellY?: number; x?: number; y?: number }[] | Set | ((cx: number, cy: number) => boolean), + reachableFromCell?: { cellX: number; cellY: number }, ): { cellX: number; cellY: number; x: number; y: number } { const targetGrid = grid?.grid ?? grid const isBlocked = (cx: number, cy: number): boolean => { - if (!targetGrid) return false + if (!targetGrid && typeof grid?.overlap !== 'function') return false // 1. Function check (e.g. overlap callback or mock in tests) if (typeof targetGrid === 'function') { return targetGrid(cx, cy) !== 0 } - if (typeof targetGrid.isBlocked === 'function') { + if (typeof targetGrid?.isBlocked === 'function') { return targetGrid.isBlocked(cx, cy) } // 2. Real CollisionGrid (cellsX, cellsY, gridWidth, blocked, optional collisionMasks) - if (typeof targetGrid.cellsX === 'number' && typeof targetGrid.cellsY === 'number') { + if (typeof targetGrid?.cellsX === 'number' && typeof targetGrid?.cellsY === 'number') { if (cx < 0 || cy < 0 || cx >= targetGrid.cellsX || cy >= targetGrid.cellsY) { return true // Out of map bounds -> COLLIDE_MASK_INVALID } @@ -418,18 +439,35 @@ export function findSafeDropPosition( if (targetGrid.collisionMasks) { const mask = targetGrid.collisionMasks[subIdx] ?? COLLIDE_NONE - if ((mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0) { + if ((mask & COLLIDE_MASK_DROP_BLOCK) !== 0) { return true } } if (targetGrid.blocked && targetGrid.blocked[subIdx] === 1) { return true } + if (typeof grid?.overlap === 'function' && grid.widthPx !== undefined && grid.heightPx !== undefined) { + const oX = typeof grid.originX === 'number' + ? grid.originX + : typeof targetGrid.originX === 'number' + ? targetGrid.originX + : 0 + const oY = typeof grid.originY === 'number' + ? grid.originY + : typeof targetGrid.originY === 'number' + ? targetGrid.originY + : 0 + const wx = oX + (cx - cy) * ORTHO_CELL_WIDTH + const wy = oY + (cx + cy) * ORTHO_CELL_HEIGHT + if (grid.overlap(wx, wy) > 0) { + return true + } + } return false } // 3. TypedArray directly (e.g. new Uint8Array(400) or new Uint16Array(width * height)) - if (ArrayBuffer.isView(targetGrid)) { + if (targetGrid && ArrayBuffer.isView(targetGrid)) { if (cx < 0 || cy < 0) return true const width = typeof (targetGrid as any).width === 'number' ? (targetGrid as any).width @@ -445,11 +483,11 @@ export function findSafeDropPosition( if (height > 0 && cy >= height) return true const idx = cy * width + cx const val = (targetGrid as any)[idx] ?? COLLIDE_NONE - return (val & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0 || val === 1 + return (val & COLLIDE_MASK_DROP_BLOCK) !== 0 || val === 1 } // 4. Test grid object with cells or collisionMasks array - if (targetGrid.cells || targetGrid.collisionMasks) { + if (targetGrid?.cells || targetGrid?.collisionMasks) { if (cx < 0 || cy < 0) return true const width = targetGrid.width ?? targetGrid.cellsX ?? 0 const height = targetGrid.height ?? targetGrid.cellsY ?? 0 @@ -458,19 +496,20 @@ export function findSafeDropPosition( const idx = cy * width + cx const mask = (targetGrid.collisionMasks ? targetGrid.collisionMasks[idx] : targetGrid.cells[idx]) ?? COLLIDE_NONE - return (mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0 + return (mask & COLLIDE_MASK_DROP_BLOCK) !== 0 } // 5. CountingTerrain / WorldMapProvider overlap callback - if (typeof targetGrid.overlap === 'function') { - if (targetGrid.widthPx !== undefined && targetGrid.heightPx !== undefined) { - const originX = typeof targetGrid.originX === 'number' ? targetGrid.originX : 0 - const originY = typeof targetGrid.originY === 'number' ? targetGrid.originY : 0 + const overlapTarget = typeof grid?.overlap === 'function' ? grid : targetGrid + if (typeof overlapTarget?.overlap === 'function') { + if (overlapTarget.widthPx !== undefined && overlapTarget.heightPx !== undefined) { + const originX = typeof overlapTarget.originX === 'number' ? overlapTarget.originX : 0 + const originY = typeof overlapTarget.originY === 'number' ? overlapTarget.originY : 0 const wx = originX + (cx - cy) * ORTHO_CELL_WIDTH const wy = originY + (cx + cy) * ORTHO_CELL_HEIGHT - return targetGrid.overlap(wx, wy) > 0 + return overlapTarget.overlap(wx, wy) > 0 } - return targetGrid.overlap(cx, cy) !== 0 + return overlapTarget.overlap(cx, cy) !== 0 } return false @@ -504,19 +543,137 @@ export function findSafeDropPosition( return occupiedSet.has(`${cx},${cy}`) } - let chosenCellX = originCellX - let chosenCellY = originCellY + // Optional 4-connected reachability from reachableFromCell + let baseCellX = originCellX + let baseCellY = originCellY + let playerReachableCells: Set | null = null + + if ( + reachableFromCell && + Number.isFinite(reachableFromCell.cellX) && + Number.isFinite(reachableFromCell.cellY) && + !isBlocked(reachableFromCell.cellX, reachableFromCell.cellY) + ) { + const startX = reachableFromCell.cellX + const startY = reachableFromCell.cellY + const margin = Math.max(maxRadius + 4, 12) + const minX = Math.min(startX, originCellX) - margin + const maxX = Math.max(startX, originCellX) + margin + const minY = Math.min(startY, originCellY) - margin + const maxY = Math.max(startY, originCellY) + margin + + playerReachableCells = new Set([`${startX},${startY}`]) + const queue: [number, number][] = [[startX, startY]] + let head = 0 + while (head < queue.length && queue.length < 4096) { + const [cx, cy] = queue[head++]! + const neighbors: [number, number][] = [ + [cx - 1, cy], + [cx + 1, cy], + [cx, cy - 1], + [cx, cy + 1], + ] + for (const [nx, ny] of neighbors) { + if (nx < minX || nx > maxX || ny < minY || ny > maxY) continue + const key = `${nx},${ny}` + if (playerReachableCells.has(key)) continue + if (isBlocked(nx, ny)) continue + playerReachableCells.add(key) + queue.push([nx, ny]) + } + } + + if (!playerReachableCells.has(`${originCellX},${originCellY}`)) { + // Snap baseCell to the reachable cell closest to originCell + let bestX = startX + let bestY = startY + let bestL1 = Math.abs(startX - originCellX) + Math.abs(startY - originCellY) + let bestDistSq = (startX - originCellX) ** 2 + (startY - originCellY) ** 2 + for (const [rx, ry] of queue) { + const l1 = Math.abs(rx - originCellX) + Math.abs(ry - originCellY) + const dSq = (rx - originCellX) ** 2 + (ry - originCellY) ** 2 + if (l1 < bestL1 || (l1 === bestL1 && dSq < bestDistSq)) { + bestL1 = l1 + bestDistSq = dSq + bestX = rx + bestY = ry + } + } + baseCellX = bestX + baseCellY = bestY + } + } + + const baseWalkable = !isBlocked(baseCellX, baseCellY) + let anchorConnectedCells: Set | null = null + const isConnectedToBase = (cx: number, cy: number): boolean => { + if (playerReachableCells !== null) { + return playerReachableCells.has(`${cx},${cy}`) + } + if (!baseWalkable) return true + if (cx === baseCellX && cy === baseCellY) return true + + // Fast greedy 4-way walk check from (baseCellX, baseCellY) to (cx, cy) + let curX = baseCellX + let curY = baseCellY + let greedyOk = true + while (curX !== cx || curY !== cy) { + const remX = Math.abs(cx - curX) + const remY = Math.abs(cy - curY) + const stepX = curX < cx ? 1 : -1 + const stepY = curY < cy ? 1 : -1 + if (remX >= remY && remX > 0 && !isBlocked(curX + stepX, curY)) { + curX += stepX + } else if (remY > 0 && !isBlocked(curX, curY + stepY)) { + curY += stepY + } else if (remX > 0 && !isBlocked(curX + stepX, curY)) { + curX += stepX + } else { + greedyOk = false + break + } + } + if (greedyOk) return true + + if (anchorConnectedCells === null) { + const bound = Math.max(maxRadius + 2, 6) + anchorConnectedCells = new Set([`${baseCellX},${baseCellY}`]) + const q: [number, number][] = [[baseCellX, baseCellY]] + let h = 0 + while (h < q.length && q.length < 1024) { + const [qx, qy] = q[h++]! + const nbs: [number, number][] = [ + [qx - 1, qy], + [qx + 1, qy], + [qx, qy - 1], + [qx, qy + 1], + ] + for (const [nx, ny] of nbs) { + if (Math.abs(nx - baseCellX) > bound || Math.abs(ny - baseCellY) > bound) continue + const k = `${nx},${ny}` + if (anchorConnectedCells.has(k)) continue + if (isBlocked(nx, ny)) continue + anchorConnectedCells.add(k) + q.push([nx, ny]) + } + } + } + return anchorConnectedCells.has(`${cx},${cy}`) + } + + let chosenCellX = baseCellX + let chosenCellY = baseCellY let found = false let firstWalkableOccupied: { cx: number; cy: number } | null = null - // Ring 0: check origin cell - if (!isBlocked(originCellX, originCellY)) { - if (!isCellOccupied(originCellX, originCellY)) { - chosenCellX = originCellX - chosenCellY = originCellY + // Ring 0: check base cell + if (baseWalkable && isConnectedToBase(baseCellX, baseCellY)) { + if (!isCellOccupied(baseCellX, baseCellY)) { + chosenCellX = baseCellX + chosenCellY = baseCellY found = true } else { - firstWalkableOccupied = { cx: originCellX, cy: originCellY } + firstWalkableOccupied = { cx: baseCellX, cy: baseCellY } } } @@ -525,10 +682,10 @@ export function findSafeDropPosition( for (let r = 1; r <= maxRadius; r += 1) { const ringCandidates = getIsometricRingCandidates(r) for (const cand of ringCandidates) { - const cx = originCellX + cand.dx - const cy = originCellY + cand.dy + const cx = baseCellX + cand.dx + const cy = baseCellY + cand.dy - if (isBlocked(cx, cy)) { + if (isBlocked(cx, cy) || !isConnectedToBase(cx, cy)) { continue } @@ -548,6 +705,32 @@ export function findSafeDropPosition( } } + // Extended escape search when origin itself is blocked and maxRadius > 0 + if (!found && firstWalkableOccupied === null && !baseWalkable && maxRadius > 0) { + const extendedMax = Math.max(maxRadius, 15) + for (let r = maxRadius + 1; r <= extendedMax; r += 1) { + const ringCandidates = getIsometricRingCandidates(r) + for (const cand of ringCandidates) { + const cx = baseCellX + cand.dx + const cy = baseCellY + cand.dy + if (isBlocked(cx, cy) || !isConnectedToBase(cx, cy)) { + continue + } + if (isCellOccupied(cx, cy)) { + if (firstWalkableOccupied === null) { + firstWalkableOccupied = { cx, cy } + } + continue + } + chosenCellX = cx + chosenCellY = cy + found = true + break + } + if (found) break + } + } + // Authentic Fallback: // If all candidate cells within maxRadius are occupied, stack on first walkable floor cell // rather than escaping into void or walls. @@ -580,8 +763,142 @@ export function findSafeDropPosition( } } +/** Extract a CollisionGrid from either a direct CollisionGrid or a CountingTerrain wrapper. */ +function extractCollisionGrid(isBlocked: any): CollisionGrid | undefined { + if (!isBlocked || typeof isBlocked !== 'object') return undefined + if (isBlocked.collisionMasks !== undefined || isBlocked.gridWidth !== undefined) { + return isBlocked as CollisionGrid + } + if (isBlocked.grid && (isBlocked.grid.collisionMasks !== undefined || isBlocked.grid.gridWidth !== undefined)) { + return isBlocked.grid as CollisionGrid + } + return undefined +} + /** - * Helper to check whether a candidate world position is blocked by collision or Line-of-Sight. + * Checks whether a single world coordinate `(x, y)` is blocked by terrain collision masks, + * solid sub-tiles, or the player's physical bounding box (`overlap`). + */ +export function isTerrainPointBlocked( + x: number, + y: number, + isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any, +): boolean { + if (!isBlocked) return false + + const grid = extractCollisionGrid(isBlocked) + if (grid !== undefined) { + const mask = getCollisionMaskAt(grid, x, y) + if ((mask & COLLIDE_MASK_DROP_BLOCK) !== 0) { + return true + } + if (grid.blocked && isBlockedAt(grid, x, y)) { + return true + } + } + + if (typeof isBlocked.overlap === 'function') { + if (isBlocked.overlap(x, y) !== 0) { + return true + } + } + + if (typeof isBlocked === 'function') { + if (isBlocked(x, y)) { + return true + } + } + + if (typeof isBlocked.isBlocked === 'function') { + if (isBlocked.isBlocked(x, y)) { + return true + } + } + + return false +} + +/** + * Checks whether a sub-tile ray from `(fromX, fromY)` to `(toX, toY)` is blocked by + * `COLLIDE_MASK_SPAWN_LOS`, solid `blocked` sub-tiles, or diagonal wall-corner clipping + * where two orthogonal wall sub-tiles meet at a diagonal corner. + */ +function isSubTileRayBlocked( + grid: CollisionGrid, + fromX: number, + fromY: number, + toX: number, + toY: number, +): boolean { + const los = rayTraceScene(grid, fromX, fromY, toX, toY, COLLIDE_MASK_SPAWN_LOS) + if (los.hit) { + return true + } + + const start = subTileAt(grid, fromX, fromY) + const end = subTileAt(grid, toX, toY) + const gridHeight = grid.cellsY * 5 + + const isSubTileSolid = (subX: number, subY: number): boolean => { + if (subX < 0 || subY < 0 || subX >= grid.gridWidth || subY >= gridHeight) { + return true + } + const mask = getCollisionMask(grid, subX, subY) + if ((mask & (COLLIDE_MASK_SPAWN_LOS | COLLIDE_MASK_DROP_BLOCK)) !== 0) { + return true + } + if (grid.blocked && grid.blocked[subY * grid.gridWidth + subX] === 1) { + return true + } + return false + } + + let x0 = start.subX + let y0 = start.subY + const x1 = end.subX + const y1 = end.subY + const dx = Math.abs(x1 - x0) + const dy = Math.abs(y1 - y0) + const sx = x0 < x1 ? 1 : -1 + const sy = y0 < y1 ? 1 : -1 + let err = dx - dy + let steps = 0 + + while (true) { + if (steps > 0 && isSubTileSolid(x0, y0)) { + return true + } + if (x0 === x1 && y0 === y1) { + break + } + const e2 = 2 * err + const stepX = e2 > -dy + const stepY = e2 < dx + + if (stepX && stepY) { + // Diagonal step across sub-tile corner: block if both shared cardinal neighbors are solid + if (isSubTileSolid(x0 + sx, y0) && isSubTileSolid(x0, y0 + sy)) { + return true + } + } + + if (stepX) { + err -= dy + x0 += sx + } + if (stepY) { + err += dx + y0 += sy + } + steps += 1 + } + + return false +} + +/** + * Helper to check whether a candidate world position is blocked by collision, Line-of-Sight, + * or diagonal wall-corner pinching. */ export function isDropPositionBlocked( candX: number, @@ -592,52 +909,38 @@ export function isDropPositionBlocked( ): boolean { if (!isBlocked) return false - // 1. If it's a CollisionGrid or has a collision grid - const grid: CollisionGrid | undefined = - isBlocked.collisionMasks !== undefined || isBlocked.gridWidth !== undefined - ? isBlocked - : isBlocked.grid?.collisionMasks !== undefined || isBlocked.grid?.gridWidth !== undefined - ? isBlocked.grid - : undefined + if (isTerrainPointBlocked(candX, candY, isBlocked)) { + return true + } + if (candX === originX && candY === originY) { + return false + } + + const grid = extractCollisionGrid(isBlocked) if (grid !== undefined) { - const mask = getCollisionMaskAt(grid, candX, candY) - if ((mask & COLLIDE_MASK_SPAWN) !== 0) { - return true - } - if (grid.blocked && isBlockedAt(grid, candX, candY)) { - return true - } - const los = rayTraceScene(grid, originX, originY, candX, candY, COLLIDE_MASK_SPAWN_LOS) - if (los.hit) { + if (isSubTileRayBlocked(grid, originX, originY, candX, candY)) { return true } } - // 2. CountingTerrain overlap check - if (typeof isBlocked.overlap === 'function') { - if (isBlocked.overlap(candX, candY) !== 0) { - return true - } - } - - // 3. CountingTerrain LoS check if (typeof isBlocked.hasLineOfSight === 'function') { if (!isBlocked.hasLineOfSight(originX, originY, candX, candY)) { return true } } - // 4. Function callback (e.g. (x, y) => boolean) - if (typeof isBlocked === 'function') { - if (isBlocked(candX, candY)) { - return true - } - } - - // 5. General isBlocked method - if (typeof isBlocked.isBlocked === 'function') { - if (isBlocked.isBlocked(candX, candY)) { + // Prevent diagonal corner-clipping on non-grid callbacks for 1-step diagonal sub-tile offsets + const relX = candX - originX + const relY = candY - originY + const dx = Math.round((relX / ORTHO_SUB_TILE_WIDTH + relY / ORTHO_SUB_TILE_HEIGHT) / 2) + const dy = Math.round((relY / ORTHO_SUB_TILE_HEIGHT - relX / ORTHO_SUB_TILE_WIDTH) / 2) + if (Math.abs(dx) === 1 && Math.abs(dy) === 1) { + const c1x = originX + dx * ORTHO_SUB_TILE_WIDTH + const c1y = originY + dx * ORTHO_SUB_TILE_HEIGHT + const c2x = originX - dy * ORTHO_SUB_TILE_WIDTH + const c2y = originY + dy * ORTHO_SUB_TILE_HEIGHT + if (isTerrainPointBlocked(c1x, c1y, isBlocked) && isTerrainPointBlocked(c2x, c2y, isBlocked)) { return true } } @@ -686,13 +989,15 @@ export function getIsometricRingCandidates(R: number): { dx: number; dy: number; } /** - * Find an unoccupied isometric grid slot around an origin coordinate. + * Find an unoccupied, player-reachable isometric grid slot around an origin coordinate. * * Implements Blizzard Diablo II v1.13c 2:1 isometric diamond grid scatter algorithm: * - Sub-tile steps: stepX = 16px, stepY = 8px (2:1 aspect ratio). * - Concentric square rings R = 0, 1, 2, ... up to maxRings. * - L1 Manhattan distance minimization (|dx| + |dy|). - * - Collision checks against COLLIDE_MASK_SPAWN (0x3E01) and LoS raycast against COLLIDE_MASK_SPAWN_LOS (0x0801). + * - Collision checks against COLLIDE_MASK_DROP_BLOCK, player footprint overlap, and LoS raycast. + * - Verifies 4-connected walkable path reachability from the drop anchor (and from `reachableFrom` + * when provided), snapping blocked or isolated drop origins to the nearest reachable floor sub-tile. * - Prevents item stacking by enforcing minimum spacing (default 16px). * * @param originX - epicenter X in world coordinates. @@ -703,6 +1008,7 @@ export function getIsometricRingCandidates(R: number): { dx: number; dy: number; * @param stepY - isometric vertical lattice step (default ORTHO_SUB_TILE_HEIGHT = 8). * @param maxRings - max search rings (default 10). * @param minSpacing - minimum distance in pixels between ground items (default 16). + * @param reachableFrom - optional player world coordinates to guarantee the drop is path-reachable by the player. */ export function findIsometricDropPosition( originX: number, @@ -713,25 +1019,300 @@ export function findIsometricDropPosition( stepY = ORTHO_SUB_TILE_HEIGHT, maxRings = 10, minSpacing = 16, + reachableFrom?: { x: number; y: number }, ): { x: number; y: number } { const minSpacingSq = minSpacing * minSpacing + const grid = extractCollisionGrid(isBlocked) + + const coordAt = (baseX: number, baseY: number, dx: number, dy: number): { x: number; y: number } => ({ + x: baseX + (dx - dy) * stepX, + y: baseY + (dx + dy) * stepY, + }) + + const isPointWalkable = (x: number, y: number): boolean => !isTerrainPointBlocked(x, y, isBlocked) + + const canStepCardinal = (fromX: number, fromY: number, toX: number, toY: number): boolean => { + if (!isPointWalkable(toX, toY)) return false + if (grid !== undefined && isSubTileRayBlocked(grid, fromX, fromY, toX, toY)) return false + if (typeof isBlocked?.hasLineOfSight === 'function' && !isBlocked.hasLineOfSight(fromX, fromY, toX, toY)) { + return false + } + return true + } + + const canGreedyWalkOnLattice = ( + baseX: number, + baseY: number, + fromDx: number, + fromDy: number, + toDx: number, + toDy: number, + ): boolean => { + let curDx = fromDx + let curDy = fromDy + while (curDx !== toDx || curDy !== toDy) { + const curPos = coordAt(baseX, baseY, curDx, curDy) + const remX = Math.abs(toDx - curDx) + const remY = Math.abs(toDy - curDy) + const sX = curDx < toDx ? 1 : -1 + const sY = curDy < toDy ? 1 : -1 + + const nextPosX = coordAt(baseX, baseY, curDx + sX, curDy) + const nextPosY = coordAt(baseX, baseY, curDx, curDy + sY) + + if (remX >= remY && remX > 0 && canStepCardinal(curPos.x, curPos.y, nextPosX.x, nextPosX.y)) { + curDx += sX + } else if (remY > 0 && canStepCardinal(curPos.x, curPos.y, nextPosY.x, nextPosY.y)) { + curDy += sY + } else if (remX > 0 && canStepCardinal(curPos.x, curPos.y, nextPosX.x, nextPosX.y)) { + curDx += sX + } else { + return false + } + } + return true + } + + // Step 1: Resolve effective reachable anchor (anchorX, anchorY) + let anchorX = originX + let anchorY = originY + const originWalkable = isPointWalkable(originX, originY) + let playerReachableOffsetsFromOrigin: Set | null = null + + if ( + isBlocked && + reachableFrom && + Number.isFinite(reachableFrom.x) && + Number.isFinite(reachableFrom.y) + ) { + const relX = reachableFrom.x - originX + const relY = reachableFrom.y - originY + const rawStartDx = Math.round((relX / stepX + relY / stepY) / 2) + const rawStartDy = Math.round((relY / stepY - relX / stepX) / 2) + + let startNode: { dx: number; dy: number } | null = null + const rawStartPos = coordAt(originX, originY, rawStartDx, rawStartDy) + if (isPointWalkable(rawStartPos.x, rawStartPos.y)) { + startNode = { dx: rawStartDx, dy: rawStartDy } + } else if (isPointWalkable(reachableFrom.x, reachableFrom.y)) { + for (const c of getIsometricRingCandidates(1)) { + const p = coordAt(originX, originY, rawStartDx + c.dx, rawStartDy + c.dy) + if (isPointWalkable(p.x, p.y)) { + startNode = { dx: rawStartDx + c.dx, dy: rawStartDy + c.dy } + break + } + } + } else { + for (let r = 1; r <= 2 && startNode === null; r += 1) { + for (const c of getIsometricRingCandidates(r)) { + const p = coordAt(originX, originY, rawStartDx + c.dx, rawStartDy + c.dy) + if (isPointWalkable(p.x, p.y)) { + startNode = { dx: rawStartDx + c.dx, dy: rawStartDy + c.dy } + break + } + } + } + } + + if (startNode !== null) { + const distL1 = Math.abs(startNode.dx) + Math.abs(startNode.dy) + const directReachable = + originWalkable && + distL1 <= 80 && + canGreedyWalkOnLattice(originX, originY, startNode.dx, startNode.dy, 0, 0) + + if (!directReachable && distL1 <= 120) { + const margin = Math.max(maxRings + 6, 16) + const minDx = Math.min(startNode.dx, 0) - margin + const maxDx = Math.max(startNode.dx, 0) + margin + const minDy = Math.min(startNode.dy, 0) - margin + const maxDy = Math.max(startNode.dy, 0) + margin + + const visited = new Set([`${startNode.dx},${startNode.dy}`]) + const queue: [number, number][] = [[startNode.dx, startNode.dy]] + let head = 0 + while (head < queue.length && queue.length < 4096) { + const [cx, cy] = queue[head++]! + const curWorld = coordAt(originX, originY, cx, cy) + const neighbors: [number, number][] = [ + [cx - 1, cy], + [cx + 1, cy], + [cx, cy - 1], + [cx, cy + 1], + ] + for (const [nx, ny] of neighbors) { + if (nx < minDx || nx > maxDx || ny < minDy || ny > maxDy) continue + const key = `${nx},${ny}` + if (visited.has(key)) continue + const nextWorld = coordAt(originX, originY, nx, ny) + if (!canStepCardinal(curWorld.x, curWorld.y, nextWorld.x, nextWorld.y)) continue + visited.add(key) + queue.push([nx, ny]) + } + } + + playerReachableOffsetsFromOrigin = visited + if (!visited.has('0,0')) { + // Snap anchor to the player-reachable coordinate closest to origin (0, 0) + let snapped = false + const scanMax = Math.max(maxRings, 20) + for (let r = 1; r <= scanMax && !snapped; r += 1) { + for (const cand of getIsometricRingCandidates(r)) { + if (visited.has(`${cand.dx},${cand.dy}`)) { + const pos = coordAt(originX, originY, cand.dx, cand.dy) + anchorX = pos.x + anchorY = pos.y + snapped = true + break + } + } + } + if (!snapped) { + let bestDx = startNode.dx + let bestDy = startNode.dy + let bestL1 = Math.abs(bestDx) + Math.abs(bestDy) + let bestSq = bestDx * bestDx + bestDy * bestDy + for (const [vx, vy] of queue) { + const l1 = Math.abs(vx) + Math.abs(vy) + const sq = vx * vx + vy * vy + if (l1 < bestL1 || (l1 === bestL1 && sq < bestSq)) { + bestL1 = l1 + bestSq = sq + bestDx = vx + bestDy = vy + } + } + const pos = coordAt(originX, originY, bestDx, bestDy) + anchorX = pos.x + anchorY = pos.y + } + } + } + } + } + + // If anchor is still on a blocked coordinate (e.g. origin inside thick wall/water/void without reachableFrom), + // snap anchor to the nearest walkable floor coordinate with the largest local connected component. + if (isBlocked && !isPointWalkable(anchorX, anchorY)) { + const measureComponentSize = (startX: number, startY: number, cap = 12): number => { + const seen = new Set(['0,0']) + const q: [number, number][] = [[0, 0]] + let h = 0 + while (h < q.length && seen.size < cap) { + const [qx, qy] = q[h++]! + const curW = coordAt(startX, startY, qx, qy) + const nbs: [number, number][] = [ + [qx - 1, qy], + [qx + 1, qy], + [qx, qy - 1], + [qx, qy + 1], + ] + for (const [nx, ny] of nbs) { + const k = `${nx},${ny}` + if (seen.has(k)) continue + const nextW = coordAt(startX, startY, nx, ny) + if (!canStepCardinal(curW.x, curW.y, nextW.x, nextW.y)) continue + seen.add(k) + q.push([nx, ny]) + if (seen.size >= cap) break + } + } + return seen.size + } + + const escapeRings = Math.max(maxRings, 30) + let fallbackWalkable: { x: number; y: number; size: number } | null = null + let snapped = false + for (let r = 1; r <= escapeRings && !snapped; r += 1) { + for (const cand of getIsometricRingCandidates(r)) { + const pos = coordAt(originX, originY, cand.dx, cand.dy) + if (!isPointWalkable(pos.x, pos.y)) continue + const compSize = measureComponentSize(pos.x, pos.y, 8) + if (compSize >= 6) { + anchorX = pos.x + anchorY = pos.y + snapped = true + break + } + if (fallbackWalkable === null || compSize > fallbackWalkable.size) { + fallbackWalkable = { x: pos.x, y: pos.y, size: compSize } + } + } + } + if (!snapped && fallbackWalkable !== null) { + anchorX = fallbackWalkable.x + anchorY = fallbackWalkable.y + } + } + + // Step 2: Lazy 4-connected walkable verification around (anchorX, anchorY) + const anchorWalkable = isPointWalkable(anchorX, anchorY) + let anchorConnectedOffsets: Set | null = null + + const isCandidateConnectedToAnchor = (dx: number, dy: number): boolean => { + if (!isBlocked || !anchorWalkable) return true + if (dx === 0 && dy === 0) return true + + if (canGreedyWalkOnLattice(anchorX, anchorY, 0, 0, dx, dy)) { + return true + } + + if (anchorConnectedOffsets === null) { + const bound = Math.max(maxRings + 4, 14) + anchorConnectedOffsets = new Set(['0,0']) + const q: [number, number][] = [[0, 0]] + let h = 0 + while (h < q.length && q.length < 2048) { + const [qx, qy] = q[h++]! + const curW = coordAt(anchorX, anchorY, qx, qy) + const nbs: [number, number][] = [ + [qx - 1, qy], + [qx + 1, qy], + [qx, qy - 1], + [qx, qy + 1], + ] + for (const [nx, ny] of nbs) { + if (Math.abs(nx) > bound || Math.abs(ny) > bound) continue + const k = `${nx},${ny}` + if (anchorConnectedOffsets.has(k)) continue + const nextW = coordAt(anchorX, anchorY, nx, ny) + if (!canStepCardinal(curW.x, curW.y, nextW.x, nextW.y)) continue + anchorConnectedOffsets.add(k) + q.push([nx, ny]) + } + } + } + + return anchorConnectedOffsets.has(`${dx},${dy}`) + } + + // Step 3: Concentric diamond ring search around (anchorX, anchorY) let firstWalkableCandidate: { x: number; y: number } | null = null for (let ring = 0; ring <= maxRings; ring += 1) { const ringCandidates = getIsometricRingCandidates(ring) for (const cand of ringCandidates) { - // 2:1 Isometric projection screen offset: - // cx = originX + (dx - dy) * stepX - // cy = originY + (dx + dy) * stepY - const cx = originX + (cand.dx - cand.dy) * stepX - const cy = originY + (cand.dx + cand.dy) * stepY + const cx = anchorX + (cand.dx - cand.dy) * stepX + const cy = anchorY + (cand.dx + cand.dy) * stepY - // Check collision and line of sight - if (isDropPositionBlocked(cx, cy, originX, originY, isBlocked)) { + // Check collision and line of sight from the walkable anchor + if (isDropPositionBlocked(cx, cy, anchorX, anchorY, isBlocked)) { continue } + // Check 4-way walkable connectivity to anchor (prevents clipping across walls/corners) + if (!isCandidateConnectedToAnchor(cand.dx, cand.dy)) { + continue + } + + // If player reachability set was computed on origin lattice, verify candidate is also in it + if (playerReachableOffsetsFromOrigin !== null && anchorX === originX && anchorY === originY) { + if (!playerReachableOffsetsFromOrigin.has(`${cand.dx},${cand.dy}`)) { + continue + } + } + if (firstWalkableCandidate === null) { firstWalkableCandidate = { x: cx, y: cy } } @@ -754,7 +1335,7 @@ export function findIsometricDropPosition( } } - return firstWalkableCandidate ?? { x: originX, y: originY } + return firstWalkableCandidate ?? { x: anchorX, y: anchorY } } /** diff --git a/src/scene/net-scene.ts b/src/scene/net-scene.ts index 81ed0a7..eb13682 100644 --- a/src/scene/net-scene.ts +++ b/src/scene/net-scene.ts @@ -740,12 +740,33 @@ export function createNetSimulation( }) const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y })) + const alivePlayers = world.players.filter(p => p.alive) + const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players + let reachableFrom: { x: number; y: number } | undefined + if (refPlayers.length > 0) { + let bestPlayer = refPlayers[0]! + let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y) + for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) { + const p = refPlayers[pIdx]! + const d = Math.hypot(p.x - event.x, p.y - event.y) + if (d < bestDist) { + bestDist = d + bestPlayer = p + } + } + reachableFrom = { x: bestPlayer.x, y: bestPlayer.y } + } for (const drop of droppedItems) { const dropPos = findIsometricDropPosition( event.x, event.y, burstOccupied, mapTerrain, + undefined, + undefined, + undefined, + undefined, + reachableFrom, ) burstOccupied.push(dropPos) ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) diff --git a/tests/ground-items-reachability.test.ts b/tests/ground-items-reachability.test.ts new file mode 100644 index 0000000..19fcfd8 --- /dev/null +++ b/tests/ground-items-reachability.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest' +import { + COLLIDE_MASK_DROP_BLOCK, + findIsometricDropPosition, + findSafeDropPosition, + isDropPositionBlocked, + isTerrainPointBlocked, + isWithinPickupBounds, +} from '../src/game/ground-items.ts' +import { + COLLIDE_BLANK, + COLLIDE_DOOR, + COLLIDE_MASK_INVALID, + COLLIDE_NO_PATH, + COLLIDE_NONE, + COLLIDE_NOPLAYER, + COLLIDE_OBJECT, + COLLIDE_WALL, + COLLIDE_WATER, + ORTHO_CELL_HEIGHT, + ORTHO_CELL_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + ORTHO_SUB_TILE_WIDTH, + getCollisionMaskAt, + isBlockedAt, + subTileAt, + subTileCentre, + type CollisionGrid, +} from '../src/game/d2map.ts' +import { createIsoTerrain } from '../src/game/iso-terrain.ts' +import { GameEngine } from '../src/game/engine.ts' + +function createSubTileGrid(cellsX = 12, cellsY = 12): CollisionGrid { + const gridWidth = cellsX * 5 + const gridHeight = cellsY * 5 + return { + cellsX, + cellsY, + gridWidth, + originX: cellsY * ORTHO_CELL_WIDTH, + originY: 0, + blocked: new Uint8Array(gridWidth * gridHeight), + collisionMasks: new Uint16Array(gridWidth * gridHeight), + } +} + +function setSubTileMask(grid: CollisionGrid, subX: number, subY: number, mask: number): void { + const idx = subY * grid.gridWidth + subX + if (grid.collisionMasks) { + grid.collisionMasks[idx] = mask + } + grid.blocked[idx] = (mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_NOPLAYER | COLLIDE_WATER | COLLIDE_DOOR | COLLIDE_OBJECT)) !== 0 ? 1 : 0 +} + +describe('Issue #492: Prevent Ground Items & Gold from Dropping into Unreachable Locations', () => { + describe('1. Blocked Origin Escape (Monster Dying Inside Thick Walls / Void / Water)', () => { + it('escapes a 4x4 solid wall block instead of poisoning rayTraceScene and falling back to blocked origin', () => { + const grid = createSubTileGrid(12, 12) + // Block a 5x5 sub-tile block around sub-tile (25, 25) + for (let sy = 23; sy <= 27; sy += 1) { + for (let sx = 23; sx <= 27; sx += 1) { + setSubTileMask(grid, sx, sy, COLLIDE_WALL) + } + } + + const origin = subTileCentre(grid, 25, 25) + expect(isTerrainPointBlocked(origin.x, origin.y, grid)).toBe(true) + + const dropPos = findIsometricDropPosition(origin.x, origin.y, [], grid) + expect(isTerrainPointBlocked(dropPos.x, dropPos.y, grid)).toBe(false) + expect(getCollisionMaskAt(grid, dropPos.x, dropPos.y) & COLLIDE_MASK_DROP_BLOCK).toBe(0) + expect(isBlockedAt(grid, dropPos.x, dropPos.y)).toBe(false) + }) + + it('escapes deeply buried origin (> 10 rings inside void/water) to nearest walkable floor', () => { + const grid = createSubTileGrid(16, 16) + // Block sub-tiles [15..45, 15..45] (radius 15 > default maxRings=10 around (30, 30)) + for (let sy = 15; sy <= 45; sy += 1) { + for (let sx = 15; sx <= 45; sx += 1) { + setSubTileMask(grid, sx, sy, COLLIDE_WATER) + } + } + + const buriedOrigin = subTileCentre(grid, 30, 30) + expect(isTerrainPointBlocked(buriedOrigin.x, buriedOrigin.y, grid)).toBe(true) + + const dropPos = findIsometricDropPosition(buriedOrigin.x, buriedOrigin.y, [], grid) + expect(isTerrainPointBlocked(dropPos.x, dropPos.y, grid)).toBe(false) + expect(getCollisionMaskAt(grid, dropPos.x, dropPos.y) & COLLIDE_MASK_DROP_BLOCK).toBe(0) + }) + }) + + describe('2. Player Path-Connectivity (`reachableFrom`) Across Rivers, Moats & Sealed Rooms', () => { + it('snaps drops from a monster killed across an impassable river onto the player side of the river', () => { + const grid = createSubTileGrid(12, 12) + // Create a full north-south river at subX = 24..27 across the entire map height + for (let sy = 0; sy < 60; sy += 1) { + for (let sx = 24; sx <= 27; sx += 1) { + setSubTileMask(grid, sx, sy, COLLIDE_WATER | COLLIDE_WALL) + } + } + + // Player is on the West bank (subX = 20, subY = 30) + const playerPos = subTileCentre(grid, 20, 30) + // Flying/ranged monster dies on the East bank (subX = 32, subY = 30) across the river + const monsterDeathPos = subTileCentre(grid, 32, 30) + + expect(isTerrainPointBlocked(playerPos.x, playerPos.y, grid)).toBe(false) + expect(isTerrainPointBlocked(monsterDeathPos.x, monsterDeathPos.y, grid)).toBe(false) + + const existing: { x: number; y: number }[] = [] + for (let i = 0; i < 6; i += 1) { + const dropPos = findIsometricDropPosition( + monsterDeathPos.x, + monsterDeathPos.y, + existing, + grid, + ORTHO_SUB_TILE_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + 10, + 16, + playerPos, + ) + existing.push(dropPos) + + const dropSub = subTileAt(grid, dropPos.x, dropPos.y) + // Every dropped item must land on the West bank (subX < 24) where the player can reach it + expect(dropSub.subX).toBeLessThan(24) + expect(isTerrainPointBlocked(dropPos.x, dropPos.y, grid)).toBe(false) + } + }) + + it('never leaks items through a thin 1-sub-tile wall into an adjacent sealed room during a 15-item burst drop', () => { + const grid = createSubTileGrid(12, 12) + // Seal a 3x3 sub-tile room at subX = 20..22, subY = 20..22 with a 1-sub-tile wall ring at 19..23 + for (let sy = 19; sy <= 23; sy += 1) { + for (let sx = 19; sx <= 23; sx += 1) { + const isInterior = sx >= 20 && sx <= 22 && sy >= 20 && sy <= 22 + if (!isInterior) { + setSubTileMask(grid, sx, sy, COLLIDE_WALL) + } + } + } + + // Exterior floor (subX < 19 or > 23) is completely open, but separated by the wall ring + const roomCenter = subTileCentre(grid, 21, 21) + const existing: { x: number; y: number }[] = [] + + for (let i = 0; i < 15; i += 1) { + const pos = findIsometricDropPosition( + roomCenter.x, + roomCenter.y, + existing, + grid, + ORTHO_SUB_TILE_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + 10, + 16, + roomCenter, + ) + existing.push(pos) + const sub = subTileAt(grid, pos.x, pos.y) + expect(sub.subX).toBeGreaterThanOrEqual(20) + expect(sub.subX).toBeLessThanOrEqual(22) + expect(sub.subY).toBeGreaterThanOrEqual(20) + expect(sub.subY).toBeLessThanOrEqual(22) + } + }) + }) + + describe('3. Diagonal Wall-Corner Pinch Prevention', () => { + it('rejects diagonal step when both shared orthogonal neighbors are wall sub-tiles', () => { + const grid = createSubTileGrid(10, 10) + // Origin at (25, 25). Block (26, 25) and (25, 26) so diagonal (26, 26) is pinched by orthogonal walls. + setSubTileMask(grid, 26, 25, COLLIDE_WALL) + setSubTileMask(grid, 25, 26, COLLIDE_WALL) + + const origin = subTileCentre(grid, 25, 25) + const diagTarget = subTileCentre(grid, 26, 26) + + expect(isTerrainPointBlocked(diagTarget.x, diagTarget.y, grid)).toBe(false) + expect(isDropPositionBlocked(diagTarget.x, diagTarget.y, origin.x, origin.y, grid)).toBe(true) + }) + + it('rejects diagonal cell in findSafeDropPosition when sealed by orthogonal wall cells', () => { + const width = 5 + const height = 5 + const collisionMasks = new Uint16Array(width * height).fill(COLLIDE_WALL) + // Open (2, 2) and (3, 3), but keep (3, 2) and (2, 3) as COLLIDE_WALL so (3, 3) is diagonally pinched + collisionMasks[2 * width + 2] = COLLIDE_NONE + collisionMasks[3 * width + 3] = COLLIDE_NONE + const grid = { width, height, collisionMasks } + + // Occupy (2, 2) so findSafeDropPosition searches Ring 1 + const pos = findSafeDropPosition(grid, 2, 2, 2, [{ cellX: 2, cellY: 2 }]) + // Must NOT jump diagonally into the sealed (3, 3) pocket; instead stacks safely on (2, 2) + expect(pos.cellX).toBe(2) + expect(pos.cellY).toBe(2) + }) + }) + + describe('4. Full Collision Mask & CountingTerrain Player Footprint Clearance', () => { + it('rejects COLLIDE_WATER, COLLIDE_NOPLAYER, COLLIDE_BLANK, COLLIDE_NO_PATH, and COLLIDE_MASK_INVALID', () => { + const forbiddenMasks = [ + COLLIDE_WATER, + COLLIDE_NOPLAYER, + COLLIDE_BLANK, + COLLIDE_NO_PATH, + COLLIDE_MASK_INVALID, + COLLIDE_DOOR, + COLLIDE_OBJECT, + ] + + for (const mask of forbiddenMasks) { + const grid = createSubTileGrid(8, 8) + setSubTileMask(grid, 20, 20, mask) + const pt = subTileCentre(grid, 20, 20) + expect(isTerrainPointBlocked(pt.x, pt.y, grid)).toBe(true) + + const drop = findIsometricDropPosition(pt.x, pt.y, [], grid) + expect(drop).not.toEqual(pt) + expect(isTerrainPointBlocked(drop.x, drop.y, grid)).toBe(false) + } + }) + + it('enforces CountingTerrain.overlap(x, y) === 0 in both findIsometricDropPosition and findSafeDropPosition', () => { + const grid = createSubTileGrid(10, 10) + const terrain = createIsoTerrain(grid, 800, 800, { width: 20, height: 10 }) + + // Sub-tile (20, 20) itself is COLLIDE_NONE, but adjacent sub-tile (20, 21) is blocked so player 20x10 box overlaps + const center = subTileCentre(grid, 20, 20) + const neighbor = subTileAt(grid, center.x + 8, center.y + 4) + setSubTileMask(grid, neighbor.subX, neighbor.subY, COLLIDE_WALL) + + expect(terrain.overlap(center.x, center.y)).toBeGreaterThan(0) + expect(isTerrainPointBlocked(center.x, center.y, terrain)).toBe(true) + const drop = findIsometricDropPosition(center.x, center.y, [], terrain) + expect(terrain.overlap(drop.x, drop.y)).toBe(0) + }) + }) + + describe('5. End-to-End GameEngine Walk & Pickup Reachability Verification', () => { + it('guarantees player can physically walk to and pick up items/gold dropped when monster dies inside a wall', () => { + const grid = createSubTileGrid(12, 12) + // Create a solid wall on the right half of the map (subX >= 25) + for (let sy = 0; sy < 60; sy += 1) { + for (let sx = 25; sx < 60; sx += 1) { + setSubTileMask(grid, sx, sy, COLLIDE_WALL) + } + } + + const terrain = createIsoTerrain(grid, 1600, 1600, { width: 20, height: 10 }) + const playerSpawn = subTileCentre(grid, 20, 25) + expect(terrain.overlap(playerSpawn.x, playerSpawn.y)).toBe(0) + + const engine = new GameEngine(terrain, { + spawn: playerSpawn, + stats: [], + xpTable: [0, 500, 1500], + skills: [], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 4, + playerReach: 50, + playerCooldownTicks: 10, + playerDamage: 5, + playerManaPerAttack: 1, + respawnTicks: 100, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + }) + + // Drop item and gold at a point deep inside the wall (subX = 30, subY = 25) + const insideWallPos = subTileCentre(grid, 30, 25) + expect(terrain.overlap(insideWallPos.x, insideWallPos.y)).toBeGreaterThan(0) + + const itemEntity = engine.dropItem( + { code: 'ssd', name: 'Short Sword', invWidth: 1, invHeight: 3 } as any, + insideWallPos.x, + insideWallPos.y, + ) + const goldEntity = engine.dropGold(250, insideWallPos.x, insideWallPos.y)! + + expect(terrain.overlap(itemEntity.x, itemEntity.y)).toBe(0) + expect(terrain.overlap(goldEntity.x, goldEntity.y)).toBe(0) + + // Physically walk the player toward the dropped item using axis-separated collision checks + for (let step = 0; step < 120; step += 1) { + const dx = itemEntity.x - engine.world.player.x + const dy = itemEntity.y - engine.world.player.y + const dist = Math.hypot(dx, dy) + if (dist <= 4) break + const vx = (dx / dist) * 4 + const vy = (dy / dist) * 4 + const cur = terrain.overlap(engine.world.player.x, engine.world.player.y) + if (terrain.overlap(engine.world.player.x + vx, engine.world.player.y) <= cur) { + engine.world.player.x += vx + } + if (terrain.overlap(engine.world.player.x, engine.world.player.y + vy) <= cur) { + engine.world.player.y += vy + } + } + + expect( + isWithinPickupBounds( + engine.world.player.x, + engine.world.player.y, + itemEntity.x, + itemEntity.y, + ), + ).toBe(true) + + const itemPickup = engine.pickupItem(itemEntity.id) + expect(itemPickup.success).toBe(true) + const goldPickup = engine.pickupGold(goldEntity.id) + expect(goldPickup.success).toBe(true) + expect(goldPickup.amount).toBe(250) + }) + }) +})