diablo2-web/tests/client/combat-targeting-batch5.tes...

576 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import { CommandMapper, CORPSE_TARGET_SKILL_IDS } from '../../src/client/input/command-mapper.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { ClientUnit } from '../../src/client/world/client-unit.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { decode0x22UpdateSkillStatus, decode0x7BAssignSkillHotkey } from '../../src/netproto/d2gs/s2c/skills.ts'
import type { ClientCommand } from '../../src/netproto/index.ts'
describe('Combat Targeting, Input & Net-World Parity (Milestone M5 Phase 3)', () => {
function setupTestEnvironment() {
const world = new ClientWorld()
const hudManager = new HudManager()
const dispatchedCommands: ClientCommand[] = []
const sentCommands: ClientCommand[] = []
const hudModel = new HudModel({
world,
hudManager,
onCommand: (cmd: ClientCommand) => {
sentCommands.push(cmd)
},
})
hudManager.commandSink = hudModel
const mockCamera = {
subtileToScreen(x: number, y: number) {
return { x: (x - 5000) * 16 + 400, y: (y - 5000) * 8 + 300 }
},
screenToSubtile(x: number, y: number) {
return { x: (x - 400) / 16 + 5000, y: (y - 300) / 8 + 5000 }
},
update(_x: number, _y: number) {},
}
const commandMapper = new CommandMapper({
world,
hudManager,
hudModel,
camera: mockCamera as any,
sendCommand: (cmd: ClientCommand) => {
sentCommands.push(cmd)
},
})
// Initialize player at (5000, 5000)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 0,
id: 1,
classId: 1,
name: 'Hero',
x: 5000,
y: 5000,
},
isSelf: true,
})
return {
world,
hudManager,
hudModel,
mockCamera,
commandMapper,
dispatchedCommands,
sentCommands,
}
}
// ─── 1. Issue #581: Default Attack (skill 0) sends C2S 0x06 LeftSkillOnUnit ───
it('Issue #581: left-clicking a hostile monster with skill 0 (Attack) sends SkillOn left', () => {
const { world, commandMapper, sentCommands } = setupTestEnvironment()
world.self.leftSkill = 0 // Attack
// Spawn hostile monster at (5003, 5000)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 101,
classId: 2, // Blood Raven / monster
name: 'Zombie',
x: 5003,
y: 5000,
lifePct: 128,
mode: 1, // Neutral alive
},
})
const monster = world.getUnit('monster', 101)!
expect(monster.dead).toBe(false)
// Left click at monster position
const record = commandMapper.triggerPrimaryWorldAction(5003, 5000, monster, 1000)
expect(record).not.toBeNull()
expect(record?.command).toEqual({
type: 'SkillOn',
hand: 'left',
target: { unitType: 1, id: 101 },
shift: false,
})
})
// ─── 2. Issue #582: Mouse hover hitbox against DCC screen bounds & item priority ───
it('Issue #582: findUnitAtScreenPoint tests DCC bounding box; living monster takes priority over ground item unless Alt is held', () => {
const { world, commandMapper, mockCamera } = setupTestEnvironment()
// Spawn monster at (5000, 5000)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 201,
classId: 5,
name: 'Gargoyle',
x: 5000,
y: 5000,
lifePct: 128,
mode: 1,
},
})
const monster = world.getUnit('monster', 201)!
expect(monster.getHitBox()).toEqual({
left: -24,
top: -64,
width: 48,
height: 72,
})
// Spawn ground item at exact same subtile (5000, 5000)
world.apply({
type: 'ItemEvent',
action: 0,
container: 'ground',
item: {
id: 999,
itemId: 999,
code: 'tsc',
rawCode: 'tsc ',
x: 5000,
y: 5000,
worldX: 5000,
worldY: 5000,
action: 0,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 0,
container: 'ground',
bodyLoc: 0,
equippedSlot: 0,
gridX: 0,
gridY: 0,
storePage: 1,
storagePage: 1,
compact: true,
simple: true,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
usedSockets: 0,
} as any,
})
// Screen coordinate for (5000, 5000) is (400, 300)
// Point (400, 260) is inside monster hitbox (left -24..24 -> 376..424, top -64..8 -> 236..308)
const pickedScreen = commandMapper.findUnitAtScreenPoint(400, 260)
expect(pickedScreen?.id).toBe(201)
// Subtile picking without Alt: monster takes precedence over item
const pickedSubtileNormal = commandMapper.pickUnitAtSubtile(5000, 5000)
expect(pickedSubtileNormal?.id).toBe(201)
expect(pickedSubtileNormal?.unitType).toBe('monster')
// Subtile picking with Alt held: item takes precedence
commandMapper.handleKeyDown({ key: 'Alt', altKey: true })
const pickedSubtileAlt = commandMapper.pickUnitAtSubtile(5000, 5000)
expect(pickedSubtileAlt?.id).toBe(999)
expect(pickedSubtileAlt?.unitType).toBe('item')
commandMapper.handleKeyUp({ key: 'Alt' })
})
// ─── 3. Issue #583: Corpse unit selection for corpse skills ───
it('Issue #583: dead monster units can be targeted when active skill has TargetCorpse = 1', () => {
const { world, commandMapper } = setupTestEnvironment()
// Spawn dead monster (corpse) at (5002, 5000)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 301,
classId: 1,
name: 'Skeleton',
x: 5002,
y: 5000,
lifePct: 0,
mode: 12, // MONMODE_DEATH
},
})
const corpseUnit = world.getUnit('monster', 301)!
expect(corpseUnit.dead).toBe(true)
// With regular Attack (skill 0), corpse is NOT picked
world.self.leftSkill = 0
world.self.rightSkill = 0
let picked = commandMapper.pickUnitAtSubtile(5002, 5000)
expect(picked).toBeNull()
// With Corpse Explosion (skill 70 in CORPSE_TARGET_SKILL_IDS), corpse CAN be picked
expect(CORPSE_TARGET_SKILL_IDS.has(70)).toBe(true)
world.self.rightSkill = 70
picked = commandMapper.pickUnitAtSubtile(5002, 5000)
expect(picked?.id).toBe(301)
expect(picked?.dead).toBe(true)
// Also verifies Barbarian Find Item (142) and Necromancer Revive (95) are in the set
expect(CORPSE_TARGET_SKILL_IDS.has(142)).toBe(true)
expect(CORPSE_TARGET_SKILL_IDS.has(95)).toBe(true)
})
// ─── 4. Issue #585: F1–F8 skill hotkey binding & S2C 0x7B decoding ───
it('Issue #585: S2C 0x7B assigns hotkey slot and pressing F1..F8 selects mapped skill', () => {
const { world, hudModel, commandMapper, sentCommands } = setupTestEnvironment()
// Decode 0x7B AssignSkillHotkey packet (slot 0 = F1, skill 47 = Fire Ball, hand right = 0x00)
// Wire: 0x7B (1B) | slot (1B) | skill_lo (1B) | skill_hi|hand (1B) | itemId (4B LE)
const wire7B = new Uint8Array([0x7b, 0x00, 0x2f, 0x00, 0xff, 0xff, 0xff, 0xff])
const decoded = decode0x7BAssignSkillHotkey(wire7B) as any
expect(decoded.type).toBe('ActiveSkill')
expect(decoded.isHotkey).toBe(true)
expect(decoded.slot).toBe(0)
expect(decoded.skill).toBe(47)
// Apply to ClientWorld
world.apply(decoded)
expect(world.skillHotkeys.get(0)).toMatchObject({
hand: 'right',
skill: 47,
})
// HudModel syncs world hotkeys to hud.hotkeys
hudModel.syncFromWorld()
// Press F1
sentCommands.length = 0
const handled = commandMapper.handleKeyDown({ key: 'F1' }, 1000)
expect(handled).toBe(true)
expect(sentCommands).toContainEqual({
type: 'SelectSkill',
hand: 'right',
skill: 47,
itemId: 0xffffffff,
})
})
// ─── 5. Issue #589: NPC distance check and monstats interact flag ───
it('Issue #589: clicking an interactive NPC only opens dialog when distance <= 6', () => {
const { world, commandMapper, sentCommands } = setupTestEnvironment()
// Player is at (5000, 5000)
// 1. Far NPC (Akara, classId 148) at (5015, 5000) -> distance = 15 > 6
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 401,
classId: 148, // Akara (interact = 1)
name: 'Akara',
x: 5015,
y: 5000,
lifePct: 128,
mode: 1,
},
})
const farNpc = world.getUnit('monster', 401)!
sentCommands.length = 0
commandMapper.triggerPrimaryWorldAction(5015, 5000, farNpc, 1000)
// Distance > 6: only MoveToUnit, NO NpcInit or Interact
expect(sentCommands.some((c) => c.type === 'NpcInit')).toBe(false)
expect(sentCommands.some((c) => c.type === 'Interact')).toBe(false)
// 2. Close NPC (Warriv, classId 155) at (5003, 5000) -> distance = 3 <= 6
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 402,
classId: 155, // Warriv (interact = 1)
name: 'Warriv',
x: 5003,
y: 5000,
lifePct: 128,
mode: 1,
},
})
const closeNpc = world.getUnit('monster', 402)!
sentCommands.length = 0
commandMapper.triggerPrimaryWorldAction(5003, 5000, closeNpc, 1000)
// Distance <= 6: sends MoveToUnit, NpcInit, and Interact
expect(sentCommands.some((c) => c.type === 'NpcInit')).toBe(true)
expect(sentCommands.some((c) => c.type === 'Interact')).toBe(true)
})
// ─── 6. Issue #590: Warp click sends C2S 0x13 Interact (unitType 5) ───
it('Issue #590: clicking a warp unit sends C2S 0x13 Interact with unitType 5', () => {
const { world, commandMapper, sentCommands } = setupTestEnvironment()
// Spawn a warp / door unit at (5004, 5000)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5, // tile / warp
id: 501,
classId: 1,
name: 'TownGate',
x: 5004,
y: 5000,
warpId: 1,
},
})
const warpUnit = world.getUnit(5, 501)!
sentCommands.length = 0
commandMapper.triggerPrimaryWorldAction(5004, 5000, warpUnit, 1000)
expect(sentCommands).toContainEqual({
type: 'Interact',
target: { unitType: 5, id: 501 },
})
expect(sentCommands).toContainEqual({
type: 'TakeWarp',
warpId: 501,
})
})
// ─── 7. Issue #591: Send raw clicked sub-tile coordinates in C2S Move ───
it('Issue #591: ground movement sends the exact clicked sub-tile coordinates', () => {
const { commandMapper } = setupTestEnvironment()
const record = commandMapper.triggerPrimaryWorldAction(5035, 5042, null, 1000)
expect(record).not.toBeNull()
expect(record?.command).toEqual({
type: 'Move',
run: true,
to: { x: 5035, y: 5042 },
})
})
// ─── 8. Issue #593: Pickup items toCursor: true when inventory is open ───
it('Issue #593: picking up ground item sets toCursor: true when inventory panel is open', () => {
const { world, hudManager, commandMapper, sentCommands } = setupTestEnvironment()
// Spawn ground item at (5001, 5000)
world.apply({
type: 'ItemEvent',
action: 0,
container: 'ground',
item: {
id: 601,
itemId: 601,
code: 'r01',
rawCode: 'r01 ',
x: 5001,
y: 5000,
worldX: 5001,
worldY: 5000,
action: 0,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 0,
container: 'ground',
bodyLoc: 0,
equippedSlot: 0,
gridX: 0,
gridY: 0,
storePage: 1,
storagePage: 1,
compact: true,
simple: true,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
usedSockets: 0,
} as any,
})
const itemUnit = world.getUnit('item', 601)!
// 1. With inventory closed -> toCursor = false
hudManager.rightPanel = 'none'
const recClosed = commandMapper.triggerPrimaryWorldAction(5001, 5000, itemUnit, 1000)
expect(recClosed?.command).toMatchObject({
type: 'ItemPickup',
toCursor: false,
})
// 2. With inventory open -> toCursor = true
hudManager.rightPanel = 'inv'
const recOpen = commandMapper.triggerPrimaryWorldAction(5001, 5000, itemUnit, 1050)
expect(recOpen?.command).toMatchObject({
type: 'ItemPickup',
toCursor: true,
})
})
// ─── 9. Issue #594: 1.13c default hotkeys alignment (S, T, V, no M) ───
it('Issue #594: S toggles speedbar popup, T toggles skill tree, V toggles belt popup, M is ignored', () => {
const { hudManager, commandMapper } = setupTestEnvironment()
// 'S': toggles hotkeys speedbar popup
expect(hudManager.hotkeys.openPopup).toBeNull()
commandMapper.handleKeyDown({ key: 's' })
expect(hudManager.hotkeys.openPopup).toBe('right')
commandMapper.handleKeyDown({ key: 's' })
expect(hudManager.hotkeys.openPopup).toBeNull()
// 'T': toggles skill tree
expect(hudManager.rightPanel).toBe('none')
commandMapper.handleKeyDown({ key: 't' })
expect(hudManager.rightPanel).toBe('skill')
// 'V': toggles belt popup (requires multi-row belt equipped to expand)
hudManager.inventory.equipped.belt = { code: 'hbl' } as any
expect(hudManager.belt.isExpanded).toBe(false)
commandMapper.handleKeyDown({ key: 'v' })
expect(hudManager.belt.isExpanded).toBe(true)
commandMapper.handleKeyDown({ key: 'v' })
expect(hudManager.belt.isExpanded).toBe(false)
// 'M': ignored (does not open waypoint)
commandMapper.handleKeyDown({ key: 'm' })
expect(hudManager.leftPanel).not.toBe('waypoint')
})
// ─── 10. Issue #595: CommandMapper mouse-hold 40ms throttling ───
it('Issue #595: mouse-hold continuous action dispatch is throttled to 40ms (~25Hz)', () => {
const { world, hudManager, hudModel, mockCamera, sentCommands } = setupTestEnvironment()
const rateLimiter = new OutboundRateLimiter({ moveMinIntervalMs: 40 })
const commandMapper = new CommandMapper({
world,
hudManager,
hudModel,
rateLimiter,
camera: mockCamera as any,
sendCommand: (cmd) => sentCommands.push(cmd),
})
// Pointer down at t = 1000ms
const cmd1 = commandMapper.handlePointerDown(450, 350, 0, 1000)
expect(cmd1).not.toBeNull()
// Move pointer to steer at t = 1016ms (16ms elapsed < 40ms): throttled, returns null
commandMapper.handlePointerMove(480, 380, 1016)
const cmd2 = commandMapper.tick(1016)
expect(cmd2).toBeNull()
// Frame update at t = 1045ms (45ms elapsed >= 40ms): allowed
const cmd3 = commandMapper.tick(1045)
expect(cmd3).not.toBeNull()
// Pointer up at t = 1050ms
commandMapper.handlePointerUp(0)
})
// ─── 11. Net-World Integrity: Issues #577, #578, #579, #580 ───
it('Issue #577: fail fast when UnitAssign or ItemEvent payload is missing', () => {
const world = new ClientWorld()
expect(() => {
world.apply({ type: 'UnitAssign' })
}).toThrow(/missing unit payload/)
expect(() => {
world.apply({ type: 'ItemEvent' })
}).toThrow(/missing item payload/)
})
it('Issue #578: ClientUnit lifePercent scales 0..128 to 0..100%, and Resurrect does not optimistically revive', () => {
const world = new ClientWorld()
const unit = new ClientUnit({
unitType: 'monster',
id: 777,
classId: 1,
x: 100,
y: 100,
lifePct: 64, // exactly half (64 / 128 = 50%)
})
expect(unit.lifePercent).toBe(50)
// Player death
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 5000, y: 5000 },
isSelf: true,
})
world.apply({ type: 'LifeMana', life: 0, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
expect(world.isDead).toBe(true)
// Sending Resurrect does not optimistically flip isDead
world.noteOutgoingCommand({ type: 'Resurrect' })
expect(world.isDead).toBe(true)
// Server sends LifeMana revival -> isDead becomes false
world.apply({ type: 'LifeMana', life: 100 << 8, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
expect(world.isDead).toBe(false)
})
it('Issue #579: fail fast when ActDataLoaded missing areaId or PlayerAssign missing unitId', () => {
const world = new ClientWorld()
expect(() => {
world.apply({ type: 'ActDataLoaded', act: 0 })
}).toThrow(/missing areaId/)
expect(() => {
world.apply({ type: 'PlayerAssign' })
}).toThrow(/missing unitId/)
})
it('Issue #580: S2C 0x22 skill charges separated from level, and 8.8 pool stats normalized', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 5000, y: 5000 },
isSelf: true,
})
// S2C 0x22 UpdateSkillStatus (12B: opcode | unk | unitId | skill | quantity | unk)
// skill 42 (0x002a), quantity 15 (0x0f)
const wire22 = new Uint8Array([0x22, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x0f, 0x00, 0x00])
const decoded22 = decode0x22UpdateSkillStatus(wire22) as any
expect(decoded22.isSkillStatus).toBe(true)
expect(decoded22.skills[0]?.quantity).toBe(15)
world.apply(decoded22)
expect(world.self.skillCharges?.get(42)).toBe(15)
// LifeMana event normalizes stats with ValShift = 8
world.apply({
type: 'LifeMana',
life: 80,
mana: 50,
stamina: 100,
at: { x: 5000, y: 5000 },
})
expect(world.self.stats.get(6)).toBe(80 << 8) // STAT_HITPOINTS
expect(world.self.stats.get(8)).toBe(50 << 8) // STAT_MANA
expect(world.self.stats.get(10)).toBe(100 << 8) // STAT_STAMINA
})
})