Initial commit

This commit is contained in:
Endeavorance 2025-02-12 10:31:22 -05:00
commit 4e31f18045
15 changed files with 779 additions and 0 deletions

175
.gitignore vendored Normal file
View file

@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# lang
To install dependencies:
```bash
bun install
```
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.

48
biome.json Normal file
View file

@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": [
"dist",
".next",
"public",
"*.d.ts",
"*.json",
"build",
"*.svelte",
".svelte-kit"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noArrayIndexKey": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"cssModules": true
}
}
}

54
bun.lock Normal file
View file

@ -0,0 +1,54 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "lang",
"dependencies": {
"yaml": "^2.7.0",
"zod": "^3.24.1",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.0.0",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@types/bun": ["@types/bun@1.2.2", "", { "dependencies": { "bun-types": "1.2.2" } }, "sha512-tr74gdku+AEDN5ergNiBnplr7hpDp3V1h7fqI2GcR/rsUaM39jpSeKH0TFibRvU0KwniRx5POgaYnaXbk0hU+w=="],
"@types/node": ["@types/node@22.13.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew=="],
"@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
"bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="],
"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=="],
"yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="],
"zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
}
}

16
package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "lang",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"yaml": "^2.7.0",
"zod": "^3.24.1"
}
}

3
playbill/binding.yaml Normal file
View file

@ -0,0 +1,3 @@
id: the-great-spires
name: The Great Spires
version: "1"

View file

@ -0,0 +1,24 @@
$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

@ -0,0 +1,6 @@
$define: method
id: gourmond
name: Gourmond
curator: Lump
abilities: [["identify-poison"]]
description: Your prowess in cooking is second only to your prowess in eating.

6
playbill/world.yaml Normal file
View file

@ -0,0 +1,6 @@
$define: lore
id: world
description: |-
This is a lore file.
It has some information in it, and is mostly just to be read.

19
src/binding.ts Normal file
View file

@ -0,0 +1,19 @@
import YAML from "yaml";
import z from "zod";
const BindingSchema = z.object({
id: z.string(),
playbill: z.string().default("Unnamed playbill"),
author: z.string().optional(),
version: z.number(),
files: z.array(z.string()).default(["**/*.yaml"]),
});
type Binding = z.infer<typeof BindingSchema>;
export async function loadBindingFile(filePath: string): Promise<Binding> {
const file = Bun.file(filePath);
const text = await file.text();
const parsed = YAML.parse(text);
return BindingSchema.parse(parsed);
}

34
src/index.ts Normal file
View file

@ -0,0 +1,34 @@
import { Glob } from "bun";
import { loadBindingFile } from "./binding";
import type { AnyResource } from "./playbill-schema";
import { loadResourceFile } from "./resource";
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);
}
const loadedResources: AnyResource[] = [];
for (const filepath of allFilePaths) {
const loaded = await loadResourceFile(filepath);
loadedResources.push(...loaded);
}
console.log(loadedResources);

52
src/lang.ts Normal file
View file

@ -0,0 +1,52 @@
import type { ZodSchema } from "zod";
const directiveHandlers = {
dmg: (amount = "1", type = "physical") => {
return `<em class='dmg ${type}'>${amount}</em>`;
},
};
type DirectiveHandler = (...args: string[]) => string;
function processCommands(
text: string,
handlers: { [key: string]: DirectiveHandler },
): 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;
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;
});
}
export async function processLang(input: string): Promise<string> {
const lines = input.split("\n");
const processedLines: string[] = [];
for (const line of lines) {
const processed = processCommands(line, directiveHandlers);
processedLines.push(processed);
}
return processedLines.join("\n");
}

264
src/playbill-schema.ts Normal file
View file

