wip(shared): port buy ops and linked fns

This commit is contained in:
Matteo Pagliazzi
2016-03-16 16:25:46 +01:00
parent e68ebee980
commit ff72706cae
11 changed files with 227 additions and 215 deletions

View File

@@ -77,6 +77,7 @@
"guildQuestsNotSupported": "Guilds cannot be invited on quests.", "guildQuestsNotSupported": "Guilds cannot be invited on quests.",
"questNotFound": "Quest \"<%= key %>\" not found.", "questNotFound": "Quest \"<%= key %>\" not found.",
"questNotOwned": "You don't own that quest scroll.", "questNotOwned": "You don't own that quest scroll.",
"questNotGoldPurchasable": "Quest \"<%= key %>\" is not a Gold-purchasable quest.",
"questLevelTooHigh": "You must be level <%= level %> to begin this quest.", "questLevelTooHigh": "You must be level <%= level %> to begin this quest.",
"questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.",
"questAlreadyAccepted": "You already accepted the quest invitation.", "questAlreadyAccepted": "You already accepted the quest invitation.",
@@ -102,5 +103,9 @@
"spellNotOwned": "You don't own this spell.", "spellNotOwned": "You don't own this spell.",
"spellLevelTooHigh": "You must be level <%= level %> to use this spell.", "spellLevelTooHigh": "You must be level <%= level %> to use this spell.",
"invalidAttribute": "\"<%= attr %>\" is not a valid attribute.", "invalidAttribute": "\"<%= attr %>\" is not a valid attribute.",
"notEnoughAttrPoints": "You don't have enough attribute points." "notEnoughAttrPoints": "You don't have enough attribute points.",
"missingKeyParam": "\"req.params.key\" is required.",
"mysterySetNotFound": "Mystery set not found, or set already owned",
"itemNotFound": "Item \"<%= key %>\" not found.",
"cannoyBuyItem": "You can't buy this item"
} }

View File

@@ -1,24 +1,23 @@
import content from '../content/index'; import content from '../content/index';
import i18n from '../i18n'; import i18n from '../i18n';
module.exports = function(user, item, type, req) { module.exports = function handleTwoHanded (user, item, type = 'equipped', req) {
var message, currentWeapon, currentShield; let currentShield = content.gear.flat[user.items.gear[type].shield];
if (type == null) { let currentWeapon = content.gear.flat[user.items.gear[type].weapon];
type = 'equipped';
}
currentShield = content.gear.flat[user.items.gear[type].shield];
currentWeapon = content.gear.flat[user.items.gear[type].weapon];
if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) { let message;
if (item.type === 'shield' && (currentWeapon ? currentWeapon.twoHanded : false)) {
user.items.gear[type].weapon = 'weapon_base_0'; user.items.gear[type].weapon = 'weapon_base_0';
message = i18n.t('messageTwoHandedUnequip', { message = i18n.t('messageTwoHandedUnequip', {
twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language), twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language),
}, req.language); }, req.language);
} else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { } else if (item.twoHanded && (currentShield && user.items.gear[type].shield !== 'shield_base_0')) {
user.items.gear[type].shield = "shield_base_0"; user.items.gear[type].shield = 'shield_base_0';
message = i18n.t('messageTwoHandedEquip', { message = i18n.t('messageTwoHandedEquip', {
twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language), twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language),
}, req.language); }, req.language);
} }
return message; return message;
}; };

View File

@@ -1,20 +1,19 @@
import _ from 'lodash'; import _ from 'lodash';
/*
Because the same op needs to be performed on the client and the server (critical hits, item drops, etc),
we need things to be "random", but technically predictable so that they don't go out-of-sync
*/
module.exports = function(user, seed) { // Because the same op needs to be performed on the client and the server (critical hits, item drops, etc),
var x; // we need things to be "random", but technically predictable so that they don't go out-of-sync
module.exports = function predictableRandom (user, seed) {
if (!seed || seed === Math.PI) { if (!seed || seed === Math.PI) {
seed = _.reduce(user.stats, (function(m, v) { seed = _.reduce(user.stats, (accumulator, val) => {
if (_.isNumber(v)) { if (_.isNumber(val)) {
return m + v; return accumulator + val;
} else { } else {
return m; return accumulator;
} }
}), 0); }, 0);
} }
x = Math.sin(seed++) * 10000;
let x = Math.sin(seed++) * 10000;
return x - Math.floor(x); return x - Math.floor(x);
}; };

