``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
649 lines
19 KiB
TypeScript
649 lines
19 KiB
TypeScript
import React from "react";
|
|
|
|
import {
|
|
Collapsible,
|
|
DamageTypeIcon,
|
|
MarketplaceCta,
|
|
AoeTypeIcon,
|
|
ComponentConstants,
|
|
} from "@dndbeyond/character-components/es";
|
|
import {
|
|
AbilityLookup,
|
|
AccessUtils,
|
|
Action,
|
|
ActionUtils,
|
|
ActivationUtils,
|
|
BaseInventoryContract,
|
|
CharacterTheme,
|
|
Constants,
|
|
DiceUtils,
|
|
EntityUtils,
|
|
EntityValueLookup,
|
|
FormatUtils,
|
|
HelperUtils,
|
|
InfusionUtils,
|
|
InventoryLookup,
|
|
ItemUtils,
|
|
LimitedUseUtils,
|
|
ModelInfoContract,
|
|
RuleData,
|
|
RuleDataUtils,
|
|
ValueUtils,
|
|
} from "@dndbeyond/character-rules-engine/es";
|
|
|
|
import { HtmlContent } from "~/components/HtmlContent";
|
|
import { InfoItem } from "~/components/InfoItem";
|
|
import { NumberDisplay } from "~/components/NumberDisplay";
|
|
|
|
import EditorBox from "../EditorBox";
|
|
import SlotManager from "../SlotManager";
|
|
import SlotManagerLarge from "../SlotManagerLarge";
|
|
import ValueEditor from "../ValueEditor";
|
|
import { RemoveButton } from "../common/Button";
|
|
import styles from "./styles.module.css";
|
|
|
|
interface Props {
|
|
action: Action;
|
|
ruleData: RuleData;
|
|
onCustomDataUpdate?: (
|
|
propertyKey: Constants.AdjustmentTypeEnum,
|
|
value: any,
|
|
source: any
|
|
) => void;
|
|
showCustomize?: boolean;
|
|
entityValueLookup: EntityValueLookup;
|
|
abilityLookup: AbilityLookup;
|
|
inventoryLookup: InventoryLookup;
|
|
isReadonly: boolean;
|
|
largePoolMinAmount?: number;
|
|
onLimitedUseSet?: (usedAmount: number) => void;
|
|
onCustomizationsRemove?: () => void;
|
|
proficiencyBonus: number;
|
|
theme?: CharacterTheme;
|
|
}
|
|
|
|
const infoItemProps = { role: "listItem", inline: true };
|
|
|
|
export const ActionDetail = ({
|
|
showCustomize = true,
|
|
largePoolMinAmount = 11,
|
|
onCustomizationsRemove,
|
|
ruleData,
|
|
abilityLookup,
|
|
action,
|
|
onLimitedUseSet,
|
|
proficiencyBonus,
|
|
isReadonly,
|
|
entityValueLookup,
|
|
onCustomDataUpdate,
|
|
theme,
|
|
inventoryLookup,
|
|
}: Props) => {
|
|
const handleRemoveCustomizations = () => {
|
|
if (onCustomizationsRemove) {
|
|
onCustomizationsRemove();
|
|
}
|
|
};
|
|
|
|
const renderSmallAmountSlotPool = (): React.ReactNode => {
|
|
let limitedUse = ActionUtils.getLimitedUse(action);
|
|
if (!limitedUse) {
|
|
return null;
|
|
}
|
|
|
|
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
|
const maxUses = LimitedUseUtils.deriveMaxUses(
|
|
limitedUse,
|
|
abilityLookup,
|
|
ruleData,
|
|
proficiencyBonus
|
|
);
|
|
|
|
return (
|
|
<SlotManager
|
|
used={numberUsed}
|
|
available={maxUses}
|
|
size={"small"}
|
|
onSet={onLimitedUseSet}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderLargeAmountSlotPool = (): React.ReactNode => {
|
|
let limitedUse = ActionUtils.getLimitedUse(action);
|
|
if (!limitedUse) {
|
|
return null;
|
|
}
|
|
|
|
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
|
const maxUses = LimitedUseUtils.deriveMaxUses(
|
|
limitedUse,
|
|
abilityLookup,
|
|
ruleData,
|
|
proficiencyBonus
|
|
);
|
|
|
|
return (
|
|
<SlotManagerLarge
|
|
available={maxUses}
|
|
used={numberUsed}
|
|
onSet={onLimitedUseSet}
|
|
isReadonly={isReadonly}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderLimitedUses = (): React.ReactNode => {
|
|
let limitedUse = ActionUtils.getLimitedUse(action);
|
|
if (!limitedUse) {
|
|
return null;
|
|
}
|
|
|
|
let maxUses = LimitedUseUtils.deriveMaxUses(
|
|
limitedUse,
|
|
abilityLookup,
|
|
ruleData,
|
|
proficiencyBonus
|
|
);
|
|
if (!maxUses) {
|
|
return null;
|
|
}
|
|
let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
|
let totalSlots: number = Math.max(maxUses, numberUsed);
|
|
|
|
return (
|
|
<div className={styles.limitedUses}>
|
|
<div className={styles.limitedUsesLabel}>Limited Use</div>
|
|
{totalSlots >= largePoolMinAmount
|
|
? renderLargeAmountSlotPool()
|
|
: renderSmallAmountSlotPool()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderCustomize = (): React.ReactNode => {
|
|
const mappingId = ActionUtils.getMappingId(action);
|
|
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
|
|
|
|
if (
|
|
!showCustomize ||
|
|
isReadonly ||
|
|
mappingId === null ||
|
|
mappingEntityTypeId === null
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
let valueEditorComponents: Array<Constants.AdjustmentTypeEnum> = [
|
|
Constants.AdjustmentTypeEnum.TO_HIT_OVERRIDE,
|
|
Constants.AdjustmentTypeEnum.TO_HIT_BONUS,
|
|
Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS,
|
|
Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK,
|
|
Constants.AdjustmentTypeEnum.NAME_OVERRIDE,
|
|
Constants.AdjustmentTypeEnum.NOTES,
|
|
];
|
|
|
|
const isCustomized = ActionUtils.isCustomized(action);
|
|
|
|
return (
|
|
<Collapsible
|
|
layoutType={"minimal"}
|
|
header={`Customize${isCustomized ? "*" : ""}`}
|
|
className={styles.customize}
|
|
>
|
|
<EditorBox>
|
|
<ValueEditor
|
|
dataLookup={ValueUtils.getEntityData(
|
|
entityValueLookup,
|
|
mappingId,
|
|
mappingEntityTypeId
|
|
)}
|
|
onDataUpdate={onCustomDataUpdate}
|
|
valueEditors={valueEditorComponents}
|
|
ruleData={ruleData}
|
|
labelOverrides={{
|
|
[Constants.AdjustmentTypeEnum.NAME_OVERRIDE]: "Name",
|
|
[Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS]: "Damage Bonus",
|
|
}}
|
|
defaultValues={{
|
|
[Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK]:
|
|
ActionUtils.isDefaultDisplayAsAttack(action),
|
|
}}
|
|
layoutType={"compact"}
|
|
/>
|
|
<RemoveButton
|
|
enableConfirm={true}
|
|
size="medium"
|
|
style="filled"
|
|
disabled={!isCustomized}
|
|
isInteractive={isCustomized}
|
|
onClick={handleRemoveCustomizations}
|
|
>
|
|
{isCustomized ? "Remove" : "No"} Customizations
|
|
</RemoveButton>
|
|
</EditorBox>
|
|
</Collapsible>
|
|
);
|
|
};
|
|
|
|
const getRangedInfoData = (): Array<React.ReactNode> => {
|
|
let rangeInfo = ActionUtils.getRange(action);
|
|
let attackRangeId = ActionUtils.getAttackRangeId(action);
|
|
|
|
let rangeAreas: Array<React.ReactNode> = [];
|
|
if (
|
|
rangeInfo !== null &&
|
|
attackRangeId === Constants.AttackTypeRangeEnum.RANGED
|
|
) {
|
|
if (
|
|
rangeInfo.origin &&
|
|
rangeInfo.origin !== Constants.SpellRangeTypeEnum.RANGED
|
|
) {
|
|
let spellRangeType = RuleDataUtils.getSpellRangeType(
|
|
rangeInfo.origin,
|
|
ruleData
|
|
);
|
|
if (spellRangeType !== null) {
|
|
rangeAreas.push(spellRangeType.name);
|
|
}
|
|
}
|
|
|
|
if (rangeInfo.range) {
|
|
rangeAreas.push(
|
|
<React.Fragment>
|
|
<NumberDisplay type="distanceInFt" number={rangeInfo.range} />
|
|
{rangeInfo.longRange && <span>({rangeInfo.longRange})</span>}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
if (rangeInfo.aoeSize) {
|
|
let aoeType: ModelInfoContract | null = null;
|
|
if (rangeInfo.aoeType !== null) {
|
|
aoeType = RuleDataUtils.getAoeType(rangeInfo.aoeType, ruleData);
|
|
}
|
|
rangeAreas.push(
|
|
<React.Fragment>
|
|
<NumberDisplay type="distanceInFt" number={rangeInfo.aoeSize} />
|
|
{aoeType !== null && (
|
|
<span className={styles.rangeShape}>
|
|
<AoeTypeIcon
|
|
className={styles.rangeIcon}
|
|
type={
|
|
FormatUtils.slugify(
|
|
aoeType.name
|
|
) as ComponentConstants.AoeTypePropType
|
|
}
|
|
themeMode={theme?.isDarkMode ? "gray" : "dark"}
|
|
/>
|
|
</span>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
return rangeAreas;
|
|
//`
|
|
};
|
|
|
|
const renderDamageProperties = (): React.ReactNode => {
|
|
let damage = ActionUtils.getDamage(action);
|
|
|
|
let damageDisplay: React.ReactNode = null;
|
|
if (damage.value !== null) {
|
|
if (typeof damage.value === "number") {
|
|
damageDisplay = damage.value;
|
|
} else {
|
|
damageDisplay = DiceUtils.renderDice(damage.value);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<React.Fragment>
|
|
{damageDisplay !== null && (
|
|
<InfoItem label="Damage:" {...infoItemProps}>
|
|
{damageDisplay}
|
|
</InfoItem>
|
|
)}
|
|
{damage.type !== null && damage.type.name !== null && (
|
|
<InfoItem label="Damage Type:" {...infoItemProps}>
|
|
<DamageTypeIcon
|
|
theme={theme}
|
|
type={
|
|
FormatUtils.slugify(
|
|
damage.type.name
|
|
) as ComponentConstants.DamageTypePropType
|
|
}
|
|
/>
|
|
{damage.type.name}
|
|
</InfoItem>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
};
|
|
|
|
const renderWeaponProperties = (): React.ReactNode => {
|
|
let isProficient = ActionUtils.isProficient(action);
|
|
let toHit = ActionUtils.getToHit(action);
|
|
let attackRangeId = ActionUtils.getAttackRangeId(action);
|
|
let statId = ActionUtils.getStatId(action);
|
|
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
|
|
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
|
|
let activation = ActionUtils.getActivation(action);
|
|
let notes = ActionUtils.getNotes(action);
|
|
let attackSubtypeId = ActionUtils.getAttackSubtypeId(action);
|
|
let saveStateId = ActionUtils.getSaveStatId(action);
|
|
|
|
let rangeAreas: Array<React.ReactNode> = [];
|
|
if (attackRangeId === Constants.AttackTypeRangeEnum.RANGED) {
|
|
rangeAreas = getRangedInfoData();
|
|
} else {
|
|
rangeAreas.push(
|
|
<React.Fragment>
|
|
<NumberDisplay
|
|
type="distanceInFt"
|
|
number={ActionUtils.getReach(action)}
|
|
/>{" "}
|
|
Reach
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.properties} role="list">
|
|
{activation !== null && activation.activationType && (
|
|
<InfoItem label="Action Type:" {...infoItemProps}>
|
|
{ActivationUtils.renderActivation(activation, ruleData)}
|
|
</InfoItem>
|
|
)}
|
|
{attackSubtypeId && (
|
|
<InfoItem label="Attack Type:" {...infoItemProps}>
|
|
{ActionUtils.getAttackSubtypeName(action)}
|
|
</InfoItem>
|
|
)}
|
|
{requiresAttackRoll && (
|
|
<InfoItem label="To Hit:" {...infoItemProps}>
|
|
<NumberDisplay type="signed" number={toHit} />
|
|
</InfoItem>
|
|
)}
|
|
{requiresSavingThrow && (
|
|
<InfoItem label="Attack/Save:" {...infoItemProps}>
|
|
{saveStateId !== null
|
|
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
|
|
: ""}{" "}
|
|
{ActionUtils.getAttackSaveValue(action)}
|
|
</InfoItem>
|
|
)}
|
|
{renderDamageProperties()}
|
|
{requiresAttackRoll && (
|
|
<InfoItem label="Stat:" {...infoItemProps}>
|
|
{statId === null
|
|
? "--"
|
|
: RuleDataUtils.getStatNameById(statId, ruleData)}
|
|
</InfoItem>
|
|
)}
|
|
{rangeAreas.length > 0 && (
|
|
<InfoItem label="Range/Area:" {...infoItemProps}>
|
|
{rangeAreas.map((node, idx) => (
|
|
<React.Fragment key={idx}>
|
|
{node}
|
|
{idx + 1 < rangeAreas.length ? "/" : ""}
|
|
</React.Fragment>
|
|
))}
|
|
</InfoItem>
|
|
)}
|
|
{isProficient && (
|
|
<InfoItem label="Proficient:" {...infoItemProps}>
|
|
Yes
|
|
</InfoItem>
|
|
)}
|
|
{notes && (
|
|
<InfoItem label="Notes:" {...infoItemProps}>
|
|
{notes}
|
|
</InfoItem>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderSpellProperties = (): React.ReactNode => {
|
|
let isProficient = ActionUtils.isProficient(action);
|
|
let toHit = ActionUtils.getToHit(action);
|
|
let statId = ActionUtils.getStatId(action);
|
|
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
|
|
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
|
|
let activation = ActionUtils.getActivation(action);
|
|
let notes = ActionUtils.getNotes(action);
|
|
let attackSubtypeId = ActionUtils.getAttackSubtypeId(action);
|
|
let saveStateId = ActionUtils.getSaveStatId(action);
|
|
|
|
let rangeAreas = getRangedInfoData();
|
|
return (
|
|
<div className={styles.properties} role="list">
|
|
{activation !== null && activation.activationType && (
|
|
<InfoItem label="Casting Time:" {...infoItemProps}>
|
|
{ActivationUtils.renderActivation(activation, ruleData)}
|
|
</InfoItem>
|
|
)}
|
|
{attackSubtypeId && (
|
|
<InfoItem label="Attack Type:">
|
|
{ActionUtils.getAttackSubtypeName(action)}
|
|
</InfoItem>
|
|
)}
|
|
{requiresAttackRoll && (
|
|
<InfoItem label="To Hit:">
|
|
<NumberDisplay type="signed" number={toHit} />
|
|
</InfoItem>
|
|
)}
|
|
{requiresSavingThrow && (
|
|
<InfoItem label="Attack/Save:">
|
|
{saveStateId !== null
|
|
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
|
|
: ""}{" "}
|
|
{ActionUtils.getAttackSaveValue(action)}
|
|
</InfoItem>
|
|
)}
|
|
{renderDamageProperties()}
|
|
{requiresAttackRoll && (
|
|
<InfoItem label="Stat:">
|
|
{statId === null
|
|
? "--"
|
|
: RuleDataUtils.getStatNameById(statId, ruleData)}
|
|
</InfoItem>
|
|
)}
|
|
{rangeAreas.length > 0 && (
|
|
<InfoItem label="Range/Area:">
|
|
{rangeAreas.map((node, idx) => (
|
|
<React.Fragment key={idx}>
|
|
{node}
|
|
{idx + 1 < rangeAreas.length ? "/" : ""}
|
|
</React.Fragment>
|
|
))}
|
|
</InfoItem>
|
|
)}
|
|
{isProficient && <InfoItem label="Proficient:">Yes</InfoItem>}
|
|
{notes && <InfoItem label="Notes:">{notes}</InfoItem>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderGeneralProperties = (): React.ReactNode => {
|
|
let isProficient = ActionUtils.isProficient(action);
|
|
let toHit = ActionUtils.getToHit(action);
|
|
let statId = ActionUtils.getStatId(action);
|
|
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
|
|
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
|
|
let activation = ActionUtils.getActivation(action);
|
|
let notes = ActionUtils.getNotes(action);
|
|
let attackRangeId = ActionUtils.getAttackRangeId(action);
|
|
let saveStateId = ActionUtils.getSaveStatId(action);
|
|
|
|
let rangeAreas: Array<React.ReactNode> = [];
|
|
if (attackRangeId === Constants.AttackTypeRangeEnum.RANGED) {
|
|
rangeAreas = getRangedInfoData();
|
|
} else {
|
|
rangeAreas.push(
|
|
<React.Fragment>
|
|
<NumberDisplay
|
|
type="distanceInFt"
|
|
number={ActionUtils.getReach(action)}
|
|
/>{" "}
|
|
Reach
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.properties} role="list">
|
|
{activation !== null && activation.activationType && (
|
|
<InfoItem label={"Action Type:"} {...infoItemProps}>
|
|
{ActivationUtils.renderActivation(activation, ruleData)}
|
|
</InfoItem>
|
|
)}
|
|
{requiresAttackRoll && (
|
|
<InfoItem label="To Hit:" {...infoItemProps}>
|
|
<NumberDisplay type="signed" number={toHit} />
|
|
</InfoItem>
|
|
)}
|
|
{requiresSavingThrow && (
|
|
<InfoItem label="Attack/Save:" {...infoItemProps}>
|
|
{saveStateId !== null
|
|
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
|
|
: ""}{" "}
|
|
{ActionUtils.getAttackSaveValue(action)}
|
|
</InfoItem>
|
|
)}
|
|
{renderDamageProperties()}
|
|
{requiresAttackRoll && (
|
|
<InfoItem label="Stat:" {...infoItemProps}>
|
|
{statId === null
|
|
? "--"
|
|
: RuleDataUtils.getStatNameById(statId, ruleData)}
|
|
</InfoItem>
|
|
)}
|
|
{rangeAreas.length > 0 && (
|
|
<InfoItem label="Range/Area:" {...infoItemProps}>
|
|
{rangeAreas.map((node, idx) => (
|
|
<React.Fragment key={idx}>
|
|
{node}
|
|
{idx + 1 < rangeAreas.length ? "/" : ""}
|
|
</React.Fragment>
|
|
))}
|
|
</InfoItem>
|
|
)}
|
|
{isProficient && (
|
|
<InfoItem label="Proficient:" {...infoItemProps}>
|
|
Yes
|
|
</InfoItem>
|
|
)}
|
|
{notes && (
|
|
<InfoItem label="Notes:" {...infoItemProps}>
|
|
{notes}
|
|
</InfoItem>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderProperties = (): React.ReactNode => {
|
|
const actionTypeId = ActionUtils.getActionTypeId(action);
|
|
switch (actionTypeId) {
|
|
case Constants.ActionTypeEnum.SPELL:
|
|
return renderSpellProperties();
|
|
case Constants.ActionTypeEnum.WEAPON:
|
|
return renderWeaponProperties();
|
|
case Constants.ActionTypeEnum.GENERAL:
|
|
return renderGeneralProperties();
|
|
default:
|
|
// not implemented
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const renderDescription = (): React.ReactNode => {
|
|
let description = ActionUtils.getDescription(action);
|
|
let dataOrigin = ActionUtils.getDataOrigin(action);
|
|
let dataOriginType = ActionUtils.getDataOriginType(action);
|
|
|
|
if (!description) {
|
|
description = EntityUtils.getPrimaryDescription(dataOrigin);
|
|
}
|
|
|
|
if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) {
|
|
const itemContract = dataOrigin.primary as BaseInventoryContract;
|
|
const itemMappingId = ItemUtils.getMappingId(itemContract);
|
|
const item = HelperUtils.lookupDataOrFallback(
|
|
inventoryLookup,
|
|
itemMappingId
|
|
);
|
|
|
|
if (item === null) {
|
|
return null;
|
|
}
|
|
|
|
let infusion = ItemUtils.getInfusion(item);
|
|
|
|
if (
|
|
infusion &&
|
|
!AccessUtils.isAccessible(InfusionUtils.getAccessType(infusion))
|
|
) {
|
|
const sources = InfusionUtils.getSources(infusion);
|
|
|
|
let sourceNames: Array<string> = [];
|
|
if (sources.length > 0) {
|
|
sources.forEach((sourceMapping) => {
|
|
let source = RuleDataUtils.getSourceDataInfo(
|
|
sourceMapping.sourceId,
|
|
ruleData
|
|
);
|
|
|
|
if (source !== null && source.description !== null) {
|
|
sourceNames.push(source.description);
|
|
}
|
|
});
|
|
}
|
|
|
|
const sourceName: string | null =
|
|
sourceNames.length > 0
|
|
? FormatUtils.renderNonOxfordCommaList(sourceNames)
|
|
: null;
|
|
|
|
return (
|
|
<div className={styles.marketplaceCta}>
|
|
<MarketplaceCta
|
|
showImage={false}
|
|
sourceName={sourceName}
|
|
description={
|
|
"To unlock this infusion, check out the Marketplace to view purchase options."
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!description) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<HtmlContent
|
|
className={styles.description}
|
|
html={description}
|
|
withoutTooltips
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.details}>
|
|
{renderLimitedUses()}
|
|
{renderCustomize()}
|
|
{renderProperties()}
|
|
{renderDescription()}
|
|
</div>
|
|
);
|
|
};
|