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.
This commit is contained in:
2026-09-13 17:05:02 +08:00
parent acf1b2cf95
commit 52e08d25e4
39 changed files with 2341 additions and 0 deletions
@@ -0,0 +1,171 @@
/**
* 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}`);
}),
);