diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 506a8671a..e57efd220 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -88,6 +88,8 @@ module.exports = class LevelLoader extends CocoClass @listenToOnce @opponentSession, 'sync', @loadDependenciesForSession loadDependenciesForSession: (session) -> + if me.id isnt session.get 'creator' + session.patch = session.save = -> console.error "Not saving session, since we didn't create it." if session is @session codeLanguage = session.get('codeLanguage') or me.get('aceConfig')?.language or 'python' modulePath = "vendor/aether-#{codeLanguage}" @@ -136,6 +138,7 @@ module.exports = class LevelLoader extends CocoClass @onWorldNecessitiesLoaded() addSessionBrowserInfo: (session) -> + return unless me.id is session.get 'creator' return unless $.browser? browser = {} browser['desktop'] = $.browser.desktop if $.browser.desktop diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee index 798ec8ad9..43a9e4e8e 100644 --- a/app/schemas/models/level.coffee +++ b/app/schemas/models/level.coffee @@ -342,7 +342,7 @@ _.extend LevelSchema.properties, type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' }} campaign: c.shortString title: 'Campaign', description: 'Which campaign this level is part of (like "desert").', format: 'hidden' # Automatically set by campaign editor. - scoreTypes: c.array {title: 'Score Types', description: 'What metric to show leaderboards for.'}, + scoreTypes: c.array {title: 'Score Types', description: 'What metric to show leaderboards for.', uniqueItems: true}, c.shortString(title: 'Score Type', 'enum': ['time', 'damage-taken', 'damage-dealt', 'gold-collected', 'difficulty']) # TODO: good version of LoC; total gear value. diff --git a/app/styles/play/modal/leaderboard-tab-view.sass b/app/styles/play/modal/leaderboard-tab-view.sass index 78b21cf12..9a175295b 100644 --- a/app/styles/play/modal/leaderboard-tab-view.sass +++ b/app/styles/play/modal/leaderboard-tab-view.sass @@ -16,17 +16,21 @@ th text-align: center - .rank-cell - font-weight: bold + tbody + tr.viewable + cursor: pointer - .name-col-cell - max-width: 150px - white-space: nowrap - overflow: hidden - text-overflow: ellipsis + .rank-cell + font-weight: bold - .hero-portrait-cell, .code-language-cell - background: transparent url(/images/common/code_languages/javascript_small.png) no-repeat center center - padding: 0 9px - background-size: 30px 30px - height: 30px + .name-col-cell + max-width: 150px + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + + .hero-portrait-cell, .code-language-cell + background: transparent url(/images/common/code_languages/javascript_small.png) no-repeat center center + background-size: 30px 30px + height: 30px + width: 32px diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control_bar.jade index 2d020e7c1..176778657 100644 --- a/app/templates/play/level/control_bar.jade +++ b/app/templates/play/level/control_bar.jade @@ -9,7 +9,7 @@ .glyphicon.glyphicon-play span(data-i18n="nav.play").home-text Levels -if isMultiplayerLevel +if isMultiplayerLevel && !observing .multiplayer-area-container .multiplayer-area .multiplayer-label(data-i18n="play_level.control_bar_multiplayer") @@ -28,17 +28,19 @@ else .buttons-area - button.btn.btn-inverse#game-menu-button(title="Show game menu") - .hamburger - span.icon-bar - span.icon-bar - span.icon-bar - span.game-menu-text(data-i18n="play_level.game_menu") Game Menu + if !observing + button.btn.btn-inverse#game-menu-button(title="Show game menu") + .hamburger + span.icon-bar + span.icon-bar + span.icon-bar + span.game-menu-text(data-i18n="play_level.game_menu") Game Menu if spectateGame button.btn.btn-xs.btn-inverse.banner#next-game-button(title="Next Game", data-i18n="play_level.next-game") Next game! - button.btn.btn-xs.btn-primary.banner#level-done-button(data-i18n="play_level.done") Done + if !observing + button.btn.btn-xs.btn-primary.banner#level-done-button(data-i18n="play_level.done") Done if me.get('anonymous') button.btn.btn-xs.btn-primary.banner#control-bar-sign-up-button(data-toggle='coco-modal', data-target='core/AuthModal', data-i18n="signup.sign_up") diff --git a/app/templates/play/level/tome/cast_button.jade b/app/templates/play/level/tome/cast_button.jade index 7527f9ba6..a8f2470cd 100644 --- a/app/templates/play/level/tome/cast_button.jade +++ b/app/templates/play/level/tome/cast_button.jade @@ -1,9 +1,10 @@ button.btn.btn-lg.btn-illustrated.cast-button(title=castVerbose) span(data-i18n="play_level.tome_run_button_ran") Ran -button.btn.btn-lg.btn-illustrated.submit-button(title=castRealTimeVerbose) - span(data-i18n="play_level.tome_submit_button") Submit - span.spl.secret.submit-again-time - -button.btn.btn-lg.btn-illustrated.done-button.secret - span(data-i18n="play_level.done") Done +if !observing + button.btn.btn-lg.btn-illustrated.submit-button(title=castRealTimeVerbose) + span(data-i18n="play_level.tome_submit_button") Submit + span.spl.secret.submit-again-time + + button.btn.btn-lg.btn-illustrated.done-button.secret + span(data-i18n="play_level.done") Done diff --git a/app/templates/play/modal/leaderboard-tab-view.jade b/app/templates/play/modal/leaderboard-tab-view.jade index dd1837cdb..6ed434fd4 100644 --- a/app/templates/play/modal/leaderboard-tab-view.jade +++ b/app/templates/play/modal/leaderboard-tab-view.jade @@ -11,16 +11,23 @@ if topScores th(colspan=4, data-i18n="general.player") th(data-i18n="general.score") th(data-i18n="general.when") + th tbody for row, rank in topScores - var isMyRow = row.creator == me.id - tr(class=isMyRow ? "success" : "", data-player-id=row.creator, data-session-id=row.id) + - var viewable = rank >= 5 || me.isAdmin(); + tr(class=isMyRow ? "success" : "" + (viewable ? " viewable" : ""), data-player-id=row.creator, data-session-id=row.session, title=viewable ? "View solution" : "Can't view top 5 solutions") td.rank-cell= rank + 1 td.code-language-cell(style="background-image: url(/images/common/code_languages/#{row.codeLanguage}_small.png)" title=_.string.capitalize(row.codeLanguage)) td.hero-portrait-cell(style="background-image: url(/file/db/thang.type/#{row.hero}/portrait.png)") td.name-col-cell= row.creatorName || "Anonymous" td.score-cell= row.score td.ago-cell= row.ago + td.viewable-cell + if viewable + .glyphicon.glyphicon-eye-open + else + .glyphicon.glyphicon-eye-close else if loading h3(data-i18n="common.loading") else diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee index 9bcf79b22..c758558f2 100644 --- a/app/views/play/CampaignView.coffee +++ b/app/views/play/CampaignView.coffee @@ -206,7 +206,7 @@ module.exports = class CampaignView extends RootView for nextLevelOriginal in level.nextLevels ? [] if nextLevel = _.find(@campaign.renderedLevels, original: nextLevelOriginal) @createLine level.position, nextLevel.position - @showLeaderboard @options.justBeatLevel?.get('slug') if @options.showLeaderboard #or true + @showLeaderboard @options.justBeatLevel?.get('slug') if @options.showLeaderboard or true @applyCampaignStyles() @testParticles() @@ -220,7 +220,7 @@ module.exports = class CampaignView extends RootView @openModalView authModal showLeaderboard: (levelSlug) -> - #levelSlug ?= 'keeping-time' # Testing: show Keeping Time + levelSlug ?= 'siege-of-stonehold' # Testing leaderboardModal = new LeaderboardModal supermodel: @supermodel, levelSlug: levelSlug @openModalView leaderboardModal diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee index b1a13c1b8..b493fa064 100644 --- a/app/views/play/level/ControlBarView.coffee +++ b/app/views/play/level/ControlBarView.coffee @@ -33,6 +33,7 @@ module.exports = class ControlBarView extends CocoView @level = options.level @levelID = @level.get('slug') @spectateGame = options.spectateGame ? false + @observing = options.session.get('creator') isnt me.id super options if @level.get('type') in ['hero-ladder'] and me.isAdmin() @isMultiplayerLevel = true @@ -66,6 +67,7 @@ module.exports = class ControlBarView extends CocoView c.difficultyTitle = "#{$.i18n.t 'play.level_difficulty'}#{c.levelDifficulty}" @lastDifficulty = c.levelDifficulty c.spectateGame = @spectateGame + c.observing = @observing @homeViewArgs = [{supermodel: if @hasReceivedMemoryWarning then null else @supermodel}] if @level.get('type', true) in ['ladder', 'ladder-tutorial', 'hero-ladder'] levelID = @level.get('slug').replace /\-tutorial$/, '' diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index cf89865a7..d2afbc082 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -95,6 +95,7 @@ module.exports = class PlayLevelView extends RootView @isEditorPreview = @getQueryVariable 'dev' @sessionID = @getQueryVariable 'session' + @observing = @getQueryVariable 'observing' @opponentSessionID = @getQueryVariable('opponent') @opponentSessionID ?= @options.opponent @@ -109,7 +110,7 @@ module.exports = class PlayLevelView extends RootView setTimeout f, 100 else @load() - application.tracker?.trackEvent 'Started Level Load', category: 'Play Level', level: @levelID, label: @levelID, ['Google Analytics'] + application.tracker?.trackEvent 'Started Level Load', category: 'Play Level', level: @levelID, label: @levelID, ['Google Analytics'] unless @observing setLevel: (@level, givenSupermodel) -> @supermodel.models = givenSupermodel.models @@ -134,8 +135,9 @@ module.exports = class PlayLevelView extends RootView @loadEndTime = new Date() loadDuration = @loadEndTime - @loadStartTime console.debug "Level unveiled after #{(loadDuration / 1000).toFixed(2)}s" - application.tracker?.trackEvent 'Finished Level Load', category: 'Play Level', label: @levelID, level: @levelID, loadDuration: loadDuration, ['Google Analytics'] - application.tracker?.trackTiming loadDuration, 'Level Load Time', @levelID, @levelID + unless @observing + application.tracker?.trackEvent 'Finished Level Load', category: 'Play Level', label: @levelID, level: @levelID, loadDuration: loadDuration, ['Google Analytics'] + application.tracker?.trackTiming loadDuration, 'Level Load Time', @levelID, @levelID # CocoView overridden methods ############################################### @@ -147,7 +149,7 @@ module.exports = class PlayLevelView extends RootView afterRender: -> super() window.onPlayLevelViewLoaded? @ # still a hack - @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil, level: @levelLoader?.level ? @level # May not have @level loaded yet + @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level # May not have @level loaded yet @$el.find('#level-done-button').hide() $('body').addClass('is-playing') $('body').bind('touchmove', false) if @isIPadApp() @@ -233,7 +235,7 @@ module.exports = class PlayLevelView extends RootView @god.setGoalManager @goalManager insertSubviews: -> - @insertSubView @tome = new TomeView levelID: @levelID, session: @session, otherSession: @otherSession, thangs: @world.thangs, supermodel: @supermodel, level: @level + @insertSubView @tome = new TomeView levelID: @levelID, session: @session, otherSession: @otherSession, thangs: @world.thangs, supermodel: @supermodel, level: @level, observing: @observing @insertSubView new LevelPlaybackView session: @session, level: @level @insertSubView new GoalsView {} @insertSubView new LevelFlagsView levelID: @levelID, world: @world if @$el.hasClass 'flags' @@ -283,7 +285,7 @@ module.exports = class PlayLevelView extends RootView return unless @levelLoader.progress() is 1 # double check, since closing the guide may trigger this early # Save latest level played. - if not (@levelLoader.level.get('type') in ['ladder', 'ladder-tutorial']) + if not @observing and not (@levelLoader.level.get('type') in ['ladder', 'ladder-tutorial']) me.set('lastLevel', @levelID) me.save() application.tracker?.identify() @@ -321,7 +323,7 @@ module.exports = class PlayLevelView extends RootView if @otherSession and not (@level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']) # TODO: colorize name and cloud by team, colorize wizard by user's color config @surface.createOpponentWizard id: @otherSession.get('creator'), name: @otherSession.get('creatorName'), team: @otherSession.get('team'), levelSlug: @level.get('slug'), codeLanguage: @otherSession.get('submittedCodeLanguage') - if @isEditorPreview + if @isEditorPreview or @observing @loadingView.startUnveiling() @loadingView.unveil() @@ -337,7 +339,7 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'playback:real-time-playback-waiting', {} @realTimeMultiplayerContinueGame @options.realTimeMultiplayerSessionID # TODO: Is it possible to create a Mongoose ObjectId for 'ls', instead of the string returned from get()? - application.tracker?.trackEvent 'Started Level', category:'Play Level', levelID: @levelID, ls: @session?.get('_id') + application.tracker?.trackEvent 'Started Level', category:'Play Level', levelID: @levelID, ls: @session?.get('_id') unless @observing playAmbientSound: -> return if @destroyed @@ -414,7 +416,7 @@ module.exports = class PlayLevelView extends RootView return if @victorySeen @victorySeen = true victoryTime = (new Date()) - @loadEndTime - if victoryTime > 10 * 1000 # Don't track it if we're reloading an already-beaten level + if not @observing and victoryTime > 10 * 1000 # Don't track it if we're reloading an already-beaten level application.tracker?.trackEvent 'Saw Victory', category: 'Play Level' level: @level.get('name') @@ -436,12 +438,12 @@ module.exports = class PlayLevelView extends RootView @tome.reloadAllCode() Backbone.Mediator.publish 'level:restarted', {} $('#level-done-button', @$el).hide() - application.tracker?.trackEvent 'Confirmed Restart', category: 'Play Level', level: @level.get('name'), label: @level.get('name') + application.tracker?.trackEvent 'Confirmed Restart', category: 'Play Level', level: @level.get('name'), label: @level.get('name') unless @observing onInfiniteLoop: (e) -> return unless e.firstWorld @openModalView new InfiniteLoopModal() - application.tracker?.trackEvent 'Saw Initial Infinite Loop', category: 'Play Level', level: @level.get('name'), label: @level.get('name') + application.tracker?.trackEvent 'Saw Initial Infinite Loop', category: 'Play Level', level: @level.get('name'), label: @level.get('name') unless @observing onHighlightDOM: (e) -> @highlightElement e.selector, delay: e.delay, sides: e.sides, offset: e.offset, rotation: e.rotation diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee index 9b4d0a480..64b1324a8 100644 --- a/app/views/play/level/tome/CastButtonView.coffee +++ b/app/views/play/level/tome/CastButtonView.coffee @@ -27,6 +27,7 @@ module.exports = class CastButtonView extends CocoView @spells = options.spells @castShortcut = '⇧↵' @updateReplayabilityInterval = setInterval @updateReplayability, 1000 + @observing = options.session.get('creator') isnt me.id destroy: -> clearInterval @updateReplayabilityInterval @@ -40,6 +41,7 @@ module.exports = class CastButtonView extends CocoView castRealTimeShortcutVerbose = (if @isMac() then 'Cmd' else 'Ctrl') + '+' + castShortcutVerbose context.castVerbose = castShortcutVerbose + ': ' + $.i18n.t('keyboard_shortcuts.run_code') context.castRealTimeVerbose = castRealTimeShortcutVerbose + ': ' + $.i18n.t('keyboard_shortcuts.run_real_time') + context.observing = @observing context afterRender: -> diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee index 33ee28de2..9c4c98f6f 100644 --- a/app/views/play/level/tome/Spell.coffee +++ b/app/views/play/level/tome/Spell.coffee @@ -15,6 +15,7 @@ module.exports = class Spell @otherSession = options.otherSession @spectateView = options.spectateView @spectateOpponentCodeLanguage = options.spectateOpponentCodeLanguage + @observing = options.observing @supermodel = options.supermodel @skipProtectAPI = options.skipProtectAPI @worker = options.worker @@ -189,6 +190,7 @@ module.exports = class Spell return true if @spectateView # Use transpiled code for both teams if we're just spectating. return true if @isEnemySpell() # Use transpiled for enemy spells. # Players without permissions can't view the raw code. + return false if @observing and @levelType is 'hero' return true if @session.get('creator') isnt me.id and not (me.isAdmin() or 'employer' in me.get('permissions', true)) false diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index 7f0e3465d..4f45d87d5 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -69,6 +69,7 @@ module.exports = class SpellView extends CocoView @writable = false unless me.team in @spell.permissions.readwrite # TODO: make this do anything @highlightCurrentLine = _.throttle @highlightCurrentLine, 100 $(window).on 'resize', @onWindowResize + @observing = @session.get('creator') isnt me.id afterRender: -> super() @@ -119,14 +120,15 @@ module.exports = class SpellView extends CocoView name: 'run-code' bindKey: {win: 'Shift-Enter|Ctrl-Enter', mac: 'Shift-Enter|Command-Enter|Ctrl-Enter'} exec: -> Backbone.Mediator.publish 'tome:manual-cast', {} - addCommand - name: 'run-code-real-time' - bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'} - exec: => - if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0 - Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit - else - Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} + unless @observing + addCommand + name: 'run-code-real-time' + bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'} + exec: => + if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0 + Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit + else + Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} addCommand name: 'no-op' bindKey: {win: 'Ctrl-S', mac: 'Command-S|Ctrl-S'} diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee index 3ba0c88b0..c31886023 100644 --- a/app/views/play/level/tome/TomeView.coffee +++ b/app/views/play/level/tome/TomeView.coffee @@ -134,6 +134,7 @@ module.exports = class TomeView extends CocoView language: language spectateView: @options.spectateView spectateOpponentCodeLanguage: @options.spectateOpponentCodeLanguage + observing: @options.observing levelID: @options.levelID level: @options.level diff --git a/app/views/play/modal/LeaderboardTabView.coffee b/app/views/play/modal/LeaderboardTabView.coffee index b0fe9fc9c..33faa4680 100644 --- a/app/views/play/modal/LeaderboardTabView.coffee +++ b/app/views/play/modal/LeaderboardTabView.coffee @@ -15,7 +15,8 @@ module.exports = class LeaderboardTabView extends CocoView template: template className: 'leaderboard-tab-view' - events: {} + events: + 'click tbody tr.viewable': 'onClickRow' constructor: (options) -> super options @@ -67,3 +68,8 @@ module.exports = class LeaderboardTabView extends CocoView @hasShown = true topScores = new TopScoresCollection @level, @scoreType, @timespan @sessions = @supermodel.loadCollection(topScores, 'sessions', null, 0).model + + onClickRow: (e) -> + sessionID = $(e.target).closest('tr').data 'session-id' + url = "/play/level/#{@level.get('slug')}?session=#{sessionID}&observing=true" + window.open url, '_blank' diff --git a/server/levels/sessions/level_session_handler.coffee b/server/levels/sessions/level_session_handler.coffee index 171f90fc8..208b68071 100644 --- a/server/levels/sessions/level_session_handler.coffee +++ b/server/levels/sessions/level_session_handler.coffee @@ -15,7 +15,10 @@ class LevelSessionHandler extends Handler formatEntity: (req, document) -> documentObject = super(req, document) - if req.user?.isAdmin() or req.user?.id is document.creator or ('employer' in (req.user?.get('permissions') ? [])) + if req.user?.isAdmin() or + req.user?.id is document.creator or + ('employer' in (req.user?.get('permissions') ? [])) or + !document.submittedCode # TODO: only allow leaderboard access to non-top-5 solutions return documentObject else return _.omit documentObject, @privateProperties @@ -47,8 +50,9 @@ class LevelSessionHandler extends Handler @sendSuccess res, documents hasAccessToDocument: (req, document, method=null) -> - return true if req.method is 'GET' and document.get('submitted') - return true if ('employer' in (req.user?.get('permissions') ? [])) and (method ? req.method).toLowerCase() is 'get' + get = (method ? req.method).toLowerCase() is 'get' + return true if get and document.get('submitted') + return true if get and ('employer' in (req.user?.get('permissions') ? [])) super(arguments...) getCodeLanguageCounts: (req, res) ->