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

277 lines
9.3 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
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,
},
},
};