Merge branch 'master' into production

This commit is contained in:
Nick Winter 2015-01-07 15:04:54 -08:00
commit 3ba746094a
24 changed files with 370 additions and 210 deletions

View file

@ -131,6 +131,18 @@ module.exports.kindaEqual = compare = (l, r) ->
else else
return false return false
# Return UTC string "YYYY-MM-DD" for today + offset
module.exports.getUTCDay = (offset=0) ->
# TODO: Move to utility
day = new Date()
day.setDate(day.getUTCDate() + offset)
partYear = day.getUTCFullYear()
partMonth = (day.getUTCMonth() + 1)
partMonth = "0" + partMonth if partMonth < 10
partDay = day.getUTCDate()
partDay = "0" + partDay if partDay < 10
"#{partYear}-#{partMonth}-#{partDay}"
# Fast, basic way to replace text in an element when you don't need much. # Fast, basic way to replace text in an element when you don't need much.
# http://stackoverflow.com/a/4962398/540620 # http://stackoverflow.com/a/4962398/540620
if document? if document?

View file

@ -159,6 +159,10 @@
date: "Date" date: "Date"
body: "Body" body: "Body"
version: "Version" version: "Version"
pending: "Pending"
accepted: "Accepted"
rejected: "Rejected"
withdrawn: "Withdrawn"
submitter: "Submitter" submitter: "Submitter"
submitted: "Submitted" submitted: "Submitted"
commit_msg: "Commit Message" commit_msg: "Commit Message"

View file

@ -145,13 +145,13 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
fork: "Bifurcar" fork: "Bifurcar"
play: "Jugar" # When used as an action verb, like "Play next level" play: "Jugar" # When used as an action verb, like "Play next level"
retry: "Reintentar" retry: "Reintentar"
# actions: "Actions" actions: "Acciones"
# info: "Info" # info: "Info"
# help: "Help" help: "Ayuda"
watch: "Mirar" watch: "Mirar"
unwatch: "Pasar" unwatch: "Pasar"
submit_patch: "Mandar Parche" submit_patch: "Enviar Parche"
# submit_changes: "Submit Changes" submit_changes: "Enviar Cambios"
general: general:
and: "y" and: "y"
@ -165,10 +165,10 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
# review: "Review" # review: "Review"
version_history: "Historial de versión" version_history: "Historial de versión"
version_history_for: "Historial de las versiones de: " version_history_for: "Historial de las versiones de: "
# select_changes: "Select two changes below to see the difference." select_changes: "Selecciona dos cambios más abajo para ver la diferencia."
# undo_prefix: "Undo" undo_prefix: "Deshacer"
# undo_shortcut: "(Ctrl+Z)" # undo_shortcut: "(Ctrl+Z)"
# redo_prefix: "Redo" redo_prefix: "Rehacer"
# redo_shortcut: "(Ctrl+Shift+Z)" # redo_shortcut: "(Ctrl+Shift+Z)"
# play_preview: "Play preview of current level" # play_preview: "Play preview of current level"
result: "Resultado" result: "Resultado"
@ -233,7 +233,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
reload_title: "¿Recargar todo el código?" reload_title: "¿Recargar todo el código?"
reload_really: "¿Estas seguro que quieres reiniciar el nivel?" reload_really: "¿Estas seguro que quieres reiniciar el nivel?"
reload_confirm: "Recargarlo todo" reload_confirm: "Recargarlo todo"
# victory: "Victory" victory: "Victoria"
victory_title_prefix: "¡" victory_title_prefix: "¡"
victory_title_suffix: " Completado!" victory_title_suffix: " Completado!"
victory_sign_up: "Regístrate para recibir actualizaciones." victory_sign_up: "Regístrate para recibir actualizaciones."
@ -246,8 +246,8 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
victory_review: "¡Cuéntanos más!" # Only in old-style levels. victory_review: "¡Cuéntanos más!" # Only in old-style levels.
victory_hour_of_code_done: "¿Ya terminaste?" victory_hour_of_code_done: "¿Ya terminaste?"
victory_hour_of_code_done_yes: "Si, ¡He terminado con mi hora de código!" victory_hour_of_code_done_yes: "Si, ¡He terminado con mi hora de código!"
# victory_experience_gained: "XP Gained" victory_experience_gained: "XP Conseguida"
# victory_gems_gained: "Gems Gained" victory_gems_gained: "Gemas Conseguidas"
guide_title: "Guía" guide_title: "Guía"
tome_minion_spells: "Los hechizos de tus súbditos" # Only in old-style levels. tome_minion_spells: "Los hechizos de tus súbditos" # Only in old-style levels.
tome_read_only_spells: "Hechizos de solo lectura" # Only in old-style levels. tome_read_only_spells: "Hechizos de solo lectura" # Only in old-style levels.
@ -271,7 +271,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
loading_ready: "¡Listo!" loading_ready: "¡Listo!"
loading_start: "Iniciar Nivel" loading_start: "Iniciar Nivel"
problem_alert_title: "Arregla tu código" problem_alert_title: "Arregla tu código"
# problem_alert_help: "Help" problem_alert_help: "Ayuda"
time_current: "Ahora:" time_current: "Ahora:"
time_total: "Máx:" time_total: "Máx:"
time_goto: "Ir a:" time_goto: "Ir a:"
@ -304,7 +304,7 @@ module.exports = nativeDescription: "español (ES)", englishDescription: "Spanis
tip_talk_is_cheap: "Hablar es fácil. Enséñame el código. - Linus Torvalds" tip_talk_is_cheap: "Hablar es fácil. Enséñame el código. - Linus Torvalds"
tip_first_language: "La cosa más desastrosa que puedes aprender es tu primer lenguaje de programación. - Alan Kay" tip_first_language: "La cosa más desastrosa que puedes aprender es tu primer lenguaje de programación. - Alan Kay"
tip_hardware_problem: "P: Cuantos programadores hacen falta para cambiar una bombilla? R: Ninguno, es un problema de hardware." tip_hardware_problem: "P: Cuantos programadores hacen falta para cambiar una bombilla? R: Ninguno, es un problema de hardware."
# 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: "Ley de Hofstadter: Siempre lleva más tiempo de lo que esperas, incluso cuando tienes en cuenta la Ley de Hofstadter."
tip_premature_optimization: "La optimizacion prematura es la raiz de todo mal. - Donald Knuth" tip_premature_optimization: "La optimizacion prematura es la raiz de todo mal. - Donald Knuth"
tip_brute_force: "Cuando haya dudas, usa la fuerza bruta. - Ken Thompson" tip_brute_force: "Cuando haya dudas, usa la fuerza bruta. - Ken Thompson"
tip_extrapolation: "Existen solo dos clases de personas: aquellos que pueden extrapolar desde información incompleta..." tip_extrapolation: "Existen solo dos clases de personas: aquellos que pueden extrapolar desde información incompleta..."

View file

