/** * One-off importer: transcribe the large Cloud Empress tables from the source * rulebook chapters into `tables/*.csv`. * * Usage: * node import-tables.mjs * * The source chapters are markdown with pipe tables. This script only handles * the handful of tables that are too large or too comma-heavy to transcribe by * hand; the small tables were written directly and are not touched here. */ import fs from 'node:fs'; import path from 'node:path'; const [srcDir, outDir] = process.argv.slice(2); if (!srcDir || !outDir) { console.error('usage: node import-tables.mjs '); process.exit(1); } const read = (file) => fs.readFileSync(path.join(srcDir, file), 'utf8').replace(/\r\n/g, '\n'); const isRow = (line) => /^\s*\|/.test(line); const isSeparator = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line); const cells = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((s) => s.trim()); /** Collect the data rows of the first table whose header matches `match`. */ function tableAfter(text, match) { const lines = text.split('\n'); const header = lines.findIndex((l) => match(l)); if (header === -1) throw new Error(`header not found: ${match}`); const rows = []; for (let i = header + 1; i < lines.length; i++) { if (!isRow(lines[i])) break; if (isSeparator(lines[i])) continue; rows.push(cells(lines[i])); } return rows; } /** "00-02" | "89" | "0–1" -> [min, max] */ function parseRange(value) { const flat = value.replace(/[–—]/g, '-').replace(/\s/g, ''); let m = flat.match(/^(\d+)-(\d+)$/); if (m) return [Number(m[1]), Number(m[2])]; m = flat.match(/^(\d+)$/); if (m) return [Number(m[1]), Number(m[1])]; throw new Error(`bad range: ${value}`); } const quote = (value) => `"${String(value).replace(/"/g, '""')}"`; const row = (min, max, text, tags = '') => `${min},${max},${quote(text)},${tags}`; function write(name, rows, dir = outDir) { fs.writeFileSync(path.join(dir, name), `min,max,text,tags\n${rows.join('\n')}\n`); console.log(`${name}: ${rows.length} rows`); } const ch2 = read('02-角色创建.md'); const ch3 = read('03-核心规则机制.md'); const ch4 = read('04-纹章与魔法.md'); const ch6 = read('06-附录与参考表格.md'); // --- 1d100 name table: four 25-entry columns keyed by tens digit ------------- const NAME_COLS = [ ['农裔姓名', 'farm'], ['云裔姓名', 'cloud'], ['常见姓名', 'common'], ['古代姓名', 'ancient'], ]; const nameRows = tableAfter(ch2, (l) => l.includes('农裔姓名') && l.includes('古代姓名')); const names = new Map(); for (const c of nameRows) { if (c.length < 8) continue; for (let k = 0; k < 4; k++) { const n = Number(c[k * 2]); if (Number.isInteger(n) && c[k * 2 + 1]) names.set(n, { text: c[k * 2 + 1], tag: NAME_COLS[k][1] }); } } write( 'names.csv', [...names.keys()].sort((a, b) => a - b).map((n) => row(n, n, names.get(n).text, names.get(n).tag)), ); // --- alias table: four columns, one shared 0-99 range ------------------------ const aliasRows = tableAfter(ch2, (l) => l.includes('绰号 (Alias)')); const aliases = new Map(); for (const c of aliasRows) { if (c.length < 8) continue; for (let k = 0; k < 4; k++) { const n = Number(c[k * 2]); if (Number.isInteger(n) && c[k * 2 + 1] && !aliases.has(n)) aliases.set(n, c[k * 2 + 1]); } } write('aliases.csv', [...aliases.keys()].sort((a, b) => a - b).map((n) => row(n, n, aliases.get(n)))); // --- 1d100 equipment list (name + rarity) ------------------------------------ const gearRows = tableAfter(ch3, (l) => /^\|\s*1d100\s*\|\s*名称\s*\|\s*稀有度\s*\|/.test(l)); write( 'gear.csv', gearRows.map((c) => { const [min, max] = parseRange(c[0]); return row(min, max, c[1], c[2]); }), ); // --- 1d100 "what else do you carry" ------------------------------------------ const trinketRows = tableAfter(ch2, (l) => l.includes('你还随身携带着什么?') && l.includes('1d100')); write( 'trinkets.csv', trinketRows.map((c) => { const [min, max] = parseRange(c[0]); return row(min, max, c[1]); }), ); // --- background starting kits (four d10 tables) ------------------------------ const KITS = [ ['sellsword.csv', '佣兵装备', 'sellsword'], ['lordschild.csv', '领主裔装备', 'lordschild'], ['mage.csv', '魔法师装备', 'mage'], ['courier.csv', '信使装备', 'courier'], ]; const charactersDir = path.join(outDir, '..', 'characters'); for (const [file, label, tag] of KITS) { const rows = tableAfter(ch2, (l) => l.includes(label)); write( file, rows.map((c) => { const [min, max] = parseRange(c[0]); return row(min, max, `之前:${c[1]} 装备:${c[2]}`, tag); }), charactersDir, ); } // --- 1d100 "what do you find" (small / medium / large) ----------------------- const findRows = tableAfter(ch6, (l) => l.includes('小型物品') && l.includes('大型物品')); write( 'find.csv', findRows.map((c) => { const [min, max] = parseRange(c[0]); return row(min, max, `小型:${c[1]};中型:${c[2]};大型:${c[3]}`); }), ); // --- d28 sigils -------------------------------------------------------------- const sigilRows = tableAfter(ch4, (l) => l.includes('纹章名称') && l.includes('白垩消耗')); write( 'sigils.csv', sigilRows.map((c, i) => { const cost = Number(c[1]); const tier = c[0].includes('强效') ? 'powerful' : c[0].includes('标准') ? 'standard' : 'trinket'; const effect = c[2].replace(//gi, ';'); return row(i + 1, i + 1, `${c[0]}(白垩 ${cost}):${effect}`, `${tier};chalk-${cost}`); }), ); // --- d30 spells -------------------------------------------------------------- const spellRows = tableAfter(ch4, (l) => l.includes('法术名称') && l.includes('对施法者的后遗症')); write( 'spells.csv', spellRows.map((c, i) => { const cost = Number(c[1]); const effect = c[2].replace(//gi, ';'); const aftermath = c[3].replace(//gi, ';'); return row(i + 1, i + 1, `${c[0]}(白垩 ${cost}):${effect} 后遗症:${aftermath}`, `chalk-${cost}`); }), );