Merge branch 'feat/vendor-stash'

This commit is contained in:
troytt 2026-10-01 11:11:36 +00:00
commit 33af40cdb6
51 changed files with 5662 additions and 1600 deletions

View File

@ -377,6 +377,22 @@ async function main() {
'bank', 'bank',
'stash', 'stash',
'strGoldInStash', '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', 'box',
'strUiMenu2', 'strUiMenu2',
'strClose', 'strClose',
@ -524,6 +540,20 @@ async function main() {
} }
chiOut['strethereal'] = EXPANSION_CHI_FALLBACKS['strethereal']! 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#<n>`.
// - 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 outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts')
const fileContent = `/** const fileContent = `/**
* AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives: * AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives:

View File

@ -19,6 +19,13 @@ export function lookupTbl(key: string, lang: TblLang = getTblLang()): string | u
return resolveTblKey(key, lang) 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 let overrideLang: GameLang | null = null
const listeners = new Set<(lang: TblLang) => void>() const listeners = new Set<(lang: TblLang) => void>()
const gameLangListeners = new Set<(lang: GameLang) => void>() const gameLangListeners = new Set<(lang: GameLang) => void>()

View File

@ -57,6 +57,7 @@ import {
type SettingsStore, type SettingsStore,
} from '../settings/settings-store.ts' } from '../settings/settings-store.ts'
import { HudModel } from '../ui-model/hud-model.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 { ClientWorldSceneSource } from '../view/scene-source.ts'
import type { RenderableUnitInput } from '../view/unit-drawables.ts' import type { RenderableUnitInput } from '../view/unit-drawables.ts'
import { getViewportProfile } from '../view/viewport-profile.ts' import { getViewportProfile } from '../view/viewport-profile.ts'
@ -249,6 +250,11 @@ export class OnlineSession {
onCommand: (cmd) => { onCommand: (cmd) => {
this.dispatchCommand(cmd) this.dispatchCommand(cmd)
}, },
vendorPrice: vendorPrice113c,
vendorRepairAll: vendorRepairAll113c,
playUiSound: (soundId) => {
this.soundService.playSoundById(soundId)
},
}) })
this.commandMapper = new CommandMapper({ this.commandMapper = new CommandMapper({
@ -700,6 +706,7 @@ export class OnlineSession {
if (this.gameAdapter && this.gameAdapter.state !== 'closed') { if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
try { try {
this.gameAdapter.send(cmd) this.gameAdapter.send(cmd)
this.world.noteOutgoingCommand(cmd)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)

View File

@ -13,6 +13,7 @@ import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import { monsterDisplayName } from '../../common/units/monsters-data.ts' import { monsterDisplayName } from '../../common/units/monsters-data.ts'
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
import { ClickButtonId } from '../../netproto/index.ts'
import type { import type {
CharClassId, CharClassId,
ClientCommand, ClientCommand,
@ -32,11 +33,14 @@ import {
type UiInventoryItem, type UiInventoryItem,
type UiItemQuality, type UiItemQuality,
} from '../ui/inventory.ts' } 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 { AutomapView } from '../automap/automap-view.ts'
import type { LocalMovementPredictor } from '../motion/predictor.ts' import type { LocalMovementPredictor } from '../motion/predictor.ts'
import type { ClientUnit } from '../world/client-unit.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 { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts' import { computeCharacterSheetDerived } from './derived-stats.ts'
@ -172,8 +176,55 @@ export interface HudModelOptions {
readonly predictor?: LocalMovementPredictor | null | undefined readonly predictor?: LocalMovementPredictor | null | undefined
readonly automap?: AutomapView | null | undefined readonly automap?: AutomapView | null | undefined
readonly onCommand?: ((cmd: ClientCommand) => void) | 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 { export interface HudModelSnapshot {
readonly charName: string readonly charName: string
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */ /** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
@ -223,6 +274,17 @@ export class HudModel implements HudCommandSink {
private unsubWorld: (() => void) | null = null private unsubWorld: (() => void) | null = null
private hoveredMonsterId: number | null = null private hoveredMonsterId: number | null = null
private escapeMenuOpen = false 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<string, number>()
/** 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) { constructor(options: HudModelOptions) {
this.world = options.world this.world = options.world
@ -230,6 +292,11 @@ export class HudModel implements HudCommandSink {
this.predictor = options.predictor ?? null this.predictor = options.predictor ?? null
this.automap = options.automap ?? null this.automap = options.automap ?? null
this.onCommand = options.onCommand 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 if (this.hudManager) this.hudManager.commandSink = this
this.unsubWorld = this.world.onChange(() => { this.unsubWorld = this.world.onChange(() => {
@ -550,9 +617,143 @@ export class HudModel implements HudCommandSink {
// 7. Target monster plaque // 7. Target monster plaque
this.syncHoveredMonsterTarget() this.syncHoveredMonsterTarget()
// 8. NPC menu / store / stash panels and S2C 0x2A replies.
this.syncNpcInteraction(hud)
hud.syncPublishedState() 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`. * Update the hovered/targeted monster unit ID and refresh `hudManager.targetMonster`.
*/ */
@ -737,28 +938,35 @@ export class HudModel implements HudCommandSink {
* Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`). * Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`).
*/ */
transmuteCube(): void { 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`). * Buy an item from an active NPC vendor (`0x32 NpcBuy`).
*/ */
buyFromNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { buyFromNpc(p: { npcId: number; itemId: number; itemMode: number; gamble: boolean; shiftFill: boolean; cost: number }): void {
this.emitCommand({ type: 'NpcBuy', npcId, itemId, tab, cost }) this.emitCommand({ type: 'NpcBuy', ...p })
} }
/** /**
* Sell an item to an active NPC vendor (`0x33 NpcSell`). * Sell an item to an active NPC vendor (`0x33 NpcSell`).
*/ */
sellToNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { sellToNpc(p: { npcId: number; itemId: number; itemMode: number; cost: number }): void {
this.emitCommand({ type: 'NpcSell', npcId, itemId, tab, cost }) 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 { repairItemAtNpc(p: { npcId: number; itemId: number; itemMode: number; durability: number }): void {
this.emitCommand({ type: 'NpcRepair', npcId, itemId }) this.emitCommand({ type: 'NpcRepairItem', ...p })
}
/**
* Repair all items at an NPC smith (`0x35` field4 = 0x80000000).
*/
repairAllAtNpc(npcId: number): void {
this.emitCommand({ type: 'NpcRepairAll', npcId })
} }
/** /**
@ -767,6 +975,95 @@ export class HudModel implements HudCommandSink {
identifyAllAtNpc(npcId: number): void { identifyAllAtNpc(npcId: number): void {
this.emitCommand({ type: 'NpcIdentifyAll', npcId }) 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 { export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHudInfo {

View File

@ -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<string, { statId: number; param: number; value: number; base: number }>()
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))
}

View File

@ -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)

View File

@ -45,7 +45,6 @@ import {
type CursorState, type CursorState,
} from './cursor.ts' } from './cursor.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts'
import { import {
detectInitialLang, detectInitialLang,
setCurrentLang, setCurrentLang,
@ -54,6 +53,7 @@ import {
type TblLang, type TblLang,
} from '../i18n/lang.ts' } from '../i18n/lang.ts'
import { setTooltipLocale } from '../../common/items/item-tooltip.ts' import { setTooltipLocale } from '../../common/items/item-tooltip.ts'
import { requireTbl } from '../i18n/lang.ts'
import { import {
computeViewportAnchors, computeViewportAnchors,
getViewportProfile, getViewportProfile,
@ -283,6 +283,15 @@ export function logicalToClient(
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`; * 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. * 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 { export interface HudCommandSink {
allocateStat(key: BaseStatKey): boolean allocateStat(key: BaseStatKey): boolean
allocateSkill(skillId: number): boolean allocateSkill(skillId: number): boolean
@ -299,6 +308,28 @@ export interface HudCommandSink {
useItem(itemId: number): void useItem(itemId: number): void
transmuteCube(): void transmuteCube(): void
teleportToLevel(destAreaId: number): 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. */ /** 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 currentClass: CharacterClassCode | null = null
private _commandSink: HudCommandSink | 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 * 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()`. * `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`.
@ -693,41 +731,164 @@ export class HudManager {
return ok return ok
} }
/** private requireSink(action: string): HudCommandSink {
* Opens a Town NPC Vendor or Gamble session on the Left Dock and ensures Player Inventory is open on the Right Dock. if (!this._commandSink) throw new Error(`HudManager.${action}: no server command sink attached`)
*/ return this._commandSink
openVendorSession( }
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string,
mode: 'trade' | 'gamble' = 'trade', /** Server opened a store (`ClientWorld.npcInteraction.window`): show the vendor panel with the inventory. */
difficulty: 0 | 1 | 2 = 0, showVendorPanel(): void {
): boolean { if (!this.worldPanels.activeVendorDescriptor) {
if (this.currentClass === null) { throw new Error('HudManager.showVendorPanel: no store set (WorldPanelsHud.setVendorShop)')
throw new Error('HudManager.openVendorSession: character class not assigned by the server yet')
} }
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.worldPanels.npcMenu = null
this.leftPanel = 'vendor' this.leftPanel = 'vendor'
this.charSheet.visible = false this.charSheet.visible = false
if (this.rightPanel !== 'inv') { if (this.rightPanel !== 'inv') this.toggleRightPanel('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() 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`). * Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
*/ */
toggleLeftPanel(kind: Exclude<LeftDockPanelKind, 'none'>): void { toggleLeftPanel(kind: Exclude<LeftDockPanelKind, 'none'>): void {
if (this.leftPanel === kind) { const prev = this.leftPanel
if (prev === kind) {
this.leftPanel = 'none' this.leftPanel = 'none'
this.charSheet.visible = false this.charSheet.visible = false
this.worldPanels.vendorTradeState = 'idle' this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton() this.worldPanels.releaseMomentaryVendorButton()
this.leaveLeftPanel(prev)
} else { } else {
if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) { if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) {
throw new Error('Cannot open vendor panel without an active vendor session') throw new Error('Cannot open vendor panel without an active vendor session')
} }
this.leftPanel = kind this.leftPanel = kind
if (prev !== 'none') this.leaveLeftPanel(prev)
this.charSheet.visible = kind === 'char' this.charSheet.visible = kind === 'char'
if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') { if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') {
this.toggleRightPanel('inv') this.toggleRightPanel('inv')
@ -777,8 +941,13 @@ export class HudManager {
this.belt.lockedOpen || this.belt.lockedOpen ||
this.hotkeys.openPopup !== null || this.hotkeys.openPopup !== null ||
this.worldPanels.npcMenu !== null || this.worldPanels.npcMenu !== null ||
this.goldDialog !== null ||
this.inventory.isIdentifyMode() this.inventory.isIdentifyMode()
const prevLeft = this.leftPanel
this.goldDialog = null
this.dismissNpcMenu()
this.leftPanel = 'none' this.leftPanel = 'none'
if (prevLeft !== 'none') this.leaveLeftPanel(prevLeft)
this.rightPanel = 'none' this.rightPanel = 'none'
this.charSheet.visible = false this.charSheet.visible = false
this.inventory.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. * Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false.
*/ */
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean { isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
if (this.goldDialog) return true
const profile = this.viewportProfile const profile = this.viewportProfile
const docking = this.getDockingLayout() const docking = this.getDockingLayout()
const { marginW } = docking const { marginW } = docking
@ -898,6 +1068,14 @@ export class HudManager {
private bindEvents(): void { private bindEvents(): void {
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return 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() const key = e.key.toUpperCase()
if (key === 'I' || key === 'B') { if (key === 'I' || key === 'B') {
@ -1067,9 +1245,14 @@ export class HudManager {
handleHudMouseDown(e: MouseEvent): void { handleHudMouseDown(e: MouseEvent): void {
if (e.button !== 0 && e.button !== 2) return if (e.button !== 0 && e.button !== 2) return
const pt = this.clientToLogical(e.clientX, e.clientY) 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)) { if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
this.worldPanels.npcMenu = null this.dismissNpcMenu()
this.syncPublishedState()
} }
if (!this.isPointInterceptedByHud(pt.x, pt.y)) { if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
if (this.inventory.isIdentifyMode()) { if (this.inventory.isIdentifyMode()) {
@ -1107,19 +1290,25 @@ export class HudManager {
// Town NPC Stone Menu click // Town NPC Stone Menu click
if (this.worldPanels.npcMenu) { if (this.worldPanels.npcMenu) {
const npcId = this.worldPanels.npcMenu.npcId
this.worldPanels.handleNpcMenuClick( this.worldPanels.handleNpcMenuClick(
pt.x, pt.x,
pt.y, pt.y,
{ {
onOpenVendor: (desc, mode) => { onOpenVendor: (_desc, mode) => {
this.openVendorSession(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) => { onIdentifyAll: (_desc) => {
const count = this.worldPanels.identifyAllUiItems(this.inventory) const sink = this.requireSink('npcMenu')
this.worldPanels.showAreaBanner( if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id')
count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品', sink.identifyAllAtNpc(npcId)
count > 0 ? `Identified ${count} items` : 'No unidentified items in inventory', sink.closeNpc(npcId)
) },
onCancel: () => {
if (npcId !== null) this.requireSink('npcMenu').closeNpc(npcId)
}, },
}, },
this.font, this.font,
@ -1180,10 +1369,16 @@ export class HudManager {
this.syncPublishedState() this.syncPublishedState()
return return
} else if (this.leftPanel !== 'none') { } 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: () => { onClose: () => {
this.leftPanel = 'none' 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) => { onWaypointTeleport: (act, slug, levelId) => {
if (this._commandSink) { if (this._commandSink) {
this._commandSink.teleportToLevel(levelId) this._commandSink.teleportToLevel(levelId)
@ -1260,51 +1455,11 @@ export class HudManager {
} }
} }
} }
}
// Vendor Repair or Sell/Quick-Sell from Player Inventory if (this.leftPanel === 'vendor' && !this.inventory.isIdentifyMode() && this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)) {
if (this.leftPanel === 'vendor') { this.syncPublishedState()
// D2Client.dll 0x6fb472d0: Right-clicking while in Buy/Sell/Repair cursor mode cancels back to idle return
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._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) { if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
@ -1319,9 +1474,7 @@ export class HudManager {
{ {
onOpenCube: () => this.toggleLeftPanel('cube'), onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
onDropGold: (amt) => { onGoldButton: () => this.openGoldDialog(this.leftPanel === 'stash' ? 3 : 0),
this.dropInventoryGold(amt, pt)
},
}, },
e.button === 2 ? 2 : 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 * 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. * 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') 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 // 11. Authentic Hand / Trade Cursor & Held Item at Hotspot
this.renderCursor(ctx, nowMs) this.renderCursor(ctx, nowMs)

View File

@ -64,7 +64,7 @@ export {
import { import {
INV_COLS, INV_COLS,
INV_ROWS, INV_ROWS,
PLAYER_GOLD_CAP, STASH_GOLD_CAP,
getInventoryGoldLimit, getInventoryGoldLimit,
findFreeGridSlot, findFreeGridSlot,
type EquipSlotId, type EquipSlotId,
@ -76,7 +76,7 @@ import {
export { export {
INV_COLS, INV_COLS,
INV_ROWS, INV_ROWS,
PLAYER_GOLD_CAP, STASH_GOLD_CAP,
getInventoryGoldLimit, getInventoryGoldLimit,
findFreeGridSlot, findFreeGridSlot,
type EquipSlotId, type EquipSlotId,
@ -127,6 +127,9 @@ export const INV_GOLD_BTN_BOUNDS = {
h: 18, h: 18,
} as const } 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 = { export const INV_GOLD_BOX_RECESS = {
x: 400 + 107, x: 400 + 107,
y: 60 + 392, 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 { function normalizeEquipSlotId(slotId: string): EquipSlotId {
if (slotId === 'head') return 'helm' if (slotId === 'head') return 'helm'
if (slotId === 'mainHand') return 'weapon1' if (slotId === 'mainHand') return 'weapon1'
@ -1854,18 +1829,6 @@ export class InventoryPanel {
return false 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)`: * Handle left-click on a grid cell `(col, row)`:
* - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`. * - 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 { clickGridCell(col: number, row: number): boolean {
if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) { if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) {
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
if (!this.cursorItem) { 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, p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
) )
if (!hit) { if (!hit) {
_lastInvClickCursorItem = null
return false return false
} }
this.gridItems = this.gridItems.filter(p => p !== hit) this.gridItems = this.gridItems.filter(p => p !== hit)
this.cursorItem = hit.item this.cursorItem = hit.item
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
@ -1910,7 +1870,6 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
} }
@ -1922,12 +1881,10 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) { if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) {
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
@ -1937,7 +1894,6 @@ export class InventoryPanel {
if (overlaps.length === 0) { if (overlaps.length === 0) {
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = null this.cursorItem = null
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (overlaps.length === 1) { if (overlaps.length === 1) {
@ -1954,7 +1910,6 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
} }
@ -1966,16 +1921,13 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems = this.gridItems.filter(p => p !== single)
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = single.item this.cursorItem = single.item
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
@ -2057,14 +2009,10 @@ export class InventoryPanel {
const existing = this.equipped[slotId] ?? null const existing = this.equipped[slotId] ?? null
if (!this.cursorItem) { if (!this.cursorItem) {
if (!existing) { if (!existing) {
_lastInvClickAction = 'none'
_lastInvClickCursorItem = null
return false return false
} }
delete this.equipped[slotId] delete this.equipped[slotId]
this.cursorItem = existing this.cursorItem = existing
_lastInvClickAction = 'unequip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (existing && canSocketUiItem(this.cursorItem, existing)) { if (existing && canSocketUiItem(this.cursorItem, existing)) {
@ -2075,19 +2023,13 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickAction = 'equip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (!this.canEquipItemInSlot(this.cursorItem, slotId)) { if (!this.canEquipItemInSlot(this.cursorItem, slotId)) {
_lastInvClickAction = 'none'
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
this.equipped[slotId] = this.cursorItem this.equipped[slotId] = this.cursorItem
this.cursorItem = existing this.cursorItem = existing
_lastInvClickAction = existing ? 'swap' : 'equip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
@ -2194,7 +2136,8 @@ export class InventoryPanel {
callbacks?: { callbacks?: {
onOpenCube?: () => void onOpenCube?: () => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | 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, button: 0 | 2 = 0,
externalContainers?: { externalContainers?: {
@ -2310,12 +2253,15 @@ export class InventoryPanel {
return true return true
} }
// 5. Gold Coin Button (`goldcoinbtn.dc6` at x=484..504) & Readout Area (x: 484..625, y: 450..485) // 5. Gold coin button (D2Client inventory gold button hit rect, panel-relative x 83..103, y 392..410).
if (logicalX >= INV_GOLD_BTN_BOUNDS.x && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { // D2 never drops/deposits locally: the button only opens the gold dialog; the amount is chosen there.
if (this.gold > 0) { if (
const dropAmt = Math.min(10_000, this.gold) logicalX >= INV_PANEL_ORIGIN.x + INV_GOLD_BTN_HIT.relX0 &&
callbacks?.onDropGold?.(dropAmt) 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 return true
} }

View File

@ -40,74 +40,10 @@ import {
import type { Item } from '../../common/items/items.ts' import type { Item } from '../../common/items/items.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import { D2Rng } from '../../common/rng/d2-rng.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' 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 { import {
type TownNpcServiceDescriptor, type TownNpcServiceDescriptor,
type VendorNpcId, type VendorNpcId,
@ -117,9 +53,6 @@ import {
getLocalizedNpcGreetings, getLocalizedNpcGreetings,
} from '../../common/units/npc-table.ts' } from '../../common/units/npc-table.ts'
import type { VendorTabId } from '../../common/items/vendor-generator.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_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 { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../../common/units/monsters-data.ts'
import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts' import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts'
@ -129,6 +62,7 @@ import {
} from '../../common/items/runeword-socket.ts' } from '../../common/items/runeword-socket.ts'
export { export {
STASH_GOLD_CAP,
GRID_ITEM_BG_BLUE, GRID_ITEM_BG_BLUE,
GRID_ITEM_BG_GREEN, GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED, 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_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_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const
export const STASH_GOLD_CAP = 2_500_000 /** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */
export const PLAYER_GOLD_CAP = 2_500_000 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 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' }, { id: 'misc', index: 3, xOffset: 240, width: 79, height: 31, centerX: 281, textY: 20, strIdx: 4039, labelZh: '其他', labelEn: 'Misc' },
] as const ] 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<number> = new Set([147, 199, 254, 405, 512, 514])
export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel'
export interface NpcMenuOption { export interface NpcMenuOption {
@ -710,23 +652,15 @@ export class WorldPanelsHud {
lang: TblLang = getTblLang() lang: TblLang = getTblLang()
selectedActTab = 1 selectedActTab = 1
selectedQuestIdx = 0 selectedQuestIdx = 0
/** Server stat 15 (`goldbank`); 0 until received. */ /** Server stat 15 (`goldbank`), written by `HudModel` only; 0 until received (absent stat = 0). */
private _stashGold = 0 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
}
private _starterStashCleared = false private _starterStashCleared = false
private readonly vendorRng = new D2Rng(0x56454e44)
private vendorBuySeq = 0
cubeTransmuteCount = 0 cubeTransmuteCount = 0
escapeMenuOpen = false escapeMenuOpen = false
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
npcMenu: { npcMenu: {
/** Server unit id of the NPC (from `NpcInit`); `null` for menus opened without a server unit. */
npcId: number | null
npcName: string npcName: string
x: number x: number
y: number y: number
@ -737,14 +671,6 @@ export class WorldPanelsHud {
hoveredOptionIdx?: number | null | undefined hoveredOptionIdx?: number | null | undefined
} | null = null } | 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 { private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void {
if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) { if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) {
this._starterStashCleared = true this._starterStashCleared = true
@ -755,7 +681,13 @@ export class WorldPanelsHud {
activeVendorDescriptor: TownNpcServiceDescriptor | null = null activeVendorDescriptor: TownNpcServiceDescriptor | null = null
vendorMode: 'trade' | 'gamble' = 'trade' vendorMode: 'trade' | 'gamble' = 'trade'
activeVendorTab: VendorTabId = 'armor' 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()`. */ /** Synced from the server character level by `HudManager.syncPlayerContext()`. */
vendorCharLevel = 0 vendorCharLevel = 0
vendorPlayerStats: PlayerItemRequirementContext = { vendorPlayerStats: PlayerItemRequirementContext = {
@ -980,11 +912,13 @@ export class WorldPanelsHud {
descriptor: TownNpcServiceDescriptor, descriptor: TownNpcServiceDescriptor,
screenX = 400, screenX = 400,
screenY = 220, screenY = 220,
npcId: number | null = null,
): void { ): void {
const options = this.buildNpcMenuOptions(descriptor) const options = this.buildNpcMenuOptions(descriptor)
// D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open. // D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open.
// Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580). // Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580).
this.npcMenu = { this.npcMenu = {
npcId,
npcName: descriptor.displayName, npcName: descriptor.displayName,
x: Math.round(screenX), x: Math.round(screenX),
y: Math.round(screenY), y: Math.round(screenY),
@ -1003,68 +937,80 @@ export class WorldPanelsHud {
return true return true
} }
openVendorForNpc( /**
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, * Show the store the server filled with `0x9C AddToShop` items (`ClientWorld.containers.shop`).
mode: 'trade' | 'gamble' = 'trade', * `pages`: one entry per shop item with its bitstream `storePage` (1..4 -> tab index 0..3, D2Client
cLvl = 85, * 0x6fb91178 tab table). Re-applying the same store keeps the selected tab unless it became empty
difficulty: 0 | 1 | 2 | 'normal' | 'nightmare' | 'hell' = 0, * (D2Client 0x6fb3f710 advances to the next non-empty tab).
): boolean { */
const descriptor = setVendorShop(p: {
typeof descriptorOrName === 'object' readonly npcId: number
? descriptorOrName readonly descriptor: TownNpcServiceDescriptor
: typeof descriptorOrName === 'number' readonly mode: 'trade' | 'gamble'
? getTownNpcDescriptorByVendorId(descriptorOrName) readonly items: readonly { readonly storePage: number; readonly placement: GridPlacement }[]
: getTownNpcDescriptorByName(descriptorOrName) }): void {
if (!descriptor || descriptor.vendorId === null) return false // 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<VendorTabId, GridPlacement[]> = { 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 = /** Forget the store (NpcCancel sent: D2Client frees the store units, the server sends no RemoveFromShop). */
difficulty === 'hell' || difficulty === 2 clearVendorShop(): void {
? 2 this.activeVendorDescriptor = null
: difficulty === 'nightmare' || difficulty === 1 this.vendorNpcId = null
? 1 this.vendorMode = 'trade'
: 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
this.vendorTradeState = 'idle' this.vendorTradeState = 'idle'
this.vendorRepairAllCost = null
this.hoveredVendorButtonSlot = null this.hoveredVendorButtonSlot = null
this.pressedMomentarySlot = null this.pressedMomentarySlot = null
this.hoveredVendorItem = null this.hoveredVendorItem = null
this.vendorTabPlacements = { armor: [], weapons1: [], weapons2: [], misc: [] }
this.gamblePlacements = []
this.activeVendorTab = 'armor'
}
const tables = getEmbeddedDropTables() /** Repair-all total set by `HudModel`; throws when the store has a Repair All button but no cost was provided. */
const vendorId = descriptor.vendorId requireRepairAllCost(): number {
if (this.vendorRepairAllCost === null) {
if (mode === 'gamble') { throw new Error('WorldPanelsHud: Repair All cost not provided (HudModel needs a VendorRepairAllFn)')
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
} }
return this.vendorRepairAllCost
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
} }
getActiveVendorPlacements(): GridPlacement[] { getActiveVendorPlacements(): GridPlacement[] {
@ -1074,376 +1020,6 @@ export class WorldPanelsHud {
return this.vendorTabPlacements[this.activeVendorTab] ?? [] 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' */ /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>() private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null private cachedQuestsAtlasImg: HTMLImageElement | null = null
@ -1962,36 +1538,6 @@ export class WorldPanelsHud {
return true 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 { handleMouseMove(logicalX: number, logicalY: number): void {
this.hoveredStashItem = null this.hoveredStashItem = null
this.hoveredCubeItem = null this.hoveredCubeItem = null
@ -2140,6 +1686,8 @@ export class WorldPanelsHud {
callbacks: { callbacks: {
onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void
onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void
/** Menu dismissed (`cancel` option or click outside): the caller sends `NpcCancel` (0x30). */
onCancel: () => void
}, },
font?: Pick<D2FontRenderer, 'measureText'> | null, font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean { ): boolean {
@ -2165,6 +1713,7 @@ export class WorldPanelsHud {
logicalY > layout.top + layout.height logicalY > layout.top + layout.height
) { ) {
this.npcMenu = null this.npcMenu = null
callbacks.onCancel()
return true return true
} }
@ -2186,6 +1735,7 @@ export class WorldPanelsHud {
callbacks.onIdentifyAll(desc) callbacks.onIdentifyAll(desc)
} else { } else {
this.npcMenu = null this.npcMenu = null
callbacks.onCancel()
} }
return true return true
} }
@ -2219,6 +1769,14 @@ export class WorldPanelsHud {
hitItem: UiInventoryItem | null, hitItem: UiInventoryItem | null,
isRightClick: boolean, isRightClick: boolean,
) => void ) => 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 { ): boolean {
if (logicalX > 400) return false if (logicalX > 400) return false
@ -2358,6 +1916,19 @@ export class WorldPanelsHud {
return true return true
} }
} else if (kind === 'stash') { } 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 // Stash 6x8 Grid
if ( if (
logicalX >= STASH_GRID_ORIGIN.x && logicalX >= STASH_GRID_ORIGIN.x &&
@ -2453,10 +2024,8 @@ export class WorldPanelsHud {
} else if (btnKind === 'repairAll') { } else if (btnKind === 'repairAll') {
this.vendorTradeState = 'idle' this.vendorTradeState = 'idle'
this.pressedMomentarySlot = hitSlot this.pressedMomentarySlot = hitSlot
const inv = callbacks.inventory ?? this.activePlayerInventory if (!callbacks.onVendorRepairAll) throw new Error('handleLeftDockClick: Repair All needs onVendorRepairAll')
if (inv) { callbacks.onVendorRepairAll()
this.repairAllUiItems(inv)
}
} else if (btnKind === 'close') { } else if (btnKind === 'close') {
this.vendorTradeState = 'idle' this.vendorTradeState = 'idle'
this.pressedMomentarySlot = hitSlot this.pressedMomentarySlot = hitSlot
@ -2480,21 +2049,20 @@ export class WorldPanelsHud {
const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx)
const inv = callbacks.inventory ?? this.activePlayerInventory const inv = callbacks.inventory ?? this.activePlayerInventory
if (inv) { // Holding a cursor item over the store grid sells it (server answers with 0x2A + item/stat packets).
// If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! if (inv?.cursorItem) {
if (inv.cursorItem) { if (!callbacks.onVendorSell) throw new Error('handleLeftDockClick: vendor sell needs onVendorSell')
const held = inv.cursorItem callbacks.onVendorSell(inv.cursorItem)
const res = this.sellToActiveVendor(held, inv) return true
if (res.ok) { }
inv.cursorItem = null // Buy works in 'idle' or 'buy' state; never buy while the sell / repair cursor is armed.
} if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') {
return true const hit = this.getActiveVendorPlacements().find(
} p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
// Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state if (hit) {
if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { if (!callbacks.onVendorBuy) throw new Error('handleLeftDockClick: vendor buy needs onVendorBuy')
this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick)) callbacks.onVendorBuy(hit.item, Boolean(callbacks.isShiftClick))
this.handleMouseMove(logicalX, logicalY)
} }
} }
return true return true
@ -2652,14 +2220,14 @@ export class WorldPanelsHud {
} }
} else if (kind === 'stash') { } else if (kind === 'stash') {
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) 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', font: 'font8',
color: 'gold', color: 'gold',
align: 'center', align: 'center',
}) })
font.drawText( font.drawText(
ctx, ctx,
isEn ? `Stash Gold: ${this.stashGold.toLocaleString()}` : `储物箱金币: ${this.stashGold.toLocaleString()}`, `${requireTbl('strGoldInStash', this.lang)} ${this.stashGold}`,
ox + 160, ox + 160,
oy + 61, oy + 61,
{ {
@ -2918,9 +2486,7 @@ export class WorldPanelsHud {
if (spec.enabled) { if (spec.enabled) {
const hoverText = const hoverText =
btnKind === 'repairAll' btnKind === 'repairAll'
? isEn ? requireTbl('patchstring#95', this.lang).replace('%d', String(this.requireRepairAllCost()))
? `${spec.labelEn}: ${this.getRepairAllCost(assets.inventory)}`
: `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}`
: isEn : isEn
? spec.labelEn ? spec.labelEn
: spec.labelZh : spec.labelZh
@ -3290,35 +2856,16 @@ export class WorldPanelsHud {
} }
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) // 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) { if (item.buyCost !== undefined) {
const label = item.isGambleMystery lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font8' })
? (isEn ? 'Gamble Cost' : '赌博价格')
: (isEn ? 'Buy Cost' : '购买价格')
const suffix = isEn ? ' Gold' : ' 金币'
lines.push({ text: `${label}: ${item.buyCost.toLocaleString()}${suffix}`, color: 'gold', font: 'font8' })
} else if (vendorOpen && this.activeVendorDescriptor) { } else if (vendorOpen && this.activeVendorDescriptor) {
if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) {
const repPrice = this.getPlayerItemRepairPrice(item) if (item.repairCost !== undefined && item.repairCost > 0) {
if (repPrice > 0) { lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font8' })
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',
})
} }
} else if (item.sellCost !== undefined) {
lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font8' })
} }
} }

View File

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

View File

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

View File

@ -295,6 +295,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Baal Throne": "Baal", "Baal Throne": "Baal",
"BaalColdMage": "Death Mage", "BaalColdMage": "Death Mage",
"Baals Minion": "Minion of Destruction", "Baals Minion": "Minion of Destruction",
"Back": "cancel",
"Baezil's Vortex": "Baezil's Vortex", "Baezil's Vortex": "Baezil's Vortex",
"Bahamut's": "Bahamut's", "Bahamut's": "Bahamut's",
"Balrog": "Balrog", "Balrog": "Balrog",
@ -1779,6 +1780,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Realgar": "Realgar", "Realgar": "Realgar",
"ReanimatedHorde": "Reanimated Horde", "ReanimatedHorde": "Reanimated Horde",
"Red": "Red", "Red": "Red",
"Repair": "Repair cost: ",
"Resonant": "Resonant", "Resonant": "Resonant",
"Returned": "Returned", "Returned": "Returned",
"ReturnedArcher": "Returned Archer", "ReturnedArcher": "Returned Archer",
@ -2018,6 +2020,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Scintillating": "Scintillating", "Scintillating": "Scintillating",
"Scorching": "Condensing", "Scorching": "Condensing",
"Screaming": "Screaming", "Screaming": "Screaming",
"Sell": "Sell value: ",
"Sensei's": "Sensei's", "Sensei's": "Sensei's",
"Septic": "Septic", "Septic": "Septic",
"Seraph's Hymn": "Seraph's Hymn", "Seraph's Hymn": "Seraph's Hymn",
@ -2647,6 +2650,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Trang-Oul's Guise": "Trang-Oul's Guise", "Trang-Oul's Guise": "Trang-Oul's Guise",
"Trang-Oul's Scales": "Trang-Oul's Scales", "Trang-Oul's Scales": "Trang-Oul's Scales",
"Trang-Oul's Wing": "Trang-Oul's Wing", "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", "TrappedSoul": "Trapped Soul",
"Travincal": "Travincal", "Travincal": "Travincal",
"Treads of Cthon": "Treads of Cthon", "Treads of Cthon": "Treads of Cthon",
@ -2911,6 +2921,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"collar": "Collar", "collar": "Collar",
"compellingorb": "Compelling Orb", "compellingorb": "Compelling Orb",
"cord": "Cord", "cord": "Cord",
"cost": "Cost: ",
"cowl": "Cowl", "cowl": "Cowl",
"cqv": "Bolts", "cqv": "Bolts",
"crack": "Crack", "crack": "Crack",
@ -3471,6 +3482,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"of the Wolf": "of the Wolf", "of the Wolf": "of the Wolf",
"of the Wraith": "of the Wraith", "of the Wraith": "of the Wraith",
"of the Yeti": "of the Yeti", "of the Yeti": "of the Yeti",
"ok": "ok",
"opl": "Fulminating Potion", "opl": "Fulminating Potion",
"opm": "Exploding Potion", "opm": "Exploding Potion",
"ops": "Oil Potion", "ops": "Oil Potion",
@ -3782,7 +3794,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"strBSArmor": "Armor", "strBSArmor": "Armor",
"strBSMisc": "Misc", "strBSMisc": "Misc",
"strBSWeapons": "Weapons", "strBSWeapons": "Weapons",
"strBankGoldDeposit": "How much Gold would you like to deposit?",
"strBankGoldWithdraw": "How much Gold would you like to withdraw?",
"strClose": "Close", "strClose": "Close",
"strDropGoldHowMuch": "How much Gold would you like to drop?",
"strGoldInStash": "Gold in Stash:", "strGoldInStash": "Gold in Stash:",
"strGoldLabel": "Gold", "strGoldLabel": "Gold",
"strModAllResistances": "All Resistances +%d", "strModAllResistances": "All Resistances +%d",
@ -4000,7 +4015,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"uniquestoneskin": "Stone Skin", "uniquestoneskin": "Stone Skin",
"uniquemultishot": "Multiple Shots", "uniquemultishot": "Multiple Shots",
"UniqueAuraEnchanted": "Aura Enchanted", "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<Record<string, string>> = { export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
@ -4261,6 +4277,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Baal Throne": "巴尔", "Baal Throne": "巴尔",
"BaalColdMage": "死亡法师", "BaalColdMage": "死亡法师",
"Baals Minion": "毁灭的仆从", "Baals Minion": "毁灭的仆从",
"Back": "取消",
"Baezil's Vortex": "贝西尔的漩涡", "Baezil's Vortex": "贝西尔的漩涡",
"Balrog": "巴羅格", "Balrog": "巴羅格",
"Bane": "剋星", "Bane": "剋星",
@ -5611,6 +5628,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Razortine": "剃刀之叉", "Razortine": "剃刀之叉",
"ReanimatedHorde": " ", "ReanimatedHorde": " ",
"Red": "紅之", "Red": "紅之",
"Repair": "修復費用: ",
"Returned": "復活的怪物", "Returned": "復活的怪物",
"ReturnedArcher": "復生的弓箭手", "ReturnedArcher": "復生的弓箭手",
"ReturnedMage": "復生的法師", "ReturnedMage": "復生的法師",
@ -5682,6 +5700,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Sazabi's Mental Sheath": "沙撒璧的精神之鞘", "Sazabi's Mental Sheath": "沙撒璧的精神之鞘",
"Scarab": "聖甲蟲", "Scarab": "聖甲蟲",
"Schaefer's Hammer": "史恰佛之锤", "Schaefer's Hammer": "史恰佛之锤",
"Sell": "出售價格: ",
"Seraph's Hymn": "炽天使之韵", "Seraph's Hymn": "炽天使之韵",
"Serpent Lord": "海蛇之王", "Serpent Lord": "海蛇之王",
"Serpent's": "海蛇之", "Serpent's": "海蛇之",
@ -6249,6 +6268,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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": "你沒有足夠的金錢。",
"TransactionResults11": "她現在無法過來,請等一會再試。",
"TransactionResults4": "你沒有足夠的放置空間。",
"TransactionResults5": "你的傭兵數量已經到達最高人數。",
"TransactionResults6": "這項物品剛剛交易掉了。",
"TransactionResults8": "有些事指出你並非擁有該項物品。",
"TransactionResults9": "我無法完成那個要求。",
"TrappedSoul": "被困的靈魂", "TrappedSoul": "被困的靈魂",
"Travincal": "崔凡克", "Travincal": "崔凡克",
"Treads of Cthon": "凱松的征服", "Treads of Cthon": "凱松的征服",
@ -6495,6 +6521,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"collar": "項圈", "collar": "項圈",
"compellingorb": "強制球體", "compellingorb": "強制球體",
"cord": "捆綁", "cord": "捆綁",
"cost": "花費:",
"cowl": "蒙頭斗蓬", "cowl": "蒙頭斗蓬",
"cqv": "十字弓彈", "cqv": "十字弓彈",
"crack": "破裂", "crack": "破裂",
@ -6858,6 +6885,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"of the Vampire": "吸血鬼的", "of the Vampire": "吸血鬼的",
"of the Wolf": "狼的", "of the Wolf": "狼的",
"of the Wraith": "死靈", "of the Wraith": "死靈",
"ok": "好",
"opl": "猛爆性葯劑", "opl": "猛爆性葯劑",
"opm": "爆炸葯劑", "opm": "爆炸葯劑",
"ops": "油", "ops": "油",
@ -7169,7 +7197,10 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"strBSArmor": "裝甲", "strBSArmor": "裝甲",
"strBSMisc": "其他", "strBSMisc": "其他",
"strBSWeapons": "武器", "strBSWeapons": "武器",
"strBankGoldDeposit": "你打算存入多少數量的金幣?",
"strBankGoldWithdraw": "你打算提出多少數量的金幣?",
"strClose": "關閉", "strClose": "關閉",
"strDropGoldHowMuch": "你想要丟下多少數量的金幣?",
"strGoldInStash": "儲藏起來的金錢:", "strGoldInStash": "儲藏起來的金錢:",
"strGoldLabel": "金幣", "strGoldLabel": "金幣",
"strModAllResistances": "所有抗性 +%d", "strModAllResistances": "所有抗性 +%d",
@ -7387,7 +7418,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"uniquestoneskin": "皮膚硬化", "uniquestoneskin": "皮膚硬化",
"uniquemultishot": "多重射擊", "uniquemultishot": "多重射擊",
"UniqueAuraEnchanted": "靈氣加強", "UniqueAuraEnchanted": "靈氣加強",
"strethereal": "无形的 (无法修复)" "strethereal": "无形的 (无法修复)",
"patchstring#95": "修復所有裝備:%d"
} }
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = { export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = {

View File

@ -13,9 +13,10 @@ export const INV_COLS = 10
export const INV_ROWS = 4 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`). * Authentic Diablo II 1.13c Inventory Gold Limit (`D2Common/src/Units/Units.cpp:3103`).

View File

@ -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 type { DropTables } from './drop-tables.ts'
import { ItemFlag, ItemQuality, type Item, type ItemBase, type RolledItemProp } from './items.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 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 { export const enum TransactionType {
BUY = 0, BUY = 0,
@ -14,15 +36,6 @@ export const enum TransactionType {
REPAIR = 3, 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 { export interface TransactionCostOptions {
readonly item: Item readonly item: Item
readonly transactionType: TransactionType readonly transactionType: TransactionType
@ -30,78 +43,79 @@ export interface TransactionCostOptions {
readonly cLvl?: number | undefined readonly cLvl?: number | undefined
readonly vendorId?: VendorNpcId | undefined readonly vendorId?: VendorNpcId | undefined
readonly npcKey?: string | 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 actIndex?: 0 | 1 | 2 | 3 | 4 | undefined
readonly completedQuests?: ReadonlySet<number> | undefined readonly completedQuests?: ReadonlySet<number> | undefined
readonly reducedPricesPercent?: number | undefined readonly reducedPricesPercent?: number | undefined
readonly tables: DropTables 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<ItemTypeTable['byCode']['get']> {
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 { export function isClassSpecificItemBase(base: ItemBase, itemTypes: ItemTypeTable): boolean {
const startType = getItemTypeCode(base).trim() const def = primaryTypeOf(base, itemTypes)
const queue: string[] = [startType, ...(base.tags ?? [])] return Boolean(def?.class && def.class.trim().length > 0)
const visited = new Set<string>() }
while (queue.length > 0) { /** Whether the primary ItemType has StaffMods (`nStaffMods != 7`), as 0x6fd75970 checks. */
const code = queue.shift()!.trim() export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean {
if (!code || visited.has(code.toLowerCase())) continue const def = primaryTypeOf(base, itemTypes)
visited.add(code.toLowerCase()) return Boolean(def?.staffMods && def.staffMods.trim().length > 0)
}
const def = itemTypes.byCode.get(code) /** Primary ItemType `Quiver` set (0x6fd74e60). */
if (!def) continue export function isQuiverItem(base: ItemBase): boolean {
if (def.class && def.class.trim().length > 0) { const t = getCanonicalTransactionCostTables().itemTypes.get(txt(base).type)
return true if (!t) throw new Error(`item-cost: unknown ItemTypes code '${txt(base).type}'`)
} return t.quiver
if (def.equiv1) queue.push(def.equiv1) }
if (def.equiv2) queue.push(def.equiv2)
} /** `book` type, with equivalences (0x6fd74430). */
return false 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<string>()
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 { export function isItemRepairable(item: Item): boolean {
const isEthereal = if (isEthereal(item)) return false
Boolean(item.flags?.ethereal) ||
Boolean((item as any).ethereal) ||
((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0
if (isEthereal) return false
if (isQuiverItem(item.base) || isBookItem(item.base)) 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 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 const curDur = item.durability ?? maxDur
if (maxDur > 0 && curDur < maxDur) { if (maxDur > 0 && curDur < maxDur) {
return true return true
} }
// Also repairable if stackable throwing weapon below maxStack // A stackable throwing weapon below its max stack can be refilled.
if ((item.base as any).stackable && item.base.kind === 'weapon') { if (rec.stackable && item.base.kind === 'weapon') {
const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 const maxStack = maxStackOf(item.base)
const qty = item.quantity ?? item.stack ?? maxStack const qty = item.quantity ?? item.stack ?? maxStack
if (qty < maxStack) return true 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')) { if (item.rolledProps?.some(p => p.code.toLowerCase() === 'charged')) {
return true return true
} }
@ -133,8 +148,8 @@ export function isItemRepairable(item: Item): boolean {
} }
/** /**
* Implements `ITEMS_CalculateAdditionalCostsForItemSkill` (`0x6FD9CB50`) * StaffMods pricing (`0x6fd75970`) for single-skill staffmods
* for single-skill staffmods (`STAT_ITEM_SINGLESKILL`) on StaffMods-eligible item types. * (`STAT_ITEM_SINGLESKILL`) on StaffMods item types.
*/ */
export function calculateAdditionalCostsForItemSkill( export function calculateAdditionalCostsForItemSkill(
item: Item, item: Item,
@ -156,15 +171,18 @@ export function calculateAdditionalCostsForItemSkill(
return { sellCost, buyCost, repCost } 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 nSell = 0
let nBuy = 0 let nBuy = 0
let nRep = 0 let nRep = 0
for (const sm of staffMods) { for (const sm of staffMods) {
const skillId = Number(sm.param) const skillId = Number(sm.param)
const slvl = Math.max(1, Math.trunc(sm.value)) const slvl = Math.trunc(sm.value)
const skillRec = tables.skillCosts?.get(skillId) const skillRec = skillCosts.get(skillId)
if (!skillRec) continue if (!skillRec) throw new Error(`item-cost: no Skills.txt cost record for skill ${skillId}`)
const factor = 2 * slvl - 1 const factor = 2 * slvl - 1
nSell += factor * (skillRec.costAdd + Math.trunc((sellCost * skillRec.costMult) / 1024)) 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)) nRep += factor * (skillRec.costAdd + Math.trunc((repCost * skillRec.costMult) / 1024))
} }
const effDiv = Math.max(1, divisor)
return { return {
sellCost: sellCost + Math.trunc(nSell / effDiv), sellCost: sellCost + Math.trunc(nSell / divisor),
buyCost: buyCost + Math.trunc(nBuy / effDiv), buyCost: buyCost + Math.trunc(nBuy / divisor),
repCost: repCost + Math.trunc(nRep / effDiv), repCost: repCost + Math.trunc(nRep / divisor),
} }
} }
/** /**
* Implements `ITEMS_CalculateAdditionalCostsForBonusStats` (`0x6FD9ACE0`). * Bonus-stat pricing (`0x6fd78850`): applies `ItemStatCost.txt`
* Iterates resolved bonus stats on the item and applies `ItemStatCost.txt` * (`Encode`, `Add`, `Multiply`) or `Skills.txt` (`CostAdd`, `CostMult`) to each
* (`Encode`, `Add`, `Multiply`, `Divide`) or `Skills.txt` (`CostAdd`, `CostMult`). * rolled stat. `Divide` is not read by 1.13c.
*/ */
export function calculateAdditionalCostsForBonusStats( export function calculateAdditionalCostsForBonusStats(
item: Item, item: Item,
@ -195,15 +212,13 @@ export function calculateAdditionalCostsForBonusStats(
): { sellCost: number; buyCost: number; repCost: number } { ): { sellCost: number; buyCost: number; repCost: number } {
const propertiesTable = tables.properties const propertiesTable = tables.properties
const statCostTable = tables.itemStatCost const statCostTable = tables.itemStatCost
if (!statCostTable) { if (!statCostTable) throw new Error('item-cost: DropTables.itemStatCost is required for bonus-stat pricing')
return { sellCost, buyCost, repCost }
}
let nSell = 0 let nSell = 0
let nBuy = 0 let nBuy = 0
let nRep = 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<RolledItemProp>(item.staffMods ?? []) const staffModSet = new Set<RolledItemProp>(item.staffMods ?? [])
const props = item.rolledProps ?? [] const props = item.rolledProps ?? []
@ -211,14 +226,12 @@ export function calculateAdditionalCostsForBonusStats(
readonly statId: number readonly statId: number
readonly param: number readonly param: number
readonly value: number readonly value: number
readonly propCode?: string
readonly min?: number
readonly max?: number
} }
const entries: StatBonusEntry[] = [] 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) { for (const prop of props) {
if (staffModSet.has(prop)) continue if (staffModSet.has(prop)) continue
if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) { if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) {
@ -237,33 +250,27 @@ export function calculateAdditionalCostsForBonusStats(
prop.value, prop.value,
statCostTable, statCostTable,
) )
if (resolved.length > 0) { for (const r of resolved) {
for (const r of resolved) { entries.push({ statId: r.statId, param: r.param ?? numParam, value: r.value })
entries.push({
statId: r.statId,
param: r.param ?? numParam,
value: r.value,
propCode: prop.code,
min: prop.min,
max: prop.max,
})
}
} }
} }
} else if (item.statList && item.statList.length > 0) { } else if (item.statList && item.statList.length > 0) {
for (const s of item.statList) { for (const s of item.statList) {
entries.push({ entries.push({ statId: s.statId, param: s.param ?? 0, value: s.value })
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) { for (const entry of entries) {
const statRec = statCostTable.getById(entry.statId) const statRec = statCostTable.getById(entry.statId)
if (!statRec) continue if (!statRec) throw new Error(`item-cost: no ItemStatCost record for stat ${entry.statId}`)
// STAT_ITEM_SINGLESKILL (107) on staffMod items is priced by calculateAdditionalCostsForItemSkill // STAT_ITEM_SINGLESKILL (107) on StaffMods items is priced by calculateAdditionalCostsForItemSkill.
if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) { if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) {
continue continue
} }
@ -272,47 +279,45 @@ export function calculateAdditionalCostsForBonusStats(
switch (statRec.encode) { switch (statRec.encode) {
case 1: { case 1: {
const skillRec = tables.skillCosts?.get(entry.param) const skillRec = requireSkill(entry.param)
if (skillRec) { nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024)
nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096)
nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024)
nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024)
}
break break
} }
case 2: case 2:
case 3: { case 3: {
const skillId = entry.param > 63 ? entry.param >> 6 : entry.param const skillRec = requireSkill(entry.param >> SKILL_PARAM_SHIFT)
const slvl = entry.param > 63 ? Math.max(1, entry.param & 0x3f) : Math.max(1, entry.max ?? 1) const slvl = entry.param & SKILL_PARAM_MASK
const skillRec = tables.skillCosts?.get(skillId) nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024)
if (skillRec) { nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096)
nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024)
nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096)
nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024)
}
break break
} }
default: { default: {
if (statRec.multiply > 0 || statRec.add > 0) { nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024)
nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024)
nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024)
nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024)
}
break break
} }
} }
} }
const effDiv = Math.max(1, divisor)
return { return {
sellCost: sellCost + Math.trunc(nSell / effDiv), sellCost: sellCost + Math.trunc(nSell / divisor),
buyCost: buyCost + Math.trunc(nBuy / effDiv), buyCost: buyCost + Math.trunc(nBuy / divisor),
repCost: repCost + Math.trunc(nRep / effDiv), 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: { export function calculateGambleCost(options: {
readonly item: Item readonly item: Item
@ -320,87 +325,35 @@ export function calculateGambleCost(options: {
readonly reducedPricesPercent?: number | undefined readonly reducedPricesPercent?: number | undefined
readonly tables: DropTables readonly tables: DropTables
}): number { }): number {
const { item, cLvl, reducedPricesPercent = 0, tables } = options const { item, cLvl, reducedPricesPercent = 0 } = options
const normCode = item.base.normcode || item.base.id let cost = computeGambleBaseCost113c(item.base.id, Math.trunc(cLvl), getCanonicalTransactionCostTables())
const normBase = tables.getBase(normCode) ?? item.base const rp = capReducedPrices(reducedPricesPercent)
const codeLower = normBase.id.toLowerCase() if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0
return cost
}
let nCost = 0 /** Row index of the rolled AutoMagic affix. drop-tables.ts spreads `rolledAutoMagic` onto the item without a typed field. */
if (codeLower === 'rin' || codeLower === 'amu') { function rolledAutoMagicRow(item: Item): number | undefined {
nCost = normBase.gambleCost ?? (codeLower === 'rin' ? 50000 : 63000) if (!('rolledAutoMagic' in item)) return undefined
} else { const rolled: unknown = item.rolledAutoMagic
const minStack = Number((normBase as any).minstack) || 0 if (rolled === undefined) return undefined
const maxStack = Number((normBase as any).maxstack) || 0 const parsed = rolled as Partial<RolledAutoMagic>
const v27 = Math.max(1, Math.trunc((minStack + maxStack) / 2)) if (!parsed.entry || typeof parsed.entry.id !== 'number') throw new Error('item-cost: malformed rolledAutoMagic')
return parsed.entry.id
}
let v23 = 0 function resolveNpcRecord(tables: DropTables, vendorId?: VendorNpcId, npcKey?: string): NpcRecord {
let nUberCost = 0 if (!tables.npcTable) throw new Error('item-cost: DropTables.npcTable is required')
if (normBase.ubercode && normBase.ubercode !== '0' && normBase.ubercode !== 'xxx') { const rec =
const uberBase = tables.getBase(normBase.ubercode) (npcKey ? tables.npcTable.get(npcKey) : undefined) ??
if (uberBase) { (vendorId !== undefined ? tables.npcTable.getByVendorId(vendorId) : undefined)
const v30 = 100 * (cLvl - uberBase.level) if (!rec) throw new Error(`item-cost: no Npc.txt record for npcKey=${npcKey ?? '-'} vendorId=${vendorId ?? '-'}`)
v23 = Math.max(0, Math.trunc(v30 / 2) + 1) return rec
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)
} }
/** /**
* Resolves the NpcRecord for a vendor / town NPC. * LEGACY transaction pricing over `Item` (structured after 0x6fd78e80).
*/ * Prefer `computeTransactionCost113c`.
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`).
*/ */
export function calculateTransactionCost(options: TransactionCostOptions): number { export function calculateTransactionCost(options: TransactionCostOptions): number {
const { const {
@ -410,7 +363,6 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
cLvl = 1, cLvl = 1,
vendorId, vendorId,
npcKey, npcKey,
actIndex,
completedQuests, completedQuests,
reducedPricesPercent = 0, reducedPricesPercent = 0,
tables, tables,
@ -428,17 +380,13 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
} }
if (transactionType === TransactionType.GAMBLE) { if (transactionType === TransactionType.GAMBLE) {
return calculateGambleCost({ return calculateGambleCost({ item, cLvl, reducedPricesPercent, tables })
item,
cLvl,
reducedPricesPercent,
tables,
})
} }
const rec = txt(item.base)
const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1)) const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1))
const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) const rp = capReducedPrices(reducedPricesPercent)
const baseCost = item.base.cost ?? item.base.value ?? 1 const baseCost = rec.cost
let nSellCost = 0 let nSellCost = 0
let nBuyCost = 0 let nBuyCost = 0
@ -450,20 +398,20 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0 ((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0
if (isEar) { 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 nSellCost = baseCost * earLvl
nBuyCost = nSellCost nBuyCost = nSellCost
} else if (isBookItem(item.base)) { } else if (isBookItem(item.base)) {
const bookRec = tables.booksTable?.getByBookCode(item.base.id) const bookRec = tables.booksTable?.getByBookCode(item.base.id)
const costPerCharge = bookRec?.costPerCharge ?? 80 if (!bookRec) throw new Error(`item-cost: no Books.txt record for '${item.base.id}'`)
nSellCost = baseCost + nQuantity * costPerCharge nSellCost = baseCost + nQuantity * bookRec.costPerCharge
nBuyCost = nSellCost nBuyCost = nSellCost
} else if (isQuiverItem(item.base)) { } else if (isQuiverItem(item.base)) {
nSellCost = Math.trunc((nQuantity * baseCost) / 1024) nSellCost = Math.trunc((nQuantity * baseCost) / 1024)
nBuyCost = nSellCost nBuyCost = nSellCost
const maxStack = Math.min(511, item.base.maxStack || 350) nRepCost = Math.trunc((maxStackOf(item.base) * baseCost) / 1024)
nRepCost = Math.trunc((maxStack * baseCost) / 1024) } else if (!rec.stackable) {
} else if (!(item.base as any).stackable) {
nSellCost = baseCost nSellCost = baseCost
nBuyCost = nSellCost nBuyCost = nSellCost
nRepCost = nSellCost nRepCost = nSellCost
@ -471,18 +419,19 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
nSellCost = baseCost nSellCost = baseCost
nBuyCost = nSellCost nBuyCost = nSellCost
nRepCost = nSellCost nRepCost = nSellCost
const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 const maxStack = maxStackOf(item.base)
nDivisor = Math.max(1, Math.min(511, maxStack)) nDivisor = maxStack > 1 ? maxStack : 1
} }
// Armor defense scaling (lines 2119-2131 of Items.cpp) // 0x6fd7900b: armor scales by base defense over max defense
if (item.base.kind === 'armor') { if (isItemCodeOfType113c(getCanonicalTransactionCostTables(), item.base.id, 'armo')) {
const minAc = Number((item.base as any).minac) || 0 const minAc = rec.minAc
const maxAc = Number((item.base as any).maxac) || item.base.defense || 0 const maxAc = rec.maxAc
if (maxAc > 0 && maxAc - minAc !== -1) { if (maxAc !== 0 && maxAc - minAc !== -1) {
// Base unit STAT_ARMORCLASS (before ac% bonus stat) // 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 acPct = item.stats?.['ac%'] ?? 0
const rawDef = item.defense ?? item.stats?.defense ?? maxAc
const baseAc = const baseAc =
acPct > 0 acPct > 0
? Math.min(maxAc + 1, Math.max(minAc, Math.round((rawDef * 100) / (100 + acPct)))) ? 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 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) { if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) {
const afterSkill = calculateAdditionalCostsForItemSkill( const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables)
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterSkill.sellCost nSellCost = afterSkill.sellCost
nBuyCost = afterSkill.buyCost nBuyCost = afterSkill.buyCost
nRepCost = afterSkill.repCost nRepCost = afterSkill.repCost
@ -517,16 +459,25 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
let nSell = 0 let nSell = 0
let nBuy = 0 let nBuy = 0
let nRep = 0 let nRep = 0
const addTerm = (add: number, mult: number): void => {
// 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
nSell += add + Math.trunc((nSellCost * mult) / 1024) nSell += add + Math.trunc((nSellCost * mult) / 1024)
nBuy += add + Math.trunc((nBuyCost * mult) / 1024) nBuy += add + Math.trunc((nBuyCost * mult) / 1024)
nRep += add + Math.trunc((nRepCost * 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) { switch (quality) {
case ItemQuality.RARE: case ItemQuality.RARE:
@ -534,25 +485,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
const rareAffixes = item.rolledRareAffixes?.affixes ?? [] const rareAffixes = item.rolledRareAffixes?.affixes ?? []
for (const entry of rareAffixes) { for (const entry of rareAffixes) {
const affix = entry.affix ?? entry const affix = entry.affix ?? entry
const add = Number(affix?.add) || 0 addTerm(Number(affix?.add) || 0, Number(affix?.multiply) || 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)
}
} }
const afterBonus = calculateAdditionalCostsForBonusStats( applyBonus()
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
break break
} }
@ -561,23 +496,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
item.uniqueItemDef ?? item.uniqueItemDef ??
tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name) tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name)
if (uDef) { if (uDef) {
const costAdd = Number(uDef.costAdd ?? (uDef as any).costadd) || 0 addTerm(Number(uDef.costAdd) || 0, Number(uDef.costMult) || 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)
} else { } else {
const afterBonus = calculateAdditionalCostsForBonusStats( applyBonus()
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
} }
break break
} }
@ -588,62 +509,23 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
tables.sets.items.entries.find( tables.sets.items.entries.find(
s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name, s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name,
) )
if (sDef) { if (sDef) addTerm(Number(sDef.costAdd) || 0, Number(sDef.costMult) || 0)
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)
}
break break
} }
case ItemQuality.MAGIC: { case ItemQuality.MAGIC: {
const pAffix = item.rolledMagicAffixes?.prefix?.affix const pAffix = item.rolledMagicAffixes?.prefix?.affix
if (pAffix) { if (pAffix) addTerm(Number(pAffix.add) || 0, Number(pAffix.multiply) || 0)
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)
}
const sAffix = item.rolledMagicAffixes?.suffix?.affix const sAffix = item.rolledMagicAffixes?.suffix?.affix
if (sAffix) { if (sAffix) addTerm(Number(sAffix.add) || 0, Number(sAffix.multiply) || 0)
const add = Number(sAffix.add) || 0 applyBonus()
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
break break
} }
case ItemQuality.SUPERIOR: case ItemQuality.SUPERIOR:
case ItemQuality.TEMPERED: { case ItemQuality.TEMPERED:
const afterBonus = calculateAdditionalCostsForBonusStats( applyBonus()
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
break break
}
case ItemQuality.LOW: { case ItemQuality.LOW: {
nSell = -Math.trunc(nSellCost / 2) nSell = -Math.trunc(nSellCost / 2)
@ -661,74 +543,52 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
nRepCost += Math.trunc(nRep / nDivisor) nRepCost += Math.trunc(nRep / nDivisor)
if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) { if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) {
const afterSkill = calculateAdditionalCostsForItemSkill( const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables)
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterSkill.sellCost nSellCost = afterSkill.sellCost
nBuyCost = afterSkill.buyCost nBuyCost = afterSkill.buyCost
nRepCost = afterSkill.repCost nRepCost = afterSkill.repCost
} }
} }
// Socketed items (lines 2311-2325 of Items.cpp) // 0x6fd74a50: socketed children add half their ItemsTxt cost
if (item.socketedItems && item.socketedItems.length > 0) { for (const socketable of item.socketedItems ?? []) {
for (const socketable of item.socketedItems) { const socketableCost = Math.trunc(txt(socketable.base).cost / 2)
const socketableCost = Math.trunc((socketable.base.cost ?? socketable.base.value ?? 0) / 2) nSellCost += socketableCost
nSellCost += socketableCost nBuyCost += socketableCost
nBuyCost += socketableCost nRepCost += socketableCost
nRepCost += socketableCost
}
} }
// Ethereal acquisition quartering (lines 2327-2330) // 0x6fd798b0: ethereal and class-specific quartering of the player-sell price
const isEthereal = const ethereal = isEthereal(item)
Boolean(item.flags?.ethereal) || if (ethereal) {
Boolean((item as any).ethereal) ||
((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0
if (isEthereal) {
nBuyCost = Math.trunc(nBuyCost / 4) nBuyCost = Math.trunc(nBuyCost / 4)
} }
// Class-specific item acquisition quartering (lines 2332-2340)
if (isClassSpecificItemBase(item.base, tables.itemTypes)) { if (isClassSpecificItemBase(item.base, tables.itemTypes)) {
nBuyCost = Math.trunc(nBuyCost / 4) 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 const curDura = item.durability ?? maxDura
if (transactionType === TransactionType.SELL) { if (transactionType === TransactionType.SELL) {
if (isEthereal && maxDura > 0 && curDura <= 0) { if (ethereal && maxDura > 0 && curDura <= 0) {
nBuyCost = 0 nBuyCost = 0
} }
} else if (transactionType === TransactionType.REPAIR) { } else if (transactionType === TransactionType.REPAIR) {
if (maxDura > 0) { if (maxDura > 0) {
if (curDura < maxDura) { nRepCost = curDura < maxDura ? Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) : 0
nRepCost = Math.trunc((nRepCost * (maxDura - curDura)) / maxDura)
} else {
nRepCost = 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 npcRec = resolveNpcRecord(tables, vendorId, npcKey)
const sellMult = npcRec?.sellMult ?? 1024 nSellCost = Math.trunc((nSellCost * npcRec.sellMult) / 1024)
const buyMult = npcRec?.buyMult ?? 512 nBuyCost = Math.trunc((nBuyCost * npcRec.buyMult) / 1024)
const repMult = npcRec?.repMult ?? 1024 nRepCost = Math.trunc((nRepCost * npcRec.repMult) / 1024)
nSellCost = Math.trunc((nSellCost * sellMult) / 1024) if (completedQuests && completedQuests.size > 0) {
nBuyCost = Math.trunc((nBuyCost * buyMult) / 1024)
nRepCost = Math.trunc((nRepCost * repMult) / 1024)
if (npcRec && completedQuests && completedQuests.size > 0) {
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const qFlag = npcRec.questFlag[i] ?? 0 const qFlag = npcRec.questFlag[i]!
if (qFlag > 0 && completedQuests.has(qFlag)) { if (qFlag > 0 && completedQuests.has(qFlag)) {
nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024) nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024)
nBuyCost = Math.trunc((nBuyCost * npcRec.questBuyMult[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)) { if (!isBookItem(item.base) && !isQuiverItem(item.base)) {
nSellCost *= nQuantity nSellCost *= nQuantity
if ((item.base as any).stackable && isItemRepairable(item)) { if (rec.stackable && isItemRepairable(item)) {
const maxStack = Math.min(511, item.base.maxStack || Number((item.base as any).maxstack) || 1) const maxStack = maxStackOf(item.base)
if (nQuantity < maxStack) { nRepCost = nQuantity < maxStack ? nRepCost * (maxStack - nQuantity) : 0
nRepCost *= maxStack - nQuantity
} else {
nRepCost = 0
}
nBuyCost *= nQuantity
} else {
nBuyCost *= nQuantity
} }
nBuyCost *= nQuantity
} }
if (transactionType === TransactionType.SELL) { if (transactionType === TransactionType.SELL) {
let nCost = nBuyCost const cap = npcRec.maxBuy[difficulty]
let maxBuyCap: number const nCost = nBuyCost > cap ? cap : nBuyCost
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
}
return Math.max(1, nCost) return Math.max(1, nCost)
} }
if (transactionType === TransactionType.REPAIR) { 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) return Math.max(1, nCost)
} }
// TransactionType.BUY const nCost = rp !== 0 ? nSellCost - fogMulDiv113c(rp, nSellCost, 100) : nSellCost
let nCost = nSellCost - Math.trunc((nSellCost * clampedReduce) / 100)
return Math.max(1, nCost) return Math.max(1, nCost)
} }

File diff suppressed because it is too large Load Diff

View File

@ -24,12 +24,41 @@ export function encodeC2sWaypointGo(waypointId: number, destAreaId: number): Uin
return validateC2sPacket(w.toUint8Array()) 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) const w = new ByteWriter(7)
w.u8(0x4f) w.u8(0x4f)
w.u16LE(buttonId & 0xffff) w.u16LE(buttonId)
w.u32LE(complement >>> 0) w.u16LE((amount >>> 16) & 0xffff)
w.u16LE(amount & 0xffff)
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }

View File

@ -24,25 +24,69 @@ export function encodeC2sNpcCancel(target: UnitRef): Uint8Array {
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x32 BuyItem (17B): u8 0x32 | u32 npcId | u32 itemId | u32 tab | u32 cost */ function requireU32(name: string, v: number): number {
export function encodeC2sNpcBuy(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { 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) const w = new ByteWriter(17)
w.u8(0x32) w.u8(0x32)
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcBuy.npcId', p.npcId))
w.u32LE(itemId >>> 0) w.u32LE(requireU32('NpcBuy.itemId', p.itemId))
w.u32LE(tab >>> 0) w.u32LE(field3)
w.u32LE(cost >>> 0) w.u32LE(requireU32('NpcBuy.cost', p.cost))
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x33 SellItem (17B): u8 0x33 | u32 npcId | u32 itemId | u32 tab | u32 cost */ export interface NpcSellParams {
export function encodeC2sNpcSell(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { 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) const w = new ByteWriter(17)
w.u8(0x33) w.u8(0x33)
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcSell.npcId', p.npcId))
w.u32LE(itemId >>> 0) w.u32LE(requireU32('NpcSell.itemId', p.itemId))
w.u32LE(tab >>> 0) w.u32LE(requireU16('NpcSell.itemMode', p.itemMode))
w.u32LE(cost >>> 0) w.u32LE(requireU32('NpcSell.cost', p.cost))
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
@ -54,19 +98,41 @@ export function encodeC2sNpcIdentifyAll(npcId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x35 Repair (17B): u8 0x35 | u32 npcId | u32 itemId | u32 tab | u32 cost */ /** 0x35 field4 sign bit: repair every equipped/inventory item (D2Game 0x6fcdde60, price D2Common #10071). */
export function encodeC2sNpcRepair( export const NPC_REPAIR_ALL_FLAG = 0x80000000
npcId: number,
itemId = 0, export interface NpcRepairItemParams {
tab = 0, readonly npcId: number
cost = 0x80000000, readonly itemId: number
): Uint8Array { /** 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) const w = new ByteWriter(17)
w.u8(0x35) w.u8(0x35)
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcRepairItem.npcId', p.npcId))
w.u32LE(itemId >>> 0) w.u32LE(requireU32('NpcRepairItem.itemId', p.itemId))
w.u32LE(tab >>> 0) w.u32LE(requireU16('NpcRepairItem.itemMode', p.itemMode))
w.u32LE(cost >>> 0) 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()) return validateC2sPacket(w.toUint8Array())
} }
@ -79,13 +145,26 @@ export function encodeC2sHireMerc(npcId: number, mercId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array()) 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) const w = new ByteWriter(13)
w.u8(0x38) w.u8(0x38)
w.u32LE(actionType >>> 0) w.u32LE(requireU32('NpcEntityAction.action', action))
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcEntityAction.npcId', npcId))
w.u32LE(complement >>> 0) w.u32LE(requireU32('NpcEntityAction.complement', complement))
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }

View File

@ -4,6 +4,7 @@
import { encodeC2sClickButton } from './interact.ts' 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) return encodeC2sClickButton(action, gold)
} }

View File

@ -40,7 +40,9 @@ import {
encodeC2sNpcEntityAction, encodeC2sNpcEntityAction,
encodeC2sNpcIdentifyAll, encodeC2sNpcIdentifyAll,
encodeC2sNpcInit, encodeC2sNpcInit,
encodeC2sNpcRepair, encodeC2sNpcRepairAll,
encodeC2sNpcRepairItem,
NpcActionId,
encodeC2sNpcResurrectMerc, encodeC2sNpcResurrectMerc,
encodeC2sNpcSell, encodeC2sNpcSell,
} from './c2s/npc.ts' } from './c2s/npc.ts'
@ -468,16 +470,20 @@ export function encodeD2gsClientCommand(
return [encodeC2sNpcInit(cmd.target)] return [encodeC2sNpcInit(cmd.target)]
case 'NpcCancel': case 'NpcCancel':
return [encodeC2sNpcCancel(cmd.target)] return [encodeC2sNpcCancel(cmd.target)]
case 'NpcOpenTrade':
return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)]
case 'NpcOpenGamble':
return [encodeC2sNpcEntityAction(NpcActionId.Gamble, cmd.npcId, 0)]
case 'NpcBuy': case 'NpcBuy':
return [encodeC2sNpcBuy(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] return [encodeC2sNpcBuy(cmd)]
case 'NpcSell': case 'NpcSell':
return [encodeC2sNpcSell(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] return [encodeC2sNpcSell(cmd)]
case 'NpcRepair': case 'NpcRepairItem':
return [encodeC2sNpcRepair(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] return [encodeC2sNpcRepairItem(cmd)]
case 'NpcRepairAll':
return [encodeC2sNpcRepairAll(cmd.npcId)]
case 'NpcHeal': case 'NpcHeal':
return [encodeC2sNpcInit({ unitType: 1, id: cmd.npcId })] return [encodeC2sNpcInit({ unitType: 1, id: cmd.npcId })]
case 'NpcGamble':
return [encodeC2sNpcEntityAction(2, cmd.npcId, cmd.itemId ?? 0)]
case 'NpcIdentifyAll': case 'NpcIdentifyAll':
return [encodeC2sNpcIdentifyAll(cmd.npcId)] return [encodeC2sNpcIdentifyAll(cmd.npcId)]
case 'NpcResurrectMerc': case 'NpcResurrectMerc':
@ -493,7 +499,7 @@ export function encodeD2gsClientCommand(
case 'TownPortal': case 'TownPortal':
return [encodeC2sSkillAt('right', { x: 0, y: 0 })] return [encodeC2sSkillAt('right', { x: 0, y: 0 })]
case 'ClickButton': case 'ClickButton':
return [encodeC2sClickButton(cmd.buttonId, cmd.complement)] return [encodeC2sClickButton(cmd.buttonId, cmd.amount)]
case 'Party': case 'Party':
return [encodeC2sParty(cmd.action, cmd.targetPlayerId)] return [encodeC2sParty(cmd.action, cmd.targetPlayerId)]
case 'Hostile': case 'Hostile':

View File

@ -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 { 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) const r = new ByteReader(pkt)
r.u8() // 0x2A r.u8() // 0x2A
const tradeType = r.u8() const flag = r.u8()
const result = r.u8() const result = r.u8()
const npcId = r.u32LE() r.skip(4) // [3..6] uninitialised on the server side
const merchandiseId = r.u32LE() const itemId = r.u32LE()
const goldRemaining = r.u32LE() const gold = r.u32LE()
return { return {
type: 'Npc', type: 'NpcTransaction',
kind: 'transaction', result,
npcId, flag,
result: (result << 8) | tradeType, itemId,
merchandiseId, gold,
goldRemaining,
} }
} }

View File

@ -50,29 +50,39 @@ export type ClientCommand =
| { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number } | { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number }
| { readonly type: 'NpcInit'; readonly target: UnitRef } | { readonly type: 'NpcInit'; readonly target: UnitRef }
| { readonly type: 'NpcCancel'; 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 type: 'NpcBuy'
readonly npcId: number readonly npcId: number
readonly itemId: number readonly itemId: number
readonly tab: number readonly itemMode: number
readonly gamble: boolean
readonly shiftFill: boolean
readonly cost: number readonly cost: number
} }
| { | {
/** 0x33 SellItem; field3 = itemMode. */
readonly type: 'NpcSell' readonly type: 'NpcSell'
readonly npcId: number readonly npcId: number
readonly itemId: number readonly itemId: number
readonly tab: number readonly itemMode: number
readonly cost: 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 npcId: number
readonly itemId?: number | undefined readonly itemId: number
readonly tab?: number | undefined readonly itemMode: number
readonly cost?: number | undefined 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: 'NpcHeal'; readonly npcId: number }
| { readonly type: 'NpcGamble'; readonly npcId: number; readonly itemId?: number | undefined }
| { readonly type: 'NpcIdentifyAll'; readonly npcId: number } | { readonly type: 'NpcIdentifyAll'; readonly npcId: number }
| { readonly type: 'NpcResurrectMerc'; readonly npcId: number } | { readonly type: 'NpcResurrectMerc'; readonly npcId: number }
| { readonly type: 'HireMerc'; readonly npcId: number; readonly mercId: 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: 'AddSkillPoint'; readonly skillId: number }
| { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number } | { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number }
| { readonly type: 'TownPortal' } | { 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: 'Party'; readonly action: number; readonly targetPlayerId: number }
| { readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean } | { 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: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined }
| { readonly type: 'OverheadChat'; readonly text: string } | { readonly type: 'OverheadChat'; readonly text: string }
| { readonly type: 'Resurrect' } | { readonly type: 'Resurrect' }

View File

@ -141,13 +141,20 @@ export type ServerEvent =
} }
| { | {
readonly type: 'Npc' readonly type: 'Npc'
readonly kind: 'info' | 'menu' | 'transaction' | 'heal' readonly kind: 'info' | 'menu' | 'heal'
readonly npcId: number readonly npcId: number
readonly result?: number | undefined readonly result?: number | undefined
readonly merchandiseId?: number | undefined
readonly goldRemaining?: number | undefined
readonly raw?: Uint8Array | 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 type: 'WaypointMenu'
readonly unitId: number readonly unitId: number

View File

@ -113,6 +113,8 @@ export { decodeBncsS2cPacket } from './bncs/packets.ts'
export { McpFramer } from './mcp/framing.ts' export { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts'
export { validateC2sPacket } from './d2gs/framing-c2s.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 { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
export { export {
D2GS_S2C_REGISTRY, D2GS_S2C_REGISTRY,

View File

@ -3,7 +3,6 @@ import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/worl
import { import {
WorldPanelsHud, WorldPanelsHud,
STASH_GOLD_CAP, STASH_GOLD_CAP,
PLAYER_GOLD_CAP,
} from '../src/client/ui/world-panels.ts' } from '../src/client/ui/world-panels.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { GameEngine } from '../src/server/engine/engine.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 panels.stashGold = 100_000
}) })
it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => { it('stash gold cap is the D2Common #11060 constant (2,500,000); gold transfers are server-driven', () => {
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', () => {
expect(STASH_GOLD_CAP).toBe(2_500_000) expect(STASH_GOLD_CAP).toBe(2_500_000)
expect(PLAYER_GOLD_CAP).toBe(2_500_000) // WorldPanelsHud no longer mutates gold locally: deposit/withdraw are sent as
// ClickButton commands by HudModel and the balance arrives via server stat updates.
// Stash cap clamping on deposit expect((panels as unknown as Record<string, unknown>)['depositGold']).toBeUndefined()
panels.stashGold = 2_450_000 expect((panels as unknown as Record<string, unknown>)['withdrawGold']).toBeUndefined()
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)
}) })
it('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => { it('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => {

View File

@ -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<ServerEvent, { type: 'NpcTransaction' }> => 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)
})
})

View File

@ -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)
})
})

View File

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

View File

@ -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<typeof createSession>, 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<string>()
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/)
})
})

View File

@ -31,7 +31,8 @@ import {
type CursorFrameMeta, type CursorFrameMeta,
} from '../src/common/items/cursor-meta.ts' } from '../src/common/items/cursor-meta.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-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 { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts'
import { seedDemoHud } from './fixtures/hud-demo-state.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', () => { 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() 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) // Explicitly seed the legacy demo Sorceress state (class, level, gold, inventory)
seedDemoHud(hud) seedDemoHud(hud)
const closeNpc = vi.fn()
hud.commandSink = { closeNpc } as unknown as HudCommandSink
// Open Charsi vendor panel // Server opened Charsi's store (0x9C AddToShop items + NpcOpenTrade): HudModel fills and shows it
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true) openGeneratedVendorShop(hud.worldPanels, 'Charsi', 'trade', 24, 0, 7)
hud.showVendorPanel()
expect(hud.state.leftPanel).toBe('vendor') expect(hud.state.leftPanel).toBe('vendor')
expect(hud.state.vendorTradeState).toBe('idle') expect(hud.state.vendorTradeState).toBe('idle')
expect(hud.state.cursorTradeMode).toBeNull() 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') expect(hud.state.cursorTradeMode).toBe('sell')
hud.closeAllPanels() hud.closeAllPanels()
expect(hud.state.leftPanel).toBe('none') expect(hud.state.leftPanel).toBe('none')
expect(closeNpc).toHaveBeenCalledWith(7)
expect(hud.state.cursorTradeMode).toBeNull() expect(hud.state.cursorTradeMode).toBeNull()
expect(hud.state.cursorAnimation).toBe('protate') expect(hud.state.cursorAnimation).toBe('protate')
}) })

View File

@ -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: 'Interact', target: { unitType: 'object', id: asUnitId(100) } }, expectedOpcodes: [0x13] },
{ cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] }, { cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] },
{ cmd: { type: 'NpcCancel', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x30] }, { 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: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), itemMode: 0, gamble: false, shiftFill: false, cost: 500 },
{ cmd: { type: 'NpcRepair', npcId: asUnitId(200), itemId: asUnitId(302), tab: 0, cost: 100 }, expectedOpcodes: [0x35] }, 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: '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: false }, expectedOpcodes: [0x16] },
{ cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] }, { cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] },

View File

@ -0,0 +1,266 @@
{"format":"d2cap-v1","createdAt":"2026-10-01T09:50:38.057Z","server":"101.37.117.183 (via=mixed)","account":"<redacted>","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}

View File

@ -0,0 +1,336 @@
{"format":"d2cap-v1","createdAt":"2026-10-01T09:52:18.705Z","server":"101.37.117.183 (via=mixed)","account":"<redacted>","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}

View File

@ -0,0 +1,294 @@
{"format":"d2cap-v1","createdAt":"2026-10-01T09:55:55.592Z","server":"101.37.117.183 (via=mixed)","account":"<redacted>","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}

View File

@ -0,0 +1,212 @@
{"format":"d2cap-v1","createdAt":"2026-10-01T09:56:27.920Z","server":"101.37.117.183 (via=mixed)","account":"<redacted>","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}

View File

@ -0,0 +1,248 @@
{"format":"d2cap-v1","createdAt":"2026-10-01T09:57:10.084Z","server":"101.37.117.183 (via=mixed)","account":"<redacted>","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}

View File

@ -235,21 +235,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => {
}) })
}) })
describe('4. InventoryPanel Gold Dropping & findFreeGridSlot', () => { describe('4. InventoryPanel 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)
})
it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => { it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => {
const gridItems: GridPlacement[] = [ const gridItems: GridPlacement[] = [
{ {

View File

@ -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<typeof itemToUiInventoryItem>; 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 })
}

View File

@ -43,6 +43,7 @@ import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts' import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../src/client/ui/skill-tree-panel.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 { 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 { ControlBarHud } from '../src/client/ui/control-bar.ts'
import { GlobesHud } from '../src/client/ui/globes.ts' import { GlobesHud } from '../src/client/ui/globes.ts'
import { SkillHotkeysHud } from '../src/client/ui/hotkeys.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('剩余技能点')
expect(textCalls).toContain('角色属性 (A/C)') expect(textCalls).toContain('角色属性 (A/C)')
expect(textCalls).toContain('罗格营地') expect(textCalls).toContain('罗格营地')
expect(textCalls).toContain('私人储物箱') expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI)
expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)') expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')
expect(textCalls).not.toContain('角色属性 (Character)') 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 // Also test Charsi vendor inventory across all 4 tabs
const wpVendor = new WorldPanelsHud() const wpVendor = new WorldPanelsHud()
wpVendor.lang = 'ENG' 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 tabId of ['armor', 'weapons1', 'weapons2', 'misc'] as const) {
for (const p of wpVendor.vendorTabPlacements[tabId]) { for (const p of wpVendor.vendorTabPlacements[tabId]) {
const { ctx, font, textCalls } = createMockCanvasAndFont() const { ctx, font, textCalls } = createMockCanvasAndFont()

View File

@ -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 // NPC talking dialog body also uses fontformal12 while header uses font16
drawnCalls.length = 0 drawnCalls.length = 0
worldPanels.npcMenu = { worldPanels.npcMenu = {
npcId: null,
npcName: 'Akara', npcName: 'Akara',
x: 400, x: 400,
y: 260, y: 260,
@ -711,6 +712,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
} as unknown as D2FontRenderer } as unknown as D2FontRenderer
worldPanels.npcMenu = { worldPanels.npcMenu = {
npcId: null,
npcName: '阿卡拉', npcName: '阿卡拉',
x: 400, x: 400,
y: 260, y: 260,

View File

@ -193,8 +193,8 @@ describe('1.13c Item Transaction & Acquisition Cost Engine (M19-P3)', () => {
expect(sellHell).toBe(35000) expect(sellHell).toBe(35000)
}) })
it('halves acquisition (SELL) price for Ethereal items and forbids repairing Ethereal items', () => { 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 halving is observable) const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so quartering is observable)
const normalArmor = createDroppedItem(capBase, 'normal', { const normalArmor = createDroppedItem(capBase, 'normal', {
itemTypes: tables.itemTypes, itemTypes: tables.itemTypes,
ilvl: 25, ilvl: 25,

View File

@ -8,7 +8,7 @@
* 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance * 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance
* 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting * 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting
* 5. Normal and Telekinesis ground item pickup conversion via itemToUiInventoryItem * 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 * 7. Inventory full refusal bounce, audio feedback, and notification retention
*/ */
@ -23,7 +23,6 @@ import {
import { import {
InventoryPanel, InventoryPanel,
resolveItemSpriteRect, resolveItemSpriteRect,
PLAYER_GOLD_CAP,
} from '../src/client/ui/inventory.ts' } from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { GameEngine } from '../src/server/engine/engine.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 mockCanvas: any
let mockStatus: { textContent: string } let mockStatus: { textContent: string }

View File

@ -22,7 +22,7 @@
* - Authentic allowedSlots resolution and paperdoll equipping * - Authentic allowedSlots resolution and paperdoll equipping
* *
* 4. Gold Currency Accumulation vs Inventory Full Flippy Bounce: * 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 * - Full 10x4 inventory grid refusal, metrics.inventoryRefusals, and parabolic bounce physics
*/ */
@ -37,7 +37,6 @@ import {
resolveItemSpriteRect, resolveItemSpriteRect,
identifyUiItem, identifyUiItem,
isUiItemUnidentified, isUiItemUnidentified,
PLAYER_GOLD_CAP,
type UiInventoryItem, type UiInventoryItem,
type EquipSlotId, type EquipSlotId,
} from '../src/client/ui/inventory.ts' } from '../src/client/ui/inventory.ts'

View File

@ -144,13 +144,16 @@ describe('D2GS 1.13c extracted tables, Huffman codec, S2C/C2S framing & registry
{ type: 'IdentifyItem', itemId: 107, scrollId: 108 }, { type: 'IdentifyItem', itemId: 107, scrollId: 108 },
{ type: 'NpcInit', target: { unitType: 1, id: 15 } }, { type: 'NpcInit', target: { unitType: 1, id: 15 } },
{ type: 'NpcCancel', target: { unitType: 1, id: 15 } }, { type: 'NpcCancel', target: { unitType: 1, id: 15 } },
{ type: 'NpcBuy', npcId: 15, itemId: 200, tab: 0, cost: 50 }, { type: 'NpcOpenTrade', npcId: 15 },
{ type: 'NpcSell', npcId: 15, itemId: 200, tab: 0, cost: 25 }, { type: 'NpcOpenGamble', npcId: 15 },
{ type: 'NpcRepair', npcId: 15, itemId: 0, tab: 0, cost: 0 }, { 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: 'AddStatPoint', statId: 0, count: 1 },
{ type: 'AddSkillPoint', skillId: 36 }, { type: 'AddSkillPoint', skillId: 36 },
{ type: 'WaypointGo', waypointId: 30, destAreaId: 1 }, { 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: 'Party', action: 6, targetPlayerId: 2 },
{ type: 'Hostile', targetPlayerId: 2, hostile: true }, { type: 'Hostile', targetPlayerId: 2, hostile: true },
{ type: 'Trade', action: 2, gold: 0 }, { type: 'Trade', action: 2, gold: 0 },

View File

@ -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<typeof encodeD2gsClientCommand>[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,
})
})
})

View File

@ -12,7 +12,6 @@ import {
STASH_PANEL_ORIGIN, STASH_PANEL_ORIGIN,
STASH_GRID_ORIGIN, STASH_GRID_ORIGIN,
STASH_GOLD_CAP, STASH_GOLD_CAP,
PLAYER_GOLD_CAP,
STASH_HEADER_ICON_SOCKET, STASH_HEADER_ICON_SOCKET,
STASH_HEADER_TOP_RECESS, STASH_HEADER_TOP_RECESS,
STASH_HEADER_BOTTOM_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', () => { describe('5. Stash Gold Cap (server-driven transfers)', () => {
let panels: WorldPanelsHud it('exposes STASH_GOLD_CAP = 2,500,000 (D2Common #11060) and performs no local gold mutation', () => {
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', () => {
expect(STASH_GOLD_CAP).toBe(2_500_000) expect(STASH_GOLD_CAP).toBe(2_500_000)
panels.stashGold = 2_490_000 const panels = new WorldPanelsHud()
inv.gold = 100_000 // Deposit/withdraw are ClickButton commands sent by HudModel; D2Game owns the balances.
expect((panels as unknown as Record<string, unknown>)['depositGold']).toBeUndefined()
const deposited = panels.depositGold(50_000, inv) expect((panels as unknown as Record<string, unknown>)['withdrawGold']).toBeUndefined()
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)
}) })
}) })
@ -548,8 +492,8 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x
mockFont, mockFont,
) )
const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱')) const titleCall = drawTextCalls.find(c => c.text.includes('你的私人儲藏箱')) // tbl 'bank'
const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币')) const goldCall = drawTextCalls.find(c => c.text.includes('儲藏起來的金錢:')) // tbl 'strGoldInStash'
expect(titleCall).toBeDefined() expect(titleCall).toBeDefined()
expect(goldCall).toBeDefined() expect(goldCall).toBeDefined()

View File

@ -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> = {}): 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> = {}): 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)
}
})
})

View File

@ -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 - 7).toBeGreaterThanOrEqual(INV_GOLD_BOX_RECESS.y)
expect(goldTextCall!.y + 1).toBeLessThanOrEqual(INV_GOLD_BOX_RECESS.y + INV_GOLD_BOX_RECESS.h - 1) 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 // 4. Verify clicking directly on the Gold Coin Button (490, 460) asks for the gold dialog (no local drop)
let droppedGold = 0 let goldButtonPresses = 0
expect( expect(
inv.handleClick(490, 460, { inv.handleClick(490, 460, {
onDropGold: amt => { onGoldButton: () => {
droppedGold = amt goldButtonPresses++
}, },
}), }),
).toBe(true) ).toBe(true)
expect(droppedGold).toBe(10_000) expect(goldButtonPresses).toBe(1)
// 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia) // 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia)
const fontRenderer = new D2FontRenderer() const fontRenderer = new D2FontRenderer()

View File

@ -385,47 +385,5 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System'
expect(tomeItem.quantity).toBe(4) expect(tomeItem.quantity).toBe(4)
expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true) 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)
})
}) })
}) })

View File

@ -33,6 +33,7 @@ import {
hitTestNpcDialogOption, hitTestNpcDialogOption,
resolveVendorButtonLayout, resolveVendorButtonLayout,
} from '../src/client/ui/world-panels.ts' } from '../src/client/ui/world-panels.ts'
import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts'
function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } { function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } {
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) 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 wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv 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 { ctx, font, drawImageCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont()
const assets = { ...createFakeVendorAssets(), inventory: inv } const assets = { ...createFakeVendorAssets(), inventory: inv }
@ -326,7 +327,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
const wp = new WorldPanelsHud() const wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv 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 // Force armor tab empty to verify 0x6fb45e5e-0x6fb45f5d tab presence guard
wp.vendorTabPlacements.armor = [] wp.vendorTabPlacements.armor = []
@ -349,7 +350,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
const wp = new WorldPanelsHud() const wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv wp.activePlayerInventory = inv
expect(wp.openVendorForNpc('Gheed', 'gamble', 30, 0)).toBe(true) openGeneratedVendorShop(wp, 'Gheed', 'gamble', 30, 0)
const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont() const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont()
wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) 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() const inv = new InventoryPanel()
inv.gold = 50000 inv.gold = 50000
wp.activePlayerInventory = inv 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 // Repair All total is supplied by HudModel (VendorRepairAllFn over priceItem113c type 3)
const helm: UiInventoryItem = { const expectedCost = 1234
id: 'damaged-cap', wp.vendorRepairAllCost = expectedCost
code: 'cap', let repairAllCalls = 0
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)
// Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit // Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit
expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull() expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull()
@ -403,6 +389,9 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
}, },
onWaypointTeleport: () => {}, onWaypointTeleport: () => {},
inventory: inv, inventory: inv,
onVendorRepairAll: () => {
repairAllCalls++
},
} }
// Click slot 0 (Buy) -> latches 'buy' (button frame 3, cursor frame 3) // 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) ).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) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks)
expect(closed).toBe(false) expect(closed).toBe(false)
expect(wp.vendorTradeState).toBe('idle') expect(wp.vendorTradeState).toBe('idle')
expect(wp.getActiveVendorCursorFrame()).toBeNull() expect(wp.getActiveVendorCursorFrame()).toBeNull()
expect(wp.pressedMomentarySlot).toBe(3) expect(wp.pressedMomentarySlot).toBe(3)
expect(helm.durability?.current).toBe(12) expect(repairAllCalls).toBe(1)
wp.releaseMomentaryVendorButton() wp.releaseMomentaryVendorButton()
expect(wp.pressedMomentarySlot).toBeNull() expect(wp.pressedMomentarySlot).toBeNull()
// Switch to Akara and click slot 3 (Close) -> closes panel, slot 2 (blank) is ignored // 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() 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) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks)
expect(closed).toBe(true) 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 wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
inv.gold = 999999 inv.gold = 999999
wp.activePlayerInventory = inv wp.activePlayerInventory = inv
expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0)
expect(wp.activeVendorTab).toBe('armor') expect(wp.activeVendorTab).toBe('armor')
// Leave only 1 non-permStore item on the armor tab and buy it // The server removed the last armor item (0x9D RemoveFromShop after a buy): HudModel re-applies the
const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore) // same store without it and D2Client 0x6fb3f710 advances to the next non-empty tab.
expect(nonPerm).toBeDefined() const remaining = (['weapons1', 'weapons2', 'misc'] as const).flatMap((id, i) =>
wp.vendorTabPlacements.armor = [nonPerm!] wp.vendorTabPlacements[id].map(placement => ({ storePage: i + 2, placement })),
)
const res = wp.buyFromActiveVendor(nonPerm!.col, nonPerm!.row, inv, false) wp.setVendorShop({ npcId: 1, descriptor: wp.activeVendorDescriptor!, mode: 'trade', items: remaining })
expect(res.ok).toBe(true)
expect(wp.vendorTabPlacements.armor).toHaveLength(0) expect(wp.vendorTabPlacements.armor).toHaveLength(0)
expect(wp.activeVendorTab).toBe('weapons1') expect(wp.activeVendorTab).toBe('weapons1')
}) })
@ -642,6 +630,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
openedVendorMode = mode openedVendorMode = mode
}, },
onIdentifyAll: () => {}, onIdentifyAll: () => {},
onCancel: () => {},
}, },
font, font,
) )
@ -654,7 +643,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
const wp = new WorldPanelsHud() const wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv 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 // Set player context: Level 10 Sorceress with 25 Str, 25 Dex
wp.setPlayerContext({ wp.setPlayerContext({
@ -860,7 +849,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
dex: 500, dex: 500,
classCode: 'sor', classCode: 'sor',
}) })
expect(wp.openVendorForNpc('Gheed', 'gamble', 99, 0)).toBe(true) openGeneratedVendorShop(wp, 'Gheed', 'gamble', 99, 0)
expect(wp.gamblePlacements.length).toBeGreaterThan(0) expect(wp.gamblePlacements.length).toBeGreaterThan(0)
// Even when hovering a gamble item with an empty cursor, every gamble item background must be RED // Even when hovering a gamble item with an empty cursor, every gamble item background must be RED

View File

@ -18,7 +18,10 @@
* --idle=<seconds> Stay in-game for N seconds (default: 5 for join, 60 for idle) * --idle=<seconds> Stay in-game for N seconds (default: 5 for join, 60 for idle)
* --world Print ClientWorld state summary * --world Print ClientWorld state summary
* --capture=<out.d2cap> Write .d2cap JSONL wire capture to disk * --capture=<out.d2cap> Write .d2cap JSONL wire capture to disk
* --scenario=<name> Scenario name for record command (town|combat|items|npc|chat|full) * --scenario=<name> 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=<out.json> Write the vendor scenario report (server-driven before/after state) to disk
*/ */
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
@ -54,6 +57,7 @@ import {
type GameServerAdapter, type GameServerAdapter,
} from '../src/netproto/index.ts' } from '../src/netproto/index.ts'
import { connectNodeTcpStream } from './lib/node-tcp-stream.ts' import { connectNodeTcpStream } from './lib/node-tcp-stream.ts'
import { isVendorScenario, runVendorScenario } from './lib/vendor-scenarios.ts'
import { import {
D2S_MAGIC, D2S_MAGIC,
D2S_QUEST_OFFSET, D2S_QUEST_OFFSET,
@ -1461,7 +1465,7 @@ async function main(): Promise<void> {
} }
const mapService = const mapService =
cmd === 'walk' cmd === 'walk' || (cmd === 'record' && isVendorScenario(scenario))
? await MapService.fromPackedDir(resolve(ROOT_DIR, 'public/act-packs/tiles')) ? await MapService.fromPackedDir(resolve(ROOT_DIR, 'public/act-packs/tiles'))
: null : null
@ -1515,7 +1519,18 @@ async function main(): Promise<void> {
if (cmd === 'record') { if (cmd === 'record') {
console.log(`[d2-bot] Running scenario="${scenario}"...`) 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: '<redacted>', character: charName, report }, null, 2))
}
} else {
await runInGameScenario(scenario, world, adapter)
}
} }
const defaultIdleSec = cmd === 'idle' ? 60 : cmd === 'record' ? 6 : 5 const defaultIdleSec = cmd === 'idle' ? 60 : cmd === 'record' ? 6 : 5

View File

@ -0,0 +1,426 @@
/**
* Scripted vendor / stash / gamble scenarios for `tools/d2-bot.ts record --scenario=<name>`.
*
* 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 { 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 {
ClickButtonId,
unitTypeToId,
type ClientCommand,
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<void> => 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<Extract<ServerEvent, { type: 'NpcTransaction' }>>
readonly report: Record<string, unknown>
}
/** Sends a command and applies its local side effects to ClientWorld, like OnlineSession does. */
function send(ctx: Ctx, cmd: ClientCommand): void {
ctx.adapter.send(cmd)
ctx.world.noteOutgoingCommand(cmd)
}
function fmtItem(it: ItemData): string {
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<void> {
if (dist(ctx.world, u, ctx.pos) <= within) return
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<boolean> {
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) 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)
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
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 }
}
}
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<ClientUnit> {
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<boolean> {
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<Extract<ServerEvent, { type: 'NpcTransaction' }> | 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<string, unknown> {
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<ClientUnit> {
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 }
send(ctx, { type: 'Interact', target })
await sleep(700)
send(ctx, { type: 'NpcInit', target })
await sleep(700)
return npc
}
async function closeNpc(ctx: Ctx, npc: ClientUnit): Promise<void> {
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<readonly ItemData[]> {
const before = ctx.world.containers.shop.size
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()]
ctx.log(`${gamble ? 'gamble' : 'trade'} shop opened: ${items.length} items`)
const pages = new Map<number, number>()
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<void> {
const g0 = ctx.world.self.gold
const i = ctx.tx.length
ctx.log(`${label}: sell ${fmtItem(item)}`)
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 }
}
async function buy(
ctx: Ctx,
npc: ClientUnit,
item: ItemData,
opts: { gamble: boolean; shiftFill: boolean; cost: number },
label: string,
): Promise<number | undefined> {
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}`)
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))
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<void> {
const g0 = ctx.world.self.gold
const s0 = ctx.world.self.stashGold
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 }
}
async function openStash(ctx: Ctx): Promise<void> {
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)
send(ctx, { 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<Record<string, unknown>> {
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) {
// 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')
}
} 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()
}
}