Continued implementing action props in new engine

This commit is contained in:
ThaumRystra
2023-12-16 10:29:10 +02:00
parent 862c2ceb99
commit 1e90b345e2
2 changed files with 318 additions and 70 deletions

View File

@@ -1,7 +1,7 @@
import SimpleSchema from 'simpl-schema';
import { forEach, get, isEmpty, pick } from 'lodash';
import { forEach, get, isEmpty, pick, result } from 'lodash';
import LogContentSchema from '/imports/api/creature/log/LogContentSchema';
import { getCreature, getPropertyChildren, getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures';
import { getCreature, getPropertiesOfType, getPropertyChildren, getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures';
import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations';
import recalculateCalculation, { rollAndReduceCalculation } from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation';
import rollDice from '/imports/parser/rollDice';
@@ -10,6 +10,7 @@ import { getFromScope } from '/imports/api/creature/creatures/CreatureVariables'
import { getPropertyName } from '/imports/constants/PROPERTIES';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import numberToSignedString from '/imports/api/utility/numberToSignedString';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -262,10 +263,13 @@ export const runAction = new ValidatedMethod({
// This is run once as a simulation on the client awaiting all the various inputs or step through
// clicks from the user, then it is run as part of the runAction method, where it is expected to
// complete instantly on the client, and sent to the server as a method call
export async function applyAction(action: Action, userInput?: any, simulate?: boolean, stepThrough?: boolean) {
if (!Meteor.isClient && simulate) throw 'Cannot simulate on the server';
if (!Meteor.isClient && stepThrough) throw 'Cannot step through on the server';
if (Meteor.isClient && !simulate && stepThrough) throw 'Cannot step through on the client without simulating';
export async function applyAction(action: Action, userInput?: any[] | Function, options?: {
simulate?: boolean, stepThrough?: boolean
}) {
const { simulate, stepThrough } = options || {};
if (!simulate && stepThrough) throw 'Cannot step through unless simulating';
if (simulate && typeof userInput !== 'function') throw 'Must provide a function to get user input when simulating';
action._stepThrough = stepThrough;
action._isSimulation = simulate;
action.taskCount = 0;
@@ -455,8 +459,8 @@ async function applyTaskToEachTarget(action: Action, task: PropTask, targetIds:
}
// Combine all the action results into the scope at present
export function getEffectiveActionScope(action: Action) {
const scope = getVariables(action.creatureId);
export async function getEffectiveActionScope(action: Action) {
const scope = await getVariables(action.creatureId);
// Combine the applied results
for (const result of action.results) {
// Pop keys that are not longer used by a busy property
@@ -544,54 +548,33 @@ const applyPropertyByType = {
return;
}
// Iterate through all the resources consumed and damage them
if (prop.resources?.attributesConsumed?.length) {
for (const att of prop.resources.attributesConsumed) {
const scope = getEffectiveActionScope(action);
const statToDamage = getFromScope(att.variableName, scope);
await applyTask(action, {
prop,
targetIds: [action.creatureId],
subtaskFn: 'damageProp',
params: {
operation: 'increment',
value: +att.quantity?.value || 0,
targetProp: statToDamage,
},
}, userInput);
}
}
spendResources(action, prop, targetIds, result, userInput);
// Iterate through all the items consumed and consume them
if (prop.resources?.itemsConsumed?.length) {
for (const itemConsumed of prop.resources.itemsConsumed) {
recalculateCalculation(itemConsumed.quantity, action, 'reduce');
if (!itemConsumed.itemId) {
throw 'No ammo was selected';
const attack = prop.attackRoll || prop.attackRollBonus;
// Attack if there is an attack roll
if (attack && attack.calculation) {
if (targetIds.length) {
for (const target of targetIds) {
await applyAttackToTarget(action, prop, attack, targetIds, result, userInput);
await applyAfterTriggers(action, prop, [target], userInput);
await applyChildren(action, prop, [target], userInput);
}
const item = getSingleProperty(action.creatureId, itemConsumed.itemId);
if (!item || item.ancestors[0].id !== prop.ancestors[0].id) {
throw 'The prop\'s ammo was not found on the creature';
}
const quantity = +itemConsumed?.quantity?.value;
if (
!quantity ||
!isFinite(quantity)
) continue;
await applyTask(action, {
prop,
targetIds,
subtaskFn: 'consumeItemAsAmmo',
params: {
value: quantity,
item,
},
}, userInput);
} else {
await applyAttackWithoutTarget(action, prop, attack, result, userInput);
await applyAfterTriggers(action, prop, targetIds, userInput);
await applyChildren(action, prop, targetIds, userInput);
}
} else {
await applyAfterTriggers(action, prop, targetIds, userInput);
await applyChildren(action, prop, targetIds, userInput);
}
if (prop.actionType === 'event' && prop.variableName) {
resetProperties(action, prop, result, userInput);
}
// Finish
return await applyDefaultAfterPropTasks(action, prop, targetIds, userInput);
return await applyAfterChildrenTriggers(action, prop, targetIds, userInput);
},
async adjustment(task: PropTask, action: Action, result: TaskResult, userInput): Promise<void> {
@@ -680,7 +663,7 @@ const applyPropertyByType = {
return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput);
}
case 'hit': {
const scope = getEffectiveActionScope(action);
const scope = await getEffectiveActionScope(action);
if (scope['~attackHit']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
@@ -693,7 +676,7 @@ const applyPropertyByType = {
}
}
case 'miss': {
const scope = getEffectiveActionScope(action);
const scope = await getEffectiveActionScope(action);
if (scope['~attackMiss']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
@@ -706,7 +689,7 @@ const applyPropertyByType = {
}
}
case 'failedSave': {
const scope = getEffectiveActionScope(action);
const scope = await getEffectiveActionScope(action);
if (scope['~saveFailed']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
@@ -719,7 +702,7 @@ const applyPropertyByType = {
}
}
case 'successfulSave': {
const scope = getEffectiveActionScope(action);
const scope = await getEffectiveActionScope(action);
if (scope['~saveSucceeded']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
@@ -747,20 +730,17 @@ const applyPropertyByType = {
}
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
case 'choice': {
let index;
if (action._isSimulation) {
throw 'Not implemented';
userInput[prop._id] = {
choice: await getUserChoice();
};
}
if (!action._isSimulation && !userInput?.[prop._id]) {
throw 'User input was required for this step'
index = await userInput(prop);
} else {
// TODO
throw 'Reading stored user input not implemented'
}
const children = await getPropertyChildren(action.creatureId, prop);
if (!children.length) {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
let index = userInput[prop._id].choice;
if (!isFinite(index) || index < 0) index = 0;
if (index > children.length - 1) index = children.length - 1;
const child = children[index];
@@ -903,7 +883,7 @@ async function damageProp(task: DamagePropTask, action: Action, result: TaskResu
await applyTriggers(action, targetProp, [action.creatureId], 'damageProperty.before', userInput);
// Refetch the scope properties
const scope = getEffectiveActionScope(action);
const scope = await getEffectiveActionScope(action);
result.popScope = {
'~damage': 1, '~healing': 1, '~set': 1, '~attributeDamaged': 1,
};
@@ -1002,19 +982,20 @@ interface ItemAsAmmoTask extends BaseTask {
async function consumeItemAsAmmo(task: ItemAsAmmoTask, action: Action, result: TaskResult, userInput): Promise<void> {
const prop = task.prop;
let { value, item } = task.params;
const { item } = task.params
let { value } = task.params;
if (item.type !== 'item') throw 'Must use an item as ammo';
// Store the ammo item and value in the scope
result.scope[`#ammo`] = { propId: item._id };
result.scope['#ammo'] = { propId: item._id };
result.pushScope = { ['~ammoConsumed']: { value } };
// Apply the before triggers
await applyTriggers(action, item, [action.creatureId], 'ammo.before', userInput);
// Refetch the scope properties
const scope = getEffectiveActionScope(action);
const scope = await getEffectiveActionScope(action);
result.popScope = {
'~ammoConsumed': 1,
};
@@ -1042,3 +1023,268 @@ async function consumeItemAsAmmo(task: ItemAsAmmoTask, action: Action, result: T
await applyTriggers(action, item, [action.creatureId], 'ammo.after', userInput);
return applyDefaultAfterPropTasks(action, item, task.targetIds, userInput);
}
async function spendResources(action: Action, prop, targetIds: string[], result: TaskResult, userInput) {
// Use uses
if (prop.usesLeft) {
result.mutations.push({
targetIds,
updates: [{
propId: prop._id,
inc: { usesUsed: 1, usesLeft: -1 },
type: prop.type,
}],
contents: [{
name: 'Uses left',
value: `${prop.usesLeft - 1}`,
inline: true,
silenced: prop.silent,
}]
});
}
// Iterate through all the resources consumed and damage them
if (prop.resources?.attributesConsumed?.length) {
for (const att of prop.resources.attributesConsumed) {
const scope = await getEffectiveActionScope(action);
const statToDamage = getFromScope(att.variableName, scope);
await recalculateCalculation(att.quantity, action, 'reduce');
await applyTask(action, {
prop,
targetIds: [action.creatureId],
subtaskFn: 'damageProp',
params: {
operation: 'increment',
value: +att.quantity?.value || 0,
targetProp: statToDamage,
},
}, userInput);
}
}
// Iterate through all the items consumed and consume them
if (prop.resources?.itemsConsumed?.length) {
for (const itemConsumed of prop.resources.itemsConsumed) {
await recalculateCalculation(itemConsumed.quantity, action, 'reduce');
if (!itemConsumed.itemId) {
throw 'No ammo was selected';
}
const item = getSingleProperty(action.creatureId, itemConsumed.itemId);
if (!item || item.ancestors[0].id !== prop.ancestors[0].id) {
throw 'The prop\'s ammo was not found on the creature';
}
const quantity = +itemConsumed?.quantity?.value;
if (
!quantity ||
!isFinite(quantity)
) continue;
await applyTask(action, {
prop,
targetIds,
subtaskFn: 'consumeItemAsAmmo',
params: {
value: quantity,
item,
},
}, userInput);
}
}
}
async function applyAttackToTarget(action, prop, attack, target, taskResult: TaskResult, userInput) {
taskResult.pushScope = {
'~attackHit': {},
'~attackMiss': {},
'~criticalHit': {},
'~criticalMiss': {},
'~attackRoll': {},
}
await recalculateCalculation(attack, action, 'reduce');
const scope = await getEffectiveActionScope(action);
const contents: LogContent[] = [];
const {
resultPrefix,
result,
criticalHit,
criticalMiss,
} = await rollAttack(attack, scope, taskResult.pushScope);
if (target.variables.armor) {
const armor = target.variables.armor.value;
let name = criticalHit ? 'Critical Hit!' :
criticalMiss ? 'Critical Miss!' :
result > armor ? 'Hit!' : 'Miss!';
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
} else if (scope['~attackAdvantage']?.value === -1) {
name += ' (Disadvantage)';
}
contents.push({
name,
value: `${resultPrefix}\n**${result}**`,
inline: true,
silenced: prop.silent,
});
if (criticalMiss || result < armor) {
scope['~attackMiss'] = { value: true };
} else {
scope['~attackHit'] = { value: true };
}
} else {
contents.push({
name: 'Error',
value: 'Target has no `armor`',
inline: true,
silenced: prop.silent,
}, {
name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit',
value: `${resultPrefix}\n**${result}**`,
inline: true,
silenced: prop.silent,
});
}
if (contents.length) {
taskResult.mutations.push({
contents,
targetIds: [target],
});
}
}
async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskResult, userInput) {
taskResult.pushScope = {
'~attackHit': {},
'~attackMiss': {},
'~criticalHit': {},
'~criticalMiss': {},
'~attackRoll': {},
}
await recalculateCalculation(attack, action, 'reduce');
const scope = await getEffectiveActionScope(action);
const {
resultPrefix,
result,
criticalHit,
criticalMiss,
} = await rollAttack(attack, scope, taskResult.pushScope);
let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit';
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
} else if (scope['~attackAdvantage']?.value === -1) {
name += ' (Disadvantage)';
}
if (!criticalMiss) {
scope['~attackHit'] = { value: true }
}
if (!criticalHit) {
scope['~attackMiss'] = { value: true };
}
taskResult.mutations.push({
contents: [{
name,
value: `${resultPrefix}\n**${result}**`,
inline: true,
silenced: prop.silent,
}],
targetIds: [],
});
}
async function rollAttack(attack, scope, resultPushScope) {
const rollModifierText = numberToSignedString(attack.value, true);
let value, resultPrefix;
if (scope['~attackAdvantage']?.value === 1) {
const [a, b] = await rollDice(2, 20);
if (a >= b) {
value = a;
resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`;
} else {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else if (scope['~attackAdvantage']?.value === -1) {
const [a, b] = await rollDice(2, 20);
if (a <= b) {
value = a;
resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`;
} else {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else {
value = await rollDice(1, 20)[0];
resultPrefix = `1d20 [${value}] ${rollModifierText}`
}
resultPushScope['~attackDiceRoll'] = { value };
const result = value + attack.value;
resultPushScope['~attackRoll'] = { value: result };
const { criticalHit, criticalMiss } = applyCrits(value, scope, resultPushScope);
return { resultPrefix, result, value, criticalHit, criticalMiss };
}
function applyCrits(value, scope, resultPushScope) {
let scopeCrit = scope['~criticalHitTarget']?.value;
if (scopeCrit?.parseType === 'constant') {
scopeCrit = scopeCrit.value;
}
const criticalHitTarget = scopeCrit || 20;
const criticalHit = value >= criticalHitTarget;
let criticalMiss;
if (criticalHit) {
resultPushScope['~criticalHit'] = { value: true };
} else {
criticalMiss = value === 1;
if (criticalMiss) {
resultPushScope['~criticalMiss'] = { value: true };
}
}
return { criticalHit, criticalMiss };
}
async function resetProperties(action: Action, prop: any, result: TaskResult, userInput) {
const attributes = getPropertiesOfType(action.creatureId, 'attribute');
for (const att of attributes) {
if (att.removed || att.inactive) continue;
if (att.reset !== prop.variableName) continue;
if (!att.damage) continue;
applyTask(action, {
prop: att,
targetIds: [action.creatureId],
subtaskFn: 'damageProp',
params: {
title: getPropertyTitle(att),
operation: 'increment',
value: -att.damage ?? 0,
targetProp: att,
},
}, userInput)
}
const actions = [
...getPropertiesOfType(action.creatureId, 'action'),
...getPropertiesOfType(action.creatureId, 'spell'),
]
for (const act of actions) {
if (act.removed || act.inactive) continue;
if (act.reset !== prop.variableName) continue;
if (!act.usesUsed) continue;
result.mutations.push({
targetIds: [action.creatureId],
updates: [{
propId: act._id,
set: { usesUsed: 0 },
type: act.type,
}],
contents: [{
name: getPropertyTitle(act),
value: act.usesUsed >= 0 ? `Restored ${act.usesUsed} uses` : `Removed ${-act.usesUsed} uses`,
inline: true,
silenced: prop.silent,
}]
});
}
}

View File

@@ -69,8 +69,10 @@ describe('Interrupt action system', function () {
);
});
it('Halts execution of choice branches', async function () {
throw 'not implemented yet';
const action = await runActionById(choiceBranchId);
let userInputRequested = false;
const requestUserInput = () => { userInputRequested = true; return 0 };
const action = await runActionById(choiceBranchId, requestUserInput);
assert.isTrue(userInputRequested, 'User input should be requested when a choice branch is applied');
});
it('Applies adjustments', async function () {
let action = await runActionById(adjustmentSetId);
@@ -120,12 +122,12 @@ function createAction(prop, targetIds?) {
return Actions.insertAsync(action);
}
async function runActionById(propId) {
async function runActionById(propId, userInputFn = () => 0) {
const prop = await CreatureProperties.findOneAsync(propId);
const actionId = await createAction(prop);
const action = await Actions.findOneAsync(actionId);
if (!action) throw 'Action is expected to exist';
await applyAction(action);
await applyAction(action, userInputFn, { simulate: true });
return action;
}