diff --git a/app/imports/api/creature/log/CreatureLogs.js b/app/imports/api/creature/log/CreatureLogs.js index f606ac7f..365b3321 100644 --- a/app/imports/api/creature/log/CreatureLogs.js +++ b/app/imports/api/creature/log/CreatureLogs.js @@ -43,12 +43,17 @@ let CreatureLogSchema = new SimpleSchema({ type: String, index: 1, }, - // The tabletops this log is associated with + // The tabletop this log is associated with tabletopId: { type: String, optional: true, index: 1, }, + // The action that caused this log entry + actionId: { + type: String, + optional: true, + }, creatureName: { type: String, optional: true, diff --git a/app/imports/api/engine/action/functions/writeActionResults.ts b/app/imports/api/engine/action/functions/writeActionResults.ts index 13da7d94..cc0827f7 100644 --- a/app/imports/api/engine/action/functions/writeActionResults.ts +++ b/app/imports/api/engine/action/functions/writeActionResults.ts @@ -27,10 +27,12 @@ export default async function writeActionResults(action: EngineAction) { content: logContents, creatureId: action.creatureId, tabletopId: action.tabletopId, + actionId: action._id, }); - // Write the bulk updates - const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties); + // Write the bulk updates, force them to sequential mode means we immediately get the results + // in the subscription, rather than waiting for oplog tailing to catch up + const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties, true); await Promise.all([engineActionPromise, logPromise, bulkWritePromise]); diff --git a/app/imports/api/engine/action/tasks/applyDamagePropTask.ts b/app/imports/api/engine/action/tasks/applyDamagePropTask.ts index 6d3fa735..039e9a5e 100644 --- a/app/imports/api/engine/action/tasks/applyDamagePropTask.ts +++ b/app/imports/api/engine/action/tasks/applyDamagePropTask.ts @@ -116,24 +116,27 @@ export default async function applyDamagePropTask( 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: increment >= 0 ? 'Attribute damaged' : 'Attribute restored', - value: `${numberToSignedString(-increment)} ${getPropertyTitle(targetProp)}`, - inline: true, - ...task.silent && { silenced: true }, - }] - }); - if (targetId === action.creatureId) setScope(result, targetProp, newValue, damage); + // Only increment if the increment is non-zero + if (increment !== 0) { + 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: increment >= 0 ? 'Attribute damaged' : 'Attribute restored', + value: `${numberToSignedString(-increment)} ${getPropertyTitle(targetProp)}`, + inline: true, + ...task.silent && { silenced: true }, + }] + }); + if (targetId === action.creatureId) setScope(result, targetProp, newValue, damage); + } } await applyTriggers(action, targetProp, [targetId], 'damageTriggerIds.after', userInput); await applyTriggers(action, targetProp, [targetId], 'damageTriggerIds.afterChildren', userInput); diff --git a/app/imports/api/engine/shared/bulkWrite.ts b/app/imports/api/engine/shared/bulkWrite.ts index 4deafecc..f60c6447 100644 --- a/app/imports/api/engine/shared/bulkWrite.ts +++ b/app/imports/api/engine/shared/bulkWrite.ts @@ -2,10 +2,10 @@ // 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 async function bulkWrite(bulkWriteOps, collection: Mongo.Collection): Promise { +export default async function bulkWrite(bulkWriteOps, collection: Mongo.Collection, forceSequential?: true): Promise { if (!bulkWriteOps.length) return; // bulkWrite is only available on the server - if (!Meteor.isServer) { + if (!Meteor.isServer || forceSequential) { return writePropertiesSequentially(bulkWriteOps, collection); } return collection.rawCollection().bulkWrite( diff --git a/app/imports/client/ui/creature/actions/ActionDialog.vue b/app/imports/client/ui/creature/actions/ActionDialog.vue index fc1579d0..56d6a4e1 100644 --- a/app/imports/client/ui/creature/actions/ActionDialog.vue +++ b/app/imports/client/ui/creature/actions/ActionDialog.vue @@ -1,20 +1,11 @@ @@ -79,6 +50,9 @@ import LogContent from '/imports/client/ui/log/LogContent.vue'; //import RollInput from '/imports/client/ui/creature/actions/input/RollInput.vue'; import TargetsInput from '/imports/client/ui/creature/actions/input/TargetsInput.vue'; import CastSpellInput from '/imports/client/ui/creature/actions/input/CastSpellInput.vue'; +import { runAction } from '/imports/api/engine/action/methods/runAction'; +import TabletopLogStreamEntry from '/imports/client/ui/tabletop/TabletopLogStreamEntry.vue'; +import mutationToLogUpdates from '/imports/api/engine/action/functions/mutationToLogUpdates'; export default { components: { @@ -90,6 +64,7 @@ export default { //RollInput, TargetsInput, CastSpellInput, + TabletopLogStreamEntry, }, props: { actionId: { @@ -100,6 +75,10 @@ export default { type: Object, default: undefined, }, + actionFinishedCallback: { + type: Function, + default: undefined, + } }, data() { return { @@ -112,6 +91,7 @@ export default { activeInputParams: {}, userInput: undefined, userInputReady: true, + actionPromise: undefined, }; }, computed: { @@ -121,17 +101,19 @@ export default { resultJson() { return JSON.stringify(this.actionResult, null, 2); }, - allLogContent() { + simulatedLog() { const action = this.actionResult; - const contents = []; - action?.results?.forEach(result => { - result.mutations?.forEach(mutation => { - mutation.contents?.forEach(logContent => { - contents.push(logContent); - }); + const content = []; + action?.results.forEach(result => { + result.mutations.forEach(mutation => { + content.push(...mutationToLogUpdates(mutation)); }); }); - return contents; + return { + content, + creatureId: action?.creatureId, + tabletopId: action?.tabletopId, + } }, }, meteor: { @@ -144,7 +126,7 @@ export default { this.startAction({ stepThrough: false }); }, methods: { - startAction({ stepThrough }) { + async startAction({ stepThrough }) { this.actionBusy = true; this.actionResult = { ...this.action, @@ -152,13 +134,15 @@ export default { _isSimulation: undefined, taskCount: undefined, }; - applyAction( - this.actionResult, this, { simulate: true, stepThrough} - ).then(() => { - this.actionDone = true; - this.actionBusy = false; - this.activeInput = undefined; + await applyAction(this.actionResult, this, { simulate: true, stepThrough }); + const actionResult = await runAction.callAsync({ + actionId: this.actionResult._id, + decisions: this.actionResult._decisions }); + this.actionDone = true; + this.actionBusy = false; + this.activeInput = undefined; + if (this.actionFinishedCallback) this.actionFinishedCallback(actionResult); }, stepAction() { if (this.actionResult) { @@ -172,7 +156,7 @@ export default { } this.resumeActionFn?.(); }, - finishAction() { + async finishAction() { this.$store.dispatch('popDialogStack', this.actionResult); }, promiseInput() { @@ -196,6 +180,8 @@ export default { }, // inputProvider methods async targetIds(target) { + // Only get targets if we are on a tabletop + if (this.$router.currentRoute.name !== 'tabletop') return []; this.userInput = []; this.activeInputParams = { target, @@ -251,43 +237,19 @@ export default { diff --git a/app/imports/client/ui/creature/actions/doAction.ts b/app/imports/client/ui/creature/actions/doAction.ts index 373f0b86..2c1a48b9 100644 --- a/app/imports/client/ui/creature/actions/doAction.ts +++ b/app/imports/client/ui/creature/actions/doAction.ts @@ -12,6 +12,7 @@ type BaseDoActionParams = { creatureId: string; $store: Store; elementId: string; + callback?: (action: EngineAction) => void; } type DoTaskParams = BaseDoActionParams & { @@ -32,7 +33,7 @@ type DoActionParams = BaseDoActionParams & { * 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. */ -export default async function doAction({ propId, creatureId, $store, elementId, task, targetIds }: DoActionParams | DoTaskParams): Promise { +export default async function doAction({ propId, creatureId, $store, elementId, task, targetIds, callback }: DoActionParams | DoTaskParams): Promise { if (!task) { targetIds ??= []; if (!propId) throw new Meteor.Error('no-prop-id', 'Either propId or task must be provided'); @@ -58,8 +59,6 @@ export default async function doAction({ propId, creatureId, $store, elementId, // Get the inserted and cleaned action instance const action = EngineActions.findOne(actionId); - console.log(action); - 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 @@ -73,26 +72,21 @@ export default async function doAction({ propId, creatureId, $store, elementId, return callActionMethod(finishedAction); } catch (e) { if (e !== 'input-requested') throw e; - return new Promise((resolve, reject) => { + return new Promise((resolve) => { $store.commit('pushDialogStack', { component: 'action-dialog', elementId, data: { actionId, task, + actionFinishedCallback: resolve, }, - async callback(action: EngineAction) { - try { - if (action) await callActionMethod(action); - resolve(); - } - catch (e) { - reject(e); - } - return elementId; + callback(action) { + resolve(); + return callback?.(action); }, }); - }) + }); } } diff --git a/app/imports/client/ui/creature/actions/input/TargetsInput.vue b/app/imports/client/ui/creature/actions/input/TargetsInput.vue index eb30e195..07edc290 100644 --- a/app/imports/client/ui/creature/actions/input/TargetsInput.vue +++ b/app/imports/client/ui/creature/actions/input/TargetsInput.vue @@ -1,5 +1,8 @@ @@ -73,6 +81,10 @@ export default { newValue = [...this.value, id]; } this.$emit('input', newValue); + // If we are selecting a single creature, we are done + if (this.target === 'singleTarget') { + this.$emit('continue'); + } }, }, meteor: { @@ -87,4 +99,4 @@ export default { } } }; - \ No newline at end of file + diff --git a/app/imports/client/ui/creature/character/CharacterSheet.vue b/app/imports/client/ui/creature/character/CharacterSheet.vue index 63a842b6..bc126cf5 100644 --- a/app/imports/client/ui/creature/character/CharacterSheet.vue +++ b/app/imports/client/ui/creature/character/CharacterSheet.vue @@ -139,11 +139,10 @@ import CharacterTab from '/imports/client/ui/creature/character/characterSheetTa import BuildTab from '/imports/client/ui/creature/character/characterSheetTabs/BuildTab.vue'; import TreeTab from '/imports/client/ui/creature/character/characterSheetTabs/TreeTab.vue'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions'; -import CreatureLogs from '/imports/api/creature/log/CreatureLogs'; import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue'; import CharacterSheetFab from '/imports/client/ui/creature/character/CharacterSheetFab.vue'; import ActionsTab from '/imports/client/ui/creature/character/characterSheetTabs/ActionsTab.vue'; -import CharacterSheetInitiative from '/imports/client/ui/creature/character/CharacterSheetInitiative.vue'; +import CreatureLogs from '/imports/api/creature/log/CreatureLogs'; export default { components: { @@ -156,7 +155,6 @@ export default { BuildTab, TreeTab, CharacterSheetFab, - CharacterSheetInitiative, }, props: { creatureId: { @@ -165,7 +163,7 @@ export default { }, embedded: Boolean, }, - // @ts-ignore + // @ts-expect-error reactive provide not typed reactiveProvide: { name: 'context', include: ['creatureId', 'editPermission'], @@ -197,29 +195,29 @@ export default { changed: ({ name }) => this.$store.commit('setPageTitle', name || 'Character Sheet'), }); - let that = this; - this.logObserver = CreatureLogs.find({ - creatureId: this.creatureId, - }).observe({ - added({ content }) { - if (!that.$subReady.singleCharacter) return; - if (that.$store.state.rightDrawer) return; - snackbar({ content }); - }, - }); + if (this.$route.name === 'characterSheet') { + let that = this; + this.logObserver = CreatureLogs.find({ + creatureId: this.creatureId, + }).observe({ + added({ content }) { + if (!that.$subReady.singleCharacter) return; + if (that.$store.state.rightDrawer) return; + if (that.$store.state.dialogStack.dialogs.length) return; + snackbar({ content }); + }, + }); + } }, beforeDestroy() { - this.nameObserver.stop(); - this.logObserver.stop(); + this.nameObserver?.stop(); + this.logObserver?.stop(); }, meteor: { $subscribe: { 'singleCharacter'() { return [this.creatureId]; }, - 'otherTabletopCreatures'() { - return [this.creatureId]; - }, }, creature() { return Creatures.findOne(this.creatureId, { diff --git a/app/imports/client/ui/dialogStack/DialogStack.vue b/app/imports/client/ui/dialogStack/DialogStack.vue index 63788ae2..a36d7438 100644 --- a/app/imports/client/ui/dialogStack/DialogStack.vue +++ b/app/imports/client/ui/dialogStack/DialogStack.vue @@ -65,7 +65,7 @@ // Use in combination with browser's animation speed override to do slow-mod debugging const animationSpeed = 1; - const unsizedDialogs = new Set(['image-preview-dialog']); + const unsizedDialogs = new Set(['image-preview-dialog', 'action-dialog']); export default { components: { @@ -129,7 +129,7 @@ const num = length - 1; const left = (num - index) * -OFFSET; const top = (num - index) * -OFFSET; - return `left: calc(${left}px + 50%); top: calc(${top}px + 50%)`; + return `left: calc(${left}px + 50%); top: calc(${top}px + 50%);${index < num ? ' filter: brightness(0.7);': ''}`; }, getTopElementByDataId(elementId, offset = 0){ let stackLength = this.$store.state.dialogStack.dialogs.length - offset; @@ -166,7 +166,16 @@ // Instantly mock the source target.style.transition = 'none'; - mockElement({ source, target }); + // If we are using unsized dialogs, first let it layout with no opacity, then mock and + // carry on, otherwise it has no size + if (target.classList.contains('unsized-dialog')) { + target.style.opacity = '0'; + await new Promise(requestAnimationFrame); + mockElement({ source, target }); + target.style.opacity = '1'; + } else { + mockElement({ source, target }); + } // Wait one frame before hiding the source so we know our mock is in place await new Promise(requestAnimationFrame); diff --git a/app/imports/client/ui/pages/Tabletop.vue b/app/imports/client/ui/pages/Tabletop.vue index 431a4bef..8d810120 100644 --- a/app/imports/client/ui/pages/Tabletop.vue +++ b/app/imports/client/ui/pages/Tabletop.vue @@ -1,41 +1,51 @@ + + diff --git a/app/imports/client/ui/tabletop/TabletopLogStreamEntry.vue b/app/imports/client/ui/tabletop/TabletopLogStreamEntry.vue index f1ebadd4..cae729ba 100644 --- a/app/imports/client/ui/tabletop/TabletopLogStreamEntry.vue +++ b/app/imports/client/ui/tabletop/TabletopLogStreamEntry.vue @@ -1,7 +1,10 @@