@ -0,0 +1,264 @@
import { type ZodSchema, z } from "zod";
/*
* TYPE REFERENCES
* ------------------------
* Ability N/A
* Tag N/A
* Lore N/A
* Method Ability
* Item Tag
* Species Ability
* Entity Ability, Species
*/
// String shapes
export const IDSchema = z.string().regex(/[a-z\-]+/);
export const NameSchema = z.string().max(40).default("Unnamed");
export const DescriptionSchema = z.string().default("No description");
// Constants
export const STAT_ABBREVIATIONS = ["ap", "ep", "hp", "xp"] as const;
export const ITEM_TYPES = ["trinket", "worn", "wielded"] as const;
export const ROLL_TYPES = ["none", "attack", "fate"] as const;
export const TALENTS = [
"muscle",
"knowledge",
"focus",
"charm",
"cunning",
"spark",
] as const;
export const ABILITY_TYPES = ["action", "cue", "trait"] as const;
export const TALENT_PROWESS = ["novice", "adept", "master"] as const;
// Enums
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 = [
"ability",
"species",
"entity",
"item",
"tag",
"lore",
"method",
"playbill",
] as const;
const ResourceTypeSchema = z.enum(ResourceTypes);
type ResourceType = z.infer<typeof ResourceTypeSchema>;
// Inner utility shapes
export const StatSpreadSchema = z.object({
ap: z.number().default(0),
ep: z.number().default(0),
hp: z.number().default(0),
xp: z.number().default(0),
});
export const TalentSpreadSchema = z.object({
muscle: TalentProwessSchema.default("novice"),
knowledge: TalentProwessSchema.default("novice"),
focus: TalentProwessSchema.default("novice"),
charm: TalentProwessSchema.default("novice"),
cunning: TalentProwessSchema.default("novice"),
spark: TalentProwessSchema.default("novice"),
});
export const PlaybillResourceSchema = z.object({
$define: ResourceTypeSchema,
id: IDSchema,
name: NameSchema,
description: DescriptionSchema,
});
// Playbill component shapes
export const AbilitySchema = PlaybillResourceSchema.extend({
$define: z.literal("ability"),
name: NameSchema.default("Unnamed Ability"),
type: AbilityTypeSchema,
costs: StatSpreadSchema.default({}),
roll: RollTypeSchema.or(TalentSchema).default("none"),
banes: z.array(z.string()).default([]),
boons: z.array(z.string()).default([]),
});
export type PlaybillAbility = z.infer<typeof AbilitySchema>;
export const EntitySchema = PlaybillResourceSchema.extend({
$define: z.literal("entity"),
name: NameSchema.default("Unnamed Species"),
species: z.string(IDSchema),
abilities: z.array(IDSchema),
stats: StatSpreadSchema,
statMaxModifiers: StatSpreadSchema,
talents: TalentSpreadSchema,
});
export const ItemTagSchema = PlaybillResourceSchema.extend({
$define: z.literal("tag"),
name: NameSchema.default("Unnamed Item Tag"),
icon: z.string().url(),
});
export const ItemSchema = PlaybillResourceSchema.extend({
$define: z.literal("item"),
name: NameSchema.default("Unnamed Item"),
type: ItemTypeSchema,
slots: z.number().min(0).max(5).default(0),
tags: z.array(IDSchema).default([]),
});
export const LoreSchema = PlaybillResourceSchema.extend({
$define: z.literal("lore"),
name: NameSchema.default("Unnamed Lore Entry"),
categories: z.array(z.string()).default([]),
});
export const MethodSchema = PlaybillResourceSchema.extend({
$define: z.literal("method"),
name: NameSchema.default("Unnamed Method"),
curator: NameSchema,
abilities: z.array(z.array(IDSchema)).default([]),
});
export const SpeciesSchema = PlaybillResourceSchema.extend({
$define: z.literal("species"),
name: NameSchema.default("Unnamed Species"),
abilities: z.array(IDSchema).default([]),
statMaxModifiers: StatSpreadSchema.default({}),
talentMinimums: TalentSpreadSchema.default({}),
});
// Full Playbill Schema
export const PlaybillSchema = PlaybillResourceSchema.extend({
$define: z.literal("playbill"),
version: z.string().default("0"),
name: NameSchema.default("Unnamed Playbill"),
author: z.string().default("Anonymous"),
abilities: z.array(AbilitySchema).default([]),
entities: z.array(EntitySchema).default([]),
items: z.array(ItemSchema).default([]),
tags: z.array(ItemTagSchema).default([]),
lore: z.array(LoreSchema).default([]),
methods: z.array(MethodSchema).default([]),
species: z.array(SpeciesSchema).default([]),
});
export const ValidatedPlaybillSchema = PlaybillSchema.superRefine(
(val, ctx) => {
// For each method, ensure its abilities exist
for (const method of val.methods) {
const methodAbilityIds = method.abilities.flat();
for (const abilityId of methodAbilityIds) {
if (!val.abilities.some((a) => a.id === abilityId)) {
ctx.addIssue({
message: `Method ${method.id} references undefined ability ${abilityId}`,
code: z.ZodIssueCode.custom,
});
}
}
}
// For each species, ensure its abilities
for (const species of val.species) {
const speciesAbilityIds = species.abilities.flat();
for (const abilityId of speciesAbilityIds) {
if (!val.abilities.some((a) => a.id === abilityId)) {
ctx.addIssue({
message: `Species ${species.id} references undefined ability ${abilityId}`,
code: z.ZodIssueCode.custom,
});
}
}
}
// For each entity, ensure its species and known abilities exist
for (const entity of val.entities) {
for (const abilityId of entity.abilities) {
if (!val.abilities.some((a) => a.id === abilityId)) {
ctx.addIssue({
message: `Entity ${entity.id} references undefined ability ${abilityId}`,
code: z.ZodIssueCode.custom,
});
}
}
if (!val.species.some((s) => s.id === entity.species)) {
ctx.addIssue({
message: `Entity ${entity.id} references undefined species ${entity.species}`,
code: z.ZodIssueCode.custom,
});
}
}
// For each item, ensure its tags exist
for (const item of val.items) {
for (const tag of item.tags) {
if (!val.tags.some((t) => t.id === tag)) {
ctx.addIssue({
message: `Item ${item.id} references undefined tag ${tag}`,
code: z.ZodIssueCode.custom,
});
}
}
}
},
);
export type Playbill = z.infer<typeof PlaybillSchema>;
export type ValidatedPlaybill = z.infer<typeof ValidatedPlaybillSchema>;
export const ResourceMap: Record<ResourceType, ZodSchema> = {
ability: AbilitySchema,
species: SpeciesSchema,
entity: EntitySchema,
item: ItemSchema,
tag: ItemTagSchema,
method: MethodSchema,
lore: LoreSchema,
playbill: PlaybillSchema,
};
export const AnyResourceSchema = z.discriminatedUnion("$define", [
AbilitySchema,
SpeciesSchema,
EntitySchema,
ItemSchema,
ItemTagSchema,
MethodSchema,
LoreSchema,
PlaybillSchema,
]);
export type AnyResource = z.infer<typeof AnyResourceSchema>;
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",
},
],
});
}

36
src/resource.ts Normal file
View file

@ -0,0 +1,36 @@
import YAML from "yaml";
import {
type AnyResource,
AnyResourceSchema,
ResourceMap,
} from "./playbill-schema";
export async function loadResourceFile(
filePath: string,
): Promise<AnyResource[]> {
try {
const file = Bun.file(filePath);
const text = await file.text();
const parsedDocs = YAML.parseAllDocuments(text);
const collection: AnyResource[] = [];
for (const doc of parsedDocs) {
if (doc.errors.length > 0) {
throw new Error(`Error parsing ${filePath}: ${doc.errors}`);
}
const raw = doc.toJS();
const parsed = AnyResourceSchema.parse(raw);
const type = parsed.$define;
const schemaToUse = ResourceMap[type];
collection.push(schemaToUse.parse(parsed));
}
return collection;
} catch (error) {
throw new Error(`Error loading ${filePath}: ${error}`);
}
}

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}