Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,220 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
DigitalDiceWrapper,
LightDiceSvg,
} from "@dndbeyond/character-components/es";
import { RollKind, RollRequest, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { useSidebar } from "~/contexts/Sidebar";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import DiceAdjustmentSummary from "~/tools/js/Shared/components/DiceAdjustmentSummary";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "~/tools/js/Shared/selectors";
import { DataOrigin } from "~/types";
import { Heading } from "../../../components/Heading";
import { DeathSavesMarks } from "./DeathSavesMarks";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
damageValue: number;
}
export const DeathSavesManager: FC<Props> = ({
damageValue,
className,
...props
}) => {
const {
characterTheme,
deathSaveInfo,
ruleData,
characterActions,
ruleDataUtils,
} = useCharacterEngine();
const isDiceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const oneHPRestoreType = ruleDataUtils
.getRestoreTypes(ruleData)
.find((r) => r.name === "OneHP");
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
let rollKind = RollKind.None;
if (hasAdvantage && !hasDisadvantage) {
rollKind = RollKind.Advantage;
} else if (hasDisadvantage && !hasAdvantage) {
rollKind = RollKind.Disadvantage;
}
/* --- On Click Functions --- */
const onClickDataOrigin = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const dispatchDeathSaves = (fails: number, successes: number) => {
dispatch(
characterActions.deathSavesSet(
Math.min(ruleDataUtils.getMaxDeathsavesFail(ruleData), fails),
Math.min(ruleDataUtils.getMaxDeathsavesSuccess(ruleData), successes)
)
);
};
const onFail = (evt?: React.MouseEvent, isCritical = false) => {
const uses = isCritical ? 2 : 1;
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(
deathSaveInfo.failCount + uses,
deathSaveInfo.successCount
);
};
const onFailClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount - 1, deathSaveInfo.successCount);
};
const onSuccess = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount + 1);
};
const onSuccessClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount - 1);
};
return (
<div className={clsx([styles.column, className])} {...props}>
<div className={clsx([styles.row, styles.heading])}>
<Heading>Death Saves</Heading>
{isDiceEnabled && (
<DigitalDiceWrapper
diceNotation={"1d20"}
onRollResults={(rollRequest: RollRequest) => {
const roll = rollRequest.rolls[0].result?.total;
if (roll) {
if (roll === 1) {
onFail(undefined, true);
} else if (roll === 20 && oneHPRestoreType) {
dispatch(characterActions.restoreLife(oneHPRestoreType.id));
} else if (roll >= 10) {
onSuccess.bind(this)();
} else {
onFail.bind(this)();
}
}
}}
rollType={RollType.Save}
rollKind={rollKind}
rollAction={"Death"}
diceEnabled={isDiceEnabled}
rollContext={characterRollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
>
<LightDiceSvg />
<span>Roll</span>
</DigitalDiceWrapper>
)}
</div>
<div className={clsx([styles.row, styles.deathSavesGroups])}>
<DeathSavesMarks
label="Failures"
type="fails"
activeCount={deathSaveInfo.failCount}
willBeActiveCount={damageValue !== 0 ? 1 : 0}
totalCount={ruleData.maxDeathsavesFail}
onUse={onFail}
onClear={onFailClear}
/>
<DeathSavesMarks
label="Successes"
type="successes"
activeCount={deathSaveInfo.successCount}
totalCount={ruleData.maxDeathsavesSuccess}
onUse={onSuccess}
onClear={onSuccessClear}
/>
</div>
{(!!deathSaveInfo.advantageAdjustments.length ||
!!deathSaveInfo.disadvantageAdjustments.length) && (
<div className={styles.diceAdjustments}>
{deathSaveInfo.advantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
{deathSaveInfo.disadvantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import clsx from "clsx";
import { HTMLAttributes, FC } from "react";
import { CloseSvg } from "@dndbeyond/character-components/es";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
type: "successes" | "fails";
label: string;
activeCount: number;
totalCount: number;
willBeActiveCount?: number;
onUse: (evt: React.MouseEvent) => void;
onClear: (evt: React.MouseEvent) => void;
}
export const DeathSavesMarks: FC<Props> = ({
type,
label,
activeCount,
totalCount,
willBeActiveCount = 0,
onUse,
onClear,
className,
...props
}) => {
// TODO: Need to make these marks accessible - they currently cannot be interacted with with a keyboard.
let marks: Array<React.ReactNode> = [];
let availableSlots: number = totalCount;
for (let i = 0; i < activeCount; i++) {
marks.push(
<span
className={styles.mark}
key={`${type}-active-${i}`}
onClick={onClear}
>
<CloseSvg
fillColor={type === "successes" ? "#00c797" : "#c53131"}
secondaryFillColor=""
key={`${type}-active-${i}`}
className={clsx([styles.mark, styles.activeMark])}
/>
</span>
);
}
availableSlots -= activeCount;
for (let i = 0; i < Math.min(availableSlots, willBeActiveCount); i++) {
marks.push(
<span
key={`${type}-willbe-${i}`}
className={clsx([
styles.mark,
styles.willBeActive,
type === "fails" && styles.willBeFail,
])}
/>
);
}
availableSlots -= willBeActiveCount;
for (let i = 0; i < Math.min(availableSlots, totalCount); i++) {
marks.push(
<span
key={`${type}-inactive-${i}`}
className={clsx([styles.mark])}
onClick={onUse}
/>
);
}
return (
<div
className={clsx([styles.container, className])}
data-testid={`${type}-group`}
{...props}
>
<p className={styles.label}>{label}</p>
<div className={styles.marks}>{marks}</div>
</div>
);
};
@@ -0,0 +1,537 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { DataOriginName } from "@dndbeyond/character-components/es";
import { Constants, ItemManager } from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
import { Item, HitPointInfo, Creature } from "~/types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
hpInfo: HitPointInfo;
creature?: Creature;
handleDamageUpdate?: (damageAmount: number) => void;
vibrationAmount?: number;
}
// TODO This hitpoints adjuster doesn't work as a generic component for vehicles yet.
export const HitPointsAdjuster: FC<Props> = ({
hpInfo,
creature,
handleDamageUpdate,
vibrationAmount = 15,
className,
...props
}) => {
const {
characterTheme,
protectionSuppliers,
deathSaveInfo,
ruleData,
characterActions,
actionUtils,
characterUtils,
helperUtils,
itemUtils,
limitedUseUtils,
modifierUtils,
ruleDataUtils,
creatureUtils,
} = useCharacterEngine();
const getInitialActiveProtectionSupplierKey = (): string | null => {
const foundSupplier = protectionSuppliers.find(
(supplier) =>
supplier.availabilityStatus ===
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
);
if (foundSupplier) {
return foundSupplier.key;
}
return null;
};
const [tickCount, setTickCount] = useState(0);
const [healingAmount, setHealingAmount] = useState<number | null>(null);
const [damageAmount, setDamageAmount] = useState<number | null>(null);
const [hpDifference, setHpDifference] = useState(
characterUtils.calculateHitPoints(hpInfo, 0)
);
const [isValueChanged, setIsValueChanged] = useState<boolean>(false);
const [activeProtectionSupplierKey, setActiveProtectionSupplierKey] =
useState<string | null>(getInitialActiveProtectionSupplierKey());
const damageInput = createRef<HTMLInputElement>();
const healingInput = createRef<HTMLInputElement>();
const dispatch = useDispatch();
/* --- Helper Functions --- */
const reset = (newTemp: number | null): void => {
if (hpInfo.tempHp !== newTemp && newTemp !== null) {
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
hpInfo.removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, newTemp));
}
setTickCount(0);
setHealingAmount(0);
setDamageAmount(0);
setIsValueChanged(false);
setActiveProtectionSupplierKey(getInitialActiveProtectionSupplierKey());
};
const onKeyUpSave = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
if (evt.key === "Enter") {
onClickSave();
}
};
// onKeyDown must be used for the Escape key to reset the adjuster because the
// Sidebar uses onKeyDown to close when the Escape key is pressed.
const onKeyDownCancel = (
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (evt.key === "Escape") {
reset(hpInfo.tempHp);
}
};
/* --- On Input Change Functions --- */
const onChangeHealing = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
)
);
setDamageAmount(0);
setIsValueChanged(true);
};
const onChangeDamage = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(0);
setDamageAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
) * -1
);
setIsValueChanged(true);
};
/* --- On Click Functions --- */
const onClickIncrease = (): void => {
setHealingAmount(
healingAmount !== null && damageAmount === 0 ? healingAmount + 1 : 0
);
setDamageAmount(
damageAmount !== null && damageAmount < 0 ? damageAmount + 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickDecrease = (): void => {
setHealingAmount(
healingAmount !== null && healingAmount > 0 ? healingAmount - 1 : 0
);
setDamageAmount(
damageAmount !== null && healingAmount === 0 ? damageAmount - 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickSave = (): void => {
const { newTemp, startHp, newHp } = hpDifference;
let protectedHpResult: number | null = null;
if (!creature) {
if (
startHp !== 0 &&
newHp === 0 &&
activeProtectionSupplierKey !== null
) {
let foundActiveSupplier = protectionSuppliers.find(
(supplier) => supplier.key === activeProtectionSupplierKey
);
if (foundActiveSupplier) {
protectedHpResult = foundActiveSupplier.setHpValue;
switch (foundActiveSupplier.type) {
case Constants.ProtectionSupplierTypeEnum.ITEM: {
const itemData = foundActiveSupplier.data as Item;
const item = ItemManager.getItem(
itemUtils.getMappingId(itemData)
);
const numberUsed = item.getNumberUsed();
if (numberUsed !== null) {
item.handleItemLimitedUseSet(numberUsed + 1);
}
break;
}
case Constants.ProtectionSupplierTypeEnum.RACIAL_TRAIT:
case Constants.ProtectionSupplierTypeEnum.CLASS_FEATURE:
case Constants.ProtectionSupplierTypeEnum.FEAT: {
let action = foundActiveSupplier.action;
if (action !== null) {
let limitedUse = actionUtils.getLimitedUse(action);
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
const dataOriginType = actionUtils.getDataOriginType(action);
if (
limitedUse !== null &&
id !== null &&
entityTypeId !== null
) {
const numberUsed = limitedUseUtils.getNumberUsed(limitedUse);
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
numberUsed + 1,
dataOriginType
)
);
}
}
break;
}
default:
// not implemented
}
}
}
}
let removedHp: number = 0;
if (protectedHpResult === null) {
removedHp = hpInfo.totalHp - newHp;
} else {
removedHp = hpInfo.totalHp - protectedHpResult;
}
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(removedHp, newTemp));
const damage: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
if (startHp === 0 && !creature) {
if (newHp > 0) {
dispatch(characterActions.deathSavesSet(0, 0));
} else if (damage !== 0 && protectedHpResult === null) {
dispatch(
characterActions.deathSavesSet(
Math.min(
ruleDataUtils.getMaxDeathsavesFail(ruleData),
deathSaveInfo.failCount + 1
),
Math.min(
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
deathSaveInfo.successCount
)
)
);
reset(hpInfo.tempHp);
}
}
if (startHp === newHp) {
reset(newTemp);
}
};
const onClickCancel = (): void => {
reset(hpInfo.tempHp);
};
/* --- useEffects --- */
useEffect(() => {
const difference: number =
(healingAmount === null ? 0 : healingAmount) +
(damageAmount === null ? 0 : damageAmount) +
tickCount;
setHpDifference(characterUtils.calculateHitPoints(hpInfo, difference));
}, [healingAmount, damageAmount, tickCount, hpInfo]);
useEffect(() => {
reset(hpInfo.tempHp);
}, [hpInfo, protectionSuppliers]);
useEffect(() => {
if (handleDamageUpdate) {
const damageValue: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
handleDamageUpdate(damageValue);
}
}, [tickCount, healingAmount, damageAmount]);
/* --- Render Functions --- */
const renderProtectionInfo = (): React.ReactNode => {
const { startHp, newHp } = hpDifference;
if (
!isValueChanged ||
startHp === 0 ||
newHp !== 0 ||
protectionSuppliers.length === 0
) {
return null;
}
return (
<>
{protectionSuppliers.map((protectionSupplier) => {
if (
protectionSupplier.availabilityStatus !==
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
) {
return null;
}
let dataOrigin = modifierUtils.getDataOrigin(
protectionSupplier.modifier
);
let isEnabled =
activeProtectionSupplierKey !== null &&
protectionSupplier.key === activeProtectionSupplierKey;
//TODO remove if check and ItemName => use DataOriginName when EntityDataOriginLookup is available to be passed as a prop to DataOriginName
let nameNode: React.ReactNode = null;
if (
protectionSupplier.type ===
Constants.ProtectionSupplierTypeEnum.ITEM
) {
nameNode = <ItemName item={protectionSupplier.data as Item} />;
} else {
nameNode = (
<DataOriginName dataOrigin={dataOrigin} theme={characterTheme} />
);
}
return (
<div
className={clsx([styles.row, styles.protectionNotice])}
key={protectionSupplier.key}
>
<Checkbox
checked={isEnabled}
themed
darkMode={characterTheme.isDarkMode}
id={`${protectionSupplier.key}-checkbox`}
onClick={() => {
isEnabled = !isEnabled;
setActiveProtectionSupplierKey(
isEnabled ? protectionSupplier.key : null
);
}}
/>
<label htmlFor={`${protectionSupplier.key}-checkbox`}>
Instead of dropping to 0 hit points, use{" "}
<strong>{nameNode}</strong> to set hit points to{" "}
<strong>{protectionSupplier.setHpValue}</strong>
</label>
</div>
);
})}
</>
);
};
return (
<div
className={clsx([styles.column, styles.container, className])}
{...props}
>
<div className={clsx([styles.row, styles.container])}>
<div className={clsx([styles.column, styles.inputsContainer])}>
<div
className={clsx([styles.inputContainer, styles.healingContainer])}
>
<label
htmlFor="healing-input"
className={clsx([styles.inputLabel, styles.positiveColor])}
>
Healing
</label>
<input
ref={healingInput}
type="number"
id="healing-input"
className={styles.input}
value={
healingAmount === null
? ""
: Math.max(
tickCount +
healingAmount +
(damageAmount === null ? 0 : damageAmount),
0
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeHealing}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
<div className={clsx([styles.row, styles.newValues])}>
<div
className={clsx([
styles.column,
hpDifference.newHp > hpDifference.startHp &&
styles.positiveColor,
hpDifference.newHp < hpDifference.startHp &&
styles.negativeColor,
])}
>
<span className={styles.label}>New HP</span>
<span className={styles.value} data-testid="new-hp">
{hpDifference.newHp}
</span>
</div>
{hpInfo.tempHp !== null && hpInfo.tempHp > 0 && (
<div
className={clsx([
styles.column,
hpDifference.newTemp > hpInfo.tempHp && styles.positiveColor,
hpDifference.newTemp < hpInfo.tempHp && styles.negativeColor,
])}
>
<span className={styles.label}>New Temp</span>
<span className={styles.value} data-testid="new-temp-hp">
{hpDifference.newTemp}
</span>
</div>
)}
</div>
<div
className={clsx([styles.inputContainer, styles.damageContainer])}
>
<label
htmlFor="damage-input"
className={clsx([styles.inputLabel, styles.negativeColor])}
>
Damage
</label>
<input
ref={damageInput}
type="number"
id="damage-input"
className={styles.input}
value={
damageAmount === null
? ""
: Math.abs(
Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
)
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeDamage}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
</div>
<div className={clsx([styles.column, styles.modifyButtons])}>
<Button
onClick={onClickIncrease}
themed
size="xx-small"
className={styles.increase}
aria-label="Increase Hit Points"
/>
<Button
onClick={onClickDecrease}
themed
size="xx-small"
className={styles.decrease}
aria-label="Decrease Hit Points"
/>
</div>
</div>
{!creature && renderProtectionInfo()}
{isValueChanged && (
<div className={clsx([styles.row, styles.applyButtons])}>
<Button onClick={onClickSave} size="xx-small" themed>
Apply Changes
</Button>
<Button
onClick={onClickCancel}
variant="outline"
size="xx-small"
themed
>
Cancel
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,104 @@
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Constants } from "@dndbeyond/character-rules-engine";
import { HtmlContent } from "~/components/HtmlContent";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import { ShortModelInfoContract } from "~/types";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { DeathSavesManager } from "./DeathSavesManager/DeathSavesManager";
import { HitPointsAdjuster } from "./HitPointsAdjuster";
import { HitPointsOverrides } from "./HitPointsOverrides";
import { RestoreLifeManager } from "./RestoreLifeManager/RestoreLifeManager";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsManagePane: FC<Props> = ({ className, ...props }) => {
const { hpInfo, deathCause, ruleData, ruleDataUtils, characterActions } =
useCharacterEngine();
const dispatch = useDispatch();
const [damage, setDamage] = useState<number>(0);
const handleDamageUpdate = (damageValue: number) => {
setDamage(damageValue);
};
const onRestoreToLife = (restoreType: ShortModelInfoContract): void => {
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
dispatch(characterActions.restoreLife(restoreType.id));
dispatch(
toastMessageActions.toastSuccess(
"Character Restored to Life",
`You have been restored to life with ${restoreChoice} HP.`
)
);
};
const renderDeathSavesRules = (): React.ReactNode => {
if (
hpInfo.remainingHp > 0 ||
(deathCause !== Constants.DeathCauseEnum.NONE &&
deathCause !== Constants.DeathCauseEnum.DEATHSAVES)
) {
return null;
}
const deathSavesRule = ruleDataUtils.getRule(
RuleKeyEnum.DEATH_SAVING_THROWS,
ruleData
);
return (
<div>
<Heading>Death Saving Throws Rules</Heading>
<HtmlContent
html={deathSavesRule?.description ? deathSavesRule.description : ""}
withoutTooltips
/>
</div>
);
};
return (
<div className={className} {...props}>
<Header>HP Management</Header>
<div className={styles.container}>
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) && (
<DeathSavesManager damageValue={damage} className={styles.border} />
)}
{deathCause !== Constants.DeathCauseEnum.NONE && (
<RestoreLifeManager
onSave={onRestoreToLife}
className={styles.border}
/>
)}
<HitPointsSummary
hpInfo={hpInfo}
showOriginalMax
showPermanentInputs
className={styles.border}
/>
<HitPointsAdjuster
hpInfo={hpInfo}
handleDamageUpdate={handleDamageUpdate}
className={styles.border}
/>
<HitPointsOverrides className={styles.border} />
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) &&
renderDeathSavesRules()}
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import {
HP_BONUS_VALUE,
HP_DAMAGE_TAKEN_VALUE,
HP_OVERRIDE_MAX_VALUE,
HP_TEMP_VALUE,
} from "~/subApps/sheet/constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsOverrides: FC<Props> = ({ className, ...props }) => {
const { characterActions, hpInfo, ruleData, ruleDataUtils, helperUtils } =
useCharacterEngine();
const dispatch = useDispatch();
const [maxModifier, setMaxModifier] = useState<number | null>(hpInfo.bonusHp);
const [maxOverride, setMaxOverride] = useState<number | null>(
hpInfo.overrideHp
);
const maxModifierInput = createRef<HTMLInputElement>();
const maxOverrideInput = createRef<HTMLInputElement>();
/* --- Helper functions --- */
const updateDamageTaken = (maxHp: number) => {
if (hpInfo.totalHp !== null) {
let maxDiff = hpInfo.totalHp - maxHp;
if (maxDiff > 0) {
let newRemovedHp = Math.max(
HP_DAMAGE_TAKEN_VALUE.MIN,
hpInfo.removedHp - maxDiff
);
if (newRemovedHp !== hpInfo.removedHp) {
dispatch(
characterActions.hitPointsSet(
newRemovedHp,
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
)
);
}
}
}
};
const onKeyUp = (
func: any,
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
if (evt.key === "Enter") {
func(evt);
}
};
/* --- Max hit point modifier functions --- */
const onChangeMaxModifier = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxModifier(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxModifier = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
HP_BONUS_VALUE.MIN,
HP_BONUS_VALUE.MAX
);
}
if (value !== null) {
updateDamageTaken(hpInfo.baseTotalHp + value);
}
if (value !== hpInfo.bonusHp) {
dispatch(characterActions.bonusHitPointsSet(value));
}
setMaxModifier(value);
};
/* --- Max hit point override functions --- */
const onChangeMaxOverride = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxOverride(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxOverride = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
ruleDataUtils.getMinimumHpTotal(ruleData),
HP_OVERRIDE_MAX_VALUE
);
}
if (value !== null) {
updateDamageTaken(value);
}
if (value !== hpInfo.overrideHp) {
//TODO fix when it can accept null
dispatch(characterActions.overrideHitPointsSet(value as number));
}
setMaxOverride(value);
};
return (
<div className={clsx([styles.row, styles.overrides, className])} {...props}>
<label className={clsx([styles.column, styles.override, styles.label])}>
Max HP Modifier
<input
ref={maxModifierInput}
className={styles.input}
type="number"
value={maxModifier ?? ""}
min={HP_BONUS_VALUE.MIN}
max={HP_BONUS_VALUE.MAX}
onChange={onChangeMaxModifier}
onBlur={onBlurMaxModifier}
onKeyUp={onKeyUp.bind(this, onBlurMaxModifier)}
placeholder="--"
/>
</label>
<label className={clsx([styles.column, styles.override, styles.label])}>
Override Max HP
<input
ref={maxOverrideInput}
className={styles.input}
type="number"
value={maxOverride ?? ""}
min={ruleDataUtils.getMinimumHpTotal(ruleData)}
max={HP_OVERRIDE_MAX_VALUE}
onChange={onChangeMaxOverride}
onBlur={onBlurMaxOverride}
onKeyUp={onKeyUp.bind(this, onBlurMaxOverride)}
placeholder="--"
/>
</label>
</div>
);
};
@@ -0,0 +1,84 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import {
ExclusiveCheckbox,
TypeScriptUtils,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { ShortModelInfoContract } from "~/types";
import { Heading } from "../../../components/Heading";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
onSave?: (restoreType: ShortModelInfoContract) => void;
}
export const RestoreLifeManager: FC<Props> = ({
onSave,
className,
...props
}) => {
const { characterTheme, ruleData, ruleDataUtils } = useCharacterEngine();
const [activeChoice, setActiveChoice] = useState<number | null>(null);
const onClickRestore = () => {
if (onSave && activeChoice !== null) {
let restoreTypes = ruleDataUtils.getRestoreTypes(ruleData);
onSave(restoreTypes[activeChoice]);
}
};
const onClickReset = () => {
setActiveChoice(null);
};
const onSelection = (slotIdx: number) => {
setActiveChoice(slotIdx);
};
const renderRestoreLifeChoices = (): React.ReactNode => {
const restoreLifeChoices: Array<string> = ruleDataUtils
.getRestoreTypes(ruleData)
.map((type) => type.description)
.filter(TypeScriptUtils.isNotNullOrUndefined);
return (
<ExclusiveCheckbox
theme={characterTheme}
choices={restoreLifeChoices}
activeChoice={activeChoice}
onSelection={onSelection}
/>
);
};
const renderActions = (): React.ReactNode => {
if (activeChoice === null) {
return null;
}
return (
<div className={styles.actions}>
<Button onClick={onClickRestore} themed size="xx-small">
Restore Life
</Button>
<Button onClick={onClickReset} themed size="xx-small" variant="outline">
Reset
</Button>
</div>
);
};
return (
<div className={clsx([className])} {...props}>
<Heading className={styles.heading}>Restore Life</Heading>
{renderRestoreLifeChoices()}
{renderActions()}
</div>
);
};