Files
hypercross 52e08d25e4 feat(games): add cloud-empress game package
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.
2026-09-13 17:05:02 +08:00

172 lines
6.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`);
}),
);