codecombat/app/lib/simulator/Simulator.coffee

505 lines
19 KiB
CoffeeScript

SuperModel = require 'models/SuperModel'
CocoClass = require 'core/CocoClass'
LevelLoader = require 'lib/LevelLoader'
GoalManager = require 'lib/world/GoalManager'
God = require 'lib/God'
{createAetherOptions} = require 'lib/aether_utils'
SIMULATOR_VERSION = 4
simulatorInfo = {}
if $.browser
simulatorInfo['desktop'] = $.browser.desktop if $.browser.desktop
simulatorInfo['name'] = $.browser.name if $.browser.name
simulatorInfo['platform'] = $.browser.platform if $.browser.platform
simulatorInfo['version'] = $.browser.versionNumber if $.browser.versionNumber
module.exports = class Simulator extends CocoClass
constructor: (@options) ->
@options ?= {}
simulatorType = if @options.headlessClient then 'headless' else 'browser'
@simulator =
type: simulatorType
version: SIMULATOR_VERSION
info: simulatorInfo
_.extend @, Backbone.Events
@trigger 'statusUpdate', 'Starting simulation!'
@retryDelayInSeconds = 2
@taskURL = '/queue/scoring'
@simulatedByYou = 0
@god = new God maxAngels: 1, workerCode: @options.workerCode, headless: true # Start loading worker.
destroy: ->
@off()
@cleanupSimulation()
@god?.destroy()
super()
fetchAndSimulateOneGame: (humanGameID, ogresGameID) =>
return if @destroyed
$.ajax
url: '/queue/scoring/getTwoGames'
type: 'POST'
parse: true
data:
humansGameID: humanGameID
ogresGameID: ogresGameID
simulator: @simulator
background: Boolean(@options.background)
levelID: @options.levelID
leagueID: @options.leagueID
error: (errorData) ->
console.warn "There was an error fetching two games! #{JSON.stringify errorData}"
if errorData?.responseText?.indexOf("Old simulator") isnt -1
noty {
text: errorData.responseText
layout: 'center'
type: 'error'
}
success: (taskData) =>
return if @destroyed
unless taskData
@retryDelayInSeconds = 10
@trigger 'statusUpdate', "No games to simulate. Trying another game in #{@retryDelayInSeconds} seconds."
@simulateAnotherTaskAfterDelay()
return
@trigger 'statusUpdate', 'Setting up simulation...'
#refactor this
@task = new SimulationTask(taskData)
@supermodel ?= new SuperModel()
@supermodel.resetProgress()
@stopListening @supermodel, 'loaded-all'
@levelLoader = new LevelLoader supermodel: @supermodel, levelID: @task.getLevelName(), sessionID: @task.getFirstSessionID(), opponentSessionID: @task.getSecondSessionID(), headless: true
if @supermodel.finished()
@simulateSingleGame()
else
@listenToOnce @supermodel, 'loaded-all', @simulateSingleGame
simulateSingleGame: ->
return if @destroyed
@assignWorldAndLevelFromLevelLoaderAndDestroyIt()
@trigger 'statusUpdate', 'Simulating...'
@setupGod()
try
@commenceSingleSimulation()
catch error
@handleSingleSimulationError error
commenceSingleSimulation: ->
@listenToOnce @god, 'infinite-loop', @handleSingleSimulationInfiniteLoop
@listenToOnce @god, 'goals-calculated', @processSingleGameResults
@god.createWorld @generateSpellsObject()
handleSingleSimulationError: (error) ->
console.error 'There was an error simulating a single game!', error
return if @destroyed
if @options.headlessClient and @options.simulateOnlyOneGame
console.log 'GAMERESULT:tie'
process.exit(0)
@cleanupAndSimulateAnotherTask()
handleSingleSimulationInfiniteLoop: (e) ->
console.log 'There was an infinite loop in the single game!'
return if @destroyed
if @options.headlessClient and @options.simulateOnlyOneGame
console.log 'GAMERESULT:tie'
process.exit(0)
@cleanupAndSimulateAnotherTask()
processSingleGameResults: (simulationResults) ->
try
taskResults = @formTaskResultsObject simulationResults
catch error
console.log "Failed to form task results:", error
return @cleanupAndSimulateAnotherTask()
console.log 'Processing results:', taskResults
humanSessionRank = taskResults.sessions[0].metrics.rank
ogreSessionRank = taskResults.sessions[1].metrics.rank
if @options.headlessClient and @options.simulateOnlyOneGame
if humanSessionRank is ogreSessionRank
console.log 'GAMERESULT:tie'
else if humanSessionRank < ogreSessionRank
console.log 'GAMERESULT:humans'
else if ogreSessionRank < humanSessionRank
console.log 'GAMERESULT:ogres'
process.exit(0)
else
@sendSingleGameBackToServer(taskResults)
sendSingleGameBackToServer: (results) ->
@trigger 'statusUpdate', 'Simulation completed, sending results back to server!'
$.ajax
url: '/queue/scoring/recordTwoGames'
data: results
type: 'PUT'
parse: true
success: @handleTaskResultsTransferSuccess
error: @handleTaskResultsTransferError
complete: @cleanupAndSimulateAnotherTask
fetchAndSimulateTask: =>
return if @destroyed
# Because there's some bug where the chained rankings don't work, let's just do getTwoGames until we fix it.
return @fetchAndSimulateOneGame()
if @options.headlessClient
if @dumpThisTime # The first heapdump would be useless to find leaks.
console.log 'Writing snapshot.'
@options.heapdump.writeSnapshot()
@dumpThisTime = true if @options.heapdump
if @options.testing
_.delay @setupSimulationAndLoadLevel, 0, @options.testFile, 'Testing...', status: 400
return
@trigger 'statusUpdate', 'Fetching simulation data!'
$.ajax
url: @taskURL
type: 'GET'
parse: true
error: @handleFetchTaskError
success: @setupSimulationAndLoadLevel
cache: false
handleFetchTaskError: (errorData) =>
console.error "There was a horrible Error: #{JSON.stringify errorData}"
@trigger 'statusUpdate', 'There was an error fetching games to simulate. Retrying in 10 seconds.'
@simulateAnotherTaskAfterDelay()
handleNoGamesResponse: ->
@noTasks = true
info = 'Finding game to simulate...'
console.log info
@trigger 'statusUpdate', info
@fetchAndSimulateOneGame()
simulateAnotherTaskAfterDelay: =>
console.log "Retrying in #{@retryDelayInSeconds}"
retryDelayInMilliseconds = @retryDelayInSeconds * 1000
_.delay @fetchAndSimulateTask, retryDelayInMilliseconds
setupSimulationAndLoadLevel: (taskData, textStatus, jqXHR) =>
return @handleNoGamesResponse() if jqXHR.status is 204
@trigger 'statusUpdate', 'Setting up simulation!'
@task = new SimulationTask(taskData)
try
levelID = @task.getLevelName()
catch err
console.error err
@trigger 'statusUpdate', "Error simulating game: #{err}. Trying another game in #{@retryDelayInSeconds} seconds."
@simulateAnotherTaskAfterDelay()
return
@supermodel ?= new SuperModel()
@supermodel.resetProgress()
@stopListening @supermodel, 'loaded-all'
@levelLoader = new LevelLoader supermodel: @supermodel, levelID: levelID, sessionID: @task.getFirstSessionID(), opponentSessionID: @task.getSecondSessionID(), headless: true
if @supermodel.finished()
@simulateGame()
else
@listenToOnce @supermodel, 'loaded-all', @simulateGame
simulateGame: ->
return if @destroyed
info = 'All resources loaded, simulating!'
console.log info
@assignWorldAndLevelFromLevelLoaderAndDestroyIt()
@trigger 'statusUpdate', info, @task.getSessions()
@setupGod()
try
@commenceSimulationAndSetupCallback()
catch err
console.error 'There was an error in simulation:', err, err.stack, "-- trying again in #{@retryDelayInSeconds} seconds"
@simulateAnotherTaskAfterDelay()
assignWorldAndLevelFromLevelLoaderAndDestroyIt: ->
@world = @levelLoader.world
@task.setWorld(@world)
@level = @levelLoader.level
@session = @levelLoader.session
@otherSession = @levelLoader.opponentSession
@levelLoader.destroy()
@levelLoader = null
setupGod: ->
@god.setLevel @level.serialize(@supermodel, @session, @otherSession)
@god.setLevelSessionIDs (session.sessionID for session in @task.getSessions())
@god.setWorldClassMap @world.classMap
@god.setGoalManager new GoalManager @world, @level.get('goals'), null, {headless: true}
humanFlagHistory = _.filter @session.get('state')?.flagHistory ? [], (event) => event.source isnt 'code' and event.team is (@session.get('team') ? 'humans')
ogreFlagHistory = _.filter @otherSession.get('state')?.flagHistory ? [], (event) => event.source isnt 'code' and event.team is (@otherSession.get('team') ? 'ogres')
@god.lastFlagHistory = humanFlagHistory.concat ogreFlagHistory
#console.log 'got flag history', @god.lastFlagHistory, 'from', humanFlagHistory, ogreFlagHistory, @session.get('state'), @otherSession.get('state')
@god.lastSubmissionCount = 0 # TODO: figure out how to combine submissionCounts from both players so we can use submissionCount random seeds again.
@god.lastDifficulty = 0
commenceSimulationAndSetupCallback: ->
@listenToOnce @god, 'infinite-loop', @onInfiniteLoop
@listenToOnce @god, 'goals-calculated', @processResults
@god.createWorld @generateSpellsObject()
# Search for leaks, headless-client only.
if @options.headlessClient and @options.leakTest and not @memwatch?
leakcount = 0
maxleakcount = 0
console.log 'Setting leak callbacks.'
@memwatch = require 'memwatch'
@memwatch.on 'leak', (info) =>
console.warn "LEAK!!\n" + JSON.stringify(info)
unless @hd?
if (leakcount++ is maxleakcount)
@hd = new @memwatch.HeapDiff()
@memwatch.on 'stats', (stats) =>
console.warn 'stats callback: ' + stats
diff = @hd.end()
console.warn "HeapDiff:\n" + JSON.stringify(diff)
if @options.exitOnLeak
console.warn 'Exiting because of Leak.'
process.exit()
@hd = new @memwatch.HeapDiff()
onInfiniteLoop: (e) ->
return if @destroyed
console.warn 'Skipping infinitely looping game.'
@trigger 'statusUpdate', "Infinite loop detected; grabbing a new game in #{@retryDelayInSeconds} seconds."
_.delay @cleanupAndSimulateAnotherTask, @retryDelayInSeconds * 1000
processResults: (simulationResults) ->
try
taskResults = @formTaskResultsObject simulationResults
catch error
console.log "Failed to form task results:", error
return @cleanupAndSimulateAnotherTask()
unless taskResults.taskID
console.error "*** Error: taskResults has no taskID ***\ntaskResults:", taskResults
@cleanupAndSimulateAnotherTask()
else
@sendResultsBackToServer taskResults
sendResultsBackToServer: (results) ->
status = 'Recording:'
for session in results.sessions
states = ['wins', if _.find(results.sessions, (s) -> s.metrics.rank is 0) then 'loses' else 'draws']
status += " #{session.name} #{states[session.metrics.rank]}"
@trigger 'statusUpdate', status
console.log 'Sending result back to server:'
console.log JSON.stringify results
if @options.headlessClient and @options.testing
return @fetchAndSimulateTask()
$.ajax
url: '/queue/scoring'
data: results
type: 'PUT'
parse: true
success: @handleTaskResultsTransferSuccess
error: @handleTaskResultsTransferError
complete: @cleanupAndSimulateAnotherTask
handleTaskResultsTransferSuccess: (result) =>
return if @destroyed
console.log "Task registration result: #{JSON.stringify result}"
@trigger 'statusUpdate', 'Results were successfully sent back to server!'
@simulatedByYou++
unless @options.headlessClient
simulatedBy = parseInt($('#simulated-by-you').text(), 10) + 1
$('#simulated-by-you').text(simulatedBy)
handleTaskResultsTransferError: (error) =>
return if @destroyed
@trigger 'statusUpdate', 'There was an error sending the results back to the server.'
console.log "Task registration error: #{JSON.stringify error}"
cleanupAndSimulateAnotherTask: =>
return if @destroyed
@cleanupSimulation()
if @options.background or @noTasks
@fetchAndSimulateOneGame()
else
@fetchAndSimulateTask()
cleanupSimulation: ->
@stopListening @god
@world = null
@level = null
formTaskResultsObject: (simulationResults) ->
taskResults =
taskID: @task.getTaskID()
receiptHandle: @task.getReceiptHandle()
originalSessionID: @task.getFirstSessionID()
originalSessionRank: -1
calculationTime: 500
sessions: []
simulator: @simulator
randomSeed: @task.world.randomSeed
for session in @task.getSessions()
sessionResult =
sessionID: session.sessionID
submitDate: session.submitDate
creator: session.creator
name: session.creatorName
totalScore: session.totalScore
metrics:
rank: @calculateSessionRank session.sessionID, simulationResults.goalStates, @task.generateTeamToSessionMap()
shouldUpdateLastOpponentSubmitDateForLeague: session.shouldUpdateLastOpponentSubmitDateForLeague
if session.sessionID is taskResults.originalSessionID
taskResults.originalSessionRank = sessionResult.metrics.rank
taskResults.originalSessionTeam = session.team
taskResults.sessions.push sessionResult
return taskResults
calculateSessionRank: (sessionID, goalStates, teamSessionMap) ->
ogreGoals = (goalState for key, goalState of goalStates when goalState.team is 'ogres')
humanGoals = (goalState for key, goalState of goalStates when goalState.team is 'humans')
ogresWon = _.all ogreGoals, {status: 'success'}
humansWon = _.all humanGoals, {status: 'success'}
if ogresWon is humansWon
return 0
else if ogresWon and teamSessionMap['ogres'] is sessionID
return 0
else if ogresWon and teamSessionMap['ogres'] isnt sessionID
return 1
else if humansWon and teamSessionMap['humans'] is sessionID
return 0
else
return 1
generateSpellsObject: ->
@currentUserCodeMap = @task.generateSpellKeyToSourceMap()
@spells = {}
for thang in @level.attributes.thangs
continue if @thangIsATemplate thang
@generateSpellKeyToSourceMapPropertiesFromThang thang
@spells
thangIsATemplate: (thang) ->
for component in thang.components
continue unless @componentHasProgrammableMethods component
for methodName, method of component.config.programmableMethods
return true if @methodBelongsToTemplateThang method
return false
componentHasProgrammableMethods: (component) -> component.config? and _.has component.config, 'programmableMethods'
methodBelongsToTemplateThang: (method) -> typeof method is 'string'
generateSpellKeyToSourceMapPropertiesFromThang: (thang) =>
for component in thang.components
continue unless @componentHasProgrammableMethods component
for methodName, method of component.config.programmableMethods
spellKey = @generateSpellKeyFromThangIDAndMethodName thang.id, methodName
@createSpellAndAssignName spellKey, methodName
@createSpellThang thang, method, spellKey
@transpileSpell thang, spellKey, methodName
generateSpellKeyFromThangIDAndMethodName: (thang, methodName) ->
spellKeyComponents = [thang, methodName]
spellKeyComponents[0] = _.string.slugify spellKeyComponents[0]
spellKey = spellKeyComponents.join '/'
spellKey
createSpellAndAssignName: (spellKey, spellName) ->
@spells[spellKey] ?= {}
@spells[spellKey].name = spellName
createSpellThang: (thang, method, spellKey) ->
@spells[spellKey].thangs ?= {}
@spells[spellKey].thangs[thang.id] ?= {}
spellTeam = @task.getSpellKeyToTeamMap()[spellKey]
playerTeams = @task.getPlayerTeams()
useProtectAPI = true
if spellTeam not in playerTeams
useProtectAPI = false
else
spellSession = _.filter(@task.getSessions(), {team: spellTeam})[0]
unless codeLanguage = spellSession?.submittedCodeLanguage
console.warn 'Session', spellSession.creatorName, spellSession.team, 'didn\'t have submittedCodeLanguage, just:', spellSession
@spells[spellKey].thangs[thang.id].aether = @createAether @spells[spellKey].name, method, useProtectAPI, codeLanguage ? 'javascript'
transpileSpell: (thang, spellKey, methodName) ->
slugifiedThangID = _.string.slugify thang.id
generatedSpellKey = [slugifiedThangID,methodName].join '/'
source = @currentUserCodeMap[generatedSpellKey] ? ''
aether = @spells[spellKey].thangs[thang.id].aether
#unless _.contains(@task.spellKeysToTranspile, generatedSpellKey)
try
aether.transpile source
catch e
console.log "Couldn't transpile #{spellKey}:\n#{source}\n", e
aether.transpile ''
createAether: (methodName, method, useProtectAPI, codeLanguage) ->
aetherOptions = createAetherOptions functionName: methodName, codeLanguage: codeLanguage, skipProtectAPI: not useProtectAPI
return new Aether aetherOptions
class SimulationTask
constructor: (@rawData) ->
@spellKeyToTeamMap = {}
getLevelName: ->
levelName = @rawData.sessions?[0]?.levelID
return levelName if levelName?
@throwMalformedTaskError 'The level name couldn\'t be deduced from the task.'
generateTeamToSessionMap: ->
teamSessionMap = {}
for session in @rawData.sessions
@throwMalformedTaskError 'Two players share the same team' if teamSessionMap[session.team]?
teamSessionMap[session.team] = session.sessionID
teamSessionMap
throwMalformedTaskError: (errorString) ->
throw new Error "The task was malformed, reason: #{errorString}"
getFirstSessionID: -> @rawData.sessions[0].sessionID
getSecondSessionID: -> @rawData.sessions[1].sessionID
getTaskID: -> @rawData.taskID
getReceiptHandle: -> @rawData.receiptHandle
getSessions: -> @rawData.sessions
getSpellKeyToTeamMap: -> @spellKeyToTeamMap
getPlayerTeams: -> _.pluck @rawData.sessions, 'team'
setWorld: (@world) ->
generateSpellKeyToSourceMap: ->
# TODO: we always now only have hero-placeholder/plan vs. hero-placeholder-1/plan on humans vs. ogres, always just have to retranspile for Esper, and never need to transpile for NPCs or other methods, so we can get rid of almost all of this stuff.
playerTeams = _.pluck @rawData.sessions, 'team'
spellKeyToSourceMap = {}
for session in @rawData.sessions
teamSpells = session.teamSpells[session.team]
allTeams = _.keys session.teamSpells
for team in allTeams
for spell in session.teamSpells[team]
@spellKeyToTeamMap[spell] = team
teamCode = {}
for thangName, thangSpells of session.submittedCode
for spellName, spell of thangSpells
fullSpellName = [thangName, spellName].join '/'
if _.contains(teamSpells, fullSpellName)
teamCode[fullSpellName] = LZString.decompressFromUTF16 spell
_.merge spellKeyToSourceMap, teamCode
spellKeyToSourceMap