diff --git a/app/imports/api/campaign/Campaigns.js b/app/imports/api/campaign/Campaigns.js deleted file mode 100644 index 2e9b8e49..00000000 --- a/app/imports/api/campaign/Campaigns.js +++ /dev/null @@ -1,11 +0,0 @@ -import SimpleSchema from 'simpl-schema'; - -let Campaigns = new Mongo.Collection('campaigns'); - -let CampaignSchema = new SimpleSchema({ - -}); - -Campaigns.attachSchema(CampaignSchema); - -export default Campaigns; diff --git a/app/imports/api/campaign/Encounter.js b/app/imports/api/campaign/Encounter.js deleted file mode 100644 index 679f86ec..00000000 --- a/app/imports/api/campaign/Encounter.js +++ /dev/null @@ -1,53 +0,0 @@ -import SimpleSchema from 'simpl-schema'; - -let Encounters = new Mongo.Collection('encounters'); - -const CreatureInitiativeSchema = new SimpleSchema({ - name: { - type: String, - optional: true, - }, - initiativeRoll: { - type: SimpleSchema.Integer, - }, -}); - -const InitiativeSchema = new SimpleSchema({ - // An ordered list of all creatures in the initiative order - creatures: { - type: Array, - defaultValue: [], - }, - 'creatures.$': { - type: CreatureInitiativeSchema, - }, - active: { - type: Boolean, - defaultValue: false, - }, - roundNumber: { - type: SimpleSchema.Integer, - defaultValue: 0, - }, - initiativeNumber: { - type: SimpleSchema.Integer, - optional: true, - }, -}); - -// A creature can be in one ecounter at a time. -// All creatures in an encounter have a shared time and space. -let EncounterSchema = new SimpleSchema({ - name: { - type: String, - optional: true, - }, - initiative: { - type: InitiativeSchema, - defaultValue: {}, - }, -}); - -Encounters.attachSchema(EncounterSchema); - -export default Encounters; diff --git a/app/imports/api/creature/Creatures.js b/app/imports/api/creature/Creatures.js index 197941b5..26d82c54 100644 --- a/app/imports/api/creature/Creatures.js +++ b/app/imports/api/creature/Creatures.js @@ -5,7 +5,7 @@ import deathSaveSchema from '/imports/api/properties/subSchemas/DeathSavesSchema import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema.js'; import SharingSchema from '/imports/api/sharing/SharingSchema.js'; import {assertEditPermission} from '/imports/api/sharing/sharingPermissions.js'; -import { getUserTier } from '/imports/api/users/patreon/tiers.js'; +import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js'; import '/imports/api/creature/removeCreature.js'; import '/imports/api/creature/restCreature.js'; @@ -107,6 +107,17 @@ let CreatureSchema = new SimpleSchema({ defaultValue: {} }, + // Tabletop + tabletop: { + type: String, + regEx: SimpleSchema.RegEx.id, + optional: true, + }, + initiativeRoll: { + type: SimpleSchema.Integer, + optional: true, + }, + // Settings settings: { type: CreatureSettingsSchema, @@ -136,11 +147,7 @@ const insertCreature = new ValidatedMethod({ throw new Meteor.Error('Creatures.methods.insert.denied', 'You need to be logged in to insert a creature'); } - let tier = getUserTier(this.userId); - if (!tier.paidBenefits){ - throw new Meteor.Error('Creatures.methods.insert.denied', - `The ${tier.name} tier does not allow you to insert a creature`); - } + assertUserHasPaidBenefits(this.userId); // Create the creature document let charId = Creatures.insert({ diff --git a/app/imports/api/campaign/Parties.js b/app/imports/api/creature/Parties.js similarity index 100% rename from app/imports/api/campaign/Parties.js rename to app/imports/api/creature/Parties.js diff --git a/app/imports/api/tabletop/Tabletops.js b/app/imports/api/tabletop/Tabletops.js new file mode 100644 index 00000000..3e108494 --- /dev/null +++ b/app/imports/api/tabletop/Tabletops.js @@ -0,0 +1,189 @@ +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js'; +import Creatures from '/imports/api/creature/Creatures.js'; + +let Tabletops = new Mongo.Collection('tabletops'); + +const InitiativeSchema = new SimpleSchema({ + active: { + type: Boolean, + defaultValue: false, + }, + roundNumber: { + type: SimpleSchema.Integer, + defaultValue: 0, + }, + initiativeNumber: { + type: SimpleSchema.Integer, + optional: true, + }, + activeCreature: { + type: String, + regEx: SimpleSchema.RegEx.id, + optional: true, + }, +}); + +// All creatures in a tabletop have a shared time and space. +let TabletopSchema = new SimpleSchema({ + name: { + type: String, + optional: true, + }, + initiative: { + type: InitiativeSchema, + defaultValue: {}, + }, + gameMaster: { + type: String, + regEx: SimpleSchema.RegEx.id, + }, + players: { + type: Array, + defaultValue: [], + }, + 'players.$': { + type: String, + regEx: SimpleSchema.RegEx.id, + }, +}); + +Tabletops.attachSchema(TabletopSchema); + +function assertUserIsTabletopOwner(tabletopId, userId){ + let tabletop = Tabletops.findOne(tabletopId); + if (!tabletop){ + throw new Meteor.Error('Tabletop does not exist', + 'No tabletop could be found for the given tabletop id'); + } + if (tabletop.gameMaster !== userId){ + throw new Meteor.Error('Not the owner', + 'The user is not the owner of the given tabletop'); + } +} + +function assertUserInTabletop(tabletopId, userId){ + let tabletop = Tabletops.findOne(tabletopId); + if (!tabletop){ + throw new Meteor.Error('Tabletop does not exist', + 'No tabletop could be found for the given tabletop id'); + } + if (tabletop.gameMaster !== userId && !tabletop.players.includes(userId)){ + throw new Meteor.Error('Not in tabletop', + 'The user is not the gamemaster or a player in the given tabletop'); + } +} + +const insertTabletop = new ValidatedMethod({ + + name: 'tabletops.insert', + + validate: null, + + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + + run() { + if (!this.userId) { + throw new Meteor.Error('tabletops.insert.denied', + 'You need to be logged in to insert a tabletop'); + } + assertUserHasPaidBenefits(this.userId); + + return Tabletops.insert({ + gameMaster: this.userId, + }); + }, + +}); + +const removeTabletop = new ValidatedMethod({ + + name: 'tabletops.remove', + + validate: new SimpleSchema({ + tabletopId: { + type: String, + regEx: SimpleSchema.RegEx.id, + }, + }).validator(), + + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + + run({tabletopId}) { + if (!this.userId) { + throw new Meteor.Error('tabletops.remove.denied', + 'You need to be logged in to remove a tabletop'); + } + assertUserHasPaidBenefits(this.userId); + assertUserIsTabletopOwner(tabletopId, this.userId); + let removed = Tabletops.remove({ + _id: tabletopId, + }); + Creatures.update({ + tabletop: tabletopId, + }, { + $unset: {tabletop: 1}, + }); + return removed; + }, + +}); + +const addCreaturesToTabletop = new ValidatedMethod({ + + name: 'tabletops.addCreatures', + + validate: new SimpleSchema({ + 'creatureIds': { + type: Array, + }, + 'creatureIds.$': { + type: String, + regEx: SimpleSchema.RegEx.id, + }, + tabletopId: { + 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.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 Tabletops; +export { insertTabletop, removeTabletop, addCreaturesToTabletop }; diff --git a/app/imports/api/users/patreon/tiers.js b/app/imports/api/users/patreon/tiers.js index ccb3df1c..8b16bfec 100644 --- a/app/imports/api/users/patreon/tiers.js +++ b/app/imports/api/users/patreon/tiers.js @@ -77,5 +77,13 @@ export function getUserTier(user){ } } +export function assertUserHasPaidBenefits(user){ + let tier = getUserTier(user); + if (!tier.paidBenefits){ + throw new Meteor.Error('Creatures.methods.insert.denied', + `The ${tier.name} tier does not allow you to insert a creature`); + } +} + export default TIERS; export { GUEST_TIER }; diff --git a/app/imports/server/publications/characterList.js b/app/imports/server/publications/characterList.js index ede11228..426019e3 100644 --- a/app/imports/server/publications/characterList.js +++ b/app/imports/server/publications/characterList.js @@ -1,5 +1,5 @@ import Creatures from '/imports/api/creature/Creatures.js'; -import Parties from '/imports/api/campaign/Parties.js'; +import Parties from '/imports/api/creature/Parties.js'; Meteor.publish('characterList', function(){ this.autorun(function (){ diff --git a/app/imports/server/publications/index.js b/app/imports/server/publications/index.js index c58b5496..963baecd 100644 --- a/app/imports/server/publications/index.js +++ b/app/imports/server/publications/index.js @@ -5,3 +5,4 @@ import '/imports/server/publications/singleCharacter.js'; import '/imports/server/publications/experiences.js'; import '/imports/server/publications/users.js'; import '/imports/server/publications/icons.js'; +import '/imports/server/publications/tabletops.js'; diff --git a/app/imports/server/publications/tabletops.js b/app/imports/server/publications/tabletops.js new file mode 100644 index 00000000..7aa30abb --- /dev/null +++ b/app/imports/server/publications/tabletops.js @@ -0,0 +1,51 @@ +import Tabletops from '/imports/api/tabletop/Tabletops.js'; +import Creatures from '/imports/api/creature/Creatures.js'; + +Meteor.publish('tabletops', function(){ + var userId = this.userId; + if (!userId) { + return this.ready(); + } + return Tabletops.find({ + $or: [ + {players: userId}, + {gameMaster: userId}, + ], + }); +}); + +Meteor.publish('tabletop', function(tabletopId){ + var userId = this.userId; + if (!userId) { + return this.ready(); + } + this.autorun(function (){ + let tabletopCursor = Tabletops.find({ + _id: tabletopId, + $or: [ + {players: userId}, + {gameMaster: userId}, + ] + }); + let tabletop = tabletopCursor.fetch()[0]; + if (!tabletop){ + return this.ready(); + } + // Warning, this leaks data to users of the same tabletop who may not have + // read permission of this specific creature, so publish as few fields as + // possible + let creatureSummaries = Creatures.find({ + tabletop: tabletopId, + }, { + fields: { + name: 1, + picture: 1, + avatarPicture: 1, + variables: 1, + tabletop: 1, + initiativeRoll: 1, + }, + }); + return [ tabletopCursor, creatureSummaries] + }) +}); diff --git a/app/imports/ui/creature/CreatureListTile.vue b/app/imports/ui/creature/CreatureListTile.vue new file mode 100644 index 00000000..c6402838 --- /dev/null +++ b/app/imports/ui/creature/CreatureListTile.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/imports/ui/creature/character/MiniCharacterSheet.vue b/app/imports/ui/creature/character/MiniCharacterSheet.vue new file mode 100644 index 00000000..19f6b871 --- /dev/null +++ b/app/imports/ui/creature/character/MiniCharacterSheet.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/app/imports/ui/dialogStack/DialogComponentIndex.js b/app/imports/ui/dialogStack/DialogComponentIndex.js index 2008373c..783439d7 100644 --- a/app/imports/ui/dialogStack/DialogComponentIndex.js +++ b/app/imports/ui/dialogStack/DialogComponentIndex.js @@ -11,6 +11,7 @@ import LibraryEditDialog from '/imports/ui/library/LibraryEditDialog.vue'; import LibraryNodeCreationDialog from '/imports/ui/library/LibraryNodeCreationDialog.vue'; import LibraryNodeDialog from '/imports/ui/library/LibraryNodeDialog.vue'; import MoveLibraryNodeDialog from '/imports/ui/library/MoveLibraryNodeDialog.vue' +import SelectCreaturesDialog from '/imports/ui/tabletop/SelectCreaturesDialog.vue'; import ShareDialog from '/imports/ui/sharing/ShareDialog.vue'; import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue'; import UsernameDialog from '/imports/ui/user/UsernameDialog.vue'; @@ -29,6 +30,7 @@ export default { LibraryNodeCreationDialog, LibraryNodeDialog, MoveLibraryNodeDialog, + SelectCreaturesDialog, ShareDialog, TierTooLowDialog, UsernameDialog, diff --git a/app/imports/ui/layouts/Sidebar.vue b/app/imports/ui/layouts/Sidebar.vue index 5c7d4e05..515a136d 100644 --- a/app/imports/ui/layouts/Sidebar.vue +++ b/app/imports/ui/layouts/Sidebar.vue @@ -115,7 +115,7 @@ diff --git a/app/imports/ui/pages/Tabletops.vue b/app/imports/ui/pages/Tabletops.vue new file mode 100644 index 00000000..3a7db28f --- /dev/null +++ b/app/imports/ui/pages/Tabletops.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/app/imports/ui/router.js b/app/imports/ui/router.js index d9ca93f9..8e0fc7c5 100644 --- a/app/imports/ui/router.js +++ b/app/imports/ui/router.js @@ -20,6 +20,9 @@ import InviteSuccess from '/imports/ui/pages/InviteSuccess.vue' ; import InviteError from '/imports/ui/pages/InviteError.vue' ; import NotImplemented from '/imports/ui/pages/NotImplemented.vue'; import PatreonLevelTooLow from '/imports/ui/pages/PatreonLevelTooLow.vue'; +import Tabletops from '/imports/ui/pages/Tabletops.vue'; +import Tabletop from '/imports/ui/pages/Tabletop.vue'; +import TabletopToolbar from '/imports/ui/tabletop/TabletopToolbar.vue'; let userSubscription = Meteor.subscribe('user'); @@ -143,6 +146,19 @@ RouterFactory.configure(factory => { meta: { title: 'Character Sheet', }, + },{ + path: '/tabletops', + name: 'tabletops', + component: Tabletops, + beforeEnter: ensureLoggedIn, + },{ + path: '/tabletop/:id', + name: 'tabletop', + components: { + default: Tabletop, + toolbar: TabletopToolbar, + }, + beforeEnter: ensureLoggedIn, },{ path: '/friends', components: { diff --git a/app/imports/ui/tabletop/SelectCreaturesDialog.vue b/app/imports/ui/tabletop/SelectCreaturesDialog.vue new file mode 100644 index 00000000..5b69cda5 --- /dev/null +++ b/app/imports/ui/tabletop/SelectCreaturesDialog.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/app/imports/ui/tabletop/TabletopActionCards.vue b/app/imports/ui/tabletop/TabletopActionCards.vue new file mode 100644 index 00000000..f7a38809 --- /dev/null +++ b/app/imports/ui/tabletop/TabletopActionCards.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/app/imports/ui/tabletop/TabletopComponent.vue b/app/imports/ui/tabletop/TabletopComponent.vue new file mode 100644 index 00000000..ef482ef1 --- /dev/null +++ b/app/imports/ui/tabletop/TabletopComponent.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/app/imports/ui/tabletop/TabletopCreatureCard.vue b/app/imports/ui/tabletop/TabletopCreatureCard.vue new file mode 100644 index 00000000..24081ede --- /dev/null +++ b/app/imports/ui/tabletop/TabletopCreatureCard.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/app/imports/ui/tabletop/TabletopLog.vue b/app/imports/ui/tabletop/TabletopLog.vue new file mode 100644 index 00000000..c93fabb6 --- /dev/null +++ b/app/imports/ui/tabletop/TabletopLog.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/app/imports/ui/tabletop/TabletopMap.vue b/app/imports/ui/tabletop/TabletopMap.vue new file mode 100644 index 00000000..e95e1939 --- /dev/null +++ b/app/imports/ui/tabletop/TabletopMap.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/app/imports/ui/tabletop/TabletopToolbar.vue b/app/imports/ui/tabletop/TabletopToolbar.vue new file mode 100644 index 00000000..d5b9913f --- /dev/null +++ b/app/imports/ui/tabletop/TabletopToolbar.vue @@ -0,0 +1,29 @@ + + + + +