More achievement tweaks

* Clients check updated achievements as well as new ones
* Clients do not wait to keep checking
* Update achievement points along with everything else in EarnedAchievement.upsertFor
* Fix various bugs
This commit is contained in:
Scott Erickson 2016-08-30 15:46:34 -07:00
parent d223143557
commit 2fe28852b4
13 changed files with 283 additions and 55 deletions

View file

@ -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

View file

@ -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) ->

View file

@ -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

View file

@ -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'

View file

@ -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)
});

View file

@ -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}))

View file

@ -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()))

View file

@ -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

View file

@ -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 }

View file

@ -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) ->

View file

@ -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()

View file

@ -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) ->

View file

@ -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()