diablo2-web/tests/ground-items-drop.test.ts

448 lines
16 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Ground Items & Drop System Test Suite
*
* Requirements (Issue #388):
* 1. Safe Drop Radial Search: finds walkable cell avoiding walls, void and invalid cells.
* 2. Authentic Gold & Item Representation: parses gold amounts, qualities, and names.
* 3. GroundItemManager Lifecycle: add, remove, search, sparkle phase, and parabolic bounce physics.
* 4. Inventory & Stash Drop Interaction: dropGold, findFreeGridSlot, and auto placement.
* 5. GameEngine & Scene Integration: groundItems synchronization with engine ground list.
*/
import { describe, expect, it } from 'vitest'
import {
findSafeDropPosition,
findIsometricDropPosition,
isGoldItem,
resolveGroundItemMetadata,
calculateBounceHeight,
GroundItemManager,
type GroundItemBounceState,
} from '../src/game/ground-items.ts'
import {
COLLIDE_NONE,
COLLIDE_WALL,
COLLIDE_BLANK,
COLLIDE_MASK_INVALID,
ORTHO_CELL_WIDTH,
ORTHO_CELL_HEIGHT,
} from '../src/game/d2map.ts'
import { InventoryPanel, findFreeGridSlot, type UiInventoryItem, type GridPlacement } from '../src/ui/inventory.ts'
import { GameEngine } from '../src/game/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
describe('Issue #388 — Ground Item & Drop System Ground Truth', () => {
describe('1. findSafeDropPosition Radial Search (D2Common.dll ITEM_Drop Parity)', () => {
it('returns origin cell directly when origin is not blocked', () => {
const mockGrid = {
originX: 100,
originY: 200,
isBlocked: () => false,
}
const pos = findSafeDropPosition(mockGrid, 10, 20, 3)
expect(pos.cellX).toBe(10)
expect(pos.cellY).toBe(20)
expect(pos.x).toBe(100 + (10 - 20) * ORTHO_CELL_WIDTH)
expect(pos.y).toBe(200 + (10 + 20) * ORTHO_CELL_HEIGHT)
})
it('spirals to nearest unblocked neighbor when origin is blocked by wall', () => {
// Origin (10, 20) is blocked; (10, 21) is free
const blockedCells = new Set(['10,20'])
const mockGrid = {
originX: 0,
originY: 0,
isBlocked: (cx: number, cy: number) => blockedCells.has(`${cx},${cy}`),
}
const pos = findSafeDropPosition(mockGrid, 10, 20, 2)
expect(pos.cellX !== 10 || pos.cellY !== 20).toBe(true)
const distSq = (pos.cellX - 10) ** 2 + (pos.cellY - 20) ** 2
expect(distSq).toBe(1) // Nearest orthogonal neighbor
})
it('strictly avoids COLLIDE_WALL, COLLIDE_BLANK, and COLLIDE_MASK_INVALID in grid collisionMasks', () => {
const width = 5
const height = 5
const masks = new Uint16Array(width * height)
// Block center (2, 2) and immediate neighbors except (3, 2)
masks[2 * width + 2] = COLLIDE_WALL
masks[1 * width + 2] = COLLIDE_BLANK
masks[3 * width + 2] = COLLIDE_MASK_INVALID
masks[2 * width + 1] = COLLIDE_WALL
// (3, 2) is left as COLLIDE_NONE
const grid = {
width,
height,
originX: 0,
originY: 0,
collisionMasks: masks,
}
const pos = findSafeDropPosition(grid, 2, 2, 2)
expect(pos.cellX).toBe(3)
expect(pos.cellY).toBe(2)
})
it('falls back safely to origin if all cells within maxRadius are blocked', () => {
const mockGrid = {
isBlocked: () => true,
}
const pos = findSafeDropPosition(mockGrid, 5, 5, 2)
expect(pos.cellX).toBe(5)
expect(pos.cellY).toBe(5)
})
})
describe('2. isGoldItem & resolveGroundItemMetadata', () => {
it('accurately identifies gold items from diverse shapes', () => {
expect(isGoldItem({ isGold: true })).toBe(true)
expect(isGoldItem({ code: 'gld' })).toBe(true)
expect(isGoldItem({ code: ' gld ' })).toBe(true)
expect(isGoldItem({ id: 'gold' })).toBe(true)
expect(isGoldItem({ base: { id: 'gold' } })).toBe(true)
expect(isGoldItem({ name: 'Gold' })).toBe(true)
expect(isGoldItem({ name: '500 金币' })).toBe(true)
expect(isGoldItem({ name: 'Short Sword', code: 'swd' })).toBe(false)
expect(isGoldItem(null)).toBe(false)
expect(isGoldItem(undefined)).toBe(false)
})
it('resolves gold metadata with formatted Chinese/English names and amount', () => {
const meta = resolveGroundItemMetadata({ isGold: true, amount: 2500 })
expect(meta.isGold).toBe(true)
expect(meta.amount).toBe(2500)
expect(meta.name).toBe('2500 Gold')
expect(meta.nameZh).toBe('2500 金币')
expect(meta.quality).toBe('normal')
expect(meta.invWidth).toBe(1)
expect(meta.invHeight).toBe(1)
})
it('resolves equipment item metadata preserving quality, dimensions, and names', () => {
const item: Partial<UiInventoryItem> = {
id: 'soj_1',
name: 'The Stone of Jordan',
nameZh: '乔丹之石',
quality: 'unique',
invWidth: 1,
invHeight: 1,
}
const meta = resolveGroundItemMetadata(item)
expect(meta.isGold).toBe(false)
expect(meta.name).toBe('The Stone of Jordan')
expect(meta.nameZh).toBe('乔丹之石')
expect(meta.quality).toBe('unique')
expect(meta.invWidth).toBe(1)
expect(meta.invHeight).toBe(1)
})
})
describe('3. GroundItemManager Lifecycle & Parabolic Bounce Physics', () => {
it('adds and retrieves dropped items with unique generated IDs', () => {
const manager = new GroundItemManager()
const item: Partial<UiInventoryItem> = {
name: 'Grand Charm',
nameZh: '超大护身符',
quality: 'magic',
invWidth: 1,
invHeight: 3,
}
const entity = manager.add(item, 15, 25, 400, 300, { bounce: true })
expect(entity.id).toBeDefined()
expect(entity.name).toBe('Grand Charm')
expect(entity.nameZh).toBe('超大护身符')
expect(entity.cellX).toBe(15)
expect(entity.cellY).toBe(25)
expect(entity.x).toBe(400)
expect(entity.y).toBe(300)
expect(entity.bounceState).toBeDefined()
expect(manager.count).toBe(1)
expect(manager.get(entity.id)).toBe(entity)
})
it('removes item by ID when picked up', () => {
const manager = new GroundItemManager()
const entity = manager.add({ name: 'Potion' }, 0, 0, 100, 100)
expect(manager.count).toBe(1)
const removed = manager.remove(entity.id)
expect(removed).toBe(entity)
expect(manager.count).toBe(0)
expect(manager.get(entity.id)).toBeUndefined()
})
it('findAtPoint and findNearby correctly filter by radius', () => {
const manager = new GroundItemManager()
const item1 = manager.add({ name: 'Item 1' }, 0, 0, 100, 100)
const item2 = manager.add({ name: 'Item 2' }, 0, 0, 105, 105)
const item3 = manager.add({ name: 'Item 3' }, 0, 0, 200, 200)
const hit = manager.findAtPoint(102, 102, 10)
expect(hit).toBeDefined()
expect(hit?.id === item1.id || hit?.id === item2.id).toBe(true)
const nearby = manager.findNearby(100, 100, 20)
expect(nearby.length).toBe(2)
expect(nearby.map(i => i.id)).toContain(item1.id)
expect(nearby.map(i => i.id)).toContain(item2.id)
expect(nearby.map(i => i.id)).not.toContain(item3.id)
})
it('parabolic bounce equation h(p) = 4 * H * p * (1 - p) calculates exact apex and landing', () => {
const bounce: GroundItemBounceState = {
startTime: 1000,
durationMs: 400,
peakHeightPx: 32,
}
// At start (t = 1000): h = 0
expect(calculateBounceHeight(bounce, 1000)).toBe(0)
// At apex (t = 1200, p = 0.5): h = 4 * 32 * 0.5 * 0.5 = 32
expect(calculateBounceHeight(bounce, 1200)).toBe(32)
// At quarter time (p = 0.25): h = 4 * 32 * 0.25 * 0.75 = 24
expect(calculateBounceHeight(bounce, 1100)).toBe(24)
// At landing (t = 1400): h = 0
expect(calculateBounceHeight(bounce, 1400)).toBe(0)
// After duration: h = 0
expect(calculateBounceHeight(bounce, 1500)).toBe(0)
})
it('manager.update advances bounce from primary to secondary bounce and completes', () => {
const manager = new GroundItemManager()
const entity = manager.add({ name: 'Rune' }, 0, 0, 100, 100, {
bounce: true,
durationMs: 400,
peakHeight: 24,
now: 1000,
})
expect(entity.bounceState?.phase).toBe('primary')
// Advance past primary duration (1450ms)
manager.update(1450, 450)
expect(entity.bounceState?.phase).toBe('secondary')
expect(entity.bounceState?.peakHeightPx).toBe(6) // 24 * 0.25 = 6
// Advance past secondary duration
manager.update(1700, 250)
expect(entity.bounceState).toBeNull()
})
})
describe('4. InventoryPanel Gold Dropping & findFreeGridSlot', () => {
it('InventoryPanel.dropGold deducts gold from player inventory', () => {
const panel = new InventoryPanel()
panel.gold = 50_000
const dropped = panel.dropGold(10_000)
expect(dropped).toBe(10_000)
expect(panel.gold).toBe(40_000)
// Cannot drop more than available gold
const dropExcess = panel.dropGold(100_000)
expect(dropExcess).toBe(40_000)
expect(panel.gold).toBe(0)
})
it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => {
const gridItems: GridPlacement[] = [
{
item: { id: '1', invWidth: 2, invHeight: 2 } as any,
col: 0,
row: 0,
},
]
// A 1x1 item should find col: 2, row: 0 (or col: 0, row: 2)
const slot1x1 = findFreeGridSlot(gridItems, 1, 1, 10, 4)
expect(slot1x1).toEqual({ col: 2, row: 0 })
// A 2x2 item cannot fit at (0, 0) or (1, 0), finds (2, 0)
const slot2x2 = findFreeGridSlot(gridItems, 2, 2, 10, 4)
expect(slot2x2).toEqual({ col: 2, row: 0 })
})
it('findFreeGridSlot returns null when grid is completely packed', () => {
const gridItems: GridPlacement[] = []
// Fill all 10x4 cells with 1x1 items
for (let r = 0; r < 4; r += 1) {
for (let c = 0; c < 10; c += 1) {
gridItems.push({
item: { id: `item_${c}_${r}`, invWidth: 1, invHeight: 1 } as any,
col: c,
row: r,
})
}
}
const slot = findFreeGridSlot(gridItems, 1, 1, 10, 4)
expect(slot).toBeNull()
})
})
describe('5. GameEngine & GroundItems Integration', () => {
it('GameEngine.dropItem creates ground entity and syncs engine.ground', () => {
const engine = new GameEngine(
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
{
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
},
)
expect(engine.ground.length).toBe(0)
expect(engine.groundItems.count).toBe(0)
const dropped = engine.dropItem(
{ name: 'Broad Sword', quality: 'normal', invWidth: 1, invHeight: 3 } as any,
120,
130,
)
expect(engine.ground.length).toBe(1)
expect(engine.groundItems.count).toBe(1)
expect(dropped.x).toBe(120)
expect(dropped.y).toBe(130)
expect(dropped.name).toBe('Broad Sword')
expect(engine.ground[0]!.x).toBe(120)
expect(engine.ground[0]!.y).toBe(130)
})
it('GameEngine.dropGold creates gold entity on ground and updates groundItems', () => {
const engine = new GameEngine(
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
{
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
},
)
const goldEntity = engine.dropGold(5000, 150, 160)
expect(goldEntity).toBeDefined()
expect(goldEntity!.isGold).toBe(true)
expect(goldEntity!.amount).toBe(5000)
expect(goldEntity!.nameZh).toBe('5000 金币')
expect(engine.ground.length).toBe(1)
expect(engine.groundItems.count).toBe(1)
})
})
describe('6. findIsometricDropPosition & Ground Diamond Dispersal (D2 1.13c Ground Truth)', () => {
it('returns exact origin when ground has no existing items', () => {
const pos = findIsometricDropPosition(500, 300, [])
expect(pos.x).toBe(500)
expect(pos.y).toBe(300)
})
it('disperses multiple items dropped at identical epicenter onto unoccupied isometric grid slots', () => {
const items: { x: number; y: number }[] = []
for (let i = 0; i < 5; i++) {
const pos = findIsometricDropPosition(400, 400, items)
items.push(pos)
}
expect(items.length).toBe(5)
expect(items[0]).toEqual({ x: 400, y: 400 })
// Verify no two items share identical coordinates or are closer than minSpacing
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const dist = Math.hypot(items[i]!.x - items[j]!.x, items[i]!.y - items[j]!.y)
expect(dist).toBeGreaterThanOrEqual(20)
}
}
})
it('avoids colliding with obstacles when isBlocked is provided', () => {
const blockedCells = new Set([
// Block (400, 424) which is the nearest (1, 1) candidate
'400,424',
])
const isBlocked = (x: number, y: number) => blockedCells.has(`${x},${y}`)
const existing = [{ x: 400, y: 400 }]
const pos = findIsometricDropPosition(400, 400, existing, isBlocked)
expect(pos.x === 400 && pos.y === 424).toBe(false)
expect(isBlocked(pos.x, pos.y)).toBe(false)
expect(Math.hypot(pos.x - 400, pos.y - 400)).toBeGreaterThanOrEqual(20)
})
it('GameEngine.dropItem repeatedly spreads items out across isometric lattice without stacking', () => {
const engine = new GameEngine(
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
{
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
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)
// d2 and d3 must be dispersed and not stacked at (200, 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(20)
expect(Math.hypot(d3.x - d1.x, d3.y - d1.y)).toBeGreaterThanOrEqual(20)
expect(Math.hypot(d3.x - d2.x, d3.y - d2.y)).toBeGreaterThanOrEqual(20)
})
})
})