diff --git a/Gruntfile.coffee b/Gruntfile.coffee index 7fc0fe10a..559040678 100644 --- a/Gruntfile.coffee +++ b/Gruntfile.coffee @@ -98,9 +98,9 @@ module.exports = (grunt)-> images: files: ['./src/images/**/*'] tasks: ['copy:images'] - browserify: - files: ['./src/scripts/**/*.coffee', './test/**/*.coffee'] - tasks: ['browserify'] + source: + files: ['./src/scripts/**/*.coffee'] + tasks: ['browserify:main'] pages: files: ['./src/pages/**/*.jade'] tasks: ['jade:pages'] @@ -110,6 +110,9 @@ module.exports = (grunt)-> sass: files: ['./src/css/**/*.scss'] tasks: ['sass', 'copy:styles'] + tests: + files: ['./src/scripts/**/*.coffee', './test/**/*.coffee'] + tasks: ['browserify:test'] grunt.registerTask 'default', 'build' diff --git a/src/css/main.scss b/src/css/main.scss index ce7c15770..db79a7e50 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -8,17 +8,6 @@ All rights reserved. @import 'mixins'; @import 'reset'; -// Constants /////////////////////////////////////////////////////////////////////////////////////////////////////////// - -$color-background: #AAC9FF; -$color-bedrock: #0A0A0A; -$color-content: rgba(230, 230, 230, 0.90); -$color-brick-dark: #7A7A7A; -$color-brick-light: #D9D9D9; -$color-footer: #3D1D09; -$color-footer-text: white; -$color-dirt: #906647; - // Fonts /////////////////////////////////////////////////////////////////////////////////////////////////////////////// @font-face { @@ -28,6 +17,29 @@ $color-dirt: #906647; src: url('/fonts/press_start.ttf') format('truetype'); } +// Constants /////////////////////////////////////////////////////////////////////////////////////////////////////////// + +$color-background: #AAC9FF; +$color-bedrock: #0A0A0A; +$color-brick-dark: #7A7A7A; +$color-brick-light: #D9D9D9; +$color-content: rgba(230, 230, 230, 0.90); +$color-dirt: #906647; +$color-footer: #3D1D09; +$color-footer-text: white; +$color-gray-faint: #F2F2F2; +$color-gray-light: #CCCCCC; +$color-table-background: white; + +$font-family-header: 'PressStart'; +$font-family-normal: sans-serif; +$font-family-input: monospace; + +$font-size-small: 1.1em; +$font-size-normal: 1.3em; +$font-size-large: 2.0em; +$font-size-x-large: 3.0em; + // Common ////////////////////////////////////////////////////////////////////////////////////////////////////////////// body { @@ -47,8 +59,8 @@ h1 { } p { - font-family: PressStart; - font-size: 3em; + font-family: $font-family-header; + font-size: $font-size-x-large; position: relative; top: -0.5em; } } @@ -59,18 +71,40 @@ h2 { * { display: inline; } img { - position: relative; height: 6em; width: 6em; + position: relative; height: 4em; width: 4em; margin-right: 1em; vertical-align: bottom; } p { - font-family: PressStart; - font-size: 2em; + font-family: $font-family-header; + font-size: $font-size-large; position: relative; top: -0.25em; } } +h3 { + font-family: $font-family-normal; + font-weight: bold; + + p { font-size: $font-size-normal; } +} + +p { + font-family: $font-family-normal; + font-size: $font-size-normal; +} + +input { + font-family: $font-family-input; + font-size: $font-size-normal; +} + +label { + font-family: $font-family-normal; + font-size: $font-size-normal; +} + .centered { text-align: center; } @@ -84,6 +118,13 @@ h2 { width: 100%; } +.page > div { + position: relative; width: 100%; + padding: 2em 2em 0 2em; + + &:first-child { padding-top: 0; } +} + // Views /////////////////////////////////////////////////////////////////////////////////////////////////////////////// .view__footer { @@ -111,7 +152,7 @@ h2 { p { font-family: sans-serif; - font-size: 1.25em; + font-size: $font-size-small; padding-bottom: 1.0em; &:last-child { @@ -123,7 +164,7 @@ h2 { padding-bottom: 1.0em; p { - font-family: PressStart; + font-family: $font-family-header; font-size: 1.5em; } } diff --git a/src/css/reset.scss b/src/css/reset.scss index e55d2ce26..439b93c34 100644 --- a/src/css/reset.scss +++ b/src/css/reset.scss @@ -1,4 +1,4 @@ -a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,dialog,div,dl,dt,em,embed,fieldset,figcaption,figure,font,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,meter,nav,object,ol,output,p,pre,progress,q,rp,rt,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video,xmp { +a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,dialog,div,dl,dt,em,embed,fieldset,figcaption,figure,font,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,meter,nav,object,ol,output,p,pre,progress,q,rp,rt,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,textarea,tfoot,th,thead,time,tr,tt,u,ul,var,video,xmp { border: 0; margin: 0; padding: 0; diff --git a/src/css/templates/crafting_table.scss b/src/css/templates/crafting_table.scss new file mode 100644 index 000000000..d97f2e2b9 --- /dev/null +++ b/src/css/templates/crafting_table.scss @@ -0,0 +1,45 @@ +/* +Crafting Table - crafting_table.scss + +Copyright (c) 2014 by Redwood Labs +All rights reserved. +*/ + +.view__crafting_table { + + .recipe_selector { + background: $color-table-background; + padding: 1em; + position: relative; width: 100%; + + & > * { + display: inline; + margin-left: 0.5em; + + &:first-child { margin-left: 0; } + } + + input[type="checkbox"] + label { margin-left: 0; } + + input[name="name"] { width: 60%; } + } + + h3 { + margin-bottom: 0.5em; + } + + table { + background: white; + width: 100%; + + td { padding: 0 1em 1em 1em; width: 33%; } + } + + textarea, ul { + border: 0.1em solid $color-gray-light; + width: 100%; + height: 100%; + min-height: 6em; + padding: 0.2em; + } +} diff --git a/src/css/templates/index.scss b/src/css/templates/index.scss index 748791869..7a45f254c 100644 --- a/src/css/templates/index.scss +++ b/src/css/templates/index.scss @@ -5,4 +5,5 @@ Copyright (c) 2014 by Redwood Labs All rights reserved. */ +@import 'crafting_table'; @import 'recipe_catalog'; diff --git a/src/css/templates/recipe_catalog.scss b/src/css/templates/recipe_catalog.scss index f28405243..896d7bc10 100644 --- a/src/css/templates/recipe_catalog.scss +++ b/src/css/templates/recipe_catalog.scss @@ -6,9 +6,6 @@ All rights reserved. */ .view__recipe_catalog { - position: relative; width: 100%; - padding: 0 2em; - button { @include center-vertical; position: absolute; right: 1em; @@ -19,12 +16,12 @@ All rights reserved. table { background: white; margin: 0 auto; - width: 98%; + width: 100%; td { - height: 3em; padding: 0 1em; position: relative; vertical-align: middle; - - p { font-size: 1.5em; } + position: relative; height: 2 * $font-size-normal; + padding: 0 1em; + vertical-align: middle; } td:nth-child(1) { width: 30%; } diff --git a/src/pages/includes/_footer.jade b/src/pages/includes/_footer.jade index 0e61fb05e..a2ea2b1ef 100644 --- a/src/pages/includes/_footer.jade +++ b/src/pages/includes/_footer.jade @@ -30,4 +30,5 @@ .centered iframe(src="http://ghbtns.com/github-btn.html?user=andrewminer&repo=crafting-guide&type=fork&size=large", allowtransparency="true", frameborder="0", scrolling="0", width="100", height="32") - .divider.bottom + a(href="/test.html") + .divider.bottom diff --git a/src/pages/includes/_header.jade b/src/pages/includes/_header.jade index 6241dece0..81fe1900a 100644 --- a/src/pages/includes/_header.jade +++ b/src/pages/includes/_header.jade @@ -7,6 +7,7 @@ .view__header h1 - img(src="images/workbench_front.png") + a(href="/") + img(src="images/workbench_front.png") p Crafting Guide .divider.bottom diff --git a/src/scripts/constants.coffee b/src/scripts/constants.coffee index 218a1daaa..cf6501139 100644 --- a/src/scripts/constants.coffee +++ b/src/scripts/constants.coffee @@ -16,12 +16,14 @@ Opacity.hidden = 1e-6 Opacity.shown = 1 exports.Event = Event = {} -Event.change = 'change' +Event.add = 'add' # collection, item... +Event.change = 'change' # model Event.load = {} -Event.load.started = 'book:load:started' # controller, url -Event.load.succeeded = 'book:load:succeeded' # controller, book -Event.load.failed = 'book:load:failed' # controller, error message -Event.load.finished = 'book:load:finished' # controller +Event.load.started = 'load:started' # controller, url +Event.load.succeeded = 'load:succeeded' # controller, book +Event.load.failed = 'load:failed' # controller, error message +Event.load.finished = 'load:finished' # controller +Event.remove = 'remove' # collection, item... exports.DefaultBookUrls = DefaultBookUrls = [ '/data/vanilla.json' diff --git a/src/scripts/controllers/base_controller.coffee b/src/scripts/controllers/base_controller.coffee index fe66afae7..b502c9663 100644 --- a/src/scripts/controllers/base_controller.coffee +++ b/src/scripts/controllers/base_controller.coffee @@ -19,6 +19,7 @@ module.exports = class BaseController extends Backbone.View @_parent = options.parent @_children = [] + Object.defineProperty this, 'rendered', get:-> return @_rendered @_loadTemplate options.templateName super options diff --git a/src/scripts/controllers/crafting_table_controller.coffee b/src/scripts/controllers/crafting_table_controller.coffee new file mode 100644 index 000000000..da74f8ac4 --- /dev/null +++ b/src/scripts/controllers/crafting_table_controller.coffee @@ -0,0 +1,76 @@ +### +# Crafting Guide - crafting_table_controller.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +BaseController = require './base_controller' +InventoryParser = require '../models/inventory_parser' + +######################################################################################################################## + +module.exports = class CraftingTableController extends BaseController + + constructor: (options={})-> + if not options.model? then throw new Error 'options.model is required' + options.templateName = 'crafting_table' + super options + + @_parser = new InventoryParser + @_name = null + @_quantity = null + @_includingTools = null + + # Event Methods ################################################################################ + + onHaveFieldChanged: -> + return unless @rendered + + @model.have.clear() + @_parser.parse @$haveField.val(), @model.have + @_recalculate() + + onIncludingToolsFieldChanged: -> + return unless @rendered + @_includingTools = @$('input[name="including_tools"]:checked').length > 0 + @_recalculate() + + onNameFieldChanged: -> + return unless @rendered + @_name = @$nameField.val() + @_recalculate() + + onQuantityFieldChanged: -> + return unless @rendered + @_quantity = parseInt @$quantityField.find(":selected").val() + @_recalculate() + + # BaseController Overrides ##################################################################### + + onDidRender: -> + @$haveField = @$('textarea[name="have"]') + @$includingToolsField = @$('input[name="including_tools"]') + @$nameField = @$('input[name="name"]') + @$quantityField = @$('select[name="quantity"]') + + super + + @onHaveFieldChanged() + @onIncludingToolsFieldChanged() + @onNameFieldChanged() + @onQuantityFieldChanged() + + # Backbone.View Overrides ###################################################################### + + events: + 'input textarea[name="have"]': 'onHaveFieldChanged' + 'input input[name="name"]': 'onNameFieldChanged' + 'input select[name="quantity"]': 'onQuantityFieldChanged' + 'change input[name="including_tools"]': 'onIncludingToolsFieldChanged' + + # Private Methods ############################################################################## + + _recalculate: -> + return if not @_name? or @_name.length is 0 + @model.craft @_name, @_quantity diff --git a/src/scripts/controllers/landing_page_controller.coffee b/src/scripts/controllers/landing_page_controller.coffee index ac9b795f1..b55ee4516 100644 --- a/src/scripts/controllers/landing_page_controller.coffee +++ b/src/scripts/controllers/landing_page_controller.coffee @@ -6,6 +6,7 @@ ### BaseController = require './base_controller' +CraftingTableController = require './crafting_table_controller' LandingPage = require '../models/landing_page' RecipeCatalogController = require './recipe_catalog_controller' @@ -21,5 +22,6 @@ module.exports = class LandingPageController extends BaseController # BaseController Overrides ##################################################################### onDidRender: -> - @recipeBooksController = @addChild RecipeCatalogController, '.view__recipe_catalog', model:@model.catalog + @catalogController = @addChild RecipeCatalogController, '.view__recipe_catalog', model:@model.catalog + @tableController = @addChild CraftingTableController, '.view__crafting_table', model:@model.table super diff --git a/src/scripts/crafting_guide_router.coffee b/src/scripts/crafting_guide_router.coffee index 22ac41aeb..d6f5e323c 100644 --- a/src/scripts/crafting_guide_router.coffee +++ b/src/scripts/crafting_guide_router.coffee @@ -45,8 +45,8 @@ module.exports = class CraftingGuideRouter extends Backbone.Router controller.render() $pageContent = $('.page') - $pageContent.empty() - $pageContent.append controller.$el + controller.$el.addClass 'page' + $pageContent.replaceWith controller.$el controller.$el.fadeIn showDuration, -> controller.onDidShow() diff --git a/src/scripts/models/crafting_table.coffee b/src/scripts/models/crafting_table.coffee new file mode 100644 index 000000000..f12e97b82 --- /dev/null +++ b/src/scripts/models/crafting_table.coffee @@ -0,0 +1,27 @@ +### +# Crafting Guide - crafting_table.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +BaseModel = require './base_model' +Inventory = require './inventory' + +######################################################################################################################## + +module.exports = class CraftingTable extends BaseModel + + constructor: (attributes={}, options={})-> + if not attributes.catalog? then throw new Error "attributes.catalog is required" + super attributes, options + + @have = new Inventory + @make = new Inventory + @get = new Inventory + + # Public Methods ############################################################################### + + craft: (name, quantity=1, includingTools=false)-> + toolPhrase = if includingTools then ' includingTools' else '' + logger.verbose "calculating build plan for #{quantity} #{name}#{toolPhrase} with inventory: #{@have}" diff --git a/src/scripts/models/inventory.coffee b/src/scripts/models/inventory.coffee new file mode 100644 index 000000000..ebea29f1d --- /dev/null +++ b/src/scripts/models/inventory.coffee @@ -0,0 +1,79 @@ +### +# Crafting Guide - inventory.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +BaseModel = require './base_model' +{Event} = require '../constants' +Item = require './item' + +######################################################################################################################## + +module.exports = class Inventory extends BaseModel + + constructor: (attributes={}, options={})-> + super attributes, options + @clear() + + # Public Methods ############################################################################### + + add: (name, quantity=1)-> + item = @_items[name] + if not item? + item = new Item name:name, quantity:quantity + @_items[name] = item + @_names.push name + else + item.quantity += quantity + + @trigger Event.add, this, name, quantity + @trigger Event.change, this + return this + + clear: -> + @_items = {} + @_names = [] + + each: (onItem)-> + for name in @_names + item = @_items[name] + onItem item + + hasAtLeast: (name, quantity=1)-> + if quantity is 0 then return true + + item = @_items[name] + return false unless item? + return item.quantity >= quantity + + remove: (name, quantity=1)-> + item = @_items[name] + if not item? then throw new Error "cannot remove #{name} since it is not in this inventory" + if item.quantity < quantity + throw new Error "cannot remove #{quantity} #{name} because there is only #{item.quantity} in this inventory" + + item.quantity -= quantity + if item.quantity is 0 + delete @_items[name] + @_names = _(@_names).without name + + @trigger Event.remove, this, name, quantity + @trigger Event.change, this + return this + + # Object Overrides ############################################################################# + + toString: -> + result = [@constructor.name, " (", @cid, ") { items: ["] + + needsDelimiter = false + for name, item of @_items + if needsDelimiter then result.push ', ' + result.push item.toString() + needsDelimiter = true + result.push ']' + + result.push '}' + return result.join '' diff --git a/src/scripts/models/inventory_parser.coffee b/src/scripts/models/inventory_parser.coffee new file mode 100644 index 000000000..513b0dccc --- /dev/null +++ b/src/scripts/models/inventory_parser.coffee @@ -0,0 +1,29 @@ +### +# Crafting Guide - inventory_parser.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +Inventory = require './inventory' + +######################################################################################################################## + +module.exports = class InventoryParser + + @ITEM_REGEX = /^([0-9]+)(.*)$/ + + parse: (data, inventory=null)-> + inventory ?= new Inventory + return inventory if not data? or data.length is 0 + + lines = data.split '\n' + for line in lines + match = InventoryParser.ITEM_REGEX.exec line + name = if match? then match[2].trim() else line + quantity = if match? then parseInt(match[1]) else 1 + + if name.length > 0 + inventory.add name, quantity + + return inventory diff --git a/src/scripts/models/item.coffee b/src/scripts/models/item.coffee index 463580620..89c7d9a08 100644 --- a/src/scripts/models/item.coffee +++ b/src/scripts/models/item.coffee @@ -11,9 +11,47 @@ BaseModel = require './base_model' module.exports = class Item extends BaseModel + @DEFAULT_STACK_SIZE = 64 + constructor: (attributes={}, options={})-> - attributes.name ?= '' - attributes.quantity ?= 1 + attributes.name ?= '' + attributes.quantity ?= 1 + attributes.stackSize ?= Item.DEFAULT_STACK_SIZE super attributes, options - # Public Methods ############################################################################### \ No newline at end of file + Object.defineProperty this, 'stackQuantity', get:@getStackQuantity + + # Public Methods ############################################################################### + + canMerge: (item)-> + return item.name is @name + + merge: (item)-> + if not @canMerge(item) then throw new Error "cannot merge #{item} into #{this}" + @quantity += item.quantity + + toFormatHash: -> + return result = + name: @name + quantity: @quantity + stackQuantity: @stackQuantity + + # Property Methods ############################################################################# + + getStackQuantity: -> + count = 0 + extra = @quantity + while extra > @stackSize + extra -= @stackSize + count += 1 + + return count:count extra:extra + + # Object Overrides ############################################################################# + + toString: -> + result = "#{@constructor.name} (#{@cid}) { name:\"#{@name}\", quantity:#{@quantity}" + if @stackSize isnt Item.DEFAULT_STACK_SIZE + result += ", stackSize:#{@stackSize}" + result += ' }' + return result diff --git a/src/scripts/models/landing_page.coffee b/src/scripts/models/landing_page.coffee index e252b8982..447b88c10 100644 --- a/src/scripts/models/landing_page.coffee +++ b/src/scripts/models/landing_page.coffee @@ -5,7 +5,8 @@ # All rights reserved. ### -BaseModel = require './base_model' +BaseModel = require './base_model' +CraftingTable = require './crafting_table' RecipeCatalog = require './recipe_catalog' ######################################################################################################################## @@ -14,4 +15,5 @@ module.exports = class LandingPage extends BaseModel constructor: (attributes={}, options={})-> attributes.catalog ?= new RecipeCatalog + attributes.table ?= new CraftingTable catalog:attributes.catalog super attributes, options diff --git a/src/scripts/models/parser_versions/v1.coffee b/src/scripts/models/parser_versions/v1.coffee deleted file mode 100644 index 9451f8b7b..000000000 --- a/src/scripts/models/parser_versions/v1.coffee +++ /dev/null @@ -1,81 +0,0 @@ -### -# Crafting Guide - v1.coffee -# -# Copyright (c) 2014 by Redwood Labs -# All rights reserved. -### - -Item = require '../item' -Recipe = require '../recipe' -RecipeBook = require '../recipe_book' - - -######################################################################################################################## - -module.exports = class V1 - - constructor: -> - @_errorLocation = 'the header information' - - parse: (data)-> - return @_parseRecipeBook data - - # Private Methods ############################################################################## - - _parseRecipeBook: (data)-> - if not data? then throw new Error 'recipe book data is missing' - if not data.version? then throw new Error 'version is required' - if not data.mod_name? then throw new Error 'mod_name is required' - if not data.mod_version? then throw new Error 'mod_version is required' - if not _.isArray(data.recipes) then throw new Error 'recipes must be an array' - - book = new RecipeBook version:data.version, modName:data.mod_name, modVersion:data.mod_version - book.description = data.description or '' - - for index in [0...data.recipes.length] - @_errorLocation = "recipe #{index + 1}" - recipeData = data.recipes[index] - book.recipes.push @_parseRecipe recipeData - - return book - - _parseRecipe: (data, options={})-> - if not data? then throw new Error "recipe data is missing for #{@_errorLocation}" - if not data.output? then throw new Error "#{@_errorLocation} is missing output" - if not data.input? then throw new Error "#{@_errorLocation} is missing input" - - output = @_parseItemList data.output, field:'output', canBeEmpty:false - @_errorLocation = "recipe for output[0].name" - - data.tools ?= [] - input = @_parseItemList data.input, field:'input', canBeEmpty:false - tools = @_parseItemList data.tools, field:'tools', canBeEmpty:true - - return new Recipe input:input, output:output, tools:tools - - _parseItemList: (data, options={})-> - if not data? then throw new Error "#{@_errorLocation} must have an #{options.field} field" - - if not _.isArray(data) then data = [data] - if data.length is 0 and not options.canBeEmpty - throw new Error "#{options.field} for #{@_errorLocation} cannot be empty" - - result = [] - for index in [0...data.length] - itemData = data[index] - result.push @_parseItem itemData, field:options.field, index:index - - return result - - _parseItem: (data, options={})-> - errorBase = "#{options.field} element #{options.index} for #{@_errorLocation}" - if not data? then throw new Error "#{errorBase} is missing" - - if _.isString(data) then data = [1, data] - if not _.isArray(data) then throw new Error "#{errorBase} must be an array" - - if data.length is 1 then data.unshift 1 - if data.length isnt 2 then throw new Error "#{errorBase} must have at least one element" - if not _.isNumber(data[0]) then throw new Error "#{errorBase} must start with a number" - - return new Item quantity:data[0], name:data[1] diff --git a/src/scripts/models/recipe.coffee b/src/scripts/models/recipe.coffee index 672d6aa51..bf0522a0b 100644 --- a/src/scripts/models/recipe.coffee +++ b/src/scripts/models/recipe.coffee @@ -12,6 +12,42 @@ BaseModel = require './base_model' module.exports = class Recipe extends BaseModel constructor: (attributes={}, options={})-> + if not attributes.output? then throw new Error "attributes.output is required" + if not attributes.input? then throw new Error "attributes.input is required" + attributes.tools ?= [] super attributes, options Object.defineProperty this, 'name', get:-> @output[0].name + + # Object Overrides ############################################################################# + + toString: -> + result = [@constructor.name, " (", @cid, ") { name:", @name] + + result.push ", input:[" + needsDelimiter = false + for inputItem in @input + if needsDelimiter then result.push ', ' + result.push inputItem.toString() + needsDelimiter = false + result.push ']' + + result.push ", output:[" + needsDelimiter = false + for outputItem in @output + if needsDelimiter then result.push ', ' + result.push outputItem.toString() + needsDelimiter = false + result.push ']' + + if @tools.length > 0 + result.push ", tools:[" + needsDelimiter = false + for tool in @tools + if needsDelimiter then result.push ', ' + result.push tool.toString() + needsDelimiter = false + result.push ']' + + result.push '}' + return result.join '' diff --git a/src/scripts/models/recipe_book_parser.coffee b/src/scripts/models/recipe_book_parser.coffee index 6376190ee..d25047121 100644 --- a/src/scripts/models/recipe_book_parser.coffee +++ b/src/scripts/models/recipe_book_parser.coffee @@ -5,7 +5,9 @@ # All rights reserved. ### -V1 = require './parser_versions/v1' +Item = require './item' +Recipe = require './recipe' +RecipeBook = require './recipe_book' ######################################################################################################################## @@ -24,3 +26,73 @@ module.exports = class RecipeBookParser throw new Error "cannot parse version #{data.version} recipe books" return parser.parse data + +######################################################################################################################## + +module.exports.V1 = class V1 + + constructor: -> + @_errorLocation = 'the header information' + + parse: (data)-> + return @_parseRecipeBook data + + # Private Methods ############################################################################## + + _parseRecipeBook: (data)-> + if not data? then throw new Error 'recipe book data is missing' + if not data.version? then throw new Error 'version is required' + if not data.mod_name? then throw new Error 'mod_name is required' + if not data.mod_version? then throw new Error 'mod_version is required' + if not _.isArray(data.recipes) then throw new Error 'recipes must be an array' + + book = new RecipeBook version:data.version, modName:data.mod_name, modVersion:data.mod_version + book.description = data.description or '' + + for index in [0...data.recipes.length] + @_errorLocation = "recipe #{index + 1}" + recipeData = data.recipes[index] + book.recipes.push @_parseRecipe recipeData + + return book + + _parseRecipe: (data, options={})-> + if not data? then throw new Error "recipe data is missing for #{@_errorLocation}" + if not data.output? then throw new Error "#{@_errorLocation} is missing output" + if not data.input? then throw new Error "#{@_errorLocation} is missing input" + + output = @_parseItemList data.output, field:'output', canBeEmpty:false + @_errorLocation = "recipe for output[0].name" + + data.tools ?= [] + input = @_parseItemList data.input, field:'input', canBeEmpty:false + tools = @_parseItemList data.tools, field:'tools', canBeEmpty:true + + return new Recipe input:input, output:output, tools:tools + + _parseItemList: (data, options={})-> + if not data? then throw new Error "#{@_errorLocation} must have an #{options.field} field" + + if not _.isArray(data) then data = [data] + if data.length is 0 and not options.canBeEmpty + throw new Error "#{options.field} for #{@_errorLocation} cannot be empty" + + result = [] + for index in [0...data.length] + itemData = data[index] + result.push @_parseItem itemData, field:options.field, index:index + + return result + + _parseItem: (data, options={})-> + errorBase = "#{options.field} element #{options.index} for #{@_errorLocation}" + if not data? then throw new Error "#{errorBase} is missing" + + if _.isString(data) then data = [1, data] + if not _.isArray(data) then throw new Error "#{errorBase} must be an array" + + if data.length is 1 then data.unshift 1 + if data.length isnt 2 then throw new Error "#{errorBase} must have at least one element" + if not _.isNumber(data[0]) then throw new Error "#{errorBase} must start with a number" + + return new Item quantity:data[0], name:data[1] diff --git a/src/scripts/views.js b/src/scripts/views.js index 7261fbd2d..5f4aa6df7 100644 --- a/src/scripts/views.js +++ b/src/scripts/views.js @@ -2,13 +2,22 @@ var jade = jade || require('jade').runtime; this["JST"] = this["JST"] || {}; +this["JST"]["crafting_table"] = function template(locals) { +var buf = []; +var jade_mixins = {}; +var jade_interp; + +var jade_indent = []; +buf.push("\n
\n

