import React, { useContext } from "react"; import { DispatchProp } from "react-redux"; import { ComponentConstants, DamageTypeIcon, } from "@dndbeyond/character-components/es"; import { AbilityLookup, BaseInventoryContract, Constants, DiceUtils, EntityUtils, FormatUtils, ItemUtils, LimitedUseUtils, ModifierUtils, Spell, SpellCasterInfo, SpellSlotContract, SpellUtils, characterActions, ApiRequestHelpers, RuleData, RuleDataUtils, CharacterTheme, InventoryManager, ItemManager, } from "@dndbeyond/character-rules-engine/es"; import { toastMessageActions } from "../../actions/toastMessage"; import { InventoryManagerContext } from "../../managers/InventoryManagerContext"; import { ThemeButton } from "../common/Button"; function canCastAtLevel(level, available) { return available.indexOf(level) > -1; } function isCastAvailableAtLevel(level, available) { return available.indexOf(level) > -1; } // TODO dispatch needs to be factored out interface Props extends DispatchProp { characterLevel: number; spell: Spell; spellSlots: Array; pactMagicSlots: Array; castableSpellLevels: Array; castablePactMagicLevels: Array; availableSpellLevels: Array; availablePactMagicLevels: Array; initialCastLevel: number | null; spellCasterInfo: SpellCasterInfo; ruleData: RuleData; abilityLookup: AbilityLookup; isReadonly: boolean; proficiencyBonus: number; theme?: CharacterTheme; inventoryManager: InventoryManager; } interface State { castLevel: number; castableLevelsIdx: number; castableLevels: Array; minLevel: number; maxLevel: number; } export class SpellCaster extends React.PureComponent { static defaultProps = { initialCastLevel: null, }; constructor(props: Props) { super(props); this.state = this.getStartingCastState(props); } componentDidUpdate( prevProps: Readonly, prevState: Readonly, snapshot?: any ): void { const { spell } = this.props; if ( SpellUtils.getUniqueKey(spell) !== SpellUtils.getUniqueKey(prevProps.spell) ) { this.setState({ ...this.getStartingCastState(this.props), }); } } getStartingCastState = (props: Props): State => { const { spell, initialCastLevel, spellCasterInfo, ruleData } = props; let { startLevel, endLevel } = SpellUtils.getCastLevelRange( spell, spellCasterInfo, ruleData ); const castableLevels = SpellUtils.getCastableLevels( spell, spellCasterInfo, ruleData, initialCastLevel ); const castLevel = SpellUtils.getMinCastLevel( spell, spellCasterInfo, ruleData, initialCastLevel ); const castableLevelsIdx = castableLevels.findIndex( (level) => level === castLevel ); return { castLevel, castableLevelsIdx, castableLevels, minLevel: startLevel, maxLevel: endLevel, }; }; getSpellLevelUses = ( spellSlots: Array, level: number ): number => { const spellSlot = spellSlots.find((slot) => slot.level === level); if (spellSlot) { return spellSlot.used; } return 0; }; handleIncreaseCastLevel = (): void => { this.setState((prevState, props) => ({ castableLevelsIdx: prevState.castableLevelsIdx + 1, castLevel: prevState.castableLevels[prevState.castableLevelsIdx + 1], })); }; handleDecreaseCastLevel = (): void => { this.setState((prevState, props) => ({ castableLevelsIdx: prevState.castableLevelsIdx - 1, castLevel: prevState.castableLevels[prevState.castableLevelsIdx - 1], })); }; handleCastSpellSlot = (): void => { this.handleSpellUse(true, false); }; handleCastPactMagicSlot = (): void => { this.handleSpellUse(false, true); }; // TODO dispatch needs to be factored out handleSpellUse = (useSpellSlot: boolean, usePactMagicSlot: boolean): void => { const { castLevel, minLevel } = this.state; const { dispatch, spell, spellSlots, pactMagicSlots, isReadonly, inventoryManager, } = this.props; if (isReadonly) { return; } const dataOrigin = SpellUtils.getDataOrigin(spell); const dataOriginType = SpellUtils.getDataOriginType(spell); let limitedUse = SpellUtils.getLimitedUse(spell); let mappingId: number | null = null; let mappingTypeId: number | null = null; if (limitedUse) { let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse); let uses = SpellUtils.getConsumedLimitedUse(spell, castLevel - minLevel); switch (dataOriginType) { case Constants.DataOriginTypeEnum.ITEM: //TODO itemManager: make a way to get the item with just he mapping id and lookups? mappingId = ItemUtils.getMappingId( dataOrigin.primary as BaseInventoryContract ); mappingTypeId = ItemUtils.getMappingEntityTypeId( dataOrigin.primary as BaseInventoryContract ); const itemData = dataOrigin.primary as BaseInventoryContract; const item = ItemManager.getItem(ItemUtils.getMappingId(itemData)); item.handleItemLimitedUseSet(numberUsed + uses); dispatch( toastMessageActions.toastSuccess( "Item Spell Cast", `Cast ${SpellUtils.getName( spell )} from ${EntityUtils.getDataOriginName(dataOrigin)}` ) ); break; default: mappingId = SpellUtils.getMappingId(spell); mappingTypeId = SpellUtils.getMappingEntityTypeId(spell); if (mappingId !== null && mappingTypeId !== null) { dispatch( characterActions.spellUseSet( mappingId, mappingTypeId, numberUsed + uses, dataOriginType ) ); dispatch( toastMessageActions.toastSuccess( "Limited Use Spell Cast", `Cast ${SpellUtils.getName(spell)} with limited use` ) ); } } } if (useSpellSlot) { let castLevelKey = ApiRequestHelpers.getSpellLevelSpellSlotRequestsDataKey(castLevel); if (castLevelKey !== null) { dispatch( characterActions.spellLevelSpellSlotsSet({ [castLevelKey]: this.getSpellLevelUses(spellSlots, castLevel) + 1, }) ); dispatch( toastMessageActions.toastSuccess( "Spell Cast", `Cast ${SpellUtils.getName(spell)} in spell slot level ${castLevel}` ) ); } } if (usePactMagicSlot) { let castLevelKey = ApiRequestHelpers.getSpellLevelPactMagicRequestsDataKey(castLevel); if (castLevelKey !== null) { dispatch( characterActions.spellLevelPactMagicSlotsSet({ [castLevelKey]: this.getSpellLevelUses(pactMagicSlots, castLevel) + 1, }) ); dispatch( toastMessageActions.toastSuccess( "Spell Cast", `Cast ${SpellUtils.getName(spell)} in a pact magic slot` ) ); } } }; handleCast = (): void => { const { castLevel } = this.state; const { spell, spellCasterInfo } = this.props; let usesSpellSlot = SpellUtils.getUsesSpellSlot(spell); let limitedUse = SpellUtils.getLimitedUse(spell); const isSpellSlotAvailable = canCastAtLevel( castLevel, spellCasterInfo.castableSpellLevels ); const isPactSlotAvailable = canCastAtLevel( castLevel, spellCasterInfo.castablePactMagicLevels ); if (usesSpellSlot) { if (limitedUse) { if (SpellUtils.isValidToUsePactSlots(spell)) { if (isPactSlotAvailable) { this.handleSpellUse(false, true); } } else { if (isSpellSlotAvailable) { this.handleSpellUse(true, false); } } } else { if (isSpellSlotAvailable) { this.handleSpellUse(true, false); } else if (isPactSlotAvailable) { this.handleSpellUse(false, true); } } } else { this.handleSpellUse(false, false); } }; renderLimitedUseDetails = (): React.ReactNode => { const { spell, ruleData, abilityLookup, proficiencyBonus } = this.props; const limitedUse = SpellUtils.getLimitedUse(spell); if (!limitedUse) { return null; } const maxUses = LimitedUseUtils.deriveMaxUses( limitedUse, abilityLookup, ruleData, proficiencyBonus ); const resetType = LimitedUseUtils.getResetType(limitedUse); if (resetType === null) { return null; } let limitedUseType: string = "Use"; if ( SpellUtils.getDataOriginType(spell) === Constants.DataOriginTypeEnum.ITEM ) { limitedUseType = "Charge"; } let resetDisplay: string = ""; switch (resetType) { case Constants.LimitedUseResetTypeEnum.SHORT_REST: case Constants.LimitedUseResetTypeEnum.LONG_REST: resetDisplay = `${ maxUses === 1 ? "Once" : maxUses } per ${RuleDataUtils.getLimitedUseResetTypeName(resetType, ruleData)}`; break; default: resetDisplay = `${maxUses} ${limitedUseType}${ maxUses !== 1 ? "s" : "" }, Reset: ${RuleDataUtils.getLimitedUseResetTypeName( resetType, ruleData )}`; break; } return
{resetDisplay}
; }; renderCasting = (): React.ReactNode => { const { castLevel, minLevel, maxLevel, castableLevelsIdx, castableLevels } = this.state; const { spell, castableSpellLevels, castablePactMagicLevels, spellSlots, pactMagicSlots, availableSpellLevels, availablePactMagicLevels, ruleData, abilityLookup, spellCasterInfo, isReadonly, proficiencyBonus, } = this.props; const limitedUse = SpellUtils.getLimitedUse(spell); const isAtWill = SpellUtils.validateIsAtWill(spell, castLevel); const castAsRitual = SpellUtils.isCastAsRitual(spell); let castLevelNode: React.ReactNode; let castActionsNode: React.ReactNode; if (castAsRitual) { castActionsNode = "As Ritual"; } else if (isAtWill) { castActionsNode = "At Will"; } else { if (limitedUse) { const maxUses = LimitedUseUtils.deriveMaxUses( limitedUse, abilityLookup, ruleData, proficiencyBonus ); const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse); const consumedAmount = SpellUtils.getConsumedLimitedUse( spell, castLevel - minLevel ); const numberRemaining: number = maxUses - numberUsed; let limitedUseButtonText: React.ReactNode; let showRemainingCount: boolean = true; const dataOriginType = SpellUtils.getDataOriginType(spell); if (maxUses === 1) { if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) { limitedUseButtonText = "1 Charge"; } else { limitedUseButtonText = "Use"; showRemainingCount = false; } } else { limitedUseButtonText = `${consumedAmount} ${ dataOriginType === Constants.DataOriginTypeEnum.ITEM ? "Charge" : "Use" }${consumedAmount !== 1 ? "s" : ""}`; } const isLimitedUseAvailable = SpellUtils.validateIsLimitedUseAvailableAtScaledAmount( spell, castLevel, castLevel - minLevel, abilityLookup, ruleData, spellCasterInfo, proficiencyBonus ); const usesSpellSlots = SpellUtils.getUsesSpellSlot(spell); let spellSlotsAvailable: number | null = null; let spellSlotName: string = ""; if (usesSpellSlots) { if (SpellUtils.isValidToUsePactSlots(spell)) { spellSlotsAvailable = SpellUtils.getCastLevelAvailableCount( castLevel, pactMagicSlots ); spellSlotName = "Pact Slot"; } else { spellSlotsAvailable = SpellUtils.getCastLevelAvailableCount( castLevel, spellSlots ); spellSlotName = "Spell Slot"; } } castActionsNode = (
{usesSpellSlots ? `${spellSlotName}, ` : ""} {limitedUseButtonText} {usesSpellSlots && ( {spellSlotsAvailable} )} {showRemainingCount && ( {numberRemaining} )}
); } else { const canSpellCastAtLevel = canCastAtLevel( castLevel, castableSpellLevels ); const canPactMagicCastAtLevel = canCastAtLevel( castLevel, castablePactMagicLevels ); const isSpellCastAvailableAtLevel = isCastAvailableAtLevel( castLevel, availableSpellLevels ); const isPactMagicCastAvailableAtLevel = isCastAvailableAtLevel( castLevel, availablePactMagicLevels ); const spellCastLevelAvailableCount = SpellUtils.getCastLevelAvailableCount(castLevel, spellSlots); const pactMagicCastLevelAvailableCount = SpellUtils.getCastLevelAvailableCount(castLevel, pactMagicSlots); castActionsNode = ( {isSpellCastAvailableAtLevel && (
Spell Slot {spellCastLevelAvailableCount}
)} {isPactMagicCastAvailableAtLevel && (
Pact Slot {pactMagicCastLevelAvailableCount}
)}
); } } if (!SpellUtils.isCantrip(spell)) { castLevelNode = (
Level {minLevel !== maxLevel && ( )} {FormatUtils.renderSpellLevelAbbreviation(castLevel)} {minLevel !== maxLevel && ( )}
); } //` return (
Cast
{castActionsNode}
{castLevelNode}
); }; renderAtHigherLevels = (): React.ReactNode => { const { castLevel } = this.state; const { spell, characterLevel } = this.props; const atHigherLevels = SpellUtils.getAtHigherLevels(spell); if (!atHigherLevels) { return null; } const scaleType = SpellUtils.getScaleType(spell); const { additionalAttacks, additionalTargets, areaOfEffect, duration, creatures, special, range, } = atHigherLevels; const level = SpellUtils.getLevel(spell); let additionalAttacksDisplay: React.ReactNode; if (additionalAttacks !== null) { const additionalAttackInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, additionalAttacks, characterLevel, castLevel ); if (additionalAttackInfo) { const additionalAttackCount = level === 0 ? additionalAttackInfo.totalCount + 1 : additionalAttackInfo.totalCount; const additionalAttackLabel = level === 0 ? "Total" : "Additional"; additionalAttacksDisplay = (
{additionalAttackLabel} {additionalAttackInfo.description}:{" "} {additionalAttackCount}
); } } let additionalTargetsDisplay: React.ReactNode; if (additionalTargets !== null) { const additionalTargetInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, additionalTargets, characterLevel, castLevel ); if (additionalTargetInfo) { additionalTargetsDisplay = (
{additionalTargetInfo.targets} Additional Target {additionalTargetInfo.targets === 1 ? "" : "s"}
{additionalTargetInfo.description}
); } } let aoeDisplay: React.ReactNode; if (areaOfEffect !== null) { const aoeInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, areaOfEffect, characterLevel, castLevel ); if (aoeInfo && aoeInfo.extendedAoe !== null) { aoeDisplay = (
Extended Area: {FormatUtils.renderDistance(aoeInfo.extendedAoe)}{" "} {aoeInfo.description}
); } } let rangeDisplay: React.ReactNode; if (range !== null) { const rangeInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, range, characterLevel, castLevel ); if (rangeInfo && rangeInfo.range) { rangeDisplay = (
Extended Range: {FormatUtils.renderDistance(rangeInfo.range)}{" "} {rangeInfo.description}
); } } let durationDisplay: React.ReactNode; if (duration !== null) { const durationInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, duration, characterLevel, castLevel ); if (durationInfo) { durationDisplay = (
Extended Duration: {durationInfo.description}
); } } let creaturesDisplay: React.ReactNode; if (creatures !== null) { const creatureInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, creatures, characterLevel, castLevel ); if (creatureInfo) { creaturesDisplay = (
Creatures: {creatureInfo.description}
); } } let specialDisplay: React.ReactNode; if (special !== null) { const specialInfo = SpellUtils.getSpellScaledAtHigher( spell, scaleType, special, characterLevel, castLevel ); if (specialInfo) { specialDisplay = (
Special: {specialInfo.description}
); } } if ( !additionalAttacksDisplay && !additionalTargetsDisplay && !aoeDisplay && !durationDisplay && !creaturesDisplay && !specialDisplay && !rangeDisplay ) { return null; } return (
{additionalAttacksDisplay} {additionalTargetsDisplay} {rangeDisplay} {aoeDisplay} {durationDisplay} {creaturesDisplay} {specialDisplay}
); }; render() { const { castLevel } = this.state; const { characterLevel, spell, theme } = this.props; const modifiers = SpellUtils.getModifiers(spell); const damageModifiers = modifiers.filter((modifier) => ModifierUtils.isSpellDamageModifier(modifier) ); let damageNode: React.ReactNode; if (damageModifiers.length) { damageNode = (
{damageModifiers.map((modifier) => { const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier); const id = ModifierUtils.getId(modifier); const restriction = ModifierUtils.getRestriction(modifier); const friendlySubtypeName = ModifierUtils.getFriendlySubtypeName(modifier); const atHigherDamage = SpellUtils.getSpellScaledAtHigher( spell, atHigherLevels ? SpellUtils.getScaleType(spell) : null, atHigherLevels && atHigherLevels.points ? atHigherLevels.points : [], characterLevel, castLevel ); const scaledDamageDie = SpellUtils.getSpellFinalScaledDie( spell, modifier, atHigherDamage ); return (
{scaledDamageDie && ( {DiceUtils.renderDice(scaledDamageDie)} )} {friendlySubtypeName !== null && ( {" "} Damage )} {!!restriction && ( ({restriction}) )}
); })}
); } const hitPointHealingModifiers = modifiers.filter((modifier) => ModifierUtils.isSpellHealingHitPointsModifier(modifier) ); const tempHitPointHealingModifiers = modifiers.filter((modifier) => ModifierUtils.isSpellHealingTempHitPointsModifier(modifier) ); let healingNode: React.ReactNode; if ( hitPointHealingModifiers.length || tempHitPointHealingModifiers.length ) { healingNode = (
{hitPointHealingModifiers.map((modifier) => { const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier); const id = ModifierUtils.getId(modifier); const restriction = ModifierUtils.getRestriction(modifier); const atHigherHealing = SpellUtils.getSpellScaledAtHigher( spell, atHigherLevels ? SpellUtils.getScaleType(spell) : null, atHigherLevels && atHigherLevels.points ? atHigherLevels.points : [], characterLevel, castLevel ); const scaledHealingDie = SpellUtils.getSpellFinalScaledDie( spell, modifier, atHigherHealing, castLevel ); const isHealingDieAdditional = SpellUtils.hack__isHealingDieAdditionalBonusFixedValue( damageModifiers, spell, scaledHealingDie ); return (
Regain {isHealingDieAdditional && "Additional"}{" "} {scaledHealingDie && DiceUtils.renderDice(scaledHealingDie)}{" "} Hit Points {!!restriction && ( ({restriction}) )}
); })} {tempHitPointHealingModifiers.map((modifier) => { const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier); const id = ModifierUtils.getId(modifier); const restriction = ModifierUtils.getRestriction(modifier); const atHigherHealing = SpellUtils.getSpellScaledAtHigher( spell, atHigherLevels ? SpellUtils.getScaleType(spell) : null, atHigherLevels && atHigherLevels.points ? atHigherLevels.points : [], characterLevel, castLevel ); const scaledHealingDie = SpellUtils.getSpellFinalScaledDie( spell, modifier, atHigherHealing, castLevel ); return (
Regain{" "} {scaledHealingDie && DiceUtils.renderDice(scaledHealingDie)}{" "} Temp Hit Points {!!restriction && ( ({restriction}) )}
); })}
); } const castingNode = this.renderCasting(); const limitedUseNode = this.renderLimitedUseDetails(); const atHigherLevelsNode = this.renderAtHigherLevels(); if ( !castingNode && !limitedUseNode && !damageNode && !healingNode && !atHigherLevelsNode ) { return null; } return (
{castingNode} {limitedUseNode} {damageNode} {healingNode} {atHigherLevelsNode}
); } } const SpellCasterContainer = (props) => { const { inventoryManager } = useContext(InventoryManagerContext); return ; }; export default SpellCasterContainer;