diablo2-web/tests/ground-items-reachability.t...

325 lines
12 KiB
TypeScript

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)
})
})
})