├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .parcelrc ├── .postcssrc ├── .prettierrc ├── LICENSE.md ├── README.md ├── assets └── dddice-foundry-plugin.gif ├── cliff.toml ├── docker-compose.yml ├── index.d.ts ├── jest.config.js ├── module.njsproj.user ├── package-lock.json ├── package.json ├── src ├── dddice.css ├── dddice.ts ├── images │ └── loading.svg ├── manifest.json ├── module │ ├── ConfigPanel.tsx │ ├── DddiceSettings.tsx │ ├── PermissionProvider.ts │ ├── SdkBridge.ts │ ├── StorageProvider.ts │ ├── assets │ │ ├── arrows-diagrams-arrow-rotate-1.svg │ │ ├── dddice-16x16.png │ │ ├── dddice-32x32.png │ │ ├── dddice-48x48.png │ │ ├── interface-essential-checkmark-sqaure-copy.svg │ │ ├── interface-essential-exit-door-log-out-1.svg │ │ ├── interface-essential-left-arrow.svg │ │ ├── interface-essential-share-2.svg │ │ ├── loading.svg │ │ └── support-help-question-question-square.svg │ ├── components │ │ ├── ApiKeyEntry.tsx │ │ ├── DddiceButton.tsx │ │ ├── Room.tsx │ │ ├── RoomCard.tsx │ │ ├── RoomSelection.tsx │ │ ├── Theme.tsx │ │ ├── ThemeCard.tsx │ │ ├── ThemeSelection.tsx │ │ └── Toggle.tsx │ ├── helper │ │ └── TemplatePreloader.ts │ ├── log.ts │ └── rollFormatConverters.ts └── templates │ ├── .gitkeep │ └── ConfigPanel.html ├── static └── module.json ├── tailwind.config.js ├── tests └── rollParser.spec.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": ["@babel/plugin-syntax-import-meta"], 7 | 8 | "env": { 9 | "test": { 10 | "plugins": [ 11 | ["@babel/plugin-transform-typescript", { "allowNamespaces": true }], 12 | ["@babel/plugin-proposal-decorators", { "legacy": true }] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src/vendor 3 | tailwind.config.js 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react-hooks/recommended", 11 | "plugin:jsx-a11y/recommended", 12 | "prettier" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaFeatures": { 17 | "jsx": true 18 | }, 19 | "ecmaVersion": "latest", 20 | "sourceType": "module" 21 | }, 22 | "plugins": ["react", "@typescript-eslint", "jsx-a11y", "import"], 23 | "rules": { 24 | "import/order": [ 25 | "error", 26 | { 27 | "alphabetize": { 28 | "order": "asc", 29 | "caseInsensitive": false 30 | }, 31 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], 32 | "newlines-between": "always" 33 | } 34 | ] 35 | }, 36 | "overrides": [ 37 | { 38 | "files": ["*.ts*"], 39 | "settings": { 40 | "react": { 41 | "version": "detect" 42 | }, 43 | "import/resolver": { 44 | "node": true 45 | } 46 | }, 47 | "rules": { 48 | "@typescript-eslint/no-inferrable-types": "off", 49 | "@typescript-eslint/ban-ts-comment": "off", 50 | "@typescript-eslint/no-empty-interface": "off", 51 | "@typescript-eslint/no-explicit-any": "off", 52 | "import/no-unresolved": "off", 53 | "react-hooks/exhaustive-deps": "off", 54 | "react/no-unescaped-entities": "off", 55 | "react/display-name": "off", 56 | "no-console": [ 57 | "error", 58 | { 59 | "allow": ["warn", "error"] 60 | } 61 | ], 62 | "import/default": "off" 63 | } 64 | } 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # @format 2 | 3 | name: Release 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | increment_version: 9 | description: 'Increment Version' 10 | required: true 11 | default: 'patch' 12 | type: choice 13 | options: 14 | - major 15 | - minor 16 | - patch 17 | - none 18 | prerelease: 19 | description: 'Prerelease' 20 | default: false 21 | type: boolean 22 | 23 | jobs: 24 | release: 25 | runs-on: ubuntu-latest 26 | env: 27 | INCREMENT: ${{ github.event.inputs.increment_version }} 28 | steps: 29 | - name: Setup Volta 30 | uses: volta-cli/action@v4 31 | 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | with: 35 | fetch-depth: 0 # fetch all history for git-cliff 36 | 37 | 38 | - name: Calculate Version Number 39 | id: version 40 | run: | 41 | TAG=$(git describe --tags --abbrev=0) 42 | VERSION=${TAG#v} 43 | echo "latest version: $VERSION" 44 | if [ "$INCREMENT" != "none" ]; then 45 | VERSION=$(npx --yes semver -i "${{ (github.event.inputs.prerelease == 'true') && 'pre' || '' }}$INCREMENT" --preid rc "$VERSION") 46 | fi 47 | echo "new version: $VERSION" 48 | echo "tag=v${VERSION}" >> $GITHUB_OUTPUT 49 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 50 | 51 | - name: Update Version 52 | run: | 53 | npm --no-git-tag-version --allow-same-version version ${{ steps.version.outputs.version }} 54 | MANIFEST=$(cat src/manifest.json | jq ".version |= \"${{ steps.version.outputs.version }}\"") 55 | echo "$MANIFEST" > src/manifest.json 56 | 57 | - name: Commit 58 | run: | 59 | git add . 60 | git config user.email "developers@dddice.com" 61 | git config user.name "dddice" 62 | git commit --allow-empty -m "release: ${{ steps.version.outputs.tag }}-${{ github.run_number }}" || echo 'ok' 63 | git push 64 | 65 | - name: Tag 66 | if: inputs.increment_version != 'none' 67 | run: | 68 | git tag ${{ steps.version.outputs.tag }} 69 | git push origin ${{ steps.version.outputs.tag }} 70 | 71 | - name: Tag (Force) 72 | if: inputs.increment_version == 'none' 73 | run: | 74 | git tag -f ${{ steps.version.outputs.tag }} 75 | git push -f origin ${{ steps.version.outputs.tag }} 76 | 77 | - name: Build Release 78 | run: | 79 | npm ci 80 | npm run build 81 | 82 | # Substitute the Manifest and Download URLs in the module.json 83 | - name: Substitute Manifest and Download Links For Versioned Ones 84 | id: sub_manifest_link_version 85 | uses: microsoft/variable-substitution@v1 86 | with: 87 | files: './dist/module.json' 88 | env: 89 | version: ${{ steps.version.outputs.version }} 90 | url: https://github.com/${{github.repository}} 91 | manifest: https://github.com/${{github.repository}}/releases/latest/download/module.json 92 | download: https://github.com/${{github.repository}}/releases/download/${{steps.version.outputs.tag}}/module.zip 93 | 94 | # Create a zip file with all files required by the module to add to the release 95 | - run: cd dist && zip -r ../module.zip . 96 | 97 | - name: Generate Changelog 98 | id: git-cliff 99 | uses: orhun/git-cliff-action@v2 100 | with: 101 | config: cliff.toml 102 | args: --current --strip all 103 | env: 104 | OUTPUT: CHANGES.md 105 | 106 | - name: Set the release body 107 | id: release 108 | shell: bash 109 | run: | 110 | r=$(cat ${{ steps.git-cliff.outputs.changelog }}) 111 | echo "RELEASE_BODY<>$GITHUB_OUTPUT 112 | echo "$r" >> $GITHUB_OUTPUT 113 | echo "EOF" >> $GITHUB_OUTPUT 114 | 115 | - name: Create Release with Files 116 | id: create_version_release 117 | uses: ncipollo/release-action@v1 118 | with: 119 | allowUpdates: true # Set this to false if you want to prevent updating existing releases 120 | name: ${{ steps.version.outputs.tag }} 121 | draft: false 122 | token: ${{ secrets.GITHUB_TOKEN }} 123 | artifacts: './dist/module.json, ./module.zip' 124 | tag: ${{ steps.version.outputs.tag }} 125 | body: ${{ steps.release.outputs.RELEASE_BODY }} 126 | prerelease: ${{ inputs.prerelease }} 127 | 128 | - name: Publish FoundryVTT Package 129 | uses: cs96and/FoundryVTT-release-package@v1 130 | with: 131 | package-token: ${{ secrets.PACKAGE_TOKEN }} 132 | manifest-url: https://github.com/${{github.repository}}/releases/download/${{steps.version.outputs.tag}}/module.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | .vs/ 4 | *.iml 5 | *.njsproj 6 | /.vscode 7 | icons/\!dox/ 8 | /node_modules/ 9 | .DS_Store 10 | **.env 11 | npm-debug.log 12 | coverage/ 13 | debug/ 14 | docs/ 15 | node_modules/ 16 | bin/ 17 | obj/ 18 | 19 | # If you add foundry.js for autocomplete 20 | foundry.js 21 | dist 22 | /.parcel-cache/ 23 | /data/ 24 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@parcel/config-default" 4 | ], 5 | "reporters": [ 6 | "...", 7 | "parcel-reporter-static-files-copy" 8 | ], 9 | "transformers": { 10 | "*.svg": [ 11 | "...", 12 | "@parcel/transformer-svg-react" 13 | ], 14 | "*.{js,mjs,jsx,cjs,ts,tsx}": [ 15 | "@parcel/transformer-js", 16 | "@parcel/transformer-react-refresh-wrap" 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "postcss-nested": {}, 4 | "tailwindcss": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "insertPragma": true, 5 | "printWidth": 100, 6 | "requirePragma": false, 7 | "semi": true, 8 | "singleQuote": true, 9 | "tabWidth": 2, 10 | "useTabs": false, 11 | "trailingComma": "all", 12 | "overrides": [ 13 | { 14 | "files": "*.md", 15 | "options": { 16 | "tabWidth": 4 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 The 3D Dice Company, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dddice Foundry VTT Plugin 2 | 3 | Roll 3D dice from Foundry VTT! Integrates [dddice](https://dddice.com) with Foundry VTT, providing you with a seamless dice rolling experience. Use dddice to overlay dice on your stream or simply share the fun of dice rolling in a private room. 4 | 5 | ![dddice Foundry VTT Plugin](./assets/dddice-foundry-plugin.gif?raw=true) 6 | 7 | ## dddice vs. Dice So Nice 8 | 9 | [Dice So Nice](https://foundryvtt.com/packages/dice-so-nice/) is another 3D dice plugin for Foundry VTT, but there are few key features that separate dddice. 10 | 11 | - **Roll Anywhere** - dddice is an external service that allows you to roll 3D dice from our own site, Foundry VTT, D&D Beyond, and other VTTs. 12 | - **Synced Rolls** - Even better, dddice syncs rolls from all these platforms for a seamless rolling experience no matter where you are. 13 | - **Streaming Mode** - dddice's Streaming Mode renders your party's rolls transparently over your stream using tools like OBS or Streamlabs. [Learn More](https://dddice.com/for-streamers). 14 | - **Customize Dice** - Easily customize dice and share with your friends (or foes) using our simple [dice editor](https://dddice.com/editor?ref=foundry). 15 | 16 | For more information, visit the official [dddice homepage](https://dddice.com?ref=foundry). 17 | 18 | ## Can I use dddice and Dice So Nice together? 19 | 20 | **Yes!** 21 | 22 | dddice and Dice So Nice play very well together. Roll your favorite dice using Dice So Nice and let dddice handle the synchronization features that let you roll on platforms such as D&D Beyond and more. 23 | 24 | 1. Install and enable both dddice and Dice So Nice. 25 | 2. In the module settings of dddice set `Render Mode` to `off`. This lets Dice So Nice handle Foundry VTT animations. 26 | 3. dddice will pick up the rolls and send it to anyone else that is connected to the same dddice room. 27 | 28 | **Caveat**: Your Dice So Nice themes won't show up externally; the dddice theme you selected during configuration will be used instead. Similarly, rolls made outside of Foundry VTT will show up in your instance and be handed off to Dice So Nice to be rolled with the theme selected in your Dice So Nice configs. 29 | ## Feedback 30 | 31 | dddice is built with our community in mind! We are extremely interested in your feedback. Interested in connecting with us? 32 | 33 | - [Become a backer on Patreon](https://www.patreon.com/dddice) 34 | - [Join the dddice Discord Server](https://discord.gg/VzHq5TfAr6) 35 | - [Follow dddice on Twitter](https://twitter.com/dddice_app) 36 | - [Join the dddice subreddit](https://reddit.com/r/dddice) 37 | - [Subscribe to dddice on YouTube](https://www.youtube.com/channel/UC8OaoMy-oFAvebUi_rOc1dQ) 38 | - [Follow dddice on Twitch](https://www.twitch.tv/dddice_app) 39 | 40 | ## Documentation and API 41 | 42 | dddice features a robust API and SDK to build applications with. 43 | 44 | - [API Documentation](https://docs.dddice.com/api?ref=foundry) 45 | - [SDK Documentation](https://docs.dddice.com/sdk/js/latest?ref=foundry) 46 | 47 | ## Compatability 48 | 49 | dddice is compatible with Foundry VTT v9+, 10+. 50 | 51 | ## Development 52 | 53 | If you would like to contribute to this extension, follow the instructions below. 54 | 55 | You will need [Node.js](https://nodejs.org/en/) and [NPM](https://www.npmjs.com/). 56 | 57 | ```shell 58 | # Clone this repository 59 | git clone git@github.com:dddice/dddice-foundry-plugin.git 60 | 61 | # Install dependencies 62 | npm i 63 | 64 | # add your Foundry VTT credentials to the environment 65 | export FOUNDRY_USERNAME= 66 | export FOUNDRY_PASSWORD= 67 | 68 | # (optional) Use Docker to start foundry 69 | docker compose up v10 #or v9 if you prefer 70 | 71 | # (if you are using docker) Grant access to the module dir / build output dir 72 | sudo chmod -R 777 data/Data/modules 73 | 74 | # Start the package bundler 75 | npm run start 76 | ``` 77 | 78 | ## License 79 | 80 | MIT 81 | -------------------------------------------------------------------------------- /assets/dddice-foundry-plugin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dddice/dddice-foundry-plugin/e2d0b84e84bfa9020f793a78a020090b6ac78d98/assets/dddice-foundry-plugin.gif -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.7.0) 2 | 3 | [changelog] 4 | # changelog header 5 | header = """ 6 | # Changelog 7 | Features, fixes, and enhancements for dddice.\n 8 | """ 9 | # template for the changelog body 10 | # https://tera.netlify.app/docs/#introduction 11 | body = """ 12 | {% if version %}\ 13 | ## {{ version }} 14 | _Released {{ timestamp | date(format="%Y-%m-%d") }}_ 15 | {% else %}\ 16 | ## [unreleased] 17 | {% endif %}\ 18 | {% for group, commits in commits | group_by(attribute="group") %} 19 | #### {{ group | upper_first }} 20 | {% for commit in commits %} 21 | - {{ commit.message | upper_first }}\ 22 | {% endfor %} 23 | {% endfor %}\n 24 | """ 25 | # remove the leading and trailing whitespaces from the template 26 | trim = false 27 | # changelog footer 28 | footer = """ 29 | Install from the module library or at 👇 30 | 31 | * Foundry VTT: https://foundryvtt.com/packages/dddice 32 | * Foundry Hub: https://www.foundryvtt-hub.com/package/dddice/ 33 | * The Forge: https://forge-vtt.com/bazaar/package/dddice 34 | """ 35 | 36 | [git] 37 | # parse the commits based on https://www.conventionalcommits.org 38 | conventional_commits = true 39 | # filter out the commits that are not conventional 40 | filter_unconventional = true 41 | # regex for parsing and grouping commits 42 | commit_parsers = [ 43 | { message = "(infrastructure)", skip = true, scope="other" }, 44 | { message = "(internal)", skip = true, scope="other" }, 45 | { message = "(handbook)", skip = true, scope="other" }, 46 | { message = "(deps)", skip = true, scope="other" }, 47 | { message = "^feat", group = "🎉 New Features", scope="other"}, 48 | { message = "^fix", group = "🐛 Bug Fixes", scope="other"}, 49 | { message = "^perf", group = "🚀 Performance", scope="other"}, 50 | { message = "^doc", group = "📝 Documentation", scope="other"}, 51 | { message = "^style", group = "🎨 Styling", scope="other"}, 52 | # { message = "^refactor", group = "⚠️ Refactor", scope="other"}, 53 | { message = "^chore\\(release\\): prepare for", skip = true, scope="other"}, 54 | { body = ".*security", group = "🔐 Security", scope="other"}, 55 | { message = "^chore", group = "⚙️ Miscellaneous", scope="other"}, 56 | ] 57 | # filter out the commits that are not matched by commit parsers 58 | filter_commits = true 59 | # glob pattern for matching git tags 60 | tag_pattern = "v[0-9]*" 61 | # skip tags with -rc.# in them 62 | skip_tags = "-rc.[0-9]*" 63 | # regex for ignoring tags 64 | ignore_tags = "" 65 | # sort the tags topologically 66 | topo_order = true 67 | # sort the commits inside sections by oldest/newest order 68 | sort_commits = "oldest" 69 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | v11: 5 | image: felddy/foundryvtt:11 6 | hostname: my_foundry_host 7 | init: true 8 | volumes: 9 | - type: bind 10 | source: ./data 11 | target: /data 12 | environment: 13 | - FOUNDRY_PASSWORD 14 | - FOUNDRY_USERNAME 15 | - CONTAINER_PRESERVE_CONFIG 16 | ports: 17 | - target: 30000 18 | published: 30000 19 | protocol: tcp 20 | 21 | v12: 22 | image: felddy/foundryvtt:12 23 | hostname: my_foundry_host 24 | init: true 25 | volumes: 26 | - type: bind 27 | source: ./data 28 | target: /data 29 | environment: 30 | - FOUNDRY_PASSWORD 31 | - FOUNDRY_USERNAME 32 | - CONTAINER_PRESERVE_CONFIG 33 | ports: 34 | - target: 30000 35 | published: 30000 36 | protocol: tcp -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | declare namespace OperatorTerm { 4 | interface TermData extends DiceTerm.TermData { 5 | operator: string; 6 | } 7 | } 8 | 9 | declare class OperatorTerm extends DiceTerm { 10 | constructor(termData?: Partial); 11 | } 12 | 13 | declare class NumericTerm extends DiceTerm {} 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | * @type {import('ts-jest/dist/types').InitialOptionsTsJest} 4 | */ 5 | 6 | module.exports = { 7 | clearMocks: true, 8 | testEnvironment: 'jsdom', 9 | testEnvironmentOptions: { resources: 'usable' }, 10 | moduleFileExtensions: ['js', 'json', 'ts', 'tsx'], 11 | //setupFiles: [/tests/Jest/mocks/jest.setup.ts'], 12 | transformIgnorePatterns: [ 13 | '/node_modules/(?!three|react-dnd-touch-backend|react-colorful|@owlbear-rodeo/sdk)', 14 | ], 15 | testMatch: ['/tests/*.spec.ts*'], 16 | reporters: [ 17 | 'default', 18 | ['jest-junit', { outputDirectory: 'tests/results', outputName: 'jest.xml' }], 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /module.njsproj.user: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | ProjectFiles 5 | 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dddice-foundry-plugin", 3 | "source": [ 4 | "src/dddice.ts", 5 | "src/dddice.css", 6 | "src/templates/ConfigPanel.html" 7 | ], 8 | "browserslist": "> 0.5%, last 2 versions, not dead", 9 | "version": "0.6.0", 10 | "description": "Roll 3D digital dice using Foundry VTT.", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/dddice/dddice-foundry-plugin" 14 | }, 15 | "license": "MIT", 16 | "homepage": "https://dddice.com", 17 | "author": "dddice ", 18 | "private": true, 19 | "scripts": { 20 | "start": "parcel --dist-dir=data/Data/modules/dddice --port 2345", 21 | "build": "parcel build", 22 | "lint": "eslint src", 23 | "clean": "rm -rf .parcel-cache && sudo rm -rf data/Data/modules/dddice", 24 | "test": "jest" 25 | }, 26 | "dependencies": { 27 | "classnames": "^2.3.2", 28 | "dddice-js": "^0.19.23", 29 | "debug": "^4.3.4", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-tooltip": "^4.5.1", 33 | "uuid": "^9.0.0" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.23.3", 37 | "@babel/plugin-proposal-decorators": "^7.23.3", 38 | "@babel/preset-env": "^7.23.3", 39 | "@babel/preset-typescript": "^7.23.3", 40 | "@league-of-foundry-developers/foundry-vtt-types": "^9.280.0", 41 | "@parcel/babel-preset-env": "^2.10.3", 42 | "@parcel/transformer-svg-react": "^2.8.0", 43 | "@parcel/validator-typescript": "^2.8.0", 44 | "@types/jest": "^29.5.10", 45 | "@types/marked": "^2.0.2", 46 | "@types/node": "^18.11.5", 47 | "@typescript-eslint/eslint-plugin": "^4.22.0", 48 | "@typescript-eslint/parser": "^4.22.0", 49 | "autoprefixer": "^10.4.12", 50 | "babel-jest": "^29.7.0", 51 | "buffer": "^6.0.3", 52 | "eslint": "^7.24.0", 53 | "eslint-config-prettier": "^8.2.0", 54 | "eslint-plugin-import": "^2.29.0", 55 | "eslint-plugin-jsx-a11y": "^6.8.0", 56 | "eslint-plugin-react": "^7.33.2", 57 | "eslint-plugin-react-hooks": "^4.6.0", 58 | "eslint-webpack-plugin": "^2.5.3", 59 | "husky": "^8.0.1", 60 | "jest": "^29.7.0", 61 | "jest-dom": "^4.0.0", 62 | "jest-environment-jsdom": "^29.7.0", 63 | "jest-junit": "^16.0.0", 64 | "parcel": "^2.8.0", 65 | "parcel-namer-rewrite": "^2.0.0-rc.3", 66 | "parcel-reporter-static-files-copy": "^1.4.0", 67 | "postcss": "^8.4.18", 68 | "postcss-nested": "^6.0.1", 69 | "prettier": "^2.2.1", 70 | "process": "^0.11.10", 71 | "tailwindcss": "^3.2.1", 72 | "typescript": "^4.2.4" 73 | }, 74 | "volta": { 75 | "node": "20.9.0" 76 | }, 77 | "staticFiles": { 78 | "staticPath": "static" 79 | }, 80 | "keywords": [ 81 | "dddice", 82 | "dice", 83 | "browser" 84 | ], 85 | "lint-staged": { 86 | "{**/*,*}.{css,json,html}": [ 87 | "prettier --write" 88 | ], 89 | "*.ts*": [ 90 | "eslint --fix", 91 | "prettier --write" 92 | ] 93 | }, 94 | "parcel-namer-rewrite": { 95 | "hashing": "never" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/dddice.css: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | .dddice { 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | } 8 | 9 | .dddice-hidden { 10 | display: none; 11 | } 12 | 13 | .\!dddice-hidden { 14 | display: none !important; 15 | } 16 | -------------------------------------------------------------------------------- /src/dddice.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | import { 3 | IRoll, 4 | IRoom, 5 | ITheme, 6 | IUser, 7 | ThreeDDice, 8 | ThreeDDiceAPI, 9 | ThreeDDiceRollEvent, 10 | ThreeDDiceRoomEvent, 11 | } from 'dddice-js'; 12 | import { v4 as uuidv4 } from 'uuid'; 13 | 14 | import { ConfigPanel } from './module/ConfigPanel'; 15 | import createLogger from './module/log'; 16 | import { 17 | convertDddiceRollModelToFVTTRollModel, 18 | convertFVTTDiceEquation, 19 | convertFVTTRollModelToDddiceRollModel, 20 | } from './module/rollFormatConverters'; 21 | 22 | const log = createLogger('module'); 23 | const pendingRollsFromShowForRoll = new Map void>(); 24 | 25 | // to store last room slug, to de-dupe room changes 26 | // by comparing room slugs and not whole objects (like foundry does automatically) 27 | let roomSlug; 28 | function sleep(ms) { 29 | return new Promise(resolve => setTimeout(resolve, ms)); 30 | } 31 | declare global { 32 | interface Window { 33 | dddice: ThreeDDice; 34 | api: ThreeDDiceAPI; 35 | } 36 | } 37 | 38 | let dddice: ThreeDDice; 39 | let api: ThreeDDiceAPI; 40 | 41 | const showForRoll = (...args) => { 42 | const room = getCurrentRoom(); 43 | const theme = getCurrentTheme(); 44 | 45 | const dddiceRoll = convertFVTTRollModelToDddiceRollModel([args[0]], theme?.id as string); 46 | const uuid = 'dsnFreeRoll:' + uuidv4(); 47 | if (room && theme && dddiceRoll) { 48 | api.roll.create(dddiceRoll.dice, { 49 | room: room.slug, 50 | operator: dddiceRoll.operator, 51 | external_id: uuid, 52 | }); 53 | } 54 | return new Promise(resolve => { 55 | pendingRollsFromShowForRoll.set(uuid, resolve); 56 | //fall back resolver 57 | setTimeout(() => resolve(), 1500); 58 | }); 59 | }; 60 | 61 | Hooks.once('init', async () => { 62 | window.dddice = new ThreeDDice(); 63 | 64 | // register static settings 65 | if (game instanceof Game) { 66 | game.settings.registerMenu('dddice', 'connect', { 67 | name: 'Settings', 68 | label: 'Configure dddice', 69 | hint: 'Configure dddice settings', 70 | icon: 'fa-solid fa-dice-d20', 71 | type: ConfigPanel, 72 | restricted: false, 73 | }); 74 | 75 | game.settings.register('dddice', 'render mode', { 76 | name: 'Render Mode', 77 | hint: 'If render mode is set to "off" then dice rolls will not be rendered but dice will still be sent to your dddice room via the API', 78 | scope: 'client', 79 | default: 'on', 80 | type: String, 81 | choices: { 82 | on: 'on', 83 | off: 'off', 84 | }, 85 | config: false, 86 | onChange: value => { 87 | log.debug('change render mode', value); 88 | setUpDddiceSdk(); 89 | }, 90 | }); 91 | 92 | game.settings.register('dddice', 'apiKey', { 93 | name: 'Api Key', 94 | hint: 'Link to your dddice account with your api key. Generate one at https://dddice.com/account/developer', 95 | scope: 'client', 96 | default: '', 97 | type: String, 98 | config: false, 99 | onChange: async value => { 100 | if (value) { 101 | await setUpDddiceSdk(); 102 | await syncUserNamesAndColors(); 103 | } 104 | }, 105 | }); 106 | 107 | game.settings.register('dddice', 'room', { 108 | name: 'Room', 109 | hint: 'Choose a dice room, that you have already joined via dddice.com, to roll in', 110 | scope: 'world', 111 | type: String, 112 | default: '', 113 | config: false, 114 | restricted: true, 115 | onChange: async value => { 116 | if (value) { 117 | const room = JSON.parse(value); 118 | if (room?.slug && room?.slug !== roomSlug) { 119 | roomSlug = value.slug; 120 | await setUpDddiceSdk(); 121 | await syncUserNamesAndColors(); 122 | } 123 | } 124 | }, 125 | }); 126 | 127 | game.settings.register('dddice', 'theme', { 128 | name: 'Dice Theme', 129 | hint: 'Choose a dice theme from your dice box', 130 | scope: 'client', 131 | type: String, 132 | default: '', 133 | config: false, 134 | onChange: value => { 135 | if (value) { 136 | const theme = JSON.parse(value) as ITheme; 137 | if (dddice) { 138 | dddice.loadTheme(theme, true); 139 | dddice.loadThemeResources(theme.id, true); 140 | } 141 | } 142 | }, 143 | }); 144 | 145 | game.settings.register('dddice', 'rooms', { 146 | name: 'Rooms', 147 | hint: 'Cached Room Data', 148 | scope: 'client', 149 | type: Array, 150 | default: [], 151 | config: false, 152 | }); 153 | 154 | game.settings.register('dddice', 'themes', { 155 | name: 'Dice Themes', 156 | hint: 'Cached Theme Data', 157 | scope: 'client', 158 | type: Array, 159 | default: [], 160 | config: false, 161 | }); 162 | } 163 | 164 | document.body.addEventListener('click', () => { 165 | if (dddice && !dddice?.isDiceThrowing) { 166 | dddice.clear(); 167 | } 168 | }); 169 | }); 170 | 171 | async function syncUserNamesAndColors() { 172 | if (getCurrentRoom() && (game as Game).user) { 173 | const room: IRoom = getCurrentRoom() as IRoom; 174 | const user: IUser = game.user?.getFlag('dddice', 'user') as IUser; 175 | log.debug('user', user); 176 | log.debug('room', room); 177 | const userParticipant = room.participants.find( 178 | ({ user: { uuid: participantUuid } }) => participantUuid === user?.uuid, 179 | ); 180 | log.debug('syncUserNamesAndColors', userParticipant); 181 | if (userParticipant) { 182 | try { 183 | await api.room.updateParticipant(room.slug, userParticipant.id, { 184 | username: (game as Game).user?.name as string, 185 | color: `${(game as Game).user?.border as string}`, 186 | }); 187 | } catch (e) { 188 | // log the error and continue 189 | // there seems to be a mystery 403 that could be 190 | // a race condition that I have yet to figure out 191 | // TODO: figure it out 192 | console.error(e); 193 | } 194 | } 195 | } 196 | } 197 | 198 | Hooks.once('ready', async () => { 199 | // if apiKey isn't set, create a guest account 200 | log.debug('ready hook'); 201 | 202 | await setUpDddiceSdk(); 203 | $(document).on('click', '.dddice-settings-button', event => { 204 | event.preventDefault(); 205 | const menu = game.settings.menus.get('dddice.connect'); 206 | const app = new menu.type(); 207 | return app.render(true); 208 | }); 209 | 210 | // pretend to be dice so nice, if it isn't set up so that we can capture roll 211 | // animation requests that are sent directly to it by "better 5e rolls" 212 | log.info('check for dice so nice', game.dice3d); 213 | if (!game.dice3d) { 214 | log.debug('DSN not there'); 215 | // pretend to be dsn; 216 | game.dice3d = { 217 | isEnabled: () => true, 218 | showForRoll: (...args) => { 219 | log.debug('steal show for roll', ...args); 220 | return showForRoll(...args); 221 | }, 222 | }; 223 | } 224 | 225 | // update dddice room participant names 226 | await syncUserNamesAndColors(); 227 | }); 228 | 229 | Hooks.on('diceSoNiceRollStart', (messageId, rollData) => { 230 | log.debug('dice so nice roll start hook', messageId, rollData); 231 | 232 | // if there is no message id we presume dddice didn't know about this roll 233 | // and send it to the api 234 | if (!messageId) { 235 | showForRoll(rollData); 236 | } 237 | }); 238 | 239 | const rollDiceFromChatMessage = async (chatMessage: ChatMessage) => { 240 | log.debug('is it a roll message?', chatMessage?.isRoll); 241 | const rolls = chatMessage?.rolls?.length > 0 ? chatMessage.rolls : null; 242 | log.debug('these are the rolls', chatMessage.rolls); 243 | if (rolls?.length > 0) { 244 | // remove the sound v10 245 | mergeObject(chatMessage, { '-=sound': null }, { performDeletions: true }); 246 | 247 | if (!chatMessage.flags?.dddice?.rollId) { 248 | if (game.settings.get('dddice', 'render mode') === 'on' && chatMessage.isContentVisible) { 249 | chatMessage._dddice_hide = true; 250 | } 251 | const room = getCurrentRoom(); 252 | const theme = getCurrentTheme(); 253 | for (const roll of rolls) { 254 | try { 255 | const dddiceRoll = convertFVTTDiceEquation(roll, theme?.id); 256 | log.debug('formatted dddice roll', dddiceRoll); 257 | if (chatMessage.isAuthor && dddiceRoll.dice.length > 0) { 258 | let participantIds; 259 | const whisper: IUser[] = chatMessage.whisper.map( 260 | user => 261 | (game as Game).users 262 | .find((u: User) => u.id === user) 263 | ?.getFlag('dddice', 'user') as IUser, 264 | ); 265 | log.debug('whisper', whisper); 266 | if (whisper?.length > 0 && room?.participants) { 267 | if ( 268 | chatMessage.isContentVisible && 269 | !whisper.some(u => u.uuid === game.user.getFlag('dddice', 'user').uuid) 270 | ) { 271 | whisper.push(game.user.getFlag('dddice', 'user')); 272 | } 273 | participantIds = whisper 274 | .map( 275 | (user: IUser) => 276 | room.participants.find( 277 | ({ user: { uuid: participantUuid } }) => participantUuid === user?.uuid, 278 | )?.id, 279 | ) 280 | .filter(i => i); 281 | } 282 | 283 | const dddiceRollResponse: IRoll = ( 284 | await api.roll.create(dddiceRoll.dice, { 285 | room: room?.slug, 286 | operator: dddiceRoll.operator, 287 | external_id: 'foundryVTT:' + chatMessage.uuid, 288 | whisper: participantIds, 289 | label: roll.options?.flavor, 290 | }) 291 | ).data; 292 | 293 | await chatMessage.setFlag('dddice', 'rollId', dddiceRollResponse.uuid); 294 | } 295 | } catch (e) { 296 | console.error(e); 297 | ui.notifications?.error(`dddice | ${e.response?.data?.data?.message ?? e}`); 298 | $(`[data-message-id=${chatMessage.id}]`).removeClass('!dddice-hidden'); 299 | window.ui.chat.scrollBottom({ popout: true }); 300 | chatMessage._dddice_hide = false; 301 | } 302 | } 303 | } 304 | } 305 | }; 306 | 307 | Hooks.on('createChatMessage', async message => { 308 | log.debug('calling Create Chat Message hook', message); 309 | await rollDiceFromChatMessage(message); 310 | }); 311 | 312 | Hooks.on('updateChatMessage', async (message, updateData, options) => { 313 | log.debug('calling Update Chat Message hook', message, updateData, options); 314 | await rollDiceFromChatMessage(message); 315 | }); 316 | 317 | // add css to hide roll messages about to be deleted to prevent flicker 318 | Hooks.on('renderChatMessage', (message, html, data) => { 319 | if (message._dddice_hide) { 320 | html.addClass('!dddice-hidden'); 321 | } 322 | }); 323 | 324 | Hooks.on('updateUser', async (user: User) => { 325 | if (user.isSelf) { 326 | await syncUserNamesAndColors(); 327 | } 328 | }); 329 | 330 | function getCurrentTheme() { 331 | try { 332 | return JSON.parse(game.settings.get('dddice', 'theme') as string) as ITheme; 333 | } catch { 334 | return undefined; 335 | } 336 | } 337 | 338 | function getCurrentRoom() { 339 | try { 340 | return JSON.parse(game.settings.get('dddice', 'room') as string) as IRoom; 341 | } catch { 342 | return undefined; 343 | } 344 | } 345 | 346 | async function createGuestUserIfNeeded() { 347 | let didSetup = false; 348 | const quitSetup = false; 349 | let justCreatedAnAccount = false; 350 | let apiKey = game.settings.get('dddice', 'apiKey') as string; 351 | if (!apiKey) { 352 | log.info('creating guest account'); 353 | apiKey = (await new ThreeDDiceAPI(undefined, 'Foundry VTT').user.guest()).data; 354 | await game.settings.set('dddice', 'apiKey', apiKey); 355 | didSetup = true; 356 | justCreatedAnAccount = true; 357 | } 358 | api = new ThreeDDiceAPI(apiKey, 'Foundry VTT'); 359 | 360 | const theme = game.settings.get('dddice', 'theme'); 361 | if (theme) { 362 | try { 363 | JSON.parse(theme as string) as ITheme; 364 | } catch { 365 | didSetup = true; 366 | await game.settings.set('dddice', 'theme', JSON.stringify((await api.theme.get(theme)).data)); 367 | } 368 | } else { 369 | log.info('pick random theme'); 370 | didSetup = true; 371 | const themes = (await api.diceBox.list()).data.filter(theme => 372 | Object.values( 373 | theme.available_dice 374 | .map(die => die.type ?? die) 375 | .reduce( 376 | (prev, curr) => { 377 | prev[curr] = true; 378 | return prev; 379 | }, 380 | { d4: false, d6: false, d8: false, d10: false, d10x: false, d20: false }, 381 | ), 382 | ).every(type => type), 383 | ); 384 | await game.settings.set( 385 | 'dddice', 386 | 'theme', 387 | JSON.stringify(themes[Math.floor(Math.random() * themes.length)]), 388 | ); 389 | } 390 | 391 | if ((!game.settings.get('dddice', 'room') || justCreatedAnAccount) && game.user?.isGM) { 392 | const oldRoom = window.localStorage.getItem('dddice.room'); 393 | if (oldRoom) { 394 | log.info('migrating room'); 395 | const room = (await api.room.get(oldRoom)).data; 396 | await game.settings.set('dddice', 'room', JSON.stringify(room)); 397 | window.localStorage.removeItem('dddice.room'); 398 | } else { 399 | log.info('getting room 0'); 400 | let room = (await api.room.list()).data; 401 | if (room && room[0]) { 402 | await game.settings.set('dddice', 'room', JSON.stringify(room[0])); 403 | } else { 404 | log.info('creating room'); 405 | room = (await api.room.create()).data; 406 | await game.settings.set('dddice', 'room', JSON.stringify(room)); 407 | } 408 | } 409 | //quitSetup = true; 410 | } else { 411 | let room = getCurrentRoom(); 412 | if (room?.slug) { 413 | try { 414 | room = (await api.room.join(room.slug)).data; 415 | if (game.user?.isGM) { 416 | await game.settings.set('dddice', 'room', JSON.stringify(room)); 417 | } 418 | } catch (error) { 419 | log.warn('eating error', error.response?.data?.data?.message); 420 | } 421 | } 422 | } 423 | 424 | return [didSetup, quitSetup]; 425 | } 426 | 427 | async function setUpDddiceSdk() { 428 | log.info('setting up dddice sdk'); 429 | const [_, shouldStopSetup] = await createGuestUserIfNeeded(); 430 | const apiKey = game.settings.get('dddice', 'apiKey') as string; 431 | const room = getCurrentRoom()?.slug; 432 | roomSlug = room; 433 | if (apiKey && room && !shouldStopSetup) { 434 | try { 435 | api = new ThreeDDiceAPI(apiKey, 'Foundry VTT'); 436 | const user: IUser = (await api.user.get()).data; 437 | game.user?.setFlag('dddice', 'user', user); 438 | 439 | let canvas: HTMLCanvasElement = document.getElementById('dddice-canvas') as HTMLCanvasElement; 440 | 441 | if (dddice) { 442 | // clear the board 443 | if (canvas) { 444 | canvas.remove(); 445 | canvas = undefined; 446 | } 447 | // disconnect from echo 448 | if (dddice.api?.connection) dddice.api.connection.disconnect(); 449 | // stop the animation loop 450 | dddice.stop(); 451 | } 452 | 453 | if (game.settings.get('dddice', 'render mode') === 'on') { 454 | log.info('render mode is on'); 455 | if (!canvas) { 456 | // add canvas element to document 457 | canvas = document.createElement('canvas'); 458 | canvas.id = 'dddice-canvas'; 459 | // if the css fails to load for any reason, using tailwinds classes here 460 | // will disable the whole interface 461 | canvas.style.top = '0px'; 462 | canvas.style.position = 'fixed'; 463 | canvas.style.pointerEvents = 'none'; 464 | canvas.style.zIndex = '100000'; 465 | canvas.style.opacity = '100'; 466 | canvas.style.height = '100vh'; 467 | canvas.style.width = '100vw'; 468 | document.body.appendChild(canvas); 469 | window.addEventListener( 470 | 'resize', 471 | () => dddice && dddice.renderer && dddice.resize(window.innerWidth, window.innerHeight), 472 | ); 473 | } 474 | dddice = new ThreeDDice().initialize(canvas, apiKey, undefined, 'Foundry VTT'); 475 | dddice.start(); 476 | dddice.connect(room, undefined, user.uuid); 477 | dddice.on(ThreeDDiceRollEvent.RollCreated, (roll: IRoll) => rollCreated(roll)); 478 | dddice.off(ThreeDDiceRollEvent.RollFinished); 479 | dddice.on(ThreeDDiceRollEvent.RollFinished, (roll: IRoll) => rollFinished(roll)); 480 | const theme = getCurrentTheme(); 481 | dddice.loadTheme(theme).loadThemeResources(theme); 482 | } else { 483 | log.info('render mode is off'); 484 | dddice = new ThreeDDice(); 485 | dddice.api = new ThreeDDiceAPI(apiKey, 'Foundry VTT'); 486 | dddice.api.connect(room, undefined, user.uuid); 487 | dddice.api.listen(ThreeDDiceRollEvent.RollCreated, (roll: IRoll) => rollCreated(roll)); 488 | } 489 | 490 | dddice.api.listen(ThreeDDiceRoomEvent.RoomUpdated, async (room: IRoom) => { 491 | // if you are the gm update the shared room object stored in the world settings 492 | // that only gms have access too 493 | if (game.user?.isGM) { 494 | await game.settings.set('dddice', 'room', JSON.stringify(room)); 495 | } 496 | 497 | // everyone updates their room list cache 498 | const updatedRoomCache = (await game.settings.get('dddice', 'rooms')).map(r => 499 | r.slug === room.slug ? room : r, 500 | ); 501 | game.settings.set('dddice', 'rooms', updatedRoomCache); 502 | }); 503 | 504 | ui.notifications?.info('dddice is ready to roll!'); 505 | } catch (e) { 506 | console.error(e); 507 | ui.notifications?.error(`dddice | ${e.response?.data?.data?.message ?? e}`); 508 | notConnectedMessage(); 509 | return; 510 | } 511 | } 512 | 513 | if (!game.user.getFlag('dddice', 'welcomeMessageShown')) { 514 | sendWelcomeMessage(); 515 | game.user.setFlag('dddice', 'welcomeMessageShown', true); 516 | } 517 | } 518 | 519 | const notConnectedMessage = () => { 520 | if ($(document).find('.dddice-not-connected').length === 0) { 521 | const message = { 522 | whisper: [game.user.id], 523 | speaker: { alias: 'dddice' }, 524 | content: ` 525 |
526 |

