Skip to content

v0.51.0

Carried over from v0.50.0 — each was left open deliberately, and the reason is on the item.

Production bugs, found on v0.50.2

Both reproduce only on a hosted (S3/CloudFront) deployment, which is why neither showed up locally.

  • [x] Every from-device image upload minted a dead originalUrl. POST /api/images/register returned <storagePath>/original.webp unconditionally, but the presigned upload path never writes that key — the browser PUTs its source bytes to image.webp and nothing else. CloudFront answered 403 (S3 says AccessDenied, not NotFound, without s3:ListBucket), so every surface that edits from the source art went blank — the Tokenization editor, the Edit Token source pane — while thumbnails and portraits, which read imageUrl, looked fine. It then spread to the map, because TokenArtworkEditor copies originalUrl into imageUrl when it saves. Images added from a URL (e.g. Google) were never affected: those upload server-side, which really does write an original. Register now reports the uploaded object as the original, because on that path it is the original.

  • [x] Repair the artwork already at rest. pnpm --filter @purple-cat/server-core db:repair-original-urls walks tokens, characters and equipment, HEADs each distinct original.webp URL once, and rewrites only the ones that answer 4xx to their image.webp sibling. It probes rather than rewriting on sight because original.webp is not inherently wrong — a server-side upload of a WebP source writes that key for real, and there it is the full-resolution pre-resize original, so a blanket rewrite would silently downgrade the good ones. Anything it cannot prove dead (5xx, network error, or a relative /images/... URL with no origin to resolve against — pass --base-url= for those) is reported as unchecked and left alone. Report-only by default; --apply writes.

  • [x] /admin/hosted-modules listed nothing at all in production, while every module was loaded and serving. Two independent causes, either sufficient: the route resolved the repo root by counting ../../../.. up from __dirname, which misses by two levels in the compiled Docker layout (dist/server-core/src/routes) and so found neither config file; and data/modules-manifest.json, its source of installed versions, is never written into the image. It now resolves the root through the module loader's own findWorkspaceRoot() and takes installed-ness from the live registry (the same moduleInfos behind GET /api/modules), falling back to the manifest only for builtAt. The listing was cosmetic — module loading itself was never broken — so this fixes the instrument, not the engine.

