Add more flags and verbose logging

This commit is contained in:
Endeavorance 2025-02-12 16:53:17 -05:00
parent 4e31f18045
commit 542d28cb53
13 changed files with 406 additions and 135 deletions

View file

@ -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 ## Usage
bun install
```
To run: todo
```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.

View file

@ -4,6 +4,7 @@
"": { "": {
"name": "lang", "name": "lang",
"dependencies": { "dependencies": {
"chalk": "^5.4.1",
"yaml": "^2.7.0", "yaml": "^2.7.0",
"zod": "^3.24.1", "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=="], "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=="], "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=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],

View file

@ -1,5 +1,6 @@
{ {
"name": "lang", "name": "@endeavorance/muse",
"version": "0.0.1",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
@ -10,7 +11,13 @@
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"chalk": "^5.4.1",
"yaml": "^2.7.0", "yaml": "^2.7.0",
"zod": "^3.24.1" "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"
} }
} }

View file

@ -1,3 +1,7 @@
$binding: playbill
id: the-great-spires id: the-great-spires
name: The Great Spires name: The Great Spires
version: "1" author: "Endeavorance <hello@endeavorance.camp>"
version: 0.0.1
files:
- ./spires/**/*.yaml

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -1,19 +1,59 @@
import path from "node:path";
import { Glob } from "bun";
import YAML from "yaml"; import YAML from "yaml";
import z from "zod"; import z from "zod";
const BindingSchema = z.object({ const BindingSchema = z.object({
$binding: z.literal("playbill"),
id: z.string(), id: z.string(),
playbill: z.string().default("Unnamed playbill"), name: z.string().default("Unnamed playbill"),
author: z.string().optional(), author: z.string().optional(),
version: z.number(), version: z.string(),
files: z.array(z.string()).default(["**/*.yaml"]), files: z.array(z.string()).default(["**/*.yaml"]),
}); });
type Binding = z.infer<typeof BindingSchema>; type Binding = z.infer<typeof BindingSchema>;
export async function loadBindingFile(filePath: string): Promise<Binding> { export interface PlaybillBinding {
info: Binding;
bindingPath: string;
files: string[];
}
export async function loadBindingFile(
filePath: string,
): Promise<PlaybillBinding> {
const file = Bun.file(filePath); const file = Bun.file(filePath);
const text = await file.text(); const text = await file.text();
const parsed = YAML.parse(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,
};
} }

View file

@ -1,34 +1,164 @@
import { Glob } from "bun"; import chalk from "chalk";
import { parseArgs } from "node:util";
import { loadBindingFile } from "./binding"; import { loadBindingFile } from "./binding";
import type { AnyResource } from "./playbill-schema"; import {
type AnyResource,
ValidatedPlaybillSchema,
getEmptyPlaybill,
} from "./playbill-schema";
import { loadResourceFile } from "./resource"; 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>
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 binding = await loadBindingFile(bindingPath);
const fileGlobs = binding.files; verboseLog(
const bindingFileDirname = bindingPath.split("/").slice(0, -1).join("/"); `↳ Binding loaded with ${binding.files.length} associated resources`,
);
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 loadedResources: AnyResource[] = []; 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); const loaded = await loadResourceFile(filepath);
verboseLog(`${filepath} loaded with ${loaded.length} resources`);
loadedResources.push(...loaded); 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"));
}

View file

