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.
This commit is contained in:
troytt 2026-10-01 10:29:07 +00:00
parent 642cecbd2b
commit e0210d8702
2 changed files with 173 additions and 0 deletions

View File

@ -0,0 +1,136 @@
/**
* Builds D2Common 1.13c #10107 / #10071 pricing inputs from server item packets
* (`src/client/ui-model/transaction-cost-input.ts`). Prices are display-only:
* D2Game ignores the client cost field (buy 0x6fcdf200, sell 0x6fcef570).
*/
import {
computeRepairAllCost113c,
computeTransactionCost113c,
getCanonicalTransactionCostTables,
type ItemQuality113c,
type TransactionCostContext,
type TransactionCostInput,
type TransactionCostStat,
} from '../../common/items/transaction-cost.ts'
import type { ItemData } from '../../netproto/index.ts'
import type { ClientWorld } from '../world/client-world.ts'
/** ItemStatCost ids the item packet carries outside the property list. */
const STAT_ARMORCLASS = 31
const STAT_QUANTITY = 70
const STAT_DURABILITY = 72
const STAT_MAXDURABILITY = 73
/** `STAT_ITEM_REDUCEDPRICES`; read as a full (player + equipped items) stat by #10107. */
const STAT_ITEM_REDUCEDPRICES = 87
/** Body locations 1..12 used by the repair-all loop (0x6fd7a210 walks 13 slots, slot 0 unused). */
const REPAIR_ALL_SLOTS = 13
/**
* Compact ("simple") item packets carry no quality field; the engine creates them
* as `ITEMQUAL_NORMAL` (2) — potions, scrolls, keys, gems, gamble previews.
*/
const QUALITY_NORMAL: ItemQuality113c = 2
/** Item flag 0x02000000: packet stops after the item code (D2Common 0x6fd7a746). */
const ITEM_FLAG_CODE_ONLY = 0x02000000
export function itemToTransactionCostInput(
item: ItemData,
socketed: readonly ItemData[],
type: 0 | 1 | 2 | 3,
): TransactionCostInput {
let quality: ItemQuality113c
if (item.quality !== undefined) quality = item.quality
else if (item.simple) quality = QUALITY_NORMAL
else if (type === 2 && (item.rawFlags & ITEM_FLAG_CODE_ONLY) !== 0) {
// Gamble previews are sent code-only (flag 0x02000000, capture 09); the gamble branch
// (0x6fd78f38 -> 0x6fd74840) prices from code, clvl and item format and never reads quality.
quality = QUALITY_NORMAL
} else throw new Error(`item #${item.id} (${item.code}) has no quality in its packet`)
// Base list = fields the packet sends outside the property list; full = base + properties.
const acc = new Map<string, { statId: number; param: number; value: number; base: number }>()
const add = (statId: number, param: number, value: number, isBase: boolean): void => {
const k = `${statId}:${param}`
const cur = acc.get(k) ?? { statId, param, value: 0, base: 0 }
cur.value += value
if (isBase) cur.base += value
acc.set(k, cur)
}
if (item.defense !== undefined) add(STAT_ARMORCLASS, 0, item.defense, true)
if (item.maxDurability !== undefined) add(STAT_MAXDURABILITY, 0, item.maxDurability, true)
if (item.curDurability !== undefined) add(STAT_DURABILITY, 0, item.curDurability, true)
if (item.quantity !== undefined) add(STAT_QUANTITY, 0, item.quantity, true)
for (const s of item.stats) add(s.statId, s.param, s.value, false)
for (const s of item.runewordStats) add(s.statId, s.param, s.value, false)
const stats: TransactionCostStat[] = [...acc.values()]
// Rare/crafted affixes are sent as 3 (prefix, suffix) pairs (item-bitstream.ts).
const ra = item.rareAffixes
const rare =
ra !== undefined && ra.length === 6
? {
rarePrefixIds: [ra[0]!, ra[2]!, ra[4]!] as const,
rareSuffixIds: [ra[1]!, ra[3]!, ra[5]!] as const,
}
: {}
return {
code: item.code,
itemFormat: item.version,
quality,
identified: item.identified,
ethereal: item.ethereal,
starterItem: item.starterItem,
isEar: item.isEar,
...(item.earLevel !== undefined ? { earLevel: item.earLevel } : {}),
...(item.tomeData !== undefined ? { bookId: item.tomeData } : {}),
...(item.magicPrefixId !== undefined ? { magicPrefixId: item.magicPrefixId } : {}),
...(item.magicSuffixId !== undefined ? { magicSuffixId: item.magicSuffixId } : {}),
...(item.autoAffixId !== undefined ? { autoAffixId: item.autoAffixId } : {}),
...(item.uniqueItemId !== undefined ? { uniqueItemId: item.uniqueItemId } : {}),
...(item.setItemId !== undefined ? { setItemId: item.setItemId } : {}),
...rare,
stats,
socketedChildCodes: socketed.map(s => s.code),
}
}
/** Pricing context for `npcClass` (MonStats hcIdx) from the server-mirrored world. */
export function transactionCostContextFromWorld(world: ClientWorld, npcClass: number): TransactionCostContext {
let reduced = world.self.stats.get(STAT_ITEM_REDUCEDPRICES)
for (const it of world.containers.equipment.values()) {
for (const s of it.stats) if (s.statId === STAT_ITEM_REDUCEDPRICES) reduced += s.value
}
const quests = world.quests
return {
playerLevel: world.self.level,
playerReducedPricesPct: reduced,
difficulty: world.difficulty,
npcClass,
// 0x6fd7d0f0 tests bit `quest * 16 + state` of the quest record; S2C quest state mirrors it as 16-bit words.
isQuestFlagSet: (questId, state) => {
const word = quests[questId]
if (word === undefined) throw new Error(`quest ${questId} outside the mirrored quest record`)
return ((word >> state) & 1) === 1
},
tables: getCanonicalTransactionCostTables(),
}
}
/** #10107 price of a server item for `type` 0 buy / 1 sell / 2 gamble / 3 repair. */
export function priceItem113c(world: ClientWorld, item: ItemData, type: 0 | 1 | 2 | 3, npcClass: number): number {
const socketed = world.containers.sockets.get(item.id) ?? []
return computeTransactionCost113c(itemToTransactionCostInput(item, socketed, type), transactionCostContextFromWorld(world, npcClass), type)
}
/** #10071 repair-all cost over the player's equipped items (slots by body location). */
export function repairAllCost113c(world: ClientWorld, npcClass: number): number {
const slots: (TransactionCostInput | null)[] = new Array(REPAIR_ALL_SLOTS).fill(null)
for (const it of world.containers.equipment.values()) {
const loc = it.bodyLoc
if (!(loc > 0 && loc < REPAIR_ALL_SLOTS)) throw new Error(`equipped item #${it.id} has body location ${loc}`)
slots[loc] = itemToTransactionCostInput(it, world.containers.sockets.get(it.id) ?? [], 3)
}
return computeRepairAllCost113c(slots, transactionCostContextFromWorld(world, npcClass))
}

View File

@ -0,0 +1,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)
})
})