diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 4af58e7..fd75142 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -700,6 +700,7 @@ export class OnlineSession { if (this.gameAdapter && this.gameAdapter.state !== 'closed') { try { this.gameAdapter.send(cmd) + this.world.noteOutgoingCommand(cmd) } catch (err) { const msg = err instanceof Error ? err.message : String(err) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`) diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 38e73db..b7fdfcc 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -7,8 +7,10 @@ */ import { + ClickButtonId, unitTypeFromId, type CharClassId, + type ClientCommand, type Pt, type ServerEvent, type SkillLevel, @@ -70,6 +72,32 @@ export interface PortalState { 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). */ const MAX_D2_STAT_ID = 0x1ff @@ -107,8 +135,55 @@ export class ClientWorld { private readonly _chatLog: ChatLogEntry[] = [] private readonly _soundQueue: SoundQueueEntry[] = [] private readonly _portals = new Map() + 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>() + 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 { this._changeListeners.add(cb) return () => { @@ -590,6 +665,13 @@ export class ClientWorld { case 'NpcTransaction': { // 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 } @@ -635,6 +717,9 @@ export class ClientWorld { case 'Trade': { this._trade.lastAction = ev.action + if (ev.action === BUTTON_ACTION_OPEN_STASH) { + this._npcInteraction = { ...this._npcInteraction, stashOpen: true } + } if (ev.partnerId !== undefined) { this._trade.partnerId = ev.partnerId } diff --git a/src/client/world/inventory.ts b/src/client/world/inventory.ts index b4b5eb0..809054c 100644 --- a/src/client/world/inventory.ts +++ b/src/client/world/inventory.ts @@ -92,7 +92,15 @@ export class ClientInventoryManager implements ClientItemContainers { * Applies an `ItemEvent` from `0x9C` (`WorldItemAction`) or `0x9D` (`OwnedItemAction`). */ 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) if (action === 0x05 || action === 0x08 || action === 0x0f) { 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 { this.inventory.clear() this.equipment.clear() diff --git a/tests/client/vendor-stash-replay.test.ts b/tests/client/vendor-stash-replay.test.ts new file mode 100644 index 0000000..420c50b --- /dev/null +++ b/tests/client/vendor-stash-replay.test.ts @@ -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 +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]]) + }) +}) diff --git a/tools/lib/vendor-scenarios.ts b/tools/lib/vendor-scenarios.ts index 3224173..57b112b 100644 --- a/tools/lib/vendor-scenarios.ts +++ b/tools/lib/vendor-scenarios.ts @@ -22,6 +22,7 @@ import type { ClientUnit } from '../../src/client/world/client-unit.ts' import { ClickButtonId, unitTypeToId, + type ClientCommand, type GameServerAdapter, type ItemData, type ServerEvent, @@ -64,6 +65,12 @@ interface Ctx { readonly report: Record } +/** 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 { 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 hd = Math.hypot(hx - ctx.pos.x, hy - ctx.pos.y) 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))) ctx.world.tick() ctx.pos = { x: hx, y: hy } @@ -148,22 +155,22 @@ async function openNpc(ctx: Ctx, npcClass: number): Promise { 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) const target = { unitType: unitTypeToId('monster'), id: npc.id } - ctx.adapter.send({ type: 'Interact', target }) + send(ctx, { type: 'Interact', target }) await sleep(700) - ctx.adapter.send({ type: 'NpcInit', target }) + send(ctx, { type: 'NpcInit', target }) await sleep(700) return npc } async function closeNpc(ctx: Ctx, npc: ClientUnit): Promise { - 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) 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 { 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 sleep(1200) 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 i = ctx.tx.length 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) 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 } @@ -202,7 +209,7 @@ async function buy( const belt0 = new Set(ctx.world.containers.belt.keys()) const i = ctx.tx.length 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 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)) @@ -214,7 +221,7 @@ async function buy( async function stashGold(ctx: Ctx, button: number, amount: number, label: string): Promise { const g0 = ctx.world.self.gold const s0 = ctx.world.self.stashGold - ctx.adapter.send({ type: 'ClickButton', buttonId: button, amount }) + send(ctx, { type: 'ClickButton', buttonId: button, amount }) 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.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 { 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)}`) 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) }