diff --git a/app/assets/javascripts/workers/worker_debug.js b/app/assets/javascripts/workers/worker_debug.js new file mode 100644 index 000000000..8aef3ee8b --- /dev/null +++ b/app/assets/javascripts/workers/worker_debug.js @@ -0,0 +1,378 @@ +// There's no reason that this file is in JavaScript instead of CoffeeScript. +// We should convert it and update the brunch config. + +// If we wanted to be more robust, we could use this: https://github.com/padolsey/operative/blob/master/src/operative.js +if(typeof window !== 'undefined' || !self.importScripts) + throw "Attempt to load worker_world into main window instead of web worker."; + +// Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind +// This is here for running simuations in enviroments lacking function.bind (PhantomJS mostly) +if (!Function.prototype.bind) { + Function.prototype.bind = function (oThis) { + if (typeof this !== "function") { + // closest thing possible to the ECMAScript 5 internal IsCallable function + throw new TypeError("Function.prototype.bind (Shim) - target is not callable"); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + + return fBound; + }; +} + +// assign global window so that Brunch's require (in world.js) can go into it +self.window = self; +self.workerID = "DebugWorker"; + +self.logLimit = 2000; +self.logsLogged = 0; +var console = { + log: function() { + if(self.logsLogged++ == self.logLimit) + self.postMessage({type: 'console-log', args: ["Log limit " + self.logLimit + " reached; shutting up."], id: self.workerID}); + else if(self.logsLogged < self.logLimit) { + args = [].slice.call(arguments); + for(var i = 0; i < args.length; ++i) { + if(args[i] && args[i].constructor) { + if(args[i].constructor.className === "Thang" || args[i].isComponent) + args[i] = args[i].toString(); + } + } + try { + self.postMessage({type: 'console-log', args: args, id: self.workerID}); + } + catch(error) { + self.postMessage({type: 'console-log', args: ["Could not post log: " + args, error.toString(), error.stack, error.stackTrace], id: self.workerID}); + } + } + }}; // so that we don't crash when debugging statements happen +console.error = console.info = console.log; +self.console = console; + +// We could do way more from this: http://stackoverflow.com/questions/10653809/making-webworkers-a-safe-environment +Object.defineProperty(self, "XMLHttpRequest", { + get: function() { throw new Error("Access to XMLHttpRequest is forbidden."); }, + configurable: false +}); + +self.transferableSupported = function transferableSupported() { + // Not in IE, even in IE 11 + try { + var ab = new ArrayBuffer(1); + worker.postMessage(ab, [ab]); + return ab.byteLength == 0; + } catch(error) { + return false; + } + return false; +} +importScripts('/javascripts/world.js'); + +var World = self.require('lib/world/world'); +var GoalManager = self.require('lib/world/GoalManager'); +serializedClasses = { + "Thang": self.require('lib/world/thang'), + "Vector": self.require('lib/world/vector'), + "Rectangle": self.require('lib/world/rectangle') +} + +self.getCurrentFrame = function getCurrentFrame(args) { return self.world.frames.length; }; + +//optimize this later +self.currentUserCodeMapCopy = {}; +self.currentWorldFrame = 0; + +self.maxSerializationDepth = 3; + +self.stringifyValue = function(value, depth) { + var brackets, i, isArray, isObject, key, prefix, s, sep, size, v, values, _i, _j, _len, _len1, _ref, _ref1, _ref2, _ref3; + if (!value || _.isString(value)) { + return value; + } + if (_.isFunction(value)) { + if (depth === 2) { + return void 0; + } else { + return ""; + } + } + if (value === this.thang && depth) { + return ""; + } + if (depth === 2) { + if (((_ref = value.constructor) != null ? _ref.className : void 0) === "Thang") { + value = "<" + (value.type || value.spriteName) + " - " + value.id + ", " + (value.pos ? value.pos.toString() : 'non-physical') + ">"; + } else { + value = value.toString(); + } + return value; + } + isArray = _.isArray(value); + isObject = _.isObject(value); + if (!(isArray || isObject)) { + return value.toString(); + } + brackets = isArray ? ["[", "]"] : ["{", "}"]; + size = _.size(value); + if (!size) { + return brackets.join(""); + } + values = []; + if (isArray) { + for (_i = 0, _len = value.length; _i < _len; _i++) { + v = value[_i]; + s = this.stringifyValue(v, depth + 1); + if (s !== void 0) { + values.push("" + s); + } + } + } else { + _ref2 = (_ref1 = value.apiProperties) != null ? _ref1 : _.keys(value); + for (_j = 0, _len1 = _ref2.length; _j < _len1; _j++) { + key = _ref2[_j]; + if (key[0] === "_") continue; + s = this.stringifyValue(value[key], depth + 1); + if (s !== void 0) { + values.push(key + ": " + s); + } + } + } + sep = '\n' + ((function() { + var _k, _results; + _results = []; + for (i = _k = 0; 0 <= depth ? _k < depth : _k > depth; i = 0 <= depth ? ++_k : --_k) { + _results.push(" "); + } + return _results; + })()).join(''); + prefix = (_ref3 = value.constructor) != null ? _ref3.className : void 0; + if (isArray) { + if (prefix == null) { + prefix = "Array"; + } + } + if (isObject) { + if (prefix == null) { + prefix = "Object"; + } + } + prefix = prefix ? prefix + " " : ""; + return "" + prefix + brackets[0] + sep + " " + (values.join(sep + ' ')) + sep + brackets[1]; +}; + +var cache = {}; + +self.invalidateCache = function () { + cache = {}; +}; + +self.retrieveValueFromCache = function (thangID, spellID, variableChain, frame) { + var frameCache, thangCache, spellCache; + if ((frameCache = cache[frame]) && (thangCache = frameCache[thangID]) && (spellCache = thangCache[spellID])) + return spellCache[variableChain.join()]; + return undefined; +}; + + +self.updateCache = function (thangID, spellID, variableChain, frame, value) { + var key, keys, currentObject; + keys = [frame,thangID, spellID, variableChain.join()]; + currentObject = cache; + + for (var i = 0, len = keys.length - 1; i < len; i++) + { + key = keys[i]; + if (!(key in currentObject)) + currentObject[key] = {}; + currentObject = currentObject[key]; + } + currentObject[keys[keys.length - 1]] = value; +}; + +self.retrieveValueFromFrame = function retrieveValueFromFrame(args) { + var cacheValue; + if (args.frame === self.currentWorldFrame && (cacheValue = self.retrieveValueFromCache(args.currentThangID, args.currentSpellID, args.variableChain, args.frame))) + return self.postMessage({type: 'debug-value-return', serialized: {"key": args.variableChain.join("."), "value": cacheValue}}); + + + var retrieveProperty = function retrieveProperty(currentThangID, currentSpellID, variableChain) + { + var prop; + var value; + var keys = []; + for (var i = 0, len = variableChain.length; i < len; i++) { + prop = variableChain[i]; + if (prop === "this") + { + value = self.world.thangMap[currentThangID]; + + } + else if (i === 0) + { + try + { + var flowStates = self.world.userCodeMap[currentThangID][currentSpellID].flow.states; + //we have to go to the second last flowState as we run the world for one additional frame + //to collect the flow + value = _.last(flowStates[flowStates.length - 2].statements).variables[prop]; + } + catch (e) + { + value = undefined; + } + + } + else + { + value = value[prop]; + } + keys.push(prop); + if (!value) break; + var classOfValue; + if (classOfValue = serializedClasses[value.CN]) + { + if (value.CN === "Thang") + { + var thang = self.world.thangMap[value.id]; + value = thang || "" + } + else + { + value = classOfValue.deserializeFromAether(value); + } + } + } + var serializedProperty = { + "key": keys.join("."), + "value": self.stringifyValue(value,0) + }; + self.updateCache(currentThangID,currentSpellID,variableChain, args.frame, serializedProperty.value); + self.postMessage({type: 'debug-value-return', serialized: serializedProperty}); + }; + self.enableFlowOnThangSpell(args.currentThangID, args.currentSpellID, args.userCodeMap); + self.setupWorldToRunUntilFrame(args); + self.world.loadFramesUntilFrame( + args.frame, + retrieveProperty.bind({},args.currentThangID, args.currentSpellID, args.variableChain), + self.onWorldError, + self.onWorldLoadProgress + ); +}; + +self.enableFlowOnThangSpell = function enableFlowOnThang(thangID, spellID, userCodeMap) { + try { + if (userCodeMap[thangID][spellID].originalOptions.includeFlow === true && + userCodeMap[thangID][spellID].originalOptions.noSerializationInFlow === true) + return; + else + { + userCodeMap[thangID][spellID].originalOptions.includeFlow = true; + userCodeMap[thangID][spellID].originalOptions.noSerializationInFlow = true; + var temporaryAether = Aether.deserialize(userCodeMap[thangID][spellID]); + temporaryAether.transpile(temporaryAether.raw); + userCodeMap[thangID][spellID] = temporaryAether.serialize(); + } + + } + catch (e) { + console.log("there was an error enabling flow on thang spell:" + e) + } +}; + +self.setupWorldToRunUntilFrame = function setupWorldToRunUntilFrame(args) { + self.postedErrors = {}; + self.t0 = new Date(); + self.firstWorld = args.firstWorld; + self.postedErrors = false; + self.logsLogged = 0; + + var stringifiedUserCodeMap = JSON.stringify(args.userCodeMap); + var userCodeMapHasChanged = ! _.isEqual(self.currentUserCodeMapCopy, stringifiedUserCodeMap); + self.currentUserCodeMapCopy = stringifiedUserCodeMap; + if (!self.world || userCodeMapHasChanged || args.frame != self.currentWorldFrame) { + self.invalidateCache(); + try { + self.world = new World(args.worldName, args.userCodeMap); + if (args.level) + self.world.loadFromLevel(args.level, true); + self.goalManager = new GoalManager(self.world); + self.goalManager.setGoals(args.goals); + self.goalManager.setCode(args.userCodeMap); + self.goalManager.worldGenerationWillBegin(); + self.world.setGoalManager(self.goalManager); + } + catch (error) { + self.onWorldError(error); + return; + } + Math.random = self.world.rand.randf; // so user code is predictable + + self.world.totalFrames = args.frame; //hack to work around error checking + self.currentWorldFrame = args.frame; + } +}; +self.runWorldUntilFrame = function runWorldUntilFrame(args) { + self.setupWorldToRunUntilFrame(args); + + self.world.loadFramesUntilFrame(args.frame, self.onWorldLoaded, self.onWorldError, self.onWorldLoadProgress); + +}; + +self.onWorldLoaded = function onWorldLoaded() { + console.log("World loaded!"); +}; + +self.onWorldError = function onWorldError(error) { + if(error instanceof Aether.problems.UserCodeProblem) { + if(!self.postedErrors[error.key]) { + var problem = error.serialize(); + self.postMessage({type: 'user-code-problem', problem: problem}); + self.postedErrors[error.key] = problem; + } + } + else { + console.log("Non-UserCodeError:", error.toString() + "\n" + error.stack || error.stackTrace); + } + /* We don't actually have the recoverable property any more; hmm + if(!self.firstWorld && !error.recoverable) { + self.abort(); + return false; + } + */ + return true; +}; + +self.onWorldLoadProgress = function onWorldLoadProgress(progress) { + self.postMessage({type: 'world-load-progress-changed', progress: progress}); +}; + +self.abort = function abort() { + if(self.world && self.world.name) { + console.log("About to abort:", self.world.name, typeof self.world.abort); + if(typeof self.world !== "undefined") + self.world.abort(); + self.world = null; + } + self.postMessage({type: 'abort'}); +}; + +self.reportIn = function reportIn() { + self.postMessage({type: 'reportIn'}); +} + +self.addEventListener('message', function(event) { + self[event.data.func](event.data.args); +}); + +self.postMessage({type: 'worker-initialized'}); diff --git a/app/lib/God.coffee b/app/lib/God.coffee index b036b2467..106e1dee5 100644 --- a/app/lib/God.coffee +++ b/app/lib/God.coffee @@ -22,8 +22,13 @@ module.exports = class God @angels = [] @firstWorld = true Backbone.Mediator.subscribe 'tome:cast-spells', @onTomeCast, @ + @retriveValueFromFrame = _.throttle @retrieveValueFromFrame, 1000 + Backbone.Mediator.subscribe 'tome:spell-debug-value-request', @retrieveValueFromFrame, @ @fillWorkerPool = _.throttle @fillWorkerPool, 3000, leading: false @fillWorkerPool() + #TODO: have this as a constructor option + @debugWorker = @createDebugWorker() + @currentUserCodeMap = {} workerCode: '/javascripts/workers/worker_world.js' #Can be a string or a function. @@ -51,6 +56,13 @@ module.exports = class God worker.addEventListener 'message', @onWorkerMessage(worker) worker + createDebugWorker: -> + worker = new Worker '/javascripts/workers/worker_debug.js' + worker.creationTime = new Date() + worker.addEventListener 'message', @onDebugWorkerMessage + worker + + onWorkerMessage: (worker) => unless worker.onMessage? worker.onMessage = (event) => @@ -62,6 +74,18 @@ module.exports = class God console.warn "Received strange word from God: #{event.data.type}" worker.onMessage + onDebugWorkerMessage: (event) => + worker = event.target + switch event.data.type + when "worker-initialized" + worker.initialized = true + when 'new-debug-world' + console.log "New Debug world!" + when 'console-log' + console.log "|" + @id + "'s " + @id + "|", event.data.args... + when 'debug-value-return' + Backbone.Mediator.publish 'god:debug-value-return', event.data.serialized + getAngel: -> freeAngel = null for angel in @angels @@ -113,6 +137,22 @@ module.exports = class God goals: @goalManager?.getGoals() }} + retrieveValueFromFrame: (args) -> + if not args.thangID or not args.spellID or not args.variableChain then return + args.frame ?= @world.age / @world.dt + @debugWorker.postMessage + func: 'retrieveValueFromFrame' + args: + worldName: @level.name + userCodeMap: @currentUserCodeMap + level: @level + firstWorld: @firstWorld + goals: @goalManager?.getGoals() + frame: args.frame + currentThangID: args.thangID + currentSpellID: args.spellID + variableChain: args.variableChain + #Coffeescript needs getters and setters. setGoalManager: (@goalManager) => @@ -144,6 +184,7 @@ module.exports = class God finishBeholdingWorld: (newWorld) => newWorld.findFirstChangedFrame @world @world = newWorld + @currentUserCodeMap = @filterUserCodeMapWhenFromWorld @world.userCodeMap errorCount = (t for t in @world.thangs when t.errorsOut).length Backbone.Mediator.publish('god:new-world-created', world: @world, firstWorld: @firstWorld, errorCount: errorCount, goalStates: @latestGoalStates, team: me.team) for scriptNote in @world.scriptNotes @@ -154,6 +195,23 @@ module.exports = class God unless _.find @angels, 'busy' @spells = null # Don't hold onto old spells; memory leaks + filterUserCodeMapWhenFromWorld: (worldUserCodeMap) -> + newUserCodeMap = {} + for thangName, thang of worldUserCodeMap + newUserCodeMap[thangName] = {} + for spellName,aether of thang + shallowFilteredObject = _.pick aether, ['raw','pure','originalOptions'] + newUserCodeMap[thangName][spellName] = _.cloneDeep shallowFilteredObject + newUserCodeMap[thangName][spellName] = _.defaults newUserCodeMap[thangName][spellName], + flow: {} + metrics: {} + problems: + errors: [] + infos: [] + warnings: [] + style: {} + newUserCodeMap + getUserCodeMap: -> userCodeMap = {} for spellKey, spell of @spells @@ -167,6 +225,10 @@ module.exports = class God @dead = true Backbone.Mediator.unsubscribe('tome:cast-spells', @onTomeCast, @) @goalManager?.destroy() + @debugWorker?.terminate() + @debugWorker?.removeEventListener 'message', @onDebugWorkerMessage + @debugWorker ?= null + @currentUserCodeMap = null @goalManager = null @fillWorkerPool = null @simulateWorld = null diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee index d9ba75179..76bacd6bf 100644 --- a/app/lib/world/world.coffee +++ b/app/lib/world/world.coffee @@ -8,7 +8,6 @@ WorldScriptNote = require './world_script_note' {now, consolidateThangs, typedArraySupport} = require './world_utils' Component = require 'lib/world/component' System = require 'lib/world/system' - PROGRESS_UPDATE_INTERVAL = 200 DESERIALIZATION_INTERVAL = 20 @@ -107,6 +106,35 @@ module.exports = class World loadProgressCallback? 1 loadedCallback() + loadFramesUntilFrame: (frameToLoadUntil, loadedCallback, errorCallback, loadProgressCallback) -> + return if @aborted + unless @thangs.length + console.log "Warning: loadFrames called on empty World" + t1 = now() + @t0 ?= t1 + i = @frames.length + while i <= frameToLoadUntil #state is gathered at next frame + try + @getFrame(i) + ++i # increment this after we have succeeded in getting the frame, otherwise we'll have to do that frame again + catch error + # Not an Aether.errors.UserCodeError; maybe we can't recover + @addError error + for error in (@unhandledRuntimeErrors ? []) + return unless errorCallback error # errorCallback tells us whether the error is recoverable + @unhandledRuntimeErrors = [] + t2 = now() + if t2 - t1 > PROGRESS_UPDATE_INTERVAL + loadProgressCallback? i / @totalFrames + t1 = t2 + if t2 - @t0 > 1000 + console.log(' Loaded', i, 'of', frameToLoadUntil, "(+" + (t2 - @t0).toFixed(0) + "ms)") + @t0 = t2 + setTimeout((=> @loadFrames(loadedCallback, errorCallback, loadProgressCallback)), 0) + return + loadProgressCallback? 1 + loadedCallback() + abort: -> @aborted = true @@ -225,7 +253,7 @@ module.exports = class World @scriptNotes.push scriptNote return unless @goalManager @goalManager.submitWorldGenerationEvent(channel, event, @frames.length) - + setGoalState: (goalID, status) -> @goalManager.setGoalState(goalID, status) diff --git a/app/views/play/level/tome/spell_debug_view.coffee b/app/views/play/level/tome/spell_debug_view.coffee index dbf7a471b..0767b9e88 100644 --- a/app/views/play/level/tome/spell_debug_view.coffee +++ b/app/views/play/level/tome/spell_debug_view.coffee @@ -13,6 +13,8 @@ module.exports = class DebugView extends View subscriptions: 'god:new-world-created': 'onNewWorld' + 'god:debug-value-return': 'handleDebugValue' + 'tome:spell-shown': 'changeCurrentThangAndSpell' events: {} @@ -20,12 +22,26 @@ module.exports = class DebugView extends View super options @ace = options.ace @thang = options.thang + @spell = options.spell @variableStates = {} + @globals = {Math: Math, _: _, String: String, Number: Number, Array: Array, Object: Object} # ... add more as documented - for className, klass of serializedClasses - @globals[className] = klass + for className, serializedClass of serializedClasses + @globals[className] = serializedClass + @onMouseMove = _.throttle @onMouseMove, 25 + changeCurrentThangAndSpell: (thangAndSpellObject) -> + @thang = thangAndSpellObject.thang + @spell = thangAndSpellObject.spell + + handleDebugValue: (returnObject) -> + {key, value} = returnObject + if @variableChain and not key is @variableChain.join(".") then return + @$el.find("code").text "#{key}: #{value}" + @$el.show().css(@pos) + + afterRender: -> super() @ace.on "mousemove", @onMouseMove @@ -58,7 +74,8 @@ module.exports = class DebugView extends View token = prev start = it.getCurrentTokenColumn() chain.unshift token.value - if token and (token.value of @variableStates or token.value is "this" or @globals[token.value]) + #Highlight all tokens, so true overrides all other conditions TODO: Refactor this later + if token and (true or token.value of @variableStates or token.value is "this" or @globals[token.value]) @variableChain = chain offsetX = e.domEvent.offsetX ? e.clientX - $(e.domEvent.target).offset().left offsetY = e.domEvent.offsetY ? e.clientY - $(e.domEvent.target).offset().top @@ -79,8 +96,11 @@ module.exports = class DebugView extends View update: -> if @variableChain - {key, value} = @deserializeVariableChain @variableChain - @$el.find("code").text "#{key}: #{value}" + Backbone.Mediator.publish 'tome:spell-debug-value-request', + thangID: @thang.id + spellID: @spell.name + variableChain: @variableChain + @$el.find("code").text "Finding value..." @$el.show().css(@pos) else @$el.hide() @@ -98,7 +118,6 @@ module.exports = class DebugView extends View @hoveredProperty = if @variableChain?.length is 2 then owner: @variableChain[0], property: @variableChain[1] else {} unless _.isEqual oldHoveredProperty, @hoveredProperty Backbone.Mediator.publish 'tome:spell-debug-property-hovered', @hoveredProperty - updateMarker: -> if @marker @ace.getSession().removeMarker @marker diff --git a/app/views/play/level/tome/spell_view.coffee b/app/views/play/level/tome/spell_view.coffee index fb52061a2..44de28fed 100644 --- a/app/views/play/level/tome/spell_view.coffee +++ b/app/views/play/level/tome/spell_view.coffee @@ -193,7 +193,7 @@ module.exports = class SpellView extends View @createToolbarView() createDebugView: -> - @debugView = new SpellDebugView ace: @ace, thang: @thang + @debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell @$el.append @debugView.render().$el.hide() createToolbarView: ->