@ -1,22 +1,23 @@
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<DirectiveName, DirectiveHandler> = {
dmg: (amount = "1", type = "physical") => { dmg: (amount = "1", type = "physical") => {
return `<em class='dmg ${type}'>${amount}</em>`; return `<em class='dmg ${type}'>${amount}</em>`;
}, },
}; };
type DirectiveHandler = (...args: string[]) => string; function processCommands(text: string): string {
function processCommands(
text: string,
handlers: { [key: string]: DirectiveHandler },
): string {
const commandPattern = /\{(\w+):([^}]+)\}/g; const commandPattern = /\{(\w+):([^}]+)\}/g;
return text.replace(commandPattern, (match, command, args) => { return text.replace(commandPattern, (_match, command, args) => {
const handler = handlers[command]; // TODO: Improve error reporting
if (handler) { const directive = DirectiveSchema.parse(command);
const handler = ProsceniumLangDirectives[directive];
const parsedArgs = []; const parsedArgs = [];
let currentArg = ""; let currentArg = "";
let inQuotes = false; let inQuotes = false;
@ -35,8 +36,6 @@ function processCommands(
parsedArgs.push(currentArg.trim().replace(/^"|"$/g, "")); parsedArgs.push(currentArg.trim().replace(/^"|"$/g, ""));
return handler(...parsedArgs); return handler(...parsedArgs);
}
return match;
}); });
} }
@ -44,7 +43,7 @@ export async function processLang(input: string): 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, directiveHandlers); const processed = processCommands(line);
processedLines.push(processed); processedLines.push(processed);
} }

View file

