Skip to content

Upgrades

Characters pin exact pack versions, and the app decides when, or whether, to upgrade a character. Most new versions need no upgrade work: only base state is saved, so formula fixes and changed item values just recalculate. A version that changes the shape of saved state ships an upgrade script, and the build refuses to release it otherwise.

Between two releases of a pack, these differences are shape changes, because a saved character could refer to something that no longer means the same:

Change kind Covered by
A content item is removed content-removed renameContent or removeContent
An item’s content type changes content-type-changed removeContent
An input field is removed, or stops being an input field-removed renameField or removeField
An input field’s type changes, or an enum loses values field-type-changed mapValues or removeField
A state key is removed or its type changes (on a content type, item or template) state-removed renameState
A choice is removed choice-removed renameChoice
An option is removed from a values or inline-options choice option-removed renameOption
A new required choice appears on existing content or in the rules choice-added requireChoice
An existing item becomes standard (a standard copies query now matches it) standard-copy-added adoptStandard

standard-copy-added is for an item of the pack, in both releases with the same type, that the standard copies queries (content.md) match in the new release but not the old: the rules’ and every item’s, whichever loaded pack declares them. Saved characters hold the item as an ordinary entry, which would otherwise stay beside the new standard copy. An item that stops being standard, and a new item that is standard from the start, are not shape changes.

Adding things (content, fields with a default, optional choices, state keys with a default) and changing formulas, item field values, display hints and translations are not shape changes. Deprecating an item (it stays, marked deprecated) is not a shape change.

Change IDs are qualified:

  • content items and fields: <pack>:<id>;
  • state keys: <pack>:<type or item>/<key>;
  • choices on content: <pack>:<item>/<choice>;
  • rules-level choices: <pack>:<choice>;
  • removed options: <choice id>/<option>.

changes are listed in the table’s order, then by ID. A field that stays but stops being an input counts as field-removed. A new required choice that an existing choice was renamed to is covered by that renameChoice.

The build compares a pack’s new release with its previous release (checkRelease(previous, next, dependencies)). dependencies gives each side’s dependency releases, transitively ({ previous, next }, each [] when absent): checkRelease loads each side with them, as loadRuleset does, to resolve the standard copies queries. A side that doesn’t load fails the check with the load’s errors, each with data.release (previous or next); an engine must load the old release anyway to upgrade characters from it. The result lists the shape changes and these errors:

  • version-not-increased: the new version must be greater than the previous one.
  • version-bump-too-small: a release with shape or interface changes needs a breaking bump: the minor version while the previous version is 0.x (0.1.4 → 0.2.0), the major version from 1.0.0 on (1.4.2 → 2.0.0). Going from 0.x to 1.0.0 or later is a breaking bump too. A release without them may bump any part.
  • missing-upgrade-script: a release with shape changes must include a script with from equal to the previous version and to equal to the new one.
  • upgrade-script-incomplete: each shape change must be covered by a step of that script, as in the table. data names the uncovered change. Interface changes need neither a script nor a step (below).

A pack’s release carries all its upgrade scripts, old ones included, so any pinned version can be upgraded from the latest release.

Characters’ homebrew (characters.md) is built on a pack’s definitions, so they are part of its public interface, like an API. There is no public flag: every definition counts. Between two releases, these differences are interface changes:

Change kind ID
A derived field is removed (homebrew formulas read derived fields) field-removed <pack>:<field>
A content type is removed content-type-removed <pack>:<type>
An item field is removed from a remaining type, or its type or of changes item-field-removed <pack>:<type>/<field>
An enum item field loses values item-field-values-removed <pack>:<type>/<field>
A tag the pack’s rules declare is no longer declared tag-removed <pack>:<tag>
A rules action is removed action-removed <pack>:<action>

checkRelease lists them in changes after the shape changes, in this table’s order, then by ID. Each needs a breaking bump, but no upgrade script: no step can migrate a removed tag, and a character’s homebrew that uses one is reported by upgrade. A step that renames the thing (renameField, renameContentType, renameItemField, mapItemValues, renameTag, renameAction) migrates homebrew that uses it.

Upgrade scripts are data, not code (upgrade.schema.json), stored in upgrades/<from>-to-<to>.yaml. IDs in steps are local to the script’s pack:

pack: 5e-srd-5.1
from: 0.2.1
to: 0.3.0
steps:
- { op: renameContent, from: feats.sharpshooter, to: feats.sharp-shooter }
- { op: setDefault, field: hp.rolled, value: 'hp.max - abilities.con.mod * level.total' }
- { op: requireChoice, owner: classes.fighter, choice: weapon-mastery }

The steps are a fixed set. A step applies to every place saved state can refer to what it names:

