Merge pull request #2955 from codecombat/leagues

Leagues!
This commit is contained in:
Nick Winter 2015-08-19 15:34:54 -07:00
commit da0b7d3e64
25 changed files with 937 additions and 791 deletions

View file

@ -104,6 +104,7 @@ module.exports = class CocoRouter extends Backbone.Router
'multiplayer': go('MultiplayerView')
'play': go('play/CampaignView')
'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')

View file

@ -820,6 +820,7 @@
latest_achievement: "Latest Achievement"
playtime: "Playtime"
last_played: "Last played"
leagues_explanation: "Play in a league against other clan members in these multiplayer arena instances."
classes:
archmage_title: "Archmage"
@ -1009,6 +1010,7 @@
my_matches: "My Matches"
simulate: "Simulate"
simulation_explanation: "By simulating games you can get your game ranked faster!"
simulation_explanation_leagues: "You will mainly help simulate games for allied players in your clans and courses."
simulate_games: "Simulate Games!"
simulate_all: "RESET AND SIMULATE GAMES"
games_simulated_by: "Games simulated by you:"
@ -1059,6 +1061,7 @@
tournament_blurb_blog: "on our blog"
rules: "Rules"
winners: "Winners"
league: "League"
user:
stats: "Stats"

View file

@ -294,6 +294,14 @@ _.extend LevelSessionSchema.properties,
simulator: {type: 'object', description: 'Holds info on who simulated the match, and with what tools.'}
randomSeed: {description: 'Stores the random seed that was used during this match.'}
leagues:
c.array {description: 'Multiplayer data for the league corresponding to Clans and CourseInstances the player is a part of.'},
c.object {},
leagueID: {type: 'string', description: 'The _id of a Clan or CourseInstance the user belongs to.'}
stats: c.object {description: 'Multiplayer match statistics corresponding to this entry in the league.'}
LevelSessionSchema.properties.leagues.items.properties.stats.properties = _.pick LevelSessionSchema.properties, 'meanStrength', 'standardDeviation', 'totalScore', 'numberOfWinsAndTies', 'numberOfLosses', 'scoreHistory', 'matches'
c.extendBasicProperties LevelSessionSchema, 'level.session'
c.extendPermissionsProperties LevelSessionSchema, 'level.session'

View file

@ -26,6 +26,8 @@ block content
button.btn(data-dismiss='modal', data-i18n="modal.close") Close
button.btn.edit-description-save-btn(data-i18n="common.save_changes") Save changes
.row
.col-lg-6
if clan
h1 #{clan.get('name')}
if clan.get('type') === 'private'
@ -73,6 +75,14 @@ block content
input.join-clan-link(type="text", readonly, value="#{joinClanLink}")
.small(data-i18n="clans.invite_2") *Invite players to this Clan by sending them this link.
if arenas && arenas.length
.col-lg-6
h2(data-i18n="play.campaign_multiplayer")
p(data-i18n="clans.leagues_explanation")
for arena in arenas
h3
a(href="/play/ladder/#{arena.slug}/clan/#{clan.id}")= i18n(arena, 'name')
if members
h3
span.spr(data-i18n="clans.members") Members

View file

@ -23,12 +23,13 @@ div#columns.row
- if(!showJustTop && topSessions.length == 20) topSessions = topSessions.slice(0, 10);
for session, rank in topSessions
- var myRow = session.get('creator') == me.id
- var sessionStats = league ? (_.find(session.get('leagues') || [], {leagueID: league.id}) || {}).stats || {} : session.attributes;
tr(class=myRow ? "success" : "", data-player-id=session.get('creator'), data-session-id=session.id)
td.code-language-cell(style="background-image: url(/images/common/code_languages/" + session.get('submittedCodeLanguage') + "_icon.png)" title=capitalize(session.get('submittedCodeLanguage')))
if level.get('type', true) == 'hero-ladder'
td.hero-portrait-cell(style="background-image: url(/file/db/thang.type/#{(session.get('heroConfig') || {}).thangType || '529ffbf1cf1818f2be000001'}/portrait.png)")
td.rank-cell= rank + 1
td.score-cell= Math.round(session.get('totalScore') * 100)
td.score-cell= Math.round(sessionStats.totalScore * 100)
td.name-col-cell= session.get('creatorName') || "Anonymous"
td.fight-cell
a(href="/play/level/#{level.get('slug') || level.id}?team=#{team.otherTeam}&opponent=#{session.id}")
@ -41,12 +42,13 @@ div#columns.row
td(colspan=4).ellipsis-row ...
for session in team.leaderboard.nearbySessions()
- var myRow = session.get('creator') == me.id
- var sessionStats = league ? (_.find(session.get('leagues'), {leagueID: league.id}) || {}).stats || {} : session.attributes;
tr(class=myRow ? "success" : "", data-player-id=session.get('creator'), data-session-id=session.id)
td.code-language-cell(style="background-image: url(/images/common/code_languages/" + session.get('submittedCodeLanguage') + "_icon.png)")
if level.get('type', true) == 'hero-ladder'
td.hero-portrait-cell(style="background-image: url(/file/db/thang.type/#{(session.get('heroConfig') || {}).thangType || '529ffbf1cf1818f2be000001'}/portrait.png)")
td.rank-cell= session.rank
td.score-cell= Math.round(session.get('totalScore') * 100)
td.score-cell= Math.round(sessionStats.totalScore * 100)
td.name-col-cell= session.get('creatorName') || "Anonymous"
td.fight-cell
a(href="/play/level/#{level.get('slug') || level.id}?team=#{team.otherTeam}&opponent=#{session.id}")

View file

@ -10,6 +10,11 @@ block content
else
h1= level.get('name')
if league
h1
a(href="/#{leagueType == 'clan' ? 'clans' : leagueType}/#{league.id}")= league.get('name')
span.spl(data-i18n="ladder.league") League
if level.get('name') == 'Greed'
.tournament-blurb
h2

View file

@ -3,6 +3,7 @@ p(id="simulation-status-text")
| #{simulationStatus}
else
span(data-i18n="ladder.simulation_explanation") By simulating games you can get your game ranked faster!
span.spl(data-i18n="ladder.simulation_explanation_leagues") You will mainly help simulate games for allied players in your clans and courses.
p
button(data-i18n="ladder.simulate_games").btn.btn-warning.btn-lg.highlight#simulate-button Simulate Games!

View file

@ -10,6 +10,7 @@ LevelSession = require 'models/LevelSession'
SubscribeModal = require 'views/core/SubscribeModal'
ThangType = require 'models/ThangType'
User = require 'models/User'
utils = require 'core/utils'
# TODO: Add message for clan not found
# TODO: Progress visual for premium levels?
@ -60,7 +61,7 @@ module.exports = class ClanDetailsView extends RootView
@listenTo @memberAchievements, 'sync', @onMemberAchievementsSync
@listenTo @memberSessions, 'sync', @onMemberSessionsSync
@supermodel.loadModel @campaigns, 'clan', cache: false
@supermodel.loadModel @campaigns, 'campaigns', cache: false
@supermodel.loadModel @clan, 'clan', cache: false
@supermodel.loadCollection(@members, 'members', {cache: false})
@supermodel.loadCollection(@memberAchievements, 'member_achievements', {cache: false})
@ -120,6 +121,8 @@ module.exports = class ClanDetailsView extends RootView
context.lastUserCampaignLevelMap = lastUserCampaignLevelMap
context.showExpandedProgress = maxLastUserCampaignLevel <= 30 or @showExpandedProgress
context.userConceptsMap = userConceptsMap
context.arenas = @arenas
context.i18n = utils.i18n
context
afterRender: ->
@ -179,21 +182,24 @@ module.exports = class ClanDetailsView extends RootView
return unless @campaigns.loaded
@campaignLevelProgressions = []
@conceptsProgression = []
@arenas = []
for campaign in @campaigns.models
continue if campaign.get('slug') is 'auditions'
campaignLevelProgression =
ID: campaign.id
slug: campaign.get('slug')
name: campaign.get('fullName') or campaign.get('name')
name: utils.i18n(campaign.attributes, 'fullName') or utils.i18n(campaign.attributes, 'name')
levels: []
for levelID, level of campaign.get('levels')
campaignLevelProgression.levels.push
ID: levelID
slug: level.slug
name: level.name
name: utils.i18n level, 'name'
if level.concepts?
for concept in level.concepts
@conceptsProgression.push concept unless concept in @conceptsProgression
if level.type == 'hero-ladder'
@arenas.push level
@campaignLevelProgressions.push campaignLevelProgression
@render?()

View file

@ -43,11 +43,14 @@ module.exports = class LadderPlayModal extends ModalView
# PART 1: Load challengers from the db unless some are in the matches
startLoadingChallengersMaybe: ->
if @options.league
matches = _.find(@session?.get('leagues'), leagueID: @options.league.id)?.stats.matches
else
matches = @session?.get('matches')
if matches?.length then @loadNames() else @loadChallengers()
loadChallengers: ->
@challengersCollection = new ChallengersData(@level, @team, @otherTeam, @session)
@challengersCollection = new ChallengersData(@level, @team, @otherTeam, @session, @options.league)
@listenTo(@challengersCollection, 'sync', @loadNames)
# PART 2: Loading the names of the other users
@ -156,7 +159,10 @@ module.exports = class LadderPlayModal extends ModalView
mediumInfo = @challengeInfoFromSession(@challengersCollection.mediumPlayer.models[0])
hardInfo = @challengeInfoFromSession(@challengersCollection.hardPlayer.models[0])
else
matches = @session.get('matches')
if @options.league
matches = _.find(@session?.get('leagues'), leagueID: @options.league.id)?.stats.matches
else
matches = @session?.get('matches')
won = (m for m in matches when m.metrics.rank < m.opponents[0].metrics.rank)
lost = (m for m in matches when m.metrics.rank > m.opponents[0].metrics.rank)
tied = (m for m in matches when m.metrics.rank is m.opponents[0].metrics.rank)
@ -195,18 +201,26 @@ module.exports = class LadderPlayModal extends ModalView
}
class ChallengersData
constructor: (@level, @team, @otherTeam, @session) ->
constructor: (@level, @team, @otherTeam, @session, @league) ->
_.extend @, Backbone.Events
score = @session?.get('totalScore') or 25
@easyPlayer = new LeaderboardCollection(@level, {order: 1, scoreOffset: score - 5, limit: 1, team: @otherTeam})
@easyPlayer.fetch cache: false
@listenToOnce(@easyPlayer, 'sync', @challengerLoaded)
@mediumPlayer = new LeaderboardCollection(@level, {order: 1, scoreOffset: score, limit: 1, team: @otherTeam})
@mediumPlayer.fetch cache: false
@listenToOnce(@mediumPlayer, 'sync', @challengerLoaded)
@hardPlayer = new LeaderboardCollection(@level, {order: -1, scoreOffset: score + 5, limit: 1, team: @otherTeam})
@hardPlayer.fetch cache: false
@listenToOnce(@hardPlayer, 'sync', @challengerLoaded)
if @league
score = _.find(@session?.get('leagues'), leagueID: @league.id)?.stats?.totalScore or 10
else
score = @session?.get('totalScore') or 10
for player in [
{type: 'easyPlayer', order: 1, scoreOffset: score - 5}
{type: 'mediumPlayer', order: 1, scoreOffset: score}
{type: 'hardPlayer', order: -1, scoreOffset: score + 5}
]
playerResource = @[player.type] = new LeaderboardCollection(@level, @collectionParameters(order: player.order, scoreOffset: player.scoreOffset))
playerResource.fetch cache: false
@listenToOnce playerResource, 'sync', @challengerLoaded
collectionParameters: (parameters) ->
parameters.team = @otherTeam
parameters.limit = 1
parameters['leagues.leagueID'] = @league.id if @league
parameters
challengerLoaded: ->
if @allLoaded()

