This commit is contained in:
parent
7658ad94f1
commit
f8c938f866
|
|
@ -1603,6 +1603,35 @@ export class OnlineSession {
|
|||
break
|
||||
}
|
||||
|
||||
case 'UnitMode': {
|
||||
const uType = unitTypeToId(ev.unitType)
|
||||
const uId = ev.id
|
||||
const isLocalPlayer = uType === 0 && (uId === this.world.selfId || this.world.selfId === 0)
|
||||
if (isLocalPlayer) {
|
||||
if (ev.mode === 0 || ev.mode === 17) {
|
||||
this.predictor.stop()
|
||||
}
|
||||
} else {
|
||||
const unit = this.world.getUnit(ev.unitType, uId)
|
||||
const stopX = ev.at?.x ?? unit?.x
|
||||
const stopY = ev.at?.y ?? unit?.y
|
||||
if (
|
||||
stopX !== undefined &&
|
||||
stopY !== undefined &&
|
||||
(ev.mode === undefined ||
|
||||
(ev.mode !== 2 && ev.mode !== 3 && ev.mode !== 6 && ev.mode !== 15))
|
||||
) {
|
||||
this.interpolator.stopEntity({
|
||||
unitType: uType,
|
||||
unitId: uId,
|
||||
x: stopX,
|
||||
y: stopY,
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'UnitRemove': {
|
||||
this.interpolator.removeEntity(unitTypeToId(ev.unitType), ev.id)
|
||||
break
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export class ClientUnit {
|
|||
return { ...this._turnState }
|
||||
}
|
||||
lifePct: number
|
||||
hitClass?: number | undefined
|
||||
flags: number
|
||||
warpId?: number | undefined
|
||||
objectState?: number | undefined
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ export class ClientWorld {
|
|||
private _waypointUnitId: number | null = null
|
||||
private _quests = new Uint16Array(48)
|
||||
private readonly _party = new Map<number, PartyMemberState>()
|
||||
private readonly _corpses = new Map<number, number>()
|
||||
private readonly _trade: TradeSessionState = {
|
||||
active: false,
|
||||
lastAction: 0,
|
||||
|
|
@ -1176,6 +1177,10 @@ export class ClientWorld {
|
|||
return this._party
|
||||
}
|
||||
|
||||
get corpses(): ReadonlyMap<number, number> {
|
||||
return this._corpses
|
||||
}
|
||||
|
||||
get trade(): Readonly<TradeSessionState> {
|
||||
return this._trade
|
||||
}
|
||||
|
|
@ -1757,7 +1762,7 @@ export class ClientWorld {
|
|||
: 0
|
||||
ev = {
|
||||
type: 'UnitAssign',
|
||||
isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)),
|
||||
isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId !== 0 ? this.selfId === uId : ev.isSelf === true)),
|
||||
unit: {
|
||||
unitType: uType,
|
||||
id: uId,
|
||||
|
|
@ -1947,10 +1952,10 @@ export class ClientWorld {
|
|||
|
||||
const isSelfPlayer =
|
||||
unit.unitType === 'player' &&
|
||||
(ev.isSelf === true || this.selfId === 0 || this.selfId === unit.id)
|
||||
(this.selfId !== 0 ? unit.id === this.selfId : ev.isSelf === true)
|
||||
|
||||
if (isSelfPlayer) {
|
||||
if (ev.isSelf === true || this.selfId === 0) {
|
||||
if (this.selfId === 0 && ev.isSelf === true) {
|
||||
this.selfId = unit.id
|
||||
this._self.unitId = unit.id
|
||||
}
|
||||
|
|
@ -2043,7 +2048,7 @@ export class ClientWorld {
|
|||
unit.faceToward(targetX, targetY, false)
|
||||
}
|
||||
}
|
||||
if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) {
|
||||
if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 17) {
|
||||
const inTown = isTownLevel(this.areaId)
|
||||
if (moveMode === 'stop') {
|
||||
unit.mode = inTown ? 5 : 1
|
||||
|
|
@ -2112,37 +2117,55 @@ export class ClientWorld {
|
|||
case 'UnitMode': {
|
||||
const uId = Number(ev.id ?? ev.unitId ?? 0)
|
||||
const unit = this.ensureUnit(ev.unitType, uId)
|
||||
this.clearUnitCastState(unit)
|
||||
const now = this.resolveEventNowMs(nowMs)
|
||||
unit.mode = ev.mode
|
||||
unit.modeStartedAtMs = now
|
||||
unit.modeElapsedMs = 0
|
||||
if (unit.unitType === 'player' && ev.mode === 4) {
|
||||
const speedInfo = this.resolvePlayerAnimSpeed(4, unit.id)
|
||||
unit.modeDurationMs = speedInfo.durationMs
|
||||
unit.modeUntilMs = now + speedInfo.durationMs
|
||||
unit.animStepSpeed256 = speedInfo.stepSpeed256
|
||||
unit.animEffRate = speedInfo.effRate
|
||||
} else if (unit.unitType === 'monster' && ev.mode === 3) {
|
||||
unit.modeDurationMs = 240
|
||||
unit.modeUntilMs = now + 240
|
||||
unit.animStepSpeed256 = undefined
|
||||
unit.animEffRate = undefined
|
||||
} else {
|
||||
unit.modeDurationMs = undefined
|
||||
unit.modeUntilMs = undefined
|
||||
unit.animStepSpeed256 = undefined
|
||||
unit.animEffRate = undefined
|
||||
if (ev.mode !== undefined) {
|
||||
if (unit.unitType === 'player' && (ev.mode < 0 || ev.mode > 19)) {
|
||||
throw new RangeError(`Invalid PlrMode: ${ev.mode}, expected 0..19`)
|
||||
}
|
||||
if (unit.unitType === 'monster' && (ev.mode < 0 || ev.mode > 15)) {
|
||||
throw new RangeError(`Invalid MonMode: ${ev.mode}, expected 0..15`)
|
||||
}
|
||||
let resolvedMode = ev.mode
|
||||
if (unit.unitType === 'player' && resolvedMode === 1 && isTownLevel(this.areaId)) {
|
||||
resolvedMode = 5
|
||||
}
|
||||
this.clearUnitCastState(unit)
|
||||
const now = this.resolveEventNowMs(nowMs)
|
||||
unit.mode = resolvedMode
|
||||
if (resolvedMode !== 2 && resolvedMode !== 3 && resolvedMode !== 6 && resolvedMode !== 15) {
|
||||
unit.moveMode = 'stop'
|
||||
}
|
||||
unit.modeStartedAtMs = now
|
||||
unit.modeElapsedMs = 0
|
||||
if (unit.unitType === 'player' && resolvedMode === 4) {
|
||||
const speedInfo = this.resolvePlayerAnimSpeed(4, unit.id)
|
||||
unit.modeDurationMs = speedInfo.durationMs
|
||||
unit.modeUntilMs = now + speedInfo.durationMs
|
||||
unit.animStepSpeed256 = speedInfo.stepSpeed256
|
||||
unit.animEffRate = speedInfo.effRate
|
||||
} else if (unit.unitType === 'monster' && resolvedMode === 3) {
|
||||
unit.modeDurationMs = 240
|
||||
unit.modeUntilMs = now + 240
|
||||
unit.animStepSpeed256 = undefined
|
||||
unit.animEffRate = undefined
|
||||
} else {
|
||||
unit.modeDurationMs = undefined
|
||||
unit.modeUntilMs = undefined
|
||||
unit.animStepSpeed256 = undefined
|
||||
unit.animEffRate = undefined
|
||||
}
|
||||
}
|
||||
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
||||
unit.x = ev.at.x
|
||||
unit.y = ev.at.y
|
||||
}
|
||||
if (ev.hitClass !== undefined) {
|
||||
unit.hitClass = ev.hitClass
|
||||
}
|
||||
if (ev.lifePct !== undefined) {
|
||||
unit.lifePct = ev.lifePct
|
||||
}
|
||||
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
||||
if (ev.mode === 0 || ev.mode === 16 || ev.mode === 17 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
|
||||
if (ev.mode === 0 || ev.mode === 17 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
|
||||
this._isSelfDead = true
|
||||
this._self.isDead = true
|
||||
}
|
||||
|
|
@ -2503,10 +2526,15 @@ export class ClientWorld {
|
|||
}
|
||||
|
||||
case 'CorpseAssign': {
|
||||
const ownerId = ev.ownerId ?? ev.unitId ?? 0
|
||||
if ((ownerId === this.selfId || this.selfId === 0) && (ev.assign ?? 1) !== 0) {
|
||||
this._isSelfDead = true
|
||||
this._self.isDead = true
|
||||
const ownerId = Number(ev.ownerId ?? 0)
|
||||
const corpseId = Number(ev.corpseId ?? 0)
|
||||
const assign = Number(ev.assign ?? 1)
|
||||
if (corpseId !== 0) {
|
||||
if (assign !== 0) {
|
||||
this._corpses.set(corpseId, ownerId)
|
||||
} else {
|
||||
this._corpses.delete(corpseId)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -2515,19 +2543,6 @@ export class ClientWorld {
|
|||
const rawUnitId = ev.unitId ?? ev.playerId ?? ev.subjectId
|
||||
const rawOtherId = ev.otherId ?? ev.objectId
|
||||
const rawRel = ev.relation ?? ev.state ?? ev.relations
|
||||
// 0x74 / 0x8E CorpseAssign for local player
|
||||
if (
|
||||
ev.kind === 'relation' &&
|
||||
rawOtherId !== undefined &&
|
||||
rawRel === 1 &&
|
||||
(rawUnitId === this.selfId || (this.selfId === 0 && rawUnitId === rawOtherId))
|
||||
) {
|
||||
this._isSelfDead = true
|
||||
this._self.isDead = true
|
||||
if (!this._party.has(rawOtherId)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 0x7F PartyMemberState for pets / hirelings (isPlayer === 0)
|
||||
if (ev.kind === 'memberState' && (ev.isPlayer === false || ev.relation === 0)) {
|
||||
|
|
@ -2899,6 +2914,7 @@ export class ClientWorld {
|
|||
this._waypointUnitId = null
|
||||
this._quests = new Uint16Array(48)
|
||||
this._party.clear()
|
||||
this._corpses.clear()
|
||||
this._trade.active = false
|
||||
this._trade.lastAction = 0
|
||||
this._trade.partnerId = undefined
|
||||
|
|
|
|||
|
|
@ -38,23 +38,44 @@ function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 's
|
|||
return 'walk'
|
||||
}
|
||||
|
||||
/** 0x0D PlayerStop (13B, D2Client.dll 0x6fb5f590) */
|
||||
export function map0x0DCodeToPlrMode(rawCode: number): number {
|
||||
switch (rawCode) {
|
||||
case 0x08:
|
||||
return 0 // DT (Death)
|
||||
case 0x09:
|
||||
return 17 // DD (Dead)
|
||||
case 0x06:
|
||||
return 4 // GH (Get Hit)
|
||||
case 0x12:
|
||||
return 9 // BL (Block)
|
||||
case 0x14:
|
||||
return 19 // KB (Knockback)
|
||||
case 0x07:
|
||||
return 1 // NU (Neutral/Idle)
|
||||
default:
|
||||
return rawCode <= 19 ? rawCode : 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 0x0D PlayerStop (13B, D2Client.dll 0x6fb5f590, D2MOO PlrMsg.cpp:85-107 stru_6FD30708) */
|
||||
export function decode0x0DPlayerStop(pkt: Uint8Array): ServerEvent {
|
||||
const r = new ByteReader(pkt)
|
||||
r.u8() // 0x0D
|
||||
const unitType = clampUnitType(r.u8())
|
||||
const id = r.u32LE()
|
||||
r.u8() // unknown
|
||||
const rawCode = r.u8()
|
||||
const x = r.u16LE()
|
||||
const y = r.u16LE()
|
||||
r.u8() // unknown
|
||||
r.u8() // lifePct
|
||||
const lifePct = r.u8()
|
||||
const mode = map0x0DCodeToPlrMode(rawCode)
|
||||
return {
|
||||
type: 'UnitMove',
|
||||
type: 'UnitMode',
|
||||
unitType,
|
||||
id,
|
||||
mode: 'stop',
|
||||
to: { x, y },
|
||||
mode,
|
||||
at: { x, y },
|
||||
lifePct,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,14 +85,13 @@ export function decode0x74PlayerCorpseAssign(pkt: Uint8Array): ServerEvent {
|
|||
const r = new ByteReader(pkt)
|
||||
r.u8() // 0x74
|
||||
const assign = r.u8()
|
||||
const unitId = r.u32LE()
|
||||
const otherId = r.u32LE()
|
||||
const ownerId = r.u32LE()
|
||||
const corpseId = r.u32LE()
|
||||
return {
|
||||
type: 'Party',
|
||||
kind: 'relation',
|
||||
unitId,
|
||||
otherId,
|
||||
relation: assign,
|
||||
type: 'CorpseAssign',
|
||||
assign,
|
||||
ownerId,
|
||||
corpseId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,14 +182,13 @@ export function decode0x8ECorpseAssign(pkt: Uint8Array): ServerEvent {
|
|||
const r = new ByteReader(pkt)
|
||||
r.u8() // 0x8E
|
||||
const assign = r.u8()
|
||||
const unitId = r.u32LE()
|
||||
const otherId = r.u32LE()
|
||||
const ownerId = r.u32LE()
|
||||
const corpseId = r.u32LE()
|
||||
return {
|
||||
type: 'Party',
|
||||
kind: 'relation',
|
||||
unitId,
|
||||
otherId,
|
||||
relation: assign,
|
||||
type: 'CorpseAssign',
|
||||
assign,
|
||||
ownerId,
|
||||
corpseId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { ByteReader } from '../../core/byte-reader.ts'
|
||||
import type { Hand, SkillLevel, UnitType } from '../../domain/ids.ts'
|
||||
import type { ServerEvent } from '../../domain/server-event.ts'
|
||||
import { resolveMonsterCmdMode } from './units.ts'
|
||||
|
||||
function clampUnitType(raw: number): UnitType {
|
||||
return (raw >= 0 && raw <= 5 ? raw : 0) as UnitType
|
||||
|
|
@ -113,22 +114,24 @@ export function decode0x4DSkillOnLocation(pkt: Uint8Array): ServerEvent {
|
|||
}
|
||||
}
|
||||
|
||||
/** 0x6C NPCAttack (16B, D2Client.dll 0x6fb5f750) */
|
||||
/** 0x6C NPCAttack (16B, D2Client.dll 0x6fb5f750, D2MOO MonsterMsg.cpp:318-325) */
|
||||
export function decode0x6CNpcAttack(pkt: Uint8Array): ServerEvent {
|
||||
const r = new ByteReader(pkt)
|
||||
r.u8() // 0x6C
|
||||
const casterId = r.u32LE()
|
||||
const skill = r.u16LE()
|
||||
const targetId = r.u32LE()
|
||||
const targetType = clampUnitType(r.u8())
|
||||
const id = r.u32LE()
|
||||
const rawCmd = r.u8()
|
||||
clampUnitType(r.u8()) // targetType
|
||||
r.u32LE() // targetId
|
||||
r.u8() // direction
|
||||
const x = r.u16LE()
|
||||
const y = r.u16LE()
|
||||
const mode = resolveMonsterCmdMode(rawCmd)
|
||||
return {
|
||||
type: 'SkillCast',
|
||||
caster: { unitType: 1, id: casterId },
|
||||
skill,
|
||||
level: 1,
|
||||
target: targetId !== 0 ? { unitType: targetType, id: targetId } : { x, y },
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id,
|
||||
mode,
|
||||
at: { x, y },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,19 +50,21 @@ export function decode0x0ARemoveUnit(pkt: Uint8Array): ServerEvent {
|
|||
}
|
||||
}
|
||||
|
||||
/** 0x0C NPCGetHit (9B, D2Client.dll 0x6fb5f5f0) */
|
||||
/** 0x0C NPCGetHit (9B, D2Client.dll 0x6fb5f5f0, D2MOO SCmd.cpp:190-202 & MonsterMsg.cpp:94) */
|
||||
export function decode0x0CNpcGetHit(pkt: Uint8Array): ServerEvent {
|
||||
const r = new ByteReader(pkt)
|
||||
r.u8() // 0x0C
|
||||
const unitType = clampUnitType(r.u8())
|
||||
const id = r.u32LE()
|
||||
const mode = r.u16LE()
|
||||
const lifePct = r.u8()
|
||||
r.u8() // unk0x06 (19)
|
||||
const hitClass = r.u8()
|
||||
const rawLife = r.u8()
|
||||
const lifePct = rawLife & 0x7f
|
||||
return {
|
||||
type: 'UnitMode',
|
||||
unitType,
|
||||
id,
|
||||
mode,
|
||||
hitClass,
|
||||
lifePct,
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +165,7 @@ export const MONSTER_CMD_TO_MODE: readonly number[] = [
|
|||
11, // 0x1D: S2 (with target)
|
||||
]
|
||||
|
||||
function resolveMonsterCmdMode(rawCmd: number): number {
|
||||
export function resolveMonsterCmdMode(rawCmd: number): number {
|
||||
const mapped = MONSTER_CMD_TO_MODE[rawCmd]
|
||||
return mapped !== undefined ? mapped : rawCmd
|
||||
}
|
||||
|
|
@ -206,7 +208,6 @@ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent {
|
|||
y,
|
||||
mode: 1,
|
||||
},
|
||||
isSelf: x !== 0 || y !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,10 +77,17 @@ export type ServerEvent =
|
|||
readonly type: 'UnitMode'
|
||||
readonly unitType: UnitType
|
||||
readonly id: number
|
||||
readonly mode: number
|
||||
readonly mode?: number | undefined
|
||||
readonly at?: Pt | undefined
|
||||
readonly hitClass?: number | undefined
|
||||
readonly lifePct?: number | undefined
|
||||
}
|
||||
| {
|
||||
readonly type: 'CorpseAssign'
|
||||
readonly assign: number
|
||||
readonly ownerId: number
|
||||
readonly corpseId: number
|
||||
}
|
||||
| {
|
||||
readonly type: 'SkillCast'
|
||||
readonly caster: UnitRef
|
||||
|
|
|
|||
|
|
@ -0,0 +1,533 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||
import { decode0x0CNpcGetHit, decode0x59AssignPlayer } from '../../src/netproto/d2gs/s2c/units.ts'
|
||||
import { decode0x74PlayerCorpseAssign, decode0x8ECorpseAssign } from '../../src/netproto/d2gs/s2c/party.ts'
|
||||
import { decode0x0DPlayerStop } from '../../src/netproto/d2gs/s2c/movement.ts'
|
||||
import { decode0x6CNpcAttack } from '../../src/netproto/d2gs/s2c/skills.ts'
|
||||
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||||
|
||||
describe('Batch 1 Phase 1 Client Parity (Issues #563, #564, #567, #569, #570)', () => {
|
||||
describe('Issue #563: S2C 0x59 AssignPlayer packet & selfId stability', () => {
|
||||
it('decode0x59AssignPlayer does not infer isSelf from non-zero coordinates', () => {
|
||||
const w = new ByteWriter()
|
||||
w.u8(0x59)
|
||||
w.u32LE(2) // dwUnitId
|
||||
w.u8(0) // charClass = Amazon
|
||||
w.fixedString('RemotePlayer', 16)
|
||||
w.u16LE(5105) // x != 0
|
||||
w.u16LE(5060) // y != 0
|
||||
const pkt = w.toUint8Array()
|
||||
|
||||
const ev = decode0x59AssignPlayer(pkt)
|
||||
expect(ev.type).toBe('UnitAssign')
|
||||
if (ev.type === 'UnitAssign') {
|
||||
expect(ev.unit.id).toBe(2)
|
||||
expect(ev.unit.name).toBe('RemotePlayer')
|
||||
expect(ev.unit.x).toBe(5105)
|
||||
expect(ev.unit.y).toBe(5060)
|
||||
// Must NOT set isSelf to true just because coordinates are non-zero!
|
||||
expect(ev.isSelf).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('ClientWorld preserves local selfId and self state when secondary player 0x59 packet arrives', () => {
|
||||
const world = new ClientWorld()
|
||||
world.applyServerEvent({
|
||||
type: 'LoadAct',
|
||||
act: 0,
|
||||
mapSeed: 0x12345678,
|
||||
areaId: 1, // Town
|
||||
})
|
||||
|
||||
// Local player initial spawn
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: {
|
||||
unitType: 0,
|
||||
id: 100,
|
||||
classId: 1, // Sorceress
|
||||
name: 'LocalHero',
|
||||
x: 5100,
|
||||
y: 5050,
|
||||
},
|
||||
})
|
||||
|
||||
expect(world.selfId).toBe(100)
|
||||
expect(world.self.unitId).toBe(100)
|
||||
expect(world.self.name).toBe('LocalHero')
|
||||
expect(world.self.charClass).toBe(1)
|
||||
expect(world.self.x).toBe(5100)
|
||||
expect(world.self.y).toBe(5050)
|
||||
|
||||
// Secondary player arrives with non-zero coordinates
|
||||
const w59 = new ByteWriter()
|
||||
w59.u8(0x59)
|
||||
w59.u32LE(200)
|
||||
w59.u8(3) // Necromancer
|
||||
w59.fixedString('InvadingPlayer', 16)
|
||||
w59.u16LE(5180)
|
||||
w59.u16LE(5200)
|
||||
|
||||
const ev59 = decode0x59AssignPlayer(w59.toUint8Array())
|
||||
world.applyServerEvent(ev59)
|
||||
|
||||
// selfId and local player MUST NOT BE OVERWRITTEN
|
||||
expect(world.selfId).toBe(100)
|
||||
expect(world.self.unitId).toBe(100)
|
||||
expect(world.self.name).toBe('LocalHero')
|
||||
expect(world.self.charClass).toBe(1)
|
||||
expect(world.self.x).toBe(5100)
|
||||
expect(world.self.y).toBe(5050)
|
||||
|
||||
// Secondary player is correctly stored as remote unit in world.units
|
||||
const remoteUnit = world.getUnit(0, 200)
|
||||
expect(remoteUnit).toBeDefined()
|
||||
expect(remoteUnit?.id).toBe(200)
|
||||
expect(remoteUnit?.name).toBe('InvadingPlayer')
|
||||
expect(remoteUnit?.classId).toBe(3)
|
||||
expect(remoteUnit?.x).toBe(5180)
|
||||
expect(remoteUnit?.y).toBe(5200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #564: Corpse assignment packets 0x8E / 0x74', () => {
|
||||
it('decode0x74PlayerCorpseAssign and decode0x8ECorpseAssign return CorpseAssign event', () => {
|
||||
// 0x74 [opcode:1B][assign:1B][ownerId:4B][corpseId:4B]
|
||||
const w74 = new ByteWriter()
|
||||
w74.u8(0x74)
|
||||
w74.u8(1)
|
||||
w74.u32LE(100) // ownerId
|
||||
w74.u32LE(888) // corpseId
|
||||
const ev74 = decode0x74PlayerCorpseAssign(w74.toUint8Array())
|
||||
|
||||
expect(ev74).toEqual({
|
||||
type: 'CorpseAssign',
|
||||
assign: 1,
|
||||
ownerId: 100,
|
||||
corpseId: 888,
|
||||
})
|
||||
|
||||
// 0x8E [opcode:1B][assign:1B][ownerId:4B][corpseId:4B]
|
||||
const w8e = new ByteWriter()
|
||||
w8e.u8(0x8e)
|
||||
w8e.u8(1)
|
||||
w8e.u32LE(100)
|
||||
w8e.u32LE(999)
|
||||
const ev8e = decode0x8ECorpseAssign(w8e.toUint8Array())
|
||||
|
||||
expect(ev8e).toEqual({
|
||||
type: 'CorpseAssign',
|
||||
assign: 1,
|
||||
ownerId: 100,
|
||||
corpseId: 999,
|
||||
})
|
||||
})
|
||||
|
||||
it('ClientWorld stores corpse in world.corpses and NEVER marks living player dead or leaks into party', () => {
|
||||
const world = new ClientWorld()
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: { unitType: 0, id: 100, classId: 0, name: 'AlivePlayer', x: 5000, y: 5000 },
|
||||
})
|
||||
|
||||
expect(world.isSelfDead).toBe(false)
|
||||
expect(world.self.isDead).toBe(false)
|
||||
|
||||
// Receive 0x8E CorpseAssign for local player
|
||||
world.applyServerEvent({
|
||||
type: 'CorpseAssign',
|
||||
assign: 1,
|
||||
ownerId: 100,
|
||||
corpseId: 999,
|
||||
})
|
||||
|
||||
// Corpse tracked in world.corpses map
|
||||
expect(world.corpses.get(999)).toBe(100)
|
||||
|
||||
// Living player MUST REMAIN ALIVE
|
||||
expect(world.isSelfDead).toBe(false)
|
||||
expect(world.self.isDead).toBe(false)
|
||||
|
||||
// Must NOT pollute party map
|
||||
expect(world.party.has(999)).toBe(false)
|
||||
expect(world.party.size).toBe(0)
|
||||
|
||||
// Unassign corpse (assign = 0)
|
||||
world.applyServerEvent({
|
||||
type: 'CorpseAssign',
|
||||
assign: 0,
|
||||
ownerId: 100,
|
||||
corpseId: 999,
|
||||
})
|
||||
|
||||
expect(world.corpses.has(999)).toBe(false)
|
||||
expect(world.isSelfDead).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #567: S2C 0x0D movement packet decoding & mode lifecycle', () => {
|
||||
it('decode0x0DPlayerStop maps action codes to PlrMode (DT, DD, GH, BL, KB, NU) and preserves lifePct', () => {
|
||||
// 0x08 -> DT (0)
|
||||
const wDt = new ByteWriter()
|
||||
wDt.u8(0x0d).u8(0).u32LE(1).u8(0x08).u16LE(5100).u16LE(5200).u8(0).u8(0)
|
||||
const evDt = decode0x0DPlayerStop(wDt.toUint8Array())
|
||||
expect(evDt).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 0, // DT
|
||||
at: { x: 5100, y: 5200 },
|
||||
lifePct: 0,
|
||||
})
|
||||
|
||||
// 0x09 -> DD (17)
|
||||
const wDd = new ByteWriter()
|
||||
wDd.u8(0x0d).u8(0).u32LE(1).u8(0x09).u16LE(5100).u16LE(5200).u8(0).u8(0)
|
||||
const evDd = decode0x0DPlayerStop(wDd.toUint8Array())
|
||||
expect(evDd).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 17, // DD
|
||||
at: { x: 5100, y: 5200 },
|
||||
lifePct: 0,
|
||||
})
|
||||
|
||||
// 0x06 -> GH (4)
|
||||
const wGh = new ByteWriter()
|
||||
wGh.u8(0x0d).u8(0).u32LE(1).u8(0x06).u16LE(5100).u16LE(5200).u8(0).u8(85)
|
||||
const evGh = decode0x0DPlayerStop(wGh.toUint8Array())
|
||||
expect(evGh).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 4, // GH
|
||||
at: { x: 5100, y: 5200 },
|
||||
lifePct: 85,
|
||||
})
|
||||
|
||||
// 0x12 -> BL (9)
|
||||
const wBl = new ByteWriter()
|
||||
wBl.u8(0x0d).u8(0).u32LE(1).u8(0x12).u16LE(5100).u16LE(5200).u8(0).u8(95)
|
||||
const evBl = decode0x0DPlayerStop(wBl.toUint8Array())
|
||||
expect(evBl).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 9, // BL
|
||||
at: { x: 5100, y: 5200 },
|
||||
lifePct: 95,
|
||||
})
|
||||
|
||||
// 0x14 -> KB (19)
|
||||
const wKb = new ByteWriter()
|
||||
wKb.u8(0x0d).u8(0).u32LE(1).u8(0x14).u16LE(5100).u16LE(5200).u8(0).u8(70)
|
||||
const evKb = decode0x0DPlayerStop(wKb.toUint8Array())
|
||||
expect(evKb).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 19, // KB
|
||||
at: { x: 5100, y: 5200 },
|
||||
lifePct: 70,
|
||||
})
|
||||
|
||||
// 0x07 -> NU (1)
|
||||
const wNu = new ByteWriter()
|
||||
wNu.u8(0x0d).u8(0).u32LE(1).u8(0x07).u16LE(5100).u16LE(5200).u8(0).u8(100)
|
||||
const evNu = decode0x0DPlayerStop(wNu.toUint8Array())
|
||||
expect(evNu).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 1, // NU
|
||||
at: { x: 5100, y: 5200 },
|
||||
lifePct: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('ClientWorld handles 0x0D: resolves town idle to TN=5 and triggers death on DT=0 or DD=17', () => {
|
||||
const world = new ClientWorld()
|
||||
world.applyServerEvent({
|
||||
type: 'LoadAct',
|
||||
act: 0,
|
||||
mapSeed: 0x1234,
|
||||
areaId: 1, // Town
|
||||
})
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: { unitType: 0, id: 1, classId: 0, name: 'Hero', x: 5000, y: 5000 },
|
||||
})
|
||||
|
||||
// 0x0D action code 0x07 (NU=1) in town resolves to TN=5
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 1,
|
||||
at: { x: 5010, y: 5010 },
|
||||
lifePct: 100,
|
||||
})
|
||||
expect(world.getUnit(0, 1)?.mode).toBe(5)
|
||||
expect(world.isSelfDead).toBe(false)
|
||||
|
||||
// 0x0D DT=0 triggers death
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 0,
|
||||
at: { x: 5010, y: 5010 },
|
||||
lifePct: 0,
|
||||
})
|
||||
expect(world.isSelfDead).toBe(true)
|
||||
expect(world.self.isDead).toBe(true)
|
||||
expect(world.getUnit(0, 1)?.mode).toBe(0)
|
||||
})
|
||||
|
||||
it('PlrMode 16 (S4 Left Hand Throw) is not dead and does not block movement', () => {
|
||||
const world = new ClientWorld()
|
||||
world.applyServerEvent({
|
||||
type: 'LoadAct',
|
||||
act: 0,
|
||||
mapSeed: 0x1234,
|
||||
areaId: 2, // Wilderness
|
||||
})
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: { unitType: 0, id: 1, classId: 0, name: 'Thrower', x: 5000, y: 5000 },
|
||||
})
|
||||
|
||||
// Set mode 16 (S4)
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 16,
|
||||
lifePct: 100,
|
||||
})
|
||||
|
||||
expect(world.getUnit(0, 1)?.mode).toBe(16)
|
||||
expect(world.isSelfDead).toBe(false)
|
||||
expect(world.self.isDead).toBe(false)
|
||||
|
||||
// UnitMove from mode 16 transitions to walk (2 in wilderness) without being blocked by death check
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMove',
|
||||
unitType: 'player',
|
||||
id: 1,
|
||||
mode: 'walk',
|
||||
to: { x: 5020, y: 5020 },
|
||||
})
|
||||
expect(world.getUnit(0, 1)?.mode).toBe(2) // WL=2
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #569: S2C 0x6C monster attack command decoding', () => {
|
||||
it('decode0x6CNpcAttack extracts byte 5 command code and maps to MonMode via MONSTER_CMD_TO_MODE', () => {
|
||||
// 0x6C: [opcode:1B][dwUnitId:4B][rawCmd:1B][targetType:1B][targetId:4B][dir:1B][x:2B][y:2B]
|
||||
// rawCmd = 0x0A -> A1 (mode 4)
|
||||
const w0a = new ByteWriter()
|
||||
w0a.u8(0x6c)
|
||||
w0a.u32LE(50) // monster id
|
||||
w0a.u8(0x0a) // rawCmd = 0x0A -> A1 (mode 4)
|
||||
w0a.u8(0) // targetType = player
|
||||
w0a.u32LE(1) // targetId
|
||||
w0a.u8(12) // direction
|
||||
w0a.u16LE(5080)
|
||||
w0a.u16LE(5090)
|
||||
|
||||
const ev0a = decode0x6CNpcAttack(w0a.toUint8Array())
|
||||
expect(ev0a.type).toBe('UnitMode')
|
||||
if (ev0a.type === 'UnitMode') {
|
||||
expect(ev0a.unitType).toBe(1)
|
||||
expect(ev0a.id).toBe(50)
|
||||
expect(ev0a.mode).toBe(4) // MonMode A1
|
||||
expect(ev0a.at).toEqual({ x: 5080, y: 5090 })
|
||||
}
|
||||
|
||||
// rawCmd = 0x10 -> A2 (mode 5)
|
||||
const w10 = new ByteWriter()
|
||||
w10.u8(0x6c)
|
||||
w10.u32LE(50)
|
||||
w10.u8(0x10) // rawCmd = 0x10 -> A2 (mode 5)
|
||||
w10.u8(0)
|
||||
w10.u32LE(1)
|
||||
w10.u8(12)
|
||||
w10.u16LE(5080)
|
||||
w10.u16LE(5090)
|
||||
|
||||
const ev10 = decode0x6CNpcAttack(w10.toUint8Array())
|
||||
// MUST NOT be decoded as player SkillCast (Exploding Arrow)!
|
||||
expect(ev10.type).toBe('UnitMode')
|
||||
if (ev10.type === 'UnitMode') {
|
||||
expect(ev10.unitType).toBe(1)
|
||||
expect(ev10.id).toBe(50)
|
||||
expect(ev10.mode).toBe(5) // MonMode A2 (NOT Amazon skill 16!)
|
||||
expect(ev10.at).toEqual({ x: 5080, y: 5090 })
|
||||
}
|
||||
})
|
||||
|
||||
it('ClientWorld processes 0x6C UnitMode without corrupting player skills', () => {
|
||||
const world = new ClientWorld()
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: true,
|
||||
unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 5000, y: 5000 },
|
||||
})
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: false,
|
||||
unit: { unitType: 1, id: 50, classId: 10, name: 'Fallen', x: 5050, y: 5050, mode: 1 },
|
||||
})
|
||||
|
||||
// Send 0x6C attack event for monster
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id: 50,
|
||||
mode: 4, // A1
|
||||
at: { x: 5052, y: 5052 },
|
||||
})
|
||||
|
||||
const monster = world.getUnit(1, 50)
|
||||
expect(monster?.mode).toBe(4)
|
||||
expect(monster?.x).toBe(5052)
|
||||
expect(monster?.y).toBe(5052)
|
||||
|
||||
// Player state and skill remain completely unaffected
|
||||
expect(world.selfId).toBe(1)
|
||||
expect(world.isSelfDead).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #570: S2C 0x0C packet decoding & mode range checks', () => {
|
||||
it('decode0x0CNpcGetHit parses hitClass and lifePct without fabricating u16 mode 531', () => {
|
||||
// 0x0C: [opcode:1B][unitType:1B][guid:4B][unk0x06:1B][hitClass:1B][rawLife:1B]
|
||||
const w = new ByteWriter()
|
||||
w.u8(0x0c)
|
||||
w.u8(1) // monster
|
||||
w.u32LE(77) // id
|
||||
w.u8(19) // unk0x06
|
||||
w.u8(2) // hitClass = 2
|
||||
w.u8(0x45) // rawLife = 69 (lifePct = 69 & 0x7f = 69)
|
||||
|
||||
const ev = decode0x0CNpcGetHit(w.toUint8Array())
|
||||
expect(ev).toEqual({
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id: 77,
|
||||
hitClass: 2,
|
||||
lifePct: 69,
|
||||
})
|
||||
// mode MUST be undefined (not 531!)
|
||||
if (ev.type === 'UnitMode') {
|
||||
expect(ev.mode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('ClientWorld updates hitClass and lifePct on 0x0C without resetting active attack mode or castState', () => {
|
||||
const world = new ClientWorld()
|
||||
world.applyServerEvent({
|
||||
type: 'UnitAssign',
|
||||
isSelf: false,
|
||||
unit: { unitType: 1, id: 77, classId: 10, name: 'Fallen', x: 5000, y: 5000, mode: 4 },
|
||||
})
|
||||
|
||||
const monster = world.getUnit(1, 77)!
|
||||
// Simulate an in-progress attack castState
|
||||
monster.castState = {
|
||||
skillId: 0,
|
||||
startedAtMs: 1000,
|
||||
untilMs: 2000,
|
||||
targetX: 5010,
|
||||
targetY: 5010,
|
||||
mode: 4,
|
||||
}
|
||||
|
||||
// Apply 0x0C hitClass and lifePct update
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id: 77,
|
||||
hitClass: 2,
|
||||
lifePct: 65,
|
||||
})
|
||||
|
||||
expect(monster.hitClass).toBe(2)
|
||||
expect(monster.lifePct).toBe(65)
|
||||
// Active attack mode and castState MUST NOT be cancelled
|
||||
expect(monster.mode).toBe(4)
|
||||
expect(monster.castState).toBeDefined()
|
||||
expect(monster.castState?.untilMs).toBe(2000)
|
||||
})
|
||||
|
||||
it('ClientWorld enforces fail-fast range checks: MonMode 0..15, PlrMode 0..19', () => {
|
||||
const world = new ClientWorld()
|
||||
|
||||
// PlrMode 20 is out of bounds (0..19)
|
||||
expect(() => {
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 20,
|
||||
})
|
||||
}).toThrow(RangeError)
|
||||
|
||||
// PlrMode -1 is out of bounds
|
||||
expect(() => {
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: -1,
|
||||
})
|
||||
}).toThrow(RangeError)
|
||||
|
||||
// MonMode 16 is out of bounds (0..15)
|
||||
expect(() => {
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id: 50,
|
||||
mode: 16,
|
||||
})
|
||||
}).toThrow(RangeError)
|
||||
|
||||
// MonMode -1 is out of bounds
|
||||
expect(() => {
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id: 50,
|
||||
mode: -1,
|
||||
})
|
||||
}).toThrow(RangeError)
|
||||
|
||||
// Valid PlrMode 19 passes
|
||||
expect(() => {
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 0,
|
||||
id: 1,
|
||||
mode: 19,
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Valid MonMode 15 passes
|
||||
expect(() => {
|
||||
world.applyServerEvent({
|
||||
type: 'UnitMode',
|
||||
unitType: 1,
|
||||
id: 50,
|
||||
mode: 15,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -725,9 +725,10 @@ describe('Tier 1 — F7: D2GS S→C Decoders (Handshake, Map, Entity, Movement,
|
|||
w0d.u8(0x00)
|
||||
w0d.u8(100)
|
||||
const stopEv = decode0x0DPlayerStop(w0d.toUint8Array())
|
||||
expect(stopEv.type).toBe('UnitMove')
|
||||
if (stopEv.type === 'UnitMove') {
|
||||
expect(stopEv.to).toEqual({ x: 5100, y: 5200 })
|
||||
expect(stopEv.type).toBe('UnitMode')
|
||||
if (stopEv.type === 'UnitMode') {
|
||||
expect(stopEv.at).toEqual({ x: 5100, y: 5200 })
|
||||
expect(stopEv.lifePct).toBe(100)
|
||||
}
|
||||
|
||||
const w15 = new ByteWriter()
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ async function runAuditLevelsSubcommand(options: {
|
|||
} else if (ev.type === 'UnitMode') {
|
||||
const wpMap = waypointObjectsByAct.get(currentActIndex)
|
||||
const existingWp = wpMap?.get(ev.id)
|
||||
if (existingWp) {
|
||||
if (existingWp && ev.mode !== undefined) {
|
||||
existingWp.mode = ev.mode
|
||||
}
|
||||
} else if (
|
||||
|
|
|
|||
Loading…
Reference in New Issue