View File

@@ -1,14 +1,12 @@
import _ from 'lodash'; import _ from 'lodash';
import predictableRandom from './predictableRandom';
/* // Get a random property from an object
Get a random property from an object // returns random property (the value)
returns random property (the value)
*/
module.exports = function(user, obj, options) { module.exports = function randomVal (user, obj, options = {}) {
var array, rand; let array = options.key ? _.keys(obj) : _.values(obj);
array = (options != null ? options.key : void 0) ? _.keys(obj) : _.values(obj); let rand = predictableRandom(user, options.seed);
rand = user.fns.predictableRandom(options != null ? options.seed : void 0);
array.sort(); array.sort();
return array[Math.floor(rand * array.length)]; return array[Math.floor(rand * array.length)];
}; };

View File

@@ -1,33 +1,35 @@
import content from '../content/index'; import content from '../content/index';
import _ from 'lodash'; import _ from 'lodash';
module.exports = function(user) { module.exports = function ultimateGear (user) {
var base, owned; let owned = window ? user.items.gear.owned : user.items.gear.owned.toObject();
owned = typeof window !== "undefined" && window !== null ? user.items.gear.owned : user.items.gear.owned.toObject();
if ((base = user.achievements).ultimateGearSets == null) { if (!user.achievements.ultimateGearSets) {
base.ultimateGearSets = { user.achievements.ultimateGearSets = {
healer: false, healer: false,
wizard: false, wizard: false,
rogue: false, rogue: false,
warrior: false warrior: false,
}; };
} }
content.classes.forEach(function(klass) {
content.classes.forEach((klass) => {
if (user.achievements.ultimateGearSets[klass] !== true) { if (user.achievements.ultimateGearSets[klass] !== true) {
return user.achievements.ultimateGearSets[klass] = _.reduce(['armor', 'shield', 'head', 'weapon'], function(soFarGood, type) { user.achievements.ultimateGearSets[klass] = _.reduce(['armor', 'shield', 'head', 'weapon'], (soFarGood, type) => {
var found; let found = _.find(content.gear.tree[type][klass], {
found = _.find(content.gear.tree[type][klass], { last: true,
last: true
}); });
return soFarGood && (!found || owned[found.key] === true); return soFarGood && (!found || owned[found.key] === true);
}, true); }, true);
} }
}); });
if (typeof user.markModified === "function") {
user.markModified('achievements.ultimateGearSets'); // TODO
} if (user.markModified) user.markModified('achievements.ultimateGearSets');
if (_.contains(user.achievements.ultimateGearSets, true) && user.flags.armoireEnabled !== true) { if (_.contains(user.achievements.ultimateGearSets, true) && user.flags.armoireEnabled !== true) {
user.flags.armoireEnabled = true; user.flags.armoireEnabled = true;
return typeof user.markModified === "function" ? user.markModified('flags') : void 0;
} }
return;
}; };

View File

