879 lines
23 KiB
TypeScript
879 lines
23 KiB
TypeScript
import type { SkillCostEntry as CanonicalSkillCostEntry } from '../data/canonical-vendor-data';
|
|
|
|
export interface ParsedTxtTable {
|
|
readonly headers: readonly string[];
|
|
readonly colIndex: ReadonlyMap<string, number>;
|
|
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<string, number>();
|
|
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 (`<Prefix>Min`, `<Prefix>Max`, `<Prefix>MagicMin`,
|
|
* `<Prefix>MagicMax`, `<Prefix>MagicLvl`) for a single row in Weapons.txt / Armor.txt / Misc.txt.
|
|
*/
|
|
export function parseVendorColumnsFromRow(
|
|
table: ParsedTxtTable,
|
|
row: string[],
|
|
): Readonly<Record<number, VendorItemColumns>> {
|
|
const result: Record<number, VendorItemColumns> = {};
|
|
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<string, NpcRecord>();
|
|
|
|
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<Record<VendorNpcId, string>> = {
|
|
[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<string, BookCostRecord>();
|
|
|
|
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<number, CanonicalSkillCostEntry>;
|
|
|
|
export function buildSkillCostMap(entries: readonly CanonicalSkillCostEntry[]): SkillCostMap {
|
|
const map = new Map<number, CanonicalSkillCostEntry>();
|
|
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<number, TownNpcServiceDescriptor>(
|
|
TOWN_NPC_DESCRIPTORS.map(d => [d.hcIdx, d]),
|
|
);
|
|
|
|
const DESCRIPTOR_BY_NAME = new Map<string, TownNpcServiceDescriptor>();
|
|
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);
|
|
}
|