diff --git a/app/core/application.coffee b/app/core/application.coffee index 7fcbf5b81..f5cbd0a54 100644 --- a/app/core/application.coffee +++ b/app/core/application.coffee @@ -62,7 +62,24 @@ Application = { $(document).bind 'keydown', preventBackspace preload(COMMON_FILES) CocoModel.pollAchievements() -# @checkForNewAchievement() # TODO: Enable once thoroughly tested + unless me.get('anonymous') + # TODO: Remove logging later, once this system has proved stable + me.on 'change:earned', (user, newEarned) -> + oldEarned = user.previous('earned') + if oldEarned.gems isnt newEarned.gems + console.log 'Gems changed', oldEarned.gems, '->', newEarned.gems + newLevels = _.difference(newEarned.levels, oldEarned.levels) + if newLevels.length + console.log 'Levels added', newLevels + newItems = _.difference(newEarned.items, oldEarned.items) + if newItems.length + console.log 'Items added', newItems + newHeroes = _.difference(newEarned.heroes, oldEarned.heroes) + if newHeroes.length + console.log 'Heroes added', newHeroes + me.on 'change:points', (user, newPoints) -> + console.log 'Points changed', user.previous('points'), '->', newPoints + @checkForNewAchievement() $.i18n.init { lng: me.get('preferredLanguage', true) fallbackLng: 'en' @@ -84,12 +101,14 @@ Application = { @idleTracker.start() checkForNewAchievement: -> - id = me.get('lastAchievementChecked') or me.id - lastAchievementChecked = new Date(parseInt(id.substring(0, 8), 16) * 1000) - daysSince = moment.duration(new Date() - lastAchievementChecked).asDays() + if me.get('lastAchievementChecked') + startFrom = new Date(me.get('lastAchievementChecked')) + else + startFrom = me.created() + + daysSince = moment.duration(new Date() - startFrom).asDays() if daysSince > 1 - me.checkForNewAchievement() - setTimeout(_.bind(@checkForNewAchievement, @), moment.duration(1, 'minute').asMilliseconds()) + me.checkForNewAchievement().then => @checkForNewAchievement() } module.exports = Application diff --git a/app/lib/LocalMongo.coffee b/app/lib/LocalMongo.coffee index 558a58e5d..9fdc1be4f 100644 --- a/app/lib/LocalMongo.coffee +++ b/app/lib/LocalMongo.coffee @@ -6,10 +6,10 @@ mapred = (left, right, func) -> result or (_.reduce (_.map right, (singleRight) -> func(singleLeft, singleRight)), ((intermediate, value) -> intermediate or value), false)), false) -doQuerySelector = (value, operatorObj) -> - value = [value] unless _.isArray value # left hand can be an array too - for operator, body of operatorObj - body = [body] unless _.isArray body # right hand can be an array too +doQuerySelector = (originalValue, operatorObj) -> + value = if _.isArray originalValue then originalValue else [originalValue] # left hand can be an array too + for operator, originalBody of operatorObj + body = if _.isArray originalBody then originalBody else [originalBody] # right hand can be an array too switch operator when '$gt' then return false unless mapred value, body, (l, r) -> l > r when '$gte' then return false unless mapred value, body, (l, r) -> l >= r @@ -19,7 +19,9 @@ doQuerySelector = (value, operatorObj) -> when '$in' then return false unless _.reduce value, ((result, val) -> result or val in body), false when '$nin' then return false if _.reduce value, ((result, val) -> result or val in body), false when '$exists' then return false if value[0]? isnt body[0] - else return false + else + trimmedOperator = _.pick(operatorObj, operator) + return false unless _.isObject(originalValue) and matchesQuery(originalValue, trimmedOperator) true matchesQuery = (target, queryObj) -> diff --git a/app/schemas/models/achievement.coffee b/app/schemas/models/achievement.coffee index 9b2ca20df..d90517fa5 100644 --- a/app/schemas/models/achievement.coffee +++ b/app/schemas/models/achievement.coffee @@ -82,6 +82,7 @@ _.extend AchievementSchema.properties, i18n: {type: 'object', format: 'i18n', props: ['name', 'description'], description: 'Help translate this achievement'} rewards: c.RewardSchema 'awarded by this achievement' hidden: {type: 'boolean', description: 'Hide achievement from user if true'} + updated: c.stringDate({ description: 'When the achievement was changed in such a way that earned achievements should get updated.' }) _.extend AchievementSchema, # Let's have these on the bottom diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 3931d641b..6fa6a7bbe 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -344,7 +344,7 @@ _.extend UserSchema.properties, schoolName: {type: 'string'} role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]} birthday: c.stringDate({title: "Birthday"}) - lastAchievementChecked: c.objectId({ name: 'Last Achievement Checked' }) + lastAchievementChecked: c.stringDate({ name: 'Last Achievement Checked' }) c.extendBasicProperties UserSchema, 'user' diff --git a/scripts/mongodb/migrations/2016-08-31-add-achievements-updated.js b/scripts/mongodb/migrations/2016-08-31-add-achievements-updated.js new file mode 100644 index 000000000..b6677e1e8 --- /dev/null +++ b/scripts/mongodb/migrations/2016-08-31-add-achievements-updated.js @@ -0,0 +1,7 @@ +var d = new Date(); + +db.achievements.find({}, {_id:1}).sort({_id:1}).forEach(function(achievement) { + db.achievements.update({_id: achievement._id}, {$set: {updated: d.toISOString()}}) + print('set', achievement._id, 'to', d); + d.setSeconds(d.getSeconds() + 1) +}); diff --git a/server/middleware/achievements.coffee b/server/middleware/achievements.coffee index 739adafae..4939ff235 100644 --- a/server/middleware/achievements.coffee +++ b/server/middleware/achievements.coffee @@ -19,7 +19,12 @@ module.exports = unless hasPermission or database.isJustFillingTranslations(req, achievement) throw new errors.Forbidden('Must be an admin, artisan or submitting translations to edit an achievement') + propsWatching = ['query', 'proportionalTo', 'rewards', 'worth'] + oldCopy = _.pick(achievement.toObject(), propsWatching) database.assignBody(req, achievement) + newCopy = _.pick(achievement.toObject(), propsWatching) + unless _.isEqual(oldCopy, newCopy) + achievement.set('updated', new Date().toISOString()) database.validateDoc(achievement) achievement = yield achievement.save() res.status(200).send(achievement.toObject({req: req})) diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index 8ce363e9b..2edfbcdd9 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -20,6 +20,7 @@ Achievement = require '../models/Achievement' EarnedAchievement = require '../models/EarnedAchievement' log = require 'winston' LocalMongo = require '../../app/lib/LocalMongo' +LevelSession = require '../models/LevelSession' module.exports = fetchByGPlusID: wrap (req, res, next) -> @@ -261,28 +262,27 @@ module.exports = checkForNewAchievement: wrap (req, res) -> user = req.user + lastAchievementChecked = user.get('lastAchievementChecked') or user._id.getTimestamp().toISOString() + checkTimestamp = new Date().toISOString() + achievement = yield Achievement.findOne({ updated: { $gt: lastAchievementChecked }}).sort({updated:1}) - lastAchievementChecked = user.get('lastAchievementChecked') or user._id - achievement = yield Achievement.findOne({ _id: { $gt: lastAchievementChecked }}).sort({_id:1}) - if not achievement - userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() } + userUpdate = { 'lastAchievementChecked': checkTimestamp } user.update({$set: userUpdate}).exec() return res.send(userUpdate) - userUpdate = { 'lastAchievementChecked': achievement._id } + userUpdate = { 'lastAchievementChecked': achievement.get('updated') } query = achievement.get('query') collection = achievement.get('collection') if collection is 'users' triggers = [user] else if collection is 'level.sessions' and query['level.original'] - triggers = yield LevelSessions.find({ + triggers = yield LevelSession.find({ 'level.original': query['level.original'] - creator: user._id + creator: user.id }) else - userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() } user.update({$set: userUpdate}).exec() return res.send(userUpdate) @@ -292,10 +292,8 @@ module.exports = user.update({$set: userUpdate}).exec() return res.send(userUpdate) - earned = yield EarnedAchievement.findOne({ achievement: achievement.id, user: req.user }) - yield [ - EarnedAchievement.upsertFor(achievement, trigger, earned, req.user) - user.update({$set: userUpdate}) - ] + earned = yield EarnedAchievement.findOne({ achievement: achievement.id, user: req.user.id }) + yield EarnedAchievement.upsertFor(achievement, trigger, earned, req.user) + yield user.update({$set: userUpdate}) user = yield User.findById(user.id).select({points: 1, earned: 1}) return res.send(_.assign({}, userUpdate, user.toObject())) diff --git a/server/models/Achievement.coffee b/server/models/Achievement.coffee index f302810b9..8735a3ded 100644 --- a/server/models/Achievement.coffee +++ b/server/models/Achievement.coffee @@ -33,6 +33,7 @@ AchievementSchema.index( AchievementSchema.index({i18nCoverage: 1}, {name: 'translation coverage index', sparse: true}) AchievementSchema.index({slug: 1}, {name: 'slug index', sparse: true, unique: true}) AchievementSchema.index({related: 1}, {name: 'related index', sparse: true}) +AchievementSchema.index({updated: 1}, {name: 'updated index'}) AchievementSchema.methods.objectifyQuery = -> try @@ -105,6 +106,8 @@ AchievementSchema.post 'init', (doc) -> doc.objectifyQuery() AchievementSchema.pre 'save', (next) -> @stringifyQuery() + if not @get('updated') + @set('updated', new Date().toISOString()) next() # Reload achievements upon save diff --git a/server/models/EarnedAchievement.coffee b/server/models/EarnedAchievement.coffee index 14d07577e..e04e11ace 100644 --- a/server/models/EarnedAchievement.coffee +++ b/server/models/EarnedAchievement.coffee @@ -3,6 +3,7 @@ jsonschema = require '../../app/schemas/models/earned_achievement' util = require '../../app/core/utils' log = require 'winston' co = require 'co' +errors = require '../commons/errors' EarnedAchievementSchema = new mongoose.Schema({ notified: @@ -27,15 +28,19 @@ EarnedAchievementSchema.statics.upsertFor = (achievement, trigger, earned, user) else if earned achievementEarned = achievement.get('rewards') actuallyEarned = earned.get('earnedRewards') - if not _.isEqual(achievementEarned, actuallyEarned) - earned.set('earnedRewards', achievementEarned) - yield earned.save() + oldPoints = earned.get('earnedPoints') ? 0 + newPoints = achievement.get('worth') ? 10 + if (not _.isEqual(achievementEarned, actuallyEarned)) or (oldPoints isnt newPoints) + earned.set('earnedRewards', achievementEarned) + earned.set('earnedPoints', newPoints) + yield earned.save() + + # make sure user has all the levels, heroes, gems, items and points they should have + update = {$inc: { points: newPoints - oldPoints }} - # make sure user has all the levels and items they should have - update = {} for rewardType, rewards of achievement.get('rewards') ? {} if rewardType is 'gems' - update.$inc = { 'earned.gems': rewards - (actuallyEarned.gems ? 0) } + update.$inc['earned.gems'] = rewards - (actuallyEarned.gems ? 0) else if rewards.length update.$addToSet ?= {} update.$addToSet["earned.#{rewardType}"] = { $each: rewards } diff --git a/spec/server/functional/achievement.spec.coffee b/spec/server/functional/achievement.spec.coffee index e8761ba6c..7e6a33085 100644 --- a/spec/server/functional/achievement.spec.coffee +++ b/spec/server/functional/achievement.spec.coffee @@ -109,6 +109,40 @@ describe 'PUT /db/achievement', -> expect(res.body.name).toBe('whatev') done() + it 'touches "updated" if query, proportionalTo, worth, or rewards change', utils.wrap (done) -> + lastUpdated = @unlockable.get('updated') + expect(lastUpdated).toBeDefined() + [res, body] = yield request.putAsync {uri: url + '/'+@unlockable.id, json: { + name: 'whatev' + rewards: @unlockable.get('rewards') + query: @unlockable.get('query') + proportionalTo: @unlockable.get('proportionalTo') + }} + achievement = yield Achievement.findById(@unlockable.id) + expect(achievement.get('updated')).toBeDefined() + expect(achievement.get('updated')).toBe(lastUpdated) # unchanged + + newRewards = _.assign({}, @unlockable.get('rewards'), {gems: 1000}) + [res, body] = yield request.putAsync {uri: url + '/'+@unlockable.id, json: {rewards: newRewards}} + expect(res.body.updated).not.toBe(lastUpdated) + lastUpdated = res.body.updated + + newQuery = _.assign({}, @unlockable.get('query'), {'state.complete': true}) + [res, body] = yield request.putAsync {uri: url + '/'+@unlockable.id, json: {query: newQuery}} + expect(res.body.updated).not.toBe(lastUpdated) + lastUpdated = res.body.updated + + newProportionalTo = 'playtime' + [res, body] = yield request.putAsync {uri: url + '/'+@unlockable.id, json: {proportionalTo: newProportionalTo}} + expect(res.body.updated).not.toBe(lastUpdated) + lastUpdated = res.body.updated + + newWorth = 1000 + [res, body] = yield request.putAsync {uri: url + '/'+@unlockable.id, json: {worth: newWorth}} + expect(res.body.updated).not.toBe(lastUpdated) + done() + + describe 'GET /db/achievement', -> beforeEach addAllAchievements @@ -187,7 +221,7 @@ describe 'POST /db/earned_achievement', -> expect(body.achievement).toBe @unlockable.id expect(body.user).toBe user.id expect(body.notified).toBeFalsy() - expect(body.earnedPoints).toBe unlockable.worth + expect(body.earnedPoints).toBe @unlockable.get('worth') expect(body.achievedAmount).toBeUndefined() expect(body.previouslyAchievedAmount).toBeUndefined() earnedAchievements = yield EarnedAchievement.find() @@ -344,7 +378,7 @@ describe 'POST /admin/earned_achievement/recalculate', -> expect(earnedAchievements.length).toBe(1) user = yield User.findById(@admin.id) - expect(user.get 'points').toBe unlockable.worth + expect(user.get 'points').toBe @unlockable.get('worth') done() it 'can recalculate all achievements', utils.wrap (done) -> @@ -371,8 +405,8 @@ describe 'POST /admin/earned_achievement/recalculate', -> earnedAchievements = yield EarnedAchievement.find({}) expect(earnedAchievements.length).toBe 3 user = yield User.findById(@admin.id) - expect(user.get 'points').toBe unlockable.worth + 4 * repeatable.worth + (Math.log(.5 * (4 + .5)) + 1) * diminishing.worth - expect(user.get('earned').gems).toBe 4 * repeatable.rewards.gems + expect(user.get 'points').toBe @unlockable.get('worth') + 4 * @repeatable.get('worth') + (Math.log(.5 * (4 + .5)) + 1) * @diminishing.get('worth') + expect(user.get('earned').gems).toBe 4 * @repeatable.get('rewards').gems done() afterEach utils.wrap (done) -> diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee index 7d088b003..b4deb195e 100644 --- a/spec/server/functional/user.spec.coffee +++ b/spec/server/functional/user.spec.coffee @@ -1039,6 +1039,22 @@ describe 'POST /db/user/:handle/deteacher', -> describe 'POST /db/user/:handle/check-for-new-achievements', -> + achievementURL = getURL('/db/achievement') + achievementJSON = { + collection: 'users' + query: {'points': {$gt: 50}} + userField: '_id' + recalculable: true + worth: 75 + rewards: { + gems: 50 + levels: [new mongoose.Types.ObjectId().toString()] + } + name: 'Dungeon Arena Started' + description: 'Started playing Dungeon Arena.' + related: 'a' + } + beforeEach utils.wrap (done) -> yield utils.clearModels [Achievement, EarnedAchievement, LevelSession, User] @@ -1055,36 +1071,132 @@ describe 'POST /db/user/:handle/check-for-new-achievements', -> earned = yield EarnedAchievement.count() expect(earned).toBe(0) - achievementURL = getURL('/db/achievement') - achievementJSON = { - collection: 'users' - query: {'points': {$gt: 50}} - userField: '_id' - recalculable: true - worth: 75 - rewards: { - gems: 50 - levels: [new mongoose.Types.ObjectId().toString()] - } - name: 'Dungeon Arena Started' - description: 'Started playing Dungeon Arena.' - related: 'a' - } - admin = yield utils.initAdmin() yield utils.loginUser(admin) [res, body] = yield request.postAsync { uri: achievementURL, json: achievementJSON } - achievementID = body._id + achievementUpdated = res.body.updated expect(res.statusCode).toBe(201) user = yield User.findById(user.id) - expect(user.get('rewards')).toBeUndefined() + expect(user.get('earned')).toBeUndefined() yield utils.loginUser(user) [res, body] = yield request.postAsync({ url, json }) expect(body.points).toBe(175) earned = yield EarnedAchievement.count() expect(earned).toBe(1) - expect(body.lastAchievementChecked).toBe(achievementID) + expect(body.lastAchievementChecked).toBe(achievementUpdated) done() + + it 'updates the user if they already earned the achievement but the rewards changed', utils.wrap (done) -> + user = yield utils.initUser({points: 100}) + yield utils.loginUser(user) + url = utils.getURL("/db/user/#{user.id}/check-for-new-achievement") + json = true + [res, body] = yield request.postAsync({ url, json }) + + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + [res, body] = yield request.postAsync { uri: achievementURL, json: achievementJSON } + achievement = yield Achievement.findById(body._id) + expect(res.statusCode).toBe(201) + + user = yield User.findById(user.id) + expect(user.get('rewards')).toBeUndefined() + + yield utils.loginUser(user) + [res, body] = yield request.postAsync({ url, json }) + expect(res.body.points).toBe(175) + expect(res.body.earned.gems).toBe(50) + + achievement.set({ + updated: new Date().toISOString() + rewards: { gems: 100 } + worth: 200 + }) + yield achievement.save() + + [res, body] = yield request.postAsync({ url, json }) + expect(res.body.earned.gems).toBe(100) + expect(res.body.points).toBe(300) + expect(res.statusCode).toBe(200) + + # special case: no worth, should default to 10 + + yield achievement.update({ + $set: {updated: new Date().toISOString()}, + $unset: {worth:''} + }) + [res, body] = yield request.postAsync({ url, json }) + expect(res.body.earned.gems).toBe(100) + expect(res.body.points).toBe(110) + expect(res.statusCode).toBe(200) + done() + + it 'works for level sessions', utils.wrap (done) -> + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + level = yield utils.makeLevel() + achievement = yield utils.makeAchievement({ + collection: 'level.sessions' + userField: 'creator' + query: { + 'level.original': level.get('original').toString() + 'state': {complete: true} + } + worth: 100 + proportionalTo: 'state.difficulty' + }) + levelSession = yield utils.makeLevelSession({state: {complete: true, difficulty:2}}, { creator:admin, level }) + url = utils.getURL("/db/user/#{admin.id}/check-for-new-achievement") + json = true + [res, body] = yield request.postAsync({ url, json }) + expect(body.points).toBe(200) + + # check idempotency + achievement.set('updated', new Date().toISOString()) + yield achievement.save() + [res, body] = yield request.postAsync({ url, json }) + expect(body.points).toBe(200) + admin = yield User.findById(admin.id) + done() + + it 'skips achievements which have not been satisfied', utils.wrap (done) -> + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + level = yield utils.makeLevel() + achievement = yield utils.makeAchievement({ + collection: 'level.sessions' + userField: 'creator' + query: { + 'level.original': 'does not matter' + } + worth: 100 + }) + expect(admin.get('lastAchievementChecked')).toBeUndefined() + url = utils.getURL("/db/user/#{admin.id}/check-for-new-achievement") + json = true + [res, body] = yield request.postAsync({ url, json }) + expect(body.points).toBeUndefined() + admin = yield User.findById(admin.id) + expect(admin.get('lastAchievementChecked')).toBe(achievement.get('updated')) + done() + + it 'skips achievements which are not either for the users collection or the level sessions collection with level.original included', utils.wrap (done) -> + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + achievement = yield utils.makeAchievement({ + collection: 'not.supported' + userField: 'creator' + query: {} + worth: 100 + }) + expect(admin.get('lastAchievementChecked')).toBeUndefined() + url = utils.getURL("/db/user/#{admin.id}/check-for-new-achievement") + json = true + [res, body] = yield request.postAsync({ url, json }) + expect(body.points).toBeUndefined() + admin = yield User.findById(admin.id) + expect(admin.get('lastAchievementChecked')).toBe(achievement.get('updated')) + done() diff --git a/spec/server/utils.coffee b/spec/server/utils.coffee index 1873bb31b..899d473e9 100644 --- a/spec/server/utils.coffee +++ b/spec/server/utils.coffee @@ -4,6 +4,7 @@ co = require 'co' Promise = require 'bluebird' User = require '../../server/models/User' Level = require '../../server/models/Level' +LevelSession = require '../../server/models/LevelSession' Achievement = require '../../server/models/Achievement' Campaign = require '../../server/models/Campaign' Course = require '../../server/models/Course' @@ -98,6 +99,38 @@ module.exports = mw = request.post { uri: getURL('/db/level'), json: data }, (err, res) -> return done(err) if err Level.findById(res.body._id).exec done + + makeLevelSession: Promise.promisify (data, sources, done) -> + args = Array.from(arguments) + [done, [data, sources]] = [args.pop(), args] + + data = _.extend({}, { + state: + complete: false + scripts: + currentScript: null + }, data) + + if sources?.level and not data.level + data.level = { + original: sources.level.get('original').toString() + majorVersion: sources.level.get('version').major + } + + if sources?.creator and not data.creator + data.creator = sources.creator.id + + if data.creator and not data.permissions + data.permissions = [ + { target: data.creator, access: 'owner' } + { target: 'public', access: 'write' } + ] + + if not data.codeLanguage + data.codeLanguage = 'javascript' + + session = new LevelSession(data) + session.save(done) makeAchievement: Promise.promisify (data, sources, done) -> args = Array.from(arguments) @@ -106,12 +139,13 @@ module.exports = mw = data = _.extend({}, { name: _.uniqueId('Achievement ') }, data) - if sources.related and not data.related + if sources?.related and not data.related related = sources.related data.related = (related.get('original') or related._id).valueOf() request.post { uri: getURL('/db/achievement'), json: data }, (err, res) -> return done(err) if err + expect(res.statusCode).toBe(201) Achievement.findById(res.body._id).exec done makeCampaign: Promise.promisify (data, sources, done) -> diff --git a/test/app/lib/local_mongo.spec.coffee b/test/app/lib/local_mongo.spec.coffee index 4aa2dc76e..1109b1bec 100644 --- a/test/app/lib/local_mongo.spec.coffee +++ b/test/app/lib/local_mongo.spec.coffee @@ -9,6 +9,9 @@ describe 'Local Mongo queries', -> 'worth': 6 'type': 'unicorn' 'likes': ['poptarts', 'popsicles', 'popcorn'] + nested: { + str:'ing' + } @fixture2 = this: is: so: 'deep' @@ -24,6 +27,11 @@ describe 'Local Mongo queries', -> it 'nested match', -> expect(LocalMongo.matchesQuery(@fixture2, 'this.is.so': 'deep')).toBeTruthy() + expect(LocalMongo.matchesQuery(@fixture2, {this:{is:{so: 'deep'}}})).toBeTruthy() + expect(LocalMongo.matchesQuery(@fixture2, {'this.is':{so: 'deep'}})).toBeTruthy() + mixedQuery = { nested: {str:'ing'}, worth: {$gt:3} } + expect(LocalMongo.matchesQuery(@fixture1, mixedQuery)).toBeTruthy() + expect(LocalMongo.matchesQuery(@fixture2, mixedQuery)).toBeFalsy() it '$gt selector', -> expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': 8000)).toBeTruthy()