refactored action engine into individual files

This commit is contained in:
Thaum Rystra
2024-02-15 22:30:50 +02:00
parent 19f7d40386
commit aee9d6b8cb
63 changed files with 1854 additions and 2898 deletions

8
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"cSpell.words": [
"autorun",
"cyrb",
"EJSON",
"uncomputed"
]
}

View File

@@ -172,7 +172,7 @@ function assertSourceLibraryCopyPermission(props, method) {
});
}
function cleanProps(props) {
export function cleanProps(props) {
return props.map(prop => {
let schema = LibraryNodes.simpleSchema(prop);
return schema.clean(prop);

View File

@@ -226,5 +226,3 @@ Creatures.attachSchema(CreatureSchema);
export default Creatures;
export { CreatureSchema };
import '/imports/api/engine/actions/doAction';

View File

@@ -4,20 +4,18 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
import { propsFromForest } from '/imports/api/properties/tests/propTestBuilder.testFn';
import Creatures from '/imports/api/creature/creatures/Creatures';
import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables';
import Actions, { Action, Update, LogContent, applyAction } from '/imports/api/engine/actions/ActionEngine';
import computeCreature from '/imports/api/engine/computeCreature';
import { loadCreature } from '/imports/api/engine/loadCreatures';
import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyAction } from '/imports/api/engine/action/functions/applyAction';
import { LogContent, Removal, Update } from '/imports/api/engine/action/tasks/TaskResult';
const creatureId = Random.id();
const targetId = Random.id();
describe('Interrupt action system', function () {
let unload: (() => void) | undefined = undefined;
const dummySubscription = {
onStop(fn) {
unload = fn;
}
};
const dummySubscription = Tracker.autorun(() => undefined)
this.timeout(8000);
before(async function () {
// Remove old data
await Promise.all([
@@ -48,7 +46,7 @@ describe('Interrupt action system', function () {
loadCreature(creatureId, dummySubscription);
});
after(function () {
unload?.();
dummySubscription.stop();
});
it('writes notes to the log', async function () {
const action = await runActionById(note1Id);
@@ -86,7 +84,7 @@ describe('Interrupt action system', function () {
it('Halts execution of choice branches', async function () {
let userInputRequested = false;
const requestUserInput = () => { userInputRequested = true; return 0 };
const action = await runActionById(choiceBranchId, requestUserInput);
await runActionById(choiceBranchId, requestUserInput);
assert.isTrue(userInputRequested, 'User input should be requested when a choice branch is applied');
});
it('Applies adjustments', async function () {
@@ -124,29 +122,102 @@ describe('Interrupt action system', function () {
}
]);
});
it('Applies buffs', async function () {
const action = await runActionById(buffId);
const inserts = allInserts(action);
const newIds = inserts.map(p => p._id);
assert.notEqual(buffId, newIds[0]);
assert.deepEqual(inserts, [
{
_id: newIds[0],
left: 43,
parentId: null,
right: 48,
root: {
collection: 'creatures',
id: creatureId,
},
tags: [],
target: 'self',
type: 'buff',
}, {
_id: newIds[1],
attributeType: 'stat',
baseValue: {
calculation: '13 + buffSourceStat + 7',
},
left: 44,
parentId: newIds[0],
right: 45,
root: {
collection: 'creatures',
id: creatureId,
},
tags: [],
type: 'attribute',
variableName: 'buffStat',
}, {
_id: newIds[2],
left: 46,
parentId: newIds[0],
removeAll: true,
right: 47,
root: {
collection: 'creatures',
id: creatureId,
},
tags: [],
target: 'self',
targetParentBuff: true,
type: 'buffRemover',
}
]);
});
it('Removes parent buffs', async function () {
const action = await runActionById(removeParentBuffId);
console.log(allLogContent(action));
assert.deepEqual(allRemovals(action), [
{ propId: buffId }
]);
});
it('Removes all buffs by tag', async function () {
const action = await runActionById(removeTaggedBuffsId);
console.log(allLogContent(action));
assert.deepEqual(allRemovals(action), [
{ propId: taggedBuffId },
{ propId: secondTaggedBuffId },
]);
});
it('Removes a single buff by tag', async function () {
const action = await runActionById(removeOneTaggedBuffId);
console.log(allLogContent(action));
assert.deepEqual(allRemovals(action), [
{ propId: taggedBuffId },
]);
});
});
function createAction(prop, targetIds?) {
const action: Action = {
const action: EngineAction = {
creatureId: prop.root.id,
rootPropId: prop._id,
results: [],
taskCount: 0,
targetIds,
};
return Actions.insertAsync(action);
return EngineActions.insertAsync(action);
}
async function runActionById(propId, userInputFn = () => 0) {
const prop = await CreatureProperties.findOneAsync(propId);
const actionId = await createAction(prop);
const action = await Actions.findOneAsync(actionId);
const action = await EngineActions.findOneAsync(actionId);
if (!action) throw 'Action is expected to exist';
await applyAction(action, userInputFn, { simulate: true });
return action;
}
function allUpdates(action: Action) {
function allUpdates(action: EngineAction) {
const updates: Update[] = [];
action.results.forEach(result => {
result.mutations.forEach(mutation => {
@@ -158,7 +229,31 @@ function allUpdates(action: Action) {
return updates;
}
function allLogContent(action: Action) {
function allInserts(action: EngineAction) {
const inserts: any[] = [];
action.results.forEach(result => {
result.mutations.forEach(mutation => {
mutation.inserts?.forEach(update => {
inserts.push(update);
});
});
});
return inserts;
}
function allRemovals(action: EngineAction) {
const removals: Removal[] = [];
action.results.forEach(result => {
result.mutations.forEach(mutation => {
mutation.removals?.forEach(update => {
removals.push(update);
});
});
});
return removals
}
function allLogContent(action: EngineAction) {
const contents: LogContent[] = [];
action.results.forEach(result => {
result.mutations.forEach(mutation => {
@@ -170,8 +265,9 @@ function allLogContent(action: Action) {
return contents;
}
let note1Id, folderId, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId, adjustedStatId,
adjustmentIncrementId, adjustmentSetId, rollId;
let note1Id, folderId, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId,
adjustedStatId, adjustmentIncrementId, adjustmentSetId, rollId, buffId,
removeParentBuffId, removeTaggedBuffsId, removeOneTaggedBuffId, taggedBuffId, secondTaggedBuffId, buffAttChildId;
const propForest = [
// Apply a simple note
@@ -255,6 +351,63 @@ const propForest = [
{ type: 'note', summary: { text: 'adjustment increment applied' } },
],
},
// Apply buffs
{
_id: Random.id(),
type: 'attribute',
attributeType: 'stat',
variableName: 'buffSourceStat',
baseValue: { calculation: '13' },
}, {
_id: buffId = Random.id(),
type: 'buff',
target: 'self',
children: [
{
_id: buffAttChildId = Random.id(),
type: 'attribute',
attributeType: 'stat',
variableName: 'buffStat',
baseValue: { calculation: 'buffSourceStat + ~target.buffSourceStat + 7' },
}, {
_id: removeParentBuffId = Random.id(),
type: 'buffRemover',
target: 'self',
targetParentBuff: true,
},
],
},
// Extra buffs with and without tags
{
_id: taggedBuffId = Random.id(),
name: 'Tagged Buff',
type: 'buff',
tags: ['buff tag', 'other tag']
}, {
_id: secondTaggedBuffId = Random.id(),
name: 'Tagged buff 2',
type: 'buff',
tags: ['buff tag', 'yet another tag']
}, {
_id: Random.id(),
name: 'Untagged buff',
type: 'buff',
tags: ['other tag']
},
// Remove buffs by tag
{
_id: removeTaggedBuffsId = Random.id(),
type: 'buffRemover',
target: 'self',
removeAll: true,
targetTags: 'buff tag',
}, {
_id: removeOneTaggedBuffId = Random.id(),
type: 'buffRemover',
target: 'self',
removeAll: false,
targetTags: 'buff tag',
},
// Apply rolls
{
_id: rollId = Random.id(),

View File

@@ -0,0 +1,135 @@
import SimpleSchema from 'simpl-schema';
import TaskResult from './tasks/TaskResult';
import LogContentSchema from '/imports/api/creature/log/LogContentSchema';
const EngineActions = new Mongo.Collection<EngineAction>('actions');
export interface EngineAction {
_id?: string;
_isSimulation?: boolean;
_stepThrough?: boolean;
creatureId: string;
rootPropId: string;
targetIds?: string[];
results: TaskResult[];
taskCount: number;
}
const ActionSchema = new SimpleSchema({
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
rootPropId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
targetIds: {
type: Array,
defaultValue: [],
},
'targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
userInputNeeded: {
type: Object,
optional: true,
blackbox: true,
},
// Applied properties
results: {
type: Array,
defaultValue: [],
},
'results.$': {
type: Object,
},
// The property and target ids popped off the task stack
// Pushing these to the top of the stack and deleting the results from this point onwards
// Should re-run the action identically from this point
'results.$.propId': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
'results.$.targetIds': {
type: Array,
defaultValue: [],
},
'results.$.targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// Changes that override the local scope
'results.$.scope': {
type: Object,
optional: true,
blackbox: true,
},
// Changes that consume pushed values from the local scope
'results.$.popScope': {
type: Object,
optional: true,
blackbox: true,
},
// Changes that push values to the local scope
'results.$.pushScope': {
type: Object,
optional: true,
blackbox: true,
},
// database changes
'results.$.mutations': {
type: Array,
optional: true,
},
'results.$.mutations.$': {
type: Object,
},
'results.$.mutations.$.targetIds': {
type: Array,
},
'results.$.mutations.$.targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
'results.$.mutations.$.updates': {
type: Array,
optional: true,
},
'results.$.mutations.$.updates.$': {
type: Object,
},
'results.$.mutations.$.updates.$.propId': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// Required, because CreatureProperties.update requires a selector of { type }
'results.$.mutations.$.updates.$.type': {
type: String,
},
'results.$.mutations.$.updates.$.set': {
type: Object,
optional: true,
blackbox: true,
},
'results.$.mutations.$.updates.$.inc': {
type: Object,
optional: true,
blackbox: true,
},
'results.$.mutations.$.contents': {
type: Array,
optional: true,
},
'results.$.mutations.$.contents.$': {
type: LogContentSchema,
},
});
// @ts-expect-error Collections2 lacks TypeScript support
EngineActions.attachSchema(ActionSchema);
export default EngineActions;
export { ActionSchema }

View File

@@ -0,0 +1,273 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { PropTask } from '../tasks/Task';
import TaskResult, { LogContent } from '../tasks/TaskResult';
import { getPropertiesOfType } from '/imports/api/engine/loadCreatures';
import applyTask from '/imports/api/engine/action/tasks/applyTask';
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
import recalculateInlineCalculations from '/imports/api/engine/action/functions/recalculateInlineCalculations';
import spendResources from '/imports/api/engine/action/functions/spendResources';
import { applyAfterChildrenTriggers, applyAfterTriggers, applyChildren } from '/imports/api/engine/action/functions/applyTaskGroups';
import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import numberToSignedString from '/imports/api/utility/numberToSignedString';
import rollDice from '/imports/parser/rollDice';
export default async function applyActionProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
): Promise<void> {
const prop = task.prop;
const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds;
//Log the name and summary, check that the property has enough resources to fire
const content: LogContent = { name: prop.name };
if (prop.summary?.text) {
await recalculateInlineCalculations(prop.summary, action);
content.value = prop.summary.value;
}
if (prop.silent) content.silenced = true;
result.appendLog(content, targetIds);
// Check Uses
if (prop.usesLeft <= 0) {
if (!prop.silent) result.appendLog({
name: 'Error',
value: `${prop.name || 'action'} does not have enough uses left`,
}, targetIds);
return;
}
// Check Resources
if (prop.insufficientResources) {
if (!prop.silent) result.appendLog({
name: 'Error',
value: 'This creature doesn\'t have sufficient resources to perform this action',
}, targetIds);
return;
}
spendResources(action, prop, targetIds, result, userInput);
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);
}
} 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 applyAfterChildrenTriggers(action, prop, targetIds, 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: EngineAction, 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

@@ -0,0 +1,63 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyDefaultAfterPropTasks, applyTaskToEachTarget } from '/imports/api/engine/action/functions/applyTaskGroups';
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 applyTask from '/imports/api/engine/action/tasks/applyTask';
import { getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures';
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
export default async function applyAdjustmentProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
): Promise<void> {
const prop = task.prop;
const damageTargetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds;
if (damageTargetIds.length > 1) {
return await applyTaskToEachTarget(action, task, damageTargetIds, userInput);
}
// Get the operation and value and push the damage hooks to the queue
if (!prop.amount) {
return;
}
// Evaluate the amount
await recalculateCalculation(prop.amount, action, 'reduce');
const value = +prop.amount.value;
if (!isFinite(value)) {
return;
}
if (!damageTargetIds?.length) {
return;
}
if (damageTargetIds.length !== 1) {
throw 'At this step, only a single target is supported'
}
const targetId = damageTargetIds[0];
const statId = getVariables(targetId)?.[prop.stat]?._propId;
const stat = statId && getSingleProperty(targetId, statId);
if (!stat?.type) {
result.appendLog({
name: 'Error',
value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set`,
silenced: prop.silent,
}, damageTargetIds);
return;
}
applyTask(action, {
prop,
targetIds: damageTargetIds,
subtaskFn: 'damageProp',
params: {
title: getPropertyTitle(prop),
operation: prop.operation,
value,
targetProp: stat,
},
}, userInput);
return applyDefaultAfterPropTasks(action, prop, damageTargetIds, userInput);
}

View File

@@ -0,0 +1,129 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyAfterPropTasksForSingleChild, applyAfterTasksSkipChildren, applyDefaultAfterPropTasks, applyTaskToEachTarget } 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 { getPropertyChildren } from '/imports/api/engine/loadCreatures';
import rollDice from '/imports/parser/rollDice';
export default async function applyBranchProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
): Promise<void> {
const prop = task.prop;
const targets = task.targetIds;
switch (prop.branchType) {
case 'if': {
await recalculateCalculation(prop.condition, action, 'reduce');
if (prop.condition?.value) {
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
} else {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
}
case 'index': {
const children = await getPropertyChildren(action.creatureId, prop);
if (!children.length) {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
await recalculateCalculation(prop.condition, action, 'reduce');
if (!isFinite(prop.condition?.value)) {
result.appendLog({
name: 'Branch Error',
value: 'Index did not resolve into a valid number'
}, targets);
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
let index = Math.floor(prop.condition?.value);
if (index < 1) index = 1;
if (index > children.length) index = children.length;
const child = children[index - 1];
return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput);
}
case 'hit': {
const scope = await getEffectiveActionScope(action);
if (scope['~attackHit']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
value: '**On hit**'
}, targets);
}
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
} else {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
}
case 'miss': {
const scope = await getEffectiveActionScope(action);
if (scope['~attackMiss']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
value: '**On miss**'
}, targets);
}
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
} else {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
}
case 'failedSave': {
const scope = await getEffectiveActionScope(action);
if (scope['~saveFailed']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
value: '**On failed save**'
}, targets);
}
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
} else {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
}
case 'successfulSave': {
const scope = await getEffectiveActionScope(action);
if (scope['~saveSucceeded']?.value) {
if (!targets.length && !prop.silent) {
result.appendLog({
value: '**On save**'
}, targets);
}
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
} else {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
}
case 'random': {
const children = await getPropertyChildren(action.creatureId, prop);
if (children.length) {
const index = rollDice(1, children.length)[0];
const child = children[index - 1];
return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput);
} else {
return applyAfterTasksSkipChildren(action, prop, targets, userInput);
}
}
case 'eachTarget':
if (targets.length > 1) {
return applyTaskToEachTarget(action, task, targets, userInput);
}
return applyDefaultAfterPropTasks(action, prop, targets, userInput);
case 'choice': {
let index;
if (action._isSimulation) {
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);
}
if (!isFinite(index) || index < 0) index = 0;
if (index > children.length - 1) index = children.length - 1;
const child = children[index];
return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput);
}
}
}

View File

@@ -0,0 +1,165 @@
import { get } from 'lodash';
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
import { getPropertyDescendants } from '/imports/api/engine/loadCreatures';
import resolve, { toString, map } from '/imports/parser/resolve';
import computedSchemas from '/imports/api/properties/computedOnlyPropertySchemasIndex.js';
import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey';
import accessor from '/imports/parser/parseTree/accessor';
import TaskResult, { Mutation } from '/imports/api/engine/action/tasks/TaskResult';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import cyrb53 from '/imports/api/engine/computation/utility/cyrb53';
import { renewDocIds } from '/imports/api/parenting/parentingFunctions';
import { cleanProps } from '/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary';
import recalculateInlineCalculations from '/imports/api/engine/action/functions/recalculateInlineCalculations';
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX';
import { applyAfterTasksSkipChildren } from '/imports/api/engine/action/functions/applyTaskGroups';
export default async function applyBuffProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
) {
const prop = EJSON.clone(task.prop);
const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds;
// Get the buff and its descendants
const propList = [
EJSON.clone(prop),
...getPropertyDescendants(action.creatureId, prop._id),
];
// Crystalize the variables
if (!prop.skipCrystalization) {
await crystalizeVariables(action, propList, task, result);
}
targetIds.forEach(target => {
// Create a per-target mutation
const mutation: Mutation = { targetIds: [target], contents: [] };
// Create a per-target copy of the propList
let targetPropList = EJSON.clone(propList);
// Give the properties new IDs as descendants of the target
renewDocIds({
docArray: targetPropList,
idMap: {
[prop.parentId]: null,
[prop.root.id]: target,
},
collectionMap: { [prop.root.collection]: 'creatures' }
});
//Log the buff
let logValue = prop.description?.value
if (prop.description?.text) {
recalculateInlineCalculations(prop.description, action);
logValue = prop.description?.value;
}
result.appendLog({
name: getPropertyTitle(prop),
value: logValue
}, [target]);
// remove all the computed fields
targetPropList = cleanProps(targetPropList);
// Insert the props in the mutation
mutation.inserts = targetPropList;
// Add the mutation to the results
result.mutations.push(mutation);
});
applyAfterTasksSkipChildren(action, prop, targetIds, userInput);
}
/**
* Replaces all variables with their resolved values
* except variables of the form `~target.thing.total` become `thing.total`
*/
async function crystalizeVariables(
action: EngineAction, propList: any[], task: PropTask, result: TaskResult
) {
const scope = await getEffectiveActionScope(action);
propList.forEach(prop => {
if (prop._skipCrystalize) {
delete prop._skipCrystalize;
return;
}
// Iterate through all the calculations and crystalize them
computedSchemas[prop.type].computedFields().forEach(calcKey => {
applyFnToKey(prop, calcKey, (prop, key) => {
const calcObj = get(prop, key);
if (!calcObj?.parseNode) return;
calcObj.parseNode = map(calcObj.parseNode, node => {
// Skip nodes that aren't symbols or accessors
if (
node.parseType !== 'accessor'
) return node;
// Handle variables
if (node.parseType === 'accessor' && node.name === '~target') {
// strip ~target
if (node.path?.length > 0) {
const name = node.path.shift();
return accessor.create({
name,
path: node.path?.length ? node.path : undefined,
});
} else {
// Can't strip if there isn't anything in the path after ~target
result.appendLog({
name: 'Error',
value: 'Variable `~target` should not be used without a property: ~target.property',
}, task.targetIds);
}
return node;
} else {
// Resolve all other variables
const { result, context } = resolve('reduce', node, scope);
context.errors?.forEach(error => {
result.appendLog({
name: 'Error',
value: error,
}, task.targetIds);
});
return result;
}
});
calcObj.calculation = toString(calcObj.parseNode);
calcObj.hash = cyrb53(calcObj.calculation);
});
});
// For each key in the schema
computedSchemas[prop.type].inlineCalculationFields().forEach(calcKey => {
// That ends in .inlineCalculations
applyFnToKey(prop, calcKey, (prop, key) => {
const inlineCalcObj = get(prop, key);
if (!inlineCalcObj) return;
// If there is no text, skip
if (!inlineCalcObj.text) {
return;
}
// Replace all the existing calculations
let index = -1;
inlineCalcObj.text = inlineCalcObj.text.replace(INLINE_CALCULATION_REGEX, () => {
index += 1;
return `{${inlineCalcObj.inlineCalculations[index].calculation}}`;
});
// Set the value to the uncomputed string
inlineCalcObj.value = inlineCalcObj.text;
// Write a new hash
const inlineCalcHash = cyrb53(inlineCalcObj.text);
if (inlineCalcHash === inlineCalcObj.hash) {
// Skip if nothing changed
return;
}
inlineCalcObj.hash = inlineCalcHash;
});
});
});
}

View File

@@ -1,39 +1,40 @@
import { findLast, difference, intersection, filter } from 'lodash';
import applyProperty from '../applyProperty';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import { getPropertyAncestors, getPropertiesOfType } from '/imports/api/engine/loadCreatures';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import { softRemove } from '/imports/api/parenting/softRemove';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult from 'imports/api/engine/action/tasks/TaskResult';
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
import { findLast, filter, difference, intersection } from 'lodash';
import { getPropertiesOfType, getPropertyAncestors } from '/imports/api/engine/loadCreatures';
import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups';
import { EngineAction } from '/imports/api/engine/action/EngineActions';
export default function applyBuffRemover(node, actionContext) {
// Apply triggers
applyNodeTriggers(node, 'before', actionContext);
export default function applyBuffRemoverProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
) {
const prop = task.prop;
const prop = node.doc
// Log Name
if (prop.name && !prop.silent) {
actionContext.addLog({ name: prop.name });
// Log Name
result.appendLog({
name: getPropertyTitle(prop),
}, task.targetIds)
}
// Remove buffs
if (prop.targetParentBuff) {
// Remove nearest ancestor buff
const ancestors = getPropertyAncestors(actionContext.creature._id, prop._id);
const ancestors = getPropertyAncestors(action.creatureId, prop._id);
const nearestBuff = findLast(ancestors, ancestor => ancestor.type === 'buff');
if (!nearestBuff) {
actionContext.addLog({
result.appendLog({
name: 'Error',
value: 'Buff remover does not have a parent buff to remove',
});
}, task.targetIds);
return;
}
removeBuff(nearestBuff, actionContext, prop);
removeBuff(nearestBuff, prop, result);
} else {
// Get all the buffs targeted by tags
const allBuffs = getPropertiesOfType(actionContext.creature._id, 'buff');
const allBuffs = getPropertiesOfType(action.creatureId, 'buff');
const targetedBuffs = filter(allBuffs, buff => {
if (buff.inactive) return false;
if (buffRemoverMatchTags(prop, buff)) return true;
@@ -42,7 +43,7 @@ export default function applyBuffRemover(node, actionContext) {
if (prop.removeAll) {
// Remove all matching buffs
targetedBuffs.forEach(buff => {
removeBuff(buff, actionContext, prop);
removeBuff(buff, prop, result);
});
} else {
// Sort in reverse order
@@ -50,19 +51,23 @@ export default function applyBuffRemover(node, actionContext) {
// Remove the one with the highest order
const buff = targetedBuffs[0];
if (buff) {
removeBuff(buff, actionContext, prop);
removeBuff(buff, prop, result);
}
}
}
applyChildren(node, actionContext);
applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput);
}
function removeBuff(buff, actionContext, prop) {
if (!prop.silent) actionContext.addLog({
function removeBuff(buff: any, prop, result: TaskResult) {
result.mutations.push({
targetIds: result.targetIds,
removals: [{ propId: buff._id }],
contents: [{
name: 'Removed',
value: `${buff.name || 'Buff'}`
value: `${buff.name || 'Buff'}`,
silenced: prop.silent,
}],
});
softRemove({ _id: buff._id, collection: CreatureProperties });
}
function buffRemoverMatchTags(buffRemover, prop) {

View File

@@ -1,16 +1,4 @@
import { some, intersection, difference, remove, includes } from 'lodash';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs';
import resolve, { Context, toString } from '/imports/parser/resolve';
import logErrors from './shared/logErrors';
import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation'
import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty';
import {
getPropertiesOfType
} from '/imports/api/engine/loadCreatures';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags';
import applySavingThrow from '/imports/api/engine/actions/applyPropertyByType/applySavingThrow';
// TODO
export default function applyDamage(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);

View File

@@ -0,0 +1,11 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
export default async function applyFolderProperty(
task: PropTask, action: EngineAction, userInput
): Promise<void> {
const prop = task.prop;
return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput);
}

View File

@@ -0,0 +1,35 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups';
import recalculateInlineCalculations from '/imports/api/engine/action/functions/recalculateInlineCalculations';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult, { LogContent } from '/imports/api/engine/action/tasks/TaskResult';
export default async function applyNoteProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
): Promise<void> {
const prop = task.prop;
let contents: LogContent[] | undefined = undefined;
const logContent: LogContent = {};
if (prop.name) logContent.name = prop.name;
if (prop.summary?.text) {
await recalculateInlineCalculations(prop.summary, action);
logContent.value = prop.summary.value;
}
if (logContent.name || logContent.value) {
contents = [logContent];
}
// Log description
if (prop.description?.text) {
await recalculateInlineCalculations(prop.description, action);
if (!contents) contents = [];
contents.push({ value: prop.description.value });
}
if (contents) {
result.mutations.push({
contents,
targetIds: task.targetIds,
});
}
return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput);
}

View File

@@ -0,0 +1,58 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups';
import { rollAndReduceCalculation } 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 { toString } from '/imports/parser/resolve';
export default async function roll(
task: PropTask, action: EngineAction, result: TaskResult, userInput
): Promise<void> {
const prop = task.prop;
// If there isn't a calculation, just apply the children instead
if (!prop.roll?.calculation) {
return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput);
}
const logValue: string[] = [];
// roll the dice only and store that string
const {
rolled, reduced, errors
} = await rollAndReduceCalculation(prop.roll, action);
if (rolled.parseType !== 'constant') {
logValue.push(toString(rolled));
}
errors?.forEach(error => {
result.appendLog({ name: 'Error', value: error.message }, task.targetIds);
});
// Store the result
if (reduced.parseType === 'constant') {
prop.roll.value = reduced.value;
} else if (reduced.parseType === 'error') {
prop.roll.value = null;
} else {
prop.roll.value = toString(reduced);
}
// If we didn't end up with a constant or a number of finite value, give up
if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) {
return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput);
}
const value = reduced.value;
result.scope[prop.variableName] = { value };
logValue.push(`**${value}**`);
result.appendLog({
name: prop.name,
value: logValue.join('\n'),
inline: true,
silenced: prop.silent,
}, task.targetIds);
// Apply children
return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput);
}

View File

@@ -1,10 +1,4 @@
import rollDice from '/imports/parser/rollDice';
import recalculateCalculation from './shared/recalculateCalculation';
import applyProperty from '../applyProperty';
import numberToSignedString from '/imports/api/utility/numberToSignedString';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import { applyUnresolvedEffects } from '/imports/api/engine/actions/doCheck';
// TODO
export default function applySavingThrow(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);

View File

@@ -1,6 +1,4 @@
import recalculateCalculation from './shared/recalculateCalculation';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
// TODO
export default function applyToggle(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);

View File

@@ -0,0 +1,26 @@
import action from './applyActionProperty';
import adjustment from './applyAdjustmentProperty';
import branch from './applyBranchProperty';
import buff from './applyBuffProperty';
import buffRemover from './applyBuffRemoverProperty';
import damage from './applyDamageProperty';
import folder from './applyFolderProperty';
import note from './applyNoteProperty';
import roll from './applyRollProperty';
import savingThrow from './applySavingThrowProperty';
import toggle from './applyToggleProperty';
export default {
action,
adjustment,
branch,
buff,
buffRemover,
damage,
folder,
note,
roll,
savingThrow,
propertySlot: folder,
toggle,
}

View File

@@ -0,0 +1,42 @@
import { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import applyTask from '/imports/api/engine/action/tasks/applyTask'
import { isEmpty } from 'lodash';
// TODO create a function to get the effective value of a property,
// simulating all the result updates in the action so far
// Apply an action
// 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: EngineAction, 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;
const prop = await getSingleProperty(action.creatureId, action.rootPropId);
if (!prop) throw new Meteor.Error('Not found', 'Root action property could not be found');
await applyTask(action, {
prop,
targetIds: action.targetIds || [],
}, userInput);
return { action, userInput };
}
function writeChangedAction(original: EngineAction, changed: EngineAction) {
const $set = {};
for (const key of ActionSchema.objectKeys()) {
if (!EJSON.equals(original[key], changed[key])) {
$set[key] = changed[key];
}
}
if (!isEmpty($set) && original._id) {
return Actions.updateAsync(original._id, { $set });
}
}

View File

@@ -0,0 +1,141 @@
import { get } from 'lodash';
import { getPropertyChildren, getSingleProperty } from '/imports/api/engine/loadCreatures';
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import applyTask from '../tasks/applyTask';
import { PropTask } from '../tasks/Task';
/**
* Get all the child tasks of a given property
* @param action
* @param prop
* @param targetIds
* @returns
*/
export async function applyChildren(
action: EngineAction, prop, targetIds: string[], userInput
) {
const children = await getPropertyChildren(action.creatureId, prop);
// Push the child tasks and related triggers to the stack
for (const childProp of children) {
await applyTask(action, { prop: childProp, targetIds }, userInput);
}
}
/**
* Get the afterChildren triggers for a given property
* @param prop
* @param targetIds
* @returns
*/
export async function applyAfterChildrenTriggers(
action: EngineAction, prop, targetIds: string[], userInput
) {
if (!prop.triggerIds?.afterChildren) return;
for (const triggerId of prop.triggerIds.afterChildren) {
const trigger = await getSingleProperty(action.creatureId, triggerId);
if (!trigger) continue;
await applyTask(action, { prop: trigger, targetIds }, userInput);
}
}
export async function applyAfterTriggers(
action: EngineAction, prop, targetIds: string[], userInput
) {
if (!prop.triggerIds?.after) return;
for (const triggerId of prop.triggerIds.after) {
const trigger = await getSingleProperty(action.creatureId, triggerId);
if (!trigger) continue;
await applyTask(action, { prop: trigger, targetIds }, userInput);
}
}
/**
* Applies the following:
* After triggers
* Children of the prop
* After-children triggers
* @param action
* @param prop
* @param targetIds
* @returns
*/
export async function applyDefaultAfterPropTasks(
action: EngineAction, prop, targetIds: string[], userInput
) {
await applyAfterTriggers(action, prop, targetIds, userInput);
await applyChildren(action, prop, targetIds, userInput);
await applyAfterChildrenTriggers(action, prop, targetIds, userInput);
}
/**
* Applies the following:
* After triggers
* After-children triggers
* @param action
* @param prop
* @param targetIds
* @returns
*/
export async function applyAfterTasksSkipChildren(
action: EngineAction, prop, targetIds: string[], userInput
) {
await applyAfterTriggers(action, prop, targetIds, userInput);
await applyAfterChildrenTriggers(action, prop, targetIds, userInput);
}
/**
* Returns a list of tasks containing the following:
* After triggers
* After-children triggers
* @param action
* @param prop
* @param targetIds
* @returns
*/
export async function applyAfterPropTasksForSingleChild(
action: EngineAction, prop, childProp, targetIds: string[], userInput
) {
await applyAfterTriggers(action, prop, targetIds, userInput);
await applyTask(action, { prop: childProp, targetIds }, userInput);
await applyAfterChildrenTriggers(action, prop, targetIds, userInput);
}
/**
* Get all the trigger tasks for a given trigger path
* @param action
* @param prop
* @param targetIds
* @param triggerPath
* @returns
*/
export async function applyTriggers(
action: EngineAction, prop, targetIds: string[], triggerPath: string, userInput
) {
const triggerIds = get(prop?.triggers, triggerPath);
if (!triggerIds) return;
for (const triggerId of triggerIds) {
const trigger = await getSingleProperty(action.creatureId, triggerId);
if (!trigger) continue;
await applyTask(action, { prop: trigger, targetIds }, userInput);
}
}
/**
* Split a task over its targets, incrementing task step by 1
* @param task
* @param targetIds
* @returns Copies of the task, but with a single target each
*/
export async function applyTaskToEachTarget(
action: EngineAction, task: PropTask, targetIds: string[] = task.targetIds, userInput
) {
if (targetIds.length <= 1) throw 'Must have multiple targets to split a task';
// If there are targets, apply a new task to each target
for (const targetId of targetIds) {
await applyTask(action, {
...task,
targetIds: [targetId]
}, userInput);
}
}

View File

@@ -0,0 +1,60 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { getVariables } from '/imports/api/engine/loadCreatures';
// Combine all the action results into the scope at present
export async function getEffectiveActionScope(action: EngineAction) {
const scope = await getVariables(action.creatureId);
delete scope._id;
delete scope._creatureId;
// Combine the applied results
for (const result of action.results) {
// Pop keys that are not longer used by a busy property
if (result.popScope) {
for (const key in result.popScope) {
if (!result.popScope[key]) continue;
// If the popped keys have previous results, return to them
if (scope[key]?.previous) {
scope[key] = scope[key]?.previous;
} else {
// just remove the busy flag, the prop has been consumed
delete scope[key]?._busy
}
}
}
// For keys that have just started being used by a busy property
if (result.pushScope) {
for (const key in result.pushScope) {
// If the pushed keys already exist and are busy,
// save the previous results and overwrite
// the key
if (scope[key]?._busy) {
scope[key] = {
...result.pushScope[key],
previous: scope[key],
_busy: true,
};
} else {
scope[key] = {
...result.pushScope[key],
_busy: true,
};
}
}
}
// Assign other scope changes without bashing the scope[key].previous field
if (result.scope) {
for (const key in result.scope) {
if (scope[key]?.previous || scope[key]?._busy) {
scope[key] = {
...result.scope[key],
previous: scope[key].previous,
_busy: scope[key]._busy,
};
} else {
scope[key] = result.scope[key];
}
}
}
}
return scope;
}

View File

@@ -1,4 +1,3 @@
import logErrors from './logErrors';
import { Context, toPrimitiveOrString } from '/imports/parser/resolve';
import {
aggregateCalculationEffects,
@@ -7,12 +6,14 @@ import {
} from '/imports/api/engine/computation/computeComputation/computeByType/computeCalculation';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import resolve from '/imports/parser/resolve';
import { getEffectiveActionScope } from '/imports/api/engine/actions/ActionEngine';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
// TODO move this whole file to Actions.ts
// Redo the work of imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js
// TODO Redo the work of
// imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js
// But in the action scope
export default async function recalculateCalculation(calcObj, action, parseLevel = 'reduce', context, scope) {
export default async function recalculateCalculation(
calcObj, action, parseLevel = 'reduce', context, scope
) {
if (!calcObj?.parseNode) return;
calcObj._parseLevel = parseLevel;
if (!scope) {

View File

@@ -0,0 +1,75 @@
import { getFromScope } from '/imports/api/creature/creatures/CreatureVariables';
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import TaskResult from '/imports/api/engine/action/tasks/TaskResult';
import applyTask from '/imports/api/engine/action/tasks/applyTask';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
export default async function spendResources(
action: EngineAction, 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.root.id !== prop.root.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);
}
}
}

View File

@@ -11,6 +11,8 @@ import { damagePropertyWork } from '/imports/api/creature/creatureProperties/met
import { doActionWork } from '/imports/api/engine/actions/doAction';
import ActionContext from '/imports/api/engine/actions/ActionContext';
// TODO Migrate this to the new action engine
const doAction = new ValidatedMethod({
name: 'creatureProperties.doCastSpell',
validate: new SimpleSchema({

View File

@@ -7,9 +7,11 @@ import rollDice from '/imports/parser/rollDice';
import numberToSignedString from '/imports/api/utility/numberToSignedString';
import { applyTriggers } from '/imports/api/engine/actions/applyTriggers';
import ActionContext from '/imports/api/engine/actions/ActionContext';
import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation';
import recalculateCalculation from '../../actions/applyPropertyByType/shared/recalculateCalculation';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
// TODO Migrate this to the new action engine
const doCheck = new ValidatedMethod({
name: 'creatureProperties.doCheck',
validate: new SimpleSchema({

View File

@@ -0,0 +1,21 @@
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import SimpleSchema from 'simpl-schema';
import EngineActions, { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import { getCreature } from '/imports/api/engine/loadCreatures';
export const insertAction: ValidatedMethod = new ValidatedMethod({
name: 'actions.insertAction',
validate: new SimpleSchema({
action: ActionSchema
}).validator({ clean: true }),
run: async function ({ action }: { action: EngineAction }) {
assertEditPermission(getCreature(action.creatureId), this.userId);
// First remove all other actions on this creature
// only do one action at a time, don't wait for this to finish
EngineActions.removeAsync({ creatureId: action.creatureId });
// Force a random id even if one was provided, we may use it later as the seed for PRNG
delete action._id;
return await EngineActions.insertAsync(action);
},
});

View File

@@ -0,0 +1,34 @@
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import SimpleSchema from 'simpl-schema';
import EngineActions from '/imports/api/engine/action/EngineActions';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import { getCreature } from '/imports/api/engine/loadCreatures';
export const runAction = new ValidatedMethod({
name: 'actions.runAction',
validate: new SimpleSchema({
action: {
type: Object,
blackbox: true,
},
userInput: {
type: Object,
blackbox: true,
optional: true,
},
stepThrough: {
type: Boolean,
optional: true,
}
}).validator(),
run: async function ({ actionId, userInput }: { actionId: string, userInput?: any }) {
const action = await EngineActions.findOneAsync(actionId);
if (!action) throw 'Action not found';
assertEditPermission(getCreature(action.creatureId), this.userId);
const originalAction = EJSON.clone(action);
applyAction(action, userInput);
// Persist changes to the action
const writePromise = writeChangedAction(originalAction, action);
return writePromise;
},
});

View File

@@ -0,0 +1,33 @@
type Task = PropTask | DamagePropTask | ItemAsAmmoTask;
export default Task;
interface BaseTask {
prop: { [key: string]: any };
targetIds: string[];
}
export interface PropTask extends BaseTask {
subtaskFn?: undefined,
}
export interface DamagePropTask extends BaseTask {
subtaskFn: 'damageProp';
params: {
/**
* Use getPropertyTitle(prop) to set the title
*/
title?: string;
operation: 'increment' | 'set';
value: number;
targetProp: any;
};
}
export interface ItemAsAmmoTask extends BaseTask {
subtaskFn: 'consumeItemAsAmmo';
params: {
value: number;
item: any;
};
}

View File

@@ -0,0 +1,73 @@
/**
* The result of running a task containing all the changes that need to be made to the listed
* targets
* Each mutation may apply to a different subset of targets
*/
export default class TaskResult {
propId: string;
// The targets of the original task
targetIds: string[];
scope: any;
// Consume pushed changes from the local scope, every change pushed must be popped later
popScope?: any;
// Push changes to the scope if the same task intends to consume them in later steps
// These changes will be marked as _busy until they are consumed
// This allows a property to run in between steps of the same property type without
// bashing the variables used to maintain state between steps while still exposing
// those variables to triggers that need to change them
// If multiple properties use the same variable at once, the values used by outer
// properties can be found on variable.previous
pushScope?: any;
mutations: Mutation[];
constructor(propId: string, targetIds: string[]) {
this.propId = propId;
this.targetIds = targetIds;
this.mutations = [];
this.scope = {};
}
// Appends the log content to the latest mutation
appendLog(content: LogContent, targetIds: string[]) {
if (!this.mutations.length) {
this.mutations.push({ targetIds, contents: [] });
}
const latestMutation = this.mutations[this.mutations.length - 1]
if (!latestMutation.contents) {
latestMutation.contents = [];
}
latestMutation.contents.push(content);
}
}
export type Mutation = {
// Which creatures the mutation is applied to
// A mutation may apply to all, or a subset of, the result's targets and the acting creature
targetIds: string[];
// What changes in the database
updates?: Update[];
// What properties get added
// TODO make these properties a LibraryNode type
inserts?: any[];
// What properties get deleted
removals?: Removal[];
// Logged when this is applied
contents?: LogContent[];
}
export type Update = {
propId: string;
type: string,
set?: any;
inc?: any;
}
export type Removal = {
propId: string;
}
export type LogContent = {
name?: string;
value?: string;
inline?: boolean;
context?: any;
silenced?: boolean;
}

View File

@@ -0,0 +1,131 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { DamagePropTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult from '/imports/api/engine/action/tasks/TaskResult';
import { applyTriggers } from '/imports/api/engine/action/functions/applyTaskGroups';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
export default async function applyDamagePropTask(
task: DamagePropTask, action: EngineAction, result: TaskResult, userInput
): Promise<void> {
const prop = task.prop;
if (task.targetIds.length > 1) {
throw 'This subtask can only be called on a single target';
}
const targetId = task.targetIds[0];
let { value } = task.params;
const { title, operation } = task.params;
let targetProp = task.params.targetProp;
// Set the scope properties
result.pushScope = {};
if (prop.operation === 'increment') {
if (value >= 0) {
result.pushScope['~damage'] = { value };
} else {
result.pushScope['~healing'] = { value: -value };
}
} else {
result.pushScope['~set'] = { value };
}
// Store which property we're targeting
if (targetId === action.creatureId) {
result.pushScope['~attributeDamaged'] = { _propId: targetProp._id };
} else {
result.pushScope['~attributeDamaged'] = targetProp;
}
// Run the before triggers which may change scope properties
await applyTriggers(action, targetProp, [action.creatureId], 'damageProperty.before', userInput);
// Refetch the scope properties
const scope = await getEffectiveActionScope(action);
result.popScope = {
'~damage': 1, '~healing': 1, '~set': 1, '~attributeDamaged': 1,
};
value = +value;
if (operation === 'increment') {
if (value >= 0) {
value = scope['~damage']?.value;
} else {
value = -scope['~healing']?.value;
}
} else {
value = scope['~set']?.value;
}
const targetPropId = scope['~attributeDamaged']?._propId;
// If there are no targets, just log the result that would apply and end
if (!task.targetIds?.length) {
// Get the locally equivalent stat with the same variable name
const statName = getPropertyTitle(targetProp);
result.appendLog({
name: title,
value: `${statName}${operation === 'set' ? ' set to' : ''}` +
` ${value}`,
inline: true,
silenced: prop.silent,
}, task.targetIds);
}
let damage, newValue, increment;
targetProp = await getSingleProperty(targetId, targetPropId);
if (!targetProp) return;
if (operation === 'set') {
const total = targetProp.total || 0;
// Set represents what we want the value to be after damage
// So we need the actual damage to get to that value
damage = total - value;
// Damage can't exceed total value
if (damage > total && !targetProp.ignoreLowerLimit) damage = total;
// Damage must be positive
if (damage < 0 && !targetProp.ignoreUpperLimit) damage = 0;
newValue = targetProp.total - damage;
// Write the results
result.mutations.push({
targetIds: [targetId],
updates: [{
propId: targetProp._id,
set: { damage, value: newValue },
type: targetProp.type,
}],
contents: [{
name: title,
value: `${getPropertyTitle(targetProp)} set to ${value}`,
inline: true,
silenced: prop.silent,
}]
});
} else if (operation === 'increment') {
const currentValue = targetProp.value || 0;
const currentDamage = targetProp.damage || 0;
increment = value;
// Can't increase damage above the remaining value
if (increment > currentValue && !targetProp.ignoreLowerLimit) increment = currentValue;
// Can't decrease damage below zero
if (-increment > currentDamage && !targetProp.ignoreUpperLimit) increment = -currentDamage;
damage = currentDamage + increment;
newValue = targetProp.total - damage;
// Write the results
result.mutations.push({
targetIds: [targetId],
updates: [{
propId: targetProp._id,
inc: { damage: increment, value: -increment },
type: targetProp.type,
}],
contents: [{
name: 'Attribute damage',
value: `${getPropertyTitle(targetProp)} ${value}`,
inline: true,
silenced: prop.silent,
}]
});
}
await applyTriggers(action, prop, [action.creatureId], 'damageProperty.after', userInput);
}

View File

@@ -0,0 +1,55 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import {
applyDefaultAfterPropTasks, applyTriggers
} from '/imports/api/engine/action/functions/applyTaskGroups';
import {
getEffectiveActionScope
} from '/imports/api/engine/action/functions/getEffectiveActionScope';
import { ItemAsAmmoTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult from '/imports/api/engine/action/tasks/TaskResult';
import { getPropertyChildren } from '/imports/api/engine/loadCreatures';
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
export default async function applyItemAsAmmoTask(task: ItemAsAmmoTask, action: EngineAction, result: TaskResult, userInput): Promise<void> {
const prop = task.prop;
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.pushScope = { ['~ammoConsumed']: { value } };
// Apply the before triggers
await applyTriggers(action, item, [action.creatureId], 'ammo.before', userInput);
// Refetch the scope properties
const scope = await getEffectiveActionScope(action);
result.popScope = {
'~ammoConsumed': 1,
};
value = scope['~ammoConsumed']?.value || 0;
const itemChildren = await getPropertyChildren(action.creatureId, item);
// Do the quantity adjustment
// Check if property has quantity
result.mutations.push({
targetIds: task.targetIds,
updates: [{
propId: item._id,
inc: { quantity: -value },
type: 'item',
}],
// Log the item name as a heading if it has child properties to apply
contents: itemChildren.length ? [{
name: getPropertyTitle(item) || 'Ammo',
inline: false,
silenced: prop.silent,
}] : undefined,
});
await applyTriggers(action, item, [action.creatureId], 'ammo.after', userInput);
return applyDefaultAfterPropTasks(action, item, task.targetIds, userInput);
}

View File

@@ -0,0 +1,49 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import Task from './Task';
import TaskResult from '/imports/api/engine/action/tasks/TaskResult';
import applyDamagePropTask from '/imports/api/engine/action/tasks/applyDamagePropTask';
import applyItemAsAmmoTask from '/imports/api/engine/action/tasks/applyItemAsAmmoTask';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import applyProperties from '/imports/api/engine/action/applyProperties';
export default async function applyTask(action: EngineAction, task: Task, userInput?): Promise<void> {
action.taskCount += 1;
if (action.taskCount > 100) throw 'Only 100 properties can be applied at once';
if (task.subtaskFn) {
const result = new TaskResult(task.prop._id, task.targetIds);
action.results.push(result);
switch (task.subtaskFn) {
case 'damageProp':
return applyDamagePropTask(task, action, result, userInput);
case 'consumeItemAsAmmo':
return applyItemAsAmmoTask(task, action, result, userInput);
}
} else {
// Get property
const prop = task.prop;
// Ensure the prop exists
if (!prop) throw new Meteor.Error('Not found', 'Property could not be found');
// If the property is deactivated by a toggle, skip it
if (prop.deactivatedByToggle) return;
// Before triggers
if (prop.triggerIds?.before?.length) {
for (const triggerId of prop.triggerIds.before) {
const trigger = await getSingleProperty(action.creatureId, triggerId);
if (!trigger) continue;
await applyTask(action, { prop: trigger, targetIds: task.targetIds }, userInput);
}
}
// Create a result an push it to the action results, pass it to the apply function to modify
const result = new TaskResult(task.prop._id, task.targetIds);
result.scope[`#${prop.type}`] = prop;
action.results.push(result);
// Apply the property
return applyProperties[prop.type]?.(task, action, result, userInput);
}
}

View File

@@ -1,82 +0,0 @@
import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs';
import {
getCreature, getVariables, getPropertiesOfType, replaceLinkedVariablesWithProps
} from '/imports/api/engine/loadCreatures';
import { groupBy, remove } from 'lodash';
export default class ActionContext {
constructor(creatureId, targetIds = [], method, invocationId) {
// Get the creature
this.creature = getCreature(creatureId)
// Store the details for pausing for user interaction
this.invocationId = invocationId;
this.userInputStep = 0;
if (!this.creature) {
throw new Meteor.Error('No Creature', `No creature could be found with id: ${creatureId}`)
}
// Create a log
this.log = CreatureLogSchema.clean({
creatureId: creatureId,
creatureName: this.creature.name,
});
// Get the variables of the acting creature
this.creature.variables = getVariables(creatureId);
replaceLinkedVariablesWithProps(this.creature.variables);
delete this.creature.variables._id;
delete this.creature.variables._creatureId;
// Alias as scope
this.scope = this.creature.variables;
// Get the targets and their variables
this.targets = [];
targetIds.forEach(targetId => {
let target;
if (targetId === creatureId) {
target = this.creature;
} else {
target = getCreature(targetId);
target.variables = getVariables(targetId);
delete target.variables._id;
delete target.variables._creatureId;
}
this.targets.push(target);
});
// Store a reference to the method for inserting the log
this.method = method;
// Get triggers
this.triggers = getPropertiesOfType(creatureId, 'trigger');
// Remove deleted or inactive triggers
remove(this.triggers, trigger => trigger.removed || trigger.inactive);
// Sort triggers by order
this.triggers.sort((a, b) => a.order - b.order);
// Group the triggers into triggers.<event>.<timing> or
// triggers.doActionProperty.<propertyType>.<timing>
this.triggers = groupBy(this.triggers, 'event');
for (const event in this.triggers) {
if (event === 'doActionProperty') {
this.triggers[event] = groupBy(this.triggers[event], 'actionPropertyType');
for (const propertyType in this.triggers[event]) {
this.triggers[event][propertyType] = groupBy(this.triggers[event][propertyType], 'timing');
}
} else {
this.triggers[event] = groupBy(this.triggers[event], 'timing');
}
}
}
addLog(content) {
if (content.name || content.value) {
this.log.content.push(content);
}
}
writeLog() {
insertCreatureLogWork({
log: this.log,
creature: this.creature,
method: this.method,
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
import action from './applyPropertyByType/applyAction';
import ammo from './applyPropertyByType/applyItemAsAmmo'
import adjustment from './applyPropertyByType/applyAdjustment';
import branch from './applyPropertyByType/applyBranch';
import buff from './applyPropertyByType/applyBuff';
import buffRemover from './applyPropertyByType/applyBuffRemover';
import damage from './applyPropertyByType/applyDamage';
import folder from './applyPropertyByType/applyFolder';
import note from './applyPropertyByType/applyNote';
import roll from './applyPropertyByType/applyRoll';
import savingThrow from './applyPropertyByType/applySavingThrow';
import toggle from './applyPropertyByType/applyToggle';
import ActionContext from '/imports/api/engine/actions/ActionContext';
import { TreeNode } from '/imports/api/parenting/parentingFunctions';
import { CreatureProperty } from '/imports/api/creature/creatureProperties/CreatureProperties';
const applyPropertyByType = {
action,
ammo,
adjustment,
branch,
buff,
buffRemover,
damage,
folder,
note,
propertySlot: folder,
roll,
savingThrow,
spell: action,
toggle,
};
export default function applyProperty(node: TreeNode<CreatureProperty>, actionContext: ActionContext, ...rest) {
if (node.doc.deactivatedByToggle) return;
actionContext.scope[`#${node.doc.type}`] = node.doc;
applyPropertyByType[node.doc.type]?.(node, actionContext, ...rest);
}

View File

@@ -1,329 +0,0 @@
import recalculateInlineCalculations from './shared/recalculateInlineCalculations';
import recalculateCalculation from './shared/recalculateCalculation';
import rollDice from '/imports/parser/rollDice';
import applyProperty from '../applyProperty';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty';
import numberToSignedString from '/imports/api/utility/numberToSignedString';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import { resetProperties } from '/imports/api/creature/creatures/methods/restCreature';
import { TreeNode, hasAncestorRelationship } from '/imports/api/parenting/parentingFunctions';
import { Action } from '/imports/api/properties/Actions';
import { LogContent } from '/imports/api/creature/log/LogContentSchema';
import { Item } from '/imports/api/properties/Items';
interface Ammo extends Item {
type: 'ammo'
adjustment: number
}
export default async function applyAction(node: TreeNode<Action>, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.doc;
if (prop.target === 'self') actionContext.targets = [actionContext.creature];
const targets = actionContext.targets;
// Log the name and summary
const content: LogContent = { name: prop.name, };
if (prop.summary?.text) {
recalculateInlineCalculations(prop.summary, actionContext);
content.value = prop.summary.value;
}
if (!prop.silent) actionContext.addLog(content);
// Spend the resources
const failed = await spendResources(prop, actionContext);
if (failed) return;
const attack = prop.attackRoll;
// Attack if there is an attack roll
if (attack && attack.calculation) {
if (targets.length) {
for (const target of targets) {
await applyAttackToTarget({ attack, target, actionContext });
// Apply the children, but only to the current target
actionContext.targets = [target];
await applyChildren(node, actionContext);
}
} else {
await applyAttackWithoutTarget({ attack, actionContext });
await applyChildren(node, actionContext);
}
} else {
await applyChildren(node, actionContext);
}
if (prop.actionType === 'event' && prop.variableName) {
resetProperties(actionContext.creature._id, prop.variableName, actionContext);
}
}
function applyAttackWithoutTarget({ attack, actionContext }) {
delete actionContext.scope['~attackHit'];
delete actionContext.scope['~attackMiss'];
delete actionContext.scope['~criticalHit'];
delete actionContext.scope['~criticalMiss'];
delete actionContext.scope['~attackRoll'];
recalculateCalculation(attack, actionContext);
const scope = actionContext.scope;
const {
resultPrefix,
result,
criticalHit,
criticalMiss,
} = rollAttack(attack, scope);
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 };
}
actionContext.addLog({
name,
value: `${resultPrefix}\n**${result}**`,
inline: true,
});
}
function applyAttackToTarget({ attack, target, actionContext }) {
const scope = actionContext.scope;
delete scope['~attackHit'];
delete scope['~attackMiss'];
delete scope['~criticalHit'];
delete scope['~criticalMiss'];
delete scope['~attackDiceRoll'];
delete scope['~attackRoll'];
recalculateCalculation(attack, actionContext);
const {
resultPrefix,
result,
criticalHit,
criticalMiss,
} = rollAttack(attack, scope);
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)';
}
actionContext.addLog({
name,
value: `${resultPrefix}\n**${result}**`,
inline: true,
});
if (criticalMiss || result < armor) {
scope['~attackMiss'] = { value: true };
} else {
scope['~attackHit'] = { value: true };
}
} else {
actionContext.addLog({
name: 'Error',
value: 'Target has no `armor`',
});
actionContext.addLog({
name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit',
value: `${resultPrefix}\n**${result}**`,
inline: true,
});
}
}
function rollAttack(attack, scope) {
const rollModifierText = numberToSignedString(attack.value, true);
let value, resultPrefix;
if (scope['~attackAdvantage']?.value === 1) {
const [a, b] = 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] = rollDice(2, 20);
if (a <= b) {
value = a;
resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`;
} else {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else {
value = rollDice(1, 20)[0];
resultPrefix = `1d20 [${value}] ${rollModifierText}`
}
scope['~attackDiceRoll'] = { value };
const result = value + attack.value;
scope['~attackRoll'] = { value: result };
const { criticalHit, criticalMiss } = applyCrits(value, scope);
return { resultPrefix, result, value, criticalHit, criticalMiss };
}
function applyCrits(value, scope) {
let scopeCrit = scope['~criticalHitTarget']?.value;
if (scopeCrit?.parseType === 'constant') {
scopeCrit = scopeCrit.value;
}
const criticalHitTarget = scopeCrit || 20;
const criticalHit = value >= criticalHitTarget;
let criticalMiss;
if (criticalHit) {
scope['~criticalHit'] = { value: true };
} else {
criticalMiss = value === 1;
if (criticalMiss) {
scope['~criticalMiss'] = { value: true };
}
}
return { criticalHit, criticalMiss };
}
async function spendResources(prop: Action, actionContext) {
// Check Uses
if (!prop.usesLeft || prop.usesLeft <= 0) {
if (!prop.silent) actionContext.addLog({
name: 'Error',
value: `${prop.name || 'action'} does not have enough uses left`,
});
return true;
}
// Resources
if (prop.insufficientResources) {
if (!prop.silent) actionContext.addLog({
name: 'Error',
value: 'This creature doesn\'t have sufficient resources to perform this action',
});
return true;
}
// Items
const spendLog: string[] = [];
const gainLog: string[] = [];
const ammoToApply: TreeNode<Ammo>[] = [];
try {
prop.resources.itemsConsumed.forEach(itemConsumed => {
recalculateCalculation(itemConsumed.quantity, actionContext);
if (!itemConsumed.itemId) {
throw 'No ammo was selected for this prop';
}
const item = CreatureProperties.findOne(itemConsumed.itemId) as Item;
if (!item || item.root.id !== prop.root.id) {
throw 'The prop\'s ammo was not found on the creature';
}
if (
!itemConsumed?.quantity?.value ||
!isFinite(+itemConsumed.quantity.value)
) return;
const quantityConsumed = +itemConsumed.quantity.value;
let logName = item.name;
if (quantityConsumed > 1 || quantityConsumed < -1) {
logName = item.plural || logName;
}
if (quantityConsumed > 0) {
spendLog.push(logName + ': ' + quantityConsumed);
} else if (quantityConsumed < 0) {
gainLog.push(logName + ': ' + -quantityConsumed);
}
// So long as the item isn't an ancestor of the current prop apply it
// If it was an ancestor this would be an infinite loop
if (!hasAncestorRelationship(item, prop)) {
ammoToApply.push({
doc: {
...item,
// Use ammo pseudo-type
type: 'ammo',
// Store the adjustment to be applied
adjustment: quantityConsumed,
},
children: []
});
}
});
} catch (e) {
actionContext.addLog({
name: 'Error',
value: e.toString(),
});
console.error(e);
return true;
}
// No more errors should be thrown after this line
// Use uses
if (prop.usesLeft) {
CreatureProperties.update(prop._id, {
$inc: { usesUsed: 1 }
}, {
//@ts-expect-error no typings for collection 2 selector
selector: prop
});
if (!prop.silent) actionContext.addLog({
name: 'Uses left',
value: prop.usesLeft - 1,
inline: true,
});
}
// Damage stats
prop.resources.attributesConsumed.forEach(attConsumed => {
recalculateCalculation(attConsumed.quantity, actionContext);
if (!attConsumed.quantity?.value) return;
const quantityConsumed = +attConsumed.quantity.value;
if (!attConsumed.variableName) return;
const stat = actionContext.scope[attConsumed.variableName];
if (!stat) {
spendLog.push(attConsumed.variableName + ': ' + ' not found');
return;
}
damagePropertyWork({
prop: stat,
operation: 'increment',
value: attConsumed.quantity.value,
actionContext,
});
if (quantityConsumed > 0) {
spendLog.push(stat.name + ': ' + quantityConsumed);
} else if (quantityConsumed < 0) {
gainLog.push(stat.name + ': ' + -quantityConsumed);
}
});
// Apply the ammo children
for (const node of ammoToApply) {
await applyProperty(node, actionContext);
}
// Log all the spending
if (gainLog.length && !prop.silent) actionContext.addLog({
name: 'Gained',
value: gainLog.join('\n'),
inline: true,
});
if (spendLog.length && !prop.silent) actionContext.addLog({
name: 'Spent',
value: spendLog.join('\n'),
inline: true,
});
}

View File

@@ -1,56 +0,0 @@
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import recalculateCalculation from './shared/recalculateCalculation';
import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
export default function applyAdjustment(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.doc
const damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
if (!prop.amount) {
return applyChildren(node, actionContext);
}
// Evaluate the amount
recalculateCalculation(prop.amount, actionContext);
const value = +prop.amount.value;
if (!isFinite(value)) {
return applyChildren(node, actionContext);
}
if (damageTargets?.length) {
damageTargets.forEach(target => {
let stat = target.variables[prop.stat];
if (!stat?.type) {
if (!prop.silent) actionContext.addLog({
name: 'Error',
value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set`
});
return applyChildren(node, actionContext);
}
damagePropertyWork({
prop: stat,
operation: prop.operation,
value,
actionContext,
});
if (!prop.silent) actionContext.addLog({
name: 'Attribute damage',
value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
` ${value}`,
inline: true,
});
});
} else {
if (!prop.silent) actionContext.addLog({
name: 'Attribute damage',
value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
` ${value}`,
inline: true,
});
}
return applyChildren(node, actionContext);
}

View File

@@ -1,95 +0,0 @@
import applyProperty from '../applyProperty';
import recalculateCalculation from './shared/recalculateCalculation';
import rollDice from '/imports/parser/rollDice';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
export default async function applyBranch(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const scope = actionContext.scope;
const targets = actionContext.targets;
const prop = node.doc
switch (prop.branchType) {
case 'if':
recalculateCalculation(prop.condition, actionContext);
if (prop.condition?.value) applyChildren(node, actionContext);
break;
case 'index':
if (node.children.length) {
recalculateCalculation(prop.condition, actionContext);
if (!isFinite(prop.condition?.value)) {
actionContext.addLog({
name: 'Branch Error',
value: 'Index did not resolve into a valid number'
});
break;
}
let index = Math.floor(prop.condition?.value);
if (index < 1) index = 1;
if (index > node.children.length) index = node.children.length;
applyNodeTriggers(node, 'after', actionContext);
applyProperty(node.children[index - 1], actionContext);
applyNodeTriggers(node, 'afterChildren', actionContext);
}
break;
case 'hit':
if (scope['~attackHit']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On hit**' });
applyChildren(node, actionContext);
}
break;
case 'miss':
if (scope['~attackMiss']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On miss**' });
applyChildren(node, actionContext);
}
break;
case 'failedSave':
if (scope['~saveFailed']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On failed save**' });
applyChildren(node, actionContext);
}
break;
case 'successfulSave':
if (scope['~saveSucceeded']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On save**', });
applyChildren(node, actionContext);
}
break;
case 'random':
if (node.children.length) {
let index = rollDice(1, node.children.length)[0] - 1;
applyNodeTriggers(node, 'after', actionContext);
applyProperty(node.children[index], actionContext);
applyNodeTriggers(node, 'afterChildren', actionContext);
}
break;
case 'eachTarget':
if (targets.length) {
targets.forEach(target => {
applyNodeTriggers(node, 'after', actionContext);
actionContext.targets = [target]
node.children.forEach(child => applyProperty(child, actionContext));
applyNodeTriggers(node, 'afterChildren', actionContext);
});
} else {
applyChildren(node, actionContext);
}
break;
case 'choice': {
console.log('paused waiting for user input');
let { index } = await getUserInput({
index: 'number',
}, actionContext);
console.log('resuming with input ' + index);
if (!isFinite(index) || index < 0) index = 0;
if (index > node.children.length - 1) index = node.children.length - 1;
applyNodeTriggers(node, 'after', actionContext);
console.log('applying child ', index);
console.log(node.children[index]);
applyProperty(node.children[index], actionContext);
applyNodeTriggers(node, 'afterChildren', actionContext);
break;
}
}
}

View File

@@ -1,169 +0,0 @@
import {
renewDocIds,
} from '/imports/api/parenting/parentingFunctions';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex';
import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey';
import { get } from 'lodash';
import resolve, { map, toString } from '/imports/parser/resolve';
import accessor from '/imports/parser/parseTree/accessor';
import logErrors from './shared/logErrors';
import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs';
import cyrb53 from '/imports/api/engine/computation/utility/cyrb53';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX';
import recalculateInlineCalculations from './shared/recalculateInlineCalculations';
export default function applyBuff(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.doc
let buffTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
// Mark the buff as dirty for recalculation
prop.dirty = true;
// Then copy the descendants of the buff to the targets
let propList = [prop];
function addChildrenToPropList(children, { skipCrystalize } = { skipCrystalize: false }) {
children.forEach(child => {
if (skipCrystalize) child.node._skipCrystalize = true;
propList.push(child.node);
// recursively add the child's children, but don't crystalize nested buffs
addChildrenToPropList(child.children, {
skipCrystalize: skipCrystalize || child.node.type === 'buff'
});
});
}
addChildrenToPropList(node.children);
if (!prop.skipCrystalization) {
crystalizeVariables({ propList, actionContext });
}
buffTargets.forEach(target => {
const targetPropList = EJSON.clone(propList);
// Move the properties to the target by replacing the old subtree parent and root with the '
// target id
renewDocIds({
docArray: targetPropList,
idMap: {
[prop.parentId]: target._id,
[prop.root.id]: target._id,
},
collectionMap: { [prop.root.collection]: 'creatures' }
});
// Apply the buff
CreatureProperties.batchInsert(targetPropList);
//Log the buff
let logValue = prop.description?.value
if (prop.description?.text) {
recalculateInlineCalculations(prop.description, actionContext);
logValue = prop.description?.value;
}
if ((prop.name || prop.description?.value) && !prop.silent) {
if (target._id === actionContext.creature._id) {
// Targeting self
actionContext.addLog({
name: prop.name,
value: logValue,
});
} else {
// Targeting other
insertCreatureLog.call({
log: {
creatureId: target._id,
content: [{
name: prop.name,
value: logValue,
}],
}
});
}
}
});
applyNodeTriggers(node, 'after', actionContext);
applyNodeTriggers(node, 'afterChildren', actionContext);
// Don't apply the children of the buff, they get copied to the target instead
}
/**
* Replaces all variables with their resolved values
* except variables of the form `~target.thing.total` become `thing.total`
*/
function crystalizeVariables({ propList, actionContext }) {
propList.forEach(prop => {
if (prop._skipCrystalize) {
delete prop._skipCrystalize;
return;
}
// Iterate through all the calculations and crystalize them
computedSchemas[prop.type].computedFields().forEach(calcKey => {
applyFnToKey(prop, calcKey, (prop, key) => {
const calcObj = get(prop, key);
if (!calcObj?.parseNode) return;
calcObj.parseNode = map(calcObj.parseNode, node => {
// Skip nodes that aren't symbols or accessors
if (
node.parseType !== 'accessor'
) return node;
// Handle variables
if (node.name === '~target') {
// strip ~target
if (node.parseType === 'accessor') {
node.name = node.path.shift();
if (!node.path.length) {
return accessor.create({ name: node.name })
}
} else {
// Can't strip symbols
actionContext.addLog({
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, actionContext.scope);
logErrors(context.errors, actionContext);
return result;
}
});
calcObj.calculation = toString(calcObj.parseNode);
calcObj.hash = cyrb53(calcObj.calculation);
});
});
// For each key in the schema
computedSchemas[prop.type].inlineCalculationFields().forEach(calcKey => {
// That ends in .inlineCalculations
applyFnToKey(prop, calcKey, (prop, key) => {
const inlineCalcObj = get(prop, key);
if (!inlineCalcObj) return;
// If there is no text, skip
if (!inlineCalcObj.text) {
return;
}
// Replace all the existing calculations
let index = -1;
inlineCalcObj.text = inlineCalcObj.text.replace(INLINE_CALCULATION_REGEX, () => {
index += 1;
return `{${inlineCalcObj.inlineCalculations[index].calculation}}`;
});
// Set the value to the uncomputed string
inlineCalcObj.value = inlineCalcObj.text;
// Write a new hash
const inlineCalcHash = cyrb53(inlineCalcObj.text);
if (inlineCalcHash === inlineCalcObj.hash) {
// Skip if nothing changed
return;
}
inlineCalcObj.hash = inlineCalcHash;
});
});
});
}

View File

@@ -1,9 +0,0 @@
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
export default function applyFolder(node, actionContext) {
// Apply triggers
applyNodeTriggers(node, 'before', actionContext);
// Apply children
applyChildren(node, actionContext);
}

View File

@@ -1,42 +0,0 @@
import { getPropertyDescendants } from '/imports/api/engine/loadCreatures';
import applyProperty from '../applyProperty';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import { docsToForest as nodeArrayToTree } from '/imports/api/parenting/parentingFunctions';
import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/methods/adjustQuantity';
export default function applyItemAsAmmo(node, actionContext) {
// The item node should come without children, since it is not part of the original action tree
const prop = node.doc
// Get all the item's descendant properties
const properties = getPropertyDescendants(actionContext.creature._id, prop._id);
properties.sort((a, b) => a.order - b.order);
const propertyForest = nodeArrayToTree(properties);
// Apply the item
applyNodeTriggers(node, 'before', actionContext);
// Do the quantity adjustment
const itemProp = { ...prop, type: 'item' };
delete itemProp.adjustment;
adjustQuantityWork({
property: itemProp,
operation: 'increment',
value: prop.adjustment,
});
// Simulate the change to quantity
prop.quantity -= prop.adjustment;
// Log the item name as a heading if it's not silent and has child properties to apply
if (!prop.silent && propertyForest.length) {
actionContext.addLog({
name: prop.name || 'Ammo',
inline: false,
});
}
applyNodeTriggers(node, 'after', actionContext);
// Apply the item's children
propertyForest.forEach(node => applyProperty(node, actionContext));
applyNodeTriggers(node, 'afterChildren', actionContext);
}

View File

@@ -1,25 +0,0 @@
import recalculateInlineCalculations from './shared/recalculateInlineCalculations';
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
export default async function applyNote(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.doc
// Log Name, summary
let content = { name: prop.name };
if (prop.summary?.text) {
recalculateInlineCalculations(prop.summary, actionContext);
content.value = prop.summary.value;
}
if (content.name || content.value) {
actionContext.addLog(content);
}
// Log description
if (prop.description?.text) {
recalculateInlineCalculations(prop.description, actionContext);
actionContext.addLog({ value: prop.description.value });
}
// Apply children
await applyChildren(node, actionContext);
}

View File

@@ -1,56 +0,0 @@
import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren';
import logErrors from './shared/logErrors';
import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation';
import resolve, { toString } from '/imports/parser/resolve';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
export default function applyRoll(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.doc
if (prop.roll?.calculation) {
const logValue = [];
// roll the dice only and store that string
recalculateCalculation(prop.roll, actionContext, undefined, 'compile');
const { result: rolled, context } = resolve('roll', prop.roll.valueNode, actionContext.scope);
if (rolled.parseType !== 'constant') {
logValue.push(toString(rolled));
}
logErrors(context.errors, actionContext);
// Reset the errors so we don't log the same errors twice
context.errors = [];
// Resolve the roll to a final value
const { result: reduced } = resolve('reduce', rolled, actionContext.scope, context);
logErrors(context.errors, actionContext);
// Store the result
if (reduced.parseType === 'constant') {
prop.roll.value = reduced.value;
} else if (reduced.parseType === 'error') {
prop.roll.value = null;
} else {
prop.roll.value = toString(reduced);
}
// If we didn't end up with a constant or a number of finite value, give up
if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) {
return applyChildren(node, actionContext);
}
const value = reduced.value;
actionContext.scope[prop.variableName] = { value };
logValue.push(`**${value}**`);
if (!prop.silent) {
actionContext.addLog({
name: prop.name,
value: logValue.join('\n'),
inline: true,
});
}
}
return applyChildren(node, actionContext);
}

View File

@@ -1,10 +0,0 @@
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers';
import applyProperty from '/imports/api/engine/actions/applyProperty';
export default async function applyChildren(node, actionContext) {
applyNodeTriggers(node, 'after', actionContext);
for (const child of node.children) {
await applyProperty(child, actionContext);
}
applyNodeTriggers(node, 'afterChildren', actionContext);
}

View File

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

View File

@@ -1,67 +0,0 @@
import { triggerMatchTags } from '/imports/api/engine/actions/applyTriggers';
import clean from '/imports/api/engine/computation/utility/cleanProp.testFn';
import { assert } from 'chai';
export default function () {
const prop = clean({
id: 'propWithTags',
type: 'action',
tags: ['yes1', 'notUsed', 'no1', 'yes2', 'no2', 'or1', 'or2'],
});
const positiveProp = clean({
id: 'propWithTags',
type: 'action',
tags: ['yes1', 'notUsed', 'yes2', 'or1', 'or2'],
});
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1'],
}), prop),
'Trigger matches on a single target tag'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1', 'yes2'],
}), prop),
'Trigger matches on a multiple target tags'
);
assert.isFalse(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1'],
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), prop),
'Trigger correctly fails to match when not tags are present'
);
assert.isFalse(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), prop),
'Trigger correctly fails to match when only not tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), positiveProp),
'Trigger matches when only not tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'OR', tags: ['or1'] }]
}), positiveProp),
'Trigger matches when OR tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['missing1'],
extraTags: [{ operation: 'OR', tags: ['or1'] }]
}), positiveProp),
'Trigger matches when only OR tags are present'
);
}

View File

@@ -1,114 +0,0 @@
import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation';
import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations';
import { getPropertyDescendants } from '/imports/api/engine/loadCreatures';
import { TreeNode, docsToForest as nodeArrayToTree } from '/imports/api/parenting/parentingFunctions';
import applyProperty from '/imports/api/engine/actions/applyProperty';
import { difference, intersection } from 'lodash';
import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags';
export async function applyNodeTriggers(node: TreeNode<any>, timing, actionContext) {
const prop = node.doc;
const type = prop.type;
const triggers = actionContext.triggers?.doActionProperty?.[type]?.[timing];
if (triggers) {
for (const trigger of triggers) {
await applyTrigger(trigger, prop, actionContext);
}
}
}
export async function applyTriggers(triggers = [], prop, actionContext) {
// Apply the triggers
for (const trigger of triggers) {
await applyTrigger(trigger, prop, actionContext)
}
}
export async function applyTrigger(trigger, prop, actionContext) {
// If there is a prop we are applying the trigger from,
// don't fire if the tags don't match
if (prop && !triggerMatchTags(trigger, prop)) {
return;
}
// Prevent trigger from firing if it's inactive
if (trigger.inactive) {
return;
}
// Prevent triggers from firing if their condition is false
if (trigger.condition?.parseNode) {
recalculateCalculation(trigger.condition, actionContext);
if (!trigger.condition.value?.value) return;
}
// Prevent triggers from firing themselves in a loop
if (trigger.firing) {
/*
log.content.push({
name: trigger.name || 'Trigger',
value: 'Trigger can\'t fire itself',
inline: true,
});
*/
return;
}
trigger.firing = true;
// Fire the trigger
const content = {
name: trigger.name || 'Trigger',
value: trigger.description,
inline: false,
}
if (trigger.description?.text) {
recalculateInlineCalculations(trigger.description, actionContext);
content.value = trigger.description.value;
}
if (!trigger.silent) actionContext.addLog(content);
// Get all the trigger's properties and apply them
const properties = getPropertyDescendants(actionContext.creature._id, trigger._id);
properties.sort((a, b) => a.order - b.order);
const propertyForest = nodeArrayToTree(properties);
for (const node of propertyForest) {
await applyProperty(node, actionContext);
}
trigger.firing = false;
}
export function triggerMatchTags(trigger, prop) {
let matched = false;
const propTags = getEffectivePropTags(prop);
// Check the target tags
if (
!trigger.targetTags?.length ||
difference(trigger.targetTags, propTags).length === 0
) {
matched = true;
}
// Check the extra tags
if (trigger.extraTags) {
for (const extra of trigger.extraTags) {
if (extra.operation === 'OR') {
if (matched) break;
if (
!extra.tags.length ||
difference(extra.tags, propTags).length === 0
) {
matched = true;
}
} else if (extra.operation === 'NOT') {
if (
extra.tags.length &&
intersection(extra.tags, propTags).length > 0
) {
matched = false;
break;
}
}
}
}
return matched;
}

View File

@@ -1,111 +0,0 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions';
import { docsToForest } from '/imports/api/parenting/parentingFunctions';
import {
getPropertyAncestors, getPropertyDescendants
} from '/imports/api/engine/loadCreatures';
import Creatures from '/imports/api/creature/creatures/Creatures';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import applyProperty from './applyProperty';
import ActionContext from '/imports/api/engine/actions/ActionContext';
const doAction = new ValidatedMethod({
name: 'creatureProperties.doAction',
validate: new SimpleSchema({
actionId: SimpleSchema.RegEx.Id,
targetIds: {
type: Array,
defaultValue: [],
maxCount: 20,
optional: true,
},
'targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
scope: {
type: Object,
blackbox: true,
optional: true,
},
invocationId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
}
}).validator(),
applyOptions: {
throwStubExceptions: false,
},
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
async run({ actionId, targetIds = [], scope, invocationId }) {
console.log('do Action running');
// Get action context
const action = CreatureProperties.findOne(actionId);
if (!action) throw new Meteor.Error('not-found', 'The action was not found');
const creatureId = action.root.id;
const actionContext = new ActionContext(creatureId, targetIds, this);
// Check permissions
assertEditPermission(actionContext.creature, this.userId);
actionContext.targets.forEach(target => {
assertEditPermission(target, this.userId);
});
const ancestors = getPropertyAncestors(creatureId, action._id);
ancestors.sort((a, b) => a.order - b.order);
const properties = getPropertyDescendants(creatureId, action._id);
properties.push(action);
properties.sort((a, b) => a.order - b.order);
// Do the action
await doActionWork({ properties, ancestors, actionContext, methodScope: scope });
// Recompute all involved creatures
if (Meteor.isServer) {
Creatures.updateAsync({
_id: { $in: [creatureId, ...targetIds] }
}, {
$set: { dirty: true },
});
}
},
});
export default doAction;
export async function doActionWork({
properties, ancestors, actionContext, methodScope = {},
}) {
// get the docs
const ancestorScope = getAncestorScope(ancestors);
const propertyForest = docsToForest(properties);
if (propertyForest.length !== 1) {
throw new Meteor.Error(`The action has ${propertyForest.length} top level properties, expected 1`);
}
// Include the ancestry and method scope in the context scope
Object.assign(actionContext.scope, ancestorScope, methodScope);
// Apply the top level property, it is responsible for applying its children
// recursively
await applyProperty(propertyForest[0], actionContext);
// Insert the log
actionContext.writeLog();
}
// Assumes ancestors are in tree order already
function getAncestorScope(ancestors) {
const scope = {};
ancestors.forEach(prop => {
scope[`#${prop.type}`] = prop;
});
return scope;
}

