diablo2-web/tests/item-cost.test.ts

263 lines
8.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import {
TransactionType,
calculateTransactionCost,
calculateGambleCost,
isItemRepairable,
isClassSpecificItemBase,
hasStaffModsItemType,
} from '../src/game/item-cost.ts'
import { VendorNpcId } from '../src/game/npc-table.ts'
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { ItemFlag, ItemQuality, decodeItemFlags, type Item } from '../src/game/items.ts'
describe('1.13c Item Transaction & Acquisition Cost Engine (M19-P3)', () => {
const tables = getEmbeddedDropTables()
it('prices Starter Items at 1 gold for Buy and Sell and 0/1 for Repair', () => {
const haxBase = tables.getBase('hax')!
const normalItem = createDroppedItem(haxBase, 'normal', {
itemTypes: tables.itemTypes,
ilvl: 1,
dwInitSeed: 100,
itemRng: new D2Rng(100),
})
const starterItem: Item = {
...normalItem,
rawFlags: (normalItem.rawFlags ?? 0) | ItemFlag.STARTER_ITEM,
flags: decodeItemFlags((normalItem.rawFlags ?? 0) | ItemFlag.STARTER_ITEM),
}
expect(
calculateTransactionCost({
item: starterItem,
transactionType: TransactionType.BUY,
vendorId: VendorNpcId.CHARSI,
tables,
}),
).toBe(1)
expect(
calculateTransactionCost({
item: starterItem,
transactionType: TransactionType.SELL,
vendorId: VendorNpcId.CHARSI,
tables,
}),
).toBe(1)
})
it('prices Tomes (tbk / ibk) dynamically by scroll charge count per Books.txt', () => {
const tbkBase = tables.getBase('tbk')!
const baseTome = createDroppedItem(tbkBase, 'normal', {
itemTypes: tables.itemTypes,
ilvl: 1,
dwInitSeed: 200,
itemRng: new D2Rng(200),
})
// Tome with 3 scrolls vs Tome with 20 scrolls
const tome3: Item = { ...baseTome, stack: 3, quantity: 3 }
const tome20: Item = { ...baseTome, stack: 20, quantity: 20 }
const buy3 = calculateTransactionCost({
item: tome3,
transactionType: TransactionType.BUY,
vendorId: VendorNpcId.AKARA,
tables,
})
const buy20 = calculateTransactionCost({
item: tome20,
transactionType: TransactionType.BUY,
vendorId: VendorNpcId.AKARA,
tables,
})
// Books.txt: BaseCost=250, CostPerCharge=50 -> 3 scrolls = 400, 20 scrolls = 1250
expect(buy20).toBeGreaterThan(buy3)
expect(buy20 - buy3).toBe(17 * 50)
const sell3 = calculateTransactionCost({
item: tome3,
transactionType: TransactionType.SELL,
vendorId: VendorNpcId.AKARA,
tables,
})
const sell20 = calculateTransactionCost({
item: tome20,
transactionType: TransactionType.SELL,
vendorId: VendorNpcId.AKARA,
tables,
})
expect(sell20).toBeGreaterThan(sell3)
})
it('applies 1/4 acquisition divisor to class-specific items (e.g. Sorceress Orb / Necromancer Wand)', () => {
const ob1 = tables.getBase('ob1')! // Jared's Stone (Orb, class='sor')
expect(isClassSpecificItemBase(ob1, tables.itemTypes)).toBe(true)
const wnd = tables.getBase('wnd')! // Wand (StaffMods='nec', non-class-specific)
expect(hasStaffModsItemType(wnd, tables.itemTypes)).toBe(true)
})
it('adds affix / stat bonus cost and staffMod single-skill cost to Magic/Rare items', () => {
const wndBase = tables.getBase('wnd')!
const plainWand = createDroppedItem(wndBase, 'normal', {
itemTypes: tables.itemTypes,
ilvl: 12,
dwInitSeed: 301,
itemRng: new D2Rng(301),
})
const staffModWand: Item = {
...plainWand,
staffMods: [{ code: 'skill', param: 67, min: 3, max: 3, value: 3 }], // +3 Teeth (skillId 67)
}
const plainBuy = calculateTransactionCost({
item: plainWand,
transactionType: TransactionType.BUY,
vendorId: VendorNpcId.AKARA,
tables,
})
const staffModBuy = calculateTransactionCost({
item: staffModWand,
transactionType: TransactionType.BUY,
vendorId: VendorNpcId.AKARA,
tables,
})
expect(staffModBuy).toBeGreaterThan(plainBuy)
const plainSell = calculateTransactionCost({
item: plainWand,
transactionType: TransactionType.SELL,
vendorId: VendorNpcId.AKARA,
tables,
})
const staffModSell = calculateTransactionCost({
item: staffModWand,
transactionType: TransactionType.SELL,
vendorId: VendorNpcId.AKARA,
tables,
})
expect(staffModSell).toBeGreaterThan(plainSell)
})
it('clamps acquisition (SELL) price to Npc.txt MaxBuy cap per Act and Difficulty (5000 / 30000 / 35000)', () => {
const aarBase = tables.getBase('uar')! // Sacred Armor (high cost elite armor)
const baseArmor = createDroppedItem(aarBase, 'magic', {
itemTypes: tables.itemTypes,
ilvl: 85,
dwInitSeed: 401,
itemRng: new D2Rng(401),
})
// Give it high-cost skills/stats so raw sell value exceeds 35,000
const expensiveArmor: Item = {
...baseArmor,
statList: [
{ statId: 127, param: 0, value: 5 }, // +5 All Skills
{ statId: 7, param: 0, value: 100 }, // +100 Life
],
}
const sellAct1Norm = calculateTransactionCost({
item: expensiveArmor,
transactionType: TransactionType.SELL,
difficulty: 0,
vendorId: VendorNpcId.AKARA,
actIndex: 0,
tables,
})
expect(sellAct1Norm).toBe(5000)
const sellNightmare = calculateTransactionCost({
item: expensiveArmor,
transactionType: TransactionType.SELL,
difficulty: 1,
vendorId: VendorNpcId.AKARA,
actIndex: 0,
tables,
})
expect(sellNightmare).toBe(30000)
const sellHell = calculateTransactionCost({
item: expensiveArmor,
transactionType: TransactionType.SELL,
difficulty: 2,
vendorId: VendorNpcId.AKARA,
actIndex: 0,
tables,
})
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)
const normalArmor = createDroppedItem(capBase, 'normal', {
itemTypes: tables.itemTypes,
ilvl: 25,
ethereal: false,
dwInitSeed: 501,
itemRng: new D2Rng(501),
})
const ethArmor = createDroppedItem(capBase, 'normal', {
itemTypes: tables.itemTypes,
ilvl: 25,
ethereal: true,
dwInitSeed: 501,
itemRng: new D2Rng(501),
})
const normSell = calculateTransactionCost({
item: normalArmor,
transactionType: TransactionType.SELL,
difficulty: 2,
vendorId: VendorNpcId.CHARSI,
tables,
})
const ethSell = calculateTransactionCost({
item: ethArmor,
transactionType: TransactionType.SELL,
difficulty: 2,
vendorId: VendorNpcId.CHARSI,
tables,
})
expect(ethSell).toBeLessThan(normSell)
const damagedEth: Item = { ...ethArmor, durability: 5, maxDurability: 30 }
expect(isItemRepairable(damagedEth)).toBe(false)
expect(
calculateTransactionCost({
item: damagedEth,
transactionType: TransactionType.REPAIR,
vendorId: VendorNpcId.CHARSI,
tables,
}),
).toBe(0)
})
it('computes Gamble costs (fixed 50k Ring / 63k Amulet, cLvl-scaled equipment) and applies vendor discounts', () => {
const rinBase = tables.getBase('rin')!
const amuBase = tables.getBase('amu')!
const capBase = tables.getBase('cap')!
const rinItem = createDroppedItem(rinBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 20, dwInitSeed: 601 })
const amuItem = createDroppedItem(amuBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 20, dwInitSeed: 602 })
const capItem = createDroppedItem(capBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 20, dwInitSeed: 603 })
expect(calculateGambleCost({ item: rinItem, cLvl: 30, tables })).toBe(50000)
expect(calculateGambleCost({ item: amuItem, cLvl: 30, tables })).toBe(63000)
// 15% Gheed's Fortune + 15% Edge = 30% reduced vendor prices
expect(
calculateGambleCost({ item: rinItem, cLvl: 30, reducedPricesPercent: 30, tables }),
).toBe(35000)
const capGambleLvl10 = calculateGambleCost({ item: capItem, cLvl: 10, tables })
const capGambleLvl80 = calculateGambleCost({ item: capItem, cLvl: 80, tables })
expect(capGambleLvl80).toBeGreaterThan(capGambleLvl10)
})
})