View file

@ -154,7 +154,7 @@ module.exports = class LadderTabView extends CocoView
@supermodel.removeModelResource oldLeaderboard
oldLeaderboard.destroy()
teamSession = _.find @sessions.models, (session) -> session.get('team') is team.id
@leaderboards[team.id] = new LeaderboardData(@level, team.id, teamSession, @ladderLimit)
@leaderboards[team.id] = new LeaderboardData(@level, team.id, teamSession, @ladderLimit, @options.league)
@leaderboardRes = @supermodel.addModelResource(@leaderboards[team.id], 'leaderboard', {cache: false}, 3)
@leaderboardRes.load()
@ -166,7 +166,9 @@ module.exports = class LadderTabView extends CocoView
team = _.find @teams, name: histogramWrapper.data('team-name')
histogramData = null
$.when(
$.get "/db/level/#{@level.get('slug')}/histogram_data?team=#{team.name.toLowerCase()}", {cache: false}, (data) -> histogramData = data
url = "/db/level/#{@level.get('slug')}/histogram_data?team=#{team.name.toLowerCase()}"
url += '&leagues.leagueID=' + @options.league.id if @options.league
$.get url, {cache: false}, (data) -> histogramData = data
).then =>
@generateHistogram(histogramWrapper, histogramData, team.name.toLowerCase()) unless @destroyed
@ -181,6 +183,8 @@ module.exports = class LadderTabView extends CocoView
ctx.onFacebook = @facebookStatus is 'connected'
ctx.onGPlus = application.gplusHandler.loggedIn
ctx.capitalize = _.string.capitalize
ctx.league = @options.league
ctx._ = _
ctx
generateHistogram: (histogramElement, histogramData, teamName) ->
@ -227,8 +231,11 @@ module.exports = class LadderTabView extends CocoView
.attr('x', 1)
.attr('width', width/20)
.attr('height', (d) -> height - y(d.y))
if @leaderboards[teamName].session?
playerScore = @leaderboards[teamName].session.get('totalScore') * 100
if session = @leaderboards[teamName].session
if @options.league
playerScore = (_.find(session.get('leagues'), {leagueID: @options.league.id})?.stats.totalScore or 10) * 100
else
playerScore = session.get('totalScore') * 100
scorebar = svg.selectAll('.specialbar')
.data([playerScore])
.enter().append('g')
@ -301,24 +308,35 @@ module.exports.LeaderboardData = LeaderboardData = class LeaderboardData extends
Consolidates what you need to load for a leaderboard into a single Backbone Model-like object.
###
constructor: (@level, @team, @session, @limit) ->
constructor: (@level, @team, @session, @limit, @league) ->
super()
collectionParameters: (parameters) ->
parameters.team = @team
parameters['leagues.leagueID'] = @league.id if @league
parameters
fetch: ->
console.warn 'Already have top players on', @ if @topPlayers
@topPlayers = new LeaderboardCollection(@level, {order: -1, scoreOffset: HIGHEST_SCORE, team: @team, limit: @limit})
@topPlayers = new LeaderboardCollection(@level, @collectionParameters(order: -1, scoreOffset: HIGHEST_SCORE, limit: @limit))
promises = []
promises.push @topPlayers.fetch cache: false
if @session
if @league
score = _.find(@session.get('leagues'), {leagueID: @league.id})?.stats.totalScore or 10
else
score = @session.get('totalScore') or 10
@playersAbove = new LeaderboardCollection(@level, {order: 1, scoreOffset: score, limit: 4, team: @team})
@playersAbove = new LeaderboardCollection(@level, @collectionParameters(order: 1, scoreOffset: score, limit: 4))
promises.push @playersAbove.fetch cache: false
@playersBelow = new LeaderboardCollection(@level, {order: -1, scoreOffset: score, limit: 4, team: @team})
@playersBelow = new LeaderboardCollection(@level, @collectionParameters(order: -1, scoreOffset: score, limit: 4))
promises.push @playersBelow.fetch cache: false
level = "#{@level.get('original')}.#{@level.get('version').major}"
success = (@myRank) =>
promises.push $.ajax("/db/level/#{level}/leaderboard_rank?scoreOffset=#{@session.get('totalScore')}&team=#{@team}", cache: false, success: success)
loadURL = "/db/level/#{level}/leaderboard_rank?scoreOffset=#{score}&team=#{@team}"
loadURL += '&leagues.leagueID=' + @league.id if @league
promises.push $.ajax(loadURL, cache: false, success: success)
@promise = $.when(promises...)
@promise.then @onLoad
@promise.fail @onFail

View file

@ -12,6 +12,9 @@ SimulateTabView = require './SimulateTabView'
LadderPlayModal = require './LadderPlayModal'
CocoClass = require 'core/CocoClass'
Clan = require 'models/Clan'
#CourseInstance = require 'models/CourseInstance'
HIGHEST_SCORE = 1000000
class LevelSessionsCollection extends CocoCollection
@ -35,12 +38,19 @@ module.exports = class LadderView extends RootView
'click a:not([data-toggle])': 'onClickedLink'
'click .spectate-button': 'onClickSpectateButton'
constructor: (options, @levelID) ->
constructor: (options, @levelID, @leagueType, @leagueID) ->
super(options)
@level = @supermodel.loadModel(new Level(_id: @levelID), 'level').model
@sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model
@teams = []
@loadLeague()
loadLeague: ->
@leagueID = @leagueType = null unless @leagueType in ['clan'] #, 'course']
return unless @leagueID
modelClass = if @leagueType is 'clan' then Clan else null# else CourseInstance
resourceString = if @leagueType is 'clan' then 'clans.clan' else null# else 'courses.course'
@league = @supermodel.loadModel(new modelClass(_id: @leagueID), resourceString).model
onLoaded: ->
@teams = teamDataFromLevel @level
@ -53,6 +63,8 @@ module.exports = class LadderView extends RootView
ctx.teams = @teams
ctx.levelID = @levelID
ctx.levelDescription = marked(@level.get('description')) if @level.get('description')
ctx.leagueType = @leagueType
ctx.league = @league
ctx._ = _
if tournamentEndDate = {greed: 1402444800000, 'criss-cross': 1410912000000, 'zero-sum': 1428364800000}[@levelID]
ctx.tournamentTimeLeft = moment(new Date(tournamentEndDate)).fromNow()
@ -64,9 +76,9 @@ module.exports = class LadderView extends RootView
afterRender: ->
super()
return unless @supermodel.finished()
@insertSubView(@ladderTab = new LadderTabView({}, @level, @sessions))
@insertSubView(@myMatchesTab = new MyMatchesTabView({}, @level, @sessions))
@insertSubView(@simulateTab = new SimulateTabView())
@insertSubView(@ladderTab = new LadderTabView({league: @league}, @level, @sessions))
@insertSubView(@myMatchesTab = new MyMatchesTabView({league: @league}, @level, @sessions))
@insertSubView(@simulateTab = new SimulateTabView(league: @league))
@refreshInterval = setInterval(@fetchSessionsAndRefreshViews.bind(@), 60 * 1000)
hash = document.location.hash[1..] if document.location.hash
if hash and not (hash in ['my-matches', 'simulate', 'ladder', 'prizes', 'rules', 'winners'])
@ -101,7 +113,7 @@ module.exports = class LadderView extends RootView
showPlayModal: (teamID) ->
session = (s for s in @sessions.models when s.get('team') is teamID)[0]
modal = new LadderPlayModal({}, @level, session, teamID)
modal = new LadderPlayModal({league: @league}, @level, session, teamID)
@openModalView modal
onClickedLink: (e) ->

View file

@ -24,7 +24,8 @@ module.exports = class MyMatchesTabView extends CocoView
# Only fetch the names for the userIDs we don't already have in @nameMap
ids = []
for session in @sessions.models
for match in (session.get('matches') or [])
matches = @statsFromSession(session).matches or []
for match in matches
id = match.opponents[0].userID
unless id
console.error 'Found bad opponent ID in malformed match:', match, 'from session', session
@ -37,7 +38,8 @@ module.exports = class MyMatchesTabView extends CocoView
success = (nameMap) =>
return if @destroyed
for session in @sessions.models
for match in session.get('matches') or []
matches = @statsFromSession(session).matches or []
for match in matches
opponent = match.opponents[0]
continue if @nameMap[opponent.userID]
opponentUser = nameMap[opponent.userID]
@ -88,15 +90,16 @@ module.exports = class MyMatchesTabView extends CocoView
for team in @teams
team.session = (s for s in @sessions.models when s.get('team') is team.id)[0]
stats = @statsFromSession team.session
team.readyToRank = team.session?.readyToRank()
team.isRanking = team.session?.get('isRanking')
team.matches = (convertMatch(match, team.session.get('submitDate')) for match in team.session?.get('matches') or [])
team.matches = (convertMatch(match, team.session.get('submitDate')) for match in (stats?.matches or []))
team.matches.reverse()
team.score = (team.session?.get('totalScore') or 10).toFixed(2)
team.score = (stats?.totalScore ? 10).toFixed(2)
team.wins = _.filter(team.matches, {state: 'win', stale: false}).length
team.ties = _.filter(team.matches, {state: 'tie', stale: false}).length
team.losses = _.filter(team.matches, {state: 'loss', stale: false}).length
scoreHistory = team.session?.get('scoreHistory')
scoreHistory = stats?.scoreHistory
if scoreHistory?.length > 1
team.scoreHistory = scoreHistory
@ -123,6 +126,12 @@ module.exports = class MyMatchesTabView extends CocoView
@$el.find('tr.fresh').removeClass('fresh', 5000)
statsFromSession: (session) ->
return null unless session
if @options.league
return _.find(session.get('leagues') or [], leagueID: @options.league.id)?.stats ? {}
session.attributes
generateScoreLineChart: (wrapperID, scoreHistory, teamName) =>
margin =
top: 20

View file

@ -98,19 +98,6 @@ module.exports = class SimulateTabView extends CocoView
link = if @simulationSpectateLink then "<a href=#{@simulationSpectateLink}>#{_.string.escapeHTML(@simulationMatchDescription)}</a>" else ''
$('#simulation-status-text').html "<h3>#{@simulationStatus}</h3>#{link}"
resimulateAllSessions: ->
postData =
originalLevelID: @level.get('original')
levelMajorVersion: @level.get('version').major
console.log postData
$.ajax
url: '/queue/scoring/resimulateAllSessions'
method: 'POST'
data: postData
complete: (jqxhr) ->
console.log jqxhr.responseText
destroy: ->
clearTimeout @simulationPageRefreshTimeout
@simulator?.destroy()

View file

