// Regenerates src/data/*.json from the KCD2 "Cheat" mod repository // (https://github.com/pryans/kcd2-cheat — source of the Nexus "Cheat" mod). // // Usage: node scripts/fetch-data.mjs // // Output is committed to the repo so builds are offline; re-run to refresh // when the mod updates its item/perk/buff/skill dumps or command docs. import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const BASE = 'https://raw.githubusercontent.com/pryans/kcd2-cheat/main/docs/'; const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'data'); async function fetchText(name) { const res = await fetch(BASE + name); if (!res.ok) throw new Error(`Fetching ${name} failed: HTTP ${res.status}`); return (await res.text()).replaceAll('\r\n', '\n'); } // The mod's CSV dialect: fields are double-quoted (header and empty trailing // fields are bare), commas inside fields are backslash-escaped, and quoted // fields may contain literal newlines. function parseCsv(text) { const rows = []; let row = []; let field = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '\\' && i + 1 < text.length) { field += text[++i]; } else if (c === '"') { inQuotes = false; } else { field += c; } } else if (c === '"') { inQuotes = true; } else if (c === ',') { row.push(field); field = ''; } else if (c === '\n') { row.push(field); if (row.some((f) => f !== '')) rows.push(row); row = []; field = ''; } else { field += c; } } row.push(field); if (row.some((f) => f !== '')) rows.push(row); return rows.slice(1); // drop header } // Skill/perk descriptions are HTML-entity-encoded rich text; flatten to plain // text with paragraph breaks. function cleanText(s) { return s .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(''', "'") .replaceAll('&', '&') .replace(/<\/p>\s*

/g, '\n\n') .replace(//g, '\n') .replace(/<[^>]+>/g, '') .trim(); } const CATEGORY_RULES = [ [/money/, 'Money'], [/item|inventory|stash|gear|stolen/, 'Items & Inventory'], [/skill|stat/, 'Skills & Stats'], [/perk/, 'Perks'], [/buff|potion/, 'Buffs'], [/horse/, 'Horse'], [/teleport|_loc$|clip|phys_|checkpoint/, 'Movement & Teleport'], [/npc|kill|spawn|charm|target|revive/, 'NPCs'], [/time|weather|map/, 'World & Time'], [/lockpick|pickpocket/, 'Minigames'], [/hud|compass|statusbar|reticle|third_person|regen|wash|save|state/, 'Player & UI'], [/action|alias|localization|find_/, 'Utility'], ]; function categorize(name) { for (const [re, cat] of CATEGORY_RULES) if (re.test(name)) return cat; return 'Misc'; } function stripBbcode(s) { return s.replace(/\[\/?(?:b|i|u|size|color|url)(?:=[^\]]*)?\]/g, '').trim(); } // docs.txt: intro + command index, then one block per command: // [size=4][b]name[/b][/size] // description lines // [b]Arguments:[/b] (optional section, tab-indented "arg: (required type) desc") // [b]Examples:[/b] (optional section, tab-indented caption/command groups) function parseCommandDocs(text) { const version = text.match(/Cheat version ([\d.]+)/)?.[1] ?? null; const commands = []; const blockRe = /^\[size=4\]\[b\](\w+)\[\/b\]\[\/size\]$/gm; const headers = [...text.matchAll(blockRe)]; for (let h = 0; h < headers.length; h++) { const name = headers[h][1]; const start = headers[h].index + headers[h][0].length; const end = h + 1 < headers.length ? headers[h + 1].index : text.length; const lines = text.slice(start, end).split('\n'); let section = 'desc'; const descLines = []; const args = []; const examples = []; let example = null; for (const rawLine of lines) { const line = rawLine.replace(/\s+$/, ''); if (/^\[b\]Arguments:\[\/b\]$/.test(line)) { section = 'args'; continue; } if (/^\[b\]Examples:\[\/b\]$/.test(line)) { section = 'examples'; continue; } if (section === 'desc') { descLines.push(stripBbcode(line)); } else if (section === 'args') { const m = line.match(/^\t(\w+): \((required|optional)(?: (\w+))?\)\s*(.*)$/); if (m) args.push({ name: m[1], required: m[2] === 'required', type: m[3] ?? 'string', desc: m[4] }); } else if (section === 'examples') { const t = line.replace(/^\t/, ''); if (t === '') { if (example?.command) { examples.push(example); example = null; } } else if (t.startsWith(name)) { example ??= { caption: '' }; example.command = t; } else { example ??= { caption: '' }; example.caption = (example.caption ? example.caption + ' ' : '') + t.replace(/:$/, ''); } } } if (example?.command) examples.push(example); commands.push({ name, desc: descLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), args, examples, category: categorize(name), source: 'cheatmod', }); } return { version, commands }; } async function writeJson(name, data) { await writeFile(join(OUT_DIR, name), JSON.stringify(data)); console.log(`${name}: ${Array.isArray(data) ? data.length : data.commands?.length ?? ''} records`); } await mkdir(OUT_DIR, { recursive: true }); const [itemsCsv, perksCsv, buffsCsv, skillsCsv, docsTxt] = await Promise.all( ['items.csv', 'perks.csv', 'buffs.csv', 'skills.csv', 'docs.txt'].map(fetchText), ); const items = parseCsv(itemsCsv).map(([id, name, desc]) => ({ id, name, desc: cleanText(desc ?? '') })); const perks = parseCsv(perksCsv).map(([id, name, desc]) => ({ id, name, desc: cleanText(desc ?? '') })); const buffs = parseCsv(buffsCsv).map(([id, name, params, desc]) => ({ id, name, params: params ?? '', desc: cleanText(desc ?? '') })); const skills = parseCsv(skillsCsv).map(([id, name, desc]) => ({ id, name, desc: cleanText(desc ?? '') })); const { version, commands } = parseCommandDocs(docsTxt); await writeJson('items.json', items); await writeJson('perks.json', perks); await writeJson('buffs.json', buffs); await writeJson('skills.json', skills); await writeJson('commands-mod.json', commands); await writeJson('meta.json', { source: 'https://github.com/pryans/kcd2-cheat', cheatModDocsVersion: version, fetchedAt: new Date().toISOString(), });