From f26fa9e6363d7f503f7badf9d548562225769ddf Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 23:38:35 +0000 Subject: [PATCH] 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) --- src/client/input/command-mapper.ts | 303 +++++++--- src/client/ui-model/hud-model.ts | 12 + src/client/ui/hotkeys.ts | 5 + src/client/world/client-unit.ts | 23 + src/client/world/client-world.ts | 40 +- src/client/world/self.ts | 9 +- src/netproto/d2gs/s2c/skills.ts | 12 +- tests/client/client-world.test.ts | 3 + tests/client/combat-targeting-batch5.test.ts | 575 +++++++++++++++++++ 9 files changed, 904 insertions(+), 78 deletions(-) create mode 100644 tests/client/combat-targeting-batch5.test.ts diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index ba58f4d..10d583e 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -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([ + 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() @@ -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,21 +395,30 @@ 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') - 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 + // 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 + 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 + } } + if (bestUnit) return bestUnit } - 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,12 +527,18 @@ export class CommandMapper { if (this.isPlayerBusyCasting(nowMs)) { return } - this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) + 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 } - this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) + if (nowMs - this.lastHoldDispatchMs >= 40) { + this.lastHoldDispatchMs = nowMs + this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) + } } } @@ -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,45 +803,38 @@ export class CommandMapper { nowMs, path, ) - this.sendCommand( - { - type: 'NpcInit', - target: ref, - }, - nowMs, - ) - this.sendCommand( - { - type: 'Interact', - target: ref, - }, - nowMs, - ) + const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY) + if (dist <= 6) { + this.sendCommand( + { + type: 'NpcInit', + target: ref, + }, + nowMs, + ) + this.sendCommand( + { + type: 'Interact', + target: ref, + }, + nowMs, + ) + } return moveRecord } - // Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack - if (leftSkillId > 0) { - if (this.isPlayerBusyCasting(nowMs)) { - return null - } - return this.dispatchRateLimited( - { - type: 'SkillOn', - hand: 'left', - target: unitToRef(hoveredUnit), - }, - nowMs, - ) + + // Hostile monster: attack/cast left skill on monster (Attack skill 0 included per 1.13c) + if (this.isPlayerBusyCasting(nowMs)) { + return null } - const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning) return this.dispatchRateLimited( { - type: 'MoveToUnit', - run: isRunning, + type: 'SkillOn', + hand: 'left', target: unitToRef(hoveredUnit), + shift: this.shiftHeld, }, nowMs, - path, ) } } @@ -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, diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 48a0522..7b46c1b 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -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 diff --git a/src/client/ui/hotkeys.ts b/src/client/ui/hotkeys.ts index 5f45cf1..26d43c4 100644 --- a/src/client/ui/hotkeys.ts +++ b/src/client/ui/hotkeys.ts @@ -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 diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 5cd216f..f0960db 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -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))) + } } diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 6808773..adc11ad 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -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() + + 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 diff --git a/src/client/world/self.ts b/src/client/world/self.ts index b7a059a..e149470 100644 --- a/src/client/world/self.ts +++ b/src/client/world/self.ts @@ -45,6 +45,7 @@ export interface SelfPlayerState { readonly skills: ReadonlyMap readonly skillEntries: ReadonlyMap readonly states: ReadonlySet + readonly skillCharges?: ReadonlyMap | undefined leftSkill: number rightSkill: number runMode: boolean @@ -76,6 +77,7 @@ export class MutableSelfPlayerState implements SelfPlayerState { readonly skills = new Map() readonly skillEntries = new Map() readonly states = new Set() + readonly skillCharges = new Map() 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 diff --git a/src/netproto/d2gs/s2c/skills.ts b/src/netproto/d2gs/s2c/skills.ts index 1b6031f..17811cf 100644 --- a/src/netproto/d2gs/s2c/skills.ts +++ b/src/netproto/d2gs/s2c/skills.ts @@ -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) */ diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 7346813..70da01b 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -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 diff --git a/tests/client/combat-targeting-batch5.test.ts b/tests/client/combat-targeting-batch5.test.ts new file mode 100644 index 0000000..288a649 --- /dev/null +++ b/tests/client/combat-targeting-batch5.test.ts @@ -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 + }) +})