View File

@@ -1,58 +0,0 @@
import '/imports/api/simpleSchemaConfig';
//import testTypes from './testTypes/index';
import applyTriggers from '/imports/api/engine/actions/applyTriggers.testFn';
import { doActionWork } from './doAction';
import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import Creatures from '/imports/api/creature/creatures/Creatures';
function cleanProp(prop) {
let schema = CreatureProperties.simpleSchema(prop);
return schema.clean(prop);
}
function cleanCreature(creature) {
let schema = Creatures.simpleSchema(creature);
return schema.clean(creature);
}
// Fake ActionContext to test actions with
const creatureId = 'actionTestCreatureId';
const creatureName = 'Action Test Creature';
const testActionContext = {
creature: cleanCreature({
_id: creatureId,
}),
log: CreatureLogSchema.clean({
creatureId: creatureId,
creatureName: creatureName,
}),
scope: {},
addLog(content) {
if (content.name || content.value) {
this.log.content.push(content);
}
},
writeLog: () => { },
}
const action = cleanProp({
type: 'action',
});
const actionAncestors = [];
describe('Do Action', function () {
it('Does an empty action', function () {
doActionWork({
properties: [action],
ancestors: actionAncestors,
actionContext: testActionContext,
methodScope: {},
});
});
//testTypes.forEach(test => it(test.text, test.fn));
});
describe('Action utility functions', function () {
it('Triggers match tags', applyTriggers);
})

