import clsx from "clsx"; import { FC, HTMLAttributes, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Accordion } from "~/components/Accordion"; import { HtmlContent } from "~/components/HtmlContent"; import { Link } from "~/components/Link"; import { PreferenceProgressionTypeEnum as progressionType } from "~/constants"; import { orderBy } from "~/helpers/sortUtils"; import { useCharacterEngine } from "~/hooks/useCharacterEngine"; import { useSource } from "~/hooks/useSource"; import { navigationConfig } from "~/tools/js/CharacterBuilder/config"; import { Button } from "../../../../components/Button"; import { ConfirmClassModal } from "../../components/ConfirmClassModal"; import { Listing } from "../../components/Listing"; import { PortraitName } from "../../components/PortraitName"; import { Search } from "../../components/Search"; import { Spinner } from "../../components/Spinner"; import { RouteKey } from "../../constants"; import { useClassContext } from "../../contexts/Class"; import { getMissingRequirements } from "../../helpers/getMissingRequirements"; import { ClassDefinitionContract, ClassItems, ClassRequirements, ListingItem, MulticlassAvailability, } from "../../types"; import styles from "./styles.module.css"; //showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx export interface ClassChooseProps extends HTMLAttributes { showHeader?: boolean; onQuickSelect?: (charClass: ClassDefinitionContract) => void; } export interface SourceGroupMapping { name: string; id: number; items: ClassDefinitionContract[]; sortOrder: number | undefined; isOpen: boolean; } /** * The page which lists all available classes for the user to choose * from when building or updating a character. Can be found at * `/characters/:characterId/builder/class/choose`. */ export const ClassChoose: FC = ({ showHeader = true, className, onQuickSelect, ...props }) => { const navigate = useNavigate(); const { classes: charClasses, preferences, prerequisiteData, startingClass, totalClassLevel, currentLevel, characterId, classUtils, helperUtils, prerequisiteUtils: { validatePrerequisiteGrouping: validateGrouping, getPrerequisiteGroupingFailures: getGroupingFailures, }, } = useCharacterEngine(); const { allSources, getSourceCategoryDescription, getSourceCategoryGroups } = useSource(); const { filteredClasses, allClasses, handleSelectClass, query, setQuery, isLoading, isModalShowing, closeModal, } = useClassContext(); const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState< SourceGroupMapping[] >([]); useEffect(() => { const sourceCategoryGroups = getSourceCategoryGroups(filteredClasses); setMappedSourceCategoryGroups( sourceCategoryGroups.map((group) => ({ ...group, isOpen: true, })) ); }, [filteredClasses]); // Determine if the character's XP will be adjusted when adding a new class const willXpBeAdjusted = charClasses.length > 0 && preferences.progressionType === progressionType.XP && totalClassLevel + 1 > currentLevel; // Navigate back to the class manage page const handleNavigate = (): void => navigate( navigationConfig .getRouteDefPath(RouteKey.CLASS_MANAGE) .replace(":characterId", characterId) ); // Return requirements for multiclassing based on existing classes const getMulticlassAvailability = ( classes: ClassItems ): Array => { const { enforceMulticlassRules } = preferences; let canStartingClassMulticlass = false; let startingClassRequirements: ClassRequirements = []; if (enforceMulticlassRules) { if (startingClass) { canStartingClassMulticlass = validateGrouping( classUtils.getPrerequisites(startingClass), prerequisiteData ); startingClassRequirements = getGroupingFailures( classUtils.getPrerequisites(startingClass), prerequisiteData ); } } else { canStartingClassMulticlass = true; } return classes.map( ({ id: classId, prerequisites }): MulticlassAvailability => { let canMulticlass = false; const missingRequirements = getGroupingFailures( prerequisites, prerequisiteData ); if (enforceMulticlassRules) { canMulticlass = canStartingClassMulticlass && validateGrouping(prerequisites, prerequisiteData); } else { canMulticlass = true; } return { classId, canStartingClassMulticlass, startingClassRequirements, canMulticlass, missingRequirements, }; } ); }; const getDisabledIds = (classes: ClassItems): Array => { if (startingClass === null || !classes.length) { return []; } // const classDefinitions = classes.map((item) => item.entity); const multiclassAvailability = getMulticlassAvailability(classes); // disable any current classes let currentClassIds = charClasses.map((charClass) => classUtils.getId(charClass) ); // disable any multiclass options that don't meet required prerequisites let canMulticlassIds = multiclassAvailability .filter((classInfo) => !classInfo.canMulticlass) .map((classInfo) => classInfo.classId); return [...currentClassIds, ...canMulticlassIds]; }; // Transform the class items into a format that listing can use const transformClassItems = (data: ClassItems): Array => { let classDefinitions = [...data]; // Get the multiclass availability for each class const availability = getMulticlassAvailability(classDefinitions); return classDefinitions.map((classItem: ClassDefinitionContract) => { const { id, sources, name, portraitAvatarUrl } = classItem; // Find the class in the character's classes const existingClass = charClasses.find( (cls) => classUtils.getId(cls) === id ); // Find the multiclass info for the class const multiclassInfo = availability.find((cls) => cls.classId === id); let metaItems: Array<{ type: string; text: string }> = []; // Add the source descriptions to the meta items if (sources) sources.forEach(({ sourceId }) => { let source = helperUtils.lookupDataOrFallback(allSources, sourceId); // If a source has a description and is toggleable, add it to the meta items if ( source?.description !== null && source?.sourceCategory?.isToggleable ) metaItems.push({ type: "normal", text: source.description, }); }); // If the character is already multiclassed if (charClasses && charClasses.length >= 1) { if (existingClass) { const { level, isStartingClass } = existingClass; const currentLevel = `Level ${level}`; const startingClass = isStartingClass ? " • Starting Class" : ""; // Add the class level to the meta items metaItems.push({ type: "normal", text: `${currentLevel}${startingClass}`, }); } // If the class is the starting class else if (multiclassInfo) { const { canMulticlass, canStartingClassMulticlass, missingRequirements, startingClassRequirements, } = multiclassInfo; // Add an error message to the meta items if (!canMulticlass || !canStartingClassMulticlass) metaItems.push({ type: "error", text: !canStartingClassMulticlass ? `Starting class does not meet multiclass prerequisites: ${getMissingRequirements( startingClassRequirements )}` : !canMulticlass ? `Prerequisites not met: ${getMissingRequirements( missingRequirements )}` : "", }); } } return { id, metaItems, entityTypeId: -1, heading: name || "", image: portraitAvatarUrl || null, onClick: handleSelectClass, entity: classItem, type: "class", }; }); }; const handleOverrideClick = (override): void => { const updatedGroups = mappedSourceCategoryGroups.map((group) => ({ ...group, isOpen: override, })); setMappedSourceCategoryGroups(updatedGroups); }; const handleSourceCategoryClick = (id: string, state: boolean): void => { const parsedId = parseInt(id); const updatedGroups = mappedSourceCategoryGroups.map((group) => ({ ...group, isOpen: group.id === parsedId ? state : group.isOpen, })); setMappedSourceCategoryGroups(updatedGroups); }; return (
{showHeader && ( <>

Choose a Class{startingClass && " to Multiclass"}

{startingClass && ( <>

Learn more about multiclassing{" "} here .

Classes from the 2024 and 2014 rulebooks are not designed to be multiclassed together.

)} {charClasses.length > 0 && (
{willXpBeAdjusted && (

Your XP total will be adjusted when you add your new class.

)}
)} )} {isLoading ? ( ) : ( <> setQuery(e.target.value)} />
{mappedSourceCategoryGroups.length > 0 ? ( mappedSourceCategoryGroups.map((category) => { const description = getSourceCategoryDescription(category.id); const metaItems = description ? [ , ] : []; return ( {category.name}} summaryMetaItems={metaItems} variant="text" resetOpen={allClasses.length !== filteredClasses.length} key={category.id} handleIsOpen={handleSourceCategoryClick} override={category.isOpen} forceShow > ); }) ) : (

No Results Found

)} )}
); };