import { describe, it, expect } from 'vitest' import { GroundItemManager, findIsometricDropPosition, getIsometricRingCandidates, isDropPositionBlocked, calculateBounceHeight, triggerFlippyBounce, } from '../src/game/ground-items.ts' import type { GroundItemEntity } from '../src/game/ground-items.ts' import { GameEngine, worldToCell, } from '../src/game/engine.ts' import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine.ts' import { COLLIDE_NONE, COLLIDE_WALL, COLLIDE_ITEM, COLLIDE_OBJECT, COLLIDE_DOOR, COLLIDE_NO_PATH, COLLIDE_PET, COLLIDE_MASK_SPAWN, COLLIDE_MASK_SPAWN_LOS, ORTHO_CELL_WIDTH, ORTHO_CELL_HEIGHT, ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, subTileAt, } from '../src/game/d2map.ts' import type { CollisionGrid } from '../src/game/d2map.ts' import { drawGroundItem } from '../src/scene/act-scene.ts' import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import type { SpriteRenderer } from '../src/render/renderer.ts' import { DEMO_SKILLS, DEMO_EXPERIENCE } from '../src/game/demo-data.ts' function createDummyTerrain(widthPx = 2000, heightPx = 2000, originX = 0, originY = 0): WorldMapProvider { return { widthPx, heightPx, originX, originY, overlap: () => 0, } } function createMockRenderer() { const drawnQuads: { x: number y: number w: number h: number color: readonly [number, number, number, number] }[] = [] const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] const renderer: SpriteRenderer = { drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { drawnQuads.push({ x, y, w, h, color }) }, draw(frame: any, x: number, y: number, options: any) { drawnSprites.push({ frame, x, y, options }) }, } as unknown as SpriteRenderer return { renderer, drawnQuads, drawnSprites } } describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometric Scatter & Flippy Bounce', () => { describe('1. Monster Loot & Gold Ground Spawning (Elimination of Silent Bag Addition)', () => { it('monster gold drops spawn as GroundItemEntity on the ground and do NOT silently add to player bag', () => { const terrain = createDummyTerrain() const engineOpts: GameEngineOptions = { spawn: { x: 500, y: 500 }, stats: [ { id: 'monster1', name: 'Zombie', level: 3, hp: 1, // 1 hp so one hit kills damage: 0, cooldownTicks: 10, reach: 30, aggroRadius: 100, speed: 0, xp: 10, }, ], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], itemBases: [ { id: 'gold', name: 'Gold', invWidth: 1, invHeight: 1, code: 'gld' } as any, ], combatOptions: { playerSpeed: 10, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, monsterCount: 1, monsterSpread: 10, } const engine = new GameEngine(terrain, engineOpts) // Initial state: bag has 0 gold, 0 ground items expect(engine.bag.gold).toBe(0) expect(engine.groundItems.count).toBe(0) expect(engine.ground.length).toBe(0) // Trigger kill via attacking let killed = false for (let tick = 0; tick < 20; tick++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) if (engine.world.events.some(e => e.kind === 'kill')) { killed = true break } } expect(killed).toBe(true) // If gold or items dropped, they MUST be on the ground, NEVER siphoned into bag! if (engine.groundItems.count > 0) { const goldDrops = engine.groundItems.all.filter(g => g.isGold) if (goldDrops.length > 0) { // Strict 1.13c parity: Gold is on the floor! expect(engine.bag.gold).toBe(0) for (const g of goldDrops) { expect(g.isGold).toBe(true) expect(g.amount).toBeGreaterThan(0) expect(g.name).toContain('Gold') expect(g.nameZh).toContain('金币') expect(g.bounceState).toBeDefined() expect(g.bounceState?.phase).toBe('primary') } } } }) it('pickupGold collects gold from ground and correctly synchronizes player bag gold', () => { const terrain = createDummyTerrain() const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 10, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, }) // Drop 2500 gold const goldEntity = engine.dropGold(2500, 520, 520) expect(goldEntity).toBeDefined() expect(engine.groundItems.count).toBe(1) expect(engine.ground.length).toBe(1) expect(engine.bag.gold).toBe(0) // Pickup gold via ID const result = engine.pickupGold(goldEntity!.id) expect(result.success).toBe(true) expect(result.amount).toBe(2500) expect(engine.bag.gold).toBe(2500) expect(engine.groundItems.count).toBe(0) expect(engine.ground.length).toBe(0) expect(engine.metrics.pickups).toBe(1) }) it('pickupItem with gold entity delegates to pickupGold and updates bag', () => { const terrain = createDummyTerrain() const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 10, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, }) const goldEntity = engine.dropGold(1200, 510, 510) const res = engine.pickupItem(goldEntity!.id) expect(res.success).toBe(true) expect(engine.bag.gold).toBe(1200) expect(engine.groundItems.count).toBe(0) }) it('walking over gold with pickingUp=true adds gold to bag and removes ground item', () => { const terrain = createDummyTerrain() const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 10, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 50, pickupRadius: 40, inventoryCols: 10, inventoryRows: 4, }) // Drop gold 10px away from player engine.dropGold(750, 505, 505) expect(engine.bag.gold).toBe(0) // Player picks up engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: true, talking: false, digits: [], saving: false, loading: false, }) expect(engine.bag.gold).toBe(750) expect(engine.groundItems.count).toBe(0) expect(engine.ground.length).toBe(0) }) }) describe('2. Cell Coordinate Inversion Synchronization (worldToCell)', () => { it('accurately converts world coordinates to cell coordinates under 2:1 isometric projection', () => { const terrain: WorldMapProvider = { widthPx: 5000, heightPx: 5000, originX: 1000, originY: 500, overlap: () => 0, } // origin cell (0, 0) const c0 = worldToCell(terrain, 1000, 500) expect(c0.cellX).toBe(0) expect(c0.cellY).toBe(0) // Cell (1, 0): worldX = 1000 + (1 - 0) * 80 = 1080, worldY = 500 + (1 + 0) * 40 = 540 const c10 = worldToCell(terrain, 1080, 540) expect(c10.cellX).toBe(1) expect(c10.cellY).toBe(0) // Cell (0, 1): worldX = 1000 + (0 - 1) * 80 = 920, worldY = 500 + (0 + 1) * 40 = 540 const c01 = worldToCell(terrain, 920, 540) expect(c01.cellX).toBe(0) expect(c01.cellY).toBe(1) // Cell (5, 8): // worldX = 1000 + (5 - 8) * 80 = 760 // worldY = 500 + (5 + 8) * 40 = 1020 const c58 = worldToCell(terrain, 760, 1020) expect(c58.cellX).toBe(5) expect(c58.cellY).toBe(8) }) it('GameEngine.dropItem and dropGold assign actual cellX/cellY rather than hardcoding (0, 0)', () => { const terrain: WorldMapProvider = { widthPx: 5000, heightPx: 5000, originX: 0, originY: 0, overlap: () => 0, } const engine = new GameEngine(terrain, { spawn: { x: 800, y: 400 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 10, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, }) const item = engine.dropItem({ name: 'Long Bow' } as any, 800, 400) // (800, 400) with origin (0, 0): px = 800, py = 400 // cellX = floor((800/80 + 400/40) / 2) = floor((10 + 10) / 2) = 10 // cellY = floor((400/40 - 800/80) / 2) = floor((10 - 10) / 2) = 0 expect(item.cellX).toBe(10) expect(item.cellY).toBe(0) expect(item.cellX !== 0 || item.cellY !== 0).toBe(true) const gold = engine.dropGold(100, 160, 240) // (160, 240): px = 160, py = 240 // cellX = floor((160/80 + 240/40) / 2) = floor((2 + 6) / 2) = 4 // cellY = floor((240/40 - 160/80) / 2) = floor((6 - 2) / 2) = 2 expect(gold!.cellX).toBe(4) expect(gold!.cellY).toBe(2) }) it('items dropped at distinct cell coordinates have desynchronized sparkle phase offsets', () => { const period = 2000 const getPhase = (cellX: number, cellY: number) => Math.abs(Math.floor(cellX * 31 + cellY * 17)) % period const phase1 = getPhase(10, 5) const phase2 = getPhase(11, 5) const phase3 = getPhase(10, 6) expect(phase1).not.toBe(phase2) expect(phase1).not.toBe(phase3) expect(phase2).not.toBe(phase3) }) }) describe('3. Clock Domain Synchronization & Parabolic Flippy Bounce Kinematics', () => { it('GameEngine drops synchronize bounce startTime with simulation clock (world.tick * 40)', () => { const terrain = createDummyTerrain() const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 10, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, }) // Advance engine by 25 ticks (tick = 25, sim time = 1000ms) for (let i = 0; i < 25; i++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) } expect(engine.world.tick).toBe(25) const dropped = engine.dropItem({ name: 'Short Sword' } as any, 500, 500) expect(dropped.bounceState).toBeDefined() // startTime must match simulation clock 25 * 40 = 1000, NOT performance.now() expect(dropped.bounceState!.startTime).toBe(1000) expect(dropped.dropTime).toBe(1000) }) it('2-phase bounce decay advances in GameEngine simulation and transitions cleanly to resting state', () => { const manager = new GroundItemManager() const entity = manager.add({ name: 'Helm' }, 2, 3, 200, 200, { bounce: true, durationMs: 400, peakHeight: 24, now: 0, // sim time 0 }) expect(entity.bounceState?.phase).toBe('primary') expect(entity.bounceState?.peakHeightPx).toBe(24) // Apex of primary bounce (t = 200ms) expect(calculateBounceHeight(entity.bounceState!, 200)).toBe(24) // Advance to 400ms (end of primary bounce): transitions to secondary bounce manager.update(400, 40) expect(entity.bounceState?.phase).toBe('secondary') expect(entity.bounceState?.durationMs).toBe(200) // 50% of 400ms expect(entity.bounceState?.peakHeightPx).toBe(6) // 25% of 24px // Apex of secondary bounce (t = 500ms, p = 0.5 of 200ms) expect(calculateBounceHeight(entity.bounceState!, 500)).toBe(6) // Advance past secondary duration (t = 600ms): transitions to null (resting) manager.update(600, 40) expect(entity.bounceState).toBeNull() expect(manager.getRenderOffset(entity, 600)).toEqual({ x: 0, y: 0 }) }) it('defensively guards calculateBounceHeight against non-positive or expired elapsed times', () => { const bounce = { startTime: 1000, durationMs: 400, peakHeightPx: 24, } // Negative elapsed (sub-frame jitter / out of order): returns 0, never negative expect(calculateBounceHeight(bounce, 900)).toBe(0) expect(calculateBounceHeight(bounce, 999.9)).toBe(0) // Exactly at start (t = 1000): returns 0 expect(calculateBounceHeight(bounce, 1000)).toBe(0) // At duration boundary (t = 1400): returns 0 expect(calculateBounceHeight(bounce, 1400)).toBe(0) // Far in future: returns 0 expect(calculateBounceHeight(bounce, 2000)).toBe(0) }) }) describe('4. 2:1 Isometric Diamond Grid Scatter Algorithm Parity', () => { it('produces authentic D2Common 8-slot isometric diamond sequence on 16x8 sub-tile lattice', () => { const originX = 1000 const originY = 800 const existingItems: { x: number; y: number }[] = [] // Drop 9 items in sequence at same epicenter for (let i = 0; i < 9; i++) { const pos = findIsometricDropPosition(originX, originY, existingItems) existingItems.push(pos) } expect(existingItems.length).toBe(9) // Drop 1: Epicenter (Ring 0) expect(existingItems[0]).toEqual({ x: 1000, y: 800 }) // Ring 1 canonical sequence: // Drop 2: (-1, 0) -> (-16, -8) expect(existingItems[1]).toEqual({ x: 1000 - 16, y: 800 - 8 }) // Drop 3: (1, 0) -> (+16, +8) expect(existingItems[2]).toEqual({ x: 1000 + 16, y: 800 + 8 }) // Drop 4: (0, -1) -> (+16, -8) expect(existingItems[3]).toEqual({ x: 1000 + 16, y: 800 - 8 }) // Drop 5: (0, 1) -> (-16, +8) expect(existingItems[4]).toEqual({ x: 1000 - 16, y: 800 + 8 }) // Drop 6: (-1, -1) -> (0, -16) [Top Apex] expect(existingItems[5]).toEqual({ x: 1000, y: 800 - 16 }) // Drop 7: (1, -1) -> (+32, 0) [Right Apex] expect(existingItems[6]).toEqual({ x: 1000 + 32, y: 800 }) // Drop 8: (-1, 1) -> (-32, 0) [Left Apex] expect(existingItems[7]).toEqual({ x: 1000 - 32, y: 800 }) // Drop 9: (1, 1) -> (0, +16) [Bottom Apex] expect(existingItems[8]).toEqual({ x: 1000, y: 800 + 16 }) // Verify exact 2:1 isometric diamond aspect ratio: // Horizontal diameter = 32 - (-32) = 64px // Vertical diameter = 16 - (-16) = 32px // Ratio = 64 / 32 = 2.0 (exact 2:1!) const minX = Math.min(...existingItems.map(p => p.x)) const maxX = Math.max(...existingItems.map(p => p.x)) const minY = Math.min(...existingItems.map(p => p.y)) const maxY = Math.max(...existingItems.map(p => p.y)) const spanX = maxX - minX const spanY = maxY - minY expect(spanX).toBe(64) expect(spanY).toBe(32) expect(spanX / spanY).toBe(2) }) it('strictly minimizes L1 Manhattan distance (|dx| + |dy|) within concentric rings', () => { const ring1Cands = getIsometricRingCandidates(1) expect(ring1Cands.length).toBe(8) // First 4 candidates have L1 = 1 (|dx| + |dy| = 1) for (let i = 0; i < 4; i++) { expect(ring1Cands[i]!.l1).toBe(1) } // Remaining 4 corner candidates have L1 = 2 for (let i = 4; i < 8; i++) { expect(ring1Cands[i]!.l1).toBe(2) } const ring2Cands = getIsometricRingCandidates(2) expect(ring2Cands.length).toBe(16) // L1 must be monotonically non-decreasing for (let i = 1; i < ring2Cands.length; i++) { expect(ring2Cands[i]!.l1).toBeGreaterThanOrEqual(ring2Cands[i - 1]!.l1) } }) it('anti-stacking: all items maintain minimum spacing >= 16px', () => { const items: { x: number; y: number }[] = [] for (let i = 0; i < 15; i++) { const pos = findIsometricDropPosition(500, 500, items) items.push(pos) } 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) } } }) }) describe('5. Collision Masks & Line-of-Sight Raycast Protection', () => { it('rejects candidate sub-tiles with COLLIDE_MASK_SPAWN bits set', () => { // Create a 10x10 cell grid (50x50 sub-tiles) const widthSub = 50 const heightSub = 50 const collisionMasks = new Uint16Array(widthSub * heightSub) const mockGrid: CollisionGrid = { gridWidth: widthSub, cellsX: 10, cellsY: 10, originX: 0, originY: 0, blocked: new Uint8Array(widthSub * heightSub), collisionMasks, } // Origin at (400, 200) const originX = 400 const originY = 200 // Block candidate 2: (-16, -8) from origin const cand2X = originX - 16 const cand2Y = originY - 8 const cand2Sub = subTileAt(mockGrid, cand2X, cand2Y) collisionMasks[cand2Sub.subY * widthSub + cand2Sub.subX] = COLLIDE_WALL // 0x0001 (part of COLLIDE_MASK_SPAWN 0x3E01) const existing = [{ x: originX, y: originY }] const pos = findIsometricDropPosition(originX, originY, existing, mockGrid) // Candidate 2 is blocked by COLLIDE_WALL, so it skips to Candidate 3 (+16, +8) expect(pos).not.toEqual({ x: cand2X, y: cand2Y }) expect(pos).toEqual({ x: originX + 16, y: originY + 8 }) }) it('rejects candidates across walls via COLLIDE_MASK_SPAWN_LOS line-of-sight raycast', () => { const widthSub = 50 const heightSub = 50 const collisionMasks = new Uint16Array(widthSub * heightSub) const mockGrid: CollisionGrid = { gridWidth: widthSub, cellsX: 10, cellsY: 10, originX: 0, originY: 0, blocked: new Uint8Array(widthSub * heightSub), collisionMasks, } const originX = 400 const originY = 200 // Place a closed door (COLLIDE_DOOR = 0x0800, in COLLIDE_MASK_SPAWN_LOS) on the ray // between origin (400, 200) and right apex (432, 200) const mid = subTileAt(mockGrid, 416, 200) collisionMasks[mid.subY * widthSub + mid.subX] = COLLIDE_DOOR const existing: { x: number; y: number }[] = [ { x: originX, y: originY }, { x: originX - 16, y: originY - 8 }, { x: originX + 16, y: originY + 8 }, { x: originX + 16, y: originY - 8 }, { x: originX - 16, y: originY + 8 }, { x: originX, y: originY - 16 }, ] // Next candidate would naturally be Right Apex (+32, 0) const pos = findIsometricDropPosition(originX, originY, existing, mockGrid) // Ray to (+32, 0) hits the door, so Right Apex must be rejected expect(pos).not.toEqual({ x: originX + 32, y: originY }) // Drops to Left Apex (-32, 0) or unblocked candidate instead expect(pos).toEqual({ x: originX - 32, y: originY }) }) }) describe('6. Rendering & Sprite Selection Parity', () => { it('drawGroundItem renders sparkle glints on resting items when itemsAtlas is available', () => { const { renderer, drawnQuads, drawnSprites } = createMockRenderer() const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any const groundSword: GroundItemEntity = { id: 'sword_resting', item: { name: 'Short Sword', code: 'ssd' }, name: 'Short Sword', nameZh: '短剑', quality: 'magic', isGold: false, amount: 1, invWidth: 1, invHeight: 3, dropTime: 0, x: 300, y: 250, cellX: 0, cellY: 0, sparklePhase: 0, bounceState: null, // resting! } // Render during active flash window (t = 160ms, period = 2000, 160 < 320) drawGroundItem(renderer, groundSword, 160, mockAtlas) // Verified: Sprite is drawn expect(drawnSprites.length).toBe(1) expect(drawnSprites[0]!.options.atlas).toBe(mockAtlas) // Verified: Drop shadow is drawn const shadow = drawnQuads.find(q => q.y === 250 - 2 && q.color[3] <= 0.4) expect(shadow).toBeDefined() // Verified: Sparkle star rays and core are drawn even with itemsAtlas present! const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) expect(whiteGlint).toBeDefined() }) it('suppresses sparkle glints while item is airborne in bounce trajectory', () => { const { renderer, drawnQuads } = createMockRenderer() const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any const airborneSword: GroundItemEntity = { id: 'sword_airborne', item: { name: 'Short Sword', code: 'ssd' }, name: 'Short Sword', nameZh: '短剑', quality: 'magic', isGold: false, amount: 1, invWidth: 1, invHeight: 3, dropTime: 0, x: 300, y: 250, cellX: 0, cellY: 0, sparklePhase: 0, bounceState: { startTime: 0, durationMs: 400, peakHeightPx: 24, phase: 'primary', }, } // At t = 160ms (apex bounceH > 0), item is mid-air drawGroundItem(renderer, airborneSword, 160, mockAtlas) // While airborne, sparkle rays should NOT be drawn const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) expect(whiteGlint).toBeUndefined() }) it('resolves gold pile sprite tier based on amount (<100, 100-499, 500-4999, >=5000)', () => { const flippyRects = BAKED_UI_MANIFEST.flippyRects const small = resolveGroundItemSpriteRect({ isGold: true, amount: 45, code: 'gld' }) expect(small).toEqual(flippyRects['flpgld_0']) const med = resolveGroundItemSpriteRect({ isGold: true, amount: 250, code: 'gld' }) expect(med).toEqual(flippyRects['flpgld_1']) const large = resolveGroundItemSpriteRect({ isGold: true, amount: 2000, code: 'gld' }) expect(large).toEqual(flippyRects['flpgld_2']) const huge = resolveGroundItemSpriteRect({ isGold: true, amount: 15000, code: 'gld' }) expect(huge).toEqual(flippyRects['flpgld_3']) }) }) })