Merge pull request from rubenvereecken/achievements

Achievements - Update with some tests and admin endpoint
This commit is contained in:
Scott Erickson 2014-06-12 11:28:25 -07:00
commit c855327cef
39 changed files with 646 additions and 138 deletions

View file

@ -18,6 +18,7 @@ doQuerySelector = (value, operatorObj) ->
when '$ne' then return false if mapred value, body, (l, r) -> l == r
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
true
@ -34,11 +35,13 @@ matchesQuery = (target, queryObj) ->
pieces = prop.split('.')
obj = target
for piece in pieces
return false unless piece of obj
unless piece of obj
obj = null
break
obj = obj[piece]
if typeof query != 'object' or _.isArray query
return false unless obj == query or (query in obj if _.isArray obj)
else return false unless doQuerySelector obj, query
true
LocalMongo.matchesQuery = matchesQuery
LocalMongo.matchesQuery = matchesQuery

View file

@ -75,3 +75,25 @@ module.exports.getByPath = (target, path) ->
return undefined unless piece of obj
obj = obj[piece]
obj
module.exports.round = _.curry (digits, n) ->
n = +n.toFixed(digits)
positify = (func) -> (x) -> if x > 0 then func(x) else 0
# f(x) = ax + b
createLinearFunc = (params) ->
(x) -> (params.a or 1) * x + (params.b or 0)
# f(x) = ax² + bx + c
createQuadraticFunc = (params) ->
(x) -> (params.a or 1) * x * x + (params.b or 1) * x + (params.c or 0)
# f(x) = a log(b (x + c)) + d
createLogFunc = (params) ->
(x) -> if x > 0 then (params.a or 1) * Math.log((params.b or 1) * (x + (params.c or 0))) + (params.d or 0) else 0
module.exports.functionCreators =
linear: positify(createLinearFunc)
quadratic: positify(createQuadraticFunc)
logarithmic: positify(createLogFunc)

View file

@ -507,7 +507,7 @@
new_thang_title_login: "Log In to Create a New Thang Type"
new_level_title_login: "Log In to Create a New Level"
new_achievement_title: "Create a New Achievement"
new_achievement_title_login: "Sign Up to Create a New Achievement"
new_achievement_title_login: "Log In to Create a New Achievement"
article_search_title: "Search Articles Here"
thang_search_title: "Search Thang Types Here"
level_search_title: "Search Levels Here"

View file

@ -1,4 +1,5 @@
CocoModel = require './CocoModel'
util = require '../lib/utils'
module.exports = class Achievement extends CocoModel
@className: 'Achievement'
@ -6,4 +7,10 @@ module.exports = class Achievement extends CocoModel
urlRoot: '/db/achievement'
isRepeatable: ->
@get('proportionalTo')?
@get('proportionalTo')?
# TODO logic is duplicated in Mongoose Achievement schema
getExpFunction: ->
kind = @get('function')?.kind or @schema.function.default.kind
parameters = @get('function')?.parameters or @schema.function.default.parameters
return utils.functionCreators[kind](parameters) if kind of utils.functionCreators

View file

@ -52,22 +52,17 @@ _.extend(AchievementSchema.properties,
description: 'For repeatables only. Denotes the field a repeatable achievement needs for its calculations'
function:
type: 'object'
oneOf: [
linear:
properties:
kind: {enum: ['linear', 'logarithmic'], default: 'linear'}
parameters:
type: 'object'
properties:
a: {type: 'number', default: 1},
required: ['a']
description: 'f(x) = a * x'
logarithmic:
type:'object'
properties:
a: {type: 'number', default: 1}
b: {type: 'number', default: 1}
required: ['a', 'b']
description: 'f(x) = a * ln(1/b * (x + b))'
]
default: linear: a: 1
c: {type: 'number', default: 1}
default: {kind: 'linear', parameters: a: 1}
required: ['kind', 'parameters']
additionalProperties: false
)
AchievementSchema.definitions = {}

View file

@ -20,15 +20,11 @@ module.exports =
href: '/db/achievement/{($)}'
}
]
collection:
type: 'string'
achievementName:
type: 'string'
created:
type: 'date'
changed:
type: 'date'
achievedAmount:
type: 'number'
notified:
type: 'boolean'
collection: type: 'string'
achievementName: type: 'string'
created: type: 'date'
changed: type: 'date'
achievedAmount: type: 'number'
earnedPoints: type: 'number'
previouslyAchievedAmount: {type: 'number', default: 0}
notified: type: 'boolean'

View file

