diablo2-web/src/game/npc-table.ts

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);
}