feat(cli): add the journal command

Resolves campaigns and their game packages, looks tables up in the
campaign before the game, and exposes a game's manifest commands as
journal subcommands.
This commit is contained in:
2026-09-13 16:41:16 +08:00
parent 02933a483d
commit 6607ab1db3
3 changed files with 603 additions and 0 deletions
+583
View File
@@ -0,0 +1,583 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
rollDie,
rollExpr,
oracle as rollOracle,
parseTable,
tableRange,
rollTable,
} from '@solo/oracle';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REQUIRED_DIRS = [
'world/locations',
'world/npcs',
'world/factions',
'characters',
'state',
'sessions',
];
const HELP = `journal — solo journaling tooling
Usage:
journal roll <table> [--campaign <name>] [--game <id>] [--tag <tag>] [--count <n>]
journal oracle [<question>] [--complicate] [--campaign <name>] [--game <id>] [--table <name>]
journal dice <expr>
journal tables [--campaign <name>] [--game <id>]
journal check [--campaign <name>] [--game <id>]
journal new-campaign <name> [--game <id>]
journal games
journal game [<id>]
journal <game-command> [...] provided by the campaign's game package
Context:
Tables are looked up in the campaign first, then the campaign's game.
Pass --campaign <name>, set JOURNAL_CAMPAIGN, or have exactly one campaign.
Pass --game <id> to work with a game without a campaign.
Examples:
journal roll sparks --campaign ashfall
journal oracle "Is the gate locked?" --complicate --campaign ashfall
journal background --campaign ashfall
journal dice 2d6+1
`;
/**
* @param {string[]} argv
* @returns {{ positional: string[], flags: Record<string, string | true> }}
*/
function parseArgs(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const [key, inline] = arg.slice(2).split('=');
if (inline !== undefined) {
flags[key] = inline;
} else if (argv[i + 1] && !argv[i + 1].startsWith('--')) {
flags[key] = argv[++i];
} else {
flags[key] = true;
}
} else {
positional.push(arg);
}
}
return { positional, flags };
}
/** Walk upward until the workspace root is found. */
function findRoot(start) {
let dir = start;
for (;;) {
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error('Could not locate project root (pnpm-workspace.yaml)');
}
dir = parent;
}
}
function subdirectories(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
}
/**
* Read the scalar keys from a markdown file's YAML frontmatter. Only the flat
* `key: value` shape is supported, which is all the campaign header needs.
*/
function readFrontmatter(file) {
if (!fs.existsSync(file)) return {};
const text = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
if (!text.startsWith('---')) return {};
const end = text.indexOf('\n---', 3);
if (end === -1) return {};
const out = {};
for (const line of text.slice(3, end).split('\n')) {
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (match) out[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
}
return out;
}
/**
* Resolve the campaign from flags/env, without touching the games directory.
*
* @returns {{ root: string, campaignsDir: string, gamesDir: string,
* campaignDir: string | null, campaign: object | null }}
*/
function resolveCampaign(flags) {
const root = findRoot(HERE);
const campaignsDir = path.join(root, 'campaigns');
const gamesDir = path.join(root, 'games');
const requested =
typeof flags.campaign === 'string' ? flags.campaign : process.env.JOURNAL_CAMPAIGN;
const real = subdirectories(campaignsDir).filter((n) => !n.startsWith('_'));
let campaignDir = null;
if (requested) {
campaignDir = path.join(campaignsDir, requested);
if (!fs.existsSync(campaignDir)) {
throw new Error(
`No such campaign: ${requested}\nAvailable: ${real.join(', ') || '(none)'}`,
);
}
} else if (real.length === 1) {
campaignDir = path.join(campaignsDir, real[0]);
} else if (real.length > 1 && typeof flags.game !== 'string') {
throw new Error(
`Multiple campaigns found; pass --campaign <name>.\nAvailable: ${real.join(', ')}`,
);
}
const campaign = campaignDir
? { name: path.basename(campaignDir), ...readFrontmatter(path.join(campaignDir, 'campaign.md')) }
: null;
return { root, campaignsDir, gamesDir, campaignDir, campaign };
}
/**
* Load a game package's manifest.
*
* @param {string} gamesDir
* @param {string} id
*/
async function loadGame(gamesDir, id) {
const dir = path.join(gamesDir, id);
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
const available = subdirectories(gamesDir);
throw new Error(`No such game: ${id}\nAvailable: ${available.join(', ') || '(none)'}`);
}
let manifest = {};
const entry = path.join(dir, 'index.js');
if (fs.existsSync(entry)) {
const mod = await import(pathToFileURL(entry).href);
manifest = mod.default ?? mod;
}
return {
id,
dir,
title: manifest.title ?? id,
description: manifest.description ?? '',
rules: manifest.rules ?? 'game.md',
commands: manifest.commands ?? {},
};
}
/**
* Resolve campaign + game for a command.
*/
async function resolveContext(flags) {
const base = resolveCampaign(flags);
const gameId = typeof flags.game === 'string' ? flags.game : base.campaign?.game || null;
const game = gameId ? await loadGame(base.gamesDir, gameId) : null;
return {
...base,
game,
findTable: (name) => findTable({ ...base, game }, name),
helpers: {
rollDie,
rollExpr,
oracle: rollOracle,
parseTable,
rollTable,
tableRange,
findTable: (name) => findTable({ ...base, game }, name),
},
};
}
function tableDirs(ctx) {
const dirs = [];
if (ctx.campaignDir) {
dirs.push({ label: `campaign/${ctx.campaign.name}`, dir: path.join(ctx.campaignDir, 'tables') });
}
if (ctx.game) {
dirs.push({ label: `game/${ctx.game.id}`, dir: path.join(ctx.game.dir, 'tables') });
}
return dirs;
}
/**
* Find a table by name, searching the campaign's tables before the game's.
*
* @returns {{ file: string, label: string, entries: Array<object> }}
*/
function findTable(ctx, name) {
const dirs = tableDirs(ctx);
if (path.isAbsolute(name) && fs.existsSync(name)) {
return { file: name, label: 'file', entries: parseTable(fs.readFileSync(name, 'utf8')) };
}
for (const { label, dir } of dirs) {
for (const candidate of [path.join(dir, name), path.join(dir, `${name}.csv`)]) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return { file: candidate, label, entries: parseTable(fs.readFileSync(candidate, 'utf8')) };
}
}
}
const available = [];
for (const { label, dir } of dirs) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.csv')).sort()) {
available.push(`${label}:${file.replace(/\.csv$/, '')}`);
}
}
throw new Error(`Table not found: ${name}\nAvailable: ${available.join(', ') || '(none)'}`);
}
function countTables(dir) {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir).filter((f) => f.endsWith('.csv')).length;
}
function relative(root, target) {
return path.relative(root, target).split(path.sep).join('/');
}
async function cmdRoll(positional, flags) {
const name = positional[0];
if (!name) {
throw new Error('Usage: journal roll <table> [--campaign <name>] [--game <id>] [--tag <tag>] [--count <n>]');
}
const ctx = await resolveContext(flags);
const { file, label, entries } = ctx.findTable(name);
const tag = typeof flags.tag === 'string' ? flags.tag : undefined;
const count = flags.count ? Number(flags.count) : 1;
if (!Number.isInteger(count) || count < 1) throw new Error('--count must be a positive integer');
const lines = [];
for (let i = 0; i < count; i++) {
const { roll, entry } = rollTable(entries, { tag });
const tags = entry.tags.length ? ` [${entry.tags.join(', ')}]` : '';
const rollLabel = roll === null ? 'tagged' : `roll ${roll}`;
lines.push(`${path.basename(file)} (${label}) ${rollLabel} → "${entry.text}"${tags}`);
}
console.log(lines.join('\n'));
}
async function cmdOracle(positional, flags) {
const question = typeof flags.question === 'string' ? flags.question : positional.join(' ');
const result = rollOracle();
const out = [];
if (question) out.push(`Q: ${question}`);
out.push(`d6=${result.roll} → ${result.label}`);
if (flags.complicate) {
const ctx = await resolveContext(flags);
const tableName = typeof flags.table === 'string' ? flags.table : 'complication-themes';
const { entries } = ctx.findTable(tableName);
const { entry } = rollTable(entries);
out.push(`complication → "${entry.text}"`);
}
console.log(out.join('\n'));
}
function cmdDice(positional) {
const expr = positional.join(' ');
if (!expr) throw new Error('Usage: journal dice <expr> (e.g. journal dice 2d6+1)');
const result = rollExpr(expr);
const detail = result.parts
.map((p) => (p.rolls ? `[${p.rolls.join(', ')}]` : `${p.sign < 0 ? '-' : '+'}${p.constant}`))
.join(' ');
console.log(`${result.expr} = ${result.total} ${detail}`);
}
async function cmdTables(positional, flags) {
const ctx = await resolveContext(flags);
const dirs = tableDirs(ctx);
if (!dirs.length) throw new Error('No campaign or game to list tables for');
let any = false;
for (const { label, dir } of dirs) {
if (!fs.existsSync(dir)) {
console.log(`${label} (no tables directory)`);
continue;
}
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.csv')).sort()) {
any = true;
try {
const entries = parseTable(fs.readFileSync(path.join(dir, file), 'utf8'));
const { min, max } = tableRange(entries);
console.log(`${label} ${file.replace(/\.csv$/, '')} ${min}-${max} (${entries.length} entries)`);
} catch (err) {
console.log(`${label} ${file} ERROR: ${err.message}`);
}
}
}
if (!any) console.log('(no tables)');
}
function checkTablesDir(dir, label) {
const problems = [];
const warnings = [];
if (!fs.existsSync(dir)) return { problems, warnings };
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.csv')).sort()) {
try {
const entries = parseTable(fs.readFileSync(path.join(dir, file), 'utf8'));
const sorted = [...entries].sort((a, b) => a.min - b.min);
let cursor = sorted[0].min;
for (const entry of sorted) {
if (entry.min > cursor) {
warnings.push(`${label}/${file}: gap between ${cursor - 1} and ${entry.min}`);
}
if (entry.min < cursor) {
problems.push(`${label}/${file}: overlapping range at ${entry.min}`);
}
cursor = Math.max(cursor, entry.max + 1);
}
} catch (err) {
problems.push(`${label}/${file}: ${err.message}`);
}
}
return { problems, warnings };
}
function checkGamePackage(gameDir, id) {
const problems = [];
const warnings = [];
const pkgFile = path.join(gameDir, 'package.json');
if (!fs.existsSync(pkgFile)) {
problems.push(`${id}: missing package.json`);
} else {
try {
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
const expected = `@solo/game-${id}`;
if (pkg.name !== expected) {
warnings.push(`${id}: package.json name is "${pkg.name}", expected "${expected}"`);
}
if (pkg.type !== 'module') {
warnings.push(`${id}: package.json should set "type": "module"`);
}
} catch (err) {
problems.push(`${id}: package.json is not valid JSON (${err.message})`);
}
}
if (!fs.existsSync(path.join(gameDir, 'index.js'))) {
warnings.push(`${id}: no index.js manifest`);
}
if (!fs.existsSync(path.join(gameDir, 'tables'))) {
warnings.push(`${id}: no tables/ directory`);
}
if (!fs.existsSync(path.join(gameDir, 'characters'))) {
warnings.push(`${id}: no characters/ directory`);
}
return { problems, warnings };
}
async function cmdCheck(positional, flags) {
const base = resolveCampaign(flags);
const problems = [];
const warnings = [];
if (base.campaignDir) {
const name = base.campaign.name;
for (const dir of REQUIRED_DIRS) {
if (!fs.existsSync(path.join(base.campaignDir, dir))) {
warnings.push(`${name}: missing directory ${dir}/`);
}
}
const campaignTables = checkTablesDir(path.join(base.campaignDir, 'tables'), name);
problems.push(...campaignTables.problems);
warnings.push(...campaignTables.warnings);
if (!base.campaign.game) {
warnings.push(`${name}: campaign.md has no 'game' frontmatter`);
}
}
const gameId = typeof flags.game === 'string' ? flags.game : base.campaign?.game || null;
if (gameId) {
try {
const game = await loadGame(base.gamesDir, gameId);
const gameTables = checkTablesDir(path.join(game.dir, 'tables'), game.id);
problems.push(...gameTables.problems);
warnings.push(...gameTables.warnings);
if (!fs.existsSync(path.join(game.dir, game.rules))) {
warnings.push(`${game.id}: rules file '${game.rules}' not found`);
}
const pkg = checkGamePackage(game.dir, game.id);
problems.push(...pkg.problems);
warnings.push(...pkg.warnings);
} catch (err) {
problems.push(err.message.split('\n')[0]);
}
}
const label = base.campaign ? base.campaign.name : gameId || 'project';
for (const warning of warnings) console.log(`warning: ${warning}`);
for (const problem of problems) console.log(`error: ${problem}`);
if (!problems.length && !warnings.length) console.log(`ok: ${label}`);
else if (!problems.length) console.log(`ok: ${label} (${warnings.length} warning${warnings.length === 1 ? '' : 's'})`);
if (problems.length) process.exitCode = 1;
}
async function cmdNewCampaign(positional, flags) {
const name = positional[0];
if (!name) throw new Error('Usage: journal new-campaign <name> [--game <id>]');
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
throw new Error('Campaign name must be lowercase letters, digits, and hyphens');
}
const root = findRoot(HERE);
const gamesDir = path.join(root, 'games');
const gameId = typeof flags.game === 'string' ? flags.game : 'generic';
if (!fs.existsSync(path.join(gamesDir, gameId))) {
throw new Error(
`No such game: ${gameId}\nAvailable: ${subdirectories(gamesDir).join(', ') || '(none)'}`,
);
}
const source = path.join(root, 'campaigns', '_template');
const dest = path.join(root, 'campaigns', name);
if (!fs.existsSync(source)) throw new Error('Template campaign not found: campaigns/_template');
if (fs.existsSync(dest)) throw new Error(`Campaign already exists: ${name}`);
fs.cpSync(source, dest, { recursive: true });
const campaignFile = path.join(dest, 'campaign.md');
const filled = fs
.readFileSync(campaignFile, 'utf8')
.replaceAll('{{name}}', name)
.replaceAll('{{game}}', gameId)
.replaceAll('{{date}}', new Date().toISOString().slice(0, 10));
fs.writeFileSync(campaignFile, filled);
console.log(`Created campaign: campaigns/${name} (game: ${gameId})`);
console.log('Next: fill in campaign.md, then create the PC sheet in characters/.');
}
async function cmdGames() {
const gamesDir = path.join(findRoot(HERE), 'games');
const ids = subdirectories(gamesDir);
if (!ids.length) {
console.log('(no games)');
return;
}
for (const id of ids) {
try {
const game = await loadGame(gamesDir, id);
const tables = countTables(path.join(game.dir, 'tables'));
const commands = Object.keys(game.commands).length;
console.log(
`${id.padEnd(16)} ${game.title.padEnd(26)} ${String(tables).padStart(2)} tables ${commands} commands rules: ${game.rules}`,
);
} catch (err) {
console.log(`${id.padEnd(16)} ERROR: ${err.message}`);
}
}
}
async function cmdGame(positional, flags) {
const root = findRoot(HERE);
const gamesDir = path.join(root, 'games');
let id = positional[0];
if (!id) {
const ctx = await resolveContext(flags);
if (!ctx.game) throw new Error('No game resolved; pass a game id or use --campaign <name>');
id = ctx.game.id;
}
const game = await loadGame(gamesDir, id);
console.log(`${game.id} — ${game.title}`);
if (game.description) console.log(` ${game.description}`);
console.log(` rules: ${relative(root, path.join(game.dir, game.rules))}`);
const tablesDir = path.join(game.dir, 'tables');
console.log(` tables: ${relative(root, tablesDir)} (${countTables(tablesDir)})`);
console.log(` characters: ${relative(root, path.join(game.dir, 'characters'))}`);
const names = Object.keys(game.commands);
if (!names.length) {
console.log(' commands: (none)');
return;
}
console.log(' commands:');
for (const name of names) {
const entry = game.commands[name];
const description = typeof entry === 'function' ? '' : entry.description ?? '';
console.log(` ${name.padEnd(14)} ${description}`);
}
}
/**
* @param {string[]} argv
*/
export async function main(argv) {
const { positional, flags } = parseArgs(argv);
const command = positional.shift();
switch (command) {
case 'roll':
return cmdRoll(positional, flags);
case 'oracle':
return cmdOracle(positional, flags);
case 'dice':
return cmdDice(positional);
case 'tables':
return cmdTables(positional, flags);
case 'check':
return cmdCheck(positional, flags);
case 'new-campaign':
return cmdNewCampaign(positional, flags);
case 'games':
return cmdGames();
case 'game':
return cmdGame(positional, flags);
case 'help':
case undefined:
return console.log(HELP);
}
// Anything else may be a command contributed by the campaign's game package.
const ctx = await resolveContext(flags);
const entry = ctx.game?.commands?.[command];
if (entry) {
const run = typeof entry === 'function' ? entry : entry.run;
if (typeof run !== 'function') throw new Error(`Game command "${command}" is not runnable`);
return run({ positional, flags }, ctx);
}
throw new Error(`Unknown command: ${command}\n\n${HELP}`);
}