Additional directives
This commit is contained in:
parent
542d28cb53
commit
3c6d7c18cd
7 changed files with 304 additions and 166 deletions
|
@ -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}
|
||||||
|
|
4
playbill/spires/rules/regroup.yaml
Normal file
4
playbill/spires/rules/regroup.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
$define: rule
|
||||||
|
id: regroup
|
||||||
|
name: Regroup
|
||||||
|
description: Regain some hp by resting
|
127
src/index.ts
127
src/index.ts
|
@ -1,14 +1,19 @@
|
||||||
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";
|
||||||
|
|
||||||
|
class CLIError extends Error { }
|
||||||
|
|
||||||
|
async function main(): Promise<boolean> {
|
||||||
|
// Parse command line arguments
|
||||||
const { values: options, positionals: args } = parseArgs({
|
const { values: options, positionals: args } = parseArgs({
|
||||||
args: Bun.argv.slice(2),
|
args: Bun.argv.slice(2),
|
||||||
options: {
|
options: {
|
||||||
|
@ -37,36 +42,32 @@ const { values: options, positionals: args } = parseArgs({
|
||||||
default: false,
|
default: false,
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
},
|
},
|
||||||
|
minify: {
|
||||||
|
short: "m",
|
||||||
|
default: false,
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args.length === 0 || options.help) {
|
if (options.help) {
|
||||||
console.log(`${chalk.bold("muse")} - Compile and validate Playbills for Proscenium
|
console.log(usage);
|
||||||
${chalk.dim(`v${version}`)}
|
return true;
|
||||||
|
|
||||||
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
|
|
||||||
`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.check && options.write) {
|
if (options.check && options.write) {
|
||||||
console.error("Cannot use --check and --write together");
|
throw new CLIError("Cannot use --check and --write together");
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERBOSE = options.verbose;
|
const VERBOSE = options.verbose;
|
||||||
|
|
||||||
function verboseLog(msg: string) {
|
/**
|
||||||
|
* Log a message if the vervose flag has been set
|
||||||
|
* @param msg - The message to log
|
||||||
|
*/
|
||||||
|
function v(msg: string) {
|
||||||
if (VERBOSE) {
|
if (VERBOSE) {
|
||||||
console.log(chalk.dim(msg));
|
console.log(chalk.dim(msg));
|
||||||
}
|
}
|
||||||
|
@ -74,38 +75,33 @@ function verboseLog(msg: string) {
|
||||||
|
|
||||||
const bindingPath = args[0] ?? "./binding.yaml";
|
const bindingPath = args[0] ?? "./binding.yaml";
|
||||||
|
|
||||||
verboseLog(`Using binding file: ${bindingPath}`);
|
v(`Building Playbill with binding: ${bindingPath}`);
|
||||||
|
|
||||||
// Check if the binding file exists
|
// Check if the binding file exists
|
||||||
const bindingFileCheck = Bun.file(bindingPath);
|
const bindingFileCheck = Bun.file(bindingPath);
|
||||||
const bindingFileExists = await bindingFileCheck.exists();
|
const bindingFileExists = await bindingFileCheck.exists();
|
||||||
|
|
||||||
if (!bindingFileExists) {
|
if (!bindingFileExists) {
|
||||||
console.error(`Binding file not found: ${bindingPath}`);
|
throw new Error(`Binding file not found: ${bindingPath}`);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
verboseLog("↳ Binding file found");
|
|
||||||
|
|
||||||
const binding = await loadBindingFile(bindingPath);
|
const binding = await loadBindingFile(bindingPath);
|
||||||
|
|
||||||
verboseLog(
|
v(`↳ Binding loaded with ${binding.files.length} associated resources`);
|
||||||
`↳ Binding loaded with ${binding.files.length} associated resources`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadedResources: AnyResource[] = [];
|
const loadedResources: AnyResource[] = [];
|
||||||
|
|
||||||
verboseLog("Loading resources");
|
v("Loading resources");
|
||||||
|
|
||||||
// Load resources listed in the binding file
|
// Load resources listed in the binding file
|
||||||
for (const filepath of binding.files) {
|
for (const filepath of binding.files) {
|
||||||
verboseLog(`↳ Loading ${filepath}...`);
|
v(`↳ Loading ${filepath}`);
|
||||||
const loaded = await loadResourceFile(filepath);
|
const loaded = await loadResourceFile(filepath);
|
||||||
verboseLog(` ↳ ${filepath} loaded with ${loaded.length} resources`);
|
v(` ↳ Loaded ${loaded.length} resources`);
|
||||||
loadedResources.push(...loaded);
|
loadedResources.push(...loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
verboseLog("Constructing playbill");
|
v("Constructing playbill");
|
||||||
|
|
||||||
// Consjtruct the playbill object
|
// Consjtruct the playbill object
|
||||||
const playbill = getEmptyPlaybill();
|
const playbill = getEmptyPlaybill();
|
||||||
|
@ -146,19 +142,70 @@ for (const resource of loadedResources) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
verboseLog("Validating playbill");
|
// 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
|
// Validate the playbill object
|
||||||
const validatedPlaybill = ValidatedPlaybillSchema.parse(playbill);
|
v("Validating playbill");
|
||||||
|
const validatedPlaybill = ValidatedPlaybillSchema.safeParse(playbill);
|
||||||
|
|
||||||
verboseLog("Playbill validated");
|
if (!validatedPlaybill.success) {
|
||||||
|
console.error("Error validating playbill");
|
||||||
if (options.write) {
|
console.error(validatedPlaybill.error.errors);
|
||||||
await Bun.write(options.outfile, JSON.stringify(validatedPlaybill, null, 2));
|
return false;
|
||||||
} else if (!options.check) {
|
|
||||||
console.log(validatedPlaybill);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v("Playbill validated");
|
||||||
|
|
||||||
|
// If --check is set, exit here
|
||||||
if (options.check) {
|
if (options.check) {
|
||||||
console.log(chalk.green("Playbill validated successfully"));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
78
src/lang.ts
78
src/lang.ts
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
17
src/usage.ts
Normal 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();
|
Loading…
Add table
Add a link
Reference in a new issue