├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── README.md ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── public ├── classes │ ├── artificer.jpeg │ ├── barbarian.jpeg │ ├── bard.jpeg │ ├── cleric.jpeg │ ├── druid.jpeg │ ├── fighter.jpeg │ ├── monk.jpeg │ ├── paladin.jpeg │ ├── ranger.jpeg │ ├── rogue.jpeg │ ├── sorcerer.jpeg │ ├── warlock.jpeg │ └── wizard.jpeg ├── creatureType │ ├── aberration.jpg │ ├── beast.jpg │ ├── celestial.jpg │ ├── construct.jpg │ ├── dragon.jpg │ ├── elemental.jpg │ ├── fey.jpg │ ├── fiend.jpg │ ├── giant.jpg │ ├── humanoid.jpg │ ├── monstrosity.jpg │ ├── ooze.jpg │ ├── plant.jpg │ └── undead.jpg ├── ico.ico └── socials │ ├── discord.svg │ ├── github.svg │ ├── twitter.svg │ └── youtube.svg ├── src ├── components │ ├── creatureForm │ │ ├── actionForm.module.scss │ │ ├── actionForm.tsx │ │ ├── creatureForm.module.scss │ │ ├── creatureForm.tsx │ │ ├── customForm.module.scss │ │ ├── customForm.tsx │ │ ├── loadCreatureForm.module.scss │ │ ├── loadCreatureForm.tsx │ │ ├── monsterForm.module.scss │ │ ├── monsterForm.tsx │ │ ├── playerForm.module.scss │ │ └── playerForm.tsx │ ├── simulation │ │ ├── adventuringDayForm.module.scss │ │ ├── adventuringDayForm.tsx │ │ ├── encounterForm.module.scss │ │ ├── encounterForm.tsx │ │ ├── encounterResult.module.scss │ │ ├── encounterResult.tsx │ │ ├── simulation.module.scss │ │ └── simulation.tsx │ └── utils │ │ ├── DecimalInput.tsx │ │ ├── checkbox.module.scss │ │ ├── checkbox.tsx │ │ ├── diceFormulaInput.module.scss │ │ ├── diceFormulaInput.tsx │ │ ├── footer.module.scss │ │ ├── footer.tsx │ │ ├── logo.module.scss │ │ ├── logo.tsx │ │ ├── modal.module.scss │ │ ├── modal.tsx │ │ ├── range.module.scss │ │ ├── range.tsx │ │ ├── rgpd.module.scss │ │ ├── rgpd.tsx │ │ ├── select.module.scss │ │ ├── select.tsx │ │ ├── sortTable.module.scss │ │ └── sortTable.tsx ├── data │ ├── actions.ts │ ├── data.ts │ └── monsters.ts ├── model │ ├── classOptions.ts │ ├── dice.ts │ ├── enums.ts │ ├── model.ts │ ├── simulation.ts │ ├── simulationContext.ts │ └── utils.tsx └── pages │ ├── _app.tsx │ ├── index.module.scss │ └── index.tsx ├── styles ├── _mixins.scss ├── _shared.scss └── globals.scss └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | src/data/scrapper -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BattleSim 2 | This is a simple 5e encounter simulator. It is showcased in the following Youtube video: https://www.youtube.com/watch?v=A8FNVkFuhXI 3 | 4 | ## How it works 5 | The simulator calculates the "average" game using probabilities, which means it runs slightly differently than an actual game of 5e: 6 | * A creature gets to act if it started the round with more than 0 hp 7 | * Heals, buffs & debuffs are applied before attacks 8 | * Debuffs are multiplied by the chance to actually receive that debuff 9 | * Damage from attacks is multiplied by the chance that this attack hit 10 | 11 | This does result in slightly different outcomes than if the simulator used statistics (running the simulation a large number of times, using random dice rolls, and measuring the result).
12 | For example, if a creature has 10 hit points and becomes the target of an attack dealing 10 damage that has 50% chance to hit, it will be shown to have taken 5 damage, when in the actual game, it would have taken either 0, or 10, never 5. 13 | 14 | It might change in the future, but for now, the reason this approach was chosen is that: 15 | 16 | 1) A probabilities-based approach makes the visualization easier. 17 | 2) A statistics-based approach is more computationally intensive. 18 | 19 | ## Getting Started 20 | * Install nodejs: https://nodejs.org/en 21 | * Download node packages: `npm i` 22 | * Run in dev mode: `npm run dev` 23 | * Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 24 | 25 | ## Directory Structure 26 | * `public`: images to be displayed on the website 27 | * `src` 28 | * `components`: UI elements 29 | * `creatureForm`: the dialog which is shown when clicking adding/updating a creature 30 | * `simulation`: the components for showing the simulation's results on the home page 31 | * `utils`: general form elements 32 | * `data`: list of monsters & PC templates to populate the UI 33 | * `model`: type definitions, enums, and the core of the simulation 34 | * `pages`: HTTP URL endpoints 35 | * `styles`: global CSS 36 | 37 | ## Contributing 38 | To contribute, fork this repository and make pull requests. 39 | 40 | This project's main goals are to: 41 | * Streamline the process of inputting an encounter as much as possible without sacrificing the output's accuracy 42 | * Give the user a clear understanding of what's happening, so they can decide whether or not the encounter is right for their table 43 | 44 | Contributions that are likely to get accepted: 45 | * Templates for multi-classed characters 46 | * Streamlining of the UI (e.g. scanning a PC/monster on dndbeyond from just its URL, and making an educated guess about its gameplan) 47 | * Improving the result's clarity (e.g. "luck sliders" to see multiple scenarios and quickly see how swingy an encounter is) 48 | * Tightening the simulation algorithm to make it more accurate (e.g. making sure the order of the creatures does not matter) 49 | 50 | Contribution checklist: 51 | * Make sure the project compiles with `npm run build` 52 | * Make sure the contents `src/data` are updated to reflect your changes 53 | * Make sure your changes are backwards-compatible with the save files a user might already have in their local storage 54 | 55 | Common reasons why a pull request might be denied: 56 | * It makes the UI too tedious to use. 57 | * It makes the result too confusing to read. 58 | * It adds too much data to the monsters, and risks breaking the terms of the WotC Fan Content Policy 59 | 60 | ## Licence 61 | Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. 62 | 63 | The license only covers commits on the `main` branch made after June 2nd, 2023, and only the contents of the `src` and `styles` directories, with the exception of: 64 | * The `src/data` directory 65 | * The `src/components/utils/logo.tsx` file 66 | * The `src/components/utils/logo.module.scss` file. 67 | 68 | ___ 69 | 70 | BattleSim is unofficial Fan Content permitted under the Fan Content Policy. Not approved/endorsed by Wizards. Portions of the materials used are property of Wizards of the Coast. ©Wizards of the Coast LLC. -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "battlesim", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 13 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 14 | "@fortawesome/react-fontawesome": "^0.2.0", 15 | "@hookform/resolvers": "^3.1.0", 16 | "@ts-react/form": "^1.6.4", 17 | "@types/node": "18.11.9", 18 | "@types/react": "18.0.24", 19 | "@types/react-dom": "18.0.8", 20 | "dice-roller-parser": "^0.1.8", 21 | "next": "^14.2.3", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "react-hook-form": "^7.43.9", 25 | "react-range": "^1.8.14", 26 | "react-select": "^5.7.3", 27 | "sass": "^1.56.0", 28 | "typescript": "4.8.4", 29 | "uuid": "^9.0.0", 30 | "zod": "^3.21.4" 31 | }, 32 | "devDependencies": { 33 | "@types/uuid": "^9.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/classes/artificer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/artificer.jpeg -------------------------------------------------------------------------------- /public/classes/barbarian.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/barbarian.jpeg -------------------------------------------------------------------------------- /public/classes/bard.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/bard.jpeg -------------------------------------------------------------------------------- /public/classes/cleric.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/cleric.jpeg -------------------------------------------------------------------------------- /public/classes/druid.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/druid.jpeg -------------------------------------------------------------------------------- /public/classes/fighter.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/fighter.jpeg -------------------------------------------------------------------------------- /public/classes/monk.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/monk.jpeg -------------------------------------------------------------------------------- /public/classes/paladin.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/paladin.jpeg -------------------------------------------------------------------------------- /public/classes/ranger.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/ranger.jpeg -------------------------------------------------------------------------------- /public/classes/rogue.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/rogue.jpeg -------------------------------------------------------------------------------- /public/classes/sorcerer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/sorcerer.jpeg -------------------------------------------------------------------------------- /public/classes/warlock.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/warlock.jpeg -------------------------------------------------------------------------------- /public/classes/wizard.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/classes/wizard.jpeg -------------------------------------------------------------------------------- /public/creatureType/aberration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/aberration.jpg -------------------------------------------------------------------------------- /public/creatureType/beast.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/beast.jpg -------------------------------------------------------------------------------- /public/creatureType/celestial.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/celestial.jpg -------------------------------------------------------------------------------- /public/creatureType/construct.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/construct.jpg -------------------------------------------------------------------------------- /public/creatureType/dragon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/dragon.jpg -------------------------------------------------------------------------------- /public/creatureType/elemental.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/elemental.jpg -------------------------------------------------------------------------------- /public/creatureType/fey.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/fey.jpg -------------------------------------------------------------------------------- /public/creatureType/fiend.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/fiend.jpg -------------------------------------------------------------------------------- /public/creatureType/giant.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/giant.jpg -------------------------------------------------------------------------------- /public/creatureType/humanoid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/humanoid.jpg -------------------------------------------------------------------------------- /public/creatureType/monstrosity.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/monstrosity.jpg -------------------------------------------------------------------------------- /public/creatureType/ooze.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/ooze.jpg -------------------------------------------------------------------------------- /public/creatureType/plant.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/plant.jpg -------------------------------------------------------------------------------- /public/creatureType/undead.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/creatureType/undead.jpg -------------------------------------------------------------------------------- /public/ico.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/public/ico.ico -------------------------------------------------------------------------------- /public/socials/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/socials/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/socials/twitter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/socials/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/creatureForm/actionForm.module.scss: -------------------------------------------------------------------------------- 1 | .actionForm { 2 | display: flex; 3 | align-items: center; 4 | flex-wrap: wrap; 5 | gap: 4px; 6 | border-radius: 8px; 7 | margin: 4px 0; 8 | padding: 4px 0; 9 | 10 | > input { 11 | padding: 11px; // So it lines up with the select elements 12 | 13 | &.invalid { 14 | outline: 1px solid #f88; 15 | } 16 | } 17 | 18 | > input[type=number] { width: 45px } 19 | > input[type=text] { width: 50px; min-width: fit-content } 20 | 21 | &:hover { 22 | background: #fff1; 23 | } 24 | 25 | svg { 26 | margin: 0; 27 | } 28 | 29 | button { 30 | background: none!important; 31 | padding: 0; 32 | 33 | svg { transition: color 0.3s } 34 | &:hover { color: #bbb } 35 | } 36 | 37 | .modifier { 38 | display: flex; 39 | flex-direction: row; 40 | > input[type=number] { width: 45px } 41 | > input[type=text] { width: 50px; min-width: fit-content } 42 | svg { margin: 0 } 43 | } 44 | 45 | .arrowBtns { 46 | padding: 0 1em; 47 | display: grid; 48 | 49 | button { margin: 0 } 50 | } 51 | } -------------------------------------------------------------------------------- /src/components/creatureForm/actionForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react" 2 | import { Action, AllyTarget, AtkAction, Buff, BuffAction, DebuffAction, DiceFormula, EnemyTarget, FinalAction, Frequency, HealAction, TemplateAction } from "../../model/model" 3 | import styles from './actionForm.module.scss' 4 | import { clone } from "../../model/utils" 5 | import { ActionType, BuffDuration, ActionCondition, CreatureConditionList, CreatureCondition, ActionSlots } from "../../model/enums" 6 | import Select from "../utils/select" 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 8 | import { faChevronDown, faChevronUp, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons" 9 | import DecimalInput from "../utils/DecimalInput" 10 | import DiceFormulaInput from "../utils/diceFormulaInput" 11 | import { ActionTemplates, getFinalAction } from "../../data/actions" 12 | 13 | type PropType = { 14 | value: Action, 15 | onChange: (newvalue: Action) => void, 16 | onDelete: () => void, 17 | onMoveUp?: () => void, 18 | onMoveDown?: () => void, 19 | } 20 | 21 | type Options = { value: T, label: string}[] 22 | 23 | const srFreq: Frequency = { reset: "sr", uses: 1 } 24 | const lrFreq: Frequency = { reset: "lr", uses: 1 } 25 | const rechargeFreq: Frequency = { reset: "recharge", cooldownRounds: 2 } 26 | const FreqOptions: Options = [ 27 | { value: 'at will', label: 'At will' }, 28 | { value: '1/fight', label: '1/short rest' }, 29 | { value: srFreq, label: 'X/short rest' }, 30 | { value: '1/day', label: '1/day' }, 31 | { value: lrFreq, label: 'X/long rest' }, 32 | { value: rechargeFreq, label: 'Every X rounds' }, 33 | ] 34 | 35 | const ConditionOptions: Options = [ 36 | { value:'default', label: 'Default' }, 37 | { value:'ally at 0 HP', label: 'There is an ally at 0 HP' }, 38 | { value:'ally under half HP', label: 'An ally has less than half their maximum HP' }, 39 | { value:'is available', label: 'A use of this action is available' }, 40 | { value:'is under half HP', label: 'This creature is under half its maximum HP' }, 41 | { value:'has no THP', label: 'This creature has no temporary HP' }, 42 | { value:'not used yet', label: 'This action has not been used yet this encounter' }, 43 | { value:'enemy count one', label: 'There is only one enemy' }, 44 | { value:'enemy count multiple', label: 'There are at least two enemies' }, 45 | ] 46 | 47 | const TypeOptions: Options = [ 48 | { value: 'template', label: 'Common Spell' }, 49 | { value: 'atk', label: 'Attack' }, 50 | { value: 'heal', label: 'Heal' }, 51 | { value: 'buff', label: 'Buff' }, 52 | { value: 'debuff', label: 'Debuff' }, 53 | ] 54 | 55 | const ActionOptions: Options = Object.entries(ActionSlots).map(([label, value]) => ({label, value})) 56 | 57 | const TargetCountOptions: Options = [ 58 | { value: 1, label: 'Single target' }, 59 | { value: 2, label: 'Multi target' }, 60 | { value: 3, label: '3 targets' }, 61 | { value: 4, label: '4 targets' }, 62 | { value: 5, label: '5 targets' }, 63 | { value: 6, label: '6 targets' }, 64 | { value: 7, label: '7 targets' }, 65 | { value: 8, label: '8 targets' }, 66 | { value: 9, label: '9 targets' }, 67 | { value: 10, label: '10 targets' }, 68 | { value: 11, label: '11 targets' }, 69 | { value: 12, label: '12 targets' }, 70 | { value: 13, label: '13 targets' }, 71 | { value: 14, label: '14 targets' }, 72 | { value: 15, label: '15 targets' }, 73 | { value: 10000, label: 'Target everything' }, 74 | ] 75 | 76 | const HitCountOptions: Options = [ 77 | { value: 1, label: '1 hit' }, 78 | { value: 2, label: '2 hits' }, 79 | { value: 3, label: '3 hits' }, 80 | { value: 4, label: '4 hits' }, 81 | { value: 5, label: '5 hits' }, 82 | { value: 6, label: '6 hits' }, 83 | { value: 7, label: '7 hits' }, 84 | { value: 8, label: '8 hits' }, 85 | { value: 9, label: '9 hits' }, 86 | { value: 10, label: '10 hits' }, 87 | ] 88 | 89 | const EnemyTargetOptions: Options = [ 90 | { value: 'enemy with least HP', label: 'Enemy with least HP' }, 91 | { value: 'enemy with most HP', label: 'Enemy with most HP' }, 92 | { value: 'enemy with highest DPR', label: 'Enemy with highest DPR' }, 93 | { value: 'enemy with lowest AC', label: 'Enemy with lowest AC' }, 94 | { value: 'enemy with highest AC', label: 'Enemy with highest AC' }, 95 | ] 96 | 97 | const AllyTargetOptions: Options = [ 98 | { value: 'self', label: 'Self' }, 99 | { value: 'ally with the least HP', label: 'Ally with the least HP' }, 100 | { value: 'ally with the most HP', label: 'Ally with the most HP' }, 101 | { value: 'ally with the highest DPR', label: 'Ally with the highest DPR' }, 102 | { value: 'ally with the lowest AC', label: 'Ally with the lowest AC' }, 103 | { value: 'ally with the highest AC', label: 'Ally with the highest AC' }, 104 | ] 105 | 106 | const BuffDurationOptions: Options = [ 107 | { value: '1 round', label: "1 Round" }, 108 | { value: 'repeat the save each round', label: "Repeat the save each round" }, 109 | { value: 'entire encounter', label: 'Entire Encounter' }, 110 | { value: 'until next attack taken', label: 'Until the next attack taken' }, 111 | { value: 'until next attack made', label: 'Until the next attack made' } 112 | ] 113 | 114 | const BuffStatOptions: Options> = [ 115 | { value: 'condition', label: 'Condition' }, 116 | { value: 'ac', label: 'Armor Class' }, 117 | { value: 'save', label: 'Bonus to Saves' }, 118 | { value: 'toHit', label: 'Bonus to hit' }, 119 | { value: 'dc', label: 'Save DC Bonus' }, 120 | { value: 'damage', label: 'Extra Damage' }, 121 | { value: 'damageReduction', label: 'Damage Reduction' }, 122 | { value: 'damageMultiplier', label: 'Damage Multiplier' }, 123 | { value: 'damageTakenMultiplier', label: 'Damage Taken Multiplier' }, 124 | ] 125 | 126 | const AtkOptions: Options = [ 127 | { value: true, label: 'Save DC:' }, 128 | { value: false, label: 'To Hit:' }, 129 | ] 130 | 131 | const BuffForm:FC<{value: Buff, onUpdate: (newValue: Buff) => void}> = ({ value, onUpdate }) => { 132 | const [modifiers, setModifiers] = useState<(keyof Omit)[]>(Object.keys(value).filter(key => (key !== 'duration')) as any) 133 | 134 | 135 | function setModifier(index: number, newValue: keyof Omit | null) { 136 | const oldModifier = modifiers[index] 137 | if (oldModifier === newValue) return 138 | 139 | const buffClone = clone(value) 140 | delete buffClone[oldModifier] 141 | onUpdate(buffClone) 142 | 143 | const modifiersClone = clone(modifiers) 144 | if (newValue === null) modifiersClone.splice(index, 1) 145 | else modifiersClone[index] = newValue 146 | setModifiers(modifiersClone) 147 | } 148 | 149 | function updateValue(modifier: keyof Omit, newValue: number) { 150 | const buffClone = clone(value) 151 | buffClone[modifier] = newValue 152 | onUpdate(buffClone) 153 | } 154 | 155 | function updateDiceFormula(modifier: string, newValue: DiceFormula) { 156 | const buffClone = clone(value); 157 | (buffClone as any)[modifier] = newValue 158 | onUpdate(buffClone) 159 | } 160 | 161 | function updateCondition(newValue: CreatureCondition|undefined) { 162 | const buffClone = clone(value) 163 | buffClone.condition = newValue 164 | onUpdate(buffClone) 165 | } 166 | 167 | function addModifier() { 168 | const newModifier = BuffStatOptions.find(({value}) => !modifiers.includes(value)) 169 | if (!newModifier) return; 170 | setModifiers([...modifiers, newModifier.value]) 171 | } 172 | 173 | return ( 174 | <> 175 | Effects: 176 | {modifiers.map((modifier, index) => ( 177 |
178 | ({ value: condition, label: condition }))} 192 | onChange={(newCondition) => updateCondition(newCondition)} 193 | /> 194 | ) : ( 195 | updateDiceFormula(modifier, v || 0)} 198 | /> 199 | )} 200 | 203 |
204 | ))} 205 | 206 | 213 | 214 | ) 215 | } 216 | 217 | const ActionForm:FC = ({ value, onChange, onDelete, onMoveUp, onMoveDown }) => { 218 | function update(callback: (valueClone: Action) => void) { 219 | const valueClone = clone(value) 220 | callback(valueClone) 221 | onChange(valueClone) 222 | } 223 | 224 | function updateFinalAction(callback: (valueClone: FinalAction) => void) { 225 | if (value.type === 'template') return 226 | 227 | const valueClone = clone(value) 228 | callback(valueClone) 229 | onChange(valueClone) 230 | } 231 | 232 | function updateTemplateAction(callback: (valueClone: TemplateAction) => void) { 233 | if (value.type !== 'template') return 234 | 235 | const valueClone = clone(value) 236 | callback(valueClone) 237 | onChange(valueClone) 238 | } 239 | 240 | function updateFrequency(freq: Frequency) { 241 | const v = clone(value) 242 | 243 | v.freq = (freq === v.freq) ? v.freq 244 | : (typeof freq === 'string') ? freq 245 | : (typeof v.freq === 'string') ? clone(freq) 246 | : (v.freq.reset !== freq.reset) ? clone(freq) 247 | : v.freq 248 | 249 | onChange(v) 250 | console.log(v) 251 | } 252 | 253 | function updateRiderEffect(callback: (riderEffect: { dc: number, buff: Buff }) => void) { 254 | update((actionClone) => { 255 | const atkAction = (actionClone as AtkAction) 256 | atkAction.riderEffect ||= { dc: 0, buff: { duration: '1 round' } } 257 | callback(atkAction.riderEffect) 258 | }) 259 | } 260 | 261 | function updateType(type: ActionType) { 262 | if (type === value.type) return 263 | 264 | const finalAction = getFinalAction(value) 265 | 266 | const common = { 267 | id: value.id, 268 | name: finalAction.name, 269 | actionSlot: finalAction.actionSlot, 270 | condition: finalAction.condition, 271 | freq: finalAction.freq, 272 | targets: finalAction.targets, 273 | } 274 | 275 | const templateAction: TemplateAction = { 276 | id: value.id, 277 | type: 'template', 278 | condition: finalAction.condition, 279 | freq: finalAction.freq, 280 | templateOptions: { templateName: 'Fireball', saveDC: 10, toHit: 10, target: 'enemy with least HP' }, 281 | } 282 | 283 | switch (type) { 284 | case "template": return onChange(templateAction) 285 | case "atk": return onChange({...common, type, target: "enemy with most HP", dpr: 0, toHit: 0 }) 286 | case "heal": return onChange({...common, type, amount: 0, target: "ally with the least HP" }) 287 | case "buff": return onChange({...common, type, target: "ally with the highest DPR", buff: { duration: '1 round' } }) 288 | case "debuff": return onChange({...common, type, target: "enemy with highest DPR", saveDC: 10, buff: { duration: '1 round' } }) 289 | } 290 | } 291 | 292 | function onTemplateChange(templateName: keyof typeof ActionTemplates) { 293 | if (value.type !== 'template') return 294 | 295 | const template = ActionTemplates[templateName] 296 | const enemyTarget: EnemyTarget = 'enemy with least HP' 297 | const allyTarget: AllyTarget = 'ally with the least HP' 298 | const defaultTarget: EnemyTarget|AllyTarget = ((template.type === 'atk') || (template.type === 'debuff')) ? enemyTarget : allyTarget 299 | 300 | onChange({ 301 | ...value, 302 | templateOptions: { 303 | templateName, 304 | toHit: value.templateOptions.toHit || 0, 305 | saveDC: value.templateOptions.saveDC || 0, 306 | target: value.templateOptions.target || defaultTarget, 307 | }, 308 | }) 309 | } 310 | 311 | return ( 312 |
313 |
314 | 319 | 324 |
325 | 326 | 329 | 330 | { value.type !== 'template' ? ( 331 | <> 332 | updateFinalAction(v => { v.name = e.target.value.length < 100 ? e.target.value : v.name })} 336 | placeholder="Action name..." 337 | style={{ minWidth: `${value.name.length}ch` }} 338 | /> 339 | 344 | 345 | { value.type === 'template' ? ( 346 | updateFrequency(freq)} /> 362 | 363 | { typeof value.freq !== 'string' ? ( 364 | value.freq.reset === 'recharge' ? ( 365 | <> 366 | Cooldown in rounds: 367 | update(v => { (v.freq as any).cooldownRounds = Number(e.target.value || 0) })}/> 374 | 375 | ) : ( 376 | <> 377 | Uses: 378 | update(v => { (v.freq as any).uses = Number(e.target.value || 0) })}/> 385 | 386 | ) 387 | ) : null } 388 | 389 | { ((value.type === 'atk') && (!value.useSaves)) ? ( 390 | updateFinalAction(v => { v.targets = targets })} /> 393 | ) : null } 394 | Use this action if: 395 | update(v => { 403 | const atk = (v as AtkAction); 404 | if (atk.useSaves !== useSaves) atk.targets = 1 405 | atk.useSaves = useSaves 406 | })} /> 407 | update(v => { (v as AtkAction).toHit = toHit || 0 })} /> 408 | Damage: 409 | update(v => { (v as AtkAction).dpr = dpr || 0 })} canCrit={!value.useSaves} /> 410 | 411 | { !!value.useSaves ? ( 412 | <> 413 | Save for half? 414 | updateFinalAction(v => { v.target = target })} /> 423 | On Hit Effect: 424 | updateRiderEffect(e => { e.buff.duration = duration })} /> 438 | updateRiderEffect(e => { e.buff = newValue })} /> 439 | 440 | ) : null } 441 | 442 | ) : null } 443 | { (value.type === "heal") ? ( 444 | <> 445 | updateFinalAction(v => { v.target = target })} /> 452 | 453 | ) : null } 454 | { (value.type === "buff") ? ( 455 | <> 456 | Target: 457 | update(v => { (v as BuffAction).buff.duration = duration })} /> 460 | update(v => { (v as BuffAction).buff = newValue })} /> 461 | 462 | ) : null } 463 | { (value.type === "debuff") ? ( 464 | <> 465 | Target: 466 | update(v => { (v as DebuffAction).buff.duration = duration })} /> 469 | Save DC: 470 | update(v => { (v as DebuffAction).saveDC = Number(e.target.value) })} /> 471 | update(v => { (v as DebuffAction).buff = newValue })} /> 472 | 473 | ) : null } 474 | { (value.type === "template") ? (() => { 475 | const template = ActionTemplates[value.templateOptions.templateName] 476 | 477 | const targetForm = template.target ? null : ( 478 | <> 479 | Target: 480 | updateTemplateAction(v => { v.templateOptions.saveDC = Number(e.target.value) })} /> 495 | 496 | ) : null} 497 | {targetForm} 498 | 499 | ) 500 | if (template.type === 'debuff') return ( 501 | <> 502 | Save DC: 503 | updateTemplateAction(v => { v.templateOptions.saveDC = Number(e.target.value) })} /> 507 | {targetForm} 508 | 509 | ) 510 | 511 | return targetForm 512 | })() : null } 513 |
514 | ) 515 | } 516 | 517 | export default ActionForm -------------------------------------------------------------------------------- /src/components/creatureForm/creatureForm.module.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | .creatureForm { 4 | .modes { 5 | width: 100%; 6 | display: grid; 7 | grid-template-columns: 1fr 1fr 1fr; 8 | 9 | button { 10 | padding-left: 0.5em; 11 | padding-right: 0.5em; 12 | margin: 0; 13 | border-radius: 0; 14 | 15 | &:first-child { border-radius: 8px 0 0 8px } 16 | &:last-child { border-radius: 0 8px 8px 0 } 17 | &.active { background: #fff3 } 18 | } 19 | } 20 | 21 | .buttons { 22 | margin-top: 2em; 23 | display: flex; 24 | gap: 1em; 25 | margin-top: 1em; 26 | 27 | button { flex: 1 1 0; margin: 0 } 28 | @media (width < 600px) { flex-direction: column; } 29 | } 30 | } -------------------------------------------------------------------------------- /src/components/creatureForm/creatureForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react" 2 | import { Creature, CreatureSchema } from "../../model/model" 3 | import styles from './creatureForm.module.scss' 4 | import { clone } from "../../model/utils" 5 | import PlayerForm from "./playerForm" 6 | import MonsterForm from "./monsterForm" 7 | import CustomForm from "./customForm" 8 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 9 | import { faCheck, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons" 10 | import { v4 as uuid } from 'uuid' 11 | import Modal from "../utils/modal" 12 | 13 | type PropType = { 14 | onSubmit: (value: Creature) => void, 15 | onCancel: () => void, 16 | 17 | initialMode?: 'player'|'monster', 18 | initialValue?: Creature, 19 | onDelete?: () => void, 20 | } 21 | 22 | function newCreature(mode: 'player'|'monster'): Creature { 23 | return { 24 | id: uuid(), 25 | mode, 26 | name: (mode === 'player') ? 'Player Character' : 'Monster', 27 | AC: 10, 28 | saveBonus: 2, 29 | count: 1, 30 | hp: 10, 31 | actions: [], 32 | } 33 | } 34 | 35 | const CreatureForm:FC = ({ initialMode, onSubmit, onCancel, initialValue, onDelete }) => { 36 | const [value, setValue] = useState(initialValue || newCreature(initialMode || 'player')) 37 | const [isValid, setIsValid] = useState(false) 38 | useEffect(() => { 39 | if (!CreatureSchema.safeParse(value).success) { 40 | setIsValid(false) 41 | return 42 | } 43 | 44 | if (value.mode === 'player') { 45 | setIsValid(!!value.class) 46 | } else if (value.mode === 'monster') { 47 | setIsValid(!!value.cr) 48 | } else { 49 | setIsValid(true) 50 | } 51 | }, [value]) 52 | 53 | function update(callback: (clonedValue: Creature) => void, condition?: boolean) { 54 | if (condition === false) return 55 | const clonedValue = clone(value) 56 | callback(clonedValue) 57 | setValue(clonedValue) 58 | } 59 | 60 | return ( 61 | 62 |
63 | 69 | 75 | 81 |
82 | 83 |
84 | { (value.mode === "player") ? ( 85 | 89 | ) : (value.mode === "monster") ? ( 90 | 94 | ) : ( 95 | 99 | )} 100 |
101 | 102 |
103 | 111 | { (value.mode === 'custom') ? null : ( 112 | 120 | )} 121 | { !onDelete ? null : ( 122 | 130 | )} 131 |
132 |
133 | ) 134 | } 135 | 136 | export default CreatureForm -------------------------------------------------------------------------------- /src/components/creatureForm/customForm.module.scss: -------------------------------------------------------------------------------- 1 | .customForm { 2 | margin-top: 1em; 3 | 4 | section { 5 | margin: 0.5em 0; 6 | display: grid; 7 | grid-template-columns: 200px calc(100% - 200px); 8 | align-items: center; 9 | 10 | @media (width < 800px) { grid-template-columns: 1fr } 11 | } 12 | 13 | .nameContainer { 14 | display: flex; 15 | flex-direction: row; 16 | gap: 8px; 17 | 18 | input { flex-grow: 1; width: 75px } 19 | button { margin: 0 } 20 | svg { margin-left: 0 } 21 | 22 | @media (width < 450px) { 23 | button .btnText { display: none } 24 | svg { margin-right: 0 } 25 | } 26 | } 27 | 28 | h3 { user-select: none; white-space: nowrap; margin: 0.5em 0 } 29 | 30 | .actionsHeader { 31 | display: flex; 32 | flex-direction: row; 33 | align-items: center; 34 | 35 | .label { flex-grow: 1 } 36 | .createActionBtn { flex-shrink: 1; margin: 0 } 37 | } 38 | } -------------------------------------------------------------------------------- /src/components/creatureForm/customForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, useState } from "react" 2 | import { Action, Creature } from "../../model/model" 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { faFolder, faPlus, faSave } from "@fortawesome/free-solid-svg-icons" 5 | import styles from './customForm.module.scss' 6 | import { clone } from "../../model/utils" 7 | import ActionForm from "./actionForm" 8 | import DecimalInput from "../utils/DecimalInput" 9 | import { v4 as uuid } from 'uuid' 10 | import LoadCreatureForm, { saveCreature } from "./loadCreatureForm" 11 | 12 | type PropType = { 13 | value: Creature, 14 | onChange: (newvalue: Creature) => void, 15 | } 16 | 17 | const CustomForm:FC = ({ value, onChange }) => { 18 | const [isLoading, setIsLoading] = useState(false) 19 | 20 | function update(callback: (valueClone: Creature) => void) { 21 | const valueClone = clone(value) 22 | callback(valueClone) 23 | onChange(valueClone) 24 | } 25 | 26 | function createAction() { 27 | update(v => { v.actions.push({ 28 | id: uuid(), 29 | actionSlot: 0, 30 | name: '', 31 | freq: 'at will', 32 | condition: 'default', 33 | targets: 1, 34 | type: 'atk', 35 | dpr: 0, 36 | toHit: 0, 37 | target: 'enemy with least HP', 38 | }) }) 39 | } 40 | 41 | function updateAction(index: number, newValue: Action) { 42 | update(v => { v.actions[index] = newValue }) 43 | } 44 | 45 | function deleteAction(index: number) { 46 | update(v => { v.actions.splice(index, 1) }) 47 | } 48 | 49 | const canSaveTemplate = !!localStorage && !!localStorage.getItem('useLocalStorage') 50 | 51 | return ( 52 |
53 |
54 |

Name

55 |
56 | update(v => { v.name = e.target.value })} /> 57 | { canSaveTemplate ? ( 58 | <> 59 | 63 | 67 | 68 | ) : null } 69 |
70 |
71 |
72 |

Hit Points

73 | update(v => { v.hp = hp || 0 })} /> 74 |
75 |
76 |

Armor Class

77 | update(v => { v.AC = ac || 0 })} /> 78 |
79 |
80 |

