diablo2-web/tests/client/client-world-identity.test.ts

534 lines
16 KiB
TypeScript

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()
})
})
})