Files
rs-sdk/wiki/generate-items.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

1010 lines
38 KiB
TypeScript

#!/usr/bin/env bun
/**
* Item Wiki Generator
* Parses server content files and generates markdown wiki pages for all items.
* Run: bun wiki/generate-items.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 ITEM_DIR = join(WIKI_DIR, 'items');
const SHOP_DIR = join(WIKI_DIR, 'shops');
const NPC_DIR = join(WIKI_DIR, 'npcs');
// ─── Types ───────────────────────────────────────────────────────────────────
interface ItemConfig {
configName: string;
name: string;
desc: string;
cost: number;
weight: string;
members: boolean;
stackable: boolean;
tradeable: string; // "yes", "no", or "" (default yes)
category: string;
wearpos: string;
wearpos2: string;
wearpos3: string;
dummyitem: string;
iops: string[]; // iop1-iop5
countRefs: string[]; // count1-count9 item refs (visual variants)
params: Map<string, string>;
sourcePath: string;
}
interface ShopSource {
shopTitle: string;
shopSlug: string;
buyPrice: number; // what player pays
}
interface NpcDropSource {
npcName: string;
npcSlug: string;
rarity: string;
}
interface SpawnLocation {
mapSquare: string;
area: string;
level: number;
quantity: number;
}
type ItemCategory = 'Weapon' | 'Armour' | 'Ammunition' | 'Rune' | 'Food' | 'Potion' | 'Tool' | 'Quest Item' | 'Other';
interface ItemPage {
displayName: string;
config: ItemConfig; // primary config
category: ItemCategory;
equipSlot: string | null;
shops: ShopSource[];
drops: NpcDropSource[];
spawns: SpawnLocation[];
}
// ─── Wearpos Display Names ───────────────────────────────────────────────────
const WEARPOS_NAMES: Record<string, string> = {
'hat': 'Head',
'back': 'Back',
'front': 'Front',
'righthand': 'Weapon',
'torso': 'Body',
'lefthand': 'Shield',
'arms': 'Arms',
'legs': 'Legs',
'head': 'Head',
'hands': 'Hands',
'feet': 'Feet',
'jaw': 'Jaw',
'ring': 'Ring',
'quiver': 'Ammo',
};
// ─── Combat Stat Keys ────────────────────────────────────────────────────────
const ATTACK_STATS = ['stabattack', 'slashattack', 'crushattack', 'magicattack', 'rangeattack'];
const DEFENCE_STATS = ['stabdefence', 'slashdefence', 'crushdefence', 'magicdefence', 'rangedefence'];
const BONUS_STATS = ['strengthbonus', 'rangebonus', 'prayerbonus'];
const STAT_DISPLAY: Record<string, string> = {
'stabattack': 'Stab Attack',
'slashattack': 'Slash Attack',
'crushattack': 'Crush Attack',
'magicattack': 'Magic Attack',
'rangeattack': 'Range Attack',
'stabdefence': 'Stab Defence',
'slashdefence': 'Slash Defence',
'crushdefence': 'Crush Defence',
'magicdefence': 'Magic Defence',
'rangedefence': 'Range Defence',
'strengthbonus': 'Strength Bonus',
'rangebonus': 'Range Bonus',
'prayerbonus': 'Prayer Bonus',
'levelrequire': 'Level Requirement',
'attackrate': 'Attack Speed',
};
// ─── Parsers ─────────────────────────────────────────────────────────────────
function parseObjConfigs(content: string, sourcePath: string): Map<string, ItemConfig> {
const configs = new Map<string, ItemConfig>();
let current: ItemConfig | 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) configs.set(current.configName, current);
current = makeEmptyItemConfig(sectionMatch[1], 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();
switch (key) {
case 'name': current.name = value; break;
case 'desc': current.desc = value; break;
case 'cost': current.cost = parseInt(value) || 0; break;
case 'weight': current.weight = value; break;
case 'members': current.members = value === 'yes'; break;
case 'stackable': current.stackable = value === 'yes'; break;
case 'tradeable': current.tradeable = value; break;
case 'category': current.category = value; break;
case 'wearpos': current.wearpos = value; break;
case 'wearpos2': current.wearpos2 = value; break;
case 'wearpos3': current.wearpos3 = value; break;
case 'dummyitem': current.dummyitem = value; break;
case 'iop1': current.iops[0] = value; break;
case 'iop2': current.iops[1] = value; break;
case 'iop3': current.iops[2] = value; break;
case 'iop4': current.iops[3] = value; break;
case 'iop5': current.iops[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: {
const countMatch = key.match(/^count(\d+)$/);
if (countMatch) {
const parts = value.split(',');
if (parts.length >= 1) {
current.countRefs.push(parts[0]);
}
}
break;
}
}
}
if (current) configs.set(current.configName, current);
return configs;
}
function makeEmptyItemConfig(configName: string, sourcePath: string): ItemConfig {
return {
configName,
name: '',
desc: '',
cost: 0,
weight: '',
members: false,
stackable: false,
tradeable: '',
category: '',
wearpos: '',
wearpos2: '',
wearpos3: '',
dummyitem: '',
iops: ['', '', '', '', ''],
countRefs: [],
params: new Map(),
sourcePath,
};
}
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 slugify(name: string): string {
return name.toLowerCase()
.replace(/['']/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
// ─── Shop Data Parser (reparse .inv files for item cross-reference) ─────────
interface ShopStockEntry {
shopConfigName: string;
shopTitle: string;
itemConfigName: string;
quantity: number;
sellMultiplier: number; // shop_sell_multiplier (price when shop sells to player)
}
function parseShopData(): Map<string, ShopStockEntry[]> {
// Parse shop titles and multipliers from NPC files
interface ShopNpcInfo {
title: string;
sellMultiplier: number;
}
const shopInfo = new Map<string, ShopNpcInfo>();
const npcFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.npc');
for (const file of npcFiles) {
const content = readFileSync(file, 'utf-8');
let currentParams = new Map<string, string>();
function flushNpc() {
if (currentParams.has('owned_shop')) {
const shopName = currentParams.get('owned_shop')!;
if (!shopInfo.has(shopName)) {
shopInfo.set(shopName, {
title: (currentParams.get('shop_title') || prettifyConfigName(shopName)).replace(/\.$/, ''),
sellMultiplier: parseInt(currentParams.get('shop_sell_multiplier') || '1000'),
});
}
}
}
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('//') || line.startsWith('/ ')) continue;
if (line.match(/^\[([^\]]+)\]$/)) {
flushNpc();
currentParams = new Map();
continue;
}
if (line.startsWith('param=')) {
const val = line.slice(6);
const commaIdx = val.indexOf(',');
if (commaIdx !== -1) {
currentParams.set(val.slice(0, commaIdx), val.slice(commaIdx + 1));
}
}
}
flushNpc();
}
// Parse shop inventories
const itemToShops = new Map<string, ShopStockEntry[]>();
const invFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.inv');
for (const file of invFiles) {
const content = readFileSync(file, 'utf-8');
let currentShopName = '';
let isShop = false;
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (line.startsWith('//') || line.startsWith('/ ')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
currentShopName = sectionMatch[1];
isShop = false;
continue;
}
if (line === 'restock=yes') isShop = true;
if (line === 'scope=temp') isShop = false;
if (isShop && line.startsWith('stock')) {
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const value = line.slice(eqIdx + 1).trim();
const parts = value.split(',');
if (parts.length >= 2) {
const itemConfig = parts[0].trim();
const info = shopInfo.get(currentShopName);
const shopTitle = info?.title || prettifyConfigName(currentShopName);
const sellMultiplier = info?.sellMultiplier || 1000;
if (!itemToShops.has(itemConfig)) itemToShops.set(itemConfig, []);
itemToShops.get(itemConfig)!.push({
shopConfigName: currentShopName,
shopTitle,
itemConfigName: itemConfig,
quantity: parseInt(parts[1]) || 0,
sellMultiplier,
});
}
}
}
}
return itemToShops;
}
// ─── NPC Drop Data Parser (from generated NPC pages) ────────────────────────
function parseNpcDropData(): Map<string, NpcDropSource[]> {
const itemToDrops = new Map<string, NpcDropSource[]>();
if (!existsSync(NPC_DIR)) return itemToDrops;
const npcFiles = readdirSync(NPC_DIR).filter(f => f.endsWith('.md'));
for (const file of npcFiles) {
const content = readFileSync(join(NPC_DIR, file), 'utf-8');
const npcNameMatch = content.match(/^# (.+)$/m);
if (!npcNameMatch) continue;
const npcName = npcNameMatch[1];
const npcSlug = file.replace('.md', '');
// Parse "Always drops: ItemName" lines
const alwaysDrops = content.matchAll(/^Always drops: (.+?)(?:\s*\(x\d+\))?$/gm);
for (const match of alwaysDrops) {
const itemName = match[1].trim();
if (!itemToDrops.has(itemName)) itemToDrops.set(itemName, []);
itemToDrops.get(itemName)!.push({ npcName, npcSlug, rarity: 'Always' });
}
// Parse drop table rows: | ItemName (xQty) | N/D |
const dropRows = content.matchAll(/^\| (.+?) \| (\d+\/\d+(?:\s*\(members\))?) \|$/gm);
for (const match of dropRows) {
let itemName = match[1].trim();
const rarity = match[2].trim();
// Remove quantity suffix
itemName = itemName.replace(/\s*\(x[\d-]+\)$/, '');
if (itemName === 'Item') continue; // skip header
if (!itemToDrops.has(itemName)) itemToDrops.set(itemName, []);
// Avoid duplicate NPC entries for same item
const existing = itemToDrops.get(itemName)!;
if (!existing.some(d => d.npcSlug === npcSlug)) {
existing.push({ npcName, npcSlug, rarity });
}
}
}
return itemToDrops;
}
// ─── Map Spawn Parser ───────────────────────────────────────────────────────
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',
};
function parseObjSpawns(
mapsDir: string,
objIdToName: 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 area = MAP_SQUARE_AREAS[mapSquare] || mapSquare;
const content = readFileSync(join(mapsDir, file), 'utf-8');
const objSectionStart = content.indexOf('==== OBJ ====');
if (objSectionStart === -1) continue;
const afterObj = content.slice(objSectionStart + '==== OBJ ===='.length);
const nextSection = afterObj.indexOf('====');
const objBlock = nextSection === -1 ? afterObj : afterObj.slice(0, nextSection);
for (const line of objBlock.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
// Format: level localX localZ: itemID quantity
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+):\s*(\d+)\s+(\d+)$/);
if (!m) continue;
const level = parseInt(m[1]);
const objId = parseInt(m[4]);
const quantity = parseInt(m[5]);
const configName = objIdToName.get(objId);
if (!configName) continue;
if (!spawns.has(configName)) spawns.set(configName, []);
spawns.get(configName)!.push({ mapSquare, area, level, quantity });
}
}
return spawns;
}
// ─── Categorization ──────────────────────────────────────────────────────────
function categorizeItem(config: ItemConfig): { category: ItemCategory; equipSlot: string | null } {
const hasAttackStats = ATTACK_STATS.some(s => config.params.has(s));
const hasDefenceStats = DEFENCE_STATS.some(s => config.params.has(s));
const wearSlot = config.wearpos ? (WEARPOS_NAMES[config.wearpos] || config.wearpos) : null;
// Ammunition: stackable + quiver slot or arrow/bolt category
if (config.wearpos === 'quiver' || (config.stackable && config.category.includes('arrow')) ||
(config.stackable && config.category.includes('bolt')) ||
(config.stackable && config.category.includes('dart')) ||
(config.stackable && config.category.includes('javelin')) ||
(config.stackable && config.category.includes('throwing'))) {
if (hasAttackStats || config.wearpos === 'quiver') {
return { category: 'Ammunition', equipSlot: 'Ammo' };
}
}
// Weapons: righthand + attack stats
if (config.wearpos === 'righthand' && hasAttackStats) {
return { category: 'Weapon', equipSlot: wearSlot };
}
// Armour: wearable + defence stats (not weapons)
if (config.wearpos && config.wearpos !== 'righthand' && (hasDefenceStats || hasAttackStats)) {
return { category: 'Armour', equipSlot: wearSlot };
}
// Runes
if (config.category.includes('rune') || config.sourcePath.includes('runes')) {
return { category: 'Rune', equipSlot: null };
}
// Food: iop1=Eat
if (config.iops[0] === 'Eat') {
return { category: 'Food', equipSlot: null };
}
// Potions: iop1=Drink
if (config.iops[0] === 'Drink') {
return { category: 'Potion', equipSlot: null };
}
// Quest items: from quest paths + untradeable
if (config.sourcePath.includes('quest') && config.tradeable === 'no') {
return { category: 'Quest Item', equipSlot: null };
}
// Tools: axes, pickaxes, etc. that are wieldable
if (config.wearpos === 'righthand' && !hasAttackStats) {
return { category: 'Tool', equipSlot: wearSlot };
}
// Equipable items without combat stats (cosmetic/utility)
if (config.wearpos) {
return { category: 'Armour', equipSlot: wearSlot };
}
return { category: 'Other', equipSlot: null };
}
function hasCombatStats(config: ItemConfig): boolean {
return [...ATTACK_STATS, ...DEFENCE_STATS, ...BONUS_STATS].some(s => config.params.has(s));
}
// ─── Main Pipeline ───────────────────────────────────────────────────────────
function main() {
console.log('Item Wiki Generator');
console.log('===================\n');
// ─── Step 1: Parse all item configs ─────────────────────────────────
console.log('Step 1: Parsing item configs...');
const allConfigs = new Map<string, ItemConfig>();
// Parse all.obj (base configs)
const allObjContent = readFileSync(join(CONTENT_DIR, 'scripts', '_unpack', '225', 'all.obj'), 'utf-8');
const baseConfigs = parseObjConfigs(allObjContent, 'scripts/_unpack/225/all.obj');
for (const [name, config] of baseConfigs) allConfigs.set(name, config);
console.log(` all.obj: ${baseConfigs.size} configs`);
// Parse extra .obj files (override base)
const objFiles = findFilesRecursive(join(CONTENT_DIR, 'scripts'), '.obj');
let extraCount = 0;
for (const file of objFiles) {
if (file.includes('_unpack')) continue;
const content = readFileSync(file, 'utf-8');
const relPath = relative(CONTENT_DIR, file);
const configs = parseObjConfigs(content, relPath);
for (const [name, config] of configs) {
const existing = allConfigs.get(name);
if (existing) {
// Merge: extra overrides specific fields
if (config.name) existing.name = config.name;
if (config.desc) existing.desc = config.desc;
if (config.cost) existing.cost = config.cost;
if (config.weight) existing.weight = config.weight;
if (config.members) existing.members = config.members;
if (config.stackable) existing.stackable = config.stackable;
if (config.tradeable) existing.tradeable = config.tradeable;
if (config.category) existing.category = config.category;
if (config.wearpos) existing.wearpos = config.wearpos;
if (config.wearpos2) existing.wearpos2 = config.wearpos2;
if (config.wearpos3) existing.wearpos3 = config.wearpos3;
if (config.dummyitem) existing.dummyitem = config.dummyitem;
for (let i = 0; i < 5; i++) {
if (config.iops[i]) existing.iops[i] = config.iops[i];
}
for (const [k, v] of config.params) existing.params.set(k, v);
if (config.sourcePath !== 'scripts/_unpack/225/all.obj') {
existing.sourcePath = config.sourcePath;
}
} else {
allConfigs.set(name, config);
}
extraCount++;
}
}
console.log(` Extra .obj files: ${extraCount} configs from ${objFiles.length - 1} files`);
console.log(` Total configs: ${allConfigs.size}`);
// ─── Step 2: Build set of count variant refs (visual-only items) ────
const countVariantRefs = new Set<string>();
for (const [, config] of allConfigs) {
for (const ref of config.countRefs) {
countVariantRefs.add(ref);
}
}
console.log(` Count variant refs: ${countVariantRefs.size}`);
// ─── Step 3: Parse pack file ───────────────────────────────────────
console.log('\nStep 2: Loading pack file...');
const objPackContent = readFileSync(join(CONTENT_DIR, 'pack', 'obj.pack'), 'utf-8');
const certConfigNames = new Set<string>();
const objIdToName = new Map<number, string>();
for (const line of objPackContent.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) {
objIdToName.set(id, name);
if (name.startsWith('cert_')) {
certConfigNames.add(name);
}
}
}
console.log(` Pack entries: ${objIdToName.size}, cert names: ${certConfigNames.size}`);
// ─── Step 4: Get tutorial item config names ─────────────────────────
const tutorialObjFile = join(CONTENT_DIR, 'scripts', 'tutorial', 'configs', 'tutorial.obj');
const tutorialItems = new Set<string>();
if (existsSync(tutorialObjFile)) {
const content = readFileSync(tutorialObjFile, 'utf-8');
const configs = parseObjConfigs(content, 'tutorial');
for (const name of configs.keys()) tutorialItems.add(name);
}
console.log(` Tutorial items: ${tutorialItems.size}`);
// ─── Step 5: Filter items ───────────────────────────────────────────
console.log('\nStep 3: Filtering items...');
const filteredConfigs = new Map<string, ItemConfig>();
let filtered = 0;
const filterReasons = new Map<string, number>();
function filterOut(reason: string) {
filtered++;
filterReasons.set(reason, (filterReasons.get(reason) || 0) + 1);
}
for (const [name, config] of allConfigs) {
// No name
if (!config.name) { filterOut('no name'); continue; }
// Cert/noted variants
if (certConfigNames.has(name)) { filterOut('cert'); continue; }
if (name.startsWith('cert_')) { filterOut('cert prefix'); continue; }
// Tutorial items
if (tutorialItems.has(name)) { filterOut('tutorial'); continue; }
// Internal dummy rendering variants (count refs)
if (countVariantRefs.has(name)) { filterOut('count variant'); continue; }
// Dummyitem (visual-only internal items)
if (config.dummyitem === 'inv_only') { filterOut('dummyitem'); continue; }
// Template items (cert template)
if (name === 'template_for_cert') { filterOut('cert template'); continue; }
filteredConfigs.set(name, config);
}
console.log(` Filtered out ${filtered} items:`);
for (const [reason, count] of [...filterReasons.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${reason}: ${count}`);
}
console.log(` Remaining: ${filteredConfigs.size} items`);
// ─── Step 6: Deduplicate by display name ────────────────────────────
console.log('\nStep 4: Deduplicating by display name...');
const pagesByName = new Map<string, ItemConfig[]>();
for (const [, config] of filteredConfigs) {
if (!pagesByName.has(config.name)) {
pagesByName.set(config.name, []);
}
pagesByName.get(config.name)!.push(config);
}
// For items with same display name, pick the best config (prefer canonical versions)
const bestConfigs = new Map<string, ItemConfig>();
for (const [name, configs] of pagesByName) {
// Sort: prefer non-quest, then with stats, then with wearpos, then higher cost
configs.sort((a, b) => {
// Prefer non-quest paths over quest paths
const aQuest = a.sourcePath.includes('quest') ? 1 : 0;
const bQuest = b.sourcePath.includes('quest') ? 1 : 0;
if (aQuest !== bQuest) return aQuest - bQuest;
// Prefer tradeable over untradeable
const aUntradeable = a.tradeable === 'no' ? 1 : 0;
const bUntradeable = b.tradeable === 'no' ? 1 : 0;
if (aUntradeable !== bUntradeable) return aUntradeable - bUntradeable;
// Prefer items with combat stats
const aStats = hasCombatStats(a) ? 1 : 0;
const bStats = hasCombatStats(b) ? 1 : 0;
if (aStats !== bStats) return bStats - aStats;
// Prefer equipable items
const aWear = a.wearpos ? 1 : 0;
const bWear = b.wearpos ? 1 : 0;
if (aWear !== bWear) return bWear - aWear;
// Higher cost as tiebreaker
return b.cost - a.cost;
});
bestConfigs.set(name, configs[0]);
}
console.log(` Deduplicated to ${bestConfigs.size} unique items`);
// ─── Step 7: Parse shop and drop data ───────────────────────────────
console.log('\nStep 5: Parsing shop and NPC drop data...');
const itemToShops = parseShopData();
console.log(` Items sold in shops: ${itemToShops.size}`);
const itemToDrops = parseNpcDropData();
console.log(` Items dropped by NPCs: ${itemToDrops.size}`);
// Parse ground item spawns from map files
const itemSpawns = parseObjSpawns(join(CONTENT_DIR, 'maps'), objIdToName);
console.log(` Items with ground spawns: ${itemSpawns.size}`);
// ─── Step 8: Build item pages ───────────────────────────────────────
console.log('\nStep 6: Building item pages...');
const itemPages: ItemPage[] = [];
for (const [displayName, config] of bestConfigs) {
const { category, equipSlot } = categorizeItem(config);
// Find shop sources for this item (by config name, check all configs with this display name)
const shops: ShopSource[] = [];
const variants = pagesByName.get(displayName) || [config];
for (const variant of variants) {
const shopEntries = itemToShops.get(variant.configName);
if (shopEntries) {
for (const entry of shopEntries) {
if (!shops.some(s => s.shopSlug === slugify(entry.shopTitle))) {
// Compute buy price: item.cost * sellMultiplier / 1000 (min 1)
const baseCost = config.cost;
const buyPrice = Math.max(1, Math.floor(baseCost * entry.sellMultiplier / 1000));
shops.push({
shopTitle: entry.shopTitle,
shopSlug: slugify(entry.shopTitle),
buyPrice,
});
}
}
}
}
// Find NPC drop sources (by display name)
const drops = itemToDrops.get(displayName) || [];
// Find ground spawns (by config name, check all variants)
const spawns: SpawnLocation[] = [];
const seenSpawnAreas = new Set<string>();
for (const variant of variants) {
const variantSpawns = itemSpawns.get(variant.configName);
if (variantSpawns) {
for (const spawn of variantSpawns) {
const key = `${spawn.mapSquare}_${spawn.level}`;
if (!seenSpawnAreas.has(key)) {
seenSpawnAreas.add(key);
spawns.push(spawn);
}
}
}
}
itemPages.push({
displayName,
config,
category,
equipSlot,
shops,
drops,
spawns,
});
}
// Sort alphabetically
itemPages.sort((a, b) => a.displayName.localeCompare(b.displayName));
console.log(` Built ${itemPages.length} item pages`);
// Category breakdown
const catCounts = new Map<string, number>();
for (const page of itemPages) {
catCounts.set(page.category, (catCounts.get(page.category) || 0) + 1);
}
for (const [cat, count] of [...catCounts.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${cat}: ${count}`);
}
// ─── Step 9: Generate markdown ──────────────────────────────────────
console.log('\nStep 7: Generating markdown...');
mkdirSync(ITEM_DIR, { recursive: true });
const pageIndex: { name: string; file: string; category: ItemCategory }[] = [];
for (const page of itemPages) {
const slug = slugify(page.displayName);
const fileName = `${slug}.md`;
const filePath = join(ITEM_DIR, fileName);
const md = generateItemMarkdown(page);
writeFileSync(filePath, md);
pageIndex.push({ name: page.displayName, file: fileName, category: page.category });
}
console.log(` Generated ${pageIndex.length} item pages`);
// ─── Step 10: Update README ─────────────────────────────────────────
console.log('\nStep 8: Updating README...');
updateReadme(pageIndex);
console.log('\nDone!');
}
// ─── Generate Markdown ───────────────────────────────────────────────────────
function generateItemMarkdown(page: ItemPage): string {
const lines: string[] = [];
const config = page.config;
const isEquipment = page.equipSlot !== null;
lines.push(`# ${page.displayName}`);
lines.push('');
if (config.desc) {
lines.push(`*${config.desc}*`);
lines.push('');
}
// Info table
lines.push('| Detail | |');
lines.push('|---|---|');
lines.push(`| **Type** | ${page.category} |`);
lines.push(`| **Members** | ${config.members ? 'Yes' : 'No'} |`);
if (config.weight) {
lines.push(`| **Weight** | ${config.weight} |`);
}
lines.push(`| **Value** | ${config.cost} gp |`);
if (isEquipment) {
lines.push(`| **Equipable** | Yes — ${page.equipSlot} |`);
}
if (config.stackable) {
lines.push('| **Stackable** | Yes |');
}
if (config.tradeable === 'no') {
lines.push('| **Tradeable** | No |');
}
lines.push('');
// Combat stats for equipment
if (isEquipment && hasCombatStats(config)) {
lines.push('## Combat Stats');
lines.push('');
lines.push('| Stat | Value |');
lines.push('|------|-------|');
for (const stat of [...ATTACK_STATS, ...DEFENCE_STATS, ...BONUS_STATS]) {
const value = config.params.get(stat);
if (value !== undefined) {
const numVal = parseInt(value);
const prefix = numVal > 0 ? '+' : '';
lines.push(`| ${STAT_DISPLAY[stat]} | ${prefix}${value} |`);
}
}
// Level requirement
const levelReq = config.params.get('levelrequire');
if (levelReq && levelReq !== '0') {
lines.push(`| ${STAT_DISPLAY['levelrequire']} | ${levelReq} |`);
}
// Attack speed
const attackRate = config.params.get('attackrate');
if (attackRate) {
lines.push(`| ${STAT_DISPLAY['attackrate']} | ${attackRate} |`);
}
lines.push('');
}
// Spawn locations section
if (page.spawns.length > 0) {
lines.push('## Spawn Locations');
lines.push('');
lines.push('| Area | Floor | Quantity |');
lines.push('|------|-------|----------|');
// Group by area + level, sum quantities
const byAreaLevel = new Map<string, { area: string; level: number; count: number }>();
for (const spawn of page.spawns) {
const key = `${spawn.area}_${spawn.level}`;
if (!byAreaLevel.has(key)) {
byAreaLevel.set(key, { area: spawn.area, level: spawn.level, count: 0 });
}
byAreaLevel.get(key)!.count += spawn.quantity;
}
const sortedAreas = [...byAreaLevel.values()].sort((a, b) => a.area.localeCompare(b.area));
for (const data of sortedAreas) {
const floorStr = data.level === 0 ? 'Ground' : `Floor ${data.level}`;
lines.push(`| ${data.area} | ${floorStr} | ${data.count} |`);
}
lines.push('');
}
// Sources section
if (page.shops.length > 0 || page.drops.length > 0) {
lines.push('## Sources');
lines.push('');
for (const shop of page.shops) {
lines.push(`- Sold by: [${shop.shopTitle}](../shops/${shop.shopSlug}.md) for ${shop.buyPrice} gp`);
}
for (const drop of page.drops) {
lines.push(`- Dropped by: [${drop.npcName}](../npcs/${drop.npcSlug}.md) (${drop.rarity})`);
}
lines.push('');
}
return lines.join('\n');
}
// ─── Update README ───────────────────────────────────────────────────────────
function updateReadme(pages: { name: string; file: string; category: ItemCategory }[]) {
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 Items section if present
const itemsStart = content.indexOf('## Items');
if (itemsStart !== -1) {
const afterItems = content.slice(itemsStart + 8);
const nextSection = afterItems.search(/\n## /);
if (nextSection !== -1) {
content = content.slice(0, itemsStart) + content.slice(itemsStart + 8 + nextSection + 1);
} else {
content = content.slice(0, itemsStart).trimEnd() + '\n';
}
}
// Sort alphabetically
pages.sort((a, b) => a.name.localeCompare(b.name));
// Generate Items section
const section = [
'## Items',
'',
'| Item | Type |',
'|------|------|',
];
for (const page of pages) {
section.push(`| [${page.name}](items/${page.file}) | ${page.category} |`);
}
section.push('');
// Insert before Shops section if it exists, otherwise before NPCs, otherwise append
const shopsStart = content.indexOf('## Shops');
if (shopsStart !== -1) {
content = content.slice(0, shopsStart) + section.join('\n') + '\n' + content.slice(shopsStart);
} else {
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} item entries`);
}
// ─── Run ─────────────────────────────────────────────────────────────────────
main();