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.
What changes the saved state’s shape
Section titled “What changes the saved state’s shape”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 check
Section titled “The build check”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 withfromequal to the previous version andtoequal to the new one.upgrade-script-incomplete: each shape change must be covered by a step of that script, as in the table.datanames 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.
A pack’s interface
Section titled “A pack’s interface”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
Section titled “Upgrade scripts”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.1from: 0.2.1to: 0.3.0steps: - { 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 |
The upgrade call
Section titled “The upgrade call”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.
-
Every pinned pack must be in
to(unknown-id). Packs intothat the character doesn’t pin are ignored. -
For each pinned pack, in pin order, find the chain. Starting at the pinned version, repeat:
- take the script in
to’s release whosefromequals the current version; - otherwise, the script with the smallest
fromabove 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. - take the script in
-
Apply each script’s steps in order to a copy of the character, then pin the new version.
-
Report. Compute the sheet under both versions: the original character with
from, the upgraded one withto. Then fill in:values: every field whose value differs, as{ field, before, after }, sorted by field ID. A field renamed byrenameFieldis compared under its new ID. A field that exists on one side only has onlybeforeorafter.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 }(patha JSON Pointer into the upgraded character).errors: the upgraded character’scomputeSheeterrors (a reference no script handled). For a character with homebrew,homebrew-errorfor each load error of the homebrew onto; the upgraded character is still returned, so the app can show the report and let the player fix the custom content.
-
pending: the upgraded character’s pending choices, which include anyrequireChoice.
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
Section titled “A character’s homebrew”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 onfrom. The homebrew’s owngear.ropeis left alone when a pack renames itsgear.rope. In asourceformula (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’stype, as renamed by earlier steps.renameStateon 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.