Merge branch 'feat/vendor-stash'
This commit is contained in:
commit
33af40cdb6
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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',
|
|
||||||
difficulty: 0 | 1 | 2 = 0,
|
|
||||||
): boolean {
|
|
||||||
if (this.currentClass === null) {
|
|
||||||
throw new Error('HudManager.openVendorSession: character class not assigned by the server yet')
|
|
||||||
}
|
}
|
||||||
const playerCtx = {
|
|
||||||
level: this.charSheet.attrs.level,
|
/** Server opened a store (`ClientWorld.npcInteraction.window`): show the vendor panel with the inventory. */
|
||||||
str: this.charSheet.attrs.str,
|
showVendorPanel(): void {
|
||||||
dex: this.charSheet.attrs.dex,
|
if (!this.worldPanels.activeVendorDescriptor) {
|
||||||
classCode: this.currentClass,
|
throw new Error('HudManager.showVendorPanel: no store set (WorldPanelsHud.setVendorShop)')
|
||||||
}
|
}
|
||||||
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,52 +1455,12 @@ 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') {
|
|
||||||
// D2Client.dll 0x6fb472d0: Right-clicking while in Buy/Sell/Repair cursor mode cancels back to idle
|
|
||||||
if (e.button === 2 && this.worldPanels.vendorTradeState !== 'idle') {
|
|
||||||
this.worldPanels.vendorTradeState = 'idle'
|
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
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)) {
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 diffNum: 0 | 1 | 2 =
|
const sameStore =
|
||||||
difficulty === 'hell' || difficulty === 2
|
this.vendorNpcId === p.npcId &&
|
||||||
? 2
|
this.vendorMode === p.mode &&
|
||||||
: difficulty === 'nightmare' || difficulty === 1
|
this.activeVendorDescriptor?.hcIdx === p.descriptor.hcIdx
|
||||||
? 1
|
if (!sameStore) {
|
||||||
: 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.hoveredVendorButtonSlot = null
|
this.hoveredVendorButtonSlot = null
|
||||||
this.pressedMomentarySlot = null
|
this.pressedMomentarySlot = null
|
||||||
this.hoveredVendorItem = null
|
this.hoveredVendorItem = null
|
||||||
|
}
|
||||||
const tables = getEmbeddedDropTables()
|
this.activeVendorDescriptor = p.descriptor
|
||||||
const vendorId = descriptor.vendorId
|
this.vendorNpcId = p.npcId
|
||||||
|
this.vendorMode = p.mode
|
||||||
if (mode === 'gamble') {
|
const tabs: Record<VendorTabId, GridPlacement[]> = { armor: [], weapons1: [], weapons2: [], misc: [] }
|
||||||
const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel)
|
const gamble: GridPlacement[] = []
|
||||||
this.gamblePlacements = gambleInv.getAllItems().map(p => {
|
for (const entry of p.items) {
|
||||||
const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, true)
|
if (p.mode === 'gamble') {
|
||||||
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables)
|
gamble.push(entry.placement)
|
||||||
return { item: uiItem, col: p.x, row: p.y }
|
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'
|
this.activeVendorTab = 'armor'
|
||||||
return true
|
} else if (!sameStore) {
|
||||||
}
|
// D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 and cycle to the first non-empty tab.
|
||||||
|
|
||||||
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')
|
this.selectVendorTab('armor')
|
||||||
return true
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the store (NpcCancel sent: D2Client frees the store units, the server sends no RemoveFromShop). */
|
||||||
|
clearVendorShop(): void {
|
||||||
|
this.activeVendorDescriptor = null
|
||||||
|
this.vendorNpcId = null
|
||||||
|
this.vendorMode = 'trade'
|
||||||
|
this.vendorTradeState = 'idle'
|
||||||
|
this.vendorRepairAllCost = null
|
||||||
|
this.hoveredVendorButtonSlot = null
|
||||||
|
this.pressedMomentarySlot = null
|
||||||
|
this.hoveredVendorItem = null
|
||||||
|
this.vendorTabPlacements = { armor: [], weapons1: [], weapons2: [], misc: [] }
|
||||||
|
this.gamblePlacements = []
|
||||||
|
this.activeVendorTab = 'armor'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repair-all total set by `HudModel`; throws when the store has a Repair All button but no cost was provided. */
|
||||||
|
requireRepairAllCost(): number {
|
||||||
|
if (this.vendorRepairAllCost === null) {
|
||||||
|
throw new Error('WorldPanelsHud: Repair All cost not provided (HudModel needs a VendorRepairAllFn)')
|
||||||
|
}
|
||||||
|
return this.vendorRepairAllCost
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
if (res.ok) {
|
|
||||||
inv.cursorItem = null
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Buy works in 'idle' or 'buy' state; never buy while the sell / repair cursor is armed.
|
||||||
// Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state
|
|
||||||
if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') {
|
if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') {
|
||||||
this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick))
|
const hit = this.getActiveVendorPlacements().find(
|
||||||
this.handleMouseMove(logicalX, logicalY)
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit) {
|
||||||
|
if (!callbacks.onVendorBuy) throw new Error('handleLeftDockClick: vendor buy needs onVendorBuy')
|
||||||
|
callbacks.onVendorBuy(hit.item, Boolean(callbacks.isShiftClick))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>> = {
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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({
|
entries.push({ statId: r.statId, param: r.param ?? numParam, value: r.value })
|
||||||
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)
|
|
||||||
if (skillRec) {
|
|
||||||
nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024)
|
nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024)
|
||||||
nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096)
|
nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096)
|
||||||
nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
applyBonus()
|
||||||
const afterBonus = calculateAdditionalCostsForBonusStats(
|
|
||||||
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
|
nBuyCost *= nQuantity
|
||||||
} else {
|
|
||||||
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
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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] },
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,8 +1519,19 @@ 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}"...`)
|
||||||
|
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)
|
await runInGameScenario(scenario, world, adapter)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultIdleSec = cmd === 'idle' ? 60 : cmd === 'record' ? 6 : 5
|
const defaultIdleSec = cmd === 'idle' ? 60 : cmd === 'record' ? 6 : 5
|
||||||
const idleSec =
|
const idleSec =
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue