788 lines
25 KiB
TypeScript
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)
|
|
}
|