@ -68,7 +68,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
change_hero: "重新选择英雄" # Go back from choose inventory to choose hero change_hero: "重新选择英雄" # Go back from choose inventory to choose hero
choose_inventory: "装备道具" choose_inventory: "装备道具"
buy_gems: "购买宝石" buy_gems: "购买宝石"
# campaign_desert: "Desert Campaign" campaign_desert: "沙漠战役"
campaign_forest: "森林战役" campaign_forest: "森林战役"
campaign_dungeon: "地牢战役" campaign_dungeon: "地牢战役"
subscription_required: "需订阅" subscription_required: "需订阅"
@ -106,9 +106,9 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
load_profile: "载入 G+ 档案" load_profile: "载入 G+ 档案"
load_email: "载入 G+ 电子邮件" load_email: "载入 G+ 电子邮件"
finishing: "完成..." finishing: "完成..."
# sign_in_with_facebook: "Sign in with Facebook" sign_in_with_facebook: "Facebook账号登录"
# sign_in_with_gplus: "Sign in with G+" sign_in_with_gplus: " G+ 账号登录"
# signup_switch: "Want to create an account?" signup_switch: "是否创建新账户?"
signup: signup:
email_announcements: "通过邮件接收通知" email_announcements: "通过邮件接收通知"
@ -117,7 +117,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
log_in: "登录" log_in: "登录"
social_signup: "或者你可以通过Facebook或G+注册:" social_signup: "或者你可以通过Facebook或G+注册:"
required: "在做这件事情之前你必须先注册。" required: "在做这件事情之前你必须先注册。"
# login_switch: "Already have an account?" login_switch: "已经注册过账户?"
recover: recover:
recover_account_title: "找回账户" recover_account_title: "找回账户"
@ -145,13 +145,13 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
fork: "派生" fork: "派生"
play: "开始" # When used as an action verb, like "Play next level" play: "开始" # When used as an action verb, like "Play next level"
retry: "重试" retry: "重试"
# actions: "Actions" actions: "行为"
# info: "Info" info: "信息"
help: "帮助" help: "帮助"
watch: "关注" watch: "关注"
unwatch: "取消关注" unwatch: "取消关注"
submit_patch: "提交补丁" submit_patch: "提交补丁"
# submit_changes: "Submit Changes" submit_changes: "提交更新"
general: general:
and: "" and: ""
@ -159,18 +159,18 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
date: "日期" date: "日期"
body: "正文" body: "正文"
version: "版本" version: "版本"
# submitter: "Submitter" submitter: "提交者"
# submitted: "Submitted" submitted: "已提交"
commit_msg: "提交信息" commit_msg: "提交信息"
# review: "Review" review: "查看"
version_history: "版本历史" version_history: "版本历史"
version_history_for: "版本历史: " version_history_for: "版本历史: "
# select_changes: "Select two changes below to see the difference." select_changes: "选择下面两项更新来查看其不同。"
# undo_prefix: "Undo" undo_prefix: "取消"
# undo_shortcut: "(Ctrl+Z)" undo_shortcut: "(Ctrl+Z)"
# redo_prefix: "Redo" redo_prefix: "重做"
# redo_shortcut: "(Ctrl+Shift+Z)" redo_shortcut: "(Ctrl+Shift+Z)"
# play_preview: "Play preview of current level" play_preview: "当前关卡预览"
result: "结果" result: "结果"
results: "结果" results: "结果"
description: "描述" description: "描述"
@ -233,7 +233,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
reload_title: "重载所有代码?" reload_title: "重载所有代码?"
reload_really: "确定重载这一关,返回开始处吗?" reload_really: "确定重载这一关,返回开始处吗?"
reload_confirm: "重载所有" reload_confirm: "重载所有"
# victory: "Victory" victory: "胜利"
victory_title_prefix: "" victory_title_prefix: ""
victory_title_suffix: " 完成" victory_title_suffix: " 完成"
victory_sign_up: "保存进度" victory_sign_up: "保存进度"
@ -246,8 +246,8 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
victory_review: "给我们反馈!" # Only in old-style levels. victory_review: "给我们反馈!" # Only in old-style levels.
victory_hour_of_code_done: "你完成了吗?" victory_hour_of_code_done: "你完成了吗?"
victory_hour_of_code_done_yes: "是的, 完成了!" victory_hour_of_code_done_yes: "是的, 完成了!"
# victory_experience_gained: "XP Gained" victory_experience_gained: "获得经验"
# victory_gems_gained: "Gems Gained" victory_gems_gained: "获得宝石"
guide_title: "指南" guide_title: "指南"
tome_minion_spells: "助手的咒语" # Only in old-style levels. tome_minion_spells: "助手的咒语" # Only in old-style levels.
tome_read_only_spells: "只读的咒语" # Only in old-style levels. tome_read_only_spells: "只读的咒语" # Only in old-style levels.
@ -271,7 +271,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
loading_ready: "载入完成!" loading_ready: "载入完成!"
loading_start: "开战" loading_start: "开战"
problem_alert_title: "修正你的代码" problem_alert_title: "修正你的代码"
# problem_alert_help: "Help" problem_alert_help: "帮助"
time_current: "现在:" time_current: "现在:"
time_total: "最大:" time_total: "最大:"
time_goto: "跳到:" time_goto: "跳到:"
@ -307,7 +307,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
tip_hofstadters_law: "侯世达定律:做事所花费的时间总是比你预期的要长,即使你的预期中考虑了侯世达定律。" tip_hofstadters_law: "侯世达定律:做事所花费的时间总是比你预期的要长,即使你的预期中考虑了侯世达定律。"
tip_premature_optimization: "过早的优化是万恶之源。 - 高德纳" tip_premature_optimization: "过早的优化是万恶之源。 - 高德纳"
tip_brute_force: "拿不准时就用穷举法。 - Ken Thompson" tip_brute_force: "拿不准时就用穷举法。 - Ken Thompson"
# tip_extrapolation: "There are only two kinds of people: those that can extrapolate from incomplete data..." tip_extrapolation: "世界上只有两类人:一类人能够根据不完整的数据进行推断……"
# tip_superpower: "Coding is the closest thing we have to a superpower." # tip_superpower: "Coding is the closest thing we have to a superpower."
game_menu: game_menu:
@ -315,8 +315,8 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
save_load_tab: "保存/打开" save_load_tab: "保存/打开"
options_tab: "设置" options_tab: "设置"
guide_tab: "使用向导" guide_tab: "使用向导"
# guide_video_tutorial: "Video Tutorial" guide_video_tutorial: "视频教程"
# guide_tips: "Tips" guide_tips: "小技巧"
multiplayer_tab: "多人游戏" multiplayer_tab: "多人游戏"
auth_tab: "注册" auth_tab: "注册"
inventory_caption: "装备你的英雄" inventory_caption: "装备你的英雄"
@ -330,7 +330,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
inventory: inventory:
choose_inventory: "装备道具" choose_inventory: "装备道具"
equipped_item: "已装备" equipped_item: "已装备"
# required_purchase_title: "Required" required_purchase_title: "需要"
available_item: "可用" available_item: "可用"
restricted_title: "被限制" restricted_title: "被限制"
should_equip: "(双击装备此道具)" should_equip: "(双击装备此道具)"
@ -350,7 +350,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
prompt_title: "没有足够数量的宝石" prompt_title: "没有足够数量的宝石"
prompt_body: "还需要更多吗?" prompt_body: "还需要更多吗?"
prompt_button: "进入商店" prompt_button: "进入商店"
# recovered: "Previous gems purchase recovered. Please refresh the page." recovered: "之前购买的宝石已恢复。请刷新页面。"
subscribe: subscribe:
subscribe_title: "订阅" subscribe_title: "订阅"
@ -391,7 +391,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
regeneration: "恢复" regeneration: "恢复"
range: "攻击范围" # As in "attack or visual range" range: "攻击范围" # As in "attack or visual range"
blocks: "格挡" # As in "this shield blocks this much damage" blocks: "格挡" # As in "this shield blocks this much damage"
# backstab: "Backstab" # As in "this dagger does this much backstab damage" backstab: "背刺" # As in "this dagger does this much backstab damage"
skills: "技能" skills: "技能"
available_for_purchase: "可以购买" # Shows up when you have unlocked, but not purchased, a hero in the hero store available_for_purchase: "可以购买" # Shows up when you have unlocked, but not purchased, a hero in the hero store
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) 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)
@ -574,10 +574,10 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
classes: classes:
archmage_title: "大法师" archmage_title: "大法师"
archmage_title_description: "(代码编写人员)" archmage_title_description: "(代码编写人员)"
# archmage_summary: "If you are a developer interested in coding educational games, become an archmage to help us build CodeCombat!" archmage_summary: "如果你是对教育类游戏感兴趣的开发者,那么就选择大法师来帮我们为 CodeCombat编写代码吧"
artisan_title: "工匠" artisan_title: "工匠"
artisan_title_description: "(关卡建立人员)" artisan_title_description: "(关卡建立人员)"
# artisan_summary: "Build and share levels for you and your friends to play. Become an Artisan to learn the art of teaching others to program." artisan_summary: "建立游戏关卡并分享给朋友们。那么就选择工匠职业来教其他人编程吧。"
adventurer_title: "冒险家" adventurer_title: "冒险家"
adventurer_title_description: "(关卡测试人员)" 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: "Get our new levels (even our subscriber content) for free one week early and help us work out bugs before our public release."
@ -659,7 +659,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
# achievement_query_goals: "Key achievement off of level goals" # achievement_query_goals: "Key achievement off of level goals"
level_completion: "关卡完成" level_completion: "关卡完成"
pop_i18n: "填写 I18N" pop_i18n: "填写 I18N"
# tasks: "Tasks" tasks: "任务"
article: article:
edit_btn_preview: "预览" edit_btn_preview: "预览"
@ -829,7 +829,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
# service_apple: "Apple" # service_apple: "Apple"
# service_web: "Web" # service_web: "Web"
# paid_on: "Paid On" # paid_on: "Paid On"
# service: "Service" service: "服务"
price: "价格" price: "价格"
gems: "宝石" gems: "宝石"
# active: "Active" # active: "Active"
@ -934,7 +934,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
practices_title: "尊重最佳实践" practices_title: "尊重最佳实践"
practices_description: "这是我们对您的承诺,即玩家,尽管这在法律用语中略显不足。" practices_description: "这是我们对您的承诺,即玩家,尽管这在法律用语中略显不足。"
privacy_title: "隐私" privacy_title: "隐私"
# privacy_description: "We will not sell any of your personal information." privacy_description: "我们不会泄露您的个人信息。"
security_title: "安全" security_title: "安全"
security_description: "我们竭力保证您的个人信息安全性。作为一个开源项目,任何人都可以检讨并改善我们自由开放的网站的安全性。" security_description: "我们竭力保证您的个人信息安全性。作为一个开源项目,任何人都可以检讨并改善我们自由开放的网站的安全性。"
email_title: "电子邮件" email_title: "电子邮件"
@ -1026,7 +1026,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
next_photo: "添加一张职业的照片(可选)。" next_photo: "添加一张职业的照片(可选)。"
next_active: "将自己标记为正在寻求工作机会以使自己的名字出现在搜索结果中。" next_active: "将自己标记为正在寻求工作机会以使自己的名字出现在搜索结果中。"
example_blog: "你的博客" example_blog: "你的博客"
# example_personal_site: "Personal Site" example_personal_site: "个人主页"
links_header: "个人网站链接" links_header: "个人网站链接"
links_blurb: "链接任何你希望展示的网站或介绍例如你的Github你的领英档案或是你的博客。" links_blurb: "链接任何你希望展示的网站或介绍例如你的Github你的领英档案或是你的博客。"
links_name: "链接名称" links_name: "链接名称"
@ -1088,7 +1088,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
education_description: "描述" education_description: "描述"
education_description_help: "展示任何与你的教育经历相关的信息。140个字符选填" education_description_help: "展示任何与你的教育经历相关的信息。140个字符选填"
our_notes: "我们的评注" our_notes: "我们的评注"
# remarks: "Remarks" remarks: "评价"
projects: "项目" projects: "项目"
projects_header: "添加3个项目" projects_header: "添加3个项目"
projects_header_2: "项目前3个" projects_header_2: "项目前3个"
@ -1101,7 +1101,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
project_picture_help: "上传一站230x115像素或更大的图片来展示这个项目。" project_picture_help: "上传一站230x115像素或更大的图片来展示这个项目。"
project_link: "链接" project_link: "链接"
project_link_help: "项目的链接。" project_link_help: "项目的链接。"
# player_code: "Player Code" player_code: "玩家代码"
employers: employers:
# deprecation_warning_title: "Sorry, CodeCombat is not recruiting right now." # deprecation_warning_title: "Sorry, CodeCombat is not recruiting right now."
@ -1165,6 +1165,6 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
av_other_sub_title: "其他" av_other_sub_title: "其他"
av_other_debug_base_url: "Base用于调试 base.jade" av_other_debug_base_url: "Base用于调试 base.jade"
u_title: "用户列表" u_title: "用户列表"
# ucp_title: "User Code Problems" ucp_title: "用户代码的问题"
lg_title: "最新的游戏" lg_title: "最新的游戏"
clas: "贡献者许可协议" clas: "贡献者许可协议"

View file

@ -84,7 +84,7 @@ module.exports = class User extends CocoModel
gemsEarned = @get('earned')?.gems ? 0 gemsEarned = @get('earned')?.gems ? 0
gemsPurchased = @get('purchased')?.gems ? 0 gemsPurchased = @get('purchased')?.gems ? 0
gemsSpent = @get('spent') ? 0 gemsSpent = @get('spent') ? 0
gemsEarned + gemsPurchased - gemsSpent Math.floor gemsEarned + gemsPurchased - gemsSpent
heroes: -> heroes: ->
heroes = (me.get('purchased')?.heroes ? []).concat([ThangType.heroes.captain, ThangType.heroes.knight]) heroes = (me.get('purchased')?.heroes ? []).concat([ThangType.heroes.captain, ThangType.heroes.knight])
@ -139,7 +139,7 @@ module.exports = class User extends CocoModel
else else
@subscribeCopyGroup = 'original' @subscribeCopyGroup = 'original'
@subscribeCopyGroup @subscribeCopyGroup
getVideoTutorialStylesIndex: (numVideos=0)-> getVideoTutorialStylesIndex: (numVideos=0)->
# A/B Testing video tutorial styles # A/B Testing video tutorial styles
# Not a constant number of videos available (e.g. could be 0, 1, 3, or 4 currently) # Not a constant number of videos available (e.g. could be 0, 1, 3, or 4 currently)

View file

@ -34,7 +34,7 @@ defaultTasks = [
'Write a loading tip, if needed.' 'Write a loading tip, if needed.'
'Click the Populate i18n button.' 'Click the Populate i18n button.'
'Mark whether it requires a subscription (after adventurer week).' 'Mark whether it requires a subscription.'
'Release to everyone via MailChimp.' 'Release to everyone via MailChimp.'
'Check completion/engagement/problem analytics.' 'Check completion/engagement/problem analytics.'

View file

@ -227,7 +227,7 @@ me.RewardSchema = (descriptionFragment='earned by achievements') ->
me.stringID(links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], title: 'Item ThangType', description: 'A reference to the earned item ThangType.', format: 'thang-type') me.stringID(links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], title: 'Item ThangType', description: 'A reference to the earned item ThangType.', format: 'thang-type')
levels: me.array {uniqueItems: true, description: "Levels #{descriptionFragment}."}, levels: me.array {uniqueItems: true, description: "Levels #{descriptionFragment}."},
me.stringID(links: [{rel: 'db', href: '/db/level/{($)}/version'}], title: 'Level', description: 'A reference to the earned Level.', format: 'latest-version-original-reference') me.stringID(links: [{rel: 'db', href: '/db/level/{($)}/version'}], title: 'Level', description: 'A reference to the earned Level.', format: 'latest-version-original-reference')
gems: me.int {description: "Gems #{descriptionFragment}."} gems: me.float {description: "Gems #{descriptionFragment}."}
me.task = me.object {title: 'Task', description: 'A task to be completed', format: 'task', default: {name: 'TODO', complete: false}}, me.task = me.object {title: 'Task', description: 'A task to be completed', format: 'task', default: {name: 'TODO', complete: false}},
name: {title: 'Name', description: 'What must be done?', type: 'string'} name: {title: 'Name', description: 'What must be done?', type: 'string'}

View file

