From 642cecbd2b5c19f97a7bffd9bdb275136ff51e3f Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:25:35 +0000 Subject: [PATCH] feat(items): port D2Common 1.13c #10107/#10071 transaction and repair-all cost computeTransactionCost113c (buy/sell/gamble/repair) and computeRepairAllCost113c ported branch by branch from 0x6fd78e80 and 0x6fd7a210 (addresses cited inline); missing table data throws. item-cost.ts is marked legacy single-player and loses its fabricated 1.10 addresses and max-buy defaults. Display-only: D2Game ignores the client cost field (0x6fcdf200 buy, 0x6fcef570 sell). Includes a capture cross-check: starter scrolls sell for 1 gold (fixture 06). --- src/common/items/item-cost.ts | 642 ++++++--------- src/common/items/transaction-cost.ts | 1137 ++++++++++++++++++++++++++ tests/item-cost.test.ts | 4 +- tests/transaction-cost-113c.test.ts | 307 +++++++ 4 files changed, 1685 insertions(+), 405 deletions(-) create mode 100644 src/common/items/transaction-cost.ts create mode 100644 tests/transaction-cost-113c.test.ts diff --git a/src/common/items/item-cost.ts b/src/common/items/item-cost.ts index 4685af0..5291a4c 100644 --- a/src/common/items/item-cost.ts +++ b/src/common/items/item-cost.ts @@ -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 { 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 { 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 { BUY = 0, @@ -14,15 +36,6 @@ export const enum TransactionType { 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 { readonly item: Item readonly transactionType: TransactionType @@ -30,78 +43,79 @@ export interface TransactionCostOptions { readonly cLvl?: number | undefined readonly vendorId?: VendorNpcId | 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 completedQuests?: ReadonlySet | undefined readonly reducedPricesPercent?: number | undefined 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 { + 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 { - const startType = getItemTypeCode(base).trim() - const queue: string[] = [startType, ...(base.tags ?? [])] - const visited = new Set() + const def = primaryTypeOf(base, itemTypes) + return Boolean(def?.class && def.class.trim().length > 0) +} - while (queue.length > 0) { - const code = queue.shift()!.trim() - if (!code || visited.has(code.toLowerCase())) continue - visited.add(code.toLowerCase()) +/** Whether the primary ItemType has StaffMods (`nStaffMods != 7`), as 0x6fd75970 checks. */ +export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean { + const def = primaryTypeOf(base, itemTypes) + return Boolean(def?.staffMods && def.staffMods.trim().length > 0) +} - const def = itemTypes.byCode.get(code) - if (!def) continue - if (def.class && def.class.trim().length > 0) { - return true - } - if (def.equiv1) queue.push(def.equiv1) - if (def.equiv2) queue.push(def.equiv2) - } - return false +/** Primary ItemType `Quiver` set (0x6fd74e60). */ +export function isQuiverItem(base: ItemBase): boolean { + const t = getCanonicalTransactionCostTables().itemTypes.get(txt(base).type) + if (!t) throw new Error(`item-cost: unknown ItemTypes code '${txt(base).type}'`) + return t.quiver +} + +/** `book` type, with equivalences (0x6fd74430). */ +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() - - 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 { - const isEthereal = - Boolean(item.flags?.ethereal) || - Boolean((item as any).ethereal) || - ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 - if (isEthereal) return false + if (isEthereal(item)) 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 - 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 if (maxDur > 0 && curDur < maxDur) { return true } - // Also repairable if stackable throwing weapon below maxStack - if ((item.base as any).stackable && item.base.kind === 'weapon') { - const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 + // A stackable throwing weapon below its max stack can be refilled. + if (rec.stackable && item.base.kind === 'weapon') { + const maxStack = maxStackOf(item.base) const qty = item.quantity ?? item.stack ?? maxStack 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')) { return true } @@ -133,8 +148,8 @@ export function isItemRepairable(item: Item): boolean { } /** - * Implements `ITEMS_CalculateAdditionalCostsForItemSkill` (`0x6FD9CB50`) - * for single-skill staffmods (`STAT_ITEM_SINGLESKILL`) on StaffMods-eligible item types. + * StaffMods pricing (`0x6fd75970`) for single-skill staffmods + * (`STAT_ITEM_SINGLESKILL`) on StaffMods item types. */ export function calculateAdditionalCostsForItemSkill( item: Item, @@ -156,15 +171,18 @@ export function calculateAdditionalCostsForItemSkill( 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 nBuy = 0 let nRep = 0 for (const sm of staffMods) { const skillId = Number(sm.param) - const slvl = Math.max(1, Math.trunc(sm.value)) - const skillRec = tables.skillCosts?.get(skillId) - if (!skillRec) continue + const slvl = Math.trunc(sm.value) + const skillRec = skillCosts.get(skillId) + if (!skillRec) throw new Error(`item-cost: no Skills.txt cost record for skill ${skillId}`) const factor = 2 * slvl - 1 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)) } - const effDiv = Math.max(1, divisor) return { - sellCost: sellCost + Math.trunc(nSell / effDiv), - buyCost: buyCost + Math.trunc(nBuy / effDiv), - repCost: repCost + Math.trunc(nRep / effDiv), + sellCost: sellCost + Math.trunc(nSell / divisor), + buyCost: buyCost + Math.trunc(nBuy / divisor), + repCost: repCost + Math.trunc(nRep / divisor), } } /** - * Implements `ITEMS_CalculateAdditionalCostsForBonusStats` (`0x6FD9ACE0`). - * Iterates resolved bonus stats on the item and applies `ItemStatCost.txt` - * (`Encode`, `Add`, `Multiply`, `Divide`) or `Skills.txt` (`CostAdd`, `CostMult`). + * Bonus-stat pricing (`0x6fd78850`): applies `ItemStatCost.txt` + * (`Encode`, `Add`, `Multiply`) or `Skills.txt` (`CostAdd`, `CostMult`) to each + * rolled stat. `Divide` is not read by 1.13c. */ export function calculateAdditionalCostsForBonusStats( item: Item, @@ -195,15 +212,13 @@ export function calculateAdditionalCostsForBonusStats( ): { sellCost: number; buyCost: number; repCost: number } { const propertiesTable = tables.properties const statCostTable = tables.itemStatCost - if (!statCostTable) { - return { sellCost, buyCost, repCost } - } + if (!statCostTable) throw new Error('item-cost: DropTables.itemStatCost is required for bonus-stat pricing') let nSell = 0 let nBuy = 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(item.staffMods ?? []) const props = item.rolledProps ?? [] @@ -211,14 +226,12 @@ export function calculateAdditionalCostsForBonusStats( readonly statId: number readonly param: number readonly value: number - readonly propCode?: string - readonly min?: number - readonly max?: number } 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) { if (staffModSet.has(prop)) continue if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) { @@ -237,33 +250,27 @@ export function calculateAdditionalCostsForBonusStats( prop.value, statCostTable, ) - if (resolved.length > 0) { - for (const r of resolved) { - entries.push({ - statId: r.statId, - param: r.param ?? numParam, - value: r.value, - propCode: prop.code, - min: prop.min, - max: prop.max, - }) - } + for (const r of resolved) { + entries.push({ statId: r.statId, param: r.param ?? numParam, value: r.value }) } } } else if (item.statList && item.statList.length > 0) { for (const s of item.statList) { - entries.push({ - statId: s.statId, - param: s.param ?? 0, - value: s.value, - }) + entries.push({ 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) { const statRec = statCostTable.getById(entry.statId) - if (!statRec) continue - // STAT_ITEM_SINGLESKILL (107) on staffMod items is priced by calculateAdditionalCostsForItemSkill + if (!statRec) throw new Error(`item-cost: no ItemStatCost record for stat ${entry.statId}`) + // STAT_ITEM_SINGLESKILL (107) on StaffMods items is priced by calculateAdditionalCostsForItemSkill. if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) { continue } @@ -272,47 +279,45 @@ export function calculateAdditionalCostsForBonusStats( switch (statRec.encode) { case 1: { - const skillRec = tables.skillCosts?.get(entry.param) - if (skillRec) { - nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) - nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) - nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024) - } + const skillRec = requireSkill(entry.param) + nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) + nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) + nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024) break } case 2: case 3: { - const skillId = entry.param > 63 ? entry.param >> 6 : entry.param - const slvl = entry.param > 63 ? Math.max(1, entry.param & 0x3f) : Math.max(1, entry.max ?? 1) - const skillRec = tables.skillCosts?.get(skillId) - if (skillRec) { - nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) - nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096) - nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024) - } + const skillRec = requireSkill(entry.param >> SKILL_PARAM_SHIFT) + const slvl = entry.param & SKILL_PARAM_MASK + nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) + nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096) + nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024) break } default: { - if (statRec.multiply > 0 || statRec.add > 0) { - nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) - nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) - nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024) - } + nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) + nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) + nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024) break } } } - const effDiv = Math.max(1, divisor) return { - sellCost: sellCost + Math.trunc(nSell / effDiv), - buyCost: buyCost + Math.trunc(nBuy / effDiv), - repCost: repCost + Math.trunc(nRep / effDiv), + sellCost: sellCost + Math.trunc(nSell / divisor), + buyCost: buyCost + Math.trunc(nBuy / divisor), + 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: { readonly item: Item @@ -320,87 +325,35 @@ export function calculateGambleCost(options: { readonly reducedPricesPercent?: number | undefined readonly tables: DropTables }): number { - const { item, cLvl, reducedPricesPercent = 0, tables } = options - const normCode = item.base.normcode || item.base.id - const normBase = tables.getBase(normCode) ?? item.base - const codeLower = normBase.id.toLowerCase() + const { item, cLvl, reducedPricesPercent = 0 } = options + let cost = computeGambleBaseCost113c(item.base.id, Math.trunc(cLvl), getCanonicalTransactionCostTables()) + const rp = capReducedPrices(reducedPricesPercent) + if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0 + return cost +} - let nCost = 0 - if (codeLower === 'rin' || codeLower === 'amu') { - nCost = normBase.gambleCost ?? (codeLower === 'rin' ? 50000 : 63000) - } else { - const minStack = Number((normBase as any).minstack) || 0 - const maxStack = Number((normBase as any).maxstack) || 0 - const v27 = Math.max(1, Math.trunc((minStack + maxStack) / 2)) +/** Row index of the rolled AutoMagic affix. drop-tables.ts spreads `rolledAutoMagic` onto the item without a typed field. */ +function rolledAutoMagicRow(item: Item): number | undefined { + if (!('rolledAutoMagic' in item)) return undefined + const rolled: unknown = item.rolledAutoMagic + if (rolled === undefined) return undefined + const parsed = rolled as Partial + if (!parsed.entry || typeof parsed.entry.id !== 'number') throw new Error('item-cost: malformed rolledAutoMagic') + return parsed.entry.id +} - let v23 = 0 - let nUberCost = 0 - if (normBase.ubercode && normBase.ubercode !== '0' && normBase.ubercode !== 'xxx') { - const uberBase = tables.getBase(normBase.ubercode) - if (uberBase) { - const v30 = 100 * (cLvl - uberBase.level) - v23 = Math.max(0, Math.trunc(v30 / 2) + 1) - 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) +function resolveNpcRecord(tables: DropTables, vendorId?: VendorNpcId, npcKey?: string): NpcRecord { + if (!tables.npcTable) throw new Error('item-cost: DropTables.npcTable is required') + const rec = + (npcKey ? tables.npcTable.get(npcKey) : undefined) ?? + (vendorId !== undefined ? tables.npcTable.getByVendorId(vendorId) : undefined) + if (!rec) throw new Error(`item-cost: no Npc.txt record for npcKey=${npcKey ?? '-'} vendorId=${vendorId ?? '-'}`) + return rec } /** - * Resolves the NpcRecord for a vendor / town NPC. - */ -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`). + * LEGACY transaction pricing over `Item` (structured after 0x6fd78e80). + * Prefer `computeTransactionCost113c`. */ export function calculateTransactionCost(options: TransactionCostOptions): number { const { @@ -410,7 +363,6 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe cLvl = 1, vendorId, npcKey, - actIndex, completedQuests, reducedPricesPercent = 0, tables, @@ -428,17 +380,13 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe } if (transactionType === TransactionType.GAMBLE) { - return calculateGambleCost({ - item, - cLvl, - reducedPricesPercent, - tables, - }) + return calculateGambleCost({ item, cLvl, reducedPricesPercent, tables }) } + const rec = txt(item.base) const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1)) - const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) - const baseCost = item.base.cost ?? item.base.value ?? 1 + const rp = capReducedPrices(reducedPricesPercent) + const baseCost = rec.cost let nSellCost = 0 let nBuyCost = 0 @@ -450,20 +398,20 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe ((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0 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 nBuyCost = nSellCost } else if (isBookItem(item.base)) { const bookRec = tables.booksTable?.getByBookCode(item.base.id) - const costPerCharge = bookRec?.costPerCharge ?? 80 - nSellCost = baseCost + nQuantity * costPerCharge + if (!bookRec) throw new Error(`item-cost: no Books.txt record for '${item.base.id}'`) + nSellCost = baseCost + nQuantity * bookRec.costPerCharge nBuyCost = nSellCost } else if (isQuiverItem(item.base)) { nSellCost = Math.trunc((nQuantity * baseCost) / 1024) nBuyCost = nSellCost - const maxStack = Math.min(511, item.base.maxStack || 350) - nRepCost = Math.trunc((maxStack * baseCost) / 1024) - } else if (!(item.base as any).stackable) { + nRepCost = Math.trunc((maxStackOf(item.base) * baseCost) / 1024) + } else if (!rec.stackable) { nSellCost = baseCost nBuyCost = nSellCost nRepCost = nSellCost @@ -471,18 +419,19 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe nSellCost = baseCost nBuyCost = nSellCost nRepCost = nSellCost - const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 - nDivisor = Math.max(1, Math.min(511, maxStack)) + const maxStack = maxStackOf(item.base) + nDivisor = maxStack > 1 ? maxStack : 1 } - // Armor defense scaling (lines 2119-2131 of Items.cpp) - if (item.base.kind === 'armor') { - const minAc = Number((item.base as any).minac) || 0 - const maxAc = Number((item.base as any).maxac) || item.base.defense || 0 - if (maxAc > 0 && maxAc - minAc !== -1) { - // Base unit STAT_ARMORCLASS (before ac% bonus stat) + // 0x6fd7900b: armor scales by base defense over max defense + if (isItemCodeOfType113c(getCanonicalTransactionCostTables(), item.base.id, 'armo')) { + const minAc = rec.minAc + const maxAc = rec.maxAc + if (maxAc !== 0 && maxAc - minAc !== -1) { + // 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 rawDef = item.defense ?? item.stats?.defense ?? maxAc const baseAc = acPct > 0 ? 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 - // StaffMods for Normal / Low / Superior items (lines 2135-2138) + // StaffMods before affixes for non-magic qualities if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) { - const afterSkill = calculateAdditionalCostsForItemSkill( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) + const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables) nSellCost = afterSkill.sellCost nBuyCost = afterSkill.buyCost nRepCost = afterSkill.repCost @@ -517,16 +459,25 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe let nSell = 0 let nBuy = 0 let nRep = 0 - - // 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 + const addTerm = (add: number, mult: number): void => { nSell += add + Math.trunc((nSellCost * mult) / 1024) nBuy += add + Math.trunc((nBuyCost * 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) { case ItemQuality.RARE: @@ -534,25 +485,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe const rareAffixes = item.rolledRareAffixes?.affixes ?? [] for (const entry of rareAffixes) { const affix = entry.affix ?? entry - const add = Number(affix?.add) || 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) - } + addTerm(Number(affix?.add) || 0, Number(affix?.multiply) || 0) } - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + applyBonus() break } @@ -561,23 +496,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe item.uniqueItemDef ?? tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name) if (uDef) { - const costAdd = Number(uDef.costAdd ?? (uDef as any).costadd) || 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) + addTerm(Number(uDef.costAdd) || 0, Number(uDef.costMult) || 0) } else { - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + applyBonus() } break } @@ -588,62 +509,23 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe tables.sets.items.entries.find( s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name, ) - if (sDef) { - 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) - } + if (sDef) addTerm(Number(sDef.costAdd) || 0, Number(sDef.costMult) || 0) break } case ItemQuality.MAGIC: { const pAffix = item.rolledMagicAffixes?.prefix?.affix - if (pAffix) { - 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) - } + if (pAffix) addTerm(Number(pAffix.add) || 0, Number(pAffix.multiply) || 0) const sAffix = item.rolledMagicAffixes?.suffix?.affix - if (sAffix) { - const add = Number(sAffix.add) || 0 - 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 + if (sAffix) addTerm(Number(sAffix.add) || 0, Number(sAffix.multiply) || 0) + applyBonus() break } case ItemQuality.SUPERIOR: - case ItemQuality.TEMPERED: { - const afterBonus = calculateAdditionalCostsForBonusStats( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) - nSellCost = afterBonus.sellCost - nBuyCost = afterBonus.buyCost - nRepCost = afterBonus.repCost + case ItemQuality.TEMPERED: + applyBonus() break - } case ItemQuality.LOW: { nSell = -Math.trunc(nSellCost / 2) @@ -661,74 +543,52 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe nRepCost += Math.trunc(nRep / nDivisor) if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) { - const afterSkill = calculateAdditionalCostsForItemSkill( - item, - nSellCost, - nBuyCost, - nRepCost, - nDivisor, - tables, - ) + const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables) nSellCost = afterSkill.sellCost nBuyCost = afterSkill.buyCost nRepCost = afterSkill.repCost } } - // Socketed items (lines 2311-2325 of Items.cpp) - if (item.socketedItems && item.socketedItems.length > 0) { - for (const socketable of item.socketedItems) { - const socketableCost = Math.trunc((socketable.base.cost ?? socketable.base.value ?? 0) / 2) - nSellCost += socketableCost - nBuyCost += socketableCost - nRepCost += socketableCost - } + // 0x6fd74a50: socketed children add half their ItemsTxt cost + for (const socketable of item.socketedItems ?? []) { + const socketableCost = Math.trunc(txt(socketable.base).cost / 2) + nSellCost += socketableCost + nBuyCost += socketableCost + nRepCost += socketableCost } - // Ethereal acquisition quartering (lines 2327-2330) - const isEthereal = - Boolean(item.flags?.ethereal) || - Boolean((item as any).ethereal) || - ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 - if (isEthereal) { + // 0x6fd798b0: ethereal and class-specific quartering of the player-sell price + const ethereal = isEthereal(item) + if (ethereal) { nBuyCost = Math.trunc(nBuyCost / 4) } - - // Class-specific item acquisition quartering (lines 2332-2340) if (isClassSpecificItemBase(item.base, tables.itemTypes)) { 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 if (transactionType === TransactionType.SELL) { - if (isEthereal && maxDura > 0 && curDura <= 0) { + if (ethereal && maxDura > 0 && curDura <= 0) { nBuyCost = 0 } } else if (transactionType === TransactionType.REPAIR) { if (maxDura > 0) { - if (curDura < maxDura) { - nRepCost = Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) - } else { - nRepCost = 0 - } + nRepCost = curDura < maxDura ? Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) : 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 sellMult = npcRec?.sellMult ?? 1024 - const buyMult = npcRec?.buyMult ?? 512 - const repMult = npcRec?.repMult ?? 1024 + nSellCost = Math.trunc((nSellCost * npcRec.sellMult) / 1024) + nBuyCost = Math.trunc((nBuyCost * npcRec.buyMult) / 1024) + nRepCost = Math.trunc((nRepCost * npcRec.repMult) / 1024) - nSellCost = Math.trunc((nSellCost * sellMult) / 1024) - nBuyCost = Math.trunc((nBuyCost * buyMult) / 1024) - nRepCost = Math.trunc((nRepCost * repMult) / 1024) - - if (npcRec && completedQuests && completedQuests.size > 0) { + if (completedQuests && completedQuests.size > 0) { for (let i = 0; i < 3; i++) { - const qFlag = npcRec.questFlag[i] ?? 0 + const qFlag = npcRec.questFlag[i]! if (qFlag > 0 && completedQuests.has(qFlag)) { nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[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)) { nSellCost *= nQuantity - if ((item.base as any).stackable && isItemRepairable(item)) { - const maxStack = Math.min(511, item.base.maxStack || Number((item.base as any).maxstack) || 1) - if (nQuantity < maxStack) { - nRepCost *= maxStack - nQuantity - } else { - nRepCost = 0 - } - nBuyCost *= nQuantity - } else { - nBuyCost *= nQuantity + if (rec.stackable && isItemRepairable(item)) { + const maxStack = maxStackOf(item.base) + nRepCost = nQuantity < maxStack ? nRepCost * (maxStack - nQuantity) : 0 } + nBuyCost *= nQuantity } if (transactionType === TransactionType.SELL) { - let nCost = nBuyCost - let maxBuyCap: number - 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 - } + const cap = npcRec.maxBuy[difficulty] + const nCost = nBuyCost > cap ? cap : nBuyCost return Math.max(1, nCost) } 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) } - // TransactionType.BUY - let nCost = nSellCost - Math.trunc((nSellCost * clampedReduce) / 100) + const nCost = rp !== 0 ? nSellCost - fogMulDiv113c(rp, nSellCost, 100) : nSellCost return Math.max(1, nCost) } diff --git a/src/common/items/transaction-cost.ts b/src/common/items/transaction-cost.ts new file mode 100644 index 0000000..aeaf196 --- /dev/null +++ b/src/common/items/transaction-cost.ts @@ -0,0 +1,1137 @@ +/** + * Diablo II 1.13c item transaction pricing, ported from `D2Common.dll`. + * + * - `#10107` (`ITEMS_GetTransactionCost`) is a thunk at `0x6fd79d60` that + * forwards to the real body at `0x6fd78e80`. That body is + * {@link computeTransactionCost113c}. + * - `#10071` at `0x6fd7a210` totals the repair cost of everything the player + * has equipped. That is {@link computeRepairAllCost113c}. + * + * Every branch carries the address it was read from. All arithmetic is int32 + * (`Math.imul`, `| 0`, truncating division), as the x86 code does it, + * including the asm's quirks; those are marked `(sic)`. + * + * The input does not depend on any protocol. A client fills it from a + * server item packet; a server fills it from its own unit. The caller must + * supply the item's stat list (`full` and `base` values, the same split as + * `STATLIST_GetUnitStat*` 0x6fd88a80 / 0x6fd88b00). Durability, quantity, base + * defense and the like are read from that list, as the asm does. + * + * Names used below: + * - A = player buy price (vendor's "sell" side; Npc.txt `sell mult`). + * - B = player sell price (vendor's "buy" side; Npc.txt `buy mult`). The asm + * later moves it into `ebp`. + * - C = repair base. The asm later moves it into `edi`. + * - D = divisor applied to affix terms (stack size for stackables). + */ +import { RAW_ARMOR, RAW_ITEM_TYPES, RAW_MAGIC_PREFIX, RAW_MAGIC_SUFFIX, RAW_MISC, RAW_MONSTATS, RAW_SET_ITEMS, RAW_UNIQUE_ITEMS, RAW_WEAPONS } from '../data/canonical-drop-data.ts' +import { CANONICAL_SKILL_COSTS, RAW_BOOKS, RAW_ITEM_STAT_COST, RAW_NPC } from '../data/canonical-vendor-data.ts' +import { SKILLS_BY_ID } from '../data/skills-catalog.ts' +import { parseTable, type D2Table } from '../world/acts.ts' +import { CANONICAL_AUTOMAGIC_TSV } from './automagic.ts' + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * `D2C_TransactionTypes`: 0 = buy, 1 = sell, 2 = gamble, 3 = repair. + */ +export type TransactionType113c = 0 | 1 | 2 | 3 + +/** One entry of the item's stat list. */ +export interface TransactionCostStat { + readonly statId: number + /** Stat param (layer). Skill stats carry the skill id, or `skill << 6 | level`. */ + readonly param: number + /** Accumulated value (`0x6fd88a80`). */ + readonly value: number + /** Value in the item's own base list (`0x6fd88b00`). Magic properties are excluded. */ + readonly base: number +} + +/** Item quality as defined by `D2C_ItemQuality` (1 = low ... 9 = tempered). */ +export type ItemQuality113c = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + +/** What the pricing reads from one item. Ids are the ones the item packet carries. */ +export interface TransactionCostInput { + /** ItemsTxt code (`szCode`, 3-4 chars). */ + readonly code: string + /** + * `ItemData.wItemFormat` (+0x30), which is the packet's 10-bit `version` + * field. Only the gamble branch reads it (0x6fd78f38). + */ + readonly itemFormat: number + readonly quality: ItemQuality113c + /** `IFLAG_IDENTIFIED` (0x10). */ + readonly identified: boolean + /** `IFLAG_ETHEREAL` (0x400000). */ + readonly ethereal: boolean + /** `IFLAG_STARTER` (0x20000). */ + readonly starterItem: boolean + /** `IFLAG_ISEAR` (0x10000). */ + readonly isEar: boolean + /** Ear level (`ItemData+0x48`). Required when `isEar`. */ + readonly earLevel?: number | undefined + /** MonStats index (hcIdx) stored in `dwFileIndex` of a body part. Required for `body` items. */ + readonly bodyPartMonsterClass?: number | undefined + /** Books.txt row (`wMagicSuffix[0]`, the packet's 5-bit tome/scroll id). Required for `book` items. */ + readonly bookId?: number | undefined + /** Packet magic prefix id: 1-based into MagicPrefix.txt, 0 = none. Required for quality 4, and for 7 when the unique id is invalid. */ + readonly magicPrefixId?: number | undefined + /** Packet magic suffix id: 1-based into MagicSuffix.txt, 0 = none. */ + readonly magicSuffixId?: number | undefined + /** Three packet prefix ids for rare/crafted items (0 = none). */ + readonly rarePrefixIds?: readonly [number, number, number] | undefined + /** Three packet suffix ids for rare/crafted items (0 = none). */ + readonly rareSuffixIds?: readonly [number, number, number] | undefined + /** Packet automagic id: 1-based into AutoMagic.txt, 0 = none. */ + readonly autoAffixId?: number | undefined + /** UniqueItems.txt record index (`dwFileIndex`). Required for quality 7. */ + readonly uniqueItemId?: number | undefined + /** SetItems.txt record index (`dwFileIndex`). Required for quality 5. */ + readonly setItemId?: number | undefined + /** The item's stat list (full and base values). */ + readonly stats: readonly TransactionCostStat[] + /** ItemsTxt codes of the items socketed into this one. */ + readonly socketedChildCodes: readonly string[] +} + +/** Item-independent state of the transaction. */ +export interface TransactionCostContext { + /** Player `STAT_LEVEL` (12). */ + readonly playerLevel: number + /** Player full `STAT_ITEM_REDUCEDPRICES` (87). The asm only caps it at 99. */ + readonly playerReducedPricesPct: number + readonly difficulty: 0 | 1 | 2 + /** MonStats index (hcIdx) of the NPC (`Npc.txt` is searched on it). Not read for gamble. */ + readonly npcClass: number + /** Quest-flag test `0x6fd7d0f0`: bit `quest * 16 + state` of the player's quest record. */ + readonly isQuestFlagSet: (questId: number, state: 0 | 1) => boolean + readonly tables: TransactionCostTables +} + +export interface ItemsTxtCostRecord { + readonly code: string + readonly type: string + readonly type2: string + /** +0xe0 */ + readonly cost: number + /** +0xd4 */ + readonly gambleCost: number + /** +0xe4 / +0xe8 */ + readonly minStack: number + readonly maxStack: number + /** +0x132 */ + readonly stackable: boolean + /** +0x112 (byte) */ + readonly durability: number + /** +0x113 */ + readonly noDurability: boolean + /** +0xfd (byte) */ + readonly level: number + /** +0x84 / +0x88 / +0x8c. Empty string when the field is 0. */ + readonly normCode: string + readonly uberCode: string + readonly ultraCode: string + /** +0xcc / +0xd0 */ + readonly minAc: number + readonly maxAc: number +} + +export interface ItemTypeCostRecord { + readonly code: string + readonly equiv1: string + readonly equiv2: string + /** +0x08 */ + readonly repair: boolean + /** +0x0e: the `Quiver` word is non-zero */ + readonly quiver: boolean + /** +0x10 */ + readonly throwable: boolean + /** +0x1f: `StaffMods` is a class (not 7 = none) */ + readonly staffMods: boolean + /** +0x21: `Class` < 7 */ + readonly classSpecific: boolean +} + +export interface CostMultAdd { + readonly name: string + readonly multiply: number + readonly add: number +} + +export interface ItemStatCostRecord { + readonly id: number + /** +0x30 */ + readonly encode: number + /** +0x14 */ + readonly add: number + /** +0x10 */ + readonly multiply: number + /** +0x18 */ + readonly valShift: number +} + +export interface SkillCostRecord { + readonly id: number + /** Skills.txt +0x234 */ + readonly costMult: number + /** Skills.txt +0x238 */ + readonly costAdd: number + /** Skills.txt +0x174. Undefined when the embedded data has no value; recharge pricing then throws. */ + readonly reqLevel: number | undefined +} + +export interface BookCostRecord { + readonly name: string + /** +0x10. The pricing does not read it (it uses ItemsTxt cost). */ + readonly baseCost: number + /** +0x14 */ + readonly costPerCharge: number +} + +export interface NpcCostRecord { + readonly name: string + /** +0x00: MonStats hcIdx */ + readonly npcClass: number + /** +0x04: Npc.txt `sell mult`, applied to A (player buy price) */ + readonly sellMult: number + /** +0x08: Npc.txt `buy mult`, applied to B (player sell price) */ + readonly buyMult: number + /** +0x0c */ + readonly repMult: number + /** +0x10 */ + readonly questFlags: readonly [number, number, number] + /** +0x1c, applied to A */ + readonly questSellMults: readonly [number, number, number] + /** +0x28, applied to B */ + readonly questBuyMults: readonly [number, number, number] + /** +0x34 */ + readonly questRepMults: readonly [number, number, number] + /** +0x40, one per difficulty */ + readonly maxBuy: readonly [number, number, number] +} + +/** Compiled data the pricing reads. Build it with {@link buildTransactionCostTables}. */ +export interface TransactionCostTables { + readonly items: ReadonlyMap + readonly itemTypes: ReadonlyMap + /** Compiled rows (with "Expansion" rows removed), 0-based. Packet id `n` is row `n - 1`. */ + readonly magicPrefixes: readonly CostMultAdd[] + readonly magicSuffixes: readonly CostMultAdd[] + readonly autoMagic: readonly CostMultAdd[] + /** Compiled rows (with "Expansion" rows removed), indexed directly by `dwFileIndex`. */ + readonly uniqueItems: readonly CostMultAdd[] + readonly setItems: readonly CostMultAdd[] + readonly itemStatCost: ReadonlyMap + readonly skills: ReadonlyMap + readonly books: readonly BookCostRecord[] + readonly npcs: ReadonlyMap + /** Lower-cased `npc` name → MonStats hcIdx, for callers that know NPCs by name. */ + readonly npcClassByName: ReadonlyMap + /** MonStats `Level`, `Level(N)`, `Level(H)` (+0xaa/+0xac/+0xae) by hcIdx. */ + readonly monsterLevels: readonly (readonly [number, number, number])[] + /** Bit split of encode-2/3 stat params (DataTables +0xc6c / +0xc70). */ + readonly skillParamShift: number + readonly skillParamMask: number +} + +/** Raw tables used to build {@link TransactionCostTables}. */ +export interface TransactionCostRawTables { + readonly weapons: D2Table + readonly armor: D2Table + readonly misc: D2Table + readonly itemTypes: D2Table + readonly magicPrefix: D2Table + readonly magicSuffix: D2Table + readonly autoMagic: D2Table + readonly uniqueItems: D2Table + readonly setItems: D2Table + readonly itemStatCost: D2Table + readonly books: D2Table + readonly npc: D2Table + readonly monStats: D2Table + readonly skills: readonly SkillCostRecord[] +} + +// --------------------------------------------------------------------------- +// Stat ids read by the asm +// --------------------------------------------------------------------------- + +const STAT_ARMORCLASS = 31 +const STAT_QUANTITY = 70 +const STAT_DURABILITY = 72 +const STAT_MAXDURABILITY = 73 +const STAT_ITEM_SINGLESKILL = 107 +const STAT_ITEM_INDESTRUCTIBLE = 152 +const STAT_ITEM_CHARGED_SKILL = 204 +const STAT_ITEM_REPLENISH_DURABILITY = 252 +const STAT_ITEM_REPLENISH_QUANTITY = 253 +const STAT_ITEM_EXTRA_STACK = 254 + +/** `0x6fd760f0` caps the stack size here. */ +const MAX_STACK_CAP = 511 +/** `0x6fd78e80` caps reduced prices here (with no lower clamp). */ +const REDUCED_PRICES_CAP = 99 +/** `0x6fd7a210` loops over body locations 0..12. */ +export const REPAIR_ALL_SLOT_COUNT = 13 + +// --------------------------------------------------------------------------- +// int32 helpers +// --------------------------------------------------------------------------- + +const imul = Math.imul + +/** Signed `idiv`, truncating; throws on division by zero (#DE in the original). */ +function idiv(a: number, b: number, where: string): number { + if (b === 0) throw new Error(`transaction-cost: integer division by zero at ${where}`) + return (a / b) | 0 +} + +/** Signed division by a power of two as compiled (`cdq; and; add; sar`), which truncates toward zero. */ +function sdiv(a: number, d: 1024 | 4096 | 4 | 2): number { + return (a / d) | 0 +} + +/** Unsigned `div` of an accumulator by the divisor; the result is put back into int32. */ +function udiv(acc: number, d: number, where: string): number { + if (d === 0) throw new Error(`transaction-cost: unsigned division by zero at ${where}`) + return Math.floor((acc >>> 0) / (d >>> 0)) | 0 +} + +/** + * `FOG_10182`-style mul-div at `0x6fd511e0` (eax = a, edx = b, ecx = c). + * Computes `a * b / c` without overflowing, choosing the path by magnitude. + */ +export function fogMulDiv113c(a: number, b: number, c: number): number { + if (c === 0) return 0 + const int64 = (): number => Number(BigInt.asIntN(32, (BigInt(a) * BigInt(b)) / BigInt(c))) + if (b > 0x100000) { + return c > b >> 4 ? int64() : imul(idiv(b, c, '0x6fd511e0'), a) + } + if (a > 0x10000) { + return c > a >> 4 ? int64() : imul(idiv(a, c, '0x6fd511e0'), b) + } + return idiv(imul(a, b), c, '0x6fd511e0') +} + +// --------------------------------------------------------------------------- +// Lookups +// --------------------------------------------------------------------------- + +function requireItem(tables: TransactionCostTables, code: string): ItemsTxtCostRecord { + const rec = tables.items.get(code) + if (!rec) throw new Error(`transaction-cost: unknown ItemsTxt code '${code}'`) + return rec +} + +function requireItemType(tables: TransactionCostTables, code: string): ItemTypeCostRecord { + const rec = tables.itemTypes.get(code) + if (!rec) throw new Error(`transaction-cost: unknown ItemTypes code '${code}'`) + return rec +} + +function requireSkill(tables: TransactionCostTables, id: number): SkillCostRecord { + const rec = tables.skills.get(id) + if (!rec) throw new Error(`transaction-cost: unknown skill id ${id}`) + return rec +} + +function requireIsc(tables: TransactionCostTables, id: number): ItemStatCostRecord { + const rec = tables.itemStatCost.get(id) + if (!rec) throw new Error(`transaction-cost: unknown ItemStatCost id ${id}`) + return rec +} + +/** + * Magic affix lookup (`0x6fdc1820`, records 1..count, 0 = none). + * The asm returns null for an out-of-range id. Here a non-zero id outside + * the table throws, because the combined table would otherwise resolve it to + * a record from a neighbouring table. + */ +function affixRecord(rows: readonly CostMultAdd[], id: number, what: string): CostMultAdd | null { + if (!Number.isInteger(id) || id < 0) throw new Error(`transaction-cost: invalid ${what} id ${id}`) + if (id === 0) return null + const rec = rows[id - 1] + if (!rec) throw new Error(`transaction-cost: ${what} id ${id} outside table (${rows.length} rows)`) + return rec +} + +function requireDefined(value: T | undefined, what: string): T { + if (value === undefined) throw new Error(`transaction-cost: missing input '${what}'`) + return value +} + +/** Full value of `(stat, param 0)`, i.e. `0x6fd88a80`. */ +function fullStat(item: TransactionCostInput, statId: number): number { + for (const s of item.stats) if (s.statId === statId && s.param === 0) return s.value | 0 + return 0 +} + +/** Base value of `(stat, param 0)`, i.e. `0x6fd88b00`. */ +function baseStat(item: TransactionCostInput, statId: number): number { + for (const s of item.stats) if (s.statId === statId && s.param === 0) return s.base | 0 + return 0 +} + +/** `ITEMS_CheckItemTypeId` at `0x6fd74430`: type or type2, walking Equiv1/Equiv2. */ +function isItemOfType(tables: TransactionCostTables, rec: ItemsTxtCostRecord, target: string): boolean { + const seen = new Set() + const queue: string[] = [] + if (rec.type) queue.push(rec.type) + if (rec.type2) queue.push(rec.type2) + while (queue.length > 0) { + const code = queue.pop()! + if (code === target) return true + if (seen.has(code)) continue + seen.add(code) + const t = requireItemType(tables, code) + if (t.equiv1) queue.push(t.equiv1) + if (t.equiv2) queue.push(t.equiv2) + } + return false +} + +/** `0x6fd74430` for an ItemsTxt code: whether its type/type2 is, or is equivalent to, `target`. */ +export function isItemCodeOfType113c(tables: TransactionCostTables, code: string, target: string): boolean { + return isItemOfType(tables, requireItem(tables, code), target) +} + +/** The primary ItemTypes record. ItemTypes flags are read from it only, with no equivalences. */ +function primaryType(tables: TransactionCostTables, rec: ItemsTxtCostRecord): ItemTypeCostRecord { + return requireItemType(tables, rec.type) +} + +/** `0x6fd88ff0`: max durability is the full value of stat 73 only if its base is non-zero. */ +function maxDurability(item: TransactionCostInput): number { + return baseStat(item, STAT_MAXDURABILITY) !== 0 ? fullStat(item, STAT_MAXDURABILITY) : 0 +} + +/** `0x6fd76d40` */ +function hasDurability(item: TransactionCostInput, rec: ItemsTxtCostRecord): boolean { + return ( + !rec.noDurability && + rec.durability > 0 && + maxDurability(item) !== 0 && + fullStat(item, STAT_ITEM_INDESTRUCTIBLE) <= 0 + ) +} + +/** `0x6fd760f0` */ +function maxStack(item: TransactionCostInput, rec: ItemsTxtCostRecord): number { + const n = (rec.maxStack + fullStat(item, STAT_ITEM_EXTRA_STACK)) | 0 + return n > MAX_STACK_CAP ? MAX_STACK_CAP : n +} + +/** `0x6fd74f00`: a stackable throwing weapon that can be refilled. */ +function isRefillableThrowing(tables: TransactionCostTables, item: TransactionCostInput, rec: ItemsTxtCostRecord): boolean { + const t = primaryType(tables, rec) + return t.repair && t.throwable && rec.stackable && !item.ethereal +} + +function hasMissingCharges(item: TransactionCostInput): boolean { + for (const s of item.stats) { + if (s.statId !== STAT_ITEM_CHARGED_SKILL) continue + if ((s.value & 0xff) < (s.value >> 8)) return true + } + return false +} + +/** `ITEMS_IsRepairable` at `0x6fd77090`. */ +export function isItemRepairable113c(item: TransactionCostInput, tables: TransactionCostTables): boolean { + if (!item.identified) return false + if (item.ethereal) return false + if (hasMissingCharges(item)) return true + const rec = requireItem(tables, item.code) + if (!primaryType(tables, rec).repair) return false + if (isRefillableThrowing(tables, item, rec)) return true + return hasDurability(item, rec) +} + +// --------------------------------------------------------------------------- +// Cost terms +// --------------------------------------------------------------------------- + +interface Triple { + a: number + b: number + c: number +} + +/** + * Affix / unique / set term (0x6fd790ce, 0x6fd7954f, 0x6fd79458, 0x6fd79720). + * The overflow guard looks at A only and is applied to all three. + */ +function multAddTerm(x: Triple, mult: number, add: number): Triple { + if (x.a > 0xffff && mult !== 0) { + return { + a: (imul(sdiv(x.a, 1024), mult) + add) | 0, + b: (imul(sdiv(x.b, 1024), mult) + add) | 0, + c: (imul(sdiv(x.c, 1024), mult) + add) | 0, + } + } + return { + a: (sdiv(imul(mult, x.a), 1024) + add) | 0, + b: (sdiv(imul(mult, x.b), 1024) + add) | 0, + c: (sdiv(imul(mult, x.c), 1024) + add) | 0, + } +} + +function addInto(acc: Triple, t: Triple): void { + acc.a = (acc.a + t.a) | 0 + acc.b = (acc.b + t.b) | 0 + acc.c = (acc.c + t.c) | 0 +} + +/** `ITEMS_CalculateAdditionalCostsForItemSkill` at `0x6fd75970` (StaffMods, stat 107). */ +function applyStaffMods(tables: TransactionCostTables, item: TransactionCostInput, rec: ItemsTxtCostRecord, x: Triple, divisor: number): void { + if (!primaryType(tables, rec).staffMods) return + let accA = 0 + let accB = 0 + let accC = 0 + for (const s of item.stats) { + if (s.statId !== STAT_ITEM_SINGLESKILL) continue + const skill = requireSkill(tables, s.param) + const mult = skill.costMult + const add = skill.costAdd + const f = (2 * s.value - 1) | 0 + if (x.a > 0xffff && mult !== 0) { + // (sic) the mult is pre-divided here, unlike the other guards. + accA = (accA + imul((imul(sdiv(mult, 1024), x.a) + add) | 0, f)) | 0 + accB = (accB + imul((imul(sdiv(mult, 4096), x.b) + add) | 0, f)) | 0 + accC = (accC + imul((imul(x.c, sdiv(mult, 1024)) + add) | 0, f)) | 0 + } else { + accA = (accA + imul((sdiv(imul(mult, x.a), 1024) + add) | 0, f)) | 0 + accB = (accB + imul((sdiv(imul(x.b, mult), 4096) + add) | 0, f)) | 0 + accC = (accC + imul((sdiv(imul(x.c, mult), 1024) + add) | 0, f)) | 0 + } + } + x.a = (x.a + udiv(accA, divisor, '0x6fd75970')) | 0 + x.b = (x.b + udiv(accB, divisor, '0x6fd75970')) | 0 + x.c = (x.c + udiv(accC, divisor, '0x6fd75970')) | 0 +} + +/** `ITEMS_CalculateAdditionalCostsForBonusStats` at `0x6fd78850`. */ +function applyBonusStats(tables: TransactionCostTables, item: TransactionCostInput, x: Triple, divisor: number): void { + let accA = 0 + let accB = 0 + let accC = 0 + const accumulate = (mult: number, add: number, v: number, bDiv: 1024 | 4096): void => { + const av = imul(x.a, v) + if (av > 0xffff && mult !== 0) { + accA = (accA + imul(sdiv(av, 1024), mult) + add) | 0 + accB = (accB + imul(sdiv(imul(x.b, v), bDiv), mult) + add) | 0 + accC = (accC + imul(sdiv(imul(x.c, v), 1024), mult) + add) | 0 + } else { + accA = (accA + sdiv(imul(imul(mult, x.a), v), 1024) + add) | 0 + accB = (accB + sdiv(imul(imul(x.b, mult), v), bDiv) + add) | 0 + accC = (accC + sdiv(imul(imul(x.c, mult), v), 1024) + add) | 0 + } + } + let visited = 0 + for (const s of item.stats) { + if (visited++ >= MAX_STACK_CAP) break // the stat array walk is bounded at 511 entries + let bonus = (s.value - s.base) | 0 + if (bonus === 0) continue + const isc = requireIsc(tables, s.statId) + bonus >>= isc.valShift + switch (isc.encode) { + case 1: { + const skill = requireSkill(tables, s.param) + accumulate(skill.costMult, skill.costAdd, bonus, 4096) + break + } + case 2: + case 3: { + const level = s.param & tables.skillParamMask + const skill = requireSkill(tables, s.param >> tables.skillParamShift) + accumulate(skill.costMult, skill.costAdd, level, 4096) + break + } + case 4: { + const min = ((bonus >> 2) & 0x3ff) - 0x100 + const max = ((bonus >> 12) & 0x3ff) - 0x100 + accumulate(isc.multiply, isc.add, sdiv(min + max, 2), 1024) + break + } + default: + accumulate(isc.multiply, isc.add, bonus, 1024) + break + } + } + x.a = (x.a + udiv(accA, divisor, '0x6fd78850')) | 0 + x.b = (x.b + udiv(accB, divisor, '0x6fd78850')) | 0 + x.c = (x.c + udiv(accC, divisor, '0x6fd78850')) | 0 +} + +/** Recharge cost of depleted charged skills (stat 204), at `0x6fd75810`. */ +function rechargeCost(tables: TransactionCostTables, item: TransactionCostInput): number { + let acc = 0 + for (const s of item.stats) { + if (s.statId !== STAT_ITEM_CHARGED_SKILL) continue + const level = s.param & tables.skillParamMask + const skill = requireSkill(tables, s.param >> tables.skillParamShift) + const charges = s.value & 0xff + const maxCharges = s.value >> 8 + if (charges >= maxCharges) continue + if (skill.reqLevel === undefined) { + throw new Error(`transaction-cost: skill ${skill.id} has no reqlevel; cannot price recharge`) + } + const l = (level + 2 * idiv(skill.reqLevel, 6, '0x6fd75810') + 2) | 0 + const x = imul(l, 10000) + let t = x > 0xffff && skill.costMult !== 0 + ? imul(sdiv(x, 1024), skill.costMult) + : sdiv(imul(imul(skill.costMult, l), 10000), 1024) + t = (t + skill.costAdd) | 0 + acc = (acc + idiv(imul(maxCharges - charges, t), maxCharges, '0x6fd75810')) | 0 + } + return acc +} + +/** + * Gamble price of an item code at a character level (`0x6fd74840`). + * The record used is `normcode` (falling back to the item's own code, as `0x6fd72ff0` does). + */ +export function computeGambleBaseCost113c(code: string, playerLevel: number, tables: TransactionCostTables): number { + const own = requireItem(tables, code) + const rec = own.normCode ? requireItem(tables, own.normCode) : own + if (rec.code === 'rin' || rec.code === 'amu') return rec.gambleCost // 0x6fd748b7 / 0x6fd74a40 + let avgStack = sdiv((rec.minStack + rec.maxStack) | 0, 2) + if (avgStack < 1) avgStack = 1 + const base = imul(rec.cost, avgStack) + let uberW = 0 + let uberCost = 0 + // 0x6fd7490c: a 0 field or "0 " is skipped. A code that does not resolve throws here (the asm skips it). + if (rec.uberCode && rec.uberCode !== '0') { + const uber = requireItem(tables, rec.uberCode) + uberW = (sdiv(imul(playerLevel - uber.level, 100), 2) + 1) | 0 + if (uberW < 0) uberW = 0 + uberCost = uber.cost + } + let ultraW = 0 + let ultraCost = 0 + if (rec.ultraCode && rec.ultraCode !== '0') { + // 0x6fd7494d + const ultra = requireItem(tables, rec.ultraCode) + ultraW = (sdiv(imul(playerLevel - ultra.level, 100), 4) + 1) | 0 + if (ultraW < 0) ultraW = 0 + ultraCost = ultra.cost + } + // 0x6fd74998 + const mixed = (imul((10000 - ultraW - uberW) | 0, base) + imul(ultraCost, ultraW) + imul(uberCost, uberW)) | 0 + const s = idiv(mixed, 10000, '0x6fd749b8') + const l = playerLevel > 5 ? playerLevel : 5 // 0x6fd749c9 + const qlvl = rec.level + const over = qlvl - 45 < 0 ? 0 : qlvl - 45 + const t = idiv(imul((over - sdiv(qlvl, 2) + l) | 0, 250), 3, '0x6fd749f6') + const f = (idiv((2 * l + 1) | 0, 3, '0x6fd74a0a') + 20) | 0 + return idiv(imul((t + s) | 0, f), 15, '0x6fd74a20') +} + +// --------------------------------------------------------------------------- +// #10107 +// --------------------------------------------------------------------------- + +function requireNpc(tables: TransactionCostTables, npcClass: number): NpcCostRecord { + const npc = tables.npcs.get(npcClass) + if (!npc) throw new Error(`transaction-cost: no Npc.txt record for npc class ${npcClass}`) // fatal 0xcdd + return npc +} + +/** Npc multiplier with the asm's guard (0x6fd799d8). */ +function npcScale(value: number, guardValue: number, guardMult: number, mult: number): number { + if (guardValue > 0xffff && guardMult !== 0) return imul(sdiv(value, 1024), mult) + return sdiv(imul(mult, value), 1024) +} + +/** + * `ITEMS_GetTransactionCost` (D2Common #10107, body at 0x6fd78e80). + * + * @param item - the item being priced. + * @param ctx - player, NPC and table state. + * @param type - 0 buy, 1 sell, 2 gamble, 3 repair. + * @returns the gold amount. + */ +export function computeTransactionCost113c( + item: TransactionCostInput, + ctx: TransactionCostContext, + type: TransactionType113c, +): number { + const { tables } = ctx + const rec = requireItem(tables, item.code) + const quantityStat = fullStat(item, STAT_QUANTITY) + const q = quantityStat <= 0 ? 1 : quantityStat + let rp = ctx.playerReducedPricesPct | 0 + if (rp >= REDUCED_PRICES_CAP) rp = REDUCED_PRICES_CAP + const clvl = ctx.playerLevel | 0 + + // 0x6fd78eb4 + if (type === 3 && !isItemRepairable113c(item, tables)) return 0 + // 0x6fd78ecd + if (item.starterItem) return 1 + + // 0x6fd78f38: gamble + if (type === 2) { + if ((item.itemFormat & 0xffff) >= 1) { + let cost = computeGambleBaseCost113c(item.code, clvl, tables) + if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0 + return cost // no clamp here + } + const own = rec.normCode ? requireItem(tables, rec.normCode) : rec + return own.gambleCost + } + + const x: Triple = { a: 0, b: 0, c: 0 } + let divisor = 1 + + // Base costs, first match wins. + if (item.isEar) { + // 0x6fd78fb0 + const lvl = requireDefined(item.earLevel, 'earLevel') & 0xff + x.a = x.b = imul(lvl, rec.cost) + } else if (isItemOfType(tables, rec, 'body')) { + // 0x6fd78fc9 + const cls = requireDefined(item.bodyPartMonsterClass, 'bodyPartMonsterClass') + const levels = tables.monsterLevels[cls] + if (!levels) throw new Error(`transaction-cost: no MonStats record ${cls}`) + x.a = x.b = (rec.cost + (levels[ctx.difficulty] << 3)) | 0 + } else if (isItemOfType(tables, rec, 'book')) { + // 0x6fd7913e + const bookId = requireDefined(item.bookId, 'bookId') + const book = tables.books[bookId] + if (!book) throw new Error(`transaction-cost: no Books.txt record ${bookId}`) // fatal 0xbbb + x.a = x.b = (imul(book.costPerCharge, q) + rec.cost) | 0 + } else if (primaryType(tables, rec).quiver) { + // 0x6fd7917a + x.a = x.b = sdiv(imul(rec.cost, q), 1024) + x.c = sdiv(imul(maxStack(item, rec), rec.cost), 1024) + } else if (rec.stackable) { + // 0x6fd791c5 + x.a = x.b = x.c = rec.cost + const ms = maxStack(item, rec) + divisor = ms > 1 ? ms : 1 + } else { + x.a = x.b = x.c = rec.cost + } + + // 0x6fd7900b: armor scales by base defense over max defense + if (isItemOfType(tables, rec, 'armo')) { + if (((rec.maxAc - rec.minAc + 1) | 0) !== 0 && rec.maxAc !== 0) { + const v = idiv(imul(rec.cost, baseStat(item, STAT_ARMORCLASS)), rec.maxAc, '0x6fd79044') + x.a = x.b = x.c = v + } + } + + // 0x6fd74210: magic, set, rare, unique, crafted, tempered + const magicish = item.quality >= 4 && item.quality <= 9 + if (!magicish) applyStaffMods(tables, item, rec, x, divisor) // 0x6fd7909c + + if (item.identified) { + // 0x6fd790b8: automagic + const n: Triple = { a: 0, b: 0, c: 0 } + const auto = affixRecord(tables.autoMagic, item.autoAffixId ?? 0, 'automagic') + if (auto) addInto(n, multAddTerm(x, auto.multiply, auto.add)) + + const magicPath = (): void => { + // 0x6fd7954f + const p = affixRecord(tables.magicPrefixes, requireDefined(item.magicPrefixId, 'magicPrefixId'), 'magic prefix') + if (p) addInto(n, multAddTerm(x, p.multiply, p.add)) + const s = affixRecord(tables.magicSuffixes, requireDefined(item.magicSuffixId, 'magicSuffixId'), 'magic suffix') + if (s) addInto(n, multAddTerm(x, s.multiply, s.add)) + applyBonusStats(tables, item, x, divisor) + } + + switch (item.quality) { + case 1: // 0x6fd797fc: low quality replaces the automagic term (sic) + n.a = -sdiv(x.a, 2) + n.b = -sdiv(x.b, 2) + n.c = -sdiv(x.c, 2) + break + case 2: + break + case 3: // 0x6fd79823 + case 9: + applyBonusStats(tables, item, x, divisor) + break + case 4: + magicPath() + break + case 5: { + // 0x6fd79720: a missing record is skipped; no bonus stats + const id = requireDefined(item.setItemId, 'setItemId') + const set = id >= 0 ? tables.setItems[id] : undefined + if (set) addInto(n, multAddTerm(x, set.multiply, set.add)) + break + } + case 6: // 0x6fd79269 + case 8: { + const pre = requireDefined(item.rarePrefixIds, 'rarePrefixIds') + const suf = requireDefined(item.rareSuffixIds, 'rareSuffixIds') + for (let i = 0; i < 3; i++) { + const p = affixRecord(tables.magicPrefixes, pre[i]!, 'rare prefix') + if (p) addInto(n, multAddTerm(x, p.multiply, p.add)) + const s = affixRecord(tables.magicSuffixes, suf[i]!, 'rare suffix') + if (s) addInto(n, multAddTerm(x, s.multiply, s.add)) + } + applyBonusStats(tables, item, x, divisor) + break + } + case 7: { + // 0x6fd79458: an invalid unique id falls back to the magic path + const id = requireDefined(item.uniqueItemId, 'uniqueItemId') + const uniq = id >= 0 ? tables.uniqueItems[id] : undefined + if (uniq) addInto(n, multAddTerm(x, uniq.multiply, uniq.add)) + else magicPath() + break + } + } + // 0x6fd79844: signed division of the terms by the divisor + x.a = (x.a + idiv(n.a, divisor, '0x6fd79844')) | 0 + x.b = (x.b + idiv(n.b, divisor, '0x6fd79844')) | 0 + x.c = (x.c + idiv(n.c, divisor, '0x6fd79844')) | 0 + if (magicish) applyStaffMods(tables, item, rec, x, divisor) + } + + // 0x6fd74a50: sockets, counted even when unidentified + for (const childCode of item.socketedChildCodes) { + const half = sdiv(requireItem(tables, childCode).cost, 2) + x.a = (x.a + half) | 0 + x.b = (x.b + half) | 0 + x.c = (x.c + half) | 0 + } + + // 0x6fd798b0: quarter the player-sell price + let ebp = x.b + if (item.ethereal) ebp = sdiv(ebp, 4) + if (primaryType(tables, rec).classSpecific) ebp = sdiv(ebp, 4) + + // 0x6fd798f5: per-type adjustment + let edi = x.c + if (type === 1) { + if (item.ethereal && hasDurability(item, rec) && fullStat(item, STAT_DURABILITY) <= 0) ebp = 0 + } else if (type === 3) { + const t = primaryType(tables, rec) + if (!t.quiver && !t.throwable && hasDurability(item, rec)) { + const cur = fullStat(item, STAT_DURABILITY) + const max = maxDurability(item) + if (max !== 0 && cur < max) { + if (fullStat(item, STAT_ITEM_REPLENISH_DURABILITY) !== 0) { + // (sic) self-repairing items are charged as if one point were missing, or nothing + edi = cur < max - 1 ? idiv(imul(max - 1, x.c), max, '0x6fd7995e') : 0 + } else { + edi = idiv(imul(max - cur, x.c), max, '0x6fd79988') + } + } else { + edi = 0 + } + } + } + + // 0x6fd799d8: Npc.txt multipliers + const npc = requireNpc(tables, ctx.npcClass) + let a = npcScale(x.a, x.a, npc.sellMult, npc.sellMult) + ebp = npcScale(ebp, ebp, npc.sellMult, npc.buyMult) // (sic) guard tests sellMult + edi = npcScale(edi, edi, npc.repMult, npc.repMult) + + // 0x6fd79aa0: quest discounts + for (let i = 0; i < 3; i++) { + const flag = npc.questFlags[i]! + if (flag === 0) continue + if (!ctx.isQuestFlagSet(flag, 0) && !ctx.isQuestFlagSet(flag, 1)) continue + a = npcScale(a, a, npc.questSellMults[i]!, npc.questSellMults[i]!) + ebp = npcScale(ebp, ebp, npc.questBuyMults[i]!, npc.questBuyMults[i]!) + edi = npcScale(edi, edi, npc.questRepMults[i]!, npc.questRepMults[i]!) + } + + // 0x6fd79b8d: quantity + if (!isItemOfType(tables, rec, 'book') && !primaryType(tables, rec).quiver) { + a = imul(a, q) + if (rec.stackable && isItemRepairable113c(item, tables)) { + const max = maxStack(item, rec) + if (q >= max || fullStat(item, STAT_ITEM_REPLENISH_QUANTITY) !== 0) { + ebp = imul(max, ebp) + edi = 0 + } else { + ebp = (imul(max, ebp) - imul(edi, max - q)) | 0 + edi = imul(edi, max - q) + } + } else { + ebp = imul(ebp, q) + } + } + + // 0x6fd79c0d: recharge, added after the Npc multipliers + if (type === 3 && !item.ethereal) edi = (edi + rechargeCost(tables, item)) | 0 + + // 0x6fd79c2c: cap the player-sell price + const cap = npc.maxBuy[ctx.difficulty] + if (ebp > cap) ebp = cap + + if (type === 1) return ebp < 1 ? 1 : ebp + if (type === 3) { + if (rp !== 0) edi = (edi - fogMulDiv113c(rp, edi, 100)) | 0 + return edi < 1 ? 1 : edi + } + if (rp !== 0) a = (a - fogMulDiv113c(rp, a, 100)) | 0 + return a < 1 ? 1 : a +} + +// --------------------------------------------------------------------------- +// #10071 +// --------------------------------------------------------------------------- + +/** + * Repair-all cost (D2Common #10071 at 0x6fd7a210). + * + * @param equipped - the 13 body locations (0..12, both weapon sets); `null` for an empty slot. + * @param ctx - same context as a single repair. + * @returns the total gold. Items that need nothing contribute 0. + */ +export function computeRepairAllCost113c( + equipped: readonly (TransactionCostInput | null)[], + ctx: TransactionCostContext, +): number { + if (equipped.length !== REPAIR_ALL_SLOT_COUNT) { + throw new Error(`transaction-cost: repair-all expects ${REPAIR_ALL_SLOT_COUNT} slots, got ${equipped.length}`) + } + const { tables } = ctx + let total = 0 + for (const item of equipped) { + if (!item) continue + const rec = requireItem(tables, item.code) + const maxDur = maxDurability(item) + const needs = + (isItemRepairable113c(item, tables) && + hasDurability(item, rec) && + maxDur !== 0 && + fullStat(item, STAT_DURABILITY) !== maxDur) || + (isRefillableThrowing(tables, item, rec) && fullStat(item, STAT_QUANTITY) < maxStack(item, rec)) || + hasMissingCharges(item) + if (!needs) continue + total = (total + computeTransactionCost113c(item, ctx, 3)) | 0 + } + return total +} + +// --------------------------------------------------------------------------- +// Table building +// --------------------------------------------------------------------------- + +function column(t: D2Table, name: string, what: string): number { + const i = t.header.indexOf(name) + if (i < 0) throw new Error(`transaction-cost: ${what} has no column '${name}'`) + return i +} + +/** Integer txt field. An empty cell compiles to 0, as the txt compiler does. */ +function intField(t: D2Table, row: readonly string[], name: string, what: string): number { + const raw = (row[column(t, name, what)] ?? '').trim() + if (raw === '') return 0 + if (!/^-?\d+$/.test(raw)) throw new Error(`transaction-cost: ${what}.${name} is not an integer: '${raw}'`) + return Number(raw) | 0 +} + +function strField(t: D2Table, row: readonly string[], name: string, what: string): string { + return (row[column(t, name, what)] ?? '').trim() +} + +/** Compiled rows: the txt compiler drops rows whose first column is "Expansion". */ +function compiledRows(t: D2Table): readonly (readonly string[])[] { + return t.rows.filter(r => (r[0] ?? '').trim() !== 'Expansion') +} + +function multAddRows(t: D2Table, nameCol: string, multCol: string, addCol: string, what: string): CostMultAdd[] { + return compiledRows(t).map(r => ({ + name: strField(t, r, nameCol, what), + multiply: intField(t, r, multCol, what), + add: intField(t, r, addCol, what), + })) +} + +/** Which optional columns a items table has. Misc.txt has no durability, AC or tier columns; they compile to 0. */ +interface ItemsTableShape { + readonly hasAc: boolean + readonly hasTiers: boolean + readonly hasDurability: boolean +} + +function addItems(out: Map, t: D2Table, what: string, shape: ItemsTableShape): void { + const { hasAc, hasTiers } = shape + for (const r of compiledRows(t)) { + const code = strField(t, r, 'code', what) + if (!code) continue + if (out.has(code)) throw new Error(`transaction-cost: duplicate ItemsTxt code '${code}'`) + const tier = (name: string): string => { + if (!hasTiers) return '' + const v = strField(t, r, name, what) + return v === '0' ? '' : v + } + out.set(code, { + code, + type: strField(t, r, 'type', what), + type2: strField(t, r, 'type2', what), + cost: intField(t, r, 'cost', what), + gambleCost: intField(t, r, 'gamble cost', what), + minStack: intField(t, r, 'minstack', what), + maxStack: intField(t, r, 'maxstack', what), + stackable: intField(t, r, 'stackable', what) !== 0, + durability: shape.hasDurability ? intField(t, r, 'durability', what) & 0xff : 0, + noDurability: intField(t, r, 'nodurability', what) !== 0, + level: intField(t, r, 'level', what) & 0xff, + normCode: tier('normcode'), + uberCode: tier('ubercode'), + ultraCode: tier('ultracode'), + minAc: hasAc ? intField(t, r, 'minac', what) : 0, + maxAc: hasAc ? intField(t, r, 'maxac', what) : 0, + }) + } +} + +/** Build the compiled tables from raw txt tables. */ +export function buildTransactionCostTables(raw: TransactionCostRawTables): TransactionCostTables { + const items = new Map() + addItems(items, raw.weapons, 'Weapons', { hasAc: false, hasTiers: true, hasDurability: true }) + addItems(items, raw.armor, 'Armor', { hasAc: true, hasTiers: true, hasDurability: true }) + addItems(items, raw.misc, 'Misc', { hasAc: false, hasTiers: false, hasDurability: false }) + + const itemTypes = new Map() + for (const r of compiledRows(raw.itemTypes)) { + const w = 'ItemTypes' + const code = strField(raw.itemTypes, r, 'Code', w) + if (!code) continue + itemTypes.set(code, { + code, + equiv1: strField(raw.itemTypes, r, 'Equiv1', w), + equiv2: strField(raw.itemTypes, r, 'Equiv2', w), + repair: intField(raw.itemTypes, r, 'Repair', w) !== 0, + quiver: strField(raw.itemTypes, r, 'Quiver', w) !== '', + throwable: intField(raw.itemTypes, r, 'Throwable', w) !== 0, + staffMods: strField(raw.itemTypes, r, 'StaffMods', w) !== '', + classSpecific: strField(raw.itemTypes, r, 'Class', w) !== '', + }) + } + + const itemStatCost = new Map() + for (const r of compiledRows(raw.itemStatCost)) { + const w = 'ItemStatCost' + if (strField(raw.itemStatCost, r, 'ID', w) === '') continue + const id = intField(raw.itemStatCost, r, 'ID', w) + itemStatCost.set(id, { + id, + encode: intField(raw.itemStatCost, r, 'Encode', w), + add: intField(raw.itemStatCost, r, 'Add', w), + multiply: intField(raw.itemStatCost, r, 'Multiply', w), + valShift: intField(raw.itemStatCost, r, 'ValShift', w), + }) + } + + const skills = new Map() + for (const s of raw.skills) skills.set(s.id, s) + + const books = compiledRows(raw.books).map(r => ({ + name: strField(raw.books, r, 'Name', 'Books'), + baseCost: intField(raw.books, r, 'BaseCost', 'Books'), + costPerCharge: intField(raw.books, r, 'CostPerCharge', 'Books'), + })) + + const monRows = compiledRows(raw.monStats) + const npcClassByName = new Map() + const monsterLevels = monRows.map((r, i) => { + const id = strField(raw.monStats, r, 'Id', 'MonStats').toLowerCase() + if (id && !npcClassByName.has(id)) npcClassByName.set(id, i) + return [ + intField(raw.monStats, r, 'Level', 'MonStats'), + intField(raw.monStats, r, 'Level(N)', 'MonStats'), + intField(raw.monStats, r, 'Level(H)', 'MonStats'), + ] as const + }) + + const npcs = new Map() + const npcNames = new Map() + for (const r of compiledRows(raw.npc)) { + const t = raw.npc + const w = 'Npc' + const name = strField(t, r, 'npc', w) + if (!name) continue + const npcClass = npcClassByName.get(name.toLowerCase()) + if (npcClass === undefined) throw new Error(`transaction-cost: Npc.txt '${name}' has no MonStats row`) + const abc = (prefix: string): [number, number, number] => [ + intField(t, r, `${prefix} A`, w), + intField(t, r, `${prefix} B`, w), + intField(t, r, `${prefix} C`, w), + ] + npcs.set(npcClass, { + name, + npcClass, + sellMult: intField(t, r, 'sell mult', w), + buyMult: intField(t, r, 'buy mult', w), + repMult: intField(t, r, 'rep mult', w), + questFlags: abc('questflag'), + questSellMults: abc('questsellmult'), + questBuyMults: abc('questbuymult'), + questRepMults: abc('questrepmult'), + maxBuy: [intField(t, r, 'max buy', w), intField(t, r, 'max buy (N)', w), intField(t, r, 'max buy (H)', w)], + }) + npcNames.set(name.toLowerCase(), npcClass) + } + + return { + items, + itemTypes, + magicPrefixes: multAddRows(raw.magicPrefix, 'Name', 'multiply', 'add', 'MagicPrefix'), + magicSuffixes: multAddRows(raw.magicSuffix, 'Name', 'multiply', 'add', 'MagicSuffix'), + autoMagic: multAddRows(raw.autoMagic, 'Name', 'multiply', 'add', 'AutoMagic'), + uniqueItems: multAddRows(raw.uniqueItems, 'index', 'cost mult', 'cost add', 'UniqueItems'), + setItems: multAddRows(raw.setItems, 'index', 'cost mult', 'cost add', 'SetItems'), + itemStatCost, + skills, + books, + npcs, + npcClassByName: npcNames, + monsterLevels, + // UNCONFIRMED: the writer of DataTables +0xc6c/+0xc70 was not located; 6/0x3f is the + // split the old-format conversion at 0x6fd7bd2c uses. + skillParamShift: 6, + skillParamMask: 0x3f, + } +} + +let canonicalTables: TransactionCostTables | undefined + +/** Tables built from the embedded canonical 1.13c data. Built once, on first call. */ +export function getCanonicalTransactionCostTables(): TransactionCostTables { + if (canonicalTables) return canonicalTables + const parse = (text: string): D2Table => parseTable(new TextEncoder().encode(text)) + const skills: SkillCostRecord[] = CANONICAL_SKILL_COSTS.map(s => ({ + id: s.id, + costMult: s.costMult, + costAdd: s.costAdd, + reqLevel: SKILLS_BY_ID[s.id]?.reqlevel, + })) + canonicalTables = buildTransactionCostTables({ + weapons: parse(RAW_WEAPONS), + armor: parse(RAW_ARMOR), + misc: parse(RAW_MISC), + itemTypes: parse(RAW_ITEM_TYPES), + magicPrefix: parse(RAW_MAGIC_PREFIX), + magicSuffix: parse(RAW_MAGIC_SUFFIX), + autoMagic: parse(CANONICAL_AUTOMAGIC_TSV.trim()), + uniqueItems: parse(RAW_UNIQUE_ITEMS), + setItems: parse(RAW_SET_ITEMS), + itemStatCost: parse(RAW_ITEM_STAT_COST), + books: parse(RAW_BOOKS), + npc: parse(RAW_NPC), + monStats: parse(RAW_MONSTATS), + skills, + }) + return canonicalTables +} + +/** Look up an NPC's class (MonStats hcIdx) by its Npc.txt name; throws if unknown. */ +export function getNpcClassByName113c(tables: TransactionCostTables, name: string): number { + const cls = tables.npcClassByName.get(name.toLowerCase()) + if (cls === undefined) throw new Error(`transaction-cost: unknown Npc.txt npc '${name}'`) + return cls +} + diff --git a/tests/item-cost.test.ts b/tests/item-cost.test.ts index 1ae3323..dca0f20 100644 --- a/tests/item-cost.test.ts +++ b/tests/item-cost.test.ts @@ -193,8 +193,8 @@ describe('1.13c Item Transaction & Acquisition Cost Engine (M19-P3)', () => { expect(sellHell).toBe(35000) }) - it('halves acquisition (SELL) price for Ethereal items and forbids repairing Ethereal items', () => { - const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so halving is observable) + 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 quartering is observable) const normalArmor = createDroppedItem(capBase, 'normal', { itemTypes: tables.itemTypes, ilvl: 25, diff --git a/tests/transaction-cost-113c.test.ts b/tests/transaction-cost-113c.test.ts new file mode 100644 index 0000000..d5df8ed --- /dev/null +++ b/tests/transaction-cost-113c.test.ts @@ -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 { + 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 { + 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) + } + }) +})