codecombat/server/levels/level_handler.coffee

439 lines
16 KiB
CoffeeScript

Level = require './Level'
Session = require './sessions/LevelSession'
User = require '../users/User'
SessionHandler = require './sessions/level_session_handler'
Feedback = require './feedbacks/LevelFeedback'
Handler = require '../commons/Handler'
mongoose = require 'mongoose'
async = require 'async'
utils = require '../lib/utils'
log = require 'winston'
LevelHandler = class LevelHandler extends Handler
modelClass: Level
jsonSchema: require '../../app/schemas/models/level'
editableProperties: [
'description'
'documentation'
'background'
'nextLevel'
'scripts'
'thangs'
'systems'
'victory'
'name'
'i18n'
'icon'
'goals'
'type'
'showsGuide'
'banner'
'employerDescription'
'terrain'
'i18nCoverage'
'loadingTip'
'requiresSubscription'
'adventurer'
'practice'
'adminOnly'
'disableSpaces'
'hidesSubmitUntilRun'
'hidesPlayButton'
'hidesRunShortcut'
'hidesHUD'
'hidesSay'
'hidesCodeToolbar'
'hidesRealTimePlayback'
'backspaceThrottle'
'lockDefaultCode'
'moveRightLoopSnippet'
'realTimeSpeedFactor'
'autocompleteFontSizePx'
'requiredCode'
'suspectCode'
'requiredGear'
'restrictedGear'
'allowedHeroes'
'tasks'
'helpVideos'
'campaign'
'replayable'
'buildTime'
'scoreTypes'
]
postEditableProperties: ['name']
getByRelationship: (req, res, args...) ->
return @getSession(req, res, args[0]) if args[1] is 'session'
return @getLeaderboard(req, res, args[0]) if args[1] is 'leaderboard'
return @getMyLeaderboardRank(req, res, args[0]) if args[1] is 'leaderboard_rank'
return @getMySessions(req, res, args[0]) if args[1] is 'my_sessions'
return @getFeedback(req, res, args[0]) if args[1] is 'feedback'
return @getAllFeedback(req, res, args[0]) if args[1] is 'all_feedback'
return @getRandomSessionPair(req, res, args[0]) if args[1] is 'random_session_pair'
return @getLeaderboardFacebookFriends(req, res, args[0]) if args[1] is 'leaderboard_facebook_friends'
return @getLeaderboardGPlusFriends(req, res, args[0]) if args[1] is 'leaderboard_gplus_friends'
return @getHistogramData(req, res, args[0]) if args[1] is 'histogram_data'
return @checkExistence(req, res, args[0]) if args[1] is 'exists'
return @getPlayCountsBySlugs(req, res) if args[1] is 'play_counts'
return @getLevelPlaytimesBySlugs(req, res) if args[1] is 'playtime_averages'
return @getTopScores(req, res, args[0], args[2], args[3]) if args[1] is 'top_scores'
super(arguments...)
fetchLevelByIDAndHandleErrors: (id, req, res, callback) ->
@getDocumentForIdOrSlug id, (err, level) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless level?
return @sendForbiddenError(res) unless @hasAccessToDocument(req, level, 'get')
callback err, level
getSession: (req, res, id) ->
return @sendNotFoundError(res) unless req.user
@fetchLevelByIDAndHandleErrors id, req, res, (err, level) =>
sessionQuery =
level:
original: level.original.toString()
majorVersion: level.version.major
creator: req.user.id
if req.query.team?
sessionQuery.team = req.query.team
Session.findOne(sessionQuery).exec (err, doc) =>
return @sendDatabaseError(res, err) if err
return @sendSuccess(res, doc) if doc?
return @sendPaymentRequiredError(res, err) if (not req.user.isPremium()) and level.get('requiresSubscription') and not level.get('adventurer')
@createAndSaveNewSession sessionQuery, req, res
createAndSaveNewSession: (sessionQuery, req, res) =>
initVals = sessionQuery
initVals.state =
complete: false
scripts:
currentScript: null # will not save empty objects
initVals.permissions = [
{
target: req.user.id
access: 'owner'
}
{
target: 'public'
access: 'write'
}
]
initVals.codeLanguage = req.user.get('aceConfig')?.language ? 'python'
session = new Session(initVals)
session.save (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @formatEntity(req, session))
# TODO: tying things like @formatEntity and saveChangesToDocument don't make sense
# associated with the handler, because the handler might return a different type
# of model, like in this case. Refactor to move that logic to the model instead.
getMySessions: (req, res, slugOrID) ->
return @sendForbiddenError(res) if not req.user
findParameters = {}
if Handler.isID slugOrID
findParameters['_id'] = slugOrID
else
findParameters['slug'] = slugOrID
selectString = 'original version.major permissions'
query = Level.findOne(findParameters)
.select(selectString)
.lean()
query.exec (err, level) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless level?
sessionQuery =
level:
original: level.original.toString()
majorVersion: level.version.major
creator: req.user._id+''
query = Session.find(sessionQuery).select('-screenshot')
query.exec (err, results) =>
if err then @sendDatabaseError(res, err) else @sendSuccess res, results
getHistogramData: (req, res, slug) ->
query = Session.aggregate [
{$match: {'levelID': slug, 'submitted': true, 'team': req.query.team}}
{$project: {totalScore: 1, _id: 0}}
]
#query.cache() # TODO: implement caching for aggregates
query.exec (err, data) =>
if err? then return @sendDatabaseError res, err
valueArray = _.pluck data, 'totalScore'
@sendSuccess res, valueArray
checkExistence: (req, res, slugOrID) ->
findParameters = {}
if Handler.isID slugOrID
findParameters['_id'] = slugOrID
else
findParameters['slug'] = slugOrID
selectString = 'original version.major permissions'
query = Level.findOne(findParameters)
.select(selectString)
.lean()
query.exec (err, level) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless level?
res.send({'exists': true})
res.end()
getLeaderboard: (req, res, id) ->
sessionsQueryParameters = @makeLeaderboardQueryParameters(req, id)
sortParameters =
'totalScore': req.query.order
selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage']
query = Session
.find(sessionsQueryParameters)
.limit(req.query.limit)
.sort(sortParameters)
.select(selectProperties.join ' ')
query.cache() if sessionsQueryParameters.totalScore.$lt is 1000000
query.exec (err, resultSessions) =>
return @sendDatabaseError(res, err) if err
resultSessions ?= []
@sendSuccess res, resultSessions
getMyLeaderboardRank: (req, res, id) ->
req.query.order = 1
sessionsQueryParameters = @makeLeaderboardQueryParameters(req, id)
Session.count sessionsQueryParameters, (err, count) =>
return @sendDatabaseError(res, err) if err
res.send JSON.stringify(count + 1)
makeLeaderboardQueryParameters: (req, id) ->
@validateLeaderboardRequestParameters req
[original, version] = id.split '.'
version = parseInt(version) ? 0
scoreQuery = {}
scoreQuery[if req.query.order is 1 then '$gt' else '$lt'] = req.query.scoreOffset
query =
level:
original: original
majorVersion: version
team: req.query.team
totalScore: scoreQuery
submitted: true
query
validateLeaderboardRequestParameters: (req) ->
req.query.order = parseInt(req.query.order) ? -1
req.query.scoreOffset = parseFloat(req.query.scoreOffset) ? 100000
req.query.team ?= 'humans'
req.query.limit = parseInt(req.query.limit) ? 20
getLeaderboardFacebookFriends: (req, res, id) -> @getLeaderboardFriends(req, res, id, 'facebookID')
getLeaderboardGPlusFriends: (req, res, id) -> @getLeaderboardFriends(req, res, id, 'gplusID')
getLeaderboardFriends: (req, res, id, serviceProperty) ->
friendIDs = req.body.friendIDs or []
return res.send([]) unless friendIDs.length
q = {}
q[serviceProperty] = {$in: friendIDs}
query = User.find(q).select("#{serviceProperty} name").lean()
query.exec (err, userResults) ->
return res.send([]) unless userResults.length
[id, version] = id.split('.')
userIDs = (r._id+'' for r in userResults)
q = {'level.original': id, 'level.majorVersion': parseInt(version), creator: {$in: userIDs}, totalScore: {$exists: true}}
query = Session.find(q)
.select('creator creatorName totalScore team')
.lean()
query.exec (err, sessionResults) ->
return res.send([]) unless sessionResults.length
userMap = {}
userMap[u._id] = u[serviceProperty] for u in userResults
session[serviceProperty] = userMap[session.creator] for session in sessionResults
res.send(sessionResults)
getRandomSessionPair: (req, res, slugOrID) ->
findParameters = {}
if Handler.isID slugOrID
findParameters['_id'] = slugOrID
else
findParameters['slug'] = slugOrID
selectString = 'original version'
query = Level.findOne(findParameters)
.select(selectString)
.lean()
.cache()
query.exec (err, level) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless level?
sessionsQueryParameters =
level:
original: level.original.toString()
majorVersion: level.version.major
submitted: true
query = Session.find(sessionsQueryParameters).distinct('team').cache()
query.exec (err, teams) =>
return @sendDatabaseError res, err if err? or not teams
findTop20Players = (sessionQueryParams, team, cb) ->
sessionQueryParams['team'] = team
aggregate = Session.aggregate [
{$match: sessionQueryParams}
{$project: {'totalScore': 1}}
{$sort: {'totalScore': -1}}
{$limit: 20}
]
#aggregate.cache() # TODO: implement caching for aggregates
aggregate.exec cb
async.map teams, findTop20Players.bind(@, sessionsQueryParameters), (err, map) =>
if err? then return @sendDatabaseError(res, err)
sessions = []
for mapItem in map
sessions.push _.sample(mapItem)
if map.length != 2 then return @sendDatabaseError res, 'There aren\'t sessions of 2 teams, so cannot choose random opponents!'
@sendSuccess res, sessions
getFeedback: (req, res, levelID) ->
return @sendNotFoundError(res) unless req.user
@doGetFeedback req, res, levelID, false
getAllFeedback: (req, res, levelID) ->
return @sendNotFoundError(res) unless req.user
@doGetFeedback req, res, levelID, true
doGetFeedback: (req, res, levelID, multiple) ->
@fetchLevelByIDAndHandleErrors levelID, req, res, (err, level) =>
feedbackQuery =
'level.original': level.original.toString()
'level.majorVersion': level.version.major
feedbackQuery.creator = mongoose.Types.ObjectId(req.user.id.toString()) unless multiple
fn = if multiple then 'find' else 'findOne'
Feedback[fn](feedbackQuery).exec (err, result) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless result?
@sendSuccess(res, result)
getPlayCountsBySlugs: (req, res) ->
# This is hella slow (4s on my box), so relying on some dumb caching for it.
# If we can't make this faster with indexing or something, we might want to maintain the counts another way.
levelIDs = req.query.ids or req.body.ids
return @sendSuccess res, [] unless levelIDs?
@playCountCache ?= {}
@playCountCachedSince ?= new Date()
if (new Date()) - @playCountCachedSince > 86400 * 1000 # Dumb cache expiration
@playCountCache = {}
@playCountCachedSince = new Date()
cacheKey = levelIDs.join ','
if playCounts = @playCountCache[cacheKey]
return @sendSuccess res, playCounts
query = Session.aggregate [
{$match: {levelID: {$in: levelIDs}}}
{$group: {_id: "$levelID", playtime: {$sum: "$playtime"}, sessions: {$sum: 1}}}
{$sort: {sessions: -1}}
]
query.exec (err, data) =>
if err? then return @sendDatabaseError res, err
@playCountCache[cacheKey] = data
@sendSuccess res, data
hasAccessToDocument: (req, document, method=null) ->
return true if req.user?.isArtisan()
method ?= req.method
return true if method is null or method is 'get'
super(req, document, method)
getLevelPlaytimesBySlugs: (req, res) ->
# Returns an array of per-day level average playtimes
# Parameters:
# slugs - array of level slugs
# startDay - Inclusive, optional, e.g. '2014-12-14'
# endDay - Exclusive, optional, e.g. '2014-12-16'
# TODO: An uncached call takes about 5s for dungeons-of-kithgard locally
# TODO: This is very similar to getLevelCompletionsBySlugs(), time to generalize analytics APIs?
# TODO: exclude admin data
levelSlugs = req.query.slugs or req.body.slugs
startDay = req.query.startDay or req.body.startDay
endDay = req.query.endDay or req.body.endDay
return @sendSuccess res, [] unless levelSlugs?
# log.warn "playtime_averages levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}"
# Cache results for 1 day
@levelPlaytimesCache ?= {}
@levelPlaytimesCachedSince ?= new Date()
if (new Date()) - @levelPlaytimesCachedSince > 86400 * 1000 # Dumb cache expiration
@levelPlaytimesCache = {}
@levelPlaytimesCachedSince = new Date()
cacheKey = levelSlugs.join(',')
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
return @sendSuccess res, levelPlaytimes if levelPlaytimes = @levelPlaytimesCache[cacheKey]
# Build query
match = {$match: {$and: [{"state.complete": true}, {"playtime": {$gt: 0}}, {levelID: {$in: levelSlugs}}]}}
match["$match"]["$and"].push _id: {$gte: utils.objectIdFromTimestamp(startDay + "T00:00:00.000Z")} if startDay?
match["$match"]["$and"].push _id: {$lt: utils.objectIdFromTimestamp(endDay + "T00:00:00.000Z")} if endDay?
project = {"$project": {"_id": 0, "levelID": 1, "playtime": 1, "created": {"$concat": [{"$substr": ["$created", 0, 10]}]}}}
group = {"$group": {"_id": {"created": "$created", "level": "$levelID"}, "average": {"$avg": "$playtime"}}}
query = Session.aggregate match, project, group
query.exec (err, data) =>
if err? then return @sendDatabaseError res, err
# Build list of level average playtimes
playtimes = []
for item in data
playtimes.push
level: item._id.level
created: item._id.created
average: item.average
@levelPlaytimesCache[cacheKey] = playtimes
@sendSuccess res, playtimes
getTopScores: (req, res, levelOriginal, scoreType, timespan) ->
query =
'level.original': levelOriginal
'state.topScores.type': scoreType
now = new Date()
if timespan is 'day'
since = new Date now - 1 * 86400 * 1000
else if timespan is 'week'
since = new Date now - 7 * 86400 * 1000
if since
query['state.topScores.date'] = $gt: since.toISOString()
sort =
'state.topScores.score': -1
select = ['state.topScores', 'creatorName', 'creator', 'codeLanguage', 'heroConfig']
query = Session
.find(query)
.limit(20)
.sort(sort)
.select(select.join ' ')
query.exec (err, resultSessions) =>
return @sendDatabaseError(res, err) if err
resultSessions ?= []
@sendSuccess res, resultSessions
module.exports = new LevelHandler()