@ -160,16 +160,18 @@ LevelHandler = class LevelHandler extends Handler
creator: req.user._id+''
query = Session.find(sessionQuery).select('-screenshot -transpiledCode')
# TODO: take out "code" as well, since that can get huge containing the transpiled code for the lat hero, and find another way of having the LadderSubmissionViews in the MyMatchesTab determine rankin readiness
# TODO: take out "code" as well, since that can get huge containing the transpiled code for the lat hero, and find another way of having the LadderSubmissionViews in the MyMatchesTab determine ranking readiness
query.exec (err, results) =>
if err then @sendDatabaseError(res, err) else @sendSuccess res, results
getHistogramData: (req, res, slug) ->
match = levelID: slug, submitted: true, team: req.query.team
match['leagues.leagueID'] = league if league = req.query['leagues.leagueID']
aggregate = Session.aggregate [
{$match: {'levelID': slug, 'submitted': true, 'team': req.query.team}}
{$match: match}
{$project: {totalScore: 1, _id: 0}}
]
aggregate.cache()
aggregate.cache() unless league
aggregate.exec (err, data) =>
if err? then return @sendDatabaseError res, err
@ -198,7 +200,7 @@ LevelHandler = class LevelHandler extends Handler
sortParameters =
'totalScore': req.query.order
selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig']
selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig', 'leagues.leagueID', 'leagues.stats.totalScore']
query = Session
.find(sessionsQueryParameters)
@ -232,6 +234,7 @@ LevelHandler = class LevelHandler extends Handler
team: req.query.team
totalScore: scoreQuery
submitted: true
query['leagues.leagueID'] = league if league = req.query['leagues.leagueID']
query
validateLeaderboardRequestParameters: (req) ->

View file

@ -17,6 +17,7 @@ LevelSessionSchema.index({levelID: 1})
LevelSessionSchema.index({'level.majorVersion': 1})
LevelSessionSchema.index({'level.original': 1}, {name: 'Level Original'})
LevelSessionSchema.index({'level.original': 1, 'level.majorVersion': 1, 'creator': 1, 'team': 1})
LevelSessionSchema.index({creator: 1, level: 1}) # Looks like the ones operating on level as two separate fields might not be working, and sometimes this query uses the "level" index instead of the "creator" index.
LevelSessionSchema.index({playtime: 1}, {name: 'Playtime'})
LevelSessionSchema.index({submitDate: 1})
LevelSessionSchema.index({submitted: 1}, {sparse: true})
@ -24,10 +25,13 @@ LevelSessionSchema.index({team: 1}, {sparse: true})
LevelSessionSchema.index({totalScore: 1}, {sparse: true})
LevelSessionSchema.index({user: 1, changed: -1}, {name: 'last played index', sparse: true})
LevelSessionSchema.index({'level.original': 1, 'state.topScores.type': 1, 'state.topScores.date': -1, 'state.topScores.score': -1}, {name: 'top scores index', sparse: true})
LevelSessionSchema.index({submitted: 1, team: 1, level:1, totalScore: -1}, {name: 'rank counting index', sparse: true})
LevelSessionSchema.index({levelID: 1, submitted:1, team: 1}, {name: 'get all scores index', sparse: true})
LevelSessionSchema.index({submitted: 1, team: 1, level: 1, totalScore: -1}, {name: 'rank counting index', sparse: true})
#LevelSessionSchema.index({level: 1, 'leagues.leagueID': 1, submitted: 1, team: 1, totalScore: -1}, {name: 'league rank counting index', sparse: true}) # needed for league leaderboards?
LevelSessionSchema.index({levelID: 1, submitted: 1, team: 1}, {name: 'get all scores index', sparse: true})
#LevelSessionSchema.index({levelID: 1, 'leagues.leagueID': 1, submitted: 1, team: 1}, {name: 'league get all scores index', sparse: true}) # needed for league histograms?
LevelSessionSchema.index({submitted: 1, team: 1, levelID: 1, submitDate: -1}, {name: 'matchmaking index', sparse: true})
LevelSessionSchema.index({submitted: 1, team: 1, levelID: 1, randomSimulationIndex: -1}, {name: 'matchmaking random index', sparse: true})
LevelSessionSchema.index({'leagues.leagueID': 1, submitted: 1, levelID: 1, team: 1, randomSimulationIndex: -1}, {name: 'league-based matchmaking random index', sparse: true}) # Really need MongoDB 3.2 for partial indexes for this and several others: https://jira.mongodb.org/browse/SERVER-785
LevelSessionSchema.plugin(plugins.PermissionsPlugin)
LevelSessionSchema.plugin(AchievablePlugin)

View file

