Guild A/B test and Achievement (#8740)

* WIP(guilds): AB test pester modal

* WIP(AB-test): guild pester cont'd

* fix(style): linting error

* fix(AB-test): markModified and notif enum

* fix(tests): update AB expectations

* fix(modal): remove extra includes

* feat(achievements): add Joined Guild cheevo
Also removes unused achievement sprites, and properly saves counter used in A/B testing

* fix(style): linting error from conflict
This commit is contained in:
Sabe Jones
2017-05-19 14:45:11 -05:00
committed by GitHub
parent 8a9ed04f5e
commit 547c87dee7
64 changed files with 167 additions and 28 deletions

View File

@@ -74,6 +74,18 @@ describe('POST /group', () => {
expect(updatedUser.guilds).to.include(guild._id);
});
it('awards the Joined Guild achievement', async () => {
await user.post('/groups', {
name: 'some guild',
type: 'guild',
privacy: 'public',
});
let updatedUser = await user.get('/user');
expect(updatedUser.achievements.joinedGuild).to.eql(true);
});
context('public guild', () => {
it('creates a group', async () => {
let groupName = 'Test Public Guild';

View File

@@ -68,6 +68,12 @@ describe('POST /group/:groupId/join', () => {
await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1);
});
it('awards Joined Guild achievement', async () => {
await joiningUser.post(`/groups/${publicGuild._id}/join`);
await expect(joiningUser.get('/user')).to.eventually.have.deep.property('achievements.joinedGuild', true);
});
});
context('Joining a private guild', () => {
@@ -147,8 +153,14 @@ describe('POST /group/:groupId/join', () => {
}),
};
expect(inviter.notifications[0].type).to.eql('GROUP_INVITE_ACCEPTED');
expect(inviter.notifications[0].data).to.eql(expectedData);
expect(inviter.notifications[1].type).to.eql('GROUP_INVITE_ACCEPTED');
expect(inviter.notifications[1].data).to.eql(expectedData);
});
it('awards Joined Guild achievement', async () => {
await invitedUser.post(`/groups/${guild._id}/join`);
await expect(invitedUser.get('/user')).to.eventually.have.deep.property('achievements.joinedGuild', true);
});
});
});

View File

