diff --git a/src/game/engine.ts b/src/game/engine.ts index 76aa45d..e1b6625 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -15,7 +15,7 @@ import { getMonsterTreasureClass } from './monsters.ts' import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts' import { MonsterStreamingManager } from './monster-streaming.ts' import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' -import { GroundItemManager, triggerFlippyBounce, findSafeDropPosition, getPickupEdgeDistance, isWithinPickupBounds } from './ground-items.ts' +import { GroundItemManager, triggerFlippyBounce, findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance, isWithinPickupBounds } from './ground-items.ts' import type { GroundItemEntity, GroundItemSource } from './ground-items.ts' import { ORTHO_CELL_WIDTH, ORTHO_CELL_HEIGHT } from './d2map.ts' import { getInventoryGoldLimit } from '../ui/inventory.ts' @@ -1448,26 +1448,25 @@ export class GameEngine { }) this.metrics.dropsRolled += 1 - const burstOccupied: { cellX?: number; cellY?: number; x?: number; y?: number }[] = [...this.groundItems.all] + const burstOccupied: { x: number; y: number }[] = this.groundItems.all.map(g => ({ x: g.x, y: g.y })) const currentSimTime = this.world.tick * 40 - const originCell = worldToCell(this.terrain, pending.x, pending.y) const spawned: GroundItemEntity[] = [] for (const drop of droppedItems) { - const dropPos = findSafeDropPosition( - this.terrain, - originCell.cellX, - originCell.cellY, - 5, + const dropPos = findIsometricDropPosition( + pending.x, + pending.y, burstOccupied, + this.terrain, ) burstOccupied.push(dropPos) + const dropCell = worldToCell(this.terrain, dropPos.x, dropPos.y) const isGold = drop.base?.id === 'gold' || drop.code?.trim() === 'gld' || (drop as any).id === 'gold' const goldAmount = isGold ? (drop.stack ?? drop.value ?? 1) : undefined this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) - const entity = this.groundItems.add(drop, dropPos.cellX, dropPos.cellY, dropPos.x, dropPos.y, { + const entity = this.groundItems.add(drop, dropCell.cellX, dropCell.cellY, dropPos.x, dropPos.y, { ...(goldAmount !== undefined ? { amount: goldAmount } : {}), bounce: true, now: currentSimTime, @@ -1487,26 +1486,20 @@ export class GameEngine { dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity { const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y - const originCell = (cellX !== undefined && cellY !== undefined) - ? { cellX, cellY } - : worldToCell(this.terrain, rawX, rawY) - const dropPos = findSafeDropPosition( - this.terrain, - originCell.cellX, - originCell.cellY, - 5, + const dropPos = findIsometricDropPosition( + rawX, + rawY, this.groundItems.all, + this.terrain, ) - const cx = cellX ?? dropPos.cellX - const cy = cellY ?? dropPos.cellY - const finalX = (x !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY) - ? rawX - : dropPos.x - const finalY = (y !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY) - ? rawY - : dropPos.y + const finalX = dropPos.x + const finalY = dropPos.y + const moved = finalX !== rawX || finalY !== rawY + const dropCell = (!moved && cellX !== undefined && cellY !== undefined) + ? { cellX, cellY } + : worldToCell(this.terrain, finalX, finalY) const now = this.world.tick * 40 - const entity = this.groundItems.add(item, cx, cy, finalX, finalY, { bounce: true, now }) + const entity = this.groundItems.add(item, dropCell.cellX, dropCell.cellY, finalX, finalY, { bounce: true, now }) this.ground.push({ x: finalX, y: finalY, item: item as any }) return entity } @@ -1525,30 +1518,24 @@ export class GameEngine { if (amount <= 0) return null const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y - const originCell = (cellX !== undefined && cellY !== undefined) - ? { cellX, cellY } - : worldToCell(this.terrain, rawX, rawY) - const dropPos = findSafeDropPosition( - this.terrain, - originCell.cellX, - originCell.cellY, - 5, + const dropPos = findIsometricDropPosition( + rawX, + rawY, this.groundItems.all, + this.terrain, ) - const cx = cellX ?? dropPos.cellX - const cy = cellY ?? dropPos.cellY - const finalX = (x !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY) - ? rawX - : dropPos.x - const finalY = (y !== undefined && dropPos.cellX === originCell.cellX && dropPos.cellY === originCell.cellY) - ? rawY - : dropPos.y + const finalX = dropPos.x + const finalY = dropPos.y + const moved = finalX !== rawX || finalY !== rawY + const dropCell = (!moved && cellX !== undefined && cellY !== undefined) + ? { cellX, cellY } + : worldToCell(this.terrain, finalX, finalY) const now = this.world.tick * 40 const goldPayload = goldItem(amount) const entity = this.groundItems.add( goldPayload, - cx, - cy, + dropCell.cellX, + dropCell.cellY, finalX, finalY, { amount, bounce: true, now }, diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index b07fef5..e95009f 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -25,9 +25,13 @@ import { ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, getCollisionMaskAt, + isBlockedAt, rayTraceScene, } from './d2map.ts' import type { CollisionGrid } from './d2map.ts' +import { translateItemName, translateMagicName, translateRareName } from './tooltip-i18n.ts' +import { isRuneCode } from './gems-data.ts' +import { getEmbeddedDropTables } from './embedded-drop-tables.ts' export interface GroundItemBounceState { startTime: number @@ -83,8 +87,18 @@ export function isGoldItem(item: any): boolean { return false } -/** Resolve quality name string safely. */ -export function resolveItemQualityString(quality: any): string { +/** Resolve quality name string safely, including rune detection per D2 1.13c ground label rules. */ +export function resolveItemQualityString(quality: any, code?: string, item?: any): string { + const normCode = typeof code === 'string' ? code.trim().toLowerCase() : '' + const rawRarity = typeof item?.rarity === 'string' ? item.rarity.toLowerCase() : '' + if ( + (normCode && isRuneCode(normCode)) || + item?.isRune === true || + item?.base?.isRune === true || + rawRarity === 'rune' + ) { + return 'rune' + } if (typeof quality === 'string') return quality.toLowerCase() if (typeof quality === 'number') { switch (quality) { @@ -99,9 +113,59 @@ export function resolveItemQualityString(quality: any): string { default: return 'normal' } } + if (item?.uniqueItemDef || rawRarity === 'unique') return 'unique' + if (item?.setItemDef || rawRarity === 'set') return 'set' + if (item?.rolledRareAffixes || rawRarity === 'rare') return 'rare' + if (item?.rolledMagicAffixes || rawRarity === 'magic') return 'magic' + if (rawRarity === 'crafted' || rawRarity === 'craft') return 'craft' return 'normal' } +/** + * Formats an item's display name into the canonical bilingual Chinese+English format + * (`"中文名 (English Name)"`) matching `itemToUiInventoryItem` (`src/ui/item-bridge.ts`), + * while preserving any pre-localized `item.nameZh` that already differs from `name`. + */ +export function formatBilingualGroundItemName(item: any, name: string, quality: string): string { + if (typeof item?.nameZh === 'string' && item.nameZh.trim().length > 0 && item.nameZh !== name) { + return item.nameZh + } + const baseEn = typeof item?.base?.name === 'string' && item.base.name.length > 0 ? item.base.name : undefined + if (quality === 'unique' && item?.uniqueItemDef) { + const uEn = item.uniqueItemDef.index || name + const uZh = translateItemName(uEn) + return uZh && uZh !== uEn ? `${uZh} (${uEn})` : uEn + } + if (quality === 'set' && item?.setItemDef) { + const sEn = item.setItemDef.index || name + const sZh = translateItemName(sEn) + return sZh && sZh !== sEn ? `${sZh} (${sEn})` : sEn + } + if ((quality === 'rare' || quality === 'craft') && item?.rolledRareAffixes) { + const rEn = item.rolledRareAffixes.name || name + const rZh = translateRareName(rEn) + return rZh && rZh !== rEn ? `${rZh} (${rEn})` : rEn + } + if (quality === 'magic' && item?.rolledMagicAffixes) { + const mEn = item.rolledMagicAffixes.name || name + const mZh = translateMagicName(mEn, baseEn ?? name) + return mZh && mZh !== mEn ? `${mZh} (${mEn})` : mEn + } + const directZh = translateItemName(name) + if (directZh && directZh !== name) { + return `${directZh} (${name})` + } + if (quality === 'rare' || quality === 'craft') { + const rZh = translateRareName(name) + if (rZh && rZh !== name) return `${rZh} (${name})` + } + if (quality === 'magic') { + const mZh = translateMagicName(name, baseEn) + if (mZh && mZh !== name) return `${mZh} (${name})` + } + return name +} + /** Metadata extracted from an item for ground display. */ export interface GroundItemMetadata { readonly name: string @@ -145,13 +209,38 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): } const name = typeof item?.name === 'string' ? item.name : 'Unknown Item' - const nameZh = typeof item?.nameZh === 'string' ? item.nameZh : name - const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality) - const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? 1) - const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? 1) + const rawCode = typeof item?.code === 'string' + ? item.code.trim().toLowerCase() + : typeof item?.base?.id === 'string' + ? item.base.id.trim().toLowerCase() + : '' + const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item) + const nameZh = formatBilingualGroundItemName(item, name, quality) + if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) { + try { + item.nameZh = nameZh + } catch { + // ignore if frozen object + } + } + + let tableBase: any + if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) { + try { + const tables = getEmbeddedDropTables() as any + tableBase = typeof tables.getBase === 'function' + ? tables.getBase(rawCode) + : (tables.weapons?.get(rawCode) ?? tables.armor?.get(rawCode) ?? tables.misc?.get(rawCode)) + } catch { + // fallback if embedded tables unavailable + } + } + + const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? tableBase?.invWidth ?? 1) + const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1) const amount = typeof item?.stack === 'number' ? item.stack : 1 - const dropSound = item?.base?.dropsound ?? item?.dropSound ?? item?.dropsound ?? 'item_drop' - const dropSfxFrame = item?.base?.dropsfxframe ?? item?.dropSfxFrame ?? item?.dropsfxframe ?? 12 + const dropSound = item?.base?.dropsound ?? item?.dropSound ?? item?.dropsound ?? tableBase?.dropsound ?? 'item_drop' + const dropSfxFrame = item?.base?.dropsfxframe ?? item?.dropSfxFrame ?? item?.dropsfxframe ?? tableBase?.dropsfxframe ?? 12 const ethereal = Boolean(item?.ethereal === true || item?.isEthereal === true || item?.item?.ethereal === true) const rawSockets = item?.sockets ?? item?.totalSockets ?? item?.socketedCount ?? item?.item?.sockets ?? item?.item?.totalSockets ?? 0 const sockets = typeof rawSockets === 'number' && rawSockets > 0 ? rawSockets : undefined @@ -465,6 +554,9 @@ export function isDropPositionBlocked( if ((mask & COLLIDE_MASK_SPAWN) !== 0) { return true } + if (grid.blocked && isBlockedAt(grid, candX, candY)) { + return true + } const los = rayTraceScene(grid, originX, originY, candX, candY, COLLIDE_MASK_SPAWN_LOS) if (los.hit) { return true @@ -572,6 +664,7 @@ export function findIsometricDropPosition( minSpacing = 16, ): { x: number; y: number } { const minSpacingSq = minSpacing * minSpacing + let firstWalkableCandidate: { x: number; y: number } | null = null for (let ring = 0; ring <= maxRings; ring += 1) { const ringCandidates = getIsometricRingCandidates(ring) @@ -588,6 +681,10 @@ export function findIsometricDropPosition( continue } + if (firstWalkableCandidate === null) { + firstWalkableCandidate = { x: cx, y: cy } + } + // Check conflict with existing items let hasConflict = false for (const item of existingItems) { @@ -606,7 +703,7 @@ export function findIsometricDropPosition( } } - return { x: originX, y: originY } + return firstWalkableCandidate ?? { x: originX, y: originY } } /** diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index cd46fce..20473f6 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -6615,16 +6615,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st }, onDropItemToGround: (item) => { const player = engine.world.player - const playerCell = cellOf(runtime.grid, player.x, player.y) - const dropPos = findSafeDropPosition(runtime.grid, playerCell.x, playerCell.y, 2) - const groundItem = engine.dropItem(item, dropPos.x, dropPos.y, dropPos.cellX, dropPos.cellY) + const groundItem = engine.dropItem(item, player.x, player.y) status.textContent = `已将「${groundItem.nameZh}」丢弃在地面` }, onDropGoldToGround: (amount) => { const player = engine.world.player - const playerCell = cellOf(runtime.grid, player.x, player.y) - const dropPos = findSafeDropPosition(runtime.grid, playerCell.x, playerCell.y, 2) - const groundGold = engine.dropGold(amount, dropPos.x, dropPos.y, dropPos.cellX, dropPos.cellY) + const groundGold = engine.dropGold(amount, player.x, player.y) if (groundGold) { status.textContent = `已将 ${amount.toLocaleString()} 金币丢弃在地面` } diff --git a/src/scene/net-scene.ts b/src/scene/net-scene.ts index e33e668..81ed0a7 100644 --- a/src/scene/net-scene.ts +++ b/src/scene/net-scene.ts @@ -54,7 +54,7 @@ import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts' import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts' import { D2Rng } from '../game/d2-rng.ts' -import { findSafeDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts' +import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts' import { worldToCell } from '../game/engine.ts' import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts' @@ -739,15 +739,13 @@ export function createNetSimulation( monsterRng: new D2Rng(monsterSeed), }) - const originCell = worldToCell(mapTerrain, event.x, event.y) - const burstOccupied: { x?: number; y?: number; cellX?: number; cellY?: number }[] = [...ground] + const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y })) for (const drop of droppedItems) { - const dropPos = findSafeDropPosition( - mapTerrain, - originCell.cellX, - originCell.cellY, - 5, + const dropPos = findIsometricDropPosition( + event.x, + event.y, burstOccupied, + mapTerrain, ) burstOccupied.push(dropPos) ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 53ec167..5dd5be4 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -57,6 +57,9 @@ export interface UiInventoryItem { readonly ethereal?: boolean | undefined readonly setPieces?: readonly string[] | undefined readonly setBonuses?: readonly { text: string; color?: D2ColorCode }[] | undefined + readonly flippyFile?: string | undefined + readonly dropSound?: string | undefined + readonly dropSfxFrame?: number | undefined } export interface GridPlacement { diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index deb0e9e..03db713 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -434,13 +434,24 @@ export function itemToUiInventoryItem( ? { current: rawItem.durability, max: rawItem.maxDurability } : undefined - const sockets = tt.sockets ?? (rawItem as any).sockets ?? rawItem.totalSockets ?? rawItem.socketedCount - const ethereal = Boolean((tt as any).ethereal || (rawItem as any).ethereal) + const rolledSockProp = Array.isArray(rawItem.rolledProps) + ? rawItem.rolledProps.reduce((sum, p) => ((p.code || '').trim().toLowerCase() === 'sock' ? sum + p.value : sum), 0) + : 0 + const sockets = + (rawItem as any).sockets ?? + rawItem.totalSockets ?? + rawItem.socketedCount ?? + (rolledSockProp > 0 ? rolledSockProp : undefined) + const ethereal = Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal) const id = rawItem.uniqueId ? `item-${code}-${rawItem.uniqueId}` : (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}` + const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile + const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound + const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe + return { id, code, @@ -466,5 +477,8 @@ export function itemToUiInventoryItem( ...((rawItem as any).runewordRunes || tt.runewordRunes ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}), ...(tt.setPieces ? { setPieces: tt.setPieces } : {}), ...(tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}), + ...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}), + ...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}), + ...(typeof dropSfxFrame === 'number' && Number.isFinite(dropSfxFrame) ? { dropSfxFrame } : {}), } } diff --git a/tests/ground-drop-parity-issues.test.ts b/tests/ground-drop-parity-issues.test.ts new file mode 100644 index 0000000..647b32f --- /dev/null +++ b/tests/ground-drop-parity-issues.test.ts @@ -0,0 +1,255 @@ +/** + * 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) + } + } + }) + }) +})