@ -30,7 +30,7 @@ block content
h4(data-i18n="contribute.how_to_join") How to Join h4(data-i18n="contribute.how_to_join") How to Join
p p
a(title='Contact', tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="contribute.contact_us_url") a(tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="contribute.contact_us_url")
| Contact us | Contact us
span , span ,
span(data-i18n="contribute.ambassador_join_desc") span(data-i18n="contribute.ambassador_join_desc")

View file

@ -41,13 +41,13 @@ block content
p p
span(data-i18n="contribute.join_desc_1") span(data-i18n="contribute.join_desc_1")
| Anyone can help out! Just check out our | Anyone can help out! Just check out our
a(title='GitHub', href="https://github.com/codecombat/codecombat", tabindex=-1) a(href="https://github.com/codecombat/codecombat", tabindex=-1)
| GitHub | GitHub
span span
span(data-i18n="contribute.join_desc_2") span(data-i18n="contribute.join_desc_2")
| to get started, and check the box below to mark yourself as a brave Archmage and get the latest news by email. | to get started, and check the box below to mark yourself as a brave Archmage and get the latest news by email.
| Want to chat about what to do or how to get more deeply involved? | Want to chat about what to do or how to get more deeply involved?
a(title='Contact', tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="contribute.join_url_email") a(tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="contribute.join_url_email")
| Email us | Email us
span(data-i18n="contribute.join_desc_3") span(data-i18n="contribute.join_desc_3")
| , or find us in our | , or find us in our

View file

@ -36,7 +36,7 @@ block content
h4(data-i18n="contribute.how_to_join") How To Join h4(data-i18n="contribute.how_to_join") How To Join
p p
a(title='Contact', tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="contribute.contact_us_url") a(tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="contribute.contact_us_url")
| Contact us | Contact us
span , span ,
span(data-i18n="contribute.scribe_join_description") span(data-i18n="contribute.scribe_join_description")

View file

@ -65,7 +65,7 @@
h4 Recent Sessions h4 Recent Sessions
if recentSessions if recentSessions
div(style='font-size:10pt') Latest 10 sessions for this level div(style='font-size:10pt') Latest #{recentSessions.length} sessions for this level
div(style='font-size:10pt') Double-click row to open player and session div(style='font-size:10pt') Double-click row to open player and session
table.table.table-bordered.table-condensed.table-hover(style='font-size:10pt') table.table.table-bordered.table-condensed.table-hover(style='font-size:10pt')
thead thead

View file

@ -1,15 +1,15 @@
.btn-group(data-toggle="buttons").status-buttons .btn-group(data-toggle="buttons").status-buttons
label.btn.btn-default.pending label.btn.btn-default.pending
input(type="radio", name="status", value="pending") input(type="radio", name="status", value="pending", data-i18n="general.pending")
| Pending | Pending
label.btn.btn-default.accepted label.btn.btn-default.accepted
input(type="radio", name="status", value="accepted") input(type="radio", name="status", value="accepted", data-i18n="general.accepted")
| Accepted | Accepted
label.btn.btn-default.rejected label.btn.btn-default.rejected
input(type="radio", name="status", value="rejected") input(type="radio", name="status", value="rejected", data-i18n="general.rejected")
| Rejected | Rejected
label.btn.btn-default.withdrawn label.btn.btn-default.withdrawn
input(type="radio", name="status", value="withdrawn") input(type="radio", name="status", value="withdrawn", data-i18n="general.withdrawn")
| Withdrawn | Withdrawn
if patches.loading if patches.loading

View file

@ -243,9 +243,7 @@ module.exports = class CampaignEditorView extends RootView
getCampaignCompletions: => getCampaignCompletions: =>
# Fetch last 7 days of campaign drop-off rates # Fetch last 7 days of campaign drop-off rates
startDay = new Date() startDay = utils.getUTCDay -6
startDay.setDate(startDay.getUTCDate() - 6)
startDay = startDay.getUTCFullYear() + '-' + (startDay.getUTCMonth() + 1) + '-' + startDay.getUTCDate()
success = (data) => success = (data) =>
return if @destroyed return if @destroyed

View file

