diff --git a/app/components/pages/utilities/dice-roller/vtm-alert.tsx b/app/components/pages/utilities/dice-roller/vtm-alert.tsx new file mode 100644 index 0000000..012304a --- /dev/null +++ b/app/components/pages/utilities/dice-roller/vtm-alert.tsx @@ -0,0 +1,26 @@ +import { InfoIcon, XIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Alert, AlertAction, AlertDescription, AlertTitle } from '~/components/ui/alert'; +import { Button } from '~/components/ui/button'; + +export default function VtmAlert() { + const [isOpen, setIsOpen] = useState(true); + + return ( + isOpen && ( + + + Important! + + Regular dice are summed with hunger dice to determine the total dice rolled. Hunger dice are a + special type of die that can result in critical successes or failures, so read the rules carefully! + + + + + + ) + ); +} diff --git a/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx b/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx index 8cf96e8..f705461 100644 --- a/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx +++ b/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx @@ -1,75 +1,251 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { type BaseSyntheticEvent, useCallback } from 'react'; +import { floor } from 'mathjs'; +import { type BaseSyntheticEvent, useCallback, useState, useTransition } from 'react'; import { Controller, type Resolver, useForm } from 'react-hook-form'; +import { Link } from 'react-router'; import { z } from 'zod/v4'; +import VtmAlert from '~/components/pages/utilities/dice-roller/vtm-alert'; import { Button } from '~/components/ui/button'; import { ButtonGroup } from '~/components/ui/button-group'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/components/ui/card'; import { Field, FieldError, FieldGroup, FieldLabel } from '~/components/ui/field'; import { Input } from '~/components/ui/input'; import useIsMobile from '~/hooks/use-mobile'; +import { rollDie } from '~/lib/dice-roller.utils'; + +// Dice import +import hungerDieBestialFailure from '~/assets/dice/vtm/Dice_Hunger_BestialFailure.png'; +import hungerDieFailure from '~/assets/dice/vtm/Dice_Hunger_Failure.png'; +import hungerDieMessyCritical from '~/assets/dice/vtm/Dice_Hunger_MessyCritical.png'; +import hungerDieSuccess from '~/assets/dice/vtm/Dice_Hunger_Success.png'; +import regularDieCritical from '~/assets/dice/vtm/Dice_Regular_Critical.png'; +import regularDieFailure from '~/assets/dice/vtm/Dice_Regular_Failure.png'; +import regularDieSuccess from '~/assets/dice/vtm/Dice_Regular_Success.png'; +import { cn } from '~/lib/utils'; + +type DiceRollData = { + regularDiceRoll: number[]; + hungerDiceRoll: number[]; + difficulty: number | undefined; +}; const VtmDiceRollerSchema = z.object({ - totalDice: z.number().min(1).max(20).default(1), - hungerDice: z.number().min(0).max(20).default(0), - difficulty: z.number().min(2).max(10).default(6).optional(), + regularDice: z.coerce.number().min(1).max(10).default(1), + hungerDice: z.coerce.number().min(0).max(5).default(0), + difficulty: z.coerce.number().min(1).max(10).default(3).optional(), }); type VtmDiceRollerSchemaType = z.infer; export default function VtmDiceRoller() { + const [currentRoll, setCurrentRoll] = useState({ + regularDiceRoll: [], + hungerDiceRoll: [], + difficulty: undefined, + }); + const [isPending, startTransition] = useTransition(); const { isMobile } = useIsMobile(); const form = useForm({ resolver: zodResolver(VtmDiceRollerSchema) as Resolver, - defaultValues: { totalDice: 1, hungerDice: 0, difficulty: 6 }, + defaultValues: { regularDice: 1, hungerDice: 0, difficulty: 3 }, }); - const onSubmitFn = useCallback(async (data: VtmDiceRollerSchemaType, event?: BaseSyntheticEvent) => { - const { totalDice, hungerDice, difficulty } = data; - const buttonClicked = (event?.nativeEvent as SubmitEvent | undefined)?.submitter?.id; - - if (buttonClicked === 'roll-dice') { - console.log('Rolling dice with the following parameters:'); - console.log(`Total Dice: ${totalDice}`); - console.log(`Hunger Dice: ${hungerDice}`); - console.log(`Difficulty: ${difficulty}`); - } else if (buttonClicked === 'rouse-check') { - console.log('Performing a rouse check with the following parameters:'); - console.log(`Total Dice: ${totalDice}`); - console.log(`Hunger Dice: ${hungerDice}`); + function rollDice(regularDice: number, hungerDice: number, difficulty: number | undefined, isRouseCheck: boolean) { + if (hungerDice > regularDice && !isRouseCheck) { + alert('Hunger dice cannot exceed total dice.'); + return; + } + + if (difficulty && (difficulty < 1 || difficulty > 10) && !isRouseCheck) { + alert('Difficulty must be between 1 and 10.'); + return; + } + + if ((regularDice < 1 || regularDice > 10) && !isRouseCheck) { + alert('Regular dice must be between 1 and 10.'); + return; + } + + if (hungerDice < 0 || hungerDice > 5) { + alert('Hunger dice must be between 0 and 5.'); + return; + } + + if (difficulty && !isRouseCheck) { + const totalAvailableDice = regularDice + hungerDice; + const maximumPotentialSuccesses = totalAvailableDice * 2; + + if (maximumPotentialSuccesses < difficulty) { + alert('Not enough dice to potentially meet the difficulty.'); + return; + } + } + + setCurrentRoll({ regularDiceRoll: [], hungerDiceRoll: [], difficulty }); + + for (let i = 0; i < regularDice; i++) { + const roll = rollDie(10); + setCurrentRoll((prev) => ({ ...prev, regularDiceRoll: [...prev.regularDiceRoll, roll] })); + } + + for (let i = 0; i < hungerDice; i++) { + const roll = rollDie(10); + setCurrentRoll((prev) => ({ ...prev, hungerDiceRoll: [...prev.hungerDiceRoll, roll] })); } - }, []); + } + + const onSubmitFn = useCallback( + (data: VtmDiceRollerSchemaType, event?: BaseSyntheticEvent) => { + startTransition(async () => { + const { regularDice, hungerDice, difficulty } = data; + const buttonClicked = (event?.nativeEvent as SubmitEvent | undefined)?.submitter?.id; + + switch (buttonClicked) { + case 'roll-dice': + rollDice(regularDice, hungerDice, difficulty, false); + break; + case 'rouse-check': + rollDice(0, 1, 1, true); + break; + default: + throw new Error('Something went wrong. Please try again.'); + } + }); + }, + [startTransition], + ); + + function calculateResults(regularDiceRoll: number[], hungerDiceRoll: number[], difficulty: number | undefined) { + let successCount = 0; + let criticalCount = 0; + let messyCriticalCount = 0; + let bestialFailureCount = 0; + + // Process regular dice rolls + for (const roll of regularDiceRoll) { + if (roll >= 6 && roll < 10) { + successCount++; + } else if (roll === 10) { + criticalCount++; + successCount += 2; // Each critical counts as two successes + } + } + + for (const roll of hungerDiceRoll) { + if (roll >= 6 && roll < 10) { + successCount++; + } else if (roll === 10) { + messyCriticalCount++; + successCount += 2; // Each messy critical counts as two successes + } else if (roll === 1) { + bestialFailureCount++; + } + } + + // Adjust critical successes + const totalCriticals = criticalCount + messyCriticalCount; + const criticalPairs = floor(totalCriticals / 2); + successCount += criticalPairs * 2; // Each pair of criticals counts as two successes + + const hasCritical = criticalPairs >= 1; + const hasMessyCritical = hasCritical && messyCriticalCount > 0; + + // Determine the outcome + let outcome: string; + let outcomeClassName: string; + + const isRouseCheck = regularDiceRoll.length === 0 && hungerDiceRoll.length === 1 && difficulty === 1; + + if (isRouseCheck) { + if (successCount >= 1) { + outcome = 'Rouse Check Success'; + outcomeClassName = cn('bg-green-100 text-green-800'); + } else { + outcome = 'Rouse Check Failure (Increase Hunger by 1)'; + outcomeClassName = cn('bg-red-100 text-red-800'); + if (bestialFailureCount > 0) { + outcome += ' and Bestial Failure (Compulsion Check)'; + outcomeClassName = cn('bg-red-100 text-red-800'); + } + } + + return { outcome, outcomeClassName }; + } + + if (difficulty !== undefined) { + if (successCount >= difficulty) { + if (hasMessyCritical) { + outcome = `Messy Critical Success (${successCount}/${difficulty})`; + outcomeClassName = cn('bg-yellow-100 text-yellow-800'); + } else if (hasCritical) { + outcome = `Critical Success (${successCount}/${difficulty})`; + outcomeClassName = cn('bg-green-100 text-green-800'); + } else { + outcome = `Success (${successCount}/${difficulty})`; + outcomeClassName = cn('bg-green-100 text-green-800'); + } + } else if (bestialFailureCount > 0) { + outcome = `Bestial Failure (${successCount}/${difficulty})`; + outcomeClassName = cn('bg-red-100 text-red-800'); + } else { + outcome = `Failure (${successCount}/${difficulty})`; + outcomeClassName = cn('bg-red-100 text-red-800'); + } + } else { + outcome = `Successes: ${successCount}`; + outcomeClassName = cn('bg-green-100 text-green-800'); + if (hasMessyCritical) { + outcome += `\n Messy Critical Possible`; + } else if (hasCritical) { + outcome += `\n Critical Possible`; + } + } + + return { outcome, outcomeClassName }; + } function reset() { form.reset(); + setCurrentRoll({ regularDiceRoll: [], hungerDiceRoll: [], difficulty: undefined }); } + const hasRolledDice = currentRoll.regularDiceRoll.length > 0 || currentRoll.hungerDiceRoll.length > 0; + return ( Vampire: The Masquerade (5th edition) Dice Roller - Roll normal and hunger dice for Vampire: The Masquerade. + + Roll regular and hunger dice for Vampire: The Masquerade (5th edition) using this simple dice + roller. + - -
+ + + + ( - Total Dice + Regular Dice {fieldState.invalid && ( @@ -92,7 +268,7 @@ export default function VtmDiceRoller() { type="number" id={field.name} min={0} - max={20} + max={5} step={1} inputMode="decimal" placeholder="e.g. 3" @@ -138,19 +314,141 @@ export default function VtmDiceRoller() { /> - - - - + +
+ {hasRolledDice && + (() => { + const { outcome, outcomeClassName } = calculateResults( + currentRoll.regularDiceRoll, + currentRoll.hungerDiceRoll, + currentRoll.difficulty, + ); + + return ( +
+ {outcome} +
+ ); + })()} + {hasRolledDice ? ( +
+
+ {currentRoll.regularDiceRoll.map((roll, index) => { + switch (true) { + case roll < 6: + return ( + {`Regular + ); + case roll >= 6 && roll < 10: + return ( + {`Regular + ); + case roll === 10: + return ( + {`Regular + ); + default: + return null; + } + })} + {currentRoll.hungerDiceRoll.map((roll, index) => { + switch (true) { + case roll > 1 && roll < 6: + return ( + {`Hunger + ); + case roll >= 6 && roll < 10: + return ( + {`Hunger + ); + case roll === 10: + return ( + {`Hunger + ); + case roll === 1: + return ( + {`Hunger + ); + default: + return null; + } + })} +
+
+ ) : ( +

No dice rolled yet.

+ )} +
+ + +

+ Disclaimer: Dice Images from the{' '} + + 5th Edition Art Pack + {' '} + from White Wolf. +

+
); } diff --git a/app/lib/dice-roller.utils.ts b/app/lib/dice-roller.utils.ts new file mode 100644 index 0000000..9f77fe3 --- /dev/null +++ b/app/lib/dice-roller.utils.ts @@ -0,0 +1,11 @@ +import { floor, random } from 'mathjs'; + +function rollDie(sides: number): number { + if (sides < 1) { + throw new Error('Number of sides must be at least 1.'); + } + + return floor(random(1, sides + 1)); +} + +export { rollDie }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e2d619..10fd215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,10 +198,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} @@ -272,11 +268,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} @@ -320,18 +311,10 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.8': resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -3858,14 +3841,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -3875,14 +3858,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.8 @@ -3927,8 +3902,8 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3937,7 +3912,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -3972,11 +3947,7 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.8': dependencies: @@ -4027,20 +3998,8 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/traverse@7.29.8': dependencies: @@ -4054,11 +4013,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7