codecombat/server/handlers/level_handler.coffee

520 lines
21 KiB
CoffeeScript

Level = require './../models/Level'
Session = require './../models/LevelSession'
User = require '../models/User'
SessionHandler = require './level_session_handler'
Feedback = require './../models/LevelFeedback'
Handler = require '../commons/Handler'
mongoose = require 'mongoose'
async = require 'async'
utils = require '../lib/utils'
log = require 'winston'
Campaign = require '../models/Campaign'
Course = require '../models/Course'
CourseInstance = require '../models/CourseInstance'
Classroom = require '../models/Classroom'
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'
'campaignIndex'
'replayable'
'buildTime'
'scoreTypes'
'concepts'
'picoCTFProblem'
'practiceThresholdMinutes'
]
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) ->
# TODO: this could probably be faster with projections, right?
@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?
if level.get('type') in ['course', 'course-ladder'] or req.query.course?
return @makeOrRejectCourseLevelSession(req, res, level, sessionQuery)
requiresSubscription = level.get('requiresSubscription') or (req.user.isOnPremiumServer() and level.get('campaign') and not (level.slug in ['dungeons-of-kithgard', 'gems-in-the-deep', 'shadow-guard', 'forgetful-gemsmith', 'signs-and-portents', 'true-names']))
canPlayAnyway = req.user.isPremium() or level.get 'adventurer'
return @sendPaymentRequiredError(res, err) if requiresSubscription and not canPlayAnyway
@createAndSaveNewSession sessionQuery, req, res
makeOrRejectCourseLevelSession: (req, res, level, sessionQuery) ->
CourseInstance.find {members: req.user.get('_id')}, (err, courseInstances) =>
courseIDs = (ci.get('courseID') for ci in courseInstances)
Course.find { _id: { $in: courseIDs }}, (err, courses) =>
campaignIDs = (c.get('campaignID') for c in courses)
Campaign.find { _id: { $in: campaignIDs }}, (err, campaigns) =>
levelOriginals = (_.keys(c.get('levels')) for c in campaigns)
levelOriginals = _.flatten(levelOriginals)
originalString = level.get('original').toString()
if originalString in levelOriginals
campaignStrings = (campaign.id.toString() for campaign in campaigns when campaign.get('levels')[originalString])
courses = _.filter(courses, (course) -> course.get('campaignID').toString() in campaignStrings)
courseStrings = (course.id.toString() for course in courses)
courseInstances = _.filter(courseInstances, (courseInstance) -> courseInstance.get('courseID').toString() in courseStrings)
classroomIDs = (courseInstance.get('classroomID') for courseInstance in courseInstances)
classroomIDs = _.filter _.uniq classroomIDs, false, (objectID='') -> objectID.toString()
if classroomIDs.length
Classroom.find({ _id: { $in: classroomIDs }}).exec (err, classrooms) =>
aceConfigs = (c.get('aceConfig') for c in classrooms)
aceConfig = _.filter(aceConfigs)[0] or {}
req.codeLanguage = aceConfig.language
@createAndSaveNewSession(sessionQuery, req, res)
else
@createAndSaveNewSession(sessionQuery, req, res)
else
return @sendPaymentRequiredError(res, 'You must be in a course which includes this level to play it')
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.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 -transpiledCode')
# TODO: take out "code" as well, since that can get huge containing the transpiled code for the lat hero, and find another way of having the LadderSubmissionViews in the MyMatchesTab determine ranking readiness
query.exec (err, results) =>
if err then @sendDatabaseError(res, err) else @sendSuccess res, results
getHistogramData: (req, res, id) ->
match = @makeLeaderboardQueryParameters req, id
delete match.totalScore
project = totalScore: 1, _id: 0
league = req.query['leagues.leagueID']
project['leagues.leagueID'] = project['leagues.stats.totalScore'] = 1 if league
aggregate = Session.aggregate [
{$match: match}
{$project: project}
]
aggregate.cache(10 * 60 * 1000) unless league
aggregate.exec (err, data) =>
if err? then return @sendDatabaseError res, err
if league
valueArray = _.pluck data, (session) -> _.find(session.leagues, leagueID: league)?.stats?.totalScore or 10
else
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', 'heroConfig', 'leagues.leagueID', 'leagues.stats.totalScore', 'submitDate', 'team']
query = Session
.find(sessionsQueryParameters)
.limit(req.query.limit)
.sort(sortParameters)
.select(selectProperties.join ' ')
query.cache(5 * 60 * 1000) if sessionsQueryParameters.totalScore.$lt is 1000000
query.exec (err, resultSessions) =>
return @sendDatabaseError(res, err) if err
resultSessions ?= []
leaderboardOptions = find: sessionsQueryParameters, limit: req.query.limit, sort: sortParameters, select: selectProperties
@interleaveAILeaderboardSessions leaderboardOptions, resultSessions, (err, resultSessions) =>
return @sendDatabaseError(res, err) if err
if league = req.query['leagues.leagueID']
resultSessions = _.sortBy resultSessions, (session) -> _.find(session.get('leagues'), leagueID: league)?.stats.totalScore ? session.get('totalScore') / 2
resultSessions.reverse() if sortParameters.totalScore is -1
@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['leagues.leagueID'] = league if league = req.query['leagues.leagueID']
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
ladderBenchmarkAIs: [
'564ba6cea33967be1312ae59'
'564ba830a33967be1312ae61'
'564ba91aa33967be1312ae65'
'564ba95ca33967be1312ae69'
'564ba9b7a33967be1312ae6d'
]
interleaveAILeaderboardSessions: (leaderboardOptions, sessions, cb) ->
return cb null, sessions unless leaderboardOptions.find['leagues.leagueID']
return cb null, sessions if leaderboardOptions.limit < 10 # Don't put them in when we're fetching sessions around another session
# Get our list of benchmark AI sessions
benchmarkSessions = Session
.find(level: leaderboardOptions.find.level, creator: {$in: @ladderBenchmarkAIs})
.sort(leaderboardOptions.sort)
.select(leaderboardOptions.select.join ' ')
.cache(30 * 60 * 1000)
.exec (err, aiSessions) ->
return cb err if err
matchingAISessions = _.filter aiSessions, (aiSession) ->
return false unless aiSession.get('team') is leaderboardOptions.find.team
return false if $gt = leaderboardOptions.find.totalScore.$gt and aiSession.get('totalScore') <= $gt
return false if $lt = leaderboardOptions.find.totalScore.$lt and aiSession.get('totalScore') >= $lt
true
# TODO: these aren't real league scores for AIs, but rather the general leaderboard scores, which will make most AI scores artificially high. So we divide by 2 for AI scores not part of the league. Pretty weak, I know. Eventually we'd want them to actually play league matches as if they were in all leagues, but without having infinite space requirements or something? Or change the UI to take them out of the main league table and into their separate area.
sessions = _.sortBy sessions.concat(matchingAISessions), (session) -> _.find(session.get('leagues'), leagueID: leaderboardOptions.find['leagues.leagueID'])?.stats.totalScore ? session.get('totalScore') / 2
sessions.reverse() if leaderboardOptions.sort.totalScore is -1
sessions = sessions.slice 0, leaderboardOptions.limit
return cb null, sessions
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(60 * 60 * 1000)
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
teams = ['humans', 'ogres']
findTop20Players = (sessionQueryParams, team, cb) ->
sessionQueryParams['team'] = team
aggregate = Session.aggregate [
{$match: sessionQueryParams}
{$sort: {'totalScore': -1}}
{$limit: 20}
{$project: {'totalScore': 1}}
]
aggregate.cache(3 * 60 * 1000)
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()