@ -0,0 +1,12 @@
#editor-achievement-edit-view
.treema-root
margin: 28px 0px 20px
button
float: right
margin-top: 15px
margin-left: 10px
textarea
width: 92%
height: 300px

View file

@ -45,3 +45,6 @@
font-family: Bangers
font-size: 16px
float: right
.progress-bar-white
background-color: white

View file

@ -33,3 +33,11 @@ block content
a(href="/admin/base", data-i18n="admin.av_other_debug_base_url") Base (for debugging base.jade)
li
a(href="/admin/clas", data-i18n="admin.clas") CLAs
hr
h3 Achievements
p This is just some stuff for temporary achievement testing. Should be replaced by a demo system.
input#increment-field(type="text")
a.btn.btn-secondary#increment-button(href="#") Increment

View file

@ -11,19 +11,21 @@ block content
li.active
| #{achievement.attributes.name}
button(data-i18n="common.save", disabled=authorized === true ? undefined : "true").btn.btn-primary#save-button Save
button(data-i18n="", disabled=me.isAdmin() === true ? undefined : "true").btn.btn-primary#recalculate-button Recalculate
button(data-i18n="common.save", disabled=me.isAdmin() === true ? undefined : "true").btn.btn-primary#save-button Save
h3(data-i18n="achievement.edit_achievement_title") Edit Achievement
span
|: "#{achievement.attributes.name}"
h3(data-i18n="achievement.edit_achievement_title") Edit Achievement
span
|: "#{achievement.attributes.name}"
#achievement-treema
#achievement-treema
#achievement-view
#achievement-view
hr
hr
div#error-view
div#error-view
else
.alert.alert-danger
span Admin only. Turn around.

View file

@ -37,6 +37,7 @@ block content
h3(data-i18n="play_level.tip_reticulating") Reticulating Splines...
.progress.progress-striped.active
.progress-bar
else
.alert.alert-danger
span Admin only. Turn around.

View file

@ -0,0 +1,11 @@
extends /templates/modal/modal_base
block modal-header-content
h3 #{confirmTitle}
block modal-body-content
p #{confirmBody}
block modal-footer-content
button.btn.btn-secondary#decline-button(type="button", data-dismiss="modal") #{confirmDecline}
button.btn.btn-primary#confirm-button(type="button", data-dismiss=closeOnConfirm === true ? "modal" : undefined) #{confirmConfirm}

View file

@ -24,4 +24,4 @@
block modal-footer
.modal-footer
block modal-footer-content
button.btn.btn-primary(type="button", data-dismiss="modal", aria-hidden="true", data-i18n="modal.okay") Okay
button.btn.btn-primary(type="button", data-dismiss="modal", aria-hidden="true", data-i18n="modal.okay") Okay

View file

@ -8,6 +8,7 @@ module.exports = class AdminView extends View
events:
'click #enter-espionage-mode': 'enterEspionageMode'
'click #increment-button': 'incrementUserAttribute'
enterEspionageMode: ->
userEmail = $("#user-email").val().toLowerCase()
@ -29,3 +30,8 @@ module.exports = class AdminView extends View
espionageFailure: (jqxhr, status,error)->
console.log "There was an error entering espionage mode: #{error}"
incrementUserAttribute: (e) ->
val = $('#increment-field').val()
me.set(val, me.get(val) + 1)
me.save()

View file

@ -1,6 +1,7 @@
View = require 'views/kinds/RootView'
template = require 'templates/editor/achievement/edit'
Achievement = require 'models/Achievement'
ConfirmModal = require 'views/modal/confirm'
module.exports = class AchievementEditView extends View
id: "editor-achievement-edit-view"
@ -9,6 +10,7 @@ module.exports = class AchievementEditView extends View
events:
'click #save-button': 'saveAchievement'
'click #recalculate-button': 'confirmRecalculation'
subscriptions:
'save-new': 'saveAchievement'
@ -72,3 +74,34 @@ module.exports = class AchievementEditView extends View
res.success =>
url = "/editor/achievement/#{@achievement.get('slug') or @achievement.id}"
document.location.href = url
confirmRecalculation: (e) ->
renderData =
'confirmTitle': "Are you really sure?"
'confirmBody': "This will trigger recalculation of the achievement for all users. Are you really sure you want to go down this path?"
'confirmDecline': "Not really"
'confirmConfirm': "Definitely"
confirmModal = new ConfirmModal(renderData)
confirmModal.onConfirm @recalculateAchievement
@openModalView confirmModal
recalculateAchievement: =>
$.ajax
data: JSON.stringify(achievements: [@achievement.get('slug') or @achievement.get('_id')])
success: (data, status, jqXHR) ->
noty
timeout: 5000
text: 'Recalculation process started'
type: 'success'
layout: 'topCenter'
error: (jqXHR, status, error) ->
console.error jqXHR
noty
timeout: 5000
text: "Starting recalculation process failed with error code #{jqXHR.status}"
type: 'error'
layout: 'topCenter'
url: '/admin/earned.achievement/recalculate'
type: 'POST'
contentType: 'application/json'

