Transcribe the six core-rulebook chapters into rules/, with every rollable table extracted into tables/ so the CSVs are the single source of truth: 21 tables plus the four starting kits under characters/. Chapters keep only non-dice reference charts and the age table. Add eight commands: background, loadout, character, sigil, spell, mishap, panic, and test (d100 under-roll with advantage and disadvantage, including the doubles-as-extremes rule). weather and encounters are package-authored stand-ins for the setting-book tables; sparks and complication-themes are original.
172 lines
6.1 KiB
JavaScript
172 lines
6.1 KiB
JavaScript
/**
|
||
* One-off importer: transcribe the large Cloud Empress tables from the source
|
||
* rulebook chapters into `tables/*.csv`.
|
||
*
|
||
* Usage:
|
||
* node import-tables.mjs <chaptersDir> <tablesOutDir>
|
||
*
|
||
* 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 <chaptersDir> <tablesOutDir>');
|
||
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(/<br\s*\/?>/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(/<br\s*\/?>/gi, ';');
|
||
const aftermath = c[3].replace(/<br\s*\/?>/gi, ';');
|
||
return row(i + 1, i + 1, `${c[0]}(白垩 ${cost}):${effect} 后遗症:${aftermath}`, `chalk-${cost}`);
|
||
}),
|
||
);
|