dddice | 3D Dice Roller

527 |

Your game has been configured to use the dddice 3D dice roller. However you are not properly connected to the system.

528 |

please update your configuration in our settings.

529 |
531 | `, 532 | }; 533 | ChatMessage.implementation.createDocuments([message]); 534 | } 535 | }; 536 | 537 | const sendWelcomeMessage = () => { 538 | const theme: ITheme = getCurrentTheme(); 539 | if ($(document).find('.dddice-welcome-message').length === 0) { 540 | const message = { 541 | whisper: [game.user.id], 542 | speaker: { alias: 'dddice' }, 543 | content: ` 544 |
545 |

dddice | 3D Dice Roller

546 |

Your game has been configured to use the dddice 3D dice roller.

547 |

Everything is all set up and ready to roll! you will be rolling these dice:

548 |
549 |
553 |
554 |
555 | ${theme.name} 556 |
557 |
558 |
559 |
560 |

If you want to change your dice you can change them in our settings

561 |
563 | `, 564 | }; 565 | ChatMessage.implementation.createDocuments([message]); 566 | } 567 | }; 568 | 569 | const rollCreated = async (roll: IRoll) => { 570 | const chatMessages = game.messages?.filter( 571 | chatMessage => chatMessage.getFlag('dddice', 'rollId') === roll.uuid, 572 | ); 573 | // if chat message doesn't exist, (for example a roll outside foundry) then add it in 574 | if ( 575 | (!chatMessages || chatMessages.length == 0) && 576 | !roll.external_id?.startsWith('dsnFreeRoll:') && 577 | !roll.external_id?.startsWith('foundryVTT:') 578 | ) { 579 | let shouldIMakeTheChat = false; 580 | 581 | // If I made the roll outside of foundry 582 | if (roll.user.uuid === (game.user?.getFlag('dddice', 'user') as IUser).uuid) 583 | // I should make the chat 584 | shouldIMakeTheChat = true; 585 | if ( 586 | // I am active user 0 587 | [...game.users.values()].filter(user => user.active)[0].isSelf && 588 | // and user who made the roll in dddice isn't connected to the game 589 | [...game.users.values()].filter( 590 | user => user.active && (user.getFlag('dddice', 'user') as IUser).uuid === roll.user.uuid, 591 | ).length === 0 592 | ) { 593 | // then I should roll on their behalf 594 | shouldIMakeTheChat = true; 595 | } 596 | 597 | if (shouldIMakeTheChat) { 598 | const foundryVttRoll: Roll = convertDddiceRollModelToFVTTRollModel(roll); 599 | 600 | let whisper; 601 | if (roll.participants) { 602 | whisper = roll.participants.map(({ participant }) => 603 | game.users.find( 604 | (user: User) => (user as User).getFlag('dddice', 'user').uuid === participant.user.uuid, 605 | ), 606 | ); 607 | } 608 | 609 | await foundryVttRoll.toMessage( 610 | { 611 | whisper, 612 | speaker: { 613 | alias: roll.room.participants.find( 614 | participant => participant.user.uuid === roll.user.uuid, 615 | )?.username, 616 | }, 617 | flags: { 618 | dddice: { 619 | rollId: roll.uuid, 620 | }, 621 | }, 622 | }, 623 | { rollMode: roll.participants?.length > 0 ? 'gmroll' : 'publicroll', create: true }, 624 | ); 625 | } 626 | } 627 | }; 628 | 629 | const rollFinished = async (roll: IRoll) => { 630 | const chatMessages = game.messages?.filter( 631 | chatMessage => chatMessage.getFlag('dddice', 'rollId') === roll.uuid, 632 | ); 633 | if (chatMessages && chatMessages.length > 0) { 634 | chatMessages?.forEach(chatMessage => { 635 | $(`[data-message-id=${chatMessage.id}]`).removeClass('!dddice-hidden'); 636 | chatMessage._dddice_hide = false; 637 | }); 638 | window.ui.chat.scrollBottom({ popout: true }); 639 | } 640 | 641 | if (roll.external_id) { 642 | if (pendingRollsFromShowForRoll.has(roll.external_id)) { 643 | const resolver = pendingRollsFromShowForRoll.get(roll.external_id); 644 | if (resolver) resolver(); 645 | pendingRollsFromShowForRoll.delete(roll.external_id); 646 | } 647 | } 648 | }; 649 | 650 | export { setUpDddiceSdk, syncUserNamesAndColors }; 651 | -------------------------------------------------------------------------------- /src/images/loading.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/module/ConfigPanel.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom/client'; 5 | 6 | import DddiceSettings from './DddiceSettings'; 7 | import StorageProvider from './StorageProvider'; 8 | import SdkBridge from './SdkBridge'; 9 | import PermissionProvider from './PermissionProvider'; 10 | 11 | export class ConfigPanel extends FormApplication { 12 | constructor(configOptions) { 13 | super(); 14 | this.render(true, { height: 500, width: 335 }); 15 | } 16 | 17 | activateListeners(html: JQuery) { 18 | super.activateListeners(html); 19 | const root = ReactDOM.createRoot(document.getElementById('dddice-config')); 20 | root.render( 21 | , 26 | ); 27 | } 28 | 29 | static get defaultOptions() { 30 | return mergeObject(super.defaultOptions, { 31 | classes: ['form'], 32 | popOut: true, 33 | closeOnSubmit: false, 34 | template: 'modules/dddice/templates/ConfigPanel.html', 35 | id: 'dddice-config-panel', 36 | title: 'dddice | configuration', 37 | }); 38 | } 39 | 40 | protected _updateObject(event: Event, formData: object | undefined): Promise { 41 | return Promise.resolve(undefined); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/module/DddiceSettings.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { IRoom, ITheme, ThreeDDiceAPI } from 'dddice-js'; 3 | import React, { useCallback, useEffect, useState, useRef } from 'react'; 4 | import ReactTooltip from 'react-tooltip'; 5 | import imageLogo from 'url:./assets/dddice-48x48.png'; 6 | 7 | import PermissionProvider from './PermissionProvider'; 8 | import SdkBridge from './SdkBridge'; 9 | import StorageProvider from './StorageProvider'; 10 | import LogOut from './assets/interface-essential-exit-door-log-out-1.svg'; 11 | import Back from './assets/interface-essential-left-arrow.svg'; 12 | import Loading from './assets/loading.svg'; 13 | import Help from './assets/support-help-question-question-square.svg'; 14 | import ApiKeyEntry from './components/ApiKeyEntry'; 15 | import Room from './components/Room'; 16 | import RoomSelection from './components/RoomSelection'; 17 | import Theme from './components/Theme'; 18 | import ThemeSelection from './components/ThemeSelection'; 19 | import Toggle from './components/Toggle'; 20 | 21 | export interface IStorage { 22 | apiKey?: string; 23 | room?: IRoom; 24 | theme?: ITheme; 25 | themes?: ITheme[]; 26 | rooms?: IRoom[]; 27 | renderMode: boolean; 28 | } 29 | 30 | export const DefaultStorage: IStorage = { 31 | apiKey: undefined, 32 | room: undefined, 33 | theme: undefined, 34 | themes: undefined, 35 | rooms: undefined, 36 | renderMode: true, 37 | }; 38 | 39 | interface DddiceSettingsProps { 40 | storageProvider: StorageProvider; 41 | sdkBridge: SdkBridge; 42 | permissionProvider: PermissionProvider; 43 | } 44 | 45 | const DddiceSettings = (props: DddiceSettingsProps) => { 46 | const { storageProvider, sdkBridge, permissionProvider } = props; 47 | 48 | /** 49 | * API 50 | */ 51 | const api = useRef(ThreeDDiceAPI); 52 | 53 | /** 54 | * Storage Object 55 | */ 56 | const [state, setState] = useState(DefaultStorage); 57 | 58 | /** 59 | * Loading 60 | */ 61 | const [isLoading, setIsLoading] = useState(0); 62 | 63 | const pushLoading = () => setIsLoading(isLoading => isLoading + 1); 64 | const popLoading = () => setIsLoading(isLoading => Math.max(isLoading - 1, 0)); 65 | const clearLoading = () => setIsLoading(0); 66 | 67 | /** 68 | * Loading 69 | */ 70 | const [loadingMessage, setLoadingMessage] = useState(''); 71 | 72 | /** 73 | * Connected 74 | */ 75 | const [isConnected, setIsConnected] = useState(false); 76 | 77 | /** 78 | * Error 79 | */ 80 | const [error, setError] = useState(); 81 | 82 | /** 83 | * Current VTT 84 | */ 85 | const [vtt, setVTT] = useState(undefined); 86 | 87 | const [isEnterApiKey, setIsEnterApiKey] = useState(false); 88 | 89 | /** 90 | * Connect to VTT 91 | * Mount / Unmount 92 | */ 93 | useEffect(() => { 94 | async function connect() { 95 | const platform = await sdkBridge.detectPlatform(); 96 | 97 | if (platform) { 98 | setIsConnected(true); 99 | setVTT(platform); 100 | } 101 | } 102 | 103 | connect(); 104 | }, []); 105 | 106 | useEffect(() => { 107 | async function initStorage() { 108 | const [apiKey, room, theme, rooms, themes, renderMode] = await Promise.all([ 109 | storageProvider.getStorage('apiKey'), 110 | storageProvider.getStorage('room'), 111 | storageProvider.getStorage('theme'), 112 | storageProvider.getStorage('rooms'), 113 | storageProvider.getStorage('themes'), 114 | storageProvider.getStorage('render mode'), 115 | ]); 116 | 117 | setState((storage: IStorage) => ({ 118 | ...storage, 119 | apiKey, 120 | room, 121 | theme, 122 | rooms, 123 | themes, 124 | renderMode, 125 | })); 126 | } 127 | 128 | if (isConnected) { 129 | initStorage(); 130 | } 131 | }, [isConnected]); 132 | 133 | const refreshThemes = async () => { 134 | let themes: ITheme[] = []; 135 | pushLoading(); 136 | setLoadingMessage('Loading themes (1)'); 137 | let _themes = (await api.current.diceBox.list()).data; 138 | 139 | let page = 1; 140 | while (_themes) { 141 | setLoadingMessage(`Loading themes (${page++})`); 142 | themes = [...themes, ..._themes]; 143 | _themes = (await api.current.diceBox.next())?.data; 144 | } 145 | storageProvider.setStorage({ themes }); 146 | setState(state => ({ 147 | ...state, 148 | themes, 149 | })); 150 | popLoading(); 151 | }; 152 | 153 | const refreshRoom = useCallback(async () => { 154 | if (state?.room?.slug) { 155 | setLoadingMessage('refreshing room data'); 156 | pushLoading(); 157 | const room = (await api.current.room.get(state.room.slug)).data; 158 | if (permissionProvider.canChangeRoom()) { 159 | storageProvider.setStorage({ room }); 160 | } 161 | setState(state => ({ ...state, room })); 162 | popLoading(); 163 | } 164 | }, [state?.room?.slug]); 165 | 166 | const refreshRooms = async () => { 167 | setLoadingMessage('Loading rooms list'); 168 | pushLoading(); 169 | const rooms = (await api.current.room.list()).data; 170 | storageProvider.setStorage({ rooms }); 171 | setState(state => ({ ...state, rooms })); 172 | popLoading(); 173 | }; 174 | 175 | useEffect(() => { 176 | if (state.apiKey) { 177 | api.current = new ThreeDDiceAPI(state.apiKey, 'Foundry VTT'); 178 | 179 | const load = async () => { 180 | pushLoading(); 181 | 182 | try { 183 | if (!state.rooms || state.rooms.length === 0) { 184 | await refreshRooms(); 185 | } 186 | 187 | if (state.room) { 188 | await refreshRoom(); 189 | } 190 | 191 | if (!state.themes || state.themes.length === 0) { 192 | await refreshThemes(); 193 | } 194 | popLoading(); 195 | } catch (error) { 196 | setError('Problem connecting with dddice'); 197 | clearLoading(); 198 | return; 199 | } 200 | }; 201 | 202 | load(); 203 | } 204 | }, [state.apiKey]); 205 | 206 | useEffect(() => ReactTooltip.rebuild()); 207 | 208 | const reloadDiceEngine = async () => { 209 | await sdkBridge.reloadDiceEngine(); 210 | }; 211 | 212 | const onJoinRoom = useCallback( 213 | async (roomSlug: string, passcode?: string) => { 214 | if (roomSlug) { 215 | setLoadingMessage('Joining room'); 216 | pushLoading(); 217 | await createGuestAccountIfNeeded(); 218 | const room = state.rooms && state.rooms.find(r => r.slug === roomSlug); 219 | if (room) { 220 | onChangeRoom(room); 221 | } else { 222 | let newRoom; 223 | try { 224 | newRoom = (await api.current.room.join(roomSlug, passcode)).data; 225 | } catch (error) { 226 | setError('could not join room'); 227 | clearLoading(); 228 | throw error; 229 | } 230 | if (newRoom) { 231 | await storageProvider.setStorage({ 232 | rooms: state.rooms ? [...state.rooms, newRoom] : [newRoom], 233 | }); 234 | setState((storage: IStorage) => ({ 235 | ...storage, 236 | rooms: storage.rooms ? [...storage.rooms, newRoom] : [newRoom], 237 | })); 238 | await onChangeRoom(newRoom); 239 | } 240 | } 241 | popLoading(); 242 | } 243 | }, 244 | [state], 245 | ); 246 | 247 | const onChangeRoom = useCallback( 248 | async (room: IRoom) => { 249 | // if room isn't in rooms list, assume it needs to be joined 250 | 251 | setState((storage: IStorage) => ({ 252 | ...storage, 253 | room, 254 | })); 255 | 256 | ReactTooltip.hide(); 257 | if (room) { 258 | if (permissionProvider.canChangeRoom()) { 259 | await storageProvider.setStorage({ room }); 260 | } 261 | await reloadDiceEngine(); 262 | } 263 | }, 264 | [state.rooms], 265 | ); 266 | 267 | const onCreateRoom = useCallback(async () => { 268 | setLoadingMessage('Creating Room'); 269 | pushLoading(); 270 | await createGuestAccountIfNeeded(); 271 | let newRoom; 272 | try { 273 | newRoom = (await api.current.room.create()).data; 274 | } catch (error) { 275 | setError('could not create room'); 276 | clearLoading(); 277 | throw error; 278 | } 279 | if (newRoom) { 280 | await storageProvider.setStorage({ 281 | rooms: state.rooms ? [...state.rooms, newRoom] : [newRoom], 282 | }); 283 | setState((storage: IStorage) => ({ 284 | ...storage, 285 | rooms: storage.rooms ? [...storage.rooms, newRoom] : [newRoom], 286 | })); 287 | } 288 | 289 | setState((storage: IStorage) => ({ 290 | ...storage, 291 | room: newRoom, 292 | })); 293 | if (permissionProvider.canChangeRoom()) { 294 | await storageProvider.setStorage({ room: newRoom }); 295 | } 296 | popLoading(); 297 | await reloadDiceEngine(); 298 | }, [state.rooms]); 299 | 300 | const onChangeTheme = useCallback((theme: ITheme) => { 301 | setState((storage: IStorage) => ({ 302 | ...storage, 303 | theme, 304 | })); 305 | 306 | if (theme) { 307 | storageProvider.setStorage({ theme }); 308 | } 309 | 310 | ReactTooltip.hide(); 311 | }, []); 312 | 313 | const onKeySuccess = useCallback((apiKey: string) => { 314 | setState((storage: IStorage) => ({ 315 | ...storage, 316 | apiKey, 317 | rooms: undefined, 318 | themes: undefined, 319 | })); 320 | storageProvider.setStorage({ apiKey }); 321 | setIsEnterApiKey(false); 322 | reloadDiceEngine(); 323 | }, []); 324 | 325 | const onSignOut = useCallback(() => { 326 | setState(DefaultStorage); 327 | storageProvider.removeStorage('apiKey'); 328 | storageProvider.removeStorage('theme'); 329 | if (permissionProvider.canChangeRoom()) { 330 | storageProvider.removeStorage('room'); 331 | } 332 | storageProvider.removeStorage('rooms'); 333 | storageProvider.removeStorage('themes'); 334 | setError(undefined); 335 | clearLoading(); 336 | }, []); 337 | 338 | const onSwitchRoom = useCallback(async () => { 339 | onChangeRoom(undefined); 340 | }, []); 341 | 342 | const onSwitchTheme = useCallback(async () => { 343 | onChangeTheme(undefined); 344 | }, []); 345 | 346 | const createGuestAccountIfNeeded = useCallback(async () => { 347 | if (!state.apiKey || !api.current) { 348 | try { 349 | const apiKey = (await new ThreeDDiceAPI().user.guest()).data; 350 | api.current = new ThreeDDiceAPI(apiKey, 'Foundry VTT'); 351 | setState((storage: IStorage) => ({ 352 | ...storage, 353 | apiKey, 354 | })); 355 | await storageProvider.setStorage({ apiKey }); 356 | } catch (error) { 357 | setError('could not create room'); 358 | clearLoading(); 359 | throw error; 360 | } 361 | } 362 | }, [state]); 363 | 364 | /** 365 | * Render 366 | */ 367 | return ( 368 |
369 | 370 | {isConnected && ( 371 | <> 372 |
373 | {isEnterApiKey ? ( 374 | setIsEnterApiKey(false)} 377 | > 378 | 379 | 380 | ) : ( 381 | 386 | 387 | 388 | )} 389 | 390 | 391 | 392 |
393 | 394 | )} 395 |
396 | dddice 397 | dddice 398 |
399 | {error && ( 400 |
401 |

{error}

402 |
403 | )} 404 | {isEnterApiKey ? ( 405 | 406 | ) : ( 407 | isConnected && ( 408 | <> 409 | {isLoading ? ( 410 |
411 | 412 |
{loadingMessage}
413 |
414 | ) : ( 415 | <> 416 | {(!state.apiKey || !state.room) && permissionProvider.canChangeRoom() ? ( 417 | setIsEnterApiKey(true)} 423 | onCreateRoom={onCreateRoom} 424 | onRefreshRooms={refreshRooms} 425 | /> 426 | ) : !state.theme ? ( 427 | setIsEnterApiKey(true)} 431 | onRefreshThemes={refreshThemes} 432 | /> 433 | ) : ( 434 | <> 435 | 440 | 441 |
442 | Render Dice 443 |
444 | { 447 | setState(state => ({ ...state, renderMode: value })); 448 | await storageProvider.setStorage({ 'render mode': value }); 449 | sdkBridge.reloadDiceEngine(); 450 | }} 451 | /> 452 |
453 |
454 | 455 | )} 456 | 457 | )} 458 | 459 | ) 460 | )} 461 | {!isConnected && ( 462 |
463 | 464 | Not connected. Please navigate to a supported VTT. 465 | 466 |
467 | )} 468 |

469 | {isConnected && ( 470 | <> 471 | Connected to {vtt} 472 | {' | '} 473 | 474 | )} 475 | dddice {process.env.VERSION} 476 |

477 |
478 | ); 479 | }; 480 | 481 | export default DddiceSettings; 482 | -------------------------------------------------------------------------------- /src/module/PermissionProvider.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | export default class PermissionProvider { 4 | canChangeRoom() { 5 | return game.user?.isGM; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/module/SdkBridge.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | export default class SdkBridge { 4 | reloadDiceEngine() { 5 | return; 6 | } 7 | 8 | async detectPlatform() { 9 | return 'Foundry VTT'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/module/StorageProvider.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | export default class StorageProvider { 3 | async getStorage(key: string): Promise { 4 | return new Promise(resolve => { 5 | const value = game.settings.get('dddice', key); 6 | if (key === 'theme') { 7 | try { 8 | resolve(JSON.parse(value as string)); 9 | } catch { 10 | resolve(undefined); 11 | } 12 | } else if (key === 'room') { 13 | try { 14 | resolve(JSON.parse(value as string)); 15 | } catch { 16 | resolve(undefined); 17 | } 18 | } else if (key === 'rooms' || key === 'themes') { 19 | if (value.length === 0) { 20 | resolve(undefined); 21 | } else { 22 | resolve(value); 23 | } 24 | } else if (key === 'render mode') { 25 | resolve(value === 'on' || value === undefined); 26 | } else { 27 | resolve(value); 28 | } 29 | }); 30 | } 31 | 32 | async setStorage(payload: Record): Promise { 33 | return Promise.all( 34 | Object.entries(payload).map(([key, value]) => { 35 | if (key === 'theme' || key === 'room') { 36 | game.settings.set('dddice', key, JSON.stringify(value)); 37 | } else if (key === 'render mode') { 38 | game.settings.set('dddice', 'render mode', value ? 'on' : 'off'); 39 | } else { 40 | game.settings.set('dddice', key, value); 41 | } 42 | }), 43 | ); 44 | } 45 | 46 | async removeStorage(key: string): Promise { 47 | if (key === 'theme' || key === 'room') { 48 | return game.settings.set('dddice', key, ''); 49 | } else if (key === 'render mode') { 50 | return game.settings.set('dddice', 'render mode', 'on'); 51 | } else { 52 | return game.settings.set('dddice', key, undefined); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/module/assets/arrows-diagrams-arrow-rotate-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/assets/dddice-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dddice/dddice-foundry-plugin/e2d0b84e84bfa9020f793a78a020090b6ac78d98/src/module/assets/dddice-16x16.png -------------------------------------------------------------------------------- /src/module/assets/dddice-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dddice/dddice-foundry-plugin/e2d0b84e84bfa9020f793a78a020090b6ac78d98/src/module/assets/dddice-32x32.png -------------------------------------------------------------------------------- /src/module/assets/dddice-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dddice/dddice-foundry-plugin/e2d0b84e84bfa9020f793a78a020090b6ac78d98/src/module/assets/dddice-48x48.png -------------------------------------------------------------------------------- /src/module/assets/interface-essential-checkmark-sqaure-copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/assets/interface-essential-exit-door-log-out-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/assets/interface-essential-left-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/assets/interface-essential-share-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/assets/loading.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/assets/support-help-question-question-square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module/components/ApiKeyEntry.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Submit API Key 3 | * 4 | * @format 5 | */ 6 | 7 | import React, { useCallback, useState, useRef } from 'react'; 8 | import classNames from 'classnames'; 9 | 10 | import { ThreeDDiceAPI, IUser } from 'dddice-js'; 11 | 12 | import Check from '../assets/interface-essential-checkmark-sqaure-copy.svg'; 13 | 14 | interface ISplash { 15 | onSuccess(apiKey: string, user: IUser): any; 16 | } 17 | 18 | const ApiKeyEntry = (props: ISplash) => { 19 | const { onSuccess } = props; 20 | const formRef = useRef(); 21 | 22 | /** 23 | * Loading 24 | */ 25 | const [isLoading, setIsLoading] = useState(false); 26 | 27 | /** 28 | * Check if API Key is valid 29 | */ 30 | const checkKeyValid = useCallback(async apiKey => { 31 | try { 32 | setIsLoading(true); 33 | const api = new ThreeDDiceAPI(apiKey, 'Foundry VTT'); 34 | const user: IUser = (await api.user.get()).data; 35 | onSuccess(apiKey, user); 36 | setIsLoading(false); 37 | } catch (error) { 38 | console.error(error); 39 | } 40 | }, []); 41 | 42 | /** 43 | * Submit API Key Form 44 | */ 45 | const onSubmit = useCallback(e => { 46 | e.preventDefault(); 47 | 48 | const formData = new FormData(formRef.current); 49 | const apiKey = formData.get('apiKey'); 50 | checkKeyValid(apiKey); 51 | }, []); 52 | 53 | /** 54 | * Render 55 | */ 56 | return ( 57 | <> 58 |
59 | 78 |
79 | 80 | {isLoading && ( 81 | Connecting ... 82 | )} 83 | 84 |

85 | Enter your{' '} 86 | 91 | dddice API Key 92 | {' '} 93 | above to roll 3D dice using your favorite VTT. 94 |

95 | 96 | ); 97 | }; 98 | 99 | export default ApiKeyEntry; 100 | -------------------------------------------------------------------------------- /src/module/components/DddiceButton.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | 5 | const DddiceButton = props => { 6 | const { onClick, children, size, isSecondary } = props; 7 | 8 | return ( 9 |
16 | 23 |   24 | 25 | 31 | 38 | {children} 39 | 40 | 41 |
42 | ); 43 | }; 44 | 45 | export default DddiceButton; 46 | -------------------------------------------------------------------------------- /src/module/components/Room.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React, { useState } from 'react'; 4 | 5 | import { IRoom } from 'dddice-js'; 6 | 7 | import Share from '../assets/interface-essential-share-2.svg'; 8 | 9 | import RoomCard from './RoomCard'; 10 | 11 | interface IRoomProps { 12 | room: IRoom; 13 | onSwitchRoom(); 14 | disabled?: boolean; 15 | } 16 | 17 | const Room = (props: IRoomProps) => { 18 | const [isCopied, setIsCopied] = useState(false); 19 | 20 | const { room, onSwitchRoom, disabled } = props; 21 | if (room) { 22 | return ( 23 |
24 |
25 |
26 |
Room
27 | {isCopied ? ( 28 |
copied to clipboard
29 | ) : ( 30 | { 32 | await navigator.clipboard.writeText( 33 | `${process.env.API_URI}/room/${room.slug}${ 34 | !room.is_public ? '?passcode=' + room.passcode : '' 35 | }`, 36 | ); 37 | setIsCopied(true); 38 | setTimeout(() => setIsCopied(false), 2000); 39 | }} 40 | className="ml-auto cursor-pointer" 41 | > 42 | 43 | 44 | )} 45 |
46 |
47 | onSwitchRoom()} 50 | disabled={disabled} 51 | key={room.slug} 52 | /> 53 |
54 |
55 | ); 56 | } 57 | }; 58 | 59 | export default Room; 60 | -------------------------------------------------------------------------------- /src/module/components/RoomCard.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React from 'react'; 4 | 5 | import { IRoom } from 'dddice-js'; 6 | import classNames from 'classnames'; 7 | 8 | interface IRoomCardProps { 9 | room: IRoom; 10 | onClick(); 11 | disabled?: boolean; 12 | key: string; 13 | } 14 | 15 | const RoomCard = (props: IRoomCardProps) => { 16 | const { room, onClick, disabled } = props; 17 | return ( 18 |
disabled || onClick()} 26 | > 27 |
28 |
29 | {room.name} 30 |
31 |
32 |
33 | {room.participants.length > 4 && ( 34 | 41 | 42 | {'+'} 43 | 44 | 45 | )} 46 | {room.participants && 47 | room.participants.slice(0, 4).map(participant => ( 48 | 56 | 63 | {participant.username[0]} 64 | 65 | 66 | ))} 67 |
68 |
69 | ); 70 | }; 71 | 72 | export default RoomCard; 73 | -------------------------------------------------------------------------------- /src/module/components/RoomSelection.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * List Rooms 3 | * 4 | * @format 5 | */ 6 | 7 | import React, { useRef } from 'react'; 8 | import { IRoom } from 'dddice-js'; 9 | 10 | import Refresh from '../assets/arrows-diagrams-arrow-rotate-1.svg'; 11 | import Check from '../assets/interface-essential-checkmark-sqaure-copy.svg'; 12 | 13 | import DddiceButton from './DddiceButton'; 14 | import RoomCard from './RoomCard'; 15 | 16 | interface IRooms { 17 | rooms: IRoom[]; 18 | onSelectRoom(room: IRoom): void; 19 | onJoinRoom(room: string, passcode?: string): void; 20 | onError(message: string): void; 21 | onConnectAccount(): void; 22 | onCreateRoom(): void; 23 | onRefreshRooms(): void; 24 | } 25 | 26 | const RoomSelection = (props: IRooms) => { 27 | const { 28 | rooms, 29 | onSelectRoom, 30 | onJoinRoom, 31 | onError, 32 | onConnectAccount, 33 | onCreateRoom, 34 | onRefreshRooms, 35 | } = props; 36 | 37 | const formRef = useRef(); 38 | 39 | const onChangeLink = event => { 40 | event.preventDefault(); 41 | 42 | const formData = new FormData(formRef.current); 43 | const link = formData.get('link') as string; 44 | const passcode = new URLSearchParams(link.split('?')[1]).get('passcode'); 45 | const match = link.match(/\/room\/([a-zA-Z0-9_-]{7,14})/); 46 | if (match) { 47 | onJoinRoom(match[1], passcode); 48 | } else { 49 | onError('Invalid room link.'); 50 | } 51 | }; 52 | 53 | /** 54 | * Render 55 | */ 56 | return ( 57 |
58 |
59 |
{''}
60 |
Join A Room
61 | 62 | 63 | 64 |
65 |
66 | 73 |
74 |
75 |
76 |
or
77 |
78 |
79 |
80 | Don't see your rooms?{' '} 81 | 82 | connect your account 83 | 84 |
85 | {rooms?.length > 0 ? ( 86 |
87 | {rooms.map((room: IRoom) => ( 88 | onSelectRoom(room)} key={room.slug} /> 89 | ))} 90 |
91 | ) : ( 92 | Create A Room 93 | )} 94 |
95 | ); 96 | }; 97 | 98 | export default RoomSelection; 99 | -------------------------------------------------------------------------------- /src/module/components/Theme.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React, { useState } from 'react'; 4 | 5 | import { ITheme } from 'dddice-js'; 6 | 7 | import Share from '../assets/interface-essential-share-2.svg'; 8 | 9 | import ThemeCard from './ThemeCard'; 10 | 11 | interface IThemeProps { 12 | theme: ITheme; 13 | onSwitchTheme(); 14 | } 15 | 16 | const Theme = (props: IThemeProps) => { 17 | const [isCopied, setIsCopied] = useState(false); 18 | 19 | const { theme, onSwitchTheme } = props; 20 | if (theme) { 21 | return ( 22 |
23 |
24 |
25 |
Dice
26 | {/*isCopied ? ( 27 |
copied to clipboard
28 | ) : ( 29 | { 31 | await navigator.clipboard.writeText(`${process.env.API_URI}/dice/${theme.id}`); 32 | setIsCopied(true); 33 | setTimeout(() => setIsCopied(false), 2000); 34 | }} 35 | className="ml-auto" 36 | > 37 | 38 | 39 | )*/} 40 |
41 |
42 | onSwitchTheme()} key={theme.id} /> 43 |
44 |
45 | ); 46 | } 47 | }; 48 | 49 | export default Theme; 50 | -------------------------------------------------------------------------------- /src/module/components/ThemeCard.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React from 'react'; 4 | import { ITheme } from 'dddice-js'; 5 | 6 | interface IRoomCardProps { 7 | theme: ITheme; 8 | onClick(); 9 | key?: string; 10 | } 11 | 12 | const ThemeCard = (props: IRoomCardProps) => { 13 | const { theme, onClick } = props; 14 | 15 | if (theme) { 16 | return ( 17 |
onClick()} 25 | > 26 |
27 |
28 | {theme.name} 29 |
30 |
31 |
32 | ); 33 | } 34 | }; 35 | 36 | export default ThemeCard; 37 | -------------------------------------------------------------------------------- /src/module/components/ThemeSelection.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * List Rooms 3 | * 4 | * @format 5 | */ 6 | 7 | import React from 'react'; 8 | import { ITheme } from 'dddice-js'; 9 | 10 | import Refresh from '../assets/arrows-diagrams-arrow-rotate-1.svg'; 11 | 12 | import DddiceButton from './DddiceButton'; 13 | import ThemeCard from './ThemeCard'; 14 | 15 | interface IThemes { 16 | themes: ITheme[]; 17 | onSelectTheme(room: ITheme): void; 18 | onConnectAccount(): void; 19 | onRefreshThemes(): void; 20 | } 21 | 22 | const ThemeSelection = (props: IThemes) => { 23 | const { themes, onSelectTheme, onConnectAccount, onRefreshThemes } = props; 24 | 25 | /** 26 | * Render 27 | */ 28 | return ( 29 |
30 |
31 |
{''}
32 |
Choose Your Dice
33 | 34 | 35 | 36 |
37 |
38 | Don't see your dice?{' '} 39 | 40 | connect your account 41 | 42 |
43 | {themes?.length > 0 && ( 44 |
45 | {themes.map((theme: ITheme) => ( 46 | onSelectTheme(theme)} key={theme.id} /> 47 | ))} 48 |
49 | )} 50 |
51 | ); 52 | }; 53 | 54 | export default ThemeSelection; 55 | -------------------------------------------------------------------------------- /src/module/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React, { useState, useCallback } from 'react'; 4 | import classNames from 'classnames'; 5 | 6 | export default ({ onChange, value = false }) => { 7 | const [isEnabled, setIsEnabled] = useState(value); 8 | 9 | const onClick = useCallback(() => { 10 | onChange(!isEnabled); 11 | setIsEnabled(isEnabled => (isEnabled === undefined ? false : !isEnabled)); 12 | }, [isEnabled]); 13 | 14 | return ( 15 |
22 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/module/helper/TemplatePreloader.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | export class TemplatePreloader { 4 | /** 5 | * Preload a set of templates to compile and cache them for fast access during rendering 6 | */ 7 | static async preloadHandlebarsTemplates() { 8 | const templatePaths = ['modules/dddice/templates/ConfigPanel.html']; 9 | return loadTemplates(templatePaths); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/module/log.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main Logger 3 | * Creates a "warn", "error", "info", and "debug" loggers for a given pathname 4 | * 5 | * @format 6 | */ 7 | 8 | import debug from 'debug'; 9 | 10 | const NS = `dddice-foundry-module`; 11 | 12 | function createLogger(name: string, level: string) { 13 | return debug(`${NS}:${name}:${level}`); 14 | } 15 | 16 | /** 17 | * Create a logger 18 | * 19 | * **Example:** 20 | * 21 | * ``` 22 | * import createLogger from '@dice/logger' 23 | * 24 | * const log = createLogger(__filename) 25 | * ``` 26 | */ 27 | export default function logger(name: string) { 28 | return { 29 | debug: createLogger(name, 'debug'), 30 | error: createLogger(name, 'error'), 31 | info: createLogger(name, 'info'), 32 | warn: createLogger(name, 'warn'), 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/module/rollFormatConverters.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { IDiceRoll, IRoll, IRollValue, Operator, parseRollEquation } from 'dddice-js'; 4 | 5 | import createLogger from '../module/log'; 6 | 7 | const log = createLogger('module'); 8 | 9 | function convertD100toD10x(theme, value) { 10 | return [ 11 | { 12 | theme, 13 | type: 'd10x', 14 | value: Math.ceil(value / 10 - 1) === 0 ? 10 : Math.ceil(value / 10 - 1), 15 | value_to_display: `${Math.ceil(value / 10 - 1) * 10}`, 16 | }, 17 | { theme, type: 'd10', value: ((value - 1) % 10) + 1 }, 18 | ]; 19 | } 20 | 21 | export function convertDddiceRollModelToFVTTRollModel(dddiceRolls: IRoll): Roll { 22 | interface DieAggregation { 23 | count: number; 24 | values: number[]; 25 | themes: string[]; 26 | } 27 | 28 | const modifiers = dddiceRolls.operator 29 | ? Object.entries(dddiceRolls.operator).map(([key, value]) => `${key}${value}`) 30 | : []; 31 | 32 | const dieAggregations: Record = dddiceRolls.values.reduce( 33 | (prev, current): { [id: string]: DieAggregation } => { 34 | if (prev[current.type]) { 35 | prev[current.type] = { 36 | values: [...prev[current.type].values, current], 37 | count: prev[current.type].count + (current.type === 'mod' ? current.vaule : 1), 38 | themes: [...prev[current.type].themes, current.theme], 39 | }; 40 | } else { 41 | prev[current.type] = { 42 | values: [current], 43 | count: current.type === 'mod' ? current.value : 1, 44 | themes: [current.theme], 45 | }; 46 | } 47 | return prev; 48 | }, 49 | {}, 50 | ); 51 | 52 | log.debug('dieAggregations', dieAggregations); 53 | 54 | if (dieAggregations?.d10x?.count > 0 && dieAggregations?.d10?.count > 0) { 55 | dieAggregations.d100 = { values: [], count: 0, themes: [] }; 56 | const d10 = dieAggregations.d10; 57 | const d10x = dieAggregations.d10x; 58 | delete dieAggregations.d10; 59 | delete dieAggregations.d10x; 60 | let i; 61 | for (i = 0; i < d10x.count && i < d10.count; ++i) { 62 | dieAggregations.d100.values[i] = { 63 | value: d10.values[i].value + d10x.values[i].value, 64 | value_to_display: 65 | parseInt(d10.values[i].value_to_display) + parseInt(d10x.values[i].value_to_display), 66 | }; 67 | dieAggregations.d100.count++; 68 | dieAggregations.d100.themes[i] = d10x.themes[i]; 69 | } 70 | log.debug('i', i); 71 | if (i < d10.count) { 72 | dieAggregations.d10 = { values: [], count: 0, themes: [] }; 73 | } 74 | if (i < d10x.count) { 75 | dieAggregations.d10x = { values: [], count: 0, themes: [] }; 76 | } 77 | while (i < d10x.count || i < d10.count) { 78 | if (i < d10.count) { 79 | dieAggregations.d10.values.push(d10.values[i]); 80 | dieAggregations.d10.count++; 81 | dieAggregations.d10.themes.push(d10.themes[i]); 82 | } 83 | if (i < d10x.count) { 84 | dieAggregations.d10x.values.push(d10x.values[i]); 85 | dieAggregations.d10x.count++; 86 | dieAggregations.d10x.themes.push(d10x.themes[i]); 87 | } 88 | ++i; 89 | } 90 | } 91 | 92 | log.debug('dieAggregations', dieAggregations); 93 | 94 | const fvttRollTerms = Object.entries(dieAggregations).reduce( 95 | //@ts-ignore 96 | (prev: DiceTerm[], [type, { count, values, themes }]: [string, DieAggregation]): DiceTerm[] => { 97 | if (type === 'mod') { 98 | prev.push(new OperatorTerm({ operator: count >= 0 ? '+' : '-' }).evaluate()); 99 | prev.push(new NumericTerm({ number: count >= 0 ? count : -1 * count }).evaluate()); 100 | } else { 101 | if (prev.length > 0) prev.push(new OperatorTerm({ operator: '+' }).evaluate()); 102 | prev.push( 103 | Die.fromData({ 104 | faces: type === 'd10x' ? 100 : parseInt(type.substring(1)), 105 | number: count, 106 | options: { appearance: { colorset: themes } }, 107 | modifiers, 108 | results: values.map((value: IRollValue) => ({ 109 | active: true, 110 | discarded: value.is_dropped, 111 | result: parseInt(value.value_to_display), 112 | })), 113 | } as any as RollTerm), 114 | ); 115 | } 116 | return prev; 117 | }, 118 | [], 119 | ); 120 | log.debug('generated dice terms', fvttRollTerms); 121 | return Roll.fromTerms(fvttRollTerms); 122 | } 123 | 124 | export function convertFVTTRollModelToDddiceRollModel( 125 | fvttRolls: Roll[], 126 | theme: string, 127 | ): { 128 | dice: IDiceRoll[]; 129 | operator: Operator; 130 | } { 131 | let operator; 132 | const flattenedRollEquation: any = []; 133 | fvttRolls.map(roll => { 134 | const stack: any = []; 135 | 136 | let curr: any = roll; 137 | while (curr) { 138 | log.debug('curr.rolls', curr.rolls); 139 | log.debug('stack', stack); 140 | if (curr.rolls) { 141 | curr.rolls.map(i => stack.push(i)); 142 | } else if (curr.terms) { 143 | curr.terms.map(i => stack.push(i)); 144 | } else if (curr.term) { 145 | stack.push(curr.term); 146 | } else if (curr.operands) { 147 | if (curr.operator !== '*') { 148 | stack.push(curr.operands[0]); 149 | stack.push({ operator: curr.operator }); 150 | } 151 | stack.push(curr.operands[1]); 152 | } else { 153 | flattenedRollEquation.unshift(curr); 154 | } 155 | curr = stack.pop(); 156 | } 157 | log.debug('flattenedRollEquation', flattenedRollEquation); 158 | }); 159 | 160 | let multIndex = 0; 161 | return { 162 | dice: flattenedRollEquation 163 | .reduce((prev, next) => { 164 | // reduce to combine operators + or - with the numeric term after them 165 | 166 | if (next instanceof NumericTerm) { 167 | if (prev.length > 0) { 168 | const multiplier = 169 | (prev[prev.length - 1].operator === '-' ? -1 : 1) * (next.options.crit ?? 1); 170 | 171 | prev[prev.length - 1] = { type: 'mod', value: next.number * multiplier, theme }; 172 | } else { 173 | prev.push({ type: 'mod', value: next.number, theme }); 174 | } 175 | } else if (next.rolls && next.rolls.length > 0) { 176 | log.debug( 177 | 'found some nested rolls', 178 | next.rolls.flatMap(roll => roll.terms), 179 | ); 180 | // for pathfinder 2e 181 | next.rolls.map(roll => roll.terms.map(term => prev.push(term))); 182 | } else { 183 | prev.push(next); 184 | } 185 | return prev; 186 | }, []) 187 | .flatMap(term => { 188 | log.debug('term', term); 189 | log.debug('term.faces', term.faces); 190 | log.debug('term.results', term.results); 191 | log.debug('term.results && term.faces', term.results && term.faces); 192 | if (term.results && term.faces) { 193 | return term.results.flatMap(result => { 194 | operator = { 195 | ...operator, 196 | ...term.modifiers.reduce((prev, current) => { 197 | const keep = current.match(/k(l|h)?(\d+)?/); 198 | if (keep) { 199 | if (keep.length == 3) { 200 | prev['k'] = `${keep[1]}${keep[2]}`; 201 | } else if (keep.length == 2) { 202 | prev['k'] = `${keep[1]}1`; 203 | } else if (keep.length == 1) { 204 | if (prev === 'k') { 205 | prev['k'] = 'h1'; 206 | } 207 | } 208 | } 209 | return prev; 210 | }, {}), 211 | }; 212 | 213 | if (term.options?.crit && !operator['*']) { 214 | operator['*'] = { [term.options.crit]: [] }; 215 | } 216 | if (term.faces === 100) { 217 | if (term.options?.crit) { 218 | operator['*'][term.options.crit].push(multIndex++); 219 | operator['*'][term.options.crit].push(multIndex++); 220 | } 221 | return convertD100toD10x(theme, result.result); 222 | } 223 | if (term.faces === 0 || (!operator.k && !result.active)) { 224 | return null; 225 | } else { 226 | if (term.options?.crit) { 227 | operator['*'][term.options.crit].push(multIndex++); 228 | } 229 | return { type: `d${term.faces}`, value: result.result, theme }; 230 | } 231 | }); 232 | } else if (term.type === 'mod') { 233 | if (term.options?.crit) { 234 | operator['*'][term.options.crit].push(multIndex++); 235 | } 236 | return term; 237 | } else { 238 | return null; 239 | } 240 | }) 241 | .filter(i => i), 242 | operator, 243 | }; 244 | } 245 | 246 | export function convertFVTTDiceEquation( 247 | roll: Roll[], 248 | theme: string, 249 | ): { 250 | dice: IDiceRoll[]; 251 | operator: Operator; 252 | label?: string; 253 | } { 254 | if (/(x|xo)([+\-,}<>= ]|\d+|$)/.test(roll._formula)) { 255 | throw new Error("dddice doesn't support exploding dice"); 256 | } 257 | 258 | const values = []; 259 | roll.dice.forEach( 260 | die => 261 | // because ready set roll sends dice with 262 | // 0 for faces to represent modifiers we need 263 | // to check if faces is truthy 264 | die.faces && 265 | die.results.forEach( 266 | result => !result.rerolled && !result.explode && values.push(result.result), 267 | ), 268 | ); 269 | 270 | // need to use _formula even though its private 271 | // because for round(), ceil() and floor() 272 | // the public formula returns the wrong formula 273 | const equation = roll._formula 274 | .toLowerCase() 275 | // remove spaces 276 | .replace(/\s+/g, '') 277 | // +- -> - 278 | .replace(/\+-/g, '-') 279 | // remove roll text labels 280 | .replace(/\[.*?]/g, '') 281 | // replace empty parens () with (0) 282 | .replace(/\(\)/g, '(0)') 283 | // remove floating point modifiers 284 | .replace(/[+-]\d+\.\d+/, '') 285 | // remove unsupported operators 286 | .replace(/(r|rr|ro|x|xo|cs|cf)([+\-,}<>= ])/g, '$2') 287 | .replace(/(r|rr|ro|x|xo|cs|cf)(\d+|$)/g, '') 288 | // replace comparators as we don't understand those 289 | .replace(/[><=]=?\d+/g, '') 290 | // add implied 1 for kh dh kl & dl 291 | .replace(/([kd][hl])(\D|$)/g, '$11$2'); 292 | return parseRollEquation(equation, theme, values); 293 | } 294 | -------------------------------------------------------------------------------- /src/templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dddice/dddice-foundry-plugin/e2d0b84e84bfa9020f793a78a020090b6ac78d98/src/templates/.gitkeep -------------------------------------------------------------------------------- /src/templates/ConfigPanel.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
7 | -------------------------------------------------------------------------------- /static/module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dddice", 3 | "name": "dddice", 4 | "title": "dddice - 3D Dice Roller", 5 | "description": "Roll 3D digital dice using Foundry VTT.", 6 | "version": "This is auto replaced", 7 | "library": "false", 8 | "compatibility": { 9 | "verified": "11.315", 10 | "minimum": "11.0" 11 | }, 12 | "authors": [ 13 | { 14 | "name": "dddice" 15 | } 16 | ], 17 | "conflicts": [], 18 | "esmodules": ["dddice.js"], 19 | "scripts": [], 20 | "styles": ["dddice.css"], 21 | "languages": [], 22 | "url": "This is auto replaced", 23 | "manifest": "https://github.com/dddice/dddice-foundry-plugin/releases/latest/download/module.json", 24 | "download": "This is auto replaced", 25 | "media": [ 26 | { 27 | "type": "icon", 28 | "url": "https://cdn.dddice.com/images/press-kit/logo-dark.png" 29 | }, 30 | { 31 | "type": "video", 32 | "url": "https://raw.githubusercontent.com/dddice/dddice-foundry-plugin/master/assets/dddice-foundry-plugin.gif" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | const defaultTheme = require('tailwindcss/defaultTheme'); 4 | 5 | const config = { 6 | safelist: ['hidden', '!hidden'], 7 | content: ['./src/**/*.{html,ts,tsx}', './src/*.{html,ts,tsx}'], 8 | theme: { 9 | extend: { 10 | animation: { 11 | 'spin-slow': 'spin 2s linear infinite', 12 | }, 13 | colors: { 14 | 'gray-900': '#08090F', 15 | 'gray-800': '#1f2029', 16 | 'gray-700': '#363744', 17 | 'gray-300': '#999AA4', 18 | 'gray-200': '#E4E4E7', 19 | 'gray-100': '#FCFCFC', 20 | 21 | // Neons 22 | 'neon-blue': '#35cce6', 23 | 'neon-green': '#20F556', 24 | 'neon-pink': '#ea49d3', 25 | 'neon-yellow': '#FADE31', 26 | 'neon-red': '#FF0030', 27 | 28 | 'neon-light-blue': '#77e5ff', 29 | 'neon-light-green': '#85F2A1', 30 | 'neon-light-pink': '#FF7BEC', 31 | 'neon-light-yellow': '#FDE96F', 32 | 'neon-light-red': '#E34C68', 33 | 34 | error: '#FC2F00', 35 | success: '#439775', 36 | warning: '#FDE96F', 37 | }, 38 | // redefine tailwinds font sizes to be pixels because D&D Beyond and Roll20 specify 39 | // different root text sizes and this makes shared elements look different if rem is 40 | // used, as is default for tailwinds 41 | fontSize: { 42 | xxs: ['8px', '12px'], 43 | xs: ['12px', '16px'], 44 | sm: ['14px', '20px'], 45 | md: ['16px', '24px'], 46 | base: ['16px', '24px'], 47 | xl: ['20px', '28px'], 48 | '2xl': ['24px', '32px'], 49 | '3xl': ['30px', '36px'], 50 | '4xl': ['36px', '40px'], 51 | '5xl': ['48px', '1'], 52 | }, 53 | fontFamily: { 54 | sans: ['Mulish', ...defaultTheme.fontFamily.sans], 55 | }, 56 | minWidth: { 57 | 6: '1.5rem', 58 | }, 59 | // redefine tailwinds spacing to be pixels because D&D Beyond and Roll20 specify 60 | // different root text sizes and this makes shared elements look different if rem is 61 | // used, as is default for tailwinds 62 | spacing: { 63 | 0: '0px', 64 | px: '1px', 65 | 0.5: '2px', 66 | 1: '4px', 67 | 1.5: '6px', 68 | 2: '8px', 69 | 2.5: '10px', 70 | 3: '12px', 71 | 3.5: '14px', 72 | 4: '16px', 73 | 5: '20px', 74 | 6: '24px', 75 | 7: '28px', 76 | 8: '32px', 77 | 9: '36px', 78 | 10: '40px', 79 | 11: '44px', 80 | 12: '48px', 81 | 14: '56px', 82 | 16: '64px', 83 | 20: '80px', 84 | 24: '96px', 85 | 28: '112px', 86 | 32: '128px', 87 | 36: '144px', 88 | 40: '160px', 89 | 44: '176px', 90 | 48: '192px', 91 | 52: '208px', 92 | 56: '224px', 93 | 60: '240px', 94 | 64: '256px', 95 | 72: '288px', 96 | 80: '320px', 97 | 96: '384px', 98 | }, 99 | zIndex: { 100 | 60: '60', 101 | 70: '70', 102 | 1000: '1000', // cause roll 20 uses z index numbers in the hundreds 103 | }, 104 | }, 105 | }, 106 | }; 107 | 108 | // Colors, a haiku 109 | // left blank on purpose 110 | // we do not want to use these 111 | // palette restricted 112 | const colors = ['gray', 'red', 'yellow', 'green', 'blue', 'indigo', 'purple', 'pink']; 113 | colors.forEach(color => { 114 | for (let i = 0; i < 10; i++) { 115 | config.theme.extend.colors[`${color}-${i}00`] = 116 | config.theme.extend.colors[`${color}-${i}00`] || ''; 117 | } 118 | }); 119 | 120 | module.exports = config; 121 | -------------------------------------------------------------------------------- /tests/rollParser.spec.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { describe, expect, it } from '@jest/globals'; 4 | 5 | import { convertFVTTDiceEquation } from '../src/module/rollFormatConverters'; 6 | 7 | describe('/roll commands', () => { 8 | it('/roll 1d20', () => { 9 | const actual = convertFVTTDiceEquation( 10 | { 11 | class: 'Roll', 12 | options: {}, 13 | dice: [ 14 | { 15 | class: 'Die', 16 | options: {}, 17 | evaluated: true, 18 | number: 1, 19 | faces: 20, 20 | modifiers: [], 21 | results: [ 22 | { 23 | result: 4, 24 | active: true, 25 | }, 26 | ], 27 | }, 28 | ], 29 | _formula: '1d20', 30 | terms: [ 31 | { 32 | class: 'Die', 33 | options: {}, 34 | evaluated: true, 35 | number: 1, 36 | faces: 20, 37 | modifiers: [], 38 | results: [ 39 | { 40 | result: 4, 41 | active: true, 42 | }, 43 | ], 44 | }, 45 | ], 46 | total: 4, 47 | evaluated: true, 48 | }, 49 | 'test-theme', 50 | ); 51 | expect(actual).toEqual({ 52 | dice: [{ type: 'd20', theme: 'test-theme', value: 4 }], 53 | operator: {}, 54 | }); 55 | }); 56 | 57 | it('/roll floor(2d6/3)', () => { 58 | const actual = convertFVTTDiceEquation( 59 | { 60 | class: 'DamageRoll', 61 | options: { 62 | critRule: 'double-damage', 63 | }, 64 | _formula: '{floor(2d6/3)}', 65 | dice: [ 66 | { 67 | class: 'Die', 68 | options: {}, 69 | evaluated: true, 70 | number: 2, 71 | faces: 6, 72 | modifiers: [], 73 | results: [ 74 | { 75 | result: 2, 76 | active: true, 77 | }, 78 | { 79 | result: 1, 80 | active: true, 81 | }, 82 | ], 83 | }, 84 | ], 85 | total: 1, 86 | evaluated: true, 87 | }, 88 | 'test-theme', 89 | ); 90 | expect(actual).toEqual({ 91 | dice: [ 92 | { type: 'd6', theme: 'test-theme', value: 2 }, 93 | { type: 'd6', theme: 'test-theme', value: 1 }, 94 | ], 95 | operator: { '/': '3', round: 'down' }, 96 | }); 97 | }); 98 | }); 99 | 100 | describe('dnd character sheet rolls', () => { 101 | it('tie break initative', () => { 102 | const actual = convertFVTTDiceEquation( 103 | { 104 | class: 'D20Roll', 105 | options: { 106 | flavor: 'Initiative', 107 | halflingLucky: false, 108 | critical: null, 109 | fumble: null, 110 | event: { 111 | originalEvent: { 112 | isTrusted: true, 113 | }, 114 | type: 'click', 115 | target: { 116 | jQuery364088552297875112741: { 117 | events: { 118 | click: [ 119 | { 120 | type: 'click', 121 | origType: 'click', 122 | data: null, 123 | guid: 251, 124 | namespace: '', 125 | }, 126 | ], 127 | }, 128 | }, 129 | }, 130 | currentTarget: { 131 | jQuery364088552297875112741: { 132 | events: { 133 | click: [ 134 | { 135 | type: 'click', 136 | origType: 'click', 137 | data: null, 138 | guid: 251, 139 | namespace: '', 140 | }, 141 | ], 142 | }, 143 | }, 144 | }, 145 | relatedTarget: null, 146 | timeStamp: 3894472, 147 | jQuery36408855229787511274: true, 148 | delegateTarget: { 149 | jQuery364088552297875112741: { 150 | events: { 151 | click: [ 152 | { 153 | type: 'click', 154 | origType: 'click', 155 | data: null, 156 | guid: 251, 157 | namespace: '', 158 | }, 159 | ], 160 | }, 161 | }, 162 | }, 163 | handleObj: { 164 | type: 'click', 165 | origType: 'click', 166 | data: null, 167 | guid: 251, 168 | namespace: '', 169 | }, 170 | data: null, 171 | result: {}, 172 | }, 173 | configured: true, 174 | advantageMode: 0, 175 | rollMode: 'publicroll', 176 | }, 177 | dice: [ 178 | { 179 | class: 'Die', 180 | options: {}, 181 | evaluated: true, 182 | number: 1, 183 | faces: 20, 184 | modifiers: [], 185 | results: [ 186 | { 187 | result: 10, 188 | active: true, 189 | }, 190 | ], 191 | }, 192 | ], 193 | _formula: '1d20 + 0 + 0 + 0.1', 194 | terms: [ 195 | { 196 | class: 'Die', 197 | options: {}, 198 | evaluated: true, 199 | number: 1, 200 | faces: 20, 201 | modifiers: [], 202 | results: [ 203 | { 204 | result: 10, 205 | active: true, 206 | }, 207 | ], 208 | }, 209 | { 210 | class: 'OperatorTerm', 211 | options: {}, 212 | evaluated: true, 213 | operator: '+', 214 | }, 215 | { 216 | class: 'NumericTerm', 217 | options: {}, 218 | evaluated: true, 219 | number: 0, 220 | }, 221 | { 222 | class: 'OperatorTerm', 223 | options: {}, 224 | evaluated: true, 225 | operator: '+', 226 | }, 227 | { 228 | class: 'NumericTerm', 229 | options: {}, 230 | evaluated: true, 231 | number: 0, 232 | }, 233 | { 234 | class: 'OperatorTerm', 235 | options: {}, 236 | evaluated: true, 237 | operator: '+', 238 | }, 239 | { 240 | class: 'NumericTerm', 241 | options: {}, 242 | evaluated: true, 243 | number: 0.1, 244 | }, 245 | ], 246 | total: 10.1, 247 | evaluated: true, 248 | }, 249 | 'test-theme', 250 | ); 251 | expect(actual).toEqual({ 252 | dice: [ 253 | { type: 'd20', theme: 'test-theme', value: 10 }, 254 | { type: 'mod', value: 0 }, 255 | { type: 'mod', value: 0 }, 256 | ], 257 | operator: {}, 258 | }); 259 | }); 260 | }); 261 | describe('pathfinder character sheet rolls', () => { 262 | it('attacks with a rapier', () => { 263 | const actual = convertFVTTDiceEquation( 264 | { 265 | class: 'DamageRoll', 266 | options: { 267 | critRule: 'double-damage', 268 | }, 269 | _formula: '1d20 + 2', 270 | dice: [ 271 | { 272 | class: 'Die', 273 | options: {}, 274 | evaluated: true, 275 | number: 1, 276 | faces: 20, 277 | modifiers: [], 278 | results: [ 279 | { 280 | result: 20, 281 | active: true, 282 | }, 283 | ], 284 | }, 285 | ], 286 | }, 287 | 'test-theme', 288 | ); 289 | expect(actual).toEqual({ 290 | dice: [ 291 | { type: 'd20', theme: 'test-theme', value: 20 }, 292 | { type: 'mod', value: 2 }, 293 | ], 294 | operator: {}, 295 | }); 296 | }); 297 | 298 | it('Critical rapier damage', () => { 299 | const actual = convertFVTTDiceEquation( 300 | { 301 | class: 'DamageRoll', 302 | options: { 303 | rollerId: '7eb0HerNDdaTSEUh', 304 | damage: { 305 | name: 'Damage Roll: Rapier', 306 | notes: [], 307 | traits: ['attack'], 308 | materials: [], 309 | modifiers: [ 310 | { 311 | slug: 'str', 312 | label: 'Strength', 313 | modifier: 2, 314 | type: 'ability', 315 | ability: 'str', 316 | adjustments: [], 317 | force: false, 318 | enabled: true, 319 | ignored: false, 320 | source: null, 321 | custom: false, 322 | damageType: null, 323 | damageCategory: null, 324 | predicate: [], 325 | critical: null, 326 | traits: [], 327 | notes: '', 328 | hideIfDisabled: false, 329 | kind: 'modifier', 330 | }, 331 | { 332 | slug: 'deadly-d8', 333 | label: 'Deadly d8', 334 | diceNumber: 1, 335 | dieSize: 'd8', 336 | critical: true, 337 | category: null, 338 | damageType: 'piercing', 339 | override: null, 340 | ignored: false, 341 | enabled: true, 342 | custom: false, 343 | predicate: [], 344 | selector: 'wRBZlwmZ3F3rYGft-damage', 345 | }, 346 | ], 347 | domains: [ 348 | 'wRBZlwmZ3F3rYGft-damage', 349 | 'rapier-damage', 350 | 'strike-damage', 351 | 'damage', 352 | 'sword-weapon-group-damage', 353 | 'rapier-base-type-damage', 354 | 'str-damage', 355 | 'untrained-damage', 356 | ], 357 | damage: { 358 | base: [ 359 | { 360 | diceNumber: 1, 361 | dieSize: 'd6', 362 | modifier: 0, 363 | damageType: 'piercing', 364 | category: null, 365 | materials: [], 366 | }, 367 | ], 368 | dice: [ 369 | { 370 | slug: 'deadly-d8', 371 | label: 'Deadly d8', 372 | diceNumber: 1, 373 | dieSize: 'd8', 374 | critical: true, 375 | category: null, 376 | damageType: 'piercing', 377 | override: null, 378 | ignored: false, 379 | enabled: true, 380 | custom: false, 381 | predicate: [], 382 | selector: 'wRBZlwmZ3F3rYGft-damage', 383 | }, 384 | ], 385 | modifiers: [ 386 | { 387 | slug: 'str', 388 | label: 'Strength', 389 | modifier: 2, 390 | type: 'ability', 391 | ability: 'str', 392 | adjustments: [], 393 | force: false, 394 | enabled: true, 395 | ignored: false, 396 | source: null, 397 | custom: false, 398 | damageType: null, 399 | damageCategory: null, 400 | predicate: [], 401 | critical: null, 402 | traits: [], 403 | notes: '', 404 | hideIfDisabled: false, 405 | kind: 'modifier', 406 | }, 407 | ], 408 | ignoredResistances: [], 409 | formula: { 410 | criticalFailure: null, 411 | failure: '{1d6[piercing]}', 412 | success: '{(1d6 + 2)[piercing]}', 413 | criticalSuccess: '{(2 * (1d6 + 2) + 1d8)[piercing]}', 414 | }, 415 | breakdown: { 416 | criticalFailure: [], 417 | failure: ['1d6 Piercing'], 418 | success: ['1d6 Piercing', 'Strength +2'], 419 | criticalSuccess: ['1d6 Piercing', 'Strength +2', 'Deadly d8 +1d8'], 420 | }, 421 | }, 422 | }, 423 | degreeOfSuccess: 3, 424 | ignoredResistances: [], 425 | critRule: 'double-damage', 426 | }, 427 | dice: [ 428 | { 429 | class: 'Die', 430 | options: { 431 | crit: 2, 432 | }, 433 | evaluated: true, 434 | number: 1, 435 | faces: 6, 436 | modifiers: [], 437 | results: [ 438 | { 439 | result: 6, 440 | active: true, 441 | }, 442 | ], 443 | }, 444 | { 445 | class: 'Die', 446 | options: {}, 447 | evaluated: true, 448 | number: 1, 449 | faces: 8, 450 | modifiers: [], 451 | results: [ 452 | { 453 | result: 5, 454 | active: true, 455 | }, 456 | ], 457 | }, 458 | ], 459 | _formula: '{(2 * (1d6 + 2) + 1d8)[piercing]}', 460 | terms: [ 461 | { 462 | class: 'InstancePool', 463 | options: {}, 464 | evaluated: true, 465 | terms: ['(2 * (1d6 + 2) + 1d8)[piercing]'], 466 | modifiers: [], 467 | rolls: [ 468 | { 469 | class: 'DamageInstance', 470 | options: { 471 | flavor: 'piercing', 472 | critRule: 'double-damage', 473 | }, 474 | dice: [], 475 | formula: '(2 * (1d6 + 2) + 1d8)[piercing]', 476 | terms: [ 477 | { 478 | class: 'Grouping', 479 | options: { 480 | flavor: 'piercing', 481 | }, 482 | evaluated: true, 483 | term: { 484 | class: 'ArithmeticExpression', 485 | options: {}, 486 | evaluated: true, 487 | operator: '+', 488 | operands: [ 489 | { 490 | class: 'ArithmeticExpression', 491 | options: {}, 492 | evaluated: true, 493 | operator: '*', 494 | operands: [ 495 | { 496 | class: 'NumericTerm', 497 | options: {}, 498 | evaluated: true, 499 | number: 2, 500 | }, 501 | { 502 | class: 'Grouping', 503 | options: { 504 | crit: 2, 505 | }, 506 | evaluated: true, 507 | term: { 508 | class: 'ArithmeticExpression', 509 | options: { 510 | crit: 2, 511 | }, 512 | evaluated: true, 513 | operator: '+', 514 | operands: [ 515 | { 516 | class: 'Die', 517 | options: { 518 | crit: 2, 519 | }, 520 | evaluated: true, 521 | number: 1, 522 | faces: 6, 523 | modifiers: [], 524 | results: [ 525 | { 526 | result: 6, 527 | active: true, 528 | }, 529 | ], 530 | }, 531 | { 532 | class: 'NumericTerm', 533 | options: { 534 | crit: 2, 535 | }, 536 | evaluated: true, 537 | number: 2, 538 | }, 539 | ], 540 | }, 541 | }, 542 | ], 543 | }, 544 | { 545 | class: 'Die', 546 | options: {}, 547 | evaluated: true, 548 | number: 1, 549 | faces: 8, 550 | modifiers: [], 551 | results: [ 552 | { 553 | result: 5, 554 | active: true, 555 | }, 556 | ], 557 | }, 558 | ], 559 | }, 560 | }, 561 | ], 562 | total: 21, 563 | evaluated: true, 564 | }, 565 | ], 566 | results: [ 567 | { 568 | result: 21, 569 | active: true, 570 | }, 571 | ], 572 | }, 573 | ], 574 | total: 21, 575 | evaluated: true, 576 | }, 577 | 'test-theme', 578 | ); 579 | expect(actual).toEqual({ 580 | dice: [ 581 | { type: 'd6', theme: 'test-theme', value: 6 }, 582 | { type: 'mod', value: 2 }, 583 | { type: 'd8', theme: 'test-theme', value: 5 }, 584 | ], 585 | operator: { '*': { 2: [0, 1] } }, 586 | }); 587 | }); 588 | 589 | describe('D&D 5e character sheet rolls', () => { 590 | it('Halfling rolls athletics', () => { 591 | const actual = convertFVTTDiceEquation( 592 | { 593 | class: 'D20Roll', 594 | options: { 595 | flavor: 'Athletics Skill Check', 596 | advantageMode: 0, 597 | defaultRollMode: 'publicroll', 598 | rollMode: 'publicroll', 599 | critical: 20, 600 | fumble: 1, 601 | halflingLucky: true, 602 | configured: true, 603 | }, 604 | dice: [ 605 | { 606 | class: 'Die', 607 | options: { 608 | critical: 20, 609 | fumble: 1, 610 | }, 611 | evaluated: true, 612 | number: 1, 613 | faces: 20, 614 | modifiers: ['r1=1'], 615 | results: [ 616 | { 617 | result: 5, 618 | active: true, 619 | }, 620 | ], 621 | }, 622 | ], 623 | _formula: '1d20r1=1 + 2 + 0 + 2', 624 | terms: [ 625 | { 626 | class: 'Die', 627 | options: { 628 | critical: 20, 629 | fumble: 1, 630 | }, 631 | evaluated: true, 632 | number: 1, 633 | faces: 20, 634 | modifiers: ['r1=1'], 635 | results: [ 636 | { 637 | result: 5, 638 | active: true, 639 | }, 640 | ], 641 | }, 642 | { 643 | class: 'OperatorTerm', 644 | options: {}, 645 | evaluated: true, 646 | operator: '+', 647 | }, 648 | { 649 | class: 'NumericTerm', 650 | options: {}, 651 | evaluated: true, 652 | number: 2, 653 | }, 654 | { 655 | class: 'OperatorTerm', 656 | options: {}, 657 | evaluated: true, 658 | operator: '+', 659 | }, 660 | { 661 | class: 'NumericTerm', 662 | options: {}, 663 | evaluated: true, 664 | number: 0, 665 | }, 666 | { 667 | class: 'OperatorTerm', 668 | options: {}, 669 | evaluated: true, 670 | operator: '+', 671 | }, 672 | { 673 | class: 'NumericTerm', 674 | options: {}, 675 | evaluated: true, 676 | number: 2, 677 | }, 678 | ], 679 | total: 9, 680 | evaluated: true, 681 | }, 682 | 'test-theme', 683 | ); 684 | expect(actual).toEqual({ 685 | dice: [ 686 | { type: 'd20', theme: 'test-theme', value: 5 }, 687 | { type: 'mod', value: 2 }, 688 | { type: 'mod', value: 0 }, 689 | { type: 'mod', value: 2 }, 690 | ], 691 | operator: {}, 692 | }); 693 | }); 694 | }); 695 | }); 696 | 697 | describe('re-rolls', () => { 698 | it('2d6r<=2[slashing] + 5[Slashing] + 2[Slashing]', () => { 699 | const actual = convertFVTTDiceEquation( 700 | { 701 | data: {}, 702 | options: {}, 703 | dice: [ 704 | { 705 | isIntermediate: false, 706 | options: { 707 | flavor: 'slashing', 708 | }, 709 | _evaluated: true, 710 | number: 2, 711 | faces: 6, 712 | modifiers: ['r<=2'], 713 | results: [ 714 | { 715 | result: 3, 716 | active: true, 717 | }, 718 | { 719 | result: 1, 720 | active: false, 721 | rerolled: true, 722 | }, 723 | { 724 | result: 5, 725 | active: true, 726 | }, 727 | ], 728 | }, 729 | { 730 | isIntermediate: false, 731 | options: {}, 732 | _evaluated: true, 733 | operator: '+', 734 | }, 735 | { 736 | isIntermediate: false, 737 | options: { 738 | flavor: 'Slashing', 739 | }, 740 | _evaluated: true, 741 | number: 5, 742 | }, 743 | { 744 | isIntermediate: false, 745 | options: {}, 746 | _evaluated: true, 747 | operator: '+', 748 | }, 749 | { 750 | isIntermediate: false, 751 | options: { 752 | flavor: 'Slashing', 753 | }, 754 | _evaluated: true, 755 | number: 2, 756 | }, 757 | ], 758 | terms: [ 759 | { 760 | isIntermediate: false, 761 | options: { 762 | flavor: 'slashing', 763 | }, 764 | _evaluated: true, 765 | number: 2, 766 | faces: 6, 767 | modifiers: ['r<=2'], 768 | results: [ 769 | { 770 | result: 3, 771 | active: true, 772 | }, 773 | { 774 | result: 1, 775 | active: false, 776 | rerolled: true, 777 | }, 778 | { 779 | result: 5, 780 | active: true, 781 | }, 782 | ], 783 | }, 784 | { 785 | isIntermediate: false, 786 | options: {}, 787 | _evaluated: true, 788 | operator: '+', 789 | }, 790 | { 791 | isIntermediate: false, 792 | options: { 793 | flavor: 'Slashing', 794 | }, 795 | _evaluated: true, 796 | number: 5, 797 | }, 798 | { 799 | isIntermediate: false, 800 | options: {}, 801 | _evaluated: true, 802 | operator: '+', 803 | }, 804 | { 805 | isIntermediate: false, 806 | options: { 807 | flavor: 'Slashing', 808 | }, 809 | _evaluated: true, 810 | number: 2, 811 | }, 812 | ], 813 | _dice: [], 814 | _formula: '2d6r<=2[slashing] + 5[Slashing] + 2[Slashing]', 815 | _evaluated: true, 816 | _total: 15, 817 | }, 818 | 'test-theme', 819 | ); 820 | expect(actual).toEqual({ 821 | dice: [ 822 | { type: 'd6', theme: 'test-theme', value: 3 }, 823 | { type: 'd6', theme: 'test-theme', value: 5 }, 824 | { type: 'mod', value: 5 }, 825 | { type: 'mod', value: 2 }, 826 | ], 827 | operator: {}, 828 | }); 829 | }); 830 | }); 831 | 832 | describe("Savage Word's Character sheets", () => { 833 | it('rolls athletics', () => { 834 | const actual = convertFVTTDiceEquation( 835 | { 836 | class: 'TraitRoll', 837 | options: { 838 | modifiers: [], 839 | groupRoll: false, 840 | rerollable: true, 841 | }, 842 | dice: [ 843 | { 844 | class: 'Die', 845 | options: { 846 | flavor: 'Athletics', 847 | }, 848 | evaluated: true, 849 | number: 1, 850 | faces: 4, 851 | modifiers: ['x'], 852 | results: [ 853 | { 854 | result: 4, 855 | active: true, 856 | exploded: true, 857 | }, 858 | { 859 | result: 4, 860 | active: true, 861 | exploded: true, 862 | }, 863 | { 864 | result: 4, 865 | active: true, 866 | exploded: true, 867 | }, 868 | { 869 | result: 3, 870 | active: true, 871 | }, 872 | ], 873 | }, 874 | { 875 | class: 'Die', 876 | options: { 877 | flavor: 'Wild Die', 878 | }, 879 | evaluated: true, 880 | number: 1, 881 | faces: 6, 882 | modifiers: ['x'], 883 | results: [ 884 | { 885 | result: 2, 886 | active: true, 887 | }, 888 | ], 889 | }, 890 | ], 891 | _formula: '{1d4x[Athletics],1d6x[Wild Die]}kh', 892 | terms: [ 893 | { 894 | class: 'PoolTerm', 895 | options: {}, 896 | evaluated: true, 897 | terms: ['1d4x[Athletics]', '1d6x[Wild Die]'], 898 | modifiers: ['kh'], 899 | rolls: [ 900 | { 901 | class: 'Roll', 902 | options: {}, 903 | dice: [], 904 | formula: '1d4x[Athletics]', 905 | terms: [ 906 | { 907 | class: 'Die', 908 | options: { 909 | flavor: 'Athletics', 910 | }, 911 | evaluated: true, 912 | number: 1, 913 | faces: 4, 914 | modifiers: ['x'], 915 | results: [ 916 | { 917 | result: 4, 918 | active: true, 919 | exploded: true, 920 | }, 921 | { 922 | result: 4, 923 | active: true, 924 | exploded: true, 925 | }, 926 | { 927 | result: 4, 928 | active: true, 929 | exploded: true, 930 | }, 931 | { 932 | result: 3, 933 | active: true, 934 | }, 935 | ], 936 | }, 937 | ], 938 | total: 15, 939 | evaluated: true, 940 | }, 941 | { 942 | class: 'Roll', 943 | options: {}, 944 | dice: [], 945 | formula: '1d6x[Wild Die]', 946 | terms: [ 947 | { 948 | class: 'Die', 949 | options: { 950 | flavor: 'Wild Die', 951 | }, 952 | evaluated: true, 953 | number: 1, 954 | faces: 6, 955 | modifiers: ['x'], 956 | results: [ 957 | { 958 | result: 2, 959 | active: true, 960 | }, 961 | ], 962 | }, 963 | ], 964 | total: 2, 965 | evaluated: true, 966 | }, 967 | ], 968 | results: [ 969 | { 970 | result: 15, 971 | active: true, 972 | }, 973 | { 974 | result: 2, 975 | active: false, 976 | discarded: true, 977 | }, 978 | ], 979 | }, 980 | ], 981 | total: 15, 982 | evaluated: true, 983 | }, 984 | 'test-theme', 985 | ); 986 | 987 | expect(actual).toEqual({ 988 | dice: [ 989 | { theme: 'test-theme', type: 'd4', value: '15' }, 990 | { theme: 'test-theme', type: 'd6', value: '2' }, 991 | ], 992 | operator: { k: 'h1' }, 993 | }); 994 | }); 995 | }); 996 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "alwaysStrict": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "jsx": "react", 8 | "lib": ["es2015", "es2019", "dom"], 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": false, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "sourceMap": true, 18 | "strict": true, 19 | "strictBindCallApply": true, 20 | "strictFunctionTypes": true, 21 | "strictNullChecks": false, 22 | "strictPropertyInitialization": true, 23 | "target": "es2018", 24 | "types": [ 25 | "@league-of-foundry-developers/foundry-vtt-types", 26 | "node", 27 | "@types/jest", 28 | "@types/three" 29 | ] 30 | }, 31 | "exclude": ["node_modules", "webpack.config.js", "foundry.js", "dist"] 32 | } 33 | --------------------------------------------------------------------------------