diff --git a/app/assets/images/common/gem.png b/app/assets/images/common/gem.png new file mode 100644 index 000000000..9598f6bef Binary files /dev/null and b/app/assets/images/common/gem.png differ diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 2dcbae4c3..a053a90d0 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -95,7 +95,7 @@ home: slogan: "Learn to Code by Playing a Game" - no_ie: "CodeCombat does not run in Internet Explorer 9 or older. Sorry!" + no_ie: "CodeCombat does not run in Internet Explorer 8 or older. Sorry!" no_mobile: "CodeCombat wasn't designed for mobile devices and may not work!" play: "Play" # The big play button that just starts playing a level old_browser: "Uh oh, your browser is too old to run CodeCombat. Sorry!" diff --git a/app/models/User.coffee b/app/models/User.coffee index 25071ac26..9a1ec0846 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -64,3 +64,15 @@ module.exports = class User extends CocoModel level: -> User.levelFromExp(@get('points')) + + gems: -> + gemsEarned = @get('earned')?.gems ? 0 + purchased = @get('purchased') ? {} + gemsPurchased = purchased.gems ? 0 + sum = (arr) -> arr?.reduce((a, b) -> a + b) ? 0 + gemsSpent = sum(purchased.heroes) + sum(purchased.items) + sum(purchased.levels) + gemsEarned + gemsPurchased - gemsSpent + + earnedHero: (heroOriginal) -> heroOriginal in me.get('earned')?.heroes ? [] + earnedItem: (itemOriginal) -> itemOriginal in me.get('earned')?.items ? [] + earnedLevel: (levelOriginal) -> levelOriginal in me.get('earned')?.levels ? [] diff --git a/app/schemas/models/achievement.coffee b/app/schemas/models/achievement.coffee index 37690cadf..d364c0277 100644 --- a/app/schemas/models/achievement.coffee +++ b/app/schemas/models/achievement.coffee @@ -45,7 +45,7 @@ _.extend AchievementSchema.properties, query: #type:'object' $ref: '#/definitions/mongoFindQuery' - worth: c.float + worth: c.float() collection: {type: 'string'} description: c.shortString() userField: c.shortString() @@ -61,7 +61,7 @@ _.extend AchievementSchema.properties, description: 'For repeatables only. Denotes the field a repeatable achievement needs for its calculations' recalculable: type: 'boolean' - description: 'Needs to be set to true before it is elligible for recalculation.' + description: 'Needs to be set to true before it is eligible for recalculation.' function: type: 'object' description: 'Function that gives total experience for X amount achieved' @@ -82,6 +82,8 @@ _.extend AchievementSchema.properties, format: 'i18n' props: ['name', 'description'] description: 'Help translate this achievement' + rewards: c.RewardSchema 'awarded by this achievement' + _.extend AchievementSchema, # Let's have these on the bottom # TODO We really need some required properties in my opinion but this makes creating new achievements impossible as it is now diff --git a/app/schemas/models/earned_achievement.coffee b/app/schemas/models/earned_achievement.coffee index 04cfe9b91..4b1848c3e 100644 --- a/app/schemas/models/earned_achievement.coffee +++ b/app/schemas/models/earned_achievement.coffee @@ -5,7 +5,7 @@ module.exports = type: 'object' default: previouslyAchievedAmount: 0 - + properties: user: c.objectId links: @@ -30,4 +30,5 @@ module.exports = achievedAmount: type: 'number' earnedPoints: type: 'number' previouslyAchievedAmount: {type: 'number'} + earnedRewards: c.RewardSchema 'awarded by this achievement to this user' notified: type: 'boolean' diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 9901c5d76..11e9c9716 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -17,6 +17,8 @@ UserSchema = c.object simulatedBy: 0 simulatedFor: 0 jobProfile: {} + earned: {heroes: [], items: [], levels: [], gems: 0} + purchased: {heroes: [], items: [], levels: [], gems: 0} c.extendNamedProperties UserSchema # let's have the name be the first property @@ -265,6 +267,8 @@ _.extend UserSchema.properties, thangTypeTranslationPatches: c.int() thangTypeMiscPatches: c.int() + earned: c.RewardSchema 'earned by achievements' + purchased: c.RewardSchema 'purchased with gems' c.extendBasicProperties UserSchema, 'user' diff --git a/app/schemas/schemas.coffee b/app/schemas/schemas.coffee index da768fff0..5d30ac865 100644 --- a/app/schemas/schemas.coffee +++ b/app/schemas/schemas.coffee @@ -18,6 +18,7 @@ me.pct = (ext) -> combine({type: 'number', maximum: 1.0, minimum: 0.0}, ext) me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext) # should just be string (Mongo ID), but sometimes mongoose turns them into objects representing those, so we are lenient me.objectId = (ext) -> schema = combine({type: ['object', 'string']}, ext) +me.stringID = (ext) -> schema = combine({type: 'string', minLength: 24, maxLength: 24}, ext) me.url = (ext) -> combine({type: 'string', format: 'url', pattern: urlPattern}, ext) me.int = (ext) -> combine {type: 'integer'}, ext me.float = (ext) -> combine {type: 'number'}, ext @@ -209,3 +210,16 @@ me.HeroConfigSchema = me.object {description: 'Which hero the player is using, e description: 'The inventory of the hero: slots to item ThangTypes.' additionalProperties: me.objectId(description: 'An item ThangType.') thangType: me.objectId(links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], title: 'Thang Type', description: 'The ThangType of the hero.', format: 'thang-type') + +me.RewardSchema = (descriptionFragment='earned by achievements') -> + type: 'object' + additionalProperties: false + description: "Rewards #{descriptionFragment}." + properties: + heroes: me.array {uniqueItems: true, description: "Heroes #{descriptionFragment}."}, + me.stringID(links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], title: 'Hero ThangType', description: 'A reference to the earned hero ThangType.', format: 'thang-type') + items: me.array {uniqueItems: true, description: "Items #{descriptionFragment}."}, + 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}."}, + 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}."} diff --git a/app/styles/base.sass b/app/styles/base.sass index 0522b50a1..2305f4f38 100644 --- a/app/styles/base.sass +++ b/app/styles/base.sass @@ -314,3 +314,27 @@ kbd background-color: #333 border-radius: 3px @include box-shadow(inset 0 -1px 0 rgba(0, 0, 0, .25)) + +.gem + display: inline-block + background: transparent url(/images/common/gem.png) no-repeat center + background-size: contain + width: 80px + height: 80px + margin: 0px 2px + + &.gem-20 + width: 20px + height: 20px + + &.gem-25 + width: 25px + height: 25px + + &.gem-40 + width: 40px + height: 40px + + &.gem-60 + width: 60px + height: 60px diff --git a/app/styles/play/world-map-view.sass b/app/styles/play/world-map-view.sass index c15c5ba69..5252b57a0 100644 --- a/app/styles/play/world-map-view.sass +++ b/app/styles/play/world-map-view.sass @@ -52,9 +52,16 @@ $gameControlMargin: 30px border: 2px groove white @include transition(margin-bottom 0.5s ease) - &.disabled + &.disabled, &.locked + background-image: url(/images/pages/game-menu/lock.png) + background-size: 75% + background-repeat: no-repeat + background-position: 50% 50% opacity: 0.7 + a + cursor: default + &.next width: 2 * $levelDotWidth height: 2 * $levelDotHeight diff --git a/app/styles/recruitment_base.sass b/app/styles/recruitment_base.sass index f6c6e1062..1846007d3 100644 --- a/app/styles/recruitment_base.sass +++ b/app/styles/recruitment_base.sass @@ -1,6 +1,5 @@ @import "bootstrap/variables" @import "bootstrap/mixins" -@import "base" #employers-wrapper background-color: #B4B4B4 @@ -51,5 +50,3 @@ #login-button margin-left: 40% width: 20% - - \ No newline at end of file diff --git a/app/templates/game-menu/choose-hero-view.jade b/app/templates/game-menu/choose-hero-view.jade index 1e5fe4f4e..829b9ab27 100644 --- a/app/templates/game-menu/choose-hero-view.jade +++ b/app/templates/game-menu/choose-hero-view.jade @@ -2,23 +2,25 @@ .carousel-indicator-container ol.carousel-indicators for hero, index in heroes - - var info = heroInfo[hero.get('slug')] - li(data-hero-id=hero.get('original'), title=hero.get('name'), data-slide-to=index, data-target="#hero-carousel", class="hero-indicator" + (info.status == "Locked" ? " locked" : "")) + li(data-hero-id=hero.get('original'), title=hero.get('name'), data-slide-to=index, data-target="#hero-carousel", class="hero-indicator" + (hero.locked ? " locked" : "")) .hero-avatar - if info.status == "Locked" + if hero.locked img.lock-indicator(src="/images/pages/game-menu/lock.png") .carousel-inner for hero in heroes - var info = heroInfo[hero.get('slug')] - div(class="item hero-item" + (info.status == "Locked" ? " locked" : ""), data-hero-id=hero.get('original')) + div(class="item hero-item" + (hero.locked ? " locked" : ""), data-hero-id=hero.get('original')) canvas.hero-canvas .hero-stats h2= info.fullName p span(data-i18n="choose_hero.status") Status span.spr : - | #{info.status} + if hero.locked + | #{info.status} + else + | Available p span(data-i18n="choose_hero.weapons") Weapons span.spr : diff --git a/app/templates/home.jade b/app/templates/home.jade index 4a4ab5426..119032578 100644 --- a/app/templates/home.jade +++ b/app/templates/home.jade @@ -4,8 +4,8 @@ block content h1#site-slogan(data-i18n="home.slogan") Learn to Code by Playing a Game - .alert.alert-danger.lt-ie10 - strong(data-i18n="home.no_ie") CodeCombat does not run in Internet Explorer 9 or older. Sorry! + .alert.alert-danger.lt-ie9 + strong(data-i18n="home.no_ie") CodeCombat does not run in Internet Explorer 8 or older. Sorry! if isMobile .alert.alert-danger.mobile diff --git a/app/templates/play/world-map-view.jade b/app/templates/play/world-map-view.jade index b6aa7f806..88523f2e4 100644 --- a/app/templates/play/world-map-view.jade +++ b/app/templates/play/world-map-view.jade @@ -6,12 +6,12 @@ each level in campaign.levels - var next = !seenNext && levelStatusMap[level.id] != "complete"; - seenNext = seenNext || next; - div(style="left: #{level.x}%; bottom: #{level.y}%; background-color: #{campaign.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + " " + levelStatusMap[level.id] || "", data-level-id=level.id, title=level.name) + div(style="left: #{level.x}%; bottom: #{level.y}%; background-color: #{campaign.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.id] || "", data-level-id=level.id, title=level.name) a(href=level.type == 'hero' ? '#' : level.disabled ? "/play" : "/play/#{level.levelPath || 'level'}/#{level.id}", disabled=level.disabled, data-level-id=level.id, data-level-path=level.levelPath || 'level', data-level-name=level.name) div(style="left: #{level.x}%; bottom: #{level.y}%", class="level-shadow" + (next ? " next" : "") + " " + levelStatusMap[level.id] || "") .level-info-container(data-level-id=level.id, data-level-path=level.levelPath || 'level', data-level-name=level.name) div(class="level-info " + (levelStatusMap[level.id] || "")) - h3= level.name + (level.disabled ? " (Coming soon!)" : "") + h3= level.name + (level.disabled ? " (Coming soon!)" : (level.locked ? " (Locked)" : "")) .level-description= level.description span(data-i18n="play.level_difficulty") Difficulty: each i in Array(level.difficulty) @@ -46,6 +46,8 @@ a(href="/play-old", data-i18n="play.older_campaigns").header-font Older Campaigns .user-status.header-font + span.gem.gem-20 + span.spr= me.gems() if me.get('anonymous') span.spr(data-i18n="play.anonymous_player") Anonymous Player button.btn.btn-default.btn-flat.btn-sm(data-toggle='coco-modal', data-target='modal/AuthModal', data-i18n="login.log_in") diff --git a/app/treema-ext.coffee b/app/treema-ext.coffee index 81020adac..8fe5242cc 100644 --- a/app/treema-ext.coffee +++ b/app/treema-ext.coffee @@ -360,11 +360,14 @@ class LatestVersionReferenceNode extends TreemaNode return 'Unknown' unless @settings.supermodel? m = CocoModel.getReferencedModel(@getData(), @workingSchema) data = @getData() - m = @settings.supermodel.getModelByOriginalAndMajorVersion(m.constructor, data.original, data.majorVersion) + if _.isString data # LatestVersionOriginalReferenceNode just uses original + m = @settings.supermodel.getModelByOriginal(m.constructor, data) + else + m = @settings.supermodel.getModelByOriginalAndMajorVersion(m.constructor, data.original, data.majorVersion) if @instance and not m m = @instance @settings.supermodel.registerModel(m) - return 'Unknown' unless m + return 'Unknown - ' + (data.original ? data) unless m return @modelToString(m) saveChanges: -> @@ -409,6 +412,15 @@ class LatestVersionReferenceNode extends TreemaNode selected = @getSelectedResultEl() return not selected.length +class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode + # Just for saving the original, not the major version. + saveChanges: -> + selected = @getSelectedResultEl() + return unless selected.length + fullValue = selected.data('value') + @data = fullValue.attributes.original + @instance = fullValue + class LevelComponentReferenceNode extends LatestVersionReferenceNode # HACK: this list of properties is needed by the thang components edit view and config views. # need a better way to specify this, or keep the search models from bleeding into those @@ -436,6 +448,7 @@ module.exports.setup = -> TreemaNode.setNodeSubclass('javascript', JavaScriptTreema) TreemaNode.setNodeSubclass('image-file', ImageFileTreema) TreemaNode.setNodeSubclass('latest-version-reference', LatestVersionReferenceNode) + TreemaNode.setNodeSubclass('latest-version-original-reference', LatestVersionOriginalReferenceNode) TreemaNode.setNodeSubclass('component-reference', LevelComponentReferenceNode) TreemaNode.setNodeSubclass('i18n', InternationalizationNode) TreemaNode.setNodeSubclass('sound-file', SoundFileTreema) diff --git a/app/views/achievements/AchievementPopup.coffee b/app/views/achievements/AchievementPopup.coffee index f25d9e88c..31356abbc 100644 --- a/app/views/achievements/AchievementPopup.coffee +++ b/app/views/achievements/AchievementPopup.coffee @@ -15,8 +15,6 @@ module.exports = class AchievementPopup extends CocoView @popup ?= true @className += ' popup' if @popup super options - console.debug 'Created an AchievementPopup', @$el - @render() calculateData: -> @@ -62,7 +60,6 @@ module.exports = class AchievementPopup extends CocoView c render: -> - console.debug 'render achievement popup' super() @container.prepend @$el if @popup diff --git a/app/views/editor/achievement/AchievementEditView.coffee b/app/views/editor/achievement/AchievementEditView.coffee index cf50026a5..fbe1a60f1 100644 --- a/app/views/editor/achievement/AchievementEditView.coffee +++ b/app/views/editor/achievement/AchievementEditView.coffee @@ -5,6 +5,7 @@ AchievementPopup = require 'views/achievements/AchievementPopup' ConfirmModal = require 'views/modal/ConfirmModal' errors = require 'lib/errors' app = require 'application' +nodes = require 'views/editor/level/treema_nodes' module.exports = class AchievementEditView extends RootView id: 'editor-achievement-edit-view' @@ -36,8 +37,13 @@ module.exports = class AchievementEditView extends RootView readOnly: me.get('anonymous') callbacks: change: @pushChangesToPreview + nodeClasses: + 'thang-type': nodes.ThangTypeNode + 'item-thang-type': nodes.ItemThangTypeNode + supermodel: @supermodel @treema = @$el.find('#achievement-treema').treema(options) @treema.build() + @treema.childrenTreemas.rewards?.open(3) @pushChangesToPreview() getRenderData: (context={}) -> diff --git a/app/views/game-menu/ChooseHeroView.coffee b/app/views/game-menu/ChooseHeroView.coffee index 11fdc8e4d..9903c00f1 100644 --- a/app/views/game-menu/ChooseHeroView.coffee +++ b/app/views/game-menu/ChooseHeroView.coffee @@ -36,6 +36,7 @@ module.exports = class ChooseHeroView extends CocoView getRenderData: (context={}) -> context = super(context) context.heroes = @heroes.models + hero.locked = temporaryHeroInfo[hero.get('slug')].status is 'Locked' and not me.earnedHero hero.get('original') for hero in context.heroes context.level = @options.level context.codeLanguages = [ {id: 'python', name: 'Python'} @@ -76,7 +77,7 @@ module.exports = class ChooseHeroView extends CocoView size = 100 - (50 / 3) * distance $(@).css width: size, height: size, top: -(100 - size) / 2 heroInfo = temporaryHeroInfo[hero.get('slug')] - locked = heroInfo.status is 'Locked' + locked = heroInfo.status is 'Locked' and not me.earnedHero ThangType.heroes[hero.get('slug')] hero = @loadHero hero, heroIndex @preloadHero heroIndex + 1 @preloadHero heroIndex - 1 diff --git a/app/views/game-menu/InventoryView.coffee b/app/views/game-menu/InventoryView.coffee index c2032813a..a406a8313 100644 --- a/app/views/game-menu/InventoryView.coffee +++ b/app/views/game-menu/InventoryView.coffee @@ -321,6 +321,8 @@ module.exports = class InventoryView extends CocoView for slot, item of items @allowedItems.push gear[item] unless gear[item] in @allowedItems break if level is @options.levelID + for item in me.get('earned')?.items ? [] when not (item in @allowedItems) + @allowedItems.push item onHeroSelectionUpdated: (e) -> @selectedHero = e.hero diff --git a/app/views/play/WorldMapView.coffee b/app/views/play/WorldMapView.coffee index cc333cbe3..99511e68c 100644 --- a/app/views/play/WorldMapView.coffee +++ b/app/views/play/WorldMapView.coffee @@ -65,9 +65,10 @@ module.exports = class WorldMapView extends RootView context = super(context) context.campaigns = campaigns for campaign in context.campaigns - for level in campaign.levels + for level, index in campaign.levels level.x ?= 10 + 80 * Math.random() level.y ?= 10 + 80 * Math.random() + level.locked = index > 0 and not me.earnedLevel level.original context.levelStatusMap = @levelStatusMap context.levelPlayCountMap = @levelPlayCountMap context.isIPadApp = application.isIPadApp @@ -96,7 +97,7 @@ module.exports = class WorldMapView extends RootView e.preventDefault() e.stopPropagation() @$levelInfo?.hide() - return if $(e.target).attr('disabled') + return if $(e.target).attr('disabled') or $(e.target).parent().hasClass 'locked' if application.isIPadApp levelID = $(e.target).parents('.level').data('level-id') @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() @@ -515,6 +516,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'dungeons-of-kithgard' + original: '528110f30268d018e3000001' description: 'Grab the gem, but touch nothing else. Start here.' x: 17.23 y: 36.94 @@ -524,6 +526,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'gems-in-the-deep' + original: '54173c90844506ae0195a0b4' description: 'Quickly collect the gems; you will need them.' x: 22.6 y: 35.1 @@ -533,6 +536,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'shadow-guard' + original: '54174347844506ae0195a0b8' description: 'Evade the Kithgard minion.' x: 27.74 y: 35.17 @@ -542,6 +546,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'true-names' + original: '541875da4c16460000ab990f' description: 'Learn an enemy\'s true name to defeat it.' x: 32.7 y: 36.7 @@ -551,6 +556,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'the-raised-sword' + original: '5418aec24c16460000ab9aa6' description: 'Learn to equip yourself for combat.' x: 36.6 y: 39.5 @@ -560,6 +566,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'the-first-kithmaze' + original: '5418b9d64c16460000ab9ab4' description: 'The builders of Kith constructed many mazes to confuse travelers.' x: 38.4 y: 43.5 @@ -569,6 +576,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'the-second-kithmaze' + original: '5418cf256bae62f707c7e1c3' description: 'Many have tried, few have found their way through this maze.' x: 38.9 y: 48.1 @@ -578,6 +586,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'new-sight' + original: '5418d40f4c16460000ab9ac2' description: 'A true name can only be seen with the correct lenses.' x: 39.3 y: 53.1 @@ -587,6 +596,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'lowly-kithmen' + original: '541b24511ccc8eaae19f3c1f' description: 'Use your glasses to seek out and attack the Kithmen.' x: 39.4 y: 57.7 @@ -596,6 +606,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'a-bolt-in-the-dark' + original: '541b288e1ccc8eaae19f3c25' description: 'Kithmen are not the only ones to stand in your way.' x: 40.0 y: 63.2 @@ -605,6 +616,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'the-final-kithmaze' + original: '541b434e1ccc8eaae19f3c33' description: 'To escape you must find your way through an Elder Kithman\'s maze.' x: 42.67 y: 67.98 @@ -614,6 +626,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'kithgard-gates' + original: '541c9a30c6362edfb0f34479' description: 'Escape the Kithgard dungeons and don\'t let the guardians get you.' x: 47.38 y: 70.55 @@ -624,6 +637,7 @@ hero = [ type: 'hero' difficulty: 1 id: 'defence-of-plainswood' + original: '541b67f71ccc8eaae19f3c62' description: 'Protect the peasants from the pursuing ogres.' x: 52.66 y: 69.66 diff --git a/package.json b/package.json index 95d305bcd..d5a56c0af 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "javascript-brunch": "> 1.0 < 1.8", "coffee-script-brunch": "https://github.com/brunch/coffee-script-brunch/tarball/master", "coffeelint-brunch": "> 1.0 < 1.8", - "sass-brunch": "1.7.0", + "sass-brunch": "1.8.3", "css-brunch": "> 1.0 < 1.8", "jade-brunch": "> 1.0 < 1.8", "uglify-js-brunch": "~1.7.4", @@ -86,7 +86,7 @@ "marked": "0.2.x", "telepath-brunch": "https://github.com/nwinter/telepath-brunch/tarball/master", "bower": "~1.3.8", - "bless-brunch": "~1.6.1", + "bless-brunch": "https://github.com/ThomasConner/bless-brunch/tarball/master", "karma-script-launcher": "~0.1.0", "karma-chrome-launcher": "~0.1.2", "karma-firefox-launcher": "~0.1.3", diff --git a/server/achievements/achievement_handler.coffee b/server/achievements/achievement_handler.coffee index 3b9833836..7aed663ea 100644 --- a/server/achievements/achievement_handler.coffee +++ b/server/achievements/achievement_handler.coffee @@ -5,7 +5,7 @@ class AchievementHandler extends Handler modelClass: Achievement # Used to determine which properties requests may edit - editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon', 'function', 'related', 'difficulty', 'category', 'recalculable'] + editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon', 'function', 'related', 'difficulty', 'category', 'recalculable', 'rewards'] allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] jsonSchema = require '../../app/schemas/models/achievement.coffee' diff --git a/server/achievements/earned_achievement_handler.coffee b/server/achievements/earned_achievement_handler.coffee index 75359b78a..b61e05b1e 100644 --- a/server/achievements/earned_achievement_handler.coffee +++ b/server/achievements/earned_achievement_handler.coffee @@ -77,15 +77,21 @@ class EarnedAchievementHandler extends Handler EarnedAchievement.find {user: userID}, (err, alreadyEarned) -> alreadyEarnedIDs = [] previousPoints = 0 + previousRewards = heroes: [], items: [], levels: [], gems: 0 async.each alreadyEarned, ((earned, doneWithEarned) -> if (_.find achievements, (single) -> earned.get('achievement') is single.get('_id').toHexString()) # if already earned alreadyEarnedIDs.push earned.get('achievement') previousPoints += earned.get 'earnedPoints' + for rewardType in ['heroes', 'items', 'levels'] + previousRewards[rewardType] = previousRewards[rewardType].concat(earned.get('earnedRewards')?[rewardType] ? []) + previousRewards.gems += earned.get('earnedRewards')?.gems ? 0 doneWithEarned() - ), -> # After checking already achieved + ), (err) -> # After checking already achieved + log.error err if err # TODO maybe also delete earned? Make sure you don't delete too many newTotalPoints = 0 + newTotalRewards = heroes: [], items: [], levels: [], gems: 0 async.each achievements, ((achievement, doneWithAchievement) -> return doneWithAchievement() unless achievement.isRecalculable() @@ -122,17 +128,43 @@ class EarnedAchievementHandler extends Handler earned.earnedPoints = newPoints newTotalPoints += newPoints + earned.earnedRewards = achievement.get('rewards') + for rewardType in ['heroes', 'items', 'levels'] + newTotalRewards[rewardType] = newTotalRewards[rewardType].concat(achievement.get('rewards')?[rewardType] ? []) + newTotalRewards.gems += achievement.get('rewards')?.gems ? 0 + EarnedAchievement.update {achievement:earned.achievement, user:earned.user}, earned, {upsert: true}, (err) -> doneWithAchievement err - ), -> # Wrap up a user, save points + ), (err) -> # Wrap up a user, save points + log.error err if err # Since some achievements cannot be recalculated it's important to deduct the old amount of exp # and add the new amount, instead of just setting to the new amount - return doneWithUser(user) unless newTotalPoints + #console.log 'User', user.get('name'), 'had newTotalPoints', newTotalPoints, 'and newTotalRewards', newTotalRewards + return doneWithUser(user) unless newTotalPoints or newTotalRewards.gems or _.some(newTotalRewards, (r) -> r.length) # log.debug "Matched a total of #{newTotalPoints} new points" # log.debug "Incrementing score for these achievements with #{newTotalPoints - previousPoints}" pctDone = (100 * usersFinished / total).toFixed(2) console.log "Updated points to #{newTotalPoints}(+#{newTotalPoints - previousPoints}) for #{user.get('name') or '???'} (#{user.get('_id')}) (#{pctDone}%)" - User.update {_id: userID}, {$inc: points: newTotalPoints - previousPoints}, {}, (err) -> + update = {$inc: {points: newTotalPoints - previousPoints}} + for rewardType, rewards of newTotalRewards + if rewardType is 'gems' + update.$inc['earned.gems'] = rewards - previousRewards.gems + else + previousCounts = _.countBy previousRewards[rewardType] + newCounts = _.countBy rewards + relevantRewards = _.union _.keys(previousCounts), _.keys(newCounts) + for reward in relevantRewards + [previousCount, newCount] = [previousCounts[reward], newCounts[reward]] + if newCount and not previousCount + update.$addToSet ?= {} + update.$addToSet["earned.#{rewardType}"] ?= {$each: []} + update.$addToSet["earned.#{rewardType}"].$each.push reward + else if previousCount and not newCount + # Might $pull $each also work here? + update.$pullAll ?= {} + update.$pullAll["earned.#{rewardType}"] ?= [] + update.$pullAll["earned.#{rewardType}"].push reward + User.update {_id: userID}, update, {}, (err) -> log.error err if err? doneWithUser(user) diff --git a/server/plugins/achievements.coffee b/server/plugins/achievements.coffee index 040ecd2db..c0dacf96f 100644 --- a/server/plugins/achievements.coffee +++ b/server/plugins/achievements.coffee @@ -50,12 +50,20 @@ AchievablePlugin = (schema, options) -> user: userID achievement: achievement._id.toHexString() achievementName: achievement.get 'name' + earnedRewarsd: achievement.get 'rewards' worth = achievement.get('worth') ? 10 earnedPoints = 0 wrapUp = -> # Update user's experience points - User.update {_id: userID}, {$inc: {points: earnedPoints}}, {}, (err, count) -> + update = {$inc: {points: earnedPoints}} + for rewardType, rewards of achievement.get('rewards') ? {} + if rewardType is 'gems' + update.$inc['earned.gems'] = rewards if rewards + else if rewards.length + update.$addToSet ?= {} + update.$addToSet["earned.#{rewardType}"] = $each: rewards + User.update {_id: userID}, update, {}, (err, count) -> log.error err if err? if isRepeatable