View file

@ -8,6 +8,7 @@ locale = require 'locale/locale'
Achievement = require '../../models/Achievement'
User = require '../../models/User'
# TODO remove
filterKeyboardEvents = (allowedEvents, func) ->
return (splat...) ->
@ -25,26 +26,24 @@ module.exports = class RootView extends CocoView
subscriptions:
'achievements:new': 'handleNewAchievements'
initialize: ->
$ =>
# TODO Ruben remove this. Allows for easy testing right now though
#test = new Achievement(_id:'537ce4855c91b8d1dda7fda8')
#test.fetch(success:@showNewAchievement)
showNewAchievement: (achievement) ->
showNewAchievement: (achievement, earnedAchievement) ->
currentLevel = me.level()
nextLevel = currentLevel + 1
currentLevelExp = User.expForLevel(currentLevel)
nextLevelExp = User.expForLevel(nextLevel)
totalExpNeeded = nextLevelExp - currentLevelExp
expFunction = achievement.getExpFunction()
currentExp = me.get('points')
worth = achievement.get('worth')
leveledUp = currentExp - worth < currentLevelExp
alreadyAchievedPercentage = 100 * (currentExp - currentLevelExp - worth) / totalExpNeeded
newlyAchievedPercentage = if currentLevelExp is currentExp then 0 else 100 * worth / totalExpNeeded
previousExp = currentExp - achievement.get('worth')
previousExp = expFunction(earnedAchievement.get('previouslyAchievedAmount')) * achievement.get('worth') if achievement.isRepeatable()
achievedExp = currentExp - previousExp
leveledUp = currentExp - achievedExp < currentLevelExp
alreadyAchievedPercentage = 100 * (previousExp - currentLevelExp) / totalExpNeeded
newlyAchievedPercentage = if leveledUp then 100 * (currentExp - currentLevelExp) / totalExpNeeded else 100 * achievedExp / totalExpNeeded
console.debug "Current level is #{currentLevel} (#{currentLevelExp} xp), next level is #{nextLevel} (#{nextLevelExp} xp)."
console.debug "Need a total of #{nextLevelExp - currentLevelExp}, already had #{currentExp - currentLevelExp - worth} and just now earned #{worth} totalling on #{currentExp}"
console.debug "Need a total of #{nextLevelExp - currentLevelExp}, already had #{previousExp} and just now earned #{achievedExp} totalling on #{currentExp}"
alreadyAchievedBar = $("<div class='progress-bar progress-bar-warning' style='width:#{alreadyAchievedPercentage}%'></div>")
newlyAchievedBar = $("<div data-toggle='tooltip' class='progress-bar progress-bar-success' style='width:#{newlyAchievedPercentage}%'></div>")
@ -53,7 +52,7 @@ module.exports = class RootView extends CocoView
message = if (currentLevel isnt 1) and leveledUp then "Reached level #{currentLevel}!" else null
alreadyAchievedBar.tooltip(title: "#{currentExp} XP in total")
newlyAchievedBar.tooltip(title: "#{worth} XP earned")
newlyAchievedBar.tooltip(title: "#{achievedExp} XP earned")
emptyBar.tooltip(title: "#{nextLevelExp - currentExp} XP until level #{nextLevel}")
# TODO a default should be linked here
@ -63,7 +62,7 @@ module.exports = class RootView extends CocoView
image: $("<img src='#{imageURL}' />")
description: achievement.get('description')
progressBar: progressBar
earnedExp: "+ #{worth} XP"
earnedExp: "+ #{achievedExp} XP"
message: message
options =
@ -77,13 +76,11 @@ module.exports = class RootView extends CocoView
$.notify( data, options )
handleNewAchievements: (earnedAchievements) ->
console.debug 'Got new earned achievements'
# TODO performance?
_.each(earnedAchievements.models, (earnedAchievement) =>
achievement = new Achievement(_id: earnedAchievement.get('achievement'))
console.log achievement
achievement.fetch(
success: @showNewAchievement
success: (achievement) => @showNewAchievement(achievement, earnedAchievement)
)
)

View file