View File

@@ -1,90 +0,0 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { set } from 'lodash';
// Reminder: throwStubExceptions: true is the default, and only
// possible when run() is not async
// For async run() stub exceptions never stop the client from sending
// the call to the server
// Dict of invocationId: {steps: {earlyAnswers, resolve, reject}}
// either resolve functions waiting for the user's input or early answers that were provided
// before the resolves could be set up
let userInputRequests = {};
let provideUserInput;
if (Meteor.isClient) {
provideUserInput = function (invocationId, step, answers, callback) {
Meteor.call('answerUserInputRequest', { invocationId, step, answers }, callback);
// Do the same work on the client without using a stub
answerInputRequestWork({ invocationId, step, answers });
}
}
export { userInputRequests, provideUserInput };
export default async function getUserInput(questions, actionContext) {
// get the invocation details from the action context
const invocationId = actionContext.invocationId;
const step = actionContext.userInputStep;
actionContext.userInputStep += 1; // increment userInput step every time
// If the answers are already waiting, just return them
if (userInputRequests[invocationId]?.[step]?.earlyAnswers) {
return userInputRequests[invocationId][step].earlyAnswers;
}
// On the client, store the questions to be answered
if (Meteor.isClient) {
set(userInputRequests, `${invocationId}[${step}]`, { questions });
}
// Create a place for the answers to go when they are provided
return new Promise((resolve, reject) => {
set(userInputRequests, `${invocationId}[${step}]`, { resolve, reject });
});
}
function answerInputRequestWork({ invocationId, step, answers }) {
console.log('running answerUserInputRequest');
const invocation = userInputRequests[invocationId];
if (!invocation) {
// Call order on the server is guaranteed, so the invocation must have been created
// Before we can update it
throw new Meteor.Error('Not found', 'The method this answer is updating does not exist');
}
if (invocation[step]?.resolve) {
// If there is a resolve waiting for this response, provide it
invocation[step].resolve(answers);
} else {
// Otherwise just store the response as early answers
invocation[step] = {
earlyAnswers: answers
};
}
}
if (Meteor.isServer) {
// This function is not defined on the client so that it has no stub function
// This allows it to be called while still simulating an awaited async method
// See https://guide.meteor.com/2.8-migration.html#the-limitations
new ValidatedMethod({
name: 'answerUserInputRequest',
validate: new SimpleSchema({
invocationId: SimpleSchema.RegEx.Id,
step: SimpleSchema.Integer,
answers: {
type: Object,
blackbox: true,
},
}).validator(),
applyOptions: {
throwStubExceptions: false,
},
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 20,
timeInterval: 5000,
},
run: answerInputRequestWork,
});
}

