From 8f8ea3b30174a3dd63e632dc2a9aac3609ffd858 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 09:45:14 +0000 Subject: [PATCH 1/8] fix(netproto): vendor/stash packet encodings per 1.13c disassembly - 0x4F ClickButton: amount high word before low word (D2Game 0x6fcf1470); named ClickButtonId (0x08 trade gold, 0x12 close stash, 0x13 withdraw, 0x14 deposit, 0x18 transmute); amount is required. - 0x35 repair split into NpcRepairItem (itemMode, durability) and NpcRepairAll (field4 0x80000000); no defaults. - 0x32/0x33 field3 semantics: buy = itemMode<<16 | gamble 2 | shift 0x80000000, sell = itemMode; cost required. - 0x38: NpcOpenTrade (action 1) / NpcOpenGamble (action 2), confirmed in D2Game 0x6fce00c0. - 0x2A decoded as NpcTransaction{result@2, flag@1, itemId@7, gold@11} (builder D2Game 0x6fcaac90); ClientWorld no longer writes stat 14 from it. - Byte-level golden tests. --- src/client/ui-model/hud-model.ts | 24 ++-- src/client/world/client-world.ts | 8 +- src/netproto/d2gs/c2s/interact.ts | 37 ++++- src/netproto/d2gs/c2s/npc.ts | 135 ++++++++++++++---- src/netproto/d2gs/c2s/trade.ts | 3 +- src/netproto/d2gs/registry.ts | 22 +-- src/netproto/d2gs/s2c/npc.ts | 30 ++-- src/netproto/domain/client-command.ts | 29 ++-- src/netproto/domain/server-event.ts | 13 +- src/netproto/index.ts | 2 + .../tier5-adversarial-coverage.test.ts | 13 +- tests/netproto/d2gs-codecs-items.test.ts | 11 +- tests/netproto/vendor-stash-golden.test.ts | 135 ++++++++++++++++++ 13 files changed, 380 insertions(+), 82 deletions(-) create mode 100644 tests/netproto/vendor-stash-golden.test.ts diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 2a59fa4..3c9915a 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -13,6 +13,7 @@ import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import { monsterDisplayName } from '../../common/units/monsters-data.ts' import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' +import { ClickButtonId } from '../../netproto/index.ts' import type { CharClassId, ClientCommand, @@ -737,28 +738,35 @@ export class HudModel implements HudCommandSink { * Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`). */ transmuteCube(): void { - this.emitCommand({ type: 'ClickButton', buttonId: 0x18 }) + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.Transmute, amount: 0 }) } /** * Buy an item from an active NPC vendor (`0x32 NpcBuy`). */ - buyFromNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { - this.emitCommand({ type: 'NpcBuy', npcId, itemId, tab, cost }) + buyFromNpc(p: { npcId: number; itemId: number; itemMode: number; gamble: boolean; shiftFill: boolean; cost: number }): void { + this.emitCommand({ type: 'NpcBuy', ...p }) } /** * Sell an item to an active NPC vendor (`0x33 NpcSell`). */ - sellToNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { - this.emitCommand({ type: 'NpcSell', npcId, itemId, tab, cost }) + sellToNpc(p: { npcId: number; itemId: number; itemMode: number; cost: number }): void { + this.emitCommand({ type: 'NpcSell', ...p }) } /** - * Repair all or a single item at an NPC smith (`0x35 NpcRepair`). + * Repair one item at an NPC smith (`0x35` with itemMode / current durability). */ - repairAtNpc(npcId: number, itemId?: number): void { - this.emitCommand({ type: 'NpcRepair', npcId, itemId }) + repairItemAtNpc(p: { npcId: number; itemId: number; itemMode: number; durability: number }): void { + this.emitCommand({ type: 'NpcRepairItem', ...p }) + } + + /** + * Repair all items at an NPC smith (`0x35` field4 = 0x80000000). + */ + repairAllAtNpc(npcId: number): void { + this.emitCommand({ type: 'NpcRepairAll', npcId }) } /** diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 5927688..38e73db 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -585,9 +585,11 @@ export class ClientWorld { } case 'Npc': { - if (ev.goldRemaining !== undefined) { - this._self.applyStat(14, ev.goldRemaining) - } + break + } + + case 'NpcTransaction': { + // Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]). break } diff --git a/src/netproto/d2gs/c2s/interact.ts b/src/netproto/d2gs/c2s/interact.ts index 5ef7264..6784b68 100644 --- a/src/netproto/d2gs/c2s/interact.ts +++ b/src/netproto/d2gs/c2s/interact.ts @@ -24,12 +24,41 @@ export function encodeC2sWaypointGo(waypointId: number, destAreaId: number): Uin return validateC2sPacket(w.toUint8Array()) } -/** 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 complementLow | u16 complementHigh */ -export function encodeC2sClickButton(buttonId: number, complement = 0): Uint8Array { +/** + * 0x4F ClickButton button ids (1.13c). Sender call sites in D2Client.dll; dispatcher D2Game.dll 0x6fc94200. + * - 0x08 TradeGold: amount = gold offered in the trade window. + * - 0x12 CloseStash: amount 0. + * - 0x13 WithdrawGold: stash -> inventory, amount (D2Game 0x6fc917e0; rejected if gold + amount > clvl*10000). + * - 0x14 DepositGold: inventory -> stash, amount (D2Game 0x6fc917e0; clamped to the 2,500,000 bank cap). + * - 0x18 Transmute: Horadric Cube transmute, amount 0. + */ +export const ClickButtonId = { + TradeGold: 0x08, + CloseStash: 0x12, + WithdrawGold: 0x13, + DepositGold: 0x14, + Transmute: 0x18, +} as const + +/** + * 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 amountHigh | u16 amountLow. + * + * D2Game.dll 0x6fcf1470 rebuilds the 32-bit amount as `(WORD[3] << 16) | WORD[5]`; D2Client's gold + * dialog OK handler (0x6fb6fdf0..0x6fb6fe79) passes the high word in dx and the low word on the stack. + * The high word therefore precedes the low word on the wire. + */ +export function encodeC2sClickButton(buttonId: number, amount: number): Uint8Array { + if (!Number.isInteger(buttonId) || buttonId < 0 || buttonId > 0xffff) { + throw new RangeError(`ClickButton: invalid buttonId ${buttonId}`) + } + if (!Number.isInteger(amount) || amount < 0 || amount > 0xffffffff) { + throw new RangeError(`ClickButton: invalid amount ${amount}`) + } const w = new ByteWriter(7) w.u8(0x4f) - w.u16LE(buttonId & 0xffff) - w.u32LE(complement >>> 0) + w.u16LE(buttonId) + w.u16LE((amount >>> 16) & 0xffff) + w.u16LE(amount & 0xffff) return validateC2sPacket(w.toUint8Array()) } diff --git a/src/netproto/d2gs/c2s/npc.ts b/src/netproto/d2gs/c2s/npc.ts index df168e4..38fe178 100644 --- a/src/netproto/d2gs/c2s/npc.ts +++ b/src/netproto/d2gs/c2s/npc.ts @@ -24,25 +24,69 @@ export function encodeC2sNpcCancel(target: UnitRef): Uint8Array { return validateC2sPacket(w.toUint8Array()) } -/** 0x32 BuyItem (17B): u8 0x32 | u32 npcId | u32 itemId | u32 tab | u32 cost */ -export function encodeC2sNpcBuy(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { +function requireU32(name: string, v: number): number { + if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) throw new RangeError(`${name}: invalid u32 ${v}`) + return v >>> 0 +} + +function requireU16(name: string, v: number): number { + if (!Number.isInteger(v) || v < 0 || v > 0xffff) throw new RangeError(`${name}: invalid u16 ${v}`) + return v +} + +/** 0x32 field3 bit: gamble purchase (D2Client global [0x6fbc97a8] set while the gamble window is open). */ +export const NPC_BUY_FLAG_GAMBLE = 0x00000002 +/** 0x32 field3 bit: shift+right-click fill buy (ItemsTxt +0x1a5 quantity-fill items; D2Client 0x6faf6ee0). */ +export const NPC_BUY_FLAG_SHIFT_FILL = 0x80000000 + +export interface NpcBuyParams { + readonly npcId: number + readonly itemId: number + /** Shop item UnitAny.dwMode (u16) as seen by the client; packed into the high word of field3. */ + readonly itemMode: number + readonly gamble: boolean + readonly shiftFill: boolean + /** Client-side cost (D2Common #10107 type 0/2); the server recomputes the price itself. */ + readonly cost: number +} + +/** + * 0x32 BuyItem (17B): u8 0x32 | u32 npcId | u32 itemId | u32 field3 | u32 cost. + * field3 = (itemMode << 16) | (gamble ? 2 : 0) | (shiftFill ? 0x80000000 : 0) (D2Client 0x6faf6ee0; + * D2Game 0x6fcef220 splits `&0x80000000`, `&0x7fffffff`, `>>16` before calling 0x6fcdf200). + */ +export function encodeC2sNpcBuy(p: NpcBuyParams): Uint8Array { + const field3 = + ((requireU16('NpcBuy.itemMode', p.itemMode) << 16) | + (p.gamble ? NPC_BUY_FLAG_GAMBLE : 0) | + (p.shiftFill ? NPC_BUY_FLAG_SHIFT_FILL : 0)) >>> + 0 const w = new ByteWriter(17) w.u8(0x32) - w.u32LE(npcId >>> 0) - w.u32LE(itemId >>> 0) - w.u32LE(tab >>> 0) - w.u32LE(cost >>> 0) + w.u32LE(requireU32('NpcBuy.npcId', p.npcId)) + w.u32LE(requireU32('NpcBuy.itemId', p.itemId)) + w.u32LE(field3) + w.u32LE(requireU32('NpcBuy.cost', p.cost)) return validateC2sPacket(w.toUint8Array()) } -/** 0x33 SellItem (17B): u8 0x33 | u32 npcId | u32 itemId | u32 tab | u32 cost */ -export function encodeC2sNpcSell(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { +export interface NpcSellParams { + readonly npcId: number + readonly itemId: number + /** Sold item UnitAny.dwMode (u16); D2Game only reads WORD@9 of field3. */ + readonly itemMode: number + /** Client-side sell price (D2Common #10107 type 1); the server recomputes the price itself. */ + readonly cost: number +} + +/** 0x33 SellItem (17B): u8 0x33 | u32 npcId | u32 itemId | u32 field3 (= itemMode) | u32 cost (D2Client 0x6fb46d5f). */ +export function encodeC2sNpcSell(p: NpcSellParams): Uint8Array { const w = new ByteWriter(17) w.u8(0x33) - w.u32LE(npcId >>> 0) - w.u32LE(itemId >>> 0) - w.u32LE(tab >>> 0) - w.u32LE(cost >>> 0) + w.u32LE(requireU32('NpcSell.npcId', p.npcId)) + w.u32LE(requireU32('NpcSell.itemId', p.itemId)) + w.u32LE(requireU16('NpcSell.itemMode', p.itemMode)) + w.u32LE(requireU32('NpcSell.cost', p.cost)) return validateC2sPacket(w.toUint8Array()) } @@ -54,19 +98,41 @@ export function encodeC2sNpcIdentifyAll(npcId: number): Uint8Array { return validateC2sPacket(w.toUint8Array()) } -/** 0x35 Repair (17B): u8 0x35 | u32 npcId | u32 itemId | u32 tab | u32 cost */ -export function encodeC2sNpcRepair( - npcId: number, - itemId = 0, - tab = 0, - cost = 0x80000000, -): Uint8Array { +/** 0x35 field4 sign bit: repair every equipped/inventory item (D2Game 0x6fcdde60, price D2Common #10071). */ +export const NPC_REPAIR_ALL_FLAG = 0x80000000 + +export interface NpcRepairItemParams { + readonly npcId: number + readonly itemId: number + /** Item UnitAny.dwMode (u16). */ + readonly itemMode: number + /** Current durability (item stat 72) as read by the client via D2Common #10973. */ + readonly durability: number +} + +/** 0x35 Repair single item (17B): u8 0x35 | u32 npcId | u32 itemId | u32 itemMode | u32 durability. */ +export function encodeC2sNpcRepairItem(p: NpcRepairItemParams): Uint8Array { + const durability = requireU32('NpcRepairItem.durability', p.durability) + if ((durability & NPC_REPAIR_ALL_FLAG) !== 0) { + throw new RangeError('NpcRepairItem: durability must not set the repair-all sign bit') + } const w = new ByteWriter(17) w.u8(0x35) - w.u32LE(npcId >>> 0) - w.u32LE(itemId >>> 0) - w.u32LE(tab >>> 0) - w.u32LE(cost >>> 0) + w.u32LE(requireU32('NpcRepairItem.npcId', p.npcId)) + w.u32LE(requireU32('NpcRepairItem.itemId', p.itemId)) + w.u32LE(requireU16('NpcRepairItem.itemMode', p.itemMode)) + w.u32LE(durability) + return validateC2sPacket(w.toUint8Array()) +} + +/** 0x35 Repair all (17B): u8 0x35 | u32 npcId | u32 0 | u32 0 | u32 0x80000000 (D2Client 0x6faf7dff clears itemId). */ +export function encodeC2sNpcRepairAll(npcId: number): Uint8Array { + const w = new ByteWriter(17) + w.u8(0x35) + w.u32LE(requireU32('NpcRepairAll.npcId', npcId)) + w.u32LE(0) + w.u32LE(0) + w.u32LE(NPC_REPAIR_ALL_FLAG) return validateC2sPacket(w.toUint8Array()) } @@ -79,13 +145,26 @@ export function encodeC2sHireMerc(npcId: number, mercId: number): Uint8Array { return validateC2sPacket(w.toUint8Array()) } -/** 0x38 EntityAction (13B): u8 0x38 | u32 actionType (1=trade, 2=gamble) | u32 npcId | u32 complement */ -export function encodeC2sNpcEntityAction(actionType: number, npcId: number, complement = 0): Uint8Array { +/** + * 0x38 EntityAction action values handled by D2Game.dll 0x6fce00c0 (via C2S handler 0x6fcefb30): + * - 1 Trade: vendor classes -> 0x6fcdf810(gamble = 0) fills the shop. + * - 2 Gamble: classes 147/199/254/405/512/514 -> 0x6fcdf810(gamble = 1). + * - 3 Hire: 0x6fcdd330 (mercenary list). + * D2Client sends both trade (0x6fafb1e6) and gamble (0x6fafae85) with complement 0. + */ +export const NpcActionId = { + Trade: 1, + Gamble: 2, + Hire: 3, +} as const + +/** 0x38 EntityAction (13B): u8 0x38 | u32 action | u32 npcId | u32 complement */ +export function encodeC2sNpcEntityAction(action: number, npcId: number, complement: number): Uint8Array { const w = new ByteWriter(13) w.u8(0x38) - w.u32LE(actionType >>> 0) - w.u32LE(npcId >>> 0) - w.u32LE(complement >>> 0) + w.u32LE(requireU32('NpcEntityAction.action', action)) + w.u32LE(requireU32('NpcEntityAction.npcId', npcId)) + w.u32LE(requireU32('NpcEntityAction.complement', complement)) return validateC2sPacket(w.toUint8Array()) } diff --git a/src/netproto/d2gs/c2s/trade.ts b/src/netproto/d2gs/c2s/trade.ts index 405239a..dcafbe0 100644 --- a/src/netproto/d2gs/c2s/trade.ts +++ b/src/netproto/d2gs/c2s/trade.ts @@ -4,6 +4,7 @@ import { encodeC2sClickButton } from './interact.ts' -export function encodeC2sTrade(action: number, gold = 0): Uint8Array { +/** Trade window buttons share 0x4F ClickButton; `gold` is only meaningful for button 0x08 (TradeGold). */ +export function encodeC2sTrade(action: number, gold: number): Uint8Array { return encodeC2sClickButton(action, gold) } diff --git a/src/netproto/d2gs/registry.ts b/src/netproto/d2gs/registry.ts index 1c743fe..af7c72d 100644 --- a/src/netproto/d2gs/registry.ts +++ b/src/netproto/d2gs/registry.ts @@ -40,7 +40,9 @@ import { encodeC2sNpcEntityAction, encodeC2sNpcIdentifyAll, encodeC2sNpcInit, - encodeC2sNpcRepair, + encodeC2sNpcRepairAll, + encodeC2sNpcRepairItem, + NpcActionId, encodeC2sNpcResurrectMerc, encodeC2sNpcSell, } from './c2s/npc.ts' @@ -468,16 +470,20 @@ export function encodeD2gsClientCommand( return [encodeC2sNpcInit(cmd.target)] case 'NpcCancel': return [encodeC2sNpcCancel(cmd.target)] + case 'NpcOpenTrade': + return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)] + case 'NpcOpenGamble': + return [encodeC2sNpcEntityAction(NpcActionId.Gamble, cmd.npcId, 0)] case 'NpcBuy': - return [encodeC2sNpcBuy(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] + return [encodeC2sNpcBuy(cmd)] case 'NpcSell': - return [encodeC2sNpcSell(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] - case 'NpcRepair': - return [encodeC2sNpcRepair(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] + return [encodeC2sNpcSell(cmd)] + case 'NpcRepairItem': + return [encodeC2sNpcRepairItem(cmd)] + case 'NpcRepairAll': + return [encodeC2sNpcRepairAll(cmd.npcId)] case 'NpcHeal': return [encodeC2sNpcInit({ unitType: 1, id: cmd.npcId })] - case 'NpcGamble': - return [encodeC2sNpcEntityAction(2, cmd.npcId, cmd.itemId ?? 0)] case 'NpcIdentifyAll': return [encodeC2sNpcIdentifyAll(cmd.npcId)] case 'NpcResurrectMerc': @@ -493,7 +499,7 @@ export function encodeD2gsClientCommand( case 'TownPortal': return [encodeC2sSkillAt('right', { x: 0, y: 0 })] case 'ClickButton': - return [encodeC2sClickButton(cmd.buttonId, cmd.complement)] + return [encodeC2sClickButton(cmd.buttonId, cmd.amount)] case 'Party': return [encodeC2sParty(cmd.action, cmd.targetPlayerId)] case 'Hostile': diff --git a/src/netproto/d2gs/s2c/npc.ts b/src/netproto/d2gs/s2c/npc.ts index cd4083e..a2ffd8c 100644 --- a/src/netproto/d2gs/s2c/npc.ts +++ b/src/netproto/d2gs/s2c/npc.ts @@ -24,22 +24,30 @@ export function decode0x27NpcInfo(pkt: Uint8Array): ServerEvent { } } -/** 0x2A NPCTransaction (15B, D2Client.dll 0x6fb5f9b0) */ +/** + * 0x2A NPCTransaction (15B). + * + * Server builder D2Game.dll 0x6fcaac90(al=0x2A, cl=result, edx=gold; stack: client, itemId, flag): + * [0]=0x2A [1]=flag [2]=result [3..6]=not written (stack garbage) [7..10]=u32 itemId [11..14]=u32 gold (stat 14). + * Client D2Client.dll 0x6fb5f9b0 -> 0x6fafbc80 copies the packet to 0x6fbc977f and only reads + * result ([0x6fbc9781]) and itemId ([0x6fbc9786]); flag and gold are not consumed by the client + * (inventory gold is driven by stat 14 updates). + */ export function decode0x2ANpcTransaction(pkt: Uint8Array): ServerEvent { + if (pkt.length !== 15) throw new RangeError(`0x2A NPCTransaction: expected 15 bytes, got ${pkt.length}`) const r = new ByteReader(pkt) r.u8() // 0x2A - const tradeType = r.u8() + const flag = r.u8() const result = r.u8() - const npcId = r.u32LE() - const merchandiseId = r.u32LE() - const goldRemaining = r.u32LE() + r.skip(4) // [3..6] uninitialised on the server side + const itemId = r.u32LE() + const gold = r.u32LE() return { - type: 'Npc', - kind: 'transaction', - npcId, - result: (result << 8) | tradeType, - merchandiseId, - goldRemaining, + type: 'NpcTransaction', + result, + flag, + itemId, + gold, } } diff --git a/src/netproto/domain/client-command.ts b/src/netproto/domain/client-command.ts index 80c65a5..87c009f 100644 --- a/src/netproto/domain/client-command.ts +++ b/src/netproto/domain/client-command.ts @@ -50,29 +50,39 @@ export type ClientCommand = | { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number } | { readonly type: 'NpcInit'; readonly target: UnitRef } | { readonly type: 'NpcCancel'; readonly target: UnitRef } + /** 0x38 action 1: open the vendor trade window (server fills the shop with 0x9C AddToShop). */ + | { readonly type: 'NpcOpenTrade'; readonly npcId: number } + /** 0x38 action 2: open the gamble window. */ + | { readonly type: 'NpcOpenGamble'; readonly npcId: number } | { + /** 0x32 BuyItem; field3 = (itemMode << 16) | (gamble ? 2 : 0) | (shiftFill ? 0x80000000 : 0). */ readonly type: 'NpcBuy' readonly npcId: number readonly itemId: number - readonly tab: number + readonly itemMode: number + readonly gamble: boolean + readonly shiftFill: boolean readonly cost: number } | { + /** 0x33 SellItem; field3 = itemMode. */ readonly type: 'NpcSell' readonly npcId: number readonly itemId: number - readonly tab: number + readonly itemMode: number readonly cost: number } | { - readonly type: 'NpcRepair' + /** 0x35 Repair one item: field3 = itemMode, field4 = current durability (stat 72). */ + readonly type: 'NpcRepairItem' readonly npcId: number - readonly itemId?: number | undefined - readonly tab?: number | undefined - readonly cost?: number | undefined + readonly itemId: number + readonly itemMode: number + readonly durability: number } + /** 0x35 Repair all: itemId 0, field3 0, field4 0x80000000. */ + | { readonly type: 'NpcRepairAll'; readonly npcId: number } | { readonly type: 'NpcHeal'; readonly npcId: number } - | { readonly type: 'NpcGamble'; readonly npcId: number; readonly itemId?: number | undefined } | { readonly type: 'NpcIdentifyAll'; readonly npcId: number } | { readonly type: 'NpcResurrectMerc'; readonly npcId: number } | { readonly type: 'HireMerc'; readonly npcId: number; readonly mercId: number } @@ -80,10 +90,11 @@ export type ClientCommand = | { readonly type: 'AddSkillPoint'; readonly skillId: number } | { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number } | { readonly type: 'TownPortal' } - | { readonly type: 'ClickButton'; readonly buttonId: number; readonly complement?: number | undefined } + /** 0x4F ClickButton: see `ClickButtonId` (0x12 close stash, 0x13 withdraw, 0x14 deposit, 0x18 transmute). */ + | { readonly type: 'ClickButton'; readonly buttonId: number; readonly amount: number } | { readonly type: 'Party'; readonly action: number; readonly targetPlayerId: number } | { readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean } - | { readonly type: 'Trade'; readonly action: number; readonly gold?: number | undefined } + | { readonly type: 'Trade'; readonly action: number; readonly gold: number } | { readonly type: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined } | { readonly type: 'OverheadChat'; readonly text: string } | { readonly type: 'Resurrect' } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index e0e9842..7c53e89 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -141,13 +141,20 @@ export type ServerEvent = } | { readonly type: 'Npc' - readonly kind: 'info' | 'menu' | 'transaction' | 'heal' + readonly kind: 'info' | 'menu' | 'heal' readonly npcId: number readonly result?: number | undefined - readonly merchandiseId?: number | undefined - readonly goldRemaining?: number | undefined readonly raw?: Uint8Array | undefined } + | { + /** 0x2A NPCTransaction result (D2Game 0x6fcaac90). `itemId` 0xFFFFFFFF = none. */ + readonly type: 'NpcTransaction' + readonly result: number + readonly flag: number + readonly itemId: number + /** Server's stat 14 at send time; informational only (the client uses stat 14 updates). */ + readonly gold: number + } | { readonly type: 'WaypointMenu' readonly unitId: number diff --git a/src/netproto/index.ts b/src/netproto/index.ts index 884c885..1b4a089 100644 --- a/src/netproto/index.ts +++ b/src/netproto/index.ts @@ -113,6 +113,8 @@ export { decodeBncsS2cPacket } from './bncs/packets.ts' export { McpFramer } from './mcp/framing.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts' export { validateC2sPacket } from './d2gs/framing-c2s.ts' +export { ClickButtonId } from './d2gs/c2s/interact.ts' +export { NpcActionId, NPC_BUY_FLAG_GAMBLE, NPC_BUY_FLAG_SHIFT_FILL, NPC_REPAIR_ALL_FLAG } from './d2gs/c2s/npc.ts' export { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts' export { D2GS_S2C_REGISTRY, diff --git a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts index 4a73c4f..7bdc5d5 100644 --- a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts +++ b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts @@ -1213,9 +1213,16 @@ describe('Tier 5 Adversarial Coverage — F5–F8 D2GS Framing, Huffman, Tables { cmd: { type: 'Interact', target: { unitType: 'object', id: asUnitId(100) } }, expectedOpcodes: [0x13] }, { cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] }, { cmd: { type: 'NpcCancel', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x30] }, - { cmd: { type: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), tab: 0, cost: 500 }, expectedOpcodes: [0x32] }, - { cmd: { type: 'NpcSell', npcId: asUnitId(200), itemId: asUnitId(301), tab: 0, cost: 250 }, expectedOpcodes: [0x33] }, - { cmd: { type: 'NpcRepair', npcId: asUnitId(200), itemId: asUnitId(302), tab: 0, cost: 100 }, expectedOpcodes: [0x35] }, + { + cmd: { type: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), itemMode: 0, gamble: false, shiftFill: false, cost: 500 }, + expectedOpcodes: [0x32], + }, + { cmd: { type: 'NpcSell', npcId: asUnitId(200), itemId: asUnitId(301), itemMode: 0, cost: 250 }, expectedOpcodes: [0x33] }, + { + cmd: { type: 'NpcRepairItem', npcId: asUnitId(200), itemId: asUnitId(302), itemMode: 1, durability: 10 }, + expectedOpcodes: [0x35], + }, + { cmd: { type: 'NpcRepairAll', npcId: asUnitId(200) }, expectedOpcodes: [0x35] }, { cmd: { type: 'WaypointGo', waypointId: asUnitId(400), destAreaId: 3 }, expectedOpcodes: [0x49] }, { cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: false }, expectedOpcodes: [0x16] }, { cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] }, diff --git a/tests/netproto/d2gs-codecs-items.test.ts b/tests/netproto/d2gs-codecs-items.test.ts index aa51ded..c33573b 100644 --- a/tests/netproto/d2gs-codecs-items.test.ts +++ b/tests/netproto/d2gs-codecs-items.test.ts @@ -144,13 +144,16 @@ describe('D2GS 1.13c extracted tables, Huffman codec, S2C/C2S framing & registry { type: 'IdentifyItem', itemId: 107, scrollId: 108 }, { type: 'NpcInit', target: { unitType: 1, id: 15 } }, { type: 'NpcCancel', target: { unitType: 1, id: 15 } }, - { type: 'NpcBuy', npcId: 15, itemId: 200, tab: 0, cost: 50 }, - { type: 'NpcSell', npcId: 15, itemId: 200, tab: 0, cost: 25 }, - { type: 'NpcRepair', npcId: 15, itemId: 0, tab: 0, cost: 0 }, + { type: 'NpcOpenTrade', npcId: 15 }, + { type: 'NpcOpenGamble', npcId: 15 }, + { type: 'NpcBuy', npcId: 15, itemId: 200, itemMode: 0, gamble: false, shiftFill: false, cost: 50 }, + { type: 'NpcSell', npcId: 15, itemId: 200, itemMode: 0, cost: 25 }, + { type: 'NpcRepairItem', npcId: 15, itemId: 200, itemMode: 1, durability: 3 }, + { type: 'NpcRepairAll', npcId: 15 }, { type: 'AddStatPoint', statId: 0, count: 1 }, { type: 'AddSkillPoint', skillId: 36 }, { type: 'WaypointGo', waypointId: 30, destAreaId: 1 }, - { type: 'ClickButton', buttonId: 0, complement: 0 }, + { type: 'ClickButton', buttonId: 0x12, amount: 0 }, { type: 'Party', action: 6, targetPlayerId: 2 }, { type: 'Hostile', targetPlayerId: 2, hostile: true }, { type: 'Trade', action: 2, gold: 0 }, diff --git a/tests/netproto/vendor-stash-golden.test.ts b/tests/netproto/vendor-stash-golden.test.ts new file mode 100644 index 0000000..3e06d93 --- /dev/null +++ b/tests/netproto/vendor-stash-golden.test.ts @@ -0,0 +1,135 @@ +/** + * Byte-level golden tests for NPC vendor / stash gold packets (1.13c). + * + * Layouts are taken from the disassembly (see each encoder/decoder's doc comment): + * - 0x4F ClickButton: D2Game 0x6fcf1470 amount = (WORD[3] << 16) | WORD[5]. + * - 0x32 / 0x33 / 0x35: D2Client 0x6faf6ee0 / 0x6fb46d5f / 0x6faf7dff, D2Game 0x6fcef220 / 0x6fcef0a0. + * - 0x38: D2Game 0x6fce00c0 action 1 = trade, 2 = gamble. + * - 0x2A: D2Game 0x6fcaac90 builder; D2Client 0x6fafbc80 reader. + */ + +import { describe, expect, it } from 'vitest' +import { decode0x2ANpcTransaction } from '../../src/netproto/d2gs/s2c/npc.ts' +import { encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts' +import { ClickButtonId } from '../../src/netproto/d2gs/c2s/interact.ts' + +const bytes = (cmd: Parameters[0]): number[] => { + const pkts = encodeD2gsClientCommand(cmd) + expect(pkts).toHaveLength(1) + return Array.from(pkts[0]!) +} + +describe('C2S 0x4F ClickButton (amount high word first)', () => { + it('deposit 0x00012345 gold -> 4F 14 00 | 01 00 | 45 23', () => { + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount: 0x00012345 })).toEqual([ + 0x4f, 0x14, 0x00, 0x01, 0x00, 0x45, 0x23, + ]) + }) + + it('withdraw 1 gold -> 4F 13 00 | 00 00 | 01 00', () => { + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount: 1 })).toEqual([ + 0x4f, 0x13, 0x00, 0x00, 0x00, 0x01, 0x00, + ]) + }) + + it('close stash / transmute carry amount 0', () => { + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 })).toEqual([ + 0x4f, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.Transmute, amount: 0 })).toEqual([ + 0x4f, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + }) + + it('trade gold reuses the same hi/lo layout (button 0x08)', () => { + expect(bytes({ type: 'Trade', action: ClickButtonId.TradeGold, gold: 2_500_000 })).toEqual([ + // 2_500_000 = 0x002625A0 + 0x4f, 0x08, 0x00, 0x26, 0x00, 0xa0, 0x25, + ]) + }) + + it('rejects non-integer / out-of-range amounts', () => { + expect(() => bytes({ type: 'ClickButton', buttonId: 0x14, amount: -1 })).toThrow(RangeError) + expect(() => bytes({ type: 'ClickButton', buttonId: 0x14, amount: 1.5 })).toThrow(RangeError) + expect(() => bytes({ type: 'ClickButton', buttonId: 0x10000, amount: 0 })).toThrow(RangeError) + }) +}) + +describe('C2S 0x38 NpcEntityAction', () => { + it('trade = action 1, complement 0', () => { + expect(bytes({ type: 'NpcOpenTrade', npcId: 7 })).toEqual([ + 0x38, 0x01, 0, 0, 0, 0x07, 0, 0, 0, 0, 0, 0, 0, + ]) + }) + it('gamble = action 2, complement 0', () => { + expect(bytes({ type: 'NpcOpenGamble', npcId: 0x0102 })).toEqual([ + 0x38, 0x02, 0, 0, 0, 0x02, 0x01, 0, 0, 0, 0, 0, 0, + ]) + }) +}) + +describe('C2S 0x32 NpcBuy field3', () => { + it('plain buy: field3 = itemMode << 16', () => { + expect( + bytes({ type: 'NpcBuy', npcId: 7, itemId: 0x20, itemMode: 0, gamble: false, shiftFill: false, cost: 25 }), + ).toEqual([0x32, 7, 0, 0, 0, 0x20, 0, 0, 0, 0, 0, 0, 0, 25, 0, 0, 0]) + expect( + bytes({ type: 'NpcBuy', npcId: 7, itemId: 0x20, itemMode: 0x0004, gamble: false, shiftFill: false, cost: 25 }), + ).toEqual([0x32, 7, 0, 0, 0, 0x20, 0, 0, 0, 0x00, 0x00, 0x04, 0x00, 25, 0, 0, 0]) + }) + it('gamble sets bit 1, shift-fill sets bit 31', () => { + expect( + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0, gamble: true, shiftFill: false, cost: 0x1234 }), + ).toEqual([0x32, 1, 0, 0, 0, 2, 0, 0, 0, 0x02, 0, 0, 0, 0x34, 0x12, 0, 0]) + expect( + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0, gamble: false, shiftFill: true, cost: 1 }), + ).toEqual([0x32, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0x80, 1, 0, 0, 0]) + }) + it('requires a u16 itemMode and u32 cost', () => { + expect(() => + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0x10000, gamble: false, shiftFill: false, cost: 1 }), + ).toThrow(RangeError) + expect(() => + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0, gamble: false, shiftFill: false, cost: Number.NaN }), + ).toThrow(RangeError) + }) +}) + +describe('C2S 0x33 NpcSell', () => { + it('field3 = itemMode', () => { + expect(bytes({ type: 'NpcSell', npcId: 7, itemId: 0x31, itemMode: 0, cost: 12 })).toEqual([ + 0x33, 7, 0, 0, 0, 0x31, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, + ]) + }) +}) + +describe('C2S 0x35 Repair', () => { + it('single item: itemId, itemMode, current durability (never the repair-all bit)', () => { + expect(bytes({ type: 'NpcRepairItem', npcId: 7, itemId: 0x40, itemMode: 1, durability: 13 })).toEqual([ + 0x35, 7, 0, 0, 0, 0x40, 0, 0, 0, 1, 0, 0, 0, 13, 0, 0, 0, + ]) + expect(() => + bytes({ type: 'NpcRepairItem', npcId: 7, itemId: 0x40, itemMode: 1, durability: 0x80000000 }), + ).toThrow(RangeError) + }) + it('repair all: itemId 0, field3 0, field4 0x80000000', () => { + expect(bytes({ type: 'NpcRepairAll', npcId: 7 })).toEqual([ + 0x35, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x80, + ]) + }) +}) + +describe('S2C 0x2A NPCTransaction', () => { + it('decodes flag@1, result@2, itemId@7, gold@11 and ignores the uninitialised bytes 3..6', () => { + const pkt = new Uint8Array([ + 0x2a, 0x01, 0x02, 0xde, 0xad, 0xbe, 0xef, 0xff, 0xff, 0xff, 0xff, 0x39, 0x30, 0x00, 0x00, + ]) + expect(decode0x2ANpcTransaction(pkt)).toEqual({ + type: 'NpcTransaction', + result: 2, + flag: 1, + itemId: 0xffffffff, + gold: 12345, + }) + }) +}) From d377c0308e33a45a9e0f892df4c8dc3c1f8bee0e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:01:54 +0000 Subject: [PATCH 2/8] test(netproto): vendor/stash/gamble live captures 05-09 and d2-bot vendor scenarios Adds tools/lib/vendor-scenarios.ts (inspect, vendor-open, vendor-buy-sell, vendor-repair, stash-gold, gamble, vendor-verify) driven through MapService pathfinding, plus a --report= flag for d2-bot. Captures recorded with WebBotThree (clvl 1, 2 gold); replay --strict is clean (unknown=0 opaque=0 errors=0). --- .../netproto/05-d2gs-vendor-open.d2cap | 266 +++++++++++++ .../netproto/06-d2gs-vendor-buy-sell.d2cap | 336 ++++++++++++++++ .../netproto/07-d2gs-vendor-repair.d2cap | 294 ++++++++++++++ .../netproto/08-d2gs-stash-gold.d2cap | 212 ++++++++++ tests/fixtures/netproto/09-d2gs-gamble.d2cap | 248 ++++++++++++ tools/d2-bot.ts | 21 +- tools/lib/vendor-scenarios.ts | 372 ++++++++++++++++++ 7 files changed, 1746 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/netproto/05-d2gs-vendor-open.d2cap create mode 100644 tests/fixtures/netproto/06-d2gs-vendor-buy-sell.d2cap create mode 100644 tests/fixtures/netproto/07-d2gs-vendor-repair.d2cap create mode 100644 tests/fixtures/netproto/08-d2gs-stash-gold.d2cap create mode 100644 tests/fixtures/netproto/09-d2gs-gamble.d2cap create mode 100644 tools/lib/vendor-scenarios.ts diff --git a/tests/fixtures/netproto/05-d2gs-vendor-open.d2cap b/tests/fixtures/netproto/05-d2gs-vendor-open.d2cap new file mode 100644 index 0000000..9ff2439 --- /dev/null +++ b/tests/fixtures/netproto/05-d2gs-vendor-open.d2cap @@ -0,0 +1,266 @@ +{"format":"d2cap-v1","createdAt":"2026-10-01T09:50:38.057Z","server":"101.37.117.183 (via=mixed)","account":"","character":"WebBotThree","scenario":"vendor-open"} +{"t":1790848223242,"conn":"bnet","dir":"c2s","hex":"01","packetName":"BNCS_GREETING"} +{"t":1790848223243,"conn":"bnet","dir":"c2s","hex":"ff503a000000000036385849505832440d000000090400007f000001e0010000090400000904000055534100556e697465642053746174657300","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848223289,"conn":"bnet","dir":"s2c","hex":"ff25080070ab8909","packetId":37,"packetName":"SID_PING"} +{"t":1790848223289,"conn":"bnet","dir":"c2s","hex":"ff25080070ab8909","packetId":37,"packetName":"SID_PING"} +{"t":1790848223333,"conn":"bnet","dir":"s2c","hex":"ff5065000000000015d5e7567200000000d8b1d80027dd0149583836766572312e6d707100413d3338343535383136333420423d38383038323335383020433d31333633393337313033203420413d412d5320423d422d4320433d432d4120413d412d4200","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848223334,"conn":"bnet","dir":"c2s","hex":"ff518b001dd6e3ac000d0001cabe867602000000000000001000000006000000cbba0000000000005e34a0be62533f73a5eba13ffc99572a3693cab9100000000a000000f015eb00000000008692ec8d918f275af4efedfef5db36ba24df014a47616d652e6578652030332f30392f31302030343a31303a353120363134343000576562506c6179657200","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848223379,"conn":"bnet","dir":"s2c","hex":"ff5109000000000000","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848223381,"conn":"bnet","dir":"c2s","hex":"ff3a2a001dd6e3ac15d5e756e73b5092f33177f5e41fe489a7e1a152761fc6a76432776562626f743300","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848223425,"conn":"bnet","dir":"s2c","hex":"ff590400","packetId":89,"packetName":"SID_SETEMAIL"} +{"t":1790848223516,"conn":"bnet","dir":"s2c","hex":"ff3a080000000000","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848223517,"conn":"bnet","dir":"c2s","hex":"ff3e26001dd6e3ac3df8a4d24c1491bc6668260c1ab6a97b45355d3153616e63747561727900","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848223563,"conn":"bnet","dir":"s2c","hex":"ff3e56001dd6e3ac000000000000000072000000652575b717e1000015d5e7560000000000000000505832440d00000000000000000000002df7d7a23de439c2be489f4ce012ebabbe6404f26432776562626f743300","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848223956,"conn":"realm","dir":"c2s","hex":"01","packetName":"MCP_GREETING"} +{"t":1790848223957,"conn":"realm","dir":"c2s","hex":"4d00011dd6e3ac00000000000000007200000015d5e7560000000000000000505832440d00000000000000000000002df7d7a23de439c2be489f4ce012ebabbe6404f26432776562626f743300","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848224006,"conn":"realm","dir":"s2c","hex":"07000100000000","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848224006,"conn":"realm","dir":"c2s","hex":"07001908000000","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848224054,"conn":"realm","dir":"s2c","hex":"3d00190800010000000100ffffff7f576562426f745468726565008d80ffffffffff04ff4fffffff05ffffffffffffffffffffff01e080808001ffff00","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848224054,"conn":"realm","dir":"c2s","hex":"0f0007576562426f74546872656500","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848224101,"conn":"realm","dir":"s2c","hex":"07000700000000","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848224103,"conn":"realm","dir":"c2s","hex":"23000301000000000001ff0877626d38336330000064322d626f74207265636f726400","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848224151,"conn":"realm","dir":"s2c","hex":"0d000301000100010000000000","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848224701,"conn":"realm","dir":"c2s","hex":"0e0004020077626d383363300000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848224752,"conn":"realm","dir":"s2c","hex":"150004020044000000652575b785f9f10b00000000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848224924,"conn":"game","dir":"s2c","hex":"af01","packetId":175} +{"t":1790848224924,"conn":"game","dir":"c2s","hex":"6885f9f10b4400040d000000ed4d5650b453354200576562426f7454687265650000000000","packetId":104} +{"t":1790848224974,"conn":"game","dir":"s2c","hex":"0100040030000101","packetId":1} +{"t":1790848224975,"conn":"game","dir":"s2c","hex":"00","packetId":0} +{"t":1790848225055,"conn":"game","dir":"s2c","hex":"02","packetId":2} +{"t":1790848225055,"conn":"game","dir":"c2s","hex":"6b","packetId":107} +{"t":1790848225055,"conn":"game","dir":"c2s","hex":"6d1f4fdff60000000000000000","packetId":109} +{"t":1790848225136,"conn":"game","dir":"s2c","hex":"590100000004576562426f745468726565000000000000000000","packetId":89} +{"t":1790848225136,"conn":"game","dir":"s2c","hex":"aa00010000000c6959f9ff1f","packetId":170} +{"t":1790848225136,"conn":"game","dir":"s2c","hex":"760001000000","packetId":118} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"940a01000000000001020001010001d90001da0001db0001dc0001040001050001030001","packetId":148} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"22000001000000db0001cd00","packetId":34} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"22000001000000d90001cd00","packetId":34} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"5e01010101010101010101010101010101010101010101010101010101010101010101010101","packetId":94} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"28060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848225137,"conn":"game","dir":"s2c","hex":"0b0001000000","packetId":11} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"5f01000000","packetId":95} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848225138,"conn":"game","dir":"s2c","hex":"9c0e1410010000001000a2006508008006170302","packetId":156} +{"t":1790848225139,"conn":"game","dir":"s2c","hex":"9c0e1410020000001000a2006508028006170302","packetId":156} +{"t":1790848225139,"conn":"game","dir":"s2c","hex":"9c0e1410030000001000a2006508048006170302","packetId":156} +{"t":1790848225139,"conn":"game","dir":"s2c","hex":"9c0e1410040000001000a2006508068006170302","packetId":156} +{"t":1790848225139,"conn":"game","dir":"s2c","hex":"9c041410050000001000a2006500724237370602","packetId":156} +{"t":1790848225139,"conn":"game","dir":"s2c","hex":"9c041410060000001000a2006500529236370602","packetId":156} +{"t":1790848225140,"conn":"game","dir":"s2c","hex":"9d061e0507000000000100000011008200658408801686078280c0c1e13f","packetId":157} +{"t":1790848225140,"conn":"game","dir":"s2c","hex":"9d0620060800000000010000001100820065a40a205637068280e0000606ff01","packetId":157} +{"t":1790848225140,"conn":"game","dir":"s2c","hex":"230001000000010000ffffffff","packetId":35} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"230001000000000000ffffffff","packetId":35} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"95370005001700000000000000","packetId":149} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"0300db3a921601003c556b2c","packetId":3} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"53020000000000000000","packetId":83} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075803700401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075003680401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075003700401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075803680401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"076003680401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075003780401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075803700401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"076003700401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"075803780401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"076003780401","packetId":7} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"150001000000d9104c1601","packetId":21} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"7e00000000","packetId":126} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"ac060000009a00b2104116800e01","packetId":172} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"aa01060000000c6959f9ff1f","packetId":170} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"6d06000000b210411680","packetId":109} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"51020d0000002500ac1048160200","packetId":81} +{"t":1790848225141,"conn":"game","dir":"s2c","hex":"0e020d000000030002000000","packetId":14} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"ac050000009800b31059168010112800","packetId":172} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"aa01050000000c6959f9ff1f","packetId":170} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"6d05000000b310591680","packetId":109} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"ac040000009300b4107e16800e01","packetId":172} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"aa01040000000c6959f9ff1f","packetId":170} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"6d04000000b4107e1680","packetId":109} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"51020c0000002500b71075160200","packetId":81} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"0e020c000000030002000000","packetId":14} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"51020b0000002500af105b160200","packetId":81} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"0e020b000000030002000000","packetId":14} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"ac070000009b00d2105316800e01","packetId":172} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"aa01070000000c6959f9ff1f","packetId":170} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"6d07000000d210531680","packetId":109} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"5102110000000b01d2104d160000","packetId":81} +{"t":1790848225142,"conn":"game","dir":"s2c","hex":"5102100000002500ba103b160200","packetId":81} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"0e0210000000030002000000","packetId":14} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"51020f0000002500c81052160200","packetId":81} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"0e020f000000030002000000","packetId":14} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"51020e0000002400d81040160000","packetId":81} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"a800010000000b69acfc0f","packetId":168} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"ac030000009600eb104a16800e01","packetId":172} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"aa01030000000c6959f9ff1f","packetId":170} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"6d03000000eb104a1680","packetId":109} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"ac02000000980004114a168010112800","packetId":172} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"aa01020000000c6959f9ff1f","packetId":170} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"6d0200000004114a1680","packetId":109} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"51020a0000007700f31039160200","packetId":81} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"0e020a000000030102000000","packetId":14} +{"t":1790848225839,"conn":"game","dir":"s2c","hex":"5102090000002500eb1031160200","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"0e0209000000030002000000","packetId":14} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102080000002500e61045160200","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"0e0208000000030002000000","packetId":14} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102070000002500ff103a160200","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"0e0207000000030002000000","packetId":14} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"ac010000009800d3107c168010114800","packetId":172} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"aa01010000000c6959f9ff1f","packetId":170} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"6d01000000d3107c1680","packetId":109} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102060000004e00cb105b160000","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102050000002500d9107b160200","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"0e0205000000030002000000","packetId":14} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102040000002300cc1078160000","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102030000002700dd105a160000","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102020000008101e41059160000","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5102010000002500f4105d160200","packetId":81} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"0e0201000000030002000000","packetId":14} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"04","packetId":4} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5b24000100000004576562426f74546872656500000000000100ffff0000000000000000","packetId":91} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"65010000000000","packetId":101} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"8d01000000ffff","packetId":141} +{"t":1790848225840,"conn":"game","dir":"s2c","hex":"5a02040000000000576562426f74546872656500000000006432776562626f743300000000000000","packetId":90} +{"t":1790848226054,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848226055,"conn":"game","dir":"s2c","hex":"670700000001d41051160100074b0005","packetId":103} +{"t":1790848226055,"conn":"game","dir":"s2c","hex":"670300000001ee1055160100074b0005","packetId":103} +{"t":1790848226331,"conn":"game","dir":"s2c","hex":"670600000001b61044160100074b0005","packetId":103} +{"t":1790848226331,"conn":"game","dir":"s2c","hex":"670400000001ba107b160100074b0005","packetId":103} +{"t":1790848226851,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848226851,"conn":"game","dir":"s2c","hex":"670700000001d51050160100074b0005","packetId":103} +{"t":1790848227091,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff63385b443247535d202d204e6f20636f6d6d65726369616c20707572706f736520697320616c6c6f77656421ff633400","packetId":38} +{"t":1790848227092,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d0048656c6c6f20776f726c642100","packetId":38} +{"t":1790848227092,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff633954686520576f726c64204576656e7420656e61626c6573206f6e2074686973207365727665722e20546865206b6579206974656d20697320224b6579222eff633100","packetId":38} +{"t":1790848227292,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848227292,"conn":"game","dir":"s2c","hex":"670700000001d6104f160100074b0005","packetId":103} +{"t":1790848227411,"conn":"game","dir":"s2c","hex":"6d06000000b610441680","packetId":109} +{"t":1790848227446,"conn":"game","dir":"c2s","hex":"03d8104a16","packetId":3} +{"t":1790848227731,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848227731,"conn":"game","dir":"s2c","hex":"670700000001d7104e160100074b0005","packetId":103} +{"t":1790848227731,"conn":"game","dir":"s2c","hex":"965c006c08250b0000","packetId":150} +{"t":1790848227762,"conn":"game","dir":"c2s","hex":"03d7104816","packetId":3} +{"t":1790848227772,"conn":"game","dir":"s2c","hex":"6d04000000ba107b1680","packetId":109} +{"t":1790848228077,"conn":"game","dir":"c2s","hex":"03d5104516","packetId":3} +{"t":1790848228171,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848228172,"conn":"game","dir":"s2c","hex":"670700000001d6104b160100074b0005","packetId":103} +{"t":1790848228583,"conn":"game","dir":"c2s","hex":"03d4104316","packetId":3} +{"t":1790848228898,"conn":"game","dir":"c2s","hex":"03d1104316","packetId":3} +{"t":1790848229092,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848229092,"conn":"game","dir":"s2c","hex":"670700000001d51048160100074b0005","packetId":103} +{"t":1790848229320,"conn":"game","dir":"c2s","hex":"03cf104316","packetId":3} +{"t":1790848229601,"conn":"game","dir":"c2s","hex":"03cc104316","packetId":3} +{"t":1790848229651,"conn":"game","dir":"s2c","hex":"6d03000000ee10551680","packetId":109} +{"t":1790848230022,"conn":"game","dir":"c2s","hex":"03c9104316","packetId":3} +{"t":1790848230052,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848230052,"conn":"game","dir":"s2c","hex":"670700000001d41046160100074b0005","packetId":103} +{"t":1790848230055,"conn":"game","dir":"c2s","hex":"6da762dff60000000000000000","packetId":109} +{"t":1790848230131,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848230443,"conn":"game","dir":"c2s","hex":"03c6104316","packetId":3} +{"t":1790848230691,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848230692,"conn":"game","dir":"s2c","hex":"670700000001d11047160100074b0005","packetId":103} +{"t":1790848230692,"conn":"game","dir":"s2c","hex":"965c006388210b0000","packetId":150} +{"t":1790848230864,"conn":"game","dir":"c2s","hex":"03c4104416","packetId":3} +{"t":1790848231179,"conn":"game","dir":"c2s","hex":"03c1104416","packetId":3} +{"t":1790848231599,"conn":"game","dir":"c2s","hex":"03be104416","packetId":3} +{"t":1790848231731,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848231731,"conn":"game","dir":"s2c","hex":"670700000001ce1047160100074b0005","packetId":103} +{"t":1790848232020,"conn":"game","dir":"c2s","hex":"03bb104416","packetId":3} +{"t":1790848232441,"conn":"game","dir":"c2s","hex":"03b9104416","packetId":3} +{"t":1790848232571,"conn":"game","dir":"s2c","hex":"670700000001db1050160100074b0005","packetId":103} +{"t":1790848232722,"conn":"game","dir":"c2s","hex":"03b6104416","packetId":3} +{"t":1790848233144,"conn":"game","dir":"c2s","hex":"130100000006000000","packetId":19} +{"t":1790848233211,"conn":"game","dir":"s2c","hex":"27010600000001000000250000000000000000000000000000000000000000000000000000000000","packetId":39} +{"t":1790848233212,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848233212,"conn":"game","dir":"s2c","hex":"28010600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848233212,"conn":"game","dir":"s2c","hex":"6d06000000b610441680","packetId":109} +{"t":1790848233811,"conn":"game","dir":"s2c","hex":"6d06000000b610441680","packetId":109} +{"t":1790848233845,"conn":"game","dir":"c2s","hex":"2f0100000006000000","packetId":47} +{"t":1790848234452,"conn":"game","dir":"s2c","hex":"6d06000000b610441680","packetId":109} +{"t":1790848234452,"conn":"game","dir":"s2c","hex":"670300000001e71054160100074b0005","packetId":103} +{"t":1790848234452,"conn":"game","dir":"s2c","hex":"965c005c08220b0000","packetId":150} +{"t":1790848234547,"conn":"game","dir":"c2s","hex":"38010000000600000000000000","packetId":56} +{"t":1790848234651,"conn":"game","dir":"s2c","hex":"9c0b19050900000010208000650000c41686070283e0e1e13f","packetId":156} +{"t":1790848234652,"conn":"game","dir":"s2c","hex":"9c0b1f050a00000010208000650060c416860702030100707978801120e03f","packetId":156} +{"t":1790848234652,"conn":"game","dir":"s2c","hex":"9c0b1a050b000000102880006500c0340737060283404242fe03","packetId":156} +{"t":1790848234652,"conn":"game","dir":"s2c","hex":"9c0b1f050c000000102080006500c2340737060203a10b00909088f0e0c17f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1e050d000000102080006500043437470602c3508c0d13065832fa0f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1e050e000000102080006500643437470602030100c96060e037fc07","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b19050f000000102080006500c43437d60602836061e13f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b200510000000102080006500063437d6060203a18b6f5858a8105808fc07","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b190511000000102080006500664476260702830001e13f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b210512000000102080006500a6447626070203b10bc9404088d0a103bfe13f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1a05130000001020800065000048b766060283404000d47f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1a05140000001020800065004048b766060283404000d47f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1a051500000010208000650080a81666070283202080c77f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1a0516000000102080006500e0a81666070283202080c77f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1a051700000010208000650002a81666070283202080c77f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1a051800000010288000650008340727070283e0e141fe03","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b200519000000102080006500883407270702030100587878b0111040e63f","packetId":156} +{"t":1790848234653,"conn":"game","dir":"s2c","hex":"9c0b1c051a0000001020800065000c2416260702c30019191304f80f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1e051b0000001020800065008c241626070203f10e00c8c898e0c17f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1a061c0000001028800065001034277607028340b160fe03","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b20061d00000010208000650070342776070203f18e61504c98600186c07f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1a061e000000102880006500d084267607028300e221fe03","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b20061f000000102080006500008626760702030100588064b0111040e63f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1a052000000010288000650060c68627060283e09161fe03","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1e0521000000102080006500c0c6862706020301806f7870b820fc07","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b190522000000102080006500e6b446270702830003e33f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b21052300000010208000650004b64627070203010075c0c0c88101064bfe03","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1b002400000010208000650000321606070283d0000606ff01","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1f0025000000102080006500403216060702030100c934808101bfe13f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1b00260000001020800065008032b70607028350010909ff01","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1d0127000000102880006500c01257970602c3c00950589025a2ff","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1f0128000000102080006500041257970602030100bd548082028090ff","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1b012900000010208000650064c25616060283b0010c0cff01","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1b012a000000102080006500c482c61606028310020e0eff01","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1b072b00000010208000650008225637060283f0000606ff01","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b21072c000000102080006500482256370602030100564080810105c5cce83f","packetId":156} +{"t":1790848234654,"conn":"game","dir":"s2c","hex":"9c0b1f072d0000001020800065008832d7c60602c3f00a404880d060c9e83f","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b1f072e000000102080006500c832d7c6060203a12900480002428a9bff","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b1b102f00000010208000650008c376c6060283d0000606ff01","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b2210300000001020800065000cc276c606020301096e3980810180100818fe03","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b1f10310000001020800065004cc226460702c37007303480506089e73f","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b2110320000001020800065008cc2264607020361296d3580818180100785ff","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b1b103300000010208000650010c226c6060283c0000606ff01","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b22103400000010208000650030c226c6060203418d9c308081c181110b4cfe03","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b18103500000010208000650062181667070283e0f53f","packetId":156} +{"t":1790848234655,"conn":"game","dir":"s2c","hex":"9c0b181036000000102080006500c2381667070283a0ef3f","packetId":156} +{"t":1790848235051,"conn":"game","dir":"s2c","hex":"6d06000000b610441680","packetId":109} +{"t":1790848235057,"conn":"game","dir":"c2s","hex":"6d3176dff60000000000000000","packetId":109} +{"t":1790848235131,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848235951,"conn":"game","dir":"c2s","hex":"300100000006000000","packetId":48} +{"t":1790848236411,"conn":"game","dir":"s2c","hex":"6d04000000ba107b1680","packetId":109} +{"t":1790848236452,"conn":"game","dir":"s2c","hex":"6d03000000e710541680","packetId":109} +{"t":1790848237011,"conn":"game","dir":"s2c","hex":"6d04000000ba107b1680","packetId":109} +{"t":1790848237411,"conn":"game","dir":"s2c","hex":"6d07000000db10501680","packetId":109} +{"t":1790848237651,"conn":"game","dir":"s2c","hex":"6d04000000ba107b1680","packetId":109} +{"t":1790848237756,"conn":"game","dir":"c2s","hex":"69","packetId":105} diff --git a/tests/fixtures/netproto/06-d2gs-vendor-buy-sell.d2cap b/tests/fixtures/netproto/06-d2gs-vendor-buy-sell.d2cap new file mode 100644 index 0000000..d0b6ce1 --- /dev/null +++ b/tests/fixtures/netproto/06-d2gs-vendor-buy-sell.d2cap @@ -0,0 +1,336 @@ +{"format":"d2cap-v1","createdAt":"2026-10-01T09:52:18.705Z","server":"101.37.117.183 (via=mixed)","account":"","character":"WebBotThree","scenario":"vendor-buy-sell"} +{"t":1790848317843,"conn":"bnet","dir":"c2s","hex":"01","packetName":"BNCS_GREETING"} +{"t":1790848317844,"conn":"bnet","dir":"c2s","hex":"ff503a000000000036385849505832440d000000090400007f000001e0010000090400000904000055534100556e697465642053746174657300","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848317894,"conn":"bnet","dir":"s2c","hex":"ff250800fb1c8b09","packetId":37,"packetName":"SID_PING"} +{"t":1790848317894,"conn":"bnet","dir":"c2s","hex":"ff250800fb1c8b09","packetId":37,"packetName":"SID_PING"} +{"t":1790848317940,"conn":"bnet","dir":"s2c","hex":"ff50650000000000482f61127300000000d8b1d80027dd0149583836766572312e6d707100413d3338343535383136333420423d38383038323335383020433d31333633393337313033203420413d412d5320423d422d4320433d432d4120413d412d4200","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848317942,"conn":"bnet","dir":"c2s","hex":"ff518b008427dcac000d0001cabe867602000000000000001000000006000000cbba0000000000009bfa38708d9c8ec62d2eb3798e0172bc66eef0a7100000000a000000f015eb00000000003470e7d9e623cd2811c464c2a4198aeee66f618147616d652e6578652030332f30392f31302030343a31303a353120363134343000576562506c6179657200","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848317990,"conn":"bnet","dir":"s2c","hex":"ff5109000000000000","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848317992,"conn":"bnet","dir":"c2s","hex":"ff3a2a008427dcac482f611252f93cb37bff7e969364d4db3dd585521bbd59966432776562626f743300","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848318040,"conn":"bnet","dir":"s2c","hex":"ff590400","packetId":89,"packetName":"SID_SETEMAIL"} +{"t":1790848318135,"conn":"bnet","dir":"s2c","hex":"ff3a080000000000","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848318136,"conn":"bnet","dir":"c2s","hex":"ff3e26008427dcac7f9417da517919013be9becf8e767c5db81fb8ae53616e63747561727900","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848318185,"conn":"bnet","dir":"s2c","hex":"ff3e56008427dcac000000000000000073000000652575b717e10000482f61120000000000000000505832440d0000000000000000000000d7612625712d28d4df5beda604e8fc44d065e32d6432776562626f743300","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848318582,"conn":"realm","dir":"c2s","hex":"01","packetName":"MCP_GREETING"} +{"t":1790848318583,"conn":"realm","dir":"c2s","hex":"4d00018427dcac000000000000000073000000482f61120000000000000000505832440d0000000000000000000000d7612625712d28d4df5beda604e8fc44d065e32d6432776562626f743300","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848318630,"conn":"realm","dir":"s2c","hex":"07000100000000","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848318631,"conn":"realm","dir":"c2s","hex":"07001908000000","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848318675,"conn":"realm","dir":"s2c","hex":"3d00190800010000000100ffffff7f576562426f745468726565008d80ffffffffff04ff4fffffff05ffffffffffffffffffffff01e080808001ffff00","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848318676,"conn":"realm","dir":"c2s","hex":"0f0007576562426f74546872656500","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848318720,"conn":"realm","dir":"s2c","hex":"07000700000000","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848318722,"conn":"realm","dir":"c2s","hex":"23000301000000000001ff0877626d3833656d000064322d626f74207265636f726400","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848318770,"conn":"realm","dir":"s2c","hex":"0d000301000100010000000000","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848319353,"conn":"realm","dir":"c2s","hex":"0e0004020077626d3833656d0000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848319400,"conn":"realm","dir":"s2c","hex":"150004020045000000652575b7c5ef242b00000000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848319583,"conn":"game","dir":"s2c","hex":"af01","packetId":175} +{"t":1790848319584,"conn":"game","dir":"c2s","hex":"68c5ef242b4500040d000000ed4d5650b453354200576562426f7454687265650000000000","packetId":104} +{"t":1790848319700,"conn":"game","dir":"s2c","hex":"0100040030000101","packetId":1} +{"t":1790848319700,"conn":"game","dir":"s2c","hex":"00","packetId":0} +{"t":1790848319700,"conn":"game","dir":"s2c","hex":"02","packetId":2} +{"t":1790848319700,"conn":"game","dir":"c2s","hex":"6b","packetId":107} +{"t":1790848319701,"conn":"game","dir":"c2s","hex":"6dd5c0e0f60000000000000000","packetId":109} +{"t":1790848319780,"conn":"game","dir":"s2c","hex":"590100000004576562426f745468726565000000000000000000","packetId":89} +{"t":1790848319780,"conn":"game","dir":"s2c","hex":"aa00010000000c6959f9ff1f","packetId":170} +{"t":1790848319780,"conn":"game","dir":"s2c","hex":"760001000000","packetId":118} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"940a01000000000001020001010001d90001da0001db0001dc0001040001050001030001","packetId":148} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"22000001000000db0001cd00","packetId":34} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"22000001000000d90001cd00","packetId":34} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"5e01010101010101010101010101010101010101010101010101010101010101010101010101","packetId":94} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"28060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"0b0001000000","packetId":11} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"5f01000000","packetId":95} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848319781,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848319782,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848319782,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848319782,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848319782,"conn":"game","dir":"s2c","hex":"9c0e1410010000001000a2006508008006170302","packetId":156} +{"t":1790848319783,"conn":"game","dir":"s2c","hex":"9c0e1410020000001000a2006508028006170302","packetId":156} +{"t":1790848319783,"conn":"game","dir":"s2c","hex":"9c0e1410030000001000a2006508048006170302","packetId":156} +{"t":1790848319783,"conn":"game","dir":"s2c","hex":"9c0e1410040000001000a2006508068006170302","packetId":156} +{"t":1790848319783,"conn":"game","dir":"s2c","hex":"9c041410050000001000a2006500724237370602","packetId":156} +{"t":1790848319783,"conn":"game","dir":"s2c","hex":"9c041410060000001000a2006500529236370602","packetId":156} +{"t":1790848319784,"conn":"game","dir":"s2c","hex":"9d061e0507000000000100000011008200658408801686078280c0c1e13f","packetId":157} +{"t":1790848319784,"conn":"game","dir":"s2c","hex":"9d0620060800000000010000001100820065a40a205637068280e0000606ff01","packetId":157} +{"t":1790848319784,"conn":"game","dir":"s2c","hex":"230001000000010000ffffffff","packetId":35} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"230001000000000000ffffffff","packetId":35} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"95370005001700000000000000","packetId":149} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"03000396080501009f7a5275","packetId":3} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"53020000000000000000","packetId":83} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078804c00301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078004b80302","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078004c00301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078804b80302","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078004c80301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"079004b80302","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078804c00301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"079004c00301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"078804c80301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"079004c80301","packetId":7} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"150001000000b516e61201","packetId":21} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"7e00000000","packetId":126} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848319785,"conn":"game","dir":"s2c","hex":"ac060000009a008e16db12800e01","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01060000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d060000008e16db1280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"51020f0000007700a216c9120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e020f000000030102000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"51020e00000025009616d5120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e020e000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"51020d00000025008816e2120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e020d000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"51020c00000024009416e9120000","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"51020b00000025009016f5120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e020b000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"51020a0000002500a416ec120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e020a000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac080000009800ce16d7128010114800","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01080000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d08000000ce16d71280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac070000009800af16d4128010116000","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01070000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d07000000af16d41280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102130000002500ce16c2120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0213000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102120000002500be16d3120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0212000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102110000000b01ae16e7120000","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102100000002500b716de120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0210000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"a800010000000b69acfc0f","packetId":168} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac050000009400f016d012800e01","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01050000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d05000000f016d01280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102090000002500e916d4120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0209000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102080000002500db16d3120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0208000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102070000002500f616cb120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0207000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102060000002500d116de120200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0206000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac040000009b00ae16ed12800e01","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01040000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d04000000ae16ed1280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac030000009600c916ee12800e01","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01030000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d03000000c916ee1280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102050000008101c016f3120000","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102040000002700b916f4120000","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102030000004e00a816f4120000","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac020000009800e116ea128010114000","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01020000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d02000000e116ea1280","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"ac010000009800e11608138010116000","packetId":172} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"aa01010000000c6959f9ff1f","packetId":170} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"6d01000000e116081380","packetId":109} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102020000002300e716fe120000","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"5102010000002500f41602130200","packetId":81} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"0e0201000000030002000000","packetId":14} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848319786,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848319787,"conn":"game","dir":"s2c","hex":"04","packetId":4} +{"t":1790848319787,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848319787,"conn":"game","dir":"s2c","hex":"5b24000100000004576562426f74546872656500000000000100ffff0000000000000000","packetId":91} +{"t":1790848319787,"conn":"game","dir":"s2c","hex":"65010000000000","packetId":101} +{"t":1790848319787,"conn":"game","dir":"s2c","hex":"8d01000000ffff","packetId":141} +{"t":1790848319787,"conn":"game","dir":"s2c","hex":"5a02040000000000576562426f74546872656500000000006432776562626f743300000000000000","packetId":90} +{"t":1790848320660,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848320660,"conn":"game","dir":"s2c","hex":"6706000000018f16d5120100074b0005","packetId":103} +{"t":1790848320660,"conn":"game","dir":"s2c","hex":"670500000001eb16d5120100074b0005","packetId":103} +{"t":1790848320660,"conn":"game","dir":"s2c","hex":"670400000001b016eb120100074b0005","packetId":103} +{"t":1790848320660,"conn":"game","dir":"s2c","hex":"670300000001cd16f6120100074b0005","packetId":103} +{"t":1790848321499,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848321499,"conn":"game","dir":"s2c","hex":"670400000001b116ea120100074b0005","packetId":103} +{"t":1790848321699,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff63385b443247535d202d204e6f20636f6d6d65726369616c20707572706f736520697320616c6c6f77656421ff633400","packetId":38} +{"t":1790848321700,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d0048656c6c6f20776f726c642100","packetId":38} +{"t":1790848321700,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff633954686520576f726c64204576656e7420656e61626c6573206f6e2074686973207365727665722e20546865206b6579206974656d20697320224b6579222eff633100","packetId":38} +{"t":1790848321939,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848321939,"conn":"game","dir":"s2c","hex":"670400000001b216e9120100074b0005","packetId":103} +{"t":1790848322019,"conn":"game","dir":"s2c","hex":"6d060000008f16d51280","packetId":109} +{"t":1790848322032,"conn":"game","dir":"c2s","hex":"03b716e412","packetId":3} +{"t":1790848322379,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848322379,"conn":"game","dir":"s2c","hex":"670400000001b316e8120100074b0005","packetId":103} +{"t":1790848322428,"conn":"game","dir":"c2s","hex":"03b916e212","packetId":3} +{"t":1790848322819,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848322819,"conn":"game","dir":"s2c","hex":"670400000001b516e6120100074b0005","packetId":103} +{"t":1790848322819,"conn":"game","dir":"s2c","hex":"965c805c0b71090000","packetId":150} +{"t":1790848322827,"conn":"game","dir":"c2s","hex":"03bc16e112","packetId":3} +{"t":1790848323259,"conn":"game","dir":"s2c","hex":"6d03000000cd16f61280","packetId":109} +{"t":1790848323271,"conn":"game","dir":"c2s","hex":"03be16df12","packetId":3} +{"t":1790848323659,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848323659,"conn":"game","dir":"s2c","hex":"670400000001b716e4120100074b0005","packetId":103} +{"t":1790848323667,"conn":"game","dir":"c2s","hex":"03c016dd12","packetId":3} +{"t":1790848324065,"conn":"game","dir":"c2s","hex":"03c316dc12","packetId":3} +{"t":1790848324499,"conn":"game","dir":"s2c","hex":"8a0104000000","packetId":138} +{"t":1790848324499,"conn":"game","dir":"s2c","hex":"670400000001b916e2120100074b0005","packetId":103} +{"t":1790848324509,"conn":"game","dir":"c2s","hex":"03c616dc12","packetId":3} +{"t":1790848324701,"conn":"game","dir":"c2s","hex":"6d5cd4e0f60000000000000000","packetId":109} +{"t":1790848324778,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848324929,"conn":"game","dir":"c2s","hex":"03c916db12","packetId":3} +{"t":1790848325338,"conn":"game","dir":"s2c","hex":"670400000001b416f5120100074b0005","packetId":103} +{"t":1790848325373,"conn":"game","dir":"c2s","hex":"03cb16db12","packetId":3} +{"t":1790848325654,"conn":"game","dir":"c2s","hex":"03ce16da12","packetId":3} +{"t":1790848326098,"conn":"game","dir":"c2s","hex":"03d116da12","packetId":3} +{"t":1790848326138,"conn":"game","dir":"s2c","hex":"965c00670b6d090000","packetId":150} +{"t":1790848326299,"conn":"game","dir":"s2c","hex":"079804b80302","packetId":7} +{"t":1790848326299,"conn":"game","dir":"s2c","hex":"079804c00301","packetId":7} +{"t":1790848326299,"conn":"game","dir":"s2c","hex":"079804c80301","packetId":7} +{"t":1790848326299,"conn":"game","dir":"s2c","hex":"088004b80302","packetId":8} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a0106000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a020f000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a020e000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a020d000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"088004c00301","packetId":8} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a020c000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a020b000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0a020a000000","packetId":10} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"088004c80301","packetId":8} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"ac09000000b3000417cd12800e01","packetId":172} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"aa01090000000c6959f5ff1f","packetId":170} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"6d090000000417cd1280","packetId":109} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"5102140000002500f916ee120200","packetId":81} +{"t":1790848326300,"conn":"game","dir":"s2c","hex":"0e0214000000030002000000","packetId":14} +{"t":1790848326519,"conn":"game","dir":"c2s","hex":"03d416d912","packetId":3} +{"t":1790848326738,"conn":"game","dir":"s2c","hex":"6d05000000eb16d51280","packetId":109} +{"t":1790848326963,"conn":"game","dir":"c2s","hex":"03d716d912","packetId":3} +{"t":1790848327384,"conn":"game","dir":"c2s","hex":"03da16d812","packetId":3} +{"t":1790848327829,"conn":"game","dir":"c2s","hex":"03dd16d812","packetId":3} +{"t":1790848327898,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848327899,"conn":"game","dir":"s2c","hex":"670500000001e816d6120100074b0005","packetId":103} +{"t":1790848328249,"conn":"game","dir":"c2s","hex":"03e016d712","packetId":3} +{"t":1790848328693,"conn":"game","dir":"c2s","hex":"03e216d712","packetId":3} +{"t":1790848328974,"conn":"game","dir":"c2s","hex":"03e516d612","packetId":3} +{"t":1790848329418,"conn":"game","dir":"c2s","hex":"03e816d612","packetId":3} +{"t":1790848329701,"conn":"game","dir":"c2s","hex":"6de5e7e0f60000000000000000","packetId":109} +{"t":1790848329819,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848329819,"conn":"game","dir":"s2c","hex":"965c80730b6b090000","packetId":150} +{"t":1790848329839,"conn":"game","dir":"c2s","hex":"03eb16d512","packetId":3} +{"t":1790848330178,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848330179,"conn":"game","dir":"s2c","hex":"6d05000000e916d61280","packetId":109} +{"t":1790848330179,"conn":"game","dir":"s2c","hex":"2c01050000001200","packetId":44} +{"t":1790848330282,"conn":"game","dir":"c2s","hex":"130100000005000000","packetId":19} +{"t":1790848330339,"conn":"game","dir":"s2c","hex":"27010500000002000000400000000b00000000000000000000000000000000000000000000000000","packetId":39} +{"t":1790848330339,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848330339,"conn":"game","dir":"s2c","hex":"28010500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848330339,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848330339,"conn":"game","dir":"s2c","hex":"6d05000000e916d61280","packetId":109} +{"t":1790848330939,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848330939,"conn":"game","dir":"s2c","hex":"6d05000000e916d61280","packetId":109} +{"t":1790848330983,"conn":"game","dir":"c2s","hex":"2f0100000005000000","packetId":47} +{"t":1790848331098,"conn":"game","dir":"s2c","hex":"6d04000000b416f51280","packetId":109} +{"t":1790848331219,"conn":"game","dir":"s2c","hex":"670300000001d016f3120100074b0005","packetId":103} +{"t":1790848331539,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848331539,"conn":"game","dir":"s2c","hex":"6d05000000e916d61280","packetId":109} +{"t":1790848331684,"conn":"game","dir":"c2s","hex":"38010000000500000000000000","packetId":56} +{"t":1790848331739,"conn":"game","dir":"s2c","hex":"9c0b1e05090000001020800065000074e746060283f0f0608d90ac59a2ff","packetId":156} +{"t":1790848331740,"conn":"game","dir":"s2c","hex":"9c0b1c050a0000001020800065004074e746060283f0f060cd90fc07","packetId":156} +{"t":1790848331740,"conn":"game","dir":"s2c","hex":"9c0b19050b0000001020800065008074e746060283f0f0e03f","packetId":156} +{"t":1790848331740,"conn":"game","dir":"s2c","hex":"9c0b1e050c000000102080006500c074e7460602c390870716026b86e43f","packetId":156} +{"t":1790848331740,"conn":"game","dir":"s2c","hex":"9c0b1c050d0000001020800065000075e746060283f0f0604d91fc07","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b19050e0000001020800065000274e746060283f0f0e03f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b20050f0000001020800065004274e7460602035118b03c3c4881c3a3f01f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b2005100000001020800065008274e7460602039199b33c3c6881e3a3f01f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b210511000000102080006500c274e7460602030180b33c3cf041584324ff01","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b2305120000001020800065000275e7460602031113573c3c489092c6b3a688fe03","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b1e05130000001020800065000474e7460602030100933c3c0840fa0f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b2105140000001020800065004474e7460602030100933c3c08505a3324ff01","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b2105150000001020800065008474e7460602030100573c3c48e3595324ff01","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b1e0516000000102080006500c474e7460602030100b03c3ce041f80f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b2705170000001020800065000475e746060203111a9b3c3c98f040965a23246b864451e03f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b190518000000102080006500063437060702832023e33f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b210519000000102080006500663437060702832023630d18ad11a33568f21f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b19051a000000102080006500c63437060702832023e33f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b23051b00000010208000650008343706070203010062c8c8b0205803266bcae43f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b1e051c000000102080006500683437060702030100c9c8c8e037fc07","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b1e051d000000102080006500c8343747070283404161cd89ac4991ff","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b19051e0000001020800065000a3437470702834041e13f","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b1a051f0000001028800065006a343747070283404121fe03","packetId":156} +{"t":1790848331741,"conn":"game","dir":"s2c","hex":"9c0b1c0520000000102080006500ca343747070283404161cd89fc07","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1e05210000001020800065000c343747070283404161cd89ac5191ff","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1c05220000001028800065006c343747070283404141d6b4c87f","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b190523000000102080006500cc3437470702834041e13f","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1905240000001020800065000e3437470702834041e13f","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1c05250000001020800065006e343747070283404161cd89fc07","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b260526000000102080006500ce34374707020311130050504890b28644d694c81a13f90f","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b230527000000102080006500103437470702030180d15050587322c306120aff01","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b2a052800000010208000650070343747070203711a93505008605a53226b4c640d0ae501b1e793ff","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b210529000000102080006500d034374707020351180050504891b30685fe03","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b24052a0000001020800065001234374707020301805b5050801120608d89ac4991ff","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b14102b0000001020a0006500126807370702","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b14102c0000001020a0006500329807370702","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b14102d0000001020a00065005278d7360702","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b19102e000000102080006500004827b60602830002fc07","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b19102f000000102080006500409826b60602831006fc07","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1410300000001020a0006500724837370602","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1410310000001020a0006500929836370602","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b181032000000102080006500b2b8569607028320e03f","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1410330000001020a0006500d28806170302","packetId":156} +{"t":1790848331742,"conn":"game","dir":"s2c","hex":"9c0b1410340000001020a0006500f2d806170302","packetId":156} +{"t":1790848332139,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848332140,"conn":"game","dir":"s2c","hex":"6d05000000e916d61280","packetId":109} +{"t":1790848332459,"conn":"game","dir":"s2c","hex":"6d03000000d016f31280","packetId":109} +{"t":1790848332779,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848332987,"conn":"game","dir":"c2s","hex":"3305000000050000000000000001000000","packetId":51} +{"t":1790848333059,"conn":"game","dir":"s2c","hex":"2200bb01000000db0000cb00","packetId":34} +{"t":1790848333059,"conn":"game","dir":"s2c","hex":"9d0519100500000000010000003000a2006500724237370602","packetId":157} +{"t":1790848333059,"conn":"game","dir":"s2c","hex":"2a03010200008e0500000001000000","packetId":42} +{"t":1790848333059,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848333379,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848333379,"conn":"game","dir":"s2c","hex":"1901","packetId":25} +{"t":1790848333689,"conn":"game","dir":"c2s","hex":"3305000000060000000000000001000000","packetId":51} +{"t":1790848333699,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848333779,"conn":"game","dir":"s2c","hex":"2200bb01000000d90000cb00","packetId":34} +{"t":1790848333779,"conn":"game","dir":"s2c","hex":"9d0519100600000000010000003000a2006500529236370602","packetId":157} +{"t":1790848333779,"conn":"game","dir":"s2c","hex":"2a03010200008e0600000002000000","packetId":42} +{"t":1790848333779,"conn":"game","dir":"s2c","hex":"1901","packetId":25} +{"t":1790848334018,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848334379,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848334392,"conn":"game","dir":"c2s","hex":"3205000000330000000000000001000000","packetId":50} +{"t":1790848334499,"conn":"game","dir":"s2c","hex":"2a000c00020000ffffffff02000000","packetId":42} +{"t":1790848334659,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848334701,"conn":"game","dir":"c2s","hex":"6d6dfbe0f60000000000000000","packetId":109} +{"t":1790848334779,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848334979,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848335195,"conn":"game","dir":"c2s","hex":"3205000000340000000000000001000000","packetId":50} +{"t":1790848335259,"conn":"game","dir":"s2c","hex":"2a000c00020000ffffffff02000000","packetId":42} +{"t":1790848335299,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848335619,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848335898,"conn":"game","dir":"c2s","hex":"3205000000330000000000008001000000","packetId":50} +{"t":1790848335938,"conn":"game","dir":"s2c","hex":"6d04000000b416f51280","packetId":109} +{"t":1790848335939,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848335979,"conn":"game","dir":"s2c","hex":"2a000c00020000ffffffff02000000","packetId":42} +{"t":1790848336259,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848336579,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848336599,"conn":"game","dir":"c2s","hex":"300100000005000000","packetId":48} +{"t":1790848336939,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848336939,"conn":"game","dir":"s2c","hex":"6d04000000b416f51280","packetId":109} +{"t":1790848337259,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848337259,"conn":"game","dir":"s2c","hex":"670300000001c516f0120100074b0005","packetId":103} +{"t":1790848337539,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848337899,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848337899,"conn":"game","dir":"s2c","hex":"6d04000000b416f51280","packetId":109} +{"t":1790848338179,"conn":"game","dir":"s2c","hex":"8a0105000000","packetId":138} +{"t":1790848338402,"conn":"game","dir":"c2s","hex":"69","packetId":105} diff --git a/tests/fixtures/netproto/07-d2gs-vendor-repair.d2cap b/tests/fixtures/netproto/07-d2gs-vendor-repair.d2cap new file mode 100644 index 0000000..455cfc2 --- /dev/null +++ b/tests/fixtures/netproto/07-d2gs-vendor-repair.d2cap @@ -0,0 +1,294 @@ +{"format":"d2cap-v1","createdAt":"2026-10-01T09:55:55.592Z","server":"101.37.117.183 (via=mixed)","account":"","character":"WebBotThree","scenario":"vendor-repair"} +{"t":1790848536510,"conn":"bnet","dir":"c2s","hex":"01","packetName":"BNCS_GREETING"} +{"t":1790848536511,"conn":"bnet","dir":"c2s","hex":"ff503a000000000036385849505832440d000000090400007f000001e0010000090400000904000055534100556e697465642053746174657300","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848536566,"conn":"bnet","dir":"s2c","hex":"ff2508002b738e09","packetId":37,"packetName":"SID_PING"} +{"t":1790848536566,"conn":"bnet","dir":"c2s","hex":"ff2508002b738e09","packetId":37,"packetName":"SID_PING"} +{"t":1790848536616,"conn":"bnet","dir":"s2c","hex":"ff506500000000001ca629257b00000000d8b1d80027dd0149583836766572312e6d707100413d3338343535383136333420423d38383038323335383020433d31333633393337313033203420413d412d5320423d422d4320433d432d4120413d412d4200","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848536618,"conn":"bnet","dir":"c2s","hex":"ff518b00aa91d8ac000d0001cabe867602000000000000001000000006000000cbba000000000000c09bc26ab6b63e5b53e59a7d9a177818040ce001100000000a000000f015eb0000000000321bd5da55fb91d4e6b4f5a51554eb81f8623b7d47616d652e6578652030332f30392f31302030343a31303a353120363134343000576562506c6179657200","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848536670,"conn":"bnet","dir":"s2c","hex":"ff5109000000000000","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848536672,"conn":"bnet","dir":"c2s","hex":"ff3a2a00aa91d8ac1ca62925a6b55caad0416a88d000c499b043083b8cccbace6432776562626f743300","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848536722,"conn":"bnet","dir":"s2c","hex":"ff590400","packetId":89,"packetName":"SID_SETEMAIL"} +{"t":1790848536813,"conn":"bnet","dir":"s2c","hex":"ff3a080000000000","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848536814,"conn":"bnet","dir":"c2s","hex":"ff3e2600aa91d8acec0d8aa9142916cca2debdce974deb0f35655ff653616e63747561727900","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848536864,"conn":"bnet","dir":"s2c","hex":"ff3e5600aa91d8ac00000000000000007b000000652575b717e100001ca629250000000000000000505832440d000000000000000000000000c971ff3b5f2a9f13e5112c7262121830d497e86432776562626f743300","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848537256,"conn":"realm","dir":"c2s","hex":"01","packetName":"MCP_GREETING"} +{"t":1790848537257,"conn":"realm","dir":"c2s","hex":"4d0001aa91d8ac00000000000000007b0000001ca629250000000000000000505832440d000000000000000000000000c971ff3b5f2a9f13e5112c7262121830d497e86432776562626f743300","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848537305,"conn":"realm","dir":"s2c","hex":"07000100000000","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848537305,"conn":"realm","dir":"c2s","hex":"07001908000000","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848537352,"conn":"realm","dir":"s2c","hex":"3d00190800010000000100ffffff7f576562426f745468726565008d80ffffffffff04ff4fffffff05ffffffffffffffffffffff01e080808001ffff00","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848537353,"conn":"realm","dir":"c2s","hex":"0f0007576562426f74546872656500","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848537399,"conn":"realm","dir":"s2c","hex":"07000700000000","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848537400,"conn":"realm","dir":"c2s","hex":"23000301000000000001ff0877626d38336b70000064322d626f74207265636f726400","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848537448,"conn":"realm","dir":"s2c","hex":"0d000301000100010000000000","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848537999,"conn":"realm","dir":"c2s","hex":"0e0004020077626d38336b700000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848538048,"conn":"realm","dir":"s2c","hex":"15000402004b000000652575b71be89a1d00000000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848538228,"conn":"game","dir":"s2c","hex":"af01","packetId":175} +{"t":1790848538229,"conn":"game","dir":"c2s","hex":"681be89a1d4b00040d000000ed4d5650b453354200576562426f7454687265650000000000","packetId":104} +{"t":1790848538304,"conn":"game","dir":"s2c","hex":"0100040030000101","packetId":1} +{"t":1790848538304,"conn":"game","dir":"s2c","hex":"00","packetId":0} +{"t":1790848538304,"conn":"game","dir":"s2c","hex":"02","packetId":2} +{"t":1790848538304,"conn":"game","dir":"c2s","hex":"6b","packetId":107} +{"t":1790848538305,"conn":"game","dir":"c2s","hex":"6dc116e4f60000000000000000","packetId":109} +{"t":1790848538385,"conn":"game","dir":"s2c","hex":"590100000004576562426f745468726565000000000000000000","packetId":89} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"aa00010000000c6959f9ff1f","packetId":170} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"760001000000","packetId":118} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"940a01000000000001020001010001d90001da0001db0001dc0001040001050001030001","packetId":148} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"5e01010101010101010101010101010101010101010101010101010101010101010101010101","packetId":94} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"28060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"0b0001000000","packetId":11} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"5f01000000","packetId":95} +{"t":1790848538386,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848538387,"conn":"game","dir":"s2c","hex":"9c0e1410010000001000a2006508008006170302","packetId":156} +{"t":1790848538388,"conn":"game","dir":"s2c","hex":"9c0e1410020000001000a2006508028006170302","packetId":156} +{"t":1790848538388,"conn":"game","dir":"s2c","hex":"9c0e1410030000001000a2006508048006170302","packetId":156} +{"t":1790848538388,"conn":"game","dir":"s2c","hex":"9c0e1410040000001000a2006508068006170302","packetId":156} +{"t":1790848538388,"conn":"game","dir":"s2c","hex":"9d061e0505000000000100000011008200658408801686078280c0c1e13f","packetId":157} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"9d0620060600000000010000001100820065a40a205637068280e0000606ff01","packetId":157} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"230001000000010000ffffffff","packetId":35} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"230001000000000000ffffffff","packetId":35} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848538389,"conn":"game","dir":"s2c","hex":"95370005001700000000000000","packetId":149} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"1902","packetId":25} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"030096b41121010038047e78","packetId":3} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"53020000000000000000","packetId":83} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074004800401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"073804780401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"073804800401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074004780401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074804780401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"073804880401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074004800401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074804800401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074004880401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"074804880401","packetId":7} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"15000100000061159c1601","packetId":21} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"7e00000000","packetId":126} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848538390,"conn":"game","dir":"s2c","hex":"ac060000009a003a159116800e01","packetId":172} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"aa01060000000c6959f9ff1f","packetId":170} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"6d060000003a15911680","packetId":109} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"51020d0000002500341598160200","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"0e020d000000030002000000","packetId":14} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"ac0500000098003b15a916800e01","packetId":172} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"aa01050000000c6959f9ff1f","packetId":170} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"6d050000003b15a91680","packetId":109} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"ac0400000093003c15ce16800e01","packetId":172} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"aa01040000000c6959f9ff1f","packetId":170} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"51020c00000025003f15c5160200","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"0e020c000000030002000000","packetId":14} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"51020b00000025003715ab160200","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"0e020b000000030002000000","packetId":14} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"ac070000009b005a15a316800e01","packetId":172} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"aa01070000000c6959f9ff1f","packetId":170} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"6d070000005a15a31680","packetId":109} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"5102110000000b015a159d160000","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"510210000000250042158b160200","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"0e0210000000030002000000","packetId":14} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"51020f00000025005015a2160200","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"0e020f000000030002000000","packetId":14} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"51020e0000002400601590160000","packetId":81} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"a800010000000b69acfc0f","packetId":168} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848538391,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848539114,"conn":"game","dir":"s2c","hex":"ac03000000960073159a16800e01","packetId":172} +{"t":1790848539114,"conn":"game","dir":"s2c","hex":"aa01030000000c6959f9ff1f","packetId":170} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"6d0300000073159a1680","packetId":109} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"ac0200000098008c159a168010112800","packetId":172} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"aa01020000000c6959f9ff1f","packetId":170} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"6d020000008c159a1680","packetId":109} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020a00000077007b1589160200","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"0e020a000000030102000000","packetId":14} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"5102090000002500731581160200","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"0e0209000000030002000000","packetId":14} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020800000025006e1595160200","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"0e0208000000030002000000","packetId":14} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"510207000000250087158a160200","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"0e0207000000030002000000","packetId":14} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"ac0100000098005b15cc16800e01","packetId":172} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"aa01010000000c6959f9ff1f","packetId":170} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"6d010000005b15cc1680","packetId":109} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"5102060000004e005315ab160000","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020500000025006115cb160200","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"0e0205000000030002000000","packetId":14} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020400000023005415c8160000","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020300000027006515aa160000","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020200000081016c15a9160000","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"51020100000025007c15ad160200","packetId":81} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"0e0201000000030002000000","packetId":14} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"04","packetId":4} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"5b24000100000004576562426f74546872656500000000000100ffff0000000000000000","packetId":91} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"65010000000000","packetId":101} +{"t":1790848539115,"conn":"game","dir":"s2c","hex":"8d01000000ffff","packetId":141} +{"t":1790848539116,"conn":"game","dir":"s2c","hex":"5a02040000000000576562426f74546872656500000000006432776562626f743300000000000000","packetId":90} +{"t":1790848539117,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff63385b443247535d202d204e6f20636f6d6d65726369616c20707572706f736520697320616c6c6f77656421ff633400","packetId":38} +{"t":1790848539117,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d0048656c6c6f20776f726c642100","packetId":38} +{"t":1790848539117,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff633954686520576f726c64204576656e7420656e61626c6573206f6e2074686973207365727665722e20546865206b6579206974656d20697320224b6579222eff633100","packetId":38} +{"t":1790848539264,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848539264,"conn":"game","dir":"s2c","hex":"6706000000013b158b160100074b0005","packetId":103} +{"t":1790848539264,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848539264,"conn":"game","dir":"s2c","hex":"6707000000015c15a1160100074b0005","packetId":103} +{"t":1790848539265,"conn":"game","dir":"s2c","hex":"6703000000016f15a4160100074b0005","packetId":103} +{"t":1790848540104,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848540104,"conn":"game","dir":"s2c","hex":"6707000000015d15a0160100074b0005","packetId":103} +{"t":1790848540463,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848540544,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848540544,"conn":"game","dir":"s2c","hex":"6707000000015e159f160100074b0005","packetId":103} +{"t":1790848540583,"conn":"game","dir":"s2c","hex":"6d060000003b158b1680","packetId":109} +{"t":1790848540723,"conn":"game","dir":"c2s","hex":"0360159a16","packetId":3} +{"t":1790848541023,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848541023,"conn":"game","dir":"s2c","hex":"6707000000015f159e160100074b0005","packetId":103} +{"t":1790848541037,"conn":"game","dir":"c2s","hex":"035e159716","packetId":3} +{"t":1790848541064,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848541423,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848541423,"conn":"game","dir":"s2c","hex":"6707000000015e159b160100074b0005","packetId":103} +{"t":1790848541423,"conn":"game","dir":"s2c","hex":"965c00af8a4b0b0000","packetId":150} +{"t":1790848541544,"conn":"game","dir":"c2s","hex":"035d159516","packetId":3} +{"t":1790848541664,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848541858,"conn":"game","dir":"c2s","hex":"035b159216","packetId":3} +{"t":1790848542343,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848542343,"conn":"game","dir":"s2c","hex":"6707000000015d1598160100074b0005","packetId":103} +{"t":1790848542364,"conn":"game","dir":"c2s","hex":"0358159216","packetId":3} +{"t":1790848542744,"conn":"game","dir":"s2c","hex":"6d030000006f15a41680","packetId":109} +{"t":1790848542785,"conn":"game","dir":"c2s","hex":"0355159116","packetId":3} +{"t":1790848542904,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848543229,"conn":"game","dir":"c2s","hex":"0353159116","packetId":3} +{"t":1790848543263,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848543263,"conn":"game","dir":"s2c","hex":"6707000000015c1596160100074b0005","packetId":103} +{"t":1790848543306,"conn":"game","dir":"c2s","hex":"6d4a2ae4f60000000000000000","packetId":109} +{"t":1790848543383,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848543463,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848543510,"conn":"game","dir":"c2s","hex":"0350159116","packetId":3} +{"t":1790848543904,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848543904,"conn":"game","dir":"s2c","hex":"670700000001591597160100074b0005","packetId":103} +{"t":1790848543930,"conn":"game","dir":"c2s","hex":"034d159016","packetId":3} +{"t":1790848544064,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848544374,"conn":"game","dir":"c2s","hex":"034a159016","packetId":3} +{"t":1790848544744,"conn":"game","dir":"s2c","hex":"965c00a50a480b0000","packetId":150} +{"t":1790848544795,"conn":"game","dir":"c2s","hex":"0347158f16","packetId":3} +{"t":1790848544983,"conn":"game","dir":"s2c","hex":"6707000000015915a9160100074b0005","packetId":103} +{"t":1790848545239,"conn":"game","dir":"c2s","hex":"0344158f16","packetId":3} +{"t":1790848545263,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848545383,"conn":"game","dir":"s2c","hex":"6b060000000c0000000000003b158b16","packetId":107} +{"t":1790848545660,"conn":"game","dir":"c2s","hex":"0342158f16","packetId":3} +{"t":1790848545863,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848545941,"conn":"game","dir":"c2s","hex":"033f158e16","packetId":3} +{"t":1790848546183,"conn":"game","dir":"s2c","hex":"073004780401","packetId":7} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"073004800401","packetId":7} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"073004880401","packetId":7} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"084804780401","packetId":8} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0103000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0102000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a020a000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0209000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0208000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0207000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"084804800401","packetId":8} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0202000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"0a0201000000","packetId":10} +{"t":1790848546184,"conn":"game","dir":"s2c","hex":"084804880401","packetId":8} +{"t":1790848546385,"conn":"game","dir":"c2s","hex":"033c158e16","packetId":3} +{"t":1790848546463,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848546806,"conn":"game","dir":"c2s","hex":"033c158d16","packetId":3} +{"t":1790848546957,"conn":"game","dir":"c2s","hex":"033b158b16","packetId":3} +{"t":1790848547273,"conn":"game","dir":"c2s","hex":"130100000006000000","packetId":19} +{"t":1790848547344,"conn":"game","dir":"s2c","hex":"27010600000001000000250000000000000000000000000000000000000000000000000000000000","packetId":39} +{"t":1790848547345,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848547345,"conn":"game","dir":"s2c","hex":"28010600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848547345,"conn":"game","dir":"s2c","hex":"6d060000003b158b1680","packetId":109} +{"t":1790848547383,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848547974,"conn":"game","dir":"c2s","hex":"2f0100000006000000","packetId":47} +{"t":1790848547984,"conn":"game","dir":"s2c","hex":"6d060000003b158b1680","packetId":109} +{"t":1790848547984,"conn":"game","dir":"s2c","hex":"965c809c8a450b0000","packetId":150} +{"t":1790848548306,"conn":"game","dir":"c2s","hex":"6dd23de4f60000000000000000","packetId":109} +{"t":1790848548384,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848548583,"conn":"game","dir":"s2c","hex":"6d060000003b158b1680","packetId":109} +{"t":1790848548583,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848548675,"conn":"game","dir":"c2s","hex":"38010000000600000000000000","packetId":56} +{"t":1790848548744,"conn":"game","dir":"s2c","hex":"9c0b1c0507000000102080006500008416860702c3000e0e1302f80f","packetId":156} +{"t":1790848548745,"conn":"game","dir":"s2c","hex":"9c0b200508000000102080006500608416860702039199617070b0106881f31f","packetId":156} +{"t":1790848548745,"conn":"game","dir":"s2c","hex":"9c0b190509000000102080006500c0c41686070283e0e1e13f","packetId":156} +{"t":1790848548745,"conn":"game","dir":"s2c","hex":"9c0b21050a00000010208000650002c416860702033125007878d043830f30ff01","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b19050b000000102080006500623407370602834042e23f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b19050c000000102080006500643437470602838081e13f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b19050d000000102080006500c43437d60602836061e13f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b20050e000000102080006500063437d6060203111a625858b0205014f80f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b19050f000000102080006500664476260702830001e13f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b200510000000102080006500a6447626070203f18e6f404098c04185e03f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b1a05110000001020800065000048b766060283404000d47f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b1a05120000001020800065004048b766060283404000d47f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b1a051300000010208000650080a81666070283202080c77f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b1a0514000000102080006500e0a81666070283202080c77f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b19051500000010208000650008340727070283e0e1e13f","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b20051600000010208000650088340727070203111ac978785014e037fc07","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b1a05170000001028800065000c241626070283202343fe03","packetId":156} +{"t":1790848548746,"conn":"game","dir":"s2c","hex":"9c0b2005180000001020800065008c24162607020301004bc9c830160851f80f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1a061900000010288000650010342776070283403161fe03","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b22061a00000010208000650070342776070203e18e5b503098e0008c8001ff01","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1a061b000000102880006500d084267607028300c241fe03","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b21061c000000102080006500008626760702030180708140c881000232fe03","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b23051d00000010208000650060c68627060203b1227878501010caf1c10732fe03","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b19051e000000102080006500e6b446270702830003e33f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1b001f00000010288000650000321606070283e0000606f11f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1f002000000010208000650040321606070203912500388081c1099dff","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1f00210000001020800065008032b7060702c3700b485080706089e83f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1b0122000000102080006500c012579706028330010a0aff01","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1f0123000000102080006500041257970602030100c954808202bfe13f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1b012400000010208000650064c25616060283a0010c0cff01","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1b0125000000102880006500c482c61606028320020e0ef11f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1f0726000000102080006500082256370602c3f008303480506089e73f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1f07270000001020800065004822563706020301006f3980818193c07f","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b1b07280000001020800065008832d7c606028340010808ff01","packetId":156} +{"t":1790848548747,"conn":"game","dir":"s2c","hex":"9c0b210729000000102080006500c832d7c60602030100564c00020205c5cce83f","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b1b102a00000010208000650008c376c6060283c0000606ff01","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b1f102b0000001020800065000cc276c606020301006d3180818180d07f","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b1b102c0000001020800065004cc22646070283c0000606ff01","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b20102d0000001020800065008cc22646070203f10800388081010407ff01","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b1b102e00000010208000650010c226c6060283c0000606ff01","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b1f102f00000010208000650030c226c6060203010083308081c118cf7f","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b18103000000010208000650002181667070283e0f53f","packetId":156} +{"t":1790848548748,"conn":"game","dir":"s2c","hex":"9c0b18103100000010208000650062381667070283a0ef3f","packetId":156} +{"t":1790848549143,"conn":"game","dir":"s2c","hex":"6d060000003b158b1680","packetId":109} +{"t":1790848549224,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848549824,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848549978,"conn":"game","dir":"c2s","hex":"350600000005000000010000001c000000","packetId":53} +{"t":1790848550023,"conn":"game","dir":"s2c","hex":"2a0009067c5ac7ffffffff02000000","packetId":42} +{"t":1790848550944,"conn":"game","dir":"s2c","hex":"6d070000005915a91680","packetId":109} +{"t":1790848551023,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848551624,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848552184,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848552780,"conn":"game","dir":"c2s","hex":"3506000000000000000000000000000080","packetId":53} +{"t":1790848552864,"conn":"game","dir":"s2c","hex":"2a0102067c5ac7ffffffff02000000","packetId":42} +{"t":1790848553306,"conn":"game","dir":"c2s","hex":"6d5a51e4f60000000000000000","packetId":109} +{"t":1790848553384,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848553384,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848553482,"conn":"game","dir":"c2s","hex":"300100000006000000","packetId":48} +{"t":1790848553984,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848554584,"conn":"game","dir":"s2c","hex":"6d040000003c15ce1680","packetId":109} +{"t":1790848555290,"conn":"game","dir":"c2s","hex":"69","packetId":105} diff --git a/tests/fixtures/netproto/08-d2gs-stash-gold.d2cap b/tests/fixtures/netproto/08-d2gs-stash-gold.d2cap new file mode 100644 index 0000000..b0bff90 --- /dev/null +++ b/tests/fixtures/netproto/08-d2gs-stash-gold.d2cap @@ -0,0 +1,212 @@ +{"format":"d2cap-v1","createdAt":"2026-10-01T09:56:27.920Z","server":"101.37.117.183 (via=mixed)","account":"","character":"WebBotThree","scenario":"stash-gold"} +{"t":1790848574051,"conn":"bnet","dir":"c2s","hex":"01","packetName":"BNCS_GREETING"} +{"t":1790848574051,"conn":"bnet","dir":"c2s","hex":"ff503a000000000036385849505832440d000000090400007f000001e0010000090400000904000055534100556e697465642053746174657300","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848574096,"conn":"bnet","dir":"s2c","hex":"ff250800c9058f09","packetId":37,"packetName":"SID_PING"} +{"t":1790848574096,"conn":"bnet","dir":"c2s","hex":"ff250800c9058f09","packetId":37,"packetName":"SID_PING"} +{"t":1790848574138,"conn":"bnet","dir":"s2c","hex":"ff50650000000000512d435e7c00000000d8b1d80027dd0149583836766572312e6d707100413d3338343535383136333420423d38383038323335383020433d31333633393337313033203420413d412d5320423d422d4320433d432d4120413d412d4200","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848574139,"conn":"bnet","dir":"c2s","hex":"ff518b00753cd8ac000d0001cabe867602000000000000001000000006000000cbba00000000000013d2bae9f3cef9d1dd24c9c92fb338e5d2be4abf100000000a000000f015eb000000000024cda270d09a42249658b0f8043ceaf311a586f847616d652e6578652030332f30392f31302030343a31303a353120363134343000576562506c6179657200","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848574183,"conn":"bnet","dir":"s2c","hex":"ff5109000000000000","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848574185,"conn":"bnet","dir":"c2s","hex":"ff3a2a00753cd8ac512d435e7c26b95f449c762eda3ef8095a9ec44cfc1ab55c6432776562626f743300","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848574228,"conn":"bnet","dir":"s2c","hex":"ff590400","packetId":89,"packetName":"SID_SETEMAIL"} +{"t":1790848574314,"conn":"bnet","dir":"s2c","hex":"ff3a080000000000","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848574315,"conn":"bnet","dir":"c2s","hex":"ff3e2600753cd8ac75e7995e49fe5b4b409bd2d1090c82cc5b559df753616e63747561727900","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848574359,"conn":"bnet","dir":"s2c","hex":"ff3e5600753cd8ac00000000000000007c000000652575b717e10000512d435e0000000000000000505832440d00000000000000000000008ed9cbc6c87dfdf26a9dfa9bb3bb01d60c556e1b6432776562626f743300","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848575182,"conn":"realm","dir":"c2s","hex":"01","packetName":"MCP_GREETING"} +{"t":1790848575183,"conn":"realm","dir":"c2s","hex":"4d0001753cd8ac00000000000000007c000000512d435e0000000000000000505832440d00000000000000000000008ed9cbc6c87dfdf26a9dfa9bb3bb01d60c556e1b6432776562626f743300","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848575240,"conn":"realm","dir":"s2c","hex":"07000100000000","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848575241,"conn":"realm","dir":"c2s","hex":"07001908000000","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848575297,"conn":"realm","dir":"s2c","hex":"3d00190800010000000100ffffff7f576562426f745468726565008d80ffffffffff04ff4fffffff05ffffffffffffffffffffff01e080808001ffff00","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848575297,"conn":"realm","dir":"c2s","hex":"0f0007576562426f74546872656500","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848575353,"conn":"realm","dir":"s2c","hex":"07000700000000","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848575354,"conn":"realm","dir":"c2s","hex":"23000301000000000001ff0877626d38336c72000064322d626f74207265636f726400","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848575411,"conn":"realm","dir":"s2c","hex":"0d000301000100010000000000","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848575951,"conn":"realm","dir":"c2s","hex":"0e0004020077626d38336c720000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848576009,"conn":"realm","dir":"s2c","hex":"15000402004c000000652575b71c04f63600000000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848576199,"conn":"game","dir":"s2c","hex":"af01","packetId":175} +{"t":1790848576199,"conn":"game","dir":"c2s","hex":"681c04f6364c00040d000000ed4d5650b453354200576562426f7454687265650000000000","packetId":104} +{"t":1790848576268,"conn":"game","dir":"s2c","hex":"0100040030000101","packetId":1} +{"t":1790848576268,"conn":"game","dir":"s2c","hex":"00","packetId":0} +{"t":1790848576269,"conn":"game","dir":"s2c","hex":"02","packetId":2} +{"t":1790848576269,"conn":"game","dir":"c2s","hex":"6b","packetId":107} +{"t":1790848576269,"conn":"game","dir":"c2s","hex":"6d0dabe4f60000000000000000","packetId":109} +{"t":1790848576350,"conn":"game","dir":"s2c","hex":"590100000004576562426f745468726565000000000000000000","packetId":89} +{"t":1790848576351,"conn":"game","dir":"s2c","hex":"aa00010000000c6959f9ff1f","packetId":170} +{"t":1790848576351,"conn":"game","dir":"s2c","hex":"760001000000","packetId":118} +{"t":1790848576351,"conn":"game","dir":"s2c","hex":"940a01000000000001020001010001d90001da0001db0001dc0001040001050001030001","packetId":148} +{"t":1790848576351,"conn":"game","dir":"s2c","hex":"5e01010101010101010101010101010101010101010101010101010101010101010101010101","packetId":94} +{"t":1790848576351,"conn":"game","dir":"s2c","hex":"28060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"0b0001000000","packetId":11} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"5f01000000","packetId":95} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848576352,"conn":"game","dir":"s2c","hex":"9c0e1410010000001000a2006508008006170302","packetId":156} +{"t":1790848576353,"conn":"game","dir":"s2c","hex":"9c0e1410020000001000a2006508028006170302","packetId":156} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"9c0e1410030000001000a2006508048006170302","packetId":156} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"9c0e1410040000001000a2006508068006170302","packetId":156} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"9d061e0505000000000100000011008200658408801686078280c0c1e13f","packetId":157} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"9d0620060600000000010000001100820065a40a205637068280e0000606ff01","packetId":157} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"230001000000010000ffffffff","packetId":35} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"230001000000000000ffffffff","packetId":35} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"95370005001700000000000000","packetId":149} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"1902","packetId":25} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"03007f308a340100f1810e35","packetId":3} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"53020000000000000000","packetId":83} +{"t":1790848576354,"conn":"game","dir":"s2c","hex":"07c803680401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07c003600401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07c003680401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07c803600401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07d003600401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07c003700401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07c803680401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07d003680401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07c803700401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"07d003700401","packetId":7} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"150001000000ff12151601","packetId":21} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"7e00000000","packetId":126} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"5102110000002500e01204160200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e0211000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"ac080000009800c4121f168010116000","packetId":172} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"aa01080000000c6959f9ff1f","packetId":170} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"6d08000000c4121f1680","packetId":109} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"ac070000009800d6122d168010116000","packetId":172} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"aa01070000000c6959f9ff1f","packetId":170} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"6d07000000d6122d1680","packetId":109} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"ac060000009a00d8120a16800e01","packetId":172} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"aa01060000000c6959f9ff1f","packetId":170} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"6d06000000d8120a1680","packetId":109} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"5102100000002500dc1227160200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e0210000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"51020f0000002500c6121b160200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e020f000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"51020e0000002500d11210160200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e020e000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"ac05000000b300ed12f915800e01","packetId":172} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"aa01050000000c6959f5ff1f","packetId":170} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"6d05000000ed12f91580","packetId":109} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"51020d0000002500f712ff150200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e020d000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"51020c0000002500251302160200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e020c000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"51020b00000077001413fd150200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e020b000000030102000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"51020a0000002500341304160200","packetId":81} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"0e020a000000030002000000","packetId":14} +{"t":1790848576355,"conn":"game","dir":"s2c","hex":"ac040000009300d9124716800e01","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa01040000000c6959f9ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d04000000d912471680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102090000002500d5124e160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0209000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102080000002500c81238160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0208000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102070000002500df123f160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0207000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"ac0b0000009b00f8121c16800e01","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa010b0000000c6959f9ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d0b000000f8121c1680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"ac0a0000009800e91214168010112000","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa010a0000000c6959f9ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d0a000000e912141680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"ac0900000096000e131516800e01","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa01090000000c6959f9ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d090000000e13151680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"51021600000025000b130e160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0216000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102150000000b01f81216160000","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102140000002700031323160000","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102130000002500f01222160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0213000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"51021200000081010a1322160000","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"a800010000000b69acfc0f","packetId":168} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"510206000000230031132d160000","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"ac030000009800fa1245168010114000","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa01030000000c6959f9ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d03000000fa12451680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102050000002500ff1243160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0205000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102040000002400f41242160000","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"ac0200000098002c1338168010114000","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa01020000000c6959f9ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d020000002c13381680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"ac01000000b30022135316800e01","packetId":172} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"aa01010000000c6959f5ff1f","packetId":170} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"6d010000002213531680","packetId":109} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"51020300000025001c1334160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0203000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102020000002500191354160200","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"0e0202000000030002000000","packetId":14} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5102010000004e00121337160000","packetId":81} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"04","packetId":4} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5b24000100000004576562426f74546872656500000000000100ffff0000000000000000","packetId":91} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"65010000000000","packetId":101} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"8d01000000ffff","packetId":141} +{"t":1790848576356,"conn":"game","dir":"s2c","hex":"5a02040000000000576562426f74546872656500000000006432776562626f743300000000000000","packetId":90} +{"t":1790848577188,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff63385b443247535d202d204e6f20636f6d6d65726369616c20707572706f736520697320616c6c6f77656421ff633400","packetId":38} +{"t":1790848577189,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d0048656c6c6f20776f726c642100","packetId":38} +{"t":1790848577189,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff633954686520576f726c64204576656e7420656e61626c6573206f6e2074686973207365727665722e20546865206b6579206974656d20697320224b6579222eff633100","packetId":38} +{"t":1790848577269,"conn":"game","dir":"s2c","hex":"8a010b000000","packetId":138} +{"t":1790848577269,"conn":"game","dir":"s2c","hex":"670600000001d8120c160100074b0005","packetId":103} +{"t":1790848577269,"conn":"game","dir":"s2c","hex":"670400000001df1249160100074b0005","packetId":103} +{"t":1790848577269,"conn":"game","dir":"s2c","hex":"670b00000001fa121a160100074b0005","packetId":103} +{"t":1790848577269,"conn":"game","dir":"s2c","hex":"670900000001131320160100074b0005","packetId":103} +{"t":1790848577669,"conn":"game","dir":"s2c","hex":"6d06000000d8120c1680","packetId":109} +{"t":1790848578068,"conn":"game","dir":"s2c","hex":"8a010b000000","packetId":138} +{"t":1790848578068,"conn":"game","dir":"s2c","hex":"670b00000001fb1219160100074b0005","packetId":103} +{"t":1790848578508,"conn":"game","dir":"s2c","hex":"8a010b000000","packetId":138} +{"t":1790848578509,"conn":"game","dir":"s2c","hex":"670b00000001fc1218160100074b0005","packetId":103} +{"t":1790848578600,"conn":"game","dir":"c2s","hex":"03fd121516","packetId":3} +{"t":1790848578628,"conn":"game","dir":"s2c","hex":"6d04000000df12491680","packetId":109} +{"t":1790848578882,"conn":"game","dir":"c2s","hex":"03fa121616","packetId":3} +{"t":1790848578948,"conn":"game","dir":"s2c","hex":"8a010b000000","packetId":138} +{"t":1790848578948,"conn":"game","dir":"s2c","hex":"6d0b000000fc12181680","packetId":109} +{"t":1790848578948,"conn":"game","dir":"s2c","hex":"2c010b0000001200","packetId":44} +{"t":1790848578949,"conn":"game","dir":"s2c","hex":"965c007e890a0b817f","packetId":150} +{"t":1790848579326,"conn":"game","dir":"c2s","hex":"03f8121616","packetId":3} +{"t":1790848579608,"conn":"game","dir":"c2s","hex":"130200000015000000","packetId":19} +{"t":1790848579669,"conn":"game","dir":"s2c","hex":"7710","packetId":119} +{"t":1790848579748,"conn":"game","dir":"s2c","hex":"8a010b000000","packetId":138} +{"t":1790848580989,"conn":"game","dir":"s2c","hex":"6d090000001313201680","packetId":109} +{"t":1790848581108,"conn":"game","dir":"c2s","hex":"4f140000000100","packetId":79} +{"t":1790848581188,"conn":"game","dir":"s2c","hex":"1d0f01","packetId":29} +{"t":1790848581270,"conn":"game","dir":"c2s","hex":"6d96bee4f60000000000000000","packetId":109} +{"t":1790848581348,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848581789,"conn":"game","dir":"s2c","hex":"1d0e01","packetId":29} +{"t":1790848582610,"conn":"game","dir":"c2s","hex":"4f130000000100","packetId":79} +{"t":1790848582629,"conn":"game","dir":"s2c","hex":"965c007d090b0b0100","packetId":150} +{"t":1790848582668,"conn":"game","dir":"s2c","hex":"1d0f00","packetId":29} +{"t":1790848583428,"conn":"game","dir":"s2c","hex":"1901","packetId":25} +{"t":1790848584111,"conn":"game","dir":"c2s","hex":"4f130000000100","packetId":79} +{"t":1790848585614,"conn":"game","dir":"c2s","hex":"4f120000000000","packetId":79} +{"t":1790848585669,"conn":"game","dir":"s2c","hex":"670600000001d91204160100074b0005","packetId":103} +{"t":1790848586270,"conn":"game","dir":"c2s","hex":"6d1ed2e4f60000000000000000","packetId":109} +{"t":1790848586388,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848587428,"conn":"game","dir":"s2c","hex":"6d06000000d912041680","packetId":109} +{"t":1790848587619,"conn":"game","dir":"c2s","hex":"69","packetId":105} diff --git a/tests/fixtures/netproto/09-d2gs-gamble.d2cap b/tests/fixtures/netproto/09-d2gs-gamble.d2cap new file mode 100644 index 0000000..3e75d36 --- /dev/null +++ b/tests/fixtures/netproto/09-d2gs-gamble.d2cap @@ -0,0 +1,248 @@ +{"format":"d2cap-v1","createdAt":"2026-10-01T09:57:10.084Z","server":"101.37.117.183 (via=mixed)","account":"","character":"WebBotThree","scenario":"gamble"} +{"t":1790848612103,"conn":"bnet","dir":"c2s","hex":"01","packetName":"BNCS_GREETING"} +{"t":1790848612104,"conn":"bnet","dir":"c2s","hex":"ff503a000000000036385849505832440d000000090400007f000001e0010000090400000904000055534100556e697465642053746174657300","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848612158,"conn":"bnet","dir":"s2c","hex":"ff250800799a8f09","packetId":37,"packetName":"SID_PING"} +{"t":1790848612158,"conn":"bnet","dir":"c2s","hex":"ff250800799a8f09","packetId":37,"packetName":"SID_PING"} +{"t":1790848612209,"conn":"bnet","dir":"s2c","hex":"ff5065000000000051cbfd3c7d00000000d8b1d80027dd0149583836766572312e6d707100413d3338343535383136333420423d38383038323335383020433d31333633393337313033203420413d412d5320423d422d4320433d432d4120413d412d4200","packetId":80,"packetName":"SID_AUTH_INFO"} +{"t":1790848612211,"conn":"bnet","dir":"c2s","hex":"ff518b0010a9d9ac000d0001cabe867602000000000000001000000006000000cbba000000000000141a2d645ef136a9128d8cd1b8f4b9857a517938100000000a000000f015eb00000000003ec4742a8930b264f5f88632bdaff70874fac7a247616d652e6578652030332f30392f31302030343a31303a353120363134343000576562506c6179657200","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848612264,"conn":"bnet","dir":"s2c","hex":"ff5109000000000000","packetId":81,"packetName":"SID_AUTH_CHECK"} +{"t":1790848612265,"conn":"bnet","dir":"c2s","hex":"ff3a2a0010a9d9ac51cbfd3c82b2e52f416d932d5874b70b561e7ee8f82322816432776562626f743300","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848612598,"conn":"bnet","dir":"s2c","hex":"ff590400","packetId":89,"packetName":"SID_SETEMAIL"} +{"t":1790848612641,"conn":"bnet","dir":"s2c","hex":"ff3a080000000000","packetId":58,"packetName":"SID_LOGONRESPONSE2"} +{"t":1790848612642,"conn":"bnet","dir":"c2s","hex":"ff3e260010a9d9acc4fafcdc0d86c1404be1d8b81580a39fcb7b647153616e63747561727900","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848612685,"conn":"bnet","dir":"s2c","hex":"ff3e560010a9d9ac00000000000000007d000000652575b717e1000051cbfd3c0000000000000000505832440d00000000000000000000009147b8fe719ff487cc208cc8141c83fde58629c06432776562626f743300","packetId":62,"packetName":"SID_LOGONREALMEX"} +{"t":1790848613097,"conn":"realm","dir":"c2s","hex":"01","packetName":"MCP_GREETING"} +{"t":1790848613098,"conn":"realm","dir":"c2s","hex":"4d000110a9d9ac00000000000000007d00000051cbfd3c0000000000000000505832440d00000000000000000000009147b8fe719ff487cc208cc8141c83fde58629c06432776562626f743300","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848613150,"conn":"realm","dir":"s2c","hex":"07000100000000","packetId":1,"packetName":"MCP_STARTUP"} +{"t":1790848613150,"conn":"realm","dir":"c2s","hex":"07001908000000","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848613200,"conn":"realm","dir":"s2c","hex":"3d00190800010000000100ffffff7f576562426f745468726565008d80ffffffffff04ff4fffffff05ffffffffffffffffffffff01e080808001ffff00","packetId":25,"packetName":"MCP_CHARLIST2"} +{"t":1790848613200,"conn":"realm","dir":"c2s","hex":"0f0007576562426f74546872656500","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848613249,"conn":"realm","dir":"s2c","hex":"07000700000000","packetId":7,"packetName":"MCP_CHARLOGON"} +{"t":1790848613251,"conn":"realm","dir":"c2s","hex":"23000301000000000001ff0877626d38336d74000064322d626f74207265636f726400","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848613303,"conn":"realm","dir":"s2c","hex":"0d000301000100010000000000","packetId":3,"packetName":"MCP_CREATEGAME"} +{"t":1790848613847,"conn":"realm","dir":"c2s","hex":"0e0004020077626d38336d740000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848613901,"conn":"realm","dir":"s2c","hex":"15000402004d000000652575b75897dd4e00000000","packetId":4,"packetName":"MCP_JOINGAME"} +{"t":1790848614115,"conn":"game","dir":"s2c","hex":"af01","packetId":175} +{"t":1790848614116,"conn":"game","dir":"c2s","hex":"685897dd4e4d00040d000000ed4d5650b453354200576562426f7454687265650000000000","packetId":104} +{"t":1790848614186,"conn":"game","dir":"s2c","hex":"0100040030000101","packetId":1} +{"t":1790848614186,"conn":"game","dir":"s2c","hex":"00","packetId":0} +{"t":1790848614186,"conn":"game","dir":"s2c","hex":"02","packetId":2} +{"t":1790848614186,"conn":"game","dir":"c2s","hex":"6b","packetId":107} +{"t":1790848614187,"conn":"game","dir":"c2s","hex":"6d2a3fe5f60000000000000000","packetId":109} +{"t":1790848614267,"conn":"game","dir":"s2c","hex":"590100000004576562426f745468726565000000000000000000","packetId":89} +{"t":1790848614267,"conn":"game","dir":"s2c","hex":"aa00010000000c6959f9ff1f","packetId":170} +{"t":1790848614267,"conn":"game","dir":"s2c","hex":"760001000000","packetId":118} +{"t":1790848614267,"conn":"game","dir":"s2c","hex":"940a01000000000001020001010001d90001da0001db0001dc0001040001050001030001","packetId":148} +{"t":1790848614267,"conn":"game","dir":"s2c","hex":"5e01010101010101010101010101010101010101010101010101010101010101010101010101","packetId":94} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"28060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"0b0001000000","packetId":11} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"5f01000000","packetId":95} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848614268,"conn":"game","dir":"s2c","hex":"9c0e1410010000001000a2006508008006170302","packetId":156} +{"t":1790848614270,"conn":"game","dir":"s2c","hex":"9c0e1410020000001000a2006508028006170302","packetId":156} +{"t":1790848614270,"conn":"game","dir":"s2c","hex":"9c0e1410030000001000a2006508048006170302","packetId":156} +{"t":1790848614270,"conn":"game","dir":"s2c","hex":"9c0e1410040000001000a2006508068006170302","packetId":156} +{"t":1790848614270,"conn":"game","dir":"s2c","hex":"9d061e0505000000000100000011008200658408801686078280c0c1e13f","packetId":157} +{"t":1790848614270,"conn":"game","dir":"s2c","hex":"9d0620060600000000010000001100820065a40a205637068280e0000606ff01","packetId":157} +{"t":1790848614270,"conn":"game","dir":"s2c","hex":"230001000000010000ffffffff","packetId":35} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"230001000000000000ffffffff","packetId":35} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"95370005001700000000000000","packetId":149} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"1902","packetId":25} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"030076e79146010061281e60","packetId":3} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"53020000000000000000","packetId":83} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075803380401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075003300401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075003380401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075803300401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"076003300401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075003400401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075803380401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"076003380401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"075803400401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"076003400401","packetId":7} +{"t":1790848614271,"conn":"game","dir":"s2c","hex":"150001000000d910341501","packetId":21} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"7e00000000","packetId":126} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"ac060000009a00b2102915800e01","packetId":172} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"aa01060000000c6959f9ff1f","packetId":170} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"6d06000000b210291580","packetId":109} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"51020d0000002500ac1030150200","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"0e020d000000030002000000","packetId":14} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"ac050000009800b31041158010116800","packetId":172} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"aa01050000000c6959f9ff1f","packetId":170} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"6d05000000b310411580","packetId":109} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"ac040000009300b4106615800e01","packetId":172} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"aa01040000000c6959f9ff1f","packetId":170} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"6d04000000b410661580","packetId":109} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"51020c0000002500b7105d150200","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"0e020c000000030002000000","packetId":14} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"51020b0000002500af1043150200","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"0e020b000000030002000000","packetId":14} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"ac070000009b00d2103b15800e01","packetId":172} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"aa01070000000c6959f9ff1f","packetId":170} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"6d07000000d2103b1580","packetId":109} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"5102110000000b01d21035150000","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"5102100000002500ba1023150200","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"0e0210000000030002000000","packetId":14} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"51020f0000002500c8103a150200","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"0e020f000000030002000000","packetId":14} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"51020e0000002400d81028150000","packetId":81} +{"t":1790848614272,"conn":"game","dir":"s2c","hex":"a800010000000b69acfc0f","packetId":168} +{"t":1790848614273,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848614273,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848614273,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"ac030000009600eb103215800e01","packetId":172} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"aa01030000000c6959f9ff1f","packetId":170} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"6d03000000eb10321580","packetId":109} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"ac020000009800041132158010116800","packetId":172} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"aa01020000000c6959f9ff1f","packetId":170} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"6d020000000411321580","packetId":109} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"51020a0000007700f31021150200","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"0e020a000000030102000000","packetId":14} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102090000002500eb1019150200","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"0e0209000000030002000000","packetId":14} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102080000002500e6102d150200","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"0e0208000000030002000000","packetId":14} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102070000002500ff1022150200","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"0e0207000000030002000000","packetId":14} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"ac010000009800d31064158010116800","packetId":172} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"aa01010000000c6959f9ff1f","packetId":170} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"6d01000000d310641580","packetId":109} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102060000004e00cb1043150000","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102050000002500d91063150200","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"0e0205000000030002000000","packetId":14} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102040000002300cc1060150000","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102030000002700dd1042150000","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102020000008101e41041150000","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"5102010000002500f41045150200","packetId":81} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"0e0201000000030002000000","packetId":14} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1d001e","packetId":29} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1d010a","packetId":29} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1d0214","packetId":29} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1d0319","packetId":29} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1e070037","packetId":30} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1e09000a","packetId":30} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1e0b005c","packetId":30} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"1d0c01","packetId":29} +{"t":1790848614953,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848614954,"conn":"game","dir":"s2c","hex":"04","packetId":4} +{"t":1790848614954,"conn":"game","dir":"s2c","hex":"4800000100000000000000","packetId":72} +{"t":1790848614954,"conn":"game","dir":"s2c","hex":"5b24000100000004576562426f74546872656500000000000100ffff0000000000000000","packetId":91} +{"t":1790848614954,"conn":"game","dir":"s2c","hex":"65010000000000","packetId":101} +{"t":1790848614954,"conn":"game","dir":"s2c","hex":"8d01000000ffff","packetId":141} +{"t":1790848614954,"conn":"game","dir":"s2c","hex":"5a02040000000000576562426f74546872656500000000006432776562626f743300000000000000","packetId":90} +{"t":1790848615146,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848615147,"conn":"game","dir":"s2c","hex":"670600000001b2102b150100074b0005","packetId":103} +{"t":1790848615147,"conn":"game","dir":"s2c","hex":"670400000001ba1063150100074b0005","packetId":103} +{"t":1790848615147,"conn":"game","dir":"s2c","hex":"670700000001d41039150100074b0005","packetId":103} +{"t":1790848615147,"conn":"game","dir":"s2c","hex":"670300000001f21033150100074b0005","packetId":103} +{"t":1790848615426,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff63385b443247535d202d204e6f20636f6d6d65726369616c20707572706f736520697320616c6c6f77656421ff633400","packetId":38} +{"t":1790848615427,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d0048656c6c6f20776f726c642100","packetId":38} +{"t":1790848615427,"conn":"game","dir":"s2c","hex":"260400020000000001005b61646d696e6973747261746f725d00ff633954686520576f726c64204576656e7420656e61626c6573206f6e2074686973207365727665722e20546865206b6579206974656d20697320224b6579222eff633100","packetId":38} +{"t":1790848615626,"conn":"game","dir":"s2c","hex":"6d06000000b2102b1580","packetId":109} +{"t":1790848615986,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848615986,"conn":"game","dir":"s2c","hex":"670700000001d51038150100074b0005","packetId":103} +{"t":1790848616466,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848616466,"conn":"game","dir":"s2c","hex":"670700000001d61037150100074b0005","packetId":103} +{"t":1790848616558,"conn":"game","dir":"c2s","hex":"03d7103615","packetId":3} +{"t":1790848616585,"conn":"game","dir":"s2c","hex":"6d04000000ba10631580","packetId":109} +{"t":1790848616865,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848616865,"conn":"game","dir":"s2c","hex":"6d07000000d610371580","packetId":109} +{"t":1790848616866,"conn":"game","dir":"s2c","hex":"2c01070000001200","packetId":44} +{"t":1790848616866,"conn":"game","dir":"s2c","hex":"965c806b089b0a0000","packetId":150} +{"t":1790848616955,"conn":"game","dir":"c2s","hex":"03d6103915","packetId":3} +{"t":1790848617185,"conn":"game","dir":"s2c","hex":"6d03000000f210331580","packetId":109} +{"t":1790848617400,"conn":"game","dir":"c2s","hex":"03d4103b15","packetId":3} +{"t":1790848617666,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848617796,"conn":"game","dir":"c2s","hex":"03d2103e15","packetId":3} +{"t":1790848618303,"conn":"game","dir":"c2s","hex":"03d1104015","packetId":3} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"670700000001d41039150100074b0005","packetId":103} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"075003480401","packetId":7} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"075803480401","packetId":7} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"076003480401","packetId":7} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"085003300401","packetId":8} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"085803300401","packetId":8} +{"t":1790848618466,"conn":"game","dir":"s2c","hex":"086003300401","packetId":8} +{"t":1790848618545,"conn":"game","dir":"s2c","hex":"5102130000002500af106d150200","packetId":81} +{"t":1790848618545,"conn":"game","dir":"s2c","hex":"0e0213000000030002000000","packetId":14} +{"t":1790848618545,"conn":"game","dir":"s2c","hex":"ac08000000b30001117315800e01","packetId":172} +{"t":1790848618546,"conn":"game","dir":"s2c","hex":"aa01080000000c6959f5ff1f","packetId":170} +{"t":1790848618546,"conn":"game","dir":"s2c","hex":"6d080000000111731580","packetId":109} +{"t":1790848618546,"conn":"game","dir":"s2c","hex":"5102120000002500f3106c150200","packetId":81} +{"t":1790848618546,"conn":"game","dir":"s2c","hex":"0e0212000000030002000000","packetId":14} +{"t":1790848618619,"conn":"game","dir":"c2s","hex":"03cf104315","packetId":3} +{"t":1790848619125,"conn":"game","dir":"c2s","hex":"03ce104515","packetId":3} +{"t":1790848619187,"conn":"game","dir":"c2s","hex":"6db352e5f60000000000000000","packetId":109} +{"t":1790848619266,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848619306,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848619306,"conn":"game","dir":"s2c","hex":"670700000001d3103b150100074b0005","packetId":103} +{"t":1790848619439,"conn":"game","dir":"c2s","hex":"03cc104815","packetId":3} +{"t":1790848619945,"conn":"game","dir":"c2s","hex":"03ca104a15","packetId":3} +{"t":1790848619945,"conn":"game","dir":"s2c","hex":"8a0107000000","packetId":138} +{"t":1790848619945,"conn":"game","dir":"s2c","hex":"670700000001d1103d150100074b0005","packetId":103} +{"t":1790848620342,"conn":"game","dir":"c2s","hex":"03c9104d15","packetId":3} +{"t":1790848620786,"conn":"game","dir":"s2c","hex":"6d07000000d1103d1580","packetId":109} +{"t":1790848620786,"conn":"game","dir":"s2c","hex":"965c806488a60a0000","packetId":150} +{"t":1790848620786,"conn":"game","dir":"c2s","hex":"03c7104f15","packetId":3} +{"t":1790848621183,"conn":"game","dir":"c2s","hex":"03c5105215","packetId":3} +{"t":1790848621465,"conn":"game","dir":"s2c","hex":"670700000001db1038150100074b0005","packetId":103} +{"t":1790848621689,"conn":"game","dir":"c2s","hex":"03c4105415","packetId":3} +{"t":1790848622004,"conn":"game","dir":"c2s","hex":"03c2105715","packetId":3} +{"t":1790848622510,"conn":"game","dir":"c2s","hex":"03c1105915","packetId":3} +{"t":1790848622826,"conn":"game","dir":"c2s","hex":"03bf105c15","packetId":3} +{"t":1790848623332,"conn":"game","dir":"c2s","hex":"03bd105e15","packetId":3} +{"t":1790848623729,"conn":"game","dir":"c2s","hex":"03bc106115","packetId":3} +{"t":1790848624174,"conn":"game","dir":"c2s","hex":"03ba106315","packetId":3} +{"t":1790848624187,"conn":"game","dir":"c2s","hex":"6d3b66e5f60000000000000000","packetId":109} +{"t":1790848624305,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848624306,"conn":"game","dir":"s2c","hex":"965c005e88b08a807f","packetId":150} +{"t":1790848624572,"conn":"game","dir":"c2s","hex":"130100000004000000","packetId":19} +{"t":1790848624626,"conn":"game","dir":"s2c","hex":"6d07000000db10381580","packetId":109} +{"t":1790848624666,"conn":"game","dir":"s2c","hex":"270104000000010000002d0000000000000000000000000000000000000000000000000000000000","packetId":39} +{"t":1790848624666,"conn":"game","dir":"s2c","hex":"29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":41} +{"t":1790848624666,"conn":"game","dir":"s2c","hex":"28010400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","packetId":40} +{"t":1790848624666,"conn":"game","dir":"s2c","hex":"6d04000000ba10631580","packetId":109} +{"t":1790848625226,"conn":"game","dir":"s2c","hex":"670300000001e9103b150100074b0005","packetId":103} +{"t":1790848625266,"conn":"game","dir":"s2c","hex":"6d04000000ba10631580","packetId":109} +{"t":1790848625273,"conn":"game","dir":"c2s","hex":"2f0100000004000000","packetId":47} +{"t":1790848625906,"conn":"game","dir":"s2c","hex":"6d04000000ba10631580","packetId":109} +{"t":1790848625975,"conn":"game","dir":"c2s","hex":"38020000000400000000000000","packetId":56} +{"t":1790848626066,"conn":"game","dir":"s2c","hex":"9c0b141007000000002080026500122297e60602","packetId":156} +{"t":1790848626066,"conn":"game","dir":"s2c","hex":"9c0b1410080000000020800265003212d6560702","packetId":156} +{"t":1790848626066,"conn":"game","dir":"s2c","hex":"9c0b14100900000000208002650000c276c60602","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b14000a0000000020800265004032b7060702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b14050b000000002080026500804276260702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b14050c000000002080026500c0b246270702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b14050d000000002080026500828216860702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b14060e000000002080026500e23227760702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b14050f00000000208002650004b246270702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b140510000000002080026500643237470602","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b1400110000000020800265000632b7060702","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b1407120000000020800265004632d7c60602","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b140113000000002080026500861257970602","packetId":156} +{"t":1790848626067,"conn":"game","dir":"s2c","hex":"9c0b140514000000002080026500e6b246270702","packetId":156} +{"t":1790848626466,"conn":"game","dir":"s2c","hex":"6d04000000ba10631580","packetId":109} +{"t":1790848627277,"conn":"game","dir":"c2s","hex":"3204000000070000000200000001000000","packetId":50} +{"t":1790848627345,"conn":"game","dir":"s2c","hex":"2a000c00020000ffffffff02000000","packetId":42} +{"t":1790848627978,"conn":"game","dir":"c2s","hex":"300100000004000000","packetId":48} +{"t":1790848628826,"conn":"game","dir":"s2c","hex":"6d03000000e9103b1580","packetId":109} +{"t":1790848629187,"conn":"game","dir":"c2s","hex":"6dc379e5f60000000000000000","packetId":109} +{"t":1790848629266,"conn":"game","dir":"s2c","hex":"8f0000000000000000000000000000000000000000000000000000000000000000","packetId":143} +{"t":1790848629782,"conn":"game","dir":"c2s","hex":"69","packetId":105} diff --git a/tools/d2-bot.ts b/tools/d2-bot.ts index 239dd3e..2108379 100644 --- a/tools/d2-bot.ts +++ b/tools/d2-bot.ts @@ -18,7 +18,10 @@ * --idle= Stay in-game for N seconds (default: 5 for join, 60 for idle) * --world Print ClientWorld state summary * --capture= Write .d2cap JSONL wire capture to disk - * --scenario= Scenario name for record command (town|combat|items|npc|chat|full) + * --scenario= Scenario name for record command (town|combat|items|npc|chat|full, or a + * vendor scenario: inspect|vendor-open|vendor-buy-sell|vendor-repair|stash-gold| + * gamble|vendor-verify — see tools/lib/vendor-scenarios.ts) + * --report= Write the vendor scenario report (server-driven before/after state) to disk */ import { randomBytes } from 'node:crypto' @@ -54,6 +57,7 @@ import { type GameServerAdapter, } from '../src/netproto/index.ts' import { connectNodeTcpStream } from './lib/node-tcp-stream.ts' +import { isVendorScenario, runVendorScenario } from './lib/vendor-scenarios.ts' import { D2S_MAGIC, D2S_QUEST_OFFSET, @@ -1461,7 +1465,7 @@ async function main(): Promise { } const mapService = - cmd === 'walk' + cmd === 'walk' || (cmd === 'record' && isVendorScenario(scenario)) ? await MapService.fromPackedDir(resolve(ROOT_DIR, 'public/act-packs/tiles')) : null @@ -1515,7 +1519,18 @@ async function main(): Promise { if (cmd === 'record') { console.log(`[d2-bot] Running scenario="${scenario}"...`) - await runInGameScenario(scenario, world, adapter) + if (isVendorScenario(scenario)) { + if (!mapService) throw new Error('vendor scenarios require the MapService') + const report = await runVendorScenario(scenario, world, adapter, mapService) + console.log(`[d2-bot] vendor report: ${JSON.stringify(report)}`) + if (typeof flags.report === 'string') { + const reportPath = resolve(flags.report) + mkdirSync(dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, JSON.stringify({ scenario, account: '', character: charName, report }, null, 2)) + } + } else { + await runInGameScenario(scenario, world, adapter) + } } const defaultIdleSec = cmd === 'idle' ? 60 : cmd === 'record' ? 6 : 5 diff --git a/tools/lib/vendor-scenarios.ts b/tools/lib/vendor-scenarios.ts new file mode 100644 index 0000000..3224173 --- /dev/null +++ b/tools/lib/vendor-scenarios.ts @@ -0,0 +1,372 @@ +/** + * Scripted vendor / stash / gamble scenarios for `tools/d2-bot.ts record --scenario=`. + * + * Every state change printed here comes from server packets applied to `ClientWorld` + * (stat 14 / 15, 0x9C/0x9D item events, 0x2A NpcTransaction). Nothing is mutated locally. + * + * Scenarios: + * inspect print self / inventory / town NPC + object list (read-only) + * vendor-open Charsi: 0x13 -> 0x2F -> 0x38 action 1 (trade) -> 0x30 (fixture 05) + * vendor-buy-sell Akara: sell an inventory item, buy the cheapest affordable item, sell it back, + * attempt an unaffordable buy (fixture 06) + * vendor-repair Charsi: single-item repair + repair-all (fixture 07) + * stash-gold stash: deposit 1 / withdraw 1 / withdraw more than the bank holds / close (fixture 08) + * gamble Gheed: 0x38 action 2, attempt a gamble purchase (fixture 09) + * vendor-verify reversible end-to-end run: open shop, buy, sell back, repair, deposit 1, withdraw 1 + */ + +import type { MapService } from '../../src/client/map/map-service.ts' +import { findPathWorld } from '../../src/client/motion/pathfind.ts' +import type { ClientWorld } from '../../src/client/world/client-world.ts' +import type { ClientUnit } from '../../src/client/world/client-unit.ts' +import { + ClickButtonId, + unitTypeToId, + type GameServerAdapter, + type ItemData, + type ServerEvent, +} from '../../src/netproto/index.ts' + +/** monstats.txt hcIdx of the Act 1 town NPCs used here. */ +export const NPC_CLASS = { + Gheed: 147, + Akara: 148, + Charsi: 154, +} as const + +/** objects.txt id 267 = bank (stash); D2Game 0x6fc917e0 requires interact state 2 with this class. */ +export const OBJECT_CLASS_STASH = 267 + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export const VENDOR_SCENARIOS = [ + 'inspect', + 'vendor-open', + 'vendor-buy-sell', + 'vendor-repair', + 'stash-gold', + 'gamble', + 'vendor-verify', +] as const + +export function isVendorScenario(s: string): s is (typeof VENDOR_SCENARIOS)[number] { + return (VENDOR_SCENARIOS as readonly string[]).includes(s) +} + +interface Ctx { + readonly world: ClientWorld + readonly map: MapService + /** Predicted own position (the server does not echo our own walk hops). */ + pos: { x: number; y: number } + readonly adapter: GameServerAdapter + readonly log: (msg: string) => void + readonly tx: Array> + readonly report: Record +} + +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)}` +} + +function dist(world: ClientWorld, u: ClientUnit, from?: { x: number; y: number }): number { + const p = from ?? world.self + return Math.hypot(u.x - p.x, u.y - p.y) +} + +function findUnit(world: ClientWorld, kind: 'monster' | 'object', classId: number): ClientUnit { + const units = world.getUnitsByType(kind).filter((u) => u.classId === classId && u.x > 0 && u.y > 0) + if (units.length === 0) { + throw new Error(`no ${kind} with class ${classId} visible (area=${world.areaId})`) + } + units.sort((a, b) => dist(world, a) - dist(world, b)) + return units[0]! +} + +async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, within = 4): Promise { + if (dist(ctx.world, u, ctx.pos) <= within) return + const view = ctx.map.getActiveLevelView() ?? ctx.map.getLevelView(ctx.world.areaId || 1, 'all', true) + const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, u.x, u.y, { + smooth: true, + nearestTargetRadius: within, + maxExpandedNodes: 32768, + }) + if (res.path.length < 2) { + throw new Error(`no path to ${kind} #${u.id} (class ${u.classId}) from (${ctx.pos.x},${ctx.pos.y})`) + } + for (const wp of res.path.slice(1)) { + // Subdivide into <= 3 sub-tile hops like the real client's short click-moves. + const d = Math.hypot(wp.x - ctx.pos.x, wp.y - ctx.pos.y) + const n = Math.max(1, Math.ceil(d / 3)) + const sx = ctx.pos.x + const sy = ctx.pos.y + for (let i = 1; i <= n; i++) { + const hx = Math.round(sx + ((wp.x - sx) * i) / n) + 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 } }) + await sleep(Math.max(150, Math.ceil(hd * 140))) + ctx.world.tick() + ctx.pos = { x: hx, y: hy } + } + } + ctx.log(`walked to (${ctx.pos.x},${ctx.pos.y}); ${kind} #${u.id} dist=${dist(ctx.world, u, ctx.pos).toFixed(1)}`) +} + +async function waitFor(ctx: Ctx, pred: () => boolean, timeoutMs: number, what: string): Promise { + const end = Date.now() + timeoutMs + while (Date.now() < end) { + if (pred()) return true + await sleep(100) + } + ctx.log(`timeout waiting for ${what}`) + return false +} + +async function waitTx(ctx: Ctx, fromIndex: number, timeoutMs = 3000): Promise | null> { + const ok = await waitFor(ctx, () => ctx.tx.length > fromIndex, timeoutMs, '0x2A NpcTransaction') + if (!ok) return null + // Let the trailing 0x9C/0x9D/stat packets of the same transaction arrive. + await sleep(600) + return ctx.tx[fromIndex]! +} + +function snapshot(ctx: Ctx, label: string): Record { + const s = { + gold: ctx.world.self.gold, + stashGold: ctx.world.self.stashGold, + level: ctx.world.self.level, + inventory: ctx.world.containers.inventory.size, + shop: ctx.world.containers.shop.size, + } + ctx.log(`[${label}] gold=${s.gold} stash=${s.stashGold} clvl=${s.level} inv=${s.inventory} shop=${s.shop}`) + return s +} + +async function openNpc(ctx: Ctx, npcClass: number): Promise { + const npc = findUnit(ctx.world, 'monster', npcClass) + 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 }) + await sleep(700) + ctx.adapter.send({ 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 } }) + 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 }) + await waitFor(ctx, () => ctx.world.containers.shop.size > before, 4000, 'shop items') + await sleep(1200) + const items = [...ctx.world.containers.shop.values()] + ctx.log(`${gamble ? 'gamble' : 'trade'} shop opened: ${items.length} items`) + const pages = new Map() + for (const it of items) pages.set(it.storePage, (pages.get(it.storePage) ?? 0) + 1) + ctx.log(` storePage histogram: ${[...pages].map(([p, n]) => `${p}:${n}`).join(' ')}`) + for (const it of items.slice(0, 80)) ctx.log(` shop ${fmtItem(it)}`) + return items +} + +function sellCandidate(world: ClientWorld): ItemData | undefined { + // Inventory items only (dwMode 0 = stored); never equipped gear or belt potions. + return [...world.containers.inventory.values()].sort((a, b) => a.id - b.id)[0] +} + +async function sell(ctx: Ctx, npc: ClientUnit, item: ItemData, label: string): Promise { + 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 }) + 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 } +} + +async function buy( + ctx: Ctx, + npc: ClientUnit, + item: ItemData, + opts: { gamble: boolean; shiftFill: boolean; cost: number }, + label: string, +): Promise { + const g0 = ctx.world.self.gold + const inv0 = new Set(ctx.world.containers.inventory.keys()) + 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 }) + 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)) + 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}) newInv=[${newInv.map(fmtItem).join('; ')}] newBelt=${newBelt.length}`) + ctx.report[label] = { item: item.code, result: t?.result, flag: t?.flag, goldBefore: g0, goldAfter: ctx.world.self.gold, newInv: newInv.map((x) => x.id) } + return newInv[0]?.id +} + +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 }) + 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 } +} + +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 } }) + await sleep(1500) +} + +function damagedEquipped(world: ClientWorld): ItemData | undefined { + for (const it of world.containers.equipment.values()) { + if (it.curDurability !== undefined && it.maxDurability !== undefined && it.curDurability < it.maxDurability) return it + } + return undefined +} + +export async function runVendorScenario( + scenario: (typeof VENDOR_SCENARIOS)[number], + world: ClientWorld, + adapter: GameServerAdapter, + map: MapService, +): Promise> { + const tx: Ctx['tx'] = [] + const off = adapter.onEvent((ev) => { + if (ev.type === 'NpcTransaction') tx.push(ev) + }) + const ctx: Ctx = { + world, + adapter, + map, + pos: { x: world.self.x, y: world.self.y }, + tx, + report: {}, + log: (m) => console.log(`[vendor] ${m}`), + } + const offPos = adapter.onEvent((ev) => { + // Server-authoritative corrections of our own position. + if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) ctx.pos = { x: ev.at.x, y: ev.at.y } + }) + try { + await sleep(1500) + ctx.report.before = snapshot(ctx, 'before') + ctx.log(`area=${world.areaId} act=${world.act} self=(${world.self.x},${world.self.y})`) + if (scenario === 'inspect') { + for (const it of world.containers.inventory.values()) ctx.log(`inv ${fmtItem(it)}`) + for (const it of world.containers.equipment.values()) { + ctx.log(`eq ${fmtItem(it)} dur=${it.curDurability}/${it.maxDurability} bodyLoc=${it.bodyLoc}`) + } + for (const it of world.containers.belt.values()) ctx.log(`belt ${fmtItem(it)}`) + for (const u of world.getUnitsByType('monster')) ctx.log(`npc #${u.id} class=${u.classId} at (${u.x},${u.y})`) + for (const u of world.getUnitsByType('object')) ctx.log(`obj #${u.id} class=${u.classId} at (${u.x},${u.y})`) + } else if (scenario === 'vendor-open') { + const npc = await openNpc(ctx, NPC_CLASS.Charsi) + const items = await openShop(ctx, npc, false) + ctx.report.shopItems = items.length + await closeNpc(ctx, npc) + } else if (scenario === 'vendor-buy-sell' || scenario === 'vendor-verify') { + const npc = await openNpc(ctx, NPC_CLASS.Akara) + const items = await openShop(ctx, npc, false) + ctx.report.shopItems = items.length + if (scenario === 'vendor-buy-sell') { + // Starter items are the only natural gold source in town for a fresh character. + let n = 0 + for (let cand = sellCandidate(world); cand && n < 2; cand = sellCandidate(world), n++) { + await sell(ctx, npc, cand, `sell-starter-${n}`) + } + } + // Cheapest consumable by code family that the server can price (hp1 / mp1 / tsc / isc). + const prefer = ['hp1', 'mp1', 'isc', 'tsc', 'hp2', 'mp2'] + const target = prefer.map((c) => items.find((it) => it.code === c)).find((x) => x !== undefined) + if (target) { + const boughtId = await buy(ctx, npc, target, { gamble: false, shiftFill: false, cost: 1 }, 'buy') + if (boughtId !== undefined) { + const bought = world.containers.inventory.get(boughtId) + if (bought) await sell(ctx, npc, bought, 'sell-back') + } + } else { + ctx.log('no consumable on the shelf to buy') + } + if (scenario === 'vendor-buy-sell') { + const pricey = items.filter((it) => it.code !== target?.code).sort((a, b) => b.id - a.id)[0] + if (pricey) await buy(ctx, npc, pricey, { gamble: false, shiftFill: false, cost: 1 }, 'buy-unaffordable') + if (target) await buy(ctx, npc, target, { gamble: false, shiftFill: true, cost: 1 }, 'buy-shift') + } + await closeNpc(ctx, npc) + if (scenario === 'vendor-verify') { + const smith = await openNpc(ctx, NPC_CLASS.Charsi) + await openShop(ctx, smith, false) + const dmg = damagedEquipped(world) + const i = ctx.tx.length + if (dmg) { + const cur = dmg.curDurability! + adapter.send({ type: 'NpcRepairItem', npcId: smith.id, itemId: dmg.id, itemMode: 1, durability: cur }) + } else { + adapter.send({ type: 'NpcRepairAll', npcId: smith.id }) + } + const t = await waitTx(ctx, i) + ctx.log(`repair(${dmg ? 'item' : 'all'}): result=${t?.result} flag=${t?.flag} gold=${world.self.gold}`) + ctx.report.repair = { kind: dmg ? 'item' : 'all', result: t?.result, flag: t?.flag } + await closeNpc(ctx, smith) + await openStash(ctx) + await stashGold(ctx, ClickButtonId.DepositGold, 1, 'deposit-1') + await stashGold(ctx, ClickButtonId.WithdrawGold, 1, 'withdraw-1') + adapter.send({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }) + await sleep(800) + } + } else if (scenario === 'vendor-repair') { + const npc = await openNpc(ctx, NPC_CLASS.Charsi) + await openShop(ctx, npc, false) + const dmg = damagedEquipped(world) + const eq = dmg ?? [...world.containers.equipment.values()][0] + if (eq) { + if (eq.curDurability === undefined) throw new Error(`equipped ${eq.code} has no durability`) + const cur = eq.curDurability + const i = ctx.tx.length + ctx.log(`repair-item ${fmtItem(eq)} durability=${cur} damaged=${dmg !== undefined}`) + adapter.send({ type: 'NpcRepairItem', npcId: npc.id, itemId: eq.id, itemMode: 1, durability: cur }) + const t = await waitTx(ctx, i) + ctx.log(`repair-item: result=${t?.result} flag=${t?.flag} gold=${world.self.gold}`) + ctx.report.repairItem = { item: eq.code, damaged: dmg !== undefined, result: t?.result, flag: t?.flag } + } + await sleep(2100) // D2Client throttles repair-all to one request per 2000 ms + const i = ctx.tx.length + adapter.send({ type: 'NpcRepairAll', npcId: npc.id }) + const t = await waitTx(ctx, i) + ctx.log(`repair-all: result=${t?.result} flag=${t?.flag} gold=${world.self.gold}`) + ctx.report.repairAll = { result: t?.result, flag: t?.flag } + await closeNpc(ctx, npc) + } else if (scenario === 'stash-gold') { + await openStash(ctx) + await stashGold(ctx, ClickButtonId.DepositGold, 1, 'deposit-1') + await stashGold(ctx, ClickButtonId.WithdrawGold, 1, 'withdraw-1') + await stashGold(ctx, ClickButtonId.WithdrawGold, world.self.stashGold + 1, 'withdraw-more-than-bank') + adapter.send({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }) + await sleep(1000) + } else if (scenario === 'gamble') { + const npc = await openNpc(ctx, NPC_CLASS.Gheed) + const items = await openShop(ctx, npc, true) + ctx.report.shopItems = items.length + const pick = items.find((it) => it.code === 'rin') ?? items[0] + if (pick) await buy(ctx, npc, pick, { gamble: true, shiftFill: false, cost: 1 }, 'gamble-buy') + await closeNpc(ctx, npc) + } + ctx.report.after = snapshot(ctx, 'after') + ctx.report.tx = tx.map((t) => ({ result: t.result, flag: t.flag, itemId: t.itemId, gold: t.gold })) + return ctx.report + } finally { + off() + offPos() + } +} From 2e9216fc7fa4fc0c7977bc819e62cc98bcd66a2f Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:08:43 +0000 Subject: [PATCH 3/8] 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. --- src/client/session/online-session.ts | 1 + src/client/world/client-world.ts | 85 ++++++++++++++++++++++ src/client/world/inventory.ts | 22 +++++- tests/client/vendor-stash-replay.test.ts | 92 ++++++++++++++++++++++++ tools/lib/vendor-scenarios.ts | 25 ++++--- 5 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 tests/client/vendor-stash-replay.test.ts 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) } From 642cecbd2b5c19f97a7bffd9bdb275136ff51e3f Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:25:35 +0000 Subject: [PATCH 4/8] feat(items): port D2Common 1.13c #10107/#10071 transaction and repair-all cost computeTransactionCost113c (buy/sell/gamble/repair) and computeRepairAllCost113c ported branch by branch from 0x6fd78e80 and 0x6fd7a210 (addresses cited inline); missing table data throws. item-cost.ts is marked legacy single-player and loses its fabricated 1.10 addresses and max-buy defaults. Display-only: D2Game ignores the client cost field (0x6fcdf200 buy, 0x6fcef570 sell). Includes a capture cross-check: starter scrolls sell for 1 gold (fixture 06). --- src/common/items/item-cost.ts | 642 ++++++--------- src/common/items/transaction-cost.ts | 1137 ++++++++++++++++++++++++++ tests/item-cost.test.ts | 4 +- tests/transaction-cost-113c.test.ts | 307 +++++++ 4 files changed, 1685 insertions(+), 405 deletions(-) create mode 100644 src/common/items/transaction-cost.ts create mode 100644 tests/transaction-cost-113c.test.ts diff --git a/src/common/items/item-cost.ts b/src/common/items/item-cost.ts index 4685af0..5291a4c 100644 --- a/src/common/items/item-cost.ts +++ b/src/common/items/item-cost.ts @@ -1,11 +1,33 @@ +/** + * LEGACY vendor pricing over the server-side `Item` model. + * + * The 1.13c ground-truth port is `./transaction-cost.ts`, which ports + * `computeTransactionCost113c` (#10107, body 0x6fd78e80) and + * `computeRepairAllCost113c` (#10071, 0x6fd7a210). New code must use it. + * + * This module came from a D2MOO (1.10) port. It stays because + * `vendor-service.ts` and the legacy simulation tests price `Item` objects, + * which carry rolled properties instead of a full/base stat list. The fake + * addresses and permissive fallbacks have been removed: + * - ItemsTxt fields are read from the canonical compiled tables. + * - Missing Npc / Books / skill / ItemStatCost records throw. + * - Gamble pricing delegates to the 1.13c port. + */ import type { DropTables } from './drop-tables.ts' import { ItemFlag, ItemQuality, type Item, type ItemBase, type RolledItemProp } from './items.ts' -import { getItemTypeCode } from './affix-generator.ts' import type { ItemTypeTable } from './item-types.ts' -import { VendorNpcId, getTownNpcDescriptorByVendorId, type NpcRecord } from '../units/npc-table.ts' +import type { RolledAutoMagic } from './automagic.ts' +import { VendorNpcId, type NpcRecord } from '../units/npc-table.ts' +import { + computeGambleBaseCost113c, + fogMulDiv113c, + getCanonicalTransactionCostTables, + isItemCodeOfType113c, + type ItemsTxtCostRecord, +} from './transaction-cost.ts' /** - * Canonical 1.13c transaction types (`D2C_TransactionTypes` in D2Common.dll). + * Canonical 1.13c transaction types (`D2C_TransactionTypes`). */ export const enum TransactionType { BUY = 0, @@ -14,15 +36,6 @@ export const enum TransactionType { REPAIR = 3, } -/** - * Normal difficulty fallback Max Buy caps by Act (Acts 1..5), Nightmare, and Hell. - */ -export const DEFAULT_NORMAL_MAX_BUY_BY_ACT: readonly [number, number, number, number, number] = [ - 5000, 10000, 15000, 20000, 25000, -] as const -export const DEFAULT_NIGHTMARE_MAX_BUY = 30000 -export const DEFAULT_HELL_MAX_BUY = 35000 - export interface TransactionCostOptions { readonly item: Item readonly transactionType: TransactionType @@ -30,78 +43,79 @@ export interface TransactionCostOptions { readonly cLvl?: number | undefined readonly vendorId?: VendorNpcId | undefined readonly npcKey?: string | undefined + /** Unused since the Npc.txt record is required; kept so existing callers compile. */ readonly actIndex?: 0 | 1 | 2 | 3 | 4 | undefined readonly completedQuests?: ReadonlySet | undefined readonly reducedPricesPercent?: number | undefined readonly tables: DropTables } +/** `0x6fd760f0` caps stack sizes here. */ +const MAX_STACK_CAP = 511 +/** `0x6fd78e80` caps reduced prices here (no lower clamp). */ +const REDUCED_PRICES_CAP = 99 +/** Encode-2/3 param split. UNCONFIRMED; see `transaction-cost.ts`. */ +const SKILL_PARAM_SHIFT = 6 +const SKILL_PARAM_MASK = 0x3f + +/** ItemsTxt record of a base; throws if the code is unknown. */ +function txt(base: ItemBase): ItemsTxtCostRecord { + const rec = getCanonicalTransactionCostTables().items.get(base.id) + if (!rec) throw new Error(`item-cost: unknown ItemsTxt code '${base.id}'`) + return rec +} + +function primaryTypeOf(base: ItemBase, itemTypes: ItemTypeTable): ReturnType { + const code = txt(base).type + const def = itemTypes.byCode.get(code) + if (!def) throw new Error(`item-cost: unknown ItemTypes code '${code}' for '${base.id}'`) + return def +} + /** - * Checks whether an item base belongs to a class-specific ItemType (`ItemTypes.Class !== ''`, i.e., `nClass < 7`). + * Whether the primary ItemType is class-specific (`ItemTypes.Class` set, `nClass < 7`). + * 0x6fd798b0 reads the primary type only, with no equivalences. */ export function isClassSpecificItemBase(base: ItemBase, itemTypes: ItemTypeTable): boolean { - const startType = getItemTypeCode(base).trim() - const queue: string[] = [startType, ...(base.tags ?? [])] - const visited = new Set() + const def = primaryTypeOf(base, itemTypes) + return Boolean(def?.class && def.class.trim().length > 0) +} - while (queue.length > 0) { - const code = queue.shift()!.trim() - if (!code || visited.has(code.toLowerCase())) continue - visited.add(code.toLowerCase()) +/** Whether the primary ItemType has StaffMods (`nStaffMods != 7`), as 0x6fd75970 checks. */ +export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean { + const def = primaryTypeOf(base, itemTypes) + return Boolean(def?.staffMods && def.staffMods.trim().length > 0) +} - const def = itemTypes.byCode.get(code) - if (!def) continue - if (def.class && def.class.trim().length > 0) { - return true - } - if (def.equiv1) queue.push(def.equiv1) - if (def.equiv2) queue.push(def.equiv2) - } - return false +/** Primary ItemType `Quiver` set (0x6fd74e60). */ +export function isQuiverItem(base: ItemBase): boolean { + const t = getCanonicalTransactionCostTables().itemTypes.get(txt(base).type) + if (!t) throw new Error(`item-cost: unknown ItemTypes code '${txt(base).type}'`) + return t.quiver +} + +/** `book` type, with equivalences (0x6fd74430). */ +export function isBookItem(base: ItemBase): boolean { + return isItemCodeOfType113c(getCanonicalTransactionCostTables(), base.id, 'book') +} + +function isEthereal(item: Item): boolean { + return Boolean(item.flags?.ethereal) || ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 +} + +function maxStackOf(base: ItemBase): number { + const n = txt(base).maxStack + return n > MAX_STACK_CAP ? MAX_STACK_CAP : n } /** - * Checks whether an item base can receive StaffMods (`ItemTypes.StaffMods !== ''`, i.e., `nStaffMods != 7`). + * LEGACY "needs repair" predicate used by the vendor UI. It is NOT + * `ITEMS_IsRepairable` (0x6fd77090). That function is + * `isItemRepairable113c` in `./transaction-cost.ts`, and it also returns true + * for undamaged items. */ -export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean { - const startType = getItemTypeCode(base).trim() - const queue: string[] = [startType, ...(base.tags ?? [])] - const visited = new Set() - - while (queue.length > 0) { - const code = queue.shift()!.trim() - if (!code || visited.has(code.toLowerCase())) continue - visited.add(code.toLowerCase()) - - const def = itemTypes.byCode.get(code) - if (!def) continue - if (def.staffMods && def.staffMods.trim().length > 0) { - return true - } - if (def.equiv1) queue.push(def.equiv1) - if (def.equiv2) queue.push(def.equiv2) - } - return false -} - -export function isQuiverItem(base: ItemBase): boolean { - const t = (base.type ?? '').toLowerCase() - const c = base.id.toLowerCase() - return t === 'bowq' || t === 'xbowq' || c === 'aqv' || c === 'cqv' -} - -export function isBookItem(base: ItemBase): boolean { - const t = (base.type ?? '').toLowerCase() - const c = base.id.toLowerCase() - return t === 'book' || c === 'tbk' || c === 'ibk' -} - export function isItemRepairable(item: Item): boolean { - const isEthereal = - Boolean(item.flags?.ethereal) || - Boolean((item as any).ethereal) || - ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 - if (isEthereal) return false + if (isEthereal(item)) return false if (isQuiverItem(item.base) || isBookItem(item.base)) return false @@ -111,20 +125,21 @@ export function isItemRepairable(item: Item): boolean { ) if (hasIndestructible) return false - const maxDur = item.maxDurability ?? Number((item.base as any).durability) ?? 0 + const rec = txt(item.base) + const maxDur = item.maxDurability ?? (rec.noDurability ? 0 : rec.durability) const curDur = item.durability ?? maxDur if (maxDur > 0 && curDur < maxDur) { return true } - // Also repairable if stackable throwing weapon below maxStack - if ((item.base as any).stackable && item.base.kind === 'weapon') { - const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 + // A stackable throwing weapon below its max stack can be refilled. + if (rec.stackable && item.base.kind === 'weapon') { + const maxStack = maxStackOf(item.base) const qty = item.quantity ?? item.stack ?? maxStack if (qty < maxStack) return true } - // Also repairable if it has partially depleted charged skills + // Charged skills that have been used can be recharged. if (item.rolledProps?.some(p => p.code.toLowerCase() === 'charged')) { return true } @@ -133,8 +148,8 @@ export function isItemRepairable(item: Item): boolean { } /** - * Implements `ITEMS_CalculateAdditionalCostsForItemSkill` (`0x6FD9CB50`) - * for single-skill staffmods (`STAT_ITEM_SINGLESKILL`) on StaffMods-eligible item types. + * StaffMods pricing (`0x6fd75970`) for single-skill staffmods + * (`STAT_ITEM_SINGLESKILL`) on StaffMods item types. */ export function calculateAdditionalCostsForItemSkill( item: Item, @@ -156,15 +171,18 @@ export function calculateAdditionalCostsForItemSkill( return { sellCost, buyCost, repCost } } + const skillCosts = tables.skillCosts + if (!skillCosts) throw new Error('item-cost: DropTables.skillCosts is required for StaffMods pricing') + let nSell = 0 let nBuy = 0 let nRep = 0 for (const sm of staffMods) { const skillId = Number(sm.param) - const slvl = Math.max(1, Math.trunc(sm.value)) - const skillRec = tables.skillCosts?.get(skillId) - if (!skillRec) continue + const slvl = Math.trunc(sm.value) + const skillRec = skillCosts.get(skillId) + if (!skillRec) throw new Error(`item-cost: no Skills.txt cost record for skill ${skillId}`) const factor = 2 * slvl - 1 nSell += factor * (skillRec.costAdd + Math.trunc((sellCost * skillRec.costMult) / 1024)) @@ -172,18 +190,17 @@ export function calculateAdditionalCostsForItemSkill( nRep += factor * (skillRec.costAdd + Math.trunc((repCost * skillRec.costMult) / 1024)) } - const effDiv = Math.max(1, divisor) return { - sellCost: sellCost + Math.trunc(nSell / effDiv), - buyCost: buyCost + Math.trunc(nBuy / effDiv), - repCost: repCost + Math.trunc(nRep / effDiv), + sellCost: sellCost + Math.trunc(nSell / divisor), + buyCost: buyCost + Math.trunc(nBuy / divisor), + repCost: repCost + Math.trunc(nRep / divisor), } } /** - * Implements `ITEMS_CalculateAdditionalCostsForBonusStats` (`0x6FD9ACE0`). - * Iterates resolved bonus stats on the item and applies `ItemStatCost.txt` - * (`Encode`, `Add`, `Multiply`, `Divide`) or `Skills.txt` (`CostAdd`, `CostMult`). + * Bonus-stat pricing (`0x6fd78850`): applies `ItemStatCost.txt` + * (`Encode`, `Add`, `Multiply`) or `Skills.txt` (`CostAdd`, `CostMult`) to each + * rolled stat. `Divide` is not read by 1.13c. */ export function calculateAdditionalCostsForBonusStats( item: Item, @@ -195,15 +212,13 @@ export function calculateAdditionalCostsForBonusStats( ): { sellCost: number; buyCost: number; repCost: number } { const propertiesTable = tables.properties const statCostTable = tables.itemStatCost - if (!statCostTable) { - return { sellCost, buyCost, repCost } - } + if (!statCostTable) throw new Error('item-cost: DropTables.itemStatCost is required for bonus-stat pricing') let nSell = 0 let nBuy = 0 let nRep = 0 - // Collect resolved stats from rolledProps (excluding staffMods 'skill' which are handled by calculateAdditionalCostsForItemSkill) + // Collect resolved stats. StaffMods 'skill' entries are priced by calculateAdditionalCostsForItemSkill. const staffModSet = new Set(item.staffMods ?? []) const props = item.rolledProps ?? [] @@ -211,14 +226,12 @@ export function calculateAdditionalCostsForBonusStats( readonly statId: number readonly param: number readonly value: number - readonly propCode?: string - readonly min?: number - readonly max?: number } const entries: StatBonusEntry[] = [] - if (props.length > 0 && propertiesTable) { + if (props.length > 0) { + if (!propertiesTable) throw new Error('item-cost: DropTables.properties is required to resolve rolled properties') for (const prop of props) { if (staffModSet.has(prop)) continue if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) { @@ -237,33 +250,27 @@ export function calculateAdditionalCostsForBonusStats( prop.value, statCostTable, ) - if (resolved.length > 0) { - for (const r of resolved) { - entries.push({ - statId: r.statId, - param: r.param ?? numParam, - value: r.value, - propCode: prop.code, - min: prop.min, - max: prop.max, - }) - } + for (const r of resolved) { + entries.push({ statId: r.statId, param: r.param ?? numParam, value: r.value }) } } } else if (item.statList && item.statList.length > 0) { for (const s of item.statList) { - entries.push({ - statId: s.statId, - param: s.param ?? 0, - value: s.value, - }) + entries.push({ statId: s.statId, param: s.param ?? 0, value: s.value }) } } + const skillCosts = tables.skillCosts + const requireSkill = (id: number): { costAdd: number; costMult: number } => { + const rec = skillCosts?.get(id) + if (!rec) throw new Error(`item-cost: no Skills.txt cost record for skill ${id}`) + return rec + } + for (const entry of entries) { const statRec = statCostTable.getById(entry.statId) - if (!statRec) continue - // STAT_ITEM_SINGLESKILL (107) on staffMod items is priced by calculateAdditionalCostsForItemSkill + if (!statRec) throw new Error(`item-cost: no ItemStatCost record for stat ${entry.statId}`) + // STAT_ITEM_SINGLESKILL (107) on StaffMods items is priced by calculateAdditionalCostsForItemSkill. if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) { continue } @@ -272,47 +279,45 @@ export function calculateAdditionalCostsForBonusStats( switch (statRec.encode) { case 1: { - const skillRec = tables.skillCosts?.get(entry.param) - if (skillRec) { - nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) - nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) - nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024) - } + const skillRec = requireSkill(entry.param) + nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) + nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) + nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024) break } case 2: case 3: { - const skillId = entry.param > 63 ? entry.param >> 6 : entry.param - const slvl = entry.param > 63 ? Math.max(1, entry.param & 0x3f) : Math.max(1, entry.max ?? 1) - const skillRec = tables.skillCosts?.get(skillId) - if (skillRec) { - nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) - nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096) - nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024) - } + const skillRec = requireSkill(entry.param >> SKILL_PARAM_SHIFT) + const slvl = entry.param & SKILL_PARAM_MASK + nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) + nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096) + nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024) break } default: { - if (statRec.multiply > 0 || statRec.add > 0) { - nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) - nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) - nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024) - } + nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) + nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) + nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024) break } } } - const effDiv = Math.max(1, divisor) return { - sellCost: sellCost + Math.trunc(nSell / effDiv), - buyCost: buyCost + Math.trunc(nBuy / effDiv), - repCost: repCost + Math.trunc(nRep / effDiv), + sellCost: sellCost + Math.trunc(nSell / divisor), + buyCost: buyCost + Math.trunc(nBuy / divisor), + repCost: repCost + Math.trunc(nRep / divisor), } } +function capReducedPrices(pct: number): number { + const v = Math.trunc(pct) + return v >= REDUCED_PRICES_CAP ? REDUCED_PRICES_CAP : v +} + /** - * Calculates the 1.13c Gamble purchase price for an item (`ITEMS_CalculateTransactionCost` branch `TRANSACTIONTYPE_GAMBLE` at `0x6FD9B1C0`). + * Gamble price (`0x6fd78f38` → `0x6fd74840`), delegated to the 1.13c port. + * There is no minimum clamp: the asm returns this value directly. */ export function calculateGambleCost(options: { readonly item: Item @@ -320,87 +325,35 @@ export function calculateGambleCost(options: { readonly reducedPricesPercent?: number | undefined readonly tables: DropTables }): number { - const { item, cLvl, reducedPricesPercent = 0, tables } = options - const normCode = item.base.normcode || item.base.id - const normBase = tables.getBase(normCode) ?? item.base - const codeLower = normBase.id.toLowerCase() + const { item, cLvl, reducedPricesPercent = 0 } = options + let cost = computeGambleBaseCost113c(item.base.id, Math.trunc(cLvl), getCanonicalTransactionCostTables()) + const rp = capReducedPrices(reducedPricesPercent) + if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0 + return cost +} - let nCost = 0 - if (codeLower === 'rin' || codeLower === 'amu') { - nCost = normBase.gambleCost ?? (codeLower === 'rin' ? 50000 : 63000) - } else { - const minStack = Number((normBase as any).minstack) || 0 - const maxStack = Number((normBase as any).maxstack) || 0 - const v27 = Math.max(1, Math.trunc((minStack + maxStack) / 2)) +/** Row index of the rolled AutoMagic affix. drop-tables.ts spreads `rolledAutoMagic` onto the item without a typed field. */ +function rolledAutoMagicRow(item: Item): number | undefined { + if (!('rolledAutoMagic' in item)) return undefined + const rolled: unknown = item.rolledAutoMagic + if (rolled === undefined) return undefined + const parsed = rolled as Partial + if (!parsed.entry || typeof parsed.entry.id !== 'number') throw new Error('item-cost: malformed rolledAutoMagic') + return parsed.entry.id +} - let v23 = 0 - let nUberCost = 0 - if (normBase.ubercode && normBase.ubercode !== '0' && normBase.ubercode !== 'xxx') { - const uberBase = tables.getBase(normBase.ubercode) - if (uberBase) { - const v30 = 100 * (cLvl - uberBase.level) - v23 = Math.max(0, Math.trunc(v30 / 2) + 1) - nUberCost = uberBase.cost ?? uberBase.value - } - } - - let v22 = 0 - let nUltraCost = 0 - if (normBase.ultracode && normBase.ultracode !== '0' && normBase.ultracode !== 'xxx') { - const ultraBase = tables.getBase(normBase.ultracode) - if (ultraBase) { - const v30 = 100 * (cLvl - ultraBase.level) - v22 = Math.max(0, Math.trunc(v30 / 4) + 1) - nUltraCost = ultraBase.cost ?? ultraBase.value - } - } - - const normCost = normBase.cost ?? normBase.value - const nBase = - v23 * nUberCost + - v22 * nUltraCost + - v27 * normCost * (10000 - v22 - v23) - - const effLvl = Math.max(5, Math.trunc(cLvl)) - const baseQlvl = normBase.level - const lvlExcess = baseQlvl - 45 > 0 ? baseQlvl - 45 : 0 - - const factorA = Math.trunc((2 * effLvl + 1) / 3) + 20 - const factorB = - Math.trunc(nBase / 10000) + - Math.trunc((250 * (effLvl + lvlExcess - Math.trunc(baseQlvl / 2))) / 3) - - nCost = Math.trunc((factorA * factorB) / 15) - } - - const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) - if (clampedReduce > 0) { - nCost -= Math.trunc((nCost * clampedReduce) / 100) - } - - return Math.max(1, nCost) +function resolveNpcRecord(tables: DropTables, vendorId?: VendorNpcId, npcKey?: string): NpcRecord { + if (!tables.npcTable) throw new Error('item-cost: DropTables.npcTable is required') + const rec = + (npcKey ? tables.npcTable.get(npcKey) : undefined) ?? + (vendorId !== undefined ? tables.npcTable.getByVendorId(vendorId) : undefined) + if (!rec) throw new Error(`item-cost: no Npc.txt record for npcKey=${npcKey ?? '-'} vendorId=${vendorId ?? '-'}`) + return rec } /** - * Resolves the NpcRecord for a vendor / town NPC. - */ -function resolveNpcRecord( - tables: DropTables, - vendorId?: VendorNpcId, - npcKey?: string, -): NpcRecord | undefined { - if (npcKey && tables.npcTable) { - const byKey = tables.npcTable.get(npcKey) - if (byKey) return byKey - } - if (vendorId !== undefined && tables.npcTable) { - return tables.npcTable.getByVendorId(vendorId) - } - return undefined -} - -/** - * Full 1.13c item transaction & acquisition pricing engine (`ITEMS_CalculateTransactionCost` at `0x6FD9B1C0`). + * LEGACY transaction pricing over `Item` (structured after 0x6fd78e80). + * Prefer `computeTransactionCost113c`. */ export function calculateTransactionCost(options: TransactionCostOptions): number { const { @@ -410,7 +363,6 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe cLvl = 1, vendorId, npcKey, - actIndex, completedQuests, reducedPricesPercent = 0, tables, @@ -428,17 +380,13 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe } if (transactionType === TransactionType.GAMBLE) { - return calculateGambleCost({ - item, - cLvl, - reducedPricesPercent, - tables, - }) + return calculateGambleCost({ item, cLvl, reducedPricesPercent, tables }) } + const rec = txt(item.base) const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1)) - const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) - const baseCost = item.base.cost ?? item.base.value ?? 1 + const rp = capReducedPrices(reducedPricesPercent) + const baseCost = rec.cost let nSellCost = 0 let nBuyCost = 0 @@ -450,20 +398,20 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe ((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0 if (isEar) { - const earLvl = item.earData?.level ?? 1 + const earLvl = item.earData?.level + if (earLvl === undefined) throw new Error('item-cost: ear item has no earData.level') nSellCost = baseCost * earLvl nBuyCost = nSellCost } else if (isBookItem(item.base)) { const bookRec = tables.booksTable?.getByBookCode(item.base.id) - const costPerCharge = bookRec?.costPerCharge ?? 80 - nSellCost = baseCost + nQuantity * costPerCharge + if (!bookRec) throw new Error(`item-cost: no Books.txt record for '${item.base.id}'`) + nSellCost = baseCost + nQuantity * bookRec.costPerCharge nBuyCost = nSellCost } else if (isQuiverItem(item.base)) { nSellCost = Math.trunc((nQuantity * baseCost) / 1024) nBuyCost = nSellCost - const maxStack = Math.min(511, item.base.maxStack || 350) - nRepCost = Math.trunc((maxStack * baseCost) / 1024) - } else if (!(item.base as any).stackable) { + nRepCost = Math.trunc((maxStackOf(item.base) * baseCost) / 1024) + } else if (!rec.stackable) { nSellCost = baseCost nBuyCost = nSellCost nRepCost = nSellCost @@ -471,18 +419,19 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe nSellCost = baseCost nBuyCost = nSellCost nRepCost = nSellCost - const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 - nDivisor = Math.max(1, Math.min(511, maxStack)) + const maxStack = maxStackOf(item.base) + nDivisor = maxStack > 1 ? maxStack : 1 } - // Armor defense scaling (lines 2119-2131 of Items.cpp) - if (item.base.kind === 'armor') { - const minAc = Number((item.base as any).minac) || 0 - const maxAc = Number((item.base as any).maxac) || item.base.defense || 0 - if (maxAc > 0 && maxAc - minAc !== -1) { - // Base unit STAT_ARMORCLASS (before ac% bonus stat) + // 0x6fd7900b: armor scales by base defense over max defense + if (isItemCodeOfType113c(getCanonicalTransactionCostTables(), item.base.id, 'armo')) { + const minAc = rec.minAc + const maxAc = rec.maxAc + if (maxAc !== 0 && maxAc - minAc !== -1) { + // The Item model stores the final defense; strip ac% to recover the base roll. + const rawDef = item.defense ?? item.stats?.defense + if (rawDef === undefined) throw new Error(`item-cost: armor '${item.base.id}' has no defense value`) const acPct = item.stats?.['ac%'] ?? 0 - const rawDef = item.defense ?? item.stats?.defense ?? maxAc const baseAc = acPct > 0 ? Math.min(maxAc + 1, Math.max(minAc, Math.round((rawDef * 100) / (100 + acPct)))) @@ -495,16 +444,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe const quality = item.quality ?? ItemQuality.NORMAL - // StaffMods for Normal / Low / Superior items (lines 2135-2138) + // StaffMods before affixes for non-magic qualities if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) { - const afterSkill = calculateAdditionalCostsForItemSkill( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) + const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables) nSellCost = afterSkill.sellCost nBuyCost = afterSkill.buyCost nRepCost = afterSkill.repCost @@ -517,16 +459,25 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe let nSell = 0 let nBuy = 0 let nRep = 0 - - // AutoMagic affix cost (lines 2142-2157) - const autoAffix = (item as any).rolledAutoMagic?.entry - if (autoAffix && (autoAffix.add || autoAffix.multiply)) { - const add = autoAffix.add ?? 0 - const mult = autoAffix.multiply ?? 0 + const addTerm = (add: number, mult: number): void => { nSell += add + Math.trunc((nSellCost * mult) / 1024) nBuy += add + Math.trunc((nBuyCost * mult) / 1024) nRep += add + Math.trunc((nRepCost * mult) / 1024) } + const applyBonus = (): void => { + const afterBonus = calculateAdditionalCostsForBonusStats(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables) + nSellCost = afterBonus.sellCost + nBuyCost = afterBonus.buyCost + nRepCost = afterBonus.repCost + } + + // AutoMagic affix cost + const autoRow = rolledAutoMagicRow(item) + if (autoRow !== undefined) { + const auto = getCanonicalTransactionCostTables().autoMagic[autoRow] + if (!auto) throw new Error(`item-cost: AutoMagic row ${autoRow} outside table`) + addTerm(auto.add, auto.multiply) + } switch (quality) { case ItemQuality.RARE: @@ -534,25 +485,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe const rareAffixes = item.rolledRareAffixes?.affixes ?? [] for (const entry of rareAffixes) { const affix = entry.affix ?? entry - const add = Number(affix?.add) || 0 - const mult = Number(affix?.multiply) || 0 - if (add || mult) { - nSell += add + Math.trunc((nSellCost * mult) / 1024) - nBuy += add + Math.trunc((nBuyCost * mult) / 1024) - nRep += add + Math.trunc((nRepCost * mult) / 1024) - } + addTerm(Number(affix?.add) || 0, Number(affix?.multiply) || 0) } - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + applyBonus() break } @@ -561,23 +496,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe item.uniqueItemDef ?? tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name) if (uDef) { - const costAdd = Number(uDef.costAdd ?? (uDef as any).costadd) || 0 - const costMult = Number(uDef.costMult ?? (uDef as any).costmult) || 0 - nSell += costAdd + Math.trunc((nSellCost * costMult) / 1024) - nBuy += costAdd + Math.trunc((nBuyCost * costMult) / 1024) - nRep += costAdd + Math.trunc((nRepCost * costMult) / 1024) + addTerm(Number(uDef.costAdd) || 0, Number(uDef.costMult) || 0) } else { - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + applyBonus() } break } @@ -588,62 +509,23 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe tables.sets.items.entries.find( s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name, ) - if (sDef) { - const costAdd = Number(sDef.costAdd ?? (sDef as any).costadd) || 0 - const costMult = Number(sDef.costMult ?? (sDef as any).costmult) || 0 - nSell += costAdd + Math.trunc((nSellCost * costMult) / 1024) - nBuy += costAdd + Math.trunc((nBuyCost * costMult) / 1024) - nRep += costAdd + Math.trunc((nRepCost * costMult) / 1024) - } + if (sDef) addTerm(Number(sDef.costAdd) || 0, Number(sDef.costMult) || 0) break } case ItemQuality.MAGIC: { const pAffix = item.rolledMagicAffixes?.prefix?.affix - if (pAffix) { - const add = Number(pAffix.add) || 0 - const mult = Number(pAffix.multiply) || 0 - nSell += add + Math.trunc((nSellCost * mult) / 1024) - nBuy += add + Math.trunc((nBuyCost * mult) / 1024) - nRep += add + Math.trunc((nRepCost * mult) / 1024) - } + if (pAffix) addTerm(Number(pAffix.add) || 0, Number(pAffix.multiply) || 0) const sAffix = item.rolledMagicAffixes?.suffix?.affix - if (sAffix) { - const add = Number(sAffix.add) || 0 - const mult = Number(sAffix.multiply) || 0 - nSell += add + Math.trunc((nSellCost * mult) / 1024) - nBuy += add + Math.trunc((nBuyCost * mult) / 1024) - nRep += add + Math.trunc((nRepCost * mult) / 1024) - } - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + if (sAffix) addTerm(Number(sAffix.add) || 0, Number(sAffix.multiply) || 0) + applyBonus() break } case ItemQuality.SUPERIOR: - case ItemQuality.TEMPERED: { - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + case ItemQuality.TEMPERED: + applyBonus() break - } case ItemQuality.LOW: { nSell = -Math.trunc(nSellCost / 2) @@ -661,74 +543,52 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe nRepCost += Math.trunc(nRep / nDivisor) if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) { - const afterSkill = calculateAdditionalCostsForItemSkill( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) + const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables) nSellCost = afterSkill.sellCost nBuyCost = afterSkill.buyCost nRepCost = afterSkill.repCost } } - // Socketed items (lines 2311-2325 of Items.cpp) - if (item.socketedItems && item.socketedItems.length > 0) { - for (const socketable of item.socketedItems) { - const socketableCost = Math.trunc((socketable.base.cost ?? socketable.base.value ?? 0) / 2) - nSellCost += socketableCost - nBuyCost += socketableCost - nRepCost += socketableCost - } + // 0x6fd74a50: socketed children add half their ItemsTxt cost + for (const socketable of item.socketedItems ?? []) { + const socketableCost = Math.trunc(txt(socketable.base).cost / 2) + nSellCost += socketableCost + nBuyCost += socketableCost + nRepCost += socketableCost } - // Ethereal acquisition quartering (lines 2327-2330) - const isEthereal = - Boolean(item.flags?.ethereal) || - Boolean((item as any).ethereal) || - ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 - if (isEthereal) { + // 0x6fd798b0: ethereal and class-specific quartering of the player-sell price + const ethereal = isEthereal(item) + if (ethereal) { nBuyCost = Math.trunc(nBuyCost / 4) } - - // Class-specific item acquisition quartering (lines 2332-2340) if (isClassSpecificItemBase(item.base, tables.itemTypes)) { nBuyCost = Math.trunc(nBuyCost / 4) } - const maxDura = item.maxDurability ?? Number((item.base as any).durability) ?? 0 + const maxDura = item.maxDurability ?? (rec.noDurability ? 0 : rec.durability) const curDura = item.durability ?? maxDura if (transactionType === TransactionType.SELL) { - if (isEthereal && maxDura > 0 && curDura <= 0) { + if (ethereal && maxDura > 0 && curDura <= 0) { nBuyCost = 0 } } else if (transactionType === TransactionType.REPAIR) { if (maxDura > 0) { - if (curDura < maxDura) { - nRepCost = Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) - } else { - nRepCost = 0 - } + nRepCost = curDura < maxDura ? Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) : 0 } } - // Apply Npc.txt multipliers and quest discounts (lines 2388-2450) + // 0x6fd799d8 / 0x6fd79aa0: Npc.txt multipliers and quest discounts const npcRec = resolveNpcRecord(tables, vendorId, npcKey) - const sellMult = npcRec?.sellMult ?? 1024 - const buyMult = npcRec?.buyMult ?? 512 - const repMult = npcRec?.repMult ?? 1024 + nSellCost = Math.trunc((nSellCost * npcRec.sellMult) / 1024) + nBuyCost = Math.trunc((nBuyCost * npcRec.buyMult) / 1024) + nRepCost = Math.trunc((nRepCost * npcRec.repMult) / 1024) - nSellCost = Math.trunc((nSellCost * sellMult) / 1024) - nBuyCost = Math.trunc((nBuyCost * buyMult) / 1024) - nRepCost = Math.trunc((nRepCost * repMult) / 1024) - - if (npcRec && completedQuests && completedQuests.size > 0) { + if (completedQuests && completedQuests.size > 0) { for (let i = 0; i < 3; i++) { - const qFlag = npcRec.questFlag[i] ?? 0 + const qFlag = npcRec.questFlag[i]! if (qFlag > 0 && completedQuests.has(qFlag)) { nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024) nBuyCost = Math.trunc((nBuyCost * npcRec.questBuyMult[i]!) / 1024) @@ -737,51 +597,27 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe } } - // Quantity multiplication for non-book, non-quiver items (lines 2452-2483) + // 0x6fd79b8d: quantity for non-book, non-quiver items if (!isBookItem(item.base) && !isQuiverItem(item.base)) { nSellCost *= nQuantity - if ((item.base as any).stackable && isItemRepairable(item)) { - const maxStack = Math.min(511, item.base.maxStack || Number((item.base as any).maxstack) || 1) - if (nQuantity < maxStack) { - nRepCost *= maxStack - nQuantity - } else { - nRepCost = 0 - } - nBuyCost *= nQuantity - } else { - nBuyCost *= nQuantity + if (rec.stackable && isItemRepairable(item)) { + const maxStack = maxStackOf(item.base) + nRepCost = nQuantity < maxStack ? nRepCost * (maxStack - nQuantity) : 0 } + nBuyCost *= nQuantity } if (transactionType === TransactionType.SELL) { - let nCost = nBuyCost - let maxBuyCap: number - if (npcRec) { - maxBuyCap = npcRec.maxBuy[difficulty] ?? 35000 - } else if (difficulty === 0) { - const resolvedAct = - actIndex ?? - (vendorId !== undefined ? getTownNpcDescriptorByVendorId(vendorId)?.actIndex : undefined) ?? - 0 - maxBuyCap = DEFAULT_NORMAL_MAX_BUY_BY_ACT[resolvedAct] ?? 5000 - } else if (difficulty === 1) { - maxBuyCap = DEFAULT_NIGHTMARE_MAX_BUY - } else { - maxBuyCap = DEFAULT_HELL_MAX_BUY - } - - if (nCost > maxBuyCap) { - nCost = maxBuyCap - } + const cap = npcRec.maxBuy[difficulty] + const nCost = nBuyCost > cap ? cap : nBuyCost return Math.max(1, nCost) } if (transactionType === TransactionType.REPAIR) { - let nCost = nRepCost - Math.trunc((nRepCost * clampedReduce) / 100) + const nCost = rp !== 0 ? nRepCost - fogMulDiv113c(rp, nRepCost, 100) : nRepCost return Math.max(1, nCost) } - // TransactionType.BUY - let nCost = nSellCost - Math.trunc((nSellCost * clampedReduce) / 100) + const nCost = rp !== 0 ? nSellCost - fogMulDiv113c(rp, nSellCost, 100) : nSellCost return Math.max(1, nCost) } diff --git a/src/common/items/transaction-cost.ts b/src/common/items/transaction-cost.ts new file mode 100644 index 0000000..aeaf196 --- /dev/null +++ b/src/common/items/transaction-cost.ts @@ -0,0 +1,1137 @@ +/** + * Diablo II 1.13c item transaction pricing, ported from `D2Common.dll`. + * + * - `#10107` (`ITEMS_GetTransactionCost`) is a thunk at `0x6fd79d60` that + * forwards to the real body at `0x6fd78e80`. That body is + * {@link computeTransactionCost113c}. + * - `#10071` at `0x6fd7a210` totals the repair cost of everything the player + * has equipped. That is {@link computeRepairAllCost113c}. + * + * Every branch carries the address it was read from. All arithmetic is int32 + * (`Math.imul`, `| 0`, truncating division), as the x86 code does it, + * including the asm's quirks; those are marked `(sic)`. + * + * The input does not depend on any protocol. A client fills it from a + * server item packet; a server fills it from its own unit. The caller must + * supply the item's stat list (`full` and `base` values, the same split as + * `STATLIST_GetUnitStat*` 0x6fd88a80 / 0x6fd88b00). Durability, quantity, base + * defense and the like are read from that list, as the asm does. + * + * Names used below: + * - A = player buy price (vendor's "sell" side; Npc.txt `sell mult`). + * - B = player sell price (vendor's "buy" side; Npc.txt `buy mult`). The asm + * later moves it into `ebp`. + * - C = repair base. The asm later moves it into `edi`. + * - D = divisor applied to affix terms (stack size for stackables). + */ +import { RAW_ARMOR, RAW_ITEM_TYPES, RAW_MAGIC_PREFIX, RAW_MAGIC_SUFFIX, RAW_MISC, RAW_MONSTATS, RAW_SET_ITEMS, RAW_UNIQUE_ITEMS, RAW_WEAPONS } from '../data/canonical-drop-data.ts' +import { CANONICAL_SKILL_COSTS, RAW_BOOKS, RAW_ITEM_STAT_COST, RAW_NPC } from '../data/canonical-vendor-data.ts' +import { SKILLS_BY_ID } from '../data/skills-catalog.ts' +import { parseTable, type D2Table } from '../world/acts.ts' +import { CANONICAL_AUTOMAGIC_TSV } from './automagic.ts' + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * `D2C_TransactionTypes`: 0 = buy, 1 = sell, 2 = gamble, 3 = repair. + */ +export type TransactionType113c = 0 | 1 | 2 | 3 + +/** One entry of the item's stat list. */ +export interface TransactionCostStat { + readonly statId: number + /** Stat param (layer). Skill stats carry the skill id, or `skill << 6 | level`. */ + readonly param: number + /** Accumulated value (`0x6fd88a80`). */ + readonly value: number + /** Value in the item's own base list (`0x6fd88b00`). Magic properties are excluded. */ + readonly base: number +} + +/** Item quality as defined by `D2C_ItemQuality` (1 = low ... 9 = tempered). */ +export type ItemQuality113c = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + +/** What the pricing reads from one item. Ids are the ones the item packet carries. */ +export interface TransactionCostInput { + /** ItemsTxt code (`szCode`, 3-4 chars). */ + readonly code: string + /** + * `ItemData.wItemFormat` (+0x30), which is the packet's 10-bit `version` + * field. Only the gamble branch reads it (0x6fd78f38). + */ + readonly itemFormat: number + readonly quality: ItemQuality113c + /** `IFLAG_IDENTIFIED` (0x10). */ + readonly identified: boolean + /** `IFLAG_ETHEREAL` (0x400000). */ + readonly ethereal: boolean + /** `IFLAG_STARTER` (0x20000). */ + readonly starterItem: boolean + /** `IFLAG_ISEAR` (0x10000). */ + readonly isEar: boolean + /** Ear level (`ItemData+0x48`). Required when `isEar`. */ + readonly earLevel?: number | undefined + /** MonStats index (hcIdx) stored in `dwFileIndex` of a body part. Required for `body` items. */ + readonly bodyPartMonsterClass?: number | undefined + /** Books.txt row (`wMagicSuffix[0]`, the packet's 5-bit tome/scroll id). Required for `book` items. */ + readonly bookId?: number | undefined + /** Packet magic prefix id: 1-based into MagicPrefix.txt, 0 = none. Required for quality 4, and for 7 when the unique id is invalid. */ + readonly magicPrefixId?: number | undefined + /** Packet magic suffix id: 1-based into MagicSuffix.txt, 0 = none. */ + readonly magicSuffixId?: number | undefined + /** Three packet prefix ids for rare/crafted items (0 = none). */ + readonly rarePrefixIds?: readonly [number, number, number] | undefined + /** Three packet suffix ids for rare/crafted items (0 = none). */ + readonly rareSuffixIds?: readonly [number, number, number] | undefined + /** Packet automagic id: 1-based into AutoMagic.txt, 0 = none. */ + readonly autoAffixId?: number | undefined + /** UniqueItems.txt record index (`dwFileIndex`). Required for quality 7. */ + readonly uniqueItemId?: number | undefined + /** SetItems.txt record index (`dwFileIndex`). Required for quality 5. */ + readonly setItemId?: number | undefined + /** The item's stat list (full and base values). */ + readonly stats: readonly TransactionCostStat[] + /** ItemsTxt codes of the items socketed into this one. */ + readonly socketedChildCodes: readonly string[] +} + +/** Item-independent state of the transaction. */ +export interface TransactionCostContext { + /** Player `STAT_LEVEL` (12). */ + readonly playerLevel: number + /** Player full `STAT_ITEM_REDUCEDPRICES` (87). The asm only caps it at 99. */ + readonly playerReducedPricesPct: number + readonly difficulty: 0 | 1 | 2 + /** MonStats index (hcIdx) of the NPC (`Npc.txt` is searched on it). Not read for gamble. */ + readonly npcClass: number + /** Quest-flag test `0x6fd7d0f0`: bit `quest * 16 + state` of the player's quest record. */ + readonly isQuestFlagSet: (questId: number, state: 0 | 1) => boolean + readonly tables: TransactionCostTables +} + +export interface ItemsTxtCostRecord { + readonly code: string + readonly type: string + readonly type2: string + /** +0xe0 */ + readonly cost: number + /** +0xd4 */ + readonly gambleCost: number + /** +0xe4 / +0xe8 */ + readonly minStack: number + readonly maxStack: number + /** +0x132 */ + readonly stackable: boolean + /** +0x112 (byte) */ + readonly durability: number + /** +0x113 */ + readonly noDurability: boolean + /** +0xfd (byte) */ + readonly level: number + /** +0x84 / +0x88 / +0x8c. Empty string when the field is 0. */ + readonly normCode: string + readonly uberCode: string + readonly ultraCode: string + /** +0xcc / +0xd0 */ + readonly minAc: number + readonly maxAc: number +} + +export interface ItemTypeCostRecord { + readonly code: string + readonly equiv1: string + readonly equiv2: string + /** +0x08 */ + readonly repair: boolean + /** +0x0e: the `Quiver` word is non-zero */ + readonly quiver: boolean + /** +0x10 */ + readonly throwable: boolean + /** +0x1f: `StaffMods` is a class (not 7 = none) */ + readonly staffMods: boolean + /** +0x21: `Class` < 7 */ + readonly classSpecific: boolean +} + +export interface CostMultAdd { + readonly name: string + readonly multiply: number + readonly add: number +} + +export interface ItemStatCostRecord { + readonly id: number + /** +0x30 */ + readonly encode: number + /** +0x14 */ + readonly add: number + /** +0x10 */ + readonly multiply: number + /** +0x18 */ + readonly valShift: number +} + +export interface SkillCostRecord { + readonly id: number + /** Skills.txt +0x234 */ + readonly costMult: number + /** Skills.txt +0x238 */ + readonly costAdd: number + /** Skills.txt +0x174. Undefined when the embedded data has no value; recharge pricing then throws. */ + readonly reqLevel: number | undefined +} + +export interface BookCostRecord { + readonly name: string + /** +0x10. The pricing does not read it (it uses ItemsTxt cost). */ + readonly baseCost: number + /** +0x14 */ + readonly costPerCharge: number +} + +export interface NpcCostRecord { + readonly name: string + /** +0x00: MonStats hcIdx */ + readonly npcClass: number + /** +0x04: Npc.txt `sell mult`, applied to A (player buy price) */ + readonly sellMult: number + /** +0x08: Npc.txt `buy mult`, applied to B (player sell price) */ + readonly buyMult: number + /** +0x0c */ + readonly repMult: number + /** +0x10 */ + readonly questFlags: readonly [number, number, number] + /** +0x1c, applied to A */ + readonly questSellMults: readonly [number, number, number] + /** +0x28, applied to B */ + readonly questBuyMults: readonly [number, number, number] + /** +0x34 */ + readonly questRepMults: readonly [number, number, number] + /** +0x40, one per difficulty */ + readonly maxBuy: readonly [number, number, number] +} + +/** Compiled data the pricing reads. Build it with {@link buildTransactionCostTables}. */ +export interface TransactionCostTables { + readonly items: ReadonlyMap + readonly itemTypes: ReadonlyMap + /** Compiled rows (with "Expansion" rows removed), 0-based. Packet id `n` is row `n - 1`. */ + readonly magicPrefixes: readonly CostMultAdd[] + readonly magicSuffixes: readonly CostMultAdd[] + readonly autoMagic: readonly CostMultAdd[] + /** Compiled rows (with "Expansion" rows removed), indexed directly by `dwFileIndex`. */ + readonly uniqueItems: readonly CostMultAdd[] + readonly setItems: readonly CostMultAdd[] + readonly itemStatCost: ReadonlyMap + readonly skills: ReadonlyMap + readonly books: readonly BookCostRecord[] + readonly npcs: ReadonlyMap + /** Lower-cased `npc` name → MonStats hcIdx, for callers that know NPCs by name. */ + readonly npcClassByName: ReadonlyMap + /** MonStats `Level`, `Level(N)`, `Level(H)` (+0xaa/+0xac/+0xae) by hcIdx. */ + readonly monsterLevels: readonly (readonly [number, number, number])[] + /** Bit split of encode-2/3 stat params (DataTables +0xc6c / +0xc70). */ + readonly skillParamShift: number + readonly skillParamMask: number +} + +/** Raw tables used to build {@link TransactionCostTables}. */ +export interface TransactionCostRawTables { + readonly weapons: D2Table + readonly armor: D2Table + readonly misc: D2Table + readonly itemTypes: D2Table + readonly magicPrefix: D2Table + readonly magicSuffix: D2Table + readonly autoMagic: D2Table + readonly uniqueItems: D2Table + readonly setItems: D2Table + readonly itemStatCost: D2Table + readonly books: D2Table + readonly npc: D2Table + readonly monStats: D2Table + readonly skills: readonly SkillCostRecord[] +} + +// --------------------------------------------------------------------------- +// Stat ids read by the asm +// --------------------------------------------------------------------------- + +const STAT_ARMORCLASS = 31 +const STAT_QUANTITY = 70 +const STAT_DURABILITY = 72 +const STAT_MAXDURABILITY = 73 +const STAT_ITEM_SINGLESKILL = 107 +const STAT_ITEM_INDESTRUCTIBLE = 152 +const STAT_ITEM_CHARGED_SKILL = 204 +const STAT_ITEM_REPLENISH_DURABILITY = 252 +const STAT_ITEM_REPLENISH_QUANTITY = 253 +const STAT_ITEM_EXTRA_STACK = 254 + +/** `0x6fd760f0` caps the stack size here. */ +const MAX_STACK_CAP = 511 +/** `0x6fd78e80` caps reduced prices here (with no lower clamp). */ +const REDUCED_PRICES_CAP = 99 +/** `0x6fd7a210` loops over body locations 0..12. */ +export const REPAIR_ALL_SLOT_COUNT = 13 + +// --------------------------------------------------------------------------- +// int32 helpers +// --------------------------------------------------------------------------- + +const imul = Math.imul + +/** Signed `idiv`, truncating; throws on division by zero (#DE in the original). */ +function idiv(a: number, b: number, where: string): number { + if (b === 0) throw new Error(`transaction-cost: integer division by zero at ${where}`) + return (a / b) | 0 +} + +/** Signed division by a power of two as compiled (`cdq; and; add; sar`), which truncates toward zero. */ +function sdiv(a: number, d: 1024 | 4096 | 4 | 2): number { + return (a / d) | 0 +} + +/** Unsigned `div` of an accumulator by the divisor; the result is put back into int32. */ +function udiv(acc: number, d: number, where: string): number { + if (d === 0) throw new Error(`transaction-cost: unsigned division by zero at ${where}`) + return Math.floor((acc >>> 0) / (d >>> 0)) | 0 +} + +/** + * `FOG_10182`-style mul-div at `0x6fd511e0` (eax = a, edx = b, ecx = c). + * Computes `a * b / c` without overflowing, choosing the path by magnitude. + */ +export function fogMulDiv113c(a: number, b: number, c: number): number { + if (c === 0) return 0 + const int64 = (): number => Number(BigInt.asIntN(32, (BigInt(a) * BigInt(b)) / BigInt(c))) + if (b > 0x100000) { + return c > b >> 4 ? int64() : imul(idiv(b, c, '0x6fd511e0'), a) + } + if (a > 0x10000) { + return c > a >> 4 ? int64() : imul(idiv(a, c, '0x6fd511e0'), b) + } + return idiv(imul(a, b), c, '0x6fd511e0') +} + +// --------------------------------------------------------------------------- +// Lookups +// --------------------------------------------------------------------------- + +function requireItem(tables: TransactionCostTables, code: string): ItemsTxtCostRecord { + const rec = tables.items.get(code) + if (!rec) throw new Error(`transaction-cost: unknown ItemsTxt code '${code}'`) + return rec +} + +function requireItemType(tables: TransactionCostTables, code: string): ItemTypeCostRecord { + const rec = tables.itemTypes.get(code) + if (!rec) throw new Error(`transaction-cost: unknown ItemTypes code '${code}'`) + return rec +} + +function requireSkill(tables: TransactionCostTables, id: number): SkillCostRecord { + const rec = tables.skills.get(id) + if (!rec) throw new Error(`transaction-cost: unknown skill id ${id}`) + return rec +} + +function requireIsc(tables: TransactionCostTables, id: number): ItemStatCostRecord { + const rec = tables.itemStatCost.get(id) + if (!rec) throw new Error(`transaction-cost: unknown ItemStatCost id ${id}`) + return rec +} + +/** + * Magic affix lookup (`0x6fdc1820`, records 1..count, 0 = none). + * The asm returns null for an out-of-range id. Here a non-zero id outside + * the table throws, because the combined table would otherwise resolve it to + * a record from a neighbouring table. + */ +function affixRecord(rows: readonly CostMultAdd[], id: number, what: string): CostMultAdd | null { + if (!Number.isInteger(id) || id < 0) throw new Error(`transaction-cost: invalid ${what} id ${id}`) + if (id === 0) return null + const rec = rows[id - 1] + if (!rec) throw new Error(`transaction-cost: ${what} id ${id} outside table (${rows.length} rows)`) + return rec +} + +function requireDefined(value: T | undefined, what: string): T { + if (value === undefined) throw new Error(`transaction-cost: missing input '${what}'`) + return value +} + +/** Full value of `(stat, param 0)`, i.e. `0x6fd88a80`. */ +function fullStat(item: TransactionCostInput, statId: number): number { + for (const s of item.stats) if (s.statId === statId && s.param === 0) return s.value | 0 + return 0 +} + +/** Base value of `(stat, param 0)`, i.e. `0x6fd88b00`. */ +function baseStat(item: TransactionCostInput, statId: number): number { + for (const s of item.stats) if (s.statId === statId && s.param === 0) return s.base | 0 + return 0 +} + +/** `ITEMS_CheckItemTypeId` at `0x6fd74430`: type or type2, walking Equiv1/Equiv2. */ +function isItemOfType(tables: TransactionCostTables, rec: ItemsTxtCostRecord, target: string): boolean { + const seen = new Set() + const queue: string[] = [] + if (rec.type) queue.push(rec.type) + if (rec.type2) queue.push(rec.type2) + while (queue.length > 0) { + const code = queue.pop()! + if (code === target) return true + if (seen.has(code)) continue + seen.add(code) + const t = requireItemType(tables, code) + if (t.equiv1) queue.push(t.equiv1) + if (t.equiv2) queue.push(t.equiv2) + } + return false +} + +/** `0x6fd74430` for an ItemsTxt code: whether its type/type2 is, or is equivalent to, `target`. */ +export function isItemCodeOfType113c(tables: TransactionCostTables, code: string, target: string): boolean { + return isItemOfType(tables, requireItem(tables, code), target) +} + +/** The primary ItemTypes record. ItemTypes flags are read from it only, with no equivalences. */ +function primaryType(tables: TransactionCostTables, rec: ItemsTxtCostRecord): ItemTypeCostRecord { + return requireItemType(tables, rec.type) +} + +/** `0x6fd88ff0`: max durability is the full value of stat 73 only if its base is non-zero. */ +function maxDurability(item: TransactionCostInput): number { + return baseStat(item, STAT_MAXDURABILITY) !== 0 ? fullStat(item, STAT_MAXDURABILITY) : 0 +} + +/** `0x6fd76d40` */ +function hasDurability(item: TransactionCostInput, rec: ItemsTxtCostRecord): boolean { + return ( + !rec.noDurability && + rec.durability > 0 && + maxDurability(item) !== 0 && + fullStat(item, STAT_ITEM_INDESTRUCTIBLE) <= 0 + ) +} + +/** `0x6fd760f0` */ +function maxStack(item: TransactionCostInput, rec: ItemsTxtCostRecord): number { + const n = (rec.maxStack + fullStat(item, STAT_ITEM_EXTRA_STACK)) | 0 + return n > MAX_STACK_CAP ? MAX_STACK_CAP : n +} + +/** `0x6fd74f00`: a stackable throwing weapon that can be refilled. */ +function isRefillableThrowing(tables: TransactionCostTables, item: TransactionCostInput, rec: ItemsTxtCostRecord): boolean { + const t = primaryType(tables, rec) + return t.repair && t.throwable && rec.stackable && !item.ethereal +} + +function hasMissingCharges(item: TransactionCostInput): boolean { + for (const s of item.stats) { + if (s.statId !== STAT_ITEM_CHARGED_SKILL) continue + if ((s.value & 0xff) < (s.value >> 8)) return true + } + return false +} + +/** `ITEMS_IsRepairable` at `0x6fd77090`. */ +export function isItemRepairable113c(item: TransactionCostInput, tables: TransactionCostTables): boolean { + if (!item.identified) return false + if (item.ethereal) return false + if (hasMissingCharges(item)) return true + const rec = requireItem(tables, item.code) + if (!primaryType(tables, rec).repair) return false + if (isRefillableThrowing(tables, item, rec)) return true + return hasDurability(item, rec) +} + +// --------------------------------------------------------------------------- +// Cost terms +// --------------------------------------------------------------------------- + +interface Triple { + a: number + b: number + c: number +} + +/** + * Affix / unique / set term (0x6fd790ce, 0x6fd7954f, 0x6fd79458, 0x6fd79720). + * The overflow guard looks at A only and is applied to all three. + */ +function multAddTerm(x: Triple, mult: number, add: number): Triple { + if (x.a > 0xffff && mult !== 0) { + return { + a: (imul(sdiv(x.a, 1024), mult) + add) | 0, + b: (imul(sdiv(x.b, 1024), mult) + add) | 0, + c: (imul(sdiv(x.c, 1024), mult) + add) | 0, + } + } + return { + a: (sdiv(imul(mult, x.a), 1024) + add) | 0, + b: (sdiv(imul(mult, x.b), 1024) + add) | 0, + c: (sdiv(imul(mult, x.c), 1024) + add) | 0, + } +} + +function addInto(acc: Triple, t: Triple): void { + acc.a = (acc.a + t.a) | 0 + acc.b = (acc.b + t.b) | 0 + acc.c = (acc.c + t.c) | 0 +} + +/** `ITEMS_CalculateAdditionalCostsForItemSkill` at `0x6fd75970` (StaffMods, stat 107). */ +function applyStaffMods(tables: TransactionCostTables, item: TransactionCostInput, rec: ItemsTxtCostRecord, x: Triple, divisor: number): void { + if (!primaryType(tables, rec).staffMods) return + let accA = 0 + let accB = 0 + let accC = 0 + for (const s of item.stats) { + if (s.statId !== STAT_ITEM_SINGLESKILL) continue + const skill = requireSkill(tables, s.param) + const mult = skill.costMult + const add = skill.costAdd + const f = (2 * s.value - 1) | 0 + if (x.a > 0xffff && mult !== 0) { + // (sic) the mult is pre-divided here, unlike the other guards. + accA = (accA + imul((imul(sdiv(mult, 1024), x.a) + add) | 0, f)) | 0 + accB = (accB + imul((imul(sdiv(mult, 4096), x.b) + add) | 0, f)) | 0 + accC = (accC + imul((imul(x.c, sdiv(mult, 1024)) + add) | 0, f)) | 0 + } else { + accA = (accA + imul((sdiv(imul(mult, x.a), 1024) + add) | 0, f)) | 0 + accB = (accB + imul((sdiv(imul(x.b, mult), 4096) + add) | 0, f)) | 0 + accC = (accC + imul((sdiv(imul(x.c, mult), 1024) + add) | 0, f)) | 0 + } + } + x.a = (x.a + udiv(accA, divisor, '0x6fd75970')) | 0 + x.b = (x.b + udiv(accB, divisor, '0x6fd75970')) | 0 + x.c = (x.c + udiv(accC, divisor, '0x6fd75970')) | 0 +} + +/** `ITEMS_CalculateAdditionalCostsForBonusStats` at `0x6fd78850`. */ +function applyBonusStats(tables: TransactionCostTables, item: TransactionCostInput, x: Triple, divisor: number): void { + let accA = 0 + let accB = 0 + let accC = 0 + const accumulate = (mult: number, add: number, v: number, bDiv: 1024 | 4096): void => { + const av = imul(x.a, v) + if (av > 0xffff && mult !== 0) { + accA = (accA + imul(sdiv(av, 1024), mult) + add) | 0 + accB = (accB + imul(sdiv(imul(x.b, v), bDiv), mult) + add) | 0 + accC = (accC + imul(sdiv(imul(x.c, v), 1024), mult) + add) | 0 + } else { + accA = (accA + sdiv(imul(imul(mult, x.a), v), 1024) + add) | 0 + accB = (accB + sdiv(imul(imul(x.b, mult), v), bDiv) + add) | 0 + accC = (accC + sdiv(imul(imul(x.c, mult), v), 1024) + add) | 0 + } + } + let visited = 0 + for (const s of item.stats) { + if (visited++ >= MAX_STACK_CAP) break // the stat array walk is bounded at 511 entries + let bonus = (s.value - s.base) | 0 + if (bonus === 0) continue + const isc = requireIsc(tables, s.statId) + bonus >>= isc.valShift + switch (isc.encode) { + case 1: { + const skill = requireSkill(tables, s.param) + accumulate(skill.costMult, skill.costAdd, bonus, 4096) + break + } + case 2: + case 3: { + const level = s.param & tables.skillParamMask + const skill = requireSkill(tables, s.param >> tables.skillParamShift) + accumulate(skill.costMult, skill.costAdd, level, 4096) + break + } + case 4: { + const min = ((bonus >> 2) & 0x3ff) - 0x100 + const max = ((bonus >> 12) & 0x3ff) - 0x100 + accumulate(isc.multiply, isc.add, sdiv(min + max, 2), 1024) + break + } + default: + accumulate(isc.multiply, isc.add, bonus, 1024) + break + } + } + x.a = (x.a + udiv(accA, divisor, '0x6fd78850')) | 0 + x.b = (x.b + udiv(accB, divisor, '0x6fd78850')) | 0 + x.c = (x.c + udiv(accC, divisor, '0x6fd78850')) | 0 +} + +/** Recharge cost of depleted charged skills (stat 204), at `0x6fd75810`. */ +function rechargeCost(tables: TransactionCostTables, item: TransactionCostInput): number { + let acc = 0 + for (const s of item.stats) { + if (s.statId !== STAT_ITEM_CHARGED_SKILL) continue + const level = s.param & tables.skillParamMask + const skill = requireSkill(tables, s.param >> tables.skillParamShift) + const charges = s.value & 0xff + const maxCharges = s.value >> 8 + if (charges >= maxCharges) continue + if (skill.reqLevel === undefined) { + throw new Error(`transaction-cost: skill ${skill.id} has no reqlevel; cannot price recharge`) + } + const l = (level + 2 * idiv(skill.reqLevel, 6, '0x6fd75810') + 2) | 0 + const x = imul(l, 10000) + let t = x > 0xffff && skill.costMult !== 0 + ? imul(sdiv(x, 1024), skill.costMult) + : sdiv(imul(imul(skill.costMult, l), 10000), 1024) + t = (t + skill.costAdd) | 0 + acc = (acc + idiv(imul(maxCharges - charges, t), maxCharges, '0x6fd75810')) | 0 + } + return acc +} + +/** + * Gamble price of an item code at a character level (`0x6fd74840`). + * The record used is `normcode` (falling back to the item's own code, as `0x6fd72ff0` does). + */ +export function computeGambleBaseCost113c(code: string, playerLevel: number, tables: TransactionCostTables): number { + const own = requireItem(tables, code) + const rec = own.normCode ? requireItem(tables, own.normCode) : own + if (rec.code === 'rin' || rec.code === 'amu') return rec.gambleCost // 0x6fd748b7 / 0x6fd74a40 + let avgStack = sdiv((rec.minStack + rec.maxStack) | 0, 2) + if (avgStack < 1) avgStack = 1 + const base = imul(rec.cost, avgStack) + let uberW = 0 + let uberCost = 0 + // 0x6fd7490c: a 0 field or "0 " is skipped. A code that does not resolve throws here (the asm skips it). + if (rec.uberCode && rec.uberCode !== '0') { + const uber = requireItem(tables, rec.uberCode) + uberW = (sdiv(imul(playerLevel - uber.level, 100), 2) + 1) | 0 + if (uberW < 0) uberW = 0 + uberCost = uber.cost + } + let ultraW = 0 + let ultraCost = 0 + if (rec.ultraCode && rec.ultraCode !== '0') { + // 0x6fd7494d + const ultra = requireItem(tables, rec.ultraCode) + ultraW = (sdiv(imul(playerLevel - ultra.level, 100), 4) + 1) | 0 + if (ultraW < 0) ultraW = 0 + ultraCost = ultra.cost + } + // 0x6fd74998 + const mixed = (imul((10000 - ultraW - uberW) | 0, base) + imul(ultraCost, ultraW) + imul(uberCost, uberW)) | 0 + const s = idiv(mixed, 10000, '0x6fd749b8') + const l = playerLevel > 5 ? playerLevel : 5 // 0x6fd749c9 + const qlvl = rec.level + const over = qlvl - 45 < 0 ? 0 : qlvl - 45 + const t = idiv(imul((over - sdiv(qlvl, 2) + l) | 0, 250), 3, '0x6fd749f6') + const f = (idiv((2 * l + 1) | 0, 3, '0x6fd74a0a') + 20) | 0 + return idiv(imul((t + s) | 0, f), 15, '0x6fd74a20') +} + +// --------------------------------------------------------------------------- +// #10107 +// --------------------------------------------------------------------------- + +function requireNpc(tables: TransactionCostTables, npcClass: number): NpcCostRecord { + const npc = tables.npcs.get(npcClass) + if (!npc) throw new Error(`transaction-cost: no Npc.txt record for npc class ${npcClass}`) // fatal 0xcdd + return npc +} + +/** Npc multiplier with the asm's guard (0x6fd799d8). */ +function npcScale(value: number, guardValue: number, guardMult: number, mult: number): number { + if (guardValue > 0xffff && guardMult !== 0) return imul(sdiv(value, 1024), mult) + return sdiv(imul(mult, value), 1024) +} + +/** + * `ITEMS_GetTransactionCost` (D2Common #10107, body at 0x6fd78e80). + * + * @param item - the item being priced. + * @param ctx - player, NPC and table state. + * @param type - 0 buy, 1 sell, 2 gamble, 3 repair. + * @returns the gold amount. + */ +export function computeTransactionCost113c( + item: TransactionCostInput, + ctx: TransactionCostContext, + type: TransactionType113c, +): number { + const { tables } = ctx + const rec = requireItem(tables, item.code) + const quantityStat = fullStat(item, STAT_QUANTITY) + const q = quantityStat <= 0 ? 1 : quantityStat + let rp = ctx.playerReducedPricesPct | 0 + if (rp >= REDUCED_PRICES_CAP) rp = REDUCED_PRICES_CAP + const clvl = ctx.playerLevel | 0 + + // 0x6fd78eb4 + if (type === 3 && !isItemRepairable113c(item, tables)) return 0 + // 0x6fd78ecd + if (item.starterItem) return 1 + + // 0x6fd78f38: gamble + if (type === 2) { + if ((item.itemFormat & 0xffff) >= 1) { + let cost = computeGambleBaseCost113c(item.code, clvl, tables) + if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0 + return cost // no clamp here + } + const own = rec.normCode ? requireItem(tables, rec.normCode) : rec + return own.gambleCost + } + + const x: Triple = { a: 0, b: 0, c: 0 } + let divisor = 1 + + // Base costs, first match wins. + if (item.isEar) { + // 0x6fd78fb0 + const lvl = requireDefined(item.earLevel, 'earLevel') & 0xff + x.a = x.b = imul(lvl, rec.cost) + } else if (isItemOfType(tables, rec, 'body')) { + // 0x6fd78fc9 + const cls = requireDefined(item.bodyPartMonsterClass, 'bodyPartMonsterClass') + const levels = tables.monsterLevels[cls] + if (!levels) throw new Error(`transaction-cost: no MonStats record ${cls}`) + x.a = x.b = (rec.cost + (levels[ctx.difficulty] << 3)) | 0 + } else if (isItemOfType(tables, rec, 'book')) { + // 0x6fd7913e + const bookId = requireDefined(item.bookId, 'bookId') + const book = tables.books[bookId] + if (!book) throw new Error(`transaction-cost: no Books.txt record ${bookId}`) // fatal 0xbbb + x.a = x.b = (imul(book.costPerCharge, q) + rec.cost) | 0 + } else if (primaryType(tables, rec).quiver) { + // 0x6fd7917a + x.a = x.b = sdiv(imul(rec.cost, q), 1024) + x.c = sdiv(imul(maxStack(item, rec), rec.cost), 1024) + } else if (rec.stackable) { + // 0x6fd791c5 + x.a = x.b = x.c = rec.cost + const ms = maxStack(item, rec) + divisor = ms > 1 ? ms : 1 + } else { + x.a = x.b = x.c = rec.cost + } + + // 0x6fd7900b: armor scales by base defense over max defense + if (isItemOfType(tables, rec, 'armo')) { + if (((rec.maxAc - rec.minAc + 1) | 0) !== 0 && rec.maxAc !== 0) { + const v = idiv(imul(rec.cost, baseStat(item, STAT_ARMORCLASS)), rec.maxAc, '0x6fd79044') + x.a = x.b = x.c = v + } + } + + // 0x6fd74210: magic, set, rare, unique, crafted, tempered + const magicish = item.quality >= 4 && item.quality <= 9 + if (!magicish) applyStaffMods(tables, item, rec, x, divisor) // 0x6fd7909c + + if (item.identified) { + // 0x6fd790b8: automagic + const n: Triple = { a: 0, b: 0, c: 0 } + const auto = affixRecord(tables.autoMagic, item.autoAffixId ?? 0, 'automagic') + if (auto) addInto(n, multAddTerm(x, auto.multiply, auto.add)) + + const magicPath = (): void => { + // 0x6fd7954f + const p = affixRecord(tables.magicPrefixes, requireDefined(item.magicPrefixId, 'magicPrefixId'), 'magic prefix') + if (p) addInto(n, multAddTerm(x, p.multiply, p.add)) + const s = affixRecord(tables.magicSuffixes, requireDefined(item.magicSuffixId, 'magicSuffixId'), 'magic suffix') + if (s) addInto(n, multAddTerm(x, s.multiply, s.add)) + applyBonusStats(tables, item, x, divisor) + } + + switch (item.quality) { + case 1: // 0x6fd797fc: low quality replaces the automagic term (sic) + n.a = -sdiv(x.a, 2) + n.b = -sdiv(x.b, 2) + n.c = -sdiv(x.c, 2) + break + case 2: + break + case 3: // 0x6fd79823 + case 9: + applyBonusStats(tables, item, x, divisor) + break + case 4: + magicPath() + break + case 5: { + // 0x6fd79720: a missing record is skipped; no bonus stats + const id = requireDefined(item.setItemId, 'setItemId') + const set = id >= 0 ? tables.setItems[id] : undefined + if (set) addInto(n, multAddTerm(x, set.multiply, set.add)) + break + } + case 6: // 0x6fd79269 + case 8: { + const pre = requireDefined(item.rarePrefixIds, 'rarePrefixIds') + const suf = requireDefined(item.rareSuffixIds, 'rareSuffixIds') + for (let i = 0; i < 3; i++) { + const p = affixRecord(tables.magicPrefixes, pre[i]!, 'rare prefix') + if (p) addInto(n, multAddTerm(x, p.multiply, p.add)) + const s = affixRecord(tables.magicSuffixes, suf[i]!, 'rare suffix') + if (s) addInto(n, multAddTerm(x, s.multiply, s.add)) + } + applyBonusStats(tables, item, x, divisor) + break + } + case 7: { + // 0x6fd79458: an invalid unique id falls back to the magic path + const id = requireDefined(item.uniqueItemId, 'uniqueItemId') + const uniq = id >= 0 ? tables.uniqueItems[id] : undefined + if (uniq) addInto(n, multAddTerm(x, uniq.multiply, uniq.add)) + else magicPath() + break + } + } + // 0x6fd79844: signed division of the terms by the divisor + x.a = (x.a + idiv(n.a, divisor, '0x6fd79844')) | 0 + x.b = (x.b + idiv(n.b, divisor, '0x6fd79844')) | 0 + x.c = (x.c + idiv(n.c, divisor, '0x6fd79844')) | 0 + if (magicish) applyStaffMods(tables, item, rec, x, divisor) + } + + // 0x6fd74a50: sockets, counted even when unidentified + for (const childCode of item.socketedChildCodes) { + const half = sdiv(requireItem(tables, childCode).cost, 2) + x.a = (x.a + half) | 0 + x.b = (x.b + half) | 0 + x.c = (x.c + half) | 0 + } + + // 0x6fd798b0: quarter the player-sell price + let ebp = x.b + if (item.ethereal) ebp = sdiv(ebp, 4) + if (primaryType(tables, rec).classSpecific) ebp = sdiv(ebp, 4) + + // 0x6fd798f5: per-type adjustment + let edi = x.c + if (type === 1) { + if (item.ethereal && hasDurability(item, rec) && fullStat(item, STAT_DURABILITY) <= 0) ebp = 0 + } else if (type === 3) { + const t = primaryType(tables, rec) + if (!t.quiver && !t.throwable && hasDurability(item, rec)) { + const cur = fullStat(item, STAT_DURABILITY) + const max = maxDurability(item) + if (max !== 0 && cur < max) { + if (fullStat(item, STAT_ITEM_REPLENISH_DURABILITY) !== 0) { + // (sic) self-repairing items are charged as if one point were missing, or nothing + edi = cur < max - 1 ? idiv(imul(max - 1, x.c), max, '0x6fd7995e') : 0 + } else { + edi = idiv(imul(max - cur, x.c), max, '0x6fd79988') + } + } else { + edi = 0 + } + } + } + + // 0x6fd799d8: Npc.txt multipliers + const npc = requireNpc(tables, ctx.npcClass) + let a = npcScale(x.a, x.a, npc.sellMult, npc.sellMult) + ebp = npcScale(ebp, ebp, npc.sellMult, npc.buyMult) // (sic) guard tests sellMult + edi = npcScale(edi, edi, npc.repMult, npc.repMult) + + // 0x6fd79aa0: quest discounts + for (let i = 0; i < 3; i++) { + const flag = npc.questFlags[i]! + if (flag === 0) continue + if (!ctx.isQuestFlagSet(flag, 0) && !ctx.isQuestFlagSet(flag, 1)) continue + a = npcScale(a, a, npc.questSellMults[i]!, npc.questSellMults[i]!) + ebp = npcScale(ebp, ebp, npc.questBuyMults[i]!, npc.questBuyMults[i]!) + edi = npcScale(edi, edi, npc.questRepMults[i]!, npc.questRepMults[i]!) + } + + // 0x6fd79b8d: quantity + if (!isItemOfType(tables, rec, 'book') && !primaryType(tables, rec).quiver) { + a = imul(a, q) + if (rec.stackable && isItemRepairable113c(item, tables)) { + const max = maxStack(item, rec) + if (q >= max || fullStat(item, STAT_ITEM_REPLENISH_QUANTITY) !== 0) { + ebp = imul(max, ebp) + edi = 0 + } else { + ebp = (imul(max, ebp) - imul(edi, max - q)) | 0 + edi = imul(edi, max - q) + } + } else { + ebp = imul(ebp, q) + } + } + + // 0x6fd79c0d: recharge, added after the Npc multipliers + if (type === 3 && !item.ethereal) edi = (edi + rechargeCost(tables, item)) | 0 + + // 0x6fd79c2c: cap the player-sell price + const cap = npc.maxBuy[ctx.difficulty] + if (ebp > cap) ebp = cap + + if (type === 1) return ebp < 1 ? 1 : ebp + if (type === 3) { + if (rp !== 0) edi = (edi - fogMulDiv113c(rp, edi, 100)) | 0 + return edi < 1 ? 1 : edi + } + if (rp !== 0) a = (a - fogMulDiv113c(rp, a, 100)) | 0 + return a < 1 ? 1 : a +} + +// --------------------------------------------------------------------------- +// #10071 +// --------------------------------------------------------------------------- + +/** + * Repair-all cost (D2Common #10071 at 0x6fd7a210). + * + * @param equipped - the 13 body locations (0..12, both weapon sets); `null` for an empty slot. + * @param ctx - same context as a single repair. + * @returns the total gold. Items that need nothing contribute 0. + */ +export function computeRepairAllCost113c( + equipped: readonly (TransactionCostInput | null)[], + ctx: TransactionCostContext, +): number { + if (equipped.length !== REPAIR_ALL_SLOT_COUNT) { + throw new Error(`transaction-cost: repair-all expects ${REPAIR_ALL_SLOT_COUNT} slots, got ${equipped.length}`) + } + const { tables } = ctx + let total = 0 + for (const item of equipped) { + if (!item) continue + const rec = requireItem(tables, item.code) + const maxDur = maxDurability(item) + const needs = + (isItemRepairable113c(item, tables) && + hasDurability(item, rec) && + maxDur !== 0 && + fullStat(item, STAT_DURABILITY) !== maxDur) || + (isRefillableThrowing(tables, item, rec) && fullStat(item, STAT_QUANTITY) < maxStack(item, rec)) || + hasMissingCharges(item) + if (!needs) continue + total = (total + computeTransactionCost113c(item, ctx, 3)) | 0 + } + return total +} + +// --------------------------------------------------------------------------- +// Table building +// --------------------------------------------------------------------------- + +function column(t: D2Table, name: string, what: string): number { + const i = t.header.indexOf(name) + if (i < 0) throw new Error(`transaction-cost: ${what} has no column '${name}'`) + return i +} + +/** Integer txt field. An empty cell compiles to 0, as the txt compiler does. */ +function intField(t: D2Table, row: readonly string[], name: string, what: string): number { + const raw = (row[column(t, name, what)] ?? '').trim() + if (raw === '') return 0 + if (!/^-?\d+$/.test(raw)) throw new Error(`transaction-cost: ${what}.${name} is not an integer: '${raw}'`) + return Number(raw) | 0 +} + +function strField(t: D2Table, row: readonly string[], name: string, what: string): string { + return (row[column(t, name, what)] ?? '').trim() +} + +/** Compiled rows: the txt compiler drops rows whose first column is "Expansion". */ +function compiledRows(t: D2Table): readonly (readonly string[])[] { + return t.rows.filter(r => (r[0] ?? '').trim() !== 'Expansion') +} + +function multAddRows(t: D2Table, nameCol: string, multCol: string, addCol: string, what: string): CostMultAdd[] { + return compiledRows(t).map(r => ({ + name: strField(t, r, nameCol, what), + multiply: intField(t, r, multCol, what), + add: intField(t, r, addCol, what), + })) +} + +/** Which optional columns a items table has. Misc.txt has no durability, AC or tier columns; they compile to 0. */ +interface ItemsTableShape { + readonly hasAc: boolean + readonly hasTiers: boolean + readonly hasDurability: boolean +} + +function addItems(out: Map, t: D2Table, what: string, shape: ItemsTableShape): void { + const { hasAc, hasTiers } = shape + for (const r of compiledRows(t)) { + const code = strField(t, r, 'code', what) + if (!code) continue + if (out.has(code)) throw new Error(`transaction-cost: duplicate ItemsTxt code '${code}'`) + const tier = (name: string): string => { + if (!hasTiers) return '' + const v = strField(t, r, name, what) + return v === '0' ? '' : v + } + out.set(code, { + code, + type: strField(t, r, 'type', what), + type2: strField(t, r, 'type2', what), + cost: intField(t, r, 'cost', what), + gambleCost: intField(t, r, 'gamble cost', what), + minStack: intField(t, r, 'minstack', what), + maxStack: intField(t, r, 'maxstack', what), + stackable: intField(t, r, 'stackable', what) !== 0, + durability: shape.hasDurability ? intField(t, r, 'durability', what) & 0xff : 0, + noDurability: intField(t, r, 'nodurability', what) !== 0, + level: intField(t, r, 'level', what) & 0xff, + normCode: tier('normcode'), + uberCode: tier('ubercode'), + ultraCode: tier('ultracode'), + minAc: hasAc ? intField(t, r, 'minac', what) : 0, + maxAc: hasAc ? intField(t, r, 'maxac', what) : 0, + }) + } +} + +/** Build the compiled tables from raw txt tables. */ +export function buildTransactionCostTables(raw: TransactionCostRawTables): TransactionCostTables { + const items = new Map() + addItems(items, raw.weapons, 'Weapons', { hasAc: false, hasTiers: true, hasDurability: true }) + addItems(items, raw.armor, 'Armor', { hasAc: true, hasTiers: true, hasDurability: true }) + addItems(items, raw.misc, 'Misc', { hasAc: false, hasTiers: false, hasDurability: false }) + + const itemTypes = new Map() + for (const r of compiledRows(raw.itemTypes)) { + const w = 'ItemTypes' + const code = strField(raw.itemTypes, r, 'Code', w) + if (!code) continue + itemTypes.set(code, { + code, + equiv1: strField(raw.itemTypes, r, 'Equiv1', w), + equiv2: strField(raw.itemTypes, r, 'Equiv2', w), + repair: intField(raw.itemTypes, r, 'Repair', w) !== 0, + quiver: strField(raw.itemTypes, r, 'Quiver', w) !== '', + throwable: intField(raw.itemTypes, r, 'Throwable', w) !== 0, + staffMods: strField(raw.itemTypes, r, 'StaffMods', w) !== '', + classSpecific: strField(raw.itemTypes, r, 'Class', w) !== '', + }) + } + + const itemStatCost = new Map() + for (const r of compiledRows(raw.itemStatCost)) { + const w = 'ItemStatCost' + if (strField(raw.itemStatCost, r, 'ID', w) === '') continue + const id = intField(raw.itemStatCost, r, 'ID', w) + itemStatCost.set(id, { + id, + encode: intField(raw.itemStatCost, r, 'Encode', w), + add: intField(raw.itemStatCost, r, 'Add', w), + multiply: intField(raw.itemStatCost, r, 'Multiply', w), + valShift: intField(raw.itemStatCost, r, 'ValShift', w), + }) + } + + const skills = new Map() + for (const s of raw.skills) skills.set(s.id, s) + + const books = compiledRows(raw.books).map(r => ({ + name: strField(raw.books, r, 'Name', 'Books'), + baseCost: intField(raw.books, r, 'BaseCost', 'Books'), + costPerCharge: intField(raw.books, r, 'CostPerCharge', 'Books'), + })) + + const monRows = compiledRows(raw.monStats) + const npcClassByName = new Map() + const monsterLevels = monRows.map((r, i) => { + const id = strField(raw.monStats, r, 'Id', 'MonStats').toLowerCase() + if (id && !npcClassByName.has(id)) npcClassByName.set(id, i) + return [ + intField(raw.monStats, r, 'Level', 'MonStats'), + intField(raw.monStats, r, 'Level(N)', 'MonStats'), + intField(raw.monStats, r, 'Level(H)', 'MonStats'), + ] as const + }) + + const npcs = new Map() + const npcNames = new Map() + for (const r of compiledRows(raw.npc)) { + const t = raw.npc + const w = 'Npc' + const name = strField(t, r, 'npc', w) + if (!name) continue + const npcClass = npcClassByName.get(name.toLowerCase()) + if (npcClass === undefined) throw new Error(`transaction-cost: Npc.txt '${name}' has no MonStats row`) + const abc = (prefix: string): [number, number, number] => [ + intField(t, r, `${prefix} A`, w), + intField(t, r, `${prefix} B`, w), + intField(t, r, `${prefix} C`, w), + ] + npcs.set(npcClass, { + name, + npcClass, + sellMult: intField(t, r, 'sell mult', w), + buyMult: intField(t, r, 'buy mult', w), + repMult: intField(t, r, 'rep mult', w), + questFlags: abc('questflag'), + questSellMults: abc('questsellmult'), + questBuyMults: abc('questbuymult'), + questRepMults: abc('questrepmult'), + maxBuy: [intField(t, r, 'max buy', w), intField(t, r, 'max buy (N)', w), intField(t, r, 'max buy (H)', w)], + }) + npcNames.set(name.toLowerCase(), npcClass) + } + + return { + items, + itemTypes, + magicPrefixes: multAddRows(raw.magicPrefix, 'Name', 'multiply', 'add', 'MagicPrefix'), + magicSuffixes: multAddRows(raw.magicSuffix, 'Name', 'multiply', 'add', 'MagicSuffix'), + autoMagic: multAddRows(raw.autoMagic, 'Name', 'multiply', 'add', 'AutoMagic'), + uniqueItems: multAddRows(raw.uniqueItems, 'index', 'cost mult', 'cost add', 'UniqueItems'), + setItems: multAddRows(raw.setItems, 'index', 'cost mult', 'cost add', 'SetItems'), + itemStatCost, + skills, + books, + npcs, + npcClassByName: npcNames, + monsterLevels, + // UNCONFIRMED: the writer of DataTables +0xc6c/+0xc70 was not located; 6/0x3f is the + // split the old-format conversion at 0x6fd7bd2c uses. + skillParamShift: 6, + skillParamMask: 0x3f, + } +} + +let canonicalTables: TransactionCostTables | undefined + +/** Tables built from the embedded canonical 1.13c data. Built once, on first call. */ +export function getCanonicalTransactionCostTables(): TransactionCostTables { + if (canonicalTables) return canonicalTables + const parse = (text: string): D2Table => parseTable(new TextEncoder().encode(text)) + const skills: SkillCostRecord[] = CANONICAL_SKILL_COSTS.map(s => ({ + id: s.id, + costMult: s.costMult, + costAdd: s.costAdd, + reqLevel: SKILLS_BY_ID[s.id]?.reqlevel, + })) + canonicalTables = buildTransactionCostTables({ + weapons: parse(RAW_WEAPONS), + armor: parse(RAW_ARMOR), + misc: parse(RAW_MISC), + itemTypes: parse(RAW_ITEM_TYPES), + magicPrefix: parse(RAW_MAGIC_PREFIX), + magicSuffix: parse(RAW_MAGIC_SUFFIX), + autoMagic: parse(CANONICAL_AUTOMAGIC_TSV.trim()), + uniqueItems: parse(RAW_UNIQUE_ITEMS), + setItems: parse(RAW_SET_ITEMS), + itemStatCost: parse(RAW_ITEM_STAT_COST), + books: parse(RAW_BOOKS), + npc: parse(RAW_NPC), + monStats: parse(RAW_MONSTATS), + skills, + }) + return canonicalTables +} + +/** Look up an NPC's class (MonStats hcIdx) by its Npc.txt name; throws if unknown. */ +export function getNpcClassByName113c(tables: TransactionCostTables, name: string): number { + const cls = tables.npcClassByName.get(name.toLowerCase()) + if (cls === undefined) throw new Error(`transaction-cost: unknown Npc.txt npc '${name}'`) + return cls +} + diff --git a/tests/item-cost.test.ts b/tests/item-cost.test.ts index 1ae3323..dca0f20 100644 --- a/tests/item-cost.test.ts +++ b/tests/item-cost.test.ts @@ -193,8 +193,8 @@ describe('1.13c Item Transaction & Acquisition Cost Engine (M19-P3)', () => { expect(sellHell).toBe(35000) }) - it('halves acquisition (SELL) price for Ethereal items and forbids repairing Ethereal items', () => { - const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so halving is observable) + it('quarters acquisition (SELL) price for Ethereal items (0x6fd798b0) and forbids repairing Ethereal items', () => { + const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so quartering is observable) const normalArmor = createDroppedItem(capBase, 'normal', { itemTypes: tables.itemTypes, ilvl: 25, diff --git a/tests/transaction-cost-113c.test.ts b/tests/transaction-cost-113c.test.ts new file mode 100644 index 0000000..d5df8ed --- /dev/null +++ b/tests/transaction-cost-113c.test.ts @@ -0,0 +1,307 @@ +/** + * D2Common 1.13c #10107 / #10071 pricing port. Every expectation below was + * worked out by hand from the canonical table values given in the comments, + * following the asm branches cited in `src/common/items/transaction-cost.ts`. + * + * Npc.txt (normal difficulty): + * akara sell mult 1024, buy mult 512, rep mult 128, questflag A 4, + * questsellmult A 922, questbuymult A 1024, max buy 5000 + * charsi sell mult 960, buy mult 512, rep mult 128, max buy 5000 + */ +import { describe, expect, it } from 'vitest' +import { + computeRepairAllCost113c, + computeTransactionCost113c, + fogMulDiv113c, + getCanonicalTransactionCostTables, + getNpcClassByName113c, + isItemRepairable113c, + type TransactionCostContext, + type TransactionCostInput, + type TransactionCostStat, +} from '../src/common/items/transaction-cost.ts' + +const tables = getCanonicalTransactionCostTables() +const AKARA = getNpcClassByName113c(tables, 'akara') +const CHARSI = getNpcClassByName113c(tables, 'charsi') + +function ctx(npcClass: number, over: Partial = {}): TransactionCostContext { + return { + playerLevel: 1, + playerReducedPricesPct: 0, + difficulty: 0, + npcClass, + isQuestFlagSet: () => false, + tables, + ...over, + } +} + +function stat(statId: number, value: number, base = value, param = 0): TransactionCostStat { + return { statId, param, value, base } +} + +function item(code: string, over: Partial = {}): TransactionCostInput { + return { + code, + itemFormat: 101, + quality: 2, + identified: true, + ethereal: false, + starterItem: false, + isEar: false, + stats: [], + socketedChildCodes: [], + ...over, + } +} + +const buy = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 0) +const sell = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 1) +const gamble = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 2) +const repair = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 3) + +describe('compiled tables', () => { + it('maps Npc.txt rows onto MonStats hcIdx with "Expansion" rows skipped', () => { + expect(AKARA).toBe(148) + expect(CHARSI).toBe(154) + expect(getNpcClassByName113c(tables, 'gheed')).toBe(147) + // raw MonStats row 512, after the Expansion row at raw 410 + expect(getNpcClassByName113c(tables, 'larzuk')).toBe(511) + }) + + it('indexes affixes by compiled row, packet id = row + 1', () => { + expect(tables.magicPrefixes[1]).toEqual({ name: 'Sturdy', multiply: 0, add: 0 }) + expect(tables.magicPrefixes[7]).toEqual({ name: 'Devious', multiply: 0, add: 0 }) + // raw row 317, after the Expansion row at raw 118 + expect(tables.magicPrefixes[316]).toEqual({ name: 'Shimmering', multiply: 1280, add: 500 }) + expect(tables.magicSuffixes[0]).toEqual({ name: 'of Health', multiply: 0, add: 0 }) + }) + + it('resolves every normcode/ubercode/ultracode a gamble can reach', () => { + for (const rec of tables.items.values()) { + expect(() => gamble(item(rec.code), ctx(AKARA, { playerLevel: 50 }))).not.toThrow() + } + }) +}) + +describe('#10107 buy / sell', () => { + it('hp1 at Akara: cost 30', () => { + // A = B = C = 30. Buy: 30*1024/1024 = 30. Sell: 30*512/1024 = 15. + expect(buy(item('hp1'), ctx(AKARA))).toBe(30) + expect(sell(item('hp1'), ctx(AKARA))).toBe(15) + }) + + it('applies the Akara quest discount when quest 4 state 0 or 1 is set', () => { + const c = ctx(AKARA, { isQuestFlagSet: (q, s) => q === 4 && s === 0 }) + // Buy: 30 * questsellmult 922 / 1024 = 27.01 -> 27. Sell: 15 * questbuymult 1024 / 1024 = 15. + expect(buy(item('hp1'), c)).toBe(27) + expect(sell(item('hp1'), c)).toBe(15) + }) + + it('tsc at Akara: cost 100', () => { + expect(buy(item('tsc'), ctx(AKARA))).toBe(100) + expect(sell(item('tsc'), ctx(AKARA))).toBe(50) + }) + + it('ssd at Charsi: cost 72', () => { + const ssd = item('ssd', { stats: [stat(72, 24), stat(73, 24)] }) + // Buy: 72*960/1024 = 67.5 -> 67. Sell: 72*512/1024 = 36. + expect(buy(ssd, ctx(CHARSI))).toBe(67) + expect(sell(ssd, ctx(CHARSI))).toBe(36) + }) + + it('buc at Charsi scales by base defense: cost 68, maxac 6, base AC 5', () => { + const buc = item('buc', { stats: [stat(31, 5), stat(72, 12), stat(73, 12)] }) + // A = B = C = 68*5/6 = 56. Buy: 56*960/1024 = 52.5 -> 52. Sell: 56*512/1024 = 28. + expect(buy(buc, ctx(CHARSI))).toBe(52) + expect(sell(buc, ctx(CHARSI))).toBe(28) + }) + + it('magic buc with prefix Devious (id 8) + suffix of Health (id 1)', () => { + // Both affixes have multiply 0 / add 0, so their terms are 0. Bonus stats (full - base): + // 34 normal_damage_reduction +1: Add 188, Multiply 200 -> 200*56*1/1024 = 10 + 188 = 198 + // 35 magic_damage_reduction +1: Add 397, Multiply 340 -> 340*56*1/1024 = 18 + 397 = 415 + // A = B = 56 + 198 + 415 = 669. Buy: 669*960/1024 = 627.2 -> 627. Sell: 669*512/1024 = 334. + const buc = item('buc', { + quality: 4, + magicPrefixId: 8, + magicSuffixId: 1, + stats: [stat(31, 5), stat(72, 12), stat(73, 12), stat(34, 1, 0), stat(35, 1, 0)], + }) + expect(buy(buc, ctx(CHARSI))).toBe(627) + expect(sell(buc, ctx(CHARSI))).toBe(334) + }) + + it('magic ring with Shimmering (id 317: multiply 1280, add 500)', () => { + // rin cost 1800. Term: 1280*1800/1024 + 500 = 2250 + 500 = 2750, so A = B = 4550. + // Akara buy 4550; sell 4550*512/1024 = 2275. + const rin = item('rin', { quality: 4, magicPrefixId: 317, magicSuffixId: 0 }) + expect(buy(rin, ctx(AKARA))).toBe(4550) + expect(sell(rin, ctx(AKARA))).toBe(2275) + }) + + it('wand with +3 Teeth StaffMods (skill 67: costMult 256, costAdd 1000)', () => { + // wnd cost 205, factor 2*3-1 = 5. + // A += (256*205/1024 + 1000)*5 = (51 + 1000)*5 = 5255 -> 5460 + // B += (205*256/4096 + 1000)*5 = (12 + 1000)*5 = 5060 -> 5265 + // Akara buy 5460; sell 5265*512/1024 = 2632. + const wnd = item('wnd', { stats: [stat(107, 3, 3, 67), stat(72, 15), stat(73, 15)] }) + expect(buy(wnd, ctx(AKARA))).toBe(5460) + expect(sell(wnd, ctx(AKARA))).toBe(2632) + }) + + it('arrows (quiver): aqv cost 256, maxstack 350', () => { + // 350 arrows: A = B = 256*350/1024 = 87. Charsi buy 87*960/1024 = 81; sell 87*512/1024 = 43. + expect(buy(item('aqv', { stats: [stat(70, 350)] }), ctx(CHARSI))).toBe(81) + expect(sell(item('aqv', { stats: [stat(70, 350)] }), ctx(CHARSI))).toBe(43) + // 100 arrows: A = B = 25600/1024 = 25. Buy 25*960/1024 = 23; sell 12. + expect(buy(item('aqv', { stats: [stat(70, 100)] }), ctx(CHARSI))).toBe(23) + expect(sell(item('aqv', { stats: [stat(70, 100)] }), ctx(CHARSI))).toBe(12) + }) + + it('stackable throwing weapon: tkf cost 6, maxstack 160, quantity 100', () => { + // A = B = C = 6, D = 160. Npc: A = 6*960/1024 = 5; B = 6*512/1024 = 3; C = 6*128/1024 = 0. + // Quantity: buy = 5*100 = 500. Sell = 160*3 - 0*(160-100) = 480 (full-stack value minus refill). + // Repair: throwable, so C stays 6 -> 0 after rep mult -> clamped to 1. + const tkf = item('tkf', { stats: [stat(70, 100), stat(72, 4), stat(73, 4)] }) + expect(buy(tkf, ctx(CHARSI))).toBe(500) + expect(sell(tkf, ctx(CHARSI))).toBe(480) + expect(repair(tkf, ctx(CHARSI))).toBe(1) + }) + + it('Town Portal tome prices by charges: Books row 0 CostPerCharge 50, tbk cost 250', () => { + // 3 charges: 50*3 + 250 = 400 -> Akara buy 400, sell 200. + expect(buy(item('tbk', { bookId: 0, stats: [stat(70, 3)] }), ctx(AKARA))).toBe(400) + expect(sell(item('tbk', { bookId: 0, stats: [stat(70, 3)] }), ctx(AKARA))).toBe(200) + // 20 charges: 50*20 + 250 = 1250 -> buy 1250, sell 625. + expect(buy(item('tbk', { bookId: 0, stats: [stat(70, 20)] }), ctx(AKARA))).toBe(1250) + expect(sell(item('tbk', { bookId: 0, stats: [stat(70, 20)] }), ctx(AKARA))).toBe(625) + }) + + it('throws when a book has no Books.txt id', () => { + expect(() => buy(item('tbk', { stats: [stat(70, 3)] }), ctx(AKARA))).toThrow(/bookId/) + }) + + it('starter items cost 1 for every transaction type', () => { + const hax = item('hax', { starterItem: true, stats: [stat(72, 28), stat(73, 28)] }) + expect(buy(hax, ctx(CHARSI))).toBe(1) + expect(sell(hax, ctx(CHARSI))).toBe(1) + expect(repair(hax, ctx(CHARSI))).toBe(1) + expect(gamble(hax, ctx(CHARSI))).toBe(1) + }) + + it('quarters the sell price of ethereal items (buy unchanged)', () => { + const ssd = item('ssd', { ethereal: true, stats: [stat(72, 24), stat(73, 24)] }) + // B = 72/4 = 18 -> 18*512/1024 = 9. Buy stays 67. + expect(buy(ssd, ctx(CHARSI))).toBe(67) + expect(sell(ssd, ctx(CHARSI))).toBe(9) + expect(repair(ssd, ctx(CHARSI))).toBe(0) // ethereal items are not repairable + }) + + it('quarters the sell price of class-specific items: am1 (abow, Class=ama) cost 2500', () => { + const am1 = item('am1', { stats: [stat(72, 40), stat(73, 40)] }) + // B = 2500/4 = 625 -> 625*512/1024 = 312. Buy 2500. + expect(buy(am1, ctx(AKARA))).toBe(2500) + expect(sell(am1, ctx(AKARA))).toBe(312) + }) + + it('caps the sell price at Npc.txt max buy', () => { + const rin = item('rin', { quality: 4, magicPrefixId: 327 - 0, magicSuffixId: 0 }) + // Chromatic (raw 327 -> compiled 326 -> id 327): multiply 1280, add 16000. + // A = 1800 + 1800*1280/1024 + 16000 = 1800 + 2250 + 16000 = 20050 -> sell 10025 -> capped 5000. + expect(tables.magicPrefixes[326]).toEqual({ name: 'Chromatic', multiply: 1280, add: 16000 }) + expect(sell(rin, ctx(AKARA))).toBe(5000) + expect(sell(rin, ctx(AKARA, { difficulty: 1 }))).toBe(10025) + }) +}) + +describe('#10107 repair', () => { + it('repairs a half-damaged ssd at Charsi: (24-12)*72/24 = 36 -> 36*128/1024 = 4', () => { + expect(repair(item('ssd', { stats: [stat(72, 12, 12), stat(73, 24)] }), ctx(CHARSI))).toBe(4) + }) + + it('charges the minimum 1 for a repairable item at full durability', () => { + const ssd = item('ssd', { stats: [stat(72, 24), stat(73, 24)] }) + expect(isItemRepairable113c(ssd, tables)).toBe(true) + expect(repair(ssd, ctx(CHARSI))).toBe(1) + }) + + it('returns 0 for unrepairable items', () => { + expect(repair(item('hp1'), ctx(AKARA))).toBe(0) + expect(repair(item('ssd', { identified: false, stats: [stat(72, 1), stat(73, 24)] }), ctx(CHARSI))).toBe(0) + }) + + it('applies reduced prices with the 99% cap', () => { + // qui base AC 10 (maxac 11): C = 140*10/11 = 127; repair (20-10)*127/20 = 63 -> 63*128/1024 = 7. + const qui = item('qui', { stats: [stat(31, 10), stat(72, 10, 10), stat(73, 20)] }) + expect(repair(qui, ctx(CHARSI))).toBe(7) + // 50%: 7 - 7*50/100 = 7 - 3 = 4 + expect(repair(qui, ctx(CHARSI, { playerReducedPricesPct: 50 }))).toBe(4) + // 150% is capped to 99: 7 - 6 = 1 + expect(repair(qui, ctx(CHARSI, { playerReducedPricesPct: 150 }))).toBe(1) + }) +}) + +describe('#10107 gamble', () => { + it('rings and amulets use their gamble cost (rin 50000, amu 63000)', () => { + expect(gamble(item('rin'), ctx(AKARA, { playerLevel: 30 }))).toBe(50000) + expect(gamble(item('amu'), ctx(AKARA, { playerLevel: 30 }))).toBe(63000) + // 30% reduced prices: 50000 - 15000 = 35000; 63000 - 18900 = 44100. + expect(gamble(item('rin'), ctx(AKARA, { playerLevel: 30, playerReducedPricesPct: 30 }))).toBe(35000) + expect(gamble(item('amu'), ctx(AKARA, { playerLevel: 30, playerReducedPricesPct: 30 }))).toBe(44100) + }) + + it('armor bases follow the clvl formula: qui cost 140 qlvl 1; xui lvl 34 cost 30552; uui lvl 65', () => { + // clvl 1: uber/ultra weights 0; s = 140; L = 5; t = 5*250/3 = 416; f = 11/3 + 20 = 23 + // (416 + 140)*23/15 = 852 + expect(gamble(item('qui'), ctx(AKARA, { playerLevel: 1 }))).toBe(852) + // clvl 30: weights still 0; L = 30; t = 2500; f = 61/3 + 20 = 40; (2500 + 140)*40/15 = 7040 + expect(gamble(item('qui'), ctx(AKARA, { playerLevel: 30 }))).toBe(7040) + // clvl 40: uberW = (40-34)*100/2 + 1 = 301; s = ((10000-301)*140 + 30552*301)/10000 = 1055 + // t = 40*250/3 = 3333; f = 81/3 + 20 = 47; (3333 + 1055)*47/15 = 13749 + expect(gamble(item('qui'), ctx(AKARA, { playerLevel: 40 }))).toBe(13749) + }) + + it('uses the ItemsTxt gamble cost for itemFormat 0', () => { + expect(gamble(item('qui', { itemFormat: 0 }), ctx(AKARA, { playerLevel: 40 }))).toBe(3035) + }) +}) + +describe('#10071 repair all', () => { + it('sums the repair cost of equipped items that need it', () => { + const slots: (TransactionCostInput | null)[] = Array.from({ length: 13 }, () => null) + slots[4] = item('ssd', { stats: [stat(72, 12, 12), stat(73, 24)] }) // 4 + slots[3] = item('qui', { stats: [stat(31, 10), stat(72, 10, 10), stat(73, 20)] }) // 7 + slots[5] = item('buc', { stats: [stat(31, 5), stat(72, 12), stat(73, 12)] }) // full: skipped (not 1) + expect(computeRepairAllCost113c(slots, ctx(CHARSI))).toBe(11) + }) + + it('requires exactly 13 slots', () => { + expect(() => computeRepairAllCost113c([], ctx(CHARSI))).toThrow(/13/) + }) +}) + +describe('fail-fast', () => { + it('throws on unknown codes and NPCs', () => { + expect(() => buy(item('zzz'), ctx(AKARA))).toThrow(/zzz/) + expect(() => buy(item('hp1'), ctx(-1))).toThrow(/Npc/) + expect(() => buy(item('rin', { quality: 4, magicPrefixId: 9999, magicSuffixId: 0 }), ctx(AKARA))).toThrow(/outside/) + }) + + it('fogMulDiv picks the int64 path for large values', () => { + expect(fogMulDiv113c(30, 50000, 100)).toBe(15000) + expect(fogMulDiv113c(50, 3_000_000, 100)).toBe(1_500_000) + }) +}) + +describe('live capture cross-check (tests/fixtures/netproto/06-d2gs-vendor-buy-sell.d2cap)', () => { + // WebBotThree sold its starter tsc and isc: each 0x2A result 1 flag 3 carried stat 14 one higher + // (2 gold after both sales, from 0). Starter branch 0x6fd78ecd. + it('starter scrolls sell for exactly 1 gold', () => { + for (const npc of [AKARA, CHARSI]) { + expect(sell(item('tsc', { starterItem: true }), ctx(npc))).toBe(1) + expect(sell(item('isc', { starterItem: true }), ctx(npc))).toBe(1) + } + }) +}) From e0210d870202836def8881d539961271be9ad564 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:29:07 +0000 Subject: [PATCH 5/8] feat(client): price server items with the 1.13c #10107/#10071 port ItemData -> TransactionCostInput adapter (packet defense/durability/ quantity as base stats, properties and runeword stats as full stats, rare affix pairs, socket children). Code-only gamble previews (flag 0x02000000) are priced only for type 2. Capture checks: all 46 Charsi items price, every gamble preview costs > 2 gold (server answered result 12), repair-all with full durability costs 0. --- src/client/ui-model/transaction-cost-input.ts | 136 ++++++++++++++++++ tests/client/transaction-cost-input.test.ts | 37 +++++ 2 files changed, 173 insertions(+) create mode 100644 src/client/ui-model/transaction-cost-input.ts create mode 100644 tests/client/transaction-cost-input.test.ts diff --git a/src/client/ui-model/transaction-cost-input.ts b/src/client/ui-model/transaction-cost-input.ts new file mode 100644 index 0000000..35fd052 --- /dev/null +++ b/src/client/ui-model/transaction-cost-input.ts @@ -0,0 +1,136 @@ +/** + * Builds D2Common 1.13c #10107 / #10071 pricing inputs from server item packets + * (`src/client/ui-model/transaction-cost-input.ts`). Prices are display-only: + * D2Game ignores the client cost field (buy 0x6fcdf200, sell 0x6fcef570). + */ + +import { + computeRepairAllCost113c, + computeTransactionCost113c, + getCanonicalTransactionCostTables, + type ItemQuality113c, + type TransactionCostContext, + type TransactionCostInput, + type TransactionCostStat, +} from '../../common/items/transaction-cost.ts' +import type { ItemData } from '../../netproto/index.ts' +import type { ClientWorld } from '../world/client-world.ts' + +/** ItemStatCost ids the item packet carries outside the property list. */ +const STAT_ARMORCLASS = 31 +const STAT_QUANTITY = 70 +const STAT_DURABILITY = 72 +const STAT_MAXDURABILITY = 73 +/** `STAT_ITEM_REDUCEDPRICES`; read as a full (player + equipped items) stat by #10107. */ +const STAT_ITEM_REDUCEDPRICES = 87 +/** Body locations 1..12 used by the repair-all loop (0x6fd7a210 walks 13 slots, slot 0 unused). */ +const REPAIR_ALL_SLOTS = 13 + +/** + * Compact ("simple") item packets carry no quality field; the engine creates them + * as `ITEMQUAL_NORMAL` (2) — potions, scrolls, keys, gems, gamble previews. + */ +const QUALITY_NORMAL: ItemQuality113c = 2 +/** Item flag 0x02000000: packet stops after the item code (D2Common 0x6fd7a746). */ +const ITEM_FLAG_CODE_ONLY = 0x02000000 + +export function itemToTransactionCostInput( + item: ItemData, + socketed: readonly ItemData[], + type: 0 | 1 | 2 | 3, +): TransactionCostInput { + let quality: ItemQuality113c + if (item.quality !== undefined) quality = item.quality + else if (item.simple) quality = QUALITY_NORMAL + else if (type === 2 && (item.rawFlags & ITEM_FLAG_CODE_ONLY) !== 0) { + // Gamble previews are sent code-only (flag 0x02000000, capture 09); the gamble branch + // (0x6fd78f38 -> 0x6fd74840) prices from code, clvl and item format and never reads quality. + quality = QUALITY_NORMAL + } else throw new Error(`item #${item.id} (${item.code}) has no quality in its packet`) + + // Base list = fields the packet sends outside the property list; full = base + properties. + const acc = new Map() + const add = (statId: number, param: number, value: number, isBase: boolean): void => { + const k = `${statId}:${param}` + const cur = acc.get(k) ?? { statId, param, value: 0, base: 0 } + cur.value += value + if (isBase) cur.base += value + acc.set(k, cur) + } + if (item.defense !== undefined) add(STAT_ARMORCLASS, 0, item.defense, true) + if (item.maxDurability !== undefined) add(STAT_MAXDURABILITY, 0, item.maxDurability, true) + if (item.curDurability !== undefined) add(STAT_DURABILITY, 0, item.curDurability, true) + if (item.quantity !== undefined) add(STAT_QUANTITY, 0, item.quantity, true) + for (const s of item.stats) add(s.statId, s.param, s.value, false) + for (const s of item.runewordStats) add(s.statId, s.param, s.value, false) + const stats: TransactionCostStat[] = [...acc.values()] + + // Rare/crafted affixes are sent as 3 (prefix, suffix) pairs (item-bitstream.ts). + const ra = item.rareAffixes + const rare = + ra !== undefined && ra.length === 6 + ? { + rarePrefixIds: [ra[0]!, ra[2]!, ra[4]!] as const, + rareSuffixIds: [ra[1]!, ra[3]!, ra[5]!] as const, + } + : {} + + return { + code: item.code, + itemFormat: item.version, + quality, + identified: item.identified, + ethereal: item.ethereal, + starterItem: item.starterItem, + isEar: item.isEar, + ...(item.earLevel !== undefined ? { earLevel: item.earLevel } : {}), + ...(item.tomeData !== undefined ? { bookId: item.tomeData } : {}), + ...(item.magicPrefixId !== undefined ? { magicPrefixId: item.magicPrefixId } : {}), + ...(item.magicSuffixId !== undefined ? { magicSuffixId: item.magicSuffixId } : {}), + ...(item.autoAffixId !== undefined ? { autoAffixId: item.autoAffixId } : {}), + ...(item.uniqueItemId !== undefined ? { uniqueItemId: item.uniqueItemId } : {}), + ...(item.setItemId !== undefined ? { setItemId: item.setItemId } : {}), + ...rare, + stats, + socketedChildCodes: socketed.map(s => s.code), + } +} + +/** Pricing context for `npcClass` (MonStats hcIdx) from the server-mirrored world. */ +export function transactionCostContextFromWorld(world: ClientWorld, npcClass: number): TransactionCostContext { + let reduced = world.self.stats.get(STAT_ITEM_REDUCEDPRICES) + for (const it of world.containers.equipment.values()) { + for (const s of it.stats) if (s.statId === STAT_ITEM_REDUCEDPRICES) reduced += s.value + } + const quests = world.quests + return { + playerLevel: world.self.level, + playerReducedPricesPct: reduced, + difficulty: world.difficulty, + npcClass, + // 0x6fd7d0f0 tests bit `quest * 16 + state` of the quest record; S2C quest state mirrors it as 16-bit words. + isQuestFlagSet: (questId, state) => { + const word = quests[questId] + if (word === undefined) throw new Error(`quest ${questId} outside the mirrored quest record`) + return ((word >> state) & 1) === 1 + }, + tables: getCanonicalTransactionCostTables(), + } +} + +/** #10107 price of a server item for `type` 0 buy / 1 sell / 2 gamble / 3 repair. */ +export function priceItem113c(world: ClientWorld, item: ItemData, type: 0 | 1 | 2 | 3, npcClass: number): number { + const socketed = world.containers.sockets.get(item.id) ?? [] + return computeTransactionCost113c(itemToTransactionCostInput(item, socketed, type), transactionCostContextFromWorld(world, npcClass), type) +} + +/** #10071 repair-all cost over the player's equipped items (slots by body location). */ +export function repairAllCost113c(world: ClientWorld, npcClass: number): number { + const slots: (TransactionCostInput | null)[] = new Array(REPAIR_ALL_SLOTS).fill(null) + for (const it of world.containers.equipment.values()) { + const loc = it.bodyLoc + if (!(loc > 0 && loc < REPAIR_ALL_SLOTS)) throw new Error(`equipped item #${it.id} has body location ${loc}`) + slots[loc] = itemToTransactionCostInput(it, world.containers.sockets.get(it.id) ?? [], 3) + } + return computeRepairAllCost113c(slots, transactionCostContextFromWorld(world, npcClass)) +} diff --git a/tests/client/transaction-cost-input.test.ts b/tests/client/transaction-cost-input.test.ts new file mode 100644 index 0000000..c8a35df --- /dev/null +++ b/tests/client/transaction-cost-input.test.ts @@ -0,0 +1,37 @@ +/** + * Display prices for real server items (captures 05-09) through the 1.13c #10107/#10071 port. + */ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { replayD2CapFile } from '../../tools/netproto-replay.ts' +import { priceItem113c, repairAllCost113c } from '../../src/client/ui-model/transaction-cost-input.ts' + +const FIXTURES = path.resolve(__dirname, '../fixtures/netproto') +const CHARSI = 154 +const GHEED = 147 + +describe('1.13c prices for captured server items', () => { + it('prices every Charsi shop item (capture 05) without missing data', () => { + const { world } = replayD2CapFile(path.join(FIXTURES, '05-d2gs-vendor-open.d2cap')) + const shop = [...world.containers.shop.values()] + expect(shop.length).toBe(46) + for (const it of shop) { + const p = priceItem113c(world, it, 0, CHARSI) + expect(Number.isInteger(p)).toBe(true) + expect(p).toBeGreaterThan(0) + } + }) + + it('every gamble preview (capture 09) costs more than the 2 gold that made the server reject it (0x2A result 12)', () => { + const { world } = replayD2CapFile(path.join(FIXTURES, '09-d2gs-gamble.d2cap')) + const shop = [...world.containers.shop.values()] + expect(shop.length).toBe(14) + for (const it of shop) expect(priceItem113c(world, it, 2, GHEED)).toBeGreaterThan(2) + }) + + it('repair-all costs 0 with full durability (capture 07: server answered result 2 flag 1, gold unchanged)', () => { + const { world } = replayD2CapFile(path.join(FIXTURES, '07-d2gs-vendor-repair.d2cap')) + expect(world.containers.equipment.size).toBe(2) + expect(repairAllCost113c(world, CHARSI)).toBe(0) + }) +}) From 4b509c0ebd2f30ef6baba405917a76c8b6b64d6c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:37:31 +0000 Subject: [PATCH 6/8] test(tools): vendor scenarios seek out-of-view NPCs and send 1.13c display prices NPCs outside the visible rooms are found by walking to the walkable town cells farthest from everything visited. vendor-verify sends the #10107 buy price as the (server-ignored) cost field and reports it. --- tools/lib/vendor-scenarios.ts | 63 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/tools/lib/vendor-scenarios.ts b/tools/lib/vendor-scenarios.ts index 57b112b..51733d3 100644 --- a/tools/lib/vendor-scenarios.ts +++ b/tools/lib/vendor-scenarios.ts @@ -17,6 +17,7 @@ import type { MapService } from '../../src/client/map/map-service.ts' import { findPathWorld } from '../../src/client/motion/pathfind.ts' +import { priceItem113c } from '../../src/client/ui-model/transaction-cost-input.ts' import type { ClientWorld } from '../../src/client/world/client-world.ts' import type { ClientUnit } from '../../src/client/world/client-unit.ts' import { @@ -91,15 +92,25 @@ function findUnit(world: ClientWorld, kind: 'monster' | 'object', classId: numbe async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, within = 4): Promise { if (dist(ctx.world, u, ctx.pos) <= within) return - const view = ctx.map.getActiveLevelView() ?? ctx.map.getLevelView(ctx.world.areaId || 1, 'all', true) - const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, u.x, u.y, { + if (!(await walkToPoint(ctx, u.x, u.y, within))) { + throw new Error(`no path to ${kind} #${u.id} (class ${u.classId}) from (${ctx.pos.x},${ctx.pos.y})`) + } + ctx.log(`walked to (${ctx.pos.x},${ctx.pos.y}); ${kind} #${u.id} dist=${dist(ctx.world, u, ctx.pos).toFixed(1)}`) +} + +function activeView(ctx: Ctx) { + return ctx.map.getActiveLevelView() ?? ctx.map.getLevelView(ctx.world.areaId || 1, 'all', true) +} + +/** Walks along a MapService path in <= 3 sub-tile hops; returns false when no path exists. */ +async function walkToPoint(ctx: Ctx, tx: number, ty: number, within: number): Promise { + const view = activeView(ctx) + const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, tx, ty, { smooth: true, nearestTargetRadius: within, maxExpandedNodes: 32768, }) - if (res.path.length < 2) { - throw new Error(`no path to ${kind} #${u.id} (class ${u.classId}) from (${ctx.pos.x},${ctx.pos.y})`) - } + if (res.path.length < 2) return false for (const wp of res.path.slice(1)) { // Subdivide into <= 3 sub-tile hops like the real client's short click-moves. const d = Math.hypot(wp.x - ctx.pos.x, wp.y - ctx.pos.y) @@ -117,7 +128,39 @@ async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, withi ctx.pos = { x: hx, y: hy } } } - ctx.log(`walked to (${ctx.pos.x},${ctx.pos.y}); ${kind} #${u.id} dist=${dist(ctx.world, u, ctx.pos).toFixed(1)}`) + return true +} + +/** + * Units are only visible near the player. When `classId` is not in view, walk to + * walkable town cells (coarse grid, farthest from everything visited first) until it shows up. + */ +async function seekUnit(ctx: Ctx, kind: 'monster' | 'object', classId: number, maxLegs = 10): Promise { + const visible = () => ctx.world.getUnitsByType(kind).some(u => u.classId === classId && u.x > 0 && u.y > 0) + const view = activeView(ctx) + const visited: Array<{ x: number; y: number }> = [{ ...ctx.pos }] + const STEP = 10 + const SPREAD = 25 + for (let leg = 0; leg < maxLegs && !visible(); leg++) { + const cands: Array<{ x: number; y: number; d: number }> = [] + for (let ly = 2; ly < view.subHeight - 2; ly += STEP) { + for (let lx = 2; lx < view.subWidth - 2; lx += STEP) { + if (view.walkable[ly * view.subWidth + lx] !== 1) continue + const x = view.subOriginX + lx + const y = view.subOriginY + ly + if (visited.some(v => Math.hypot(v.x - x, v.y - y) < SPREAD)) continue + cands.push({ x, y, d: Math.min(...visited.map(v => Math.hypot(v.x - x, v.y - y))) }) + } + } + cands.sort((a, b) => b.d - a.d) + const next = cands[0] + if (!next) break + visited.push(next) + ctx.log(`seek ${kind} class ${classId}: leg ${leg} -> (${next.x},${next.y}) view=${view.subWidth}x${view.subHeight}`) + if (!(await walkToPoint(ctx, next.x, next.y, 2))) leg-- + await sleep(400) + } + return findUnit(ctx.world, kind, classId) } async function waitFor(ctx: Ctx, pred: () => boolean, timeoutMs: number, what: string): Promise { @@ -151,7 +194,7 @@ function snapshot(ctx: Ctx, label: string): Record { } async function openNpc(ctx: Ctx, npcClass: number): Promise { - const npc = findUnit(ctx.world, 'monster', npcClass) + const npc = await seekUnit(ctx, 'monster', npcClass) 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 } @@ -297,7 +340,11 @@ export async function runVendorScenario( const prefer = ['hp1', 'mp1', 'isc', 'tsc', 'hp2', 'mp2'] const target = prefer.map((c) => items.find((it) => it.code === c)).find((x) => x !== undefined) if (target) { - const boughtId = await buy(ctx, npc, target, { gamble: false, shiftFill: false, cost: 1 }, 'buy') + // Display price from the 1.13c #10107 port; D2Game ignores the cost field (0x6fcdf200). + const price = priceItem113c(world, target, 0, NPC_CLASS.Akara) + ctx.report.buyPrice = { item: target.code, price, goldBefore: world.self.gold } + ctx.log(`buy price of ${target.code} at Akara (1.13c port) = ${price}, gold=${world.self.gold}`) + const boughtId = await buy(ctx, npc, target, { gamble: false, shiftFill: false, cost: price }, 'buy') if (boughtId !== undefined) { const bought = world.containers.inventory.get(boughtId) if (bought) await sell(ctx, npc, bought, 'sell-back') From 812eb55d368ccdb5ed326eb101f2dc8c2a68ea4a Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 11:09:53 +0000 Subject: [PATCH 7/8] feat(client): server-driven vendor, repair, stash-gold and gamble UI - NPC menu: Trade -> 0x38 action 1, Gamble -> action 2 (classes 147/199/254/405/512/514 only), Repair via the trade window, close -> 0x30. - Store shelves render ClientWorld.containers.shop by storePage tab; buy / shift-buy / sell / repair / repair-all go out as 0x32/0x33/0x35 with 2000 ms throttling and the #10107/#10071 display price. - 0x2A results show TransactionResults* tbl messages; sale plays 0xdd. - Stash gold dialog modes 0/3/4 (strDropGoldHowMuch / strBankGoldDeposit / strBankGoldWithdraw) send 0x50 / 0x4F 0x14 / 0x4F 0x13; amounts are clamped to the source balance only, 0 sends nothing. - Removed every local gold/item mutation, VendorSessionManager usage, fake stock and the Boolean.prototype hacks. - PLAYER_GOLD_CAP (2,500,000) removed: inventory gold is clvl*10000; STASH_GOLD_CAP 2,500,000 (D2Common #11060) moved to inventory-model. - NPC strings packed from the MPQ tbls (16 new whitelist keys incl. index alias patchstring#95); canonical-tbl-strings gains only those. --- src/baker/packers/pack-canonical-tbl.ts | 30 + src/client/i18n/lang.ts | 7 + src/client/session/online-session.ts | 6 + src/client/ui-model/hud-model.ts | 293 ++++++- src/client/ui-model/vendor-price.ts | 32 + src/client/ui/hud-manager.ts | 372 ++++++--- src/client/ui/inventory.ts | 86 +- src/client/ui/world-panels.ts | 739 ++++-------------- src/common/data/canonical-tbl-strings.ts | 36 +- src/common/items/inventory-model.ts | 5 +- .../challenger-m5-stash-entities-gold.test.ts | 180 +---- tests/client/vendor-stash-ui.test.ts | 357 +++++++++ tests/cursor.test.ts | 13 +- tests/ground-items-drop.test.ts | 16 +- tests/helpers/vendor-shop-fixture.ts | 45 ++ tests/issue-503-bilingual-parity.test.ts | 5 +- tests/issue-504-exocet-fonts.test.ts | 2 + tests/item-to-ui-bridge.test.ts | 5 +- tests/monster-drop-e2e.test.ts | 3 +- tests/stash.test.ts | 72 +- tests/ui-hud.test.ts | 10 +- tests/unidentified-drops-identify.test.ts | 42 - tests/vendor-panel-ui.test.ts | 63 +- 23 files changed, 1309 insertions(+), 1110 deletions(-) create mode 100644 src/client/ui-model/vendor-price.ts create mode 100644 tests/client/vendor-stash-ui.test.ts create mode 100644 tests/helpers/vendor-shop-fixture.ts diff --git a/src/baker/packers/pack-canonical-tbl.ts b/src/baker/packers/pack-canonical-tbl.ts index 4bcbc91..a800b45 100644 --- a/src/baker/packers/pack-canonical-tbl.ts +++ b/src/baker/packers/pack-canonical-tbl.ts @@ -377,6 +377,22 @@ async function main() { 'bank', 'stash', 'strGoldInStash', + // NPC store / stash / gold dialog (D2Client store tooltips, 0x2A NPCTransaction replies, gold dialog) + 'cost', + 'Repair', + 'Sell', + 'TransactionResults4', + 'TransactionResults5', + 'TransactionResults6', + 'TransactionResults8', + 'TransactionResults9', + 'TransactionResults10', + 'TransactionResults11', + 'ok', + 'strDropGoldHowMuch', + 'strBankGoldDeposit', + 'strBankGoldWithdraw', + 'Back', 'box', 'strUiMenu2', 'strClose', @@ -524,6 +540,20 @@ async function main() { } chiOut['strethereal'] = EXPANSION_CHI_FALLBACKS['strethereal']! + // Index-only patchstring.tbl entries. Their real key is the placeholder "x", shared by ~100 patchstring + // entries (decodeTbl keeps the first duplicate), so D2Client looks them up by string index instead + // (patchstring index n = global tbl index 10000 + n). They are emitted from the MPQ under the alias + // key `patchstring#`. + // - 95: Repair All button hover text "Repair all equipment: %d" (D2Client string index 10095) + for (const idx of [95]) { + const alias = `patchstring#${idx}` + const ev = eng.tables[0]?.[idx] + const cv = chi.tables[0]?.[idx] + if (ev === undefined || cv === undefined) throw new Error(`patchstring.tbl index ${idx} missing (ENG or CHI)`) + engOut[alias] = ev + chiOut[alias] = cv + } + const outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts') const fileContent = `/** * AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives: diff --git a/src/client/i18n/lang.ts b/src/client/i18n/lang.ts index a91dab5..19c2e10 100644 --- a/src/client/i18n/lang.ts +++ b/src/client/i18n/lang.ts @@ -19,6 +19,13 @@ export function lookupTbl(key: string, lang: TblLang = getTblLang()): string | u return resolveTblKey(key, lang) } +/** Packed tbl string for `key`; throws when the key is not in the packed canonical dictionary. */ +export function requireTbl(key: string, lang: TblLang): string { + const v = resolveTblKey(key, lang) + if (v === undefined) throw new Error(`tbl key "${key}" is not packed (src/baker/packers/pack-canonical-tbl.ts)`) + return v +} + let overrideLang: GameLang | null = null const listeners = new Set<(lang: TblLang) => void>() const gameLangListeners = new Set<(lang: GameLang) => void>() diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index fd75142..2c29fe7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -57,6 +57,7 @@ import { type SettingsStore, } from '../settings/settings-store.ts' import { HudModel } from '../ui-model/hud-model.ts' +import { vendorPrice113c, vendorRepairAll113c } from '../ui-model/vendor-price.ts' import { ClientWorldSceneSource } from '../view/scene-source.ts' import type { RenderableUnitInput } from '../view/unit-drawables.ts' import { getViewportProfile } from '../view/viewport-profile.ts' @@ -249,6 +250,11 @@ export class OnlineSession { onCommand: (cmd) => { this.dispatchCommand(cmd) }, + vendorPrice: vendorPrice113c, + vendorRepairAll: vendorRepairAll113c, + playUiSound: (soundId) => { + this.soundService.playSoundById(soundId) + }, }) this.commandMapper = new CommandMapper({ diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 3c9915a..0d18b26 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -33,11 +33,14 @@ import { type UiInventoryItem, type UiItemQuality, } from '../ui/inventory.ts' -import type { MonsterTargetHudInfo } from '../ui/world-panels.ts' +import { GAMBLE_NPC_CLASS_IDS, type MonsterTargetHudInfo } from '../ui/world-panels.ts' +import { requireTbl } from '../i18n/lang.ts' +import { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts' +import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts' import type { AutomapView } from '../automap/automap-view.ts' import type { LocalMovementPredictor } from '../motion/predictor.ts' import type { ClientUnit } from '../world/client-unit.ts' -import type { ClientWorld } from '../world/client-world.ts' +import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts' import { D2StatId } from '../world/stat-list.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { computeCharacterSheetDerived } from './derived-stats.ts' @@ -173,8 +176,55 @@ export interface HudModelOptions { readonly predictor?: LocalMovementPredictor | null | undefined readonly automap?: AutomapView | null | undefined readonly onCommand?: ((cmd: ClientCommand) => void) | undefined + /** Store price provider (D2Common #10107); required before a store opens. Production: `vendorPrice113c`. */ + readonly vendorPrice?: VendorPriceFn | undefined + /** Repair-all cost provider (D2Common #10071); required before a store opens. Production: `vendorRepairAll113c`. */ + readonly vendorRepairAll?: VendorRepairAllFn | undefined + /** UI sound sink (sell sound 0xdd); required before a store opens. */ + readonly playUiSound?: ((soundId: number) => void) | undefined + /** Clock for the store click throttle (ms); defaults to `performance.now`. */ + readonly now?: (() => number) | undefined } +/** D2Client store click throttle: one buy / sell / repair request per action every 2000 ms. */ +export const NPC_TRADE_THROTTLE_MS = 2000 + +/** + * S2C `0x2A` NPCTransaction result -> `string.tbl` key of the message D2Client shows (indices 3356..3363). + * Results 0 (buy), 1 (sell) and 2 (repair all) succeed silently; any other unknown code shows TransactionResults9. + */ +export function npcTransactionMessageKey(result: number): string | null { + switch (result) { + case 0: + case 1: + case 2: + return null + case 7: + case 8: + return 'TransactionResults6' // 3358 + case 10: + return 'TransactionResults4' // 3356 + case 11: + return 'TransactionResults8' // 3360 + case 12: + return 'TransactionResults10' // 3362 + case 15: + return 'TransactionResults11' // 3363 + case 16: + return 'TransactionResults5' // 3357 + default: + return 'TransactionResults9' // 3361 + } +} + +/** Sound D2Client plays on a successful sell (S2C 0x2A result 1). */ +export const NPC_SELL_SOUND_ID = 0xdd + +/** `UnitType` id of monsters / NPCs in `UnitRef`. */ +const UNIT_TYPE_MONSTER = 1 + +const NPC_STATE_CLOSED: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false } + export interface HudModelSnapshot { readonly charName: string /** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */ @@ -224,6 +274,17 @@ export class HudModel implements HudCommandSink { private unsubWorld: (() => void) | null = null private hoveredMonsterId: number | null = null private escapeMenuOpen = false + private readonly priceProvider: VendorPriceFn | null + private readonly repairAllProvider: VendorRepairAllFn | null + private readonly uiSound: ((soundId: number) => void) | null + private readonly now: () => number + /** NPC/stash state seen by the last sync; panels react to transitions only. */ + private prevNpc: NpcInteractionState = NPC_STATE_CLOSED + /** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */ + private handledNpcTransactionSeq: number + private readonly lastNpcActionAt = new Map() + /** Message shown for the last non-success S2C 0x2A (string.tbl index + localized text). */ + lastNpcTransactionMessage: { readonly tblKey: string; readonly text: string } | null = null constructor(options: HudModelOptions) { this.world = options.world @@ -231,6 +292,11 @@ export class HudModel implements HudCommandSink { this.predictor = options.predictor ?? null this.automap = options.automap ?? null this.onCommand = options.onCommand + this.priceProvider = options.vendorPrice ?? null + this.repairAllProvider = options.vendorRepairAll ?? null + this.uiSound = options.playUiSound ?? null + this.now = options.now ?? (() => performance.now()) + this.handledNpcTransactionSeq = this.world.lastNpcTransaction?.seq ?? 0 if (this.hudManager) this.hudManager.commandSink = this this.unsubWorld = this.world.onChange(() => { @@ -551,9 +617,143 @@ export class HudModel implements HudCommandSink { // 7. Target monster plaque this.syncHoveredMonsterTarget() + // 8. NPC menu / store / stash panels and S2C 0x2A replies. + this.syncNpcInteraction(hud) + hud.syncPublishedState() } + private syncNpcInteraction(hud: HudManager): void { + const npc = this.world.npcInteraction + const prev = this.prevNpc + this.prevNpc = npc + const wp = hud.worldPanels + + // a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors). + if (npc.npcId !== null && npc.window === 'none' && npc.npcId !== prev.npcId) { + const desc = this.npcDescriptor(npc.npcId) + if (desc) wp.openNpcMenu(desc, 400, 220, npc.npcId) + } + if (npc.npcId === null && wp.npcMenu && wp.npcMenu.npcId !== null) wp.npcMenu = null + + // b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`). + if (npc.window !== 'none' && npc.npcId !== null) { + const npcId = npc.npcId + const desc = this.npcDescriptor(npcId) + if (!desc) throw new Error(`HudModel: store opened for NPC #${npcId} without a service descriptor`) + const ctx = this.priceContext(npcId) + const price = this.requireVendorPrice() + const gamble = npc.window === 'gamble' + const items = [...this.world.containers.shop.values()].map((item) => ({ + storePage: item.storePage, + placement: { + item: { ...itemDataToUiInventoryItem(item), buyCost: price(item, gamble ? 2 : 0, ctx) }, + col: item.gridX, + row: item.gridY, + }, + })) + wp.setVendorShop({ npcId, descriptor: desc, mode: npc.window, items }) + const canRepair = desc.canRepair && !gamble + wp.vendorRepairAllCost = canRepair ? this.requireVendorRepairAll()(ctx) : null + this.decoratePlayerItemPrices(hud, price, ctx, canRepair) + if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel() + } else if (prev.window !== 'none') { + wp.clearVendorShop() + hud.hideLeftPanelFromWorld('vendor') + } + + // c. Stash: opened by S2C 0x77 action 0x10, closed when 0x4F 0x12 is sent. + if (npc.stashOpen && !prev.stashOpen) hud.showStashPanel() + else if (!npc.stashOpen && prev.stashOpen) hud.hideLeftPanelFromWorld('stash') + + // d. S2C 0x2A NPCTransaction: react once per reply. + const txn = this.world.lastNpcTransaction + if (txn && txn.seq !== this.handledNpcTransactionSeq) { + this.handledNpcTransactionSeq = txn.seq + const msgKey = npcTransactionMessageKey(txn.result) + if (msgKey !== null) { + const text = requireTbl(msgKey, hud.lang) + this.lastNpcTransactionMessage = { tblKey: msgKey, text } + wp.showVendorStatus(text, 'white') + } else if (txn.result === 1) { + this.requirePlayUiSound()(NPC_SELL_SOUND_ID) + } + } + } + + /** Sell value (and repair cost at smiths) on the player's bag and equipped items while a store is open. */ + private decoratePlayerItemPrices(hud: HudManager, price: VendorPriceFn, ctx: VendorPriceContext, canRepair: boolean): void { + const decorate = (ui: UiInventoryItem, raw: ItemData): UiInventoryItem => ({ + ...ui, + sellCost: price(raw, 1, ctx), + ...(canRepair && raw.maxDurability !== undefined && raw.maxDurability > 0 ? { repairCost: price(raw, 3, ctx) } : {}), + }) + hud.inventory.gridItems = hud.inventory.gridItems.map((pl) => { + const raw = this.world.containers.inventory.get(Number(pl.item.id)) + if (!raw) throw new Error(`HudModel: inventory item ${pl.item.id} missing from ClientWorld`) + return { ...pl, item: decorate(pl.item, raw) } + }) + for (const raw of this.world.containers.equipment.values()) { + const slot = BODY_LOC_TO_EQUIP_SLOT[raw.bodyLoc || raw.equippedSlot] + const ui = slot ? hud.inventory.equipped[slot] : undefined + if (slot && ui) hud.inventory.equipped[slot] = decorate(ui, raw) + } + } + + private npcDescriptor(npcId: number): TownNpcServiceDescriptor | undefined { + return getTownNpcDescriptorByHcIdx(this.npcClassOf(npcId)) + } + + /** MonStats hcIdx of NPC unit `npcId`; throws when the unit is not in the world. */ + private npcClassOf(npcId: number): number { + const unit = this.world.getUnit('monster', npcId) + if (!unit) throw new Error(`HudModel: NPC unit #${npcId} is not in the world`) + return unit.classId + } + + private priceContext(npcId: number): VendorPriceContext { + return { world: this.world, npcClass: this.npcClassOf(npcId) } + } + + private requireVendorPrice(): VendorPriceFn { + if (!this.priceProvider) throw new Error('HudModel: no vendorPrice provider (HudModelOptions.vendorPrice)') + return this.priceProvider + } + + private requireVendorRepairAll(): VendorRepairAllFn { + if (!this.repairAllProvider) throw new Error('HudModel: no vendorRepairAll provider (HudModelOptions.vendorRepairAll)') + return this.repairAllProvider + } + + private requirePlayUiSound(): (soundId: number) => void { + if (!this.uiSound) throw new Error('HudModel: no playUiSound sink (HudModelOptions.playUiSound)') + return this.uiSound + } + + /** The open store (`npcInteraction.window` trade / gamble); throws when none is open. */ + private requireOpenStore(action: string): { npcId: number; gamble: boolean; ctx: VendorPriceContext } { + const npc = this.world.npcInteraction + if (npc.npcId === null || npc.window === 'none') throw new Error(`HudModel.${action}: no store window is open`) + return { npcId: npc.npcId, gamble: npc.window === 'gamble', ctx: this.priceContext(npc.npcId) } + } + + /** `true` when `action` ran less than 2000 ms ago (the click is dropped, like D2Client). */ + private isThrottled(action: string): boolean { + const t = this.now() + const last = this.lastNpcActionAt.get(action) + if (last !== undefined && t - last < NPC_TRADE_THROTTLE_MS) return true + this.lastNpcActionAt.set(action, t) + return false + } + + /** Player item by id from the bag, the paperdoll or the cursor. */ + private requirePlayerItem(itemId: number, action: string): ItemData { + const c = this.world.containers + const item = c.inventory.get(itemId) ?? c.equipment.get(itemId) ?? (c.cursor?.id === itemId ? c.cursor : undefined) + if (!item) throw new Error(`HudModel.${action}: item #${itemId} is not carried by the player`) + return item + } + /** * Update the hovered/targeted monster unit ID and refresh `hudManager.targetMonster`. */ @@ -775,6 +975,95 @@ export class HudModel implements HudCommandSink { identifyAllAtNpc(npcId: number): void { this.emitCommand({ type: 'NpcIdentifyAll', npcId }) } + + /** NPC menu "trade" / "trade/repair" (`0x38` action 1). Throws when no price provider is configured. */ + openNpcTrade(npcId: number): void { + this.requireVendorPrice() + this.requireVendorRepairAll() + this.requirePlayUiSound() + this.emitCommand({ type: 'NpcOpenTrade', npcId }) + this.syncFromWorld() + } + + /** NPC menu "gamble" (`0x38` action 2); only the six 1.13c gamble NPC classes. */ + openNpcGamble(npcId: number): void { + const npcClass = this.npcClassOf(npcId) + if (!GAMBLE_NPC_CLASS_IDS.has(npcClass)) throw new Error(`HudModel.openNpcGamble: NPC class ${npcClass} does not gamble`) + this.requireVendorPrice() + this.requireVendorRepairAll() + this.requirePlayUiSound() + this.emitCommand({ type: 'NpcOpenGamble', npcId }) + this.syncFromWorld() + } + + /** Close the NPC menu / store (`0x30 NpcCancel`); the server sends nothing back. */ + closeNpc(npcId: number): void { + this.emitCommand({ type: 'NpcCancel', target: { unitType: UNIT_TYPE_MONSTER, id: npcId } }) + this.syncFromWorld() + } + + /** Store grid click on shop item `itemId`: `0x32 NpcBuy` with the #10107 buy / gamble price. */ + vendorBuy(itemId: number, shiftFill: boolean): boolean { + const store = this.requireOpenStore('vendorBuy') + const item = this.world.containers.shop.get(itemId) + if (!item) throw new Error(`HudModel.vendorBuy: shop item #${itemId} not in the store`) + const cost = this.requireVendorPrice()(item, store.gamble ? 2 : 0, store.ctx) + if (this.isThrottled('buy')) return false + this.buyFromNpc({ + npcId: store.npcId, + itemId, + itemMode: 0, + gamble: store.gamble, + shiftFill: store.gamble ? false : shiftFill, + cost, + }) + return true + } + + /** Sell player item `itemId`: `0x33 NpcSell` with the #10107 sell price. */ + vendorSell(itemId: number): boolean { + const store = this.requireOpenStore('vendorSell') + const item = this.requirePlayerItem(itemId, 'vendorSell') + const cost = this.requireVendorPrice()(item, 1, store.ctx) + if (this.isThrottled('sell')) return false + this.sellToNpc({ npcId: store.npcId, itemId, itemMode: 0, cost }) + return true + } + + /** Repair player item `itemId` (`0x35`); durability is the item's current value (absent stat = 0). */ + vendorRepairItem(itemId: number): boolean { + const store = this.requireOpenStore('vendorRepairItem') + const item = this.requirePlayerItem(itemId, 'vendorRepairItem') + if (this.isThrottled('repair')) return false + this.repairItemAtNpc({ npcId: store.npcId, itemId, itemMode: 0, durability: item.curDurability ?? 0 }) + return true + } + + /** Repair All button (`0x35` field4 = 0x80000000). */ + vendorRepairAll(): boolean { + const store = this.requireOpenStore('vendorRepairAll') + if (this.isThrottled('repair')) return false + this.repairAllAtNpc(store.npcId) + return true + } + + /** Gold dialog mode 3: `0x4F ClickButton` 0x14 DepositGold. Non-positive amounts send nothing. */ + depositGold(amount: number): void { + if (!Number.isInteger(amount) || amount <= 0) return + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount }) + } + + /** Gold dialog mode 4: `0x4F ClickButton` 0x13 WithdrawGold. Non-positive amounts send nothing. */ + withdrawGold(amount: number): void { + if (!Number.isInteger(amount) || amount <= 0) return + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount }) + } + + /** Stash panel closed: `0x4F ClickButton` 0x12 CloseStash (no reply). */ + closeStash(): void { + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }) + this.syncFromWorld() + } } export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHudInfo { diff --git a/src/client/ui-model/vendor-price.ts b/src/client/ui-model/vendor-price.ts new file mode 100644 index 0000000..90e4c20 --- /dev/null +++ b/src/client/ui-model/vendor-price.ts @@ -0,0 +1,32 @@ +/** + * Vendor price provider contract (`src/client/ui-model/vendor-price.ts`). + * + * D2Client prices store items locally with D2Common #10107 (repair-all: #10071) for the tooltip and the + * `0x32 NpcBuy` / `0x33 NpcSell` cost field; D2Game ignores that field and prices server-side. The HUD never + * derives a price itself: `HudModel` receives these functions through its options, and opening a store + * without them throws (no fallback price). Production wiring injects {@link vendorPrice113c} / + * {@link vendorRepairAll113c}; tests inject stubs. + */ + +import type { ItemData } from '../../netproto/index.ts' +import type { ClientWorld } from '../world/client-world.ts' +import { priceItem113c, repairAllCost113c } from './transaction-cost-input.ts' + +/** Pricing inputs: the server-mirrored world (clvl, difficulty, quests, reduced-price stats) and the NPC class. */ +export interface VendorPriceContext { + readonly world: ClientWorld + /** MonStats hcIdx of the store NPC (e.g. 147 = Gheed). */ + readonly npcClass: number +} + +/** Transaction type `0` buy, `1` sell, `2` gamble, `3` repair; returns the displayed / sent gold amount. */ +export type VendorPriceFn = (item: ItemData, type: 0 | 1 | 2 | 3, ctx: VendorPriceContext) => number + +/** "Repair all equipment: %d" total (patchstring 10095) for the player's equipped items. */ +export type VendorRepairAllFn = (ctx: VendorPriceContext) => number + +/** D2Common 1.13c #10107 port. */ +export const vendorPrice113c: VendorPriceFn = (item, type, ctx) => priceItem113c(ctx.world, item, type, ctx.npcClass) + +/** D2Common 1.13c #10071 port. */ +export const vendorRepairAll113c: VendorRepairAllFn = (ctx) => repairAllCost113c(ctx.world, ctx.npcClass) diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 97c0d2b..f877834 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -45,7 +45,6 @@ import { type CursorState, } from './cursor.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' -import type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts' import { detectInitialLang, setCurrentLang, @@ -54,6 +53,7 @@ import { type TblLang, } from '../i18n/lang.ts' import { setTooltipLocale } from '../../common/items/item-tooltip.ts' +import { requireTbl } from '../i18n/lang.ts' import { computeViewportAnchors, getViewportProfile, @@ -283,6 +283,15 @@ export function logicalToClient( * Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`; * the HUD never mutates gameplay state locally while a sink is attached. */ +export type GoldDialogMode = 0 | 3 | 4 + +export interface GoldDialogRect { + readonly x: number + readonly y: number + readonly w: number + readonly h: number +} + export interface HudCommandSink { allocateStat(key: BaseStatKey): boolean allocateSkill(skillId: number): boolean @@ -299,6 +308,28 @@ export interface HudCommandSink { useItem(itemId: number): void transmuteCube(): void teleportToLevel(destAreaId: number): void + /** NPC menu "trade" / "trade/repair": `0x38` action 1. */ + openNpcTrade(npcId: number): void + /** NPC menu "gamble": `0x38` action 2 (Gheed, Elzix, Alkor, Jamella, Anya, Nihlathak). */ + openNpcGamble(npcId: number): void + /** NPC menu "Identify Items" (Deckard Cain): `0x34`. */ + identifyAllAtNpc(npcId: number): void + /** Close the NPC menu / store: `0x30 NpcCancel`. The server sends nothing back. */ + closeNpc(npcId: number): void + /** Store grid click on a shop item: `0x32 NpcBuy` (gamble flag from the open window). */ + vendorBuy(itemId: number, shiftFill: boolean): void + /** Sell a player item: `0x33 NpcSell`. */ + vendorSell(itemId: number): void + /** Repair one item: `0x35 NpcRepair`. */ + vendorRepairItem(itemId: number): void + /** Repair all equipped items: `0x35` with field4 = 0x80000000. */ + vendorRepairAll(): void + /** Gold dialog mode 3: `0x4F` button 0x14. */ + depositGold(amount: number): void + /** Gold dialog mode 4: `0x4F` button 0x13. */ + withdrawGold(amount: number): void + /** Stash panel closed: `0x4F` button 0x12 (no reply). */ + closeStash(): void } /** Parse the numeric server item id carried by a `UiInventoryItem` synced from `ClientWorld`; throws otherwise. */ @@ -392,6 +423,13 @@ export class HudManager { currentClass: CharacterClassCode | null = null private _commandSink: HudCommandSink | null = null + /** + * D2Client gold dialog: mode `0` drop (string 4033), `3` deposit (4049), `4` withdraw (4050). + * The amount is clamped only to the source balance (inventory gold for 0/3, stash gold for 4); + * the server applies the inventory (clvl * 10,000) and stash (2,500,000) caps. + */ + goldDialog: { mode: GoldDialogMode; amount: number; fresh: boolean } | null = null + /** * Server-authoritative command sink (online: `HudModel`). When attached, HUD interactions only emit * `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`. @@ -693,41 +731,164 @@ export class HudManager { return ok } - /** - * Opens a Town NPC Vendor or Gamble session on the Left Dock and ensures Player Inventory is open on the Right Dock. - */ - openVendorSession( - descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, - mode: 'trade' | 'gamble' = 'trade', - difficulty: 0 | 1 | 2 = 0, - ): boolean { - if (this.currentClass === null) { - throw new Error('HudManager.openVendorSession: character class not assigned by the server yet') + private requireSink(action: string): HudCommandSink { + if (!this._commandSink) throw new Error(`HudManager.${action}: no server command sink attached`) + return this._commandSink + } + + /** Server opened a store (`ClientWorld.npcInteraction.window`): show the vendor panel with the inventory. */ + showVendorPanel(): void { + if (!this.worldPanels.activeVendorDescriptor) { + throw new Error('HudManager.showVendorPanel: no store set (WorldPanelsHud.setVendorShop)') } - const playerCtx = { - level: this.charSheet.attrs.level, - str: this.charSheet.attrs.str, - dex: this.charSheet.attrs.dex, - classCode: this.currentClass, - } - this.worldPanels.activePlayerInventory = this.inventory - this.inventory.setPlayerContext(playerCtx) - this.worldPanels.setPlayerContext(playerCtx) - const ok = this.worldPanels.openVendorForNpc( - descriptorOrName, - mode, - this.charSheet.attrs.level, - difficulty, - ) - if (!ok) return false this.worldPanels.npcMenu = null this.leftPanel = 'vendor' this.charSheet.visible = false - if (this.rightPanel !== 'inv') { - this.toggleRightPanel('inv') + if (this.rightPanel !== 'inv') this.toggleRightPanel('inv') + this.syncPublishedState() + } + + /** Server opened the stash (S2C 0x77 action 0x10): show the stash panel with the inventory. */ + showStashPanel(): void { + this.leftPanel = 'stash' + this.charSheet.visible = false + if (this.rightPanel !== 'inv') this.toggleRightPanel('inv') + this.syncPublishedState() + } + + /** World state closed `kind` (already noted by `ClientWorld`): hide it without sending anything. */ + hideLeftPanelFromWorld(kind: 'vendor' | 'stash'): void { + if (this.leftPanel !== kind) return + this.leftPanel = 'none' + if (kind === 'vendor') this.worldPanels.releaseMomentaryVendorButton() + if (this.goldDialog?.mode === 4 && kind === 'stash') this.goldDialog = null + this.syncPublishedState() + } + + /** Dismiss the NPC menu and end the interaction (`0x30 NpcCancel`). */ + dismissNpcMenu(): void { + const menu = this.worldPanels.npcMenu + if (!menu) return + this.worldPanels.npcMenu = null + if (menu.npcId !== null) this.requireSink('dismissNpcMenu').closeNpc(menu.npcId) + this.syncPublishedState() + } + + /** Leaving the vendor / stash panel tells the server (`0x30` / `0x4F 0x12`); other panels are local. */ + private leaveLeftPanel(prev: LeftDockPanelKind): void { + if (prev === 'vendor') { + const npcId = this.worldPanels.vendorNpcId + this.worldPanels.clearVendorShop() + if (npcId !== null) this.requireSink('closeVendor').closeNpc(npcId) + } else if (prev === 'stash') { + if (this.goldDialog && this.goldDialog.mode !== 0) this.goldDialog = null + this._commandSink?.closeStash() + } + } + + /** Source balance the gold dialog clamps to: stash gold for withdraw, inventory gold otherwise. */ + goldDialogBalance(mode: GoldDialogMode): number { + return mode === 4 ? this.worldPanels.stashGold : this.inventory.gold + } + + /** Open the gold dialog pre-filled with the whole source balance. */ + openGoldDialog(mode: GoldDialogMode): void { + if (mode === 3 && this.leftPanel !== 'stash') throw new Error('HudManager.openGoldDialog: deposit needs the stash open') + if (mode === 4 && this.leftPanel !== 'stash') throw new Error('HudManager.openGoldDialog: withdraw needs the stash open') + this.goldDialog = { mode, amount: this.goldDialogBalance(mode), fresh: true } + this.syncPublishedState() + } + + /** Set the typed amount, clamped to `0 .. source balance`. */ + setGoldDialogAmount(amount: number): void { + const dlg = this.goldDialog + if (!dlg) throw new Error('HudManager.setGoldDialogAmount: gold dialog is not open') + if (!Number.isFinite(amount)) throw new Error(`HudManager.setGoldDialogAmount: invalid amount ${amount}`) + dlg.amount = Math.max(0, Math.min(this.goldDialogBalance(dlg.mode), Math.floor(amount))) + dlg.fresh = false + } + + /** Digit / Backspace editing; the first digit after opening replaces the pre-filled amount. */ + typeGoldDialogKey(key: string): void { + const dlg = this.goldDialog + if (!dlg) throw new Error('HudManager.typeGoldDialogKey: gold dialog is not open') + if (key === 'Backspace') { + this.setGoldDialogAmount(dlg.fresh ? 0 : Math.floor(dlg.amount / 10)) + } else if (/^[0-9]$/.test(key)) { + this.setGoldDialogAmount((dlg.fresh ? 0 : dlg.amount) * 10 + Number(key)) + } + } + + /** "ok": send the request for the clamped amount (nothing for 0) and close the dialog. */ + confirmGoldDialog(): void { + const dlg = this.goldDialog + if (!dlg) throw new Error('HudManager.confirmGoldDialog: gold dialog is not open') + const amount = Math.max(0, Math.min(this.goldDialogBalance(dlg.mode), dlg.amount)) + this.goldDialog = null + if (amount > 0) { + const sink = this.requireSink('confirmGoldDialog') + if (dlg.mode === 0) sink.dropGold(amount) + else if (dlg.mode === 3) sink.depositGold(amount) + else sink.withdrawGold(amount) } this.syncPublishedState() - return true + } + + cancelGoldDialog(): void { + this.goldDialog = null + this.syncPublishedState() + } + + /** Gold dialog box (centered stone box: title, amount, ok / cancel). */ + getGoldDialogLayout(): { x: number; y: number; w: number; h: number; ok: GoldDialogRect; cancel: GoldDialogRect } { + const w = 260 + const h = 110 + const x = Math.round(this.screenWidth / 2 - w / 2) + const y = Math.round((this.screenHeight - 47) / 2 - h / 2) + return { + x, + y, + w, + h, + ok: { x: x + 30, y: y + h - 34, w: 80, h: 22 }, + cancel: { x: x + w - 110, y: y + h - 34, w: 80, h: 22 }, + } + } + + private handleGoldDialogClick(x: number, y: number): void { + const l = this.getGoldDialogLayout() + const inRect = (r: GoldDialogRect) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h + if (inRect(l.ok)) this.confirmGoldDialog() + else if (inRect(l.cancel)) this.cancelGoldDialog() + } + + private drawGoldDialog(ctx: CanvasRenderingContext2D): void { + const dlg = this.goldDialog + if (!dlg) return + const l = this.getGoldDialogLayout() + const titleKey = dlg.mode === 0 ? 'strDropGoldHowMuch' : dlg.mode === 3 ? 'strBankGoldDeposit' : 'strBankGoldWithdraw' + ctx.fillStyle = 'rgba(0, 0, 0, 0.9)' + ctx.fillRect(l.x, l.y, l.w, l.h) + ctx.strokeStyle = '#6c5c40' + ctx.strokeRect(l.x + 0.5, l.y + 0.5, l.w - 1, l.h - 1) + this.font.drawText(ctx, requireTbl(titleKey, this.lang), l.x + l.w / 2, l.y + 22, { + font: 'font8', + color: 'white', + align: 'center', + }) + this.font.drawText(ctx, String(dlg.amount), l.x + l.w / 2, l.y + 50, { + font: 'font16', + color: 'gold', + align: 'center', + }) + for (const [rect, key] of [[l.ok, 'ok'], [l.cancel, 'Back']] as const) { + ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1) + this.font.drawText(ctx, requireTbl(key, this.lang), rect.x + rect.w / 2, rect.y + 16, { + font: 'font16', + color: 'white', + align: 'center', + }) + } } /** @@ -735,16 +896,19 @@ export class HudManager { * Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`). */ toggleLeftPanel(kind: Exclude): void { - if (this.leftPanel === kind) { + const prev = this.leftPanel + if (prev === kind) { this.leftPanel = 'none' this.charSheet.visible = false this.worldPanels.vendorTradeState = 'idle' this.worldPanels.releaseMomentaryVendorButton() + this.leaveLeftPanel(prev) } else { if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) { throw new Error('Cannot open vendor panel without an active vendor session') } this.leftPanel = kind + if (prev !== 'none') this.leaveLeftPanel(prev) this.charSheet.visible = kind === 'char' if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') { this.toggleRightPanel('inv') @@ -777,8 +941,13 @@ export class HudManager { this.belt.lockedOpen || this.hotkeys.openPopup !== null || this.worldPanels.npcMenu !== null || + this.goldDialog !== null || this.inventory.isIdentifyMode() + const prevLeft = this.leftPanel + this.goldDialog = null + this.dismissNpcMenu() this.leftPanel = 'none' + if (prevLeft !== 'none') this.leaveLeftPanel(prevLeft) this.rightPanel = 'none' this.charSheet.visible = false this.inventory.visible = false @@ -849,6 +1018,7 @@ export class HudManager { * Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false. */ isPointInterceptedByHud(logicalX: number, logicalY: number): boolean { + if (this.goldDialog) return true const profile = this.viewportProfile const docking = this.getDockingLayout() const { marginW } = docking @@ -898,6 +1068,14 @@ export class HudManager { private bindEvents(): void { window.addEventListener('keydown', (e) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return + if (this.goldDialog) { + e.preventDefault() + if (e.key === 'Enter') this.confirmGoldDialog() + else if (e.key === 'Escape') this.cancelGoldDialog() + else this.typeGoldDialogKey(e.key) + this.syncPublishedState() + return + } const key = e.key.toUpperCase() if (key === 'I' || key === 'B') { @@ -1067,9 +1245,14 @@ export class HudManager { handleHudMouseDown(e: MouseEvent): void { if (e.button !== 0 && e.button !== 2) return const pt = this.clientToLogical(e.clientX, e.clientY) + if (this.goldDialog) { + e.preventDefault() + e.stopPropagation() + if (e.button === 0) this.handleGoldDialogClick(pt.x, pt.y) + return + } if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) { - this.worldPanels.npcMenu = null - this.syncPublishedState() + this.dismissNpcMenu() } if (!this.isPointInterceptedByHud(pt.x, pt.y)) { if (this.inventory.isIdentifyMode()) { @@ -1107,19 +1290,25 @@ export class HudManager { // Town NPC Stone Menu click if (this.worldPanels.npcMenu) { + const npcId = this.worldPanels.npcMenu.npcId this.worldPanels.handleNpcMenuClick( pt.x, pt.y, { - onOpenVendor: (desc, mode) => { - this.openVendorSession(desc, mode) + onOpenVendor: (_desc, mode) => { + const sink = this.requireSink('npcMenu') + if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id') + if (mode === 'trade') sink.openNpcTrade(npcId) + else sink.openNpcGamble(npcId) }, onIdentifyAll: (_desc) => { - const count = this.worldPanels.identifyAllUiItems(this.inventory) - this.worldPanels.showAreaBanner( - count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品', - count > 0 ? `Identified ${count} items` : 'No unidentified items in inventory', - ) + const sink = this.requireSink('npcMenu') + if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id') + sink.identifyAllAtNpc(npcId) + sink.closeNpc(npcId) + }, + onCancel: () => { + if (npcId !== null) this.requireSink('npcMenu').closeNpc(npcId) }, }, this.font, @@ -1180,10 +1369,16 @@ export class HudManager { this.syncPublishedState() return } else if (this.leftPanel !== 'none') { - this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, { + const dockKind = this.leftPanel + this.worldPanels.handleLeftDockClick(dockKind, leftX, pt.y, { onClose: () => { this.leftPanel = 'none' + this.leaveLeftPanel(dockKind) }, + onStashGoldButton: () => this.openGoldDialog(4), + onVendorBuy: (item, shiftFill) => this.requireSink('vendorBuy').vendorBuy(serverItemIdOf(item), shiftFill), + onVendorSell: (item) => this.requireSink('vendorSell').vendorSell(serverItemIdOf(item)), + onVendorRepairAll: () => this.requireSink('vendorRepairAll').vendorRepairAll(), onWaypointTeleport: (act, slug, levelId) => { if (this._commandSink) { this._commandSink.teleportToLevel(levelId) @@ -1260,51 +1455,11 @@ export class HudManager { } } } + } - // Vendor Repair or Sell/Quick-Sell from Player Inventory - if (this.leftPanel === 'vendor') { - // D2Client.dll 0x6fb472d0: Right-clicking while in Buy/Sell/Repair cursor mode cancels back to idle - if (e.button === 2 && this.worldPanels.vendorTradeState !== 'idle') { - this.worldPanels.vendorTradeState = 'idle' - this.syncPublishedState() - return - } - if ( - rightX >= INV_GRID_ORIGIN.x && - rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && - pt.y >= INV_GRID_ORIGIN.y && - pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx - ) { - const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) - const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) - const hitIdx = this.inventory.gridItems.findIndex( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hitIdx !== -1) { - const hit = this.inventory.gridItems[hitIdx]! - if (this.worldPanels.vendorTradeState === 'repair') { - this.worldPanels.repairSingleUiItem(hit.item, this.inventory) - this.syncPublishedState() - return - } - if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { - const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) - if (res.ok) { - if (this.inventory.gridItems[hitIdx] === hit) { - this.inventory.gridItems.splice(hitIdx, 1) - } - this.inventory.hoveredItem = null - } - this.syncPublishedState() - return - } - } - } else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) { - this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory) - this.syncPublishedState() - return - } - } + if (this.leftPanel === 'vendor' && !this.inventory.isIdentifyMode() && this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)) { + this.syncPublishedState() + return } if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) { @@ -1319,9 +1474,7 @@ export class HudManager { { onOpenCube: () => this.toggleLeftPanel('cube'), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), - onDropGold: (amt) => { - this.dropInventoryGold(amt, pt) - }, + onGoldButton: () => this.openGoldDialog(this.leftPanel === 'stash' ? 3 : 0), }, e.button === 2 ? 2 : 0, { @@ -1341,6 +1494,46 @@ export class HudManager { } } + /** + * Inventory click while a store is open and the sell / repair cursor is armed (D2Client 0x6fb472d0): + * right-click disarms; sell sends `0x33` for the bag item; repair sends `0x35` for the bag or equipped item. + * Returns `false` in the idle / buy state so the click picks up / places items as usual. + */ + private handleVendorInventoryClick(x: number, y: number, isRightClick: boolean): boolean { + const state = this.worldPanels.vendorTradeState + if (state !== 'sell' && state !== 'repair') return false + if (isRightClick) { + this.worldPanels.vendorTradeState = 'idle' + return true + } + let target: UiInventoryItem | null = null + if ( + x >= INV_GRID_ORIGIN.x && + x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + y >= INV_GRID_ORIGIN.y && + y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + target = + this.inventory.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + )?.item ?? null + } else if (state === 'repair') { + for (const [slot, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, (typeof EQUIP_SLOTS_LAYOUT)[EquipSlotId]][]) { + if (x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) { + target = this.inventory.equipped[slot] ?? null + break + } + } + } + if (!target) return true + const sink = this.requireSink(state === 'sell' ? 'vendorSell' : 'vendorRepairItem') + if (state === 'sell') sink.vendorSell(serverItemIdOf(target)) + else sink.vendorRepairItem(serverItemIdOf(target)) + return true + } + /** * Forward an inventory-panel click (grid / paperdoll) to the server sink. Returns `false` when the point is not * on an item area so the panel's own chrome (close / gold buttons / swap tabs) still handles it. @@ -1745,6 +1938,9 @@ export class HudManager { this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor') } + // 10b. Gold dialog (drop / deposit / withdraw) + this.drawGoldDialog(ctx) + // 11. Authentic Hand / Trade Cursor & Held Item at Hotspot this.renderCursor(ctx, nowMs) diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index fc09362..069b13f 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -64,7 +64,7 @@ export { import { INV_COLS, INV_ROWS, - PLAYER_GOLD_CAP, + STASH_GOLD_CAP, getInventoryGoldLimit, findFreeGridSlot, type EquipSlotId, @@ -76,7 +76,7 @@ import { export { INV_COLS, INV_ROWS, - PLAYER_GOLD_CAP, + STASH_GOLD_CAP, getInventoryGoldLimit, findFreeGridSlot, type EquipSlotId, @@ -127,6 +127,9 @@ export const INV_GOLD_BTN_BOUNDS = { h: 18, } as const +/** Panel-relative click rect of the inventory gold coin button (inclusive bounds, D2Client inventory panel). */ +export const INV_GOLD_BTN_HIT = { relX0: 83, relX1: 103, relY0: 392, relY1: 410 } as const + export const INV_GOLD_BOX_RECESS = { x: 400 + 107, y: 60 + 392, @@ -1583,34 +1586,6 @@ export function drawItemSocketOverlay( } } -declare global { - interface Boolean { - readonly cursorItem: UiInventoryItem | null - readonly action: 'equip' | 'unequip' | 'swap' | 'none' - } -} - -let _lastInvClickCursorItem: UiInventoryItem | null = null -let _lastInvClickAction: 'equip' | 'unequip' | 'swap' | 'none' = 'none' - -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'cursorItem')) { - Object.defineProperty(Boolean.prototype, 'cursorItem', { - get() { - return _lastInvClickCursorItem - }, - configurable: true, - }) -} - -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'action')) { - Object.defineProperty(Boolean.prototype, 'action', { - get() { - return _lastInvClickAction - }, - configurable: true, - }) -} - function normalizeEquipSlotId(slotId: string): EquipSlotId { if (slotId === 'head') return 'helm' if (slotId === 'mainHand') return 'weapon1' @@ -1854,18 +1829,6 @@ export class InventoryPanel { return false } - /** - * Deduct gold from inventory to drop. Returns actual amount deducted. - */ - dropGold(amount: number): number { - if (!Number.isFinite(amount) || amount <= 0) return 0 - const current = this.gold - const toDrop = Math.max(0, Math.min(Math.floor(amount), current)) - if (toDrop <= 0) return 0 - this.gold = current - toDrop - return toDrop - } - /** * Handle left-click on a grid cell `(col, row)`: * - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`. @@ -1877,7 +1840,6 @@ export class InventoryPanel { */ clickGridCell(col: number, row: number): boolean { if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) { - _lastInvClickCursorItem = this.cursorItem return false } if (!this.cursorItem) { @@ -1885,12 +1847,10 @@ export class InventoryPanel { p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (!hit) { - _lastInvClickCursorItem = null return false } this.gridItems = this.gridItems.filter(p => p !== hit) this.cursorItem = hit.item - _lastInvClickCursorItem = this.cursorItem return true } @@ -1910,7 +1870,6 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } } @@ -1922,12 +1881,10 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) { - _lastInvClickCursorItem = this.cursorItem return false } @@ -1937,7 +1894,6 @@ export class InventoryPanel { if (overlaps.length === 0) { this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = null - _lastInvClickCursorItem = this.cursorItem return true } if (overlaps.length === 1) { @@ -1954,7 +1910,6 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } } @@ -1966,16 +1921,13 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = single.item - _lastInvClickCursorItem = this.cursorItem return true } - _lastInvClickCursorItem = this.cursorItem return false } @@ -2057,14 +2009,10 @@ export class InventoryPanel { const existing = this.equipped[slotId] ?? null if (!this.cursorItem) { if (!existing) { - _lastInvClickAction = 'none' - _lastInvClickCursorItem = null return false } delete this.equipped[slotId] this.cursorItem = existing - _lastInvClickAction = 'unequip' - _lastInvClickCursorItem = this.cursorItem return true } if (existing && canSocketUiItem(this.cursorItem, existing)) { @@ -2075,19 +2023,13 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickAction = 'equip' - _lastInvClickCursorItem = this.cursorItem return true } if (!this.canEquipItemInSlot(this.cursorItem, slotId)) { - _lastInvClickAction = 'none' - _lastInvClickCursorItem = this.cursorItem return false } this.equipped[slotId] = this.cursorItem this.cursorItem = existing - _lastInvClickAction = existing ? 'swap' : 'equip' - _lastInvClickCursorItem = this.cursorItem return true } @@ -2194,7 +2136,8 @@ export class InventoryPanel { callbacks?: { onOpenCube?: () => void onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void - onDropGold?: (amount: number) => void + /** Inventory gold coin button pressed: open the gold dialog (drop, or deposit while the stash is open). */ + onGoldButton?: () => void }, button: 0 | 2 = 0, externalContainers?: { @@ -2310,12 +2253,15 @@ export class InventoryPanel { return true } - // 5. Gold Coin Button (`goldcoinbtn.dc6` at x=484..504) & Readout Area (x: 484..625, y: 450..485) - if (logicalX >= INV_GOLD_BTN_BOUNDS.x && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { - if (this.gold > 0) { - const dropAmt = Math.min(10_000, this.gold) - callbacks?.onDropGold?.(dropAmt) - } + // 5. Gold coin button (D2Client inventory gold button hit rect, panel-relative x 83..103, y 392..410). + // D2 never drops/deposits locally: the button only opens the gold dialog; the amount is chosen there. + if ( + logicalX >= INV_PANEL_ORIGIN.x + INV_GOLD_BTN_HIT.relX0 && + logicalX <= INV_PANEL_ORIGIN.x + INV_GOLD_BTN_HIT.relX1 && + logicalY >= INV_PANEL_ORIGIN.y + INV_GOLD_BTN_HIT.relY0 && + logicalY <= INV_PANEL_ORIGIN.y + INV_GOLD_BTN_HIT.relY1 + ) { + if (this.gold > 0) callbacks?.onGoldButton?.() return true } diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index 371a092..4f479a2 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -40,74 +40,10 @@ import { import type { Item } from '../../common/items/items.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import { D2Rng } from '../../common/rng/d2-rng.ts' -import { getTblLang, type TblLang } from '../i18n/lang.ts' +import { getTblLang, requireTbl, type TblLang } from '../i18n/lang.ts' +import { STASH_GOLD_CAP } from '../../common/items/inventory-model.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' -let _lastVendorReason: string | undefined = undefined -let _lastVendorBoughtCount = 0 -let _lastVendorTotalCost = 0 -let _lastVendorGoldReceived = 0 - -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'ok')) { - Object.defineProperty(Boolean.prototype, 'ok', { - get() { - return this.valueOf() - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'reason')) { - Object.defineProperty(Boolean.prototype, 'reason', { - get() { - return _lastVendorReason - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'boughtCount')) { - Object.defineProperty(Boolean.prototype, 'boughtCount', { - get() { - return _lastVendorBoughtCount - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'totalCost')) { - Object.defineProperty(Boolean.prototype, 'totalCost', { - get() { - return _lastVendorTotalCost - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'goldReceived')) { - Object.defineProperty(Boolean.prototype, 'goldReceived', { - get() { - return _lastVendorGoldReceived - }, - configurable: true, - }) -} - -function makeVendorBuyResult( - ok: boolean, - boughtCount: number, - totalCost: number, - reason?: string, -): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } & boolean { - _lastVendorReason = reason - _lastVendorBoughtCount = boughtCount - _lastVendorTotalCost = totalCost - return ok as any -} - -function makeVendorSellResult( - ok: boolean, - goldReceived: number, -): { ok: boolean; goldReceived: number } & boolean { - _lastVendorGoldReceived = goldReceived - return ok as any -} import { type TownNpcServiceDescriptor, type VendorNpcId, @@ -117,9 +53,6 @@ import { getLocalizedNpcGreetings, } from '../../common/units/npc-table.ts' import type { VendorTabId } from '../../common/items/vendor-generator.ts' -import { VendorSessionManager } from '../../common/items/vendor-service.ts' -import { getEmbeddedDropTables } from '../../common/items/embedded-drop-tables.ts' -import { isItemRepairable } from '../../common/items/item-cost.ts' import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../../common/data/canonical-tbl-strings.ts' import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../../common/units/monsters-data.ts' import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts' @@ -129,6 +62,7 @@ import { } from '../../common/items/runeword-socket.ts' export { + STASH_GOLD_CAP, GRID_ITEM_BG_BLUE, GRID_ITEM_BG_GREEN, GRID_ITEM_BG_RED, @@ -141,8 +75,10 @@ export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const -export const STASH_GOLD_CAP = 2_500_000 -export const PLAYER_GOLD_CAP = 2_500_000 +/** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */ +export const STASH_GOLD_BTN_HIT = { relX0: 73, relX1: 150, relY0: 24, relY1: 41 } as const + +/** Packed canonical tbl lookup that fails fast when the key was not packed. */ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const /** @@ -376,6 +312,12 @@ export const VENDOR_TABS: readonly { { id: 'misc', index: 3, xOffset: 240, width: 79, height: 31, centerX: 281, textY: 20, strIdx: 4039, labelZh: '其他', labelEn: 'Misc' }, ] as const +/** + * MonStats classes whose NPC menu offers "gamble" in 1.13c: Gheed (147), Elzix (199), Alkor (254), + * Jamella (405), Anya (512), Nihlathak (514); matches `TownNpcServiceDescriptor.canGamble`. + */ +export const GAMBLE_NPC_CLASS_IDS: ReadonlySet = new Set([147, 199, 254, 405, 512, 514]) + export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' export interface NpcMenuOption { @@ -710,23 +652,15 @@ export class WorldPanelsHud { lang: TblLang = getTblLang() selectedActTab = 1 selectedQuestIdx = 0 - /** Server stat 15 (`goldbank`); 0 until received. */ - private _stashGold = 0 - private _stashGoldExplicitlySet = false - get stashGold(): number { - return this._stashGold - } - set stashGold(val: number) { - this._stashGold = Math.max(0, Math.floor(Number.isFinite(val) ? val : 0)) - this._stashGoldExplicitlySet = true - } + /** Server stat 15 (`goldbank`), written by `HudModel` only; 0 until received (absent stat = 0). */ + stashGold = 0 private _starterStashCleared = false - private readonly vendorRng = new D2Rng(0x56454e44) - private vendorBuySeq = 0 cubeTransmuteCount = 0 escapeMenuOpen = false lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null npcMenu: { + /** Server unit id of the NPC (from `NpcInit`); `null` for menus opened without a server unit. */ + npcId: number | null npcName: string x: number y: number @@ -737,14 +671,6 @@ export class WorldPanelsHud { hoveredOptionIdx?: number | null | undefined } | null = null - private _vendorSession: VendorSessionManager | null = null - get vendorSession(): VendorSessionManager { - if (!this._vendorSession) { - this._vendorSession = new VendorSessionManager(getEmbeddedDropTables()) - } - return this._vendorSession - } - private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void { if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) { this._starterStashCleared = true @@ -755,7 +681,13 @@ export class WorldPanelsHud { activeVendorDescriptor: TownNpcServiceDescriptor | null = null vendorMode: 'trade' | 'gamble' = 'trade' activeVendorTab: VendorTabId = 'armor' - vendorDifficulty: 0 | 1 | 2 = 0 + /** Server unit id of the NPC whose store is open (`ClientWorld.npcInteraction.npcId`); `null` when closed. */ + vendorNpcId: number | null = null + /** + * "Repair all equipment: %d" total (patchstring 10095), computed by `HudModel` from its injected + * `VendorRepairAllFn`; `null` when no repair-capable store is open. + */ + vendorRepairAllCost: number | null = null /** Synced from the server character level by `HudManager.syncPlayerContext()`. */ vendorCharLevel = 0 vendorPlayerStats: PlayerItemRequirementContext = { @@ -980,11 +912,13 @@ export class WorldPanelsHud { descriptor: TownNpcServiceDescriptor, screenX = 400, screenY = 220, + npcId: number | null = null, ): void { const options = this.buildNpcMenuOptions(descriptor) // D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open. // Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580). this.npcMenu = { + npcId, npcName: descriptor.displayName, x: Math.round(screenX), y: Math.round(screenY), @@ -1003,68 +937,80 @@ export class WorldPanelsHud { return true } - openVendorForNpc( - descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, - mode: 'trade' | 'gamble' = 'trade', - cLvl = 85, - difficulty: 0 | 1 | 2 | 'normal' | 'nightmare' | 'hell' = 0, - ): boolean { - const descriptor = - typeof descriptorOrName === 'object' - ? descriptorOrName - : typeof descriptorOrName === 'number' - ? getTownNpcDescriptorByVendorId(descriptorOrName) - : getTownNpcDescriptorByName(descriptorOrName) - if (!descriptor || descriptor.vendorId === null) return false + /** + * Show the store the server filled with `0x9C AddToShop` items (`ClientWorld.containers.shop`). + * `pages`: one entry per shop item with its bitstream `storePage` (1..4 -> tab index 0..3, D2Client + * 0x6fb91178 tab table). Re-applying the same store keeps the selected tab unless it became empty + * (D2Client 0x6fb3f710 advances to the next non-empty tab). + */ + setVendorShop(p: { + readonly npcId: number + readonly descriptor: TownNpcServiceDescriptor + readonly mode: 'trade' | 'gamble' + readonly items: readonly { readonly storePage: number; readonly placement: GridPlacement }[] + }): void { + // Fail fast against the D2Client.dll 0x6fb3f430 button layout table. + resolveVendorButtonLayout(p.descriptor.hcIdx, p.mode) + const sameStore = + this.vendorNpcId === p.npcId && + this.vendorMode === p.mode && + this.activeVendorDescriptor?.hcIdx === p.descriptor.hcIdx + if (!sameStore) { + this.vendorTradeState = 'idle' + this.hoveredVendorButtonSlot = null + this.pressedMomentarySlot = null + this.hoveredVendorItem = null + } + this.activeVendorDescriptor = p.descriptor + this.vendorNpcId = p.npcId + this.vendorMode = p.mode + const tabs: Record = { armor: [], weapons1: [], weapons2: [], misc: [] } + const gamble: GridPlacement[] = [] + for (const entry of p.items) { + if (p.mode === 'gamble') { + gamble.push(entry.placement) + continue + } + const tab = VENDOR_TABS[entry.storePage - 1] + if (!tab) throw new Error(`setVendorShop: shop item store page ${entry.storePage} is outside 1..4`) + tabs[tab.id].push(entry.placement) + } + this.vendorTabPlacements = tabs + this.gamblePlacements = gamble + if (p.mode === 'gamble') { + this.activeVendorTab = 'armor' + } else if (!sameStore) { + // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 and cycle to the first non-empty tab. + this.selectVendorTab('armor') + } else if (this.vendorTabPlacements[this.activeVendorTab].length === 0) { + this.selectVendorTab(this.activeVendorTab) + } + if (this.hoveredVendorItem && !this.getActiveVendorPlacements().some(pl => pl.item.id === this.hoveredVendorItem!.item.id)) { + this.hoveredVendorItem = null + } + } - const diffNum: 0 | 1 | 2 = - difficulty === 'hell' || difficulty === 2 - ? 2 - : difficulty === 'nightmare' || difficulty === 1 - ? 1 - : 0 - - // Fail-fast validation against D2Client.dll 0x6fb3f430 button layout table - resolveVendorButtonLayout(descriptor.hcIdx, mode) - - this.activeVendorDescriptor = descriptor - this.vendorMode = mode - this.vendorCharLevel = Math.max(1, cLvl) - this.vendorDifficulty = diffNum + /** Forget the store (NpcCancel sent: D2Client frees the store units, the server sends no RemoveFromShop). */ + clearVendorShop(): void { + this.activeVendorDescriptor = null + this.vendorNpcId = null + this.vendorMode = 'trade' this.vendorTradeState = 'idle' + this.vendorRepairAllCost = null this.hoveredVendorButtonSlot = null this.pressedMomentarySlot = null this.hoveredVendorItem = null + this.vendorTabPlacements = { armor: [], weapons1: [], weapons2: [], misc: [] } + this.gamblePlacements = [] + this.activeVendorTab = 'armor' + } - const tables = getEmbeddedDropTables() - const vendorId = descriptor.vendorId - - if (mode === 'gamble') { - const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel) - this.gamblePlacements = gambleInv.getAllItems().map(p => { - const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, true) - const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables) - return { item: uiItem, col: p.x, row: p.y } - }) - this.activeVendorTab = 'armor' - return true + /** Repair-all total set by `HudModel`; throws when the store has a Repair All button but no cost was provided. */ + requireRepairAllCost(): number { + if (this.vendorRepairAllCost === null) { + throw new Error('WorldPanelsHud: Repair All cost not provided (HudModel needs a VendorRepairAllFn)') } - - const store = this.vendorSession.getOrCreateVendorStore(vendorId, diffNum, this.vendorCharLevel) - const tabIds: VendorTabId[] = ['armor', 'weapons1', 'weapons2', 'misc'] - for (const tabId of tabIds) { - const inv = store.tabs[tabId] - this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => { - const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, false) - const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, diffNum, this.vendorCharLevel) - const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables) - return { item: uiItem, col: p.x, row: p.y } - }) - } - - // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 ('armor') and cycle to first non-empty tab - this.selectVendorTab('armor') - return true + return this.vendorRepairAllCost } getActiveVendorPlacements(): GridPlacement[] { @@ -1074,376 +1020,6 @@ export class WorldPanelsHud { return this.vendorTabPlacements[this.activeVendorTab] ?? [] } - getPlayerItemSellPrice(item: UiInventoryItem): number { - if (item.sellCost !== undefined) return item.sellCost - const vendorId = this.activeVendorDescriptor?.vendorId ?? (0 as VendorNpcId) - if (item.rawItem) { - return this.vendorSession.getSellPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel) - } - if ((item as any).baseCost !== undefined && Number.isFinite((item as any).baseCost)) { - return Math.max(1, Math.floor((item as any).baseCost / 2)) - } - return Math.max(1, Math.floor(150)) - } - - getPlayerItemRepairPrice(item: UiInventoryItem): number { - if (!item.durability || item.durability.current >= item.durability.max) return 0 - if (item.ethereal) return 0 - const vendorId = this.activeVendorDescriptor?.vendorId ?? (2 as VendorNpcId) - if (item.rawItem) { - const syncedRaw = { - ...item.rawItem, - durability: item.durability.current, - maxDurability: item.durability.max, - } - if (!isItemRepairable(syncedRaw)) return 0 - return this.vendorSession.getRepairPrice(syncedRaw, vendorId, this.vendorDifficulty, this.vendorCharLevel) - } - const lost = Math.max(0, item.durability.max - item.durability.current) - return Math.max(1, lost * 12) - } - - buyFromActiveVendor( - col: number, - row: number, - playerInventory: InventoryPanel, - isShiftClick = false, - ): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } { - if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { - return makeVendorBuyResult(false, 0, 0, 'no_vendor') - } - const vendorId = this.activeVendorDescriptor.vendorId - const placements = this.getActiveVendorPlacements() - const hitIdx = placements.findIndex( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hitIdx === -1) { - return makeVendorBuyResult(false, 0, 0, 'empty_cell') - } - const hit = placements[hitIdx]! - const unitCost = hit.item.buyCost ?? 1 - - if (playerInventory.gold < unitCost) { - this.showVendorStatus(this.lang === 'ENG' ? 'Not enough gold' : '金币不足', 'red') - return makeVendorBuyResult(false, 0, 0, 'not_enough_gold') - } - - const tables = getEmbeddedDropTables() - - // Gamble Mode: resolve mystery item into concrete rolled item! - if (this.vendorMode === 'gamble') { - const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) - if (!freeSlot) { - this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red') - return makeVendorBuyResult(false, 0, 0, 'inventory_full') - } - const gambleRes = this.vendorSession.buyGambleItem( - vendorId, - hit.col, - hit.row, - playerInventory.gold, - this.vendorCharLevel, - ) - if (!gambleRes.success || !gambleRes.item) { - this.showVendorStatus(this.lang === 'ENG' ? 'Cannot gamble this item' : '无法赌博该物品', 'red') - return makeVendorBuyResult(false, 0, 0, gambleRes.error ?? 'gamble_failed') - } - playerInventory.gold = gambleRes.remainingGold - const sellCost = this.vendorSession.getSellPrice(gambleRes.item, vendorId, this.vendorDifficulty, this.vendorCharLevel) - const resolvedUi = itemToUiInventoryItem({ ...(gambleRes.item as any), sellCost }, tables) - playerInventory.gridItems.push({ item: resolvedUi, col: freeSlot.col, row: freeSlot.row }) - this.gamblePlacements.splice(hitIdx, 1) - this.showVendorStatus( - this.lang === 'ENG' ? `Gambled: ${resolvedUi.name}` : `赌博获得: ${resolvedUi.nameZh}`, - 'gold', - ) - return makeVendorBuyResult(true, 1, gambleRes.goldSpent) - } - - // Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys - const isScrollOrPotionOrKey = - hit.item.permStore && - (hit.item.code === 'tsc' || - hit.item.code === 'isc' || - hit.item.code === 'key' || - hit.item.code === 'yps' || - hit.item.code === 'vps' || - hit.item.code === 'wms' || - hit.item.code.startsWith('hp') || - hit.item.code.startsWith('mp')) - - const maxCopies = isShiftClick && isScrollOrPotionOrKey ? 8 : 1 - let boughtCount = 0 - let totalCost = 0 - - for (let i = 0; i < maxCopies; i++) { - if (playerInventory.gold < unitCost) break - const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) - if (!freeSlot) { - if (boughtCount === 0) { - this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red') - return makeVendorBuyResult(false, 0, 0, 'inventory_full') - } - break - } - playerInventory.gold -= unitCost - totalCost += unitCost - boughtCount++ - - const rawClone = hit.item.rawItem - ? { ...hit.item.rawItem, uniqueId: this.vendorRng.randRange(1, 0x7fffffff), permStore: false } - : undefined - const sellCost = rawClone - ? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel) - : Math.max(1, Math.floor(unitCost / 2)) - - const clonedUiItem: UiInventoryItem = { - ...hit.item, - id: `item-${hit.item.code}-v${++this.vendorBuySeq}-${this.vendorRng.randRange(10000, 99999)}`, - ...(rawClone ? { rawItem: rawClone } : {}), - sellCost, - } - delete (clonedUiItem as any).buyCost - delete (clonedUiItem as any).permStore - - playerInventory.gridItems.push({ item: clonedUiItem, col: freeSlot.col, row: freeSlot.row }) - - if (!hit.item.permStore) { - placements.splice(hitIdx, 1) - if (this.vendorMode === 'trade' && placements.length === 0) { - this.selectVendorTab(this.activeVendorTab) - } - break - } - } - - if (boughtCount > 0) { - this.showVendorStatus( - this.lang === 'ENG' - ? `Bought ${hit.item.name}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} Gold)` - : `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`, - 'gold', - ) - return makeVendorBuyResult(true, boughtCount, totalCost) - } - return makeVendorBuyResult(false, 0, 0, 'purchase_failed') - } - - sellToActiveVendor( - rawItem: UiInventoryItem | Item, - playerInventory: InventoryPanel, - ): { ok: boolean; goldReceived: number } { - const item = rawItem as UiInventoryItem - if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { - return makeVendorSellResult(false, 0) - } - const vendorId = this.activeVendorDescriptor.vendorId - // Quest items cannot be sold - if (item.rawItem?.base?.quest && item.rawItem.base.quest > 0) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售', - 'red', - ) - return makeVendorSellResult(false, 0) - } - - const sellPrice = this.getPlayerItemSellPrice(item) - playerInventory.gold = Math.min( - getInventoryGoldLimit(playerInventory.playerLevel), - playerInventory.gold + sellPrice, - ) - - const invIdx = playerInventory.gridItems.findIndex( - p => p.item === item || (item.id !== undefined && p.item.id === item.id), - ) - if (invIdx !== -1) { - playerInventory.gridItems.splice(invIdx, 1) - } - - // Place into vendor store tab for buyback (unless in gamble mode or item is permStore) - if (this.vendorMode === 'trade' && !item.permStore) { - const allowed = Array.isArray(item.allowedSlots) ? item.allowedSlots : [] - const kind = - item.rawItem?.base?.kind ?? - (item as any).type ?? - (allowed.includes('weapon1') ? 'weapon' : allowed.length > 0 ? 'armor' : 'misc') - let targetTab: VendorTabId = kind === 'armor' ? 'armor' : kind === 'weapon' ? 'weapons1' : 'misc' - let tabPlacements = this.vendorTabPlacements[targetTab] - let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) - if (!freeSlot && targetTab === 'weapons1') { - targetTab = 'weapons2' - tabPlacements = this.vendorTabPlacements[targetTab] - freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) - } - while (!freeSlot) { - const evictIdx = tabPlacements.findIndex(p => !p.item.permStore) - if (evictIdx === -1) break - tabPlacements.splice(evictIdx, 1) - freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) - } - if (freeSlot) { - const buybackPrice = item.rawItem - ? this.vendorSession.getBuyPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel, false) - : Math.max(sellPrice * 2, 2) - const buybackUi: UiInventoryItem = { - ...item, - buyCost: buybackPrice, - sellCost: sellPrice, - permStore: false, - } - tabPlacements.push({ item: buybackUi, col: freeSlot.col, row: freeSlot.row }) - if (this.vendorTabPlacements[this.activeVendorTab].length === 0) { - this.selectVendorTab(targetTab) - } - } - } - - this.showVendorStatus( - this.lang === 'ENG' - ? `Sold ${item.name} (+${sellPrice} Gold)` - : `已出售 ${item.nameZh} (+${sellPrice} 金币)`, - 'gold', - ) - return makeVendorSellResult(true, sellPrice) - } - - repairSingleUiItem( - item: UiInventoryItem, - playerInventory: InventoryPanel, - ): { ok: boolean; cost: number } { - if (!this.activeVendorDescriptor?.canRepair) { - return { ok: false, cost: 0 } - } - if (!item.durability || item.durability.current >= item.durability.max) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Item does not need repair' : '该物品无需修理', - 'tan', - ) - return { ok: false, cost: 0 } - } - if (item.ethereal) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Cannot repair ethereal item' : '无形物品无法修理', - 'red', - ) - return { ok: false, cost: 0 } - } - const cost = this.getPlayerItemRepairPrice(item) - if (cost <= 0) return { ok: false, cost: 0 } - if (playerInventory.gold < cost) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Not enough gold to repair' : '金币不足,无法修理', - 'red', - ) - return { ok: false, cost: 0 } - } - playerInventory.gold -= cost - ;(item as { durability: { current: number; max: number } }).durability = { - current: item.durability.max, - max: item.durability.max, - } - if (item.rawItem) { - item.rawItem.durability = item.durability.max - } - this.showVendorStatus( - this.lang === 'ENG' - ? `Repaired ${item.name} (-${cost} Gold)` - : `已修理 ${item.nameZh} (-${cost} 金币)`, - 'gold', - ) - return { ok: true, cost } - } - - private collectRepairablePlayerItems(playerInventory: InventoryPanel): { item: UiInventoryItem; cost: number }[] { - const allItems: UiInventoryItem[] = [ - ...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]), - ...playerInventory.gridItems.map(p => p.item), - ] - const result: { item: UiInventoryItem; cost: number }[] = [] - for (const item of allItems) { - if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue - const cost = this.getPlayerItemRepairPrice(item) - if (cost <= 0) continue - result.push({ item, cost }) - } - return result - } - - /** - * D2Client.dll `0x6fb3f260` -> D2Common `0x6fabc99a` (`#10095`): - * Computes total repair cost across all damaged repairable equipment and inventory items. - */ - getRepairAllCost(playerInventory?: InventoryPanel | null): number { - if (!this.activeVendorDescriptor?.canRepair) return 0 - const inv = playerInventory ?? this.activePlayerInventory - if (!inv) return 0 - let total = 0 - for (const entry of this.collectRepairablePlayerItems(inv)) { - total += entry.cost - } - return total - } - - repairAllUiItems(playerInventory: InventoryPanel): { repairedCount: number; totalCost: number } { - if (!this.activeVendorDescriptor?.canRepair) { - return { repairedCount: 0, totalCost: 0 } - } - let repairedCount = 0 - let totalCost = 0 - for (const { item, cost } of this.collectRepairablePlayerItems(playerInventory)) { - if ( !item.durability) continue - if (playerInventory.gold < cost) break - const maxDur = item.durability.max - playerInventory.gold -= cost - totalCost += cost - repairedCount++ - ;(item as { durability: { current: number; max: number } }).durability = { - current: maxDur, - max: maxDur, - } - if (item.rawItem) { - item.rawItem.durability = maxDur - } - } - if (repairedCount > 0) { - this.showVendorStatus( - this.lang === 'ENG' - ? `Repaired ${repairedCount} items (-${totalCost} Gold)` - : `已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`, - 'gold', - ) - } else { - this.showVendorStatus( - this.lang === 'ENG' ? 'All equipment is at full durability' : '所有装备完好,无需修理', - 'tan', - ) - } - return { repairedCount, totalCost } - } - - identifyAllUiItems(playerInventory: InventoryPanel): number { - let count = 0 - for (const placed of playerInventory.gridItems) { - if (isUiItemUnidentified(placed.item)) { - identifyUiItem(placed.item) - count++ - } - } - for (const placed of this.stashItems) { - if (isUiItemUnidentified(placed.item)) { - identifyUiItem(placed.item) - count++ - } - } - for (const placed of this.cubeItems) { - if (isUiItemUnidentified(placed.item)) { - identifyUiItem(placed.item) - count++ - } - } - return count - } - /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */ private questStatuses = new Map() private cachedQuestsAtlasImg: HTMLImageElement | null = null @@ -1962,36 +1538,6 @@ export class WorldPanelsHud { return true } - depositGold(amount: number, playerInventory: { gold: number }): number { - if (amount <= 0) return 0 - if (!this._stashGoldExplicitlySet) { - this._stashGold = 0 - this._stashGoldExplicitlySet = true - } - const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this._stashGold)) - if (available <= 0) return 0 - playerInventory.gold -= available - this._stashGold += available - return available - } - - withdrawGold( - amount: number, - playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean }, - ): number { - if (amount <= 0) return 0 - const cap = - playerInventory._playerLevelExplicitlySet && - playerInventory.maxGold !== undefined - ? playerInventory.maxGold - : PLAYER_GOLD_CAP - const available = Math.max(0, Math.min(amount, this._stashGold, cap - playerInventory.gold)) - if (available <= 0) return 0 - this._stashGold -= available - playerInventory.gold += available - return available - } - handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredStashItem = null this.hoveredCubeItem = null @@ -2140,6 +1686,8 @@ export class WorldPanelsHud { callbacks: { onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void + /** Menu dismissed (`cancel` option or click outside): the caller sends `NpcCancel` (0x30). */ + onCancel: () => void }, font?: Pick | null, ): boolean { @@ -2165,6 +1713,7 @@ export class WorldPanelsHud { logicalY > layout.top + layout.height ) { this.npcMenu = null + callbacks.onCancel() return true } @@ -2186,6 +1735,7 @@ export class WorldPanelsHud { callbacks.onIdentifyAll(desc) } else { this.npcMenu = null + callbacks.onCancel() } return true } @@ -2219,6 +1769,14 @@ export class WorldPanelsHud { hitItem: UiInventoryItem | null, isRightClick: boolean, ) => void + /** Stash gold coin button: open the withdraw gold dialog. */ + onStashGoldButton?: () => void + /** Vendor grid click on a store item (buy / gamble). `shiftFill`: Shift held (0x32 shift-fill flag). */ + onVendorBuy?: (item: UiInventoryItem, shiftFill: boolean) => void + /** Vendor grid click while holding a cursor item: sell it (0x33). */ + onVendorSell?: (item: UiInventoryItem) => void + /** Repair All button (0x35 with field4 = 0x80000000). */ + onVendorRepairAll?: () => void }, ): boolean { if (logicalX > 400) return false @@ -2358,6 +1916,19 @@ export class WorldPanelsHud { return true } } else if (kind === 'stash') { + // Stash gold coin button (panel-relative x 73..150, y 24..41): opens the withdraw dialog. + if ( + logicalX >= ox + STASH_GOLD_BTN_HIT.relX0 && + logicalX <= ox + STASH_GOLD_BTN_HIT.relX1 && + logicalY >= oy + STASH_GOLD_BTN_HIT.relY0 && + logicalY <= oy + STASH_GOLD_BTN_HIT.relY1 + ) { + if (!callbacks.isRightClick && this.stashGold > 0) { + if (!callbacks.onStashGoldButton) throw new Error('handleLeftDockClick: stash gold button needs onStashGoldButton') + callbacks.onStashGoldButton() + } + return true + } // Stash 6x8 Grid if ( logicalX >= STASH_GRID_ORIGIN.x && @@ -2453,10 +2024,8 @@ export class WorldPanelsHud { } else if (btnKind === 'repairAll') { this.vendorTradeState = 'idle' this.pressedMomentarySlot = hitSlot - const inv = callbacks.inventory ?? this.activePlayerInventory - if (inv) { - this.repairAllUiItems(inv) - } + if (!callbacks.onVendorRepairAll) throw new Error('handleLeftDockClick: Repair All needs onVendorRepairAll') + callbacks.onVendorRepairAll() } else if (btnKind === 'close') { this.vendorTradeState = 'idle' this.pressedMomentarySlot = hitSlot @@ -2480,21 +2049,20 @@ export class WorldPanelsHud { const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) const inv = callbacks.inventory ?? this.activePlayerInventory - if (inv) { - // If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! - if (inv.cursorItem) { - const held = inv.cursorItem - const res = this.sellToActiveVendor(held, inv) - if (res.ok) { - inv.cursorItem = null - } - return true - } - - // Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state - if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { - this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick)) - this.handleMouseMove(logicalX, logicalY) + // Holding a cursor item over the store grid sells it (server answers with 0x2A + item/stat packets). + if (inv?.cursorItem) { + if (!callbacks.onVendorSell) throw new Error('handleLeftDockClick: vendor sell needs onVendorSell') + callbacks.onVendorSell(inv.cursorItem) + return true + } + // Buy works in 'idle' or 'buy' state; never buy while the sell / repair cursor is armed. + if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { + const hit = this.getActiveVendorPlacements().find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit) { + if (!callbacks.onVendorBuy) throw new Error('handleLeftDockClick: vendor buy needs onVendorBuy') + callbacks.onVendorBuy(hit.item, Boolean(callbacks.isShiftClick)) } } return true @@ -2652,14 +2220,14 @@ export class WorldPanelsHud { } } else if (kind === 'stash') { if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) - font.drawText(ctx, isEn ? 'Private Stash' : '私人储物箱', ox + 172, oy + 35, { + font.drawText(ctx, requireTbl('bank', this.lang), ox + 172, oy + 35, { font: 'font8', color: 'gold', align: 'center', }) font.drawText( ctx, - isEn ? `Stash Gold: ${this.stashGold.toLocaleString()}` : `储物箱金币: ${this.stashGold.toLocaleString()}`, + `${requireTbl('strGoldInStash', this.lang)} ${this.stashGold}`, ox + 160, oy + 61, { @@ -2918,9 +2486,7 @@ export class WorldPanelsHud { if (spec.enabled) { const hoverText = btnKind === 'repairAll' - ? isEn - ? `${spec.labelEn}: ${this.getRepairAllCost(assets.inventory)}` - : `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}` + ? requireTbl('patchstring#95', this.lang).replace('%d', String(this.requireRepairAllCost())) : isEn ? spec.labelEn : spec.labelZh @@ -3290,35 +2856,16 @@ export class WorldPanelsHud { } // Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) + // Prices come from HudModel's injected VendorPriceFn (buyCost / sellCost / repairCost on the UI item). if (item.buyCost !== undefined) { - const label = item.isGambleMystery - ? (isEn ? 'Gamble Cost' : '赌博价格') - : (isEn ? 'Buy Cost' : '购买价格') - const suffix = isEn ? ' Gold' : ' 金币' - lines.push({ text: `${label}: ${item.buyCost.toLocaleString()}${suffix}`, color: 'gold', font: 'font8' }) + lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font8' }) } else if (vendorOpen && this.activeVendorDescriptor) { if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { - const repPrice = this.getPlayerItemRepairPrice(item) - if (repPrice > 0) { - lines.push({ - text: isEn - ? `Repair Cost: ${repPrice.toLocaleString()} Gold` - : `修理费用: ${repPrice.toLocaleString()} 金币`, - color: 'gold', - font: 'font8', - }) - } - } else { - const sellPrice = this.getPlayerItemSellPrice(item) - if (sellPrice > 0) { - lines.push({ - text: isEn - ? `Sell Value: ${sellPrice.toLocaleString()} Gold` - : `出售价格: ${sellPrice.toLocaleString()} 金币`, - color: 'gold', - font: 'font8', - }) + if (item.repairCost !== undefined && item.repairCost > 0) { + lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font8' }) } + } else if (item.sellCost !== undefined) { + lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font8' }) } } diff --git a/src/common/data/canonical-tbl-strings.ts b/src/common/data/canonical-tbl-strings.ts index 6f76ef7..ae159f9 100644 --- a/src/common/data/canonical-tbl-strings.ts +++ b/src/common/data/canonical-tbl-strings.ts @@ -295,6 +295,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Baal Throne": "Baal", "BaalColdMage": "Death Mage", "Baals Minion": "Minion of Destruction", + "Back": "cancel", "Baezil's Vortex": "Baezil's Vortex", "Bahamut's": "Bahamut's", "Balrog": "Balrog", @@ -1779,6 +1780,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Realgar": "Realgar", "ReanimatedHorde": "Reanimated Horde", "Red": "Red", + "Repair": "Repair cost: ", "Resonant": "Resonant", "Returned": "Returned", "ReturnedArcher": "Returned Archer", @@ -2018,6 +2020,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Scintillating": "Scintillating", "Scorching": "Condensing", "Screaming": "Screaming", + "Sell": "Sell value: ", "Sensei's": "Sensei's", "Septic": "Septic", "Seraph's Hymn": "Seraph's Hymn", @@ -2647,6 +2650,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Trang-Oul's Guise": "Trang-Oul's Guise", "Trang-Oul's Scales": "Trang-Oul's Scales", "Trang-Oul's Wing": "Trang-Oul's Wing", + "TransactionResults10": "You do not have enough gold for that.", + "TransactionResults11": "She cannot come right now. Try again later.", + "TransactionResults4": "You do not have enough free space to do that.", + "TransactionResults5": "You already have the maximum number of Mercenaries.", + "TransactionResults6": "That item has just been traded.", + "TransactionResults8": "Something tells me that you do not have that item.", + "TransactionResults9": "I cannot complete that request.", "TrappedSoul": "Trapped Soul", "Travincal": "Travincal", "Treads of Cthon": "Treads of Cthon", @@ -2911,6 +2921,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "collar": "Collar", "compellingorb": "Compelling Orb", "cord": "Cord", + "cost": "Cost: ", "cowl": "Cowl", "cqv": "Bolts", "crack": "Crack", @@ -3471,6 +3482,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "of the Wolf": "of the Wolf", "of the Wraith": "of the Wraith", "of the Yeti": "of the Yeti", + "ok": "ok", "opl": "Fulminating Potion", "opm": "Exploding Potion", "ops": "Oil Potion", @@ -3782,7 +3794,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "strBSArmor": "Armor", "strBSMisc": "Misc", "strBSWeapons": "Weapons", + "strBankGoldDeposit": "How much Gold would you like to deposit?", + "strBankGoldWithdraw": "How much Gold would you like to withdraw?", "strClose": "Close", + "strDropGoldHowMuch": "How much Gold would you like to drop?", "strGoldInStash": "Gold in Stash:", "strGoldLabel": "Gold", "strModAllResistances": "All Resistances +%d", @@ -4000,7 +4015,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "uniquestoneskin": "Stone Skin", "uniquemultishot": "Multiple Shots", "UniqueAuraEnchanted": "Aura Enchanted", - "strethereal": "Ethereal (Cannot be Repaired)" + "strethereal": "Ethereal (Cannot be Repaired)", + "patchstring#95": "Repair all equipment: %d" } export const CANONICAL_CHI_TBL_DICT: Readonly> = { @@ -4261,6 +4277,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Baal Throne": "巴尔", "BaalColdMage": "死亡法师", "Baals Minion": "毁灭的仆从", + "Back": "取消", "Baezil's Vortex": "贝西尔的漩涡", "Balrog": "巴羅格", "Bane": "剋星", @@ -5611,6 +5628,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Razortine": "剃刀之叉", "ReanimatedHorde": " ", "Red": "紅之", + "Repair": "修復費用: ", "Returned": "復活的怪物", "ReturnedArcher": "復生的弓箭手", "ReturnedMage": "復生的法師", @@ -5682,6 +5700,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Sazabi's Mental Sheath": "沙撒璧的精神之鞘", "Scarab": "聖甲蟲", "Schaefer's Hammer": "史恰佛之锤", + "Sell": "出售價格: ", "Seraph's Hymn": "炽天使之韵", "Serpent Lord": "海蛇之王", "Serpent's": "海蛇之", @@ -6249,6 +6268,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Trang-Oul's Guise": "塔格奥之外装", "Trang-Oul's Scales": "塔格奥之鳞", "Trang-Oul's Wing": "塔格奥之翼", + "TransactionResults10": "你沒有足夠的金錢。", + "TransactionResults11": "她現在無法過來,請等一會再試。", + "TransactionResults4": "你沒有足夠的放置空間。", + "TransactionResults5": "你的傭兵數量已經到達最高人數。", + "TransactionResults6": "這項物品剛剛交易掉了。", + "TransactionResults8": "有些事指出你並非擁有該項物品。", + "TransactionResults9": "我無法完成那個要求。", "TrappedSoul": "被困的靈魂", "Travincal": "崔凡克", "Treads of Cthon": "凱松的征服", @@ -6495,6 +6521,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "collar": "項圈", "compellingorb": "強制球體", "cord": "捆綁", + "cost": "花費:", "cowl": "蒙頭斗蓬", "cqv": "十字弓彈", "crack": "破裂", @@ -6858,6 +6885,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "of the Vampire": "吸血鬼的", "of the Wolf": "狼的", "of the Wraith": "死靈", + "ok": "好", "opl": "猛爆性葯劑", "opm": "爆炸葯劑", "ops": "油", @@ -7169,7 +7197,10 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "strBSArmor": "裝甲", "strBSMisc": "其他", "strBSWeapons": "武器", + "strBankGoldDeposit": "你打算存入多少數量的金幣?", + "strBankGoldWithdraw": "你打算提出多少數量的金幣?", "strClose": "關閉", + "strDropGoldHowMuch": "你想要丟下多少數量的金幣?", "strGoldInStash": "儲藏起來的金錢:", "strGoldLabel": "金幣", "strModAllResistances": "所有抗性 +%d", @@ -7387,7 +7418,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "uniquestoneskin": "皮膚硬化", "uniquemultishot": "多重射擊", "UniqueAuraEnchanted": "靈氣加強", - "strethereal": "无形的 (无法修复)" + "strethereal": "无形的 (无法修复)", + "patchstring#95": "修復所有裝備:%d" } export const CANONICAL_ISC_DESC_TABLE: Readonly> = { diff --git a/src/common/items/inventory-model.ts b/src/common/items/inventory-model.ts index 53d67be..d108adf 100644 --- a/src/common/items/inventory-model.ts +++ b/src/common/items/inventory-model.ts @@ -13,9 +13,10 @@ export const INV_COLS = 10 export const INV_ROWS = 4 /** - * Authoritative Diablo II v1.13c Shared Stash Gold Cap (`D2Common.dll` max gold in stash = 2,500,000). + * Diablo II v1.13c stash gold cap (`D2Common.dll` ordinal #11060 returns 2,500,000). + * The character inventory has no fixed cap; see {@link getInventoryGoldLimit}. */ -export const PLAYER_GOLD_CAP = 2_500_000 +export const STASH_GOLD_CAP = 2_500_000 /** * Authentic Diablo II 1.13c Inventory Gold Limit (`D2Common/src/Units/Units.cpp:3103`). diff --git a/tests/challenger-m5-stash-entities-gold.test.ts b/tests/challenger-m5-stash-entities-gold.test.ts index f8b7d19..64771e7 100644 --- a/tests/challenger-m5-stash-entities-gold.test.ts +++ b/tests/challenger-m5-stash-entities-gold.test.ts @@ -3,7 +3,6 @@ import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/worl import { WorldPanelsHud, STASH_GOLD_CAP, - PLAYER_GOLD_CAP, } from '../src/client/ui/world-panels.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' import { GameEngine } from '../src/server/engine/engine.ts' @@ -130,181 +129,12 @@ describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction panels.stashGold = 100_000 }) - it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => { - const initialTotal = inv.gold + panels.stashGold // 600,000 - - const transferSequence = [10_000, 50_000, 100_000, 25_000, 1_000, 777] - for (const amt of transferSequence) { - const deposited = panels.depositGold(amt, inv) - expect(deposited).toBe(amt) - expect(inv.gold + panels.stashGold).toBe(initialTotal) - } - - const withdrawSequence = [5_000, 20_000, 80_000, 500, 123] - for (const amt of withdrawSequence) { - const withdrawn = panels.withdrawGold(amt, inv) - expect(withdrawn).toBe(amt) - expect(inv.gold + panels.stashGold).toBe(initialTotal) - } - }) - - it('stress tests 100 random valid transactions preserving gold conservation law', () => { - const initialTotal = inv.gold + panels.stashGold // 600,000 - let seed = 123456789 - const nextRand = () => { - seed = (seed * 1664525 + 1013904223) >>> 0 - return seed / 4294967296 - } - - for (let i = 0; i < 100; i++) { - if (nextRand() > 0.5) { - // Deposit random amount up to player's current gold - const amt = Math.floor(nextRand() * (inv.gold + 1)) - panels.depositGold(amt, inv) - } else { - // Withdraw random amount up to stash's current gold - const amt = Math.floor(nextRand() * (panels.stashGold + 1)) - panels.withdrawGold(amt, inv) - } - // Strict conservation invariant: - expect(inv.gold + panels.stashGold).toBe(initialTotal) - expect(inv.gold).toBeGreaterThanOrEqual(0) - expect(panels.stashGold).toBeGreaterThanOrEqual(0) - } - }) - - it('negative values (e.g. -500 gold): must be rejected or no-op without balance mutation', () => { - const initialInv = inv.gold - const initialStash = panels.stashGold - - const negativeAmounts = [-1, -500, -10_000, -2_500_000, -1e9, Number.MIN_SAFE_INTEGER] - for (const neg of negativeAmounts) { - expect(panels.depositGold(neg, inv)).toBe(0) - expect(inv.gold).toBe(initialInv) - expect(panels.stashGold).toBe(initialStash) - - expect(panels.withdrawGold(neg, inv)).toBe(0) - expect(inv.gold).toBe(initialInv) - expect(panels.stashGold).toBe(initialStash) - } - }) - - it('zero and -0 value transfers: must be rejected as no-op', () => { - expect(panels.depositGold(0, inv)).toBe(0) - expect(panels.depositGold(-0, inv)).toBe(0) - expect(panels.withdrawGold(0, inv)).toBe(0) - expect(panels.withdrawGold(-0, inv)).toBe(0) - expect(inv.gold).toBe(500_000) - expect(panels.stashGold).toBe(100_000) - }) - - it('exceeding player gold on deposit: deposits max available without creating negative player gold', () => { - inv.gold = 35_000 - panels.stashGold = 50_000 - - const deposited = panels.depositGold(100_000, inv) - expect(deposited).toBe(35_000) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(85_000) - - // Further deposit when player gold is 0 yields 0 - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(85_000) - }) - - it('exceeding stash gold on withdraw: withdraws max available without creating negative stash gold', () => { - panels.stashGold = 42_000 - inv.gold = 10_000 - - const withdrawn = panels.withdrawGold(100_000, inv) - expect(withdrawn).toBe(42_000) - expect(panels.stashGold).toBe(0) - expect(inv.gold).toBe(52_000) - - // Further withdrawal when stash gold is 0 yields 0 - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(panels.stashGold).toBe(0) - expect(inv.gold).toBe(52_000) - }) - - it('gold cap enforcement: character and stash gold cannot exceed 2,500,000', () => { + it('stash gold cap is the D2Common #11060 constant (2,500,000); gold transfers are server-driven', () => { expect(STASH_GOLD_CAP).toBe(2_500_000) - expect(PLAYER_GOLD_CAP).toBe(2_500_000) - - // Stash cap clamping on deposit - panels.stashGold = 2_450_000 - inv.gold = 200_000 - const dep1 = panels.depositGold(100_000, inv) - expect(dep1).toBe(50_000) - expect(panels.stashGold).toBe(2_500_000) - expect(inv.gold).toBe(150_000) - - // Further deposits clamped to 0 - expect(panels.depositGold(50_000, inv)).toBe(0) - expect(panels.stashGold).toBe(2_500_000) - expect(inv.gold).toBe(150_000) - - // Player cap clamping on withdrawal - inv.gold = 2_480_000 - panels.stashGold = 500_000 - const w1 = panels.withdrawGold(100_000, inv) - expect(w1).toBe(20_000) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(480_000) - - // Further withdrawals clamped to 0 - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(480_000) - }) - - it('boundary caps: handles both character and stash at 2,500,000 or both at 0', () => { - // Both at zero - inv.gold = 0 - panels.stashGold = 0 - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(0) - - // Both at maximum 2,500,000 - inv.gold = 2_500_000 - panels.stashGold = 2_500_000 - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(2_500_000) - }) - - it('infinity and extreme numbers: safely clamps or rejects without corrupting finite state', () => { - inv.gold = 300_000 - panels.stashGold = 100_000 - - // Deposit Infinity: clamped to player's entire available gold - const depInf = panels.depositGold(Infinity, inv) - expect(depInf).toBe(300_000) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(400_000) - - // Withdraw Infinity: clamped to stash's entire available gold - const wInf = panels.withdrawGold(Infinity, inv) - expect(wInf).toBe(400_000) - expect(inv.gold).toBe(400_000) - expect(panels.stashGold).toBe(0) - - // Negative infinity: rejected as <= 0 - expect(panels.depositGold(-Infinity, inv)).toBe(0) - expect(panels.withdrawGold(-Infinity, inv)).toBe(0) - expect(inv.gold).toBe(400_000) - expect(panels.stashGold).toBe(0) - - // Number.MAX_SAFE_INTEGER - inv.gold = 50_000 - const depMaxSafe = panels.depositGold(Number.MAX_SAFE_INTEGER, inv) - expect(depMaxSafe).toBe(50_000) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(50_000) + // WorldPanelsHud no longer mutates gold locally: deposit/withdraw are sent as + // ClickButton commands by HudModel and the balance arrives via server stat updates. + expect((panels as unknown as Record)['depositGold']).toBeUndefined() + expect((panels as unknown as Record)['withdrawGold']).toBeUndefined() }) it('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => { diff --git a/tests/client/vendor-stash-ui.test.ts b/tests/client/vendor-stash-ui.test.ts new file mode 100644 index 0000000..aa82e57 --- /dev/null +++ b/tests/client/vendor-stash-ui.test.ts @@ -0,0 +1,357 @@ +/** + * Server-driven store / stash / gold UI (D2Client 1.13c): every click becomes a `ClientCommand`, + * nothing is bought, sold, repaired or moved locally. World state comes from the live captures + * 05 (Charsi store), 06 (sells + rejected buys) and 08 (stash) replayed into a fresh `ClientWorld`. + */ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { replayD2CapFile } from '../../tools/netproto-replay.ts' +import { ClickButtonId, type ClientCommand, type ServerEvent } from '../../src/netproto/index.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { D2StatId } from '../../src/client/world/stat-list.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { STASH_GOLD_BTN_HIT, VENDOR_BUTTON_TABLE_X, VENDOR_GRID_ORIGIN } from '../../src/client/ui/world-panels.ts' +import { requireTbl } from '../../src/client/i18n/lang.ts' +import { + HudModel, + NPC_SELL_SOUND_ID, + NPC_TRADE_THROTTLE_MS, + npcTransactionMessageKey, +} from '../../src/client/ui-model/hud-model.ts' +import type { VendorPriceFn, VendorRepairAllFn } from '../../src/client/ui-model/vendor-price.ts' + +const FIXTURES = path.resolve(__dirname, '../fixtures/netproto') +/** Charsi (MonStats hcIdx 154) is unit #6 in captures 05-07. */ +const CHARSI_ID = 6 +/** Akara (hcIdx 148) is unit #5 in capture 06 (Charsi #6 leaves the client view first). */ +const AKARA_ID = 5 +const MONSTER = 1 +const REPAIR_ALL_STUB = 777 +/** Left-dock panel origin (WorldPanelsHud ox / oy). */ +const STASH_X = 80 +const STASH_Y = 60 + +/** Display-only price stub (D2Game ignores the client cost): distinct per item and transaction type. */ +const stubPrice: VendorPriceFn = (item, type) => 1000 * (type + 1) + item.id +const stubRepairAll: VendorRepairAllFn = () => REPAIR_ALL_STUB + +function createSession() { + const world = new ClientWorld() + const hud = new HudManager() + const commands: ClientCommand[] = [] + const sounds: number[] = [] + const clock = { t: 50_000 } + const model = new HudModel({ + world, + hudManager: hud, + // OnlineSession.forwardCommandToAdapter notes every sent command on the world. + onCommand: (c) => { + commands.push(c) + world.noteOutgoingCommand(c) + }, + vendorPrice: stubPrice, + vendorRepairAll: stubRepairAll, + playUiSound: (id) => sounds.push(id), + now: () => clock.t, + }) + return { world, hud, model, commands, sounds, clock } +} + +function loadCapture(name: string): readonly ServerEvent[] { + const r = replayD2CapFile(path.join(FIXTURES, name), { strict: true }) + expect(r.unknownPackets).toBe(0) + return r.events +} + +const isShopItem = (e: ServerEvent): boolean => e.type === 'ItemEvent' && e.container === 'shop' + +/** Replay up to the store contents, click the NPC (C2S 0x13 NpcInit) and return the remaining events. */ +function walkUpToNpc(s: ReturnType, events: readonly ServerEvent[], npcId: number): ServerEvent[] { + const firstShop = events.findIndex(isShopItem) + expect(firstShop).toBeGreaterThan(0) + for (const e of events.slice(0, firstShop)) s.world.apply(e) + s.model.syncFromWorld() + s.world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: npcId } }) + s.model.syncFromWorld() + return events.slice(firstShop) +} + +function mouseDown(hud: HudManager, x: number, y: number, opts: { button?: number; shiftKey?: boolean } = {}): void { + hud.handleHudMouseDown({ + clientX: x, + clientY: y, + button: opts.button ?? 0, + shiftKey: opts.shiftKey ?? false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as MouseEvent) +} + +function clickNpcOption(hud: HudManager, optionId: string): void { + const menu = hud.worldPanels.npcMenu + if (!menu?.options) throw new Error('no NPC menu') + const idx = menu.options.findIndex((o) => o.id === optionId) + expect(idx).toBeGreaterThanOrEqual(0) + const layout = hud.worldPanels.getNpcMenuLayout(hud.font)! + mouseDown(hud, layout.left + Math.trunc(layout.width / 2), layout.optionYs[idx]! - 3) +} + +function openCharsiStore(capture: string, keep: (e: ServerEvent) => boolean = () => true) { + const s = createSession() + const rest = walkUpToNpc(s, loadCapture(capture), CHARSI_ID) + s.model.openNpcTrade(CHARSI_ID) + for (const e of rest) if (keep(e)) s.world.apply(e) + s.model.syncFromWorld() + return s +} + +describe('NPC menu -> server store', () => { + it('NpcInit opens the descriptor menu; "trade/repair" sends 0x38 and the 0x9C store fills the panel with display prices', () => { + const s = createSession() + const rest = walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID) + const menu = s.hud.worldPanels.npcMenu + expect(menu?.npcId).toBe(CHARSI_ID) + expect(menu?.options?.map((o) => o.id)).toContain('trade') + + clickNpcOption(s.hud, 'trade') + expect(s.commands).toEqual([{ type: 'NpcOpenTrade', npcId: CHARSI_ID }]) + expect(s.hud.leftPanel).toBe('vendor') + expect(s.world.npcInteraction.window).toBe('trade') + + for (const e of rest) s.world.apply(e) + s.model.syncFromWorld() + const wp = s.hud.worldPanels + const shown = Object.values(wp.vendorTabPlacements).flat() + expect(shown.length).toBe(s.world.containers.shop.size) + expect(shown.length).toBe(46) + for (const pl of shown) { + const raw = s.world.containers.shop.get(Number(pl.item.id))! + expect(pl.item.buyCost).toBe(stubPrice(raw, 0, { world: s.world, npcClass: 154 })) + expect(pl.col).toBe(raw.gridX) + expect(pl.row).toBe(raw.gridY) + } + expect(wp.vendorRepairAllCost).toBe(REPAIR_ALL_STUB) + // Player items carry the sell value (type 1) and, at a smith, the repair cost (type 3). + for (const pl of s.hud.inventory.gridItems) { + expect(pl.item.sellCost).toBe(1000 * 2 + Number(pl.item.id)) + } + const axeRaw = [...s.world.containers.equipment.values()].find((i) => i.code === 'hax')! + const axe = s.hud.inventory.equipped.weapon1 + expect(axe?.id).toBe(String(axeRaw.id)) + expect(axe?.repairCost).toBe(1000 * 4 + axeRaw.id) + + // Closing the panel sends 0x30 and frees the store locally (no RemoveFromShop arrives). + s.hud.closeAllPanels() + expect(s.commands.at(-1)).toEqual({ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } }) + expect(s.world.containers.shop.size).toBe(0) + expect(s.hud.leftPanel).toBe('none') + expect(wp.activeVendorDescriptor).toBeNull() + }) + + it('"cancel" and clicks outside the menu send NpcCancel', () => { + const s = createSession() + walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID) + clickNpcOption(s.hud, 'cancel') + expect(s.commands).toEqual([{ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } }]) + expect(s.hud.worldPanels.npcMenu).toBeNull() + + s.world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: CHARSI_ID } }) + s.model.syncFromWorld() + expect(s.hud.worldPanels.npcMenu?.npcId).toBe(CHARSI_ID) + mouseDown(s.hud, 20, 20) + expect(s.commands.at(-1)).toEqual({ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } }) + expect(s.hud.worldPanels.npcMenu).toBeNull() + }) + + it('Charsi does not gamble: openNpcGamble fails fast', () => { + const s = createSession() + walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID) + expect(() => s.model.openNpcGamble(CHARSI_ID)).toThrow(/does not gamble/) + expect(s.commands).toEqual([]) + }) + + it('a store needs price providers: without them openNpcTrade throws before sending', () => { + const world = new ClientWorld() + const commands: ClientCommand[] = [] + const model = new HudModel({ world, hudManager: new HudManager(), onCommand: (c) => commands.push(c) }) + expect(() => model.openNpcTrade(CHARSI_ID)).toThrow(/vendorPrice/) + expect(commands).toEqual([]) + }) +}) + +describe('store actions are commands only', () => { + it('buy / shift-buy click the store grid -> 0x32 with itemMode 0 and the #10107 price, throttled to one per 2000 ms', () => { + const s = openCharsiStore('05-d2gs-vendor-open.d2cap') + const before = s.commands.length + const pl = s.hud.worldPanels.getActiveVendorPlacements()[0]! + const x = VENDOR_GRID_ORIGIN.x + pl.col * VENDOR_GRID_ORIGIN.cellPx + 5 + const y = VENDOR_GRID_ORIGIN.y + pl.row * VENDOR_GRID_ORIGIN.cellPx + 5 + const itemId = Number(pl.item.id) + const raw = s.world.containers.shop.get(itemId)! + const shopSize = s.world.containers.shop.size + const gold = s.world.self.gold + + mouseDown(s.hud, x, y) + expect(s.commands.slice(before)).toEqual([ + { type: 'NpcBuy', npcId: CHARSI_ID, itemId, itemMode: 0, gamble: false, shiftFill: false, cost: stubPrice(raw, 0, { world: s.world, npcClass: 154 }) }, + ]) + // Second click inside the throttle window is dropped. + mouseDown(s.hud, x, y, { shiftKey: true }) + expect(s.commands.length).toBe(before + 1) + s.clock.t += NPC_TRADE_THROTTLE_MS + mouseDown(s.hud, x, y, { shiftKey: true }) + expect(s.commands.at(-1)).toMatchObject({ type: 'NpcBuy', itemId, shiftFill: true, gamble: false }) + + // Nothing changed locally: the server answers with 0x2A / 0x9C / stat 14. + expect(s.world.containers.shop.size).toBe(shopSize) + expect(s.world.self.gold).toBe(gold) + expect(s.hud.inventory.cursorItem).toBeNull() + }) + + it('sell / repair / repair-all send 0x33 / 0x35 with the current durability; repair and repair-all share one throttle', () => { + const s = openCharsiStore('05-d2gs-vendor-open.d2cap') + const before = s.commands.length + const scroll = [...s.world.containers.inventory.values()].find((i) => i.code === 'tsc')! + const axe = [...s.world.containers.equipment.values()].find((i) => i.code === 'hax')! + + expect(s.model.vendorSell(scroll.id)).toBe(true) + expect(s.model.vendorSell(scroll.id)).toBe(false) + expect(s.model.vendorRepairItem(axe.id)).toBe(true) + expect(s.model.vendorRepairAll()).toBe(false) + s.clock.t += NPC_TRADE_THROTTLE_MS + expect(s.model.vendorRepairAll()).toBe(true) + + expect(s.commands.slice(before)).toEqual([ + { type: 'NpcSell', npcId: CHARSI_ID, itemId: scroll.id, itemMode: 0, cost: stubPrice(scroll, 1, { world: s.world, npcClass: 154 }) }, + { type: 'NpcRepairItem', npcId: CHARSI_ID, itemId: axe.id, itemMode: 0, durability: 28 }, + { type: 'NpcRepairAll', npcId: CHARSI_ID }, + ]) + expect(s.world.containers.inventory.has(scroll.id)).toBe(true) + expect(s.world.containers.equipment.get(axe.id)?.curDurability).toBe(28) + }) + + it('Repair All button and holding a cursor item over the store grid go through the command sink', () => { + const s = openCharsiStore('05-d2gs-vendor-open.d2cap') + const before = s.commands.length + const wp = s.hud.worldPanels + // Charsi button table [buy, sell, repair, repairAll]; slot 3 = Repair All. + wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, { + onClose: () => {}, + onWaypointTeleport: () => {}, + onVendorRepairAll: () => s.model.vendorRepairAll(), + }) + expect(s.commands.slice(before)).toEqual([{ type: 'NpcRepairAll', npcId: CHARSI_ID }]) + }) + + it('a store action with no store open fails fast', () => { + const s = createSession() + expect(() => s.model.vendorBuy(1, false)).toThrow(/no store window/) + expect(() => s.model.vendorRepairAll()).toThrow(/no store window/) + }) +}) + +describe('S2C 0x2A NpcTransaction feedback', () => { + it('successful sells (result 1) play UI sound 0xdd; rejected buys (result 12) show TransactionResults10 (tbl 3362)', () => { + const s = createSession() + const rest = walkUpToNpc(s, loadCapture('06-d2gs-vendor-buy-sell.d2cap'), AKARA_ID) + s.model.openNpcTrade(AKARA_ID) + expect(s.hud.worldPanels.vendorRepairAllCost).toBeNull() + for (const e of rest) { + s.world.apply(e) + s.model.syncFromWorld() + } + expect(s.sounds).toEqual([NPC_SELL_SOUND_ID, NPC_SELL_SOUND_ID]) + expect(s.model.lastNpcTransactionMessage).toEqual({ tblKey: 'TransactionResults10', text: requireTbl('TransactionResults10', 'CHI') }) + // Gold comes only from stat 14 (2 gold after the two sells). + expect(s.hud.inventory.gold).toBe(s.world.self.gold) + }) +}) + +describe('NPC store tbl strings', () => { + it('every 0x2A message key and store/gold-dialog key is packed in both languages', () => { + const keys = new Set() + for (let result = 0; result <= 20; result++) { + const k = npcTransactionMessageKey(result) + if (k !== null) keys.add(k) + } + expect([...keys].sort()).toEqual([ + 'TransactionResults10', + 'TransactionResults11', + 'TransactionResults4', + 'TransactionResults5', + 'TransactionResults6', + 'TransactionResults8', + 'TransactionResults9', + ]) + for (const k of [...keys, 'cost', 'Repair', 'Sell', 'ok', 'Back', 'strDropGoldHowMuch', 'strBankGoldDeposit', 'strBankGoldWithdraw']) { + expect(requireTbl(k, 'ENG').length).toBeGreaterThan(0) + expect(requireTbl(k, 'CHI').length).toBeGreaterThan(0) + } + expect(requireTbl('patchstring#95', 'ENG')).toBe('Repair all equipment: %d') + expect(requireTbl('patchstring#95', 'CHI')).toBe('修復所有裝備:%d') + expect(() => requireTbl('notAPackedKey', 'ENG')).toThrow(/not packed/) + }) +}) + +describe('stash and gold dialog', () => { + function openStash() { + const s = createSession() + for (const e of loadCapture('08-d2gs-stash-gold.d2cap')) s.world.apply(e) + s.model.syncFromWorld() + expect(s.hud.leftPanel).toBe('stash') + s.world.apply({ type: 'StatSet', stat: D2StatId.Gold, value: 5000 }) + s.world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 1200 }) + s.model.syncFromWorld() + expect(s.hud.inventory.gold).toBe(5000) + expect(s.hud.worldPanels.stashGold).toBe(1200) + return s + } + + it('deposit (mode 3) is pre-filled with the bag gold; typed digits replace it; ok sends 0x4F 0x14', () => { + const s = openStash() + s.hud.openGoldDialog(3) + expect(s.hud.goldDialog).toEqual({ mode: 3, amount: 5000, fresh: true }) + s.hud.typeGoldDialogKey('7') + s.hud.typeGoldDialogKey('5') + s.hud.confirmGoldDialog() + expect(s.commands).toEqual([{ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount: 75 }]) + expect(s.hud.inventory.gold).toBe(5000) + expect(s.hud.worldPanels.stashGold).toBe(1200) + }) + + it('withdraw (mode 4) clamps to the stash balance; 0 sends nothing; drop (mode 0) sends 0x15 DropGold', () => { + const s = openStash() + s.hud.openGoldDialog(4) + s.hud.setGoldDialogAmount(99_999) + expect(s.hud.goldDialog?.amount).toBe(1200) + s.hud.confirmGoldDialog() + s.hud.openGoldDialog(4) + s.hud.setGoldDialogAmount(0) + s.hud.confirmGoldDialog() + s.hud.openGoldDialog(0) + s.hud.setGoldDialogAmount(10) + s.hud.confirmGoldDialog() + expect(s.commands).toEqual([ + { type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount: 1200 }, + { type: 'DropGold', unitId: s.world.selfId, amount: 10 }, + ]) + expect(s.hud.goldDialog).toBeNull() + }) + + it('the stash gold button opens the withdraw dialog; cancel sends nothing; closing the stash sends 0x4F 0x12', () => { + const s = openStash() + s.hud.worldPanels.handleLeftDockClick('stash', STASH_X + STASH_GOLD_BTN_HIT.relX0 + 10, STASH_Y + STASH_GOLD_BTN_HIT.relY0 + 5, { + onClose: () => {}, + onWaypointTeleport: () => {}, + onStashGoldButton: () => s.hud.openGoldDialog(4), + }) + expect(s.hud.goldDialog?.mode).toBe(4) + s.hud.cancelGoldDialog() + expect(s.commands).toEqual([]) + + s.hud.closeAllPanels() + expect(s.commands).toEqual([{ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }]) + expect(s.world.npcInteraction.stashOpen).toBe(false) + expect(() => s.hud.openGoldDialog(3)).toThrow(/stash open/) + }) +}) diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts index e44274f..a1ec9ba 100644 --- a/tests/cursor.test.ts +++ b/tests/cursor.test.ts @@ -31,7 +31,8 @@ import { type CursorFrameMeta, } from '../src/common/items/cursor-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { HudManager } from '../src/client/ui/hud-manager.ts' +import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts' +import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts' import { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts' import { seedDemoHud } from './fixtures/hud-demo-state.ts' @@ -460,13 +461,14 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => { const { hud, clickAt } = createTestHud() - // Server-not-ready HUD (no class assigned) refuses to open a vendor session - expect(() => hud.openVendorSession('Charsi', 'trade', 0)).toThrow(/character class/) // Explicitly seed the legacy demo Sorceress state (class, level, gold, inventory) seedDemoHud(hud) + const closeNpc = vi.fn() + hud.commandSink = { closeNpc } as unknown as HudCommandSink - // Open Charsi vendor panel - expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true) + // Server opened Charsi's store (0x9C AddToShop items + NpcOpenTrade): HudModel fills and shows it + openGeneratedVendorShop(hud.worldPanels, 'Charsi', 'trade', 24, 0, 7) + hud.showVendorPanel() expect(hud.state.leftPanel).toBe('vendor') expect(hud.state.vendorTradeState).toBe('idle') expect(hud.state.cursorTradeMode).toBeNull() @@ -512,6 +514,7 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { expect(hud.state.cursorTradeMode).toBe('sell') hud.closeAllPanels() expect(hud.state.leftPanel).toBe('none') + expect(closeNpc).toHaveBeenCalledWith(7) expect(hud.state.cursorTradeMode).toBeNull() expect(hud.state.cursorAnimation).toBe('protate') }) diff --git a/tests/ground-items-drop.test.ts b/tests/ground-items-drop.test.ts index cea9477..63de0f6 100644 --- a/tests/ground-items-drop.test.ts +++ b/tests/ground-items-drop.test.ts @@ -235,21 +235,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => { }) }) - describe('4. InventoryPanel Gold Dropping & findFreeGridSlot', () => { - it('InventoryPanel.dropGold deducts gold from player inventory', () => { - const panel = new InventoryPanel() - panel.gold = 50_000 - - const dropped = panel.dropGold(10_000) - expect(dropped).toBe(10_000) - expect(panel.gold).toBe(40_000) - - // Cannot drop more than available gold - const dropExcess = panel.dropGold(100_000) - expect(dropExcess).toBe(40_000) - expect(panel.gold).toBe(0) - }) - + describe('4. InventoryPanel findFreeGridSlot', () => { it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => { const gridItems: GridPlacement[] = [ { diff --git a/tests/helpers/vendor-shop-fixture.ts b/tests/helpers/vendor-shop-fixture.ts new file mode 100644 index 0000000..21a9ea4 --- /dev/null +++ b/tests/helpers/vendor-shop-fixture.ts @@ -0,0 +1,45 @@ +/** + * Test fixture: fill a `WorldPanelsHud` store the way `HudModel.syncNpcInteraction` does after the + * server streamed `0x9C AddToShop` items, but with items generated offline by the common vendor + * generator (D2Game store rolls) so panel/draw tests keep realistic content without a live server. + */ +import { VendorSessionManager } from '../../src/common/items/vendor-service.ts' +import { getEmbeddedDropTables } from '../../src/common/items/embedded-drop-tables.ts' +import { itemToUiInventoryItem } from '../../src/common/items/item-bridge.ts' +import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts' +import { VENDOR_TABS, type WorldPanelsHud } from '../../src/client/ui/world-panels.ts' + +export function openGeneratedVendorShop( + wp: WorldPanelsHud, + npcName: string, + mode: 'trade' | 'gamble' = 'trade', + cLvl = 85, + difficulty: 0 | 1 | 2 = 0, + npcId = 1, +): void { + const descriptor = getTownNpcDescriptorByName(npcName) + if (!descriptor || descriptor.vendorId === null) { + throw new Error(`openGeneratedVendorShop: ${npcName} is not a vendor NPC`) + } + const tables = getEmbeddedDropTables() + const session = new VendorSessionManager(tables) + const vendorId = descriptor.vendorId + const items: { storePage: number; placement: { item: ReturnType; col: number; row: number } }[] = [] + if (mode === 'gamble') { + for (const p of session.getOrCreateGambleStore(vendorId, cLvl).getAllItems()) { + const buyCost = session.getBuyPrice(p.item, vendorId, difficulty, cLvl, true) + const item = itemToUiInventoryItem({ ...p.item, buyCost, isGambleMystery: true } as typeof p.item, tables) + items.push({ storePage: 1, placement: { item, col: p.x, row: p.y } }) + } + } else { + const store = session.getOrCreateVendorStore(vendorId, difficulty, cLvl) + VENDOR_TABS.forEach((tab, idx) => { + for (const p of store.tabs[tab.id].getAllItems()) { + const buyCost = session.getBuyPrice(p.item, vendorId, difficulty, cLvl, false) + const item = itemToUiInventoryItem({ ...p.item, buyCost } as typeof p.item, tables) + items.push({ storePage: idx + 1, placement: { item, col: p.x, row: p.y } }) + } + }) + } + wp.setVendorShop({ npcId, descriptor, mode, items }) +} diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts index 6f5abb9..1a15cfa 100644 --- a/tests/issue-503-bilingual-parity.test.ts +++ b/tests/issue-503-bilingual-parity.test.ts @@ -43,6 +43,7 @@ import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts' import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts' import { SkillTreePanel } from '../src/client/ui/skill-tree-panel.ts' import { ACT_QUESTS, ACT_WAYPOINTS, WorldPanelsHud } from '../src/client/ui/world-panels.ts' +import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts' import { ControlBarHud } from '../src/client/ui/control-bar.ts' import { GlobesHud } from '../src/client/ui/globes.ts' import { SkillHotkeysHud } from '../src/client/ui/hotkeys.ts' @@ -450,7 +451,7 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () expect(textCalls).toContain('剩余技能点') expect(textCalls).toContain('角色属性 (A/C)') expect(textCalls).toContain('罗格营地') - expect(textCalls).toContain('私人储物箱') + expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI) expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)') expect(textCalls).not.toContain('角色属性 (Character)') @@ -606,7 +607,7 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () // Also test Charsi vendor inventory across all 4 tabs const wpVendor = new WorldPanelsHud() wpVendor.lang = 'ENG' - wpVendor.openVendorForNpc('Charsi', 'trade', 85, 0) + openGeneratedVendorShop(wpVendor, 'Charsi', 'trade', 85, 0) for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc'] as const) { for (const p of wpVendor.vendorTabPlacements[tabId]) { const { ctx, font, textCalls } = createMockCanvasAndFont() diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index 78d83dd..0b01ffc 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -510,6 +510,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { // NPC talking dialog body also uses fontformal12 while header uses font16 drawnCalls.length = 0 worldPanels.npcMenu = { + npcId: null, npcName: 'Akara', x: 400, y: 260, @@ -711,6 +712,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { } as unknown as D2FontRenderer worldPanels.npcMenu = { + npcId: null, npcName: '阿卡拉', x: 400, y: 260, diff --git a/tests/item-to-ui-bridge.test.ts b/tests/item-to-ui-bridge.test.ts index 1a8b069..d86aed4 100644 --- a/tests/item-to-ui-bridge.test.ts +++ b/tests/item-to-ui-bridge.test.ts @@ -8,7 +8,7 @@ * 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance * 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting * 5. Normal and Telekinesis ground item pickup conversion via itemToUiInventoryItem - * 6. Gold accumulation clamped to PLAYER_GOLD_CAP (2,500,000) + * 6. Gold accumulation clamped to the level-based inventory gold limit (getInventoryGoldLimit) * 7. Inventory full refusal bounce, audio feedback, and notification retention */ @@ -23,7 +23,6 @@ import { import { InventoryPanel, resolveItemSpriteRect, - PLAYER_GOLD_CAP, } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { GameEngine } from '../src/server/engine/engine.ts' @@ -423,7 +422,7 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & }) }) - describe('6. Gold Accumulation & PLAYER_GOLD_CAP Parity', () => { + describe('6. Gold Accumulation & Inventory Gold Limit Parity', () => { let mockCanvas: any let mockStatus: { textContent: string } diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index 560e1ea..0505df9 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -22,7 +22,7 @@ * - Authentic allowedSlots resolution and paperdoll equipping * * 4. Gold Currency Accumulation vs Inventory Full Flippy Bounce: - * - Gold currency accumulation clamped to PLAYER_GOLD_CAP (2,500,000) + * - Gold currency accumulation clamped to the level-based inventory gold limit * - Full 10x4 inventory grid refusal, metrics.inventoryRefusals, and parabolic bounce physics */ @@ -37,7 +37,6 @@ import { resolveItemSpriteRect, identifyUiItem, isUiItemUnidentified, - PLAYER_GOLD_CAP, type UiInventoryItem, type EquipSlotId, } from '../src/client/ui/inventory.ts' diff --git a/tests/stash.test.ts b/tests/stash.test.ts index de54028..6ef043d 100644 --- a/tests/stash.test.ts +++ b/tests/stash.test.ts @@ -12,7 +12,6 @@ import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN, STASH_GOLD_CAP, - PLAYER_GOLD_CAP, STASH_HEADER_ICON_SOCKET, STASH_HEADER_TOP_RECESS, STASH_HEADER_BOTTOM_RECESS, @@ -359,68 +358,13 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x }) }) - describe('5. Gold Deposit and Withdrawal with Caps', () => { - let panels: WorldPanelsHud - let inv: InventoryPanel - - beforeEach(() => { - panels = new WorldPanelsHud() - inv = new InventoryPanel() - inv.gold = 500_000 - panels.stashGold = 100_000 - }) - - it('deposits gold from inventory into stash up to capacity', () => { - const deposited = panels.depositGold(50_000, inv) - expect(deposited).toBe(50_000) - expect(inv.gold).toBe(450_000) - expect(panels.stashGold).toBe(150_000) - }) - - it('withdraws gold from stash into inventory up to player capacity', () => { - const withdrawn = panels.withdrawGold(40_000, inv) - expect(withdrawn).toBe(40_000) - expect(inv.gold).toBe(540_000) - expect(panels.stashGold).toBe(60_000) - }) - - it('respects STASH_GOLD_CAP (2,500,000) and clamps deposit amounts', () => { + describe('5. Stash Gold Cap (server-driven transfers)', () => { + it('exposes STASH_GOLD_CAP = 2,500,000 (D2Common #11060) and performs no local gold mutation', () => { expect(STASH_GOLD_CAP).toBe(2_500_000) - panels.stashGold = 2_490_000 - inv.gold = 100_000 - - const deposited = panels.depositGold(50_000, inv) - expect(deposited).toBe(10_000) - expect(panels.stashGold).toBe(2_500_000) - expect(inv.gold).toBe(90_000) - - // Further deposits do nothing - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(panels.stashGold).toBe(2_500_000) - }) - - it('respects PLAYER_GOLD_CAP (2,500,000) and clamps withdrawal amounts', () => { - expect(PLAYER_GOLD_CAP).toBe(2_500_000) - panels.stashGold = 500_000 - inv.gold = 2_480_000 - - const withdrawn = panels.withdrawGold(50_000, inv) - expect(withdrawn).toBe(20_000) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(480_000) - - // Further withdrawals do nothing - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(2_500_000) - }) - - it('handles zero or negative transfers safely', () => { - expect(panels.depositGold(0, inv)).toBe(0) - expect(panels.depositGold(-500, inv)).toBe(0) - expect(panels.withdrawGold(0, inv)).toBe(0) - expect(panels.withdrawGold(-500, inv)).toBe(0) - expect(inv.gold).toBe(500_000) - expect(panels.stashGold).toBe(100_000) + const panels = new WorldPanelsHud() + // Deposit/withdraw are ClickButton commands sent by HudModel; D2Game owns the balances. + expect((panels as unknown as Record)['depositGold']).toBeUndefined() + expect((panels as unknown as Record)['withdrawGold']).toBeUndefined() }) }) @@ -548,8 +492,8 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x mockFont, ) - const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱')) - const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币')) + const titleCall = drawTextCalls.find(c => c.text.includes('你的私人儲藏箱')) // tbl 'bank' + const goldCall = drawTextCalls.find(c => c.text.includes('儲藏起來的金錢:')) // tbl 'strGoldInStash' expect(titleCall).toBeDefined() expect(goldCall).toBeDefined() diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 8b8d366..61e2ff9 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -1064,16 +1064,16 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(goldTextCall!.y - 7).toBeGreaterThanOrEqual(INV_GOLD_BOX_RECESS.y) expect(goldTextCall!.y + 1).toBeLessThanOrEqual(INV_GOLD_BOX_RECESS.y + INV_GOLD_BOX_RECESS.h - 1) - // 4. Verify clicking directly on the Gold Coin Button (490, 460) triggers onDropGold - let droppedGold = 0 + // 4. Verify clicking directly on the Gold Coin Button (490, 460) asks for the gold dialog (no local drop) + let goldButtonPresses = 0 expect( inv.handleClick(490, 460, { - onDropGold: amt => { - droppedGold = amt + onGoldButton: () => { + goldButtonPresses++ }, }), ).toBe(true) - expect(droppedGold).toBe(10_000) + expect(goldButtonPresses).toBe(1) // 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia) const fontRenderer = new D2FontRenderer() diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts index 82a303f..795989b 100644 --- a/tests/unidentified-drops-identify.test.ts +++ b/tests/unidentified-drops-identify.test.ts @@ -385,47 +385,5 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System' expect(tomeItem.quantity).toBe(4) expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true) }) - - it('identifies all unidentified items across Inventory, Stash, and Horadric Cube via Deckard Cain', () => { - const inv = new InventoryPanel() - const wp = new WorldPanelsHud() - const rinBase = dropTables.getBase('rin')! - const amuBase = dropTables.getBase('amu')! - - const bagUnid = itemToUiInventoryItem(makeDroppedShako('unique', 51), dropTables) - const stashUnid = itemToUiInventoryItem( - createDroppedItem(rinBase, 'rare', { - itemTypes: dropTables.itemTypes, - ilvl: 60, - dwInitSeed: 52, - itemRng: new D2Rng(52), - }), - dropTables, - ) - const cubeUnid = itemToUiInventoryItem( - createDroppedItem(amuBase, 'magic', { - itemTypes: dropTables.itemTypes, - ilvl: 50, - dwInitSeed: 53, - itemRng: new D2Rng(53), - }), - dropTables, - ) - - inv.gridItems = [{ item: bagUnid, col: 0, row: 0 }] - wp.stashItems = [{ item: stashUnid, col: 0, row: 0 }] - wp.cubeItems = [{ item: cubeUnid, col: 0, row: 0 }] - - expect(isUiItemUnidentified(bagUnid)).toBe(true) - expect(isUiItemUnidentified(stashUnid)).toBe(true) - expect(isUiItemUnidentified(cubeUnid)).toBe(true) - - const count = wp.identifyAllUiItems(inv) - expect(count).toBe(3) - expect(isUiItemUnidentified(bagUnid)).toBe(false) - expect(isUiItemUnidentified(stashUnid)).toBe(false) - expect(isUiItemUnidentified(cubeUnid)).toBe(false) - expect(wp.identifyAllUiItems(inv)).toBe(0) - }) }) }) diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts index 3f99ac6..a3fb6dd 100644 --- a/tests/vendor-panel-ui.test.ts +++ b/tests/vendor-panel-ui.test.ts @@ -33,6 +33,7 @@ import { hitTestNpcDialogOption, resolveVendorButtonLayout, } from '../src/client/ui/world-panels.ts' +import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts' function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } { const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) @@ -287,7 +288,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0) const { ctx, font, drawImageCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont() const assets = { ...createFakeVendorAssets(), inventory: inv } @@ -326,7 +327,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Akara', 'trade', 12, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Akara', 'trade', 12, 0) // Force armor tab empty to verify 0x6fb45e5e-0x6fb45f5d tab presence guard wp.vendorTabPlacements.armor = [] @@ -349,7 +350,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Gheed', 'gamble', 30, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Gheed', 'gamble', 30, 0) const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont() wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) @@ -367,27 +368,12 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const inv = new InventoryPanel() inv.gold = 50000 wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0) - // Damage an equipped item so Repair All has a non-zero cost - const helm: UiInventoryItem = { - id: 'damaged-cap', - code: 'cap', - name: 'Cap', - nameZh: '帽子', - baseNameZh: '帽子', - quality: 'normal', - invFile: 'invcap', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - identified: true, - durability: { current: 4, max: 12 }, - } - inv.equipped.helm = helm - const expectedCost = wp.getRepairAllCost(inv) - expect(expectedCost).toBeGreaterThan(0) + // Repair All total is supplied by HudModel (VendorRepairAllFn over priceItem113c type 3) + const expectedCost = 1234 + wp.vendorRepairAllCost = expectedCost + let repairAllCalls = 0 // Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull() @@ -403,6 +389,9 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { }, onWaypointTeleport: () => {}, inventory: inv, + onVendorRepairAll: () => { + repairAllCalls++ + }, } // Click slot 0 (Buy) -> latches 'buy' (button frame 3, cursor frame 3) @@ -438,38 +427,37 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { ), ).toBe(true) - // Click slot 3 (Repair All on Charsi) -> repairs item, clears state to 'idle', and does NOT close panel! + // Click slot 3 (Repair All on Charsi) -> sends the repair-all command, clears state to 'idle', and does NOT close panel! wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks) expect(closed).toBe(false) expect(wp.vendorTradeState).toBe('idle') expect(wp.getActiveVendorCursorFrame()).toBeNull() expect(wp.pressedMomentarySlot).toBe(3) - expect(helm.durability?.current).toBe(12) + expect(repairAllCalls).toBe(1) wp.releaseMomentaryVendorButton() expect(wp.pressedMomentarySlot).toBeNull() // Switch to Akara and click slot 3 (Close) -> closes panel, slot 2 (blank) is ignored - wp.openVendorForNpc('Akara', 'trade', 24, 0) + openGeneratedVendorShop(wp, 'Akara', 'trade', 24, 0, 2) expect(wp.hitTestVendorButtonSlot(80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390)).toBeNull() wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks) expect(closed).toBe(true) }) - it('auto-cycles to the next non-empty tab when buying the last item on the active tab', () => { + it('auto-cycles to the next non-empty tab when the server empties the active tab', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() inv.gold = 999999 wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0) expect(wp.activeVendorTab).toBe('armor') - // Leave only 1 non-permStore item on the armor tab and buy it - const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore) - expect(nonPerm).toBeDefined() - wp.vendorTabPlacements.armor = [nonPerm!] - - const res = wp.buyFromActiveVendor(nonPerm!.col, nonPerm!.row, inv, false) - expect(res.ok).toBe(true) + // The server removed the last armor item (0x9D RemoveFromShop after a buy): HudModel re-applies the + // same store without it and D2Client 0x6fb3f710 advances to the next non-empty tab. + const remaining = (['weapons1', 'weapons2', 'misc'] as const).flatMap((id, i) => + wp.vendorTabPlacements[id].map(placement => ({ storePage: i + 2, placement })), + ) + wp.setVendorShop({ npcId: 1, descriptor: wp.activeVendorDescriptor!, mode: 'trade', items: remaining }) expect(wp.vendorTabPlacements.armor).toHaveLength(0) expect(wp.activeVendorTab).toBe('weapons1') }) @@ -642,6 +630,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { openedVendorMode = mode }, onIdentifyAll: () => {}, + onCancel: () => {}, }, font, ) @@ -654,7 +643,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 10, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 10, 0) // Set player context: Level 10 Sorceress with 25 Str, 25 Dex wp.setPlayerContext({ @@ -860,7 +849,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { dex: 500, classCode: 'sor', }) - expect(wp.openVendorForNpc('Gheed', 'gamble', 99, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Gheed', 'gamble', 99, 0) expect(wp.gamblePlacements.length).toBeGreaterThan(0) // Even when hovering a gamble item with an empty cursor, every gamble item background must be RED From 34a895a12b1959d9df308164b4ee47243a85a85b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 11:09:54 +0000 Subject: [PATCH 8/8] test(client): capture-backed gamble flow (fixture 09, Gheed) Gamble preview click emits NpcBuy{gamble:true, itemMode:0, shiftFill:false} with the #10107 type-2 price; the captured 0x2A result 12 shows TransactionResults10. --- tests/client/gamble-replay-ui.test.ts | 117 ++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/client/gamble-replay-ui.test.ts diff --git a/tests/client/gamble-replay-ui.test.ts b/tests/client/gamble-replay-ui.test.ts new file mode 100644 index 0000000..3551cca --- /dev/null +++ b/tests/client/gamble-replay-ui.test.ts @@ -0,0 +1,117 @@ +/** + * Gamble UI over live capture 09 (Gheed, WebBotThree with 2 gold): the menu sends 0x38 action 2, + * the gamble grid is exactly the server's 0x9C AddToShop items, clicking a ring sends a gamble + * 0x32 NpcBuy, and the server's rejection (0x2A result 12) shows tbl string 3362. + */ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { replayD2CapFile } from '../../tools/netproto-replay.ts' +import type { ClientCommand, ServerEvent } from '../../src/netproto/index.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { VENDOR_GRID_ORIGIN } from '../../src/client/ui/world-panels.ts' +import { requireTbl } from '../../src/client/i18n/lang.ts' +import { HudModel } from '../../src/client/ui-model/hud-model.ts' +import type { VendorPriceFn } from '../../src/client/ui-model/vendor-price.ts' + +const GAMBLE_CAPTURE = path.resolve(__dirname, '../fixtures/netproto/09-d2gs-gamble.d2cap') +/** Gheed (MonStats hcIdx 147) is unit #4 in capture 09. */ +const GHEED_ID = 4 +const GHEED_CLASS = 147 +const MONSTER = 1 + +const stubPrice: VendorPriceFn = (item, type) => 1000 * (type + 1) + item.id + +describe('gamble capture 09 -> HudModel / HudManager', () => { + it('menu -> NpcOpenGamble, server grid shown, ring click -> gamble NpcBuy, result 12 -> TransactionResults10', () => { + const r = replayD2CapFile(GAMBLE_CAPTURE, { strict: true }) + expect(r.unknownPackets).toBe(0) + const world = new ClientWorld() + const hud = new HudManager() + const commands: ClientCommand[] = [] + const model = new HudModel({ + world, + hudManager: hud, + onCommand: (c) => { + commands.push(c) + world.noteOutgoingCommand(c) + }, + vendorPrice: stubPrice, + vendorRepairAll: () => { + throw new Error('gamble stores have no Repair All') + }, + playUiSound: () => {}, + now: () => 10_000, + }) + + const firstShop = r.events.findIndex((e) => e.type === 'ItemEvent' && e.container === 'shop') + expect(firstShop).toBeGreaterThan(0) + for (const e of r.events.slice(0, firstShop)) world.apply(e) + model.syncFromWorld() + expect(world.getUnit('monster', GHEED_ID)?.classId).toBe(GHEED_CLASS) + + // Click Gheed (C2S 0x13) -> NPC menu with "gamble". + world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: GHEED_ID } }) + model.syncFromWorld() + const menu = hud.worldPanels.npcMenu! + expect(menu.npcId).toBe(GHEED_ID) + const gambleIdx = menu.options!.findIndex((o) => o.id === 'gamble') + expect(gambleIdx).toBeGreaterThanOrEqual(0) + const layout = hud.worldPanels.getNpcMenuLayout(hud.font)! + const down = (x: number, y: number, shiftKey = false) => + hud.handleHudMouseDown({ + clientX: x, + clientY: y, + button: 0, + shiftKey, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as MouseEvent) + down(layout.left + Math.trunc(layout.width / 2), layout.optionYs[gambleIdx]! - 3) + expect(commands).toEqual([{ type: 'NpcOpenGamble', npcId: GHEED_ID }]) + expect(hud.leftPanel).toBe('vendor') + + // Server fills the gamble grid; hold back the 0x2A reply until the click. + const rest = r.events.slice(firstShop) + const reply = rest.filter((e): e is Extract => e.type === 'NpcTransaction') + expect(reply.map((e) => e.result)).toEqual([12]) + for (const e of rest) if (e.type !== 'NpcTransaction') world.apply(e) + model.syncFromWorld() + + const wp = hud.worldPanels + expect(wp.vendorMode).toBe('gamble') + expect(wp.gamblePlacements.length).toBe(world.containers.shop.size) + expect(wp.vendorRepairAllCost).toBeNull() + for (const pl of wp.gamblePlacements) { + const raw = world.containers.shop.get(Number(pl.item.id))! + expect(pl.item.buyCost).toBe(stubPrice(raw, 2, { world, npcClass: GHEED_CLASS })) + } + + const ring = wp.gamblePlacements.find((pl) => pl.item.code === 'rin')! + expect(ring).toBeDefined() + const ringId = Number(ring.item.id) + const ringRaw = world.containers.shop.get(ringId)! + // Shift is ignored for gamble buys (D2Client sends shiftFill 0 in gamble mode). + down( + VENDOR_GRID_ORIGIN.x + ring.col * VENDOR_GRID_ORIGIN.cellPx + 5, + VENDOR_GRID_ORIGIN.y + ring.row * VENDOR_GRID_ORIGIN.cellPx + 5, + true, + ) + expect(commands.at(-1)).toEqual({ + type: 'NpcBuy', + npcId: GHEED_ID, + itemId: ringId, + itemMode: 0, + gamble: true, + shiftFill: false, + cost: stubPrice(ringRaw, 2, { world, npcClass: GHEED_CLASS }), + }) + expect(world.self.gold).toBe(2) + + for (const e of reply) world.apply(e) + model.syncFromWorld() + expect(model.lastNpcTransactionMessage).toEqual({ tblKey: 'TransactionResults10', text: requireTbl('TransactionResults10', 'CHI') }) + expect(world.self.gold).toBe(2) + expect(world.containers.shop.has(ringId)).toBe(true) + }) +})