Files
DiceCloud/app/imports/api/engine/action/applyProperties/applyDamageProperty.ts
2024-03-30 21:12:35 +02:00

323 lines
11 KiB
TypeScript

import { some, includes, difference, intersection } from 'lodash';
import { getConstantValueFromScope } from '/imports/api/creature/creatures/CreatureVariables';
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult from '/imports/api/engine/action/tasks/TaskResult';
import { isFiniteNode } from '/imports/parser/parseTree/constant';
import resolve from '/imports/parser/resolve';
import toString from '/imports/parser/toString';
import { getPropertiesOfType } from '/imports/api/engine/loadCreatures';
import applyTask from '/imports/api/engine/action/tasks/applyTask';
import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider';
import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags';
import Context from '/imports/parser/types/Context';
import applySavingThrowProperty from '/imports/api/engine/action/applyProperties/applySavingThrowProperty';
export default async function applyDamageProperty(
task: PropTask, action: EngineAction, result: TaskResult, inputProvider: InputProvider
) {
const prop = task.prop;
const scope = getEffectiveActionScope(action);
// Skip if there is no parse node to work with
if (!prop.amount?.parseNode) return;
// Choose target
const damageTargets = prop.target === 'self' ? [action.creatureId] : task.targetIds;
// Determine if the hit is critical
const criticalHit = await getConstantValueFromScope('~criticalHit', scope)
&& prop.damageType !== 'healing'; // Can't critically heal
// Double the damage rolls if the hit is critical
const context = new Context({
options: { doubleRolls: criticalHit },
});
// Gather all the lines we need to log into an array
const logValue: string[] = [];
const logName = prop.damageType === 'healing' ? 'Healing' : 'Damage';
// roll the dice only and store that string
recalculateCalculation(prop.amount, action, 'compile', inputProvider);
const { result: rolled } = await resolve('roll', prop.amount.valueNode, scope, context);
if (rolled.parseType !== 'constant') {
logValue.push(toString(rolled));
}
result.appendParserContextErrors(context, damageTargets);
// Reset the errors so we don't log the same errors twice
context.errors = [];
// Resolve the roll to a final value
const { result: reduced } = await resolve('reduce', rolled, scope, context);
result.appendParserContextErrors(context, damageTargets);
// Store the result
let damage: number | undefined = undefined;
if (reduced.parseType === 'constant') {
prop.amount.value = reduced.value;
if (typeof reduced.value === 'number') {
damage = reduced.value;
}
} else if (reduced.parseType === 'error') {
prop.amount.value = null;
} else {
prop.amount.value = toString(reduced);
}
// If we didn't end up with damage of finite amount, give up
if (
typeof damage !== 'number'
|| !isFinite(damage)
) {
return applyDefaultAfterPropTasks(action, prop, damageTargets, inputProvider);
}
// Round the damage to a whole number
damage = Math.floor(damage);
scope['~damage'] = { value: damage };
// Convert extra damage into the stored type
const lastDamageType = await getConstantValueFromScope('~lastDamageType', scope);
if (prop.damageType === 'extra' && typeof lastDamageType === 'string') {
prop.damageType = lastDamageType;
}
// Store current damage type
if (prop.damageType !== 'healing') {
scope['~lastDamageType'] = { value: prop.damageType };
}
// Memoise the damage suffix for the log
const suffix = (criticalHit ? ' critical ' : ' ') +
prop.damageType +
(prop.damageType !== 'healing' ? ' damage ' : '');
// If there is a save, calculate the save damage
let damageOnSave, saveProp, saveRoll;
if (prop.save) {
if (prop.save.damageFunction?.calculation) {
recalculateCalculation(prop.save.damageFunction, action, 'compile', inputProvider);
context.errors = [];
const { result: saveDamageRolled } = await resolve(
'roll', prop.save.damageFunction.valueNode, scope, context
);
saveRoll = toString(saveDamageRolled);
const { result: saveDamageResult } = await resolve(
'reduce', saveDamageRolled, scope, context
);
result.appendParserContextErrors(context, damageTargets);
// If we didn't end up with a constant of finite amount, give up
if (
!isFiniteNode(saveDamageResult)
) {
return applyDefaultAfterPropTasks(action, prop, damageTargets, inputProvider);
}
// Round the damage to a whole number
damageOnSave = Math.floor(saveDamageResult.value);
} else {
damageOnSave = Math.floor(damage / 2);
}
saveProp = {
node: {
...prop.save,
name: prop.save.stat,
silent: prop.silent,
},
children: [],
}
}
if (damageTargets && damageTargets.length) {
// Iterate through all the targets
for (const target of damageTargets) {
let damageToApply = damage || 0;
// If there is a saving throw, apply that first
if (prop.save) {
await applySavingThrowProperty({
prop: saveProp,
targetIds: task.targetIds,
}, action, result, inputProvider);
if (await getConstantValueFromScope('~saveSucceeded', scope)) {
// Log the total damage
logValue.push(toString(reduced));
// Log the save damage
const damageText = damageFunctionText(prop.save);
if (damageText) {
logValue.push(damageText);
} else {
logValue.push(
'**Damage on successful save**',
prop.save.damageFunction.calculation,
saveRoll
);
}
damageToApply = damageOnSave;
}
}
// Apply weaknesses/resistances/immunities
damageToApply = applyDamageMultipliers({
target,
damage: damageToApply,
damageProp: prop,
logValue
});
// Deal the damage to the target
await dealDamage(
action, prop, result, inputProvider, target, prop.damageType, damageToApply
);
}
} else {
// There are no targets, just log the result
logValue.push(`**${damage}** ${suffix}`);
if (prop.save) {
await applySavingThrowProperty(saveProp, action, result, inputProvider);
await applySavingThrowProperty({
prop: saveProp,
targetIds: task.targetIds,
}, action, result, inputProvider);
logValue.push(`**${damageOnSave}** ${suffix} on a successful save`);
}
}
if (logValue.length) result.appendLog({
name: logName,
value: logValue.join('\n'),
inline: true,
}, damageTargets);
return applyDefaultAfterPropTasks(action, prop, damageTargets, inputProvider);
}
function damageFunctionText(save) {
if (!save) return;
if (!save.damageFunction) {
return '**Half damage on successful save**';
}
if (save.damageFunction.calculation == '0' || save.damageFunction.value === 0) {
return '**No damage on successful save**'
}
}
function applyDamageMultipliers({ target, damage, damageProp, logValue }) {
const damageType = damageProp?.damageType;
if (!damageType) return damage;
const multiplier = target?.variables?.[damageType];
if (!multiplier) return damage;
const damageTypeText = damageType == 'healing' ? 'healing' : `${damageType} damage`;
if (
multiplier.immunity &&
some(multiplier.immunities, multiplierAppliesTo(damageProp, 'immunity'))
) {
logValue.push(`Immune to ${damageTypeText}`);
return 0;
} else {
if (
multiplier.resistance &&
some(multiplier.resistances, multiplierAppliesTo(damageProp, 'resistance'))
) {
logValue.push(`Resistant to ${damageTypeText}`);
damage = Math.floor(damage / 2);
}
if (
multiplier.vulnerability &&
some(multiplier.vulnerabilities, multiplierAppliesTo(damageProp, 'vulnerability'))
) {
logValue.push(`Vulnerable to ${damageTypeText}`);
damage = Math.floor(damage * 2);
}
}
return damage;
}
function multiplierAppliesTo(damageProp, multiplierType) {
return multiplier => {
// Apply the default 'ignore x' tags
const effectiveTags = getEffectivePropTags(damageProp);
if (includes(effectiveTags, `ignore ${multiplierType}`)) return false;
const hasRequiredTags = difference(
multiplier.includeTags, effectiveTags
).length === 0;
const hasNoExcludedTags = intersection(
multiplier.excludeTags, effectiveTags
).length === 0;
return hasRequiredTags && hasNoExcludedTags;
}
}
async function dealDamage(
action: EngineAction, prop: any, result: TaskResult, userInput: InputProvider,
targetId: string, damageType: string, amount: number
) {
// Get all the health bars and do damage to them
let healthBars = getPropertiesOfType(targetId, 'attribute');
// Keep only the healthbars that can take damage/healing
healthBars = healthBars.filter((bar) => {
if (bar.attributeType !== 'healthBar' || bar.inactive || bar.removed || bar.overridden) {
return false;
}
if (damageType === 'healing' && bar.healthBarNoHealing) {
return false;
}
if (damageType !== 'healing' && amount >= 0 && bar.healthBarNoDamage) {
return false;
}
return true;
});
// Sort healthbars by damage/healing order or tree order as a fallback
healthBars.sort((a, b) => {
let diff;
if (amount >= 0) {
diff = a.healthBarDamageOrder - b.healthBarDamageOrder;
} else {
diff = a.healthBarHealingOrder - b.healthBarHealingOrder;
}
if (Number.isFinite(diff)) {
return diff;
} else {
return a.order - b.order;
}
});
// Deal the damage to each healthbar in order until all damage is done
const totalDamage = amount;
let damageLeft = totalDamage;
if (damageType === 'healing') damageLeft = -totalDamage;
for (const healthBar of healthBars) {
if (damageLeft === 0) return;
// Do the damage
const damageAdded = await applyTask(action, {
prop,
targetIds: [targetId],
subtaskFn: 'damageProp',
params: {
operation: 'increment',
value: +damageLeft || 0,
targetProp: healthBar,
},
}, userInput);
damageLeft -= damageAdded;
// Prevent overflow
if (
damageType === 'healing' ?
healthBar.healthBarNoHealingOverflow :
healthBar.healthBarNoDamageOverflow
) {
damageLeft = 0;
}
}
return totalDamage;
}