@ -3,6 +3,7 @@ Level = require 'models/Level'
LevelSession = require 'models/LevelSession' LevelSession = require 'models/LevelSession'
ModelModal = require 'views/modal/ModelModal' ModelModal = require 'views/modal/ModelModal'
User = require 'models/User' User = require 'models/User'
utils = require 'core/utils'
module.exports = class CampaignLevelView extends CocoView module.exports = class CampaignLevelView extends CocoView
id: 'campaign-level-view' id: 'campaign-level-view'
@ -47,9 +48,7 @@ module.exports = class CampaignLevelView extends CocoView
getCommonLevelProblems: -> getCommonLevelProblems: ->
# Fetch last 30 days of common level problems # Fetch last 30 days of common level problems
startDay = new Date() startDay = utils.getUTCDay -29
startDay.setDate(startDay.getUTCDate() - 29)
startDay = startDay.getUTCFullYear() + '-' + (startDay.getUTCMonth() + 1) + '-' + startDay.getUTCDate()
success = (data) => success = (data) =>
return if @destroyed return if @destroyed
@ -77,9 +76,7 @@ module.exports = class CampaignLevelView extends CocoView
@levelCompletions = _.map data, mapFn, @ @levelCompletions = _.map data, mapFn, @
@render() @render()
startDay = new Date() startDay = utils.getUTCDay -6
startDay.setDate(startDay.getUTCDate() - 6)
startDay = startDay.getUTCFullYear() + '-' + (startDay.getUTCMonth() + 1) + '-' + startDay.getUTCDate()
# TODO: Why do we need this url dash? # TODO: Why do we need this url dash?
request = @supermodel.addRequestResource 'level_completions', { request = @supermodel.addRequestResource 'level_completions', {
@ -97,10 +94,8 @@ module.exports = class CampaignLevelView extends CocoView
@levelPlaytimes = data.sort (a, b) -> if a.created < b.created then 1 else -1 @levelPlaytimes = data.sort (a, b) -> if a.created < b.created then 1 else -1
@render() @render()
startDay = new Date() startDay = utils.getUTCDay -6
startDay.setDate(startDay.getUTCDate() - 6)
startDay = startDay.getUTCFullYear() + '-' + (startDay.getUTCMonth() + 1) + '-' + startDay.getUTCDate()
# TODO: Why do we need this url dash? # TODO: Why do we need this url dash?
request = @supermodel.addRequestResource 'playtime_averages', { request = @supermodel.addRequestResource 'playtime_averages', {
url: '/db/level/-/playtime_averages' url: '/db/level/-/playtime_averages'
@ -111,7 +106,7 @@ module.exports = class CampaignLevelView extends CocoView
request.load() request.load()
getRecentSessions: -> getRecentSessions: ->
limit = 10 limit = 100
success = (data) => success = (data) =>
return if @destroyed return if @destroyed

View file

@ -110,6 +110,8 @@ module.exports = class HeroVictoryModal extends ModalView
# rewards = achievement.get('rewards') or {} # rewards = achievement.get('rewards') or {}
# rewards.gems *= (index + 1) # rewards.gems *= (index + 1)
# TODO: use earned achievement worths or somehow pull in recalculated exp/gems
c.thangTypes = @thangTypes c.thangTypes = @thangTypes
c.me = me c.me = me
c.readyToRank = @level.get('type', true) is 'hero-ladder' and @session.readyToRank() c.readyToRank = @level.get('type', true) is 'hero-ladder' and @session.readyToRank()

View file

@ -0,0 +1,82 @@
# Calculate level completion rates via mixpanel export API
# TODO: unique users
# TODO: align output
# TODO: order output
import sys
from mixpanel import Mixpanel
try:
import json
except ImportError:
import simplejson as json
# NOTE: mixpanel dates are by day and inclusive
# E.g. '2014-12-08' is any date that day, up to 2014-12-09 12am
if __name__ == '__main__':
if not len(sys.argv) is 3:
print "Script format: <script> <api_key> <api_secret>"
else:
api_key = sys.argv[1]
api_secret = sys.argv[2]
api = Mixpanel(
api_key = api_key,
api_secret = api_secret
)
startDate = '2014-12-31'
endDate = '2015-01-05'
print("Requesting data for {0} to {1}".format(startDate, endDate))
data = api.request(['export'], {
'event' : ['Started Level', 'Saw Victory'],
'from_date' : startDate,
'to_date' : endDate
})
levelRates = {}
lines = data.split('\n')
print "Received %d entries" % len(lines)
for line in lines:
try:
if len(line) is 0: continue
eventData = json.loads(line)
eventName = eventData['event']
if not eventName in ['Started Level', 'Saw Victory']:
print 'Unexpected event ' + eventName
break
properties = eventData['properties']
if 'levelID' in properties:
levelID = properties['levelID']
elif 'level' in properties:
levelID = properties['level'].lower().replace(' ', '-')
else:
print("Unkonwn levelID for", eventName)
print(properties)
break
if not levelID in levelRates:
levelRates[levelID] = {'started': 0, 'finished': 0}
if eventName == 'Started Level':
levelRates[levelID]['started'] += 1
elif eventName == 'Saw Victory':
levelRates[levelID]['finished'] += 1
else:
print("Unknown event name", eventName)
print(eventData)
break
except:
print "Unexpected error:", sys.exc_info()[0]
print line
break
# print(levelRates)
for levelID in levelRates:
started = levelRates[levelID]['started']
finished = levelRates[levelID]['finished']
# if not levelID == 'endangered-burl':
# continue
if started > 0:
print("{0}\t{1}\t{2}\t{3}%".format(levelID, started, finished, float(finished) / started * 100))
else:
print("{0}\t{1}\t{2}".format(levelID, started, finished))

View file

@ -20,12 +20,15 @@ print("Today is " + today);
var todayMinus6 = new Date(); var todayMinus6 = new Date();
todayMinus6.setDate(todayMinus6.getUTCDate() - 6); todayMinus6.setDate(todayMinus6.getUTCDate() - 6);
var startDate = todayMinus6.toISOString().substr(0, 10) + "T00:00:00.000Z"; var startDate = todayMinus6.toISOString().substr(0, 10) + "T00:00:00.000Z";
// startDate = "2014-12-01T00:00:00.000Z"; // startDate = "2014-12-31T00:00:00.000Z";
print("Start date is " + startDate) print("Start date is " + startDate)
// var endDate = "2015-01-06T00:00:00.000Z";
// print("End date is " + endDate)
var cursor = db['analytics.log.events'].find({ var cursor = db['analytics.log.events'].find({
$and: [ $and: [
{"created": { $gte: ISODate(startDate)}}, {"created": { $gte: ISODate(startDate)}},
// {"created": { $lt: ISODate(endDate)}},
{$or: [ {"event" : 'Started Level'}, {"event" : 'Saw Victory'}]} {$or: [ {"event" : 'Started Level'}, {"event" : 'Saw Victory'}]}
] ]
}); });
@ -115,6 +118,7 @@ var campaigns = {
// Bucketize events by user // Bucketize events by user
print("Getting event data..."); print("Getting event data...");
var userProgression = {}; var userProgression = {};
var userLevelEventMap = {}; // Only want unique users per-level/event
while (cursor.hasNext()) { while (cursor.hasNext()) {
var doc = cursor.next(); var doc = cursor.next();
var created = doc.created; var created = doc.created;
@ -124,12 +128,17 @@ while (cursor.hasNext()) {
if (level) { if (level) {
if (level.length > longestLevelName) longestLevelName = level.length; if (level.length > longestLevelName) longestLevelName = level.length;
var user = doc.user.valueOf(); var user = doc.user.valueOf();
if (!userProgression[user]) userProgression[user] = []; if (!userLevelEventMap[user]) userLevelEventMap[user] = {};
userProgression[user].push({ if (!userLevelEventMap[user][level]) userLevelEventMap[user][level] = {};
created: created, if (!userLevelEventMap[user][level][event]) {
event: event, userLevelEventMap[user][level][event] = true;
level: level if (!userProgression[user]) userProgression[user] = [];
}); userProgression[user].push({
created: created,
event: event,
level: level
});
}
} }
} }
longestLevelName += 2; longestLevelName += 2;
@ -224,7 +233,7 @@ for (campaign in campaigns) {
print("\n" + campaign); print("\n" + campaign);
var level = "level"; var level = "level";
var levelSpacer = new Array(longestLevelName - level.length).join(' '); var levelSpacer = new Array(longestLevelName - level.length).join(' ');
print(level + levelSpacer + "started\tdropped\t\tfinished dropped"); print(level + levelSpacer + "started\tdropped\t\tfinished dropped\tcompletion");
for (var i = 0; i < campaignRates[campaign].levels.length; i++) { for (var i = 0; i < campaignRates[campaign].levels.length; i++) {
var level = campaignRates[campaign].levels[i].level; var level = campaignRates[campaign].levels[i].level;
var started = campaignRates[campaign].levels[i].started; var started = campaignRates[campaign].levels[i].started;
@ -232,13 +241,13 @@ for (campaign in campaigns) {
var finished = campaignRates[campaign].levels[i].finished; var finished = campaignRates[campaign].levels[i].finished;
var finishDropped = campaignRates[campaign].levels[i].finishDropped; var finishDropped = campaignRates[campaign].levels[i].finishDropped;
var levelSpacer = new Array(longestLevelName - level.length).join(' '); var levelSpacer = new Array(longestLevelName - level.length).join(' ');
print(level + levelSpacer + started + "\t" + (started < 100 ? "\t" : "") + startDropped + "\t" + (startDropped / started * 100).toFixed(2) + "%\t" + finished + "\t" + finishDropped + "\t" + (finishDropped / finished * 100).toFixed(2) + "%"); print(level + levelSpacer + started + "\t" + (started < 100 ? "\t" : "") + startDropped + "\t" + (startDropped / started * 100).toFixed(2) + "%\t" + finished + "\t" + finishDropped + "\t" + (finishDropped / finished * 100).toFixed(2) + "%" + "\t" + (finished / started * 100).toFixed(2) + "%");
} }
var level = 'Overall'; // var level = 'Overall';
var started = campaignRates[campaign].overall.started; // var started = campaignRates[campaign].overall.started;
var startDropped = campaignRates[campaign].overall.startDropped; // var startDropped = campaignRates[campaign].overall.startDropped;
var finished = campaignRates[campaign].overall.finished; // var finished = campaignRates[campaign].overall.finished;
var finishDropped = campaignRates[campaign].overall.finishDropped; // var finishDropped = campaignRates[campaign].overall.finishDropped;
var levelSpacer = new Array(longestLevelName - level.length).join(' '); // var levelSpacer = new Array(longestLevelName - level.length).join(' ');
print(level + levelSpacer + started + "\t" + (started < 100 ? "\t" : "") + startDropped + "\t" + (startDropped / started * 100).toFixed(2) + "%\t" + finished + "\t" + finishDropped + "\t" + (finishDropped / finished * 100).toFixed(2) + "%"); // print(level + levelSpacer + started + "\t" + (started < 100 ? "\t" : "") + startDropped + "\t" + (startDropped / started * 100).toFixed(2) + "%\t" + finished + "\t" + finishDropped + "\t" + (finishDropped / finished * 100).toFixed(2) + "%");
} }

View file

@ -16,7 +16,7 @@ EarnedAchievementSchema.pre 'save', (next) ->
EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'}) EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'})
EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '}) EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '})
EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, originalDocObj, done) -> EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, originalDocObj=null, previouslyEarnedAchievement=null, done) ->
User = require '../users/User' User = require '../users/User'
userObjectID = doc.get(achievement.get('userField')) userObjectID = doc.get(achievement.get('userField'))
userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use strings, not ObjectId's userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use strings, not ObjectId's
@ -27,18 +27,20 @@ EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, origin
achievementName: achievement.get 'name' achievementName: achievement.get 'name'
earnedRewards: achievement.get 'rewards' earnedRewards: achievement.get 'rewards'
worth = achievement.get('worth') ? 10 pointWorth = achievement.get('worth') ? 10
gemWorth = achievement.get('rewards')?.gems ? 0
earnedPoints = 0 earnedPoints = 0
earnedGems = 0
wrapUp = (earnedAchievementDoc) -> wrapUp = (earnedAchievementDoc) ->
# Update user's experience points # Update user's experience points
update = {$inc: {points: earnedPoints}} update = {$inc: {points: earnedPoints, 'earned.gems': earnedGems}}
for rewardType, rewards of achievement.get('rewards') ? {} for rewardType, rewards of achievement.get('rewards') ? {}
if rewardType is 'gems' continue if rewardType is 'gems'
update.$inc['earned.gems'] = rewards if rewards if rewards.length
else if rewards.length
update.$addToSet ?= {} update.$addToSet ?= {}
update.$addToSet["earned.#{rewardType}"] = $each: rewards update.$addToSet["earned.#{rewardType}"] = $each: rewards
User.update {_id: userID}, update, {}, (err, count) -> User.update {_id: mongoose.Types.ObjectId(userID)}, update, {}, (err, count) ->
log.error err if err? log.error err if err?
done?(earnedAchievementDoc) done?(earnedAchievementDoc)
@ -46,29 +48,40 @@ EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, origin
if isRepeatable if isRepeatable
#log.debug 'Upserting repeatable achievement called \'' + (achievement.get 'name') + '\' for ' + userID #log.debug 'Upserting repeatable achievement called \'' + (achievement.get 'name') + '\' for ' + userID
proportionalTo = achievement.get 'proportionalTo' proportionalTo = achievement.get 'proportionalTo'
originalAmount = if originalDocObj then util.getByPath(originalDocObj, proportionalTo) or 0 else 0
docObj = doc.toObject() docObj = doc.toObject()
newAmount = docObj[proportionalTo] newAmount = util.getByPath(docObj, proportionalTo) or 0
if previouslyEarnedAchievement
originalAmount = previouslyEarnedAchievement.get('achievedAmount') or 0
else if originalDocObj # This branch could get buggy if unchangedCopy tracking isn't working.
originalAmount = util.getByPath(originalDocObj, proportionalTo) or 0
else
originalAmount = 0
#console.log 'original amount is', originalAmount, 'and new amount is', newAmount, 'for', proportionalTo, 'with doc', docObj, 'and previously earned achievement amount', previouslyEarnedAchievement?.get('achievedAmount'), 'because we had originalDocObj', originalDocObj
if originalAmount isnt newAmount if originalAmount isnt newAmount
expFunction = achievement.getExpFunction() expFunction = achievement.getExpFunction()
earned.notified = false earned.notified = false
earned.achievedAmount = newAmount earned.achievedAmount = newAmount
earned.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * worth #console.log 'earnedPoints is', (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth, 'was', earned.earnedPoints, earned.previouslyAchievedAmount, 'got exp function for new amount', newAmount, expFunction(newAmount), 'for original amount', originalAmount, expFunction(originalAmount), 'with point worth', pointWorth
earnedPoints = earned.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth
earnedGems = earned.earnedGems = (expFunction(newAmount) - expFunction(originalAmount)) * gemWorth
earned.previouslyAchievedAmount = originalAmount earned.previouslyAchievedAmount = originalAmount
EarnedAchievement.update {achievement: earned.achievement, user: earned.user}, earned, {upsert: true}, (err) -> EarnedAchievement.update {achievement: earned.achievement, user: earned.user}, earned, {upsert: true}, (err) ->
return log.debug err if err? return log.debug err if err?
earnedPoints = earned.earnedPoints
#log.debug earnedPoints #log.debug earnedPoints
wrapUp() wrapUp(new EarnedAchievement(earned))
else
done?()
else # not alreadyAchieved else # not alreadyAchieved
#log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID #log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID
earned.earnedPoints = worth earned.earnedPoints = pointWorth
earned.earnedGems = gemWorth
(new EarnedAchievement(earned)).save (err, doc) -> (new EarnedAchievement(earned)).save (err, doc) ->
return log.error err if err? return log.error err if err?
earnedPoints = worth earnedPoints = pointWorth
earnedGems = gemWorth
wrapUp(doc) wrapUp(doc)
User.saveActiveUser userID, "achievement" User.saveActiveUser userID, "achievement"

