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:
parent
642cecbd2b
commit
e0210d8702
|
|
@ -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,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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue