diff --git a/app/assets/javascripts/workers/worker_world.js b/app/assets/javascripts/workers/worker_world.js index 2004f7c4e..e40889e04 100644 --- a/app/assets/javascripts/workers/worker_world.js +++ b/app/assets/javascripts/workers/worker_world.js @@ -319,6 +319,7 @@ self.setupDebugWorldToRunUntilFrame = function (args) { self.debugWorld.submissionCount = args.submissionCount; self.debugWorld.fixedSeed = args.fixedSeed; self.debugWorld.flagHistory = args.flagHistory; + self.debugWorld.realTimeInputEvents = args.realTimeInputEvents; self.debugWorld.difficulty = args.difficulty; if (args.level) self.debugWorld.loadFromLevel(args.level, true); @@ -381,6 +382,7 @@ self.runWorld = function runWorld(args) { self.world.submissionCount = args.submissionCount; self.world.fixedSeed = args.fixedSeed; self.world.flagHistory = args.flagHistory || []; + self.world.realTimeInputEvents = args.realTimeInputEvents || []; self.world.difficulty = args.difficulty || 0; if(args.level) self.world.loadFromLevel(args.level, true); @@ -539,6 +541,11 @@ self.addFlagEvent = function addFlagEvent(flagEvent) { self.world.addFlagEvent(flagEvent); }; +self.addRealTimeInputEvent = function addRealTimeInputEvent(realTimeInputEvent) { + if(!self.world) return; + self.world.addRealTimeInputEvent(realTimeInputEvent); +}; + self.stopRealTimePlayback = function stopRealTimePlayback() { if(!self.world) return; self.world.realTime = false; diff --git a/app/core/ModuleLoader.coffee b/app/core/ModuleLoader.coffee index 0771ab657..495cd5dd4 100644 --- a/app/core/ModuleLoader.coffee +++ b/app/core/ModuleLoader.coffee @@ -60,6 +60,11 @@ module.exports = ModuleLoader = class ModuleLoader extends CocoClass # load dependencies if it's not a vendor library if not _.string.startsWith(e.item.id, 'vendor') have = window.require.list() + haveWithIndexRemoved = _(have) + .filter (file) -> _.string.endsWith(file, 'index') + .map (file) -> file.slice(0,-6) + .value() + have = have.concat(haveWithIndexRemoved) console.group('Dependencies', e.item.id) if LOG @recentLoadedBytes += e.rawResult.length dependencies = @parseDependencies(e.rawResult) diff --git a/app/lib/Angel.coffee b/app/lib/Angel.coffee index 923177eeb..7279424b0 100644 --- a/app/lib/Angel.coffee +++ b/app/lib/Angel.coffee @@ -37,6 +37,7 @@ module.exports = class Angel extends CocoClass @allLogs = [] @hireWorker() @shared.angels.push @ + @listenTo @shared.gameUIState.get('realTimeInputEvents'), 'add', @onAddRealTimeInputEvent destroy: -> @fireWorker false @@ -266,6 +267,10 @@ module.exports = class Angel extends CocoClass return unless @running and @work.realTime @worker.postMessage func: 'addFlagEvent', args: e + onAddRealTimeInputEvent: (realTimeInputEvent) -> + return unless @running and @work.realTime + @worker.postMessage func: 'addRealTimeInputEvent', args: realTimeInputEvent.toJSON() + onStopRealTimePlayback: (e) -> return unless @running and @work.realTime @work.realTime = false diff --git a/app/lib/God.coffee b/app/lib/God.coffee index 29d055192..1371958f7 100644 --- a/app/lib/God.coffee +++ b/app/lib/God.coffee @@ -6,6 +6,7 @@ World = require 'lib/world/world' CocoClass = require 'core/CocoClass' Angel = require 'lib/Angel' +GameUIState = require 'models/GameUIState' module.exports = class God extends CocoClass @nicks: ['Athena', 'Baldr', 'Crom', 'Dagr', 'Eris', 'Freyja', 'Great Gish', 'Hades', 'Ishtar', 'Janus', 'Khronos', 'Loki', 'Marduk', 'Negafook', 'Odin', 'Poseidon', 'Quetzalcoatl', 'Ra', 'Shiva', 'Thor', 'Umvelinqangi', 'Týr', 'Vishnu', 'Wepwawet', 'Xipe Totec', 'Yahweh', 'Zeus', '上帝', 'Tiamat', '盘古', 'Phoebe', 'Artemis', 'Osiris', '嫦娥', 'Anhur', 'Teshub', 'Enlil', 'Perkele', 'Chaos', 'Hera', 'Iris', 'Theia', 'Uranus', 'Stribog', 'Sabazios', 'Izanagi', 'Ao', 'Tāwhirimātea', 'Tengri', 'Inmar', 'Torngarsuk', 'Centzonhuitznahua', 'Hunab Ku', 'Apollo', 'Helios', 'Thoth', 'Hyperion', 'Alectrona', 'Eos', 'Mitra', 'Saranyu', 'Freyr', 'Koyash', 'Atropos', 'Clotho', 'Lachesis', 'Tyche', 'Skuld', 'Urðr', 'Verðandi', 'Camaxtli', 'Huhetotl', 'Set', 'Anu', 'Allah', 'Anshar', 'Hermes', 'Lugh', 'Brigit', 'Manannan Mac Lir', 'Persephone', 'Mercury', 'Venus', 'Mars', 'Azrael', 'He-Man', 'Anansi', 'Issek', 'Mog', 'Kos', 'Amaterasu Omikami', 'Raijin', 'Susanowo', 'Blind Io', 'The Lady', 'Offler', 'Ptah', 'Anubis', 'Ereshkigal', 'Nergal', 'Thanatos', 'Macaria', 'Angelos', 'Erebus', 'Hecate', 'Hel', 'Orcus', 'Ishtar-Deela Nakh', 'Prometheus', 'Hephaestos', 'Sekhmet', 'Ares', 'Enyo', 'Otrera', 'Pele', 'Hadúr', 'Hachiman', 'Dayisun Tngri', 'Ullr', 'Lua', 'Minerva'] @@ -18,15 +19,17 @@ module.exports = class God extends CocoClass constructor: (options) -> options ?= {} @retrieveValueFromFrame = _.throttle @retrieveValueFromFrame, 1000 + @gameUIState ?= options.gameUIState or new GameUIState() super() # Angels are all given access to this. - @angelsShare = + @angelsShare = { workerCode: options.workerCode or '/javascripts/workers/worker_world.js' # Either path or function headless: options.headless # Whether to just simulate the goals, or to deserialize all simulation results spectate: options.spectate god: @ godNick: @nick + @gameUIState workQueue: [] firstWorld: true world: undefined @@ -34,6 +37,7 @@ module.exports = class God extends CocoClass worldClassMap: undefined angels: [] busyAngels: [] # Busy angels will automatically register here. + } # Determine how many concurrent Angels/web workers to use at a time # ~20MB per idle worker + angel overhead - every Angel maps to 1 worker diff --git a/app/lib/surface/Surface.coffee b/app/lib/surface/Surface.coffee index 7e6cf6482..01980a2d8 100644 --- a/app/lib/surface/Surface.coffee +++ b/app/lib/surface/Surface.coffee @@ -92,6 +92,8 @@ module.exports = Surface = class Surface extends CocoClass @gameUIState = @options.gameUIState or new GameUIState({ canDragCamera: true }) + @realTimeInputEvents = @gameUIState.get('realTimeInputEvents') + @listenTo(@gameUIState, 'sprite:mouse-down', @onSpriteMouseDown) @initEasel() @initAudio() @onResize = _.debounce @onResize, resizeDelay @@ -510,6 +512,15 @@ module.exports = Surface = class Surface extends CocoClass @gameUIState.trigger('surface:stage-mouse-down', event) @mouseIsDown = true + onSpriteMouseDown: (e) => + return unless @realTime + @realTimeInputEvents.add({ + type: 'mousedown' + pos: @camera.screenToWorld x: e.originalEvent.stageX, y: e.originalEvent.stageY + time: @world.dt * @world.frames.length + thangID: e.sprite.thang.id + }) + onMouseUp: (e) => return if @disabled onBackground = not @webGLStage.hitTest e.stageX, e.stageY @@ -596,6 +607,7 @@ module.exports = Surface = class Surface extends CocoClass onRealTimePlaybackStarted: (e) -> return if @realTime + @realTimeInputEvents.reset() @realTime = true @onResize() @playing = false # Will start when countdown is done. diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee index 266f4bc1e..cff8ad5be 100644 --- a/app/lib/world/world.coffee +++ b/app/lib/world/world.coffee @@ -216,6 +216,9 @@ module.exports = class World addFlagEvent: (flagEvent) -> @flagHistory.push flagEvent + addRealTimeInputEvent: (realTimeInputEvent) -> + @realTimeInputEvents.push realTimeInputEvent + loadFromLevel: (level, willSimulate=true) -> @levelID = level.slug @levelComponents = level.levelComponents diff --git a/app/models/GameUIState.coffee b/app/models/GameUIState.coffee index 6d86e8379..580ff7652 100644 --- a/app/models/GameUIState.coffee +++ b/app/models/GameUIState.coffee @@ -26,4 +26,5 @@ module.exports = class GameUIState extends CocoModel defaults: -> { selected: [] canDragCamera: true + realTimeInputEvents: new Backbone.Collection() } diff --git a/app/views/editor/verifier/VerifierTest.coffee b/app/views/editor/verifier/VerifierTest.coffee index d12dc6625..7c84e7389 100644 --- a/app/views/editor/verifier/VerifierTest.coffee +++ b/app/views/editor/verifier/VerifierTest.coffee @@ -48,6 +48,7 @@ module.exports = class VerifierTest extends CocoClass session.set 'code', {'hero-placeholder': plan: session.solution.source} state = session.get 'state' state.flagHistory = session.solution.flagHistory + state.realTimeInputEvents = session.solution.realTimeInputEvents state.difficulty = session.solution.difficulty or 0 session.solution.seed = undefined unless _.isNumber session.solution.seed # TODO: migrate away from submissionCount/sessionID seed objects catch e diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 913bbe315..b764b6251 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -20,6 +20,7 @@ Article = require 'models/Article' Camera = require 'lib/surface/Camera' AudioPlayer = require 'lib/AudioPlayer' Simulator = require 'lib/simulator/Simulator' +GameUIState = require 'models/GameUIState' # subviews LevelLoadingView = require './LevelLoadingView' @@ -108,6 +109,7 @@ module.exports = class PlayLevelView extends RootView @opponentSessionID = @getQueryVariable('opponent') @opponentSessionID ?= @options.opponent + @gameUIState = new GameUIState() $(window).on 'resize', @onWindowResize @saveScreenshot = _.throttle @saveScreenshot, 30000 @@ -137,7 +139,7 @@ module.exports = class PlayLevelView extends RootView load: -> @loadStartTime = new Date() - @god = new God() + @god = new God({@gameUIState}) levelLoaderOptions = supermodel: @supermodel, levelID: @levelID, sessionID: @sessionID, opponentSessionID: @opponentSessionID, team: @getQueryVariable('team'), observing: @observing, courseID: @courseID if me.isSessionless() levelLoaderOptions.fakeSessionConfig = {} @@ -345,12 +347,14 @@ module.exports = class PlayLevelView extends RootView initSurface: -> webGLSurface = $('canvas#webgl-surface', @$el) normalSurface = $('canvas#normal-surface', @$el) - surfaceOptions = + surfaceOptions = { thangTypes: @supermodel.getModels(ThangType) - observing: @observing + @observing playerNames: @findPlayerNames() levelType: @level.get('type', true) stayVisible: @showAds() + @gameUIState + } @surface = new Surface(@world, normalSurface, webGLSurface, surfaceOptions) worldBounds = @world.getBounds() bounds = [{x: worldBounds.left, y: worldBounds.top}, {x: worldBounds.right, y: worldBounds.bottom}] diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee index 98a11b42d..442b8cf4b 100644 --- a/app/views/play/level/tome/TomeView.coffee +++ b/app/views/play/level/tome/TomeView.coffee @@ -202,7 +202,7 @@ module.exports = class TomeView extends CocoView spell = @spellFor thang, spellName unless spell?.canRead() @clearSpellView() - @updateSpellPalette thang, spell + @updateSpellPalette thang, spell if spell return unless spell.view is @spellView @clearSpellView() diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index 1e8e7d5a2..45f12436f 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -13,6 +13,8 @@ User = require '../models/User' Classroom = require '../models/Classroom' facebook = require '../lib/facebook' gplus = require '../lib/gplus' +TrialRequest = require '../models/TrialRequest' +log = require 'winston' module.exports = fetchByGPlusID: wrap (req, res, next) -> @@ -133,16 +135,7 @@ module.exports = throw new errors.Conflict('Email already taken') req.user.set({ password, email, anonymous: false }) - try - yield req.user.save() - catch e - if e.code is 11000 # Duplicate key error - throw new errors.Conflict('Email already taken') - else - throw e - - req.user.sendWelcomeEmail() - res.status(200).send(req.user.toObject({req: req})) + yield module.exports.finishSignup(req, res) signupWithFacebook: wrap (req, res) -> unless req.user.isAnonymous() @@ -159,16 +152,7 @@ module.exports = throw new errors.UnprocessableEntity('Invalid facebookAccessToken') req.user.set({ facebookID, email, anonymous: false }) - try - yield req.user.save() - catch e - if e.code is 11000 # Duplicate key error - throw new errors.Conflict('Email already taken') - else - throw e - - req.user.sendWelcomeEmail() - res.status(200).send(req.user.toObject({req: req})) + yield module.exports.finishSignup(req, res) signupWithGPlus: wrap (req, res) -> unless req.user.isAnonymous() @@ -186,6 +170,9 @@ module.exports = throw new errors.UnprocessableEntity('Invalid gplusAccessToken') req.user.set({ gplusID, email, anonymous: false }) + yield module.exports.finishSignup(req, res) + + finishSignup: co.wrap (req, res) -> try yield req.user.save() catch e @@ -194,5 +181,21 @@ module.exports = else throw e + # post-successful account signup tasks + req.user.sendWelcomeEmail() + + # If person A creates a trial request without creating an account, then person B uses that computer + # to create an account, then person A's trial request is associated with person B's account. To prevent + # this, we check that the signup email matches the trial request email, for every signup. If they do + # not match, the trial request applicant field is cleared, disassociating the trial request from this + # account. + trialRequest = yield TrialRequest.findOne({applicant: req.user._id}) + if trialRequest + email = trialRequest.get('properties')?.email or '' + emailLower = email.toLowerCase() + if emailLower and emailLower isnt req.user.get('emailLower') + log.warn('User submitted trial request and created account with different emails. Disassociating trial request.') + yield trialRequest.update({$unset: {applicant: ''}}) + res.status(200).send(req.user.toObject({req: req})) diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee index e0599cfeb..cc5123f48 100644 --- a/spec/server/functional/user.spec.coffee +++ b/spec/server/functional/user.spec.coffee @@ -3,6 +3,7 @@ utils = require '../utils' urlUser = '/db/user' User = require '../../../server/models/User' Classroom = require '../../../server/models/Classroom' +TrialRequest = require '../../../server/models/TrialRequest' Prepaid = require '../../../server/models/Prepaid' request = require '../request' facebook = require '../../../server/lib/facebook' @@ -706,6 +707,32 @@ describe 'POST /db/user/:handle/signup-with-password', -> [res, body] = yield request.postAsync({url, json}) expect(res.statusCode).toBe(409) done() + + it 'disassociates the user from their trial request if the trial request email and signup email do not match', utils.wrap (done) -> + user = yield utils.becomeAnonymous() + trialRequest = yield utils.makeTrialRequest({ properties: { email: 'one@email.com' } }) + expect(trialRequest.get('applicant').equals(user._id)).toBe(true) + url = getURL("/db/user/#{user.id}/signup-with-password") + email = 'two@email.com' + json = { email, password: '12345' } + [res, body] = yield request.postAsync({url, json}) + expect(res.statusCode).toBe(200) + trialRequest = yield TrialRequest.findById(trialRequest.id) + expect(trialRequest.get('applicant')).toBeUndefined() + done() + + it 'does NOT disassociate the user from their trial request if the trial request email and signup email DO match', utils.wrap (done) -> + user = yield utils.becomeAnonymous() + trialRequest = yield utils.makeTrialRequest({ properties: { email: 'one@email.com' } }) + expect(trialRequest.get('applicant').equals(user._id)).toBe(true) + url = getURL("/db/user/#{user.id}/signup-with-password") + email = 'one@email.com' + json = { email, password: '12345' } + [res, body] = yield request.postAsync({url, json}) + expect(res.statusCode).toBe(200) + trialRequest = yield TrialRequest.findById(trialRequest.id) + expect(trialRequest.get('applicant').equals(user._id)).toBe(true) + done() describe 'POST /db/user/:handle/signup-with-facebook', ->