View File

@@ -1,2 +0,0 @@
import './doCastSpell';
import './doCheck';

View File

@@ -163,12 +163,13 @@ export function getPropertyDescendants(creatureId, propertyId) {
if (
prop.left > property.left
&& prop.right < property.right
&& prop.removed !== true
) {
props.push(prop);
}
}
const cloneProps = EJSON.clone(props);
return cloneProps
const cloneProps = EJSON.clone(props).sort((a, b) => a.left - b.left);
return cloneProps;
} else {
return CreatureProperties.find({
...getFilter.descendants(property),

View File

@@ -315,13 +315,13 @@ export function renewDocIds({ docArray, collectionMap = {}, idMap = {} }) {
// Give new ids and map the changes as {oldId: newId}
docArray.forEach(doc => {
const oldId = doc._id;
const newId = idMap[oldId] || randomSrc.id();
const newId = oldId in idMap ? idMap[oldId] : randomSrc.id();
doc._id = newId;
idMap[oldId] = newId;
});
// Get the id from the map if it exists, leave unchanged otherwise
const remap = id => idMap[id] || id
const remap = id => id in idMap ? idMap[id] : id
// If there are references by id that need to be maintained when copying from
// a library, here is where we would update them

View File

@@ -0,0 +1,6 @@
import { getPropertyName } from '/imports/constants/PROPERTIES';
export default function getPropertyTitle(prop) {
if (prop.name) return prop.name;
return getPropertyName(prop.type);
}

View File

@@ -117,7 +117,7 @@
<script lang="js">
import { getPropertyName } from '/imports/constants/PROPERTIES';
import numberToSignedString from '../../../../../api/utility/numberToSignedString';
import doAction from '/imports/api/engine/actions/doAction';
//TODO import doAction from '/imports/api/engine/actions/doAction';
import ActionConditionView from '/imports/client/ui/properties/components/actions/ActionConditionView.vue';
import AttributeConsumedView from '/imports/client/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/client/ui/properties/components/actions/ItemConsumedView.vue';

View File

@@ -21,7 +21,7 @@
</template>
<script lang="js">
import doAction from '/imports/api/engine/actions/doAction';
//TODO import doAction from '/imports/api/engine/actions/doAction';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';

View File

@@ -153,3 +153,4 @@ export default {
min-width: 42px;
}
</style>
../../../../../api/engine/action/methods/doCheck

View File

@@ -106,4 +106,4 @@ export default {
min-width: 72px;
justify-content: center;
}
</style>
</style>../../../../../api/engine/action/methods/doCheck

View File

@@ -84,4 +84,4 @@ export default {
},
}
}
</script>
</script>../../../../../api/engine/action/methods/doCastSpell

View File

@@ -138,3 +138,4 @@ export default {
color: rgba(0, 0, 0, 0.54) !important;
}
</style>
../../../../../api/engine/action/methods/doCheck

View File

@@ -119,7 +119,7 @@
<script lang="js">
import propertyViewerMixin from '/imports/client/ui/properties/viewers/shared/propertyViewerMixin';
import doAction from '/imports/api/engine/actions/doAction';
//TODO import doAction from '/imports/api/engine/actions/doAction';
import ActionConditionView from '/imports/client/ui/properties/components/actions/ActionConditionView.vue';
import AttributeConsumedView from '/imports/client/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/client/ui/properties/components/actions/ItemConsumedView.vue';
@@ -254,3 +254,4 @@ export default {
height: 40px;
}
</style>
../../../../api/engine/action/methods/doCastSpell

View File

@@ -153,7 +153,7 @@
<script lang="js">
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import numberToSignedString from '/imports/api/utility/numberToSignedString.js';
import doAction from '/imports/api/engine/actions/doAction.js';
//TODO import doAction from '/imports/api/engine/actions/doAction.js';
import AttributeConsumedView from '/imports/client/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/client/ui/properties/components/actions/ItemConsumedView.vue';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';

View File

@@ -93,7 +93,7 @@ const accessor = {
}
},
toString(node) {
if (!node.path) return `${node.name}`;
if (!node.path?.length) return `${node.name}`;
return `${node.name}.${node.path.join('.')}`;
}
}