From f757f220be5608c549a71fe9071d842b38bdffd7 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Thu, 13 Aug 2015 11:17:38 -0700 Subject: [PATCH 1/5] Refactored scoring.coffee into separate files per route plus a scoringUtils file, and cleaned up a few of the verbose methods. --- app/views/ladder/SimulateTabView.coffee | 13 - server/queues/scoring.coffee | 697 +----------------- .../{task => scoring}/ScoringTask.coffee | 0 server/queues/scoring/createNewTask.coffee | 91 +++ .../scoring/dispatchTaskToConsumer.coffee | 80 ++ server/queues/scoring/getTwoGames.coffee | 76 ++ .../queues/scoring/processTaskResult.coffee | 172 +++++ server/queues/scoring/recordTwoGames.coffee | 21 + server/queues/scoring/scoringUtils.coffee | 187 +++++ server/routes/queue.coffee | 4 - server/users/User.coffee | 6 +- 11 files changed, 647 insertions(+), 700 deletions(-) rename server/queues/{task => scoring}/ScoringTask.coffee (100%) create mode 100644 server/queues/scoring/createNewTask.coffee create mode 100644 server/queues/scoring/dispatchTaskToConsumer.coffee create mode 100644 server/queues/scoring/getTwoGames.coffee create mode 100644 server/queues/scoring/processTaskResult.coffee create mode 100644 server/queues/scoring/recordTwoGames.coffee create mode 100644 server/queues/scoring/scoringUtils.coffee diff --git a/app/views/ladder/SimulateTabView.coffee b/app/views/ladder/SimulateTabView.coffee index 1fc3ab3b2..52f2cf100 100644 --- a/app/views/ladder/SimulateTabView.coffee +++ b/app/views/ladder/SimulateTabView.coffee @@ -98,19 +98,6 @@ module.exports = class SimulateTabView extends CocoView link = if @simulationSpectateLink then "#{_.string.escapeHTML(@simulationMatchDescription)}" else '' $('#simulation-status-text').html "

#{@simulationStatus}

#{link}" - resimulateAllSessions: -> - postData = - originalLevelID: @level.get('original') - levelMajorVersion: @level.get('version').major - console.log postData - - $.ajax - url: '/queue/scoring/resimulateAllSessions' - method: 'POST' - data: postData - complete: (jqxhr) -> - console.log jqxhr.responseText - destroy: -> clearTimeout @simulationPageRefreshTimeout @simulator?.destroy() diff --git a/server/queues/scoring.coffee b/server/queues/scoring.coffee index 737556107..842dcaf16 100644 --- a/server/queues/scoring.coffee +++ b/server/queues/scoring.coffee @@ -9,25 +9,24 @@ queues = require '../commons/queue' LevelSession = require '../levels/sessions/LevelSession' Level = require '../levels/Level' User = require '../users/User' -TaskLog = require './task/ScoringTask' -bayes = new (require 'bayesian-battle')() +TaskLog = require './scoring/ScoringTask' +scoringUtils = require './scoring/scoringUtils' +getTwoGames = require './scoring/getTwoGames' +recordTwoGames = require './scoring/recordTwoGames' +createNewTask = require './scoring/createNewTask' +dispatchTaskToConsumer = require './scoring/dispatchTaskToConsumer' +processTaskResult = require './scoring/processTaskResult' -scoringTaskQueue = undefined -scoringTaskTimeoutInSeconds = 600 - -SIMULATOR_VERSION = 3 - -module.exports.setup = (app) -> connectToScoringQueue() - -connectToScoringQueue = -> +module.exports.setup = (app) -> + # Connect to scoring queue queues.initializeQueueClient -> queues.queueClient.registerQueue 'scoring', {}, (error, data) -> if error? then throw new Error "There was an error registering the scoring queue: #{error}" - scoringTaskQueue = data + scoringUtils.scoringTaskQueue = data #log.info 'Connected to scoring task queue!' module.exports.messagesInQueueCount = (req, res) -> - scoringTaskQueue.totalMessagesInQueue (err, count) -> + scoringUtils.scoringTaskQueue.totalMessagesInQueue (err, count) -> if err? then return errors.serverError res, "There was an issue finding the Mongoose count:#{err}" response = String(count) res.send(response) @@ -35,673 +34,13 @@ module.exports.messagesInQueueCount = (req, res) -> module.exports.addPairwiseTaskToQueueFromRequest = (req, res) -> taskPair = req.body.sessions - addPairwiseTaskToQueue req.body.sessions, (err, success) -> + scoringUtils.addPairwiseTaskToQueue req.body.sessions, (err, success) -> if err? then return errors.serverError res, "There was an error adding pairwise tasks: #{err}" - sendResponseObject req, res, {message: 'All task pairs were succesfully sent to the queue'} + scoringUtils.sendResponseObject req, res, {message: 'All task pairs were succesfully sent to the queue'} -addPairwiseTaskToQueue = (taskPair, cb) -> - LevelSession.findOne(_id: taskPair[0]).lean().exec (err, firstSession) => - if err? then return cb err - LevelSession.find(_id: taskPair[1]).exec (err, secondSession) => - if err? then return cb err - try - taskPairs = generateTaskPairs(secondSession, firstSession) - catch e - if e then return cb e - sendEachTaskPairToTheQueue taskPairs, (taskPairError) -> - if taskPairError? then return cb taskPairError - cb null - -# We should rip these out, probably -module.exports.resimulateAllSessions = (req, res) -> - unless isUserAdmin req then return errors.unauthorized res, 'Unauthorized. Even if you are authorized, you shouldn\'t do this' - - originalLevelID = req.body.originalLevelID - levelMajorVersion = parseInt(req.body.levelMajorVersion) - - findParameters = - submitted: true - level: - original: originalLevelID - majorVersion: levelMajorVersion - - query = LevelSession - .find(findParameters) - .lean() - - query.exec (err, result) -> - if err? then return errors.serverError res, err - result = _.sample result, 10 - async.each result, resimulateSession.bind(@, originalLevelID, levelMajorVersion), (err) -> - if err? then return errors.serverError res, err - sendResponseObject req, res, {message: 'All task pairs were succesfully sent to the queue'} - -resimulateSession = (originalLevelID, levelMajorVersion, session, cb) => - sessionUpdateObject = - submitted: true - submitDate: new Date() - meanStrength: 25 - standardDeviation: 25/3 - totalScore: 10 - numberOfWinsAndTies: 0 - numberOfLosses: 0 - isRanking: true - LevelSession.update {_id: session._id}, sessionUpdateObject, (err, updatedSession) -> - if err? then return cb err, null - opposingTeam = calculateOpposingTeam(session.team) - fetchInitialSessionsToRankAgainst levelMajorVersion, originalLevelID, opposingTeam, (err, sessionsToRankAgainst) -> - if err? then return cb err, null - - taskPairs = generateTaskPairs(sessionsToRankAgainst, session) - sendEachTaskPairToTheQueue taskPairs, (taskPairError) -> - if taskPairError? then return cb taskPairError, null - cb null - -earliestSubmissionCache = {} -findEarliestSubmission = (queryParams, callback) -> - cacheKey = JSON.stringify queryParams - return callback null, cached if cached = earliestSubmissionCache[cacheKey] - LevelSession.findOne(queryParams).sort(submitDate: 1).lean().exec (err, earliest) -> - return callback err if err - result = earliestSubmissionCache[cacheKey] = earliest?.submitDate - callback null, result - -findRecentRandomSession = (queryParams, callback) -> - # We pick a random submitDate between the first submit date for the level and now, then do a $lt fetch to find a session to simulate. - # We bias it towards recently submitted sessions. - findEarliestSubmission queryParams, (err, startDate) -> - return callback err, null unless startDate - now = new Date() - interval = now - startDate - cutoff = new Date now - Math.pow(Math.random(), 4) * interval - queryParams.submitDate = $gte: startDate, $lt: cutoff - selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' - LevelSession.findOne(queryParams).sort(submitDate: -1).select(selection).lean().exec (err, session) -> - return callback err if err - callback null, session - -findRandomSession = (queryParams, callback) -> - queryParams.submitted = true - favorRecent = queryParams.favorRecent - delete queryParams.favorRecent - if favorRecent - return findRecentRandomSession queryParams, callback - queryParams.randomSimulationIndex = $lte: Math.random() - selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' - sort = randomSimulationIndex: -1 - LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) -> - return callback err if err - return callback null, session if session - delete queryParams.randomSimulationIndex # Effectively switch to $gt, if our randomSimulationIndex was lower than the lowest one. - LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) -> - return callback err if err - callback null, session - -formatSessionInformation = (session) -> - sessionID: session._id - team: session.team ? 'No team' - transpiledCode: session.transpiledCode - submittedCodeLanguage: session.submittedCodeLanguage - teamSpells: session.teamSpells ? {} - levelID: session.levelID - creatorName: session.creatorName - creator: session.creator - totalScore: session.totalScore - -module.exports.getTwoGames = (req, res) -> - #if isUserAnonymous req then return errors.unauthorized(res, 'You need to be logged in to get games.') - humansGameID = req.body.humansGameID - ogresGameID = req.body.ogresGameID - return if simulatorIsTooOld req, res - #ladderGameIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span'] # Let's not give any extra simulations to old ladders. - ladderGameIDs = ['dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove', 'harrowland', 'zero-sum'] - levelID = _.sample ladderGameIDs - unless ogresGameID and humansGameID - recentHumans = Math.random() < 0.5 # We pick one session favoring recent submissions, then find another one uniformly to play against - async.map [{levelID: levelID, team: 'humans', favorRecent: recentHumans}, {levelID: levelID, team: 'ogres', favorRecent: not recentHumans}], findRandomSession, (err, sessions) -> - if err then return errors.serverError(res, "Couldn't get two games to simulate for #{levelID}.") - unless sessions.length is 2 - res.send(204, 'No games to score.') - return res.end() - taskObject = messageGenerated: Date.now(), sessions: (formatSessionInformation session for session in sessions) - #console.log 'Dispatching random game between', taskObject.sessions[0].creatorName, 'and', taskObject.sessions[1].creatorName - sendResponseObject req, res, taskObject - else - #console.log "Directly simulating #{humansGameID} vs. #{ogresGameID}." - selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' - LevelSession.findOne(_id: humansGameID).select(selection).lean().exec (err, humanSession) => - if err? then return errors.serverError(res, 'Couldn\'t find the human game') - LevelSession.findOne(_id: ogresGameID).select(selection).lean().exec (err, ogreSession) => - if err? then return errors.serverError(res, 'Couldn\'t find the ogre game') - taskObject = messageGenerated: Date.now(), sessions: (formatSessionInformation session for session in [humanSession, ogreSession]) - sendResponseObject req, res, taskObject - -module.exports.recordTwoGames = (req, res) -> - sessions = req.body.sessions - #console.log 'Recording non-chained result of', sessions?[0]?.name, sessions[0]?.metrics?.rank, 'and', sessions?[1]?.name, sessions?[1]?.metrics?.rank - return if simulatorIsTooOld req, res - req.body?.simulator?.user = '' + req.user?._id - - yetiGuru = clientResponseObject: req.body, isRandomMatch: true - async.waterfall [ - calculateSessionScores.bind(yetiGuru) # Fetches a few small properties from both sessions, prepares @levelSessionUpdates with the score part - indexNewScoreArray.bind(yetiGuru) # Creates and returns @newScoresObject, no query - addMatchToSessionsAndUpdate.bind(yetiGuru) # Adds matches to the session updates and does the writes - updateUserSimulationCounts.bind(yetiGuru, req.user?._id) - ], (err, successMessageObject) -> - if err? then return errors.serverError res, "There was an error recording the single game: #{err}" - sendResponseObject req, res, {message: 'The single game was submitted successfully!'} - -module.exports.createNewTask = (req, res) -> - requestSessionID = req.body.session - originalLevelID = req.body.originalLevelID - currentLevelID = req.body.levelID - transpiledCode = req.body.transpiledCode - requestLevelMajorVersion = parseInt(req.body.levelMajorVersion) - - yetiGuru = {} - async.waterfall [ - validatePermissions.bind(yetiGuru, req, requestSessionID) - fetchAndVerifyLevelType.bind(yetiGuru, currentLevelID) - fetchSessionObjectToSubmit.bind(yetiGuru, requestSessionID) - updateSessionToSubmit.bind(yetiGuru, transpiledCode) - fetchInitialSessionsToRankAgainst.bind(yetiGuru, requestLevelMajorVersion, originalLevelID) - generateAndSendTaskPairsToTheQueue - ], (err, successMessageObject) -> - if err? then return errors.serverError res, "There was an error submitting the game to the queue:#{err}" - sendResponseObject req, res, successMessageObject - -validatePermissions = (req, sessionID, callback) -> - if isUserAnonymous req then return callback 'You are unauthorized to submit that game to the simulator' - if isUserAdmin req then return callback null - - findParameters = - _id: sessionID - selectString = 'creator submittedCode code' - query = LevelSession - .findOne(findParameters) - .select(selectString) - .lean() - - query.exec (err, retrievedSession) -> - if err? then return callback err - userHasPermissionToSubmitCode = retrievedSession.creator is req.user?.id and - not _.isEqual(retrievedSession.code, retrievedSession.submittedCode) - unless userHasPermissionToSubmitCode then return callback 'You are unauthorized to submit that game to the simulator' - callback null - -fetchAndVerifyLevelType = (levelID, cb) -> - findParameters = - _id: levelID - selectString = 'type' - - query = Level - .findOne(findParameters) - .select(selectString) - .lean() - query.exec (err, levelWithType) -> - if err? then return cb err - if not levelWithType.type or not (levelWithType.type in ['ladder', 'hero-ladder']) then return cb 'Level isn\'t of type "ladder"' - cb null - -fetchSessionObjectToSubmit = (sessionID, callback) -> - findParameters = - _id: sessionID - selectString = 'team code' - - query = LevelSession - .findOne(findParameters) - .select(selectString) - - query.exec (err, session) -> - callback err, session?.toObject() - -updateSessionToSubmit = (transpiledCode, sessionToUpdate, callback) -> - sessionUpdateObject = - submitted: true - submittedCode: sessionToUpdate.code - transpiledCode: transpiledCode - submitDate: new Date() - #meanStrength: 25 # Let's try not resetting the score on resubmission - standardDeviation: 25/3 - #totalScore: 10 # Let's try not resetting the score on resubmission - numberOfWinsAndTies: 0 - numberOfLosses: 0 - isRanking: true - randomSimulationIndex: Math.random() - LevelSession.update {_id: sessionToUpdate._id}, sessionUpdateObject, (err, result) -> - callback err, sessionToUpdate - -fetchInitialSessionsToRankAgainst = (levelMajorVersion, levelID, submittedSession, callback) -> - opposingTeam = calculateOpposingTeam(submittedSession.team) - - findParameters = - 'level.original': levelID - 'level.majorVersion': levelMajorVersion - submitted: true - team: opposingTeam - - sortParameters = - totalScore: 1 - - limitNumber = 1 - query = LevelSession.aggregate [ - {$match: findParameters} - {$sort: sortParameters} - {$limit: limitNumber} - ] - - query.exec (err, sessionToRankAgainst) -> - callback err, sessionToRankAgainst, submittedSession - -generateAndSendTaskPairsToTheQueue = (sessionToRankAgainst, submittedSession, callback) -> - taskPairs = generateTaskPairs(sessionToRankAgainst, submittedSession) - sendEachTaskPairToTheQueue taskPairs, (taskPairError) -> - if taskPairError? then return callback taskPairError - #console.log 'Sent task pairs to the queue!' - #console.log taskPairs - callback null, {message: 'All task pairs were succesfully sent to the queue'} - -module.exports.dispatchTaskToConsumer = (req, res) -> - yetiGuru = {} - async.waterfall [ - checkSimulationPermissions.bind(yetiGuru, req) - receiveMessageFromSimulationQueue - changeMessageVisibilityTimeout - parseTaskQueueMessage - constructTaskObject - constructTaskLogObject.bind(yetiGuru, getUserIDFromRequest(req)) - processTaskObject - ], (err, taskObjectToSend) -> - if err? - if typeof err is 'string' and err.indexOf 'No more games in the queue' isnt -1 - res.send(204, 'No games to score.') - return res.end() - else - return errors.serverError res, "There was an error dispatching the task: #{err}" - sendResponseObject req, res, taskObjectToSend - -checkSimulationPermissions = (req, cb) -> - if isUserAnonymous req - cb 'You need to be logged in to simulate games' - else - cb null - -receiveMessageFromSimulationQueue = (cb) -> - scoringTaskQueue.receiveMessage (err, message) -> - if err? then return cb "No more games in the queue, error:#{err}" - if messageIsInvalid(message) then return cb 'Message received from queue is invalid' - cb null, message - -changeMessageVisibilityTimeout = (message, cb) -> - message.changeMessageVisibilityTimeout scoringTaskTimeoutInSeconds, (err) -> cb err, message - -parseTaskQueueMessage = (message, cb) -> - try - if typeof message.getBody() is 'object' - messageBody = message.getBody() - else - messageBody = JSON.parse message.getBody() - cb null, messageBody, message - catch e - cb "There was an error parsing the task.Error: #{e}" - -constructTaskObject = (taskMessageBody, message, callback) -> - async.map taskMessageBody.sessions, getSessionInformation, (err, sessions) -> - if err? then return callback err - taskObject = messageGenerated: Date.now(), sessions: (formatSessionInformation session for session in sessions) - callback null, taskObject, message - -constructTaskLogObject = (calculatorUserID, taskObject, message, callback) -> - taskLogObject = new TaskLog - 'createdAt': new Date() - 'calculator': calculatorUserID - 'sentDate': Date.now() - 'messageIdentifierString': message.getReceiptHandle() - taskLogObject.save (err) -> callback err, taskObject, taskLogObject, message - -processTaskObject = (taskObject, taskLogObject, message, cb) -> - taskObject.taskID = taskLogObject._id - taskObject.receiptHandle = message.getReceiptHandle() - cb null, taskObject - -getSessionInformation = (sessionIDString, callback) -> - findParameters = - _id: sessionIDString - selectString = 'submitDate team submittedCode teamSpells levelID creator creatorName transpiledCode submittedCodeLanguage totalScore' - query = LevelSession - .findOne(findParameters) - .select(selectString) - .lean() - - query.exec (err, session) -> - if err? then return callback err, {'error': 'There was an error retrieving the session.'} - callback null, session - -module.exports.processTaskResult = (req, res) -> - return if simulatorIsTooOld req, res - originalSessionID = req.body?.originalSessionID - req.body?.simulator?.user = '' + req.user?._id - yetiGuru = {} - try - async.waterfall [ - verifyClientResponse.bind(yetiGuru, req.body) - fetchTaskLog.bind(yetiGuru) - checkTaskLog.bind(yetiGuru) - deleteQueueMessage.bind(yetiGuru) - fetchLevelSession.bind(yetiGuru) - checkSubmissionDate.bind(yetiGuru) - logTaskComputation.bind(yetiGuru) - calculateSessionScores.bind(yetiGuru) - indexNewScoreArray.bind(yetiGuru) - addMatchToSessionsAndUpdate.bind(yetiGuru) - updateUserSimulationCounts.bind(yetiGuru, req.user?._id) - determineIfSessionShouldContinueAndUpdateLog.bind(yetiGuru) - findNearestBetterSessionID.bind(yetiGuru) - addNewSessionsToQueue.bind(yetiGuru) - ], (err, results) -> - if err is 'shouldn\'t continue' - markSessionAsDoneRanking originalSessionID, (err) -> - if err? then return sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'} - sendResponseObject req, res, {message: 'The scores were updated successfully, person lost so no more games are being inserted!'} - else if err is 'no session was found' - markSessionAsDoneRanking originalSessionID, (err) -> - if err? then return sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'} - sendResponseObject req, res, {message: 'There were no more games to rank (game is at top)!'} - else if err? - errors.serverError res, "There was an error:#{err}" - else - sendResponseObject req, res, {message: 'The scores were updated successfully and more games were sent to the queue!'} - catch e - errors.serverError res, 'There was an error processing the task result!' - -verifyClientResponse = (responseObject, callback) -> - #TODO: better verification - if typeof responseObject isnt 'object' or responseObject?.originalSessionID?.length isnt 24 - callback 'The response to that query is required to be a JSON object.' - else - @clientResponseObject = responseObject - callback null, responseObject - -fetchTaskLog = (responseObject, callback) -> - TaskLog.findOne(_id: responseObject.taskID).lean().exec (err, taskLog) => - return callback new Error("Couldn't find TaskLog for _id #{responseObject.taskID}!") unless taskLog - @taskLog = taskLog - callback err, taskLog - -checkTaskLog = (taskLog, callback) -> - if taskLog.calculationTimeMS then return callback 'That computational task has already been performed' - if hasTaskTimedOut taskLog.sentDate then return callback 'The task has timed out' - callback null - -deleteQueueMessage = (callback) -> - scoringTaskQueue.deleteMessage @clientResponseObject.receiptHandle, (err) -> - callback err - -fetchLevelSession = (callback) -> - LevelSession.findOne(_id: @clientResponseObject.originalSessionID).select('submitDate creator level standardDeviation meanStrength totalScore submittedCodeLanguage').lean().exec (err, session) => - @levelSession = session - callback err - -checkSubmissionDate = (callback) -> - supposedSubmissionDate = new Date(@clientResponseObject.sessions[0].submitDate) - if Number(supposedSubmissionDate) isnt Number(@levelSession.submitDate) - callback 'The game has been resubmitted. Removing from queue...' - else - callback null - -logTaskComputation = (callback) -> - @taskLog.set('calculationTimeMS', @clientResponseObject.calculationTimeMS) - @taskLog.set('sessions') # Huh? - @taskLog.calculationTimeMS = @clientResponseObject.calculationTimeMS - @taskLog.sessions = @clientResponseObject.sessions - @taskLog.save (err, saved) -> - callback err - -calculateSessionScores = (callback) -> - sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID' - async.map sessionIDs, retrieveOldSessionData, (err, oldScores) => - if err? then callback err, {error: 'There was an error retrieving the old scores'} - try - oldScoreArray = _.toArray putRankingFromMetricsIntoScoreObject @clientResponseObject, oldScores - newScoreArray = bayes.updatePlayerSkills oldScoreArray - createSessionScoreUpdate.call @, scoreObject for scoreObject in newScoreArray - callback err, newScoreArray - catch e - callback e - -createSessionScoreUpdate = (scoreObject) -> - newTotalScore = scoreObject.meanStrength - 1.8 * scoreObject.standardDeviation - scoreHistoryAddition = [Date.now(), newTotalScore] - @levelSessionUpdates ?= {} - @levelSessionUpdates[scoreObject.id] = - meanStrength: scoreObject.meanStrength - standardDeviation: scoreObject.standardDeviation - totalScore: newTotalScore - $push: {scoreHistory: {$each: [scoreHistoryAddition], $slice: -1000}} - randomSimulationIndex: Math.random() - -indexNewScoreArray = (newScoreArray, callback) -> - newScoresObject = _.indexBy newScoreArray, 'id' - @newScoresObject = newScoresObject - callback null, newScoresObject - -addMatchToSessionsAndUpdate = (newScoreObject, callback) -> - matchObject = {} - matchObject.date = new Date() - matchObject.opponents = {} - for session in @clientResponseObject.sessions - sessionID = session.sessionID - matchObject.opponents[sessionID] = match = {} - match.sessionID = sessionID - match.userID = session.creator - match.name = session.name - match.totalScore = session.totalScore - match.metrics = {} - match.metrics.rank = Number(newScoreObject[sessionID]?.gameRanking ? 0) - match.codeLanguage = newScoreObject[sessionID].submittedCodeLanguage - - #log.info "Match object computed, result: #{JSON.stringify(matchObject, null, 2)}" - #log.info 'Writing match object to database...' - #use bind with async to do the writes - sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID' - async.each sessionIDs, updateMatchesInSession.bind(@, matchObject), (err) -> - callback err - -updateMatchesInSession = (matchObject, sessionID, callback) -> - currentMatchObject = {} - currentMatchObject.date = matchObject.date - currentMatchObject.metrics = matchObject.opponents[sessionID].metrics - opponentsClone = _.cloneDeep matchObject.opponents - opponentsClone = _.omit opponentsClone, sessionID - opponentsArray = _.toArray opponentsClone - currentMatchObject.opponents = opponentsArray - currentMatchObject.codeLanguage = matchObject.opponents[opponentsArray[0].sessionID].codeLanguage - #currentMatchObject.simulator = @clientResponseObject.simulator # Uncomment when actively debugging simulation mismatches - #currentMatchObject.randomSeed = parseInt(@clientResponseObject.randomSeed or 0, 10) # Uncomment when actively debugging simulation mismatches - sessionUpdateObject = @levelSessionUpdates[sessionID] - sessionUpdateObject.$push.matches = {$each: [currentMatchObject], $slice: -200} - #log.info "Update is #{JSON.stringify(sessionUpdateObject, null, 2)}" - LevelSession.update {_id: sessionID}, sessionUpdateObject, callback - -updateUserSimulationCounts = (reqUserID, callback) -> - incrementUserSimulationCount reqUserID, 'simulatedBy', (err) => - if err? then return callback err - #console.log 'Incremented user simulation count!' - unless @isRandomMatch - incrementUserSimulationCount @levelSession.creator, 'simulatedFor', callback - else - callback null - -incrementUserSimulationCount = (userID, type, callback) => - return callback null unless userID - inc = {} - inc[type] = 1 - User.update {_id: userID}, {$inc: inc}, (err, affected) -> - log.error "Error incrementing #{type} for #{userID}: #{err}" if err - callback err - -determineIfSessionShouldContinueAndUpdateLog = (cb) -> - sessionID = @clientResponseObject.originalSessionID - sessionRank = parseInt @clientResponseObject.originalSessionRank - - queryParameters = _id: sessionID - updateParameters = '$inc': {} - - if sessionRank is 0 - updateParameters['$inc'] = {numberOfWinsAndTies: 1} - else - updateParameters['$inc'] = {numberOfLosses: 1} - - LevelSession.findOneAndUpdate queryParameters, updateParameters, {select: 'numberOfWinsAndTies numberOfLosses', lean: true}, (err, updatedSession) -> - if err? then return cb err, updatedSession - - totalNumberOfGamesPlayed = updatedSession.numberOfWinsAndTies + updatedSession.numberOfLosses - if totalNumberOfGamesPlayed < 10 - #console.log 'Number of games played is less than 10, continuing...' - cb null - else - ratio = (updatedSession.numberOfLosses) / (totalNumberOfGamesPlayed) - if ratio > 0.33 - cb 'shouldn\'t continue' - #console.log "Ratio(#{ratio}) is bad, ending simulation" - else - #console.log "Ratio(#{ratio}) is good, so continuing simulations" - cb null - -findNearestBetterSessionID = (cb) -> - try - levelOriginalID = @levelSession.level.original - levelMajorVersion = @levelSession.level.majorVersion - sessionID = @clientResponseObject.originalSessionID - sessionTotalScore = @newScoresObject[sessionID].totalScore - opponentSessionID = _.pull(_.keys(@newScoresObject), sessionID) - opponentSessionTotalScore = @newScoresObject[opponentSessionID].totalScore - opposingTeam = calculateOpposingTeam(@clientResponseObject.originalSessionTeam) - catch e - cb e - - retrieveAllOpponentSessionIDs sessionID, (err, opponentSessionIDs) -> - if err? then return cb err, null - - queryParameters = - totalScore: - $gt: opponentSessionTotalScore - _id: - $nin: opponentSessionIDs - 'level.original': levelOriginalID - 'level.majorVersion': levelMajorVersion - submitted: true - team: opposingTeam - - if opponentSessionTotalScore < 30 - # Don't play a ton of matches at low scores--skip some in proportion to how close to 30 we are. - # TODO: this could be made a lot more flexible. - queryParameters['totalScore']['$gt'] = opponentSessionTotalScore + 2 * (30 - opponentSessionTotalScore) / 20 - - limitNumber = 1 - - sortParameters = - totalScore: 1 - - selectString = '_id totalScore' - - query = LevelSession.findOne(queryParameters) - .sort(sortParameters) - .limit(limitNumber) - .select(selectString) - .lean() - - #console.log "Finding session with score near #{opponentSessionTotalScore}" - query.exec (err, session) -> - if err? then return cb err, session - unless session then return cb 'no session was found' - #console.log "Found session with score #{session.totalScore}" - cb err, session._id - -retrieveAllOpponentSessionIDs = (sessionID, cb) -> - query = LevelSession.findOne({_id: sessionID}) - .select('matches.opponents.sessionID matches.date submitDate') - .lean() - query.exec (err, session) -> - if err? then return cb err, null - opponentSessionIDs = (match.opponents[0].sessionID for match in session.matches when match.date > session.submitDate) - cb err, opponentSessionIDs - -calculateOpposingTeam = (sessionTeam) -> - teams = ['ogres', 'humans'] - opposingTeams = _.pull teams, sessionTeam - return opposingTeams[0] - -addNewSessionsToQueue = (sessionID, callback) -> - sessions = [@clientResponseObject.originalSessionID, sessionID] - addPairwiseTaskToQueue sessions, callback - -messageIsInvalid = (message) -> (not message?) or message.isEmpty() - -sendEachTaskPairToTheQueue = (taskPairs, callback) -> async.each taskPairs, sendTaskPairToQueue, callback - -generateTaskPairs = (submittedSessions, sessionToScore) -> - taskPairs = [] - for session in submittedSessions - if session.toObject? - session = session.toObject() - teams = ['ogres', 'humans'] - opposingTeams = _.pull teams, sessionToScore.team - if String(session._id) isnt String(sessionToScore._id) and session.team in opposingTeams - #console.log 'Adding game to taskPairs!' - taskPairs.push [sessionToScore._id, String session._id] - return taskPairs - -sendTaskPairToQueue = (taskPair, callback) -> - scoringTaskQueue.sendMessage {sessions: taskPair}, 5, (err, data) -> callback? err, data - -getUserIDFromRequest = (req) -> if req.user? then return req.user._id else return null - -isUserAnonymous = (req) -> if req.user? then return req.user.get('anonymous') else return true - -isUserAdmin = (req) -> return Boolean(req.user?.isAdmin()) - -sendResponseObject = (req, res, object) -> - res.setHeader('Content-Type', 'application/json') - res.send(object) - res.end() - -hasTaskTimedOut = (taskSentTimestamp) -> taskSentTimestamp + scoringTaskTimeoutInSeconds * 1000 < Date.now() - -handleTimedOutTask = (req, res, taskBody) -> errors.clientTimeout res, 'The results weren\'t provided within the timeout' - -putRankingFromMetricsIntoScoreObject = (taskObject, scoreObject) -> - scoreObject = _.indexBy scoreObject, 'id' - scoreObject[session.sessionID].gameRanking = session.metrics.rank for session in taskObject.sessions - return scoreObject - -retrieveOldSessionData = (sessionID, callback) -> - formatOldScoreObject = (session) -> - standardDeviation: session.standardDeviation ? 25/3 - meanStrength: session.meanStrength ? 25 - totalScore: session.totalScore ? (25 - 1.8*(25/3)) - id: sessionID - submittedCodeLanguage: session.submittedCodeLanguage - - return formatOldScoreObject @levelSession if sessionID is @levelSession?._id # No need to fetch again - - query = _id: sessionID - selection = 'standardDeviation meanStrength totalScore submittedCodeLanguage' - LevelSession.findOne(query).select(selection).lean().exec (err, session) -> - return callback err, {'error': 'There was an error retrieving the session.'} if err? - callback err, formatOldScoreObject session - -markSessionAsDoneRanking = (sessionID, cb) -> - #console.log 'Marking session as done ranking...' - LevelSession.update {_id: sessionID}, {isRanking: false}, cb - -simulatorIsTooOld = (req, res) -> - clientSimulator = req.body.simulator - return false if clientSimulator?.version >= SIMULATOR_VERSION - message = "Old simulator version #{clientSimulator?.version}, need to clear cache and get version #{SIMULATOR_VERSION}." - log.debug "400: #{message}" - res.send 400, message - res.end() - true +module.exports.getTwoGames = getTwoGames +module.exports.recordTwoGames = recordTwoGames +module.exports.createNewTask = createNewTask +module.exports.dispatchTaskToConsumer = dispatchTaskToConsumer +module.exports.processTaskResult = processTaskResult diff --git a/server/queues/task/ScoringTask.coffee b/server/queues/scoring/ScoringTask.coffee similarity index 100% rename from server/queues/task/ScoringTask.coffee rename to server/queues/scoring/ScoringTask.coffee diff --git a/server/queues/scoring/createNewTask.coffee b/server/queues/scoring/createNewTask.coffee new file mode 100644 index 000000000..bbddd6948 --- /dev/null +++ b/server/queues/scoring/createNewTask.coffee @@ -0,0 +1,91 @@ +log = require 'winston' +async = require 'async' +errors = require '../../commons/errors' +scoringUtils = require './scoringUtils' +LevelSession = require '../../levels/sessions/LevelSession' + +module.exports = createNewTask = (req, res) -> + requestSessionID = req.body.session + originalLevelID = req.body.originalLevelID + currentLevelID = req.body.levelID + transpiledCode = req.body.transpiledCode + requestLevelMajorVersion = parseInt(req.body.levelMajorVersion) + + yetiGuru = {} + async.waterfall [ + validatePermissions.bind(yetiGuru, req, requestSessionID) + fetchAndVerifyLevelType.bind(yetiGuru, currentLevelID) + fetchSessionObjectToSubmit.bind(yetiGuru, requestSessionID) + updateSessionToSubmit.bind(yetiGuru, transpiledCode) + fetchInitialSessionsToRankAgainst.bind(yetiGuru, requestLevelMajorVersion, originalLevelID) + generateAndSendTaskPairsToTheQueue + ], (err, successMessageObject) -> + if err? then return errors.serverError res, "There was an error submitting the game to the queue:#{err}" + scoringUtils.sendResponseObject req, res, successMessageObject + + +validatePermissions = (req, sessionID, callback) -> + return callback 'You are unauthorized to submit that game to the simulator' unless req.user?.get('email') + return callback null if req.user?.isAdmin() + + findParameters = _id: sessionID + selectString = 'creator submittedCode code' + LevelSession.findOne(findParameters).select(selectString).lean().exec (err, retrievedSession) -> + if err? then return callback err + userHasPermissionToSubmitCode = retrievedSession.creator is req.user?.id and + not _.isEqual(retrievedSession.code, retrievedSession.submittedCode) + unless userHasPermissionToSubmitCode then return callback 'You are unauthorized to submit that game to the simulator' + callback null + + +fetchAndVerifyLevelType = (levelID, cb) -> + Level.findOne(_id: levelID).select('type').lean().exec (err, levelWithType) -> + if err? then return cb err + if not levelWithType.type or not (levelWithType.type in ['ladder', 'hero-ladder', 'course-ladder']) then return cb 'Level isn\'t of type "ladder"' + cb null + +fetchSessionObjectToSubmit = (sessionID, callback) -> + LevelSession.findOne({_id: sessionID}).select('team code').exec (err, session) -> + callback err, session?.toObject() + +updateSessionToSubmit = (transpiledCode, sessionToUpdate, callback) -> + sessionUpdateObject = + submitted: true + submittedCode: sessionToUpdate.code + transpiledCode: transpiledCode + submitDate: new Date() + #meanStrength: 25 # Let's try not resetting the score on resubmission + standardDeviation: 25/3 + #totalScore: 10 # Let's try not resetting the score on resubmission + numberOfWinsAndTies: 0 + numberOfLosses: 0 + isRanking: true + randomSimulationIndex: Math.random() + LevelSession.update {_id: sessionToUpdate._id}, sessionUpdateObject, (err, result) -> + callback err, sessionToUpdate + +fetchInitialSessionsToRankAgainst = (levelMajorVersion, levelID, submittedSession, callback) -> + opposingTeam = scoringUtils.calculateOpposingTeam(submittedSession.team) + findParameters = + 'level.original': levelID + 'level.majorVersion': levelMajorVersion + submitted: true + team: opposingTeam + sortParameters = totalScore: 1 + limitNumber = 1 + query = LevelSession.aggregate [ + {$match: findParameters} + {$sort: sortParameters} + {$limit: limitNumber} + ] + + query.exec (err, sessionToRankAgainst) -> + callback err, sessionToRankAgainst, submittedSession + +generateAndSendTaskPairsToTheQueue = (sessionToRankAgainst, submittedSession, callback) -> + taskPairs = scoringUtils.generateTaskPairs(sessionToRankAgainst, submittedSession) + scoringUtils.sendEachTaskPairToTheQueue taskPairs, (taskPairError) -> + if taskPairError? then return callback taskPairError + #console.log 'Sent task pairs to the queue!' + #console.log taskPairs + callback null, {message: 'All task pairs were succesfully sent to the queue'} diff --git a/server/queues/scoring/dispatchTaskToConsumer.coffee b/server/queues/scoring/dispatchTaskToConsumer.coffee new file mode 100644 index 000000000..02f6b1370 --- /dev/null +++ b/server/queues/scoring/dispatchTaskToConsumer.coffee @@ -0,0 +1,80 @@ +log = require 'winston' +async = require 'async' +errors = require '../../commons/errors' +scoringUtils = require './scoringUtils' +LevelSession = require '../../levels/sessions/LevelSession' +TaskLog = require './ScoringTask' + +module.exports = dispatchTaskToConsumer = (req, res) -> + yetiGuru = {} + async.waterfall [ + checkSimulationPermissions.bind(yetiGuru, req) + receiveMessageFromSimulationQueue + changeMessageVisibilityTimeout + parseTaskQueueMessage + constructTaskObject + constructTaskLogObject.bind(yetiGuru, getUserIDFromRequest(req)) + processTaskObject + ], (err, taskObjectToSend) -> + if err? + if typeof err is 'string' and err.indexOf 'No more games in the queue' isnt -1 + res.send(204, 'No games to score.') + return res.end() + else + return errors.serverError res, "There was an error dispatching the task: #{err}" + scoringUtils.sendResponseObject req, res, taskObjectToSend + + +checkSimulationPermissions = (req, cb) -> + if req.user?.get('email') + cb null + else + cb 'You need to be logged in to simulate games' + +receiveMessageFromSimulationQueue = (cb) -> + scoringUtils.scoringTaskQueue.receiveMessage (err, message) -> + if err? then return cb "No more games in the queue, error: #{err}" + if not message? or message.isEmpty() then return cb 'Message received from queue is invalid' + cb null, message + +changeMessageVisibilityTimeout = (message, cb) -> + message.changeMessageVisibilityTimeout scoringUtils.scoringTaskTimeoutInSeconds, (err) -> + cb err, message + +parseTaskQueueMessage = (message, cb) -> + try + messageBody = message.getBody() + unless typeof messageBody is 'object' + messageBody = JSON.parse messageBody + cb null, messageBody, message + catch e + cb "There was an error parsing the task. Error: #{e}" + +constructTaskObject = (taskMessageBody, message, callback) -> + async.map taskMessageBody.sessions, getSessionInformation, (err, sessions) -> + if err? then return callback err + taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in sessions) + callback null, taskObject, message + +getSessionInformation = (sessionIDString, callback) -> + selectString = 'submitDate team submittedCode teamSpells levelID creator creatorName transpiledCode submittedCodeLanguage totalScore' + LevelSession.findOne(_id: sessionIDString).select(selectString).lean().exec (err, session) -> + if err? then return callback err, {'error': 'There was an error retrieving the session.'} + callback null, session + +constructTaskLogObject = (calculatorUserID, taskObject, message, callback) -> + taskLogObject = new TaskLog + createdAt: new Date() + calculator: calculatorUserID + sentDate: Date.now() + messageIdentifierString: message.getReceiptHandle() + taskLogObject.save (err) -> + callback err, taskObject, taskLogObject, message + +getUserIDFromRequest = (req) -> + if req.user? then return req.user._id else return null + +processTaskObject = (taskObject, taskLogObject, message, cb) -> + taskObject.taskID = taskLogObject._id + taskObject.receiptHandle = message.getReceiptHandle() + cb null, taskObject diff --git a/server/queues/scoring/getTwoGames.coffee b/server/queues/scoring/getTwoGames.coffee new file mode 100644 index 000000000..00b0a3fb6 --- /dev/null +++ b/server/queues/scoring/getTwoGames.coffee @@ -0,0 +1,76 @@ +log = require 'winston' +async = require 'async' +errors = require '../../commons/errors' +scoringUtils = require './scoringUtils' +LevelSession = require '../../levels/sessions/LevelSession' + +module.exports = getTwoGames = (req, res) -> + #if isUserAnonymous req then return errors.unauthorized(res, 'You need to be logged in to get games.') + humansGameID = req.body.humansGameID + ogresGameID = req.body.ogresGameID + return if scoringUtils.simulatorIsTooOld req, res + #ladderGameIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span'] # Let's not give any extra simulations to old ladders. + ladderGameIDs = ['dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove', 'harrowland', 'zero-sum'] + levelID = _.sample ladderGameIDs + unless ogresGameID and humansGameID + recentHumans = Math.random() < 0.5 # We pick one session favoring recent submissions, then find another one uniformly to play against + async.map [{levelID: levelID, team: 'humans', favorRecent: recentHumans}, {levelID: levelID, team: 'ogres', favorRecent: not recentHumans}], findRandomSession, (err, sessions) -> + if err then return errors.serverError(res, "Couldn't get two games to simulate for #{levelID}.") + unless sessions.length is 2 + res.send(204, 'No games to score.') + return res.end() + taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in sessions) + #console.log 'Dispatching random game between', taskObject.sessions[0].creatorName, 'and', taskObject.sessions[1].creatorName + scoringUtils.sendResponseObject req, res, taskObject + else + #console.log "Directly simulating #{humansGameID} vs. #{ogresGameID}." + selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' + LevelSession.findOne(_id: humansGameID).select(selection).lean().exec (err, humanSession) => + if err? then return errors.serverError(res, 'Couldn\'t find the human game') + LevelSession.findOne(_id: ogresGameID).select(selection).lean().exec (err, ogreSession) => + if err? then return errors.serverError(res, 'Couldn\'t find the ogre game') + taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in [humanSession, ogreSession]) + scoringUtils.sendResponseObject req, res, taskObject + + +earliestSubmissionCache = {} +findEarliestSubmission = (queryParams, callback) -> + cacheKey = JSON.stringify queryParams + return callback null, cached if cached = earliestSubmissionCache[cacheKey] + LevelSession.findOne(queryParams).sort(submitDate: 1).lean().exec (err, earliest) -> + return callback err if err + result = earliestSubmissionCache[cacheKey] = earliest?.submitDate + callback null, result + +findRecentRandomSession = (queryParams, callback) -> + # We pick a random submitDate between the first submit date for the level and now, then do a $lt fetch to find a session to simulate. + # We bias it towards recently submitted sessions. + findEarliestSubmission queryParams, (err, startDate) -> + return callback err, null unless startDate + now = new Date() + interval = now - startDate + cutoff = new Date now - Math.pow(Math.random(), 4) * interval + queryParams.submitDate = $gte: startDate, $lt: cutoff + selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' + LevelSession.findOne(queryParams).sort(submitDate: -1).select(selection).lean().exec (err, session) -> + return callback err if err + callback null, session + +findRandomSession = (queryParams, callback) -> + # In MongoDB 3.2, we will be able to easily get a random document with aggregate $sample: https://jira.mongodb.org/browse/SERVER-533 + queryParams.submitted = true + favorRecent = queryParams.favorRecent + delete queryParams.favorRecent + if favorRecent + return findRecentRandomSession queryParams, callback + queryParams.randomSimulationIndex = $lte: Math.random() + selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' + sort = randomSimulationIndex: -1 + LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) -> + return callback err if err + return callback null, session if session + delete queryParams.randomSimulationIndex # Just find the highest-indexed session, if our randomSimulationIndex was lower than the lowest one. + LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) -> + return callback err if err + callback null, session + diff --git a/server/queues/scoring/processTaskResult.coffee b/server/queues/scoring/processTaskResult.coffee new file mode 100644 index 000000000..3dc4f99d9 --- /dev/null +++ b/server/queues/scoring/processTaskResult.coffee @@ -0,0 +1,172 @@ +log = require 'winston' +async = require 'async' +errors = require '../../commons/errors' +scoringUtils = require './scoringUtils' +LevelSession = require '../../levels/sessions/LevelSession' +TaskLog = require './ScoringTask' + +module.exports = processTaskResult = (req, res) -> + return if scoringUtils.simulatorIsTooOld req, res + originalSessionID = req.body?.originalSessionID + req.body?.simulator?.user = '' + req.user?._id + yetiGuru = {} + try + async.waterfall [ + verifyClientResponse.bind(yetiGuru, req.body) + fetchTaskLog.bind(yetiGuru) + checkTaskLog.bind(yetiGuru) + deleteQueueMessage.bind(yetiGuru) + fetchLevelSession.bind(yetiGuru) + checkSubmissionDate.bind(yetiGuru) + logTaskComputation.bind(yetiGuru) + scoringUtils.calculateSessionScores.bind(yetiGuru) + scoringUtils.indexNewScoreArray.bind(yetiGuru) + scoringUtils.addMatchToSessionsAndUpdate.bind(yetiGuru) + scoringUtils.updateUserSimulationCounts.bind(yetiGuru, req.user?._id) + determineIfSessionShouldContinueAndUpdateLog.bind(yetiGuru) + findNearestBetterSessionID.bind(yetiGuru) + addNewSessionsToQueue.bind(yetiGuru) + ], (err, results) -> + if err is 'shouldn\'t continue' + markSessionAsDoneRanking originalSessionID, (err) -> + if err? then return scoringUtils.sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'} + scoringUtils.sendResponseObject req, res, {message: 'The scores were updated successfully, person lost so no more games are being inserted!'} + else if err is 'no session was found' + markSessionAsDoneRanking originalSessionID, (err) -> + if err? then return scoringUtils.sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'} + scoringUtils.sendResponseObject req, res, {message: 'There were no more games to rank (game is at top)!'} + else if err? + errors.serverError res, "There was an error:#{err}" + else + scoringUtils.sendResponseObject req, res, {message: 'The scores were updated successfully and more games were sent to the queue!'} + catch e + errors.serverError res, 'There was an error processing the task result!' + + +verifyClientResponse = (responseObject, callback) -> + # TODO: better verification + if typeof responseObject isnt 'object' or responseObject?.originalSessionID?.length isnt 24 + callback 'The response to that query is required to be a JSON object.' + else + @clientResponseObject = responseObject + callback null, responseObject + +fetchTaskLog = (responseObject, callback) -> + TaskLog.findOne(_id: responseObject.taskID).lean().exec (err, taskLog) => + return callback new Error("Couldn't find TaskLog for _id #{responseObject.taskID}!") unless taskLog + @taskLog = taskLog + callback err, taskLog + +checkTaskLog = (taskLog, callback) -> + if taskLog.calculationTimeMS then return callback 'That computational task has already been performed' + if hasTaskTimedOut taskLog.sentDate then return callback 'The task has timed out' + callback null + +hasTaskTimedOut = (taskSentTimestamp) -> + taskSentTimestamp + scoringUtils.scoringTaskTimeoutInSeconds * 1000 < Date.now() + +deleteQueueMessage = (callback) -> + scoringUtils.scoringTaskQueue.deleteMessage @clientResponseObject.receiptHandle, (err) -> + callback err + +fetchLevelSession = (callback) -> + selectString = 'submitDate creator level standardDeviation meanStrength totalScore submittedCodeLanguage' + LevelSession.findOne(_id: @clientResponseObject.originalSessionID).select(selectString).lean().exec (err, session) => + @levelSession = session + callback err + +checkSubmissionDate = (callback) -> + supposedSubmissionDate = new Date(@clientResponseObject.sessions[0].submitDate) + if Number(supposedSubmissionDate) isnt Number(@levelSession.submitDate) + callback 'The game has been resubmitted. Removing from queue...' + else + callback null + +logTaskComputation = (callback) -> + @taskLog.set('calculationTimeMS', @clientResponseObject.calculationTimeMS) + @taskLog.set('sessions') # Huh? + @taskLog.calculationTimeMS = @clientResponseObject.calculationTimeMS + @taskLog.sessions = @clientResponseObject.sessions + @taskLog.save (err, saved) -> + callback err + +determineIfSessionShouldContinueAndUpdateLog = (cb) -> + sessionID = @clientResponseObject.originalSessionID + sessionRank = parseInt @clientResponseObject.originalSessionRank + update = '$inc': {} + if sessionRank is 0 + update['$inc'] = {numberOfWinsAndTies: 1} + else + update['$inc'] = {numberOfLosses: 1} + LevelSession.findOneAndUpdate {_id: sessionID}, update, {select: 'numberOfWinsAndTies numberOfLosses', lean: true}, (err, updatedSession) -> + if err? then return cb err, updatedSession + totalNumberOfGamesPlayed = updatedSession.numberOfWinsAndTies + updatedSession.numberOfLosses + if totalNumberOfGamesPlayed < 10 + #console.log 'Number of games played is less than 10, continuing...' + cb null + else + ratio = (updatedSession.numberOfLosses) / (totalNumberOfGamesPlayed) + if ratio > 0.33 + cb 'shouldn\'t continue' + #console.log "Ratio(#{ratio}) is bad, ending simulation" + else + #console.log "Ratio(#{ratio}) is good, so continuing simulations" + cb null + +findNearestBetterSessionID = (cb) -> + try + levelOriginalID = @levelSession.level.original + levelMajorVersion = @levelSession.level.majorVersion + sessionID = @clientResponseObject.originalSessionID + sessionTotalScore = @newScoresObject[sessionID].totalScore + opponentSessionID = _.pull(_.keys(@newScoresObject), sessionID) + opponentSessionTotalScore = @newScoresObject[opponentSessionID].totalScore + opposingTeam = scoringUtils.calculateOpposingTeam(@clientResponseObject.originalSessionTeam) + catch e + cb e + + retrieveAllOpponentSessionIDs sessionID, (err, opponentSessionIDs) -> + if err? then return cb err, null + queryParameters = + totalScore: + $gt: opponentSessionTotalScore + _id: + $nin: opponentSessionIDs + 'level.original': levelOriginalID + 'level.majorVersion': levelMajorVersion + submitted: true + team: opposingTeam + if opponentSessionTotalScore < 30 + # Don't play a ton of matches at low scores--skip some in proportion to how close to 30 we are. + # TODO: this could be made a lot more flexible. + queryParameters['totalScore']['$gt'] = opponentSessionTotalScore + 2 * (30 - opponentSessionTotalScore) / 20 + + limitNumber = 1 + sortParameters = totalScore: 1 + selectString = '_id totalScore' + query = LevelSession.findOne(queryParameters) + .sort(sortParameters) + .limit(limitNumber) + .select(selectString) + .lean() + #console.log "Finding session with score near #{opponentSessionTotalScore}" + query.exec (err, session) -> + if err? then return cb err, session + unless session then return cb 'no session was found' + #console.log "Found session with score #{session.totalScore}" + cb err, session._id + +retrieveAllOpponentSessionIDs = (sessionID, cb) -> + selectString = 'matches.opponents.sessionID matches.date submitDate' + LevelSession.findOne({_id: sessionID}).select(selectString).lean().exec (err, session) -> + if err? then return cb err, null + opponentSessionIDs = (match.opponents[0].sessionID for match in session.matches when match.date > session.submitDate) + cb err, opponentSessionIDs + +addNewSessionsToQueue = (sessionID, callback) -> + sessions = [@clientResponseObject.originalSessionID, sessionID] + scoringUtils.addPairwiseTaskToQueue sessions, callback + +markSessionAsDoneRanking = (sessionID, cb) -> + #console.log 'Marking session as done ranking...' + LevelSession.update {_id: sessionID}, {isRanking: false}, cb diff --git a/server/queues/scoring/recordTwoGames.coffee b/server/queues/scoring/recordTwoGames.coffee new file mode 100644 index 000000000..aebc5a4b0 --- /dev/null +++ b/server/queues/scoring/recordTwoGames.coffee @@ -0,0 +1,21 @@ +log = require 'winston' +async = require 'async' +errors = require '../../commons/errors' +scoringUtils = require './scoringUtils' +LevelSession = require '../../levels/sessions/LevelSession' + +module.exports = recordTwoGames = (req, res) -> + sessions = req.body.sessions + #console.log 'Recording non-chained result of', sessions?[0]?.name, sessions[0]?.metrics?.rank, 'and', sessions?[1]?.name, sessions?[1]?.metrics?.rank + return if scoringUtils.simulatorIsTooOld req, res + req.body?.simulator?.user = '' + req.user?._id + + yetiGuru = clientResponseObject: req.body, isRandomMatch: true + async.waterfall [ + scoringUtils.calculateSessionScores.bind(yetiGuru) # Fetches a few small properties from both sessions, prepares @levelSessionUpdates with the score part + scoringUtils.indexNewScoreArray.bind(yetiGuru) # Creates and returns @newScoresObject, no query + scoringUtils.addMatchToSessionsAndUpdate.bind(yetiGuru) # Adds matches to the session updates and does the writes + scoringUtils.updateUserSimulationCounts.bind(yetiGuru, req.user?._id) + ], (err, successMessageObject) -> + if err? then return errors.serverError res, "There was an error recording the single game: #{err}" + scoringUtils.sendResponseObject req, res, {message: 'The single game was submitted successfully!'} diff --git a/server/queues/scoring/scoringUtils.coffee b/server/queues/scoring/scoringUtils.coffee new file mode 100644 index 000000000..57ba2d460 --- /dev/null +++ b/server/queues/scoring/scoringUtils.coffee @@ -0,0 +1,187 @@ +log = require 'winston' +async = require 'async' +bayes = new (require 'bayesian-battle')() +LevelSession = require '../../levels/sessions/LevelSession' +User = require '../../users/User' + +SIMULATOR_VERSION = 3 + +module.exports.scoringTaskTimeoutInSeconds = 600 + +module.exports.scoringTaskQueue = null + +module.exports.simulatorIsTooOld = (req, res) -> + clientSimulator = req.body.simulator + return false if clientSimulator?.version >= SIMULATOR_VERSION + message = "Old simulator version #{clientSimulator?.version}, need to clear cache and get version #{SIMULATOR_VERSION}." + log.debug "400: #{message}" + res.send 400, message + res.end() + true + + +module.exports.sendResponseObject = (req, res, object) -> + res.setHeader('Content-Type', 'application/json') + res.send(object) + res.end() + + +module.exports.formatSessionInformation = (session) -> + sessionID: session._id + team: session.team ? 'No team' + transpiledCode: session.transpiledCode + submittedCodeLanguage: session.submittedCodeLanguage + teamSpells: session.teamSpells ? {} + levelID: session.levelID + creatorName: session.creatorName + creator: session.creator + totalScore: session.totalScore + + +module.exports.calculateSessionScores = (callback) -> + sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID' + async.map sessionIDs, retrieveOldSessionData, (err, oldScores) => + if err? then callback err, {error: 'There was an error retrieving the old scores'} + try + oldScoreArray = _.toArray putRankingFromMetricsIntoScoreObject @clientResponseObject, oldScores + newScoreArray = bayes.updatePlayerSkills oldScoreArray + createSessionScoreUpdate.call @, scoreObject for scoreObject in newScoreArray + callback err, newScoreArray + catch e + callback e + +retrieveOldSessionData = (sessionID, callback) -> + formatOldScoreObject = (session) -> + standardDeviation: session.standardDeviation ? 25/3 + meanStrength: session.meanStrength ? 25 + totalScore: session.totalScore ? (25 - 1.8*(25/3)) + id: sessionID + submittedCodeLanguage: session.submittedCodeLanguage + + return formatOldScoreObject @levelSession if sessionID is @levelSession?._id # No need to fetch again + + query = _id: sessionID + selection = 'standardDeviation meanStrength totalScore submittedCodeLanguage' + LevelSession.findOne(query).select(selection).lean().exec (err, session) -> + return callback err, {'error': 'There was an error retrieving the session.'} if err? + callback err, formatOldScoreObject session + +putRankingFromMetricsIntoScoreObject = (taskObject, scoreObject) -> + scoreObject = _.indexBy scoreObject, 'id' + scoreObject[session.sessionID].gameRanking = session.metrics.rank for session in taskObject.sessions + return scoreObject + +createSessionScoreUpdate = (scoreObject) -> + newTotalScore = scoreObject.meanStrength - 1.8 * scoreObject.standardDeviation + scoreHistoryAddition = [Date.now(), newTotalScore] + @levelSessionUpdates ?= {} + @levelSessionUpdates[scoreObject.id] = + meanStrength: scoreObject.meanStrength + standardDeviation: scoreObject.standardDeviation + totalScore: newTotalScore + $push: {scoreHistory: {$each: [scoreHistoryAddition], $slice: -1000}} + randomSimulationIndex: Math.random() + + +module.exports.indexNewScoreArray = (newScoreArray, callback) -> + newScoresObject = _.indexBy newScoreArray, 'id' + @newScoresObject = newScoresObject + callback null, newScoresObject + + +module.exports.addMatchToSessionsAndUpdate = (newScoreObject, callback) -> + matchObject = {} + matchObject.date = new Date() + matchObject.opponents = {} + for session in @clientResponseObject.sessions + sessionID = session.sessionID + matchObject.opponents[sessionID] = match = {} + match.sessionID = sessionID + match.userID = session.creator + match.name = session.name + match.totalScore = session.totalScore + match.metrics = {} + match.metrics.rank = Number(newScoreObject[sessionID]?.gameRanking ? 0) + match.codeLanguage = newScoreObject[sessionID].submittedCodeLanguage + + #log.info "Match object computed, result: #{JSON.stringify(matchObject, null, 2)}" + #log.info 'Writing match object to database...' + #use bind with async to do the writes + sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID' + async.each sessionIDs, updateMatchesInSession.bind(@, matchObject), (err) -> + callback err + +updateMatchesInSession = (matchObject, sessionID, callback) -> + currentMatchObject = {} + currentMatchObject.date = matchObject.date + currentMatchObject.metrics = matchObject.opponents[sessionID].metrics + opponentsClone = _.cloneDeep matchObject.opponents + opponentsClone = _.omit opponentsClone, sessionID + opponentsArray = _.toArray opponentsClone + currentMatchObject.opponents = opponentsArray + currentMatchObject.codeLanguage = matchObject.opponents[opponentsArray[0].sessionID].codeLanguage + #currentMatchObject.simulator = @clientResponseObject.simulator # Uncomment when actively debugging simulation mismatches + #currentMatchObject.randomSeed = parseInt(@clientResponseObject.randomSeed or 0, 10) # Uncomment when actively debugging simulation mismatches + sessionUpdateObject = @levelSessionUpdates[sessionID] + sessionUpdateObject.$push.matches = {$each: [currentMatchObject], $slice: -200} + #log.info "Update is #{JSON.stringify(sessionUpdateObject, null, 2)}" + LevelSession.update {_id: sessionID}, sessionUpdateObject, callback + + +module.exports.updateUserSimulationCounts = (reqUserID, callback) -> + incrementUserSimulationCount reqUserID, 'simulatedBy', (err) => + if err? then return callback err + #console.log 'Incremented user simulation count!' + unless @isRandomMatch + incrementUserSimulationCount @levelSession.creator, 'simulatedFor', callback + else + callback null + +incrementUserSimulationCount = (userID, type, callback) => + return callback null unless userID + inc = {} + inc[type] = 1 + User.update {_id: userID}, {$inc: inc}, (err, affected) -> + log.error "Error incrementing #{type} for #{userID}: #{err}" if err + callback err + + +module.exports.calculateOpposingTeam = (sessionTeam) -> + teams = ['ogres', 'humans'] + opposingTeams = _.pull teams, sessionTeam + return opposingTeams[0] + + +module.exports.sendEachTaskPairToTheQueue = (taskPairs, callback) -> + async.each taskPairs, sendTaskPairToQueue, callback + +sendTaskPairToQueue = (taskPair, callback) -> + module.exports.scoringTaskQueue.sendMessage {sessions: taskPair}, 5, (err, data) -> callback? err, data + + +module.exports.generateTaskPairs = (submittedSessions, sessionToScore) -> + taskPairs = [] + for session in submittedSessions + if session.toObject? + session = session.toObject() + teams = ['ogres', 'humans'] + opposingTeams = _.pull teams, sessionToScore.team + if String(session._id) isnt String(sessionToScore._id) and session.team in opposingTeams + #console.log 'Adding game to taskPairs!' + taskPairs.push [sessionToScore._id, String session._id] + return taskPairs + + +module.exports.addPairwiseTaskToQueue = (taskPair, cb) -> + LevelSession.findOne(_id: taskPair[0]).lean().exec (err, firstSession) => + if err? then return cb err + LevelSession.find(_id: taskPair[1]).exec (err, secondSession) => + if err? then return cb err + try + taskPairs = module.exports.generateTaskPairs(secondSession, firstSession) + catch e + if e then return cb e + + module.exports.sendEachTaskPairToTheQueue taskPairs, (taskPairError) -> + if taskPairError? then return cb taskPairError + cb null diff --git a/server/routes/queue.coffee b/server/routes/queue.coffee index 169b71371..6dd987cc2 100644 --- a/server/routes/queue.coffee +++ b/server/routes/queue.coffee @@ -13,10 +13,6 @@ module.exports.setup = (app) -> handler = loadQueueHandler 'scoring' handler.messagesInQueueCount req, res - app.post '/queue/scoring/resimulateAllSessions', (req, res) -> - handler = loadQueueHandler 'scoring' - handler.resimulateAllSessions req, res - app.post '/queue/scoring/getTwoGames', (req, res) -> handler = loadQueueHandler 'scoring' handler.getTwoGames req, res diff --git a/server/users/User.coffee b/server/users/User.coffee index f5da99711..c8ce9ffb2 100644 --- a/server/users/User.coffee +++ b/server/users/User.coffee @@ -51,10 +51,8 @@ UserSchema.methods.isAnonymous = -> @get 'anonymous' UserSchema.methods.getUserInfo = -> - info = - id : @get('_id') - email : if @get('anonymous') then 'Unregistered User' else @get('email') - return info + id: @get('_id') + email: if @get('anonymous') then 'Unregistered User' else @get('email') UserSchema.methods.trackActivity = (activityName, increment) -> now = new Date() From bda2483738c0075ba788e811ebb3a34792256aa4 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Sat, 15 Aug 2015 05:38:47 -0700 Subject: [PATCH 2/5] Added league-based ladder game simulation. --- app/schemas/models/level_session.coffee | 8 + server/levels/sessions/LevelSession.coffee | 1 + server/queues/scoring.coffee | 2 +- server/queues/scoring/createNewTask.coffee | 26 +++- .../scoring/dispatchTaskToConsumer.coffee | 2 +- server/queues/scoring/getTwoGames.coffee | 138 +++++++++++------- .../queues/scoring/processTaskResult.coffee | 12 +- server/queues/scoring/recordTwoGames.coffee | 2 +- server/queues/scoring/scoringUtils.coffee | 77 ++++++++-- 9 files changed, 190 insertions(+), 78 deletions(-) diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee index d7b360987..66a3bddb8 100644 --- a/app/schemas/models/level_session.coffee +++ b/app/schemas/models/level_session.coffee @@ -294,6 +294,14 @@ _.extend LevelSessionSchema.properties, simulator: {type: 'object', description: 'Holds info on who simulated the match, and with what tools.'} randomSeed: {description: 'Stores the random seed that was used during this match.'} + leagues: + c.array {description: 'Multiplayer data for the league corresponding to Clans and CourseInstances the player is a part of.'}, + c.object {}, + leagueID: {type: 'string', description: 'The _id of a Clan or CourseInstance the user belongs to.'} + stats: c.object {description: 'Multiplayer match statistics corresponding to this entry in the league.'} + +LevelSessionSchema.properties.leagues.items.properties.stats.properties = _.pick LevelSessionSchema.properties, 'meanStrength', 'standardDeviation', 'totalScore', 'numberOfWinsAndTies', 'numberOfLosses', 'scoreHistory', 'matches' + c.extendBasicProperties LevelSessionSchema, 'level.session' c.extendPermissionsProperties LevelSessionSchema, 'level.session' diff --git a/server/levels/sessions/LevelSession.coffee b/server/levels/sessions/LevelSession.coffee index f2060ab63..d702b61fa 100644 --- a/server/levels/sessions/LevelSession.coffee +++ b/server/levels/sessions/LevelSession.coffee @@ -28,6 +28,7 @@ LevelSessionSchema.index({submitted: 1, team: 1, level:1, totalScore: -1}, {name LevelSessionSchema.index({levelID: 1, submitted:1, team: 1}, {name: 'get all scores index', sparse: true}) LevelSessionSchema.index({submitted: 1, team: 1, levelID: 1, submitDate: -1}, {name: 'matchmaking index', sparse: true}) LevelSessionSchema.index({submitted: 1, team: 1, levelID: 1, randomSimulationIndex: -1}, {name: 'matchmaking random index', sparse: true}) +LevelSessionSchema.index({'leagues.leagueID': 1, submitted: 1, levelID: 1, team: 1, randomSimulationIndex: -1}, {name: 'league-based matchmaking random index', sparse: true}) # Really need MongoDB 3.2 for partial indexes for this and several others: https://jira.mongodb.org/browse/SERVER-785 LevelSessionSchema.plugin(plugins.PermissionsPlugin) LevelSessionSchema.plugin(AchievablePlugin) diff --git a/server/queues/scoring.coffee b/server/queues/scoring.coffee index 842dcaf16..c36bf747b 100644 --- a/server/queues/scoring.coffee +++ b/server/queues/scoring.coffee @@ -36,7 +36,7 @@ module.exports.addPairwiseTaskToQueueFromRequest = (req, res) -> taskPair = req.body.sessions scoringUtils.addPairwiseTaskToQueue req.body.sessions, (err, success) -> if err? then return errors.serverError res, "There was an error adding pairwise tasks: #{err}" - scoringUtils.sendResponseObject req, res, {message: 'All task pairs were succesfully sent to the queue'} + scoringUtils.sendResponseObject res, {message: 'All task pairs were succesfully sent to the queue'} module.exports.getTwoGames = getTwoGames diff --git a/server/queues/scoring/createNewTask.coffee b/server/queues/scoring/createNewTask.coffee index bbddd6948..2e63856b2 100644 --- a/server/queues/scoring/createNewTask.coffee +++ b/server/queues/scoring/createNewTask.coffee @@ -3,6 +3,7 @@ async = require 'async' errors = require '../../commons/errors' scoringUtils = require './scoringUtils' LevelSession = require '../../levels/sessions/LevelSession' +Level = require '../../levels/Level' module.exports = createNewTask = (req, res) -> requestSessionID = req.body.session @@ -16,12 +17,12 @@ module.exports = createNewTask = (req, res) -> validatePermissions.bind(yetiGuru, req, requestSessionID) fetchAndVerifyLevelType.bind(yetiGuru, currentLevelID) fetchSessionObjectToSubmit.bind(yetiGuru, requestSessionID) - updateSessionToSubmit.bind(yetiGuru, transpiledCode) + updateSessionToSubmit.bind(yetiGuru, transpiledCode, req.user) fetchInitialSessionsToRankAgainst.bind(yetiGuru, requestLevelMajorVersion, originalLevelID) generateAndSendTaskPairsToTheQueue ], (err, successMessageObject) -> if err? then return errors.serverError res, "There was an error submitting the game to the queue:#{err}" - scoringUtils.sendResponseObject req, res, successMessageObject + scoringUtils.sendResponseObject res, successMessageObject validatePermissions = (req, sessionID, callback) -> @@ -45,22 +46,37 @@ fetchAndVerifyLevelType = (levelID, cb) -> cb null fetchSessionObjectToSubmit = (sessionID, callback) -> - LevelSession.findOne({_id: sessionID}).select('team code').exec (err, session) -> + LevelSession.findOne({_id: sessionID}).select('team code leagues').exec (err, session) -> callback err, session?.toObject() -updateSessionToSubmit = (transpiledCode, sessionToUpdate, callback) -> +updateSessionToSubmit = (transpiledCode, user, sessionToUpdate, callback) -> sessionUpdateObject = submitted: true submittedCode: sessionToUpdate.code transpiledCode: transpiledCode submitDate: new Date() #meanStrength: 25 # Let's try not resetting the score on resubmission - standardDeviation: 25/3 + standardDeviation: 25 / 3 #totalScore: 10 # Let's try not resetting the score on resubmission numberOfWinsAndTies: 0 numberOfLosses: 0 isRanking: true randomSimulationIndex: Math.random() + + # Reset all league stats as well, and enter the session into any leagues the user is currently part of (not retroactive when joining new leagues) + leagueIDs = user.get('clans') or [] + #leagueIDs = leagueIDs.concat user.get('courseInstances') or [] + leagueIDs = (leagueID + '' for leagueID in leagueIDs) # Make sure to save them as strings. + newLeagues = [] + for leagueID in leagueIDs + league = _.find(sessionToUpdate.leagues, leagueID: leagueID) ? leagueID: leagueID + league.stats ?= {} + league.stats.standardDeviation = 25 / 3 + league.stats.numberOfWinsAndTies = 0 + league.stats.numberOfLosses = 0 + newLeagues.push(league) + unless _.isEqual newLeagues, sessionToUpdate.leagues + sessionUpdateObject.leagues = sessionToUpdate.leagues = newLeagues LevelSession.update {_id: sessionToUpdate._id}, sessionUpdateObject, (err, result) -> callback err, sessionToUpdate diff --git a/server/queues/scoring/dispatchTaskToConsumer.coffee b/server/queues/scoring/dispatchTaskToConsumer.coffee index 02f6b1370..53af54c05 100644 --- a/server/queues/scoring/dispatchTaskToConsumer.coffee +++ b/server/queues/scoring/dispatchTaskToConsumer.coffee @@ -22,7 +22,7 @@ module.exports = dispatchTaskToConsumer = (req, res) -> return res.end() else return errors.serverError res, "There was an error dispatching the task: #{err}" - scoringUtils.sendResponseObject req, res, taskObjectToSend + scoringUtils.sendResponseObject res, taskObjectToSend checkSimulationPermissions = (req, cb) -> diff --git a/server/queues/scoring/getTwoGames.coffee b/server/queues/scoring/getTwoGames.coffee index 00b0a3fb6..375bcb6c0 100644 --- a/server/queues/scoring/getTwoGames.coffee +++ b/server/queues/scoring/getTwoGames.coffee @@ -5,42 +5,89 @@ scoringUtils = require './scoringUtils' LevelSession = require '../../levels/sessions/LevelSession' module.exports = getTwoGames = (req, res) -> - #if isUserAnonymous req then return errors.unauthorized(res, 'You need to be logged in to get games.') - humansGameID = req.body.humansGameID - ogresGameID = req.body.ogresGameID + #return errors.unauthorized(res, 'You need to be logged in to get games.') unless req.user?.get('email') return if scoringUtils.simulatorIsTooOld req, res - #ladderGameIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span'] # Let's not give any extra simulations to old ladders. - ladderGameIDs = ['dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove', 'harrowland', 'zero-sum'] - levelID = _.sample ladderGameIDs - unless ogresGameID and humansGameID - recentHumans = Math.random() < 0.5 # We pick one session favoring recent submissions, then find another one uniformly to play against - async.map [{levelID: levelID, team: 'humans', favorRecent: recentHumans}, {levelID: levelID, team: 'ogres', favorRecent: not recentHumans}], findRandomSession, (err, sessions) -> - if err then return errors.serverError(res, "Couldn't get two games to simulate for #{levelID}.") - unless sessions.length is 2 - res.send(204, 'No games to score.') - return res.end() - taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in sessions) - #console.log 'Dispatching random game between', taskObject.sessions[0].creatorName, 'and', taskObject.sessions[1].creatorName - scoringUtils.sendResponseObject req, res, taskObject - else - #console.log "Directly simulating #{humansGameID} vs. #{ogresGameID}." - selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' - LevelSession.findOne(_id: humansGameID).select(selection).lean().exec (err, humanSession) => - if err? then return errors.serverError(res, 'Couldn\'t find the human game') - LevelSession.findOne(_id: ogresGameID).select(selection).lean().exec (err, ogreSession) => - if err? then return errors.serverError(res, 'Couldn\'t find the ogre game') - taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in [humanSession, ogreSession]) - scoringUtils.sendResponseObject req, res, taskObject + humansSessionID = req.body.humansGameID + ogresSessionID = req.body.ogresGameID + return getSpecificSessions res, humansSessionID, ogresSessionID if humansSessionID and ogresSessionID + getRandomSessions req.user, sendSessionsResponse(res) +sessionSelectionString = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate leagues' -earliestSubmissionCache = {} -findEarliestSubmission = (queryParams, callback) -> - cacheKey = JSON.stringify queryParams - return callback null, cached if cached = earliestSubmissionCache[cacheKey] - LevelSession.findOne(queryParams).sort(submitDate: 1).lean().exec (err, earliest) -> +sendSessionsResponse = (res) -> + (err, sessions) -> + if err then return errors.serverError res, "Couldn't get two games to simulate: #{err}" + unless sessions.length is 2 + console.log 'No games to score.', sessions.length + res.send 204, 'No games to score.' + return res.end() + taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in sessions) + #console.log 'Dispatching ladder game simulation between', taskObject.sessions[0].creatorName, 'and', taskObject.sessions[1].creatorName + scoringUtils.sendResponseObject res, taskObject + +getSpecificSessions = (res, humansSessionID, ogresSessionID) -> + async.map [humansSessionID, ogresSessionID], getSpecificSession, sendSessionsResponse(res) + +getSpecificSession = (sessionID, callback) -> + LevelSession.findOne(_id: sessionID).select(sessionSelectionString).lean().exec (err, session) -> + if err? then return callback "Couldn\'t find target simulation session #{sessionID}" + callback null, session + +getRandomSessions = (user, callback) -> + # Determine whether to play a random match, an internal league match, or an external league match. + # Only people in a league will end up simulating internal league matches (for leagues they're in) except by dumb chance. + # If we don't like that, we can rework sampleByLevel to have an opportunity to switch to internal leagues if the first session had a league affiliation. + leagueIDs = user.get('clans') or [] + #leagueIDs = leagueIDs.concat user.get('courseInstances') or [] + leagueIDs = (leagueID + '' for leagueID in leagueIDs) # Make sure to fetch them as strings. + return sampleByLevel callback unless leagueIDs.length and Math.random() > 1 / leagueIDs.length + leagueID = _.sample leagueIDs + findRandomSession {'leagues.leagueID': leagueID, favorRecent: true}, (err, session) -> + if err then return callback err + unless session then return sampleByLevel callback + otherTeam = scoringUtils.calculateOpposingTeam session.team + queryParameters = team: otherTeam, levelID: session.levelID + if Math.random() < 0.5 + # Try to play a match on the internal league ladder for this level + queryParameters['leagues.leagueID'] = leagueID + findRandomSession queryParameters, (err, otherSession) -> + if err then return callback err + if otherSession then return callback null, [session, otherSession] + # No opposing league session found; try to play an external match + delete queryParameters['leagues.leagueID'] + findRandomSession queryParameters, (err, otherSession) -> + if err then return callback err + callback null, [session, otherSession] + else + # Play what will probably end up being an external match + findRandomSession queryParameters, (err, otherSession) -> + if err then return callback err + callback null, [session, otherSession] + +# Sampling by level: we pick a level, then find a human and ogre session for that level, one at random, one biased towards recent submissions. +#ladderLevelIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span'] # Let's not give any extra simulations to old ladders. +ladderLevelIDs = ['dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove', 'harrowland', 'zero-sum'] +sampleByLevel = (callback) -> + levelID = _.sample ladderLevelIDs + favorRecentHumans = Math.random() < 0.5 # We pick one session favoring recent submissions, then find another one uniformly to play against + async.map [{levelID: levelID, team: 'humans', favorRecent: favorRecentHumans}, {levelID: levelID, team: 'ogres', favorRecent: not favorRecentHumans}], findRandomSession, callback + +findRandomSession = (queryParams, callback) -> + # In MongoDB 3.2, we will be able to easily get a random document with aggregate $sample: https://jira.mongodb.org/browse/SERVER-533 + queryParams.submitted = true + favorRecent = queryParams.favorRecent + delete queryParams.favorRecent + if favorRecent + return findRecentRandomSession queryParams, callback + queryParams.randomSimulationIndex = $lte: Math.random() + sort = randomSimulationIndex: -1 + LevelSession.findOne(queryParams).sort(sort).select(sessionSelectionString).lean().exec (err, session) -> return callback err if err - result = earliestSubmissionCache[cacheKey] = earliest?.submitDate - callback null, result + return callback null, session if session + delete queryParams.randomSimulationIndex # Just find the highest-indexed session, if our randomSimulationIndex was lower than the lowest one. + LevelSession.findOne(queryParams).sort(sort).select(sessionSelectionString).lean().exec (err, session) -> + return callback err if err + callback null, session findRecentRandomSession = (queryParams, callback) -> # We pick a random submitDate between the first submit date for the level and now, then do a $lt fetch to find a session to simulate. @@ -51,26 +98,17 @@ findRecentRandomSession = (queryParams, callback) -> interval = now - startDate cutoff = new Date now - Math.pow(Math.random(), 4) * interval queryParams.submitDate = $gte: startDate, $lt: cutoff - selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' - LevelSession.findOne(queryParams).sort(submitDate: -1).select(selection).lean().exec (err, session) -> + LevelSession.findOne(queryParams).sort(submitDate: -1).select(sessionSelectionString).lean().exec (err, session) -> return callback err if err callback null, session -findRandomSession = (queryParams, callback) -> - # In MongoDB 3.2, we will be able to easily get a random document with aggregate $sample: https://jira.mongodb.org/browse/SERVER-533 - queryParams.submitted = true - favorRecent = queryParams.favorRecent - delete queryParams.favorRecent - if favorRecent - return findRecentRandomSession queryParams, callback - queryParams.randomSimulationIndex = $lte: Math.random() - selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate' - sort = randomSimulationIndex: -1 - LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) -> +earliestSubmissionCache = {} +findEarliestSubmission = (queryParams, callback) -> + cacheKey = JSON.stringify queryParams + return callback null, cached if cached = earliestSubmissionCache[cacheKey] + LevelSession.findOne(queryParams).sort(submitDate: 1).lean().exec (err, earliest) -> return callback err if err - return callback null, session if session - delete queryParams.randomSimulationIndex # Just find the highest-indexed session, if our randomSimulationIndex was lower than the lowest one. - LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) -> - return callback err if err - callback null, session + result = earliestSubmissionCache[cacheKey] = earliest?.submitDate + callback null, result + diff --git a/server/queues/scoring/processTaskResult.coffee b/server/queues/scoring/processTaskResult.coffee index 3dc4f99d9..993ed88e1 100644 --- a/server/queues/scoring/processTaskResult.coffee +++ b/server/queues/scoring/processTaskResult.coffee @@ -29,16 +29,16 @@ module.exports = processTaskResult = (req, res) -> ], (err, results) -> if err is 'shouldn\'t continue' markSessionAsDoneRanking originalSessionID, (err) -> - if err? then return scoringUtils.sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'} - scoringUtils.sendResponseObject req, res, {message: 'The scores were updated successfully, person lost so no more games are being inserted!'} + if err? then return scoringUtils.sendResponseObject res, {'error': 'There was an error marking the session as done ranking'} + scoringUtils.sendResponseObject res, {message: 'The scores were updated successfully, person lost so no more games are being inserted!'} else if err is 'no session was found' markSessionAsDoneRanking originalSessionID, (err) -> - if err? then return scoringUtils.sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'} - scoringUtils.sendResponseObject req, res, {message: 'There were no more games to rank (game is at top)!'} + if err? then return scoringUtils.sendResponseObject res, {'error': 'There was an error marking the session as done ranking'} + scoringUtils.sendResponseObject res, {message: 'There were no more games to rank (game is at top)!'} else if err? errors.serverError res, "There was an error:#{err}" else - scoringUtils.sendResponseObject req, res, {message: 'The scores were updated successfully and more games were sent to the queue!'} + scoringUtils.sendResponseObject res, {message: 'The scores were updated successfully and more games were sent to the queue!'} catch e errors.serverError res, 'There was an error processing the task result!' @@ -70,7 +70,7 @@ deleteQueueMessage = (callback) -> callback err fetchLevelSession = (callback) -> - selectString = 'submitDate creator level standardDeviation meanStrength totalScore submittedCodeLanguage' + selectString = 'submitDate creator level standardDeviation meanStrength totalScore submittedCodeLanguage leagues' LevelSession.findOne(_id: @clientResponseObject.originalSessionID).select(selectString).lean().exec (err, session) => @levelSession = session callback err diff --git a/server/queues/scoring/recordTwoGames.coffee b/server/queues/scoring/recordTwoGames.coffee index aebc5a4b0..fe9702dca 100644 --- a/server/queues/scoring/recordTwoGames.coffee +++ b/server/queues/scoring/recordTwoGames.coffee @@ -18,4 +18,4 @@ module.exports = recordTwoGames = (req, res) -> scoringUtils.updateUserSimulationCounts.bind(yetiGuru, req.user?._id) ], (err, successMessageObject) -> if err? then return errors.serverError res, "There was an error recording the single game: #{err}" - scoringUtils.sendResponseObject req, res, {message: 'The single game was submitted successfully!'} + scoringUtils.sendResponseObject res, {message: 'The single game was submitted successfully!'} diff --git a/server/queues/scoring/scoringUtils.coffee b/server/queues/scoring/scoringUtils.coffee index 57ba2d460..264ee52df 100644 --- a/server/queues/scoring/scoringUtils.coffee +++ b/server/queues/scoring/scoringUtils.coffee @@ -20,7 +20,7 @@ module.exports.simulatorIsTooOld = (req, res) -> true -module.exports.sendResponseObject = (req, res, object) -> +module.exports.sendResponseObject = (res, object) -> res.setHeader('Content-Type', 'application/json') res.send(object) res.end() @@ -40,37 +40,65 @@ module.exports.formatSessionInformation = (session) -> module.exports.calculateSessionScores = (callback) -> sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID' - async.map sessionIDs, retrieveOldSessionData, (err, oldScores) => - if err? then callback err, {error: 'There was an error retrieving the old scores'} + async.map sessionIDs, retrieveOldSessionData.bind(@), (err, oldScores) => + if err? then return callback err, {error: 'There was an error retrieving the old scores'} try oldScoreArray = _.toArray putRankingFromMetricsIntoScoreObject @clientResponseObject, oldScores - newScoreArray = bayes.updatePlayerSkills oldScoreArray + newScoreArray = updatePlayerSkills oldScoreArray createSessionScoreUpdate.call @, scoreObject for scoreObject in newScoreArray callback err, newScoreArray catch e callback e retrieveOldSessionData = (sessionID, callback) -> - formatOldScoreObject = (session) -> - standardDeviation: session.standardDeviation ? 25/3 - meanStrength: session.meanStrength ? 25 - totalScore: session.totalScore ? (25 - 1.8*(25/3)) - id: sessionID - submittedCodeLanguage: session.submittedCodeLanguage + formatOldScoreObject = (session) => + oldScoreObject = + standardDeviation: session.standardDeviation ? 25/3 + meanStrength: session.meanStrength ? 25 + totalScore: session.totalScore ? (25 - 1.8*(25/3)) + id: sessionID + submittedCodeLanguage: session.submittedCodeLanguage + if session.leagues?.length + _.find(@clientResponseObject.sessions, sessionID: sessionID).leagues = session.leagues + oldScoreObject.leagues = [] + for league in session.leagues + oldScoreObject.leagues.push + leagueID: league.leagueID + stats: + id: sessionID + standardDeviation: league.stats.standardDeviation ? 25/3 + meanStrength: league.stats.meanStrength ? 25 + totalScore: league.stats.totalScore ? (25 - 1.8*(25/3)) + oldScoreObject return formatOldScoreObject @levelSession if sessionID is @levelSession?._id # No need to fetch again query = _id: sessionID - selection = 'standardDeviation meanStrength totalScore submittedCodeLanguage' + selection = 'standardDeviation meanStrength totalScore submittedCodeLanguage leagues' LevelSession.findOne(query).select(selection).lean().exec (err, session) -> return callback err, {'error': 'There was an error retrieving the session.'} if err? callback err, formatOldScoreObject session putRankingFromMetricsIntoScoreObject = (taskObject, scoreObject) -> scoreObject = _.indexBy scoreObject, 'id' - scoreObject[session.sessionID].gameRanking = session.metrics.rank for session in taskObject.sessions + sharedLeagueIDs = (league.leagueID for league in (taskObject.sessions[0].leagues ? []) when _.find(taskObject.sessions[1].leagues, leagueID: league.leagueID)) + for session in taskObject.sessions + scoreObject[session.sessionID].gameRanking = session.metrics.rank + for league in (session.leagues ? []) when league.leagueID in sharedLeagueIDs + # We will also score any shared leagues, and we indicate that by assigning a non-null gameRanking to them. + _.find(scoreObject[session.sessionID].leagues, leagueID: league.leagueID).stats.gameRanking = session.metrics.rank return scoreObject +updatePlayerSkills = (oldScoreArray) -> + newScoreArray = bayes.updatePlayerSkills oldScoreArray + scoreObjectA = newScoreArray[0] + scoreObjectB = newScoreArray[1] + for leagueA in (scoreObjectA.leagues ? []) when leagueA.stats.gameRanking? + leagueB = _.find scoreObjectB.leagues, leagueID: leagueA.leagueID + [leagueA.stats, leagueB.stats] = bayes.updatePlayerSkills [leagueA.stats, leagueB.stats] + leagueA.stats.updated = leagueB.stats.updated = true + newScoreArray + createSessionScoreUpdate = (scoreObject) -> newTotalScore = scoreObject.meanStrength - 1.8 * scoreObject.standardDeviation scoreHistoryAddition = [Date.now(), newTotalScore] @@ -81,6 +109,17 @@ createSessionScoreUpdate = (scoreObject) -> totalScore: newTotalScore $push: {scoreHistory: {$each: [scoreHistoryAddition], $slice: -1000}} randomSimulationIndex: Math.random() + for league, leagueIndex in (scoreObject.leagues ? []) + continue unless league.stats.updated + newTotalScore = league.stats.meanStrength - 1.8 * league.stats.standardDeviation + scoreHistoryAddition = [scoreHistoryAddition[0], newTotalScore] + leagueSetPrefix = "leagues.#{leagueIndex}.stats." + @levelSessionUpdates[scoreObject.id].$set ?= {} + @levelSessionUpdates[scoreObject.id].$push ?= {} + @levelSessionUpdates[scoreObject.id].$set[leagueSetPrefix + 'meanStrength'] = league.stats.meanStrength + @levelSessionUpdates[scoreObject.id].$set[leagueSetPrefix + 'standardDeviation'] = league.stats.standardDeviation + @levelSessionUpdates[scoreObject.id].$set[leagueSetPrefix + 'totalScore'] = newTotalScore + @levelSessionUpdates[scoreObject.id].$push[leagueSetPrefix + 'scoreHistory'] = {$each: [scoreHistoryAddition], $slice: -1000} module.exports.indexNewScoreArray = (newScoreArray, callback) -> @@ -119,12 +158,22 @@ updateMatchesInSession = (matchObject, sessionID, callback) -> opponentsClone = _.omit opponentsClone, sessionID opponentsArray = _.toArray opponentsClone currentMatchObject.opponents = opponentsArray - currentMatchObject.codeLanguage = matchObject.opponents[opponentsArray[0].sessionID].codeLanguage + currentMatchObject.codeLanguage = matchObject.opponents[opponentsArray[0].sessionID].codeLanguage # TODO: we have our opponent code language in twice, do we maybe want our own code language instead? #currentMatchObject.simulator = @clientResponseObject.simulator # Uncomment when actively debugging simulation mismatches #currentMatchObject.randomSeed = parseInt(@clientResponseObject.randomSeed or 0, 10) # Uncomment when actively debugging simulation mismatches sessionUpdateObject = @levelSessionUpdates[sessionID] sessionUpdateObject.$push.matches = {$each: [currentMatchObject], $slice: -200} - #log.info "Update is #{JSON.stringify(sessionUpdateObject, null, 2)}" + + myScoreObject = @newScoresObject[sessionID] + opponentSession = _.find @clientResponseObject.sessions, (session) -> session.sessionID isnt sessionID + for league, leagueIndex in myScoreObject.leagues ? [] + continue unless league.stats.updated + opponentLeagueTotalScore = _.find(opponentSession.leagues, leagueID: league.leagueID).stats.totalScore ? (25 - 1.8*(25/3)) + leagueMatch = _.cloneDeep currentMatchObject + leagueMatch.opponents[0].totalScore = opponentLeagueTotalScore + sessionUpdateObject.$push["leagues.#{leagueIndex}.stats.matches"] = {$each: [leagueMatch], $slice: -200} + + #log.info "Update for #{sessionID} is #{JSON.stringify(sessionUpdateObject, null, 2)}" LevelSession.update {_id: sessionID}, sessionUpdateObject, callback From 32ca453dec0867caad408d2f2f56d23492ccf0f0 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Sat, 15 Aug 2015 06:45:38 -0700 Subject: [PATCH 3/5] Added /play/ladder/levelID/clan/clanID clan-specific ladder pages. --- app/core/Router.coffee | 1 + app/templates/play/ladder/ladder.jade | 3 +++ app/views/ladder/LadderTabView.coffee | 24 +++++++++++++++------- app/views/ladder/LadderView.coffee | 22 +++++++++++++++----- app/views/ladder/MyMatchesTabView.coffee | 19 ++++++++++++----- server/levels/level_handler.coffee | 11 ++++++---- server/levels/sessions/LevelSession.coffee | 6 ++++-- 7 files changed, 63 insertions(+), 23 deletions(-) diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 263140bea..d03429fcf 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -104,6 +104,7 @@ module.exports = class CocoRouter extends Backbone.Router 'multiplayer': go('MultiplayerView') 'play': go('play/CampaignView') + 'play/ladder/:levelID/:leagueType/:leagueID': go('ladder/LadderView') 'play/ladder/:levelID': go('ladder/LadderView') 'play/ladder': go('ladder/MainLadderView') 'play/level/:levelID': go('play/level/PlayLevelView') diff --git a/app/templates/play/ladder/ladder.jade b/app/templates/play/ladder/ladder.jade index b92891c08..d0a7829b6 100644 --- a/app/templates/play/ladder/ladder.jade +++ b/app/templates/play/ladder/ladder.jade @@ -10,6 +10,9 @@ block content else h1= level.get('name') + if league + h1 #{league.get('name')} League + if level.get('name') == 'Greed' .tournament-blurb h2 diff --git a/app/views/ladder/LadderTabView.coffee b/app/views/ladder/LadderTabView.coffee index eda4ad279..c87729879 100644 --- a/app/views/ladder/LadderTabView.coffee +++ b/app/views/ladder/LadderTabView.coffee @@ -154,7 +154,7 @@ module.exports = class LadderTabView extends CocoView @supermodel.removeModelResource oldLeaderboard oldLeaderboard.destroy() teamSession = _.find @sessions.models, (session) -> session.get('team') is team.id - @leaderboards[team.id] = new LeaderboardData(@level, team.id, teamSession, @ladderLimit) + @leaderboards[team.id] = new LeaderboardData(@level, team.id, teamSession, @ladderLimit, @options.league) @leaderboardRes = @supermodel.addModelResource(@leaderboards[team.id], 'leaderboard', {cache: false}, 3) @leaderboardRes.load() @@ -166,7 +166,9 @@ module.exports = class LadderTabView extends CocoView team = _.find @teams, name: histogramWrapper.data('team-name') histogramData = null $.when( - $.get "/db/level/#{@level.get('slug')}/histogram_data?team=#{team.name.toLowerCase()}", {cache: false}, (data) -> histogramData = data + url = "/db/level/#{@level.get('slug')}/histogram_data?team=#{team.name.toLowerCase()}" + url += '&leagues.leagueID=' + @options.league.id if @options.league + $.get url, {cache: false}, (data) -> histogramData = data ).then => @generateHistogram(histogramWrapper, histogramData, team.name.toLowerCase()) unless @destroyed @@ -301,24 +303,32 @@ module.exports.LeaderboardData = LeaderboardData = class LeaderboardData extends Consolidates what you need to load for a leaderboard into a single Backbone Model-like object. ### - constructor: (@level, @team, @session, @limit) -> + constructor: (@level, @team, @session, @limit, @league) -> super() + collectionParameters: (parameters) -> + parameters.team = @team + parameters['leagues.leagueID'] = @league.id if @league + parameters + fetch: -> console.warn 'Already have top players on', @ if @topPlayers - @topPlayers = new LeaderboardCollection(@level, {order: -1, scoreOffset: HIGHEST_SCORE, team: @team, limit: @limit}) + + @topPlayers = new LeaderboardCollection(@level, @collectionParameters(order: -1, scoreOffset: HIGHEST_SCORE, limit: @limit)) promises = [] promises.push @topPlayers.fetch cache: false if @session score = @session.get('totalScore') or 10 - @playersAbove = new LeaderboardCollection(@level, {order: 1, scoreOffset: score, limit: 4, team: @team}) + @playersAbove = new LeaderboardCollection(@level, @collectionParameters(order: 1, scoreOffset: score, limit: 4)) promises.push @playersAbove.fetch cache: false - @playersBelow = new LeaderboardCollection(@level, {order: -1, scoreOffset: score, limit: 4, team: @team}) + @playersBelow = new LeaderboardCollection(@level, @collectionParameters(order: -1, scoreOffset: score, limit: 4)) promises.push @playersBelow.fetch cache: false level = "#{@level.get('original')}.#{@level.get('version').major}" success = (@myRank) => - promises.push $.ajax("/db/level/#{level}/leaderboard_rank?scoreOffset=#{@session.get('totalScore')}&team=#{@team}", cache: false, success: success) + loadURL = "/db/level/#{level}/leaderboard_rank?scoreOffset=#{@session.get('totalScore')}&team=#{@team}" + loadURL += '&leagues.leagueID=' + @league.id if @league + promises.push $.ajax(loadURL, cache: false, success: success) @promise = $.when(promises...) @promise.then @onLoad @promise.fail @onFail diff --git a/app/views/ladder/LadderView.coffee b/app/views/ladder/LadderView.coffee index 0c13316cd..48edcf5ee 100644 --- a/app/views/ladder/LadderView.coffee +++ b/app/views/ladder/LadderView.coffee @@ -12,6 +12,9 @@ SimulateTabView = require './SimulateTabView' LadderPlayModal = require './LadderPlayModal' CocoClass = require 'core/CocoClass' +Clan = require 'models/Clan' +#CourseInstance = require 'models/CourseInstance' + HIGHEST_SCORE = 1000000 class LevelSessionsCollection extends CocoCollection @@ -35,12 +38,19 @@ module.exports = class LadderView extends RootView 'click a:not([data-toggle])': 'onClickedLink' 'click .spectate-button': 'onClickSpectateButton' - constructor: (options, @levelID) -> + constructor: (options, @levelID, @leagueType, @leagueID) -> super(options) @level = @supermodel.loadModel(new Level(_id: @levelID), 'level').model @sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model - @teams = [] + @loadLeague() + + loadLeague: -> + @leagueID = @leagueType = null unless @leagueType in ['clan'] #, 'course'] + return unless @leagueID + modelClass = if @leagueType is 'clan' then Clan else null# else CourseInstance + resourceString = if @leagueType is 'clan' then 'clans.clan' else null# else 'courses.course' + @league = @supermodel.loadModel(new modelClass(_id: @leagueID), resourceString).model onLoaded: -> @teams = teamDataFromLevel @level @@ -53,6 +63,8 @@ module.exports = class LadderView extends RootView ctx.teams = @teams ctx.levelID = @levelID ctx.levelDescription = marked(@level.get('description')) if @level.get('description') + ctx.leagueType = @leagueType + ctx.league = @league ctx._ = _ if tournamentEndDate = {greed: 1402444800000, 'criss-cross': 1410912000000, 'zero-sum': 1428364800000}[@levelID] ctx.tournamentTimeLeft = moment(new Date(tournamentEndDate)).fromNow() @@ -64,9 +76,9 @@ module.exports = class LadderView extends RootView afterRender: -> super() return unless @supermodel.finished() - @insertSubView(@ladderTab = new LadderTabView({}, @level, @sessions)) - @insertSubView(@myMatchesTab = new MyMatchesTabView({}, @level, @sessions)) - @insertSubView(@simulateTab = new SimulateTabView()) + @insertSubView(@ladderTab = new LadderTabView({league: @league}, @level, @sessions)) + @insertSubView(@myMatchesTab = new MyMatchesTabView({league: @league}, @level, @sessions)) + @insertSubView(@simulateTab = new SimulateTabView(league: @league)) @refreshInterval = setInterval(@fetchSessionsAndRefreshViews.bind(@), 60 * 1000) hash = document.location.hash[1..] if document.location.hash if hash and not (hash in ['my-matches', 'simulate', 'ladder', 'prizes', 'rules', 'winners']) diff --git a/app/views/ladder/MyMatchesTabView.coffee b/app/views/ladder/MyMatchesTabView.coffee index b03b87726..039513150 100644 --- a/app/views/ladder/MyMatchesTabView.coffee +++ b/app/views/ladder/MyMatchesTabView.coffee @@ -24,7 +24,8 @@ module.exports = class MyMatchesTabView extends CocoView # Only fetch the names for the userIDs we don't already have in @nameMap ids = [] for session in @sessions.models - for match in (session.get('matches') or []) + matches = @statsFromSession(session).matches or [] + for match in matches id = match.opponents[0].userID unless id console.error 'Found bad opponent ID in malformed match:', match, 'from session', session @@ -37,7 +38,8 @@ module.exports = class MyMatchesTabView extends CocoView success = (nameMap) => return if @destroyed for session in @sessions.models - for match in session.get('matches') or [] + matches = @statsFromSession(session).matches or [] + for match in matches opponent = match.opponents[0] continue if @nameMap[opponent.userID] opponentUser = nameMap[opponent.userID] @@ -88,15 +90,16 @@ module.exports = class MyMatchesTabView extends CocoView for team in @teams team.session = (s for s in @sessions.models when s.get('team') is team.id)[0] + stats = @statsFromSession team.session team.readyToRank = team.session?.readyToRank() team.isRanking = team.session?.get('isRanking') - team.matches = (convertMatch(match, team.session.get('submitDate')) for match in team.session?.get('matches') or []) + team.matches = (convertMatch(match, team.session.get('submitDate')) for match in (stats?.matches or [])) team.matches.reverse() - team.score = (team.session?.get('totalScore') or 10).toFixed(2) + team.score = (stats?.totalScore ? 10).toFixed(2) team.wins = _.filter(team.matches, {state: 'win', stale: false}).length team.ties = _.filter(team.matches, {state: 'tie', stale: false}).length team.losses = _.filter(team.matches, {state: 'loss', stale: false}).length - scoreHistory = team.session?.get('scoreHistory') + scoreHistory = stats?.scoreHistory if scoreHistory?.length > 1 team.scoreHistory = scoreHistory @@ -123,6 +126,12 @@ module.exports = class MyMatchesTabView extends CocoView @$el.find('tr.fresh').removeClass('fresh', 5000) + statsFromSession: (session) -> + return null unless session + if @options.league + return _.find(session.get('leagues') or [], leagueID: @options.league.id)?.stats + session.attributes + generateScoreLineChart: (wrapperID, scoreHistory, teamName) => margin = top: 20 diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index 26ea7ac26..09dfc9b7e 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -160,16 +160,18 @@ LevelHandler = class LevelHandler extends Handler 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 rankin readiness + # 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'] aggregate = Session.aggregate [ - {$match: {'levelID': slug, 'submitted': true, 'team': req.query.team}} + {$match: match} {$project: {totalScore: 1, _id: 0}} ] - aggregate.cache() + aggregate.cache() unless league aggregate.exec (err, data) => if err? then return @sendDatabaseError res, err @@ -198,7 +200,7 @@ LevelHandler = class LevelHandler extends Handler sortParameters = 'totalScore': req.query.order - selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig'] + selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig', 'leagues.leagueID'] query = Session .find(sessionsQueryParameters) @@ -232,6 +234,7 @@ LevelHandler = class LevelHandler extends Handler team: req.query.team totalScore: scoreQuery submitted: true + query['leagues.leagueID'] = league if league = req.query['leagues.leagueID'] query validateLeaderboardRequestParameters: (req) -> diff --git a/server/levels/sessions/LevelSession.coffee b/server/levels/sessions/LevelSession.coffee index d702b61fa..2285ed29d 100644 --- a/server/levels/sessions/LevelSession.coffee +++ b/server/levels/sessions/LevelSession.coffee @@ -24,8 +24,10 @@ LevelSessionSchema.index({team: 1}, {sparse: true}) LevelSessionSchema.index({totalScore: 1}, {sparse: true}) LevelSessionSchema.index({user: 1, changed: -1}, {name: 'last played index', sparse: true}) LevelSessionSchema.index({'level.original': 1, 'state.topScores.type': 1, 'state.topScores.date': -1, 'state.topScores.score': -1}, {name: 'top scores index', sparse: true}) -LevelSessionSchema.index({submitted: 1, team: 1, level:1, totalScore: -1}, {name: 'rank counting index', sparse: true}) -LevelSessionSchema.index({levelID: 1, submitted:1, team: 1}, {name: 'get all scores index', sparse: true}) +LevelSessionSchema.index({submitted: 1, team: 1, level: 1, totalScore: -1}, {name: 'rank counting index', sparse: true}) +#LevelSessionSchema.index({level: 1, 'leagues.leagueID': 1, submitted: 1, team: 1, totalScore: -1}, {name: 'league rank counting index', sparse: true}) # needed for league leaderboards? +LevelSessionSchema.index({levelID: 1, submitted: 1, team: 1}, {name: 'get all scores index', sparse: true}) +#LevelSessionSchema.index({levelID: 1, 'leagues.leagueID': 1, submitted: 1, team: 1}, {name: 'league get all scores index', sparse: true}) # needed for league histograms? LevelSessionSchema.index({submitted: 1, team: 1, levelID: 1, submitDate: -1}, {name: 'matchmaking index', sparse: true}) LevelSessionSchema.index({submitted: 1, team: 1, levelID: 1, randomSimulationIndex: -1}, {name: 'matchmaking random index', sparse: true}) LevelSessionSchema.index({'leagues.leagueID': 1, submitted: 1, levelID: 1, team: 1, randomSimulationIndex: -1}, {name: 'league-based matchmaking random index', sparse: true}) # Really need MongoDB 3.2 for partial indexes for this and several others: https://jira.mongodb.org/browse/SERVER-785 From 6f2b1b2a41b4f2e6f36a3738250db2c24fae7142 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Sat, 15 Aug 2015 12:44:19 -0700 Subject: [PATCH 4/5] Added back an index we need on LevelSession, but in code this time instead of just from the shell as 'weird query index'. --- server/levels/sessions/LevelSession.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/server/levels/sessions/LevelSession.coffee b/server/levels/sessions/LevelSession.coffee index 2285ed29d..e8d218bbb 100644 --- a/server/levels/sessions/LevelSession.coffee +++ b/server/levels/sessions/LevelSession.coffee @@ -17,6 +17,7 @@ LevelSessionSchema.index({levelID: 1}) LevelSessionSchema.index({'level.majorVersion': 1}) LevelSessionSchema.index({'level.original': 1}, {name: 'Level Original'}) LevelSessionSchema.index({'level.original': 1, 'level.majorVersion': 1, 'creator': 1, 'team': 1}) +LevelSessionSchema.index({creator: 1, level: 1}) # Looks like the ones operating on level as two separate fields might not be working, and sometimes this query uses the "level" index instead of the "creator" index. LevelSessionSchema.index({playtime: 1}, {name: 'Playtime'}) LevelSessionSchema.index({submitDate: 1}) LevelSessionSchema.index({submitted: 1}, {sparse: true}) From f4d796a717538ab705a4a555b037ba33d4e494a8 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Wed, 19 Aug 2015 15:30:37 -0700 Subject: [PATCH 5/5] More ladder page support for leagues. Linking to clan league pages from clans pages. --- app/locale/en.coffee | 3 + app/templates/clans/clan-details.jade | 100 ++++++++++-------- .../play/ladder/ladder-tab-view.jade | 6 +- app/templates/play/ladder/ladder.jade | 4 +- app/templates/play/ladder/simulate_tab.jade | 1 + app/views/clans/ClanDetailsView.coffee | 12 ++- app/views/ladder/LadderPlayModal.coffee | 42 +++++--- app/views/ladder/LadderTabView.coffee | 16 ++- app/views/ladder/LadderView.coffee | 2 +- app/views/ladder/MyMatchesTabView.coffee | 2 +- server/levels/level_handler.coffee | 2 +- 11 files changed, 118 insertions(+), 72 deletions(-) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index d3173b371..b620be376 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -820,6 +820,7 @@ latest_achievement: "Latest Achievement" playtime: "Playtime" last_played: "Last played" + leagues_explanation: "Play in a league against other clan members in these multiplayer arena instances." classes: archmage_title: "Archmage" @@ -1009,6 +1010,7 @@ my_matches: "My Matches" simulate: "Simulate" simulation_explanation: "By simulating games you can get your game ranked faster!" + simulation_explanation_leagues: "You will mainly help simulate games for allied players in your clans and courses." simulate_games: "Simulate Games!" simulate_all: "RESET AND SIMULATE GAMES" games_simulated_by: "Games simulated by you:" @@ -1059,6 +1061,7 @@ tournament_blurb_blog: "on our blog" rules: "Rules" winners: "Winners" + league: "League" user: stats: "Stats" diff --git a/app/templates/clans/clan-details.jade b/app/templates/clans/clan-details.jade index ab1548311..072385987 100644 --- a/app/templates/clans/clan-details.jade +++ b/app/templates/clans/clan-details.jade @@ -26,52 +26,62 @@ block content button.btn(data-dismiss='modal', data-i18n="modal.close") Close button.btn.edit-description-save-btn(data-i18n="common.save_changes") Save changes - if clan - h1 #{clan.get('name')} - if clan.get('type') === 'private' - small(data-i18n="clans.private") (private) - if clan.get('ownerID') === me.id - span.spl - button.btn.btn-xs.edit-name-btn(data-toggle='modal', data-target='#editNameModal', data-i18n="clans.edit_name") edit name + .row + .col-lg-6 + if clan + h1 #{clan.get('name')} + if clan.get('type') === 'private' + small(data-i18n="clans.private") (private) + if clan.get('ownerID') === me.id + span.spl + button.btn.btn-xs.edit-name-btn(data-toggle='modal', data-target='#editNameModal', data-i18n="clans.edit_name") edit name + + if clan.get('description') + .clan-description + each line in clan.get('description').split('\n') + p= line + if clan.get('ownerID') === me.id + button.btn.btn-xs.edit-description-btn(data-toggle='modal', data-target='#editDescriptionModal', data-i18n="clans.edit_description") edit description + + h5(data-i18n="clans.summary") Summary + table.table.table-condensed.stats-table + if owner + tr + td + span.spr(data-i18n="clans.chieftain") Chieftain + td + span.spr.player-hero-icon(data-memberid="#{clan.get('ownerID')}") + a(href="/user/#{clan.get('ownerID')}")= owner.get('name') + if stats.averageLevel + tr + td(data-i18n="clans.average_level") Average Level + td= stats.averageLevel + if stats.averageAchievements && clan.get('type') === 'public' + tr + td(data-i18n="clans.average_achievements") Average Achievements + td= stats.averageAchievements + + p + if isOwner + button.btn.btn-xs.btn-warning.delete-clan-btn(data-i18n="clans.delete_clan") Delete Clan + else if isMember + button.btn.btn-xs.btn-warning.leave-clan-btn(data-i18n="clans.leave_clan") Leave Clan + else + button.btn.btn-lg.btn-success.join-clan-btn(data-i18n="clans.join_clan") Join Clan + + if clan.get('ownerID') === me.id || clan.get('type') === 'public' + div + span.spl.spr.join-link-prompt(data-i18n="clans.invite_1") Invite: + input.join-clan-link(type="text", readonly, value="#{joinClanLink}") + .small(data-i18n="clans.invite_2") *Invite players to this Clan by sending them this link. - if clan.get('description') - .clan-description - each line in clan.get('description').split('\n') - p= line - if clan.get('ownerID') === me.id - button.btn.btn-xs.edit-description-btn(data-toggle='modal', data-target='#editDescriptionModal', data-i18n="clans.edit_description") edit description - - h5(data-i18n="clans.summary") Summary - table.table.table-condensed.stats-table - if owner - tr - td - span.spr(data-i18n="clans.chieftain") Chieftain - td - span.spr.player-hero-icon(data-memberid="#{clan.get('ownerID')}") - a(href="/user/#{clan.get('ownerID')}")= owner.get('name') - if stats.averageLevel - tr - td(data-i18n="clans.average_level") Average Level - td= stats.averageLevel - if stats.averageAchievements && clan.get('type') === 'public' - tr - td(data-i18n="clans.average_achievements") Average Achievements - td= stats.averageAchievements - - p - if isOwner - button.btn.btn-xs.btn-warning.delete-clan-btn(data-i18n="clans.delete_clan") Delete Clan - else if isMember - button.btn.btn-xs.btn-warning.leave-clan-btn(data-i18n="clans.leave_clan") Leave Clan - else - button.btn.btn-lg.btn-success.join-clan-btn(data-i18n="clans.join_clan") Join Clan - - if clan.get('ownerID') === me.id || clan.get('type') === 'public' - div - span.spl.spr.join-link-prompt(data-i18n="clans.invite_1") Invite: - input.join-clan-link(type="text", readonly, value="#{joinClanLink}") - .small(data-i18n="clans.invite_2") *Invite players to this Clan by sending them this link. + if arenas && arenas.length + .col-lg-6 + h2(data-i18n="play.campaign_multiplayer") + p(data-i18n="clans.leagues_explanation") + for arena in arenas + h3 + a(href="/play/ladder/#{arena.slug}/clan/#{clan.id}")= i18n(arena, 'name') if members h3 diff --git a/app/templates/play/ladder/ladder-tab-view.jade b/app/templates/play/ladder/ladder-tab-view.jade index 23be6e709..3b24bcfe5 100644 --- a/app/templates/play/ladder/ladder-tab-view.jade +++ b/app/templates/play/ladder/ladder-tab-view.jade @@ -23,12 +23,13 @@ div#columns.row - if(!showJustTop && topSessions.length == 20) topSessions = topSessions.slice(0, 10); for session, rank in topSessions - var myRow = session.get('creator') == me.id + - var sessionStats = league ? (_.find(session.get('leagues') || [], {leagueID: league.id}) || {}).stats || {} : session.attributes; tr(class=myRow ? "success" : "", data-player-id=session.get('creator'), data-session-id=session.id) td.code-language-cell(style="background-image: url(/images/common/code_languages/" + session.get('submittedCodeLanguage') + "_icon.png)" title=capitalize(session.get('submittedCodeLanguage'))) if level.get('type', true) == 'hero-ladder' td.hero-portrait-cell(style="background-image: url(/file/db/thang.type/#{(session.get('heroConfig') || {}).thangType || '529ffbf1cf1818f2be000001'}/portrait.png)") td.rank-cell= rank + 1 - td.score-cell= Math.round(session.get('totalScore') * 100) + td.score-cell= Math.round(sessionStats.totalScore * 100) td.name-col-cell= session.get('creatorName') || "Anonymous" td.fight-cell a(href="/play/level/#{level.get('slug') || level.id}?team=#{team.otherTeam}&opponent=#{session.id}") @@ -41,12 +42,13 @@ div#columns.row td(colspan=4).ellipsis-row ... for session in team.leaderboard.nearbySessions() - var myRow = session.get('creator') == me.id + - var sessionStats = league ? (_.find(session.get('leagues'), {leagueID: league.id}) || {}).stats || {} : session.attributes; tr(class=myRow ? "success" : "", data-player-id=session.get('creator'), data-session-id=session.id) td.code-language-cell(style="background-image: url(/images/common/code_languages/" + session.get('submittedCodeLanguage') + "_icon.png)") if level.get('type', true) == 'hero-ladder' td.hero-portrait-cell(style="background-image: url(/file/db/thang.type/#{(session.get('heroConfig') || {}).thangType || '529ffbf1cf1818f2be000001'}/portrait.png)") td.rank-cell= session.rank - td.score-cell= Math.round(session.get('totalScore') * 100) + td.score-cell= Math.round(sessionStats.totalScore * 100) td.name-col-cell= session.get('creatorName') || "Anonymous" td.fight-cell a(href="/play/level/#{level.get('slug') || level.id}?team=#{team.otherTeam}&opponent=#{session.id}") diff --git a/app/templates/play/ladder/ladder.jade b/app/templates/play/ladder/ladder.jade index d0a7829b6..dd085a269 100644 --- a/app/templates/play/ladder/ladder.jade +++ b/app/templates/play/ladder/ladder.jade @@ -11,7 +11,9 @@ block content h1= level.get('name') if league - h1 #{league.get('name')} League + h1 + a(href="/#{leagueType == 'clan' ? 'clans' : leagueType}/#{league.id}")= league.get('name') + span.spl(data-i18n="ladder.league") League if level.get('name') == 'Greed' .tournament-blurb diff --git a/app/templates/play/ladder/simulate_tab.jade b/app/templates/play/ladder/simulate_tab.jade index dda0f4d02..b7adb8e64 100644 --- a/app/templates/play/ladder/simulate_tab.jade +++ b/app/templates/play/ladder/simulate_tab.jade @@ -3,6 +3,7 @@ p(id="simulation-status-text") | #{simulationStatus} else span(data-i18n="ladder.simulation_explanation") By simulating games you can get your game ranked faster! + span.spl(data-i18n="ladder.simulation_explanation_leagues") You will mainly help simulate games for allied players in your clans and courses. p button(data-i18n="ladder.simulate_games").btn.btn-warning.btn-lg.highlight#simulate-button Simulate Games! diff --git a/app/views/clans/ClanDetailsView.coffee b/app/views/clans/ClanDetailsView.coffee index 0ffa55e40..dd2e82821 100644 --- a/app/views/clans/ClanDetailsView.coffee +++ b/app/views/clans/ClanDetailsView.coffee @@ -10,6 +10,7 @@ LevelSession = require 'models/LevelSession' SubscribeModal = require 'views/core/SubscribeModal' ThangType = require 'models/ThangType' User = require 'models/User' +utils = require 'core/utils' # TODO: Add message for clan not found # TODO: Progress visual for premium levels? @@ -60,7 +61,7 @@ module.exports = class ClanDetailsView extends RootView @listenTo @memberAchievements, 'sync', @onMemberAchievementsSync @listenTo @memberSessions, 'sync', @onMemberSessionsSync - @supermodel.loadModel @campaigns, 'clan', cache: false + @supermodel.loadModel @campaigns, 'campaigns', cache: false @supermodel.loadModel @clan, 'clan', cache: false @supermodel.loadCollection(@members, 'members', {cache: false}) @supermodel.loadCollection(@memberAchievements, 'member_achievements', {cache: false}) @@ -120,6 +121,8 @@ module.exports = class ClanDetailsView extends RootView context.lastUserCampaignLevelMap = lastUserCampaignLevelMap context.showExpandedProgress = maxLastUserCampaignLevel <= 30 or @showExpandedProgress context.userConceptsMap = userConceptsMap + context.arenas = @arenas + context.i18n = utils.i18n context afterRender: -> @@ -179,21 +182,24 @@ module.exports = class ClanDetailsView extends RootView return unless @campaigns.loaded @campaignLevelProgressions = [] @conceptsProgression = [] + @arenas = [] for campaign in @campaigns.models continue if campaign.get('slug') is 'auditions' campaignLevelProgression = ID: campaign.id slug: campaign.get('slug') - name: campaign.get('fullName') or campaign.get('name') + name: utils.i18n(campaign.attributes, 'fullName') or utils.i18n(campaign.attributes, 'name') levels: [] for levelID, level of campaign.get('levels') campaignLevelProgression.levels.push ID: levelID slug: level.slug - name: level.name + name: utils.i18n level, 'name' if level.concepts? for concept in level.concepts @conceptsProgression.push concept unless concept in @conceptsProgression + if level.type == 'hero-ladder' + @arenas.push level @campaignLevelProgressions.push campaignLevelProgression @render?() diff --git a/app/views/ladder/LadderPlayModal.coffee b/app/views/ladder/LadderPlayModal.coffee index d0c14ba17..6fa076ea6 100644 --- a/app/views/ladder/LadderPlayModal.coffee +++ b/app/views/ladder/LadderPlayModal.coffee @@ -43,11 +43,14 @@ module.exports = class LadderPlayModal extends ModalView # PART 1: Load challengers from the db unless some are in the matches startLoadingChallengersMaybe: -> - matches = @session?.get('matches') + if @options.league + matches = _.find(@session?.get('leagues'), leagueID: @options.league.id)?.stats.matches + else + matches = @session?.get('matches') if matches?.length then @loadNames() else @loadChallengers() loadChallengers: -> - @challengersCollection = new ChallengersData(@level, @team, @otherTeam, @session) + @challengersCollection = new ChallengersData(@level, @team, @otherTeam, @session, @options.league) @listenTo(@challengersCollection, 'sync', @loadNames) # PART 2: Loading the names of the other users @@ -156,7 +159,10 @@ module.exports = class LadderPlayModal extends ModalView mediumInfo = @challengeInfoFromSession(@challengersCollection.mediumPlayer.models[0]) hardInfo = @challengeInfoFromSession(@challengersCollection.hardPlayer.models[0]) else - matches = @session.get('matches') + if @options.league + matches = _.find(@session?.get('leagues'), leagueID: @options.league.id)?.stats.matches + else + matches = @session?.get('matches') won = (m for m in matches when m.metrics.rank < m.opponents[0].metrics.rank) lost = (m for m in matches when m.metrics.rank > m.opponents[0].metrics.rank) tied = (m for m in matches when m.metrics.rank is m.opponents[0].metrics.rank) @@ -195,18 +201,26 @@ module.exports = class LadderPlayModal extends ModalView } class ChallengersData - constructor: (@level, @team, @otherTeam, @session) -> + constructor: (@level, @team, @otherTeam, @session, @league) -> _.extend @, Backbone.Events - score = @session?.get('totalScore') or 25 - @easyPlayer = new LeaderboardCollection(@level, {order: 1, scoreOffset: score - 5, limit: 1, team: @otherTeam}) - @easyPlayer.fetch cache: false - @listenToOnce(@easyPlayer, 'sync', @challengerLoaded) - @mediumPlayer = new LeaderboardCollection(@level, {order: 1, scoreOffset: score, limit: 1, team: @otherTeam}) - @mediumPlayer.fetch cache: false - @listenToOnce(@mediumPlayer, 'sync', @challengerLoaded) - @hardPlayer = new LeaderboardCollection(@level, {order: -1, scoreOffset: score + 5, limit: 1, team: @otherTeam}) - @hardPlayer.fetch cache: false - @listenToOnce(@hardPlayer, 'sync', @challengerLoaded) + if @league + score = _.find(@session?.get('leagues'), leagueID: @league.id)?.stats?.totalScore or 10 + else + score = @session?.get('totalScore') or 10 + for player in [ + {type: 'easyPlayer', order: 1, scoreOffset: score - 5} + {type: 'mediumPlayer', order: 1, scoreOffset: score} + {type: 'hardPlayer', order: -1, scoreOffset: score + 5} + ] + playerResource = @[player.type] = new LeaderboardCollection(@level, @collectionParameters(order: player.order, scoreOffset: player.scoreOffset)) + playerResource.fetch cache: false + @listenToOnce playerResource, 'sync', @challengerLoaded + + collectionParameters: (parameters) -> + parameters.team = @otherTeam + parameters.limit = 1 + parameters['leagues.leagueID'] = @league.id if @league + parameters challengerLoaded: -> if @allLoaded() diff --git a/app/views/ladder/LadderTabView.coffee b/app/views/ladder/LadderTabView.coffee index c87729879..d3859cb4c 100644 --- a/app/views/ladder/LadderTabView.coffee +++ b/app/views/ladder/LadderTabView.coffee @@ -183,6 +183,8 @@ module.exports = class LadderTabView extends CocoView ctx.onFacebook = @facebookStatus is 'connected' ctx.onGPlus = application.gplusHandler.loggedIn ctx.capitalize = _.string.capitalize + ctx.league = @options.league + ctx._ = _ ctx generateHistogram: (histogramElement, histogramData, teamName) -> @@ -229,8 +231,11 @@ module.exports = class LadderTabView extends CocoView .attr('x', 1) .attr('width', width/20) .attr('height', (d) -> height - y(d.y)) - if @leaderboards[teamName].session? - playerScore = @leaderboards[teamName].session.get('totalScore') * 100 + if session = @leaderboards[teamName].session + if @options.league + playerScore = (_.find(session.get('leagues'), {leagueID: @options.league.id})?.stats.totalScore or 10) * 100 + else + playerScore = session.get('totalScore') * 100 scorebar = svg.selectAll('.specialbar') .data([playerScore]) .enter().append('g') @@ -319,14 +324,17 @@ module.exports.LeaderboardData = LeaderboardData = class LeaderboardData extends promises.push @topPlayers.fetch cache: false if @session - score = @session.get('totalScore') or 10 + if @league + score = _.find(@session.get('leagues'), {leagueID: @league.id})?.stats.totalScore or 10 + else + score = @session.get('totalScore') or 10 @playersAbove = new LeaderboardCollection(@level, @collectionParameters(order: 1, scoreOffset: score, limit: 4)) promises.push @playersAbove.fetch cache: false @playersBelow = new LeaderboardCollection(@level, @collectionParameters(order: -1, scoreOffset: score, limit: 4)) promises.push @playersBelow.fetch cache: false level = "#{@level.get('original')}.#{@level.get('version').major}" success = (@myRank) => - loadURL = "/db/level/#{level}/leaderboard_rank?scoreOffset=#{@session.get('totalScore')}&team=#{@team}" + loadURL = "/db/level/#{level}/leaderboard_rank?scoreOffset=#{score}&team=#{@team}" loadURL += '&leagues.leagueID=' + @league.id if @league promises.push $.ajax(loadURL, cache: false, success: success) @promise = $.when(promises...) diff --git a/app/views/ladder/LadderView.coffee b/app/views/ladder/LadderView.coffee index 48edcf5ee..5385da4e7 100644 --- a/app/views/ladder/LadderView.coffee +++ b/app/views/ladder/LadderView.coffee @@ -113,7 +113,7 @@ module.exports = class LadderView extends RootView showPlayModal: (teamID) -> session = (s for s in @sessions.models when s.get('team') is teamID)[0] - modal = new LadderPlayModal({}, @level, session, teamID) + modal = new LadderPlayModal({league: @league}, @level, session, teamID) @openModalView modal onClickedLink: (e) -> diff --git a/app/views/ladder/MyMatchesTabView.coffee b/app/views/ladder/MyMatchesTabView.coffee index 039513150..e6d3c34e7 100644 --- a/app/views/ladder/MyMatchesTabView.coffee +++ b/app/views/ladder/MyMatchesTabView.coffee @@ -129,7 +129,7 @@ module.exports = class MyMatchesTabView extends CocoView statsFromSession: (session) -> return null unless session if @options.league - return _.find(session.get('leagues') or [], leagueID: @options.league.id)?.stats + return _.find(session.get('leagues') or [], leagueID: @options.league.id)?.stats ? {} session.attributes generateScoreLineChart: (wrapperID, scoreHistory, teamName) => diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index 09dfc9b7e..f841cafb7 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -200,7 +200,7 @@ LevelHandler = class LevelHandler extends Handler sortParameters = 'totalScore': req.query.order - selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig', 'leagues.leagueID'] + selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig', 'leagues.leagueID', 'leagues.stats.totalScore'] query = Session .find(sessionsQueryParameters)