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:
parent
d7ca085046
commit
f26fa9e636
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue