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,15 @@
export const QUICK_BUILD_REQUEST = "builder.QUICK_BUILD_REQUEST";
export const RANDOM_BUILD_REQUEST = "builder.RANDOM_BUILD_REQUEST";
export const STEP_BUILD_REQUEST = "builder.STEP_BUILD_REQUEST";
export const CHARACTER_LOAD_REQUEST = "builder.CHARACTER_LOAD_REQUEST";
export const BUILDER_METHOD_SET = "builder.BUILDER_METHOD_SET";
export const BUILDER_METHOD_SET_COMMIT = "builder.BUILDER_METHOD_SET_COMMIT";
export const CHARACTER_LOADING_SET_COMMIT =
"builder.CHARACTER_LOADING_SET_COMMIT";
export const CHARACTER_LOADED_SET_COMMIT =
"builder.CHARACTER_LOADED_SET_COMMIT";
export const CONFIRM_SPECIES_SET = "builder.CONFIRM_SPECIES_SET";
export const CONFIRM_CLASS_SET = "builder.CONFIRM_CLASS_SET";
export const SHOW_HELP_TEXT_SET = "builder.SHOW_HELP_TEXT_SET";
export const SUGGESTED_NAMES_SET = "builder.SUGGESTED_NAMES_SET";
export const SUGGESTED_NAMES_REQUEST = "builder.SUGGESTED_NAMES_REQUEST";
@@ -0,0 +1,169 @@
import {
ClassDefinitionContract,
RaceDefinitionContract,
} from "@dndbeyond/character-rules-engine/es";
import { builderActionTypes } from "../builder";
import {
BuilderMethodSetAction,
BuilderMethodSetActionPayload,
BuilderMethodSetCommitAction,
CharacterLoadedSetCommitAction,
CharacterLoadingSetCommitAction,
CharacterLoadRequestAction,
ConfirmClassSetAction,
ConfirmSpeciesSetAction,
QuickBuildRequestAction,
RandomBuildRequestAction,
ShowHelpTextSetAction,
StepBuildRequestAction,
SuggestedNamesRequestAction,
SuggestedNamesSetAction,
} from "./typings";
export function characterLoadRequest(): CharacterLoadRequestAction {
return {
type: builderActionTypes.CHARACTER_LOAD_REQUEST,
payload: {},
};
}
export function quickBuildRequest(
entitySpeciesId: number | null,
entitySpeciesTypeId: number | null,
classId: number | null,
name: string | null
): QuickBuildRequestAction {
return {
type: builderActionTypes.QUICK_BUILD_REQUEST,
payload: {
entityRaceId: entitySpeciesId,
entityRaceTypeId: entitySpeciesTypeId,
classId,
name,
},
};
}
export function randomBuildRequest(
level: number | null,
entitySpeciesId: number | null,
entitySpeciesTypeId: number | null,
classId: number | null,
allowMulticlass: boolean,
allowFeats: boolean,
name: string | null
): RandomBuildRequestAction {
return {
type: builderActionTypes.RANDOM_BUILD_REQUEST,
payload: {
level,
entityRaceId: entitySpeciesId,
entityRaceTypeId: entitySpeciesTypeId,
classId,
allowMulticlass,
allowFeats,
name,
},
};
}
export function stepBuildRequest(
showHelpText: boolean
): StepBuildRequestAction {
return {
type: builderActionTypes.STEP_BUILD_REQUEST,
payload: {
showHelpText,
},
};
}
export function characterLoadingSetCommit(
isLoading: boolean
): CharacterLoadingSetCommitAction {
return {
type: builderActionTypes.CHARACTER_LOADING_SET_COMMIT,
payload: {
isLoading,
},
};
}
export function characterLoadedSetCommit(
isLoaded: boolean
): CharacterLoadedSetCommitAction {
return {
type: builderActionTypes.CHARACTER_LOADED_SET_COMMIT,
payload: {
isLoaded,
},
};
}
export function showHelpTextSet(showHelpText: boolean): ShowHelpTextSetAction {
return {
type: builderActionTypes.SHOW_HELP_TEXT_SET,
payload: {
showHelpText,
},
};
}
export function builderMethodSet(method: string): BuilderMethodSetAction {
return {
type: builderActionTypes.BUILDER_METHOD_SET,
payload: {
method,
},
};
}
export function builderMethodSetCommit(
payload: BuilderMethodSetActionPayload
): BuilderMethodSetCommitAction {
return {
type: builderActionTypes.BUILDER_METHOD_SET_COMMIT,
payload,
};
}
export function confirmSpeciesSet(
species: RaceDefinitionContract
): ConfirmSpeciesSetAction {
return {
type: builderActionTypes.CONFIRM_SPECIES_SET,
payload: {
race: species,
},
};
}
export function confirmClassSet(
charClass: ClassDefinitionContract
): ConfirmClassSetAction {
return {
type: builderActionTypes.CONFIRM_CLASS_SET,
payload: {
charClass,
},
};
}
export function suggestedNamesSet(
suggestedNames: Array<string>
): SuggestedNamesSetAction {
return {
type: builderActionTypes.SUGGESTED_NAMES_SET,
payload: {
suggestedNames,
},
};
}
export function suggestedNamesRequest(): SuggestedNamesRequestAction {
return {
type: builderActionTypes.SUGGESTED_NAMES_REQUEST,
payload: {},
};
}
@@ -0,0 +1,9 @@
import * as actionTypes from "./actionTypes";
import * as actions from "./actions";
import * as typings from "./typings";
export const builderActionTypes = actionTypes;
export const builderActions = actions;
export const builderTypings = typings;
export * from "./typings";
@@ -0,0 +1,52 @@
import {
AbilityManager,
HelperUtils,
} from "@dndbeyond/character-rules-engine/es";
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
interface Props {
abilities: Array<AbilityManager>;
minScore?: number;
maxScore?: number;
}
export default function AbilityScoreManagerManual({
abilities,
minScore = 3,
maxScore = 18,
}: Props) {
const handleTransformValueOnBlur = (value: string): number | null => {
const parsedValue = HelperUtils.parseInputInt(value);
let clampedValue: number | null = null;
if (parsedValue !== null) {
clampedValue = HelperUtils.clampInt(parsedValue, minScore, maxScore);
}
return clampedValue;
};
return (
<div className=" ability-score-manager ability-score-manager-manual">
<div className="ability-score-manager-stats">
{abilities.map((abilityScore) => (
<div
className="ability-score-manager-stat"
key={abilityScore.getId()}
data-stat-id={abilityScore.getId()}
>
<FormInputField
transformValueOnBlur={handleTransformValueOnBlur}
onBlur={(value) => abilityScore.handleScoreChange(Number(value))}
label={abilityScore.getLabel() ?? ""}
initialValue={abilityScore.getBaseScore()}
/>
<div className="ability-score-manager-stat-total">
Total: {abilityScore.getTotalScore() ?? "--"}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,4 @@
import AbilityScoreManagerManual from "./AbilityScoreManagerManual";
export default AbilityScoreManagerManual;
export { AbilityScoreManagerManual };
@@ -0,0 +1,130 @@
import { uniqueId } from "lodash";
import { Select } from "@dndbeyond/character-components/es";
import {
AbilityManager,
HelperUtils,
HtmlSelectOption,
} from "@dndbeyond/character-rules-engine/es";
const totalPoints: number = 27;
const scoreChoices: Array<number> = [8, 9, 10, 11, 12, 13, 14, 15];
const scorePoints: Record<number, number> = {
8: 0,
9: 1,
10: 2,
11: 3,
12: 4,
13: 5,
14: 7,
15: 9,
};
function renderScoreLabel(score: number, currentScore: number | null): string {
if (currentScore !== null) {
if (score > currentScore) {
const pointDiff = scorePoints[score] - scorePoints[currentScore];
return `${score} (-${pointDiff} Point${pointDiff === 1 ? "" : "s"})`;
} else if (score < currentScore) {
const pointDiff = scorePoints[currentScore] - scorePoints[score];
return `${score} (+${pointDiff} Point${pointDiff === 1 ? "" : "s"})`;
}
}
return "" + score;
}
interface Props {
abilities: Array<AbilityManager>;
}
export default function AbilityScoreManagerPointBuy({ abilities }: Props) {
const currentPoints = abilities.reduce((acc: number, abilityScore) => {
const baseScore = abilityScore.getBaseScore();
if (baseScore !== null) {
return acc + scorePoints[baseScore];
}
return acc;
}, 0);
const remainingPoints: number = totalPoints - currentPoints;
return (
<div className="ability-score-manager ability-score-manager-point">
<div className="ability-score-manager-points">
<div className="ability-score-manager-points-heading">
Points Remaining
</div>
<div className="ability-score-manager-points-value">
<span className="ability-score-manager-points-value-remaining">
{remainingPoints}
</span>
<span className="ability-score-manager-points-value-sep">/</span>
<span className="ability-score-manager-points-value-total">
{totalPoints}
</span>
</div>
</div>
<div className="ability-score-manager-stats">
{abilities.map((abilityScore) => {
const uId = uniqueId("qry_");
const baseScore = abilityScore.getBaseScore();
const availableScores = scoreChoices.reduce(
(acc: Array<number>, score) => {
let pointDiff = scorePoints[score];
if (baseScore !== null) {
pointDiff -= scorePoints[baseScore];
}
if (
pointDiff < 0 ||
(pointDiff > 0 && pointDiff <= remainingPoints) ||
score === abilityScore.getBaseScore()
) {
acc.push(score);
}
return acc;
},
[]
);
const availableScoreOptions: Array<HtmlSelectOption> =
availableScores.map((score) => ({
label: renderScoreLabel(score, abilityScore.getBaseScore()),
value: score,
}));
return (
<div
className="ability-score-manager-stat"
key={abilityScore.getId()}
>
<div className="builder-field form-select-field">
<span className="builder-field-label">
<label
className="builder-field-heading form-select-field-label"
htmlFor={uId}
>
{abilityScore.getLabel()}
</label>
</span>
<span className="builder-field-input">
<Select
id={uId}
options={availableScoreOptions}
value={abilityScore.getBaseScore()}
onChange={(value) =>
abilityScore.handleScoreChange(Number(value))
}
initialOptionRemoved={true}
/>
</span>
<div className="ability-score-manager-stat-total">
Total: {abilityScore.getTotalScore() ?? "--"}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,4 @@
import AbilityScoreManagerPointBuy from "./AbilityScoreManagerPointBuy";
export default AbilityScoreManagerPointBuy;
export { AbilityScoreManagerPointBuy };
@@ -0,0 +1,82 @@
import { uniqueId } from "lodash";
import { Select } from "@dndbeyond/character-components/es";
import {
AbilityManager,
HelperUtils,
HtmlSelectOption,
} from "@dndbeyond/character-rules-engine/es";
import { TypeScriptUtils } from "../../../Shared/utils";
const standardScores: Array<number> = [8, 10, 12, 13, 14, 15];
interface Props {
abilities: Array<AbilityManager>;
}
export default function AbilityScoreManagerStandardArray({ abilities }: Props) {
const usedScores = abilities
.map((abilityScore) => abilityScore.getBaseScore())
.filter(TypeScriptUtils.isNotNullOrUndefined);
return (
<div className="ability-score-manager ability-score-manager-standard">
<div className="ability-score-manager-stats">
{abilities.map((abilityScore) => {
const uId = uniqueId("qry_");
const availableScores = standardScores.reduce(
(acc: Array<number>, score) => {
if (
!usedScores.includes(score) ||
score === abilityScore.getBaseScore()
) {
acc.push(score);
}
return acc;
},
[]
);
const availableScoreOptions: Array<HtmlSelectOption> =
availableScores.map((score) => ({
label: "" + score,
value: score,
}));
return (
<div
className="ability-score-manager-stat"
key={abilityScore.getId()}
>
<div className="builder-field form-select-field">
<span className="builder-field-label">
<label
className="builder-field-heading form-select-field-label"
htmlFor={uId}
>
{abilityScore.getLabel()}
</label>
</span>
<span className="builder-field-input">
<Select
id={uId}
options={availableScoreOptions}
value={abilityScore.getBaseScore()}
onChange={(value) => {
const parsedValue = HelperUtils.parseInputInt(value);
abilityScore.handleScoreChange(parsedValue);
}}
placeholder={"--"}
/>
</span>
<div className="ability-score-manager-stat-total">
Total: {abilityScore.getTotalScore() ?? "--"}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,4 @@
import AbilityScoreManagerStandardArray from "./AbilityScoreManagerStandardArray";
export default AbilityScoreManagerStandardArray;
export { AbilityScoreManagerStandardArray };
@@ -0,0 +1,34 @@
import React from "react";
import { Button } from "@dndbeyond/character-components/es";
interface withBuilderProps {
clsNames: Array<string>;
}
function withBuilderButton<P extends object, C>(
WrappedComponent: React.JSXElementConstructor<P> & C
) {
type Props = JSX.LibraryManagedAttributes<C, P>;
return class WithBuilderButton extends React.PureComponent<
Props & withBuilderProps
> {
static defaultProps = {
clsNames: [],
};
render() {
const { children, clsNames } = this.props;
return (
<WrappedComponent
{...(this.props as any)}
clsNames={[...clsNames, "builder-button"]}
>
{children}
</WrappedComponent>
);
}
};
}
export default withBuilderButton(Button);
@@ -0,0 +1,4 @@
import Button from "./Button";
export default Button;
export { Button };
@@ -0,0 +1,68 @@
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormLabel from "@mui/material/FormLabel";
import Radio from "@mui/material/Radio";
import MuiRadioGroup from "@mui/material/RadioGroup";
import Typography from "@mui/material/Typography";
import { ChangeEvent } from "react";
import { TitleH3 } from "../shared-styles";
type RadioGroupProps = {
name?: string;
defaultValue?: any;
label: string;
options?: any;
subtitle?: string;
onChange?: (event: ChangeEvent, value: any) => void;
disabled?: boolean;
value?: any;
};
export const RadioGroup = ({
name,
defaultValue,
label,
options,
subtitle,
onChange,
disabled = false,
value,
}: RadioGroupProps) => {
return (
<FormControl disabled={disabled}>
<FormLabel
sx={{ ...TitleH3, color: "#000" }}
id={`radio-button-group-${label.split(" ").join("-")}`}
>
{label}
</FormLabel>
<Typography
variant="subtitle1"
sx={{ color: "#12181ca3", fontSize: "14px" }}
>
{subtitle}
</Typography>
<MuiRadioGroup
aria-labelledby={`radio-button-group-${label.split(" ").join("-")}`}
name={name}
onChange={onChange}
{...{
...(defaultValue && { defaultValue }),
...(value && { value }),
}}
>
{options.map((option) => {
return (
<FormControlLabel
value={option.value}
control={<Radio color="secondary" />}
label={option.label}
key={option.value}
/>
);
})}
</MuiRadioGroup>
</FormControl>
);
};
@@ -0,0 +1,4 @@
import { RadioGroup } from "./RadioGroup";
export default RadioGroup;
export { RadioGroup };
@@ -0,0 +1,8 @@
export const TitleH3 = {
fontSize: "16px",
fontWeight: "bold",
fontFamily: "Roboto Condensed",
letterSpacing: "normal",
lineHeight: 1.5,
marginTop: 4,
};
@@ -0,0 +1,46 @@
import React from "react";
import { ClassDefinitionContract } from "@dndbeyond/character-rules-engine/es";
import Button from "../Button";
interface Props {
charClass: ClassDefinitionContract;
onRequestChange: () => void;
}
export default class ClassDisplaySimple extends React.PureComponent<Props> {
handleChangeClick = () => {
const { onRequestChange } = this.props;
if (onRequestChange) {
onRequestChange();
}
};
render() {
const { charClass } = this.props;
let portraitAvatarUrl = charClass.portraitAvatarUrl;
return (
<div className="builder-field builder-field-valid class-simple">
<div className="builder-field-heading">Selected Class</div>
<div className="class-simple-chosen">
<div className="class-simple-chosen-preview">
<img
className="class-simple-chosen-preview-img"
src={portraitAvatarUrl ? portraitAvatarUrl : ""}
alt=""
/>
</div>
<div className="class-simple-chosen-heading">{charClass.name}</div>
<div className="class-simple-chosen-action">
<Button onClick={this.handleChangeClick} size="small">
Change
</Button>
</div>
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import ClassDisplaySimple from "./ClassDisplaySimple";
export default ClassDisplaySimple;
export { ClassDisplaySimple };
@@ -0,0 +1,93 @@
import React, { useContext } from "react";
import { Select } from "@dndbeyond/character-components/es";
import { FeatList, FormatUtils } from "@dndbeyond/character-rules-engine";
import { Accordion } from "~/components/Accordion";
import { FeatDetail } from "~/subApps/sheet/components/Sidebar/panes/FeatsManagePane/FeatDetail";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
type GrantedFeatProps = {
featList: FeatList;
requiredLevel?: number;
};
export const GrantedFeat: React.FC<GrantedFeatProps> = ({
featList,
requiredLevel,
}) => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
// Only show the Feat List section if there is something to show.
if (featList.isEmpty()) {
return null;
}
let featContent: React.ReactNode | undefined;
let selectBox: React.ReactNode | undefined;
let infoMessage: React.ReactNode | undefined;
let hasUnfinishedChoices = featList.hasChoiceToMake();
let subtitleItems = ["Granted Feat"];
// Show a message when the character already has every feat in the feat list from somewhere else.
if (featList.alreadyHasEveryFeat() && featList.chosenFeatId === null) {
infoMessage = <p>This feat already exists on your character.</p>;
} else {
// Show the feat details if a feat has been chosen from the feat list.
if (featList.chosenFeatId !== null) {
const featId = featList.chosenFeatId;
const featManager = characterFeaturesManager.getFeatById(featId);
if (featManager !== null) {
const needToChooseFeatOptions =
featManager.getUnfinishedChoices().length > 0;
hasUnfinishedChoices = hasUnfinishedChoices || needToChooseFeatOptions;
featContent = <FeatDetail featManager={featManager} />;
if (featManager.isHiddenFeat()) {
subtitleItems = []; // don't call it a feat if it isn't really a feat.
}
const choiceCount = featManager.getChoices().length;
if (choiceCount) {
subtitleItems.push(
`${choiceCount} Choice${choiceCount !== 1 ? "s" : ""}`
);
}
}
}
// When there is more than one feat in the list, show the select box to choose one.
if (!featList.isSingleFeat()) {
selectBox = (
<Select
onChangePromise={(...args) => featList.handleChoiceSelected(...args)}
options={featList.availableChoices}
value={featList.chosenFeatId !== null ? featList.chosenFeatId : -1}
clsNames={["description-manage-background-granted-feat-chooser"]}
/>
);
}
}
if (requiredLevel) {
subtitleItems.push(`${FormatUtils.ordinalize(requiredLevel)} level`);
}
return (
<Accordion
summary={featList.definition.name}
summaryMetaItems={subtitleItems}
showAlert={hasUnfinishedChoices}
variant="paper"
key={featList.definition.id}
>
{infoMessage}
{selectBox}
{featContent}
</Accordion>
);
};
@@ -0,0 +1,263 @@
import React from "react";
import { Checkbox, Select } from "@dndbeyond/character-components/es";
import {
Constants,
FormatUtils,
HtmlSelectOption,
AccessUtils,
} from "@dndbeyond/character-rules-engine/es";
import { CollapsibleContent } from "~/components/CollapsibleContent";
import { Link } from "~/components/Link";
export interface AffectedFeatureInfo {
name: string;
definitionKey: string;
disabled: boolean;
}
interface OptionalFeatureProps {
name: string;
requiredLevel?: number;
description: string | null;
featureType: Constants.FeatureTypeEnum | null;
definitionKey: string;
affectedFeatureDefinitionKey: string | null;
isSelected: boolean;
onSelection: (
definitionKey: string,
affectedFeatureDefinitionKey: string | null
) => void;
onRemoveSelectionPromise?: (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
onChangeReplacementPromise?: (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
) => void;
affectedFeatures: Array<AffectedFeatureInfo>;
accessType: Constants.AccessTypeEnum;
}
export class OptionalFeature extends React.PureComponent<
OptionalFeatureProps,
{}
> {
handleSelection = (isEnabled: boolean): void => {
const { featureType, onSelection, definitionKey, affectedFeatures } =
this.props;
switch (featureType) {
case Constants.FeatureTypeEnum.REPLACEMENT: {
if (isEnabled) {
const affectedFeatureDefinitionKey: string | null =
affectedFeatures.length === 1
? affectedFeatures[0].definitionKey
: null;
onSelection(definitionKey, affectedFeatureDefinitionKey);
}
break;
}
case Constants.FeatureTypeEnum.ADDITIONAL: {
if (isEnabled) {
onSelection(definitionKey, null);
}
break;
}
default:
//not implemented
}
};
transformDefinitionKey = (definitionKey: string): string | null => {
return definitionKey.length ? definitionKey : null;
};
handleReplacementChangePromise = (
newAffectedDefinitionKey: string,
oldAffectedDefinitionKey: string,
accept: () => void,
reject: () => void
) => {
const { onChangeReplacementPromise, definitionKey } = this.props;
if (onChangeReplacementPromise) {
onChangeReplacementPromise(
definitionKey,
this.transformDefinitionKey(newAffectedDefinitionKey),
this.transformDefinitionKey(oldAffectedDefinitionKey),
accept,
reject
);
}
};
handleUnselectPromise = (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => {
const { onRemoveSelectionPromise, definitionKey } = this.props;
if (onRemoveSelectionPromise) {
onRemoveSelectionPromise(definitionKey, newIsEnabled, accept, reject);
}
};
renderReplacements = (): React.ReactNode => {
const {
affectedFeatures,
isSelected,
featureType,
affectedFeatureDefinitionKey,
} = this.props;
if (!affectedFeatures.length) {
return null;
}
const introText: Array<string> = ["Replaces:"];
const disabledText: string = "(already replaced)";
if (affectedFeatures.length === 1) {
introText.push(affectedFeatures[0].name);
if (affectedFeatures[0].disabled) {
introText.push(disabledText);
}
}
const options: Array<HtmlSelectOption> = [];
if (affectedFeatures.length > 1) {
affectedFeatures.forEach((feature) => {
const label: Array<string> = [feature.name];
if (feature.disabled) {
label.push(disabledText);
}
options.push({
label: label.join(" "),
value: feature.definitionKey,
disabled: feature.disabled,
});
});
}
const classNames: Array<string> = ["ct-optional-feature__select"];
if (
featureType === Constants.FeatureTypeEnum.REPLACEMENT &&
isSelected &&
affectedFeatureDefinitionKey === null
) {
classNames.push("ct-optional-feature__select--todo");
}
return (
<React.Fragment>
<div className="ct-optional-feature__type">{introText.join(" ")}</div>
{options.length > 1 && (
<div className={classNames.join(" ")}>
<Select
options={options}
value={affectedFeatureDefinitionKey}
onChangePromise={this.handleReplacementChangePromise}
disabled={!isSelected}
preventClickPropagating={true}
/>
</div>
)}
</React.Fragment>
);
};
renderFeatureTypeContent = (): React.ReactNode => {
const { featureType } = this.props;
switch (featureType) {
case Constants.FeatureTypeEnum.ADDITIONAL:
return (
<div className="ct-optional-feature__type">Additional feature</div>
);
case Constants.FeatureTypeEnum.REPLACEMENT:
return this.renderReplacements();
default:
return null;
}
};
renderDescription = (): React.ReactNode => {
const { description, accessType } = this.props;
let contentNode: React.ReactNode = null;
if (AccessUtils.isAccessible(accessType)) {
contentNode = description ? (
<CollapsibleContent className="ct-optional-feature__description">
{description}
</CollapsibleContent>
) : null;
} else {
contentNode = (
<div className="ct-character-tools__marketplace-callout">
To fully unlock this feature, check out the{" "}
<Link href="/marketplace">Marketplace</Link>.
</div>
);
}
return contentNode;
};
render() {
const { isSelected, name, affectedFeatures, requiredLevel } = this.props;
const isDisabled: boolean =
affectedFeatures.length === 1 && affectedFeatures[0].disabled;
const classNames: Array<string> = ["ct-optional-feature"];
if (isDisabled) {
classNames.push("ct-optional-feature--is-disabled");
}
return (
<div className={classNames.join(" ")}>
<div className="ct-optional-feature__selection">
<div className="ct-optional-feature__checkmark">
<Checkbox
initiallyEnabled={isSelected}
onChange={this.handleSelection}
stopPropagation={true}
onChangePromise={
isSelected ? this.handleUnselectPromise : undefined
}
isInteractive={!isDisabled}
/>
</div>
</div>
<div className="ct-optional-feature__primary">
<div className="ct-optional-feature__name">{name}</div>
{requiredLevel && (
<div className="ct-optional-feature__level">
{`${FormatUtils.ordinalize(requiredLevel)} level`}
</div>
)}
{this.renderDescription()}
<div className="ct-optional-feature__secondary">
{this.renderFeatureTypeContent()}
</div>
</div>
</div>
);
}
}
export default OptionalFeature;
@@ -0,0 +1,18 @@
import React from "react";
interface Props {
clsNames: Array<string>;
}
export default class Page extends React.PureComponent<Props> {
static defaultProps = {
clsNames: [],
};
render() {
const { clsNames, children } = this.props;
const conClassNames = [...clsNames, "builder-page"];
return <div className={conClassNames.join(" ")}>{children}</div>;
}
}
@@ -0,0 +1,4 @@
import Page from "./Page";
export default Page;
export { Page };
@@ -0,0 +1,23 @@
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import { BuilderMethod } from "~/subApps/builder/constants";
import { PortraitName } from "../../../../../subApps/builder/components/PortraitName";
import { builderSelectors } from "../../selectors";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const PageBody: FC<Props> = ({ children, ...props }) => {
const builderMethod = useSelector(builderSelectors.getBuilderMethod);
return (
<div className="builder-page-body" {...props}>
{builderMethod === BuilderMethod.STEP_BY_STEP && (
<div className="builder-page-body-character-name">
<PortraitName useDefaultCharacterName={true} />
</div>
)}
{children}
</div>
);
};
@@ -0,0 +1,9 @@
import React from "react";
export default class PageHeader extends React.PureComponent {
render() {
const { children } = this.props;
return <div className="builder-page-header">{children}</div>;
}
}
@@ -0,0 +1,4 @@
import PageHeader from "./PageHeader";
export default PageHeader;
export { PageHeader };
@@ -0,0 +1,9 @@
import React from "react";
export default class PageSubHeader extends React.PureComponent {
render() {
const { children } = this.props;
return <div className="builder-page-subheader">{children}</div>;
}
}
@@ -0,0 +1,4 @@
import PageSubHeader from "./PageSubHeader";
export default PageSubHeader;
export { PageSubHeader };
@@ -0,0 +1,63 @@
import React from "react";
import {
RaceDefinitionContract,
RuleData,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine/es";
import Button from "../Button";
interface Props {
species: RaceDefinitionContract | null;
onRequestAction: () => void;
actionText: string;
headingText: string;
ruleData: RuleData;
}
export default class SpeciesDisplaySimple extends React.PureComponent<Props> {
static defaultProps = {
actionText: "Change",
headingText: "Selected ",
};
render() {
const { species, actionText, headingText, ruleData, onRequestAction } =
this.props;
if (species === null) {
return null;
}
const { portraitAvatarUrl, baseName, subRaceShortName } = species;
const previewUrl: string | null = portraitAvatarUrl
? portraitAvatarUrl
: RuleDataUtils.getDefaultRaceImageUrl(ruleData);
return (
<div className="builder-field builder-field-valid race-simple">
<div className="builder-field-heading">{headingText}</div>
<div className="race-simple-content">
<div className="race-simple-preview">
<img
className="race-simple-preview-img"
src={previewUrl ? previewUrl : ""}
alt=""
/>
</div>
<div className="race-simple-info">
{subRaceShortName ? (
<div className="race-simple-subclass">{subRaceShortName}</div>
) : null}
<div className="race-simple-parent">{baseName}</div>
</div>
<div className="race-simple-action">
<Button onClick={onRequestAction} size="small">
{actionText}
</Button>
</div>
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpeciesDisplaySimple from "./SpeciesDisplaySimple";
export default SpeciesDisplaySimple;
export { SpeciesDisplaySimple };
@@ -0,0 +1,74 @@
import React from "react";
import {
AnySimpleDataType,
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiResponse,
CharacterPreferences,
ChoiceData,
DefinitionPool,
FeatDefinitionContract,
FeatLookup,
OptionalOriginLookup,
PrerequisiteData,
RacialTrait,
RacialTraitUtils,
} from "@dndbeyond/character-rules-engine/es";
import { SpeciesManageSpeciesTrait } from "../../containers/pages/SpeciesManage";
interface Props {
speciesTraits: RacialTrait[];
featLookup: FeatLookup;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<FeatDefinitionContract[]>>;
handleSpeciesTraitChoiceChange: (
speciesTraitId: number,
choiceId: string,
choiceType: number,
optionValue: AnySimpleDataType
) => void;
choiceInfo: ChoiceData;
prerequisiteData: PrerequisiteData;
preferences: CharacterPreferences;
optionalOriginLookup: OptionalOriginLookup;
definitionPool: DefinitionPool;
speciesName?: string | null;
}
const SpeciesTraitList: React.FC<Props> = ({
speciesTraits,
featLookup,
loadAvailableFeats,
handleSpeciesTraitChoiceChange,
choiceInfo,
prerequisiteData,
preferences,
optionalOriginLookup,
definitionPool,
speciesName,
}) => {
return (
<>
{speciesTraits.map((speciesTrait) => (
<SpeciesManageSpeciesTrait
key={RacialTraitUtils.getId(speciesTrait)}
speciesTrait={speciesTrait}
featLookup={featLookup}
loadAvailableFeats={loadAvailableFeats}
onChoiceChange={handleSpeciesTraitChoiceChange}
choiceInfo={choiceInfo}
prerequisiteData={prerequisiteData}
preferences={preferences}
optionalOriginLookup={optionalOriginLookup}
definitionPool={definitionPool}
speciesName={speciesName}
/>
))}
</>
);
};
export default SpeciesTraitList;
@@ -0,0 +1,4 @@
import SpeciesTraitList from "./SpeciesTraitList";
export default SpeciesTraitList;
export { SpeciesTraitList };
@@ -0,0 +1,8 @@
import * as navigationConfig from "./navigation";
export const BUILDER_DESKTOP_COMPONENT_START_WIDTH = 768;
export const BUILDER_MQ_IS_MOBILE_SIZE = `(max-width: ${
BUILDER_DESKTOP_COMPONENT_START_WIDTH - 1
}px)`;
export { navigationConfig };
@@ -0,0 +1,386 @@
import React from "react";
import { matchPath } from "react-router-dom";
import {
FeatureFlagInfoState,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
// export interface RouteDefinition {
// key: string,
// type: string,
// path: string,
// Component: any,
// checkCanAccess: (routeDef:any, state:any) => boolean,
// }
import config from "~/config";
import {
BuilderMethod,
NavigationType,
RouteKey,
SectionKey,
} from "~/subApps/builder/constants";
import { ClassChoose } from "~/subApps/builder/routes/ClassChoose";
import { QuickBuild } from "~/subApps/builder/routes/QuickBuild";
import { RandomBuild } from "~/subApps/builder/routes/RandomBuild";
import { SpeciesChoose } from "~/subApps/builder/routes/SpeciesChoose";
import AbilityScoresHelp from "../containers/pages/AbilityScoresHelp";
import AbilityScoresManage from "../containers/pages/AbilityScoresManage";
import ClassHelp from "../containers/pages/ClassHelp";
import ClassesManage from "../containers/pages/ClassesManage";
import DescriptionHelp from "../containers/pages/DescriptionHelp";
import DescriptionManage from "../containers/pages/DescriptionManage";
import EquipmentHelp from "../containers/pages/EquipmentHelp";
import EquipmentManage from "../containers/pages/EquipmentManage";
import HomeBasicInfo from "../containers/pages/HomeBasicInfo";
import HomeHelp from "../containers/pages/HomeHelp";
import SpeciesHelp from "../containers/pages/SpeciesHelp";
import SpeciesManage from "../containers/pages/SpeciesManage";
import WhatsNext from "../containers/pages/WhatsNext";
import { NavigationUtils } from "../utils";
const BASE_PATHNAME = config.basePathname;
export const ROUTE_DEFINITIONS = {
[RouteKey.QUICK_BUILD]: {
key: RouteKey.QUICK_BUILD,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/builder/quick-build`,
Component: QuickBuild,
getComponent: () => <QuickBuild />,
checkCanAccess: (routeDef, state) => {
return NavigationUtils.checkStdBuilderPageRequirements(routeDef, state);
},
},
[RouteKey.RANDOMIZE_BUILD]: {
key: RouteKey.RANDOMIZE_BUILD,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/builder/randomize`,
Component: RandomBuild,
getComponent: () => <RandomBuild />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.HOME_BASIC_INFO]: {
key: RouteKey.HOME_BASIC_INFO,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/home/basic`,
Component: HomeBasicInfo,
getComponent: () => <HomeBasicInfo />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.RACE_CHOOSE]: {
key: RouteKey.RACE_CHOOSE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/species/choose`,
Component: SpeciesChoose,
getComponent: () => <SpeciesChoose />,
checkCanAccess: (routeDef, state, dispatch) => {
if (!NavigationUtils.checkStdBuilderPageRequirements(routeDef, state)) {
return false;
}
if (rulesEngineSelectors.getRace(state) !== null) {
return false;
}
return true;
},
},
[RouteKey.RACE_MANAGE]: {
key: RouteKey.RACE_MANAGE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/species/manage`,
Component: SpeciesManage,
getComponent: () => <SpeciesManage />,
checkCanAccess: (routeDef, state, dispatch) => {
if (rulesEngineSelectors.getRace(state) === null) {
return false;
}
return true;
},
},
[RouteKey.CLASS_CHOOSE]: {
key: RouteKey.CLASS_CHOOSE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/class/choose`,
Component: ClassChoose,
getComponent: () => <ClassChoose />,
checkCanAccess: (routeDef, state, dispatch) => {
if (!NavigationUtils.checkStdBuilderPageRequirements(routeDef, state)) {
return false;
}
if (rulesEngineSelectors.getClasses(state).length > 0) {
return false;
}
return true;
},
},
[RouteKey.CLASS_MANAGE]: {
key: RouteKey.CLASS_MANAGE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/class/manage`,
Component: ClassesManage,
getComponent: () => <ClassesManage />,
checkCanAccess: (routeDef, state, dispatch) => {
if (!NavigationUtils.checkStdBuilderPageRequirements(routeDef, state)) {
return false;
}
if (rulesEngineSelectors.getClasses(state).length === 0) {
return false;
}
return true;
},
},
[RouteKey.ABILITY_SCORES_MANAGE]: {
key: RouteKey.ABILITY_SCORES_MANAGE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/ability-scores/manage`,
Component: AbilityScoresManage,
getComponent: () => <AbilityScoresManage />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.DESCRIPTION_MANAGE]: {
key: RouteKey.DESCRIPTION_MANAGE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/description/manage`,
Component: DescriptionManage,
getComponent: () => <DescriptionManage />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.EQUIPMENT_MANAGE]: {
key: RouteKey.EQUIPMENT_MANAGE,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/equipment/manage`,
Component: EquipmentManage,
getComponent: () => <EquipmentManage />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.WHATS_NEXT]: {
key: RouteKey.WHATS_NEXT,
type: NavigationType.Page,
path: `${BASE_PATHNAME}/:characterId/builder/whats-next`,
Component: WhatsNext,
getComponent: () => <WhatsNext />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.HOME_HELP]: {
key: RouteKey.HOME_HELP,
type: NavigationType.HelpPage,
path: `${BASE_PATHNAME}/:characterId/builder/home/help`,
Component: HomeHelp,
getComponent: () => <HomeHelp />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.RACE_HELP]: {
key: RouteKey.RACE_HELP,
type: NavigationType.HelpPage,
path: `${BASE_PATHNAME}/:characterId/builder/species/help`,
Component: SpeciesHelp,
getComponent: () => <SpeciesHelp />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.CLASS_HELP]: {
key: RouteKey.CLASS_HELP,
type: NavigationType.HelpPage,
path: `${BASE_PATHNAME}/:characterId/builder/class/help`,
Component: ClassHelp,
getComponent: () => <ClassHelp />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.ABILITY_SCORES_HELP]: {
key: RouteKey.ABILITY_SCORES_HELP,
type: NavigationType.HelpPage,
path: `${BASE_PATHNAME}/:characterId/builder/ability-scores/help`,
Component: AbilityScoresHelp,
getComponent: () => <AbilityScoresHelp />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.DESCRIPTION_HELP]: {
key: RouteKey.DESCRIPTION_HELP,
type: NavigationType.HelpPage,
path: `${BASE_PATHNAME}/:characterId/builder/description/help`,
Component: DescriptionHelp,
getComponent: () => <DescriptionHelp />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
[RouteKey.EQUIPMENT_HELP]: {
key: RouteKey.EQUIPMENT_HELP,
type: NavigationType.HelpPage,
path: `${BASE_PATHNAME}/:characterId/builder/equipment/help`,
Component: EquipmentHelp,
getComponent: () => <EquipmentHelp />,
checkCanAccess: (routeDef, state) =>
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
},
};
export interface RouteDef {
key: string; // get the thing as an enum
type: typeof NavigationType;
path: string;
Component: React.ComponentType<any>;
getComponent: () => React.ComponentType<any>;
checkCanAccess?: (routeDef: RouteDef, state: any) => boolean;
}
export const getAllRouterRoutes = () => {
return Object.values(ROUTE_DEFINITIONS).map((routeDef: RouteDef) => ({
element: <routeDef.Component />,
path: routeDef.path,
routeDef,
}));
};
export const getRouteDef = (routeKey) => ROUTE_DEFINITIONS[routeKey];
export const getRouteDefPath = (routeKey) => getRouteDef(routeKey).path;
export const getRouteDefType = (routeKey) => getRouteDef(routeKey).type;
export const NAVIGATION_DEFINITIONS = {
[BuilderMethod.QUICK]: [
{
type: NavigationType.Section,
key: SectionKey.QUICK_BUILD,
getLabel: () => "Quick Build",
routes: [getRouteDef(RouteKey.QUICK_BUILD)],
},
],
[BuilderMethod.RANDOMIZE]: [
{
type: NavigationType.Section,
key: SectionKey.RANDOMIZE,
getLabel: () => "Randomize Build",
routes: [getRouteDef(RouteKey.RANDOMIZE_BUILD)],
},
],
[BuilderMethod.STEP_BY_STEP]: [
{
type: NavigationType.Section,
key: SectionKey.HOME,
getLabel: () => "Home",
routes: [
getRouteDef(RouteKey.HOME_HELP),
getRouteDef(RouteKey.HOME_BASIC_INFO),
],
},
{
type: NavigationType.Section,
key: SectionKey.CLASS,
getLabel: () => "1. Class",
routes: [
getRouteDef(RouteKey.CLASS_HELP),
getRouteDef(RouteKey.CLASS_CHOOSE),
getRouteDef(RouteKey.CLASS_MANAGE),
],
},
{
type: NavigationType.Section,
key: SectionKey.DESCRIPTION,
getLabel: () => "2. Background",
routes: [
getRouteDef(RouteKey.DESCRIPTION_HELP),
getRouteDef(RouteKey.DESCRIPTION_MANAGE),
],
},
{
type: NavigationType.Section,
key: SectionKey.RACE,
getLabel: () => "3. Species",
routes: [
getRouteDef(RouteKey.RACE_HELP),
getRouteDef(RouteKey.RACE_CHOOSE),
getRouteDef(RouteKey.RACE_MANAGE),
],
},
{
type: NavigationType.Section,
key: SectionKey.ABILITY_SCORES,
getLabel: () => "4. Abilities",
routes: [
getRouteDef(RouteKey.ABILITY_SCORES_HELP),
getRouteDef(RouteKey.ABILITY_SCORES_MANAGE),
],
},
{
type: NavigationType.Section,
key: SectionKey.EQUIPMENT,
getLabel: () => "5. Equipment",
routes: [
getRouteDef(RouteKey.EQUIPMENT_HELP),
getRouteDef(RouteKey.EQUIPMENT_MANAGE),
],
},
{
type: NavigationType.Section,
key: SectionKey.WHATS_NEXT,
getLabel: () => "What's Next",
routes: [getRouteDef(RouteKey.WHATS_NEXT)],
},
],
};
export function checkIsRouteInSection(testRouteDef, sectionRoutes) {
if (testRouteDef && sectionRoutes) {
return !!sectionRoutes.filter(
(routeDef) => routeDef.key === testRouteDef.key
).length;
}
return false;
}
export function getNavigationSections(
builderMethod,
currentRouteDef,
featureFlags: FeatureFlagInfoState
) {
const navConfig = getNavBuilderConfig(builderMethod, featureFlags);
return navConfig.map((section) => ({
...section,
active: checkIsRouteInSection(currentRouteDef, section.routes),
}));
}
export function getNavBuilderConfig(
builderMethod,
featureFlags: FeatureFlagInfoState
) {
if (builderMethod) {
return NAVIGATION_DEFINITIONS[builderMethod];
} else {
return [];
}
}
export function getCurrentRouteDef(currentPath) {
const routes = getAllRouterRoutes();
let matchedRouteDef: RouteDef | null = null;
routes.forEach((route) => {
const match = matchPath(route.path, currentPath);
if (match) {
matchedRouteDef = route.routeDef;
}
});
return matchedRouteDef;
}
@@ -0,0 +1,323 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { connect, DispatchProp } from "react-redux";
import {
DiceRollGroupManager,
LoadingPlaceholder,
} from "@dndbeyond/character-components/es";
import {
Ability,
AbilityUtils,
characterActions,
CharacterConfiguration,
DiceRolls,
HelperUtils,
HtmlSelectOption,
RollResultContract,
rulesEngineSelectors,
Constants,
RollGroupContract,
} from "@dndbeyond/character-rules-engine/es";
import {
DiceNotation,
DiceOperation,
DieTerm,
RollRequest,
RollRequestRoll,
} from "@dndbeyond/dice";
import { rollResultActions } from "../../../Shared/actions/rollResult";
import * as toastActions from "../../../Shared/actions/toastMessage/actions";
import DataLoadingStatusEnum from "../../../Shared/constants/DataLoadingStatusEnum";
import { rollResultSelectors } from "../../../Shared/selectors";
import {
RollResultUtils,
RollResultGroupsLookup,
RollStatusLookup,
} from "../../../Shared/utils";
import { BuilderAppState } from "../../typings";
interface AbilityScore {
statId: number | null;
value: number | null;
}
interface AbilityScoreDiceManagerProps extends DispatchProp {
abilities: Array<Ability>;
configuration: CharacterConfiguration;
rollResultGroupsLookup: RollResultGroupsLookup;
simulatedGroupsLookup: RollResultGroupsLookup;
rollStatusLookup: RollStatusLookup;
}
const AbilityScoreDiceManager: React.FunctionComponent<
AbilityScoreDiceManagerProps
> = ({
abilities,
configuration,
rollResultGroupsLookup,
simulatedGroupsLookup,
rollStatusLookup,
dispatch,
}) => {
const SIMULATED_GROUP_COUNT: number = 1;
const SIMULATED_ROLL_RESULT_COUNT: number = 6;
const [loadingStatus, setLoadingStatus] = useState(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const componentKey = useMemo<string | null>(
() =>
configuration.abilityScoreType
? DiceRolls.generateAbilityManagerKey(configuration.abilityScoreType)
: null,
[configuration]
);
useEffect(() => {
if (componentKey) {
setLoadingStatus(DataLoadingStatusEnum.LOADING);
dispatch(
rollResultActions.rollResultGroupsLoad(
componentKey,
SIMULATED_GROUP_COUNT,
SIMULATED_ROLL_RESULT_COUNT
)
);
}
}, [setLoadingStatus]);
const diceRollGroups = useMemo<Array<RollGroupContract> | null>(() => {
if (!componentKey) {
return null;
}
let groups: Array<RollGroupContract> = [];
const simulatedGroups = RollResultUtils.getComponentOrderedGroups(
componentKey,
simulatedGroupsLookup
);
if (simulatedGroups.length) {
groups = simulatedGroups;
} else {
groups = RollResultUtils.getComponentOrderedGroups(
componentKey,
rollResultGroupsLookup
);
}
setLoadingStatus(DataLoadingStatusEnum.LOADED);
return groups.map((group) => {
return {
...group,
rollResults: RollResultUtils.getGroupOrderedRollResults(
group.rollResults
),
};
});
}, [
simulatedGroupsLookup,
rollResultGroupsLookup,
componentKey,
setLoadingStatus,
]);
const diceRollRequest = useMemo<RollRequest>(() => {
const dieTerm: DieTerm = new DieTerm(4, "d6", DiceOperation.Max, 3);
const rollRequestRoll = new RollRequestRoll(new DiceNotation([dieTerm]));
return {
action: "Ability Score",
rolls: [rollRequestRoll],
};
}, []);
const abilityStatOptions = useMemo<Array<HtmlSelectOption>>(() => {
return abilities.map((ability) => {
return {
label: AbilityUtils.getName(ability),
value: String(AbilityUtils.getId(ability)),
};
});
}, [abilities]);
const handleRollError = useCallback((title: string): void => {
dispatch(
toastActions.toastError(title, "Please wait a few seconds and try again")
);
}, []);
const handleAddGroup = useCallback((): void => {
if (componentKey) {
dispatch(
rollResultActions.rollResultGroupCreate(
componentKey,
SIMULATED_ROLL_RESULT_COUNT
)
);
}
}, [componentKey]);
const handleRemoveGroup = useCallback(
(groupKey: string, nextGroupKey: string | null): void => {
if (componentKey) {
dispatch(
rollResultActions.rollResultGroupDestroy(
componentKey,
groupKey,
nextGroupKey
)
);
}
},
[componentKey]
);
const handleUpdateGroupRolls = useCallback(
(groupKey: string, rollResults: Array<RollResultContract>): void => {
if (componentKey) {
dispatch(
rollResultActions.rollResultGroupDiceRollsSet(
componentKey,
groupKey,
rollResults
)
);
}
},
[componentKey]
);
const handleSetScores = useCallback(
(groupKey: string, rollResults: Array<RollResultContract>): void => {
const abilityScores: Array<AbilityScore> = rollResults
.filter(
(roll) => roll.assignedValue !== null && roll.rollTotal !== null
)
.map((roll) => ({
statId: roll.assignedValue
? HelperUtils.parseInputInt(roll.assignedValue)
: null,
value: roll.rollTotal,
}));
abilityScores.forEach((score) => {
// Flash ability score input if it was changed
const abilityScoreElement: HTMLElement | null = document.querySelector(
`.ability-score-manager [data-stat-id="${score.statId}"] input`
);
if (abilityScoreElement !== null) {
abilityScoreElement.classList.add("builder-field-update-animation");
setTimeout(() => {
abilityScoreElement.classList.remove(
"builder-field-update-animation"
);
}, 1000);
}
if (score.statId) {
dispatch(
characterActions.abilityScoreSet(
score.statId,
Constants.AbilityScoreStatTypeEnum.BASE,
score.value
)
);
}
});
},
[]
);
const handleResetGroup = useCallback(
(groupKey: string, rollResults: Array<RollResultContract>) => {
rollResults = rollResults.map((rollResult: RollResultContract) => ({
...rollResult,
rollTotal: null,
rollValues: [],
assignedValue: null,
}));
if (componentKey) {
dispatch(
rollResultActions.rollResultGroupReset(
componentKey,
groupKey,
rollResults
)
);
}
},
[componentKey]
);
const handleDiceRollSet = useCallback(
(
rollKey: string,
properties: Omit<Partial<RollResultContract>, "rollKey">,
nextRollKey: string | null
) => {
if (componentKey) {
dispatch(
rollResultActions.rollResultDiceRollSet(
rollKey,
properties,
nextRollKey
)
);
}
},
[componentKey]
);
const handleSetRollStatus = useCallback(
(rollKey: string, status: DataLoadingStatusEnum) => {
dispatch(rollResultActions.rollResultDiceRollStatusSet(rollKey, status));
},
[rollStatusLookup]
);
if (!componentKey || !diceRollGroups?.length) {
return null;
}
return (
<div className="ct-ability-score-dice-manager">
{loadingStatus !== DataLoadingStatusEnum.LOADED ? (
<LoadingPlaceholder label="Loading Dice Groups" />
) : (
<DiceRollGroupManager
componentKey={componentKey}
groups={diceRollGroups}
showAddGroupButton={true}
onAddGroup={handleAddGroup}
options={abilityStatOptions}
diceRollRequest={diceRollRequest}
exclusiveOptions={true}
confirmButtonText="Apply Ability Scores"
onConfirm={handleSetScores}
onUpdateGroup={handleUpdateGroupRolls}
onRollError={handleRollError}
onRemoveGroup={handleRemoveGroup}
onResetGroup={handleResetGroup}
onUpdateDiceRoll={handleDiceRollSet}
onSetRollStatus={handleSetRollStatus}
rollStatusLookup={rollStatusLookup}
/>
)}
</div>
);
};
function mapStateToProps(state: BuilderAppState) {
return {
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
abilities: rulesEngineSelectors.getAbilities(state),
rollStatusLookup: rollResultSelectors.getRollStatusLookup(state),
rollResultGroupsLookup:
rollResultSelectors.getRollResultComponentGroupsLookup(state),
simulatedGroupsLookup:
rollResultSelectors.getRollResultComponentSimulatedGroupsLookup(state),
};
}
export default connect(mapStateToProps)(AbilityScoreDiceManager);
@@ -0,0 +1,83 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import { Select } from "@dndbeyond/character-components/es";
import {
characterActions,
CharacterConfiguration,
Constants,
HelperUtils,
HtmlSelectOption,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { BuilderAppState } from "../../typings";
interface Props extends DispatchProp {
configuration: CharacterConfiguration;
initialOptionRemoved: boolean;
onScoreMethodChange?: (value: number | null) => void;
hideTilAbilityScoreTypeSet: boolean;
}
class AbilityScoreTypeChooser extends React.PureComponent<Props> {
static defaultProps = {
initialOptionRemoved: true,
hideTilAbilityScoreTypeSet: false,
};
handleScoreMethodChange = (value: string): void => {
const { dispatch, onScoreMethodChange } = this.props;
const parsedValue = HelperUtils.parseInputInt(value);
dispatch(characterActions.abilityScoreTypeSetRequest(parsedValue));
if (onScoreMethodChange) {
onScoreMethodChange(parsedValue);
}
};
render() {
const { configuration, initialOptionRemoved, hideTilAbilityScoreTypeSet } =
this.props;
const scoreMethodOptions: Array<HtmlSelectOption> = [
{
label: "Standard Array",
value: Constants.AbilityScoreTypeEnum.STANDARD_ARRAY,
},
{
label: "Manual/Rolled",
value: Constants.AbilityScoreTypeEnum.MANUAL,
},
{
label: "Point Buy",
value: Constants.AbilityScoreTypeEnum.POINT_BUY,
},
];
if (hideTilAbilityScoreTypeSet && configuration.abilityScoreType === null) {
return null;
}
return (
<div className="ability-score-type-chooser">
<Select
options={scoreMethodOptions}
onChange={this.handleScoreMethodChange}
initialOptionRemoved={initialOptionRemoved}
value={configuration.abilityScoreType}
placeholder="-- Choose a Generation Method --"
/>
</div>
);
}
}
function mapStateToProps(state: BuilderAppState) {
return {
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
};
}
export default connect(mapStateToProps)(AbilityScoreTypeChooser);
@@ -0,0 +1,4 @@
import AbilityScoreTypeChooser from "./AbilityScoreTypeChooser";
export default AbilityScoreTypeChooser;
export { AbilityScoreTypeChooser };
@@ -0,0 +1,513 @@
import React, { useState } from "react";
import { connect, DispatchProp, useDispatch } from "react-redux";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { CharacterAvatarPortrait } from "@dndbeyond/character-components/es";
import {
characterSelectors,
CharacterStatusSlug,
} from "@dndbeyond/character-rules-engine";
import {
DecorationInfo,
DecorationUtils,
FormatUtils,
rulesEngineSelectors,
characterActions,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import { NotificationSystem } from "~/components/NotificationSystem";
import config from "~/config";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { useHeadContext } from "~/contexts/Head";
import { BuilderMethod } from "~/subApps/builder/constants";
import { ClassProvider } from "~/subApps/builder/contexts/Class";
import { ModalManagerProvider } from "~/subApps/builder/contexts/ModalManager";
import { SpeciesProvider } from "~/subApps/builder/contexts/Species";
import { BuilderTypeChoicePage } from "~/subApps/builder/routes/BuilderTypeChoicePage";
import { appEnvActions, toastMessageActions } from "../../../Shared/actions";
import LoadingBlocker from "../../../Shared/components/LoadingBlocker";
import { BuilderLinkButton } from "../../../Shared/components/common/LinkButton";
import AppErrorTypeEnum from "../../../Shared/constants/AppErrorTypeEnum";
import UserRoles from "../../../Shared/constants/UserRoles";
import { DiceContainer } from "../../../Shared/containers/DiceContainer";
import {
appEnvSelectors,
appInfoSelectors,
toastMessageSelectors,
} from "../../../Shared/selectors";
import { AppInfoErrorState } from "../../../Shared/stores/typings";
import { builderActions } from "../../actions/builder";
import { BUILDER_MQ_IS_MOBILE_SIZE } from "../../config";
import { ROUTE_DEFINITIONS } from "../../config/navigation";
import { builderSelectors } from "../../selectors";
import { BuilderAppState } from "../../typings";
import { getUrlWithParams } from "../../utils/navigationUtils";
import NavigationSections from "../NavigationSections";
import SynchronousBlocker from "../SynchronousBlocker";
import TodoNavigation from "../TodoNavigation";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
interface Props extends DispatchProp {
appError: AppInfoErrorState | null;
toastMessages: any;
builderMethod: string | null;
isCharacterLoading: boolean;
isCharacterLoaded: boolean;
isMobile: boolean;
name: string | null;
username: string | null;
redirect: string;
characterId: number | null;
diceEnabled: boolean;
decorationInfo: DecorationInfo;
canEdit: boolean;
isReadonly: boolean;
userRoles: string[] | null | undefined;
characterStatus: string | null;
}
const RenderAuthError: React.FC<{ appError: Props["appError"] }> | null = ({
appError,
}) => {
if (appError === null) {
return null;
}
let contentNode: React.ReactNode;
switch (appError.type) {
case AppErrorTypeEnum.AUTH_MISSING:
let signInUrl: string = `/sign-in?returnUrl=${encodeURIComponent(
window.location.pathname
)}`;
contentNode = (
<React.Fragment>
<p>
You are no longer signed into D&D Beyond. Go to the{" "}
<Link href={signInUrl}>Sign In page</Link> to continue the
adventure!
</p>
</React.Fragment>
);
break;
case AppErrorTypeEnum.API_FAIL:
contentNode = (
<React.Fragment>
<p>
Whoops! We rolled a 1 on our API check. We're heading into town to
visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</React.Fragment>
);
break;
case AppErrorTypeEnum.API_DATA_FAIL:
contentNode = (
<React.Fragment>
<p>
Whoops! We rolled a 1 on our API Data check. We're heading into town
to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</React.Fragment>
);
break;
case AppErrorTypeEnum.AUTH_FAIL:
contentNode = (
<React.Fragment>
<p>
Whoops! We rolled a 1 on our Authentication check. We're heading
into town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</React.Fragment>
);
break;
case AppErrorTypeEnum.ACCESS_DENIED:
contentNode = (
<React.Fragment>
<p>You don't have access to this page.</p>
<p>
If you are trying to access a character sheet, make sure its privacy
setting is configured correctly for you to access it.
</p>
<p>
It is also possible that you are trying to enter the 403rd level of
the endless dungeon and the ancient dragon Rylzrayrth is rising up
to block your path...
</p>
</React.Fragment>
);
break;
case AppErrorTypeEnum.NOT_FOUND:
appError.errorId = null;
contentNode = (
<>
<p>
We live in a world of uncertainty. But certainly, the page you were
looking for isnt here. Perhaps this halfling has stolen it and
hidden it in another place. Try searching for what you were looking
for in another realm.
</p>
</>
);
break;
case AppErrorTypeEnum.GENERIC:
default:
contentNode = (
<React.Fragment>
<p>
Whoops! We rolled a 1 on our check. We're heading into town to visit
the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</React.Fragment>
);
break;
}
return (
<div className="ct-character-sheet--failed">
<div
className={[
"ct-character-sheet__failed",
`ct-character-sheet__failed--${FormatUtils.slugify(appError.type)}`,
].join(" ")}
>
<div className="ct-character-sheet__failed-content">
{contentNode}
{appError.errorId && (
<React.Fragment>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Error Code
</div>
<div className="ct-character-sheet__failed-code-value">
{appError.errorId}
</div>
</div>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Version
</div>
<div className="ct-character-sheet__failed-code-value">
{config.version}
</div>
</div>
</React.Fragment>
)}
</div>
</div>
</div>
);
};
const checkForMessages = (
toastMessages,
dispatch,
notificationSystem
): void => {
Object.keys(toastMessages).forEach((messageId) => {
if (!notificationSystem) {
return;
}
const message = toastMessages[messageId];
const { autoDismiss, dismissible, position } = message.meta;
if (!notificationSystem.current) {
return;
}
notificationSystem.current.addNotification({
title: message.payload.title,
message: message.payload.message,
level: message.meta.level,
autoDismiss,
dismissible,
position,
uid: messageId,
onRemove: () => dispatch(toastMessageActions.removeToast(messageId)),
});
});
};
const CharacterBuilder: React.FC<Props> | null = ({
appError,
toastMessages,
builderMethod,
isCharacterLoading,
isCharacterLoaded,
isMobile,
name,
username,
redirect,
characterId,
diceEnabled,
decorationInfo,
canEdit,
isReadonly,
userRoles,
characterStatus,
}) => {
let mobileMql: MediaQueryList | null = null;
// useImperativeHandle is hard to type when we us our own context this will just disapear
const notificationSystem = React.createRef<any>();
const dispatch = useDispatch();
const navigate = useNavigate();
const { pathname } = useLocation();
const { setTitle } = useHeadContext();
const [isLoaded, setIsLoaded] = useState(false);
const { featureFlags } = useFeatureFlags();
React.useEffect(() => {
if (characterId !== null && !isLoaded) {
dispatch(builderActions.characterLoadRequest());
setIsLoaded(true);
}
mobileMql = window.matchMedia(BUILDER_MQ_IS_MOBILE_SIZE);
dispatch(appEnvActions.mobileSet(mobileMql.matches));
mobileMql.onchange = (e) => {
dispatch(appEnvActions.mobileSet(e.matches));
};
}, [characterId]);
React.useEffect(() => {
if (
isLoaded &&
characterId !== null &&
characterStatus === CharacterStatusSlug.PREMADE
) {
const isLorekeeper =
userRoles?.includes(UserRoles.LOREKEEPER) ||
userRoles?.includes(UserRoles.ADMIN);
isLorekeeper && dispatch(characterActions.premadeInfoGet(characterId));
}
}, [characterId, characterStatus, isLoaded, userRoles]);
React.useEffect(() => {
//check for isCharacterLoaded and canEdit to set isReadonly and redirect, or is Random build to set isReadonly
if (isCharacterLoaded) {
dispatch(appEnvActions.dataSet({ isReadonly: !canEdit }));
if (!canEdit) {
navigate(getUrlWithParams(`${BASE_PATHNAME}/${characterId}`));
}
} else if (builderMethod && builderMethod === BuilderMethod.RANDOMIZE) {
dispatch(appEnvActions.dataSet({ isReadonly: !canEdit }));
}
}, [
isCharacterLoaded,
canEdit,
characterId,
navigate,
dispatch,
builderMethod,
]);
React.useEffect(() => {
checkForMessages(toastMessages, dispatch, notificationSystem);
});
React.useLayoutEffect(() => {
const toolsTarget = document.getElementById("character-tools-target");
if (toolsTarget) {
toolsTarget.style.zIndex = "0";
}
return () => {
const toolsTarget = document.getElementById("character-tools-target");
if (toolsTarget) {
toolsTarget.style.zIndex = "auto";
}
};
});
React.useEffect(() => {
setTitle("Character Builder");
}, [setTitle]);
if (appError !== null) {
return <RenderAuthError appError={appError} />;
}
let clsNames: Array<string> = ["character-builder"];
if (builderMethod) {
clsNames.push(`character-builder-${FormatUtils.slugify(builderMethod)}`);
}
// useEffect above handles redirect but this blocks UI from showing
if (isReadonly && isCharacterLoaded) {
return null;
}
if (!isCharacterLoading && builderMethod === null) {
let anonNode: React.ReactNode;
if (!username) {
anonNode = (
<div className="character-builder-anon">
<div className="character-builder-anon-heading">
Start Your Adventure
</div>
<div className="character-builder-anon-content">
<p>
You need to be signed in to a D&amp;D Beyond account in order to
save characters. You can use your character sheet anywhere, on any
device.
</p>
</div>
<div className="character-builder-anon-actions">
<BuilderLinkButton size="oversized" url={redirect}>
Sign in or Create Account
</BuilderLinkButton>
</div>
</div>
);
}
return (
<Routes>
<Route
path="/"
element={
<div className="character-builder">
<SynchronousBlocker />
<div className="character-builder-inner">
{anonNode}
{<BuilderTypeChoicePage isEnabled={!!username} />}
</div>
</div>
}
></Route>
</Routes>
);
}
clsNames.push(styles.characterBuilder);
if (diceEnabled) {
clsNames.push("character-builder--dice-enabled");
}
clsNames.push(styles.characterBuilder);
return (
<Routes>
<Route
path="/*"
element={
<div className={clsNames.join(" ")}>
{(characterId !== null || isCharacterLoading) && (
<LoadingBlocker isFinished={isCharacterLoaded} />
)}
{!isCharacterLoading && (
<ModalManagerProvider>
<ClassProvider>
<SpeciesProvider>
<SynchronousBlocker />
<div className="character-builder-inner">
{builderMethod === BuilderMethod.STEP_BY_STEP && (
<React.Fragment>
{!isMobile && (
<div className="character-builder-page-header">
<h1 className="character-builder-page-header-heading">
Character Builder
</h1>
{name && (
<div className="character-builder-page-header-summary">
<CharacterAvatarPortrait
className={
"character-builder-page-header-avatar"
}
avatarUrl={
DecorationUtils.getAvatarInfo(
decorationInfo
).avatarUrl
}
/>
<div className="character-builder-page-header-name">
{name}
</div>
</div>
)}
</div>
)}
<NavigationSections
pathname={pathname}
characterId={characterId}
/>
<TodoNavigation
pathname={pathname}
characterId={characterId}
/>
</React.Fragment>
)}
<Routes>
{Object.values(ROUTE_DEFINITIONS).map(
({ key, path, getComponent }) => {
const Component = getComponent(featureFlags);
// console.log('COMPONENT', Component);
return (
<Route
key={key}
path={path
.replace(`${BASE_PATHNAME}/builder/`, "")
.replace(
`${BASE_PATHNAME}/:characterId/builder/`,
""
)}
element={Component}
/>
);
}
)}
<Route
path="/"
element={<BuilderTypeChoicePage />}
></Route>
</Routes>
</div>
</SpeciesProvider>
</ClassProvider>
</ModalManagerProvider>
)}
<NotificationSystem ref={notificationSystem} />
{diceEnabled && !isReadonly ? (
<DiceContainer canShow={isCharacterLoaded} />
) : null}
</div>
}
></Route>
</Routes>
);
};
function mapStateToProps(state: BuilderAppState) {
return {
toastMessages: toastMessageSelectors.getToastMessages(state),
builderMethod: builderSelectors.getBuilderMethod(state),
isCharacterLoading: builderSelectors.getIsCharacterLoading(state),
isCharacterLoaded: builderSelectors.getIsCharacterLoaded(state),
isMobile: appEnvSelectors.getIsMobile(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
name: rulesEngineSelectors.getName(state),
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
canEdit: rulesEngineSelectors.getCanEdit(state),
username: appEnvSelectors.getUsername(state),
redirect: appEnvSelectors.getRedirect(state),
appError: appInfoSelectors.getError(state),
characterId: appEnvSelectors.getCharacterId(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
userRoles: appEnvSelectors.getUserRoles(state),
characterStatus: characterSelectors.getStatusSlug(state),
};
}
export default connect(mapStateToProps)(CharacterBuilder);
@@ -0,0 +1,4 @@
import CharacterBuilder from "./CharacterBuilder";
export default CharacterBuilder;
export { CharacterBuilder };
@@ -0,0 +1,38 @@
import { useLayoutEffect } from "react";
import { BuilderProvider } from "~/subApps/builder/contexts/Builder";
import ErrorBoundary from "../../../Shared/components/ErrorBoundary";
import CharacterBuilder from "../CharacterBuilder";
export function CharacterBuilderContainer(props) {
useLayoutEffect(() => {
document
.getElementsByTagName("body")[0]
.classList.add(
"site",
"body-rpgcharacterbuilder",
"site-dndbeyond",
"body-rpgcharacter"
);
return () => {
document
.getElementsByTagName("body")[0]
.classList.remove(
"site",
"body-rpgcharacterbuilder",
"site-dndbeyond",
"body-rpgcharacter"
);
};
}, []);
return (
<BuilderProvider>
<ErrorBoundary>
<CharacterBuilder />
</ErrorBoundary>
</BuilderProvider>
);
}
export default CharacterBuilderContainer;
@@ -0,0 +1,4 @@
import CharacterBuilderContainer from "./CharacterBuilderContainer";
export default CharacterBuilderContainer;
export { CharacterBuilderContainer };
@@ -0,0 +1,79 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterConfiguration,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { builderActions } from "../../actions";
import { BuilderAppState } from "../../typings";
interface Props extends DispatchProp {
configuration: CharacterConfiguration;
}
interface State {
enabled: boolean;
}
class HelpTextManager extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const { configuration } = props;
this.state = {
enabled:
configuration.showHelpText === null
? false
: configuration.showHelpText,
};
}
componentDidUpdate(prevProps: Props, prevState: State) {
const { configuration } = this.props;
this.setState({
enabled:
configuration.showHelpText === null
? false
: configuration.showHelpText,
});
}
handleHelpTextChange = (evt: React.MouseEvent): void => {
const { dispatch } = this.props;
const { enabled } = this.state;
const isEnabled = !enabled;
this.setState({
enabled: isEnabled,
});
dispatch(builderActions.showHelpTextSet(isEnabled));
};
render() {
const { enabled } = this.state;
let classNames: Array<string> = ["help-text-manager"];
if (enabled) {
classNames.push("help-text-manager-enabled");
} else {
classNames.push("help-text-manager-disabled");
}
return (
<div className={classNames.join(" ")} onClick={this.handleHelpTextChange}>
<div className="help-text-manager-icon" />
</div>
);
}
}
function mapStateToProps(state: BuilderAppState) {
return {
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
};
}
export default connect(mapStateToProps)(HelpTextManager);
@@ -0,0 +1,4 @@
import HelpTextManager from "./HelpTextManager";
export default HelpTextManager;
export { HelpTextManager };
@@ -0,0 +1,234 @@
import React from "react";
import { connect } from "react-redux";
import SwipeableViews from "react-swipeable-views";
import { FeatureFlagContext } from "@dndbeyond/character-components/es";
import {
characterSelectors,
CharacterStatusSlug,
featureFlagInfoSelectors,
FormatUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import { PremadeCharacterEditStatus } from "~/components/PremadeCharacterEditStatus";
import { appEnvSelectors } from "../../../Shared/selectors";
import { MobileMessengerUtils } from "../../../Shared/utils";
import { navigationConfig } from "../../config";
import { builderEnvSelectors, builderSelectors } from "../../selectors";
import { BuilderAppState } from "../../typings";
import HelpTextManager from "../HelpTextManager";
interface Props {
characterId: number | null;
builderMethod: string | null;
sections: Array<any>;
firstAvailableSectionRoutes: any;
isCharacterSheetReady: boolean;
characterSheetUrl: string;
activeSectionIdx: number;
isMobile: boolean;
characterStatus: CharacterStatusSlug | null;
isReadonly: boolean;
}
class NavigationSections extends React.PureComponent<Props> {
handleSheetShowClick = () => {
const { characterId } = this.props;
if (characterId !== null) {
MobileMessengerUtils.sendMessage(
MobileMessengerUtils.createShowCharacterSheetMessage(characterId)
);
}
};
renderNonMobileUi = (): React.ReactNode => {
const {
characterId,
sections,
firstAvailableSectionRoutes,
isCharacterSheetReady,
characterSheetUrl,
} = this.props;
let classNames: Array<string> = ["builder-sections-sheet"];
if (isCharacterSheetReady) {
classNames.push("builder-sections-sheet-ready");
} else {
classNames.push("builder-sections-sheet-disabled");
}
return (
<div className="builder-sections builder-sections-large">
<HelpTextManager />
<div className={classNames.join(" ")}>
{isCharacterSheetReady ? (
<Link
className="builder-sections-sheet-icon"
href={characterSheetUrl}
onClick={this.handleSheetShowClick}
useRouter
/>
) : (
<div className="builder-sections-sheet-icon" />
)}
</div>
<div className="builder-sections-large-routes">
{sections.map((section) => {
const firstRoute = firstAvailableSectionRoutes[section.key];
let clsNames: Array<string> = [
"builder-sections-link",
`builder-sections-${FormatUtils.slugify(section.key)}`,
];
if (section.active) {
clsNames.push("builder-sections-link-active");
}
return (
<div className="builder-sections-view" key={section.key}>
<Link
href={firstRoute.path.replace(":characterId", characterId)}
className={clsNames.join(" ")}
useRouter
>
<FeatureFlagContext.Consumer>
{(featureFlags) => section.getLabel(featureFlags)}
</FeatureFlagContext.Consumer>
</Link>
</div>
);
})}
</div>
</div>
);
};
renderMobileUi = (): React.ReactNode => {
const {
characterId,
sections,
firstAvailableSectionRoutes,
activeSectionIdx,
isCharacterSheetReady,
characterSheetUrl,
} = this.props;
const scrollbarStyles: React.CSSProperties = {
padding: "0 37.5%",
};
let classNames = ["builder-sections-sheet"];
if (isCharacterSheetReady) {
classNames.push("builder-sections-sheet-ready");
} else {
classNames.push("builder-sections-sheet-disabled");
}
return (
<div className="builder-sections builder-sections-small">
<HelpTextManager />
<div className={classNames.join(" ")}>
{isCharacterSheetReady ? (
<Link
className="builder-sections-sheet-icon"
href={characterSheetUrl}
onClick={this.handleSheetShowClick}
useRouter
/>
) : (
<div className="builder-sections-sheet-icon" />
)}
</div>
<SwipeableViews
index={activeSectionIdx}
style={scrollbarStyles}
className="builder-sections-views"
ignoreNativeScroll={true}
>
{sections.map((section) => {
const firstRoute = firstAvailableSectionRoutes[section.key];
let clsNames: Array<string> = [
"builder-sections-link",
`builder-sections-${FormatUtils.slugify(section.key)}`,
];
if (section.active) {
clsNames.push("builder-sections-link-active");
}
return (
<div className="builder-sections-view" key={section.key}>
<Link
href={firstRoute.path.replace(":characterId", characterId)}
className={clsNames.join(" ")}
useRouter
>
<FeatureFlagContext.Consumer>
{(featureFlags) => section.getLabel(featureFlags)}
</FeatureFlagContext.Consumer>
</Link>
</div>
);
})}
</SwipeableViews>
</div>
);
};
render() {
const { builderMethod, isMobile, characterStatus, isReadonly } = this.props;
if (builderMethod === null) {
return null;
}
return isMobile ? (
<>
{this.renderMobileUi()}
<PremadeCharacterEditStatus
characterStatus={characterStatus}
isReadonly={isReadonly}
isBuilderView
/>
</>
) : (
<>
{this.renderNonMobileUi()}
<PremadeCharacterEditStatus
characterStatus={characterStatus}
isReadonly={isReadonly}
isBuilderView
/>
</>
);
}
}
function mapStateToProps(state: BuilderAppState, ownProps) {
const currentPath = ownProps.pathname;
const sections = navigationConfig.getNavigationSections(
builderSelectors.getBuilderMethod(state),
navigationConfig.getCurrentRouteDef(currentPath),
featureFlagInfoSelectors.getFeatureFlagInfo(state)
);
let activeSectionIdx: number = 0;
sections.forEach((section, idx) => {
if (section.active) {
activeSectionIdx = idx;
}
});
return {
sections,
firstAvailableSectionRoutes:
builderSelectors.getFirstAvailableSectionRoutes(state),
isCharacterSheetReady: builderSelectors.checkIsCharacterSheetReady(state),
characterSheetUrl: builderEnvSelectors.getCharacterSheetUrl(state),
builderMethod: builderSelectors.getBuilderMethod(state),
activeSectionIdx,
isMobile: appEnvSelectors.getIsMobile(state),
characterStatus: characterSelectors.getStatusSlug(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
export default connect(mapStateToProps)(NavigationSections);
@@ -0,0 +1,4 @@
import NavigationSections from "./NavigationSections";
export default NavigationSections;
export { NavigationSections };
@@ -0,0 +1,120 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useMemo, useState } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
CharacterUtils,
Constants,
} from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { ConfirmModal } from "~/components/ConfirmModal";
import { XpManager } from "~/components/XpManager";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const ProgressionManager: FC<Props> = ({ ...props }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [newXpTotal, setNewXpTotal] = useState<number | null>(null);
const [shouldReset, setShouldReset] = useState<boolean>(false);
const dispatch = useDispatch();
const { currentXp, preferences, totalClassLevel, ruleData } =
useCharacterEngine();
useEffect(() => {
setShouldReset(newXpTotal === null);
}, [newXpTotal]);
const handleManageXpClick = (): void => {
setIsModalOpen(true);
};
const closeModal = (): void => {
setNewXpTotal(null);
setIsModalOpen(false);
};
const onConfirm = (): void => {
if (newXpTotal !== null) {
closeModal();
dispatch(characterActions.xpSet(newXpTotal));
dispatch(
toastMessageActions.toastSuccess(
"Experience Points Updated",
"You have successfully updated your XP"
)
);
}
};
const handleXpUpdates = (newXpTotal: number): void => {
setNewXpTotal(newXpTotal);
};
const levelDiff = useMemo(() => {
const currentLevel = CharacterUtils.deriveXpLevel(currentXp, ruleData);
return currentLevel - totalClassLevel;
}, [currentXp, totalClassLevel]);
return (
<div className="progression-manager" {...props}>
<div className="progression-manager-info">
<div className="progression-manager-heading">
Character Level: {totalClassLevel}
{preferences.progressionType ===
Constants.PreferenceProgressionTypeEnum.XP &&
levelDiff !== 0 && (
<span
className={clsx([
"progression-manager-heading-diff",
levelDiff > 0 && "progression-manager-heading-diff-positive",
levelDiff < 0 && "progression-manager-heading-diff-negative",
])}
>
<span className="progression-manager-heading-diff-icon" />
<span className="progression-manager-heading-diff-amount">
{Math.abs(levelDiff)}
</span>
</span>
)}
</div>
</div>
{preferences.progressionType ===
Constants.PreferenceProgressionTypeEnum.MILESTONE ? (
<div className="progression-manager-meta">Milestone Advancement</div>
) : (
<div className="progression-manager-action">
<Button
color="info"
variant="builder-text"
size="x-small"
onClick={handleManageXpClick}
>
Manage XP
</Button>
</div>
)}
<ConfirmModal
open={isModalOpen}
onClose={closeModal}
onConfirm={onConfirm}
heading="Manage XP"
className={styles.xpManagerModal}
confirmButtonText="Apply"
>
<XpManager
handleXpUpdates={handleXpUpdates}
shouldReset={shouldReset}
className={styles.xpManager}
/>
</ConfirmModal>
</div>
);
};
@@ -0,0 +1,127 @@
import React from "react";
import { connect } from "react-redux";
import { syncTransactionSelectors } from "@dndbeyond/character-rules-engine/es";
import { BuilderAppState } from "../../typings";
import { SyncBlockerLoadingPlaceholder } from "./SynchronousBlockerLoadingPlaceholder";
let showBlockerTimerId: number;
let hideBlockerTimerId: number;
interface Props {
waitDelayToShow: number;
waitDelayToHide: number;
className: string;
syncActive: boolean;
}
interface State {
showBlocker: boolean;
transitioning: boolean;
}
class SynchronousBlocker extends React.PureComponent<Props, State> {
static defaultProps = {
waitDelayToShow: 300,
waitDelayToHide: 0,
className: "",
};
constructor(props) {
super(props);
this.state = {
showBlocker: false,
transitioning: false,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { syncActive, waitDelayToShow, waitDelayToHide } = this.props;
if (syncActive === prevProps.syncActive) {
return;
}
if (prevState.transitioning) {
return;
}
if (syncActive) {
window.clearTimeout(hideBlockerTimerId);
this.setState({
transitioning: true,
});
showBlockerTimerId = window.setTimeout(() => {
this.setState({
showBlocker: true,
transitioning: false,
});
}, waitDelayToShow);
} else {
window.clearTimeout(showBlockerTimerId);
if (waitDelayToHide) {
this.setState({
transitioning: true,
});
hideBlockerTimerId = window.setTimeout(() => {
this.setState({
showBlocker: false,
transitioning: false,
});
}, waitDelayToHide);
} else {
this.setState({
showBlocker: false,
});
}
}
}
handleOverlayClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
};
renderContent = (): React.ReactNode => {
const { children } = this.props;
if (children) {
return children;
}
return <SyncBlockerLoadingPlaceholder />;
};
render() {
const { transitioning } = this.state;
const { syncActive, className } = this.props;
const classNames: Array<string> = ["sync-blocker", className];
if (syncActive) {
classNames.push("sync-blocker-active");
} else {
classNames.push("sync-blocker-inactive");
}
if (transitioning) {
classNames.push("sync-blocker-transition-in-out");
}
return (
<div className={classNames.join(" ")} onClick={this.handleOverlayClick}>
{this.renderContent()}
</div>
);
}
}
function mapStateToProps(state: BuilderAppState) {
return {
syncActive: syncTransactionSelectors.getActive(state),
};
}
export default connect(mapStateToProps)(SynchronousBlocker);
@@ -0,0 +1,15 @@
import React from "react";
import { AnimatedLoadingRingSvg } from "@dndbeyond/character-components/es";
interface Props {}
export default class SyncBlockerLoadingPlaceholder extends React.PureComponent<Props> {
render() {
return (
<div className="sync-blocker-group">
<div className="sync-blocker-logo" />
<AnimatedLoadingRingSvg className="sync-blocker-anim" />
</div>
);
}
}
@@ -0,0 +1,5 @@
import SynchronousBlocker from "./SynchronousBlocker";
import SyncBlockerLoadingPlaceholder from "./SynchronousBlockerLoadingPlaceholder";
export default SynchronousBlocker;
export { SynchronousBlocker, SyncBlockerLoadingPlaceholder };
@@ -0,0 +1,207 @@
import React from "react";
import { connect } from "react-redux";
import {
DisabledChevronLeftSvg,
DisabledChevronRightSvg,
LightChevronLeftSvg,
LightChevronRightSvg,
} from "@dndbeyond/character-components/es";
import { Link } from "~/components/Link";
import { appEnvSelectors } from "../../../Shared/selectors";
import { navigationConfig } from "../../config";
import { builderSelectors } from "../../selectors";
import { BuilderAppState } from "../../typings";
import { NavigationUtils } from "../../utils";
interface PrevProps {
disabled: boolean;
}
class TodoNavigationPrev extends React.PureComponent<PrevProps> {
static defaultProps = {
disabled: false,
};
render() {
const { disabled } = this.props;
let conClassNames: Array<string> = [
"builder-navigation-large-action",
"builder-navigation-large-action-prev",
];
if (disabled) {
conClassNames.push("builder-navigation-large-action-disabled");
conClassNames.push("builder-navigation-large-action-prev-disabled");
}
return (
<div className={conClassNames.join(" ")}>
<div className="builder-navigation-large-action-icon">
{disabled ? <DisabledChevronLeftSvg /> : <LightChevronLeftSvg />}
</div>
<div className="builder-navigation-large-action-text">Prev</div>
</div>
);
}
}
interface NextProps {
disabled: boolean;
}
class TodoNavigationNext extends React.PureComponent<NextProps> {
static defaultProps = {
disabled: false,
};
render() {
const { disabled } = this.props;
let conClassNames: Array<string> = [
"builder-navigation-large-action",
"builder-navigation-large-action-next",
];
if (disabled) {
conClassNames.push("builder-navigation-large-action-disabled");
conClassNames.push("builder-navigation-large-action-next-disabled");
}
return (
<div className={conClassNames.join(" ")}>
<div className="builder-navigation-large-action-text">Next</div>
<div className="builder-navigation-large-action-icon">
{disabled ? <DisabledChevronRightSvg /> : <LightChevronRightSvg />}
</div>
</div>
);
}
}
interface Props {
prevRouteDef: any;
nextRouteDef: any;
builderMethod: string | null;
isMobile: boolean;
characterId: number | null;
}
class TodoNavigation extends React.PureComponent<Props> {
handleNavClick = (): void => {
window.scrollTo(0, 0);
};
renderNonMobileUi = (): React.ReactNode => {
const { prevRouteDef, nextRouteDef, characterId } = this.props;
return (
<div className="builder-navigation-large">
{prevRouteDef !== null ? (
<Link
className="builder-navigation-large-link"
href={prevRouteDef.path.replace(":characterId", characterId)}
onClick={this.handleNavClick}
useRouter
>
<TodoNavigationPrev />
</Link>
) : (
<div className="builder-navigation-large-link">
<TodoNavigationPrev disabled={true} />
</div>
)}
{nextRouteDef !== null ? (
<Link
className="builder-navigation-large-link"
href={nextRouteDef.path.replace(":characterId", characterId)}
onClick={this.handleNavClick}
useRouter
>
<TodoNavigationNext />
</Link>
) : (
<div className="builder-navigation-large-link">
<TodoNavigationNext disabled={true} />
</div>
)}
</div>
);
};
renderMobileUi = (): React.ReactNode => {
const { prevRouteDef, nextRouteDef, characterId } = this.props;
return (
<div className="builder-navigation">
{prevRouteDef !== null ? (
<Link
className="builder-navigation-link"
href={prevRouteDef.path.replace(":characterId", characterId)}
onClick={this.handleNavClick}
useRouter
>
&lt; Prev
</Link>
) : (
<div className="builder-navigation-link builder-navigation-link-disabled">
&lt; Prev
</div>
)}
{nextRouteDef !== null ? (
<Link
className="builder-navigation-link"
href={nextRouteDef.path.replace(":characterId", characterId)}
onClick={this.handleNavClick}
useRouter
>
Next &gt;
</Link>
) : (
<div className="builder-navigation-link builder-navigation-link-disabled">
Next &gt;
</div>
)}
</div>
);
};
render() {
const { isMobile, builderMethod } = this.props;
if (builderMethod === null) {
return null;
}
return isMobile ? this.renderMobileUi() : this.renderNonMobileUi();
}
}
function mapStateToProps(state: BuilderAppState, ownProps): Props {
const currentPath = ownProps.pathname;
const characterId = ownProps.characterId;
const currentRouteDef = navigationConfig.getCurrentRouteDef(currentPath);
let prevRouteDef, nextRouteDef;
if (currentRouteDef === null) {
prevRouteDef = null;
nextRouteDef = null;
} else {
prevRouteDef = NavigationUtils.getAvailablePrevRoute(
currentRouteDef,
state
);
nextRouteDef = NavigationUtils.getAvailableNextRoute(
currentRouteDef,
state
);
}
return {
prevRouteDef,
nextRouteDef,
builderMethod: builderSelectors.getBuilderMethod(state),
isMobile: appEnvSelectors.getIsMobile(state),
characterId,
};
}
export default connect(mapStateToProps)(TodoNavigation);
@@ -0,0 +1,4 @@
import TodoNavigation from "./TodoNavigation";
export default TodoNavigation;
export { TodoNavigation };
@@ -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 };
@@ -0,0 +1,60 @@
import { BuilderAction, builderActionTypes } from "../actions/builder";
import { BuilderState } from "../typings";
const initialState: BuilderState = {
method: null,
confirmSpecies: null,
confirmClass: null,
isCharacterLoading: false,
isCharacterLoaded: false,
suggestedNames: [],
};
function builder(
state: BuilderState = initialState,
action: BuilderAction
): BuilderState {
switch (action.type) {
case builderActionTypes.BUILDER_METHOD_SET_COMMIT:
return {
...state,
method: action.payload.method,
};
case builderActionTypes.CONFIRM_SPECIES_SET:
return {
...state,
confirmSpecies: action.payload.race,
};
case builderActionTypes.CONFIRM_CLASS_SET:
return {
...state,
confirmClass: action.payload.charClass,
};
case builderActionTypes.CHARACTER_LOADING_SET_COMMIT:
return {
...state,
isCharacterLoading: action.payload.isLoading,
};
case builderActionTypes.CHARACTER_LOADED_SET_COMMIT:
return {
...state,
isCharacterLoaded: action.payload.isLoaded,
};
case builderActionTypes.SUGGESTED_NAMES_SET:
return {
...state,
suggestedNames: action.payload.suggestedNames,
};
default:
// not implemented
}
return state;
}
export default builder;
@@ -0,0 +1,381 @@
import { uniqueId } from "lodash";
import { call, put, select, takeEvery } from "redux-saga/effects";
import {
rulesEngineSelectors,
characterActionTypes,
characterActions,
syncTransactionActions,
syncTransactionSelectors,
ApiAdapterUtils,
ApiRequests,
UnpackMakeApiResponseData,
ruleDataActions,
SagaHelpers,
characterEnvSelectors,
Constants,
RaceUtils,
} from "@dndbeyond/character-rules-engine/es";
import { BuilderMethod, RouteKey } from "~/subApps/builder/constants";
import { appEnvActions } from "../../Shared/actions";
import { appEnvSelectors } from "../../Shared/selectors";
import appConfig from "../../config";
import {
builderActions,
builderActionTypes,
BuilderAction,
BuilderMethodSetAction,
QuickBuildRequestAction,
RandomBuildRequestAction,
StepBuildRequestAction,
SuggestedNamesRequestAction,
} from "../actions";
import * as navConfig from "../config/navigation";
import { getRouteDefPath } from "../config/navigation";
import { builderSelectors } from "../selectors";
import { NavigationUtils } from "../utils";
const syncActions = [
builderActionTypes.CHARACTER_LOAD_REQUEST,
builderActionTypes.QUICK_BUILD_REQUEST,
builderActionTypes.RANDOM_BUILD_REQUEST,
builderActionTypes.STEP_BUILD_REQUEST,
builderActionTypes.BUILDER_METHOD_SET,
builderActionTypes.SUGGESTED_NAMES_REQUEST,
];
export default function* saga() {
yield takeEvery(syncActions, syncFilter);
yield takeEvery(
Object.keys(characterActionTypes).map((key) => characterActionTypes[key]),
characterTypesFilter
);
yield takeEvery(
Object.keys(builderActionTypes).map((key) => builderActionTypes[key]),
filter
);
}
function* characterTypesFilter(action: any) {
switch (action.type) {
case characterActionTypes.RACE_CHOOSE_POST_ACTION: {
// yield put(push(getRouteDefPath(RouteKey.RACE_MANAGE)));
const characterId = yield select((state: any) =>
rulesEngineSelectors.getId(state)
);
const navigate = yield select(
(state: any) => state.routeControls.navigate
);
navigate(
getRouteDefPath(RouteKey.RACE_MANAGE).replace(
":characterId",
characterId
)
);
break;
}
case characterActionTypes.CLASS_ADD_POST_ACTION: {
// yield put(push(getRouteDefPath(RouteKey.CLASS_MANAGE)));
const characterId = yield select((state: any) =>
rulesEngineSelectors.getId(state)
);
const navigate = yield select(
(state: any) => state.routeControls.navigate
);
navigate(
getRouteDefPath(RouteKey.CLASS_MANAGE).replace(
":characterId",
characterId
)
);
break;
}
case characterActionTypes.CLASS_REMOVE_POST_ACTION: {
let classes = yield select(rulesEngineSelectors.getClasses);
if (classes.length === 0) {
// yield put(push(getRouteDefPath(RouteKey.CLASS_CHOOSE)));
const characterId = yield select((state: any) =>
rulesEngineSelectors.getId(state)
);
const navigate = yield select(
(state: any) => state.routeControls.navigate
);
navigate(
getRouteDefPath(RouteKey.CLASS_CHOOSE).replace(
":characterId",
characterId
)
);
}
break;
}
case characterActionTypes.CHARACTER_LOAD_POST_ACTION: {
yield put(builderActions.characterLoadingSetCommit(false));
yield put(builderActions.characterLoadedSetCommit(true));
const builderMethod: ReturnType<
typeof builderSelectors.getBuilderMethod
> = yield select(builderSelectors.getBuilderMethod);
const context: ReturnType<typeof characterEnvSelectors.getContext> =
yield select(characterEnvSelectors.getContext);
if (
builderMethod === BuilderMethod.STEP_BY_STEP &&
context === Constants.AppContextTypeEnum.BUILDER
) {
const configuration: ReturnType<
typeof rulesEngineSelectors.getCharacterConfiguration
> = yield select(rulesEngineSelectors.getCharacterConfiguration);
const stepByStepInitialPage = configuration.showHelpText
? RouteKey.HOME_HELP
: RouteKey.HOME_BASIC_INFO;
const isCurrentRouteInRoutePathing = yield select(
NavigationUtils.checkIsCurrentRouteInRoutePathing
);
// TODO routes this needs fixed
// yield put(push(navConfig.getRouteDefPath(stepByStepInitialPage)));
if (!isCurrentRouteInRoutePathing) {
const navigate = yield select(
(state: any) => state.routeControls.navigate
);
navigate(
navConfig
.getRouteDefPath(stepByStepInitialPage)
.replace(":characterId", action.payload.characterId)
);
}
// window.location = navConfig.getRouteDefPath(stepByStepInitialPage).replace(':characterId', action.payload.characterId);
// }
}
break;
}
}
}
function* filter(action: BuilderAction) {
switch (action.type) {
case builderActionTypes.SHOW_HELP_TEXT_SET:
yield call(ApiRequests.putCharacterHelpText, action.payload);
yield put(characterActions.showHelpTextSet(action.payload.showHelpText));
if (action.payload.showHelpText) {
const currentSectionHelpRoutePath = yield select(
NavigationUtils.getCurrentSectionHelpRoutePath
);
if (currentSectionHelpRoutePath !== null) {
const characterId = yield select((state: any) =>
rulesEngineSelectors.getId(state)
);
const navigate = yield select(
(state: any) => state.routeControls.navigate
);
navigate(
currentSectionHelpRoutePath.replace(":characterId", characterId)
);
// yield put(push(currentSectionHelpRoutePath));
}
} else {
const availablePathRedirect = yield select(
NavigationUtils.getAvailablePathRedirect
);
if (availablePathRedirect !== null) {
const characterId = yield select((state: any) =>
rulesEngineSelectors.getId(state)
);
const navigate = yield select(
(state: any) => state.routeControls.navigate
);
navigate(availablePathRedirect.replace(":characterId", characterId));
// yield put(push(availablePathRedirect));
}
}
break;
}
}
function* syncFilter(action: BuilderAction) {
const isSyncTransactionActive = yield select(
syncTransactionSelectors.getActive
);
let transactionInitiatorId;
if (!isSyncTransactionActive) {
transactionInitiatorId = uniqueId();
yield put(syncTransactionActions.activate(transactionInitiatorId));
}
switch (action.type) {
case builderActionTypes.QUICK_BUILD_REQUEST:
yield call(handleQuickBuildRequest, action);
break;
case builderActionTypes.RANDOM_BUILD_REQUEST:
yield call(handleRandomBuildRequest, action);
break;
case builderActionTypes.STEP_BUILD_REQUEST:
yield call(handleStepBuildRequest, action);
break;
case builderActionTypes.CHARACTER_LOAD_REQUEST:
yield call(handleLoadCharacterRequest, action);
break;
case builderActionTypes.BUILDER_METHOD_SET:
yield call(handleBuilderMethodSet, action);
break;
case builderActionTypes.SUGGESTED_NAMES_REQUEST:
yield call(handleSuggestedNamesRequest, action);
break;
}
switch (action.type) {
case builderActionTypes.QUICK_BUILD_REQUEST:
case builderActionTypes.RANDOM_BUILD_REQUEST:
break;
default:
let syncTransactionInitiator = yield select(
syncTransactionSelectors.getInitiator
);
if (syncTransactionInitiator === transactionInitiatorId) {
yield put(syncTransactionActions.deactivate());
}
break;
}
}
function* handleBuilderMethodSet(action: BuilderMethodSetAction) {
const response = yield call(ApiRequests.getCharacterRuleData, {
params: { v: appConfig.version },
});
const data: UnpackMakeApiResponseData<
typeof ApiRequests.getCharacterRuleData
> | null = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
yield put(ruleDataActions.dataSet(data));
}
yield put(builderActions.builderMethodSetCommit(action.payload));
}
function* handleQuickBuildRequest(action: QuickBuildRequestAction) {
const characterId: UnpackMakeApiResponseData<
typeof ApiRequests.postCharacterBuilderQuickBuild
> = yield call(
SagaHelpers.getApiRequestData,
ApiRequests.postCharacterBuilderQuickBuild,
action.payload
);
// if (data.success) {
const characterUrl = NavigationUtils.getCharacterBuilderUrl(characterId);
const initialPath = navConfig.getRouteDefPath(RouteKey.WHATS_NEXT);
// todo just a soft navigation
window.location = `${characterUrl}${initialPath}` as any;
// } else {
//TODO do something with error
// }
}
function* handleRandomBuildRequest(action: RandomBuildRequestAction) {
const characterId: UnpackMakeApiResponseData<
typeof ApiRequests.postCharacterBuilderRandomBuild
> = yield call(
SagaHelpers.getApiRequestData,
ApiRequests.postCharacterBuilderRandomBuild,
action.payload
);
// if (data.success) {
const characterUrl = NavigationUtils.getCharacterBuilderUrl(characterId);
const initialPath = navConfig.getRouteDefPath(RouteKey.WHATS_NEXT);
window.location = `${characterUrl}#${initialPath}` as any;
// } else {
//TODO do something with error
// }
}
function* handleStepBuildRequest(action: StepBuildRequestAction) {
yield put(builderActions.characterLoadingSetCommit(true));
//TODO fix with common refactor for populating character
const characterId: UnpackMakeApiResponseData<
typeof ApiRequests.postCharacterBuilderStandardBuild
> = yield call(
SagaHelpers.getApiRequestData,
ApiRequests.postCharacterBuilderStandardBuild,
action.payload
);
yield put(appEnvActions.dataSet({ characterId }));
yield put(
builderActions.builderMethodSetCommit({
method: BuilderMethod.STEP_BY_STEP,
})
);
yield select(appEnvSelectors.getUsername);
/* eslint-disable-next-line */
yield put(
characterActions.characterLoad(characterId, { v: appConfig.version })
);
}
function* handleLoadCharacterRequest(action: BuilderAction) {
yield put(builderActions.characterLoadingSetCommit(true));
const characterId: ReturnType<typeof appEnvSelectors.getCharacterId> =
yield select(appEnvSelectors.getCharacterId);
if (characterId === null) {
// TODO do something
return;
}
try {
yield put(
builderActions.builderMethodSetCommit({
method: BuilderMethod.STEP_BY_STEP,
})
);
yield put(
characterActions.characterLoad(characterId, { v: appConfig.version })
);
} catch (e) {
// TODO handle failure in builder init process
}
}
/**
*
* @param action
*/
export function* handleSuggestedNamesRequest(
action: SuggestedNamesRequestAction
) {
const species: ReturnType<typeof rulesEngineSelectors.getRace> = yield select(
rulesEngineSelectors.getRace
);
let reqParams: ApiRequests.GetSuggestedNames_RequestData = {
count: 5,
};
if (species !== null) {
reqParams = {
...reqParams,
raceDefinitionKey: RaceUtils.getDefinitionKey(species),
};
}
const names: UnpackMakeApiResponseData<typeof ApiRequests.getSuggestedNames> =
yield call(SagaHelpers.getApiRequestData, ApiRequests.getSuggestedNames, {
params: reqParams,
});
yield put(builderActions.suggestedNamesSet(names));
}
@@ -0,0 +1,181 @@
import { createSelector } from "reselect";
import {
featureFlagInfoSelectors,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { NavigationType } from "~/subApps/builder/constants";
import { getNavBuilderConfig } from "../config/navigation";
import { BuilderAppState } from "../typings";
export const getCurrentPath = (state) => window.location.pathname;
export const getBuilderMethod = (state: BuilderAppState) =>
state.builder.method;
export const getConfirmSpecies = (state: BuilderAppState) =>
state.builder.confirmSpecies;
export const getConfirmClass = (state: BuilderAppState) =>
state.builder.confirmClass;
export const getIsCharacterLoading = (state: BuilderAppState) =>
state.builder.isCharacterLoading;
export const getIsCharacterLoaded = (state: BuilderAppState) =>
state.builder.isCharacterLoaded;
export const getSuggestedNames = (state: BuilderAppState) =>
state.builder.suggestedNames;
export const getRoutePathing = createSelector(
[
(state) => state,
getBuilderMethod,
rulesEngineSelectors.getCharacterConfiguration,
],
(state, builderMethod, configuration) => {
const navConfig = getNavBuilderConfig(
builderMethod,
featureFlagInfoSelectors.getFeatureFlagInfo(state)
);
let exclusionNavTypes: Array<any> = [];
if (!configuration.showHelpText) {
exclusionNavTypes.push(NavigationType.HelpPage);
}
let routePath: Array<any> = [];
navConfig.forEach((section) => {
const { routes } = section;
routes.forEach((routeDef) => {
let available = true;
if (exclusionNavTypes.indexOf(routeDef.type) > -1) {
available = false;
}
if (!routeDef.checkCanAccess(routeDef, state)) {
available = false;
}
routePath.push({
...routeDef,
available,
});
});
});
return routePath;
}
);
export const getAvailableRoutePathing = createSelector(
[
(state) => state,
getBuilderMethod,
rulesEngineSelectors.getCharacterConfiguration,
],
(state, builderMethod, configuration) => {
const navConfig = getNavBuilderConfig(
builderMethod,
featureFlagInfoSelectors.getFeatureFlagInfo(state)
);
let exclusionNavTypes: Array<any> = [];
if (!configuration.showHelpText) {
exclusionNavTypes.push(NavigationType.HelpPage);
}
let routePath: Array<any> = [];
navConfig.forEach((section) => {
const { routes } = section;
routes.forEach((routeDef) => {
if (exclusionNavTypes.indexOf(routeDef.type) > -1) {
return;
}
if (!routeDef.checkCanAccess(routeDef, state)) {
return;
}
routePath.push({
...routeDef,
});
});
});
return routePath;
}
);
export const getFirstAvailableSectionRoutes = createSelector(
[
(state) => state,
getBuilderMethod,
rulesEngineSelectors.getCharacterConfiguration,
],
(state, builderMethod, configuration) => {
const navConfig = getNavBuilderConfig(
builderMethod,
featureFlagInfoSelectors.getFeatureFlagInfo(state)
);
let exclusionNavTypes: Array<any> = [];
if (!configuration.showHelpText) {
exclusionNavTypes.push(NavigationType.HelpPage);
}
let sections = {};
navConfig.forEach((section) => {
const { routes } = section;
let found = false;
routes.forEach((routeDef) => {
if (found) {
return;
}
if (exclusionNavTypes.indexOf(routeDef.type) > -1) {
return;
}
if (!routeDef.checkCanAccess(routeDef, state)) {
return;
}
sections[section.key] = routeDef;
found = true;
});
});
return sections;
}
);
export const checkIsCharacterSheetReady = createSelector(
[
rulesEngineSelectors.getRace,
rulesEngineSelectors.getClasses,
rulesEngineSelectors.getAbilities,
rulesEngineSelectors.getCharacterConfiguration,
],
(species, classes, stats, configuration) => {
if (!species) {
return false;
}
if (!classes.length) {
return false;
}
let hasAllStats = true;
if (configuration.abilityScoreType === null) {
hasAllStats = false;
}
stats.forEach((stat) => {
if (stat.baseScore === null) {
hasAllStats = false;
}
});
if (!hasAllStats) {
return false;
}
return true;
}
);
export const checkHasCharRequiredData = createSelector(
[rulesEngineSelectors.getRace, rulesEngineSelectors.getClasses],
(species, classes) => species !== null && !!classes.length
);
@@ -0,0 +1,14 @@
import { createSelector } from "reselect";
import * as appEnvSelectors from "../../Shared/selectors/appEnv";
import * as NavigationUtils from "../utils/navigationUtils";
export const getProfileCharacterListingUrl = () =>
NavigationUtils.getCharacterListingUrl();
export const getCharacterSheetUrl = createSelector(
[appEnvSelectors.getCharacterId],
(characterId) => {
return NavigationUtils.getCharacterSheetUrl(characterId);
}
);
@@ -0,0 +1,163 @@
import { flatten } from "lodash";
import {
featureFlagInfoSelectors,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine";
import config from "~/config";
import { NavigationType } from "~/subApps/builder/constants";
import * as navConfig from "../config/navigation";
import { builderSelectors } from "../selectors";
const BASE_PATHNAME = config.basePathname;
function checkIsRouteInNavConfig(targetRouteDef, navConfig) {
const routeDefs: Array<any> = flatten(
navConfig.map((section) => section.routes)
);
return routeDefs.filter((routeDef) => routeDef.key === targetRouteDef.key)
.length;
}
export function checkStdBuilderPageRequirements(routeDef, state) {
return (
builderSelectors.getBuilderMethod(state) !== null &&
checkIsRouteInNavConfig(
routeDef,
navConfig.getNavBuilderConfig(
builderSelectors.getBuilderMethod(state),
featureFlagInfoSelectors.getFeatureFlagInfo(state)
)
)
);
}
export function checkIsCurrentRouteInRoutePathing(state) {
const currentPath = state.routeControls.currentPath;
const characterId = rulesEngineSelectors.getId(state);
const routePathing = builderSelectors.getRoutePathing(state);
const currentRoutePathIdx = getRoutePathIdxByPath(
currentPath,
routePathing,
characterId
);
return currentRoutePathIdx > 0;
}
// TODO: This function seems to jsut return '/' so investigate it and remove it if it's not needed
export const getAvailableRouteRedirect = (routeKey, state) => {
const routePath = navConfig.getRouteDefPath(routeKey);
const routePathing = builderSelectors.getRoutePathing(state);
const currentRoutePathIdx = getRoutePathIdxByPath(routePath, routePathing);
const currentRoutePathingNode = routePathing[currentRoutePathIdx];
if (currentRoutePathIdx === -1 || !currentRoutePathingNode.available) {
return "/";
}
return "/";
};
export const getRoutePathIdx = (routeKey, routePathDef) => {
const path = navConfig.getRouteDefPath(routeKey);
return routePathDef.findIndex((routeDef) => path === routeDef.path);
};
export function getRoutePathIdxByPath(path, routePathDef, characterId = 0) {
return routePathDef.findIndex(
(routeDef) => path === routeDef.path.replace(":characterId", characterId)
);
}
export function getCurrentSectionHelpRoutePath(state) {
const currentPath = builderSelectors.getCurrentPath(state);
const sections = navConfig.getNavigationSections(
builderSelectors.getBuilderMethod(state),
navConfig.getCurrentRouteDef(currentPath),
featureFlagInfoSelectors.getFeatureFlagInfo(state)
);
const currentSection = sections.find((section) => section.active);
const currentSectionHelpRoute = currentSection.routes.find(
(route) => route.type === NavigationType.HelpPage
);
//TODO change this to getRouteDefPath once it takes a routeDef instead of routeKey
if (currentSectionHelpRoute) {
return currentSectionHelpRoute.path;
} else {
return null;
}
}
export function getAvailablePathRedirect(state) {
const currentPath = builderSelectors.getCurrentPath(state);
const routePathing = builderSelectors.getRoutePathing(state);
const characterId = rulesEngineSelectors.getId(state);
const currentRoutePathIdx = getRoutePathIdxByPath(
currentPath,
routePathing,
characterId
);
const currentRoutePathingNode = routePathing[currentRoutePathIdx];
if (!currentRoutePathingNode.available) {
for (let i = currentRoutePathIdx; i < routePathing.length; i++) {
if (routePathing[i].available) {
return routePathing[i].path;
}
}
}
return null;
}
export const getAvailablePrevRoute = (routeDef, state) => {
const availableRoutePath = builderSelectors.getAvailableRoutePathing(state);
const currentRoutePathIdx = getRoutePathIdx(routeDef.key, availableRoutePath);
// -1 === can't find in route path
// 0 === first page in path and there isn't a previous
if (currentRoutePathIdx <= 0) {
return null;
}
return availableRoutePath[currentRoutePathIdx - 1];
};
export const getAvailableNextRoute = (routeDef, state) => {
const availableRoutePath = builderSelectors.getAvailableRoutePathing(state);
const currentRoutePathIdx = getRoutePathIdx(routeDef.key, availableRoutePath);
const lastAvailableRoutePathIdx = availableRoutePath.length - 1;
// -1 === can't find in route path
// current = last === can't go any next because at last
if (
currentRoutePathIdx < 0 ||
currentRoutePathIdx >= lastAvailableRoutePathIdx
) {
return null;
}
return availableRoutePath[currentRoutePathIdx + 1];
};
export function getCharacterBuilderUrl(characterId): string {
return `${BASE_PATHNAME}/${characterId}/builder`;
}
export function getCharacterSheetUrl(characterId): string {
return `${BASE_PATHNAME}/${characterId}`;
}
export function getCharacterListingUrl(): string {
return `${BASE_PATHNAME}`;
}
export function getUrlWithParams(url?: string): string {
const baseUrl = url ?? BASE_PATHNAME;
return window.location.search
? `${baseUrl}${window.location.search}`
: baseUrl;
}
@@ -0,0 +1,2 @@
export const CHARACTER_INIT = "sheet.CHARACTER_INIT";
export const CHARACTER_RECEIVE_FAIL = "sheet.CHARACTER_RECEIVE_FAIL";
@@ -0,0 +1,28 @@
import {
CharacterInitAction,
CharacterReceiveFailAction,
sheetActionTypes,
} from "../sheet";
/**
*
*/
export function characterInit(): CharacterInitAction {
return {
type: sheetActionTypes.CHARACTER_INIT,
payload: {},
};
}
/**
*
* @param error
*/
export function characterReceiveFail(error: Error): CharacterReceiveFailAction {
return {
type: sheetActionTypes.CHARACTER_RECEIVE_FAIL,
payload: {
error,
},
};
}
@@ -0,0 +1,9 @@
import * as actionTypes from "./actionTypes";
import * as actions from "./actions";
import * as typings from "./typings";
export const sheetActionTypes = actionTypes;
export const sheetActions = actions;
export const sheetTypings = typings;
export * from "./typings";
@@ -0,0 +1,179 @@
import React from "react";
import { Snippet } from "@dndbeyond/character-components/es";
import {
AbilityLookup,
Action,
ActionUtils,
ActivationContract,
ActivationUtils,
BaseInventoryContract,
ClassUtils,
Constants,
ClassFeatureContract,
InfusionUtils,
ItemUtils,
LevelScaleContract,
RuleData,
SnippetData,
ClassFeatureUtils,
InventoryLookup,
HelperUtils,
Hack__BaseCharClass,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { FeatureSnippetLimitedUse } from "../FeatureSnippet";
interface Props {
action: Action;
activation: ActivationContract;
showActivationInfo: boolean;
onActionClick?: (action: Action) => void;
onActionUseSet?: (action: Action, used: number) => void;
abilityLookup: AbilityLookup;
inventoryLookup: InventoryLookup;
ruleData: RuleData;
snippetData: SnippetData;
isInteractive: boolean;
proficiencyBonus: number;
theme: CharacterTheme;
}
export default class ActionSnippet extends React.PureComponent<Props> {
static defaultProps = {
showActivationInfo: false,
};
handleActionUseSet = (action: Action, uses: number): void => {
const { onActionUseSet } = this.props;
if (onActionUseSet) {
onActionUseSet(action, uses);
}
};
handleActionClick = (evt: React.MouseEvent): void => {
const { action, onActionClick } = this.props;
if (onActionClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onActionClick(action);
}
};
render() {
const {
action,
ruleData,
abilityLookup,
snippetData,
isInteractive,
showActivationInfo,
inventoryLookup,
activation,
proficiencyBonus,
theme,
} = this.props;
let limitedUse = ActionUtils.getLimitedUse(action);
let name: React.ReactNode = ActionUtils.getName(action);
let dataOrigin = ActionUtils.getDataOrigin(action);
let dataOriginType = ActionUtils.getDataOriginType(action);
let classLevel: number | null = null;
let scaleValue: LevelScaleContract | null = null;
switch (dataOriginType) {
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
classLevel = ClassUtils.getLevel(
dataOrigin.parent as Hack__BaseCharClass
);
scaleValue = ClassFeatureUtils.getLevelScale(
dataOrigin.primary as ClassFeatureContract
);
break;
case Constants.DataOriginTypeEnum.RACE:
//scaleValue = RacialTraitUtils.getLevelScale(dataOrigin.primary); //TODO this doesn't exist.. is it still needed?
break;
case Constants.DataOriginTypeEnum.ITEM: {
if (!name) {
const itemContract = dataOrigin.primary as BaseInventoryContract;
const itemMappingId = ItemUtils.getMappingId(itemContract);
const item = HelperUtils.lookupDataOrFallback(
inventoryLookup,
itemMappingId
);
if (item !== null) {
const infusion = ItemUtils.getInfusion(item);
name = (
<React.Fragment>
<ItemName item={item} />
{infusion !== null && (
<span className="ct-feature-snippet__heading-extra">
(Infusion: {InfusionUtils.getName(infusion)})
</span>
)}
</React.Fragment>
);
}
}
break;
}
}
let activationDisplay: React.ReactNode;
if (showActivationInfo && activation) {
activationDisplay = (
<span
className={`ct-feature-snippet__heading-activation ${
theme.isDarkMode
? "ct-feature-snippet__heading-activation--dark-mode"
: ""
}`}
>
({ActivationUtils.renderActivation(activation, ruleData)})
</span>
);
}
return (
<div className="ct-feature-snippet" onClick={this.handleActionClick}>
<div
className={`ct-feature-snippet__heading ${
theme.isDarkMode ? "ct-feature-snippet__heading--dark-mode" : ""
}`}
>
{name} {activationDisplay}
</div>
<div className="ct-feature-snippet__content">
<Snippet
snippetData={snippetData}
theme={theme}
classLevel={classLevel}
levelScale={scaleValue}
limitedUse={limitedUse}
proficiencyBonus={proficiencyBonus}
>
{ActionUtils.getSnippet(action)}
</Snippet>
</div>
<FeatureSnippetLimitedUse
component={action}
theme={theme}
limitedUse={limitedUse}
onUseSet={this.handleActionUseSet}
abilityLookup={abilityLookup}
ruleData={ruleData}
isInteractive={isInteractive}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
}
}
@@ -0,0 +1,4 @@
import ActionSnippet from "./ActionSnippet";
export default ActionSnippet;
export { ActionSnippet };
@@ -0,0 +1,49 @@
import { CharacterTheme } from "@dndbeyond/character-rules-engine";
import { HTMLAttributes } from "react";
import styles from "./styles.module.css";
import clsx from "clsx";
interface ActionListSectionProps extends HTMLAttributes<HTMLDivElement> {
headingText: string;
theme: CharacterTheme;
testId?: string;
};
export const ActionListSection = ({
// Component Props
theme,
headingText,
testId = "",
// From HTMLAttributes
className,
children,
...props
}: ActionListSectionProps) => {
const headingTestId = `actions-list-${testId}-heading`;
const listTestId = `actions-list-${testId}-list`;
return (
<div className={clsx([styles.section, className])} {...props}>
<div
className={clsx([
styles.sectionHeading,
theme?.isDarkMode && styles.darkMode,
])}
data-testid={headingTestId}
>
{headingText}
</div>
<div
className={clsx([
styles.list,
theme?.isDarkMode && styles.darkMode,
])}
data-testid={listTestId}
>
{children}
</div>
</div>
);
};
@@ -0,0 +1,238 @@
import { orderBy } from "lodash";
import React, { HTMLAttributes, useMemo } from "react";
import { AttackTable } from "@dndbeyond/character-components/es";
import {
AbilityLookup,
Activatable,
Attack,
BasicActionContract,
Constants,
RuleData,
SnippetData,
Spell,
SpellCasterInfo,
SpellUtils,
WeaponSpellDamageGroup,
InventoryLookup,
DataOriginRefData,
HelperUtils,
ActionUtils,
BaseSpell,
CharacterTheme,
Action,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import ActionSnippet from "../ActionSnippet";
import BasicActions from "../BasicActions";
import { FeatureSnippetSpells } from "../FeatureSnippet";
import clsx from "clsx";
import styles from "./styles.module.css";
import { ActionListSection } from "./ActionListSection";
interface ActionsListProps extends HTMLAttributes<HTMLDivElement> {
heading: React.ReactNode;
theme: CharacterTheme;
showNotes: boolean;
diceEnabled?: boolean;
isInteractive: boolean;
showActivationInfo?: boolean;
actions?: Array<Activatable>;
basicActions?: Array<BasicActionContract>;
ritualSpells?: Array<Spell>;
attacks?: Array<Attack>;
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
ruleData: RuleData;
spellCasterInfo: SpellCasterInfo;
abilityLookup: AbilityLookup;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
rollContext: IRollContext;
inventoryLookup: InventoryLookup;
snippetData: SnippetData;
// ActionHandlers
onAttackClick: (attack: Attack) => void;
onActionClick: (action: Action) => void;
onBasicActionClick: (basicAction: BasicActionContract) => void;
onActionUseSet: (action: Action, used: number) => void;
onSpellClick: (spell: Spell) => void;
onSpellUseSet: (spell: Spell, used: number) => void;
};
export const ActionsList = ({
heading,
theme,
showNotes,
isInteractive,
diceEnabled = false,
showActivationInfo = false,
actions = [],
basicActions = [],
ritualSpells = [],
attacks = [],
weaponSpellDamageGroups,
ruleData,
spellCasterInfo,
abilityLookup,
dataOriginRefData,
proficiencyBonus,
rollContext,
inventoryLookup,
snippetData,
// From ActionHandlers
onAttackClick,
onBasicActionClick,
onActionClick,
onActionUseSet,
onSpellClick,
onSpellUseSet,
// From HTMLAttributes
className,
...props
}: ActionsListProps) => {
const { actionLookup } = useCharacterEngine();
const isVisible = useMemo(
() => !!basicActions.length
|| !!actions.length
|| !!ritualSpells.length
|| !!attacks.length,
[basicActions, actions, ritualSpells, attacks]
);
const attackTableProps = {
attacks,
weaponSpellDamageGroups,
ruleData,
abilityLookup,
spellCasterInfo,
onAttackClick,
showNotes,
diceEnabled,
theme,
dataOriginRefData,
proficiencyBonus,
rollContext,
};
const spellSnippetProps = {
onSpellClick,
onSpellUseSet,
ruleData,
theme,
abilityLookup,
isInteractive,
dataOriginRefData,
proficiencyBonus,
};
const spells = useMemo(() => {
let spells: Array<BaseSpell> = [];
actions.forEach((action) => {
switch (action.type) {
case Constants.ActivatableTypeEnum.CLASS_SPELL:
case Constants.ActivatableTypeEnum.CHARACTER_SPELL:
spells.push(action.entity);
break;
}
});
return spells;
}, [actions]);
const orderedRitualSpells = useMemo(
() => orderBy(
ritualSpells,
(spell) => SpellUtils.getName(spell)
),
[ritualSpells]
);
const features = useMemo(() => actions.filter(
a => a.type === Constants.ActivatableTypeEnum.ACTION)
.map(feature => {
const action = HelperUtils.lookupDataOrFallback(
actionLookup,
ActionUtils.getUniqueKey(feature.entity)
);
return (
<div
className={styles.activatable}
key={feature.key}
data-testid="actions-list-activatable-feature"
>
<ActionSnippet
theme={theme}
action={action ?? feature.entity}
onActionClick={onActionClick}
onActionUseSet={onActionUseSet}
abilityLookup={abilityLookup}
inventoryLookup={inventoryLookup}
ruleData={ruleData}
snippetData={snippetData}
isInteractive={isInteractive}
showActivationInfo={showActivationInfo}
activation={feature.activation}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
}),
[
actions,
actionLookup,
theme,
onActionClick,
onActionUseSet,
abilityLookup,
inventoryLookup,
ruleData,
snippetData,
isInteractive,
showActivationInfo,
proficiencyBonus,
]);
return isVisible ? (
<div className={clsx([styles.actionsList, className])} {...props}>
<div
className={clsx([styles.heading, theme.isDarkMode && styles.darkMode])}
data-testid="actions-list-header"
>
{heading}
</div>
<div>
{!!attacks.length && <AttackTable className={styles.attackTable} {...attackTableProps} />}
{!!basicActions.length && (
<ActionListSection headingText="Actions in Combat" testId="basic" theme={theme}>
<BasicActions
onActionClick={onBasicActionClick}
basicActions={basicActions}
theme={theme}
/>
</ActionListSection>
)}
{!!spells.length && (
<ActionListSection headingText="Spells" theme={theme}>
<FeatureSnippetSpells layoutType={"compact"} spells={spells} {...spellSnippetProps} />
</ActionListSection>
)}
{!!ritualSpells.length && (
<ActionListSection headingText="Ritual Spells" theme={theme}>
<FeatureSnippetSpells layoutType={"compact"} spells={orderedRitualSpells} {...spellSnippetProps} />
</ActionListSection>
)}
{features}
</div>
</div>
) : null;
};
@@ -0,0 +1,100 @@
import React from "react";
import {
AbilityLookup,
Action,
Background,
BackgroundUtils,
CharacterFeaturesManager,
CharacterTheme,
DataOriginRefData,
FeatManager,
RuleData,
SnippetData,
Spell,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { DataOriginTypeEnum } from "~/constants";
import { FeatFeatureSnippet } from "../FeatureSnippet";
interface Props {
background: Background | null;
onClick: () => void;
onFeatClick?: (feat: FeatManager) => void;
featuresManager: CharacterFeaturesManager;
onActionUseSet: (action: Action, uses: number) => void;
onActionClick?: (action: Action) => void;
onSpellClick?: (spell: Spell) => void;
onSpellUseSet: (spell: Spell, uses: number) => void;
snippetData: SnippetData;
ruleData: RuleData;
abilityLookup: AbilityLookup;
dataOriginRefData: DataOriginRefData;
isReadonly: boolean;
proficiencyBonus: number;
theme: CharacterTheme;
}
export default class BackgroundDetail extends React.PureComponent<Props> {
handleBackgroundClick = (evt: React.MouseEvent): void => {
const { onClick } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick();
}
};
renderDefault = (): React.ReactNode => {
return (
<div className="ct-background__default">
Your character's background feature will display in this section once
chosen.
</div>
);
};
render() {
const { background, featuresManager, onFeatClick, ...restProps } =
this.props;
if (!background) {
return this.renderDefault();
}
const name = BackgroundUtils.getName(background);
const featureName = BackgroundUtils.getFeatureName(background);
const featureDescription =
BackgroundUtils.getFeatureDescription(background);
const feats = featuresManager.getDataOriginOnlyFeatsByParent(
DataOriginTypeEnum.BACKGROUND,
`${BackgroundUtils.getId(background)}`
);
return (
<div className="ct-background" onClick={this.handleBackgroundClick}>
<div className="ct-background__name">{name}</div>
<>
{featureDescription && (
<div className="ct-background__feature">
<div className="ct-background__feature-name">
{featureName ? `Feature: ${featureName}` : "Feature"}
</div>
<HtmlContent
className="ct-background__feature-description"
html={featureDescription}
withoutTooltips
/>
</div>
)}
{feats.map((feat) => (
<FeatFeatureSnippet key={feat.getId()} feat={feat} {...restProps} />
))}
</>
</div>
);
}
}
@@ -0,0 +1,4 @@
import BackgroundDetail from "./BackgroundDetail";
export default BackgroundDetail;
export { BackgroundDetail };
@@ -0,0 +1,62 @@
import { orderBy } from "lodash";
import React from "react";
import {
BasicActionContract,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
basicActions: Array<BasicActionContract>;
onActionClick?: (basicAction: BasicActionContract) => void;
theme: CharacterTheme;
}
export default class BasicActions extends React.PureComponent<Props> {
handleActionClick = (
basicAction: BasicActionContract,
evt: React.MouseEvent
): void => {
const { onActionClick } = this.props;
if (onActionClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onActionClick(basicAction);
}
};
render() {
const { basicActions, theme } = this.props;
if (!basicActions.length) {
return null;
}
let orderedActions = orderBy(
basicActions,
[(basicAction) => basicAction.name],
["asc"]
);
return (
<div
className={`ct-basic-actions ${
theme.isDarkMode ? "ct-basic-actions--dark-mode" : ""
}`}
>
{orderedActions.map((basicAction, idx) => (
<span
className="ct-basic-actions__action"
key={basicAction.id}
onClick={this.handleActionClick.bind(this, basicAction)}
>
{basicAction.name}
{idx + 1 < orderedActions.length && (
<span className="ct-basic-actions__action-sep">,</span>
)}
</span>
))}
</div>
);
}
}
@@ -0,0 +1,4 @@
import BasicActions from "./BasicActions";
export default BasicActions;
export { BasicActions };

Some files were not shown because too many files have changed in this diff Show More