A result-description table (a SuccessTestResult.LimitedDescription[]) maps a
test outcome to a descriptive label — the mechanism that lets a test report
“You go screaming down the halls in terror” instead of a bare “Critical Failure”.
Every success test resolves its outcome through one of these tables to produce the
resultText / resultDesc shown on the chat card, plus a numeric star/quality
count.
They are an extension point: the system ships a few built-ins, and a table can be supplied per test so authors can attach meaningful, flavorful descriptions.
The row shape
Each row of a table is a LimitedDescription:
| Field | Type | Meaning |
|---|---|---|
maxValue | number | Upper bound (inclusive) of the test’s target value this row matches. |
lastDigits | number[] | Roll last-digits this row applies to; empty matches any. |
success | boolean | Whether the row is a success. |
label | string | SafeExpression | The result label (resultText). |
description | string | SafeExpression | The result description (resultDesc). |
result | number | SafeExpression | The numeric result/quality (e.g. star count). |
Resolution finds the first row (sorted by maxValue) whose maxValue >= targetValue
and whose lastDigits matches the roll’s last digit, then reads its
label/description/result.
Derived on read, never stored
The display outcome — resultText, resultDesc, and the numeric valueDiamonds —
is not stored on a SuccessTestResult. Those are getters that resolve the table
against the result’s evaluated successLevel / targetValue / lastDigit each time
they are read, so toJSON carries only the raw successLevel and the table, never
the three derived values (issue #205). The one place the derived strings are
materialized is toChat, which folds them into the chat-card data — rendered once by
the sender (whose targetValueFunc is live) and posted as HTML.
This keeps a single source of truth: change the table and the label changes, with no
stale frozen copy to reconcile. It relies on the table itself crossing the wire (see
Serialization); the raw successLevel is the one deliberately
cached derived value, so a re-evaluated result recomputes its stars identically.
Literal or computed fields
label, description, and result may each be a literal or a
SafeExpression computed at resolution time. A computed
field is evaluated against these bindings:
successLevel— the test’s success level.targetValue— the resolved target value.lastDigit— the roll’s last digit.
import { SafeExpression } from "@src/entity/expr/SafeExpression";
// A row whose star count is one more than the (negative) success level.
const row = {
maxValue: -1,
lastDigits: [],
success: false,
label: "You go screaming down the halls in terror",
description: "",
result: new SafeExpression({ source: "successLevel + 1" }, { parent }),
};
Why
SafeExpression, not a raw function? A computed field must be aSafeExpression, never a JavaScript function. A function is dropped silently byJSON.stringify(and reviving one is forbidden by the security model), so a table with a raw function could not cross to another client — the player watching would see nothing. ASafeExpressionis data (a source string evaluated in the sandbox), so the whole table serializes and every client renders the same flavor text.
SafeExpressiontoday covers numbers, booleans, comparisons, string literals, and+concatenation — enough for a computedresult. Richer string operations for computedlabel/descriptiontext (toUpper,concat,trim, …) are tracked separately.
Serialization
Because a result crosses between clients (a chat card seen by every player), its table rides the wire as pure data, following the subsystem’s reference-on-wire / live-object-in-memory rule:
toJSONreduces each computed field to its serialized form viaserializeLimitedDescriptionTable— aSafeExpressionbecomes its__kind-tagged source string; literals pass through. (Emitting a liveSafeExpressionwould recurse into its parent back-reference during theundefined→nullpass — see the helper’s note.)- The constructor revives each field via
reviveLimitedDescriptionTable, rehydrating a serialized expression into a liveSafeExpressionowned by the result’s parent logic.
Where tables come from
- Built-ins — MasteryLevelModifier’s default
testDescTable(the standard success-level table) andsvTable(the success-value table); the skill fate table. - Per test — a MasteryLevelModifier may be built
with a custom
testDescTable/svTable, or a result with a customresultDescTable, to attach a bespoke set of descriptions to that test.
A resultDescTable is how a generic successTest() becomes a bespoke
graded test without a subclass or a bespoke card — it is the “outcome
mapping as data” half of the pattern in
Extension Points §3.
The other half — offering a follow-up action from the result — is the buttons
input documented next.
The toChat card-data contract
SuccessTestResult.toChat(data?) renders the standard test card
(templates/chat/standard-test-card.hbs). The card template binds directly to
the result’s serialized toJSON() payload, so toChat folds several derived,
non-serialized fields into the card data (they are getters toJSON omits, or
live objects fvttMergeObject’s deep-copy would strip of their prototype). An
author reading the template — or supplying extra data — relies on this
contract:
| Card-data field | Source | Why it must be folded |
|---|---|---|
resultText / resultDesc / valueDiamonds | resolveDescription() against the resultDescTable | Derived-on-read, never stored (see above) |
mlMod | the MasteryLevelModifier’s constrainedEffective, effective, chatHtml, empty, successLevelMod | The Target (roll-at-or-under value) and the per-delta breakdown — live getters |
roll.total | SimpleRoll.total | A getter absent from SimpleRoll.toJSON |
isSuccess / isCritical | the result’s outcome getters | Drive pass/fail styling and the localized footer |
item.uuid / actor.uuid | the owning item and its actor | The card root, edit-pencil, and Fate button dispatch against these |
fateScopeJSON | serialized { priorTestResult: this }, only when Fate is offered | Lets a Fate click reconstruct this result (never a re-roll) |
A subclass or a caller that reposts the card must fold the same fields, or the Target, Roll, styling, and buttons render blank — the class binds to
toJSON, not the live object.
Follow-up buttons (data.buttons)
toChat accepts an optional buttons entry in data — one
ActionCardButton or an array. It is folded through the
shared toRenderableButtons normalizer (the same one the action-card framework
uses), so the standard card can carry arbitrary follow-up consent buttons
without a bespoke template:
await result.toChat({
buttons: {
action: "applyShockState",
handlerUuid: beingUuid,
scope: { priorTestResult: result },
label: sohl.i18n.localize("SOHL.Being.ShockTest.apply"),
iconFAClass: "fa-solid fa-face-dizzy",
},
});
Each rendered button carries the well-known action-card-button handles
(data-action / data-handler-uuid / data-scope / data-skip-dialog) and
dispatches through the shared chat-card chokepoint. Nothing auto-fires — the
button is offered and the target’s controlling player accepts (the consent
model). Combined with resultDescTable, a graded test is now the outcome
mapping (resultDescTable) plus the follow-up actions (buttons), both
as data — removing the last reason several bespoke result cards existed. The
existing edit-pencil and Perform Fate Test buttons are unaffected.