Achievement recalculation is now covered with tests

This commit is contained in:
Ruben Vereecken 2014-07-03 21:20:06 +02:00
parent a367082cc4
commit e904a0a8f7
6 changed files with 132 additions and 71 deletions

View file

@ -64,6 +64,6 @@ AchievementSchema.post 'save', -> @constructor.loadAchievements()
AchievementSchema.plugin(plugins.NamedPlugin)
AchievementSchema.plugin(plugins.SearchablePlugin, {searchable: ['name']})
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema)
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema, 'achievements')
AchievementSchema.statics.loadAchievements()

View file

@ -23,14 +23,13 @@ class EarnedAchievementHandler extends Handler
EarnedAchievementHandler.recalculate onSuccess
@sendAccepted res, {}
# Returns success: boolean
# TODO call onFinished
@recalculate: (callbackOrSlugsOrIDs, onFinished) ->
if _.isArray callbackOrSlugsOrIDs
@recalculate: (callbackOrSlugsOrIDs, callback) ->
if _.isArray callbackOrSlugsOrIDs # slugs or ids
achievementSlugs = (thing for thing in callbackOrSlugsOrIDs when not Handler.isID(thing))
achievementIDs = (thing for thing in callbackOrSlugsOrIDs when Handler.isID(thing))
else
onFinished = callbackOrSlugsOrIDs
else # just a callback
callback = callbackOrSlugsOrIDs
onFinished = -> callback arguments... if callback?
filter = {}
filter.$or = [
@ -40,11 +39,11 @@ class EarnedAchievementHandler extends Handler
# Fetch all relevant achievements
Achievement.find filter, (err, achievements) ->
return log.error err if err?
log.error err if err?
# Fetch every single user
User.find {}, (err, users) ->
_.each users, (user) ->
async.each users, ((user, doneWithUser) ->
# Keep track of a user's already achieved in order to set the notified values correctly
userID = user.get('_id').toHexString()
@ -52,64 +51,70 @@ class EarnedAchievementHandler extends Handler
EarnedAchievement.find {user: userID}, (err, alreadyEarned) ->
alreadyEarnedIDs = []
previousPoints = 0
_.each alreadyEarned, (earned) ->
if (_.find achievements, (single) -> earned.get('achievement') is single.get('_id').toHexString())
async.each alreadyEarned, ((earned, doneWithEarned) ->
if (_.find achievements, (single) -> earned.get('achievement') is single.get('_id').toHexString()) # if already earned
alreadyEarnedIDs.push earned.get('achievement')
previousPoints += earned.get 'earnedPoints'
doneWithEarned()
), -> # After checking already achieved
# TODO maybe also delete earned? Make sure you don't delete too many
# TODO maybe also delete earned? Make sure you don't delete too many
newTotalPoints = 0
newTotalPoints = 0
async.each achievements, ((achievement, doneWithAchievement) ->
isRepeatable = achievement.get('proportionalTo')?
model = mongoose.modelNameByCollection(achievement.get('collection'))
if not model?
log.error "Model with collection '#{achievement.get 'collection'}' doesn't exist."
return doneWithAchievement()
earnedAchievementSaverGenerator = (achievement) -> (callback) ->
isRepeatable = achievement.get('proportionalTo')?
model = mongoose.model(achievement.get('collection'))
if not model?
log.error "Model #{achievement.get 'collection'} doesn't even exist."
return callback()
finalQuery = _.clone achievement.get 'query'
finalQuery.$or = [{}, {}] # Allow both ObjectIDs or hexa string IDs
finalQuery.$or[0][achievement.userField] = userID
finalQuery.$or[1][achievement.userField] = ObjectId userID
model.findOne achievement.query, (err, something) ->
return callback() unless something
model.findOne finalQuery, (err, something) ->
return doneWithAchievement() if _.isEmpty something
log.debug "Matched an achievement: #{achievement.get 'name'}"
log.debug "Matched an achievement: #{achievement.get 'name'} for #{user.get 'name'}"
earned =
user: userID
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
notified: achievement._id in alreadyEarnedIDs
earned =
user: userID
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
notified: achievement._id in alreadyEarnedIDs
if isRepeatable
earned.achievedAmount = something.get(achievement.get 'proportionalTo')
earned.previouslyAchievedAmount = 0
if isRepeatable
earned.achievedAmount = something.get(achievement.get 'proportionalTo')
earned.previouslyAchievedAmount = 0
expFunction = achievement.getExpFunction()
newPoints = expFunction(earned.achievedAmount) * achievement.get('worth')
expFunction = achievement.getExpFunction()
newPoints = expFunction(earned.achievedAmount) * achievement.get('worth')
else
newPoints = achievement.get 'worth'
earned.earnedPoints = newPoints
newTotalPoints += newPoints
EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) ->
log.error err if err?
doneWithAchievement()
), saveUserPoints = ->
# In principle it is enough to deduct the old amount of points and add the new amount,
# but just to be entirely safe let's start from 0 in case we're updating all of a user's achievements
return doneWithUser() unless newTotalPoints
log.debug "Matched a total of #{newTotalPoints} new points"
if _.isEmpty filter # Completely clean
log.debug "Setting this user's score to #{newTotalPoints}"
User.update {_id: userID}, {$set: points: newTotalPoints}, {}, (err) ->
log.error err if err?
doneWithUser()
else
newPoints = achievement.get 'worth'
earned.earnedPoints = newPoints
newTotalPoints += newPoints
EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) ->
log.error err if err?
callback()
saveUserPoints = (callback) ->
# In principle it is enough to deduct the old amount of points and add the new amount,
# but just to be entirely safe let's start from 0 in case we're updating all of a user's achievements
log.debug "Matched a total of #{newTotalPoints} new points"
if _.isEmpty filter # Completely clean
User.update {_id: userID}, {$set: points: newTotalPoints}, {}, (err) -> log.error err if err?
else
log.debug "Incrementing score for these achievements with #{newTotalPoints - previousPoints}"
User.update {_id: userID}, {$inc: points: newTotalPoints - previousPoints}, {}, (err) -> log.error err if err?
earnedAchievementSavers = (earnedAchievementSaverGenerator(achievement) for achievement in achievements)
earnedAchievementSavers.push saveUserPoints
# We need to have all these database updates chained so we know the final score
async.series earnedAchievementSavers
log.debug "Incrementing score for these achievements with #{newTotalPoints - previousPoints}"
User.update {_id: userID}, {$inc: points: newTotalPoints - previousPoints}, {}, (err) ->
log.error err if err?
doneWithUser()
), onFinished
module.exports = new EarnedAchievementHandler()