View file

@ -6,6 +6,7 @@ EarnedAchievement = require './EarnedAchievement'
User = require '../users/User' User = require '../users/User'
Handler = require '../commons/Handler' Handler = require '../commons/Handler'
LocalMongo = require '../../app/lib/LocalMongo' LocalMongo = require '../../app/lib/LocalMongo'
util = require '../../app/core/utils'
class EarnedAchievementHandler extends Handler class EarnedAchievementHandler extends Handler
modelClass: EarnedAchievement modelClass: EarnedAchievement
@ -65,6 +66,10 @@ class EarnedAchievementHandler extends Handler
return @sendNotFoundError(res, 'Could not find achievement.') return @sendNotFoundError(res, 'Could not find achievement.')
else if not trigger else if not trigger
return @sendNotFoundError(res, 'Could not find trigger.') return @sendNotFoundError(res, 'Could not find trigger.')
else if achievement.get('proportionalTo') and earned
EarnedAchievement.createForAchievement(achievement, trigger, null, earned, (earnedAchievementDoc) =>
@sendCreated(res, (earnedAchievementDoc or earned)?.toObject())
)
else if earned else if earned
achievementEarned = achievement.get('rewards') achievementEarned = achievement.get('rewards')
actuallyEarned = earned.get('earnedRewards') actuallyEarned = earned.get('earnedRewards')
@ -82,10 +87,8 @@ class EarnedAchievementHandler extends Handler
return @sendDatabaseError(res, err) if err return @sendDatabaseError(res, err) if err
return @sendSuccess(res, earned.toObject()) return @sendSuccess(res, earned.toObject())
) )
else if achievement.get('proportionalTo')
return @sendBadInputError(res, 'Cannot currently do this to repeatable docs...')
else else
EarnedAchievement.createForAchievement(achievement, trigger, null, (earnedAchievementDoc) => EarnedAchievement.createForAchievement(achievement, trigger, null, null, (earnedAchievementDoc) =>
@sendCreated(res, earnedAchievementDoc.toObject()) @sendCreated(res, earnedAchievementDoc.toObject())
) )
) )
@ -221,13 +224,15 @@ class EarnedAchievementHandler extends Handler
notified: achievement._id in alreadyEarnedIDs notified: achievement._id in alreadyEarnedIDs
if isRepeatable if isRepeatable
earned.achievedAmount = something.get(achievement.get 'proportionalTo') earned.achievedAmount = util.getByPath(something.toObject(), achievement.get 'proportionalTo') or 0
earned.previouslyAchievedAmount = 0 earned.previouslyAchievedAmount = 0
expFunction = achievement.getExpFunction() expFunction = achievement.getExpFunction()
newPoints = expFunction(earned.achievedAmount) * achievement.get('worth') ? 10 newPoints = expFunction(earned.achievedAmount) * achievement.get('worth') ? 10
newGems = expFunction(earned.achievedAmount) * (achievement.get('rewards')?.gems ? 0)
else else
newPoints = achievement.get('worth') ? 10 newPoints = achievement.get('worth') ? 10
newGems = achievement.get('rewards')?.gems ? 0
earned.earnedPoints = newPoints earned.earnedPoints = newPoints
newTotalPoints += newPoints newTotalPoints += newPoints
@ -235,7 +240,10 @@ class EarnedAchievementHandler extends Handler
earned.earnedRewards = achievement.get('rewards') earned.earnedRewards = achievement.get('rewards')
for rewardType in ['heroes', 'items', 'levels'] for rewardType in ['heroes', 'items', 'levels']
newTotalRewards[rewardType] = newTotalRewards[rewardType].concat(achievement.get('rewards')?[rewardType] ? []) newTotalRewards[rewardType] = newTotalRewards[rewardType].concat(achievement.get('rewards')?[rewardType] ? [])
newTotalRewards.gems += achievement.get('rewards')?.gems ? 0 if isRepeatable and earned.earnedRewards
earned.earnedRewards = _.clone earned.earnedRewards
earned.earnedRewards.gems = newGems
newTotalRewards.gems += newGems
EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) -> EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) ->
doneWithAchievement err doneWithAchievement err