@ -6,6 +6,7 @@ import { type ZodSchema, z } from "zod";
* Ability N/A * Ability N/A
* Tag N/A * Tag N/A
* Lore N/A * Lore N/A
* Rule N/A
* Method Ability * Method Ability
* Item Tag * Item Tag
* Species Ability * Species Ability
@ -37,9 +38,7 @@ export const StatsSchema = z.enum(STAT_ABBREVIATIONS);
export const ItemTypeSchema = z.enum(ITEM_TYPES); export const ItemTypeSchema = z.enum(ITEM_TYPES);
export const RollTypeSchema = z.enum(ROLL_TYPES); export const RollTypeSchema = z.enum(ROLL_TYPES);
export const AbilityTypeSchema = z.enum(ABILITY_TYPES); export const AbilityTypeSchema = z.enum(ABILITY_TYPES);
export const TalentSchema = z.enum(TALENTS); export const TalentSchema = z.enum(TALENTS);
export const TalentProwessSchema = z.enum(TALENT_PROWESS); export const TalentProwessSchema = z.enum(TALENT_PROWESS);
const ResourceTypes = [ const ResourceTypes = [
@ -50,6 +49,7 @@ const ResourceTypes = [
"tag", "tag",
"lore", "lore",
"method", "method",
"rule",
"playbill", "playbill",
] as const; ] as const;
const ResourceTypeSchema = z.enum(ResourceTypes); const ResourceTypeSchema = z.enum(ResourceTypes);
@ -136,6 +136,11 @@ export const SpeciesSchema = PlaybillResourceSchema.extend({
talentMinimums: TalentSpreadSchema.default({}), talentMinimums: TalentSpreadSchema.default({}),
}); });
export const RuleSchema = PlaybillResourceSchema.extend({
$define: z.literal("rule"),
overrule: z.string().optional(),
});
// Full Playbill Schema // Full Playbill Schema
export const PlaybillSchema = PlaybillResourceSchema.extend({ export const PlaybillSchema = PlaybillResourceSchema.extend({
$define: z.literal("playbill"), $define: z.literal("playbill"),
@ -150,12 +155,35 @@ export const PlaybillSchema = PlaybillResourceSchema.extend({
lore: z.array(LoreSchema).default([]), lore: z.array(LoreSchema).default([]),
methods: z.array(MethodSchema).default([]), methods: z.array(MethodSchema).default([]),
species: z.array(SpeciesSchema).default([]), species: z.array(SpeciesSchema).default([]),
rules: z.array(RuleSchema).default([]),
}); });
export const ValidatedPlaybillSchema = PlaybillSchema.superRefine( export const ValidatedPlaybillSchema = PlaybillSchema.superRefine(
(val, ctx) => { (val, ctx) => {
// For each ability, ensure unique IDs
const abilityIds = new Set<string>();
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 // For each method, ensure its abilities exist
// Also ensure unique IDs
const methodIds = new Set<string>();
for (const method of val.methods) { 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(); const methodAbilityIds = method.abilities.flat();
for (const abilityId of methodAbilityIds) { 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<string>();
for (const species of val.species) { 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(); const speciesAbilityIds = species.abilities.flat();
for (const abilityId of speciesAbilityIds) { for (const abilityId of speciesAbilityIds) {
@ -183,7 +221,16 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine(
} }
// For each entity, ensure its species and known abilities exist // For each entity, ensure its species and known abilities exist
const entityIds = new Set<string>();
for (const entity of val.entities) { 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) { for (const abilityId of entity.abilities) {
if (!val.abilities.some((a) => a.id === abilityId)) { if (!val.abilities.some((a) => a.id === abilityId)) {
ctx.addIssue({ ctx.addIssue({
@ -202,7 +249,15 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine(
} }
// For each item, ensure its tags exist // For each item, ensure its tags exist
const itemIds = new Set<string>();
for (const item of val.items) { 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) { for (const tag of item.tags) {
if (!val.tags.some((t) => t.id === tag)) { if (!val.tags.some((t) => t.id === tag)) {
ctx.addIssue({ ctx.addIssue({
@ -212,8 +267,44 @@ export const ValidatedPlaybillSchema = PlaybillSchema.superRefine(
} }
} }
} }
// For each tag, ensure unique IDs
const tagIds = new Set<string>();
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<string>();
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<string>();
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<typeof PlaybillSchema>; export type Playbill = z.infer<typeof PlaybillSchema>;
export type ValidatedPlaybill = z.infer<typeof ValidatedPlaybillSchema>; export type ValidatedPlaybill = z.infer<typeof ValidatedPlaybillSchema>;
@ -226,6 +317,7 @@ export const ResourceMap: Record<ResourceType, ZodSchema> = {
tag: ItemTagSchema, tag: ItemTagSchema,
method: MethodSchema, method: MethodSchema,
lore: LoreSchema, lore: LoreSchema,
rule: RuleSchema,
playbill: PlaybillSchema, playbill: PlaybillSchema,
}; };
@ -237,6 +329,7 @@ export const AnyResourceSchema = z.discriminatedUnion("$define", [
ItemTagSchema, ItemTagSchema,
MethodSchema, MethodSchema,
LoreSchema, LoreSchema,
RuleSchema,
PlaybillSchema, PlaybillSchema,
]); ]);
@ -246,19 +339,5 @@ export function getEmptyPlaybill(): Playbill {
return PlaybillSchema.parse({ return PlaybillSchema.parse({
$define: "playbill", $define: "playbill",
id: "empty", 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",
},
],
}); });
} }

View file

@ -5,20 +5,32 @@ import {
ResourceMap, ResourceMap,
} from "./playbill-schema"; } from "./playbill-schema";
export class ResourceFileError extends Error { }
export class NullResourceError extends Error { }
export async function loadResourceFile( export async function loadResourceFile(
filePath: string, filePath: string,
): Promise<AnyResource[]> { ): Promise<AnyResource[]> {
try {
const file = Bun.file(filePath); const file = Bun.file(filePath);
const text = await file.text(); const text = await file.text();
const parsedDocs = YAML.parseAllDocuments(text); const parsedDocs = YAML.parseAllDocuments(text);
if (parsedDocs.some((doc) => doc.toJS() === null)) {
throw new NullResourceError(`Null resource defined in ${filePath}`);
}
const errors = parsedDocs.flatMap((doc) => doc.errors);
if (errors.length > 0) {
throw new ResourceFileError(`Error parsing ${filePath}: ${errors}`);
}
const collection: AnyResource[] = []; const collection: AnyResource[] = [];
for (const doc of parsedDocs) { for (const doc of parsedDocs) {
if (doc.errors.length > 0) { if (doc.errors.length > 0) {
throw new Error(`Error parsing ${filePath}: ${doc.errors}`); throw new ResourceFileError(`Error parsing ${filePath}: ${doc.errors}`);
} }
const raw = doc.toJS(); const raw = doc.toJS();
@ -30,7 +42,4 @@ export async function loadResourceFile(
} }
return collection; return collection;
} catch (error) {
throw new Error(`Error loading ${filePath}: ${error}`);
}
} }