569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390).
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
|
|
import type { GroundItemEntity } from '../src/game/ground-items.ts'
|
|
import {
|
|
GROUND_LABEL_QUALITY_COLORS,
|
|
getGroundItemQualityColor,
|
|
formatGroundItemLabelText,
|
|
estimateLabelWidth,
|
|
doLabelsOverlap,
|
|
resolveLadderCollisions,
|
|
computeInitialGroundLabelLayouts,
|
|
GroundLabelOverlay,
|
|
type GroundLabelLayout,
|
|
} from '../src/ui/ground-labels.ts'
|
|
|
|
function createMockItem(partial: Partial<GroundItemEntity> = {}): GroundItemEntity {
|
|
return {
|
|
id: partial.id ?? 'item-1',
|
|
item: partial.item ?? {},
|
|
name: partial.name ?? 'Short Sword',
|
|
nameZh: partial.nameZh ?? '短剑',
|
|
quality: partial.quality ?? 'normal',
|
|
isGold: partial.isGold ?? false,
|
|
amount: partial.amount ?? 1,
|
|
invWidth: partial.invWidth ?? 1,
|
|
invHeight: partial.invHeight ?? 2,
|
|
dropTime: 1000,
|
|
x: partial.x ?? 400,
|
|
y: partial.y ?? 300,
|
|
cellX: 20,
|
|
cellY: 15,
|
|
sparklePhase: 0,
|
|
ethereal: partial.ethereal,
|
|
sockets: partial.sockets,
|
|
}
|
|
}
|
|
|
|
describe('Ground Items Alt Labels (Issue #390)', () => {
|
|
describe('Quality Colors & Text Formatting', () => {
|
|
it('formats gold amounts accurately as authentic Chinese localized text', () => {
|
|
const gold1 = createMockItem({ isGold: true, amount: 150 })
|
|
expect(formatGroundItemLabelText(gold1)).toBe('150 金币')
|
|
|
|
const gold2 = createMockItem({ isGold: true, amount: 25000 })
|
|
expect(formatGroundItemLabelText(gold2)).toBe('25000 金币')
|
|
})
|
|
|
|
it('formats item display names preferring nameZh with fallback to name', () => {
|
|
const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' })
|
|
expect(formatGroundItemLabelText(item1)).toBe('短剑')
|
|
|
|
const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' })
|
|
expect(formatGroundItemLabelText(item2)).toBe('Grand Charm')
|
|
})
|
|
|
|
it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => {
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.normal,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.low,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.superior,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.magic,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.rare,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.set,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.unique,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.craft,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.rune,
|
|
)
|
|
expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe(
|
|
GROUND_LABEL_QUALITY_COLORS.gold,
|
|
)
|
|
})
|
|
|
|
it('colors ethereal or socketed normal and superior items gray (#808080) while preserving magic/rare/set/unique/runeword colors (Issue #424)', () => {
|
|
// Ethereal normal -> gray (#808080)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: true })),
|
|
).toBe('#808080')
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { ethereal: true } })),
|
|
).toBe('#808080')
|
|
|
|
// Socketed normal -> gray (#808080)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 3 })),
|
|
).toBe('#808080')
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { sockets: 4 } })),
|
|
).toBe('#808080')
|
|
|
|
// Socketed or ethereal superior / high -> gray (#808080)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'superior', sockets: 2 })),
|
|
).toBe('#808080')
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'high', ethereal: true })),
|
|
).toBe('#808080')
|
|
|
|
// Non-ethereal 0-socket normal/superior remains normal/superior white
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: false, sockets: 0 })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.normal)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'superior', ethereal: false, sockets: 0 })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
|
|
|
// Completed runewords keep unique gold (#908858)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 4, item: { isRuneword: true, sockets: 4 } })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
|
|
// Magic, rare, set, unique keep their quality colors even when ethereal or socketed
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'magic', ethereal: true, sockets: 2 })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.magic)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'rare', ethereal: true, sockets: 2 })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.rare)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'set', sockets: 3 })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.set)
|
|
expect(
|
|
getGroundItemQualityColor(createMockItem({ quality: 'unique', ethereal: true, sockets: 1 })),
|
|
).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
})
|
|
})
|
|
|
|
describe('Label Width Estimation', () => {
|
|
it('correctly accounts for both ASCII and CJK wide characters', () => {
|
|
const asciiW = estimateLabelWidth('OK', 13)
|
|
const cjkW = estimateLabelWidth('戒指', 13)
|
|
// CJK characters have greater pixel width than Latin characters for same character count
|
|
expect(cjkW).toBeGreaterThan(asciiW)
|
|
expect(asciiW).toBeGreaterThan(20)
|
|
})
|
|
})
|
|
|
|
describe('Overlap Detection & Layout Calculation', () => {
|
|
it('projects ground items to initial layouts with natural Y offset', () => {
|
|
const items = [
|
|
createMockItem({ id: 'i1', x: 100, y: 200 }),
|
|
createMockItem({ id: 'i2', x: 300, y: 400 }),
|
|
]
|
|
const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 })
|
|
const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18)
|
|
|
|
expect(layouts.length).toBe(2)
|
|
expect(layouts[0]!.screenX).toBe(200)
|
|
expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12
|
|
expect(layouts[0]!.screenY).toBe(388)
|
|
expect(layouts[1]!.screenX).toBe(600)
|
|
expect(layouts[1]!.naturalY).toBe(788)
|
|
})
|
|
|
|
it('filters out items outside viewport when project returns null', () => {
|
|
const items = [createMockItem({ id: 'i1' })]
|
|
const layouts = computeInitialGroundLabelLayouts(items, () => null)
|
|
expect(layouts.length).toBe(0)
|
|
})
|
|
|
|
it('accurately identifies overlapping and non-overlapping label rectangles', () => {
|
|
const l1: GroundLabelLayout = {
|
|
id: '1',
|
|
itemId: '1',
|
|
item: createMockItem(),
|
|
text: 'A',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 100,
|
|
screenY: 100,
|
|
naturalY: 100,
|
|
}
|
|
const l2Overlapping: GroundLabelLayout = {
|
|
id: '2',
|
|
itemId: '2',
|
|
item: createMockItem(),
|
|
text: 'B',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 110,
|
|
screenY: 105,
|
|
naturalY: 105,
|
|
}
|
|
const l3Distant: GroundLabelLayout = {
|
|
id: '3',
|
|
itemId: '3',
|
|
item: createMockItem(),
|
|
text: 'C',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 300,
|
|
screenY: 100,
|
|
naturalY: 100,
|
|
}
|
|
|
|
expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true)
|
|
expect(doLabelsOverlap(l1, l3Distant)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => {
|
|
it('returns empty array or single label unchanged', () => {
|
|
expect(resolveLadderCollisions([])).toEqual([])
|
|
const single: GroundLabelLayout[] = [
|
|
{
|
|
id: '1',
|
|
itemId: '1',
|
|
item: createMockItem(),
|
|
text: 'A',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 50,
|
|
height: 18,
|
|
screenX: 100,
|
|
screenY: 100,
|
|
naturalY: 100,
|
|
},
|
|
]
|
|
expect(resolveLadderCollisions(single)).toEqual(single)
|
|
})
|
|
|
|
it('retains natural positions when labels do not overlap horizontally', () => {
|
|
const nonOverlapping: GroundLabelLayout[] = [
|
|
{
|
|
id: '1',
|
|
itemId: '1',
|
|
item: createMockItem(),
|
|
text: 'Left Item',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 100,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
{
|
|
id: '2',
|
|
itemId: '2',
|
|
item: createMockItem(),
|
|
text: 'Right Item',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 300,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
]
|
|
const resolved = resolveLadderCollisions(nonOverlapping)
|
|
expect(resolved[0]!.screenY).toBe(200)
|
|
expect(resolved[1]!.screenY).toBe(200)
|
|
})
|
|
|
|
it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => {
|
|
const overlapping: GroundLabelLayout[] = [
|
|
{
|
|
id: '1',
|
|
itemId: '1',
|
|
item: createMockItem(),
|
|
text: 'Item A',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 100,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
{
|
|
id: '2',
|
|
itemId: '2',
|
|
item: createMockItem(),
|
|
text: 'Item B',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 105,
|
|
screenY: 202,
|
|
naturalY: 202,
|
|
},
|
|
]
|
|
|
|
const resolved = resolveLadderCollisions(overlapping, 4, 2)
|
|
expect(resolved.length).toBe(2)
|
|
|
|
// Zero overlap verification
|
|
expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false)
|
|
|
|
// Vertical distance between centers must be at least height + padY = 20px
|
|
const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY)
|
|
expect(distY).toBeGreaterThanOrEqual(20)
|
|
})
|
|
|
|
it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => {
|
|
const cluster: GroundLabelLayout[] = []
|
|
for (let i = 0; i < 5; i += 1) {
|
|
cluster.push({
|
|
id: `item-${i}`,
|
|
itemId: `item-${i}`,
|
|
item: createMockItem({ id: `item-${i}` }),
|
|
text: `Drop ${i}`,
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 70,
|
|
height: 18,
|
|
screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally
|
|
screenY: 300,
|
|
naturalY: 300,
|
|
})
|
|
}
|
|
|
|
const resolved = resolveLadderCollisions(cluster, 4, 2)
|
|
expect(resolved.length).toBe(5)
|
|
|
|
// Assert pairwise zero-overlap across all 5 labels
|
|
for (let i = 0; i < resolved.length; i += 1) {
|
|
for (let j = i + 1; j < resolved.length; j += 1) {
|
|
expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false)
|
|
}
|
|
}
|
|
|
|
// Check ladder sorting: positions strictly decreasing (stacking upward)
|
|
const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY)
|
|
for (let k = 0; k < sortedByY.length - 1; k += 1) {
|
|
expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20)
|
|
}
|
|
})
|
|
|
|
it('handles multiple independent clusters simultaneously', () => {
|
|
const items: GroundLabelLayout[] = [
|
|
// Cluster 1 around X=100
|
|
{
|
|
id: 'c1-1',
|
|
itemId: 'c1-1',
|
|
item: createMockItem(),
|
|
text: 'Loot A',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 50,
|
|
height: 18,
|
|
screenX: 100,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
{
|
|
id: 'c1-2',
|
|
itemId: 'c1-2',
|
|
item: createMockItem(),
|
|
text: 'Loot B',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 50,
|
|
height: 18,
|
|
screenX: 102,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
// Cluster 2 around X=500
|
|
{
|
|
id: 'c2-1',
|
|
itemId: 'c2-1',
|
|
item: createMockItem(),
|
|
text: 'Loot C',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 50,
|
|
height: 18,
|
|
screenX: 500,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
{
|
|
id: 'c2-2',
|
|
itemId: 'c2-2',
|
|
item: createMockItem(),
|
|
text: 'Loot D',
|
|
color: '#fff',
|
|
borderColor: '#fff',
|
|
width: 50,
|
|
height: 18,
|
|
screenX: 503,
|
|
screenY: 200,
|
|
naturalY: 200,
|
|
},
|
|
]
|
|
|
|
const resolved = resolveLadderCollisions(items)
|
|
// Both clusters have their own pairwise overlap resolved
|
|
expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false)
|
|
expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false)
|
|
// Cluster 1 and Cluster 2 remain at their distinct X
|
|
expect(resolved[0]!.screenX).toBeLessThan(200)
|
|
expect(resolved[2]!.screenX).toBeGreaterThan(400)
|
|
})
|
|
})
|
|
|
|
describe('GroundLabelOverlay DOM Integration', () => {
|
|
function createMockDomElement(tag = 'div'): any {
|
|
const listeners: Record<string, ((e: any) => void)[]> = {}
|
|
const children: any[] = []
|
|
const el: any = {
|
|
tagName: tag.toUpperCase(),
|
|
className: '',
|
|
textContent: '',
|
|
style: {},
|
|
dataset: {},
|
|
hidden: false,
|
|
children,
|
|
appendChild(child: any) {
|
|
children.push(child)
|
|
return child
|
|
},
|
|
addEventListener(type: string, fn: any) {
|
|
listeners[type] = listeners[type] ?? []
|
|
listeners[type].push(fn)
|
|
},
|
|
dispatchEvent(event: any) {
|
|
for (const fn of listeners[event.type] ?? []) {
|
|
fn(event)
|
|
}
|
|
},
|
|
}
|
|
return el
|
|
}
|
|
|
|
const origDocument = (globalThis as any).document
|
|
beforeAll(() => {
|
|
;(globalThis as any).document = {
|
|
createElement: (tag: string) => createMockDomElement(tag),
|
|
}
|
|
})
|
|
afterAll(() => {
|
|
;(globalThis as any).document = origDocument
|
|
})
|
|
|
|
it('hides all pooled label elements when visible is false or layouts empty', () => {
|
|
const container = createMockDomElement('div')
|
|
const overlay = new GroundLabelOverlay(container)
|
|
|
|
const layout: GroundLabelLayout = {
|
|
id: '1',
|
|
itemId: '1',
|
|
item: createMockItem(),
|
|
text: 'Short Sword',
|
|
color: '#d8d8d8',
|
|
borderColor: '#d8d8d8',
|
|
width: 60,
|
|
height: 18,
|
|
screenX: 100,
|
|
screenY: 100,
|
|
naturalY: 100,
|
|
}
|
|
|
|
overlay.update(true, [layout])
|
|
expect(container.children.length).toBe(1)
|
|
expect(container.children[0].style.display).toBe('block')
|
|
|
|
overlay.update(false, [])
|
|
expect(container.children[0].style.display).toBe('none')
|
|
})
|
|
|
|
it('renders labels with authentic quality colors and triggers pickup callback on click', () => {
|
|
const container = createMockDomElement('div')
|
|
const overlay = new GroundLabelOverlay(container)
|
|
const onPickup = vi.fn()
|
|
overlay.setOnPickupCallback(onPickup)
|
|
|
|
const uniqueItem = createMockItem({
|
|
id: 'soj',
|
|
name: 'The Stone of Jordan',
|
|
nameZh: '乔丹之石',
|
|
quality: 'unique',
|
|
})
|
|
|
|
const layout: GroundLabelLayout = {
|
|
id: 'soj',
|
|
itemId: 'soj',
|
|
item: uniqueItem,
|
|
text: '乔丹之石',
|
|
color: GROUND_LABEL_QUALITY_COLORS.unique!,
|
|
borderColor: GROUND_LABEL_QUALITY_COLORS.unique!,
|
|
width: 80,
|
|
height: 18,
|
|
screenX: 250,
|
|
screenY: 150,
|
|
naturalY: 150,
|
|
}
|
|
|
|
overlay.update(true, [layout])
|
|
const el = container.children[0]
|
|
expect(el.textContent).toBe('乔丹之石')
|
|
expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
expect(el.style.left).toBe('250px')
|
|
expect(el.style.top).toBe('150px')
|
|
|
|
// Simulate pointerdown
|
|
el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() })
|
|
expect(onPickup).toHaveBeenCalledWith(uniqueItem)
|
|
})
|
|
|
|
it('triggers onHoverCallback on mouseenter and mouseleave for hovered labels (Issue #447)', () => {
|
|
const container = createMockDomElement('div')
|
|
const overlay = new GroundLabelOverlay(container)
|
|
const onHover = vi.fn()
|
|
overlay.setOnHoverCallback(onHover)
|
|
|
|
const magicItem = createMockItem({
|
|
id: 'ring-1',
|
|
name: 'Ring of the Apprentice',
|
|
nameZh: '学徒之戒',
|
|
quality: 'magic',
|
|
})
|
|
|
|
const layout: GroundLabelLayout = {
|
|
id: 'ring-1',
|
|
itemId: 'ring-1',
|
|
item: magicItem,
|
|
text: '学徒之戒',
|
|
color: GROUND_LABEL_QUALITY_COLORS.magic!,
|
|
borderColor: GROUND_LABEL_QUALITY_COLORS.magic!,
|
|
width: 70,
|
|
height: 18,
|
|
screenX: 200,
|
|
screenY: 100,
|
|
naturalY: 100,
|
|
}
|
|
|
|
overlay.update(true, [layout])
|
|
const el = container.children[0]
|
|
|
|
// Mouseenter
|
|
el.dispatchEvent({ type: 'mouseenter' })
|
|
expect(onHover).toHaveBeenCalledWith(magicItem)
|
|
expect(el.style.borderColor).toBe('#ffffff')
|
|
|
|
// Mouseleave
|
|
el.dispatchEvent({ type: 'mouseleave' })
|
|
expect(onHover).toHaveBeenCalledWith(null)
|
|
})
|
|
})
|
|
})
|