Showing text
Text and translation explains where text lives and how each key is
built. This page shows the shortcut: @opencharacter/text, a small helper that builds the keys
from engine output and formats the messages for you. The engine still returns no text; the
helper is on your app’s side, and you can drop it for your own code at any time.
It’s a reference helper, not part of the spec: another engine’s output works with it too, and a port needn’t have one.
A text bundle
Section titled “A text bundle”Build one bundle per player language from the packs’ text and the spec’s standard text:
import { createText } from '@opencharacter/text';import packsEn from '@opencharacter/packs/i18n/en';import specEn from '@opencharacter/spec/i18n/en';
const text = createText({ languages: ['en'], // lookup order, the player's language first: ['de', 'en'] packs: { en: packsEn }, // by language, then pack ID spec: { en: specEn }, contentTypes: ruleset.contentTypes, // optional: field defaults for content cards content: ruleset.content, // optional: content types for headlines display: ruleset.rules.display, // optional: field formats for headlines});packsis keyed by language, then pack ID, the shape of@opencharacter/packs/i18n/<lang>. A single pack’s text goes in as{ en: { '5e-srd-5.1': srdEn } }; a character’s homebrew text under the pack IDhomebrew(character.homebrew.i18n.en).- A key missing in every listed language falls back to any other language the bundle holds for that pack, so a pack’s default language fills the gaps of a partial translation.
- Messages are formatted with ICU MessageFormat (
intl-messageformat), in the language they were found in. - A name that’s missing comes back as the ID you passed, never as an error, so a gap shows up
on screen instead of breaking it.
text.message(pack, key)andtext.specMessage(key)returnundefinedwhen you want to know.
text.content('5e-srd-5.1:armor.shield'); // 'Shield'text.copy({ item: '5e-srd-5.1:weapons.longsword', templates: ['5e-srd-5.1:magic-items.weapon-plus-one'],});// 'Longsword +1'text.field('5e-srd-5.1:combat.ac'); // 'Armor Class'text.field('5e-srd-5.1:combat.ac', { short: true }); // 'AC'text.type('5e-srd-5.1:weapon'); // 'Weapon'text.tag('5e-srd-5.1:save.int'); // 'Intelligence saving throw'text.tag('save.int', '5e-srd-5.1'); // the same, for a tag as a set field holds ittext.setMember('skill.arcana', '5e-srd-5.1'); // 'Arcana': content, else field, else tag, else the IDtext.action('5e-srd-5.1:features.fighter.second-wind#second-wind'); // 'Second Wind'text.band('fitd-scoundrel:action', 'partial'); // 'Partial success'Content cards
Section titled “Content cards”A spell, a weapon or a feat reads best as a card: a header from its mechanics, then its text. Packs write the header as a block, a text template per content type, so you never lay one out from field names:
const fireBolt = ruleset.content['5e-srd-5.1:spells.fire-bolt'];text.contentBlock(fireBolt);// '**Fire Bolt** — *Evocation cantrip* · 1 action · 120 ft · V, S · Instantaneous'text.contentCard(fireBolt);// the block, a blank line, then the description: 'You hurl a mote of fire at a creature …'text.value('5e-srd-5.1:spell', 'school', 'evocation'); // 'Evocation'- Show
contentCardfor an item’s card. It is Markdown: the block and the description, or whichever the item has;undefinedwhen it has neither.contentBlockis the header alone,undefinedwhen the pack has no block for the item. - Pass the item as the ruleset holds it (
ruleset.content[id]). For a copy on a sheet, also pass the copy,{ templates, fields, state, computed }(computedfrom computeSheet’scopies[entry]), so the card names a Longsword +1 and can show the copy’s state. - Give the bundle
contentTypes(ruleset.contentTypes), or pass the item’s type as the third argument: a field the item leaves out then reads as its declared default (a Fate stunt’saction: any), as the rules read it. - Render the Markdown, or show it as plain text, as you do descriptions.
- A field the player names (a orc-2e Lore of their city) has
nameon its computeSheet value: pass it,text.field(id, { name: sheet.values[id].name })(“Absalom Lore”); without it the text reads “Lore”. - Templates have cards too: pass a template as the ruleset holds it (a orc-2e
rune,
type: 'template') andcontentCardgives its own block (“Flaming rune — Rune 8 · Magical, Property rune · 500 gp”) and description. text.value(type, field, value)names an enum value outside a card, for a filter or a column. A rules-level field’s enum value istext.fieldValue(field, value)(fields.<field>.values.<v>.name:creature.size’smediumis “Medium”), and an action parameter’stext.paramValue(action, param, value)(<actionKey>.params.<param>.values.<v>.name).
Forms: item fields and parameters
Section titled “Forms: item fields and parameters”A form labels each input with the pack’s text, never a humanized ID:
text.typeField('mork-borg-core:weapon', 'attackDrModifier'); // 'Attack DR modifier'text.typeFieldDescription('5e-srd-5.1:armor', 'maxDex'); // 'The most Dexterity modifier AC adds.'text.templateField('5e-srd-5.1:acBonus'); // 'AC bonus', for a custom template's formtext.param('mork-borg-core:test', 'dr'); // 'Difficulty rating (DR)'text.paramDescription('mork-borg-core:test', 'dr');// 'The target to beat. 12 for most rolls; the GM raises or lowers it.'- Every item field has a name (
types.<type>.fields.<field>.name); a hint (.description) is optional, one line, for help text under the input. Missing text falls back to the field’s ID; a missing hint isundefined. - A parameter’s hint is
<actionKey>.params.<param>.description, optional.actionisneeds.action’s form, so an inline action is<item>#<action>. A parameter with an explicitlabelkey is named bytext.lookupwith that key.
Headlines
Section titled “Headlines”A character list wants a line under each name: “Level 3 Hill dwarf Wizard”, not the pack’s name.
Each pack words it as its headline, a text template, so your app needs no
code per game:
const sheet = computeSheet(ruleset, character);text.headline(sheet, character); // 'Level 3 Hill dwarf Wizard'text.headline(sheet, character) ?? text.pack(character.packs[0].id); // with a fallback| Pack | A finished character | While it’s created |
|---|---|---|
| 5E SRD | “Level 3 Hill dwarf Wizard”, “Hill dwarf Wizard 2 / Fighter 1”, “Goblin, CR 1/4” | “Dwarf”, then “Hill dwarf” |
| orc-2e | “Level 2 Elf Rogue” | “Elf” |
| FitD scoundrel | a table’s own playbook: “Bruiser”, “Bruiser of the Ravens” with the crew’s name | nothing without a playbook |
| FitD crew | “Tier 1”, or “Tier 1 Shadows” with a table’s own crew type | “Tier 0” |
| Mothership | “Marine” | nothing before a class |
| Fate Core | the high concept | nothing before one |
| MÖRK BORG | “Heretical Priest”, “Classless”, “Ancient Gore-Hound” | nothing before a class |
undefinedmeans fall back: no pack has a headline, or it words nothing yet (a sheet with nothing picked). Show the pack’s name, or nothing.- Pass the character file for its packs’ order (the last pack with a headline words it, homebrew last), its copies’ templates and state (a class’s level).
- Give the bundle
contentanddisplay(ruleset.content,ruleset.rules.display): withoutcontenta headline can’t find the character’s race or class; withoutdisplaya number shows as is (“CR 0.25”, not “CR 1/4”). - Linked characters’ names are yours: the format doesn’t name characters, so pass the names
of the characters filling its link slots, by slot ID:
text.headline(sheet, ash, { links: { 'fitd-scoundrel:crew': 'the Ravens' } })is “Bruiser of the Ravens”. - It’s plain text on one line: no Markdown, spaces collapsed.
Choices
Section titled “Choices”A choice’s prompt comes from its owner, never from its path. Pass any entry of
computeSheet’s choices (or a pause’s needs.choice):
const sheet = computeSheet(ruleset, marine);for (const choice of sheet.choices) console.log(text.choicePrompt(choice));// 'Choose one Expert skill or two Trained skills'// 'Choose two Trained skills' (owner: …classes.marine.choices.bonus-skills.options.trained)A slot of a slotted choice is worded with its number: a wizard’s spellbook slots read “Choose six 1st-level spells for your spellbook”, then “Choose two spells for your spellbook (level 2)”.
An option is labeled by text.option(id, context), whatever its kind:
| Option ID | Label from |
|---|---|
#done |
the spec’s actions.options.done.name (“Done”); over pending choices (context.choice) actions.options.done.pending.name (“Skip the rest”) |
<content id>#<option> (added by content) |
<content>.options.<option>.name in the content’s pack |
| a content ID | the item’s name |
a copy’s instance ID (context.copies) |
the copy’s name |
an inline option or value (context.choice) |
<owner>.choices.<choice>.options.<id>.name, else as a set member (skill.arcana is “Arcana”) |
a written option of an action (context.action) |
<actionKey>.options.<id>.name, else as a set member |
const bonus = sheet.choices[0];text.option(bonus.picks[0], { choice: bonus }); // 'Two Trained skills'A paused action
Section titled “A paused action”describeNeed words a whole pause in one call:
import { describeNeed } from '@opencharacter/text';
const result = startAction(ruleset, character, input);if (result.status === 'paused') { const { title, options, fields, dice, targets } = describeNeed(ruleset, text, result.needs, { character, // names the options of a step over copies });}needs.kind |
title |
Also |
|---|---|---|
choice |
the asked choice’s prompt over pending choices, else the step’s prompt | options, each { id, label } |
allocate |
the step’s prompt | fields, each { id, label } |
dice |
the roll step’s name, else the action’s | dice, and targets for a banded roll |
handoff |
the name of the action the participant runs |
A FitD action roll with a push, for a scoundrel with Battleborn:
{ title: 'How do you pay for pushing yourself?', options: [ { id: 'stress', label: 'Take 2 stress per push' }, { id: 'fitd-scoundrel:special-abilities.battleborn#special-armor', label: 'Mark special armor instead of stress (Battleborn: pushing while fighting)', }, ], fields: [],}A Mothership stat check with advantage, paused for dice:
{ title: 'Stat check', options: [], fields: [], dice: '2d100, keep lowest', targets: [ // … { band: 'success', name: 'Success', condition: 'Roll under 34 (90+ always fails)' }, // … ],}- Targets.
text.rollTargets(summary)words a roll summary (a dice pause’sneeds.summary, a log entry’ssummary): each band the roll can still reach with its name and what it needs. The pack’sbands.<set>.<band>.conditiontext wins; without it, the spec’s comparisons are joined (“10 or higher and under 20”). A band that holds whatever the roll has no condition unless its pack words one. - Dice.
text.dice(items)words a roll’s dice items (orneeds.dice): “4d6, drop lowest”, “2d20, keep highest - 1d4”, “4dF”, “5d6, exploding on 6”. Flat items are left out, and so is a keep or drop that can’t change the roll: one die kept highest is “1d6”. The words are the spec’sdice.*text, so they translate like the rest.
Issues and errors
Section titled “Issues and errors”text.issue(issue); // the pack's own text when it has one, else the spec'stext.error(error); // the spec's text, data filledprerequisite-not-metuses<owner>.prerequisites.<id>,constraint-failedconstraints.<id>, andtoo-many-copiesthe type’stypes.<type>.copies(“A character has only one race”), each with the issue’sdataas arguments.- The spec’s generic text gets
datawith IDs replaced by names:{ code: 'not-input-field', data: { field: '5e-srd-5.1:combat.ac' } }reads"Armor Class" is calculated and cannot be set directly.
Your own formatter
Section titled “Your own formatter”The key builders are exported as keys, each returning { pack, key } (pack: null for the
spec’s text), if you’d rather format messages yourself:
import { keys } from '@opencharacter/text';
keys.choicePromptKey(choice); // { pack: 'mothership-1e', key: 'classes.marine.choices.…prompt' }keys.stepKey({ action: 'mothership-1e:panic-check', step: 'panic/panic' });// { pack: 'mothership-1e', key: 'actions.panic-check.steps.panic.prompt' }The helper doesn’t follow an explicit prompt or label written in the mechanics (no official
pack has one); if your packs use them, look those keys up with text.message.