diff --git a/app/assets/images/pages/play/level/modal/share_level_parchment.png b/app/assets/images/pages/play/level/modal/share_level_parchment.png new file mode 100644 index 000000000..b76ae9fb8 Binary files /dev/null and b/app/assets/images/pages/play/level/modal/share_level_parchment.png differ diff --git a/app/assets/javascripts/web-dev-listener.js b/app/assets/javascripts/web-dev-listener.js new file mode 100644 index 000000000..58f11db96 --- /dev/null +++ b/app/assets/javascripts/web-dev-listener.js @@ -0,0 +1,164 @@ +// TODO: don't serve this script from codecombat.com; serve it from a harmless extra domain we don't have yet. + +window.addEventListener('message', receiveMessage, false); + +var concreteDom; +var virtualDom; +var goalStates; + +var allowedOrigins = [ + /https:\/\/codecombat\.com/, + /http:\/\/localhost:3000/, + /http:\/\/direct\.codecombat\.com/, + /http:\/\/staging\.codecombat\.com/, + /http:\/\/next\.codecombat\.com/, + /http:\/\/.*codecombat-staging-codecombat\.runnableapp\.com/, +]; + +function receiveMessage(event) { + var origin = event.origin || event.originalEvent.origin; // For Chrome, the origin property is in the event.originalEvent object. + var allowed = false; + allowedOrigins.forEach(function(pattern) { + allowed = allowed || pattern.test(origin); + }); + if (!allowed) { + console.log('Ignoring message from bad origin:', origin); + return; + } + var data = event.data; + var source = event.source; + switch (data.type) { + case 'create': + create(_.pick(data, 'dom', 'styles', 'scripts')); + checkGoals(data.goals, source, origin); + break; + case 'update': + if (virtualDom) + update(_.pick(data, 'dom', 'styles', 'scripts')); + else + create(_.pick(data, 'dom', 'styles', 'scripts')); + checkGoals(data.goals, source, origin); + break; + case 'log': + console.log(data.text); + break; + default: + console.log('Unknown message type:', data.type); + } +} + +function create({ dom, styles, scripts }) { + virtualDom = dom; + virtualStyles = styles; + virtualScripts = scripts; + concreteDom = deku.dom.create(dom); + concreteStyles = deku.dom.create(styles); + concreteScripts = deku.dom.create(scripts); + // TODO: target the actual HTML tag and combine our initial structure for styles/scripts/tags with theirs + // TODO: :after elements don't seem to work? (:before do) + $('body').first().empty().append(concreteDom); + $('#player-styles').first().empty().append(concreteStyles); + $('#player-scripts').first().empty().append(concreteScripts); +} + +function update({ dom, styles, scripts }) { + function dispatch() {} // Might want to do something here in the future + var context = {}; // Might want to use this to send shared state to every component + + var domChanges = deku.diff.diffNode(virtualDom, dom); + domChanges.reduce(deku.dom.update(dispatch, context), concreteDom); // Rerender + + var scriptChanges = deku.diff.diffNode(virtualScripts, scripts); + scriptChanges.reduce(deku.dom.update(dispatch, context), concreteScripts); // Rerender + + var styleChanges = deku.diff.diffNode(virtualStyles, styles); + styleChanges.reduce(deku.dom.update(dispatch, context), concreteStyles); // Rerender + + virtualDom = dom; + virtualStyles = styles; + virtualScripts = scripts; +} + +function checkGoals(goals, source, origin) { + // Check right now and also in one second, since our 1-second CSS transition might be affecting things until it is done. + doCheckGoals(goals, source, origin); + _.delay(function() { doCheckGoals(goals, source, origin); }, 1001); +} + +function doCheckGoals(goals, source, origin) { + var newGoalStates = {}; + var overallSuccess = true; + goals.forEach(function(goal) { + var $result = $(goal.html.selector); + //console.log('ran selector', goal.html.selector, 'to find element(s)', $result); + var success = true; + goal.html.valueChecks.forEach(function(check) { + //console.log(' ... and should make sure that the value of', check.eventProps, 'is', _.omit(check, 'eventProps'), '?', matchesCheck($result, check)) + success = success && matchesCheck($result, check); + }); + overallSuccess = overallSuccess && success; + newGoalStates[goal.id] = {status: success ? 'success' : 'incomplete'}; // No 'failure' state + }); + if (!_.isEqual(newGoalStates, goalStates)) { + goalStates = newGoalStates; + var overallStatus = overallSuccess ? 'success' : null; // Can't really get to 'failure', just 'incomplete', which is represented by null here + source.postMessage({type: 'goals-updated', goalStates: goalStates, overallStatus: overallStatus}, origin); + } +} + +function downTheChain(obj, keyChain) { + if (!obj) + return null; + if (!_.isArray(keyChain)) + return obj[keyChain]; + var value = obj; + while (keyChain.length && value) { + if (keyChain[0].match(/\(.*\)$/)) { + var args, argsString = keyChain[0].match(/\((.*)\)$/)[1]; + if (argsString) + args = eval(argsString).split(/, ?/g).filter(function(x) { return x !== ''; }); // TODO: can/should we avoid eval here? + else + args = []; + value = value[keyChain[0].split('(')[0]].apply(value, args); // value.text(), value.css('background-color'), etc. + } + else + value = value[keyChain[0]]; + keyChain = keyChain.slice(1); + } + return value; +}; + +function matchesCheck(value, check) { + var v = downTheChain(value, check.eventProps); + if ((check.equalTo != null) && v !== check.equalTo) { + return false; + } + if ((check.notEqualTo != null) && v === check.notEqualTo) { + return false; + } + if ((check.greaterThan != null) && !(v > check.greaterThan)) { + return false; + } + if ((check.greaterThanOrEqualTo != null) && !(v >= check.greaterThanOrEqualTo)) { + return false; + } + if ((check.lessThan != null) && !(v < check.lessThan)) { + return false; + } + if ((check.lessThanOrEqualTo != null) && !(v <= check.lessThanOrEqualTo)) { + return false; + } + if ((check.containingString != null) && (!v || v.search(check.containingString) === -1)) { + return false; + } + if ((check.notContainingString != null) && (v != null ? v.search(check.notContainingString) : void 0) !== -1) { + return false; + } + if ((check.containingRegexp != null) && (!v || v.search(new RegExp(check.containingRegexp)) === -1)) { + return false; + } + if ((check.notContainingRegexp != null) && (v != null ? v.search(new RegExp(check.notContainingRegexp)) : void 0) !== -1) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/workers/aether_worker.js b/app/assets/javascripts/workers/aether_worker.js index 80c72bf54..285e6600d 100644 --- a/app/assets/javascripts/workers/aether_worker.js +++ b/app/assets/javascripts/workers/aether_worker.js @@ -19,6 +19,7 @@ var languagesImported = {}; var ensureLanguageImported = function(language) { if (languagesImported[language]) return; + if (language === 'html') return; importScripts("/javascripts/app/vendor/aether-" + language + ".js"); languagesImported[language] = true; }; diff --git a/app/assets/javascripts/workers/worker_world.js b/app/assets/javascripts/workers/worker_world.js index e40889e04..5857ba810 100644 --- a/app/assets/javascripts/workers/worker_world.js +++ b/app/assets/javascripts/workers/worker_world.js @@ -80,7 +80,7 @@ var myImportScripts = importScripts; var languagesImported = {}; var ensureLanguageImported = function(language) { if (languagesImported[language]) return; - if (language === 'javascript') return; // Only has JSHint, but we don't need to lint here. + if (language === 'javascript' || language === 'html') return; // Only has JSHint, but we don't need to lint here. myImportScripts("/javascripts/app/vendor/aether-" + language + ".js"); languagesImported[language] = true; }; diff --git a/app/assets/web-dev-iframe.html b/app/assets/web-dev-iframe.html new file mode 100644 index 000000000..304578e33 --- /dev/null +++ b/app/assets/web-dev-iframe.html @@ -0,0 +1,43 @@ + + + + + + + + + My CodeCombat Website + + + + + + + + + + + + + + + + + + + + + + + + + +

Loading...

+ + diff --git a/app/collections/RealTimeCollection.coffee b/app/collections/RealTimeCollection.coffee deleted file mode 100644 index a5af239ed..000000000 --- a/app/collections/RealTimeCollection.coffee +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = class RealTimeCollection extends Backbone.Firebase.Collection - constructor: (savePath) -> - # TODO: Don't hard code this here - # TODO: Use prod path in prod - @firebase = 'https://codecombat.firebaseio.com/test/db/' + savePath - super() diff --git a/app/core/ParticleMan.coffee b/app/core/ParticleMan.coffee index bcaebd72b..41be7829c 100644 --- a/app/core/ParticleMan.coffee +++ b/app/core/ParticleMan.coffee @@ -245,6 +245,12 @@ particleKinds['level-dungeon-game-dev'] = particleKinds['level-dungeon-game-dev- colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-dungeon-web-dev'] = particleKinds['level-dungeon-web-dev-premium'] = ext particleKinds['level-dungeon-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-dungeon-premium-item'] = ext particleKinds['level-dungeon-gate'], emitter: particleCount: 2000 @@ -300,6 +306,12 @@ particleKinds['level-forest-game-dev'] = particleKinds['level-forest-game-dev-pr colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-forest-web-dev'] = particleKinds['level-forest-web-dev-premium'] = ext particleKinds['level-forest-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-forest-premium-item'] = ext particleKinds['level-forest-gate'], emitter: particleCount: 2000 @@ -355,6 +367,12 @@ particleKinds['level-desert-game-dev'] = particleKinds['level-desert-game-dev-pr colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-desert-web-dev'] = particleKinds['level-desert-web-dev-premium'] = ext particleKinds['level-desert-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-mountain-premium-hero'] = ext particleKinds['level-mountain-premium'], emitter: particleCount: 200 @@ -395,6 +413,12 @@ particleKinds['level-mountain-game-dev'] = particleKinds['level-mountain-game-de colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-mountain-web-dev'] = particleKinds['level-mountain-web-dev-premium'] = ext particleKinds['level-mountain-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-glacier-premium-hero'] = ext particleKinds['level-glacier-premium'], emitter: particleCount: 200 @@ -435,6 +459,12 @@ particleKinds['level-glacier-game-dev'] = particleKinds['level-glacier-game-dev- colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-glacier-web-dev'] = particleKinds['level-glacier-web-dev-premium'] = ext particleKinds['level-glacier-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-volcano-premium-hero'] = ext particleKinds['level-volcano-premium'], emitter: particleCount: 200 @@ -474,3 +504,9 @@ particleKinds['level-volcano-game-dev'] = particleKinds['level-volcano-game-dev- colorStart: hsl 0.7, 0.75, 0.7 colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 + +particleKinds['level-volcano-web-dev'] = particleKinds['level-volcano-web-dev-premium'] = ext particleKinds['level-volcano-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 diff --git a/app/core/Router.coffee b/app/core/Router.coffee index eeb42e2c2..b80e7f671 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -126,13 +126,13 @@ module.exports = class CocoRouter extends Backbone.Router 'legal': go('LegalView') - 'multiplayer': go('MultiplayerView') - 'play(/)': go('play/CampaignView') # extra slash is to get Facebook app to work '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') + 'play/game-dev-level/:levelID/:sessionID': go('play/level/PlayGameDevLevelView') + 'play/web-dev-level/:levelID/:sessionID': go('play/level/PlayWebDevLevelView') 'play/spectate/:levelID': go('play/SpectateView') 'play/:map': go('play/CampaignView') @@ -193,7 +193,7 @@ module.exports = class CocoRouter extends Backbone.Router @listenToOnce application.moduleLoader, 'load-complete', -> @routeDirectly(path, args, options) return - return @openView @notFoundView() if not ViewClass + return go('NotFoundView') if not ViewClass view = new ViewClass(options, args...) # options, then any path fragment args view.render() @openView(view) diff --git a/app/core/initialize.coffee b/app/core/initialize.coffee index 7acbc0cdc..b81841a6b 100644 --- a/app/core/initialize.coffee +++ b/app/core/initialize.coffee @@ -8,7 +8,6 @@ channelSchemas = 'errors': require 'schemas/subscriptions/errors' 'ipad': require 'schemas/subscriptions/ipad' 'misc': require 'schemas/subscriptions/misc' - 'multiplayer': require 'schemas/subscriptions/multiplayer' 'play': require 'schemas/subscriptions/play' 'surface': require 'schemas/subscriptions/surface' 'tome': require 'schemas/subscriptions/tome' @@ -165,5 +164,5 @@ window.onbeforeunload = (e) -> return leavingMessage else return - + $ -> init() diff --git a/app/core/treema-ext.coffee b/app/core/treema-ext.coffee index f64f6f3cb..685ccb1fd 100644 --- a/app/core/treema-ext.coffee +++ b/app/core/treema-ext.coffee @@ -2,6 +2,7 @@ CocoModel = require 'models/CocoModel' CocoCollection = require 'collections/CocoCollection' {me} = require('core/auth') locale = require 'locale/locale' +utils = require 'core/utils' initializeFilePicker = -> require('core/services/filepicker')() unless window.application.isIPadApp @@ -234,21 +235,14 @@ class ImageFileTreema extends TreemaNode.nodeMap.string @refreshDisplay() -codeLanguages = - javascript: 'ace/mode/javascript' - coffeescript: 'ace/mode/coffee' - python: 'ace/mode/python' - lua: 'ace/mode/lua' - java: 'ace/mode/java' - class CodeLanguagesObjectTreema extends TreemaNode.nodeMap.object childPropertiesAvailable: -> - (key for key in _.keys(codeLanguages) when not @data[key]? and not (key is 'javascript' and @workingSchema.skipJavaScript)) + (key for key in _.keys(utils.aceEditModes) when not @data[key]? and not (key is 'javascript' and @workingSchema.skipJavaScript)) class CodeLanguageTreema extends TreemaNode.nodeMap.string buildValueForEditing: (valEl, data) -> super(valEl, data) - valEl.find('input').autocomplete(source: _.keys(codeLanguages), minLength: 0, delay: 0, autoFocus: true) + valEl.find('input').autocomplete(source: _.keys(utils.aceEditModes), minLength: 0, delay: 0, autoFocus: true) valEl class CodeTreema extends TreemaNode.nodeMap.ace @@ -256,8 +250,8 @@ class CodeTreema extends TreemaNode.nodeMap.ace super(arguments...) @workingSchema.aceTabSize = 4 # TODO: Find a less hacky solution for this - @workingSchema.aceMode = mode if mode = codeLanguages[@keyForParent] - @workingSchema.aceMode = mode if mode = codeLanguages[@parent?.data?.language] + @workingSchema.aceMode = mode if mode = utils.aceEditModes[@keyForParent] + @workingSchema.aceMode = mode if mode = utils.aceEditModes[@parent?.data?.language] class CoffeeTreema extends CodeTreema constructor: -> diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 07e90f4f5..166db035e 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -259,7 +259,7 @@ startsWithVowel = (s) -> s[0] in 'aeiouAEIOU' module.exports.filterMarkdownCodeLanguages = (text, language) -> return '' unless text currentLanguage = language or me.get('aceConfig')?.language or 'python' - excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'java', 'io'], currentLanguage + excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'java', 'io', 'html'], currentLanguage # Exclude language-specific code blocks like ```python (... code ...)``` for each non-target language. codeBlockExclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm' # Exclude language-specific images like ![python - image description](image url) for each non-target language. @@ -290,13 +290,15 @@ module.exports.filterMarkdownCodeLanguages = (text, language) -> return text module.exports.aceEditModes = aceEditModes = - 'javascript': 'ace/mode/javascript' - 'coffeescript': 'ace/mode/coffee' - 'python': 'ace/mode/python' - 'java': 'ace/mode/java' - 'lua': 'ace/mode/lua' - 'java': 'ace/mode/java' + javascript: 'ace/mode/javascript' + coffeescript: 'ace/mode/coffee' + python: 'ace/mode/python' + lua: 'ace/mode/lua' + java: 'ace/mode/java' + html: 'ace/mode/html' +# These ACEs are used for displaying code snippets statically, like in SpellPaletteEntryView popovers +# and have short lifespans module.exports.initializeACE = (el, codeLanguage) -> contents = $(el).text().trim() editor = ace.edit el diff --git a/app/lib/Bus.coffee b/app/lib/Bus.coffee index ffe508754..2180dae4b 100644 --- a/app/lib/Bus.coffee +++ b/app/lib/Bus.coffee @@ -22,6 +22,7 @@ module.exports = Bus = class Bus extends CocoClass 'auth:me-synced': 'onMeSynced' connect: -> + # Put Firebase back in bower if you want to use this Backbone.Mediator.publish 'bus:connecting', {bus: @} Firebase.goOnline() @fireRef = new Firebase(Bus.fireHost + '/' + @docName) diff --git a/app/lib/God.coffee b/app/lib/God.coffee index 1371958f7..60c15c5b6 100644 --- a/app/lib/God.coffee +++ b/app/lib/God.coffee @@ -94,9 +94,9 @@ module.exports = class God extends CocoClass return if hadPreloader @angelsShare.workQueue = [] - work = + work = { userCodeMap: userCodeMap - level: @level + @level levelSessionIDs: @levelSessionIDs submissionCount: @lastSubmissionCount fixedSeed: @lastFixedSeed @@ -104,9 +104,10 @@ module.exports = class God extends CocoClass difficulty: @lastDifficulty goals: @angelsShare.goalManager?.getGoals() headless: @angelsShare.headless - preload: preload + preload synchronous: not Worker? # Profiling world simulation is easier on main thread, or we are IE9. - realTime: realTime + realTime + } @angelsShare.workQueue.push work angel.workIfIdle() for angel in @angelsShare.angels work @@ -114,9 +115,7 @@ module.exports = class God extends CocoClass getUserCodeMap: (spells) -> userCodeMap = {} for spellKey, spell of spells - for thangID, spellThang of spell.thangs - continue if spellThang.thang?.programmableMethods[spell.name].cloneOf - (userCodeMap[thangID] ?= {})[spell.name] = spellThang.aether.serialize() + (userCodeMap[spell.thang.thang.id] ?= {})[spell.name] = spell.thang.aether.serialize() userCodeMap diff --git a/app/lib/LevelBus.coffee b/app/lib/LevelBus.coffee index 741420cca..aab23bf16 100644 --- a/app/lib/LevelBus.coffee +++ b/app/lib/LevelBus.coffee @@ -41,7 +41,6 @@ module.exports = class LevelBus extends Bus @fireScriptsRef = @fireRef?.child('scripts') setSession: (@session) -> - @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) @timerIntervalID = setInterval(@incrementSessionPlaytime, 1000) onIdleChanged: (e) -> @@ -53,8 +52,7 @@ module.exports = class LevelBus extends Bus @session.set('playtime', (@session.get('playtime') ? 0) + 1) onPoint: -> - return true unless @session?.get('multiplayer') - super() + return true onMeSynced: => super() @@ -236,17 +234,11 @@ module.exports = class LevelBus extends Bus @changedSessionProperties.chat = true @saveSession() - onMultiplayerChanged: -> - @changedSessionProperties.multiplayer = true - @session.updatePermissions() - @changedSessionProperties.permissions = true - @saveSession() - # Debounced as saveSession reallySaveSession: -> return if _.isEmpty @changedSessionProperties # don't let peeking admins mess with the session accidentally - return unless @session.get('multiplayer') or @session.get('creator') is me.id + return unless @session.get('creator') is me.id return if @session.fake Backbone.Mediator.publish 'level:session-will-save', session: @session patch = {} diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 24feca40f..3b2712f13 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -53,6 +53,16 @@ module.exports = class LevelLoader extends CocoClass # Supermodel (Level) Loading + loadWorldNecessities: -> + # TODO: Actually trigger loading, instead of in the constructor + new Promise((resolve, reject) => + return resolve(@) if @world + @once 'world-necessities-loaded', => resolve(@) + @once 'world-necessity-load-failed', ({resource}) -> + { jqxhr } = resource + reject({message: jqxhr.responseJSON?.message or jqxhr.responseText or 'Unknown Error'}) + ) + loadLevel: -> @level = @supermodel.getModel(Level, @levelID) or new Level _id: @levelID if @level.loaded @@ -62,9 +72,18 @@ module.exports = class LevelLoader extends CocoClass @listenToOnce @level, 'sync', @onLevelLoaded onLevelLoaded: -> - if not @sessionless and @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course'] + if not @sessionless and @level.isType('hero', 'hero-ladder', 'hero-coop', 'course') @sessionDependenciesRegistered = {} - if (@courseID and @level.get('type', true) not in ['course', 'course-ladder']) or window.serverConfig.picoCTF + if @level.isType('web-dev') + @headless = true + if @sessionless + # When loading a web-dev level in the level editor, pretend it's a normal hero level so we can put down our placeholder Thang. + # TODO: avoid this whole roundabout Thang-based way of doing web-dev levels + originalGet = @level.get + @level.get = -> + return 'hero' if arguments[0] is 'type' + originalGet.apply @, arguments + if (@courseID and not @level.isType('course', 'course-ladder', 'game-dev', 'web-dev')) or window.serverConfig.picoCTF # Because we now use original hero levels for both hero and course levels, we fake being a course level in this context. originalGet = @level.get @level.get = -> @@ -169,7 +188,7 @@ module.exports = class LevelLoader extends CocoClass @consolidateFlagHistory() if @opponentSession?.loaded else if session is @opponentSession @consolidateFlagHistory() if @session.loaded - if @level.get('type', true) in ['course'] # course-ladder is hard to handle because there's 2 sessions + if @level.isType('course') # course-ladder is hard to handle because there's 2 sessions heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain console.log "Course mode, loading custom hero: ", heroThangType if LOG url = "/db/thang.type/#{heroThangType}/version" @@ -178,7 +197,7 @@ module.exports = class LevelLoader extends CocoClass @worldNecessities.push heroResource @sessionDependenciesRegistered[session.id] = true return - return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] + return unless @level.isType('hero', 'hero-ladder', 'hero-coop') heroConfig = session.get('heroConfig') heroConfig ?= me.get('heroConfig') if session is @session and not @headless heroConfig ?= {} @@ -332,8 +351,8 @@ module.exports = class LevelLoader extends CocoClass @worldNecessities = (r for r in @worldNecessities when r?) @onWorldNecessitiesLoaded() if @checkAllWorldNecessitiesRegisteredAndLoaded() - onWorldNecessityLoadFailed: (resource) -> - @trigger('world-necessity-load-failed', resource: resource) + onWorldNecessityLoadFailed: (event) -> + @trigger('world-necessity-load-failed', event) checkAllWorldNecessitiesRegisteredAndLoaded: -> return false unless _.filter(@worldNecessities).length is 0 @@ -401,7 +420,8 @@ module.exports = class LevelLoader extends CocoClass resource.markLoaded() if resource.spriteSheetKeys.length is 0 denormalizeSession: -> - return if @headless or @sessionDenormalized or @spectateMode or @sessionless or me.isSessionless() + return if @sessionDenormalized or @spectateMode or @sessionless or me.isSessionless() + return if @headless and not @level.isType('web-dev') # This is a way (the way?) PUT /db/level.sessions/undefined was happening # See commit c242317d9 return if not @session.id @@ -443,7 +463,7 @@ module.exports = class LevelLoader extends CocoClass @grabTeamConfigs() @thangTypeTeams = {} for thang in @level.get('thangs') - if @level.get('type', true) in ['hero', 'course'] and thang.id is 'Hero Placeholder' + if @level.isType('hero', 'course') and thang.id is 'Hero Placeholder' continue # No team colors for heroes on single-player levels for component in thang.components if team = component.config?.team @@ -471,6 +491,7 @@ module.exports = class LevelLoader extends CocoClass initWorld: -> return if @initialized @initialized = true + return if @level.isType('web-dev') @world = new World() @world.levelSessionIDs = if @opponentSessionID then [@sessionID, @opponentSessionID] else [@sessionID] @world.submissionCount = @session?.get('state')?.submissionCount ? 0 diff --git a/app/lib/LevelSetupManager.coffee b/app/lib/LevelSetupManager.coffee index 78d0c7205..840967c12 100644 --- a/app/lib/LevelSetupManager.coffee +++ b/app/lib/LevelSetupManager.coffee @@ -74,7 +74,7 @@ module.exports = class LevelSetupManager extends CocoClass @session.set 'heroConfig', {"thangType":raider,"inventory":{}} @onInventoryModalPlayClicked() return - if @level.get('type', true) in ['course', 'course-ladder'] or window.serverConfig.picoCTF + if @level.isType('course', 'course-ladder', 'game-dev', 'web-dev') or window.serverConfig.picoCTF @onInventoryModalPlayClicked() return @heroesModal = new PlayHeroesModal({supermodel: @supermodel, session: @session, confirmButtonI18N: 'play.next', level: @level, hadEverChosenHero: @options.hadEverChosenHero}) diff --git a/app/lib/coursesHelper.coffee b/app/lib/coursesHelper.coffee index ad68f1fee..a6186ada5 100644 --- a/app/lib/coursesHelper.coffee +++ b/app/lib/coursesHelper.coffee @@ -195,6 +195,17 @@ module.exports = _.assign(progressData, progressMixin) return progressData + courseLabelsArray: (courses) -> + labels = [] + courseLabelIndexes = CS: 0, GD: 0, WD: 0 + for course in courses + acronym = switch + when /game-dev/.test(course.get('slug')) then 'GD' + when /web-dev/.test(course.get('slug')) then 'WD' + else 'CS' + labels.push acronym + ++courseLabelIndexes[acronym] + labels + progressMixin = get: (options={}) -> { classroom, course, level, user } = options diff --git a/app/lib/surface/Surface.coffee b/app/lib/surface/Surface.coffee index 01980a2d8..4f63e1ce3 100644 --- a/app/lib/surface/Surface.coffee +++ b/app/lib/surface/Surface.coffee @@ -10,7 +10,6 @@ Letterbox = require './Letterbox' Dimmer = require './Dimmer' CountdownScreen = require './CountdownScreen' PlaybackOverScreen = require './PlaybackOverScreen' -WaitingScreen = require './WaitingScreen' DebugDisplay = require './DebugDisplay' CoordinateDisplay = require './CoordinateDisplay' CoordinateGrid = require './CoordinateGrid' @@ -70,7 +69,6 @@ module.exports = Surface = class Surface extends CocoClass 'level:set-letterbox': 'onSetLetterbox' 'application:idle-changed': 'onIdleChanged' 'camera:zoom-updated': 'onZoomUpdated' - 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' 'level:flag-color-selected': 'onFlagColorSelected' @@ -135,7 +133,6 @@ module.exports = Surface = class Surface extends CocoClass @countdownScreen = new CountdownScreen camera: @camera, layer: @screenLayer, showsCountdown: @world.showsCountdown @playbackOverScreen = new PlaybackOverScreen camera: @camera, layer: @screenLayer, playerNames: @options.playerNames @normalStage.addChildAt @playbackOverScreen.dimLayer, 0 # Put this below the other layers, actually, so we can more easily read text on the screen. - @waitingScreen = new WaitingScreen camera: @camera, layer: @screenLayer @initCoordinates() @webGLStage.enableMouseOver(10) @webGLStage.addEventListener 'stagemousemove', @onMouseMove @@ -570,7 +567,7 @@ module.exports = Surface = class Surface extends CocoClass scaleFactor = 1 if @options.stayVisible availableHeight = window.innerHeight - availableHeight -= $('.ad-container').outerHeight() + availableHeight -= $('.ad-container').outerHeight() availableHeight -= $('#game-area').outerHeight() - $('#canvas-wrapper').outerHeight() scaleFactor = availableHeight / newHeight if availableHeight < newHeight newWidth *= scaleFactor @@ -602,9 +599,6 @@ module.exports = Surface = class Surface extends CocoClass #- Real-time playback - onRealTimePlaybackWaiting: (e) -> - @onRealTimePlaybackStarted e - onRealTimePlaybackStarted: (e) -> return if @realTime @realTimeInputEvents.reset() @@ -741,7 +735,6 @@ module.exports = Surface = class Surface extends CocoClass @dimmer?.destroy() @countdownScreen?.destroy() @playbackOverScreen?.destroy() - @waitingScreen?.destroy() @coordinateDisplay?.destroy() @coordinateGrid?.destroy() @normalStage.clear() diff --git a/app/lib/surface/WaitingScreen.coffee b/app/lib/surface/WaitingScreen.coffee deleted file mode 100644 index 232ddb5f8..000000000 --- a/app/lib/surface/WaitingScreen.coffee +++ /dev/null @@ -1,65 +0,0 @@ -CocoClass = require 'core/CocoClass' -RealTimeCollection = require 'collections/RealTimeCollection' - -module.exports = class WaitingScreen extends CocoClass - subscriptions: - 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' - 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' - 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' - 'real-time-multiplayer:player-status': 'onRealTimeMultiplayerPlayerStatus' - - constructor: (options) -> - super() - options ?= {} - @camera = options.camera - @layer = options.layer - @waitingText = options.text or 'Waiting...' - console.error @toString(), 'needs a camera.' unless @camera - console.error @toString(), 'needs a layer.' unless @layer - @build() - - onCastingBegins: (e) -> @show() unless e.preload - onCastingEnds: (e) -> @hide() - - toString: -> '' - - build: -> - @dimLayer = new createjs.Container() - @dimLayer.mouseEnabled = @dimLayer.mouseChildren = false - @dimLayer.addChild @dimScreen = new createjs.Shape() - @dimScreen.graphics.beginFill('rgba(0,0,0,0.5)').rect 0, 0, @camera.canvasWidth, @camera.canvasHeight - @dimLayer.alpha = 0 - @dimLayer.addChild @makeWaitingText() - - makeWaitingText: -> - size = Math.ceil @camera.canvasHeight / 8 - text = new createjs.Text @waitingText, "#{size}px Open Sans Condensed", '#F7B42C' - text.shadow = new createjs.Shadow '#000', Math.ceil(@camera.canvasHeight / 300), Math.ceil(@camera.canvasHeight / 300), Math.ceil(@camera.canvasHeight / 120) - text.textAlign = 'center' - text.textBaseline = 'middle' - text.x = @camera.canvasWidth / 2 - text.y = @camera.canvasHeight / 2 - @text = text - return text - - show: -> - return if @showing - @showing = true - @dimLayer.alpha = 0 - createjs.Tween.removeTweens @dimLayer - createjs.Tween.get(@dimLayer).to({alpha: 1}, 500) - @layer.addChild @dimLayer - - hide: -> - return unless @showing - @showing = false - createjs.Tween.removeTweens @dimLayer - createjs.Tween.get(@dimLayer).to({alpha: 0}, 500).call => @layer.removeChild @dimLayer unless @destroyed - - onRealTimeMultiplayerPlayerStatus: (e) -> @text.text = e.status - - onRealTimePlaybackWaiting: (e) -> @show() - - onRealTimePlaybackStarted: (e) -> @hide() - - onRealTimePlaybackEnded: (e) -> @hide() diff --git a/app/lib/world/GoalManager.coffee b/app/lib/world/GoalManager.coffee index 378068507..c701646b5 100644 --- a/app/lib/world/GoalManager.coffee +++ b/app/lib/world/GoalManager.coffee @@ -38,6 +38,7 @@ module.exports = class GoalManager extends CocoClass subscriptions: 'god:new-world-created': 'onNewWorldCreated' + 'god:new-html-goal-states': 'onNewHTMLGoalStates' 'level:restarted': 'onLevelRestarted' backgroundSubscriptions: @@ -86,6 +87,9 @@ module.exports = class GoalManager extends CocoClass @world = e.world @updateGoalStates(e.goalStates) if e.goalStates? + onNewHTMLGoalStates: (e) -> + @updateGoalStates(e.goalStates) if e.goalStates? + updateGoalStates: (newGoalStates) -> for goalID, goalState of newGoalStates continue unless @goalStates[goalID]? @@ -114,7 +118,7 @@ module.exports = class GoalManager extends CocoClass goalStates: @goalStates goals: @goals overallStatus: overallStatus - timedOut: @world.totalFrames is @world.maxTotalFrames and overallStatus not in ['success', 'failure'] + timedOut: @world? and (@world.totalFrames is @world.maxTotalFrames and overallStatus not in ['success', 'failure']) Backbone.Mediator.publish('goal-manager:new-goal-states', event) checkOverallStatus: (ignoreIncomplete=false) -> @@ -264,7 +268,7 @@ module.exports = class GoalManager extends CocoClass mostEagerGoal = _.min matchedGoals, 'worldEndsAfter' victory = overallStatus is 'success' tentative = overallStatus is 'success' - @world.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity + @world?.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity updateGoalState: (goalID, thangID, progressObjectName, frameNumber) -> # A thang has done something related to the goal! @@ -291,7 +295,7 @@ module.exports = class GoalManager extends CocoClass mostEagerGoal = _.min matchedGoals, 'worldEndsAfter' victory = overallStatus is 'success' tentative = overallStatus is 'success' - @world.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity + @world?.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity goalIsPositive: (goalID) -> # Positive goals are completed when all conditions are true (kill all these thangs) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 6e14070c0..29ce12d0e 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -318,7 +318,7 @@ write_this_down: "Write this down:" start_playing: "Start Playing!" sso_connected: "Successfully connected with:" - + recover: recover_account_title: "Recover Account" send_password: "Send Recovery Password" @@ -450,8 +450,6 @@ incomplete: "Incomplete" timed_out: "Ran out of time" failing: "Failing" - control_bar_multiplayer: "Multiplayer" - control_bar_join_game: "Join Game" reload: "Reload" reload_title: "Reload All Code?" reload_really: "Are you sure you want to reload this level back to the beginning?" @@ -480,10 +478,7 @@ tome_cast_button_running: "Running" tome_cast_button_ran: "Ran" tome_submit_button: "Submit" - tome_reload_method: "Reload original code for this method" # Title text for individual method reload button. - tome_select_method: "Select a Method" - tome_see_all_methods: "See all methods you can edit" # Title text for method list selector (shown when there are multiple programmable methods). - tome_select_a_thang: "Select Someone for " + tome_reload_method: "Reload original code to restart the level" # {change} tome_available_spells: "Available Spells" tome_your_skills: "Your Skills" tome_current_method: "Current Method" @@ -1478,6 +1473,29 @@ status_not_enrolled: "Not Enrolled" status_enrolled: "Expires on {{date}}" select_all: "Select All" + projects: "Projects" + + sharing: + game: "Game" + webpage: "Webpage" + share_game: "Share This Game" + share_web: "Share This Webpage" + victory_share_prefix: "Share this link to invite your friends & family to" + victory_share_game: "play your game level" + victory_share_web: "view your webpage" + victory_share_suffix: "." + victory_course_share_prefix: "This link will let your friends & family" + victory_course_share_game: "play the game" + victory_course_share_web: "view the webpage" + victory_course_share_suffix: "you just created." + copy_url: "Copy URL" + + game_dev: + creator: "Creator" + + web_dev: + image_gallery_title: "Image Gallery" + image_gallery_description: "Copy these images into your webpage, or find your own image URLs online." classes: archmage_title: "Archmage" @@ -1880,6 +1898,17 @@ vectors: "Vectors" while_loops: "While Loops" recursion: "Recursion" + basic_html: "Basic HTML" # TODO: these web-dev concepts will change, don't need to translate + basic_css: "Basic CSS" + basic_web_scripting: "Basic Web Scripting" + intermediate_html: "Intermediate HTML" + intermediate_css: "Intermediate CSS" + intermediate_web_scripting: "Intermediate Web Scripting" + advanced_html: "Advanced HTML" + advanced_css: "Advanced CSS" + advanced_web_scripting: "Advanced Web Scripting" + jquery: "jQuery" + bootstrap: "Bootstrap" delta: added: "Added" @@ -1891,16 +1920,6 @@ merge_conflict_with: "MERGE CONFLICT WITH" no_changes: "No Changes" - multiplayer: - multiplayer_title: "Multiplayer Settings" # We'll be changing this around significantly soon. Until then, it's not important to translate. - multiplayer_toggle: "Enable multiplayer" - multiplayer_toggle_description: "Allow others to join your game." - multiplayer_link_description: "Give this link to anyone to have them join you." - multiplayer_hint_label: "Hint:" - multiplayer_hint: " Click the link to select all, then press ⌘-C or Ctrl-C to copy the link." - multiplayer_coming_soon: "More multiplayer features to come!" - multiplayer_sign_in_leaderboard: "Sign in or create an account and get your solution on the leaderboard." - legal: page_title: "Legal" opensource_intro: "CodeCombat is completely open source." diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee index ef47355b0..eef817bfb 100644 --- a/app/models/Classroom.coffee +++ b/app/models/Classroom.coffee @@ -74,7 +74,7 @@ module.exports = class Classroom extends CocoModel } getLevels: (options={}) -> - # options: courseID, withoutLadderLevels + # options: courseID, withoutLadderLevels, projectLevels Levels = require 'collections/Levels' courses = @get('courses') return new Levels() unless courses @@ -86,6 +86,8 @@ module.exports = class Classroom extends CocoModel levels = new Levels(_.flatten(levelObjects)) if options.withoutLadderLevels levels.remove(levels.filter((level) -> level.isLadder())) + if options.projectLevels + levels.remove(levels.filter((level) -> level.get('shareable') isnt 'project')) return levels getLadderLevel: (courseID) -> diff --git a/app/models/Course.coffee b/app/models/Course.coffee index 7cd6ee20c..86705745e 100644 --- a/app/models/Course.coffee +++ b/app/models/Course.coffee @@ -5,3 +5,10 @@ module.exports = class Course extends CocoModel @className: 'Course' @schema: schema urlRoot: '/db/course' + + fetchForCourseInstance: (courseInstanceID, opts) -> + options = { + url: "/db/course_instance/#{courseInstanceID}/course" + } + _.extend options, opts + @fetch options diff --git a/app/models/Level.coffee b/app/models/Level.coffee index 77c4ff84c..998e40c2c 100644 --- a/app/models/Level.coffee +++ b/app/models/Level.coffee @@ -34,7 +34,7 @@ module.exports = class Level extends CocoModel for tt in supermodel.getModels ThangType if tmap[tt.get('original')] or (tt.get('kind') isnt 'Hero' and tt.get('kind')? and tt.get('components') and not tt.notInLevel) or - (tt.get('kind') is 'Hero' and ((@get('type', true) in ['course', 'course-ladder']) or tt.get('original') in sessionHeroes)) + (tt.get('kind') is 'Hero' and (@isType('course', 'course-ladder', 'game-dev') or tt.get('original') in sessionHeroes)) o.thangTypes.push (original: tt.get('original'), name: tt.get('name'), components: $.extend(true, [], tt.get('components'))) @sortThangComponents o.thangTypes, o.levelComponents, 'ThangType' @fillInDefaultComponentConfiguration o.thangTypes, o.levelComponents @@ -59,7 +59,7 @@ module.exports = class Level extends CocoModel denormalize: (supermodel, session, otherSession) -> o = $.extend true, {}, @attributes - if o.thangs and @get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if o.thangs and @isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') thangTypesWithComponents = (tt for tt in supermodel.getModels(ThangType) when tt.get('components')?) thangTypesByOriginal = _.indexBy thangTypesWithComponents, (tt) -> tt.get('original') # Optimization for levelThang in o.thangs @@ -68,7 +68,7 @@ module.exports = class Level extends CocoModel denormalizeThang: (levelThang, supermodel, session, otherSession, thangTypesByOriginal) -> levelThang.components ?= [] - isHero = /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] + isHero = /Hero Placeholder/.test(levelThang.id) and @isType('hero', 'hero-ladder', 'hero-coop') if isHero and otherSession # If it's a hero and there's another session, find the right session for it. # If there is no other session (playing against default code, or on single player), clone all placeholders. @@ -147,7 +147,7 @@ module.exports = class Level extends CocoModel levelThang.components.push placeholderComponent # Load the user's chosen hero AFTER getting stats from default char - if /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['course'] and not @headless and not @sessionless + if /Hero Placeholder/.test(levelThang.id) and @isType('course') and not @headless and not @sessionless heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain levelThang.thangType = heroThangType if heroThangType @@ -263,6 +263,9 @@ module.exports = class Level extends CocoModel isLadder: -> return @get('type')?.indexOf('ladder') > -1 + isType: (types...) -> + return @get('type', true) in types + fetchNextForCourse: ({ levelOriginalID, courseInstanceID, courseID, sessionID }, options={}) -> if courseInstanceID options.url = "/db/course_instance/#{courseInstanceID}/levels/#{levelOriginalID}/sessions/#{sessionID}/next" diff --git a/app/models/LevelSession.coffee b/app/models/LevelSession.coffee index 8b2b73104..9e845fa78 100644 --- a/app/models/LevelSession.coffee +++ b/app/models/LevelSession.coffee @@ -15,8 +15,6 @@ module.exports = class LevelSession extends CocoModel updatePermissions: -> permissions = @get 'permissions', true permissions = (p for p in permissions when p.target isnt 'public') - if @get('multiplayer') - permissions.push {target: 'public', access: 'write'} @set 'permissions', permissions getSourceFor: (spellKey) -> @@ -76,6 +74,7 @@ module.exports = class LevelSession extends CocoModel wait recordScores: (scores, level) -> + return unless scores state = @get 'state' oldTopScores = state.topScores ? [] newTopScores = [] @@ -93,3 +92,17 @@ module.exports = class LevelSession extends CocoModel newTopScores.push oldTopScore state.topScores = newTopScores @set 'state', state + + generateSpellsObject: (options={}) -> + {level} = options + {createAetherOptions} = require 'lib/aether_utils' + aetherOptions = createAetherOptions functionName: 'plan', codeLanguage: @get('codeLanguage'), skipProtectAPI: options.level?.isType('game-dev') + spellThang = thang: {id: 'Hero Placeholder'}, aether: new Aether aetherOptions + spells = "hero-placeholder/plan": thang: spellThang, name: 'plan' + source = @get('code')?['hero-placeholder']?.plan ? '' + try + spellThang.aether.transpile source + catch e + console.log "Couldn't transpile!\n#{source}\n", e + spellThang.aether.transpile '' + spells diff --git a/app/models/RealTimeModel.coffee b/app/models/RealTimeModel.coffee deleted file mode 100644 index 217c72f2e..000000000 --- a/app/models/RealTimeModel.coffee +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = class RealTimeModel extends Backbone.Firebase.Model - constructor: (savePath) -> - # TODO: Don't hard code this here - # TODO: Use prod path in prod - @firebase = 'https://codecombat.firebaseio.com/test/db/' + savePath - super() diff --git a/app/models/SuperModel.coffee b/app/models/SuperModel.coffee index aca0021bd..eb9fc5b82 100644 --- a/app/models/SuperModel.coffee +++ b/app/models/SuperModel.coffee @@ -247,6 +247,15 @@ module.exports = class SuperModel extends Backbone.Model getResource: (rid) -> return @resources[rid] + + # Promises + finishLoading: -> + new Promise (resolve, reject) => + return resolve(@) if @finished() + @once 'failed', ({resource}) -> + jqxhr = resource.jqxhr + reject({message: jqxhr.responseJSON?.message or jqxhr.responseText or 'Unknown Error'}) + @once 'loaded-all', => resolve(@) class Resource extends Backbone.Model constructor: (name, value=1) -> diff --git a/app/schemas/models/campaign.schema.coffee b/app/schemas/models/campaign.schema.coffee index d5e0a1c8a..82b0eb763 100644 --- a/app/schemas/models/campaign.schema.coffee +++ b/app/schemas/models/campaign.schema.coffee @@ -61,12 +61,13 @@ _.extend CampaignSchema.properties, { i18n: { type: 'object', format: 'hidden' } requiresSubscription: { type: 'boolean' } replayable: { type: 'boolean' } - type: {'enum': ['ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']} + type: {'enum': ['ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev']} slug: { type: 'string', format: 'hidden' } original: { type: 'string', format: 'hidden' } adventurer: { type: 'boolean' } practice: { type: 'boolean' } practiceThresholdMinutes: {type: 'number'} + shareable: { title: 'Shareable', type: ['string', 'boolean'], enum: [false, true, 'project'], description: 'Whether the level is not shareable, shareable, or a sharing-encouraged project level.' } adminOnly: { type: 'boolean' } disableSpaces: { type: ['boolean','number'] } hidesSubmitUntilRun: { type: 'boolean' } diff --git a/app/schemas/models/classroom.schema.coffee b/app/schemas/models/classroom.schema.coffee index 9f3e63ca7..e33563b40 100644 --- a/app/schemas/models/classroom.schema.coffee +++ b/app/schemas/models/classroom.schema.coffee @@ -25,7 +25,7 @@ _.extend ClassroomSchema.properties, levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, { practice: {type: 'boolean'} practiceThresholdMinutes: {type: 'number'} - shareable: {type: 'boolean'} + shareable: { title: 'Shareable', type: ['string', 'boolean'], enum: [false, true, 'project'], description: 'Whether the level is not shareable, shareable, or a sharing-encouraged project level.' } type: c.shortString() original: c.objectId() name: {type: 'string'} diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee index 707bf6cd4..75c31b35f 100644 --- a/app/schemas/models/level.coffee +++ b/app/schemas/models/level.coffee @@ -114,6 +114,9 @@ GoalSchema = c.object {title: 'Goal', description: 'A goal that the player can a targets: c.array {title: 'Targets', description: 'The target items which the Thangs must not collect.', minItems: 1}, thang codeProblems: c.array {title: 'Code Problems', description: 'A list of Thang IDs that should not have any code problems, or team names.', uniqueItems: true, minItems: 1, 'default': ['humans']}, thang linesOfCode: {title: 'Lines of Code', description: 'A mapping of Thang IDs or teams to how many many lines of code should be allowed (well, statements).', type: 'object', default: {humans: 10}, additionalProperties: {type: 'integer', description: 'How many lines to allow for this Thang.'}} + html: c.object {title: 'HTML', description: 'A jQuery selector and what its result should be'}, + selector: {type: 'string', description: 'jQuery selector to run on the user HTML, like "h1:first-child"'} + valueChecks: c.array {title: 'Value checks', description: 'Logical checks on the resulting value for this goal to pass.', format: 'event-prereqs'}, EventPrereqSchema ResponseSchema = c.object {title: 'Dialogue Button', description: 'A button to be shown to the user with the dialogue.', required: ['text']}, text: {title: 'Title', description: 'The text that will be on the button', 'default': 'Okay', type: 'string', maxLength: 30} @@ -313,7 +316,7 @@ _.extend LevelSchema.properties, icon: {type: 'string', format: 'image-file', title: 'Icon'} banner: {type: 'string', format: 'image-file', title: 'Banner'} goals: c.array {title: 'Goals', description: 'An array of goals which are visible to the player and can trigger scripts.'}, GoalSchema - type: c.shortString(title: 'Type', description: 'What kind of level this is.', 'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) + type: c.shortString(title: 'Type', description: 'What kind of level this is.', 'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev']) terrain: c.terrainString showsGuide: c.shortString(title: 'Shows Guide', description: 'If the guide is shown at the beginning of the level.', 'enum': ['first-time', 'always']) requiresSubscription: {title: 'Requires Subscription', description: 'Whether this level is available to subscribers only.', type: 'boolean'} @@ -325,8 +328,8 @@ _.extend LevelSchema.properties, replayable: {type: 'boolean', title: 'Replayable', description: 'Whether this (hero) level infinitely scales up its difficulty and can be beaten over and over for greater rewards.'} buildTime: {type: 'number', description: 'How long it has taken to build this level.'} practice: { type: 'boolean' } - shareable: { type: 'boolean', title: 'Shareable' } practiceThresholdMinutes: {type: 'number', description: 'Players with larger playtimes may be directed to a practice level.'} + shareable: { title: 'Shareable', type: ['string', 'boolean'], enum: [false, true, 'project'], description: 'Whether the level is not shareable, shareable, or a sharing-encouraged project level.' } # Admin flags adventurer: { type: 'boolean' } diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee index 95be39816..2c7bbe1fe 100644 --- a/app/schemas/models/level_session.coffee +++ b/app/schemas/models/level_session.coffee @@ -37,8 +37,6 @@ _.extend LevelSessionSchema.properties, type: 'string' levelID: type: 'string' - multiplayer: - type: 'boolean' creator: c.objectId links: [ diff --git a/app/schemas/schemas.coffee b/app/schemas/schemas.coffee index f9be247c2..9df53b58a 100644 --- a/app/schemas/schemas.coffee +++ b/app/schemas/schemas.coffee @@ -261,4 +261,15 @@ me.concept = me.shortString enum: [ 'vectors' 'while_loops' 'recursion' + 'basic_html' + 'basic_css' + 'basic_web_scripting' + 'intermediate_html' + 'intermediate_css' + 'intermediate_web_scripting' + 'advanced_html' + 'advanced_css' + 'advanced_web_scripting' + 'jquery' + 'bootstrap' ] diff --git a/app/schemas/subscriptions/god.coffee b/app/schemas/subscriptions/god.coffee index 1c59ec44f..98628cb19 100644 --- a/app/schemas/subscriptions/god.coffee +++ b/app/schemas/subscriptions/god.coffee @@ -45,6 +45,10 @@ module.exports = 'god:streaming-world-updated': worldUpdatedEventSchema + 'god:new-html-goal-states': c.object {required: ['goalStates', 'overallStatus']}, + goalStates: goalStatesSchema + overallStatus: {type: ['string', 'null'], enum: ['success', 'failure', 'incomplete', null]} + 'god:goals-calculated': c.object {required: ['goalStates', 'god']}, god: {type: 'object'} goalStates: goalStatesSchema diff --git a/app/schemas/subscriptions/multiplayer.coffee b/app/schemas/subscriptions/multiplayer.coffee deleted file mode 100644 index fd88f449c..000000000 --- a/app/schemas/subscriptions/multiplayer.coffee +++ /dev/null @@ -1,21 +0,0 @@ -c = require 'schemas/schemas' - -module.exports = - 'real-time-multiplayer:created-game': c.object {title: 'Multiplayer created game', required: ['realTimeSessionID']}, - realTimeSessionID: {type: 'string'} - - 'real-time-multiplayer:joined-game': c.object {title: 'Multiplayer joined game', required: ['realTimeSessionID']}, - realTimeSessionID: {type: 'string'} - - 'real-time-multiplayer:left-game': c.object {title: 'Multiplayer left game'}, - userID: {type: 'string'} - - 'real-time-multiplayer:manual-cast': c.object {title: 'Multiplayer manual cast'} - - 'real-time-multiplayer:new-opponent-code': c.object {title: 'Multiplayer new opponent code', required: ['code', 'codeLanguage']}, - code: {type: 'object'} - codeLanguage: {type: 'string'} - team: {type: 'string'} - - 'real-time-multiplayer:player-status': c.object {title: 'Multiplayer player status', required: ['status']}, - status: {type: 'string'} diff --git a/app/schemas/subscriptions/play.coffee b/app/schemas/subscriptions/play.coffee index 1c4adc9b4..e03736613 100644 --- a/app/schemas/subscriptions/play.coffee +++ b/app/schemas/subscriptions/play.coffee @@ -96,8 +96,6 @@ module.exports = 'playback:stop-real-time-playback': c.object {} - 'playback:real-time-playback-waiting': c.object {} - 'playback:real-time-playback-started': c.object {} 'playback:real-time-playback-ended': c.object {} diff --git a/app/schemas/subscriptions/tome.coffee b/app/schemas/subscriptions/tome.coffee index 983b746a3..06008d209 100644 --- a/app/schemas/subscriptions/tome.coffee +++ b/app/schemas/subscriptions/tome.coffee @@ -39,8 +39,6 @@ module.exports = variableChain: c.array {}, {type: 'string'} frame: {type: 'integer', minimum: 0} - 'tome:toggle-spell-list': c.object {title: 'Toggle Spell List', description: 'Published when you toggle the dropdown for a thang\'s spells'} - 'tome:reload-code': c.object {title: 'Reload Code', description: 'Published when you reset a spell to its original source', required: []}, spell: {type: 'object'} @@ -91,10 +89,6 @@ module.exports = problems: {type: 'array'} isCast: {type: 'boolean'} - 'tome:spell-shown': c.object {title: 'Spell Shown', description: 'Published when we show a spell', required: ['thang', 'spell']}, - thang: {type: 'object'} - spell: {type: 'object'} - 'tome:change-language': c.object {title: 'Tome Change Language', description: 'Published when the Tome should update its programming language', required: ['language']}, language: {type: 'string'} reload: {type: 'boolean', description: 'Whether player code should reload to the default when the language changes.'} @@ -146,3 +140,7 @@ module.exports = lineOffsetPx: {type: ['number', 'undefined']} 'tome:hide-problem-alert': c.object {title: 'Hide Problem Alert'} 'tome:jiggle-problem-alert': c.object {title: 'Jiggle Problem Alert'} + + 'tome:html-updated': c.object {title: 'HTML Updated', required: ['html', 'create']}, + html: {type: 'string', description: 'The full HTML to display'} + create: {type: 'boolean', description: 'Whether we should (re)create the DOM (as opposed to updating it)'} diff --git a/app/styles/courses/course-details.sass b/app/styles/courses/course-details.sass index b8e76e217..179164b3b 100644 --- a/app/styles/courses/course-details.sass +++ b/app/styles/courses/course-details.sass @@ -35,3 +35,6 @@ h1 font-size: 48px + + .btn-view-project-level + margin-left: 10px; diff --git a/app/styles/courses/teacher-class-view.sass b/app/styles/courses/teacher-class-view.sass index c5d848b38..66f880ce5 100644 --- a/app/styles/courses/teacher-class-view.sass +++ b/app/styles/courses/teacher-class-view.sass @@ -177,7 +177,7 @@ // Course Progress tab - #course-progress-tab + #course-progress-tab, #student-projects-tab .course-overview-row margin-top: 50px border: thin solid gray @@ -221,7 +221,20 @@ .btn margin-top: 6.5px margin-bottom: 6.5px - + + #student-projects-tab + .student-levels-table + margin-top: 0px + + .student-info + margin-top: 5px + + .student-levels-row + padding-top: 10px + padding-bottom: 15px + + .btn-view-project-level + margin-left: 15px // Checkboxes .checkbox-flat diff --git a/app/styles/play/campaign-view.sass b/app/styles/play/campaign-view.sass index 75d683e23..5b2286cdd 100644 --- a/app/styles/play/campaign-view.sass +++ b/app/styles/play/campaign-view.sass @@ -25,8 +25,10 @@ $gameControlMargin: 30px margin-bottom: -$levelDotHeight / 3 + $levelDotZ #campaign-view - width: 100% - height: 100% + top: 0 + right: 0 + bottom: 0 + left: 0 position: absolute .gradient @@ -615,6 +617,8 @@ $gameControlMargin: 30px .gameplay-container position: absolute + height: 100% + width: 100% body.ipad #campaign-view // iPad only supports up to Kithgard Gates for now. diff --git a/app/styles/play/level/control_bar.sass b/app/styles/play/level/control_bar.sass index a31844c8e..48f835534 100644 --- a/app/styles/play/level/control_bar.sass +++ b/app/styles/play/level/control_bar.sass @@ -124,37 +124,6 @@ @include rotate(-15deg) vertical-align: middle - .multiplayer-area-container - position: relative - width: 100% - height: 50px - pointer-events: none - - .multiplayer-area - min-width: 200px - max-width: 293px - height: 60px - margin: 0 auto - padding: 8px - border-style: solid - border-image: url(/images/level/control_bar_level_name_background.png) 30 fill round - border-width: 0 15px 15px 15px - text-align: center - position: absolute - left: 50% - cursor: pointer - pointer-events: all - @include translate(-50%, 0) - - .multiplayer-label - font-size: 12px - color: $control-yellow-highlight - margin-bottom: -5px - - .multiplayer-status - color: white - font-size: 18px - .buttons-area position: absolute right: 35px @@ -210,11 +179,6 @@ html.no-borderimage background: transparent url(/images/level/control_bar_level_name_background.png) background-size: contain background-repeat: no-repeat - #control-bar-view .multiplayer-area - border: 0 - background: transparent url(/images/level/control_bar_level_name_background.png) - background-size: contain - background-repeat: no-repeat body:not(.ipad) diff --git a/app/styles/play/level/loading.sass b/app/styles/play/level/loading.sass index d5d3f4537..39f584321 100644 --- a/app/styles/play/level/loading.sass +++ b/app/styles/play/level/loading.sass @@ -181,3 +181,13 @@ $UNVEIL_TIME: 1.2s left: 48px right: 77px width: auto + + +#level-view.web-dev + #loading-details.preview + @media screen and ( min-height: 900px ) + background: transparent + border: 1px solid transparent + border-width: 124px 76px 64px 40px + border-image: url(/images/level/code_editor_background.png) 124 76 64 40 fill round + padding: 0 35px 0 15px diff --git a/app/styles/play/level/modal/course-victory-modal.sass b/app/styles/play/level/modal/course-victory-modal.sass index 8e624e3c3..a67e783d3 100644 --- a/app/styles/play/level/modal/course-victory-modal.sass +++ b/app/styles/play/level/modal/course-victory-modal.sass @@ -9,6 +9,9 @@ padding-top: 0 width: 750px + @media screen and ( max-height: 625px ) + margin-top: -50px + .modal-content position: relative margin-top: -251px @@ -55,10 +58,14 @@ top: 80px margin-top: 80px + @media screen and ( max-height: 650px ) + padding-top: 10px + .well-parchment margin-top: 20px - + @media screen and ( max-height: 675px ) + margin-top: 0 html.no-borderimage diff --git a/app/styles/play/level/modal/hero-victory-modal.sass b/app/styles/play/level/modal/hero-victory-modal.sass index 692eef2bf..e76f3140d 100644 --- a/app/styles/play/level/modal/hero-victory-modal.sass +++ b/app/styles/play/level/modal/hero-victory-modal.sass @@ -298,6 +298,33 @@ height: 100% position: absolute + #share-level-container + width: 709px + height: 96px + background: transparent url(/images/pages/play/level/modal/share_level_parchment.png) + position: relative + text-align: left + padding: 12px 20px 0 20px + text-align: center + + .share-level-label + color: rgb(103, 92, 76) + text-transform: uppercase + font-weight: bold + font-family: $headings-font-family + font-size: 18px + margin-top: 13px + line-height: 18px + text-align: center + + #share-level-input + font-size: 12px + margin-top: 8px + + #share-level-btn + width: 100% + margin-top: 7px + //- Footer - other stuff diff --git a/app/styles/play/level/modal/progress-view.sass b/app/styles/play/level/modal/progress-view.sass index 61d9f89c3..096d0766e 100644 --- a/app/styles/play/level/modal/progress-view.sass +++ b/app/styles/play/level/modal/progress-view.sass @@ -4,10 +4,18 @@ color: black margin-bottom: 5px - p - margin-top: 30px + .next-level-description + p + margin-top: 30px .course-title white-space: nowrap text-overflow: ellipsis overflow: hidden + + #share-level-input + font-size: 12px + margin-top: 5px + + #share-level-btn + width: 100% diff --git a/app/styles/play/level/play-game-dev-level-view.sass b/app/styles/play/level/play-game-dev-level-view.sass new file mode 100644 index 000000000..c14a38add --- /dev/null +++ b/app/styles/play/level/play-game-dev-level-view.sass @@ -0,0 +1,22 @@ +#play-game-dev-level-view + #canvas-wrapper + width: 100% + position: relative + overflow: hidden + z-index: 0 + + #webgl-surface + background-color: #333 + + #normal-surface + position: absolute + top: 0 + left: 0 + pointer-events: none + + canvas#webgl-surface, canvas#normal-surface + display: block + z-index: 2 + + #play-btn + text-transform: uppercase diff --git a/app/styles/play/level/play-web-dev-level-view.sass b/app/styles/play/level/play-web-dev-level-view.sass new file mode 100644 index 000000000..ddba9ee6e --- /dev/null +++ b/app/styles/play/level/play-web-dev-level-view.sass @@ -0,0 +1,18 @@ +#play-web-dev-level-view + #web-surface-view + position: absolute + top: 0 + right: 0 + bottom: 0 + left: 0 + z-index: 0 + + #info-bar + position: absolute + right: 0 + bottom: 0 + left: 0 + height: 100px + z-index: 1 + background-color: transparent + text-align: center diff --git a/app/styles/play/level/tome/spell-palette-view.sass b/app/styles/play/level/tome/spell-palette-view.sass index 1ba004ff4..89fab6088 100644 --- a/app/styles/play/level/tome/spell-palette-view.sass +++ b/app/styles/play/level/tome/spell-palette-view.sass @@ -113,6 +113,14 @@ width: -webkit-calc(100% - 38px) width: calc(100% - 38px) + &.web-dev.hero .properties + .property-entry-item-group + width: 100px + + .spell-palette-entry-view + margin-left: 0 + width: 100px + @media only screen and (max-width: 1100px) #spell-palette-view // Make sure we have enough room for at least two columns diff --git a/app/styles/play/level/tome/spell_list_entry.sass b/app/styles/play/level/tome/spell-top-bar-view.sass similarity index 66% rename from app/styles/play/level/tome/spell_list_entry.sass rename to app/styles/play/level/tome/spell-top-bar-view.sass index 3f7e3e4df..39207901d 100644 --- a/app/styles/play/level/tome/spell_list_entry.sass +++ b/app/styles/play/level/tome/spell-top-bar-view.sass @@ -1,15 +1,7 @@ @import "app/styles/mixins" @import "app/styles/bootstrap/variables" -.spell-list-entry-view - .method-signature - background-color: transparent - border: 0 - font-size: 1.1em - display: inline-block - padding: 4px - -.spell-list-entry-view.spell-tab +#spell-top-bar-view $height: 87px $paddingTop: 10px $paddingBottom: 25px @@ -46,12 +38,6 @@ > *:not(.spell-tool-buttons) @include opacity(0.5) - .thang-avatar-view - width: $childSize - 10px - margin: 5px 0.4vw - display: inline-block - float: left - .btn.btn-small margin-top: 15px margin-right: 1.3vw @@ -97,46 +83,8 @@ .thang-avatar-wrapper border-width: 0 -.spell-list-entry-view:not(.spell-tab) - cursor: pointer - @include opacity(0.90) - clear: both - padding: 5px - position: relative - - &:hover - @include opacity(1) - background-color: hsla(240, 40, 80, 0.25) - - &.shows-top-divider:not(:first-child) - border-top: 1px dashed #ccc - - .method-signature - margin-top: 5px - - .thang-names - float: right - margin: 8px - font-variant: small-caps - color: darken(#ca8, 50%) - white-space: nowrap - overflow: hidden - text-overflow: ellipsis - font-size: 13px - max-width: 35% - text-align: right - - .thang-avatar-view - width: 40px - float: right - - .thang-avatar-wrapper - margin: 0 5px 0 0 - //margin: 2px 10px 2px 5px - - //html.no-borderimage -// .spell-list-entry-view.spell-tab +// .spell-top-bar-view // border-width: 0 // border-image: none // background: transparent url(/images/level/code_editor_tab_background.png) no-repeat diff --git a/app/styles/play/level/tome/spell_list.sass b/app/styles/play/level/tome/spell_list.sass deleted file mode 100644 index b66cd5f82..000000000 --- a/app/styles/play/level/tome/spell_list.sass +++ /dev/null @@ -1,20 +0,0 @@ -@import "app/styles/mixins" -@import "app/styles/bootstrap/variables" - -#spell-list-view - display: none - position: absolute - z-index: 10 - top: 50px - left: 0% - right: 10% - padding: 4% - border-style: solid - border-image: url(/images/level/popover_border_background.png) 16 12 fill round - border-width: 16px 12px - -html.no-borderimage - #spell-list-view - background: transparent url(/images/level/popover_background.png) - background-size: 100% 100% - border: 0 diff --git a/app/styles/play/level/tome/spell_list_entry_thangs.sass b/app/styles/play/level/tome/spell_list_entry_thangs.sass deleted file mode 100644 index abe03b44a..000000000 --- a/app/styles/play/level/tome/spell_list_entry_thangs.sass +++ /dev/null @@ -1,30 +0,0 @@ -@import "app/styles/mixins" -@import "app/styles/bootstrap/variables" - -.spell-list-entry-view - .spell-list-entry-thangs-view - position: absolute - z-index: 11 - top: 50px - right: -10% - max-width: 70% - max-height: 500px - overflow: scroll - padding: 4% - border-style: solid - border-image: url(/images/level/popover_border_background.png) 16 12 fill round - border-width: 16px 12px - - .thang-avatar-view - cursor: pointer - max-width: 100px - width: 20% - display: inline-block - - -html.no-borderimage - .spell-list-entry-view - .spell-list-entry-thangs-view - background: transparent url(/images/level/popover_background.png) - background-size: 100% 100% - border: 0 diff --git a/app/styles/play/level/web-surface-view.sass b/app/styles/play/level/web-surface-view.sass new file mode 100644 index 000000000..e66a99e87 --- /dev/null +++ b/app/styles/play/level/web-surface-view.sass @@ -0,0 +1,6 @@ +#web-surface-view + background-color: white + + iframe + width: 100% + height: 100% diff --git a/app/styles/play/menu/multiplayer-view.sass b/app/styles/play/menu/multiplayer-view.sass deleted file mode 100644 index c1bfbee23..000000000 --- a/app/styles/play/menu/multiplayer-view.sass +++ /dev/null @@ -1,8 +0,0 @@ -#multiplayer-view - textarea - width: 100% - box-sizing: border-box - padding: 5px - text-align: center - height: 30px - font-size: 11px diff --git a/app/styles/play/modal/image-gallery-modal.sass b/app/styles/play/modal/image-gallery-modal.sass new file mode 100644 index 000000000..caf5d0eef --- /dev/null +++ b/app/styles/play/modal/image-gallery-modal.sass @@ -0,0 +1,11 @@ +@import "app/styles/mixins" + +#image-gallery-modal + .modal-dialog + width: 800px + + li + font-size: 12px + + .no-select + @include user-select(none) diff --git a/app/styles/play/play-level-view.sass b/app/styles/play/play-level-view.sass index 8b3c027ae..a0a560ff9 100644 --- a/app/styles/play/play-level-view.sass +++ b/app/styles/play/play-level-view.sass @@ -272,6 +272,29 @@ $level-resize-transition-time: 0.5s right: 45% z-index: 1000000 + &.web-dev + position: absolute + top: 0 + bottom: 0 + left: 0 + right: 0 + + #playback-view, #thang-hud, #level-dialogue-view, #play-footer, #level-footer-background, #level-footer-shadow + display: none + + .game-container, .level-content, #game-area, #canvas-wrapper + height: 100% + + #canvas-wrapper canvas + display: none + + #web-surface-view + position: absolute + top: 0 + right: 0 + left: 0 + bottom: 0 + html.fullscreen-editor #level-view #fullscreen-editor-background-screen diff --git a/app/templates/admin.jade b/app/templates/admin.jade index 0e66c2128..f43601bd6 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -44,7 +44,7 @@ block content a(href="/admin/classroom-levels") Classroom Levels li button.classroom-progress-csv.btn.btn-sm.btn-success Classroom Progress CSV - input.classroom-progress-class-code(type=text value="") + input.classroom-progress-class-code(type=text placeholder="") li a(href="/admin/analytics") Dashboard li diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index 6118f447b..2ee0f69b0 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -104,13 +104,23 @@ block content tr td if previousLevelCompleted || view.teacherMode || !passedLastCompletedLevel || levelStatus - - var i18n = level.get('type') === 'course-ladder' ? 'play.compete' : 'home.play'; - button.btn.btn-success.btn-play-level(data-level-slug=level.get('slug'), data-i18n=i18n, data-level-id=level.get('original')) + - var i18nTag = level.isType('course-ladder') ? 'play.compete' : 'home.play'; + button.btn.btn-success.btn-play-level(data-level-slug=level.get('slug'), data-i18n=i18nTag, data-level-id=level.get('original')) + if level.get('shareable') + - var levelOriginal = level.get('original'); + - var session = view.levelSessions.find(function(session) { return session.get('level').original === levelOriginal }); + if session + - var url = '/play/' + level.get('type') + '-level/' + level.get('slug') + '/' + session.id + '?course=' + view.courseID; + a.btn.btn-warning.btn-view-project-level(href=url) + if level.isType('game-dev') + span(data-i18n='sharing.game') + else + span(data-i18n='sharing.webpage') td if view.userLevelStateMap[me.id] div= view.userLevelStateMap[me.id][level.get('original')] td #{level.get('practice') ? 'practice' : 'required'} - td #{levelNumber}. #{level.get('name').replace('Course: ', '')} + td #{levelNumber}. #{i18n(level.attributes, 'name').replace('Course: ', '')} td if view.levelConceptMap[level.get('original')] each concept in view.course.get('concepts') diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index e6e025574..d1581640a 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -122,6 +122,10 @@ block content li(class=(activeTab === "#enrollment-status-tab" ? 'active' : '')) a.course-progress-tab-btn(href='#enrollment-status-tab') .small-details.text-center(data-i18n='teacher.enrollment_status') + .tab-spacer + li(class=(activeTab === "#student-projects-tab" ? 'active' : '')) + a.course-progress-tab-btn(href='#student-projects-tab') + .small-details.text-center(data-i18n='teacher.projects') .tab-filler .tab-content @@ -129,8 +133,10 @@ block content +studentsTab else if activeTab === '#course-progress-tab' +courseProgressTab - else + else if activeTab === '#enrollment-status-tab' +enrollmentStatusTab + else + +studentProjectsTab else .text-center.m-t-5.m-b-5 @@ -150,11 +156,10 @@ mixin breadcrumbs mixin longLevelName(data) if data div.level-name - span.spr Course - span= data.courseNumber - span.spr , Level - span= data.levelNumber - span.spr : + span(data-i18n="courses.course") + span= ' ' + data.courseNumber + ', ' + span(data-i18n="play_level.level") + span= ' ' + data.levelNumber + ': ' span= data.levelName else div.level-name(data-i18n='teacher.not_applicable') @@ -223,6 +228,8 @@ mixin studentRow(student) +longLevelName(student.latestCompleteLevel) td if state.get('progressData') + - var courses = view.classroom.get('courses').map(function(c) { return view.courses.get(c._id); }); + - var courseLabelsArray = view.helper.courseLabelsArray(courses); each trimCourse, index in view.classroom.get('courses') - var course = view.courses.get(trimCourse._id); - var instance = view.courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id }) @@ -230,7 +237,8 @@ mixin studentRow(student) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, user: student }) - var levelsTotal = trimCourse.levels.length //- - var level = ??? - +studentCourseProgressDot(progress, levelsTotal, level, 'CS' + (index+1)) + - var label = courseLabelsArray[index]; + +studentCourseProgressDot(progress, levelsTotal, level, label) unless student.isEnrolled() +enrollStudentButton(student) //- td @@ -305,7 +313,7 @@ mixin courseOverview .course-overview-row .course-title.student-name span= course.get('name') - span : + span= ': ' span(data-i18n='teacher.course_overview') .course-overview-progress each level, index in levels @@ -324,7 +332,7 @@ mixin studentLevelsRow(student) each level, index in levels - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student }) - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1) - +studentLevelProgressDot(progress, level, levelNumber, session) + +studentLevelProgressDot(progress, level, levelNumber) mixin studentCourseProgressDot(progress, levelsTotal, level, label) //- TODO: Refactor with TeacherClassesView jade @@ -336,7 +344,7 @@ mixin studentCourseProgressDot(progress, levelsTotal, level, label) mixin allStudentsLevelProgressDot(progress, level, levelNumber) - dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : ''); - - levelName = level.get('name') + - levelName = i18n(level.attributes, 'name') - context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, numStudents: view.students.length }) .progress-dot.level-progress-dot(class=dotClass, data-html='true', data-title=view.allStudentsLevelProgressDotTemplate(context)) +progressDotLabel(levelNumber) @@ -344,7 +352,7 @@ mixin allStudentsLevelProgressDot(progress, level, levelNumber) mixin studentLevelProgressDot(progress, level, levelNumber) //- TODO: Refactor with TeacherClassesView jade - dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : ''); - - levelName = level.get('name') + - levelName = i18n(level.attributes, 'name') - context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, moment: moment }) .progress-dot.level-progress-dot(class=dotClass, data-html='true', data-title=view.singleStudentLevelProgressDotTemplate(context)) +progressDotLabel(levelNumber) @@ -430,3 +438,37 @@ mixin enrollmentStatusTab td.enroll-col if status !== 'enrolled' button.enroll-student-button.btn.btn-navy(data-i18n="teacher.enroll_student", data-user-id=student.id, data-event-action="Teachers Class Enrollment Enroll Student") + +mixin studentProjectsTab + #student-projects-tab.m-t-3 + if state.get('progressData') + .render-on-course-sync + .student-levels-table + +sortButtons + each student in state.get('students').models + +studentProjectsRow(student) + +mixin studentProjectsRow(student) + .row.student-levels-row.alternating-background + div.student-info.col-sm-3 + div.student-name= student.broadName() + div.student-email.small-details= student.get('email') + div.student-levels-progress.col-sm-9 + each trimCourse in view.classroom.get('courses') + - var course = view.courses.get(trimCourse._id); + - var levels = view.classroom.getLevels({courseID: course.id, projectLevels: true}).models + each level in levels + - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student }) + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1) + +studentProjectLink(progress, level, levelNumber, course) + +mixin studentProjectLink(progress, level, levelNumber, course) + - var colorClass = progress.completed ? 'btn-primary' : (progress.started ? 'btn-warning' : 'btn-primary'); + - var levelName = i18n(level.attributes, 'name') + - var context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, moment: moment }) + - var title = view.singleStudentLevelProgressDotTemplate(context); + if context.session + - var url = '/play/' + level.get('type') + '-level/' + level.get('slug') + '/' + context.session.id + '?course=' + course.id; + a(class="btn btn-lg btn-view-project-level " + colorClass, href=url, data-title=title)= levelName + else + btn(class="btn btn-lg btn-view-project-level " + colorClass, data-title=title, disabled=true)= levelName diff --git a/app/templates/courses/teacher-classes-view.jade b/app/templates/courses/teacher-classes-view.jade index 82e11e3b9..ee8839edd 100644 --- a/app/templates/courses/teacher-classes-view.jade +++ b/app/templates/courses/teacher-classes-view.jade @@ -76,10 +76,13 @@ mixin classRow(classroom) if classroom.get('members').length == 0 +addStudentsButton(classroom) else + - var courses = classroom.get('courses').map(function(c) { return view.courses.get(c._id); }); + - var courseLabelsArray = view.helper.courseLabelsArray(courses); each trimCourse, index in classroom.get('courses') || [] - var course = view.courses.get(trimCourse._id); if view.courseInstances.findWhere({ classroomID: classroom.id, courseID: course.id }) - +progressDot(classroom, course, index) + - var label = courseLabelsArray[index]; + +progressDot(classroom, course, label) .view-class-arrow.col-xs-1 a.view-class-arrow-inner.glyphicon.glyphicon-chevron-right.view-class-btn(data-classroom-id=classroom.id data-event-action="Teachers Classes View Class Chevron") @@ -99,8 +102,7 @@ mixin createClassButton a.create-classroom-btn.btn.btn-lg.btn-primary(data-i18n='teacher.create_new_class') | Create a New Class -mixin progressDot(classroom, course, index) - //- TODO: Give classes abbreviations instead of using index? +mixin progressDot(classroom, course, label) //- TODO: inefficient. Cache this in the view? - courseInstance = view.courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id }) - var total = classroom.get('members').length @@ -113,14 +115,11 @@ mixin progressDot(classroom, course, index) - dotClass = complete === total ? 'forest' : started ? 'gold' : ''; - var progressDotContext = {total: total, complete: complete}; .progress-dot(class=dotClass, data-title=view.progressDotTemplate(progressDotContext)) - +progressDotLabel(index) + +progressDotLabel(label) -mixin progressDotLabel(index) +mixin progressDotLabel(label) .dot-label - .text-h6 - | CS - span - = index + 1 + .text-h6= label mixin archivedClassRow(classroom) .class.row diff --git a/app/templates/editor/level/edit.jade b/app/templates/editor/level/edit.jade index e64012f06..207f446c9 100644 --- a/app/templates/editor/level/edit.jade +++ b/app/templates/editor/level/edit.jade @@ -64,7 +64,7 @@ block header a span.glyphicon-floppy-disk.glyphicon - if level.get('type') === 'ladder' + if level.isType('ladder') li.dropdown a(data-toggle='dropdown').play-with-team-parent span.glyphicon-play.glyphicon diff --git a/app/templates/play/ladder/play_modal.jade b/app/templates/play/ladder/play_modal.jade index 10118e10c..ecb48f01a 100644 --- a/app/templates/play/ladder/play_modal.jade +++ b/app/templates/play/ladder/play_modal.jade @@ -5,7 +5,7 @@ block modal-header-content block modal-body-content - if view.level.get('type') != 'course-ladder' + if !view.level.isType('course-ladder') h4.language-selection(data-i18n="ladder.select_your_language") Select your language! .form-group.select-group select#tome-language(name="language") diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control_bar.jade index 639a389f8..61607bef6 100644 --- a/app/templates/play/level/control_bar.jade +++ b/app/templates/play/level/control_bar.jade @@ -7,24 +7,15 @@ .levels-link-area a.levels-link(href=homeLink || "/") .glyphicon.glyphicon-play - span(data-i18n=me.isSessionless() ? "nav.courses" : (ladderGame ? "general.ladder" : "nav.play")).home-text Levels + span(data-i18n=me.isSessionless() ? "nav.courses" : (ladderGame ? "general.ladder" : "nav.play")).home-text -if isMultiplayerLevel && !observing - .multiplayer-area-container - .multiplayer-area - .multiplayer-label(data-i18n="play_level.control_bar_multiplayer") - if multiplayerStatus - .multiplayer-status= multiplayerStatus - else - .multiplayer-status(data-i18n="play_level.control_bar_join_game") -else - .level-name-area-container - .level-name-area - .level-label(data-i18n="play_level.level") - .level-name(title=difficultyTitle || "") - span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')} - if levelDifficulty - sup.level-difficulty= levelDifficulty +.level-name-area-container + .level-name-area + .level-label(data-i18n="play_level.level") + .level-name(title=difficultyTitle || "") + span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')} + if levelDifficulty + sup.level-difficulty= levelDifficulty .buttons-area diff --git a/app/templates/play/level/modal/hero-victory-modal.jade b/app/templates/play/level/modal/hero-victory-modal.jade index 540962ddd..c1b651c82 100644 --- a/app/templates/play/level/modal/hero-victory-modal.jade +++ b/app/templates/play/level/modal/hero-victory-modal.jade @@ -48,7 +48,7 @@ block modal-body-content textarea(data-i18n="[placeholder]play_level.victory_review_placeholder") .clearfix - if level.get('type', true) === 'hero' || level.get('type') == 'hero-ladder' + if level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') for achievement in achievements - var animate = achievement.completed && !achievement.completedAWhileAgo .achievement-panel(class=achievement.completedAWhileAgo ? 'earned' : '' data-achievement-id=achievement.id data-animate=animate) @@ -108,6 +108,24 @@ block modal-footer-content .total-count#gem-total 0 .total-label(data-i18n="play_level.victory_gems_gained") Gems Gained + if view.shareURL + #share-level-container + span.share-level-label + span(data-i18n='sharing.victory_share_prefix') Share this link to invite your friends & family to + span= ' ' + a(href=view.shareURL, target='_blank') + if view.level.isType('game-dev') + span(data-i18n='sharing.victory_share_game') play your game level + else + span(data-i18n='sharing.victory_share_web') view your webpage + span(data-i18n='sharing.victory_share_suffix') . + .row + .col-sm-9 + input.text-h4.semibold.form-control.input-md#share-level-input(value=view.shareURL) + .col-sm-3 + button#share-level-btn.btn.btn-md.btn-success.btn-illustrated + span(data-i18n='sharing.copy_url') Copy URL + if me.get('anonymous') .sign-up-poke.hide .sign-up-blurb(data-i18n="play_level.victory_sign_up_poke") Want to save your code? Create a free account! @@ -118,7 +136,7 @@ block modal-footer-content .next-level-buttons if readyToRank .ladder-submission-view - else if level.get('type') === 'hero-ladder' + else if level.isType('hero-ladder') button.btn.btn-illustrated.btn-primary.btn-lg.return-to-ladder-button(data-href="/play/ladder/#{level.get('slug')}#my-matches", data-dismiss="modal", data-i18n="play_level.victory_return_to_ladder") Return to Ladder else button.btn.btn-illustrated.btn-success.btn-lg.world-map-button.next-level-button.hide#continue-button(data-i18n="common.continue") Continue diff --git a/app/templates/play/level/modal/image-gallery-modal.jade b/app/templates/play/level/modal/image-gallery-modal.jade new file mode 100644 index 000000000..7899ea8c2 --- /dev/null +++ b/app/templates/play/level/modal/image-gallery-modal.jade @@ -0,0 +1,23 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + h3(data-i18n="web_dev.image_gallery_title") + span(data-i18n="web_dev.image_gallery_description") + +block modal-body-content + dl.dl-horizontal + for image in view.images + dt + img(src=image.portraitURL) + dd + ul.list-unstyled + li + span.no-select= 'URL: ' + kbd= image.portraitURL + br + li + span.no-select= ': ' + kbd= '' + +block modal-footer-content + a(href='#', data-dismiss="modal", aria-hidden="true", data-i18n="modal.close").btn.btn-primary Close diff --git a/app/templates/play/level/modal/progress-view.jade b/app/templates/play/level/modal/progress-view.jade index 8c754fb33..35f331c8b 100644 --- a/app/templates/play/level/modal/progress-view.jade +++ b/app/templates/play/level/modal/progress-view.jade @@ -38,12 +38,37 @@ span : h2.text-uppercase= i18n(view.nextLevel.attributes, 'name').replace('Course: ', '') - div!= view.nextLevelDescription + div.next-level-description!= view.nextLevelDescription + + if view.shareURL + .well.well-sm.well-parchment + h3.text-uppercase + if view.level.isType('game-dev') + span(data-i18n='sharing.share_game') + else + span(data-i18n='sharing.share_web') + p + span(data-i18n='sharing.victory_course_share_prefix') + span= ' ' + a(href=view.shareURL, target='_blank') + if view.level.isType('game-dev') + span(data-i18n='sharing.victory_course_share_game') + else + span(data-i18n='sharing.victory_course_share_web') + span= ' ' + span(data-i18n='sharing.victory_course_share_suffix') + .row + .col-sm-9 + input.text-h4.semibold.form-control.input-lg#share-level-input(value=view.shareURL) + .col-sm-3 + button#share-level-btn.btn.btn-lg.btn-success.btn-illustrated + span(data-i18n='sharing.copy_url') .row .col-sm-5.col-sm-offset-2 - // TODO: Add this and rest of campaign functionality - // button#continue-btn.btn.btn-illustrated.btn-default.btn-block.btn-lg.text-uppercase View Leaderboards + // TODO: Add rest of campaign functionality + if view.level.get('type') === 'course-ladder' + button#ladder-btn.btn.btn-illustrated.btn-default.btn-block.btn-lg.text-uppercase Ladder .col-sm-5 if !view.nextLevel.isNew() button#next-level-btn.btn.btn-illustrated.btn-primary.btn-block.btn-lg.text-uppercase(data-i18n='play_level.next_level') diff --git a/app/templates/play/level/modal/victory.jade b/app/templates/play/level/modal/victory.jade index effaad52c..6e6b9c56b 100644 --- a/app/templates/play/level/modal/victory.jade +++ b/app/templates/play/level/modal/victory.jade @@ -13,7 +13,7 @@ block modal-body-content block modal-footer-content if readyToRank .ladder-submission-view - else if level.get('type') === 'ladder' + else if level.isType('ladder') a.btn.btn-primary(href="/play/ladder/#{level.get('slug')}#my-matches", data-dismiss="modal", data-i18n="play_level.victory_return_to_ladder") Return to Ladder else a.btn.btn-primary(href="/", data-dismiss="modal", data-i18n="play_level.victory_go_home") Go Home diff --git a/app/templates/play/level/play-game-dev-level-view.jade b/app/templates/play/level/play-game-dev-level-view.jade new file mode 100644 index 000000000..c4b80c67e --- /dev/null +++ b/app/templates/play/level/play-game-dev-level-view.jade @@ -0,0 +1,38 @@ +.container-fluid + .row + .col-xs-9 + #canvas-wrapper + canvas(width=924, height=589)#webgl-surface + canvas(width=924, height=589)#normal-surface + + .col-xs-3#info-col.style-flat + if view.state.get('errorMessage') + .alert.alert-danger= view.state.get('errorMessage') + + else if view.state.get('loading') + h1.m-y-1(data-i18n="common.loading") + .progress + .progress-bar(style="width: #{view.state.get('progress')}") + + else + h1.m-y-1 Info + ul + li + b + span(data-i18n="play_level.level") + span= ': ' + | #{view.level.get('name')} + + li + b + span(data-i18n="game_dev.creator") + span= ': ' + | #{view.session.get('creatorName')} + + - var playing = view.state.get('playing') + .m-y-3 + if playing + button#play-btn.btn.btn-lg.btn-burgandy(data-i18n="play_level.restart") + else + button#play-btn.btn.btn-lg.btn-navy(data-i18n="common.play") + diff --git a/app/templates/play/level/play-web-dev-level-view.jade b/app/templates/play/level/play-web-dev-level-view.jade new file mode 100644 index 000000000..4e87873de --- /dev/null +++ b/app/templates/play/level/play-web-dev-level-view.jade @@ -0,0 +1,11 @@ +#web-surface-view + +#info-bar.style-flat + if !view.supermodel.finished() + h1(data-i18n="common.loading") + + else + h1 + span(data-i18n="game_dev.creator") + span= ': ' + | #{view.session.get('creatorName')} diff --git a/app/templates/play/level/tome/spell_list_tab_entry.jade b/app/templates/play/level/tome/spell-top-bar-view.jade similarity index 50% rename from app/templates/play/level/tome/spell_list_tab_entry.jade rename to app/templates/play/level/tome/spell-top-bar-view.jade index 5f5b02af9..2af252e1e 100644 --- a/app/templates/play/level/tome/spell_list_tab_entry.jade +++ b/app/templates/play/level/tome/spell-top-bar-view.jade @@ -3,16 +3,10 @@ .hinge.hinge-2 .hinge.hinge-3 -if includeSpellList - .btn.btn-small.btn-illustrated.spell-list-button(data-i18n="[title]play_level.tome_see_all_methods", title="See all methods you can edit") - .glyphicon.glyphicon-chevron-down - -.thang-avatar-placeholder - .spell-tool-buttons - .btn.btn-small.btn-illustrated.btn-warning.reload-code(data-i18n="[title]play_level.tome_reload_method", title="Reload original code for this method") + .btn.btn-small.btn-illustrated.btn-warning.reload-code(data-i18n="[title]play_level.tome_reload_method") .glyphicon.glyphicon-repeat - span.spl(data-i18n="play_level.reload") Reload + span.spl(data-i18n="play_level.restart") if me.level() >= 15 .btn.btn-small.btn-illustrated.fullscreen-code(title=maximizeShortcutVerbose) @@ -27,4 +21,17 @@ if includeSpellList .btn.btn-small.btn-illustrated.hints-button span(data-i18n="play_level.hints") + if view.options.level.isType('web-dev') + .btn.btn-small.btn-illustrated.image-gallery-button + span(data-i18n='web_dev.image_gallery_title') + + if view.options.level.get('shareable') + - var url = '/play/' + view.options.level.get('type') + '-level/' + view.options.level.get('slug') + '/' + view.options.session.id; + - if (view.options.courseID) url += '?course=' + view.options.courseID; + a.btn.btn-small.btn-illustrated(href=url) + if view.options.level.isType('game-dev') + span(data-i18n='sharing.game') + else + span(data-i18n='sharing.webpage') + .clearfix diff --git a/app/templates/play/level/tome/spell_list.jade b/app/templates/play/level/tome/spell_list.jade deleted file mode 100644 index 84b28511a..000000000 --- a/app/templates/play/level/tome/spell_list.jade +++ /dev/null @@ -1 +0,0 @@ -h5(data-i18n="play_level.tome_select_method") Select a Method diff --git a/app/templates/play/level/tome/spell_list_entry.jade b/app/templates/play/level/tome/spell_list_entry.jade deleted file mode 100644 index 561591078..000000000 --- a/app/templates/play/level/tome/spell_list_entry.jade +++ /dev/null @@ -1,7 +0,0 @@ -if showTopDivider - // Don't repeat Thang names when not changed from previous entry - .thang-names(title=thangNames)= thangNames - -code #{spell.name}(#{parameters}) - - diff --git a/app/templates/play/level/tome/spell_list_entry_thangs.jade b/app/templates/play/level/tome/spell_list_entry_thangs.jade deleted file mode 100644 index bcc7804f1..000000000 --- a/app/templates/play/level/tome/spell_list_entry_thangs.jade +++ /dev/null @@ -1,3 +0,0 @@ -h4 - span(data-i18n="play_level.tome_select_a_thang") Select Someone for - code #{view.spell.name}(#{(view.spell.parameters || []).join(", ")}) diff --git a/app/templates/play/level/tome/spell_palette_entry_popover.jade b/app/templates/play/level/tome/spell_palette_entry_popover.jade index 5ae7d312c..dd5306643 100644 --- a/app/templates/play/level/tome/spell_palette_entry_popover.jade +++ b/app/templates/play/level/tome/spell_palette_entry_popover.jade @@ -95,7 +95,7 @@ if !selectedMethod else if language == 'io' span= (doc.ownerName == 'this' ? '' : doc.ownerName + ' ') + docName + '(' + argumentExamples.join(', ') + ')' -if (doc.type != 'function' && doc.type != 'snippet') || doc.name == 'now' +if (doc.type != 'function' && doc.type != 'snippet' && doc.owner != 'HTML' && doc.owner != 'CSS') || doc.name == 'now' p.value strong span(data-i18n="skill_docs.current_value") Current Value diff --git a/app/templates/play/level/tome/tome.jade b/app/templates/play/level/tome/tome.jade index c29f9e4f0..526d8bc4d 100644 --- a/app/templates/play/level/tome/tome.jade +++ b/app/templates/play/level/tome/tome.jade @@ -1,11 +1,7 @@ -#spell-list-tab-entry-view - -#spell-list-view +#spell-top-bar-view #cast-button-view #spell-view #spell-palette-view - - diff --git a/app/templates/play/level/web-surface-view.jade b/app/templates/play/level/web-surface-view.jade new file mode 100644 index 000000000..f2991ad44 --- /dev/null +++ b/app/templates/play/level/web-surface-view.jade @@ -0,0 +1 @@ +iframe(src="/web-dev-iframe.html") diff --git a/app/templates/play/menu/multiplayer-view.jade b/app/templates/play/menu/multiplayer-view.jade deleted file mode 100644 index c45a1259c..000000000 --- a/app/templates/play/menu/multiplayer-view.jade +++ /dev/null @@ -1,90 +0,0 @@ -if !ladderGame - .form - .form-group.checkbox - label(for="multiplayer") - input#multiplayer(name="multiplayer", type="checkbox", checked=multiplayer) - span(data-i18n="multiplayer.multiplayer_toggle") Enable multiplayer - span.help-block(data-i18n="multiplayer.multiplayer_toggle_description") Allow others to join your game. - - hr - - div#link-area - p(data-i18n="multiplayer.multiplayer_link_description") Give this link to anyone to have them join you. - - textarea.well#multiplayer-join-link(readonly=true)= joinLink - - p - strong(data-i18n="multiplayer.multiplayer_hint_label") Hint: - span(data-i18n="multiplayer.multiplayer_hint") Click the link to select all, then press ⌘-C or Ctrl-C to copy the link. - - p(data-i18n="multiplayer.multiplayer_coming_soon") More multiplayer features to come! - -if ladderGame - if me.get('anonymous') - p(data-i18n="multiplayer.multiplayer_sign_in_leaderboard") Sign in or create an account and get your solution on the leaderboard. - else if realTimeSessions && realTimeSessionsPlayers - button#create-game-button Create Game - - hr - - div#created-multiplayer-session - h3 Your Game - if currentRealTimeSession - div - span(style="margin:10px")= currentRealTimeSession.get('levelID') - span(style="margin:10px")= currentRealTimeSession.get('creatorName') - span(style="margin:10px")= currentRealTimeSession.get('state') - span(style="margin:10px")= currentRealTimeSession.id - button#leave-game-button(data-item=item) Leave Game - div - - var players = realTimeSessionsPlayers[currentRealTimeSession.id] - if players - span(style="margin:10px") Players: - - for (var i=0; i < players.length; i++) { - span(style="margin:10px")= players.at(i).get('name') - span(style="margin:10px")= players.at(i).get('team') - span(style="margin:10px")= players.at(i).get('state') - - } - else - span No Players? - else - div Click something above to create a game. - - hr - - div#open-games - h3 Open Games - //- TODO: do not let you join ones with same-team opponent - - var noOpenGames = true - - for (var i=0; i < realTimeSessions.length; i++) { - if (currentRealTimeSession && realTimeSessions.at(i).id == currentRealTimeSession.id) - - continue - if levelID === realTimeSessions.at(i).get('levelID') && realTimeSessions.at(i).get('state') === 'creating' - - var id = realTimeSessions.at(i).get('id') - - var players = realTimeSessionsPlayers[id] - if players && players.length === 1 - - noOpenGames = false - - var creatorName = realTimeSessions.at(i).get('creatorName') - - var creator = realTimeSessions.at(i).get('creator') - - var state = realTimeSessions.at(i).get('state') - - var item = realTimeSessions.at(i) - div - button#join-game-button(data-item=item) Join Game - span(style="margin:10px")= levelID - span(style="margin:10px")= creatorName - span(style="margin:10px")= state - span(style="margin:10px")= id - div - span(style="margin:10px") Players: - span(style="margin:10px")= players.at(0).get('name') - span(style="margin:10px")= players.at(0).get('team') - span(style="margin:10px")= players.at(0).get('state') - - } - if noOpenGames - div No games available. - - hr - - .ladder-submission-view - else - a.btn.btn-primary(href="/play/ladder/#{levelSlug}#my-matches", data-i18n="multiplayer.victory_go_ladder") Return to Ladder diff --git a/app/templates/play/play-level-view.jade b/app/templates/play/play-level-view.jade index a225ea79b..77508fcfb 100644 --- a/app/templates/play/play-level-view.jade +++ b/app/templates/play/play-level-view.jade @@ -24,6 +24,8 @@ if view.showAds() #canvas-wrapper canvas(width=924, height=589)#webgl-surface canvas(width=924, height=589)#normal-surface + + #web-surface-view #ascii-surface #canvas-left-gradient.gradient #canvas-top-gradient.gradient diff --git a/app/views/admin/MainAdminView.coffee b/app/views/admin/MainAdminView.coffee index dd4b33b32..f6be77f19 100644 --- a/app/views/admin/MainAdminView.coffee +++ b/app/views/admin/MainAdminView.coffee @@ -9,6 +9,7 @@ Campaigns = require 'collections/Campaigns' Classroom = require 'models/Classroom' CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' +Courses = require 'collections/Courses' LevelSessions = require 'collections/LevelSessions' User = require 'models/User' Users = require 'collections/Users' @@ -152,6 +153,7 @@ module.exports = class MainAdminView extends RootView $('.classroom-progress-csv').prop('disabled', true) classCode = $('.classroom-progress-class-code').val() classroom = null + courses = null courseLevels = [] sessions = null users = null @@ -161,12 +163,16 @@ module.exports = class MainAdminView extends RootView classroom = new Classroom({ _id: model.data._id }) Promise.resolve(classroom.fetch()) .then (model) => + courses = new Courses() + Promise.resolve(courses.fetch()) + .then (models) => for course, index in classroom.get('courses') for level in course.levels courseLevels.push courseIndex: index + 1 levelID: level.original slug: level.slug + courseSlug: courses.get(course._id).get('slug') users = new Users() Promise.resolve($.when(users.fetchForClassroom(classroom)...)) .then (models) => @@ -202,12 +208,19 @@ module.exports = class MainAdminView extends RootView columnLabels = "Username" currentLevel = 1 + courseLabelIndexes = CS: 1, GD: 0, WD: 0 lastCourseIndex = 1 + lastCourseLabel = 'CS1' for level in courseLevels unless level.courseIndex is lastCourseIndex currentLevel = 1 lastCourseIndex = level.courseIndex - columnLabels += ",CS#{level.courseIndex}.#{currentLevel++} #{level.slug}" + acronym = switch + when /game-dev/.test(level.courseSlug) then 'GD' + when /web-dev/.test(level.courseSlug) then 'WD' + else 'CS' + lastCourseLabel = acronym + ++courseLabelIndexes[acronym] + columnLabels += ",#{lastCourseLabel}.#{currentLevel++} #{level.slug}" csvContent = "data:text/csv;charset=utf-8,#{columnLabels}\n" for studentRow in userPlaytimes csvContent += studentRow.join(',') + "\n" diff --git a/app/views/clans/ClanDetailsView.coffee b/app/views/clans/ClanDetailsView.coffee index 8e8d7bc12..c9bcdb1e3 100644 --- a/app/views/clans/ClanDetailsView.coffee +++ b/app/views/clans/ClanDetailsView.coffee @@ -195,7 +195,7 @@ module.exports = class ClanDetailsView extends RootView if level.concepts? for concept in level.concepts @conceptsProgression.push concept unless concept in @conceptsProgression - if level.type is 'hero-ladder' and level.slug not in ['capture-their-flag'] + if level.type is 'hero-ladder' and level.slug not in ['capture-their-flag'] # Would use isType, but it's not a Level model @arenas.push level @campaignLevelProgressions.push campaignLevelProgression @render?() diff --git a/app/views/core/CocoView.coffee b/app/views/core/CocoView.coffee index 3ec65e5f0..3450ac721 100644 --- a/app/views/core/CocoView.coffee +++ b/app/views/core/CocoView.coffee @@ -496,6 +496,13 @@ module.exports = class CocoView extends Backbone.View playSound: (trigger, volume=1) -> Backbone.Mediator.publish 'audio-player:play-sound', trigger: trigger, volume: volume + tryCopy: -> + try + document.execCommand('copy') + catch err + message = 'Oops, unable to copy' + noty text: message, layout: 'topCenter', type: 'error', killer: false + mobileRELong = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i mobileREShort = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index 8a659103d..2f7e4c4f4 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -52,7 +52,7 @@ module.exports = class CourseDetailsView extends RootView @supermodel.trackRequest(@classroom.fetch()) levelsLoaded = @supermodel.trackRequest(@levels.fetchForClassroomAndCourse(classroomID, @courseID, { - data: { project: 'concepts,practice,type,slug,name,original,description' } + data: { project: 'concepts,practice,type,slug,name,original,description,shareable,i18n' } })) @supermodel.trackRequest($.when(levelsLoaded, sessionsLoaded).then(=> @@ -62,7 +62,7 @@ module.exports = class CourseDetailsView extends RootView # need to figure out the next course instance @courseComplete = true @courseInstances.comparator = 'courseID' - # TODO: make this logic use locked course content to figure out the next course, then fetch the + # TODO: make this logic use locked course content to figure out the next course, then fetch the # course instance for that @supermodel.trackRequest(@courseInstances.fetchForClassroom(classroomID).then(=> @nextCourseInstance = _.find @courseInstances.models, (ci) => ci.get('courseID') > @courseID @@ -84,9 +84,9 @@ module.exports = class CourseDetailsView extends RootView @levelConceptMap = {} for level in @levels.models @levelConceptMap[level.get('original')] ?= {} - for concept in level.get('concepts') + for concept in level.get('concepts') or [] @levelConceptMap[level.get('original')][concept] = true - if level.get('type') is 'course-ladder' + if level.isType('course-ladder') @arenaLevel = level # console.log 'onLevelSessionsSync' @@ -124,13 +124,13 @@ module.exports = class CourseDetailsView extends RootView for concept, state of conceptStateMap @conceptsCompleted[concept] ?= 0 @conceptsCompleted[concept]++ - + onClickPlayLevel: (e) -> levelSlug = $(e.target).closest('.btn-play-level').data('level-slug') levelID = $(e.target).closest('.btn-play-level').data('level-id') level = @levels.findWhere({original: levelID}) window.tracker?.trackEvent 'Students Class Course Play Level', category: 'Students', courseID: @courseID, courseInstanceID: @courseInstanceID, levelSlug: levelSlug, ['Mixpanel'] - if level.get('type') is 'course-ladder' + if level.isType('course-ladder') viewClass = 'views/ladder/LadderView' viewArgs = [{supermodel: @supermodel}, levelSlug] route = '/play/ladder/' + levelSlug diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 711ef4c82..5cd64b0ec 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -23,6 +23,7 @@ CourseInstances = require 'collections/CourseInstances' module.exports = class TeacherClassView extends RootView id: 'teacher-class-view' template: template + helper: helper events: 'click .nav-tabs a': 'onClickNavTabLink' @@ -43,7 +44,7 @@ module.exports = class TeacherClassView extends RootView 'click .student-checkbox': 'onClickStudentCheckbox' 'keyup #student-search': 'onKeyPressStudentSearch' 'change .course-select, .bulk-course-select': 'onChangeCourseSelect' - + getInitialState: -> { sortAttribute: 'name' @@ -72,21 +73,21 @@ module.exports = class TeacherClassView extends RootView @singleStudentCourseProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-course' @singleStudentLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-level' @allStudentsLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-all-students-single-level' - + @debouncedRender = _.debounce @render - + @state = new State(@getInitialState()) @updateHash @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab) - + @classroom = new Classroom({ _id: classroomID }) @supermodel.trackRequest @classroom.fetch() @onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200) - + @students = new Users() @listenTo @classroom, 'sync', -> jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true) @supermodel.trackRequests jqxhrs - + @classroom.sessions = new LevelSessions() requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom) @supermodel.trackRequests(requests) @@ -96,7 +97,7 @@ module.exports = class TeacherClassView extends RootView value = @state.get('sortValue') if value is 'name' return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir) - + if value is 'progress' # TODO: I would like for this to be in the Level model, # but it doesn't know about its own courseNumber. @@ -105,7 +106,7 @@ module.exports = class TeacherClassView extends RootView return -dir if not level1 return dir if not level2 return dir * (level1.courseNumber - level2.courseNumber or level1.levelNumber - level2.levelNumber) - + if value is 'status' statusMap = { expired: 0, 'not-enrolled': 1, enrolled: 2 } diff = statusMap[student1.prepaidStatus()] - statusMap[student2.prepaidStatus()] @@ -114,13 +115,13 @@ module.exports = class TeacherClassView extends RootView @courses = new Courses() @supermodel.trackRequest @courses.fetch() - + @courseInstances = new CourseInstances() @supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID) @levels = new Levels() - @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice'}}) - + @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice,shareable,i18n'}}) + @attachMediatorEvents() window.tracker?.trackEvent 'Teachers Class Loaded', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] @@ -160,11 +161,11 @@ module.exports = class TeacherClassView extends RootView course.instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id }) course.members = course.instance?.get('members') or [] null - + onLoaded: -> @removeDeletedStudents() # TODO: Move this to mediator listeners? For both classroom and students? @calculateProgressAndLevels() - + # render callback setup @listenTo @courseInstances, 'sync change update', @debouncedRender @listenTo @state, 'sync change', -> @@ -174,17 +175,17 @@ module.exports = class TeacherClassView extends RootView @debouncedRender() @listenTo @students, 'sort', @debouncedRender super() - + afterRender: -> super(arguments...) - $('.progress-dot').each (i, el) -> + $('.progress-dot, .btn-view-project-level').each (i, el) -> dot = $(el) dot.tooltip({ html: true container: dot }).delegate '.tooltip', 'mousemove', -> dot.tooltip('hide') - + calculateProgressAndLevels: -> return unless @supermodel.progress is 1 # TODO: How to structure this in @state? @@ -192,14 +193,14 @@ module.exports = class TeacherClassView extends RootView # TODO: this is a weird hack studentsStub = new Users([ student ]) student.latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, studentsStub) - + earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students) latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students) - + classroomsStub = new Classrooms([ @classroom ]) progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students) # conceptData: helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students) - + @state.set { earliestIncompleteLevel latestCompleteLevel @@ -212,7 +213,7 @@ module.exports = class TeacherClassView extends RootView hash = $(e.target).closest('a').attr('href') @updateHash(hash) @state.set activeTab: hash - + updateHash: (hash) -> return if application.testing window.location.hash = hash @@ -227,17 +228,10 @@ module.exports = class TeacherClassView extends RootView @$('#join-url-input').val(@state.get('joinURL')).select() @tryCopy() - tryCopy: -> - try - document.execCommand('copy') - catch err - message = 'Oops, unable to copy' - noty text: message, layout: 'topCenter', type: 'error', killer: false - onClickUnarchive: -> window.tracker?.trackEvent 'Teachers Class Unarchive', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] @classroom.save { archived: false } - + onClickEditClassroom: (e) -> window.tracker?.trackEvent 'Teachers Class Edit Class Started', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] classroom = @classroom @@ -328,9 +322,11 @@ module.exports = class TeacherClassView extends RootView window.tracker?.trackEvent 'Teachers Class Export CSV', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] courseLabels = "" courseOrder = [] - for course, index in @classroom.get('courses') - courseLabels += "CS#{index + 1} Playtime," - courseOrder.push(course._id) + courses = (@courses.get(c._id) for c in @classroom.get('courses')) + courseLabelsArray = helper.courseLabelsArray courses + for course, index in courses + courseLabels += "#{courseLabelsArray[index]} Playtime," + courseOrder.push(course.id) csvContent = "data:text/csv;charset=utf-8,Username,Email,Total Playtime,#{courseLabels}Concepts\n" levelCourseMap = {} for trimCourse in @classroom.get('courses') @@ -396,6 +392,7 @@ module.exports = class TeacherClassView extends RootView not @students.get(userID).isEnrolled() assigningToNobody = selectedIDs.length is 0 @state.set errors: { assigningToNobody, assigningToUnenrolled } + return if assigningToNobody @assignCourse courseID, members window.tracker?.trackEvent 'Teachers Class Students Assign Selected', category: 'Teachers', classroomID: @classroom.id, courseID: courseID, ['Mixpanel'] @@ -459,7 +456,7 @@ module.exports = class TeacherClassView extends RootView enrolledUsers = @students.filter (user) -> user.isEnrolled() stats.enrolledUsers = _.size(enrolledUsers) - + return stats studentStatusString: (student) -> diff --git a/app/views/courses/TeacherClassesView.coffee b/app/views/courses/TeacherClassesView.coffee index b281a2ce0..5dc0c77ee 100644 --- a/app/views/courses/TeacherClassesView.coffee +++ b/app/views/courses/TeacherClassesView.coffee @@ -17,6 +17,7 @@ helper = require 'lib/coursesHelper' module.exports = class TeacherClassesView extends RootView id: 'teacher-classes-view' template: template + helper: helper events: 'click .edit-classroom': 'onClickEditClassroom' diff --git a/app/views/editor/component/ThangComponentConfigView.coffee b/app/views/editor/component/ThangComponentConfigView.coffee index dc5498b41..2182e2a8c 100644 --- a/app/views/editor/component/ThangComponentConfigView.coffee +++ b/app/views/editor/component/ThangComponentConfigView.coffee @@ -46,7 +46,7 @@ module.exports = class ThangComponentConfigView extends CocoView schema.default ?= {} _.merge schema.default, @additionalDefaults if @additionalDefaults - if @level?.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if @level?.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') schema.required = [] treemaOptions = supermodel: @supermodel diff --git a/app/views/editor/level/LevelEditView.coffee b/app/views/editor/level/LevelEditView.coffee index bf86e6825..32c45bf9b 100644 --- a/app/views/editor/level/LevelEditView.coffee +++ b/app/views/editor/level/LevelEditView.coffee @@ -37,6 +37,7 @@ require 'vendor/aether-python' require 'vendor/aether-coffeescript' require 'vendor/aether-lua' require 'vendor/aether-java' +require 'vendor/aether-html' module.exports = class LevelEditView extends RootView id: 'editor-level-view' diff --git a/app/views/editor/level/thangs/LevelThangEditView.coffee b/app/views/editor/level/thangs/LevelThangEditView.coffee index 84429d644..46da5067d 100644 --- a/app/views/editor/level/thangs/LevelThangEditView.coffee +++ b/app/views/editor/level/thangs/LevelThangEditView.coffee @@ -41,7 +41,7 @@ module.exports = class LevelThangEditView extends CocoView level: @level world: @world - if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then options.thangType = thangType + if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') then options.thangType = thangType @thangComponentEditView = new ThangComponentsEditView options @listenTo @thangComponentEditView, 'components-changed', @onComponentsChanged diff --git a/app/views/editor/level/thangs/ThangsTabView.coffee b/app/views/editor/level/thangs/ThangsTabView.coffee index 1e794a40d..a62c28460 100644 --- a/app/views/editor/level/thangs/ThangsTabView.coffee +++ b/app/views/editor/level/thangs/ThangsTabView.coffee @@ -251,7 +251,7 @@ module.exports = class ThangsTabView extends CocoView @dragged = 0 @willUnselectSprite = false @gameUIState.set('canDragCamera', true) - + if @addThangLank?.thangType.get('kind') is 'Wall' @paintingWalls = true @gameUIState.set('canDragCamera', false) @@ -259,7 +259,7 @@ module.exports = class ThangsTabView extends CocoView else if @addThangLank # We clicked on the background when we had an add Thang selected, so add it @addThang @addThangType, @addThangLank.thang.pos - + else if e.onBackground @gameUIState.set('selected', []) @@ -331,18 +331,18 @@ module.exports = class ThangsTabView extends CocoView @onSpriteContextMenu e clearInterval(@movementInterval) if @movementInterval? @movementInterval = null - + return unless _.any(selected) - + for singleSelected in selected pos = singleSelected.thang.pos - + thang = _.find(@level.get('thangs') ? [], {id: singleSelected.thang.id}) path = "#{@pathForThang(thang)}/components/original=#{LevelComponent.PhysicalID}" physical = @thangsTreema.get path continue if not physical or (physical.config.pos.x is pos.x and physical.config.pos.y is pos.y) @thangsTreema.set path + '/config/pos', x: pos.x, y: pos.y, z: pos.z - + if @willUnselectSprite clickedSprite = _.find(selected, {sprite: e.sprite}) @gameUIState.set('selected', _.without(selected, clickedSprite)) @@ -379,7 +379,7 @@ module.exports = class ThangsTabView extends CocoView thang = selected?.thang previousSprite?.setNameLabel?(null) unless previousSprite is sprite - + if thang and not (@addThangLank and @addThangType.get('name') in overlappableThangTypeNames) # We clicked on a Thang (or its Treema), so select the Thang @selectAddThang(null, true) @@ -619,7 +619,7 @@ module.exports = class ThangsTabView extends CocoView onTreemaThangSelected: (e, selectedTreemas) => selectedThangTreemas = _.filter(selectedTreemas, (t) -> t instanceof ThangNode) thangIDs = (node.data.id for node in selectedThangTreemas) - lanks = (@surface.lankBoss.lanks[thangID] for thangID in thangIDs when thangID) + lanks = (@surface.lankBoss.lanks[thangID] for thangID in thangIDs when thangID) selected = ({ thang: lank.thang, sprite: lank } for lank in lanks when lank) @gameUIState.set('selected', selected) @@ -636,14 +636,14 @@ module.exports = class ThangsTabView extends CocoView if batchInsert if thangType.get('name') is 'Hero Placeholder' thangID = 'Hero Placeholder' - return if not (@level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) or @getThangByID(thangID) + return if not @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') or @getThangByID(thangID) else thangID = "Random #{thangType.get('name')} #{@thangsBatch.length}" else thangID = Thang.nextID(thangType.get('name'), @world) until thangID and not @getThangByID(thangID) if @cloneSourceThang components = _.cloneDeep @getThangByID(@cloneSourceThang.id).components - else if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + else if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') components = [] # Load them all from default ThangType Components else components = _.cloneDeep thangType.get('components') ? [] diff --git a/app/views/editor/verifier/VerifierTest.coffee b/app/views/editor/verifier/VerifierTest.coffee index 926ed1dee..6f0922b5f 100644 --- a/app/views/editor/verifier/VerifierTest.coffee +++ b/app/views/editor/verifier/VerifierTest.coffee @@ -78,7 +78,7 @@ module.exports = class VerifierTest extends CocoClass @listenToOnce @god, 'infinite-loop', @fail @listenToOnce @god, 'user-code-problem', @onUserCodeProblem @listenToOnce @god, 'goals-calculated', @processSingleGameResults - @god.createWorld @generateSpellsObject() + @god.createWorld @session.generateSpellsObject() @updateCallback? state: 'running' processSingleGameResults: (e) -> @@ -118,18 +118,6 @@ module.exports = class VerifierTest extends CocoClass @updateCallback? state: @state @scheduleCleanup() - generateSpellsObject: -> - aetherOptions = createAetherOptions functionName: 'plan', codeLanguage: @session.get('codeLanguage') - spellThang = aether: new Aether aetherOptions - spells = "hero-placeholder/plan": thangs: {'Hero Placeholder': spellThang}, name: 'plan' - source = @session.get('code')['hero-placeholder'].plan - try - spellThang.aether.transpile source - catch e - console.log "Couldn't transpile!\n#{source}\n", e - spellThang.aether.transpile '' - spells - scheduleCleanup: -> setTimeout @cleanup, 100 diff --git a/app/views/editor/verifier/VerifierView.coffee b/app/views/editor/verifier/VerifierView.coffee index 32c8e22e2..423ea104d 100644 --- a/app/views/editor/verifier/VerifierView.coffee +++ b/app/views/editor/verifier/VerifierView.coffee @@ -51,7 +51,7 @@ module.exports = class VerifierView extends RootView for campaign in @campaigns.models when campaign.get('type') in ['course', 'hero'] and campaign.get('slug') isnt 'picoctf' @levelsByCampaign[campaign.get('slug')] ?= {levels: [], checked: true} campaignInfo = @levelsByCampaign[campaign.get('slug')] - for levelID, level of campaign.get('levels') when level.type not in ['hero-ladder', 'course-ladder', 'game-dev'] + for levelID, level of campaign.get('levels') when level.type not in ['hero-ladder', 'course-ladder', 'game-dev', 'web-dev'] # Would use isType, but it's not a Level model campaignInfo.levels.push level.slug filterCodeLanguages: -> diff --git a/app/views/ladder/LadderPlayModal.coffee b/app/views/ladder/LadderPlayModal.coffee index 77a87fd2d..a60b10d08 100644 --- a/app/views/ladder/LadderPlayModal.coffee +++ b/app/views/ladder/LadderPlayModal.coffee @@ -26,8 +26,8 @@ module.exports = class LadderPlayModal extends ModalView initialize: (options, @level, @session, @team) -> @otherTeam = if @team is 'ogres' then 'humans' else 'ogres' - @startLoadingChallengersMaybe() @wizardType = ThangType.loadUniversalWizard() + @startLoadingChallengersMaybe() @levelID = @level.get('slug') or @level.id @language = @session?.get('codeLanguage') ? me.get('aceConfig')?.language ? 'python' @languages = [ diff --git a/app/views/ladder/MainLadderView.coffee b/app/views/ladder/MainLadderView.coffee index b4108ee42..171f8b3ee 100644 --- a/app/views/ladder/MainLadderView.coffee +++ b/app/views/ladder/MainLadderView.coffee @@ -21,7 +21,7 @@ module.exports = class MainLadderView extends RootView @campaigns = campaigns @sessions = @supermodel.loadCollection(new LevelSessionsCollection(), 'your_sessions', {cache: false}, 0).model - @listenToOnce @sessions, 'sync', @onSessionsLoaded + @listenToOnce @sessions, 'sync', @onSessionsLoaded @getLevelPlayCounts() @@ -94,52 +94,6 @@ heroArenas = [ } ] -oldArenas = [ - { - name: 'Criss-Cross' - difficulty: 5 - id: 'criss-cross' - image: '/file/db/level/5391f3d519dc22b8082159b2/banner2.png' - description: 'Participate in a bidding war with opponents to reach the other side!' - } - { - name: 'Greed' - difficulty: 4 - id: 'greed' - image: '/file/db/level/53558b5a9914f5a90d7ccddb/greed_banner.jpg' - description: 'Liked Dungeon Arena and Gold Rush? Put them together in this economic arena!' - } - { - name: 'Sky Span (Testing)' - difficulty: 3 - id: 'sky-span' - image: '/file/db/level/53c80fce0ddbef000084c667/sky-Span-banner.jpg' - description: 'Preview version of an upgraded Dungeon Arena. Help us with hero balance before release!' - } - { - name: 'Dungeon Arena' - difficulty: 3 - id: 'dungeon-arena' - image: '/file/db/level/53173f76c269d400000543c2/Level%20Banner%20Dungeon%20Arena.jpg' - description: 'Play head-to-head against fellow Wizards in a dungeon melee!' - } - { - name: 'Gold Rush' - difficulty: 3 - id: 'gold-rush' - image: '/file/db/level/533353722a61b7ca6832840c/Gold-Rush.png' - description: 'Prove you are better at collecting gold than your opponent!' - } - { - name: 'Brawlwood' - difficulty: 4 - id: 'brawlwood' - image: '/file/db/level/52d97ecd32362bc86e004e87/Level%20Banner%20Brawlwood.jpg' - description: 'Combat the armies of other Wizards in a strategic forest arena! (Fast computer required.)' - } -] - campaigns = [ {id: 'multiplayer', name: 'Multiplayer Arenas', description: '... in which you code head-to-head against other players.', levels: heroArenas} - #{id: 'old_multiplayer', name: '(Deprecated) Old Multiplayer Arenas', description: 'Relics of a more civilized age. No simulations are run for these older, hero-less multiplayer arenas.', levels: oldArenas} ] diff --git a/app/views/ladder/SimulateTabView.coffee b/app/views/ladder/SimulateTabView.coffee index d30c3f9b1..65c73d171 100644 --- a/app/views/ladder/SimulateTabView.coffee +++ b/app/views/ladder/SimulateTabView.coffee @@ -24,7 +24,7 @@ module.exports = class SimulateTabView extends CocoView onLoaded: -> super() @render() - if (document.location.hash is '#simulate' or @options.level.get('type') is 'course-ladder') and not @simulator + if (document.location.hash is '#simulate' or @options.level.isType('course-ladder')) and not @simulator @startSimulating() afterRender: -> diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee index 931a5244c..20062873a 100644 --- a/app/views/play/CampaignView.coffee +++ b/app/views/play/CampaignView.coffee @@ -397,8 +397,9 @@ module.exports = class CampaignView extends RootView @particleMan.removeEmitters() @particleMan.attach @$el.find('.map') for level in @campaign.renderedLevels ? {} - particleKey = ['level', @terrain.replace('-branching-test', '')] - particleKey.push level.type if level.type and not (level.type in ['hero', 'course']) + terrain = @terrain.replace('-branching-test', '').replace(/(game|web)-dev-\d/, 'forest') + particleKey = ['level', terrain] + particleKey.push level.type if level.type and not (level.type in ['hero', 'course']) # Would use isType, but it's not a Level model particleKey.push 'replayable' if level.replayable particleKey.push 'premium' if level.requiresSubscription particleKey.push 'gate' if level.slug in ['kithgard-gates', 'siege-of-stonehold', 'clash-of-clones', 'summits-gate'] @@ -532,7 +533,7 @@ module.exports = class CampaignView extends RootView levelElement = $(e.target).parents('.level-info-container') levelSlug = levelElement.data('level-slug') level = _.find _.values(@campaign.get('levels')), slug: levelSlug - if level.type in ['hero-ladder', 'course-ladder'] + if level.type in ['hero-ladder', 'course-ladder'] # Would use isType, but it's not a Level model Backbone.Mediator.publish 'router:navigate', route: "/play/ladder/#{levelSlug}", viewClass: 'views/ladder/LadderView', viewArgs: [{supermodel: @supermodel}, levelSlug] else @showLeaderboard levelSlug diff --git a/app/views/play/SpectateView.coffee b/app/views/play/SpectateView.coffee index 272b69d5b..f5a28fac8 100644 --- a/app/views/play/SpectateView.coffee +++ b/app/views/play/SpectateView.coffee @@ -144,9 +144,6 @@ module.exports = class SpectateLevelView extends RootView if c then myCode[thang][spell] = c else delete myCode[thang][spell] @session.set('code', myCode) - if @session.get('multiplayer') and @otherSession? - # For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet. - @session.set 'multiplayer', false onLevelStarted: (e) -> go = => @@ -181,7 +178,7 @@ module.exports = class SpectateLevelView extends RootView @insertSubView new GoldView {} @insertSubView new HUDView {level: @level} - @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder'] + @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.isType('hero-ladder', 'course-ladder') @insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel, spectateGame: true} # callbacks diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee index 8254e9b18..bda779e4d 100644 --- a/app/views/play/level/ControlBarView.coffee +++ b/app/views/play/level/ControlBarView.coffee @@ -7,17 +7,13 @@ Classroom = require 'models/Classroom' Course = require 'models/Course' CourseInstance = require 'models/CourseInstance' GameMenuModal = require 'views/play/menu/GameMenuModal' -RealTimeModel = require 'models/RealTimeModel' -RealTimeCollection = require 'collections/RealTimeCollection' LevelSetupManager = require 'lib/LevelSetupManager' -GameMenuModal = require 'views/play/menu/GameMenuModal' module.exports = class ControlBarView extends CocoView id: 'control-bar-view' template: template subscriptions: - 'bus:player-states-changed': 'onPlayerStatesChanged' 'level:disable-controls': 'onDisableControls' 'level:enable-controls': 'onEnableControls' 'ipad:memory-warning': 'onIPadMemoryWarning' @@ -28,7 +24,6 @@ module.exports = class ControlBarView extends CocoView 'click': -> Backbone.Mediator.publish 'tome:focus-editor', {} 'click .levels-link-area': 'onClickHome' 'click .home a': 'onClickHome' - 'click .multiplayer-area': 'onClickMultiplayer' 'click #control-bar-sign-up-button': 'onClickSignupButton' constructor: (options) -> @@ -45,7 +40,7 @@ module.exports = class ControlBarView extends CocoView @observing = options.session.get('creator') isnt me.id @levelNumber = '' - if @level.get('type') is 'course' and @level.get('campaignIndex')? + if @level.isType('course', 'game-dev', 'web-dev') and @level.get('campaignIndex')? @levelNumber = @level.get('campaignIndex') + 1 if @courseInstanceID @courseInstance = new CourseInstance(_id: @courseInstanceID) @@ -64,9 +59,6 @@ module.exports = class ControlBarView extends CocoView @supermodel.trackRequest(@campaign.fetch()) ) super options - if @level.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() - @isMultiplayerLevel = true - @multiplayerStatusManager = new MultiplayerStatusManager @levelID, @onMultiplayerStateChanged if @level.get 'replayable' @listenTo @session, 'change-difficulty', @onSessionDifficultyChanged @@ -79,25 +71,10 @@ module.exports = class ControlBarView extends CocoView setBus: (@bus) -> - onPlayerStatesChanged: (e) -> - # TODO: this doesn't fire any more. Replacement? - return unless @bus is e.bus - numPlayers = _.keys(e.players).length - return if numPlayers is @numPlayers - @numPlayers = numPlayers - text = 'Multiplayer' - text += " (#{numPlayers})" if numPlayers > 1 - $('#multiplayer-button', @$el).text(text) - - onMultiplayerStateChanged: => @render?() - getRenderData: (c={}) -> super c c.worldName = @worldName - c.multiplayerEnabled = @session.get('multiplayer') - c.ladderGame = @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] - if c.isMultiplayerLevel = @isMultiplayerLevel - c.multiplayerStatus = @multiplayerStatusManager?.status + c.ladderGame = @level.isType('ladder', 'hero-ladder', 'course-ladder') if @level.get 'replayable' c.levelDifficulty = @session.get('state')?.difficulty ? 0 if @observing @@ -110,23 +87,17 @@ module.exports = class ControlBarView extends CocoView if me.isSessionless() @homeLink = "/teachers/courses" @homeViewClass = "views/courses/TeacherCoursesView" - else if @level.get('type', true) in ['ladder', 'ladder-tutorial', 'hero-ladder', 'course-ladder'] + else if @level.isType('ladder', 'ladder-tutorial', 'hero-ladder', 'course-ladder') levelID = @level.get('slug')?.replace(/\-tutorial$/, '') or @level.id @homeLink = '/play/ladder/' + levelID @homeViewClass = 'views/ladder/LadderView' @homeViewArgs.push levelID if leagueID = @getQueryVariable 'league' - leagueType = if @level.get('type') is 'course-ladder' then 'course' else 'clan' + leagueType = if @level.isType('course-ladder') then 'course' else 'clan' @homeViewArgs.push leagueType @homeViewArgs.push leagueID @homeLink += "/#{leagueType}/#{leagueID}" - else if @level.get('type', true) in ['hero', 'hero-coop'] or window.serverConfig.picoCTF - @homeLink = '/play' - @homeViewClass = 'views/play/CampaignView' - campaign = @level.get 'campaign' - @homeLink += '/' + campaign - @homeViewArgs.push campaign - else if @level.get('type', true) in ['course'] + else if @level.isType('course') or @courseID @homeLink = '/courses' @homeViewClass = 'views/courses/CoursesView' if @courseID @@ -136,7 +107,12 @@ module.exports = class ControlBarView extends CocoView if @courseInstanceID @homeLink += "/#{@courseInstanceID}" @homeViewArgs.push @courseInstanceID - #else if @level.get('type', true) is 'game-dev' # TODO + else if @level.isType('hero', 'hero-coop', 'game-dev', 'web-dev') or window.serverConfig.picoCTF + @homeLink = '/play' + @homeViewClass = 'views/play/CampaignView' + campaign = @level.get 'campaign' + @homeLink += '/' + campaign + @homeViewArgs.push campaign else @homeLink = '/' @homeViewClass = 'views/HomeView' @@ -153,16 +129,13 @@ module.exports = class ControlBarView extends CocoView @setupManager.open() onClickHome: (e) -> - if @level.get('type', true) in ['course'] + if @level.isType('course') category = if me.isTeacher() then 'Teachers' else 'Students' window.tracker?.trackEvent 'Play Level Back To Levels', category: category, levelSlug: @levelSlug, ['Mixpanel'] e.preventDefault() e.stopImmediatePropagation() Backbone.Mediator.publish 'router:navigate', route: @homeLink, viewClass: @homeViewClass, viewArgs: @homeViewArgs - onClickMultiplayer: (e) -> - @showGameMenuModal e, 'multiplayer' - onClickSignupButton: (e) -> window.tracker?.trackEvent 'Started Signup', category: 'Play Level', label: 'Control Bar', level: @levelID @@ -183,62 +156,4 @@ module.exports = class ControlBarView extends CocoView destroy: -> @setupManager?.destroy() - @multiplayerStatusManager?.destroy() super() - -# MultiplayerStatusManager ###################################################### -# -# Manages the multiplayer status, and calls @statusChangedCallback when it changes. -# -# It monitors these: -# Real-time multiplayer players -# Internal multiplayer status -# -# Real-time state variables: -# @playersCollection - Real-time multiplayer players -# -# TODO: Not currently using player counts. Should remove if we keep simple design. -# -class MultiplayerStatusManager - - constructor: (@levelID, @statusChangedCallback) -> - @status = '' - # @players = {} - # @playersCollection = new RealTimeCollection('multiplayer_players/' + @levelID) - # @playersCollection.on 'add', @onPlayerAdded - # @playersCollection.each (player) => @onPlayerAdded player - Backbone.Mediator.subscribe 'real-time-multiplayer:player-status', @onMultiplayerPlayerStatus - - destroy: -> - Backbone.Mediator.unsubscribe 'real-time-multiplayer:player-status', @onMultiplayerPlayerStatus - # @playersCollection?.off 'add', @onPlayerAdded - # player.off 'change', @onPlayerChanged for id, player of @players - - onMultiplayerPlayerStatus: (e) => - @status = e.status - @statusChangedCallback() - - # onPlayerAdded: (player) => - # unless player.id is me.id - # @players[player.id] = new RealTimeModel('multiplayer_players/' + @levelID + '/' + player.id) - # @players[player.id].on 'change', @onPlayerChanged - # @countPlayers player - # - # onPlayerChanged: (player) => - # @countPlayers player - # - # countPlayers: (changedPlayer) => - # # TODO: save this stale hearbeat threshold setting somewhere - # staleHeartbeat = new Date() - # staleHeartbeat.setMinutes staleHeartbeat.getMinutes() - 3 - # @playerCount = 0 - # @playersCollectionAvailable = 0 - # @playersCollectionUnavailable = 0 - # @playersCollection.each (player) => - # # Assume changedPlayer is fresher than entry in @playersCollection collection - # player = changedPlayer if changedPlayer? and player.id is changedPlayer.id - # unless staleHeartbeat >= new Date(player.get('heartbeat')) - # @playerCount++ - # @playersCollectionAvailable++ if player.get('state') is 'available' - # @playersCollectionUnavailable++ if player.get('state') is 'unavailable' - # @statusChangedCallback() diff --git a/app/views/play/level/LevelChatView.coffee b/app/views/play/level/LevelChatView.coffee index 06c218294..122b15b7a 100644 --- a/app/views/play/level/LevelChatView.coffee +++ b/app/views/play/level/LevelChatView.coffee @@ -18,6 +18,7 @@ module.exports = class LevelChatView extends CocoView constructor: (options) -> @levelID = options.levelID @session = options.session + # TODO: we took out session.multiplayer, so this will not fire. If we want to resurrect it, we'll of course need a new way of activating chat. @listenTo(@session, 'change:multiplayer', @updateMultiplayerVisibility) @sessionID = options.sessionID @bus = LevelBus.get(@levelID, @sessionID) diff --git a/app/views/play/level/LevelFlagsView.coffee b/app/views/play/level/LevelFlagsView.coffee index 9bdb04c4a..83b2a71ce 100644 --- a/app/views/play/level/LevelFlagsView.coffee +++ b/app/views/play/level/LevelFlagsView.coffee @@ -1,9 +1,6 @@ CocoView = require 'views/core/CocoView' template = require 'templates/play/level/level-flags-view' {me} = require 'core/auth' -RealTimeCollection = require 'collections/RealTimeCollection' - -multiplayerFlagDelay = 0.5 # Long, static second delay for now; should be more than enough. module.exports = class LevelFlagsView extends CocoView id: 'level-flags-view' @@ -17,7 +14,6 @@ module.exports = class LevelFlagsView extends CocoView 'god:new-world-created': 'onNewWorld' 'god:streaming-world-updated': 'onNewWorld' 'surface:remove-flag': 'onRemoveFlag' - 'real-time-multiplayer:joined-game': 'onJoinedMultiplayerGame' events: 'click .green-flag': -> @onFlagSelected color: 'green', source: 'button' @@ -60,9 +56,8 @@ module.exports = class LevelFlagsView extends CocoView return unless @flagColor and @realTime @playSound 'menu-button-click' # TODO: different flag placement sound? pos = x: e.worldPos.x, y: e.worldPos.y - delay = if @realTimeFlags then multiplayerFlagDelay else 0 now = @world.dt * @world.frames.length - flag = player: me.id, team: me.team, color: @flagColor, pos: pos, time: now + delay, active: true, source: 'click' + flag = player: me.id, team: me.team, color: @flagColor, pos: pos, time: now, active: true, source: 'click' @flags[@flagColor] = flag @flagHistory.push flag @realTimeFlags?.create flag @@ -75,9 +70,8 @@ module.exports = class LevelFlagsView extends CocoView onRemoveFlag: (e) -> delete @flags[e.color] - delay = if @realTimeFlags then multiplayerFlagDelay else 0 now = @world.dt * @world.frames.length - flag = player: me.id, team: me.team, color: e.color, time: now + delay, active: false, source: 'click' + flag = player: me.id, team: me.team, color: e.color, time: now, active: false, source: 'click' @flagHistory.push flag Backbone.Mediator.publish 'level:flag-updated', flag #console.log e.color, 'deleted at time', flag.time @@ -85,31 +79,3 @@ module.exports = class LevelFlagsView extends CocoView onNewWorld: (event) -> return unless event.world.name is @world.name @world = @options.world = event.world - - onJoinedMultiplayerGame: (e) -> - @realTimeFlags = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}/#{e.realTimeSessionID}/flagHistory") - @realTimeFlags.on 'add', @onRealTimeMultiplayerFlagAdded - @realTimeFlags.on 'remove', @onRealTimeMultiplayerFlagRemoved - - onLeftMultiplayerGame: (e) -> - if @realTimeFlags - @realTimeFlags.off 'add', @onRealTimeMultiplayerFlagAdded - @realTimeFlags.off 'remove', @onRealTimeMultiplayerFlagRemoved - @realTimeFlags = null - - onRealTimeMultiplayerFlagAdded: (e) => - if e.get('player') != me.id - # TODO: what is @flags used for? - # Build local flag from Backbone.Model flag - flag = - player: e.get('player') - team: e.get('team') - color: e.get('color') - pos: e.get('pos') - time: e.get('time') - active: e.get('active') - #source: 'click'? e.get('source')? nothing? - @flagHistory.push flag - Backbone.Mediator.publish 'level:flag-updated', flag - - onRealTimeMultiplayerFlagRemoved: (e) => diff --git a/app/views/play/level/LevelGoalsView.coffee b/app/views/play/level/LevelGoalsView.coffee index 1c37e1245..d4749ae88 100644 --- a/app/views/play/level/LevelGoalsView.coffee +++ b/app/views/play/level/LevelGoalsView.coffee @@ -49,7 +49,7 @@ module.exports = class LevelGoalsView extends CocoView goals = [] for goal in e.goals state = e.goalStates[goal.id] - continue if goal.optional and @level.get('type', true) is 'course' and state.status isnt 'success' + continue if goal.optional and @level.isType('course') and state.status isnt 'success' if goal.hiddenGoal continue if goal.optional and state.status isnt 'success' continue if not goal.optional and state.status isnt 'failure' diff --git a/app/views/play/level/LevelHUDView.coffee b/app/views/play/level/LevelHUDView.coffee index 661da3aaf..b2cad7500 100644 --- a/app/views/play/level/LevelHUDView.coffee +++ b/app/views/play/level/LevelHUDView.coffee @@ -100,7 +100,7 @@ module.exports = class LevelHUDView extends CocoView @stage?.stopTalking() createProperties: -> - if @options.level.get('type') in ['game-dev'] + if @options.level.isType('game-dev') name = 'Game' # TODO: we don't need the HUD at all else if @thang.id in ['Hero Placeholder', 'Hero Placeholder 1'] name = @thangType?.getHeroShortName() or 'Hero' diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee index eb9024d14..6e47711a0 100644 --- a/app/views/play/level/LevelLoadingView.coffee +++ b/app/views/play/level/LevelLoadingView.coffee @@ -59,7 +59,7 @@ module.exports = class LevelLoadingView extends CocoView goalList = goalContainer.find('ul') goalCount = 0 for goalID, goal of @level.get('goals') when (not goal.team or goal.team is (e.team or 'humans')) and not goal.hiddenGoal - continue if goal.optional and @level.get('type', true) is 'course' + continue if goal.optional and @level.isType('course') name = utils.i18n goal, 'name' goalList.append $('
  • ' + name + '
  • ') ++goalCount @@ -169,7 +169,7 @@ module.exports = class LevelLoadingView extends CocoView @playSound 'loading-view-unveil', 0.5 @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0' @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0' - $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) + $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) unless @level.isType('web-dev') unveilIntro: => return if @destroyed or not @intro or @unveiled diff --git a/app/views/play/level/LevelPlaybackView.coffee b/app/views/play/level/LevelPlaybackView.coffee index 6547a683b..811d431a1 100644 --- a/app/views/play/level/LevelPlaybackView.coffee +++ b/app/views/play/level/LevelPlaybackView.coffee @@ -21,7 +21,6 @@ module.exports = class LevelPlaybackView extends CocoView 'tome:cast-spells': 'onTomeCast' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' 'playback:stop-real-time-playback': 'onStopRealTimePlayback' - 'real-time-multiplayer:manual-cast': 'onRealTimeMultiplayerCast' events: 'click #music-button': 'onToggleMusic' @@ -110,11 +109,6 @@ module.exports = class LevelPlaybackView extends CocoView Backbone.Mediator.publish 'playback:real-time-playback-started', {} @playSound 'real-time-playback-start' - onRealTimeMultiplayerCast: (e) -> - @realTime = true - @togglePlaybackControls false - Backbone.Mediator.publish 'playback:real-time-playback-waiting', {} - onWindowResize: (s...) => @barWidth = $('.progress', @$el).width() diff --git a/app/views/play/level/PlayGameDevLevelView.coffee b/app/views/play/level/PlayGameDevLevelView.coffee new file mode 100644 index 000000000..9de9fb440 --- /dev/null +++ b/app/views/play/level/PlayGameDevLevelView.coffee @@ -0,0 +1,92 @@ +RootView = require 'views/core/RootView' + +GameUIState = require 'models/GameUIState' +God = require 'lib/God' +LevelLoader = require 'lib/LevelLoader' +GoalManager = require 'lib/world/GoalManager' +ScriptManager = require 'lib/scripts/ScriptManager' +Surface = require 'lib/surface/Surface' +ThangType = require 'models/ThangType' +Level = require 'models/Level' +LevelSession = require 'models/LevelSession' +State = require 'models/State' + +TEAM = 'humans' + +module.exports = class PlayGameDevLevelView extends RootView + id: 'play-game-dev-level-view' + template: require 'templates/play/level/play-game-dev-level-view' + + events: + 'click #play-btn': 'onClickPlayButton' + + initialize: (@options, @levelID, @sessionID) -> + @state = new State({ + loading: true + progress: 0 + }) + + @supermodel.on 'update-progress', (progress) => + @state.set({progress: (progress*100).toFixed(1)+'%'}) + @level = new Level() + @session = new LevelSession() + @gameUIState = new GameUIState() + @courseID = @getQueryVariable 'course' + @god = new God({ @gameUIState }) + @levelLoader = new LevelLoader({ @supermodel, @levelID, @sessionID, observing: true, team: TEAM, @courseID }) + @listenTo @state, 'change', _.debounce(-> @renderSelectors('#info-col')) + + @levelLoader.loadWorldNecessities() + + .then (levelLoader) => + { @level, @session, @world } = levelLoader + @god.setLevel(@level.serialize {@supermodel, @session}) + @god.setWorldClassMap(@world.classMap) + @goalManager = new GoalManager(@world, @level.get('goals'), @team) + @god.setGoalManager(@goalManager) + @god.angelsShare.firstWorld = false # HACK + me.team = TEAM + @session.set 'team', TEAM + @scriptManager = new ScriptManager({ + scripts: @world.scripts or [], view: @, @session, levelID: @level.get('slug')}) + @scriptManager.loadFromSession() # Should we? TODO: Figure out how scripts work for game dev levels + @supermodel.finishLoading() + + .then (supermodel) => + @levelLoader.destroy() + @levelLoader = null + webGLSurface = @$('canvas#webgl-surface') + normalSurface = @$('canvas#normal-surface') + @surface = new Surface(@world, normalSurface, webGLSurface, { + thangTypes: @supermodel.getModels(ThangType) + levelType: @level.get('type', true) + @gameUIState + }) + worldBounds = @world.getBounds() + bounds = [{x: worldBounds.left, y: worldBounds.top}, {x: worldBounds.right, y: worldBounds.bottom}] + @surface.camera.setBounds(bounds) + @surface.camera.zoomTo({x: 0, y: 0}, 0.1, 0) + @surface.setWorld(@world) + @scriptManager.initializeCamera() + @renderSelectors '#info-col' + @spells = @session.generateSpellsObject level: @level + @state.set('loading', false) + + .catch ({message}) => + console.error message + @state.set('errorMessage', message) + + onClickPlayButton: -> + @god.createWorld(@spells, false, true) + Backbone.Mediator.publish('playback:real-time-playback-started', {}) + Backbone.Mediator.publish('level:set-playing', {playing: true}) + @state.set('playing', true) + + destroy: -> + @levelLoader?.destroy() + @surface?.destroy() + @god?.destroy() + @goalManager?.destroy() + @scriptManager?.destroy() + delete window.world # not sure where this is set, but this is one way to clean it up + super() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 45340f0d7..a775a2d53 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -44,6 +44,7 @@ LevelSetupManager = require 'lib/LevelSetupManager' ContactModal = require 'views/core/ContactModal' HintsView = require './HintsView' HintsState = require './HintsState' +WebSurfaceView = require './WebSurfaceView' PROFILE_ME = false @@ -72,14 +73,10 @@ module.exports = class PlayLevelView extends RootView 'level:started': 'onLevelStarted' 'level:loading-view-unveiling': 'onLoadingViewUnveiling' 'level:loading-view-unveiled': 'onLoadingViewUnveiled' + 'level:loaded': 'onLevelLoaded' 'level:session-loaded': 'onSessionLoaded' - 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' - 'real-time-multiplayer:created-game': 'onRealTimeMultiplayerCreatedGame' - 'real-time-multiplayer:joined-game': 'onRealTimeMultiplayerJoinedGame' - 'real-time-multiplayer:left-game': 'onRealTimeMultiplayerLeftGame' - 'real-time-multiplayer:manual-cast': 'onRealTimeMultiplayerCast' 'ipad:memory-warning': 'onIPadMemoryWarning' 'store:item-purchased': 'onItemPurchased' @@ -137,7 +134,6 @@ module.exports = class PlayLevelView extends RootView load: -> @loadStartTime = new Date() - @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 = {} @@ -145,6 +141,10 @@ module.exports = class PlayLevelView extends RootView @listenToOnce @levelLoader, 'world-necessities-loaded', @onWorldNecessitiesLoaded @listenTo @levelLoader, 'world-necessity-load-failed', @onWorldNecessityLoadFailed + onLevelLoaded: (e) -> + @god = new God({@gameUIState}) unless e.level.isType('web-dev') + @setUpGod() if @waitingToSetUpGod + trackLevelLoadEnd: -> return if @isEditorPreview @loadEndTime = new Date() @@ -185,15 +185,13 @@ module.exports = class PlayLevelView extends RootView onWorldNecessitiesLoaded: -> # Called when we have enough to build the world, but not everything is loaded @grabLevelLoaderData() - team = @getQueryVariable('team') ? @session.get('team') ? @world.teamForPlayer(0) + team = @getQueryVariable('team') ? @session.get('team') ? @world?.teamForPlayer(0) ? 'humans' @loadOpponentTeam(team) @setupGod() @setTeam team @initGoalManager() @insertSubviews() @initVolume() - @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) - @register() @controlBar.setBus(@bus) @initScriptManager() @@ -203,9 +201,12 @@ module.exports = class PlayLevelView extends RootView grabLevelLoaderData: -> @session = @levelLoader.session - @world = @levelLoader.world @level = @levelLoader.level - @$el.addClass 'hero' if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if @level.isType('web-dev') + @$el.addClass 'web-dev' # Hide some of the elements we won't be using + return + @world = @levelLoader.world + @$el.addClass 'hero' if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev') # TODO: figure out what this does and comment it @$el.addClass 'flags' if _.any(@world.thangs, (t) -> (t.programmableProperties and 'findFlags' in t.programmableProperties) or t.inventory?.flag) or @level.get('slug') is 'sky-span' # TODO: Update terminology to always be opponentSession or otherSession # TODO: E.g. if it's always opponent right now, then variable names should be opponentSession until we have coop play @@ -239,11 +240,11 @@ module.exports = class PlayLevelView extends RootView myCode[thang] ?= {} if c then myCode[thang][spell] = c else delete myCode[thang][spell] @session.set('code', myCode) - if @session.get('multiplayer') and @otherSession? - # For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet. - @session.set 'multiplayer', false setupGod: -> + return if @level.isType('web-dev') + return @waitingToSetUpGod = true unless @god + @waitingToSetUpGod = undefined @god.setLevel @level.serialize {@supermodel, @session, @otherSession, headless: false, sessionless: false} @god.setLevelSessionIDs if @otherSession then [@session.id, @otherSession.id] else [@session.id] @god.setWorldClassMap @world.classMap @@ -258,22 +259,23 @@ module.exports = class PlayLevelView extends RootView initGoalManager: -> @goalManager = new GoalManager(@world, @level.get('goals'), @team) - @god.setGoalManager @goalManager + @god?.setGoalManager @goalManager insertSubviews: -> @hintsState = new HintsState({ hidden: true }, { @session, @level }) - @insertSubView @tome = new TomeView { @levelID, @session, @otherSession, thangs: @world.thangs, @supermodel, @level, @observing, @courseID, @courseInstanceID, @god, @hintsState } - @insertSubView new LevelPlaybackView session: @session, level: @level + @insertSubView @tome = new TomeView { @levelID, @session, @otherSession, thangs: @world?.thangs ? [], @supermodel, @level, @observing, @courseID, @courseInstanceID, @god, @hintsState } + @insertSubView new LevelPlaybackView session: @session, level: @level unless @level.isType('web-dev') @insertSubView new GoalsView {level: @level} @insertSubView new LevelFlagsView levelID: @levelID, world: @world if @$el.hasClass 'flags' - @insertSubView new GoldView {} unless @level.get('slug') in ['wakka-maul'] - @insertSubView new HUDView {level: @level} + @insertSubView new GoldView {} unless @level.get('slug') in ['wakka-maul'] unless @level.isType('web-dev') + @insertSubView new HUDView {level: @level} unless @level.isType('web-dev') @insertSubView new LevelDialogueView {level: @level, sessionID: @session.id} @insertSubView new ChatView levelID: @levelID, sessionID: @session.id, session: @session @insertSubView new ProblemAlertView session: @session, level: @level, supermodel: @supermodel - @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder'] + @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.isType('hero-ladder', 'course-ladder') @insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel, courseID: @courseID, courseInstanceID: @courseInstanceID} @insertSubView @hintsView = new HintsView({ @session, @level, @hintsState }), @$('.hints-view') + @insertSubView @webSurface = new WebSurfaceView {level: @level, @goalManager} if @level.isType('web-dev') #_.delay (=> Backbone.Mediator.publish('level:set-debug', debug: true)), 5000 if @isIPadApp() # if me.displayName() is 'Nick' initVolume: -> @@ -282,6 +284,7 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'level:set-volume', volume: volume initScriptManager: -> + return if @level.isType('web-dev') @scriptManager = new ScriptManager({scripts: @world.scripts or [], view: @, session: @session, levelID: @level.get('slug')}) @scriptManager.loadFromSession() @@ -289,9 +292,7 @@ module.exports = class PlayLevelView extends RootView @bus = LevelBus.get(@levelID, @session.id) @bus.setSession(@session) @bus.setSpells @tome.spells - if @session.get('multiplayer') and not me.isAdmin() - @session.set 'multiplayer', false # Temp: multiplayer has bugged out some sessions, so ignoring it. - @bus.connect() if @session.get('multiplayer') + #@bus.connect() if @session.get('multiplayer') # TODO: session's multiplayer flag removed; connect bus another way if we care about it # Load Completed Setup ###################################################### @@ -310,13 +311,11 @@ module.exports = class PlayLevelView extends RootView else if e.level.get('slug') is 'assembly-speed' raider = '55527eb0b8abf4ba1fe9a107' e.session.set 'heroConfig', {"thangType":raider,"inventory":{}} - else if e.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] and not _.size e.session.get('heroConfig')?.inventory ? {} + else if e.level.isType('hero', 'hero-ladder', 'hero-coop') and not _.size e.session.get('heroConfig')?.inventory ? {} @setupManager?.destroy() @setupManager = new LevelSetupManager({supermodel: @supermodel, level: e.level, levelID: @levelID, parent: @, session: e.session, courseID: @courseID, courseInstanceID: @courseInstanceID}) @setupManager.open() - @onRealTimeMultiplayerLevelLoaded e.session if e.level.get('type') in ['hero-ladder', 'course-ladder'] - onLoaded: -> _.defer => @onLevelLoaderLoaded() @@ -325,14 +324,17 @@ module.exports = class PlayLevelView extends RootView return unless @levelLoader.progress() is 1 # double check, since closing the guide may trigger this early # Save latest level played. - if not @observing and not (@levelLoader.level.get('type') in ['ladder', 'ladder-tutorial']) + if not @observing and not (@levelLoader.level.isType('ladder', 'ladder-tutorial')) me.set('lastLevel', @levelID) me.save() application.tracker?.identify() @saveRecentMatch() if @otherSession @levelLoader.destroy() @levelLoader = null - @initSurface() + if @level.isType('web-dev') + Backbone.Mediator.publish 'level:started', {} + else + @initSurface() saveRecentMatch: -> allRecentlyPlayedMatches = storage.load('recently-played-matches') ? {} @@ -360,7 +362,7 @@ module.exports = class PlayLevelView extends RootView @surface.camera.zoomTo({x: 0, y: 0}, 0.1, 0) findPlayerNames: -> - return {} unless @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] + return {} unless @level.isType('ladder', 'hero-ladder', 'course-ladder') playerNames = {} for session in [@session, @otherSession] when session?.get('team') playerNames[session.get('team')] = session.get('creatorName') or 'Anonymous' @@ -369,33 +371,30 @@ module.exports = class PlayLevelView extends RootView # Once Surface is Loaded #################################################### onLevelStarted: -> - return unless @surface? + return unless @surface? or @webSurface? @loadingView.showReady() @trackLevelLoadEnd() if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @ - @surface.showLevel() + @surface?.showLevel() Backbone.Mediator.publish 'level:set-time', time: 0 if (@isEditorPreview or @observing) and not @getQueryVariable('intro') @loadingView.startUnveiling() @loadingView.unveil true else - @scriptManager.initializeCamera() + @scriptManager?.initializeCamera() onLoadingViewUnveiling: (e) -> @selectHero() onLoadingViewUnveiled: (e) -> - if @level.get('type') in ['course-ladder', 'hero-ladder'] or @observing + if @level.isType('course-ladder', 'hero-ladder') or @observing # We used to autoplay by default, but now we only do it if the level says to in the introduction script. Backbone.Mediator.publish 'level:set-playing', playing: true @loadingView.$el.remove() @removeSubView @loadingView @loadingView = null @playAmbientSound() - if @options.realTimeMultiplayerSessionID? - Backbone.Mediator.publish 'playback:real-time-playback-waiting', {} - @realTimeMultiplayerContinueGame @options.realTimeMultiplayerSessionID # TODO: Is it possible to create a Mongoose ObjectId for 'ls', instead of the string returned from get()? application.tracker?.trackEvent 'Started Level', category:'Play Level', levelID: @levelID, ls: @session?.get('_id') unless @observing $(window).trigger 'resize' @@ -423,7 +422,7 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true Backbone.Mediator.publish 'tome:select-primary-sprite', {} Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false - @surface.focusOnHero() + @surface?.focusOnHero() perhapsStartSimulating: -> return unless @shouldSimulate() @@ -440,7 +439,7 @@ module.exports = class PlayLevelView extends RootView simulateNextGame: -> return @simulator.fetchAndSimulateOneGame() if @simulator simulatorOptions = background: true, leagueID: @courseInstanceID - simulatorOptions.levelID = @level.get('slug') if @level.get('type', true) in ['course-ladder', 'hero-ladder'] + simulatorOptions.levelID = @level.get('slug') if @level.isType('course-ladder', 'hero-ladder') @simulator = new Simulator simulatorOptions # Crude method of mitigating Simulator memory leak issues fetchAndSimulateOneGameOriginal = @simulator.fetchAndSimulateOneGame @@ -462,31 +461,30 @@ module.exports = class PlayLevelView extends RootView cores = window.navigator.hardwareConcurrency or defaultCores # Available on Chrome/Opera, soon Safari defaultHeapLimit = 793000000 heapLimit = window.performance?.memory?.jsHeapSizeLimit or defaultHeapLimit # Only available on Chrome, basically just says 32- vs. 64-bit - levelType = @level.get 'type', true gamesSimulated = me.get('simulatedBy') console.debug "Should we start simulating? Cores:", window.navigator.hardwareConcurrency, "Heap limit:", window.performance?.memory?.jsHeapSizeLimit, "Load duration:", @loadDuration return false unless $.browser?.desktop return false if $.browser?.msie or $.browser?.msedge return false if $.browser.linux return false if me.level() < 8 - if levelType in ['course', 'game-dev'] + if @level.isType('course', 'game-dev', 'web-dev') return false - else if levelType is 'hero' and gamesSimulated + else if @level.isType('hero') and gamesSimulated return false if stillBuggy return false if cores < 8 return false if heapLimit < defaultHeapLimit return false if @loadDuration > 10000 - else if levelType is 'hero-ladder' and gamesSimulated + else if @level.isType('hero-ladder') and gamesSimulated return false if stillBuggy return false if cores < 4 return false if heapLimit < defaultHeapLimit return false if @loadDuration > 15000 - else if levelType is 'hero-ladder' and not gamesSimulated + else if @level.isType('hero-ladder') and not gamesSimulated return false if stillBuggy return false if cores < 8 return false if heapLimit <= defaultHeapLimit return false if @loadDuration > 20000 - else if levelType is 'course-ladder' + else if @level.isType('course-ladder') return false if cores <= defaultCores return false if heapLimit < defaultHeapLimit return false if @loadDuration > 18000 @@ -542,7 +540,7 @@ module.exports = class PlayLevelView extends RootView onDonePressed: -> @showVictory() onShowVictory: (e) -> - $('#level-done-button').show() unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + $('#level-done-button').show() unless @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') @showVictory() if e.showModal return if @victorySeen @victorySeen = true @@ -560,8 +558,11 @@ module.exports = class PlayLevelView extends RootView return if @level.hasLocalChanges() # Don't award achievements when beating level changed in level editor @endHighlight() options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning, courseID: @courseID, courseInstanceID: @courseInstanceID, world: @world} - ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then HeroVictoryModal else VictoryModal + ModalClass = if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') then HeroVictoryModal else VictoryModal ModalClass = CourseVictoryModal if @isCourseMode() or me.isSessionless() + if @level.isType('course-ladder') + ModalClass = CourseVictoryModal + options.courseInstanceID = @getQueryVariable 'league' ModalClass = PicoCTFVictoryModal if window.serverConfig.picoCTF victoryModal = new ModalClass(options) @openModalView(victoryModal) @@ -585,13 +586,6 @@ module.exports = class PlayLevelView extends RootView onFocusDom: (e) -> $(e.selector).focus() - onMultiplayerChanged: (e) -> - if @session.get('multiplayer') - @bus.connect() - else - @bus.removeFirebaseData => - @bus.disconnect() - onContactClicked: (e) -> Backbone.Mediator.publish 'level:contact-button-pressed', {} @openModalView contactModal = new ContactModal levelID: @level.get('slug') or @level.id, courseID: @courseID, courseInstanceID: @courseInstanceID @@ -630,10 +624,6 @@ module.exports = class PlayLevelView extends RootView AudioPlayer.preloadSoundReference sound # Real-time playback - onRealTimePlaybackWaiting: (e) -> - @$el.addClass('real-time').focus() - @onWindowResize() - onRealTimePlaybackStarted: (e) -> @$el.addClass('real-time').focus() @onWindowResize() @@ -646,14 +636,12 @@ module.exports = class PlayLevelView extends RootView _.delay @onSubmissionComplete, 750 # Wait for transition to end. else @waitingForSubmissionComplete = true - @onRealTimeMultiplayerPlaybackEnded() onSubmissionComplete: => return if @destroyed Backbone.Mediator.publish 'level:set-time', ratio: 1 return if @level.hasLocalChanges() # Don't award achievements when beating level changed in level editor - # TODO: Show a victory dialog specific to hero-ladder level - if @goalManager.checkOverallStatus() is 'success' and not @options.realTimeMultiplayerSessionID? + if @goalManager.checkOverallStatus() is 'success' showModalFn = -> Backbone.Mediator.publish 'level:show-victory', showModal: true @session.recordScores @world.scores, @level if @level.get 'replayable' @@ -678,7 +666,6 @@ module.exports = class PlayLevelView extends RootView #@instance.save() unless @instance.loading delete window.nextURL console.profileEnd?() if PROFILE_ME - @onRealTimeMultiplayerLevelUnloaded() application.tracker?.disableInspectletJS() super() @@ -694,358 +681,3 @@ module.exports = class PlayLevelView extends RootView @setupManager?.destroy() @setupManager = new LevelSetupManager({supermodel: @supermodel, level: @level, levelID: @levelID, parent: @, session: @session, hadEverChosenHero: true}) @setupManager.open() - - # Start Real-time Multiplayer ###################################################### - # - # This view acts as a hub for the real-time multiplayer session for the current level. - # - # It performs these actions: - # Player heartbeat - # Publishes player status - # Updates real-time multiplayer session state - # Updates real-time multiplayer player state - # Cleans up old sessions (sets state to 'finished') - # Real-time multiplayer cast handshake - # Swap teams on game joined, if necessary - # Reload PlayLevelView on real-time submit, automatically continue game and real-time playback - # - # It monitors these: - # Real-time multiplayer sessions - # Current real-time multiplayer session - # Internal multiplayer create/joined/left events - # - # Real-time state variables. - # Each Ref is Firebase reference, and may have a matching Data suffixed variable with the latest data received. - # @realTimePlayerRef - User's real-time multiplayer player for this level - # @realTimePlayerGameRef - User's current real-time multiplayer player game session - # @realTimeSessionRef - Current real-time multiplayer game session - # @realTimeOpponentRef - Current real-time multiplayer opponent - # @realTimePlayersRef - Real-time players for current real-time multiplayer game session - # @options.realTimeMultiplayerSessionID - Need to continue an existing real-time multiplayer session - # - # TODO: Move this code to it's own file, or possibly the LevelBus - # TODO: Save settings somewhere reasonable - multiplayerFireHost: 'https://codecombat.firebaseio.com/test/db/' - - onRealTimeMultiplayerLevelLoaded: (session) -> - # console.log 'PlayLevelView onRealTimeMultiplayerLevelLoaded' - return if @realTimePlayerRef? - return if me.get('anonymous') - @realTimePlayerRef = new Firebase "#{@multiplayerFireHost}multiplayer_players/#{@levelID}/#{me.id}" - unless @options.realTimeMultiplayerSessionID? - # TODO: Wait for name instead of using 'Anon', or try and update it later? - name = me.get('name') ? session.get('creatorName') ? 'Anon' - @realTimePlayerRef.set - id: me.id # TODO: is this redundant info necessary? - name: name - state: 'playing' - created: new Date().toISOString() - heartbeat: new Date().toISOString() - @timerMultiplayerHeartbeatID = setInterval @onRealTimeMultiplayerHeartbeat, 60 * 1000 - @cleanupRealTimeSessions() - - cleanupRealTimeSessions: -> - # console.log 'PlayLevelView cleanupRealTimeSessions' - # TODO: Reduce this call, possibly by username and dates - realTimeSessionCollection = new Firebase "#{@multiplayerFireHost}multiplayer_level_sessions/#{@levelID}" - realTimeSessionCollection.once 'value', (collectionSnapshot) => - for multiplayerSessionID, multiplayerSession of collectionSnapshot.val() - continue if @options.realTimeMultiplayerSessionID? and @options.realTimeMultiplayerSessionID is multiplayerSessionID - continue unless multiplayerSession.state isnt 'finished' - player = realTimeSessionCollection.child "#{multiplayerSession.id}/players/#{me.id}" - player.once 'value', (playerSnapshot) => - if playerSnapshot.val() - console.info 'Cleaning up previous real-time multiplayer session', multiplayerSessionID - player.update 'state': 'left' - multiplayerSessionRef = realTimeSessionCollection.child "#{multiplayerSessionID}" - multiplayerSessionRef.update 'state': 'finished' - - onRealTimeMultiplayerLevelUnloaded: -> - # console.log 'PlayLevelView onRealTimeMultiplayerLevelUnloaded' - if @timerMultiplayerHeartbeatID? - clearInterval @timerMultiplayerHeartbeatID - @timerMultiplayerHeartbeatID = null - - # TODO: similar to game ending cleanup - if @realTimeOpponentRef? - @realTimeOpponentRef.off 'value', @onRealTimeOpponentChanged - @realTimeOpponentRef = null - if @realTimePlayersRef? - @realTimePlayersRef.off 'child_added', @onRealTimePlayerAdded - @realTimePlayersRef = null - if @realTimeSessionRef? - @realTimeSessionRef.off 'value', @onRealTimeSessionChanged - @realTimeSessionRef = null - if @realTimePlayerGameRef? - @realTimePlayerGameRef = null - if @realTimePlayerRef? - @realTimePlayerRef = null - - onRealTimeMultiplayerHeartbeat: => - # console.log 'PlayLevelView onRealTimeMultiplayerHeartbeat', @realTimePlayerRef - @realTimePlayerRef.update 'heartbeat': new Date().toISOString() if @realTimePlayerRef? - - onRealTimeMultiplayerCreatedGame: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerCreatedGame' - @joinRealTimeMultiplayerGame e - @realTimePlayerGameRef.update 'state': 'coding' - @realTimePlayerRef.update 'state': 'available' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Waiting for opponent..' - - onRealTimeSessionChanged: (snapshot) => - # console.log 'PlayLevelView onRealTimeSessionChanged', snapshot.val() - @realTimeSessionData = snapshot.val() - if @realTimeSessionData?.state is 'finished' - @realTimeGameEnded() - Backbone.Mediator.publish 'real-time-multiplayer:left-game', {} - - onRealTimePlayerAdded: (snapshot) => - # console.log 'PlayLevelView onRealTimePlayerAdded', snapshot.val() - # Assume game is full, game on - data = snapshot.val() - if data? and data.id isnt me.id - @realTimeOpponentData = data - # console.log 'PlayLevelView onRealTimePlayerAdded opponent', @realTimeOpponentData, @realTimePlayersData - @realTimePlayersData[@realTimeOpponentData.id] = @realTimeOpponentData - if @realTimeSessionData?.state is 'creating' - @realTimeSessionRef.update 'state': 'coding' - @realTimePlayerRef.update 'state': 'unavailable' - @realTimeOpponentRef = @realTimeSessionRef.child "players/#{@realTimeOpponentData.id}" - @realTimeOpponentRef.on 'value', @onRealTimeOpponentChanged - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "Playing against #{@realTimeOpponentData.name}" - - onRealTimeOpponentChanged: (snapshot) => - # console.log 'PlayLevelView onRealTimeOpponentChanged', snapshot.val() - @realTimeOpponentData = snapshot.val() - switch @realTimeOpponentData?.state - when 'left' - console.info 'Real-time multiplayer opponent left the game' - opponentID = @realTimeOpponentData.id - @realTimeGameEnded() - Backbone.Mediator.publish 'real-time-multiplayer:left-game', userID: opponentID - when 'submitted' - # TODO: What should this message say? - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "#{@realTimeOpponentData.name} waiting for your code" - - joinRealTimeMultiplayerGame: (e) -> - # console.log 'PlayLevelView joinRealTimeMultiplayerGame', e - unless @realTimeSessionRef? - @session.set('submittedCodeLanguage', @session.get('codeLanguage')) - @session.save() - - @realTimeSessionRef = new Firebase "#{@multiplayerFireHost}multiplayer_level_sessions/#{@levelID}/#{e.realTimeSessionID}" - @realTimePlayersRef = @realTimeSessionRef.child 'players' - - # Look for opponent - @realTimeSessionRef.once 'value', (multiplayerSessionSnapshot) => - if @realTimeSessionData = multiplayerSessionSnapshot.val() - @realTimePlayersRef.once 'value', (playsSnapshot) => - if @realTimePlayersData = playsSnapshot.val() - for id, player of @realTimePlayersData - if id isnt me.id - @realTimeOpponentRef = @realTimeSessionRef.child "players/#{id}" - @realTimeOpponentRef.once 'value', (opponentSnapshot) => - if @realTimeOpponentData = opponentSnapshot.val() - @updateTeam() - else - console.error 'Could not lookup multiplayer opponent data.' - @realTimeOpponentRef.on 'value', @onRealTimeOpponentChanged - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Playing against ' + player.name - else - console.error 'Could not lookup multiplayer session players data.' - # TODO: need child_removed too? - @realTimePlayersRef.on 'child_added', @onRealTimePlayerAdded - else - console.error 'Could not lookup multiplayer session data.' - @realTimeSessionRef.on 'value', @onRealTimeSessionChanged - - @realTimePlayerGameRef = @realTimeSessionRef.child "players/#{me.id}" - - # TODO: Follow up in MultiplayerView to see if double joins can be avoided - # else - # console.error 'Joining real-time multiplayer game with an existing @realTimeSessionRef.' - - onRealTimeMultiplayerJoinedGame: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerJoinedGame', e - @joinRealTimeMultiplayerGame e - @realTimePlayerGameRef.update 'state': 'coding' - @realTimePlayerRef.update 'state': 'unavailable' - - onRealTimeMultiplayerLeftGame: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerLeftGame', e - if e.userID? and e.userID is me.id - @realTimePlayerGameRef.update 'state': 'left' - @realTimeGameEnded() - - realTimeMultiplayerContinueGame: (realTimeSessionID) -> - # console.log 'PlayLevelView realTimeMultiplayerContinueGame', realTimeSessionID, me.id - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: realTimeSessionID - - console.info 'Setting my game status to ready' - @realTimePlayerGameRef.update 'state': 'ready' - - if @realTimeOpponentData.state is 'ready' - @realTimeOpponentIsReady() - else - console.info 'Waiting for opponent to be ready' - @realTimeOpponentRef.on 'value', @realTimeOpponentMaybeReady - - realTimeOpponentMaybeReady: (snapshot) => - # console.log 'PlayLevelView realTimeOpponentMaybeReady' - if @realTimeOpponentData = snapshot.val() - if @realTimeOpponentData.state is 'ready' - @realTimeOpponentRef.off 'value', @realTimeOpponentMaybeReady - @realTimeOpponentIsReady() - - realTimeOpponentIsReady: => - console.info 'All real-time multiplayer players are ready!' - @realTimeSessionRef.update 'state': 'running' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Battling ' + @realTimeOpponentData.name - Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} - - realTimeGameEnded: -> - if @realTimeOpponentRef? - @realTimeOpponentRef.off 'value', @onRealTimeOpponentChanged - @realTimeOpponentRef = null - if @realTimePlayersRef? - @realTimePlayersRef.off 'child_added', @onRealTimePlayerAdded - @realTimePlayersRef = null - if @realTimeSessionRef? - @realTimeSessionRef.off 'value', @onRealTimeSessionChanged - @realTimeSessionRef.update 'state': 'finished' - @realTimeSessionRef = null - if @realTimePlayerGameRef? - @realTimePlayerGameRef = null - if @realTimePlayerRef? - @realTimePlayerRef.update 'state': 'playing' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: '' - - onRealTimeMultiplayerCast: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerCast', @realTimeSessionData, @realTimePlayersData - unless @realTimeSessionRef? - console.error 'Real-time multiplayer cast without multiplayer session.' - return - unless @realTimeSessionData? - console.error 'Real-time multiplayer cast without multiplayer data.' - return - unless @realTimePlayersData? - console.error 'Real-time multiplayer cast without multiplayer players data.' - return - - # Set submissionCount for created real-time multiplayer session - if me.id is @realTimeSessionData.creator - sessionState = @session.get('state') - if sessionState? - submissionCount = sessionState.submissionCount ? 0 - console.info 'Setting multiplayer submissionCount to', submissionCount - @realTimeSessionRef.update 'submissionCount': submissionCount - else - console.error 'Failed to read sessionState in onRealTimeMultiplayerCast' - - console.info 'Submitting my code' - permissions = @session.get 'permissions' ? [] - unless _.find(permissions, (p) -> p.target is 'public' and p.access is 'read') - permissions.push target:'public', access:'read' - @session.set 'permissions', permissions - @session.patch() - @realTimePlayerGameRef.update 'state': 'submitted' - - console.info 'Other player is', @realTimeOpponentData.state - if @realTimeOpponentData.state in ['submitted', 'ready'] - @realTimeOpponentSubmittedCode @realTimeOpponentData, @realTimePlayerGameData - else - # Wait for opponent to submit their code - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "Waiting for code from #{@realTimeOpponentData.name}" - @realTimeOpponentRef.on 'value', @realTimeOpponentMaybeSubmitted - - realTimeOpponentMaybeSubmitted: (snapshot) => - if @realTimeOpponentData = snapshot.val() - if @realTimeOpponentData.state in ['submitted', 'ready'] - @realTimeOpponentRef.off 'value', @realTimeOpponentMaybeSubmitted - @realTimeOpponentSubmittedCode @realTimeOpponentData, @realTimePlayerGameData - - onRealTimeMultiplayerPlaybackEnded: -> - # console.log 'PlayLevelView onRealTimeMultiplayerPlaybackEnded' - if @realTimeSessionRef? - @realTimeSessionRef.update 'state': 'coding' - @realTimePlayerGameRef.update 'state': 'coding' - if @realTimeOpponentData? - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "Playing against #{@realTimeOpponentData.name}" - - realTimeOpponentSubmittedCode: (opponentPlayer, myPlayer) => - # console.log 'PlayLevelView realTimeOpponentSubmittedCode', @realTimeSessionData.id, opponentPlayer.level_session - # Read submissionCount for joined real-time multiplayer session - if me.id isnt @realTimeSessionData.creator - sessionState = @session.get('state') ? {} - newSubmissionCount = @realTimeSessionData.submissionCount - if newSubmissionCount? - # TODO: This isn't always getting updated where the random seed generation uses it. - sessionState.submissionCount = parseInt newSubmissionCount - console.info 'Got multiplayer submissionCount', sessionState.submissionCount - @session.set 'state', sessionState - @session.patch() - - # Reload this level so the opponent session can easily be wired up - Backbone.Mediator.publish 'router:navigate', - route: "/play/level/#{@levelID}" - viewClass: PlayLevelView - viewArgs: [{supermodel: @supermodel, autoUnveil: true, realTimeMultiplayerSessionID: @realTimeSessionData.id, opponent: opponentPlayer.level_session, team: @team}, @levelID] - - updateTeam: -> - # If not creator, and same team as creator, then switch teams - # TODO: Assumes there are only 'humans' and 'ogres' - - unless @realTimeOpponentData? - console.error 'Tried to switch teams without real-time multiplayer opponent data.' - return - unless @realTimeSessionData? - console.error 'Tried to switch teams without real-time multiplayer session data.' - return - return if me.id is @realTimeSessionData.creator - - oldTeam = @realTimeOpponentData.team - return unless oldTeam is @session.get('team') - - # Need to switch to other team - newTeam = if oldTeam is 'humans' then 'ogres' else 'humans' - console.info "Switching from team #{oldTeam} to #{newTeam}" - - # Move code from old team to new team - # Assumes teamSpells has matching spells for each team - # TODO: Similar to code in loadOpponentTeam, consolidate? - code = @session.get 'code' - teamSpells = @session.get 'teamSpells' - for oldSpellKey in teamSpells[oldTeam] - [oldThang, oldSpell] = oldSpellKey.split '/' - oldCode = code[oldThang]?[oldSpell] - continue unless oldCode? - # Move oldCode to new team under same spell - for newSpellKey in teamSpells[newTeam] - [newThang, newSpell] = newSpellKey.split '/' - if newSpell is oldSpell - # Found spell location under new team - # console.log "Swapping spell=#{oldSpell} from #{oldThang} to #{newThang}" - if code[newThang]?[oldSpell]? - # Option 1: have a new spell to swap - code[oldThang][oldSpell] = code[newThang][oldSpell] - else - # Option 2: no new spell to swap - delete code[oldThang][oldSpell] - code[newThang] = {} unless code[newThang]? - code[newThang][oldSpell] = oldCode - break - - @setTeam newTeam # Sets @session 'team' - sessionState = @session.get('state') - if sessionState? - # TODO: Don't hard code thangID - sessionState.selected = if newTeam is 'humans' then 'Hero Placeholder' else 'Hero Placeholder 1' - @session.set 'state', sessionState - @session.set 'code', code - @session.patch() - - if sessionState? - # TODO: Don't hardcode spellName - Backbone.Mediator.publish 'level:select-sprite', thangID: sessionState.selected, spellName: 'plan' - -# End Real-time Multiplayer ###################################################### diff --git a/app/views/play/level/PlayWebDevLevelView.coffee b/app/views/play/level/PlayWebDevLevelView.coffee new file mode 100644 index 000000000..40d99e3f7 --- /dev/null +++ b/app/views/play/level/PlayWebDevLevelView.coffee @@ -0,0 +1,35 @@ +RootView = require 'views/core/RootView' + +Level = require 'models/Level' +LevelSession = require 'models/LevelSession' +WebSurfaceView = require './WebSurfaceView' + +module.exports = class PlayWebDevLevelView extends RootView + id: 'play-web-dev-level-view' + template: require 'templates/play/level/play-web-dev-level-view' + + initialize: (@options, @levelID, @sessionID) -> + @courseID = @getQueryVariable 'course' + @level = @supermodel.loadModel(new Level _id: @levelID).model + @session = @supermodel.loadModel(new LevelSession _id: @sessionID).model + + onLoaded: -> + super() + @insertSubView @webSurface = new WebSurfaceView {level: @level} + Backbone.Mediator.publish 'tome:html-updated', html: @getHTML() ? '

    Player has no HTML

    ', create: true + @$el.find('#info-bar').delay(4000).fadeOut(2000) + $('body').css('overflow', 'hidden') # Don't show tiny scroll bar from our minimal additions to the iframe + + showError: (jqxhr) -> + $('h1').text jqxhr.statusText + + getHTML: -> + playerHTML = @session.get('code')?['hero-placeholder']?.plan + return playerHTML unless hero = _.find @level.get('thangs'), id: 'Hero Placeholder' + return playerHTML unless programmableConfig = _.find(hero.components, (component) -> component.config?.programmableMethods).config + return programmableConfig.programmableMethods.plan.languages.html.replace /[\s\S]*<\/playercode>/, playerHTML + + destroy: -> + @webSurface?.destroy() + $('body').css('overflow', 'initial') # Recover from our modifications to body overflow before we leave + super() diff --git a/app/views/play/level/ThangAvatarView.coffee b/app/views/play/level/ThangAvatarView.coffee index f16fcea96..1d6c05fb0 100644 --- a/app/views/play/level/ThangAvatarView.coffee +++ b/app/views/play/level/ThangAvatarView.coffee @@ -57,12 +57,9 @@ module.exports = class ThangAvatarView extends CocoView @$el.toggleClass 'selected', Boolean(selected) onProblemsUpdated: (e) -> - return unless @thang?.id of e.spell.thangs - myProblems = [] - for thangID, spellThang of e.spell.thangs when thangID is @thang.id - #aether = if e.isCast and spellThang.castAether then spellThang.castAether else spellThang.aether - aether = spellThang.castAether # try only paying attention to the actually cast ones - myProblems = myProblems.concat aether.getAllProblems() if aether + return unless @thang?.id is e.spell.thang?.thang.id + aether = e.spell.thang.castAether + myProblems = aether?.getAllProblems() ? [] worstLevel = null for level in ['error', 'warning', 'info'] when _.some myProblems, {level: level} worstLevel = level diff --git a/app/views/play/level/WebSurfaceView.coffee b/app/views/play/level/WebSurfaceView.coffee new file mode 100644 index 000000000..7ee8c95c9 --- /dev/null +++ b/app/views/play/level/WebSurfaceView.coffee @@ -0,0 +1,98 @@ +CocoView = require 'views/core/CocoView' +template = require 'templates/play/level/web-surface-view' + +module.exports = class WebSurfaceView extends CocoView + id: 'web-surface-view' + template: template + + subscriptions: + 'tome:html-updated': 'onHTMLUpdated' + + initialize: (options) -> + @goals = (goal for goal in options.goalManager?.goals ? [] when goal.html) + # Consider https://www.npmjs.com/package/css-select to do this on virtualDom instead of in iframe on concreteDOM + super(options) + + afterRender: -> + super() + @iframe = @$('iframe')[0] + $(@iframe).on 'load', (e) => + window.addEventListener 'message', @onIframeMessage + @iframeLoaded = true + @onIframeLoaded?() + @onIframeLoaded = null + + # TODO: make clicking Run actually trigger a 'create' update here (for resetting scripts) + + onHTMLUpdated: (e) -> + unless @iframeLoaded + return @onIframeLoaded = => @onHTMLUpdated e unless @destroyed + dom = htmlparser2.parseDOM e.html, {} + body = _.find(dom, name: 'body') ? {name: 'body', attribs: null, children: dom} + html = _.find(dom, name: 'html') ? {name: 'html', attribs: null, children: [body]} + # TODO: pull out the actual scripts, styles, and body/elements they are doing so we can merge them with our initial structure on the other side + { virtualDom, styles, scripts } = @extractStylesAndScripts(@dekuify html) + messageType = if e.create or not @virtualDom then 'create' else 'update' + @iframe.contentWindow.postMessage {type: messageType, dom: virtualDom, styles, scripts, goals: @goals}, '*' + @virtualDom = virtualDom + + dekuify: (elem) -> + return elem.data if elem.type is 'text' + return null if elem.type is 'comment' # TODO: figure out how to make a comment in virtual dom + elem.attribs = _.omit elem.attribs, (val, attr) -> attr.indexOf('<') > -1 # Deku chokes on `

    ` + unless elem.name + console.log("Failed to dekuify", elem) + return elem.type + deku.element(elem.name, elem.attribs, (@dekuify(c) for c in elem.children ? [])) + + extractStylesAndScripts: (dekuTree) -> + recurse = (dekuTree) -> + #base case + if dekuTree.type is '#text' + return { virtualDom: dekuTree, styles: [], scripts: [] } + if dekuTree.type is 'style' + console.log 'Found a style: ', dekuTree + return { styles: [dekuTree], scripts: [] } + if dekuTree.type is 'script' + console.log 'Found a script: ', dekuTree + return { styles: [], scripts: [dekuTree] } + # recurse over children + childStyles = [] + childScripts = [] + dekuTree.children?.forEach (dekuChild, index) => + { virtualDom, styles, scripts } = recurse(dekuChild) + dekuTree.children[index] = virtualDom + childStyles = childStyles.concat(styles) + childScripts = childScripts.concat(scripts) + dekuTree.children = _.filter dekuTree.children # Remove the nodes we extracted + return { virtualDom: dekuTree, scripts: childScripts, styles: childStyles } + + { virtualDom, scripts, styles } = recurse(dekuTree) + combinedScripts = @combineNodes('script', scripts) + combinedStyles = @combineNodes('style', styles) + return { virtualDom, scripts: combinedScripts, styles: combinedStyles } + + combineNodes: (type, nodes) -> + if _.any(nodes, (node) -> node.type isnt type) + throw new Error("Can't combine nodes of different types. (Got #{nodes.map (n) -> n.type})") + children = nodes.map((n) -> n.children).reduce(((a,b) -> a.concat(b)), []) + if _.isEmpty(children) + deku.element(type, {}) + else + deku.element(type, {}, children) + + onIframeMessage: (event) => + origin = event.origin or event.originalEvent.origin + unless origin is window.location.origin + return console.log 'Ignoring message from bad origin:', origin + unless event.source is @iframe.contentWindow + return console.log 'Ignoring message from somewhere other than our iframe:', event.source + switch event.data.type + when 'goals-updated' + Backbone.Mediator.publish 'god:new-html-goal-states', goalStates: event.data.goalStates, overallStatus: event.data.overallStatus + else + console.warn 'Unknown message type', event.data.type, 'for message', e, 'from origin', origin + + destroy: -> + window.removeEventListener 'message', @onIframeMessage + super() diff --git a/app/views/play/level/modal/CourseVictoryModal.coffee b/app/views/play/level/modal/CourseVictoryModal.coffee index 8df142bd1..0e34d2744 100644 --- a/app/views/play/level/modal/CourseVictoryModal.coffee +++ b/app/views/play/level/modal/CourseVictoryModal.coffee @@ -44,6 +44,11 @@ module.exports = class CourseVictoryModal extends ModalView @levelSessions = @supermodel.loadCollection(@levelSessions, 'sessions', { data: { project: 'state.complete level.original playtime changed' } }).model + + if not @course + @course = new Course() + @supermodel.trackRequest @course.fetchForCourseInstance(@courseInstanceID) + window.tracker?.trackEvent 'Play Level Victory Modal Loaded', category: 'Students', levelSlug: @level.get('slug'), ['Mixpanel'] onResourceLoadFailed: (e) -> @@ -53,6 +58,7 @@ module.exports = class CourseVictoryModal extends ModalView onLoaded: -> super() + @courseID ?= @course.id @views = [] @levelSessions?.remove(@session) @@ -63,10 +69,12 @@ module.exports = class CourseVictoryModal extends ModalView course: @course classroom: @classroom levelSessions: @levelSessions + session: @session }) progressView.once 'done', @onDone, @ progressView.once 'next-level', @onNextLevel, @ + progressView.once 'ladder', @onLadder, @ for view in @views view.on 'continue', @onViewContinue, @ @views.push(progressView) @@ -104,3 +112,15 @@ module.exports = class CourseVictoryModal extends ModalView else link = "/courses/#{@courseID}/#{@courseInstanceID}" application.router.navigate(link, {trigger: true}) + + onLadder: -> + # Preserve the supermodel as we navigate back to the ladder. + viewArgs = [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @level.get('slug')] + ladderURL = "/play/ladder/#{@level.get('slug') || @level.id}" + if leagueID = (@courseInstanceID or @getQueryVariable 'league') + leagueType = if @level.get('type') is 'course-ladder' then 'course' else 'clan' + viewArgs.push leagueType + viewArgs.push leagueID + ladderURL += "/#{leagueType}/#{leagueID}" + ladderURL += '#my-matches' + Backbone.Mediator.publish 'router:navigate', route: ladderURL, viewClass: 'views/ladder/LadderView', viewArgs: viewArgs diff --git a/app/views/play/level/modal/HeroVictoryModal.coffee b/app/views/play/level/modal/HeroVictoryModal.coffee index 1f7a46672..3e7ce32a6 100644 --- a/app/views/play/level/modal/HeroVictoryModal.coffee +++ b/app/views/play/level/modal/HeroVictoryModal.coffee @@ -32,6 +32,7 @@ module.exports = class HeroVictoryModal extends ModalView 'click .sign-up-button': 'onClickSignupButton' 'click .continue-from-offer-button': 'onClickContinueFromOffer' 'click .skip-offer-button': 'onClickSkipOffer' + 'click #share-level-btn': 'onClickShareLevelButton' # Feedback events 'mouseover .rating i': (e) -> @showStars(@starNum($(e.target))) @@ -49,7 +50,7 @@ module.exports = class HeroVictoryModal extends ModalView @session = options.session @level = options.level @thangTypes = {} - if @level.get('type', true) in ['hero', 'hero-ladder', 'course', 'course-ladder', 'game-dev'] + if @level.isType('hero', 'hero-ladder', 'course', 'course-ladder', 'game-dev', 'web-dev') achievements = new CocoCollection([], { url: "/db/achievement?related=#{@session.get('level').original}" model: Achievement @@ -63,17 +64,19 @@ module.exports = class HeroVictoryModal extends ModalView else @readyToContinue = true @playSound 'victory' - if @level.get('type', true) is 'course' + if @level.isType('course', 'game-dev', 'web-dev') if nextLevel = @level.get('nextLevel') @nextLevel = new Level().setURL "/db/level/#{nextLevel.original}/version/#{nextLevel.majorVersion}" @nextLevel = @supermodel.loadModel(@nextLevel).model if @courseID @course = new Course().setURL "/db/course/#{@courseID}" @course = @supermodel.loadModel(@course).model - if @level.get('type', true) in ['course', 'course-ladder'] + if @level.isType('course', 'course-ladder') @saveReviewEventually = _.debounce(@saveReviewEventually, 2000) @loadExistingFeedback() - # TODO: support game-dev + + if @level.get('shareable') is 'project' + @shareURL = "#{window.location.origin}/play/#{@level.get('type')}-level/#{@level.get('slug')}/#{@session.id}" destroy: -> clearInterval @sequentialAnimationInterval @@ -154,8 +157,7 @@ module.exports = class HeroVictoryModal extends ModalView getRenderData: -> c = super() c.levelName = utils.i18n @level.attributes, 'name' - # TODO: support 'game-dev' - if @level.get('type', true) not in ['hero', 'game-dev'] + if @level.isType('hero', 'game-dev', 'web-dev') c.victoryText = utils.i18n @level.get('victory') ? {}, 'body' earnedAchievementMap = _.indexBy(@newEarnedAchievements or [], (ea) -> ea.get('achievement')) for achievement in (@achievements?.models or []) @@ -192,7 +194,7 @@ module.exports = class HeroVictoryModal extends ModalView c.thangTypes = @thangTypes c.me = me - c.readyToRank = @level.get('type', true) in ['hero-ladder', 'course-ladder'] and @session.readyToRank() + c.readyToRank = @level.isType('hero-ladder', 'course-ladder') and @session.readyToRank() c.level = @level c.i18n = utils.i18n @@ -211,10 +213,10 @@ module.exports = class HeroVictoryModal extends ModalView # Show the "I'm done" button between 30 - 120 minutes if they definitely came from Hour of Code c.showHourOfCodeDoneButton = showDone - c.showLeaderboard = @level.get('scoreTypes')?.length > 0 and @level.get('type', true) isnt 'course' + c.showLeaderboard = @level.get('scoreTypes')?.length > 0 and not @level.isType('course') - c.showReturnToCourse = not c.showLeaderboard and not me.get('anonymous') and @level.get('type', true) in ['course', 'course-ladder'] - c.isCourseLevel = @level.get('type', true) in ['course'] + c.showReturnToCourse = not c.showLeaderboard and not me.get('anonymous') and @level.isType('course', 'course-ladder') + c.isCourseLevel = @level.isType('course') c.currentCourseName = @course?.get('name') c.currentLevelName = @level?.get('name') c.nextLevelName = @nextLevel?.get('name') @@ -223,17 +225,17 @@ module.exports = class HeroVictoryModal extends ModalView afterRender: -> super() - @$el.toggleClass 'with-achievements', @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev'] # TODO: support game-dev + @$el.toggleClass 'with-achievements', @level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') return unless @supermodel.finished() @playSelectionSound hero, true for original, hero of @thangTypes # Preload them @updateSavingProgressStatus() @initializeAnimations() - if @level.get('type', true) in ['hero-ladder', 'course-ladder'] + if @level.isType('hero-ladder', 'course-ladder') @ladderSubmissionView = new LadderSubmissionView session: @session, level: @level @insertSubView @ladderSubmissionView, @$el.find('.ladder-submission-view') initializeAnimations: -> - return @endSequentialAnimations() unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev'] # TODO: support game-dev + return @endSequentialAnimations() unless @level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') @updateXPBars 0 #playVictorySound = => @playSound 'victory-title-appear' # TODO: actually add this @$el.find('#victory-header').delay(250).queue(-> @@ -264,7 +266,7 @@ module.exports = class HeroVictoryModal extends ModalView beginSequentialAnimations: -> return if @destroyed - return unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev'] # TODO: support game-dev + return unless @level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') @sequentialAnimatedPanels = _.map(@animatedPanels.find('.reward-panel'), (panel) -> { number: $(panel).data('number') previousNumber: $(panel).data('previous-number') @@ -379,7 +381,7 @@ module.exports = class HeroVictoryModal extends ModalView clearInterval @sequentialAnimationInterval @animationComplete = true @updateSavingProgressStatus() - Backbone.Mediator.publish 'music-player:enter-menu', terrain: @level.get('terrain', true) + Backbone.Mediator.publish 'music-player:enter-menu', terrain: @level.get('terrain', true) or 'forest' updateSavingProgressStatus: -> @$el.find('#saving-progress-label').toggleClass('hide', @readyToContinue) @@ -394,7 +396,7 @@ module.exports = class HeroVictoryModal extends ModalView viewArgs = [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @level.get('slug')] ladderURL = "/play/ladder/#{@level.get('slug') || @level.id}" if leagueID = (@courseInstanceID or @getQueryVariable 'league') - leagueType = if @level.get('type') is 'course-ladder' then 'course' else 'clan' + leagueType = if @level.isType('course-ladder') then 'course' else 'clan' viewArgs.push leagueType viewArgs.push leagueID ladderURL += "/#{leagueType}/#{leagueID}" @@ -414,14 +416,14 @@ module.exports = class HeroVictoryModal extends ModalView {'kithgard-gates': 'forest', 'kithgard-mastery': 'forest', 'siege-of-stonehold': 'desert', 'clash-of-clones': 'mountain', 'summits-gate': 'glacier'}[@level.get('slug')] or @level.get 'campaign' # Much easier to just keep this updated than to dynamically figure it out. getNextLevelLink: (returnToCourse=false) -> - if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') and not returnToCourse + if @level.isType('course', 'game-dev', 'web-dev') and nextLevel = @level.get('nextLevel') and not returnToCourse # need to do something more complicated to load its slug console.log 'have @nextLevel', @nextLevel, 'from nextLevel', nextLevel link = "/play/level/#{@nextLevel.get('slug')}" if @courseID link += "?course=#{@courseID}" link += "&course-instance=#{@courseInstanceID}" if @courseInstanceID - else if @level.get('type', true) is 'course' + else if @level.isType('course') link = "/courses" if @courseID link += "/#{@courseID}" @@ -440,12 +442,12 @@ module.exports = class HeroVictoryModal extends ModalView justBeatLevel: @level supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel _.merge options, extraOptions if extraOptions - if @level.get('type', true) is 'course' and @nextLevel and not options.returnToCourse + if @level.isType('course') and @nextLevel and not options.returnToCourse viewClass = require 'views/play/level/PlayLevelView' options.courseID = @courseID options.courseInstanceID = @courseInstanceID viewArgs = [options, @nextLevel.get('slug')] - else if @level.get('type', true) is 'course' + else if @level.isType('course') # TODO: shouldn't set viewClass and route in different places viewClass = require 'views/courses/CoursesView' viewArgs = [options] @@ -453,7 +455,7 @@ module.exports = class HeroVictoryModal extends ModalView viewClass = require 'views/courses/CourseDetailsView' viewArgs.push @courseID viewArgs.push @courseInstanceID if @courseInstanceID - else if @level.get('type', true) is 'course-ladder' + else if @level.isType('course-ladder') leagueID = @courseInstanceID or @getQueryVariable 'league' nextLevelLink = "/play/ladder/#{@level.get('slug')}" nextLevelLink += "/course/#{leagueID}" if leagueID @@ -499,6 +501,10 @@ module.exports = class HeroVictoryModal extends ModalView onClickSkipOffer: (e) -> Backbone.Mediator.publish 'router:navigate', @navigationEventUponCompletion + onClickShareLevelButton: -> + @$('#share-level-input').val(@shareURL).select() + @tryCopy() + # Ratings and reviews starNum: (starEl) -> starEl.prevAll('i').length + 1 diff --git a/app/views/play/level/modal/ImageGalleryModal.coffee b/app/views/play/level/modal/ImageGalleryModal.coffee new file mode 100644 index 000000000..50a300b59 --- /dev/null +++ b/app/views/play/level/modal/ImageGalleryModal.coffee @@ -0,0 +1,876 @@ +ModalView = require 'views/core/ModalView' + +module.exports = class ImageGalleryModal extends ModalView + id: 'image-gallery-modal' + template: require 'templates/play/level/modal/image-gallery-modal' + # Top most useful Thang portraits + images: [ + {slug: 'archer-f', name: 'Archer F', original: '529ab1a24b67a988ad000002', portraitURL: '/file/db/thang.type/529ab1a24b67a988ad000002/portrait.png', kind: 'Unit'} + {slug: 'archer-m', name: 'Archer M', original: '52cee45a76ebd5196b00003a', portraitURL: '/file/db/thang.type/52cee45a76ebd5196b00003a/portrait.png', kind: 'Unit'} + {slug: 'artist', name: 'Artist', original: '56d0c4f601476e2100de76c0', portraitURL: '/file/db/thang.type/56d0c4f601476e2100de76c0/portrait.png', kind: 'Unit'} + {slug: 'assassin', name: 'Assassin', original: '566a2202e132c81f00f38c81', portraitURL: '/file/db/thang.type/566a2202e132c81f00f38c81/portrait.png', kind: 'Hero'} + {slug: 'baby-griffin', name: 'baby griffin', original: '57586f0a22179b2800efda37', portraitURL: '/file/db/thang.type/57586f0a22179b2800efda37/portrait.png', kind: 'Item'} + {slug: 'basic-flags', name: 'Basic Flags', original: '545bacb41e649a4495f887da', portraitURL: '/file/db/thang.type/545bacb41e649a4495f887da/portrait.png', kind: 'Item'} + {slug: 'boom-ball', name: 'Boom Ball', original: '54eb540b49fa2d5c905ddf1a', portraitURL: '/file/db/thang.type/54eb540b49fa2d5c905ddf1a/portrait.png', kind: 'Item'} + {slug: 'breaker', name: 'Breaker', original: '56d0dd5b441ddd2f002ba3d8', portraitURL: '/file/db/thang.type/56d0dd5b441ddd2f002ba3d8/portrait.png', kind: 'Unit'} + {slug: 'burl', name: 'Burl', original: '530e5926c06854403ba68693', portraitURL: '/file/db/thang.type/530e5926c06854403ba68693/portrait.png', kind: 'Unit'} + {slug: 'captain', name: 'Captain', original: '529ec584c423d4e83b000014', portraitURL: '/file/db/thang.type/529ec584c423d4e83b000014/portrait.png', kind: 'Hero'} + {slug: 'champion', name: 'Champion', original: '575848b522179b2800efbfbf', portraitURL: '/file/db/thang.type/575848b522179b2800efbfbf/portrait.png', kind: 'Hero'} + {slug: 'chest-of-gems', name: 'Chest of Gems', original: '5432f9d18364d30000d1f943', portraitURL: '/file/db/thang.type/5432f9d18364d30000d1f943/portrait.png', kind: 'Misc'} + {slug: 'confuse', name: 'Confuse', original: '53024b76a6efdd32359c5340', portraitURL: '/file/db/thang.type/53024b76a6efdd32359c5340/portrait.png', kind: 'Mark'} + {slug: 'control', name: 'Control', original: '53024c7b27471514685d5397', portraitURL: '/file/db/thang.type/53024c7b27471514685d5397/portrait.png', kind: 'Mark'} + {slug: 'cougar', name: 'Cougar', original: '5744e3683af6bf590cd27371', portraitURL: '/file/db/thang.type/5744e3683af6bf590cd27371/portrait.png', kind: 'Item'} + {slug: 'cow', name: 'Cow', original: '52e95a5022efc8e709001743', portraitURL: '/file/db/thang.type/52e95a5022efc8e709001743/portrait.png', kind: 'Doodad'} + {slug: 'dantdm', name: 'DanTDM', original: '578674c3a6c641350091b645', portraitURL: '/file/db/thang.type/578674c3a6c641350091b645/portrait.png', kind: 'Unit'} + {slug: 'desert-bones-2', name: 'Desert Bones 2', original: '548cf11b0f559d0000be7e2b', portraitURL: '/file/db/thang.type/548cf11b0f559d0000be7e2b/portrait.png', kind: 'Doodad'} + {slug: 'duelist', name: 'Duelist', original: '57588f09046caf2e0012ed41', portraitURL: '/file/db/thang.type/57588f09046caf2e0012ed41/portrait.png', kind: 'Hero'} + {slug: 'equestrian', name: 'Equestrian', original: '52e95b4222efc8e70900175d', portraitURL: '/file/db/thang.type/52e95b4222efc8e70900175d/portrait.png', kind: 'Unit'} + {slug: 'flower-1', name: 'Flower 1', original: '54e951c8f54ef5794f354ed1', portraitURL: '/file/db/thang.type/54e951c8f54ef5794f354ed1/portrait.png', kind: 'Doodad'} + {slug: 'flower-2', name: 'Flower 2', original: '54e9525ff54ef5794f354ed5', portraitURL: '/file/db/thang.type/54e9525ff54ef5794f354ed5/portrait.png', kind: 'Doodad'} + {slug: 'flower-3', name: 'Flower 3', original: '54e95293f54ef5794f354ed9', portraitURL: '/file/db/thang.type/54e95293f54ef5794f354ed9/portrait.png', kind: 'Doodad'} + {slug: 'flower-4', name: 'Flower 4', original: '54e952b7f54ef5794f354edd', portraitURL: '/file/db/thang.type/54e952b7f54ef5794f354edd/portrait.png', kind: 'Doodad'} + {slug: 'flower-5', name: 'Flower 5', original: '54e952daf54ef5794f354ee1', portraitURL: '/file/db/thang.type/54e952daf54ef5794f354ee1/portrait.png', kind: 'Doodad'} + {slug: 'flower-6', name: 'Flower 6', original: '54e95308f54ef5794f354ee5', portraitURL: '/file/db/thang.type/54e95308f54ef5794f354ee5/portrait.png', kind: 'Doodad'} + {slug: 'flower-7', name: 'Flower 7', original: '54e9532ff54ef5794f354ee9', portraitURL: '/file/db/thang.type/54e9532ff54ef5794f354ee9/portrait.png', kind: 'Doodad'} + {slug: 'flower-8', name: 'Flower 8', original: '54e9534ef54ef5794f354eed', portraitURL: '/file/db/thang.type/54e9534ef54ef5794f354eed/portrait.png', kind: 'Doodad'} + {slug: 'forest-archer', name: 'Forest Archer', original: '5466d4f2417c8b48a9811e87', portraitURL: '/file/db/thang.type/5466d4f2417c8b48a9811e87/portrait.png', kind: 'Hero'} + {slug: 'frozen-munchkin', name: 'Frozen Munchkin', original: '5576686e1e82182d9e6889bb', portraitURL: '/file/db/thang.type/5576686e1e82182d9e6889bb/portrait.png', kind: 'Doodad'} + {slug: 'frozen-soldier-f', name: 'Frozen Soldier F', original: '5576683e1e82182d9e6889b7', portraitURL: '/file/db/thang.type/5576683e1e82182d9e6889b7/portrait.png', kind: 'Doodad'} + {slug: 'frozen-soldier-m', name: 'Frozen Soldier M', original: '557662bf1e82182d9e6889af', portraitURL: '/file/db/thang.type/557662bf1e82182d9e6889af/portrait.png', kind: 'Doodad'} + {slug: 'gem', name: 'Gem', original: '52aa3b9eccbd588d4d000003', portraitURL: '/file/db/thang.type/52aa3b9eccbd588d4d000003/portrait.png', kind: 'Misc'} + {slug: 'gold-coin', name: 'Gold Coin', original: '535ef031c519160709f2f63a', portraitURL: '/file/db/thang.type/535ef031c519160709f2f63a/portrait.png', kind: 'Misc'} + {slug: 'goliath', name: 'Goliath', original: '55e1a6e876cb0948c96af9f8', portraitURL: '/file/db/thang.type/55e1a6e876cb0948c96af9f8/portrait.png', kind: 'Hero'} + {slug: 'guardian', name: 'Guardian', original: '566a058620de41290036a745', portraitURL: '/file/db/thang.type/566a058620de41290036a745/portrait.png', kind: 'Hero'} + {slug: 'horse', name: 'Horse', original: '52e989a4427172ae56001f04', portraitURL: '/file/db/thang.type/52e989a4427172ae56001f04/portrait.png', kind: 'Doodad'} + {slug: 'knight', name: 'Knight', original: '529ffbf1cf1818f2be000001', portraitURL: '/file/db/thang.type/529ffbf1cf1818f2be000001/portrait.png', kind: 'Hero'} + {slug: 'librarian', name: 'Librarian', original: '52fbf74b7e01835453bd8d8e', portraitURL: '/file/db/thang.type/52fbf74b7e01835453bd8d8e/portrait.png', kind: 'Hero'} + {slug: 'necromancer', name: 'Necromancer', original: '55652fb3b9effa46a1f775fd', portraitURL: '/file/db/thang.type/55652fb3b9effa46a1f775fd/portrait.png', kind: 'Hero'} + {slug: 'ninja', name: 'Ninja', original: '52fc0ed77e01835453bd8f6c', portraitURL: '/file/db/thang.type/52fc0ed77e01835453bd8f6c/portrait.png', kind: 'Hero'} + {slug: 'ogre-brawler', name: 'Ogre Brawler', original: '529e5ee76febb9ca7e00000b', portraitURL: '/file/db/thang.type/529e5ee76febb9ca7e00000b/portrait.png', kind: 'Unit'} + {slug: 'ogre-chieftain', name: 'Ogre Chieftain', original: '55370661428ddac5686fd026', portraitURL: '/file/db/thang.type/55370661428ddac5686fd026/portrait.png', kind: 'Unit'} + {slug: 'ogre-f', name: 'Ogre F', original: '52cedd3e0b0d5c1b4c003ec6', portraitURL: '/file/db/thang.type/52cedd3e0b0d5c1b4c003ec6/portrait.png', kind: 'Unit'} + {slug: 'ogre-fangrider', name: 'Ogre Fangrider', original: '529e5f0c6febb9ca7e00000c', portraitURL: '/file/db/thang.type/529e5f0c6febb9ca7e00000c/portrait.png', kind: 'Unit'} + {slug: 'ogre-headhunter', name: 'Ogre Headhunter', original: '54c96c3cdef3ad363ff998a1', portraitURL: '/file/db/thang.type/54c96c3cdef3ad363ff998a1/portrait.png', kind: 'Unit'} + {slug: 'ogre-m', name: 'Ogre M', original: '529e40856febb9ca7e000004', portraitURL: '/file/db/thang.type/529e40856febb9ca7e000004/portrait.png', kind: 'Unit'} + {slug: 'ogre-munchkin-f', name: 'Ogre Munchkin F', original: '52cee1d976ebd5196b000038', portraitURL: '/file/db/thang.type/52cee1d976ebd5196b000038/portrait.png', kind: 'Unit'} + {slug: 'ogre-munchkin-m', name: 'Ogre Munchkin M', original: '529e5d756febb9ca7e00000a', portraitURL: '/file/db/thang.type/529e5d756febb9ca7e00000a/portrait.png', kind: 'Unit'} + {slug: 'ogre-shaman', name: 'Ogre Shaman', original: '529f92f9dacd325127000008', portraitURL: '/file/db/thang.type/529f92f9dacd325127000008/portrait.png', kind: 'Unit'} + {slug: 'ogre-thrower', name: 'Ogre Thrower', original: '529fff23cf1818f2be000003', portraitURL: '/file/db/thang.type/529fff23cf1818f2be000003/portrait.png', kind: 'Unit'} + {slug: 'ogre-warlock', name: 'Ogre Warlock', original: '5536f88c428ddac5686fd00c', portraitURL: '/file/db/thang.type/5536f88c428ddac5686fd00c/portrait.png', kind: 'Unit'} + {slug: 'ogre-witch', name: 'Ogre Witch', original: '5536ce98428ddac5686fcfd3', portraitURL: '/file/db/thang.type/5536ce98428ddac5686fcfd3/portrait.png', kind: 'Unit'} + {slug: 'oracle', name: 'Oracle', original: '56d0cfa063103d2a00af5449', portraitURL: '/file/db/thang.type/56d0cfa063103d2a00af5449/portrait.png', kind: 'Unit'} + {slug: 'paladin', name: 'Paladin', original: '552be965c54551e79b57b766', portraitURL: '/file/db/thang.type/552be965c54551e79b57b766/portrait.png', kind: 'Unit'} + {slug: 'peasant-f', name: 'Peasant F', original: '52d48f02d0ce9936e2000005', portraitURL: '/file/db/thang.type/52d48f02d0ce9936e2000005/portrait.png', kind: 'Unit'} + {slug: 'peasant-m', name: 'Peasant M', original: '529f9026dacd325127000005', portraitURL: '/file/db/thang.type/529f9026dacd325127000005/portrait.png', kind: 'Unit'} + {slug: 'polar-bear-cub', name: 'Polar Bear Cub', original: '578691f9bd31c1440083251d', portraitURL: '/file/db/thang.type/578691f9bd31c1440083251d/portrait.png', kind: 'Item'} + {slug: 'potion-master', name: 'Potion Master', original: '52e9adf7427172ae56002172', portraitURL: '/file/db/thang.type/52e9adf7427172ae56002172/portrait.png', kind: 'Hero'} + {slug: 'pugicorn', name: 'Pugicorn', original: '577d5d4dab818b210046b3bf', portraitURL: '/file/db/thang.type/577d5d4dab818b210046b3bf/portrait.png', kind: 'Item'} + {slug: 'raider', name: 'Raider', original: '55527eb0b8abf4ba1fe9a107', portraitURL: '/file/db/thang.type/55527eb0b8abf4ba1fe9a107/portrait.png', kind: 'Hero'} + {slug: 'raven', name: 'Raven', original: '5786a472a6c64135009238d3', portraitURL: '/file/db/thang.type/5786a472a6c64135009238d3/portrait.png', kind: 'Item'} + {slug: 'raven-pet', name: 'Raven Pet', original: '540f389a821af8000097dc5a', portraitURL: '/file/db/thang.type/540f389a821af8000097dc5a/portrait.png', kind: 'Unit'} + {slug: 'razordisc', name: 'Razordisc', original: '54eb4d5949fa2d5c905ddf06', portraitURL: '/file/db/thang.type/54eb4d5949fa2d5c905ddf06/portrait.png', kind: 'Item'} + {slug: 'samurai', name: 'Samurai', original: '53e12be0d042f23505c3023b', portraitURL: '/file/db/thang.type/53e12be0d042f23505c3023b/portrait.png', kind: 'Hero'} + {slug: 'skeleton', name: 'Skeleton', original: '54c83b8ae2829db30d0310e0', portraitURL: '/file/db/thang.type/54c83b8ae2829db30d0310e0/portrait.png', kind: 'Unit'} + {slug: 'soldier-f', name: 'Soldier F', original: '52d49552d0ce9936e2000007', portraitURL: '/file/db/thang.type/52d49552d0ce9936e2000007/portrait.png', kind: 'Unit'} + {slug: 'soldier-m', name: 'Soldier M', original: '529e680ac423d4e83b000001', portraitURL: '/file/db/thang.type/529e680ac423d4e83b000001/portrait.png', kind: 'Unit'} + {slug: 'sorcerer', name: 'Sorcerer', original: '52fd1524c7e6cf99160e7bc9', portraitURL: '/file/db/thang.type/52fd1524c7e6cf99160e7bc9/portrait.png', kind: 'Hero'} + {slug: 'target', name: 'Target', original: '52b32ad97385ec3d03000001', portraitURL: '/file/db/thang.type/52b32ad97385ec3d03000001/portrait.png', kind: 'Mark'} + {slug: 'thoktar', name: 'Thoktar', original: '52a00542cf1818f2be000006', portraitURL: '/file/db/thang.type/52a00542cf1818f2be000006/portrait.png', kind: 'Unit'} + {slug: 'tinker', name: 'Tinker', original: '56cdd89be906e72400f13451', portraitURL: '/file/db/thang.type/56cdd89be906e72400f13451/portrait.png', kind: 'Unit'} + {slug: 'trapper', name: 'Trapper', original: '5466d449417c8b48a9811e83', portraitURL: '/file/db/thang.type/5466d449417c8b48a9811e83/portrait.png', kind: 'Hero'} + {slug: 'wizard', name: 'Wizard', original: '52a00d55cf1818f2be00000b', portraitURL: '/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png', kind: 'Unit'} + {slug: 'wyrm', name: 'Wyrm', original: '56ba2b34e942de2600c792ed', portraitURL: '/file/db/thang.type/56ba2b34e942de2600c792ed/portrait.png', kind: 'Unit'} + ] + + # Ones we didn't decide to use + otherImages: [ + {slug: 'hero-placeholder', name: 'Hero Placeholder', original: '53ed1d9c2b65b0e32b9c96a9', portraitURL: '/file/db/thang.type/53ed1d9c2b65b0e32b9c96a9/portrait.png', kind: 'Unit'} + {slug: 'flag', name: 'Flag', original: '53fa25f25bc220000052c2be', portraitURL: '/file/db/thang.type/53fa25f25bc220000052c2be/portrait.png', kind: 'Misc'} + {slug: 'ace-of-coders-background', name: 'Ace of Coders Background', original: '55ef24a10e11a95a0d0ab103', portraitURL: '/file/db/thang.type/55ef24a10e11a95a0d0ab103/portrait.png', kind: 'Floor'} + {slug: 'advanced-flags', name: 'Advanced Flags', original: '5478b97e8707a2c3a2493b2f', portraitURL: '/file/db/thang.type/5478b97e8707a2c3a2493b2f/portrait.png', kind: 'Item'} + {slug: 'aerial-spear', name: 'Aerial Spear', original: '5400da521130f1881ca255e4', portraitURL: '/file/db/thang.type/5400da521130f1881ca255e4/portrait.png', kind: 'Misc'} + {slug: 'altar', name: 'Altar', original: '54ef8eb683b08b7d054b7f04', portraitURL: '/file/db/thang.type/54ef8eb683b08b7d054b7f04/portrait.png', kind: 'Doodad'} + {slug: 'amber-sense-stone', name: 'Amber Sense Stone', original: '54693413a2b1f53ce79443dd', portraitURL: '/file/db/thang.type/54693413a2b1f53ce79443dd/portrait.png', kind: 'Item'} + {slug: 'angel-fountain', name: 'Angel Fountain', original: '54f11438021968810565376b', portraitURL: '/file/db/thang.type/54f11438021968810565376b/portrait.png', kind: 'Doodad'} + {slug: 'angel-statue', name: 'Angel Statue', original: '54f1152a021968810565378a', portraitURL: '/file/db/thang.type/54f1152a021968810565378a/portrait.png', kind: 'Doodad'} + {slug: 'archway', name: 'Archway', original: '534dd3531a52ddd804f34efc', portraitURL: '/file/db/thang.type/534dd3531a52ddd804f34efc/portrait.png', kind: 'Misc'} + {slug: 'arrow', name: 'Arrow', original: '529ce66b0bf0bccdc6000005', portraitURL: '/file/db/thang.type/529ce66b0bf0bccdc6000005/portrait.png', kind: 'Missile'} + {slug: 'arrow-tower', name: 'Arrow Tower', original: '529f93cfdacd32512700000a', portraitURL: '/file/db/thang.type/529f93cfdacd32512700000a/portrait.png', kind: 'Unit'} + {slug: 'artillery', name: 'Artillery', original: '529e7a16c423d4e83b000003', portraitURL: '/file/db/thang.type/529e7a16c423d4e83b000003/portrait.png', kind: 'Unit'} + {slug: 'baby-griffin-pet', name: 'Baby Griffin Pet', original: '5750ef2f9f734c20005f1f57', portraitURL: '/file/db/thang.type/5750ef2f9f734c20005f1f57/portrait.png', kind: 'Unit'} + {slug: 'ball', name: 'Ball', original: '5580af39b43ce0b15a91b299', portraitURL: '/file/db/thang.type/5580af39b43ce0b15a91b299/portrait.png', kind: 'Doodad'} + {slug: 'balsa-staff', name: 'Balsa Staff', original: '544d88478494308424f56505', portraitURL: '/file/db/thang.type/544d88478494308424f56505/portrait.png', kind: 'Item'} + {slug: 'banded-redwood-wand', name: 'Banded Redwood Wand', original: '544d887c8494308424f56509', portraitURL: '/file/db/thang.type/544d887c8494308424f56509/portrait.png', kind: 'Item'} + {slug: 'barn', name: 'Barn', original: '54f1136f25be5e88058374b3', portraitURL: '/file/db/thang.type/54f1136f25be5e88058374b3/portrait.png', kind: 'Doodad'} + {slug: 'barrel', name: 'Barrel', original: '52aa5ff120fccb0000000003', portraitURL: '/file/db/thang.type/52aa5ff120fccb0000000003/portrait.png', kind: 'Doodad'} + {slug: 'barrel-animated', name: 'Barrel Animated', original: '54d2b28e7e1b915605556c37', portraitURL: '/file/db/thang.type/54d2b28e7e1b915605556c37/portrait.png', kind: 'Doodad'} + {slug: 'barrel-animated-2', name: 'Barrel Animated 2', original: '54d2b4fdae912a520569cff1', portraitURL: '/file/db/thang.type/54d2b4fdae912a520569cff1/portrait.png', kind: 'Doodad'} + {slug: 'bat', name: 'Bat', original: '55c13175c87e47c60604f987', portraitURL: '/file/db/thang.type/55c13175c87e47c60604f987/portrait.png', kind: 'Doodad'} + {slug: 'beam', name: 'Beam', original: '529ec2cec423d4e83b000011', portraitURL: '/file/db/thang.type/529ec2cec423d4e83b000011/portrait.png', kind: 'Missile'} + {slug: 'beam-tower', name: 'Beam Tower', original: '529ec0c1c423d4e83b00000d', portraitURL: '/file/db/thang.type/529ec0c1c423d4e83b00000d/portrait.png', kind: 'Unit'} + {slug: 'bear', name: 'Bear', original: '54e95b22f54ef5794f354f41', portraitURL: '/file/db/thang.type/54e95b22f54ef5794f354f41/portrait.png', kind: 'Doodad'} + {slug: 'bear-trap', name: 'Bear Trap', original: '54d2b8ef3e16915505f0bfeb', portraitURL: '/file/db/thang.type/54d2b8ef3e16915505f0bfeb/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-1', name: 'Big Rocks 1', original: '557f950db43ce0b15a91b1d9', portraitURL: '/file/db/thang.type/557f950db43ce0b15a91b1d9/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-2', name: 'Big Rocks 2', original: '557f959ab43ce0b15a91b1dd', portraitURL: '/file/db/thang.type/557f959ab43ce0b15a91b1dd/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-3', name: 'Big Rocks 3', original: '557f95e7b43ce0b15a91b1e1', portraitURL: '/file/db/thang.type/557f95e7b43ce0b15a91b1e1/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-4', name: 'Big Rocks 4', original: '557f9627b43ce0b15a91b1e5', portraitURL: '/file/db/thang.type/557f9627b43ce0b15a91b1e5/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-5', name: 'Big Rocks 5', original: '557f9661b43ce0b15a91b1e9', portraitURL: '/file/db/thang.type/557f9661b43ce0b15a91b1e9/portrait.png', kind: 'Doodad'} + {slug: 'bird', name: 'Bird', original: '53e2e31f6f406a3505b3eab0', portraitURL: '/file/db/thang.type/53e2e31f6f406a3505b3eab0/portrait.png', kind: 'Doodad'} + {slug: 'bloodhenge', name: 'Bloodhenge', original: '54f1168802196881056537df', portraitURL: '/file/db/thang.type/54f1168802196881056537df/portrait.png', kind: 'Doodad'} + {slug: 'blue-cart', name: 'Blue Cart', original: '5435d3207b554def1f99c49c', portraitURL: '/file/db/thang.type/5435d3207b554def1f99c49c/portrait.png', kind: 'Doodad'} + {slug: 'bluff-1', name: 'Bluff 1', original: '52afce51c5b1813ec200001a', portraitURL: '/file/db/thang.type/52afce51c5b1813ec200001a/portrait.png', kind: 'Doodad'} + {slug: 'bluff-2', name: 'Bluff 2', original: '52afcecbc5b1813ec200001c', portraitURL: '/file/db/thang.type/52afcecbc5b1813ec200001c/portrait.png', kind: 'Doodad'} + {slug: 'bolt', name: 'Bolt', original: '55c658a8a03e2014d693990a', portraitURL: '/file/db/thang.type/55c658a8a03e2014d693990a/portrait.png', kind: 'Missile'} + {slug: 'bolt-spitter', name: 'Bolt Spitter', original: '544d85d88494308424f564e4', portraitURL: '/file/db/thang.type/544d85d88494308424f564e4/portrait.png', kind: 'Item'} + {slug: 'boltsaw', name: 'Boltsaw', original: '544d6f5e8494308424f56476', portraitURL: '/file/db/thang.type/544d6f5e8494308424f56476/portrait.png', kind: 'Item'} + {slug: 'bone-dagger', name: 'Bone Dagger', original: '54eb4b2249fa2d5c905ddefe', portraitURL: '/file/db/thang.type/54eb4b2249fa2d5c905ddefe/portrait.png', kind: 'Item'} + {slug: 'book-of-life-i', name: 'Book of Life I', original: '546375653839c6e02811d30b', portraitURL: '/file/db/thang.type/546375653839c6e02811d30b/portrait.png', kind: 'Item'} + {slug: 'book-of-life-ii', name: 'Book of Life II', original: '546375813839c6e02811d30e', portraitURL: '/file/db/thang.type/546375813839c6e02811d30e/portrait.png', kind: 'Item'} + {slug: 'book-of-life-iii', name: 'Book of Life III', original: '546375a43839c6e02811d311', portraitURL: '/file/db/thang.type/546375a43839c6e02811d311/portrait.png', kind: 'Item'} + {slug: 'book-of-life-iv', name: 'Book of Life IV', original: '546376ca3839c6e02811d31d', portraitURL: '/file/db/thang.type/546376ca3839c6e02811d31d/portrait.png', kind: 'Item'} + {slug: 'book-of-life-v', name: 'Book of Life V', original: '546376ea3839c6e02811d320', portraitURL: '/file/db/thang.type/546376ea3839c6e02811d320/portrait.png', kind: 'Item'} + {slug: 'bookshelf', name: 'Bookshelf', original: '52e994ea427172ae56001fc9', portraitURL: '/file/db/thang.type/52e994ea427172ae56001fc9/portrait.png', kind: 'Doodad'} + {slug: 'bookshelf-2', name: 'Bookshelf 2', original: '54ef925a64112781056c18b5', portraitURL: '/file/db/thang.type/54ef925a64112781056c18b5/portrait.png', kind: 'Doodad'} + {slug: 'boom-ball-missile', name: 'Boom Ball Missile', original: '5535b5d4428ddac5686fcf82', portraitURL: '/file/db/thang.type/5535b5d4428ddac5686fcf82/portrait.png', kind: 'Missile'} + {slug: 'boomrod', name: 'Boomrod', original: '544d85898494308424f564df', portraitURL: '/file/db/thang.type/544d85898494308424f564df/portrait.png', kind: 'Item'} + {slug: 'boots-of-jumping', name: 'Boots of Jumping', original: '546d4e289df4a17d0d449ad5', portraitURL: '/file/db/thang.type/546d4e289df4a17d0d449ad5/portrait.png', kind: 'Item'} + {slug: 'boots-of-leaping', name: 'Boots of Leaping', original: '53e214f153457600003e3eab', portraitURL: '/file/db/thang.type/53e214f153457600003e3eab/portrait.png', kind: 'Item'} + {slug: 'boss-star-i', name: 'Boss Star I', original: '54eb58e449fa2d5c905ddf46', portraitURL: '/file/db/thang.type/54eb58e449fa2d5c905ddf46/portrait.png', kind: 'Item'} + {slug: 'boss-star-ii', name: 'Boss Star II', original: '54eb5bf649fa2d5c905ddf4a', portraitURL: '/file/db/thang.type/54eb5bf649fa2d5c905ddf4a/portrait.png', kind: 'Item'} + {slug: 'boss-star-iii', name: 'Boss Star III', original: '54eb5c8f49fa2d5c905ddf4e', portraitURL: '/file/db/thang.type/54eb5c8f49fa2d5c905ddf4e/portrait.png', kind: 'Item'} + {slug: 'boss-star-iv', name: 'Boss Star IV', original: '54eb5d1649fa2d5c905ddf52', portraitURL: '/file/db/thang.type/54eb5d1649fa2d5c905ddf52/portrait.png', kind: 'Item'} + {slug: 'boss-star-v', name: 'Boss Star V', original: '54eb5dbc49fa2d5c905ddf56', portraitURL: '/file/db/thang.type/54eb5dbc49fa2d5c905ddf56/portrait.png', kind: 'Item'} + {slug: 'boulder', name: 'Boulder', original: '544d86828494308424f564ec', portraitURL: '/file/db/thang.type/544d86828494308424f564ec/portrait.png', kind: 'Missile'} + {slug: 'boulder-trap', name: 'Boulder Trap', original: '55c246b1dfc8d0b576e60a23', portraitURL: '/file/db/thang.type/55c246b1dfc8d0b576e60a23/portrait.png', kind: 'Doodad'} + {slug: 'box', name: 'Box', original: '54d2b68a3e16915505f0bc8a', portraitURL: '/file/db/thang.type/54d2b68a3e16915505f0bc8a/portrait.png', kind: 'Doodad'} + {slug: 'box-2', name: 'Box 2', original: '54d2b797051a3a5305424c62', portraitURL: '/file/db/thang.type/54d2b797051a3a5305424c62/portrait.png', kind: 'Doodad'} + {slug: 'brawlwood', name: 'Brawlwood', original: '533b1f1642aef2202fdcc487', portraitURL: '/file/db/thang.type/533b1f1642aef2202fdcc487/portrait.png', kind: 'Floor'} + {slug: 'breakout-background', name: 'Breakout Background', original: '56c65f8b79735337006047df', portraitURL: '/file/db/thang.type/56c65f8b79735337006047df/portrait.png', kind: 'Floor'} + {slug: 'broken-tower', name: 'Broken Tower', original: '5376b2caff7b2d3805a396a9', portraitURL: '/file/db/thang.type/5376b2caff7b2d3805a396a9/portrait.png', kind: 'Doodad'} + {slug: 'bronze-coin', name: 'Bronze Coin', original: '535ef2d54f10444d08486ba8', portraitURL: '/file/db/thang.type/535ef2d54f10444d08486ba8/portrait.png', kind: 'Misc'} + {slug: 'bronze-shield', name: 'Bronze Shield', original: '544c310ae0017993fce214bf', portraitURL: '/file/db/thang.type/544c310ae0017993fce214bf/portrait.png', kind: 'Item'} + {slug: 'bullet', name: 'Bullet', original: '544d82bd8494308424f564d0', portraitURL: '/file/db/thang.type/544d82bd8494308424f564d0/portrait.png', kind: 'Missile'} + {slug: 'cabin-1', name: 'Cabin 1', original: '54e93b41970f0b0a263c0400', portraitURL: '/file/db/thang.type/54e93b41970f0b0a263c0400/portrait.png', kind: 'Doodad'} + {slug: 'cabin-2', name: 'Cabin 2', original: '54e93cb4970f0b0a263c0406', portraitURL: '/file/db/thang.type/54e93cb4970f0b0a263c0406/portrait.png', kind: 'Doodad'} + {slug: 'cabin-3', name: 'Cabin 3', original: '54e93d1cf54ef5794f354e7d', portraitURL: '/file/db/thang.type/54e93d1cf54ef5794f354e7d/portrait.png', kind: 'Doodad'} + {slug: 'cabin-4', name: 'Cabin 4', original: '54e93db7f54ef5794f354e83', portraitURL: '/file/db/thang.type/54e93db7f54ef5794f354e83/portrait.png', kind: 'Doodad'} + {slug: 'cabinet', name: 'Cabinet', original: '54ef9101c1f3bd7c0593f232', portraitURL: '/file/db/thang.type/54ef9101c1f3bd7c0593f232/portrait.png', kind: 'Doodad'} + {slug: 'cactus-1', name: 'Cactus 1', original: '546e24949df4a17d0d449bc5', portraitURL: '/file/db/thang.type/546e24949df4a17d0d449bc5/portrait.png', kind: 'Doodad'} + {slug: 'cactus-2', name: 'Cactus 2', original: '546e24039df4a17d0d449bb9', portraitURL: '/file/db/thang.type/546e24039df4a17d0d449bb9/portrait.png', kind: 'Doodad'} + {slug: 'caltrop-belt', name: 'Caltrop Belt', original: '54694af7a2b1f53ce7944441', portraitURL: '/file/db/thang.type/54694af7a2b1f53ce7944441/portrait.png', kind: 'Item'} + {slug: 'caltrops', name: 'Caltrops', original: '557f9700b43ce0b15a91b1ed', portraitURL: '/file/db/thang.type/557f9700b43ce0b15a91b1ed/portrait.png', kind: 'Doodad'} + {slug: 'camel', name: 'Camel', original: '548cf4cd0f559d0000be7e57', portraitURL: '/file/db/thang.type/548cf4cd0f559d0000be7e57/portrait.png', kind: 'Doodad'} + {slug: 'camp-fire', name: 'Camp Fire', original: '52e097c110012a5b250000b2', portraitURL: '/file/db/thang.type/52e097c110012a5b250000b2/portrait.png', kind: 'Doodad'} + {slug: 'campfire-stone', name: 'Campfire Stone', original: '54f118e125be5e880583759a', portraitURL: '/file/db/thang.type/54f118e125be5e880583759a/portrait.png', kind: 'Doodad'} + {slug: 'candle', name: 'Candle', original: '52e95fb222efc8e7090017d7', portraitURL: '/file/db/thang.type/52e95fb222efc8e7090017d7/portrait.png', kind: 'Doodad'} + {slug: 'carved-steel-ring', name: 'Carved Steel Ring', original: '54692dfaa2b1f53ce794439f', portraitURL: '/file/db/thang.type/54692dfaa2b1f53ce794439f/portrait.png', kind: 'Item'} + {slug: 'catapult', name: 'Catapult', original: '553e7ba29bdea5d00f1fd905', portraitURL: '/file/db/thang.type/553e7ba29bdea5d00f1fd905/portrait.png', kind: 'Unit'} + {slug: 'cave', name: 'Cave', original: '52e95983427172ae560018ce', portraitURL: '/file/db/thang.type/52e95983427172ae560018ce/portrait.png', kind: 'Doodad'} + {slug: 'chainmail-tunic', name: 'Chainmail Tunic', original: '5441c4dd4e9aeb727cc9713b', portraitURL: '/file/db/thang.type/5441c4dd4e9aeb727cc9713b/portrait.png', kind: 'Item'} + {slug: 'chains', name: 'Chains', original: '52aa602020fccb0000000004', portraitURL: '/file/db/thang.type/52aa602020fccb0000000004/portrait.png', kind: 'Doodad'} + {slug: 'chair', name: 'Chair', original: '52e9960e427172ae56001fdf', portraitURL: '/file/db/thang.type/52e9960e427172ae56001fdf/portrait.png', kind: 'Doodad'} + {slug: 'charge-belt', name: 'Charge Belt', original: '54694b27a2b1f53ce7944445', portraitURL: '/file/db/thang.type/54694b27a2b1f53ce7944445/portrait.png', kind: 'Item'} + {slug: 'choppable-tree-1', name: 'Choppable Tree 1', original: '52fbd1d67e01835453bd8a26', portraitURL: '/file/db/thang.type/52fbd1d67e01835453bd8a26/portrait.png', kind: 'Doodad'} + {slug: 'choppable-tree-2', name: 'Choppable Tree 2', original: '52fbd7e07e01835453bd8afc', portraitURL: '/file/db/thang.type/52fbd7e07e01835453bd8afc/portrait.png', kind: 'Doodad'} + {slug: 'choppable-tree-3', name: 'Choppable Tree 3', original: '52fbd9beab6e45c813bc79c6', portraitURL: '/file/db/thang.type/52fbd9beab6e45c813bc79c6/portrait.png', kind: 'Doodad'} + {slug: 'choppable-tree-4', name: 'Choppable Tree 4', original: '52fbdb747e01835453bd8b4a', portraitURL: '/file/db/thang.type/52fbdb747e01835453bd8b4a/portrait.png', kind: 'Doodad'} + {slug: 'circle-tree-stand-1', name: 'Circle Tree Stand 1', original: '541cb842c6362edfb0f3447d', portraitURL: '/file/db/thang.type/541cb842c6362edfb0f3447d/portrait.png', kind: 'Doodad'} + {slug: 'circle-tree-stand-2', name: 'Circle Tree Stand 2', original: '541cc5708e78524aad94de69', portraitURL: '/file/db/thang.type/541cc5708e78524aad94de69/portrait.png', kind: 'Doodad'} + {slug: 'circle-tree-stand-3', name: 'Circle Tree Stand 3', original: '541cc6898e78524aad94de6f', portraitURL: '/file/db/thang.type/541cc6898e78524aad94de6f/portrait.png', kind: 'Doodad'} + {slug: 'circlet-of-the-magi', name: 'Circlet of the Magi', original: '54ea39342b7506e891ca70f2', portraitURL: '/file/db/thang.type/54ea39342b7506e891ca70f2/portrait.png', kind: 'Item'} + {slug: 'classroom-bench', name: 'classroom bench', original: '56eb09520c6e9f1f00990e81', portraitURL: '/file/db/thang.type/56eb09520c6e9f1f00990e81/portrait.png', kind: 'Doodad'} + {slug: 'classroom-floor', name: 'Classroom Floor', original: '56a139f9d987c52900d4de5a', portraitURL: '/file/db/thang.type/56a139f9d987c52900d4de5a/portrait.png', kind: 'Floor'} + {slug: 'classroom-sculpture', name: 'Classroom Sculpture', original: '56a16510088f002400720564', portraitURL: '/file/db/thang.type/56a16510088f002400720564/portrait.png', kind: 'Doodad'} + {slug: 'classroom-students-desk', name: 'Classroom Students Desk', original: '56a15d88d987c52900d4ecdb', portraitURL: '/file/db/thang.type/56a15d88d987c52900d4ecdb/portrait.png', kind: 'Doodad'} + {slug: 'classroom-students-seat', name: 'Classroom Students Seat', original: '56a162348431922e0042fae3', portraitURL: '/file/db/thang.type/56a162348431922e0042fae3/portrait.png', kind: 'Doodad'} + {slug: 'classroom-viewscreen', name: 'Classroom Viewscreen', original: '569fdf3c6ff9591f000050bf', portraitURL: '/file/db/thang.type/569fdf3c6ff9591f000050bf/portrait.png', kind: 'Doodad'} + {slug: 'classroom-wall', name: 'Classroom Wall', original: '56a0150cf363ed1f0029e11c', portraitURL: '/file/db/thang.type/56a0150cf363ed1f0029e11c/portrait.png', kind: 'Wall'} + {slug: 'claymore', name: 'Claymore', original: '544d6d4a8494308424f56471', portraitURL: '/file/db/thang.type/544d6d4a8494308424f56471/portrait.png', kind: 'Item'} + {slug: 'cloud-1', name: 'Cloud 1', original: '550b42b7343675176d05a919', portraitURL: '/file/db/thang.type/550b42b7343675176d05a919/portrait.png', kind: 'Doodad'} + {slug: 'cloud-2', name: 'Cloud 2', original: '550b43fc343675176d05a923', portraitURL: '/file/db/thang.type/550b43fc343675176d05a923/portrait.png', kind: 'Doodad'} + {slug: 'cloud-3', name: 'Cloud 3', original: '550b4506343675176d05a933', portraitURL: '/file/db/thang.type/550b4506343675176d05a933/portrait.png', kind: 'Doodad'} + {slug: 'coin', name: 'Coin', original: '52aa3a8fccbd588d4d000001', portraitURL: '/file/db/thang.type/52aa3a8fccbd588d4d000001/portrait.png', kind: 'Misc'} + {slug: 'compound-boots', name: 'Compound Boots', original: '546d4d8e9df4a17d0d449acd', portraitURL: '/file/db/thang.type/546d4d8e9df4a17d0d449acd/portrait.png', kind: 'Item'} + {slug: 'cougar-pet', name: 'Cougar Pet', original: '540f3a33821af8000097dc62', portraitURL: '/file/db/thang.type/540f3a33821af8000097dc62/portrait.png', kind: 'Unit'} + {slug: 'crevasse-1', name: 'Crevasse 1', original: '5576080a1e82182d9e6888cd', portraitURL: '/file/db/thang.type/5576080a1e82182d9e6888cd/portrait.png', kind: 'Doodad'} + {slug: 'crevasse-2', name: 'Crevasse 2', original: '557630c31e82182d9e688921', portraitURL: '/file/db/thang.type/557630c31e82182d9e688921/portrait.png', kind: 'Doodad'} + {slug: 'crevasse-3', name: 'Crevasse 3', original: '557631321e82182d9e688925', portraitURL: '/file/db/thang.type/557631321e82182d9e688925/portrait.png', kind: 'Doodad'} + {slug: 'crisscross-back', name: 'Crisscross Back', original: '53b495e37e17883a05754216', portraitURL: '/file/db/thang.type/53b495e37e17883a05754216/portrait.png', kind: 'Floor'} + {slug: 'crisscross-front', name: 'Crisscross Front', original: '53b495b02082f23505b844e5', portraitURL: '/file/db/thang.type/53b495b02082f23505b844e5/portrait.png', kind: 'Floor'} + {slug: 'cross-bones-background', name: 'Cross Bones Background', original: '572e51175366918e018060e5', portraitURL: '/file/db/thang.type/572e51175366918e018060e5/portrait.png', kind: 'Floor'} + {slug: 'crossbeam-support', name: 'crossbeam support', original: '5786828a0d397a2e0026f274', portraitURL: '/file/db/thang.type/5786828a0d397a2e0026f274/portrait.png', kind: 'Doodad'} + {slug: 'crossbow', name: 'Crossbow', original: '53e21ae653457600003e3ec2', portraitURL: '/file/db/thang.type/53e21ae653457600003e3ec2/portrait.png', kind: 'Item'} + {slug: 'crude-builders-hammer', name: 'Crude Builder\'s Hammer', original: '53f4e6e3d822c23505b74f42', portraitURL: '/file/db/thang.type/53f4e6e3d822c23505b74f42/portrait.png', kind: 'Item'} + {slug: 'crude-crossbow', name: 'Crude Crossbow', original: '544d7ffd8494308424f564c3', portraitURL: '/file/db/thang.type/544d7ffd8494308424f564c3/portrait.png', kind: 'Item'} + {slug: 'crude-dagger', name: 'Crude Dagger', original: '544d952b8494308424f56517', portraitURL: '/file/db/thang.type/544d952b8494308424f56517/portrait.png', kind: 'Item'} + {slug: 'crude-dagger-missile', name: 'Crude Dagger Missile', original: '546e292d9df4a17d0d449c0c', portraitURL: '/file/db/thang.type/546e292d9df4a17d0d449c0c/portrait.png', kind: 'Missile'} + {slug: 'crude-glasses', name: 'Crude Glasses', original: '53e238df53457600003e3f0b', portraitURL: '/file/db/thang.type/53e238df53457600003e3f0b/portrait.png', kind: 'Item'} + {slug: 'crude-spike', name: 'Crude Spike', original: '544d79e28494308424f56482', portraitURL: '/file/db/thang.type/544d79e28494308424f56482/portrait.png', kind: 'Item'} + {slug: 'crude-telephoto-glasses', name: 'Crude Telephoto Glasses', original: '5469415aa2b1f53ce7944411', portraitURL: '/file/db/thang.type/5469415aa2b1f53ce7944411/portrait.png', kind: 'Item'} + {slug: 'crypt-key', name: 'Crypt Key', original: '54eb573549fa2d5c905ddf36', portraitURL: '/file/db/thang.type/54eb573549fa2d5c905ddf36/portrait.png', kind: 'Item'} + {slug: 'crystal-wand', name: 'Crystal Wand', original: '54eab63b2b7506e891ca71f2', portraitURL: '/file/db/thang.type/54eab63b2b7506e891ca71f2/portrait.png', kind: 'Item'} + {slug: 'cupboards-of-kgard-background', name: 'Cupboards of Kgard background', original: '56994ec3d32e4c1f0075460d', portraitURL: '/file/db/thang.type/56994ec3d32e4c1f0075460d/portrait.png', kind: 'Floor'} + {slug: 'curse', name: 'Curse', original: '53024d18a6efdd32359c5365', portraitURL: '/file/db/thang.type/53024d18a6efdd32359c5365/portrait.png', kind: 'Mark'} + {slug: 'cut-garnet-sense-stone', name: 'Cut Garnet Sense Stone', original: '546933a5a2b1f53ce79443d5', portraitURL: '/file/db/thang.type/546933a5a2b1f53ce79443d5/portrait.png', kind: 'Item'} + {slug: 'cut-stone-builders-hammer', name: 'Cut Stone Builder\'s Hammer', original: '54694c0ba2b1f53ce7944456', portraitURL: '/file/db/thang.type/54694c0ba2b1f53ce7944456/portrait.png', kind: 'Item'} + {slug: 'darksteel-blade', name: 'Darksteel Blade', original: '544d7f558494308424f564bb', portraitURL: '/file/db/thang.type/544d7f558494308424f564bb/portrait.png', kind: 'Item'} + {slug: 'deadeye-crossbow', name: 'Deadeye Crossbow', original: '54eaad752b7506e891ca71d1', portraitURL: '/file/db/thang.type/54eaad752b7506e891ca71d1/portrait.png', kind: 'Item'} + {slug: 'decoy', name: 'Decoy', original: '5498bb758e52573b10d3bce6', portraitURL: '/file/db/thang.type/5498bb758e52573b10d3bce6/portrait.png', kind: 'Unit'} + {slug: 'defensive-boots', name: 'Defensive Boots', original: '546d4e019df4a17d0d449ad1', portraitURL: '/file/db/thang.type/546d4e019df4a17d0d449ad1/portrait.png', kind: 'Item'} + {slug: 'defensive-infantry-shield', name: 'Defensive Infantry Shield', original: '544d7b408494308424f5648f', portraitURL: '/file/db/thang.type/544d7b408494308424f5648f/portrait.png', kind: 'Item'} + {slug: 'deflector', name: 'Deflector', original: '54eabff349fa2d5c905ddeee', portraitURL: '/file/db/thang.type/54eabff349fa2d5c905ddeee/portrait.png', kind: 'Item'} + {slug: 'derrick', name: 'Derrick', original: '546e24339df4a17d0d449bbd', portraitURL: '/file/db/thang.type/546e24339df4a17d0d449bbd/portrait.png', kind: 'Doodad'} + {slug: 'desert-bones-1', name: 'Desert Bones 1', original: '548cf0cc0f559d0000be7e27', portraitURL: '/file/db/thang.type/548cf0cc0f559d0000be7e27/portrait.png', kind: 'Doodad'} + {slug: 'desert-bones-3', name: 'Desert Bones 3', original: '548cf1630f559d0000be7e2f', portraitURL: '/file/db/thang.type/548cf1630f559d0000be7e2f/portrait.png', kind: 'Doodad'} + {slug: 'desert-green-1', name: 'Desert Green 1', original: '548cef670f559d0000be7e17', portraitURL: '/file/db/thang.type/548cef670f559d0000be7e17/portrait.png', kind: 'Doodad'} + {slug: 'desert-green-2', name: 'Desert Green 2', original: '548cefc50f559d0000be7e1b', portraitURL: '/file/db/thang.type/548cefc50f559d0000be7e1b/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-1', name: 'Desert House 1', original: '548cf35a0f559d0000be7e43', portraitURL: '/file/db/thang.type/548cf35a0f559d0000be7e43/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-2', name: 'Desert House 2', original: '548cf3ae0f559d0000be7e47', portraitURL: '/file/db/thang.type/548cf3ae0f559d0000be7e47/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-3', name: 'Desert House 3', original: '548cf4000f559d0000be7e4b', portraitURL: '/file/db/thang.type/548cf4000f559d0000be7e4b/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-4', name: 'Desert House 4', original: '548cf44c0f559d0000be7e4f', portraitURL: '/file/db/thang.type/548cf44c0f559d0000be7e4f/portrait.png', kind: 'Doodad'} + {slug: 'desert-palm-1', name: 'Desert Palm 1', original: '548cf0110f559d0000be7e1f', portraitURL: '/file/db/thang.type/548cf0110f559d0000be7e1f/portrait.png', kind: 'Doodad'} + {slug: 'desert-palm-2', name: 'Desert Palm 2', original: '548cf06f0f559d0000be7e23', portraitURL: '/file/db/thang.type/548cf06f0f559d0000be7e23/portrait.png', kind: 'Doodad'} + {slug: 'desert-pillar', name: 'Desert Pillar', original: '541c5ff487338f570851ad83', portraitURL: '/file/db/thang.type/541c5ff487338f570851ad83/portrait.png', kind: 'Doodad'} + {slug: 'desert-pyramid', name: 'Desert Pyramid', original: '53e239c253457600003e3f11', portraitURL: '/file/db/thang.type/53e239c253457600003e3f11/portrait.png', kind: 'Doodad'} + {slug: 'desert-rubble-1', name: 'Desert Rubble 1', original: '53126c48f5a594b00fbfcc42', portraitURL: '/file/db/thang.type/53126c48f5a594b00fbfcc42/portrait.png', kind: 'Doodad'} + {slug: 'desert-rubble-2', name: 'Desert Rubble 2', original: '52f01b0b5071878f7650e11a', portraitURL: '/file/db/thang.type/52f01b0b5071878f7650e11a/portrait.png', kind: 'Doodad'} + {slug: 'desert-rubble-3', name: 'Desert Rubble 3', original: '546e23a89df4a17d0d449bb1', portraitURL: '/file/db/thang.type/546e23a89df4a17d0d449bb1/portrait.png', kind: 'Doodad'} + {slug: 'desert-sand-rock', name: 'Desert Sand Rock', original: '55c64774ef141c65665beb84', portraitURL: '/file/db/thang.type/55c64774ef141c65665beb84/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-big-1', name: 'Desert Shrub Big 1', original: '546e237d9df4a17d0d449bad', portraitURL: '/file/db/thang.type/546e237d9df4a17d0d449bad/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-big-2', name: 'Desert Shrub Big 2', original: '546e22c59df4a17d0d449ba1', portraitURL: '/file/db/thang.type/546e22c59df4a17d0d449ba1/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-big-3', name: 'Desert Shrub Big 3', original: '53f4c776d822c23505b7091c', portraitURL: '/file/db/thang.type/53f4c776d822c23505b7091c/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-small-1', name: 'Desert Shrub Small 1', original: '548ceec80f559d0000be7e0f', portraitURL: '/file/db/thang.type/548ceec80f559d0000be7e0f/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-small-2', name: 'Desert Shrub Small 2', original: '548cef1f0f559d0000be7e13', portraitURL: '/file/db/thang.type/548cef1f0f559d0000be7e13/portrait.png', kind: 'Doodad'} + {slug: 'desert-skullcave', name: 'Desert Skullcave', original: '546e231c9df4a17d0d449ba5', portraitURL: '/file/db/thang.type/546e231c9df4a17d0d449ba5/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-1', name: 'Desert Wall 1', original: '5404fe5f1d10b2f170618ae9', portraitURL: '/file/db/thang.type/5404fe5f1d10b2f170618ae9/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-2', name: 'Desert Wall 2', original: '540100ba794c1a8b4d328437', portraitURL: '/file/db/thang.type/540100ba794c1a8b4d328437/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-3', name: 'Desert Wall 3', original: '53f4e7fff7bc7336054dcf64', portraitURL: '/file/db/thang.type/53f4e7fff7bc7336054dcf64/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-4', name: 'Desert Wall 4', original: '53f3ef04e7a7643005c0f4a1', portraitURL: '/file/db/thang.type/53f3ef04e7a7643005c0f4a1/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-5', name: 'Desert Wall 5', original: '53ebafdd1a100989a40ce479', portraitURL: '/file/db/thang.type/53ebafdd1a100989a40ce479/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-6', name: 'Desert Wall 6', original: '53eb989b1a100989a40ce46a', portraitURL: '/file/db/thang.type/53eb989b1a100989a40ce46a/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-7', name: 'Desert Wall 7', original: '53eaa7de786ccc3405a9f2a4', portraitURL: '/file/db/thang.type/53eaa7de786ccc3405a9f2a4/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-8', name: 'Desert Wall 8', original: '53eaa6f6ef27b33605514a64', portraitURL: '/file/db/thang.type/53eaa6f6ef27b33605514a64/portrait.png', kind: 'Doodad'} + {slug: 'desert-well', name: 'Desert Well', original: '548cf4880f559d0000be7e53', portraitURL: '/file/db/thang.type/548cf4880f559d0000be7e53/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-human-tower', name: 'destroyed human tower', original: '57867e5acca8994b002702a9', portraitURL: '/file/db/thang.type/57867e5acca8994b002702a9/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-human-tower-with-trees', name: 'destroyed human tower with trees', original: '572d5abed7787fc300d85964', portraitURL: '/file/db/thang.type/572d5abed7787fc300d85964/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-human-tower-with-trees-2', name: 'destroyed human tower with trees 2', original: '572d5b42d7787fc300d8596f', portraitURL: '/file/db/thang.type/572d5b42d7787fc300d8596f/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-ogre-tower-footing', name: 'destroyed ogre tower footing', original: '578680980d397a2e0026eff9', portraitURL: '/file/db/thang.type/578680980d397a2e0026eff9/portrait.png', kind: 'Doodad'} + {slug: 'diamond-sense-stone', name: 'Diamond Sense Stone', original: '546934b7a2b1f53ce79443e1', portraitURL: '/file/db/thang.type/546934b7a2b1f53ce79443e1/portrait.png', kind: 'Item'} + {slug: 'dirt-path-1', name: 'Dirt Path 1', original: '5302acfd27471514685d5fd4', portraitURL: '/file/db/thang.type/5302acfd27471514685d5fd4/portrait.png', kind: 'Floor'} + {slug: 'disintegrate', name: 'Disintegrate', original: '54d2bb1abb157252059b1d29', portraitURL: '/file/db/thang.type/54d2bb1abb157252059b1d29/portrait.png', kind: 'Mark'} + {slug: 'dispel', name: 'Dispel', original: '55c2807d3767fd3435eb4465', portraitURL: '/file/db/thang.type/55c2807d3767fd3435eb4465/portrait.png', kind: 'Mark'} + {slug: 'dragonscale-chainmail-coif', name: 'Dragonscale Chainmail Coif', original: '546d477d9df4a17d0d449a6b', portraitURL: '/file/db/thang.type/546d477d9df4a17d0d449a6b/portrait.png', kind: 'Item'} + {slug: 'dragonscale-chainmail-tunic', name: 'Dragonscale Chainmail Tunic', original: '546d3d149df4a17d0d449a43', portraitURL: '/file/db/thang.type/546d3d149df4a17d0d449a43/portrait.png', kind: 'Item'} + {slug: 'dragontooth', name: 'Dragontooth', original: '54eb51d349fa2d5c905ddf0e', portraitURL: '/file/db/thang.type/54eb51d349fa2d5c905ddf0e/portrait.png', kind: 'Item'} + {slug: 'drain-life', name: 'Drain Life', original: '54d2bc5b4e4a08550556da55', portraitURL: '/file/db/thang.type/54d2bc5b4e4a08550556da55/portrait.png', kind: 'Mark'} + {slug: 'dread-door-background', name: 'Dread Door Background', original: '572e46a3f8c4f9b601ede6c0', portraitURL: '/file/db/thang.type/572e46a3f8c4f9b601ede6c0/portrait.png', kind: 'Floor'} + {slug: 'dueling-grounds-background', name: 'Dueling Grounds Background', original: '572e5163e8db5195014848b3', portraitURL: '/file/db/thang.type/572e5163e8db5195014848b3/portrait.png', kind: 'Floor'} + {slug: 'dunes', name: 'Dunes', original: '546e251d9df4a17d0d449bd1', portraitURL: '/file/db/thang.type/546e251d9df4a17d0d449bd1/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-door', name: 'Dungeon Door', original: '52a0e5123abf480000000001', portraitURL: '/file/db/thang.type/52a0e5123abf480000000001/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-entrance', name: 'Dungeon Entrance', original: '544d850e8494308424f564dd', portraitURL: '/file/db/thang.type/544d850e8494308424f564dd/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-floor', name: 'Dungeon Floor', original: '52af688f6320a8049d000001', portraitURL: '/file/db/thang.type/52af688f6320a8049d000001/portrait.png', kind: 'Floor'} + {slug: 'dungeon-pillar', name: 'Dungeon Pillar', original: '543ea0ff9692aa00006208e7', portraitURL: '/file/db/thang.type/543ea0ff9692aa00006208e7/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-pit', name: 'Dungeon Pit', original: '52b09408ccbc671372000002', portraitURL: '/file/db/thang.type/52b09408ccbc671372000002/portrait.png', kind: 'Floor'} + {slug: 'dungeon-rock-1', name: 'Dungeon Rock 1', original: '54ef944764112781056c1f96', portraitURL: '/file/db/thang.type/54ef944764112781056c1f96/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-2', name: 'Dungeon Rock 2', original: '54ef99bf223edd8105b00eaa', portraitURL: '/file/db/thang.type/54ef99bf223edd8105b00eaa/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-3', name: 'Dungeon Rock 3', original: '54ef9af5b4740779058448c6', portraitURL: '/file/db/thang.type/54ef9af5b4740779058448c6/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-4', name: 'Dungeon Rock 4', original: '54ef9c26933e1e7b0584663e', portraitURL: '/file/db/thang.type/54ef9c26933e1e7b0584663e/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-5', name: 'Dungeon Rock 5', original: '54ef9d376aea7d7805535cc8', portraitURL: '/file/db/thang.type/54ef9d376aea7d7805535cc8/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-group', name: 'Dungeon Rock Group', original: '54ef9e0583b08b7d054ba331', portraitURL: '/file/db/thang.type/54ef9e0583b08b7d054ba331/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-stairs-horizontal', name: 'Dungeon Stairs Horizontal', original: '5463dc27c295cc4fb9c06257', portraitURL: '/file/db/thang.type/5463dc27c295cc4fb9c06257/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-stairs-vertical', name: 'Dungeon Stairs Vertical', original: '5463d8a0c295cc4fb9c06255', portraitURL: '/file/db/thang.type/5463d8a0c295cc4fb9c06255/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-wall', name: 'Dungeon Wall', original: '529e7aecc423d4e83b000004', portraitURL: '/file/db/thang.type/529e7aecc423d4e83b000004/portrait.png', kind: 'Wall'} + {slug: 'dungeons-of-kgard-background', name: 'Dungeons of Kgard Background', original: '563d3c02f5b71e8405fabff8', portraitURL: '/file/db/thang.type/563d3c02f5b71e8405fabff8/portrait.png', kind: 'Floor'} + {slug: 'dynamic-flags', name: 'Dynamic Flags', original: '5478b9068707a2c3a2493b2b', portraitURL: '/file/db/thang.type/5478b9068707a2c3a2493b2b/portrait.png', kind: 'Item'} + {slug: 'earthskin', name: 'Earthskin', original: '54d2bcf66ec7cf53051e7855', portraitURL: '/file/db/thang.type/54d2bcf66ec7cf53051e7855/portrait.png', kind: 'Mark'} + {slug: 'east-mounted-camera-facing-east-west', name: 'east mounted camera facing east west', original: '56f183091e1daf0a016c670b', portraitURL: '/file/db/thang.type/56f183091e1daf0a016c670b/portrait.png', kind: 'Doodad'} + {slug: 'east-mounted-camera-facing-north', name: 'east mounted camera facing north', original: '56f1782541c1a0cb00f8d66c', portraitURL: '/file/db/thang.type/56f1782541c1a0cb00f8d66c/portrait.png', kind: 'Doodad'} + {slug: 'east-mounted-camera-facing-south', name: 'east mounted camera facing south', original: '56f1811841c1a0cb00f8ddb1', portraitURL: '/file/db/thang.type/56f1811841c1a0cb00f8ddb1/portrait.png', kind: 'Doodad'} + {slug: 'edge-of-darkness', name: 'Edge of Darkness', original: '54eaa8762b7506e891ca71a9', portraitURL: '/file/db/thang.type/54eaa8762b7506e891ca71a9/portrait.png', kind: 'Item'} + {slug: 'eldritch-icicle', name: 'Eldritch Icicle', original: '54ea311e2b7506e891ca70b0', portraitURL: '/file/db/thang.type/54ea311e2b7506e891ca70b0/portrait.png', kind: 'Item'} + {slug: 'electrocute', name: 'Electrocute', original: '55c281263767fd3435eb4469', portraitURL: '/file/db/thang.type/55c281263767fd3435eb4469/portrait.png', kind: 'Mark'} + {slug: 'electrowall', name: 'Electrowall', original: '54177e26571f116c0b1f00c0', portraitURL: '/file/db/thang.type/54177e26571f116c0b1f00c0/portrait.png', kind: 'Doodad'} + {slug: 'elemental-codex-i', name: 'Elemental Codex I', original: '5463755a3839c6e02811d30a', portraitURL: '/file/db/thang.type/5463755a3839c6e02811d30a/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-ii', name: 'Elemental Codex II', original: '546375783839c6e02811d30d', portraitURL: '/file/db/thang.type/546375783839c6e02811d30d/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-iii', name: 'Elemental Codex III', original: '5463759c3839c6e02811d310', portraitURL: '/file/db/thang.type/5463759c3839c6e02811d310/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-iv', name: 'Elemental Codex IV', original: '546376bf3839c6e02811d31c', portraitURL: '/file/db/thang.type/546376bf3839c6e02811d31c/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-v', name: 'Elemental Codex V', original: '546376e23839c6e02811d31f', portraitURL: '/file/db/thang.type/546376e23839c6e02811d31f/portrait.png', kind: 'Item'} + {slug: 'embroidered-griffin-wool-hat', name: 'Embroidered Griffin Wool Hat', original: '546d4ca19df4a17d0d449abf', portraitURL: '/file/db/thang.type/546d4ca19df4a17d0d449abf/portrait.png', kind: 'Item'} + {slug: 'embroidered-griffin-wool-robe', name: 'Embroidered Griffin Wool Robe', original: '546d4a549df4a17d0d449a97', portraitURL: '/file/db/thang.type/546d4a549df4a17d0d449a97/portrait.png', kind: 'Item'} + {slug: 'emerald-chainmail-coif', name: 'Emerald Chainmail Coif', original: '546d46cf9df4a17d0d449a63', portraitURL: '/file/db/thang.type/546d46cf9df4a17d0d449a63/portrait.png', kind: 'Item'} + {slug: 'emerald-chainmail-tunic', name: 'Emerald Chainmail Tunic', original: '546d3c8d9df4a17d0d449a3b', portraitURL: '/file/db/thang.type/546d3c8d9df4a17d0d449a3b/portrait.png', kind: 'Item'} + {slug: 'emperors-gloves', name: 'Emperor\'s Gloves', original: '546949aca2b1f53ce7944431', portraitURL: '/file/db/thang.type/546949aca2b1f53ce7944431/portrait.png', kind: 'Item'} + {slug: 'enameled-dragonplate', name: 'Enameled Dragonplate', original: '546ab1e53777d61863292876', portraitURL: '/file/db/thang.type/546ab1e53777d61863292876/portrait.png', kind: 'Item'} + {slug: 'enameled-dragonplate-helmet', name: 'Enameled Dragonplate Helmet', original: '546d3a539df4a17d0d449a1f', portraitURL: '/file/db/thang.type/546d3a539df4a17d0d449a1f/portrait.png', kind: 'Item'} + {slug: 'enameled-dragonshield', name: 'Enameled Dragonshield', original: '54eabf022b7506e891ca7236', portraitURL: '/file/db/thang.type/54eabf022b7506e891ca7236/portrait.png', kind: 'Item'} + {slug: 'enchanted-lambswool-cloak', name: 'Enchanted Lambswool Cloak', original: '546d49109df4a17d0d449a7f', portraitURL: '/file/db/thang.type/546d49109df4a17d0d449a7f/portrait.png', kind: 'Item'} + {slug: 'enchanted-lenses', name: 'Enchanted Lenses', original: '546941cba2b1f53ce7944419', portraitURL: '/file/db/thang.type/546941cba2b1f53ce7944419/portrait.png', kind: 'Item'} + {slug: 'enchanted-stick', name: 'Enchanted Stick', original: '544d87188494308424f564f1', portraitURL: '/file/db/thang.type/544d87188494308424f564f1/portrait.png', kind: 'Item'} + {slug: 'enemy-mine-background', name: 'Enemy Mine Background', original: '563cdd340b2c7c87054e102b', portraitURL: '/file/db/thang.type/563cdd340b2c7c87054e102b/portrait.png', kind: 'Floor'} + {slug: 'energy-ball', name: 'Energy Ball', original: '53025d83222f73867774d8ed', portraitURL: '/file/db/thang.type/53025d83222f73867774d8ed/portrait.png', kind: 'Missile'} + {slug: 'energy-ball-diet', name: 'Energy Ball Diet', original: '531a6ddf1ddc910545d5e96d', portraitURL: '/file/db/thang.type/531a6ddf1ddc910545d5e96d/portrait.png', kind: 'Missile'} + {slug: 'enforcer', name: 'Enforcer', original: '56d0ca1263103d2a00af5331', portraitURL: '/file/db/thang.type/56d0ca1263103d2a00af5331/portrait.png', kind: 'Unit'} + {slug: 'engraved-builders-hammer', name: 'Engraved Builder\'s Hammer', original: '54694ca7a2b1f53ce7944462', portraitURL: '/file/db/thang.type/54694ca7a2b1f53ce7944462/portrait.png', kind: 'Item'} + {slug: 'engraved-obsidian-breastplate', name: 'Engraved Obsidian Breastplate', original: '546ab15e3777d6186329286e', portraitURL: '/file/db/thang.type/546ab15e3777d6186329286e/portrait.png', kind: 'Item'} + {slug: 'engraved-obsidian-helmet', name: 'Engraved Obsidian Helmet', original: '546d39d89df4a17d0d449a17', portraitURL: '/file/db/thang.type/546d39d89df4a17d0d449a17/portrait.png', kind: 'Item'} + {slug: 'engraved-obsidian-shield', name: 'Engraved Obsidian Shield', original: '54eabbd22b7506e891ca721e', portraitURL: '/file/db/thang.type/54eabbd22b7506e891ca721e/portrait.png', kind: 'Item'} + {slug: 'engraved-wristwatch', name: 'Engraved Wristwatch', original: '546937dea2b1f53ce79443ed', portraitURL: '/file/db/thang.type/546937dea2b1f53ce79443ed/portrait.png', kind: 'Item'} + {slug: 'explosive-potion', name: 'Explosive Potion', original: '5466d9a5417c8b48a9811e8e', portraitURL: '/file/db/thang.type/5466d9a5417c8b48a9811e8e/portrait.png', kind: 'Missile'} + {slug: 'ezeroths-timepiece', name: 'Ezeroth\'s Timepiece', original: '546938cea2b1f53ce79443f5', portraitURL: '/file/db/thang.type/546938cea2b1f53ce79443f5/portrait.png', kind: 'Item'} + {slug: 'farm', name: 'Farm', original: '52ea853d427172ae56003494', portraitURL: '/file/db/thang.type/52ea853d427172ae56003494/portrait.png', kind: 'Doodad'} + {slug: 'faux-fur-hat', name: 'Faux Fur Hat', original: '5441c2be4e9aeb727cc97105', portraitURL: '/file/db/thang.type/5441c2be4e9aeb727cc97105/portrait.png', kind: 'Item'} + {slug: 'fear', name: 'Fear', original: '53024db827471514685d53b2', portraitURL: '/file/db/thang.type/53024db827471514685d53b2/portrait.png', kind: 'Mark'} + {slug: 'fence', name: 'Fence', original: '5421bc5218adb78d98d265e8', portraitURL: '/file/db/thang.type/5421bc5218adb78d98d265e8/portrait.png', kind: 'Doodad'} + {slug: 'fence-wall', name: 'Fence Wall', original: '54349179a4cc5c900efa4814', portraitURL: '/file/db/thang.type/54349179a4cc5c900efa4814/portrait.png', kind: 'Doodad'} + {slug: 'filing-cabinet', name: 'Filing Cabinet', original: '52e9fa73427172ae56002593', portraitURL: '/file/db/thang.type/52e9fa73427172ae56002593/portrait.png', kind: 'Doodad'} + {slug: 'fine-boots', name: 'Fine Boots', original: '53e2388e53457600003e3f09', portraitURL: '/file/db/thang.type/53e2388e53457600003e3f09/portrait.png', kind: 'Item'} + {slug: 'fine-leather-chainmail-coif', name: 'Fine Leather Chainmail Coif', original: '546d455f9df4a17d0d449a4f', portraitURL: '/file/db/thang.type/546d455f9df4a17d0d449a4f/portrait.png', kind: 'Item'} + {slug: 'fine-leather-chainmail-tunic', name: 'Fine Leather Chainmail Tunic', original: '546d3b129df4a17d0d449a27', portraitURL: '/file/db/thang.type/546d3b129df4a17d0d449a27/portrait.png', kind: 'Item'} + {slug: 'fine-stone-builders-hammer', name: 'Fine Stone Builder\'s Hammer', original: '54694c44a2b1f53ce794445a', portraitURL: '/file/db/thang.type/54694c44a2b1f53ce794445a/portrait.png', kind: 'Item'} + {slug: 'fine-telephoto-glasses', name: 'Fine Telephoto Glasses', original: '54694194a2b1f53ce7944415', portraitURL: '/file/db/thang.type/54694194a2b1f53ce7944415/portrait.png', kind: 'Item'} + {slug: 'fine-wooden-glasses', name: 'Fine Wooden Glasses', original: '5469405ba2b1f53ce7944404', portraitURL: '/file/db/thang.type/5469405ba2b1f53ce7944404/portrait.png', kind: 'Item'} + {slug: 'fir-tree-1', name: 'Fir Tree 1', original: '54e9503df54ef5794f354ec1', portraitURL: '/file/db/thang.type/54e9503df54ef5794f354ec1/portrait.png', kind: 'Doodad'} + {slug: 'fir-tree-2', name: 'Fir Tree 2', original: '54e95107f54ef5794f354ec5', portraitURL: '/file/db/thang.type/54e95107f54ef5794f354ec5/portrait.png', kind: 'Doodad'} + {slug: 'fir-tree-3', name: 'Fir Tree 3', original: '54e9513ff54ef5794f354ec9', portraitURL: '/file/db/thang.type/54e9513ff54ef5794f354ec9/portrait.png', kind: 'Doodad'} + {slug: 'fir-tree-4', name: 'Fir Tree 4', original: '54e9518df54ef5794f354ecd', portraitURL: '/file/db/thang.type/54e9518df54ef5794f354ecd/portrait.png', kind: 'Doodad'} + {slug: 'fire', name: 'Fire', original: '54d2bdea4e4a08550556dbfe', portraitURL: '/file/db/thang.type/54d2bdea4e4a08550556dbfe/portrait.png', kind: 'Mark'} + {slug: 'fire-dancing-background', name: 'Fire Dancing Background', original: '576ad6ab7e64f325002df2e4', portraitURL: '/file/db/thang.type/576ad6ab7e64f325002df2e4/portrait.png', kind: 'Floor'} + {slug: 'fire-opal-sense-stone', name: 'Fire Opal Sense Stone', original: '546932e4a2b1f53ce79443cd', portraitURL: '/file/db/thang.type/546932e4a2b1f53ce79443cd/portrait.png', kind: 'Item'} + {slug: 'fire-trap', name: 'Fire Trap', original: '5449536afb56d566e86972ba', portraitURL: '/file/db/thang.type/5449536afb56d566e86972ba/portrait.png', kind: 'Misc'} + {slug: 'fireball', name: 'Fireball', original: '531a6a2f1ddc910545d5e944', portraitURL: '/file/db/thang.type/531a6a2f1ddc910545d5e944/portrait.png', kind: 'Missile'} + {slug: 'firewall', name: 'firewall', original: '56f56687db0216900f086ac1', portraitURL: '/file/db/thang.type/56f56687db0216900f086ac1/portrait.png', kind: 'Doodad'} + {slug: 'firewood-1', name: 'Firewood 1', original: '52e953d0427172ae5600181d', portraitURL: '/file/db/thang.type/52e953d0427172ae5600181d/portrait.png', kind: 'Doodad'} + {slug: 'firewood-2', name: 'Firewood 2', original: '52e9575d22efc8e7090016ed', portraitURL: '/file/db/thang.type/52e9575d22efc8e7090016ed/portrait.png', kind: 'Doodad'} + {slug: 'firewood-3', name: 'Firewood 3', original: '52e957ec22efc8e7090016fd', portraitURL: '/file/db/thang.type/52e957ec22efc8e7090016fd/portrait.png', kind: 'Doodad'} + {slug: 'firn-1', name: 'Firn 1', original: '557639fa1e82182d9e68894d', portraitURL: '/file/db/thang.type/557639fa1e82182d9e68894d/portrait.png', kind: 'Floor'} + {slug: 'firn-2', name: 'Firn 2', original: '55763a971e82182d9e688951', portraitURL: '/file/db/thang.type/55763a971e82182d9e688951/portrait.png', kind: 'Floor'} + {slug: 'firn-3', name: 'Firn 3', original: '55763ab51e82182d9e688955', portraitURL: '/file/db/thang.type/55763ab51e82182d9e688955/portrait.png', kind: 'Floor'} + {slug: 'firn-4', name: 'Firn 4', original: '55763ad11e82182d9e688959', portraitURL: '/file/db/thang.type/55763ad11e82182d9e688959/portrait.png', kind: 'Floor'} + {slug: 'firn-5', name: 'Firn 5', original: '55763aea1e82182d9e68895d', portraitURL: '/file/db/thang.type/55763aea1e82182d9e68895d/portrait.png', kind: 'Floor'} + {slug: 'firn-6', name: 'Firn 6', original: '55763b111e82182d9e688961', portraitURL: '/file/db/thang.type/55763b111e82182d9e688961/portrait.png', kind: 'Floor'} + {slug: 'firn-cliff', name: 'Firn Cliff', original: '55c277983767fd3435eb444e', portraitURL: '/file/db/thang.type/55c277983767fd3435eb444e/portrait.png', kind: 'Floor'} + {slug: 'flame-armor', name: 'Flame Armor', original: '55c27b5c3767fd3435eb445a', portraitURL: '/file/db/thang.type/55c27b5c3767fd3435eb445a/portrait.png', kind: 'Mark'} + {slug: 'flaming-shell', name: 'Flaming Shell', original: '553e80669bdea5d00f1fd90e', portraitURL: '/file/db/thang.type/553e80669bdea5d00f1fd90e/portrait.png', kind: 'Missile'} + {slug: 'flippable-land', name: 'Flippable Land', original: '53a20126610a6b3505568163', portraitURL: '/file/db/thang.type/53a20126610a6b3505568163/portrait.png', kind: 'Floor'} + {slug: 'floppy-lambswool-hat', name: 'Floppy Lambswool Hat', original: '546d4b069df4a17d0d449aa3', portraitURL: '/file/db/thang.type/546d4b069df4a17d0d449aa3/portrait.png', kind: 'Item'} + {slug: 'force-bolt', name: 'Force Bolt', original: '5467807c417c8b48a9811efd', portraitURL: '/file/db/thang.type/5467807c417c8b48a9811efd/portrait.png', kind: 'Missile'} + {slug: 'forest-river-tile-deadend', name: 'Forest River tile deadend', original: '577d5b367e0491260074b95b', portraitURL: '/file/db/thang.type/577d5b367e0491260074b95b/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-full-intersection', name: 'forest river tile full intersection', original: '5786badaa6c6413500926209', portraitURL: '/file/db/thang.type/5786badaa6c6413500926209/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-straight', name: 'forest river tile straight', original: '577d58d5dbf35b24001b91cb', portraitURL: '/file/db/thang.type/577d58d5dbf35b24001b91cb/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-t-intersection', name: 'Forest River tile t intersection', original: '577d5b927e0491260074ba3a', portraitURL: '/file/db/thang.type/577d5b927e0491260074ba3a/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-turn', name: 'Forest River tile turn', original: '577d59e37e0491260074b5bd', portraitURL: '/file/db/thang.type/577d59e37e0491260074b5bd/portrait.png', kind: 'undefined'} + {slug: 'forgetful-gemsmith-background', name: 'Forgetful Gemsmith Background', original: '562a9b9ea4cdd48805fb98ca', portraitURL: '/file/db/thang.type/562a9b9ea4cdd48805fb98ca/portrait.png', kind: 'Floor'} + {slug: 'forgotten-bronze-ring', name: 'Forgotten Bronze Ring', original: '54692d8aa2b1f53ce7944397', portraitURL: '/file/db/thang.type/54692d8aa2b1f53ce7944397/portrait.png', kind: 'Item'} + {slug: 'frog', name: 'Frog', original: '57869cf7bd31c14400834028', portraitURL: '/file/db/thang.type/57869cf7bd31c14400834028/portrait.png', kind: 'Item'} + {slug: 'frog-pet', name: 'Frog Pet', original: '540f3678821af8000097dc56', portraitURL: '/file/db/thang.type/540f3678821af8000097dc56/portrait.png', kind: 'Unit'} + {slug: 'gargoyle', name: 'Gargoyle', original: '52afc8f0c5b1813ec2000008', portraitURL: '/file/db/thang.type/52afc8f0c5b1813ec2000008/portrait.png', kind: 'Doodad'} + {slug: 'gargoyle-side', name: 'Gargoyle Side', original: '54efa07f4bb4788505d2339e', portraitURL: '/file/db/thang.type/54efa07f4bb4788505d2339e/portrait.png', kind: 'Doodad'} + {slug: 'gauntlets-of-strength', name: 'Gauntlets of Strength', original: '53e2202953457600003e3ed9', portraitURL: '/file/db/thang.type/53e2202953457600003e3ed9/portrait.png', kind: 'Item'} + {slug: 'gem-pile-medium', name: 'Gem Pile Medium', original: '543306638364d30000d1f951', portraitURL: '/file/db/thang.type/543306638364d30000d1f951/portrait.png', kind: 'Misc'} + {slug: 'gem-pile-small', name: 'Gem Pile Small', original: '543305f78364d30000d1f94a', portraitURL: '/file/db/thang.type/543305f78364d30000d1f94a/portrait.png', kind: 'Misc'} + {slug: 'gems-of-the-deep-background', name: 'Gems of the Deep Background', original: '563aa55276289f86054a7c02', portraitURL: '/file/db/thang.type/563aa55276289f86054a7c02/portrait.png', kind: 'Floor'} + {slug: 'generic-armor-mark-1', name: 'Generic Armor Mark 1', original: '54d2be25bb157252059b2202', portraitURL: '/file/db/thang.type/54d2be25bb157252059b2202/portrait.png', kind: 'Mark'} + {slug: 'generic-armor-mark-2', name: 'Generic Armor Mark 2', original: '54d2be9e3e16915505f0c7a4', portraitURL: '/file/db/thang.type/54d2be9e3e16915505f0c7a4/portrait.png', kind: 'Mark'} + {slug: 'generic-item', name: 'Generic Item', original: '545d3eb52d03e700001b5a5b', portraitURL: '/file/db/thang.type/545d3eb52d03e700001b5a5b/portrait.png', kind: 'Item'} + {slug: 'gift-of-the-trees', name: 'Gift of the Trees', original: '54eab0a32b7506e891ca71dd', portraitURL: '/file/db/thang.type/54eab0a32b7506e891ca71dd/portrait.png', kind: 'Item'} + {slug: 'gilt-wristwatch', name: 'Gilt Wristwatch', original: '54693830a2b1f53ce79443f1', portraitURL: '/file/db/thang.type/54693830a2b1f53ce79443f1/portrait.png', kind: 'Item'} + {slug: 'glasses-doodad', name: 'Glasses Doodad', original: '5420c4c5a0feb36ad21d45e2', portraitURL: '/file/db/thang.type/5420c4c5a0feb36ad21d45e2/portrait.png', kind: 'Item'} + {slug: 'glitterbomb', name: 'Glitterbomb', original: '54eb50f649fa2d5c905ddf0a', portraitURL: '/file/db/thang.type/54eb50f649fa2d5c905ddf0a/portrait.png', kind: 'Item'} + {slug: 'goal-trigger', name: 'Goal Trigger', original: '52bcbf0dce43b70000000006', portraitURL: '/file/db/thang.type/52bcbf0dce43b70000000006/portrait.png', kind: 'Misc'} + {slug: 'gold-ball', name: 'Gold Ball', original: '550b742b8a7d3c197a824dad', portraitURL: '/file/db/thang.type/550b742b8a7d3c197a824dad/portrait.png', kind: 'Missile'} + {slug: 'gold-cloud', name: 'Gold Cloud', original: '550b4b9d8a7d3c197a824d5e', portraitURL: '/file/db/thang.type/550b4b9d8a7d3c197a824d5e/portrait.png', kind: 'Doodad'} + {slug: 'golden-wand', name: 'Golden Wand', original: '54eab7f52b7506e891ca7202', portraitURL: '/file/db/thang.type/54eab7f52b7506e891ca7202/portrait.png', kind: 'Item'} + {slug: 'goldspun-silk-cloak', name: 'Goldspun Silk Cloak', original: '546d49da9df4a17d0d449a8f', portraitURL: '/file/db/thang.type/546d49da9df4a17d0d449a8f/portrait.png', kind: 'Item'} + {slug: 'goldspun-silk-hat', name: 'Goldspun Silk Hat', original: '546d4c249df4a17d0d449ab7', portraitURL: '/file/db/thang.type/546d4c249df4a17d0d449ab7/portrait.png', kind: 'Item'} + {slug: 'grass-cliffs', name: 'Grass Cliffs', original: '52bcb96ece43b70000000003', portraitURL: '/file/db/thang.type/52bcb96ece43b70000000003/portrait.png', kind: 'Floor'} + {slug: 'grass01', name: 'Grass01', original: '53016dddd82649ec2c0c9b29', portraitURL: '/file/db/thang.type/53016dddd82649ec2c0c9b29/portrait.png', kind: 'Floor'} + {slug: 'grass02', name: 'Grass02', original: '53016fc098f2ca1f6e82eebd', portraitURL: '/file/db/thang.type/53016fc098f2ca1f6e82eebd/portrait.png', kind: 'Floor'} + {slug: 'grass03', name: 'Grass03', original: '5301702d98f2ca1f6e82eec4', portraitURL: '/file/db/thang.type/5301702d98f2ca1f6e82eec4/portrait.png', kind: 'Floor'} + {slug: 'grass04', name: 'Grass04', original: '530170a198f2ca1f6e82eecf', portraitURL: '/file/db/thang.type/530170a198f2ca1f6e82eecf/portrait.png', kind: 'Floor'} + {slug: 'grass05', name: 'Grass05', original: '5301716398f2ca1f6e82eedc', portraitURL: '/file/db/thang.type/5301716398f2ca1f6e82eedc/portrait.png', kind: 'Floor'} + {slug: 'gravestone-cross', name: 'Gravestone Cross', original: '54f1100e8d380d7f05acc975', portraitURL: '/file/db/thang.type/54f1100e8d380d7f05acc975/portrait.png', kind: 'Doodad'} + {slug: 'gravestone-rounded', name: 'Gravestone Rounded', original: '54f110e3f854c97a05551616', portraitURL: '/file/db/thang.type/54f110e3f854c97a05551616/portrait.png', kind: 'Doodad'} + {slug: 'gravestone-square', name: 'Gravestone Square', original: '54f10f08d2969f8405ef51fd', portraitURL: '/file/db/thang.type/54f10f08d2969f8405ef51fd/portrait.png', kind: 'Doodad'} + {slug: 'graveyard-fence', name: 'Graveyard Fence', original: '54f111b379054c8705757747', portraitURL: '/file/db/thang.type/54f111b379054c8705757747/portrait.png', kind: 'Doodad'} + {slug: 'great-sword', name: 'Great Sword', original: '544d7f8d8494308424f564bf', portraitURL: '/file/db/thang.type/544d7f8d8494308424f564bf/portrait.png', kind: 'Item'} + {slug: 'greed-background', name: 'Greed Background', original: '53764e4ea7b5ab3805f153a4', portraitURL: '/file/db/thang.type/53764e4ea7b5ab3805f153a4/portrait.png', kind: 'Floor'} + {slug: 'green-bubble-missile', name: 'Green Bubble Missile', original: '540e35a34f21cd879ba4f140', portraitURL: '/file/db/thang.type/540e35a34f21cd879ba4f140/portrait.png', kind: 'Missile'} + {slug: 'griffin-rider', name: 'Griffin Rider', original: '52d45d1ab10ae4b024000002', portraitURL: '/file/db/thang.type/52d45d1ab10ae4b024000002/portrait.png', kind: 'Unit'} + {slug: 'griffin-wool-hat', name: 'Griffin Wool Hat', original: '546d4c699df4a17d0d449abb', portraitURL: '/file/db/thang.type/546d4c699df4a17d0d449abb/portrait.png', kind: 'Item'} + {slug: 'griffin-wool-robe', name: 'Griffin Wool Robe', original: '546d4a159df4a17d0d449a93', portraitURL: '/file/db/thang.type/546d4a159df4a17d0d449a93/portrait.png', kind: 'Item'} + {slug: 'hand-sewn-linen-wizards-hat', name: 'Hand-sewn Linen Wizard\'s Hat', original: '546d4bec9df4a17d0d449ab3', portraitURL: '/file/db/thang.type/546d4bec9df4a17d0d449ab3/portrait.png', kind: 'Item'} + {slug: 'hardened-emerald-chainmail-coif', name: 'Hardened Emerald Chainmail Coif', original: '546d47159df4a17d0d449a67', portraitURL: '/file/db/thang.type/546d47159df4a17d0d449a67/portrait.png', kind: 'Item'} + {slug: 'hardened-emerald-chainmail-tunic', name: 'Hardened Emerald Chainmail Tunic', original: '546d3cce9df4a17d0d449a3f', portraitURL: '/file/db/thang.type/546d3cce9df4a17d0d449a3f/portrait.png', kind: 'Item'} + {slug: 'hardened-steel-glasses', name: 'Hardened Steel Glasses', original: '546940d8a2b1f53ce794440d', portraitURL: '/file/db/thang.type/546940d8a2b1f53ce794440d/portrait.png', kind: 'Item'} + {slug: 'harrowland-background', name: 'Harrowland Background', original: '572e51e3f8c4f9b601ede885', portraitURL: '/file/db/thang.type/572e51e3f8c4f9b601ede885/portrait.png', kind: 'Floor'} + {slug: 'haste', name: 'Haste', original: '530251cfa6efdd32359c53d5', portraitURL: '/file/db/thang.type/530251cfa6efdd32359c53d5/portrait.png', kind: 'Mark'} + {slug: 'haunted-kithmaze-background', name: 'Haunted Kithmaze Background', original: '569dd4f2b55fd82e0011b79b', portraitURL: '/file/db/thang.type/569dd4f2b55fd82e0011b79b/portrait.png', kind: 'Floor'} + {slug: 'heal', name: 'Heal', original: '55c63ebcef141c65665beb59', portraitURL: '/file/db/thang.type/55c63ebcef141c65665beb59/portrait.png', kind: 'Mark'} + {slug: 'health-potion-large', name: 'Health Potion Large', original: '52afc634c5b1813ec2000002', portraitURL: '/file/db/thang.type/52afc634c5b1813ec2000002/portrait.png', kind: 'Misc'} + {slug: 'health-potion-medium', name: 'Health Potion Medium', original: '52afc742c5b1813ec2000004', portraitURL: '/file/db/thang.type/52afc742c5b1813ec2000004/portrait.png', kind: 'Misc'} + {slug: 'health-potion-small', name: 'Health Potion Small', original: '52afc7b6c5b1813ec2000006', portraitURL: '/file/db/thang.type/52afc7b6c5b1813ec2000006/portrait.png', kind: 'Misc'} + {slug: 'heavy-iron-breastplate', name: 'Heavy Iron Breastplate', original: '546aaf1b3777d6186329285e', portraitURL: '/file/db/thang.type/546aaf1b3777d6186329285e/portrait.png', kind: 'Item'} + {slug: 'heavy-iron-helmet', name: 'Heavy Iron Helmet', original: '546d390b9df4a17d0d449a0b', portraitURL: '/file/db/thang.type/546d390b9df4a17d0d449a0b/portrait.png', kind: 'Item'} + {slug: 'helmet-fall-1', name: 'Helmet Fall 1', original: '53e2e3e66c59f5340504108f', portraitURL: '/file/db/thang.type/53e2e3e66c59f5340504108f/portrait.png', kind: 'Doodad'} + {slug: 'hide', name: 'Hide', original: '55c281e83767fd3435eb446d', portraitURL: '/file/db/thang.type/55c281e83767fd3435eb446d/portrait.png', kind: 'Mark'} + {slug: 'highlight', name: 'Highlight', original: '529f8fdbdacd325127000003', portraitURL: '/file/db/thang.type/529f8fdbdacd325127000003/portrait.png', kind: 'Mark'} + {slug: 'holoball', name: 'Holoball', original: '56d0fa8a087ee32400764bb8', portraitURL: '/file/db/thang.type/56d0fa8a087ee32400764bb8/portrait.png', kind: 'Doodad'} + {slug: 'holy-sword', name: 'Holy Sword', original: '53e21249b82921000051ce11', portraitURL: '/file/db/thang.type/53e21249b82921000051ce11/portrait.png', kind: 'Item'} + {slug: 'house-1', name: 'House 1', original: '52b095bbccbc671372000006', portraitURL: '/file/db/thang.type/52b095bbccbc671372000006/portrait.png', kind: 'Doodad'} + {slug: 'house-2', name: 'House 2', original: '52b09d35ccbc671372000009', portraitURL: '/file/db/thang.type/52b09d35ccbc671372000009/portrait.png', kind: 'Doodad'} + {slug: 'house-3', name: 'House 3', original: '52b09dd0ccbc67137200000b', portraitURL: '/file/db/thang.type/52b09dd0ccbc67137200000b/portrait.png', kind: 'Doodad'} + {slug: 'house-4', name: 'House 4', original: '52b09e2fccbc67137200000d', portraitURL: '/file/db/thang.type/52b09e2fccbc67137200000d/portrait.png', kind: 'Doodad'} + {slug: 'hoverboard-stand', name: 'Hoverboard Stand', original: '56c630aeed946a44004ff139', portraitURL: '/file/db/thang.type/56c630aeed946a44004ff139/portrait.png', kind: 'Doodad'} + {slug: 'human-barracks', name: 'Human Barracks', original: '530ce329ec5bdaba2a72a99c', portraitURL: '/file/db/thang.type/530ce329ec5bdaba2a72a99c/portrait.png', kind: 'Unit'} + {slug: 'hunting-rifle', name: 'Hunting Rifle', original: '544d82aa8494308424f564cf', portraitURL: '/file/db/thang.type/544d82aa8494308424f564cf/portrait.png', kind: 'Item'} + {slug: 'ice-crystals-1', name: 'Ice Crystals 1', original: '557639501e82182d9e688945', portraitURL: '/file/db/thang.type/557639501e82182d9e688945/portrait.png', kind: 'Doodad'} + {slug: 'ice-crystals-2', name: 'Ice Crystals 2', original: '557639b91e82182d9e688949', portraitURL: '/file/db/thang.type/557639b91e82182d9e688949/portrait.png', kind: 'Doodad'} + {slug: 'ice-door', name: 'Ice Door', original: '557f32b0b43ce0b15a91b16d', portraitURL: '/file/db/thang.type/557f32b0b43ce0b15a91b16d/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle', name: 'Ice Gargoyle', original: '55760d3f1e82182d9e6888f6', portraitURL: '/file/db/thang.type/55760d3f1e82182d9e6888f6/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle-fore', name: 'Ice Gargoyle Fore', original: '55760e311e82182d9e688902', portraitURL: '/file/db/thang.type/55760e311e82182d9e688902/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle-ruin', name: 'Ice Gargoyle Ruin', original: '55760dc31e82182d9e6888fa', portraitURL: '/file/db/thang.type/55760dc31e82182d9e6888fa/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle-ruin-fore', name: 'Ice Gargoyle Ruin Fore', original: '55760dfa1e82182d9e6888fe', portraitURL: '/file/db/thang.type/55760dfa1e82182d9e6888fe/portrait.png', kind: 'Doodad'} + {slug: 'ice-rink-1', name: 'Ice Rink 1', original: '557f321bb43ce0b15a91b161', portraitURL: '/file/db/thang.type/557f321bb43ce0b15a91b161/portrait.png', kind: 'Floor'} + {slug: 'ice-rink-2', name: 'Ice Rink 2', original: '557f325cb43ce0b15a91b165', portraitURL: '/file/db/thang.type/557f325cb43ce0b15a91b165/portrait.png', kind: 'Floor'} + {slug: 'ice-rink-3', name: 'Ice Rink 3', original: '557f3275b43ce0b15a91b169', portraitURL: '/file/db/thang.type/557f3275b43ce0b15a91b169/portrait.png', kind: 'Floor'} + {slug: 'ice-tree-1', name: 'Ice Tree 1', original: '557635641e82182d9e688929', portraitURL: '/file/db/thang.type/557635641e82182d9e688929/portrait.png', kind: 'Doodad'} + {slug: 'ice-tree-2', name: 'Ice Tree 2', original: '557636401e82182d9e68892d', portraitURL: '/file/db/thang.type/557636401e82182d9e68892d/portrait.png', kind: 'Doodad'} + {slug: 'ice-tree-3', name: 'Ice Tree 3', original: '557636e11e82182d9e688931', portraitURL: '/file/db/thang.type/557636e11e82182d9e688931/portrait.png', kind: 'Doodad'} + {slug: 'ice-wall', name: 'Ice Wall', original: '5575f002f3f8d13b4ee1e7fc', portraitURL: '/file/db/thang.type/5575f002f3f8d13b4ee1e7fc/portrait.png', kind: 'Wall'} + {slug: 'ice-yak', name: 'Ice Yak', original: '557f3917b43ce0b15a91b175', portraitURL: '/file/db/thang.type/557f3917b43ce0b15a91b175/portrait.png', kind: 'Unit'} + {slug: 'igloo-1', name: 'Igloo 1', original: '557608f61e82182d9e6888cf', portraitURL: '/file/db/thang.type/557608f61e82182d9e6888cf/portrait.png', kind: 'Doodad'} + {slug: 'igloo-2', name: 'Igloo 2', original: '557609b01e82182d9e6888d3', portraitURL: '/file/db/thang.type/557609b01e82182d9e6888d3/portrait.png', kind: 'Doodad'} + {slug: 'igloo-3', name: 'Igloo 3', original: '557609dd1e82182d9e6888d7', portraitURL: '/file/db/thang.type/557609dd1e82182d9e6888d7/portrait.png', kind: 'Doodad'} + {slug: 'igloo-4', name: 'Igloo 4', original: '55760a2c1e82182d9e6888db', portraitURL: '/file/db/thang.type/55760a2c1e82182d9e6888db/portrait.png', kind: 'Doodad'} + {slug: 'impaling-firebolt', name: 'Impaling Firebolt', original: '54f767c4b3e4927805021022', portraitURL: '/file/db/thang.type/54f767c4b3e4927805021022/portrait.png', kind: 'Missile'} + {slug: 'importer-of-great-justice', name: 'Importer of Great Justice', original: '54938575e9850ae3e8fbdd74', portraitURL: '/file/db/thang.type/54938575e9850ae3e8fbdd74/portrait.png', kind: 'undefined'} + {slug: 'indoor-floor', name: 'Indoor Floor', original: '52ead2b2207133f35c000833', portraitURL: '/file/db/thang.type/52ead2b2207133f35c000833/portrait.png', kind: 'Floor'} + {slug: 'indoor-wall', name: 'Indoor Wall', original: '52ea9a13d23f140d100000b2', portraitURL: '/file/db/thang.type/52ea9a13d23f140d100000b2/portrait.png', kind: 'Wall'} + {slug: 'infantry-shield', name: 'Infantry Shield', original: '544d7bb88494308424f56493', portraitURL: '/file/db/thang.type/544d7bb88494308424f56493/portrait.png', kind: 'Item'} + {slug: 'invisible', name: 'Invisible', original: '52b0f9c75c5c4af6bd000004', portraitURL: '/file/db/thang.type/52b0f9c75c5c4af6bd000004/portrait.png', kind: 'Misc'} + {slug: 'iron-chainmail-coif', name: 'Iron Chainmail Coif', original: '546d45c59df4a17d0d449a53', portraitURL: '/file/db/thang.type/546d45c59df4a17d0d449a53/portrait.png', kind: 'Item'} + {slug: 'iron-chainmail-tunic', name: 'Iron Chainmail Tunic', original: '546d3b7c9df4a17d0d449a2b', portraitURL: '/file/db/thang.type/546d3b7c9df4a17d0d449a2b/portrait.png', kind: 'Item'} + {slug: 'iron-defender', name: 'Iron Defender', original: '54eaabe62b7506e891ca71c9', portraitURL: '/file/db/thang.type/54eaabe62b7506e891ca71c9/portrait.png', kind: 'Item'} + {slug: 'iron-link', name: 'Iron Link', original: '54692d5ca2b1f53ce7944393', portraitURL: '/file/db/thang.type/54692d5ca2b1f53ce7944393/portrait.png', kind: 'Item'} + {slug: 'iron-maiden', name: 'Iron Maiden', original: '54ef9f0a83b08b7d054ba50d', portraitURL: '/file/db/thang.type/54ef9f0a83b08b7d054ba50d/portrait.png', kind: 'Doodad'} + {slug: 'iron-shield', name: 'Iron Shield', original: '5441c3f44e9aeb727cc97129', portraitURL: '/file/db/thang.type/5441c3f44e9aeb727cc97129/portrait.png', kind: 'Item'} + {slug: 'kings-ring', name: 'King\'s Ring', original: '54eb56df49fa2d5c905ddf2e', portraitURL: '/file/db/thang.type/54eb56df49fa2d5c905ddf2e/portrait.png', kind: 'Item'} + {slug: 'kithgard-gates-background', name: 'Kithgard Gates Background', original: '572e52b17a9c3e8101b8be0e', portraitURL: '/file/db/thang.type/572e52b17a9c3e8101b8be0e/portrait.png', kind: 'Floor'} + {slug: 'kithgard-workers-glasses', name: 'Kithgard Worker\'s Glasses', original: '53eb99f41a100989a40ce46e', portraitURL: '/file/db/thang.type/53eb99f41a100989a40ce46e/portrait.png', kind: 'Item'} + {slug: 'kithsteel-blade', name: 'Kithsteel Blade', original: '54eaa78a2b7506e891ca719d', portraitURL: '/file/db/thang.type/54eaa78a2b7506e891ca719d/portrait.png', kind: 'Item'} + {slug: 'knightfire-charge', name: 'Knightfire Charge', original: '544d96328494308424f56533', portraitURL: '/file/db/thang.type/544d96328494308424f56533/portrait.png', kind: 'Item'} + {slug: 'knightfire-charge-missile', name: 'Knightfire Charge Missile', original: '546297f1f44055a4b5e735bb', portraitURL: '/file/db/thang.type/546297f1f44055a4b5e735bb/portrait.png', kind: 'Missile'} + {slug: 'known-enemy-background', name: 'Known Enemy Background', original: '572e4e61b2088976012429eb', portraitURL: '/file/db/thang.type/572e4e61b2088976012429eb/portrait.png', kind: 'Floor'} + {slug: 'koraths-promise', name: 'Korath\'s Promise', original: '54eb575749fa2d5c905ddf3a', portraitURL: '/file/db/thang.type/54eb575749fa2d5c905ddf3a/portrait.png', kind: 'Item'} + {slug: 'krummholz-1', name: 'Krummholz 1', original: '54e953adf54ef5794f354ef1', portraitURL: '/file/db/thang.type/54e953adf54ef5794f354ef1/portrait.png', kind: 'Doodad'} + {slug: 'krummholz-2', name: 'Krummholz 2', original: '54e9545bf54ef5794f354ef5', portraitURL: '/file/db/thang.type/54e9545bf54ef5794f354ef5/portrait.png', kind: 'Doodad'} + {slug: 'krummholz-3', name: 'Krummholz 3', original: '54e95492f54ef5794f354ef9', portraitURL: '/file/db/thang.type/54e95492f54ef5794f354ef9/portrait.png', kind: 'Doodad'} + {slug: 'lambswool-cloak', name: 'Lambswool Cloak', original: '546d48ce9df4a17d0d449a7b', portraitURL: '/file/db/thang.type/546d48ce9df4a17d0d449a7b/portrait.png', kind: 'Item'} + {slug: 'large-bolt-crossbow', name: 'Large Bolt Crossbow', original: '544d80598494308424f564c7', portraitURL: '/file/db/thang.type/544d80598494308424f564c7/portrait.png', kind: 'Item'} + {slug: 'large-classroom-viewscreen-off', name: 'Large Classroom Viewscreen Off', original: '56c632c6abf4a61f009040b5', portraitURL: '/file/db/thang.type/56c632c6abf4a61f009040b5/portrait.png', kind: 'Doodad'} + {slug: 'large-classroom-viewscreen-on', name: 'Large Classroom Viewscreen On', original: '56eb28b267a0142000a36358', portraitURL: '/file/db/thang.type/56eb28b267a0142000a36358/portrait.png', kind: 'Doodad'} + {slug: 'lava-grate', name: 'Lava Grate', original: '54ef9fb8c1f3bd7c05941750', portraitURL: '/file/db/thang.type/54ef9fb8c1f3bd7c05941750/portrait.png', kind: 'Doodad'} + {slug: 'leather-belt', name: 'Leather Belt', original: '5437002a7beba4a82024a97d', portraitURL: '/file/db/thang.type/5437002a7beba4a82024a97d/portrait.png', kind: 'Item'} + {slug: 'leather-boots', name: 'Leather Boots', original: '53e2384453457600003e3f07', portraitURL: '/file/db/thang.type/53e2384453457600003e3f07/portrait.png', kind: 'Item'} + {slug: 'leather-chainmail-coif', name: 'Leather Chainmail Coif', original: '546d45089df4a17d0d449a4b', portraitURL: '/file/db/thang.type/546d45089df4a17d0d449a4b/portrait.png', kind: 'Item'} + {slug: 'leather-chainmail-tunic', name: 'Leather Chainmail Tunic', original: '546d3ab69df4a17d0d449a23', portraitURL: '/file/db/thang.type/546d3ab69df4a17d0d449a23/portrait.png', kind: 'Item'} + {slug: 'leather-tunic', name: 'Leather Tunic', original: '545d3cf22d03e700001b5a58', portraitURL: '/file/db/thang.type/545d3cf22d03e700001b5a58/portrait.png', kind: 'Item'} + {slug: 'level-banner', name: 'Level Banner', original: '5432c9688364d30000d1f935', portraitURL: '/file/db/thang.type/5432c9688364d30000d1f935/portrait.png', kind: 'Misc'} + {slug: 'lightning-bolt', name: 'Lightning Bolt', original: '54f3fa515fcc6a3950c7eabd', portraitURL: '/file/db/thang.type/54f3fa515fcc6a3950c7eabd/portrait.png', kind: 'Missile'} + {slug: 'lightning-stick', name: 'Lightning Stick', original: '544d86318494308424f564e8', portraitURL: '/file/db/thang.type/544d86318494308424f564e8/portrait.png', kind: 'Item'} + {slug: 'lightning-twig', name: 'Lightning Twig', original: '54eab1ec2b7506e891ca71e1', portraitURL: '/file/db/thang.type/54eab1ec2b7506e891ca71e1/portrait.png', kind: 'Item'} + {slug: 'lightstone', name: 'Lightstone', original: '54da20b7163110520551ed33', portraitURL: '/file/db/thang.type/54da20b7163110520551ed33/portrait.png', kind: 'Misc'} + {slug: 'log-1', name: 'Log 1', original: '54e954d7f54ef5794f354efd', portraitURL: '/file/db/thang.type/54e954d7f54ef5794f354efd/portrait.png', kind: 'Doodad'} + {slug: 'log-2', name: 'Log 2', original: '54e9553ef54ef5794f354f01', portraitURL: '/file/db/thang.type/54e9553ef54ef5794f354f01/portrait.png', kind: 'Doodad'} + {slug: 'log-3', name: 'Log 3', original: '54e9556af54ef5794f354f05', portraitURL: '/file/db/thang.type/54e9556af54ef5794f354f05/portrait.png', kind: 'Doodad'} + {slug: 'long-sword', name: 'Long Sword', original: '544d7d1f8494308424f564a3', portraitURL: '/file/db/thang.type/544d7d1f8494308424f564a3/portrait.png', kind: 'Item'} + {slug: 'loop-da-loop-background', name: 'Loop Da Loop Background', original: '56c67cba797353370060506d', portraitURL: '/file/db/thang.type/56c67cba797353370060506d/portrait.png', kind: 'Floor'} + {slug: 'magic-missile', name: 'Magic Missile', original: '5467beaf69d1ba0000fb91fb', portraitURL: '/file/db/thang.type/5467beaf69d1ba0000fb91fb/portrait.png', kind: 'Missile'} + {slug: 'magnetize', name: 'Magnetize', original: '55c6403eef141c65665beb5e', portraitURL: '/file/db/thang.type/55c6403eef141c65665beb5e/portrait.png', kind: 'Mark'} + {slug: 'mahogany-glasses', name: 'Mahogany Glasses', original: '54694093a2b1f53ce7944408', portraitURL: '/file/db/thang.type/54694093a2b1f53ce7944408/portrait.png', kind: 'Item'} + {slug: 'mahogany-staff', name: 'Mahogany Staff', original: '544d88158494308424f56501', portraitURL: '/file/db/thang.type/544d88158494308424f56501/portrait.png', kind: 'Item'} + {slug: 'maka-test-wall', name: 'maka-test-wall', original: '56a7d85fb679392600e31138', portraitURL: '/file/db/thang.type/56a7d85fb679392600e31138/portrait.png', kind: 'Wall'} + {slug: 'market-stand', name: 'Market Stand', original: '54f11600f854c97a055516da', portraitURL: '/file/db/thang.type/54f11600f854c97a055516da/portrait.png', kind: 'Doodad'} + {slug: 'master-of-names-background', name: 'Master of Names Background', original: '572e4ec2e8db519501484869', portraitURL: '/file/db/thang.type/572e4ec2e8db519501484869/portrait.png', kind: 'Floor'} + {slug: 'master-sword', name: 'Master Sword', original: '54ea89112b7506e891ca717d', portraitURL: '/file/db/thang.type/54ea89112b7506e891ca717d/portrait.png', kind: 'Item'} + {slug: 'masters-flags', name: 'Master\'s Flags', original: '5478b9be8707a2c3a2493b33', portraitURL: '/file/db/thang.type/5478b9be8707a2c3a2493b33/portrait.png', kind: 'Item'} + {slug: 'mausoleum', name: 'Mausoleum', original: '54f1128a25be5e8805837491', portraitURL: '/file/db/thang.type/54f1128a25be5e8805837491/portrait.png', kind: 'Doodad'} + {slug: 'mayhem-background', name: 'Mayhem Background', original: '572e5071e8db519501484896', portraitURL: '/file/db/thang.type/572e5071e8db519501484896/portrait.png', kind: 'Floor'} + {slug: 'mcp', name: 'mcp', original: '576322da0d81132500afdc8d', portraitURL: '/file/db/thang.type/576322da0d81132500afdc8d/portrait.png', kind: 'Unit'} + {slug: 'megaphone', name: 'Megaphone', original: '53e216ff53457600003e3eb7', portraitURL: '/file/db/thang.type/53e216ff53457600003e3eb7/portrait.png', kind: 'Item'} + {slug: 'metal-builders-hammer', name: 'Metal Builder\'s Hammer', original: '54694c79a2b1f53ce794445e', portraitURL: '/file/db/thang.type/54694c79a2b1f53ce794445e/portrait.png', kind: 'Item'} + {slug: 'moonless-night', name: 'Moonless Night', original: '54692f44a2b1f53ce79443b8', portraitURL: '/file/db/thang.type/54692f44a2b1f53ce79443b8/portrait.png', kind: 'Item'} + {slug: 'moonlit-blade', name: 'Moonlit Blade', original: '544d95a48494308424f56523', portraitURL: '/file/db/thang.type/544d95a48494308424f56523/portrait.png', kind: 'Item'} + {slug: 'moonlit-blade-missile', name: 'Moonlit Blade Missile', original: '544d97bc8494308424f5653c', portraitURL: '/file/db/thang.type/544d97bc8494308424f5653c/portrait.png', kind: 'Missile'} + {slug: 'mornings-edge', name: 'Morning\'s Edge', original: '54eaa69a2b7506e891ca7195', portraitURL: '/file/db/thang.type/54eaa69a2b7506e891ca7195/portrait.png', kind: 'Item'} + {slug: 'mountain-1', name: 'Mountain 1', original: '54e931d7970f0b0a263c03ef', portraitURL: '/file/db/thang.type/54e931d7970f0b0a263c03ef/portrait.png', kind: 'Doodad'} + {slug: 'mountain-2', name: 'Mountain 2', original: '54e9340b970f0b0a263c03f3', portraitURL: '/file/db/thang.type/54e9340b970f0b0a263c03f3/portrait.png', kind: 'Doodad'} + {slug: 'mountain-3', name: 'Mountain 3', original: '54e935d1970f0b0a263c03f7', portraitURL: '/file/db/thang.type/54e935d1970f0b0a263c03f7/portrait.png', kind: 'Doodad'} + {slug: 'mountain-4', name: 'Mountain 4', original: '54e9377e970f0b0a263c03fc', portraitURL: '/file/db/thang.type/54e9377e970f0b0a263c03fc/portrait.png', kind: 'Doodad'} + {slug: 'mountain-lake-1', name: 'Mountain Lake 1', original: '54e93f0ef54ef5794f354e99', portraitURL: '/file/db/thang.type/54e93f0ef54ef5794f354e99/portrait.png', kind: 'Doodad'} + {slug: 'mountain-lake-2', name: 'Mountain Lake 2', original: '54e94106f54ef5794f354ea3', portraitURL: '/file/db/thang.type/54e94106f54ef5794f354ea3/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-1', name: 'Mountain Shrub 1', original: '54e9567ff54ef5794f354f11', portraitURL: '/file/db/thang.type/54e9567ff54ef5794f354f11/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-2', name: 'Mountain Shrub 2', original: '54e956b7f54ef5794f354f15', portraitURL: '/file/db/thang.type/54e956b7f54ef5794f354f15/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-3', name: 'Mountain Shrub 3', original: '54e956def54ef5794f354f19', portraitURL: '/file/db/thang.type/54e956def54ef5794f354f19/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-4', name: 'Mountain Shrub 4', original: '54e95724f54ef5794f354f1d', portraitURL: '/file/db/thang.type/54e95724f54ef5794f354f1d/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-1', name: 'Mountain Tree Stand 1', original: '55c24e91dfc8d0b576e60a5e', portraitURL: '/file/db/thang.type/55c24e91dfc8d0b576e60a5e/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-2', name: 'Mountain Tree Stand 2', original: '55c25141dfc8d0b576e60a64', portraitURL: '/file/db/thang.type/55c25141dfc8d0b576e60a64/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-3', name: 'Mountain Tree Stand 3', original: '55c25173dfc8d0b576e60a6a', portraitURL: '/file/db/thang.type/55c25173dfc8d0b576e60a6a/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-4', name: 'Mountain Tree Stand 4', original: '55c25190dfc8d0b576e60a70', portraitURL: '/file/db/thang.type/55c25190dfc8d0b576e60a70/portrait.png', kind: 'Doodad'} + {slug: 'movement-stone', name: 'Movement Stone', original: '546e257a9df4a17d0d449bd9', portraitURL: '/file/db/thang.type/546e257a9df4a17d0d449bd9/portrait.png', kind: 'Doodad'} + {slug: 'movement-stone-loop', name: 'Movement Stone Loop', original: '546e24679df4a17d0d449bc1', portraitURL: '/file/db/thang.type/546e24679df4a17d0d449bc1/portrait.png', kind: 'Doodad'} + {slug: 'multiplayer-treasure-grove-background', name: 'Multiplayer Treasure Grove Background', original: '572e526e7a9c3e8101b8be02', portraitURL: '/file/db/thang.type/572e526e7a9c3e8101b8be02/portrait.png', kind: 'Floor'} + {slug: 'mummy', name: 'Mummy', original: '54ef799c8d75558205e98a8e', portraitURL: '/file/db/thang.type/54ef799c8d75558205e98a8e/portrait.png', kind: 'Doodad'} + {slug: 'mushroom', name: 'Mushroom', original: '52bcc23a8c4289607b00000a', portraitURL: '/file/db/thang.type/52bcc23a8c4289607b00000a/portrait.png', kind: 'Misc'} + {slug: 'mushroom-cluster-1', name: 'Mushroom Cluster 1', original: '5576376f1e82182d9e688935', portraitURL: '/file/db/thang.type/5576376f1e82182d9e688935/portrait.png', kind: 'Doodad'} + {slug: 'mushroom-cluster-2', name: 'Mushroom Cluster 2', original: '557638341e82182d9e688939', portraitURL: '/file/db/thang.type/557638341e82182d9e688939/portrait.png', kind: 'Doodad'} + {slug: 'mushroom-cluster-3', name: 'Mushroom Cluster 3', original: '557638731e82182d9e68893d', portraitURL: '/file/db/thang.type/557638731e82182d9e68893d/portrait.png', kind: 'Doodad'} + {slug: 'mushroom-cluster-4', name: 'Mushroom Cluster 4', original: '5576390e1e82182d9e688941', portraitURL: '/file/db/thang.type/5576390e1e82182d9e688941/portrait.png', kind: 'Doodad'} + {slug: 'musty-linen-robe', name: 'Musty Linen Robe', original: '546d49409df4a17d0d449a83', portraitURL: '/file/db/thang.type/546d49409df4a17d0d449a83/portrait.png', kind: 'Item'} + {slug: 'newmakatesthushbaum', name: 'newmakatesthushbaum', original: '56ce223647c33f2400d98c66', portraitURL: '/file/db/thang.type/56ce223647c33f2400d98c66/portrait.png', kind: 'undefined'} + {slug: 'nightingales-song', name: 'Nightingale\'s Song', original: '54eb570b49fa2d5c905ddf32', portraitURL: '/file/db/thang.type/54eb570b49fa2d5c905ddf32/portrait.png', kind: 'Item'} + {slug: 'north-mounted-camera-facing-east-west', name: 'north mounted camera facing east-west', original: '56f175f2f3ae4cc900a0c5fc', portraitURL: '/file/db/thang.type/56f175f2f3ae4cc900a0c5fc/portrait.png', kind: 'Doodad'} + {slug: 'north-mounted-camera-facing-north', name: 'north mounted camera facing north', original: '56f173384852efd20059948a', portraitURL: '/file/db/thang.type/56f173384852efd20059948a/portrait.png', kind: 'Doodad'} + {slug: 'north-mounted-camera-facing-south', name: 'north mounted camera facing south', original: '56f17562f3ae4cc900a0c57a', portraitURL: '/file/db/thang.type/56f17562f3ae4cc900a0c57a/portrait.png', kind: 'Doodad'} + {slug: 'oak-crossbow', name: 'Oak Crossbow', original: '544d80928494308424f564cb', portraitURL: '/file/db/thang.type/544d80928494308424f564cb/portrait.png', kind: 'Item'} + {slug: 'oak-sphere-staff', name: 'Oak Sphere Staff', original: '544d88b78494308424f5650d', portraitURL: '/file/db/thang.type/544d88b78494308424f5650d/portrait.png', kind: 'Item'} + {slug: 'oak-wand', name: 'Oak Wand', original: '544d87d18494308424f564fd', portraitURL: '/file/db/thang.type/544d87d18494308424f564fd/portrait.png', kind: 'Item'} + {slug: 'oasis-1', name: 'Oasis 1', original: '544d79678494308424f56480', portraitURL: '/file/db/thang.type/544d79678494308424f56480/portrait.png', kind: 'Doodad'} + {slug: 'oasis-2', name: 'Oasis 2', original: '544d71198494308424f5647c', portraitURL: '/file/db/thang.type/544d71198494308424f5647c/portrait.png', kind: 'Doodad'} + {slug: 'oasis-3', name: 'Oasis 3', original: '5435d22f7b554def1f99c49a', portraitURL: '/file/db/thang.type/5435d22f7b554def1f99c49a/portrait.png', kind: 'Doodad'} + {slug: 'obsidian-breastplate', name: 'Obsidian Breastplate', original: '546ab11b3777d6186329286a', portraitURL: '/file/db/thang.type/546ab11b3777d6186329286a/portrait.png', kind: 'Item'} + {slug: 'obsidian-helmet', name: 'Obsidian Helmet', original: '546d39989df4a17d0d449a13', portraitURL: '/file/db/thang.type/546d39989df4a17d0d449a13/portrait.png', kind: 'Item'} + {slug: 'obsidian-shield', name: 'Obsidian Shield', original: '54eaba502b7506e891ca7216', portraitURL: '/file/db/thang.type/54eaba502b7506e891ca7216/portrait.png', kind: 'Item'} + {slug: 'obsidian-staff', name: 'Obsidian Staff', original: '54eab4b92b7506e891ca71ea', portraitURL: '/file/db/thang.type/54eab4b92b7506e891ca71ea/portrait.png', kind: 'Item'} + {slug: 'obstacle', name: 'Obstacle', original: '52bcc10d1f766a891c000001', portraitURL: '/file/db/thang.type/52bcc10d1f766a891c000001/portrait.png', kind: 'Misc'} + {slug: 'office-chair', name: 'office-chair', original: '56b25d8cc9d8ed21008354b8', portraitURL: '/file/db/thang.type/56b25d8cc9d8ed21008354b8/portrait.png', kind: 'Doodad'} + {slug: 'office-desk', name: 'Office Desk', original: '56b26c487168802600d26218', portraitURL: '/file/db/thang.type/56b26c487168802600d26218/portrait.png', kind: 'Doodad'} + {slug: 'office-door', name: 'Office Door', original: '56ba3366131fde2a000b84db', portraitURL: '/file/db/thang.type/56ba3366131fde2a000b84db/portrait.png', kind: 'Doodad'} + {slug: 'office-filing-cabinet', name: 'Office Filing Cabinet', original: '56b267eec2958a26005fbb58', portraitURL: '/file/db/thang.type/56b267eec2958a26005fbb58/portrait.png', kind: 'Doodad'} + {slug: 'office-filing-cabinet-2', name: 'Office Filing Cabinet 2', original: '56b268dd7168802600d25f3d', portraitURL: '/file/db/thang.type/56b268dd7168802600d25f3d/portrait.png', kind: 'Doodad'} + {slug: 'office-floor', name: 'Office Floor', original: '56b26e39bb550b26003adef0', portraitURL: '/file/db/thang.type/56b26e39bb550b26003adef0/portrait.png', kind: 'Floor'} + {slug: 'office-wall', name: 'office-wall', original: '56abc26c26c92a26005b3745', portraitURL: '/file/db/thang.type/56abc26c26c92a26005b3745/portrait.png', kind: 'Wall'} + {slug: 'ogre-barracks', name: 'Ogre Barracks', original: '530d11faa8583eb90a2fc76f', portraitURL: '/file/db/thang.type/530d11faa8583eb90a2fc76f/portrait.png', kind: 'Unit'} + {slug: 'ogre-fence', name: 'Ogre Fence', original: '5456b5c5d5ada30000525609', portraitURL: '/file/db/thang.type/5456b5c5d5ada30000525609/portrait.png', kind: 'Doodad'} + {slug: 'ogre-fence-2', name: 'Ogre Fence 2', original: '5456b631d5ada3000052560b', portraitURL: '/file/db/thang.type/5456b631d5ada3000052560b/portrait.png', kind: 'Doodad'} + {slug: 'ogre-headhunter-hero', name: 'Ogre Headhunter Hero', original: '5670779dfb9b702400cf6987', portraitURL: '/file/db/thang.type/5670779dfb9b702400cf6987/portrait.png', kind: 'Unit'} + {slug: 'ogre-peon-f', name: 'Ogre Peon F', original: '53765709a7b5ab3805f15512', portraitURL: '/file/db/thang.type/53765709a7b5ab3805f15512/portrait.png', kind: 'Unit'} + {slug: 'ogre-peon-m', name: 'Ogre Peon M', original: '53793734f883583805e356e2', portraitURL: '/file/db/thang.type/53793734f883583805e356e2/portrait.png', kind: 'Unit'} + {slug: 'ogre-scout-f', name: 'Ogre Scout F', original: '54909436b30e9eb7027fe21c', portraitURL: '/file/db/thang.type/54909436b30e9eb7027fe21c/portrait.png', kind: 'Unit'} + {slug: 'ogre-scout-m', name: 'Ogre Scout M', original: '54908ce5b30e9eb7027fe201', portraitURL: '/file/db/thang.type/54908ce5b30e9eb7027fe201/portrait.png', kind: 'Unit'} + {slug: 'ogre-tent', name: 'Ogre Tent', original: '5456b49dd5ada30000525607', portraitURL: '/file/db/thang.type/5456b49dd5ada30000525607/portrait.png', kind: 'Doodad'} + {slug: 'ogre-tower', name: 'ogre tower', original: '578686459fabcb1f0087d064', portraitURL: '/file/db/thang.type/578686459fabcb1f0087d064/portrait.png', kind: 'Doodad'} + {slug: 'ogre-tower-with-desert-rocks', name: 'ogre tower with desert rocks', original: '572d465dab2d38ad00a1c918', portraitURL: '/file/db/thang.type/572d465dab2d38ad00a1c918/portrait.png', kind: 'Doodad'} + {slug: 'ogre-towers-with-trees', name: 'ogre towers with trees', original: '572d47eee24ce2fb0025c6f3', portraitURL: '/file/db/thang.type/572d47eee24ce2fb0025c6f3/portrait.png', kind: 'Doodad'} + {slug: 'ogre-treasure-chest', name: 'Ogre Treasure Chest', original: '540e16d6821af8000097dc55', portraitURL: '/file/db/thang.type/540e16d6821af8000097dc55/portrait.png', kind: 'Doodad'} + {slug: 'ogre-wall', name: 'ogre wall', original: '5786834a2437842400f4009c', portraitURL: '/file/db/thang.type/5786834a2437842400f4009c/portrait.png', kind: 'Doodad'} + {slug: 'ogre-witch-hero', name: 'Ogre Witch Hero', original: '5638f6c4ef9d6464094a559d', portraitURL: '/file/db/thang.type/5638f6c4ef9d6464094a559d/portrait.png', kind: 'Unit'} + {slug: 'old-selection', name: 'Old Selection', original: '52aa5f7520fccb0000000002', portraitURL: '/file/db/thang.type/52aa5f7520fccb0000000002/portrait.png', kind: 'Mark'} + {slug: 'order-of-the-paladin', name: 'Order of the Paladin', original: '54eb55af49fa2d5c905ddf22', portraitURL: '/file/db/thang.type/54eb55af49fa2d5c905ddf22/portrait.png', kind: 'Item'} + {slug: 'overseer', name: 'Overseer', original: '56e75e0b67a0142000a12699', portraitURL: '/file/db/thang.type/56e75e0b67a0142000a12699/portrait.png', kind: 'Unit'} + {slug: 'painted-steel-breastplate', name: 'Painted Steel Breastplate', original: '546ab0dd3777d61863292866', portraitURL: '/file/db/thang.type/546ab0dd3777d61863292866/portrait.png', kind: 'Item'} + {slug: 'painted-steel-helmet', name: 'Painted Steel Helmet', original: '546d39589df4a17d0d449a0f', portraitURL: '/file/db/thang.type/546d39589df4a17d0d449a0f/portrait.png', kind: 'Item'} + {slug: 'painted-steel-shield', name: 'Painted Steel Shield', original: '544d7c5b8494308424f5649b', portraitURL: '/file/db/thang.type/544d7c5b8494308424f5649b/portrait.png', kind: 'Item'} + {slug: 'palisade', name: 'Palisade', original: '546e24bd9df4a17d0d449bc9', portraitURL: '/file/db/thang.type/546e24bd9df4a17d0d449bc9/portrait.png', kind: 'Doodad'} + {slug: 'paralyze', name: 'Paralyze', original: '53024e6b222f73867774d773', portraitURL: '/file/db/thang.type/53024e6b222f73867774d773/portrait.png', kind: 'Mark'} + {slug: 'pedestal', name: 'Pedestal', original: '542ae4750048dcb95727a1e6', portraitURL: '/file/db/thang.type/542ae4750048dcb95727a1e6/portrait.png', kind: 'Doodad'} + {slug: 'phoenixfire', name: 'Phoenixfire', original: '54ea8b602b7506e891ca718d', portraitURL: '/file/db/thang.type/54ea8b602b7506e891ca718d/portrait.png', kind: 'Item'} + {slug: 'plasma-ball', name: 'Plasma Ball', original: '5589fe594bed1b6c2a2cab6b', portraitURL: '/file/db/thang.type/5589fe594bed1b6c2a2cab6b/portrait.png', kind: 'Missile'} + {slug: 'poison', name: 'Poison', original: '53024020222f73867774d619', portraitURL: '/file/db/thang.type/53024020222f73867774d619/portrait.png', kind: 'Mark'} + {slug: 'poisoned-throwing-shard-missile', name: 'Poisoned Throwing Shard Missile', original: '544d97088494308424f56539', portraitURL: '/file/db/thang.type/544d97088494308424f56539/portrait.png', kind: 'Missile'} + {slug: 'polar-bear-cub-pet', name: 'Polar Bear Cub pet', original: '57588d4b87b06e1f00ded849', portraitURL: '/file/db/thang.type/57588d4b87b06e1f00ded849/portrait.png', kind: 'Unit'} + {slug: 'polished-agate-sense-stone', name: 'Polished Agate Sense Stone', original: '54693274a2b1f53ce79443c9', portraitURL: '/file/db/thang.type/54693274a2b1f53ce79443c9/portrait.png', kind: 'Item'} + {slug: 'polished-bronze-breastplate', name: 'Polished Bronze Breastplate', original: '545d3f0b2d03e700001b5a5d', portraitURL: '/file/db/thang.type/545d3f0b2d03e700001b5a5d/portrait.png', kind: 'Item'} + {slug: 'polished-bronze-helmet', name: 'Polished Bronze Helmet', original: '546d38779df4a17d0d449a03', portraitURL: '/file/db/thang.type/546d38779df4a17d0d449a03/portrait.png', kind: 'Item'} + {slug: 'polished-bronze-shield', name: 'Polished Bronze Shield', original: '544d7a888494308424f56487', portraitURL: '/file/db/thang.type/544d7a888494308424f56487/portrait.png', kind: 'Item'} + {slug: 'polished-emerald-sense-stone', name: 'Polished Emerald Sense Stone', original: '546933dda2b1f53ce79443d9', portraitURL: '/file/db/thang.type/546933dda2b1f53ce79443d9/portrait.png', kind: 'Item'} + {slug: 'polished-sense-stone', name: 'Polished Sense Stone', original: '53e215a253457600003e3eaf', portraitURL: '/file/db/thang.type/53e215a253457600003e3eaf/portrait.png', kind: 'Item'} + {slug: 'polished-steel-scale-chainmail-coif', name: 'Polished Steel Scale Chainmail Coif', original: '546d46889df4a17d0d449a5f', portraitURL: '/file/db/thang.type/546d46889df4a17d0d449a5f/portrait.png', kind: 'Item'} + {slug: 'polished-steel-scale-chainmail-tunic', name: 'Polished Steel Scale Chainmail Tunic', original: '546d3c3f9df4a17d0d449a37', portraitURL: '/file/db/thang.type/546d3c3f9df4a17d0d449a37/portrait.png', kind: 'Item'} + {slug: 'pot-1', name: 'Pot 1', original: '54ef882f83b08b7d054b6d49', portraitURL: '/file/db/thang.type/54ef882f83b08b7d054b6d49/portrait.png', kind: 'Doodad'} + {slug: 'pot-2', name: 'Pot 2', original: '54ef89dc4bb4788505d21234', portraitURL: '/file/db/thang.type/54ef89dc4bb4788505d21234/portrait.png', kind: 'Doodad'} + {slug: 'pot-3', name: 'Pot 3', original: '54ef8b1f305d7e790557d5d5', portraitURL: '/file/db/thang.type/54ef8b1f305d7e790557d5d5/portrait.png', kind: 'Doodad'} + {slug: 'pot-4', name: 'Pot 4', original: '54ef8becace2147e05868483', portraitURL: '/file/db/thang.type/54ef8becace2147e05868483/portrait.png', kind: 'Doodad'} + {slug: 'potion-belt', name: 'Potion Belt', original: '54694ac4a2b1f53ce794443d', portraitURL: '/file/db/thang.type/54694ac4a2b1f53ce794443d/portrait.png', kind: 'Item'} + {slug: 'potted-tree', name: 'Potted Tree', original: '56b2c8baf2ea182100d8ce78', portraitURL: '/file/db/thang.type/56b2c8baf2ea182100d8ce78/portrait.png', kind: 'Doodad'} + {slug: 'powder-charge', name: 'Powder Charge', original: '5462952cf44055a4b5e73599', portraitURL: '/file/db/thang.type/5462952cf44055a4b5e73599/portrait.png', kind: 'Item'} + {slug: 'powder-charge-missile', name: 'Powder Charge Missile', original: '544d99328494308424f56540', portraitURL: '/file/db/thang.type/544d99328494308424f56540/portrait.png', kind: 'Missile'} + {slug: 'power-up', name: 'Power Up', original: '55c64140ef141c65665beb6b', portraitURL: '/file/db/thang.type/55c64140ef141c65665beb6b/portrait.png', kind: 'Mark'} + {slug: 'power-up-2', name: 'Power Up 2', original: '55c6419fef141c65665beb6f', portraitURL: '/file/db/thang.type/55c6419fef141c65665beb6f/portrait.png', kind: 'Mark'} + {slug: 'precision-rifle', name: 'Precision Rifle', original: '54eaaecc2b7506e891ca71d9', portraitURL: '/file/db/thang.type/54eaaecc2b7506e891ca71d9/portrait.png', kind: 'Item'} + {slug: 'programmaticon-i', name: 'Programmaticon I', original: '53e4108204c00d4607a89f78', portraitURL: '/file/db/thang.type/53e4108204c00d4607a89f78/portrait.png', kind: 'Item'} + {slug: 'programmaticon-ii', name: 'Programmaticon II', original: '546e25d99df4a17d0d449be1', portraitURL: '/file/db/thang.type/546e25d99df4a17d0d449be1/portrait.png', kind: 'Item'} + {slug: 'programmaticon-iii', name: 'Programmaticon III', original: '546e266e9df4a17d0d449be5', portraitURL: '/file/db/thang.type/546e266e9df4a17d0d449be5/portrait.png', kind: 'Item'} + {slug: 'programmaticon-iv', name: 'Programmaticon IV', original: '55240951f76d6ee949f66512', portraitURL: '/file/db/thang.type/55240951f76d6ee949f66512/portrait.png', kind: 'Item'} + {slug: 'programmaticon-v', name: 'Programmaticon V', original: '557871261ff17fef5abee3ee', portraitURL: '/file/db/thang.type/557871261ff17fef5abee3ee/portrait.png', kind: 'Item'} + {slug: 'pugicorn-pet', name: 'Pugicorn Pet', original: '577d5edcab818b210046b73c', portraitURL: '/file/db/thang.type/577d5edcab818b210046b73c/portrait.png', kind: 'Unit'} + {slug: 'pushcart', name: 'Pushcart', original: '54f119a6d2969f8405ef539f', portraitURL: '/file/db/thang.type/54f119a6d2969f8405ef539f/portrait.png', kind: 'Doodad'} + {slug: 'quartz-sense-stone', name: 'Quartz Sense Stone', original: '54693240a2b1f53ce79443c5', portraitURL: '/file/db/thang.type/54693240a2b1f53ce79443c5/portrait.png', kind: 'Item'} + {slug: 'ragged-silk-hat', name: 'Ragged Silk Hat', original: '546d4ba19df4a17d0d449aaf', portraitURL: '/file/db/thang.type/546d4ba19df4a17d0d449aaf/portrait.png', kind: 'Item'} + {slug: 'railgun', name: 'Railgun', original: '54ea8ea52b7506e891ca7191', portraitURL: '/file/db/thang.type/54ea8ea52b7506e891ca7191/portrait.png', kind: 'Item'} + {slug: 'rapidfire-rifle', name: 'Rapidfire Rifle', original: '54eaae422b7506e891ca71d5', portraitURL: '/file/db/thang.type/54eaae422b7506e891ca71d5/portrait.png', kind: 'Item'} + {slug: 'rat', name: 'Rat', original: '55c11b70c87e47c60604f974', portraitURL: '/file/db/thang.type/55c11b70c87e47c60604f974/portrait.png', kind: 'Doodad'} + {slug: 'razor-ring', name: 'Razor Ring', original: '54c97c9bdef3ad363ff998b7', portraitURL: '/file/db/thang.type/54c97c9bdef3ad363ff998b7/portrait.png', kind: 'Missile'} + {slug: 'razordisc-missile', name: 'Razordisc Missile', original: '5318d3e56ad8999d34bdf338', portraitURL: '/file/db/thang.type/5318d3e56ad8999d34bdf338/portrait.png', kind: 'Missile'} + {slug: 'rectangle', name: 'Rectangle', original: '568d915e1717e2f90e9a1250', portraitURL: '/file/db/thang.type/568d915e1717e2f90e9a1250/portrait.png', kind: 'Misc'} + {slug: 'red-button', name: 'Red Button', original: '56d102c0441ddd2f002ba760', portraitURL: '/file/db/thang.type/56d102c0441ddd2f002ba760/portrait.png', kind: 'Doodad'} + {slug: 'regen', name: 'Regen', original: '53024f8b27471514685d53e1', portraitURL: '/file/db/thang.type/53024f8b27471514685d53e1/portrait.png', kind: 'Mark'} + {slug: 'reindeer', name: 'Reindeer', original: '54e95a88f54ef5794f354f3d', portraitURL: '/file/db/thang.type/54e95a88f54ef5794f354f3d/portrait.png', kind: 'Doodad'} + {slug: 'reinforced-boots', name: 'Reinforced Boots', original: '546d4d259df4a17d0d449ac5', portraitURL: '/file/db/thang.type/546d4d259df4a17d0d449ac5/portrait.png', kind: 'Item'} + {slug: 'reinforced-crossbow', name: 'Reinforced Crossbow', original: '54eaacdd2b7506e891ca71cd', portraitURL: '/file/db/thang.type/54eaacdd2b7506e891ca71cd/portrait.png', kind: 'Item'} + {slug: 'reinforced-iron-chainmail-coif', name: 'Reinforced Iron Chainmail Coif', original: '546d46099df4a17d0d449a57', portraitURL: '/file/db/thang.type/546d46099df4a17d0d449a57/portrait.png', kind: 'Item'} + {slug: 'reinforced-iron-chainmail-tunic', name: 'Reinforced Iron Chainmail Tunic', original: '546d3bbb9df4a17d0d449a2f', portraitURL: '/file/db/thang.type/546d3bbb9df4a17d0d449a2f/portrait.png', kind: 'Item'} + {slug: 'repair', name: 'Repair', original: '52bcc4591f766a891c000003', portraitURL: '/file/db/thang.type/52bcc4591f766a891c000003/portrait.png', kind: 'Mark'} + {slug: 'ring-of-developer-experimentation', name: 'Ring of Developer Experimentation', original: '54bac99bacbf5aea089da177', portraitURL: '/file/db/thang.type/54bac99bacbf5aea089da177/portrait.png', kind: 'Item'} + {slug: 'ring-of-earth', name: 'Ring of Earth', original: '5441c35c4e9aeb727cc9711d', portraitURL: '/file/db/thang.type/5441c35c4e9aeb727cc9711d/portrait.png', kind: 'Item'} + {slug: 'ring-of-fire', name: 'Ring of Fire', original: '54692ea2a2b1f53ce79443ab', portraitURL: '/file/db/thang.type/54692ea2a2b1f53ce79443ab/portrait.png', kind: 'Item'} + {slug: 'ring-of-flowers', name: 'Ring of Flowers', original: '5523224b0676ecb7d5c89319', portraitURL: '/file/db/thang.type/5523224b0676ecb7d5c89319/portrait.png', kind: 'Item'} + {slug: 'ring-of-ice', name: 'Ring of Ice', original: '54692ed3a2b1f53ce79443af', portraitURL: '/file/db/thang.type/54692ed3a2b1f53ce79443af/portrait.png', kind: 'Item'} + {slug: 'ring-of-speed', name: 'Ring of Speed', original: '54692d2aa2b1f53ce794438f', portraitURL: '/file/db/thang.type/54692d2aa2b1f53ce794438f/portrait.png', kind: 'Item'} + {slug: 'riveted-dragonscale-chainmail-coif', name: 'Riveted Dragonscale Chainmail Coif', original: '546d47c09df4a17d0d449a6f', portraitURL: '/file/db/thang.type/546d47c09df4a17d0d449a6f/portrait.png', kind: 'Item'} + {slug: 'riveted-dragonscale-chainmail-tunic', name: 'Riveted Dragonscale Chainmail Tunic', original: '546d3d549df4a17d0d449a47', portraitURL: '/file/db/thang.type/546d3d549df4a17d0d449a47/portrait.png', kind: 'Item'} + {slug: 'robe-of-the-magi', name: 'Robe of the Magi', original: '54ea3ec22b7506e891ca7126', portraitURL: '/file/db/thang.type/54ea3ec22b7506e891ca7126/portrait.png', kind: 'Item'} + {slug: 'robobomb', name: 'Robobomb', original: '55b7fb22a337d9b0ea024bb4', portraitURL: '/file/db/thang.type/55b7fb22a337d9b0ea024bb4/portrait.png', kind: 'Unit'} + {slug: 'robot-walker', name: 'Robot Walker', original: '5301696ad82649ec2c0c9b0d', portraitURL: '/file/db/thang.type/5301696ad82649ec2c0c9b0d/portrait.png', kind: 'Unit'} + {slug: 'rock-1', name: 'Rock 1', original: '52afcc1fc5b1813ec2000010', portraitURL: '/file/db/thang.type/52afcc1fc5b1813ec2000010/portrait.png', kind: 'Doodad'} + {slug: 'rock-2', name: 'Rock 2', original: '52afcce4c5b1813ec2000012', portraitURL: '/file/db/thang.type/52afcce4c5b1813ec2000012/portrait.png', kind: 'Doodad'} + {slug: 'rock-3', name: 'Rock 3', original: '52afcd43c5b1813ec2000014', portraitURL: '/file/db/thang.type/52afcd43c5b1813ec2000014/portrait.png', kind: 'Doodad'} + {slug: 'rock-4', name: 'Rock 4', original: '52afcd7bc5b1813ec2000016', portraitURL: '/file/db/thang.type/52afcd7bc5b1813ec2000016/portrait.png', kind: 'Doodad'} + {slug: 'rock-5', name: 'Rock 5', original: '52afcdc7c5b1813ec2000018', portraitURL: '/file/db/thang.type/52afcdc7c5b1813ec2000018/portrait.png', kind: 'Doodad'} + {slug: 'rock-6', name: 'Rock 6', original: '54e95916f54ef5794f354f2d', portraitURL: '/file/db/thang.type/54e95916f54ef5794f354f2d/portrait.png', kind: 'Doodad'} + {slug: 'rock-7', name: 'Rock 7', original: '54e959d6f54ef5794f354f31', portraitURL: '/file/db/thang.type/54e959d6f54ef5794f354f31/portrait.png', kind: 'Doodad'} + {slug: 'rock-8', name: 'Rock 8', original: '54e95a10f54ef5794f354f35', portraitURL: '/file/db/thang.type/54e95a10f54ef5794f354f35/portrait.png', kind: 'Doodad'} + {slug: 'rock-cluster-1', name: 'Rock Cluster 1', original: '52afcb47c5b1813ec200000a', portraitURL: '/file/db/thang.type/52afcb47c5b1813ec200000a/portrait.png', kind: 'Doodad'} + {slug: 'rock-cluster-2', name: 'Rock Cluster 2', original: '52afcb98c5b1813ec200000c', portraitURL: '/file/db/thang.type/52afcb98c5b1813ec200000c/portrait.png', kind: 'Doodad'} + {slug: 'rock-cluster-3', name: 'Rock Cluster 3', original: '52afcbe0c5b1813ec200000e', portraitURL: '/file/db/thang.type/52afcbe0c5b1813ec200000e/portrait.png', kind: 'Doodad'} + {slug: 'rock-field-1', name: 'Rock Field 1', original: '54e95753f54ef5794f354f21', portraitURL: '/file/db/thang.type/54e95753f54ef5794f354f21/portrait.png', kind: 'Doodad'} + {slug: 'rock-field-2', name: 'Rock Field 2', original: '54e95861f54ef5794f354f25', portraitURL: '/file/db/thang.type/54e95861f54ef5794f354f25/portrait.png', kind: 'Doodad'} + {slug: 'rock-field-3', name: 'Rock Field 3', original: '54e958aaf54ef5794f354f29', portraitURL: '/file/db/thang.type/54e958aaf54ef5794f354f29/portrait.png', kind: 'Doodad'} + {slug: 'root', name: 'Root', original: '55c640feef141c65665beb67', portraitURL: '/file/db/thang.type/55c640feef141c65665beb67/portrait.png', kind: 'Mark'} + {slug: 'rough-sense-stone', name: 'Rough Sense Stone', original: '54693140a2b1f53ce79443bc', portraitURL: '/file/db/thang.type/54693140a2b1f53ce79443bc/portrait.png', kind: 'Item'} + {slug: 'roughedge', name: 'Roughedge', original: '544d7d918494308424f564a7', portraitURL: '/file/db/thang.type/544d7d918494308424f564a7/portrait.png', kind: 'Item'} + {slug: 'rs-demo', name: 'RS Demo', original: '56ce48892438c720001e3ca3', portraitURL: '/file/db/thang.type/56ce48892438c720001e3ca3/portrait.png', kind: 'undefined'} + {slug: 'runesword', name: 'Runesword', original: '54eaa9622b7506e891ca71b1', portraitURL: '/file/db/thang.type/54eaa9622b7506e891ca71b1/portrait.png', kind: 'Item'} + {slug: 'rusted-iron-breastplate', name: 'Rusted Iron Breastplate', original: '545d3fe42d03e700001b5a5f', portraitURL: '/file/db/thang.type/545d3fe42d03e700001b5a5f/portrait.png', kind: 'Item'} + {slug: 'rusted-iron-helmet', name: 'Rusted Iron Helmet', original: '546d38d09df4a17d0d449a07', portraitURL: '/file/db/thang.type/546d38d09df4a17d0d449a07/portrait.png', kind: 'Item'} + {slug: 'rusted-steel-scale-chainmail-coif', name: 'Rusted Steel Scale Chainmail Coif', original: '546d46419df4a17d0d449a5b', portraitURL: '/file/db/thang.type/546d46419df4a17d0d449a5b/portrait.png', kind: 'Item'} + {slug: 'rusted-steel-scale-chainmail-tunic', name: 'Rusted Steel Scale Chainmail Tunic', original: '546d3bf99df4a17d0d449a33', portraitURL: '/file/db/thang.type/546d3bf99df4a17d0d449a33/portrait.png', kind: 'Item'} + {slug: 'sand-01', name: 'Sand 01', original: '5484df79d7b7b862291456af', portraitURL: '/file/db/thang.type/5484df79d7b7b862291456af/portrait.png', kind: 'Floor'} + {slug: 'sand-02', name: 'Sand 02', original: '5484e7c5d7b7b862291456b3', portraitURL: '/file/db/thang.type/5484e7c5d7b7b862291456b3/portrait.png', kind: 'Floor'} + {slug: 'sand-03', name: 'Sand 03', original: '5484e81bd7b7b862291456b7', portraitURL: '/file/db/thang.type/5484e81bd7b7b862291456b7/portrait.png', kind: 'Floor'} + {slug: 'sand-04', name: 'Sand 04', original: '5484e857d7b7b862291456bb', portraitURL: '/file/db/thang.type/5484e857d7b7b862291456bb/portrait.png', kind: 'Floor'} + {slug: 'sand-05', name: 'Sand 05', original: '5484e89cd7b7b862291456bf', portraitURL: '/file/db/thang.type/5484e89cd7b7b862291456bf/portrait.png', kind: 'Floor'} + {slug: 'sand-06', name: 'Sand 06', original: '5484e8ddd7b7b862291456c3', portraitURL: '/file/db/thang.type/5484e8ddd7b7b862291456c3/portrait.png', kind: 'Floor'} + {slug: 'sand-yak', name: 'Sand Yak', original: '5480b2251bf0b10000711c51', portraitURL: '/file/db/thang.type/5480b2251bf0b10000711c51/portrait.png', kind: 'Unit'} + {slug: 'sapphire-sense-stone', name: 'Sapphire Sense Stone', original: '54693363a2b1f53ce79443d1', portraitURL: '/file/db/thang.type/54693363a2b1f53ce79443d1/portrait.png', kind: 'Item'} + {slug: 'sarcophagus', name: 'sarcophagus', original: '572d5a2d3ff46db2000a381b', portraitURL: '/file/db/thang.type/572d5a2d3ff46db2000a381b/portrait.png', kind: 'Doodad'} + {slug: 'scaled-gloves', name: 'Scaled Gloves', original: '5469496ca2b1f53ce794442d', portraitURL: '/file/db/thang.type/5469496ca2b1f53ce794442d/portrait.png', kind: 'Item'} + {slug: 'school-locker', name: 'School locker', original: '56eb14804eb67a25009be23e', portraitURL: '/file/db/thang.type/56eb14804eb67a25009be23e/portrait.png', kind: 'Doodad'} + {slug: 'scoreboard', name: 'Scoreboard', original: '56de0ff26f9cc02400831e06', portraitURL: '/file/db/thang.type/56de0ff26f9cc02400831e06/portrait.png', kind: 'Doodad'} + {slug: 'scorpion', name: 'Scorpion', original: '548cf5340f559d0000be7e5b', portraitURL: '/file/db/thang.type/548cf5340f559d0000be7e5b/portrait.png', kind: 'Doodad'} + {slug: 'selection', name: 'Selection', original: '546e23d49df4a17d0d449bb5', portraitURL: '/file/db/thang.type/546e23d49df4a17d0d449bb5/portrait.png', kind: 'Misc'} + {slug: 'shadow-guard-background', name: 'Shadow Guard Background', original: '55bfc4c950cac5d58def9a67', portraitURL: '/file/db/thang.type/55bfc4c950cac5d58def9a67/portrait.png', kind: 'Floor'} + {slug: 'shadowless-bird', name: 'Shadowless Bird', original: '55079c55cea461db22519e9d', portraitURL: '/file/db/thang.type/55079c55cea461db22519e9d/portrait.png', kind: 'Doodad'} + {slug: 'shadowless-cloud-1', name: 'Shadowless Cloud 1', original: '53e2df9cd12e873205b6bce8', portraitURL: '/file/db/thang.type/53e2df9cd12e873205b6bce8/portrait.png', kind: 'Doodad'} + {slug: 'shadowless-cloud-2', name: 'Shadowless Cloud 2', original: '53e2e0176c59f5340504102f', portraitURL: '/file/db/thang.type/53e2e0176c59f5340504102f/portrait.png', kind: 'Doodad'} + {slug: 'shadowless-cloud-3', name: 'Shadowless Cloud 3', original: '53e2e08eae44ec37059f2148', portraitURL: '/file/db/thang.type/53e2e08eae44ec37059f2148/portrait.png', kind: 'Doodad'} + {slug: 'sharpened-sword', name: 'Sharpened Sword', original: '544d7deb8494308424f564ab', portraitURL: '/file/db/thang.type/544d7deb8494308424f564ab/portrait.png', kind: 'Item'} + {slug: 'sharpsong', name: 'Sharpsong', original: '544d95c78494308424f56527', portraitURL: '/file/db/thang.type/544d95c78494308424f56527/portrait.png', kind: 'Item'} + {slug: 'sharpsong-missile', name: 'Sharpsong Missile', original: '544d98368494308424f5653e', portraitURL: '/file/db/thang.type/544d98368494308424f5653e/portrait.png', kind: 'Missile'} + {slug: 'shell', name: 'Shell', original: '52ba2c6c981fbb7e48000093', portraitURL: '/file/db/thang.type/52ba2c6c981fbb7e48000093/portrait.png', kind: 'Missile'} + {slug: 'shield', name: 'Shield', original: '573fa531d0bee72000a4255f', portraitURL: '/file/db/thang.type/573fa531d0bee72000a4255f/portrait.png', kind: 'Mark'} + {slug: 'short-sword', name: 'Short Sword', original: '544d7f1a8494308424f564b7', portraitURL: '/file/db/thang.type/544d7f1a8494308424f564b7/portrait.png', kind: 'Item'} + {slug: 'shrub-1', name: 'Shrub 1', original: '52b0a113ccbc671372000017', portraitURL: '/file/db/thang.type/52b0a113ccbc671372000017/portrait.png', kind: 'Doodad'} + {slug: 'shrub-2', name: 'Shrub 2', original: '52b0a15accbc671372000019', portraitURL: '/file/db/thang.type/52b0a15accbc671372000019/portrait.png', kind: 'Doodad'} + {slug: 'shrub-3', name: 'Shrub 3', original: '52b0a1a3ccbc67137200001b', portraitURL: '/file/db/thang.type/52b0a1a3ccbc67137200001b/portrait.png', kind: 'Doodad'} + {slug: 'sign', name: 'Sign', original: '5435cbe77b554def1f99c491', portraitURL: '/file/db/thang.type/5435cbe77b554def1f99c491/portrait.png', kind: 'Doodad'} + {slug: 'silver-coin', name: 'Silver Coin', original: '535ef1f64f10444d08486b61', portraitURL: '/file/db/thang.type/535ef1f64f10444d08486b61/portrait.png', kind: 'Misc'} + {slug: 'simple-boots', name: 'Simple Boots', original: '53e237bf53457600003e3f05', portraitURL: '/file/db/thang.type/53e237bf53457600003e3f05/portrait.png', kind: 'Item'} + {slug: 'simple-katana', name: 'Simple Katana', original: '544d7ed58494308424f564b3', portraitURL: '/file/db/thang.type/544d7ed58494308424f564b3/portrait.png', kind: 'Item'} + {slug: 'simple-rifle', name: 'Simple Rifle', original: '544d70a18494308424f5647a', portraitURL: '/file/db/thang.type/544d70a18494308424f5647a/portrait.png', kind: 'Item'} + {slug: 'simple-sword', name: 'Simple Sword', original: '53e218d853457600003e3ebe', portraitURL: '/file/db/thang.type/53e218d853457600003e3ebe/portrait.png', kind: 'Item'} + {slug: 'simple-wand', name: 'Simple Wand', original: '544d874f8494308424f564f5', portraitURL: '/file/db/thang.type/544d874f8494308424f564f5/portrait.png', kind: 'Item'} + {slug: 'simple-wristwatch', name: 'Simple Wristwatch', original: '54693797a2b1f53ce79443e9', portraitURL: '/file/db/thang.type/54693797a2b1f53ce79443e9/portrait.png', kind: 'Item'} + {slug: 'skeleton-bits-1', name: 'Skeleton Bits 1', original: '54ef85bdc1f3bd7c0593d125', portraitURL: '/file/db/thang.type/54ef85bdc1f3bd7c0593d125/portrait.png', kind: 'Doodad'} + {slug: 'skeleton-bits-2', name: 'Skeleton Bits 2', original: '54ef874370ff9c8005e1eb0d', portraitURL: '/file/db/thang.type/54ef874370ff9c8005e1eb0d/portrait.png', kind: 'Doodad'} + {slug: 'sky-span-background-1', name: 'Sky Span Background 1', original: '53e3f096ae44ec37059f92d8', portraitURL: '/file/db/thang.type/53e3f096ae44ec37059f92d8/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-2', name: 'Sky Span Background 2', original: '53e3f3556c59f5340504359e', portraitURL: '/file/db/thang.type/53e3f3556c59f5340504359e/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-3', name: 'Sky Span Background 3', original: '53e3f500ae44ec37059f9415', portraitURL: '/file/db/thang.type/53e3f500ae44ec37059f9415/portrait.png', kind: 'Doodad'} + {slug: 'sky-span-background-4', name: 'Sky Span Background 4', original: '53e3f5dbae44ec37059f944a', portraitURL: '/file/db/thang.type/53e3f5dbae44ec37059f944a/portrait.png', kind: 'Doodad'} + {slug: 'sky-span-background-5', name: 'Sky Span Background 5', original: '53e3f646d12e873205b72abd', portraitURL: '/file/db/thang.type/53e3f646d12e873205b72abd/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-6', name: 'Sky Span Background 6', original: '53e3f724d12e873205b72af9', portraitURL: '/file/db/thang.type/53e3f724d12e873205b72af9/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-7', name: 'Sky Span Background 7', original: '53e3f74dae44ec37059f94b3', portraitURL: '/file/db/thang.type/53e3f74dae44ec37059f94b3/portrait.png', kind: 'Doodad'} + {slug: 'sleep', name: 'Sleep', original: '5302504b222f73867774d7a1', portraitURL: '/file/db/thang.type/5302504b222f73867774d7a1/portrait.png', kind: 'Mark'} + {slug: 'slow', name: 'Slow', original: '5302511327471514685d5405', portraitURL: '/file/db/thang.type/5302511327471514685d5405/portrait.png', kind: 'Mark'} + {slug: 'snake', name: 'Snake', original: '548cf57c0f559d0000be7e5f', portraitURL: '/file/db/thang.type/548cf57c0f559d0000be7e5f/portrait.png', kind: 'Doodad'} + {slug: 'snake-pillar', name: 'Snake Pillar', original: '54ef8db1223edd8105aff2b9', portraitURL: '/file/db/thang.type/54ef8db1223edd8105aff2b9/portrait.png', kind: 'Doodad'} + {slug: 'soft-leather-gloves', name: 'Soft Leather Gloves', original: '546948e9a2b1f53ce7944425', portraitURL: '/file/db/thang.type/546948e9a2b1f53ce7944425/portrait.png', kind: 'Item'} + {slug: 'softened-leather-boots', name: 'Softened Leather Boots', original: '546d4d589df4a17d0d449ac9', portraitURL: '/file/db/thang.type/546d4d589df4a17d0d449ac9/portrait.png', kind: 'Item'} + {slug: 'sparkbomb', name: 'Sparkbomb', original: '54eb528449fa2d5c905ddf12', portraitURL: '/file/db/thang.type/54eb528449fa2d5c905ddf12/portrait.png', kind: 'Item'} + {slug: 'sparkbomb-missile', name: 'Sparkbomb Missile', original: '5535b3bd428ddac5686fcf7a', portraitURL: '/file/db/thang.type/5535b3bd428ddac5686fcf7a/portrait.png', kind: 'Missile'} + {slug: 'spear', name: 'Spear', original: '52ba2affd68e4b7c48000030', portraitURL: '/file/db/thang.type/52ba2affd68e4b7c48000030/portrait.png', kind: 'Missile'} + {slug: 'spider', name: 'Spider', original: '55c1353bc87e47c60604f997', portraitURL: '/file/db/thang.type/55c1353bc87e47c60604f997/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-1', name: 'Spiderweb 1', original: '54ef7c69223edd8105afc1f4', portraitURL: '/file/db/thang.type/54ef7c69223edd8105afc1f4/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-2', name: 'Spiderweb 2', original: '54ef7d41ace2147e058655a8', portraitURL: '/file/db/thang.type/54ef7d41ace2147e058655a8/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-3', name: 'Spiderweb 3', original: '54ef7ed7b4740779058410c9', portraitURL: '/file/db/thang.type/54ef7ed7b4740779058410c9/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-4', name: 'Spiderweb 4', original: '54ef8938ace2147e05867d6d', portraitURL: '/file/db/thang.type/54ef8938ace2147e05867d6d/portrait.png', kind: 'Doodad'} + {slug: 'spike-walls', name: 'Spike Walls', original: '5422f63718adb78d98d265f7', portraitURL: '/file/db/thang.type/5422f63718adb78d98d265f7/portrait.png', kind: 'Doodad'} + {slug: 'spiked-ogre-wall', name: 'spiked ogre wall', original: '578682dccca8994b002708eb', portraitURL: '/file/db/thang.type/578682dccca8994b002708eb/portrait.png', kind: 'Doodad'} + {slug: 'stalactite-1', name: 'Stalactite 1', original: '55760f0a1e82182d9e688912', portraitURL: '/file/db/thang.type/55760f0a1e82182d9e688912/portrait.png', kind: 'Doodad'} + {slug: 'stalactite-2', name: 'Stalactite 2', original: '55760f4a1e82182d9e688916', portraitURL: '/file/db/thang.type/55760f4a1e82182d9e688916/portrait.png', kind: 'Doodad'} + {slug: 'stalactite-3', name: 'Stalactite 3', original: '55760f6f1e82182d9e68891a', portraitURL: '/file/db/thang.type/55760f6f1e82182d9e68891a/portrait.png', kind: 'Doodad'} + {slug: 'stalagmite-1', name: 'Stalagmite 1', original: '55760e6f1e82182d9e688906', portraitURL: '/file/db/thang.type/55760e6f1e82182d9e688906/portrait.png', kind: 'Doodad'} + {slug: 'stalagmite-2', name: 'Stalagmite 2', original: '55760eb61e82182d9e68890a', portraitURL: '/file/db/thang.type/55760eb61e82182d9e68890a/portrait.png', kind: 'Doodad'} + {slug: 'stalagmite-3', name: 'Stalagmite 3', original: '55760ee51e82182d9e68890e', portraitURL: '/file/db/thang.type/55760ee51e82182d9e68890e/portrait.png', kind: 'Doodad'} + {slug: 'statue-stone-hooded', name: 'Statue Stone Hooded', original: '546e23469df4a17d0d449ba9', portraitURL: '/file/db/thang.type/546e23469df4a17d0d449ba9/portrait.png', kind: 'Doodad'} + {slug: 'steel-breastplate', name: 'Steel Breastplate', original: '546ab0a83777d61863292862', portraitURL: '/file/db/thang.type/546ab0a83777d61863292862/portrait.png', kind: 'Item'} + {slug: 'steel-helmet', name: 'Steel Helmet', original: '5441c2ed4e9aeb727cc9710b', portraitURL: '/file/db/thang.type/5441c2ed4e9aeb727cc9710b/portrait.png', kind: 'Item'} + {slug: 'steel-ring', name: 'Steel Ring', original: '54692dbca2b1f53ce794439b', portraitURL: '/file/db/thang.type/54692dbca2b1f53ce794439b/portrait.png', kind: 'Item'} + {slug: 'steel-shield', name: 'Steel Shield', original: '544d7bec8494308424f56497', portraitURL: '/file/db/thang.type/544d7bec8494308424f56497/portrait.png', kind: 'Item'} + {slug: 'steel-striker', name: 'Steel Striker', original: '544d7c948494308424f5649f', portraitURL: '/file/db/thang.type/544d7c948494308424f5649f/portrait.png', kind: 'Item'} + {slug: 'steel-wand', name: 'Steel Wand', original: '544d88e48494308424f56511', portraitURL: '/file/db/thang.type/544d88e48494308424f56511/portrait.png', kind: 'Item'} + {slug: 'stiff-lambswool-hat', name: 'Stiff Lambswool Hat', original: '546d4b379df4a17d0d449aa7', portraitURL: '/file/db/thang.type/546d4b379df4a17d0d449aa7/portrait.png', kind: 'Item'} + {slug: 'stone-builders-hammer', name: 'Stone Builder\'s Hammer', original: '54694bcca2b1f53ce7944451', portraitURL: '/file/db/thang.type/54694bcca2b1f53ce7944451/portrait.png', kind: 'Item'} + {slug: 'stone-fall-1', name: 'Stone Fall 1', original: '53e2e5046f406a3505b3ead6', portraitURL: '/file/db/thang.type/53e2e5046f406a3505b3ead6/portrait.png', kind: 'Doodad'} + {slug: 'stone-fall-2', name: 'Stone Fall 2', original: '53e2e66d6c59f534050410d0', portraitURL: '/file/db/thang.type/53e2e66d6c59f534050410d0/portrait.png', kind: 'Doodad'} + {slug: 'stone-fall-3', name: 'Stone Fall 3', original: '53e2e728ae44ec37059f2438', portraitURL: '/file/db/thang.type/53e2e728ae44ec37059f2438/portrait.png', kind: 'Doodad'} + {slug: 'stone-pillars', name: 'stone pillars', original: '572d5958f5da8e29013e4e8d', portraitURL: '/file/db/thang.type/572d5958f5da8e29013e4e8d/portrait.png', kind: 'Doodad'} + {slug: 'stone-statue', name: 'Stone Statue', original: '546e25479df4a17d0d449bd5', portraitURL: '/file/db/thang.type/546e25479df4a17d0d449bd5/portrait.png', kind: 'Doodad'} + {slug: 'stormbringer', name: 'Stormbringer', original: '54ea87342b7506e891ca7175', portraitURL: '/file/db/thang.type/54ea87342b7506e891ca7175/portrait.png', kind: 'Item'} + {slug: 'stretched-hide', name: 'Stretched Hide', original: '557608901e82182d9e6888ce', portraitURL: '/file/db/thang.type/557608901e82182d9e6888ce/portrait.png', kind: 'Doodad'} + {slug: 'student-a', name: 'Student A', original: '56d0edd0441ddd2f002ba5aa', portraitURL: '/file/db/thang.type/56d0edd0441ddd2f002ba5aa/portrait.png', kind: 'Unit'} + {slug: 'student-b', name: 'Student B', original: '56d0efc14292981f009f51de', portraitURL: '/file/db/thang.type/56d0efc14292981f009f51de/portrait.png', kind: 'Unit'} + {slug: 'stump-1', name: 'Stump 1', original: '54e955f6f54ef5794f354f09', portraitURL: '/file/db/thang.type/54e955f6f54ef5794f354f09/portrait.png', kind: 'Doodad'} + {slug: 'stump-2', name: 'Stump 2', original: '54e95634f54ef5794f354f0d', portraitURL: '/file/db/thang.type/54e95634f54ef5794f354f0d/portrait.png', kind: 'Doodad'} + {slug: 'stump-3', name: 'Stump 3', original: '557f91f9b43ce0b15a91b1cd', portraitURL: '/file/db/thang.type/557f91f9b43ce0b15a91b1cd/portrait.png', kind: 'Doodad'} + {slug: 'stump-4', name: 'Stump 4', original: '557f923eb43ce0b15a91b1d1', portraitURL: '/file/db/thang.type/557f923eb43ce0b15a91b1d1/portrait.png', kind: 'Doodad'} + {slug: 'stump-5', name: 'Stump 5', original: '557f925ab43ce0b15a91b1d5', portraitURL: '/file/db/thang.type/557f925ab43ce0b15a91b1d5/portrait.png', kind: 'Doodad'} + {slug: 'sturdy-bronze-shield', name: 'Sturdy Bronze Shield', original: '544d7b028494308424f5648b', portraitURL: '/file/db/thang.type/544d7b028494308424f5648b/portrait.png', kind: 'Item'} + {slug: 'sulphur-staff', name: 'Sulphur Staff', original: '54eab7132b7506e891ca71fa', portraitURL: '/file/db/thang.type/54eab7132b7506e891ca71fa/portrait.png', kind: 'Item'} + {slug: 'sundial-wristwatch', name: 'Sundial Wristwatch', original: '53e2396a53457600003e3f0f', portraitURL: '/file/db/thang.type/53e2396a53457600003e3f0f/portrait.png', kind: 'Item'} + {slug: 'sword', name: 'Sword', original: '52bcda141f766a891c00000a', portraitURL: '/file/db/thang.type/52bcda141f766a891c00000a/portrait.png', kind: 'Misc'} + {slug: 'sword-belt', name: 'Sword Belt', original: '5441beb74e9aeb727cc970d3', portraitURL: '/file/db/thang.type/5441beb74e9aeb727cc970d3/portrait.png', kind: 'Item'} + {slug: 'sword-fall-1', name: 'Sword Fall 1', original: '53e2e8a7d12e873205b6c0f1', portraitURL: '/file/db/thang.type/53e2e8a7d12e873205b6c0f1/portrait.png', kind: 'Doodad'} + {slug: 'sword-fall-2', name: 'Sword Fall 2', original: '53e2e9a9ae44ec37059f2571', portraitURL: '/file/db/thang.type/53e2e9a9ae44ec37059f2571/portrait.png', kind: 'Doodad'} + {slug: 'sword-of-the-forgotten', name: 'Sword of the Forgotten', original: '54eaaa522b7506e891ca71b9', portraitURL: '/file/db/thang.type/54eaaa522b7506e891ca71b9/portrait.png', kind: 'Item'} + {slug: 'sword-of-the-temple-guard', name: 'Sword of the Temple Guard', original: '54eaab372b7506e891ca71c1', portraitURL: '/file/db/thang.type/54eaab372b7506e891ca71c1/portrait.png', kind: 'Item'} + {slug: 'table', name: 'Table', original: '52e9987a427172ae56001ffd', portraitURL: '/file/db/thang.type/52e9987a427172ae56001ffd/portrait.png', kind: 'Doodad'} + {slug: 'tailored-linen-robe', name: 'Tailored Linen Robe', original: '546d49759df4a17d0d449a87', portraitURL: '/file/db/thang.type/546d49759df4a17d0d449a87/portrait.png', kind: 'Item'} + {slug: 'talus-1', name: 'Talus 1', original: '54e944a3f54ef5794f354ea9', portraitURL: '/file/db/thang.type/54e944a3f54ef5794f354ea9/portrait.png', kind: 'Floor'} + {slug: 'talus-2', name: 'Talus 2', original: '54e94880f54ef5794f354ead', portraitURL: '/file/db/thang.type/54e94880f54ef5794f354ead/portrait.png', kind: 'Floor'} + {slug: 'talus-3', name: 'Talus 3', original: '54e948daf54ef5794f354eb1', portraitURL: '/file/db/thang.type/54e948daf54ef5794f354eb1/portrait.png', kind: 'Floor'} + {slug: 'talus-4', name: 'Talus 4', original: '54e94908f54ef5794f354eb5', portraitURL: '/file/db/thang.type/54e94908f54ef5794f354eb5/portrait.png', kind: 'Floor'} + {slug: 'talus-5', name: 'Talus 5', original: '54e9493cf54ef5794f354eb9', portraitURL: '/file/db/thang.type/54e9493cf54ef5794f354eb9/portrait.png', kind: 'Floor'} + {slug: 'talus-6', name: 'Talus 6', original: '54e94965f54ef5794f354ebd', portraitURL: '/file/db/thang.type/54e94965f54ef5794f354ebd/portrait.png', kind: 'Floor'} + {slug: 'tarnished-bronze-breastplate', name: 'Tarnished Bronze Breastplate', original: '53e22eac53457600003e3efc', portraitURL: '/file/db/thang.type/53e22eac53457600003e3efc/portrait.png', kind: 'Item'} + {slug: 'tarnished-bronze-helmet', name: 'Tarnished Bronze Helmet', original: '546d38269df4a17d0d4499ff', portraitURL: '/file/db/thang.type/546d38269df4a17d0d4499ff/portrait.png', kind: 'Item'} + {slug: 'tarnished-copper-band', name: 'Tarnished Copper Band', original: '54692a75a2b1f53ce7944387', portraitURL: '/file/db/thang.type/54692a75a2b1f53ce7944387/portrait.png', kind: 'Item'} + {slug: 'tauran-helm', name: 'Tauran Helm', original: '54ea49982b7506e891ca7165', portraitURL: '/file/db/thang.type/54ea49982b7506e891ca7165/portrait.png', kind: 'Item'} + {slug: 'tauran-plate', name: 'Tauran Plate', original: '54ea4b302b7506e891ca716d', portraitURL: '/file/db/thang.type/54ea4b302b7506e891ca716d/portrait.png', kind: 'Item'} + {slug: 'teacher-b', name: 'Teacher B', original: '56de0554d048927700b4f741', portraitURL: '/file/db/thang.type/56de0554d048927700b4f741/portrait.png', kind: 'Doodad'} + {slug: 'tent-1', name: 'Tent 1', original: '548cf2280f559d0000be7e37', portraitURL: '/file/db/thang.type/548cf2280f559d0000be7e37/portrait.png', kind: 'Doodad'} + {slug: 'tent-2', name: 'Tent 2', original: '548cf2b10f559d0000be7e3b', portraitURL: '/file/db/thang.type/548cf2b10f559d0000be7e3b/portrait.png', kind: 'Doodad'} + {slug: 'tent-3', name: 'Tent 3', original: '548cf30b0f559d0000be7e3f', portraitURL: '/file/db/thang.type/548cf30b0f559d0000be7e3f/portrait.png', kind: 'Doodad'} + {slug: 'the-final-kithmaze-background', name: 'the final kithmaze background', original: '577ecc2b67053f25007eb916', portraitURL: '/file/db/thang.type/577ecc2b67053f25007eb916/portrait.png', kind: 'Floor'} + {slug: 'the-gauntlet-background', name: 'The Gauntlet Background', original: '572d631812f2abce00164c15', portraitURL: '/file/db/thang.type/572d631812f2abce00164c15/portrait.png', kind: 'Floor'} + {slug: 'the-monolith', name: 'The Monolith', original: '54eabcb72b7506e891ca7226', portraitURL: '/file/db/thang.type/54eabcb72b7506e891ca7226/portrait.png', kind: 'Item'} + {slug: 'the-precious', name: 'The Precious', original: '54eb56ae49fa2d5c905ddf2a', portraitURL: '/file/db/thang.type/54eb56ae49fa2d5c905ddf2a/portrait.png', kind: 'Item'} + {slug: 'thick-burlap-robe', name: 'Thick Burlap Robe', original: '546d48989df4a17d0d449a77', portraitURL: '/file/db/thang.type/546d48989df4a17d0d449a77/portrait.png', kind: 'Item'} + {slug: 'thin-burlap-robe', name: 'Thin Burlap Robe', original: '546d485b9df4a17d0d449a73', portraitURL: '/file/db/thang.type/546d485b9df4a17d0d449a73/portrait.png', kind: 'Item'} + {slug: 'thoktars-discarded-hammer', name: 'Thoktar\'s Discarded Hammer', original: '54694cd6a2b1f53ce7944466', portraitURL: '/file/db/thang.type/54694cd6a2b1f53ce7944466/portrait.png', kind: 'Item'} + {slug: 'thornprick', name: 'Thornprick', original: '54692e75a2b1f53ce79443a7', portraitURL: '/file/db/thang.type/54692e75a2b1f53ce79443a7/portrait.png', kind: 'Item'} + {slug: 'threadbare-burlap-wizards-hat', name: 'Threadbare Burlap Wizards Hat', original: '546d4a909df4a17d0d449a9b', portraitURL: '/file/db/thang.type/546d4a909df4a17d0d449a9b/portrait.png', kind: 'Item'} + {slug: 'throne', name: 'Throne', original: '54efa174933e1e7b05846fe6', portraitURL: '/file/db/thang.type/54efa174933e1e7b05846fe6/portrait.png', kind: 'Doodad'} + {slug: 'tomb-ring', name: 'Tomb Ring', original: '54eb55d849fa2d5c905ddf26', portraitURL: '/file/db/thang.type/54eb55d849fa2d5c905ddf26/portrait.png', kind: 'Item'} + {slug: 'tool-belt', name: 'Tool Belt', original: '5441beff4e9aeb727cc970d9', portraitURL: '/file/db/thang.type/5441beff4e9aeb727cc970d9/portrait.png', kind: 'Item'} + {slug: 'torch', name: 'Torch', original: '52aa608b20fccb0000000005', portraitURL: '/file/db/thang.type/52aa608b20fccb0000000005/portrait.png', kind: 'Doodad'} + {slug: 'torn-silk-cloak', name: 'Torn Silk Cloak', original: '546d49a79df4a17d0d449a8b', portraitURL: '/file/db/thang.type/546d49a79df4a17d0d449a8b/portrait.png', kind: 'Item'} + {slug: 'torture-table', name: 'Torture Table', original: '54ef8fd4b474077905843564', portraitURL: '/file/db/thang.type/54ef8fd4b474077905843564/portrait.png', kind: 'Doodad'} + {slug: 'tower-ruined', name: 'Tower Ruined', original: '54f117c548724e7d052b540b', portraitURL: '/file/db/thang.type/54f117c548724e7d052b540b/portrait.png', kind: 'Doodad'} + {slug: 'training-dummy', name: 'Training Dummy', original: '53e65923bc5cc012113e07b1', portraitURL: '/file/db/thang.type/53e65923bc5cc012113e07b1/portrait.png', kind: 'Doodad'} + {slug: 'trap-belt', name: 'Trap Belt', original: '54694a8fa2b1f53ce7944439', portraitURL: '/file/db/thang.type/54694a8fa2b1f53ce7944439/portrait.png', kind: 'Item'} + {slug: 'treasure-chest', name: 'Treasure Chest', original: '52aa3be0ccbd588d4d000005', portraitURL: '/file/db/thang.type/52aa3be0ccbd588d4d000005/portrait.png', kind: 'Doodad'} + {slug: 'tree-1', name: 'Tree 1', original: '52b09ef7ccbc67137200000f', portraitURL: '/file/db/thang.type/52b09ef7ccbc67137200000f/portrait.png', kind: 'Doodad'} + {slug: 'tree-2', name: 'Tree 2', original: '52b09fdeccbc671372000011', portraitURL: '/file/db/thang.type/52b09fdeccbc671372000011/portrait.png', kind: 'Doodad'} + {slug: 'tree-3', name: 'Tree 3', original: '52b0a04fccbc671372000013', portraitURL: '/file/db/thang.type/52b0a04fccbc671372000013/portrait.png', kind: 'Doodad'} + {slug: 'tree-4', name: 'Tree 4', original: '52b0a0a5ccbc671372000015', portraitURL: '/file/db/thang.type/52b0a0a5ccbc671372000015/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-1', name: 'Tree Stand 1', original: '541cc7c48e78524aad94de7d', portraitURL: '/file/db/thang.type/541cc7c48e78524aad94de7d/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-2', name: 'Tree Stand 2', original: '542068f38e78524aad94de83', portraitURL: '/file/db/thang.type/542068f38e78524aad94de83/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-3', name: 'Tree Stand 3', original: '5420693d8e78524aad94de89', portraitURL: '/file/db/thang.type/5420693d8e78524aad94de89/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-4', name: 'Tree Stand 4', original: '542069888e78524aad94de8f', portraitURL: '/file/db/thang.type/542069888e78524aad94de8f/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-5', name: 'Tree Stand 5', original: '542092628e78524aad94deca', portraitURL: '/file/db/thang.type/542092628e78524aad94deca/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-6', name: 'Tree Stand 6', original: '542092c38e78524aad94ded0', portraitURL: '/file/db/thang.type/542092c38e78524aad94ded0/portrait.png', kind: 'Doodad'} + {slug: 'true-names-background', name: 'True Names Background', original: '55e451bd206f7df7df6ba966', portraitURL: '/file/db/thang.type/55e451bd206f7df7df6ba966/portrait.png', kind: 'Floor'} + {slug: 'twilight-glasses', name: 'Twilight Glasses', original: '546941fda2b1f53ce794441d', portraitURL: '/file/db/thang.type/546941fda2b1f53ce794441d/portrait.png', kind: 'Item'} + {slug: 'twisted-pine-wand', name: 'Twisted Pine Wand', original: '544d877d8494308424f564f9', portraitURL: '/file/db/thang.type/544d877d8494308424f564f9/portrait.png', kind: 'Item'} + {slug: 'undead', name: 'Undead', original: '55c284933767fd3435eb4471', portraitURL: '/file/db/thang.type/55c284933767fd3435eb4471/portrait.png', kind: 'Mark'} + {slug: 'undergrowth-dagger', name: 'Undergrowth Dagger', original: '544d95e68494308424f5652b', portraitURL: '/file/db/thang.type/544d95e68494308424f5652b/portrait.png', kind: 'Item'} + {slug: 'undergrowth-dagger-missile', name: 'Undergrowth Dagger Missile', original: '544d99618494308424f56541', portraitURL: '/file/db/thang.type/544d99618494308424f56541/portrait.png', kind: 'Missile'} + {slug: 'undying-ring', name: 'Undying Ring', original: '54eb54d349fa2d5c905ddf1e', portraitURL: '/file/db/thang.type/54eb54d349fa2d5c905ddf1e/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-i', name: 'Unholy Tome I', original: '546374bc3839c6e02811d308', portraitURL: '/file/db/thang.type/546374bc3839c6e02811d308/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-ii', name: 'Unholy Tome II', original: '5463756f3839c6e02811d30c', portraitURL: '/file/db/thang.type/5463756f3839c6e02811d30c/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-iii', name: 'Unholy Tome III', original: '5463758f3839c6e02811d30f', portraitURL: '/file/db/thang.type/5463758f3839c6e02811d30f/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-iv', name: 'Unholy Tome IV', original: '546376b63839c6e02811d31b', portraitURL: '/file/db/thang.type/546376b63839c6e02811d31b/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-v', name: 'Unholy Tome V', original: '546376da3839c6e02811d31e', portraitURL: '/file/db/thang.type/546376da3839c6e02811d31e/portrait.png', kind: 'Item'} + {slug: 'viking-helmet', name: 'Viking Helmet', original: '5441c3144e9aeb727cc97111', portraitURL: '/file/db/thang.type/5441c3144e9aeb727cc97111/portrait.png', kind: 'Item'} + {slug: 'viking-helmet-doodad', name: 'Viking Helmet Doodad', original: '5518239d1f12482609b44f76', portraitURL: '/file/db/thang.type/5518239d1f12482609b44f76/portrait.png', kind: 'Doodad'} + {slug: 'vine-staff', name: 'Vine Staff', original: '54eab92b2b7506e891ca720a', portraitURL: '/file/db/thang.type/54eab92b2b7506e891ca720a/portrait.png', kind: 'Item'} + {slug: 'volcano', name: 'Volcano', original: '55c64512ef141c65665beb7e', portraitURL: '/file/db/thang.type/55c64512ef141c65665beb7e/portrait.png', kind: 'Doodad'} + {slug: 'vr-artist', name: 'VR Artist', original: '56d0c6bf087ee32400763d49', portraitURL: '/file/db/thang.type/56d0c6bf087ee32400763d49/portrait.png', kind: 'Unit'} + {slug: 'vr-breaker', name: 'VR Breaker', original: '56d0e6e563103d2a00af5795', portraitURL: '/file/db/thang.type/56d0e6e563103d2a00af5795/portrait.png', kind: 'Unit'} + {slug: 'vr-door', name: 'VR Door', original: '56aa6bf503ec4e2000878867', portraitURL: '/file/db/thang.type/56aa6bf503ec4e2000878867/portrait.png', kind: 'Doodad'} + {slug: 'vr-floor', name: 'VR Floor', original: '56a2e305b0b7242000e9986e', portraitURL: '/file/db/thang.type/56a2e305b0b7242000e9986e/portrait.png', kind: 'Floor'} + {slug: 'vr-oracle', name: 'VR Oracle', original: '56d0d144a7daf22000023a13', portraitURL: '/file/db/thang.type/56d0d144a7daf22000023a13/portrait.png', kind: 'Unit'} + {slug: 'vr-security', name: 'VR Security', original: '56d758b787781b1f00cf4b20', portraitURL: '/file/db/thang.type/56d758b787781b1f00cf4b20/portrait.png', kind: 'Unit'} + {slug: 'vr-tinker', name: 'VR Tinker', original: '56d07d682a1e1736005b1b37', portraitURL: '/file/db/thang.type/56d07d682a1e1736005b1b37/portrait.png', kind: 'Unit'} + {slug: 'vr-wall', name: 'vr-wall', original: '56b0c75302b7db290079b542', portraitURL: '/file/db/thang.type/56b0c75302b7db290079b542/portrait.png', kind: 'Wall'} + {slug: 'vr-wyrm', name: 'VR Wyrm', original: '56bb944d203af82000b2a406', portraitURL: '/file/db/thang.type/56bb944d203af82000b2a406/portrait.png', kind: 'Unit'} + {slug: 'wagon-broken', name: 'Wagon Broken', original: '548cf1cd0f559d0000be7e33', portraitURL: '/file/db/thang.type/548cf1cd0f559d0000be7e33/portrait.png', kind: 'Doodad'} + {slug: 'wakka-maul-background', name: 'Wakka Maul Background', original: '5654eae2f9285e86053f7504', portraitURL: '/file/db/thang.type/5654eae2f9285e86053f7504/portrait.png', kind: 'Floor'} + {slug: 'warcry', name: 'Warcry', original: '53024777222f73867774d6cd', portraitURL: '/file/db/thang.type/53024777222f73867774d6cd/portrait.png', kind: 'Mark'} + {slug: 'waterfall', name: 'Waterfall', original: '53e2eaffae44ec37059f262a', portraitURL: '/file/db/thang.type/53e2eaffae44ec37059f262a/portrait.png', kind: 'Doodad'} + {slug: 'weak-charge', name: 'Weak Charge', original: '544d957d8494308424f5651f', portraitURL: '/file/db/thang.type/544d957d8494308424f5651f/portrait.png', kind: 'Item'} + {slug: 'weak-charge-missile', name: 'Weak Charge Missile', original: '544d97798494308424f5653b', portraitURL: '/file/db/thang.type/544d97798494308424f5653b/portrait.png', kind: 'Missile'} + {slug: 'weighted-throwing-knives', name: 'Weighted Throwing Knives', original: '544d96108494308424f5652f', portraitURL: '/file/db/thang.type/544d96108494308424f5652f/portrait.png', kind: 'Item'} + {slug: 'weighted-throwing-knives-missile', name: 'Weighted Throwing Knives Missile', original: '544d99b98494308424f56545', portraitURL: '/file/db/thang.type/544d99b98494308424f56545/portrait.png', kind: 'Missile'} + {slug: 'well', name: 'Well', original: '52b094cbccbc671372000004', portraitURL: '/file/db/thang.type/52b094cbccbc671372000004/portrait.png', kind: 'Doodad'} + {slug: 'white-deerhide-gloves', name: 'White Deerhide Gloves', original: '54694936a2b1f53ce7944429', portraitURL: '/file/db/thang.type/54694936a2b1f53ce7944429/portrait.png', kind: 'Item'} + {slug: 'windwalker-coif', name: 'Windwalker Coif', original: '54ea48512b7506e891ca7157', portraitURL: '/file/db/thang.type/54ea48512b7506e891ca7157/portrait.png', kind: 'Item'} + {slug: 'windwalker-mail', name: 'Windwalker Mail', original: '54ea46092b7506e891ca7143', portraitURL: '/file/db/thang.type/54ea46092b7506e891ca7143/portrait.png', kind: 'Item'} + {slug: 'winged-boots', name: 'Winged Boots', original: '546d4e5c9df4a17d0d449ad9', portraitURL: '/file/db/thang.type/546d4e5c9df4a17d0d449ad9/portrait.png', kind: 'Item'} + {slug: 'wizard-bird-f', name: 'Wizard Bird F', original: '52fc0c9e7e01835453bd8ef8', portraitURL: '/file/db/thang.type/52fc0c9e7e01835453bd8ef8/portrait.png', kind: 'Unit'} + {slug: 'wizard-bird-m', name: 'Wizard Bird M', original: '52fd015f3a58c6c50fcf4782', portraitURL: '/file/db/thang.type/52fd015f3a58c6c50fcf4782/portrait.png', kind: 'Unit'} + {slug: 'wizard-doctor', name: 'Wizard Doctor', original: '52fc04fbab6e45c813bc7ced', portraitURL: '/file/db/thang.type/52fc04fbab6e45c813bc7ced/portrait.png', kind: 'Unit'} + {slug: 'wizard-dude', name: 'Wizard Dude', original: '53e126a4e06b897606d38bef', portraitURL: '/file/db/thang.type/53e126a4e06b897606d38bef/portrait.png', kind: 'Unit'} + {slug: 'wizard-hermes', name: 'Wizard Hermes', original: '52fc09daab6e45c813bc7d52', portraitURL: '/file/db/thang.type/52fc09daab6e45c813bc7d52/portrait.png', kind: 'Unit'} + {slug: 'wizard-knight', name: 'Wizard Knight', original: '52fc00ffab6e45c813bc7cb2', portraitURL: '/file/db/thang.type/52fc00ffab6e45c813bc7cb2/portrait.png', kind: 'Unit'} + {slug: 'wizard-ninja-m', name: 'Wizard Ninja M', original: '52fd04aff0cd954d619a9a4c', portraitURL: '/file/db/thang.type/52fd04aff0cd954d619a9a4c/portrait.png', kind: 'Unit'} + {slug: 'wizard-overseer-f', name: 'Wizard Overseer F', original: '52fc11fbb2b91c0d5a7b6a14', portraitURL: '/file/db/thang.type/52fc11fbb2b91c0d5a7b6a14/portrait.png', kind: 'Unit'} + {slug: 'wizard-overseer-m', name: 'Wizard Overseer M', original: '52fd0728ccb2653821eaf8b0', portraitURL: '/file/db/thang.type/52fd0728ccb2653821eaf8b0/portrait.png', kind: 'Unit'} + {slug: 'wizard-purple', name: 'Wizard Purple', original: '52fd0e16c7e6cf99160e7b6a', portraitURL: '/file/db/thang.type/52fd0e16c7e6cf99160e7b6a/portrait.png', kind: 'Unit'} + {slug: 'wizard-spine', name: 'Wizard Spine', original: '52fcfed63a58c6c50fcf4732', portraitURL: '/file/db/thang.type/52fcfed63a58c6c50fcf4732/portrait.png', kind: 'Unit'} + {slug: 'wizard-spine-m', name: 'Wizard Spine M', original: '52fd0c70f0cd954d619a9b10', portraitURL: '/file/db/thang.type/52fd0c70f0cd954d619a9b10/portrait.png', kind: 'Unit'} + {slug: 'wizard-thorn-f', name: 'Wizard Thorn F', original: '52fc1460b2b91c0d5a7b6af3', portraitURL: '/file/db/thang.type/52fc1460b2b91c0d5a7b6af3/portrait.png', kind: 'Unit'} + {slug: 'wizard-thorn-m', name: 'Wizard Thorn M', original: '52fd0a40f0cd954d619a9ad7', portraitURL: '/file/db/thang.type/52fd0a40f0cd954d619a9ad7/portrait.png', kind: 'Unit'} + {slug: 'wizard-top-hat', name: 'Wizard Top Hat', original: '52fd124accb2653821eaf991', portraitURL: '/file/db/thang.type/52fd124accb2653821eaf991/portrait.png', kind: 'Unit'} + {slug: 'wooden-builders-hammer', name: 'Wooden Builder\'s Hammer', original: '54694ba3a2b1f53ce794444d', portraitURL: '/file/db/thang.type/54694ba3a2b1f53ce794444d/portrait.png', kind: 'Item'} + {slug: 'wooden-glasses', name: 'Wooden Glasses', original: '53e2167653457600003e3eb3', portraitURL: '/file/db/thang.type/53e2167653457600003e3eb3/portrait.png', kind: 'Item'} + {slug: 'wooden-shield', name: 'Wooden Shield', original: '53e22aa153457600003e3ef5', portraitURL: '/file/db/thang.type/53e22aa153457600003e3ef5/portrait.png', kind: 'Item'} + {slug: 'wooden-strand', name: 'Wooden Strand', original: '54692e3ea2b1f53ce79443a3', portraitURL: '/file/db/thang.type/54692e3ea2b1f53ce79443a3/portrait.png', kind: 'Item'} + {slug: 'workers-gloves', name: 'Worker\'s Gloves', original: '5469425ca2b1f53ce7944421', portraitURL: '/file/db/thang.type/5469425ca2b1f53ce7944421/portrait.png', kind: 'Item'} + {slug: 'worn-dragonplate', name: 'Worn Dragonplate', original: '546ab1a13777d61863292872', portraitURL: '/file/db/thang.type/546ab1a13777d61863292872/portrait.png', kind: 'Item'} + {slug: 'worn-dragonplate-helmet', name: 'Worn Dragonplate Helmet', original: '546d3a199df4a17d0d449a1b', portraitURL: '/file/db/thang.type/546d3a199df4a17d0d449a1b/portrait.png', kind: 'Item'} + {slug: 'worn-dragonshield', name: 'Worn Dragonshield', original: '54eabd662b7506e891ca722e', portraitURL: '/file/db/thang.type/54eabd662b7506e891ca722e/portrait.png', kind: 'Item'} + {slug: 'wyrm2', name: 'wyrm2', original: '56c32fd1807b9f36005e5fd0', portraitURL: '/file/db/thang.type/56c32fd1807b9f36005e5fd0/portrait.png', kind: 'Unit'} + {slug: 'wyvernclaw', name: 'Wyvernclaw', original: '54ea35fd2b7506e891ca70d5', portraitURL: '/file/db/thang.type/54ea35fd2b7506e891ca70d5/portrait.png', kind: 'Item'} + {slug: 'x-mark-bones', name: 'X Mark Bones', original: '54938352e9850ae3e8fbdd64', portraitURL: '/file/db/thang.type/54938352e9850ae3e8fbdd64/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-forest', name: 'X Mark Forest', original: '549381a7e9850ae3e8fbdd60', portraitURL: '/file/db/thang.type/549381a7e9850ae3e8fbdd60/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-red', name: 'X Mark Red', original: '5493844be9850ae3e8fbdd70', portraitURL: '/file/db/thang.type/5493844be9850ae3e8fbdd70/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-stone', name: 'X Mark Stone', original: '549383aae9850ae3e8fbdd68', portraitURL: '/file/db/thang.type/549383aae9850ae3e8fbdd68/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-wood', name: 'X Mark Wood', original: '54938408e9850ae3e8fbdd6c', portraitURL: '/file/db/thang.type/54938408e9850ae3e8fbdd6c/portrait.png', kind: 'Doodad'} + {slug: 'x-marker', name: 'X Marker', original: '5452ec9f06a59e000067e518', portraitURL: '/file/db/thang.type/5452ec9f06a59e000067e518/portrait.png', kind: 'Doodad'} + {slug: 'x-ray-goggles', name: 'X-Ray Goggles', original: '53e2392453457600003e3f0d', portraitURL: '/file/db/thang.type/53e2392453457600003e3f0d/portrait.png', kind: 'Item'} + {slug: 'yeti', name: 'Yeti', original: '54e91dc5970f0b0a263c03de', portraitURL: '/file/db/thang.type/54e91dc5970f0b0a263c03de/portrait.png', kind: 'Unit'} + {slug: 'yeti-cave', name: 'Yeti Cave', original: '557f8f84b43ce0b15a91b1c7', portraitURL: '/file/db/thang.type/557f8f84b43ce0b15a91b1c7/portrait.png', kind: 'Doodad'} + {slug: 'yeti-skin', name: 'Yeti Skin', original: '557f370ab43ce0b15a91b171', portraitURL: '/file/db/thang.type/557f370ab43ce0b15a91b171/portrait.png', kind: 'Doodad'} + ] diff --git a/app/views/play/level/modal/ProgressView.coffee b/app/views/play/level/modal/ProgressView.coffee index 552d28fe0..0c35c61da 100644 --- a/app/views/play/level/modal/ProgressView.coffee +++ b/app/views/play/level/modal/ProgressView.coffee @@ -10,6 +10,8 @@ module.exports = class ProgressView extends CocoView events: 'click #done-btn': 'onClickDoneButton' 'click #next-level-btn': 'onClickNextLevelButton' + 'click #ladder-btn': 'onClickLadderButton' + 'click #share-level-btn': 'onClickShareLevelButton' initialize: (options) -> @level = options.level @@ -17,13 +19,24 @@ module.exports = class ProgressView extends CocoView @classroom = options.classroom @nextLevel = options.nextLevel @levelSessions = options.levelSessions + @session = options.session # Translate and Markdownify level description, but take out any images (we don't have room for arena banners, etc.). # Images in Markdown are like ![description](url) @nextLevel.get('description', true) # Make sure the defaults are available @nextLevelDescription = marked(utils.i18n(@nextLevel.attributesWithDefaults, 'description').replace(/!\[.*?\]\(.*?\)\n*/g, '')) + if @level.get('shareable') is 'project' + @shareURL = "#{window.location.origin}/play/#{@level.get('type')}-level/#{@level.get('slug')}/#{@session.id}" + @shareURL += "?course=#{@course.id}" if @course onClickDoneButton: -> @trigger 'done' onClickNextLevelButton: -> @trigger 'next-level' + + onClickLadderButton: -> + @trigger 'ladder' + + onClickShareLevelButton: -> + @$('#share-level-input').val(@shareURL).select() + @tryCopy() diff --git a/app/views/play/level/modal/VictoryModal.coffee b/app/views/play/level/modal/VictoryModal.coffee index fcc8bdb9b..360ff0937 100644 --- a/app/views/play/level/modal/VictoryModal.coffee +++ b/app/views/play/level/modal/VictoryModal.coffee @@ -71,7 +71,7 @@ module.exports = class VictoryModal extends ModalView c.me = me c.levelName = utils.i18n @level.attributes, 'name' c.level = @level - if c.level.get('type') is 'ladder' + if c.level.isType('ladder') c.readyToRank = @session.readyToRank() c diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee index deade19b4..f29520726 100644 --- a/app/views/play/level/tome/CastButtonView.coffee +++ b/app/views/play/level/tome/CastButtonView.coffee @@ -18,9 +18,6 @@ module.exports = class CastButtonView extends CocoView 'tome:cast-spells': 'onCastSpells' 'tome:manual-cast-denied': 'onManualCastDenied' 'god:new-world-created': 'onNewWorld' - 'real-time-multiplayer:created-game': 'onJoinedRealTimeMultiplayerGame' - 'real-time-multiplayer:joined-game': 'onJoinedRealTimeMultiplayerGame' - 'real-time-multiplayer:left-game': 'onLeftRealTimeMultiplayerGame' 'goal-manager:new-goal-states': 'onNewGoalStates' 'god:goals-calculated': 'onGoalsCalculated' 'playback:ended-changed': 'onPlaybackEndedChanged' @@ -43,9 +40,9 @@ module.exports = class CastButtonView extends CocoView super() @castButton = $('.cast-button', @$el) spell.view?.createOnCodeChangeHandlers() for spellKey, spell of @spells - if @options.level.get('hidesSubmitUntilRun') or @options.level.get 'hidesRealTimePlayback' + if @options.level.get('hidesSubmitUntilRun') or @options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev') @$el.find('.submit-button').hide() # Hide Submit for the first few until they run it once. - if @options.session.get('state')?.complete and @options.level.get 'hidesRealTimePlayback' + if @options.session.get('state')?.complete and (@options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev')) @$el.find('.done-button').show() if @options.level.get('slug') in ['course-thornbush-farm', 'thornbush-farm'] @$el.find('.submit-button').hide() # Hide submit until first win so that script can explain it. @@ -71,9 +68,7 @@ module.exports = class CastButtonView extends CocoView Backbone.Mediator.publish 'tome:manual-cast', {} onCastRealTimeButtonClick: (e) -> - if @inRealTimeMultiplayerSession - Backbone.Mediator.publish 'real-time-multiplayer:manual-cast', {} - else if @options.level.get('replayable') and (timeUntilResubmit = @options.session.timeUntilResubmit()) > 0 + if @options.level.get('replayable') and (timeUntilResubmit = @options.session.timeUntilResubmit()) > 0 Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit else Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} @@ -81,7 +76,7 @@ module.exports = class CastButtonView extends CocoView onDoneButtonClick: (e) -> return if @options.level.hasLocalChanges() # Don't award achievements when beating level changed in level editor - @options.session.recordScores @world.scores, @options.level + @options.session.recordScores @world?.scores, @options.level Backbone.Mediator.publish 'level:show-victory', showModal: true onSpellChanged: (e) -> @@ -118,7 +113,7 @@ module.exports = class CastButtonView extends CocoView @winnable = winnable @$el.toggleClass 'winnable', @winnable Backbone.Mediator.publish 'tome:winnability-updated', winnable: @winnable, level: @options.level - if @options.level.get 'hidesRealTimePlayback' + if @options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev') @$el.find('.done-button').toggle @winnable else if @winnable and @options.level.get('slug') in ['course-thornbush-farm', 'thornbush-farm'] @$el.find('.submit-button').show() # Hide submit until first win so that script can explain it. @@ -178,9 +173,3 @@ module.exports = class CastButtonView extends CocoView return unless placeholder.length @ladderSubmissionView = new LadderSubmissionView session: @options.session, level: @options.level, mirrorSession: @mirrorSession @insertSubView @ladderSubmissionView, placeholder - - onJoinedRealTimeMultiplayerGame: (e) -> - @inRealTimeMultiplayerSession = true - - onLeftRealTimeMultiplayerGame: (e) -> - @inRealTimeMultiplayerSession = false diff --git a/app/views/play/level/tome/DocFormatter.coffee b/app/views/play/level/tome/DocFormatter.coffee index 93cdd1d6f..b09d7092b 100644 --- a/app/views/play/level/tome/DocFormatter.coffee +++ b/app/views/play/level/tome/DocFormatter.coffee @@ -42,12 +42,15 @@ module.exports = class DocFormatter @fillOutDoc() fillOutDoc: -> + # TODO: figure out better ways to format html/css/scripting docs for web-dev levels if _.isString @doc @doc = name: @doc, type: typeof @options.thang[@doc] if @options.isSnippet @doc.type = 'snippet' @doc.owner = 'snippets' @doc.shortName = @doc.shorterName = @doc.title = @doc.name + else if @doc.owner in ['HTML', 'CSS'] + @doc.shortName = @doc.shorterName = @doc.title = @doc.name else @doc.owner ?= 'this' ownerName = @doc.ownerName = if @doc.owner isnt 'this' then @doc.owner else switch @options.language @@ -139,7 +142,7 @@ module.exports = class DocFormatter if @doc.args arg.example = arg.example.replace thisToken[@options.language], 'hero' for arg in @doc.args when arg.example - if @doc.shortName is 'loop' and @options.level.get('type', true) in ['course', 'course-ladder'] + if @doc.shortName is 'loop' and @options.level.isType('course', 'course-ladder') @replaceSimpleLoops() replaceSimpleLoops: -> @@ -185,6 +188,7 @@ module.exports = class DocFormatter [docName, args] formatValue: (v) -> + return null if @options.level.isType('web-dev') return null if @doc.type is 'snippet' return @options.thang.now() if @doc.name is 'now' return '[Function]' if not v and @doc.type is 'function' diff --git a/app/views/play/level/tome/Problem.coffee b/app/views/play/level/tome/Problem.coffee index a42addd5d..9b5b22fa8 100644 --- a/app/views/play/level/tome/Problem.coffee +++ b/app/views/play/level/tome/Problem.coffee @@ -23,6 +23,7 @@ module.exports = class Problem raw: text, text: text, type: @aetherProblem.level ? 'error' + createdBy: 'aether' buildMarkerRange: -> return unless @aetherProblem.range diff --git a/app/views/play/level/tome/ProblemAlertView.coffee b/app/views/play/level/tome/ProblemAlertView.coffee index c6508eb17..c9ffc1363 100644 --- a/app/views/play/level/tome/ProblemAlertView.coffee +++ b/app/views/play/level/tome/ProblemAlertView.coffee @@ -14,7 +14,6 @@ module.exports = class ProblemAlertView extends CocoView 'level:restart': 'onHideProblemAlert' 'tome:jiggle-problem-alert': 'onJiggleProblemAlert' 'tome:manual-cast': 'onHideProblemAlert' - 'real-time-multiplayer:manual-cast': 'onHideProblemAlert' events: 'click .close': 'onRemoveClicked' diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee index 71b68fb3d..ddd1f187c 100644 --- a/app/views/play/level/tome/Spell.coffee +++ b/app/views/play/level/tome/Spell.coffee @@ -1,5 +1,5 @@ SpellView = require './SpellView' -SpellListTabEntryView = require './SpellListTabEntryView' +SpellTopBarView = require './SpellTopBarView' {me} = require 'core/auth' {createAetherOptions} = require 'lib/aether_utils' utils = require 'core/utils' @@ -7,7 +7,7 @@ utils = require 'core/utils' module.exports = class Spell loaded: false view: null - entryView: null + topBarView: null constructor: (options) -> @spellKey = options.spellKey @@ -20,8 +20,6 @@ module.exports = class Spell @supermodel = options.supermodel @skipProtectAPI = options.skipProtectAPI @worker = options.worker - @levelID = options.levelID - @levelType = options.level.get('type', true) @level = options.level p = options.programmableMethod @@ -47,30 +45,41 @@ module.exports = class Spell if p.aiSource and not @otherSession and not @canWrite() @source = @originalSource = p.aiSource @isAISource = true - @thangs = {} if @canRead() # We can avoid creating these views if we'll never use them. - @view = new SpellView {spell: @, level: options.level, session: @session, otherSession: @otherSession, worker: @worker, god: options.god, @supermodel} + @view = new SpellView {spell: @, level: options.level, session: @session, otherSession: @otherSession, worker: @worker, god: options.god, @supermodel, levelID: options.levelID} @view.render() # Get it ready and code loaded in advance - @tabView = new SpellListTabEntryView + @topBarView = new SpellTopBarView hintsState: options.hintsState spell: @ supermodel: @supermodel codeLanguage: @language level: options.level - @tabView.render() + session: options.session + courseID: options.courseID + @topBarView.render() Backbone.Mediator.publish 'tome:spell-created', spell: @ destroy: -> @view?.destroy() - @tabView?.destroy() - @thangs = null + @topBarView?.destroy() + @thang = null @worker = null setLanguage: (@language) -> + @language = 'html' if @level.isType('web-dev') #console.log 'setting language to', @language, 'so using original source', @languages[language] ? @languages.javascript @originalSource = @languages[@language] ? @languages.javascript @originalSource = @addPicoCTFProblem() if window.serverConfig.picoCTF + if @level.isType('web-dev') + # Pull apart the structural wrapper code and the player code, remember the wrapper code, and strip indentation on player code. + playerCode = @originalSource.match(/\n([\s\S]*)\n *<\/playercode>/)[1] + playerCodeLines = playerCode.split('\n') + indentation = playerCodeLines[0].length - playerCodeLines[0].trim().length + playerCode = (line.substr(indentation) for line in playerCodeLines).join('\n') + @wrapperCode = @originalSource.replace /[\s\S]*<\/playercode>/, '☃' # ☃ serves as placeholder for constructHTML + @originalSource = playerCode + # Translate comments chosen spoken language. return unless @commentContext context = $.extend true, {}, @commentContext @@ -87,7 +96,7 @@ module.exports = class Spell catch e console.error "Couldn't create example code template of", @originalSource, "\nwith context", context, "\nError:", e - if /loop/.test(@originalSource) and @levelType in ['course', 'course-ladder'] + if /loop/.test(@originalSource) and @level.isType('course', 'course-ladder') # Temporary hackery to make it look like we meant while True: in our sample code until we can update everything @originalSource = switch @language when 'python' then @originalSource.replace /loop:/, 'while True:' @@ -96,6 +105,9 @@ module.exports = class Spell when 'coffeescript' then @originalSource else @originalSource + constructHTML: (source) -> + @wrapperCode.replace '☃', source + addPicoCTFProblem: -> return @originalSource unless problem = @level.picoCTFProblem description = """ @@ -105,13 +117,13 @@ module.exports = class Spell ("// #{line}" for line in description.split('\n')).join('\n') + '\n' + @originalSource addThang: (thang) -> - if @thangs[thang.id] - @thangs[thang.id].thang = thang + if @thang?.thang.id is thang.id + @thang.thang = thang else - @thangs[thang.id] = {thang: thang, aether: @createAether(thang), castAether: null} + @thang = {thang: thang, aether: @createAether(thang), castAether: null} removeThangID: (thangID) -> - delete @thangs[thangID] + @thang = null if @thang?.thang.id is thangID canRead: (team) -> (team ? me.team) in @permissions.read or (team ? me.team) in @permissions.readwrite @@ -127,28 +139,16 @@ module.exports = class Spell @source = source else source = @getSource() - [pure, problems] = [null, null] - for thangID, spellThang of @thangs - unless pure - pure = spellThang.aether.transpile source - problems = spellThang.aether.problems - #console.log 'aether transpiled', source.length, 'to', spellThang.aether.pure.length, 'for', thangID, @spellKey - else - spellThang.aether.raw = source - spellThang.aether.pure = pure - spellThang.aether.problems = problems - #console.log 'aether reused transpilation for', thangID, @spellKey + unless @language is 'html' + @thang?.aether.transpile source null hasChanged: (newSource=null, currentSource=null) -> (newSource ? @originalSource) isnt (currentSource ? @source) hasChangedSignificantly: (newSource=null, currentSource=null, cb) -> - for thangID, spellThang of @thangs - aether = spellThang.aether - break - unless aether - console.error @toString(), 'couldn\'t find a spellThang with aether of', @thangs + unless aether = @thang?.aether + console.error @toString(), 'couldn\'t find a spellThang with aether', @thang cb false if @worker workerMessage = @@ -169,9 +169,9 @@ module.exports = class Spell createAether: (thang) -> writable = @permissions.readwrite.length > 0 and not @isAISource - skipProtectAPI = @skipProtectAPI or not writable or @levelType in ['game-dev'] + skipProtectAPI = @skipProtectAPI or not writable or @level.isType('game-dev') problemContext = @createProblemContext thang - includeFlow = (@levelType in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) and not skipProtectAPI + includeFlow = @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev') and not skipProtectAPI aetherOptions = createAetherOptions functionName: @name codeLanguage: @language @@ -190,10 +190,9 @@ module.exports = class Spell aether updateLanguageAether: (@language) -> - for thangId, spellThang of @thangs - spellThang.aether?.setLanguage @language - spellThang.castAether = null - Backbone.Mediator.publish 'tome:spell-changed-language', spell: @, language: @language + @thang?.aether?.setLanguage @language + @thang?.castAether = null + Backbone.Mediator.publish 'tome:spell-changed-language', spell: @, language: @language if @worker workerMessage = function: 'updateLanguageAether' diff --git a/app/views/play/level/tome/SpellDebugView.coffee b/app/views/play/level/tome/SpellDebugView.coffee index ddc787731..d02314259 100644 --- a/app/views/play/level/tome/SpellDebugView.coffee +++ b/app/views/play/level/tome/SpellDebugView.coffee @@ -17,7 +17,6 @@ module.exports = class SpellDebugView extends CocoView 'god:new-world-created': 'onNewWorld' 'god:debug-value-return': 'handleDebugValue' 'god:debug-world-load-progress-changed': 'handleWorldLoadProgressChanged' - 'tome:spell-shown': 'changeCurrentThangAndSpell' 'tome:cast-spells': 'onTomeCast' 'surface:frame-changed': 'onFrameChanged' 'tome:spell-has-changed-significantly-calculation': 'onSpellChangedCalculation' @@ -98,10 +97,6 @@ module.exports = class SpellDebugView extends CocoView currentObject = currentObject[key] currentObject[keys[keys.length - 1]] = value - changeCurrentThangAndSpell: (thangAndSpellObject) -> - @thang = thangAndSpellObject.thang - @spell = thangAndSpellObject.spell - handleDebugValue: (e) -> {key, value} = e @workerIsSimulating = false diff --git a/app/views/play/level/tome/SpellListEntryThangsView.coffee b/app/views/play/level/tome/SpellListEntryThangsView.coffee deleted file mode 100644 index 599cabe51..000000000 --- a/app/views/play/level/tome/SpellListEntryThangsView.coffee +++ /dev/null @@ -1,33 +0,0 @@ -CocoView = require 'views/core/CocoView' -ThangAvatarView = require 'views/play/level/ThangAvatarView' -template = require 'templates/play/level/tome/spell_list_entry_thangs' - -module.exports = class SpellListEntryThangsView extends CocoView - className: 'spell-list-entry-thangs-view' - template: template - - constructor: (options) -> - super options - @thangs = options.thangs - @thang = options.thang - @spell = options.spell - @avatars = [] - - afterRender: -> - super() - avatar.destroy() for avatar in @avatars if @avatars - @avatars = [] - spellName = @spell.name - for thang in @thangs - avatar = new ThangAvatarView thang: thang, includeName: true, supermodel: @supermodel, creator: @ - @$el.append avatar.el - avatar.render() - avatar.setSelected thang is @thang - avatar.$el.data('thang-id', thang.id).click (e) -> - Backbone.Mediator.publish 'level:select-sprite', thangID: $(@).data('thang-id'), spellName: spellName - avatar.onProblemsUpdated spell: @spell - @avatars.push avatar - - destroy: -> - avatar.destroy() for avatar in @avatars - super() diff --git a/app/views/play/level/tome/SpellListEntryView.coffee b/app/views/play/level/tome/SpellListEntryView.coffee deleted file mode 100644 index 92c5462d0..000000000 --- a/app/views/play/level/tome/SpellListEntryView.coffee +++ /dev/null @@ -1,115 +0,0 @@ -# TODO: This still needs a way to send problem states to its Thang - -CocoView = require 'views/core/CocoView' -ThangAvatarView = require 'views/play/level/ThangAvatarView' -SpellListEntryThangsView = require 'views/play/level/tome/SpellListEntryThangsView' -template = require 'templates/play/level/tome/spell_list_entry' - -module.exports = class SpellListEntryView extends CocoView - tagName: 'div' #'li' - className: 'spell-list-entry-view' - template: template - controlsEnabled: true - - subscriptions: - 'tome:problems-updated': 'onProblemsUpdated' - 'tome:spell-changed-language': 'onSpellChangedLanguage' - 'level:disable-controls': 'onDisableControls' - 'level:enable-controls': 'onEnableControls' - 'god:new-world-created': 'onNewWorld' - - events: - 'click': 'onClick' - 'mouseenter .thang-avatar-view': 'onMouseEnterAvatar' - 'mouseleave .thang-avatar-view': 'onMouseLeaveAvatar' - - constructor: (options) -> - super options - @spell = options.spell - @showTopDivider = options.showTopDivider - - getRenderData: (context={}) -> - context = super context - context.spell = @spell - context.thangNames = (thangID for thangID, spellThang of @spell.thangs when spellThang.thang.exists).join(', ') # + ', Marcus, Robert, Phoebe, Will Smith, Zap Brannigan, You, Gandaaaaalf' - context.showTopDivider = @showTopDivider - context - - getPrimarySpellThang: -> - if @lastSelectedThang - spellThang = _.find @spell.thangs, (spellThang) => spellThang.thang.id is @lastSelectedThang.id - return spellThang if spellThang - for thangID, spellThang of @spell.thangs - continue unless spellThang.thang.exists - return spellThang # Just do the first one else - - afterRender: -> - super() - return unless @options.showTopDivider # Don't repeat Thang avatars when not changed from previous entry - return @$el.hide() unless spellThang = @getPrimarySpellThang() - @$el.show() - @avatar?.destroy() - @avatar = new ThangAvatarView thang: spellThang.thang, includeName: false, supermodel: @supermodel - @$el.prepend @avatar.el # Before rendering, so render can use parent for popover - @avatar.render() - @avatar.setSharedThangs _.size @spell.thangs - @$el.addClass 'shows-top-divider' if @options.showTopDivider - - setSelected: (selected, @lastSelectedThang) -> - @avatar?.setSelected selected - - onClick: (e) -> - spellThang = @getPrimarySpellThang() - Backbone.Mediator.publish 'level:select-sprite', thangID: spellThang.thang.id, spellName: @spell.name - - onMouseEnterAvatar: (e) -> - return unless @controlsEnabled and _.size(@spell.thangs) > 1 - @showThangs() - - onMouseLeaveAvatar: (e) -> - return unless @controlsEnabled and _.size(@spell.thangs) > 1 - @hideThangsTimeout = _.delay @hideThangs, 100 - - showThangs: -> - clearTimeout @hideThangsTimeout if @hideThangsTimeout - return if @thangsView - spellThang = @getPrimarySpellThang() - return unless spellThang - @thangsView = new SpellListEntryThangsView thangs: (spellThang.thang for thangID, spellThang of @spell.thangs), thang: spellThang.thang, spell: @spell, supermodel: @supermodel - @thangsView.render() - @$el.append @thangsView.el - @thangsView.$el.mouseenter (e) => @onMouseEnterAvatar() - @thangsView.$el.mouseleave (e) => @onMouseLeaveAvatar() - - hideThangs: => - return unless @thangsView - @thangsView.off 'mouseenter mouseleave' - @thangsView.$el.remove() - @thangsView.destroy() - @thangsView = null - - onProblemsUpdated: (e) -> - return unless e.spell is @spell - @$el.toggleClass 'user-code-problem', e.problems.length - - onSpellChangedLanguage: (e) -> - return unless e.spell is @spell - @render() # So that we can update parameters if needed - - onDisableControls: (e) -> @toggleControls e, false - onEnableControls: (e) -> @toggleControls e, true - toggleControls: (e, enabled) -> - return if e.controls and not ('editor' in e.controls) - return if enabled is @controlsEnabled - @controlsEnabled = enabled - disabled = not enabled - # Should refactor the disabling list so we can target the spell list separately? - # Should not call it 'editor' any more? - @$el.toggleClass('disabled', disabled).find('*').prop('disabled', disabled) - - onNewWorld: (e) -> - @lastSelectedThang = e.world.thangMap[@lastSelectedThang.id] if @lastSelectedThang - - destroy: -> - @avatar?.destroy() - super() diff --git a/app/views/play/level/tome/SpellListView.coffee b/app/views/play/level/tome/SpellListView.coffee deleted file mode 100644 index 59a966d75..000000000 --- a/app/views/play/level/tome/SpellListView.coffee +++ /dev/null @@ -1,96 +0,0 @@ -# The SpellListView has SpellListEntryViews, which have ThangAvatarViews. -# The SpellListView serves as a dropdown triggered from a SpellListTabEntryView, which actually isn't in a list, just had a lot of similar parts. -# There is only one SpellListView, and it belongs to the TomeView. - -# TODO: showTopDivider should change when we reorder - -CocoView = require 'views/core/CocoView' -template = require 'templates/play/level/tome/spell_list' -{me} = require 'core/auth' -SpellListEntryView = require './SpellListEntryView' - -module.exports = class SpellListView extends CocoView - className: 'spell-list-view' - id: 'spell-list-view' - template: template - - subscriptions: - 'god:new-world-created': 'onNewWorld' - - constructor: (options) -> - super options - @entries = [] - @sortSpells() - - sortSpells: -> - # Keep only spells for which we have permissions - spells = _.filter @options.spells, (s) -> s.canRead() - @spells = _.sortBy spells, @sortScoreForSpell - #console.log 'Kept sorted spells', @spells - - sortScoreForSpell: (s) => - # Sort by most spells per fewest Thangs - # Lower comes first - score = 0 - # Selected spell at the top - score -= 9001900190019001 if s is @spell - # Spells for selected thang at the top - score -= 900190019001 if @thang and @thang.id of s.thangs - # Read-only spells at the bottom - score += 90019001 unless s.canWrite() - # The more Thangs sharing a spell, the lower - score += 9001 * _.size(s.thangs) - # The more spells per Thang, the higher - score -= _.filter(@spells, (s2) -> thangID of s2.thangs).length for thangID of s.thangs - score - - sortEntries: -> - # Call sortSpells before this - @entries = _.sortBy @entries, (entry) => _.indexOf @spells, entry.spell - @$el.append entry.$el for entry in @entries - - afterRender: -> - super() - @addSpellListEntries() - - addSpellListEntries: -> - newEntries = [] - lastThangs = null - for spell, index in @spells - continue if _.find @entries, spell: spell - theseThangs = _.keys(spell.thangs) - changedThangs = not lastThangs or not _.isEqual theseThangs, lastThangs - lastThangs = theseThangs - newEntries.push entry = new SpellListEntryView spell: spell, showTopDivider: changedThangs, supermodel: @supermodel, level: @options.level - @entries.push entry - for entry in newEntries - @$el.append entry.el - entry.render() # Render after appending so that we can access parent container for popover - - rerenderEntries: -> - entry.render() for entry in @entries - - onNewWorld: (e) -> - @thang = e.world.thangMap[@thang.id] if @thang - - setThangAndSpell: (@thang, @spell) -> - @entries[0]?.setSelected false - @sortSpells() - @sortEntries() - @entries[0].setSelected true, @thang - - addThang: (thang) -> - @sortSpells() - @addSpellListEntries() - - adjustSpells: (spells) -> - for entry in @entries when _.isEmpty entry.spell.thangs - entry.$el.remove() - entry.destroy() - @spells = @options.spells = spells - @sortSpells() - @addSpellListEntries() - - destroy: -> - entry.destroy() for entry in @entries - super() diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee index cbada6610..1b400b673 100644 --- a/app/views/play/level/tome/SpellPaletteEntryView.coffee +++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee @@ -33,7 +33,7 @@ module.exports = class SpellPaletteEntryView extends CocoView afterRender: -> super() - @$el.addClass(@doc.type) + @$el.addClass _.string.slugify @doc.type placement = -> if $('body').hasClass('dialogue-view-active') then 'top' else 'left' @$el.popover( animation: false @@ -53,6 +53,7 @@ module.exports = class SpellPaletteEntryView extends CocoView oldEditor.destroy() for oldEditor in @aceEditors @aceEditors = [] aceEditors = @aceEditors + # Initialize Ace for each popover code snippet popover?.$tip?.find('.docs-ace').each -> aceEditor = utils.initializeACE @, codeLanguage aceEditors.push aceEditor @@ -84,7 +85,7 @@ module.exports = class SpellPaletteEntryView extends CocoView Backbone.Mediator.publish 'tome:palette-pin-toggled', entry: @, pinned: @popoverPinned onClick: (e) => - if true or @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if true or @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev') # Jiggle instead of pin for hero levels # Actually, do it all the time, because we recently busted the pin CSS. TODO: restore pinning jigglyPopover = $('.spell-palette-popover.popover') diff --git a/app/views/play/level/tome/SpellPaletteView.coffee b/app/views/play/level/tome/SpellPaletteView.coffee index 87f7b21f4..e8d4a6405 100644 --- a/app/views/play/level/tome/SpellPaletteView.coffee +++ b/app/views/play/level/tome/SpellPaletteView.coffee @@ -87,6 +87,7 @@ module.exports = class SpellPaletteView extends CocoView entry.$el.addClass 'first-entry' @$el.addClass 'hero' @$el.toggleClass 'shortenize', Boolean @shortenize + @$el.toggleClass 'web-dev', @options.level.isType('web-dev') @updateMaxHeight() unless application.isIPadApp afterInsert: -> @@ -100,7 +101,9 @@ module.exports = class SpellPaletteView extends CocoView return unless @isHero # We figure out how many columns we can fit, width-wise, and then guess how many rows will be needed. # We can then assign a height based on the number of rows, and the flex layout will do the rest. - columnWidth = if @shortenize then 175 else 212 + columnWidth = 212 + columnWidth = 175 if @shortenize + columnWidth = 100 if @options.level.isType('web-dev') nColumns = Math.floor @$el.find('.properties-this').innerWidth() / columnWidth # will always have 2 columns, since at 1024px screen we have 424px .properties columns = ({items: [], nEntries: 0} for i in [0 ... nColumns]) orderedColumns = [] @@ -153,11 +156,13 @@ module.exports = class SpellPaletteView extends CocoView JSON: 'programmableJSONProperties' LoDash: 'programmableLoDashProperties' Vector: 'programmableVectorProperties' + HTML: 'programmableHTMLProperties' + CSS: 'programmableCSSProperties' snippets: 'programmableSnippets' else propStorage = 'this': ['apiProperties', 'apiMethods'] - if not (@options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) or not @options.programmable + if not @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') or not @options.programmable @organizePalette propStorage, allDocs, excludedDocs else @organizePaletteHero propStorage, allDocs, excludedDocs @@ -192,14 +197,14 @@ module.exports = class SpellPaletteView extends CocoView return 'more' if entry.doc.owner is 'this' and entry.doc.name in (propGroups.more ? []) entry.doc.owner @entries = _.sortBy @entries, (entry) -> - order = ['this', 'more', 'Math', 'Vector', 'String', 'Object', 'Array', 'Function', 'snippets'] + order = ['this', 'more', 'Math', 'Vector', 'String', 'Object', 'Array', 'Function', 'HTML', 'CSS', 'snippets'] index = order.indexOf groupForEntry entry index = String.fromCharCode if index is -1 then order.length else index index += entry.doc.name if tabbify and _.find @entries, ((entry) -> entry.doc.owner isnt 'this') @entryGroups = _.groupBy @entries, groupForEntry else - i18nKey = if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then 'play_level.tome_your_skills' else 'play_level.tome_available_spells' + i18nKey = if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') then 'play_level.tome_your_skills' else 'play_level.tome_available_spells' defaultGroup = $.i18n.t i18nKey @entryGroups = {} @entryGroups[defaultGroup] = @entries @@ -243,7 +248,7 @@ module.exports = class SpellPaletteView extends CocoView console.log @thang.id, "couldn't find item ThangType for", slot, thangTypeName # Get any Math-, Vector-, etc.-owned properties into their own tabs - for owner, storage of propStorage when not (owner in ['this', 'more', 'snippets']) + for owner, storage of propStorage when not (owner in ['this', 'more', 'snippets', 'HTML', 'CSS']) continue unless @thang[storage]?.length @tabs ?= {} @tabs[owner] = [] @@ -257,7 +262,7 @@ module.exports = class SpellPaletteView extends CocoView # Assign any unassigned properties to the hero itself. for owner, storage of propStorage - continue unless owner in ['this', 'more', 'snippets'] + continue unless owner in ['this', 'more', 'snippets', 'HTML', 'CSS'] for prop in _.reject(@thang[storage] ? [], (prop) -> itemsByProp[prop] or prop[0] is '_') # no private properties continue if prop is 'say' and @options.level.get 'hidesSay' # Hide for Dungeon Campaign continue if prop is 'moveXY' and @options.level.get('slug') is 'slalom' # Hide for Slalom @@ -282,7 +287,10 @@ module.exports = class SpellPaletteView extends CocoView doc ?= prop if doc @entries.push @addEntry(doc, @shortenize, owner is 'snippets', item, propIndex > 0) - @entryGroups = _.groupBy @entries, (entry) -> itemsByProp[entry.doc.name]?.get('name') ? 'Hero' + if @options.level.isType('web-dev') + @entryGroups = _.groupBy @entries, (entry) -> entry.doc.type + else + @entryGroups = _.groupBy @entries, (entry) -> itemsByProp[entry.doc.name]?.get('name') ? 'Hero' iOSEntryGroups = {} for group, entries of @entryGroups iOSEntryGroups[group] = diff --git a/app/views/play/level/tome/SpellListTabEntryView.coffee b/app/views/play/level/tome/SpellTopBarView.coffee similarity index 54% rename from app/views/play/level/tome/SpellListTabEntryView.coffee rename to app/views/play/level/tome/SpellTopBarView.coffee index b15798bbb..7c8051f86 100644 --- a/app/views/play/level/tome/SpellListTabEntryView.coffee +++ b/app/views/play/level/tome/SpellTopBarView.coffee @@ -1,32 +1,31 @@ -SpellListEntryView = require './SpellListEntryView' -ThangAvatarView = require 'views/play/level/ThangAvatarView' -template = require 'templates/play/level/tome/spell_list_tab_entry' -LevelComponent = require 'models/LevelComponent' -DocFormatter = require './DocFormatter' +template = require 'templates/play/level/tome/spell-top-bar-view' ReloadLevelModal = require 'views/play/level/modal/ReloadLevelModal' +CocoView = require 'views/core/CocoView' +ImageGalleryModal = require 'views/play/level/modal/ImageGalleryModal' -module.exports = class SpellListTabEntryView extends SpellListEntryView +module.exports = class SpellTopBarView extends CocoView template: template - id: 'spell-list-tab-entry-view' + id: 'spell-top-bar-view' + controlsEnabled: true subscriptions: 'level:disable-controls': 'onDisableControls' 'level:enable-controls': 'onEnableControls' 'tome:spell-loaded': 'onSpellLoaded' 'tome:spell-changed': 'onSpellChanged' - 'god:new-world-created': 'onNewWorld' 'tome:spell-changed-language': 'onSpellChangedLanguage' 'tome:toggle-maximize': 'onToggleMaximize' events: - 'click .spell-list-button': 'onDropdownClick' 'click .reload-code': 'onCodeReload' 'click .beautify-code': 'onBeautifyClick' 'click .fullscreen-code': 'onToggleMaximize' 'click .hints-button': 'onClickHintsButton' + 'click .image-gallery-button': 'onClickImageGalleryButton' constructor: (options) -> @hintsState = options.hintsState + @spell = options.spell super(options) getRenderData: (context={}) -> @@ -35,9 +34,7 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView shift = $.i18n.t 'keyboard_shortcuts.shift' context.beautifyShortcutVerbose = "#{ctrl}+#{shift}+B: #{$.i18n.t 'keyboard_shortcuts.beautify'}" context.maximizeShortcutVerbose = "#{ctrl}+#{shift}+M: #{$.i18n.t 'keyboard_shortcuts.maximize_editor'}" - context.includeSpellList = @options.level.get('slug') in ['break-the-prison', 'zone-of-danger', 'k-means-cluster-wars', 'brawlwood', 'dungeon-arena', 'sky-span', 'minimax-tic-tac-toe'] context.codeLanguage = @options.codeLanguage - context.levelType = @options.level.get 'type', true context afterRender: -> @@ -45,68 +42,19 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView @$el.addClass 'spell-tab' @attachTransitionEventListener() - onNewWorld: (e) -> - @thang = e.world.thangMap[@thang.id] if @thang - - setThang: (thang) -> - return if thang.id is @thang?.id - @thang = thang - @spellThang = @spell.thangs[@thang.id] - @buildAvatar() - @buildDocs() unless @docsBuilt - - buildAvatar: -> - avatar = new ThangAvatarView thang: @thang, includeName: false, supermodel: @supermodel - if @avatar - @avatar.$el.replaceWith avatar.$el - @avatar.destroy() - else - @$el.find('.thang-avatar-placeholder').replaceWith avatar.$el - @avatar = avatar - @avatar.render() - - buildDocs: -> - return if @spell.name is 'plan' # Too confusing for beginners - @docsBuilt = true - lcs = @supermodel.getModels LevelComponent - found = false - for lc in lcs when not found - for doc in lc.get('propertyDocumentation') ? [] - if doc.name is @spell.name - found = true - break - return unless found - docFormatter = new DocFormatter doc: doc, thang: @thang, language: @options.codeLanguage, selectedMethod: true - @$el.find('.method-signature').popover( - animation: true - html: true - placement: 'bottom' - trigger: 'hover' - content: docFormatter.formatPopover() - container: @$el.parent() - ).on 'show.bs.popover', => - @playSound 'spell-tab-entry-open', 0.75 - - onMouseEnterAvatar: (e) -> # Don't call super - onMouseLeaveAvatar: (e) -> # Don't call super - onClick: (e) -> # Don't call super onDisableControls: (e) -> @toggleControls e, false onEnableControls: (e) -> @toggleControls e, true + onClickImageGalleryButton: (e) -> + @openModalView new ImageGalleryModal() + onClickHintsButton: -> return unless @hintsState? @hintsState.set('hidden', not @hintsState.get('hidden')) window.tracker?.trackEvent 'Hints Clicked', category: 'Students', levelSlug: @options.level.get('slug'), hintCount: @hintsState.get('hints')?.length ? 0, ['Mixpanel'] - onDropdownClick: (e) -> - return unless @controlsEnabled - Backbone.Mediator.publish 'tome:toggle-spell-list', {} - @playSound 'spell-list-open' - onCodeReload: (e) -> - #return unless @controlsEnabled - #Backbone.Mediator.publish 'tome:reload-code', spell: @spell # Old: just reload the current code - @openModalView new ReloadLevelModal() # New: prompt them to restart the level + @openModalView new ReloadLevelModal() onBeautifyClick: (e) -> return unless @controlsEnabled @@ -134,14 +82,10 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView onSpellChangedLanguage: (e) -> return unless e.spell is @spell @options.codeLanguage = e.language - @$el.find('.method-signature').popover 'destroy' @render() - @docsBuilt = false - @buildDocs() if @thang @updateReloadButton() toggleControls: (e, enabled) -> - # Don't call super; do it differently return if e.controls and not ('editor' in e.controls) return if enabled is @controlsEnabled @controlsEnabled = enabled @@ -163,8 +107,5 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView $codearea.on transitionListener, => $codearea.css 'z-index', 2 unless $('html').hasClass 'fullscreen-editor' - destroy: -> - @avatar?.destroy() - @$el.find('.method-signature').popover 'destroy' super() diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index c3e2b7e14..f288a12d1 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -21,6 +21,7 @@ module.exports = class SpellView extends CocoView controlsEnabled: true eventsSuppressed: true writable: true + languagesThatUseWorkers: ['html'] keyBindings: 'default': null @@ -61,7 +62,6 @@ module.exports = class SpellView extends CocoView @supermodel = options.supermodel @worker = options.worker @session = options.session - @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) @spell = options.spell @problems = [] @savedProblems = {} # Cache saved user code problems to prevent duplicates @@ -77,12 +77,9 @@ module.exports = class SpellView extends CocoView @fillACE() @createOnCodeChangeHandlers() @lockDefaultCode() - if @session.get('multiplayer') - @createFirepad() - else - # needs to happen after the code generating this view is complete - _.defer @onAllLoaded + _.defer @onAllLoaded # Needs to happen after the code generating this view is complete + # This ACE is used for the code editor, and is only instantiated once per level. createACE: -> # Test themes and settings here: http://ace.ajax.org/build/kitchen-sink.html aceConfig = me.get('aceConfig') ? {} @@ -90,7 +87,7 @@ module.exports = class SpellView extends CocoView @ace = ace.edit @$el.find('.ace')[0] @aceSession = @ace.getSession() @aceDoc = @aceSession.getDocument() - @aceSession.setUseWorker false + @aceSession.setUseWorker @spell.language in @languagesThatUseWorkers @aceSession.setMode utils.aceEditModes[@spell.language] @aceSession.setWrapLimitRange null @aceSession.setUseWrapMode true @@ -231,7 +228,7 @@ module.exports = class SpellView extends CocoView disableSpaces = @options.level.get('disableSpaces') or false aceConfig = me.get('aceConfig') ? {} disableSpaces = false if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Not in vim/emacs mode - disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript'] # Don't disable for more advanced/experimental languages + disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript', 'html'] # Don't disable for more advanced/experimental languages if not disableSpaces or (_.isNumber(disableSpaces) and disableSpaces < me.level()) return @ace.execCommand 'insertstring', ' ' line = @aceDoc.getLine @ace.getCursorPosition().row @@ -402,7 +399,6 @@ module.exports = class SpellView extends CocoView wrapper => orig.apply obj, args obj[method] - finishRange = (row, startRow, startColumn) => range = new Range startRow, startColumn, row, @aceSession.getLine(row).length - 1 range.start = @aceDoc.createAnchor range.start @@ -476,6 +472,7 @@ module.exports = class SpellView extends CocoView # TODO: Turn on more autocompletion based on level sophistication # TODO: E.g. using the language default snippets yields a bunch of crazy non-beginner suggestions # TODO: Options logic shouldn't exist both here and in updateAutocomplete() + return if @spell.language is 'html' popupFontSizePx = @options.level.get('autocompleteFontSizePx') ? 16 @zatanna = new Zatanna @ace, basic: false @@ -502,8 +499,6 @@ module.exports = class SpellView extends CocoView return unless @zatanna and @autocomplete @zatanna.addCodeCombatSnippets @options.level, @, e - - translateFindNearest: -> # If they have advanced glasses but are playing a level which assumes earlier glasses, we'll adjust the sample code to use the more advanced APIs instead. oldSource = @getSource() @@ -514,14 +509,9 @@ module.exports = class SpellView extends CocoView @updateACEText newSource _.delay (=> @recompile?()), 1000 - onMultiplayerChanged: -> - if @session.get('multiplayer') - @createFirepad() - else - @firepad?.dispose() - createFirepad: -> - # load from firebase or the original source if there's nothing there + # Currently not called; could be brought back for future multiplayer modes. + # Load from firebase or the original source if there's nothing there. return if @firepadLoading @eventsSuppressed = true @loaded = false @@ -532,19 +522,18 @@ module.exports = class SpellView extends CocoView @fireRef = new Firebase fireURL firepadOptions = userId: me.id @firepad = Firepad.fromACE @fireRef, @ace, firepadOptions - @firepad.on 'ready', @onFirepadLoaded @firepadLoading = true - - onFirepadLoaded: => - @firepadLoading = false - firepadSource = @ace.getValue() - if firepadSource - @spell.source = firepadSource - else - @ace.setValue @previousSource - @aceSession.setUndoManager(new UndoManager()) - @ace.clearSelection() - @onAllLoaded() + @firepad.on 'ready', => + return if @destroyed + @firepadLoading = false + firepadSource = @ace.getValue() + if firepadSource + @spell.source = firepadSource + else + @ace.setValue @previousSource + @aceSession.setUndoManager(new UndoManager()) + @ace.clearSelection() + @onAllLoaded() onAllLoaded: => @spell.transpile @spell.source @@ -552,9 +541,10 @@ module.exports = class SpellView extends CocoView Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell @eventsSuppressed = false # Now that the initial change is in, we can start running any changed code @createToolbarView() + @updateHTML create: true if @options.level.isType('web-dev') createDebugView: -> - return if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] # We'll turn this on later, maybe, but not yet. + return if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') # We'll turn this on later, maybe, but not yet. @debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell @$el.append @debugView.render().$el.hide() @@ -573,7 +563,7 @@ module.exports = class SpellView extends CocoView @saveSpade() getSource: -> - @ace.getValue() # could also do @firepad.getText() + @ace.getValue() setThang: (thang) -> @focus() @@ -581,7 +571,7 @@ module.exports = class SpellView extends CocoView @updateLines() return if thang.id is @thang?.id @thang = thang - @spellThang = @spell.thangs[@thang.id] + @spellThang = @spell.thang @createDebugView() unless @debugView @debugView?.thang = @thang @createTranslationView() unless @translationView @@ -624,11 +614,11 @@ module.exports = class SpellView extends CocoView lineHeight = @ace.renderer.lineHeight or 20 tomeHeight = $('#tome-view').innerHeight() spellPaletteView = $('#spell-palette-view') - spellListTabEntryHeight = $('#spell-list-tab-entry-view').outerHeight() + spellTopBarHeight = $('#spell-top-bar-view').outerHeight() spellToolbarHeight = $('.spell-toolbar-view').outerHeight() @spellPaletteHeight ?= spellPaletteView.outerHeight() # Remember this until resize, since we change it afterward spellPaletteAllowedHeight = Math.min @spellPaletteHeight, tomeHeight / 3 - maxHeight = tomeHeight - spellListTabEntryHeight - spellToolbarHeight - spellPaletteAllowedHeight + maxHeight = tomeHeight - spellTopBarHeight - spellToolbarHeight - spellPaletteAllowedHeight linesAtMaxHeight = Math.floor(maxHeight / lineHeight) lines = Math.max 8, Math.min(screenLineCount + 2, linesAtMaxHeight) # 2 lines buffer is nice @@ -675,6 +665,7 @@ module.exports = class SpellView extends CocoView cast = @$el.parent().length @recompile cast, e.realTime @focus() if cast + @updateHTML create: true if @options.level.isType('web-dev') onCodeReload: (e) -> return unless e.spell is @spell or not e.spell @@ -725,6 +716,8 @@ module.exports = class SpellView extends CocoView ] onSignificantChange.push _.debounce @checkRequiredCode, 750 if @options.level.get 'requiredCode' onSignificantChange.push _.debounce @checkSuspectCode, 750 if @options.level.get 'suspectCode' + onAnyChange.push _.throttle @updateHTML, 10 if @options.level.isType('web-dev') + @onCodeChangeMetaHandler = => return if @eventsSuppressed #@playSound 'code-change', volume: 0.5 # Currently not using this sound. @@ -737,6 +730,9 @@ module.exports = class SpellView extends CocoView onCursorActivity: => # Used to refresh autocast delay; doesn't do anything at the moment. + updateHTML: (options={}) => + Backbone.Mediator.publish 'tome:html-updated', html: @spell.constructHTML(@getSource()), create: Boolean(options.create) + # Design for a simpler system? # * Keep Aether linting, debounced, on any significant change # - All problems just vanish when you make any change to the code @@ -795,10 +791,12 @@ module.exports = class SpellView extends CocoView else finishUpdatingAether(aether) + # Clear annotations and highlights generated by Aether, but not by the ACE worker clearAetherDisplay: -> problem.destroy() for problem in @problems @problems = [] - @aceSession.setAnnotations [] + nonAetherAnnotations = _.reject @aceSession.getAnnotations(), (annotation) -> annotation.createdBy is 'aether' + @aceSession.setAnnotations nonAetherAnnotations @highlightCurrentLine {} # This'll remove all highlights displayAether: (aether, isCast=false) -> @@ -806,12 +804,12 @@ module.exports = class SpellView extends CocoView isCast = isCast or not _.isEmpty(aether.metrics) or _.some aether.getAllProblems(), {type: 'runtime'} problem.destroy() for problem in @problems # Just in case another problem was added since clearAetherDisplay() ran. @problems = [] - annotations = [] + annotations = @aceSession.getAnnotations() seenProblemKeys = {} for aetherProblem, problemIndex in aether.getAllProblems() continue if key = aetherProblem.userInfo?.key and key of seenProblemKeys seenProblemKeys[key] = true if key - @problems.push problem = new Problem aether, aetherProblem, @ace, isCast, @spell.levelID + @problems.push problem = new Problem aether, aetherProblem, @ace, isCast, @options.levelID if isCast and problemIndex is 0 if problem.aetherProblem.range? lineOffsetPx = 0 @@ -859,7 +857,7 @@ module.exports = class SpellView extends CocoView @userCodeProblem.set 'errRange', aetherProblem.range if aetherProblem.range @userCodeProblem.set 'errType', aetherProblem.type if aetherProblem.type @userCodeProblem.set 'language', aether.language.id if aether.language?.id - @userCodeProblem.set 'levelID', @spell.levelID if @spell.levelID + @userCodeProblem.set 'levelID', @options.levelID if @options.levelID @userCodeProblem.save() null @@ -907,16 +905,14 @@ module.exports = class SpellView extends CocoView return if @spell.source.indexOf('while') isnt -1 # If they're working with while-loops, it's more likely to be an incomplete infinite loop, so don't preload. return if @spell.source.length > 500 # Only preload on really short methods return if @spellThang?.castAether?.metrics?.statementsExecuted > 2000 # Don't preload if they are running significant amounts of user code + return if @options.level.isType('web-dev') oldSource = @spell.source - oldSpellThangAethers = {} - for thangID, spellThang of @spell.thangs - oldSpellThangAethers[thangID] = spellThang.aether.serialize() # Get raw, pure, and problems + oldSpellThangAether = @spell.thang?.aether.serialize() @spell.transpile @getSource() @cast true @spell.source = oldSource - for thangID, spellThang of @spell.thangs - for key, value of oldSpellThangAethers[thangID] - spellThang.aether[key] = value + for key, value of oldSpellThangAether + @spell.thang.aether[key] = value onSpellChanged: (e) -> @spellHasChanged = true @@ -933,10 +929,10 @@ module.exports = class SpellView extends CocoView return unless e.god is @options.god return @onInfiniteLoop e if e.problem.id is 'runtime_InfiniteLoop' return unless e.problem.userInfo.methodName is @spell.name - return unless spellThang = _.find @spell.thangs, (spellThang, thangID) -> thangID is e.problem.userInfo.thangID + return unless @spell.thang?.thang.id is e.problem.userInfo.thangID @spell.hasChangedSignificantly @getSource(), null, (hasChanged) => return if hasChanged - spellThang.aether.addProblem e.problem + @spell.thang.aether.addProblem e.problem @lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile @updateAether false, false @@ -957,13 +953,14 @@ module.exports = class SpellView extends CocoView @updateAether false, false onNewWorld: (e) -> - @spell.removeThangID thangID for thangID of @spell.thangs when not e.world.getThangByID thangID - for thangID, spellThang of @spell.thangs - thang = e.world.getThangByID(thangID) - aether = e.world.userCodeMap[thangID]?[@spell.name] # Might not be there if this is a new Programmable Thang. - spellThang.castAether = aether - spellThang.aether = @spell.createAether thang - #console.log thangID, @spell.spellKey, 'ran', aether.metrics.callsExecuted, 'times over', aether.metrics.statementsExecuted, 'statements, with max recursion depth', aether.metrics.maxDepth, 'and full flow/metrics', aether.metrics, aether.flow + if thang = e.world.getThangByID @spell.thang?.thang.id + aether = e.world.userCodeMap[thang.id]?[@spell.name] + @spell.thang.castAether = aether + @spell.thang.aether = @spell.createAether thang + #console.log thang.id, @spell.spellKey, 'ran', aether.metrics.callsExecuted, 'times over', aether.metrics.statementsExecuted, 'statements, with max recursion depth', aether.metrics.maxDepth, 'and full flow/metrics', aether.metrics, aether.flow + else + @spell.thang = null + @spell.transpile() # TODO: is there any way we can avoid doing this if it hasn't changed? Causes a slight hang. @updateAether false, false @@ -990,8 +987,6 @@ module.exports = class SpellView extends CocoView @ace.insert "{x=#{e.x}, y=#{e.y}}" else @ace.insert "{x: #{e.x}, y: #{e.y}}" - - @highlightCurrentLine() onStatementIndexUpdated: (e) -> @@ -1156,7 +1151,7 @@ module.exports = class SpellView extends CocoView toggleBackground: => # TODO: make the background an actual background and do the CSS trick - # used in spell_list_entry.sass for disabling + # used in spell-top-bar-view.sass for disabling background = @$el.find('img.code-background')[0] if background.naturalWidth is 0 # not loaded yet return _.delay @toggleBackground, 100 @@ -1260,7 +1255,7 @@ module.exports = class SpellView extends CocoView @debugView?.destroy() @translationView?.destroy() @toolbarView?.destroy() - @zatanna.addSnippets [], @editorLang if @editorLang? + @zatanna?.addSnippets [], @editorLang if @editorLang? $(window).off 'resize', @onWindowResize window.clearTimeout @saveSpadeTimeout @saveSpadeTimeout = null diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee index 442b8cf4b..d35bbbb4c 100644 --- a/app/views/play/level/tome/TomeView.coffee +++ b/app/views/play/level/tome/TomeView.coffee @@ -2,36 +2,26 @@ # - a CastButtonView, which has # - a cast button # - a submit/done button -# - for each spell (programmableMethod): +# - for each spell (programmableMethod) (which is now just always only 'plan') # - a Spell, which has -# - a list of Thangs that share that Spell, with one aether per Thang per Spell +# - a Thang that uses that Spell, with an aether and a castAether # - a SpellView, which has # - tons of stuff; the meat -# - a SpellListView, which has -# - for each spell: -# - a SpellListEntryView, which has -# - icons for each Thang -# - the spell name -# - a reload button -# - documentation for that method (in a popover) +# - a SpellTopBarView, which has some controls # - a SpellPaletteView, which has # - for each programmableProperty: # - a SpellPaletteEntryView # -# The CastButtonView and SpellListView always show. +# The CastButtonView always shows. # The SpellPaletteView shows the entries for the currently selected Programmable Thang. # The SpellView shows the code and runtime state for the currently selected Spell and, specifically, Thang. -# The SpellView obscures most of the SpellListView when present. We might mess with this. # You can switch a SpellView to showing the runtime state of another Thang sharing that Spell. # SpellPaletteViews are destroyed and recreated whenever you switch Thangs. -# The SpellListView shows spells to which your team has read or readwrite access. -# It doubles as a Thang selector, since it's there when nothing is selected. CocoView = require 'views/core/CocoView' template = require 'templates/play/level/tome/tome' {me} = require 'core/auth' Spell = require './Spell' -SpellListView = require './SpellListView' SpellPaletteView = require './SpellPaletteView' CastButtonView = require './CastButtonView' @@ -44,7 +34,6 @@ module.exports = class TomeView extends CocoView subscriptions: 'tome:spell-loaded': 'onSpellLoaded' 'tome:cast-spell': 'onCastSpell' - 'tome:toggle-spell-list': 'onToggleSpellList' 'tome:change-language': 'updateLanguageForAllSpells' 'surface:sprite-selected': 'onSpriteSelected' 'god:new-world-created': 'onNewWorld' @@ -52,16 +41,16 @@ module.exports = class TomeView extends CocoView 'tome:select-primary-sprite': 'onSelectPrimarySprite' events: - 'click #spell-view': 'onSpellViewClick' 'click': 'onClick' afterRender: -> super() @worker = @createWorker() programmableThangs = _.filter @options.thangs, (t) -> t.isProgrammable and t.programmableMethods - @createSpells programmableThangs, programmableThangs[0]?.world # Do before spellList, thangList, and castButton - unless @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] - @spellList = @insertSubView new SpellListView spells: @spells, supermodel: @supermodel, level: @options.level + if @options.level.isType('web-dev') + if @fakeProgrammableThang = @createFakeProgrammableThang() + programmableThangs = [@fakeProgrammableThang] + @createSpells programmableThangs, programmableThangs[0]?.world # Do before castButton @castButton = @insertSubView new CastButtonView spells: @spells, level: @options.level, session: @options.session, god: @options.god @teamSpellMap = @generateTeamSpellMap(@spells) unless programmableThangs.length @@ -72,10 +61,8 @@ module.exports = class TomeView extends CocoView delete @options.thangs onNewWorld: (e) -> - thangs = _.filter e.world.thangs, 'inThangList' - programmableThangs = _.filter thangs, (t) -> t.isProgrammable and t.programmableMethods + programmableThangs = _.filter e.thangs, (t) -> t.isProgrammable and t.programmableMethods and t.inThangList @createSpells programmableThangs, e.world - @spellList?.adjustSpells @spells onCommentMyCode: (e) -> for spellKey, spell of @spells when spell.canWrite() @@ -114,33 +101,31 @@ module.exports = class TomeView extends CocoView @thangSpells[thang.id] = [] for methodName, method of thang.programmableMethods pathComponents = [thang.id, methodName] - if method.cloneOf - pathComponents[0] = method.cloneOf # referencing another Thang's method pathComponents[0] = _.string.slugify pathComponents[0] spellKey = pathComponents.join '/' @thangSpells[thang.id].push spellKey - unless method.cloneOf - skipProtectAPI = @getQueryVariable 'skip_protect_api', (@options.levelID in ['gridmancer', 'minimax-tic-tac-toe']) - spell = @spells[spellKey] = new Spell - hintsState: @options.hintsState - programmableMethod: method - spellKey: spellKey - pathComponents: pathPrefixComponents.concat(pathComponents) - session: @options.session - otherSession: @options.otherSession - supermodel: @supermodel - skipProtectAPI: skipProtectAPI - worker: @worker - language: language - spectateView: @options.spectateView - spectateOpponentCodeLanguage: @options.spectateOpponentCodeLanguage - observing: @options.observing - levelID: @options.levelID - level: @options.level - god: @options.god + skipProtectAPI = @getQueryVariable 'skip_protect_api', false + spell = @spells[spellKey] = new Spell + hintsState: @options.hintsState + programmableMethod: method + spellKey: spellKey + pathComponents: pathPrefixComponents.concat(pathComponents) + session: @options.session + otherSession: @options.otherSession + supermodel: @supermodel + skipProtectAPI: skipProtectAPI + worker: @worker + language: language + spectateView: @options.spectateView + spectateOpponentCodeLanguage: @options.spectateOpponentCodeLanguage + observing: @options.observing + levelID: @options.levelID + level: @options.level + god: @options.god + courseID: @options.courseID for thangID, spellKeys of @thangSpells - thang = world.getThangByID thangID + thang = @fakeProgrammableThang ? world.getThangByID thangID if thang @spells[spellKey].addThang thang for spellKey in spellKeys else @@ -161,6 +146,7 @@ module.exports = class TomeView extends CocoView @cast e?.preload, e?.realTime cast: (preload=false, realTime=false) -> + return if @options.level.isType('web-dev') sessionState = @options.session.get('state') ? {} if realTime sessionState.submissionCount = (sessionState.submissionCount ? 0) + 1 @@ -172,53 +158,27 @@ module.exports = class TomeView extends CocoView difficulty = Math.max 0, difficulty - 1 # Show the difficulty they won, not the next one. Backbone.Mediator.publish 'tome:cast-spells', spells: @spells, preload: preload, realTime: realTime, submissionCount: sessionState.submissionCount ? 0, flagHistory: sessionState.flagHistory ? [], difficulty: difficulty, god: @options.god, fixedSeed: @options.fixedSeed - onToggleSpellList: (e) -> - @spellList?.rerenderEntries() - @spellList?.$el.toggle() - - onSpellViewClick: (e) -> - @spellList?.$el.hide() - onClick: (e) -> Backbone.Mediator.publish 'tome:focus-editor', {} unless $(e.target).parents('.popover').length - clearSpellView: -> - @spellView?.dismiss() - @spellView?.$el.after('
    ').detach() - @spellView = null - @spellTabView?.$el.after('
    ').detach() - @spellTabView = null - @removeSubView @spellPaletteView if @spellPaletteView - @spellPaletteView = null - @$el.find('#spell-palette-view').hide() - @castButton?.$el.hide() - onSpriteSelected: (e) -> - return if @spellView and @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] # Never deselect the hero in the Tome. - thang = e.thang - spellName = e.spellName - @spellList?.$el.hide() - return @clearSpellView() unless thang - spell = @spellFor thang, spellName - unless spell?.canRead() - @clearSpellView() - @updateSpellPalette thang, spell if spell - return + return if @spellView and @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev'] # Never deselect the hero in the Tome. + spell = @spellFor e.thang, e.spellName + if spell?.canRead() + @setSpellView spell, e.thang + + setSpellView: (spell, thang) -> unless spell.view is @spellView - @clearSpellView() @spellView = spell.view - @spellTabView = spell.tabView + @spellTopBarView = spell.topBarView @$el.find('#' + @spellView.id).after(@spellView.el).remove() - @$el.find('#' + @spellTabView.id).after(@spellTabView.el).remove() + @$el.find('#' + @spellTopBarView.id).after(@spellTopBarView.el).remove() @castButton?.attachTo @spellView - Backbone.Mediator.publish 'tome:spell-shown', thang: thang, spell: spell @updateSpellPalette thang, spell - @spellList?.setThangAndSpell thang, spell @spellView?.setThang thang - @spellTabView?.setThang thang updateSpellPalette: (thang, spell) -> - return unless thang and @spellPaletteView?.thang isnt thang and thang.programmableProperties or thang.apiProperties + return unless thang and @spellPaletteView?.thang isnt thang and (thang.programmableProperties or thang.apiProperties or thang.programmableHTMLProperties) useHero = /hero/.test(spell.getSource()) or not /(self[\.\:]|this\.|\@)/.test(spell.getSource()) @spellPaletteView = @insertSubView new SpellPaletteView { thang, @supermodel, programmable: spell?.canRead(), language: spell?.language ? @options.session.get('codeLanguage'), session: @options.session, level: @options.level, courseID: @options.courseID, courseInstanceID: @options.courseInstanceID, useHero } @spellPaletteView.toggleControls {}, spell.view.controlsEnabled if spell?.view # TODO: know when palette should have been disabled but didn't exist @@ -246,13 +206,26 @@ module.exports = class TomeView extends CocoView @cast() onSelectPrimarySprite: (e) -> - # This is only fired by PlayLevelView for hero levels currently - # TODO: Don't hard code these hero names + if @options.level.isType('web-dev') + @setSpellView @spells['hero-placeholder/plan'], @fakeProgrammableThang + return + # This is fired by PlayLevelView if @options.session.get('team') is 'ogres' Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder 1' else Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder' + createFakeProgrammableThang: -> + return null unless hero = _.find @options.level.get('thangs'), id: 'Hero Placeholder' + return null unless programmableConfig = _.find(hero.components, (component) -> component.config?.programmableMethods).config + usesHTMLConfig = _.find(hero.components, (component) -> component.config?.programmableHTMLProperties).config + console.warn "Couldn't find usesHTML config; is it presented and not defaulted on the Hero Placeholder?" unless usesHTMLConfig + thang = + id: 'Hero Placeholder' + isProgrammable: true + thang = _.merge thang, programmableConfig, usesHTMLConfig + thang + destroy: -> spell.destroy() for spellKey, spell of @spells @worker?.terminate() diff --git a/app/views/play/level/tome/editor/zatanna.coffee b/app/views/play/level/tome/editor/zatanna.coffee index 81d2ef5d6..57907b17e 100644 --- a/app/views/play/level/tome/editor/zatanna.coffee +++ b/app/views/play/level/tome/editor/zatanna.coffee @@ -1,6 +1,6 @@ utils = require 'core/utils' -defaults = +defaults = autoLineEndings: # Mapping ace mode language to line endings to automatically insert # E.g. javascript: ";" @@ -69,7 +69,7 @@ module.exports = class Zatanna @editor.commands.on 'afterExec', @doLiveCompletion setAceOptions: () -> - aceOptions = + aceOptions = 'enableLiveAutocompletion': @options.liveCompletion 'enableBasicAutocompletion': @options.basic 'enableSnippets': @options.completers.snippets @@ -92,20 +92,20 @@ module.exports = class Zatanna else if typeof comp is 'string' if @completers[comp]? and @editor.completers[@completers[comp].pos] isnt @completers[comp].comp @editor.completers.splice(@completers[comp].pos, 0, @completers[comp].comp) - else + else @editor.completers = [] for type, comparator of @completers if @options.completers[type] is true - @activateCompleter type + @activateCompleter type addSnippets: (snippets, language) -> @options.language = language ace.config.loadModule 'ace/ext/language_tools', () => @snippetManager = ace.require('ace/snippets').snippetManager snippetModulePath = 'ace/snippets/' + language - ace.config.loadModule snippetModulePath, (m) => + ace.config.loadModule snippetModulePath, (m) => if m? - @snippetManager.files[language] = m + @snippetManager.files[language] = m @snippetManager.unregister m.snippets if m.snippets?.length > 0 @snippetManager.unregister @oldSnippets if @oldSnippets? m.snippets = if @options.snippetsLangDefaults then @snippetManager.parseSnippetFile m.snippetText else [] @@ -265,7 +265,7 @@ module.exports = class Zatanna when 'python' then 'loop:\n self.moveRight()\n ${1:}' when 'javascript' then 'loop {\n this.moveRight();\n ${1:}\n}' else content - if /loop/.test(content) and level.get('type') in ['course', 'course-ladder'] + if /loop/.test(content) and level.isType('course', 'course-ladder') # Temporary hackery to make it look like we meant while True: in our loop snippets until we can update everything content = switch e.language when 'python' then content.replace /loop:/, 'while True:' diff --git a/app/views/play/menu/GameMenuModal.coffee b/app/views/play/menu/GameMenuModal.coffee index a62a47cc2..b0489865c 100644 --- a/app/views/play/menu/GameMenuModal.coffee +++ b/app/views/play/menu/GameMenuModal.coffee @@ -5,7 +5,6 @@ submenuViews = [ require 'views/play/menu/SaveLoadView' require 'views/play/menu/OptionsView' require 'views/play/menu/GuideView' - require 'views/play/menu/MultiplayerView' ] module.exports = class GameMenuModal extends ModalView @@ -31,11 +30,10 @@ module.exports = class GameMenuModal extends ModalView getRenderData: (context={}) -> context = super(context) docs = @options.level.get('documentation') ? {} - submenus = ['guide', 'options', 'save-load', 'multiplayer'] + submenus = ['guide', 'options', 'save-load'] submenus = _.without submenus, 'options' if window.serverConfig.picoCTF submenus = _.without submenus, 'guide' unless docs.specificArticles?.length or docs.generalArticles?.length or window.serverConfig.picoCTF submenus = _.without submenus, 'save-load' unless me.isAdmin() or /https?:\/\/localhost/.test(window.location.href) - submenus = _.without submenus, 'multiplayer' unless me.isAdmin() or (@level?.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] and @level.get('slug') not in ['ace-of-coders', 'elemental-wars']) @includedSubmenus = submenus context.showTab = @options.showTab ? submenus[0] context.submenus = submenus @@ -43,11 +41,10 @@ module.exports = class GameMenuModal extends ModalView 'options': 'cog' 'guide': 'list' 'save-load': 'floppy-disk' - 'multiplayer': 'globe' context showsChooseHero: -> - return false if @level?.get('type') in ['course', 'course-ladder'] + return false if @level?.isType('course', 'course-ladder') return false if @options.levelID in ['zero-sum', 'ace-of-coders', 'elemental-wars'] return true @@ -55,7 +52,6 @@ module.exports = class GameMenuModal extends ModalView super() @insertSubView new submenuView @options for submenuView in submenuViews firstView = switch @options.showTab - when 'multiplayer' then @subviews.multiplayer_view when 'guide' then @subviews.guide_view else if 'guide' in @includedSubmenus then @subviews.guide_view else @subviews.options_view diff --git a/app/views/play/menu/GuideView.coffee b/app/views/play/menu/GuideView.coffee index d456ad687..fcd794117 100644 --- a/app/views/play/menu/GuideView.coffee +++ b/app/views/play/menu/GuideView.coffee @@ -19,7 +19,7 @@ module.exports = class LevelGuideView extends CocoView @levelSlug = options.level.get('slug') @sessionID = options.session.get('_id') @requiresSubscription = not me.isPremium() - @isCourseLevel = options.level.get('type', true) in ['course', 'course-ladder'] + @isCourseLevel = options.level.isType('course', 'course-ladder') @helpVideos = if @isCourseLevel then [] else options.level.get('helpVideos') ? [] @trackedHelpVideoStart = @trackedHelpVideoFinish = false # A/B Testing video tutorial styles diff --git a/app/views/play/menu/MultiplayerView.coffee b/app/views/play/menu/MultiplayerView.coffee deleted file mode 100644 index 13b28f149..000000000 --- a/app/views/play/menu/MultiplayerView.coffee +++ /dev/null @@ -1,229 +0,0 @@ -CocoView = require 'views/core/CocoView' -template = require 'templates/play/menu/multiplayer-view' -{me} = require 'core/auth' -ThangType = require 'models/ThangType' -LadderSubmissionView = require 'views/play/common/LadderSubmissionView' -RealTimeModel = require 'models/RealTimeModel' -RealTimeCollection = require 'collections/RealTimeCollection' - -module.exports = class MultiplayerView extends CocoView - id: 'multiplayer-view' - className: 'tab-pane' - template: template - - subscriptions: - 'ladder:game-submitted': 'onGameSubmitted' - - events: - 'click textarea': 'onClickLink' - 'change #multiplayer': 'updateLinkSection' - 'click #create-game-button': 'onCreateRealTimeGame' - 'click #join-game-button': 'onJoinRealTimeGame' - 'click #leave-game-button': 'onLeaveRealTimeGame' - - constructor: (options) -> - super(options) - @level = options.level - @levelID = @level?.get 'slug' - @session = options.session - @listenTo @session, 'change:multiplayer', @updateLinkSection - @watchRealTimeSessions() if @level?.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() - - destroy: -> - @realTimeSessions?.off 'add', @onRealTimeSessionAdded - @currentRealTimeSession?.off 'change', @onCurrentRealTimeSessionChanged - collection.off() for id, collection of @realTimeSessionsPlayers - super() - - getRenderData: -> - c = super() - c.joinLink = "#{document.location.href.replace(/\?.*/, '').replace('#', '')}?session=#{@session.id}" - c.multiplayer = @session.get 'multiplayer' - c.team = @session.get 'team' - c.levelSlug = @levelID - # For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet. - if @level?.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] - c.ladderGame = true - c.readyToRank = @session?.readyToRank() - - # Real-time multiplayer stuff - if @level?.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() - c.levelID = @session.get('levelID') - c.realTimeSessions = @realTimeSessions - c.currentRealTimeSession = @currentRealTimeSession if @currentRealTimeSession - c.realTimeSessionsPlayers = @realTimeSessionsPlayers if @realTimeSessionsPlayers - # console.log 'MultiplayerView getRenderData', c.levelID - # console.log 'realTimeSessions', c.realTimeSessions - # console.log c.realTimeSessions.at(c.realTimeSessions.length - 1).get('state') if c.realTimeSessions.length > 0 - # console.log 'currentRealTimeSession', c.currentRealTimeSession - # console.log 'realTimeSessionPlayers', c.realTimeSessionsPlayers - - c - - afterRender: -> - super() - @updateLinkSection() - @ladderSubmissionView = new LadderSubmissionView session: @session, level: @level - @insertSubView @ladderSubmissionView, @$el.find('.ladder-submission-view') - @$el.find('#created-multiplayer-session').toggle Boolean(@currentRealTimeSession?) - @$el.find('#create-game-button').toggle Boolean(not (@currentRealTimeSession?)) - - onClickLink: (e) -> - e.target.select() - - onGameSubmitted: (e) -> - # Preserve the supermodel as we navigate back to the ladder. - viewArgs = [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @levelID] - ladderURL = "/play/ladder/#{@levelID}" - if leagueID = @getQueryVariable 'league' - leagueType = if @level?.get('type') is 'course-ladder' then 'course' else 'clan' - viewArgs.push leagueType - viewArgs.push leagueID - ladderURL += "/#{leagueType}/#{leagueID}" - ladderURL += '#my-matches' - Backbone.Mediator.publish 'router:navigate', route: ladderURL, viewClass: 'views/ladder/LadderView', viewArgs: viewArgs - - updateLinkSection: -> - multiplayer = @$el.find('#multiplayer').prop('checked') - la = @$el.find('#link-area') - la.toggle if @level?.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] then false else Boolean(multiplayer) - true - - onHidden: -> - multiplayer = Boolean(@$el.find('#multiplayer').prop('checked')) - @session.set('multiplayer', multiplayer) - - # Real-time Multiplayer ###################################################### - # - # This view is responsible for joining and leaving real-time multiplayer games. - # - # It performs these actions: - # Display your current game (level, players) - # Display open games - # Create game button, if not in a game - # Join game button - # Leave game button, if in a game - # - # It monitors these: - # Real-time multiplayer sessions (for open games, player states) - # Current real-time multiplayer game session for changes - # Players for real-time multiplayer game session - # - # Real-time state variables: - # @realTimeSessionsPlayers - Collection of player lists for active real-time multiplayer sessions - # @realTimeSessions - Active real-time multiplayer sessions - # @currentRealTimeSession - Our current real-time multiplayer session - # - # TODO: Ditch backfire and just use Firebase directly. Easier to debug, richer APIs (E.g. presence stuff). - - watchRealTimeSessions: -> - # Setup monitoring of real-time multiplayer level sessions - @realTimeSessionsPlayers = {} - # TODO: only request sessions for this level, !team, etc. - @realTimeSessions = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}") - @realTimeSessions.on 'add', @onRealTimeSessionAdded - @realTimeSessions.each (rts) => @watchRealTimeSession rts - - watchRealTimeSession: (rts) -> - return if rts.get('state') is 'finished' - return if rts.get('levelID') isnt @session.get('levelID') - # console.log 'MultiplayerView watchRealTimeSession', rts - # Setup monitoring of players for given session - # TODO: verify we need this - realTimeSession = new RealTimeModel("multiplayer_level_sessions/#{@levelID}/#{rts.id}") - realTimeSession.on 'change', @onRealTimeSessionChanged - @realTimeSessionsPlayers[rts.id] = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}/#{rts.id}/players") - @realTimeSessionsPlayers[rts.id].on 'add', @onRealTimePlayerAdded - @findCurrentRealTimeSession rts - - findCurrentRealTimeSession: (rts) -> - # Look for our current real-time session (level, level state, member player) - return if @currentRealTimeSession or not @realTimeSessionsPlayers? - if rts.get('levelID') is @session.get('levelID') and rts.get('state') isnt 'finished' - @realTimeSessionsPlayers[rts.id].each (player) => - if player.id is me.id and player.get('state') isnt 'left' - # console.log 'MultiplayerView found current real-time session', rts - @currentRealTimeSession = new RealTimeModel("multiplayer_level_sessions/#{@levelID}/#{rts.id}") - @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - - # TODO: Is this necessary? Shouldn't everyone already know we joined a game at this point? - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: @currentRealTimeSession.id - - onRealTimeSessionAdded: (rts) => - @watchRealTimeSession rts - @render() - - onRealTimeSessionChanged: (rts) => - # console.log 'MultiplayerView onRealTimeSessionChanged', rts.get('state') - # TODO: @realTimeSessions isn't updated before we call render() here - # TODO: so this game isn't updated in open games list - @render?() - - onCurrentRealTimeSessionChanged: (rts) => - # console.log 'MultiplayerView onCurrentRealTimeSessionChanged', rts - if rts.get('state') is 'finished' - @currentRealTimeSession.off 'change', @onCurrentRealTimeSessionChanged - @currentRealTimeSession = null - @render?() - - onRealTimePlayerAdded: (e) => - @render?() - - onCreateRealTimeGame: -> - @playSound 'menu-button-click' - s = @realTimeSessions.create { - creator: @session.get('creator') - creatorName: @session.get('creatorName') - levelID: @session.get('levelID') - created: (new Date()).toISOString() - state: 'creating' - } - @currentRealTimeSession = @realTimeSessions.get(s.id) - @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - # TODO: s.id === @currentRealTimeSession.id ? - players = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}/#{@currentRealTimeSession.id}/players") - players.create - id: me.id - state: 'coding' - name: @session.get('creatorName') - team: @session.get('team') - level_session: @session.id - Backbone.Mediator.publish 'real-time-multiplayer:created-game', realTimeSessionID: @currentRealTimeSession.id - @render() - - onJoinRealTimeGame: (e) -> - return if @currentRealTimeSession - @playSound 'menu-button-click' - item = @$el.find(e.target).data('item') - @currentRealTimeSession = @realTimeSessions.get(item.id) - @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - if @realTimeSessionsPlayers[item.id] - - # TODO: SpellView updateTeam() should take care of this team swap update in the real-time multiplayer session - creatorID = @currentRealTimeSession.get('creator') - creator = @realTimeSessionsPlayers[item.id].get(creatorID) - creatorTeam = creator.get('team') - myTeam = @session.get('team') - if myTeam is creatorTeam - myTeam = if creatorTeam is 'humans' then 'ogres' else 'humans' - - @realTimeSessionsPlayers[item.id].create - id: me.id - state: 'coding' - name: me.get('name') - team: myTeam - level_session: @session.id - else - console.error 'MultiplayerView onJoinRealTimeGame did not have a players collection', @currentRealTimeSession - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: @currentRealTimeSession.id - @render() - - onLeaveRealTimeGame: (e) -> - @playSound 'menu-button-click' - if @currentRealTimeSession - @currentRealTimeSession.off 'change', @onCurrentRealTimeSessionChanged - @currentRealTimeSession = null - Backbone.Mediator.publish 'real-time-multiplayer:left-game', userID: me.id - else - console.error "Tried to leave a game with no currentMultiplayerSession" - @render() diff --git a/bower.json b/bower.json index 333ffe8ec..85732107b 100644 --- a/bower.json +++ b/bower.json @@ -32,9 +32,8 @@ "firepad": "~0.1.2", "marked": "~0.3.0", "moment": "~2.5.0", - "aether": "~0.5.6", + "aether": "~0.5.21", "underscore.string": "~2.3.3", - "firebase": "~1.0.2", "d3": "~3.4.4", "jsondiffpatch": "0.1.8", "nanoscroller": "~0.8.0", @@ -44,7 +43,6 @@ "validated-backbone-mediator": "~0.1.3", "jquery.browser": "~0.0.6", "modernizr": "~2.8.3", - "backfire": "~0.3.0", "fastclick": "~1.0.3", "three.js": "~0.71.0", "lscache": "~1.0.5", @@ -64,9 +62,6 @@ "backbone": { "main": "backbone.js" }, - "backfire": { - "main": "backbone-firebase.min.js" - }, "lodash": { "main": "dist/lodash.js" }, @@ -112,13 +107,12 @@ "aether": { "main": [ "build/aether.js", - "build/clojure.js", "build/coffeescript.js", - "build/io.js", "build/javascript.js", "build/lua.js", "build/python.js", - "build/java.js" + "build/java.js", + "build/html.js" ] } }, diff --git a/config.coffee b/config.coffee index 75e614786..739525c65 100644 --- a/config.coffee +++ b/config.coffee @@ -115,7 +115,7 @@ exports.config = 'javascripts/app/vendor/aether-lua.js': 'bower_components/aether/build/lua.js' 'javascripts/app/vendor/aether-java.js': 'bower_components/aether/build/java.js' 'javascripts/app/vendor/aether-python.js': 'bower_components/aether/build/python.js' - 'javascripts/app/vendor/aether-java.js': 'bower_components/aether/build/java.js' + 'javascripts/app/vendor/aether-html.js': 'bower_components/aether/build/html.js' # Any vendor libraries we don't want the client to load immediately 'javascripts/app/vendor/d3.js': regJoin('^bower_components/d3') diff --git a/package.json b/package.json index fe72f18cf..5b3aeff4f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "JQDeferred": "~2.1.0", "ace-builds": "https://github.com/ajaxorg/ace-builds/archive/3fb55e8e374ab02ce47c1ae55ffb60a1835f3055.tar.gz", - "aether": "~0.5.6", + "aether": "~0.5.21", "async": "0.2.x", "aws-sdk": "~2.0.0", "bayesian-battle": "0.0.7", diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee index 4ef0abfb8..e95af999a 100644 --- a/server/middleware/classrooms.coffee +++ b/server/middleware/classrooms.coffee @@ -145,6 +145,7 @@ module.exports = query = {} query = {adminOnly: {$ne: true}} unless req.user?.isAdmin() courses = yield Course.find(query) + courses = Course.sortCourses courses campaigns = yield Campaign.find({_id: {$in: (course.get('campaignID') for course in courses)}}) campaignMap = {} campaignMap[campaign.id] = campaign for campaign in campaigns diff --git a/server/middleware/course-instances.coffee b/server/middleware/course-instances.coffee index 362dc046c..e32f1eccb 100644 --- a/server/middleware/course-instances.coffee +++ b/server/middleware/course-instances.coffee @@ -140,6 +140,18 @@ module.exports = res.status(200).send(classroom) + fetchCourse: wrap (req, res) -> + courseInstance = yield database.getDocFromHandle(req, CourseInstance) + if not courseInstance + throw new errors.NotFound('Course Instance not found.') + + course = yield Course.findById(courseInstance.get('courseID')).select(parse.getProjectFromReq(req)) + if not course + throw new errors.NotFound('Course not found.') + + res.status(200).send(course.toObject({req: req})) + + fetchRecent: wrap (req, res) -> query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]} query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay? diff --git a/server/middleware/courses.coffee b/server/middleware/courses.coffee index 22cd0a48d..34b05715d 100644 --- a/server/middleware/courses.coffee +++ b/server/middleware/courses.coffee @@ -57,4 +57,5 @@ module.exports = dbq = Model.find(query) dbq.select(parse.getProjectFromReq(req)) results = yield database.viewSearch(dbq, req) + results = Course.sortCourses results res.send(results) diff --git a/server/models/AnalyticsLogEvent.coffee b/server/models/AnalyticsLogEvent.coffee index e824ad776..e00234607 100644 --- a/server/models/AnalyticsLogEvent.coffee +++ b/server/models/AnalyticsLogEvent.coffee @@ -31,6 +31,6 @@ AnalyticsLogEventSchema.statics.logEvent = (user, event, properties={}) -> unless config.proxy analyticsMongoose = mongoose.createConnection() analyticsMongoose.open "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}", (error) -> - log.error "Couldnt connect to analytics", error if error - + log.error "Couldn't connect to analytics", error if error + module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection) diff --git a/server/models/Course.coffee b/server/models/Course.coffee index 7ab4ec71d..bab22640f 100644 --- a/server/models/Course.coffee +++ b/server/models/Course.coffee @@ -13,4 +13,32 @@ CourseSchema.statics.editableProperties = [] CourseSchema.statics.jsonSchema = jsonSchema +CourseSchema.statics.sortCourses = (courses) -> + ordering = [ + 'introduction-to-computer-science' + 'computer-science-2' + 'game-dev-1' + 'web-dev-1' + 'computer-science-3' + 'game-dev-2' + 'web-dev-2' + 'computer-science-4' + 'game-dev-3' + 'web-dev-3' + 'computer-science-5' + 'game-dev-4' + 'web-dev-4' + 'computer-science-6' + 'game-dev-5' + 'web-dev-5' + 'computer-science-7' + 'game-dev-6' + 'web-dev-6' + 'computer-science-8' + ] + _.sortBy courses, (course) -> + index = ordering.indexOf(course.get?('slug') or course.slug) + index = 9001 if index is -1 + index + module.exports = Course = mongoose.model 'course', CourseSchema, 'courses' diff --git a/server/models/LevelSession.coffee b/server/models/LevelSession.coffee index bc3076fdc..b68263841 100644 --- a/server/models/LevelSession.coffee +++ b/server/models/LevelSession.coffee @@ -83,7 +83,7 @@ LevelSessionSchema.pre 'save', (next) -> next() LevelSessionSchema.statics.privateProperties = ['code', 'submittedCode', 'unsubscribed'] -LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code', 'codeLanguage', 'completed', 'state', +LevelSessionSchema.statics.editableProperties = ['players', 'code', 'codeLanguage', 'completed', 'state', 'levelName', 'creatorName', 'levelID', 'chat', 'teamSpells', 'submitted', 'submittedCodeLanguage', 'unsubscribed', 'playtime', 'heroConfig', 'team', @@ -110,7 +110,7 @@ if config.mongo.level_session_replica_string? levelSessionMongo = mongoose.createConnection() levelSessionMongo.open config.mongo.level_session_replica_string, (error) -> if error - log.error "Couldnt connect to session mongo!", error + log.error "Couldn't connect to session mongo!", error else log.info "Connected to seperate level session server with string", config.mongo.level_session_replica_string else @@ -122,7 +122,7 @@ if config.mongo.level_session_aux_replica_string? auxLevelSessionMongo = mongoose.createConnection() auxLevelSessionMongo.open config.mongo.level_session_aux_replica_string, (error) -> if error - log.error "Couldnt connect to AUX session mongo!", error + log.error "Couldn't connect to AUX session mongo!", error else log.info "Connected to seperate level AUX session server with string", config.mongo.level_session_aux_replica_string diff --git a/server/routes/index.coffee b/server/routes/index.coffee index 67e6d2c26..2d1493096 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -88,7 +88,8 @@ module.exports.setup = (app) -> app.get('/db/course_instance/:handle/levels/:levelOriginal/sessions/:sessionID/next', mw.courseInstances.fetchNextLevel) app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers) app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom) - + app.get('/db/course_instance/:handle/course', mw.auth.checkLoggedIn(), mw.courseInstances.fetchCourse) + app.put('/db/user/:handle', mw.users.resetEmailVerifiedFlag) app.delete('/db/user/:handle', mw.users.removeFromClassrooms) app.get('/db/user', mw.users.fetchByGPlusID, mw.users.fetchByFacebookID) diff --git a/spec/server/functional/course_instance.spec.coffee b/spec/server/functional/course_instance.spec.coffee index 5c00cec82..cbcbd8bb6 100644 --- a/spec/server/functional/course_instance.spec.coffee +++ b/spec/server/functional/course_instance.spec.coffee @@ -375,6 +375,25 @@ describe 'GET /db/course_instance/:handle/classroom', -> expect(res.statusCode).toBe(403) done() +describe 'GET /db/course_instance/:handle/course', -> + + beforeEach utils.wrap (done) -> + yield utils.clearModels [User, CourseInstance, Classroom] + @course = new Course({}) + yield @course.save() + @courseInstance = new CourseInstance({courseID: @course._id}) + yield @courseInstance.save() + @url = getURL("/db/course_instance/#{@courseInstance.id}/course") + done() + + it 'returns the course instance\'s referenced course', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser user + [res, body] = yield request.getAsync(@url, {json: true}) + expect(res.statusCode).toBe(200) + expect(body._id).toBe(@course.id) + done() + describe 'POST /db/course_instance/-/recent', -> url = getURL('/db/course_instance/-/recent')