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 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 '); 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 [--advantage[=n]] [--disadvantage[=n]] [--label ]'); } 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 ', 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, }, }, };