v0.23.0
2026-05-16
Experimental: dddice Dice Roller (exp/dddice)
Experimental integration of the dddice 3D dice service as a pluggable platform module. While this branch is active, the built-in @3d-dice/dice-box 3D dice are suppressed in favor of the module-provided roller. A future module will re-package the legacy roller as a DiceRollerExtension of its own.
- [x] Module API: add
DiceRollerExtensionextension point — New extension type that replaces the platform's built-in 3D dice with a module-provided roller. Registered inmoduleRegistry; the first registered roller belonging to an active module wins. BumpsMODULE_API_VERSIONto1.2.0. AddsDiceRollContext,DiceRollResolution, and optionalOverlayComponent/chatSettingsComponentfields on the extension. Includes registry tests and MODULE_AUTHOR_GUIDE docs. - [x] Adapter & integration points (platform) — Add a small dice adapter in
apps/webso actionroll(), dice-bag buttons, and chat dice expressions route through the activeDiceRollerExtensionwhen one is registered. Hide<DiceBoxOverlay />inWebGLMapwhen an extension is active. RenderchatSettingsComponentinside the Chat Settings modal. - [x] Scaffold
@purple-cat-games/dddice-roller— New package invtt-modules/modules/dddice-rollerwithpurple-cat-module.type: "integration", build/test scripts mirroringscenes. - [x] Stub roller + Chat Settings UI — Module client
register()exposes aDiceRollerExtensionwith a stubrollExpression(RNG only, no SDK yet) and achatSettingsComponentthat captures API key and room id (stored in localStorage per user). - [x] Real dddice SDK integration — Install
dddice-js, mount theThreeDDicecanvas viaOverlayComponent, calldddice.roll()and resolverollExpressionon the roll-finished event. Per-campaign room auto-create/join. - [x] Local dev wiring + docs — Add
modules.local.example.jsonentry andMODULE_AUTHOR_GUIDEexample forDiceRollerExtension.
Misc
- [x] Bug: the map-side tokenized image shader (
TokenizedImageMaterialinWebGLTokenLayer.tsx) didn't match the canvas editor's crop math, so the pan configured in the Token Image editor was applied non-1:1 on the map (sliding the image down in the tokenizer moved it UP on the map), non-square source art was stretched to the square plane's aspect ratio (the rendered token always inherited the source image's aspect ratio instead of being properly square-cropped to a circle), and thecropYdirection was inverted so a head-favoring crop showed the chest on the map. Fix: added asourceScale = (min(W,H)/W, min(W,H)/H)uniform computed from the texture's natural dimensions and rewrote the fragment shader to matchapplyTransformToCanvas, accounting for the Y-axis flip between canvas image coords and Three.js plane/texture coords (theflipY=truedefault onTextureLoader):texUv = vec2(cropX, 1-cropY) + (vUv - vec2(translateX, -translateY)*0.3 - 0.5) * sourceScale / zoom. Regression test inWebGLTokenLayer.tokenizedImage.test.tspins the formula. - [x] In the "frequently accessed" section of the root route (and probably other routes), the link to a campaign is
https://map.purplecat.games/campaigns/campaign_1771469072279_0v53fdp4zbut 'campaigns' should be 'campaign'. The link goes nowhere. - [ ] Tokenization pipeline cleanup follow-ups (split out from the v0.22.0 tokenized-art-on-map fix):
- Blob-only image uploads. ✅ Verified in v0.23.0: the current
uploadImageinapps/web/src/utils/imageUtils.tsalready convertsdata:andblob:URL sources to aBlob(viadataUrlToBlob/fetch().blob()) before any network call and uploads them either via a presigned S3 PUT or the multipart/api/images/upload-fileendpoint. The JSON/api/images/uploadroute is now only used for external URLs and POSTs{url, ...options}-- it never carries base64. The original TODO description was stale. Pinned with a regression test inapps/web/src/utils/imageUtils.uploadImage.test.ts. - Verify 512² optimized webp on every source-art assignment. SKIPPED in v0.23.0 pending a design decision. Audit results: server-side (
packages/server-core/src/storage/imageProcessor.ts) already converts every uploaded image to webp via sharp, butshouldResizeForCategory()returnsfalseforartworkandcampaign(onlytoken/userare resized -- totokenMaxSize, default 500px). Client-side (apps/web/src/utils/imageUtils.tsCATEGORY_MAX_DIMENSIONS) similarly allowsartworkup to 4096px andtokenup to 512px. The source-art upload path inTokenModal.tsx(L766) uploads with the originatingcategory-- which for character source art is'token'(already ≤500px), butBackgroundRemovalPanel/BackgroundRemovalEditorDrawerupload withcategory: 'artwork'and so keep full resolution. Blocker: forcing 512² onartworkwould also downsize character-portrait artwork used in non-token contexts (character sheet, portraits panel) and degrade their quality. Needs a design decision: either (a) splitartworkinto a "for tokenization" vs "for portrait" sub-category, or (b) accept the portrait-quality degradation and globally cap at 512². Re-open with a chosen approach in a future version. - Debounce
tokenizeSettingsupdates from the editor drawer. ✅ Fixed in v0.23.0. Audit traced the flood path toCharacterDetail.handleEditorArtworksChange-- which awaitedupdateCharacterArtworks(characterId, artworks)on everyTokenizationEditorDrawer.onArtworksChangeemission (slider drags fire many per second). Wrapped the server save in auseDebouncedCallback(250ms, via@mantine/hooks-- already used elsewhere in the codebase) while keeping local state updates synchronous so the editor stays responsive. Other consumers were already safe:TokenModal.handleArtworksChangeonly sets local state (PATCH happens on Save) andGenerateEncounter.setGroupArtworkArrayis local-only. Regression tests inCharacterDetail.artworksDebounce.test.tspin the pattern. - Deprecate / drop
processedUrlandcutoutUrl-as-data-URL from write paths. ✅ Partially landed in v0.23.0.ArtworkEntity.processedUrlis already@deprecatedinpackages/shared/src/types/artwork.tsand the sharedgetMapImageUrlalready ignores it. Two concrete write-side fixes shipped: (a) added a one-time client migration inuseCharacters.fixCharacterArtworkUrlsthat stripsprocessedUrlvalues still stored asdata:orblob:URLs on every character load, so stale pre-baked tokenized images get dropped instead of round-tripping through PATCH; (b)TokenArtworkEditor.handleTokenizedno longer writes the rawprocessedUrlargument into the artwork entity -- the transform alone is now sufficient for the shader. The remainingTokenModal.tsxsave-path branches (~L815-L870) that upload transientprocessedUrl: data:blobs to S3 were intentionally left in place because the local map-side helper (WebGLMap.getTokenDisplayImageL105,MapSecondLevelNav.tsxL218,tokenFromMonster.tsL142) still consultsprocessedUrl || imageUrlas a fallback. Switching those readers to the sharedgetMapImageUrl(which ignoresprocessedUrl) would close out this TODO completely, but doing so risks visual regression for tokens whoseprocessedUrlcurrently points at a real S3-hosted tokenized image whileimageUrlis the un-tokenized source -- needs visual QA we can't do from CI. Regression tests inuseCharacters.processedUrlMigration.test.tspin the migration and the editor write-drop.
- Blob-only image uploads. ✅ Verified in v0.23.0: the current
- [x] In the /settings path, when an API key is entered, technically the key is immediately updated in the DB. However, the UI doesn't reflect that unless you reload. Fixed in v0.23.0 by removing the
onBlurauto-save inAIConfigSection.APIKeyInputand adding an explicit Save button (disabled until the draft differs from the saved value; Enter also saves, Esc cancels). BecauseProviderModelList'suseEffectis keyed onapiKey, callingsetApiKeyfrom the Save handler propagates through the store and the Models section automatically refetches -- no explicit refresh signal needed. Regression tests inAIConfigSection.apiKeySave.test.tspin the new UX.- The API key fields do not immediately save on input. Instead, they have a Save button which saves the key to the DB and updates the UI to reflect the new key. This makes it more clear to the user that their key has been updated, and also allows them to cancel if they entered it by mistake.
- Ensure the Save action also triggers an update of the Models section to take the new API key into account for model availability and recommendations.
- [x] Bug in prod: https://api.purplecat.games/api/ai/models/gemini was failing with 404 with response
{"error":"No API key configured for gemini"}. Root cause was a header-name mismatch: the client (AIConfigSection.ProviderModelList.fetchModels) was sending the user-level key underx-ai-api-key, but the server route inpackages/server-core/src/routes/ai.tsreadsreq.headers['x-ai-key'](which is also the convention used byutils/aiHeaders.tsandAIStatusBanner.tsx). The mismatch meantuserKeywas always undefined on this route, so it only ever consulted server-level env vars. Fixed by changing the client header name tox-ai-key. Regression tests inAIConfigSection.userKeyHeader.test.tspin both the client header and the server route to keep them from drifting apart again. - [x] DALL-E 3 image generation was failing with
Image generation failed: OpenAI API error (400): { "error": { "message": "Unknown parameter: 'response_format'.", "type": "invalid_request_error", "param": "response_format", "code": "unknown_parameter" } }. Root cause: OpenAI dropped support for theresponse_formatparameter on DALL-E 3 image generations, and@purple-cat-games/genai@0.3.4(the version pinned inmodules.hosted.json) still sentresponse_format: 'url'inOpenAIAdapter.generate. Upstream fix already shipped in genai v0.3.5 (commitb71df7c, "v0.3.5 - fixed openAI bug") which drops the parameter and falls back throughdata[0].url || data[0].b64_json. Bumped the platform's genai pin from 0.3.4 → 0.3.5. Regression test inscripts/install-hosted-modules.test.tspins the floor at >= 0.3.5 so the version can't accidentally regress.- Payload:
{"prompt":"a typical starbucks customer","modelId":"dall-e-3","provider":"openai"} - Status: 502
- Response:
{ "error": "Image generation failed: OpenAI API error (400): {\n \"error\": {\n \"message\": \"Unknown parameter: 'response_format'.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"response_format\",\n \"code\": \"unknown_parameter\"\n }\n}"}
- Payload: