diff --git a/app/lib/LevelBus.coffee b/app/lib/LevelBus.coffee index fa55a612e..fd459244b 100644 --- a/app/lib/LevelBus.coffee +++ b/app/lib/LevelBus.coffee @@ -21,6 +21,7 @@ module.exports = class LevelBus extends Bus 'thang-code-ran': 'onCodeRan' 'level-show-victory': 'onVictory' 'tome:spell-changed': 'onSpellChanged' + 'tome:spell-created': 'onSpellCreated' constructor: -> super(arguments...) @@ -91,12 +92,26 @@ module.exports = class LevelBus extends Bus code = @session.get('code') code ?= {} parts = e.spell.spellKey.split('/') + code[parts[0]] ?= {} code[parts[0]][parts[1]] = e.spell.getSource() @changedSessionProperties.code = true @session.set({'code': code}) @saveSession() + onSpellCreated: (e) -> + return unless @onPoint() + spellTeam = e.spell.team + @teamSpellMap[spellTeam] ?= [] + + unless e.spell.spellKey in @teamSpellMap[spellTeam] + @teamSpellMap[spellTeam].push e.spell.spellKey + @changedSessionProperties.teamSpells = true + @session.set({'teamSpells': @teamSpellMap}) + @saveSession() + + + onScriptStateChanged: (e) -> return unless @onPoint() @fireScriptsRef?.update(e) @@ -220,4 +235,12 @@ module.exports = class LevelBus extends Bus destroy: -> super() - @session.off 'change:multiplayer', @onMultiplayerChanged, @ \ No newline at end of file + @session.off 'change:multiplayer', @onMultiplayerChanged, @ + + setTeamSpellMap: (spellMap) -> + @teamSpellMap = spellMap + console.log @teamSpellMap + @changedSessionProperties.teamSpells = true + @session.set({'teamSpells': @teamSpellMap}) + @saveSession() + diff --git a/app/lib/Router.coffee b/app/lib/Router.coffee index 927401749..613122fe5 100644 --- a/app/lib/Router.coffee +++ b/app/lib/Router.coffee @@ -20,6 +20,9 @@ module.exports = class CocoRouter extends Backbone.Router 'db/*path': 'routeToServer' 'file/*path': 'routeToServer' + 'play/level/:levelID/leaderboard/:teamID/:startRank/:endRank': 'getPaginatedLevelRank' + 'play/level/:levelID/player/:playerID': 'getPlayerLevelInfo' + # most go through here '*name': 'general' @@ -27,6 +30,13 @@ module.exports = class CocoRouter extends Backbone.Router general: (name) -> @openRoute(name) + getPaginatedLevelRank: (levelID,teamID,startRank,endRank) -> + return + + getPlayerLevelInfo: (levelID,playerID) -> + return + + editorModelView: (modelName, slugOrId, subview) -> modulePrefix = "views/editor/#{modelName}/" suffix = subview or (if slugOrId then 'edit' else 'home') diff --git a/app/lib/world/GoalManager.coffee b/app/lib/world/GoalManager.coffee index 62655b724..315639762 100644 --- a/app/lib/world/GoalManager.coffee +++ b/app/lib/world/GoalManager.coffee @@ -135,7 +135,6 @@ module.exports = class GoalManager extends CocoClass status: null # should eventually be either 'success', 'failure', or 'incomplete' keyFrame: 0 # when it became a 'success' or 'failure' } - @initGoalState(state, [goal.killThangs, goal.saveThangs], 'killed') @initGoalState(state, [goal.getToLocations?.who, goal.keepFromLocations?.who], 'arrived') @initGoalState(state, [goal.leaveOffSides?.who, goal.keepFromLeavingOffSides?.who], 'left') diff --git a/app/templates/home.jade b/app/templates/home.jade index e1c8b23fe..fa290ca98 100644 --- a/app/templates/home.jade +++ b/app/templates/home.jade @@ -25,4 +25,6 @@ block content div.homepage_button a#beginner-campaign(href="/play/level/rescue-mission") canvas(width="125", height="150") - button(data-i18n="home.play").btn.btn-warning.btn-lg.highlight Play \ No newline at end of file + button(data-i18n="home.play").btn.btn-warning.btn-lg.highlight Play + if me.isAdmin() + button.btn.btn-warning.btn-lg.highlight#simulate-button SIMULATE \ No newline at end of file diff --git a/app/views/home_view.coffee b/app/views/home_view.coffee index 200de4831..359b3d935 100644 --- a/app/views/home_view.coffee +++ b/app/views/home_view.coffee @@ -2,6 +2,10 @@ View = require 'views/kinds/RootView' template = require 'templates/home' WizardSprite = require 'lib/surface/WizardSprite' ThangType = require 'models/ThangType' +LevelLoader = require 'lib/LevelLoader' +God = require 'lib/God' + +GoalManager = require 'lib/world/GoalManager' module.exports = class HomeView extends View id: 'home-view' @@ -10,6 +14,7 @@ module.exports = class HomeView extends View events: 'mouseover #beginner-campaign': 'onMouseOverButton' 'mouseout #beginner-campaign': 'onMouseOutButton' + 'click #simulate-button': 'onSimulateButtonClick' getRenderData: -> c = super() @@ -97,4 +102,91 @@ module.exports = class HomeView extends View destroy: -> super() - @wizardSprite?.destroy() \ No newline at end of file + @wizardSprite?.destroy() + + onSimulateButtonClick: (e) => + $.get "/queue/scoring", (data) => + levelName = data.sessions[0].levelID + #TODO: Refactor. So much refactor. + world = {} + god = new God() + levelLoader = new LevelLoader(levelName, @supermodel, data.sessions[0].sessionID) + levelLoader.once 'loaded-all', => + world = levelLoader.world + level = levelLoader.level + levelLoader.destroy() + god.level = level.serialize @supermodel + god.worldClassMap = world.classMap + god.goalManager = new GoalManager(world) + #move goals in here + goalsToAdd = god.goalManager.world.scripts[0].noteChain[0].goals.add + god.goalManager.goals = goalsToAdd + god.goalManager.goalStates = + "destroy-humans": + keyFrame: 0 + killed: + "Human Base": false + status: "incomplete" + "destroy-ogres": + keyFrame:0 + killed: + "Ogre Base": false + status: "incomplete" + god.spells = @filterProgrammableComponents level.attributes.thangs, @generateSpellToSourceMap data.sessions + god.createWorld() + + Backbone.Mediator.subscribe 'god:new-world-created', @onWorldCreated, @ + + onWorldCreated: (data) -> + console.log "GOAL STATES" + console.log data + + + filterProgrammableComponents: (thangs, spellToSourceMap) => + spells = {} + for thang in thangs + isTemplate = false + for component in thang.components + if component.config? and _.has component.config,'programmableMethods' + for methodName, method of component.config.programmableMethods + if typeof method is 'string' + isTemplate = true + break + + pathComponents = [thang.id,methodName] + pathComponents[0] = _.string.slugify pathComponents[0] + spellKey = pathComponents.join '/' + spells[spellKey] ?= {} + spells[spellKey].thangs ?= {} + spells[spellKey].name = methodName + thangID = _.string.slugify thang.id + spells[spellKey].thangs[thang.id] ?= {} + spells[spellKey].thangs[thang.id].aether = @createAether methodName, method + if spellToSourceMap[thangID]? then source = spellToSourceMap[thangID][methodName] else source = "" + spells[spellKey].thangs[thang.id].aether.transpile source + if isTemplate + break + + spells + + createAether : (methodName, method) -> + aetherOptions = + functionName: methodName + protectAPI: false + includeFlow: false + return new Aether aetherOptions + + generateSpellToSourceMap: (sessions) -> + spellKeyToSourceMap = {} + spellSources = {} + for session in sessions + teamSpells = session.teamSpells[session.team] + _.merge spellSources, _.pick(session.code, teamSpells) + + #merge common ones, this overwrites until the last session + commonSpells = session.teamSpells["common"] + if commonSpells? + _.merge spellSources, _.pick(session.code, commonSpells) + + spellSources + diff --git a/app/views/play/level/tome/spell.coffee b/app/views/play/level/tome/spell.coffee index df5b1ccea..cee5cf382 100644 --- a/app/views/play/level/tome/spell.coffee +++ b/app/views/play/level/tome/spell.coffee @@ -26,12 +26,16 @@ module.exports = class Spell @view.render() # Get it ready and code loaded in advance @tabView = new SpellListTabEntryView spell: @, supermodel: @supermodel @tabView.render() + @team = @permissions.readwrite[0] ? "common" + Backbone.Mediator.publish 'tome:spell-created', spell: @ + destroy: -> @view.destroy() @tabView.destroy() @thangs = null + addThang: (thang) -> if @thangs[thang.id] @thangs[thang.id].thang = thang diff --git a/app/views/play/level/tome/tome_view.coffee b/app/views/play/level/tome/tome_view.coffee index 69c856718..4ae9ca057 100644 --- a/app/views/play/level/tome/tome_view.coffee +++ b/app/views/play/level/tome/tome_view.coffee @@ -62,6 +62,7 @@ module.exports = class TomeView extends View @spellList = @insertSubView new SpellListView spells: @spells, supermodel: @supermodel @thangList = @insertSubView new ThangListView spells: @spells, thangs: @options.thangs, supermodel: @supermodel @castButton = @insertSubView new CastButtonView spells: @spells + @teamSpellMap = @generateTeamSpellMap(@spells) else @cast() console.warn "Warning: There are no Programmable Thangs in this level, which makes it unplayable." @@ -74,6 +75,21 @@ module.exports = class TomeView extends View @thangList.adjustThangs @spells, thangs @spellList.adjustSpells @spells + generateTeamSpellMap: (spellObject) -> + teamSpellMap = {} + for spellName, spell of spellObject + teamName = spell.team + teamSpellMap[teamName] ?= [] + + spellNameElements = spellName.split '/' + thangName = spellNameElements[0] + spellName = spellNameElements[1] + + teamSpellMap[teamName].push thangName if thangName not in teamSpellMap[teamName] + + return teamSpellMap + + createSpells: (programmableThangs, world) -> pathPrefixComponents = ['play', 'level', @options.levelID, @options.session.id, 'code'] @spells ?= {} diff --git a/app/views/play/level_view.coffee b/app/views/play/level_view.coffee index 1592c9dfa..8e8bb7962 100644 --- a/app/views/play/level_view.coffee +++ b/app/views/play/level_view.coffee @@ -360,6 +360,7 @@ module.exports = class PlayLevelView extends View register: -> @bus = LevelBus.get(@levelID, @session.id) @bus.setSession(@session) + @bus.setTeamSpellMap @tome.teamSpellMap @bus.connect() if @session.get('multiplayer') onSessionWillSave: (e) -> @@ -391,4 +392,4 @@ module.exports = class PlayLevelView extends View @bus?.destroy() #@instance.save() unless @instance.loading console.profileEnd?() if PROFILE_ME - @session.off 'change:multiplayer', @onMultiplayerChanged, @ \ No newline at end of file + @session.off 'change:multiplayer', @onMultiplayerChanged, @ diff --git a/package.json b/package.json index 384dedabb..e44d4d448 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,9 @@ "express-useragent": "~0.0.9", "gridfs-stream": "0.4.x", "stream-buffers": "0.2.x", - "sendwithus": "2.0.x" + "sendwithus": "2.0.x", + "aws-sdk":"~2.0.0", + "bayesian-battle":"0.0.x" }, "devDependencies": { "jade": "0.33.x", diff --git a/server.coffee b/server.coffee index 8ceda5d8f..6021f2865 100644 --- a/server.coffee +++ b/server.coffee @@ -25,14 +25,3 @@ createAndConfigureApp = -> serverSetup.setupRoutes app app - - - - - - - - - - - diff --git a/server/commons/database.coffee b/server/commons/database.coffee index 2ddc446be..6356fe7dd 100644 --- a/server/commons/database.coffee +++ b/server/commons/database.coffee @@ -7,18 +7,22 @@ testing = '--unittest' in process.argv module.exports.connect = () -> + address = module.exports.generateMongoConnectionString() + winston.info "Connecting to Mongo with connection string #{address}" + + mongoose.connect address + mongoose.connection.once 'open', -> Grid.gfs = Grid(mongoose.connection.db, mongoose.mongo) + + +module.exports.generateMongoConnectionString = -> if config.mongo.mongoose_replica_string address = config.mongo.mongoose_replica_string - winston.info "Connecting to replica set: #{address}" else dbName = config.mongo.db dbName += '_unittest' if testing address = config.mongo.host + ":" + config.mongo.port if config.mongo.username and config.mongo.password address = config.mongo.username + ":" + config.mongo.password + "@" + address - # address = config.mongo.username + "@" + address # if connecting to production server address = "mongodb://#{address}/#{dbName}" - winston.info "Connecting to standalone server #{address}" - mongoose.connect address - mongoose.connection.once 'open', -> - Grid.gfs = Grid(mongoose.connection.db, mongoose.mongo) + + return address \ No newline at end of file diff --git a/server/commons/errors.coffee b/server/commons/errors.coffee index c5345042f..8af347126 100644 --- a/server/commons/errors.coffee +++ b/server/commons/errors.coffee @@ -33,3 +33,11 @@ module.exports.badInput = (res, message='Unprocessable Entity. Bad Input.') -> module.exports.serverError = (res, message='Internal Server Error') -> res.send 500, message res.end() + +module.exports.gatewayTimeoutError = (res, message="Gateway timeout") -> + res.send 504, message + res.end() + +module.exports.clientTimeout = (res, message="The server did not recieve the client response in a timely manner") -> + res.send 408, message + res.end() \ No newline at end of file diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 0a74baa04..2f659811b 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -33,4 +33,5 @@ module.exports.routes = 'routes/languages' 'routes/mail' 'routes/sprites' + 'routes/queue' ] diff --git a/server/commons/queue.coffee b/server/commons/queue.coffee new file mode 100644 index 000000000..6cc1b06ae --- /dev/null +++ b/server/commons/queue.coffee @@ -0,0 +1,283 @@ +config = require '../../server_config' +log = require 'winston' +mongoose = require 'mongoose' +async = require 'async' +aws = require 'aws-sdk' +db = require './database' +mongoose = require 'mongoose' +events = require 'events' +crypto = require 'crypto' + +module.exports.queueClient = undefined + +defaultMessageVisibilityTimeoutInSeconds = 20 +defaultMessageReceiptTimeout = 10 + + +module.exports.initializeQueueClient = (cb) -> + module.exports.queueClient = generateQueueClient() unless queueClient? + + cb?() + + +generateQueueClient = -> + #if config.queue.accessKeyId + if false #TODO: Change this in production + queueClient = new SQSQueueClient() + else + queueClient = new MongoQueueClient() + + +class SQSQueueClient + registerQueue: (queueName, options, callback) -> + queueCreationOptions = + QueueName: queueName + + @sqs.createQueue queueCreationOptions, (err,data) => + @_logAndThrowFatalException "There was an error creating a new SQS queue, reason: #{JSON.stringify err}" if err? + + newQueue = new SQSQueue queueName, data.QueueUrl, @sqs + + callback? err, newQueue + + + constructor: -> + @_configure() + @sqs = @_generateSQSInstance() + + + _configure: -> + aws.config.update + accessKeyId: config.queue.accessKeyId + secretAccessKey: config.queue.secretAccessKey + region: config.queue.region + + + _generateSQSInstance: -> new aws.SQS() + + + _logAndThrowFatalException: (errorMessage) -> + log.error errorMessage + throw new Error errorMessage + + + +class SQSQueue extends events.EventEmitter + constructor: (@queueName, @queueUrl, @sqs) -> + + + subscribe: (eventName, callback) -> @on eventName, callback + unsubscribe: (eventName, callback) -> @removeListener eventName, callback + + + receiveMessage: (callback) -> + queueReceiveOptions = + QueueUrl: @queueUrl + WaitTimeSeconds: defaultMessageReceiptTimeout + + @sqs.receiveMessage queueReceiveOptions, (err, data) => + if err? + @emit 'error',err,originalData + else + originalData = data + data = new SQSMessage originalData, this + @emit 'message',err,data + + callback? err,data + + + deleteMessage: (receiptHandle, callback) -> + queueDeletionOptions = + QueueUrl: @queueUrl + ReceiptHandle: receiptHandle + + @sqs.deleteMessage queueDeletionOptions, (err, data) => + if err? then @emit 'error',err,data else @emit 'message',err,data + + callback? err,data + + + changeMessageVisibilityTimeout: (secondsFromNow, receiptHandle, callback) -> + messageVisibilityTimeoutOptions = + QueueUrl: @queueUrl + ReceiptHandle: receiptHandle + VisibilityTimeout: secondsFromNow + + @sqs.changeMessageVisibility messageVisibilityTimeoutOptions, (err, data) => + if err? then @emit 'error',err,data else @emit 'edited',err,data + + callback? err,data + + + sendMessage: (messageBody, delaySeconds, callback) -> + queueSendingOptions = + QueueUrl: @queueUrl + MessageBody: messageBody + DelaySeconds: delaySeconds + + @sqs.sendMessage queueSendingOptions, (err, data) => + if err? then @emit 'error',err,data else @emit 'sent',err, data + + callback? err,data + + + listenForever: => async.forever (asyncCallback) => @receiveMessage (err, data) -> asyncCallback(null) + + +class SQSMessage + constructor: (@originalMessage, @parentQueue) -> + + isEmpty: -> not @originalMessage.Messages?[0]? + + getBody: -> @originalMessage.Messages[0].Body + + getID: -> @originalMessage.Messages[0].MessageId + + removeFromQueue: (callback) -> @parentQueue.deleteMessage @getReceiptHandle(), callback + + requeue: (callback) -> @parentQueue.changeMessageVisibilityTimeout 0, @getReceiptHandle(), callback + + changeMessageVisibilityTimeout: (secondsFromFunctionCall, callback) -> + @parentQueue.changeMessageVisibilityTimeout secondsFromFunctionCall,@getReceiptHandle(), callback + + getReceiptHandle: -> @originalMessage.Messages[0].ReceiptHandle + + + + +class MongoQueueClient + registerQueue: (queueName, options, callback) -> + newQueue = new MongoQueue queueName,options,@messageModel + callback(null, newQueue) + + + constructor: -> + @_configure() + @_createMongoConnection() + @messageModel = @_generateMessageModel() + + + _configure: -> @databaseAddress = db.generateMongoConnectionString() + + + _createMongoConnection: -> + @mongooseConnection = mongoose.createConnection @databaseAddress + @mongooseConnection.on 'error', -> log.error "There was an error connecting to the queue in MongoDB" + @mongooseConnection.once 'open', -> log.info "Successfully connected to MongoDB queue!" + + + _generateMessageModel: -> + schema = new mongoose.Schema + messageBody: Object, + queue: {type: String, index:true} + scheduledVisibilityTime: {type: Date, index: true} + receiptHandle: {type: String, index: true} + + @mongooseConnection.model 'messageQueue',schema + + +class MongoQueue extends events.EventEmitter + constructor: (queueName, options, messageModel) -> + @Message = messageModel + @queueName = queueName + + + subscribe: (eventName, callback) -> @on eventName, callback + unsubscribe: (eventName, callback) -> @removeListener eventName, callback + + + receiveMessage: (callback) -> + conditions = + queue: @queueName + scheduledVisibilityTime: + $lt: new Date() + + options = + sort: 'scheduledVisibilityTime' + + update = + $set: + receiptHandle: @_generateRandomReceiptHandle() + scheduledVisibilityTime: @_constructDefaultVisibilityTimeoutDate() + + @Message.findOneAndUpdate conditions, update, options, (err, data) => + return @emit 'error',err,data if err? + + originalData = data + data = new MongoMessage originalData, this + @emit 'message',err,data + callback? err,data + + + deleteMessage: (receiptHandle, callback) -> + conditions = + queue: @queueName + receiptHandle: receiptHandle + scheduledVisibilityTime: + $lt: new Date() + + @Message.findOneAndRemove conditions, {}, (err, data) => + if err? then @emit 'error',err,data else @emit 'delete',err,data + + callback? err,data + + + sendMessage: (messageBody, delaySeconds, callback) -> + messageToSend = new @Message + messageBody: messageBody + queue: @queueName + scheduledVisibilityTime: @_constructDefaultVisibilityTimeoutDate delaySeconds + + messageToSend.save (err,data) => + if err? then @emit 'error',err,data else @emit 'sent',err, data + callback? err,data + + changeMessageVisibilityTimeout: (secondsFromNow, receiptHandle, callback) -> + conditions = + queue: @queueName + receiptHandle: receiptHandle + scheduledVisibilityTime: + $lt: new Date() + + update = + $set: + scheduledVisibilityTime: @_constructDefaultVisibilityTimeoutDate secondsFromNow + + @Message.findOneAndUpdate conditions, update, (err, data) => + if err? then @emit 'error',err,data else @emit 'update',err,data + + callback? err, data + + + listenForever: => async.forever (asyncCallback) => @recieveMessage (err, data) -> asyncCallback(null) + + + _constructDefaultVisibilityTimeoutDate: (timeoutSeconds) -> + timeoutSeconds ?= defaultMessageVisibilityTimeoutInSeconds + newDate = new Date() + newDate = new Date(newDate.getTime() + 1000 * timeoutSeconds) + + newDate + + + _generateRandomReceiptHandle: -> crypto.randomBytes(20).toString('hex') + + + +class MongoMessage + constructor: (@originalMessage, @parentQueue) -> + + isEmpty: -> not @originalMessage + + getBody: -> @originalMessage.messageBody + + getID: -> @originalMesage._id + + removeFromQueue: (callback) -> @parentQueue.deleteMessage @getReceiptHandle(), callbacks + + requeue: (callback) -> @parentQueue.changeMessageVisibilityTimeout 0, @getReceiptHandle(), callback + + changeMessageVisibilityTimeout: (secondsFromFunctionCall, callback) -> + @parentQueue.changeMessageVisibilityTimeout secondsFromFunctionCall,@getReceiptHandle(), callback + + getReceiptHandle: -> @originalMessage.receiptHandle \ No newline at end of file diff --git a/server/levels/sessions/level_session_handler.coffee b/server/levels/sessions/level_session_handler.coffee index 01d71358f..4996b8e53 100644 --- a/server/levels/sessions/level_session_handler.coffee +++ b/server/levels/sessions/level_session_handler.coffee @@ -7,7 +7,7 @@ class LevelSessionHandler extends Handler modelClass: LevelSession editableProperties: ['multiplayer', 'players', 'code', 'completed', 'state', 'levelName', 'creatorName', 'levelID', 'screenshot', - 'chat'] + 'chat', 'teamSpells'] getByRelationship: (req, res, args...) -> return @sendNotFoundError(res) unless args.length is 2 and args[1] is 'active' diff --git a/server/levels/sessions/level_session_schema.coffee b/server/levels/sessions/level_session_schema.coffee index 420fd5140..3e6c9f007 100644 --- a/server/levels/sessions/level_session_schema.coffee +++ b/server/levels/sessions/level_session_schema.coffee @@ -55,9 +55,18 @@ _.extend LevelSessionSchema.properties, # TODO: specify this more code: { type: 'object' } + teamSpells: + type: 'object' + additionalProperties: + type: 'array' + + players: { type: 'object' } chat: { type: 'array' } + meanStrength: {type: 'number', default: 25} + standardDeviation: {type:'number', default:25/3, minimum: 0} + totalScore: {type: 'number', default: 10} c.extendBasicProperties LevelSessionSchema, 'level.session' diff --git a/server/queues/scoring.coffee b/server/queues/scoring.coffee new file mode 100644 index 000000000..ccca45d5c --- /dev/null +++ b/server/queues/scoring.coffee @@ -0,0 +1,306 @@ +config = require '../../server_config' +log = require 'winston' +mongoose = require 'mongoose' +async = require 'async' +errors = require '../commons/errors' +aws = require 'aws-sdk' +db = require './../routes/db' +mongoose = require 'mongoose' +queues = require '../commons/queue' +LevelSession = require '../levels/sessions/LevelSession' +TaskLog = require './task/ScoringTask' +bayes = new (require 'bayesian-battle')() + +scoringTaskQueue = undefined +scoringTaskTimeoutInSeconds = 400 + + +module.exports.setup = (app) -> connectToScoringQueue() + +connectToScoringQueue = -> + queues.initializeQueueClient -> + queues.queueClient.registerQueue "scoring", {}, (err,data) -> + throwScoringQueueRegistrationError(err) if err? + scoringTaskQueue = data + log.info "Connected to scoring task queue!" + +throwScoringQueueRegistrationError = (error) -> + log.error "There was an error registering the scoring queue: #{error}" + throw new Error "There was an error registering the scoring queue." + +module.exports.createNewTask = (req, res) -> + scoringTaskQueue.sendMessage req.body, 0, (err, data) -> + return errors.badInput res, "There was an error creating the message, reason: #{err}" if err? + + res.send data + res.end() + + +module.exports.dispatchTaskToConsumer = (req, res) -> + userID = getUserIDFromRequest req,res + return errors.forbidden res, "You need to be logged in to simulate games" if isUserAnonymous req + + scoringTaskQueue.receiveMessage (taskQueueReceiveError, message) -> + if (not message?) or message.isEmpty() or taskQueueReceiveError? + return errors.gatewayTimeoutError res, "No messages were receieved from the queue. Msg:#{taskQueueReceiveError}" + + messageBody = parseTaskQueueMessage req, res, message + return errors.serverError res, "There was an error parsing the queue message" unless messageBody? + + constructTaskObject messageBody, (taskConstructionError, taskObject) -> + return errors.serverError res, "There was an error constructing the scoring task" if taskConstructionError? + + message.changeMessageVisibilityTimeout scoringTaskTimeoutInSeconds + + constructTaskLogObject userID,message.getReceiptHandle(), (taskLogError, taskLogObject) -> + return errors.serverError res, "There was an error creating the task log object." if taskLogError? + + setTaskObjectTaskLogID taskObject, taskLogObject._id + + sendResponseObject req, res, taskObject + + +getUserIDFromRequest = (req) -> if req.user? then return req.user._id else return null + + +isUserAnonymous = (req) -> if req.user? then return req.user.anonymous else return true + + +parseTaskQueueMessage = (req, res, message) -> + try + if typeof message.getBody() is "object" then return message.getBody() + + return messageBody = JSON.parse message.getBody() + catch e + sendResponseObject req, res, {"error":"There was an error parsing the task.Error: #{e}" } + return null + +constructTaskObject = (taskMessageBody, callback) -> + async.map taskMessageBody.sessions, getSessionInformation, (err, sessions) -> + return callback err, data if err? + + taskObject = + "messageGenerated": Date.now() + "sessions": [] + + for session in sessions + sessionInformation = + "sessionID": session.sessionID + "sessionChangedTime": session.changed + "team": session.team ? "No team" + "code": session.code + "teamSpells": session.teamSpells ? {} + "levelID": session.levelID + + taskObject.sessions.push sessionInformation + callback err, taskObject + + +getSessionInformation = (sessionIDString, callback) -> + LevelSession.findOne {"_id": sessionIDString }, (err, session) -> + return callback err, {"error":"There was an error retrieving the session."} if err? + + session = session.toObject() + sessionInformation = + "sessionID": session._id + "code": _.cloneDeep session.code + "changed": session.changed + "creator": session.creator + "team": session.team + "teamSpells": session.teamSpells + "levelID": session.levelID + + callback err, sessionInformation + + +constructTaskLogObject = (calculatorUserID, messageIdentifierString, callback) -> + taskLogObject = new TaskLog + "createdAt": new Date() + "calculator":calculatorUserID + "sentDate": Date.now() + "messageIdentifierString":messageIdentifierString + + taskLogObject.save callback + + +setTaskObjectTaskLogID = (taskObject, taskLogObjectID) -> taskObject.taskID = taskLogObjectID + + +sendResponseObject = (req,res,object) -> + res.setHeader('Content-Type', 'application/json') + res.send(object) + res.end() + +module.exports.processTaskResult = (req, res) -> + clientResponseObject = verifyClientResponse req.body, res + + if clientResponseObject? + TaskLog.findOne {"_id": clientResponseObject.taskID}, (err, taskLog) -> + return errors.serverError res, "There was an error retrieiving the task log object" if err? + + taskLogJSON = taskLog.toObject() + + return errors.badInput res, "That computational task has already been performed" if taskLogJSON.calculationTimeMS + return handleTimedOutTask req, res, clientResponseObject if hasTaskTimedOut taskLogJSON.sentDate + + logTaskComputation clientResponseObject, taskLog, (loggingError) -> + if loggingError? + return errors.serverError res, "There as a problem logging the task computation: #{loggingError}" + + updateScores clientResponseObject, (updatingScoresError, newScores) -> + if updatingScoresError? + return errors.serverError res, "There was an error updating the scores.#{updatingScoresError}" + + sendResponseObject req, res, {"message":"The scores were updated successfully!"} + + + + +hasTaskTimedOut = (taskSentTimestamp) -> taskSentTimestamp + scoringTaskTimeoutInSeconds * 1000 < Date.now() + + +handleTimedOutTask = (req, res, taskBody) -> errors.clientTimeout res, "The results weren't provided within the timeout" + + +verifyClientResponse = (responseObject, res) -> + unless typeof responseObject is "object" + errors.badInput res, "The response to that query is required to be a JSON object." + null + else + responseObject + + +logTaskComputation = (taskObject,taskLogObject, callback) -> + taskLogObject.calculationTimeMS = taskObject.calculationTimeMS + taskLogObject.sessions = taskObject.sessions + taskLogObject.save callback + + +updateScores = (taskObject,callback) -> + sessionIDs = _.pluck taskObject.sessions, 'sessionID' + + async.map sessionIDs, retrieveOldScoreMetrics, (err, oldScores) -> + callback err, {"error": "There was an error retrieving the old scores"} if err? + + oldScoreArray = _.toArray putRankingFromMetricsIntoScoreObject taskObject, oldScores + + newScoreArray = bayes.updatePlayerSkills oldScoreArray + + saveNewScoresToDatabase newScoreArray, callback + + +saveNewScoresToDatabase = (newScoreArray, callback) -> + async.eachSeries newScoreArray, updateScoreInSession, (err) -> + if err? then callback err, null else callback err, {"message":"All scores were saved successfully."} + + +updateScoreInSession = (scoreObject,callback) -> + sessionObjectQuery = + "_id": scoreObject.id + + LevelSession.findOne sessionObjectQuery, (err, session) -> + return callback err, null if err? + + session.meanStrength = scoreObject.meanStrength + session.standardDeviation = scoreObject.standardDeviation + session.totalScore = scoreObject.meanStrength - 1.8 * scoreObject.standardDeviation + + log.info "Saving session #{session._id}!" + + session.save callback + + +putRankingFromMetricsIntoScoreObject = (taskObject,scoreObject) -> + scoreObject = _.indexBy scoreObject, 'id' + + for session in taskObject.sessions + scoreObject[session.sessionID].gameRanking = session.metrics.rank + + scoreObject + +retrieveOldScoreMetrics = (sessionID, callback) -> + sessionQuery = + "_id":sessionID + + LevelSession.findOne sessionQuery, (err, session) -> + return callback err, {"error":"There was an error retrieving the session."} if err? + + defaultScore = (25 - 1.8*(25/3)) + defaultStandardDeviation = 25/3 + + oldScoreObject = + "standardDeviation":session.standardDeviation ? defaultStandardDeviation + "meanStrength":session.meanStrength ? 25 + "totalScore":session.totalScore ? defaultScore + "id": sessionID + + callback err, oldScoreObject + + + + +###Sample Messages +sampleQueueMessage = + { + "sessions": ["52dea9b77e486eeb97000001","52d981a73cf02dcf260003cb"] + } + +sampleUndoneTaskObject = + "taskID": "507f191e810c19729de860ea" + "sessions" : [ + { + "ID":"52dfeb17c8b5f435c7000025" + "sessionChangedTime": "2014-01-22T16:28:12.450Z" + "team":"humans" + "code": "code goes here" + }, + { + "ID":"51eb2714fa058cb20d00fedg" + "sessionChangedTime": "2014-01-22T16:28:12.450Z" + "team":"ogres" + "code": "code goes here" + } + ] +sampleResponseObject = + "taskID": "507f191e810c19729de860ea" + "calculationTime":3201 + "sessions": [ + { + "ID":"52dfeb17c8b5f435c7000025" + "sessionChangedTime": "2014-01-22T16:28:12.450Z" + "metrics": { + "rank":2 + } + }, + { + "ID":"51eb2714fa058cb20d00fedg" + "sessionChangedTime": "2014-01-22T16:28:12.450Z" + "metrics": { + "rank":1 + } + } + ] + +sampleTaskLogObject= +{ + "_id":ObjectId("507f191e810c19729de860ea") #datestamp is built into objectId + "calculatedBy":ObjectId("51eb2714fa058cb20d0006ef") + "calculationTime":3201 + timedOut: false + "sessions":[ + { + "ID":ObjectId("52dfeb17c8b5f435c7000025") + "metrics": { + "rank":2 + } + }, + { + "ID":ObjectId("51eb2714fa058cb20d00feda") + "metrics": { + "rank":1 + } + } + ] +} + +### \ No newline at end of file diff --git a/server/queues/sendwithus.coffee b/server/queues/sendwithus.coffee new file mode 100644 index 000000000..e69de29bb diff --git a/server/queues/task/ScoringTask.coffee b/server/queues/task/ScoringTask.coffee new file mode 100644 index 000000000..c5147bbef --- /dev/null +++ b/server/queues/task/ScoringTask.coffee @@ -0,0 +1,12 @@ +mongoose = require('mongoose') + +ScoringTaskSchema = new mongoose.Schema( + createdAt: {type: Date, expires: 3600} #expire document 1 hour after they are created + calculator: {type:mongoose.Schema.Types.ObjectId} + sentDate: {type: Number} + messageIdentifierString: {type: String} + calculationTimeMS: {type: Number, default: 0} + sessions: {type: Array, default: []} +) + +module.exports = mongoose.model('scoringTask', ScoringTaskSchema) diff --git a/server/routes/queue.coffee b/server/routes/queue.coffee new file mode 100644 index 000000000..f8bfa0613 --- /dev/null +++ b/server/routes/queue.coffee @@ -0,0 +1,50 @@ +log = require 'winston' +errors = require '../commons/errors' +scoringQueue = require '../queues/scoring' + + +module.exports.setup = (app) -> + scoringQueue.setup() + + app.all '/queue/*', (req, res) -> + setResponseHeaderToJSONContentType res + + queueName = getQueueNameFromPath req.path + try + handler = loadQueueHandler queueName + if isHTTPMethodGet req + handler.dispatchTaskToConsumer req,res + else if isHTTPMethodPut req + handler.processTaskResult req,res + else if isHTTPMethodPost req + handler.createNewTask req, res #TODO: do not use this in production + else + sendMethodNotSupportedError req, res + catch error + log.error error + sendQueueError req, res, error + +setResponseHeaderToJSONContentType = (res) -> res.setHeader('Content-Type', 'application/json') + +getQueueNameFromPath = (path) -> + pathPrefix = '/queue/' + pathAfterPrefix = path[pathPrefix.length..] + partsOfURL = pathAfterPrefix.split '/' + queueName = partsOfURL[0] + queueName + +loadQueueHandler = (queueName) -> require ('../queues/' + queueName) + + +isHTTPMethodGet = (req) -> return req.route.method is 'get' + +isHTTPMethodPost = (req) -> return req.route.method is 'post' + +isHTTPMethodPut = (req) -> return req.route.method is 'put' + + +sendMethodNotSupportedError = (req, res) -> errors.badMethod(res,"Queues do not support the HTTP method used." ) + +sendQueueError = (req,res, error) -> errors.serverError(res, "Route #{req.path} had a problem: #{error}") + + diff --git a/server/sendwithus.coffee b/server/sendwithus.coffee index ef7bcf341..a9bb41bf4 100644 --- a/server/sendwithus.coffee +++ b/server/sendwithus.coffee @@ -1,6 +1,11 @@ config = require '../server_config' sendwithusAPI = require 'sendwithus' swuAPIKey = config.mail.sendwithusAPIKey +queues = require './commons/queue' + +module.exports.setupRoutes = (app) -> + return + options = { DEBUG: not config.isProduction } module.exports.api = new sendwithusAPI swuAPIKey, options diff --git a/server_config.js b/server_config.js index 67140215e..880fcfd78 100644 --- a/server_config.js +++ b/server_config.js @@ -31,6 +31,14 @@ config.mail.mailchimpAPIKey = process.env.COCO_MAILCHIMP_API_KEY || ''; config.mail.mailchimpWebhook = process.env.COCO_MAILCHIMP_WEBHOOK || '/mail/webhook'; config.mail.sendwithusAPIKey = process.env.COCO_SENDWITHUS_API_KEY || ''; +config.queue = {}; +config.queue.accessKeyId = process.env.COCO_AWS_ACCESS_KEY_ID || ''; +config.queue.secretAccessKey = process.env.COCO_AWS_SECRET_ACCESS_KEY || ''; +config.queue.region = 'us-east-1'; +config.queue.simulationQueueName = "simulationQueue"; +config.mongoQueue = {}; +config.mongoQueue.queueDatabaseName = "coco_queue"; + config.salt = process.env.COCO_SALT || 'pepper'; config.cookie_secret = process.env.COCO_COOKIE_SECRET || 'chips ahoy'; diff --git a/server_setup.coffee b/server_setup.coffee index fb90c4460..d0afd259d 100644 --- a/server_setup.coffee +++ b/server_setup.coffee @@ -80,6 +80,7 @@ setupFacebookCrossDomainCommunicationRoute = (app) -> exports.setupRoutes = (app) -> app.use app.router + baseRoute.setup app setupFacebookCrossDomainCommunicationRoute app setupFallbackRouteToIndex app