View file

@ -26,3 +26,12 @@ module.exports.routes =
'routes/queue'
'routes/stacklead'
]
mongoose = require 'mongoose'
module.exports.modules = modules = # by collection name
'achievements': 'Achievement'
'level.sessions': 'level.session'
'users': 'User'
mongoose.modelNameByCollection = (collection) ->
mongoose.model modules[collection] if collection of modules

View file

@ -42,4 +42,4 @@ LevelSessionSchema.pre 'save', (next) ->
delete previous[id] if initd
next()
module.exports = LevelSession = mongoose.model('level.session', LevelSessionSchema)
module.exports = LevelSession = mongoose.model('level.session', LevelSessionSchema, 'level.sessions')

View file

@ -14,10 +14,12 @@ AchievablePlugin = (schema, options) ->
before = {}
# Keep track the document before it's saved
schema.post 'init', (doc) ->
before[doc.id] = doc.toObject()
# TODO check out how many objects go unreleased
# Check if an achievement has been earned
schema.post 'save', (doc) ->
isNew = not doc.isInit('_id') or not (doc.id of before)
originalDocObj = before[doc.id] unless isNew
@ -36,27 +38,25 @@ AchievablePlugin = (schema, options) ->
isRepeatable = achievement.get('proportionalTo')?
alreadyAchieved = if isNew then false else LocalMongo.matchesQuery originalDocObj, query
newlyAchieved = LocalMongo.matchesQuery(docObj, query)
log.debug 'isRepeatable: ' + isRepeatable
log.debug 'alreadyAchieved: ' + alreadyAchieved
log.debug 'newlyAchieved: ' + newlyAchieved
#log.debug 'isRepeatable: ' + isRepeatable
#log.debug 'alreadyAchieved: ' + alreadyAchieved
#log.debug 'newlyAchieved: ' + newlyAchieved
userObjectID = doc.get(achievement.get('userField'))
userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use strings, not ObjectId's
if newlyAchieved and (not alreadyAchieved or isRepeatable)
earned = {
earned =
user: userID
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
}
worth = achievement.get('worth')
earnedPoints = 0
wrapUp = ->
# Update user's experience points
User.update({_id: userID}, {$inc: {points: earnedPoints}}, {}, (err, count) ->
User.update {_id: userID}, {$inc: {points: earnedPoints}}, {}, (err, count) ->
log.error err if err?
)
if isRepeatable
log.debug 'Upserting repeatable achievement called \'' + (achievement.get 'name') + '\' for ' + userID
@ -81,7 +81,7 @@ AchievablePlugin = (schema, options) ->
log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID
earned.earnedPoints = worth
(new EarnedAchievement(earned)).save (err, doc) ->
return log.debug err if err?
return log.error err if err?
earnedPoints = worth
wrapUp()

