diablo2-web/src/game/item-cost.ts

788 lines
25 KiB
TypeScript

import type { DropTables } from './drop-pipeline.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 './npc-table.ts'
/**
* Canonical 1.13c transaction types (`D2C_TransactionTypes` in D2Common.dll).
*/
export const enum TransactionType {
BUY = 0,
SELL = 1,
GAMBLE = 2,
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
readonly difficulty?: 0 | 1 | 2 | undefined
readonly cLvl?: number | undefined
readonly vendorId?: VendorNpcId | undefined
readonly npcKey?: string | undefined
readonly actIndex?: 0 | 1 | 2 | 3 | 4 | undefined
readonly completedQuests?: ReadonlySet<number> | undefined
readonly reducedPricesPercent?: number | undefined
readonly tables: DropTables
}
/**
* Checks whether an item base belongs to a class-specific ItemType (`ItemTypes.Class !== ''`, i.e., `nClass < 7`).
*/
export function isClassSpecificItemBase(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.class && def.class.trim().length > 0) {
return true
}
if (def.equiv1) queue.push(def.equiv1)
if (def.equiv2) queue.push(def.equiv2)
}
return false
}
/**
* Checks whether an item base can receive StaffMods (`ItemTypes.StaffMods !== ''`, i.e., `nStaffMods != 7`).
*/
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 {
const isEthereal =
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
const hasIndestructible = Boolean(
item.rolledProps?.some(p => p.code.toLowerCase() === 'indestruct') ||
item.stats?.indestruct,
)
if (hasIndestructible) return false
const maxDur = item.maxDurability ?? Number((item.base as any).durability) ?? 0
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
const qty = item.quantity ?? item.stack ?? maxStack
if (qty < maxStack) return true
}
// Also repairable if it has partially depleted charged skills
if (item.rolledProps?.some(p => p.code.toLowerCase() === 'charged')) {
return true
}
return false
}
/**
* Implements `ITEMS_CalculateAdditionalCostsForItemSkill` (`0x6FD9CB50`)
* for single-skill staffmods (`STAT_ITEM_SINGLESKILL`) on StaffMods-eligible item types.
*/
export function calculateAdditionalCostsForItemSkill(
item: Item,
sellCost: number,
buyCost: number,
repCost: number,
divisor: number,
tables: DropTables,
): { sellCost: number; buyCost: number; repCost: number } {
if (!hasStaffModsItemType(item.base, tables.itemTypes)) {
return { sellCost, buyCost, repCost }
}
const staffMods: readonly RolledItemProp[] =
item.staffMods ??
(item.rolledProps?.filter(p => p.code.toLowerCase() === 'skill') ?? [])
if (staffMods.length === 0) {
return { sellCost, buyCost, repCost }
}
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 factor = 2 * slvl - 1
nSell += factor * (skillRec.costAdd + Math.trunc((sellCost * skillRec.costMult) / 1024))
nBuy += factor * (skillRec.costAdd + Math.trunc((buyCost * skillRec.costMult) / 4096))
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),
}
}
/**
* Implements `ITEMS_CalculateAdditionalCostsForBonusStats` (`0x6FD9ACE0`).
* Iterates resolved bonus stats on the item and applies `ItemStatCost.txt`
* (`Encode`, `Add`, `Multiply`, `Divide`) or `Skills.txt` (`CostAdd`, `CostMult`).
*/
export function calculateAdditionalCostsForBonusStats(
item: Item,
sellCost: number,
buyCost: number,
repCost: number,
divisor: number,
tables: DropTables,
): { sellCost: number; buyCost: number; repCost: number } {
const propertiesTable = tables.properties
const statCostTable = tables.itemStatCost
if (!statCostTable) {
return { sellCost, buyCost, repCost }
}
let nSell = 0
let nBuy = 0
let nRep = 0
// Collect resolved stats from rolledProps (excluding staffMods 'skill' which are handled by calculateAdditionalCostsForItemSkill)
const staffModSet = new Set<RolledItemProp>(item.staffMods ?? [])
const props = item.rolledProps ?? []
interface StatBonusEntry {
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) {
for (const prop of props) {
if (staffModSet.has(prop)) continue
if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) {
continue
}
const numParam =
typeof prop.param === 'number'
? prop.param
: typeof prop.param === 'string' && prop.param.trim() !== ''
? parseInt(prop.param, 10) || 0
: 0
const resolved = propertiesTable.resolveProperty(
prop.code,
numParam,
prop.value,
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,
})
}
}
}
} 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,
})
}
}
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.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) {
continue
}
const nBonusValue = entry.value
if (!nBonusValue) continue
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)
}
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)
}
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)
}
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),
}
}
/**
* Calculates the 1.13c Gamble purchase price for an item (`ITEMS_CalculateTransactionCost` branch `TRANSACTIONTYPE_GAMBLE` at `0x6FD9B1C0`).
*/
export function calculateGambleCost(options: {
readonly item: Item
readonly cLvl: number
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()
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))
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)
}
/**
* 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`).
*/
export function calculateTransactionCost(options: TransactionCostOptions): number {
const {
item,
transactionType,
difficulty = 0,
cLvl = 1,
vendorId,
npcKey,
actIndex,
completedQuests,
reducedPricesPercent = 0,
tables,
} = options
if (transactionType === TransactionType.REPAIR && !isItemRepairable(item)) {
return 0
}
const isStarter =
Boolean(item.flags?.starterItem) ||
((item.rawFlags ?? 0) & ItemFlag.STARTER_ITEM) !== 0
if (isStarter) {
return 1
}
if (transactionType === TransactionType.GAMBLE) {
return calculateGambleCost({
item,
cLvl,
reducedPricesPercent,
tables,
})
}
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
let nSellCost = 0
let nBuyCost = 0
let nRepCost = 0
let nDivisor = 1
const isEar =
Boolean(item.flags?.isEar) ||
((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0
if (isEar) {
const earLvl = item.earData?.level ?? 1
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
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) {
nSellCost = baseCost
nBuyCost = nSellCost
nRepCost = nSellCost
} else {
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))
}
// 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)
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))))
: Math.min(maxAc, Math.max(1, rawDef))
nSellCost = Math.trunc((baseAc * baseCost) / maxAc)
nBuyCost = nSellCost
nRepCost = nSellCost
}
}
const quality = item.quality ?? ItemQuality.NORMAL
// StaffMods for Normal / Low / Superior items (lines 2135-2138)
if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) {
const afterSkill = calculateAdditionalCostsForItemSkill(
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterSkill.sellCost
nBuyCost = afterSkill.buyCost
nRepCost = afterSkill.repCost
}
const isIdentified =
item.identified !== false && item.flags?.identified !== false && !item.isGambleMystery
if (isIdentified) {
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
nSell += add + Math.trunc((nSellCost * mult) / 1024)
nBuy += add + Math.trunc((nBuyCost * mult) / 1024)
nRep += add + Math.trunc((nRepCost * mult) / 1024)
}
switch (quality) {
case ItemQuality.RARE:
case ItemQuality.CRAFTED: {
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)
}
}
const afterBonus = calculateAdditionalCostsForBonusStats(
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
break
}
case ItemQuality.UNIQUE: {
const uDef =
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)
} else {
const afterBonus = calculateAdditionalCostsForBonusStats(
item,
nSellCost,
nBuyCost,
nRepCost,
nDivisor,
tables,
)
nSellCost = afterBonus.sellCost
nBuyCost = afterBonus.buyCost
nRepCost = afterBonus.repCost
}
break
}
case ItemQuality.SET: {
const sDef =
item.setItemDef ??
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)
}
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)
}
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
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
break
}
case ItemQuality.LOW: {
nSell = -Math.trunc(nSellCost / 2)
nBuy = -Math.trunc(nBuyCost / 2)
nRep = -Math.trunc(nRepCost / 2)
break
}
default:
break
}
nSellCost += Math.trunc(nSell / nDivisor)
nBuyCost += Math.trunc(nBuy / nDivisor)
nRepCost += Math.trunc(nRep / nDivisor)
if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) {
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
}
}
// 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) {
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 curDura = item.durability ?? maxDura
if (transactionType === TransactionType.SELL) {
if (isEthereal && 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
}
}
}
// Apply Npc.txt multipliers and quest discounts (lines 2388-2450)
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 * sellMult) / 1024)
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++) {
const qFlag = npcRec.questFlag[i] ?? 0
if (qFlag > 0 && completedQuests.has(qFlag)) {
nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024)
nBuyCost = Math.trunc((nBuyCost * npcRec.questBuyMult[i]!) / 1024)
nRepCost = Math.trunc((nRepCost * npcRec.questRepMult[i]!) / 1024)
}
}
}
// Quantity multiplication for non-book, non-quiver items (lines 2452-2483)
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 (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
}
return Math.max(1, nCost)
}
if (transactionType === TransactionType.REPAIR) {
let nCost = nRepCost - Math.trunc((nRepCost * clampedReduce) / 100)
return Math.max(1, nCost)
}
// TransactionType.BUY
let nCost = nSellCost - Math.trunc((nSellCost * clampedReduce) / 100)
return Math.max(1, nCost)
}