@ -0,0 +1,30 @@
ModalView = require '../kinds/ModalView'
template = require 'templates/modal/confirm'
module.exports = class ConfirmModal extends ModalView
id: "confirm-modal"
template: template
closeButton: true
closeOnConfirm: true
events:
'click #decline-button': 'doDecline'
'click #confirm-button': 'doConfirm'
constructor: (@renderData={}, options={}) ->
super(options)
getRenderData: ->
context = super()
context.closeOnConfirm = @closeOnConfirm
_.extend context, @renderData
setRenderData: (@renderData) ->
onDecline: (@decline) ->
onConfirm: (@confirm) ->
doConfirm: -> @confirm() if @confirm
doDecline: -> @decline() if @decline

View file

@ -1,6 +1,8 @@
mongoose = require('mongoose')
jsonschema = require('../../app/schemas/models/achievement')
log = require 'winston'
util = require '../../app/lib/utils'
plugins = require('../plugins/plugins')
# `pre` and `post` are not called for update operations executed directly on the database,
# including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order
@ -11,16 +13,21 @@ AchievementSchema = new mongoose.Schema({
userField: String
}, {strict: false})
AchievementSchema.methods.objectifyQuery = () ->
AchievementSchema.methods.objectifyQuery = ->
try
@set('query', JSON.parse(@get('query'))) if typeof @get('query') == "string"
catch error
log.error "Couldn't convert query string to object because of #{error}"
@set('query', {})
AchievementSchema.methods.stringifyQuery = () ->
AchievementSchema.methods.stringifyQuery = ->
@set('query', JSON.stringify(@get('query'))) if typeof @get('query') != "string"
getExpFunction: ->
kind = @get('function')?.kind or jsonschema.function.default.kind
parameters = @get('function')?.parameters or jsonschema.function.default.parameters
return utils.functionCreators[kind](parameters) if kind of utils.functionCreators
AchievementSchema.post('init', (doc) -> doc.objectifyQuery())
AchievementSchema.pre('save', (next) ->
@ -28,9 +35,11 @@ AchievementSchema.pre('save', (next) ->
next()
)
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema)
plugins = require('../plugins/plugins')
AchievementSchema.plugin(plugins.NamedPlugin)
AchievementSchema.plugin(plugins.SearchablePlugin, {searchable: ['name']})
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema)
# Reload achievements upon save
AchievablePlugin = require '../plugins/achievements'
AchievementSchema.post 'save', (doc) -> AchievablePlugin.loadAchievements()

View file

@ -13,7 +13,15 @@ EarnedAchievementSchema = new mongoose.Schema({
default: false
}, {strict:false})
EarnedAchievementSchema.pre 'save', (next) ->
@set('changed', Date.now())
next()
EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'})
EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '})
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)

View file

@ -5,7 +5,7 @@ class AchievementHandler extends Handler
modelClass: Achievement
# Used to determine which properties requests may edit
editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon']
editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon', 'function']
jsonSchema = require '../../app/schemas/models/achievement.coffee'
hasAccess: (req) ->

View file

@ -1,12 +1,115 @@
mongoose = require('mongoose')
log = require 'winston'
mongoose = require 'mongoose'
async = require 'async'
Achievement = require './Achievement'
EarnedAchievement = require './EarnedAchievement'
User = require '../users/User'
Handler = require '../commons/Handler'
LocalMongo = require '../../app/lib/LocalMongo'
class EarnedAchievementHandler extends Handler
modelClass: EarnedAchievement
# Don't allow POSTs or anything yet
hasAccess: (req) ->
req.method is 'GET'
req.method is 'GET' # or req.user.isAdmin()
recalculate: (req, res) ->
onSuccess = (data) => log.debug "Finished recalculating achievements"
if 'achievements' of req.body # Support both slugs and IDs separated by commas
achievementSlugsOrIDs = req.body.achievements
EarnedAchievementHandler.recalculate achievementSlugsOrIDs, onSuccess
else
EarnedAchievementHandler.recalculate onSuccess
@sendSuccess res, {}
# Returns success: boolean
# TODO call onFinished
@recalculate: (callbackOrSlugsOrIDs, onFinished) ->
if _.isArray callbackOrSlugsOrIDs
achievementSlugs = (thing for thing in callbackOrSlugsOrIDs when not Handler.isID(thing))
achievementIDs = (thing for thing in callbackOrSlugsOrIDs when Handler.isID(thing))
else
onFinished = callbackOrSlugsOrIDs
filter = {}
filter.$or = [
{_id: $in: achievementIDs},
{slug: $in: achievementSlugs}
] if achievementSlugs? or achievementIDs?
# Fetch all relevant achievements
Achievement.find filter, (err, achievements) ->
return log.error err if err?
# Fetch every single user
User.find {}, (err, users) ->
_.each users, (user) ->
# Keep track of a user's already achieved in order to set the notified values correctly
userID = user.get('_id').toHexString()
# Fetch all of a user's earned achievements
EarnedAchievement.find {user: userID}, (err, alreadyEarned) ->
alreadyEarnedIDs = []
previousPoints = 0
_.each alreadyEarned, (earned) ->
if (_.find achievements, (single) -> earned.get('achievement') is single.get('_id').toHexString())
alreadyEarnedIDs.push earned.get('achievement')
previousPoints += earned.get 'earnedPoints'
# TODO maybe also delete earned? Make sure you don't delete too many
newTotalPoints = 0
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()
model.findOne achievement.query, (err, something) ->
return callback() unless something
log.debug "Matched an achievement: #{achievement.get 'name'}"
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
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?
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
module.exports = new EarnedAchievementHandler()

View file

@ -17,6 +17,7 @@ module.exports = class Handler
postEditableProperties: []
jsonSchema: {}
waterfallFunctions: []
allowedMethods: ['GET', 'POST', 'PUT', 'PATCH']
# subclasses should override these methods
hasAccess: (req) -> true
@ -420,3 +421,7 @@ module.exports = class Handler
dict[document.id] = document
res.send dict
res.end()
delete: (req, res) -> @sendMethodNotAllowed res, @allowedMethods, "DELETE not allowed."
head: (req, res) -> @sendMethodNotAllowed res, @allowedMethods, "HEAD not allowed."

View file

@ -17,8 +17,9 @@ module.exports.notFound = (res, message='Not found.') ->
res.send 404, message
res.end()
module.exports.badMethod = (res, message='Method Not Allowed') ->
# TODO: The response MUST include an Allow header containing a list of valid methods for the requested resource
module.exports.badMethod = (res, allowed=['GET', 'POST', 'PUT', 'PATCH'], message='Method Not Allowed') ->
allowHeader = _.reduce allowed, ((str, current) -> str += ', ' + current)
res.set 'Allow', allowHeader # TODO not sure if these are always the case
res.send 405, message
res.end()
@ -40,4 +41,4 @@ module.exports.gatewayTimeoutError = (res, message="Gateway timeout") ->
module.exports.clientTimeout = (res, message="The server did not recieve the client response in a timely manner") ->
res.send 408, message
res.end()
res.end()

View file

@ -14,6 +14,7 @@ module.exports.handlers =
module.exports.routes =
[
'routes/admin'
'routes/auth'
'routes/contact'
'routes/db'

View file

@ -1,5 +1,4 @@
mongoose = require('mongoose')
Achievement = require('../achievements/Achievement')
EarnedAchievement = require '../achievements/EarnedAchievement'
LocalMongo = require '../../app/lib/LocalMongo'
util = require '../../app/lib/utils'
@ -7,18 +6,9 @@ log = require 'winston'
achievements = {}
loadAchievements = ->
achievements = {}
query = Achievement.find({})
query.exec (err, docs) ->
_.each docs, (achievement) ->
category = achievement.get 'collection'
achievements[category] = [] unless category of achievements
achievements[category].push achievement
loadAchievements()
module.exports = AchievablePlugin = (schema, options) ->
User = require '../users/User'
User = require '../users/User' # Avoid mutual inclusion cycles
Achievement = require('../achievements/Achievement')
checkForAchievement = (doc) ->
collectionName = doc.constructor.modelName
@ -54,6 +44,8 @@ module.exports = AchievablePlugin = (schema, options) ->
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
}
worth = achievement.get('worth')
earnedPoints = 0
wrapUp = ->
# Update user's experience points
@ -68,23 +60,37 @@ module.exports = AchievablePlugin = (schema, options) ->
newAmount = docObj[proportionalTo]
if originalAmount isnt newAmount
expFunction = achievement.getExpFunction()
earned.notified = false
earned.achievedAmount = newAmount
earned.changed = Date.now()
EarnedAchievement.findOneAndUpdate({achievement:earned.achievement, user:earned.user}, earned, upsert:true, (err, docs) ->
return log.debug err if err?
)
earned.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * worth
earned.previouslyAchievedAmount = originalAmount
EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) ->
return log.debug err if err?
earnedPoints = achievement.get('worth') * (newAmount - originalAmount)
earnedPoints = earned.earnedPoints
log.debug earnedPoints
wrapUp()
else # not alreadyAchieved
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?
earnedPoints = achievement.get('worth')
earnedPoints = worth
wrapUp()
delete before[doc.id] unless isNew # This assumes everything we patch has a _id
return
module.exports.loadAchievements = ->
achievements = {}
Achievement = require('../achievements/Achievement')
query = Achievement.find({})
query.exec (err, docs) ->
_.each docs, (achievement) ->
category = achievement.get 'collection'
achievements[category] = [] unless category of achievements
achievements[category].push achievement
AchievablePlugin.loadAchievements()

