Skip to content

v0.117.0 — Buffs

Merged 2026-10-05 (ca7f4f72) — built on version/0.117.0; the tag is in the changelog. Module release: dnd5e 0.21.0.

The roadmap page is Buffs. This release is the evaluator, plus static modifiers working end to end in 5e (AC, speed, HP, ability scores):

  • One pure resolve() in shared runs the stage pipeline: set (the highest wins), then add and percent, then floor (the highest wins), then ceiling (the lowest wins). The result does not depend on the order of the modifiers.
  • The module's stacking policy applies first, and every result comes with a breakdown.
  • Equipment, feats, powers, conditions and TimedEffects all carry modifiers.
  • dnd5e routes every derived number through the evaluator.

Decisions B-D1–B-D7 were taken at their recommendations:

  • There is no buffs table: every applied buff is a TimedEffect.
  • There is no Character/Token prompt.
  • Effects stay on their map.
  • Multipliers are percent of the base.
  • 5e's set-score items are floors.
  • Roll-time modifiers come next release.
  • 5e goes first.

Roll-time modifiers and System 8 follow in the next release.

The model and the evaluator

  • [x] B.1 — BuffEffect v2:
    • The attribute / condition union, with target, an op of add | percent | set | floor | ceiling, a number or expression value, and stackingGroup.
    • Buff gains category and positivity, and its duration becomes optional.
    • One zod schema in shared, used by the equipment, characters (feats and powers) and effects routes.
    • upgradeBuff converts v1 data when it is read.
    • Done: the types live in types/buff.ts.
      • BuffOp, AttributeBuffEffect, ConditionBuffEffect and Buff, with category, positivity and an optional duration.
      • parseBuff / parseBuffList / parseBuffEffect / parseBuffDuration are the one validator. They accept the v1 effect shape and return v2.
      • upgradeBuffs reads stored data leniently, and describeBuffEffect gives each effect a short line.
      • The validator is zod-free, because shared is on zod 3 and server-core on zod 4, and a schema can't cross that line.
    • utils/buffSchema.ts's BuffListSchema wraps it for the routes:
      • The equipment route's local BuffSchema is gone.
      • Feats and powers no longer accept z.array(z.any()).
      • A PATCH that omits buffs leaves them alone.
    • EquipmentSection reads its badges through upgradeBuffs and describeBuffEffect.
    • Prod's stored buff rows were not counted (no prod access from here). Rows written before this are upgraded on read and on their next write, so no one-off migration was written.
  • [x] B.2 — The evaluator: resolve(key, base, modifiers, policy, valueOf?) → { total, parts }.
    • The pipeline, percentOf, the four stacking policies, and prefix matching of keys.
    • It becomes the body of calculateModifiedValue and getAttributeValue.
    • Table tests for S8, 5e, 3.5 and PF2e, plus a shuffle property test for order independence.
    • Done: buffs/resolve.ts holds resolve(key, base, modifiers, policy, evaluate?) → { key, base, total, parts }.
      • A Modifier is an attribute effect plus its buffName (5e's stacking identity) and its source.
      • BuffPolicy has three fields: sameSource, groups and percentOf. DEFAULT_BUFF_POLICY stacks everything.
      • targetMatches handles exact keys, x.* (which also reaches x) and *, case-insensitively.
      • Every modifier stays in parts with a status: applied, same-source, stacking-group, superseded, no-effect or unresolved.
      • describePart / describeResolved render 10 base · +3 Dex · +5 Shield.
      • "Most potent" breaks an exact-magnitude tie toward the positive value, so +2 and −2 from one source never depend on arrival order.
    • In types/character.ts:
      • calculateModifiedValue(subtotal, modifiers, { key, policy, evaluate }) is now real.
      • resolveAttribute returns the breakdown, and the minimum component joins stage 4 as a floor.
      • getAttributeValue with no modifiers is still exactly sumComponents, so the ~60 callers are unchanged.
      • attributeTargetKey gives the generic attribute.<slug> key.
    • The expression option is evaluate, not valueOf: {} had a valueOf already, and tsc refused the default.
    • 37 tests cover the original spec's pinned examples, S8 / 5e / 3.5 / PF2e tables, and 200 deterministic shuffles of each of six fixtures.
  • [x] B.3 — collectModifiers(context): collects from equipped equipment, enabled feats and powers, conditions and running TimedEffects, each tagged with its source.
    • Implied conditions are added, with a guard against recursion.
    • It also returns the effective condition list.
    • Done: buffs/collect.ts.
      • collectModifiers({ character, token, equipment, conditions, effects }) → { modifiers, active, conditionIds, implied }, over structural input types so the server and the client can both call it with what they have.
      • Equipment applies only while equipped. A feat applies unless enabled: false, because a feat you have is passive. A power applies only while enabled: true, because a power is something you switch on.
      • TimedEffects contribute only while running (endedAt null) and only for the token being collected for.
      • Conditions come from the character and the token, matched against the catalogue by id first, then by name, case-insensitively (findCondition). A condition effect implies its condition, and that condition's own effects and implications are collected breadth-first, each once.
      • Every buff in play is listed in active, including one whose only effect is a condition.

Where modifiers live

  • [x] B.4 — Equipment, feats and powers feed it: equipped and enabled are read, and toggling them re-projects.
    • Done: the reading happens in B.3's collector.
    • Equipment writes now re-project the holder's linked tokens (runCharacterFanOutById, after the response, never throwing) when they change what the character projects. That covers an item created equipped with a buff, equipped or unequipped, its buffs edited while equipped, or removed. equipmentBuffSignature decides, so a rename does nothing.
    • A Treasury transfer that moves a whole equipped, buffed item re-projects the character it left, since the transfer unequips it.
    • Feats and powers are character writes, which already fan out.
    • Fixed on the way: UpdateEquipmentSchema was NewEquipmentSchema.partial(), and zod 4 keeps a .default() under .partial(). So every equipment PATCH that didn't mention equipped or quantity wrote false and 1: renaming an equipped item unequipped it.
    • The same trap was in mapObjects.ts, chatMessages.ts and a character's skill and attack PATCH. It's fixed under Misc, with a guard.
  • [x] B.5 — Conditions carry effects: ICondition.effects and a conditions.effects column (both dialects, journalled).
    • DEFAULT_CONDITIONS get speed ceilings of 0.
    • The fixture version is bumped so they re-seed.
    • Done: ICondition.effects (shared, and the web's copy).
      • conditions.effects in both dialects: migrations-pg/0056_conditions_effects.sql (journalled) and the sqlite addColumn.
      • parseBuffEffectList / BuffEffectListSchema validate on POST / PATCH /api/conditions and on the system-trait routes.
      • seedSystems (module fixtures) and the system clone carry the column. The campaign copy already spreads the row.
      • SPEED_ZERO (a speed.* ceiling of 0) is on the platform defaults Grappled, Restrained, Paralyzed, Petrified, Stunned, Unconscious, Dying and Dead.
    • As built: the platform defaults aren't a versioned fixture. Each campaign gets its own copy through seed-defaults, so there's no version to bump. Instead, seed-defaults backfills the defaults' effects onto a campaign's same-named rows whose column was never set (null). A GM's own [] is left alone, and the batch still announces once.
    • The module fixtures' version bump is B.18.
  • [x] B.6 — TimedEffects carry effects: timed_effects.effects and .category (one migration, both dialects), validated and returned.
    • startBuff passes effects, positivity and category instead of dropping them.
    • createEffect and editEffect accept them.
    • Done: timed_effects.effects (JSON) and .category in both dialects, via 0057_timed_effects_effects.sql and the sqlite addColumns.
      • POST / PATCH /api/maps/:id/effects validate effects through BuffEffectListSchema (v1 upgraded). category is trimmed, up to 64 characters.
      • A change that doesn't name them leaves them alone, and an empty list is stored as null.
      • Every read returns effects ([] when none, and [] for a stored value that doesn't parse rather than failing the list) and category.
    • Client: TimedEffect / EffectInput in useTimedEffects, the Action API's EffectInput, and api.timeline.startBuff, which now sends the buff's effects, positivity and category instead of dropping them.
  • [x] B.7 — Concentration carries its effects: { type: 'effect', id } links.
    • Ending an effect, by any path, ends the effects linked to it.
    • startBuff(buff, { tokenId, parent }) writes the link.
    • Done: EffectLink.type gains effect, accepted by the route and typed on the client.
      • endEffect ends every running effect that links to the one it ended, with endedBy: 'linked'. It's recursive, and an effect is marked ended before its own dependents are looked for, so a cycle ends once.
      • Every ending goes through endEffect, so the cascade covers a hand, End Concentration, a condition's function, expiry and a function duration.
      • Removing a parent (DELETE) ends its dependents at the current tick rather than removing them, because they did run.
    • api.timeline.startBuff(buff, { tokenId, parent }) takes an effect or its id and writes the link.
  • [x] B.8 — Cleansing: api.timeline.endEffects({ tokenId, category }). Action API minor bump, with completions and docs.
    • Done: Action API 0.8.0.
    • api.timeline.endEffects({ tokenId?, category }) takes a string or a list, case-insensitive, and the token defaults to the acting one. It ends every running effect on the token of that category, each through the server's end (so the same permissions as endEffect), and returns { ended, refused } rather than throwing on a refusal.
    • version.ts records 0.8.0, which also covers B.6–B.7: startBuff carrying effects / positivity / category, parent, and effects / category / effect links on createEffect.
    • The completions have endEffects and a rewritten startBuff.
    • In api-timeline.md: the signature and EffectInput gained the new fields, the Buffs section explains the five operations and parent, and there's a new Cleansing section. The function index has a row for it.

The Module API and the readers

  • [x] B.9 — Module API: optional modifierTargets() ({ key, label, group, metadataKey? }[]), buffPolicy and buffCategories.
    • A ModifierSet passed into getAttributes, projectToken and SheetRendererProps.
    • Generic attribute.<slug> defaults for modules that declare nothing.
    • Module API minor bump.
    • Done: Module API 1.42.0.
      • SystemModule.modifierTargets?(): ModifierTarget[] ({ key, label, group?, metadataKey? }), buffPolicy?: Partial<BuffPolicy> and buffCategories?: BuffCategory[].
      • A trailing modifiers?: ModifierSet on getAttributes, getTokenAttributes, getDerivedStats and projectToken, plus SheetRendererProps.modifiers. All are additive.
    • buffs/modifierSet.ts:
      • makeModifierSet(collected, policy) freezes the collected modifiers with resolve / total / touches bound to the policy (buffPolicyOf fills what the module leaves out).
      • EMPTY_MODIFIER_SET is the identity.
      • genericModifierTargets(attributes) gives one attribute.<slug> per stored attribute.
    • The developer index and the module-api extension table advertise 1.42.0. The web's getTokenAttributes / getAttributes signature pins follow.
  • [x] B.10 — Linked tokens re-project with their effects: buildFanOutPatch includes the token's running TimedEffects.
    • An effect with modifiers re-projects its token when it starts, is edited or ends.
    • A tick that ends nothing re-projects nothing.
    • Done: fanOutCharacterToTokens takes an optional modifiersFor(character, token | null).
      • It projects once with the character's own modifiers (equipped items, enabled feats and powers, conditions), and again only for a token whose running TimedEffects carry effects. Every other token shares the first projection.
      • A failure on one token is counted, and its siblings still update.
    • The adapter (services/characterFanOut.ts) reads, once per fan-out:
      • the equipped items;
      • the condition catalogue: the system's defaults, the linked tokens' campaigns, and any id the character names;
      • each map's running effects. It passes makeModifierSet(…, module.buffPolicy) to projectToken(character, modifiers).
    • runCharacterFanOutWith(character, broadcast) / broadcastVia(req | io) let a tick re-project without a request.
    • onEffectsChanged in services/timedEffects.ts fires when an effect with effects, on a token, starts, changes (before and after), ends (dependents included) or is removed. index.ts re-projects those tokens' characters (reprojectForEffects).
    • Pinned by tests: an effect without effects, an end of an ended one, and a tick that ends nothing tell nobody.
  • [x] B.11 — The overlay for unlinked tokens: withModifiers(token, set, targets), never written back.
    • Applied in hoverProperties, bars, Mini-Me, roll substitution and the server sandbox's token handles.
    • A guard against persisting overlaid metadata.
    • Done in shared: buffs/overlay.ts.
      • overlayTokenMetadata(metadata, set, targets) copies the metadata and replaces each vocabulary key's metadataKey field with its resolved total, returning the breakdown per field.
      • genericTokenTargets(propertyDefinitions) is the vocabulary when the module declares none.
    • Done in web:
      • stores/buffOverlayStore.ts holds forToken and buffedMetadataOf, for non-React readers.
      • hooks/useBuffOverlay.ts has buildForToken, useBuffOverlaySource (mounted once in UI.tsx from the map's effects, the campaign's conditions, the module and the property definitions) and useTokenBuffOverlay.
      • A linked token gets nothing here, because its projection already carries its buffs (B.10).
    • Readers that see the overlay:
      • the hover portrait and the hover card;
      • the map's token bars (WebGLTokenLayer);
      • Mini-Me's bars;
      • CurrentTokenBar's stats and bars, through a separate displayMetadata. Its writes and its Properties editor keep the raw base.
      • Roll substitution: the action's roll / rollDetailed / rollSilent, read per roll so an earlier setProperty is seen. Also the map's and the chat's requested rolls.
      • token.getProperty (and so target.getProperty('ac')).
    • setProperty and the executor's persisted snapshot stay raw.
    • buffOverlay.guard.test.ts pins the allowed importers, keeps the token cache (useTokens) clear of the overlay, and pins every write in those files to the raw base.
    • As built: no server code exposes token metadata to the sandbox or resolves an expression against it. The duration function's requestRoll is answered on the client, which reads the overlay. So there was nothing server-side to overlay.
    • An expression value ('13 + @dex.mod') on an unlinked token is left unresolved for now, because the generic path has no evaluator. A module's own derivation (B.16) has one.
  • [x] B.12 — The breakdown, shown: a ModifiedValue sheet-kit primitive (the total tinted by direction, parts in a tooltip).
    • The platform sheet uses it, and hover properties are tinted.
    • CurrentTokenBar's "No buffs yet" placeholder becomes the list of active buffs.
    • Done in the kit:
      • ModifiedValue / modifiedDirection. Given a breakdown in which anything but the base took part, it tints the value (skin.gain up, skin.loss down, accent when unmoved) and lists every part on hover, dimming the ones that did nothing. It renders its children untouched otherwise, and shows the number alone in print.
      • The breakdown is structural, so the kit takes nothing from shared. Shared's breakdownOf(resolved) / isModified build it.
    • The platform sheet:
      • The SheetModifiersProvider / useSheetModifiers context supplies the set (empty without a host; B.14 provides it).
      • AttributeGroupSection resolves an attribute the module didn't pre-total through it (resolveAttribute, the generic key) and passes a breakdown to the ability, numeric and stat tiles. Those wrap their number in ModifiedValue.
      • ProjectedAttribute.breakdown (Module API 1.42.0) lets a module that did pre-total supply its own.
    • The hover portrait tints a buffed property by direction and carries the breakdown as its title, since the portrait isn't interactive.
    • components/UI/Buffs/ActiveBuffsList replaces CurrentTokenBar's placeholder in both the tab and the drawer. Each buff on the token shows its source (item, feat, power, condition, effect) and its effects, and a condition another buff implied says what put it there.
  • [x] B.13 — The effects editor: one BuffEffectsEditor (target picker over the vocabulary, op, value, group), with z-index-checked Selects.
    • Mounted in TimedEffectModal, the equipment and feat/power editors, and ConditionsEditor.
    • Done:
      • components/Buffs/BuffEffectsEditor gives one row per effect: Number (target autocomplete over the vocabulary grouped by the module's groups, op, a value that's a number or an expression, and a bonus type when the policy reads groups) or Condition (picked from the catalogue).
      • BuffListEditor edits named buffs, each with a category, good/bad, and effects.
      • ConditionEffectsField is a draft with its own Save.
      • useModifierVocabulary / vocabularyOf supply the module's targets, else the property definitions' generic keys, plus its categories (else Magic / Curse / Poison / Disease / Physical) and policy.
      • The Selects take the theme's TOP dropdown layer; no call site passes a z-index.
    • Mounted in:
      • TimedEffectModal: "Does" and a Category, sent with the effect, and Save held while a row is incomplete.
      • ConditionsEditor (system) and CampaignEditModal (campaign conditions).
      • EquipmentDetailModal, in build mode, as "While equipped". Only complete buffs that do something are saved.
      • CustomTraitModal: a homebrew feat or power's buffs, with "its buffs apply" / "on now" setting enabled. It takes the sheet's loaded module rather than a system id, because CC-1 keeps systemId out of CharacterSheet.tsx.
    • As built: there was no feat/power edit dialogue to mount the editor in, only the custom-trait creator.
  • [x] B.14 — The character sheet's view of map effects (B-D2): a sheet opened from a token includes its TimedEffects.
    • A sheet opened from the library shows "N effects running on map".
    • Done:
      • useModuleProjection(character, campaignId, { token }) collects the character's own modifiers (equipped items via useEquipment, feats, powers, conditions against the campaign's catalogue), plus the token's running TimedEffects when given one. It hands makeModifierSet(…, module.buffPolicy) to getAttributes, getDerivedStats and projectToken, re-projects when what's in play changes, and returns it as modifiers.
      • CharacterSheet passes its token and wraps its sections in SheetModifiersProvider. SheetHost passes its token and hands the renderer modifiers.
      • From the library (no token), EffectsElsewhereNote says e.g. "1 effect is running on this character's token: Shield of Faith on The Tomb" from the new GET /api/characters/:id/effects, gated within the character and classified in the read-route inventory.
    • Not verified live: the built-in browser has no Clerk session, and signing in goes through an external host.

dnd5e (vtt-modules, a minor release, published before this merges)

  • [x] B.15 — Vocabulary and policy: abilities, ac, ac.base, speed.*, hp.max, initiative, save.*, check.skill.*, passive.perception.
    • buffPolicy: { sameSource: 'mostPotent', groups: 'ignore' }.
    • Categories: Magic, Curse, Poison, Disease.
  • [x] B.16 — Every derived number resolves: projection, the sheet model, attack math, projectToken and getAbilityTotal. Abilities resolve first.
    • "Stored attribute wins" still picks the base, and modifiers apply on top.
  • [x] B.17 — AC formulas: equipped armour and shields as ac.base sets and ac adds, and Unarmored Defense as a set.
    • A stored AC attribute stays the base.
  • [x] B.18 — Conditions and spells: the condition fixtures get their effects.
    • buildEffectCode emits buffs for Shield, Shield of Faith, Mage Armor, Haste, Longstrider, Aid, Barkskin and Bull's Strength.
    • Concentration spells pass parent.
    • Two Hastes give one +2.
  • Done for B.15–B.18 in vtt-modules version/dnd5e-0.21.0 (7f121c5, f2df293, 2aba6cd, d710f2c, plus the version bump ae49424), released as dnd5e/v0.21.0 (8fddf86, with B.21–B.22 and B.27). modules.hosted.json pins it: publish the module tag before deploying this release. Details are in vtt-modules' CHANGELOG.
    • It compiles against the platform it pins (platformRef v0.107.0, typecheck-as-ci.sh ✓). The 1.42.0 members are spread in from an object and the modifier set is typed structurally, so it is publishable before this platform release reaches main, and an older host gets the numbers it always got.
    • B.15: the vocabulary (abilities, ac / ac.base, speeds, hp.max, initiative, passive.perception, saves, the 18 skills, flat melee / ranged attack and damage), mostPotent, and the categories Magic / Curse / Poison / Disease.
    • B.16: buffCharacter resolves abilities first and returns a derivation-only copy, and every output resolves its own key. Covered: projection, projectToken, derived panels, attack math and the sheet model. The 5e sheet shows buffed values through the kit's ModifiedValue, and every edit starts from the stored value.
    • B.17:
      • Armour and shields carry their SRD AC as buffs, both from the picker and in the starting kit.
      • Unarmored Defense competes only with a derived AC while no armour is worn.
      • Platform half: startingKitFor items' buffs are now kept on insert (services/treasury.ts), validated through upgradeBuffs.
    • B.18:
      • Condition fixtures v12 add the speed ceilings.
      • As built: the promoted spell specs gave the buff spells no action, so instead of buildEffectCode a buff-spell table (core/buffSpells.ts) casts Shield, Shield of Faith, Mage Armor, Haste, Longstrider, Aid and Barkskin through api.timeline.startBuff, with concentration as parent and Aid's heal.
      • Bull's Strength (2014: advantage on Strength checks) is roll-time, like Bless, and moves with the next release.

Finishing

  • [x] B.19 — Guards, help and the release record:
    • a help page on buffs;
    • the startBuff / endEffects and Module API docs;
    • the changelog.
    • Done:
      • docs/help/buffs.md is the players' page (where buffs come from, the four stages, stacking, seeing why, ending, writing one from an action, what is not yet). It is linked from the help index and from the Timeline's timed effects.
      • module-api.md §5.1 covers the three hooks and the ModifierSet argument. The Action API docs went in with B.8.
      • The roadmap entry has an "As built" section.
    • Guard apps/web/src/buffs.guard.test.ts: every projectToken / getAttributes / getDerivedStats / getTokenAttributes call in web, server and shared passes the modifiers. The exceptions are the two named creation-time projections, which are covered because a new linked token is re-projected server-side.
    • Added on the way:
      • POST /api/tokens with a character now calls runCharacterFanOutIfBuffed, because the seven client paths that create a linked token project it without the character's modifiers.
      • The token-only sheet's getTokenAttributes now takes the token's effects and conditions.

Buffs in the UI (added 2026-10-05)

Where a player sees and adds buffs. 5e only for the sheet; System 8's sheet follows with its own release.

  • [x] B.20 — The sheet host opens the timed-effect dialog: SheetApi.openPicker('timed-effect') (Module API 1.42.0, additive) opens it on the token the sheet was opened from, through the same request the token menu files. Without a token it says why rather than doing nothing.
    • Done:
      • openTimedEffectDialog(tokenId) in stores/timedEffectRequestStore.ts opens the strip (the drawer on a phone) and files the request the Timeline takes. The token menu now goes through it too.
      • SheetApi.openPicker('timed-effect') (shared type and host) calls it with the sheet's token, or notifies "No token to put it on".
  • [x] B.21 — dnd5e sheet: the conditions bar shows the timed buffs running on the token (by positivity) and conditions a buff implied, beside the stored conditions.
  • [x] B.22 — dnd5e sheet: a Buffs section, read-only, listing what is in play by source (items, feats, powers, conditions, timed effects) with each effect's line; an Add buff button opens the timed-effect dialog (B.20) when the sheet has a token.
    • Done for B.21–B.22 in vtt-modules version/dnd5e-0.21.0 (9463eaa, one commit, since both read the same buildBuffs):
      • The conditions bar adds the token's timed buffs (by positivity) and implied conditions.
      • The Buffs panel lists every buff in play with its source and effects, and Add buff calls openPicker('timed-effect'). It is disabled without a token, and on a host that passes no modifiers the panel says buffs aren't tracked.
  • [x] B.23 — Mini-Me: a + at the end of the status row (conditions, buffs, timed effects): Condition… (toggle one from the catalogue) or Timed effect or buff… (the dialog, on the impersonated token).
    • Done: MiniMeAddStatus is an outlined chip at the end of the status row, so the row is now always drawn. Its popover (the FLOATING_DIALOG band, as the other Mini-Me menus) holds:
      • Timed effect or buff…, via openTimedEffectDialog;
      • the campaign's conditions as checkboxes, using the same addTokenCondition / removeTokenCondition writes as the token bar.
  • [x] B.24 — The map token context menu's "Add timed effect…" reads "Add timed effect or buff…".
  • [x] B.25 — Playtest, the effects editor: the Target picker's dropdown is wide enough to read (it clipped to the input's ~100px), and a target the system does not know is flagged under the row: it would change nothing.
    • Done: the Target autocomplete's dropdown is 320px wide and bottom-start, reading the label first with the key beside it in monospace. knownTarget (exact key, alias, a prefix.* that reaches a key, *, or anything when there's no vocabulary) drives an orange line under any row whose target the system doesn't know.
  • [x] B.26 — Playtest, Mini-Me: a buff's tooltip and popover list what it does ("Strength +10") under its own description, in the system's words; a condition's do too (Restrained: "Speed (walking) ≤ 0").
    • Done:
      • Shared describeBuffEffect(effect, names?) takes the system's names for targets and conditions. targetLabel(key, targets) handles a key, an alias, or prefix.* → "Speed (all)".
      • miniMeStatuses adds one generated line per effect under an effect's (or a condition's) own description, worded through useModifierVocabulary. The chip's tooltip and popover both show it, e.g. "custom description / Strength +10".
  • [x] B.27 — Playtest, targets have aliases: a key the module declares can name the other spellings that mean it (ability.str ← attribute.str, str, strength), and the platform matches them, so a buff written against the generic key, or before the module declared its vocabulary, still applies. The playtest's Bull's Strength targeted attribute.str, which dnd5e's ability.str never matched.
    • Done:
      • ModifierTarget.aliases (Module API 1.42.0, additive).
      • makeModifierSet(collected, policy, targets) re-addresses a modifier written against an alias to its key before resolving, so the breakdown is unchanged. touches follows.
      • modifierTargetsOf(module) is a safe read. The fan-out, the sheet projection and the token overlay all pass the vocabulary.
      • The editor's knownTarget counts aliases.
      • dnd5e's aliases are in vtt-modules (below).

