Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/added-param-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@opencharacter/spec': minor
'@opencharacter/engine': minor
---

A `params` effect may add bounds to a parameter the run already has: an entry with only `min`
and/or `max` (no `type`) bounds the action's own parameter, or one another effect adds, instead
of declaring it. Every bound must hold; the `out-of-range` error names that parameter, with the
failing bound's `min` and `max` and `boundBy`, the content that added it. describeAction lists
them under the parameter's `addedBounds`. Two bounds on one name don't conflict; a bound on a
name no action declares and no effect adds is `invalid-added-param` (reason `undeclared`). So
Healing Grace's enhancements bound the ability's own `spend`, and an overspend names `spend`
(#252).
20 changes: 20 additions & 0 deletions .changeset/derived-params-computed-in-effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@opencharacter/spec': minor
'@opencharacter/engine': minor
---

Derived parameters: an action parameter (its own, or one a `params` effect adds) may declare a
`formula` instead of being given. startAction works it out once every given parameter has its
value and before any bound, in declaration order, reading the given parameters and the derived
ones before it (a later one is `unknown-name` at load, so they can't loop), and the run's
`participants`, now checked before the parameters (their errors come first). Everything reads it
as `params.<name>`. A value for it from the app, a `use`, an `action` step or a handoff is the new
error `invalid-param` (reason `derived`); the build reports a `use`'s, a creation start's, a
field roll's, an `action` step's and an `action` effect's. describeAction lists it with
`derived: true`.

Effects that act as an action runs (on rolls, choice and changes steps, listed actions and
incoming changes) read their content's computed values as `self.<name>`, as its actions do. A
field effect still can't (`unknown-name` at load): computed values are worked out after every
field, so one feeding a field could loop. So Cunning Strike's cost is worked out once, and its
DC is a computed value its strike effects read (#251).
3 changes: 2 additions & 1 deletion apps/website/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ One-line definitions of OCF terms, consistent with the [specification](../../pac
- **Choice path:** the address of a choice: an optional entry, then alternating choice IDs and picked options.
- **Clock action:** a rules action the app runs at a moment of its clock on every character: `clock: turn-boundary` at each turn change, with the IDs of the characters whose turn ended and started.
- **Competing options:** several active `set` effects on one field, or the effects of a `playerChooses` stacking group (one group across every field for a key spanning fields, an option per source); the engine reports them and the recommended one, and the app picks.
- **Computed value:** a value worked out from the character for each copy of an item (a FitD cohort's quality), or for picked or granted content (a Draw Steel ability's damage), declared by its content type, item or templates, never saved; computeSheet returns it in `copies` or `activationValues`, its actions and follow-ups read it as `self.<name>`, and its text as `{self_<name>}`.
- **Computed value:** a value worked out from the character for each copy of an item (a FitD cohort's quality), or for picked or granted content (a Draw Steel ability's damage), declared by its content type, item or templates, never saved; computeSheet returns it in `copies` or `activationValues`, its actions, follow-ups and the effects that act as they run (on rolls, steps, listed actions and incoming changes) read it as `self.<name>`, and its text as `{self_<name>}`.
- **Condition:** a formula that must evaluate to true or false (`when`, `showWhen`, `where`, prerequisites).
- **Constraint:** a game rule in the rules data, checked against the current character, whose failure is a rules issue.
- **Content order:** the order the engine lists content in: packs in load order, each pack's content in its release's order (file path order), homebrew last; query matches and choice options follow it, so a dependency's content comes first.
Expand All @@ -36,6 +36,7 @@ One-line definitions of OCF terms, consistent with the [specification](../../pac
- **Default language:** the pack's language whose translation file must be complete and ships with the mechanics.
- **Derived field:** a field computed from a formula and effects; never saved.
- **Derived offer:** a follow-up (`while`) offered exactly as long as its condition, an imbalance on the sheet, holds: a level-up the experience has earned, or a linked character to make for an empty link slot (`create`). computeSheet derives it; it is never stored or dismissed.
- **Derived parameter:** an action parameter that declares a `formula` instead of being given: worked out as the action starts from the given parameters (and earlier derived ones), read as `params.<name>`; describeAction marks it `derived`.
- **Dice function:** the swappable function the engine calls for die faces; app-supplied or the built-in random one.
- **Dice modifier:** one of the fixed set of notation modifiers (keep, drop, explode, reroll, count successes, …).
- **Display hint:** optional presentation data (groups, priority, format, unit, `showWhen`, hidden) that never affects calculations. A display group holds fields, actions or content.
Expand Down
4 changes: 4 additions & 0 deletions apps/website/guides/rolling-dice.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ const described = describeAction(ruleset, character, {
included), and for a field, content or tag parameter the `options` startAction takes, its
`where` applied. Offer those as the picker.
- **A parameter the item's `use` fixes** has `fixed: true` and its `value`: don't ask for it.
- **A derived parameter** has `derived: true`: startAction works it out from the others (a
Cunning Strike's cost in Sneak Attack dice). Don't ask for it; giving one is `invalid-param`.
- **A parameter content adds** (a `params` effect: an ability's damage type chosen on each use,
a feature's opt-in) comes after the action's own, with `addedBy`. Ask for it like the others.
Bounds content adds to a parameter are in its `addedBounds` (each with `addedBy`): every one
must hold, and an `out-of-range` error for one carries `boundBy`, the content that added it.
- **Label each** with `text.param(definedBy, name)` (an added one with
`text.addedParam(addedBy, name)`), or `text.lookup` with its own `label` key.
- **`participants`** lists the action's roles with `min` and `max`
Expand Down
82 changes: 81 additions & 1 deletion packages/engine/src/check/formulas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,13 @@ class FormulaChecker<L> {
this.formula(aura.size, at, `${where} size`, NUMBER);
this.typed({ applier: viewType(aura.fields ?? {}) }, () =>
this.withRoots(['applier'], () => {
for (const effect of aura.effects ?? []) this.effect(effect, at, where);
const outer = this.inAura;
this.inAura = true;
try {
for (const effect of aura.effects ?? []) this.effect(effect, at, where);
} finally {
this.inAura = outer;
}
for (const immunity of aura.immuneTo ?? []) this.immunity(immunity, at, where);
}),
);
Expand Down Expand Up @@ -734,8 +740,32 @@ class FormulaChecker<L> {
};
}

/** Inside an aura: its effects' `self` is the aura's entry, which has no computed values. */
private inAura = false;

/**
* An effect that acts as an action runs (on rolls, choice and changes steps, listed actions and
* incoming changes) reads its content's computed values as `self.<name>`: they are worked out
* after every field, which such an effect never feeds. A field effect can't (`unknown-name`),
* nor can an applied one or an aura's, whose `self` is an applied entry.
*/
private effect(effect: Effect, at: L, where: string): void {
const kind = targetKind(effect.target);
const self = this.types.self;
if (kind === 'field' || effect.scope === 'applied' || this.inAura || self === undefined)
this.effectOf(effect, at, where, kind);
else
this.typed({ self: revealComputed(self) as FType }, () =>
this.effectOf(effect, at, where, kind),
);
}

private effectOf(
effect: Effect,
at: L,
where: string,
kind: ReturnType<typeof targetKind>,
): void {
if (kind === 'roll') {
this.typed(this.rollTypes(), () =>
this.withRoots(ROLL_ROOTS, () => this.effectBody(effect, at, where, kind)),
Expand Down Expand Up @@ -852,6 +882,33 @@ class FormulaChecker<L> {
this.formula(decl.max, at, `${place} max`, NUMBER);
}),
);
// A derived one sees the run's participants too.
this.typed({ params: 'dyn', participants: 'dyn' }, () =>
this.withRoots(['params', 'participants'], () =>
this.formula(decl.formula, at, `${place} formula`, [fieldFormulaType(decl.type)]),
),
);
// Of this effect's derived ones, it reads only those before it, so they can't loop.
const tree = decl.formula === undefined ? undefined : formulaTree(decl.formula);
if (tree?.ok) {
const entries = Object.entries(effect.value as Record<string, ValueDecl>);
const index = entries.findIndex(([n]) => n === name);
const later = new Set(
entries
.slice(index)
.filter(([, d]) => d.formula !== undefined)
.map(([n]) => `params.${n}`),
);
for (const ref of references(tree.value, new Set()))
if (later.has(ref) || [...later].some((l) => ref.startsWith(`${l}.`)))
this.problems.push({
at,
where: `${place} formula`,
formula: decl.formula as Formula,
code: 'unknown-name',
message: `"${ref}" is a derived parameter declared at or after "${name}": a derived parameter reads only those before it`,
});
}
if (decl.where === undefined || decl.type !== 'id' || decl.of === undefined) continue;
if (decl.of === 'field') this.query({ type: 'field', where: decl.where }, at, place);
else if (decl.of !== 'tag' && decl.of !== 'string')
Expand Down Expand Up @@ -1105,6 +1162,29 @@ class FormulaChecker<L> {
}),
),
);
// A derived parameter sees what a bound sees and `participants`, but only the derived
// parameters before it: a later one, or its own, is `unknown-name`, so they can't loop.
const derived = Object.entries(action.params ?? {})
.filter(([, decl]) => decl.formula !== undefined)
.map(([name]) => name);
for (const [index, name] of derived.entries()) {
const decl = action.params?.[name] as ValueDecl;
const later = new Set(derived.slice(index));
const all = (types.params as { object: Record<string, FType> }).object;
const params: FType = {
object: Object.fromEntries(Object.entries(all).filter(([n]) => !later.has(n))),
};
const seen = { situational: types.situational, participants: types.participants };
this.typed({ ...own, ...(seen as Record<string, FType>), params }, () =>
this.at('content', () =>
this.onlyRoots(['situational', 'participants', 'params'], () =>
this.formula(decl.formula, at, `${key} param ${name} formula`, [
fieldFormulaType(decl.type),
]),
),
),
);
}
this.typed(types, () => this.actionBody(action, at, key, each));
}

Expand Down
Loading
Loading