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:
@@ -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}`);
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user