diff --git a/playbill/spires/methods/gourmond.yaml b/playbill/spires/methods/gourmond.yaml index eedafaa..ba16391 100644 --- a/playbill/spires/methods/gourmond.yaml +++ b/playbill/spires/methods/gourmond.yaml @@ -4,6 +4,7 @@ name: Gourmond curator: TBD abilities: - [identify-poison, neutralize-poison] + - [camp-food] description: Your prowess in cooking is second only to your prowess in eating. --- $define: ability @@ -21,7 +22,6 @@ banes: description: |- Focus your senses on a food or drink to determine if it is poisoned. - {dmg:1,magic} You can also identify the type of poison used. --- $define: ability @@ -29,10 +29,16 @@ id: neutralize-poison name: Neutralize Poison type: action roll: cunning -description: Neutralize a poison by adding an ingredient known to render the poison inert +description: Neutralize a poison detected using {ability:identify-poison} by adding an ingredient known to render the poison inert boons: - You manage to neutralize the poison without drawing any attention - You are able to also neutralize poisons in the food of your party members banes: - You are not able to fully neutralize the poison, only weakening the effects - Someone catches on to you +--- +$define: ability +id: camp-food +name: Camp Food +type: trait +description: Regain an additional {stat:hp,2} when you {rule:regroup} diff --git a/playbill/spires/rules/regroup.yaml b/playbill/spires/rules/regroup.yaml new file mode 100644 index 0000000..335bf0b --- /dev/null +++ b/playbill/spires/rules/regroup.yaml @@ -0,0 +1,4 @@ +$define: rule +id: regroup +name: Regroup +description: Regain some hp by resting diff --git a/src/index.ts b/src/index.ts index 161608a..dfeff05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,164 +1,211 @@ -import chalk from "chalk"; import { parseArgs } from "node:util"; +import chalk from "chalk"; import { loadBindingFile } from "./binding"; +import { DirectiveError, processLang } from "./lang"; import { type AnyResource, ValidatedPlaybillSchema, getEmptyPlaybill, } from "./playbill-schema"; import { loadResourceFile } from "./resource"; -import { version } from "../package.json" with { type: "json" }; +import { usage } from "./usage"; -const { values: options, positionals: args } = parseArgs({ - args: Bun.argv.slice(2), - options: { - write: { - short: "w", - type: "boolean", - default: false, - }, - check: { - short: "c", - type: "boolean", - default: false, - }, - outfile: { - short: "o", - type: "string", - default: "playbill.json", - }, - help: { - short: "h", - default: false, - type: "boolean", - }, - verbose: { - short: "v", - default: false, - type: "boolean", - }, - }, - strict: true, - allowPositionals: true, -}); +class CLIError extends Error { } -if (args.length === 0 || options.help) { - console.log(`${chalk.bold("muse")} - Compile and validate Playbills for Proscenium -${chalk.dim(`v${version}`)} +async function main(): Promise { + // Parse command line arguments + const { values: options, positionals: args } = parseArgs({ + args: Bun.argv.slice(2), + options: { + write: { + short: "w", + type: "boolean", + default: false, + }, + check: { + short: "c", + type: "boolean", + default: false, + }, + outfile: { + short: "o", + type: "string", + default: "playbill.json", + }, + help: { + short: "h", + default: false, + type: "boolean", + }, + verbose: { + short: "v", + default: false, + type: "boolean", + }, + minify: { + short: "m", + default: false, + type: "boolean", + }, + }, + strict: true, + allowPositionals: true, + }); -Usage: - muse [/path/to/binding.yaml] + if (options.help) { + console.log(usage); + return true; + } -Options: - --check Only load and check the current binding and resources, but do not compile - --write Write the output to a file. If not specified, outputs to stdout - --outfile Specify the output file path [default: playbill.json] - --verbose, -v Verbose output - --help, -h Show this help message -`); - process.exit(0); + if (options.check && options.write) { + throw new CLIError("Cannot use --check and --write together"); + } + + const VERBOSE = options.verbose; + + /** + * Log a message if the vervose flag has been set + * @param msg - The message to log + */ + function v(msg: string) { + if (VERBOSE) { + console.log(chalk.dim(msg)); + } + } + + const bindingPath = args[0] ?? "./binding.yaml"; + + v(`Building Playbill with binding: ${bindingPath}`); + + // Check if the binding file exists + const bindingFileCheck = Bun.file(bindingPath); + const bindingFileExists = await bindingFileCheck.exists(); + + if (!bindingFileExists) { + throw new Error(`Binding file not found: ${bindingPath}`); + } + + const binding = await loadBindingFile(bindingPath); + + v(`↳ Binding loaded with ${binding.files.length} associated resources`); + + const loadedResources: AnyResource[] = []; + + v("Loading resources"); + + // Load resources listed in the binding file + for (const filepath of binding.files) { + v(`↳ Loading ${filepath}`); + const loaded = await loadResourceFile(filepath); + v(` ↳ Loaded ${loaded.length} resources`); + loadedResources.push(...loaded); + } + + v("Constructing playbill"); + + // Consjtruct the playbill object + const playbill = getEmptyPlaybill(); + + playbill.id = binding.info.id; + playbill.name = binding.info.name; + playbill.author = binding.info.author ?? "Anonymous"; + playbill.version = binding.info.version; + + for (const resource of loadedResources) { + switch (resource.$define) { + case "ability": + playbill.abilities.push(resource); + break; + case "species": + playbill.species.push(resource); + break; + case "entity": + playbill.entities.push(resource); + break; + case "item": + playbill.items.push(resource); + break; + case "tag": + playbill.tags.push(resource); + break; + case "method": + playbill.methods.push(resource); + break; + case "lore": + playbill.lore.push(resource); + break; + case "rule": + playbill.rules.push(resource); + break; + case "playbill": + throw new Error("Cannot load playbills rn dawg"); + } + } + + // Evaluate directives in descriptions for all resources + v("Processing descriptions"); + for (const resource of loadedResources) { + try { + resource.description = await processLang(resource.description, playbill); + } catch (error) { + if (error instanceof DirectiveError) { + console.error( + `Error processing directives in ${resource.$define}/${resource.id}`, + ); + console.error(error.message); + } else { + console.error(error); + } + return false; + } + } + + // Validate the playbill object + v("Validating playbill"); + const validatedPlaybill = ValidatedPlaybillSchema.safeParse(playbill); + + if (!validatedPlaybill.success) { + console.error("Error validating playbill"); + console.error(validatedPlaybill.error.errors); + return false; + } + + v("Playbill validated"); + + // If --check is set, exit here + if (options.check) { + console.log(chalk.green("Playbill validated successfully")); + return true; + } + + const finalizedPlaybill = JSON.stringify( + validatedPlaybill.data, + null, + options.minify ? undefined : 2, + ); + if (options.write) { + await Bun.write(options.outfile, finalizedPlaybill); + return true; + } + + console.log(finalizedPlaybill); + return true; } -if (options.check && options.write) { - console.error("Cannot use --check and --write together"); +try { + const success = await main(); + if (success) { + process.exit(0); + } else { + process.exit(1); + } +} catch (error) { + if (error instanceof Error) { + console.error(chalk.red(error.message)); + } else { + console.error("An unknown error occurred"); + console.error(error); + } + process.exit(1); } - -const VERBOSE = options.verbose; - -function verboseLog(msg: string) { - if (VERBOSE) { - console.log(chalk.dim(msg)); - } -} - -const bindingPath = args[0] ?? "./binding.yaml"; - -verboseLog(`Using binding file: ${bindingPath}`); - -// Check if the binding file exists -const bindingFileCheck = Bun.file(bindingPath); -const bindingFileExists = await bindingFileCheck.exists(); - -if (!bindingFileExists) { - console.error(`Binding file not found: ${bindingPath}`); - process.exit(1); -} - -verboseLog("↳ Binding file found"); - -const binding = await loadBindingFile(bindingPath); - -verboseLog( - `↳ Binding loaded with ${binding.files.length} associated resources`, -); - -const loadedResources: AnyResource[] = []; - -verboseLog("Loading resources"); - -// Load resources listed in the binding file -for (const filepath of binding.files) { - verboseLog(`↳ Loading ${filepath}...`); - const loaded = await loadResourceFile(filepath); - verboseLog(` ↳ ${filepath} loaded with ${loaded.length} resources`); - loadedResources.push(...loaded); -} - -verboseLog("Constructing playbill"); - -// Consjtruct the playbill object -const playbill = getEmptyPlaybill(); - -playbill.id = binding.info.id; -playbill.name = binding.info.name; -playbill.author = binding.info.author ?? "Anonymous"; -playbill.version = binding.info.version; - -for (const resource of loadedResources) { - switch (resource.$define) { - case "ability": - playbill.abilities.push(resource); - break; - case "species": - playbill.species.push(resource); - break; - case "entity": - playbill.entities.push(resource); - break; - case "item": - playbill.items.push(resource); - break; - case "tag": - playbill.tags.push(resource); - break; - case "method": - playbill.methods.push(resource); - break; - case "lore": - playbill.lore.push(resource); - break; - case "rule": - playbill.rules.push(resource); - break; - case "playbill": - throw new Error("Cannot load playbills rn dawg"); - } -} - -verboseLog("Validating playbill"); - -// Validate the playbill object -const validatedPlaybill = ValidatedPlaybillSchema.parse(playbill); - -verboseLog("Playbill validated"); - -if (options.write) { - await Bun.write(options.outfile, JSON.stringify(validatedPlaybill, null, 2)); -} else if (!options.check) { - console.log(validatedPlaybill); -} - -if (options.check) { - console.log(chalk.green("Playbill validated successfully")); -} diff --git a/src/lang.ts b/src/lang.ts index f31a6f9..58ba167 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -1,21 +1,74 @@ import { z } from "zod"; +import { StatsSchema, type Playbill } from "./playbill-schema"; -const ALL_DIRECTIVES = ["dmg"] as const; -const DirectiveSchema = z.enum(ALL_DIRECTIVES); -type DirectiveName = (typeof ALL_DIRECTIVES)[number]; -type DirectiveHandler = (...args: string[]) => string; +const DirectiveSchema = z.enum(["dmg", "ability", "stat", "rule"] as const); +type DirectiveName = z.infer; +type DirectiveHandler = (playbill: Playbill, ...args: string[]) => string; + +export class DirectiveError extends Error { } const ProsceniumLangDirectives: Record = { - dmg: (amount = "1", type = "physical") => { - return `${amount}`; + dmg: (_, amount = "1", type = "phy") => { + const DAMAGE_TYPES = ["phy", "arc", "physical", "arcane"]; + if (!DAMAGE_TYPES.includes(type)) { + throw new DirectiveError(`Unknown damage type: ${type}`); + } + + const finalizedType = type.slice(0, 3); + return `${amount}`; + }, + + ability: (playbill: Playbill, ref: string) => { + if (typeof ref !== "string") { + throw new DirectiveError("Invalid ability reference"); + } + + const foundAbility = playbill.abilities.find((a) => a.id === ref); + + if (foundAbility === undefined) { + throw new DirectiveError(`Unknown ability reference: ${ref}`); + } + + return `${foundAbility.name}`; + }, + + rule: (playbill: Playbill, ref: string) => { + if (typeof ref !== "string") { + throw new DirectiveError("Invalid rule reference"); + } + + const foundRule = playbill.rules.find((r) => r.id === ref); + + if (foundRule === undefined) { + throw new DirectiveError(`Unknown ability reference: ${ref}`); + } + + return `${foundRule.name}`; + }, + stat: (_, stat: string, amount = "") => { + const lower = stat.toLowerCase(); + const parsed = StatsSchema.safeParse(lower); + + if (!parsed.success) { + throw new DirectiveError(`Unknown stat: ${stat}`); + } + + if (amount !== "") { + const amountAsNum = Number.parseInt(amount, 10); + if (Number.isNaN(amountAsNum)) { + throw new DirectiveError(`Invalid amount: ${amount}`); + } + return `${amount} ${stat.toUpperCase()}`; + } + + return `${stat.toUpperCase()}`; }, }; -function processCommands(text: string): string { +function processCommands(text: string, playbill: Playbill): string { const commandPattern = /\{(\w+):([^}]+)\}/g; return text.replace(commandPattern, (_match, command, args) => { - // TODO: Improve error reporting const directive = DirectiveSchema.parse(command); const handler = ProsceniumLangDirectives[directive]; const parsedArgs = []; @@ -35,15 +88,18 @@ function processCommands(text: string): string { } parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); - return handler(...parsedArgs); + return handler(playbill, ...parsedArgs); }); } -export async function processLang(input: string): Promise { +export async function processLang( + input: string, + playbill: Playbill, +): Promise { const lines = input.split("\n"); const processedLines: string[] = []; for (const line of lines) { - const processed = processCommands(line); + const processed = processCommands(line, playbill); processedLines.push(processed); } diff --git a/src/playbill-schema.ts b/src/playbill-schema.ts index eb480de..2412eea 100644 --- a/src/playbill-schema.ts +++ b/src/playbill-schema.ts @@ -1,4 +1,4 @@ -import { type ZodSchema, z } from "zod"; +import { z } from "zod"; /* * TYPE REFERENCES @@ -309,7 +309,7 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( export type Playbill = z.infer; export type ValidatedPlaybill = z.infer; -export const ResourceMap: Record = { +export const ResourceMap = { ability: AbilitySchema, species: SpeciesSchema, entity: EntitySchema, @@ -319,7 +319,7 @@ export const ResourceMap: Record = { lore: LoreSchema, rule: RuleSchema, playbill: PlaybillSchema, -}; +} as const; export const AnyResourceSchema = z.discriminatedUnion("$define", [ AbilitySchema, diff --git a/src/resource.ts b/src/resource.ts index 82c3147..8b7e6f2 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -8,6 +8,14 @@ import { export class ResourceFileError extends Error { } export class NullResourceError extends Error { } +/** + * Load and process all documents in a yaml file at the given path + * @param filePath - The path to the yaml file + * @returns An array of validated resources + * + * @throws ResourceFileError + * @throws NullResourceError + */ export async function loadResourceFile( filePath: string, ): Promise { @@ -37,8 +45,8 @@ export async function loadResourceFile( const parsed = AnyResourceSchema.parse(raw); const type = parsed.$define; const schemaToUse = ResourceMap[type]; - - collection.push(schemaToUse.parse(parsed)); + const validated = schemaToUse.parse(parsed); + collection.push(validated); } return collection; diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..dd339ae --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,17 @@ +import { version } from "../package.json" with { type: "json" }; +import chalk from "chalk"; + +export const usage = ` +${chalk.bold("muse")} - Compile and validate Playbills for Proscenium +${chalk.dim(`v${version}`)} + +Usage: + muse [/path/to/binding.yaml] + +Options: + --check Only load and check the current binding and resources, but do not compile + --write Write the output to a file. If not specified, outputs to stdout + --outfile Specify the output file path [default: playbill.json] + --verbose, -v Verbose output + --help, -h Show this help message +`.trim();