@@ -3,117 +3,133 @@ import i18n from '../i18n';
import _ from 'lodash'; import _ from 'lodash';
import count from '../count'; import count from '../count';
import splitWhitespace from '../libs/splitWhitespace'; import splitWhitespace from '../libs/splitWhitespace';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../libs/errors';
import predictableRandom from '../fns/predictableRandom';
import randomVal from '../fns/randomVal';
import handleTwoHanded from '../fns/handleTwoHanded';
import ultimateGear from '../fns/ultimateGear';
module.exports = function(user, req, cb, analytics) { module.exports = function buy (user, req = {}, analytics) {
var analyticsData, armoireExp, armoireResp, armoireResult, base, buyResp, drop, eligibleEquipment, item, key, message, name; let key = _.get(req, 'params.key');
key = req.params.key; if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language));
item = key === 'potion' ? content.potion : key === 'armoire' ? content.armoire : content.gear.flat[key];
if (!item) { let item;
return typeof cb === "function" ? cb({ if (key === 'potion') {
code: 404, item = content.potion;
message: "Item '" + key + " not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" } else if (key === 'armoire') {
}) : void 0; item = content.armoire;
} else {
item = content.gear.flat[key];
} }
if (!item) throw new NotFound(i18n.t('itemNotFound', {key}, req.language));
if (user.stats.gp < item.value) { if (user.stats.gp < item.value) {
return typeof cb === "function" ? cb({ throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language));
code: 401,
message: i18n.t('messageNotEnoughGold', req.language)
}) : void 0;
} }
if ((item.canOwn != null) && !item.canOwn(user)) {
return typeof cb === "function" ? cb({ if (item.canOwn && !item.canOwn(user)) {
code: 401, throw new NotAuthorized(i18n.t('cannoyBuyItem', req.language));
message: "You can't buy this item"
}) : void 0;
} }
armoireResp = void 0;
let armoireResp;
let armoireResult;
let eligibleEquipment;
let drop;
let message;
if (item.key === 'potion') { if (item.key === 'potion') {
user.stats.hp += 15; user.stats.hp += 15;
if (user.stats.hp > 50) { if (user.stats.hp > 50) {
user.stats.hp = 50; user.stats.hp = 50;
} }
} else if (item.key === 'armoire') { } else if (item.key === 'armoire') {
armoireResult = user.fns.predictableRandom(user.stats.gp); armoireResult = predictableRandom(user, user.stats.gp);
eligibleEquipment = _.filter(content.gear.flat, (function(i) { eligibleEquipment = _.filter(content.gear.flat, (eligible) => {
return i.klass === 'armoire' && !user.items.gear.owned[i.key]; return eligible.klass === 'armoire' && !user.items.gear.owned[eligible.key];
})); });
if (!_.isEmpty(eligibleEquipment) && (armoireResult < .6 || !user.flags.armoireOpened)) {
if (!_.isEmpty(eligibleEquipment) && (armoireResult < 0.6 || !user.flags.armoireOpened)) {
eligibleEquipment.sort(); eligibleEquipment.sort();
drop = user.fns.randomVal(eligibleEquipment); drop = randomVal(user, eligibleEquipment);
user.items.gear.owned[drop.key] = true; user.items.gear.owned[drop.key] = true;
user.flags.armoireOpened = true; user.flags.armoireOpened = true;
message = i18n.t('armoireEquipment', { message = i18n.t('armoireEquipment', {
image: '<span class="shop_' + drop.key + ' pull-left"></span>', image: `<span class="shop_${drop.key} pull-left"></span>`,
dropText: drop.text(req.language) dropText: drop.text(req.language),
}, req.language); }, req.language);
if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) { if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) {
user.flags.armoireEmpty = true; user.flags.armoireEmpty = true;
} }
armoireResp = { armoireResp = {
type: "gear", type: 'gear',
dropKey: drop.key, dropKey: drop.key,
dropText: drop.text(req.language) dropText: drop.text(req.language),
}; };
} else if ((!_.isEmpty(eligibleEquipment) && armoireResult < .8) || armoireResult < .5) { } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < 0.8) || armoireResult < 0.5) { // eslint-disable-line no-extra-parens
drop = user.fns.randomVal(_.where(content.food, { drop = randomVal(_.where(content.food, {
canDrop: true canDrop: true,
})); }));
if ((base = user.items.food)[name = drop.key] == null) { user.items.food[drop.key] = user.items.food[drop.key] || 0;
base[name] = 0;
}
user.items.food[drop.key] += 1; user.items.food[drop.key] += 1;
message = i18n.t('armoireFood', { message = i18n.t('armoireFood', {
image: '<span class="Pet_Food_' + drop.key + ' pull-left"></span>', image: `<span class="Pet_Food_${drop.key} pull-left"></span>`,
dropArticle: drop.article, dropArticle: drop.article,
dropText: drop.text(req.language) dropText: drop.text(req.language),
}, req.language); }, req.language);
armoireResp = { armoireResp = {
type: "food", type: 'food',
dropKey: drop.key, dropKey: drop.key,
dropArticle: drop.article, dropArticle: drop.article,
dropText: drop.text(req.language) dropText: drop.text(req.language),
}; };
} else { } else {
armoireExp = Math.floor(user.fns.predictableRandom(user.stats.exp) * 40 + 10); let armoireExp = Math.floor(predictableRandom(user, user.stats.exp) * 40 + 10);
user.stats.exp += armoireExp; user.stats.exp += armoireExp;
message = i18n.t('armoireExp', req.language); message = i18n.t('armoireExp', req.language);
armoireResp = { armoireResp = {
"type": "experience", type: 'experience',
"value": armoireExp value: armoireExp,
}; };
} }
} else { } else {
if (user.preferences.autoEquip) { if (user.preferences.autoEquip) {
user.items.gear.equipped[item.type] = item.key; user.items.gear.equipped[item.type] = item.key;
message = user.fns.handleTwoHanded(item, null, req); message = handleTwoHanded(user, item, null, req);
} }
user.items.gear.owned[item.key] = true; user.items.gear.owned[item.key] = true;
if (message == null) {
if (!message) {
message = i18n.t('messageBought', { message = i18n.t('messageBought', {
itemText: item.text(req.language) itemText: item.text(req.language),
}, req.language); }, req.language);
} }
if (item.last) { if (item.last) ultimateGear(user);
user.fns.ultimateGear();
}
} }
user.stats.gp -= item.value; user.stats.gp -= item.value;
analyticsData = { if (analytics) {
uuid: user._id, analytics.track('acquire item', {
itemKey: key, uuid: user._id,
acquireMethod: 'Gold', itemKey: key,
goldCost: item.value, acquireMethod: 'Gold',
category: 'behavior' goldCost: item.value,
category: 'behavior',
});
}
let buyResp = _.pick(user, splitWhitespace('items achievements stats flags'));
if (armoireResp) buyResp.armoire = armoireResp;
return {
data: buyResp,
message,
}; };
if (analytics != null) {
analytics.track('acquire item', analyticsData);
}
buyResp = _.pick(user, splitWhitespace('items achievements stats flags'));
if (armoireResp) {
buyResp["armoire"] = armoireResp;
}
return typeof cb === "function" ? cb({
code: 200,
message: message
}, buyResp) : void 0;
}; };