Average Save

81 | update(v => { v.saveBonus = save || 0 })} /> 82 |
Average of all saves' bonuses. For player characters, you can use the Proficiency Bonus. For monsters, either calculate it, or just use half of the monster's CR.
83 |
84 | 85 |

86 | Actions 87 | 92 |

93 |
94 | { value.actions.map((action, index) => ( 95 | updateAction(index, a)} 99 | onDelete={() => deleteAction(index)} 100 | onMoveUp={(index <= 0) ? undefined : () => update(v => { 101 | v.actions[index] = v.actions[index - 1] 102 | v.actions[index - 1] = action 103 | })} 104 | onMoveDown={(index >= value.actions.length - 1) ? undefined : () => update(v => { 105 | v.actions[index] = v.actions[index + 1] 106 | v.actions[index + 1] = action 107 | })} 108 | /> 109 | ))} 110 |
111 | 112 | { isLoading ? ( 113 | { onChange(creature); setIsLoading(false) }} 115 | onCancel={() => setIsLoading(false)} /> 116 | ) : null} 117 |
118 | ) 119 | } 120 | 121 | export default CustomForm -------------------------------------------------------------------------------- /src/components/creatureForm/loadCreatureForm.module.scss: -------------------------------------------------------------------------------- 1 | .loadCreature { 2 | margin: auto 2em; 3 | 4 | .creature { 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | 9 | button { 10 | background: none; 11 | padding: 0; 12 | } 13 | } 14 | 15 | > button { 16 | width: 100%; 17 | } 18 | } -------------------------------------------------------------------------------- /src/components/creatureForm/loadCreatureForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { Creature, CreatureSchema } from "../../model/model"; 3 | import styles from './loadCreatureForm.module.scss' 4 | import Modal from "../utils/modal"; 5 | import { z } from 'zod' 6 | import { clone, useCalculatedState } from "../../model/utils"; 7 | import SortTable from "../utils/sortTable"; 8 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 9 | import { faFolder, faTrash } from "@fortawesome/free-solid-svg-icons"; 10 | 11 | type PropType = { 12 | onCancel: () => void, 13 | onLoad: (newCreature: Creature) => void, 14 | } 15 | 16 | function loadSaves(): Creature[] { 17 | if (typeof localStorage === undefined) return [] 18 | 19 | const json = localStorage.getItem('savedCreatures') 20 | if (!json) return [] 21 | 22 | const obj = JSON.parse(json) 23 | const parsed = z.array(CreatureSchema).safeParse(obj) 24 | 25 | if (parsed.success) { 26 | return parsed.data 27 | } 28 | return [] 29 | } 30 | 31 | export function saveCreature(creature: Creature) { 32 | if (typeof localStorage === undefined) return 33 | if (!localStorage.getItem('useLocalStorage')) return 34 | 35 | const saves = loadSaves() 36 | 37 | const existingIndex = saves.findIndex(save => (save.id === creature.id) && (save.name === creature.name)) 38 | 39 | if (existingIndex != -1) { 40 | saves[existingIndex] = creature 41 | } else { 42 | saves.push(creature) 43 | } 44 | 45 | localStorage.setItem('savedCreatures', JSON.stringify(saves)) 46 | } 47 | 48 | const LoadCreatureForm:FC = ({ onCancel, onLoad }) => { 49 | const [selected, setSelected] = useState(undefined) 50 | const [saves, setSaves] = useState(loadSaves()) 51 | 52 | function deleteSave(creature: Creature) { 53 | const index = saves.findIndex((save) => (save.id === creature.id) && (save.name === creature.name)) 54 | if (index === -1) return 55 | 56 | const savesClone = clone(saves) 57 | savesClone.splice(index, 1) 58 | setSaves(savesClone) 59 | setTimeout(() => setSelected(undefined), 0) 60 | 61 | if (typeof localStorage === undefined) return 62 | if (!localStorage.getItem('useLocalStorage')) return 63 | localStorage.setItem('savedCreatures', JSON.stringify(savesClone)) 64 | } 65 | 66 | return ( 67 | 68 | a.name.localeCompare(b.name), 74 | }}> 75 | { creature => ( 76 |
77 | {creature.name} 78 | 79 |
80 | )} 81 |
82 | 83 | 89 |
90 | ) 91 | } 92 | 93 | export default LoadCreatureForm -------------------------------------------------------------------------------- /src/components/creatureForm/monsterForm.module.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | .monsterForm { 4 | section { 5 | margin: 1em 0; 6 | display: grid; 7 | grid-template-columns: 200px calc(100% - 200px); 8 | align-items: center; 9 | gap: 8px; 10 | 11 | @media (width < 800px) { grid-template-columns: 1fr } 12 | } 13 | 14 | h3 { user-select: none; white-space: nowrap; margin: 0.5em 0 } 15 | 16 | .creatureTypes { 17 | @include scrollable; 18 | overflow: hidden; 19 | border-radius: 8px; 20 | display: grid; 21 | 22 | grid-template-columns: repeat(16, 1fr); 23 | @media (width < 1400px) { grid-template-columns: repeat(8, 1fr) } 24 | @media (width < calc(800px + 6em)) { grid-template-columns: repeat(4, 1fr) } 25 | @media (width < calc(400px + 6em)) { grid-template-columns: 1fr 1fr; height: 100px; overflow-y: scroll } 26 | @media (width < calc(200px + 6em)) { grid-template-columns: 1fr } 27 | 28 | button { 29 | padding: 8px; 30 | margin: 0; 31 | display: flex; 32 | flex-direction: column; 33 | justify-content: center; 34 | align-items: center; 35 | border-radius: 0; 36 | 37 | img { 38 | border-radius: 8px; 39 | width: 40px; 40 | margin-bottom: 1em; 41 | } 42 | 43 | &.active { background: #fff3 } 44 | &:hover { background: #fff2 } 45 | } 46 | } 47 | 48 | .monster .stats { 49 | float: right; 50 | font-weight: lighter; 51 | font-size: smaller; 52 | padding-top: 4px; 53 | padding-left: 1ch; 54 | 55 | &:not(:last-child) { 56 | @media (width < 600px) { display: none } 57 | } 58 | } 59 | 60 | } -------------------------------------------------------------------------------- /src/components/creatureForm/monsterForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, } from "react" 2 | import { Creature, CreatureSchema } from "../../model/model" 3 | import styles from './monsterForm.module.scss' 4 | import { ChallengeRating, ChallengeRatingList, CreatureType, CreatureTypeList, numericCR } from "../../model/enums" 5 | import { capitalize, clone, sharedStateGenerator, useCalculatedState } from "../../model/utils" 6 | import { Monsters } from "../../data/monsters" 7 | import Range from "../utils/range" 8 | import SortTable from "../utils/sortTable" 9 | 10 | type PropType = { 11 | value: Creature, 12 | onChange: (newvalue: Creature) => void, 13 | } 14 | 15 | const defaultTypeFilter: {[type in CreatureType]: boolean} = Object.fromEntries(CreatureTypeList.map(t => [t, true])) as any 16 | 17 | const MonsterForm:FC = ({ onChange, value }) => { 18 | const useSharedState = sharedStateGenerator('monsterForm') 19 | const [creatureType, setCreatureType] = useSharedState(defaultTypeFilter) 20 | const [minCR, setMinCR] = useSharedState(ChallengeRatingList[0]) 21 | const [maxCR, setMaxCR] = useSharedState(ChallengeRatingList[ChallengeRatingList.length - 2]) 22 | const [name, setName] = useSharedState('') 23 | 24 | const searchResults = useCalculatedState(() => Monsters.filter(monster => { 25 | if (!monster.name.toLocaleLowerCase().includes(name.toLocaleLowerCase())) return false 26 | if (!monster.cr) return false 27 | if (numericCR(monster.cr) > numericCR(maxCR)) return false 28 | if (numericCR(monster.cr) < numericCR(minCR)) return false 29 | if (!monster.type) return false 30 | if (!creatureType[monster.type]) return false 31 | 32 | return true 33 | }), [creatureType, minCR, maxCR, name]) 34 | 35 | function toggleCreatureType(type: CreatureType) { 36 | const newValue = clone(creatureType) 37 | newValue[type] = !newValue[type] 38 | setCreatureType(newValue) 39 | } 40 | 41 | function selectMonster(monster: Creature) { 42 | const templates = JSON.parse(localStorage.getItem('monsterTemplates') || "{}") 43 | const monsterTemplate = CreatureSchema.safeParse(templates[monster.id]) 44 | 45 | const creature = monsterTemplate.success ? monsterTemplate.data : monster 46 | creature.count = value?.count || 1 47 | onChange(creature) 48 | } 49 | 50 | return ( 51 |
52 |
53 |

Name

54 | setName(e.target.value)} placeholder='Bandit...' autoFocus={true} /> 55 |
56 | 57 |
58 |

Creature Type

59 |
60 | 65 | 70 | { CreatureTypeList.map(type => ( 71 | 77 | )) } 78 |
79 |
80 | 81 |
82 |

Challenge Rating

83 | { await setMaxCR(ChallengeRatingList[values[1]]); await setMinCR(ChallengeRatingList[values[0]]) }} 88 | label={minCR} 89 | upperLabel={maxCR} 90 | /> 91 |
92 | 93 | a.name.localeCompare(b.name), 98 | CR: (a: Creature, b: Creature) => (numericCR(a.cr!) - numericCR(b.cr!)), 99 | }} 100 | onChange={selectMonster}> 101 | { monster => ( 102 |
103 | {monster.name} 104 | {monster.type}, {monster.src} 105 | CR {monster.cr} 106 |
107 | )} 108 |
109 |
110 | ) 111 | } 112 | 113 | export default MonsterForm -------------------------------------------------------------------------------- /src/components/creatureForm/playerForm.module.scss: -------------------------------------------------------------------------------- 1 | .playerForm { 2 | 3 | h3 { user-select: none } 4 | 5 | .classes { 6 | overflow: hidden; 7 | border-radius: 8px; 8 | display: flex; 9 | flex-wrap: wrap; 10 | 11 | .class { 12 | flex: 1 1 0; 13 | min-width: 200px; 14 | padding-left: 0; 15 | padding-right: 0; 16 | margin: 0; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | align-items: center; 21 | border-radius: 0; 22 | 23 | img { 24 | border-radius: 8px; 25 | width: 50px; 26 | margin-bottom: 1em; 27 | 28 | @media (width < 600px) { display: none } 29 | } 30 | 31 | &.active { background: #fff3 } 32 | } 33 | } 34 | 35 | .levels { 36 | overflow: hidden; 37 | border-radius: 8px; 38 | display: grid; 39 | grid-template-columns: repeat(20, 1fr); 40 | @media (width < 800px) { grid-template-columns: repeat(10, 1fr) } 41 | @media (width < 400px) { grid-template-columns: repeat(5, 1fr) } 42 | @media (width < 200px) { grid-template-columns: repeat(4, 1fr) } 43 | @media (width < 160px) { grid-template-columns: 1fr 1fr } 44 | 45 | .level { 46 | padding-left: 0; 47 | padding-right: 0; 48 | margin: 0; 49 | border-radius: 0; 50 | &.active { background: #fff3 } 51 | } 52 | } 53 | 54 | .classOptions { 55 | display: grid; 56 | grid-template-columns: 1fr 1fr 1fr; 57 | align-items: center; 58 | gap: 8px; 59 | 60 | @media (width < 1200px) { grid-template-columns: 1fr 1fr; } 61 | @media (width < 800px) { grid-template-columns: 1fr; } 62 | 63 | .option { 64 | display: flex; 65 | flex-direction: row; 66 | align-items: center; 67 | gap: 8px; 68 | 69 | :last-child { flex-grow: 1 } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/components/creatureForm/playerForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext, useEffect, useState } from "react" 2 | import { Creature } from "../../model/model" 3 | import styles from './playerForm.module.scss' 4 | import { Class, ClassesList } from "../../model/enums" 5 | import { capitalize, clone, range } from "../../model/utils" 6 | import { PlayerTemplates } from "../../data/data" 7 | import ClassOptions from "../../model/classOptions" 8 | import { z } from "zod" 9 | import Checkbox from "../utils/checkbox" 10 | import Range from "../utils/range" 11 | 12 | type PropType = { 13 | value?: Creature, 14 | onChange: (newvalue: Creature) => void, 15 | } 16 | 17 | type ClassForm = { type: 'artificer', options: z.infer } 18 | | { type: 'barbarian', options: z.infer } 19 | | { type: 'bard', options: z.infer } 20 | | { type: 'cleric', options: z.infer } 21 | | { type: 'druid', options: z.infer } 22 | | { type: 'fighter', options: z.infer } 23 | | { type: 'monk', options: z.infer } 24 | | { type: 'paladin', options: z.infer } 25 | | { type: 'ranger', options: z.infer } 26 | | { type: 'rogue', options: z.infer } 27 | | { type: 'sorcerer', options: z.infer } 28 | | { type: 'warlock', options: z.infer } 29 | | { type: 'wizard', options: z.infer } 30 | 31 | const DefaultOptions: {[key in Class]: z.infer} = { 32 | artificer: {}, 33 | barbarian: { gwm: false, weaponBonus: 0 }, 34 | bard: {}, 35 | cleric: {}, 36 | druid: {}, 37 | fighter: { gwm: false, weaponBonus: 0 }, 38 | monk: {}, 39 | paladin: { gwm: false, weaponBonus: 0 }, 40 | ranger: { ss: false, weaponBonus: 0 }, 41 | rogue: { ss: false, weaponBonus: 0 }, 42 | sorcerer: {}, 43 | warlock: {}, 44 | wizard: {}, 45 | } 46 | 47 | const DefaultClass = { type: 'barbarian', options: DefaultOptions.barbarian } 48 | const DefaultLevel = 1 49 | 50 | const PlayerForm:FC = ({ value, onChange }) => { 51 | const [chosenClass, setChosenClass] = useState((value && value.class) ? { type: value.class.type, options: value.class.options } as any : DefaultClass) 52 | const [level, setLevel] = useState((value && value.class) ? value.class.level : DefaultLevel) 53 | 54 | useEffect(() => { 55 | if (!level || !chosenClass) return 56 | 57 | const template = PlayerTemplates[chosenClass.type] 58 | const creature = template(level, chosenClass.options as any) 59 | creature.class = { 60 | type: chosenClass.type, 61 | level: level, 62 | options: chosenClass.options, 63 | } 64 | creature.count = value?.count || 1 65 | onChange(creature) 66 | }, [chosenClass, level]) 67 | 68 | function setClass(type: Class) { 69 | const chosenClass = { type, options: DefaultOptions[type] } 70 | setChosenClass(chosenClass as any) 71 | } 72 | 73 | function setClassOptions(callback: (classOptions: z.infer) => void) { 74 | if (!chosenClass) return 75 | 76 | const chosenClassClone = clone(chosenClass) 77 | callback(chosenClassClone.options) 78 | setChosenClass(chosenClassClone) 79 | } 80 | 81 | return ( 82 |
83 |

Class

84 |
85 | { ClassesList.map(className => ( 86 | 94 | )) } 95 |
96 | 97 |

Level

98 |
99 | { range(20).map(i => i+1).map(lvl => ( 100 | 107 | )) } 108 |
109 | 110 | { !chosenClass ? null : ( 111 | (chosenClass.type === 'barbarian') ? ( 112 | <> 113 |

Barbarian-specific Options

114 |
115 | setClassOptions<'barbarian'>(options => { options.gwm = !options.gwm })}> 118 | Use Great Weapon Master 119 | 120 |
121 | Weapon: 122 | setClassOptions<'barbarian'>(options => { options.weaponBonus = newValue })} 127 | label={`+${chosenClass.options.weaponBonus}`} 128 | /> 129 |
130 |
131 | 132 | ) : (chosenClass.type === 'bard') ? ( 133 | <> 134 | ) : (chosenClass.type === 'cleric') ? ( 135 | <> 136 | ) : (chosenClass.type === 'druid') ? ( 137 | <> 138 | ) : (chosenClass.type === 'fighter') ? ( 139 | <> 140 |

Fighter-specific Options

141 |
142 | setClassOptions<'fighter'>(options => { options.gwm = !options.gwm })}> 145 | Use Great Weapon Master 146 | 147 |
148 | Weapon: 149 | setClassOptions<'fighter'>(options => { options.weaponBonus = newValue })} 154 | label={`+${chosenClass.options.weaponBonus}`} 155 | /> 156 |
157 |
158 | 159 | ) : (chosenClass.type === 'monk') ? ( 160 | <> 161 | ) : (chosenClass.type === 'paladin') ? ( 162 | <> 163 |

Paladin-specific Options

164 |
165 | setClassOptions<'paladin'>(options => { options.gwm = !options.gwm })}> 168 | Use Great Weapon Master 169 | 170 |
171 | Weapon: 172 | setClassOptions<'paladin'>(options => { options.weaponBonus = newValue })} 177 | label={`+${chosenClass.options.weaponBonus}`} 178 | /> 179 |
180 |
181 | 182 | ) : (chosenClass.type === 'ranger') ? ( 183 | <> 184 |

Ranger-specific Options

185 |
186 | setClassOptions<'ranger'>(options => { options.ss = !options.ss })}> 189 | Use Sharpshooter 190 | 191 |
192 | Weapon: 193 | setClassOptions<'ranger'>(options => { options.weaponBonus = newValue })} 198 | label={`+${chosenClass.options.weaponBonus}`} 199 | /> 200 |
201 |
202 | 203 | ) : (chosenClass.type === 'rogue') ? ( 204 | <> 205 |

Rogue-specific Options

206 |
207 | setClassOptions<'ranger'>(options => { options.ss = !options.ss })}> 210 | Use Sharpshooter 211 | 212 |
213 | Weapon: 214 | setClassOptions<'ranger'>(options => { options.weaponBonus = newValue })} 219 | label={`+${chosenClass.options.weaponBonus}`} 220 | /> 221 |
222 |
223 | 224 | ) : (chosenClass.type === 'sorcerer') ? ( 225 | <> 226 | ) : (chosenClass.type === 'warlock') ? ( 227 | <> 228 | ) : (chosenClass.type === 'wizard') ? ( 229 | <> 230 | ) : null 231 | ) } 232 |
233 | ) 234 | } 235 | 236 | export default PlayerForm -------------------------------------------------------------------------------- /src/components/simulation/adventuringDayForm.module.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | .adventuringDay { 4 | display: flex; 5 | flex-direction: column; 6 | gap: 1.5em; 7 | 8 | section { 9 | display: grid; 10 | grid-template-columns: 200px calc(100% - 200px); 11 | align-items: center; 12 | gap: 8px; 13 | 14 | @media (width < 800px) { grid-template-columns: 1fr } 15 | } 16 | 17 | h3 { user-select: none; white-space: nowrap; margin: 0.5em 0 } 18 | 19 | .save { 20 | align-items: center; 21 | display: grid; 22 | grid-template-columns: 1fr 1fr 50px; 23 | 24 | .fileName { 25 | font-weight: bold; 26 | } 27 | 28 | button { 29 | padding: 0; 30 | background: none; 31 | transition: color 0.3s; 32 | &:hover { color: #aaa } 33 | } 34 | } 35 | 36 | .buttons { 37 | display: flex; 38 | flex-direction: row; 39 | 40 | button, label { 41 | flex: 1 1 0; 42 | } 43 | 44 | label { 45 | @include clickable; 46 | } 47 | } 48 | 49 | .error { 50 | text-align: center; 51 | color: #c88; 52 | } 53 | } -------------------------------------------------------------------------------- /src/components/simulation/adventuringDayForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react" 2 | import { Creature, CreatureSchema, Encounter, EncounterSchema } from "../../model/model" 3 | import styles from './adventuringDayForm.module.scss' 4 | import { sharedStateGenerator, useCalculatedState } from "../../model/utils" 5 | import {z} from 'zod' 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 7 | import { faDownload, faFolder, faSave, faTrash, faUpload } from "@fortawesome/free-solid-svg-icons" 8 | import { PlayerTemplates } from "../../data/data" 9 | import { getMonster } from "../../data/monsters" 10 | import SortTable from "../utils/sortTable" 11 | import Modal from "../utils/modal" 12 | 13 | type PropType = { 14 | players: Creature[], 15 | encounters: Encounter[], 16 | onCancel: () => void, 17 | onLoad?: (players: Creature[], encounters: Encounter[]) => void, 18 | } 19 | 20 | function carefulSave(key: string, value: string) { 21 | if (!localStorage.getItem('useLocalStorage')) return 22 | localStorage.setItem(key, value) 23 | } 24 | 25 | const SaveFileSchema = z.object({ 26 | updated: z.number(), 27 | name: z.string(), 28 | players: z.array(CreatureSchema), 29 | encounters: z.array(EncounterSchema), 30 | }) 31 | type SaveFile = z.infer 32 | 33 | const SaveCollectionSchema = z.array(SaveFileSchema) 34 | type SaveCollection = z.infer 35 | 36 | const ExampleAdventuringDay: SaveFile = { 37 | updated: Date.now(), 38 | name: 'Example', 39 | players: [ 40 | PlayerTemplates.barbarian(3, {gwm: false, weaponBonus: 0}), 41 | PlayerTemplates.cleric(3, {}), 42 | PlayerTemplates.rogue(3, {ss: false, weaponBonus: 0}), 43 | PlayerTemplates.wizard(3, {}), 44 | ], 45 | encounters: [ 46 | { 47 | monsters: [ 48 | getMonster('Bandit Captain')!, 49 | {...getMonster('Bandit')!, count: 5}, 50 | ], 51 | }, 52 | ] 53 | } 54 | 55 | function loadSaves(): SaveCollection { 56 | if (typeof localStorage === undefined) return [] 57 | 58 | const json = localStorage.getItem('saveFiles') 59 | if (!json) return [ExampleAdventuringDay] 60 | 61 | const obj = JSON.parse(json) 62 | const parsed = SaveCollectionSchema.safeParse(obj) 63 | 64 | if (parsed.success) { 65 | return parsed.data 66 | } 67 | return [] 68 | } 69 | 70 | function currentSaveName(): string { 71 | if (typeof localStorage === undefined) return '' 72 | 73 | return localStorage.getItem('saveName') || '' 74 | } 75 | 76 | const AdventuringDayForm:FC = ({ players, encounters, onCancel, onLoad }) => { 77 | const useSharedContext = sharedStateGenerator('adventuringDayForm') 78 | const [name, setName] = useSharedContext(currentSaveName()) 79 | const [deleted, setDeleted] = useState(0) 80 | const [error, setError] = useState(null) 81 | 82 | const isValid = useCalculatedState(() => !!name, [name]) 83 | const searchResults = useCalculatedState(loadSaves, [name, deleted]) 84 | 85 | function save() { 86 | if (!isValid) return 87 | 88 | const newSaveFile: SaveFile = { 89 | updated: Date.now(), 90 | name, 91 | players, 92 | encounters, 93 | } 94 | 95 | const saveFiles = loadSaves() 96 | 97 | const existingIndex = saveFiles.findIndex(save => (save.name === newSaveFile.name)) 98 | if (existingIndex !== -1) saveFiles[existingIndex] = newSaveFile 99 | else saveFiles.push(newSaveFile) 100 | 101 | carefulSave('saveFiles', JSON.stringify(saveFiles)) 102 | carefulSave('saveName', name) 103 | onCancel() 104 | } 105 | 106 | function load() { 107 | if (!onLoad) return 108 | if (!isValid) return 109 | 110 | const saveFile = loadSaves().find(save => (save.name === name)) 111 | 112 | if (!saveFile) return 113 | 114 | onLoad(saveFile.players, saveFile.encounters) 115 | carefulSave('saveName', name) 116 | } 117 | 118 | function deleteSave(saveName: string) { 119 | setDeleted(deleted + 1) 120 | const saveFiles = loadSaves() 121 | const index = saveFiles.findIndex(save => (save.name === saveName)) 122 | 123 | if (index === -1) return 124 | 125 | saveFiles.splice(index, 1) 126 | carefulSave('saveFiles', JSON.stringify(saveFiles)) 127 | 128 | setName('') 129 | localStorage.removeItem('saveName') 130 | } 131 | 132 | async function onDownload() { 133 | const newSaveFile: SaveFile = { 134 | updated: Date.now(), 135 | name, 136 | players, 137 | encounters, 138 | } 139 | 140 | const file = new Blob([JSON.stringify(newSaveFile)], {type: 'json'}) 141 | const a = document.createElement("a") 142 | const url = URL.createObjectURL(file) 143 | a.href = url 144 | a.download = `${newSaveFile.name}.json` 145 | document.body.appendChild(a) 146 | a.click() 147 | setTimeout(() => { 148 | document.body.removeChild(a) 149 | window.URL.revokeObjectURL(url) 150 | }, 0) 151 | } 152 | 153 | async function onUpload(files: FileList | null) { 154 | if (!onLoad) return 155 | if (!files || !files.length) { setError('No files uploaded'); return } 156 | 157 | const file = files[0] 158 | if (!file) { setError('No file uploaded'); return } 159 | 160 | const json = await file.text() 161 | if (!json) return 162 | 163 | let obj 164 | try { obj = JSON.parse(json) } 165 | catch (e) { setError('File is not valid JSON'); return } 166 | 167 | const parsed = SaveFileSchema.safeParse(obj) 168 | if (!parsed.success) { setError('Invalid schema'); return } 169 | 170 | const newSave: SaveFile = parsed.data 171 | 172 | const saveFiles = loadSaves() 173 | 174 | const existingIndex = saveFiles.findIndex(save => (save.name === newSave.name)) 175 | if (existingIndex !== -1) saveFiles[existingIndex] = newSave 176 | else saveFiles.push(newSave) 177 | 178 | carefulSave('saveFiles', JSON.stringify(saveFiles)) 179 | carefulSave('saveName', newSave.name) 180 | onLoad(newSave.players, newSave.encounters) 181 | } 182 | 183 | function getSaveByName(saveName: string) { 184 | return searchResults.find(save => (save.name === saveName))! 185 | } 186 | 187 | return ( 188 | 189 | { onLoad ? null : ( 190 |
191 |

Save File Name:

192 | setName(e.target.value)} /> 193 |
194 | )} 195 | 196 | save.name)} 199 | comparators={{ 200 | Name: (a, b) => a.localeCompare(b), 201 | 'Last Update': (a, b) => getSaveByName(a).updated - getSaveByName(b).updated, 202 | }} 203 | onChange={setName}> 204 | { saveName => { 205 | const save = getSaveByName(saveName) 206 | return ( 207 |
208 | {save.name} 209 | {new Date(save.updated).toLocaleDateString()} 210 | 211 |
212 | ) 213 | }} 214 |
215 | 216 |
217 | { onLoad ? ( 218 | <> 219 | 225 | 226 | 234 | onUpload(e.target.files)} /> 240 | 241 | ) : ( 242 | <> 243 | 249 | 257 | 258 | )} 259 |
260 | 261 | { (error !== null) ? ( 262 |
263 | {error} 264 |
265 | ) : null} 266 |
267 | ) 268 | } 269 | 270 | export default AdventuringDayForm -------------------------------------------------------------------------------- /src/components/simulation/encounterForm.module.scss: -------------------------------------------------------------------------------- 1 | .encounterForm { 2 | position: relative; 3 | width: 100%; 4 | border-radius: 8px 8px 0 0; 5 | background-color: #544; 6 | padding: 1em; 7 | display: flex; 8 | flex-direction: column; 9 | 10 | .addCreatureBtn { margin: 0 } 11 | 12 | .encounterActions { 13 | position: absolute; 14 | top: 1em; 15 | right: 0.5em; 16 | display: flex; 17 | gap: 4px; 18 | 19 | button { 20 | border-radius: 4px; 21 | padding: 4px; 22 | &:not(:hover) {background: transparent } 23 | } 24 | svg { 25 | margin: 0; 26 | } 27 | } 28 | 29 | .header { 30 | user-select: none; 31 | cursor: default; 32 | 33 | &.monster::after { 34 | counter-increment: encounters 1; 35 | content: " " counter(encounters); 36 | } 37 | } 38 | 39 | .formBody { 40 | display: grid; 41 | grid-template-columns: 1fr 1fr; 42 | @media (width < 600px) { grid-template-columns: 1fr } 43 | } 44 | 45 | .creatures { 46 | margin: 1em 0; 47 | gap: 8px; 48 | display: grid; 49 | 50 | .creature { 51 | flex: 1 1 0; 52 | 53 | padding: 8px 1em; 54 | display: flex; 55 | flex-direction: row; 56 | align-items: center; 57 | border-radius: 8px; 58 | transition: background-color 0.3s; 59 | gap: 8px; 60 | 61 | &:hover { background: #fff1 } 62 | 63 | .name { flex-grow: 1 } 64 | 65 | input[type=number] { 66 | width: 50px; 67 | } 68 | 69 | button { 70 | padding: 1em; 71 | margin: 0; 72 | 73 | label { display: none } 74 | } 75 | 76 | .inlineInput { 77 | display: flex; 78 | align-items: center; 79 | gap: 4px; 80 | } 81 | 82 | @media (width < 450px) { 83 | flex-direction: column; 84 | align-items: flex-start; 85 | border-bottom: 1px solid #fff8; 86 | border-radius: 0; 87 | 88 | .name { font-weight: bold } 89 | 90 | .inlineInput { 91 | width: 100%; 92 | justify-content: space-between; 93 | } 94 | 95 | button { width: 100% } 96 | button label { display: initial } 97 | } 98 | } 99 | } 100 | 101 | .encounterSettings { 102 | padding: 1em 0; 103 | display: flex; 104 | flex-direction: column; 105 | gap: 8px; 106 | } 107 | 108 | .luckSlider { 109 | margin-top: 1em; 110 | display: flex; 111 | gap: 1em; 112 | align-items: center; 113 | 114 | > :last-child { 115 | flex-grow: 1; 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/components/simulation/encounterForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, useState } from "react" 2 | import { Creature, Encounter } from "../../model/model" 3 | import styles from './encounterForm.module.scss' 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import { faChevronDown, faChevronUp, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons" 6 | import CreatureForm from "./../creatureForm/creatureForm" 7 | import { clone } from "../../model/utils" 8 | import Checkbox from "../utils/checkbox" 9 | import Range from "../utils/range" 10 | 11 | type PropType = { 12 | mode: 'player'|'monster', 13 | encounter: Encounter, 14 | onUpdate: (newValue: Encounter) => void, 15 | onDelete?: () => void, 16 | children?: ReactNode, 17 | onMoveUp?: () => void, 18 | onMoveDown?: () => void, 19 | luck: number, 20 | setLuck: (newValue: number) => void, 21 | } 22 | 23 | const EncounterForm:FC = ({ mode, encounter, onUpdate, onDelete, children, onMoveUp, onMoveDown, luck, setLuck }) => { 24 | const [updating, setUpdating] = useState(null) 25 | const [creating, setCreating] = useState(false) 26 | 27 | function createCreature(creature: Creature) { 28 | const encounterClone = clone(encounter) 29 | encounterClone.monsters.push(creature) 30 | onUpdate(encounterClone) 31 | setCreating(false) 32 | } 33 | 34 | function updateCreature(index: number, newValue: Creature) { 35 | const encounterClone = clone(encounter) 36 | encounterClone.monsters[index] = newValue 37 | onUpdate(encounterClone) 38 | setUpdating(null) 39 | } 40 | 41 | function deleteCreature(index: number) { 42 | const encounterClone = clone(encounter) 43 | encounterClone.monsters.splice(index, 1) 44 | onUpdate(encounterClone) 45 | setUpdating(null) 46 | } 47 | 48 | function update(callback: (encounterClone: Encounter) => void) { 49 | const encounterClone = clone(encounter) 50 | callback(encounterClone) 51 | onUpdate(encounterClone) 52 | } 53 | 54 | return ( 55 | <> 56 |
57 |
58 | { !!onDelete && ( 59 | 62 | )} 63 | { (onMoveUp || onMoveDown) && ( 64 | 67 | )} 68 | { (onMoveUp || onMoveDown) && ( 69 | 72 | )} 73 |
74 | 75 |

76 | { (mode === 'player') ? 'Player Characters' : 'Encounter' } 77 |

78 | 79 |
80 |
81 | { encounter.monsters.map((creature, index) => ( 82 |
83 | {creature.name} 84 | 85 | Count: 86 | updateCreature(index, {...creature, count: Math.max(0, Math.min(20, Math.round(Number(e.target.value))))})} 91 | /> 92 | 93 | { !children && 94 | Arrives on round: 95 | updateCreature(index, {...creature, arrival: Math.max(0, Math.min(20, Math.round(Number(e.target.value))))})} /> 100 | } 101 | 105 |
106 | )) } 107 |
108 |
109 | { children || (encounter.monsters.length ? ( 110 | <> 111 | update(e => { e.playersSurprised = !e.playersSurprised })}> 112 | The players are surprised 113 | 114 | update(e => { e.monstersSurprised = !e.monstersSurprised })}> 115 | The enemies are surprised 116 | 117 | { !onDelete ? null : ( 118 | update(e => { e.shortRest = !e.shortRest })}> 119 | The players get a short rest 120 | 121 | )} 122 | 123 | ) : null)} 124 |
125 |
126 | 127 | 131 | 132 | { (mode === "player") && ( 133 |
134 | 137 | 138 |
139 |

140 | Changing this setting allows you to quickly visualize how swingy your encounter is, 141 | by simulating what happens if your players are slightly luckier or slightly more unlucky than average. 142 |

143 |

144 | A luck factor of +1 means instead of rolling 10 on average, the players will roll 11 on average, and their enemies will roll 9 on average. 145 |

146 |
147 | 148 | setLuck(v/100)} 151 | min={35} 152 | max={65} 153 | step={5} 154 | label={ 155 | (luck === 0.5) ? "even" 156 | : (luck > 0.5) ? `+${Math.round(luck * 20 - 10)}` 157 | : String(Math.round(luck * 20 - 10)) 158 | } /> 159 |
160 | )} 161 |
162 | 163 | { (updating === null) ? null : ( 164 | setUpdating(null)} 168 | onSubmit={(newValue) => updateCreature(updating, newValue)} 169 | onDelete={() => deleteCreature(updating)} 170 | /> 171 | )} 172 | 173 | { !creating ? null : ( 174 | setCreating(false)} 177 | onSubmit={createCreature} 178 | /> 179 | )} 180 | 181 | ) 182 | } 183 | 184 | export default EncounterForm -------------------------------------------------------------------------------- /src/components/simulation/encounterResult.module.scss: -------------------------------------------------------------------------------- 1 | .encounterResult { 2 | width: 100%; 3 | background: #544; 4 | border-radius: 0 0 8px 8px; 5 | padding: 1em; 6 | 7 | .lifebars { 8 | display: flex; 9 | flex-direction: row; 10 | gap: 1em; 11 | 12 | .team { 13 | flex: 1 1 0; 14 | display: grid; 15 | gap: 4px; 16 | grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; 17 | 18 | @media (width < 1200px) { grid-template-columns: 1fr 1fr 1fr 1fr 1fr } 19 | @media (width < 1000px) { grid-template-columns: 1fr 1fr 1fr 1fr } 20 | @media (width < 800px) { grid-template-columns: 1fr 1fr 1fr } 21 | @media (width < 600px) { grid-template-columns: 1fr 1fr } 22 | @media (width < 400px) { grid-template-columns: 1fr } 23 | 24 | .lifebar { 25 | position: relative; 26 | 27 | .lifebarBackground { 28 | position: relative; 29 | width: 100%; 30 | height: 2em; 31 | border-radius: 8px; 32 | overflow: hidden; 33 | background: #a55; 34 | 35 | &.highlighted { 36 | outline: 4px solid #f88; 37 | } 38 | 39 | .lifebarForeground, .lifebarTHP { 40 | position: absolute; 41 | top: 0; 42 | left: 0; 43 | height: 100%; 44 | background: #5a5; 45 | } 46 | 47 | .lifebarTHP { 48 | left: unset; 49 | right: 0; 50 | background: #88c; 51 | } 52 | 53 | .lifebarLabel { 54 | position: absolute; 55 | top: 50%; 56 | left: 50%; 57 | transform: translate(-50%, -50%); 58 | } 59 | 60 | } 61 | ul { 62 | margin: 0; 63 | padding-left: 1em; 64 | 65 | b { color: #c88 } 66 | } 67 | 68 | .creatureName { 69 | width: 100%; 70 | text-align: center; 71 | } 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src/components/simulation/encounterResult.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react" 2 | import { Combattant, EncounterResult as EncounterResultType, EncounterStats, FinalAction, Buff, DiceFormula } from "../../model/model" 3 | import styles from './encounterResult.module.scss' 4 | import { Round } from "../../model/model" 5 | import { clone } from "../../model/utils" 6 | 7 | type TeamPropType = { 8 | round: Round, 9 | team: Combattant[], 10 | stats?: Map, 11 | highlightedIds?: string[], 12 | onHighlight?: (targetIds: string[]) => void, 13 | } 14 | 15 | const TeamResults:FC = ({ round, team, stats, highlightedIds, onHighlight }) => { 16 | function getTarget(combattantAction: { action: FinalAction, targets: Map }) { 17 | if (combattantAction.action.target === 'self') return 'itself' 18 | 19 | const allCombattants = [...round.team1, ...round.team2].map(combattant => combattant) 20 | const targetNames = Array.from(combattantAction.targets.entries()).map(([targetId, count]) => { 21 | const targetCombattant = allCombattants.find(combattant => (combattant.id === targetId)) 22 | if (!targetCombattant) { 23 | return null 24 | } 25 | 26 | const creatureName = targetCombattant.creature.name 27 | 28 | if (count === 1) return creatureName 29 | 30 | return creatureName + ' x' + count 31 | }) 32 | .filter(nullable => !!nullable) 33 | 34 | return targetNames.join(' and ') 35 | } 36 | 37 | function getNumberWithSign(n: DiceFormula) { 38 | let result = String(n) 39 | if (!result.startsWith('-')) result = '+' + result 40 | return ' ' + result 41 | } 42 | 43 | function getBuffEffect(buff: Buff) { 44 | const buffEffects: string[] = [] 45 | 46 | if (buff.ac != undefined) buffEffects.push(getNumberWithSign(buff.ac) + ' AC') 47 | if (buff.condition != undefined) buffEffects.push(' ' + buff.condition) 48 | if (buff.damageMultiplier != undefined) buffEffects.push(' x' + buff.damageMultiplier + ' damage') 49 | if (buff.damageTakenMultiplier != undefined) buffEffects.push( ' x' + buff.damageTakenMultiplier + ' damage taken') 50 | if (buff.toHit != undefined) buffEffects.push(getNumberWithSign(buff.toHit) + ' to hit') 51 | if (buff.save != undefined) buffEffects.push(getNumberWithSign(buff.save) + ' to save') 52 | if (buff.damage != undefined) buffEffects.push(getNumberWithSign(buff.damage) + ' extra damage') 53 | if (buff.damageReduction != undefined) buffEffects.push(getNumberWithSign(buff.damageReduction) + ' reduced damage') 54 | 55 | return buffEffects.join(', ') 56 | } 57 | 58 | return ( 59 |
60 | { team.map(combattant => ( 61 |
onHighlight?.(combattant.actions.flatMap(action => Array.from(action.targets.keys())))} 64 | onMouseLeave={() => onHighlight?.([])} 65 | className={`${styles.lifebar} tooltipContainer`}> 66 |
67 |
73 | { combattant.initialState.tempHP ? ( 74 |
80 | ) : null } 81 |
82 | {Math.round(combattant.initialState.currentHP)}/{combattant.creature.hp} 83 | { combattant.initialState.tempHP ? `+${Math.round(combattant.initialState.tempHP)}` : null } 84 |
85 |
86 |
87 | {combattant.creature.name} 88 |
89 | 90 | { (!stats && (combattant.actions.length === 0) && (combattant.finalState.buffs.size)) ? null : ( 91 |
92 |
    93 | { stats ? (() => { 94 | const creatureStats = stats.get(combattant.id) 95 | if (!creatureStats) return <>No Stats 96 | 97 | return ( 98 | <> 99 | {creatureStats.damageDealt ?
  • Damage Dealt: {Math.round(creatureStats.damageDealt)} dmg
  • : null} 100 | {creatureStats.damageTaken ?
  • Damage Taken: {Math.round(creatureStats.damageTaken)} dmg
  • : null} 101 | {creatureStats.healGiven ?
  • Healed allies for: {Math.round(creatureStats.healGiven)} hp
  • : null} 102 | {creatureStats.healReceived ?
  • Was healed for: {Math.round(creatureStats.healReceived)} hp
  • : null} 103 | {creatureStats.timesUnconscious ?
  • Went unconscious: {Math.round(creatureStats.timesUnconscious)} times
  • : null} 104 | {creatureStats.charactersBuffed ?
  • Buffed: {Math.round(creatureStats.charactersBuffed)} allies
  • : null} 105 | {creatureStats.buffsReceived ?
  • Was buffed: {Math.round(creatureStats.buffsReceived)} times
  • : null} 106 | {creatureStats.charactersDebuffed ?
  • Debuffed: {Math.round(creatureStats.charactersDebuffed)} enemies
  • : null} 107 | {creatureStats.debuffsReceived ?
  • Was debuffed: {Math.round(creatureStats.debuffsReceived)} times
  • : null} 108 | 109 | ) 110 | })() : (() => { 111 | const li = combattant.actions 112 | .filter(({ targets }) => !!targets.size) 113 | .map((action, index) => ( 114 |
  • onHighlight?.(Array.from(action.targets.keys()))} 117 | onMouseLeave={() => onHighlight?.(combattant.actions.flatMap(a => Array.from(a.targets.keys())))}> 118 | {action.action.name} on {getTarget(action)} 119 |
  • 120 | )) 121 | 122 | //todo effects that disappear in the same round are not shown, which can be misleading 123 | const buffCount = combattant.finalState.buffs.size 124 | const bi = Array.from(combattant.finalState.buffs) 125 | .filter(([_, buff]) => ((buff.magnitude === undefined) || (buff.magnitude > 0.1))) 126 | .map(([buffId, buff], index) => ( 127 | (buffCount <= 3) ? 128 |
  • 129 | {buff.displayName}{getBuffEffect(buff)} {(buff.magnitude !== undefined && buff.magnitude !== 1) ? ( 130 | `(${Math.round(buff.magnitude * 100)}%)` 131 | ) : ''} 132 |
  • : 133 | <> 134 | {buff.displayName}{(index < buffCount - 1) ? ', ' : null} 135 | 136 | )) 137 | 138 | return ( 139 | <> 140 | {li.length ? li : No Actions} 141 | {bi.length ? <> 142 |
    Active Effects
    143 | {bi} 144 | : null} 145 | 146 | ) 147 | })()} 148 |
149 |
150 | )} 151 |
152 | )) } 153 |
154 | ) 155 | } 156 | 157 | type PropType = { 158 | value: EncounterResultType, 159 | } 160 | 161 | const EncounterResult:FC = ({ value }) => { 162 | const lastRound = clone(value.rounds[value.rounds.length - 1]) 163 | const [highlightedIds, setHighlightedIds] = useState([]) 164 | const [highlightedRound, setHighlightedRound] = useState(0) 165 | 166 | ;([...lastRound.team1, ...lastRound.team2]).forEach(combattant => { 167 | combattant.initialState = combattant.finalState 168 | combattant.actions = [] 169 | }) 170 | 171 | if (value.rounds.length === 1 && (!value.rounds[0].team1.length || !value.rounds[0].team2.length)) return <> 172 | 173 | return ( 174 |
175 | {value.rounds.map((round, roundIndex) => ( 176 |
177 |

Round {roundIndex + 1}

178 | 179 |
180 | { setHighlightedIds(targetIds); setHighlightedRound(roundIndex)}} /> 185 |
186 | { setHighlightedIds(targetIds); setHighlightedRound(roundIndex)}} /> 191 |
192 |
193 | ))} 194 |
195 |