@ -9,25 +9,24 @@ queues = require '../commons/queue'
LevelSession = require '../levels/sessions/LevelSession'
Level = require '../levels/Level'
User = require '../users/User'
TaskLog = require './task/ScoringTask'
bayes = new (require 'bayesian-battle')()
TaskLog = require './scoring/ScoringTask'
scoringUtils = require './scoring/scoringUtils'
getTwoGames = require './scoring/getTwoGames'
recordTwoGames = require './scoring/recordTwoGames'
createNewTask = require './scoring/createNewTask'
dispatchTaskToConsumer = require './scoring/dispatchTaskToConsumer'
processTaskResult = require './scoring/processTaskResult'
scoringTaskQueue = undefined
scoringTaskTimeoutInSeconds = 600
SIMULATOR_VERSION = 3
module.exports.setup = (app) -> connectToScoringQueue()
connectToScoringQueue = ->
module.exports.setup = (app) ->
# Connect to scoring queue
queues.initializeQueueClient ->
queues.queueClient.registerQueue 'scoring', {}, (error, data) ->
if error? then throw new Error "There was an error registering the scoring queue: #{error}"
scoringTaskQueue = data
scoringUtils.scoringTaskQueue = data
#log.info 'Connected to scoring task queue!'
module.exports.messagesInQueueCount = (req, res) ->
scoringTaskQueue.totalMessagesInQueue (err, count) ->
scoringUtils.scoringTaskQueue.totalMessagesInQueue (err, count) ->
if err? then return errors.serverError res, "There was an issue finding the Mongoose count:#{err}"
response = String(count)
res.send(response)
@ -35,673 +34,13 @@ module.exports.messagesInQueueCount = (req, res) ->
module.exports.addPairwiseTaskToQueueFromRequest = (req, res) ->
taskPair = req.body.sessions
addPairwiseTaskToQueue req.body.sessions, (err, success) ->
scoringUtils.addPairwiseTaskToQueue req.body.sessions, (err, success) ->
if err? then return errors.serverError res, "There was an error adding pairwise tasks: #{err}"
sendResponseObject req, res, {message: 'All task pairs were succesfully sent to the queue'}
scoringUtils.sendResponseObject res, {message: 'All task pairs were succesfully sent to the queue'}
addPairwiseTaskToQueue = (taskPair, cb) ->
LevelSession.findOne(_id: taskPair[0]).lean().exec (err, firstSession) =>
if err? then return cb err
LevelSession.find(_id: taskPair[1]).exec (err, secondSession) =>
if err? then return cb err
try
taskPairs = generateTaskPairs(secondSession, firstSession)
catch e
if e then return cb e
sendEachTaskPairToTheQueue taskPairs, (taskPairError) ->
if taskPairError? then return cb taskPairError
cb null
# We should rip these out, probably
module.exports.resimulateAllSessions = (req, res) ->
unless isUserAdmin req then return errors.unauthorized res, 'Unauthorized. Even if you are authorized, you shouldn\'t do this'
originalLevelID = req.body.originalLevelID
levelMajorVersion = parseInt(req.body.levelMajorVersion)
findParameters =
submitted: true
level:
original: originalLevelID
majorVersion: levelMajorVersion
query = LevelSession
.find(findParameters)
.lean()
query.exec (err, result) ->
if err? then return errors.serverError res, err
result = _.sample result, 10
async.each result, resimulateSession.bind(@, originalLevelID, levelMajorVersion), (err) ->
if err? then return errors.serverError res, err
sendResponseObject req, res, {message: 'All task pairs were succesfully sent to the queue'}
resimulateSession = (originalLevelID, levelMajorVersion, session, cb) =>
sessionUpdateObject =
submitted: true
submitDate: new Date()
meanStrength: 25
standardDeviation: 25/3
totalScore: 10
numberOfWinsAndTies: 0
numberOfLosses: 0
isRanking: true
LevelSession.update {_id: session._id}, sessionUpdateObject, (err, updatedSession) ->
if err? then return cb err, null
opposingTeam = calculateOpposingTeam(session.team)
fetchInitialSessionsToRankAgainst levelMajorVersion, originalLevelID, opposingTeam, (err, sessionsToRankAgainst) ->
if err? then return cb err, null
taskPairs = generateTaskPairs(sessionsToRankAgainst, session)
sendEachTaskPairToTheQueue taskPairs, (taskPairError) ->
if taskPairError? then return cb taskPairError, null
cb null
earliestSubmissionCache = {}
findEarliestSubmission = (queryParams, callback) ->
cacheKey = JSON.stringify queryParams
return callback null, cached if cached = earliestSubmissionCache[cacheKey]
LevelSession.findOne(queryParams).sort(submitDate: 1).lean().exec (err, earliest) ->
return callback err if err
result = earliestSubmissionCache[cacheKey] = earliest?.submitDate
callback null, result
findRecentRandomSession = (queryParams, callback) ->
# We pick a random submitDate between the first submit date for the level and now, then do a $lt fetch to find a session to simulate.
# We bias it towards recently submitted sessions.
findEarliestSubmission queryParams, (err, startDate) ->
return callback err, null unless startDate
now = new Date()
interval = now - startDate
cutoff = new Date now - Math.pow(Math.random(), 4) * interval
queryParams.submitDate = $gte: startDate, $lt: cutoff
selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate'
LevelSession.findOne(queryParams).sort(submitDate: -1).select(selection).lean().exec (err, session) ->
return callback err if err
callback null, session
findRandomSession = (queryParams, callback) ->
queryParams.submitted = true
favorRecent = queryParams.favorRecent
delete queryParams.favorRecent
if favorRecent
return findRecentRandomSession queryParams, callback
queryParams.randomSimulationIndex = $lte: Math.random()
selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate'
sort = randomSimulationIndex: -1
LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) ->
return callback err if err
return callback null, session if session
delete queryParams.randomSimulationIndex # Effectively switch to $gt, if our randomSimulationIndex was lower than the lowest one.
LevelSession.findOne(queryParams).sort(sort).select(selection).lean().exec (err, session) ->
return callback err if err
callback null, session
formatSessionInformation = (session) ->
sessionID: session._id
team: session.team ? 'No team'
transpiledCode: session.transpiledCode
submittedCodeLanguage: session.submittedCodeLanguage
teamSpells: session.teamSpells ? {}
levelID: session.levelID
creatorName: session.creatorName
creator: session.creator
totalScore: session.totalScore
module.exports.getTwoGames = (req, res) ->
#if isUserAnonymous req then return errors.unauthorized(res, 'You need to be logged in to get games.')
humansGameID = req.body.humansGameID
ogresGameID = req.body.ogresGameID
return if simulatorIsTooOld req, res
#ladderGameIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span'] # Let's not give any extra simulations to old ladders.
ladderGameIDs = ['dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove', 'harrowland', 'zero-sum']
levelID = _.sample ladderGameIDs
unless ogresGameID and humansGameID
recentHumans = Math.random() < 0.5 # We pick one session favoring recent submissions, then find another one uniformly to play against
async.map [{levelID: levelID, team: 'humans', favorRecent: recentHumans}, {levelID: levelID, team: 'ogres', favorRecent: not recentHumans}], findRandomSession, (err, sessions) ->
if err then return errors.serverError(res, "Couldn't get two games to simulate for #{levelID}.")
unless sessions.length is 2
res.send(204, 'No games to score.')
return res.end()
taskObject = messageGenerated: Date.now(), sessions: (formatSessionInformation session for session in sessions)
#console.log 'Dispatching random game between', taskObject.sessions[0].creatorName, 'and', taskObject.sessions[1].creatorName
sendResponseObject req, res, taskObject
else
#console.log "Directly simulating #{humansGameID} vs. #{ogresGameID}."
selection = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate'
LevelSession.findOne(_id: humansGameID).select(selection).lean().exec (err, humanSession) =>
if err? then return errors.serverError(res, 'Couldn\'t find the human game')
LevelSession.findOne(_id: ogresGameID).select(selection).lean().exec (err, ogreSession) =>
if err? then return errors.serverError(res, 'Couldn\'t find the ogre game')
taskObject = messageGenerated: Date.now(), sessions: (formatSessionInformation session for session in [humanSession, ogreSession])
sendResponseObject req, res, taskObject
module.exports.recordTwoGames = (req, res) ->
sessions = req.body.sessions
#console.log 'Recording non-chained result of', sessions?[0]?.name, sessions[0]?.metrics?.rank, 'and', sessions?[1]?.name, sessions?[1]?.metrics?.rank
return if simulatorIsTooOld req, res
req.body?.simulator?.user = '' + req.user?._id
yetiGuru = clientResponseObject: req.body, isRandomMatch: true
async.waterfall [
calculateSessionScores.bind(yetiGuru) # Fetches a few small properties from both sessions, prepares @levelSessionUpdates with the score part
indexNewScoreArray.bind(yetiGuru) # Creates and returns @newScoresObject, no query
addMatchToSessionsAndUpdate.bind(yetiGuru) # Adds matches to the session updates and does the writes
updateUserSimulationCounts.bind(yetiGuru, req.user?._id)
], (err, successMessageObject) ->
if err? then return errors.serverError res, "There was an error recording the single game: #{err}"
sendResponseObject req, res, {message: 'The single game was submitted successfully!'}
module.exports.createNewTask = (req, res) ->
requestSessionID = req.body.session
originalLevelID = req.body.originalLevelID
currentLevelID = req.body.levelID
transpiledCode = req.body.transpiledCode
requestLevelMajorVersion = parseInt(req.body.levelMajorVersion)
yetiGuru = {}
async.waterfall [
validatePermissions.bind(yetiGuru, req, requestSessionID)
fetchAndVerifyLevelType.bind(yetiGuru, currentLevelID)
fetchSessionObjectToSubmit.bind(yetiGuru, requestSessionID)
updateSessionToSubmit.bind(yetiGuru, transpiledCode)
fetchInitialSessionsToRankAgainst.bind(yetiGuru, requestLevelMajorVersion, originalLevelID)
generateAndSendTaskPairsToTheQueue
], (err, successMessageObject) ->
if err? then return errors.serverError res, "There was an error submitting the game to the queue:#{err}"
sendResponseObject req, res, successMessageObject
validatePermissions = (req, sessionID, callback) ->
if isUserAnonymous req then return callback 'You are unauthorized to submit that game to the simulator'
if isUserAdmin req then return callback null
findParameters =
_id: sessionID
selectString = 'creator submittedCode code'
query = LevelSession
.findOne(findParameters)
.select(selectString)
.lean()
query.exec (err, retrievedSession) ->
if err? then return callback err
userHasPermissionToSubmitCode = retrievedSession.creator is req.user?.id and
not _.isEqual(retrievedSession.code, retrievedSession.submittedCode)
unless userHasPermissionToSubmitCode then return callback 'You are unauthorized to submit that game to the simulator'
callback null
fetchAndVerifyLevelType = (levelID, cb) ->
findParameters =
_id: levelID
selectString = 'type'
query = Level
.findOne(findParameters)
.select(selectString)
.lean()
query.exec (err, levelWithType) ->
if err? then return cb err
if not levelWithType.type or not (levelWithType.type in ['ladder', 'hero-ladder']) then return cb 'Level isn\'t of type "ladder"'
cb null
fetchSessionObjectToSubmit = (sessionID, callback) ->
findParameters =
_id: sessionID
selectString = 'team code'
query = LevelSession
.findOne(findParameters)
.select(selectString)
query.exec (err, session) ->
callback err, session?.toObject()
updateSessionToSubmit = (transpiledCode, sessionToUpdate, callback) ->
sessionUpdateObject =
submitted: true
submittedCode: sessionToUpdate.code
transpiledCode: transpiledCode
submitDate: new Date()
#meanStrength: 25 # Let's try not resetting the score on resubmission
standardDeviation: 25/3
#totalScore: 10 # Let's try not resetting the score on resubmission
numberOfWinsAndTies: 0
numberOfLosses: 0
isRanking: true
randomSimulationIndex: Math.random()
LevelSession.update {_id: sessionToUpdate._id}, sessionUpdateObject, (err, result) ->
callback err, sessionToUpdate
fetchInitialSessionsToRankAgainst = (levelMajorVersion, levelID, submittedSession, callback) ->
opposingTeam = calculateOpposingTeam(submittedSession.team)
findParameters =
'level.original': levelID
'level.majorVersion': levelMajorVersion
submitted: true
team: opposingTeam
sortParameters =
totalScore: 1
limitNumber = 1
query = LevelSession.aggregate [
{$match: findParameters}
{$sort: sortParameters}
{$limit: limitNumber}
]
query.exec (err, sessionToRankAgainst) ->
callback err, sessionToRankAgainst, submittedSession
generateAndSendTaskPairsToTheQueue = (sessionToRankAgainst, submittedSession, callback) ->
taskPairs = generateTaskPairs(sessionToRankAgainst, submittedSession)
sendEachTaskPairToTheQueue taskPairs, (taskPairError) ->
if taskPairError? then return callback taskPairError
#console.log 'Sent task pairs to the queue!'
#console.log taskPairs
callback null, {message: 'All task pairs were succesfully sent to the queue'}
module.exports.dispatchTaskToConsumer = (req, res) ->
yetiGuru = {}
async.waterfall [
checkSimulationPermissions.bind(yetiGuru, req)
receiveMessageFromSimulationQueue
changeMessageVisibilityTimeout
parseTaskQueueMessage
constructTaskObject
constructTaskLogObject.bind(yetiGuru, getUserIDFromRequest(req))
processTaskObject
], (err, taskObjectToSend) ->
if err?
if typeof err is 'string' and err.indexOf 'No more games in the queue' isnt -1
res.send(204, 'No games to score.')
return res.end()
else
return errors.serverError res, "There was an error dispatching the task: #{err}"
sendResponseObject req, res, taskObjectToSend
checkSimulationPermissions = (req, cb) ->
if isUserAnonymous req
cb 'You need to be logged in to simulate games'
else
cb null
receiveMessageFromSimulationQueue = (cb) ->
scoringTaskQueue.receiveMessage (err, message) ->
if err? then return cb "No more games in the queue, error:#{err}"
if messageIsInvalid(message) then return cb 'Message received from queue is invalid'
cb null, message
changeMessageVisibilityTimeout = (message, cb) ->
message.changeMessageVisibilityTimeout scoringTaskTimeoutInSeconds, (err) -> cb err, message
parseTaskQueueMessage = (message, cb) ->
try
if typeof message.getBody() is 'object'
messageBody = message.getBody()
else
messageBody = JSON.parse message.getBody()
cb null, messageBody, message
catch e
cb "There was an error parsing the task.Error: #{e}"
constructTaskObject = (taskMessageBody, message, callback) ->
async.map taskMessageBody.sessions, getSessionInformation, (err, sessions) ->
if err? then return callback err
taskObject = messageGenerated: Date.now(), sessions: (formatSessionInformation session for session in sessions)
callback null, taskObject, message
constructTaskLogObject = (calculatorUserID, taskObject, message, callback) ->
taskLogObject = new TaskLog
'createdAt': new Date()
'calculator': calculatorUserID
'sentDate': Date.now()
'messageIdentifierString': message.getReceiptHandle()
taskLogObject.save (err) -> callback err, taskObject, taskLogObject, message
processTaskObject = (taskObject, taskLogObject, message, cb) ->
taskObject.taskID = taskLogObject._id
taskObject.receiptHandle = message.getReceiptHandle()
cb null, taskObject
getSessionInformation = (sessionIDString, callback) ->
findParameters =
_id: sessionIDString
selectString = 'submitDate team submittedCode teamSpells levelID creator creatorName transpiledCode submittedCodeLanguage totalScore'
query = LevelSession
.findOne(findParameters)
.select(selectString)
.lean()
query.exec (err, session) ->
if err? then return callback err, {'error': 'There was an error retrieving the session.'}
callback null, session
module.exports.processTaskResult = (req, res) ->
return if simulatorIsTooOld req, res
originalSessionID = req.body?.originalSessionID
req.body?.simulator?.user = '' + req.user?._id
yetiGuru = {}
try
async.waterfall [
verifyClientResponse.bind(yetiGuru, req.body)
fetchTaskLog.bind(yetiGuru)
checkTaskLog.bind(yetiGuru)
deleteQueueMessage.bind(yetiGuru)
fetchLevelSession.bind(yetiGuru)
checkSubmissionDate.bind(yetiGuru)
logTaskComputation.bind(yetiGuru)
calculateSessionScores.bind(yetiGuru)
indexNewScoreArray.bind(yetiGuru)
addMatchToSessionsAndUpdate.bind(yetiGuru)
updateUserSimulationCounts.bind(yetiGuru, req.user?._id)
determineIfSessionShouldContinueAndUpdateLog.bind(yetiGuru)
findNearestBetterSessionID.bind(yetiGuru)
addNewSessionsToQueue.bind(yetiGuru)
], (err, results) ->
if err is 'shouldn\'t continue'
markSessionAsDoneRanking originalSessionID, (err) ->
if err? then return sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'}
sendResponseObject req, res, {message: 'The scores were updated successfully, person lost so no more games are being inserted!'}
else if err is 'no session was found'
markSessionAsDoneRanking originalSessionID, (err) ->
if err? then return sendResponseObject req, res, {'error': 'There was an error marking the session as done ranking'}
sendResponseObject req, res, {message: 'There were no more games to rank (game is at top)!'}
else if err?
errors.serverError res, "There was an error:#{err}"
else
sendResponseObject req, res, {message: 'The scores were updated successfully and more games were sent to the queue!'}
catch e
errors.serverError res, 'There was an error processing the task result!'
verifyClientResponse = (responseObject, callback) ->
#TODO: better verification
if typeof responseObject isnt 'object' or responseObject?.originalSessionID?.length isnt 24
callback 'The response to that query is required to be a JSON object.'
else
@clientResponseObject = responseObject
callback null, responseObject
fetchTaskLog = (responseObject, callback) ->
TaskLog.findOne(_id: responseObject.taskID).lean().exec (err, taskLog) =>
return callback new Error("Couldn't find TaskLog for _id #{responseObject.taskID}!") unless taskLog
@taskLog = taskLog
callback err, taskLog
checkTaskLog = (taskLog, callback) ->
if taskLog.calculationTimeMS then return callback 'That computational task has already been performed'
if hasTaskTimedOut taskLog.sentDate then return callback 'The task has timed out'
callback null
deleteQueueMessage = (callback) ->
scoringTaskQueue.deleteMessage @clientResponseObject.receiptHandle, (err) ->
callback err
fetchLevelSession = (callback) ->
LevelSession.findOne(_id: @clientResponseObject.originalSessionID).select('submitDate creator level standardDeviation meanStrength totalScore submittedCodeLanguage').lean().exec (err, session) =>
@levelSession = session
callback err
checkSubmissionDate = (callback) ->
supposedSubmissionDate = new Date(@clientResponseObject.sessions[0].submitDate)
if Number(supposedSubmissionDate) isnt Number(@levelSession.submitDate)
callback 'The game has been resubmitted. Removing from queue...'
else
callback null
logTaskComputation = (callback) ->
@taskLog.set('calculationTimeMS', @clientResponseObject.calculationTimeMS)
@taskLog.set('sessions') # Huh?
@taskLog.calculationTimeMS = @clientResponseObject.calculationTimeMS
@taskLog.sessions = @clientResponseObject.sessions
@taskLog.save (err, saved) ->
callback err
calculateSessionScores = (callback) ->
sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID'
async.map sessionIDs, retrieveOldSessionData, (err, oldScores) =>
if err? then callback err, {error: 'There was an error retrieving the old scores'}
try
oldScoreArray = _.toArray putRankingFromMetricsIntoScoreObject @clientResponseObject, oldScores
newScoreArray = bayes.updatePlayerSkills oldScoreArray
createSessionScoreUpdate.call @, scoreObject for scoreObject in newScoreArray
callback err, newScoreArray
catch e
callback e
createSessionScoreUpdate = (scoreObject) ->
newTotalScore = scoreObject.meanStrength - 1.8 * scoreObject.standardDeviation
scoreHistoryAddition = [Date.now(), newTotalScore]
@levelSessionUpdates ?= {}
@levelSessionUpdates[scoreObject.id] =
meanStrength: scoreObject.meanStrength
standardDeviation: scoreObject.standardDeviation
totalScore: newTotalScore
$push: {scoreHistory: {$each: [scoreHistoryAddition], $slice: -1000}}
randomSimulationIndex: Math.random()
indexNewScoreArray = (newScoreArray, callback) ->
newScoresObject = _.indexBy newScoreArray, 'id'
@newScoresObject = newScoresObject
callback null, newScoresObject
addMatchToSessionsAndUpdate = (newScoreObject, callback) ->
matchObject = {}
matchObject.date = new Date()
matchObject.opponents = {}
for session in @clientResponseObject.sessions
sessionID = session.sessionID
matchObject.opponents[sessionID] = match = {}
match.sessionID = sessionID
match.userID = session.creator
match.name = session.name
match.totalScore = session.totalScore
match.metrics = {}
match.metrics.rank = Number(newScoreObject[sessionID]?.gameRanking ? 0)
match.codeLanguage = newScoreObject[sessionID].submittedCodeLanguage
#log.info "Match object computed, result: #{JSON.stringify(matchObject, null, 2)}"
#log.info 'Writing match object to database...'
#use bind with async to do the writes
sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID'
async.each sessionIDs, updateMatchesInSession.bind(@, matchObject), (err) ->
callback err
updateMatchesInSession = (matchObject, sessionID, callback) ->
currentMatchObject = {}
currentMatchObject.date = matchObject.date
currentMatchObject.metrics = matchObject.opponents[sessionID].metrics
opponentsClone = _.cloneDeep matchObject.opponents
opponentsClone = _.omit opponentsClone, sessionID
opponentsArray = _.toArray opponentsClone
currentMatchObject.opponents = opponentsArray
currentMatchObject.codeLanguage = matchObject.opponents[opponentsArray[0].sessionID].codeLanguage
#currentMatchObject.simulator = @clientResponseObject.simulator # Uncomment when actively debugging simulation mismatches
#currentMatchObject.randomSeed = parseInt(@clientResponseObject.randomSeed or 0, 10) # Uncomment when actively debugging simulation mismatches
sessionUpdateObject = @levelSessionUpdates[sessionID]
sessionUpdateObject.$push.matches = {$each: [currentMatchObject], $slice: -200}
#log.info "Update is #{JSON.stringify(sessionUpdateObject, null, 2)}"
LevelSession.update {_id: sessionID}, sessionUpdateObject, callback
updateUserSimulationCounts = (reqUserID, callback) ->
incrementUserSimulationCount reqUserID, 'simulatedBy', (err) =>
if err? then return callback err
#console.log 'Incremented user simulation count!'
unless @isRandomMatch
incrementUserSimulationCount @levelSession.creator, 'simulatedFor', callback
else
callback null
incrementUserSimulationCount = (userID, type, callback) =>
return callback null unless userID
inc = {}
inc[type] = 1
User.update {_id: userID}, {$inc: inc}, (err, affected) ->
log.error "Error incrementing #{type} for #{userID}: #{err}" if err
callback err
determineIfSessionShouldContinueAndUpdateLog = (cb) ->
sessionID = @clientResponseObject.originalSessionID
sessionRank = parseInt @clientResponseObject.originalSessionRank
queryParameters = _id: sessionID
updateParameters = '$inc': {}
if sessionRank is 0
updateParameters['$inc'] = {numberOfWinsAndTies: 1}
else
updateParameters['$inc'] = {numberOfLosses: 1}
LevelSession.findOneAndUpdate queryParameters, updateParameters, {select: 'numberOfWinsAndTies numberOfLosses', lean: true}, (err, updatedSession) ->
if err? then return cb err, updatedSession
totalNumberOfGamesPlayed = updatedSession.numberOfWinsAndTies + updatedSession.numberOfLosses
if totalNumberOfGamesPlayed < 10
#console.log 'Number of games played is less than 10, continuing...'
cb null
else
ratio = (updatedSession.numberOfLosses) / (totalNumberOfGamesPlayed)
if ratio > 0.33
cb 'shouldn\'t continue'
#console.log "Ratio(#{ratio}) is bad, ending simulation"
else
#console.log "Ratio(#{ratio}) is good, so continuing simulations"
cb null
findNearestBetterSessionID = (cb) ->
try
levelOriginalID = @levelSession.level.original
levelMajorVersion = @levelSession.level.majorVersion
sessionID = @clientResponseObject.originalSessionID
sessionTotalScore = @newScoresObject[sessionID].totalScore
opponentSessionID = _.pull(_.keys(@newScoresObject), sessionID)
opponentSessionTotalScore = @newScoresObject[opponentSessionID].totalScore
opposingTeam = calculateOpposingTeam(@clientResponseObject.originalSessionTeam)
catch e
cb e
retrieveAllOpponentSessionIDs sessionID, (err, opponentSessionIDs) ->
if err? then return cb err, null
queryParameters =
totalScore:
$gt: opponentSessionTotalScore
_id:
$nin: opponentSessionIDs
'level.original': levelOriginalID
'level.majorVersion': levelMajorVersion
submitted: true
team: opposingTeam
if opponentSessionTotalScore < 30
# Don't play a ton of matches at low scores--skip some in proportion to how close to 30 we are.
# TODO: this could be made a lot more flexible.
queryParameters['totalScore']['$gt'] = opponentSessionTotalScore + 2 * (30 - opponentSessionTotalScore) / 20
limitNumber = 1
sortParameters =
totalScore: 1
selectString = '_id totalScore'
query = LevelSession.findOne(queryParameters)
.sort(sortParameters)
.limit(limitNumber)
.select(selectString)
.lean()
#console.log "Finding session with score near #{opponentSessionTotalScore}"
query.exec (err, session) ->
if err? then return cb err, session
unless session then return cb 'no session was found'
#console.log "Found session with score #{session.totalScore}"
cb err, session._id
retrieveAllOpponentSessionIDs = (sessionID, cb) ->
query = LevelSession.findOne({_id: sessionID})
.select('matches.opponents.sessionID matches.date submitDate')
.lean()
query.exec (err, session) ->
if err? then return cb err, null
opponentSessionIDs = (match.opponents[0].sessionID for match in session.matches when match.date > session.submitDate)
cb err, opponentSessionIDs
calculateOpposingTeam = (sessionTeam) ->
teams = ['ogres', 'humans']
opposingTeams = _.pull teams, sessionTeam
return opposingTeams[0]
addNewSessionsToQueue = (sessionID, callback) ->
sessions = [@clientResponseObject.originalSessionID, sessionID]
addPairwiseTaskToQueue sessions, callback
messageIsInvalid = (message) -> (not message?) or message.isEmpty()
sendEachTaskPairToTheQueue = (taskPairs, callback) -> async.each taskPairs, sendTaskPairToQueue, callback
generateTaskPairs = (submittedSessions, sessionToScore) ->
taskPairs = []
for session in submittedSessions
if session.toObject?
session = session.toObject()
teams = ['ogres', 'humans']
opposingTeams = _.pull teams, sessionToScore.team
if String(session._id) isnt String(sessionToScore._id) and session.team in opposingTeams
#console.log 'Adding game to taskPairs!'
taskPairs.push [sessionToScore._id, String session._id]
return taskPairs
sendTaskPairToQueue = (taskPair, callback) ->
scoringTaskQueue.sendMessage {sessions: taskPair}, 5, (err, data) -> callback? err, data
getUserIDFromRequest = (req) -> if req.user? then return req.user._id else return null
isUserAnonymous = (req) -> if req.user? then return req.user.get('anonymous') else return true
isUserAdmin = (req) -> return Boolean(req.user?.isAdmin())
sendResponseObject = (req, res, object) ->
res.setHeader('Content-Type', 'application/json')
res.send(object)
res.end()
hasTaskTimedOut = (taskSentTimestamp) -> taskSentTimestamp + scoringTaskTimeoutInSeconds * 1000 < Date.now()
handleTimedOutTask = (req, res, taskBody) -> errors.clientTimeout res, 'The results weren\'t provided within the timeout'
putRankingFromMetricsIntoScoreObject = (taskObject, scoreObject) ->
scoreObject = _.indexBy scoreObject, 'id'
scoreObject[session.sessionID].gameRanking = session.metrics.rank for session in taskObject.sessions
return scoreObject
retrieveOldSessionData = (sessionID, callback) ->
formatOldScoreObject = (session) ->
standardDeviation: session.standardDeviation ? 25/3
meanStrength: session.meanStrength ? 25
totalScore: session.totalScore ? (25 - 1.8*(25/3))
id: sessionID
submittedCodeLanguage: session.submittedCodeLanguage
return formatOldScoreObject @levelSession if sessionID is @levelSession?._id # No need to fetch again
query = _id: sessionID
selection = 'standardDeviation meanStrength totalScore submittedCodeLanguage'
LevelSession.findOne(query).select(selection).lean().exec (err, session) ->
return callback err, {'error': 'There was an error retrieving the session.'} if err?
callback err, formatOldScoreObject session
markSessionAsDoneRanking = (sessionID, cb) ->
#console.log 'Marking session as done ranking...'
LevelSession.update {_id: sessionID}, {isRanking: false}, cb
simulatorIsTooOld = (req, res) ->
clientSimulator = req.body.simulator
return false if clientSimulator?.version >= SIMULATOR_VERSION
message = "Old simulator version #{clientSimulator?.version}, need to clear cache and get version #{SIMULATOR_VERSION}."
log.debug "400: #{message}"
res.send 400, message
res.end()
true
module.exports.getTwoGames = getTwoGames
module.exports.recordTwoGames = recordTwoGames
module.exports.createNewTask = createNewTask
module.exports.dispatchTaskToConsumer = dispatchTaskToConsumer
module.exports.processTaskResult = processTaskResult

