Merge branch 'master' into production

This commit is contained in:
Nick Winter 2015-02-05 15:05:44 -08:00
commit 28d01738f7
22 changed files with 317 additions and 104 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

View file

@ -1,6 +1,8 @@
// Helper for running tests through Karma.
// Hooks into the test view logic for running tests.
window.userObject = {_id:'1'}
initialize = require('core/initialize');
initialize.init();
console.debug = function() {}; // Karma conf doesn't seem to work? Debug messages are still emitted when they shouldn't be.

View file

@ -43,6 +43,8 @@ init = ->
handleNormalUrls()
setUpMoment() # Set up i18n for moment
module.exports.init = init
handleNormalUrls = ->
# http://artsy.github.com/blog/2012/06/25/replacing-hashbang-routes-with-pushstate/
$(document).on 'click', "a[href^='/']", (event) ->

View file

@ -399,10 +399,6 @@
thank_you: "Thank you for supporting CodeCombat."
sorry_to_see_you_go: "Sorry to see you go! Please let us know what we could have done better."
unsubscribe_feedback_placeholder: "O, what have we done?"
levels: "Get more practice with bonus levels!"
heroes: "More powerful heroes!"
gems: "3500 bonus gems every month!"
items: "Over 250 bonus items!"
parent_button: "Ask your parent"
parent_email_description: "We'll email them so they can buy you a CodeCombat subscription."
parent_email_input_invalid: "Email address invalid."
@ -416,7 +412,6 @@
parents_blurb1: "With CodeCombat, your child learns by writing real code. They start by learning simple commands, and progress to more advanced topics."
parents_blurb2: "For $9.99 USD/mo, they get new challenges every week and personal email support from professional programmers."
parents_blurb3: "No Risk: 100% money back guarantee, easy 1-click unsubscribe."
subscribe_button: "Subscribe"
stripe_description: "Monthly Subscription"
subscription_required_to_play: "You'll need a subscription to play this level."
unlock_help_videos: "Subscribe to unlock all video tutorials."
@ -1003,6 +998,7 @@
play_counts: "Play Counts"
feedback: "Feedback"
payment_info: "Payment Info"
campaigns: "Campaigns"
delta:
added: "Added"

View file

@ -148,6 +148,14 @@ module.exports = class User extends CocoModel
application.tracker.identify leaderboardsGroup: @leaderboardsGroup unless me.isAdmin()
@leaderboardsGroup
getShowsPortal: ->
return @showsPortal if @showsPortal?
group = me.get('testGroupNumber')
@showsPortal = if group < 128 then true else false
@showsPortal = true if me.isAdmin()
application.tracker.identify showsPortal: @showsPortal unless me.isAdmin()
@showsPortal
getVideoTutorialStylesIndex: (numVideos=0)->
# A/B Testing video tutorial styles
# Not a constant number of videos available (e.g. could be 0, 1, 3, or 4 currently)

View file

@ -91,5 +91,6 @@ AchievementSchema.definitions = {}
AchievementSchema.definitions['mongoQueryOperator'] = MongoQueryOperatorSchema
AchievementSchema.definitions['mongoFindQuery'] = MongoFindQuerySchema
c.extendTranslationCoverageProperties AchievementSchema
c.extendPatchableProperties AchievementSchema
module.exports = AchievementSchema

View file