View File

@@ -2,42 +2,48 @@ import i18n from '../i18n';
import content from '../content/index'; import content from '../content/index';
import _ from 'lodash'; import _ from 'lodash';
import splitWhitespace from '../libs/splitWhitespace'; import splitWhitespace from '../libs/splitWhitespace';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../libs/errors';
module.exports = function buyMysterySet (user, req = {}, analytics) {
let key = _.get(req, 'params.key');
if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language));
module.exports = function(user, req, cb, analytics) {
var mysterySet, ref;
if (!(user.purchased.plan.consecutive.trinkets > 0)) { if (!(user.purchased.plan.consecutive.trinkets > 0)) {
return typeof cb === "function" ? cb({ throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language));
code: 401,
message: i18n.t('notEnoughHourglasses', req.language)
}) : void 0;
}
mysterySet = (ref = content.timeTravelerStore(user.items.gear.owned)) != null ? ref[req.params.key] : void 0;
if ((typeof window !== "undefined" && window !== null ? window.confirm : void 0) != null) {
if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) {
return;
}
} }
let ref = content.timeTravelerStore(user.items.gear.owned);
let mysterySet = ref ? ref[key] : undefined;
if (!mysterySet) { if (!mysterySet) {
return typeof cb === "function" ? cb({ throw new NotFound(i18n.t('mysterySetNotFound', req.language));
code: 404,
message: "Mystery set not found, or set already owned"
}) : void 0;
} }
_.each(mysterySet.items, function(i) {
var analyticsData; if (window && window.confirm) { // TODO move to client
user.items.gear.owned[i.key] = true; if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) return;
analyticsData = { }
uuid: user._id,
itemKey: i.key, _.each(mysterySet.items, item => {
itemType: 'Subscriber Gear', user.items.gear.owned[item.key] = true;
acquireMethod: 'Hourglass', if (analytics) {
category: 'behavior' analytics.track('acquire item', {
}; uuid: user._id,
return analytics != null ? analytics.track('acquire item', analyticsData) : void 0; itemKey: item.key,
itemType: 'Subscriber Gear',
acquireMethod: 'Hourglass',
category: 'behavior',
});
}
}); });
user.purchased.plan.consecutive.trinkets--; user.purchased.plan.consecutive.trinkets--;
return typeof cb === "function" ? cb({
code: 200, return {
message: i18n.t('hourglassPurchaseSet', req.language) data: _.pick(user, splitWhitespace('items purchased.plan.consecutive')),
}, _.pick(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; message: i18n.t('hourglassPurchaseSet', req.language),
};
}; };