View file

@ -0,0 +1,27 @@
log = require 'winston'
errors = require '../commons/errors'
handlers = require('../commons/mapping').handlers
mongoose = require('mongoose')
module.exports.setup = (app) ->
app.post '/admin/*', (req, res) ->
# TODO apparently I can leave this out as long as I use res.send
res.setHeader('Content-Type', 'application/json')
module = req.path[7..]
parts = module.split('/')
module = parts[0]
return errors.unauthorized(res, 'Must be admin to access this area.') unless req.user?.isAdmin()
try
moduleName = module.replace '.', '_'
name = handlers[moduleName]
handler = require('../' + name)
return handler[parts[1]](req, res, parts[2..]...) if parts[1] of handler
catch error
log.error("Error trying db method '#{req.route.method}' route '#{parts}' from #{name}: #{error}")
errors.notFound(res, "Route #{req.path} not found.")

View file

@ -8,7 +8,7 @@ module.exports.setup = (app) ->
app.all '/file*', (req, res) ->
return fileGet(req, res) if req.route.method is 'get'
return filePost(req, res) if req.route.method is 'post'
return errors.badMethod(res)
return errors.badMethod(res, ['GET', 'POST'])
fileGet = (req, res) ->

View file

@ -4,7 +4,7 @@ errors = require '../commons/errors'
module.exports.setup = (app) ->
app.all '/folder*', (req, res) ->
return folderGet(req, res) if req.route.method is 'get'
return errors.badMethod(res)
return errors.badMethod(res, ['GET'])
folderGet = (req, res) ->
folder = req.path[7..]
@ -15,4 +15,4 @@ folderGet = (req, res) ->
mongoose.connection.db.collection 'media.files', (errors, collection) ->
collection.find({'metadata.path': folder}).toArray (err, results) ->
res.send(results)
res.end()
res.end()

View file

@ -11,7 +11,7 @@ module.exports.setup = (app) ->
app.all '/languages', (req, res) ->
# Now that these are in the client, not sure when we would use this, but hey
return errors.badMethod(res) if req.route.method isnt 'get'
return errors.badMethod(res, ['GET']) if req.route.method isnt 'get'
res.send(languages)
return res.end()

View file

@ -28,7 +28,7 @@ module.exports.setup = (app) ->
app.all '/queue/*', (req, res) ->
setResponseHeaderToJSONContentType res
queueName = getQueueNameFromPath req.path
try
handler = loadQueueHandler queueName
@ -64,7 +64,7 @@ isHTTPMethodPost = (req) -> return req.route.method is 'post'
isHTTPMethodPut = (req) -> return req.route.method is 'put'
sendMethodNotSupportedError = (req, res) -> errors.badMethod(res,"Queues do not support the HTTP method used." )
sendMethodNotSupportedError = (req, res) -> errors.badMethod(res, ['GET', 'POST', 'PUT'], "Queues do not support the HTTP method used." )
sendQueueError = (req,res, error) -> errors.serverError(res, "Route #{req.path} had a problem: #{error}")

View file