View file

@ -13,7 +13,7 @@ repeatable =
description: 'Simulated Games.'
worth: 1
collection: 'users'
query: "{\"simulatedBy\":{\"$gt\":\"0\"}}"
query: "{\"simulatedBy\":{\"$gt\":0}}"
userField: '_id'
proportionalTo: 'simulatedBy'
@ -21,7 +21,7 @@ diminishing =
name: 'Simulated2'
worth: 1.5
collection: 'users'
query: "{\"simulatedBy\":{\"$gt\":\"0\"}}"
query: "{\"simulatedBy\":{\"$gt\":0}}"
userField: '_id'
proportionalTo: 'simulatedBy'
function:
@ -106,7 +106,6 @@ describe 'Achievement', ->
expect(body.type).toBeDefined()
done()
describe 'Achieving Achievements', ->
it 'wait for achievements to be loaded', (done) ->
Achievement.loadAchievements (achievements) ->
@ -120,11 +119,10 @@ describe 'Achieving Achievements', ->
it 'saving an object that should trigger an unlockable achievement', (done) ->
unittest.getNormalJoe (joe) ->
session = new LevelSession(
session = new LevelSession
permissions: simplePermissions
creator: joe._id
level: original: 'dungeon-arena'
)
session.save (err, doc) ->
expect(err).toBeNull()
@ -139,6 +137,7 @@ describe 'Achieving Achievements', ->
expect(err).toBeNull()
expect(docs.length).toBe(1)
achievement = docs[0]
expect(achievement).toBeDefined()
expect(achievement.get 'achievement').toBe unlockable._id
expect(achievement.get 'user').toBe joe._id.toHexString()
@ -185,7 +184,55 @@ describe 'Achieving Achievements', ->
done()
it 'cleaning up test: deleting all Achievements and relates', (done) ->
describe 'Recalculate Achievements', ->
EarnedAchievementHandler = require '../../../server/achievements/earned_achievement_handler'
it 'remove earned achievements', (done) ->
clearModels [EarnedAchievement], (err) ->
expect(err).toBeNull()
EarnedAchievement.find {}, (err, earned) ->
expect(earned.length).toBe 0
User.update {}, {$set: {points: 0}}, {multi:true}, (err) ->
expect(err).toBeNull()
done()
it 'can not be accessed by regular users', (done) ->
loginJoe -> request.post {uri:getURL '/admin/earned_achievement/recalculate'}, (err, res, body) ->
expect(res.statusCode).toBe 403
done()
it 'can recalculate a selection of achievements', (done) ->
loginAdmin ->
EarnedAchievementHandler.constructor.recalculate ['dungeon-arena-started'], ->
EarnedAchievement.find {}, (err, earnedAchievements) ->
expect(earnedAchievements.length).toBe 1
# Recalculate again, doesn't change a thing
EarnedAchievementHandler.constructor.recalculate ['dungeon-arena-started'], ->
EarnedAchievement.find {}, (err, earnedAchievements) ->
expect(earnedAchievements.length).toBe 1
unittest.getNormalJoe (joe) ->
User.findById joe.get('id'), (err, guy) ->
expect(err).toBeNull()
expect(guy.get 'points').toBe unlockable.worth
done()
it 'can recalculate all achievements', (done) ->
loginAdmin ->
Achievement.count {}, (err, count) ->
expect(count).toBe 3
EarnedAchievementHandler.constructor.recalculate ->
EarnedAchievement.find {}, (err, earnedAchievements) ->
expect(earnedAchievements.length).toBe 3
unittest.getNormalJoe (joe) ->
User.findById joe.get('id'), (err, guy) ->
expect(err).toBeNull()
expect(guy.get 'points').toBe unlockable.worth + 2 * repeatable.worth + (Math.log(.5 * (2 + .5)) + 1) * diminishing.worth
done()
it 'cleaning up test: deleting all Achievements and related', (done) ->
clearModels [Achievement, EarnedAchievement, LevelSession], (err) ->
expect(err).toBeNull()