534 lines
16 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|