diff --git a/app/collections/NewAchievementCollection.coffee b/app/collections/NewAchievementCollection.coffee index 7b33d7d75..b9e4326b5 100644 --- a/app/collections/NewAchievementCollection.coffee +++ b/app/collections/NewAchievementCollection.coffee @@ -1,6 +1,9 @@ CocoCollection = require 'collections/CocoCollection' +Achievement = require 'models/Achievement' class NewAchievementCollection extends CocoCollection + model: Achievement + initialize: (me = require('lib/auth').me) -> @url = "/db/user/#{me.id}/achievements?notified=false" diff --git a/app/models/Achievement.coffee b/app/models/Achievement.coffee index 494c29091..1010d2eaf 100644 --- a/app/models/Achievement.coffee +++ b/app/models/Achievement.coffee @@ -7,7 +7,7 @@ module.exports = class Achievement extends CocoModel urlRoot: '/db/achievement' isRepeatable: -> - @get('proportionalTo')?a + @get('proportionalTo')? # TODO logic is duplicated in Mongoose Achievement schema getExpFunction: -> diff --git a/app/models/CocoModel.coffee b/app/models/CocoModel.coffee index a50edd924..b27f64ba1 100644 --- a/app/models/CocoModel.coffee +++ b/app/models/CocoModel.coffee @@ -1,8 +1,6 @@ storage = require 'lib/storage' deltasLib = require 'lib/deltas' -NewAchievementCollection = require '../collections/NewAchievementCollection' - class CocoModel extends Backbone.Model idAttribute: '_id' loaded: false @@ -298,13 +296,15 @@ class CocoModel extends Backbone.Model return if _.isString @url then @url else @url() @pollAchievements: -> + NewAchievementCollection = require '../collections/NewAchievementCollection' # Nasty mutual inclusion if put on top + console.debug 'Polling for new achievements' achievements = new NewAchievementCollection - achievements.fetch( + achievements.fetch success: (collection) -> + console.debug 'Polling for achievements success', collection me.fetch (success: -> Backbone.Mediator.publish('achievements:new', collection)) unless _.isEmpty(collection.models) error: (collection, res, options) -> console.error 'Miserably failed to fetch unnotified achievements' - ) CocoModel.pollAchievements = _.debounce CocoModel.pollAchievements, 500 diff --git a/app/models/SuperModel.coffee b/app/models/SuperModel.coffee index c366d8285..6bf27d963 100644 --- a/app/models/SuperModel.coffee +++ b/app/models/SuperModel.coffee @@ -58,9 +58,15 @@ module.exports = class SuperModel extends Backbone.Model return res else @addCollection collection - @listenToOnce collection, 'sync', (c) -> - console.debug 'Registering collection', url - @registerCollection c + onCollectionSynced = (c) -> + if collection.url is c.url + console.debug 'Registering collection', url, c + @registerCollection c + else + console.warn 'Sync triggered for collection', c + console.warn 'Yet got other object', c + @listenToOnce collection, 'sync', onCollectionSynced + @listenToOnce collection, 'sync', onCollectionSynced res = @addModelResource(collection, name, fetchOptions, value) res.load() if not (res.isLoading or res.isLoaded) return res diff --git a/app/models/User.coffee b/app/models/User.coffee index fec03efec..810212065 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -16,6 +16,10 @@ module.exports = class User extends CocoModel super() @migrateEmails() + onLoaded: -> + CocoModel.pollAchievements() # Check for achievements on login + super arguments... + isAdmin: -> permissions = @attributes['permissions'] or [] return 'admin' in permissions @@ -121,15 +125,16 @@ module.exports = class User extends CocoModel isEmailSubscriptionEnabled: (name) -> (@get('emails') or {})[name]?.enabled a = 5 - b = 40 + b = 100 + c = b - # y = a * ln(1/b * (x + b)) + 1 + # y = a * ln(1/b * (x + c)) + 1 @levelFromExp: (xp) -> - if xp > 0 then Math.floor(a * Math.log((1/b) * (xp + b))) + 1 else 1 + if xp > 0 then Math.floor(a * Math.log((1/b) * (xp + c))) + 1 else 1 - # x = (e^((y-1)/a) - 1) * b + # x = b * e^((y-1)/a) - c @expForLevel: (level) -> - Math.ceil((Math.exp((level - 1)/ a) - 1) * b) + if level > 1 then Math.ceil Math.exp((level - 1)/ a) * b - c else 0 level: -> User.levelFromExp(@get('points')) diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 4f2d3beed..be4478820 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -229,25 +229,25 @@ _.extend UserSchema.properties, levelSystemEdits: c.int() levelComponentEdits: c.int() thangTypeEdits: c.int() - 'stats.patchesSubmitted': c.int + patchesSubmitted: c.int description: 'Amount of patches submitted, not necessarily accepted' - 'stats.patchesContributed': c.int + patchesContributed: c.int description: 'Amount of patches submitted and accepted' - 'stats.patchesAccepted': c.int + patchesAccepted: c.int description: 'Amount of patches accepted by the user as owner' # The below patches only apply to those that actually got accepted - 'stats.totalTranslationPatches': c.int() - 'stats.totalMiscPatches': c.int() - 'stats.articleTranslationPatches': c.int() - 'stats.articleMiscPatches': c.int() - 'stats.levelTranslationPatches': c.int() - 'stats.levelMiscPatches': c.int() - 'stats.levelComponentTranslationPatches': c.int() - 'stats.levelComponentMiscPatches': c.int() - 'stats.levelSystemTranslationPatches': c.int() - 'stats.levelSystemMiscPatches': c.int() - 'stats.thangTypeTranslationPatches': c.int() - 'stats.thangTypeMiscPatches': c.int() + totalTranslationPatches: c.int() + totalMiscPatches: c.int() + articleTranslationPatches: c.int() + articleMiscPatches: c.int() + levelTranslationPatches: c.int() + levelMiscPatches: c.int() + levelComponentTranslationPatches: c.int() + levelComponentMiscPatches: c.int() + levelSystemTranslationPatches: c.int() + levelSystemMiscPatches: c.int() + thangTypeTranslationPatches: c.int() + thangTypeMiscPatches: c.int() c.extendBasicProperties UserSchema, 'user' diff --git a/app/styles/achievements.sass b/app/styles/achievements.sass index c91cb9237..688f8640e 100644 --- a/app/styles/achievements.sass +++ b/app/styles/achievements.sass @@ -129,7 +129,8 @@ > .progress-bar-wrapper position: absolute - width: 331px + margin-left: 12px + width: 319px height: 20px z-index: 2 @@ -147,27 +148,27 @@ background-size: 100% 100% z-index: 1 -.notifyjs-achievement-wood-base +.notifyjs-achievement-wood-base, .achievement-wood .achievement-icon background: url("/images/achievements/border_wood.png") no-repeat background-size: 100% 100% -.notifyjs-achievement-stone-base +.notifyjs-achievement-stone-base, .achievement-stone .achievement-icon background: url("/images/achievements/border_stone.png") no-repeat background-size: 100% 100% -.notifyjs-achievement-silver-base +.notifyjs-achievement-silver-base, .achievement-silver .achievement-icon background: url("/images/achievements/border_silver.png") no-repeat background-size: 100% 100% -.notifyjs-achievement-gold-base +.notifyjs-achievement-gold-base, .achievement-gold .achievement-icon background: url("/images/achievements/border_gold.png") no-repeat background-size: 100% 100% -.notifyjs-achievement-diamond-base +.notifyjs-achievement-diamond-base, .achievement-diamond .achievement-icon background: url("/images/achievements/border_diamond.png") no-repeat background-size: 100% 100% @@ -185,3 +186,11 @@ z-index: 1000 box-shadow: 0 0 0 1px black, 0 0 0 3px lightgrey, 0 0 0 4px black font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif + +// Achievements page +h2.achievements-category + margin-left: 20px + +.table-layout + #no-achievements + margin-top: 40px diff --git a/app/templates/achievement_notify.jade b/app/templates/achievement_notify.jade index a01b832c4..505097224 100644 --- a/app/templates/achievement_notify.jade +++ b/app/templates/achievement_notify.jade @@ -1,6 +1,6 @@ -div - .clearfix.achievement-body - .achievement-icon(class=locked === true ? "locked" : "", class=border) +div(class=notifyClass) + .clearfix.achievement-body(class=locked === true ? "locked" : "") + .achievement-icon .achievement-image(data-notify-html="image") if imgURL img(src=imgURL) diff --git a/app/templates/base.jade b/app/templates/base.jade index e47222734..7eca794e8 100644 --- a/app/templates/base.jade +++ b/app/templates/base.jade @@ -51,7 +51,7 @@ body .col-xs-4.text-center a(href="/user/#{me.get('slug') || me.get('_id')}") Profile .col-xs-4.text-center - a(href="#") Stats + a(href="/user/#{me.get('slug') || me.get('_id')}/stats") Stats .col-xs-4.text-center a.disabled() Code li.user-dropdown-footer diff --git a/app/templates/kinds/user.jade b/app/templates/kinds/user.jade index 6cb134ce2..a40153635 100644 --- a/app/templates/kinds/user.jade +++ b/app/templates/kinds/user.jade @@ -3,14 +3,12 @@ extends /templates/base // User pages might have some user page specific header, if not remove this block content .clearfix - //- - if user && viewName - ol.breadcrumb - li - - var userName = user.get('name'); - //_a(href="/user/#{user.id}") #{userName} - li.active - //-| #{viewName} + if user && viewName + ol.breadcrumb + li + a(href="/user/#{user.id}") #{user.displayName()} + li.active + | #{viewName} if !userLoaded | LOADING else if !user diff --git a/app/templates/user/achievements.jade b/app/templates/user/achievements.jade index ecb2dc673..1bdf4bc85 100644 --- a/app/templates/user/achievements.jade +++ b/app/templates/user/achievements.jade @@ -1,12 +1,46 @@ extends /templates/kinds/user block append content - if achievements - .row - each achievement, index in achievements - - var title = achievement.get('name'); - - var description = achievement.get('description'); - - var imgURL = achievement.get('icon'); - - var locked = ! achievement.get('unlocked'); - .col-lg-4.col-xs-12 - include ../achievement_notify + .btn-group.pull-right + button#grid-layout-button.btn.btn-default(data-layout='grid', class=activeLayout==='grid' ? 'active' : '') Grid + button#table-layout-button.btn.btn-default(data-layout='table', class=activeLayout==='table' ? 'active' : '') Table + if achievementsByCategory + if activeLayout === 'grid' + .grid-layout + each achievements, category in achievementsByCategory + .row + h2.achievements-category=category + each achievement, index in achievements + - var title = achievement.get('name'); + - var description = achievement.get('description'); + - var imgURL = achievement.getImageURL(); + - var locked = ! achievement.get('unlocked'); + - var notifyClass = achievement.getNotifyStyle() + .col-lg-4.col-xs-12 + include ../achievement_notify + else if activeLayout === 'table' + .table-layout + if earnedAchievements.length + table.table + tr + th Name + th Description + th Date + th Amount + th XP + each earnedAchievement in earnedAchievements + - var achievement = earnedAchievement.get('achievement'); + tr + td= achievement.get('name') + td= achievement.get('description') + td= moment().format("MMMM Do YY", earnedAchievement.get('changed')) + if achievement.isRepeatable() + td= earnedAchievement.get('achievedAmount') + else + td + td= earnedAchievement.get('earnedPoints') + else + .panel#no-achievements + .panel-body No achievements earned yet. + else + div How did you even do that? diff --git a/app/views/kinds/RootView.coffee b/app/views/kinds/RootView.coffee index 3ce1add4e..0f1a046aa 100644 --- a/app/views/kinds/RootView.coffee +++ b/app/views/kinds/RootView.coffee @@ -8,6 +8,8 @@ locale = require 'locale/locale' Achievement = require '../../models/Achievement' User = require '../../models/User' +utils = require 'lib/utils' + # TODO remove filterKeyboardEvents = (allowedEvents, func) -> @@ -39,11 +41,15 @@ module.exports = class RootView extends CocoView totalExpNeeded = nextLevelExp - currentLevelExp expFunction = achievement.getExpFunction() currentExp = me.get('points') - previousExp = currentExp - achievement.get('worth') - previousExp = expFunction(earnedAchievement.get('previouslyAchievedAmount')) * achievement.get('worth') if achievement.isRepeatable() - achievedExp = currentExp - previousExp + if achievement.isRepeatable() + achievedExp = expFunction(earnedAchievement.get('previouslyAchievedAmount')) * achievement.get('worth') if achievement.isRepeatable() + else + achievedExp = achievement.get 'worth' + previousExp = currentExp - achievedExp leveledUp = currentExp - achievedExp < currentLevelExp + console.debug 'Leveled up' if leveledUp alreadyAchievedPercentage = 100 * (previousExp - currentLevelExp) / totalExpNeeded + alreadyAchievedPercentage = 0 if alreadyAchievedPercentage < 0 # In case of level up newlyAchievedPercentage = if leveledUp then 100 * (currentExp - currentLevelExp) / totalExpNeeded else 100 * achievedExp / totalExpNeeded console.debug "Current level is #{currentLevel} (#{currentLevelExp} xp), next level is #{nextLevel} (#{nextLevelExp} xp)." @@ -92,25 +98,23 @@ module.exports = class RootView extends CocoView data showNewAchievement: (achievement, earnedAchievement) -> - data = createNotifyData achievement, earnedAchievement + data = @createNotifyData achievement, earnedAchievement options = - autoHideDelay: 10000 + autoHideDelay: 1000000 globalPosition: 'bottom right' showDuration: 400 style: achievement.getNotifyStyle() - autoHide: true + autoHide: false clickToHide: true + console.debug 'showing achievement', achievement.get 'name' $.notify( data, options ) handleNewAchievements: (earnedAchievements) -> - _.each(earnedAchievements.models, (earnedAchievement) => + _.each earnedAchievements.models, (earnedAchievement) => achievement = new Achievement(_id: earnedAchievement.get('achievement')) - console.log achievement - achievement.fetch( + achievement.fetch success: (achievement) => @showNewAchievement(achievement, earnedAchievement) - ) - ) logoutAccount: -> logoutUser($('#login-email').val()) diff --git a/app/views/user/AchievementsView.coffee b/app/views/user/AchievementsView.coffee index cad94f181..03e7a851e 100644 --- a/app/views/user/AchievementsView.coffee +++ b/app/views/user/AchievementsView.coffee @@ -9,6 +9,12 @@ EarnedAchievementCollection = require 'collections/EarnedAchievementCollection' module.exports = class AchievementsView extends UserView id: 'user-achievements-view' template: template + viewName: 'Stats' + activeLayout: 'grid' + + events: + 'click #grid-layout-button': 'layoutChanged' + 'click #table-layout-button': 'layoutChanged' constructor: (userID, options) -> super options, userID @@ -29,9 +35,20 @@ module.exports = class AchievementsView extends UserView earned.set 'achievement', relatedAchievement super() + layoutChanged: (e) -> + @activeLayout = $(e.currentTarget).data 'layout' + @render() + getRenderData: -> context = super() + context.activeLayout = @activeLayout + + # After user is loaded if @user and not @user.isAnonymous() - context.achievements = @achievements.models context.earnedAchievements = @earnedAchievements.models + context.achievements = @achievements.models + context.achievementsByCategory = {} + for achievement in @achievements.models + context.achievementsByCategory[achievement.get('category')] ?= [] + context.achievementsByCategory[achievement.get('category')].push achievement context diff --git a/scripts/setupAchievements.coffee b/scripts/setupAchievements.coffee index a3463cd4b..a06c5ebce 100644 --- a/scripts/setupAchievements.coffee +++ b/scripts/setupAchievements.coffee @@ -19,7 +19,7 @@ achievements = worth: 10 collection: 'users' userField: '_id' - category: 'Miscellaneous' + category: 'miscellaneous' difficulty: 1 completedFirstLevel: @@ -29,7 +29,17 @@ achievements = worth: 50 collection: 'users' userField: '_id' - category: 'Levels' + category: 'levels' + difficulty: 1 + + completedFiveLevels: + name: 'Completed one Level' + description: 'Completed your very first level.' + query: 'stats.gamesCompleted': $gte: 1 + worth: 50 + collection: 'users' + userField: '_id' + category: 'levels' difficulty: 1 simulatedBy: @@ -39,7 +49,7 @@ achievements = worth: 1 collection: 'users' userField: '_id' - category: 'Miscellaneous' + category: 'miscellaneous' difficulty: 1 proportionalTo: 'simulatedBy' function: diff --git a/server/achievements/earned_achievement_handler.coffee b/server/achievements/earned_achievement_handler.coffee index 313da4612..4002de462 100644 --- a/server/achievements/earned_achievement_handler.coffee +++ b/server/achievements/earned_achievement_handler.coffee @@ -75,9 +75,9 @@ class EarnedAchievementHandler extends Handler return doneWithAchievement() finalQuery = _.clone achievement.get 'query' - finalQuery.$or = [{}, {}] # Allow both ObjectIDs or hexa string IDs + finalQuery.$or = [{}, {}] # Allow both ObjectIDs or hex string IDs finalQuery.$or[0][achievement.userField] = userID - finalQuery.$or[1][achievement.userField] = ObjectId userID + finalQuery.$or[1][achievement.userField] = mongoose.Types.ObjectId userID model.findOne finalQuery, (err, something) -> return doneWithAchievement() if _.isEmpty something diff --git a/test/app/lib/utils.spec.coffee b/test/app/lib/utils.spec.coffee index 061a9aa71..bb1ab7233 100644 --- a/test/app/lib/utils.spec.coffee +++ b/test/app/lib/utils.spec.coffee @@ -1,45 +1,48 @@ -describe 'utils library', -> +describe 'Utility library', -> util = require 'lib/utils' - beforeEach -> - this.fixture1 = - 'text': 'G\'day, Wizard! Come to practice? Well, let\'s get started...' - 'blurb': 'G\'day' - 'i18n': - 'es-419': - 'text': '¡Buenas, Hechicero! ¿Vienes a practicar? Bueno, empecemos...' - 'es-ES': - 'text': '¡Buenas Mago! ¿Vienes a practicar? Bien, empecemos...' - 'es': - 'text': '¡Buenas Mago! ¿Vienes a practicar? Muy bien, empecemos...' - 'fr': - 'text': 'S\'lut, Magicien! Venu pratiquer? Ok, bien débutons...' - 'pt-BR': - 'text': 'Bom dia, feiticeiro! Veio praticar? Então vamos começar...' - 'en': - 'text': 'Ohai Magician!' - 'de': - 'text': '\'N Tach auch, Zauberer! Kommst Du zum Üben? Dann lass uns anfangen...' - 'sv': - 'text': 'Godagens, trollkarl! Kommit för att öva? Nå, låt oss börja...' + describe 'i18n', -> + beforeEach -> + this.fixture1 = + 'text': 'G\'day, Wizard! Come to practice? Well, let\'s get started...' + 'blurb': 'G\'day' + 'i18n': + 'es-419': + 'text': '¡Buenas, Hechicero! ¿Vienes a practicar? Bueno, empecemos...' + 'es-ES': + 'text': '¡Buenas Mago! ¿Vienes a practicar? Bien, empecemos...' + 'es': + 'text': '¡Buenas Mago! ¿Vienes a practicar? Muy bien, empecemos...' + 'fr': + 'text': 'S\'lut, Magicien! Venu pratiquer? Ok, bien débutons...' + 'pt-BR': + 'text': 'Bom dia, feiticeiro! Veio praticar? Então vamos começar...' + 'en': + 'text': 'Ohai Magician!' + 'de': + 'text': '\'N Tach auch, Zauberer! Kommst Du zum Üben? Dann lass uns anfangen...' + 'sv': + 'text': 'Godagens, trollkarl! Kommit för att öva? Nå, låt oss börja...' - it 'i18n should find a valid target string', -> - expect(util.i18n(this.fixture1, 'text', 'sv')).toEqual(this.fixture1.i18n['sv'].text) - expect(util.i18n(this.fixture1, 'text', 'es-ES')).toEqual(this.fixture1.i18n['es-ES'].text) + it 'i18n should find a valid target string', -> + expect(util.i18n(this.fixture1, 'text', 'sv')).toEqual(this.fixture1.i18n['sv'].text) + expect(util.i18n(this.fixture1, 'text', 'es-ES')).toEqual(this.fixture1.i18n['es-ES'].text) - it 'i18n picks the correct fallback for a specific language', -> - expect(util.i18n(this.fixture1, 'text', 'fr-be')).toEqual(this.fixture1.i18n['fr'].text) + it 'i18n picks the correct fallback for a specific language', -> + expect(util.i18n(this.fixture1, 'text', 'fr-be')).toEqual(this.fixture1.i18n['fr'].text) - it 'i18n picks the correct fallback', -> - expect(util.i18n(this.fixture1, 'text', 'nl')).toEqual(this.fixture1.i18n['en'].text) - expect(util.i18n(this.fixture1, 'text', 'nl', 'de')).toEqual(this.fixture1.i18n['de'].text) + it 'i18n picks the correct fallback', -> + expect(util.i18n(this.fixture1, 'text', 'nl')).toEqual(this.fixture1.i18n['en'].text) + expect(util.i18n(this.fixture1, 'text', 'nl', 'de')).toEqual(this.fixture1.i18n['de'].text) - it 'i18n falls back to the default text, even for other targets (like blurb)', -> - delete this.fixture1.i18n['en'] - expect(util.i18n(this.fixture1, 'text', 'en')).toEqual(this.fixture1.text) - expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(this.fixture1.blurb) - delete this.fixture1.blurb - expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(null) + it 'i18n falls back to the default text, even for other targets (like blurb)', -> + delete this.fixture1.i18n['en'] + expect(util.i18n(this.fixture1, 'text', 'en')).toEqual(this.fixture1.text) + expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(this.fixture1.blurb) + delete this.fixture1.blurb + expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(null) - it 'i18n can fall forward if a general language is not found', -> - expect(util.i18n(this.fixture1, 'text', 'pt')).toEqual(this.fixture1.i18n['pt-BR'].text) + it 'i18n can fall forward if a general language is not found', -> + expect(util.i18n(this.fixture1, 'text', 'pt')).toEqual(this.fixture1.i18n['pt-BR'].text) + + describe 'Miscellaneous utility', -> diff --git a/test/app/models/User.spec.coffee b/test/app/models/User.spec.coffee index b751690ed..35b87f450 100644 --- a/test/app/models/User.spec.coffee +++ b/test/app/models/User.spec.coffee @@ -3,7 +3,8 @@ User = require 'models/User' describe 'UserModel', -> it 'experience functions are correct', -> expect(User.expForLevel(User.levelFromExp 0)).toBe 0 - expect(User.expForLevel(User.levelFromExp 50)).toBe 50 + expect(User.levelFromExp User.expForLevel 1).toBe 1 + expect(User.levelFromExp User.expForLevel 10).toBe 10 expect(User.expForLevel 1).toBe 0 expect(User.expForLevel 2).toBeGreaterThan User.expForLevel 1 @@ -13,4 +14,3 @@ describe 'UserModel', -> me.set 'points', 50 expect(me.level()).toBe User.levelFromExp 50 -