View file

@ -33,6 +33,7 @@ class AnalyticsLogEventHandler extends Handler
# endDay - Exclusive, optional, e.g. '2014-12-16' # endDay - Exclusive, optional, e.g. '2014-12-16'
# TODO: An uncached call takes about 15s locally # TODO: An uncached call takes about 15s locally
# TODO: Use unique users
levelSlug = req.query.slug or req.body.slug levelSlug = req.query.slug or req.body.slug
startDay = req.query.startDay or req.body.startDay startDay = req.query.startDay or req.body.startDay
@ -105,11 +106,12 @@ class AnalyticsLogEventHandler extends Handler
# startDay - Inclusive, optional, e.g. '2014-12-14' # startDay - Inclusive, optional, e.g. '2014-12-14'
# endDay - Exclusive, optional, e.g. '2014-12-16' # endDay - Exclusive, optional, e.g. '2014-12-16'
# TODO: Must be a better way to organize this series of 3 database calls (campaigns, levels, analytics) # TODO: Must be a better way to organize this series of database calls (campaigns, levels, analytics)
# TODO: An uncached call can take over 30s locally # TODO: An uncached call can take over 30s locally
# TODO: Returns all the campaigns # TODO: Returns all the campaigns
# TODO: Calculate overall campaign stats # TODO: Calculate overall campaign stats
# TODO: Assumes db campaign levels are in progression order. Should build this based on actual progression. # TODO: Assumes db campaign levels are in progression order. Should build this based on actual progression.
# TODO: Remove earliest duplicate event so our dropped counts will be more accurate.
campaignSlug = req.query.slug or req.body.slug campaignSlug = req.query.slug or req.body.slug
startDay = req.query.startDay or req.body.startDay startDay = req.query.startDay or req.body.startDay
@ -128,95 +130,111 @@ class AnalyticsLogEventHandler extends Handler
cacheKey += 'e' + endDay if endDay? cacheKey += 'e' + endDay if endDay?
return @sendSuccess res, campaignDropOffs if campaignDropOffs = @campaignDropOffsCache[cacheKey] return @sendSuccess res, campaignDropOffs if campaignDropOffs = @campaignDropOffsCache[cacheKey]
getCompletions = (campaigns) => getCompletions = (campaigns, userProgression) =>
# Calculate campaign drop off rates # Calculate campaign drop off rates
# Input: # Input:
# campaigns - per-campaign dictionary of ordered level slugs # campaigns - per-campaign dictionary of ordered level slugs
# userProgression - per-user event lists
# Remove duplicate user events
for user of userProgression
userProgression[user] = _.uniq userProgression[user], false, (val, index, arr) -> val.event + val.level
# Order user progression by created
for user of userProgression
userProgression[user].sort (a,b) -> if a.created < b.created then return -1 else 1
# Per-level start/drop/finish/drop
levelProgression = {}
for user of userProgression
for i in [0...userProgression[user].length]
event = userProgression[user][i].event
level = userProgression[user][i].level
levelProgression[level] ?=
started: 0
startDropped: 0
finished: 0
finishDropped: 0
if event is 'Started Level'
levelProgression[level].started++
levelProgression[level].startDropped++ if i is userProgression[user].length - 1
else if event is 'Saw Victory'
levelProgression[level].finished++
levelProgression[level].finishDropped++ if i is userProgression[user].length - 1
# Put in campaign order
completions = {}
for level of levelProgression
for campaign of campaigns
if level in campaigns[campaign]
started = levelProgression[level].started
startDropped = levelProgression[level].startDropped
finished = levelProgression[level].finished
finishDropped = levelProgression[level].finishDropped
completions[campaign] ?=
levels: []
# overall:
# started: 0,
# startDropped: 0,
# finished: 0,
# finishDropped: 0
completions[campaign].levels.push
level: level
started: started
startDropped: startDropped
finished: finished
finishDropped: finishDropped
break
# Sort level data by campaign order
for campaign of completions
completions[campaign].levels.sort (a, b) ->
if campaigns[campaign].indexOf(a.level) < campaigns[campaign].indexOf(b.level) then return -1 else 1
# Return all campaign data for simplicity
# Cache other individual campaigns too, since we have them
@campaignDropOffsCache[cacheKey] = completions
for campaign of completions
cacheKey = campaign
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
@campaignDropOffsCache[cacheKey] = completions
@sendSuccess res, completions
getUserEventData = (campaigns) =>
# Gather user start and finish event data
# Input:
# campaigns - per-campaign dictionary of ordered level slugs
# Output:
# userProgression - per-user event lists
userProgression = {}
queryParams = {$and: [{$or: [ {"event" : 'Started Level'}, {"event" : 'Saw Victory'}]}]} queryParams = {$and: [{$or: [ {"event" : 'Started Level'}, {"event" : 'Saw Victory'}]}]}
queryParams["$and"].push created: {$gte: new Date(startDay + "T00:00:00.000Z")} if startDay? queryParams["$and"].push created: {$gte: new Date(startDay + "T00:00:00.000Z")} if startDay?
queryParams["$and"].push created: {$lt: new Date(endDay + "T00:00:00.000Z")} if endDay? queryParams["$and"].push created: {$lt: new Date(endDay + "T00:00:00.000Z")} if endDay?
AnalyticsLogEvent.find(queryParams).select('created event properties user').exec (err, data) => # Query stream is better for large results
if err? then return @sendDatabaseError res, err # http://mongoosejs.com/docs/api.html#query_Query-stream
stream = AnalyticsLogEvent.find(queryParams).select('created event properties user').stream()
# Bucketize events by user stream.on 'data', (item) =>
userProgression = {} created = item.get('created')
for item in data event = item.get('event')
created = item.get('created') if event is 'Saw Victory'
event = item.get('event') level = item.get('properties.level').toLowerCase().replace new RegExp(' ', 'g'), '-'
if event is 'Saw Victory' else
level = item.get('properties.level').toLowerCase().replace new RegExp(' ', 'g'), '-' level = item.get('properties.levelID')
else return unless level?
level = item.get('properties.levelID') user = item.get('user')
continue unless level? userProgression[user] ?= []
user = item.get('user') userProgression[user].push
userProgression[user] ?= [] created: created
userProgression[user].push event: event
created: created level: level
event: event .on 'error', (err) =>
level: level return @sendDatabaseError res, err
.on 'close', () =>
# Order user progression by created getCompletions campaigns, userProgression
for user in userProgression
userProgression[user].sort (a,b) -> if a.created < b.created then return -1 else 1
# Per-level start/drop/finish/drop
levelProgression = {}
for user of userProgression
for i in [0...userProgression[user].length]
event = userProgression[user][i].event
level = userProgression[user][i].level
levelProgression[level] ?=
started: 0
startDropped: 0
finished: 0
finishDropped: 0
if event is 'Started Level'
levelProgression[level].started++
levelProgression[level].startDropped++ if i is userProgression[user].length - 1
else if event is 'Saw Victory'
levelProgression[level].finished++
levelProgression[level].finishDropped++ if i is userProgression[user].length - 1
# Put in campaign order
completions = {}
for level of levelProgression
for campaign of campaigns
if level in campaigns[campaign]
started = levelProgression[level].started
startDropped = levelProgression[level].startDropped
finished = levelProgression[level].finished
finishDropped = levelProgression[level].finishDropped
completions[campaign] ?=
levels: []
# overall:
# started: 0,
# startDropped: 0,
# finished: 0,
# finishDropped: 0
completions[campaign].levels.push
level: level
started: started
startDropped: startDropped
finished: finished
finishDropped: finishDropped
break
# Sort level data by campaign order
for campaign of completions
completions[campaign].levels.sort (a, b) ->
if campaigns[campaign].indexOf(a.level) < campaigns[campaign].indexOf(b.level) then return -1 else 1
# Return all campaign data for simplicity
# Cache other individual campaigns too, since we have them
@campaignDropOffsCache[cacheKey] = completions
for campaign of completions
cacheKey = campaign
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
@campaignDropOffsCache[cacheKey] = completions
@sendSuccess res, completions
getLevelData = (campaigns, campaignLevelIDs) => getLevelData = (campaigns, campaignLevelIDs) =>
# Get level data and replace levelIDs with level slugs in campaigns # Get level data and replace levelIDs with level slugs in campaigns
@ -240,7 +258,7 @@ class AnalyticsLogEventHandler extends Handler
mapFn = (item) -> levelSlugMap[item] mapFn = (item) -> levelSlugMap[item]
campaigns[campaign] = _.map campaigns[campaign], mapFn, @ campaigns[campaign] = _.map campaigns[campaign], mapFn, @
getCompletions campaigns getUserEventData campaigns
getCampaignData = () => getCampaignData = () =>
# Get campaign data # Get campaign data