D&D 5E Character Sheet — module (vtt-modules, modules/dnd5e, branch version/dnd5e-0.3.0)

  • [ ] 5E-3c — Widen the parser as gaps show up. ~50% coverage is where prose-parsing lands honestly. Candidates when they matter: cantrip scaling by character level (Fire Bolt should be 2d10 at level 5), applying the At Higher Levels dice when a slot level is chosen, and area/AoE targeting for save spells. Each needs a decision about where the caster's choice (which slot) is expressed, which is why none of it is guessed at now.

  • [ ] 5E-4 — Publish @purple-cat-games/dnd5e 0.3.0 and bump modules.hosted.json, per the v0.46.1 pattern. Publish before the platform deploy, not after. (PREPARED, NOT PUBLISHED. The module's package.json is at 0.3.0 (vtt-modules commit 9724295) and modules.hosted.json points at it. What remains is one step, and it is the user's: push the tag dnd5e/v0.3.0, which is what publish.yml fires on — the workflow checks the tag's version against package.json, so the bump had to land first. Do not deploy the platform until that tag has published: modules are installed at image build time from modules.hosted.json, so a deploy made now would look for a 0.3.0 that is not on the registry. Only one tag is going out, so the ">3 tags in one push drops every tag event" hazard does not apply here.)

Ability Scores (5e)

  • [ ] Decide how +/- should behave for 5e. Deferred deliberately: doing anything cleverer than simple up/down needs the ability-score generation method, which straddles phase 1 (it is character data — 5E-7's picker persists it) and phase 2 (what it implies — point-buy costs, standard-array assignment — is validation and automation). Simple up/down is correct for now. Revisit alongside 5E-7 and the validation release, and decide there whether the method constrains the steppers or merely records intent.

App-wide field commit/revert adoption

  • [x] After FC-1/FC-2 land, sweep the rest of the app. Every surface with auto-persisting typed inputs — Edit Campaign, the Token modal, property/prop-type editors, Map settings — should route through the AsyncField wrappers so the commit/revert pair arrives with no per-site work. The style guide has asked for that consolidation since 2.0.0 ("all inputs that work this way should use standardized components, instead of one-off implementations"); FC-1 is what makes finishing it worth doing. Scope the audit first: the count of hand-rolled blur-save inputs is unknown, and it decides whether this is one version or several. (Audited (v0.50.0), sweep still deferred. 113 files render a typed Mantine input; 13 already go through an Async* / Draft wrapper. 27 hand-written blur-save handlers remain across 14 call sites — the raw grep says 30 across 17, but three of those are the wrappers' own field.commit(). The distribution is the useful part: the TraitEditors family is 17 of the 27, across 8 files — Conditions, Currencies, Functions, PropertyDefinitions, PropTypes, Actions, Bars, LightSources, VisionTypes — and they are near-identical grid editors, so they convert as one change or not at all. The tail is six files (TokenPropertyInput, CampaignEditModal, AdvancedTokenSettings, RangeMaxInput, RangeCurrentInput, MapContext). So: one version, not several, with the TraitEditors cluster as its single substantial change — but it is a version of its own, since converting a grid editor touches how every row saves and wants its own interactive pass.)

AI-assisted action authoring

  • [ ] Let AI fill in an action's targeting, damage and roll data. Raised while verifying 5E-3b's spell actions.

    • Write a script to read each spell's description and determine one or more actions that should be created based on the spell.
    • The shape of an action is determined by the properties of Actions in the VTT, such as targeting, area of effect, and what rolls are required. Using requestRoll() and applyDamage(), it is possible to make an action that handles the end-to-end process of, for example, rolling fireball damage dice, prompting all targets to roll a Dex save for half, and applying the damage to each target.
    • The targeting info follows a regular pattern (see System 8 actions), and this is the desired output of the parser.
    • The parser should not write entire action scripts, but rather should output another regular pattern, drafted for this feature, which then gets input into a function that translates it into a script. The pattern might look something like (but not necessarily):
      {
        "targeting": "20-foot-radius sphere",
        "damage": {
          "dice": "8d6",
          "type": "fire"
        },
        "save": {
          "ability": "Dex",
          "dc": 15,
          "effect": "half"
        }
      }
      
    • The parser should make the determination if a spell has multiple actions, and output each action separately. If any property differs in one use case (e.g. target, range, duration, save), then it should be output as a separate action. A common exception migh tbe the tendency of many spells with a save to allow willing recipients to forego the save. In that case, the spell doesn't work differently, the recipient is just voluntarily failing, which they can still do if the action is written with a save.
      • A better example might be a spell that teleports the caster somewhere else as a standard action, but could also create an open portal for 1 round that others can teleport through. Those are fundamentally different actions, and should be output separately.
    • This script, when complete and verified through test cases, will run on all spells, outputting actions in some form (JSON, etc) to be stored in the dnd5e module. Thereafter, the module can look these up and provide Actions for each spell taken for a given character.

    (First draft written, not yet run — vtt-modules commit 8970df9, folded into a new unpublished dnd5e 0.4.0. pnpm --filter @purple-cat-games/dnd5e authoring:spell-actions --slugs=fireball,fire-bolt,cure-wounds,fly is the sample run; add --dry-run to see the exact prompt without spending anything. It needs ANTHROPIC_API_KEY or an ant auth login profile, which this machine has neither of.

    The spec is scripts/spell-action-spec.mjs: closed vocabularies for targeting, roll kind, save effect, damage type, area shape and scaling, enforced twice — once by the response schema the model is constrained to, once by validateSpec, which catches the things a schema cannot (a save action with no save, dice the roller would not evaluate, a distance that is not a multiple of five because the model converted to squares). Distances stay in feet exactly as written; feetToSquares does the conversion in code. The prompt's first rule is that refusing is a correct answer, since about half the SRD has no roll in it, and every spec carries an unmodelled list of what its actions leave out. 35 tests cover the half no model is involved in.

    Still open, in order: (1) run the sample and read the drafts — the prompt is a hypothesis until then; (2) the spec → action script translator, which is deliberately ordinary code, not a second model call; (3) a full run over all 317 spells; (4) deciding where the output lives in data/ and how the module serves it. Two design questions the drafts should answer: whether the caster's slot choice belongs in the spec or in the action's own prompt (this is 5E-3c's blocker too), and whether confidence: low specs are worth keeping at all.)

Character Sheet - 5e

  • [x] In the spell chooser dialogue, add a 2nd option to each spell: add to sheet and continue browsing. Existing option adds the spell to the character and closes the dialogue.
  • [x] In the ability scores section, render saves as Save\n+${mod}, removing the colon, adding a line break. Also use text-align: center and increase top margin of the save row by 0.5rem
  • [x] In the Feat chooser and Spell chooser dialogue, add a Custom option to allow users to create their own feats or spells.

Misc

  • [x] Update the model of Campaign to add an "abbreviation" field (string, null allowed, no default).

  • [x] Add the System field to the New Campaign page.

  • [x] A previous change to all TextInputs where changing the input auto-persists on blur added elements to the right edge of the input to commit or revert the change before blur. This was also applied to some numeric inputs, such as the Class Level input in the 5e character sheet (metadata section). This interferes with the operation of a numeric input. The behavior for a numeric input should be that the change happens as soon as the increment/decrement occurs, so no commit/revert is needed.

  • [x] Implement an Inventory section, duplicating the System 8 treatment, sourcing data from the 5e module. Do not enforce attunement, item slots, etc.

  • [x] Restore the equipment text input from the old character sheet (Build mode only) — a typeahead over the canonical equipment list that also accepts free text as a custom item name. (Smoke-testing this surfaced a latent module bug: /api/modules/system8/system-equipment answers 401. The routes look unauthenticated — the platform mounts module routers with no auth middleware of its own — but several app.use("/api", requireAuth, …) catch-alls are registered first, and Express runs middleware in registration order, so requireAuth replies before the module's router is reached. System 8's content provider sent no token and swallowed the failure into an empty page, which is why it went unseen: it had no platform caller until this typeahead, since useModuleTraitDetail asks for a getDetail it does not implement. Fixed in system8 0.6.3 (vtt-modules 2d45f51) — it now sends moduleRegistry.authHeaders() and throws. modules.hosted.json still pins 0.6.2; the fix needs system8/v0.6.3 published and that pin moved before it reaches a deploy. The dnd5e provider has done this correctly since 0.3.0.)

  • [x] Art assigned on the character sheet never reached the token on the map. A token created without art — "New Character", "Insert Monster" — showed the no-art placeholder forever, however much art you then gave its character. The two halves never met: the map resolves artwork token-scoped (token.artworks + tokenArtworkId, then legacy imageUrl), while the sheet's Artwork modal writes the character row, and the character→token fan-out named none of the artwork fields. The flows that worked — "Generate Monster", adding an already-arted character to a map — only worked because they snapshot the art into the token's creation payload, so the art was never assigned post-hoc at all. The fan-out now carries artworks, tokenArtworkId, portraitArtworkId and the mirrored legacy imageUrl, but only onto a token holding none of its own: R-b made artwork per-instance so two copies of one monster can look different, and a token showing anything is a token whose look someone chose. That guard is also what makes it idempotent — the first fan-out fills a bare token, and every later one sees art and declines. Artwork fans out even when the character's system has no projectToken, since art is the character's own field rather than something a module derives, and an unprojectable character is exactly the one that can never pick it up by another route.