@ -124,5 +124,6 @@ _.extend CampaignSchema.properties, {
c.extendBasicProperties CampaignSchema, 'campaign'
c.extendTranslationCoverageProperties CampaignSchema
c.extendPatchableProperties CampaignSchema
module.exports = CampaignSchema

View file

@ -1,6 +1,6 @@
c = require './../schemas'
patchables = ['level', 'thang_type', 'level_system', 'level_component', 'article']
patchables = ['level', 'thang_type', 'level_system', 'level_component', 'article', 'achievement', 'campaign']
PatchSchema = c.object({title: 'Patch', required: ['target', 'delta', 'commitMessage']}, {
delta: {title: 'Delta', type: ['array', 'object']}

View file

@ -478,6 +478,80 @@ $gameControlMargin: 30px
.particle-man
z-index: 2
.portal
position: relative
width: 100%
height: 100%
background: transparent url(/images/pages/play/portal-background.png)
display: flex
align-items: center
justify-content: center
.portals
$campaignWidth: 317px
$campaignHeight: 634px
$campaignHoverScale: 1.2
width: 6 * $campaignWidth
height: $campaignHeight * $campaignHoverScale
flex-wrap: nowrap
display: flex
overflow: hidden
.campaign
width: $campaignWidth
height: $campaignHeight
margin-top: $campaignHeight * ($campaignHoverScale - 1) / 2
background: transparent url(/images/pages/play/portal-campaigns.png) no-repeat 0 0
display: inline-block
flex-shrink: 0
position: relative
cursor: pointer
// http://easings.net/#easeOutBack plus tweaked a bit: http://cubic-bezier.com/#.11,.67,.08,1.42
@include transition(0.25s cubic-bezier(0.11, 0.67, 0.8, 1.42))
&:hover
@include scale($campaignHoverScale)
&.silhouette
@include filter(contrast(50%) brightness(65%))
pointer-events: none
&.locked
@include filter(contrast(80%) brightness(80%))
pointer-events: none
&.forest
background-position: (-1 * $campaignWidth) 0
&.desert
background-position: (-2 * $campaignWidth) 0
&.mountain
background-position: (-3 * $campaignWidth) 0
&.ice
background-position: (-4 * $campaignWidth) 0
&.volcano
background-position: (-5 * $campaignWidth) 0
.campaign-label
position: absolute
top: 55%
width: 100%
text-align: center
.campaign-name, .levels-completed, .campaign-locked
margin: 0
color: rgb(232, 217, 87)
text-shadow: black 2px 2px 0, black -2px -2px 0, black 2px -2px 0, black -2px 2px 0, black 2px 0px 0, black 0px -2px 0, black -2px 0px 0, black 0px 2px 0
z-index: 30
pointer-events: none
.levels-completed
font-size: 22px
.play-button
margin-top: 30px
min-width: 100px
body.ipad #campaign-view
// iPad only supports up to Kithgard Gates for now.

View file

@ -65,7 +65,7 @@
span.glyphicon.glyphicon-ok
#parents-info(data-i18n="subscribe.parents")
button.btn.btn-lg.btn-illustrated.purchase-button(data-i18n="subscribe.subscribe_button")
button.btn.btn-lg.btn-illustrated.purchase-button(data-i18n="subscribe.subscribe_title")
button.btn.btn-lg.btn-illustrated.parent-button(data-i18n="subscribe.parent_button")
if state === 'declined'

View file

@ -1,3 +1,4 @@
if campaign
.map
.gradient.horizontal-gradient.top-gradient
.gradient.vertical-gradient.right-gradient
@ -50,6 +51,28 @@
a(href=(editorMode ? "/editor/campaign/" : "/play/") + adjacentCampaign.slug)
span.glyphicon.glyphicon-share-alt.campaign-switch(style=adjacentCampaign.style, title=adjacentCampaign.name, data-campaign-id=adjacentCampaign.id)
else
.portal
.portals
for campaignSlug in ['dungeon', 'forest', 'desert', 'mountain', 'ice', 'volcano']
- var campaign = campaigns[campaignSlug];
div(class="campaign #{campaignSlug}" + (campaign ? "" : " silhouette") + (campaign && campaign.locked ? " locked" : ""), data-campaign-slug=campaignSlug)
.campaign-label
h2.campaign-name
if campaign
span= i18n(campaign.attributes, 'fullName')
else
span ???
if campaign && campaign.levelsTotal
h3.levels-completed
span= campaign.levelsCompleted
| /
span= campaign.levelsTotal
if campaign && campaign.locked
h3.campaign-locked(data-i18n="play.locked") Locked
else if campaign
btn(data-i18n="common.play").btn.btn-illustrated.btn-lg.btn-success.play-button
.game-controls.header-font
button.btn.items(data-toggle='coco-modal', data-target='play/modal/PlayItemsModal', data-i18n="[title]play.items")
button.btn.heroes(data-toggle='coco-modal', data-target='play/modal/PlayHeroesModal', data-i18n="[title]play.heroes")
@ -85,7 +108,7 @@ button.btn.btn-lg.btn-inverse#volume-button(data-i18n="[title]play.adjust_volume
.glyphicon.glyphicon-volume-down
.glyphicon.glyphicon-volume-up
if campaign.loaded
if campaign && campaign.loaded
h1#campaign-status
.campaign-status-background
.campaign-name

View file

@ -241,7 +241,7 @@ module.exports = class CocoView extends Backbone.View
showLoading: ($el=@$el) ->
$el.find('>').addClass('hidden')
$el.append loadingScreenTemplate()
$el.append(loadingScreenTemplate()).i18n()
@_lastLoading = $el
hideLoading: ->

View file

@ -140,9 +140,14 @@ module.exports = class I18NEditModelView extends RootView
return _.isArray(delta.o) and delta.o.length is 1 and 'i18n' in delta.dataPath
)
commitMessage = "Diplomat submission for lang #{@selectedLanguage}: #{flattened.length} change(s)."
save = false if @savedBefore
if save
modelToSave = @model.cloneNewMinorVersion()
modelToSave.updateI18NCoverage() if modelToSave.get('i18nCoverage')
if @modelClass.schema.properties.commitMessage
modelToSave.set 'commitMessage', commitMessage
else
modelToSave = new Patch()
@ -151,17 +156,21 @@ module.exports = class I18NEditModelView extends RootView
'collection': _.string.underscored @model.constructor.className
'id': @model.id
}
if @modelClass.schema.properties.commitMessage
commitMessage = "Diplomat submission for lang #{@selectedLanguage}: #{flattened.length} change(s)."
modelToSave.set 'commitMessage', commitMessage
errors = modelToSave.validate()
button = $(e.target)
button.attr('disabled', 'disabled')
return button.text('Failed to Submit Changes') if errors
res = modelToSave.save(null, {type: 'POST'}) # Override PUT so we can trigger postNewVersion logic
type = 'PUT'
if @modelClass.schema.properties.version or (not save)
# Override PUT so we can trigger postNewVersion logic
# or you're POSTing a Patch
type = 'POST'
res = modelToSave.save(null, {type: type})
return button.text('Failed to Submit Changes') unless res
button.text('Submitting...')
res.error => button.text('Error Submitting Changes')
res.success => button.text('Submit Changes')
res.success =>
@savedBefore = true
button.text('Submit Changes')

View file

@ -27,6 +27,11 @@ class LevelSessionsCollection extends CocoCollection
super()
@url = "/db/user/#{me.id}/level.sessions?project=state.complete,levelID"
class CampaignsCollection extends CocoCollection
url: '/db/campaign'
model: Campaign
project: ['name', 'fullName', 'i18n']
module.exports = class CampaignView extends RootView
id: 'campaign-view'
template: template
@ -41,18 +46,29 @@ module.exports = class CampaignView extends RootView
'click .level-info-container .start-level': 'onClickStartLevel'
'click .level-info-container .view-solutions': 'onClickViewSolutions'
'click #volume-button': 'onToggleVolume'
'click .portal .campaign': 'onClickPortalCampaign'
'mouseenter .portals': 'onMouseEnterPortals'
'mouseleave .portals': 'onMouseLeavePortals'
'mousemove .portals': 'onMouseMovePortals'
constructor: (options, @terrain='dungeon') ->
constructor: (options, @terrain) ->
super options
options ?= {}
@campaign = new Campaign({_id:@terrain})
@campaign = @supermodel.loadModel(@campaign, 'campaign').model
@editorMode = options.editorMode
@editorMode = options?.editorMode
if @editorMode
@terrain ?= 'dungeon'
else unless me.getShowsPortal()
@terrain ?= 'dungeon'
@levelStatusMap = {}
@levelPlayCountMap = {}
@sessions = @supermodel.loadCollection(new LevelSessionsCollection(), 'your_sessions', null, 0).model
@listenToOnce @sessions, 'sync', @onSessionsLoaded
unless @terrain
@campaigns = @supermodel.loadCollection(new CampaignsCollection(), 'campaigns', null, 0).model
@listenToOnce @campaigns, 'sync', @onCampaignsLoaded
return
@campaign = new Campaign({_id:@terrain})
@campaign = @supermodel.loadModel(@campaign, 'campaign').model
# Temporary attempt to make sure all earned rewards are accounted for. Figure out a better solution...
@earnedAchievements = new CocoCollection([], {url: '/db/earned_achievement', model:EarnedAchievement, project: ['earnedRewards']})
@ -69,7 +85,6 @@ module.exports = class CampaignView extends RootView
@supermodel.loadCollection(@earnedAchievements, 'achievements')
@listenToOnce @sessions, 'sync', @onSessionsLoaded
@listenToOnce @campaign, 'sync', @getLevelPlayCounts
$(window).on 'resize', @onWindowResize
@probablyCachedMusic = storage.load("loaded-menu-music")
@ -99,6 +114,7 @@ module.exports = class CampaignView extends RootView
@musicPlayer?.destroy()
clearTimeout @playMusicTimeout
@particleMan?.destroy()
clearInterval @portalScrollInterval
super()
getLevelPlayCounts: ->
@ -136,31 +152,16 @@ module.exports = class CampaignView extends RootView
getRenderData: (context={}) ->
context = super(context)
context.campaign = @campaign
context.levels = _.values($.extend true, {}, @campaign.get('levels'))
context.levelsCompleted = context.levelsTotal = 0
for level in context.levels
level.position ?= { x: 10, y: 10 }
level.locked = not me.ownsLevel level.original
level.locked = false if @levelStatusMap[level.slug] in ['started', 'complete']
level.locked = false if @editorMode
level.locked = false if @campaign.get('name') is 'Auditions'
level.disabled = true if level.adminOnly and @levelStatusMap[level.slug] not in ['started', 'complete']
level.color = 'rgb(255, 80, 60)'
if level.requiresSubscription
level.color = 'rgb(80, 130, 200)'
if unlocksHero = _.find(level.rewards, 'hero')?.hero
level.unlocksHero = unlocksHero
if level.unlocksHero
level.purchasedHero = level.unlocksHero in (me.get('purchased')?.heroes or [])
level.hidden = level.locked
unless level.disabled
++context.levelsTotal
++context.levelsCompleted if @levelStatusMap[level.slug] is 'complete'
context.levels = _.values($.extend true, {}, @campaign?.get('levels') ? {})
@annotateLevel level for level in context.levels
count = @countLevels context.levels
context.levelsCompleted = count.completed
context.levelsTotal = count.total
@determineNextLevel context.levels if @sessions.loaded
@determineNextLevel context.levels if @sessions?.loaded
# put lower levels in last, so in the world map they layer over one another properly.
context.levels = (_.sortBy context.levels, (l) -> l.position.y).reverse()
@campaign.renderedLevels = context.levels
@campaign.renderedLevels = context.levels if @campaign
context.levelStatusMap = @levelStatusMap
context.levelPlayCountMap = @levelPlayCountMap
@ -168,7 +169,7 @@ module.exports = class CampaignView extends RootView
context.mapType = _.string.slugify @terrain
context.requiresSubscription = @requiresSubscription
context.editorMode = @editorMode
context.adjacentCampaigns = _.filter _.values(_.cloneDeep(@campaign.get('adjacentCampaigns') or {})), (ac) =>
context.adjacentCampaigns = _.filter _.values(_.cloneDeep(@campaign?.get('adjacentCampaigns') or {})), (ac) =>
return false if ac.showIfUnlocked and (ac.showIfUnlocked not in me.levels()) and not @editorMode
ac.name = utils.i18n ac, 'name'
styles = []
@ -181,6 +182,26 @@ module.exports = class CampaignView extends RootView
return true
context.marked = marked
context.i18n = utils.i18n
if @campaigns
context.campaigns = {}
for campaign in @campaigns.models
context.campaigns[campaign.get('slug')] = campaign
if @sessions.loaded
levels = _.values($.extend true, {}, campaign.get('levels') ? {})
count = @countLevels levels
campaign.levelsTotal = count.total
campaign.levelsCompleted = count.completed
if campaign.get('slug') is 'dungeon'
campaign.locked = false
else unless campaign.levelsTotal
campaign.locked = true
else
campaign.locked = true
for campaign in @campaigns.models
for acID, ac of campaign.get('adjacentCampaigns') ? {}
_.find(@campaigns.models, id: acID)?.locked = false if ac.showIfUnlocked in me.levels()
context
afterRender: ->
@ -203,7 +224,7 @@ module.exports = class CampaignView extends RootView
unless window.currentModal or not @fullyRendered
@highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top']
if @editorMode
for level in @campaign.renderedLevels
for level in @campaign?.renderedLevels ? []
for nextLevelOriginal in level.nextLevels ? []
if nextLevel = _.find(@campaign.renderedLevels, original: nextLevelOriginal)
@createLine level.position, nextLevel.position
@ -220,6 +241,32 @@ module.exports = class CampaignView extends RootView
authModal.mode = 'signup'
@openModalView authModal
annotateLevel: (level) ->
level.position ?= { x: 10, y: 10 }
level.locked = not me.ownsLevel level.original
level.locked = false if @levelStatusMap[level.slug] in ['started', 'complete']
level.locked = false if @editorMode
level.locked = false if @campaign?.get('name') is 'Auditions'
level.disabled = true if level.adminOnly and @levelStatusMap[level.slug] not in ['started', 'complete']
level.color = 'rgb(255, 80, 60)'
if level.requiresSubscription
level.color = 'rgb(80, 130, 200)'
if unlocksHero = _.find(level.rewards, 'hero')?.hero
level.unlocksHero = unlocksHero
if level.unlocksHero
level.purchasedHero = level.unlocksHero in (me.get('purchased')?.heroes or [])
level.hidden = level.locked
level
countLevels: (levels) ->
count = total: 0, completed: 0
for level in levels
@annotateLevel level unless level.locked? # Annotate if we haven't already.
unless level.disabled
++count.total
++count.completed if @levelStatusMap[level.slug] is 'complete'
count
showLeaderboard: (levelSlug) ->
#levelSlug ?= 'siege-of-stonehold' # Testing
leaderboardModal = new LeaderboardModal supermodel: @supermodel, levelSlug: levelSlug
@ -249,7 +296,7 @@ module.exports = class CampaignView extends RootView
line.append($('<div class="line">')).append($('<div class="point">'))
applyCampaignStyles: ->
return unless @campaign.loaded
return unless @campaign?.loaded
if (backgrounds = @campaign.get 'backgroundImage') and backgrounds.length
backgrounds = _.sortBy backgrounds, 'width'
backgrounds.reverse()
@ -267,7 +314,7 @@ module.exports = class CampaignView extends RootView
@playAmbientSound()
testParticles: ->
return unless @campaign.loaded and me.getForeshadowsLevels()
return unless @campaign?.loaded and me.getForeshadowsLevels()
@particleMan ?= new ParticleMan()
@particleMan.removeEmitters()
@particleMan.attach @$el.find('.map')
@ -280,12 +327,42 @@ module.exports = class CampaignView extends RootView
continue if particleKey.length is 2 # Don't show basic levels
@particleMan.addEmitter level.position.x / 100, level.position.y / 100, particleKey.join('-')
onMouseEnterPortals: (e) ->
return unless @campaigns?.loaded and @sessions?.loaded
@portalScrollInterval = setInterval @onMouseMovePortals, 100
@onMouseMovePortals e
onMouseLeavePortals: (e) ->
return unless @portalScrollInterval
clearInterval @portalScrollInterval
@portalScrollInterval = null
onMouseMovePortals: (e) =>
return unless @portalScrollInterval
$portal = @$el.find('.portal')
$portals = @$el.find('.portals')
if e
@portalOffsetX = Math.round Math.max 0, e.clientX - $portal.offset().left
bodyWidth = $('body').innerWidth()
fraction = @portalOffsetX / bodyWidth
return if 0.2 < fraction < 0.8
direction = if fraction < 0.5 then 1 else -1
magnitude = 0.2 * bodyWidth * (if direction is -1 then fraction - 0.8 else 0.2 - fraction) / 0.2
portalsWidth = 1902 # TODO: if we add campaigns or change margins, this will get out of date...
scrollTo = $portals.offset().left + direction * magnitude
scrollTo = Math.max bodyWidth - portalsWidth, scrollTo
scrollTo = Math.min 0, scrollTo
$portals.stop().animate {marginLeft: scrollTo}, 100, 'linear'
onSessionsLoaded: (e) ->
return if @editorMode
for session in @sessions.models
@levelStatusMap[session.get('levelID')] = if session.get('state')?.complete then 'complete' else 'started'
@render()
onCampaignsLoaded: (e) ->
@render()
preloadLevel: (levelSlug) ->
levelURL = "/db/level/#{levelSlug}"
level = new Level().setURL levelURL
@ -445,3 +522,12 @@ module.exports = class CampaignView extends RootView
@$el.find('.player-hero-icon').removeClass().addClass('player-hero-icon ' + slug)
return
console.error "CampaignView hero update couldn't find hero slug for original:", hero
onClickPortalCampaign: (e) ->
campaign = $(e.target).closest('.campaign')
return if campaign.is('.locked') or campaign.is('.silhouette')
campaignSlug = campaign.data('campaign-slug')
Backbone.Mediator.publish 'router:navigate',
route: "/play/#{campaignSlug}"
viewClass: CampaignView
viewArgs: [{supermodel: @supermodel}, campaignSlug]

View file

@ -80,7 +80,6 @@ module.exports = class ControlBarView extends CocoView
@homeLink = c.homeLink = '/play'
@homeViewClass = 'views/play/CampaignView'
campaign = @level.get 'campaign'
if campaign isnt 'dungeon'
@homeLink += '/' + campaign
@homeViewArgs.push campaign
else

View file

@ -326,7 +326,7 @@ module.exports = class HeroVictoryModal extends ModalView
getNextLevelLink: ->
link = '/play'
nextCampaign = @getNextLevelCampaign()
link += '/' + nextCampaign unless nextCampaign is 'dungeon'
link += '/' + nextCampaign
link
onClickContinue: (e, extraOptions=null) ->

View file

@ -9,6 +9,7 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files : [
'public/javascripts/vendor.js', // need for jade definition...
'public/javascripts/whole-vendor.js',
'public/lib/ace/ace.js',
'public/javascripts/aether.js',

View file

@ -81,6 +81,7 @@ AchievementSchema.post 'save', -> @constructor.loadAchievements()
AchievementSchema.plugin(plugins.NamedPlugin)
AchievementSchema.plugin(plugins.SearchablePlugin, {searchable: ['name']})
AchievementSchema.plugin plugins.TranslationCoveragePlugin
AchievementSchema.plugin plugins.PatchablePlugin
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema, 'achievements')

View file

@ -8,5 +8,6 @@ CampaignSchema.index({slug: 1}, {name: 'slug index', sparse: true, unique: true}
CampaignSchema.plugin(plugins.NamedPlugin)
CampaignSchema.plugin(plugins.TranslationCoveragePlugin)
CampaignSchema.plugin plugins.PatchablePlugin
module.exports = mongoose.model('campaign', CampaignSchema)

View file

@ -24,6 +24,15 @@ CampaignHandler = class CampaignHandler extends Handler
hasAccess: (req) ->
req.method is 'GET' or req.user?.isAdmin()
get: (req, res) ->
return @sendForbiddenError(res) if not @hasAccess(req)
# We don't have normal text search or anything set up to make /db/campaign work, so we'll just give them all campaigns, no problem.
q = @modelClass.find {}
q.exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
getByRelationship: (req, res, args...) ->
relationship = args[1]
if relationship in ['levels', 'achievements']

View file

@ -34,7 +34,7 @@ module.exports = class Handler
hasAccessToDocument: (req, document, method=null) ->
return true if req.user?.isAdmin()
if @modelClass.schema.uses_coco_translation_coverage and (method or req.method).toLowerCase() is 'post'
if @modelClass.schema.uses_coco_translation_coverage and (method or req.method).toLowerCase() in ['post', 'put']
return true if @isJustFillingTranslations(req, document)
if @modelClass.schema.uses_coco_permissions