diablo2-web/tests/ground-items-quadrant-spira...

275 lines
9.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
findSafeDropPosition,
getIsometricRingCandidates,
} from '../src/game/ground-items.ts'
import {
COLLIDE_NONE,
COLLIDE_WALL,
COLLIDE_BLANK,
COLLIDE_MASK_INVALID,
COLLIDE_DOOR,
COLLIDE_WATER,
ORTHO_CELL_WIDTH,
ORTHO_CELL_HEIGHT,
type CollisionGrid,
} from '../src/game/d2map.ts'
import { GameEngine, worldToCell } from '../src/game/engine.ts'
describe('Issue #422: Grid Quadrant Spiral Drop Coordinates (1.13c Parity)', () => {
describe('Suite 1: Discrete Collision Grid Quadrant Spiral Search', () => {
it('1.1: places item directly on origin when origin is unblocked and unoccupied', () => {
const mockGrid = { isBlocked: () => false }
const pos = findSafeDropPosition(mockGrid, 12, 18, 3)
expect(pos.cellX).toBe(12)
expect(pos.cellY).toBe(18)
})
it('1.2: rejects COLLIDE_WALL (0x0001) and selects nearest cardinal neighbor in Ring 1', () => {
const width = 5
const height = 5
const masks = new Uint16Array(width * height)
masks[2 * width + 2] = COLLIDE_WALL // origin (2, 2) blocked
const grid = { width, height, collisionMasks: masks }
const pos = findSafeDropPosition(grid, 2, 2, 2)
expect(pos.cellX !== 2 || pos.cellY !== 2).toBe(true)
const manhattan = Math.abs(pos.cellX - 2) + Math.abs(pos.cellY - 2)
expect(manhattan).toBe(1) // Nearest cardinal axis neighbor
})
it('1.3: rejects COLLIDE_BLANK (sparse void cells) per Diablo II user rules', () => {
const width = 5
const height = 5
const masks = new Uint16Array(width * height)
masks[2 * width + 2] = COLLIDE_BLANK // void at (2, 2)
masks[2 * width + 1] = COLLIDE_BLANK // void at (1, 2)
masks[2 * width + 3] = COLLIDE_NONE // walkable floor at (3, 2)
const grid = { width, height, collisionMasks: masks }
const pos = findSafeDropPosition(grid, 2, 2, 2)
expect(pos.cellX).toBe(3)
expect(pos.cellY).toBe(2)
})
it('1.4: rejects COLLIDE_MASK_INVALID on unindexed cells and map boundaries', () => {
const width = 10
const height = 10
const masks = new Uint16Array(width * height)
masks[0] = COLLIDE_MASK_INVALID
const grid = { width, height, collisionMasks: masks }
const pos = findSafeDropPosition(grid, 0, 0, 2)
expect(pos.cellX).toBeGreaterThanOrEqual(0)
expect(pos.cellY).toBeGreaterThanOrEqual(0)
expect(pos.cellX !== 0 || pos.cellY !== 0).toBe(true)
})
it('1.5: concentric quadrant rings strictly order cardinal axes before diagonals', () => {
const ring1 = getIsometricRingCandidates(1)
expect(ring1.length).toBe(8)
// First 4 candidates must be cardinal axes (L1 = 1)
for (let i = 0; i < 4; i++) {
expect(ring1[i]!.l1).toBe(1)
}
// Remaining 4 candidates must be diagonals (L1 = 2)
for (let i = 4; i < 8; i++) {
expect(ring1[i]!.l1).toBe(2)
}
})
})
describe('Suite 2: Multi-Item Burst Dispersal & Anti-Stacking', () => {
it('2.1: 6-item drop burst from a single monster disperses into 6 distinct cells', () => {
const occupied: { cellX: number; cellY: number }[] = []
const grid = { isBlocked: () => false }
for (let i = 0; i < 6; i++) {
const pos = findSafeDropPosition(grid, 15, 15, 3, occupied)
occupied.push(pos)
}
expect(occupied.length).toBe(6)
const uniqueKeys = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
expect(uniqueKeys.size).toBe(6)
})
it('2.2: existing ground items force subsequent drops to spiral outward', () => {
const existing = [{ cellX: 10, cellY: 10 }]
const grid = { isBlocked: () => false }
const pos = findSafeDropPosition(grid, 10, 10, 3, existing)
expect(pos.cellX !== 10 || pos.cellY !== 10).toBe(true)
const dist = Math.hypot(pos.cellX - 10, pos.cellY - 10)
expect(dist).toBeGreaterThanOrEqual(1)
})
it('2.3: room saturation fallback stacks on first walkable floor when radius is saturated', () => {
// 1x1 sealed walkable room surrounded by void
const width = 3
const height = 3
const masks = new Uint16Array(width * height).fill(COLLIDE_WALL)
masks[1 * width + 1] = COLLIDE_NONE // only (1, 1) is walkable floor
const grid = { width, height, collisionMasks: masks }
const item1 = findSafeDropPosition(grid, 1, 1, 1, [])
expect(item1.cellX).toBe(1)
expect(item1.cellY).toBe(1)
// Item 2 drops into the same saturated room: must fallback to walkable cell (1, 1) rather than wall
const item2 = findSafeDropPosition(grid, 1, 1, 1, [item1])
expect(item2.cellX).toBe(1)
expect(item2.cellY).toBe(1)
})
})
describe('Suite 3: Varied Map Collision Layouts Parity', () => {
it('3.1: Corridor Layout (1-cell wide hallway): drops spread along corridor floor, never in walls', () => {
const width = 20
const height = 20
const masks = new Uint16Array(width * height)
// East-West corridor along y = 10; walls along y = 9 and y = 11
for (let x = 0; x < width; x++) {
masks[9 * width + x] = COLLIDE_WALL
masks[11 * width + x] = COLLIDE_WALL
}
const grid = { width, height, collisionMasks: masks }
const placed: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 5; i++) {
const pos = findSafeDropPosition(grid, 10, 10, 4, placed)
placed.push(pos)
// All drops must strictly remain inside corridor floor (y = 10)
expect(pos.cellY).toBe(10)
expect(pos.cellX).toBeGreaterThanOrEqual(8)
expect(pos.cellX).toBeLessThanOrEqual(12)
}
})
it('3.2: Corner / L-Shape Room Layout: drops exclusively populate open walkable quadrant', () => {
const width = 10
const height = 10
const masks = new Uint16Array(width * height)
// Corner at (1, 1): walls at x = 0 and y = 0
for (let i = 0; i < width; i++) {
masks[0 * width + i] = COLLIDE_WALL
masks[i * width + 0] = COLLIDE_WALL
}
const grid = { width, height, collisionMasks: masks }
const placed: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 4; i++) {
const pos = findSafeDropPosition(grid, 1, 1, 3, placed)
placed.push(pos)
expect(pos.cellX).toBeGreaterThanOrEqual(1)
expect(pos.cellY).toBeGreaterThanOrEqual(1)
}
})
it('3.3: Moat / River Boundary (Mephisto moat parity): drops stay on ground bank, 0 in water', () => {
const width = 30
const height = 30
const masks = new Uint16Array(width * height)
// Moat water from x = 15 to 30
for (let y = 0; y < height; y++) {
for (let x = 15; x < width; x++) {
masks[y * width + x] = COLLIDE_WALL | COLLIDE_WATER
}
}
const grid = { width, height, collisionMasks: masks }
// Mephisto dies on stone bank adjacent to water (14, 15)
const placed: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 6; i++) {
const pos = findSafeDropPosition(grid, 14, 15, 4, placed)
placed.push(pos)
expect(pos.cellX).toBeLessThan(15) // Never in moat
}
})
it('3.4: Monster dying inside a wall cell: items safely spiral out to nearest walkable floor', () => {
const width = 10
const height = 10
const masks = new Uint16Array(width * height)
masks[5 * width + 5] = COLLIDE_WALL // monster died inside wall
const grid = { width, height, collisionMasks: masks }
const pos = findSafeDropPosition(grid, 5, 5, 3)
expect(pos.cellX !== 5 || pos.cellY !== 5).toBe(true)
expect(masks[pos.cellY * width + pos.cellX]).toBe(COLLIDE_NONE)
})
})
describe('Suite 4: GameEngine & CollisionGrid Integration', () => {
it('4.1: GameEngine.dropItem spreads items cleanly across discrete cell coordinates', () => {
const engine = new GameEngine(
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
{
spawn: { x: 100, y: 100 },
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,
},
)
const d1 = engine.dropItem({ name: 'Item 1' } as any, 200, 200)
const d2 = engine.dropItem({ name: 'Item 2' } as any, 200, 200)
const d3 = engine.dropItem({ name: 'Item 3' } as any, 200, 200)
expect(d1.x).toBe(200)
expect(d1.y).toBe(200)
expect(d2.x !== 200 || d2.y !== 200).toBe(true)
expect(d3.x !== 200 || d3.y !== 200).toBe(true)
expect(Math.hypot(d2.x - d1.x, d2.y - d1.y)).toBeGreaterThanOrEqual(16)
expect(Math.hypot(d3.x - d1.x, d3.y - d1.y)).toBeGreaterThanOrEqual(16)
})
it('4.2: GameEngine drops adhere to real CollisionGrid sub-tile masks', () => {
const cellsX = 10
const cellsY = 10
const gridWidth = cellsX * 5
const blocked = new Uint8Array(gridWidth * cellsY * 5)
const collisionMasks = new Uint16Array(gridWidth * cellsY * 5)
// Block cell (3, 3) center sub-tile
collisionMasks[(3 * 5 + 2) * gridWidth + (3 * 5 + 2)] = COLLIDE_WALL
const collisionGrid: CollisionGrid = {
cellsX,
cellsY,
gridWidth,
originX: 0,
originY: 0,
blocked,
collisionMasks,
}
const terrain = {
widthPx: 1000,
heightPx: 1000,
grid: collisionGrid,
overlap: () => 0,
}
const pos = findSafeDropPosition(terrain, 3, 3, 2)
expect(pos.cellX !== 3 || pos.cellY !== 3).toBe(true)
const mask = collisionMasks[(pos.cellY * 5 + 2) * gridWidth + (pos.cellX * 5 + 2)]
expect((mask & COLLIDE_WALL)).toBe(0)
})
})
})