View file

@ -0,0 +1,107 @@
log = require 'winston'
async = require 'async'
errors = require '../../commons/errors'
scoringUtils = require './scoringUtils'
LevelSession = require '../../levels/sessions/LevelSession'
Level = require '../../levels/Level'
module.exports = createNewTask = (req, res) ->
requestSessionID = req.body.session
originalLevelID = req.body.originalLevelID
currentLevelID = req.body.levelID
transpiledCode = req.body.transpiledCode
requestLevelMajorVersion = parseInt(req.body.levelMajorVersion)
yetiGuru = {}
async.waterfall [
validatePermissions.bind(yetiGuru, req, requestSessionID)
fetchAndVerifyLevelType.bind(yetiGuru, currentLevelID)
fetchSessionObjectToSubmit.bind(yetiGuru, requestSessionID)
updateSessionToSubmit.bind(yetiGuru, transpiledCode, req.user)
fetchInitialSessionsToRankAgainst.bind(yetiGuru, requestLevelMajorVersion, originalLevelID)
generateAndSendTaskPairsToTheQueue
], (err, successMessageObject) ->
if err? then return errors.serverError res, "There was an error submitting the game to the queue:#{err}"
scoringUtils.sendResponseObject res, successMessageObject
validatePermissions = (req, sessionID, callback) ->
return callback 'You are unauthorized to submit that game to the simulator' unless req.user?.get('email')
return callback null if req.user?.isAdmin()
findParameters = _id: sessionID
selectString = 'creator submittedCode code'
LevelSession.findOne(findParameters).select(selectString).lean().exec (err, retrievedSession) ->
if err? then return callback err
userHasPermissionToSubmitCode = retrievedSession.creator is req.user?.id and
not _.isEqual(retrievedSession.code, retrievedSession.submittedCode)
unless userHasPermissionToSubmitCode then return callback 'You are unauthorized to submit that game to the simulator'
callback null
fetchAndVerifyLevelType = (levelID, cb) ->
Level.findOne(_id: levelID).select('type').lean().exec (err, levelWithType) ->
if err? then return cb err
if not levelWithType.type or not (levelWithType.type in ['ladder', 'hero-ladder', 'course-ladder']) then return cb 'Level isn\'t of type "ladder"'
cb null
fetchSessionObjectToSubmit = (sessionID, callback) ->
LevelSession.findOne({_id: sessionID}).select('team code leagues').exec (err, session) ->
callback err, session?.toObject()
updateSessionToSubmit = (transpiledCode, user, sessionToUpdate, callback) ->
sessionUpdateObject =
submitted: true
submittedCode: sessionToUpdate.code
transpiledCode: transpiledCode
submitDate: new Date()
#meanStrength: 25 # Let's try not resetting the score on resubmission
standardDeviation: 25 / 3
#totalScore: 10 # Let's try not resetting the score on resubmission
numberOfWinsAndTies: 0
numberOfLosses: 0
isRanking: true
randomSimulationIndex: Math.random()
# Reset all league stats as well, and enter the session into any leagues the user is currently part of (not retroactive when joining new leagues)
leagueIDs = user.get('clans') or []
#leagueIDs = leagueIDs.concat user.get('courseInstances') or []
leagueIDs = (leagueID + '' for leagueID in leagueIDs) # Make sure to save them as strings.
newLeagues = []
for leagueID in leagueIDs
league = _.find(sessionToUpdate.leagues, leagueID: leagueID) ? leagueID: leagueID
league.stats ?= {}
league.stats.standardDeviation = 25 / 3
league.stats.numberOfWinsAndTies = 0
league.stats.numberOfLosses = 0
newLeagues.push(league)
unless _.isEqual newLeagues, sessionToUpdate.leagues
sessionUpdateObject.leagues = sessionToUpdate.leagues = newLeagues
LevelSession.update {_id: sessionToUpdate._id}, sessionUpdateObject, (err, result) ->
callback err, sessionToUpdate
fetchInitialSessionsToRankAgainst = (levelMajorVersion, levelID, submittedSession, callback) ->
opposingTeam = scoringUtils.calculateOpposingTeam(submittedSession.team)
findParameters =
'level.original': levelID
'level.majorVersion': levelMajorVersion
submitted: true
team: opposingTeam
sortParameters = totalScore: 1
limitNumber = 1
query = LevelSession.aggregate [
{$match: findParameters}
{$sort: sortParameters}
{$limit: limitNumber}
]
query.exec (err, sessionToRankAgainst) ->
callback err, sessionToRankAgainst, submittedSession
generateAndSendTaskPairsToTheQueue = (sessionToRankAgainst, submittedSession, callback) ->
taskPairs = scoringUtils.generateTaskPairs(sessionToRankAgainst, submittedSession)
scoringUtils.sendEachTaskPairToTheQueue taskPairs, (taskPairError) ->
if taskPairError? then return callback taskPairError
#console.log 'Sent task pairs to the queue!'
#console.log taskPairs
callback null, {message: 'All task pairs were succesfully sent to the queue'}

