Files
rs-sdk/wiki/generate-shops.ts
max bittkerandClaude Fable 5 2ae032c998 docs(wiki,sdk): shop-page coordinates, decoy-furnace learning, disconnectAfter doc
From the GLM 5.3 zero-score run analysis (rs-bench2 2026-08-14):

- generate-shops.ts now emits a Coordinates row from owner NPC map
  spawns, so agents can walk to a shop instead of sweeping the area
  name (Dommik wild-goose chase). 105/117 pages gain coordinates.
- learnings/smithing.md sample used the bare nearest-name furnace
  match that selects the inert decoy loc; now uses {withOption}.
- runner.ts disconnectAfter docstring claimed default false; the
  actual default is true for runner-opened connections, which is
  what tears down a shared MCP client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:04:16 -04:00

629 lines
22 KiB
TypeScript

#!/usr/bin/env bun
/**
* Shop Wiki Generator
* Parses server content files and generates markdown wiki pages for all shops.
* Run: bun wiki/generate-shops.ts
*/
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, relative } from 'path';
const CONTENT_DIR = join(import.meta.dir, '..', 'server', 'content');
const WIKI_DIR = join(import.meta.dir);
const SHOP_DIR = join(WIKI_DIR, 'shops');
// ─── Types ───────────────────────────────────────────────────────────────────
interface StockItem {
configName: string;
quantity: number;
price: number;
}
interface ShopInventory {
name: string; // section name e.g. "axeshop"
scope: string;
restock: boolean;
allstock: boolean;
size: number;
stock: StockItem[];
sourcePath: string;
}
interface ShopOwner {
npcConfigName: string;
npcDisplayName: string;
shopName: string; // matches ShopInventory.name
shopTitle: string;
sellMultiplier: number;
buyMultiplier: number;
delta: number;
sourcePath: string;
}
interface ShopPage {
shopName: string;
shopTitle: string;
inventory: ShopInventory;
owners: ShopOwner[];
location: string;
isGeneralStore: boolean;
}
// ─── 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',
};
// ─── Parsers ─────────────────────────────────────────────────────────────────
function parseInvConfigs(content: string, sourcePath: string): ShopInventory[] {
const shops: ShopInventory[] = [];
let current: ShopInventory | null = null;
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('//') || line.startsWith('/ ')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
if (current && current.stock.length > 0) shops.push(current);
current = {
name: sectionMatch[1],
scope: '',
restock: false,
allstock: false,
size: 40,
stock: [],
sourcePath,
};
continue;
}
if (!current || !line) continue;
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const key = line.slice(0, eqIdx).trim();
const value = line.slice(eqIdx + 1).trim();
if (key === 'scope') current.scope = value;
else if (key === 'restock') current.restock = value === 'yes';
else if (key === 'allstock') current.allstock = value === 'yes';
else if (key === 'size') current.size = parseInt(value) || 40;
else if (key.startsWith('stock')) {
const parts = value.split(',');
if (parts.length >= 3) {
current.stock.push({
configName: parts[0].trim(),
quantity: parseInt(parts[1]) || 0,
price: parseInt(parts[2]) || 0,
});
}
}
}
if (current && current.stock.length > 0) shops.push(current);
return shops;
}
interface NpcShopInfo {
configName: string;
displayName: string;
params: Map<string, string>;
sourcePath: string;
}
function parseNpcConfigs(content: string, sourcePath: string): NpcShopInfo[] {
const results: NpcShopInfo[] = [];
let currentName = '';
let currentDisplayName = '';
let currentParams = new Map<string, string>();
function flush() {
if (currentName && currentParams.has('owned_shop')) {
results.push({
configName: currentName,
displayName: currentDisplayName,
params: currentParams,
sourcePath,
});
}
}
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('//') || line.startsWith('/ ')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
flush();
currentName = sectionMatch[1];
currentDisplayName = '';
currentParams = new Map();
continue;
}
if (!currentName || !line) continue;
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const key = line.slice(0, eqIdx).trim();
const value = line.slice(eqIdx + 1).trim();
if (key === 'name') currentDisplayName = value;
else if (key === 'param') {
const commaIdx = value.indexOf(',');
if (commaIdx !== -1) {
currentParams.set(value.slice(0, commaIdx), value.slice(commaIdx + 1));
}
}
}
flush();
return results;
}
interface ObjInfo {
displayName: string;
cost: number;
}
// ─── Parser: Pack File + Map Spawns (mirrors generate-npcs.ts) ───────────────
interface SpawnLocation {
globalX: number;
globalZ: number;
level: number;
}
function parseNpcPackIdToName(content: string): Map<number, string> {
const idToName = new Map<number, string>();
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);
}
return idToName;
}
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 content = readFileSync(join(mapsDir, file), 'utf-8');
const npcSectionStart = content.indexOf('==== NPC ====');
if (npcSectionStart === -1) continue;
const afterNpc = content.slice(npcSectionStart + '==== NPC ===='.length);
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;
if (!spawns.has(configName)) spawns.set(configName, []);
spawns.get(configName)!.push({
globalX: mapX * 64 + localX,
globalZ: mapZ * 64 + localZ,
level,
});
}
}
return spawns;
}
function parseObjConfigsFull(content: string): Map<string, ObjInfo> {
const items = new Map<string, ObjInfo>();
let currentName = '';
let currentDisplayName = '';
let currentCost = 0;
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) {
items.set(currentName, { displayName: currentDisplayName, cost: currentCost });
}
currentName = sectionMatch[1];
currentDisplayName = '';
currentCost = 0;
continue;
}
if (line.startsWith('name=')) {
currentDisplayName = line.slice(5);
} else if (line.startsWith('cost=')) {
currentCost = parseInt(line.slice(5)) || 0;
}
}
if (currentName && currentDisplayName) {
items.set(currentName, { displayName: currentDisplayName, cost: currentCost });
}
return items;
}
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;
}
function prettifyConfigName(name: string): string {
return name
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
.trim();
}
function getAreaFromSourcePath(sourcePath: string): string {
const match = sourcePath.match(/scripts\/(?:areas\/)?(\w+)\//);
if (!match) return 'Unknown';
return SOURCE_PATH_AREAS[match[1]] || prettifyConfigName(match[1]);
}
function slugify(name: string): string {
return name.toLowerCase()
.replace(/['']/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
// ─── Main Pipeline ───────────────────────────────────────────────────────────
function main() {
console.log('Shop Wiki Generator');
console.log('===================\n');
// ─── Step 1: Parse shop inventories ─────────────────────────────────
console.log('Step 1: Parsing shop inventories...');
const invFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.inv');
const allShops = new Map<string, ShopInventory>();
for (const file of invFiles) {
const content = readFileSync(file, 'utf-8');
const relPath = relative(CONTENT_DIR, file);
const shops = parseInvConfigs(content, relPath);
for (const shop of shops) {
// Filter non-shop inventories: must have restock=yes and not be temp scope
if (shop.restock && shop.scope !== 'temp') {
allShops.set(shop.name, shop);
}
}
}
console.log(` Found ${allShops.size} shop inventories from ${invFiles.length} .inv files`);
// ─── Step 2: Parse shop owner NPCs ──────────────────────────────────
console.log('\nStep 2: Parsing shop owner NPCs...');
const npcFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.npc');
const shopOwners = new Map<string, ShopOwner[]>(); // shopName -> owners
for (const file of npcFiles) {
const content = readFileSync(file, 'utf-8');
const relPath = relative(CONTENT_DIR, file);
const npcs = parseNpcConfigs(content, relPath);
for (const npc of npcs) {
const shopName = npc.params.get('owned_shop')!;
const owner: ShopOwner = {
npcConfigName: npc.configName,
npcDisplayName: npc.displayName,
shopName,
shopTitle: npc.params.get('shop_title') || prettifyConfigName(shopName),
sellMultiplier: parseInt(npc.params.get('shop_sell_multiplier') || '1000'),
buyMultiplier: parseInt(npc.params.get('shop_buy_multiplier') || '600'),
delta: parseInt(npc.params.get('shop_delta') || '10'),
sourcePath: relPath,
};
if (!shopOwners.has(shopName)) shopOwners.set(shopName, []);
shopOwners.get(shopName)!.push(owner);
}
}
let totalOwners = 0;
for (const owners of shopOwners.values()) totalOwners += owners.length;
console.log(` Found ${totalOwners} shop owners for ${shopOwners.size} shops`);
// ─── Step 3: Load item data (names + costs) ────────────────────────
console.log('\nStep 3: Loading item data...');
const objData = new Map<string, ObjInfo>();
const allObjContent = readFileSync(join(CONTENT_DIR, 'scripts', '_unpack', '225', 'all.obj'), 'utf-8');
const allObjData = parseObjConfigsFull(allObjContent);
for (const [k, v] of allObjData) objData.set(k, v);
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 parsed = parseObjConfigsFull(content);
for (const [k, v] of parsed) {
const existing = objData.get(k);
if (existing) {
// Merge: override name/cost if set
if (v.displayName) existing.displayName = v.displayName;
if (v.cost) existing.cost = v.cost;
} else {
objData.set(k, v);
}
}
}
console.log(` Loaded ${objData.size} items`);
// ─── Step 3b: Owner NPC spawn coordinates ───────────────────────────
console.log('\nStep 3b: Loading owner NPC spawn coordinates...');
const npcIdToName = parseNpcPackIdToName(
readFileSync(join(CONTENT_DIR, 'pack', 'npc.pack'), 'utf-8')
);
const npcSpawns = parseMapSpawns(join(CONTENT_DIR, 'maps'), npcIdToName);
console.log(` Map spawns: ${npcSpawns.size} NPCs with spawn data`);
// ─── Step 4: Build shop pages ───────────────────────────────────────
console.log('\nStep 4: Building shop pages...');
const shopPages: ShopPage[] = [];
for (const [shopName, inventory] of allShops) {
const owners = shopOwners.get(shopName) || [];
const primaryOwner = owners[0];
const shopTitle = (primaryOwner?.shopTitle || prettifyConfigName(shopName)).replace(/\.$/, '');
const location = primaryOwner
? getAreaFromSourcePath(primaryOwner.sourcePath)
: getAreaFromSourcePath(inventory.sourcePath);
shopPages.push({
shopName,
shopTitle,
inventory,
owners,
location,
isGeneralStore: inventory.allstock,
});
}
// Sort by shop title
shopPages.sort((a, b) => a.shopTitle.localeCompare(b.shopTitle));
console.log(` Built ${shopPages.length} shop pages`);
// ─── Step 5: Generate markdown ──────────────────────────────────────
console.log('\nStep 5: Generating markdown...');
mkdirSync(SHOP_DIR, { recursive: true });
const pageIndex: { name: string; file: string; location: string }[] = [];
for (const page of shopPages) {
const slug = slugify(page.shopTitle);
const fileName = `${slug}.md`;
const filePath = join(SHOP_DIR, fileName);
const md = generateShopMarkdown(page, objData, npcSpawns);
writeFileSync(filePath, md);
pageIndex.push({ name: page.shopTitle, file: fileName, location: page.location });
}
console.log(` Generated ${pageIndex.length} shop pages`);
// ─── Step 6: Update README ──────────────────────────────────────────
console.log('\nStep 6: Updating README...');
updateReadme(pageIndex);
console.log('\nDone!');
}
// ─── Generate Markdown ───────────────────────────────────────────────────────
function generateShopMarkdown(
page: ShopPage,
objData: Map<string, ObjInfo>,
npcSpawns: Map<string, SpawnLocation[]>,
): string {
const lines: string[] = [];
const primaryOwner = page.owners[0];
// Price multipliers (confusing naming in source: shop_sell_multiplier = price when shop sells TO player)
const sellToPlayerMul = primaryOwner?.sellMultiplier || 1000;
const buyFromPlayerMul = primaryOwner?.buyMultiplier || 600;
lines.push(`# ${page.shopTitle}`);
lines.push('');
// Info table
lines.push('| Detail | |');
lines.push('|---|---|');
// Owner(s)
if (page.owners.length > 0) {
const ownerLinks = page.owners.map(o => {
const npcSlug = slugify(o.npcDisplayName);
const npcPagePath = `../npcs/${npcSlug}.md`;
return `[${o.npcDisplayName}](${npcPagePath})`;
});
lines.push(`| **Owner** | ${ownerLinks.join(', ')} |`);
} else {
lines.push('| **Owner** | Unknown |');
}
lines.push(`| **Location** | ${page.location} |`);
// Owner spawn coordinates, so agents can walk straight to the shop
// instead of guessing from the area name alone.
const coordParts: string[] = [];
for (const owner of page.owners) {
const spawns = npcSpawns.get(owner.npcConfigName);
if (!spawns || spawns.length === 0) continue;
const samples = new Set<string>();
for (const spawn of spawns) {
const floor = spawn.level > 0 ? ` L${spawn.level}` : '';
samples.add(`(${spawn.globalX}, ${spawn.globalZ})${floor}`);
}
const sorted = [...samples].sort();
const overflow = sorted.length > 6 ? `, +${sorted.length - 6} more` : '';
const label = page.owners.length > 1 ? `${owner.npcDisplayName}: ` : '';
coordParts.push(`${label}${sorted.slice(0, 6).join(', ')}${overflow}`);
}
if (coordParts.length > 0) {
lines.push(`| **Coordinates** | ${coordParts.join('; ')} |`);
}
lines.push(`| **Type** | ${page.isGeneralStore ? 'General Store' : 'Specialty Shop'} |`);
lines.push('');
// Stock table with computed prices
lines.push('## Stock');
lines.push('');
lines.push('| Item | Stock | Buy Price | Sell Price |');
lines.push('|------|-------|-----------|------------|');
for (const item of page.inventory.stock) {
const info = objData.get(item.configName);
const displayName = info?.displayName || prettifyConfigName(item.configName);
const baseCost = info?.cost || 0;
// Buy price = what player pays to buy from shop (at normal stock)
const buyPrice = Math.max(1, Math.floor(baseCost * sellToPlayerMul / 1000));
// Sell price = what player gets selling to shop (at normal stock)
const sellPrice = Math.floor(baseCost * buyFromPlayerMul / 1000);
const itemSlug = slugify(displayName);
const itemLink = `[${displayName}](../items/${itemSlug}.md)`;
lines.push(`| ${itemLink} | ${item.quantity} | ${buyPrice} gp | ${sellPrice} gp |`);
}
lines.push('');
return lines.join('\n');
}
// ─── Update README ───────────────────────────────────────────────────────────
function updateReadme(pages: { name: string; file: string; location: 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 Shops section if present
const shopsStart = content.indexOf('## Shops');
if (shopsStart !== -1) {
const afterShops = content.slice(shopsStart + 8);
const nextSection = afterShops.search(/\n## /);
if (nextSection !== -1) {
content = content.slice(0, shopsStart) + content.slice(shopsStart + 8 + nextSection + 1);
} else {
content = content.slice(0, shopsStart).trimEnd() + '\n';
}
}
// Sort alphabetically
pages.sort((a, b) => a.name.localeCompare(b.name));
// Generate Shops section
const section = [
'## Shops',
'',
'| Shop | Location |',
'|------|----------|',
];
for (const page of pages) {
section.push(`| [${page.name}](shops/${page.file}) | ${page.location} |`);
}
section.push('');
// Insert before NPCs section if it exists, otherwise append
const npcsStart = content.indexOf('## NPCs');
if (npcsStart !== -1) {
content = content.slice(0, npcsStart) + section.join('\n') + '\n' + content.slice(npcsStart);
} else {
content = content.trimEnd() + '\n\n' + section.join('\n');
}
writeFileSync(readmePath, content);
console.log(` Updated README.md with ${pages.length} shop entries`);
}
// ─── Run ─────────────────────────────────────────────────────────────────────
main();