feat(client): server-driven NPC/stash interaction state in ClientWorld

- 0x9D act 0x05 with a non-cursor location (NPC sale, capture 06) removes
  the item instead of moving it to the cursor.
- ClientInventoryManager.clearShop(): the server sends no RemoveFromShop
  after 0x30 (capture 05), so the store is freed locally.
- ClientWorld.npcInteraction (npcId / trade|gamble window / stashOpen from
  S2C 0x77 0x10) and lastNpcTransaction (numbered 0x2A log, display only).
- OnlineSession and the bot apply outgoing-command side effects through
  ClientWorld.noteOutgoingCommand.
- Replay tests over captures 05-09.
This commit is contained in:
troytt 2026-10-01 10:08:43 +00:00
parent d377c0308e
commit 2e9216fc7f
5 changed files with 215 additions and 10 deletions

View File

@ -700,6 +700,7 @@ export class OnlineSession {
if (this.gameAdapter && this.gameAdapter.state !== 'closed') { if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
try { try {
this.gameAdapter.send(cmd) this.gameAdapter.send(cmd)
this.world.noteOutgoingCommand(cmd)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)

View File

@ -7,8 +7,10 @@
*/ */
import { import {
ClickButtonId,
unitTypeFromId, unitTypeFromId,
type CharClassId, type CharClassId,
type ClientCommand,
type Pt, type Pt,
type ServerEvent, type ServerEvent,
type SkillLevel, type SkillLevel,
@ -70,6 +72,32 @@ export interface PortalState {
readonly flags?: number | undefined readonly flags?: number | undefined
} }
/**
* Local NPC/stash interaction state. D2Client tracks this itself: the server never
* reports "window closed" (no RemoveFromShop after 0x30, no reply to 0x4F 0x12;
* captures 05 and 08). Stash opening is server-driven (0x77 action 0x10, capture 08).
*/
export interface NpcInteractionState {
/** NPC unit id from the last `NpcInit` (0x2F); `null` after `NpcCancel` (0x30). */
readonly npcId: number | null
/** Window opened by 0x38 action 1 (`trade`) or 2 (`gamble`). */
readonly window: 'none' | 'trade' | 'gamble'
/** Set by S2C 0x77 action 0x10, cleared when the client sends 0x4F button 0x12. */
readonly stashOpen: boolean
}
/** One S2C 0x2A NPCTransaction, numbered so the HUD can react to each reply once. */
export interface NpcTransactionRecord {
readonly seq: number
readonly result: number
readonly flag: number
readonly itemId: number
readonly gold: number
}
/** S2C 0x77 ButtonActions action that opens the stash panel (capture 08; D2Client jump table 0x6fb0a520[0x10]). */
const BUTTON_ACTION_OPEN_STASH = 0x10
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */ /** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
const MAX_D2_STAT_ID = 0x1ff const MAX_D2_STAT_ID = 0x1ff
@ -107,8 +135,55 @@ export class ClientWorld {
private readonly _chatLog: ChatLogEntry[] = [] private readonly _chatLog: ChatLogEntry[] = []
private readonly _soundQueue: SoundQueueEntry[] = [] private readonly _soundQueue: SoundQueueEntry[] = []
private readonly _portals = new Map<number, PortalState>() private readonly _portals = new Map<number, PortalState>()
private _npcInteraction: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false }
private _lastNpcTransaction: NpcTransactionRecord | null = null
private _npcTransactionSeq = 0
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>() private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
get npcInteraction(): NpcInteractionState {
return this._npcInteraction
}
/** Last S2C 0x2A NPCTransaction; display-only (gold/items come from stat and item packets). */
get lastNpcTransaction(): NpcTransactionRecord | null {
return this._lastNpcTransaction
}
/**
* Records the local side effects of an outgoing command, mirroring what D2Client
* does when it sends it: 0x38 opens the trade/gamble window and the store is
* refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
*/
noteOutgoingCommand(cmd: ClientCommand): void {
const cur = this._npcInteraction
switch (cmd.type) {
case 'NpcInit':
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none' }
break
case 'NpcOpenTrade':
case 'NpcOpenGamble':
this._containers.clearShop()
this._npcInteraction = {
...cur,
npcId: cmd.npcId,
window: cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble',
}
break
case 'NpcCancel':
this._containers.clearShop()
this._npcInteraction = { ...cur, npcId: null, window: 'none' }
break
case 'ClickButton':
if (cmd.buttonId === ClickButtonId.CloseStash) {
this._npcInteraction = { ...cur, stashOpen: false }
}
break
default:
break
}
}
onChange(cb: (ev: ServerEvent) => void): () => void { onChange(cb: (ev: ServerEvent) => void): () => void {
this._changeListeners.add(cb) this._changeListeners.add(cb)
return () => { return () => {
@ -590,6 +665,13 @@ export class ClientWorld {
case 'NpcTransaction': { case 'NpcTransaction': {
// Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]). // Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]).
this._lastNpcTransaction = {
seq: ++this._npcTransactionSeq,
result: ev.result,
flag: ev.flag,
itemId: ev.itemId,
gold: ev.gold,
}
break break
} }
@ -635,6 +717,9 @@ export class ClientWorld {
case 'Trade': { case 'Trade': {
this._trade.lastAction = ev.action this._trade.lastAction = ev.action
if (ev.action === BUTTON_ACTION_OPEN_STASH) {
this._npcInteraction = { ...this._npcInteraction, stashOpen: true }
}
if (ev.partnerId !== undefined) { if (ev.partnerId !== undefined) {
this._trade.partnerId = ev.partnerId this._trade.partnerId = ev.partnerId
} }

View File

@ -92,7 +92,15 @@ export class ClientInventoryManager implements ClientItemContainers {
* Applies an `ItemEvent` from `0x9C` (`WorldItemAction`) or `0x9D` (`OwnedItemAction`). * Applies an `ItemEvent` from `0x9C` (`WorldItemAction`) or `0x9D` (`OwnedItemAction`).
*/ */
applyItemEvent(action: number, item: ItemData, container: ItemContainer, selfId?: number): void { applyItemEvent(action: number, item: ItemData, container: ItemContainer, selfId?: number): void {
// Removal-to-cursor or shop removal actions: // 0x05 RemoveFromContainer: the item goes to the cursor only when the packet's
// location is the cursor (pickup). Selling to an NPC sends act 0x05 with
// location 0 (capture 06-d2gs-vendor-buy-sell: 0x22 -> 0x9D act 5 loc 0 ->
// 0x2A result 1 flag 3), and the item simply leaves the player's containers.
if (action === 0x05 && container !== 'cursor') {
this.removeItemById(item.id)
return
}
// Removal-to-cursor actions:
// 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor) // 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
if (action === 0x05 || action === 0x08 || action === 0x0f) { if (action === 0x05 || action === 0x08 || action === 0x0f) {
this.removeItemById(item.id) this.removeItemById(item.id)
@ -176,6 +184,18 @@ export class ClientInventoryManager implements ClientItemContainers {
} }
} }
/**
* Drops every store unit. The server sends no RemoveFromShop after C2S 0x30
* (capture 05-d2gs-vendor-open), so the client frees the store locally.
*/
clearShop(): void {
for (const id of this.shop.keys()) {
this.allById.delete(id)
this.sockets.delete(id)
}
this.shop.clear()
}
clear(): void { clear(): void {
this.inventory.clear() this.inventory.clear()
this.equipment.clear() this.equipment.clear()

View File

@ -0,0 +1,92 @@
/**
* Vendor / stash / gamble ClientWorld replay over live 1.13c captures 05-09
* (WebBotThree, clvl 1, 2 gold). Everything asserted here is what the server sent.
*/
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { replayD2CapFile, type ReplayResult } from '../../tools/netproto-replay.ts'
import { ClickButtonId, type ServerEvent } from '../../src/netproto/index.ts'
const FIXTURES = path.resolve(__dirname, '../fixtures/netproto')
const replay = (name: string): ReplayResult => {
const r = replayD2CapFile(path.join(FIXTURES, name), { strict: true })
expect(r.unknownPackets).toBe(0)
expect(r.opaquePackets).toBe(0)
expect(r.protocolErrors).toBe(0)
return r
}
type Tx = Extract<ServerEvent, { type: 'NpcTransaction' }>
const txs = (r: ReplayResult): Tx[] => r.events.filter((e): e is Tx => e.type === 'NpcTransaction')
const MONSTER = 1
describe('vendor/stash captures -> ClientWorld', () => {
it('05 vendor-open: Charsi store arrives only as 0x9C AddToShop and is freed locally on 0x30', () => {
const r = replay('05-d2gs-vendor-open.d2cap')
const shop = [...r.world.containers.shop.values()]
expect(shop.length).toBe(46)
for (const it of shop) {
expect(it.action).toBe(0x0b)
expect([1, 2, 3, 4]).toContain(it.storePage)
expect(it.gridX).toBeGreaterThanOrEqual(0)
expect(it.gridX).toBeLessThan(10)
expect(it.gridY).toBeGreaterThanOrEqual(0)
expect(it.gridY).toBeLessThan(10)
}
expect(txs(r)).toEqual([])
// The capture ends after C2S 0x30 with no RemoveFromShop; the client frees the store itself.
const npcId = r.world.npcInteraction.npcId
r.world.noteOutgoingCommand({ type: 'NpcCancel', target: { unitType: MONSTER, id: 6 } })
expect(npcId).toBeNull() // replay feeds S2C only; NpcInit was never noted
expect(r.world.containers.shop.size).toBe(0)
expect(r.world.npcInteraction.window).toBe('none')
})
it('06 vendor-buy-sell: sells remove the item (act 5 loc 0), buys without gold are rejected with result 12', () => {
const r = replay('06-d2gs-vendor-buy-sell.d2cap')
const t = txs(r)
const sells = t.filter(x => x.flag === 3)
expect(sells.length).toBe(2)
for (const s of sells) {
expect(s.result).toBe(1)
expect(s.itemId).not.toBe(0xffffffff)
}
const rejects = t.filter(x => x.result === 12)
expect(rejects.length).toBeGreaterThan(0)
for (const x of rejects) {
expect(x.flag).toBe(0)
expect(x.itemId).toBe(0xffffffff)
}
// Sold items must not reappear on the cursor (0x9D act 5 with location 0).
expect(r.world.containers.cursor).toBeNull()
expect(r.world.containers.inventory.size).toBe(0)
expect(r.world.self.gold).toBe(sells[sells.length - 1]!.gold)
expect(r.world.lastNpcTransaction?.seq).toBe(t.length)
})
it('07 vendor-repair: undamaged repair -> result 9, repair-all with nothing damaged -> result 2 flag 1', () => {
const r = replay('07-d2gs-vendor-repair.d2cap')
expect(txs(r).map(x => [x.result, x.flag, x.itemId])).toEqual([
[9, 0, 0xffffffff],
[2, 1, 0xffffffff],
])
expect(r.world.self.gold).toBe(2)
})
it('08 stash-gold: 0x77 0x10 opens the stash; deposit/withdraw update stats 15/14; close is local', () => {
const r = replay('08-d2gs-stash-gold.d2cap')
expect(r.events.some(e => e.type === 'Trade' && e.action === 0x10)).toBe(true)
expect(r.world.npcInteraction.stashOpen).toBe(true)
expect(r.world.self.gold).toBe(2)
expect(r.world.self.stashGold).toBe(0)
r.world.noteOutgoingCommand({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 })
expect(r.world.npcInteraction.stashOpen).toBe(false)
})
it('09 gamble: Gheed gamble store fills via 0x9C; gambling a ring with 2 gold is rejected (result 12)', () => {
const r = replay('09-d2gs-gamble.d2cap')
const codes = [...r.world.containers.shop.values()].map(i => i.code)
expect(codes).toContain('rin')
expect(codes).toContain('amu')
expect(txs(r).map(x => [x.result, x.flag, x.itemId])).toEqual([[12, 0, 0xffffffff]])
})
})

View File

@ -22,6 +22,7 @@ import type { ClientUnit } from '../../src/client/world/client-unit.ts'
import { import {
ClickButtonId, ClickButtonId,
unitTypeToId, unitTypeToId,
type ClientCommand,
type GameServerAdapter, type GameServerAdapter,
type ItemData, type ItemData,
type ServerEvent, type ServerEvent,
@ -64,6 +65,12 @@ interface Ctx {
readonly report: Record<string, unknown> readonly report: Record<string, unknown>
} }
/** Sends a command and applies its local side effects to ClientWorld, like OnlineSession does. */
function send(ctx: Ctx, cmd: ClientCommand): void {
ctx.adapter.send(cmd)
ctx.world.noteOutgoingCommand(cmd)
}
function fmtItem(it: ItemData): string { function fmtItem(it: ItemData): string {
return `#${it.id} ${it.code} q=${it.quality ?? '-'} loc=${it.location} page=${it.storePage} grid=(${it.gridX},${it.gridY}) act=0x${it.action.toString(16)}` return `#${it.id} ${it.code} q=${it.quality ?? '-'} loc=${it.location} page=${it.storePage} grid=(${it.gridX},${it.gridY}) act=0x${it.action.toString(16)}`
} }
@ -104,7 +111,7 @@ async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, withi
const hy = Math.round(sy + ((wp.y - sy) * i) / n) const hy = Math.round(sy + ((wp.y - sy) * i) / n)
const hd = Math.hypot(hx - ctx.pos.x, hy - ctx.pos.y) const hd = Math.hypot(hx - ctx.pos.x, hy - ctx.pos.y)
if (hd < 0.5) continue if (hd < 0.5) continue
ctx.adapter.send({ type: 'Move', run: true, to: { x: hx, y: hy } }) send(ctx, { type: 'Move', run: true, to: { x: hx, y: hy } })
await sleep(Math.max(150, Math.ceil(hd * 140))) await sleep(Math.max(150, Math.ceil(hd * 140)))
ctx.world.tick() ctx.world.tick()
ctx.pos = { x: hx, y: hy } ctx.pos = { x: hx, y: hy }
@ -148,22 +155,22 @@ async function openNpc(ctx: Ctx, npcClass: number): Promise<ClientUnit> {
ctx.log(`npc class=${npcClass} id=${npc.id} at (${npc.x},${npc.y}) dist=${dist(ctx.world, npc).toFixed(1)}`) ctx.log(`npc class=${npcClass} id=${npc.id} at (${npc.x},${npc.y}) dist=${dist(ctx.world, npc).toFixed(1)}`)
await walkTo(ctx, 'monster', npc) await walkTo(ctx, 'monster', npc)
const target = { unitType: unitTypeToId('monster'), id: npc.id } const target = { unitType: unitTypeToId('monster'), id: npc.id }
ctx.adapter.send({ type: 'Interact', target }) send(ctx, { type: 'Interact', target })
await sleep(700) await sleep(700)
ctx.adapter.send({ type: 'NpcInit', target }) send(ctx, { type: 'NpcInit', target })
await sleep(700) await sleep(700)
return npc return npc
} }
async function closeNpc(ctx: Ctx, npc: ClientUnit): Promise<void> { async function closeNpc(ctx: Ctx, npc: ClientUnit): Promise<void> {
ctx.adapter.send({ type: 'NpcCancel', target: { unitType: unitTypeToId('monster'), id: npc.id } }) send(ctx, { type: 'NpcCancel', target: { unitType: unitTypeToId('monster'), id: npc.id } })
await sleep(800) await sleep(800)
ctx.log(`closed npc #${npc.id}; shop items left in ClientWorld=${ctx.world.containers.shop.size}`) ctx.log(`closed npc #${npc.id}; shop items left in ClientWorld=${ctx.world.containers.shop.size}`)
} }
async function openShop(ctx: Ctx, npc: ClientUnit, gamble: boolean): Promise<readonly ItemData[]> { async function openShop(ctx: Ctx, npc: ClientUnit, gamble: boolean): Promise<readonly ItemData[]> {
const before = ctx.world.containers.shop.size const before = ctx.world.containers.shop.size
ctx.adapter.send(gamble ? { type: 'NpcOpenGamble', npcId: npc.id } : { type: 'NpcOpenTrade', npcId: npc.id }) send(ctx, gamble ? { type: 'NpcOpenGamble', npcId: npc.id } : { type: 'NpcOpenTrade', npcId: npc.id })
await waitFor(ctx, () => ctx.world.containers.shop.size > before, 4000, 'shop items') await waitFor(ctx, () => ctx.world.containers.shop.size > before, 4000, 'shop items')
await sleep(1200) await sleep(1200)
const items = [...ctx.world.containers.shop.values()] const items = [...ctx.world.containers.shop.values()]
@ -184,7 +191,7 @@ async function sell(ctx: Ctx, npc: ClientUnit, item: ItemData, label: string): P
const g0 = ctx.world.self.gold const g0 = ctx.world.self.gold
const i = ctx.tx.length const i = ctx.tx.length
ctx.log(`${label}: sell ${fmtItem(item)}`) ctx.log(`${label}: sell ${fmtItem(item)}`)
ctx.adapter.send({ type: 'NpcSell', npcId: npc.id, itemId: item.id, itemMode: 0, cost: 1 }) send(ctx, { type: 'NpcSell', npcId: npc.id, itemId: item.id, itemMode: 0, cost: 1 })
const t = await waitTx(ctx, i) const t = await waitTx(ctx, i)
ctx.log(`${label}: result=${t?.result} flag=${t?.flag} itemId=0x${(t?.itemId ?? 0).toString(16)} gold@11=${t?.gold} | stat14 ${g0} -> ${ctx.world.self.gold} (Δ${ctx.world.self.gold - g0}) stillInInv=${ctx.world.containers.inventory.has(item.id)}`) ctx.log(`${label}: result=${t?.result} flag=${t?.flag} itemId=0x${(t?.itemId ?? 0).toString(16)} gold@11=${t?.gold} | stat14 ${g0} -> ${ctx.world.self.gold} (Δ${ctx.world.self.gold - g0}) stillInInv=${ctx.world.containers.inventory.has(item.id)}`)
ctx.report[label] = { item: item.code, result: t?.result, flag: t?.flag, goldBefore: g0, goldAfter: ctx.world.self.gold } ctx.report[label] = { item: item.code, result: t?.result, flag: t?.flag, goldBefore: g0, goldAfter: ctx.world.self.gold }
@ -202,7 +209,7 @@ async function buy(
const belt0 = new Set(ctx.world.containers.belt.keys()) const belt0 = new Set(ctx.world.containers.belt.keys())
const i = ctx.tx.length const i = ctx.tx.length
ctx.log(`${label}: buy ${fmtItem(item)} gamble=${opts.gamble} shift=${opts.shiftFill} cost=${opts.cost}`) ctx.log(`${label}: buy ${fmtItem(item)} gamble=${opts.gamble} shift=${opts.shiftFill} cost=${opts.cost}`)
ctx.adapter.send({ type: 'NpcBuy', npcId: npc.id, itemId: item.id, itemMode: 0, ...opts }) send(ctx, { type: 'NpcBuy', npcId: npc.id, itemId: item.id, itemMode: 0, ...opts })
const t = await waitTx(ctx, i) const t = await waitTx(ctx, i)
const newInv = [...ctx.world.containers.inventory.values()].filter((x) => !inv0.has(x.id)) const newInv = [...ctx.world.containers.inventory.values()].filter((x) => !inv0.has(x.id))
const newBelt = [...ctx.world.containers.belt.values()].filter((x) => !belt0.has(x.id)) const newBelt = [...ctx.world.containers.belt.values()].filter((x) => !belt0.has(x.id))
@ -214,7 +221,7 @@ async function buy(
async function stashGold(ctx: Ctx, button: number, amount: number, label: string): Promise<void> { async function stashGold(ctx: Ctx, button: number, amount: number, label: string): Promise<void> {
const g0 = ctx.world.self.gold const g0 = ctx.world.self.gold
const s0 = ctx.world.self.stashGold const s0 = ctx.world.self.stashGold
ctx.adapter.send({ type: 'ClickButton', buttonId: button, amount }) send(ctx, { type: 'ClickButton', buttonId: button, amount })
await sleep(1500) await sleep(1500)
ctx.log(`${label}: 0x4F 0x${button.toString(16)} amount=${amount} | gold ${g0} -> ${ctx.world.self.gold}, stash ${s0} -> ${ctx.world.self.stashGold}`) ctx.log(`${label}: 0x4F 0x${button.toString(16)} amount=${amount} | gold ${g0} -> ${ctx.world.self.gold}, stash ${s0} -> ${ctx.world.self.stashGold}`)
ctx.report[label] = { amount, goldBefore: g0, goldAfter: ctx.world.self.gold, stashBefore: s0, stashAfter: ctx.world.self.stashGold } ctx.report[label] = { amount, goldBefore: g0, goldAfter: ctx.world.self.gold, stashBefore: s0, stashAfter: ctx.world.self.stashGold }
@ -224,7 +231,7 @@ async function openStash(ctx: Ctx): Promise<void> {
const stash = findUnit(ctx.world, 'object', OBJECT_CLASS_STASH) const stash = findUnit(ctx.world, 'object', OBJECT_CLASS_STASH)
ctx.log(`stash object id=${stash.id} at (${stash.x},${stash.y}) dist=${dist(ctx.world, stash).toFixed(1)}`) ctx.log(`stash object id=${stash.id} at (${stash.x},${stash.y}) dist=${dist(ctx.world, stash).toFixed(1)}`)
await walkTo(ctx, 'object', stash, 3) await walkTo(ctx, 'object', stash, 3)
ctx.adapter.send({ type: 'Interact', target: { unitType: unitTypeToId('object'), id: stash.id } }) send(ctx, { type: 'Interact', target: { unitType: unitTypeToId('object'), id: stash.id } })
await sleep(1500) await sleep(1500)
} }