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