@ -2,7 +2,7 @@ describe 'Local Mongo queries', ->
LocalMongo = require 'lib/LocalMongo'
beforeEach ->
this.fixture1 =
@fixture1 =
'id': 'somestring'
'value': 9000
'levels': [3, 8, 21]
@ -10,68 +10,72 @@ describe 'Local Mongo queries', ->
'type': 'unicorn'
'likes': ['poptarts', 'popsicles', 'popcorn']
this.fixture2 = this: is: so: 'deep'
@fixture2 = this: is: so: 'deep'
it 'regular match of a property', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'gender': 'unicorn')).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'unicorn')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'zebra')).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'unicorn', 'id':'somestring')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'gender': 'unicorn')).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type':'unicorn')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'type':'zebra')).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type':'unicorn', 'id':'somestring')).toBeTruthy()
it 'array match of a property', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'likes':'poptarts')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes':'walks on the beach')).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes':'poptarts')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes':'walks on the beach')).toBeFalsy()
it 'nested match', ->
expect(LocalMongo.matchesQuery(this.fixture2, 'this.is.so':'deep')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture2, 'this.is.so':'deep')).toBeTruthy()
it '$gt selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': 8000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': [8000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$gt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$gt': 8000}, 'worth': {'$gt': 5})).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': 8000)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': [8000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'levels': '$gt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': {'$gt': 8000}, 'worth': {'$gt': 5})).toBeTruthy()
it '$gte selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': 9001)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': [9000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$gte': [21, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gte': 9001)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$gte': [9000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'levels': '$gte': [21, 30])).toBeTruthy()
it '$lt selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': 9001)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': [9001, 9000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$lt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$lt': 9001}, 'worth': {'$lt': 7})).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lt': 9001)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lt': [9001, 9000])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'levels': '$lt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': {'$lt': 9001}, 'worth': {'$lt': 7})).toBeTruthy()
it '$lte selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lte': 8000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$lte': 9000}, 'worth': {'$lte': [6, 5]})).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$lte': 8000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': {'$lte': 9000}, 'worth': {'$lte': [6, 5]})).toBeTruthy()
it '$ne selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$ne': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'id': '$ne': 'otherstring')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'id': '$ne': ['otherstring', 'somestring'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$ne': ['popcorn', 'chicken'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'value': '$ne': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'id': '$ne': 'otherstring')).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'id': '$ne': ['otherstring', 'somestring'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes': '$ne': ['popcorn', 'chicken'])).toBeFalsy()
it '$in selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$in': ['unicorn', 'zebra'])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$in': ['cats', 'dogs'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$in': ['popcorn', 'chicken'])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$in': ['unicorn', 'zebra'])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$in': ['cats', 'dogs'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes': '$in': ['popcorn', 'chicken'])).toBeTruthy()
it '$nin selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$nin': ['unicorn', 'zebra'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$nin': ['cats', 'dogs'])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$nin': ['popcorn', 'chicken'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$nin': ['unicorn', 'zebra'])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, 'type': '$nin': ['cats', 'dogs'])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, 'likes': '$nin': ['popcorn', 'chicken'])).toBeFalsy()
it '$or operator', ->
expect(LocalMongo.matchesQuery(this.fixture1, $or: [{value:9000}, {type:'zebra'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, $or: [{value:9001}, {worth:$lt:10}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $or: [{value:9000}, {type:'zebra'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $or: [{value:9001}, {worth:$lt:10}])).toBeTruthy()
it '$and operator', ->
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:9000}, {type:'zebra'}])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:9000}, {type:'unicorn'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:$gte:9000}, {worth:$lt:10}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $and: [{value:9000}, {type:'zebra'}])).toBeFalsy()
expect(LocalMongo.matchesQuery(@fixture1, $and: [{value:9000}, {type:'unicorn'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, $and: [{value:$gte:9000}, {worth:$lt:10}])).toBeTruthy()
it '$exists operator', ->
expect(LocalMongo.matchesQuery(@fixture1, type: $exists: true)).toBeTruthy()
expect(LocalMongo.matchesQuery(@fixture1, interesting: $exists: false)).toBeTruthy()

View file

@ -30,6 +30,8 @@ models_path = [
'../../server/levels/thangs/LevelThangType'
'../../server/users/User'
'../../server/patches/Patch'
'../../server/achievements/Achievement'
'../../server/achievements/EarnedAchievement'
]
for m in models_path
@ -162,4 +164,4 @@ tick = ->
mongoose.disconnect()
clearTimeout tickInterval
tickInterval = setInterval tick, 1000
tickInterval = setInterval tick, 1000

View file

@ -0,0 +1,110 @@
require '../common'
unlockable =
name: 'Dungeon Arena Started'
description: 'Started playing Dungeon Arena.'
worth: 3
collection: 'level.session'
query: "{\"level.original\":\"dungeon-arena\"}"
userField: 'creator'
repeatable =
name: 'Simulated'
description: 'Simulated Games.'
worth: 1
collection: 'User'
query: "{\"simulatedBy\":{\"$gt\":\"0\"}}"
userField: '_id'
proportionalTo: 'simulatedBy'
url = getURL('/db/achievement')
describe 'Achievement', ->
allowHeader = 'GET, POST, PUT, PATCH'
it 'preparing test: deleting all Achievements first', (done) ->
clearModels [Achievement, EarnedAchievement, LevelSession, User], (err) ->
expect(err).toBeNull()
done()
it 'can\'t be created by ordinary users', (done) ->
loginJoe ->
request.post {uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(403)
done()
it 'can\'t be updated by ordinary users', (done) ->
loginJoe ->
request.put {uri: url, json:unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(403)
request {method: 'patch', uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(403)
done()
it 'can be created by admins', (done) ->
loginAdmin ->
request.post {uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
unlockable._id = body._id
request.post {uri: url, json: repeatable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
repeatable._id = body._id
done()
it 'can get all for ordinary users', (done) ->
loginJoe ->
request.get {uri: url, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.length).toBe(2)
done()
it 'can be read by ordinary users', (done) ->
loginJoe ->
request.get {uri: url + '/' + unlockable._id, json: unlockable}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.name).toBe(unlockable.name)
done()
it 'can\'t be requested with HTTP HEAD method', (done) ->
loginJoe ->
request.head {uri: url + '/' + unlockable._id}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP DEL method', (done) ->
loginJoe ->
request.del {uri: url + '/' + unlockable._id}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'get schema', (done) ->
request.get {uri:url + '/schema'}, (err, res, body) ->
expect(res.statusCode).toBe(200)
body = JSON.parse(body)
expect(body.type).toBeDefined()
done()
describe 'Achieving Achievements', ->
it 'allows users to unlock one-time Achievements', (done) ->
loginJoe (joe) ->
levelSession =
creator: joe._id
level: original: 'dungeon-arena'
request.post {uri:getURL('/db/level.session'), json:levelSession}, (session) ->
done()
xit 'cleaning up test: deleting all Achievements and relates', (done) ->
clearModels [Achievement, EarnedAchievement, LevelSession], (err) ->
expect(err).toBeNull()
done()

View file

@ -28,6 +28,8 @@ xdescribe '/file', ->
my_buffer_url: 'http://fc07.deviantart.net/fs37/f/2008/283/5/1/Chu_Chu_Pikachu_by_angelishi.gif'
}
allowHeader = 'GET, POST'
it 'preparing test : deletes all the files first', (done) ->
dropGridFS ->
done()
@ -147,19 +149,28 @@ xdescribe '/file', ->
request.post(options, func)
it ' can\'t be requested with HTTP PATCH method', (done) ->
request {method: 'patch', uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it ' can\'t be requested with HTTP PUT method', (done) ->
request.put {uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it ' can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it ' can\'t be requested with HTTP DEL method', (done) ->
request.del {uri:url}, (err, res) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
# TODO: test server errors, see what they do

View file

@ -0,0 +1,35 @@
require '../common'
describe 'folder', ->
url = getURL('/folder')
allowHeader = 'GET'
it 'can\'t be requested with HTTP POST method', (done) ->
request.post {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PUT method', (done) ->
request.put {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PATCH method', (done) ->
request {method:'patch', uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP DELETE method', (done) ->
request.del {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()

View file

@ -0,0 +1,35 @@
require '../common'
describe 'languages', ->
url = getURL('/languages')
allowHeader = 'GET'
it 'can\'t be requested with HTTP POST method', (done) ->
request.post {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PUT method', (done) ->
request.put {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP PATCH method', (done) ->
request {method:'patch', uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
it 'can\'t be requested with HTTP DELETE method', (done) ->
request.del {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()

View file

@ -130,17 +130,17 @@ describe 'LevelComponent', ->
xit ' can\'t be requested with HTTP PUT method', (done) ->
request.put {uri:url+'/'+components[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it ' can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri:url+'/'+components[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it ' can\'t be requested with HTTP DEL method', (done) ->
request.del {uri:url+'/'+components[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it 'get schema', (done) ->

View file

@ -123,12 +123,12 @@ describe 'LevelSystem', ->
it ' can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri:url+'/'+systems[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it ' can\'t be requested with HTTP DEL method', (done) ->
request.del {uri:url+'/'+systems[0]._id}, (err, res) ->
expect(res.statusCode).toBe(404)
expect(res.statusCode).toBe(405)
done()
it 'get schema', (done) ->

View file

@ -0,0 +1,25 @@
require '../common'
describe 'queue', ->
someURL = getURL('/queue/')
allowHeader = 'GET, POST, PUT'
xit 'can\'t be requested with HTTP PATCH method', (done) ->
request {method:'patch', uri: someURL}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
xit 'can\'t be requested with HTTP HEAD method', (done) ->
request.head {uri: someURL}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()
xit 'can\'t be requested with HTTP DELETE method', (done) ->
request.del {uri: someURL}, (err, res, body) ->
expect(res.statusCode).toBe(405)
expect(res.headers.allow).toBe(allowHeader)
done()