View file

@ -0,0 +1,80 @@
log = require 'winston'
async = require 'async'
errors = require '../../commons/errors'
scoringUtils = require './scoringUtils'
LevelSession = require '../../levels/sessions/LevelSession'
TaskLog = require './ScoringTask'
module.exports = dispatchTaskToConsumer = (req, res) ->
yetiGuru = {}
async.waterfall [
checkSimulationPermissions.bind(yetiGuru, req)
receiveMessageFromSimulationQueue
changeMessageVisibilityTimeout
parseTaskQueueMessage
constructTaskObject
constructTaskLogObject.bind(yetiGuru, getUserIDFromRequest(req))
processTaskObject
], (err, taskObjectToSend) ->
if err?
if typeof err is 'string' and err.indexOf 'No more games in the queue' isnt -1
res.send(204, 'No games to score.')
return res.end()
else
return errors.serverError res, "There was an error dispatching the task: #{err}"
scoringUtils.sendResponseObject res, taskObjectToSend
checkSimulationPermissions = (req, cb) ->
if req.user?.get('email')
cb null
else
cb 'You need to be logged in to simulate games'
receiveMessageFromSimulationQueue = (cb) ->
scoringUtils.scoringTaskQueue.receiveMessage (err, message) ->
if err? then return cb "No more games in the queue, error: #{err}"
if not message? or message.isEmpty() then return cb 'Message received from queue is invalid'
cb null, message
changeMessageVisibilityTimeout = (message, cb) ->
message.changeMessageVisibilityTimeout scoringUtils.scoringTaskTimeoutInSeconds, (err) ->
cb err, message
parseTaskQueueMessage = (message, cb) ->
try
messageBody = message.getBody()
unless typeof messageBody is 'object'
messageBody = JSON.parse messageBody
cb null, messageBody, message
catch e
cb "There was an error parsing the task. Error: #{e}"
constructTaskObject = (taskMessageBody, message, callback) ->
async.map taskMessageBody.sessions, getSessionInformation, (err, sessions) ->
if err? then return callback err
taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in sessions)
callback null, taskObject, message
getSessionInformation = (sessionIDString, callback) ->
selectString = 'submitDate team submittedCode teamSpells levelID creator creatorName transpiledCode submittedCodeLanguage totalScore'
LevelSession.findOne(_id: sessionIDString).select(selectString).lean().exec (err, session) ->
if err? then return callback err, {'error': 'There was an error retrieving the session.'}
callback null, session
constructTaskLogObject = (calculatorUserID, taskObject, message, callback) ->
taskLogObject = new TaskLog
createdAt: new Date()
calculator: calculatorUserID
sentDate: Date.now()
messageIdentifierString: message.getReceiptHandle()
taskLogObject.save (err) ->
callback err, taskObject, taskLogObject, message
getUserIDFromRequest = (req) ->
if req.user? then return req.user._id else return null
processTaskObject = (taskObject, taskLogObject, message, cb) ->
taskObject.taskID = taskLogObject._id
taskObject.receiptHandle = message.getReceiptHandle()
cb null, taskObject

View file

@ -0,0 +1,114 @@
log = require 'winston'
async = require 'async'
errors = require '../../commons/errors'
scoringUtils = require './scoringUtils'
LevelSession = require '../../levels/sessions/LevelSession'
module.exports = getTwoGames = (req, res) ->
#return errors.unauthorized(res, 'You need to be logged in to get games.') unless req.user?.get('email')
return if scoringUtils.simulatorIsTooOld req, res
humansSessionID = req.body.humansGameID
ogresSessionID = req.body.ogresGameID
return getSpecificSessions res, humansSessionID, ogresSessionID if humansSessionID and ogresSessionID
getRandomSessions req.user, sendSessionsResponse(res)
sessionSelectionString = 'team totalScore transpiledCode submittedCodeLanguage teamSpells levelID creatorName creator submitDate leagues'
sendSessionsResponse = (res) ->
(err, sessions) ->
if err then return errors.serverError res, "Couldn't get two games to simulate: #{err}"
unless sessions.length is 2
console.log 'No games to score.', sessions.length
res.send 204, 'No games to score.'
return res.end()
taskObject = messageGenerated: Date.now(), sessions: (scoringUtils.formatSessionInformation session for session in sessions)
#console.log 'Dispatching ladder game simulation between', taskObject.sessions[0].creatorName, 'and', taskObject.sessions[1].creatorName
scoringUtils.sendResponseObject res, taskObject
getSpecificSessions = (res, humansSessionID, ogresSessionID) ->
async.map [humansSessionID, ogresSessionID], getSpecificSession, sendSessionsResponse(res)
getSpecificSession = (sessionID, callback) ->
LevelSession.findOne(_id: sessionID).select(sessionSelectionString).lean().exec (err, session) ->
if err? then return callback "Couldn\'t find target simulation session #{sessionID}"
callback null, session
getRandomSessions = (user, callback) ->
# Determine whether to play a random match, an internal league match, or an external league match.
# Only people in a league will end up simulating internal league matches (for leagues they're in) except by dumb chance.
# If we don't like that, we can rework sampleByLevel to have an opportunity to switch to internal leagues if the first session had a league affiliation.
leagueIDs = user.get('clans') or []
#leagueIDs = leagueIDs.concat user.get('courseInstances') or []
leagueIDs = (leagueID + '' for leagueID in leagueIDs) # Make sure to fetch them as strings.
return sampleByLevel callback unless leagueIDs.length and Math.random() > 1 / leagueIDs.length
leagueID = _.sample leagueIDs
findRandomSession {'leagues.leagueID': leagueID, favorRecent: true}, (err, session) ->
if err then return callback err
unless session then return sampleByLevel callback
otherTeam = scoringUtils.calculateOpposingTeam session.team
queryParameters = team: otherTeam, levelID: session.levelID
if Math.random() < 0.5
# Try to play a match on the internal league ladder for this level
queryParameters['leagues.leagueID'] = leagueID
findRandomSession queryParameters, (err, otherSession) ->
if err then return callback err
if otherSession then return callback null, [session, otherSession]
# No opposing league session found; try to play an external match
delete queryParameters['leagues.leagueID']
findRandomSession queryParameters, (err, otherSession) ->
if err then return callback err
callback null, [session, otherSession]
else
# Play what will probably end up being an external match
findRandomSession queryParameters, (err, otherSession) ->
if err then return callback err
callback null, [session, otherSession]
# Sampling by level: we pick a level, then find a human and ogre session for that level, one at random, one biased towards recent submissions.
#ladderLevelIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span'] # Let's not give any extra simulations to old ladders.
ladderLevelIDs = ['dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove', 'harrowland', 'zero-sum']
sampleByLevel = (callback) ->
levelID = _.sample ladderLevelIDs
favorRecentHumans = Math.random() < 0.5 # We pick one session favoring recent submissions, then find another one uniformly to play against
async.map [{levelID: levelID, team: 'humans', favorRecent: favorRecentHumans}, {levelID: levelID, team: 'ogres', favorRecent: not favorRecentHumans}], findRandomSession, callback
findRandomSession = (queryParams, callback) ->
# In MongoDB 3.2, we will be able to easily get a random document with aggregate $sample: https://jira.mongodb.org/browse/SERVER-533
queryParams.submitted = true
favorRecent = queryParams.favorRecent
delete queryParams.favorRecent
if favorRecent
return findRecentRandomSession queryParams, callback
queryParams.randomSimulationIndex = $lte: Math.random()
sort = randomSimulationIndex: -1
LevelSession.findOne(queryParams).sort(sort).select(sessionSelectionString).lean().exec (err, session) ->
return callback err if err
return callback null, session if session
delete queryParams.randomSimulationIndex # Just find the highest-indexed session, if our randomSimulationIndex was lower than the lowest one.
LevelSession.findOne(queryParams).sort(sort).select(sessionSelectionString).lean().exec (err, session) ->
return callback err if err
callback null, session
findRecentRandomSession = (queryParams, callback) ->
# We pick a random submitDate between the first submit date for the level and now, then do a $lt fetch to find a session to simulate.
# We bias it towards recently submitted sessions.
findEarliestSubmission queryParams, (err, startDate) ->
return callback err, null unless startDate
now = new Date()
interval = now - startDate
cutoff = new Date now - Math.pow(Math.random(), 4) * interval
queryParams.submitDate = $gte: startDate, $lt: cutoff
LevelSession.findOne(queryParams).sort(submitDate: -1).select(sessionSelectionString).lean().exec (err, session) ->
return callback err if err
callback null, session
earliestSubmissionCache = {}
findEarliestSubmission = (queryParams, callback) ->
cacheKey = JSON.stringify queryParams
return callback null, cached if cached = earliestSubmissionCache[cacheKey]
LevelSession.findOne(queryParams).sort(submitDate: 1).lean().exec (err, earliest) ->
return callback err if err
result = earliestSubmissionCache[cacheKey] = earliest?.submitDate
callback null, result

