From 621f284cffeb7a82fdcb8d152d8b08da089324e4 Mon Sep 17 00:00:00 2001 From: Thaum Rystra <9525416+ThaumRystra@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:30:37 +0200 Subject: [PATCH] Iterated on tabletops --- .vscode/settings.json | 1 + .../api/creature/creatures/Creatures.ts | 1 + .../writeAlteredProperties.js | 4 + app/imports/api/tabletop/Tabletops.ts | 13 +++ .../functions/denormalizeTabletopPropCount.ts | 52 +++++++++++ .../addCreaturesFromLibraryToTabletop.ts | 6 +- .../methods/addCreaturesToTabletop.js | 8 +- .../methods/removeCreatureFromTabletop.js | 53 ----------- .../methods/removeCreatureFromTabletop.ts | 89 +++++++++++++++++++ .../tabletop/methods/shared/tabletopLimits.ts | 13 +++ .../client/ui/tabletop/TabletopComponent.vue | 13 +++ .../SelectedCreatureBar.vue | 13 +++ 12 files changed, 210 insertions(+), 56 deletions(-) create mode 100644 app/imports/api/tabletop/functions/denormalizeTabletopPropCount.ts delete mode 100644 app/imports/api/tabletop/methods/removeCreatureFromTabletop.js create mode 100644 app/imports/api/tabletop/methods/removeCreatureFromTabletop.ts create mode 100644 app/imports/api/tabletop/methods/shared/tabletopLimits.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index dce4a83f..58810132 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "blackbox", "Crits", "cyrb", + "denormalize", "denormalized", "EJSON", "healthbar", diff --git a/app/imports/api/creature/creatures/Creatures.ts b/app/imports/api/creature/creatures/Creatures.ts index 010d1407..0abcc963 100644 --- a/app/imports/api/creature/creatures/Creatures.ts +++ b/app/imports/api/creature/creatures/Creatures.ts @@ -243,6 +243,7 @@ const CreatureSchema = new SimpleSchema({ // Tabletop tabletopId: { + index: 1, type: String, regEx: SimpleSchema.RegEx.Id, optional: true, diff --git a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js index 418f4bc6..c206c076 100644 --- a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js +++ b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js @@ -1,6 +1,7 @@ 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'; +import denormalizeTabletopPropCount from '/imports/api/tabletop/functions/denormalizeTabletopPropCount' export default function writeAlteredProperties(computation) { let bulkWriteOperations = []; @@ -35,6 +36,9 @@ export default function writeAlteredProperties(computation) { }); bulkWrite(bulkWriteOperations, CreatureProperties); //if (bulkWriteOperations.length) console.log(`Wrote ${bulkWriteOperations.length} props`); + + // Update the relevant tabletop's property count + if (computation.creature.tabletopId) denormalizeTabletopPropCount(computation.creature.tabletopId); } function addChangedKeysToOp(op, keys, original, changed) { diff --git a/app/imports/api/tabletop/Tabletops.ts b/app/imports/api/tabletop/Tabletops.ts index 431cc0c0..6b701960 100644 --- a/app/imports/api/tabletop/Tabletops.ts +++ b/app/imports/api/tabletop/Tabletops.ts @@ -15,6 +15,7 @@ export type Tabletop = { initiativeNumber?: number, activeCreature?: string, }, + propCount: number, } const Tabletops = new Mongo.Collection('tabletops'); @@ -101,6 +102,17 @@ const TabletopSchema = new SimpleSchema({ defaultValue: {}, }, + // Denormalized fields + // Number of properties on all creatures in this tabletop + propCount: { + type: SimpleSchema.Integer, + defaultValue: 0, + }, + // Number of creatures in this tabletop + creatureCount: { + type: SimpleSchema.Integer, + defaultValue: 0, + }, }); //@ts-expect-error attachSchema not defined in simpl-schema package @@ -112,5 +124,6 @@ import '/imports/api/tabletop/methods/updateTabletop'; import '/imports/api/tabletop/methods/addCreaturesToTabletop'; import '/imports/api/tabletop/methods/updateTabletopSharing'; import '/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop'; +import '/imports/api/tabletop/methods/removeCreatureFromTabletop'; export default Tabletops; diff --git a/app/imports/api/tabletop/functions/denormalizeTabletopPropCount.ts b/app/imports/api/tabletop/functions/denormalizeTabletopPropCount.ts new file mode 100644 index 00000000..4c166702 --- /dev/null +++ b/app/imports/api/tabletop/functions/denormalizeTabletopPropCount.ts @@ -0,0 +1,52 @@ +import { debounce } from 'lodash'; +import Creatures from '/imports/api/creature/creatures/Creatures'; +import Tabletops from '/imports/api/tabletop/Tabletops'; + +// Store a function per tabletop to debounce the update +const queues: Record void> = {}; +/** + * Update the propCount field on a tabletop to reflect the sum of all propCounts of creatures in + * that tabletop. + * Debounced by 1s, per tabletop + */ +export default function updateTabletopPropCount(tabletopId: string) { + if (!tabletopId) return; + + // Server only + if (Meteor.isClient) return; + + // If there isn't a debounced function for this tabletop, create one + if (!queues[tabletopId]) { + queues[tabletopId] = debounce(() => { + doUpdateTabletopPropCount(tabletopId); + // When this function is actually run, delete the debounced function + delete queues[tabletopId]; + }, 1_000); + } + + // Call the debounced function for this tabletop + queues[tabletopId](); +} + +/** + * Update the propCount field on a tabletop to reflect the sum of all propCounts of creatures in + * that tabletop + */ +async function doUpdateTabletopPropCount(tabletopId: string) { + let propCount = 0; + let creatureCount = 0; + await Creatures.find({ + tabletopId + }, { + fields: { propCount: 1 } + }).forEachAsync(creature => { + creatureCount += 1; + propCount += creature.propCount || 0; + }); + return Tabletops.update(tabletopId, { + $set: { + propCount, + creatureCount, + } + }); +} diff --git a/app/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop.ts b/app/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop.ts index a92d5645..5ec398f3 100644 --- a/app/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop.ts +++ b/app/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop.ts @@ -9,6 +9,8 @@ import { getFilter, renewDocIds } from '/imports/api/parenting/parentingFunction import { reifyNodeReferences, storeLibraryNodeReferences } from '/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; +import Tabletops from '/imports/api/tabletop/Tabletops'; +import { assertTabletopHasPropSpace } from '/imports/api/tabletop/methods/shared/tabletopLimits' const addCreaturesFromLibraryToTabletop = new ValidatedMethod({ @@ -40,7 +42,9 @@ const addCreaturesFromLibraryToTabletop = new ValidatedMethod({ 'You need to be logged in to remove a tabletop'); } assertUserHasPaidBenefits(this.userId); - assertUserInTabletop(tabletopId, this.userId); + const tabletop = Tabletops.findOne(tabletopId); + assertUserInTabletop(tabletop, this.userId); + assertTabletopHasPropSpace(tabletop); for (const nodeId of libraryNodeIds) { const creatureNode = LibraryNodes.findOne({ diff --git a/app/imports/api/tabletop/methods/addCreaturesToTabletop.js b/app/imports/api/tabletop/methods/addCreaturesToTabletop.js index 24f84833..4f18fdf1 100644 --- a/app/imports/api/tabletop/methods/addCreaturesToTabletop.js +++ b/app/imports/api/tabletop/methods/addCreaturesToTabletop.js @@ -4,6 +4,8 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { assertUserInTabletop } from './shared/tabletopPermissions'; import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers'; import Creatures from '/imports/api/creature/creatures/Creatures'; +import Tabletops from '/imports/api/tabletop/Tabletops'; +import { assertTabletopHasPropSpace } from '/imports/api/tabletop/methods/shared/tabletopLimits'; const addCreaturesToTabletop = new ValidatedMethod({ @@ -12,6 +14,7 @@ const addCreaturesToTabletop = new ValidatedMethod({ validate: new SimpleSchema({ 'creatureIds': { type: Array, + max: 20, }, 'creatureIds.$': { type: String, @@ -24,7 +27,6 @@ const addCreaturesToTabletop = new ValidatedMethod({ }).validator(), mixins: [RateLimiterMixin], - // @ts-expect-error Rate limit not defined rateLimit: { numRequests: 10, timeInterval: 5000, @@ -36,7 +38,9 @@ const addCreaturesToTabletop = new ValidatedMethod({ 'You need to be logged in to remove a tabletop'); } assertUserHasPaidBenefits(this.userId); - assertUserInTabletop(tabletopId, this.userId); + const tabletop = Tabletops.findOne(tabletopId); + assertUserInTabletop(tabletop, this.userId); + assertTabletopHasPropSpace(tabletop); Creatures.update({ _id: { $in: creatureIds }, diff --git a/app/imports/api/tabletop/methods/removeCreatureFromTabletop.js b/app/imports/api/tabletop/methods/removeCreatureFromTabletop.js deleted file mode 100644 index 74b241c0..00000000 --- a/app/imports/api/tabletop/methods/removeCreatureFromTabletop.js +++ /dev/null @@ -1,53 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import { assertUserInTabletop } from './shared/tabletopPermissions'; -import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers'; -import Creatures from '/imports/api/creature/creatures/Creatures'; - -const addCreaturesToTabletop = new ValidatedMethod({ - - name: 'tabletops.addCreatures', - - validate: new SimpleSchema({ - creatureId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - tabletopId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - }).validator(), - - mixins: [RateLimiterMixin], - // @ts-expect-error Rate limit not defined - rateLimit: { - numRequests: 10, - timeInterval: 5000, - }, - - run({ tabletopId, creatureIds }) { - if (!this.userId) { - throw new Meteor.Error('tabletops.addCreatures.denied', - 'You need to be logged in to remove a tabletop'); - } - assertUserHasPaidBenefits(this.userId); - assertUserInTabletop(tabletopId, this.userId); - - Creatures.update({ - _id: { $in: creatureIds }, - $or: [ - { writers: this.userId }, - { owner: this.userId }, - ], - }, { - $set: { tabletop: tabletopId }, - }, { - multi: true, - }); - }, - -}); - -export default addCreaturesToTabletop; diff --git a/app/imports/api/tabletop/methods/removeCreatureFromTabletop.ts b/app/imports/api/tabletop/methods/removeCreatureFromTabletop.ts new file mode 100644 index 00000000..cd8a50bc --- /dev/null +++ b/app/imports/api/tabletop/methods/removeCreatureFromTabletop.ts @@ -0,0 +1,89 @@ +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import { assertUserInTabletop } from './shared/tabletopPermissions'; +import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers'; +import Creatures from '/imports/api/creature/creatures/Creatures'; +import updateTabletopPropCount from '/imports/api/tabletop/functions/denormalizeTabletopPropCount'; +import { getCreature } from '/imports/api/engine/loadCreatures'; +import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature'; +import { assertOwnership } from '/imports/api/creature/creatures/creaturePermissions'; + +const removeCreatureFromTabletop = new ValidatedMethod({ + + name: 'tabletops.removeCreature', + + validate: new SimpleSchema({ + tabletopId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'creatureIds': { + type: Array, + }, + 'creatureIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validator(), + + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 10, + timeInterval: 5000, + }, + + run({ tabletopId, creatureIds }) { + if (!this.userId) { + throw new Meteor.Error('tabletops.removeCreature.denied', + 'You need to be logged in to remove creatures from tabletop'); + } + assertUserHasPaidBenefits(this.userId); + assertUserInTabletop(tabletopId, this.userId); + + const creaturesToRemove: any[] = []; + const creatureIdsToClearTabletopId: string[] = []; + + for (const creatureId of creatureIds) { + const creature = getCreature(creatureId); + // Make sure the creature exists and is in this tabletop + if (!creature || creature.tabletopId !== tabletopId) continue; + switch (creature.type) { + // Remove character creatures from the tabletop + case 'pc': + creatureIdsToClearTabletopId.push(creatureId); + break; + // Delete non player characters and monsters + case 'npc': + case 'monster': + creaturesToRemove.push(creature); + break; + } + } + + // Clear tabletopId from all player characters + if (creatureIdsToClearTabletopId.length) Creatures.update({ + _id: { $in: creatureIdsToClearTabletopId }, + $or: [ + { writers: this.userId }, + { owner: this.userId }, + ], + }, { + $unset: { tabletopId: 1 }, + }, { + multi: true, + }); + + // Remove all non player characters and monsters + for (const creature of creaturesToRemove) { + assertOwnership(creature, this.userId) + removeCreatureWork(creature._id); + } + + if (Meteor.isServer) { + updateTabletopPropCount(tabletopId); + } + }, +}); + +export default removeCreatureFromTabletop; diff --git a/app/imports/api/tabletop/methods/shared/tabletopLimits.ts b/app/imports/api/tabletop/methods/shared/tabletopLimits.ts new file mode 100644 index 00000000..01be33a4 --- /dev/null +++ b/app/imports/api/tabletop/methods/shared/tabletopLimits.ts @@ -0,0 +1,13 @@ +const MAX_PROP_COUNT = 10_000; +const MAX_CREATURE_COUNT = 110; + +export function assertTabletopHasPropSpace(tabletop) { + if (tabletop.propCount >= MAX_PROP_COUNT) { + throw new Meteor.Error('tabletops.denied', + 'This tabletop is full, either remove some creatures or reduce how many properties each creature has'); + } + if (tabletop.creatureCount >= MAX_CREATURE_COUNT) { + throw new Meteor.Error('tabletops.denied', + 'This tabletop is full, you can\'t add any more creatures to it'); + } +} diff --git a/app/imports/client/ui/tabletop/TabletopComponent.vue b/app/imports/client/ui/tabletop/TabletopComponent.vue index 50ab3fa1..add21540 100644 --- a/app/imports/client/ui/tabletop/TabletopComponent.vue +++ b/app/imports/client/ui/tabletop/TabletopComponent.vue @@ -90,6 +90,7 @@ :key="activeCreatureId" :creature-id="activeCreatureId" @active-action-change="activeActionId = $event" + @remove="removeCreature(activeCreatureId)" /> @@ -109,6 +110,7 @@ import ActionCard from '/imports/client/ui/tabletop/TabletopActionCard.vue'; import SelectedCreatureBar from '/imports/client/ui/tabletop/selectedCreatureBar/SelectedCreatureBar.vue'; import TabletopCreatureListItem from '/imports/client/ui/tabletop/TabletopCreatureListItem.vue'; import addCreaturesFromLibraryToTabletop from '/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop'; +import removeCreatureFromTabletop from '/imports/api/tabletop/methods/removeCreatureFromTabletop'; const getProperties = function (creatureId, selector = {}) { return CreatureProperties.find({ @@ -254,6 +256,17 @@ export default { if (index > -1) { this.targets.splice(index, 1); } + }, + removeCreature(creatureId) { + if (this.activeCreatureId === creatureId) this.activeCreatureId = undefined; + removeCreatureFromTabletop.call({ + tabletopId: this.model._id, + creatureIds: [creatureId] + }, error => { + if (!error) return; + console.error(error); + snackbar({ text: error.message || error.toString() }); + }); } }, } diff --git a/app/imports/client/ui/tabletop/selectedCreatureBar/SelectedCreatureBar.vue b/app/imports/client/ui/tabletop/selectedCreatureBar/SelectedCreatureBar.vue index be8be091..f96c1f1a 100644 --- a/app/imports/client/ui/tabletop/selectedCreatureBar/SelectedCreatureBar.vue +++ b/app/imports/client/ui/tabletop/selectedCreatureBar/SelectedCreatureBar.vue @@ -43,6 +43,19 @@ + +
+ +
+