@@ -112,8 +112,8 @@ describe('POST /tasks/:id/approve/:userId', () => {
await user.sync();
await member2.sync();
expect(user.notifications.length).to.equal(1);
expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(user.notifications.length).to.equal(2);
expect(user.notifications[1].type).to.equal('GROUP_TASK_APPROVAL');
expect(member2.notifications.length).to.equal(1);
expect(member2.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
@@ -122,7 +122,7 @@ describe('POST /tasks/:id/approve/:userId', () => {
await user.sync();
await member2.sync();
expect(user.notifications.length).to.equal(0);
expect(user.notifications.length).to.equal(1);
expect(member2.notifications.length).to.equal(0);
});

View File

@@ -52,14 +52,14 @@ describe('POST /tasks/:id/score/:direction', () => {
await user.sync();
expect(user.notifications.length).to.equal(1);
expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(user.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', {
expect(user.notifications.length).to.equal(2);
expect(user.notifications[1].type).to.equal('GROUP_TASK_APPROVAL');
expect(user.notifications[1].data.message).to.equal(t('userHasRequestedTaskApproval', {
user: member.auth.local.username,
taskName: updatedTask.text,
taskId: updatedTask._id,
}, 'cs')); // This test only works if we have the notification translated
expect(user.notifications[0].data.groupId).to.equal(guild._id);
expect(user.notifications[1].data.groupId).to.equal(guild._id);
expect(updatedTask.group.approval.requested).to.equal(true);
expect(updatedTask.group.approval.requestedDate).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
@@ -82,14 +82,14 @@ describe('POST /tasks/:id/score/:direction', () => {
await user.sync();
await member2.sync();
expect(user.notifications.length).to.equal(1);
expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(user.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', {
expect(user.notifications.length).to.equal(2);
expect(user.notifications[1].type).to.equal('GROUP_TASK_APPROVAL');
expect(user.notifications[1].data.message).to.equal(t('userHasRequestedTaskApproval', {
user: member.auth.local.username,
taskName: updatedTask.text,
taskId: updatedTask._id,
}));
expect(user.notifications[0].data.groupId).to.equal(guild._id);
expect(user.notifications[1].data.groupId).to.equal(guild._id);
expect(member2.notifications.length).to.equal(1);
expect(member2.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');

View File

@@ -251,7 +251,6 @@ describe('POST /user/auth/local/register', () => {
confirmPassword: password,
});
await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string');
await expect(getProperty('users', user._id, '_ABtests')).to.eventually.be.a('object');
});

View File

@@ -83,7 +83,7 @@ describe('POST /user/auth/social', () => {
network,
});
await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string');
await expect(getProperty('users', user._id, '_ABtests')).to.eventually.be.a('object');
});
});
@@ -139,7 +139,7 @@ describe('POST /user/auth/social', () => {
network,
});
await expect(getProperty('users', user._id, '_ABtest')).to.eventually.be.a('string');
await expect(getProperty('users', user._id, '_ABtests')).to.eventually.be.a('object');
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -78,7 +78,7 @@ habitrpg.controller('NotificationCtrl',
// Avoid showing the same notiication more than once
var lastShownNotifications = [];
function trasnferGroupNotification(notification) {
function transferGroupNotification(notification) {
if (!User.user.groupNotifications) User.user.groupNotifications = [];
User.user.groupNotifications.push(notification);
}
@@ -110,6 +110,13 @@ habitrpg.controller('NotificationCtrl',
var markAsRead = true;
switch (notification.type) {
case 'GUILD_PROMPT':
if (notification.data.textVariant === -1) {
$rootScope.openModal('testing');
} else {
$rootScope.openModal('testingVariant');
}
break;
case 'DROPS_ENABLED':
$rootScope.openModal('dropsEnabled');
break;
@@ -129,12 +136,19 @@ habitrpg.controller('NotificationCtrl',
}
break;
case 'ULTIMATE_GEAR_ACHIEVEMENT':
$rootScope.playSound('Achievement_Unlocked');
Achievement.displayAchievement('ultimateGear', {size: 'md'});
break;
case 'REBIRTH_ACHIEVEMENT':
$rootScope.playSound('Achievement_Unlocked');
Achievement.displayAchievement('rebirth');
break;
case 'GUILD_JOINED_ACHIEVEMENT':
$rootScope.playSound('Achievement_Unlocked');
Achievement.displayAchievement('joinedGuild', {size: 'md'});
break;
case 'NEW_CONTRIBUTOR_LEVEL':
$rootScope.playSound('Achievement_Unlocked');
Achievement.displayAchievement('contributor', {size: 'md'});
break;
case 'CRON':
@@ -144,11 +158,11 @@ habitrpg.controller('NotificationCtrl',
}
break;
case 'GROUP_TASK_APPROVAL':
trasnferGroupNotification(notification);
transferGroupNotification(notification);
markAsRead = false;
break;
case 'GROUP_TASK_APPROVED':
trasnferGroupNotification(notification);
transferGroupNotification(notification);
markAsRead = false;
break;
case 'SCORED_TASK':

View File

@@ -282,5 +282,7 @@
"userIsNotManager": "User is not manager",
"canOnlyApproveTaskOnce": "This task has already been approved.",
"leaderMarker": " - Leader",
"managerMarker": " - Manager"
"managerMarker": " - Manager",
"joinedGuild": "Joined a Guild",
"joinedGuildText": "Ventured into the social side of Habitica by joining a Guild!"
}

View File

@@ -0,0 +1,7 @@
{
"guildReminderTitle": "Check out Guilds!",
"guildReminderText1": "Now that you've mastered Habitica's basics, come check out Guilds, where you can chat with other people who are committed to improving their lives!",
"guildReminderText2": "Ready for more? Join a Guild to meet other people who are committed to improving their lives!",
"guildReminderCTA": "Take me there!",
"guildReminderDismiss": "Not right now."
}

View File

@@ -102,6 +102,11 @@ let basicAchievs = {
titleKey: 'royallyLoyal',
textKey: 'royallyLoyalText',
},
joinedGuild: {
icon: 'achievement-guild',
titleKey: 'joinedGuild',
textKey: 'joinedGuildText',
},
};
Object.assign(achievementsData, basicAchievs);

View File

@@ -179,6 +179,7 @@ function _getBasicAchievements (user, language) {
_addSimple(result, user, {path: 'partyUp', language});
_addSimple(result, user, {path: 'partyOn', language});
_addSimple(result, user, {path: 'joinedGuild', language});
_addSimple(result, user, {path: 'royallyLoyal', language});
_addSimpleWithMasterCount(result, user, {path: 'beastMaster', language});

View File

@@ -122,6 +122,14 @@ api.createGroup = {
user.balance--;
user.guilds.push(group._id);
if (!user.achievements.joinedGuild) {
user.achievements.joinedGuild = true;
user.addNotification('GUILD_JOINED_ACHIEVEMENT');
}
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter = -1;
user.markModified('_ABtests');
}
} else {
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
@@ -510,6 +518,14 @@ api.joinGroup = {
throw new NotAuthorized(res.t('userAlreadyInGroup'));
}
user.guilds.push(group._id); // Add group to user's guilds
if (!user.achievements.joinedGuild) {
user.achievements.joinedGuild = true;
user.addNotification('GUILD_JOINED_ACHIEVEMENT');
}
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter = -1;
user.markModified('_ABtests');
}
}
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));

View File

@@ -589,6 +589,18 @@ api.scoreTask = {
}
}
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter++;
if (user._ABtests.counter > 1) {
if (user._ABtests.guildReminder.indexOf('timing1') !== -1 || user._ABtests.counter > 4) {
user._ABtests.counter = -1;
let textVariant = user._ABtests.guildReminder.indexOf('text2');
user.addNotification('GUILD_PROMPT', {textVariant});
}
}
user.markModified('_ABtests');
}
if (task.type === 'daily') {
task.isDue = common.shouldDo(Date.now(), task, user.preferences);
}

View File

@@ -121,6 +121,8 @@ api.getBuyList = {
};
let updatablePaths = [
'_ABtests.counter',
'flags.customizationsNotification',
'flags.showTour',
'flags.tour',

View File

@@ -10,7 +10,7 @@ import schema from './schema';
schema.plugin(baseModel, {
// noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
noSet: [],
private: ['auth.local.hashed_password', 'auth.local.passwordHashMethod', 'auth.local.salt', '_cronSignature', '_ABtest', '_ABtests'],
private: ['auth.local.hashed_password', 'auth.local.passwordHashMethod', 'auth.local.salt', '_cronSignature', '_ABtests'],
toJSONTransform: function userToJSON (plainObj, originalDoc) {
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
delete plainObj.filters;
@@ -94,13 +94,25 @@ function _setUpNewUser (user) {
let taskTypes;
let iterableFlags = user.flags.toObject();
user._ABtest = '';
// A/B test 2016-12-21: Should we deliver notifications for upcoming incentives on days when users don't receive rewards?
if (Math.random() < 0.5) {
user._ABtests.checkInModals = '20161221_noCheckInPreviews'; // no 'preview' check-in modals
// A/B test 2017-05-11: Can we encourage people to join Guilds with a pester modal?
let testGroup = Math.random();
if (testGroup < 0.1) {
user._ABtests.guildReminder = '20170511_noGuildReminder'; // control group, don't pester about Guilds
user._ABtests.counter = -1;
} else if (testGroup < 0.235) {
user._ABtests.guildReminder = '20170511_text1timing1'; // first sample text, show after two clicks
user._ABtests.counter = 0;
} else if (testGroup < 0.46) {
user._ABtests.guildReminder = '20170511_text2timing1'; // second sample text, show after two clicks
user._ABtests.counter = 0;
} else if (testGroup < 0.685) {
user._ABtests.guildReminder = '20170511_text1timing2'; // first sample text, show after five clicks
user._ABtests.counter = 0;
} else {
user._ABtests.checkInModals = '20161221_showCheckInPreviews'; // show 'preview' check-in modals
user._ABtests.guildReminder = '20170511_text2timing2'; // second sample text, show after five clicks
user._ABtests.counter = 0;
}
user.items.quests.dustbunnies = 1;
user.purchased.background.violet = true;
user.preferences.background = 'violet';

View File

@@ -112,6 +112,7 @@ let schema = new Schema({
partyUp: Boolean,
partyOn: Boolean,
royallyLoyal: Boolean,
joinedGuild: Boolean,
},
backer: {
@@ -545,7 +546,6 @@ let schema = new Schema({
return {};
}},
pushDevices: [PushDeviceSchema],
_ABtest: {type: String}, // deprecated. Superseded by _ABtests
_ABtests: {type: Schema.Types.Mixed, default: () => {
return {};
}},

View File

@@ -18,6 +18,8 @@ const NOTIFICATION_TYPES = [
'GROUP_INVITE_ACCEPTED',
'SCORED_TASK',
'BOSS_DAMAGE', // Not used currently but kept to avoid validation errors
'GUILD_PROMPT',
'GUILD_JOINED_ACHIEVEMENT',
];
const Schema = mongoose.Schema;

View File

@@ -117,7 +117,7 @@ script(id='modals/achievements/contributor.html', type='text/ng-template')
button.btn.btn-primary(style='margin-top:1em' ng-click='$close()')=env.t('huzzah')
+achievementFooter
//Rebirth
// Rebirth
script(id='modals/achievements/rebirth.html', type='text/ng-template')
.modal-content(style='min-width:28em')
.modal-body.text-center
@@ -152,3 +152,14 @@ script(id='modals/achievements/partyOn.html', type='text/ng-template')
br
button.btn.btn-primary(ng-click='$close()')=env.t('huzzah')
+achievementFooter
// Joined Guild
script(id='modals/achievements/joinedGuild.html', type='text/ng-template')
.modal-content(style='min-width:28em')
.modal-body.text-center
h3(style='margin-bottom:0')=env.t('modalAchievement')
+achievementAvatar('guild',0)
p=env.t('joinedGuildText')
br
button.btn.btn-primary(ng-click='$close()')=env.t('huzzah')
+achievementFooter

View File

@@ -27,6 +27,7 @@ include ./generic.jade
include ./tasks-edit.jade
include ./task-notes.jade
include ./task-extra-notes.jade
include ./testing.jade
//- Settings
script(type='text/ng-template', id='modals/change-day-start.html')

View File

@@ -0,0 +1,31 @@
script(type='text/ng-template', id='modals/testing.html')
.modal-content(style='min-width:28em')
.modal-body.text-center(style='padding-bottom:0')
h3=env.t('guildReminderTitle')
br
.scene_guilds.center-block
br
h4=env.t('guildReminderText1')
.modal-footer(style='padding-top:0')
.container-fluid
.row
.col-xs-6.text-center
button.btn-lg.btn-default(ng-click='$close()')=env.t('guildReminderDismiss')
.col-xs-6.text-center
button.btn-lg.btn-primary(ui-sref='options.social.guilds.public', href='/#/options/groups/guilds/public', ng-click='$close()')=env.t('guildReminderCTA')
script(type='text/ng-template', id='modals/testingVariant.html')
.modal-content(style='min-width:28em')
.modal-body.text-center(style='padding-bottom:0')
h3=env.t('guildReminderTitle')
br
.scene_guilds.center-block
br
h4=env.t('guildReminderText2')
.modal-footer(style='padding-top:0')
.container-fluid
.row
.col-xs-6.text-center
button.btn-lg.btn-default(ng-click='$close()')=env.t('guildReminderDismiss')
.col-xs-6.text-center
button.btn-lg.btn-primary(ui-sref='options.social.guilds.public', href='/#/options/groups/guilds/public', ng-click='$close()')=env.t('guildReminderCTA')