Tested and fixed branches and notes

in new action interrupt system
This commit is contained in:
ThaumRystra
2023-11-16 22:21:48 +02:00
parent 375a84226d
commit 581f99d467
3 changed files with 339 additions and 79 deletions

View File

@@ -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;
},
}

View File

@@ -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() {

View File

@@ -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) {