Files
rs-sdk/wiki/generate-npcs.ts
Max BittkerandClaude Opus 5 e5fb1e9d07 Make the agent API self-describing and add regression CI (#43)
* Improve agent API docs and validation

* Drop the wiki search tool

Removes wiki/search.ts and its test. The index only covered names, aliases,
summaries and Sources lines, so it had worse recall than grep on body text
(e.g. "cow hide" missed skills/crafting.md), and normalize() stripped
underscores so map-square queries matched every neighbouring square
(m50_50 returned m50_53 NPCs). The computed mapSquareBounds were only
indexed as text, so coordinate lookups — the one thing grep can't do —
never worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Regenerate the wiki from server/content

Adds the coordinate-samples column to NPC location tables and picks up
content drift the checked-in pages had missed (807 NPC, 117 shop, 1573 item
pages; 335 of them new). Examples: Aubury now stocks 1000 chaos/death runes
rather than 500, and shop rows point at "Vial of water"/"Bolts" instead of
the stale "Vial"/"Bolt" names.

Also stops all three generators from crashing on their final step. Each one
read wiki/README.md unconditionally, but that file has never existed, so the
index update threw ENOENT on every run. Skip it when absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 17:52:51 -04:00

1303 lines
51 KiB
TypeScript

#!/usr/bin/env bun
/**
* NPC Wiki Generator
* Parses server content files and generates markdown wiki pages for all NPCs.
* Run: bun wiki/generate-npcs.ts
*/
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
import { join, basename, dirname, relative } from 'path';
const CONTENT_DIR = join(import.meta.dir, '..', 'server', 'content');
const WIKI_DIR = join(import.meta.dir);
const NPC_DIR = join(WIKI_DIR, 'npcs');
// ─── Types ───────────────────────────────────────────────────────────────────
interface NpcConfig {
configName: string;
name: string;
desc: string;
vislevel: string; // number or "hide"
hitpoints: number;
attack: number;
strength: number;
defence: number;
category: string;
huntmode: string;
wanderrange: number;
maxrange: number;
respawnrate: number;
defaultmode: string;
givechase: string;
moverestrict: string;
ops: string[]; // op1-op5
params: Map<string, string>;
sourcePath: string; // file this config came from
hasPatrol: boolean;
}
interface SpawnLocation {
mapX: number;
mapZ: number;
globalX: number;
globalZ: number;
level: number;
mapSquare: string; // e.g. "m50_50"
}
interface DropEntry {
itemConfigName: string;
quantity: string; // "1" or "2-12" for ranges
numerator: number;
denominator: number;
membersOnly: boolean;
}
interface AlwaysDrop {
itemConfigName: string;
quantity: number;
}
interface DropTable {
triggerNames: string[]; // config names or _category names that use this table
alwaysDrop: boolean; // whether it uses npc_param(death_drop)
entries: DropEntry[];
alwaysDropItems: AlwaysDrop[]; // additional always-drop items (like raw_chicken for chicken)
clueTier: string | null; // "easy", "medium", "hard"
}
interface NpcPage {
displayName: string;
variants: NpcConfig[];
spawns: SpawnLocation[];
drops: DropEntry[];
alwaysDropItem: string | null; // from death_drop param
alwaysDropExtra: AlwaysDrop[]; // additional always-drops (cow hide, raw chicken, etc)
clueTier: string | null;
}
// ─── Area Name Mappings ──────────────────────────────────────────────────────
const SOURCE_PATH_AREAS: Record<string, string> = {
'area_lumbridge': 'Lumbridge',
'area_alkharid': 'Al Kharid',
'area_ardougne_east': 'East Ardougne',
'area_ardougne_west': 'West Ardougne',
'area_barbarian_village': 'Barbarian Village',
'area_barbarian_outpost': 'Barbarian Outpost',
'area_brimhaven': 'Brimhaven',
'area_camelot': 'Camelot',
'area_canifis': 'Canifis',
'area_catherby': 'Catherby',
'area_combat_training': 'Combat Training Camp',
'area_desert': 'Desert',
'area_draynor': 'Draynor Village',
'area_edgeville': 'Edgeville',
'area_entrana': 'Entrana',
'area_falador': 'Falador',
'area_fishing_platform': 'Fishing Platform',
'area_gnome': 'Tree Gnome Stronghold',
'area_karamja': 'Karamja',
'area_lostcity': 'Zanaris',
'area_mage_arena': 'Mage Arena',
'area_port_sarim': 'Port Sarim',
'area_rimmington': 'Rimmington',
'area_seers': "Seers' Village",
'area_shilo': 'Shilo Village',
'area_taverly': 'Taverley',
'area_varrock': 'Varrock',
'area_white_wolf_mountain': 'White Wolf Mountain',
'area_wilderness': 'Wilderness',
'area_wizard_tower': 'Wizard Tower',
'area_yanille': 'Yanille',
'areas_heroes_guild': "Heroes' Guild",
'monastery': 'Monastery',
};
const MAP_SQUARE_AREAS: Record<string, string> = {
'm50_50': 'Lumbridge',
'm49_50': 'Lumbridge (west)',
'm51_50': 'Lumbridge (east)',
'm50_49': 'Al Kharid',
'm51_49': 'Al Kharid',
'm49_49': 'Al Kharid (south)',
'm48_50': 'Edgeville area',
'm48_51': 'Edgeville area',
'm49_51': 'Barbarian Village',
'm49_52': 'Barbarian Village',
'm50_51': 'Varrock (south)',
'm50_52': 'Varrock',
'm51_51': 'Varrock (east)',
'm51_52': 'Varrock (east)',
'm50_53': 'Varrock (north)',
'm45_50': 'Falador (south)',
'm46_50': 'Falador',
'm46_51': 'Falador (north)',
'm47_50': 'Falador (east)',
'm45_51': 'Falador (west)',
'm44_50': 'Rimmington',
'm44_49': 'Karamja',
'm45_49': 'Karamja',
'm43_49': 'Karamja (west)',
'm44_48': 'Brimhaven',
'm47_51': 'Taverley',
'm47_52': 'Taverley (north)',
'm41_50': "Seers' Village area",
'm42_50': 'Catherby',
'm40_50': 'Ardougne',
'm40_49': 'Ardougne (south)',
'm39_50': 'Ardougne (west)',
'm40_48': 'Ardougne area',
'm39_49': 'West Ardougne',
'm41_49': 'Yanille area',
'm41_48': 'Yanille',
'm48_52': 'Wilderness (low)',
'm48_53': 'Wilderness',
'm48_54': 'Wilderness',
'm49_53': 'Wilderness',
'm49_54': 'Wilderness',
'm50_54': 'Wilderness',
'm50_55': 'Wilderness (deep)',
'm51_53': 'Wilderness',
'm51_54': 'Wilderness',
'm46_52': 'Camelot area',
'm43_50': 'Gnome Stronghold',
'm43_51': 'Gnome Stronghold',
'm42_49': 'Fight Arena area',
'm52_50': 'Canifis area',
'm53_50': 'Canifis',
'm53_51': 'Morytania',
'm44_51': 'Entrana',
'm45_48': 'Shilo Village area',
'm46_48': 'Shilo Village area',
'm43_48': 'Feldip Hills',
'm48_49': 'Desert',
'm49_48': 'Desert',
};
// ─── Parser: INI Config ──────────────────────────────────────────────────────
function parseIniConfigs(content: string, sourcePath: string): Map<string, NpcConfig> {
const configs = new Map<string, NpcConfig>();
let current: NpcConfig | null = null;
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
// skip comments
if (line.startsWith('//') || line.startsWith('/ ')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
if (current) configs.set(current.configName, current);
current = makeEmptyConfig(sectionMatch[1], sourcePath);
continue;
}
if (!current || !line || line === '') {
continue;
}
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const key = line.slice(0, eqIdx).trim();
const value = line.slice(eqIdx + 1).trim();
switch (key) {
case 'name': current.name = value; break;
case 'desc': current.desc = value; break;
case 'vislevel': current.vislevel = value; break;
case 'hitpoints': current.hitpoints = parseInt(value) || 0; break;
case 'attack': current.attack = parseInt(value) || 0; break;
case 'strength': current.strength = parseInt(value) || 0; break;
case 'defence': current.defence = parseInt(value) || 0; break;
case 'category': current.category = value; break;
case 'huntmode': current.huntmode = value; break;
case 'wanderrange': current.wanderrange = parseInt(value) || 0; break;
case 'maxrange': current.maxrange = parseInt(value) || 0; break;
case 'respawnrate': current.respawnrate = parseInt(value) || 0; break;
case 'defaultmode': current.defaultmode = value; break;
case 'givechase': current.givechase = value; break;
case 'moverestrict': current.moverestrict = value; break;
case 'op1': current.ops[0] = value; break;
case 'op2': current.ops[1] = value; break;
case 'op3': current.ops[2] = value; break;
case 'op4': current.ops[3] = value; break;
case 'op5': current.ops[4] = value; break;
case 'param': {
const commaIdx = value.indexOf(',');
if (commaIdx !== -1) {
current.params.set(value.slice(0, commaIdx), value.slice(commaIdx + 1));
}
break;
}
default: {
if (key.startsWith('patrol')) {
current.hasPatrol = true;
}
break;
}
}
}
if (current) configs.set(current.configName, current);
return configs;
}
function makeEmptyConfig(configName: string, sourcePath: string): NpcConfig {
return {
configName,
name: '',
desc: '',
vislevel: '',
hitpoints: 0,
attack: 0,
strength: 0,
defence: 0,
category: '',
huntmode: '',
wanderrange: 0,
maxrange: 0,
respawnrate: 0,
defaultmode: '',
givechase: '',
moverestrict: '',
ops: ['', '', '', '', ''],
params: new Map(),
sourcePath,
hasPatrol: false,
};
}
// ─── Parser: Obj Configs (for display names) ────────────────────────────────
function parseObjConfigs(content: string): Map<string, string> {
const names = new Map<string, string>();
let currentName = '';
let currentDisplayName = '';
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('//') || line.startsWith('/ ')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
if (currentName && currentDisplayName) {
names.set(currentName, currentDisplayName);
}
currentName = sectionMatch[1];
currentDisplayName = '';
continue;
}
if (line.startsWith('name=')) {
currentDisplayName = line.slice(5);
}
}
if (currentName && currentDisplayName) {
names.set(currentName, currentDisplayName);
}
return names;
}
// ─── Parser: Pack File ───────────────────────────────────────────────────────
function parsePackFile(content: string): { idToName: Map<number, string>; nameToId: Map<string, number> } {
const idToName = new Map<number, string>();
const nameToId = new Map<string, number>();
for (const line of content.split('\n')) {
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const id = parseInt(line.slice(0, eqIdx));
const name = line.slice(eqIdx + 1).trim();
if (!isNaN(id) && name) {
idToName.set(id, name);
nameToId.set(name, id);
}
}
return { idToName, nameToId };
}
// ─── Parser: Map Spawns ──────────────────────────────────────────────────────
function parseMapSpawns(
mapsDir: string,
npcIdToName: Map<number, string>
): Map<string, SpawnLocation[]> {
const spawns = new Map<string, SpawnLocation[]>();
const mapFiles = readdirSync(mapsDir).filter(f => f.endsWith('.jm2'));
for (const file of mapFiles) {
const match = file.match(/^m(\d+)_(\d+)\.jm2$/);
if (!match) continue;
const mapX = parseInt(match[1]);
const mapZ = parseInt(match[2]);
const mapSquare = `m${mapX}_${mapZ}`;
const content = readFileSync(join(mapsDir, file), 'utf-8');
const npcSectionStart = content.indexOf('==== NPC ====');
if (npcSectionStart === -1) continue;
const afterNpc = content.slice(npcSectionStart + '==== NPC ===='.length);
// NPC section goes until the next ==== or end of file
const nextSection = afterNpc.indexOf('====');
const npcBlock = nextSection === -1 ? afterNpc : afterNpc.slice(0, nextSection);
for (const line of npcBlock.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
// Format: level localX localZ: npcID
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+):\s*(\d+)$/);
if (!m) continue;
const level = parseInt(m[1]);
const localX = parseInt(m[2]);
const localZ = parseInt(m[3]);
const npcId = parseInt(m[4]);
const configName = npcIdToName.get(npcId);
if (!configName) continue;
const globalX = mapX * 64 + localX;
const globalZ = mapZ * 64 + localZ;
if (!spawns.has(configName)) spawns.set(configName, []);
spawns.get(configName)!.push({ mapX, mapZ, globalX, globalZ, level, mapSquare });
}
}
return spawns;
}
// ─── Parser: Drop Tables ─────────────────────────────────────────────────────
interface ParsedDropFile {
triggers: Map<string, string>; // configName/category -> label name (or "inline")
labels: Map<string, DropTableData>; // label name -> parsed data
}
interface DropTableData {
hasDeathDrop: boolean;
entries: DropEntry[];
alwaysItems: AlwaysDrop[];
clueTier: string | null;
}
function parseDropTables(dropsDir: string): Map<string, DropTable> {
const files = readdirSync(dropsDir).filter(f => f.endsWith('.rs2') && f !== 'shared_droptables.rs2' && f !== 'drop_table.rs2');
// Map from trigger name (config name or _category) to DropTable
const result = new Map<string, DropTable>();
for (const file of files) {
const content = readFileSync(join(dropsDir, file), 'utf-8');
const parsed = parseDropFile(content);
for (const [triggerName, target] of parsed.triggers) {
const data = parsed.labels.get(target);
if (!data) continue;
if (!result.has(target)) {
result.set(target, {
triggerNames: [],
alwaysDrop: data.hasDeathDrop,
entries: data.entries,
alwaysDropItems: data.alwaysItems,
clueTier: data.clueTier,
});
}
result.get(target)!.triggerNames.push(triggerName);
}
}
return result;
}
function parseDropFile(content: string): ParsedDropFile {
const triggers = new Map<string, string>();
const labels = new Map<string, DropTableData>();
const lines = content.split('\n');
let currentLabel: string | null = null;
let currentData: DropTableData | null = null;
let denominator = 128; // default
let lastThreshold = 0;
function finishLabel() {
if (currentLabel && currentData) {
labels.set(currentLabel, currentData);
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('//')) continue;
// Trigger line: [ai_queue3,name] @label; or [ai_queue3,name] without @
const triggerMatch = line.match(/^\[ai_queue3,([^\]]+)\]\s*(?:@(\w+)\s*;)?/);
if (triggerMatch) {
const triggerName = triggerMatch[1];
const labelRef = triggerMatch[2];
if (labelRef) {
triggers.set(triggerName, labelRef);
} else {
// Inline drop table - use trigger name as label
finishLabel();
currentLabel = `_inline_${triggerName}`;
currentData = { hasDeathDrop: false, entries: [], alwaysItems: [], clueTier: null };
denominator = 128;
lastThreshold = 0;
triggers.set(triggerName, currentLabel);
}
continue;
}
// Label line: [label,name]
const labelMatch = line.match(/^\[label,(\w+)\]/);
if (labelMatch) {
finishLabel();
currentLabel = labelMatch[1];
currentData = { hasDeathDrop: false, entries: [], alwaysItems: [], clueTier: null };
denominator = 128;
lastThreshold = 0;
continue;
}
if (!currentData) continue;
// Death drop line
if (line.includes('npc_param(death_drop)')) {
currentData.hasDeathDrop = true;
continue;
}
// Clue scroll detection
if (line.includes('trail_easycluedrop')) {
currentData.clueTier = 'easy';
} else if (line.includes('trail_mediumcluedrop')) {
currentData.clueTier = currentData.clueTier === 'easy' ? 'easy' : 'medium';
} else if (line.includes('trail_hardcluedrop')) {
currentData.clueTier = currentData.clueTier ? currentData.clueTier : 'hard';
}
// Random declaration: def_int $random = random(N);
const randomMatch = line.match(/random\((\d+)\)/);
if (randomMatch && line.includes('def_int')) {
denominator = parseInt(randomMatch[1]);
lastThreshold = 0;
continue;
}
// Always-drop items (outside the if/else chain, plain obj_add)
// These come after the death_drop line but before the random() declaration
if (line.includes('obj_add(npc_coord,') && !line.includes('npc_param(death_drop)') && !currentData.entries.length) {
const alwaysMatch = line.match(/obj_add\(npc_coord,\s*([^,]+),\s*(\d+)/);
if (alwaysMatch && !line.includes('$random') && !line.includes('$drop') && !line.includes('$rng')) {
// Check if we're inside an if block - simple heuristic: no if/else context
const prevLines = lines.slice(Math.max(0, i - 3), i).join(' ');
if (!prevLines.includes('if (') && !prevLines.includes('else if') && !prevLines.includes('random(')) {
currentData.alwaysItems.push({
itemConfigName: alwaysMatch[1].trim(),
quantity: parseInt(alwaysMatch[2]),
});
continue;
}
}
}
// If/else if threshold: if ($random < N) or else if ($random < N) or ($dropint < N) or ($rng < N)
const threshMatch = line.match(/(?:if|else\s+if)\s*\(\s*\$\w+\s*<\s*(\d+)\s*\)/);
if (threshMatch) {
const newThreshold = parseInt(threshMatch[1]);
const numerator = newThreshold - lastThreshold;
// Collect all lines belonging to this branch until the next } else if or closing }
const blockLines: string[] = [line];
for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) {
const jline = lines[j].trim();
// Stop at next else-if branch or end of chain
if (jline.startsWith('} else if') || jline === '}') break;
blockLines.push(jline);
}
const block = blockLines.join('\n');
// Check for members-only variant
const membersCheck = block.includes('map_members = ^true');
const hasFallback = block.includes('} else {');
// Parse obj_add calls with awareness of members/F2P branches
// obj_add patterns: obj_add(npc_coord, item, qty, duration) or obj_add(npc_coord, ~func, duration)
const objAddPattern = /obj_add\(npc_coord,\s*([^,)]+),\s*(.+?),\s*\^lootdrop_duration\)/g;
const sharedCallPattern = /obj_add\(npc_coord,\s*~(\w+),\s*\^lootdrop_duration\)/g;
if (membersCheck && hasFallback) {
// Split block into members and F2P sections
const membersIdx = block.indexOf('map_members = ^true');
const elseIdx = block.indexOf('} else {', membersIdx);
const membersBlock = block.slice(membersIdx, elseIdx || block.length);
const f2pBlock = elseIdx !== -1 ? block.slice(elseIdx) : '';
// Add members items
for (const m of membersBlock.matchAll(objAddPattern)) {
const itemName = m[1].trim();
const rawQty = m[2].trim();
if (itemName.startsWith('~')) continue;
currentData.entries.push({
itemConfigName: itemName,
quantity: parseQuantity(rawQty),
numerator, denominator,
membersOnly: true,
});
}
for (const m of membersBlock.matchAll(sharedCallPattern)) {
currentData.entries.push({
itemConfigName: `~${m[1]}`, quantity: '1',
numerator, denominator, membersOnly: true,
});
}
// Add F2P fallback items
for (const m of f2pBlock.matchAll(objAddPattern)) {
const itemName = m[1].trim();
const rawQty = m[2].trim();
if (itemName.startsWith('~')) continue;
currentData.entries.push({
itemConfigName: itemName,
quantity: parseQuantity(rawQty),
numerator, denominator,
membersOnly: false,
});
}
} else {
// Regular block (no members split, or members-only with no fallback)
const isMembers = membersCheck && !hasFallback;
for (const m of block.matchAll(objAddPattern)) {
const itemName = m[1].trim();
const rawQty = m[2].trim();
if (itemName.startsWith('~')) continue;
currentData.entries.push({
itemConfigName: itemName,
quantity: parseQuantity(rawQty),
numerator, denominator,
membersOnly: isMembers,
});
}
for (const m of block.matchAll(sharedCallPattern)) {
currentData.entries.push({
itemConfigName: `~${m[1]}`, quantity: '1',
numerator, denominator, membersOnly: isMembers,
});
}
}
lastThreshold = newThreshold;
continue;
}
}
finishLabel();
return { triggers, labels };
}
// ─── Parse Quantity Expression ────────────────────────────────────────────────
function parseQuantity(raw: string): string {
const rangeMatch = raw.match(/~random_range\((\d+)\s*,\s*(\d+)\)/);
if (rangeMatch) return `${rangeMatch[1]}-${rangeMatch[2]}`;
// Plain integer
const num = parseInt(raw);
if (!isNaN(num)) return String(num);
return raw;
}
// ─── Prettify Config Names ───────────────────────────────────────────────────
function prettifyConfigName(name: string): string {
return name
.replace(/_/g, ' ')
.replace(/\b(\d+)\b/g, '$1') // keep numbers
.replace(/\b\w/g, c => c.toUpperCase())
.replace(/\bcert\b/i, 'Certificate')
.trim();
}
function getObjDisplayName(configName: string, objNames: Map<string, string>): string {
// Handle shared table references
if (configName === '~randomherb') return 'Herbs (varies)';
if (configName === '~randomjewel') return 'Gems (varies)';
if (configName === '~ultrarare_getitem') return 'Rare drop table';
if (configName === '~megararetable') return 'Mega-rare drop table';
if (configName === '~randomjunk') return 'Junk items (varies)';
if (configName.startsWith('~')) return prettifyConfigName(configName.slice(1));
return objNames.get(configName) || prettifyConfigName(configName);
}
// ─── Derive Area Name ────────────────────────────────────────────────────────
function getAreaFromSourcePath(sourcePath: string): string | null {
// Extract area directory name from source path
const match = sourcePath.match(/scripts\/(?:areas\/)?(\w+)\//);
if (!match) return null;
const dirName = match[1];
return SOURCE_PATH_AREAS[dirName] || null;
}
function getAreaFromMapSquare(mapSquare: string): string {
return MAP_SQUARE_AREAS[mapSquare] || mapSquare;
}
// ─── Movement Description ────────────────────────────────────────────────────
function getMovementDesc(config: NpcConfig): string {
if (config.defaultmode === 'patrol' || config.hasPatrol) return 'Patrol';
if (config.moverestrict === 'nomove') return 'Stationary';
if (config.wanderrange > 0) return `Wander (range ${config.wanderrange})`;
if (config.moverestrict === 'indoors') return 'Stationary (indoors)';
return 'Wander';
}
// ─── Attack Style from Param ─────────────────────────────────────────────────
function getAttackStyle(params: Map<string, string>): string {
const dt = params.get('damagetype') || '';
if (dt.includes('stab')) return 'Stab';
if (dt.includes('slash')) return 'Slash';
if (dt.includes('crush')) return 'Crush';
if (dt.includes('magic')) return 'Magic';
if (dt.includes('range')) return 'Ranged';
return 'Crush'; // default
}
// ─── Check if NPC is Combat ──────────────────────────────────────────────────
function isCombatNpc(config: NpcConfig): boolean {
return config.vislevel !== '' && config.vislevel !== 'hide' && config.hitpoints > 0;
}
// ─── Check if NPC has Combat Stats ───────────────────────────────────────────
function hasCombatStats(config: NpcConfig): boolean {
return config.params.has('attackbonus') || config.params.has('strengthbonus') ||
config.params.has('stabdefence') || config.params.has('slashdefence') ||
config.params.has('crushdefence') || config.params.has('magicdefence') ||
config.params.has('rangedefence');
}
// ─── Main Pipeline ───────────────────────────────────────────────────────────
function main() {
console.log('NPC Wiki Generator');
console.log('==================\n');
// ─── Step 1: Parse all data sources ──────────────────────────────────
console.log('Step 1: Parsing data sources...');
// 1a: NPC pack (ID map)
const npcPackContent = readFileSync(join(CONTENT_DIR, 'pack', 'npc.pack'), 'utf-8');
const npcPack = parsePackFile(npcPackContent);
console.log(` npc.pack: ${npcPack.idToName.size} entries`);
// 1b: Obj pack (ID map)
const objPackContent = readFileSync(join(CONTENT_DIR, 'pack', 'obj.pack'), 'utf-8');
const objPack = parsePackFile(objPackContent);
console.log(` obj.pack: ${objPack.idToName.size} entries`);
// 1c: NPC configs from all.npc
const allNpcContent = readFileSync(join(CONTENT_DIR, 'scripts', '_unpack', '225', 'all.npc'), 'utf-8');
const allNpcConfigs = parseIniConfigs(allNpcContent, 'scripts/_unpack/225/all.npc');
console.log(` all.npc: ${allNpcConfigs.size} configs`);
// 1d: NPC configs from script area/quest config files
const npcConfigFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.npc');
const extraNpcConfigs = new Map<string, NpcConfig>();
for (const file of npcConfigFiles) {
if (file.includes('_unpack')) continue; // already parsed
const content = readFileSync(file, 'utf-8');
const relPath = relative(CONTENT_DIR, file);
const configs = parseIniConfigs(content, relPath);
for (const [name, config] of configs) {
extraNpcConfigs.set(name, config);
}
}
console.log(` Area/quest NPC configs: ${extraNpcConfigs.size} configs from ${npcConfigFiles.length - 1} files`);
// 1e: Merge NPC configs (extra configs override all.npc for matching names)
const allConfigs = new Map<string, NpcConfig>();
for (const [name, config] of allNpcConfigs) {
allConfigs.set(name, config);
}
for (const [name, config] of extraNpcConfigs) {
const existing = allConfigs.get(name);
if (existing) {
// Merge: extra config overrides specific fields if set, keep the rest
if (config.name) existing.name = config.name;
if (config.desc) existing.desc = config.desc;
if (config.vislevel) existing.vislevel = config.vislevel;
if (config.hitpoints) existing.hitpoints = config.hitpoints;
if (config.attack) existing.attack = config.attack;
if (config.strength) existing.strength = config.strength;
if (config.defence) existing.defence = config.defence;
if (config.category) existing.category = config.category;
if (config.huntmode) existing.huntmode = config.huntmode;
if (config.wanderrange) existing.wanderrange = config.wanderrange;
if (config.maxrange) existing.maxrange = config.maxrange;
if (config.respawnrate) existing.respawnrate = config.respawnrate;
if (config.defaultmode) existing.defaultmode = config.defaultmode;
if (config.givechase) existing.givechase = config.givechase;
if (config.moverestrict) existing.moverestrict = config.moverestrict;
if (config.hasPatrol) existing.hasPatrol = config.hasPatrol;
for (const [k, v] of config.params) existing.params.set(k, v);
for (let j = 0; j < 5; j++) {
if (config.ops[j]) existing.ops[j] = config.ops[j];
}
// Keep the more specific source path
if (config.sourcePath !== 'scripts/_unpack/225/all.npc') {
existing.sourcePath = config.sourcePath;
}
} else {
allConfigs.set(name, config);
}
}
console.log(` Total merged NPC configs: ${allConfigs.size}`);
// 1f: Obj display names
const objNames = new Map<string, string>();
// Parse all.obj using bash since Read tool can't handle .obj extension
const allObjContent = readFileSync(join(CONTENT_DIR, 'scripts', '_unpack', '225', 'all.obj'), 'utf-8');
const allObjNames = parseObjConfigs(allObjContent);
for (const [k, v] of allObjNames) objNames.set(k, v);
// Parse extra .obj config files
const objConfigFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.obj');
for (const file of objConfigFiles) {
if (file.includes('_unpack')) continue;
const content = readFileSync(file, 'utf-8');
const names = parseObjConfigs(content);
for (const [k, v] of names) {
if (!objNames.has(k)) objNames.set(k, v);
}
}
console.log(` Obj display names: ${objNames.size}`);
// 1g: NPC spawns from map files
const spawns = parseMapSpawns(join(CONTENT_DIR, 'maps'), npcPack.idToName);
console.log(` Map spawns: ${spawns.size} NPCs with spawn data`);
// 1h: Drop tables
const dropTables = parseDropTables(join(CONTENT_DIR, 'scripts', 'drop tables', 'scripts'));
console.log(` Drop tables: ${dropTables.size} tables parsed`);
// Build category map: category name -> list of config names
const categoryMap = new Map<string, string[]>();
for (const [configName, config] of allConfigs) {
if (config.category) {
if (!categoryMap.has(config.category)) categoryMap.set(config.category, []);
categoryMap.get(config.category)!.push(configName);
}
}
// ─── Step 2: Filter ──────────────────────────────────────────────────
console.log('\nStep 2: Filtering NPCs...');
// Get tutorial and antimacro config names
const tutorialFile = join(CONTENT_DIR, 'scripts', 'tutorial', 'configs', 'tutorial.npc');
const tutorialConfigs = new Set<string>();
if (existsSync(tutorialFile)) {
const content = readFileSync(tutorialFile, 'utf-8');
const configs = parseIniConfigs(content, 'tutorial');
for (const name of configs.keys()) tutorialConfigs.add(name);
}
const antimacroFile = join(CONTENT_DIR, 'scripts', 'macro events', 'configs', 'antimacro.npc');
const antimacroConfigs = new Set<string>();
if (existsSync(antimacroFile)) {
const content = readFileSync(antimacroFile, 'utf-8');
const configs = parseIniConfigs(content, 'antimacro');
for (const name of configs.keys()) antimacroConfigs.add(name);
}
const filteredConfigs = new Map<string, NpcConfig>();
let filtered = 0;
for (const [name, config] of allConfigs) {
// Skip tutorial NPCs
if (tutorialConfigs.has(name)) { filtered++; continue; }
// Skip antimacro NPCs
if (antimacroConfigs.has(name)) { filtered++; continue; }
// Skip NPCs with macro event categories
if (config.category.startsWith('macro_event')) { filtered++; continue; }
// Skip NPCs with no name
if (!config.name) { filtered++; continue; }
filteredConfigs.set(name, config);
}
console.log(` Filtered out ${filtered} NPCs (tutorial, antimacro, unnamed)`);
console.log(` Remaining: ${filteredConfigs.size} configs`);
// ─── Step 3: Deduplicate by display name ─────────────────────────────
console.log('\nStep 3: Deduplicating by display name...');
const pagesByName = new Map<string, NpcPage>();
for (const [configName, config] of filteredConfigs) {
const displayName = config.name;
if (!pagesByName.has(displayName)) {
pagesByName.set(displayName, {
displayName,
variants: [],
spawns: [],
drops: [],
alwaysDropItem: null,
alwaysDropExtra: [],
clueTier: null,
});
}
const page = pagesByName.get(displayName)!;
page.variants.push(config);
}
console.log(` Deduplicated to ${pagesByName.size} unique NPC pages`);
// ─── Step 4: Resolve drops and spawns ────────────────────────────────
console.log('\nStep 4: Resolving drops and spawns...');
for (const [displayName, page] of pagesByName) {
// Collect spawns from all variants
const seenMapSquares = new Set<string>();
for (const variant of page.variants) {
const variantSpawns = spawns.get(variant.configName) || [];
for (const spawn of variantSpawns) {
if (!seenMapSquares.has(`${spawn.mapSquare}_${spawn.level}`)) {
seenMapSquares.add(`${spawn.mapSquare}_${spawn.level}`);
page.spawns.push(spawn);
}
}
}
// Resolve death_drop (always drop item)
// Count all variants - those without explicit death_drop default to "bones" for combat NPCs
const deathDropCounts = new Map<string, number>();
for (const variant of page.variants) {
const dd = variant.params.get('death_drop');
if (dd === 'null') {
// Explicitly drops nothing
} else if (dd) {
deathDropCounts.set(dd, (deathDropCounts.get(dd) || 0) + 1);
} else if (isCombatNpc(variant)) {
// No explicit death_drop → default is "bones"
deathDropCounts.set('bones', (deathDropCounts.get('bones') || 0) + 1);
}
}
if (deathDropCounts.size > 0) {
// Pick the most common death_drop
let bestDrop = '';
let bestCount = 0;
for (const [drop, count] of deathDropCounts) {
if (count > bestCount) { bestDrop = drop; bestCount = count; }
}
page.alwaysDropItem = bestDrop;
}
// Find drop table
for (const variant of page.variants) {
// Check direct config name trigger
for (const [, table] of dropTables) {
if (table.triggerNames.includes(variant.configName)) {
if (page.drops.length === 0) {
page.drops = [...table.entries];
page.alwaysDropExtra = [...table.alwaysDropItems];
if (table.clueTier) page.clueTier = table.clueTier;
}
break;
}
}
if (page.drops.length > 0) break;
// Check _category trigger
if (variant.category) {
for (const [, table] of dropTables) {
if (table.triggerNames.includes(`_${variant.category}`)) {
if (page.drops.length === 0) {
page.drops = [...table.entries];
page.alwaysDropExtra = [...table.alwaysDropItems];
if (table.clueTier) page.clueTier = table.clueTier;
}
break;
}
}
if (page.drops.length > 0) break;
}
}
}
// ─── Step 5: Generate markdown ───────────────────────────────────────
console.log('\nStep 5: Generating markdown...');
mkdirSync(NPC_DIR, { recursive: true });
let generated = 0;
const pageIndex: { name: string; file: string }[] = [];
for (const [displayName, page] of pagesByName) {
const slug = displayName.toLowerCase()
.replace(/['']/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const fileName = `${slug}.md`;
const filePath = join(NPC_DIR, fileName);
const md = generateNpcMarkdown(page, objNames);
writeFileSync(filePath, md);
pageIndex.push({ name: displayName, file: fileName });
generated++;
}
console.log(` Generated ${generated} NPC pages`);
// ─── Step 6: Update README ───────────────────────────────────────────
console.log('\nStep 6: Updating README...');
updateReadme(pageIndex);
console.log('\nDone!');
}
// ─── Generate Markdown for One NPC Page ──────────────────────────────────────
function generateNpcMarkdown(page: NpcPage, objNames: Map<string, string>): string {
const primary = page.variants[0];
const combat = isCombatNpc(primary);
const lines: string[] = [];
// Title
lines.push(`# ${page.displayName}`);
lines.push('');
// Info table
lines.push('| Detail | |');
lines.push('|---|---|');
// Combat level - show range if variants differ
const vislevels = [...new Set(page.variants.map(v => v.vislevel).filter(v => v && v !== 'hide'))];
if (vislevels.length > 1) {
const nums = vislevels.map(v => parseInt(v)).filter(n => !isNaN(n)).sort((a, b) => a - b);
if (nums.length > 1) {
lines.push(`| **Combat Level** | ${nums[0]}-${nums[nums.length - 1]} |`);
} else {
lines.push(`| **Combat Level** | ${vislevels[0]} |`);
}
} else if (vislevels.length === 1) {
lines.push(`| **Combat Level** | ${vislevels[0]} |`);
} else {
lines.push('| **Combat Level** | None |');
}
// Hitpoints
const hps = [...new Set(page.variants.map(v => v.hitpoints).filter(h => h > 0))].sort((a, b) => a - b);
if (hps.length > 1) {
lines.push(`| **Hitpoints** | ${hps[0]}-${hps[hps.length - 1]} |`);
} else if (hps.length === 1) {
lines.push(`| **Hitpoints** | ${hps[0]} |`);
} else {
lines.push('| **Hitpoints** | N/A |');
}
// Description
lines.push(`| **Description** | ${primary.desc || 'N/A'} |`);
// Aggressive
const isAggressive = page.variants.some(v => v.huntmode.includes('aggressive'));
lines.push(`| **Aggressive** | ${isAggressive ? 'Yes' : 'No'} |`);
// Movement
lines.push(`| **Movement** | ${getMovementDesc(primary)} |`);
// Respawn (only for combat NPCs)
if (combat) {
const respawnRates = [...new Set(page.variants.map(v => v.respawnrate).filter(r => r > 0))].sort((a, b) => a - b);
if (respawnRates.length > 0) {
if (respawnRates.length > 1) {
lines.push(`| **Respawn** | ${respawnRates[0]}-${respawnRates[respawnRates.length - 1]} ticks |`);
} else {
lines.push(`| **Respawn** | ${respawnRates[0]} ticks |`);
}
}
}
lines.push('');
// Combat Stats section (only if NPC has param stats)
if (hasCombatStats(primary)) {
lines.push('## Combat Stats');
lines.push('');
lines.push('| Stat | Value |');
lines.push('|------|-------|');
const atkBonus = primary.params.get('attackbonus') || '0';
const strBonus = primary.params.get('strengthbonus') || '0';
const stabDef = primary.params.get('stabdefence') || '0';
const slashDef = primary.params.get('slashdefence') || '0';
const crushDef = primary.params.get('crushdefence') || '0';
const magicDef = primary.params.get('magicdefence') || '0';
const rangeDef = primary.params.get('rangedefence') || '0';
const style = getAttackStyle(primary.params);
lines.push(`| Attack Bonus | ${atkBonus} |`);
lines.push(`| Strength Bonus | ${strBonus} |`);
lines.push(`| Stab Defence | ${stabDef} |`);
lines.push(`| Slash Defence | ${slashDef} |`);
lines.push(`| Crush Defence | ${crushDef} |`);
lines.push(`| Magic Defence | ${magicDef} |`);
lines.push(`| Range Defence | ${rangeDef} |`);
lines.push(`| Attack Style | ${style} |`);
lines.push('');
}
// Locations section
if (page.spawns.length > 0) {
lines.push(page.spawns.length > 1 ? '## Locations' : '## Location');
lines.push('');
lines.push('| Area | Map | Coordinate samples |');
lines.push('|------|-----|--------------------|');
// Group spawns by area while retaining useful exact coordinates. Limit
// samples so very common NPCs do not produce enormous generated pages.
const byArea = new Map<string, { squares: Set<string>; coordinates: Set<string> }>();
for (const spawn of page.spawns) {
const area = getAreaFromMapSquare(spawn.mapSquare);
if (!byArea.has(area)) {
byArea.set(area, { squares: new Set(), coordinates: new Set() });
}
const group = byArea.get(area)!;
group.squares.add(spawn.mapSquare);
const floor = spawn.level > 0 ? ` L${spawn.level}` : '';
group.coordinates.add(`(${spawn.globalX}, ${spawn.globalZ})${floor}`);
}
// Sort by area name
const sortedAreas = [...byArea.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [area, group] of sortedAreas) {
const squareList = [...group.squares].sort().join(', ');
const allCoordinates = [...group.coordinates].sort();
const coordinateList = allCoordinates.slice(0, 12).join(', ');
const overflow = allCoordinates.length > 12 ? `, +${allCoordinates.length - 12} more` : '';
lines.push(`| ${area} | ${squareList} | ${coordinateList}${overflow} |`);
}
lines.push('');
} else if (primary.hasPatrol || primary.defaultmode === 'patrol') {
lines.push('## Location');
lines.push('');
lines.push('| Area | Coordinates |');
lines.push('|------|------------|');
const area = getAreaFromSourcePath(primary.sourcePath);
if (area) {
lines.push(`| ${area} | Patrols around the area |`);
} else {
lines.push('| Unknown | Patrols around the area |');
}
lines.push('');
}
// Interactions section (for non-combat NPCs with ops)
if (!combat) {
const ops = primary.ops.filter(op => op && op !== 'Attack');
if (ops.length > 0) {
lines.push('## Interactions');
lines.push('');
lines.push('| Option | Action |');
lines.push('|--------|--------|');
for (const op of ops) {
lines.push(`| ${op} | ${op} |`);
}
lines.push('');
}
}
// Drops section
lines.push('## Drops');
lines.push('');
const alwaysDropName = page.alwaysDropItem ? getObjDisplayName(page.alwaysDropItem, objNames) : null;
if (combat && alwaysDropName && page.alwaysDropItem !== 'null') {
lines.push(`Always drops: ${alwaysDropName}`);
lines.push('');
}
// Always-drop extra items (like raw chicken, cow hide)
if (page.alwaysDropExtra.length > 0) {
for (const item of page.alwaysDropExtra) {
const name = getObjDisplayName(item.itemConfigName, objNames);
if (item.quantity > 1) {
lines.push(`Always drops: ${name} (x${item.quantity})`);
} else {
lines.push(`Always drops: ${name}`);
}
}
lines.push('');
}
if (page.drops.length > 0) {
lines.push('| Item | Rarity |');
lines.push('|------|--------|');
for (const drop of page.drops) {
const itemName = getObjDisplayName(drop.itemConfigName, objNames);
let qtyStr = '';
if (drop.quantity !== '1') {
qtyStr = ` (x${drop.quantity})`;
}
const membersStr = drop.membersOnly ? ' (members)' : '';
lines.push(`| ${itemName}${qtyStr} | ${drop.numerator}/${drop.denominator}${membersStr} |`);
}
lines.push('');
} else if (!combat || (!alwaysDropName && page.alwaysDropExtra.length === 0)) {
lines.push(`${page.displayName} has no drops.`);
lines.push('');
}
// Notes section
const notes = generateNotes(page);
if (notes.length > 0) {
lines.push('## Notes');
lines.push('');
for (const note of notes) {
lines.push(`- ${note}`);
}
lines.push('');
}
return lines.join('\n');
}
// ─── Generate Notes ──────────────────────────────────────────────────────────
function generateNotes(page: NpcPage): string[] {
const notes: string[] = [];
const primary = page.variants[0];
// Quest association from source path
const questMatch = primary.sourcePath.match(/quests\/quest_(\w+)\//);
if (questMatch) {
const questName = prettifyConfigName(questMatch[1]);
notes.push(`Associated with the **${questName}** quest.`);
}
// Variants info
if (page.variants.length > 1) {
notes.push(`Has ${page.variants.length} visual variants (${page.variants.map(v => v.configName).join(', ')}).`);
}
// Hunt mode / behavior notes
if (primary.huntmode === 'cowardly') {
notes.push('Will flee from combat when attacked.');
}
if (primary.givechase === 'false') {
notes.push('Does not chase players.');
}
if (primary.huntmode.includes('aggressive')) {
notes.push('Will attack nearby players on sight.');
}
// Pickpocket
if (primary.ops.includes('Pickpocket')) {
notes.push('Can be pickpocketed for Thieving experience.');
}
// Category info
if (primary.category && !primary.category.startsWith('category_')) {
const prettyCategory = prettifyConfigName(primary.category);
notes.push(`Category: ${prettyCategory}.`);
}
// Clue scroll
if (page.clueTier) {
notes.push(`Can drop ${page.clueTier} clue scrolls.`);
}
return notes;
}
// ─── Update README ───────────────────────────────────────────────────────────
function updateReadme(pages: { name: string; file: string }[]) {
const readmePath = join(WIKI_DIR, 'README.md');
if (!existsSync(readmePath)) {
console.log(' No wiki/README.md; skipping index update');
return;
}
let content = readFileSync(readmePath, 'utf-8');
// Remove existing NPCs section if present
const npcsStart = content.indexOf('## NPCs');
if (npcsStart !== -1) {
// Find the next ## section after NPCs, or end of file
const afterNpcs = content.slice(npcsStart + 7);
const nextSection = afterNpcs.search(/\n## /);
if (nextSection !== -1) {
content = content.slice(0, npcsStart) + content.slice(npcsStart + 7 + nextSection + 1);
} else {
content = content.slice(0, npcsStart).trimEnd() + '\n';
}
}
// Sort alphabetically
pages.sort((a, b) => a.name.localeCompare(b.name));
// Generate NPCs section
const npcSection = [
'## NPCs',
'',
'| NPC | Combat Level |',
'|-----|-------------|',
];
for (const page of pages) {
// Read back the file to get combat level
const filePath = join(NPC_DIR, page.file);
const md = readFileSync(filePath, 'utf-8');
const levelMatch = md.match(/\*\*Combat Level\*\*\s*\|\s*([^|]+)\|/);
const level = levelMatch ? levelMatch[1].trim() : 'None';
npcSection.push(`| [${page.name}](npcs/${page.file}) | ${level} |`);
}
npcSection.push('');
// Append to content
content = content.trimEnd() + '\n\n' + npcSection.join('\n');
writeFileSync(readmePath, content);
console.log(` Updated README.md with ${pages.length} NPC entries`);
}
// ─── Utility: Find files recursively ─────────────────────────────────────────
function findFilesRecursive(dir: string, ext: string): string[] {
const results: string[] = [];
function walk(d: string) {
const entries = readdirSync(d, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(d, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.name.endsWith(ext)) {
results.push(fullPath);
}
}
}
walk(dir);
return results;
}
// ─── Run ─────────────────────────────────────────────────────────────────────
main();