/** * Diablo II: Lord of Destruction v1.13c — Monster Drop End-to-End Integration Suite * * Test Specifications for Milestone M11.5 (Issue #408): * 1. All 4 Damage Sources & Single Drop Gate Invariant: * - Melee normal attack * - Projectile skill (Fireball / Firebolt) * - Instant radial AoE (Frost Nova / Nova) * - Continuous periodic aura pulse (Holy Fire at tick % 50 === 0) * - Single drop gate assertion: metrics.dropsRolled === 1, no duplicate spawns. * * 2. All 4 Monster Ranks & Authentic TreasureClass Hierarchy: * - Normal (fallen1, zombie1, skeleton1) -> TC1 (Act 1 H2H A) * - Champion -> TC2 (Act 1 Champ A) * - Unique -> TC3 (Act 1 Unique A) * - SuperUnique: Bishibosh, Rakanishu, Blood Raven, The Countess (rune drop picks = -2) * * 3. Full UI Inventory Pickup & Equipping Loop: * - Conversion via itemToUiInventoryItem * - 100% invFile atlas match against BAKED_UI_MANIFEST.itemRects * - Non-empty bilingual nameZh and formatted stats array * - Authentic allowedSlots resolution and paperdoll equipping * * 4. Gold Currency Accumulation vs Inventory Full Flippy Bounce: * - Gold currency accumulation clamped to PLAYER_GOLD_CAP (2,500,000) * - Full 10x4 inventory grid refusal, metrics.inventoryRefusals, and parabolic bounce physics */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { DEMO_EXPERIENCE } from '../src/game/demo-data.ts' import { getMonsterTreasureClass } from '../src/game/monsters.ts' import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts' import { InventoryPanel, resolveItemSpriteRect, PLAYER_GOLD_CAP, type UiInventoryItem, type EquipSlotId, } from '../src/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { SceneMouseController } from '../src/scene/act-scene.ts' import type { Monster } from '../src/game/combat.ts' describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration Suite', () => { const dropTables = getEmbeddedDropTables() const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } function createTestEngine(overrides?: Partial) { const engine = new GameEngine(terrain, { spawn: { x: 500, y: 500 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: [ { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 0, range: 48, projectile: false, speed: 0, baseMinDamage: 50, baseMaxDamage: 50, damagePerLevel: 0, radius: 20, }, { id: 'fireball', name: 'Fire Ball', manaCost: 0, cooldownTicks: 0, range: 500, projectile: true, speed: 300, baseMinDamage: 50, baseMaxDamage: 50, damagePerLevel: 0, radius: 20, }, { id: 'frostnova', name: 'Frost Nova', manaCost: 0, cooldownTicks: 0, range: 150, projectile: false, speed: 0, baseMinDamage: 50, baseMaxDamage: 50, damagePerLevel: 0, radius: 150, }, ], questDefs: [], combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 0, playerDamage: 50, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 48, pickupRadius: 48, inventoryCols: 10, inventoryRows: 4, lootSeed: 42, npcDefs: [], ...overrides, }) engine.setDropTables(dropTables) return engine } function createTestMonster( index: number, id: string, x: number, y: number, hp = 20, rank: 'normal' | 'champion' | 'unique' | 'minion' = 'normal', superUniqueId?: string, ): Monster { return { index, stats: { id, name: id, hp, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 10, xp: 10, level: 5, rank, ...(superUniqueId !== undefined ? { superUniqueId } : {}), }, x, y, hp, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, dropRolled: false, } } describe('1. All 4 Damage Sources & Single Drop Gate Invariant', () => { it('deals Melee Normal Attack damage, kills monster, rolls drop once, and respects single drop gate', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'fallen1', 520, 500, 20) engine.world.monsters.push(monster) engine.selectedSkill = 0 // Attack // Tick 1: Player melee attacks monster engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(monster.state).toBe('dead') expect(monster.dropRolled).toBe(true) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) expect(engine.ground.length).toBe(engine.groundItems.count) const initialDropCount = engine.groundItems.count // Subsequent Ticks: Advance simulation multiple ticks — assert single drop gate invariant for (let t = 0; t < 5; t++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) } expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBe(initialDropCount) }) it('casts Projectile Skill (Fireball), collides with monster, kills, and respects single drop gate', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'zombie1', 520, 500, 20) engine.world.monsters.push(monster) engine.selectedSkill = 1 // Fireball engine.world.player.facing = 6 // Facing East towards (520, 500) // Tick 1: Cast fireball projectile towards East engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) // Tick 2: Projectile advances, collides, damage applied, pending kills processed engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(monster.state).toBe('dead') expect(monster.dropRolled).toBe(true) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) const initialDropCount = engine.groundItems.count // Subsequent Ticks: Single drop gate invariant check for (let t = 0; t < 5; t++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) } expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBe(initialDropCount) }) it('casts Instant AoE Skill (Frost Nova), hits monster in radius, and respects single drop gate', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'skeleton1', 550, 500, 20) engine.world.monsters.push(monster) engine.selectedSkill = 2 // Frost Nova (radius 150) // Tick 1: Cast Frost Nova instant radial AoE engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(monster.state).toBe('dead') expect(monster.dropRolled).toBe(true) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) const initialDropCount = engine.groundItems.count // Subsequent Ticks: Single drop gate invariant check for (let t = 0; t < 5; t++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) } expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBe(initialDropCount) }) it('inflicts Continuous Aura Pulse (Holy Fire) at tick % 50 === 0, kills monster, and respects single drop gate', () => { const engine = createTestEngine() engine.isTown = false engine.levelId = 2 // Blood Moor (wilderness) engine.activeAuraSkillId = 102 // Holy Fire engine.activeAuraLevel = 20 // High aura level for guaranteed kill const monster = createTestMonster(0, 'fallen1', 530, 500, 10) engine.world.monsters.push(monster) // Advance engine ticks until tick 49 (right before periodic pulse at tick 50) while (engine.world.tick % 50 !== 49) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) } expect(monster.state).not.toBe('dead') expect(engine.metrics.dropsRolled).toBe(0) // Tick 50: Holy Fire periodic pulse fires engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(engine.world.tick % 50).toBe(0) expect(monster.state).toBe('dead') expect(monster.dropRolled).toBe(true) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) const dropCountAtPulse = engine.groundItems.count // Advance through next 55 ticks (including tick 100 second pulse) for (let t = 0; t < 55; t++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) } expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBe(dropCountAtPulse) }) }) describe('2. All 4 Monster Ranks & Authentic TreasureClass Hierarchy', () => { it('resolves Normal rank monsters (fallen1, zombie1, skeleton1) to TC1 (Act 1 H2H A)', () => { const normalIds = ['fallen1', 'zombie1', 'skeleton1'] as const for (const id of normalIds) { const kind = dropTables.monsterKinds.get(id)! expect(kind).toBeDefined() const tcName = getMonsterTreasureClass(kind, 'normal', 1) expect(tcName).toBe('Act 1 H2H A') const engine = createTestEngine({ lootSeed: 100 }) const monster = createTestMonster(0, id, 520, 500, 10, 'normal') engine.world.monsters.push(monster) engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) } }) it('resolves Champion rank monster to TC2 (Act 1 Champ A) and generates champion drop', () => { const kind = dropTables.monsterKinds.get('fallen1')! expect(getMonsterTreasureClass(kind, 'normal', 2)).toBe('Act 1 Champ A') const engine = createTestEngine({ lootSeed: 200 }) const champ = createTestMonster(0, 'fallen1', 520, 500, 10, 'champion') engine.world.monsters.push(champ) engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) }) it('resolves Unique rank monster to TC3 (Act 1 Unique A) and generates unique drop', () => { const kind = dropTables.monsterKinds.get('fallen1')! expect(getMonsterTreasureClass(kind, 'normal', 3)).toBe('Act 1 Unique A') const engine = createTestEngine({ lootSeed: 300 }) const unique = createTestMonster(0, 'fallen1', 520, 500, 10, 'unique') engine.world.monsters.push(unique) engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [], }) expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThan(0) }) it('resolves SuperUniques: Bishibosh (Act 1 Super A), Rakanishu (Act 1 Super B), Blood Raven, and The Countess (Countess Rune drop)', () => { // 1. Bishibosh const bishiboshDef = dropTables.superUniques.get('Bishibosh')! expect(bishiboshDef.getTreasureClass?.('normal')).toBe('Act 1 Super A') const engineBishi = createTestEngine({ lootSeed: 5555 }) engineBishi.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'Bishibosh')) engineBishi.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] }) expect(engineBishi.metrics.dropsRolled).toBe(1) expect(engineBishi.groundItems.count).toBeGreaterThan(0) // 2. Rakanishu const rakanishuDef = dropTables.superUniques.get('Rakanishu')! expect(rakanishuDef.getTreasureClass?.('normal')).toBe('Act 1 Super B') const engineRaka = createTestEngine({ lootSeed: 7777 }) engineRaka.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'Rakanishu')) engineRaka.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] }) expect(engineRaka.metrics.dropsRolled).toBe(1) expect(engineRaka.groundItems.count).toBeGreaterThan(0) // 3. Blood Raven (Boss kind bloodraven) const bloodRavenKind = dropTables.monsterKinds.get('bloodraven')! expect(bloodRavenKind.boss).toBe(true) expect(getMonsterTreasureClass(bloodRavenKind, 'normal', 1)).toBe('Blood Raven') const engineRaven = createTestEngine({ lootSeed: 9999 }) engineRaven.world.monsters.push(createTestMonster(0, 'bloodraven', 520, 500, 10, 'unique')) engineRaven.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] }) expect(engineRaven.metrics.dropsRolled).toBe(1) expect(engineRaven.groundItems.count).toBeGreaterThan(0) // 4. The Countess (picks = -2, Countess Item and Countess Rune) const countessDef = dropTables.superUniques.get('The Countess')! expect(countessDef.getTreasureClass?.('normal')).toBe('Countess') const engineCountess = createTestEngine({ lootSeed: 12345678 }) engineCountess.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'The Countess')) engineCountess.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] }) expect(engineCountess.metrics.dropsRolled).toBe(1) expect(engineCountess.groundItems.count).toBeGreaterThan(0) // Assert Countess rune drop is generated on ground const countessGroundItems = engineCountess.ground.map(g => g.item) const hasRune = countessGroundItems.some(i => i.code?.trim().startsWith('r') || i.quality === 8 || i.name.toLowerCase().includes('rune')) expect(hasRune, 'The Countess must drop at least one Rune from Countess Rune sub-TC').toBe(true) }) }) describe('3. Full UI Inventory Pickup Loop & Paperdoll Equipping', () => { let mockCanvas: any let mockStatus: { textContent: string } beforeEach(() => { mockStatus = { textContent: '' } mockCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), addEventListener: vi.fn(), removeEventListener: vi.fn(), } }) it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => { const engine = createTestEngine({ lootSeed: 12345678 }) const invPanel = new InventoryPanel() invPanel.gridItems = [] for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) { delete invPanel.equipped[k] } const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), } // Slay Countess to generate authentic varied drops (shield, helm, rune, potion) engine.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'The Countess')) engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] }) expect(engine.groundItems.count).toBeGreaterThan(0) const droppedEntities = [...engine.groundItems.all] const controller = new SceneMouseController({ canvas: mockCanvas, engine, camera: { zoom: 1 } as any, input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, hudManager, waypointNetwork: null as any, status: mockStatus as any, playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, getCharacter: () => null, }) // Pickup non-gold items and test bridge conversion let equippableItem: UiInventoryItem | null = null let equippableSlot: EquipSlotId | null = null for (const groundEntity of droppedEntities) { const itemObj = groundEntity.item const isGold = (groundEntity as any).isGold || itemObj.code?.trim() === 'gld' || itemObj.base?.id === 'gold' if (isGold) continue const uiItem = itemToUiInventoryItem(itemObj, dropTables) // 1. Invariant: 100% invFile Atlas Resolution expect(uiItem.invFile).toBeTruthy() const atlasRect = BAKED_UI_MANIFEST.itemRects[uiItem.invFile] ?? BAKED_UI_MANIFEST.itemRects[uiItem.invFile.toLowerCase()] expect(atlasRect, `Missing atlas sprite rect for ${uiItem.name} (${uiItem.invFile})`).toBeDefined() expect(atlasRect.w).toBeGreaterThan(0) expect(atlasRect.h).toBeGreaterThan(0) expect(resolveItemSpriteRect(uiItem)).not.toBeNull() // 2. Invariant: Bilingual Naming expect(uiItem.nameZh).toBeTruthy() expect(uiItem.nameZh.length).toBeGreaterThan(0) // 3. Invariant: Formatted Stats Array expect(Array.isArray(uiItem.stats)).toBe(true) // 4. Invariant: Allowed Slots Array expect(Array.isArray(uiItem.allowedSlots)).toBe(true) // Pickup via controller into inventory panel controller.pickupGroundItem(groundEntity) if (!equippableItem && uiItem.allowedSlots.length > 0) { equippableItem = uiItem equippableSlot = uiItem.allowedSlots[0]! } } expect(invPanel.gridItems.length).toBeGreaterThan(0) expect(equippableItem).not.toBeNull() expect(equippableSlot).not.toBeNull() // 5. Invariant: Character Paperdoll Slot Equipping const placed = invPanel.gridItems.find(p => p.item.id === equippableItem!.id) expect(placed).toBeDefined() // Left-click grid cell to lift item to cursor const pickedToCursor = invPanel.clickGridCell(placed!.col, placed!.row) expect(pickedToCursor).toBe(true) expect(invPanel.cursorItem).toBe(placed!.item) // Attempt equipping into invalid slot (e.g. boots if slot is amulet/weapon/helm) const invalidSlot: EquipSlotId = equippableSlot === 'boots' ? 'helm' : 'boots' const invalidEquip = invPanel.clickEquipSlot(invalidSlot) expect(invalidEquip).toBe(false) expect(invPanel.equipped[invalidSlot]).toBeUndefined() expect(invPanel.cursorItem).toBe(placed!.item) // Equip into valid matching slot const validEquip = invPanel.clickEquipSlot(equippableSlot!) expect(validEquip).toBe(true) expect(invPanel.equipped[equippableSlot!]).toBe(placed!.item) expect(invPanel.cursorItem).toBeNull() }) }) describe('4. Gold Currency Accumulation vs Inventory Full Flippy Bounce', () => { let mockCanvas: any let mockStatus: { textContent: string } beforeEach(() => { mockStatus = { textContent: '' } mockCanvas = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), addEventListener: vi.fn(), removeEventListener: vi.fn(), } }) it('accumulates gold drops into inventory currency and clamps to PLAYER_GOLD_CAP (2,500,000)', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() invPanel.gold = 2_450_000 const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), } // Drop 100,000 gold pile on ground const goldDrop = engine.dropGold(100_000, 510, 500) expect(goldDrop).not.toBeNull() const controller = new SceneMouseController({ canvas: mockCanvas, engine, camera: { zoom: 1 } as any, input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, hudManager, waypointNetwork: null as any, status: mockStatus as any, playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, getCharacter: () => null, }) controller.pickupGroundItem(goldDrop!) // 2,450,000 + 100,000 clamped to 2,500,000 expect(invPanel.gold).toBe(PLAYER_GOLD_CAP) expect(hudManager.syncPublishedState).toHaveBeenCalled() expect(mockStatus.textContent).toContain('拾起金币:100000') expect(engine.groundItems.count).toBe(0) }) it('refuses item pickup when 10x4 inventory grid is 100% full, triggers flippy bounce, and keeps item on ground', () => { const engine = createTestEngine() const invPanel = new InventoryPanel() invPanel.gridItems = [] // Fill all 10x4 = 40 cells with 10 2x2 filler items for (let c = 0; c < 10; c += 2) { for (let r = 0; r < 4; r += 2) { invPanel.gridItems.push({ item: { id: `filler-${c}-${r}`, code: 'cap', invFile: 'invcap', name: 'Cap', nameZh: '帽子 (Cap)', baseNameZh: '帽子 (Cap)', quality: 'normal', invWidth: 2, invHeight: 2, allowedSlots: ['helm'], stats: [], }, col: c, row: r, }) } } expect(invPanel.gridItems.length).toBe(10) // 40 cells completely occupied // Drop an item on the ground const testItemBase = dropTables.getBase('hax')! const droppedEntity = engine.dropItem( { code: 'hax', name: 'Hand Axe', quality: 2, invWidth: 1, invHeight: 2, base: testItemBase, stats: {}, prefix: null, suffix: null, level: 1, stack: 1, value: 100, } as any, 510, 500, ) const hudManager: any = { inventory: invPanel, syncPublishedState: vi.fn(), } const controller = new SceneMouseController({ canvas: mockCanvas, engine, camera: { zoom: 1 } as any, input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any, getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any, hudManager, waypointNetwork: null as any, status: mockStatus as any, playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any, getCharacter: () => null, }) const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback') const notificationSpy = vi.spyOn(controller, 'showNotification') controller.pickupGroundItem(droppedEntity) // Invariants: // 1. Refusal recorded in metrics expect(engine.metrics.inventoryRefusals).toBe(1) // 2. Item remains on ground expect(engine.groundItems.count).toBe(1) // 3. Parabolic flippy bounce initialized expect(droppedEntity.bounceState).toBeDefined() expect(droppedEntity.bounceState!.phase).toBe('primary') expect(droppedEntity.bounceState!.durationMs).toBe(350) expect(droppedEntity.bounceState!.peakHeightPx).toBe(28) // 4. Refusal audio and visual feedback expect(feedbackSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalledWith('包裹已满。') expect(mockStatus.textContent).toBe('包裹已满。') }) }) })