Formulas and Functions
The expressions inside steps and rules, the functions they can call, and how they read lists and rows.
After reading this guide, you will know:
- Which names a formula can use.
- The operators, and how text, dates and true or false are written.
- How to round, and how rounding treats halves.
- How to work over lists and rows with
MAP,FILTER,PLUCK,REDUCE,SCAN,SORT,ANYandALL. - How to quote formulas in YAML.
What a Formula Is
A formula is a spreadsheet-style expression. It appears as a formula
step, as a rule’s when and value, as a with, start or times on a run or repeat, and nowhere it
could run code: a formula can only compute.
A formula can use:
- the calculation’s inputs,
- the blueprint’s tables, by name, as lists of rows,
- any earlier step, by name,
- a row’s columns as
name.column, and a run’s outputs asname.output.
check reports any other name: step dealt uses armor, which is not an input or an earlier step.
Operators
| Write | Means |
|---|---|
+ - * / |
arithmetic. Division is exact to 32 significant digits |
^, % |
power and remainder: 2 ^ 10 is 1024, 17 % 5 is 2 |
= != < <= > >= |
comparisons |
AND, OR, NOT(x) |
logic, also as AND(a, b) and OR(a, b) |
"text" |
text, in double quotes |
true, false |
true and false |
{a, b, c} |
a list |
list[0] |
an item of a list, counting from 0 |
row.column |
a column of a row |
Text needs double quotes, and the YAML string around it single quotes or none:
- {name: label, formula: '"critical"'}
- {name: concession, formula: 'IF(rider = "adult", 1, 0.5)'}
A Tour
This blueprint uses most of what formulas are for:
calculations:
tour:
inputs:
hours: {type: list}
fighters: {type: list}
losses: {type: list}
name: {type: string}
price: {type: number}
steps:
- {name: doubled, formula: 'MAP(hours, h, h * 2)'}
- {name: long_days, formula: 'FILTER(hours, h, h > 7)'}
- {name: total_hours, formula: SUM(hours)}
- {name: days, formula: COUNT(hours)}
- {name: average, formula: AVG(hours)}
- {name: longest, formula: MAX(hours)}
- {name: running, formula: 'SCAN(losses, t, l, t + l.loss, 0)'}
- {name: lost, formula: 'REDUCE(losses, t, l, t + l.loss, 0)'}
- {name: by_dex, formula: 'PLUCK(SORT(fighters, f, -f.dex), name)'}
- {name: any_ghoul, formula: 'ANY(losses, l, l.creature = "ghoul")'}
- {name: all_small, formula: 'ALL(losses, l, l.loss < 10)'}
- {name: greeting, formula: 'CONCAT("Hello, ", name)'}
- {name: size, formula: 'SWITCH(COUNT(fighters), 1, "duel", 2, "skirmish", "melee")'}
- {name: with_gst, formula: 'ROUND(price * 1.1, 2)'}
- {name: whole_up, formula: ROUNDUP(price)}
- {name: whole_down, formula: ROUNDDOWN(price)}
- {name: busy, formula: 'IF(total_hours > 20 AND NOT(days < 3), "busy", "quiet")'}
- {name: both_lists, formula: 'FLATTEN({long_days, doubled})'}
outputs: [doubled, long_days, total_hours, days, average, longest, running, lost, by_dex, any_ghoul, all_small, greeting, size, with_gst, whole_up, whole_down, busy, both_lists]
{
"hours": [6, 8, 7.5],
"fighters": [{"name": "Ada", "dex": 60}, {"name": "Brick", "dex": 75}, {"name": "Cole", "dex": 60}],
"losses": [{"creature": "ghoul", "loss": 3}, {"creature": "deep_one", "loss": 7}, {"creature": "ghoul", "loss": 2}],
"name": "Ada",
"price": 12.345
}
$ rmt run tour.yml --facts tour_facts.json
doubled 12, 16, 15
long_days 8, 7.5
total_hours 21.5
days 3
average 7.1666666666666666666666666666667
longest 8
running 3, 10, 12
lost 12
by_dex Brick, Ada, Cole
any_ghoul true
all_small true
greeting Hello, Ada
size melee
with_gst 13.58
whole_up 13
whole_down 12
busy busy
both_lists 8, 7.5, 12, 16, 15
Rounding
| Function | Does | Example |
|---|---|---|
ROUND(x, places) |
rounds half away from zero | ROUND(2.345, 2) is 2.35, ROUND(-2.5) is -3 |
ROUNDUP(x) |
up to a whole number | ROUNDUP(12.345) is 13, ROUNDUP(-1.2) is -1 |
ROUNDDOWN(x) |
down to a whole number | ROUNDDOWN(12.345) is 12 |
Numbers are exact decimals, so 0.1 + 0.2 is 0.3 and rounding happens only where you write it. Round
once, at the point the source rounds. The shipped electricity bill rounds each line to the cent after
adding it up, and totals the rounded lines, because that is what a bill shows.
Lists and Rows
The list functions take a name for each item, then a formula over it:
| Function | Gives |
|---|---|
MAP(list, item, formula) |
a new list, one value per item |
FILTER(list, item, condition) |
the items that meet the condition |
ANY(list, item, condition), ALL(...) |
true if any, or every, item meets it |
PLUCK(list, column) |
one column from each row |
REDUCE(list, total, item, formula, start) |
one value built up item by item |
SCAN(list, total, item, formula, start) |
every running total of a REDUCE |
SORT(list, item, key) |
the list ordered by key, smallest first; ties keep their order |
SUM, COUNT, AVG, MIN, MAX |
over a list, or over several values |
FLATTEN({list, list}) |
lists joined into one |
CONTAINS(value, list) |
true if the list has the value |
SORT(fighters, f, -f.dex) puts the highest DEX first; Ada and Cole tie on 60 and stay in the order they
came. PLUCK of a column a row does not have fails, naming the row, rather than quietly giving nothing.
To carry several values through a SCAN, make the total a list:
SCAN(losses, t, l, {t[0] - l.loss, t[1] + 1}, {50, 0}).
A formula can read a table by name, so FILTER(tiers, t, t.plan = plan) picks the rows of a table to run
over. The rows of a run over a list are rows too: ANY(log, e, e.temporary_insanity_risk).
When a row needs a running total of the rows before it, use carry on the run rather than SCAN. See
Running over Lists.
Dates and Dice
Formulas add days to dates (date + 2), count days between them (period_to - period_from + 1), and
read the weekday (WEEKDAY(date), 0 for Sunday). They read dice notation (DICE_MAX("1D8+1D4+2") is 14)
and roll it the same way every time for the same seed (ROLL("1D100", seed, round, "attack")). See
Dates and Dice.
Quoting in YAML
Quote any formula with a comma when it sits inside {...}. YAML splits flow mappings on commas, so
{name: x, formula: MAX(0, y)} breaks into pieces. check notices and says what to do:
t: step x has a formula that cannot be read: too many opening parentheses. If it sits inside {...} in the YAML, quote it, because YAML splits {...} on commas
Write {name: x, formula: 'MAX(0, y)'}, or the block form:
- name: x
formula: MAX(0, y)
Use the block form with |- for a formula over several lines:
- name: problems
formula: |-
FILTER({
IF(COUNT(tens) != dice_expected, CONCAT("tens dice rolled: ", COUNT(tens), ", expected ", dice_expected), "")
}, p, p != "")
That one, from the shipped coc_7e_percentile, is a pattern worth copying: build a list of problems as
text, with "" for each check that passed, and filter out the empty ones.
When a Formula Fails
A formula that fails while running fails the run and names the path of steps to it:
$ echo '{"x": 0}' | rmt run divide.yml
✗ d > y: divides by zero
Guard a divisor that can be zero with IF(x = 0, 0, 10 / x).
Every function, with its usage, is listed in Formula Functions.
Next Steps
- Tables and Lookups - Read rows by their columns, on a date or in a band.
- Dates and Dice - Date arithmetic and repeatable rolls.
- Formula Functions - Every function in one place.