diablo2-web/tests/e2e-d2gs-online/tier5-adversarial-hardening...

775 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 5 — White-Box Adversarial & Post-Implementation Hardening Suite (20 Tests)
*
* Stress-tests the real implementation across R1–R5 for:
* - Out-of-order / burst S2C packet & WorldEvent streams (socketed child/parent ordering,
* rapid 0x21/0x22 skill bonus updates, rapid 0x0C LifeManaChange bursts, belt slot collisions)
* - Re-entrant panel state transitions (P2P trade cancel mid-cursor-drag, NPC menu -> Identify -> Talk,
* quest reward panel interrupts)
* - Stale unit ID handling (removed warp/NPC/ground item via 0x0A UnitRemove)
* - Rapid belt shift-clicks + mercenary life/death transitions & paperdoll ('O') locking
* - Full-roundtrip binary C2S packet byte & C2S_PACKET_SIZES verification across all newly wired commands
*/
import { describe, expect, it } from 'vitest'
import {
C2S_PACKET_SIZES,
decodeD2gsS2cPacket,
validateC2sPacket,
} from '../../src/netproto/index.ts'
import {
assertNoUndefinedText,
bridgeOnlineItemToUi,
createOnlineHudSession,
encodeClientCommandBytes,
extractStatStrings,
getLeftPanelOrigin,
makeItemData,
readU16LE,
readU32LE,
} from './helpers.ts'
const EMPTY_TABLES = {
itemStatCost: new Map(),
armor: new Map(),
weapons: new Map(),
misc: new Map(),
getStatMeta: () => undefined,
getStatCost: () => undefined,
getItemBaseMeta: () => undefined,
getItemMeta: () => undefined,
} as const
describe('Tier 5 — White-Box Adversarial & Post-Implementation Hardening (20 Tests)', () => {
// =========================================================================
// Section 1: R1 — Out-of-Order & Burst Container / Tooltip / Belt Streams
// =========================================================================
describe('R1 Adversarial — Out-of-Order Socket Streams, Belt Collisions & Stat Bursts', () => {
it('T5.1: socketed child (location=6) arriving after parent (0x9C) dynamically updates parent usedSockets and tooltip stats on re-sync', () => {
const { world, hud, hudModel } = createOnlineHudSession()
// 1. Parent 2-socket armor arrives first with 0 children
const armor = makeItemData({
id: 9001,
code: 'qui',
quality: 2,
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 2,
invHeight: 3,
socketed: true,
totalSockets: 2,
usedSockets: 0,
stats: [{ statId: 31, param: 0, value: 11 }],
})
world.apply({ type: 'ItemEvent', action: 0x00, item: armor })
hudModel.syncFromWorld()
let uiArmor = hud.inventory.inventory.find((i) => i.id === 9001)!
expect(uiArmor).toBeDefined()
expect(uiArmor.sockets).toBe(2)
expect(uiArmor.usedSockets).toBe(0)
// 2. Two socketed children (location=6) arrive subsequently via 0x9D OwnedItemAction (action=0x00 PutInContainer)
const chippedRuby = makeItemData({
id: 9002,
code: 'gcr',
quality: 2,
location: 6,
container: 'socket',
ownerType: 4,
ownerId: 9001,
gridX: 0,
gridY: 0,
stats: [{ statId: 39, param: 0, value: 12 }], // Fire Resist +12%
})
const tirRune = makeItemData({
id: 9003,
code: 'r03',
quality: 2,
location: 6,
container: 'socket',
ownerType: 4,
ownerId: 9001,
gridX: 1,
gridY: 0,
stats: [{ statId: 138, param: 0, value: 2 }], // +2 to Mana after each Kill
})
world.apply({ type: 'ItemEvent', action: 0x00, item: chippedRuby })
world.apply({ type: 'ItemEvent', action: 0x00, item: tirRune })
hudModel.syncFromWorld()
uiArmor = hud.inventory.inventory.find((i) => i.id === 9001)!
expect(uiArmor.usedSockets).toBe(2)
expect(uiArmor.socketedGems?.length).toBe(2)
const statLines = extractStatStrings(uiArmor.statsEn ?? uiArmor.stats).join(' | ')
expect(statLines).toMatch(/10 to Life/i)
expect(statLines).toMatch(/2 to Mana/i)
})
it('T5.2: replacing a belt slot via action=0x0E evicts the old potion from world.containers.allById and belt map without orphan leaks', () => {
const { world, hud, hudModel, sentCommands, clearRecorded } = createOnlineHudSession()
const oldPotion = makeItemData({
id: 9010,
code: 'hp1',
location: 2,
container: 'belt',
gridX: 2,
gridY: 0,
})
const newPotion = makeItemData({
id: 9011,
code: 'rvl',
location: 2,
container: 'belt',
gridX: 2,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 0x0e, item: oldPotion })
hudModel.syncFromWorld()
expect(world.containers.beltBySlot.get(2)?.id).toBe(9010)
expect(world.containers.allById.has(9010)).toBe(true)
// Overwrite belt slot 2 with newPotion
world.apply({ type: 'ItemEvent', action: 0x0e, item: newPotion })
hudModel.syncFromWorld()
expect(world.containers.beltBySlot.get(2)?.id).toBe(9011)
expect(world.containers.belt.has(9010)).toBe(false)
expect(world.containers.allById.has(9010)).toBe(false)
expect(hud.inventory.belt[2]?.id).toBe(9011)
clearRecorded()
hudModel.useBeltSlot(2, false)
expect(sentCommands).toContainEqual({ type: 'BeltUse', itemId: 9011, shift: false })
})
it('T5.3: action=0x02 (ToCursor) with container=inventory does not hijack the cursor slot unless location===4 or container===cursor', () => {
const { world, hud, hudModel } = createOnlineHudSession()
const bagItem = makeItemData({
id: 9020,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 4,
gridY: 2,
})
// Adversarial packet where action is 0x02 but item is still in inventory (location=0)
world.apply({ type: 'ItemEvent', action: 0x02, item: bagItem })
hudModel.syncFromWorld()
expect(world.containers.cursor).toBeNull()
expect(hud.inventory.cursorItem).toBeNull()
expect(hud.inventory.inventory.some((i) => i.id === 9020)).toBe(true)
})
it('T5.4: rapid burst of SkillList (0x94/0x21/0x22) and PlayerLifeManaChange (0x0C) events converges to exact final state without duplicate hotkeys', () => {
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1, charClass: 1 })
// Rapid burst of skill updates on Fire Bolt (36) and Teleport (54)
for (let lvl = 1; lvl <= 10; lvl++) {
world.apply({
type: 'SkillList',
unitId: 1,
skills: [
{ skillId: 36, skill: 36, level: lvl, baseLevel: lvl, bonus: lvl % 3 },
{ skillId: 54, skill: 54, level: 1, baseLevel: 1, bonus: lvl },
],
})
}
// Rapid burst of PlayerLifeManaChange events
for (let i = 0; i < 15; i++) {
world.apply({
type: 'PlayerLifeManaChange',
life: 100 + i * 5,
mana: 80 + i * 3,
stamina: 50 + i,
x: 5100 + i,
y: 5050 + i,
vx: 0,
vy: 0,
})
}
hudModel.syncFromWorld()
expect(hud.skillTree.hardPoints.get(36)).toBe(10)
expect(hud.skillTree.bonusLevels.get(36)).toBe(1)
expect(hud.skillTree.hardPoints.get(54)).toBe(1)
expect(hud.skillTree.bonusLevels.get(54)).toBe(10)
// Hotkeys list must not duplicate skills across burst syncs
const skills36 = hud.hotkeys.availableSkills.filter((s) => s.skillId === 36)
expect(skills36.length).toBe(1)
expect(skills36[0]!.level).toBe(11)
})
it('T5.5: bridgeOnlineItemToUi handles armor with explicitDefense and statId=31 without double-adding base ac or crashing on unknown affixes', () => {
const item = makeItemData({
id: 9030,
code: 'qui',
quality: 4, // Magic
identified: true,
defense: 15,
magicPrefixId: 99999, // Out-of-range prefix ID
magicSuffixId: 88888, // Out-of-range suffix ID
stats: [
{ statId: 31, param: 0, value: 15 }, // ArmorClass
{ statId: 194, param: 0, value: 2 }, // NumSockets
],
})
const ui = bridgeOnlineItemToUi(item)
expect(ui.defense).toBe(15)
expect(ui.sockets).toBe(2)
expect(ui.nameEn.length).toBeGreaterThan(0)
})
})
// =========================================================================
// Section 2: R1.3 & R2 — Weapon Swap & Multi-Act NPC Adversarial Transitions
// =========================================================================
describe('R1.3 & R2 Adversarial — Weapon Swap Cycles, Unsolicited S2C 0x27 & NPC Menu Re-entry', () => {
it('T5.6: rapid WeaponSwap (0x60) command emissions do not mutate hud.inventory.weaponSwapSet until S2C 0x97 arrives', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
expect(hud.inventory.weaponSwapSet).toBe(0)
hudModel.swapWeapons()
hudModel.swapWeapons()
hudModel.swapWeapons()
expect(sentCommands.filter((c) => c.type === 'SwapWeapons').length).toBe(3)
expect(sentPackets.filter((p) => p[0] === 0x60 && p.byteLength === 1).length).toBe(3)
// Must remain 0 until server confirms via 0x97 (ItemEvent action 0x17)
expect(hud.inventory.weaponSwapSet).toBe(0)
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.weaponSwapSet).toBe(1)
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.weaponSwapSet).toBe(0)
})
it('T5.7: unsolicited S2C 0x27 NPC Info packet when no NpcInit was noted stores npcInfoRaw without hijacking npcInteraction.npcId', () => {
const { world, worldPanels, hudModel } = createOnlineHudSession()
const pkt27 = new Uint8Array(40)
pkt27[0] = 0x27
pkt27[1] = 0x01 // unitType = monster
pkt27[2] = 0x8c
pkt27[3] = 0x23 // unitId = 9100
const ev = decodeD2gsS2cPacket(pkt27, { tables: EMPTY_TABLES, encoding: 'gbk', rttMs: 0 })
world.apply(ev)
hudModel.syncFromWorld()
expect(world.npcInteraction.npcId).toBeNull()
expect(worldPanels.npcMenu).toBeNull()
expect(world.npcInteraction.npcInfoRaw).toBeDefined()
})
it('T5.8: clicking Cain identify closes menu, and subsequent NpcInit + syncFromWorld cleanly re-opens Cain menu for Talk/Trade', () => {
const { world, worldPanels, hudModel, sentCommands, clearRecorded } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 9101, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9101 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
worldPanels.clickNpcMenuOption('identify')
expect(sentCommands).toContainEqual({ type: 'NpcIdentifyItems', npcId: 9101 })
expect(worldPanels.npcMenu).toBeNull()
// Re-initiate interaction with same NPC 9101
clearRecorded()
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9101 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
expect(worldPanels.npcMenu?.npcId).toBe(9101)
worldPanels.clickNpcMenuOption('talk')
expect(sentCommands).toContainEqual({ type: 'QuestMessage', npcId: 9101, messageId: 0 })
})
it('T5.9: Escape key / closeActiveNpcPanel while NPC menu is open emits 0x30 NpcCancel and clears world.npcInteraction.npcId', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 9102, classId: 148, name: 'Akara', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9102 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
hudModel.closeActiveNpcPanel()
hudModel.syncFromWorld()
expect(world.npcInteraction.npcId).toBeNull()
expect(worldPanels.npcMenu).toBeNull()
expect(sentCommands).toContainEqual({ type: 'NpcCancel', npcId: 9102 })
expect(sentPackets.some((p) => p[0] === 0x30 && p.byteLength === 9 && readU32LE(p, 5) === 9102)).toBe(true)
})
})
// =========================================================================
// Section 3: R3 — Mercenary Life/Death Transitions & Paperdoll ('O') Locking
// =========================================================================
describe('R3 Adversarial — Mercenary Death Mid-Session, Equipment Sync & Revive Guards', () => {
it('T5.10: mercenary dying (UnitMode mode=12, lifePct=0) locks O hotkey from opening merc paperdoll and enables captain resurrect option', () => {
const { world, hud, worldPanels, hudModel, dom } = createOnlineHudSession({ selfId: 1 })
// Assign active mercenary (lifePct=100)
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 9201,
classId: 271,
nameId: 10,
})
hudModel.syncFromWorld()
expect(hud.merc?.isDead).toBe(false)
// Mercenary dies (mode=12, lifePct=0) and server reports reviveCost via 0x9B
world.apply({
type: 'UnitMode',
unitType: 1,
id: 9201,
mode: 12,
lifePct: 0,
})
world.apply({
type: 'Merc',
kind: 'reviveCost',
deadNameId: 10,
reviveCost: 12500,
})
hudModel.syncFromWorld()
expect(hud.merc?.isDead).toBe(true)
expect(hud.merc?.reviveCost).toBe(12500)
expect((worldPanels as any).hasDeadMercenary).toBe(true)
// Pressing 'O' when merc is dead must NOT open the merc paperdoll
hud.leftPanel = 'none'
dom.dispatchKeyDown('o')
expect(hud.leftPanel).toBe('none')
})
it('T5.11: equipping and swapping mercenary items across slots 1, 3, 4 keeps both merc.equipped (numeric id) and merc.equipment (string id) synchronized and renders without undefined', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 9202,
classId: 338,
nameId: 12,
})
const helm = makeItemData({
id: 9211,
opcode: 0x9d,
action: 0x06,
code: 'cap',
location: 1,
container: 'merc',
bodyLoc: 1,
equippedSlot: 1,
})
const armor = makeItemData({
id: 9213,
opcode: 0x9d,
action: 0x06,
code: 'qui',
location: 1,
container: 'merc',
bodyLoc: 3,
equippedSlot: 3,
})
const weapon = makeItemData({
id: 9214,
opcode: 0x9d,
action: 0x06,
code: 'spc',
location: 1,
container: 'merc',
bodyLoc: 4,
equippedSlot: 4,
})
world.apply({ type: 'ItemEvent', action: 0x06, item: helm })
world.apply({ type: 'ItemEvent', action: 0x06, item: armor })
world.apply({ type: 'ItemEvent', action: 0x06, item: weapon })
hudModel.syncFromWorld()
expect(hud.merc?.equipped.head?.id).toBe(9211)
expect(hud.merc?.equipped.torso?.id).toBe(9213)
expect(hud.merc?.equipped.rightHand?.id).toBe(9214)
expect(hud.merc?.equipment.head?.id).toBe('9211')
expect(hud.merc?.equipment.torso?.id).toBe('9213')
expect(hud.merc?.equipment.rightHand?.id).toBe('9214')
dom.dispatchKeyDown('o')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
const texts = ctx.drawnTexts.map((t) => t.text).join(' ')
expect(texts.includes('cap') || texts.includes('Cap') || texts.includes('帽子')).toBe(true)
expect(
texts.includes('qui') || texts.includes('Quilted Armor') || texts.includes('布甲'),
).toBe(true)
expect(texts.includes('spc') || texts.includes('Scepter') || texts.includes('權杖')).toBe(
true,
)
// Click merc slot 4 (Right Hand / Weapon: ox + 36..92, oy + 98..210) -> C2S 0x61 MercItem (3B)
clearRecorded()
const { ox, oy } = getLeftPanelOrigin()
hud.handleClick(ox + 64, oy + 154)
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
expect(
sentPackets.some((p) => p[0] === 0x61 && p.byteLength === 3 && readU16LE(p, 1) === 4),
).toBe(true)
})
})
// =========================================================================
// Section 4: R4 — Stale Unit IDs, Warp Removal, SkillCast & Death Guards
// =========================================================================
describe('R4 Adversarial — Stale Unit Removal (0x0A), SkillCast Decoding & Death Lockout', () => {
it('T5.12: removing a warp or ground item via S2C 0x0A UnitRemove purges it from world units and containers immediately', () => {
const { world, hudModel } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 9301,
classId: 2,
warpId: 2,
name: 'To Cave',
x: 5110,
y: 5060,
} as any,
})
const groundSword = makeItemData({
id: 9302,
action: 0x03,
code: 'ssd',
location: 3,
container: 'ground',
worldX: 5112,
worldY: 5062,
})
world.apply({ type: 'ItemEvent', action: 0x03, item: groundSword })
hudModel.syncFromWorld()
expect(world.getUnit('tile', 9301)).toBeDefined()
expect(world.getUnit('item', 9302)).toBeDefined()
// Server despawns both via UnitRemove (0x0A)
world.apply({ type: 'UnitRemove', unitType: 5, id: 9301 })
world.apply({ type: 'UnitRemove', unitType: 4, id: 9302 })
hudModel.syncFromWorld()
expect(world.getUnit('tile', 9301)).toBeUndefined()
expect(world.getUnit('item', 9302)).toBeUndefined()
})
it('T5.13: decoding S2C 0x4C (unit-target SkillCast) and 0x4D (ground-target SkillCast) updates caster castState and facing direction', () => {
const { world, hudModel } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 9350, classId: 1, name: 'Fallen', x: 5120, y: 5050 },
})
// S2C 0x4C (16B): [0x4c, unitType=0, unitId(4)=1, skillId(2)=47, lvl=1, unk=1, targetId(4)=9350, 0, 0]
const pkt4c = new Uint8Array(16)
const v4c = new DataView(pkt4c.buffer)
pkt4c[0] = 0x4c
pkt4c[1] = 0
v4c.setUint32(2, 1, true)
v4c.setUint16(6, 47, true)
pkt4c[8] = 1
pkt4c[9] = 1
v4c.setUint32(10, 9350, true)
const ev4c = decodeD2gsS2cPacket(pkt4c, { tables: EMPTY_TABLES, encoding: 'gbk', rttMs: 0 })
world.apply(ev4c)
hudModel.syncFromWorld()
const selfUnit = world.getUnit('player', 1) as Record<string, any> | undefined
const castState = selfUnit?.castState ?? (world.self as Record<string, any>).castState
expect(castState).toBeDefined()
expect(castState.skillId).toBe(47)
})
it('T5.14: player death (life=0) sets world.isDead=true, and S2C PlayerLifeManaChange at town spawn clears isDead after 0x41 Resurrect', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
life: 100,
maxLife: 100,
})
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 40,
stamina: 50,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(world.isDead).toBe(true)
expect((hud as any).isDead).toBe(true)
hudModel.resurrect()
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
// Server restores life in town
world.apply({
type: 'PlayerLifeManaChange',
life: 100,
mana: 50,
stamina: 80,
x: 5104,
y: 5054,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(world.isDead).toBe(false)
expect((hud as any).isDead).toBe(false)
})
})
// =========================================================================
// Section 5: R5 — Re-Entrant Trade Cancels, Party Departures & Full C2S Audit
// =========================================================================
describe('R5 Adversarial — Re-Entrant Trade Aborts, Party Leaves & C2S Binary Audit', () => {
it('T5.15: receiving S2C 0x77 action=0x0C (Trade Cancel) while trade window is open with partner items and gold resets trade state and closes left panel', () => {
const { world, hud, worldPanels, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
// Open trade window (0x77 action 0x01)
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
const partnerRing = makeItemData({
id: 9401,
code: 'rin',
location: 0,
container: 'tradePartner',
storePage: 2,
storagePage: 2,
gridX: 0,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 0x00, item: partnerRing })
world.apply({ type: 'Trade', kind: 'gold', isSelf: false, amount: 25000 })
world.apply({ type: 'Trade', kind: 'button', action: 0x05 }) // Partner clicked accept
hudModel.syncFromWorld()
expect(world.trade.active).toBe(true)
expect(world.trade.partnerAccepted).toBe(true)
expect(world.trade.partnerGold).toBe(25000)
expect(hud.leftPanel).toBe('trade')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
expect(ctx.drawnTexts.some((t) => t.text.includes('Partner Gold: 25000'))).toBe(true)
// Server cancels trade mid-session (0x77 action 0x0C)
world.apply({ type: 'Trade', kind: 'button', action: 0x0c })
hudModel.syncFromWorld()
expect(world.trade.active).toBe(false)
expect(world.trade.partnerAccepted).toBe(false)
expect(world.trade.selfAccepted).toBe(false)
expect(hud.leftPanel).toBe('none')
expect((worldPanels as any).activePanel).toBe('none')
})
it('T5.16: S2C 0x5C PlayerLeaveGame (partyId=0xffff, empty charName) removes the departed player from world.party and hud.partyMembers', () => {
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Party',
kind: 'roster',
playerId: 9450,
charClass: 3,
charLevel: 42,
partyId: 1,
charName: 'PaladinBro',
})
hudModel.syncFromWorld()
expect(world.party.has(9450)).toBe(true)
expect((hud as any).partyMembers.some((m: any) => m.playerId === 9450)).toBe(true)
// 0x5C leaves game -> partyId=0xffff, empty charName
world.apply({
type: 'Party',
kind: 'roster',
playerId: 9450,
partyId: 0xffff,
charName: '',
})
hudModel.syncFromWorld()
expect(world.party.has(9450)).toBe(false)
expect((hud as any).partyMembers.some((m: any) => m.playerId === 9450)).toBe(false)
})
it('T5.17: Charsi imbue, Larzuk socket, and Anya personalize panels open inventory alongside leftPanel and emit 0x38 NpcEntityAction(action=0x58, npcId, itemId) on canvas Confirm click', () => {
const { world, hud, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession()
const { ox, oy } = getLeftPanelOrigin()
for (const reward of [
{ npcId: 9501, hcIdx: 154, name: 'Charsi', option: 'imbue' as const, itemId: 9601 },
{ npcId: 9502, hcIdx: 511, name: 'Larzuk', option: 'add_sockets' as const, itemId: 9602 },
{ npcId: 9503, hcIdx: 512, name: 'Anya', option: 'personalize' as const, itemId: 9603 },
]) {
clearRecorded()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: reward.npcId, classId: reward.hcIdx, name: reward.name, x: 5100, y: 5050 },
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: reward.itemId,
code: 'cap',
location: 4,
container: 'cursor',
}),
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: reward.npcId } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption(reward.option)
expect(hud.leftPanel).toBe(reward.option)
expect(hud.rightPanel).toBe('inv')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
// Place cursor item into 1-item slot box (ox + 116..204, oy + 120..236)
hud.handleClick(ox + 160, oy + 178)
const ctx2 = renderFrame()
assertNoUndefinedText(ctx2)
clearRecorded()
// Click Confirm button box (ox + 112..208, oy + 280..312)
hud.handleClick(ox + 160, oy + 296)
expect(sentCommands).toContainEqual({
type: 'NpcEntityAction',
action: 0,
npcId: reward.npcId,
complement: reward.itemId,
})
expect(
sentPackets.some(
(p) =>
p[0] === 0x38 &&
p.byteLength === 13 &&
readU32LE(p, 1) === 0 &&
readU32LE(p, 5) === reward.npcId &&
readU32LE(p, 9) === reward.itemId,
),
).toBe(true)
}
})
it('T5.18: ClickButton (0x4F) 32-bit gold complement encodes low u16 at bytes [3..4] and high u16 at bytes [5..6] per 1.13c word order', () => {
const goldAmount = 0x0012_3456 // 1,193,046 gold
const pkts = encodeClientCommandBytes({
type: 'ClickButton',
button: 0x0007,
complement: goldAmount,
})
const pkt = pkts[0]!
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[0x4f])
expect(pkt[0]).toBe(0x4f)
expect(readU16LE(pkt, 1)).toBe(0x0007)
expect(readU16LE(pkt, 3)).toBe(0x3456)
expect(readU16LE(pkt, 5)).toBe(0x0012)
expect(validateC2sPacket(pkt).valid).toBe(true)
})
it('T5.19: all newly wired C2S opcodes match exact C2S_PACKET_SIZES entries and pass validateC2sPacket', () => {
const cases: Array<{ opcode: number; cmd: Record<string, any> }> = [
{ opcode: 0x09, cmd: { type: 'WarpTo', unitType: 5, warpId: 101 } },
{ opcode: 0x13, cmd: { type: 'Interact', target: { unitType: 2, id: 102 } } },
{ opcode: 0x16, cmd: { type: 'ItemPickup', itemId: 103, toCursor: true } },
{ opcode: 0x17, cmd: { type: 'ItemDrop', itemId: 104 } },
{ opcode: 0x18, cmd: { type: 'ItemPlace', itemId: 105, x: 2, y: 3, buffer: 0 } },
{ opcode: 0x19, cmd: { type: 'ItemToCursor', itemId: 106 } },
{ opcode: 0x1a, cmd: { type: 'ItemEquip', itemId: 107, slot: 4 } },
{ opcode: 0x1c, cmd: { type: 'ItemUnequip', slot: 4 } },
{ opcode: 0x1d, cmd: { type: 'ItemSwap', itemId: 108, slot: 4 } },
{ opcode: 0x1f, cmd: { type: 'SwapContainerItem', cursorItemId: 109, replacedItemId: 110, x: 1, y: 1 } },
{ opcode: 0x20, cmd: { type: 'ItemUse', itemId: 111, at: { x: 5100, y: 5050 } } },
{ opcode: 0x23, cmd: { type: 'BeltPut', itemId: 114, slot: 2 } },
{ opcode: 0x24, cmd: { type: 'BeltRemove', itemId: 115 } },
{ opcode: 0x25, cmd: { type: 'BeltSwap', cursorItemId: 116, beltItemId: 117 } },
{ opcode: 0x26, cmd: { type: 'BeltUse', itemId: 118, useOnMerc: 1 } },
{ opcode: 0x27, cmd: { type: 'IdentifyItem', itemId: 119, scrollId: 120 } },
{ opcode: 0x28, cmd: { type: 'SocketItem', itemId: 121, targetItemId: 122 } },
{ opcode: 0x29, cmd: { type: 'ScrollToBook', scrollId: 123, bookId: 124 } },
{ opcode: 0x31, cmd: { type: 'QuestMessage', npcId: 125, messageId: 0 } },
{ opcode: 0x36, cmd: { type: 'MercHire', npcId: 126, mercId: 127 } },
{ opcode: 0x38, cmd: { type: 'NpcEntityAction', action: 0, npcId: 128, complement: 40 } },
{ opcode: 0x41, cmd: { type: 'Resurrect' } },
{ opcode: 0x4f, cmd: { type: 'ClickButton', button: 2, complement: 0 } },
{ opcode: 0x5d, cmd: { type: 'Hostile', button: 1, hostile: true, targetPlayerId: 129 } },
{ opcode: 0x5e, cmd: { type: 'PartyAction', action: 0x06, targetPlayerId: 130 } },
{ opcode: 0x60, cmd: { type: 'SwapWeapons' } },
{ opcode: 0x61, cmd: { type: 'MercItem', slot: 3 } },
{ opcode: 0x62, cmd: { type: 'MercResurrect', npcId: 131 } },
]
for (const { opcode, cmd } of cases) {
const pkts = encodeClientCommandBytes(cmd)
expect(pkts.length).toBeGreaterThan(0)
const pkt = pkts[0]!
expect(pkt[0]).toBe(opcode)
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[opcode])
expect(validateC2sPacket(pkt).valid).toBe(true)
}
})
it('T5.20: architectural import boundary guard — no file in src/client/** imports directly from src/netproto/d2gs/**', async () => {
const fs = await import('node:fs')
const path = await import('node:path')
const walk = (dir: string): string[] => {
const out: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) out.push(...walk(full))
else if (entry.name.endsWith('.ts')) out.push(full)
}
return out
}
const clientFiles = walk(path.resolve(process.cwd(), 'src/client'))
expect(clientFiles.length).toBeGreaterThan(0)
for (const file of clientFiles) {
const content = fs.readFileSync(file, 'utf8')
expect(content).not.toMatch(/from\s+['"][^'"]*netproto\/d2gs\//)
}
})
})
})