Start of action system re-write

This commit is contained in:
Stefan Zermatten
2021-10-09 12:36:06 +02:00
parent 0097696cc8
commit 0cdec4a429
41 changed files with 783 additions and 119 deletions

View File

@@ -0,0 +1,15 @@
const applyPropertyByType = {
action,
branch,
buff,
damage,
roll,
savingThrow,
spell,
toggle,
};
export default function applyProperty(node, ...args){
return applyPropertyByType[node.node.type]?.(node, ...args);
}

View File

@@ -0,0 +1,200 @@
import recalculateInlineCalculations from './shared/recalculateInlineCalculations.js';
import recalculateCalculation from './shared/recalculateCalculation.js';
import rollDice from '/imports/parser/rollDice.js';
import applyProperty from '../applyProperty.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/methods/adjustQuantity.js';
import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
export default function applyAction(node, {creature, targets, scope, log}){
const prop = node.node;
if (prop.target === 'self') targets = [creature];
const failed = spendResources({prop, log, scope});
if (failed) return;
let content = { name: prop.name };
if (prop.summary?.text){
recalculateInlineCalculations(prop.summary, scope, log);
content.value = prop.summary.value;
}
log.content.push(content);
if (prop.attackRoll && prop.attackRoll.calculation){
if (targets.length){
targets.forEach(target => {
applyAttackToTarget({prop, target, scope, log});
// Apply the children, but only to the current target
applyChildren(node, {targets: [target], scope, log});
});
} else {
applyAttackWithoutTarget({prop, scope, log});
applyChildren(node, {creature, targets, scope, log});
}
}
}
function applyAttackWithoutTarget({prop, scope, log}){
delete scope['$attackHit'];
delete scope['$attackMiss'];
recalculateCalculation(prop.rollBonus, scope, log);
let value = rollDice(1, 20)[0];
scope['$attackRoll'] = {value};
let criticalHitTarget = scope.criticalHitTarget?.value || 20;
let criticalHit = value >= criticalHitTarget;
if (criticalHit) scope['$criticalHit'] = {value: true};
let result = value + prop.rollBonus.value;
scope['$toHit'] = {value: result};
log.content.push({
name: criticalHit ? 'Critical Hit!' : 'To Hit',
value: `1d20 {${value}} + ${prop.rollBonus.value} = ` + result,
});
}
function applyAttackToTarget({prop, target, scope, log}){
delete scope['$attackHit'];
delete scope['$attackMiss'];
recalculateCalculation(prop.rollBonus, scope, log);
const value = rollDice(1, 20)[0];
scope['$attackRoll'] = {value};
const criticalHitTarget = scope.criticalHitTarget?.value || 20;
const criticalHit = value >= criticalHitTarget;
const criticalMiss = value === 1;
if (criticalHit) scope['$criticalHit'] = {value: true};
if (criticalMiss) scope['$criticalMiss'] = {value: true};
const result = value + prop.rollBonus.value;
scope['$toHit'] = {value: result};
if (target.variables.armor){
const armor = target.variables.armor.value;
const name = criticalHit ? 'Critical Hit!' :
criticalMiss ? 'Critical miss!' :
result > armor ? 'Hit!' :
'Miss!'
log.content.push({
name,
value: `1d20 {${value}} + ${prop.rollBonus.value} = ` + result,
});
if ((result > armor) || (criticalHit)){
scope['$attackHit'] = true;
} else {
scope['$attackMiss'] = true;
}
} else {
log.content.push({
name: 'Error',
value:'Target has no `armor`',
});
log.content.push({
name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical miss!' : 'To Hit',
value: `1d20 {${value}} + ${prop.rollBonus.value} = ` + result,
});
}
}
function applyChildren(node, args){
node.children.forEach(child => applyProperty(child, args));
}
function spendResources({prop, log, scope}){
// Check Uses
if (prop.usesUsed >= prop.uses?.value){
log.content.push({
name: 'Error',
value: `${prop.name || 'action'} does not have enough uses left`,
});
return true;
}
// Resources
if (prop.insufficientResources){
log.content.push({
name: 'Error',
value: 'This creature doesn\'t have sufficient resources to perform this action',
});
return true;
}
// Items
let itemQuantityAdjustments = [];
let spendLog = [];
let gainLog = [];
try {
prop.resources.itemsConsumed.forEach(itemConsumed => {
if (!itemConsumed.itemId){
throw 'No ammo was selected for this prop';
}
let item = CreatureProperties.findOne(itemConsumed.itemId);
if (!item || item.ancestors[0].id !== prop.ancestors[0].id){
throw 'The prop\'s ammo was not found on the creature';
}
if (!item.equipped){
throw 'The selected ammo is not equipped';
}
if (!itemConsumed.quantity) return;
itemQuantityAdjustments.push({
property: item,
operation: 'increment',
value: itemConsumed.quantity,
});
let logName = item.name;
if (itemConsumed.quantity > 1 || itemConsumed.quantity < -1){
logName = item.plural || logName;
}
if (itemConsumed.quantity > 0){
spendLog.push(logName + ': ' + itemConsumed.quantity);
} else if (itemConsumed.quantity < 0){
gainLog.push(logName + ': ' + -itemConsumed.quantity);
}
});
} catch (e){
log.content.push({
name: 'Error',
value: e,
});
return true;
}
// No more errors should be thrown after this line
// Now that we have confirmed that there are no errors, do actual work
//Items
itemQuantityAdjustments.forEach(adjustQuantityWork);
// Use uses
if (prop.usesResult){
CreatureProperties.update(prop._id, {
$inc: {usesUsed: 1}
}, {
selector: prop
});
log.content.push({
name: 'Uses left',
value: prop.usesResult - (prop.usesUsed || 0) - 1,
});
}
// Damage stats
prop.resources.attributesConsumed.forEach(attConsumed => {
if (!attConsumed.quantity) return;
let stat = scope[attConsumed.variableName];
if (!stat) return;
damagePropertyWork({
property: stat,
operation: 'increment',
value: attConsumed.quantity,
});
if (attConsumed.quantity > 0){
spendLog.push(stat.name + ': ' + attConsumed.quantity);
} else if (attConsumed.quantity < 0){
gainLog.push(stat.name + ': ' + -attConsumed.quantity);
}
});
// Log all the spending
if (gainLog.length) log.content.push({
name: 'Gained',
value: gainLog.join('\n'),
});
if (spendLog.length) log.content.push({
name: 'Spent',
value: spendLog.join('\n'),
});
}

