Skip to content

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 DiceRollerExtension extension point — New extension type that replaces the platform's built-in 3D dice with a module-provided roller. Registered in moduleRegistry; the first registered roller belonging to an active module wins. Bumps MODULE_API_VERSION to 1.2.0. Adds DiceRollContext, DiceRollResolution, and optional OverlayComponent / chatSettingsComponent fields on the extension. Includes registry tests and MODULE_AUTHOR_GUIDE docs.
  • [x] Adapter & integration points (platform) — Add a small dice adapter in apps/web so action roll(), dice-bag buttons, and chat dice expressions route through the active DiceRollerExtension when one is registered. Hide <DiceBoxOverlay /> in WebGLMap when an extension is active. Render chatSettingsComponent inside the Chat Settings modal.
  • [x] Scaffold @purple-cat-games/dddice-roller — New package in vtt-modules/modules/dddice-roller with purple-cat-module.type: "integration", build/test scripts mirroring scenes.
  • [x] Stub roller + Chat Settings UI — Module client register() exposes a DiceRollerExtension with a stub rollExpression (RNG only, no SDK yet) and a chatSettingsComponent that captures API key and room id (stored in localStorage per user).
  • [x] Real dddice SDK integration — Install dddice-js, mount the ThreeDDice canvas via OverlayComponent, call dddice.roll() and resolve rollExpression on the roll-finished event. Per-campaign room auto-create/join.
  • [x] Local dev wiring + docs — Add modules.local.example.json entry and MODULE_AUTHOR_GUIDE example for DiceRollerExtension.

Misc

  • [x] Bug: the map-side tokenized image shader (TokenizedImageMaterial in WebGLTokenLayer.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 the cropY direction was inverted so a head-favoring crop showed the chest on the map. Fix: added a sourceScale = (min(W,H)/W, min(W,H)/H) uniform computed from the texture's natural dimensions and rewrote the fragment shader to match applyTransformToCanvas, accounting for the Y-axis flip between canvas image coords and Three.js plane/texture coords (the flipY=true default on TextureLoader): texUv = vec2(cropX, 1-cropY) + (vUv - vec2(translateX, -translateY)*0.3 - 0.5) * sourceScale / zoom. Regression test in WebGLTokenLayer.tokenizedImage.test.ts pins 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_0v53fdp4z but '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 uploadImage in apps/web/src/utils/imageUtils.ts already converts data: and blob: URL sources to a Blob (via dataUrlToBlob / fetch().blob()) before any network call and uploads them either via a presigned S3 PUT or the multipart /api/images/upload-file endpoint. The JSON /api/images/upload route 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 in apps/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, but shouldResizeForCategory() returns false for artwork and campaign (only token/user are resized -- to tokenMaxSize, default 500px). Client-side (apps/web/src/utils/imageUtils.ts CATEGORY_MAX_DIMENSIONS) similarly allows artwork up to 4096px and token up to 512px. The source-art upload path in TokenModal.tsx (L766) uploads with the originating category -- which for character source art is 'token' (already ≤500px), but BackgroundRemovalPanel / BackgroundRemovalEditorDrawer upload with category: 'artwork' and so keep full resolution. Blocker: forcing 512² on artwork would also downsize character-portrait artwork used in non-token contexts (character sheet, portraits panel) and degrade their quality. Needs a design decision: either (a) split artwork into 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 tokenizeSettings updates from the editor drawer. ✅ Fixed in v0.23.0. Audit traced the flood path to CharacterDetail.handleEditorArtworksChange -- which awaited updateCharacterArtworks(characterId, artworks) on every TokenizationEditorDrawer.onArtworksChange emission (slider drags fire many per second). Wrapped the server save in a useDebouncedCallback (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.handleArtworksChange only sets local state (PATCH happens on Save) and GenerateEncounter.setGroupArtworkArray is local-only. Regression tests in CharacterDetail.artworksDebounce.test.ts pin the pattern.
    • Deprecate / drop processedUrl and cutoutUrl-as-data-URL from write paths. ✅ Partially landed in v0.23.0. ArtworkEntity.processedUrl is already @deprecated in packages/shared/src/types/artwork.ts and the shared getMapImageUrl already ignores it. Two concrete write-side fixes shipped: (a) added a one-time client migration in useCharacters.fixCharacterArtworkUrls that strips processedUrl values still stored as data: or blob: URLs on every character load, so stale pre-baked tokenized images get dropped instead of round-tripping through PATCH; (b) TokenArtworkEditor.handleTokenized no longer writes the raw processedUrl argument into the artwork entity -- the transform alone is now sufficient for the shader. The remaining TokenModal.tsx save-path branches (~L815-L870) that upload transient processedUrl: data: blobs to S3 were intentionally left in place because the local map-side helper (WebGLMap.getTokenDisplayImage L105, MapSecondLevelNav.tsx L218, tokenFromMonster.ts L142) still consults processedUrl || imageUrl as a fallback. Switching those readers to the shared getMapImageUrl (which ignores processedUrl) would close out this TODO completely, but doing so risks visual regression for tokens whose processedUrl currently points at a real S3-hosted tokenized image while imageUrl is the un-tokenized source -- needs visual QA we can't do from CI. Regression tests in useCharacters.processedUrlMigration.test.ts pin the migration and the editor write-drop.
  • [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 onBlur auto-save in AIConfigSection.APIKeyInput and adding an explicit Save button (disabled until the draft differs from the saved value; Enter also saves, Esc cancels). Because ProviderModelList's useEffect is keyed on apiKey, calling setApiKey from the Save handler propagates through the store and the Models section automatically refetches -- no explicit refresh signal needed. Regression tests in AIConfigSection.apiKeySave.test.ts pin 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 under x-ai-api-key, but the server route in packages/server-core/src/routes/ai.ts reads req.headers['x-ai-key'] (which is also the convention used by utils/aiHeaders.ts and AIStatusBanner.tsx). The mismatch meant userKey was always undefined on this route, so it only ever consulted server-level env vars. Fixed by changing the client header name to x-ai-key. Regression tests in AIConfigSection.userKeyHeader.test.ts pin 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 the response_format parameter on DALL-E 3 image generations, and @purple-cat-games/genai@0.3.4 (the version pinned in modules.hosted.json) still sent response_format: 'url' in OpenAIAdapter.generate. Upstream fix already shipped in genai v0.3.5 (commit b71df7c, "v0.3.5 - fixed openAI bug") which drops the parameter and falls back through data[0].url || data[0].b64_json. Bumped the platform's genai pin from 0.3.4 → 0.3.5. Regression test in scripts/install-hosted-modules.test.ts pins 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}"}