325 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|