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).
This commit is contained in:
troytt 2026-10-01 10:25:35 +00:00
parent 2e9216fc7f
commit 642cecbd2b
4 changed files with 1685 additions and 405 deletions

View File

@ -1,11 +1,33 @@
/**
* LEGACY vendor pricing over the server-side `Item` model.
*
* The 1.13c ground-truth port is `./transaction-cost.ts`, which ports
* `computeTransactionCost113c` (#10107, body 0x6fd78e80) and
* `computeRepairAllCost113c` (#10071, 0x6fd7a210). New code must use it.
*
* This module came from a D2MOO (1.10) port. It stays because
* `vendor-service.ts` and the legacy simulation tests price `Item` objects,
* which carry rolled properties instead of a full/base stat list. The fake
* addresses and permissive fallbacks have been removed:
* - ItemsTxt fields are read from the canonical compiled tables.
* - Missing Npc / Books / skill / ItemStatCost records throw.
* - Gamble pricing delegates to the 1.13c port.
*/
import type { DropTables } from './drop-tables.ts' import type { DropTables } from './drop-tables.ts'
import { ItemFlag, ItemQuality, type Item, type ItemBase, type RolledItemProp } from './items.ts' import { ItemFlag, ItemQuality, type Item, type ItemBase, type RolledItemProp } from './items.ts'
import { getItemTypeCode } from './affix-generator.ts'
import type { ItemTypeTable } from './item-types.ts' import type { ItemTypeTable } from './item-types.ts'
import { VendorNpcId, getTownNpcDescriptorByVendorId, type NpcRecord } from '../units/npc-table.ts' import type { RolledAutoMagic } from './automagic.ts'
import { VendorNpcId, type NpcRecord } from '../units/npc-table.ts'
import {
computeGambleBaseCost113c,
fogMulDiv113c,
getCanonicalTransactionCostTables,
isItemCodeOfType113c,
type ItemsTxtCostRecord,
} from './transaction-cost.ts'
/** /**
* Canonical 1.13c transaction types (`D2C_TransactionTypes` in D2Common.dll). * Canonical 1.13c transaction types (`D2C_TransactionTypes`).
*/ */
export const enum TransactionType { export const enum TransactionType {
BUY = 0, BUY = 0,
@ -14,15 +36,6 @@ export const enum TransactionType {
REPAIR = 3, REPAIR = 3,
} }
/**
* Normal difficulty fallback Max Buy caps by Act (Acts 1..5), Nightmare, and Hell.
*/
export const DEFAULT_NORMAL_MAX_BUY_BY_ACT: readonly [number, number, number, number, number] = [
5000, 10000, 15000, 20000, 25000,
] as const
export const DEFAULT_NIGHTMARE_MAX_BUY = 30000
export const DEFAULT_HELL_MAX_BUY = 35000
export interface TransactionCostOptions { export interface TransactionCostOptions {
readonly item: Item readonly item: Item
readonly transactionType: TransactionType readonly transactionType: TransactionType
@ -30,78 +43,79 @@ export interface TransactionCostOptions {
readonly cLvl?: number | undefined readonly cLvl?: number | undefined
readonly vendorId?: VendorNpcId | undefined readonly vendorId?: VendorNpcId | undefined
readonly npcKey?: string | undefined readonly npcKey?: string | undefined
/** Unused since the Npc.txt record is required; kept so existing callers compile. */
readonly actIndex?: 0 | 1 | 2 | 3 | 4 | undefined readonly actIndex?: 0 | 1 | 2 | 3 | 4 | undefined
readonly completedQuests?: ReadonlySet<number> | undefined readonly completedQuests?: ReadonlySet<number> | undefined
readonly reducedPricesPercent?: number | undefined readonly reducedPricesPercent?: number | undefined
readonly tables: DropTables readonly tables: DropTables
} }
/** `0x6fd760f0` caps stack sizes here. */
const MAX_STACK_CAP = 511
/** `0x6fd78e80` caps reduced prices here (no lower clamp). */
const REDUCED_PRICES_CAP = 99
/** Encode-2/3 param split. UNCONFIRMED; see `transaction-cost.ts`. */
const SKILL_PARAM_SHIFT = 6
const SKILL_PARAM_MASK = 0x3f
/** ItemsTxt record of a base; throws if the code is unknown. */
function txt(base: ItemBase): ItemsTxtCostRecord {
const rec = getCanonicalTransactionCostTables().items.get(base.id)
if (!rec) throw new Error(`item-cost: unknown ItemsTxt code '${base.id}'`)
return rec
}
function primaryTypeOf(base: ItemBase, itemTypes: ItemTypeTable): ReturnType<ItemTypeTable['byCode']['get']> {
const code = txt(base).type
const def = itemTypes.byCode.get(code)
if (!def) throw new Error(`item-cost: unknown ItemTypes code '${code}' for '${base.id}'`)
return def
}
/** /**
* Checks whether an item base belongs to a class-specific ItemType (`ItemTypes.Class !== ''`, i.e., `nClass < 7`). * Whether the primary ItemType is class-specific (`ItemTypes.Class` set, `nClass < 7`).
* 0x6fd798b0 reads the primary type only, with no equivalences.
*/ */
export function isClassSpecificItemBase(base: ItemBase, itemTypes: ItemTypeTable): boolean { export function isClassSpecificItemBase(base: ItemBase, itemTypes: ItemTypeTable): boolean {
const startType = getItemTypeCode(base).trim() const def = primaryTypeOf(base, itemTypes)
const queue: string[] = [startType, ...(base.tags ?? [])] return Boolean(def?.class && def.class.trim().length > 0)
const visited = new Set<string>() }
while (queue.length > 0) { /** Whether the primary ItemType has StaffMods (`nStaffMods != 7`), as 0x6fd75970 checks. */
const code = queue.shift()!.trim() export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean {
if (!code || visited.has(code.toLowerCase())) continue const def = primaryTypeOf(base, itemTypes)
visited.add(code.toLowerCase()) return Boolean(def?.staffMods && def.staffMods.trim().length > 0)
}
const def = itemTypes.byCode.get(code) /** Primary ItemType `Quiver` set (0x6fd74e60). */
if (!def) continue export function isQuiverItem(base: ItemBase): boolean {
if (def.class && def.class.trim().length > 0) { const t = getCanonicalTransactionCostTables().itemTypes.get(txt(base).type)
return true if (!t) throw new Error(`item-cost: unknown ItemTypes code '${txt(base).type}'`)
} return t.quiver
if (def.equiv1) queue.push(def.equiv1) }
if (def.equiv2) queue.push(def.equiv2)
} /** `book` type, with equivalences (0x6fd74430). */
return false export function isBookItem(base: ItemBase): boolean {
return isItemCodeOfType113c(getCanonicalTransactionCostTables(), base.id, 'book')
}
function isEthereal(item: Item): boolean {
return Boolean(item.flags?.ethereal) || ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0
}
function maxStackOf(base: ItemBase): number {
const n = txt(base).maxStack
return n > MAX_STACK_CAP ? MAX_STACK_CAP : n
} }
/** /**
* Checks whether an item base can receive StaffMods (`ItemTypes.StaffMods !== ''`, i.e., `nStaffMods != 7`). * LEGACY "needs repair" predicate used by the vendor UI. It is NOT
* `ITEMS_IsRepairable` (0x6fd77090). That function is
* `isItemRepairable113c` in `./transaction-cost.ts`, and it also returns true
* for undamaged items.
*/ */
export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean {
const startType = getItemTypeCode(base).trim()
const queue: string[] = [startType, ...(base.tags ?? [])]
const visited = new Set<string>()
while (queue.length > 0) {
const code = queue.shift()!.trim()
if (!code || visited.has(code.toLowerCase())) continue
visited.add(code.toLowerCase())
const def = itemTypes.byCode.get(code)
if (!def) continue
if (def.staffMods && def.staffMods.trim().length > 0) {
return true
}
if (def.equiv1) queue.push(def.equiv1)
if (def.equiv2) queue.push(def.equiv2)
}
return false
}
export function isQuiverItem(base: ItemBase): boolean {
const t = (base.type ?? '').toLowerCase()
const c = base.id.toLowerCase()
return t === 'bowq' || t === 'xbowq' || c === 'aqv' || c === 'cqv'
}
export function isBookItem(base: ItemBase): boolean {
const t = (base.type ?? '').toLowerCase()
const c = base.id.toLowerCase()
return t === 'book' || c === 'tbk' || c === 'ibk'
}
export function isItemRepairable(item: Item): boolean { export function isItemRepairable(item: Item): boolean {
const isEthereal = if (isEthereal(item)) return false
Boolean(item.flags?.ethereal) ||
Boolean((item as any).ethereal) ||
((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0
if (isEthereal) return false
if (isQuiverItem(item.base) || isBookItem(item.base)) return false if (isQuiverItem(item.base) || isBookItem(item.base)) return false
@ -111,20 +125,21 @@ export function isItemRepairable(item: Item): boolean {
) )
if (hasIndestructible) return false if (hasIndestructible) return false
const maxDur = item.maxDurability ?? Number((item.base as any).durability) ?? 0 const rec = txt(item.base)
const maxDur = item.maxDurability ?? (rec.noDurability ? 0 : rec.durability)
const curDur = item.durability ?? maxDur const curDur = item.durability ?? maxDur
if (maxDur > 0 && curDur < maxDur) { if (maxDur > 0 && curDur < maxDur) {
return true return true
} }
// Also repairable if stackable throwing weapon below maxStack // A stackable throwing weapon below its max stack can be refilled.
if ((item.base as any).stackable && item.base.kind === 'weapon') { if (rec.stackable && item.base.kind === 'weapon') {
const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 const maxStack = maxStackOf(item.base)
const qty = item.quantity ?? item.stack ?? maxStack const qty = item.quantity ?? item.stack ?? maxStack
if (qty < maxStack) return true if (qty < maxStack) return true
} }
// Also repairable if it has partially depleted charged skills // Charged skills that have been used can be recharged.
if (item.rolledProps?.some(p => p.code.toLowerCase() === 'charged')) { if (item.rolledProps?.some(p => p.code.toLowerCase() === 'charged')) {
return true return true
} }
@ -133,8 +148,8 @@ export function isItemRepairable(item: Item): boolean {
} }
/** /**
* Implements `ITEMS_CalculateAdditionalCostsForItemSkill` (`0x6FD9CB50`) * StaffMods pricing (`0x6fd75970`) for single-skill staffmods
* for single-skill staffmods (`STAT_ITEM_SINGLESKILL`) on StaffMods-eligible item types. * (`STAT_ITEM_SINGLESKILL`) on StaffMods item types.
*/ */
export function calculateAdditionalCostsForItemSkill( export function calculateAdditionalCostsForItemSkill(
item: Item, item: Item,
@ -156,15 +171,18 @@ export function calculateAdditionalCostsForItemSkill(
return { sellCost, buyCost, repCost } return { sellCost, buyCost, repCost }
} }
const skillCosts = tables.skillCosts
if (!skillCosts) throw new Error('item-cost: DropTables.skillCosts is required for StaffMods pricing')
let nSell = 0 let nSell = 0
let nBuy = 0 let nBuy = 0
let nRep = 0 let nRep = 0
for (const sm of staffMods) { for (const sm of staffMods) {
const skillId = Number(sm.param) const skillId = Number(sm.param)
const slvl = Math.max(1, Math.trunc(sm.value)) const slvl = Math.trunc(sm.value)
const skillRec = tables.skillCosts?.get(skillId) const skillRec = skillCosts.get(skillId)
if (!skillRec) continue if (!skillRec) throw new Error(`item-cost: no Skills.txt cost record for skill ${skillId}`)
const factor = 2 * slvl - 1 const factor = 2 * slvl - 1
nSell += factor * (skillRec.costAdd + Math.trunc((sellCost * skillRec.costMult) / 1024)) nSell += factor * (skillRec.costAdd + Math.trunc((sellCost * skillRec.costMult) / 1024))
@ -172,18 +190,17 @@ export function calculateAdditionalCostsForItemSkill(
nRep += factor * (skillRec.costAdd + Math.trunc((repCost * skillRec.costMult) / 1024)) nRep += factor * (skillRec.costAdd + Math.trunc((repCost * skillRec.costMult) / 1024))
} }
const effDiv = Math.max(1, divisor)
return { return {
sellCost: sellCost + Math.trunc(nSell / effDiv), sellCost: sellCost + Math.trunc(nSell / divisor),
buyCost: buyCost + Math.trunc(nBuy / effDiv), buyCost: buyCost + Math.trunc(nBuy / divisor),
repCost: repCost + Math.trunc(nRep / effDiv), repCost: repCost + Math.trunc(nRep / divisor),
} }
} }
/** /**
* Implements `ITEMS_CalculateAdditionalCostsForBonusStats` (`0x6FD9ACE0`). * Bonus-stat pricing (`0x6fd78850`): applies `ItemStatCost.txt`
* Iterates resolved bonus stats on the item and applies `ItemStatCost.txt` * (`Encode`, `Add`, `Multiply`) or `Skills.txt` (`CostAdd`, `CostMult`) to each
* (`Encode`, `Add`, `Multiply`, `Divide`) or `Skills.txt` (`CostAdd`, `CostMult`). * rolled stat. `Divide` is not read by 1.13c.
*/ */
export function calculateAdditionalCostsForBonusStats( export function calculateAdditionalCostsForBonusStats(
item: Item, item: Item,
@ -195,15 +212,13 @@ export function calculateAdditionalCostsForBonusStats(
): { sellCost: number; buyCost: number; repCost: number } { ): { sellCost: number; buyCost: number; repCost: number } {
const propertiesTable = tables.properties const propertiesTable = tables.properties
const statCostTable = tables.itemStatCost const statCostTable = tables.itemStatCost
if (!statCostTable) { if (!statCostTable) throw new Error('item-cost: DropTables.itemStatCost is required for bonus-stat pricing')
return { sellCost, buyCost, repCost }
}
let nSell = 0 let nSell = 0
let nBuy = 0 let nBuy = 0
let nRep = 0 let nRep = 0
// Collect resolved stats from rolledProps (excluding staffMods 'skill' which are handled by calculateAdditionalCostsForItemSkill) // Collect resolved stats. StaffMods 'skill' entries are priced by calculateAdditionalCostsForItemSkill.
const staffModSet = new Set<RolledItemProp>(item.staffMods ?? []) const staffModSet = new Set<RolledItemProp>(item.staffMods ?? [])
const props = item.rolledProps ?? [] const props = item.rolledProps ?? []
@ -211,14 +226,12 @@ export function calculateAdditionalCostsForBonusStats(
readonly statId: number readonly statId: number
readonly param: number readonly param: number
readonly value: number readonly value: number
readonly propCode?: string
readonly min?: number
readonly max?: number
} }
const entries: StatBonusEntry[] = [] const entries: StatBonusEntry[] = []
if (props.length > 0 && propertiesTable) { if (props.length > 0) {
if (!propertiesTable) throw new Error('item-cost: DropTables.properties is required to resolve rolled properties')
for (const prop of props) { for (const prop of props) {
if (staffModSet.has(prop)) continue if (staffModSet.has(prop)) continue
if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) { if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) {
@ -237,33 +250,27 @@ export function calculateAdditionalCostsForBonusStats(
prop.value, prop.value,
statCostTable, statCostTable,
) )
if (resolved.length > 0) { for (const r of resolved) {
for (const r of resolved) { entries.push({ statId: r.statId, param: r.param ?? numParam, value: r.value })
entries.push({
statId: r.statId,
param: r.param ?? numParam,
value: r.value,
propCode: prop.code,
min: prop.min,
max: prop.max,
})
}
} }
} }
} else if (item.statList && item.statList.length > 0) { } else if (item.statList && item.statList.length > 0) {
for (const s of item.statList) { for (const s of item.statList) {
entries.push({ entries.push({ statId: s.statId, param: s.param ?? 0, value: s.value })
statId: s.statId,
param: s.param ?? 0,
value: s.value,
})
} }
} }
const skillCosts = tables.skillCosts
const requireSkill = (id: number): { costAdd: number; costMult: number } => {
const rec = skillCosts?.get(id)
if (!rec) throw new Error(`item-cost: no Skills.txt cost record for skill ${id}`)
return rec
}
for (const entry of entries) { for (const entry of entries) {
const statRec = statCostTable.getById(entry.statId) const statRec = statCostTable.getById(entry.statId)
if (!statRec) continue if (!statRec) throw new Error(`item-cost: no ItemStatCost record for stat ${entry.statId}`)
// STAT_ITEM_SINGLESKILL (107) on staffMod items is priced by calculateAdditionalCostsForItemSkill // STAT_ITEM_SINGLESKILL (107) on StaffMods items is priced by calculateAdditionalCostsForItemSkill.
if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) { if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) {
continue continue
} }
@ -272,47 +279,45 @@ export function calculateAdditionalCostsForBonusStats(
switch (statRec.encode) { switch (statRec.encode) {
case 1: { case 1: {
const skillRec = tables.skillCosts?.get(entry.param) const skillRec = requireSkill(entry.param)
if (skillRec) { nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024)
nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096)
nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024)
nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024)
}
break break
} }
case 2: case 2:
case 3: { case 3: {
const skillId = entry.param > 63 ? entry.param >> 6 : entry.param const skillRec = requireSkill(entry.param >> SKILL_PARAM_SHIFT)
const slvl = entry.param > 63 ? Math.max(1, entry.param & 0x3f) : Math.max(1, entry.max ?? 1) const slvl = entry.param & SKILL_PARAM_MASK
const skillRec = tables.skillCosts?.get(skillId) nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024)
if (skillRec) { nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096)
nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024)
nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096)
nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024)
}
break break
} }
default: { default: {
if (statRec.multiply > 0 || statRec.add > 0) { nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024)
nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024)
nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024)
nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024)
}
break break
} }
} }
} }
const effDiv = Math.max(1, divisor)
return { return {
sellCost: sellCost + Math.trunc(nSell / effDiv), sellCost: sellCost + Math.trunc(nSell / divisor),
buyCost: buyCost + Math.trunc(nBuy / effDiv), buyCost: buyCost + Math.trunc(nBuy / divisor),
repCost: repCost + Math.trunc(nRep / effDiv), repCost: repCost + Math.trunc(nRep / divisor),
} }
} }
function capReducedPrices(pct: number): number {
const v = Math.trunc(pct)
return v >= REDUCED_PRICES_CAP ? REDUCED_PRICES_CAP : v
}
/** /**
* Calculates the 1.13c Gamble purchase price for an item (`ITEMS_CalculateTransactionCost` branch `TRANSACTIONTYPE_GAMBLE` at `0x6FD9B1C0`). * Gamble price (`0x6fd78f38` → `0x6fd74840`), delegated to the 1.13c port.
* There is no minimum clamp: the asm returns this value directly.
*/ */
export function calculateGambleCost(options: { export function calculateGambleCost(options: {
readonly item: Item readonly item: Item
@ -320,87 +325,35 @@ export function calculateGambleCost(options: {
readonly reducedPricesPercent?: number | undefined readonly reducedPricesPercent?: number | undefined
readonly tables: DropTables readonly tables: DropTables
}): number { }): number {
const { item, cLvl, reducedPricesPercent = 0, tables } = options const { item, cLvl, reducedPricesPercent = 0 } = options
const normCode = item.base.normcode || item.base.id let cost = computeGambleBaseCost113c(item.base.id, Math.trunc(cLvl), getCanonicalTransactionCostTables())
const normBase = tables.getBase(normCode) ?? item.base const rp = capReducedPrices(reducedPricesPercent)
const codeLower = normBase.id.toLowerCase() if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0
return cost
}
let nCost = 0 /** Row index of the rolled AutoMagic affix. drop-tables.ts spreads `rolledAutoMagic` onto the item without a typed field. */
if (codeLower === 'rin' || codeLower === 'amu') { function rolledAutoMagicRow(item: Item): number | undefined {
nCost = normBase.gambleCost ?? (codeLower === 'rin' ? 50000 : 63000) if (!('rolledAutoMagic' in item)) return undefined
} else { const rolled: unknown = item.rolledAutoMagic
const minStack = Number((normBase as any).minstack) || 0 if (rolled === undefined) return undefined
const maxStack = Number((normBase as any).maxstack) || 0 const parsed = rolled as Partial<RolledAutoMagic>
const v27 = Math.max(1, Math.trunc((minStack + maxStack) / 2)) if (!parsed.entry || typeof parsed.entry.id !== 'number') throw new Error('item-cost: malformed rolledAutoMagic')
return parsed.entry.id
}
let v23 = 0 function resolveNpcRecord(tables: DropTables, vendorId?: VendorNpcId, npcKey?: string): NpcRecord {
let nUberCost = 0 if (!tables.npcTable) throw new Error('item-cost: DropTables.npcTable is required')
if (normBase.ubercode && normBase.ubercode !== '0' && normBase.ubercode !== 'xxx') { const rec =
const uberBase = tables.getBase(normBase.ubercode) (npcKey ? tables.npcTable.get(npcKey) : undefined) ??
if (uberBase) { (vendorId !== undefined ? tables.npcTable.getByVendorId(vendorId) : undefined)
const v30 = 100 * (cLvl - uberBase.level) if (!rec) throw new Error(`item-cost: no Npc.txt record for npcKey=${npcKey ?? '-'} vendorId=${vendorId ?? '-'}`)
v23 = Math.max(0, Math.trunc(v30 / 2) + 1) return rec
nUberCost = uberBase.cost ?? uberBase.value
}
}
let v22 = 0
let nUltraCost = 0
if (normBase.ultracode && normBase.ultracode !== '0' && normBase.ultracode !== 'xxx') {
const ultraBase = tables.getBase(normBase.ultracode)
if (ultraBase) {
const v30 = 100 * (cLvl - ultraBase.level)
v22 = Math.max(0, Math.trunc(v30 / 4) + 1)
nUltraCost = ultraBase.cost ?? ultraBase.value
}
}
const normCost = normBase.cost ?? normBase.value
const nBase =
v23 * nUberCost +
v22 * nUltraCost +
v27 * normCost * (10000 - v22 - v23)
const effLvl = Math.max(5, Math.trunc(cLvl))
const baseQlvl = normBase.level
const lvlExcess = baseQlvl - 45 > 0 ? baseQlvl - 45 : 0
const factorA = Math.trunc((2 * effLvl + 1) / 3) + 20
const factorB =
Math.trunc(nBase / 10000) +
Math.trunc((250 * (effLvl + lvlExcess - Math.trunc(baseQlvl / 2))) / 3)
nCost = Math.trunc((factorA * factorB) / 15)
}
const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent)))
if (clampedReduce > 0) {
nCost -= Math.trunc((nCost * clampedReduce) / 100)
}
return Math.max(1, nCost)
} }
/** /**
* Resolves the NpcRecord for a vendor / town NPC. * LEGACY transaction pricing over `Item` (structured after 0x6fd78e80).
*/ * Prefer `computeTransactionCost113c`.
function resolveNpcRecord(
tables: DropTables,
vendorId?: VendorNpcId,
npcKey?: string,
): NpcRecord | undefined {
if (npcKey && tables.npcTable) {
const byKey = tables.npcTable.get(npcKey)
if (byKey) return byKey
}
if (vendorId !== undefined && tables.npcTable) {
return tables.npcTable.getByVendorId(vendorId)
}
return undefined
}
/**
* Full 1.13c item transaction & acquisition pricing engine (`ITEMS_CalculateTransactionCost` at `0x6FD9B1C0`).
*/ */
export function calculateTransactionCost(options: TransactionCostOptions): number { export function calculateTransactionCost(options: TransactionCostOptions): number {
const { const {
@ -410,7 +363,6 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
cLvl = 1, cLvl = 1,
vendorId, vendorId,
npcKey, npcKey,
actIndex,
completedQuests, completedQuests,
reducedPricesPercent = 0, reducedPricesPercent = 0,
tables, tables,
@ -428,17 +380,13 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
} }
if (transactionType === TransactionType.GAMBLE) { if (transactionType === TransactionType.GAMBLE) {
return calculateGambleCost({ return calculateGambleCost({ item, cLvl, reducedPricesPercent, tables })
item,
cLvl,
reducedPricesPercent,
tables,
})
} }
const rec = txt(item.base)
const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1)) const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1))
const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) const rp = capReducedPrices(reducedPricesPercent)
const baseCost = item.base.cost ?? item.base.value ?? 1 const baseCost = rec.cost
let nSellCost = 0 let nSellCost = 0
let nBuyCost = 0 let nBuyCost = 0
@ -450,20 +398,20 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0 ((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0
if (isEar) { if (isEar) {
const earLvl = item.earData?.level ?? 1 const earLvl = item.earData?.level
if (earLvl === undefined) throw new Error('item-cost: ear item has no earData.level')
nSellCost = baseCost * earLvl nSellCost = baseCost * earLvl
nBuyCost = nSellCost nBuyCost = nSellCost
} else if (isBookItem(item.base)) { } else if (isBookItem(item.base)) {
const bookRec = tables.booksTable?.getByBookCode(item.base.id) const bookRec = tables.booksTable?.getByBookCode(item.base.id)
const costPerCharge = bookRec?.costPerCharge ?? 80 if (!bookRec) throw new Error(`item-cost: no Books.txt record for '${item.base.id}'`)
nSellCost = baseCost + nQuantity * costPerCharge nSellCost = baseCost + nQuantity * bookRec.costPerCharge
nBuyCost = nSellCost nBuyCost = nSellCost
} else if (isQuiverItem(item.base)) { } else if (isQuiverItem(item.base)) {
nSellCost = Math.trunc((nQuantity * baseCost) / 1024) nSellCost = Math.trunc((nQuantity * baseCost) / 1024)
nBuyCost = nSellCost nBuyCost = nSellCost
const maxStack = Math.min(511, item.base.maxStack || 350) nRepCost = Math.trunc((maxStackOf(item.base) * baseCost) / 1024)
nRepCost = Math.trunc((maxStack * baseCost) / 1024) } else if (!rec.stackable) {
} else if (!(item.base as any).stackable) {
nSellCost = baseCost nSellCost = baseCost
nBuyCost = nSellCost nBuyCost = nSellCost
nRepCost = nSellCost nRepCost = nSellCost
@ -471,18 +419,19 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
nSellCost = baseCost nSellCost = baseCost
nBuyCost = nSellCost nBuyCost = nSellCost
nRepCost = nSellCost nRepCost = nSellCost
const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 const maxStack = maxStackOf(item.base)
nDivisor = Math.max(1, Math.min(511, maxStack)) nDivisor = maxStack > 1 ? maxStack : 1
} }
// Armor defense scaling (lines 2119-2131 of Items.cpp) // 0x6fd7900b: armor scales by base defense over max defense
if (item.base.kind === 'armor') { if (isItemCodeOfType113c(getCanonicalTransactionCostTables(), item.base.id, 'armo')) {
const minAc = Number((item.base as any).minac) || 0 const minAc = rec.minAc
const maxAc = Number((item.base as any).maxac) || item.base.defense || 0 const maxAc = rec.maxAc
if (maxAc > 0 && maxAc - minAc !== -1) { if (maxAc !== 0 && maxAc - minAc !== -1) {
// Base unit STAT_ARMORCLASS (before ac% bonus stat) // The Item model stores the final defense; strip ac% to recover the base roll.
const rawDef = item.defense ?? item.stats?.defense
if (rawDef === undefined) throw new Error(`item-cost: armor '${item.base.id}' has no defense value`)
const acPct = item.stats?.['ac%'] ?? 0 const acPct = item.stats?.['ac%'] ?? 0
const rawDef = item.defense ?? item.stats?.defense ?? maxAc
const baseAc = const baseAc =
acPct > 0 acPct > 0
? Math.min(maxAc + 1, Math.max(minAc, Math.round((rawDef * 100) / (100 + acPct)))) ? Math.min(maxAc + 1, Math.max(minAc, Math.round((rawDef * 100) / (100 + acPct))))
@ -495,16 +444,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
const quality = item.quality ?? ItemQuality.NORMAL const quality = item.quality ?? ItemQuality.NORMAL
// StaffMods for Normal / Low / Superior items (lines 2135-2138) // StaffMods before affixes for non-magic qualities
if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) { if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) {
const afterSkill = calculateAdditionalCostsForItemSkill( const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables)
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterSkill.sellCost nSellCost = afterSkill.sellCost
nBuyCost = afterSkill.buyCost nBuyCost = afterSkill.buyCost
nRepCost = afterSkill.repCost nRepCost = afterSkill.repCost
@ -517,16 +459,25 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
let nSell = 0 let nSell = 0
let nBuy = 0 let nBuy = 0
let nRep = 0 let nRep = 0
const addTerm = (add: number, mult: number): void => {
// AutoMagic affix cost (lines 2142-2157)
const autoAffix = (item as any).rolledAutoMagic?.entry
if (autoAffix && (autoAffix.add || autoAffix.multiply)) {
const add = autoAffix.add ?? 0
const mult = autoAffix.multiply ?? 0
nSell += add + Math.trunc((nSellCost * mult) / 1024) nSell += add + Math.trunc((nSellCost * mult) / 1024)
nBuy += add + Math.trunc((nBuyCost * mult) / 1024) nBuy += add + Math.trunc((nBuyCost * mult) / 1024)
nRep += add + Math.trunc((nRepCost * mult) / 1024) nRep += add + Math.trunc((nRepCost * mult) / 1024)
} }
const applyBonus = (): void => {
const afterBonus = calculateAdditionalCostsForBonusStats(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
}
// AutoMagic affix cost
const autoRow = rolledAutoMagicRow(item)
if (autoRow !== undefined) {
const auto = getCanonicalTransactionCostTables().autoMagic[autoRow]
if (!auto) throw new Error(`item-cost: AutoMagic row ${autoRow} outside table`)
addTerm(auto.add, auto.multiply)
}
switch (quality) { switch (quality) {
case ItemQuality.RARE: case ItemQuality.RARE:
@ -534,25 +485,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
const rareAffixes = item.rolledRareAffixes?.affixes ?? [] const rareAffixes = item.rolledRareAffixes?.affixes ?? []
for (const entry of rareAffixes) { for (const entry of rareAffixes) {
const affix = entry.affix ?? entry const affix = entry.affix ?? entry
const add = Number(affix?.add) || 0 addTerm(Number(affix?.add) || 0, Number(affix?.multiply) || 0)
const mult = Number(affix?.multiply) || 0
if (add || mult) {
nSell += add + Math.trunc((nSellCost * mult) / 1024)
nBuy += add + Math.trunc((nBuyCost * mult) / 1024)
nRep += add + Math.trunc((nRepCost * mult) / 1024)
}
} }
const afterBonus = calculateAdditionalCostsForBonusStats( applyBonus()
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
break break
} }
@ -561,23 +496,9 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
item.uniqueItemDef ?? item.uniqueItemDef ??
tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name) tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name)
if (uDef) { if (uDef) {
const costAdd = Number(uDef.costAdd ?? (uDef as any).costadd) || 0 addTerm(Number(uDef.costAdd) || 0, Number(uDef.costMult) || 0)
const costMult = Number(uDef.costMult ?? (uDef as any).costmult) || 0
nSell += costAdd + Math.trunc((nSellCost * costMult) / 1024)
nBuy += costAdd + Math.trunc((nBuyCost * costMult) / 1024)
nRep += costAdd + Math.trunc((nRepCost * costMult) / 1024)
} else { } else {
const afterBonus = calculateAdditionalCostsForBonusStats( applyBonus()
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
} }
break break
} }
@ -588,62 +509,23 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
tables.sets.items.entries.find( tables.sets.items.entries.find(
s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name, s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name,
) )
if (sDef) { if (sDef) addTerm(Number(sDef.costAdd) || 0, Number(sDef.costMult) || 0)
const costAdd = Number(sDef.costAdd ?? (sDef as any).costadd) || 0
const costMult = Number(sDef.costMult ?? (sDef as any).costmult) || 0
nSell += costAdd + Math.trunc((nSellCost * costMult) / 1024)
nBuy += costAdd + Math.trunc((nBuyCost * costMult) / 1024)
nRep += costAdd + Math.trunc((nRepCost * costMult) / 1024)
}
break break
} }
case ItemQuality.MAGIC: { case ItemQuality.MAGIC: {
const pAffix = item.rolledMagicAffixes?.prefix?.affix const pAffix = item.rolledMagicAffixes?.prefix?.affix
if (pAffix) { if (pAffix) addTerm(Number(pAffix.add) || 0, Number(pAffix.multiply) || 0)
const add = Number(pAffix.add) || 0
const mult = Number(pAffix.multiply) || 0
nSell += add + Math.trunc((nSellCost * mult) / 1024)
nBuy += add + Math.trunc((nBuyCost * mult) / 1024)
nRep += add + Math.trunc((nRepCost * mult) / 1024)
}
const sAffix = item.rolledMagicAffixes?.suffix?.affix const sAffix = item.rolledMagicAffixes?.suffix?.affix
if (sAffix) { if (sAffix) addTerm(Number(sAffix.add) || 0, Number(sAffix.multiply) || 0)
const add = Number(sAffix.add) || 0 applyBonus()
const mult = Number(sAffix.multiply) || 0
nSell += add + Math.trunc((nSellCost * mult) / 1024)
nBuy += add + Math.trunc((nBuyCost * mult) / 1024)
nRep += add + Math.trunc((nRepCost * mult) / 1024)
}
const afterBonus = calculateAdditionalCostsForBonusStats(
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
break break
} }
case ItemQuality.SUPERIOR: case ItemQuality.SUPERIOR:
case ItemQuality.TEMPERED: { case ItemQuality.TEMPERED:
const afterBonus = calculateAdditionalCostsForBonusStats( applyBonus()
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
break break
}
case ItemQuality.LOW: { case ItemQuality.LOW: {
nSell = -Math.trunc(nSellCost / 2) nSell = -Math.trunc(nSellCost / 2)
@ -661,74 +543,52 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
nRepCost += Math.trunc(nRep / nDivisor) nRepCost += Math.trunc(nRep / nDivisor)
if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) { if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) {
const afterSkill = calculateAdditionalCostsForItemSkill( const afterSkill = calculateAdditionalCostsForItemSkill(item, nSellCost, nBuyCost, nRepCost, nDivisor, tables)
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterSkill.sellCost nSellCost = afterSkill.sellCost
nBuyCost = afterSkill.buyCost nBuyCost = afterSkill.buyCost
nRepCost = afterSkill.repCost nRepCost = afterSkill.repCost
} }
} }
// Socketed items (lines 2311-2325 of Items.cpp) // 0x6fd74a50: socketed children add half their ItemsTxt cost
if (item.socketedItems && item.socketedItems.length > 0) { for (const socketable of item.socketedItems ?? []) {
for (const socketable of item.socketedItems) { const socketableCost = Math.trunc(txt(socketable.base).cost / 2)
const socketableCost = Math.trunc((socketable.base.cost ?? socketable.base.value ?? 0) / 2) nSellCost += socketableCost
nSellCost += socketableCost nBuyCost += socketableCost
nBuyCost += socketableCost nRepCost += socketableCost
nRepCost += socketableCost
}
} }
// Ethereal acquisition quartering (lines 2327-2330) // 0x6fd798b0: ethereal and class-specific quartering of the player-sell price
const isEthereal = const ethereal = isEthereal(item)
Boolean(item.flags?.ethereal) || if (ethereal) {
Boolean((item as any).ethereal) ||
((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0
if (isEthereal) {
nBuyCost = Math.trunc(nBuyCost / 4) nBuyCost = Math.trunc(nBuyCost / 4)
} }
// Class-specific item acquisition quartering (lines 2332-2340)
if (isClassSpecificItemBase(item.base, tables.itemTypes)) { if (isClassSpecificItemBase(item.base, tables.itemTypes)) {
nBuyCost = Math.trunc(nBuyCost / 4) nBuyCost = Math.trunc(nBuyCost / 4)
} }
const maxDura = item.maxDurability ?? Number((item.base as any).durability) ?? 0 const maxDura = item.maxDurability ?? (rec.noDurability ? 0 : rec.durability)
const curDura = item.durability ?? maxDura const curDura = item.durability ?? maxDura
if (transactionType === TransactionType.SELL) { if (transactionType === TransactionType.SELL) {
if (isEthereal && maxDura > 0 && curDura <= 0) { if (ethereal && maxDura > 0 && curDura <= 0) {
nBuyCost = 0 nBuyCost = 0
} }
} else if (transactionType === TransactionType.REPAIR) { } else if (transactionType === TransactionType.REPAIR) {
if (maxDura > 0) { if (maxDura > 0) {
if (curDura < maxDura) { nRepCost = curDura < maxDura ? Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) : 0
nRepCost = Math.trunc((nRepCost * (maxDura - curDura)) / maxDura)
} else {
nRepCost = 0
}
} }
} }
// Apply Npc.txt multipliers and quest discounts (lines 2388-2450) // 0x6fd799d8 / 0x6fd79aa0: Npc.txt multipliers and quest discounts
const npcRec = resolveNpcRecord(tables, vendorId, npcKey) const npcRec = resolveNpcRecord(tables, vendorId, npcKey)
const sellMult = npcRec?.sellMult ?? 1024 nSellCost = Math.trunc((nSellCost * npcRec.sellMult) / 1024)
const buyMult = npcRec?.buyMult ?? 512 nBuyCost = Math.trunc((nBuyCost * npcRec.buyMult) / 1024)
const repMult = npcRec?.repMult ?? 1024 nRepCost = Math.trunc((nRepCost * npcRec.repMult) / 1024)
nSellCost = Math.trunc((nSellCost * sellMult) / 1024) if (completedQuests && completedQuests.size > 0) {
nBuyCost = Math.trunc((nBuyCost * buyMult) / 1024)
nRepCost = Math.trunc((nRepCost * repMult) / 1024)
if (npcRec && completedQuests && completedQuests.size > 0) {
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const qFlag = npcRec.questFlag[i] ?? 0 const qFlag = npcRec.questFlag[i]!
if (qFlag > 0 && completedQuests.has(qFlag)) { if (qFlag > 0 && completedQuests.has(qFlag)) {
nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024) nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024)
nBuyCost = Math.trunc((nBuyCost * npcRec.questBuyMult[i]!) / 1024) nBuyCost = Math.trunc((nBuyCost * npcRec.questBuyMult[i]!) / 1024)
@ -737,51 +597,27 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
} }
} }
// Quantity multiplication for non-book, non-quiver items (lines 2452-2483) // 0x6fd79b8d: quantity for non-book, non-quiver items
if (!isBookItem(item.base) && !isQuiverItem(item.base)) { if (!isBookItem(item.base) && !isQuiverItem(item.base)) {
nSellCost *= nQuantity nSellCost *= nQuantity
if ((item.base as any).stackable && isItemRepairable(item)) { if (rec.stackable && isItemRepairable(item)) {
const maxStack = Math.min(511, item.base.maxStack || Number((item.base as any).maxstack) || 1) const maxStack = maxStackOf(item.base)
if (nQuantity < maxStack) { nRepCost = nQuantity < maxStack ? nRepCost * (maxStack - nQuantity) : 0
nRepCost *= maxStack - nQuantity
} else {
nRepCost = 0
}
nBuyCost *= nQuantity
} else {
nBuyCost *= nQuantity
} }
nBuyCost *= nQuantity
} }
if (transactionType === TransactionType.SELL) { if (transactionType === TransactionType.SELL) {
let nCost = nBuyCost const cap = npcRec.maxBuy[difficulty]
let maxBuyCap: number const nCost = nBuyCost > cap ? cap : nBuyCost
if (npcRec) {
maxBuyCap = npcRec.maxBuy[difficulty] ?? 35000
} else if (difficulty === 0) {
const resolvedAct =
actIndex ??
(vendorId !== undefined ? getTownNpcDescriptorByVendorId(vendorId)?.actIndex : undefined) ??
0
maxBuyCap = DEFAULT_NORMAL_MAX_BUY_BY_ACT[resolvedAct] ?? 5000
} else if (difficulty === 1) {
maxBuyCap = DEFAULT_NIGHTMARE_MAX_BUY
} else {
maxBuyCap = DEFAULT_HELL_MAX_BUY
}
if (nCost > maxBuyCap) {
nCost = maxBuyCap
}
return Math.max(1, nCost) return Math.max(1, nCost)
} }
if (transactionType === TransactionType.REPAIR) { if (transactionType === TransactionType.REPAIR) {
let nCost = nRepCost - Math.trunc((nRepCost * clampedReduce) / 100) const nCost = rp !== 0 ? nRepCost - fogMulDiv113c(rp, nRepCost, 100) : nRepCost
return Math.max(1, nCost) return Math.max(1, nCost)
} }
// TransactionType.BUY const nCost = rp !== 0 ? nSellCost - fogMulDiv113c(rp, nSellCost, 100) : nSellCost
let nCost = nSellCost - Math.trunc((nSellCost * clampedReduce) / 100)
return Math.max(1, nCost) return Math.max(1, nCost)
} }

File diff suppressed because it is too large Load Diff

View File

@ -193,8 +193,8 @@ describe('1.13c Item Transaction & Acquisition Cost Engine (M19-P3)', () => {
expect(sellHell).toBe(35000) expect(sellHell).toBe(35000)
}) })
it('halves acquisition (SELL) price for Ethereal items and forbids repairing Ethereal items', () => { it('quarters acquisition (SELL) price for Ethereal items (0x6fd798b0) and forbids repairing Ethereal items', () => {
const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so halving is observable) const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so quartering is observable)
const normalArmor = createDroppedItem(capBase, 'normal', { const normalArmor = createDroppedItem(capBase, 'normal', {
itemTypes: tables.itemTypes, itemTypes: tables.itemTypes,
ilvl: 25, ilvl: 25,

View File

@ -0,0 +1,307 @@
/**
* D2Common 1.13c #10107 / #10071 pricing port. Every expectation below was
* worked out by hand from the canonical table values given in the comments,
* following the asm branches cited in `src/common/items/transaction-cost.ts`.
*
* Npc.txt (normal difficulty):
* akara sell mult 1024, buy mult 512, rep mult 128, questflag A 4,
* questsellmult A 922, questbuymult A 1024, max buy 5000
* charsi sell mult 960, buy mult 512, rep mult 128, max buy 5000
*/
import { describe, expect, it } from 'vitest'
import {
computeRepairAllCost113c,
computeTransactionCost113c,
fogMulDiv113c,
getCanonicalTransactionCostTables,
getNpcClassByName113c,
isItemRepairable113c,
type TransactionCostContext,
type TransactionCostInput,
type TransactionCostStat,
} from '../src/common/items/transaction-cost.ts'
const tables = getCanonicalTransactionCostTables()
const AKARA = getNpcClassByName113c(tables, 'akara')
const CHARSI = getNpcClassByName113c(tables, 'charsi')
function ctx(npcClass: number, over: Partial<TransactionCostContext> = {}): TransactionCostContext {
return {
playerLevel: 1,
playerReducedPricesPct: 0,
difficulty: 0,
npcClass,
isQuestFlagSet: () => false,
tables,
...over,
}
}
function stat(statId: number, value: number, base = value, param = 0): TransactionCostStat {
return { statId, param, value, base }
}
function item(code: string, over: Partial<TransactionCostInput> = {}): TransactionCostInput {
return {
code,
itemFormat: 101,
quality: 2,
identified: true,
ethereal: false,
starterItem: false,
isEar: false,
stats: [],
socketedChildCodes: [],
...over,
}
}
const buy = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 0)
const sell = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 1)
const gamble = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 2)
const repair = (i: TransactionCostInput, c: TransactionCostContext): number => computeTransactionCost113c(i, c, 3)
describe('compiled tables', () => {
it('maps Npc.txt rows onto MonStats hcIdx with "Expansion" rows skipped', () => {
expect(AKARA).toBe(148)
expect(CHARSI).toBe(154)
expect(getNpcClassByName113c(tables, 'gheed')).toBe(147)
// raw MonStats row 512, after the Expansion row at raw 410
expect(getNpcClassByName113c(tables, 'larzuk')).toBe(511)
})
it('indexes affixes by compiled row, packet id = row + 1', () => {
expect(tables.magicPrefixes[1]).toEqual({ name: 'Sturdy', multiply: 0, add: 0 })
expect(tables.magicPrefixes[7]).toEqual({ name: 'Devious', multiply: 0, add: 0 })
// raw row 317, after the Expansion row at raw 118
expect(tables.magicPrefixes[316]).toEqual({ name: 'Shimmering', multiply: 1280, add: 500 })
expect(tables.magicSuffixes[0]).toEqual({ name: 'of Health', multiply: 0, add: 0 })
})
it('resolves every normcode/ubercode/ultracode a gamble can reach', () => {
for (const rec of tables.items.values()) {
expect(() => gamble(item(rec.code), ctx(AKARA, { playerLevel: 50 }))).not.toThrow()
}
})
})
describe('#10107 buy / sell', () => {
it('hp1 at Akara: cost 30', () => {
// A = B = C = 30. Buy: 30*1024/1024 = 30. Sell: 30*512/1024 = 15.
expect(buy(item('hp1'), ctx(AKARA))).toBe(30)
expect(sell(item('hp1'), ctx(AKARA))).toBe(15)
})
it('applies the Akara quest discount when quest 4 state 0 or 1 is set', () => {
const c = ctx(AKARA, { isQuestFlagSet: (q, s) => q === 4 && s === 0 })
// Buy: 30 * questsellmult 922 / 1024 = 27.01 -> 27. Sell: 15 * questbuymult 1024 / 1024 = 15.
expect(buy(item('hp1'), c)).toBe(27)
expect(sell(item('hp1'), c)).toBe(15)
})
it('tsc at Akara: cost 100', () => {
expect(buy(item('tsc'), ctx(AKARA))).toBe(100)
expect(sell(item('tsc'), ctx(AKARA))).toBe(50)
})
it('ssd at Charsi: cost 72', () => {
const ssd = item('ssd', { stats: [stat(72, 24), stat(73, 24)] })
// Buy: 72*960/1024 = 67.5 -> 67. Sell: 72*512/1024 = 36.
expect(buy(ssd, ctx(CHARSI))).toBe(67)
expect(sell(ssd, ctx(CHARSI))).toBe(36)
})
it('buc at Charsi scales by base defense: cost 68, maxac 6, base AC 5', () => {
const buc = item('buc', { stats: [stat(31, 5), stat(72, 12), stat(73, 12)] })
// A = B = C = 68*5/6 = 56. Buy: 56*960/1024 = 52.5 -> 52. Sell: 56*512/1024 = 28.
expect(buy(buc, ctx(CHARSI))).toBe(52)
expect(sell(buc, ctx(CHARSI))).toBe(28)
})
it('magic buc with prefix Devious (id 8) + suffix of Health (id 1)', () => {
// Both affixes have multiply 0 / add 0, so their terms are 0. Bonus stats (full - base):
// 34 normal_damage_reduction +1: Add 188, Multiply 200 -> 200*56*1/1024 = 10 + 188 = 198
// 35 magic_damage_reduction +1: Add 397, Multiply 340 -> 340*56*1/1024 = 18 + 397 = 415
// A = B = 56 + 198 + 415 = 669. Buy: 669*960/1024 = 627.2 -> 627. Sell: 669*512/1024 = 334.
const buc = item('buc', {
quality: 4,
magicPrefixId: 8,
magicSuffixId: 1,
stats: [stat(31, 5), stat(72, 12), stat(73, 12), stat(34, 1, 0), stat(35, 1, 0)],
})
expect(buy(buc, ctx(CHARSI))).toBe(627)
expect(sell(buc, ctx(CHARSI))).toBe(334)
})
it('magic ring with Shimmering (id 317: multiply 1280, add 500)', () => {
// rin cost 1800. Term: 1280*1800/1024 + 500 = 2250 + 500 = 2750, so A = B = 4550.
// Akara buy 4550; sell 4550*512/1024 = 2275.
const rin = item('rin', { quality: 4, magicPrefixId: 317, magicSuffixId: 0 })
expect(buy(rin, ctx(AKARA))).toBe(4550)
expect(sell(rin, ctx(AKARA))).toBe(2275)
})
it('wand with +3 Teeth StaffMods (skill 67: costMult 256, costAdd 1000)', () => {
// wnd cost 205, factor 2*3-1 = 5.
// A += (256*205/1024 + 1000)*5 = (51 + 1000)*5 = 5255 -> 5460
// B += (205*256/4096 + 1000)*5 = (12 + 1000)*5 = 5060 -> 5265
// Akara buy 5460; sell 5265*512/1024 = 2632.
const wnd = item('wnd', { stats: [stat(107, 3, 3, 67), stat(72, 15), stat(73, 15)] })
expect(buy(wnd, ctx(AKARA))).toBe(5460)
expect(sell(wnd, ctx(AKARA))).toBe(2632)
})
it('arrows (quiver): aqv cost 256, maxstack 350', () => {
// 350 arrows: A = B = 256*350/1024 = 87. Charsi buy 87*960/1024 = 81; sell 87*512/1024 = 43.
expect(buy(item('aqv', { stats: [stat(70, 350)] }), ctx(CHARSI))).toBe(81)
expect(sell(item('aqv', { stats: [stat(70, 350)] }), ctx(CHARSI))).toBe(43)
// 100 arrows: A = B = 25600/1024 = 25. Buy 25*960/1024 = 23; sell 12.
expect(buy(item('aqv', { stats: [stat(70, 100)] }), ctx(CHARSI))).toBe(23)
expect(sell(item('aqv', { stats: [stat(70, 100)] }), ctx(CHARSI))).toBe(12)
})
it('stackable throwing weapon: tkf cost 6, maxstack 160, quantity 100', () => {
// A = B = C = 6, D = 160. Npc: A = 6*960/1024 = 5; B = 6*512/1024 = 3; C = 6*128/1024 = 0.
// Quantity: buy = 5*100 = 500. Sell = 160*3 - 0*(160-100) = 480 (full-stack value minus refill).
// Repair: throwable, so C stays 6 -> 0 after rep mult -> clamped to 1.
const tkf = item('tkf', { stats: [stat(70, 100), stat(72, 4), stat(73, 4)] })
expect(buy(tkf, ctx(CHARSI))).toBe(500)
expect(sell(tkf, ctx(CHARSI))).toBe(480)
expect(repair(tkf, ctx(CHARSI))).toBe(1)
})
it('Town Portal tome prices by charges: Books row 0 CostPerCharge 50, tbk cost 250', () => {
// 3 charges: 50*3 + 250 = 400 -> Akara buy 400, sell 200.
expect(buy(item('tbk', { bookId: 0, stats: [stat(70, 3)] }), ctx(AKARA))).toBe(400)
expect(sell(item('tbk', { bookId: 0, stats: [stat(70, 3)] }), ctx(AKARA))).toBe(200)
// 20 charges: 50*20 + 250 = 1250 -> buy 1250, sell 625.
expect(buy(item('tbk', { bookId: 0, stats: [stat(70, 20)] }), ctx(AKARA))).toBe(1250)
expect(sell(item('tbk', { bookId: 0, stats: [stat(70, 20)] }), ctx(AKARA))).toBe(625)
})
it('throws when a book has no Books.txt id', () => {
expect(() => buy(item('tbk', { stats: [stat(70, 3)] }), ctx(AKARA))).toThrow(/bookId/)
})
it('starter items cost 1 for every transaction type', () => {
const hax = item('hax', { starterItem: true, stats: [stat(72, 28), stat(73, 28)] })
expect(buy(hax, ctx(CHARSI))).toBe(1)
expect(sell(hax, ctx(CHARSI))).toBe(1)
expect(repair(hax, ctx(CHARSI))).toBe(1)
expect(gamble(hax, ctx(CHARSI))).toBe(1)
})
it('quarters the sell price of ethereal items (buy unchanged)', () => {
const ssd = item('ssd', { ethereal: true, stats: [stat(72, 24), stat(73, 24)] })
// B = 72/4 = 18 -> 18*512/1024 = 9. Buy stays 67.
expect(buy(ssd, ctx(CHARSI))).toBe(67)
expect(sell(ssd, ctx(CHARSI))).toBe(9)
expect(repair(ssd, ctx(CHARSI))).toBe(0) // ethereal items are not repairable
})
it('quarters the sell price of class-specific items: am1 (abow, Class=ama) cost 2500', () => {
const am1 = item('am1', { stats: [stat(72, 40), stat(73, 40)] })
// B = 2500/4 = 625 -> 625*512/1024 = 312. Buy 2500.
expect(buy(am1, ctx(AKARA))).toBe(2500)
expect(sell(am1, ctx(AKARA))).toBe(312)
})
it('caps the sell price at Npc.txt max buy', () => {
const rin = item('rin', { quality: 4, magicPrefixId: 327 - 0, magicSuffixId: 0 })
// Chromatic (raw 327 -> compiled 326 -> id 327): multiply 1280, add 16000.
// A = 1800 + 1800*1280/1024 + 16000 = 1800 + 2250 + 16000 = 20050 -> sell 10025 -> capped 5000.
expect(tables.magicPrefixes[326]).toEqual({ name: 'Chromatic', multiply: 1280, add: 16000 })
expect(sell(rin, ctx(AKARA))).toBe(5000)
expect(sell(rin, ctx(AKARA, { difficulty: 1 }))).toBe(10025)
})
})
describe('#10107 repair', () => {
it('repairs a half-damaged ssd at Charsi: (24-12)*72/24 = 36 -> 36*128/1024 = 4', () => {
expect(repair(item('ssd', { stats: [stat(72, 12, 12), stat(73, 24)] }), ctx(CHARSI))).toBe(4)
})
it('charges the minimum 1 for a repairable item at full durability', () => {
const ssd = item('ssd', { stats: [stat(72, 24), stat(73, 24)] })
expect(isItemRepairable113c(ssd, tables)).toBe(true)
expect(repair(ssd, ctx(CHARSI))).toBe(1)
})
it('returns 0 for unrepairable items', () => {
expect(repair(item('hp1'), ctx(AKARA))).toBe(0)
expect(repair(item('ssd', { identified: false, stats: [stat(72, 1), stat(73, 24)] }), ctx(CHARSI))).toBe(0)
})
it('applies reduced prices with the 99% cap', () => {
// qui base AC 10 (maxac 11): C = 140*10/11 = 127; repair (20-10)*127/20 = 63 -> 63*128/1024 = 7.
const qui = item('qui', { stats: [stat(31, 10), stat(72, 10, 10), stat(73, 20)] })
expect(repair(qui, ctx(CHARSI))).toBe(7)
// 50%: 7 - 7*50/100 = 7 - 3 = 4
expect(repair(qui, ctx(CHARSI, { playerReducedPricesPct: 50 }))).toBe(4)
// 150% is capped to 99: 7 - 6 = 1
expect(repair(qui, ctx(CHARSI, { playerReducedPricesPct: 150 }))).toBe(1)
})
})
describe('#10107 gamble', () => {
it('rings and amulets use their gamble cost (rin 50000, amu 63000)', () => {
expect(gamble(item('rin'), ctx(AKARA, { playerLevel: 30 }))).toBe(50000)
expect(gamble(item('amu'), ctx(AKARA, { playerLevel: 30 }))).toBe(63000)
// 30% reduced prices: 50000 - 15000 = 35000; 63000 - 18900 = 44100.
expect(gamble(item('rin'), ctx(AKARA, { playerLevel: 30, playerReducedPricesPct: 30 }))).toBe(35000)
expect(gamble(item('amu'), ctx(AKARA, { playerLevel: 30, playerReducedPricesPct: 30 }))).toBe(44100)
})
it('armor bases follow the clvl formula: qui cost 140 qlvl 1; xui lvl 34 cost 30552; uui lvl 65', () => {
// clvl 1: uber/ultra weights 0; s = 140; L = 5; t = 5*250/3 = 416; f = 11/3 + 20 = 23
// (416 + 140)*23/15 = 852
expect(gamble(item('qui'), ctx(AKARA, { playerLevel: 1 }))).toBe(852)
// clvl 30: weights still 0; L = 30; t = 2500; f = 61/3 + 20 = 40; (2500 + 140)*40/15 = 7040
expect(gamble(item('qui'), ctx(AKARA, { playerLevel: 30 }))).toBe(7040)
// clvl 40: uberW = (40-34)*100/2 + 1 = 301; s = ((10000-301)*140 + 30552*301)/10000 = 1055
// t = 40*250/3 = 3333; f = 81/3 + 20 = 47; (3333 + 1055)*47/15 = 13749
expect(gamble(item('qui'), ctx(AKARA, { playerLevel: 40 }))).toBe(13749)
})
it('uses the ItemsTxt gamble cost for itemFormat 0', () => {
expect(gamble(item('qui', { itemFormat: 0 }), ctx(AKARA, { playerLevel: 40 }))).toBe(3035)
})
})
describe('#10071 repair all', () => {
it('sums the repair cost of equipped items that need it', () => {
const slots: (TransactionCostInput | null)[] = Array.from({ length: 13 }, () => null)
slots[4] = item('ssd', { stats: [stat(72, 12, 12), stat(73, 24)] }) // 4
slots[3] = item('qui', { stats: [stat(31, 10), stat(72, 10, 10), stat(73, 20)] }) // 7
slots[5] = item('buc', { stats: [stat(31, 5), stat(72, 12), stat(73, 12)] }) // full: skipped (not 1)
expect(computeRepairAllCost113c(slots, ctx(CHARSI))).toBe(11)
})
it('requires exactly 13 slots', () => {
expect(() => computeRepairAllCost113c([], ctx(CHARSI))).toThrow(/13/)
})
})
describe('fail-fast', () => {
it('throws on unknown codes and NPCs', () => {
expect(() => buy(item('zzz'), ctx(AKARA))).toThrow(/zzz/)
expect(() => buy(item('hp1'), ctx(-1))).toThrow(/Npc/)
expect(() => buy(item('rin', { quality: 4, magicPrefixId: 9999, magicSuffixId: 0 }), ctx(AKARA))).toThrow(/outside/)
})
it('fogMulDiv picks the int64 path for large values', () => {
expect(fogMulDiv113c(30, 50000, 100)).toBe(15000)
expect(fogMulDiv113c(50, 3_000_000, 100)).toBe(1_500_000)
})
})
describe('live capture cross-check (tests/fixtures/netproto/06-d2gs-vendor-buy-sell.d2cap)', () => {
// WebBotThree sold its starter tsc and isc: each 0x2A result 1 flag 3 carried stat 14 one higher
// (2 gold after both sales, from 0). Starter branch 0x6fd78ecd.
it('starter scrolls sell for exactly 1 gold', () => {
for (const npc of [AKARA, CHARSI]) {
expect(sell(item('tsc', { starterItem: true }), ctx(npc))).toBe(1)
expect(sell(item('isc', { starterItem: true }), ctx(npc))).toBe(1)
}
})
})