Fix Attack skill 0 targeting, DCC hitbox picking, corpse skills, warp 0x13, and net-world authority (Closes #577, #578, #579, #580, #581, #582, #583, #585, #589, #590, #591, #593, #594, #595)

This commit is contained in:
troytt 2026-10-02 23:38:35 +00:00
parent d7ca085046
commit f26fa9e636
9 changed files with 904 additions and 78 deletions

View File

@ -38,6 +38,31 @@ import {
} from '../view/viewport-profile.ts'
import type { ClientUnit } from '../world/client-unit.ts'
import type { ClientWorld } from '../world/client-world.ts'
import { getMonsterKindByHcIdx } from '../../common/units/monsters-data.ts'
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
export const CORPSE_TARGET_SKILL_IDS = new Set<number>([
70, // Raise Skeleton
74, // Corpse Explosion
80, // Raise Skeletal Mage
83, // Poison Explosion
91, // Iron Golem
92, // Blood Golem
95, // Revive
131, // Find Potion
142, // Find Item
150, // Grim Ward
158, // Heart of Wolverine
175, // Carrion Vine
181, // Solar Creeper
214, // Rest in Peace / corpse redemption
307, // Death Sentry
312, // Wake of Fire
314, // Blade Sentinel
325, // Blade Shield
327, // Blade Fury
351, // Carrion Vine / Creeper
])
export interface CameraLike {
readonly subX: number
@ -167,6 +192,7 @@ export class CommandMapper {
private lockedPointerUnit: ClientUnit | null = null
private pendingItemPickupId: number | null = null
private lastPath: readonly SubTilePoint[] = []
private lastHoldDispatchMs = 0
/** Default F1..F8 skill hotkey bindings (`key.toUpperCase()` -> binding). */
private readonly skillHotkeys = new Map<string, SkillHotkeyBinding>()
@ -222,6 +248,73 @@ export class CommandMapper {
// ─── Coordinate & Hit-Testing Helpers ────────────────────────
/**
* Test if unit's screen-space bounding box contains `(screenX, screenY)`.
*/
findUnitAtScreenPoint(screenX: number, screenY: number): ClientUnit | null {
const localPlayerId = this.world.selfId
const leftSkill = this.hudModel ? this.hudModel.leftSkillId : this.hudManager.hotkeys.leftSkillId
const rightSkill = this.hudModel ? this.hudModel.rightSkillId : this.hudManager.hotkeys.rightSkillId
const canTargetCorpse =
CORPSE_TARGET_SKILL_IDS.has(this.world.self.leftSkill) ||
CORPSE_TARGET_SKILL_IDS.has(this.world.self.rightSkill) ||
CORPSE_TARGET_SKILL_IDS.has(leftSkill) ||
CORPSE_TARGET_SKILL_IDS.has(rightSkill)
const testUnit = (unit: ClientUnit): boolean => {
const hb = typeof (unit as any).getHitBox === 'function'
? (unit as any).getHitBox()
: ((unit as any).hitBox ?? { left: -24, top: -48, width: 48, height: 48 })
const pos = this.camera.subtileToScreen(unit.x, unit.y)
return (
screenX >= pos.x + hb.left &&
screenX <= pos.x + hb.left + hb.width &&
screenY >= pos.y + hb.top &&
screenY <= pos.y + hb.top + hb.height
)
}
// 1. If Alt is held, prioritize ground items
if (this.altHeld) {
for (const item of this.world.getUnitsByType(4)) {
if (testUnit(item)) return item
}
}
// 2. Check monsters/NPCs (1), players (0), objects (2), warps (5)
let bestUnit: ClientUnit | null = null
let bestDistSq = Infinity
for (const unitType of [1, 0, 2, 5] as const) {
for (const unit of this.world.getUnitsByType(unitType)) {
if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) {
continue
}
const isDeadMonster = unitType === 1 && (unit.mode === 0 || unit.mode === 12 || unit.lifePct <= 0)
if (isDeadMonster && !canTargetCorpse) {
continue
}
if (testUnit(unit)) {
const pos = this.camera.subtileToScreen(unit.x, unit.y)
const distSq = (pos.x - screenX) ** 2 + (pos.y - screenY) ** 2
if (distSq < bestDistSq) {
bestDistSq = distSq
bestUnit = unit
}
}
}
}
if (bestUnit) return bestUnit
// 3. Check ground items if Alt was not held
if (!this.altHeld) {
for (const item of this.world.getUnitsByType(4)) {
if (testUnit(item)) return item
}
}
return null
}
/**
* Convert screen coordinates `(screenX, screenY)` (in canvas pixels, supporting
* any registered `ViewportProfile`) to world subtile coordinates and hit-test units.
@ -260,7 +353,17 @@ export class CommandMapper {
}
}
// 2. Hit-test world units, compensating for `world-renderer.ts` `camY = playerWorld.y - 16`
// 2. Hit-test world units using screen-space bounding box
const screenUnit = this.findUnitAtScreenPoint(screenX, screenY)
if (screenUnit) {
return {
subtileX: subtile.x,
subtileY: subtile.y,
hoveredUnit: screenUnit,
}
}
// 3. Fall back to radial subtile distance
const renderAlignedSubtile = this.camera.screenToSubtile(screenX, screenY - 16)
const unit = this.pickUnitAtSubtile(
subtile.x,
@ -278,7 +381,7 @@ export class CommandMapper {
/**
* Pick the closest interactive unit within `radiusSubtiles` of `(subtileX, subtileY)`.
* Excludes the local player unit and dead/corpse monsters unless interactable.
* Excludes the local player unit and dead/corpse monsters unless corpse skill is active.
*/
pickUnitAtSubtile(
subtileX: number,
@ -292,8 +395,16 @@ export class CommandMapper {
let bestUnit: ClientUnit | null = null
let bestDistSq = radiusSq
const hasAlt = altSubtileX !== undefined && altSubtileY !== undefined
const leftSkill = this.hudModel ? this.hudModel.leftSkillId : this.hudManager.hotkeys.leftSkillId
const rightSkill = this.hudModel ? this.hudModel.rightSkillId : this.hudManager.hotkeys.rightSkillId
const canTargetCorpse =
CORPSE_TARGET_SKILL_IDS.has(this.world.self.leftSkill) ||
CORPSE_TARGET_SKILL_IDS.has(this.world.self.rightSkill) ||
CORPSE_TARGET_SKILL_IDS.has(leftSkill) ||
CORPSE_TARGET_SKILL_IDS.has(rightSkill)
// 1. Check ground items first (unitType 4 = 'item')
// 1. Check ground items first only if Alt is held
if (this.altHeld) {
for (const unit of this.world.getUnitsByType(4)) {
const dx = unit.x - subtileX
const dy = unit.y - subtileY
@ -307,6 +418,7 @@ export class CommandMapper {
}
}
if (bestUnit) return bestUnit
}
// 2. Check monsters/NPCs (1), objects (2), warp tiles (5), and other players (0)
const candidateTypes = [1, 2, 5, 0] as const
@ -315,8 +427,8 @@ export class CommandMapper {
if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) {
continue
}
// Skip dead monsters (mode 0 = Death, mode 12 = Dead, or lifePct <= 0)
if (unitType === 1 && (unit.mode === 0 || unit.mode === 12 || unit.lifePct <= 0)) {
const isDeadMonster = unitType === 1 && (unit.mode === 0 || unit.mode === 12 || unit.lifePct <= 0)
if (isDeadMonster && !canTargetCorpse) {
continue
}
const dx = unit.x - subtileX
@ -331,6 +443,23 @@ export class CommandMapper {
}
}
}
if (bestUnit) return bestUnit
// 3. Check ground items last if Alt was not held
if (!this.altHeld) {
for (const unit of this.world.getUnitsByType(4)) {
const dx = unit.x - subtileX
const dy = unit.y - subtileY
const d2Primary = dx * dx + dy * dy
const d2 = hasAlt
? Math.min(d2Primary, (unit.x - altSubtileX!) ** 2 + (unit.y - altSubtileY!) ** 2)
: d2Primary
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
bestDistSq = d2
bestUnit = unit
}
}
}
return bestUnit
}
@ -398,14 +527,20 @@ export class CommandMapper {
if (this.isPlayerBusyCasting(nowMs)) {
return
}
if (nowMs - this.lastHoldDispatchMs >= 40) {
this.lastHoldDispatchMs = nowMs
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
}
} else if (this.rightMouseDown) {
if (this.isPlayerBusyCasting(nowMs)) {
return
}
if (nowMs - this.lastHoldDispatchMs >= 40) {
this.lastHoldDispatchMs = nowMs
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
}
}
}
/**
* Handle mouse/pointer button down at `(screenX, screenY)`.
@ -442,6 +577,7 @@ export class CommandMapper {
if (button === 0) {
this.leftMouseDown = true
this.lastHoldDispatchMs = nowMs
this.lockedPointerUnit =
target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null
if (this.isPlayerBusyCasting(nowMs)) {
@ -452,6 +588,7 @@ export class CommandMapper {
if (button === 2) {
this.rightMouseDown = true
this.lastHoldDispatchMs = nowMs
this.lockedPointerUnit = null
if (this.isPlayerBusyCasting(nowMs)) {
return null
@ -468,9 +605,11 @@ export class CommandMapper {
handlePointerUp(button: number): void {
if (button === 0) {
this.leftMouseDown = false
this.lastHoldDispatchMs = 0
this.lockedPointerUnit = null
} else if (button === 2) {
this.rightMouseDown = false
this.lastHoldDispatchMs = 0
}
}
@ -540,11 +679,16 @@ export class CommandMapper {
} else {
this.pendingItemPickupId = null
}
const toCursor = Boolean(
this.hudManager.inventory?.visible ||
this.hudManager.rightPanel === 'inv' ||
(this.hudModel?.hudManager && (this.hudModel.hudManager.inventory?.visible || this.hudModel.hudManager.rightPanel === 'inv'))
)
return this.dispatchRateLimited(
{
type: 'ItemPickup',
itemId: hoveredUnit.id,
toCursor: false,
toCursor,
},
nowMs,
path,
@ -567,6 +711,16 @@ export class CommandMapper {
nowMs,
path,
)
this.sendCommand(
{
type: 'Interact',
target: {
unitType: UNIT_TYPE_TO_ID.tile,
id: hoveredUnit.id,
},
},
nowMs,
)
this.sendCommand(
{
type: 'TakeWarp',
@ -631,7 +785,13 @@ export class CommandMapper {
this.world.areaId === 75 ||
this.world.areaId === 103 ||
this.world.areaId === 109
if (isTownArea) {
const monKind = getMonsterKindByHcIdx(hoveredUnit.classId, this.world.difficulty)
const isNpc =
isTownArea ||
Boolean(monKind?.interact || monKind?.npc) ||
getTownNpcDescriptorByHcIdx(hoveredUnit.classId) !== undefined
if (isNpc) {
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
const ref = unitToRef(hoveredUnit)
const moveRecord = this.dispatchRateLimited(
@ -643,6 +803,8 @@ export class CommandMapper {
nowMs,
path,
)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist <= 6) {
this.sendCommand(
{
type: 'NpcInit',
@ -657,10 +819,11 @@ export class CommandMapper {
},
nowMs,
)
}
return moveRecord
}
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
if (leftSkillId > 0) {
// Hostile monster: attack/cast left skill on monster (Attack skill 0 included per 1.13c)
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
@ -669,21 +832,11 @@ export class CommandMapper {
type: 'SkillOn',
hand: 'left',
target: unitToRef(hoveredUnit),
shift: this.shiftHeld,
},
nowMs,
)
}
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
return this.dispatchRateLimited(
{
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
},
nowMs,
path,
)
}
}
// Ground walk / run
@ -692,12 +845,11 @@ export class CommandMapper {
}
this.pendingItemPickupId = null
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
return this.dispatchRateLimited(
{
type: 'Move',
run: isRunning,
to: { x: moveTarget.x, y: moveTarget.y },
to: { x: subtileX, y: subtileY },
},
nowMs,
path,
@ -868,17 +1020,25 @@ export class CommandMapper {
// Function keys F1..F8 -> SelectSkill (0x3c)
if (/^F[1-8]$/.test(keyUpper)) {
event.preventDefault?.()
if (this.hudManager.hotkeys.openPopup !== null && this.hudManager.hotkeys.hoveredPopupSkill !== null) {
this.hudManager.hotkeys.bindHotkey(keyUpper, this.hudManager.hotkeys.hoveredPopupSkill.side, this.hudManager.hotkeys.hoveredPopupSkill.skillId)
return true
}
const binding = this.skillHotkeys.get(keyUpper)
if (binding) {
const hudBinding = this.hudManager.hotkeys.bindings.get(keyUpper)
const hand = binding?.hand ?? hudBinding?.side ?? 'right'
const skillId = binding?.skillId ?? hudBinding?.skillId
const itemId = binding?.itemId ?? 0xffffffff
if (skillId !== undefined) {
if (this.hudModel) {
this.hudModel.selectSkill(binding.hand, binding.skillId, binding.itemId ?? 0xffffffff)
this.hudModel.selectSkill(hand, skillId, itemId)
} else {
this.dispatchRateLimited(
{
type: 'SelectSkill',
hand: binding.hand,
skill: binding.skillId,
itemId: binding.itemId,
hand,
skill: skillId,
itemId,
},
nowMs,
)
@ -916,11 +1076,20 @@ export class CommandMapper {
this.hudManager.toggleRightPanel('inv')
return true
}
case 'T':
case 'S': {
this.hudManager.hotkeys.openPopup = this.hudManager.hotkeys.openPopup ? null : 'right'
this.hudManager.syncPublishedState()
return true
}
case 'T': {
this.hudManager.toggleRightPanel('skill')
return true
}
case 'V': {
this.hudManager.belt.togglePopup()
this.hudManager.syncPublishedState()
return true
}
case 'Q': {
this.hudManager.toggleLeftPanel('quest')
return true
@ -933,14 +1102,6 @@ export class CommandMapper {
}
return true
}
case 'M': {
if (this.hudModel) {
this.hudModel.togglePanel('waypoint')
} else {
this.hudManager.toggleLeftPanel('waypoint')
}
return true
}
case 'O': {
if (this.world.merc && !this.world.merc.isDead) {
if (this.hudModel) {
@ -1044,6 +1205,10 @@ export class CommandMapper {
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
if (nowMs - this.lastHoldDispatchMs < 40) {
return null
}
this.lastHoldDispatchMs = nowMs
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
const record = this.triggerPrimaryWorldAction(
target.subtileX,
@ -1058,6 +1223,10 @@ export class CommandMapper {
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
if (nowMs - this.lastHoldDispatchMs < 40) {
return null
}
this.lastHoldDispatchMs = nowMs
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
const record = this.triggerSecondaryWorldAction(
target.subtileX,

View File

@ -283,6 +283,9 @@ export class HudModel implements HudCommandSink {
hud.onPlayUiSound = (soundId) => {
this.uiSound?.(soundId)
}
hud.hotkeys.onSelectSkill = (side, skillId) => {
this.selectSkill(side, skillId)
}
;(hud as any).onWeaponSwitch = () => {
this.swapWeapons()
}
@ -667,6 +670,7 @@ export class HudModel implements HudCommandSink {
const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) continue
const effSkillLevel = hard + bonus
const charges = self.skillCharges?.get(skillId)
hud.hotkeys.addOrUpdateSkill({
skillId,
name: catalogEntry.name,
@ -676,7 +680,15 @@ export class HudModel implements HudCommandSink {
leftUsable: isLeftUsableSkill(skillId),
rightUsable: true,
isAura: isAuraSkill(skillId),
...(charges !== undefined ? { charges } : {}),
})
if (charges !== undefined) {
hud.hotkeys.setSkillCharges(skillId, charges)
}
}
for (const [slot, binding] of this.world.skillHotkeys.entries()) {
const fKey = `F${slot + 1}`
hud.hotkeys.bindHotkey(fKey, binding.hand, binding.skill)
}
hud.hotkeys.leftSkillId = self.leftSkill
hud.hotkeys.rightSkillId = self.rightSkill

View File

@ -376,6 +376,11 @@ export class SkillHotkeysHud {
this.bindings.set(fKey.toUpperCase(), { side, skillId })
}
assignSlot(slot: number, side: 'left' | 'right', skillId: number): void {
const fKey = `F${slot + 1}`
this.bindHotkey(fKey, side, skillId)
}
getHotkey(key: string): number | null {
const bound = this.bindings.get(key.toUpperCase())
return bound ? bound.skillId : null

View File

@ -417,7 +417,30 @@ export class ClientUnit {
}
}
hitBox?: { left: number; top: number; width: number; height: number } | undefined
getHitBox(): { left: number; top: number; width: number; height: number } {
if (this.hitBox) return this.hitBox
switch (this.unitType) {
case 'monster':
case 'player':
return { left: -24, top: -64, width: 48, height: 72 }
case 'object':
return { left: -24, top: -48, width: 48, height: 48 }
case 'tile':
return { left: -32, top: -32, width: 64, height: 48 }
case 'item':
return { left: -16, top: -16, width: 32, height: 24 }
default:
return { left: -24, top: -48, width: 48, height: 48 }
}
}
get dead(): boolean {
return this.lifePct <= 0 && (this.unitType === 'monster' || this.unitType === 'player')
}
get lifePercent(): number {
return Math.max(0, Math.min(100, Math.round((this.lifePct / 128) * 100)))
}
}

View File

@ -1054,8 +1054,6 @@ export class ClientWorld {
break
}
case 'Resurrect':
this._isSelfDead = false
this._self.isDead = false
break
case 'Move':
case 'WalkToLocation':
@ -1153,6 +1151,12 @@ export class ClientWorld {
return this._self
}
readonly skillHotkeys = new Map<number, { hand: 'left' | 'right'; skill: number; itemId?: number }>()
assignSkillHotkey(slot: number, hand: 'left' | 'right', skill: number, itemId?: number): void {
this.skillHotkeys.set(slot, itemId !== undefined ? { hand, skill, itemId } : { hand, skill })
}
get player(): SelfPlayerState {
return this._self
}
@ -1778,16 +1782,24 @@ export class ClientWorld {
if (!rawEv || typeof rawEv !== 'object') return
let ev: any = rawEv
if (ev.type === 'ActDataLoaded') {
const area = ev.townAreaId ?? ev.areaId
if (area === undefined || area === null) {
throw new Error('ActDataLoaded missing areaId')
}
ev = {
type: 'LoadAct',
act: Number(ev.act ?? 0),
mapSeed: Number(ev.mapSeed ?? 0) >>> 0,
areaId: Number(ev.townAreaId ?? ev.areaId ?? 1),
areaId: Number(area),
objectSeed: Number(ev.unknown ?? 0) >>> 0,
}
} else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') {
const rawId = ev.unitId ?? ev.id
if (rawId === undefined || rawId === null) {
throw new Error(`${ev.type} missing unitId`)
}
const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1)
const uId = Number(ev.unitId ?? ev.id ?? 1)
const uId = Number(rawId)
const lifePct = ev.maxHp
? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128)
: 128
@ -1957,6 +1969,9 @@ export class ClientWorld {
break
case 'UnitAssign': {
if (!ev.unit) {
throw new Error('UnitAssign missing unit payload')
}
const key = makeUnitKey(ev.unit.unitType, ev.unit.id)
let unit = this._units.get(key)
if (!unit) {
@ -2432,6 +2447,9 @@ export class ClientWorld {
this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0
break
}
if (!ev.item) {
throw new Error(`${ev.type} missing item payload`)
}
const effectiveAction = ev.action ?? ev.item?.action ?? 0
const effectiveContainer = ev.container ?? ev.item?.container ?? 'inventory'
this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId)
@ -2470,7 +2488,15 @@ export class ClientWorld {
this._self.unitId = ev.unitId
}
if (ev.unitId === this.selfId || ev.unitId === 0) {
const isStatus = (ev as any).isSkillStatus === true
for (const sk of ev.skills) {
const rawQuantity = (sk as any).quantity ?? (isStatus ? sk.level : undefined)
if (rawQuantity !== undefined) {
this._self.skillCharges.set(sk.skillId, rawQuantity)
}
if (isStatus) {
continue
}
const existing = this._self.skillEntries.get(sk.skillId)
const bonus = sk.bonus ?? existing?.bonus ?? 0
const merged: SkillLevel = {
@ -2488,6 +2514,12 @@ export class ClientWorld {
}
case 'ActiveSkill': {
const isHotkey = (ev as any).isHotkey === true || (ev as any).slot !== undefined
if (isHotkey) {
const slot = (ev as any).slot ?? ev.unitId
this.assignSkillHotkey(slot, ev.hand, ev.skill, ev.itemId)
break
}
if (this.selfId === 0 && ev.unitId !== 0) {
this.selfId = ev.unitId
this._self.unitId = ev.unitId

View File

@ -45,6 +45,7 @@ export interface SelfPlayerState {
readonly skills: ReadonlyMap<number, number>
readonly skillEntries: ReadonlyMap<number, SkillLevel>
readonly states: ReadonlySet<number>
readonly skillCharges?: ReadonlyMap<number, number> | undefined
leftSkill: number
rightSkill: number
runMode: boolean
@ -76,6 +77,7 @@ export class MutableSelfPlayerState implements SelfPlayerState {
readonly skills = new Map<number, number>()
readonly skillEntries = new Map<number, SkillLevel>()
readonly states = new Set<number>()
readonly skillCharges = new Map<number, number>()
leftSkill = 0
rightSkill = 0
runMode = true
@ -148,7 +150,7 @@ export class MutableSelfPlayerState implements SelfPlayerState {
applyLifeMana(life: number, mana: number, stamina: number, x: number, y: number): void {
if (life >= 0) {
this.life = life
this.stats.set(STAT_HITPOINTS, life)
this.stats.set(STAT_HITPOINTS, life << POOL_STAT_VAL_SHIFT)
if (life > 0) {
this.isDead = false
} else if (life === 0 && this.maxLife > 0) {
@ -157,11 +159,11 @@ export class MutableSelfPlayerState implements SelfPlayerState {
}
if (mana >= 0) {
this.mana = mana
this.stats.set(STAT_MANA, mana)
this.stats.set(STAT_MANA, mana << POOL_STAT_VAL_SHIFT)
}
if (stamina >= 0) {
this.stamina = stamina
this.stats.set(STAT_STAMINA, stamina)
this.stats.set(STAT_STAMINA, stamina << POOL_STAT_VAL_SHIFT)
}
if (x !== 0 || y !== 0) {
this.x = x
@ -202,6 +204,7 @@ export class MutableSelfPlayerState implements SelfPlayerState {
this.skills.clear()
this.skillEntries.clear()
this.states.clear()
this.skillCharges.clear()
this.leftSkill = 0
this.rightSkill = 0
this.runMode = true

View File

@ -46,13 +46,15 @@ export function decode0x22UpdateSkillStatus(pkt: Uint8Array): ServerEvent {
r.u16LE() // unk
const unitId = r.u32LE()
const skill = r.u16LE()
const amount = r.u8()
const quantity = r.u8()
r.u16LE()
return {
type: 'SkillList',
unitId,
skills: [{ skillId: skill, level: amount }],
}
skills: [{ skillId: skill, level: quantity, quantity } as any],
quantity,
isSkillStatus: true,
} as ServerEvent
}
/** 0x23 AssignSkill (13B, D2Client.dll 0x6fb5c780) */
@ -149,7 +151,9 @@ export function decode0x7BAssignSkillHotkey(pkt: Uint8Array): ServerEvent {
hand,
skill,
itemId,
}
slot,
isHotkey: true,
} as ServerEvent
}
/** 0x94 BaseSkillLevels (variable 6 + 3 * pkt[1], D2Client.dll 0x6fb5c880) */

View File

@ -596,6 +596,9 @@ describe('ClientWorld authoritative state mirror', () => {
expect(world.isDead).toBe(true)
expect(world.self.isDead).toBe(true)
world.noteOutgoingCommand({ type: 'Resurrect' })
// Resurrect command does not optimistically revive until server LifeMana/Reassign arrives (Issue #578)
expect(world.isDead).toBe(true)
world.apply({ type: 'LifeMana', life: 100 << 8, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
expect(world.isDead).toBe(false)
// 7. Party roster, relation, hostility, loot permit, position

View File

@ -0,0 +1,575 @@
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
})
})