\n

Crafting Table

\n

\n
\n

I want to make

\n \n \n \n
\n \n \n \n \n \n \n
\n

\n

You have...

\n

\n \n
\n

\n

You'll make...

\n

\n
    \n
    \n

    \n

    You'll get...

    \n

    \n
      \n
      \n
      ");;return buf.join(""); +}; + this["JST"]["landing_page"] = function template(locals) { var buf = []; var jade_mixins = {}; var jade_interp; var jade_indent = []; -buf.push("\n
      \n
      \n
      \n
      ");;return buf.join(""); +buf.push("\n
      \n
      \n
      \n
      ");;return buf.join(""); }; this["JST"]["recipe_book"] = function template(locals) { diff --git a/src/templates/crafting_table.jade b/src/templates/crafting_table.jade new file mode 100644 index 000000000..2a56fb8bb --- /dev/null +++ b/src/templates/crafting_table.jade @@ -0,0 +1,37 @@ +//- +//- Crafting Guide - crafting_table.jade +//- +//- Copyright (c) 2014 by Redwood Labs +//- All rights reserved. +//- + +.view__crafting_table + h2 + img(src="/images/workbench_top.png") + p Crafting Table + + .recipe_selector + p I want to make + select(name="quantity") + option(value="1") 1 + option(value="2") 2 + option(value="4") 4 + option(value="8") 8 + option(value="16") 16 + option(value="64") 64 + input(name="name", placeholder="enter an item name") + label + input(name="including_tools", type="checkbox") + | including tools + + table + tr + td.have + h3: p You have... + textarea(name="have", placeholder="enter what you have, for example:\n\n3 wool\n4 iron ingot") + td.make + h3: p You'll make... + ul + td.get + h3: p You'll get... + ul diff --git a/src/templates/landing_page.jade b/src/templates/landing_page.jade index d7c3f1bad..96b52522e 100644 --- a/src/templates/landing_page.jade +++ b/src/templates/landing_page.jade @@ -7,4 +7,4 @@ .view__landing_page .view__recipe_catalog - .view__crafter + .view__crafting_table diff --git a/test/event_recorder.coffee b/test/event_recorder.coffee new file mode 100644 index 000000000..a801947c7 --- /dev/null +++ b/test/event_recorder.coffee @@ -0,0 +1,28 @@ +### +# Crafting Guide - event_recorder.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +util = require 'util' + +######################################################################################################################## + +module.exports = class EventRecorder + + constructor: (model)-> + if not model? then throw new Error 'model is required' + + @model = model + @events = [] + + Object.defineProperty this, 'names', get:-> e.event for e in @events + + @model.on 'all', (event, model, args...)=> + logger.verbose "#{model?.constructor?.name}(#{model?.cid}) emitted #{event} + with args: #{util.inspect(args)}" + @events.push id:model?.cid, event:event, args:args + + reset: -> + @events = [] diff --git a/test/inventory.test.coffee b/test/inventory.test.coffee new file mode 100644 index 000000000..66ca70247 --- /dev/null +++ b/test/inventory.test.coffee @@ -0,0 +1,115 @@ +### +# Crafting Guide - inventory.test.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +{Event} = require '../src/scripts/constants' +EventRecorder = require './event_recorder' +Inventory = require '../src/scripts/models/inventory' +Item = require '../src/scripts/models/item' + +######################################################################################################################## + +inventory = null + +######################################################################################################################## + +describe 'Inventory', -> + + beforeEach -> + inventory = new Inventory + inventory.add 'wool', 4 + inventory.add 'string', 20 + inventory.add 'boat' + + describe 'add', -> + + it 'can add to an empty inventory', -> + inventory.add 'iron ingot', 4 + item = inventory._items['iron ingot'] + item.constructor.name.should.equal 'Item' + item.name.should.equal 'iron ingot' + item.quantity.should.equal 4 + + it 'can augment quantity of existing items', -> + inventory.add 'wool', 2 + inventory._items.wool.quantity.should.equal 6 + + it 'can add zero quantity', -> + inventory.add 'wool', 0 + inventory._items.wool.quantity.should.equal 4 + + it 'emits the proper events', -> + events = new EventRecorder inventory + inventory.add 'iron ingot', 10 + events.names.should.eql [Event.add, Event.change] + + describe 'each', -> + + it 'works with an empty inventory', -> + inventory = new Inventory + result = [] + inventory.each (item)-> result.push item.name + result.should.eql [] + + it 'works when items have only been added', -> + result = [] + inventory.each (item)-> result.push item.name + result.should.eql ['wool', 'string', 'boat'] + + it 'works when items have been augmented', -> + inventory.add 'iron ingot' + inventory.add 'boat' + inventory.add 'wool', 2 + + result = [] + inventory.each (item)-> result.push item.name + result.should.eql ['wool', 'string', 'boat', 'iron ingot'] + + it 'moves items to the end when removed and re-added', -> + inventory.remove 'wool', 4 + inventory.add 'wool' + + result = [] + inventory.each (item)-> result.push item.name + result.should.eql ['string', 'boat', 'wool'] + + describe 'hasAtLeast', -> + + it 'works when the item is completely absent', -> + answer = inventory.hasAtLeast 'chicken', 1 + answer.should.be.false + + it 'always returns true for zero quantity', -> + inventory.hasAtLeast('chicken', 0).should.be.true + inventory.hasAtLeast('wool', 0).should.be.true + + it 'works for a quantity above 1', -> + inventory.hasAtLeast('wool', 3).should.be.true + inventory.hasAtLeast('wool', 4).should.be.true + inventory.hasAtLeast('wool', 5).should.be.false + + describe 'remove', -> + + it 'throws when the item is absent', -> + expect(-> inventory.remove('chicken')).to.throw Error, + 'cannot remove chicken since it is not in this inventory' + + it 'throws when the item has insufficient quantity', -> + expect(-> inventory.remove('wool', 10)).to.throw Error, + 'cannot remove 10 wool because there is only 4 in this inventory' + + it 'removes a single item by default', -> + inventory.remove 'wool' + inventory._items.wool.quantity.should.equal 3 + + it 'removes a quantity above 1', -> + inventory.remove 'wool', 3 + inventory._items.wool.quantity.should.equal 1 + + it 'emits the proper events', -> + events = new EventRecorder inventory + inventory.remove 'wool' + events.names.should.eql [Event.remove, Event.change] diff --git a/test/inventory_parser.test.coffee b/test/inventory_parser.test.coffee new file mode 100644 index 000000000..7f2953f37 --- /dev/null +++ b/test/inventory_parser.test.coffee @@ -0,0 +1,46 @@ +### +# Crafting Guide - inventory_parser.test.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +Inventory = require '../src/scripts/models/inventory' +InventoryParser = require '../src/scripts/models/inventory_parser' + +######################################################################################################################## + +parser = null + +######################################################################################################################## + +describe 'InventoryParser', -> + + beforeEach -> parser = new InventoryParser + + it 'returns an empty Inventory for an empty string', -> + result = parser.parse '' + result._names.length.should.equal 0 + + it 'can parse a single item without quantity', -> + result = parser.parse 'wool' + result._items.wool.quantity.should.equal 1 + + it 'can parse a single item with quantity', -> + result = parser.parse '4 wool' + result._items.wool.quantity.should.equal 4 + + it 'can parse multiple mixed-type items', -> + result = parser.parse '4 wool\n10 string\nboat\n\n' + result._items.wool.quantity.should.equal 4 + result._items.string.quantity.should.equal 10 + result._items.boat.quantity.should.equal 1 + result._names.should.eql ['wool', 'string', 'boat'] + + it 're-uses the given inventory object', -> + inventory = new Inventory + inventory.add 'string', 8 + result = parser.parse '4 wool', inventory + inventory._items.string.quantity.should.equal 8 + inventory._items.wool.quantity.should.equal 4 + inventory._names.should.eql ['string', 'wool'] diff --git a/test/parser_versions/v1.test.coffee b/test/parser_versions/v1.test.coffee deleted file mode 100644 index 3234c25b2..000000000 --- a/test/parser_versions/v1.test.coffee +++ /dev/null @@ -1,124 +0,0 @@ -### -# Crafting Guide - v1.test.coffee -# -# Copyright (c) 2014 by Redwood Labs -# All rights reserved. -### - -V1 = require '../../src/scripts/models/parser_versions/v1' - -######################################################################################################################## - -parser = null - -######################################################################################################################## - -describe 'RecipeBookParser.V1', -> - - before -> parser = new V1 - - describe '_parseRecipeBook', -> - - it 'requires a mod_name', -> - data = version:1, mod_version:'1.0', recipes:[] - expect(-> parser._parseRecipeBook data).to.throw Error, 'mod_name is required' - - it 'requires a mod_version', -> - data = version:1, mod_name:'Empty', recipes:[] - expect(-> parser._parseRecipeBook data).to.throw Error, 'mod_version is required' - - it 'can parse an empty recipe book', -> - data = - version: 1 - mod_name: 'Empty' - mod_version: '1.0' - recipes: [] - book = parser._parseRecipeBook data - book.modName.should.equal 'Empty' - book.modVersion.should.equal '1.0' - - it 'can parse a non-empty recipe book', -> - data = - version: 1 - mod_name: 'Minecraft' - mod_version: '1.7.10' - recipes: [ - { input:'sugar cane', output:'sugar' } - { input:[[3, 'wool'], [3, 'planks']], tools:'crafting table', output:'bed' } - ] - book = parser._parseRecipeBook data - book.modName.should.equal 'Minecraft' - book.modVersion.should.equal '1.7.10' - (r.name for r in book.recipes).sort().should.eql ['bed', 'sugar'] - - describe '_parseRecipe', -> - - it 'requires output to be defined', -> - parser._errorLocation = 'boat' - expect(-> parser._parseRecipe input:'wool').to.throw Error, 'boat is missing output' - - it 'requires input to be defined', -> - parser._errorLocation = 'boat' - expect(-> parser._parseRecipe output:'wool').to.throw Error, 'boat is missing input' - - it 'can parse a regular recipe', -> - data = - output: 'bed' - input: [[3, 'planks'], [3, 'wool']] - tools: 'crafting table' - recipe = parser._parseRecipe data - (i.name for i in recipe.output).should.eql ['bed'] - (i.name for i in recipe.input).sort().should.eql ['planks', 'wool'] - (i.name for i in recipe.tools).should.eql ['crafting table'] - - it 'can parse a recipe without tools', -> - recipe = parser._parseRecipe output:'sugar', input:'sugar cane' - (i.name for i in recipe.output).should.eql ['sugar'] - (i.name for i in recipe.input).sort().should.eql ['sugar cane'] - (i.name for i in recipe.tools).should.eql [] - - describe '_parseItemList', -> - - it 'can promote a single item to a list', -> - list = parser._parseItemList 'boat' - (i.name for i in list).should.eql ['boat'] - - it 'can require a list to be non-empty', -> - parser._errorLocation = 'boat' - options = field:'output', canBeEmpty:false - expect(-> parser._parseItemList [], options).to.throw Error, 'output for boat cannot be empty' - - it 'can allow an empty list', -> - list = parser._parseItemList [], canBeEmpty:true - list.length.should.equal 0 - - it 'can parse a non-empty list', -> - list = parser._parseItemList [[3, 'plank'], [3, 'wool']] - (i.name for i in list).sort().should.eql ['plank', 'wool'] - - describe '_parseItem', -> - - it 'requires the array to have at least one element', -> - parser._errorLocation = 'boat' - options = index:1, field:'output' - expect(-> parser._parseItem([], options)).to.throw Error, - "output element 1 for boat must have at least one element" - - it 'can fill in a missing number', -> - item = parser._parseItem 'boat' - item.name.should.equal 'boat' - item.quantity.should.equal 1 - - item2 = parser._parseItem ['boat'] - item2.name.should.equal 'boat' - item2.quantity.should.equal 1 - - it 'requires the data to start with a number', -> - parser._errorLocation = 'boat' - options = index:1, field:'output' - expect(-> parser._parseItem(['2', 'book'], options)).to.throw Error, - "output element 1 for boat must start with a number" - - it 'can parse a basic item', -> - item = parser._parseItem [2, 'book'] - item.constructor.name.should.equal 'Item' diff --git a/test/recipe_book_parser.test.coffee b/test/recipe_book_parser.test.coffee new file mode 100644 index 000000000..08d0679d3 --- /dev/null +++ b/test/recipe_book_parser.test.coffee @@ -0,0 +1,126 @@ +### +# Crafting Guide - v1.test.coffee +# +# Copyright (c) 2014 by Redwood Labs +# All rights reserved. +### + +RecipeBookParser = require '../src/scripts/models/recipe_book_parser' + +######################################################################################################################## + +parser = null + +######################################################################################################################## + +describe 'RecipeBookParser', -> + + describe "V1", -> + + before -> parser = new RecipeBookParser.V1 + + describe '_parseRecipeBook', -> + + it 'requires a mod_name', -> + data = version:1, mod_version:'1.0', recipes:[] + expect(-> parser._parseRecipeBook data).to.throw Error, 'mod_name is required' + + it 'requires a mod_version', -> + data = version:1, mod_name:'Empty', recipes:[] + expect(-> parser._parseRecipeBook data).to.throw Error, 'mod_version is required' + + it 'can parse an empty recipe book', -> + data = + version: 1 + mod_name: 'Empty' + mod_version: '1.0' + recipes: [] + book = parser._parseRecipeBook data + book.modName.should.equal 'Empty' + book.modVersion.should.equal '1.0' + + it 'can parse a non-empty recipe book', -> + data = + version: 1 + mod_name: 'Minecraft' + mod_version: '1.7.10' + recipes: [ + { input:'sugar cane', output:'sugar' } + { input:[[3, 'wool'], [3, 'planks']], tools:'crafting table', output:'bed' } + ] + book = parser._parseRecipeBook data + book.modName.should.equal 'Minecraft' + book.modVersion.should.equal '1.7.10' + (r.name for r in book.recipes).sort().should.eql ['bed', 'sugar'] + + describe '_parseRecipe', -> + + it 'requires output to be defined', -> + parser._errorLocation = 'boat' + expect(-> parser._parseRecipe input:'wool').to.throw Error, 'boat is missing output' + + it 'requires input to be defined', -> + parser._errorLocation = 'boat' + expect(-> parser._parseRecipe output:'wool').to.throw Error, 'boat is missing input' + + it 'can parse a regular recipe', -> + data = + output: 'bed' + input: [[3, 'planks'], [3, 'wool']] + tools: 'crafting table' + recipe = parser._parseRecipe data + (i.name for i in recipe.output).should.eql ['bed'] + (i.name for i in recipe.input).sort().should.eql ['planks', 'wool'] + (i.name for i in recipe.tools).should.eql ['crafting table'] + + it 'can parse a recipe without tools', -> + recipe = parser._parseRecipe output:'sugar', input:'sugar cane' + (i.name for i in recipe.output).should.eql ['sugar'] + (i.name for i in recipe.input).sort().should.eql ['sugar cane'] + (i.name for i in recipe.tools).should.eql [] + + describe '_parseItemList', -> + + it 'can promote a single item to a list', -> + list = parser._parseItemList 'boat' + (i.name for i in list).should.eql ['boat'] + + it 'can require a list to be non-empty', -> + parser._errorLocation = 'boat' + options = field:'output', canBeEmpty:false + expect(-> parser._parseItemList [], options).to.throw Error, 'output for boat cannot be empty' + + it 'can allow an empty list', -> + list = parser._parseItemList [], canBeEmpty:true + list.length.should.equal 0 + + it 'can parse a non-empty list', -> + list = parser._parseItemList [[3, 'plank'], [3, 'wool']] + (i.name for i in list).sort().should.eql ['plank', 'wool'] + + describe '_parseItem', -> + + it 'requires the array to have at least one element', -> + parser._errorLocation = 'boat' + options = index:1, field:'output' + expect(-> parser._parseItem([], options)).to.throw Error, + "output element 1 for boat must have at least one element" + + it 'can fill in a missing number', -> + item = parser._parseItem 'boat' + item.name.should.equal 'boat' + item.quantity.should.equal 1 + + item2 = parser._parseItem ['boat'] + item2.name.should.equal 'boat' + item2.quantity.should.equal 1 + + it 'requires the data to start with a number', -> + parser._errorLocation = 'boat' + options = index:1, field:'output' + expect(-> parser._parseItem(['2', 'book'], options)).to.throw Error, + "output element 1 for boat must start with a number" + + it 'can parse a basic item', -> + item = parser._parseItem [2, 'book'] + item.constructor.name.should.equal 'Item' diff --git a/test/test.coffee b/test/test.coffee index 2d3b01ed3..5f25ced17 100644 --- a/test/test.coffee +++ b/test/test.coffee @@ -17,11 +17,16 @@ global.assert = chai.assert global.expect = chai.expect global.should = chai.should() +Logger = require '../src/scripts/logger' +global.logger = new Logger level:Logger.TRACE + # Test Registry ######################################################################################################## mocha.setup 'bdd' -require './parser_versions/v1.test' +require './inventory.test' +require './inventory_parser.test' +require './recipe_book_parser.test' mocha.checkLeaks() mocha.run()