View File

@@ -1,49 +1,44 @@
import i18n from '../i18n'; import i18n from '../i18n';
import content from '../content/index'; import content from '../content/index';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../libs/errors';
import _ from 'lodash';
module.exports = function buyQuest (user, req = {}, analytics) {
let key = _.get(req, 'params.key');
if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language));
let item = content.quests[key];
if (!item) throw new NotFound(i18n.t('questNotFound', req.language));
module.exports = function(user, req, cb, analytics) {
var analyticsData, base, item, key, message, name;
key = req.params.key;
item = content.quests[key];
if (!item) {
return typeof cb === "function" ? cb({
code: 404,
message: "Quest '" + key + " not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)"
}) : void 0;
}
if (!(item.category === 'gold' && item.goldValue)) { if (!(item.category === 'gold' && item.goldValue)) {
return typeof cb === "function" ? cb({ throw new NotAuthorized(i18n.t('questNotGoldPurchasable', {key}, req.language));
code: 404,
message: "Quest '" + key + " is not a Gold-purchasable quest (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)"
}) : void 0;
} }
if (user.stats.gp < item.goldValue) { if (user.stats.gp < item.goldValue) {
return typeof cb === "function" ? cb({ throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language));
code: 401,
message: i18n.t('messageNotEnoughGold', req.language)
}) : void 0;
} }
message = i18n.t('messageBought', {
itemText: item.text(req.language) user.items.quests[item.key] = user.items.quests[item.key] || 0;
}, req.language); user.items.quests[item.key]++;
if ((base = user.items.quests)[name = item.key] == null) {
base[name] = 0;
}
user.items.quests[item.key] += 1;
user.stats.gp -= item.goldValue; user.stats.gp -= item.goldValue;
analyticsData = {
uuid: user._id, if (analytics) {
itemKey: item.key, analytics.track('acquire item', {
itemType: 'Market', uuid: user._id,
goldCost: item.goldValue, itemKey: item.key,
acquireMethod: 'Gold', itemType: 'Market',
category: 'behavior' goldCost: item.goldValue,
}; acquireMethod: 'Gold',
if (analytics != null) { category: 'behavior',
analytics.track('acquire item', analyticsData); });
} }
return typeof cb === "function" ? cb({
code: 200, return {
message: message data: user.items.quests,
}, user.items.quests) : void 0; message: i18n.t('messageBought', {
itemText: item.text(req.language),
}, req.language),
};
}; };

View File

