/** * 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 = { 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 = { 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(16) } } }) 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(16) }) 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(16) expect(Math.hypot(d3.x - d1.x, d3.y - d1.y)).toBeGreaterThanOrEqual(16) expect(Math.hypot(d3.x - d2.x, d3.y - d2.y)).toBeGreaterThanOrEqual(16) }) }) })