View file

@ -39,7 +39,7 @@ CampaignHandler = class CampaignHandler extends Handler
getRelatedLevels: (req, res, campaign, projection) -> getRelatedLevels: (req, res, campaign, projection) ->
extraProjectionProps = [] extraProjectionProps = []
if projection unless _.isEmpty(projection)
# Make sure that permissions and version are fetched, but not sent back if they didn't ask for them. # Make sure that permissions and version are fetched, but not sent back if they didn't ask for them.
extraProjectionProps.push 'permissions' unless projection.permissions extraProjectionProps.push 'permissions' unless projection.permissions
extraProjectionProps.push 'version' unless projection.version extraProjectionProps.push 'version' unless projection.version

View file

@ -14,7 +14,8 @@ AchievablePlugin = (schema, options) ->
# Keep track the document before it's saved # Keep track the document before it's saved
schema.post 'init', (doc) -> schema.post 'init', (doc) ->
doc.unchangedCopy = doc.toObject() unless doc.unchangedCopy
doc.unchangedCopy = doc.toObject()
# Check if an achievement has been earned # Check if an achievement has been earned
schema.post 'save', (doc) -> schema.post 'save', (doc) ->

View file

@ -575,8 +575,8 @@ sendLadderUpdateEmail = (session, now, daysAgo) ->
unless user.get('email') and allowNotes and not session.unsubscribed unless user.get('email') and allowNotes and not session.unsubscribed
#log.info "Not sending email to #{user.get('email')} #{user.get('name')} because they only want emails about #{user.get('emailSubscriptions')}, #{user.get('emails')} - session unsubscribed: #{session.unsubscribed}" #log.info "Not sending email to #{user.get('email')} #{user.get('name')} because they only want emails about #{user.get('emailSubscriptions')}, #{user.get('emails')} - session unsubscribed: #{session.unsubscribed}"
return return
unless session.levelName unless session.levelName and session.team
#log.info "Not sending email to #{user.get('email')} #{user.get('name')} because the session had no levelName in it." #log.info "Not sending email to #{user.get('email')} #{user.get('name')} because the session had levelName #{session.levelName} or team #{session.team} in it."
return return
name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name') name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name')
name = 'Wizard' if not name or name is 'Anoner' name = 'Wizard' if not name or name is 'Anoner'

View file

@ -21,6 +21,8 @@ repeatable =
userField: '_id' userField: '_id'
proportionalTo: 'simulatedBy' proportionalTo: 'simulatedBy'
recalculable: true recalculable: true
rewards:
gems: 1
diminishing = diminishing =
name: 'Simulated2' name: 'Simulated2'
@ -169,19 +171,23 @@ describe 'Achieving Achievements', ->
it 'verify that a repeatable achievement has been earned', (done) -> it 'verify that a repeatable achievement has been earned', (done) ->
unittest.getNormalJoe (joe) -> unittest.getNormalJoe (joe) ->
EarnedAchievement.find {achievementName: repeatable.name}, (err, docs) ->
expect(err).toBeNull()
expect(docs.length).toBe(1)
achievement = docs[0]
if achievement User.findById(joe.get('_id')).exec (err, joe2) ->
expect(achievement.get 'achievement').toBe repeatable._id expect(joe2.get('earned').gems).toBe(2)
expect(achievement.get 'user').toBe joe._id.toHexString()
expect(achievement.get 'notified').toBeFalsy() EarnedAchievement.find {achievementName: repeatable.name}, (err, docs) ->
expect(achievement.get 'earnedPoints').toBe 2 * repeatable.worth expect(err).toBeNull()
expect(achievement.get 'achievedAmount').toBe 2 expect(docs.length).toBe(1)
expect(achievement.get 'previouslyAchievedAmount').toBeFalsy() achievement = docs[0]
done()
if achievement
expect(achievement.get 'achievement').toBe repeatable._id
expect(achievement.get 'user').toBe joe._id.toHexString()
expect(achievement.get 'notified').toBeFalsy()
expect(achievement.get 'earnedPoints').toBe 2 * repeatable.worth
expect(achievement.get 'achievedAmount').toBe 2
expect(achievement.get 'previouslyAchievedAmount').toBeFalsy()
done()
it 'verify that the repeatable achievement with complex exp has been earned', (done) -> it 'verify that the repeatable achievement with complex exp has been earned', (done) ->
unittest.getNormalJoe (joe) -> unittest.getNormalJoe (joe) ->
@ -196,6 +202,17 @@ describe 'Achieving Achievements', ->
done() done()
it 'increases gems proportionally to changes made', (done) ->
unittest.getNormalJoe (joe) ->
User.findById(joe.get('_id')).exec (err, joe2) ->
joe2.set('simulatedBy', 4)
joe2.save (err, joe3) ->
expect(err).toBeNull()
User.findById(joe3.get('_id')).exec (err, joe4) ->
expect(joe4.get('earned').gems).toBe(4) # Crap, it's 6 TODO
done()
describe 'Recalculate Achievements', -> describe 'Recalculate Achievements', ->
EarnedAchievementHandler = require '../../../server/achievements/earned_achievement_handler' EarnedAchievementHandler = require '../../../server/achievements/earned_achievement_handler'
@ -241,7 +258,8 @@ describe 'Recalculate Achievements', ->
unittest.getNormalJoe (joe) -> unittest.getNormalJoe (joe) ->
User.findById joe.get('id'), (err, guy) -> User.findById joe.get('id'), (err, guy) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(guy.get 'points').toBe unlockable.worth + 2 * repeatable.worth + (Math.log(.5 * (2 + .5)) + 1) * diminishing.worth expect(guy.get 'points').toBe unlockable.worth + 4 * repeatable.worth + (Math.log(.5 * (4 + .5)) + 1) * diminishing.worth
expect(guy.get('earned').gems).toBe 4 * repeatable.rewards.gems
done() done()
it 'cleaning up test: deleting all Achievements and related', (done) -> it 'cleaning up test: deleting all Achievements and related', (done) ->