Tested and fixed branches and notes
in new action interrupt system
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import SimpleSchema from 'simpl-schema';
|
||||
import { forEach, isEmpty } from 'lodash';
|
||||
import { forEach, isEmpty, pick } from 'lodash';
|
||||
import LogContentSchema from '/imports/api/creature/log/LogContentSchema';
|
||||
import { getPropertyChildren, getSingleProperty } from '/imports/api/engine/loadCreatures';
|
||||
import { getPropertyChildren, getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures';
|
||||
import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations';
|
||||
import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation';
|
||||
import rollDice from '/imports/parser/rollDice';
|
||||
|
||||
const Actions = new Mongo.Collection<ActionWithId>('actions');
|
||||
|
||||
@@ -10,7 +12,7 @@ interface Action {
|
||||
creatureId: string;
|
||||
rootPropId: string;
|
||||
targetIds?: string[];
|
||||
userInputNeeded?: boolean;
|
||||
userInputNeeded?: any;
|
||||
stepThrough?: boolean;
|
||||
taskQueue: Task[];
|
||||
results: TaskResult[];
|
||||
@@ -32,9 +34,24 @@ type TaskResult = {
|
||||
mutations: Mutation[];
|
||||
}
|
||||
|
||||
type PartialTaskResult = {
|
||||
class PartialTaskResult {
|
||||
scope: any;
|
||||
mutations: Mutation[];
|
||||
constructor() {
|
||||
this.scope = {};
|
||||
this.mutations = [];
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
type Mutation = {
|
||||
@@ -74,8 +91,9 @@ const ActionSchema = new SimpleSchema({
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
},
|
||||
userInputNeeded: {
|
||||
type: Boolean,
|
||||
defaultValue: false,
|
||||
type: Object,
|
||||
optional: true,
|
||||
blackbox: true,
|
||||
},
|
||||
stepThrough: {
|
||||
type: Boolean,
|
||||
@@ -115,7 +133,8 @@ const ActionSchema = new SimpleSchema({
|
||||
// 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: Object,
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
},
|
||||
'results.$.targetIds': {
|
||||
type: Array,
|
||||
@@ -139,16 +158,34 @@ const ActionSchema = new SimpleSchema({
|
||||
'results.$.mutations.$': {
|
||||
type: Object,
|
||||
},
|
||||
'results.$.mutations.$.propId': {
|
||||
'results.$.mutations.$.targetIds': {
|
||||
type: Array,
|
||||
},
|
||||
'results.$.mutations.$.targetIds.$': {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
},
|
||||
'results.$.mutations.$.set': {
|
||||
'results.$.mutations.$.updates': {
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'results.$.mutations.$.updates.$': {
|
||||
type: Object,
|
||||
},
|
||||
'results.$.mutations.$.updates.$.propId': {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
},
|
||||
'results.$.mutations.$.updates.$.set': {
|
||||
type: Object,
|
||||
optional: true,
|
||||
blackbox: true,
|
||||
},
|
||||
'results.$.mutations.$.logContent': {
|
||||
'results.$.mutations.$.contents': {
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'results.$.mutations.$.contents.$': {
|
||||
type: LogContentSchema,
|
||||
},
|
||||
});
|
||||
@@ -173,22 +210,52 @@ export function createAction(prop) {
|
||||
}
|
||||
|
||||
// Run an already created action
|
||||
export async function runAction(actionId: string, userInput) {
|
||||
export async function runAction(actionId: string, userInput?) {
|
||||
const action = await Actions.findOneAsync(actionId);
|
||||
if (!action) throw new Meteor.Error('Not found', 'The action does not exist');
|
||||
const originalAction = EJSON.clone(action);
|
||||
let count = 0;
|
||||
do {
|
||||
// Get the next task
|
||||
const task = action.taskQueue.shift();
|
||||
// If there isn't one, stop
|
||||
if (!task) break;
|
||||
|
||||
// Apply the prop
|
||||
await applyProperty(task, action, userInput);
|
||||
} while (!action.userInputNeeded || !action.stepThrough)
|
||||
// If there isn't a next task, stop
|
||||
if (!action.taskQueue.length) break;
|
||||
await applyNextTask(action, userInput);
|
||||
count += 1;
|
||||
if (count > 100) {
|
||||
break;
|
||||
}
|
||||
} while (!action.userInputNeeded && !action.stepThrough)
|
||||
|
||||
// Persist changes to the action
|
||||
return writeChangedAction(originalAction, action);
|
||||
const writePromise = writeChangedAction(originalAction, action);
|
||||
if (count > 100) {
|
||||
throw new Meteor.Error('Too many properties', 'Only 100 properties may fire at a time');
|
||||
}
|
||||
return writePromise;
|
||||
}
|
||||
|
||||
async function applyNextTask(action, userInput?) {
|
||||
// Get the next task
|
||||
const task = action.taskQueue[0];
|
||||
// Ensure the prop exists
|
||||
const prop = await getSingleProperty(action.creatureId, task.propId);
|
||||
if (!prop) throw new Meteor.Error('Not found', 'Property could not be found');
|
||||
if (prop.deactivatedByToggle) return;
|
||||
|
||||
// Apply the property
|
||||
const result: TaskResult | undefined = await applyPropertyByType[prop.type]?.(prop, task, action, userInput);
|
||||
|
||||
if (result) {
|
||||
// There was a result, we can remove this task from the queue
|
||||
action.taskQueue.shift();
|
||||
// store the task's details and save the result
|
||||
result.scope[`#${prop.type}`] = prop;
|
||||
result.propId = task.propId;
|
||||
result.targetIds = task.targetIds;
|
||||
action.results.push(result);
|
||||
} else if (!action.userInputNeeded) {
|
||||
// Prevent accidental infinite loops if we don't remove the task, but also don't break for input
|
||||
throw 'The only time result can be undefined is if we are waiting for user input';
|
||||
}
|
||||
}
|
||||
|
||||
function writeChangedAction(original: ActionWithId, changed: ActionWithId) {
|
||||
@@ -203,21 +270,6 @@ function writeChangedAction(original: ActionWithId, changed: ActionWithId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProperty(task, action, userInput) {
|
||||
// Ensure the prop exists
|
||||
const prop = await getSingleProperty(action.creatureId, task.propId);
|
||||
if (!prop) throw new Meteor.Error('Not found', 'Property could not be found');
|
||||
if (prop.deactivatedByToggle) return;
|
||||
|
||||
// Apply the property
|
||||
const { result }: { result: TaskResult } = await applyPropertyByType[prop.type]?.(prop, task, action, userInput);
|
||||
|
||||
// store the task's details and save the result
|
||||
result.propId = task.propId;
|
||||
result.targetIds = task.targetIds;
|
||||
action.results.push(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a prop and its before/after triggers to the task stack
|
||||
* Triggers will share the same targetIds as the prop task
|
||||
@@ -241,43 +293,45 @@ function pushPropAndTriggers(action: Action, prop, targetIds?) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Push all the children of a prop and all related triggers to the task stack
|
||||
* Triggers will share the same targetIds as the prop task
|
||||
* Push all the children of a prop and all trigger of those children to the task queue
|
||||
* @param action The action to add the task to
|
||||
* @param prop The property to make a task of
|
||||
* @param targetIds The targetIds the prop and triggers will apply to
|
||||
*/
|
||||
async function pushChildrenAndTriggers(action: Action, prop, targetIds) {
|
||||
async function pushChildren(action: Action, prop, targetIds) {
|
||||
const children = await getPropertyChildren(action.creatureId, prop._id);
|
||||
|
||||
// Push the child tasks and related triggers to the stack
|
||||
forEach(children, childProp => {
|
||||
pushPropAndTriggers(action, childProp, targetIds)
|
||||
pushPropAndTriggers(action, childProp, targetIds);
|
||||
});
|
||||
}
|
||||
|
||||
// After the children run, it must run 'afterChildren' triggers
|
||||
// Make sure they're on the bottom of the stack
|
||||
function pushAfterChildrenTriggers(action: Action, prop, targetIds) {
|
||||
forEach(prop.triggerIds?.afterChildren, triggerId => {
|
||||
action.taskQueue.push({ propId: triggerId, targetIds });
|
||||
});
|
||||
}
|
||||
|
||||
function createResult(prop): PartialTaskResult {
|
||||
function createResult(): PartialTaskResult {
|
||||
// Add the property to the action's local scope
|
||||
return {
|
||||
scope: {
|
||||
[`#${prop.type}`]: { _propId: prop._id }
|
||||
},
|
||||
mutations: [],
|
||||
};
|
||||
return new PartialTaskResult();
|
||||
}
|
||||
|
||||
// Combine all the action results into the scope at present
|
||||
export function getEffectiveActionScope(action) {
|
||||
const scope = getVariables(action.creatureId);
|
||||
for (const result of action.results) {
|
||||
Object.assign(scope, result.scope);
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
// Return result object
|
||||
// No side effects except pushing to taskQueue
|
||||
const applyPropertyByType = {
|
||||
|
||||
async note(prop, task: Task, action) {
|
||||
const result = createResult(prop);
|
||||
async note(prop, task: Task, action: Action) {
|
||||
const result = createResult();
|
||||
|
||||
let contents: LogContent[] | undefined = undefined;
|
||||
const logContent = { name: prop.name, value: undefined };
|
||||
@@ -302,9 +356,134 @@ const applyPropertyByType = {
|
||||
});
|
||||
}
|
||||
|
||||
await pushChildrenAndTriggers(action, prop, task.targetIds);
|
||||
await pushChildren(action, prop, task.targetIds);
|
||||
await pushAfterChildrenTriggers(action, prop, task.targetIds);
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
async branch(prop, task: Task, action: Action, userInput) {
|
||||
// const scope = getEffectiveActionScope(action);
|
||||
const result = createResult();
|
||||
const targets = task.targetIds;
|
||||
|
||||
switch (prop.branchType) {
|
||||
case 'if': {
|
||||
recalculateCalculation(prop.condition, action, 'reduce');
|
||||
if (prop.condition?.value) {
|
||||
await pushChildren(action, prop, targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'index': {
|
||||
const children = await getPropertyChildren(action.creatureId, prop._id);
|
||||
if (children.length) {
|
||||
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);
|
||||
break;
|
||||
}
|
||||
let index = Math.floor(prop.condition?.value);
|
||||
if (index < 1) index = 1;
|
||||
if (index > children.length) index = children.length;
|
||||
pushPropAndTriggers(action, children[index - 1], targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'hit': {
|
||||
const scope = getEffectiveActionScope(action);
|
||||
if (scope['~attackHit']?.value) {
|
||||
if (!targets.length && !prop.silent) {
|
||||
result.appendLog({
|
||||
value: '**On hit**'
|
||||
}, targets);
|
||||
}
|
||||
await pushChildren(action, prop, targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'miss': {
|
||||
const scope = getEffectiveActionScope(action);
|
||||
if (scope['~attackMiss']?.value) {
|
||||
if (!targets.length && !prop.silent) {
|
||||
result.appendLog({
|
||||
value: '**On miss**'
|
||||
}, targets);
|
||||
}
|
||||
await pushChildren(action, prop, targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'failedSave': {
|
||||
const scope = getEffectiveActionScope(action);
|
||||
if (scope['~saveFailed']?.value) {
|
||||
if (!targets.length && !prop.silent) {
|
||||
result.appendLog({
|
||||
value: '**On failed save**'
|
||||
}, targets);
|
||||
}
|
||||
await pushChildren(action, prop, targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'successfulSave': {
|
||||
const scope = getEffectiveActionScope(action);
|
||||
if (scope['~saveSucceeded']?.value) {
|
||||
if (!targets.length && !prop.silent) {
|
||||
result.appendLog({
|
||||
value: '**On save**'
|
||||
}, targets);
|
||||
}
|
||||
await pushChildren(action, prop, targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'random': {
|
||||
const children = await getPropertyChildren(action.creatureId, prop._id);
|
||||
if (children.length) {
|
||||
const index = rollDice(1, children.length)[0] - 1;
|
||||
pushPropAndTriggers(action, children[index], targets);
|
||||
}
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
case 'eachTarget':
|
||||
if (targets.length) {
|
||||
for (const targetId in targets) {
|
||||
await pushChildren(action, prop, [targetId]);
|
||||
pushAfterChildrenTriggers(action, prop, [targetId]);
|
||||
}
|
||||
} else {
|
||||
await pushChildren(action, prop, targets);
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
}
|
||||
break;
|
||||
case 'choice': {
|
||||
// If there is no input to consume, return no result, but mark the action as requiring input
|
||||
if (!userInput) {
|
||||
action.userInputNeeded = pick(prop, ['_id', 'type', 'branchType']);
|
||||
return;
|
||||
}
|
||||
const children = await getPropertyChildren(action.creatureId, prop._id);
|
||||
let index = userInput.choice;
|
||||
if (!isFinite(index) || index < 0) index = 0;
|
||||
if (index > children.length - 1) index = children.length - 1;
|
||||
pushPropAndTriggers(action, children[index], targets);
|
||||
pushAfterChildrenTriggers(action, prop, targets);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -4,37 +4,116 @@ 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, { createAction } from '/imports/api/engine/actions/Actions';
|
||||
import Actions, { createAction, runAction } from '/imports/api/engine/actions/Actions';
|
||||
import computeCreature from '/imports/api/engine/computeCreature';
|
||||
|
||||
describe('Interrupt action system', async function () {
|
||||
CreatureProperties.remove({});
|
||||
Creatures.remove({});
|
||||
CreatureVariables.remove({});
|
||||
const creatureId = await Creatures.insertAsync({
|
||||
name: 'action test creature',
|
||||
owner: Random.id(),
|
||||
let creatureId;
|
||||
|
||||
describe('Interrupt action system', function () {
|
||||
before(async function () {
|
||||
CreatureProperties.remove({});
|
||||
Creatures.remove({});
|
||||
CreatureVariables.remove({});
|
||||
creatureId = await Creatures.insertAsync({
|
||||
name: 'action test creature',
|
||||
owner: Random.id(),
|
||||
dirty: true,
|
||||
});
|
||||
await insertActionTestProps();
|
||||
computeCreature(creatureId);
|
||||
});
|
||||
await insertActionTestProps();
|
||||
|
||||
it('creates an action', async function () {
|
||||
const note1 = await CreatureProperties.findOneAsync(note1Id);
|
||||
const actionId = await createAction(note1);
|
||||
const action = await Actions.findOneAsync(actionId);
|
||||
console.log(action);
|
||||
it('writes notes to the log', async function () {
|
||||
assert.equal(
|
||||
await testRunActionById(note1Id),
|
||||
'Note 1 summary. 1 + 1 = 2'
|
||||
);
|
||||
});
|
||||
it('Applies the children of if branches', async function () {
|
||||
assert.equal(
|
||||
await testRunActionById(ifTruthyBranchId),
|
||||
'child of if branch'
|
||||
);
|
||||
assert.isUndefined(
|
||||
await testRunActionById(ifFalsyBranchId)
|
||||
);
|
||||
});
|
||||
it('Applies the children of index branches', async function () {
|
||||
assert.equal(
|
||||
await testRunActionById(indexBranchId),
|
||||
'child 2 of index branch'
|
||||
);
|
||||
});
|
||||
it('Halts execution of choice branches', async function () {
|
||||
const action = await runActionById(choiceBranchId);
|
||||
if (!action) throw 'Action is expected to exist';
|
||||
assert.isUndefined(action.results[0]);
|
||||
assert.exists(action.userInputNeeded);
|
||||
});
|
||||
});
|
||||
|
||||
const creatureId = Random.id();
|
||||
const note1Id = Random.id();
|
||||
async function runActionById(propId) {
|
||||
const prop = await CreatureProperties.findOneAsync(propId);
|
||||
const actionId = await createAction(prop);
|
||||
await runAction(actionId);
|
||||
const action = await Actions.findOneAsync(actionId);
|
||||
return action;
|
||||
}
|
||||
|
||||
async function testRunActionById(propId) {
|
||||
const action = await runActionById(propId);
|
||||
return action?.results?.[action.results.length - 1]?.mutations?.[0]?.contents?.[0]?.value;
|
||||
}
|
||||
|
||||
let note1Id, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId;
|
||||
|
||||
const propForest = [
|
||||
// Apply a simple note
|
||||
{
|
||||
_id: note1Id,
|
||||
_id: note1Id = Random.id(),
|
||||
type: 'note',
|
||||
summary: {
|
||||
text: 'Note 1 summary. 1 + 1 = {1 + 1}'
|
||||
},
|
||||
}
|
||||
},
|
||||
// Apply an if branch with a truthy condition
|
||||
{
|
||||
_id: ifTruthyBranchId = Random.id(),
|
||||
type: 'branch',
|
||||
branchType: 'if',
|
||||
condition: { calculation: '1 + 1' },
|
||||
children: [{ type: 'note', summary: { text: 'child of if branch' } }],
|
||||
},
|
||||
// Apply an if branch with a falsy condition
|
||||
{
|
||||
_id: ifFalsyBranchId = Random.id(),
|
||||
type: 'branch',
|
||||
branchType: 'if',
|
||||
condition: { calculation: '1 - 1' },
|
||||
children: [{ type: 'note', summary: { text: 'child of if branch' } }],
|
||||
},
|
||||
// Apply an index branch
|
||||
{
|
||||
_id: indexBranchId = Random.id(),
|
||||
type: 'branch',
|
||||
branchType: 'index',
|
||||
condition: { calculation: '1 + 1' },
|
||||
children: [
|
||||
{ type: 'note', summary: { text: 'child 1 of index branch' } },
|
||||
{ type: 'note', summary: { text: 'child 2 of index branch' } },
|
||||
{ type: 'note', summary: { text: 'child 3 of index branch' } },
|
||||
],
|
||||
},
|
||||
// Apply a choice branch
|
||||
{
|
||||
_id: choiceBranchId = Random.id(),
|
||||
type: 'branch',
|
||||
branchType: 'choice',
|
||||
children: [
|
||||
{ type: 'note', summary: { text: 'child 1 of choice branch' } },
|
||||
{ type: 'note', summary: { text: 'child 2 of choice branch' } },
|
||||
{ type: 'note', summary: { text: 'child 3 of choice branch' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function insertActionTestProps() {
|
||||
|
||||
@@ -7,14 +7,16 @@ import {
|
||||
} from '/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js';
|
||||
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
|
||||
import resolve from '/imports/parser/resolve.js';
|
||||
import { getEffectiveActionScope } from '/imports/api/engine/actions/Actions';
|
||||
|
||||
// Redo the work of imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js
|
||||
// But in the action scope
|
||||
export default function recalculateCalculation(calcObj, actionContext, parseLevel = 'reduce', context) {
|
||||
export default function recalculateCalculation(calcObj, action, parseLevel = 'reduce', context) {
|
||||
if (!calcObj?.parseNode) return;
|
||||
calcObj._parseLevel = parseLevel;
|
||||
const scope = getEffectiveActionScope(action);
|
||||
// Re-resolve the parse node
|
||||
resolveCalculationNode(calcObj, calcObj.parseNode, actionContext.scope, context);
|
||||
resolveCalculationNode(calcObj, calcObj.parseNode, scope, context);
|
||||
// store the unaffected value
|
||||
if (calcObj.effectIds || calcObj.proficiencyIds) {
|
||||
calcObj.unaffected = toPrimitiveOrString(calcObj.valueNode);
|
||||
@@ -22,20 +24,20 @@ export default function recalculateCalculation(calcObj, actionContext, parseLeve
|
||||
// Apply all the effects and proficiencies
|
||||
aggregateCalculationEffects(
|
||||
calcObj,
|
||||
id => getSingleProperty(actionContext.creature._id, id)
|
||||
id => getSingleProperty(action.creatureId, id)
|
||||
);
|
||||
aggregateCalculationProficiencies(
|
||||
calcObj,
|
||||
id => getSingleProperty(actionContext.creature._id, id),
|
||||
actionContext.scope['proficiencyBonus']?.value || 0
|
||||
id => getSingleProperty(action.creatureId, id),
|
||||
scope['proficiencyBonus']?.value || 0
|
||||
);
|
||||
// Resolve the modified valueNode
|
||||
resolveCalculationNode(calcObj, calcObj.valueNode, actionContext.scope, context);
|
||||
resolveCalculationNode(calcObj, calcObj.valueNode, scope, context);
|
||||
|
||||
// Store the primitive value
|
||||
calcObj.value = toPrimitiveOrString(calcObj.valueNode);
|
||||
|
||||
logErrors(calcObj.errors, actionContext);
|
||||
logErrors(calcObj.errors, action);
|
||||
}
|
||||
|
||||
export function rollAndReduceCalculation(calcObj, actionContext, context) {
|
||||
|
||||
Reference in New Issue
Block a user