@@ -2,30 +2,30 @@ import i18n from '../i18n';
import content from '../content/index'; import content from '../content/index';
import _ from 'lodash'; import _ from 'lodash';
import splitWhitespace from '../libs/splitWhitespace'; import splitWhitespace from '../libs/splitWhitespace';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../libs/errors';
module.exports = function buySpecialSpell (user, req = {}) {
let key = _.get(req, 'params.key');
if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language));
let item = content.special[key];
if (!item) throw new NotFound(i18n.t('spellNotFound', {spellId: key}, req.language));
module.exports = function(user, req, cb) {
var base, item, key, message;
key = req.params.key;
item = content.special[key];
if (user.stats.gp < item.value) { if (user.stats.gp < item.value) {
return typeof cb === "function" ? cb({ throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language));
code: 401,
message: i18n.t('messageNotEnoughGold', req.language)
}) : void 0;
} }
user.stats.gp -= item.value; user.stats.gp -= item.value;
if ((base = user.items.special)[key] == null) {
base[key] = 0;
}
user.items.special[key]++; user.items.special[key]++;
if (typeof user.markModified === "function") {
user.markModified('items.special'); return {
} data: _.pick(user, splitWhitespace('items stats')),
message = i18n.t('messageBought', { message: i18n.t('messageBought', {
itemText: item.text(req.language) itemText: item.text(req.language),
}, req.language); }, req.language),
return typeof cb === "function" ? cb({ };
code: 200,
message: message
}, _.pick(user, splitWhitespace('items stats'))) : void 0;
}; };

View File

@@ -21,10 +21,6 @@ const COMMON_FILES = [
'!./common/script/ops/addWebhook.js', '!./common/script/ops/addWebhook.js',
'!./common/script/ops/allocateNow.js', '!./common/script/ops/allocateNow.js',
'!./common/script/ops/blockUser.js', '!./common/script/ops/blockUser.js',
'!./common/script/ops/buy.js',
'!./common/script/ops/buyMysterySet.js',
'!./common/script/ops/buyQuest.js',
'!./common/script/ops/buySpecialSpell.js',
'!./common/script/ops/changeClass.js', '!./common/script/ops/changeClass.js',
'!./common/script/ops/clearCompleted.js', '!./common/script/ops/clearCompleted.js',
'!./common/script/ops/clearPMs.js', '!./common/script/ops/clearPMs.js',
@@ -63,13 +59,9 @@ const COMMON_FILES = [
'!./common/script/fns/dotGet.js', '!./common/script/fns/dotGet.js',
'!./common/script/fns/dotSet.js', '!./common/script/fns/dotSet.js',
'!./common/script/fns/getItem.js', '!./common/script/fns/getItem.js',
'!./common/script/fns/handleTwoHanded.js',
'!./common/script/fns/nullify.js', '!./common/script/fns/nullify.js',
'!./common/script/fns/predictableRandom.js',
'!./common/script/fns/preenUserHistory.js', '!./common/script/fns/preenUserHistory.js',
'!./common/script/fns/randomDrop.js', '!./common/script/fns/randomDrop.js',
'!./common/script/fns/randomVal.js',
'!./common/script/fns/ultimateGear.js',
'!./common/script/fns/updateStats.js', '!./common/script/fns/updateStats.js',
'!./common/script/libs/appliedTags.js', '!./common/script/libs/appliedTags.js',
'!./common/script/libs/countExists.js', '!./common/script/libs/countExists.js',

View File

@@ -263,15 +263,15 @@ export let schema = new Schema({
spookDust: {type: Number, default: 0}, spookDust: {type: Number, default: 0},
shinySeed: {type: Number, default: 0}, shinySeed: {type: Number, default: 0},
seafoam: {type: Number, default: 0}, seafoam: {type: Number, default: 0},
valentine: Number, valentine: {type: Number, default: 0},
valentineReceived: Array, // array of strings, by sender name valentineReceived: Array, // array of strings, by sender name
nye: Number, nye: {type: Number, default: 0},
nyeReceived: Array, nyeReceived: Array,
greeting: Number, greeting: {type: Number, default: 0},
greetingReceived: Array, greetingReceived: Array,
thankyou: Number, thankyou: {type: Number, default: 0},
thankyouReceived: Array, thankyouReceived: Array,
birthday: Number, birthday: {type: Number, default: 0},
birthdayReceived: Array, birthdayReceived: Array,
}, },