Merge branch 'master' into production

This commit is contained in:
Nick Winter 2014-12-02 09:18:53 -08:00
commit 9346b93cfd
20 changed files with 262 additions and 67 deletions

View file

@ -4,9 +4,16 @@ var Global = self;
importScripts("/javascripts/lodash.js", "/javascripts/aether.js");
//console.log("Aether Tome worker has finished importing scripts.");
var aethers = {};
var languagesImported = {};
var createAether = function (spellKey, options)
{
var ensureLanguageImported = function(language) {
if (languagesImported[language]) return;
importScripts("/javascripts/app/vendor/aether-" + language + ".js");
languagesImported[language] = true;
};
var createAether = function (spellKey, options) {
ensureLanguageImported(options.language);
aethers[spellKey] = new Aether(options);
return JSON.stringify({
"message": "Created aether for " + spellKey,
@ -15,7 +22,6 @@ var createAether = function (spellKey, options)
};
var hasChangedSignificantly = function(spellKey, a,b,careAboutLineNumbers,careAboutLint) {
var hasChanged = aethers[spellKey].hasChangedSignificantly(a,b,careAboutLineNumbers,careAboutLint);
var functionName = "hasChangedSignificantly";
var returnObject = {
@ -26,8 +32,8 @@ var hasChangedSignificantly = function(spellKey, a,b,careAboutLineNumbers,careAb
return JSON.stringify(returnObject);
};
var updateLanguageAether = function(newLanguage)
{
var updateLanguageAether = function(newLanguage) {
ensureLanguageImported(newLanguage);
for (var spellKey in aethers)
{
if (aethers.hasOwnProperty(spellKey))
@ -38,8 +44,7 @@ var updateLanguageAether = function(newLanguage)
}
};
var lint = function(spellKey, source)
{
var lint = function(spellKey, source) {
var currentAether = aethers[spellKey];
var lintMessages = currentAether.lint(source);
var functionName = "lint";
@ -50,8 +55,7 @@ var lint = function(spellKey, source)
return JSON.stringify(returnObject);
};
var transpile = function(spellKey, source)
{
var transpile = function(spellKey, source) {
var currentAether = aethers[spellKey];
currentAether.transpile(source);
var functionName = "transpile";
@ -62,6 +66,7 @@ var transpile = function(spellKey, source)
};
return JSON.stringify(returnObject);
};
self.addEventListener('message', function(e) {
var data = JSON.parse(e.data);
if (data.function == "createAether")
@ -70,7 +75,7 @@ self.addEventListener('message', function(e) {
}
else if (data.function == "updateLanguageAether")
{
updateLanguageAether(data.newLanguage)
updateLanguageAether(data.newLanguage);
}
else if (data.function == "hasChangedSignificantly")
{

View file

@ -64,6 +64,24 @@ console.error = console.warn = console.info = console.debug = console.log;
self.console = console;
self.importScripts('/javascripts/lodash.js', '/javascripts/world.js', '/javascripts/aether.js');
var myImportScripts = importScripts;
var languagesImported = {};
var ensureLanguageImported = function(language) {
if (languagesImported[language]) return;
if (language === 'javascript') return; // Only has JSHint, but we don't need to lint here.
myImportScripts("/javascripts/app/vendor/aether-" + language + ".js");
languagesImported[language] = true;
};
var ensureLanguagesImportedFromUserCodeMap = function (userCodeMap) {
for (var thangID in userCodeMap)
for (var spellName in userCodeMap[thangID]) {
var language = userCodeMap[thangID][spellName].originalOptions.language;
ensureLanguageImported(language);
}
};
var restricted = ["XMLHttpRequest", "Worker"];
if (!self.navigator || !(self.navigator.userAgent.indexOf('MSIE') > 0) &&
@ -283,6 +301,7 @@ self.setupDebugWorldToRunUntilFrame = function (args) {
self.debugt0 = new Date();
self.logsLogged = 0;
ensureLanguagesImportedFromUserCodeMap(args.userCodeMap);
var stringifiedUserCodeMap = JSON.stringify(args.userCodeMap);
var userCodeMapHasChanged = ! _.isEqual(self.currentUserCodeMapCopy, stringifiedUserCodeMap);
self.currentUserCodeMapCopy = stringifiedUserCodeMap;
@ -347,6 +366,7 @@ self.runWorld = function runWorld(args) {
self.logsLogged = 0;
try {
ensureLanguagesImportedFromUserCodeMap(args.userCodeMap);
self.world = new World(args.userCodeMap);
self.world.levelSessionIDs = args.levelSessionIDs;
self.world.submissionCount = args.submissionCount;

View file

@ -77,7 +77,13 @@ module.exports = ModuleLoader = class ModuleLoader extends CocoClass
# just a bit of cleanup to get the script objects out of the body element
$(e.result).remove()
# get treema set up only when the library loads, if it loads
if e.item.id is 'vendor/treema'
console.log 'setting up treema-ext'
treemaExt = require 'core/treema-ext'
treemaExt.setup()
# a module and its dependencies have loaded!
if @queue.progress is 1
$('#module-loading-list').modal('hide')
@ -86,11 +92,7 @@ module.exports = ModuleLoader = class ModuleLoader extends CocoClass
console.debug 'loaded', @recentPaths.length, 'files,', parseInt(@recentLoadedBytes/1024), 'KB'
@trigger 'load-complete'
# get treema set up only when the library loads, if it loads
if e.item.id is 'vendor/treema'
console.log 'setting up treema-ext'
treemaExt = require 'core/treema-ext'
treemaExt.setup()
@trigger 'loaded', e.item
parseDependencies: (raw) ->
bits = raw.match(/(require\(['"](.+?)['"])|(register\(['"].+?['"])/g) or []

View file

@ -89,6 +89,16 @@ module.exports = class LevelLoader extends CocoClass
loadDependenciesForSession: (session) ->
if session is @session
codeLanguage = session.get('codeLanguage') or me.get('aceConfig')?.language or 'python'
modulePath = "vendor/aether-#{codeLanguage}"
loading = application.moduleLoader.load(modulePath)
if loading
@languageModuleResource = @supermodel.addSomethingResource 'language_module'
@listenTo application.moduleLoader, 'loaded', (e) ->
if e.id is modulePath
@languageModuleResource.markLoaded()
@stopListening application.moduleLoader
# hero-ladder games require the correct session team in level:loaded
team = @team ? @session.get('team')
Backbone.Mediator.publish 'level:loaded', level: @level, team: team

View file

@ -116,11 +116,13 @@ class CocoModel extends Backbone.Model
save: (attrs, options) ->
options ?= {}
originalOptions = _.cloneDeep(options)
options.headers ?= {}
options.headers['X-Current-Path'] = document.location?.pathname ? 'unknown'
success = options.success
error = options.error
options.success = (model, res) =>
@retries = 0
@trigger 'save:success', @
success(@, res) if success
@markToRevert() if @_revertAttributes
@ -128,6 +130,17 @@ class CocoModel extends Backbone.Model
CocoModel.pollAchievements()
options.success = options.error = null # So the callbacks can be garbage-collected.
options.error = (model, res) =>
if res.status is 0
@retries ?= 0
@retries += 1
if @retries > 20
msg = 'Your computer or our servers appear to be offline. Please try refreshing.'
noty text: msg, layout: 'center', type: 'error', killer: true
return
else
msg = $.i18n.t 'loading_error.connection_failure', defaultValue: 'Connection failed.'
noty text: msg, layout: 'center', type: 'error', killer: true, timeout: 3000
return _.delay((f = => @save(attrs, originalOptions)), 3000)
error(@, res) if error
return unless @notyErrors
errorMessage = "Error saving #{@get('name') ? @type()}"

View file

@ -104,6 +104,7 @@ module.exports = class User extends CocoModel
getBranchingGroup: ->
return @branchingGroup if @branchingGroup
return 'no-practice' # A/B test paused for school testing
group = me.get('testGroupNumber') % 4
@branchingGroup = switch group
when 0 then 'no-practice'
@ -124,15 +125,6 @@ module.exports = class User extends CocoModel
application.tracker.identify gemPromptGroup: @gemPromptGroup unless me.isAdmin()
@gemPromptGroup
getHideLockedLevelsGroup: ->
return @hideLockedLevelsGroup if @hideLockedLevelsGroup
group = if me.isAdmin() then 0 else me.get('testGroupNumber') % 2
@hideLockedLevelsGroup = switch group
when 0 then 'show'
when 1 then 'hide'
application.tracker.identify hideLockedLevelsGroup: @hideLockedLevelsGroup unless me.isAdmin()
@hideLockedLevelsGroup
tiersByLevel = [-1, 0, 0.05, 0.14, 0.18, 0.32, 0.41, 0.5, 0.64, 0.82, 0.91, 1.04, 1.22, 1.35, 1.48, 1.65, 1.78, 1.96, 2.1, 2.24, 2.38, 2.55, 2.69, 2.86, 3.03, 3.16, 3.29, 3.42, 3.58, 3.74, 3.89, 4.04, 4.19, 4.32, 4.47, 4.64, 4.79, 4.96,
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15
]

View file

@ -146,6 +146,8 @@ _.extend ThangTypeSchema.properties,
maleRangerThumb: { type: 'string', format: 'image-file', title: 'Thumb (Male Ranger)' }
femaleRanger: { type: 'string', format: 'image-file', title: 'Glove (Female Ranger)' }
femaleRangeThumbr: { type: 'string', format: 'image-file', title: 'Thumb (Female Ranger)' }
maleBack: { type: 'string', format: 'image-file', title: ' Male Back' }
femaleBack: { type: 'string', format: 'image-file', title: ' Female Back' }
colorGroups: c.object
title: 'Color Groups'
additionalProperties:

View file

@ -489,10 +489,16 @@ $itemSlotGridHeight: 51px
left: 65px
bottom: 31px
&.Ranger
left: -7px
&.female
left: 80px
bottom: 31px
&.Ranger
left: -7px
#hero-image-head
z-index: 16
#hero-image-hair
@ -500,12 +506,12 @@ $itemSlotGridHeight: 51px
#hero-image-thumb
z-index: 16
&.female
&.female:not(.Ranger)
@include rotate(-15deg)
left: 66px
bottom: 54px
&.male
&.male:not(.Ranger)
@include rotate(-15deg)
left: 53px
bottom: 54px
@ -525,7 +531,7 @@ $itemSlotGridHeight: 51px
&.feet
z-index: 13
&.right-hand
&.right-hand:not(.Ranger)
@include rotate(-15deg)
&.female
left: 66px
@ -542,7 +548,7 @@ $itemSlotGridHeight: 51px
&.torso
z-index: 14
&.gloves
&.gloves:not(.Ranger)
z-index: 15
&.female

View file

@ -1,3 +1,5 @@
@import "app/styles/mixins"
#item-details-view
.nano-content
@ -42,6 +44,9 @@
.item-shadow
top: 25px
left: 5px
@include filter(contrast(0%) brightness(0%))
opacity: 0.2
img.hr
width: 80%

View file

@ -159,6 +159,11 @@ $gameControlMargin: 30px
margin-bottom: -2 * $levelDotShadowHeight / 3
.level:hover
// TODO: This rotate stops Firefox from flickering, but also disables the scaleY(0.75)
// TODO: The dot looks like it's jumping.
// TODO: -moz-transform: scaleY(0.75) didn't do anything
// TODO: Does not break Chrome's oval.
-moz-transform: rotate(0)
margin-bottom: -$levelDotHeight / 3 + $levelDotHoverZ
@include box-shadow(0px 0px 35px skyblue)

View file

@ -31,15 +31,14 @@
if item.silhouetted && !item.owned
span.glyphicon.glyphicon-lock.bolder
span.glyphicon.glyphicon-lock
img.item-silhouette(src=item.getPortraitURL(), draggable="false")
img.item-silhouette(draggable="false")
if item.level
.required-level
div(data-i18n="general.player_level")
div= item.level
else
strong.big-font= item.name
img.item-img(src=item.getPortraitURL(), draggable="false")
//img.item-shadow(src=item.getPortraitURL(), draggable="false") // Not performant, takes too much memory with filter
img.item-img(draggable="false")
if item.owned
span.big-font.owned(data-i18n="play.owned")

View file

@ -10,32 +10,30 @@
if !level.hidden
- var next = level.id == nextLevel || (!seenNext && levelStatusMap[level.id] != "complete" && !level.locked && !level.disabled && (!level.practice || me.getBranchingGroup() == 'all-practice'));
- seenNext = seenNext || next;
//- A/B Test hiding locked levels
if !hideLockedLevels || !level.locked
div(style="left: #{level.x}%; bottom: #{level.y}%; background-color: #{level.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.id] || "", data-level-id=level.id, title=level.name + (level.disabled ? ' (Coming Soon to Adventurers)' : ''))
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!)" : (level.locked ? " (Locked)" : ""))
.level-description= level.description
if level.disabled
p
span.spr(data-i18n="play.awaiting_levels_adventurer_prefix") We release five levels per week.
a.spr(href="/contribute/adventurer")
strong(data-i18n="play.awaiting_levels_adventurer") Sign up as an Adventurer
span.spl(data-i18n="play.awaiting_levels_adventurer_suffix") to be the first to play new levels.
- var playCount = levelPlayCountMap[level.id]
if playCount && playCount.sessions > 20
div
span.spr #{playCount.sessions}
span(data-i18n="play.players") players
span.spr , #{Math.round(playCount.playtime / 3600)}
span(data-i18n="play.hours_played") hours played
.campaign-label(style="color: #{campaign.color}")= campaign.name
if isIPadApp && !level.disabled && !level.locked
button.btn.btn-success.btn-lg.start-level(data-i18n="common.play") Play
div(style="left: #{level.x}%; bottom: #{level.y}%; background-color: #{level.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.id] || "", data-level-id=level.id, title=level.name + (level.disabled ? ' (Coming Soon to Adventurers)' : ''))
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!)" : (level.locked ? " (Locked)" : ""))
.level-description= level.description
if level.disabled
p
span.spr(data-i18n="play.awaiting_levels_adventurer_prefix") We release five levels per week.
a.spr(href="/contribute/adventurer")
strong(data-i18n="play.awaiting_levels_adventurer") Sign up as an Adventurer
span.spl(data-i18n="play.awaiting_levels_adventurer_suffix") to be the first to play new levels.
- var playCount = levelPlayCountMap[level.id]
if playCount && playCount.sessions > 20
div
span.spr #{playCount.sessions}
span(data-i18n="play.players") players
span.spr , #{Math.round(playCount.playtime / 3600)}
span(data-i18n="play.hours_played") hours played
.campaign-label(style="color: #{campaign.color}")= campaign.name
if isIPadApp && !level.disabled && !level.locked
button.btn.btn-success.btn-lg.start-level(data-i18n="common.play") Play
if mapType === 'dungeon' && forestIsAvailable
a#forest-link.glyphicon.glyphicon-share-alt.campaign-switch(href="/play/forest", data-i18n="[title]play.campaign_forest")
if mapType === 'forest'

View file

@ -49,6 +49,7 @@ module.exports = class CocoView extends Backbone.View
@listenTo(@supermodel, 'loaded-all', @onLoaded)
@listenTo(@supermodel, 'update-progress', @updateProgress)
@listenTo(@supermodel, 'failed', @onResourceLoadFailed)
@warnConnectionError = _.throttle(@warnConnectionError, 3000)
super options
@ -149,6 +150,17 @@ module.exports = class CocoView extends Backbone.View
# Error handling for loading
onResourceLoadFailed: (e) ->
r = e.resource
if r.jqxhr?.status is 0
r.retries ?= 0
r.retries += 1
if r.retries > 20
msg = 'Your computer or our servers appear to be offline. Please try refreshing.'
noty text: msg, layout: 'center', type: 'error', killer: true
return
else
@warnConnectionError()
return _.delay (=> r.load()), 3000
@$el.find('.loading-container .errors').append(loadingErrorTemplate({
status: r.jqxhr?.status
name: r.name
@ -157,6 +169,10 @@ module.exports = class CocoView extends Backbone.View
})).i18n()
@$el.find('.progress').hide()
warnConnectionError: ->
msg = $.i18n.t 'loading_error.connection_failure', defaultValue: 'Connection failed.'
noty text: msg, layout: 'center', type: 'error', killer: true, timeout: 3000
onRetryResource: (e) ->
res = @supermodel.getResource($(e.target).data('resource-index'))
# different views may respond to this call, and not all have the resource to reload

View file

@ -69,9 +69,7 @@ module.exports = class WorldMapView extends RootView
@hadEverChosenHero = me.get('heroConfig')?.thangType
@listenTo me, 'change:purchased', -> @renderSelectors('#gems-count')
@listenTo me, 'change:spent', -> @renderSelectors('#gems-count')
# A/B Test hiding locked levels
# window.tracker?.trackEvent 'Loaded World Map', category: 'World Map', ['Google Analytics']
window.tracker?.trackEvent 'Loaded World Map', category: 'World Map', hideLockedLevelsGroup: me.getHideLockedLevelsGroup()
window.tracker?.trackEvent 'Loaded World Map', category: 'World Map', ['Google Analytics']
# If it's a new player who didn't appear to come from Hour of Code, we register her here without setting the hourOfCode property.
elapsed = (new Date() - new Date(me.get('dateCreated')))
@ -137,8 +135,6 @@ module.exports = class WorldMapView extends RootView
context.mapType = _.string.slugify @terrain
context.nextLevel = @nextLevel
context.forestIsAvailable = @startedForestLevel or '541b67f71ccc8eaae19f3c62' in (me.get('earned')?.levels or [])
# A/B Test hiding locked levels
context.hideLockedLevels = me.getHideLockedLevelsGroup() is 'hide'
context
afterRender: ->

View file

@ -293,7 +293,7 @@ module.exports = class PlayHeroesModal extends ModalView
if @session.get('codeLanguage') isnt @codeLanguage
@session.set('codeLanguage', @codeLanguage)
changed = true
Backbone.Mediator.publish 'tome:change-language', language: @codeLanguage, reload: true
#Backbone.Mediator.publish 'tome:change-language', language: @codeLanguage, reload: true # We'll reload the PlayLevelView instead.
@session.patch() if changed

View file

@ -51,8 +51,10 @@ module.exports = class PlayItemsModal extends ModalView
'click .buy-gems-prompt-button': 'onBuyGemsPromptButtonClicked'
'click #close-modal': 'hide'
'click': 'onClickedSomewhere'
'update .tab-pane .nano': 'onScrollItemPane'
constructor: (options) ->
@onScrollItemPane = _.throttle(_.bind(@onScrollItemPane, @), 200)
super options
@items = new Backbone.Collection()
@itemCategoryCollections = {}
@ -143,7 +145,21 @@ module.exports = class PlayItemsModal extends ModalView
onTabClicked: (e) ->
@playSound 'game-menu-tab-switch'
$($(e.target).attr('href')).find('.nano').nanoScroller({alwaysVisible: true})
nano = $($(e.target).attr('href')).find('.nano')
nano.nanoScroller({alwaysVisible: true})
@paneNanoContent = nano.find('.nano-content')
@onScrollItemPane()
onScrollItemPane: ->
# dynamically load visible items when the user scrolls enough to see them
items = @paneNanoContent.find('.item:not(.loaded)')
threshold = @paneNanoContent.height() + 100
for itemEl in items
itemEl = $(itemEl)
if itemEl.position().top < threshold
$(itemEl).addClass('loaded')
item = @idToItem[itemEl.data('item-id')]
itemEl.find('.item-silhouette, .item-img').attr('src', item.getPortraitURL())
onUnlockButtonClicked: (e) ->
e.stopPropagation()

View file

@ -32,7 +32,7 @@
"firepad": "~0.1.2",
"marked": "~0.3.0",
"moment": "~2.5.0",
"aether": "~0.2.39",
"aether": "~0.3.0",
"underscore.string": "~2.3.3",
"firebase": "~1.0.2",
"d3": "~3.4.4",
@ -96,6 +96,17 @@
},
"modernizr": {
"main": "modernizr.js"
},
"aether": {
"main": [
"build/aether.js",
"build/clojure.js",
"build/coffeescript.js",
"build/io.js",
"build/javascript.js",
"build/lua.js",
"build/python.js"
]
}
},
"devDependencies": {

View file

@ -88,6 +88,12 @@ exports.config =
'javascripts/box2d.js': regJoin('^vendor/scripts/Box2dWeb-2.1.a.3')
'javascripts/lodash.js': regJoin('^bower_components/lodash/dist/lodash.js')
'javascripts/aether.js': regJoin('^bower_components/aether/build/aether.js')
'javascripts/app/vendor/aether-clojure.js': 'bower_components/aether/build/clojure.js'
'javascripts/app/vendor/aether-coffeescript.js': 'bower_components/aether/build/coffeescript.js'
'javascripts/app/vendor/aether-io.js': 'bower_components/aether/build/io.js'
'javascripts/app/vendor/aether-javascript.js': 'bower_components/aether/build/javascript.js'
'javascripts/app/vendor/aether-lua.js': 'bower_components/aether/build/lua.js'
'javascripts/app/vendor/aether-python.js': 'bower_components/aether/build/python.js'
# Any vendor libraries we don't want the client to load immediately
'javascripts/app/vendor/d3.js': regJoin('^bower_components/d3')

View file

@ -66,7 +66,7 @@
"redis": "",
"webworker-threads": "~0.4.11",
"node-gyp": "~0.13.0",
"aether": "~0.2.39",
"aether": "~0.3.0",
"JASON": "~0.1.3",
"JQDeferred": "~2.1.0",
"jsondiffpatch": "0.1.17",

View file

@ -0,0 +1,93 @@
# Life's too short to write these things in JS, so install cofmon:
# npm install -g cofmon
# Then you can paste CoffeeScript into it.
nDays = 1
dayOffset = 0.3
now = new Date()
startDate = new Date(now - 86400 * 1000 * (nDays + dayOffset))
endDate = new Date(now - 86400 * 1000 * dayOffset)
users = db.users.find({dateCreated: {$gt: startDate, $lt: endDate}}, {_id: 1, name: 1, testGroupNumber: 1}).toArray()
goodUsers = []
for user in users
totalPlaytime = 0
sessions = db.level.sessions.find({creator: '' + user._id}, {playtime: 1, levelID: 1}).toArray()
firstSessions = []
for session in sessions when session.playtime
totalPlaytime += session.playtime
if totalPlaytime > 60 * 60
break
firstSessions.push session
if totalPlaytime < 60 * 60
continue
goodUsers.push {user: user, playtime: totalPlaytime, sessions: firstSessions}
levelUserCounts = {}
for user in goodUsers
for session in user.sessions
levelUserCounts[session.levelID] ?= 0
levelUserCounts[session.levelID]++
print "Found #{goodUsers.length} users who played more than an hour out of #{users.length}."
print "Levels by number of users completing:"
levelUserCounts
"""
Found 194 users who played more than an hour out of 93952.
rs0:PRIMARY> levelUserCounts;
{
"dungeons-of-kithgard" : 190,
"gems-in-the-deep" : 184,
"shadow-guard" : 186,
"forgetful-gemsmith" : 189,
"kounter-kithwise" : 80,
"true-names" : 186,
"favorable-odds" : 76,
"the-raised-sword" : 181,
"haunted-kithmaze" : 181,
"descending-further" : 70,
"the-second-kithmaze" : 171,
"dread-door" : 172,
"known-enemy" : 170,
"master-of-names" : 160,
"lowly-kithmen" : 138,
"closing-the-distance" : 137,
"tactical-strike" : 48,
"the-final-kithmaze" : 108,
"the-gauntlet" : 43,
"kithgard-gates" : 96,
"defense-of-plainswood" : 88,
"winding-trail" : 75,
"endangered-burl" : 51,
"village-guard" : 40,
"thornbush-farm" : 33,
"back-to-back" : 27,
"ogre-encampment" : 22,
"woodland-cleaver" : 18,
"shield-rush" : 10,
"peasant-protection" : 8,
"munchkin-swarm" : 10,
"munchkin-harvest" : 4,
"swift-dagger" : 1,
"shrapnel" : 1,
"arcane-ally" : 1,
"touch-of-death" : 1
"bonemender" : 1,
"coinucopia" : 6,
"copper-meadows" : 3,
"drop-the-flag" : 3,
"deadly-pursuit" : 2,
"rich-forager" : 1,
"multiplayer-treasure-grove" : 1,
"rescue-mission" : 2,
"dungeon-arena-tutorial" : 3,
"dungeon-arena" : 2,
"undefined" : 2,
"grab-the-mushroom" : 2,
"gold-rush" : 1,
"criss-cross" : 1,
}
"""