fix(drop): unify monster and inventory ground drops on 16x8 sub-tile lattice with bilingual metadata parity

1. Sub-Tile Scatter Spacing (D2Common.dll COLLISION_GetFreeCoordinatesImpl 0x6FD9D140):
   - Unify GameEngine.triggerMonsterDrop, GameEngine.dropItem, GameEngine.dropGold,
     and net-scene monster drops on findIsometricDropPosition (16x8 sub-tile diamond
     lattice, 16px orthogonal / 8px vertical spacing) instead of 80x40px macro cells.
   - Derive GroundItemEntity (cellX, cellY) from final scattered (finalX, finalY)
     via worldToCell rather than overwriting with stale origin cell coordinates.

2. Monster vs. Inventory Drop Metadata & Bilingual Name Parity:
   - Add formatBilingualGroundItemName in src/game/ground-items.ts using
     translateItemName / translateMagicName / translateRareName so raw monster-dropped
     Item objects receive identical '中文名 (English Name)' nameZh as UiInventoryItem.
   - Detect runes via isRuneCode in resolveItemQualityString so monster-dropped runes
     render with #ff9c18 orange labels matching inventory-dropped runes.
   - Preserve flippyFile, dropSound, and dropSfxFrame in itemToUiInventoryItem, fix
     socket count resolution so unsocketed items do not inherit base.gemsockets, and
     add embedded drop table fallback in resolveGroundItemMetadata.

3. Anti-Stacking for Sequential Player Inventory/Body Drops:
   - Simplify act-scene onDropItemToGround / onDropGoldToGround to delegate directly
     to engine.dropItem / engine.dropGold at (player.x, player.y) so every dropped
     item checks engine.groundItems.all and scatters to a unique sub-tile slot.

Fixes #479
This commit is contained in:
troytt 2026-09-26 04:51:32 +00:00
parent 9022579306
commit c09e237ff8
7 changed files with 419 additions and 69 deletions

View File

@ -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 },

View File

@ -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 }
}
/**

View File

@ -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()} 金币丢弃在地面`
}

View File

@ -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 })

View File

@ -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 {

View File

@ -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 } : {}),
}
}

View File

@ -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<GameEngineOptions>): 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)
}
}
})
})
})