diff --git a/README.md b/README.md index d03e06cdb..22480125a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ CodeCombat is a multiplayer programming game for learning how to code. **See the It's both a startup and a community project, completely open source under the [MIT and Creative Commons licenses](http://codecombat.com/legal). It's the largest open source [CoffeeScript](http://coffeescript.org/) project by lines of code, and since it's a game (with [really cool tech](https://github.com/codecombat/codecombat/wiki/Third-party-software-and-services)), it's really fun to hack on. Join us in teaching the world to code! Your contribution will go on to show millions of players how cool programming can be. -### [Getting Started](https://github.com/codecombat/codecombat/wiki/Developer-environment) +### [Getting Started](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-General-Information) -We've made it easy to fork the project, run a simple script that'll install all the dependencies, and get a local copy of CodeCombat running right away on Mac, Linux, or Windows. See [the docs for details](https://github.com/codecombat/codecombat/wiki/Developer-environment). +We've made it easy to fork the project, run a simple script that'll install all the dependencies, and get a local copy of CodeCombat running right away on Mac, Linux, or Windows. See [the docs for details](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-General-Information). ### [Getting In Touch](https://github.com/codecombat/codecombat/wiki/Developer-organization) diff --git a/app/core/Tracker.coffee b/app/core/Tracker.coffee index 90c97e989..b2a31258b 100644 --- a/app/core/Tracker.coffee +++ b/app/core/Tracker.coffee @@ -62,7 +62,7 @@ module.exports = class Tracker # https://segment.com/docs/integrations/mixpanel/ properties = properties or {} - @trackEventInternal action, _.cloneDeep properties + @trackEventInternal action, _.cloneDeep properties unless me?.isAdmin() and @isProduction console.log 'Would track analytics event:', action, properties, includeIntegrations if debugAnalytics return unless me and @isProduction and analytics? and not me.isAdmin() diff --git a/app/locale/it.coffee b/app/locale/it.coffee index 1c909e337..e52f60d8b 100644 --- a/app/locale/it.coffee +++ b/app/locale/it.coffee @@ -81,7 +81,7 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t awaiting_levels_adventurer_prefix: "Pubblichiamo 5 livelli alla settimana." awaiting_levels_adventurer: "Iscriviti come Avventuriero" awaiting_levels_adventurer_suffix: "per essere tra i primi a provare i nuovi livelli." -# adjust_volume: "Adjust volume" + adjust_volume: "Regola il volume" choose_your_level: "Scegli il tuo livello" # The rest of this section is the old play view at /play-old and isn't very important. adventurer_prefix: "Puoi entrare in qualunque livello qui sotto, o scambiare opinioni su questi livelli sul" adventurer_forum: "forum degli Avventurieri" @@ -161,9 +161,9 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t body: "Testo" version: "Versione" # pending: "Pending" -# accepted: "Accepted" -# rejected: "Rejected" -# withdrawn: "Withdrawn" + accepted: "Accettato" + rejected: "Rifiutato" + withdrawn: "Ritirato" # submitter: "Submitter" # submitted: "Submitted" commit_msg: "Messaggio di commit" @@ -171,10 +171,10 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t # version_history: "Version History" # version_history_for: "Version History for: " # select_changes: "Select two changes below to see the difference." -# undo_prefix: "Undo" -# undo_shortcut: "(Ctrl+Z)" -# redo_prefix: "Redo" -# redo_shortcut: "(Ctrl+Shift+Z)" + undo_prefix: "Annulla" + undo_shortcut: "(Ctrl+Z)" + redo_prefix: "Ripristina" + redo_shortcut: "(Ctrl+Shift+Z)" play_preview: "Vedi anteprima del livello attuale" result: "Risultato" results: "Risultati" @@ -198,9 +198,9 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t hard: "Difficile" player: "Giocatore" player_level: "Livello" # Like player level 5, not like level: Dungeons of Kithgard -# warrior: "Warrior" -# ranger: "Ranger" -# wizard: "Wizard" + warrior: "Guerriero" + ranger: "Ranger" + wizard: "Mago" units: second: "secondo" @@ -262,14 +262,14 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t tome_other_units: "Altre unità" # Only in old-style levels. tome_cast_button_run: "Vai" tome_cast_button_running: "In esecuzione" -# tome_cast_button_ran: "Ran" + tome_cast_button_ran: "Esegui" tome_submit_button: "Entra" tome_reload_method: "Ricarica codice originale per questo metodo" # Title text for individual method reload button. tome_select_method: "Scegli un metodo" tome_see_all_methods: "Vedi tutti i metodi che puoi modificare" # Title text for method list selector (shown when there are multiple programmable methdos). tome_select_a_thang: "Seleziona qualcuno per " tome_available_spells: "Incantesimi disponibili" -# tome_your_skills: "Your Skills" + tome_your_skills: "Le tue Abilità" tome_help: "Aiuto" # tome_current_method: "Current Method" hud_continue_short: "Continua" @@ -458,29 +458,29 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t # why_paragraph_2_prefix: "That's what programming is about. It's gotta be fun. Not fun like" # why_paragraph_2_italic: "yay a badge" # why_paragraph_2_center: "but fun like" -# why_paragraph_2_italic_caps: "NO MOM I HAVE TO FINISH THE LEVEL!" + why_paragraph_2_italic_caps: "NO MAMMA, DEVO FINIRE IL LIVELLO!" # why_paragraph_2_suffix: "That's why CodeCombat is a multiplayer game, not a gamified lesson course. We won't stop until you can't stop--but this time, that's a good thing." # why_paragraph_3: "If you're going to get addicted to some game, get addicted to this one and become one of the wizards of the tech age." # press_title: "Bloggers/Press" # press_paragraph_1_prefix: "Want to write about us? Feel free to download and use all of the resources included in our" # press_paragraph_1_link: "press packet" -# press_paragraph_1_suffix: ". All logos and images may be used without contacting us directly." + press_paragraph_1_suffix: ". Tutti i loghi e le immagini possono essere usate senza contattarci direttamente." # team: "Team" # george_title: "CEO" # george_blurb: "Businesser" -# scott_title: "Programmer" + scott_title: "Programmatore" # scott_blurb: "Reasonable One" -# nick_title: "Programmer" + nick_title: "Programmatore" # nick_blurb: "Motivation Guru" -# michael_title: "Programmer" -# michael_blurb: "Sys Admin" -# matt_title: "Programmer" + michael_title: "Programmatore" + michael_blurb: "Amministratore di sistema" + matt_title: "Programmatore" # matt_blurb: "Bicyclist" versions: save_version_title: "Salva nuova versione" new_major_version: "Nuova versione" -# submitting_patch: "Submitting Patch..." + submitting_patch: "Invio modifiche in corso..." cla_prefix: "Per salvare le modifiche, prima devi accettare la nostra " cla_url: "CLA" cla_suffix: "." @@ -493,7 +493,7 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t forum_page: "il nostro forum" forum_suffix: " invece." # faq_prefix: "There's also a" -# faq: "FAQ" + faq: "FAQ" # subscribe_prefix: "If you need help figuring out a level, please" # subscribe: "buy a CodeCombat subscription" # subscribe_suffix: "and we'll be happy to help you with your code." @@ -540,7 +540,7 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t # job_profile_approved: "Your job profile has been approved by CodeCombat. Employers will be able to see it until you either mark it inactive or it has not been changed for four weeks." # job_profile_explanation: "Hi! Fill this out, and we will get in touch about finding you a software developer job." # sample_profile: "See a sample profile" -# view_profile: "View Your Profile" + view_profile: "Visualizza il tuo Profilo" keyboard_shortcuts: keyboard_shortcuts: "Abbreviazioni da tastiera" @@ -636,9 +636,9 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t level_tab_thangs_all: "Tutti" level_tab_thangs_conditions: "Condizioni iniziali" level_tab_thangs_add: "Aggiungi thang" -# add_components: "Add Components" -# component_configs: "Component Configurations" -# config_thang: "Double click to configure a thang" + add_components: "Aggiungi Componenti" + component_configs: "Configurazioni componenti" + config_thang: "Doppio click per configurare un thang" delete: "Cancella" duplicate: "Duplica" # stop_duplicate: "Stop Duplicate" @@ -658,12 +658,12 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t create_system_title: "Crea nuovo sistema" new_component_title: "Crea nuovo componente" new_component_field_system: "Sistema" -# new_article_title: "Create a New Article" + new_article_title: "Crea un nuovo articolo" # new_thang_title: "Create a New Thang Type" -# new_level_title: "Create a New Level" -# new_article_title_login: "Log In to Create a New Article" + new_level_title: "Crea un nuovo livello" + new_article_title_login: "Accedi per creare un nuovo articolo" # new_thang_title_login: "Log In to Create a New Thang Type" -# new_level_title_login: "Log In to Create a New Level" + new_level_title_login: "Accedi per creare un nuovo livello" # new_achievement_title: "Create a New Achievement" # new_achievement_title_login: "Log In to Create a New Achievement" # article_search_title: "Search Articles Here" @@ -789,9 +789,9 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t # simple_ai: "Simple AI" warmup: "Allenamento" # friends_playing: "Friends Playing" -# log_in_for_friends: "Log in to play with your friends!" + log_in_for_friends: "Accedi per giocare con i tuoi amici!" # social_connect_blurb: "Connect and play against your friends!" -# invite_friends_to_battle: "Invite your friends to join you in battle!" + invite_friends_to_battle: "Invita i tuoi amici a unirsi a te nella battaglia!" fight: "Combatti!" # watch_victory: "Watch your victory" # defeat_the: "Defeat the" @@ -952,7 +952,7 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t practices_title: "Buone pratiche di rispetto" practices_description: "Queste sono le promesse che ti facciamo, come giocatore, in linguaggio un po' meno legale." privacy_title: "Privacy" -# privacy_description: "We will not sell any of your personal information." + privacy_description: "Non venderemo nessuna delle tue informazioni personali." security_title: "Sicurezza" security_description: "Facciamo tutto il possibile per tenere sicure le tue informazioni. Essendo un progetto open source, il nostro sito è aperto liberamente a chiunque per controllare e migliorare i nostri sistemi di sicurezza." email_title: "Email" @@ -1169,10 +1169,10 @@ module.exports = nativeDescription: "Italiano", englishDescription: "Italian", t admin: # av_espionage: "Espionage" # Really not important to translate /admin controls. -# av_espionage_placeholder: "Email or username" + av_espionage_placeholder: "Email o nome utente" # av_usersearch: "User Search" # av_usersearch_placeholder: "Email, username, name, whatever" -# av_usersearch_search: "Search" + av_usersearch_search: "Cerca" av_title: "Vista amministratore" av_entities_sub_title: "Entità" av_entities_users_url: "Utenti" diff --git a/app/locale/nl-NL.coffee b/app/locale/nl-NL.coffee index 98fc9801e..ad1768462 100644 --- a/app/locale/nl-NL.coffee +++ b/app/locale/nl-NL.coffee @@ -198,9 +198,9 @@ module.exports = nativeDescription: "Nederlands (Nederland)", englishDescription hard: "Moeilijk" player: "Speler" player_level: "Niveau" # Like player level 5, not like level: Dungeons of Kithgard -# warrior: "Warrior" + warrior: "Krijger" # ranger: "Ranger" -# wizard: "Wizard" + wizard: "Tovenaar" units: second: "seconde" @@ -269,16 +269,16 @@ module.exports = nativeDescription: "Nederlands (Nederland)", englishDescription # tome_see_all_methods: "See all methods you can edit" # Title text for method list selector (shown when there are multiple programmable methdos). tome_select_a_thang: "Selecteer Iemand voor " tome_available_spells: "Beschikbare spreuken" -# tome_your_skills: "Your Skills" + tome_your_skills: "Jouw Vaardigheden" # tome_help: "Help" # tome_current_method: "Current Method" -# hud_continue_short: "Continue" -# code_saved: "Code Saved" + hud_continue_short: "Doorgaan" + code_saved: "Code Opgeslagen" skip_tutorial: "Overslaan (esc)" # keyboard_shortcuts: "Key Shortcuts" loading_ready: "Klaar!" # loading_start: "Start Level" -# problem_alert_title: "Fix Your Code" + problem_alert_title: "Verbeter je Code" # problem_alert_help: "Help" time_current: "Nu:" time_total: "Maximum:" @@ -312,34 +312,34 @@ module.exports = nativeDescription: "Nederlands (Nederland)", englishDescription tip_talk_is_cheap: "Je kunt het goed uitleggen, maar toon me de code. - Linus Torvalds" tip_first_language: "Het ergste dat je kan leren is je eerste programmeertaal. - Alan Kay" # tip_hardware_problem: "Q: How many programmers does it take to change a light bulb? A: None, it's a 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_hofstadters_law: "De Wet van Hofstadter: Het duurt altijd langer dan je verwacht, zelfs wanneer je rekening houdt met de Wet van Hofstadter." # 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_extrapolation: "There are only two kinds of people: those that can extrapolate from incomplete data..." # tip_superpower: "Coding is the closest thing we have to a superpower." game_menu: -# inventory_tab: "Inventory" -# save_load_tab: "Save/Load" -# options_tab: "Options" -# guide_tab: "Guide" + inventory_tab: "Inventaris" + save_load_tab: "Opslaan/Laden" + options_tab: "Opties" + guide_tab: "Handleiding" # guide_video_tutorial: "Video Tutorial" # guide_tips: "Tips" multiplayer_tab: "Multiplayer" -# auth_tab: "Sign Up" + auth_tab: "Inschrijven" # inventory_caption: "Equip your hero" -# choose_hero_caption: "Choose hero, language" + choose_hero_caption: "Kies held, taal" # save_load_caption: "... and view history" -# options_caption: "Configure settings" + options_caption: "Instellingen" # guide_caption: "Docs and tips" -# multiplayer_caption: "Play with friends!" -# auth_caption: "Save your progress." + multiplayer_caption: "Speel met vrienden!" + auth_caption: "Bewaar je voortgang." -# inventory: + inventory: # choose_inventory: "Equip Items" # equipped_item: "Equipped" -# required_purchase_title: "Required" -# available_item: "Available" + required_purchase_title: "Verplicht" + available_item: "Beschikbaar" # restricted_title: "Restricted" # should_equip: "(double-click to equip)" # equipped: "(equipped)" @@ -348,26 +348,26 @@ module.exports = nativeDescription: "Nederlands (Nederland)", englishDescription # equip: "Equip" # unequip: "Unequip" -# buy_gems: -# few_gems: "A few gems" -# pile_gems: "Pile of gems" -# chest_gems: "Chest of gems" -# purchasing: "Purchasing..." -# declined: "Your card was declined" + buy_gems: + few_gems: "Een paar edelstenen" + pile_gems: "Berg edelstenen" + chest_gems: "Schatkist met edelstenen" + purchasing: "Aan het kopen..." + declined: "Je kaart is geweigerd" # retrying: "Server error, retrying." -# prompt_title: "Not Enough Gems" + prompt_title: "Niet genoeg edelstenen" # prompt_body: "Do you want to get more?" -# prompt_button: "Enter Shop" + prompt_button: "Naar de winkel" # recovered: "Previous gems purchase recovered. Please refresh the page." -# subscribe: + subscribe: # subscribe_title: "Subscribe" # unsubscribe: "Unsubscribe" # levels: "Get more practice with bonus levels!" # heroes: "More powerful heroes!" -# gems: "3500 bonus gems every month!" -# items: "Over 250 bonus items!" -# parents: "For Parents" + gems: "3500 extra edelstenen elke maand!" + items: "Meer dan 250 bonus items!" + parents: "Voor ouders" # parents_title: "Your child will learn to code." # parents_blurb1: "With CodeCombat, your child learns by writing real code. They start by learning simple commands, and progress to more advanced topics." # parents_blurb2: "For $9.99 USD/mo, they get new challenges every week and personal email support from professional programmers." @@ -376,12 +376,12 @@ module.exports = nativeDescription: "Nederlands (Nederland)", englishDescription # stripe_description: "Monthly Subscription" # subscription_required_to_play: "You'll need a subscription to play this level." -# choose_hero: -# choose_hero: "Choose Your Hero" -# programming_language: "Programming Language" -# programming_language_description: "Which programming language do you want to use?" -# default: "Default" -# experimental: "Experimental" + choose_hero: + choose_hero: "Kies je held" + programming_language: "Programmeertaal" + programming_language_description: "Welke programmeertaal wil je gebruiken?" + default: "standaard" + experimental: "Experimenteel" # python_blurb: "Simple yet powerful, great for beginners and experts." # javascript_blurb: "The language of the web. (Not the same as Java.)" # coffeescript_blurb: "Nicer JavaScript syntax." @@ -389,18 +389,18 @@ module.exports = nativeDescription: "Nederlands (Nederland)", englishDescription # lua_blurb: "Game scripting language." # io_blurb: "Simple but obscure." # status: "Status" -# weapons: "Weapons" + weapons: "Wapens" # 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" -# health: "Health" -# speed: "Speed" + health: "Gezondheid" + speed: "Snelheid" # regeneration: "Regeneration" # range: "Range" # As in "attack or visual range" # blocks: "Blocks" # As in "this shield blocks this much damage" # backstab: "Backstab" # As in "this dagger does this much backstab damage" -# skills: "Skills" + skills: "Vaardigheden" # available_for_purchase: "Available for Purchase" # Shows up when you have unlocked, but not purchased, a hero in the hero store # level_to_unlock: "Level to unlock:" # Label for which level you have to beat to unlock a particular hero (click a locked hero in the store to see) # restricted_to_certain_heroes: "Only certain heroes can play this level." diff --git a/app/locale/pt-PT.coffee b/app/locale/pt-PT.coffee index 7318e9a60..0a074fb9d 100644 --- a/app/locale/pt-PT.coffee +++ b/app/locale/pt-PT.coffee @@ -960,7 +960,7 @@ module.exports = nativeDescription: "Português (Portugal)", englishDescription: email_settings_url: "tuas definições de e-mail" email_description_suffix: "ou através de ligações presentes nos e-mails que enviamos, podes mudar as tuas preferências e parar a tua subscrição facilmente, em qualquer momento." cost_title: "Custo" -# cost_description: "CodeCombat is free to play for all of its core levels, with a $9.99 USD/mo subscription for access to extra level branches and 3500 bonus gems per month. You can cancel with a click, and we offer a 100% money-back guarantee." + cost_description: "O CodeCombat é gratuito para os níveis fundamentais, com uma subscrição de $9.99 USD/mês para acederes a ramos de níveis extra e 3500 gemas de bónus por mês. Podes cancelar com um clique, e oferecemos uma garantia de 100% de devolução do dinheiro." copyrights_title: "Direitos Autorais e Licensas" contributor_title: "Contrato de Licença do Contribuinte (CLA)" contributor_description_prefix: "Todas as contribuições, tanto no sítio como no nosso repositório GitHub, estão sujeitas ao nosso" @@ -973,15 +973,15 @@ module.exports = nativeDescription: "Português (Portugal)", englishDescription: art_title: "Arte/Música - Creative Commons " art_description_prefix: "Todos os conteúdos comuns estão disponíveis à luz da" cc_license_url: "Licença 'Creative Commons Attribution 4.0 International'" -# art_description_suffix: "Common content is anything made generally available by CodeCombat for the purpose of creating Levels. This includes:" + art_description_suffix: "Conteúdo comum é, geralmente, qualquer coisa disponibilizada pelo CodeCombat com o propósito de criar Níveis. Isto inclui:" art_music: "Música" art_sound: "Som" art_artwork: "Arte" art_sprites: "Sprites" -# art_other: "Any and all other non-code creative works that are made available when creating Levels." + art_other: "Quaisquer e todos os trabalhos criativos não-código que são disponibilizados aquando da criação de Níveis." # art_access: "Currently there is no universal, easy system for fetching these assets. In general, fetch them from the URLs as used by the site, contact us for assistance, or help us in extending the site to make these assets more easily accessible." # art_paragraph_1: "For attribution, please name and link to codecombat.com near where the source is used or where appropriate for the medium. For example:" -# use_list_1: "If used in a movie or another game, include codecombat.com in the credits." + use_list_1: "Se usado num filme ou noutro jogo, inclui 'codecombat.com' nos créditos." # use_list_2: "If used on a website, include a link near the usage, for example underneath an image, or in a general attributions page where you might also mention other Creative Commons works and open source software being used on the site. Something that's already clearly referencing CodeCombat, such as a blog post mentioning CodeCombat, does not need some separate attribution." # art_paragraph_2: "If the content being used is created not by CodeCombat but instead by a user of codecombat.com, attribute them instead, and follow attribution directions provided in that resource's description if there are any." rights_title: "Direitos Reservados" diff --git a/app/locale/ru.coffee b/app/locale/ru.coffee index 8b5623b25..8eefec3d4 100644 --- a/app/locale/ru.coffee +++ b/app/locale/ru.coffee @@ -81,7 +81,7 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi awaiting_levels_adventurer_prefix: "Мы выпускаем по 5 уровней в неделю." awaiting_levels_adventurer: "Зарегистрируйтесь в качестве Искателя приключений" awaiting_levels_adventurer_suffix: "чтобы первым поиграть в новые уровни." -# adjust_volume: "Adjust volume" + adjust_volume: "Регулировать громкость" choose_your_level: "Выберите ваш уровень" # The rest of this section is the old play view at /play-old and isn't very important. adventurer_prefix: "Вы можете зайти на любой из этих уровней, а также обсудить уровни на " adventurer_forum: "форуме Искателей приключений" @@ -198,9 +198,9 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi hard: "Сложно" player: "Игрок" player_level: "Уровень" # Like player level 5, not like level: Dungeons of Kithgard -# warrior: "Warrior" -# ranger: "Ranger" -# wizard: "Wizard" + warrior: "Воин" + ranger: "Рейнджер" + wizard: "Волшебник" units: second: "секунда" @@ -480,7 +480,7 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi versions: save_version_title: "Сохранить новую версию" new_major_version: "Новая основная версия" -# submitting_patch: "Submitting Patch..." + submitting_patch: "Отправка патча..." cla_prefix: "Чтобы сохранить изменения, сначала вы должны согласиться с нашим" cla_url: "лицензионным соглашением соавторов" cla_suffix: "." @@ -610,12 +610,12 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi revert: "Откатить" revert_models: "Откатить Модели" pick_a_terrain: "Выберите ландшафт" -# dungeon: "Dungeon" -# indoor: "Indoor" -# desert: "Desert" + dungeon: "Подземелье" + indoor: "Комнатный" + desert: "Пустыня" grassy: "Травянистый" small: "Маленький" -# large: "Large" + large: "Большой" fork_title: "Форк новой версии" fork_creating: "Создание форка..." generate_terrain: "Создать ландшафт" @@ -636,12 +636,12 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi level_tab_thangs_all: "Все" level_tab_thangs_conditions: "Начальные условия" level_tab_thangs_add: "Добавить объект" -# add_components: "Add Components" -# component_configs: "Component Configurations" -# config_thang: "Double click to configure a thang" + add_components: "Добавить компоненты" + component_configs: "Конфигурации компонентов" + config_thang: "Двойной клик для конфигурирования объектов" delete: "Удалить" duplicate: "Дублировать" -# stop_duplicate: "Stop Duplicate" + stop_duplicate: "Остановить дублирование" rotate: "Повернуть" level_settings_title: "Настройки" level_component_tab_title: "Текущие компоненты" @@ -885,7 +885,7 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi leaderboard: "таблица лидеров" user_schema: "Пользовательская Schema" user_profile: "Пользовательский профиль" -# patch: "Patch" + patch: "Патч" patches: "Патчи" patched_model: "Исходный документ" model: "Модель" diff --git a/app/locale/zh-HANS.coffee b/app/locale/zh-HANS.coffee index 79969a06c..ff34003ba 100644 --- a/app/locale/zh-HANS.coffee +++ b/app/locale/zh-HANS.coffee @@ -81,7 +81,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese awaiting_levels_adventurer_prefix: "我们每周开放五个关卡" awaiting_levels_adventurer: "注册成为冒险家" awaiting_levels_adventurer_suffix: "来优先尝试新关卡" -# adjust_volume: "Adjust volume" + adjust_volume: "音量调节" choose_your_level: "选择关卡" # The rest of this section is the old play view at /play-old and isn't very important. adventurer_prefix: "你可以选择以下任意关卡,或者讨论以上的关卡。到" adventurer_forum: "冒险者论坛" @@ -160,10 +160,10 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese date: "日期" body: "正文" version: "版本" -# pending: "Pending" -# accepted: "Accepted" -# rejected: "Rejected" -# withdrawn: "Withdrawn" + pending: "处理中" + accepted: "已接受" + rejected: "未接受" + withdrawn: "撤回" submitter: "提交者" submitted: "已提交" commit_msg: "提交信息" @@ -198,9 +198,9 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese hard: "困难" player: "玩家" player_level: "等级" # Like player level 5, not like level: Dungeons of Kithgard -# warrior: "Warrior" -# ranger: "Ranger" -# wizard: "Wizard" + warrior: "武士" + ranger: "巡逻兵" + wizard: "巫师" units: second: "秒" @@ -316,7 +316,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese tip_premature_optimization: "过早的优化是万恶之源。 - 高德纳" tip_brute_force: "拿不准时就用穷举法。 - Ken Thompson" tip_extrapolation: "世界上只有两类人:一类人能够根据不完整的数据进行推断……" -# tip_superpower: "Coding is the closest thing we have to a superpower." + tip_superpower: "编程是我们拥有的最接近超能力的技能" game_menu: inventory_tab: "道具箱" @@ -480,7 +480,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese versions: save_version_title: "保存新版本" new_major_version: "新的重要版本" -# submitting_patch: "Submitting Patch..." + submitting_patch: "正在提交补丁..." cla_prefix: "要想保存更改,您必须先同意我们的" cla_url: "贡献者许可协议" cla_suffix: "。" @@ -492,14 +492,14 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese forum_prefix: "如果你想发布任何公开的东西, 可以试试" forum_page: "我们的论坛" forum_suffix: "" -# faq_prefix: "There's also a" + faq_prefix: "这里还有一个" faq: "FAQ" -# subscribe_prefix: "If you need help figuring out a level, please" + subscribe_prefix: "如果你需要帮助解决一个关卡,请" subscribe: "订阅CodeCombat" subscribe_suffix: "并且我们很乐意给你提供代码相关的帮助" subscriber_support: "既然你已经订阅了CodeCombat,我们将给你提供优先帮助" screenshot_included: "包含截屏" -# where_reply: "Where should we reply?" + where_reply: "我们应该回复谁?" send: "反馈意见" contact_candidate: "联系参选人" # Deprecated recruitment_reminder: "用这张表格来联系你希望面试的求职者。但请记住如果成功雇佣,CodeCombat会收取与这位员工第一年工资的15%等值的佣金。佣金需在雇佣此员工时就付清并且在之后的90天内如果此员工离职则可退款。兼职,远程办公员工,合同工以及实习生都可免除此费用。" # Deprecated @@ -589,7 +589,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese artisan_summary: "建立游戏关卡并分享给朋友们。那么就选择工匠职业来教其他人编程吧。" adventurer_title: "冒险家" adventurer_title_description: "(关卡测试人员)" -# adventurer_summary: "Get our new levels (even our subscriber content) for free one week early and help us work out bugs before our public release." + adventurer_summary: "提前一周免费获得我们最新的关卡(还有我们的订阅内容),并帮助我们在发布之前寻找程序错误" scribe_title: "文书" scribe_title_description: "(提示编辑人员)" scribe_summary: "好代码需要好文档,来自全世界数百万的玩家一起编写,编辑以及提高文档的可读性" @@ -610,21 +610,21 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese revert: "还原" revert_models: "还原模式" pick_a_terrain: "选择地形" -# dungeon: "Dungeon" -# indoor: "Indoor" -# desert: "Desert" + dungeon: "地牢" + indoor: "室内" + desert: "沙漠" grassy: "草地" small: "小的" -# large: "Large" + large: "大的" fork_title: "派生新版本" fork_creating: "正在执行派生..." generate_terrain: "Generate Terrain" more: "更多" wiki: "维基" live_chat: "在线聊天" -# thang_main: "Main" + thang_main: "主菜单" # thang_spritesheets: "Spritesheets" -# thang_colors: "Colors" + thang_colors: "颜色" level_some_options: "有哪些选项?" level_tab_thangs: "物体" level_tab_scripts: "脚本" @@ -636,12 +636,12 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese level_tab_thangs_all: "所有" level_tab_thangs_conditions: "启动条件" level_tab_thangs_add: "增加物体" -# add_components: "Add Components" + add_components: "添加组件" # component_configs: "Component Configurations" # config_thang: "Double click to configure a thang" delete: "删除" duplicate: "复制" -# stop_duplicate: "Stop Duplicate" + stop_duplicate: "停止复制" rotate: "旋转" level_settings_title: "设置" level_component_tab_title: "目前所有组件" @@ -795,12 +795,12 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese fight: "战斗!" watch_victory: "观看你的胜利" defeat_the: "击败了" -# tournament_ends: "Tournament ends" -# tournament_ended: "Tournament ended" + tournament_ends: "锦标赛结束" + tournament_ended: "Tournament ended" tournament_rules: "锦标赛规则" # tournament_blurb: "Write code, collect gold, build armies, crush foes, win prizes, and upgrade your career in our $40,000 Greed tournament! Check out the details" tournament_blurb_criss_cross: "赢得竞拍,建造道路,智胜对手,夺取宝石,在纵横交错锦标赛中完成生涯晋级! 现在就查看详情!" -# tournament_blurb_blog: "on our blog" + tournament_blurb_blog: "关注我们的博客" rules: "规则" winners: "胜利者" @@ -825,7 +825,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese achievement: "成就" category_contributor: "贡献" # category_ladder: "Ladder" -# category_level: "Level" + category_level: "等级" category_miscellaneous: "其他" category_levels: "等级" category_undefined: "未分类" @@ -840,18 +840,18 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese account: recently_played: "最近玩过的关卡" no_recent_games: "最近两个星期没有玩过游戏。" -# payments: "Payments" -# purchased: "Purchased" -# subscription: "Subscription" -# service_apple: "Apple" -# service_web: "Web" + payments: "支付方式" + purchased: "已购买" + subscription: "订阅" + service_apple: "设备:Apple" + service_web: "设备:Web" # paid_on: "Paid On" service: "服务" price: "价格" gems: "宝石" # active: "Active" -# subscribed: "Subscribed" -# unsubscribed: "Unsubscribed" + subscribed: "已订阅" + unsubscribed: "取消订阅" # active_until: "Active Until" # cost: "Cost" # next_payment: "Next Payment" @@ -1013,7 +1013,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese # credits: "credits" # one_month_coupon: "coupon: choose either Rails or HTML" # one_month_discount: "discount, 30% off: choose either Rails or HTML" -# license: "license" + license: "证书" # oreilly: "ebook of your choice" account_profile: @@ -1078,7 +1078,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese skills_header: "技能" skills_help: "按照熟练程度列出你所掌握的与开发有关的技能。" long_description_header: "详细描述你所期望的职位" -# long_description_blurb: "Tell employers how awesome you are and what role you want." + long_description_blurb: "告诉我们你的优点与你感兴趣的职位" long_description: "描述" long_description_help: "向潜在的雇主描述你自己。尽量简明扼要。我们建议你列出你最感兴趣的职位。请勿超过600个字符。" work_experience: "工作经验" diff --git a/app/styles/editor/campaign/campaign-analytics-modal.sass b/app/styles/editor/campaign/campaign-analytics-modal.sass new file mode 100644 index 000000000..40d19a78d --- /dev/null +++ b/app/styles/editor/campaign/campaign-analytics-modal.sass @@ -0,0 +1,29 @@ +#campaign-analytics-modal + .modal-dialog + width: 75% + .level-name-container + position: relative + .level-name-background + position: absolute + height: 100% + left: 0px + top: 0px + background-color: green + opacity: 0.25 + .level-completion-container + position: relative + .level-completion-background + position: absolute + height: 100% + width: 100% + left: 0px + top: 0px + .level-playtime-container + position: relative + .level-playtime-background + position: absolute + height: 100% + left: 0px + top: 0px + background-color: green + opacity: 0.25 diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass index af81785e5..8ce962a8d 100644 --- a/app/styles/editor/campaign/campaign-editor-view.sass +++ b/app/styles/editor/campaign/campaign-editor-view.sass @@ -21,13 +21,3 @@ bottom: 0 right: 0 width: 75% - - #analytics-button - position: absolute - right: 1% - top: 1% - padding: 3px 8px - - #analytics-modal - .modal-content - background-color: white diff --git a/app/templates/editor/campaign/campaign-analytics-modal.jade b/app/templates/editor/campaign/campaign-analytics-modal.jade index a19be9ac9..db429e173 100644 --- a/app/templates/editor/campaign/campaign-analytics-modal.jade +++ b/app/templates/editor/campaign/campaign-analytics-modal.jade @@ -16,21 +16,50 @@ block modal-body-content td Level td Started td Finished + td Left Game + td LG % td Playtime (s) + td LG/s td Completion % tbody - for (var i = 0; i < campaignCompletions.levels.length; i++) tr - td= campaignCompletions.levels[i].level + td.level-name-container= campaignCompletions.levels[i].level + span.level-name-background(style="width:#{campaignCompletions.levels[i].usersRemaining || 0}%;") td= campaignCompletions.levels[i].started td= campaignCompletions.levels[i].finished - td= campaignCompletions.levels[i].averagePlaytime - if campaignCompletions.top3.indexOf(campaignCompletions.levels[i].level) >= 0 - td(style='background-color:lightblue;')= campaignCompletions.levels[i].completionRate - else if campaignCompletions.bottom3.indexOf(campaignCompletions.levels[i].level) >= 0 - td(style='background-color:pink;')= campaignCompletions.levels[i].completionRate + td= campaignCompletions.levels[i].dropped + if campaignCompletions.levels[i].dropPercentage + if campaignCompletions.top3DropPercentage && campaignCompletions.top3DropPercentage.indexOf(campaignCompletions.levels[i].level) >= 0 + td(style='background-color:pink;')= campaignCompletions.levels[i].dropPercentage.toFixed(2) + else + td= campaignCompletions.levels[i].dropPercentage.toFixed(2) else - td= campaignCompletions.levels[i].completionRate + td + if campaignCompletions.levels[i].averagePlaytime + td.level-playtime-container= campaignCompletions.levels[i].averagePlaytime.toFixed(2) + span.level-playtime-background(style="width:#{campaignCompletions.levels[i].playtimePercentage || 0}%;") + else + td + if campaignCompletions.levels[i].droppedPerSecond + if campaignCompletions.top3DropPerSecond && campaignCompletions.top3DropPerSecond.indexOf(campaignCompletions.levels[i].level) >= 0 + td(style='background-color:pink;')= campaignCompletions.levels[i].droppedPerSecond.toFixed(2) + else + td= campaignCompletions.levels[i].droppedPerSecond.toFixed(2) + else + td + if campaignCompletions.levels[i].completionRate + if campaignCompletions.top3 && campaignCompletions.top3.indexOf(campaignCompletions.levels[i].level) >= 0 + td.level-completion-container(style='background-color:lightblue;')= campaignCompletions.levels[i].completionRate.toFixed(2) + svg.level-completion-background(id="background#{campaignCompletions.levels[i].level}") + else if campaignCompletions.bottom3 && campaignCompletions.bottom3.indexOf(campaignCompletions.levels[i].level) >= 0 + td.level-completion-container(style='background-color:pink;')= campaignCompletions.levels[i].completionRate.toFixed(2) + svg.level-completion-background(id="background#{campaignCompletions.levels[i].level}") + else + td.level-completion-container= campaignCompletions.levels[i].completionRate.toFixed(2) + svg.level-completion-background(id="background#{campaignCompletions.levels[i].level}") + else + td else div Loading... diff --git a/app/templates/editor/campaign/campaign-level-view.jade b/app/templates/editor/campaign/campaign-level-view.jade index 35f55e9b8..bf2f53a8a 100644 --- a/app/templates/editor/campaign/campaign-level-view.jade +++ b/app/templates/editor/campaign/campaign-level-view.jade @@ -14,6 +14,11 @@ td Started td Finished td Completion % + if levelHelps && levelHelps.length === levelCompletions.length + td Helps Clicked + td Helps / Started + td Help Videos + td Videos / Started tbody - for (var i = 0; i < levelCompletions.length; i++) tr @@ -21,6 +26,11 @@ td= levelCompletions[i].started td= levelCompletions[i].finished td= levelCompletions[i].rate + if levelHelps && levelHelps.length === levelCompletions.length + td= levelHelps[i].alertHelps + levelHelps[i].paletteHelps + td= ((levelHelps[i].alertHelps + levelHelps[i].paletteHelps) / levelCompletions[i].started).toFixed(2) + td= levelHelps[i].videoStarts + td= (levelHelps[i].videoStarts / levelCompletions[i].started).toFixed(2) else div Loading... diff --git a/app/views/editor/campaign/CampaignAnalyticsModal.coffee b/app/views/editor/campaign/CampaignAnalyticsModal.coffee index 0b47bf6b5..e2c33c038 100644 --- a/app/views/editor/campaign/CampaignAnalyticsModal.coffee +++ b/app/views/editor/campaign/CampaignAnalyticsModal.coffee @@ -1,5 +1,6 @@ template = require 'templates/editor/campaign/campaign-analytics-modal' utils = require 'core/utils' +require 'vendor/d3' ModalView = require 'views/core/ModalView' # TODO: jquery-ui datepicker doesn't work well in this view @@ -26,53 +27,101 @@ module.exports = class CampaignAnalyticsModal extends ModalView super() $("#input-startday").datepicker dateFormat: "yy-mm-dd" $("#input-endday").datepicker dateFormat: "yy-mm-dd" + @addCompletionLineGraphs() + + addCompletionLineGraphs: -> + return unless @campaignCompletions.levels + for level in @campaignCompletions.levels + days = [] + for day of level['days'] + continue unless level['days'][day].started > 0 + days.push + day: day + rate: level['days'][day].finished / level['days'][day].started + days.sort (a, b) -> a.day - b.day + data = [] + for i in [0...days.length] + data.push + x: i + y: days[i].rate + @addLineGraph '#background' + level.level, data + + addLineGraph: (containerSelector, lineData, lineColor='green', min=0, max=1.0) -> + # Add a line chart to the given container + # TODO: Move this to a utility library + vis = d3.select(containerSelector) + width = $(containerSelector).width() + height = $(containerSelector).height() + xRange = d3.scale.linear().range([0, width]).domain([d3.min(lineData, (d) -> d.x), d3.max(lineData, (d) -> d.x)]) + yRange = d3.scale.linear().range([height, 0]).domain([min, max]) + xAxis = d3.svg.axis() + .scale(xRange) + .tickSize(5) + .tickSubdivide(true) + yAxis = d3.svg.axis() + .scale(yRange) + .tickSize(5) + .orient('left') + .tickSubdivide(true) + vis.append('svg:g') + .attr('class', 'x axis') + .attr('transform', 'translate(0,' + height + ')') + .call(xAxis) + vis.append('svg:g') + .attr('class', 'y axis') + .attr('transform', 'translate(0,0)') + .call(yAxis) + lineFunc = d3.svg.line() + .x((d) -> xRange(d.x)) + .y((d) -> yRange(d.y)) + .interpolate('linear') + vis.append('svg:path') + .attr('d', lineFunc(lineData)) + .attr('stroke', lineColor) + .attr('stroke-width', 1) + .attr('fill', 'none') onClickReloadButton: () => startDay = $('#input-startday').val() endDay = $('#input-endday').val() delete @campaignCompletions.levels + @campaignCompletions.startDay = startDay + @campaignCompletions.endDay = endDay @render() @getCampaignAnalytics startDay, endDay getCampaignAnalytics: (startDay, endDay) => - # Fetch campaign analytics, unless dates given + if startDay? + startDayDashed = startDay + startDay = startDay.replace(/-/g, '') + else + startDay = utils.getUTCDay -14 + startDayDashed = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}" + if endDay? + endDayDashed = endDay + endDay = endDay.replace(/-/g, '') + else + endDay = utils.getUTCDay -1 + endDayDashed = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}" + @campaignCompletions.startDay = startDayDashed + @campaignCompletions.endDay = endDayDashed - startDay = startDay.replace(/-/g, '') if startDay? - endDay = endDay.replace(/-/g, '') if endDay? + # Chain these together so we can calculate relative metrics (e.g. left game per second) + @getCampaignLevelCompletions startDay, endDay, () => + @render() + @getCompaignLevelDrops startDay, endDay, () => + @render() + @getCampaignAveragePlaytimes startDayDashed, endDayDashed, () => + @render() - startDay ?= utils.getUTCDay -14 - endDay ?= utils.getUTCDay -1 - - success = (data) => - return if @destroyed - mapFn = (item) -> - item.completionRate = (item.finished / item.started * 100).toFixed(2) - item - @campaignCompletions.levels = _.map data, mapFn, @ - sortedLevels = _.cloneDeep @campaignCompletions.levels - sortedLevels = _.filter sortedLevels, ((a) -> a.finished >= 10), @ - sortedLevels.sort (a, b) -> b.completionRate - a.completionRate - @campaignCompletions.top3 = _.pluck sortedLevels[0..2], 'level' - sortedLevels.sort (a, b) -> a.completionRate - b.completionRate - @campaignCompletions.bottom3 = _.pluck sortedLevels[0..2], 'level' - @campaignCompletions.startDay = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}" - @campaignCompletions.endDay = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}" - @getCampaignAveragePlaytimes startDay, endDay - - # TODO: Why do we need this url dash? - request = @supermodel.addRequestResource 'campaign_completions', { - url: '/db/analytics_perday/-/campaign_completions' - data: {startDay: startDay, endDay: endDay, slug: @campaignHandle} - method: 'POST' - success: success - }, 0 - request.load() - - getCampaignAveragePlaytimes: (startDay, endDay) => + getCampaignAveragePlaytimes: (startDay, endDay, doneCallback) => # Fetch level average playtimes + # Needs date format yyyy-mm-dd success = (data) => return if @destroyed + # console.log 'getCampaignAveragePlaytimes success', data levelAverages = {} + maxPlaytime = 0 for item in data levelAverages[item.level] ?= [] levelAverages[item.level].push item.average @@ -80,15 +129,23 @@ module.exports = class CampaignAnalyticsModal extends ModalView if levelAverages[level.level] if levelAverages[level.level].length > 0 total = _.reduce levelAverages[level.level], ((sum, num) -> sum + num) - level.averagePlaytime = (total / levelAverages[level.level].length).toFixed(2) + level.averagePlaytime = total / levelAverages[level.level].length + maxPlaytime = level.averagePlaytime if maxPlaytime < level.averagePlaytime + if level.averagePlaytime > 0 and level.dropped > 0 + level.droppedPerSecond = level.dropped / level.averagePlaytime else level.averagePlaytime = 0.0 - @render() - startDay ?= utils.getUTCDay -14 - startDay = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}" - endDay ?= utils.getUTCDay -1 - endDay = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}" + addPlaytimePercentage = (item) -> + item.playtimePercentage = Math.round(item.averagePlaytime / maxPlaytime * 100.0) unless maxPlaytime is 0 + item + @campaignCompletions.levels = _.map @campaignCompletions.levels, addPlaytimePercentage, @ + + sortedLevels = _.cloneDeep @campaignCompletions.levels + sortedLevels = _.filter sortedLevels, ((a) -> a.droppedPerSecond > 0), @ + sortedLevels.sort (a, b) -> b.droppedPerSecond - a.droppedPerSecond + @campaignCompletions.top3DropPerSecond = _.pluck sortedLevels[0..2], 'level' + doneCallback() levelSlugs = _.pluck @campaignCompletions.levels, 'level' @@ -99,3 +156,73 @@ module.exports = class CampaignAnalyticsModal extends ModalView success: success }, 0 request.load() + + getCampaignLevelCompletions: (startDay, endDay, doneCallback) => + # Needs date format yyyymmdd + success = (data) => + return if @destroyed + # console.log 'getCampaignLevelCompletions success', data + countCompletions = (item) -> + item.started = _.reduce item.days, ((result, current) -> result + current.started), 0 + item.finished = _.reduce item.days, ((result, current) -> result + current.finished), 0 + item.completionRate = if item.started > 0 then item.finished / item.started * 100 else 0.0 + item + addUserRemaining = (item) -> + item.usersRemaining = Math.round(item.started / maxStarted * 100.0) unless maxStarted is 0 + item + + @campaignCompletions.levels = _.map data, countCompletions, @ + if @campaignCompletions.levels.length > 0 + maxStarted = (_.max @campaignCompletions.levels, ((a) -> a.started)).started + else + maxStarted = 0 + @campaignCompletions.levels = _.map @campaignCompletions.levels, addUserRemaining, @ + + sortedLevels = _.cloneDeep @campaignCompletions.levels + sortedLevels = _.filter sortedLevels, ((a) -> a.finished >= 10), @ + if sortedLevels.length >= 3 + sortedLevels.sort (a, b) -> b.completionRate - a.completionRate + @campaignCompletions.top3 = _.pluck sortedLevels[0..2], 'level' + @campaignCompletions.bottom3 = _.pluck sortedLevels[sortedLevels.length - 4...sortedLevels.length - 1], 'level' + + doneCallback() + + # TODO: Why do we need this url dash? + request = @supermodel.addRequestResource 'campaign_completions', { + url: '/db/analytics_perday/-/campaign_completions' + data: {startDay: startDay, endDay: endDay, slug: @campaignHandle} + method: 'POST' + success: success + }, 0 + request.load() + + getCompaignLevelDrops: (startDay, endDay, doneCallback) => + # Fetch level drops + # Needs date format yyyymmdd + success = (data) => + return if @destroyed + # console.log 'getCompaignLevelDrops success', data + levelDrops = {} + for item in data + levelDrops[item.level] ?= item.dropped + for level in @campaignCompletions.levels + level.dropped = levelDrops[level.level] ? 0 + level.dropPercentage = if level.started > 0 then level.dropped / level.started * 100 else 0.0 + + sortedLevels = _.cloneDeep @campaignCompletions.levels + sortedLevels = _.filter sortedLevels, ((a) -> a.dropPercentage > 0), @ + if sortedLevels.length >= 3 + sortedLevels.sort (a, b) -> b.dropPercentage - a.dropPercentage + @campaignCompletions.top3DropPercentage = _.pluck sortedLevels[0..2], 'level' + doneCallback() + + return unless @campaignCompletions?.levels? + levelSlugs = _.pluck @campaignCompletions.levels, 'level' + + request = @supermodel.addRequestResource 'level_drops', { + url: '/db/analytics_perday/-/level_drops' + data: {startDay: startDay, endDay: endDay, slugs: levelSlugs} + method: 'POST' + success: success + }, 0 + request.load() diff --git a/app/views/editor/campaign/CampaignLevelView.coffee b/app/views/editor/campaign/CampaignLevelView.coffee index 48bc8d90f..9c384d82d 100644 --- a/app/views/editor/campaign/CampaignLevelView.coffee +++ b/app/views/editor/campaign/CampaignLevelView.coffee @@ -22,6 +22,7 @@ module.exports = class CampaignLevelView extends CocoView @levelSlug = @level.get('slug') @getCommonLevelProblems() @getLevelCompletions() + @getLevelHelps() @getLevelPlaytimes() @getRecentSessions() @@ -30,6 +31,7 @@ module.exports = class CampaignLevelView extends CocoView c.level = if @fullLevel.loaded then @fullLevel else @level c.commonProblems = @commonProblems c.levelCompletions = @levelCompletions + c.levelHelps = @levelHelps c.levelPlaytimes = @levelPlaytimes c.recentSessions = @recentSessions c @@ -88,6 +90,23 @@ module.exports = class CampaignLevelView extends CocoView }, 0 request.load() + getLevelHelps: -> + # Fetch last 14 days of level completion counts + success = (data) => + return if @destroyed + @levelHelps = data.sort (a, b) -> if a.created < b.created then 1 else -1 + @render() + + startDay = utils.getUTCDay -14 + + request = @supermodel.addRequestResource 'level_helps', { + url: '/db/analytics_perday/-/level_helps' + data: {startDay: startDay, slugs: [@levelSlug]} + method: 'POST' + success: success + }, 0 + request.load() + getLevelPlaytimes: -> # Fetch last 14 days of level average playtimes success = (data) => diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index 86518d90d..20ab3c854 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -8,6 +8,8 @@ // Finish count for the same start date is how many unique users finished the remaining steps in the following ~30 days // https://mixpanel.com/help/questions/articles/how-are-funnels-calculated +// Drop count: last started or finished level event for a given unique user + // TODO: Why are Mixpanel level finish events significantly lower? // TODO: dungeons-of-kithgard completion rate is 62% vs. 77% // TODO: Similar start events, finish events off by 20% (5334 vs 6486) @@ -74,11 +76,12 @@ function getLevelFunnelData(startDay, eventFunnel) { var queryParams = {$and: [{_id: {$gte: startObj}},{"event": {$in: eventFunnel}}]}; var cursor = db['analytics.log.events'].find(queryParams); - // Map ordering: level, user, event, created + // Map ordering: level, user, event, day var userDataMap = {}; while (cursor.hasNext()) { var doc = cursor.next(); - var created = doc._id.getTimestamp().toISOString().substring(0, 10); + var created = doc._id.getTimestamp().toISOString(); + var day = created.substring(0, 10); var event = doc.event; var properties = doc.properties; var user = doc.user; @@ -91,13 +94,13 @@ function getLevelFunnelData(startDay, eventFunnel) { if (!userDataMap[level]) userDataMap[level] = {}; if (!userDataMap[level][user]) userDataMap[level][user] = {}; - if (!userDataMap[level][user][event] || userDataMap[level][user][event].localeCompare(created) > 0) { - // if (userDataMap[level][user][event]) log("Found earlier date " + level + " " + event + " " + user + " " + userDataMap[level][user][event] + " " + created); - userDataMap[level][user][event] = created; + if (!userDataMap[level][user][event] || userDataMap[level][user][event].localeCompare(day) > 0) { + // if (userDataMap[level][user][event]) log("Found earlier date " + level + " " + event + " " + user + " " + userDataMap[level][user][event] + " " + day); + userDataMap[level][user][event] = day; } } - // Data: level, created, event + // Data: level, day, event var levelFunnelData = {}; for (level in userDataMap) { for (user in userDataMap[level]) { @@ -105,14 +108,14 @@ function getLevelFunnelData(startDay, eventFunnel) { // Find first event date var funnelStartDay = null; for (event in userDataMap[level][user]) { - var created = userDataMap[level][user][event]; + var day = userDataMap[level][user][event]; if (!levelFunnelData[level]) levelFunnelData[level] = {}; - if (!levelFunnelData[level][created]) levelFunnelData[level][created] = {}; - if (!levelFunnelData[level][created][event]) levelFunnelData[level][created][event] = 0; + if (!levelFunnelData[level][day]) levelFunnelData[level][day] = {}; + if (!levelFunnelData[level][day][event]) levelFunnelData[level][day][event] = 0; if (eventFunnel[0] === event) { // First event gets attributed to current date - levelFunnelData[level][created][event]++; - funnelStartDay = created; + levelFunnelData[level][day][event]++; + funnelStartDay = day; break; } } @@ -135,6 +138,97 @@ function getLevelFunnelData(startDay, eventFunnel) { return levelFunnelData; } +function getLevelDropCounts(startDay, events) { + // How many unique users did one of these events last? + // Return level/day breakdown + + if (!startDay || !events || events.length === 0) return {}; + + var startObj = objectIdWithTimestamp(ISODate(startDay + "T00:00:00.000Z")); + var queryParams = {$and: [{_id: {$gte: startObj}},{"event": {$in: events}}]}; + var cursor = db['analytics.log.events'].find(queryParams); + + var userProgression = {}; + while (cursor.hasNext()) { + var doc = cursor.next(); + var created = doc._id.getTimestamp().toISOString(); + var event = doc.event; + var properties = doc.properties; + var user = doc.user; + var level; + + // TODO: Switch to properties.levelID for 'Saw Victory' + if (event === 'Saw Victory' && properties.level) level = properties.level.toLowerCase().replace(/ /g, '-'); + else if (properties.levelID) level = properties.levelID + else continue + + if (!userProgression[user]) userProgression[user] = []; + userProgression[user].push({ + created: created, + event: event, + level: level + }); + } + + var levelDropCounts = {}; + for (user in userProgression) { + userProgression[user].sort(function (a,b) {return a.created < b.created ? -1 : 1}); + var lastEvent = userProgression[user][userProgression[user].length - 1]; + var level = lastEvent.level; + var day = lastEvent.created.substring(0, 10); + if (!levelDropCounts[level]) levelDropCounts[level] = {}; + if (!levelDropCounts[level][day]) levelDropCounts[level][day] = 0 + levelDropCounts[level][day]++; + } + return levelDropCounts; +} + +function getLevelHelpCounts(startDay, events) { + if (!startDay || !events || events.length === 0) return {}; + + var startObj = objectIdWithTimestamp(ISODate(startDay + "T00:00:00.000Z")); + var queryParams = {$and: [{_id: {$gte: startObj}},{"event": {$in: events}}]}; + var cursor = db['analytics.log.events'].find(queryParams); + + // Map ordering: level, user, event, day + var userDataMap = {}; + while (cursor.hasNext()) { + var doc = cursor.next(); + var created = doc._id.getTimestamp().toISOString(); + var day = created.substring(0, 10); + var event = doc.event; + var properties = doc.properties; + var user = doc.user; + var level; + + if (properties.level) level = properties.level; + else if (properties.levelID) level = properties.levelID + else continue + + if (!userDataMap[level]) userDataMap[level] = {}; + if (!userDataMap[level][user]) userDataMap[level][user] = {}; + if (!userDataMap[level][user][event] || userDataMap[level][user][event].localeCompare(day) > 0) { + // if (userDataMap[level][user][event]) log("Found earlier date " + level + " " + event + " " + user + " " + userDataMap[level][user][event] + " " + day); + userDataMap[level][user][event] = day; + } + } + + // Data: level, day, event + var levelEventData = {}; + for (level in userDataMap) { + for (user in userDataMap[level]) { + for (event in userDataMap[level][user]) { + var day = userDataMap[level][user][event]; + if (!levelEventData[level]) levelEventData[level] = {}; + if (!levelEventData[level][day]) levelEventData[level][day] = {}; + if (!levelEventData[level][day][event]) levelEventData[level][day][event] = 0; + levelEventData[level][day][event]++; + } + } + } + return levelEventData; +} + function insertEventCount(event, level, day, count) { // analytics.perdays schema in server/analytics/AnalyticsPeryDay.coffee day = day.replace(/-/g, ''); @@ -153,7 +247,7 @@ function insertEventCount(event, level, day, count) { // log("Updating count in db for " + day + " " + event + " " + level + " " + doc.c + " => " + count); var results = db['analytics.perdays'].update(queryParams, {$set: {c: count}}); if (results.nMatched !== 1 && results.nModified !== 1) { - log("ERROR: update count failed"); + log("ERROR: update event count failed"); printjson(results); } } @@ -174,31 +268,53 @@ function insertEventCount(event, level, day, count) { try { // Look at last 30 days, same as Mixpanel var numDays = 30; - + var startDay = new Date(); today = startDay.toISOString().substr(0, 10); startDay.setUTCDate(startDay.getUTCDate() - numDays); startDay = startDay.toISOString().substr(0, 10); var levelCompletionFunnel = ['Started Level', 'Saw Victory']; + var levelHelpEvents = ['Problem alert help clicked', 'Spell palette help clicked', 'Start help video']; log("Today is " + today); log("Start day is " + startDay); log("Funnel events are " + levelCompletionFunnel); - + log("Getting level completion data..."); var levelCompletionData = getLevelFunnelData(startDay, levelCompletionFunnel); - log("Inserting aggregated level completion data..."); for (level in levelCompletionData) { - for (created in levelCompletionData[level]) { - if (today === created) continue; // Never save data for today because it's incomplete - for (event in levelCompletionData[level][created]) { - insertEventCount(event, level, created, levelCompletionData[level][created][event]); + for (day in levelCompletionData[level]) { + if (today === day) continue; // Never save data for today because it's incomplete + for (event in levelCompletionData[level][day]) { + insertEventCount(event, level, day, levelCompletionData[level][day][event]); } } } -} + + log("Getting level drop counts..."); + var levelDropCounts = getLevelDropCounts(startDay, levelCompletionFunnel) + log("Inserting level drop counts..."); + for (level in levelDropCounts) { + for (day in levelDropCounts[level]) { + if (today === day) continue; // Never save data for today because it's incomplete + insertEventCount('User Dropped', level, day, levelDropCounts[level][day]); + } + } + + log("Getting level help counts..."); + var levelHelpCounts = getLevelHelpCounts(startDay, levelHelpEvents) + log("Inserting level help counts..."); + for (level in levelHelpCounts) { + for (day in levelHelpCounts[level]) { + if (today === day) continue; // Never save data for today because it's incomplete + for (event in levelHelpCounts[level][day]) { + insertEventCount(event, level, day, levelHelpCounts[level][day][event]); + } + } + } +} catch(err) { log("ERROR: " + err); printjson(err); diff --git a/server/analytics/analytics_perday_handler.coffee b/server/analytics/analytics_perday_handler.coffee index c5cbde6b8..e5e4a8e31 100644 --- a/server/analytics/analytics_perday_handler.coffee +++ b/server/analytics/analytics_perday_handler.coffee @@ -15,10 +15,12 @@ class AnalyticsPerDayHandler extends Handler getByRelationship: (req, res, args...) -> return @getCampaignCompletionsBySlug(req, res) if args[1] is 'campaign_completions' return @getLevelCompletionsBySlug(req, res) if args[1] is 'level_completions' + return @getLevelDropsBySlugs(req, res) if args[1] is 'level_drops' + return @getLevelHelpsBySlugs(req, res) if args[1] is 'level_helps' super(arguments...) getCampaignCompletionsBySlug: (req, res) -> - # Send back an array of level starts and finishes + # Send back an ordered array of level per-day starts and finishes # Parameters: # slug - campaign slug # startDay - Inclusive, optional, YYYYMMDD e.g. '20141214' @@ -41,7 +43,7 @@ class AnalyticsPerDayHandler extends Handler cacheKey = campaignSlug cacheKey += 's' + startDay if startDay? cacheKey += 'e' + endDay if endDay? - return @sendSuccess res, campaignDropOffs if campaignDropOffs = @campaignCompletionsCache[cacheKey] + return @sendSuccess res, completions if completions = @campaignCompletionsCache[cacheKey] getCompletions = (orderedLevelSlugs, levelStringIDSlugMap) => # 3. Send back an array of level starts and finishes @@ -73,15 +75,20 @@ class AnalyticsPerDayHandler extends Handler levelEventCounts = {} for doc in documents levelEventCounts[doc.l] ?= {} - levelEventCounts[doc.l][doc.e] ?= 0 - levelEventCounts[doc.l][doc.e] += doc.c + levelEventCounts[doc.l][doc.d] ?= {} + levelEventCounts[doc.l][doc.d][doc.e] ?= 0 + levelEventCounts[doc.l][doc.d][doc.e] += doc.c completions = [] for levelID of levelEventCounts + days = {} + for day of levelEventCounts[levelID] + days[day] = + started: levelEventCounts[levelID][day][startEventID] ? 0 + finished: levelEventCounts[levelID][day][finishEventID] ? 0 completions.push level: levelStringIDSlugMap[levelID] - started: levelEventCounts[levelID][startEventID] - finished: levelEventCounts[levelID][finishEventID] + days: days completions.sort (a, b) -> orderedLevelSlugs.indexOf(a.level) - orderedLevelSlugs.indexOf(b.level) @campaignCompletionsCache[cacheKey] = completions @@ -124,6 +131,69 @@ class AnalyticsPerDayHandler extends Handler campaignLevels.push level for level of doc.get('levels') for doc in documents getLevelData campaignLevels + getLevelDropsBySlugs: (req, res) -> + # Send back an array of level/drops + # Drops - Number of unique users for which this was the last level they played + # Parameters: + # slugs - level slugs + # startDay - Inclusive, optional, YYYYMMDD e.g. '20141214' + # endDay - Exclusive, optional, YYYYMMDD e.g. '20141216' + + levelSlugs = req.query.slugs or req.body.slugs + startDay = req.query.startDay or req.body.startDay + endDay = req.query.endDay or req.body.endDay + + # log.warn "level_drops levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}" + + return @sendSuccess res, [] unless levelSlugs? + + # Cache results in app server memory for 1 day + @levelDropsCache ?= {} + @levelDropsCachedSince ?= new Date() + if (new Date()) - @levelDropsCachedSince > 86400 * 1000 + @levelDropsCache = {} + @levelDropsCachedSince = new Date() + cacheKey = levelSlugs.join '' + cacheKey += 's' + startDay if startDay? + cacheKey += 'e' + endDay if endDay? + return @sendSuccess res, drops if drops = @levelDropsCache[cacheKey] + + AnalyticsString.find({v: {$in: ['User Dropped', 'all'].concat(levelSlugs)}}).exec (err, documents) => + if err? then return @sendDatabaseError res, err + + levelStringIDSlugMap = {} + for doc in documents + droppedEventID = doc._id if doc.v is 'User Dropped' + filterEventID = doc._id if doc.v is 'all' + levelStringIDSlugMap[doc._id] = doc.v if doc.v in levelSlugs + + return @sendSuccess res, [] unless droppedEventID? and filterEventID? + + queryParams = {$and: [ + {e: droppedEventID}, + {f: filterEventID}, + {l: {$in: Object.keys(levelStringIDSlugMap)}} + ]} + queryParams["$and"].push {d: {$gte: startDay}} if startDay? + queryParams["$and"].push {d: {$lt: endDay}} if endDay? + AnalyticsPerDay.find(queryParams).exec (err, documents) => + if err? then return @sendDatabaseError res, err + + levelEventCounts = {} + for doc in documents + levelEventCounts[doc.l] ?= {} + levelEventCounts[doc.l][doc.e] ?= 0 + levelEventCounts[doc.l][doc.e] += doc.c + + drops = [] + for levelID of levelEventCounts + drops.push + level: levelStringIDSlugMap[levelID] + dropped: levelEventCounts[levelID][droppedEventID] ? 0 + + @levelDropsCache[cacheKey] = drops + @sendSuccess res, drops + getLevelCompletionsBySlug: (req, res) -> # Returns an array of per-day starts and finishes for given level # Parameters: @@ -190,4 +260,78 @@ class AnalyticsPerDayHandler extends Handler @levelCompletionsCache[cacheKey] = completions @sendSuccess res, completions + getLevelHelpsBySlugs: (req, res) -> + # Send back an array of per-day level help buttons clicked and videos started + # Parameters: + # slugs - level slugs + # startDay - Inclusive, optional, YYYYMMDD e.g. '20141214' + # endDay - Exclusive, optional, YYYYMMDD e.g. '20141216' + + levelSlugs = req.query.slugs or req.body.slugs + startDay = req.query.startDay or req.body.startDay + endDay = req.query.endDay or req.body.endDay + + # log.warn "level_helps levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}" + + return @sendSuccess res, [] unless levelSlugs? + + # Cache results in app server memory for 1 day + @levelHelpsCache ?= {} + @levelHelpsCachedSince ?= new Date() + if (new Date()) - @levelHelpsCachedSince > 86400 * 1000 + @levelHelpsCache = {} + @levelHelpsCachedSince = new Date() + cacheKey = levelSlugs.join '' + cacheKey += 's' + startDay if startDay? + cacheKey += 'e' + endDay if endDay? + return @sendSuccess res, helps if helps = @levelHelpsCache[cacheKey] + + findQueryParams = {v: {$in: ['Problem alert help clicked', 'Spell palette help clicked', 'Start help video', 'all'].concat(levelSlugs)}} + AnalyticsString.find(findQueryParams).exec (err, documents) => + if err? then return @sendDatabaseError res, err + + levelStringIDSlugMap = {} + for doc in documents + alertHelpEventID = doc._id if doc.v is 'Problem alert help clicked' + palettteHelpEventID = doc._id if doc.v is 'Spell palette help clicked' + videoHelpEventID = doc._id if doc.v is 'Start help video' + filterEventID = doc._id if doc.v is 'all' + levelStringIDSlugMap[doc._id] = doc.v if doc.v in levelSlugs + + return @sendSuccess res, [] unless alertHelpEventID? and palettteHelpEventID? and videoHelpEventID? and filterEventID? + + queryParams = {$and: [ + {e: {$in: [alertHelpEventID, palettteHelpEventID, videoHelpEventID]}}, + {f: filterEventID}, + {l: {$in: Object.keys(levelStringIDSlugMap)}} + ]} + queryParams["$and"].push {d: {$gte: startDay}} if startDay? + queryParams["$and"].push {d: {$lt: endDay}} if endDay? + AnalyticsPerDay.find(queryParams).exec (err, documents) => + if err? then return @sendDatabaseError res, err + + levelEventCounts = {} + for doc in documents + levelEventCounts[doc.l] ?= {} + levelEventCounts[doc.l][doc.d] ?= {} + levelEventCounts[doc.l][doc.d][doc.e] ?= 0 + levelEventCounts[doc.l][doc.d][doc.e] += doc.c + + helps = [] + for levelID of levelEventCounts + for day of levelEventCounts[levelID] + for eventID of levelEventCounts[levelID][day] + alertHelps = levelEventCounts[levelID][day][eventID] if parseInt(eventID) is alertHelpEventID + paletteHelps = levelEventCounts[levelID][day][eventID] if parseInt(eventID) is palettteHelpEventID + videoStarts = levelEventCounts[levelID][day][eventID] if parseInt(eventID) is videoHelpEventID + helps.push + level: levelStringIDSlugMap[levelID] + day: day + alertHelps: alertHelps ? 0 + paletteHelps: paletteHelps ? 0 + videoStarts: videoStarts ? 0 + + @levelHelpsCache[cacheKey] = helps + @sendSuccess res, helps + module.exports = new AnalyticsPerDayHandler() diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index 4e86301f5..1831b5b35 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -7,6 +7,7 @@ Handler = require '../commons/Handler' mongoose = require 'mongoose' async = require 'async' utils = require '../lib/utils' +log = require 'winston' LevelHandler = class LevelHandler extends Handler modelClass: Level @@ -363,6 +364,8 @@ LevelHandler = class LevelHandler extends Handler return @sendSuccess res, [] unless levelSlugs? + # log.warn "playtime_averages levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}" + # Cache results for 1 day @levelPlaytimesCache ?= {} @levelPlaytimesCachedSince ?= new Date()