codecombat/app/lib/LevelLoader.coffee
Phoenix Eliot d3db19dee3 Report timeout problems, and fix retries
Fix getting model URLs
2016-08-10 15:28:25 -07:00

573 lines
25 KiB
CoffeeScript

Level = require 'models/Level'
LevelComponent = require 'models/LevelComponent'
LevelSystem = require 'models/LevelSystem'
Article = require 'models/Article'
LevelSession = require 'models/LevelSession'
ThangType = require 'models/ThangType'
ThangNamesCollection = require 'collections/ThangNamesCollection'
CocoClass = require 'core/CocoClass'
AudioPlayer = require 'lib/AudioPlayer'
app = require 'core/application'
World = require 'lib/world/world'
utils = require 'core/utils'
{sendContactMessage} = require 'core/contact'
LOG = false
# This is an initial stab at unifying loading and setup into a single place which can
# monitor everything and keep a LoadingScreen visible overall progress.
#
# Would also like to incorporate into here:
# * World Building
# * Sprite map generation
# * Connecting to Firebase
# LevelLoader depends on SuperModel retrying timed out requests, as these occasionally happen during play.
# If LevelLoader ever moves away from SuperModel, it will have to manage its own retries.
reportedLoadErrorAlready = false
module.exports = class LevelLoader extends CocoClass
constructor: (options) ->
@t0 = new Date().getTime()
super()
@supermodel = options.supermodel
@supermodel.setMaxProgress 0.2
@levelID = options.levelID
@sessionID = options.sessionID
@opponentSessionID = options.opponentSessionID
@team = options.team
@headless = options.headless
@sessionless = options.sessionless
@fakeSessionConfig = options.fakeSessionConfig
@spectateMode = options.spectateMode ? false
@observing = options.observing
@courseID = options.courseID
@worldNecessities = []
@listenTo @supermodel, 'resource-loaded', @onWorldNecessityLoaded
@listenTo @supermodel, 'failed', @onWorldNecessityLoadFailed
@loadLevel()
@loadAudio()
@playJingle()
if @supermodel.finished()
@onSupermodelLoaded()
else
@loadTimeoutID = setTimeout @reportLoadError.bind(@), 30000
@listenToOnce @supermodel, 'loaded-all', @onSupermodelLoaded
# 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
@onLevelLoaded()
else
@level = @supermodel.loadModel(@level, 'level').model
@listenToOnce @level, 'sync', @onLevelLoaded
reportLoadError: ->
return if me.isAdmin() or /dev=true/.test(window.location?.href ? '') or reportedLoadErrorAlready
reportedLoadErrorAlready = true
context = email: me.get('email')
context.message = """
Automatic Report - Unable to Load Level (LevelLoader timeout)
URL: #{window?.location?.toString()}
These models are marked as having not loaded:
#{JSON.stringify(@supermodel.report().map (m) -> _.result(m.model, 'url'))}
Object.keys(supermodel.models):
#{JSON.stringify(Object.keys(@supermodel.models))}
"""
if $.browser
context.browser = "#{$.browser.platform} #{$.browser.name} #{$.browser.versionNumber}"
context.screenSize = "#{screen?.width ? $(window).width()} x #{screen?.height ? $(window).height()}"
context.subject = "Level Load Error: #{@work?.level?.name or 'Unknown Level'}"
context.levelSlug = @work?.level?.slug
sendContactMessage context
onLevelLoaded: ->
if not @sessionless and @level.isType('hero', 'hero-ladder', 'hero-coop', 'course')
@sessionDependenciesRegistered = {}
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 = ->
return 'course' if arguments[0] is 'type'
originalGet.apply @, arguments
if window.serverConfig.picoCTF
@supermodel.addRequestResource(url: '/picoctf/problems', success: (picoCTFProblems) =>
@level?.picoCTFProblem = _.find picoCTFProblems, pid: @level.get('picoCTFProblem')
).load()
if @sessionless
null
else if @fakeSessionConfig?
@loadFakeSession()
else
@loadSession()
@populateLevel()
# Session Loading
loadFakeSession: ->
initVals =
level:
original: @level.get('original')
majorVersion: @level.get('version').major
creator: me.id
state:
complete: false
scripts: {}
permissions: [
{target: me.id, access: 'owner'}
{target: 'public', access: 'write'}
]
codeLanguage: @fakeSessionConfig.codeLanguage or me.get('aceConfig')?.language or 'python'
_id: 'A Fake Session ID'
@session = new LevelSession initVals
@session.loaded = true
@fakeSessionConfig.callback? @session, @level
# TODO: set the team if we need to, for multiplayer
# TODO: just finish the part where we make the submit button do what is right when we are fake
# TODO: anything else to make teacher session-less play make sense when we are fake
# TODO: make sure we are not actually calling extra save/patch/put things throwing warnings because we know we are fake and so we shouldn't try to do that
for method in ['save', 'patch', 'put']
@session[method] = -> console.error "We shouldn't be doing a session.#{method}, since it's a fake session."
@session.fake = true
@loadDependenciesForSession @session
loadSession: ->
if @sessionID
url = "/db/level.session/#{@sessionID}"
url += "?interpret=true" if @spectateMode
else
url = "/db/level/#{@levelID}/session"
url += "?team=#{@team}" if @team
url += "?course=#{@courseID}" if @courseID
session = new LevelSession().setURL url
session.project = ['creator', 'team', 'heroConfig', 'codeLanguage', 'submittedCodeLanguage', 'state', 'submittedCode'] if @headless
@sessionResource = @supermodel.loadModel(session, 'level_session', {cache: false})
@session = @sessionResource.model
if @opponentSessionID
opponentURL = "/db/level.session/#{@opponentSessionID}?interpret=true"
opponentSession = new LevelSession().setURL opponentURL
opponentSession.project = session.project if @headless
@opponentSessionResource = @supermodel.loadModel(opponentSession, 'opponent_session', {cache: false})
@opponentSession = @opponentSessionResource.model
if @session.loaded
@session.setURL '/db/level.session/' + @session.id
@loadDependenciesForSession @session
else
@listenToOnce @session, 'sync', ->
@session.setURL '/db/level.session/' + @session.id
@loadDependenciesForSession @session
if @opponentSession
if @opponentSession.loaded
@loadDependenciesForSession @opponentSession
else
@listenToOnce @opponentSession, 'sync', @loadDependenciesForSession
loadDependenciesForSession: (session) ->
console.log "Loading dependencies for session: ", session if LOG
if me.id isnt session.get 'creator'
session.patch = session.save = -> console.error "Not saving session, since we didn't create it."
else if codeLanguage = utils.getQueryVariable 'codeLanguage'
session.set 'codeLanguage', codeLanguage
@loadCodeLanguagesForSession session
if compressed = session.get 'interpret'
uncompressed = LZString.decompressFromUTF16 compressed
code = session.get 'code'
code[if session.get('team') is 'humans' then 'hero-placeholder' else 'hero-placeholder-1'].plan = uncompressed
session.set 'code', code
session.unset 'interpret'
if session.get('codeLanguage') in ['io', 'clojure']
session.set 'codeLanguage', 'python'
if session is @session
@addSessionBrowserInfo session
# hero-ladder games require the correct session team in level:loaded
team = @team ? @session.get('team')
Backbone.Mediator.publish 'level:loaded', level: @level, team: team
Backbone.Mediator.publish 'level:session-loaded', level: @level, session: @session
@consolidateFlagHistory() if @opponentSession?.loaded
else if session is @opponentSession
@consolidateFlagHistory() if @session.loaded
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"
if heroResource = @maybeLoadURL(url, ThangType, 'thang')
console.log "Pushing resource: ", heroResource if LOG
@worldNecessities.push heroResource
@sessionDependenciesRegistered[session.id] = true
return
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 ?= {}
heroConfig.inventory ?= feet: '53e237bf53457600003e3f05' # If all else fails, assign simple boots.
heroConfig.thangType ?= '529ffbf1cf1818f2be000001' # If all else fails, assign Tharin as the hero.
session.set 'heroConfig', heroConfig unless _.isEqual heroConfig, session.get('heroConfig')
url = "/db/thang.type/#{heroConfig.thangType}/version"
if heroResource = @maybeLoadURL(url, ThangType, 'thang')
@worldNecessities.push heroResource
else
heroThangType = @supermodel.getModel url
@loadDefaultComponentsForThangType heroThangType
@loadThangsRequiredByThangType heroThangType
for itemThangType in _.values(heroConfig.inventory)
url = "/db/thang.type/#{itemThangType}/version?project=name,components,original,rasterIcon,kind"
if itemResource = @maybeLoadURL(url, ThangType, 'thang')
@worldNecessities.push itemResource
else
itemThangType = @supermodel.getModel url
@loadDefaultComponentsForThangType itemThangType
@loadThangsRequiredByThangType itemThangType
@sessionDependenciesRegistered[session.id] = true
if _.size(@sessionDependenciesRegistered) is 2 and @checkAllWorldNecessitiesRegisteredAndLoaded()
@onWorldNecessitiesLoaded()
loadCodeLanguagesForSession: (session) ->
codeLanguages = _.uniq _.filter [session.get('codeLanguage') or 'python', session.get('submittedCodeLanguage')]
for codeLanguage in codeLanguages
do (codeLanguage) =>
modulePath = "vendor/aether-#{codeLanguage}"
return unless application.moduleLoader?.load modulePath
languageModuleResource = @supermodel.addSomethingResource 'language_module'
onModuleLoaded = (e) ->
return unless e.id is modulePath
languageModuleResource.markLoaded()
@stopListening application.moduleLoader, 'loaded', onModuleLoaded # listenToOnce might work here instead, haven't tried
@listenTo application.moduleLoader, 'loaded', onModuleLoaded
addSessionBrowserInfo: (session) ->
return unless me.id is session.get 'creator'
return unless $.browser?
browser = {}
browser['desktop'] = $.browser.desktop if $.browser.desktop
browser['name'] = $.browser.name if $.browser.name
browser['platform'] = $.browser.platform if $.browser.platform
browser['version'] = $.browser.version if $.browser.version
session.set 'browser', browser
session.patch() unless session.fake
consolidateFlagHistory: ->
state = @session.get('state') ? {}
myFlagHistory = _.filter state.flagHistory ? [], team: @session.get('team')
opponentFlagHistory = _.filter @opponentSession.get('state')?.flagHistory ? [], team: @opponentSession.get('team')
state.flagHistory = myFlagHistory.concat opponentFlagHistory
@session.set 'state', state
# Grabbing the rest of the required data for the level
populateLevel: ->
thangIDs = []
componentVersions = []
systemVersions = []
articleVersions = []
flagThang = thangType: '53fa25f25bc220000052c2be', id: 'Placeholder Flag', components: []
for thang in (@level.get('thangs') or []).concat [flagThang]
thangIDs.push thang.thangType
@loadThangsRequiredByLevelThang(thang)
for comp in thang.components or []
componentVersions.push _.pick(comp, ['original', 'majorVersion'])
for system in @level.get('systems') or []
systemVersions.push _.pick(system, ['original', 'majorVersion'])
if indieSprites = system?.config?.indieSprites
for indieSprite in indieSprites
thangIDs.push indieSprite.thangType
unless @headless
for article in @level.get('documentation')?.generalArticles or []
articleVersions.push _.pick(article, ['original', 'majorVersion'])
objUniq = (array) -> _.uniq array, false, (arg) -> JSON.stringify(arg)
worldNecessities = []
@thangIDs = _.uniq thangIDs
@thangNames = new ThangNamesCollection(@thangIDs)
worldNecessities.push @supermodel.loadCollection(@thangNames, 'thang_names')
@listenToOnce @thangNames, 'sync', @onThangNamesLoaded
worldNecessities.push @sessionResource if @sessionResource?.isLoading
worldNecessities.push @opponentSessionResource if @opponentSessionResource?.isLoading
for obj in objUniq componentVersions
url = "/db/level.component/#{obj.original}/version/#{obj.majorVersion}"
worldNecessities.push @maybeLoadURL(url, LevelComponent, 'component')
for obj in objUniq systemVersions
url = "/db/level.system/#{obj.original}/version/#{obj.majorVersion}"
worldNecessities.push @maybeLoadURL(url, LevelSystem, 'system')
for obj in objUniq articleVersions
url = "/db/article/#{obj.original}/version/#{obj.majorVersion}"
@maybeLoadURL url, Article, 'article'
if obj = @level.get 'nextLevel' # TODO: update to get next level from campaigns, not this old property
url = "/db/level/#{obj.original}/version/#{obj.majorVersion}"
@maybeLoadURL url, Level, 'level'
@worldNecessities = @worldNecessities.concat worldNecessities
loadThangsRequiredByLevelThang: (levelThang) ->
@loadThangsRequiredFromComponentList levelThang.components
loadThangsRequiredByThangType: (thangType) ->
@loadThangsRequiredFromComponentList thangType.get('components')
loadThangsRequiredFromComponentList: (components) ->
return unless components
requiredThangTypes = []
for component in components when component.config
if component.original is LevelComponent.EquipsID
requiredThangTypes.push itemThangType for itemThangType in _.values (component.config.inventory ? {})
else if component.config.requiredThangTypes
requiredThangTypes = requiredThangTypes.concat component.config.requiredThangTypes
extantRequiredThangTypes = _.filter requiredThangTypes
if extantRequiredThangTypes.length < requiredThangTypes.length
console.error "Some Thang had a blank required ThangType in components list:", components
for thangType in extantRequiredThangTypes
url = "/db/thang.type/#{thangType}/version?project=name,components,original,rasterIcon,kind,prerenderedSpriteSheetData"
@worldNecessities.push @maybeLoadURL(url, ThangType, 'thang')
onThangNamesLoaded: (thangNames) ->
for thangType in thangNames.models
@loadDefaultComponentsForThangType(thangType)
@loadThangsRequiredByThangType(thangType)
@thangNamesLoaded = true
@onWorldNecessitiesLoaded() if @checkAllWorldNecessitiesRegisteredAndLoaded()
loadDefaultComponentsForThangType: (thangType) ->
return unless components = thangType.get('components')
for component in components
url = "/db/level.component/#{component.original}/version/#{component.majorVersion}"
@worldNecessities.push @maybeLoadURL(url, LevelComponent, 'component')
onWorldNecessityLoaded: (resource) ->
index = @worldNecessities.indexOf(resource)
if resource.name is 'thang'
@loadDefaultComponentsForThangType(resource.model)
@loadThangsRequiredByThangType(resource.model)
return unless index >= 0
@worldNecessities.splice(index, 1)
@worldNecessities = (r for r in @worldNecessities when r?)
@onWorldNecessitiesLoaded() if @checkAllWorldNecessitiesRegisteredAndLoaded()
onWorldNecessityLoadFailed: (event) ->
@reportLoadError()
@trigger('world-necessity-load-failed', event)
checkAllWorldNecessitiesRegisteredAndLoaded: ->
return false unless _.filter(@worldNecessities).length is 0
return false unless @thangNamesLoaded
return false if @sessionDependenciesRegistered and not @sessionDependenciesRegistered[@session.id] and not @sessionless
return false if @sessionDependenciesRegistered and @opponentSession and not @sessionDependenciesRegistered[@opponentSession.id] and not @sessionless
true
onWorldNecessitiesLoaded: ->
console.log "World necessities loaded." if LOG
@initWorld()
@supermodel.clearMaxProgress()
@trigger 'world-necessities-loaded'
return if @headless
thangsToLoad = _.uniq( (t.spriteName for t in @world.thangs when t.exists) )
nameModelTuples = ([thangType.get('name'), thangType] for thangType in @thangNames.models)
nameModelMap = _.zipObject nameModelTuples
@spriteSheetsToBuild ?= []
# for thangTypeName in thangsToLoad
# thangType = nameModelMap[thangTypeName]
# continue if not thangType or thangType.isFullyLoaded()
# thangType.fetch()
# thangType = @supermodel.loadModel(thangType, 'thang').model
# res = @supermodel.addSomethingResource 'sprite_sheet', 5
# res.thangType = thangType
# res.markLoading()
# @spriteSheetsToBuild.push res
@buildLoopInterval = setInterval @buildLoop, 5 if @spriteSheetsToBuild.length
maybeLoadURL: (url, Model, resourceName) ->
return if @supermodel.getModel(url)
model = new Model().setURL url
@supermodel.loadModel(model, resourceName)
onSupermodelLoaded: ->
clearTimeout @loadTimeoutID
return if @destroyed
console.log 'SuperModel for Level loaded in', new Date().getTime() - @t0, 'ms' if LOG
@loadLevelSounds()
@denormalizeSession()
buildLoop: =>
someLeft = false
for spriteSheetResource, i in @spriteSheetsToBuild ? []
continue if spriteSheetResource.spriteSheetKeys
someLeft = true
thangType = spriteSheetResource.thangType
if thangType.loaded and not thangType.loading
keys = @buildSpriteSheetsForThangType spriteSheetResource.thangType
if keys and keys.length
@listenTo spriteSheetResource.thangType, 'build-complete', @onBuildComplete
spriteSheetResource.spriteSheetKeys = keys
else
spriteSheetResource.markLoaded()
clearInterval @buildLoopInterval unless someLeft
onBuildComplete: (e) ->
resource = null
for resource in @spriteSheetsToBuild
break if e.thangType is resource.thangType
return console.error 'Did not find spriteSheetToBuildResource for', e unless resource
resource.spriteSheetKeys = (k for k in resource.spriteSheetKeys when k isnt e.key)
resource.markLoaded() if resource.spriteSheetKeys.length is 0
denormalizeSession: ->
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
patch =
'levelName': @level.get('name')
'levelID': @level.get('slug') or @level.id
if me.id is @session.get 'creator'
patch.creatorName = me.get('name')
for key, value of patch
if @session.get(key) is value
delete patch[key]
unless _.isEmpty patch
@session.set key, value for key, value of patch
tempSession = new LevelSession _id: @session.id
tempSession.save(patch, {patch: true, type: 'PUT'})
@sessionDenormalized = true
# Building sprite sheets
buildSpriteSheetsForThangType: (thangType) ->
return if @headless
# TODO: Finish making sure the supermodel loads the raster image before triggering load complete, and that the cocosprite has access to the asset.
# if f = thangType.get('raster')
# queue = new createjs.LoadQueue()
# queue.loadFile('/file/'+f)
@grabThangTypeTeams() unless @thangTypeTeams
keys = []
for team in @thangTypeTeams[thangType.get('original')] ? [null]
spriteOptions = {resolutionFactor: SPRITE_RESOLUTION_FACTOR, async: true}
if thangType.get('kind') is 'Floor'
spriteOptions.resolutionFactor = 2
if team and color = @teamConfigs[team]?.color
spriteOptions.colorConfig = team: color
key = @buildSpriteSheet thangType, spriteOptions
if _.isString(key) then keys.push key
keys
grabThangTypeTeams: ->
@grabTeamConfigs()
@thangTypeTeams = {}
for thang in @level.get('thangs')
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
@thangTypeTeams[thang.thangType] ?= []
@thangTypeTeams[thang.thangType].push team unless team in @thangTypeTeams[thang.thangType]
break
@thangTypeTeams
grabTeamConfigs: ->
for system in @level.get('systems')
if @teamConfigs = system.config?.teamConfigs
break
unless @teamConfigs
# Hack: pulled from Alliance System code. TODO: put in just one place.
@teamConfigs = {'humans': {'superteam': 'humans', 'color': {'hue': 0, 'saturation': 0.75, 'lightness': 0.5}, 'playable': true}, 'ogres': {'superteam': 'ogres', 'color': {'hue': 0.66, 'saturation': 0.75, 'lightness': 0.5}, 'playable': false}, 'neutral': {'superteam': 'neutral', 'color': {'hue': 0.33, 'saturation': 0.75, 'lightness': 0.5}}}
@teamConfigs
buildSpriteSheet: (thangType, options) ->
if thangType.get('name') is 'Wizard'
options.colorConfig = me.get('wizard')?.colorConfig or {}
thangType.buildSpriteSheet options
# World init
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
@world.flagHistory = @session?.get('state')?.flagHistory ? []
@world.difficulty = @session?.get('state')?.difficulty ? 0
if @observing
@world.difficulty = Math.max 0, @world.difficulty - 1 # Show the difficulty they won, not the next one.
serializedLevel = @level.serialize {@supermodel, @session, @opponentSession, @headless, @sessionless}
@world.loadFromLevel serializedLevel, false
console.log 'World has been initialized from level loader.' if LOG
# Initial Sound Loading
playJingle: ->
return if @headless or not me.get('volume')
volume = 0.5
if me.level() < 3
volume = 0.25 # Start softly, since they may not be expecting it
# Apparently the jingle, when it tries to play immediately during all this loading, you can't hear it.
# Add the timeout to fix this weird behavior.
f = ->
jingles = ['ident_1', 'ident_2']
AudioPlayer.playInterfaceSound jingles[Math.floor Math.random() * jingles.length], volume
setTimeout f, 500
loadAudio: ->
return if @headless or not me.get('volume')
AudioPlayer.preloadInterfaceSounds ['victory']
loadLevelSounds: ->
return if @headless or not me.get('volume')
scripts = @level.get 'scripts'
return unless scripts
for script in scripts when script.noteChain
for noteGroup in script.noteChain when noteGroup.sprites
for sprite in noteGroup.sprites when sprite.say?.sound
AudioPlayer.preloadSoundReference(sprite.say.sound)
thangTypes = @supermodel.getModels(ThangType)
for thangType in thangTypes
for trigger, sounds of thangType.get('soundTriggers') or {} when trigger isnt 'say'
AudioPlayer.preloadSoundReference sound for sound in sounds
# everything else sound wise is loaded as needed as worlds are generated
progress: -> @supermodel.progress
destroy: ->
clearInterval @buildLoopInterval if @buildLoopInterval
super()