308 lines
14 KiB
TypeScript
308 lines
14 KiB
TypeScript
/**
|
|
* 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)
|
|
}
|
|
})
|
|
})
|