/** * Regression Test Suite for Issue #479: * 1. 打怪掉落的物品图标间隔太大,和原版不符合 (16x8 sub-tile diamond lattice vs 80x40 macro cell) * 2. 打怪掉落物品和从身上扔下来的物品逻辑不一致(比如名字一个是纯英文一个是中英、符文品质颜色、掉落音效等) * 3. 身上扔下来的不同物品图标不应该叠在一起(比如血瓶连续丢弃不重叠) */ import { describe, it, expect } from 'vitest' import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { createDroppedItem } from '../src/game/drop-pipeline.ts' import { D2Rng } from '../src/game/d2-rng.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS } from '../src/game/demo-data.ts' import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts' import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts' import { formatGroundItemLabelText, getGroundItemQualityColor, GROUND_LABEL_QUALITY_COLORS, } from '../src/ui/ground-labels.ts' import { ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_CELL_WIDTH } from '../src/game/d2map.ts' import type { Monster } from '../src/game/combat.ts' describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacking', () => { const dropTables = getEmbeddedDropTables() const terrain = { widthPx: 2000, heightPx: 2000, originX: 0, originY: 0, overlap: () => 0 } function createEngine(overrides?: Partial): GameEngine { const engine = new GameEngine(terrain, { spawn: { x: 512, y: 384 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 0, playerDamage: 100, playerManaPerAttack: 0, respawnTicks: 1000, }, talkRadius: 48, pickupRadius: 0, // keep items on ground for inspection inventoryCols: 10, inventoryRows: 4, lootSeed: 1337, ...overrides, }) engine.setDropTables(dropTables) return engine } describe('1. Monster Drop Sub-Tile Spacing (16x8 Sub-Tile Diamond Lattice vs 80x40 Macro Cell)', () => { it('places monster drops on the 16x8 sub-tile diamond lattice around death position, never 80x40 macro cells apart', () => { const engine = createEngine() // Use a non-macro-cell-aligned sub-tile death coordinate (e.g. 533, 417) // Countesses / Act Bosses drop multiple items in a single burst const deathX = 533 const deathY = 417 const boss: Monster = { index: 0, stats: { id: 'andariel', name: 'Andariel', hp: 10, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 10, xp: 500, level: 12, rank: 'normal', }, x: deathX, y: deathY, hp: 0, cooldown: 0, state: 'dead', facing: 0, hitFlash: 0, corpseTicks: 0, dropRolled: false, pendingDrop: { tcName: 'Andariel', nLevel: 12, monsterType: 'boss', isBoss: true, isNoRatio: false, seed: 424242, x: deathX, y: deathY, killerContext: { gamePlayers: 8, partyPlayers: 8, playerMf: 200, playerGf: 100, }, }, } engine.world.monsters.push(boss) const spawned = engine.triggerMonsterDrop(boss) expect(spawned.length).toBeGreaterThanOrEqual(2) // 1st drop lands at the exact monster death epicenter (Ring 0) expect(spawned[0]!.x).toBe(deathX) expect(spawned[0]!.y).toBe(deathY) // Every subsequent drop in the burst must be on the 16x8 sub-tile lattice around (deathX, deathY) for (let i = 1; i < spawned.length; i++) { const item = spawned[i]! const dx = item.x - deathX const dy = item.y - deathY expect(Math.abs(dx) % ORTHO_SUB_TILE_WIDTH).toBe(0) expect(Math.abs(dy) % ORTHO_SUB_TILE_HEIGHT).toBe(0) // Ring 1 & Ring 2 sub-tile offsets are compact (< ORTHO_CELL_WIDTH = 80px), NOT 80..400px macro cell steps! const distFromEpicenter = Math.hypot(dx, dy) expect(distFromEpicenter).toBeGreaterThanOrEqual(16) expect(distFromEpicenter).toBeLessThan(ORTHO_CELL_WIDTH) } // Pairwise anti-stacking: no two monster drops overlap for (let i = 0; i < spawned.length; i++) { for (let j = i + 1; j < spawned.length; j++) { const d = Math.hypot(spawned[i]!.x - spawned[j]!.x, spawned[i]!.y - spawned[j]!.y) expect(d).toBeGreaterThanOrEqual(16) } } }) }) describe('2. Monster Drop vs. Inventory/Body Drop Metadata & Bilingual Name Parity', () => { it('produces identical bilingual nameZh, quality, label color, sound, and sprite rect for monster-dropped vs inventory-dropped items', () => { const testCases = [ { code: 'hp2', level: 5, mf: 0 }, // Light Healing Potion { code: 'mp4', level: 20, mf: 0 }, // Greater Mana Potion { code: 'rvl', level: 30, mf: 0 }, // Full Rejuvenation Potion { code: 'hax', level: 5, mf: 0 }, // Hand Axe { code: 'cap', level: 5, mf: 0 }, // Cap { code: 'r01', level: 15, mf: 0 }, // El Rune { code: 'r31', level: 85, mf: 0 }, // Jah Rune ] for (const tc of testCases) { const engine = createEngine() const rng = new D2Rng(999) const base = dropTables.getBase(tc.code) expect(base).toBeDefined() const rawDroppedItem = createDroppedItem(base!, 'normal', { ilvl: tc.level, dwInitSeed: 999, itemRng: rng, itemTypes: dropTables.itemTypes, }) expect(rawDroppedItem).not.toBeNull() // Path A: Dropped directly by a monster onto the ground (raw Item) const monsterGroundEntity = engine.groundItems.add(rawDroppedItem, 5, 5, 400, 300, { bounce: true, now: 1000 }) // Path B: Picked up into player UI inventory (converted via itemToUiInventoryItem) and dropped from body/inventory const uiInventoryItem = itemToUiInventoryItem(rawDroppedItem, dropTables) const playerGroundEntity = engine.dropItem(uiInventoryItem, 500, 300) // 1. Bilingual nameZh parity (both must be "中文名 (English Name)", NEVER pure English on monster drop) expect(monsterGroundEntity.name).toBe(playerGroundEntity.name) expect(monsterGroundEntity.nameZh).toBe(playerGroundEntity.nameZh) expect(monsterGroundEntity.nameZh).toContain('(') expect(monsterGroundEntity.nameZh).not.toBe(monsterGroundEntity.name) expect(formatGroundItemLabelText(monsterGroundEntity)).toBe(formatGroundItemLabelText(playerGroundEntity)) // 2. Quality & ground label color parity (especially Runes -> 'rune' -> #ff9c18 orange) expect(monsterGroundEntity.quality).toBe(playerGroundEntity.quality) expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(getGroundItemQualityColor(playerGroundEntity)) if (tc.code.startsWith('r0') || tc.code.startsWith('r3')) { expect(monsterGroundEntity.quality).toBe('rune') expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.rune) } // 3. Audio & dimensions parity expect(monsterGroundEntity.invWidth).toBe(playerGroundEntity.invWidth) expect(monsterGroundEntity.invHeight).toBe(playerGroundEntity.invHeight) expect(monsterGroundEntity.dropSound).toBe(playerGroundEntity.dropSound) expect(monsterGroundEntity.dropSfxFrame).toBe(playerGroundEntity.dropSfxFrame) // 4. Ground flippy sprite rect parity const monsterRect = resolveGroundItemSpriteRect(monsterGroundEntity.item) const playerRect = resolveGroundItemSpriteRect(playerGroundEntity.item) expect(monsterRect).not.toBeNull() expect(monsterRect).toEqual(playerRect) } }) }) describe('3. Player Body/Inventory Dropped Items Anti-Stacking (Potions & Equipment)', () => { it('scatters 8 sequentially dropped potions/items from player inventory onto distinct sub-tile slots with zero stacking', () => { const engine = createEngine() const playerX = engine.world.player.x const playerY = engine.world.player.y const potionsToDrop = [ { id: 'pot-1', code: 'hp1', invFile: 'invhp1', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂 (Minor Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-2', code: 'hp2', invFile: 'invhp2', name: 'Light Healing Potion', nameZh: '轻度治疗药剂 (Light Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-3', code: 'hp3', invFile: 'invhp3', name: 'Healing Potion', nameZh: '治疗药剂 (Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-4', code: 'hp4', invFile: 'invhp4', name: 'Greater Healing Potion', nameZh: '强效治疗药剂 (Greater Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-5', code: 'hp5', invFile: 'invhp5', name: 'Super Healing Potion', nameZh: '超级治疗药剂 (Super Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-6', code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂 (Greater Mana Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-7', code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂 (Rejuvenation Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, { id: 'pot-8', code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂 (Full Rejuvenation Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] }, ] const droppedEntities = potionsToDrop.map(p => engine.dropItem(p, playerX, playerY)) expect(droppedEntities.length).toBe(8) // Check that all 8 dropped items have unique (x, y) coordinates const coordSet = new Set(droppedEntities.map(e => `${e.x},${e.y}`)) expect(coordSet.size).toBe(8) // Check pairwise distance >= 16px (minimum 1 sub-tile spacing) and compact Ring 0..1 radius for (let i = 0; i < droppedEntities.length; i++) { const a = droppedEntities[i]! expect(Math.hypot(a.x - playerX, a.y - playerY)).toBeLessThanOrEqual(32) for (let j = i + 1; j < droppedEntities.length; j++) { const b = droppedEntities[j]! const dist = Math.hypot(a.x - b.x, a.y - b.y) expect(dist).toBeGreaterThanOrEqual(16) } } }) it('scatters multiple gold piles dropped from player inventory onto distinct sub-tile slots without stacking', () => { const engine = createEngine() const playerX = engine.world.player.x const playerY = engine.world.player.y const g1 = engine.dropGold(100, playerX, playerY)! const g2 = engine.dropGold(250, playerX, playerY)! const g3 = engine.dropGold(500, playerX, playerY)! const g4 = engine.dropGold(1000, playerX, playerY)! const piles = [g1, g2, g3, g4] const coordSet = new Set(piles.map(g => `${g.x},${g.y}`)) expect(coordSet.size).toBe(4) for (let i = 0; i < piles.length; i++) { for (let j = i + 1; j < piles.length; j++) { expect(Math.hypot(piles[i]!.x - piles[j]!.x, piles[i]!.y - piles[j]!.y)).toBeGreaterThanOrEqual(16) } } }) }) })