diff --git a/README.md b/README.md index fbd02e1..0ecb6d6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,7 @@ -# lang +# Playbill Builder CLI -To install dependencies: +This is a CLI tool for compiling YAML files into a validated Playbill for [Proscenium](https://proscenium.game) -```bash -bun install -``` +## Usage -To run: - -```bash -bun run index.ts -``` - -This project was created using `bun init` in bun v1.2.2. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +todo diff --git a/bun.lock b/bun.lock index f00cd95..373f671 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "lang", "dependencies": { + "chalk": "^5.4.1", "yaml": "^2.7.0", "zod": "^3.24.1", }, @@ -43,6 +44,8 @@ "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], + "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], diff --git a/package.json b/package.json index e07fe5f..41ca6e2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { - "name": "lang", + "name": "@endeavorance/muse", + "version": "0.0.1", "module": "index.ts", "type": "module", "devDependencies": { @@ -10,7 +11,13 @@ "typescript": "^5.0.0" }, "dependencies": { + "chalk": "^5.4.1", "yaml": "^2.7.0", "zod": "^3.24.1" + }, + "scripts": { + "fmt": "bunx --bun biome check --fix", + "build": "bun build ./src/index.ts --outfile muse --compile", + "demo": "bun run ./src/index.ts -- ./playbill/binding.yaml" } -} \ No newline at end of file +} diff --git a/playbill/binding.yaml b/playbill/binding.yaml index 21980ec..a7d46bf 100644 --- a/playbill/binding.yaml +++ b/playbill/binding.yaml @@ -1,3 +1,7 @@ +$binding: playbill id: the-great-spires name: The Great Spires -version: "1" +author: "Endeavorance " +version: 0.0.1 +files: + - ./spires/**/*.yaml diff --git a/playbill/methods/abilities/identify-poison.yaml b/playbill/methods/abilities/identify-poison.yaml deleted file mode 100644 index cc200c9..0000000 --- a/playbill/methods/abilities/identify-poison.yaml +++ /dev/null @@ -1,24 +0,0 @@ -$define: ability -id: identify-poison -name: Identify Poison -type: action -costs: - ap: 1 -roll: focus -boons: - - You know the antidote to the detected poison - - Your detection is imperceptible -banes: - - It is obvious you are checking for poison -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 -id: neutralize-poison -name: Neutralize Poison -type: action -roll: cunning -description: Neutralize a poison diff --git a/playbill/methods/gourmond.yaml b/playbill/methods/gourmond.yaml deleted file mode 100644 index 744f7b4..0000000 --- a/playbill/methods/gourmond.yaml +++ /dev/null @@ -1,6 +0,0 @@ -$define: method -id: gourmond -name: Gourmond -curator: Lump -abilities: [["identify-poison"]] -description: Your prowess in cooking is second only to your prowess in eating. diff --git a/playbill/spires/methods/gourmond.yaml b/playbill/spires/methods/gourmond.yaml new file mode 100644 index 0000000..eedafaa --- /dev/null +++ b/playbill/spires/methods/gourmond.yaml @@ -0,0 +1,38 @@ +$define: method +id: gourmond +name: Gourmond +curator: TBD +abilities: + - [identify-poison, neutralize-poison] +description: Your prowess in cooking is second only to your prowess in eating. +--- +$define: ability +id: identify-poison +name: Identify Poison +type: action +costs: + ap: 1 +roll: focus +boons: + - You know the antidote to the detected poison + - Your detection is imperceptible +banes: + - It is obvious you are checking for poison +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 +id: neutralize-poison +name: Neutralize Poison +type: action +roll: cunning +description: Neutralize a 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 diff --git a/playbill/world.yaml b/playbill/spires/world.yaml similarity index 100% rename from playbill/world.yaml rename to playbill/spires/world.yaml diff --git a/src/binding.ts b/src/binding.ts index c8ddcaf..a85aad4 100644 --- a/src/binding.ts +++ b/src/binding.ts @@ -1,19 +1,59 @@ +import path from "node:path"; +import { Glob } from "bun"; import YAML from "yaml"; import z from "zod"; const BindingSchema = z.object({ + $binding: z.literal("playbill"), id: z.string(), - playbill: z.string().default("Unnamed playbill"), + name: z.string().default("Unnamed playbill"), author: z.string().optional(), - version: z.number(), + version: z.string(), files: z.array(z.string()).default(["**/*.yaml"]), }); type Binding = z.infer; -export async function loadBindingFile(filePath: string): Promise { +export interface PlaybillBinding { + info: Binding; + bindingPath: string; + files: string[]; +} + +export async function loadBindingFile( + filePath: string, +): Promise { const file = Bun.file(filePath); const text = await file.text(); const parsed = YAML.parse(text); - return BindingSchema.parse(parsed); + const binding = BindingSchema.parse(parsed); + + const fileGlobs = binding.files; + const bindingFileDirname = filePath.split("/").slice(0, -1).join("/"); + + const allFilePaths: string[] = []; + + for (const thisGlob of fileGlobs) { + const glob = new Glob(thisGlob); + + const results = glob.scanSync({ + cwd: bindingFileDirname, + absolute: true, + followSymlinks: true, + onlyFiles: true, + }); + + allFilePaths.push(...results); + } + + const bindingFileAbsolutePath = path.resolve(filePath); + const filteredFilePaths = allFilePaths.filter((filePath) => { + return filePath !== bindingFileAbsolutePath; + }); + + return { + info: binding, + bindingPath: filePath, + files: filteredFilePaths, + }; } diff --git a/src/index.ts b/src/index.ts index 1658ec1..161608a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,164 @@ -import { Glob } from "bun"; +import chalk from "chalk"; +import { parseArgs } from "node:util"; import { loadBindingFile } from "./binding"; -import type { AnyResource } from "./playbill-schema"; +import { + type AnyResource, + ValidatedPlaybillSchema, + getEmptyPlaybill, +} from "./playbill-schema"; import { loadResourceFile } from "./resource"; +import { version } from "../package.json" with { type: "json" }; + +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, +}); + +if (args.length === 0 || options.help) { + console.log(`${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 +`); + process.exit(0); +} + +if (options.check && options.write) { + console.error("Cannot use --check and --write together"); + 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 bindingPath = Bun.argv[2]; const binding = await loadBindingFile(bindingPath); -const fileGlobs = binding.files; -const bindingFileDirname = bindingPath.split("/").slice(0, -1).join("/"); - -const allFilePaths: string[] = []; - -for (const thisGlob of fileGlobs) { - const glob = new Glob(thisGlob); - - const results = glob.scanSync({ - cwd: bindingFileDirname, - absolute: true, - followSymlinks: true, - onlyFiles: true, - }); - - allFilePaths.push(...results); -} +verboseLog( + `↳ Binding loaded with ${binding.files.length} associated resources`, +); const loadedResources: AnyResource[] = []; -for (const filepath of allFilePaths) { +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); } -console.log(loadedResources); +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 8d89722..f31a6f9 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -1,42 +1,41 @@ -import type { ZodSchema } from "zod"; +import { z } from "zod"; -const directiveHandlers = { +const ALL_DIRECTIVES = ["dmg"] as const; +const DirectiveSchema = z.enum(ALL_DIRECTIVES); +type DirectiveName = (typeof ALL_DIRECTIVES)[number]; +type DirectiveHandler = (...args: string[]) => string; + +const ProsceniumLangDirectives: Record = { dmg: (amount = "1", type = "physical") => { return `${amount}`; }, }; -type DirectiveHandler = (...args: string[]) => string; - -function processCommands( - text: string, - handlers: { [key: string]: DirectiveHandler }, -): string { +function processCommands(text: string): string { const commandPattern = /\{(\w+):([^}]+)\}/g; - return text.replace(commandPattern, (match, command, args) => { - const handler = handlers[command]; - if (handler) { - const parsedArgs = []; - let currentArg = ""; - let inQuotes = false; + return text.replace(commandPattern, (_match, command, args) => { + // TODO: Improve error reporting + const directive = DirectiveSchema.parse(command); + const handler = ProsceniumLangDirectives[directive]; + const parsedArgs = []; + let currentArg = ""; + let inQuotes = false; - for (let i = 0; i < args.length; i++) { - const char = args[i]; - if (char === '"' && (i === 0 || args[i - 1] !== "\\")) { - inQuotes = !inQuotes; - } else if (char === "," && !inQuotes) { - parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); - currentArg = ""; - } else { - currentArg += char; - } + for (let i = 0; i < args.length; i++) { + const char = args[i]; + if (char === '"' && (i === 0 || args[i - 1] !== "\\")) { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); + currentArg = ""; + } else { + currentArg += char; } - parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); - - return handler(...parsedArgs); } - return match; + parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); + + return handler(...parsedArgs); }); } @@ -44,7 +43,7 @@ export async function processLang(input: string): Promise { const lines = input.split("\n"); const processedLines: string[] = []; for (const line of lines) { - const processed = processCommands(line, directiveHandlers); + const processed = processCommands(line); processedLines.push(processed); } diff --git a/src/playbill-schema.ts b/src/playbill-schema.ts index 52a288c..eb480de 100644 --- a/src/playbill-schema.ts +++ b/src/playbill-schema.ts @@ -6,6 +6,7 @@ import { type ZodSchema, z } from "zod"; * Ability N/A * Tag N/A * Lore N/A + * Rule N/A * Method Ability * Item Tag * Species Ability @@ -37,9 +38,7 @@ export const StatsSchema = z.enum(STAT_ABBREVIATIONS); export const ItemTypeSchema = z.enum(ITEM_TYPES); export const RollTypeSchema = z.enum(ROLL_TYPES); export const AbilityTypeSchema = z.enum(ABILITY_TYPES); - export const TalentSchema = z.enum(TALENTS); - export const TalentProwessSchema = z.enum(TALENT_PROWESS); const ResourceTypes = [ @@ -50,6 +49,7 @@ const ResourceTypes = [ "tag", "lore", "method", + "rule", "playbill", ] as const; const ResourceTypeSchema = z.enum(ResourceTypes); @@ -136,6 +136,11 @@ export const SpeciesSchema = PlaybillResourceSchema.extend({ talentMinimums: TalentSpreadSchema.default({}), }); +export const RuleSchema = PlaybillResourceSchema.extend({ + $define: z.literal("rule"), + overrule: z.string().optional(), +}); + // Full Playbill Schema export const PlaybillSchema = PlaybillResourceSchema.extend({ $define: z.literal("playbill"), @@ -150,12 +155,35 @@ export const PlaybillSchema = PlaybillResourceSchema.extend({ lore: z.array(LoreSchema).default([]), methods: z.array(MethodSchema).default([]), species: z.array(SpeciesSchema).default([]), + rules: z.array(RuleSchema).default([]), }); export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( (val, ctx) => { + // For each ability, ensure unique IDs + const abilityIds = new Set(); + for (const ability of val.abilities) { + if (abilityIds.has(ability.id)) { + ctx.addIssue({ + message: `Ability ${ability.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + abilityIds.add(ability.id); + } + // For each method, ensure its abilities exist + // Also ensure unique IDs + const methodIds = new Set(); for (const method of val.methods) { + if (methodIds.has(method.id)) { + ctx.addIssue({ + message: `Method ${method.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + + methodIds.add(method.id); const methodAbilityIds = method.abilities.flat(); for (const abilityId of methodAbilityIds) { @@ -168,8 +196,18 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( } } - // For each species, ensure its abilities + // For each species, ensure its abilities and IDs + const speciesIds = new Set(); for (const species of val.species) { + if (speciesIds.has(species.id)) { + ctx.addIssue({ + message: `Species ${species.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + + speciesIds.add(species.id); + const speciesAbilityIds = species.abilities.flat(); for (const abilityId of speciesAbilityIds) { @@ -183,7 +221,16 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( } // For each entity, ensure its species and known abilities exist + const entityIds = new Set(); for (const entity of val.entities) { + if (entityIds.has(entity.id)) { + ctx.addIssue({ + message: `Entity ${entity.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + entityIds.add(entity.id); + for (const abilityId of entity.abilities) { if (!val.abilities.some((a) => a.id === abilityId)) { ctx.addIssue({ @@ -202,7 +249,15 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( } // For each item, ensure its tags exist + const itemIds = new Set(); for (const item of val.items) { + if (itemIds.has(item.id)) { + ctx.addIssue({ + message: `Item ${item.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + itemIds.add(item.id); for (const tag of item.tags) { if (!val.tags.some((t) => t.id === tag)) { ctx.addIssue({ @@ -212,8 +267,44 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( } } } + + // For each tag, ensure unique IDs + const tagIds = new Set(); + for (const tag of val.tags) { + if (tagIds.has(tag.id)) { + ctx.addIssue({ + message: `Tag ${tag.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + tagIds.add(tag.id); + } + + // For each rule, ensure unique IDs + const ruleIds = new Set(); + for (const rule of val.rules) { + if (ruleIds.has(rule.id)) { + ctx.addIssue({ + message: `Rule ${rule.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + ruleIds.add(rule.id); + } + + // For each lore, ensure unique IDs + const loreIds = new Set(); + for (const lore of val.lore) { + if (loreIds.has(lore.id)) { + ctx.addIssue({ + message: `Lore ${lore.id} has a duplicate ID`, + code: z.ZodIssueCode.custom, + }); + } + loreIds.add(lore.id); + } }, -); +).brand("ValidatedPlaybill"); export type Playbill = z.infer; export type ValidatedPlaybill = z.infer; @@ -226,6 +317,7 @@ export const ResourceMap: Record = { tag: ItemTagSchema, method: MethodSchema, lore: LoreSchema, + rule: RuleSchema, playbill: PlaybillSchema, }; @@ -237,6 +329,7 @@ export const AnyResourceSchema = z.discriminatedUnion("$define", [ ItemTagSchema, MethodSchema, LoreSchema, + RuleSchema, PlaybillSchema, ]); @@ -246,19 +339,5 @@ export function getEmptyPlaybill(): Playbill { return PlaybillSchema.parse({ $define: "playbill", id: "empty", - abilities: [ - { - id: "fireball", - name: "Fireball", - type: "action", - description: "Cast a fireball! My goodness.", - }, - { - id: "magic-shield", - name: "Magic Shield", - type: "cue", - description: "Defend yerself", - }, - ], }); } diff --git a/src/resource.ts b/src/resource.ts index c55e144..82c3147 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -5,32 +5,41 @@ import { ResourceMap, } from "./playbill-schema"; +export class ResourceFileError extends Error { } +export class NullResourceError extends Error { } + export async function loadResourceFile( filePath: string, ): Promise { - try { - const file = Bun.file(filePath); - const text = await file.text(); + const file = Bun.file(filePath); + const text = await file.text(); - const parsedDocs = YAML.parseAllDocuments(text); + const parsedDocs = YAML.parseAllDocuments(text); - const collection: AnyResource[] = []; + if (parsedDocs.some((doc) => doc.toJS() === null)) { + throw new NullResourceError(`Null resource defined in ${filePath}`); + } - for (const doc of parsedDocs) { - if (doc.errors.length > 0) { - throw new Error(`Error parsing ${filePath}: ${doc.errors}`); - } + const errors = parsedDocs.flatMap((doc) => doc.errors); - const raw = doc.toJS(); - const parsed = AnyResourceSchema.parse(raw); - const type = parsed.$define; - const schemaToUse = ResourceMap[type]; + if (errors.length > 0) { + throw new ResourceFileError(`Error parsing ${filePath}: ${errors}`); + } - collection.push(schemaToUse.parse(parsed)); + const collection: AnyResource[] = []; + + for (const doc of parsedDocs) { + if (doc.errors.length > 0) { + throw new ResourceFileError(`Error parsing ${filePath}: ${doc.errors}`); } - return collection; - } catch (error) { - throw new Error(`Error loading ${filePath}: ${error}`); + const raw = doc.toJS(); + const parsed = AnyResourceSchema.parse(raw); + const type = parsed.$define; + const schemaToUse = ResourceMap[type]; + + collection.push(schemaToUse.parse(parsed)); } + + return collection; }