From d23cf5c725be2c79a5d9dd9cd8e464ff8409b047 Mon Sep 17 00:00:00 2001 From: Ehnydeel Date: Fri, 14 Nov 2014 22:27:24 +0100 Subject: [PATCH 1/5] Added some german translations Added some german translations --- app/locale/de-DE.coffee | 138 ++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/app/locale/de-DE.coffee b/app/locale/de-DE.coffee index 17f311555..d823b00cb 100644 --- a/app/locale/de-DE.coffee +++ b/app/locale/de-DE.coffee @@ -28,7 +28,7 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: about: "Über" contact: "Kontakt" twitter_follow: "Twitter" -# teachers: "Teachers" + teachers: "Lehrer" modal: close: "Schließen" @@ -51,28 +51,28 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: players: "Spieler" # Hover over a level on /play hours_played: "Stunden gespielt" # Hover over a level on /play items: "Gegenstände" # Tooltip on item shop button from /play -# unlock: "Unlock" # For purchasing items and heroes -# confirm: "Confirm" -# owned: "Owned" # For items you own -# locked: "Locked" -# available: "Available" -# skills_granted: "Skills Granted" # Property documentation details + unlock: "Entsperren" # For purchasing items and heroes + confirm: "Bestätigen" + owned: "Besitzen" # For items you own + locked: "Gesperrt" + available: "Verfügbar" + skills_granted: "Verfügbare Fähigkeiten" # Property documentation details heroes: "Helden" # Tooltip on hero shop button from /play - achievements: "Achievements" # Tooltip on achievement list button from /play + achievements: "Erfolge" # Tooltip on achievement list button from /play account: "Account" # Tooltip on account button from /play settings: "Einstellungen" # Tooltip on settings button from /play -# next: "Next" # Go from choose hero to choose inventory before playing a level -# change_hero: "Change Hero" # Go back from choose inventory to choose hero + next: "Nächster" # Go from choose hero to choose inventory before playing a level + change_hero: "Held wechseln" # Go back from choose inventory to choose hero choose_inventory: "Gegenstände ausrüsten" -# older_campaigns: "Older Campaigns" -# anonymous: "Anonymous Player" + older_campaigns: "Ältere Kampagne" + anonymous: "Anonymer Spieler" level_difficulty: "Schwierigkeit: " campaign_beginner: "Anfängerkampagne" choose_your_level: "Wähle dein Level" # The rest of this section is the old play view at /play-old and isn't very important. adventurer_prefix: "Du kannst zu jedem Level springen oder diskutiere die Level " adventurer_forum: "im Abenteurerforum" adventurer_suffix: "." -# campaign_old_beginner: "Old Beginner Campaign" + campaign_old_beginner: "Alte Anfänger Kampagne" campaign_old_beginner_description: "... in der Du die Zauberei der Programmierung lernst." campaign_dev: "Beliebiges schwierigeres Level" campaign_dev_description: "... in welchem Du die Bedienung erlernst, indem Du etwas schwierigeres machst." @@ -82,8 +82,8 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: campaign_player_created_description: "... in welchem Du gegen die Kreativität eines Artisan Zauberers kämpfst." campaign_classic_algorithms: "Klassiche Algorithmen" campaign_classic_algorithms_description: "... in welchem du die populärsten Algorithmen der Informatik lernst." -# campaign_forest: "Forest Campaign" -# campaign_dungeon: "Dungeon Campaign" + campaign_forest: "Forest Kampagne" + campaign_dungeon: "Dungeon Kampagne" login: sign_up: "Registrieren" @@ -110,12 +110,12 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: recovery_sent: "Wiederherstellungs-Email versandt." items: -# primary: "Primary" -# secondary: "Secondary" + primary: "Primär" + secondary: "Sekundär" armor: "Rüstung" accessories: "Zubehör" misc: "Sonstiges" -# books: "Books" + books: "Bücher" common: loading: "Lade..." @@ -130,8 +130,8 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: fork: "Fork" play: "Abspielen" # When used as an action verb, like "Play next level" retry: "Erneut versuchen" -# watch: "Watch" -# unwatch: "Unwatch" + watch: "Verfolgen" + unwatch: "Nicht verfolgen" submit_patch: "Patch einreichen" general: @@ -184,21 +184,21 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: play_level: done: "Fertig" home: "Startseite" # Not used any more, will be removed soon. -# level: "Level" # Like "Level: Dungeons of Kithgard" -# skip: "Skip" + level: "Level" # Like "Level: Dungeons of Kithgard" + skip: "Überspringen" game_menu: "Spielmenü" guide: "Hilfe" restart: "Neustart" goals: "Ziele" -# goal: "Goal" -# running: "Running..." + goal: "Ziel" + running: "Läuft..." success: "Erfolgreich!" incomplete: "Unvollständig" timed_out: "Zeit abgelaufen" -# failing: "Failing" + failing: "Fehlgeschlagen" action_timeline: "Aktionszeitstrahl" click_to_select: "Klicke auf eine Einheit, um sie auszuwählen." -# reload: "Reload" + reload: "Neu laden" reload_title: "Gesamten Code neu laden?" reload_really: "Bist Du sicher, dass Du das Level neu beginnen willst?" reload_confirm: "Alles neu laden" @@ -208,14 +208,14 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: victory_sign_up_poke: "Möchtest Du Neuigkeiten per Mail erhalten? Erstelle einen kostenlosen Account und wir halten Dich auf dem Laufenden." victory_rate_the_level: "Bewerte das Level: " # Only in old-style levels. victory_return_to_ladder: "Zurück zur Rangliste" -# victory_play_continue: "Continue" -# victory_play_skip: "Skip Ahead" + victory_play_continue: "Fortsetzen" + victory_play_skip: "Überspringen" victory_play_next_level: "Spiel das nächste Level" -# victory_play_more_practice: "More Practice" -# victory_play_too_easy: "Too Easy" -# victory_play_just_right: "Just Right" -# victory_play_too_hard: "Too Hard" -# victory_saving_progress: "Saving Progress" + victory_play_more_practice: "Mehr Training" + victory_play_too_easy: "Zu einfach" + victory_play_just_right: "Genau richtig" + victory_play_too_hard: "Zu schwer" + victory_saving_progress: "Fortschritt speichern" victory_go_home: "Geh auf die Startseite" # Only in old-style levels. victory_review: "Erzähl uns davon!" # Only in old-style levels. victory_hour_of_code_done: "Bist Du fertig?" @@ -224,24 +224,24 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: tome_minion_spells: "Die Zaubersprüche Deiner Knechte" # Only in old-style levels. tome_read_only_spells: "Nur-lesen Zauberspüche" # Only in old-style levels. tome_other_units: "Andere Einheiten" # Only in old-style levels. -# tome_cast_button_run: "Run" -# tome_cast_button_running: "Running" -# tome_cast_button_ran: "Ran" -# tome_submit_button: "Submit" -# tome_reload_method: "Reload original code for this method" # Title text for individual method reload button. -# tome_select_method: "Select a Method" -# tome_see_all_methods: "See all methods you can edit" # Title text for method list selector (shown when there are multiple programmable methdos). + tome_cast_button_run: "Run" + tome_cast_button_running: "Running" + tome_cast_button_ran: "Ran" + tome_submit_button: "Senden" + tome_reload_method: "Original Code für diese Methode neu laden" # Title text for individual method reload button. + tome_select_method: "Methode auswählen" + tome_see_all_methods: "Alle bearbeitbare Methoden anzeigen" # Title text for method list selector (shown when there are multiple programmable methdos). tome_select_a_thang: "Wähle jemanden aus, um " tome_available_spells: "Verfügbare Zauber" -# tome_your_skills: "Your Skills" -# tome_current_method: "Current Method" -# hud_continue_short: "Continue" -# code_saved: "Code Saved" + tome_your_skills: "Deine Fähigkeiten" + tome_current_method: "Aktuelle Methode" + hud_continue_short: "Fortsetzen" + code_saved: "Code gespeichert" skip_tutorial: "Überspringen (Esc)" keyboard_shortcuts: "Tastenkürzel" loading_ready: "Bereit!" -# loading_start: "Start Level" -# problem_alert_title: "Fix Your Code" + loading_start: "Starte Level" + problem_alert_title: "Repariere deinen Code" time_current: "Aktuell" time_total: "Total" time_goto: "Gehe zu" @@ -261,7 +261,7 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: tip_baby_coders: "In der Zukunft werden sogar Babies Erzmagier sein." tip_morale_improves: "Das Laden wird weiter gehen bis die Stimmung sich verbessert." tip_all_species: "Wir glauben an gleiche Chancen für alle Arten Programmieren zu lernen." -# tip_reticulating: "Reticulating spines." + tip_reticulating: "Spines neuberechnen." tip_harry: "Du bist ein Zauberer, " tip_great_responsibility: "Mit großen Programmierfähigkeiten kommt große Verantwortung." tip_munchkin: "Wenn du dein Gemüse nicht isst, besucht dich ein Zwerg während du schläfst." @@ -274,9 +274,9 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: tip_talk_is_cheap: "Reden ist billig. Zeig mir den Code. - Linus Torvalds" tip_first_language: "Das schwierigste, das du jemals lernen wirst, ist die erste Programmiersprache. - Alan Kay" tip_hardware_problem: "Q: Wie viele Programmierer braucht man um eine Glühbirne auszuwechseln? A: Keine, es ist ein Hardware-Problem." -# tip_hofstadters_law: "Hofstadter's Law: It always takes longer than you expect, even when you take into account Hofstadter's Law." -# tip_premature_optimization: "Premature optimization is the root of all evil. - Donald Knuth" -# tip_brute_force: "When in doubt, use brute force. - Ken Thompson" + tip_hofstadters_law: "Hofstadter's Gesetz: Es dauert immer länger als erwartet, auch wenn du Hofstadter's Gesetz anwendest." + tip_premature_optimization: "Vorzeitige Optimierung ist die Wurzel alles Übels (oder der mindestens Meister) bei der Programmierung - Donald Knuth" + tip_brute_force: "Verwende im Zweifelsfall rohe Gewalt. - Ken Thompson" customize_wizard: "Bearbeite den Zauberer" game_menu: @@ -285,23 +285,23 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: options_tab: "Einstellungen" guide_tab: "Guide" multiplayer_tab: "Mehrspieler" -# auth_tab: "Sign Up" + auth_tab: "Registrieren" inventory_caption: "Rüste deinen Helden aus" choose_hero_caption: "Wähle Helden, Sprache" save_load_caption: "... und schaue dir die Historie an" options_caption: "konfiguriere Einstellungen" guide_caption: "Doku und Tipps" multiplayer_caption: "Spiele mit Freunden!" -# auth_caption: "Save your progress." + auth_caption: "Fortschritt speichern." inventory: choose_inventory: "Gegenstände ausrüsten" -# equipped_item: "Equipped" -# available_item: "Available" -# should_equip: "(double-click to equip)" -# equipped: "(equipped)" -# locked: "(locked)" -# restricted: "(restricted in this level)" + equipped_item: "Hinzugefügt" + available_item: "Verfügbar" + should_equip: "(Doppelklick zum Hinzufügen)" + equipped: "(hinzugefügt)" + locked: "(gesperrt)" + restricted: "(benötigt für dieses Level)" choose_hero: choose_hero: "Wähle deinen Helden" @@ -315,16 +315,16 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: io_blurb: "Simpel aber obskur." status: "Status" weapons: "Waffen" -# weapons_warrior: "Swords - Short Range, No Magic" -# weapons_ranger: "Crossbows, Guns - Long Range, No Magic" -# weapons_wizard: "Wands, Staffs - Long Range, Magic" -# attack: "Damage" # Can also translate as "Attack" + weapons_warrior: "Schwert - Kurze Reichweite, Kein Zauber" + weapons_ranger: "Armbrust, Geschütz - Hohe Reichweite, Kein Zauber" + weapons_wizard: "Stäbe, Stäbe - Lange Reichweite, Zauber" + attack: "Schaden" # Can also translate as "Attack" health: "Gesundheit" speed: "Geschwindigkeit" -# regeneration: "Regeneration" -# range: "Range" # As in "attack or visual range" -# blocks: "Blocks" # As in "this shield blocks this much damage" -# skills: "Skills" + regeneration: "Regeneration" + range: "Reichweite" # As in "attack or visual range" + blocks: "Blockieren" # As in "this shield blocks this much damage" + skills: "Fähigkeiten" save_load: granularity_saved_games: "Gespeichert" @@ -565,10 +565,10 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: edit_article_title: "Artikel bearbeiten" contribute: -# page_title: "Contributing" + page_title: "Mitwirken" character_classes_title: "Charakter Klassen" introduction_desc_intro: "Wir haben hohe Erwartungen für CodeCombat." -# introduction_desc_pref: "We want to be where programmers of all stripes come to learn and play together, introduce others to the wonderful world of coding, and reflect the best parts of the community. We can't and don't want to do that alone; what makes projects like GitHub, Stack Overflow and Linux great are the people who use them and build on them. To that end, " +# introduction_desc_pref: "We want to be where programmers of all stripes come to learn and play together, introduce others to the wonderful world of coding, and reflect the best parts of the community. We can't and don't want to do that alone; what makes projects like GitHub, Stack Overflow and Linux great are the people who use them and build on them. To that end, " introduction_desc_github_url: "CodeCombat ist komplett OpenSource" # introduction_desc_suf: ", and we aim to provide as many ways as possible for you to take part and make this project as much yours as ours." introduction_desc_ending: "Wir hoffen du nimmst an unserer Party teil!" @@ -578,7 +578,7 @@ module.exports = nativeDescription: "Deutsch (Deutschland)", englishDescription: # archmage_summary: "Interested in working on game graphics, user interface design, database and server organization, multiplayer networking, physics, sound, or game engine performance? Want to help build a game to help other people learn what you are good at? We have a lot to do and if you are an experienced programmer and want to develop for CodeCombat, this class is for you. We would love your help building the best programming game ever." # archmage_introduction: "One of the best parts about building games is they synthesize so many different things. Graphics, sound, real-time networking, social networking, and of course many of the more common aspects of programming, from low-level database management, and server administration to user facing design and interface building. There's a lot to do, and if you're an experienced programmer with a hankering to really dive into the nitty-gritty of CodeCombat, this class might be for you. We would love to have your help building the best programming game ever." class_attributes: "Klassenattribute" -# archmage_attribute_1_pref: "Knowledge in " + archmage_attribute_1_pref: "Kentnisse in " # archmage_attribute_1_suf: ", or a desire to learn. Most of our code is in this language. If you're a fan of Ruby or Python, you'll feel right at home. It's JavaScript, but with a nicer syntax." # archmage_attribute_2: "Some experience in programming and personal initiative. We'll help you get oriented, but we can't spend much time training you." # how_to_join: "How To Join" From 9fabe74da668a4cd322eda95f1c3aaca062f72ed Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Mon, 17 Nov 2014 14:45:09 -0800 Subject: [PATCH 2/5] Changed the name of incorrectly preloaded old spell palette background texture. --- app/application.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/application.coffee b/app/application.coffee index 828a042f2..38eb102c1 100644 --- a/app/application.coffee +++ b/app/application.coffee @@ -29,7 +29,7 @@ elementAcceptsKeystrokes = (el) -> # not radio, checkbox, range, or color return (tag is 'textarea' or (tag is 'input' and type in textInputTypes) or el.contentEditable in ['', 'true']) and not (el.readOnly or el.disabled) -COMMON_FILES = ['/images/pages/base/modal_background.png', '/images/level/code_palette_background.png', '/images/level/popover_background.png', '/images/level/code_editor_background.png'] +COMMON_FILES = ['/images/pages/base/modal_background.png', '/images/level/code_palette_wood_background.png', '/images/level/popover_background.png', '/images/level/code_editor_background.png'] preload = (arrayOfImages) -> $(arrayOfImages).each -> $('')[0].src = @ From f56d01419f58bc98cceabb9404ebf95d68854520 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Mon, 17 Nov 2014 15:07:10 -0800 Subject: [PATCH 3/5] Update sync pvp teams and real-time playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Non-creator will switch teams upon joining a sync pvp game, if creator is on same team -Team swapping should only affect level session -Real-time multiplayer submit now reloads PlayLevelView to ease loading both player’s latest level sessions, and then automatically starts real-time playback. --- app/lib/Bus.coffee | 1 - app/lib/LevelLoader.coffee | 6 +- app/lib/surface/WaitingScreen.coffee | 29 +- app/models/SuperModel.coffee | 5 +- app/schemas/subscriptions/multiplayer.coffee | 13 +- app/templates/game-menu/multiplayer-view.jade | 6 +- app/views/game-menu/MultiplayerView.coffee | 54 ++-- app/views/play/level/LevelFlagsView.coffee | 5 +- app/views/play/level/PlayLevelView.coffee | 252 ++++++++++++++---- .../play/level/tome/CastButtonView.coffee | 14 +- app/views/play/level/tome/Spell.coffee | 15 +- app/views/play/level/tome/TomeView.coffee | 8 +- server/levels/level_handler.coffee | 4 - server/levels/sessions/LevelSession.coffee | 3 +- 14 files changed, 273 insertions(+), 142 deletions(-) diff --git a/app/lib/Bus.coffee b/app/lib/Bus.coffee index cd52e5f4d..f590c9f2e 100644 --- a/app/lib/Bus.coffee +++ b/app/lib/Bus.coffee @@ -35,7 +35,6 @@ module.exports = Bus = class Bus extends CocoClass Backbone.Mediator.publish 'bus:connected', {bus: @} disconnect: -> - Firebase.goOffline() @fireRef?.off() @fireRef = null @fireChatRef?.off() diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index cd07e3020..f12c213ef 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -56,7 +56,6 @@ module.exports = class LevelLoader extends CocoClass onLevelLoaded: -> @loadSession() @populateLevel() - Backbone.Mediator.publish 'level:loaded', level: @level, team: @team ? 'humans' # Session Loading @@ -72,7 +71,7 @@ module.exports = class LevelLoader extends CocoClass @session = @sessionResource.model if @opponentSessionID opponentSession = new LevelSession().setURL "/db/level.session/#{@opponentSessionID}" - @opponentSessionResource = @supermodel.loadModel(opponentSession, 'opponent_session') + @opponentSessionResource = @supermodel.loadModel(opponentSession, 'opponent_session', {cache: false}) @opponentSession = @opponentSessionResource.model if @session.loaded @@ -90,6 +89,9 @@ module.exports = class LevelLoader extends CocoClass loadDependenciesForSession: (session) -> if session is @session + # hero-ladder games require the correct session team in level:loaded + team = @team ? @session.get('team') + Backbone.Mediator.publish 'level:loaded', level: @level, team: team Backbone.Mediator.publish 'level:session-loaded', level: @level, session: @session @consolidateFlagHistory() if @opponentSession?.loaded else if session is @opponentSession diff --git a/app/lib/surface/WaitingScreen.coffee b/app/lib/surface/WaitingScreen.coffee index 25c2081f4..b8f4d5576 100644 --- a/app/lib/surface/WaitingScreen.coffee +++ b/app/lib/surface/WaitingScreen.coffee @@ -6,8 +6,7 @@ module.exports = class WaitingScreen extends CocoClass 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' - 'real-time-multiplayer:joined-game': 'onJoinedRealTimeMultiplayerGame' - 'real-time-multiplayer:left-game': 'onLeftRealTimeMultiplayerGame' + 'real-time-multiplayer:player-status': 'onRealTimeMultiplayerPlayerStatus' constructor: (options) -> super() @@ -49,7 +48,6 @@ module.exports = class WaitingScreen extends CocoClass @dimLayer.alpha = 0 createjs.Tween.removeTweens @dimLayer createjs.Tween.get(@dimLayer).to({alpha: 1}, 500) - @updateText() @layer.addChild @dimLayer hide: -> @@ -58,27 +56,10 @@ module.exports = class WaitingScreen extends CocoClass createjs.Tween.removeTweens @dimLayer createjs.Tween.get(@dimLayer).to({alpha: 0}, 500).call => @layer.removeChild @dimLayer unless @destroyed - updateText: -> - if @multiplayerSession - players = new RealTimeCollection('multiplayer_level_sessions/' + @multiplayerSession.id + '/players') - players.each (player) => - if player.id isnt me.id - name = player.get('name') - @text.text = "Waiting for #{name}..." + onRealTimeMultiplayerPlayerStatus: (e) -> @text.text = e.status - onRealTimePlaybackWaiting: (e) -> - @show() + onRealTimePlaybackWaiting: (e) -> @show() - onRealTimePlaybackStarted: (e) -> - @hide() + onRealTimePlaybackStarted: (e) -> @hide() - onRealTimePlaybackEnded: (e) -> - @hide() - - onJoinedRealTimeMultiplayerGame: (e) -> - @multiplayerSession = e.session - - onLeftRealTimeMultiplayerGame: (e) -> - if @multiplayerSession - @multiplayerSession.off() - @multiplayerSession = null + onRealTimePlaybackEnded: (e) -> @hide() diff --git a/app/models/SuperModel.coffee b/app/models/SuperModel.coffee index dbff4d2d3..158f864f9 100644 --- a/app/models/SuperModel.coffee +++ b/app/models/SuperModel.coffee @@ -28,7 +28,10 @@ module.exports = class SuperModel extends Backbone.Model unfinished loadModel: (model, name, fetchOptions, value=1) -> - cachedModel = @getModelByURL(model.getURL()) + # hero-ladder levels need remote opponent_session for latest session data (e.g. code) + # Can't apply to everything since other features rely on cached models being more recent (E.g. level_session) + # E.g.#2 heroConfig isn't necessarily saved to db in world map inventory modal, so we need to load the cached session on level start + cachedModel = @getModelByURL(model.getURL()) unless fetchOptions?.cache is false and name is 'opponent_session' if cachedModel if cachedModel.loaded res = @addModelResource(cachedModel, name, fetchOptions, 0) diff --git a/app/schemas/subscriptions/multiplayer.coffee b/app/schemas/subscriptions/multiplayer.coffee index ec522a5b8..fd88f449c 100644 --- a/app/schemas/subscriptions/multiplayer.coffee +++ b/app/schemas/subscriptions/multiplayer.coffee @@ -1,15 +1,14 @@ c = require 'schemas/schemas' module.exports = - 'real-time-multiplayer:created-game': c.object {title: 'Multiplayer created game', required: ['session']}, - session: {type: 'object'} + 'real-time-multiplayer:created-game': c.object {title: 'Multiplayer created game', required: ['realTimeSessionID']}, + realTimeSessionID: {type: 'string'} - 'real-time-multiplayer:joined-game': c.object {title: 'Multiplayer joined game', required: ['id', 'session']}, - id: {type: 'string'} - session: {type: 'object'} + 'real-time-multiplayer:joined-game': c.object {title: 'Multiplayer joined game', required: ['realTimeSessionID']}, + realTimeSessionID: {type: 'string'} - 'real-time-multiplayer:left-game': c.object {title: 'Multiplayer left game', required: ['id']}, - id: {type: 'string'} + 'real-time-multiplayer:left-game': c.object {title: 'Multiplayer left game'}, + userID: {type: 'string'} 'real-time-multiplayer:manual-cast': c.object {title: 'Multiplayer manual cast'} diff --git a/app/templates/game-menu/multiplayer-view.jade b/app/templates/game-menu/multiplayer-view.jade index d0bdc2e3c..c45a1259c 100644 --- a/app/templates/game-menu/multiplayer-view.jade +++ b/app/templates/game-menu/multiplayer-view.jade @@ -22,7 +22,7 @@ if !ladderGame if ladderGame if me.get('anonymous') p(data-i18n="multiplayer.multiplayer_sign_in_leaderboard") Sign in or create an account and get your solution on the leaderboard. - else if readyToRank + else if realTimeSessions && realTimeSessionsPlayers button#create-game-button Create Game hr @@ -37,7 +37,7 @@ if ladderGame span(style="margin:10px")= currentRealTimeSession.id button#leave-game-button(data-item=item) Leave Game div - - var players = realTimeSessionPlayers[currentRealTimeSession.id] + - var players = realTimeSessionsPlayers[currentRealTimeSession.id] if players span(style="margin:10px") Players: - for (var i=0; i < players.length; i++) { @@ -61,7 +61,7 @@ if ladderGame - continue if levelID === realTimeSessions.at(i).get('levelID') && realTimeSessions.at(i).get('state') === 'creating' - var id = realTimeSessions.at(i).get('id') - - var players = realTimeSessionPlayers[id] + - var players = realTimeSessionsPlayers[id] if players && players.length === 1 - noOpenGames = false - var creatorName = realTimeSessions.at(i).get('creatorName') diff --git a/app/views/game-menu/MultiplayerView.coffee b/app/views/game-menu/MultiplayerView.coffee index 5b7de2dfa..8808c799d 100644 --- a/app/views/game-menu/MultiplayerView.coffee +++ b/app/views/game-menu/MultiplayerView.coffee @@ -26,7 +26,7 @@ module.exports = class MultiplayerView extends CocoView @level = options.level @session = options.session @listenTo @session, 'change:multiplayer', @updateLinkSection - @watchRealTimeSessions() + @watchRealTimeSessions() if @level?.get('type') in ['hero-ladder'] destroy: -> @realTimeSessions?.off 'add', @onRealTimeSessionAdded @@ -46,15 +46,16 @@ module.exports = class MultiplayerView extends CocoView c.readyToRank = @session?.readyToRank() # Real-time multiplayer stuff - c.levelID = @session.get('levelID') - c.realTimeSessions = @realTimeSessions - c.currentRealTimeSession = @currentRealTimeSession if @currentRealTimeSession - c.realTimeSessionPlayers = @realTimeSessionsPlayers if @realTimeSessionsPlayers - # console.log 'MultiplayerView getRenderData', c.levelID - # console.log 'realTimeSessions', c.realTimeSessions - # console.log c.realTimeSessions.at(c.realTimeSessions.length - 1).get('state') if c.realTimeSessions.length > 0 - # console.log 'currentRealTimeSession', c.currentRealTimeSession - # console.log 'realTimeSessionPlayers', c.realTimeSessionPlayers + if @level?.get('type') in ['hero-ladder'] + c.levelID = @session.get('levelID') + c.realTimeSessions = @realTimeSessions + c.currentRealTimeSession = @currentRealTimeSession if @currentRealTimeSession + c.realTimeSessionsPlayers = @realTimeSessionsPlayers if @realTimeSessionsPlayers + # console.log 'MultiplayerView getRenderData', c.levelID + # console.log 'realTimeSessions', c.realTimeSessions + # console.log c.realTimeSessions.at(c.realTimeSessions.length - 1).get('state') if c.realTimeSessions.length > 0 + # console.log 'currentRealTimeSession', c.currentRealTimeSession + # console.log 'realTimeSessionPlayers', c.realTimeSessionsPlayers c @@ -134,7 +135,9 @@ module.exports = class MultiplayerView extends CocoView # console.log 'MultiplayerView found current real-time session', rts @currentRealTimeSession = new RealTimeModel('multiplayer_level_sessions/' + rts.id) @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', id: me.id, session: @currentRealTimeSession + + # TODO: Is this necessary? Shouldn't everyone already know we joined a game at this point? + Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: @currentRealTimeSession.id onRealTimeSessionAdded: (rts) => @watchRealTimeSession rts @@ -168,8 +171,13 @@ module.exports = class MultiplayerView extends CocoView @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged # TODO: s.id === @currentRealTimeSession.id ? players = new RealTimeCollection('multiplayer_level_sessions/' + @currentRealTimeSession.id + '/players') - players.create id: me.id, state: 'coding', name: @session.get('creatorName'), team: @session.get('team') - Backbone.Mediator.publish 'real-time-multiplayer:created-game', session: @currentRealTimeSession + players.create + id: me.id + state: 'coding' + name: @session.get('creatorName') + team: @session.get('team') + level_session: @session.id + Backbone.Mediator.publish 'real-time-multiplayer:created-game', realTimeSessionID: @currentRealTimeSession.id @render() onJoinRealTimeGame: (e) -> @@ -178,17 +186,31 @@ module.exports = class MultiplayerView extends CocoView @currentRealTimeSession = @realTimeSessions.get(item.id) @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged if @realTimeSessionsPlayers[item.id] - @realTimeSessionsPlayers[item.id].create id: me.id, state: 'coding', name: @session.get('creatorName'), team: @session.get('team') + + # TODO: SpellView updateTeam() should take care of this team swap update in the real-time multiplayer session + creatorID = @currentRealTimeSession.get('creator') + creator = @realTimeSessionsPlayers[item.id].get(creatorID) + creatorTeam = creator.get('team') + myTeam = @session.get('team') + if myTeam is creatorTeam + myTeam = if creatorTeam is 'humans' then 'ogres' else 'humans' + + @realTimeSessionsPlayers[item.id].create + id: me.id + state: 'coding' + name: me.get('name') + team: myTeam + level_session: @session.id else console.error 'MultiplayerView onJoinRealTimeGame did not have a players collection', @currentRealTimeSession - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', id: me.id, session: @currentRealTimeSession + Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: @currentRealTimeSession.id @render() onLeaveRealTimeGame: (e) -> if @currentRealTimeSession @currentRealTimeSession.off 'change', @onCurrentRealTimeSessionChanged @currentRealTimeSession = null - Backbone.Mediator.publish 'real-time-multiplayer:left-game', id: me.id + Backbone.Mediator.publish 'real-time-multiplayer:left-game', userID: me.id else console.error "Tried to leave a game with no currentMultiplayerSession" @render() diff --git a/app/views/play/level/LevelFlagsView.coffee b/app/views/play/level/LevelFlagsView.coffee index ae1bc0ddf..485caa3b0 100644 --- a/app/views/play/level/LevelFlagsView.coffee +++ b/app/views/play/level/LevelFlagsView.coffee @@ -84,13 +84,14 @@ module.exports = class LevelFlagsView extends CocoView @world = @options.world = event.world onJoinedMultiplayerGame: (e) -> - @realTimeFlags = new RealTimeCollection('multiplayer_level_sessions/' + e.session.id + '/flagHistory') + @realTimeFlags = new RealTimeCollection('multiplayer_level_sessions/' + e.realTimeSessionID + '/flagHistory') @realTimeFlags.on 'add', @onRealTimeMultiplayerFlagAdded @realTimeFlags.on 'remove', @onRealTimeMultiplayerFlagRemoved onLeftMultiplayerGame: (e) -> if @realTimeFlags - @realTimeFlags.off() + @realTimeFlags.off 'add', @onRealTimeMultiplayerFlagAdded + @realTimeFlags.off 'remove', @onRealTimeMultiplayerFlagRemoved @realTimeFlags = null onRealTimeMultiplayerFlagAdded: (e) => diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 34f0feca5..8450ce7da 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -97,6 +97,9 @@ module.exports = class PlayLevelView extends RootView @isEditorPreview = @getQueryVariable 'dev' @sessionID = @getQueryVariable 'session' + + @opponentSessionID = @getQueryVariable('opponent') + @opponentSessionID ?= @options.opponent $(window).on 'resize', @onWindowResize @saveScreenshot = _.throttle @saveScreenshot, 30000 @@ -131,7 +134,7 @@ module.exports = class PlayLevelView extends RootView load: -> @loadStartTime = new Date() @god = new God debugWorker: true - @levelLoader = new LevelLoader supermodel: @supermodel, levelID: @levelID, sessionID: @sessionID, opponentSessionID: @getQueryVariable('opponent'), team: @getQueryVariable('team') + @levelLoader = new LevelLoader supermodel: @supermodel, levelID: @levelID, sessionID: @sessionID, opponentSessionID: @opponentSessionID, team: @getQueryVariable('team') @listenToOnce @levelLoader, 'world-necessities-loaded', @onWorldNecessitiesLoaded trackLevelLoadEnd: -> @@ -170,7 +173,7 @@ module.exports = class PlayLevelView extends RootView onWorldNecessitiesLoaded: -> # Called when we have enough to build the world, but not everything is loaded @grabLevelLoaderData() - team = @getQueryVariable('team') ? @world.teamForPlayer(0) + team = @getQueryVariable('team') ? @session.get('team') ? @world.teamForPlayer(0) @loadOpponentTeam(team) @setupGod() @setTeam team @@ -190,6 +193,8 @@ module.exports = class PlayLevelView extends RootView @level = @levelLoader.level @$el.addClass 'hero' if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] @$el.addClass 'flags' if _.any(@world.thangs, (t) -> (t.programmableProperties and 'findFlags' in t.programmableProperties) or t.inventory?.flag) or @level.get('slug') is 'sky-span' + # TODO: Update terminology to always be opponentSession or otherSession + # TODO: E.g. if it's always opponent right now, then variable names should be opponentSession until we have coop play @otherSession = @levelLoader.opponentSession @worldLoadFakeResources = [] # first element (0) is 1%, last (100) is 100% for percent in [1 .. 100] @@ -211,7 +216,8 @@ module.exports = class PlayLevelView extends RootView opponentSpells = opponentSpells.concat spells if (not @session.get('teamSpells')) and @otherSession?.get('teamSpells') @session.set('teamSpells', @otherSession.get('teamSpells')) - opponentCode = @otherSession?.get('transpiledCode') or {} + # hero-ladder levels use code instead of transpiledCode + opponentCode = @otherSession?.get('transpiledCode') or @otherSession?.get('code') or {} myCode = @session.get('code') or {} for spell in opponentSpells [thang, spell] = spell.split '/' @@ -232,6 +238,7 @@ module.exports = class PlayLevelView extends RootView team = team?.team unless _.isString team team ?= 'humans' me.team = team + @session.set 'team', team Backbone.Mediator.publish 'level:team-set', team: team # Needed for scripts @team = team @@ -248,7 +255,7 @@ module.exports = class PlayLevelView extends RootView @insertSubView new HUDView {level: @level} @insertSubView new LevelDialogueView {level: @level} @insertSubView new ChatView levelID: @levelID, sessionID: @session.id, session: @session - if @level.get('type') in ['ladder', 'hero-ladder'] + if @level.get('type') in ['hero-ladder'] @insertSubView new MultiplayerStatusView levelID: @levelID, session: @session, level: @level @insertSubView new ProblemAlertView {} worldName = utils.i18n @level.attributes, 'name' @@ -283,7 +290,7 @@ module.exports = class PlayLevelView extends RootView @setupManager = new LevelSetupManager({supermodel: @supermodel, levelID: @levelID, parent: @, session: @session}) @setupManager.open() - @onRealTimeMultiplayerLevelLoaded e.session if e.level.get('type') in ['ladder', 'hero-ladder'] + @onRealTimeMultiplayerLevelLoaded() if e.level.get('type') in ['hero-ladder'] onLoaded: -> _.defer => @onLevelLoaderLoaded() @@ -342,6 +349,9 @@ module.exports = class PlayLevelView extends RootView @removeSubView @loadingView @loadingView = null @playAmbientSound() + if @options.realTimeMultiplayerSessionID? + Backbone.Mediator.publish 'playback:real-time-playback-waiting', {} + @realTimeMultiplayerContinueGame @options.realTimeMultiplayerSessionID application.tracker?.trackEvent 'Play Level', Action: 'Start Level', levelID: @levelID playAmbientSound: -> @@ -540,7 +550,9 @@ module.exports = class PlayLevelView extends RootView onSubmissionComplete: => return if @destroyed - Backbone.Mediator.publish 'level:show-victory', showModal: true if @goalManager.checkOverallStatus() is 'success' + # TODO: Show a victory dialog specific to hero-ladder level + if @goalManager.checkOverallStatus() is 'success' and not @options.realTimeMultiplayerSessionID? + Backbone.Mediator.publish 'level:show-victory', showModal: true destroy: -> @levelLoader?.destroy() @@ -572,6 +584,8 @@ module.exports = class PlayLevelView extends RootView # Updates real-time multiplayer player state # Cleans up old sessions (sets state to 'finished') # Real-time multiplayer cast handshake + # Swap teams on game joined, if necessary + # Reload PlayLevelView on real-time submit, automatically continue game and real-time playback # # It monitors these: # Real-time multiplayer sessions @@ -585,19 +599,23 @@ module.exports = class PlayLevelView extends RootView # @realTimeOpponent - Current real-time multiplayer opponent # @realTimePlayers - Real-time players for current real-time multiplayer game session # @realTimeSessionCollection - Collection of all real-time multiplayer sessions + # @options.realTimeMultiplayerSessionID - Need to continue an existing real-time multiplayer session # # TODO: Move this code to it's own file, or possibly the LevelBus - # TODO: save settings somewhere reasonable + # TODO: Save settings somewhere reasonable + # TODO: Ditch backfire and just use Firebase directly. Easier to debug, richer APIs (E.g. presence stuff). - onRealTimeMultiplayerLevelLoaded: (session) -> + onRealTimeMultiplayerLevelLoaded: -> + return if @realTimePlayerStatus? return if me.get('anonymous') - players = new RealTimeCollection('multiplayer_players/' + @levelID) - players.create - id: me.id - name: me.get('name') - state: 'playing' - created: new Date().toISOString() - heartbeat: new Date().toISOString() + unless @options.realTimeMultiplayerSessionID? + players = new RealTimeCollection('multiplayer_players/' + @levelID) + players.create + id: me.id + name: me.get('name') + state: 'playing' + created: new Date().toISOString() + heartbeat: new Date().toISOString() @realTimePlayerStatus = new RealTimeModel('multiplayer_players/' + @levelID + '/' + me.id) @timerMultiplayerHeartbeatID = setInterval @onRealTimeMultiplayerHeartbeat, 60 * 1000 @cleanupRealTimeSessions() @@ -608,6 +626,7 @@ module.exports = class PlayLevelView extends RootView @realTimeSessionCollection.each @cleanupRealTimeSession cleanupRealTimeSession: (session) => + return if @options.realTimeMultiplayerSessionID? and @options.realTimeMultiplayerSessionID is session.id if session.get('state') isnt 'finished' players = new RealTimeCollection 'multiplayer_level_sessions/' + session.id + '/players' players.each (player) => @@ -618,21 +637,34 @@ module.exports = class PlayLevelView extends RootView session.set 'state', 'finished' onRealTimeMultiplayerLevelUnloaded: -> - clearInterval @timerMultiplayerHeartbeatID if @timerMultiplayerHeartbeatID? + # console.log 'PlayLevelView onRealTimeMultiplayerLevelUnloaded' + if @timerMultiplayerHeartbeatID? + clearInterval @timerMultiplayerHeartbeatID + @timerMultiplayerHeartbeatID = null if @realTimeSessionCollection? @realTimeSessionCollection.off 'add', @cleanupRealTimeSession @realTimeSessionCollection = null + # TODO: similar to game ending cleanup + if @realTimeOpponent? + @realTimeOpponent.off 'change', @onRealTimeOpponentChanged + @realTimeOpponent = null + if @realTimePlayers? + @realTimePlayers.off 'add', @onRealTimePlayerAdded + @realTimePlayers = null + if @realTimeSession? + @realTimeSession.off 'change', @onRealTimeSessionChanged + @realTimeSession = null + if @realTimePlayerGameStatus? + @realTimePlayerGameStatus = null + if @realTimePlayerStatus? + @realTimePlayerStatus = null + onRealTimeMultiplayerHeartbeat: => @realTimePlayerStatus.set 'heartbeat', new Date().toISOString() if @realTimePlayerStatus onRealTimeMultiplayerCreatedGame: (e) -> - # Watch external multiplayer session - @realTimeSession = new RealTimeModel 'multiplayer_level_sessions/' + e.session.id - @realTimeSession.on 'change', @onRealTimeSessionChanged - @realTimePlayers = new RealTimeCollection 'multiplayer_level_sessions/' + e.session.id + '/players' - @realTimePlayers.on 'add', @onRealTimePlayerAdded - @realTimePlayerGameStatus = new RealTimeModel 'multiplayer_level_sessions/' + e.session.id + '/players/' + me.id + @joinRealTimeMultiplayerGame e @realTimePlayerGameStatus.set 'state', 'coding' @realTimePlayerStatus.set 'state', 'available' Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Waiting for opponent..' @@ -662,33 +694,72 @@ module.exports = class PlayLevelView extends RootView console.info 'Real-time multiplayer opponent left the game' opponentID = @realTimeOpponent.id @realTimeGameEnded() - Backbone.Mediator.publish 'real-time-multiplayer:left-game', id: opponentID + Backbone.Mediator.publish 'real-time-multiplayer:left-game', userID: opponentID when 'submitted' # TODO: What should this message say? - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: @realTimeOpponent.get('name') + ' waiting for your code..' + Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: @realTimeOpponent.get('name') + ' waiting for your code' + + joinRealTimeMultiplayerGame: (e) -> + unless @realTimeSession? + # TODO: Necessary for real-time multiplayer sessions? + @session.set('submittedCodeLanguage', @session.get('codeLanguage')) + @session.save() + + @realTimeSession = new RealTimeModel 'multiplayer_level_sessions/' + e.realTimeSessionID + @realTimeSession.on 'change', @onRealTimeSessionChanged + @realTimePlayers = new RealTimeCollection 'multiplayer_level_sessions/' + e.realTimeSessionID + '/players' + @realTimePlayers.on 'add', @onRealTimePlayerAdded + @realTimePlayerGameStatus = new RealTimeModel 'multiplayer_level_sessions/' + e.realTimeSessionID + '/players/' + me.id + # TODO: Follow up in MultiplayerView to see if double joins can be avoided + # else + # console.error 'Joining real-time multiplayer game with an existing @realTimeSession.' onRealTimeMultiplayerJoinedGame: (e) -> # console.log 'PlayLevelView onRealTimeMultiplayerJoinedGame', e - if e.id is me.id - @realTimeSession = new RealTimeModel 'multiplayer_level_sessions/' + e.session.id - @realTimeSession.set 'state', 'coding' - @realTimeSession.on 'change', @onRealTimeSessionChanged - @realTimePlayers = new RealTimeCollection 'multiplayer_level_sessions/' + e.session.id + '/players' - @realTimePlayers.on 'add', @onRealTimePlayerAdded - @realTimePlayerGameStatus = new RealTimeModel 'multiplayer_level_sessions/' + e.session.id + '/players/' + me.id - @realTimePlayerGameStatus.set 'state', 'coding' - @realTimePlayerStatus.set 'state', 'unavailable' - for id, player of e.session.get('players') + @joinRealTimeMultiplayerGame e + @realTimePlayerGameStatus.set 'state', 'coding' + @realTimePlayerStatus.set 'state', 'unavailable' + unless @realTimeOpponent? + for id, player of @realTimeSession.get('players') if id isnt me.id - @realTimeOpponent = new RealTimeModel 'multiplayer_level_sessions/' + e.session.id + '/players/' + id + @realTimeOpponent = new RealTimeModel 'multiplayer_level_sessions/' + e.realTimeSessionID + '/players/' + id + @realTimeOpponent.on 'change', @onRealTimeOpponentChanged Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Playing against ' + player.name + unless @realTimeOpponent? + console.error 'Did not find an oppoonent in onRealTimeMultiplayerJoinedGame.' + @updateTeam() onRealTimeMultiplayerLeftGame: (e) -> # console.log 'PlayLevelView onRealTimeMultiplayerLeftGame', e - if e.id? and e.id is me.id + if e.userID? and e.userID is me.id @realTimePlayerGameStatus.set 'state', 'left' @realTimeGameEnded() + realTimeMultiplayerContinueGame: (realTimeSessionID) -> + # console.log 'PlayLevelView realTimeMultiplayerContinueGame', realTimeSessionID, me.id + Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: realTimeSessionID + + console.info 'Setting my game status to ready' + @realTimePlayerGameStatus.set 'state', 'ready' + + if @realTimeOpponent.get('state') is 'ready' + @realTimeOpponentIsReady() + else + console.info 'Waiting for opponent to be ready' + @realTimeOpponent.on 'change', @realTimeOpponentMaybeReady + + realTimeOpponentMaybeReady: => + # console.log 'PlayLevelView realTimeOpponentMaybeReady' + if @realTimeOpponent.get('state') is 'ready' + @realTimeOpponent.off 'change', @realTimeOpponentMaybeReady + @realTimeOpponentIsReady() + + realTimeOpponentIsReady: => + console.info 'All real-time multiplayer players are ready!' + @realTimeSession.set 'state', 'running' + Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Battling ' + @realTimeOpponent.get('name') + Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} + realTimeGameEnded: -> if @realTimeOpponent? @realTimeOpponent.off 'change', @onRealTimeOpponentChanged @@ -707,9 +778,21 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: '' onRealTimeMultiplayerCast: (e) -> + # console.log 'PlayLevelView onRealTimeMultiplayerCast', e unless @realTimeSession console.error 'onRealTimeMultiplayerCast without a multiplayerSession' return + + # Set submissionCount for created real-time multiplayer session + if me.id is @realTimeSession.get('creator') + sessionState = @session.get('state') + if sessionState? + submissionCount = sessionState.submissionCount + console.info 'Setting multiplayer submissionCount to', submissionCount + @realTimeSession.set 'submissionCount', submissionCount + else + console.error 'Failed to read sessionState in onRealTimeMultiplayerCast' + players = new RealTimeCollection('multiplayer_level_sessions/' + @realTimeSession.id + '/players') myPlayer = opponentPlayer = null players.each (player) -> @@ -719,10 +802,8 @@ module.exports = class PlayLevelView extends RootView opponentPlayer = player if myPlayer console.info 'Submitting my code' - myPlayer.set 'code', @session.get('code') - myPlayer.set 'codeLanguage', @session.get('codeLanguage') + @session.patch() myPlayer.set 'state', 'submitted' - myPlayer.set 'team', me.team else console.error 'Did not find my player in onRealTimeMultiplayerCast' if opponentPlayer @@ -738,6 +819,9 @@ module.exports = class PlayLevelView extends RootView state = opponentPlayer.get('state') if state in ['submitted', 'ready'] @realTimeOpponentSubmittedCode opponentPlayer, myPlayer + opponentPlayer.off 'change' + else + console.error 'Did not find opponent player in onRealTimeMultiplayerCast' onRealTimeMultiplayerPlaybackEnded: -> if @realTimeSession? @@ -747,19 +831,77 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Playing against ' + @realTimeOpponent.get('name') realTimeOpponentSubmittedCode: (opponentPlayer, myPlayer) => - # Save opponent's code - Backbone.Mediator.publish 'real-time-multiplayer:new-opponent-code', {codeLanguage: opponentPlayer.get('codeLanguage'), code: opponentPlayer.get('code'), team: opponentPlayer.get('team')} - # I'm ready to rumble - myPlayer.set 'state', 'ready' - if opponentPlayer.get('state') is 'ready' - console.info 'All real-time multiplayer players are ready!' - @realTimeSession.set 'state', 'running' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Battling ' + @realTimeOpponent.get('name') - else - # Wait for opponent to be ready - opponentPlayer.on 'change', (e) => - if opponentPlayer.get('state') is 'ready' - opponentPlayer.off 'change' - console.info 'All real-time multiplayer players are ready!' - @realTimeSession.set 'state', 'running' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Battling ' + @realTimeOpponent.get('name') + # console.log 'PlayLevelView realTimeOpponentSubmittedCode', @realTimeSession.id, opponentPlayer.get('level_session') + # Read submissionCount for joined real-time multiplayer session + if me.id isnt @realTimeSession.get('creator') + sessionState = @session.get('state') ? {} + newSubmissionCount = @realTimeSession.get 'submissionCount' + if newSubmissionCount? + # TODO: This isn't always getting updated where the random seed generation uses it. + sessionState.submissionCount = parseInt newSubmissionCount + console.info 'Got multiplayer submissionCount', sessionState.submissionCount + @session.set 'state', sessionState + @session.patch() + + # Reload this level so the opponent session can easily be wired up + Backbone.Mediator.publish 'router:navigate', + route: "/play/level/#{@levelID}" + viewClass: PlayLevelView + viewArgs: [{supermodel: @supermodel, autoUnveil: true, realTimeMultiplayerSessionID: @realTimeSession.id, opponent: opponentPlayer.get('level_session'), team: @team}, @levelID] + + updateTeam: -> + # If not creator, and same team as creator, then switch teams + # TODO: Assumes there are only 'humans' and 'ogres' + + unless @realTimeOpponent? + console.error 'Tried to switch teams without a real-time opponent.' + return + unless @realTimeSession? + console.error 'Tried to switch teams without a real-time session.' + return + return if me.id is @realTimeSession.get('creator') + + oldTeam = @realTimeOpponent.get('team') + return unless oldTeam is @session.get('team') + + # Need to switch to other team + newTeam = if oldTeam is 'humans' then 'ogres' else 'humans' + console.info "Switching from team #{oldTeam} to #{newTeam}" + + # Move code from old team to new team + # Assumes teamSpells has matching spells for each team + # TODO: Similar to code in loadOpponentTeam, consolidate? + code = @session.get 'code' + teamSpells = @session.get 'teamSpells' + for oldSpellKey in teamSpells[oldTeam] + [oldThang, oldSpell] = oldSpellKey.split '/' + oldCode = code[oldThang]?[oldSpell] + continue unless oldCode? + # Move oldCode to new team under same spell + for newSpellKey in teamSpells[newTeam] + [newThang, newSpell] = newSpellKey.split '/' + if newSpell is oldSpell + # Found spell location under new team + console.log "Swapping spell=#{oldSpell} from #{oldThang} to #{newThang}" + if code[newThang]?[oldSpell]? + # Option 1: have a new spell to swap + code[oldThang][oldSpell] = code[newThang][oldSpell] + else + # Option 2: no new spell to swap + delete code[oldThang][oldSpell] + code[newThang] = {} unless code[newThang]? + code[newThang][oldSpell] = oldCode + break + + @setTeam newTeam # Sets @session 'team' + sessionState = @session.get('state') + if sessionState? + # TODO: Don't hard code thangID + sessionState.selected = if newTeam is 'humans' then 'Hero Placeholder' else 'Hero Placeholder 1' + @session.set 'state', sessionState + @session.set 'code', code + @session.patch() + + if sessionState? + # TODO: Don't hardcode spellName + Backbone.Mediator.publish 'level:select-sprite', thangID: sessionState.selected, spellName: 'plan' diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee index 48f339201..d425ad6db 100644 --- a/app/views/play/level/tome/CastButtonView.coffee +++ b/app/views/play/level/tome/CastButtonView.coffee @@ -63,14 +63,8 @@ module.exports = class CastButtonView extends CocoView Backbone.Mediator.publish 'tome:manual-cast', {} onCastRealTimeButtonClick: (e) -> - if @multiplayerSession + if @inRealTimeMultiplayerSession Backbone.Mediator.publish 'real-time-multiplayer:manual-cast', {} - # Wait for multiplayer session to be up and running - @multiplayerSession.on 'change', (e) => - if @multiplayerSession.get('state') is 'running' - # Real-time multiplayer session is ready to go, so resume normal cast - @multiplayerSession.off 'change' - Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} else Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} @@ -149,12 +143,10 @@ module.exports = class CastButtonView extends CocoView $(@).toggleClass('selected', parseInt($(@).attr('data-delay')) is delay) onJoinedRealTimeMultiplayerGame: (e) -> - @multiplayerSession = e.session + @inRealTimeMultiplayerSession = true onLeftRealTimeMultiplayerGame: (e) -> - if @multiplayerSession - @multiplayerSession.off 'change' - @multiplayerSession = null + @inRealTimeMultiplayerSession = false initButtonTextABTest: -> return if me.isAdmin() diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee index dd494484a..1d7acdcd9 100644 --- a/app/views/play/level/tome/Spell.coffee +++ b/app/views/play/level/tome/Spell.coffee @@ -46,20 +46,18 @@ module.exports = class Spell @source = @originalSource = p.aiSource @thangs = {} if @canRead() # We can avoid creating these views if we'll never use them. - @view = new SpellView {spell: @, level: options.level, session: @session, worker: @worker} + @view = new SpellView {spell: @, level: options.level, session: @session, otherSession: @otherSession, worker: @worker} @view.render() # Get it ready and code loaded in advance @tabView = new SpellListTabEntryView spell: @, supermodel: @supermodel, codeLanguage: @language, level: options.level @tabView.render() @team = @permissions.readwrite[0] ? 'common' Backbone.Mediator.publish 'tome:spell-created', spell: @ - Backbone.Mediator.subscribe 'real-time-multiplayer:new-opponent-code', @onNewOpponentCode, @ destroy: -> @view?.destroy() @tabView?.destroy() @thangs = null @worker = null - Backbone.Mediator.unsubscribe 'real-time-multiplayer:new-opponent-code', @onNewOpponentCode, @ setLanguage: (@language) -> #console.log 'setting language to', @language, 'so using original source', @languages[language] ? @languages.javascript @@ -187,22 +185,13 @@ module.exports = class Spell shouldUseTranspiledCode: -> # Determine whether this code has already been transpiled, or whether it's raw source needing transpilation. + return false if @levelType is 'hero-ladder' 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 true if @session.get('creator') isnt me.id and not (me.isAdmin() or 'employer' in me.get('permissions', true)) false - onNewOpponentCode: (e) -> - return unless @spellKey and @canWrite e.team - if e.codeLanguage and e.code - [thangSlug, methodSlug] = @spellKey.split '/' - if opponentCode = e.code[thangSlug]?[methodSlug] - @source = opponentCode - @updateLanguageAether e.codeLanguage - else - console.error 'Spell onNewOpponentCode did not receive code', e - createProblemContext: (thang) -> # Create problemContext Aether can use to craft better error messages # stringReferences: values that should be referred to as a string instead of a variable (e.g. "Brak", not Brak) diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee index 6845e18b9..1e7e00789 100644 --- a/app/views/play/level/tome/TomeView.coffee +++ b/app/views/play/level/tome/TomeView.coffee @@ -237,8 +237,12 @@ module.exports = class TomeView extends CocoView @cast() onSelectPrimarySprite: (e) -> - # TODO: this may not be correct - Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder' + # This is only fired by PlayLevelView for hero levels currently + # TODO: Don't hard code these hero names + if @options.session.get('team') is 'ogres' + Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder 1' + else + Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder' destroy: -> spell.destroy() for spellKey, spell of @spells diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index d18d9f421..b7c9ccaaa 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -68,10 +68,6 @@ LevelHandler = class LevelHandler extends Handler if req.query.team? sessionQuery.team = req.query.team - # TODO: generalize this for levels based on their teams - else if level.get('type') in ['ladder', 'hero-ladder'] - sessionQuery.team = 'humans' - Session.findOne(sessionQuery).exec (err, doc) => return @sendDatabaseError(res, err) if err return @sendSuccess(res, doc) if doc? diff --git a/server/levels/sessions/LevelSession.coffee b/server/levels/sessions/LevelSession.coffee index b9ae9cb5d..80f511a9c 100644 --- a/server/levels/sessions/LevelSession.coffee +++ b/server/levels/sessions/LevelSession.coffee @@ -38,7 +38,8 @@ LevelSessionSchema.pre 'save', (next) -> LevelSessionSchema.statics.privateProperties = ['code', 'submittedCode', 'unsubscribed'] LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code', 'codeLanguage', 'completed', 'state', 'levelName', 'creatorName', 'levelID', 'screenshot', - 'chat', 'teamSpells', 'submitted', 'submittedCodeLanguage', 'unsubscribed', 'playtime', 'heroConfig'] + 'chat', 'teamSpells', 'submitted', 'submittedCodeLanguage', + 'unsubscribed', 'playtime', 'heroConfig', 'team'] LevelSessionSchema.statics.jsonSchema = jsonschema LevelSessionSchema.index {user: 1, changed: -1}, {sparse: true, name: 'last played index'} From 95dca575d1f30b7a96c98a3faa8b9a7555a4315e Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Mon, 17 Nov 2014 15:15:02 -0800 Subject: [PATCH 4/5] Set up stripe on the server and site to allow purchases through the website. --- app/lib/services/stripe.coffee | 10 ++ app/locale/en.coffee | 3 + app/schemas/models/payment.schema.coffee | 3 +- app/schemas/models/user.coffee | 1 + app/schemas/subscriptions/misc.coffee | 7 +- app/styles/play/modal/buy-gems-modal.sass | 9 ++ app/templates/play/modal/buy-gems-modal.jade | 35 +++- app/templates/play/world-map-view.jade | 4 +- app/views/play/modal/BuyGemsModal.coffee | 50 +++++- package.json | 3 +- server/payments/payment_handler.coffee | 148 +++++++++++++++-- server/users/User.coffee | 2 +- server_config.coffee | 6 +- test/server/functional/payment.spec.coffee | 162 ++++++++++++++++++- 14 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 app/lib/services/stripe.coffee diff --git a/app/lib/services/stripe.coffee b/app/lib/services/stripe.coffee new file mode 100644 index 000000000..0a457b362 --- /dev/null +++ b/app/lib/services/stripe.coffee @@ -0,0 +1,10 @@ +publishableKey = if application.isProduction() then 'pk_live_27jQZozjDGN1HSUTnSuM578g' else 'pk_test_zG5UwVu6Ww8YhtE9ZYh0JO6a' + +module.exports = handler = StripeCheckout.configure({ + key: publishableKey + name: 'CodeCombat' + email: me.get('email') + image: '/images/pages/base/logo_square_250.png' + token: (token) -> + Backbone.Mediator.publish 'stripe:received-token', { token: token } +}) \ No newline at end of file diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 564ed81a2..48de20a20 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -319,6 +319,9 @@ few_gems: 'A few gems' pile_gems: 'Pile of gems' chest_gems: 'Chest of gems' + purchasing: 'Purchasing...' + declined: 'Your card was declined' + retrying: 'Server error, retrying.' choose_hero: choose_hero: "Choose Your Hero" diff --git a/app/schemas/models/payment.schema.coffee b/app/schemas/models/payment.schema.coffee index 74b6fd80c..4d7ac2f63 100644 --- a/app/schemas/models/payment.schema.coffee +++ b/app/schemas/models/payment.schema.coffee @@ -8,6 +8,7 @@ PaymentSchema = c.object({title: 'Payment', required: []}, { amount: { type: 'integer', description: 'Payment in cents.' } created: c.date({title: 'Created', readOnly: true}) gems: { type: 'integer', description: 'The number of gems acquired.' } + productID: { enum: ['gems_5', 'gems_10', 'gems_20']} ios: c.object({title: 'iOS IAP Data'}, { transactionID: { type: 'string' } @@ -17,7 +18,7 @@ PaymentSchema = c.object({title: 'Payment', required: []}, { stripe: c.object({title: 'Stripe Data'}, { timestamp: { type: 'integer', description: 'Unique identifier provided by the client, to guard against duplicate payments.' } - transactionID: { type: 'string' } + chargeID: { type: 'string' } customerID: { type: 'string' } }) }) diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index aff178469..ee41c436a 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -270,6 +270,7 @@ _.extend UserSchema.properties, earned: c.RewardSchema 'earned by achievements' purchased: c.RewardSchema 'purchased with gems or money' spent: {type: 'number'} + stripeCustomerID: { type: 'string' } c.extendBasicProperties UserSchema, 'user' diff --git a/app/schemas/subscriptions/misc.coffee b/app/schemas/subscriptions/misc.coffee index ef4cebc3c..5773dbbff 100644 --- a/app/schemas/subscriptions/misc.coffee +++ b/app/schemas/subscriptions/misc.coffee @@ -51,4 +51,9 @@ module.exports = 'buy-gems-modal:update-products': { } 'buy-gems-modal:purchase-initiated': c.object {required: ['productID']}, - productID: { type: 'string' } \ No newline at end of file + productID: { type: 'string' } + + 'stripe:received-token': c.object { required: ['token'] }, + token: { type: 'object', properties: { + id: {type: 'string'} + }} \ No newline at end of file diff --git a/app/styles/play/modal/buy-gems-modal.sass b/app/styles/play/modal/buy-gems-modal.sass index 52fd3bc02..e147d6041 100644 --- a/app/styles/play/modal/buy-gems-modal.sass +++ b/app/styles/play/modal/buy-gems-modal.sass @@ -42,3 +42,12 @@ button width: 80% + + + //- Errors + .alert + position: absolute + left: 10% + width: 80% + top: 20px + border: 5px solid gray diff --git a/app/templates/play/modal/buy-gems-modal.jade b/app/templates/play/modal/buy-gems-modal.jade index f06470d88..6884890eb 100644 --- a/app/templates/play/modal/buy-gems-modal.jade +++ b/app/templates/play/modal/buy-gems-modal.jade @@ -1,11 +1,30 @@ .modal-dialog .modal-content - img(src="/images/pages/play/modal/buy-gems-background.png")#buy-gems-background + if state === 'purchasing' + .alert.alert-info(data-i18n="buy_gems.purchasing") - #products - for product in products - .product - h4 x#{product.gems} - h3(data-i18n=product.i18n) - button.btn.btn-illustrated.btn-lg(value=product.id) - span= product.price \ No newline at end of file + else if state === 'retrying' + #retrying-alert.alert.alert-danger(data-i18n="buy_gems.retrying") + + else + img(src="/images/pages/play/modal/buy-gems-background.png")#buy-gems-background + + #products + for product in products + .product + h4 x#{product.gems} + h3(data-i18n=product.i18n) + button.btn.btn-illustrated.btn-lg(value=product.id) + span= product.price + + if state === 'declined' + #declined-alert.alert.alert-danger.alert-dismissible + span(data-i18n="buy_gems.declined") + button.close(type="button" data-dismiss="alert") + span(aria-hidden="true") × + + if state === 'unknown_error' + #error-alert.alert.alert-danger.alert-dismissible + span(data-i18n="loading_error.unknown") + button.close(type="button" data-dismiss="alert") + span(aria-hidden="true") × \ No newline at end of file diff --git a/app/templates/play/world-map-view.jade b/app/templates/play/world-map-view.jade index 7f3d729bc..b56457b49 100644 --- a/app/templates/play/world-map-view.jade +++ b/app/templates/play/world-map-view.jade @@ -43,13 +43,13 @@ .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") - if me.isAdmin() || isIPadApp + if me.get('anonymous') === false button.btn.gems(data-toggle='coco-modal', data-target='play/modal/BuyGemsModal', data-i18n="[title]play.buy_gems") if me.isAdmin() button.btn.achievements(data-toggle='coco-modal', data-target='play/modal/PlayAchievementsModal', data-i18n="[title]play.achievements") button.btn.account(data-toggle='coco-modal', data-target='play/modal/PlayAccountModal', data-i18n="[title]play.account") button.btn.settings(data-toggle='coco-modal', data-target='play/modal/PlaySettingsModal', data-i18n="[title]play.settings") - else if me.get('anonymous') + else if me.get('anonymous', true) button.btn.settings(data-toggle='coco-modal', data-target='modal/AuthModal', data-i18n="[title]play.settings") // Don't show these things, they are bad and take us out of the game. Just wait until the new ones work. //else diff --git a/app/views/play/modal/BuyGemsModal.coffee b/app/views/play/modal/BuyGemsModal.coffee index 41d2767ce..af24c3507 100644 --- a/app/views/play/modal/BuyGemsModal.coffee +++ b/app/views/play/modal/BuyGemsModal.coffee @@ -1,5 +1,7 @@ ModalView = require 'views/kinds/ModalView' template = require 'templates/play/modal/buy-gems-modal' +stripeHandler = require 'lib/services/stripe' +utils = require 'lib/utils' module.exports = class BuyGemsModal extends ModalView id: 'buy-gems-modal' @@ -7,20 +9,22 @@ module.exports = class BuyGemsModal extends ModalView plain: true originalProducts: [ - { price: '$4.99', gems: 5000, id: 'gems_5', i18n: 'buy_gems.few_gems' } - { price: '$9.99', gems: 11000, id: 'gems_10', i18n: 'buy_gems.pile_gems' } - { price: '$19.99', gems: 25000, id: 'gems_20', i18n: 'buy_gems.chest_gems' } + { price: '$4.99', gems: 5000, amount: 499, id: 'gems_5', i18n: 'buy_gems.few_gems' } + { price: '$9.99', gems: 11000, amount: 999, id: 'gems_10', i18n: 'buy_gems.pile_gems' } + { price: '$19.99', gems: 25000, amount: 1999, id: 'gems_20', i18n: 'buy_gems.chest_gems' } ] subscriptions: 'ipad:products': 'onIPadProducts' 'ipad:iap-complete': 'onIAPComplete' + 'stripe:received-token': 'onStripeReceivedToken' events: 'click .product button': 'onClickProductButton' constructor: (options) -> super(options) + @state = 'standby' if application.isIPadApp @products = [] Backbone.Mediator.publish 'buy-gems-modal:update-products' @@ -30,6 +34,7 @@ module.exports = class BuyGemsModal extends ModalView getRenderData: -> c = super() c.products = @products + c.state = @state return c onIPadProducts: (e) -> @@ -43,15 +48,48 @@ module.exports = class BuyGemsModal extends ModalView @render() onClickProductButton: (e) -> - productID = $(e.target).closest('button.product').val() - console.log 'purchasing', _.find @products, { id: productID } + productID = $(e.target).closest('button').val() + product = _.find @products, { id: productID } if application.isIPadApp Backbone.Mediator.publish 'buy-gems-modal:purchase-initiated', { productID: productID } else application.tracker?.trackEvent 'Started purchase', {productID:productID}, ['Google Analytics'] - alert('Not yet implemented, but thanks for trying!') + stripeHandler.open({ + description: $.t(product.i18n) + amount: product.amount + }) + + @productBeingPurchased = product + + onStripeReceivedToken: (e) -> + @timestampForPurchase = new Date().getTime() + data = { + productID: @productBeingPurchased.id + stripe: { + token: e.token.id + timestamp: @timestampForPurchase + } + } + @state = 'purchasing' + @render() + jqxhr = $.post('/db/payment', data) + jqxhr.done(=> + document.location.reload() + ) + jqxhr.fail(=> + if jqxhr.status is 402 + @state = 'declined' + @stateMessage = arguments[2] + else if jqxhr.status is 500 + @state = 'retrying' + f = _.bind @onStripeReceivedToken, @, e + _.delay f, 2000 + else + @state = 'unknown_error' + @render() + ) onIAPComplete: (e) -> product = _.find @products, { id: e.productID } diff --git a/package.json b/package.json index 80d7aac88..0929c6c6d 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "aether": "~0.2.39", "JASON": "~0.1.3", "JQDeferred": "~2.1.0", - "jsondiffpatch": "0.1.17" + "jsondiffpatch": "0.1.17", + "stripe": "~2.9.0" }, "devDependencies": { "jade": "0.33.x", diff --git a/server/payments/payment_handler.coffee b/server/payments/payment_handler.coffee index 891d151fa..d4c60abeb 100644 --- a/server/payments/payment_handler.coffee +++ b/server/payments/payment_handler.coffee @@ -8,21 +8,26 @@ sendwithus = require '../sendwithus' hipchat = require '../hipchat' config = require '../../server_config' request = require 'request' +stripe = require('stripe')(config.stripe.secretKey) +async = require 'async' products = { 'gems_5': { - amount: 500 + amount: 499 gems: 5000 + id: 'gems_5' } 'gems_10': { - amount: 1000 + amount: 999 gems: 11000 + id: 'gems_10' } 'gems_20': { - amount: 2000 + amount: 1999 gems: 25000 + id: 'gems_20' } } @@ -45,9 +50,16 @@ PaymentHandler = class PaymentHandler extends Handler appleLocalPrice = req.body.apple?.localPrice stripeToken = req.body.stripe?.token stripeTimestamp = parseInt(req.body.stripe?.timestamp) + productID = req.body.productID - if not (appleReceipt or stripeTimestamp) - return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp') + if not (appleReceipt or (stripeTimestamp and productID)) + return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp and productID') + + if stripeTimestamp and not productID + return @sendBadInputError(res, 'Need productID if paying with Stripe.') + + if stripeTimestamp and (not stripeToken) and (not user.get('stripeCustomerID')) + return @sendBadInputError(res, 'Need stripe.token if new customer.') if appleReceipt if not appleTransactionID @@ -55,7 +67,10 @@ PaymentHandler = class PaymentHandler extends Handler @handleApplePaymentPost(req, res, appleReceipt, appleTransactionID, appleLocalPrice) else - @handleStripePaymentPost(req, res, stripeTimestamp, stripeToken) + @handleStripePaymentPost(req, res, stripeTimestamp, productID, stripeToken) + + + #- Apple payments handleApplePaymentPost: (req, res, receipt, transactionID, localPrice) -> formFields = { 'receipt-data': receipt } @@ -87,8 +102,6 @@ PaymentHandler = class PaymentHandler extends Handler payment.set 'service', 'ios' product = products[transaction.product_id] - product ?= _.values(products)[0] # TEST - payment.set 'amount', product.amount payment.set 'gems', product.gems payment.set 'ios', { @@ -110,10 +123,123 @@ PaymentHandler = class PaymentHandler extends Handler ) - handleStripePaymentPost: (req, res, timestamp, token) -> - console.log 'lol not implemented yet' - @sendNotFoundError(res) + #- Stripe payments + handleStripePaymentPost: (req, res, timestamp, productID, token) -> + + # First, make sure we save the payment info as a Customer object, if we haven't already. + if not req.user.get('stripeCustomerID') + stripe.customers.create({ + card: token + description: req.user._id + '' + }).then(((customer) => + req.user.set('stripeCustomerID', customer.id) + req.user.save((err) => + return @sendDatabaseError(res, err) if err + @beginStripePayment(req, res, timestamp, productID) + ) + ), + (err) => + return @sendDatabaseError(res, err) + ) + + else + @beginStripePayment(req, res, timestamp, productID) + + + beginStripePayment: (req, res, timestamp, productID) -> + product = products[productID] + + async.parallel([ + ((callback) -> + criteria = { recipient: req.user._id, 'stripe.timestamp': timestamp } + Payment.findOne(criteria).exec((err, payment) => + callback(err, payment) + ) + ), + ((callback) -> + stripe.charges.list({customer: req.user.get('stripeCustomerID')}, (err, recentCharges) => + return callback(err) if err + charge = _.find recentCharges.data, (c) -> c.metadata.timestamp is timestamp + callback(null, charge) + ) + ) + ], + + ((err, results) => + return @sendDatabaseError(res, err) if err + [payment, charge] = results + + if not (payment or charge) + # Proceed normally from the beginning + @chargeStripe(req, res, payment, product) + + else if charge and not payment + # Initialized Payment. Start from charging. + @recordStripeCharge(req, res, payment, product, charge) + + else + # Charged Stripe and recorded it. Recalculate gems to make sure credited the purchase. + @recalculateGemsFor(req.user, (err) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, @formatEntity(req, payment)) + ) + ) + ) + + + chargeStripe: (req, res, payment, product) -> + stripe.charges.create({ + amount: product.amount + currency: 'usd' + customer: req.user.get('stripeCustomerID') + metadata: { + productID: product.id + userID: req.user._id + '' + gems: product.gems + timestamp: parseInt(req.body.stripe?.timestamp) + } + receipt_email: req.user.get('email') + }).then( + # success case + ((charge) => @recordStripeCharge(req, res, payment, product, charge)), + + # error case + ((err) => + if err.type in ['StripeCardError', 'StripeInvalidRequestError'] + @sendError(res, 402, err.message) + else + @sendDatabaseError(res, 'Error charging card, please retry.')) + ) + + + recordStripeCharge: (req, res, payment, product, charge) -> + return @sendError(res, 500, 'Fake db error for testing.') if req.body.breakAfterCharging + payment = @makeNewInstance(req) + payment.set 'service', 'stripe' + payment.set 'productID', req.body.productID + payment.set 'amount', product.amount + payment.set 'gems', product.gems + payment.set 'stripe', { + customerID: req.user.get('stripeCustomerID') + timestamp: parseInt(req.body.stripe.timestamp) + chargeID: charge.id + } + + validation = @validateDocumentInput(payment.toObject()) + return @sendBadInputError(res, validation.errors) if validation.valid is false + payment.save((err) => + + # Credit gems + return @sendDatabaseError(res, err) if err + @incrementGemsFor(req.user, product.gems, (err) => + return @sendDatabaseError(res, err) if err + @sendCreated(res, @formatEntity(req, payment)) + ) + ) + + + #- Incrementing/recalculating gems incrementGemsFor: (user, gems, done) -> purchased = _.clone(user.get('purchased')) diff --git a/server/users/User.coffee b/server/users/User.coffee index 365b8176b..c7cd3a391 100644 --- a/server/users/User.coffee +++ b/server/users/User.coffee @@ -192,7 +192,7 @@ UserSchema.statics.hashPassword = (password) -> UserSchema.statics.privateProperties = [ 'permissions', 'email', 'mailChimp', 'firstName', 'lastName', 'gender', 'facebookID', 'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement', - 'emailSubscriptions', 'emails', 'activity' + 'emailSubscriptions', 'emails', 'activity', 'stripeCustomerID' ] UserSchema.statics.jsonSchema = jsonschema UserSchema.statics.editableProperties = [ diff --git a/server_config.coffee b/server_config.coffee index 9bd8de2a8..c4e6ecde9 100644 --- a/server_config.coffee +++ b/server_config.coffee @@ -18,8 +18,10 @@ config.mongo = mongoose_replica_string: process.env.COCO_MONGO_MONGOOSE_REPLICA_STRING or '' config.apple = - #verifyURL: process.env.COCO_APPLE_VERIFY_URL or 'https://sandbox.itunes.apple.com/verifyReceipt' - verifyURL: process.env.COCO_APPLE_VERIFY_URL or 'https://buy.itunes.apple.com/verifyReceipt' + verifyURL: process.env.COCO_APPLE_VERIFY_URL or 'https://sandbox.itunes.apple.com/verifyReceipt' + +config.stripe = + secretKey: process.env.COCO_STRIPE_SECRET_KEY or 'sk_test_MFnZHYD0ixBbiBuvTlLjl2da' config.redis = port: process.env.COCO_REDIS_PORT or 6379 diff --git a/test/server/functional/payment.spec.coffee b/test/server/functional/payment.spec.coffee index 884f4406e..a779b8778 100644 --- a/test/server/functional/payment.spec.coffee +++ b/test/server/functional/payment.spec.coffee @@ -1,6 +1,6 @@ testReceipt = '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' - +config = require '../../../server_config' require '../common' describe '/db/payment', -> @@ -61,13 +61,163 @@ describe '/db/payment', -> expect(user.get('purchased').gems).toBe(16000) done() ) - -# describe 'posting Stripe tokens' + + describe 'posting Stripe purchases', -> + + stripe = require('stripe')(config.stripe.secretKey) + + charge = null + joeID = null + timestamp = new Date().getTime() + stripeTokenID = null + + it 'clears the db first', (done) -> + clearModels [User, Payment], (err) -> + throw err if err + done() + + it 'handles a purchase', (done) -> + stripe.tokens.create({ + card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + stripeTokenID = token.id + loginJoe (joe) -> + joeID = joe.get('_id')+'' + data = { + productID: 'gems_5' + stripe: { + token: token.id + timestamp: timestamp + } + } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe 201 + expect(body.stripe.chargeID).toBeDefined() + expect(body.stripe.timestamp).toBe(timestamp) + expect(body.stripe.customerID).toBeDefined() + expect(body.gems).toBe(5000) + expect(body.amount).toBe(499) + expect(body.productID).toBe('gems_5') + expect(body.service).toBe('stripe') + expect(body.recipient).toBe(joeID) + expect(body.purchaser).toBe(joeID) + User.findById(joe.get('_id'), (err, user) -> + expect(user.get('purchased').gems).toBe(5000) + expect(user.get('stripeCustomerID')).toBe(body.stripe.customerID) + done() + ) + ) + it 'ignores repeated purchases', (done) -> + data = { productID: 'gems_5', stripe: { token: stripeTokenID, timestamp: timestamp } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe 200 + Payment.count({}, (err, count) -> + expect(count).toBe(1) + User.findById(joeID, (err, user) -> + expect(user.get('purchased').gems).toBe(5000) + done() + ) + ) + it 'clears the db', (done) -> + clearModels [User, Payment], (err) -> + throw err if err + done() + it 'recovers from breaking between charge and document creation', (done) -> + stripe.tokens.create({ + card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> - - + data = { + productID: 'gems_5' + stripe: { token: token.id, timestamp: timestamp } + breakAfterCharging: true + } + + loginJoe (joe) -> + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe 500 + + data = _.omit data, 'breakAfterCharging' + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe 201 + + Payment.count({}, (err, count) -> + expect(count).toBe(1) + User.findById(joe.get('_id'), (err, user) -> + expect(user.get('purchased').gems).toBe(5000) + done() + ) + ) + ) + + it 'clears the db', (done) -> + clearModels [User, Payment], (err) -> + throw err if err + done() - \ No newline at end of file + # Testing card numbers are here: https://stripe.com/docs/testing + + it 'handles card that attaches to customer but fails to be charged', (done) -> + stripe.tokens.create({ + card: { number: '4000000000000341', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + loginJoe (joe) -> + timestamp = new Date().getTime() + data = { productID: 'gems_5', stripe: { token: token.id, timestamp: timestamp } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(402) + done() + ) + + it 'handles card that always is declined with card_declined code', (done) -> + stripe.tokens.create({ + card: { number: '4000000000000002', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + loginJoe (joe) -> + timestamp = new Date().getTime() + data = { productID: 'gems_5', stripe: { token: token.id, timestamp: timestamp } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(402) + done() + ) + + it 'handles card that always is declined with incorrect_cvc code', (done) -> + stripe.tokens.create({ + card: { number: '4000000000000127', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + loginJoe (joe) -> + timestamp = new Date().getTime() + data = { productID: 'gems_5', stripe: { token: token.id, timestamp: timestamp } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(402) + done() + ) + + it 'handles card that always is declined with expired_card code', (done) -> + stripe.tokens.create({ + card: { number: '4000000000000069', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + loginJoe (joe) -> + timestamp = new Date().getTime() + data = { productID: 'gems_5', stripe: { token: token.id, timestamp: timestamp } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(402) + done() + ) + + it 'handles card that always is declined with processing_error code', (done) -> + stripe.tokens.create({ + card: { number: '4000000000000119', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + loginJoe (joe) -> + timestamp = new Date().getTime() + data = { productID: 'gems_5', stripe: { token: token.id, timestamp: timestamp } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(402) + done() + ) + + \ No newline at end of file From bf71893ddf92beba8715a1abd0ec0d07c7dac120 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Mon, 17 Nov 2014 21:30:44 -0800 Subject: [PATCH 5/5] Significantly reduced memory usage and simulation time by further limiting the amount of Thangs which even start tracking ThangState in the first place. --- .../javascripts/workers/worker_world.js | 26 ++++++++++++++----- app/lib/world/world.coffee | 22 +++++++++++++--- app/lib/world/world_frame.coffee | 2 +- app/views/play/level/PlayLevelView.coffee | 14 +++++----- server/queues/scoring.coffee | 2 +- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/workers/worker_world.js b/app/assets/javascripts/workers/worker_world.js index 40af1eeae..623f6b334 100644 --- a/app/assets/javascripts/workers/worker_world.js +++ b/app/assets/javascripts/workers/worker_world.js @@ -392,20 +392,29 @@ self.onWorldLoaded = function onWorldLoaded() { self.postMessage({type: 'end-load-frames', goalStates: goalStates, overallStatus: overallStatus}); var t1 = new Date(); var diff = t1 - self.t0; - if (self.world.headless) + if(self.world.headless) return console.log('Headless simulation completed in ' + diff + 'ms.'); + var worldEnded = self.world.ended; + var totalFrames = self.world.totalFrames; var transferableSupported = self.transferableSupported(); try { var serialized = self.world.serialize(); } catch(error) { console.log("World serialization error:", error.toString() + "\n" + error.stack || error.stackTrace); + self.destroyWorld(); + return; } + //self.serialized = serialized; // Testing peak memory usage + //return; // Testing peak memory usage + if(worldEnded) + // Make sure we clean up memory as soon as possible, since we just used the most ever and don't want to crash. + self.destroyWorld(); var t2 = new Date(); //console.log("About to transfer", serialized.serializedWorld.trackedPropertiesPerThangValues, serialized.transferableObjects); - var messageType = self.world.ended ? 'new-world' : 'some-frames-serialized'; + var messageType = worldEnded ? 'new-world' : 'some-frames-serialized'; try { var message = {type: messageType, serialized: serialized.serializedWorld, goalStates: goalStates, startFrame: serialized.startFrame, endFrame: serialized.endFrame}; if(transferableSupported) @@ -417,15 +426,18 @@ self.onWorldLoaded = function onWorldLoaded() { console.log("World delivery error:", error.toString() + "\n" + error.stack || error.stackTrace); } - if(self.world.ended) { + if(worldEnded) { var t3 = new Date(); - console.log("And it was so: (" + (diff / self.world.totalFrames).toFixed(3) + "ms per frame,", self.world.totalFrames, "frames)\nSimulation :", diff + "ms \nSerialization:", (t2 - t1) + "ms\nDelivery :", (t3 - t2) + "ms"); - self.world.goalManager.destroy(); - self.world.destroy(); - self.world = null; + console.log("And it was so: (" + (diff / totalFrames).toFixed(3) + "ms per frame,", totalFrames, "frames)\nSimulation :", diff + "ms \nSerialization:", (t2 - t1) + "ms\nDelivery :", (t3 - t2) + "ms"); } }; +self.destroyWorld = function destroyWorld() { + self.world.goalManager.destroy(); + self.world.destroy(); + self.world = null; +}; + self.onWorldPreloaded = function onWorldPreloaded() { self.goalManager.worldGenerationEnded(); var goalStates = self.goalManager.getGoalStates(); diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee index 6c21b795e..3b932200f 100644 --- a/app/lib/world/world.coffee +++ b/app/lib/world/world.coffee @@ -17,6 +17,7 @@ REAL_TIME_BUFFER_MAX = 3 * PROGRESS_UPDATE_INTERVAL REAL_TIME_BUFFERED_WAIT_INTERVAL = 0.5 * PROGRESS_UPDATE_INTERVAL REAL_TIME_COUNTDOWN_DELAY = 3000 # match CountdownScreen ITEM_ORIGINAL = '53e12043b82921000051cdf9' +EXISTS_ORIGINAL = '524b4150ff92f1f4f8000024' COUNTDOWN_LEVELS = ['sky-span'] module.exports = class World @@ -237,13 +238,19 @@ module.exports = class World loadThangFromLevel: (thangConfig, levelComponents, thangTypes, equipBy=null) -> components = [] - for component in thangConfig.components + for component, componentIndex in thangConfig.components componentModel = _.find levelComponents, (c) -> c.original is component.original and c.version.major is (component.majorVersion ? 0) componentClass = @loadClassFromCode componentModel.js, componentModel.name, 'component' components.push [componentClass, component.config] - if equipBy and component.original is ITEM_ORIGINAL - component.config.ownerID = equipBy + if component.original is ITEM_ORIGINAL + isItem = true + component.config.ownerID = equipBy if equipBy + else if component.original is EXISTS_ORIGINAL + existsConfigIndex = componentIndex + if isItem and existsConfigIndex? + # For memory usage performance, make sure these don't get any tracked properties assigned. + components[existsConfigIndex][1] = {exists: false, stateless: true} thangTypeOriginal = thangConfig.thangType thangTypeModel = _.find thangTypes, (t) -> t.original is thangTypeOriginal return console.error thangConfig.id ? equipBy, 'could not find ThangType for', thangTypeOriginal unless thangTypeModel @@ -347,6 +354,7 @@ module.exports = class World serialize: -> # Code hotspot; optimize it + @freeMemoryBeforeFinalSerialization() if @ended startFrame = @framesSerializedSoFar endFrame = @frames.length #console.log "... world serializing frames from", startFrame, "to", endFrame, "of", @totalFrames @@ -437,6 +445,7 @@ module.exports = class World o.scriptNotes = (sn.serialize() for sn in @scriptNotes) if o.scriptNotes.length > 200 console.log 'Whoa, serializing a lot of WorldScriptNotes here:', o.scriptNotes.length + @freeMemoryAfterEachSerialization() unless @ended {serializedWorld: o, transferableObjects: [o.storageBuffer], startFrame: startFrame, endFrame: endFrame} @deserialize: (o, classMap, oldSerializedWorldFrames, finishedWorldCallback, startFrame, endFrame, level, streamingWorld) -> @@ -535,6 +544,13 @@ module.exports = class World console.log 'No frames were changed out of all', @frames.length firstChangedFrame + freeMemoryBeforeFinalSerialization: -> + @levelComponents = null + @thangTypes = null + + freeMemoryAfterEachSerialization: -> + @frames[i] = null for frame, i in @frames when i < @frames.length - 1 + pointsForThang: (thangID, frameStart=0, frameEnd=null, camera=null, resolution=4) -> # Optimized @pointsForThangCache ?= {} diff --git a/app/lib/world/world_frame.coffee b/app/lib/world/world_frame.coffee index d1386b718..7fb0940bc 100644 --- a/app/lib/world/world_frame.coffee +++ b/app/lib/world/world_frame.coffee @@ -17,7 +17,7 @@ module.exports = class WorldFrame return nextFrame setState: -> - for thang in @world.thangs + for thang in @world.thangs when not thang.stateless @thangStateMap[thang.id] = thang.getState() restoreState: -> diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 8450ce7da..2205135f7 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -97,7 +97,7 @@ module.exports = class PlayLevelView extends RootView @isEditorPreview = @getQueryVariable 'dev' @sessionID = @getQueryVariable 'session' - + @opponentSessionID = @getQueryVariable('opponent') @opponentSessionID ?= @options.opponent @@ -369,7 +369,7 @@ module.exports = class PlayLevelView extends RootView return if @alreadyLoadedState @alreadyLoadedState = true state = @originalSessionState - if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] + if not @level or @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true Backbone.Mediator.publish 'tome:select-primary-sprite', {} Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false @@ -552,7 +552,7 @@ module.exports = class PlayLevelView extends RootView return if @destroyed # TODO: Show a victory dialog specific to hero-ladder level if @goalManager.checkOverallStatus() is 'success' and not @options.realTimeMultiplayerSessionID? - Backbone.Mediator.publish 'level:show-victory', showModal: true + Backbone.Mediator.publish 'level:show-victory', showModal: true destroy: -> @levelLoader?.destroy() @@ -753,7 +753,7 @@ module.exports = class PlayLevelView extends RootView if @realTimeOpponent.get('state') is 'ready' @realTimeOpponent.off 'change', @realTimeOpponentMaybeReady @realTimeOpponentIsReady() - + realTimeOpponentIsReady: => console.info 'All real-time multiplayer players are ready!' @realTimeSession.set 'state', 'running' @@ -840,7 +840,7 @@ module.exports = class PlayLevelView extends RootView # TODO: This isn't always getting updated where the random seed generation uses it. sessionState.submissionCount = parseInt newSubmissionCount console.info 'Got multiplayer submissionCount', sessionState.submissionCount - @session.set 'state', sessionState + @session.set 'state', sessionState @session.patch() # Reload this level so the opponent session can easily be wired up @@ -862,7 +862,7 @@ module.exports = class PlayLevelView extends RootView return if me.id is @realTimeSession.get('creator') oldTeam = @realTimeOpponent.get('team') - return unless oldTeam is @session.get('team') + return unless oldTeam is @session.get('team') # Need to switch to other team newTeam = if oldTeam is 'humans' then 'ogres' else 'humans' @@ -898,7 +898,7 @@ module.exports = class PlayLevelView extends RootView if sessionState? # TODO: Don't hard code thangID sessionState.selected = if newTeam is 'humans' then 'Hero Placeholder' else 'Hero Placeholder 1' - @session.set 'state', sessionState + @session.set 'state', sessionState @session.set 'code', code @session.patch() diff --git a/server/queues/scoring.coffee b/server/queues/scoring.coffee index d3fa48a54..bfe3793d2 100644 --- a/server/queues/scoring.coffee +++ b/server/queues/scoring.coffee @@ -124,7 +124,7 @@ module.exports.getTwoGames = (req, res) -> #if userIsAnonymous req then return errors.unauthorized(res, 'You need to be logged in to get games.') humansGameID = req.body.humansGameID ogresGameID = req.body.ogresGameID - ladderGameIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span', 'dueling-grounds', 'cavern-survival', 'intuit-tf2014'] + ladderGameIDs = ['greed', 'criss-cross', 'brawlwood', 'dungeon-arena', 'gold-rush', 'sky-span', 'dueling-grounds', 'cavern-survival', 'multiplayer-treasure-grove'] levelID = _.sample ladderGameIDs unless ogresGameID and humansGameID #fetch random games here