View file

@ -0,0 +1,172 @@
log = require 'winston'
async = require 'async'
errors = require '../../commons/errors'
scoringUtils = require './scoringUtils'
LevelSession = require '../../levels/sessions/LevelSession'
TaskLog = require './ScoringTask'
module.exports = processTaskResult = (req, res) ->
return if scoringUtils.simulatorIsTooOld req, res
originalSessionID = req.body?.originalSessionID
req.body?.simulator?.user = '' + req.user?._id
yetiGuru = {}
try
async.waterfall [
verifyClientResponse.bind(yetiGuru, req.body)
fetchTaskLog.bind(yetiGuru)
checkTaskLog.bind(yetiGuru)
deleteQueueMessage.bind(yetiGuru)
fetchLevelSession.bind(yetiGuru)
checkSubmissionDate.bind(yetiGuru)
logTaskComputation.bind(yetiGuru)
scoringUtils.calculateSessionScores.bind(yetiGuru)
scoringUtils.indexNewScoreArray.bind(yetiGuru)
scoringUtils.addMatchToSessionsAndUpdate.bind(yetiGuru)
scoringUtils.updateUserSimulationCounts.bind(yetiGuru, req.user?._id)
determineIfSessionShouldContinueAndUpdateLog.bind(yetiGuru)
findNearestBetterSessionID.bind(yetiGuru)
addNewSessionsToQueue.bind(yetiGuru)
], (err, results) ->
if err is 'shouldn\'t continue'
markSessionAsDoneRanking originalSessionID, (err) ->
if err? then return scoringUtils.sendResponseObject res, {'error': 'There was an error marking the session as done ranking'}
scoringUtils.sendResponseObject res, {message: 'The scores were updated successfully, person lost so no more games are being inserted!'}
else if err is 'no session was found'
markSessionAsDoneRanking originalSessionID, (err) ->
if err? then return scoringUtils.sendResponseObject res, {'error': 'There was an error marking the session as done ranking'}
scoringUtils.sendResponseObject res, {message: 'There were no more games to rank (game is at top)!'}
else if err?
errors.serverError res, "There was an error:#{err}"
else
scoringUtils.sendResponseObject res, {message: 'The scores were updated successfully and more games were sent to the queue!'}
catch e
errors.serverError res, 'There was an error processing the task result!'
verifyClientResponse = (responseObject, callback) ->
# TODO: better verification
if typeof responseObject isnt 'object' or responseObject?.originalSessionID?.length isnt 24
callback 'The response to that query is required to be a JSON object.'
else
@clientResponseObject = responseObject
callback null, responseObject
fetchTaskLog = (responseObject, callback) ->
TaskLog.findOne(_id: responseObject.taskID).lean().exec (err, taskLog) =>
return callback new Error("Couldn't find TaskLog for _id #{responseObject.taskID}!") unless taskLog
@taskLog = taskLog
callback err, taskLog
checkTaskLog = (taskLog, callback) ->
if taskLog.calculationTimeMS then return callback 'That computational task has already been performed'
if hasTaskTimedOut taskLog.sentDate then return callback 'The task has timed out'
callback null
hasTaskTimedOut = (taskSentTimestamp) ->
taskSentTimestamp + scoringUtils.scoringTaskTimeoutInSeconds * 1000 < Date.now()
deleteQueueMessage = (callback) ->
scoringUtils.scoringTaskQueue.deleteMessage @clientResponseObject.receiptHandle, (err) ->
callback err
fetchLevelSession = (callback) ->
selectString = 'submitDate creator level standardDeviation meanStrength totalScore submittedCodeLanguage leagues'
LevelSession.findOne(_id: @clientResponseObject.originalSessionID).select(selectString).lean().exec (err, session) =>
@levelSession = session
callback err
checkSubmissionDate = (callback) ->
supposedSubmissionDate = new Date(@clientResponseObject.sessions[0].submitDate)
if Number(supposedSubmissionDate) isnt Number(@levelSession.submitDate)
callback 'The game has been resubmitted. Removing from queue...'
else
callback null
logTaskComputation = (callback) ->
@taskLog.set('calculationTimeMS', @clientResponseObject.calculationTimeMS)
@taskLog.set('sessions') # Huh?
@taskLog.calculationTimeMS = @clientResponseObject.calculationTimeMS
@taskLog.sessions = @clientResponseObject.sessions
@taskLog.save (err, saved) ->
callback err
determineIfSessionShouldContinueAndUpdateLog = (cb) ->
sessionID = @clientResponseObject.originalSessionID
sessionRank = parseInt @clientResponseObject.originalSessionRank
update = '$inc': {}
if sessionRank is 0
update['$inc'] = {numberOfWinsAndTies: 1}
else
update['$inc'] = {numberOfLosses: 1}
LevelSession.findOneAndUpdate {_id: sessionID}, update, {select: 'numberOfWinsAndTies numberOfLosses', lean: true}, (err, updatedSession) ->
if err? then return cb err, updatedSession
totalNumberOfGamesPlayed = updatedSession.numberOfWinsAndTies + updatedSession.numberOfLosses
if totalNumberOfGamesPlayed < 10
#console.log 'Number of games played is less than 10, continuing...'
cb null
else
ratio = (updatedSession.numberOfLosses) / (totalNumberOfGamesPlayed)
if ratio > 0.33
cb 'shouldn\'t continue'
#console.log "Ratio(#{ratio}) is bad, ending simulation"
else
#console.log "Ratio(#{ratio}) is good, so continuing simulations"
cb null
findNearestBetterSessionID = (cb) ->
try
levelOriginalID = @levelSession.level.original
levelMajorVersion = @levelSession.level.majorVersion
sessionID = @clientResponseObject.originalSessionID
sessionTotalScore = @newScoresObject[sessionID].totalScore
opponentSessionID = _.pull(_.keys(@newScoresObject), sessionID)
opponentSessionTotalScore = @newScoresObject[opponentSessionID].totalScore
opposingTeam = scoringUtils.calculateOpposingTeam(@clientResponseObject.originalSessionTeam)
catch e
cb e
retrieveAllOpponentSessionIDs sessionID, (err, opponentSessionIDs) ->
if err? then return cb err, null
queryParameters =
totalScore:
$gt: opponentSessionTotalScore
_id:
$nin: opponentSessionIDs
'level.original': levelOriginalID
'level.majorVersion': levelMajorVersion
submitted: true
team: opposingTeam
if opponentSessionTotalScore < 30
# Don't play a ton of matches at low scores--skip some in proportion to how close to 30 we are.
# TODO: this could be made a lot more flexible.
queryParameters['totalScore']['$gt'] = opponentSessionTotalScore + 2 * (30 - opponentSessionTotalScore) / 20
limitNumber = 1
sortParameters = totalScore: 1
selectString = '_id totalScore'
query = LevelSession.findOne(queryParameters)
.sort(sortParameters)
.limit(limitNumber)
.select(selectString)
.lean()
#console.log "Finding session with score near #{opponentSessionTotalScore}"
query.exec (err, session) ->
if err? then return cb err, session
unless session then return cb 'no session was found'
#console.log "Found session with score #{session.totalScore}"
cb err, session._id
retrieveAllOpponentSessionIDs = (sessionID, cb) ->
selectString = 'matches.opponents.sessionID matches.date submitDate'
LevelSession.findOne({_id: sessionID}).select(selectString).lean().exec (err, session) ->
if err? then return cb err, null
opponentSessionIDs = (match.opponents[0].sessionID for match in session.matches when match.date > session.submitDate)
cb err, opponentSessionIDs
addNewSessionsToQueue = (sessionID, callback) ->
sessions = [@clientResponseObject.originalSessionID, sessionID]
scoringUtils.addPairwiseTaskToQueue sessions, callback
markSessionAsDoneRanking = (sessionID, cb) ->
#console.log 'Marking session as done ranking...'
LevelSession.update {_id: sessionID}, {isRanking: false}, cb

View file

@ -0,0 +1,21 @@
log = require 'winston'
async = require 'async'
errors = require '../../commons/errors'
scoringUtils = require './scoringUtils'
LevelSession = require '../../levels/sessions/LevelSession'
module.exports = recordTwoGames = (req, res) ->
sessions = req.body.sessions
#console.log 'Recording non-chained result of', sessions?[0]?.name, sessions[0]?.metrics?.rank, 'and', sessions?[1]?.name, sessions?[1]?.metrics?.rank
return if scoringUtils.simulatorIsTooOld req, res
req.body?.simulator?.user = '' + req.user?._id
yetiGuru = clientResponseObject: req.body, isRandomMatch: true
async.waterfall [
scoringUtils.calculateSessionScores.bind(yetiGuru) # Fetches a few small properties from both sessions, prepares @levelSessionUpdates with the score part
scoringUtils.indexNewScoreArray.bind(yetiGuru) # Creates and returns @newScoresObject, no query
scoringUtils.addMatchToSessionsAndUpdate.bind(yetiGuru) # Adds matches to the session updates and does the writes
scoringUtils.updateUserSimulationCounts.bind(yetiGuru, req.user?._id)
], (err, successMessageObject) ->
if err? then return errors.serverError res, "There was an error recording the single game: #{err}"
scoringUtils.sendResponseObject res, {message: 'The single game was submitted successfully!'}

View file

