mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-01 03:16:56 -05:00
470 lines
18 KiB
CoffeeScript
470 lines
18 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'
|
|
Campaign = require '../campaigns/Campaign'
|
|
Course = require '../courses/Course'
|
|
CourseInstance = require '../courses/CourseInstance'
|
|
|
|
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'
|
|
'concepts'
|
|
]
|
|
|
|
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') is '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)
|
|
if level.get('original').toString() in levelOriginals
|
|
@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.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, slug) ->
|
|
match = levelID: slug, submitted: true, team: req.query.team
|
|
match['leagues.leagueID'] = league if league = req.query['leagues.leagueID']
|
|
project = totalScore: 1, _id: 0
|
|
project['leagues.leagueID'] = project['leagues.stats.totalScore'] = 1 if league
|
|
aggregate = Session.aggregate [
|
|
{$match: match}
|
|
{$project: project}
|
|
]
|
|
aggregate.cache() 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']
|
|
|
|
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['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
|
|
|
|
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}
|
|
{$sort: {'totalScore': -1}}
|
|
{$limit: 20}
|
|
{$project: {'totalScore': 1}}
|
|
]
|
|
aggregate.cache()
|
|
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()
|