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.
277 lines
9.3 KiB
JavaScript
277 lines
9.3 KiB
JavaScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { parseTable, rollExpr, rollTable, rollDie } from '@solo/oracle';
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
/** The four playable 职业, keyed by the tag on characters/backgrounds.csv. */
|
||
const CLASSES = {
|
||
sellsword: {
|
||
name: '佣兵',
|
||
saves: { 躯体: 30 },
|
||
skills: '刀剑 或 火器、街头智慧,另选任意 2 项技能',
|
||
state: '当你附近的盟友看到你恐慌时,他们必须进行恐惧豁免,否则也会恐慌。',
|
||
},
|
||
lordschild: {
|
||
name: '领主裔',
|
||
saves: { 恐惧: 40, 现实: -10 },
|
||
skills: '决斗、礼仪,另选任意 2 项技能',
|
||
state: '你令人感到不安;你附近的盟友在进行恐惧豁免时具有劣势。',
|
||
},
|
||
mage: {
|
||
name: '魔法师',
|
||
saves: { 现实: 20 },
|
||
skills: '神秘学、施法,另选任意 2 项技能',
|
||
state: '当你未通过现实豁免时,你附近的盟友承受 1 点压力。',
|
||
forbiddenAge: '老年',
|
||
},
|
||
courier: {
|
||
name: '信使',
|
||
saves: { 现实: 10, 恐惧: 10, 躯体: 10 },
|
||
skills: '搜刮、修补工匠,另选任意 2 项技能',
|
||
state: '你见多识广:每场游戏一次,你可以带着优势进行任意一次属性检定或豁免检定。',
|
||
},
|
||
};
|
||
|
||
const AGES = [
|
||
{ min: 1, max: 3, name: '青少年', mods: { 心灵: 20, 心智: -10 }, note: '' },
|
||
{ min: 4, max: 7, name: '成年', mods: { 速度: 20, 心灵: -10 }, note: '' },
|
||
{ min: 8, max: 9, name: '老年', mods: { 心智: 20, 力量: -10 }, note: '没有老年的魔法师。' },
|
||
{
|
||
min: 10,
|
||
max: 10,
|
||
name: '换躯者',
|
||
mods: { 力量: 30, 速度: -10 },
|
||
note: '获得法术「不完美的躯体转移」;换躯者不能被诅咒。',
|
||
},
|
||
];
|
||
|
||
function gameDir(ctx) {
|
||
if (!ctx.game) {
|
||
throw new Error('Command needs the cloud-empress game; pass --campaign <name> or --game cloud-empress');
|
||
}
|
||
return ctx.game.dir;
|
||
}
|
||
|
||
function readCsv(file) {
|
||
return parseTable(fs.readFileSync(file, 'utf8'));
|
||
}
|
||
|
||
function characterTable(ctx, file) {
|
||
return readCsv(path.join(gameDir(ctx), 'characters', file));
|
||
}
|
||
|
||
/** Roll one entry and print it in the CLI's standard shape. */
|
||
function printRoll(prefix, entries) {
|
||
const { roll, entry } = rollTable(entries);
|
||
const tags = entry.tags.length ? ` [${entry.tags.join(', ')}]` : '';
|
||
const label = roll === null ? 'tagged' : `roll ${roll}`;
|
||
console.log(`${prefix} ${label} → "${entry.text}"${tags}`);
|
||
return entry;
|
||
}
|
||
|
||
function rollOn(ctx, table, prefix = table) {
|
||
const { entries } = ctx.findTable(table);
|
||
return printRoll(prefix, entries);
|
||
}
|
||
|
||
/** Draw one entry from a table without printing it. */
|
||
function draw(ctx, table) {
|
||
const { entries } = ctx.findTable(table);
|
||
return rollTable(entries).entry;
|
||
}
|
||
|
||
/** 1d100 read as two d10; 00 reads as 100. */
|
||
function rollD100() {
|
||
const tens = rollDie(10) - 1;
|
||
const ones = rollDie(10) - 1;
|
||
return { tens, ones, value: tens * 10 + ones, doubles: tens === ones };
|
||
}
|
||
|
||
function showD100(r) {
|
||
const display = r.value === 0 ? '100' : String(r.value).padStart(2, '0');
|
||
return `${display}(${r.tens},${r.ones})`;
|
||
}
|
||
|
||
function flagCount(value) {
|
||
if (value === undefined) return 0;
|
||
if (value === true) return 1;
|
||
const n = Number(value);
|
||
return Number.isFinite(n) && n > 0 ? n : 1;
|
||
}
|
||
|
||
function background(args, ctx) {
|
||
printRoll('background', characterTable(ctx, 'backgrounds.csv'));
|
||
}
|
||
|
||
function loadout(args, ctx) {
|
||
const key = args.positional[0];
|
||
if (!key || !CLASSES[key]) {
|
||
throw new Error(`Usage: journal loadout <${Object.keys(CLASSES).join('|')}>`);
|
||
}
|
||
printRoll(`loadout ${key}`, characterTable(ctx, `${key}.csv`));
|
||
}
|
||
|
||
function character(args, ctx) {
|
||
const baseStats = {};
|
||
for (const stat of ['心智', '心灵', '力量', '速度']) baseStats[stat] = rollExpr('2d10+20').total;
|
||
|
||
const bgEntry = rollTable(characterTable(ctx, 'backgrounds.csv')).entry;
|
||
const bgId = bgEntry.tags[0];
|
||
const klass = CLASSES[bgId];
|
||
|
||
let ageRoll;
|
||
let age;
|
||
do {
|
||
ageRoll = rollDie(10);
|
||
age = AGES.find((a) => ageRoll >= a.min && ageRoll <= a.max);
|
||
} while (klass.forbiddenAge === age.name);
|
||
|
||
const stats = { ...baseStats };
|
||
for (const [key, value] of Object.entries(age.mods)) stats[key] += value;
|
||
|
||
const saves = {};
|
||
for (const save of ['现实', '恐惧', '躯体']) saves[save] = rollExpr('1d10+20').total;
|
||
for (const [key, value] of Object.entries(klass.saves)) saves[key] += value;
|
||
|
||
const kit = rollTable(characterTable(ctx, `${bgId}.csv`)).entry;
|
||
const trinket = draw(ctx, 'trinkets');
|
||
const name = draw(ctx, 'names');
|
||
const alias = draw(ctx, 'aliases');
|
||
|
||
console.log('');
|
||
console.log(`background ${bgEntry.text}`);
|
||
console.log(`age roll ${ageRoll} → ${age.name}${age.note ? ` ${age.note}` : ''}`);
|
||
console.log(`stats ${Object.entries(stats).map(([k, v]) => `${k} ${v}`).join(' ')}`);
|
||
console.log(`saves ${Object.entries(saves).map(([k, v]) => `${k} ${v}`).join(' ')}`);
|
||
console.log(`skills ${klass.skills}`);
|
||
console.log(`class state ${klass.state}`);
|
||
console.log(`kit ${kit.text}`);
|
||
console.log(`trinket ${trinket.text}`);
|
||
console.log(`name ${name.text} alias ${alias.text}`);
|
||
|
||
console.log('');
|
||
console.log('PC sheet frontmatter:');
|
||
console.log('---');
|
||
console.log(`name: ${name.text}(${alias.text})`);
|
||
console.log('game: cloud-empress');
|
||
console.log('stats:');
|
||
for (const [key, value] of Object.entries(stats)) console.log(` ${key}: ${value}`);
|
||
console.log('saves:');
|
||
for (const [key, value] of Object.entries(saves)) console.log(` ${key}: ${value}`);
|
||
console.log(`age: ${age.name}`);
|
||
console.log(`background: ${klass.name}`);
|
||
console.log('stress: 0');
|
||
console.log('tracks:');
|
||
console.log(' harm: { value: 0, max: 10 }');
|
||
console.log(' wounds: { value: 0, max: 3 }');
|
||
console.log('conditions: []');
|
||
console.log('inventory: []');
|
||
console.log('---');
|
||
}
|
||
|
||
function sigil(args, ctx) {
|
||
rollOn(ctx, 'sigils');
|
||
}
|
||
|
||
function spell(args, ctx) {
|
||
rollOn(ctx, 'spells');
|
||
}
|
||
|
||
function mishap(args, ctx) {
|
||
rollOn(ctx, 'mishaps');
|
||
}
|
||
|
||
function panic(args, ctx) {
|
||
const stress = Number(args.positional[0]);
|
||
if (!Number.isInteger(stress)) throw new Error('Usage: journal panic <current-stress>');
|
||
|
||
const roll = rollDie(10);
|
||
if (roll > stress) {
|
||
console.log(`panic d10=${roll} > 压力 ${stress} → 成功,保持镇定`);
|
||
return;
|
||
}
|
||
|
||
const { entries } = ctx.findTable('panics');
|
||
const entry = entries.find((e) => roll >= e.min && roll <= e.max);
|
||
console.log(`panic d10=${roll} ≤ 压力 ${stress} → 恐慌`);
|
||
console.log(` → ${entry.text}`);
|
||
}
|
||
|
||
function test(args, ctx) {
|
||
const target = Number(args.positional[0]);
|
||
if (!Number.isFinite(target)) {
|
||
throw new Error('Usage: journal test <target> [--advantage[=n]] [--disadvantage[=n]] [--label <name>]');
|
||
}
|
||
|
||
const net = flagCount(args.flags.advantage) - flagCount(args.flags.disadvantage);
|
||
const label = typeof args.flags.label === 'string' ? `${args.flags.label} ` : '';
|
||
|
||
if (net >= 2) {
|
||
console.log(`test ${label}目标 ${target} → 自动成功(一个以上的优势)`);
|
||
return;
|
||
}
|
||
if (net <= -2) {
|
||
console.log(`test ${label}目标 ${target} → 自动失败(一个以上的劣势),承受 1 点压力`);
|
||
return;
|
||
}
|
||
|
||
const rolls = net === 0 ? [rollD100()] : [rollD100(), rollD100()];
|
||
const succeeded = (r) => r.value !== 0 && r.value < target;
|
||
const success = net > 0 ? rolls.some(succeeded) : net < 0 ? rolls.every(succeeded) : succeeded(rolls[0]);
|
||
const agreeing = rolls.filter((r) => succeeded(r) === success);
|
||
const repr = agreeing.find((r) => r.doubles) ?? agreeing[0] ?? rolls[0];
|
||
|
||
const shown = rolls.map(showD100).join(' | ');
|
||
console.log(`test ${label}目标 ${target} d100 ${shown} → ${success ? '成功' : '失败'}`);
|
||
|
||
if (repr.doubles) {
|
||
if (success) console.log(' 极值:大成功。');
|
||
else console.log(' 极值:大失败。另做一次恐慌检定。');
|
||
}
|
||
if (!success) console.log(' 承受 1 点压力。');
|
||
}
|
||
|
||
export default {
|
||
id: 'cloud-empress',
|
||
title: '云之女帝 Cloud Empress',
|
||
description:
|
||
'生态科学奇幻生存恐怖。d100 低掷检定、压力与恐慌、白垩魔法与骨皮纹章,在巨蝉统治的余世中旅行。',
|
||
rules: 'game.md',
|
||
commands: {
|
||
background: {
|
||
description: '掷一个职业(佣兵 / 领主裔 / 魔法师 / 信使)。',
|
||
run: background,
|
||
},
|
||
loadout: {
|
||
description: '掷某职业 1d10 的背景故事与起始装备:journal loadout <sellsword|lordschild|mage|courier>',
|
||
run: loadout,
|
||
},
|
||
character: {
|
||
description: '生成一名完整 PC:属性、豁免、年龄、职业、装备、随身物、姓名与绰号。',
|
||
run: character,
|
||
},
|
||
sigil: {
|
||
description: '掷一个随机纹章(1d28)。',
|
||
run: sigil,
|
||
},
|
||
spell: {
|
||
description: '掷一个随机法术(1d27)。',
|
||
run: spell,
|
||
},
|
||
mishap: {
|
||
description: '掷施法失误表(1d10)。',
|
||
run: mishap,
|
||
},
|
||
panic: {
|
||
description: '恐慌检定:journal panic <当前压力>,d10 大于压力即成功。',
|
||
run: panic,
|
||
},
|
||
test: {
|
||
description: '属性/豁免检定(低掷 d100):journal test <目标值> [--advantage[=n]] [--disadvantage[=n]] [--label <名称>]',
|
||
run: test,
|
||
},
|
||
},
|
||
};
|