View File

@@ -0,0 +1,61 @@
import applyProperty from '../applyProperty.js';
import recalculateCalculation from './shared/recalculateCalculation.js';
import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
export default function applyAdjustment(node, {
creature, targets, scope, log
}){
const prop = node.node;
const damageTargets = prop.target === 'self' ? [creature] : targets;
if (!prop.amount) {
return applyChildren(node, {creature, targets, scope, log});
}
// Evaluate the amount
recalculateCalculation(prop.amount, scope, log);
prop.amount.errors?.forEach(error => {
if (error.type !== 'info'){
log.content.push({name: 'Error', value: error.message});
}
});
const value = prop.amount.value;
if (!isFinite(value)) {
return applyChildren(node, {creature, targets, scope, log});
}
if (damageTargets?.length) {
damageTargets.forEach(target => {
let stat = target.variables[prop.stat];
if (!stat) {
log({
name: 'Error',
value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set`
});
return;
}
damagePropertyWork({
property: stat,
operation: prop.operation,
value,
});
log.content.push({
name: 'Attribute damage',
value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
` ${value}`,
});
});
} else {
log.content.push({
name: 'Attribute damage',
value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
` ${value}`,
});
}
return applyChildren(node, {creature, targets, scope, log});
}
function applyChildren(node, args){
node.children.forEach(child => applyProperty(child, args));
}

View File

@@ -0,0 +1,42 @@
import applyProperty from '../applyProperty.js';
import recalculateCalculation from './shared/recalculateCalculation.js';
export default function applyBranch(node, {
creature, targets, scope, log
}){
const applyChildren = function(){
node.children.forEach(child => applyProperty(child, {
creature, targets, scope, log
}));
};
const prop = node.node;
switch(prop.branchType){
case 'if':
recalculateCalculation(prop.condition, scope, log);
if (prop.condition?.value) applyChildren();
break;
case 'hit':
if (scope['$attackHit']?.value) applyChildren();
break;
case 'miss':
if (scope['$attackMiss']?.value) applyChildren();
break;
case 'failedSave':
if (scope['$saveFailed']?.value) applyChildren();
break;
case 'successfulSave':
if (scope['$saveSucceeded']?.value) applyChildren();
break;
case 'eachTarget':
if (targets.length){
targets.forEach(target => {
node.children.forEach(child => applyProperty(child, {
creature, targets: [target], scope, log
}));
});
} else {
applyChildren();
}
break;
}
}

View File

@@ -0,0 +1,95 @@
import {
setLineageOfDocs,
renewDocIds
} from '/imports/api/parenting/parenting.js';
import {setDocToLastOrder} from '/imports/api/parenting/order.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex.js';
import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey.js';
import { get } from 'lodash';
import resolve, { map } from '/imports/parser/resolve.js';
import logErrors from './shared/logErrors.js';
export default function applyBuff(node, {creature, targets, scope, log}){
const prop = node.node;
let buffTargets = prop.target === 'self' ? [creature] : targets;
// Then copy the decendants of the buff to the targets
prop.applied = true;
let propList = [prop];
function addChildrenToPropList(children){
children.forEach(child => {
propList.push(child.node);
addChildrenToPropList(child.children);
});
}
addChildrenToPropList(node.children);
crystalizeVariables({propList, scope, log});
let oldParent = {
id: prop.parent.id,
collection: prop.parent.collection,
};
buffTargets.forEach(target => {
copyNodeListToTarget(propList, target, oldParent);
});
// Don't apply the children of the buff, they get copied to the target instead
}
function copyNodeListToTarget(propList, target, oldParent){
let ancestry = [{collection: 'creatures', id: target._id}];
setLineageOfDocs({
docArray: propList,
newAncestry: ancestry,
oldParent,
});
renewDocIds({
docArray: propList,
});
setDocToLastOrder({
collection: CreatureProperties,
doc: propList[0],
});
CreatureProperties.batchInsert(propList);
}
/**
* Replaces all variables with their resolved values
* except variables of the form `$target.thing.total` become `thing.total`
*/
function crystalizeVariables({propList, scope, log}){
propList.forEach(prop => {
computedSchemas[prop.type].computedFields().forEach( calcKey => {
applyFnToKey(prop, calcKey, (prop, key) => {
const calcObj = get(prop, key);
if (!calcObj?.parseNode) return;
map(calcObj.parseNode, node => {
// Skip nodes that aren't symbols or accessors
if (
node.parseType !== 'accessor' && node.parseType !== 'symbol'
) return node;
// Handle variables
if (node.name === '$target'){
// strip $target
if (node.parseType === 'accessor'){
node.name = node.path.shift();
} else {
// Can't strip symbols
log.content.push({
name: 'Error',
value: 'Variable `$target` should not be used without a property: $target.property'
});
}
return node;
} else {
// Resolve all other variables
const {result, context} = resolve('reduce', node, scope);
logErrors(context.errors, log);
return result;
}
});
});
});
});
}

View File

@@ -0,0 +1,7 @@
export default function logErrors(errors, log){
errors?.forEach(error => {
if (error.type !== 'info'){
log.content.push({name: 'Error', value: error.message});
}
});
}

View File

@@ -0,0 +1,9 @@
import evaluateCalculation from '../utility/evaluateCalculation.js';
import logErrors from './logErrors.js';
export default function recalculateCalculation(calc, scope, log){
if (!calc.parseNode) return;
calc._parseLevel = 'reduce';
evaluateCalculation(calc, scope);
logErrors(calc.errors, log);
}

View File

@@ -0,0 +1,13 @@
import embedInlineCalculations from '/imports/api/engine/computation/utility/embedInlineCalculations.js';
import recalculateCalculation from './recalculateCalculation.js'
export default function recalculateInlineCalculations(inlineCalcObj, scope, log){
// Skip if there are no calculations
if (!inlineCalcObj?.calculations?.length) return;
// Recalculate each calculation with the current scope
inlineCalcObj.inlineCalculations.forEach(calc => {
recalculateCalculation(calc, scope, log);
});
// Embed the new calculated values
embedInlineCalculations(inlineCalcObj);
}

View File

@@ -0,0 +1,103 @@
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs.js';
import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js';
import applyProperty from './applyProperty.js';
import computeCreature from '/imports/api/engine/computeCreature.js';
export default function doAction({actionId, targetIds, method}){
// get the docs
const {
creature, targets, properties, ancestors
} = fetchActionDocs(actionId, targetIds);
const ancestorScope = getAncestorScope(ancestors);
const propertyForest = nodeArrayToTree(properties);
if (propertyForest.length !== 1){
throw new Meteor.Error(`The action has ${propertyForest.length} top level properties, expected 1`);
}
// Create the log
let log = CreatureLogSchema.clean({
creatureId: creature._id,
creatureName: creature.name,
});
// Apply the top level property, it is responsible for applying its children
// recursively
const scope = {
...creature.variables,
...ancestorScope,
}
applyProperty(propertyForest[0], {
creature,
targets,
scope,
log,
});
// Insert the log
insertCreatureLogWork({log, creature, method});
// Recompute the creature and targets
Meteor.defer(() => computeCreature(creature._id));
targetIds.forEach(targetId => {
Meteor.defer(() => computeCreature(targetId));
});
}
function fetchActionDocs(actionId, targetIds){
// Fetch the action with ancestors only
const action = CreatureProperties.findOne({
_id: actionId,
removed: {$ne: true},
}, {
fields: {ancestors: 1}
});
if (!action) throw new Meteor.Error('The specified action was not found');
// Fetch all the action's ancestor creatureProperties
const ancestorIds = [];
action.ancestors.forEach(ref => {
if (ref.collection === 'creatureProperties') {
ancestorIds.push(ref.id);
}
});
// Get cursor of ancestors
const ancestors = CreatureProperties.find({
_id: {$in: ancestorIds},
}, {
sort: {order: 1},
});
// Fetch the action's top level ancestor creature
const creature = Creatures.findOne(action.ancestors[0].id, {
fields: {variables: 1},
});
if (!creature) throw new Meteor.Error('The creature for this action was not found');
// Fetch all the target creatures
const targets = Creatures.find({
_id: targetIds,
}, {
fields: {variables: 1},
}).fetch();
// Get cursor of the properties
const properties = CreatureProperties.find({
$or: [{_id: actionId}, {'ancestors.id': actionId}],
removed: {$ne: true},
}, {
sort: {order: 1},
});
return {action, creature, targets, properties, ancestors}
}
// Assumes ancestors are in tree order already
function getAncestorScope(ancestors){
let scope = {};
ancestors.forEach(prop => {
scope[`#${prop.type}`] = prop;
});
return scope;
}

View File

@@ -27,7 +27,6 @@ function computeResources(computation, node){
const att = computation.scope[attConsumed.variableName];
if (!att._id) return;
attConsumed.available = att.value;
attConsumed.statId = att._id;
attConsumed.statName = att.name;
});
}

View File

@@ -1,5 +1,5 @@
import resolve, { toString } from '/imports/parser/resolve.js';
import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js';
import embedInlineCalculations from '../utility/embedInlineCalculations.js';
import evaluateCalculation from '../utility/evaluateCalculation.js';
export default function computeCalculations(computation, node){
if (!node.data) return;
@@ -11,32 +11,3 @@ export default function computeCalculations(computation, node){
embedInlineCalculations(inlineCalcObj);
});
}
function evaluateCalculation(calculation, scope){
const parseNode = calculation.parseNode;
const fn = calculation._parseLevel;
const calculationScope = {...calculation._localScope, ...scope};
const {result: resultNode, context} = resolve(fn, parseNode, calculationScope);
calculation.errors = context.errors;
if (resultNode?.parseType === 'constant'){
calculation.value = resultNode.value;
} else if (resultNode?.parseType === 'error'){
calculation.value = null;
} else {
calculation.value = toString(resultNode);
}
// remove the working fields
delete calculation._parseLevel;
delete calculation._localScope;
}
function embedInlineCalculations(inlineCalcObj){
const string = inlineCalcObj.text;
const calculations = inlineCalcObj.inlineCalculations;
if (!string || !calculations) return;
let index = 0;
inlineCalcObj.value = string.replace(INLINE_CALCULATION_REGEX, substring => {
let calc = calculations[index++];
return (calc && 'value' in calc) ? calc.value : substring;
});
}

View File

@@ -0,0 +1,12 @@
import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js';
export default function embedInlineCalculations(inlineCalcObj){
const string = inlineCalcObj.text;
const calculations = inlineCalcObj.inlineCalculations;
if (!string || !calculations) return;
let index = 0;
inlineCalcObj.value = string.replace(INLINE_CALCULATION_REGEX, substring => {
let calc = calculations[index++];
return (calc && 'value' in calc) ? calc.value : substring;
});
}

View File

@@ -0,0 +1,19 @@
import resolve, { toString } from '/imports/parser/resolve.js';
export default function evaluateCalculation(calculation, scope){
const parseNode = calculation.parseNode;
const fn = calculation._parseLevel;
const calculationScope = {...calculation._localScope, ...scope};
const {result: resultNode, context} = resolve(fn, parseNode, calculationScope);
calculation.errors = context.errors;
if (resultNode?.parseType === 'constant'){
calculation.value = resultNode.value;
} else if (resultNode?.parseType === 'error'){
calculation.value = null;
} else {
calculation.value = toString(resultNode);
}
// remove the working fields
delete calculation._parseLevel;
delete calculation._localScope;
}

View File

@@ -0,0 +1,5 @@
import Creatures from '/imports/api/creature/creatures/Creatures.js';
export default function writeScope(creatureId, scope){
Creatures.update(creatureId, {$set: {variables: scope}});
}

View File

@@ -1,11 +1,13 @@
import buildCreatureComputation from './computation/buildCreatureComputation.js';
import computeCreatureComputation from './computation/computeCreatureComputation.js';
import writeAlteredProperties from './computation/writeComputation/writeAlteredProperties.js';
import writeScope from './computation/writeComputation/writeScope.js';
export default function computeCreature(creatureId){
const computation = buildCreatureComputation(creatureId);
computeCreatureComputation(computation);
writeAlteredProperties(computation);
writeScope(creatureId, computation.scope);
}
// For now just recompute the whole creature, TODO only recompute a single

View File

@@ -1,13 +0,0 @@
import spendResources from '/imports/api/creature/actions/spendResources.js'
export default function applyAction({prop, log}){
let content = { name: prop.name };
/*
if (prop.summary){
content.value = embedInlineCalculations(
prop.summary, prop.summaryCalculations
);
}*/
log.content.push(content);
spendResources({prop, log});
}

View File

@@ -1,4 +1,4 @@
import roll from '/imports/parser/roll.js';
import rollDice from '/imports/parser/rollDice.js';
export default function applyAttack({
prop,
@@ -6,7 +6,7 @@ export default function applyAttack({
actionContext,
creature,
}){
let value = roll(1, 20)[0];
let value = rollDice(1, 20)[0];
actionContext.attackRoll = {value};
let criticalHitTarget = creature.variables.criticalHitTarget &&
creature.variables.criticalHitTarget.value || 20;

View File

@@ -7,8 +7,8 @@ import applyRoll from '/imports/api/creature/actions/applyRoll.js';
import applyToggle from '/imports/api/creature/actions/applyToggle.js';
import applySave from '/imports/api/creature/actions/applySave.js';
function applyProperty(options){
let prop = options.prop;
function applyProperty(args){
let prop = args.prop;
if (prop.type === 'buff'){
// ignore only applied buffs, don't apply them again
if (prop.applied === true){
@@ -26,28 +26,27 @@ function applyProperty(options){
switch (prop.type){
case 'action':
case 'spell':
applyAction(options);
break;
case 'attack':
applyAction(options);
applyAttack(options);
if (prop.attackRoll && prop.attackRoll.calculation){
applyAttack(args)
}
applyAction(args);
break;
case 'damage':
applyDamage(options);
applyDamage(args);
break;
case 'adjustment':
applyAdjustment(options);
applyAdjustment(args);
break;
case 'buff':
applyBuff(options);
applyBuff(args);
return false;
case 'toggle':
return applyToggle(options);
return applyToggle(args);
case 'roll':
applyRoll(options);
applyRoll(args);
break;
case 'savingThrow':
return applySave(options);
return applySave(args);
}
return true;
}