diablo2-web/tests/vendor-generator.test.ts

247 lines
9.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import {
VendorNpcId,
VENDOR_COLUMN_PREFIXES,
TOWN_NPC_DESCRIPTORS,
getTownNpcDescriptorByName,
} from '../src/game/npc-table.ts'
import {
computeVendorItemLevel,
resolveVendorBaseUpgrade,
generateVendorStoreInventory,
generateGambleInventory,
resolveGambledItem,
VENDOR_NORMAL_ILVL_CAPS,
} from '../src/game/vendor-generator.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { ItemQuality } from '../src/game/items.ts'
describe('1.13c Vendor Economic & Inventory Tables (M19-P1 & M19-P2)', () => {
const dropTables = getEmbeddedDropTables()
it('parses all 17 canonical vendors in Npc.txt, Gamble.txt, Books.txt, and LowQualityItems.txt', () => {
expect(VENDOR_COLUMN_PREFIXES).toHaveLength(17)
// Blizzard's historical typo in Weapons.txt/Armor.txt/Misc.txt is 'Hralti' at index 6
expect(VENDOR_COLUMN_PREFIXES[6]).toBe('Hralti')
expect(dropTables.npcTable!.size).toBeGreaterThanOrEqual(17)
const akara = dropTables.npcTable!.getByVendorId(VendorNpcId.AKARA)
expect(akara).toBeDefined()
expect(akara?.npc.toLowerCase()).toBe('akara')
expect(akara?.maxBuy).toEqual([5000, 30000, 35000])
const charsi = dropTables.npcTable!.getByVendorId(VendorNpcId.CHARSI)
expect(charsi).toBeDefined()
expect(charsi?.npc.toLowerCase()).toBe('charsi')
expect(charsi?.repMult).toBeGreaterThan(0)
expect(dropTables.gambleCodes!.length).toBeGreaterThan(30)
expect(dropTables.gambleCodes).toContain('rin')
expect(dropTables.gambleCodes).toContain('amu')
expect(dropTables.gambleCodes).toContain('ci0')
const tbk = dropTables.booksTable!.getByBookCode('tbk')
expect(tbk).toBeDefined()
expect(tbk?.scrollSpellCode).toBe('tsc')
expect(tbk?.baseCost).toBe(250)
expect(tbk?.costPerCharge).toBe(50)
const ibk = dropTables.booksTable!.getByBookCode('ibk')
expect(ibk).toBeDefined()
expect(ibk?.scrollSpellCode).toBe('isc')
expect(dropTables.lowQualityItems).toHaveLength(4)
expect(dropTables.lowQualityItems![0]).toBe('Crude')
})
it('resolves all Town NPC descriptors across Acts 1-5 including healers, traders, gamblers, and Cain', () => {
expect(TOWN_NPC_DESCRIPTORS.length).toBeGreaterThanOrEqual(25)
const akara = getTownNpcDescriptorByName('Akara', 0)
expect(akara?.vendorId).toBe(VendorNpcId.AKARA)
expect(akara?.canTrade).toBe(true)
expect(akara?.canHeal).toBe(true)
const gheed = getTownNpcDescriptorByName('Gheed', 0)
expect(gheed?.vendorId).toBe(VendorNpcId.GHEED)
expect(gheed?.canTrade).toBe(true)
expect(gheed?.canGamble).toBe(true)
const charsi = getTownNpcDescriptorByName('Charsi', 0)
expect(charsi?.vendorId).toBe(VendorNpcId.CHARSI)
expect(charsi?.canTrade).toBe(true)
expect(charsi?.canRepair).toBe(true)
const cainA1 = getTownNpcDescriptorByName('Deckard Cain', 0)
expect(cainA1?.canIdentify).toBe(true)
const atma = getTownNpcDescriptorByName('Atma', 1)
expect(atma?.canHeal).toBe(true)
const hratli = getTownNpcDescriptorByName('Hratli', 2)
expect(hratli?.vendorId).toBe(VendorNpcId.HRATLI)
expect(hratli?.canTrade).toBe(true)
expect(hratli?.canRepair).toBe(true)
const larzuk = getTownNpcDescriptorByName('Larzuk', 4)
expect(larzuk?.vendorId).toBe(VendorNpcId.LARZUK)
expect(larzuk?.canTrade).toBe(true)
expect(larzuk?.canRepair).toBe(true)
})
it('computes 1.13c vendor ilvl with Normal Act caps [12, 20, 28, 36, 45] and uncapped NM/Hell cLvl + 5', () => {
expect(VENDOR_NORMAL_ILVL_CAPS).toEqual([12, 20, 28, 36, 45])
// Normal difficulty: capped by Act
expect(computeVendorItemLevel(VendorNpcId.AKARA, 0, 1, 0)).toBe(6)
expect(computeVendorItemLevel(VendorNpcId.AKARA, 0, 10, 0)).toBe(12) // Act 1 cap 12
expect(computeVendorItemLevel(VendorNpcId.FARA, 0, 30, 1)).toBe(20) // Act 2 cap 20
expect(computeVendorItemLevel(VendorNpcId.HRATLI, 0, 30, 2)).toBe(28) // Act 3 cap 28
expect(computeVendorItemLevel(VendorNpcId.HALBU, 0, 40, 3)).toBe(36) // Act 4 cap 36
expect(computeVendorItemLevel(VendorNpcId.LARZUK, 0, 50, 4)).toBe(45) // Act 5 cap 45
// Nightmare & Hell: uncapped cLvl + 5 clamped to [1..99]
expect(computeVendorItemLevel(VendorNpcId.AKARA, 1, 40, 0)).toBe(45)
expect(computeVendorItemLevel(VendorNpcId.AKARA, 2, 85, 0)).toBe(90)
expect(computeVendorItemLevel(VendorNpcId.LARZUK, 2, 97, 4)).toBe(99)
})
it('upgrades Normal base items to Exceptional/Elite in Nightmare and Hell according to 1.13c formulas', () => {
const cap = dropTables.getBase('cap')!
// Normal difficulty never upgrades base item tier
const rngNorm = new D2Rng(12345)
for (let i = 0; i < 20; i++) {
const res = resolveVendorBaseUpgrade(cap, 80, 12, 0, true, dropTables, rngNorm)
expect(res.id).toBe('cap')
}
// Hell difficulty at high cLvl upgrades some items to ubercode ('xap') or ultracode ('uap')
const rngHell = new D2Rng(54321)
const seenIds = new Set<string>()
for (let i = 0; i < 60; i++) {
const res = resolveVendorBaseUpgrade(cap, 85, 90, 2, true, dropTables, rngHell)
seenIds.add(res.id)
}
expect(seenIds.has('xap') || seenIds.has('uap')).toBe(true)
})
it('generates Akara, Charsi, Hratli, and Larzuk store inventories across difficulties and cLvls', () => {
// Akara (Vendor 0) in Normal at cLvl 1
const akaraStore = generateVendorStoreInventory({
vendorId: VendorNpcId.AKARA,
difficulty: 0,
cLvl: 1,
tables: dropTables,
rng: new D2Rng(1001),
})
expect(akaraStore.vendorId).toBe(VendorNpcId.AKARA)
expect(akaraStore.nItemLevel).toBe(6)
const akaraMisc = akaraStore.tabs.misc.list()
expect(akaraMisc.length).toBeGreaterThan(0)
// Akara permanently sells Tomes/Scrolls/Potions/Keys
const akaraCodes = new Set(akaraMisc.map(p => p.item.base.id))
expect(akaraCodes.has('isc')).toBe(true)
expect(akaraCodes.has('tsc')).toBe(true)
expect(akaraCodes.has('ibk')).toBe(true)
expect(akaraCodes.has('tbk')).toBe(true)
expect(akaraCodes.has('hp1')).toBe(true)
expect(akaraCodes.has('mp1')).toBe(true)
for (const placed of akaraMisc) {
if (['isc', 'tsc', 'ibk', 'tbk', 'hp1', 'mp1', 'key'].includes(placed.item.base.id)) {
expect(placed.item.permStore).toBe(true)
expect(placed.item.quality).toBe(ItemQuality.NORMAL)
}
}
// Charsi (Vendor 2) in Normal at cLvl 12
const charsiStore = generateVendorStoreInventory({
vendorId: VendorNpcId.CHARSI,
difficulty: 0,
cLvl: 12,
tables: dropTables,
rng: new D2Rng(2002),
})
expect(charsiStore.nItemLevel).toBe(12)
const charsiArmor = charsiStore.tabs.armor.list()
const charsiWeap1 = charsiStore.tabs.weapons1.list()
expect(charsiArmor.length).toBeGreaterThan(0)
expect(charsiWeap1.length).toBeGreaterThan(0)
// Verify magic items generated by Charsi are already identified and have affixes
const magicArmors = charsiArmor.filter(p => p.item.quality === ItemQuality.MAGIC)
expect(magicArmors.length).toBeGreaterThan(0)
for (const m of magicArmors) {
expect(m.item.flags?.identified).toBe(true)
expect(Boolean(m.item.prefix || m.item.suffix)).toBe(true)
}
// Hratli (Vendor 6, 'Hralti' column) in Nightmare at cLvl 45
const hratliStore = generateVendorStoreInventory({
vendorId: VendorNpcId.HRATLI,
difficulty: 1,
cLvl: 45,
tables: dropTables,
rng: new D2Rng(3003),
})
expect(hratliStore.nItemLevel).toBe(50)
expect(hratliStore.tabs.armor.list().length).toBeGreaterThan(0)
expect(hratliStore.tabs.weapons1.list().length).toBeGreaterThan(0)
// Larzuk (Vendor 15) in Hell at cLvl 80
const larzukStore = generateVendorStoreInventory({
vendorId: VendorNpcId.LARZUK,
difficulty: 2,
cLvl: 80,
tables: dropTables,
rng: new D2Rng(4004),
})
expect(larzukStore.nItemLevel).toBe(85)
expect(larzukStore.tabs.armor.list().length).toBeGreaterThan(0)
expect(larzukStore.tabs.weapons1.list().length).toBeGreaterThan(0)
})
it('generates Gamble inventory and resolves mystery items on purchase', () => {
const rng = new D2Rng(7777)
const gambleGrid = generateGambleInventory({
vendorId: VendorNpcId.GHEED,
cLvl: 50,
tables: dropTables,
rng,
})
const allGambleItems = gambleGrid.list()
expect(allGambleItems.length).toBeGreaterThan(5)
// Always includes Ring and Amulet in Gamble
const gambleCodes = new Set(allGambleItems.map(p => p.item.base.id))
expect(gambleCodes.has('rin')).toBe(true)
expect(gambleCodes.has('amu')).toBe(true)
for (const placed of allGambleItems) {
expect(placed.item.isGambleMystery).toBe(true)
expect(placed.item.identified).toBe(false)
expect(placed.item.ilvl).toBeGreaterThanOrEqual(45)
expect(placed.item.ilvl).toBeLessThanOrEqual(54)
}
// Resolve gambled items across multiple rolls and verify quality & identification
const rarities = new Set<string>()
for (let i = 0; i < 40; i++) {
const target = allGambleItems[i % allGambleItems.length]!.item
const resolved = resolveGambledItem({
mysteryItem: target,
cLvl: 50,
tables: dropTables,
rng,
})
expect(resolved.identified).toBe(true)
expect(resolved.isGambleMystery).toBe(false)
rarities.add(resolved.rarity ?? 'normal')
}
expect(rarities.has('magic')).toBe(true)
})
})