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:
@@ -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);
|
||||||
|
});
|
||||||
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user