import type { SkillCostEntry as CanonicalSkillCostEntry } from '../data/canonical-vendor-data'; export interface ParsedTxtTable { readonly headers: readonly string[]; readonly colIndex: ReadonlyMap; readonly rows: readonly string[][]; } export function parseTxtTable(rawTsv: string): ParsedTxtTable { const lines = rawTsv.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); const nonEmptyLines = lines.filter((l) => l.trim().length > 0); if (nonEmptyLines.length === 0) { return { headers: [], colIndex: new Map(), rows: [] }; } const headers = nonEmptyLines[0]!.split('\t').map((h) => h.trim()); const colIndex = new Map(); headers.forEach((h, i) => { if (h && !colIndex.has(h)) { colIndex.set(h, i); } }); const rows: string[][] = []; for (let i = 1; i < nonEmptyLines.length; i++) { const cols = nonEmptyLines[i]!.split('\t'); if (cols[0]?.trim().toLowerCase() === 'expansion') continue; rows.push(cols); } return { headers, colIndex, rows }; } /** * Canonical 1.13c Vendor IDs (0..16) from D2Game.dll (NPC_GetVendorId / sgptDataTables->pItemCache). * Note: Index 6 (Hratli) uses column prefix 'Hralti' in Weapons.txt / Armor.txt / Misc.txt! */ export const enum VendorNpcId { AKARA = 0, GHEED = 1, CHARSI = 2, FARA = 3, LYSANDER = 4, DROGNAN = 5, HRATLI = 6, ALKOR = 7, ORMUS = 8, ELZIX = 9, ASHEARA = 10, CAIN = 11, HALBU = 12, JAMELLA = 13, MALAH = 14, LARZUK = 15, DREHYA = 16, } export const VENDOR_COUNT = 17; /** * Exact 1.13c column prefixes in Weapons.txt, Armor.txt, and Misc.txt for VendorNpcId 0..16. * Note index 6 is 'Hralti' (official Blizzard typo in MPQ tables). */ export const VENDOR_COLUMN_PREFIXES: readonly string[] = [ 'Akara', 'Gheed', 'Charsi', 'Fara', 'Lysander', 'Drognan', 'Hralti', 'Alkor', 'Ormus', 'Elzix', 'Asheara', 'Cain', 'Halbu', 'Jamella', 'Malah', 'Larzuk', 'Drehya', ] as const; export interface VendorItemColumns { readonly min: number; readonly max: number; readonly magicMin: number; readonly magicMax: number; readonly magicLvl: number; } /** * Parses the 17 vendor column sets (`Min`, `Max`, `MagicMin`, * `MagicMax`, `MagicLvl`) for a single row in Weapons.txt / Armor.txt / Misc.txt. */ export function parseVendorColumnsFromRow( table: ParsedTxtTable, row: string[], ): Readonly> { const result: Record = {}; for (let vendorId = 0; vendorId < VENDOR_COUNT; vendorId++) { const prefix = VENDOR_COLUMN_PREFIXES[vendorId]; const minIdx = table.colIndex.get(`${prefix}Min`); const maxIdx = table.colIndex.get(`${prefix}Max`); const magicMinIdx = table.colIndex.get(`${prefix}MagicMin`); const magicMaxIdx = table.colIndex.get(`${prefix}MagicMax`); const magicLvlIdx = table.colIndex.get(`${prefix}MagicLvl`); const min = minIdx !== undefined ? parseInt(row[minIdx] ?? '', 10) || 0 : 0; const max = maxIdx !== undefined ? parseInt(row[maxIdx] ?? '', 10) || 0 : 0; const magicMin = magicMinIdx !== undefined ? parseInt(row[magicMinIdx] ?? '', 10) || 0 : 0; const magicMax = magicMaxIdx !== undefined ? parseInt(row[magicMaxIdx] ?? '', 10) || 0 : 0; const magicLvl = magicLvlIdx !== undefined ? parseInt(row[magicLvlIdx] ?? '', 10) || 0 : 0; if (min > 0 || max > 0 || magicMin > 0 || magicMax > 0) { result[vendorId] = { min, max, magicMin, magicMax: magicMax, magicLvl: magicLvl > 0 ? magicLvl : 255, }; } } return result; } export interface NpcRecord { /** MonStats hcIdx name (e.g. 'akara', 'charsi', 'Warriv2') */ readonly npc: string; /** Sell multiplier (1024-based, when player buys from NPC) */ readonly sellMult: number; /** Buy multiplier (1024-based, when NPC buys from player) */ readonly buyMult: number; /** Repair multiplier (1024-based) */ readonly repMult: number; /** Quest flags (up to 3 quest slots) */ readonly questFlag: readonly [number, number, number]; readonly questSellMult: readonly [number, number, number]; readonly questBuyMult: readonly [number, number, number]; readonly questRepMult: readonly [number, number, number]; /** Max gold NPC pays for a single item in Normal / Nightmare / Hell */ readonly maxBuy: readonly [number, number, number]; } export class NpcTable { private readonly byKey = new Map(); constructor(records: NpcRecord[]) { for (const rec of records) { this.byKey.set(rec.npc.toLowerCase(), rec); } } get(npcKey: string): NpcRecord | undefined { return this.byKey.get(npcKey.toLowerCase()); } getByVendorId(vendorId: VendorNpcId): NpcRecord | undefined { const key = VENDOR_ID_TO_NPC_TXT_KEY[vendorId]; return key ? this.byKey.get(key) : undefined; } get size(): number { return this.byKey.size; } } const VENDOR_ID_TO_NPC_TXT_KEY: Readonly> = { [VendorNpcId.AKARA]: 'akara', [VendorNpcId.GHEED]: 'gheed', [VendorNpcId.CHARSI]: 'charsi', [VendorNpcId.FARA]: 'fara', [VendorNpcId.LYSANDER]: 'lysander', [VendorNpcId.DROGNAN]: 'drognan', [VendorNpcId.HRATLI]: 'hratli', [VendorNpcId.ALKOR]: 'alkor', [VendorNpcId.ORMUS]: 'ormus', [VendorNpcId.ELZIX]: 'elzix', [VendorNpcId.ASHEARA]: 'asheara', [VendorNpcId.CAIN]: 'cain1', [VendorNpcId.HALBU]: 'halbu', [VendorNpcId.JAMELLA]: 'jamella', [VendorNpcId.MALAH]: 'malah', [VendorNpcId.LARZUK]: 'larzuk', [VendorNpcId.DREHYA]: 'drehya', }; function cellStr(table: ParsedTxtTable, row: string[], col: string): string { const idx = table.colIndex.get(col); if (idx === undefined) return ''; return (row[idx] ?? '').trim(); } function cellNum(table: ParsedTxtTable, row: string[], col: string, fallback = 0): number { const raw = cellStr(table, row, col); if (!raw) return fallback; const n = parseInt(raw, 10); return Number.isNaN(n) ? fallback : n; } export function parseNpcTable(rawTsv: string): NpcTable { const table = parseTxtTable(rawTsv); const records: NpcRecord[] = []; for (const row of table.rows) { const npc = cellStr(table, row, 'npc'); if (!npc) continue; records.push({ npc, sellMult: cellNum(table, row, 'sell mult', 1024), buyMult: cellNum(table, row, 'buy mult', 1024), repMult: cellNum(table, row, 'rep mult', 1024), questFlag: [ cellNum(table, row, 'questflag A', 0), cellNum(table, row, 'questflag B', 0), cellNum(table, row, 'questflag C', 0), ], questSellMult: [ cellNum(table, row, 'questsellmult A', 1024), cellNum(table, row, 'questsellmult B', 1024), cellNum(table, row, 'questsellmult C', 1024), ], questBuyMult: [ cellNum(table, row, 'questbuymult A', 1024), cellNum(table, row, 'questbuymult B', 1024), cellNum(table, row, 'questbuymult C', 1024), ], questRepMult: [ cellNum(table, row, 'questrepmult A', 1024), cellNum(table, row, 'questrepmult B', 1024), cellNum(table, row, 'questrepmult C', 1024), ], maxBuy: [ cellNum(table, row, 'max buy', 5000), cellNum(table, row, 'max buy (N)', 30000), cellNum(table, row, 'max buy (H)', 35000), ], }); } return new NpcTable(records); } /** * Parses Gamble.txt and returns the ordered array of gambleable base item codes. */ export function parseGambleTable(rawTsv: string): readonly string[] { const table = parseTxtTable(rawTsv); const codes: string[] = []; for (const row of table.rows) { const code = cellStr(table, row, 'code'); if (code) { codes.push(code); } } return codes; } export interface BookCostRecord { readonly name: string; readonly scrollSpellCode: string; readonly bookSpellCode: string; readonly baseCost: number; readonly costPerCharge: number; } export class BooksTable { private readonly byBookCode = new Map(); constructor(records: BookCostRecord[]) { for (const rec of records) { this.byBookCode.set(rec.bookSpellCode, rec); } } getByBookCode(code: string): BookCostRecord | undefined { return this.byBookCode.get(code); } } export function parseBooksTable(rawTsv: string): BooksTable { const table = parseTxtTable(rawTsv); const records: BookCostRecord[] = []; for (const row of table.rows) { const bookSpellCode = cellStr(table, row, 'BookSpellCode'); if (!bookSpellCode) continue; records.push({ name: cellStr(table, row, 'Name'), scrollSpellCode: cellStr(table, row, 'ScrollSpellCode'), bookSpellCode, baseCost: cellNum(table, row, 'BaseCost', 0), costPerCharge: cellNum(table, row, 'CostPerCharge', 0), }); } return new BooksTable(records); } export function parseLowQualityTable(rawTsv: string): readonly string[] { const table = parseTxtTable(rawTsv); const names: string[] = []; for (const row of table.rows) { const name = cellStr(table, row, 'Name'); if (name) names.push(name); } return names; } export type SkillCostMap = ReadonlyMap; export function buildSkillCostMap(entries: readonly CanonicalSkillCostEntry[]): SkillCostMap { const map = new Map(); for (const entry of entries) { map.set(entry.id, entry); } return map; } /** * Town NPC service descriptor mapping D2 1.13c MonStats hcIdx & town NPC names * to their canonical VendorNpcId, Act index (0..4), and available services. */ export interface TownNpcServiceDescriptor { readonly hcIdx: number; readonly npcTxtKey: string; readonly displayName: string; readonly actIndex: 0 | 1 | 2 | 3 | 4; readonly vendorId: VendorNpcId | null; readonly canTrade: boolean; readonly canRepair: boolean; readonly canGamble: boolean; readonly canHeal: boolean; readonly canIdentify: boolean; readonly greetingLines: readonly string[]; } export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [ // Act 1 { hcIdx: 148, npcTxtKey: 'akara', displayName: 'Akara', actIndex: 0, vendorId: VendorNpcId.AKARA, canTrade: true, canRepair: false, canGamble: false, canHeal: true, canIdentify: false, greetingLines: [ 'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.', 'Welcome back, my friend. We are still clearing the monastery, but you are welcome to rest here.', ], }, { hcIdx: 147, npcTxtKey: 'gheed', displayName: 'Gheed', actIndex: 0, vendorId: VendorNpcId.GHEED, canTrade: true, canRepair: false, canGamble: true, canHeal: false, canIdentify: false, greetingLines: [ 'Good day to you, partner! I am Gheed, and I can already tell that I will be your best friend in this forsaken camp.', 'Step right up! Spend some gold or take a chance on a mystery item!', ], }, { hcIdx: 154, npcTxtKey: 'charsi', displayName: 'Charsi', actIndex: 0, vendorId: VendorNpcId.CHARSI, canTrade: true, canRepair: true, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Hi there! I am Charsi, the blacksmith here in camp.', 'It is good to see some strong adventurers around here. Need your gear forged or repaired?', ], }, { hcIdx: 146, npcTxtKey: 'cain1', displayName: 'Deckard Cain', actIndex: 0, vendorId: VendorNpcId.CAIN, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: true, greetingLines: [ 'Stay awhile and listen!', 'Bring me any unidentified items you have found, and I shall reveal their hidden properties.', ], }, // Act 2 { hcIdx: 178, npcTxtKey: 'fara', displayName: 'Fara', actIndex: 1, vendorId: VendorNpcId.FARA, canTrade: true, canRepair: true, canGamble: false, canHeal: true, canIdentify: false, greetingLines: [ 'Greetings, wanderer. I am Fara. I was once a paladin of the Zakarum in the eastern lands.', 'Now I ply my trade as a smith and healer here in Lut Gholein.', ], }, { hcIdx: 202, npcTxtKey: 'lysander', displayName: 'Lysander', actIndex: 1, vendorId: VendorNpcId.LYSANDER, canTrade: true, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'What do you want? Speak up! Mixing potions can be hazardous to one\'s hearing.', 'Buy a potion or move along before something explodes!', ], }, { hcIdx: 177, npcTxtKey: 'drognan', displayName: 'Drognan', actIndex: 1, vendorId: VendorNpcId.DROGNAN, canTrade: true, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Indeed, dark times have come to Lut Gholein.', 'If you seek arcane knowledge, wands, or staves, examine my wares.', ], }, { hcIdx: 199, npcTxtKey: 'elzix', displayName: 'Elzix', actIndex: 1, vendorId: VendorNpcId.ELZIX, canTrade: true, canRepair: false, canGamble: true, canHeal: false, canIdentify: false, greetingLines: [ 'Glad to meet you, traveler. I am Elzix, innkeeper and merchant.', 'In my youth I was a bandit in the desert. Care to trade or test your luck?', ], }, { hcIdx: 244, npcTxtKey: 'cain2', displayName: 'Deckard Cain', actIndex: 1, vendorId: VendorNpcId.CAIN, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: true, greetingLines: [ 'Stay awhile and listen!', 'The Horadric mysteries run deep in Lut Gholein. Let me identify your artifacts.', ], }, // Act 3 { hcIdx: 253, npcTxtKey: 'hratli', displayName: 'Hratli', actIndex: 2, vendorId: VendorNpcId.HRATLI, canTrade: true, canRepair: true, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Welcome, stranger. I am Hratli, a sorcerer and smith.', 'My enchantments keep these docks safe, and my anvil keeps your blades sharp.', ], }, { hcIdx: 254, npcTxtKey: 'alkor', displayName: 'Alkor', actIndex: 2, vendorId: VendorNpcId.ALKOR, canTrade: true, canRepair: false, canGamble: true, canHeal: false, canIdentify: false, greetingLines: [ 'Oh, great. Another interrupting adventurer.', 'If you want potions or wish to gamble away your coin, make it quick.', ], }, { hcIdx: 255, npcTxtKey: 'ormus', displayName: 'Ormus', actIndex: 2, vendorId: VendorNpcId.ORMUS, canTrade: true, canRepair: false, canGamble: false, canHeal: true, canIdentify: false, greetingLines: [ 'You now speak to Ormus. He was once a great mage, but now lives like a rat in a sinking vessel.', 'Ormus can heal your wounds and trade mystic garments.', ], }, { hcIdx: 252, npcTxtKey: 'asheara', displayName: 'Asheara', actIndex: 2, vendorId: VendorNpcId.ASHEARA, canTrade: true, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Greetings. I am Asheara, commander of the Iron Wolves.', 'Browse my weapons and armor if you are heading into the jungle.', ], }, { hcIdx: 245, npcTxtKey: 'cain3', displayName: 'Deckard Cain', actIndex: 2, vendorId: VendorNpcId.CAIN, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: true, greetingLines: [ 'Stay awhile and listen!', 'Mephisto\'s corruption has twisted Kurast. Show me any relics you have uncovered.', ], }, // Act 4 { hcIdx: 257, npcTxtKey: 'halbu', displayName: 'Halbu', actIndex: 3, vendorId: VendorNpcId.HALBU, canTrade: true, canRepair: true, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Keep your weapons ready and your armor mended, mortal.', ], }, { hcIdx: 405, npcTxtKey: 'jamella', displayName: 'Jamella', actIndex: 3, vendorId: VendorNpcId.JAMELLA, canTrade: true, canRepair: false, canGamble: true, canHeal: true, canIdentify: false, greetingLines: [ 'The Light blesses you, hero. Let me restore your strength and offer my sacred wares.', ], }, { hcIdx: 246, npcTxtKey: 'cain4', displayName: 'Deckard Cain', actIndex: 3, vendorId: VendorNpcId.CAIN, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: true, greetingLines: [ 'Stay awhile and listen!', 'Even at the gates of Hell, Horadric wisdom shall guide us.', ], }, // Act 5 { hcIdx: 513, npcTxtKey: 'malah', displayName: 'Malah', actIndex: 4, vendorId: VendorNpcId.MALAH, canTrade: true, canRepair: false, canGamble: false, canHeal: true, canIdentify: false, greetingLines: [ 'Welcome to Harrogath, traveler. I am Malah, healer of this besieged fortress.', 'Let me tend to your wounds or supply you with potions and scrolls.', ], }, { hcIdx: 511, npcTxtKey: 'larzuk', displayName: 'Larzuk', actIndex: 4, vendorId: VendorNpcId.LARZUK, canTrade: true, canRepair: true, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'What are you looking at? Never seen a Barbarian smith before?', 'Take a look at my steel—finest in all the northern highlands!', ], }, { hcIdx: 512, npcTxtKey: 'drehya', displayName: 'Anya', actIndex: 4, vendorId: VendorNpcId.DREHYA, canTrade: true, canRepair: false, canGamble: true, canHeal: false, canIdentify: false, greetingLines: [ 'Thank you for rescuing me from the frozen prison, hero.', 'Please, browse my wares or test your fortune with a gamble.', ], }, { hcIdx: 514, npcTxtKey: 'nihlathak', displayName: 'Nihlathak', actIndex: 4, vendorId: VendorNpcId.DREHYA, canTrade: false, canRepair: false, canGamble: true, canHeal: false, canIdentify: false, greetingLines: [ 'Why do you bother me? Harrogath does not need outsiders.', 'If you insist on wasting your gold, I have some curiosities you can gamble on.', ], }, { hcIdx: 520, npcTxtKey: 'cain5', displayName: 'Deckard Cain', actIndex: 4, vendorId: VendorNpcId.CAIN, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: true, greetingLines: [ 'Stay awhile and listen!', 'Mount Arreat holds the Worldstone itself. Let me identify your discoveries.', ], }, // Non-vendor Town NPCs (Talk / Heal) { hcIdx: 155, npcTxtKey: 'warriv1', displayName: 'Warriv', actIndex: 0, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Greetings, stranger. Once the pass to the East is safe, my caravan will take you to Lut Gholein.', ], }, { hcIdx: 150, npcTxtKey: 'kashya', displayName: 'Kashya', actIndex: 0, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'My Rogue scouts have suffered heavy losses. Prove your valor in the field, warrior.', ], }, { hcIdx: 176, npcTxtKey: 'atma', displayName: 'Atma', actIndex: 1, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: true, canIdentify: false, greetingLines: [ 'Welcome to my tavern, traveler. Let me tend to your wounds while you rest from the desert sun.', ], }, { hcIdx: 198, npcTxtKey: 'greiz', displayName: 'Greiz', actIndex: 1, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'My desert mercenaries guard the walls of Lut Gholein day and night.', ], }, { hcIdx: 201, npcTxtKey: 'jerhyn', displayName: 'Jerhyn', actIndex: 1, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Welcome to Lut Gholein. Dark forces stir beneath the desert sands.', ], }, { hcIdx: 210, npcTxtKey: 'meshif1', displayName: 'Meshif', actIndex: 1, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Ahoy, traveler! My ship is ready to sail across the Twin Seas whenever your quest demands.', ], }, { hcIdx: 200, npcTxtKey: 'geglash', displayName: 'Geglash', actIndex: 1, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'What are you looking at? Pour another drink or move along!', ], }, { hcIdx: 256, npcTxtKey: 'natalya', displayName: 'Natalya', actIndex: 2, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Keep your eyes open in the jungle. The Prime Evils are gathering in Travincal.', ], }, { hcIdx: 367, npcTxtKey: 'tyrael1', displayName: 'Tyrael', actIndex: 3, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Hail to you, champion. The hour of judgment upon Diablo is at hand.', ], }, { hcIdx: 515, npcTxtKey: 'qual-kehk', displayName: 'Qual-Kehk', actIndex: 4, vendorId: null, canTrade: false, canRepair: false, canGamble: false, canHeal: false, canIdentify: false, greetingLines: [ 'Once you free our warriors on the Frigid Highlands, the finest Barbarians will fight by your side.', ], }, ]; const DESCRIPTOR_BY_HCIDX = new Map( TOWN_NPC_DESCRIPTORS.map(d => [d.hcIdx, d]), ); const DESCRIPTOR_BY_NAME = new Map(); for (const d of TOWN_NPC_DESCRIPTORS) { if (!DESCRIPTOR_BY_NAME.has(d.displayName.toLowerCase())) { DESCRIPTOR_BY_NAME.set(d.displayName.toLowerCase(), d); } DESCRIPTOR_BY_NAME.set(d.npcTxtKey.toLowerCase(), d); } export function getTownNpcDescriptorByHcIdx(hcIdx: number): TownNpcServiceDescriptor | undefined { return DESCRIPTOR_BY_HCIDX.get(hcIdx); } export function getTownNpcDescriptorByName( name: string, actIndex?: number, ): TownNpcServiceDescriptor | undefined { const lower = name.trim().toLowerCase(); if (lower.includes('cain')) { const cainMatch = TOWN_NPC_DESCRIPTORS.find( d => d.vendorId === VendorNpcId.CAIN && (actIndex === undefined || d.actIndex === actIndex), ); if (cainMatch) return cainMatch; } return DESCRIPTOR_BY_NAME.get(lower); } export function getTownNpcDescriptorByVendorId( vendorId: VendorNpcId, actIndex?: number, ): TownNpcServiceDescriptor | undefined { return TOWN_NPC_DESCRIPTORS.find( d => d.vendorId === vendorId && (actIndex === undefined || d.actIndex === actIndex), ) ?? TOWN_NPC_DESCRIPTORS.find(d => d.vendorId === vendorId); }