Backend work to support actions consuming their resources on use

This commit is contained in:
Stefan Zermatten
2020-06-13 23:11:49 +02:00
parent 1535e00093
commit dc18734d1f
11 changed files with 386 additions and 81 deletions

View File

@@ -20,6 +20,8 @@ import {
import {setDocToLastOrder} from '/imports/api/parenting/order.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import '/imports/api/creature/actions/doAction.js';
let CreatureProperties = new Mongo.Collection('creatureProperties');
let CreaturePropertySchema = new SimpleSchema({
@@ -56,7 +58,7 @@ for (let key in propertySchemasIndex){
});
}
function getCreature(property){
export function getCreature(property){
if (!property) throw new Meteor.Error('No property provided');
let creature = Creatures.findOne(property.ancestors[0].id);
if (!creature) throw new Meteor.Error('Creature does not exist');
@@ -231,6 +233,37 @@ const updateProperty = new ValidatedMethod({
},
});
export function damagePropertyWork({property, operation, value}){
if (operation === 'set'){
let currentValue = property.value;
// Set represents what we want the value to be after damage
// So we need the actual damage to get to that value
let damage = currentValue - value;
// Damage can't exceed total value
if (damage > currentValue) damage = currentValue;
// Damage must be positive
if (damage < 0) damage = 0;
CreatureProperties.update(property._id, {
$set: {damage}
}, {
selector: property
});
} else if (operation === 'increment'){
let currentValue = property.value - (property.damage || 0);
let currentDamage = property.damage;
let increment = value;
// Can't increase damage above the remaining value
if (increment > currentValue) increment = currentValue;
// Can't decrease damage below zero
if (-increment > currentDamage) increment = -currentDamage;
CreatureProperties.update(property._id, {
$inc: {damage: increment}
}, {
selector: property
});
}
}
const damageProperty = new ValidatedMethod({
name: 'creatureProperties.damage',
validate: new SimpleSchema({
@@ -258,38 +291,39 @@ const damageProperty = new ValidatedMethod({
`Property of type "${currentProperty.type}" can't be damaged`
);
}
if (operation === 'set'){
let currentValue = currentProperty.value;
// Set represents what we want the value to be after damage
// So we need the actual damage to get to that value
let damage = currentValue - value;
// Damage can't exceed total value
if (damage > currentValue) damage = currentValue;
// Damage must be positive
if (damage < 0) damage = 0;
CreatureProperties.update(_id, {
$set: {damage}
}, {
selector: currentProperty
});
} else if (operation === 'increment'){
let currentValue = currentProperty.value - (currentProperty.damage || 0);
let currentDamage = currentProperty.damage;
let increment = value;
// Can't increase damage above the remaining value
if (increment > currentValue) increment = currentValue;
// Can't decrease damage below zero
if (-increment > currentDamage) increment = -currentDamage;
CreatureProperties.update(_id, {
$inc: {damage: increment}
}, {
selector: currentProperty
});
}
damagePropertyWork({property: currentProperty, operation, value})
recomputeCreatures(currentProperty);
},
});
export function adjustQuantityWork({property, operation, value}){
// Check if property has quantity
let schema = CreatureProperties.simpleSchema(property);
if (!schema.allowsKey('quantity')){
throw new Meteor.Error(
'Adjust quantity failed',
`Property of type "${property.type}" doesn't have a quantity`
);
}
if (operation === 'set'){
CreatureProperties.update(property._id, {
$set: {quantity: value}
}, {
selector: property
});
} else if (operation === 'increment'){
// value here is 'damage'
value = -value;
let currentQuantity = property.quantity;
if (currentQuantity + value < 0) value = -currentQuantity;
CreatureProperties.update(property._id, {
$inc: {quantity: value}
}, {
selector: property
});
}
}
const adjustQuantity = new ValidatedMethod({
name: 'creatureProperties.adjustQuantity',
validate: new SimpleSchema({
@@ -309,31 +343,7 @@ const adjustQuantity = new ValidatedMethod({
let currentProperty = CreatureProperties.findOne(_id);
// Check permissions
assertPropertyEditPermission(currentProperty, this.userId);
// Check if property can take damage
let schema = CreatureProperties.simpleSchema(currentProperty);
if (!schema.allowsKey('quantity')){
throw new Meteor.Error(
'Adjust quantity failed',
`Property of type "${currentProperty.type}" doesn't have a quantity`
);
}
if (operation === 'set'){
CreatureProperties.update(_id, {
$set: {quantity: value}
}, {
selector: currentProperty
});
} else if (operation === 'increment'){
// value here is 'damage'
value = -value;
let currentQuantity = currentProperty.quantity;
if (currentQuantity + value < 0) value = -currentQuantity;
CreatureProperties.update(_id, {
$inc: {quantity: value}
}, {
selector: currentProperty
});
}
adjustQuantityWork({property: currentProperty, operation, value})
},
});

View File

@@ -0,0 +1,89 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import CreatureProperties, { getCreature, damagePropertyWork, adjustQuantityWork } from '/imports/api/creature/CreatureProperties.js';
import { assertEditPermission } from '/imports/api/creature/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/recomputeCreature.js';
const doAction = new ValidatedMethod({
name: 'creatureProperties.doAction',
validate: new SimpleSchema({
actionId: SimpleSchema.RegEx.Id,
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({actionId}) {
let action = CreatureProperties.findOne(actionId);
// Check permissions
let creature = getCreature(action);
assertEditPermission(creature, this.userId);
doActionWork(action);
// Note this only recomputes the top-level creature, not the nearest one
recomputeCreatureByDoc(creature);
},
});
function doActionWork(action){
spendResources(action);
}
function spendResources(action){
// Check Uses
if (action.usesUsed >= action.usesResult){
throw new Meteor.Error('Insufficient Uses',
'This action has no uses left');
}
// Resources
if (action.insufficientResources){
throw new Meteor.Error('Insufficient Resources',
'This creature doesn\'t have sufficient resources to perform this action');
}
// Items
let itemQuantityAdjustments = [];
action.resources.itemsConsumed.forEach(itemConsumed => {
if (!itemConsumed.itemId){
throw new Meteor.Error('Ammo not selected',
'No ammo was selected for this action');
}
let item = CreatureProperties.findOne(itemConsumed.itemId);
if (!item || item.ancestors[0].id !== action.ancestors[0].id){
throw new Meteor.Error('Ammo not found',
'The action\'s ammo was not found on the creature');
}
if (!item.equipped){
throw new Meteor.Error('Ammo not equipped',
'The selected ammo is not equipped');
}
if (!itemConsumed.quantity) return;
itemQuantityAdjustments.push({
property: item,
operation: 'increment',
value: itemConsumed.quantity,
});
});
// No more errors should be thrown after this line
// Now that we have confirmed that there are no errors, do actual work
//Items
itemQuantityAdjustments.forEach(adjustQuantityWork);
// Use uses
CreatureProperties.update(action._id, {
$inc: {usesUsed: 1}
}, {
selector: action
});
// Damage stats
action.resources.attributesConsumed.forEach(attConsumed => {
if (!attConsumed.quantity) return;
let stat = CreatureProperties.findOne(attConsumed.statId);
damagePropertyWork({
property: stat,
operation: 'increment',
value: attConsumed.quantity,
});
});
}
export default doAction;

View File

@@ -20,6 +20,7 @@ export default function computeEndStepProperty(prop, memo){
}
function computeAction(prop, memo){
// Uses
let {value, errors} = evaluateCalculation(prop.uses, memo);
prop.usesResult = value;
if (errors.length){
@@ -27,10 +28,28 @@ function computeAction(prop, memo){
} else {
delete prop.usesErrors;
}
// TODO compute resources.$.$.available and insufficientResources
prop.insufficientResources = undefined;
if (prop.usesUsed >= prop.usesResult){
prop.insufficientResources = true;
}
// Attributes consumed
prop.resources.attributesConsumed.forEach((attConsumed, i) => {
if (attConsumed.variableName){
let stat = memo.statsByVariableName[attConsumed.variableName];
prop.resources.attributesConsumed[i].statId = stat && stat._id;
let available = stat && stat.currentValue || 0;
prop.resources.attributesConsumed[i].available = available;
if (available < attConsumed.quantity){
prop.insufficientResources = true;
}
}
});
// Items consumed
// TODO
}
function computeAttack(prop, memo){
// Roll bonus
let {value, errors} = evaluateCalculation(prop.rollBonus, memo);
prop.rollBonusResult = value;
if (errors.length){

View File

@@ -91,7 +91,7 @@ export function recomputeCreatureById(creatureId){
* - Mark the stat as computed
* - Write the computed results back to the database
*/
function recomputeCreatureByDoc(creature){
export function recomputeCreatureByDoc(creature){
const creatureId = creature._id;
let props = getActiveProperties({
ancestorId: creatureId,

View File

@@ -1,5 +1,4 @@
import SimpleSchema from 'simpl-schema';
import ResourcesSchema from '/imports/api/properties/subSchemas/ResourcesSchema.js'
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
/*
@@ -41,13 +40,60 @@ let ActionSchema = new SimpleSchema({
'tags.$': {
type: String,
},
// Duplicate the ResourceSchema here so we can extend it elegantly.
resources: {
type: ResourcesSchema,
type: Object,
defaultValue: {},
},
'resources.itemsConsumed': {
type: Array,
defaultValue: [],
},
'resources.itemsConsumed.$': {
type: Object,
},
'resources.itemsConsumed.$._id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
autoValue(){
if (!this.isSet) return Random.id();
}
},
'resources.itemsConsumed.$.tag': {
type: String,
optional: true,
},
'resources.itemsConsumed.$.quantity': {
type: Number,
defaultValue: 1,
},
'resources.itemsConsumed.$.itemId': {
type: String,
optional: true,
},
'resources.attributesConsumed': {
type: Array,
defaultValue: [],
},
'resources.attributesConsumed.$': {
type: Object,
},
'resources.attributesConsumed.$._id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
autoValue(){
if (!this.isSet) return Random.id();
}
},
'resources.attributesConsumed.$.variableName': {
type: String,
optional: true,
},
'resources.attributesConsumed.$.quantity': {
type: Number,
defaultValue: 1,
},
// Calculation of how many times this action can be used
// Only set if this action tracks its own uses, rather than adjusting
// resources
uses: {
type: String,
optional: true,
@@ -84,12 +130,23 @@ const ComputedOnlyActionSchema = new SimpleSchema({
type: Number,
optional: true,
},
// This appears both in the computed and uncomputed schema because it can be
// set by both a computation or a form
'resources.itemsConsumed.$.itemId': {
type: String,
optional: true,
},
'resources.attributesConsumed': Array,
'resources.attributesConsumed.$': Object,
'resources.attributesConsumed.$.available': {
type: Number,
optional: true,
},
'resources.attributesConsumed.$.statId': {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
insufficientResources: {
type: Boolean,
optional: true,

View File

@@ -17,6 +17,10 @@ const ItemConsumedSchema = new SimpleSchema({
type: Number,
defaultValue: 1,
},
itemId: {
type: String,
optional: true,
},
});
export default ItemConsumedSchema;

View File

@@ -247,24 +247,15 @@
</div>
<div
v-if="actions.length"
v-for="action in actions"
:key="action._id"
class="actions"
>
<v-card>
<v-list
two-line
subheader
>
<v-subheader>Actions</v-subheader>
<action-list-tile
v-for="action in actions"
:key="action._id"
:model="action"
:data-id="action._id"
@click="clickProperty({_id: action._id})"
/>
</v-list>
</v-card>
<action-card
:model="action"
:data-id="action._id"
@click="clickProperty({_id: action._id})"
/>
</div>
<div
v-if="attacks.length"
@@ -304,6 +295,7 @@
import ResourceCard from '/imports/ui/properties/components/attributes/ResourceCard.vue';
import SpellSlotListTile from '/imports/ui/properties/components/attributes/SpellSlotListTile.vue';
import ActionListTile from '/imports/ui/properties/components/actions/ActionListTile.vue';
import ActionCard from '/imports/ui/properties/components/actions/ActionCard.vue';
import RestButton from '/imports/ui/creature/RestButton.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
@@ -346,6 +338,7 @@
ResourceCard,
SpellSlotListTile,
ActionListTile,
ActionCard,
},
props: {
creatureId: {

View File

@@ -160,6 +160,7 @@ export default {
});
},
push({path, value, ack}){
console.log({path, value, ack});
pushToProperty.call({_id: this._id, path, value}, (error) =>{
if (error) console.warn(error);
ack && ack(error && error.reason || error);

View File

@@ -0,0 +1,110 @@
<template lang="html">
<v-card
class="action"
@click="$emit('click')"
>
<v-card-title
primary-title
class="layout row pa-2"
>
<div class="avatar">
<v-btn
flat
icon
class="headline"
:disabled="model.insufficientResources"
@click.stop="doAction"
>
<template v-if="rollBonus">
{{ rollBonus }}
</template>
<v-icon v-else>
$vuetify.icons.action
</v-icon>
</v-btn>
</div>
<div class="action-header flex">
<div class="action-title">
{{ model.name }}
</div>
<div class="action-type">
action type text
</div>
</div>
<div class="action-uses">
{{ usesLeft }}/{{ totalUses }}
</div>
</v-card-title>
<v-card-text
v-if="childText"
class="action-details"
v-html="childText"
/>
</v-card>
</template>
<script>
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
import doAction from '/imports/api/creature/actions/doAction.js';
export default {
inject: {
context: {
default: {},
},
},
props: {
model: {
type: Object,
required: true,
},
attack: {
type: Boolean,
},
},
computed: {
hasClickListener(){
return this.$listeners && this.$listeners.click
},
rollBonus(){
return numberToSignedString(this.model.rollBonusResult);
},
childText(){
let scope = this.context.creature && this.context.creature.variables;
if (!this.model.children || !this.model.children.length) return;
let textArray = [];
this.model.children.forEach(child => {
if (child.type === 'damage'){
let { result } = evaluateString(child.amount, scope);
textArray.push(`${result} ${child.damageType}`);
} else if (child.type === 'savingThrow'){
textArray.push(`DC ${child.dcResult} ${child.name}`);
}
});
return textArray.join(' ');
},
totalUses(){
return Math.max(this.model.usesResult, 0);
},
usesLeft(){
return Math.max(this.model.usesResult - this.model.usesUsed, 0);
},
},
methods: {
click(e){
this.$emit('click', e);
},
doAction(){
doAction.call({actionId: this.model._id}, error => {
if (error){
console.error(error);
}
});
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -3,12 +3,19 @@
class="ability-list-tile"
v-on="hasClickListener ? {click} : {}"
>
<v-list-tile-avatar
<v-list-tile-action
v-if="attack"
class="headline"
>
{{ rollBonus }}
</v-list-tile-avatar>
<v-btn
flat
icon
class="headline"
:disabled="model.insufficientResources"
@click.stop="doAction"
>
{{ rollBonus }}
</v-btn>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
{{ model.name }}
@@ -20,7 +27,7 @@
</v-list-tile-content>
<v-list-tile-action v-if="model.usesResult">
<v-list-tile-action-text>
{{ model.usesResult - (model.usesUsed) }}/{{ model.usesResult }}
{{ usesLeft }}/{{ totalUses }}
</v-list-tile-action-text>
</v-list-tile-action>
</v-list-tile>
@@ -29,6 +36,7 @@
<script>
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
import doAction from '/imports/api/creature/actions/doAction.js';
export default {
inject: {
@@ -65,12 +73,25 @@ export default {
}
});
return textArray.join(' ');
},
totalUses(){
return Math.max(this.model.usesResult, 0);
},
usesLeft(){
return Math.max(this.model.usesResult - this.model.usesUsed, 0);
},
},
methods: {
click(e){
this.$emit('click', e);
},
doAction(){
doAction.call({actionId: this.model._id}, error => {
if (error){
console.error(error);
}
});
},
}
}
</script>

View File

@@ -83,6 +83,7 @@
this.addResourceLoading = false;
},
addAttributesConsumed(){
console.log(AttributeConsumedSchema.clean({}));
this.addResourceLoading = true;
this.$emit('push', {
path: ['attributesConsumed'],