Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,121 @@
import React from "react";
import { RouteKey } from "~/subApps/builder/constants";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import PageSubHeader from "../../../components/PageSubHeader";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
class AbilityScoresHelp extends React.PureComponent {
render() {
return (
<Page>
<PageBody>
<PageHeader>Determine Ability Scores</PageHeader>
<p>
Much of what your character does in the game depends on their six
abilities: <strong>Strength</strong>, <strong>Dexterity</strong>,{" "}
<strong>Constitution</strong>, <strong>Intelligence</strong>,{" "}
<strong>Wisdom</strong>, and <strong>Charisma</strong>. Each ability
has a score, which is a number you record for use during play.
</p>
<PageHeader>Generation Method</PageHeader>
<p>
You can determine ability scores in one of three ways, chosen in the
next step:
</p>
<PageSubHeader>Standard Array</PageSubHeader>
<p>
If you want to save time or dont like the idea of randomly
determining ability scores, you can choose from a fixed list (15,
14, 13, 12, 10, 8).
</p>
<PageSubHeader>Manual or Random Generation</PageSubHeader>
<p>
Manually enter your ability scores. If you roll to randomly
determine scores, choose this option to record your results. The
standard method is to roll 4d6 and drop the lowest dice roll for
each score. This system is used when you select Roll in the Manual
category.
</p>
<PageSubHeader>Point Cost</PageSubHeader>
<p>
Customize your ability scores by spending points. If you are playing
an Adventurers League character, choose this option. You have 27
points to spend on your ability scores. The cost of each score is
shown on the Ability Score Point Costs table. For example, a score
of 14 costs 7 of your 27 points.
</p>
<h5>Ability Score Point Costs</h5>
<table
className="table-compendium"
style={
{
maxWidth: "420px",
"--sb-table-row-bg-hover": "#b3d3df",
"--sb-table-row-bg-dark": "#d7e8ee",
"--sb-table-row-bg-light": "#f1f7f9",
"--dark-sb-table-row-bg-light": "#5e7982",
"--dark-sb-table-row-bg-dark": "#5c7f8c",
"--dark-sb-table-row-bg-hover": "#78a2b0",
} as React.CSSProperties
}
>
<thead>
<tr>
<th>Score</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td>8</td>
<td>0</td>
</tr>
<tr>
<td>9</td>
<td>1</td>
</tr>
<tr>
<td>10</td>
<td>2</td>
</tr>
<tr>
<td>11</td>
<td>3</td>
</tr>
<tr>
<td>12</td>
<td>4</td>
</tr>
<tr>
<td>13</td>
<td>5</td>
</tr>
<tr>
<td>14</td>
<td>7</td>
</tr>
<tr>
<td>15</td>
<td>9</td>
</tr>
</tbody>
</table>
</PageBody>
</Page>
);
}
}
export default ConnectedBuilderPage(
AbilityScoresHelp,
RouteKey.ABILITY_SCORES_HELP
);
@@ -0,0 +1,4 @@
import AbilityScoresHelp from "./AbilityScoresHelp";
export default AbilityScoresHelp;
export { AbilityScoresHelp };
@@ -0,0 +1,315 @@
import React from "react";
import { DispatchProp } from "react-redux";
import {
Collapsible,
CollapsibleHeaderContent,
CollapsibleHeading,
LightDiceSvg,
} from "@dndbeyond/character-components/es";
import {
AbilityManager,
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiResponse,
characterActions,
CharacterConfiguration,
CharacterPreferences,
CharClass,
ChoiceData,
ClassUtils,
Constants,
DefinitionPool,
DefinitionUtils,
FeatDefinitionContract,
FeatLookup,
OptionalOriginLookup,
PrerequisiteData,
Race,
RaceUtils,
RacialTraitUtils,
RuleData,
RuleDataUtils,
rulesEngineSelectors,
serviceDataSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { Dice } from "@dndbeyond/dice";
import { AbilityScoreManager } from "~/components/AbilityScoreManager";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { useAbilities } from "~/hooks/useAbilities";
import { RouteKey } from "~/subApps/builder/constants";
import { appEnvActions } from "../../../../Shared/actions/appEnv";
import {
apiCreatorSelectors,
appEnvSelectors,
} from "../../../../Shared/selectors";
import AbilityScoreManagerManual from "../../../components/AbilityScoreManagerManual";
import AbilityScoreManagerPointBuy from "../../../components/AbilityScoreManagerPointBuy";
import AbilityScoreManagerStandardArray from "../../../components/AbilityScoreManagerStandardArray";
import { Button } from "../../../components/Button";
import { Page } from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import { PageHeader } from "../../../components/PageHeader";
import SpeciesTraitList from "../../../components/SpeciesTraitList";
import { AbilityScoreDiceManager } from "../../AbilityScoreDiceManager";
import AbilityScoreTypeChooser from "../../AbilityScoreTypeChooser";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props extends DispatchProp {
abilities: AbilityManager[];
configuration: CharacterConfiguration;
diceEnabled: boolean;
species: Race;
featLookup: FeatLookup;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<FeatDefinitionContract[]>>;
choiceInfo: ChoiceData;
prerequisiteData: PrerequisiteData;
preferences: CharacterPreferences;
optionalOriginLookup: OptionalOriginLookup;
definitionPool: DefinitionPool;
charClasses: CharClass[];
ruleData: RuleData;
}
class AbilityScoresManage extends React.PureComponent<Props> {
static defaultProps = {
diceEnabled: false,
};
handleDiceEnable = (): void => {
const { dispatch, diceEnabled } = this.props;
if (diceEnabled) {
return;
}
const newDiceEnabledSetting: boolean = !diceEnabled;
try {
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
Dice.setEnabled(newDiceEnabledSetting);
} catch (e) {}
dispatch(
appEnvActions.dataSet({
diceEnabled: newDiceEnabledSetting,
})
);
};
renderAbilityScoreManager = (): React.ReactNode => {
const { abilities, configuration } = this.props;
switch (configuration.abilityScoreType) {
case Constants.AbilityScoreTypeEnum.MANUAL:
return <AbilityScoreManagerManual abilities={abilities} />;
case Constants.AbilityScoreTypeEnum.STANDARD_ARRAY:
return <AbilityScoreManagerStandardArray abilities={abilities} />;
case Constants.AbilityScoreTypeEnum.POINT_BUY:
return <AbilityScoreManagerPointBuy abilities={abilities} />;
default:
// not implemented
}
return null;
};
renderAbilityDice = (): React.ReactNode => {
const { diceEnabled } = this.props;
if (!diceEnabled) {
return (
<div className="ability-score-dice-control">
<span className="ability-score-dice-control-text">
Enable digital dice to assist with rolling your ability scores:
</span>
<Button size="medium" onClick={this.handleDiceEnable}>
<LightDiceSvg />
<span className="ability-score-dice-control-button-text">
Use Dice
</span>
</Button>
</div>
);
}
let headingNode: React.ReactNode = (
<CollapsibleHeading>Dice Roll Groups</CollapsibleHeading>
);
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent heading={headingNode} />
);
return (
<Collapsible
header={headerNode}
className="ability-score-dice"
initiallyCollapsed={false}
>
<AbilityScoreDiceManager />
</Collapsible>
);
};
handleSpeciesTraitChoiceChange = (
speciesTraitId,
choiceId,
choiceType,
optionValue
): void => {
const { dispatch } = this.props;
dispatch(
characterActions.racialTraitChoiceSetRequest(
speciesTraitId,
choiceType,
choiceId,
optionValue
)
);
};
renderSpeciesTraits() {
const {
species,
featLookup,
loadAvailableFeats,
choiceInfo,
prerequisiteData,
preferences,
optionalOriginLookup,
definitionPool,
} = this.props;
if (!species) {
return null;
}
const speciesTraits = RaceUtils.getVisibleRacialTraits(species);
const filteredSpeciesTraits =
RacialTraitUtils.filterRacialTraitsByDisplayConfigurationType(
speciesTraits,
[Constants.DisplayConfigurationTypeEnum.ABILITY_SCORE]
);
return (
filteredSpeciesTraits.length > 0 && (
<SpeciesTraitList
speciesTraits={filteredSpeciesTraits}
featLookup={featLookup}
loadAvailableFeats={loadAvailableFeats}
handleSpeciesTraitChoiceChange={this.handleSpeciesTraitChoiceChange}
choiceInfo={choiceInfo}
prerequisiteData={prerequisiteData}
preferences={preferences}
optionalOriginLookup={optionalOriginLookup}
definitionPool={definitionPool}
speciesName={RaceUtils.getFullName(species)}
/>
)
);
}
renderInformationCollapsible = (): React.ReactNode => {
const { ruleData, charClasses, species } = this.props;
if (charClasses.length === 0 || species === null) {
return null;
}
let definitionKeys: string[] = [];
if (species) {
definitionKeys.push(
DefinitionUtils.hack__generateDefinitionKey(
RaceUtils.getEntityRaceTypeId(species),
RaceUtils.getEntityRaceId(species)
)
);
}
if (charClasses.length > 0) {
definitionKeys = definitionKeys.concat(
charClasses.map((charClass) =>
DefinitionUtils.hack__generateDefinitionKey(
ClassUtils.getMappingEntityTypeId(charClass),
ClassUtils.getId(charClass)
)
)
);
}
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
definitionKeys,
ruleData,
Constants.DisplayConfigurationTypeEnum.ABILITY_SCORE
);
return <HelperTextAccordion builderHelperText={builderText} />;
};
render() {
const { configuration, abilities } = this.props;
return (
<Page>
<PageBody>
<PageHeader>Ability Scores</PageHeader>
<AbilityScoreTypeChooser initialOptionRemoved={false} />
{this.renderInformationCollapsible()}
{this.renderAbilityScoreManager()}
{configuration.abilityScoreType ===
Constants.AbilityScoreTypeEnum.MANUAL && this.renderAbilityDice()}
{this.renderSpeciesTraits()}
<PageHeader>Score Calculations</PageHeader>
<p>
Calculations, including the base scores you set above and any
modifiers, are found below. You can also override any automatic
calculations or modify them under each ability summary.
</p>
<div className="ability-score-calcs">
{abilities.map((ability) => (
<AbilityScoreManager
ability={ability}
showHeader={true}
isReadonly={false}
isBuilder={true}
/>
))}
</div>
</PageBody>
</Page>
);
}
}
function AbilityScoresManageContainer(props) {
const abilities = useAbilities();
return <AbilityScoresManage {...props} abilities={abilities} />;
}
export default ConnectedBuilderPage(
AbilityScoresManageContainer,
RouteKey.ABILITY_SCORES_MANAGE,
(state) => {
return {
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
species: rulesEngineSelectors.getRace(state),
featLookup: rulesEngineSelectors.getFeatLookup(state),
loadAvailableFeats: apiCreatorSelectors.makeLoadAvailableFeats(state),
choiceInfo: rulesEngineSelectors.getChoiceInfo(state),
prerequisiteData: rulesEngineSelectors.getPrerequisiteData(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
optionalOriginLookup: rulesEngineSelectors.getOptionalOriginLookup(state),
definitionPool: serviceDataSelectors.getDefinitionPool(state),
charClasses: rulesEngineSelectors.getClasses(state),
ruleData: rulesEngineSelectors.getRuleData(state),
};
}
);
@@ -0,0 +1,4 @@
import AbilityScoresManage from "./AbilityScoresManage";
export default AbilityScoresManage;
export { AbilityScoresManage };
@@ -0,0 +1,75 @@
import React from "react";
import { RouteKey } from "~/subApps/builder/constants";
import { CollapsibleContent } from "../../../../../../components/CollapsibleContent";
import { appEnvSelectors } from "../../../../Shared/selectors";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import { BuilderAppState } from "../../../typings";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props {
isMobile: boolean;
}
class ClassHelp extends React.PureComponent<Props> {
render() {
const { isMobile } = this.props;
return (
<Page>
<PageBody>
<PageHeader>Choose your Class</PageHeader>
<CollapsibleContent
forceShow={!isMobile}
heading={
<p>
In the first step, choose a class for your character. Every
adventurer is a member of a class. Class broadly describes a
character's vocation, what special talents they possess, and the
tactics they are most likely to employ when exploring a dungeon,
fighting monsters, or engaging in a tense negotiation.
</p>
}
>
<p>
Your character receives a number of benefits from your chosen
class. These features are capabilities (such as Spellcasting) that
set your character apart from members of other classes. You also
gain a number of proficiencies: weapons, skills, saving throws,
and sometimes tools. Your proficiencies define many of the things
your character can do particularly well, from using certain
weapons to telling a convincing lie.
</p>
</CollapsibleContent>
<PageHeader>Multiclassing</PageHeader>
<p>
Adventurers sometimes advance in more than one class. A Rogue might
switch direction in life and swear the oath of a Paladin, A
Barbarian might discover latent magical ability and dabble in the
Sorcerer class while continuing to advance as a Barbarian.
</p>
<p>
The optional rules for combining classes in this way is called
Multiclassing.
</p>
</PageBody>
</Page>
);
}
}
export default ConnectedBuilderPage(
ClassHelp,
RouteKey.CLASS_HELP,
(state: BuilderAppState) => {
return {
isMobile: appEnvSelectors.getIsMobile(state),
};
}
);
@@ -0,0 +1,4 @@
import ClassHelp from "./ClassHelp";
export default ClassHelp;
export { ClassHelp };
@@ -0,0 +1,484 @@
import React from "react";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiResponse,
BaseSpellContract,
CharacterPreferences,
CharacterTheme,
CharClass,
ChoiceData,
ClassDefinitionContract,
ClassFeatureDefinitionContract,
ClassFeatureUtils,
ClassSpellInfo,
ClassSpellListSpellsLookup,
ClassUtils,
Constants,
DataOriginRefData,
DefinitionPool,
DefinitionUtils,
EntitledEntity,
FeatDefinitionContract,
FeatLookup,
InfusionChoice,
InfusionDefinitionContract,
InventoryManager,
Modifier,
OptionalClassFeatureLookup,
OverallSpellInfo,
PrerequisiteData,
RuleData,
RuleDataUtils,
Spell,
SpellCasterInfo,
TypeValueLookup,
} from "@dndbeyond/character-rules-engine/es";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { TabList } from "~/components/TabList";
import { ClassHeader } from "~/subApps/builder/routes/Class/ClassHeader";
import ClassSpellListManager from "../../../../../Shared/components/legacy/ClassSpellListManager";
import {
ApiClassFeaturesRequest,
ApiSpellsRequest,
} from "../../../../../Shared/selectors/composite/apiCreator";
import ClassManagerFeature from "../ClassManagerFeature";
import { OptionalFeatureManager } from "../OptionalFeatureManager";
interface Props {
charClass: CharClass;
spellCasterInfo: SpellCasterInfo;
overallSpellInfo: OverallSpellInfo;
ruleData: RuleData;
prerequisiteData: PrerequisiteData;
choiceInfo: ChoiceData;
preferences: CharacterPreferences;
globalModifiers: Array<Modifier>;
typeValueLookup: TypeValueLookup;
definitionPool: DefinitionPool;
knownInfusionLookup: Record<string, InfusionChoice>;
knownReplicatedItems: Array<string>;
activeSourceCategories: Array<number>;
onClassFeatureChoiceChange: (
classId: number,
classFeatureId: number,
choiceType: any,
choiceId: string,
optionValue: number | null,
parentChoiceId: string | null
) => void;
onSpellPrepare: (spell: Spell, classMappingId: number) => void;
onSpellUnprepare: (spell: Spell, classMappingId: number) => void;
onSpellRemove: (spell: Spell, classMappingId: number) => void;
onSpellAdd: (spell: Spell, classMappingId: number) => void;
onAlwaysKnownLoad: (
spells: Array<BaseSpellContract>,
classId: number
) => void;
onInfusionChoiceItemChangePromise?: (
infusionChoiceKey: string,
infusionId: string,
itemDefinitionKey: string,
itemName: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceItemDestroyPromise?: (
infusionChoiceKey: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceChangePromise?: (
infusionChoiceKey: string,
infusionId: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceDestroyPromise?: (
infusionChoiceKey: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceCreatePromise?: (
infusionChoiceKey: string,
infusionId: string,
accept: () => void,
reject: () => void
) => void;
onOptionalFeatureSelection: (
definitionKey: string,
affectedClassFeatureDefinitionKey: string | null
) => void;
onChangeReplacementPromise?: (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
) => void;
onRemoveSelectionPromise?: (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
classSpellList: ClassSpellInfo | null;
levelsRemaining: number;
isMulticlass: boolean;
loadAvailableSubclasses: (
baseClassId: number,
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<Array<ClassDefinitionContract>>>;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<Array<FeatDefinitionContract>>>;
loadRemainingSpellList: ApiSpellsRequest | null;
loadAlwaysKnownSpells?: ApiSpellsRequest | null;
loadAvailableOptionalClassFeatures: ApiClassFeaturesRequest | null;
loadAvailableInfusions: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<InfusionDefinitionContract>>
>;
onDefinitionsLoaded?: (
definitions: Array<InfusionDefinitionContract>,
accessTypes: Record<string, number>
) => void;
onFeatureDefinitionsLoaded?: (
definitionData: EntitledEntity<ClassFeatureDefinitionContract>
) => void;
featLookup: FeatLookup;
dataOriginRefData: DataOriginRefData;
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
optionalClassFeatureLookup: OptionalClassFeatureLookup;
theme: CharacterTheme;
inventoryManager: InventoryManager;
}
interface State {
showClassFeatures: boolean;
showAtHigherLevelsClassFeatures: boolean;
}
export default class ClassManager extends React.PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
showClassFeatures: true,
showAtHigherLevelsClassFeatures: false,
};
}
handleAtHigherLevelsTriggerClick = (evt: React.MouseEvent): void => {
this.setState((prevState) => ({
showAtHigherLevelsClassFeatures:
!prevState.showAtHigherLevelsClassFeatures,
}));
};
handleFeatureChoiceChange = (
classFeatureId: number,
choiceId: string,
choiceType: any,
optionValue: number | null,
parentChoiceId: string | null
): void => {
const { onClassFeatureChoiceChange, charClass } = this.props;
if (onClassFeatureChoiceChange) {
onClassFeatureChoiceChange(
ClassUtils.getActiveId(charClass),
classFeatureId,
choiceType,
choiceId,
optionValue,
parentChoiceId
);
}
};
renderInformationCollapsible = (): React.ReactNode => {
const { ruleData, charClass } = this.props;
if (charClass === null) {
return null;
}
const definitionKey = DefinitionUtils.hack__generateDefinitionKey(
ClassUtils.getMappingEntityTypeId(charClass),
ClassUtils.getId(charClass)
);
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
[definitionKey],
ruleData,
Constants.DisplayConfigurationTypeEnum.CLASS_FEATURE
);
return <HelperTextAccordion builderHelperText={builderText} />;
};
renderClassFeatureGroups = (): React.ReactNode => {
const { showClassFeatures, showAtHigherLevelsClassFeatures } = this.state;
const {
charClass,
choiceInfo,
prerequisiteData,
preferences,
loadAvailableSubclasses,
loadAvailableFeats,
typeValueLookup,
globalModifiers,
ruleData,
definitionPool,
loadAvailableInfusions,
onDefinitionsLoaded,
onInfusionChoiceItemChangePromise,
onInfusionChoiceItemDestroyPromise,
onInfusionChoiceChangePromise,
onInfusionChoiceDestroyPromise,
onInfusionChoiceCreatePromise,
featLookup,
knownInfusionLookup,
knownReplicatedItems,
optionalClassFeatureLookup,
inventoryManager,
} = this.props;
if (!showClassFeatures) {
return null;
}
const level = ClassUtils.getLevel(charClass);
const isStartingClass = ClassUtils.isStartingClass(charClass);
const visibleClassFeatures = ClassUtils.getVisibileClassFeatures(charClass);
const orderedVisibleFeatures = ClassUtils.deriveOrderedClassFeatures(
visibleClassFeatures
).filter((feature) => ClassFeatureUtils.getRequiredLevel(feature) <= level);
const classFeatures = ClassUtils.getClassFeatures(charClass);
const orderedClassFeatures =
ClassUtils.deriveOrderedClassFeatures(classFeatures);
const atHigherLevelsClassFeatures = orderedClassFeatures.filter(
(feature) => ClassFeatureUtils.getRequiredLevel(feature) > level
);
return (
<div className="class-manager-features-groups">
{this.renderInformationCollapsible()}
{orderedVisibleFeatures.length > 0 && (
<div className="class-manager-features-group">
<div className="class-manager-features-group-items">
{orderedVisibleFeatures.map((feature) => (
<ClassManagerFeature
key={ClassFeatureUtils.getId(feature)}
charClass={charClass}
feature={feature}
isStartingClass={isStartingClass}
onChoiceChange={this.handleFeatureChoiceChange}
onInfusionChoiceItemChangePromise={
onInfusionChoiceItemChangePromise
}
onInfusionChoiceItemDestroyPromise={
onInfusionChoiceItemDestroyPromise
}
onInfusionChoiceChangePromise={onInfusionChoiceChangePromise}
onInfusionChoiceDestroyPromise={
onInfusionChoiceDestroyPromise
}
onInfusionChoiceCreatePromise={onInfusionChoiceCreatePromise}
onDefinitionsLoaded={onDefinitionsLoaded}
choiceInfo={choiceInfo}
prerequisiteData={prerequisiteData}
preferences={preferences}
loadAvailableInfusions={loadAvailableInfusions}
loadAvailableSubclasses={loadAvailableSubclasses}
loadAvailableFeats={loadAvailableFeats}
typeValueLookup={typeValueLookup}
globalModifiers={globalModifiers}
ruleData={ruleData}
definitionPool={definitionPool}
featLookup={featLookup}
knownReplicatedItems={knownReplicatedItems}
knownInfusionLookup={knownInfusionLookup}
optionalClassFeatureLookup={optionalClassFeatureLookup}
inventoryManager={inventoryManager}
/>
))}
</div>
</div>
)}
{atHigherLevelsClassFeatures.length > 0 && (
<div
className={`class-manager-features-group ${
showAtHigherLevelsClassFeatures
? "class-manager-features-group-opened"
: "class-manager-features-group-collapsed"
}`}
>
<div className="class-manager-features-group-header">
<div
className="class-manager-features-group-heading"
onClick={this.handleAtHigherLevelsTriggerClick}
>
Available at Higher Levels ({atHigherLevelsClassFeatures.length}
)
</div>
<div
className="class-manager-features-group-trigger"
onClick={this.handleAtHigherLevelsTriggerClick}
/>
</div>
{showAtHigherLevelsClassFeatures && (
<div className="class-manager-features-group-items">
{atHigherLevelsClassFeatures.map((feature) => (
<ClassManagerFeature
key={ClassFeatureUtils.getId(feature)}
charClass={charClass}
choiceInfo={choiceInfo}
feature={feature}
isStartingClass={isStartingClass}
isActive={false}
prerequisiteData={prerequisiteData}
preferences={preferences}
loadAvailableInfusions={loadAvailableInfusions}
loadAvailableSubclasses={loadAvailableSubclasses}
loadAvailableFeats={loadAvailableFeats}
typeValueLookup={typeValueLookup}
globalModifiers={globalModifiers}
ruleData={ruleData}
definitionPool={definitionPool}
knownReplicatedItems={knownReplicatedItems}
knownInfusionLookup={knownInfusionLookup}
optionalClassFeatureLookup={optionalClassFeatureLookup}
inventoryManager={inventoryManager}
/>
))}
</div>
)}
</div>
)}
</div>
);
};
renderClassFeatures = (): React.ReactNode => {
const { showClassFeatures } = this.state;
return (
<div
className={`class-manager-features ${
showClassFeatures
? "class-manager-features-opened"
: "class-manager-features-collapsed"
}`}
>
{/*<div className="class-manager-features-header">*/}
{/*<div className="class-manager-features-heading" onClick={this.handleFeaturesTriggerClick}>Class Features</div>*/}
{/*<div className="class-manager-features-trigger" onClick={this.handleFeaturesTriggerClick} />*/}
{/*</div>*/}
{this.renderClassFeatureGroups()}
</div>
);
};
render() {
const {
charClass,
definitionPool,
levelsRemaining,
isMulticlass,
loadRemainingSpellList,
loadAlwaysKnownSpells,
loadAvailableOptionalClassFeatures,
preferences,
classSpellList,
spellCasterInfo,
ruleData,
overallSpellInfo,
onSpellAdd,
onSpellRemove,
onSpellPrepare,
onSpellUnprepare,
onAlwaysKnownLoad,
dataOriginRefData,
onOptionalFeatureSelection,
onRemoveSelectionPromise,
onChangeReplacementPromise,
onFeatureDefinitionsLoaded,
optionalClassFeatureLookup,
theme,
activeSourceCategories,
} = this.props;
return (
<div className="class-manager">
<ClassHeader
charClass={charClass}
isMulticlass={isMulticlass}
levelsRemaining={levelsRemaining}
/>
<TabList
variant="toggle"
defaultActiveId={isMulticlass ? "none" : "features"}
tabs={[
{
label: "Class Features",
content: this.renderClassFeatures(),
id: "features",
},
preferences.enableOptionalClassFeatures
? {
label: "Optional Feature Manager",
content: (
<OptionalFeatureManager
charClass={charClass}
definitionPool={definitionPool}
optionalClassFeatureLookup={optionalClassFeatureLookup}
onDefinitionsLoaded={onFeatureDefinitionsLoaded}
loadAvailableOptionalClassFeatures={
loadAvailableOptionalClassFeatures
}
onSelection={onOptionalFeatureSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
ruleData={ruleData}
/>
),
id: "optional-features",
}
: null,
classSpellList
? {
label: "Spells",
content: (
<ClassSpellListManager
{...(classSpellList as any)}
loadRemainingSpellList={loadRemainingSpellList}
loadAlwaysKnownSpells={loadAlwaysKnownSpells}
onSpellPrepare={onSpellPrepare}
onSpellUnprepare={onSpellUnprepare}
onSpellRemove={onSpellRemove}
onSpellAdd={onSpellAdd}
onAlwaysKnownLoad={onAlwaysKnownLoad}
showHeader={false}
spellCasterInfo={spellCasterInfo}
enableSpellcasting={false}
enableCustomization={false}
ruleData={ruleData}
overallSpellInfo={overallSpellInfo}
dataOriginRefData={dataOriginRefData}
theme={theme}
activeSourceCategories={activeSourceCategories}
/>
),
id: "spells",
}
: null,
]}
/>
</div>
);
}
}
@@ -0,0 +1,4 @@
import ClassManager from "./ClassManager";
export default ClassManager;
export { ClassManager };
@@ -0,0 +1,673 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
CharacterPreferences,
CharClass,
ChoiceData,
ChoiceUtils,
ClassDefinitionContract,
ClassFeature,
ClassFeatureUtils,
ClassUtils,
Constants,
DefinitionPool,
EntitledEntity,
Feat,
FeatDefinitionContract,
FeatLookup,
FeatUtils,
FormatUtils,
HelperUtils,
InfusionChoice,
InfusionChoiceUtils,
InfusionDefinitionContract,
InfusionUtils,
InventoryManager,
KnownInfusionUtils,
Modifier,
OptionalClassFeatureLookup,
OptionalClassFeatureUtils,
PrerequisiteData,
RuleData,
RuleDataUtils,
SourceData,
TypeValueLookup,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { CollapsibleContent } from "~/components/CollapsibleContent";
import { FeatureChoice } from "~/components/FeatureChoice";
import { Link } from "~/components/Link";
import { GrantedFeat } from "~/tools/js/CharacterBuilder/components/GrantedFeat/GrantedFeat";
import InfusionChoiceManager from "../../../../../Shared/components/InfusionChoiceManager";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils, TypeScriptUtils } from "../../../../../Shared/utils";
interface Props {
isActive: boolean;
charClass: CharClass;
choiceInfo: ChoiceData;
feature: ClassFeature;
prerequisiteData: PrerequisiteData;
isStartingClass: boolean;
preferences: CharacterPreferences;
globalModifiers: Array<Modifier>;
typeValueLookup: TypeValueLookup;
ruleData: RuleData;
definitionPool: DefinitionPool;
knownInfusionLookup: Record<string, InfusionChoice>;
knownReplicatedItems: Array<string>;
onChoiceChange?: (
classFeatureId: number,
choiceId: string,
type: any,
value: number | null,
parentChoiceId: string | null
) => void;
onInfusionChoiceItemChangePromise?: (
infusionChoiceKey: string,
infusionId: string,
itemDefinitionKey: string,
itemName: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceItemDestroyPromise?: (
infusionChoiceKey: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceChangePromise?: (
infusionChoiceKey: string,
infusionId: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceDestroyPromise?: (
infusionChoiceKey: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceCreatePromise?: (
infusionChoiceKey: string,
infusionId: string,
accept: () => void,
reject: () => void
) => void;
loadAvailableSubclasses: (
baseClassId: number,
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<Array<ClassDefinitionContract>>>;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<Array<FeatDefinitionContract>>>;
loadAvailableInfusions: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<InfusionDefinitionContract>>
>;
onDefinitionsLoaded?: (
definitions: Array<InfusionDefinitionContract>,
accessTypes: Record<string, number>
) => void;
featLookup: FeatLookup;
optionalClassFeatureLookup: OptionalClassFeatureLookup;
inventoryManager: InventoryManager;
}
interface State {
featData: Array<Feat>;
subclassData: Array<ClassDefinitionContract>;
hasFeatChoice: boolean;
hasSubclassChoice: boolean;
collapsibleOpened: boolean;
featLoadingStatus: DataLoadingStatusEnum;
subclassLoadingStatus: DataLoadingStatusEnum;
}
export default class ClassManagerFeature extends React.PureComponent<
Props,
State
> {
static defaultProps = {
isActive: true,
featLookup: {},
};
loadFeatsCanceler: null | Canceler = null;
loadSubclassesCanceler: null | Canceler = null;
constructor(props: Props) {
super(props);
this.state = {
featData: [],
subclassData: [],
hasFeatChoice: false,
hasSubclassChoice: false,
collapsibleOpened: false,
featLoadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
subclassLoadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
};
}
componentDidMount(): void {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
hasSubclassChoice: this.hasSubclassChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { feature } = this.props;
const { collapsibleOpened } = this.state;
if (feature !== prevProps.feature) {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
hasSubclassChoice: this.hasSubclassChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
if (collapsibleOpened && !prevState.collapsibleOpened) {
this.conditionallyLoadFeatData();
}
}
conditionallyLoadFeatData = (): void => {
const { loadAvailableFeats, loadAvailableSubclasses, charClass } =
this.props;
const {
hasFeatChoice,
hasSubclassChoice,
featLoadingStatus,
subclassLoadingStatus,
collapsibleOpened,
} = this.state;
if (
hasFeatChoice &&
collapsibleOpened &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
this.setState({
featLoadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableFeats({
cancelToken: new axios.CancelToken((c) => {
this.loadFeatsCanceler = c;
}),
})
.then((response) => {
let featData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featData = data.map((definition) =>
FeatUtils.simulateFeat(definition)
);
}
this.setState({
featData,
featLoadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
hasSubclassChoice &&
collapsibleOpened &&
subclassLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
this.setState({
subclassLoadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableSubclasses(ClassUtils.getId(charClass), {
cancelToken: new axios.CancelToken((c) => {
this.loadSubclassesCanceler = c;
}),
})
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
this.setState({
subclassData,
subclassLoadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadSubclassesCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
};
componentWillUnmount(): void {
if (this.loadFeatsCanceler !== null) {
this.loadFeatsCanceler();
}
if (this.loadSubclassesCanceler !== null) {
this.loadSubclassesCanceler();
}
}
hasFeatChoice = (props: Props): boolean => {
const { feature } = props;
const choices = ClassFeatureUtils.getChoices(feature);
return choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
};
hasSubclassChoice = (props: Props): boolean => {
const { feature } = props;
const choices = ClassFeatureUtils.getChoices(feature);
return choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.SUB_CLASS_OPTION
);
};
isDataLoaded = (): boolean => {
const {
hasSubclassChoice,
hasFeatChoice,
subclassLoadingStatus,
featLoadingStatus,
} = this.state;
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
handleCollapsibleOpened = (id: string, state: boolean): void => {
this.setState({
collapsibleOpened: state,
});
};
handleChoiceChange = (
id: string,
type: number,
value: any,
parentChoiceId: string | null
): void => {
const { onChoiceChange, feature } = this.props;
if (onChoiceChange) {
onChoiceChange(
ClassFeatureUtils.getId(feature),
id,
type,
HelperUtils.parseInputInt(value),
parentChoiceId
);
}
};
getAvailableInfusionChoices = (): Array<InfusionChoice> => {
const { feature } = this.props;
return ClassFeatureUtils.getInfusionChoices(feature).filter(
InfusionChoiceUtils.validateIsAvailable
);
};
getInfusionChoiceCount = (): number => {
return this.getAvailableInfusionChoices().length;
};
getTodoInfusionChoiceCount = (): number => {
return this.getAvailableInfusionChoices().filter(
(infusionChoice) =>
InfusionChoiceUtils.getKnownInfusion(infusionChoice) === null
).length;
};
getInfusionItemChoiceCount = (): number => {
return this.getAvailableInfusionChoices().filter((infusionChoice) => {
const knownInfusion =
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
if (knownInfusion === null) {
return false;
}
const simulatedInfusion =
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
if (simulatedInfusion === null) {
return false;
}
return (
InfusionUtils.getType(simulatedInfusion) ===
Constants.InfusionTypeEnum.REPLICATE
);
}).length;
};
getTodoInfusionItemChoiceCount = (): number => {
return this.getAvailableInfusionChoices().filter((infusionChoice) => {
const knownInfusion =
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
if (knownInfusion === null) {
return false;
}
const simulatedInfusion =
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
if (simulatedInfusion === null) {
return false;
}
if (
InfusionUtils.getType(simulatedInfusion) !==
Constants.InfusionTypeEnum.REPLICATE
) {
return false;
}
if (KnownInfusionUtils.getItemId(knownInfusion) !== null) {
return false;
}
return true;
}).length;
};
getChoiceCount = (): number => {
const { feature } = this.props;
return ClassFeatureUtils.getChoices(feature).length;
};
getTodoChoiceCount = (): number => {
const { feature } = this.props;
return ClassFeatureUtils.getChoices(feature).filter(ChoiceUtils.isTodo)
.length;
};
getTotalChoiceCount = (): number => {
return (
this.getChoiceCount() +
this.getInfusionChoiceCount() +
this.getInfusionItemChoiceCount()
);
};
getTotalTodoCount = (): number => {
return (
this.getTodoChoiceCount() +
this.getTodoInfusionChoiceCount() +
this.getTodoInfusionItemChoiceCount()
);
};
getSubclassSources = (
subclass: ClassDefinitionContract
): Array<SourceData> => {
const { ruleData } = this.props;
if (subclass.sources === null) {
return [];
}
return subclass.sources
.map((sourceMapping) =>
HelperUtils.lookupDataOrFallback(
RuleDataUtils.getSourceDataLookup(ruleData),
sourceMapping.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
};
getSubclassData = (): Array<ClassDefinitionContract> => {
const { subclassData } = this.state;
const { charClass } = this.props;
let data: Array<ClassDefinitionContract> = [...subclassData];
let existingSubclass = ClassUtils.getSubclass(charClass);
if (
existingSubclass !== null &&
!data.some(
(classDefinition) =>
existingSubclass !== null &&
classDefinition.id === existingSubclass.id
)
) {
data.push(existingSubclass);
}
return data;
};
getClassFeatureDescription = (): string => {
const { feature, isStartingClass } = this.props;
const multiClassDescription =
ClassFeatureUtils.getMultiClassDescription(feature);
if (
!isStartingClass &&
multiClassDescription &&
multiClassDescription.length > 0
) {
return multiClassDescription;
}
const description = ClassFeatureUtils.getDescription(feature);
return description === null ? "" : description;
};
renderChoices = (): React.ReactNode => {
const { isActive, feature, charClass } = this.props;
const { featData } = this.state;
if (!isActive) {
return null;
}
const choices = ClassFeatureUtils.getChoices(feature);
if (choices === null) {
return null;
}
return choices.map((choice) => (
<FeatureChoice
choice={choice}
charClass={charClass}
feature={feature}
featsData={featData}
subclassData={this.getSubclassData()}
onChoiceChange={this.handleChoiceChange}
key={ChoiceUtils.getId(choice)}
/>
));
};
getMetaItems = () => {
const { feature, optionalClassFeatureLookup, definitionPool } = this.props;
const choiceCount = this.getTotalChoiceCount();
const featureType = ClassFeatureUtils.getFeatureType(feature);
let metaItems: Array<string> = [];
// Display the number of choices if there are any
if (choiceCount) {
metaItems.push(`${choiceCount} Choice${choiceCount !== 1 ? "s" : ""}`);
}
// Display the required level
metaItems.push(
`${FormatUtils.ordinalize(
ClassFeatureUtils.getRequiredLevel(feature)
)} level`
);
// Display that this is an optional class feature if it is
if (featureType !== Constants.FeatureTypeEnum.GRANTED) {
metaItems.push("Optional Class Feature");
}
// Display the feature that this feature replaces if it is a replacement
if (featureType === Constants.FeatureTypeEnum.REPLACEMENT) {
const optionalFeature = HelperUtils.lookupDataOrFallback(
optionalClassFeatureLookup,
ClassFeatureUtils.getDefinitionKey(feature)
);
if (optionalFeature) {
const affectedDefinitionKey =
OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
optionalFeature
);
if (affectedDefinitionKey) {
const replacedFeature = ClassFeatureUtils.simulateClassFeature(
affectedDefinitionKey,
definitionPool
);
if (replacedFeature) {
metaItems.push(
`Replaces ${ClassFeatureUtils.getName(replacedFeature)}`
);
}
}
}
}
return metaItems;
};
render() {
const {
isActive,
charClass,
definitionPool,
ruleData,
globalModifiers,
typeValueLookup,
loadAvailableInfusions,
onInfusionChoiceItemChangePromise,
onInfusionChoiceItemDestroyPromise,
onInfusionChoiceCreatePromise,
onInfusionChoiceDestroyPromise,
onInfusionChoiceChangePromise,
onDefinitionsLoaded,
knownReplicatedItems,
knownInfusionLookup,
feature,
inventoryManager,
} = this.props;
const { hasSubclassChoice, hasFeatChoice } = this.state;
// If the class feature grants feats, render those instead of the class feature.
if (feature.featLists.length > 0) {
return feature.featLists.map((fl) => (
<GrantedFeat
featList={fl}
key={fl.definition.id}
requiredLevel={ClassFeatureUtils.getRequiredLevel(feature)}
/>
));
}
const conClsNames: Array<string> = ["class-manager-feature-name"];
let contentNode: React.ReactNode;
if (isActive) {
if (this.getTotalTodoCount() > 0) {
conClsNames.push("collapsible-todo");
}
if (hasFeatChoice || hasSubclassChoice) {
if (this.isDataLoaded()) {
contentNode = this.renderChoices();
} else {
contentNode = <LoadingPlaceholder />;
}
} else {
contentNode = this.renderChoices();
}
}
const hasChoices = this.getTotalTodoCount() > 0;
const accordionId = ClassFeatureUtils.getId(feature).toString();
return (
<Accordion
id={accordionId}
summary={ClassFeatureUtils.getName(feature)}
summaryMetaItems={this.getMetaItems()}
variant="paper"
showAlert={hasChoices}
handleIsOpen={this.handleCollapsibleOpened}
>
<CollapsibleContent className="class-manager-feature-description">
{this.getClassFeatureDescription()}
</CollapsibleContent>
{isActive && hasSubclassChoice && (
<div className="ct-character-tools__marketplace-callout">
Looking for something not in the list below? Unlock all official
options in the <Link href="/marketplace">Marketplace</Link>.
</div>
)}
{contentNode}
{isActive && (
<InfusionChoiceManager
infusionChoices={this.getAvailableInfusionChoices()}
contextLevel={ClassUtils.getLevel(charClass)}
definitionPool={definitionPool}
ruleData={ruleData}
globalModifiers={globalModifiers}
typeValueLookup={typeValueLookup}
knownInfusionLookup={knownInfusionLookup}
knownReplicatedItems={knownReplicatedItems}
loadAvailableInfusions={loadAvailableInfusions}
onInfusionChoiceItemChangePromise={
onInfusionChoiceItemChangePromise
}
onInfusionChoiceItemDestroyPromise={
onInfusionChoiceItemDestroyPromise
}
onInfusionChoiceChangePromise={onInfusionChoiceChangePromise}
onInfusionChoiceDestroyPromise={onInfusionChoiceDestroyPromise}
onInfusionChoiceCreatePromise={onInfusionChoiceCreatePromise}
onDefinitionsLoaded={onDefinitionsLoaded}
inventoryManager={inventoryManager}
/>
)}
</Accordion>
);
}
}
@@ -0,0 +1,4 @@
import ClassManagerFeature from "./ClassManagerFeature";
export default ClassManagerFeature;
export { ClassManagerFeature };
@@ -0,0 +1,374 @@
import axios, { Canceler } from "axios";
import React from "react";
import {
MarketplaceCta,
LoadingPlaceholder,
} from "@dndbeyond/character-components/es";
import {
ApiAdapterUtils,
CharClass,
ClassFeature,
ClassFeatureUtils,
Constants,
DefinitionPool,
DefinitionPoolUtils,
DefinitionUtils,
OptionalClassFeature,
OptionalClassFeatureLookup,
OptionalClassFeatureUtils,
ClassUtils,
HelperUtils,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import {
ApiClassFeatureResponseData,
ApiClassFeaturesRequest,
} from "../../../../../Shared/selectors/composite/apiCreator";
import { AppLoggerUtils, TypeScriptUtils } from "../../../../../Shared/utils";
import { OptionalFeature } from "../../../../components/OptionalFeature";
import { AffectedFeatureInfo } from "../../../../components/OptionalFeature/OptionalFeature";
import { PageSubHeader } from "../../../../components/PageSubHeader";
interface OptionalFeatureManagerProps {
charClass: CharClass;
definitionPool: DefinitionPool;
optionalClassFeatureLookup: OptionalClassFeatureLookup;
loadAvailableOptionalClassFeatures?: ApiClassFeaturesRequest | null;
onDefinitionsLoaded?: (definitionData: ApiClassFeatureResponseData) => void;
onSelection: (
definitionKey: string,
affectedClassFeatureDefinitionKey: string | null
) => void;
onChangeReplacementPromise?: (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
) => void;
onRemoveSelectionPromise?: (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
ruleData: RuleData;
}
interface OptionalFeatureManagerState {
loadingStatus: DataLoadingStatusEnum;
}
export class OptionalFeatureManager extends React.PureComponent<
OptionalFeatureManagerProps,
OptionalFeatureManagerState
> {
loadOptionalOriginsCanceler: null | Canceler = null;
constructor(props: OptionalFeatureManagerProps) {
super(props);
this.state = {
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
};
}
componentDidMount() {
const { loadAvailableOptionalClassFeatures, onDefinitionsLoaded } =
this.props;
const { loadingStatus } = this.state;
if (
loadAvailableOptionalClassFeatures &&
loadingStatus === DataLoadingStatusEnum.NOT_LOADED
) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableOptionalClassFeatures({
cancelToken: new axios.CancelToken((c) => {
this.loadOptionalOriginsCanceler = c;
}),
})
.then((response) => {
let data = ApiAdapterUtils.getResponseData(response);
if (data?.definitionData.length && onDefinitionsLoaded) {
onDefinitionsLoaded(data);
}
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadOptionalOriginsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
}
}
componentWillUnmount(): void {
if (this.loadOptionalOriginsCanceler !== null) {
this.loadOptionalOriginsCanceler();
}
}
getAffectedFeatureInfos = (
optionalClassFeature: ClassFeature
): Array<AffectedFeatureInfo> => {
const { definitionPool, charClass } = this.props;
return ClassFeatureUtils.getAffectedFeatureDefinitionKeys(
optionalClassFeature
)
.map((definitionKey: string) =>
ClassFeatureUtils.simulateClassFeature(definitionKey, definitionPool)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.map((affectedClassFeature: ClassFeature) => {
const definitionKey =
ClassFeatureUtils.getDefinitionKey(affectedClassFeature);
let disabled = ClassUtils.getOptionalClassFeatures(charClass).some(
(optionalFeatureMapping: OptionalClassFeature) =>
OptionalClassFeatureUtils.getDefinitionKey(
optionalFeatureMapping
) !== ClassFeatureUtils.getDefinitionKey(optionalClassFeature) &&
OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
optionalFeatureMapping
) === definitionKey
);
return {
name: ClassFeatureUtils.getName(affectedClassFeature),
definitionKey,
disabled,
};
});
};
renderOptionalFeatureCta = (): React.ReactNode => {
return (
<div className="ct-optional-feature-manager__content">
<div className="ct-optional-feature-manager__content--empty">
<span>
You currently have no available optional class features for this
class
</span>
<div className="ct-character-tools__marketplace-callout">
Unlock all official options in the{" "}
<Link href="/marketplace">Marketplace</Link> or create them for{" "}
<Link href="/my-creations">Homebrew</Link> Subclasses.
</div>
</div>
</div>
);
};
render() {
const { loadingStatus } = this.state;
const {
definitionPool,
charClass,
optionalClassFeatureLookup,
onChangeReplacementPromise,
onRemoveSelectionPromise,
onSelection,
} = this.props;
let contentNode: React.ReactNode;
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
contentNode = <LoadingPlaceholder />;
} else {
let availableOptionalFeatures: Array<ClassFeature> =
DefinitionPoolUtils.getTypedDefinitionList(
Constants.DefinitionTypeEnum.CLASS_FEATURE,
definitionPool
)
.map((featureDefinition) =>
ClassFeatureUtils.simulateClassFeature(
DefinitionUtils.getDefinitionKey(featureDefinition),
definitionPool
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.filter((classFeature) =>
ClassFeatureUtils.isValidClassClassFeature(charClass, classFeature)
)
.filter(
(classFeature) =>
ClassFeatureUtils.getFeatureType(classFeature) !==
Constants.FeatureTypeEnum.GRANTED
);
let availableOptionalClassFeatureLookup =
HelperUtils.generateNonNullLookup(
availableOptionalFeatures,
ClassFeatureUtils.getDefinitionKey
);
const unEntitledOptionalClassFeatures: Array<ClassFeature> = [];
Object.keys(optionalClassFeatureLookup).forEach((definitionKey) => {
if (
!availableOptionalClassFeatureLookup.hasOwnProperty(definitionKey)
) {
const classFeature = ClassFeatureUtils.simulateClassFeature(
definitionKey,
definitionPool
);
if (
classFeature &&
ClassFeatureUtils.isValidClassClassFeature(charClass, classFeature)
) {
unEntitledOptionalClassFeatures.push(classFeature);
}
}
});
const consolidatedOptionalOrigins = [
...availableOptionalFeatures,
...unEntitledOptionalClassFeatures,
].filter(
(feature) =>
!ClassFeatureUtils.getHideInContext(
feature,
Constants.AppContextTypeEnum.BUILDER
)
);
if (!consolidatedOptionalOrigins.length) {
contentNode = this.renderOptionalFeatureCta();
} else {
const replacementFeatures: Array<ClassFeature> = [];
const additionalFeatures: Array<ClassFeature> = [];
consolidatedOptionalOrigins.forEach((feature) => {
switch (ClassFeatureUtils.getFeatureType(feature)) {
case Constants.FeatureTypeEnum.ADDITIONAL:
additionalFeatures.push(feature);
break;
case Constants.FeatureTypeEnum.REPLACEMENT:
replacementFeatures.push(feature);
break;
default:
//not implemented
}
});
contentNode = (
<div className="ct-optional-feature-manager__content">
{replacementFeatures.length > 0 && (
<React.Fragment>
<PageSubHeader>Replacement Features</PageSubHeader>
{ClassUtils.deriveOrderedClassFeatures(replacementFeatures).map(
(feature) => {
const optionalFeatureMapping =
HelperUtils.lookupDataOrFallback(
optionalClassFeatureLookup,
ClassFeatureUtils.getDefinitionKey(feature)
);
const affectedFeatureDefinitionKey: string | null =
optionalFeatureMapping
? OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
optionalFeatureMapping
)
: null;
return (
<OptionalFeature
key={ClassFeatureUtils.getDefinitionKey(feature)}
name={ClassFeatureUtils.getName(feature)}
description={ClassFeatureUtils.getDescription(feature)}
requiredLevel={ClassFeatureUtils.getRequiredLevel(
feature
)}
featureType={ClassFeatureUtils.getFeatureType(feature)}
definitionKey={ClassFeatureUtils.getDefinitionKey(
feature
)}
affectedFeatureDefinitionKey={
affectedFeatureDefinitionKey
}
isSelected={!!optionalFeatureMapping ?? false}
onSelection={onSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
affectedFeatures={this.getAffectedFeatureInfos(feature)}
accessType={ClassFeatureUtils.getAccessType(feature)}
/>
);
}
)}
</React.Fragment>
)}
{additionalFeatures.length > 0 && (
<React.Fragment>
<PageSubHeader>Additional Features</PageSubHeader>
{ClassUtils.deriveOrderedClassFeatures(additionalFeatures).map(
(feature) => {
const optionalFeatureMapping =
HelperUtils.lookupDataOrFallback(
optionalClassFeatureLookup,
ClassFeatureUtils.getDefinitionKey(feature)
);
const affectedFeatureDefinitionKey: string | null =
optionalFeatureMapping
? OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
optionalFeatureMapping
)
: null;
return (
<OptionalFeature
key={ClassFeatureUtils.getDefinitionKey(feature)}
name={ClassFeatureUtils.getName(feature)}
description={ClassFeatureUtils.getDescription(feature)}
requiredLevel={ClassFeatureUtils.getRequiredLevel(
feature
)}
featureType={ClassFeatureUtils.getFeatureType(feature)}
definitionKey={ClassFeatureUtils.getDefinitionKey(
feature
)}
affectedFeatureDefinitionKey={
affectedFeatureDefinitionKey
}
isSelected={!!optionalFeatureMapping ?? false}
onSelection={onSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
affectedFeatures={this.getAffectedFeatureInfos(feature)}
accessType={ClassFeatureUtils.getAccessType(feature)}
/>
);
}
)}
</React.Fragment>
)}
</div>
);
}
}
return (
<div className="ct-optional-feature-manager">
<div className="ct-optional-feature-manager__intro">
Unlike the features in the Players Handbook, you dont gain the
features here automatically. Consulting with your DM, you decide
whether to gain a feature in this section if you meet the level
requirement noted in the features description. These features can be
selected separately from one another; you can use some, all, or none
of them.
</div>
{contentNode}
</div>
);
}
}
export default OptionalFeatureManager;
@@ -0,0 +1,5 @@
import ClassManagerFeature from "./ClassManagerFeature";
import ClassesManage from "./ClassesManage";
export default ClassesManage;
export { ClassesManage, ClassManagerFeature };
@@ -0,0 +1,55 @@
import React from "react";
import { connect } from "react-redux";
import { Navigate } from "react-router-dom";
import { navigationConfig } from "../../../config";
import { BuilderAppState } from "../../../typings";
import { NavigationUtils } from "../../../utils";
interface Props {
isPageAccessible: boolean;
redirectRoute?: string | null;
}
const BuilderPage = (Wrapped) =>
class extends React.PureComponent<Props> {
render() {
const { isPageAccessible, redirectRoute } = this.props;
if (!isPageAccessible && redirectRoute) {
return <Navigate to={redirectRoute} />;
}
return (
<div>
<Wrapped {...this.props} />
</div>
);
}
};
const ConnectedBuilderPage = (
Wrapped,
routeKey,
wrappedMapStateToProps = (state: BuilderAppState): any => ({})
) => {
function mapStateToProps(state: BuilderAppState) {
// const isPageAccessible = checkIsRouteAccessible(routeKey, state);
const isPageAccessible = NavigationUtils.checkStdBuilderPageRequirements(
navigationConfig.getRouteDef(routeKey),
state
);
return {
...wrappedMapStateToProps(state),
isPageAccessible,
redirectRoute: isPageAccessible
? null
: NavigationUtils.getAvailableRouteRedirect(routeKey, state),
};
}
return connect(mapStateToProps)(BuilderPage(Wrapped));
};
export default ConnectedBuilderPage;
@@ -0,0 +1,4 @@
import ConnectedBuilderPage from "./ConnectedBuilderPage";
export default ConnectedBuilderPage;
export { ConnectedBuilderPage };
@@ -0,0 +1,54 @@
import React from "react";
import { RouteKey } from "~/subApps/builder/constants";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props {}
class DescriptionHelp extends React.PureComponent<Props> {
render() {
return (
<Page>
<PageBody>
<PageHeader>Background</PageHeader>
<p>
Your characters background describes where they came from, their
original occupation, and their place in the D&amp;D world.
</p>
<p>
Backgrounds from the Core D&amp;D source category give your
character ability score increases, an Origin feat, and proficiencies
in specific skills and tools.
</p>
<p>
Backgrounds from other sources give your character a background
feature (a general benefit) and proficiency in two skills, and it
might also give you additional languages or proficiency with certain
kinds of tools.
</p>
<PageHeader>Describe Your Character</PageHeader>
<p>
In this step, you will flesh your character out as a person. Your
character needs a name.
</p>
<p>
Youll need to decide your characters appearance and personality.
Choose your characters alignment (the moral compass that guides his
or her decisions) and ideals. Identify the things your character
holds most dear, called bonds, and the flaws that could one day
undermine them.
</p>
</PageBody>
</Page>
);
}
}
export default ConnectedBuilderPage(DescriptionHelp, RouteKey.DESCRIPTION_HELP);
@@ -0,0 +1,4 @@
import DescriptionHelp from "./DescriptionHelp";
export default DescriptionHelp;
export { DescriptionHelp };
@@ -0,0 +1,4 @@
import DescriptionManage from "./DescriptionManage";
export default DescriptionManage;
export { DescriptionManage };
@@ -0,0 +1,43 @@
import React from "react";
import { RouteKey } from "~/subApps/builder/constants";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import PageSubHeader from "../../../components/PageSubHeader";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
class EquipmentHelp extends React.PureComponent {
render() {
return (
<Page>
<PageBody>
<PageHeader>Choose Equipment</PageHeader>
<p>
In this step, you will select weapons, armor, and other adventuring
gear for your character.
</p>
<PageSubHeader>Starting Equipment</PageSubHeader>
<p>
Your starting equipment options will be displayed on the next
screen. Your class and background determine your characters
starting equipment. Review the options and add the appropriate items
using Manage Equipment.
</p>
<PageSubHeader>Starting Gold</PageSubHeader>
<p>
Instead of taking the gear given to you by your class and
background, you can purchase starting equipment. You have a number
of gold pieces (GP) to spend based on your class, as shown on the
next screen if you choose this option.
</p>
</PageBody>
</Page>
);
}
}
export default ConnectedBuilderPage(EquipmentHelp, RouteKey.EQUIPMENT_HELP);
@@ -0,0 +1,4 @@
import EquipmentHelp from "./EquipmentHelp";
export default EquipmentHelp;
export { EquipmentHelp };
@@ -0,0 +1,482 @@
import React from "react";
import { useContext } from "react";
import { DispatchProp } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import {
Collapsible,
CollapsibleHeaderContent,
CollapsibleHeading,
} from "@dndbeyond/character-components/es";
import {
characterActions,
CharacterConfiguration,
CharacterCurrencyContract,
CharacterNotes,
CharacterTheme,
Constants,
ContainerLookup,
ContainerUtils,
InventoryManager,
FormatUtils,
Item,
ItemUtils,
Modifier,
RuleData,
rulesEngineSelectors,
TypeValueLookup,
CoinManager,
} from "@dndbeyond/character-rules-engine/es";
import { EditorWithDialog } from "~/subApps/builder/components/EditorWithDialog";
import { RouteKey } from "~/subApps/builder/constants";
import {
ModalData,
useModalManager,
} from "~/subApps/builder/contexts/ModalManager";
import * as toastActions from "../../../../Shared/actions/toastMessage/actions";
import { ArmorListItem } from "../../../../Shared/components/legacy/ArmorList";
import { CurrencyList } from "../../../../Shared/components/legacy/CurrencyList";
import { EquipmentManagerShop } from "../../../../Shared/components/legacy/EquipmentManagerShop";
import { GearListItem } from "../../../../Shared/components/legacy/GearList";
import { WeaponListItem } from "../../../../Shared/components/legacy/WeaponList";
import { CURRENCY_VALUE } from "../../../../Shared/constants/App";
import StartingEquipment from "../../../../Shared/containers/StartingEquipment";
import { CurrencyErrorTypeEnum } from "../../../../Shared/containers/panes/CurrencyPane/CurrencyPaneConstants";
import { CoinManagerContext } from "../../../../Shared/managers/CoinManagerContext";
import { InventoryManagerContext } from "../../../../Shared/managers/InventoryManagerContext";
import { AppNotificationUtils } from "../../../../Shared/utils";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import { PageHeader } from "../../../components/PageHeader";
import { BuilderAppState } from "../../../typings";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props extends DispatchProp {
configuration: CharacterConfiguration;
inventory: Array<Item>;
containerLookup: ContainerLookup;
totalWeight: number;
notes: CharacterNotes;
ruleData: RuleData;
hasMaxAttunedItems: boolean;
characterId: number;
theme: CharacterTheme;
globalModifiers: Array<Modifier>;
valueLookupByType: TypeValueLookup;
proficiencyBonus: number;
inventoryManager: InventoryManager;
coinManager: CoinManager;
activeSourceCategories: Array<number>;
createModal: (modalData: ModalData) => void;
}
interface State {
showStartingEquipment: boolean;
}
class EquipmentManage extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showStartingEquipment: props.configuration.startingEquipmentType === null,
};
}
textareaInput = React.createRef<HTMLDivElement>();
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { configuration } = this.props;
if (configuration !== prevProps.configuration) {
this.setState({
showStartingEquipment: configuration.startingEquipmentType === null,
});
}
}
handleItemAdd = (
item: Item,
amount: number,
containerDefinitionKey: string
): void => {
const { inventoryManager } = this.props;
inventoryManager.handleAdd(
{ item, amount, containerDefinitionKey },
AppNotificationUtils.handleItemAddAccepted.bind(this, item, amount)
);
};
handleCurrencyChange = (coin: CharacterCurrencyContract): void => {
const { coinManager } = this.props;
coinManager.handleCoinSet({
coin,
containerDefinitionKey: coinManager.getCharacterContainerDefinitionKey(),
});
};
handleCurrencyError = (
currencyName: string,
errorType: CurrencyErrorTypeEnum
): void => {
const { dispatch } = this.props;
let message: string = "";
if (errorType === CurrencyErrorTypeEnum.MIN) {
message =
"Cannot set currency to a negative value, the previous amount has been set instead.";
}
if (errorType === CurrencyErrorTypeEnum.MAX) {
message = `The max amount allowed for each currency type is ${FormatUtils.renderLocaleNumber(
CURRENCY_VALUE.MAX
)}, the previous value has been set instead.`;
}
if (errorType !== null) {
dispatch(
toastActions.toastError(
`Unable to Set Currency: ${currencyName}`,
message
)
);
}
};
renderStartingEquipment = (): React.ReactNode => {
const { showStartingEquipment } = this.state;
let headingNode: React.ReactNode = (
<CollapsibleHeading>Starting Equipment</CollapsibleHeading>
);
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent heading={headingNode} />
);
return (
<Collapsible
header={headerNode}
className="equipment-manage__starting"
useBuilderStyles={true}
initiallyCollapsed={!showStartingEquipment}
collapsed={!showStartingEquipment}
onChangeHandler={(isCollapsed) => {
this.setState({
showStartingEquipment: !isCollapsed,
});
}}
>
<StartingEquipment
onStartingEquipmentChoose={() => {
this.setState({
showStartingEquipment: false,
});
}}
isInitialView={true}
/>
</Collapsible>
);
};
renderItem = (
item: Item,
itemParams,
weaponLabels,
armorLabels,
gearLabels
): React.ReactNode => {
if (ItemUtils.isWeaponContract(item)) {
let finalWeaponLabels = weaponLabels;
if (ItemUtils.isAmmunition(item)) {
//TODO fix this to be cleaner
finalWeaponLabels = {
...weaponLabels,
equipLabel: "Use",
unequipLabel:
weaponLabels.unequipLabel === "Stow"
? weaponLabels.unequipLabel
: "In Use",
};
}
return (
<WeaponListItem
key={uuidv4()}
item={item}
{...finalWeaponLabels}
{...itemParams}
/>
);
} else if (ItemUtils.isArmorContract(item)) {
return (
<ArmorListItem
key={uuidv4()}
item={item}
{...armorLabels}
{...itemParams}
/>
);
} else if (ItemUtils.isGearContract(item)) {
return (
<GearListItem
key={uuidv4()}
item={item}
{...gearLabels}
{...itemParams}
/>
);
}
return null;
};
renderItemList = (): React.ReactNode => {
const { inventory } = this.props;
const weaponLabels = {
equipLabel: "Wield",
unequipLabel: "Wielding",
};
const armorLabels = {
equipLabel: "Wear",
unequipLabel: "Wearing",
};
const gearLabels = {
equipLabel: "Use",
unequipLabel: "In Use",
};
const itemParams = {
showRemove: true,
showEquip: true,
showUnequip: true,
showHeaderAction: true,
};
return (
<React.Fragment>
{ItemUtils.sortInventoryItems(inventory).map((item) =>
this.renderItem(
item,
itemParams,
weaponLabels,
armorLabels,
gearLabels
)
)}
</React.Fragment>
);
};
renderInventory = (): React.ReactNode => {
const { inventory, totalWeight } = this.props;
const itemTotal: number = inventory.length;
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading={
<CollapsibleHeading>
Current Inventory ({itemTotal})
</CollapsibleHeading>
}
callout={
<div className="equipment-manage__callout">
Total Weight: {FormatUtils.renderWeight(totalWeight)}
</div>
}
/>
);
return (
<Collapsible
header={headerNode}
className="equipment-manage__inventory"
useBuilderStyles={true}
initiallyCollapsed={itemTotal === 0}
collapsed={itemTotal === 0}
>
{itemTotal === 0 ? (
<div className="equipment-manager__inventory-empty">
You currently have no items in your inventory. Add Starting
Equipment above or Add Items from the list of available items below.
</div>
) : (
this.renderItemList()
)}
</Collapsible>
);
};
renderOtherPossessions = (): React.ReactNode => {
const { notes, dispatch } = this.props;
return (
<Collapsible
header={"Other Possessions"}
className="equipment-manage__possessions"
useBuilderStyles={true}
>
<EditorWithDialog
heading={<h3>Personal Possessions</h3>}
editButtonLabel="Edit Possessions"
addButtonLabel="Add Possessions"
placeholder="Add personal possessions here..."
content={notes[Constants.NoteKeyEnum.PERSONAL_POSSESSIONS] ?? ""}
onSave={(content) => {
dispatch(
characterActions.noteSet(
Constants.NoteKeyEnum.PERSONAL_POSSESSIONS,
content
)
);
}}
/>
</Collapsible>
);
};
renderAddItems = (): React.ReactNode => {
const {
ruleData,
characterId,
theme,
globalModifiers,
valueLookupByType,
proficiencyBonus,
activeSourceCategories,
inventoryManager,
} = this.props;
const characterContainerKey =
ContainerUtils.getCharacterContainerDefinitionKey(characterId);
return (
<Collapsible
header={"Add Items"}
className="equipment-manage__add-items"
useBuilderStyles={true}
>
<EquipmentManagerShop
theme={theme}
globalModifiers={globalModifiers}
valueLookupByType={valueLookupByType}
onItemAdd={this.handleItemAdd}
ruleData={ruleData}
proficiencyBonus={proficiencyBonus}
containerDefinitionKey={characterContainerKey}
activeSourceCategories={activeSourceCategories}
inventoryManager={inventoryManager}
/>
</Collapsible>
);
};
renderCurrency = (): React.ReactNode => {
const { coinManager } = this.props;
const coin = coinManager.getContainerCoin(
coinManager.getCharacterContainerDefinitionKey()
);
if (!coin) {
return null;
}
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Currency</CollapsibleHeading>}
callout={
<div className="equipment-manage__callout">
Total in GP:{" "}
{FormatUtils.renderLocaleNumber(
coinManager.getTotalContainerCoinInGold(
coinManager.getCharacterContainerDefinitionKey()
)
)}
</div>
}
/>
);
return (
<Collapsible
header={headerNode}
className="equipment-manage__currency"
useBuilderStyles={true}
>
<CurrencyList
{...coin}
onChange={this.handleCurrencyChange}
onError={this.handleCurrencyError}
totalGp={coinManager.getTotalContainerCoinInGold(
coinManager.getCharacterContainerDefinitionKey()
)}
/>
</Collapsible>
);
};
render() {
return (
<Page>
<PageBody>
<PageHeader>Choose Equipment</PageHeader>
<div className="equipment-manage">
{this.renderStartingEquipment()}
{this.renderInventory()}
{this.renderOtherPossessions()}
{this.renderAddItems()}
{this.renderCurrency()}
</div>
</PageBody>
</Page>
);
}
}
function EquipmentManageContainer(props) {
const { inventoryManager } = useContext(InventoryManagerContext);
const { coinManager } = useContext(CoinManagerContext);
const { createModal } = useModalManager();
return (
<EquipmentManage
coinManager={coinManager}
inventoryManager={inventoryManager}
createModal={createModal}
{...props}
/>
);
}
export default ConnectedBuilderPage(
EquipmentManageContainer,
RouteKey.EQUIPMENT_MANAGE,
(state: BuilderAppState) => {
return {
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
inventory: rulesEngineSelectors.getInventory(state),
containerLookup: rulesEngineSelectors.getContainerLookup(state),
totalWeight: rulesEngineSelectors.getTotalCarriedWeight(state),
notes: rulesEngineSelectors.getCharacterNotes(state),
currencies: rulesEngineSelectors.getCurrencies(state),
ruleData: rulesEngineSelectors.getRuleData(state),
hasMaxAttunedItems: rulesEngineSelectors.hasMaxAttunedItems(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
characterId: rulesEngineSelectors.getId(state),
globalModifiers: rulesEngineSelectors.getValidGlobalModifiers(state),
valueLookupByType:
rulesEngineSelectors.getCharacterValueLookupByType(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
activeSourceCategories:
rulesEngineSelectors.getActiveSourceCategories(state),
};
}
);
@@ -0,0 +1,4 @@
import EquipmentManage from "./EquipmentManage";
export default EquipmentManage;
export { EquipmentManage };
@@ -0,0 +1,846 @@
import { Typography } from "@mui/material";
import React, { ChangeEvent, HTMLAttributes, ReactNode } from "react";
import { DispatchProp } from "react-redux";
import {
characterActions,
CharacterPreferences,
Constants,
HelperUtils,
HtmlSelectOption,
RuleData,
RuleDataUtils,
rulesEngineSelectors,
CharacterUtils,
ClassSpellListSpellsLookup,
ClassUtils,
CharClass,
Race,
RaceUtils,
characterSelectors,
PremadeInfo,
PremadeInfoStatus,
} from "@dndbeyond/character-rules-engine/es";
import { Dice } from "@dndbeyond/dice";
import { SourceCategoryDescription } from "~/constants";
import { RouteKey } from "~/subApps/builder/constants";
import {
ModalData,
useModalManager,
} from "~/subApps/builder/contexts/ModalManager";
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
import UserRoles from "~/tools/js/Shared/constants/UserRoles";
import PrivacyTypeRadio from "~/tools/js/smartComponents/PrivacyTypeRadio";
import { appEnvActions } from "../../../../Shared/actions";
import { SimpleClassSpellList } from "../../../../Shared/components/SimpleClassSpellList";
import {
FormCheckBoxesField,
CheckboxInfo,
} from "../../../../Shared/components/common/FormCheckBoxesField";
import FormSelectField from "../../../../Shared/components/common/FormSelectField";
import FormToggleField from "../../../../Shared/components/common/FormToggleField";
import { appEnvSelectors } from "../../../../Shared/selectors";
import config from "../../../../config";
import RadioGroup from "../../../components/CharacterSheetOptions/RadioGroup";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import { BuilderAppState } from "../../../typings";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props extends DispatchProp {
preferences: CharacterPreferences;
ruleData: RuleData;
activeSourceCategories: Array<number>;
diceEnabled: boolean;
userRoles: string[] | null | undefined;
classes: Array<CharClass>;
species: Race | null;
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
premadeInfo: PremadeInfo | null;
characterId: number;
createModal: (modalData: ModalData) => void;
}
class HomeBasicInfo extends React.PureComponent<Props> {
static defaultProps = {
diceEnabled: false,
};
handlePreferenceChange = (prefKey: string, value: boolean): void => {
const { dispatch } = this.props;
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
if (typedPrefKey !== null) {
dispatch(characterActions.preferenceChoose(typedPrefKey, value));
}
};
handleIntPreferenceChange = (prefKey: string, value: string): void => {
const { dispatch } = this.props;
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
if (typedPrefKey !== null) {
dispatch(
characterActions.preferenceChoose(
typedPrefKey,
HelperUtils.parseInputInt(value)
)
);
}
};
handleSourceCategoryChange = (sourceId: number, isActive: boolean): void => {
const { dispatch, activeSourceCategories } = this.props;
let newSourceCats: Array<any> = [];
if (isActive) {
newSourceCats = [...activeSourceCategories, sourceId];
} else {
newSourceCats = activeSourceCategories.filter((id) => id !== sourceId);
}
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
};
handlePartneredSourceChangeAll = (
sourceIds: number[],
isActive: boolean
): void => {
const { dispatch, activeSourceCategories } = this.props;
let newSourceCats: Array<any> = [];
if (isActive) {
newSourceCats = [...activeSourceCategories, ...sourceIds];
} else {
newSourceCats = activeSourceCategories.filter(
(id) => !sourceIds.includes(id)
);
}
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
};
handleOptionalClassFeaturesPreferenceChangePromise = (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
): void => {
const { classes, classSpellListSpellsLookup, createModal } = this.props;
const spellListIds: Array<number> =
ClassUtils.getUpdateEnableOptionalClassFeaturesSpellListIdsToRemove(
classes,
newIsEnabled
);
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
this.handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled);
accept();
} else {
createModal({
content: (
<div>
<p>
Are you sure you want to disable{" "}
<strong>Optional Class Features</strong> for this character?
</p>
<p>
After doing so, the following spells provided by these features
will be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Optional Class Features",
size: "fit-content",
variant: "remove",
onConfirm: () => {
this.handlePreferenceChange(
"enableOptionalClassFeatures",
newIsEnabled
);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
handleOptionalOriginsPreferenceChangePromise = (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
): void => {
const { createModal, species, classSpellListSpellsLookup } = this.props;
if (!species) {
this.handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
accept();
return;
}
const spellListIds: Array<number> =
RaceUtils.getUpdateEnableOptionalOriginsSpellListIdsToRemove(
species,
newIsEnabled
);
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
this.handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
accept();
} else {
createModal({
content: (
<div>
<p>
Are you sure you want to disable{" "}
<strong>Customized Origins</strong> for this character?
</p>
<p>
After doing so, the following spells provided by these features
will be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Customized Origin Features",
size: "fit-content",
variant: "remove",
onConfirm: () => {
this.handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
handleProgressionPreferenceChangePromise = (
newValue: string,
oldValue: string,
accept: () => void,
reject: () => void
): void => {
const { dispatch, createModal } = this.props;
const prefKey = CharacterUtils.getPreferenceKey("progressionType");
const newIdValue = HelperUtils.parseInputInt(newValue);
let content: ReactNode;
let heading: string | null = null;
if (newIdValue === Constants.PreferenceProgressionTypeEnum.XP) {
heading = "XP Advancement";
content = (
<div>
<p>
Are you sure you want to change your advancement method to XP
progression?
</p>
<p>You will begin with the base XP value for your current level.</p>
</div>
);
} else if (
newIdValue === Constants.PreferenceProgressionTypeEnum.MILESTONE
) {
heading = "Milestone Advancement";
content = (
<div>
<p>
Are you sure you want to change your advancement method to Milestone
progression?
</p>
<p>Your current XP values will be lost.</p>
</div>
);
}
if (prefKey !== null && heading) {
createModal({
content,
props: {
heading,
size: "fit-content",
onConfirm: () => {
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
accept();
},
onClose: () => {
reject();
},
},
});
}
};
handleHitPointPreferenceChangePromise = (
newValue: string,
oldValue: string,
accept: () => void,
reject: () => void
): void => {
const { dispatch, createModal } = this.props;
const prefKey = CharacterUtils.getPreferenceKey("hitPointType");
const newIdValue = HelperUtils.parseInputInt(newValue);
let content: ReactNode;
let heading: string | null = null;
if (newIdValue === Constants.PreferenceHitPointTypeEnum.FIXED) {
heading = "Fixed Hit Points";
content = (
<div>
<p>
Are you sure you want to change your hit points to the fixed value?
</p>
<p>Any rolled hit point totals will be lost.</p>
</div>
);
} else if (newIdValue === Constants.PreferenceHitPointTypeEnum.MANUAL) {
heading = "Manual Hit Points";
content = (
<div>
<p>Are you sure you want to change your hit points manual entry?</p>
<p>
After doing so, use Manage HP in the Class section to enter your
rolled values.
</p>
</div>
);
}
if (prefKey !== null && heading) {
createModal({
content,
props: {
heading,
size: "fit-content",
onConfirm: () => {
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
accept();
},
onClose: () => {
reject();
},
},
});
}
};
handleDiceToggle = (): void => {
const { dispatch, diceEnabled } = this.props;
const newDiceEnabledSetting: boolean = !diceEnabled;
try {
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
Dice.setEnabled(newDiceEnabledSetting);
} catch (e) {}
dispatch(
appEnvActions.dataSet({
diceEnabled: newDiceEnabledSetting,
})
);
};
handleChangePrivacy = (value: number | null): void => {
const typedPrefKey = CharacterUtils.getPreferenceKey("privacyType");
if (typedPrefKey !== null) {
this.props.dispatch(
characterActions.preferenceChoose(typedPrefKey, value)
);
}
};
renderPreferences = (): React.ReactNode => {
const { preferences, ruleData, activeSourceCategories, diceEnabled } =
this.props;
const {
useHomebrewContent,
encumbranceType,
hitPointType,
progressionType,
abilityScoreDisplayType,
privacyType,
ignoreCoinWeight,
enforceFeatRules,
enforceMulticlassRules,
showScaledSpells,
enableOptionalOrigins,
enableOptionalClassFeatures,
} = preferences;
const encumbranceOptions: Array<HtmlSelectOption> = [
{
label: "Use Encumbrance",
value: Constants.PreferenceEncumbranceTypeEnum.ENCUMBRANCE,
},
{
label: "No Encumbrance",
value: Constants.PreferenceEncumbranceTypeEnum.NONE,
},
{
label: "Variant Encumbrance",
value: Constants.PreferenceEncumbranceTypeEnum.VARIANT,
},
];
const hpOptions: Array<HtmlSelectOption> = [
{ label: "Fixed", value: Constants.PreferenceHitPointTypeEnum.FIXED },
{ label: "Manual", value: Constants.PreferenceHitPointTypeEnum.MANUAL },
];
const advancementOptions: Array<HtmlSelectOption> = [
{
label: "Milestone",
value: Constants.PreferenceProgressionTypeEnum.MILESTONE,
},
{ label: "XP", value: Constants.PreferenceProgressionTypeEnum.XP },
];
const abilityDisplayOptions: Array<HtmlSelectOption> = [
{
label: "Modifiers Top",
value: Constants.PreferenceAbilityScoreDisplayTypeEnum.MODIFIERS_TOP,
},
{
label: "Scores Top",
value: Constants.PreferenceAbilityScoreDisplayTypeEnum.SCORES_TOP,
},
];
let sourceToggles: Array<CheckboxInfo> = [];
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
let allPartneredSources: Array<number> = [];
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
if (!sourceCategory.isToggleable) {
return null;
}
const checkbox: CheckboxInfo = {
label: `${sourceCategory.name}`,
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
onChange: this.handleSourceCategoryChange.bind(this, sourceCategory.id),
sortOrder: sourceCategory.sortOrder,
description: sourceCategory.description ?? "",
};
if (sourceCategory.isPartneredContent) {
delete checkbox.description; // remove description from partnered content
partneredSourceCheckboxes.push(checkbox);
allPartneredSources.push(sourceCategory.id);
} else {
sourceToggles.push(checkbox);
}
});
const checkboxAllPartneredContent: CheckboxInfo = {
label: "All Partnered Content",
initiallyEnabled: allPartneredSources.every((id) =>
activeSourceCategories.includes(id)
),
onChange: this.handlePartneredSourceChangeAll.bind(
this,
allPartneredSources
),
};
return (
<div className="home-manage-preferences">
<FormCheckBoxesField
heading="Sources"
description={SourceCategoryDescription.official}
checkboxes={[
...sourceToggles,
{
label: "Homebrew",
description: SourceCategoryDescription.homebrew,
initiallyEnabled: useHomebrewContent,
onChange: this.handlePreferenceChange.bind(
this,
"useHomebrewContent"
),
sortOrder: 0,
},
]}
showAccordion={false}
variant="builder"
/>
<FormCheckBoxesField
heading="Partnered Content"
description={SourceCategoryDescription.partnered}
checkboxes={partneredSourceCheckboxes}
checkUncheckAllEnabled={true}
onCheckUncheckAll={checkboxAllPartneredContent}
showAccordion={true}
accordionHeading="Choose Partners"
variant="builder"
allText="All"
/>
<FormCheckBoxesField
heading="Dice Rolling"
description="Enables digital dice rolling for this character"
checkboxes={[
{
initiallyEnabled: diceEnabled,
label: "Enable Dice Rolling",
onChange: this.handleDiceToggle,
},
]}
variant="builder"
/>
<FormCheckBoxesField
heading="Optional Features"
description="Allow or restrict optional features for this character."
checkboxes={[
{
label: "Optional Class Features",
initiallyEnabled: enableOptionalClassFeatures,
onChangePromise:
this.handleOptionalClassFeaturesPreferenceChangePromise,
},
{
label: "Customize Your Origin",
initiallyEnabled: enableOptionalOrigins,
onChangePromise:
this.handleOptionalOriginsPreferenceChangePromise,
},
]}
variant="builder"
/>
<FormSelectField
heading="Advancement Type"
description="Story-based character progression / XP-based character progression"
onChangePromise={this.handleProgressionPreferenceChangePromise}
initialOptionRemoved={true}
options={advancementOptions}
initialValue={"" + progressionType}
block={true}
/>
<FormSelectField
heading="Hit Point Type"
description="When leveling up, increase hit points by the fixed value for your chosen class or manually enter a rolled value"
onChangePromise={this.handleHitPointPreferenceChangePromise}
initialOptionRemoved={true}
options={hpOptions}
initialValue={"" + hitPointType}
block={true}
/>
<FormCheckBoxesField
heading="Use Prerequisites"
description="Allow or restrict choices based on rule prerequisites for the following for this character"
checkboxes={[
{
label: "Feats",
initiallyEnabled: enforceFeatRules,
onChange: this.handlePreferenceChange.bind(
this,
"enforceFeatRules"
),
},
{
label: "Multiclass Requirements",
initiallyEnabled: enforceMulticlassRules,
onChange: this.handlePreferenceChange.bind(
this,
"enforceMulticlassRules"
),
},
]}
variant="builder"
/>
<FormCheckBoxesField
heading="Show Level-Scaled Spells"
description="Display and highlight available spells to cast with higher level spell slots"
checkboxes={[
{
label: "Show Level-Scaled Spells",
initiallyEnabled: showScaledSpells,
onChange: this.handlePreferenceChange.bind(
this,
"showScaledSpells"
),
},
]}
variant="builder"
/>
<FormSelectField
heading="Encumbrance Type"
description="Use the standard encumbrance rules / Disable the encumbrance display / Use the more detailed rules for encumbrance"
onChange={this.handleIntPreferenceChange.bind(
this,
"encumbranceType"
)}
initialOptionRemoved={true}
options={encumbranceOptions}
initialValue={"" + encumbranceType}
block={true}
/>
<FormCheckBoxesField
heading="Ignore Coin Weight"
description="Coins do not count against your total weight carried (50 coins weigh 1 lb.)"
checkboxes={[
{
label: "Ignore Coin Weight",
initiallyEnabled: ignoreCoinWeight,
onChange: this.handlePreferenceChange.bind(
this,
"ignoreCoinWeight"
),
},
]}
variant="builder"
/>
<FormSelectField
heading="Ability Score/Modifier Display"
description="Reverse the arrangement of ability modifiers and scores"
onChange={this.handleIntPreferenceChange.bind(
this,
"abilityScoreDisplayType"
)}
initialOptionRemoved={true}
options={abilityDisplayOptions}
initialValue={"" + abilityScoreDisplayType}
block={true}
/>
<PrivacyTypeRadio
initialValue={privacyType}
handleChange={(e) =>
this.handleChangePrivacy(parseInt(e.target.value))
}
variant="builder"
/>
</div>
);
};
handleAddPremadeInfo = (): void => {
const { dispatch, characterId } = this.props;
const data = {
characterId: characterId,
publishStatus: PremadeInfoStatus.DRAFT,
definition: {
longDescription: null,
shortDescription: null,
imageUrl: null,
imageAltText: null,
mobileImageUrl: null,
mobileImageAccessibility: null,
themeColor: null,
},
};
dispatch(characterActions.premadeInfoAdd(data));
};
handleDeletePremadeInfo = (): void => {
const { dispatch, characterId } = this.props;
dispatch(characterActions.premadeInfoDelete(characterId));
};
handlePremadeInfoChanged = (premadeInfo: PremadeInfo): void => {
const { dispatch } = this.props;
dispatch(characterActions.premadeInfoUpdate(premadeInfo));
};
renderPremadePreferences = (): React.ReactNode => {
const { premadeInfo } = this.props;
const statusOptions: Array<HtmlSelectOption> = [
{ label: "Draft", value: PremadeInfoStatus.DRAFT },
{ label: "Published", value: PremadeInfoStatus.PUBLISHED },
{ label: "Archived", value: PremadeInfoStatus.ARCHIVED },
];
const inputAttributes = {
disabled: !premadeInfo,
} as HTMLAttributes<HTMLInputElement>;
return (
<div className="home-manage-preferences">
<FormToggleField
heading="Premade Character"
toggleLabel="Enabled official character"
description="Toggle on to make this a premade character. You cannot disable this if the premade status is Published."
initiallyEnabled={!!premadeInfo}
onChange={(toggledOn) => {
toggledOn
? this.handleAddPremadeInfo()
: this.handleDeletePremadeInfo();
}}
isReadOnly={
premadeInfo?.publishStatus === PremadeInfoStatus.PUBLISHED
}
/>
{premadeInfo && (
<>
<RadioGroup
name="premadeStatus"
label="Publish Status"
subtitle="Mark this character as published when you are ready for it to be publically visible."
value={premadeInfo.publishStatus}
options={statusOptions}
disabled={!premadeInfo}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
premadeInfo.publishStatus = event.currentTarget
.value as PremadeInfoStatus;
this.handlePremadeInfoChanged(premadeInfo);
this.forceUpdate();
}}
/>
<FormInputField
label="Long Description"
initialValue={premadeInfo.definition.longDescription}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.longDescription = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
<FormInputField
label="Short Description"
initialValue={premadeInfo.definition.shortDescription}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.shortDescription = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
<FormInputField
label="Image Url"
initialValue={premadeInfo.definition.imageUrl}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.imageUrl = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
<FormInputField
label="Image Alt Text"
initialValue={premadeInfo.definition.imageAltText}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.imageAltText = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
<FormInputField
label="Mobile Image Url"
initialValue={premadeInfo.definition.mobileImageUrl}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.mobileImageUrl = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
<FormInputField
label="Mobile Image Accessibility"
initialValue={premadeInfo.definition.mobileImageAccessibility}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.mobileImageAccessibility = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
<FormInputField
label="Theme Color Hex Code"
initialValue={premadeInfo.definition.themeColor}
inputAttributes={inputAttributes}
onBlur={(value: string) => {
premadeInfo.definition.themeColor = value;
this.handlePremadeInfoChanged(premadeInfo);
}}
/>
</>
)}
</div>
);
};
render() {
const { userRoles } = this.props;
const isLorekeeper =
userRoles?.includes(UserRoles.LOREKEEPER) ||
userRoles?.includes(UserRoles.ADMIN);
return (
<Page clsNames={["home-manage"]}>
<PageBody>
{isLorekeeper && (
<div style={{ border: "5px solid hotpink" }}>
<PageHeader>
Premade Character Preferences - Lorekeepers Only
</PageHeader>
{this.renderPremadePreferences()}
</div>
)}
<Typography fontSize={24}>Character Preferences</Typography>
{this.renderPreferences()}
<div className="home-manage__version">
<div className="home-manage__version-label">Version:</div>
<div className="home-manage__version-value">{config.version}</div>
</div>
</PageBody>
</Page>
);
}
}
const HomeBasicInfoContainer = (props: Props) => {
const { createModal } = useModalManager();
return <HomeBasicInfo {...props} createModal={createModal} />;
};
export default ConnectedBuilderPage(
HomeBasicInfoContainer,
RouteKey.HOME_BASIC_INFO,
(state: BuilderAppState) => ({
preferences: rulesEngineSelectors.getCharacterPreferences(state),
ruleData: rulesEngineSelectors.getRuleData(state),
activeSourceCategories:
rulesEngineSelectors.getActiveSourceCategories(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
classes: rulesEngineSelectors.getClasses(state),
species: rulesEngineSelectors.getRace(state),
classSpellListSpellsLookup:
rulesEngineSelectors.getClassSpellListSpellsLookup(state),
userRoles: appEnvSelectors.getUserRoles(state),
premadeInfo: characterSelectors.getPremadeInfo(state),
characterId: rulesEngineSelectors.getId(state),
})
);
@@ -0,0 +1,4 @@
import HomeBasicInfo from "./HomeBasicInfo";
export default HomeBasicInfo;
export { HomeBasicInfo };
@@ -0,0 +1,103 @@
import React from "react";
import { RouteKey } from "~/subApps/builder/constants";
import { CollapsibleContent } from "../../../../../../components/CollapsibleContent";
import { appEnvSelectors } from "../../../../Shared/selectors";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import { BuilderAppState } from "../../../typings";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props {
isMobile: boolean;
lowerCase: { singular: string; plural: string; desc: string };
}
class HomeHelp extends React.PureComponent<Props> {
render() {
const { isMobile } = this.props;
return (
<Page>
<PageBody>
<CollapsibleContent
forceShow={!isMobile}
heading={
<>
<PageHeader>Creating a Character</PageHeader>
<p>
Your first step in playing an adventure in the Dungeons &
Dragons game is to imagine and create a character of your own.
Your character is a combination of game statistics,
roleplaying hooks, and your imagination.
</p>
</>
}
>
<p>
You choose a class (such as Fighter or Wizard), a background (such
as Artisan or Soldier), and a species (such as Human or Halfling).
You also invent the personality, appearance, and backstory of your
character. Once completed, your character serves as your
representative in the game, your avatar in the Dungeons &amp;
Dragons multiverse.
</p>
<p>
Before you dive into the character builder, think about the kind
of adventurer you want to play. You might be a courageous Fighter,
a skulking Rogue, a fervent Cleric, or a flamboyant Wizard. Or you
might be more interested in an unconventional character, such as a
brawny Rogue who likes hand-to-hand combat, or a sharpshooter who
picks off enemies from afar. Do you like fantasy fiction featuring
Dwarves or Elves? Try building a character of one of those
species. Do you want your character to be the toughest adventurer
at the table? Consider a class like Barbarian or Paladin. Once you
have a character in mind, follow the steps in this builder in
order, making decisions that reflect the character you want. Your
conception of your character might evolve with each choice you
make. What's important is that you come to the table with a
character you're excited to play.
</p>
</CollapsibleContent>
<CollapsibleContent
forceShow={!isMobile}
heading={
<>
<PageHeader>Character Level</PageHeader>
<p>
Typically, your character begins play at 1st level. In the
next step, adjust your level if youre playing in a
higher-powered campaign.
</p>
</>
}
>
<p>
As your character goes on adventures and overcomes challenges,
they gain experience, represented by Experience Points (XP). Once
you reach a specified XP total, you gain a new level. Adjusting
your character level will change your XP total and vice versa.
</p>
</CollapsibleContent>
<PageHeader>Preferences</PageHeader>
<p>
The next step also includes various preferences for your character.
You can proceed with the default options or make changes if desired.
</p>
</PageBody>
</Page>
);
}
}
export default ConnectedBuilderPage(
HomeHelp,
RouteKey.HOME_HELP,
(state: BuilderAppState) => {
return {
isMobile: appEnvSelectors.getIsMobile(state),
};
}
);
@@ -0,0 +1,4 @@
import HomeHelp from "./HomeHelp";
export default HomeHelp;
export { HomeHelp };
@@ -0,0 +1,122 @@
import React from "react";
import { RouteKey } from "~/subApps/builder/constants";
import { CollapsibleContent } from "../../../../../../components/CollapsibleContent";
import { appEnvSelectors } from "../../../../Shared/selectors";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import { BuilderAppState } from "../../../typings";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props {
isMobile: boolean;
}
const SpeciesHelp: React.FC<Props> = ({ isMobile }) => {
return (
<Page>
<PageBody>
<PageHeader>Choose your Species</PageHeader>
<CollapsibleContent
forceShow={!isMobile}
heading={
<p>
Choose a species in the next step. Your choice of species affects
many different aspects of your character. It establishes
fundamental qualities that exist throughout your character's
adventuring career. When making this decision, keep in mind the
kind of character you want to play.
</p>
}
>
<p>
For example, a halfling could be a good choice for a sneaky rogue, a
dwarf makes a tough warrior, and an elf can be a master of arcane
magic.
</p>
<p>
Your character species not only affects your ability scores and
traits but also provides the cues for building your character's
story. Each species' description includes information to help you
roleplay a character of that species, including personality,
physical appearance, features of society, and alignment tendencies.
</p>
<p>
These details are suggestions to help you think about your
character; adventurers can deviate widely from the norm for their
species. It's worthwhile to consider why your character is
different, as a helpful way to think about your character's
background and personality.
</p>
</CollapsibleContent>
<PageHeader>Traits</PageHeader>
<CollapsibleContent
forceShow={!isMobile}
heading={
<p>
The description of each species includes traits that are common to
members of that species. The following entries appear among the
traits of most species.
</p>
}
>
<p>
<strong>AGE</strong>
<br />
The age entry notes the age when a member of the species is
considered an adult, as well as the species' expected lifespan. This
information can help you decide how old your character is at the
start of the game. You can choose any age for your character, which
could provide an explanation for some of your ability scores.
</p>
<p>
For example, if you play a young or very old character, your age
could explain a particularly low Strength or Constitution score,
while advanced age could account for a high Intelligence or Wisdom.
</p>
<p>
<strong>SIZE</strong>
<br />
Characters of most species are Medium, a size category including
creatures that are roughly 4 to 8 feet tall. Members of a few
species are Small (between 2 and 4 feet tall), which means that
certain rules of the game affect them differently.
</p>
<p>
<strong>SPEED</strong>
<br />
Your speed determines how far you can move when traveling and
fighting.
</p>
<p>
<strong>LANGUAGES</strong>
<br />
By virtue of your species, your character can speak, read, and write
certain languages.
</p>
</CollapsibleContent>
</PageBody>
</Page>
);
};
export default ConnectedBuilderPage(
SpeciesHelp,
RouteKey.RACE_HELP,
(state: BuilderAppState) => {
return {
isMobile: appEnvSelectors.getIsMobile(state),
};
}
);
@@ -0,0 +1,4 @@
import SpeciesHelp from "./SpeciesHelp";
export default SpeciesHelp;
export { SpeciesHelp };
@@ -0,0 +1,358 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
EntitledEntity,
RacialTraitDefinitionContract,
DefinitionPool,
DefinitionPoolUtils,
Constants,
DefinitionUtils,
RacialTrait,
RacialTraitUtils,
OptionalOrigin,
Race,
RaceUtils,
OptionalOriginUtils,
OptionalOriginLookup,
HelperUtils,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils, TypeScriptUtils } from "../../../../../Shared/utils";
import { OptionalFeature } from "../../../../components/OptionalFeature";
import { AffectedFeatureInfo } from "../../../../components/OptionalFeature/OptionalFeature";
import { PageSubHeader } from "../../../../components/PageSubHeader";
interface OptionalOriginManagerProps {
species: Race;
definitionPool: DefinitionPool;
optionalOriginLookup: OptionalOriginLookup;
loadAvailableOptionalSpeciesTraits?: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<RacialTraitDefinitionContract>>
>;
onDefinitionsLoaded?: (
definitionData: EntitledEntity<RacialTraitDefinitionContract>
) => void;
onSelection: (
definitionKey: string,
affectedSpeciesTraitDefinitionKey: string | null
) => void;
onChangeReplacementPromise?: (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
) => void;
onRemoveSelectionPromise?: (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
ruleData: RuleData;
}
interface OptionalOriginManagerState {
loadingStatus: DataLoadingStatusEnum;
}
export class OptionalOriginManager extends React.PureComponent<
OptionalOriginManagerProps,
OptionalOriginManagerState
> {
loadOptionalOriginsCanceler: null | Canceler = null;
constructor(props: OptionalOriginManagerProps) {
super(props);
this.state = {
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
};
}
componentDidMount() {
const { loadAvailableOptionalSpeciesTraits, onDefinitionsLoaded } =
this.props;
const { loadingStatus } = this.state;
if (
loadAvailableOptionalSpeciesTraits &&
loadingStatus === DataLoadingStatusEnum.NOT_LOADED
) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableOptionalSpeciesTraits({
cancelToken: new axios.CancelToken((c) => {
this.loadOptionalOriginsCanceler = c;
}),
})
.then((response) => {
let data = ApiAdapterUtils.getResponseData(response);
if (data?.definitionData.length && onDefinitionsLoaded) {
onDefinitionsLoaded(data);
}
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadOptionalOriginsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
}
}
componentWillUnmount(): void {
if (this.loadOptionalOriginsCanceler !== null) {
this.loadOptionalOriginsCanceler();
}
}
getAffectedFeatureInfos = (origin: RacialTrait): AffectedFeatureInfo[] => {
const { definitionPool, species } = this.props;
return RacialTraitUtils.getAffectedFeatureDefinitionKeys(origin)
.map((definitionKey: string) =>
RacialTraitUtils.simulateRacialTrait(definitionKey, definitionPool)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.map((affectedSpeciesTrait: RacialTrait) => {
const definitionKey =
RacialTraitUtils.getDefinitionKey(affectedSpeciesTrait);
let disabled = RaceUtils.getOptionalOrigins(species).some(
(originMapping: OptionalOrigin) =>
OptionalOriginUtils.getDefinitionKey(originMapping) !==
RacialTraitUtils.getDefinitionKey(origin) &&
OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
originMapping
) === definitionKey
);
return {
name: RacialTraitUtils.getName(affectedSpeciesTrait),
definitionKey,
disabled,
};
});
};
renderOptionalOriginCta = (): React.ReactNode => {
return (
<div className="ct-optional-origin-manager__content">
<div className="ct-optional-origin-manager__content--empty">
<span>
You currently have no available custom origins for this character
</span>
<div className="ct-character-tools__marketplace-callout">
Unlock all official options in the{" "}
<Link href="/marketplace">Marketplace</Link> or create them for{" "}
<Link href="/my-creations">Homebrew</Link> Species.
</div>
</div>
</div>
);
};
render() {
const { loadingStatus } = this.state;
const {
definitionPool,
species,
optionalOriginLookup,
onChangeReplacementPromise,
onRemoveSelectionPromise,
onSelection,
} = this.props;
let contentNode: React.ReactNode;
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
contentNode = <LoadingPlaceholder />;
} else {
let availableOptionalOrigins: RacialTrait[] =
DefinitionPoolUtils.getTypedDefinitionList(
Constants.DefinitionTypeEnum.RACIAL_TRAIT,
definitionPool
)
.map((speciesTraitDef) =>
RacialTraitUtils.simulateRacialTrait(
DefinitionUtils.getDefinitionKey(speciesTraitDef),
definitionPool
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.filter((speciesTrait) =>
RacialTraitUtils.isValidRaceRacialTrait(species, speciesTrait)
)
.filter(
(speciesTrait) =>
RacialTraitUtils.getFeatureType(speciesTrait) !==
Constants.FeatureTypeEnum.GRANTED
);
let availableOptionalOriginLookup = HelperUtils.generateNonNullLookup(
availableOptionalOrigins,
RacialTraitUtils.getDefinitionKey
);
const unEntitledOptionalSpeciesTraits: RacialTrait[] = [];
Object.keys(optionalOriginLookup).forEach((definitionKey) => {
if (!availableOptionalOriginLookup.hasOwnProperty(definitionKey)) {
const speciesTrait = RacialTraitUtils.simulateRacialTrait(
definitionKey,
definitionPool
);
if (
speciesTrait &&
RacialTraitUtils.isValidRaceRacialTrait(species, speciesTrait)
) {
unEntitledOptionalSpeciesTraits.push(speciesTrait);
}
}
});
const consolidatedOptionalOrigins: RacialTrait[] = [
...availableOptionalOrigins,
...unEntitledOptionalSpeciesTraits,
].filter(
(speciesTrait) =>
!RacialTraitUtils.deriveHideInContext(
speciesTrait,
Constants.AppContextTypeEnum.BUILDER
)
);
if (!consolidatedOptionalOrigins.length) {
contentNode = this.renderOptionalOriginCta();
} else {
const replacementOrigins: RacialTrait[] = [];
const additionalOrigins: RacialTrait[] = [];
consolidatedOptionalOrigins.forEach((speciesTrait) => {
switch (RacialTraitUtils.getFeatureType(speciesTrait)) {
case Constants.FeatureTypeEnum.ADDITIONAL:
additionalOrigins.push(speciesTrait);
break;
case Constants.FeatureTypeEnum.REPLACEMENT:
replacementOrigins.push(speciesTrait);
break;
default:
//not implemented
}
});
contentNode = (
<div className="ct-optional-origin-manager__content">
{replacementOrigins.length > 0 && (
<React.Fragment>
<PageSubHeader>Replacement Traits</PageSubHeader>
{RaceUtils.deriveOrderedRacialTraits(replacementOrigins).map(
(origin: RacialTrait) => {
const optionalOriginMapping =
HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
RacialTraitUtils.getDefinitionKey(origin)
);
const affectedFeatureDefinitionKey = optionalOriginMapping
? OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
optionalOriginMapping
)
: null;
return (
<OptionalFeature
key={RacialTraitUtils.getDefinitionKey(origin)}
name={`Origin ${RacialTraitUtils.getName(origin)}`}
description={RacialTraitUtils.getDescription(origin)}
featureType={RacialTraitUtils.getFeatureType(origin)}
definitionKey={RacialTraitUtils.getDefinitionKey(
origin
)}
affectedFeatureDefinitionKey={
affectedFeatureDefinitionKey
}
isSelected={!!optionalOriginMapping ?? false}
onSelection={onSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
affectedFeatures={this.getAffectedFeatureInfos(origin)}
accessType={RacialTraitUtils.getAccessType(origin)}
/>
);
}
)}
</React.Fragment>
)}
{additionalOrigins.length > 0 && (
<React.Fragment>
<PageSubHeader>Additional Traits</PageSubHeader>
{RaceUtils.deriveOrderedRacialTraits(additionalOrigins).map(
(origin: RacialTrait) => {
const optionalOriginMapping =
HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
RacialTraitUtils.getDefinitionKey(origin)
);
const affectedFeatureDefinitionKey = optionalOriginMapping
? OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
optionalOriginMapping
)
: null;
return (
<OptionalFeature
key={RacialTraitUtils.getDefinitionKey(origin)}
name={RacialTraitUtils.getName(origin)}
description={RacialTraitUtils.getDescription(origin)}
featureType={RacialTraitUtils.getFeatureType(origin)}
definitionKey={RacialTraitUtils.getDefinitionKey(
origin
)}
affectedFeatureDefinitionKey={
affectedFeatureDefinitionKey
}
isSelected={!!optionalOriginMapping ?? false}
onSelection={onSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
affectedFeatures={this.getAffectedFeatureInfos(origin)}
accessType={RacialTraitUtils.getAccessType(origin)}
/>
);
}
)}
</React.Fragment>
)}
</div>
);
}
}
return (
<div className="ct-optional-origin-manager">
<div className="ct-optional-origin-manager__intro">
The following options allow you to customize aspects of your character
such as ability scores, languages, and certain proficiencies to fit
the origin you have in mind.
</div>
{contentNode}
</div>
);
}
}
export default OptionalOriginManager;
@@ -0,0 +1,541 @@
import React from "react";
import { DispatchProp } from "react-redux";
import { NavigateFunction, useNavigate } from "react-router-dom";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiResponse,
characterActions,
CharacterPreferences,
ChoiceData,
ClassSpellListSpellsLookup,
Constants,
DefinitionPool,
DefinitionUtils,
EntitledEntity,
FeatDefinitionContract,
FeatLookup,
HelperUtils,
OptionalOriginLookup,
OptionalOriginUtils,
PrerequisiteData,
Race,
RaceUtils,
RacialTraitDefinitionContract,
RacialTraitUtils,
RuleData,
RuleDataUtils,
rulesEngineSelectors,
serviceDataActions,
serviceDataSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { HtmlContent } from "~/components/HtmlContent";
import { SummaryList } from "~/components/SummaryList";
import { TabList } from "~/components/TabList";
import { RouteKey } from "~/subApps/builder/constants";
import {
ModalData,
useModalManager,
} from "~/subApps/builder/contexts/ModalManager";
import { SimpleClassSpellList } from "../../../../Shared/components/SimpleClassSpellList";
import { apiCreatorSelectors } from "../../../../Shared/selectors";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import SpeciesTraitList from "../../../components/SpeciesTraitList";
import { navigationConfig } from "../../../config";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
import { OptionalOriginManager } from "./OptionalOriginManager";
import styles from "./styles.module.css";
interface Props extends DispatchProp {
ruleData: RuleData;
preferences: CharacterPreferences;
prerequisiteData: PrerequisiteData;
choiceInfo: ChoiceData;
species: Race | null;
featLookup: FeatLookup;
definitionPool: DefinitionPool;
optionalOriginLookup: OptionalOriginLookup;
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
navigate: NavigateFunction;
characterId: number;
loadAvailableOptionalSpeciesTraits: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<RacialTraitDefinitionContract>>
>;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<FeatDefinitionContract[]>>;
createModal: (modal: ModalData) => void;
}
class SpeciesManage extends React.PureComponent<Props> {
handleChangeSpecies = (): void => {
const { navigate, characterId } = this.props;
navigate(
navigationConfig
.getRouteDefPath(RouteKey.RACE_CHOOSE)
.replace(":characterId", characterId)
);
};
handleSpeciesTraitChoiceChange = (
speciesTraitId,
choiceId,
choiceType,
optionValue
): void => {
const { dispatch } = this.props;
dispatch(
characterActions.racialTraitChoiceSetRequest(
speciesTraitId,
choiceType,
choiceId,
optionValue
)
);
};
handleDefinitionsLoaded = (
entitledData: EntitledEntity<RacialTraitDefinitionContract>
): void => {
const { dispatch } = this.props;
dispatch(
serviceDataActions.definitionPoolAdd(
entitledData.definitionData,
entitledData.accessTypes
)
);
};
handleOptionalOriginSelection = (
definitionKey: string,
affectedSpeciesTraitDefinitionKey: string | null
): void => {
const { dispatch } = this.props;
const speciesTraitId =
DefinitionUtils.hack__getDefinitionKeyId(definitionKey);
if (speciesTraitId === null) {
return;
}
const affectedSpeciesTraitId = affectedSpeciesTraitDefinitionKey
? DefinitionUtils.hack__getDefinitionKeyId(
affectedSpeciesTraitDefinitionKey
)
: null;
dispatch(
characterActions.optionalOriginCreate(
speciesTraitId,
affectedSpeciesTraitId
)
);
};
handleRemoveOptionalOriginSelectionPromise = (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
): void => {
const {
dispatch,
createModal,
optionalOriginLookup,
classSpellListSpellsLookup,
} = this.props;
const optionalOrigin = HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
definitionKey
);
if (optionalOrigin === null) {
return;
}
const optionalSpeciesTrait =
OptionalOriginUtils.getRacialTrait(optionalOrigin);
if (optionalSpeciesTrait === null) {
return;
}
const speciesTraitId = OptionalOriginUtils.getRacialTraitId(optionalOrigin);
const spellListIds =
OptionalOriginUtils.getRemoveMappingSpellListIds(optionalOrigin);
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
dispatch(characterActions.optionalOriginDestroy(speciesTraitId));
accept();
} else {
createModal({
content: (
<div>
<p>
You are about to remove{" "}
<strong>{RacialTraitUtils.getName(optionalSpeciesTrait)}</strong>{" "}
from your character.
</p>
<p>
After doing so, the following spells provided by this feature will
be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Customized Origin Warning",
variant: "remove",
size: "fit-content",
confirmButtonText: "Remove",
onConfirm: () => {
dispatch(characterActions.optionalOriginDestroy(speciesTraitId));
accept();
},
onClose: () => {
reject();
},
},
});
}
};
handleOnChangeOptionalOriginReplacementPromise = (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
): void => {
const {
dispatch,
createModal,
optionalOriginLookup,
classSpellListSpellsLookup,
} = this.props;
if (newAffectedDefinitionKey === oldAffectedDefinitionKey) {
return;
}
const optionalOrigin = HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
definitionKey
);
if (optionalOrigin === null) {
return;
}
const optionalSpeciesTrait =
OptionalOriginUtils.getRacialTrait(optionalOrigin);
if (optionalSpeciesTrait === null) {
return;
}
const speciesTraitId = OptionalOriginUtils.getRacialTraitId(optionalOrigin);
const newAffectedSpeciesTraitId: number | null = newAffectedDefinitionKey
? DefinitionUtils.hack__getDefinitionKeyId(newAffectedDefinitionKey)
: null;
const spellListIds =
OptionalOriginUtils.getUpdateMappingSpellListIdsToRemove(optionalOrigin, {
affectedRacialTraitId: newAffectedSpeciesTraitId,
});
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
dispatch(
characterActions.optionalOriginSetRequest(
speciesTraitId,
newAffectedSpeciesTraitId
)
);
accept();
} else {
createModal({
content: (
<div>
<p>
You are about to change the Species Trait to be replaced by{" "}
<strong>{RacialTraitUtils.getName(optionalSpeciesTrait)}</strong>
</p>
<p>
After doing so, the following spells provided by this feature will
be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Customized Origin Warning",
variant: "remove",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.optionalOriginSetRequest(
speciesTraitId,
newAffectedSpeciesTraitId
)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
renderSpeciesTraitsGroup = (): React.ReactNode => {
const {
species,
loadAvailableFeats,
choiceInfo,
prerequisiteData,
preferences,
featLookup,
optionalOriginLookup,
definitionPool,
} = this.props;
if (!species) {
return null;
}
const speciesTraits = RaceUtils.getVisibleRacialTraits(species);
const filteredSpeciesTraits =
RacialTraitUtils.filterRacialTraitsByDisplayConfigurationType(
speciesTraits,
[
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT,
Constants.DisplayConfigurationTypeEnum.LANGUAGE,
]
);
return (
<div className="race-detail-secondary">
{filteredSpeciesTraits.length > 0 && (
<div className="race-detail-racial-traits">
<SpeciesTraitList
speciesTraits={filteredSpeciesTraits}
featLookup={featLookup}
loadAvailableFeats={loadAvailableFeats}
handleSpeciesTraitChoiceChange={
this.handleSpeciesTraitChoiceChange
}
choiceInfo={choiceInfo}
prerequisiteData={prerequisiteData}
preferences={preferences}
optionalOriginLookup={optionalOriginLookup}
definitionPool={definitionPool}
/>
</div>
)}
</div>
);
};
renderSpeciesTraitsContent = () => {
const { preferences } = this.props;
if (preferences.enableOptionalOrigins) {
return this.renderTabList();
}
return this.renderSpeciesTraitsGroup();
};
renderTabList = () => {
const {
species,
optionalOriginLookup,
loadAvailableOptionalSpeciesTraits,
definitionPool,
ruleData,
} = this.props;
if (!species) {
return null;
}
return (
<TabList
className="manage-race-tabs"
variant="collapse"
tabs={[
{
label: "Species Traits",
content: this.renderSpeciesTraitsGroup(),
id: "traits",
},
{
label: "Origin Manager",
content: (
<OptionalOriginManager
species={species}
definitionPool={definitionPool}
optionalOriginLookup={optionalOriginLookup}
onDefinitionsLoaded={this.handleDefinitionsLoaded}
loadAvailableOptionalSpeciesTraits={
loadAvailableOptionalSpeciesTraits
}
onSelection={this.handleOptionalOriginSelection}
onChangeReplacementPromise={
this.handleOnChangeOptionalOriginReplacementPromise
}
onRemoveSelectionPromise={
this.handleRemoveOptionalOriginSelectionPromise
}
ruleData={ruleData}
/>
),
id: "Origin Manager",
},
]}
/>
);
};
renderInformationCollapsible = (): React.ReactNode => {
const { ruleData, species } = this.props;
if (species === null) {
return null;
}
const definitionKey = DefinitionUtils.hack__generateDefinitionKey(
RaceUtils.getEntityRaceTypeId(species),
RaceUtils.getEntityRaceId(species)
);
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
[definitionKey],
ruleData,
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT
);
return <HelperTextAccordion builderHelperText={builderText} />;
};
render() {
const { species, ruleData } = this.props;
//TODO render something else if species doesnt exist
if (!species) {
return null;
}
const fullName = RaceUtils.getFullName(species);
const portraitAvatarUrl = RaceUtils.getPortraitAvatarUrl(species);
const description = RaceUtils.getDescription(species);
const moreDetailsUrl = RaceUtils.getMoreDetailsUrl(species);
const previewUrl: string | null =
portraitAvatarUrl ?? RuleDataUtils.getDefaultRaceImageUrl(ruleData);
const descriptionClassNames: string[] = ["race-detail-description"];
const calledOutSpeciesTraits = RaceUtils.getCalledOutRacialTraits(species);
if (calledOutSpeciesTraits.length > 0) {
descriptionClassNames.push("race-detail-description--hide-traits");
}
return (
<Page>
<PageBody>
<div className="manage-race race-detail">
<div className="race-detail-primary">
<div className="race-detail-aside">
<div className="race-detail-preview">
<img
className="race-detail-preview-img"
src={previewUrl ? previewUrl : ""}
alt=""
/>
</div>
<div
className="manage-race-chosen-action"
onClick={this.handleChangeSpecies}
>
Change Species
</div>
</div>
<PageHeader>{fullName}</PageHeader>
<HtmlContent
className={descriptionClassNames.join(" ")}
html={description ? description : ""}
withoutTooltips
/>
{calledOutSpeciesTraits.length > 0 && (
<SummaryList
list={calledOutSpeciesTraits}
title="Species Traits"
className={styles.summaryList}
/>
)}
<div className="race-detail-more">
<a
className="race-detail-more-link"
href={moreDetailsUrl ? moreDetailsUrl : ""}
>
{fullName} Details Page
</a>
</div>
{this.renderInformationCollapsible()}
</div>
{this.renderSpeciesTraitsContent()}
</div>
</PageBody>
</Page>
);
}
}
const SpeciesManageWithHooks = (props) => {
const navigate = useNavigate();
const { createModal } = useModalManager();
return (
<SpeciesManage {...props} navigate={navigate} createModal={createModal} />
);
};
export default ConnectedBuilderPage(
SpeciesManageWithHooks,
RouteKey.RACE_MANAGE,
(state) => {
return {
characterId: rulesEngineSelectors.getId(state),
species: rulesEngineSelectors.getRace(state),
choiceInfo: rulesEngineSelectors.getChoiceInfo(state),
ruleData: rulesEngineSelectors.getRuleData(state),
prerequisiteData: rulesEngineSelectors.getPrerequisiteData(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
featLookup: rulesEngineSelectors.getFeatLookup(state),
definitionPool: serviceDataSelectors.getDefinitionPool(state),
loadAvailableOptionalSpeciesTraits:
apiCreatorSelectors.makeLoadAvailableOptionalRacialTraits(state),
loadAvailableFeats: apiCreatorSelectors.makeLoadAvailableFeats(state),
optionalOriginLookup: rulesEngineSelectors.getOptionalOriginLookup(state),
classSpellListSpellsLookup:
rulesEngineSelectors.getClassSpellListSpellsLookup(state),
};
}
);
@@ -0,0 +1,346 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
CharacterPreferences,
ChoiceData,
ChoiceUtils,
Constants,
CoreUtils,
DefinitionPool,
Feat,
FeatDefinitionContract,
FeatLookup,
FeatUtils,
HelperUtils,
OptionalOriginLookup,
OptionalOriginUtils,
PrerequisiteData,
RacialTrait,
RacialTraitUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { FeatureChoice } from "~/components/FeatureChoice";
import { HtmlContent } from "~/components/HtmlContent";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils } from "../../../../../Shared/utils";
interface Props {
speciesTrait: RacialTrait;
choiceInfo: ChoiceData;
preferences: CharacterPreferences;
prerequisiteData: PrerequisiteData;
featLookup: FeatLookup;
onChoiceChange: (
speciesTraitId: number,
choiceId: string,
type: number,
value: any
) => void;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<Array<FeatDefinitionContract>>>;
optionalOriginLookup: OptionalOriginLookup;
definitionPool: DefinitionPool;
speciesName?: string | null;
}
interface State {
featData: Array<Feat>;
hasFeatChoice: boolean;
collapsibleOpened: boolean;
loadingStatus: DataLoadingStatusEnum;
}
export default class SpeciesManageSpeciesTrait extends React.PureComponent<
Props,
State
> {
constructor(props: Props) {
super(props);
this.state = {
featData: [],
hasFeatChoice: false,
collapsibleOpened: false,
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
};
}
loadFeatsCanceler: null | Canceler = null;
componentDidMount(): void {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { speciesTrait } = this.props;
const { collapsibleOpened } = this.state;
if (speciesTrait !== prevProps.speciesTrait) {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
if (collapsibleOpened && !prevState.collapsibleOpened) {
this.conditionallyLoadFeatData();
}
}
componentWillUnmount(): void {
if (this.loadFeatsCanceler !== null) {
this.loadFeatsCanceler();
}
}
conditionallyLoadFeatData = (): void => {
const { loadAvailableFeats } = this.props;
const { hasFeatChoice, loadingStatus, collapsibleOpened } = this.state;
if (
hasFeatChoice &&
collapsibleOpened &&
loadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableFeats({
cancelToken: new axios.CancelToken((c) => {
this.loadFeatsCanceler = c;
}),
})
.then((response) => {
let featData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featData = data.map((definition) =>
FeatUtils.simulateFeat(definition)
);
}
this.setState({
featData,
loadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
};
getFeatData = (existingFeatId: number | null): Array<Feat> => {
const { featData } = this.state;
const { featLookup } = this.props;
let data: Array<Feat> = [...featData];
if (existingFeatId !== null) {
let existingFeat = HelperUtils.lookupDataOrFallback(
featLookup,
existingFeatId
);
if (
existingFeat !== null &&
!data.some((feat) => FeatUtils.getId(feat) === existingFeatId)
) {
data.push(existingFeat);
}
}
return data;
};
getSummary = () => {
const { speciesTrait, speciesName } = this.props;
// Get base name for the species trait
let name = RacialTraitUtils.getName(speciesTrait);
// Get the feature type for the species trait
const featureType = RacialTraitUtils.getFeatureType(speciesTrait);
// Get the display configuration for the species trait
const displayConfiguration =
RacialTraitUtils.getDisplayConfiguration(speciesTrait);
// Determine if we should add the species name to the name
const addName = displayConfiguration
? CoreUtils.getDisplayConfigurationValue(
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT,
displayConfiguration
) === Constants.DisplayConfigurationValueEnum.ON
: false;
// Add the species name to the name if necessary
if (speciesName && addName) name = `${name} (${speciesName})`;
// Add "origin" to the name if it's an optional origin replacement feature
if (featureType === Constants.FeatureTypeEnum.REPLACEMENT)
name = `Origin ${name}`;
// Return constructed name
return name;
};
getMetaItems = () => {
const { speciesTrait, optionalOriginLookup, definitionPool } = this.props;
const displayConfiguration =
RacialTraitUtils.getDisplayConfiguration(speciesTrait);
const choices = RacialTraitUtils.getChoices(speciesTrait);
let metaItems: Array<string> = [];
if (choices.length) {
metaItems.push(
`${choices.length} Choice${choices.length !== 1 ? "s" : ""}`
);
}
const featureType = RacialTraitUtils.getFeatureType(speciesTrait);
const addOrigin = displayConfiguration
? CoreUtils.getDisplayConfigurationValue(
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT,
displayConfiguration
) === Constants.DisplayConfigurationValueEnum.OFF
: false;
if (featureType !== Constants.FeatureTypeEnum.GRANTED || addOrigin) {
metaItems.push("Origin");
}
if (featureType === Constants.FeatureTypeEnum.REPLACEMENT) {
const optionalOrigin = HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
RacialTraitUtils.getDefinitionKey(speciesTrait)
);
if (optionalOrigin) {
const affectedDefinitionKey =
OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
optionalOrigin
);
if (affectedDefinitionKey) {
const replacedSpeciesTrait = RacialTraitUtils.simulateRacialTrait(
affectedDefinitionKey,
definitionPool
);
if (replacedSpeciesTrait) {
metaItems.push(
`Replaces ${RacialTraitUtils.getName(replacedSpeciesTrait)}`
);
}
}
}
}
return metaItems;
};
hasFeatChoice = (props: Props): boolean => {
const { speciesTrait } = props;
const choices = RacialTraitUtils.getChoices(speciesTrait);
return choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
};
handleCollapsibleOpened = (id: string, state: boolean): void => {
this.setState({
collapsibleOpened: state,
});
};
handleChoiceChange = (choiceId: string, type: number, value: any): void => {
const { onChoiceChange, speciesTrait } = this.props;
if (onChoiceChange) {
onChoiceChange(
RacialTraitUtils.getId(speciesTrait),
choiceId,
type,
HelperUtils.parseInputInt(value)
);
}
};
renderChoices = (): React.ReactNode => {
const { speciesTrait } = this.props;
const { featData } = this.state;
const choices = RacialTraitUtils.getChoices(speciesTrait);
if (choices === null) {
return null;
}
return choices.map((choice) => (
<FeatureChoice
choice={choice}
featsData={featData}
onChoiceChange={this.handleChoiceChange}
key={ChoiceUtils.getId(choice)}
/>
));
};
render() {
const { speciesTrait } = this.props;
const { hasFeatChoice, loadingStatus } = this.state;
const description = RacialTraitUtils.getDescription(speciesTrait);
const choices = RacialTraitUtils.getChoices(speciesTrait);
const accordionId = RacialTraitUtils.getId(speciesTrait).toString();
const hasTodoChoices: boolean = choices.some(
(choice) => ChoiceUtils.getOptionValue(choice) === null
);
const conClsNames: Array<string> = ["race-detail-racial-trait-name"];
if (hasTodoChoices) {
conClsNames.push("collapsible-todo");
}
let contentNode: React.ReactNode;
if (hasFeatChoice) {
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
contentNode = this.renderChoices();
} else {
contentNode = <LoadingPlaceholder />;
}
} else {
contentNode = this.renderChoices();
}
return (
<Accordion
id={accordionId}
summary={this.getSummary()}
summaryMetaItems={this.getMetaItems()}
variant="paper"
showAlert={hasTodoChoices}
handleIsOpen={this.handleCollapsibleOpened}
>
<HtmlContent
className="race-detail-racial-trait-description"
html={description ? description : ""}
withoutTooltips
/>
{contentNode}
</Accordion>
);
}
}
@@ -0,0 +1,4 @@
import SpeciesManageSpeciesTrait from "./SpeciesManageSpeciesTrait";
export default SpeciesManageSpeciesTrait;
export { SpeciesManageSpeciesTrait };
@@ -0,0 +1,5 @@
import SpeciesManage from "./SpeciesManage";
import SpeciesManageSpeciesTrait from "./SpeciesManageSpeciesTrait";
export default SpeciesManage;
export { SpeciesManage, SpeciesManageSpeciesTrait };
@@ -0,0 +1,293 @@
import React from "react";
import { DispatchProp } from "react-redux";
import {
LightExportSvg,
DisabledExportSvg,
DarkExportSvg,
} from "@dndbeyond/character-components/es";
import { Link } from "~/components/Link";
import { ExportPdfData, usePdfExport } from "~/hooks/usePdfExport";
import { RouteKey } from "~/subApps/builder/constants";
import { BuilderLinkButton } from "../../../../Shared/components/common/LinkButton";
import { appEnvSelectors } from "../../../../Shared/selectors";
import { ClipboardUtils, MobileMessengerUtils } from "../../../../Shared/utils";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import { builderEnvSelectors, builderSelectors } from "../../../selectors";
import { BuilderAppState } from "../../../typings";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
interface Props extends DispatchProp {
characterId: number | null;
characterListingUrl: string;
characterSheetUrl: string;
isCharacterSheetReady: boolean;
exportPdfData: ExportPdfData;
}
interface State {
hasCopied: boolean;
}
class WhatsNext extends React.PureComponent<Props, State> {
urlInput = React.createRef<HTMLInputElement>();
constructor(props: Props) {
super(props);
this.state = {
hasCopied: false,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const {
exportPdfData: { pdfUrl },
} = this.props;
if (pdfUrl !== null) {
this.selectUrlInput();
}
}
handleExportPdf = (evt: React.MouseEvent): void => {
const {
isCharacterSheetReady,
exportPdfData: { exportPdf, isLoading, isFinished },
} = this.props;
if (isCharacterSheetReady && !isLoading && !isFinished) {
exportPdf();
}
};
selectUrlInput = (): void => {
if (this.urlInput.current) {
this.urlInput.current.focus();
this.urlInput.current.setSelectionRange(
0,
this.urlInput.current.value.length
);
}
};
handleClick = (evt: React.MouseEvent): void => {
const {
exportPdfData: { pdfUrl },
} = this.props;
ClipboardUtils.copyTextToClipboard(pdfUrl === null ? "" : pdfUrl);
this.setState({
hasCopied: true,
});
};
handleSheetShowClick = (isDisabled: boolean) => {
const { characterId } = this.props;
if (characterId !== null && !isDisabled) {
MobileMessengerUtils.sendMessage(
MobileMessengerUtils.createShowCharacterSheetMessage(characterId)
);
}
};
renderPdfButton = (): React.ReactNode => {
const {
isCharacterSheetReady,
exportPdfData: { isLoading, isFinished },
} = this.props;
// const { pdfLoadingStatus } = this.state;
let pdfButtonClasses: Array<string> = [
"ct-button",
"builder-button",
"whats-next-action-pdf",
"character-button-oversized",
"character-tools-export-pdf-button",
];
let iconNode: React.ReactNode;
if (isCharacterSheetReady) {
iconNode = <LightExportSvg />;
} else {
pdfButtonClasses.push("character-button-disabled");
pdfButtonClasses.push("character-button-oversized-disabled");
iconNode = <DisabledExportSvg />;
}
if (isLoading) {
return (
<div
className={pdfButtonClasses.join(" ")}
onClick={this.handleExportPdf}
>
<span className="whats-next-action-icon">{iconNode}</span>
<span className="whats-next-action-text">Exporting PDF...</span>
</div>
);
} else if (isFinished) {
pdfButtonClasses.push("whats-next-action-confirmed");
return (
<div
className={pdfButtonClasses.join(" ")}
onClick={this.handleExportPdf}
>
<span className="whats-next-action-icon"></span>
</div>
);
}
pdfButtonClasses.push("whats-next-action-clickable");
return (
<div
className={pdfButtonClasses.join(" ")}
onClick={this.handleExportPdf}
>
<span className="whats-next-action-icon">{iconNode}</span>
<span className="whats-next-action-text">Export to PDF</span>
</div>
);
};
renderPdfData = (): React.ReactNode => {
const {
exportPdfData: { pdfUrl, isFinished },
} = this.props;
const { hasCopied } = this.state;
if (!isFinished || pdfUrl === null) {
return null;
}
return (
<div className="whats-next-pdf-data">
<div className="whats-next-pdf-data-splash-icon">
<DarkExportSvg />
</div>
<div className="whats-next-pdf-data-header">PDF Generated</div>
<div className="whats-next-pdf-data-url">
<input
type="text"
value={pdfUrl ? pdfUrl.replace(/https*:\/\//, "") : ""}
className="whats-next-pdf-data-input"
ref={this.urlInput}
/>
</div>
<div
className="whats-next-pdf-data-clipboard"
onClick={this.handleClick}
>
{hasCopied ? (
<React.Fragment>Copied! &#x2714;</React.Fragment>
) : (
"Click to Copy"
)}
</div>
{pdfUrl !== null && (
<div className="whats-next-pdf-data-download">
<BuilderLinkButton
url={pdfUrl}
size={"large"}
download={true}
className="whats-next-pdf-data-download-link"
>
Click to Download
</BuilderLinkButton>
</div>
)}
</div>
);
};
render() {
const { characterListingUrl, characterSheetUrl, isCharacterSheetReady } =
this.props;
return (
<Page clsNames={["whats-next"]}>
<PageBody>
<p>
Once you have completed creating your character, you can view your
statistics on the digital character sheet or export it for printing.
</p>
<div className="whats-next-actions">
<div className="whats-next-action">
<BuilderLinkButton
url={characterSheetUrl}
className="whats-next-action-sheet-link"
size="oversized"
disabled={!isCharacterSheetReady}
onClick={this.handleSheetShowClick}
>
<div className="whats-next-action-text">
<div className="whats-next-action-text">
View Character Sheet
{!isCharacterSheetReady && (
<span className="whats-next-action-subtext">
Unavailable - Character Incomplete
</span>
)}
</div>
</div>
</BuilderLinkButton>
</div>
<div className="whats-next-action">{this.renderPdfButton()}</div>
</div>
{this.renderPdfData()}
<div className="whats-next-characters">
<Link href={characterListingUrl}>View all my characters</Link>
</div>
<PageHeader>Come Together</PageHeader>
<p>
Most D&amp;D characters don't work alone. Each character plays a
role within a party, a group of adventurers working together for a
common purpose. Talk to your fellow players and your DM to decide
whether your characters know one another, how they met, and what
sorts of quests the group might undertake.
</p>
<p>
A campaign is a series of adventures undertaken by your group's
characters. If your DM has a campaign, you can join it by visiting
the invite link for the campaign. You can also start your own.
</p>
<div className="whats-next-campaign">
<Link href="/campaigns/create" className="whats-next-campaign-link">
Start a new campaign
</Link>
</div>
</PageBody>
</Page>
);
}
}
function WhatsNextContainer(props) {
const exportPdfData = usePdfExport();
return <WhatsNext {...props} exportPdfData={exportPdfData} />;
}
export default ConnectedBuilderPage(
WhatsNextContainer,
RouteKey.WHATS_NEXT,
(state: BuilderAppState) => {
return {
characterId: appEnvSelectors.getCharacterId(state),
characterListingUrl: builderEnvSelectors.getProfileCharacterListingUrl(),
characterSheetUrl: builderEnvSelectors.getCharacterSheetUrl(state),
isCharacterSheetReady: builderSelectors.checkIsCharacterSheetReady(state),
};
}
);
@@ -0,0 +1,4 @@
import WhatsNext from "./WhatsNext";
export default WhatsNext;
export { WhatsNext };