From e0210d870202836def8881d539961271be9ad564 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:29:07 +0000 Subject: [PATCH] feat(client): price server items with the 1.13c #10107/#10071 port ItemData -> TransactionCostInput adapter (packet defense/durability/ quantity as base stats, properties and runeword stats as full stats, rare affix pairs, socket children). Code-only gamble previews (flag 0x02000000) are priced only for type 2. Capture checks: all 46 Charsi items price, every gamble preview costs > 2 gold (server answered result 12), repair-all with full durability costs 0. --- src/client/ui-model/transaction-cost-input.ts | 136 ++++++++++++++++++ tests/client/transaction-cost-input.test.ts | 37 +++++ 2 files changed, 173 insertions(+) create mode 100644 src/client/ui-model/transaction-cost-input.ts create mode 100644 tests/client/transaction-cost-input.test.ts diff --git a/src/client/ui-model/transaction-cost-input.ts b/src/client/ui-model/transaction-cost-input.ts new file mode 100644 index 0000000..35fd052 --- /dev/null +++ b/src/client/ui-model/transaction-cost-input.ts @@ -0,0 +1,136 @@ +/** + * Builds D2Common 1.13c #10107 / #10071 pricing inputs from server item packets + * (`src/client/ui-model/transaction-cost-input.ts`). Prices are display-only: + * D2Game ignores the client cost field (buy 0x6fcdf200, sell 0x6fcef570). + */ + +import { + computeRepairAllCost113c, + computeTransactionCost113c, + getCanonicalTransactionCostTables, + type ItemQuality113c, + type TransactionCostContext, + type TransactionCostInput, + type TransactionCostStat, +} from '../../common/items/transaction-cost.ts' +import type { ItemData } from '../../netproto/index.ts' +import type { ClientWorld } from '../world/client-world.ts' + +/** ItemStatCost ids the item packet carries outside the property list. */ +const STAT_ARMORCLASS = 31 +const STAT_QUANTITY = 70 +const STAT_DURABILITY = 72 +const STAT_MAXDURABILITY = 73 +/** `STAT_ITEM_REDUCEDPRICES`; read as a full (player + equipped items) stat by #10107. */ +const STAT_ITEM_REDUCEDPRICES = 87 +/** Body locations 1..12 used by the repair-all loop (0x6fd7a210 walks 13 slots, slot 0 unused). */ +const REPAIR_ALL_SLOTS = 13 + +/** + * Compact ("simple") item packets carry no quality field; the engine creates them + * as `ITEMQUAL_NORMAL` (2) — potions, scrolls, keys, gems, gamble previews. + */ +const QUALITY_NORMAL: ItemQuality113c = 2 +/** Item flag 0x02000000: packet stops after the item code (D2Common 0x6fd7a746). */ +const ITEM_FLAG_CODE_ONLY = 0x02000000 + +export function itemToTransactionCostInput( + item: ItemData, + socketed: readonly ItemData[], + type: 0 | 1 | 2 | 3, +): TransactionCostInput { + let quality: ItemQuality113c + if (item.quality !== undefined) quality = item.quality + else if (item.simple) quality = QUALITY_NORMAL + else if (type === 2 && (item.rawFlags & ITEM_FLAG_CODE_ONLY) !== 0) { + // Gamble previews are sent code-only (flag 0x02000000, capture 09); the gamble branch + // (0x6fd78f38 -> 0x6fd74840) prices from code, clvl and item format and never reads quality. + quality = QUALITY_NORMAL + } else throw new Error(`item #${item.id} (${item.code}) has no quality in its packet`) + + // Base list = fields the packet sends outside the property list; full = base + properties. + const acc = new Map() + const add = (statId: number, param: number, value: number, isBase: boolean): void => { + const k = `${statId}:${param}` + const cur = acc.get(k) ?? { statId, param, value: 0, base: 0 } + cur.value += value + if (isBase) cur.base += value + acc.set(k, cur) + } + if (item.defense !== undefined) add(STAT_ARMORCLASS, 0, item.defense, true) + if (item.maxDurability !== undefined) add(STAT_MAXDURABILITY, 0, item.maxDurability, true) + if (item.curDurability !== undefined) add(STAT_DURABILITY, 0, item.curDurability, true) + if (item.quantity !== undefined) add(STAT_QUANTITY, 0, item.quantity, true) + for (const s of item.stats) add(s.statId, s.param, s.value, false) + for (const s of item.runewordStats) add(s.statId, s.param, s.value, false) + const stats: TransactionCostStat[] = [...acc.values()] + + // Rare/crafted affixes are sent as 3 (prefix, suffix) pairs (item-bitstream.ts). + const ra = item.rareAffixes + const rare = + ra !== undefined && ra.length === 6 + ? { + rarePrefixIds: [ra[0]!, ra[2]!, ra[4]!] as const, + rareSuffixIds: [ra[1]!, ra[3]!, ra[5]!] as const, + } + : {} + + return { + code: item.code, + itemFormat: item.version, + quality, + identified: item.identified, + ethereal: item.ethereal, + starterItem: item.starterItem, + isEar: item.isEar, + ...(item.earLevel !== undefined ? { earLevel: item.earLevel } : {}), + ...(item.tomeData !== undefined ? { bookId: item.tomeData } : {}), + ...(item.magicPrefixId !== undefined ? { magicPrefixId: item.magicPrefixId } : {}), + ...(item.magicSuffixId !== undefined ? { magicSuffixId: item.magicSuffixId } : {}), + ...(item.autoAffixId !== undefined ? { autoAffixId: item.autoAffixId } : {}), + ...(item.uniqueItemId !== undefined ? { uniqueItemId: item.uniqueItemId } : {}), + ...(item.setItemId !== undefined ? { setItemId: item.setItemId } : {}), + ...rare, + stats, + socketedChildCodes: socketed.map(s => s.code), + } +} + +/** Pricing context for `npcClass` (MonStats hcIdx) from the server-mirrored world. */ +export function transactionCostContextFromWorld(world: ClientWorld, npcClass: number): TransactionCostContext { + let reduced = world.self.stats.get(STAT_ITEM_REDUCEDPRICES) + for (const it of world.containers.equipment.values()) { + for (const s of it.stats) if (s.statId === STAT_ITEM_REDUCEDPRICES) reduced += s.value + } + const quests = world.quests + return { + playerLevel: world.self.level, + playerReducedPricesPct: reduced, + difficulty: world.difficulty, + npcClass, + // 0x6fd7d0f0 tests bit `quest * 16 + state` of the quest record; S2C quest state mirrors it as 16-bit words. + isQuestFlagSet: (questId, state) => { + const word = quests[questId] + if (word === undefined) throw new Error(`quest ${questId} outside the mirrored quest record`) + return ((word >> state) & 1) === 1 + }, + tables: getCanonicalTransactionCostTables(), + } +} + +/** #10107 price of a server item for `type` 0 buy / 1 sell / 2 gamble / 3 repair. */ +export function priceItem113c(world: ClientWorld, item: ItemData, type: 0 | 1 | 2 | 3, npcClass: number): number { + const socketed = world.containers.sockets.get(item.id) ?? [] + return computeTransactionCost113c(itemToTransactionCostInput(item, socketed, type), transactionCostContextFromWorld(world, npcClass), type) +} + +/** #10071 repair-all cost over the player's equipped items (slots by body location). */ +export function repairAllCost113c(world: ClientWorld, npcClass: number): number { + const slots: (TransactionCostInput | null)[] = new Array(REPAIR_ALL_SLOTS).fill(null) + for (const it of world.containers.equipment.values()) { + const loc = it.bodyLoc + if (!(loc > 0 && loc < REPAIR_ALL_SLOTS)) throw new Error(`equipped item #${it.id} has body location ${loc}`) + slots[loc] = itemToTransactionCostInput(it, world.containers.sockets.get(it.id) ?? [], 3) + } + return computeRepairAllCost113c(slots, transactionCostContextFromWorld(world, npcClass)) +} diff --git a/tests/client/transaction-cost-input.test.ts b/tests/client/transaction-cost-input.test.ts new file mode 100644 index 0000000..c8a35df --- /dev/null +++ b/tests/client/transaction-cost-input.test.ts @@ -0,0 +1,37 @@ +/** + * Display prices for real server items (captures 05-09) through the 1.13c #10107/#10071 port. + */ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { replayD2CapFile } from '../../tools/netproto-replay.ts' +import { priceItem113c, repairAllCost113c } from '../../src/client/ui-model/transaction-cost-input.ts' + +const FIXTURES = path.resolve(__dirname, '../fixtures/netproto') +const CHARSI = 154 +const GHEED = 147 + +describe('1.13c prices for captured server items', () => { + it('prices every Charsi shop item (capture 05) without missing data', () => { + const { world } = replayD2CapFile(path.join(FIXTURES, '05-d2gs-vendor-open.d2cap')) + const shop = [...world.containers.shop.values()] + expect(shop.length).toBe(46) + for (const it of shop) { + const p = priceItem113c(world, it, 0, CHARSI) + expect(Number.isInteger(p)).toBe(true) + expect(p).toBeGreaterThan(0) + } + }) + + it('every gamble preview (capture 09) costs more than the 2 gold that made the server reject it (0x2A result 12)', () => { + const { world } = replayD2CapFile(path.join(FIXTURES, '09-d2gs-gamble.d2cap')) + const shop = [...world.containers.shop.values()] + expect(shop.length).toBe(14) + for (const it of shop) expect(priceItem113c(world, it, 2, GHEED)).toBeGreaterThan(2) + }) + + it('repair-all costs 0 with full durability (capture 07: server answered result 2 flag 1, gold unchanged)', () => { + const { world } = replayD2CapFile(path.join(FIXTURES, '07-d2gs-vendor-repair.d2cap')) + expect(world.containers.equipment.size).toBe(2) + expect(repairAllCost113c(world, CHARSI)).toBe(0) + }) +})