Result-description tables

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:

FieldTypeMeaning
maxValuenumberUpper bound (inclusive) of the test’s target value this row matches.
lastDigitsnumber[]Roll last-digits this row applies to; empty matches any.
successbooleanWhether the row is a success.
labelstring | SafeExpressionThe result label (resultText).
descriptionstring | SafeExpressionThe result description (resultDesc).
resultnumber | SafeExpressionThe 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 a SafeExpression, never a JavaScript function. A function is dropped silently by JSON.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. A SafeExpression is data (a source string evaluated in the sandbox), so the whole table serializes and every client renders the same flavor text.

SafeExpression today covers numbers, booleans, comparisons, string literals, and + concatenation — enough for a computed result. Richer string operations for computed label/description text (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:

  • toJSON reduces each computed field to its serialized form via serializeLimitedDescriptionTable — a SafeExpression becomes its __kind-tagged source string; literals pass through. (Emitting a live SafeExpression would recurse into its parent back-reference during the undefined→null pass — see the helper’s note.)
  • The constructor revives each field via reviveLimitedDescriptionTable, rehydrating a serialized expression into a live SafeExpression owned by the result’s parent logic.

Where tables come from

  • Built-insMasteryLevelModifier’s default testDescTable (the standard success-level table) and svTable (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 custom resultDescTable, 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 fieldSourceWhy it must be folded
resultText / resultDesc / valueDiamondsresolveDescription() against the resultDescTableDerived-on-read, never stored (see above)
mlModthe MasteryLevelModifier’s constrainedEffective, effective, chatHtml, empty, successLevelModThe Target (roll-at-or-under value) and the per-delta breakdown — live getters
roll.totalSimpleRoll.totalA getter absent from SimpleRoll.toJSON
isSuccess / isCriticalthe result’s outcome gettersDrive pass/fail styling and the localized footer
item.uuid / actor.uuidthe owning item and its actorThe card root, edit-pencil, and Fate button dispatch against these
fateScopeJSONserialized { priorTestResult: this }, only when Fate is offeredLets 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.