Code frameworks
Writing rules
Write constructors, actions and ledger functions in a small, safe subset of TypeScript.
Three kinds of rule file
| Kind | Shape | What it may do |
|---|---|---|
| Constructor | export function construct(...inputs): { fields } | Reads its inputs and returns the starting values. It never writes. |
| Action | export function id(self: ElementRef, ...inputs): void | Changes values with ledger.set and ledger.add. |
| Ledger function | export function id(currentValue: T, self: ElementRef): T | Reads values and returns a derived value. It never writes. |
A constructor file exports one function. An action file or a ledger file may export several, if each export name is an id declared for that file: an action id or a ledger function id. Stored field names use lowercase letters, digits and underscores.
A ledger function can return text too. Type currentValue and the result as string, and give the ledger entry of the scenario a text currentValue.
export function last_band(currentValue: string, self: ElementRef): string {
const roll: number = element.var(self, "last_roll") as number;
const target: number = element.var(self, "last_target") as number;
if (roll === 0) {
return "No roll yet";
}
if (roll <= math.floor(target / 5)) {
return "Flawless";
}
if (roll <= math.floor(target / 2)) {
return "Strong";
}
if (roll <= target) {
return "Pass";
}
return "Miss";
}- Line 1 Text in, text out.
export function hurt(self: ElementRef, amount: number): void {
ledger.add(self, "hp", 0 - amount);
}- Line 1 The Element comes first as self, then the action's own inputs. An action returns void.
A rule file can also hold helper functions. Write them without export and call them by name from the same file. The file still exports exactly one function, or only the actions or ledger functions it declares.
function base_guard(calling: string): number {
return calling === "Guard" ? 3 : 1;
}
export function guard(currentValue: number, self: ElementRef): number {
const calling: string = element.var(self, "calling") as string;
return base_guard(calling);
}- Lines 1-3 A helper: no export, and it is called by name from the same file.
- Line 7 The call to the helper.
A rule file cannot import code. An action file exports one function, unless several actions share it. An extra export on its own fails with:
export function hurt(self: ElementRef, amount: number): void {
ledger.add(self, "hp", 0 - amount);
}
export function hurt_twice(self: ElementRef, amount: number): void {
ledger.add(self, "hp", 0 - amount - amount);
}functions but only the action hurt uses itTo share a helper between actions, let those actions share one file. Name each export after the action it runs, declare every export as an action id, and keep the helper without export. The actions may belong to different Frameworks, and one action cannot call another. Every function carries a copy of its whole file in the compiled game, so keep shared files short.
A helper may only compute and return a value. It cannot call ledger.set or ledger.add. A helper that writes is refused, even in a file that actions share:
function wound(self: ElementRef): void {
ledger.add(self, "hp", -1);
}effectful action helpers are not supported until call-target provenance is enforcedLet the helper return the value, and let each action do its own write. These two actions share one pure helper:
function clamp(value: number, low: number, high: number): number {
return math.max(low, math.min(high, value));
}
export function hurt(self: ElementRef, amount: number): void {
const hp: number = element.var(self, "hp") as number;
const max_hp: number = element.var(self, "max_hp") as number;
ledger.set(self, "hp", clamp(hp - amount, 0, max_hp));
}
export function heal(self: ElementRef, amount: number): void {
const hp: number = element.var(self, "hp") as number;
const max_hp: number = element.var(self, "max_hp") as number;
ledger.set(self, "hp", clamp(hp + amount, 0, max_hp));
}- Lines 1-3 A pure helper: no export, and it only returns a value.
- Line 8 The action writes the value that the helper returned.
- Line 14 The second action reuses the helper.
A constructor that fills child slots returns { values, children } instead of a plain record. The quickstart shows the plain record, and the Framework schemas page shows the child form.
The TypeScript subset
Rules run the same way everywhere, and they always finish. That is why the language is small.
| Allowed | Not allowed |
|---|---|
| Explicit types, const and let | import, classes, arrow functions, new, this |
| if and else, for of, return | while loops, for (;;), switch, break, continue, async code, exceptions |
| Number, string, boolean, record and array literals | Template strings, spread, optional chaining, ??, block comments (use //) |
| Arithmetic, strict comparison (=== and !==), boolean operators, ternaries | == and !=, **, ++ and +=, typeof, changing objects in place, default parameter values |
| Calls to host functions, helper functions of the same file, and as casts of values they return | Method calls such as "a".toUpperCase(), Math, Date, JSON, fetch and other globals |
End every statement with a semicolon. Each of these mistakes is caught when you check the project.
export function heal(self: ElementRef, amount: number): void {
while (amount > 0) {
ledger.add(self, "hp", 1);
amount = amount - 1;
}
}while loops are not allowedexport function heal(self: ElementRef, amount: number): void {
ledger.set(self, "hp", Math.min(10, amount));
}'Math' is a host global and not part of the TypeScript subsetimport { clamp } from "./clamp";import is not allowedA function with a return type must return a value on every path.
export function hurt(self: ElementRef, amount: number): number {
ledger.add(self, "hp", 0 - amount);
}a non-void function must return a value on every reachable pathMore mistakes the check catches
Each row quotes part of a message and says what to write instead.
| Message | What to write instead |
|---|---|
| block comments are not allowed | Use // line comments. |
| switch is not allowed | Use if and else. |
| break is not allowed | Change the loop, or wrap the rest of its body in an if. |
| continue is not allowed | Wrap the rest of the loop body in an if. |
| classic for loops are not allowed | Use for (const item of list). |
| loose equality is not allowed | Use ===. |
| loose inequality is not allowed | Use !==. |
| exponentiation (**) is not allowed | Use math.pow. |
| increment (++) is not allowed | Write count = count + 1. |
| compound assignment is not allowed | Write count = count + value. |
| typeof is not allowed | Compare with === against a literal. |
| class is not allowed | Use plain functions. |
| arrow functions are not allowed | Write a helper function at the top of the file. |
| async is not allowed | Remove async. Rules run in one step. |
| template literals are not allowed | Use string literals. |
| spread/rest (...) is not allowed | Write the items out. |
| optional chaining (?.) is not allowed | Compare with null first. |
| nullish coalescing (??) is not allowed | Use a ternary that compares with null. |
| throw is not allowed | Leave the rule without writing. |
| try/catch is not allowed | Remove try and catch. |
| new is not allowed | Write literals instead. |
| this is not allowed | Use the self parameter. |
| method calls are not allowed | Call only host functions and helpers of the same file. |
| default parameter values (=) are not allowed | Give every parameter a value where the rule is used. |
| assignment targets must be local variables | Build a new value instead of changing an object. |
| functions but only the action | Remove export from helper functions. |
| constructors must be pure and cannot read or write host state | Read and write in actions and ledger functions. |
| is not available until | A ledger function cannot write. Use an action. |
| effectful action helpers are not supported | Let the helper return a value, and write it in the action. |
Host calls
Rules reach Fabled through host calls. You write them as they are, such as ledger.add(...). You never import them.
In these signatures, HostValue is any value a host call can give back, so cast it with as before you use it. AbiValue is a number, text, boolean or null, or a list or record of those. An optional sticky parameter is for Fabled's own use, so leave it out.
element.var(ref: ElementRef, name: HostValue, sticky?: AbiValue): HostValue;
ledger.set(ref: ElementRef, key: string, value: AbiValue): void;
ledger.add(ref: ElementRef, key: string, value: number): void;
math.floor(x: number): number;
math.ceil(x: number): number;
math.round(x: number): number;
math.max(...values: number[]): number;
math.min(...values: number[]): number;
math.abs(x: number): number;
math.pow(base: number, exponent: number): number;
math.sqrt(x: number): number;
list.add(ref: ElementRef, key: string, item: ContentRef, offer: ContentOffer): void;
list.remove(ref: ElementRef, key: string, item: ContentRef): void;
list.count(ref: ElementRef, key: string): number;
offer.where(where: ContentWhere, opts?: { maxItems?: number; flag?: { by: string; map: Record<string, string> } }): ContentOffer;| Call | Where | What it does |
|---|---|---|
| element.var | Actions, ledger functions | Reads a stored value of an Element. Cast the result with as number, as string or as boolean. |
| ledger.set | Actions | Sets a stored field to a value. |
| ledger.add | Actions | Adds a number to a stored field. Add a negative number to subtract. |
| math.floor | Anywhere | Rounds down. |
| math.ceil | Anywhere | Rounds up. |
| math.round | Anywhere | Rounds to the nearest whole number. |
| math.max | Anywhere | Returns the largest of its numbers. |
| math.min | Anywhere | Returns the smallest of its numbers. |
| math.abs | Anywhere | Returns the size of a number without its sign. |
| math.pow | Anywhere | Raises a base to an exponent. |
| math.sqrt | Anywhere | Returns the square root. |
| list.add | Actions | Adds the picked entry to a list. The offer decides what may be added. Refused with NOT_OFFERED, ALREADY_IN_LIST or LIST_FULL. |
| list.remove | Actions | Removes the picked entry from a list. Refused with NOT_IN_LIST. |
| list.count | Actions | Returns how many entries a list holds. Use it in a guard before list.add. |
| offer.where | Actions | Builds the offer for list.add: the entries whose fields match conditions you write as literals. |
A list call names a list of the Framework by a literal key, and the entry is a ContentRef, an input that holds the entry the player picks. An offer takes literal conditions only, such as offer.where({ weight: { lte: 3 } }). Each field takes eq, ne, gte, lte, in or has. An offer cannot read the Element. To depend on a value of the character, test it with if before list.add.
export function pack_trinket(self: ElementRef, trinket: ContentRef): void {
if (list.count(self, "pack") < 2) {
list.add(self, "pack", trinket, offer.where({ weight: { lte: 3 } }));
}
}- Line 2 Stop when the pack already holds two entries.
- Line 3 The offer allows only entries that weigh 3 or less.
export function unpack_trinket(self: ElementRef, trinket: ContentRef): void {
list.remove(self, "pack", trinket);
}- Line 2 Remove the picked entry. A list action that only removes needs one accepted step.
Field names are written out
In an action, the name of a field must be a string written in the code, such as "hp". It cannot come from a value. A rule like this fails:
export function hurt(self: ElementRef, skill: string): void {
const value: number = element.var(self, skill) as number;
ledger.set(self, "hp", value);
}Action accesses require literal keys on self.So, without a field list, one action cannot check any skill by name. Write one action for each skill, and let each read its own field. The Roll against a skill recipe shows a short action to copy. The exception is an input that lists its fields. Give the action input a "fields" list, such as ["lore", "library_use"], and the action may pass that input as the name, as in ledger.add(self, skill, amount). The check treats the action as touching every listed field, and the server accepts a write only to the field the player picked.
This action raises whichever skill the player picks:
{
"id": "improve",
"name": "Improve a skill",
"source": "rules/improve.ts",
"inputs": {
"skill": { "label": "Skill", "fields": ["lore", "library_use"] }
}
}- Line 25 The input skill picks one of these fields by name. A list needs at least two.
export function improve(self: ElementRef, skill: string): void {
ledger.add(self, skill, 1);
}- Line 2 skill holds the name of the field the player picked.
Every name in fields must be a stored field of the Framework that an action may write. A field that a ledger function derives, or a modifier, is refused. If a field has a matching schema input with a label, the dialog shows that label; otherwise it shows the field id, such as library_use. For example, inputs.library_use.label can name the choice Library use. The selected value, scenario parameters and log placeholders such as {skill} still use the field id. Any native string choice list whose values all name writable scalar fields uses the same labels.
fields "skill_total" is not a writable state fieldOnly declared fields
An action can write only the fields that the constructor returns. The check names the field it cannot find.
Action "keeper.heal" calls ledger.set(self, "mana", ...), and "mana" is not declared on this schema. Declared scalar fields: hp, max_hp, name.
