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:
+121
@@ -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 don’t 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 };
|
||||
+315
@@ -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 };
|
||||
+484
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ClassManager from "./ClassManager";
|
||||
|
||||
export default ClassManager;
|
||||
export { ClassManager };
|
||||
+673
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ClassManagerFeature from "./ClassManagerFeature";
|
||||
|
||||
export default ClassManagerFeature;
|
||||
export { ClassManagerFeature };
|
||||
File diff suppressed because it is too large
Load Diff
+374
@@ -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 Player’s Handbook, you don’t 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 feature’s 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 };
|
||||
+55
@@ -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 };
|
||||
+54
@@ -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 character’s background describes where they came from, their
|
||||
original occupation, and their place in the D&D world.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Backgrounds from the Core D&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>
|
||||
You’ll need to decide your character’s appearance and personality.
|
||||
Choose your character’s 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 };
|
||||
+1635
File diff suppressed because it is too large
Load Diff
@@ -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 character’s
|
||||
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 };
|
||||
+482
@@ -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 &
|
||||
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 you’re 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 };
|
||||
+358
@@ -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),
|
||||
};
|
||||
}
|
||||
);
|
||||
+346
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -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! ✔</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&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 };
|
||||
Reference in New Issue
Block a user