diff --git a/packages/cli/bin/journal.js b/packages/cli/bin/journal.js new file mode 100644 index 0000000..94db200 --- /dev/null +++ b/packages/cli/bin/journal.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { main } from '../src/cli.js'; + +main(process.argv.slice(2)).catch((err) => { + console.error(`journal: ${err.message}`); + process.exit(1); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..2fdfa9f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,13 @@ +{ + "name": "@solo/cli", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The `journal` command: rolls dice, consults the oracle, and validates campaigns.", + "bin": { + "journal": "bin/journal.js" + }, + "dependencies": { + "@solo/oracle": "workspace:*" + } +} diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js new file mode 100644 index 0000000..32395cc --- /dev/null +++ b/packages/cli/src/cli.js @@ -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 [--campaign ] [--game ] [--tag ] [--count ] + journal oracle [] [--complicate] [--campaign ] [--game ] [--table ] + journal dice + journal tables [--campaign ] [--game ] + journal check [--campaign ] [--game ] + journal new-campaign [--game ] + journal games + journal game [] + journal [...] provided by the campaign's game package + +Context: + Tables are looked up in the campaign first, then the campaign's game. + Pass --campaign , set JOURNAL_CAMPAIGN, or have exactly one campaign. + Pass --game 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 }} + */ +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 .\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 }} + */ +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
[--campaign ] [--game ] [--tag ] [--count ]'); + } + + 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 (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 [--game ]'); + 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 '); + 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}`); +}