Misc

  • [x] Dev: the Vite dev server no longer watches coverage/, test-results/ or playwright-report/. pnpm verify writes thousands of coverage HTML files, and each was a full page reload and a log line, which flooded the terminal and kept reloading the dev page during a run.
  • [x] A PATCH changes only what it names: the rest of B.4's finding that zod 4's .partial() keeps a .default(). Each schema below now redeclares its defaulted fields without the default before .partial(), as UpdateEquipmentSchema does.
    • Map objects (scale, rotation, visible, locked): moving a hidden, locked, rotated object showed it, unlocked it and reset its rotation and scale.
    • Chat messages (status, type): editing a Combat Log entry or a transcript turned it into ordinary chat.
    • A character's skills (proficient, expertise) and attacks (proficient, hit, hitPower, crit, critPower), through PATCH /api/characters/:id/{skills,attacks}/:slug. Setting a skill's tier cleared its proficiency, and editing an attack zeroed its bonuses. The audit found these; they weren't in the report. registerNameKeyedCollectionRoutes now takes each collection's PATCH schema.
    • Audited and clean: campaigns, factions, conditions, treasury, tokens, timed effects and campaign roles. None of their .partial() calls covers a defaulted field.
    • patchKeepsOmittedFields.test.ts pins each route on the route harness.
    • The guard partialSchemaDefaults.guard.test.ts parses every .partial() in server-core with the TypeScript parser and works out the shape it applies to (z.object, .extend, .merge, .omit, .pick, spreads of .shape, and named schemas across relative imports). It fails on a field that still carries .default( or .prefault(, and on a shape it can't work out.
  • [x] The Campaign Artwork Editor says 'scroll to zoom, drag to pan', and scrolling works, but dragging doesn't do anything
    • Done: the pan maths needed the image's size, and a campaign's legacy card and background art has none on record. Without it the image was assumed to be the frame's shape, which leaves nothing to pan at 100% zoom; zoomed in, a drag jumped much further than the pointer. The preview now measures the image it is showing (useImageNaturalSize), and trusts the record only until that loads. The Card's Native crop takes its true shape from the same measurement, where it used to fall back to a 200px-tall strip.
  • [x] On the /campaigns route, add 'most recent access time' as a sort option (default), and remove the Frequently Accessed section (now redundant)
    • Done: the list's sort menu offers Most recently accessed (the default), Name and Most maps; campaigns never opened sort last. The Frequently Accessed strip is gone from /campaigns (/characters and /maps keep theirs). Two changes make "recent" mean recent: opening a map now counts as a visit to its campaign, not only the campaign's landing page; and a return visit is recorded again, where before each campaign was counted once per browser tab. The saved sort is stored under a new key, so a remembered "Name" from before does not hide the new default. "Most maps" sorts numerically (the column used to compare text, so 10 came before 9).
  • [x] In the Chat Log, the bubbles have 3 rounded edges and one pointy one (at least in New Retro theme). The pointy corner should point toward the avatar (i.e. upper left for left bubbles, upper right for right bubbles). Right now it's pointed downward.
    • Done: the sharp corner is now top-left on others' bubbles and top-right on yours, in every skin (New Retro and the default). The avatar heads its run, so a bottom corner pointed away from it.
  • [x] In the Library upload dialog, support .zip files which contain files of the normally allowed library types
    • Done: Upload (and Folder) accept a .zip; each image inside it is uploaded as if its folder had been dropped, keeping its path under the archive's name (Monsters.zip → Monsters/goblins/boss.png) as the upload's provenance. Only image entries are decompressed — a readme, PDFs and macOS's __MACOSX/ copies are never inflated. Whatever was not an image, loose or zipped, is now named in a notice instead of silently dropped, and an archive that cannot be opened says so. Unzipping is fflate (already in the tree through vitest's UI; now a direct dependency of the web app).
  • [x] In the Library UI, handle file drop events as the same operation as clicking Upload and selecting that file
    • Done: a drop anywhere on the Library page is the Upload button — the same handleFiles, so a dropped .zip is opened and a dropped folder keeps its paths. It used to be a drop zone around the grid only: a drop on the header, the buttons, the tabs or the margins missed it, and the browser opened the file in place of the app. The full-window overlay appears only while files are dragged in from outside, so clicks on assets are untouched and dragging an asset's own thumbnail around the page does not upload it again. The Library help page says so, and documents .zip uploads.
  • [x] Library: sort by last modified (default, newest first), created date or name — and uploads no longer look lost
    • Done: a 29-texture zip upload succeeded but showed nothing, because GET /api/library had no ORDER BY — Postgres returned rows in storage order, so new rows sat on page 14 of Load More in a 660-texture library. The server now orders before paging (sort = modified | created | name, dir; id breaks ties so pages neither repeat nor skip), and the Library has a Sort menu, remembered per browser. "Modified" is a new library_items.updated_at (migration 0058), stamped by every edit; a row never edited counts from its import. Refresh after an upload also kept dropping the Needs-review filter; it now repeats the whole last search.
  • [x] Drawing with a library texture as fill drew no fill (stroke flickered, then the shape vanished): "Could not load …/image.webp: undefined"
    • Done: the S3 cache trap. WebGL needs a CORS load, S3 only sends CORS headers to a request with an Origin and does not mark the response Vary: Origin, so a copy of the image fetched earlier without CORS sat in the browser cache and blocked every CORS load of the same URL. Cross-origin WebGL loads (drawing fills, the map foundation, token art) now fetch through corsSafeImageUrl, which adds a constant ?webgl=1 so the CORS copy has its own cache entry; signed, same-origin and data/blob URLs are left alone. (Not ?cors=1: S3 reads cors as its bucket-CORS subresource and returns 403.) A fill texture that still fails now loses only the fill, not the whole shape, and a per-render debug log in the drawing preview is gone.
  • [x] The texture pickers (drawing Fill, Edit Map's Foundation Texture) didn't show new uploads: sort, search and Load more, like the Library
    • Done: two causes. /api/textures/user and /library were mounted with no auth at all, so the server never knew who was asking — My Textures answered 401 to everyone (since v0.66.0) and Library fell back to server-wide textures only; the picker's Upload could not succeed either. They now take the same auth as every other actor route (the built-in catalogue stays public). And both were one unordered request capped at 200 with no next page, with a search box that only filtered those 200. Now they search, order and page on the server (query, sort, dir, limit, offset; a short page is the last), through libraryOrderBy, the one ordering GET /api/library uses too. The picker has a Modified / Created / Name control with a direction toggle — not a Select, whose dropdown would portal out of the Fill popover and close it — sharing the Library page's remembered order, and Load more under each paged tab.
  • [x] Bug: When impersonating a character, arbitrary chat messages typed into chat log should use that character's avatar and chat color; currently it's using the user's avatar and color, which should only happen when no character is impersonated
    • Done: while impersonating, what you type — and a whisper, a die from the chat's dice row, a roll table — speaks as the character: its name, avatar (token art, else the linked character's) and colour, through one rule, resolveChatSpeaker. A read-only impersonation is watching, so it still speaks as you, as roll tables already did. The message records the tokenId and characterId that spoke, for the two items below.
  • [x] Enhancement: When a chat bubble is attributed to a character, store the user id who chatted it (or clicked the action, etc) as well, and display it on hover of the avatar
    • Done: the server stamps the sender's user id on every message (user.userId) from the session, never from the body, and an edit keeps the original. Hovering a character's avatar reads "Kyrin — played by Andrew"; a name that isn't the sender's own (an alias) reads "— sent by …". Names come from the campaign's members endpoint. Impersonated actions record their token too, and "is this mine?" now goes by the sender id, so a line you typed as your character sits on your side of the chat.
  • [x] Enhancement: Instead of storing the chat bubble color with the message (which I assume is happening), look it up for the user/character. Currently, if a user has yellow bubbles, then posts a few chat messages, then changes to green bubbles and posts again, the newer bubbles will be green and the older will still be yellow. Instead, by looking up the color by reference, all user/character bubbles will be of matching color at render time.
    • Done: a message's colour is looked up when it is drawn (resolveMessageColor): the token that spoke, if it is on this map; else a token of the character that spoke; else the sender's current colour — yours live, everyone else's from the campaign's members endpoint, which now returns each person's chat colour; else, only then, the colour stored on the message (a token on another map, someone who has left). Your own messages from before sender ids repaint too, matched the way the chat already decides they're yours. Someone else's colour change reaches you when the members list refreshes — on returning to the window or reopening the chat, at most once a minute; your own is immediate.

Roadmap

  • [x] Roadmap: Buffs in flight as v0.117.0. v0.116.0's notes leave the CHANGELOG for their own page.
  • [x] Roadmap: Buffs specified (2026-10-05).
    • The original requirements, condensed out of the tree in v0.81.0, were recovered from git.
    • Decisions B-D1–B-D7 were taken at their recommendations.
    • 19 tasks (B.1–B.19), with roll-time modifiers and System 8 planned for the release after.
  • [x] Roadmap: Vehicles, filed as a proposal.
    • Any token can carry others (crew and passenger counts, named slots, slot positions on the artwork).
    • Occupants are drawn on the vehicle, which moves as one token. This replaces mounts and their movement syncing.
    • About 22 tasks in two parts, and ten decisions (VH-D1…D10).