Op Params Effect
renameContent from, to Replaces the content ID everywhere: entry items, templates, applied sources, picked options at any depth, members of set input values, and the owner part of prefixed choice keys
removeContent id Removes entries holding it (with their picks), removes it from templates, applied, picks (with their nested picks) and set input values
renameField from, to Moves the saved value
removeField field Drops the saved value
setDefault field, value Where the field has no saved value, saves the formula’s value. The formula is evaluated against the original character under the old ruleset
mapValues field, map Replaces a saved value found as a key of map (compared as text) with the mapped value
renameState type?, from, to Renames a state key on copies of content of type (all copies when absent)
renameChoice owner?, from, to Renames a choice key on entries holding owner, in picks of owner, and in prefixed keys (<owner>/<choice>). Without owner, renames a rules-level choice
renameOption owner?, choice, from, to? Replaces a picked option of the choice (from) with to; without to, removes the pick and its nested picks
requireChoice owner?, choice Records that a new required choice exists; the upgraded character reports it as pending
renameTag from, to Replaces the tag in saved set values of of: tag input fields (and in the homebrew, below)
adoptStandard item Renames the first entry holding the item (features, then inventory, in file order) to the item’s standard copy ID, keeping its state, picks and templates; nothing when an entry has that ID

adoptStandard is for a release that makes an item standard (content.md): entry IDs are the app’s choice, so the script names the item. checkRelease requires one per standard-copy-added.

Steps that change only a character’s homebrew (below):

Op Params Effect
renameContentType from, to Rewrites content type references
renameItemField type, from, to On homebrew items of the type, moves the item field and rewrites item.<from> in their formulas
removeItemField type, field Drops the item field from homebrew items of the type
mapItemValues type, field, map On homebrew items of the type, replaces the item field’s value found in map (compared as text; each member of a set)
renameAction from, to Rewrites action references

upgrade(from, to, character): from is the ruleset the character was saved with, to the ruleset to move it to. See engine/upgrade.schema.json.

  1. Every pinned pack must be in to (unknown-id). Packs in to that the character doesn’t pin are ignored.

  2. For each pinned pack, in pin order, find the chain. Starting at the pinned version, repeat:

    • take the script in to’s release whose from equals the current version;
    • otherwise, the script with the smallest from above it and not above the target (the versions between changed no state);
    • apply it, and continue from its to.

    Stop when no script remains at or below the target. A target below the pinned version is no-upgrade-path.

  3. Apply each script’s steps in order to a copy of the character, then pin the new version.

  4. Report. Compute the sheet under both versions: the original character with from, the upgraded one with to. Then fill in:

    • values: every field whose value differs, as { field, before, after }, sorted by field ID. A field renamed by renameField is compared under its new ID. A field that exists on one side only has only before or after.
    • steps: each step that changed the character (its homebrew included), with the script it came from.
    • homebrew: each place in the homebrew a step rewrote, in order, as { path, step } (path a JSON Pointer into the upgraded character).
    • errors: the upgraded character’s computeSheet errors (a reference no script handled). For a character with homebrew, homebrew-error for each load error of the homebrew on to; the upgraded character is still returned, so the app can show the report and let the player fix the custom content.
  5. pending: the upgraded character’s pending choices, which include any requireChoice.

Upgrading is deterministic, and it never asks the player anything. New required choices come back as pending for the app to put to the player.

A character’s homebrew is loaded on top of both rulesets: on from before the steps (a homebrew that doesn’t load there is homebrew-error, and nothing is upgraded), and on to for the report. Each step rewrites the homebrew too, after saved state.

Where a step looks. The homebrew’s rules and content (never i18n), by kind:

Kind Places
Content extends and item values; members of items lists; item field values of id or set item fields whose of is a content type; string literals in formulas equal to the qualified ID (see below)
Field target, field and into values; members of fields and absorbedBy lists; keys of recommend and of the rules’ display fields; item field values of of: field item fields; names in formulas
Content type type and of values; string literals in formulas equal to the qualified ID
Action use and action values
Tag members of tags lists and of effect targets’ choices; item field values of of: tag item fields; string literals in formulas equal to the tag
  • Matching. A reference matches a renamed <pack>:<id> when it is that qualified ID, or is <id> unqualified and the homebrew’s scope doesn’t resolve it to something else on from. The homebrew’s own gear.rope is left alone when a pack renames its gear.rope. In a source formula (applyEffect’s, an action step’s), a string literal names content as written, so it matches as a reference does.
  • Writing. A rewritten reference is written qualified (mothership-1e:gear.cell), so it keeps resolving whatever the homebrew defines. Names in formulas can’t be qualified: a renamed field’s name is its new local ID.
  • Formulas are the homebrew’s formulas written as text: parsed, renamed in the tree and written back as canonical text (formulas.md).
  • Homebrew items of a type are those whose content type (through extends) is the step’s type, as renamed by earlier steps. renameState on saved copies of homebrew items finds their type the same way.
Step Rewrites in the homebrew
renameContent content references
renameField field references
renameState the state declaration and self.<from> in formulas, on homebrew items of type (every homebrew item without it)
renameContentType content type references
renameItemField, removeItemField, mapItemValues as in the table above
renameTag tag references
renameAction action references
Others nothing: homebrew that still names removed content or fields is reported

After the steps, the homebrew is loaded on to; its load errors are in report.errors (homebrew-error), and the upgraded character is still returned, so the app can show the report and the player can fix the custom content with setHomebrew.