Result

196 | 197 |
198 | 199 |
200 | 201 |
202 |
203 |
204 | ) 205 | } 206 | 207 | export default EncounterResult -------------------------------------------------------------------------------- /src/components/simulation/simulation.module.scss: -------------------------------------------------------------------------------- 1 | .simulation { 2 | flex-grow: 1; 3 | width: 100%; 4 | padding: 1em; 5 | counter-reset: encounters 0; 6 | display:flex; 7 | flex-direction: column; 8 | gap: 2em; 9 | 10 | .header { 11 | user-select: none; 12 | color: #fff; 13 | text-align: center; 14 | margin-bottom: 1em; 15 | 16 | &::before { 17 | display: block; 18 | width: 100%; 19 | height: 0; 20 | box-shadow: 0px 0px 2em 1.5em #977; 21 | content: ''; 22 | } 23 | } 24 | 25 | .addEncounterBtn { margin: 0 } 26 | } -------------------------------------------------------------------------------- /src/components/simulation/simulation.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react" 2 | import { z } from "zod" 3 | import { Creature, CreatureSchema, Encounter, EncounterSchema, SimulationResult } from "../../model/model" 4 | import { clone, useStoredState } from "../../model/utils" 5 | import styles from './simulation.module.scss' 6 | import { runSimulation } from "../../model/simulation" 7 | import EncounterForm from "./encounterForm" 8 | import EncounterResult from "./encounterResult" 9 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 10 | import { faFolder, faPlus, faSave, faTrash } from "@fortawesome/free-solid-svg-icons" 11 | import { semiPersistentContext } from "../../model/simulationContext" 12 | import AdventuringDayForm from "./adventuringDayForm" 13 | 14 | type PropType = { 15 | // TODO 16 | } 17 | 18 | const emptyEncounter: Encounter = { 19 | monsters: [], 20 | monstersSurprised: false, 21 | playersSurprised: false, 22 | } 23 | 24 | const Simulation:FC = ({}) => { 25 | const [players, setPlayers] = useStoredState('players', [], z.array(CreatureSchema).parse) 26 | const [encounters, setEncounters] = useStoredState('encounters', [emptyEncounter], z.array(EncounterSchema).parse) 27 | const [luck, setLuck] = useStoredState('luck', 0.5, z.number().min(0).max(1).parse) 28 | const [simulationResults, setSimulationResults] = useState([]) 29 | const [state, setState] = useState(new Map()) 30 | 31 | function isEmpty() { 32 | const hasPlayers = !!players.length 33 | const hasMonsters = !!encounters.find(encounter => !!encounter.monsters.length) 34 | return !hasPlayers && !hasMonsters 35 | } 36 | 37 | const [saving, setSaving] = useState(false) 38 | const [loading, setLoading] = useState(false) 39 | const [canSave, setCanSave] = useState(false) 40 | useEffect(() => { 41 | setCanSave( 42 | !isEmpty() 43 | && (typeof window !== "undefined") 44 | && !!localStorage 45 | && !!localStorage.getItem('useLocalStorage') 46 | ) 47 | }, [players, encounters]) 48 | 49 | useEffect(() => { 50 | const results = runSimulation(players, encounters, luck) 51 | setSimulationResults(results) 52 | }, [players, encounters, luck]) 53 | 54 | function createEncounter() { 55 | setEncounters([...encounters, { 56 | monsters: [], 57 | monstersSurprised: false, 58 | playersSurprised: false, 59 | }]) 60 | } 61 | 62 | function updateEncounter(index: number, newValue: Encounter) { 63 | const encountersClone = clone(encounters) 64 | encountersClone[index] = newValue 65 | setEncounters(encountersClone) 66 | } 67 | 68 | function deleteEncounter(index: number) { 69 | if (encounters.length <= 1) return // Must have at least one encounter 70 | const encountersClone = clone(encounters) 71 | encountersClone.splice(index, 1) 72 | setEncounters(encountersClone) 73 | } 74 | 75 | function swapEncounters(index1: number, index2: number) { 76 | const encountersClone = clone(encounters) 77 | const tmp = encountersClone[index1] 78 | encountersClone[index1] = encountersClone[index2] 79 | encountersClone[index2] = tmp 80 | setEncounters(encountersClone) 81 | } 82 | 83 | return ( 84 |
85 | 86 |

BattleSim

87 | 88 | setPlayers(newValue.monsters)} 92 | luck={luck} 93 | setLuck={setLuck}> 94 | <> 95 | { !isEmpty() ? ( 96 | 100 | ) : null } 101 | { canSave ? ( 102 | 106 | ) : null} 107 | 111 | { !saving ? null : ( 112 | setSaving(false)} /> 116 | ) } 117 | { !loading ? null : ( 118 | setLoading(false)} 122 | onLoad={(p, e) => { 123 | setPlayers(p) 124 | setEncounters(e) 125 | setLoading(false) 126 | }} /> 127 | ) } 128 | 129 | 130 | 131 | { encounters.map((encounter, index) => ( 132 |
133 | updateEncounter(index, newValue)} 137 | onDelete={(index > 0) ? () => deleteEncounter(index) : undefined} 138 | onMoveUp={(!!encounters.length && !!index) ? () => swapEncounters(index, index-1) : undefined} 139 | onMoveDown={(!!encounters.length && (index < encounters.length - 1)) ? () => swapEncounters(index, index+1) : undefined} 140 | luck={luck} 141 | setLuck={setLuck} 142 | /> 143 | { (!simulationResults[index] ? null : ( 144 | 145 | ))} 146 |
147 | )) } 148 | 149 | 155 |
156 |
157 | ) 158 | } 159 | 160 | export default Simulation -------------------------------------------------------------------------------- /src/components/utils/DecimalInput.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext, useEffect, useState } from "react" 2 | 3 | type PropType = { 4 | value: number|undefined, 5 | onChange: (newValue: number|undefined) => void, 6 | min?: number, 7 | max?: number, 8 | step?: number, 9 | className?: string, 10 | placeholder?: string, 11 | disabled?: string, 12 | } 13 | 14 | const DecimalInput:FC = ({ value, onChange, min, max, step, className, placeholder, disabled }) => { 15 | const [valueString, setValueString] = useState(String(value)) 16 | 17 | const valueNum = +valueString // Can be NaN 18 | const isNumeric = !isNaN(valueNum) 19 | 20 | useEffect(() => { 21 | if (isNumeric) onChange(valueNum) 22 | }, [valueString]) 23 | 24 | return ( 25 | setValueString(e.target.value)} 29 | min={min} 30 | max={max} 31 | step={step} 32 | className={`${className} ${Number.parseInt}`} 33 | placeholder={placeholder} 34 | /> 35 | ) 36 | } 37 | 38 | export default DecimalInput -------------------------------------------------------------------------------- /src/components/utils/checkbox.module.scss: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | background: none!important; 3 | padding: 0; 4 | margin: 0; 5 | text-align: left; 6 | display: flex; 7 | flex-direction: row; 8 | align-items: center; 9 | 10 | svg { 11 | border: 2px solid white; 12 | border-radius: 3px; 13 | padding: 0.2em; 14 | transition: color 0.1s; 15 | } 16 | 17 | &:not(.checked) svg { color: transparent } 18 | } -------------------------------------------------------------------------------- /src/components/utils/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | import styles from './checkbox.module.scss' 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { faCheck } from "@fortawesome/free-solid-svg-icons" 5 | 6 | type PropType = { 7 | value: boolean, 8 | onToggle: () => void, 9 | children?: React.ReactNode, 10 | } 11 | 12 | const Checkbox:FC = ({ value, onToggle, children }) => { 13 | return ( 14 | 20 | ) 21 | } 22 | 23 | export default Checkbox -------------------------------------------------------------------------------- /src/components/utils/diceFormulaInput.module.scss: -------------------------------------------------------------------------------- 1 | .expression { 2 | input { 3 | width: 5ch; 4 | } 5 | } 6 | 7 | .invalid { 8 | outline: 1px solid #f88; 9 | } -------------------------------------------------------------------------------- /src/components/utils/diceFormulaInput.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react" 2 | import { DiceFormula } from "../../model/model" 3 | import styles from './diceFormulaInput.module.scss' 4 | import { evaluateDiceFormula, validateDiceFormula } from "../../model/dice" 5 | 6 | type PropType = { 7 | value: DiceFormula|undefined, 8 | onChange: (newValue: DiceFormula|undefined) => void, 9 | className?: string, 10 | placeholder?: string, 11 | disabled?: boolean, 12 | canCrit?: boolean, 13 | } 14 | 15 | const DiceFormulaInput:FC = ({ value, onChange, className, placeholder, disabled, canCrit }) => { 16 | const [valueString, setValueString] = useState((value === undefined) ? '' : String(value)) 17 | const [isValid, setIsValid] = useState(false) 18 | 19 | useEffect(() => { 20 | setIsValid(validateDiceFormula(valueString)) 21 | onChange(valueString) 22 | }, [valueString]) 23 | 24 | return ( 25 | 27 | setValueString(e.target.value)} 32 | disabled={disabled} 33 | className={`${className} ${isValid ? styles.isValid : styles.invalid}`} 34 | placeholder={placeholder} 35 | /> 36 | { validateDiceFormula(valueString) && (String(evaluateDiceFormula(valueString, 0.5)) !== valueString) ? ( 37 |
38 | Average: {Math.trunc(100*evaluateDiceFormula(valueString, 0.5))/100} { 39 | canCrit && <> 40 | (on crit: {Math.trunc(100*evaluateDiceFormula(valueString, 0.5, { doubleDice: true }))/100}) 41 | 42 | } 43 |
44 | ) : null } 45 |
46 | ) 47 | } 48 | 49 | export default DiceFormulaInput -------------------------------------------------------------------------------- /src/components/utils/footer.module.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | width: 100%; 3 | margin-top: 5em; 4 | padding: 2em; 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | justify-content: space-between; 9 | 10 | .legal { 11 | color: #888; 12 | font-size: 0.7em; 13 | max-width: 50%; 14 | } 15 | 16 | .socials { 17 | display: flex; 18 | flex-direction: row; 19 | align-items: center; 20 | gap: 1em; 21 | 22 | img { width: 36px } 23 | a:first-child img { width: 48px } 24 | } 25 | 26 | @media (width < 600px) { 27 | flex-direction: column; 28 | 29 | > .legal { max-width: unset; padding-bottom: 1em } 30 | } 31 | } -------------------------------------------------------------------------------- /src/components/utils/footer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import styles from './footer.module.scss' 3 | 4 | const Footer:FC<{}> = ({}) => { 5 | return ( 6 |
7 | 8 | BattleSim is a 5e combat simulator by Trekiros. BattleSim is unofficial Fan Content permitted under the Fan Content Policy. Not approved/endorsed by Wizards. Portions of the materials used are property of Wizards of the Coast. ©Wizards of the Coast LLC. 9 | 10 | 11 | 12 | Youtube 13 | 14 | 15 | Discord 16 | 17 | 18 | Twitter 19 | 20 | 21 | Github 22 | 23 | 24 |
25 | ) 26 | } 27 | 28 | export default Footer -------------------------------------------------------------------------------- /src/components/utils/logo.module.scss: -------------------------------------------------------------------------------- 1 | /* COLORS */ 2 | .logo { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | width: 100vw; 7 | height: 100vh; 8 | z-index: 1000; 9 | overflow: hidden; 10 | 11 | --bg: #222; 12 | --pink: #c17ba0; 13 | --anim-duration: 2s; 14 | 15 | background-color: var(--bg); 16 | 17 | animation-timing-function: ease-in-out; 18 | animation-duration: var(--anim-duration); 19 | animation-iteration-count: 1; 20 | animation-fill-mode: forwards; 21 | animation-name: logoAnim; 22 | 23 | @keyframes logoAnim { 24 | 0% { opacity: 1 } 25 | 80% { opacity: 1 } 26 | 99% { visibility: initial } 27 | 100% { opacity: 0; visibility: hidden } 28 | } 29 | 30 | .bookContainer { 31 | animation-timing-function: ease-in-out; 32 | animation-duration: var(--anim-duration); 33 | animation-iteration-count: 1; 34 | animation-name: bookAnim; 35 | position: absolute; 36 | top: 50%; 37 | left: 50%; 38 | 39 | @keyframes bookAnim { 40 | 0% { left: -200vh } 41 | 30% {left: 50%; transform: rotate(180deg)} 42 | 60% { transform: rotate(0) } 43 | } 44 | 45 | .book { 46 | background-color: var(--pink); 47 | height: 50vh; 48 | width: 40vh; 49 | position: absolute; 50 | transform: translate(-50%, -50%); 51 | top: 50%; 52 | left: 50%; 53 | } 54 | 55 | .corner { 56 | background-color: var(--pink); 57 | height: 7vh; 58 | width: 7vh; 59 | border: 1vh solid var(--bg); 60 | position: absolute; 61 | transform: translate(-50%, -50%); 62 | 63 | &:nth-child(2) { 64 | border-radius: 0 0 100% 0; 65 | top: calc(50% - 23vh); 66 | left: calc(50% - 18vh); 67 | } 68 | &:nth-child(3) { 69 | border-radius: 0 100% 0 0; 70 | top: calc(50% + 23vh); 71 | left: calc(50% - 18vh); 72 | } 73 | &:nth-child(4) { 74 | border-radius: 0 0 0 100%; 75 | top: calc(50% - 23vh); 76 | left: calc(50% + 18vh); 77 | } 78 | &:nth-child(5) { 79 | border-radius: 100% 0 0 0; 80 | top: calc(50% + 23vh); 81 | left: calc(50% + 18vh); 82 | } 83 | } 84 | 85 | .rim { 86 | background: 87 | -moz-radial-gradient(0 100%, circle, transparent 5vh, white 5vh), 88 | -moz-radial-gradient(100% 100%, circle, transparent 5vh, white 5vh), 89 | -moz-radial-gradient(100% 0, circle, transparent 5vh, white 5vh), 90 | -moz-radial-gradient(0 0, circle, transparent 5vh, white 5vh); 91 | background: 92 | -o-radial-gradient(0 100%, circle, transparent 5vh, white 5vh), 93 | -o-radial-gradient(100% 100%, circle, transparent 5vh, white 5vh), 94 | -o-radial-gradient(100% 0, circle, transparent 5vh, white 5vh), 95 | -o-radial-gradient(0 0, circle, transparent 5vh, white 5vh); 96 | background: 97 | -webkit-radial-gradient(0 100%, circle, transparent 5vh, white 5vh), 98 | -webkit-radial-gradient(100% 100%, circle, transparent 5vh, white 5vh), 99 | -webkit-radial-gradient(100% 0, circle, transparent 5vh, white 5vh), 100 | -webkit-radial-gradient(0 0, circle, transparent 5vh, white 5vh); 101 | background-position: bottom left, bottom right, top right, top left; 102 | -moz-background-size: 50% 50%; 103 | -webkit-background-size: 50% 50%; 104 | background-size: 50% 50%; 105 | background-repeat: no-repeat; 106 | 107 | height: 40vh; 108 | width: 30vh; 109 | position: absolute; 110 | transform: translate(-50%, -50%); 111 | top: 50%; 112 | left: 50%; 113 | 114 | display: flex; 115 | align-items: center; 116 | justify-content: center; 117 | 118 | div { 119 | background: 120 | -moz-radial-gradient(0 100%, circle, transparent 5vh, var(--pink) 5vh), 121 | -moz-radial-gradient(100% 100%, circle, transparent 5vh, var(--pink) 5vh), 122 | -moz-radial-gradient(100% 0, circle, transparent 5vh, var(--pink) 5vh), 123 | -moz-radial-gradient(0 0, circle, transparent 5vh, var(--pink) 5vh); 124 | background: 125 | -o-radial-gradient(0 100%, circle, transparent 5vh, var(--pink) 5vh), 126 | -o-radial-gradient(100% 100%, circle, transparent 5vh, var(--pink) 5vh), 127 | -o-radial-gradient(100% 0, circle, transparent 5vh, var(--pink) 5vh), 128 | -o-radial-gradient(0 0, circle, transparent 5vh, var(--pink) 5vh); 129 | background: 130 | -webkit-radial-gradient(0 100%, circle, transparent 5vh, var(--pink) 5vh), 131 | -webkit-radial-gradient(100% 100%, circle, transparent 5vh, var(--pink) 5vh), 132 | -webkit-radial-gradient(100% 0, circle, transparent 5vh, var(--pink) 5vh), 133 | -webkit-radial-gradient(0 0, circle, transparent 5vh, var(--pink) 5vh); 134 | background-position: bottom left, bottom right, top right, top left; 135 | -moz-background-size: 50% 50%; 136 | -webkit-background-size: 50% 50%; 137 | background-size: 50% 50%; 138 | background-repeat: no-repeat; 139 | 140 | height: calc(40vh - 8px); 141 | width: calc(30vh - 8px); 142 | display: flex; 143 | align-items: center; 144 | justify-content: center; 145 | 146 | div { 147 | background-color: var(--pink); 148 | width: 50%; 149 | height: 100%; 150 | } 151 | } 152 | 153 | &:after { 154 | background-color: var(--pink); 155 | } 156 | } 157 | 158 | .ribbonContainer { 159 | position: absolute; 160 | top: calc(50% + 26vh); 161 | left: calc(50% - 13vh); 162 | z-index: -1; 163 | 164 | .ribbon { 165 | background-color: var(--pink); 166 | height: 7vh; 167 | width: 5vh; 168 | 169 | animation-timing-function: ease-in-out; 170 | animation-duration: var(--anim-duration); 171 | animation-iteration-count: 1; 172 | 173 | animation-name: ribbonAnim; 174 | position: absolute; 175 | top: 50%; 176 | 177 | @keyframes ribbonAnim { 178 | 0% { height: 0; opacity: 0 } 179 | 50% { height: 0 } 180 | 54% { opacity: 0 } 181 | 55% { opacity: 1 } 182 | 75% { height: 7vh } 183 | 100% { height: 7vh } 184 | } 185 | 186 | &:after { 187 | content: ""; 188 | position: absolute; 189 | bottom: 0; 190 | left: 0; 191 | border: 2.5vh solid transparent; 192 | border-bottom-color: var(--bg); 193 | } 194 | } 195 | } 196 | } 197 | 198 | .playBtnContainer { 199 | animation-timing-function: ease-in-out; 200 | animation-duration: var(--anim-duration); 201 | animation-iteration-count: 1; 202 | animation-name: playAnim; 203 | position: absolute; 204 | top: 50%; 205 | right: 50%; 206 | 207 | @keyframes playAnim { 208 | 0% { right: -200vh } 209 | 30% { right: 50%; transform: rotate(-180deg) } 210 | 60% { transform: rotate(0) } 211 | } 212 | 213 | .playBtn { 214 | width: 0; 215 | height: 0; 216 | border-top: 6vh solid transparent; 217 | border-bottom: 6vh solid transparent; 218 | border-left: 6vh solid white; 219 | transform: translate(-50%, -50%) scale(3, 1); 220 | position: absolute; 221 | top: 50%; 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/components/utils/logo.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | import styles from './logo.module.scss' 3 | 4 | type PropType = { 5 | // TODO 6 | } 7 | 8 | const Logo: FC = (props) => { 9 | return ( 10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 |
35 | ) 36 | } 37 | 38 | export default Logo -------------------------------------------------------------------------------- /src/components/utils/modal.module.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | .overlay { 4 | position: fixed; 5 | z-index: 1; 6 | width: 100%; 7 | height: 100%; 8 | padding: 4em 2em; 9 | top: 0; 10 | left: 0; 11 | background: #fff3; 12 | display: flex; 13 | 14 | overflow-x: hidden; 15 | @include scrollable; 16 | 17 | @include fadeIn; 18 | 19 | .closeBtn { 20 | position: absolute; 21 | top: 0; 22 | right: 0; 23 | color: black; 24 | margin: 0; 25 | background: none!important; 26 | transition: color 0.3s, scale 0.3s; 27 | padding: 0.5em; 28 | font-size: 1.5em; 29 | 30 | &:hover { color: #333; scale: 1.5 } 31 | } 32 | } 33 | 34 | .modal { 35 | margin: auto 0; 36 | width: 100%; 37 | padding: 1em; 38 | border-radius: 8px; 39 | background: #544; 40 | } -------------------------------------------------------------------------------- /src/components/utils/modal.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from "react" 2 | import styles from './modal.module.scss' 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { faClose } from "@fortawesome/free-solid-svg-icons" 5 | 6 | type PropType = { 7 | className?: string, 8 | onCancel: () => void, 9 | children: ReactNode, 10 | } 11 | 12 | const Modal:FC = ({ onCancel, children, className }) => { 13 | return ( 14 |
15 | 18 | 19 |
20 | {children} 21 | 22 |
23 |
24 | ) 25 | } 26 | 27 | export default Modal -------------------------------------------------------------------------------- /src/components/utils/range.module.scss: -------------------------------------------------------------------------------- 1 | .range { 2 | padding: 8px 0; 3 | display: flex; 4 | flex-direction: row; 5 | align-items: center; 6 | 7 | label { width: 40px; font-weight: normal; &:last-child { text-align: end } } 8 | .thumb { height: 16px; width: 8px; background: #c88 } 9 | .track { height: 4px; background: white; flex-grow: 1 } 10 | .mark { height: 12px; width: 3px; background: white; border-radius: 4px; @media (width < 450px) { display: none } } 11 | } -------------------------------------------------------------------------------- /src/components/utils/range.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | import { Range as ReactRange } from "react-range" 3 | import styles from './range.module.scss' 4 | 5 | 6 | type PropType = { 7 | min: number, 8 | max: number, 9 | step?: number, 10 | label?: string, 11 | upperLabel?: string, 12 | } & ( 13 | { 14 | value: number, 15 | values?: never, 16 | onChange: (newValue: number) => void, 17 | } | { 18 | value?: never, 19 | values: number[], 20 | onChange: (newValue: number[]) => void, 21 | } 22 | ) 23 | 24 | const Range:FC = ({ min, max, step, value, values, onChange, label, upperLabel }) => { 25 | return ( 26 |
27 | { label ? : null } 28 | values ? onChange(newValues) : onChange(newValues[0])} 34 | renderThumb={({ props }) => ( 35 |
36 | )} 37 | renderTrack={({ props, children }) => ( 38 |
39 | {children} 40 |
41 | )} 42 | renderMark={({ props }) => ( 43 |
44 | )} 45 | /> 46 | { upperLabel ? : null } 47 |
48 | ) 49 | } 50 | 51 | export default Range -------------------------------------------------------------------------------- /src/components/utils/rgpd.module.scss: -------------------------------------------------------------------------------- 1 | 2 | .rgpd { 3 | position: fixed; 4 | z-index: 5; 5 | bottom: 0; 6 | left: 0; 7 | padding: 2em; 8 | width: 100%; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | text-align: center; 15 | 16 | background: #544; 17 | box-shadow: 0 0 100px 30px black; 18 | font-size: large; 19 | 20 | &:not(.visible) { 21 | display:none; 22 | } 23 | 24 | .buttons { 25 | margin-top: 2em; 26 | } 27 | } -------------------------------------------------------------------------------- /src/components/utils/rgpd.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | import styles from './rgpd.module.scss' 3 | 4 | type PropType = {} 5 | 6 | const RGPD: FC = () => { 7 | const [visible, setVisible] = useState(true) 8 | 9 | useEffect(() => { 10 | if (!localStorage) return 11 | 12 | const useLocalStorage = localStorage.getItem('useLocalStorage') 13 | 14 | if (useLocalStorage !== null) { 15 | setVisible(false) 16 | } 17 | }) 18 | 19 | function handleAgree() { 20 | setVisible(false) 21 | localStorage.setItem('useLocalStorage', 'true') 22 | } 23 | 24 | function handleDisagree() { 25 | setVisible(false) 26 | } 27 | 28 | return !visible ? null : ( 29 |
30 | Do you want to use the local storage of your web browser to save your encounters, for the next time you use this website? 31 |
32 | 33 | 34 |
35 |
36 | ) 37 | } 38 | 39 | export default RGPD -------------------------------------------------------------------------------- /src/components/utils/select.module.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | .select { 4 | .control { 5 | background: #fff1; 6 | border: none; 7 | border-radius: 8px; 8 | box-shadow: none; 9 | 10 | &:hover { background: #fff3 } 11 | } 12 | 13 | .input { color: white } 14 | .singleValue { color: white } 15 | .indicator { color: white } 16 | 17 | .menu { 18 | margin: 0; 19 | background: #544; 20 | border-radius: 8px; 21 | box-shadow: 0px 10px 10px 0px #000a; 22 | 23 | animation-name: slideIn; 24 | animation-duration: 300ms; 25 | animation-iteration-count: 1; 26 | animation-fill-mode: forwards; 27 | 28 | @keyframes slideIn { 29 | 0% { transform: translateY(-50%) scaleY(0) } 30 | 100% { transform: translateY(0) scaleY(1) } 31 | } 32 | } 33 | 34 | .menuList { 35 | max-height: 10em; 36 | @include scrollable; 37 | } 38 | 39 | // Options 40 | .isSelected { background: #fff3!important } 41 | .isFocused { background: #fff1!important } 42 | } -------------------------------------------------------------------------------- /src/components/utils/select.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import ReactSelect, { ClassNamesConfig } from 'react-select' 3 | import styles from './select.module.scss' 4 | 5 | type PropType = { 6 | value: T, 7 | options: { value: T, label: string }[], 8 | onChange: (newValue: T) => void, 9 | } 10 | 11 | const SelectStyles: ClassNamesConfig = { 12 | control: () => styles.control, 13 | input: () => styles.input, 14 | singleValue: () => styles.singleValue, 15 | menu: () => styles.menu, 16 | menuList: () => styles.menuList, 17 | option: ({ isSelected, isFocused }) => isSelected ? styles.isSelected : isFocused ? styles.isFocused : '', 18 | dropdownIndicator: () => styles.indicator, 19 | } 20 | 21 | function getEntry(options: {value: T, label: string}[], value: T) { 22 | const entry = options.find(option => (option.value === value)) 23 | return entry 24 | } 25 | 26 | const Select = ({ value, options, onChange }: PropType): JSX.Element => { 27 | const [search, setSearch] = useState('') 28 | 29 | return ( 30 | !e ? null : onChange(e.value)} 36 | inputValue={search} 37 | onInputChange={setSearch} 38 | /> 39 | ) 40 | } 41 | 42 | export default Select -------------------------------------------------------------------------------- /src/components/utils/sortTable.module.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | .searchResults { 4 | border-radius: 8px; 5 | outline: 4px solid #fff1; 6 | 7 | .header { 8 | width: 100%; 9 | background-color: #fff1; 10 | border-radius: 8px 8px 0 0; 11 | padding: 0.5em 1em; 12 | display: flex; 13 | flex-direction: row; 14 | 15 | >div { 16 | flex: 1 1 0; 17 | padding: 0.5em; 18 | 19 | svg { width: 1em; margin-left: 1em } 20 | } 21 | } 22 | 23 | .result { 24 | height: 40vh; 25 | min-height: 200px; 26 | overflow-x: hidden; 27 | @include scrollable; 28 | 29 | .placeholder { width: 100%; padding: 2em; text-align: center; color: #aaa; user-select: none; } 30 | 31 | .elem { 32 | width: 100%; 33 | text-align: left; 34 | margin: 0; 35 | border-radius: 0; 36 | background: transparent; 37 | &:hover { background: #fff1 } 38 | &.active { background: #fff3 } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/components/utils/sortTable.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react" 2 | import styles from './sortTable.module.scss' 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons" 5 | 6 | type PropType = { 7 | value: T|undefined, 8 | list: T[], 9 | onChange: (newValue: T) => void, 10 | comparators: {[name: string]: (a: T, b: T) => number}, 11 | children: (elem: T) => ReactNode, 12 | } 13 | 14 | const SortTable = ({ value, list, onChange, comparators, children }: PropType): JSX.Element => { 15 | const [criteria, setCriteria] = useState(Object.keys(comparators).at(0)!) 16 | const [direction, setDirection] = useState<'asc'|'desc'>('asc') 17 | 18 | function toggleCompare(newCriteria: string) { 19 | if (criteria === newCriteria) { 20 | setDirection(direction === 'asc' ? 'desc' : 'asc') 21 | } else { 22 | setCriteria(newCriteria) 23 | setDirection('asc') 24 | } 25 | } 26 | 27 | const sortedList = list.sort((a,b) => { 28 | const comparator = comparators[criteria] 29 | const comparisonResult = comparator(a, b) 30 | return direction === 'asc' ? comparisonResult : -comparisonResult 31 | }) 32 | 33 | return ( 34 |
35 |
36 | { Array.from(Object.entries(comparators)).map(([name]) => ( 37 |
toggleCompare(name)}> 38 | {name} 39 | { criteria === name ? ( 40 | 41 | ) : null } 42 |
43 | )) } 44 |
45 |
46 | { sortedList.length === 0 ? ( 47 |
48 | No results 49 |
50 | ) : ( 51 | sortedList.map((elem, index) => ( 52 | 58 | )) 59 | )} 60 |
61 |
62 | ) 63 | } 64 | 65 | export default SortTable -------------------------------------------------------------------------------- /src/data/actions.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { Action, FinalAction } from "../model/model" 3 | import { ActionSlots, AllyTarget, EnemyTarget } from '../model/enums'; 4 | 5 | export const ActionTemplates = { 6 | 'Bane': createTemplate({ 7 | actionSlot: ActionSlots.Action, 8 | type: 'debuff', 9 | targets: 3, 10 | buff: { 11 | duration: 'entire encounter', 12 | toHit: '-1d4', 13 | save: '-1d4', 14 | }, 15 | 16 | saveDC: 0, // Replaced later 17 | }), 18 | 'Bless': createTemplate({ 19 | actionSlot: ActionSlots.Action, 20 | type: 'buff', 21 | targets: 3, 22 | buff: { 23 | duration: 'entire encounter', 24 | toHit: '1d4', 25 | save: '1d4', 26 | }, 27 | }), 28 | 'Fireball': createTemplate({ 29 | actionSlot: ActionSlots.Action, 30 | type: 'atk', 31 | targets: 2, 32 | useSaves: true, 33 | dpr: '8d6', 34 | halfOnSave: true, 35 | 36 | toHit: 0, 37 | }), 38 | 'Heal': createTemplate({ 39 | actionSlot: ActionSlots.Action, 40 | type: 'heal', 41 | targets: 1, 42 | amount: 70, 43 | }), 44 | 'Hypnotic Pattern': createTemplate({ 45 | actionSlot: ActionSlots.Action, 46 | type: 'debuff', 47 | targets: 2, 48 | 49 | saveDC: 0, 50 | buff: { 51 | duration: '1 round', 52 | condition: 'Incapacitated', 53 | }, 54 | }), 55 | 'Meteor Swarm': createTemplate({ 56 | actionSlot: ActionSlots.Action, 57 | type: 'atk', 58 | targets: 4, 59 | useSaves: true, 60 | dpr: '20d6 + 20d6', 61 | halfOnSave: true, 62 | 63 | toHit: 0, 64 | }), 65 | 'Shield': createTemplate({ 66 | actionSlot: ActionSlots.Reaction, 67 | type: 'buff', 68 | targets: 1, 69 | target: 'self', 70 | buff: { 71 | duration: '1 round', 72 | ac: 5, 73 | }, 74 | }), 75 | } 76 | 77 | type RealOmit = { [P in keyof T as P extends K ? never : P]: T[P] }; 78 | type ActionTemplate = RealOmit & { target?: AllyTarget|EnemyTarget } 79 | 80 | // For type safety 81 | function createTemplate(action: ActionTemplate): ActionTemplate { 82 | return action 83 | } 84 | 85 | // Fetches the template if it's a TemplateAction 86 | export function getFinalAction(action: Action): FinalAction { 87 | if (action.type !== 'template') return action 88 | 89 | const { freq, condition } = action 90 | const { toHit, saveDC, target, templateName } = action.templateOptions 91 | 92 | const template: ActionTemplate = ActionTemplates[templateName] 93 | 94 | const result = { 95 | ...template, 96 | id: action.id, 97 | name: templateName, 98 | freq, 99 | condition, 100 | target: template.target || target as any, 101 | templateName, 102 | } 103 | 104 | if (result.type === 'atk') { 105 | if (toHit !== undefined) result.toHit = toHit 106 | 107 | if (result.riderEffect && (saveDC !== undefined)) result.riderEffect.dc = saveDC 108 | } 109 | 110 | if (result.type === 'debuff') { 111 | if (saveDC !== undefined) result.saveDC = saveDC 112 | } 113 | 114 | return result 115 | } -------------------------------------------------------------------------------- /src/model/classOptions.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod' 2 | 3 | // TODO: add more options 4 | 5 | export const ArtificerOptions = z.object({ 6 | 7 | }) 8 | 9 | export const BarbarianOptions = z.object({ 10 | weaponBonus: z.number(), 11 | gwm: z.boolean(), 12 | }) 13 | 14 | export const BardOptions = z.object({ 15 | 16 | }) 17 | 18 | export const ClericOptions = z.object({ 19 | 20 | }) 21 | 22 | export const DruidOptions = z.object({ 23 | 24 | }) 25 | 26 | export const FighterOptions = z.object({ 27 | weaponBonus: z.number(), 28 | gwm: z.boolean(), 29 | }) 30 | 31 | export const MonkOptions = z.object({ 32 | 33 | }) 34 | 35 | export const PaladinOptions = z.object({ 36 | weaponBonus: z.number(), 37 | gwm: z.boolean(), 38 | }) 39 | 40 | export const RangerOptions = z.object({ 41 | ss: z.boolean(), 42 | weaponBonus: z.number(), 43 | }) 44 | 45 | export const RogueOptions = z.object({ 46 | ss: z.boolean(), 47 | weaponBonus: z.number(), 48 | }) 49 | 50 | export const SorcererOptions = z.object({ 51 | 52 | }) 53 | 54 | export const WarlockOptions = z.object({ 55 | 56 | }) 57 | 58 | export const WizardOptions = z.object({ 59 | 60 | }) 61 | 62 | export default { 63 | artificer: ArtificerOptions, 64 | barbarian: BarbarianOptions, 65 | bard: BardOptions, 66 | cleric: ClericOptions, 67 | druid: DruidOptions, 68 | fighter: FighterOptions, 69 | monk: MonkOptions, 70 | paladin: PaladinOptions, 71 | ranger: RangerOptions, 72 | rogue: RogueOptions, 73 | sorcerer: SorcererOptions, 74 | warlock: WarlockOptions, 75 | wizard: WizardOptions, 76 | } as const 77 | 78 | export const ClassOptionsSchema = z.union([ 79 | ArtificerOptions, 80 | BarbarianOptions, 81 | BardOptions, 82 | ClericOptions, 83 | DruidOptions, 84 | FighterOptions, 85 | MonkOptions, 86 | PaladinOptions, 87 | RangerOptions, 88 | RogueOptions, 89 | SorcererOptions, 90 | WarlockOptions, 91 | WizardOptions, 92 | ]) -------------------------------------------------------------------------------- /src/model/dice.ts: -------------------------------------------------------------------------------- 1 | import { AnyRoll, DiceRoller, FullRoll, GroupedRoll, InlineExpression, MathExpression, MathOperation, NumberType, RollExpression, RollExpressionType, RollOrExpression, RootType } from "dice-roller-parser" 2 | import { range } from "./utils" 3 | 4 | /** Options applied to the root evaluation, and used if the appropriate condition is met */ 5 | export interface EvaluationOptions { 6 | doubleDice: boolean; // If enabled, all dice will be doubled, but flat modifiers won't (useful for handling crits) 7 | } 8 | 9 | const roller = new DiceRoller() 10 | export function validateDiceFormula(expr: number|string) { 11 | if (typeof expr === 'number' || !isNaN(+expr)) return true 12 | 13 | try { 14 | const roll = roller.roll(expr.trim()) 15 | return true 16 | } catch (e) { 17 | return false 18 | } 19 | } 20 | 21 | export function evaluateDiceFormula(expr: number|string, luck: number, options: EvaluationOptions = { doubleDice: false }): number { 22 | if (typeof expr === 'number') return expr 23 | if (!isNaN(+expr)) return Number(expr) 24 | 25 | if (!validateDiceFormula(expr)) throw 'invalid dice expression' 26 | 27 | const roll = roller.parse(expr.trim()) 28 | 29 | return evaluateUnknown(roll, luck, options); 30 | } 31 | 32 | function evaluateUnknown(roll: RootType|AnyRoll|RollExpression|RollOrExpression, luck: number, options: EvaluationOptions): number { 33 | switch (roll.type) { 34 | case "number": return evaluateNumber(roll as NumberType, options) 35 | case "die": return evaluateDie(roll as FullRoll, luck, options) 36 | case "group": return evaluateGroup(roll as GroupedRoll, luck, options) 37 | case "expression": return evaluateMathExpr(roll as MathExpression, luck, options) 38 | case "diceExpression": return evaluateDiceExpression(roll as RollExpressionType, luck, options) 39 | case "inline": return evaluateInline(roll as InlineExpression, luck, options) 40 | 41 | // TODO: handle other types of rolls, if there's demand for it 42 | default: return 0 43 | } 44 | } 45 | 46 | function evaluateNumber(roll: NumberType, options: EvaluationOptions): number { 47 | return roll.value 48 | } 49 | 50 | function evaluateDie(roll: FullRoll, luck: number, options: EvaluationOptions) { 51 | const countMultiplier = options.doubleDice ? 2 : 1 52 | const count = countMultiplier * ( 53 | (roll.count.type === 'number') ? evaluateNumber(roll.count, options) : evaluateMathExpr(roll.count, luck, options) 54 | ) 55 | 56 | const die = (roll.die.type === "number") ? evaluateNumber(roll.die, options) 57 | : (roll.die.type === 'expression') ? evaluateMathExpr(roll.die, luck, options) 58 | : 0 59 | 60 | let result = count * (die + 1) * luck 61 | 62 | // TODO: handle multiple mods simultaneously instead of replacing the result 63 | for (let mod of roll.mods || []) { 64 | if (mod.type === 'explode') { 65 | result *= die / (die - 1) 66 | } else if (mod.type === 'keep') { 67 | const keep = evaluateUnknown(mod.expr, luck, options) 68 | 69 | result = keepHighestLowest(count, die, keep, (mod.highlow === "l") ? 'min' : 'max', luck, options) 70 | } else if (mod.type === 'drop') { 71 | const drop = evaluateUnknown(mod.expr, luck, options) 72 | 73 | result = keepHighestLowest(count, die, count - drop, (mod.highlow === "l") ? 'max' : 'min', luck, options) 74 | } 75 | } 76 | 77 | return result 78 | } 79 | 80 | // Returns an array with the probabilities each face would need to have, for the luck factor to be respected 81 | // For example, if you roll a d4 and have a luck factor of 0.6, that means you want the average result to be the 60th percentile for a d4: 82 | // Target average: 1 + (4-1) * 0.6 = 2.8 83 | // So this function will return the following array: [0.16, 0.22, 0.28, 0.34], which means the weighted die should have 16% chance to roll a 1, 22% chance to roll a 2, etc. 84 | // This is useful to evaluate advanced dice expressions like 1d8! (exploding dice) or 4d6dl1 (drop lowest/highest) while taking the luck factor into account. 85 | function weightedDie(diceSize: number, luck: number) { 86 | // Implementation details: 87 | // A simple model was chosen where each face n has a chance to show up equal to A+nB, where A and B are constants that depend on the dice size (N) and the luck factor (L). 88 | // The values of A and B were calculated using two equations: 89 | // 1) The weighted average (sum from 1 to N of n(A+nB)) should be equal to the target average (1 + (N-1)*L) 90 | // 2) The sum of all faces' probabilities (sum from 1 to N of A+nB) should be equal to 1 91 | // Then, wolfram alpha was used to simplify the values of A and B. 92 | 93 | // The results can be negative for extreme values of L, but everything works fine for values close to 0.5, 94 | // which is okay because extreme luck/extreme unluck scenarios aren't useful for users to simulate 95 | 96 | const A = (4 - 6 * luck) / diceSize 97 | const B = (6 * (2 * luck - 1)) / (diceSize * (diceSize + 1)) 98 | 99 | return range(diceSize).map(i => { 100 | const n = i+1 101 | return Math.min(1, Math.max(0, A + B*n)) 102 | }) 103 | } 104 | 105 | function keepHighestLowest(diceCount: number, diceSize: number, keepCount: number, which: 'min'|'max', luck: number, options: EvaluationOptions) { 106 | if (diceSize === 0) return 0 107 | 108 | const sort = (a: number, b: number) => (which === 'min') ? (a-b) : (b-a) 109 | const weights = weightedDie(diceSize, luck) 110 | 111 | const results = new Map() 112 | const possibleRolls = BigInt(diceSize) ** BigInt(diceCount) 113 | 114 | // If the possible rolls would be too computationally intensive, we cap it, and approximate the result instead using random rolls. 115 | const maxRolls = 100_000 116 | if (possibleRolls * BigInt(diceCount) > BigInt(maxRolls)) { 117 | const iterations = maxRolls / diceCount 118 | for (let i = 0; i< iterations; i++) { 119 | const result = range(diceCount) 120 | .map(() => { 121 | let seed = Math.random() 122 | for (let i = 0 ; i < diceSize ; i++) { 123 | const result = i+1 124 | const weight = weights[i] 125 | 126 | if (seed < weight) return result 127 | else seed -= weight 128 | } 129 | return diceSize // Default, should never occur 130 | }) 131 | .sort(sort) 132 | .slice(0, keepCount) 133 | .reduce((a,b) => (a+b), 0) 134 | 135 | results.set(result, 1 + (results.get(result) || 0)) 136 | } 137 | } 138 | 139 | // Otherwise, we compute every result to find the exact average 140 | else { 141 | const rolls = range(diceCount).map(() => 1) 142 | function increment() { 143 | // A base N counter which overflows. This ensures we go through every single possible composite roll 144 | for (let i = diceCount - 1 ; i >= 0 ; i--) { 145 | rolls[i]++ 146 | if (rolls[i] > diceSize) rolls[i] = 1 147 | else return; 148 | } 149 | } 150 | 151 | // Loop until every roll is the max value and record every single result 152 | while (rolls.find(roll => roll < diceSize)) { 153 | const result = rolls.slice() 154 | .sort(sort) 155 | .slice(0, keepCount) 156 | .reduce((a,b) => (a+b), 0) 157 | 158 | const odds = rolls.map(roll => weights[roll - 1]) 159 | .reduce((a,b) => (a*b), 1) 160 | 161 | results.set(result, (results.get(result) || 0) + odds) 162 | 163 | increment() 164 | } 165 | } 166 | 167 | // Calculate average result 168 | let sum = 0 169 | let rolls = 0 170 | for (let [result, count] of results) { 171 | sum += result * count 172 | rolls += count 173 | } 174 | 175 | const average = sum / rolls 176 | return average 177 | } 178 | 179 | function evaluateGroup(group: GroupedRoll, luck: number, options: EvaluationOptions): number { 180 | return group.rolls.map(roll => evaluateUnknown(roll, luck, options)) 181 | .reduce((a,b) => (a+b)) 182 | } 183 | 184 | function evaluateDiceExpression(expr: RollExpressionType, luck: number, options: EvaluationOptions): number { 185 | const head = evaluateUnknown(expr.head, luck, options) 186 | 187 | const tails = expr.ops 188 | .map(op => { 189 | const tail = evaluateUnknown(op.tail, luck, options) 190 | 191 | return (op.op === '+') ? tail : -tail 192 | }) 193 | .reduce((a,b) => a+b) 194 | 195 | return head + tails 196 | } 197 | 198 | function evaluateInline(expr: InlineExpression, luck: number, options: EvaluationOptions): number { 199 | const unknown = expr.expr as RootType 200 | return evaluateUnknown(unknown, luck, options) 201 | } 202 | 203 | function evaluateMathExpr(expr: MathExpression, luck: number, options: EvaluationOptions): number { 204 | const head = evaluateUnknown(expr.head, luck, options) 205 | 206 | let last = head 207 | let sum = 0 208 | for (let {tail, op} of expr.ops) { 209 | const tailValue = evaluateUnknown(tail, luck, options) 210 | 211 | if (op === "+") { 212 | sum += last 213 | last = tailValue 214 | } else if (op === "-") { 215 | sum += last 216 | last = -tailValue 217 | } else if (op === "*") { 218 | last *= tailValue 219 | } else if (op === "/") { 220 | last /= tailValue 221 | } 222 | } 223 | sum += last 224 | 225 | return sum 226 | } -------------------------------------------------------------------------------- /src/model/enums.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const ActionSlots = { 4 | // Positive numbers indicate regular actions taken on the creature's turn 5 | 'Action': 0, 6 | 'Bonus Action': 1, 7 | 'Reaction': 4, 8 | 'Legendary Action': 2, 9 | 'Lair Action': 3, 10 | 'Other 1': 5, 11 | 'Other 2': 6, 12 | 13 | // Negative numbers indicate special triggers that are not affected by the 1 per turn per action slot clause 14 | 'When Reduced to 0 HP': -1, 15 | 'When reducing an enemy to 0 HP': -2, 16 | 'Before the Encounter Starts': -3, 17 | } as const 18 | 19 | export const EnemyTargetList = [ 20 | 'enemy with least HP', 21 | 'enemy with most HP', 22 | 'enemy with highest DPR', 23 | 'enemy with lowest AC', 24 | 'enemy with highest AC', 25 | ] as const 26 | export const EnemyTargetSchema = z.enum(EnemyTargetList) 27 | export type EnemyTarget = z.infer 28 | 29 | export const AllyTargetList = [ 30 | 'ally with the least HP', 31 | 'ally with the most HP', 32 | 'ally with the highest DPR', 33 | 'ally with the lowest AC', 34 | 'ally with the highest AC', 35 | 'self', 36 | ] as const 37 | export const AllyTargetSchema = z.enum(AllyTargetList) 38 | export type AllyTarget = z.infer 39 | 40 | export const ActionConditionList = [ 41 | 'default', 42 | 'ally at 0 HP', 43 | 'ally under half HP', 44 | 'is available', 45 | 'is under half HP', 46 | 'has no THP', 47 | 'not used yet', 48 | 'enemy count one', 49 | 'enemy count multiple' 50 | ] as const 51 | export const ActionConditionSchema = z.enum(ActionConditionList) 52 | export type ActionCondition = z.infer 53 | 54 | export const CreatureConditionList = [ 55 | 'Blinded', 56 | //'Charmed', 57 | //'Deafened', 58 | 'Frightened', 59 | // 'Grapple', 60 | 'Incapacitated', 61 | 'Invisible', 62 | 'Paralyzed', 63 | 'Petrified', 64 | 'Poisoned', 65 | 'Restrained', 66 | 'Stunned', 67 | 'Unconscious', 68 | 'Exhausted', 69 | 70 | 'Attacks with Advantage', 71 | 'Attacks with Disadvantage', 72 | 'Is attacked with Advantage', 73 | 'Is attacked with Disadvantage', 74 | 'Attacks and is attacked with Advantage', 75 | 'Attacks and saves with Disadvantage', 76 | 'Saves with Advantage', 77 | 'Save with Disadvantage', 78 | ] as const 79 | export const CreatureConditionSchema = z.enum(CreatureConditionList) 80 | export type CreatureCondition = z.infer 81 | 82 | export const ActionTypeList = ['atk', 'heal', 'buff', 'debuff', 'template'] as const 83 | export const ActionTypeSchema = z.enum(ActionTypeList) 84 | export type ActionType = z.infer 85 | 86 | export const BuffDurationList = ['until next attack made', 'until next attack taken', '1 round', 'repeat the save each round', 'entire encounter'] as const 87 | export const BuffDurationSchema = z.enum(BuffDurationList) 88 | export type BuffDuration = z.infer 89 | 90 | export const ClassesList = [ 'artificer', 'barbarian', 'bard', 'cleric', 'druid', 'fighter', 'monk', 'paladin', 'ranger', 'rogue', 'sorcerer', 'warlock', 'wizard' ] as const 91 | export const ClassesSchema = z.enum(ClassesList) 92 | export type Class = z.infer 93 | 94 | export const CreatureTypeList = [ 'aberration', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'giant', 'humanoid', 'monstrosity', 'ooze', 'plant', 'undead' ] as const 95 | export const CreatureTypeSchema = z.enum(CreatureTypeList) 96 | export type CreatureType = z.infer 97 | 98 | export const ChallengeRatingList = ['0', '1/8', '1/4', '1/2', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '—'] as const 99 | export const ChallengeRatingSchema = z.enum(ChallengeRatingList) 100 | export type ChallengeRating = z.infer 101 | export function numericCR(cr: ChallengeRating) { 102 | switch (cr) { 103 | case '—': return 0; 104 | case '1/8': return 1/8; 105 | case '1/4': return 1/4; 106 | case '1/2': return 1/2; 107 | default: return Number(cr); 108 | } 109 | } -------------------------------------------------------------------------------- /src/model/model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { AllyTargetSchema, BuffDurationSchema, ChallengeRatingSchema, ClassesSchema, ActionConditionSchema, CreatureTypeSchema, EnemyTargetSchema, CreatureConditionSchema, EnemyTargetList, AllyTargetList } from './enums' 3 | import { ClassOptionsSchema } from './classOptions' 4 | import { validateDiceFormula } from './dice' 5 | import { ActionTemplates } from '../data/actions' 6 | 7 | export const DiceFormulaSchema = z.number().or(z.custom((data) => { 8 | if (typeof data !== 'string') return false 9 | 10 | return validateDiceFormula(data) 11 | })) 12 | 13 | export const FrequencyList = ['at will', '1/fight', '1/day'] as const 14 | export const FrequencySchema = z.enum(FrequencyList).or(z.discriminatedUnion('reset', [ 15 | z.object({ 16 | reset: z.literal('recharge'), 17 | cooldownRounds: z.number().min(2), 18 | }), 19 | z.object({ 20 | reset: z.enum(['sr', 'lr']), 21 | uses: z.number().min(1) 22 | }) 23 | ])) 24 | export type Frequency = z.infer; 25 | 26 | const BuffSchema = z.object({ 27 | displayName: z.string().optional(), 28 | duration: BuffDurationSchema, 29 | 30 | ac: DiceFormulaSchema.optional(), 31 | toHit: DiceFormulaSchema.optional(), 32 | damage: DiceFormulaSchema.optional(), 33 | damageReduction: DiceFormulaSchema.optional(), 34 | damageMultiplier: z.number().optional(), 35 | damageTakenMultiplier: z.number().optional(), 36 | dc: DiceFormulaSchema.optional(), 37 | save: DiceFormulaSchema.optional(), 38 | condition: CreatureConditionSchema.optional(), 39 | 40 | // Odds that the buff was applied. All of the effects are multiplied by this value. Default 1. 41 | magnitude: z.number().optional(), 42 | }) 43 | 44 | // Not to be used directly. See ActionSchema 45 | const ActionSchemaBase = z.object({ 46 | id: z.string(), 47 | name: z.string(), 48 | actionSlot: z.number(), // Can only take 1 action for each action slot per turn, e.g. action slot 0 is all actions, and action slot 1 is all bonus actions 49 | freq: FrequencySchema, 50 | condition: ActionConditionSchema, 51 | targets: z.number(), 52 | }) 53 | 54 | const AtkActionSchema = ActionSchemaBase.merge(z.object({ 55 | type: z.literal('atk'), 56 | dpr: DiceFormulaSchema, 57 | toHit: DiceFormulaSchema, 58 | target: EnemyTargetSchema, 59 | useSaves: z.boolean().optional(), // If false or undefined, action.targets becomes the number of hits, and the action can now target the same creature multiple times 60 | halfOnSave: z.boolean().optional(), // Only useful if useSaves == true 61 | 62 | // TODO: add other types of rider effects, like extra damage if the target fails a save, for example 63 | riderEffect: z.object({ 64 | dc: z.number(), // TODO: make it so if the dc is undefined, the rider effect applies without a save 65 | buff: BuffSchema, 66 | }).optional(), 67 | })) 68 | 69 | const HealActionSchema = ActionSchemaBase.merge(z.object({ 70 | type: z.literal('heal'), 71 | amount: DiceFormulaSchema, 72 | tempHP: z.boolean().optional(), 73 | target: AllyTargetSchema, 74 | })) 75 | 76 | const BuffActionSchema = ActionSchemaBase.merge(z.object({ 77 | type: z.literal('buff'), 78 | target: AllyTargetSchema, 79 | 80 | buff: BuffSchema, 81 | })) 82 | 83 | const DebuffActionSchema = ActionSchemaBase.merge(z.object({ 84 | type: z.literal('debuff'), 85 | target: EnemyTargetSchema, 86 | saveDC: z.number(), 87 | 88 | buff: BuffSchema, 89 | })) 90 | 91 | type ActionTemplateName = keyof typeof ActionTemplates 92 | 93 | const TemplateActionSchema = z.object({ 94 | type: z.literal('template'), 95 | id: z.string(), 96 | freq: FrequencySchema, 97 | condition: ActionConditionSchema, 98 | 99 | templateOptions: z.object({ 100 | target: AllyTargetSchema.or(EnemyTargetSchema).optional(), 101 | templateName: z.custom(data => (typeof data === 'string')), 102 | toHit: DiceFormulaSchema.optional(), 103 | saveDC: z.number().optional(), 104 | }).refine(data => { 105 | const template = ActionTemplates[data.templateName] 106 | if (!template) return false 107 | 108 | // Attacks and debuffs need extra info 109 | if (template.type === 'atk') { 110 | if (data.toHit === undefined) return false 111 | if (template.riderEffect && (data.saveDC === undefined)) return false 112 | } 113 | 114 | if ((template.type === 'debuff') && (data.saveDC === undefined)) return false 115 | 116 | // Check that this has right kind of target 117 | if (!template.target) { 118 | if ((template.type === 'atk') || (template.type === 'debuff')) { 119 | if (!EnemyTargetList.includes(data.target as any)) return false 120 | } 121 | else if ((template.type === 'buff') || (template.type === 'heal')) { 122 | if (!AllyTargetList.includes(data.target as any)) return false 123 | } 124 | } 125 | 126 | return true 127 | }), 128 | }) 129 | 130 | // Like a regular Action, but without the possibility of it being a TemplateAction 131 | export const FinalActionSchema = z.discriminatedUnion('type', [ 132 | HealActionSchema, 133 | AtkActionSchema, 134 | BuffActionSchema, 135 | DebuffActionSchema, 136 | ]) 137 | 138 | const ActionSchema = z.discriminatedUnion('type', [ 139 | HealActionSchema, 140 | AtkActionSchema, 141 | BuffActionSchema, 142 | DebuffActionSchema, 143 | TemplateActionSchema, 144 | ]) 145 | 146 | // Creature is the definition of the creature. It's what the user inputs. 147 | // Combattant (see below) is the representation of a creature during the simulation. 148 | // A new combattant is created for each instance of the creature, and for each round of combat. 149 | export const CreatureSchema = z.object({ 150 | id: z.string(), 151 | arrival: z.number().optional(), // Which round is the creature added (optional, default: round 0) 152 | 153 | mode: z.enum(['player', 'monster', 'custom']), // This determines which UI is opened when the user clicks on "modify creature" 154 | 155 | // Properties for monsters. Not used by the simulator, but by the monster search UI. 156 | type: CreatureTypeSchema.optional(), 157 | cr: ChallengeRatingSchema.optional(), 158 | src: z.string().optional(), 159 | 160 | // Properties for player characters. Not used by the simulator, but used by the PC template UI. 161 | class: z.object({ 162 | type: ClassesSchema, 163 | level: z.number(), 164 | options: ClassOptionsSchema, 165 | }).optional(), 166 | 167 | // Properties of the creature, which are used by the simulator 168 | name: z.string(), 169 | count: z.number(), 170 | hp: z.number(), 171 | AC: z.number(), 172 | saveBonus: z.number(), // Average save bonus. Using this to simplify the input, even if it makes the result slightly less accurate. 173 | actions: z.array(ActionSchema), 174 | }) 175 | 176 | const TeamSchema = z.array(CreatureSchema) 177 | 178 | const CreatureStateSchema = z.object({ 179 | currentHP: z.number(), 180 | tempHP: z.number().optional(), 181 | buffs: z.map(z.string(), BuffSchema), 182 | remainingUses: z.map(z.string(), z.number()), 183 | upcomingBuffs: z.map(z.string(), BuffSchema), 184 | usedActions: z.set(z.string()), 185 | }) 186 | 187 | const CombattantSchema = z.object({ 188 | id: z.string(), 189 | creature: CreatureSchema, 190 | initialState: CreatureStateSchema, 191 | finalState: CreatureStateSchema, 192 | 193 | // Actions taken by the creature on that round. Initially empty, will be filled by the simulator 194 | actions: z.array(z.object({ 195 | action: FinalActionSchema, 196 | targets: z.map(z.string(), z.number()), 197 | })), 198 | }) 199 | 200 | const RoundSchema = z.object({ 201 | team1: z.array(CombattantSchema), 202 | team2: z.array(CombattantSchema), 203 | }) 204 | 205 | export const EncounterSchema = z.object({ 206 | monsters: TeamSchema, 207 | playersSurprised: z.boolean().optional(), 208 | monstersSurprised: z.boolean().optional(), 209 | shortRest: z.boolean().optional(), 210 | }) 211 | 212 | const EncounterStatsSchema = z.object({ 213 | damageDealt: z.number(), 214 | damageTaken: z.number(), 215 | 216 | healGiven: z.number(), 217 | healReceived: z.number(), 218 | 219 | charactersBuffed: z.number(), 220 | buffsReceived: z.number(), 221 | 222 | charactersDebuffed: z.number(), 223 | debuffsReceived: z.number(), 224 | 225 | timesUnconscious: z.number(), 226 | }) 227 | 228 | const EncounterResultSchema = z.object({ 229 | stats: z.map(z.string(), EncounterStatsSchema), 230 | rounds: z.array(RoundSchema), 231 | }) 232 | const SimulationResultSchema = z.array(EncounterResultSchema) 233 | 234 | export type DiceFormula = z.infer 235 | export type Buff = z.infer 236 | export type EnemyTarget = z.infer 237 | export type AllyTarget = z.infer 238 | export type AtkAction = z.infer 239 | export type HealAction = z.infer 240 | export type BuffAction = z.infer 241 | export type DebuffAction = z.infer 242 | export type TemplateAction = z.infer 243 | export type Action = z.infer 244 | export type FinalAction = z.infer 245 | export type Creature = z.infer 246 | export type Team = z.infer 247 | export type CreatureState = z.infer 248 | export type Combattant = z.infer 249 | export type Round = z.infer 250 | export type EncounterStats = z.infer 251 | export type Encounter = z.infer 252 | export type EncounterResult = z.infer 253 | export type SimulationResult = z.infer -------------------------------------------------------------------------------- /src/model/simulationContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const semiPersistentContext = createContext({ 4 | state: new Map(), 5 | setState: (newValue: Map) => {}, 6 | }) -------------------------------------------------------------------------------- /src/model/utils.tsx: -------------------------------------------------------------------------------- 1 | import { DependencyList, FC, ReactNode, createContext, useContext, useEffect, useState } from "react" 2 | import { semiPersistentContext } from "./simulationContext" 3 | 4 | export function clone(obj: T): T { 5 | return structuredClone(obj) 6 | } 7 | 8 | // A wrapper for useState which automatically backs up the state into the localStorage if the user has agreed to it 9 | export function useStoredState(key: string, defaultValue: T, parser: (str: string) => T|null) { 10 | const [state, setState] = useState(defaultValue) 11 | 12 | useEffect(() => { 13 | if (!localStorage) return 14 | 15 | const storedValue = localStorage.getItem(key) 16 | if (storedValue === null) return 17 | 18 | try { 19 | const parsedValue = parser(JSON.parse(storedValue)) 20 | if (parsedValue !== null) setState(parsedValue) 21 | else console.error('Could not parse', key, 'from localStorage') 22 | } catch (e) { 23 | console.error(e) 24 | } 25 | }, []) 26 | 27 | const stateSaver = (newValue: T) => { 28 | setState(newValue) 29 | 30 | if (!localStorage) return 31 | 32 | const useLocalStorage = localStorage.getItem('useLocalStorage') 33 | if (useLocalStorage !== null) localStorage.setItem(key, JSON.stringify(newValue)) 34 | } 35 | 36 | return [state, stateSaver] as const 37 | } 38 | 39 | // The state will be shared between identical components, even if the component is unmounted 40 | // Useful for example to save search params in a modal, and re-load those same search params later, without having to save them in local storage, or in a parent component. 41 | // Do not overuse, because the performances aren't great. 42 | export function sharedStateGenerator(componentName: string) { 43 | const {state, setState} = useContext(semiPersistentContext) 44 | let key = 0 45 | 46 | // This variable exists so if a single function calls multiple setters, they don't overwrite one another 47 | const sharedState = clone(state) 48 | 49 | return function useSharedState(initialValue: T) { 50 | const callKey = key++ 51 | const mapKey = `${componentName}/${callKey}` 52 | 53 | async function setter(newValue: T) { 54 | sharedState.set(mapKey, {value: newValue}) 55 | await setState(sharedState) 56 | } 57 | 58 | const existingValue = state.get(mapKey)?.value 59 | const value: T = (existingValue === undefined) ? initialValue : existingValue 60 | 61 | return [ value, setter ] as const 62 | } 63 | } 64 | 65 | export function useCalculatedState(generator: () => T, dependencies: DependencyList) { 66 | const [state, setState] = useState(generator()) 67 | 68 | useEffect(() => { 69 | setState(generator()) 70 | }, dependencies) 71 | 72 | return state 73 | } 74 | 75 | // Returns an array of numbers from 0 to n 76 | export function range(n: number) { 77 | return Array.from(Array(n).keys()) 78 | } 79 | 80 | // Capitalizes The First Letter Of Every Word 81 | export function capitalize(str: string) { 82 | const words = str.split(' ') 83 | return words.map(word => { 84 | const firstLetter = word.charAt(0) 85 | const otherLetters = word.substring(1) 86 | return firstLetter.toLocaleUpperCase() + otherLetters.toLocaleLowerCase() 87 | }).join(' ') 88 | } 89 | 90 | // Sort by multiple criteria, e.g. first sort by challenge rating, then by name 91 | export function multiSort(arr: T[], ...criteria: (keyof T)[]) { 92 | return arr.sort((a, b) => { 93 | for (let i = 0 ; i < criteria.length; i++) { 94 | const criterion = criteria[i] 95 | 96 | if (a[criterion] > b[criterion]) return 1 97 | if (a[criterion] < b[criterion]) return -1 98 | } 99 | return 0 100 | }) 101 | } 102 | 103 | // Can be useful for debug purposes 104 | let inDevEnvironment = false; 105 | if (process && process.env.NODE_ENV === 'development') { 106 | inDevEnvironment = true; 107 | } 108 | export {inDevEnvironment}; 109 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../../styles/globals.scss' 2 | import type { AppProps } from 'next/app' 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/index.module.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/src/pages/index.module.scss -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import React from 'react' 3 | import RGPD from '../components/utils/rgpd' 4 | import Logo from '../components/utils/logo' 5 | import Simulation from '../components/simulation/simulation' 6 | import Footer from '../components/utils/footer' 7 | 8 | export default function Home() { 9 | return ( 10 | <> 11 | 12 | Battle Sim 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 |
23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin scrollable { 2 | overflow-y: auto; 3 | 4 | // Firefox 5 | scrollbar-color: #888 #fff1; 6 | 7 | // Chromium 8 | &::-webkit-scrollbar { width: 12px; } 9 | &::-webkit-scrollbar-track { background: #fff1 } 10 | &::-webkit-scrollbar-thumb { background: #888 } 11 | } 12 | 13 | @mixin fadeIn { 14 | animation-name: fadeIn; 15 | animation-duration: 0.3s; 16 | animation-timing-function: ease; 17 | animation-fill-mode: forwards; 18 | animation-iteration-count: 1; 19 | 20 | @keyframes fadeIn { 21 | 0% { opacity: 0 } 22 | 100% { opacity: 1 } 23 | } 24 | } 25 | 26 | // Macro for buttons & other clickable elements 27 | @mixin clickable { 28 | text-align: center; 29 | border-radius: 8px; 30 | border: none; 31 | color: #eee; 32 | padding: 1em 2em; 33 | margin: 8px; 34 | font-weight: bold; 35 | cursor: pointer; 36 | user-select: none; 37 | 38 | background-color: #fff1; 39 | transition: background-color 0.3s; 40 | 41 | &:hover:not(.disabled) { background-color: #fff3 } 42 | 43 | &:disabled { 44 | background: transparent; 45 | color: #aaa; 46 | cursor: initial; 47 | pointer-events: none; 48 | } 49 | 50 | svg { 51 | margin: 0 1em; 52 | width: 1em; 53 | } 54 | } -------------------------------------------------------------------------------- /styles/_shared.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trekiros/battleSim/5d2ed1775db3cbcf0c821ca74f924cd140b73b6e/styles/_shared.scss -------------------------------------------------------------------------------- /styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import '/styles/mixins'; 2 | 3 | // General style & layout 4 | html { @include scrollable } 5 | html, body, #__next, main {min-height: 100vh; background: #433; color: #eee; margin: 0; padding:0;} 6 | main { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | padding: 0 20px; 11 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 12 | } 13 | 14 | main, input, button { font-size: 10pt; } 15 | 16 | .content { 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | } 21 | 22 | div, button { 23 | box-sizing: border-box; 24 | } 25 | 26 | h1, h2 { margin: 0 } 27 | button { 28 | position: relative; 29 | @include clickable; 30 | } 31 | 32 | input { 33 | padding: 1em; 34 | border: none; 35 | outline: none; 36 | border-radius: 8px; 37 | color: white; 38 | background: #fff1; 39 | transition: background-color 0.3s; 40 | &:hover { background: #fff3 } 41 | &::placeholder { color: #aaa } 42 | } 43 | 44 | .tooltipContainer { 45 | position: relative; 46 | 47 | &:hover { 48 | .tooltip { 49 | display: initial; 50 | opacity: 1; 51 | } 52 | } 53 | 54 | .tooltip { 55 | position: absolute; 56 | bottom: calc(100% + 8px); 57 | left: 50%; 58 | transform: translateX(-50%); 59 | min-width: 200px; 60 | padding: 1em; 61 | border-radius: 8px; 62 | background: #433; 63 | z-index: 1; 64 | display: none; 65 | opacity: 0; 66 | transition: opacity 0.3s; 67 | box-shadow: 0 10px 20px 2px #fff1; 68 | 69 | &::before { 70 | content: ""; 71 | width: 0; 72 | border-top: 8px solid #433; 73 | border-left: 8px solid transparent; 74 | border-right: 8px solid transparent; 75 | position: absolute; 76 | top: 100%; 77 | left: 50%; 78 | transform: translateX(-50%); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/dice.d.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------