Merge branch 'master' into production
This commit is contained in:
commit
c444bf9c59
15 changed files with 143 additions and 60 deletions
|
@ -1,4 +1,4 @@
|
|||
{backboneFailure, genericFailure} = require 'lib/errors'
|
||||
{backboneFailure, genericFailure, parseServerError} = require 'lib/errors'
|
||||
User = require 'models/User'
|
||||
storage = require 'lib/storage'
|
||||
BEEN_HERE_BEFORE_KEY = 'beenHereBefore'
|
||||
|
@ -16,7 +16,14 @@ init = ->
|
|||
module.exports.createUser = (userObject, failure=backboneFailure, nextURL=null) ->
|
||||
user = new User(userObject)
|
||||
user.save({}, {
|
||||
error: failure,
|
||||
error: (model,jqxhr,options) ->
|
||||
error = parseServerError(jqxhr.responseText)
|
||||
property = error.property if error.property
|
||||
if jqxhr.status is 409 and property is 'name'
|
||||
anonUserObject = _.omit(userObject, 'name')
|
||||
module.exports.createUser anonUserObject, failure, nextURL
|
||||
else
|
||||
genericFailure(jqxhr)
|
||||
success: -> if nextURL then window.location.href = nextURL else window.location.reload()
|
||||
})
|
||||
|
||||
|
|
|
@ -103,10 +103,8 @@ module.exports.getConflicts = (headDeltas, pendingDeltas) ->
|
|||
pendingPathMap = groupDeltasByAffectingPaths(pendingDeltas)
|
||||
paths = _.keys(headPathMap).concat(_.keys(pendingPathMap))
|
||||
|
||||
# Here's my thinking:
|
||||
# A) Conflicts happen when one delta path is a substring of another delta path
|
||||
# B) A delta from one self-consistent group cannot conflict with another
|
||||
# So, sort the paths, which will naturally make conflicts adjacent,
|
||||
# Here's my thinking: conflicts happen when one delta path is a substring of another delta path
|
||||
# So, sort paths from both deltas together, which will naturally make conflicts adjacent,
|
||||
# and if one is identified, one path is from the headDeltas, the other is from pendingDeltas
|
||||
# This is all to avoid an O(nm) brute force search.
|
||||
|
||||
|
@ -141,7 +139,27 @@ groupDeltasByAffectingPaths = (deltas) ->
|
|||
delta: delta
|
||||
path: (item.toString() for item in path).join('/')
|
||||
}
|
||||
_.groupBy metaDeltas, 'path'
|
||||
|
||||
map = _.groupBy metaDeltas, 'path'
|
||||
|
||||
# Turns out there are cases where a single delta can include paths
|
||||
# that 'conflict' with each other, ie one is a substring of the other
|
||||
# because of moved indices. To handle this case, go through and prune
|
||||
# out all deeper paths that conflict with more shallow paths, so
|
||||
# getConflicts path checking works properly.
|
||||
|
||||
paths = _.keys(map)
|
||||
return map unless paths.length
|
||||
paths.sort()
|
||||
prunedMap = {}
|
||||
previousPath = paths[0]
|
||||
for path, i in paths
|
||||
continue if i is 0
|
||||
continue if path.startsWith previousPath
|
||||
prunedMap[path] = map[path]
|
||||
previousPath = path
|
||||
|
||||
prunedMap
|
||||
|
||||
module.exports.pruneConflictsFromDelta = (delta, conflicts) ->
|
||||
# the jsondiffpatch delta mustn't include any dangling nodes,
|
||||
|
|
|
@ -246,6 +246,7 @@
|
|||
multiplayer_hint_label: "Hint:"
|
||||
multiplayer_hint: " Click the link to select all, then press ⌘-C or Ctrl-C to copy the link."
|
||||
multiplayer_coming_soon: "More multiplayer features to come!"
|
||||
multiplayer_sign_in_leaderboard: "Sign in or create an account and get your solution on the leaderboard."
|
||||
guide_title: "Guide"
|
||||
tome_minion_spells: "Your Minions' Spells"
|
||||
tome_read_only_spells: "Read-Only Spells"
|
||||
|
@ -710,4 +711,4 @@
|
|||
user_names: "User Names"
|
||||
files: "Files"
|
||||
top_simulators: "Top Simulators"
|
||||
source_document: "Source Document"
|
||||
source_document: "Source Document"
|
||||
|
|
|
@ -24,3 +24,15 @@ module.exports = class LevelSession extends CocoModel
|
|||
code = @get('code')
|
||||
parts = spellKey.split '/'
|
||||
code?[parts[0]]?[parts[1]]
|
||||
|
||||
readyToRank: ->
|
||||
return false unless @get('levelID') # If it hasn't been denormalized, then it's not ready.
|
||||
return false unless c1 = @get('code')
|
||||
return false unless team = @get('team')
|
||||
return true unless c2 = @get('submittedCode')
|
||||
thangSpellArr = (s.split("/") for s in @get('teamSpells')[team])
|
||||
for item in thangSpellArr
|
||||
thang = item[0]
|
||||
spell = item[1]
|
||||
return true if c1[thang][spell] isnt c2[thang][spell]
|
||||
false
|
||||
|
|
|
@ -4,6 +4,7 @@ ThangComponentSchema = require './thang_component'
|
|||
SpecificArticleSchema = c.object()
|
||||
c.extendNamedProperties SpecificArticleSchema # name first
|
||||
SpecificArticleSchema.properties.body = { type: 'string', title: 'Content', description: "The body content of the article, in Markdown.", format: 'markdown' }
|
||||
SpecificArticleSchema.properties.i18n = {type: "object", format: 'i18n', props: ['name', 'body'], description: "Help translate this article"}
|
||||
SpecificArticleSchema.displayProperty = 'name'
|
||||
|
||||
side = {title: "Side", description: "A side.", type: 'string', 'enum': ['left', 'right', 'top', 'bottom']}
|
||||
|
|
|
@ -27,10 +27,11 @@ block modal-body-content
|
|||
|
||||
if ladderGame
|
||||
if me.get('anonymous')
|
||||
p Sign in or create an account and get your solution on the leaderboard!
|
||||
p(data-i18n="play_level.multiplayer_sign_in_leaderboard") Sign in or create an account and get your solution on the leaderboard.
|
||||
else if readyToRank
|
||||
button.btn.btn-success.rank-game-button(data-i18n="play_level.victory_rank_my_game") Rank My Game
|
||||
else
|
||||
a#go-to-leaderboard-button.btn.btn-primary(href="/play/ladder/#{levelSlug}#my-matches") Go to the leaderboard!
|
||||
p You can submit your game to be ranked from the leaderboard page.
|
||||
a.btn.btn-primary(href="/play/ladder/#{levelSlug}#my-matches", data-i18n="play_level.victory_go_ladder") Return to Ladder
|
||||
|
||||
block modal-footer-content
|
||||
a(href='#', data-dismiss="modal", aria-hidden="true", data-i18n="modal.close").btn.btn-primary Close
|
||||
|
|
|
@ -72,7 +72,7 @@ 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]
|
||||
team.readyToRank = @readyToRank(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.reverse()
|
||||
|
@ -84,7 +84,7 @@ module.exports = class MyMatchesTabView extends CocoView
|
|||
if scoreHistory?.length > 1
|
||||
team.scoreHistory = scoreHistory
|
||||
scoreHistory = _.last scoreHistory, 100 # Chart URL needs to be under 2048 characters for GET
|
||||
|
||||
|
||||
team.currentScore = Math.round scoreHistory[scoreHistory.length - 1][1] * 100
|
||||
team.chartColor = team.primaryColor.replace '#', ''
|
||||
#times = (s[0] for s in scoreHistory)
|
||||
|
@ -108,36 +108,35 @@ module.exports = class MyMatchesTabView extends CocoView
|
|||
sessionID = button.data('session-id')
|
||||
session = _.find @sessions.models, {id: sessionID}
|
||||
rankingState = 'unavailable'
|
||||
if @readyToRank session
|
||||
if session.readyToRank()
|
||||
rankingState = 'rank'
|
||||
else if session.get 'isRanking'
|
||||
rankingState = 'ranking'
|
||||
@setRankingButtonText button, rankingState
|
||||
|
||||
|
||||
@$el.find('.score-chart-wrapper').each (i, el) =>
|
||||
scoreWrapper = $(el)
|
||||
team = _.find @teams, name: scoreWrapper.data('team-name')
|
||||
@generateScoreLineChart(scoreWrapper.attr('id'), team.scoreHistory, team.name)
|
||||
|
||||
|
||||
generateScoreLineChart: (wrapperID, scoreHistory,teamName) =>
|
||||
margin =
|
||||
margin =
|
||||
top: 20
|
||||
right: 20
|
||||
bottom: 30
|
||||
left: 50
|
||||
|
||||
|
||||
width = 450 - margin.left - margin.right
|
||||
height = 125
|
||||
x = d3.time.scale().range([0,width])
|
||||
y = d3.scale.linear().range([height,0])
|
||||
|
||||
|
||||
xAxis = d3.svg.axis().scale(x).orient("bottom").ticks(4).outerTickSize(0)
|
||||
yAxis = d3.svg.axis().scale(y).orient("left").ticks(4).outerTickSize(0)
|
||||
|
||||
|
||||
line = d3.svg.line().x(((d) -> x(d.date))).y((d) -> y(d.close))
|
||||
selector = "#" + wrapperID
|
||||
|
||||
|
||||
svg = d3.select(selector).append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
|
@ -150,12 +149,10 @@ module.exports = class MyMatchesTabView extends CocoView
|
|||
date: time
|
||||
close: d[1] * 100
|
||||
}
|
||||
|
||||
|
||||
x.domain(d3.extent(data, (d) -> d.date))
|
||||
y.domain(d3.extent(data, (d) -> d.close))
|
||||
|
||||
|
||||
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis)
|
||||
|
@ -172,21 +169,6 @@ module.exports = class MyMatchesTabView extends CocoView
|
|||
.datum(data)
|
||||
.attr("class",lineClass)
|
||||
.attr("d",line)
|
||||
|
||||
|
||||
|
||||
|
||||
readyToRank: (session) ->
|
||||
return false unless session?.get('levelID') # If it hasn't been denormalized, then it's not ready.
|
||||
return false unless c1 = session.get('code')
|
||||
return false unless team = session.get('team')
|
||||
return true unless c2 = session.get('submittedCode')
|
||||
thangSpellArr = (s.split("/") for s in session.get('teamSpells')[team])
|
||||
for item in thangSpellArr
|
||||
thang = item[0]
|
||||
spell = item[1]
|
||||
return true if c1[thang][spell] isnt c2[thang][spell]
|
||||
return false
|
||||
|
||||
rankSession: (e) ->
|
||||
button = $(e.target).closest('.rank-button')
|
||||
|
@ -202,7 +184,6 @@ module.exports = class MyMatchesTabView extends CocoView
|
|||
@setRankingButtonText(button, 'failed')
|
||||
|
||||
ajaxData = {session: sessionID, levelID: @level.id, originalLevelID: @level.attributes.original, levelMajorVersion: @level.attributes.version.major}
|
||||
console.log "Posting game for ranking from My Matches view."
|
||||
$.ajax '/queue/scoring', {
|
||||
type: 'POST'
|
||||
data: ajaxData
|
||||
|
|
|
@ -56,7 +56,6 @@ module.exports = class ControlBarView extends View
|
|||
c.multiplayerEnabled = @session.get('multiplayer')
|
||||
c.ladderGame = @level.get('type') is 'ladder'
|
||||
c.spectateGame = @spectateGame
|
||||
console.log "level type is", @level.get('type')
|
||||
if @level.get('type') in ['ladder', 'ladder-tutorial']
|
||||
c.homeLink = '/play/ladder/' + @level.get('slug').replace /\-tutorial$/, ''
|
||||
else
|
||||
|
|
|
@ -9,7 +9,7 @@ module.exports = class MultiplayerModal extends View
|
|||
events:
|
||||
'click textarea': 'onClickLink'
|
||||
'change #multiplayer': 'updateLinkSection'
|
||||
|
||||
'click .rank-game-button': 'onRankGame'
|
||||
|
||||
constructor: (options) ->
|
||||
super(options)
|
||||
|
@ -17,20 +17,20 @@ module.exports = class MultiplayerModal extends View
|
|||
@level = options.level
|
||||
@listenTo(@session, 'change:multiplayer', @updateLinkSection)
|
||||
@playableTeams = options.playableTeams
|
||||
@ladderGame = options.ladderGame
|
||||
console.log 'ladder game is', @ladderGame
|
||||
|
||||
getRenderData: ->
|
||||
c = super()
|
||||
c.joinLink = (document.location.href.replace(/\?.*/, '').replace('#', '') +
|
||||
'?session=' +
|
||||
@session.id)
|
||||
c.multiplayer = @session.get('multiplayer')
|
||||
c.multiplayer = @session.get 'multiplayer'
|
||||
c.team = @session.get 'team'
|
||||
c.levelSlug = @level?.get('slug')
|
||||
c.levelSlug = @level?.get 'slug'
|
||||
c.playableTeams = @playableTeams
|
||||
c.ladderGame = @ladderGame
|
||||
# For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet.
|
||||
if @level?.get('type') is 'ladder'
|
||||
c.ladderGame = true
|
||||
c.readyToRank = @session?.readyToRank()
|
||||
c
|
||||
|
||||
afterRender: ->
|
||||
|
@ -50,5 +50,20 @@ module.exports = class MultiplayerModal extends View
|
|||
multiplayer = Boolean(@$el.find('#multiplayer').prop('checked'))
|
||||
@session.set('multiplayer', multiplayer)
|
||||
|
||||
onRankGame: (e) ->
|
||||
button = @$el.find('.rank-game-button')
|
||||
button.text($.i18n.t('play_level.victory_ranking_game', defaultValue: 'Submitting...'))
|
||||
button.prop 'disabled', true
|
||||
ajaxData = session: @session.id, levelID: @level.id, originalLevelID: @level.get('original'), levelMajorVersion: @level.get('version').major
|
||||
ladderURL = "/play/ladder/#{@level.get('slug')}#my-matches"
|
||||
goToLadder = -> Backbone.Mediator.publish 'router:navigate', route: ladderURL
|
||||
$.ajax '/queue/scoring',
|
||||
type: 'POST'
|
||||
data: ajaxData
|
||||
success: goToLadder
|
||||
failure: (response) ->
|
||||
console.error "Couldn't submit game for ranking:", response
|
||||
goToLadder()
|
||||
|
||||
destroy: ->
|
||||
super()
|
||||
|
|
|
@ -65,7 +65,6 @@ module.exports = class VictoryModal extends View
|
|||
ajaxData = session: @session.id, levelID: @level.id, originalLevelID: @level.get('original'), levelMajorVersion: @level.get('version').major
|
||||
ladderURL = "/play/ladder/#{@level.get('slug')}#my-matches"
|
||||
goToLadder = -> Backbone.Mediator.publish 'router:navigate', route: ladderURL
|
||||
console.log "Posting game for ranking from victory modal."
|
||||
$.ajax '/queue/scoring',
|
||||
type: 'POST'
|
||||
data: ajaxData
|
||||
|
@ -82,9 +81,7 @@ module.exports = class VictoryModal extends View
|
|||
c.levelName = utils.i18n @level.attributes, 'name'
|
||||
c.level = @level
|
||||
if c.level.get('type') is 'ladder'
|
||||
c1 = @session?.get('code')
|
||||
c2 = @session?.get('submittedCode')
|
||||
c.readyToRank = @session.get('levelID') and c1 and not _.isEqual(c1, c2)
|
||||
c.readyToRank = @session.readyToRank()
|
||||
if me.get 'hourOfCode'
|
||||
# Show the Hour of Code "I'm Done" tracking pixel after they played for 30 minutes
|
||||
elapsed = (new Date() - new Date(me.get('dateCreated')))
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
"mongoose": "3.8.x",
|
||||
"mongoose-text-search": "~0.0.2",
|
||||
"request": "2.12.x",
|
||||
"tv4": "1.0.x",
|
||||
"tv4": "~1.0.16",
|
||||
"lodash": "~2.0.0",
|
||||
"underscore.string": "2.3.x",
|
||||
"async": "0.2.x",
|
||||
|
@ -92,7 +92,8 @@
|
|||
"karma-phantomjs-launcher": "~0.1.1",
|
||||
"karma": "~0.10.9",
|
||||
"karma-coverage": "~0.1.4",
|
||||
"compressible": "~1.0.1"
|
||||
"compressible": "~1.0.1",
|
||||
"jasmine-spec-reporter":"~0.3.0"
|
||||
},
|
||||
"license": "MIT for the code, and CC-BY for the art and music",
|
||||
"private": true,
|
||||
|
|
|
@ -15,7 +15,7 @@ module.exports.connect = () ->
|
|||
|
||||
|
||||
module.exports.generateMongoConnectionString = ->
|
||||
if config.mongo.mongoose_replica_string
|
||||
if not testing and config.mongo.mongoose_replica_string
|
||||
address = config.mongo.mongoose_replica_string
|
||||
else
|
||||
dbName = config.mongo.db
|
||||
|
@ -25,4 +25,4 @@ module.exports.generateMongoConnectionString = ->
|
|||
address = config.mongo.username + ":" + config.mongo.password + "@" + address
|
||||
address = "mongodb://#{address}/#{dbName}"
|
||||
|
||||
return address
|
||||
return address
|
||||
|
|
|
@ -105,11 +105,12 @@ UserHandler = class UserHandler extends Handler
|
|||
(req, user, callback) ->
|
||||
return callback(null, req, user) unless req.body.name
|
||||
nameLower = req.body.name?.toLowerCase()
|
||||
return callback(null, req, user) if nameLower is user.get('nameLower')
|
||||
User.findOne({nameLower:nameLower}).exec (err, otherUser) ->
|
||||
# return callback(null, req, user) if nameLower is user.get('nameLower')
|
||||
User.findOne({nameLower:nameLower,anonymous:false}).exec (err, otherUser) ->
|
||||
log.error "Database error setting user name: #{err}" if err
|
||||
return callback(res:'Database error.', code:500) if err
|
||||
r = {message:'is already used by another account', property:'name'}
|
||||
console.log 'Another user exists' if otherUser
|
||||
return callback({res:r, code:409}) if otherUser
|
||||
user.set('name', req.body.name)
|
||||
callback(null, req, user)
|
||||
|
@ -127,7 +128,7 @@ UserHandler = class UserHandler extends Handler
|
|||
@getPropertiesFromMultipleDocuments res, User, properties, ids
|
||||
|
||||
nameToID: (req, res, name) ->
|
||||
User.findOne({nameLower:name.toLowerCase()}).exec (err, otherUser) ->
|
||||
User.findOne({nameLower:name.toLowerCase(),anonymous:false}).exec (err, otherUser) ->
|
||||
res.send(if otherUser then otherUser._id else JSON.stringify(''))
|
||||
res.end()
|
||||
|
||||
|
|
|
@ -3,7 +3,12 @@
|
|||
|
||||
console.log 'IT BEGINS'
|
||||
|
||||
|
||||
require('jasmine-spec-reporter')
|
||||
jasmine.getEnv().reporter.subReporters_ = []
|
||||
jasmine.getEnv().addReporter(new jasmine.SpecReporter({
|
||||
displaySuccessfulSpec: true,
|
||||
displayFailedSpec: true
|
||||
}))
|
||||
GLOBAL._ = require('lodash')
|
||||
_.str = require('underscore.string')
|
||||
_.mixin(_.str.exports())
|
||||
|
|
|
@ -44,6 +44,20 @@ describe 'User.updateMailChimp', ->
|
|||
|
||||
describe 'POST /db/user', ->
|
||||
|
||||
createAnonNameUser = (done)->
|
||||
request.post getURL('/auth/logout'), ->
|
||||
request.get getURL('/auth/whoami'), ->
|
||||
req = request.post(getURL('/db/user'), (err, response) ->
|
||||
expect(response.statusCode).toBe(200)
|
||||
request.get getURL('/auth/whoami'), (request, response, body) ->
|
||||
res = JSON.parse(response.body)
|
||||
expect(res.anonymous).toBeTruthy()
|
||||
expect(res.name).toEqual('Jim')
|
||||
done()
|
||||
)
|
||||
form = req.form()
|
||||
form.append('name', 'Jim')
|
||||
|
||||
it 'preparing test : clears the db first', (done) ->
|
||||
clearModels [User], (err) ->
|
||||
throw err if err
|
||||
|
@ -90,6 +104,36 @@ describe 'POST /db/user', ->
|
|||
expect(user.passwordHash).toBeUndefined()
|
||||
done()
|
||||
|
||||
it 'should allow setting anonymous user name', (done) ->
|
||||
createAnonNameUser(done)
|
||||
|
||||
it 'should allow multiple anonymous users with same name', (done) ->
|
||||
createAnonNameUser(done)
|
||||
|
||||
|
||||
it 'should not allow setting existing user name to anonymous user', (done) ->
|
||||
|
||||
createAnonUser = ->
|
||||
request.post getURL('/auth/logout'), ->
|
||||
request.get getURL('/auth/whoami'), ->
|
||||
req = request.post(getURL('/db/user'), (err, response) ->
|
||||
expect(response.statusCode).toBe(409)
|
||||
done()
|
||||
)
|
||||
form = req.form()
|
||||
form.append('name', 'Jim')
|
||||
|
||||
req = request.post(getURL('/db/user'), (err,response,body) ->
|
||||
expect(response.statusCode).toBe(200)
|
||||
request.get getURL('/auth/whoami'), (request, response, body) ->
|
||||
res = JSON.parse(response.body)
|
||||
expect(res.anonymous).toBeFalsy()
|
||||
createAnonUser()
|
||||
)
|
||||
form = req.form()
|
||||
form.append('email', 'new@user.com')
|
||||
form.append('password', 'new')
|
||||
|
||||
|
||||
describe 'PUT /db/user', ->
|
||||
|
||||
|
|
Reference in a new issue