Additional directives

This commit is contained in:
Endeavorance 2025-02-12 18:56:45 -05:00
parent 542d28cb53
commit 3c6d7c18cd
7 changed files with 304 additions and 166 deletions

View file

@ -4,6 +4,7 @@ name: Gourmond
curator: TBD curator: TBD
abilities: abilities:
- [identify-poison, neutralize-poison] - [identify-poison, neutralize-poison]
- [camp-food]
description: Your prowess in cooking is second only to your prowess in eating. description: Your prowess in cooking is second only to your prowess in eating.
--- ---
$define: ability $define: ability
@ -21,7 +22,6 @@ banes:
description: |- description: |-
Focus your senses on a food or drink to determine if it is poisoned. 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. You can also identify the type of poison used.
--- ---
$define: ability $define: ability
@ -29,10 +29,16 @@ id: neutralize-poison
name: Neutralize Poison name: Neutralize Poison
type: action type: action
roll: cunning 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: boons:
- You manage to neutralize the poison without drawing any attention - You manage to neutralize the poison without drawing any attention
- You are able to also neutralize poisons in the food of your party members - You are able to also neutralize poisons in the food of your party members
banes: banes:
- You are not able to fully neutralize the poison, only weakening the effects - You are not able to fully neutralize the poison, only weakening the effects
- Someone catches on to you - 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}

View file

@ -0,0 +1,4 @@
$define: rule
id: regroup
name: Regroup
description: Regain some hp by resting

View file

