diablo2-web/tests/client/ground-item-pickup-and-alt-...

374 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { decode0x11ReportKill } from '../../src/netproto/d2gs/s2c/units.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { CommandMapper } from '../../src/client/input/command-mapper.ts'
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import type { SceneSource, SceneFrameSnapshot } from '../../src/client/view/scene-source.ts'
import { VIEWPORT_800x600 } from '../../src/client/view/viewport-profile.ts'
import { buildSceneDrawables } from '../../src/client/view/unit-drawables.ts'
import { setTblLang } from '../../src/client/i18n/lang.ts'
describe('Issue #555: Ground items pickup, arrival trigger, and Alt labels', () => {
it('0x11 ReportKill maps monster kills to mode 12 (MONMODE_DEAD) and player kills to mode 17 (PLRMODE_DEAD)', () => {
// Monster kill: unitType 1, id 100, killer 0x59
const monsterKillBytes = new Uint8Array([
0x11,
0x01,
0x64, 0x00, 0x00, 0x00, // unitId 100
0x09, // raw mode in packet
0x59, 0x00, 0x00, 0x00, // killerId
])
const monsterEv = decode0x11ReportKill(monsterKillBytes)
expect(monsterEv.type).toBe('UnitMode')
if (monsterEv.type === 'UnitMode') {
expect(monsterEv.unitType).toBe(1)
expect(monsterEv.id).toBe(100)
expect(monsterEv.mode).toBe(12) // MONMODE_DEAD
}
// Player kill: unitType 0, id 200, killer 0x10
const playerKillBytes = new Uint8Array([
0x11,
0x00,
0xc8, 0x00, 0x00, 0x00, // unitId 200
0x09, // raw mode in packet
0x10, 0x00, 0x00, 0x00, // killerId
])
const playerEv = decode0x11ReportKill(playerKillBytes)
expect(playerEv.type).toBe('UnitMode')
if (playerEv.type === 'UnitMode') {
expect(playerEv.unitType).toBe(0)
expect(playerEv.id).toBe(200)
expect(playerEv.mode).toBe(17) // PLRMODE_DEAD
}
})
it('Alt keydown/keyup synchronizes HudManager.showGroundLabels and published state', () => {
const world = new ClientWorld()
const hudManager = new HudManager()
const sentCommands: any[] = []
const commandMapper = new CommandMapper({
world,
hudManager,
sendCommand: (cmd) => sentCommands.push(cmd),
})
expect(hudManager.showGroundLabels).toBe(false)
expect(commandMapper.isAltHeld).toBe(false)
// Press Alt
const handledDown = commandMapper.handleKeyDown({ key: 'Alt', preventDefault: vi.fn() })
expect(handledDown).toBe(true)
expect(commandMapper.isAltHeld).toBe(true)
expect(hudManager.showGroundLabels).toBe(true)
expect(hudManager.state.showGroundLabels).toBe(true)
// Release Alt
commandMapper.handleKeyUp({ key: 'Alt' })
expect(commandMapper.isAltHeld).toBe(false)
expect(hudManager.showGroundLabels).toBe(false)
expect(hudManager.state.showGroundLabels).toBe(false)
// Press Alt again and blur window
commandMapper.handleKeyDown({ key: 'Alt' })
expect(commandMapper.isAltHeld).toBe(true)
expect(hudManager.showGroundLabels).toBe(true)
commandMapper.resetModifiers()
expect(commandMapper.isAltHeld).toBe(false)
expect(hudManager.showGroundLabels).toBe(false)
expect(hudManager.state.showGroundLabels).toBe(false)
})
it('renderSceneSourceFrame computes groundItemLabelBoxes with localized text and 1.13c colors when Alt is held or item is hovered', () => {
const hudManager = new HudManager()
setTblLang('ENG')
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
scale: vi.fn(),
translate: vi.fn(),
measureText: (text: string) => ({ width: text.length * 8 }),
} as unknown as CanvasRenderingContext2D
hudManager.ctx = mockCtx
// Create scene units: gold pile, a unique short sword, and a normal potion
const entities = buildSceneDrawables([
{
id: 101,
kind: 'item',
classId: 0,
x: 100,
y: 100,
code: 'gld',
goldAmount: 250,
name: '250 Gold',
nameZh: '250 金币',
itemColor: '#d8b864',
mode: 3,
},
{
id: 102,
kind: 'item',
classId: 1,
x: 102,
y: 101,
code: 'ssd',
name: 'Rixot\'s Keen',
nameZh: '瑞克希特的开胃甜点',
itemQuality: 7,
itemColor: '#c4a060',
mode: 3,
isHovered: true, // Hovered item
},
{
id: 103,
kind: 'item',
classId: 2,
x: 104,
y: 102,
code: 'hp1',
name: 'Minor Healing Potion',
nameZh: '微型治疗药剂',
itemQuality: 2,
itemColor: '#ffffff',
mode: 3,
},
])
const mockSnapshot: SceneFrameSnapshot = {
tick: 1,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 100, y: 100 },
playerLocalSubTile: { x: 100, y: 100 },
playerWorld: { x: 0, y: 1600 },
cameraTopLeft: { x: -400, y: 1300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities,
}
const mockSource: SceneSource = {
getViewportProfile: () => VIEWPORT_800x600,
setViewportProfile: () => VIEWPORT_800x600,
getActId: () => 1,
getLevelId: () => 1,
getTick: () => 1,
getPlayerSubTile: () => ({ x: 100, y: 100 }),
isWorldReady: () => true,
getPlayerLocalSubTile: () => ({ x: 100, y: 100 }),
getPlayerWorldOrtho: () => ({ x: 0, y: 1600 }),
getCameraTopLeft: () => ({ x: -400, y: 1300 }),
getLevelView: () => null,
isSubTileWalkable: () => true,
buildFrameSnapshot: () => mockSnapshot,
buildLightingInputs: () => ({} as any),
pickUnitAtScreen: () => null,
}
// 1. Without Alt: only hovered item (id 102) gets a label box
hudManager.showGroundLabels = false
renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' })
expect(hudManager.groundItemLabelBoxes.length).toBe(1)
expect(hudManager.groundItemLabelBoxes[0]!.id).toBe(102)
expect(hudManager.groundItemLabelBoxes[0]!.text).toBe('Rixot\'s Keen')
expect(hudManager.groundItemLabelBoxes[0]!.color).toBe('#c4a060')
// 2. With Alt: all 3 ground items get label boxes
hudManager.showGroundLabels = true
renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' })
expect(hudManager.groundItemLabelBoxes.length).toBe(3)
const boxIds = hudManager.groundItemLabelBoxes.map((b) => b.id)
expect(boxIds).toContain(101)
expect(boxIds).toContain(102)
expect(boxIds).toContain(103)
const goldBox = hudManager.groundItemLabelBoxes.find((b) => b.id === 101)!
expect(goldBox.text).toBe('250 Gold')
expect(goldBox.color).toBe('#d8b864')
// 3. In Chinese locale
setTblLang('CHI')
renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' })
const swordBoxZh = hudManager.groundItemLabelBoxes.find((b) => b.id === 102)!
expect(swordBoxZh.text).toBe('瑞克希特的开胃甜点')
})
it('CommandMapper.resolvePointerTarget resolves ground item when clicking directly on its label box', () => {
const world = new ClientWorld()
const hudManager = new HudManager()
const commandMapper = new CommandMapper({
world,
hudManager,
sendCommand: () => {},
})
// Simulate an item on the ground via ItemEvent
world.applyServerEvent({
type: 'ItemEvent',
action: 0x03,
container: 'ground',
item: {
id: 777,
action: 0x03,
code: 'rin',
worldX: 105,
worldY: 105,
x: 105,
y: 105,
} as any,
})
// Position label box on screen at x: 380..460, y: 220..238
hudManager.groundItemLabelBoxes = [
{
id: 777,
text: 'Bronze Ring',
label: 'Bronze Ring',
color: '#4880ff',
x: 380,
y: 220,
width: 80,
height: 18,
},
]
// Click inside the label box at (400, 228)
const target = commandMapper.resolvePointerTarget(400, 228)
expect(target.hoveredUnit).not.toBeNull()
expect(target.hoveredUnit!.id).toBe(777)
expect(target.hoveredUnit!.unitType).toBe('item')
expect(target.subtileX).toBe(105)
expect(target.subtileY).toBe(105)
})
it('CommandMapper handles distant item pickup (dist > 4) and dispatches ItemPickup upon arrival via tick', () => {
const world = new ClientWorld()
const hudManager = new HudManager()
const sentCommands: any[] = []
const commandMapper = new CommandMapper({
world,
hudManager,
sendCommand: (cmd) => sentCommands.push(cmd),
})
// Player starts at (100, 100)
world.self.x = 100
world.self.y = 100
commandMapper.getCamera().snapTo(100, 100)
// Distant item at (108, 100) -> distance = 8 (> 4)
world.applyServerEvent({
type: 'ItemEvent',
action: 0x03,
container: 'ground',
item: {
id: 888,
action: 0x03,
code: 'bsd',
worldX: 108,
worldY: 100,
x: 108,
y: 100,
} as any,
})
const itemUnit = world.getUnit('item', 888)!
// Click on distant item
const rec = commandMapper.triggerPrimaryWorldAction(108, 100, itemUnit, 1000)
expect(rec).not.toBeNull()
// Initial click sends MoveToUnit and attempts ItemPickup
expect(sentCommands.some((c) => c.type === 'MoveToUnit' && c.target.id === 888)).toBe(true)
expect(sentCommands.some((c) => c.type === 'ItemPickup' && c.itemId === 888)).toBe(true)
// Player moves toward the item: at (105, 100), distance is 3 (<= 4)
world.self.x = 105
world.self.y = 100
// Clear sent commands to observe arrival pickup
sentCommands.length = 0
// Advance tick at 1150ms (> 80ms interaction cooldown)
const tickResult = commandMapper.tick(1150)
expect(tickResult).not.toBeNull()
expect(tickResult!.type).toBe('ItemPickup')
expect((tickResult as any).itemId).toBe(888)
expect(sentCommands.some((c) => c.type === 'ItemPickup' && c.itemId === 888)).toBe(true)
// Subsequent tick does not re-emit once picked up
sentCommands.length = 0
const nextTick = commandMapper.tick(1200)
expect(nextTick).toBeNull()
expect(sentCommands.length).toBe(0)
})
it('pickUnitAtSubtile skips dead monsters with mode 0 (Death) or mode 12 (Dead)', () => {
const world = new ClientWorld()
const hudManager = new HudManager()
const commandMapper = new CommandMapper({
world,
hudManager,
sendCommand: () => {},
})
// Dead monster at (100, 100) with mode 12
world.applyServerEvent({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 301,
classId: 1,
x: 100,
y: 100,
mode: 12,
lifePct: 0,
},
})
// Dying monster at (100, 100) with mode 0
world.applyServerEvent({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 302,
classId: 1,
x: 100,
y: 100,
mode: 0,
lifePct: 0,
},
})
// Alive monster at (101, 100) with mode 1
world.applyServerEvent({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 303,
classId: 1,
x: 101,
y: 100,
mode: 1,
lifePct: 100,
},
})
const picked = commandMapper.pickUnitAtSubtile(100, 100, 2.5)
expect(picked).not.toBeNull()
expect(picked!.id).toBe(303) // Picked the alive monster, ignored 301 and 302
})
})