Added basic XP system

This commit is contained in:
Stefan Zermatten
2020-06-05 16:14:26 +02:00
parent efb8b87a2d
commit d1e7eb2fa0
14 changed files with 589 additions and 22 deletions

View File

@@ -65,16 +65,21 @@ let CreatureSchema = new SimpleSchema({
type: String,
optional: true,
},
// Mechanics
deathSave: {
type: deathSaveSchema,
defaultValue: {},
},
// Sum of all XP gained by this character
xp: {
type: SimpleSchema.Integer,
defaultValue: 0,
},
// Sum of all levels granted by milestone XP
xpLevels: {
type: SimpleSchema.Integer,
defaultValue: 0,
},
weightCarried: {
type: Number,
defaultValue: 0,

View File

@@ -0,0 +1,164 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import { assertEditPermission } from '/imports/api/creature/creaturePermissions.js';
import Creatures from '/imports/api/creature/Creatures.js';
let Experiences = new Mongo.Collection('experiences');
let ExperienceSchema = new SimpleSchema({
name: {
type: String,
optional: true,
},
// The amount of XP this experience gives
xp: {
type: SimpleSchema.Integer,
optional: true,
min: 0,
},
// Setting levels instead of value grants whole levels
levels: {
type: SimpleSchema.Integer,
optional: true,
min: 0,
index: 1,
},
// The real-world date that it occured, usually sorted by date
date: {
type: Date,
autoValue: function() {
// If the date isn't set, set it to now
if (!this.isSet) {
return new Date();
}
},
index: 1,
},
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
},
});
Experiences.attachSchema(ExperienceSchema);
const insertExperienceForCreature = function({experience, creatureId, userId}){
assertEditPermission(creatureId, userId);
if (experience.xp){
Creatures.update(creatureId, {$inc: {xp: experience.xp}});
}
if (experience.levels) {
Creatures.update(creatureId, {$inc: {xpLevels: experience.levels}});
}
experience.creatureId = creatureId;
return Experiences.insert(experience);
};
const insertExperience = new ValidatedMethod({
name: 'Experiences.methods.insert',
validate: new SimpleSchema({
experience: {
type: ExperienceSchema.omit('creatureId'),
},
creatureIds: {
type: Array,
max: 12,
},
'creatureIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validator(),
run({experience, creatureIds}) {
let userId = this.userId;
if (!userId) {
throw new Meteor.Error('Experiences.methods.insert.denied',
'You need to be logged in to insert an experience');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Experiences.methods.insert.denied',
`The ${tier.name} tier does not allow you to grant experience`);
}
let insertedIds = [];
creatureIds.forEach(creatureId => {
let id = insertExperienceForCreature({experience, creatureId, userId});
insertedIds.push(id);
});
return insertedIds;
},
});
const removeExperience = new ValidatedMethod({
name: 'Experiences.methods.remove',
validate: new SimpleSchema({
experienceId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validator(),
run({experienceId}) {
let userId = this.userId;
if (!userId) {
throw new Meteor.Error('Experiences.methods.remove.denied',
'You need to be logged in to remove an experience');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Experiences.methods.remove.denied',
`The ${tier.name} tier does not allow you to remove an experience`);
}
let experience = Experiences.findOne(experienceId);
if (!experience) return;
let creatureId = experience.creatureId
assertEditPermission(creatureId, userId);
if (experience.xp){
Creatures.update(creatureId, {$inc: {xp: -experience.xp}});
}
if (experience.levels) {
Creatures.update(creatureId, {$inc: {xpLevels: -experience.levels}});
}
experience.creatureId = creatureId;
return Experiences.remove(experienceId);
},
});
const recomputeExperiences = new ValidatedMethod({
name: 'Experiences.methods.recompute',
validate: new SimpleSchema({
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validator(),
run({creatureId}) {
let userId = this.userId;
if (!userId) {
throw new Meteor.Error('Experiences.methods.recompute.denied',
'You need to be logged in to recompute a creature\'s experiences');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Experiences.methods.recompute.denied',
`The ${tier.name} tier does not allow you to recompute a creature's experiences`);
}
assertEditPermission(creatureId, userId);
let xp = 0;
let xpLevels = 0;
Experiences.find({
creatureId
}, {
fields: {xp: 1, levels: 1}
}).forEach(experience => {
xp += experience.xp || 0;
xpLevels += experience.levels || 0;
});
Creatures.update(creatureId, {$set: {xp, xpLevels}});
},
});
export default Experiences;
export { ExperienceSchema, insertExperience, removeExperience, recomputeExperiences };

View File

@@ -1,7 +1,7 @@
import SimpleSchema from 'simpl-schema';
let ExperienceSchema = new SimpleSchema({
name: {
title: {
type: String,
optional: true,
},
@@ -10,11 +10,6 @@ let ExperienceSchema = new SimpleSchema({
type: String,
optional: true,
},
// The amount of XP this experience gives
value: {
type: SimpleSchema.Integer,
optional: true,
},
// The real-world date that it occured
date: {
type: Date,
@@ -30,6 +25,20 @@ let ExperienceSchema = new SimpleSchema({
type: String,
optional: true,
},
// Tags to better find this entry later
tags: {
type: Array,
defaultValue: [],
},
'tags.$': {
type: String,
},
// ID of the journal this entry belongs to
journalId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
}
});
export { ExperienceSchema };

View File

@@ -1,14 +1,17 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import Creatures from '/imports/api/creature/Creatures.js';
import CreatureProperties from '/imports/api/creature/CreatureProperties.js'
import { assertOwnership } from '/imports/api/creature/creaturePermissions.js';
import Experiences from '/imports/api/creature/experience/Experiences.js';
function removeRelatedDocuments(charId){
CreatureProperties.remove({'ancestors.id': charId});
};
function removeRelatedDocuments(creatureId){
CreatureProperties.remove({'ancestors.id': creatureId});
Experiences.remove({creatureId});
}
const removeCreature = new ValidatedMethod({
name: "Creatures.methods.removeCreature", // DDP method name
name: 'Creatures.methods.removeCreature', // DDP method name
validate: new SimpleSchema({
charId: {
type: String,

View File

@@ -9,7 +9,6 @@ import { ContainerSchema } from '/imports/api/properties/Containers.js';
import { DamageSchema } from '/imports/api/properties/Damages.js';
import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js';
import { ComputedEffectSchema } from '/imports/api/properties/Effects.js';
import { ExperienceSchema } from '/imports/api/properties/Experiences.js';
import { FeatureSchema } from '/imports/api/properties/Features.js';
import { FolderSchema } from '/imports/api/properties/Folders.js';
import { ItemSchema } from '/imports/api/properties/Items.js';
@@ -33,7 +32,6 @@ const propertySchemasIndex = {
damage: DamageSchema,
damageMultiplier: DamageMultiplierSchema,
effect: ComputedEffectSchema,
experience: ExperienceSchema,
feature: FeatureSchema,
folder: FolderSchema,
note: NoteSchema,

View File

@@ -8,7 +8,6 @@ import { ClassLevelSchema } from '/imports/api/properties/ClassLevels.js';
import { DamageSchema } from '/imports/api/properties/Damages.js';
import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js';
import { EffectSchema } from '/imports/api/properties/Effects.js';
import { ExperienceSchema } from '/imports/api/properties/Experiences.js';
import { FeatureSchema } from '/imports/api/properties/Features.js';
import { FolderSchema } from '/imports/api/properties/Folders.js';
import { NoteSchema } from '/imports/api/properties/Notes.js';
@@ -33,7 +32,6 @@ const propertySchemasIndex = {
damage: DamageSchema,
damageMultiplier: DamageMultiplierSchema,
effect: EffectSchema,
experience: ExperienceSchema,
feature: FeatureSchema,
folder: FolderSchema,
note: NoteSchema,

View File

@@ -39,10 +39,6 @@ const PROPERTIES = Object.freeze({
icon: '$vuetify.icons.effect',
name: 'Effect'
},
experience: {
icon: '$vuetify.icons.experience',
name: 'Experience'
},
feature: {
icon: 'subject',
name: 'Feature'

View File

@@ -0,0 +1,32 @@
import SimpleSchema from 'simpl-schema';
import Creatures from '/imports/api/creature/Creatures.js';
import Experiences from '/imports/api/creature/experience/Experiences.js';
let schema = new SimpleSchema({
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
});
Meteor.publish('experiences', function(creatureId){
schema.validate({ creatureId });
this.autorun(function (){
let userId = this.userId;
let creatureCursor = Creatures.find({
_id: creatureId,
$or: [
{readers: userId},
{writers: userId},
{owner: userId},
{public: true},
],
});
if (!creatureCursor.count()) return this.ready();
return [
Experiences.find({
creatureId,
}),
];
});
});

View File

@@ -1,5 +1,6 @@
import '/imports/server/publications/characterList.js';
import '/imports/server/publications/library.js';
import '/imports/server/publications/singleCharacter.js';
import '/imports/server/publications/experiences.js';
import '/imports/server/publications/users.js';
import '/imports/server/publications/icons.js';

View File

@@ -20,6 +20,61 @@
</v-card-text>
</v-card>
</div>
<div>
<v-card class="class-details">
<v-card-title
v-if="creature.variables.level"
class="title"
>
Level {{ creature.variables.level.value }}
</v-card-title>
<v-list v-if="highestClassLevels.length">
<v-list-tile>
<v-list-tile-content>
<v-list-tile-title v-if="creature.xpLevels">
{{ creature.xpLevels }} Levels gained
</v-list-tile-title>
<v-list-tile-title v-else>
{{ creature.xp }} XP
</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<v-btn
flat
icon
data-id="experience-info-button"
@click="showExperienceList"
>
<v-icon>info</v-icon>
</v-btn>
</v-list-tile-action>
<v-list-tile-action>
<v-btn
flat
icon
data-id="experience-add-button"
@click="addExperience"
>
<v-icon>add</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
<v-list-tile
v-for="classLevel in highestClassLevels"
:key="classLevel._id"
>
<v-list-tile-content>
<v-list-tile-title>
{{ classLevel.name }}
</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-avatar>
{{ classLevel.level }}
</v-list-tile-avatar>
</v-list-tile>
</v-list>
</v-card>
</div>
<div
v-for="note in notes"
:key="note._id"
@@ -37,6 +92,7 @@ import Creatures from '/imports/api/creature/Creatures.js';
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import NoteCard from '/imports/ui/properties/components/persona/NoteCard.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js'
export default {
components: {
@@ -44,7 +100,10 @@ export default {
NoteCard,
},
props: {
creatureId: String,
creatureId: {
type: String,
required: true,
},
},
meteor: {
notes(){
@@ -58,8 +117,34 @@ export default {
},
creature(){
return Creatures.findOne(this.creatureId);
}
},
classLevels(){
return getActiveProperties({
ancestorId: this.creatureId,
filter: {type: 'classLevel'},
});
},
},
computed: {
highestClassLevels(){
let highestLevels = {};
let highestLevelsList = [];
this.classLevels.forEach(classLevel => {
let name = classLevel.vairableName;
if (
!highestLevels[name] ||
highestLevels[name].level < classLevel.level
){
highestLevels[name] = classLevel;
}
});
for (let name in highestLevels){
highestLevelsList.push(highestLevels[name]);
}
highestLevelsList.sort((a, b) => a.level - b.level);
return highestLevelsList;
},
},
methods: {
showCharacterForm(){
this.$store.commit('pushDialogStack', {
@@ -70,6 +155,25 @@ export default {
},
});
},
addExperience(){
this.$store.commit('pushDialogStack', {
component: 'experience-insert-dialog',
elementId: 'experience-add-button',
data: {
creatureIds: [this.creatureId],
startAsMilestone: !!this.creature.xpLevels
},
});
},
showExperienceList(){
this.$store.commit('pushDialogStack', {
component: 'experience-list-dialog',
elementId: 'experience-info-button',
data: {
creatureId: this.creatureId,
},
});
},
},
};
</script>

View File

@@ -0,0 +1,66 @@
<template lang="html">
<div class="experience-form">
<div class="layout column align-center">
<smart-switch
label="Milestone"
class="mx-3"
:value="milestone"
@change="makeMilestone"
/>
<text-field
v-if="milestone"
label="Levels"
type="number"
class="base-value-field text-xs-center large-format no-flex"
:value="model.levels"
:error-messages="errors.levels"
@change="change('levels', ...arguments)"
/>
<text-field
v-else
type="number"
class="base-value-field text-xs-center large-format no-flex"
suffix="XP"
:value="model.xp"
:error-messages="errors.xp"
@change="change('xp', ...arguments)"
/>
</div>
<text-field
label="Name"
:value="model.name"
:error-messages="errors.name"
@change="change('name', ...arguments)"
/>
</div>
</template>
<script>
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
export default {
mixins: [propertyFormMixin],
props: {
startAsMilestone: {
type: Boolean,
},
},
data(){return {
milestone: this.startAsMilestone,
}},
methods: {
makeMilestone(milestone, ack){
this.milestone = milestone;
if (milestone){
this.change('xp', undefined);
this.change('levels', 1, ack);
} else {
this.change('levels', undefined, ack);
}
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,96 @@
<template lang="html">
<dialog-base>
<experience-form
:start-as-milestone="startAsMilestone"
:model="model"
:errors="errors"
@change="change"
@push="push"
@pull="pull"
/>
<p
v-if="error"
class="error"
>
{{ error }}
</p>
<div
slot="actions"
class="layout row justify-end"
>
<v-btn
flat
:disabled="!valid"
:loading="loading"
@click="insertExperience"
>
Insert
</v-btn>
</div>
</dialog-base>
</template>
<script>
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import ExperienceForm from '/imports/ui/creature/experiences/ExperienceForm.vue';
import { ExperienceSchema, insertExperience } from '/imports/api/creature/experience/Experiences.js';
import schemaFormMixin from '/imports/ui/properties/forms/shared/schemaFormMixin.js';
export default {
components: {
DialogBase,
ExperienceForm,
},
mixins: [schemaFormMixin],
provide: {
context: {
debounceTime: 0,
},
},
props: {
creatureIds: {
type: Array,
required: true,
},
startAsMilestone: {
type: Boolean,
},
},
data(){
let schema = ExperienceSchema.omit('creatureId');
let startingModel = {};
if (this.startAsMilestone){
startingModel.levels = 1;
}
return {
model: schema.clean(startingModel),
schema: schema,
validationContext: schema.newContext(),
debounceTime: 0,
loading: false,
error: undefined,
};
},
methods:{
insertExperience(){
this.loading = true;
let experience = this.schema.clean(this.model);
insertExperience.call({
experience,
creatureIds: this.creatureIds,
}, (error) => {
this.loading = false;
if (error){
this.error = error.message || error;
console.error(error);
} else {
this.$store.dispatch('popDialogStack');
}
});
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,92 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-toolbar-title>
Experiences
</v-toolbar-title>
<v-spacer />
<v-btn
icon
flat
@click="recompute"
>
<v-icon>refresh</v-icon>
</v-btn>
</template>
<v-list>
<v-list-tile
v-for="experience in experiences"
:key="experience._id"
>
<v-list-tile-content>
<v-list-tile-title>
{{ experience.name }}
</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<v-btn
icon
flat
:loading="experiencesRemovalLoading.has(experience._id)"
@click="removeExperience(experience._id)"
>
<v-icon>delete</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
</v-list>
</dialog-base>
</template>
<script>
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import Experiences, { removeExperience, recomputeExperiences } from '/imports/api/creature/experience/Experiences.js';
export default {
components: {
DialogBase,
},
props: {
creatureId: {
type: String,
required: true,
},
},
data(){ return {
experiencesRemovalLoading: new Set(),
recomputeLoading: false,
}},
meteor: {
$subscribe: {
'experiences'(){
return [this.creatureId];
},
},
experiences(){
return Experiences.find({
creatureId: this.creatureId
}, {
sort: {date: 1}
});
}
},
methods: {
removeExperience(experienceId){
this.experiencesRemovalLoading.add(experienceId);
removeExperience.call({experienceId}, (error) => {
this.experiencesRemovalLoading.delete(experienceId);
if (error) console.error(error);
});
},
recompute(){
this.recomputeLoading = true;
recomputeExperiences.call({creatureId: this.creatureId}, error => {
this.recomputeLoading = false;
if (error) console.error(error);
});
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -3,6 +3,8 @@ import CreaturePropertyCreationDialog from '/imports/ui/creature/creaturePropert
import CreaturePropertyDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue'
import CreaturePropertyFromLibraryDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyFromLibraryDialog.vue'
import DeleteConfirmationDialog from '/imports/ui/dialogStack/DeleteConfirmationDialog.vue';
import ExperienceInsertDialog from '/imports/ui/creature/experiences/ExperienceInsertDialog.vue';
import ExperienceListDialog from '/imports/ui/creature/experiences/ExperienceListDialog.vue';
import InviteDialog from '/imports/ui/user/InviteDialog.vue';
import LibraryCreationDialog from '/imports/ui/library/LibraryCreationDialog.vue';
import LibraryEditDialog from '/imports/ui/library/LibraryEditDialog.vue';
@@ -13,13 +15,14 @@ import ShareDialog from '/imports/ui/sharing/ShareDialog.vue';
import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue';
import UsernameDialog from '/imports/ui/user/UsernameDialog.vue';
export default {
CreatureFormDialog,
CreaturePropertyCreationDialog,
CreaturePropertyDialog,
CreaturePropertyFromLibraryDialog,
DeleteConfirmationDialog,
ExperienceInsertDialog,
ExperienceListDialog,
InviteDialog,
LibraryCreationDialog,
LibraryEditDialog,