Implementing persisting action result mutations

This commit is contained in:
Thaum Rystra
2024-04-02 17:46:31 +02:00
parent 2cbfc5d099
commit 1fb1eb83c7
15 changed files with 285 additions and 144 deletions

View File

@@ -9,7 +9,6 @@ import { parse, prettifyParseError } from '/imports/parser/parser';
import resolve from '/imports/parser/resolve';
import toString from '/imports/parser/toString';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS';
import { assertUserInTabletop } from '/imports/api/tabletop/methods/shared/tabletopPermissions.js';
const PER_CREATURE_LOG_LIMIT = 100;
@@ -39,17 +38,20 @@ let CreatureLogSchema = new SimpleSchema({
},
index: 1,
},
// The acting creature initiating the logged events
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
// creatures targeted by any of the logged events
targetIds: {
type: Array,
optional: true,
},
'targetIds.$': {
type: String,
index: 1,
},
creatureName: {
type: String,
optional: true,
@@ -137,7 +139,7 @@ const insertCreatureLog = new ValidatedMethod({
},
});
export function insertCreatureLogWork({ log, creature, tabletopId, method }) {
export function insertCreatureLogWork({ log, creature, method }) {
// Build the new log
if (typeof log === 'string') {
log = { content: [{ value: log }] };
@@ -151,7 +153,6 @@ export function insertCreatureLogWork({ log, creature, tabletopId, method }) {
}
});
log.date = new Date();
if (tabletopId) log.tabletopId = tabletopId;
if (creature && creature.tabletop) log.tabletopId = creature.tabletop;
// Insert it
let id = CreatureLogs.insert(log);
@@ -186,20 +187,15 @@ const logRoll = new ValidatedMethod({
roll: {
type: String,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
}).validator(),
async run({ roll, tabletopId, creatureId }) {
if (!creatureId && !tabletopId) throw new Meteor.Error('no-id',
'A creature id or tabletop id must be given'
async run({ roll, creatureId }) {
if (!creatureId) throw new Meteor.Error('no-id',
'A creature id must be given'
);
let creature;
if (creatureId) {
@@ -215,9 +211,6 @@ const logRoll = new ValidatedMethod({
});
assertEditPermission(creature, this.userId);
}
if (tabletopId) {
assertUserInTabletop(tabletopId, this.userId);
}
const variables = CreatureVariables.findOne({ _creatureId: creatureId }) || {};
let logContent = []
let parsedResult = undefined;
@@ -259,7 +252,7 @@ const logRoll = new ValidatedMethod({
date: new Date(),
};
let id = insertCreatureLogWork({ log, creature, tabletopId, method: this });
let id = insertCreatureLogWork({ log, creature, method: this });
return id;
},

View File

@@ -62,6 +62,13 @@ let LogContentSchema = new SimpleSchema({
type: Boolean,
optional: true,
},
targetIds: {
type: Array,
optional: true,
},
'targetIds.$': {
type: String,
}
});
export default LogContentSchema;

View File

@@ -1,7 +1,6 @@
import SimpleSchema from 'simpl-schema';
import TaskResult from './tasks/TaskResult';
import LogContentSchema from '/imports/api/creature/log/LogContentSchema';
import { Mongo } from 'meteor/mongo';
const EngineActions = new Mongo.Collection<EngineAction>('actions');
@@ -34,11 +33,6 @@ const ActionSchema = new SimpleSchema({
type: String,
regEx: SimpleSchema.RegEx.Id,
},
userInputNeeded: {
type: Object,
optional: true,
blackbox: true,
},
// Applied properties
results: {

View File

@@ -3,16 +3,24 @@ import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import applyTask from '/imports/api/engine/action/tasks/applyTask'
import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider';
import saveInputChoices from './userInput/saveInputChoices';
import Task from '/imports/api/engine/action/tasks/Task';
// 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
/**
* 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
* @param action The action to apply
* @param userInput The input provider
* @param { Object } options
* @param { Task } options.task If provided, the action will start with this task instead of
* applying the root property of the action
*/
export default async function applyAction(action: EngineAction, userInput: InputProvider, options?: {
simulate?: boolean, stepThrough?: boolean
simulate?: boolean, stepThrough?: boolean, task?: Task,
}) {
const { simulate, stepThrough } = options || {};
if (!simulate && stepThrough) throw 'Cannot step through unless simulating';
@@ -31,12 +39,15 @@ export default async function applyAction(action: EngineAction, userInput: Input
action._stepThrough = stepThrough;
action._isSimulation = simulate;
action.taskCount = 0;
console.log(JSON.stringify(action, null, 2));
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 };
let task = options?.task;
if (!task) {
const prop = await getSingleProperty(action.creatureId, action.rootPropId);
if (!prop) throw new Meteor.Error('Not found', 'Root action property could not be found');
task = options?.task || {
prop,
targetIds: action.targetIds || [],
}
}
await applyTask(action, task, userInput);
return action;
}

View File

@@ -0,0 +1,13 @@
import { Mutation } from '/imports/api/engine/action/tasks/TaskResult';
export default function mutationToLogUpdates(mutation: Mutation) {
if (!mutation.contents) return [];
const contents: any[] = [];
for (const content of mutation.contents) {
contents.push({
...content,
targetIds: mutation.targetIds,
});
}
return contents;
}

View File

@@ -0,0 +1,43 @@
import { Mutation } from '/imports/api/engine/action/tasks/TaskResult';
import { newOperation } from '/imports/api/engine/shared/bulkWrite';
export default function mutationToPropUpdates(mutation: Mutation) {
const bulkWriteOps: any[] = [];
// Updates to creature properties
if (mutation.updates) {
const propUpdatesById: Record<string, any> = {};
for (const update of mutation.updates) {
if (!propUpdatesById[update.propId]) {
propUpdatesById[update.propId] = newOperation(update.propId);
}
if (update.set) {
propUpdatesById[update.propId].updateOne.update.$set = {
...propUpdatesById[update.propId].updateOne.update.$set,
...update.set,
};
}
if (update.inc) {
propUpdatesById[update.propId].updateOne.update.$inc = {
...propUpdatesById[update.propId].updateOne.update.$inc,
...update.inc,
};
}
}
for (const id in propUpdatesById) {
bulkWriteOps.push(propUpdatesById[id]);
}
}
// Insert creature properties
if (mutation.inserts) for (const insertOne of mutation.inserts) {
bulkWriteOps.push({
insertOne
});
}
// Remove creature properties
if (mutation.removals) for (const removeOne of mutation.removals) {
bulkWriteOps.push({
removeOne,
});
}
return bulkWriteOps;
}

View File

@@ -0,0 +1,34 @@
import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions';
import mutationToPropUpdates from './mutationToPropUpdates';
import mutationToLogUpdates from '/imports/api/engine/action/functions/mutationToLogUpdates';
import { union } from 'lodash';
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
import bulkWrite from '/imports/api/engine/shared/bulkWrite';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
export default async function writeActionResults(action: EngineAction) {
if (!action._id) throw new Meteor.Error('type-error', 'Action does not have an _id');
EngineActions.remove(action._id);
const creaturePropUpdates: any[] = [];
const logContents: any[] = [];
// Collect all the updates and log content
action.results.forEach(result => {
result.mutations.forEach(mutation => {
creaturePropUpdates.push(...mutationToPropUpdates(mutation));
logContents.push(...mutationToLogUpdates(mutation));
});
});
const allTargetIds = union(...logContents.map(c => c.targetIds));
// Write the log
const logPromise = CreatureLogs.insertAsync({
content: logContents,
creatureId: action.creatureId,
targetIds: allTargetIds,
});
// Write the bulk updates
const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties);
return Promise.all([logPromise, bulkWritePromise]);
}

View File

@@ -1,14 +0,0 @@
import { isEmpty } from 'lodash'
import EngineActions, { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions';
export default async 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 EngineActions.updateAsync(original._id, { $set });
}
}

View File

@@ -9,13 +9,13 @@ export const insertAction = new ValidatedMethod({
validate: new SimpleSchema({
action: ActionSchema
}).validator({ clean: true }),
run: async function ({ action }: { action: EngineAction }) {
run: 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 });
EngineActions.remove({ 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 EngineActions.insertAsync(action);
return EngineActions.insert(action);
},
});

View File

@@ -4,7 +4,7 @@ import EngineActions from '/imports/api/engine/action/EngineActions';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import { getCreature } from '/imports/api/engine/loadCreatures';
import applyAction from '/imports/api/engine/action/functions/applyAction';
import writeChangedAction from '../functions/writeChangedAction';
import writeActionResults from '../functions/writeActionResults';
import getReplayChoicesInputProvider from '/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider';
export const runAction = new ValidatedMethod({
@@ -22,7 +22,7 @@ export const runAction = new ValidatedMethod({
blackbox: true,
},
}).validator(),
run: async function ({ actionId, decisions }: { actionId: string, decisions: any[] }) {
run: async function ({ actionId, decisions = [] }: { actionId: string, decisions?: any[] }) {
// Get the action
const action = await EngineActions.findOneAsync(actionId);
if (!action) throw new Meteor.Error('not-found', 'Action not found');
@@ -30,9 +30,6 @@ export const runAction = new ValidatedMethod({
// Permissions
assertEditPermission(getCreature(action.creatureId), this.userId);
// Keep a copy of the original so that a diff can be done later to store what changed
const originalAction = EJSON.clone(action);
// Replay the user's decisions as user input
const userInput = getReplayChoicesInputProvider(actionId, decisions);
@@ -40,7 +37,7 @@ export const runAction = new ValidatedMethod({
applyAction(action, userInput);
// Persist changes
const writePromise = writeChangedAction(originalAction, action);
const writePromise = writeActionResults(action);
return writePromise;
},
});

View File

@@ -17,14 +17,16 @@ export default async function applyTask(
action: EngineAction, task: PropTask | ItemAsAmmoTask, inputProvider: InputProvider
): Promise<void>
export default async function applyTask(
action: EngineAction, task: Task, inputProvider: InputProvider
): Promise<void | number>
export default async function applyTask(
action: EngineAction, task: Task, inputProvider: InputProvider
): Promise<void | number> {
// Pause and wait for the user if the action is being stepped through
console.log('applying task', action, inputProvider)
if (action._isSimulation && action._stepThrough && inputProvider.nextStep) {
console.log('waiting for next step resolution', task)
await inputProvider.nextStep(task);
}

View File

@@ -1,7 +1,6 @@
import { Meteor } from 'meteor/meteor'
import { EJSON } from 'meteor/ejson';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import propertySchemasIndex from '/imports/api/properties/computedOnlyPropertySchemasIndex';
import bulkWrite, { addSetOp, addUnsetOp, newOperation } from '/imports/api/engine/shared/bulkWrite';
export default function writeAlteredProperties(computation) {
let bulkWriteOperations = [];
@@ -33,7 +32,7 @@ export default function writeAlteredProperties(computation) {
bulkWriteOperations.push(op);
}
});
bulkWriteProperties(bulkWriteOperations);
bulkWrite(bulkWriteOperations, CreatureProperties);
//if (bulkWriteOperations.length) console.log(`Wrote ${bulkWriteOperations.length} props`);
}
@@ -42,7 +41,7 @@ function addChangedKeysToOp(op, keys, original, changed) {
// and compile an operation that sets all those keys
for (let key of keys) {
if (!EJSON.equals(original[key], changed[key])) {
if (!op) op = newOperation(original._id, changed.type);
if (!op) op = newOperation(original._id);
let value = changed[key];
if (value === undefined) {
// Unset values that become undefined
@@ -55,70 +54,3 @@ function addChangedKeysToOp(op, keys, original, changed) {
}
return op;
}
function newOperation(_id, type) {
let newOp = {
updateOne: {
filter: { _id },
update: {},
}
};
if (Meteor.isClient) {
newOp.type = type;
}
return newOp;
}
function addSetOp(op, key, value) {
if (op.updateOne.update.$set) {
op.updateOne.update.$set[key] = value;
} else {
op.updateOne.update.$set = { [key]: value };
}
}
function addUnsetOp(op, key) {
if (op.updateOne.update.$unset) {
op.updateOne.update.$unset[key] = 1;
} else {
op.updateOne.update.$unset = { [key]: 1 };
}
}
// If we re-enable client-side sheet recalculation, this needs to be run on
// both client and server to preserve latency compensation. Bulkwrite breaks
// latency compensation and causes flickering
function writePropertiesSequentially(bulkWriteOps) {
bulkWriteOps.forEach(op => {
let updateOneOrMany = op.updateOne || op.updateMany;
CreatureProperties.update(updateOneOrMany.filter, updateOneOrMany.update, {
// The bulk code is bypassing validation, so do the same here
// selector: {type: op.type} // include this if bypass is off
bypassCollection2: true,
});
});
//if (bulkWriteOps.length) console.log(`Wrote ${bulkWriteOps.length} props`);
}
// This is more efficient on the database, but significantly less efficient
// in the UI because of incompatibility with latency compensation. If the
// duplicate redraws can be fixed, this is a strictly better way of processing
// writes
function bulkWriteProperties(bulkWriteOps) {
if (!bulkWriteOps.length) return;
// bulkWrite is only available on the server
if (Meteor.isServer) {
CreatureProperties.rawCollection().bulkWrite(
bulkWriteOps,
{ ordered: false },
function (e) {
if (e) {
console.error('Bulk write failed: ');
console.error(e);
}
}
);
} else {
writePropertiesSequentially(bulkWriteOps);
}
}

View File

@@ -0,0 +1,55 @@
// This is more efficient on the database, but significantly less efficient
// in the UI because of incompatibility with latency compensation. If the
// duplicate redraws can be fixed, this is a strictly better way of processing
// writes
export default function bulkWrite(bulkWriteOps, collection): void | Promise<any> {
if (!bulkWriteOps.length) return;
// bulkWrite is only available on the server
if (!Meteor.isServer) {
return writePropertiesSequentially(bulkWriteOps, collection);
}
return collection.rawCollection().bulkWrite(
bulkWriteOps,
{ ordered: false }
);
}
// If we re-enable client-side sheet recalculation, this needs to be run on
// both client and server to preserve latency compensation. Bulkwrite breaks
// latency compensation and causes flickering
function writePropertiesSequentially(bulkWriteOps: any[], collection: Mongo.Collection<any>) {
bulkWriteOps.forEach(op => {
const updateOneOrMany = op.updateOne || op.updateMany;
collection.update(updateOneOrMany.filter, updateOneOrMany.update, {
// The bulk code is bypassing validation, so do the same here
// @ts-expect-error Collection 2 has no typescript support
bypassCollection2: true,
});
});
}
export function newOperation(_id) {
const newOp = {
updateOne: {
filter: { _id },
update: {},
}
};
return newOp;
}
export function addSetOp(op, key, value) {
if (op.updateOne.update.$set) {
op.updateOne.update.$set[key] = value;
} else {
op.updateOne.update.$set = { [key]: value };
}
}
export function addUnsetOp(op, key) {
if (op.updateOne.update.$unset) {
op.updateOne.update.$unset[key] = 1;
} else {
op.updateOne.update.$unset = { [key]: 1 };
}
}

View File

@@ -1,7 +1,31 @@
import { Store } from 'vuex';
import { insertAction } from '/imports/api/engine/action/methods/insertAction';
import Task from '/imports/api/engine/action/tasks/Task';
import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions';
import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider';
import applyAction from '/imports/api/engine/action/functions/applyAction';
import { runAction } from '/imports/api/engine/action/methods/runAction';
export default async function doAction(prop: any, $store, elementId) {
const actionId = await insertAction.call({
/**
* Apply an action on the client that first creates the action on both the client and server, then
* simulates the action, opening the action dialog if necessary to get input from the user, saving
* the decisions the user makes, then applying the action as a method call to the server with the
* saved decisions, which will persist the action results.
*
* @param prop The property initializing the action, if no task is applied the property will be
* applied as the starting point of the action
* @param $store The Vuex store instance that has the dialog stack
* @param elementId The element to animate the dialog from if a dialog needs to open
* @param task The task to apply instead of applying the property itself
*/
export default async function doAction(
prop: { _id: string, root: { id: string } },
$store: Store<any>,
elementId: string,
task?: Task,
) {
// Create the action
const actionId = insertAction.call({
action: {
creatureId: prop.root.id,
rootPropId: prop._id,
@@ -9,11 +33,49 @@ export default async function doAction(prop: any, $store, elementId) {
taskCount: 0,
}
});
$store.commit('pushDialogStack', {
component: 'action-dialog',
elementId,
data: {
actionId,
},
});
// Get the inserted and cleaned action instance
const action = EngineActions.findOne(actionId);
if (!action) throw new Meteor.Error('not-found', 'The action could not be found');
// Applying the action is deterministic, so we apply it, if it asks for user input, we escape and
// create a dialog that will re-apply the action, but with the ability to actually get input
// Either way, call the action method afterwards
try {
const finishedAction = await applyAction(
action, errorOnInputRequest, { simulate: true, task }
);
return callActionMethod(finishedAction);
} catch (e) {
if (e !== 'input-requested') throw e;
return new Promise(resolve => {
$store.commit('pushDialogStack', {
component: 'action-dialog',
elementId,
data: {
actionId,
},
async callback(action, decisions) {
resolve(await callActionMethod(action, decisions));
},
});
})
}
}
const callActionMethod = (action: EngineAction, decisions?: any[]) => {
if (!action._id) throw new Meteor.Error('type-error', 'Action must have and _id');
return runAction.call({ actionId: action._id, decisions });
}
const throwInputRequestedError = () => {
throw 'input-requested';
}
const errorOnInputRequest: InputProvider = {
nextStep: throwInputRequestedError,
rollDice: throwInputRequestedError,
choose: throwInputRequestedError,
advantage: throwInputRequestedError,
}

View File

@@ -0,0 +1,12 @@
/**
* Apply an action on the client that first creates the action on both the client and server, then
* simulates the action, opening the action dialog if necessary to get input from the user, saving
* the decisions the user makes, then applying the action as a method call to the server with the
* saved decisions, which will persist the action results.
*/
import Task from '/imports/api/engine/action/tasks/Task';
export default function doClientAction(propIdOrTask: string | Task) {
}