mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-30 19:06:59 -05:00
505 lines
19 KiB
CoffeeScript
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, useInterpreter: true
|
|
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
|