@ -1,164 +1,211 @@
import chalk from "chalk";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import chalk from "chalk";
import { loadBindingFile } from "./binding"; import { loadBindingFile } from "./binding";
import { DirectiveError, processLang } from "./lang";
import { import {
type AnyResource, type AnyResource,
ValidatedPlaybillSchema, ValidatedPlaybillSchema,
getEmptyPlaybill, getEmptyPlaybill,
} from "./playbill-schema"; } from "./playbill-schema";
import { loadResourceFile } from "./resource"; import { loadResourceFile } from "./resource";
import { version } from "../package.json" with { type: "json" }; import { usage } from "./usage";
const { values: options, positionals: args } = parseArgs({ class CLIError extends Error { }
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,
});
if (args.length === 0 || options.help) { async function main(): Promise<boolean> {
console.log(`${chalk.bold("muse")} - Compile and validate Playbills for Proscenium // Parse command line arguments
${chalk.dim(`v${version}`)} 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: if (options.help) {
muse [/path/to/binding.yaml] <options> console.log(usage);
return true;
}
Options: if (options.check && options.write) {
--check Only load and check the current binding and resources, but do not compile throw new CLIError("Cannot use --check and --write together");
--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 const VERBOSE = options.verbose;
--help, -h Show this help message
`); /**
process.exit(0); * 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) { try {
console.error("Cannot use --check and --write together"); 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); 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"));
}

View file

@ -1,21 +1,74 @@
import { z } from "zod"; import { z } from "zod";
import { StatsSchema, type Playbill } from "./playbill-schema";
const ALL_DIRECTIVES = ["dmg"] as const; const DirectiveSchema = z.enum(["dmg", "ability", "stat", "rule"] as const);
const DirectiveSchema = z.enum(ALL_DIRECTIVES); type DirectiveName = z.infer<typeof DirectiveSchema>;
type DirectiveName = (typeof ALL_DIRECTIVES)[number]; type DirectiveHandler = (playbill: Playbill, ...args: string[]) => string;
type DirectiveHandler = (...args: string[]) => string;
export class DirectiveError extends Error { }
const ProsceniumLangDirectives: Record<DirectiveName, DirectiveHandler> = { const ProsceniumLangDirectives: Record<DirectiveName, DirectiveHandler> = {
dmg: (amount = "1", type = "physical") => { dmg: (_, amount = "1", type = "phy") => {
return `<em class='dmg ${type}'>${amount}</em>`; 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 `<em class='dmg ${finalizedType}'>${amount}</em>`;
},
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 `<em class="ref-ability">${foundAbility.name}</em>`;
},
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 `<em class="ref-rule">${foundRule.name}</em>`;
},
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 `<em class="stat">${amount} ${stat.toUpperCase()}</em>`;
}
return `<em class="stat">${stat.toUpperCase()}</em>`;
}, },
}; };
function processCommands(text: string): string { function processCommands(text: string, playbill: Playbill): string {
const commandPattern = /\{(\w+):([^}]+)\}/g; const commandPattern = /\{(\w+):([^}]+)\}/g;
return text.replace(commandPattern, (_match, command, args) => { return text.replace(commandPattern, (_match, command, args) => {
// TODO: Improve error reporting
const directive = DirectiveSchema.parse(command); const directive = DirectiveSchema.parse(command);
const handler = ProsceniumLangDirectives[directive]; const handler = ProsceniumLangDirectives[directive];
const parsedArgs = []; const parsedArgs = [];
@ -35,15 +88,18 @@ function processCommands(text: string): string {
} }
parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); parsedArgs.push(currentArg.trim().replace(/^"|"$/g, ""));
return handler(...parsedArgs); return handler(playbill, ...parsedArgs);
}); });
} }
export async function processLang(input: string): Promise<string> { export async function processLang(
input: string,
playbill: Playbill,
): Promise<string> {
const lines = input.split("\n"); const lines = input.split("\n");
const processedLines: string[] = []; const processedLines: string[] = [];
for (const line of lines) { for (const line of lines) {
const processed = processCommands(line); const processed = processCommands(line, playbill);
processedLines.push(processed); processedLines.push(processed);
} }

View file

@ -1,4 +1,4 @@
import { type ZodSchema, z } from "zod"; import { z } from "zod";
/* /*
* TYPE REFERENCES * TYPE REFERENCES
@ -309,7 +309,7 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine(
export type Playbill = z.infer<typeof PlaybillSchema>; export type Playbill = z.infer<typeof PlaybillSchema>;
export type ValidatedPlaybill = z.infer<typeof ValidatedPlaybillSchema>; export type ValidatedPlaybill = z.infer<typeof ValidatedPlaybillSchema>;
export const ResourceMap: Record<ResourceType, ZodSchema> = { export const ResourceMap = {
ability: AbilitySchema, ability: AbilitySchema,
species: SpeciesSchema, species: SpeciesSchema,
entity: EntitySchema, entity: EntitySchema,
@ -319,7 +319,7 @@ export const ResourceMap: Record<ResourceType, ZodSchema> = {
lore: LoreSchema, lore: LoreSchema,
rule: RuleSchema, rule: RuleSchema,
playbill: PlaybillSchema, playbill: PlaybillSchema,
}; } as const;
export const AnyResourceSchema = z.discriminatedUnion("$define", [ export const AnyResourceSchema = z.discriminatedUnion("$define", [
AbilitySchema, AbilitySchema,

View file

@ -8,6 +8,14 @@ import {
export class ResourceFileError extends Error { } export class ResourceFileError extends Error { }
export class NullResourceError 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( export async function loadResourceFile(
filePath: string, filePath: string,
): Promise<AnyResource[]> { ): Promise<AnyResource[]> {
@ -37,8 +45,8 @@ export async function loadResourceFile(
const parsed = AnyResourceSchema.parse(raw); const parsed = AnyResourceSchema.parse(raw);
const type = parsed.$define; const type = parsed.$define;
const schemaToUse = ResourceMap[type]; const schemaToUse = ResourceMap[type];
const validated = schemaToUse.parse(parsed);
collection.push(schemaToUse.parse(parsed)); collection.push(validated);
} }
return collection; return collection;

17
src/usage.ts Normal file
View file

@ -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>
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();