@ -0,0 +1,236 @@
log = require 'winston'
async = require 'async'
bayes = new (require 'bayesian-battle')()
LevelSession = require '../../levels/sessions/LevelSession'
User = require '../../users/User'
SIMULATOR_VERSION = 3
module.exports.scoringTaskTimeoutInSeconds = 600
module.exports.scoringTaskQueue = null
module.exports.simulatorIsTooOld = (req, res) ->
clientSimulator = req.body.simulator
return false if clientSimulator?.version >= SIMULATOR_VERSION
message = "Old simulator version #{clientSimulator?.version}, need to clear cache and get version #{SIMULATOR_VERSION}."
log.debug "400: #{message}"
res.send 400, message
res.end()
true
module.exports.sendResponseObject = (res, object) ->
res.setHeader('Content-Type', 'application/json')
res.send(object)
res.end()
module.exports.formatSessionInformation = (session) ->
sessionID: session._id
team: session.team ? 'No team'
transpiledCode: session.transpiledCode
submittedCodeLanguage: session.submittedCodeLanguage
teamSpells: session.teamSpells ? {}
levelID: session.levelID
creatorName: session.creatorName
creator: session.creator
totalScore: session.totalScore
module.exports.calculateSessionScores = (callback) ->
sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID'
async.map sessionIDs, retrieveOldSessionData.bind(@), (err, oldScores) =>
if err? then return callback err, {error: 'There was an error retrieving the old scores'}
try
oldScoreArray = _.toArray putRankingFromMetricsIntoScoreObject @clientResponseObject, oldScores
newScoreArray = updatePlayerSkills oldScoreArray
createSessionScoreUpdate.call @, scoreObject for scoreObject in newScoreArray
callback err, newScoreArray
catch e
callback e
retrieveOldSessionData = (sessionID, callback) ->
formatOldScoreObject = (session) =>
oldScoreObject =
standardDeviation: session.standardDeviation ? 25/3
meanStrength: session.meanStrength ? 25
totalScore: session.totalScore ? (25 - 1.8*(25/3))
id: sessionID
submittedCodeLanguage: session.submittedCodeLanguage
if session.leagues?.length
_.find(@clientResponseObject.sessions, sessionID: sessionID).leagues = session.leagues
oldScoreObject.leagues = []
for league in session.leagues
oldScoreObject.leagues.push
leagueID: league.leagueID
stats:
id: sessionID
standardDeviation: league.stats.standardDeviation ? 25/3
meanStrength: league.stats.meanStrength ? 25
totalScore: league.stats.totalScore ? (25 - 1.8*(25/3))
oldScoreObject
return formatOldScoreObject @levelSession if sessionID is @levelSession?._id # No need to fetch again
query = _id: sessionID
selection = 'standardDeviation meanStrength totalScore submittedCodeLanguage leagues'
LevelSession.findOne(query).select(selection).lean().exec (err, session) ->
return callback err, {'error': 'There was an error retrieving the session.'} if err?
callback err, formatOldScoreObject session
putRankingFromMetricsIntoScoreObject = (taskObject, scoreObject) ->
scoreObject = _.indexBy scoreObject, 'id'
sharedLeagueIDs = (league.leagueID for league in (taskObject.sessions[0].leagues ? []) when _.find(taskObject.sessions[1].leagues, leagueID: league.leagueID))
for session in taskObject.sessions
scoreObject[session.sessionID].gameRanking = session.metrics.rank
for league in (session.leagues ? []) when league.leagueID in sharedLeagueIDs
# We will also score any shared leagues, and we indicate that by assigning a non-null gameRanking to them.
_.find(scoreObject[session.sessionID].leagues, leagueID: league.leagueID).stats.gameRanking = session.metrics.rank
return scoreObject
updatePlayerSkills = (oldScoreArray) ->
newScoreArray = bayes.updatePlayerSkills oldScoreArray
scoreObjectA = newScoreArray[0]
scoreObjectB = newScoreArray[1]
for leagueA in (scoreObjectA.leagues ? []) when leagueA.stats.gameRanking?
leagueB = _.find scoreObjectB.leagues, leagueID: leagueA.leagueID
[leagueA.stats, leagueB.stats] = bayes.updatePlayerSkills [leagueA.stats, leagueB.stats]
leagueA.stats.updated = leagueB.stats.updated = true
newScoreArray
createSessionScoreUpdate = (scoreObject) ->
newTotalScore = scoreObject.meanStrength - 1.8 * scoreObject.standardDeviation
scoreHistoryAddition = [Date.now(), newTotalScore]
@levelSessionUpdates ?= {}
@levelSessionUpdates[scoreObject.id] =
meanStrength: scoreObject.meanStrength
standardDeviation: scoreObject.standardDeviation
totalScore: newTotalScore
$push: {scoreHistory: {$each: [scoreHistoryAddition], $slice: -1000}}
randomSimulationIndex: Math.random()
for league, leagueIndex in (scoreObject.leagues ? [])
continue unless league.stats.updated
newTotalScore = league.stats.meanStrength - 1.8 * league.stats.standardDeviation
scoreHistoryAddition = [scoreHistoryAddition[0], newTotalScore]
leagueSetPrefix = "leagues.#{leagueIndex}.stats."
@levelSessionUpdates[scoreObject.id].$set ?= {}
@levelSessionUpdates[scoreObject.id].$push ?= {}
@levelSessionUpdates[scoreObject.id].$set[leagueSetPrefix + 'meanStrength'] = league.stats.meanStrength
@levelSessionUpdates[scoreObject.id].$set[leagueSetPrefix + 'standardDeviation'] = league.stats.standardDeviation
@levelSessionUpdates[scoreObject.id].$set[leagueSetPrefix + 'totalScore'] = newTotalScore
@levelSessionUpdates[scoreObject.id].$push[leagueSetPrefix + 'scoreHistory'] = {$each: [scoreHistoryAddition], $slice: -1000}
module.exports.indexNewScoreArray = (newScoreArray, callback) ->
newScoresObject = _.indexBy newScoreArray, 'id'
@newScoresObject = newScoresObject
callback null, newScoresObject
module.exports.addMatchToSessionsAndUpdate = (newScoreObject, callback) ->
matchObject = {}
matchObject.date = new Date()
matchObject.opponents = {}
for session in @clientResponseObject.sessions
sessionID = session.sessionID
matchObject.opponents[sessionID] = match = {}
match.sessionID = sessionID
match.userID = session.creator
match.name = session.name
match.totalScore = session.totalScore
match.metrics = {}
match.metrics.rank = Number(newScoreObject[sessionID]?.gameRanking ? 0)
match.codeLanguage = newScoreObject[sessionID].submittedCodeLanguage
#log.info "Match object computed, result: #{JSON.stringify(matchObject, null, 2)}"
#log.info 'Writing match object to database...'
#use bind with async to do the writes
sessionIDs = _.pluck @clientResponseObject.sessions, 'sessionID'
async.each sessionIDs, updateMatchesInSession.bind(@, matchObject), (err) ->
callback err
updateMatchesInSession = (matchObject, sessionID, callback) ->
currentMatchObject = {}
currentMatchObject.date = matchObject.date
currentMatchObject.metrics = matchObject.opponents[sessionID].metrics
opponentsClone = _.cloneDeep matchObject.opponents
opponentsClone = _.omit opponentsClone, sessionID
opponentsArray = _.toArray opponentsClone
currentMatchObject.opponents = opponentsArray
currentMatchObject.codeLanguage = matchObject.opponents[opponentsArray[0].sessionID].codeLanguage # TODO: we have our opponent code language in twice, do we maybe want our own code language instead?
#currentMatchObject.simulator = @clientResponseObject.simulator # Uncomment when actively debugging simulation mismatches
#currentMatchObject.randomSeed = parseInt(@clientResponseObject.randomSeed or 0, 10) # Uncomment when actively debugging simulation mismatches
sessionUpdateObject = @levelSessionUpdates[sessionID]
sessionUpdateObject.$push.matches = {$each: [currentMatchObject], $slice: -200}
myScoreObject = @newScoresObject[sessionID]
opponentSession = _.find @clientResponseObject.sessions, (session) -> session.sessionID isnt sessionID
for league, leagueIndex in myScoreObject.leagues ? []
continue unless league.stats.updated
opponentLeagueTotalScore = _.find(opponentSession.leagues, leagueID: league.leagueID).stats.totalScore ? (25 - 1.8*(25/3))
leagueMatch = _.cloneDeep currentMatchObject
leagueMatch.opponents[0].totalScore = opponentLeagueTotalScore
sessionUpdateObject.$push["leagues.#{leagueIndex}.stats.matches"] = {$each: [leagueMatch], $slice: -200}
#log.info "Update for #{sessionID} is #{JSON.stringify(sessionUpdateObject, null, 2)}"
LevelSession.update {_id: sessionID}, sessionUpdateObject, callback
module.exports.updateUserSimulationCounts = (reqUserID, callback) ->
incrementUserSimulationCount reqUserID, 'simulatedBy', (err) =>
if err? then return callback err
#console.log 'Incremented user simulation count!'
unless @isRandomMatch
incrementUserSimulationCount @levelSession.creator, 'simulatedFor', callback
else
callback null
incrementUserSimulationCount = (userID, type, callback) =>
return callback null unless userID
inc = {}
inc[type] = 1
User.update {_id: userID}, {$inc: inc}, (err, affected) ->
log.error "Error incrementing #{type} for #{userID}: #{err}" if err
callback err
module.exports.calculateOpposingTeam = (sessionTeam) ->
teams = ['ogres', 'humans']
opposingTeams = _.pull teams, sessionTeam
return opposingTeams[0]
module.exports.sendEachTaskPairToTheQueue = (taskPairs, callback) ->
async.each taskPairs, sendTaskPairToQueue, callback
sendTaskPairToQueue = (taskPair, callback) ->
module.exports.scoringTaskQueue.sendMessage {sessions: taskPair}, 5, (err, data) -> callback? err, data
module.exports.generateTaskPairs = (submittedSessions, sessionToScore) ->
taskPairs = []
for session in submittedSessions
if session.toObject?
session = session.toObject()
teams = ['ogres', 'humans']
opposingTeams = _.pull teams, sessionToScore.team
if String(session._id) isnt String(sessionToScore._id) and session.team in opposingTeams
#console.log 'Adding game to taskPairs!'
taskPairs.push [sessionToScore._id, String session._id]
return taskPairs
module.exports.addPairwiseTaskToQueue = (taskPair, cb) ->
LevelSession.findOne(_id: taskPair[0]).lean().exec (err, firstSession) =>
if err? then return cb err
LevelSession.find(_id: taskPair[1]).exec (err, secondSession) =>
if err? then return cb err
try
taskPairs = module.exports.generateTaskPairs(secondSession, firstSession)
catch e
if e then return cb e
module.exports.sendEachTaskPairToTheQueue taskPairs, (taskPairError) ->
if taskPairError? then return cb taskPairError
cb null

View file

@ -13,10 +13,6 @@ module.exports.setup = (app) ->
handler = loadQueueHandler 'scoring'
handler.messagesInQueueCount req, res
app.post '/queue/scoring/resimulateAllSessions', (req, res) ->
handler = loadQueueHandler 'scoring'
handler.resimulateAllSessions req, res
app.post '/queue/scoring/getTwoGames', (req, res) ->
handler = loadQueueHandler 'scoring'
handler.getTwoGames req, res

View file

@ -51,10 +51,8 @@ UserSchema.methods.isAnonymous = ->
@get 'anonymous'
UserSchema.methods.getUserInfo = ->
info =
id : @get('_id')
email : if @get('anonymous') then 'Unregistered User' else @get('email')
return info
id: @get('_id')
email: if @get('anonymous') then 'Unregistered User' else @get('email')
UserSchema.methods.trackActivity = (activityName, increment) ->
now = new Date()