#!/usr/bin/env coffee _ = require 'underscore' fs = require 'fs-extra' path = require 'path' util = require 'util' # Global Data ########################################################################################################## DEBUG = false MOD = null MOD_SCRIPT = null WORLD_DIR = null INDENT = 0 # Constants ############################################################################################################ MINETWEAKER_LOG = 'minetweaker.log' ICON_DIR = path.join 'dumps', 'itempanel_icons' ITEMPANEL_CSV = path.join 'dumps', 'itempanel.csv' # Process Arguments #################################################################################################### process.argv.shift() # coffee process.argv.shift() # script file while process.argv.length > 0 switch process.argv[0] when '--world' process.argv.shift() WORLD_DIR = process.argv[0] when '--mod' process.argv.shift() MOD_SCRIPT = process.argv[0] process.argv.shift() if not WORLD_DIR? then throw new Error '--world is required' if not MOD_SCRIPT? then throw new Error '--mod is required' # Classes ############################################################################################################## class MinecraftId constructor: (@mod, @name, @meta=null, @variant=null)-> @meta = null if not @meta or @meta in ['0', '*'] @meta = parseInt @meta @meta = null if Number.isNaN @meta compareTo: (other)-> return -1 unless other? if @mod isnt other.mod return if @mod < other.mod then -1 else +1 if @name isnt other.name return if @name < other.name then -1 else +1 if @meta isnt other.meta if not @meta? then return -1 if not other.meta? then return +1 return if @meta < other.meta then -1 else +1 if @variant isnt other.variant if not @variant? then return -1 if not other.variant? then return +1 return if @variant < other.variant then -1 else +1 return 0 createVariant: (variant)-> return new MinecraftId @mod, @name, @meta, variant equals: (other)-> return @compareTo(other) is 0 matches: (other)-> return false unless other? return false unless @mod is other.mod return false unless @name is other.name return true if other.meta is null return false unless @meta is other.meta return true toString: -> if not @_string @_string = "#{@mod}:#{@name}" if @meta? then @_string += ":#{@meta}" if @variant? then @_string += ":#{@variant}" return @_string # Helper Functions ##################################################################################################### addItem = (item)-> if not item? then throw new Error "cannot add null item" MOD.items.push item computePattern = (recipe, inputGrid)-> pattern = [['.', '.', '.'], ['.', '.', '.'], ['.', '.', '.']] cells = _.compact _.flatten inputGrid recipe.inputNames = (_.uniq (cell.item.displayName for cell in cells)).sort() for row, rowIndex in inputGrid for gridCell, columnIndex in row continue unless gridCell? itemNumber = recipe.inputNames.indexOf gridCell.item.displayName if itemNumber >= 0 pattern[rowIndex][columnIndex] = "#{itemNumber}" p = pattern recipe.pattern = "#{p[0][0]}#{p[0][1]}#{p[0][2]} #{p[1][0]}#{p[1][1]}#{p[1][2]} #{p[2][0]}#{p[2][1]}#{p[2][2]}" computeGridSize = (grid)-> grid.width = grid.height = 0 grid.rawHeight = grid.length grid.rawWidth = 0 for row, rowIndex in grid grid.rawWidth = Math.max grid.rawWidth, row.length for cell, colIndex in row continue unless cell? grid.height = Math.max grid.height, rowIndex + 1 grid.width = Math.max grid.width, colIndex + 1 grid.isLarge = grid.width > 2 or grid.height > 2 computeSlug = (text)-> return null unless text? result = text.toLowerCase() result = result.replace /[^a-zA-Z0-9_]/g, '_' result = result.replace /__+/g, '_' result = result.replace /^_/, '' result = result.replace /_$/, '' return result debugLog = (text)-> return unless DEBUG console.error text findItem = (search)-> for item in MOD.items matches = true for field, value of search if field is 'minecraftId' if not item.minecraftId.equals(search.minecraftId) matches = false break else if item[field] isnt value matches = false break return item if matches return null lookUpItems = (itemIds)-> for row in itemIds for gridCell in row continue unless gridCell? item = findItem minecraftId:gridCell.minecraftId if not item? console.error "could not find an item for id: #{gridCell.minecraftId}" return null gridCell.item = item normalizeMinecraftId = (minecraftId)-> return null unless minecraftId return minecraftId unless minecraftId.mod is 'ore' entries = MOD.oreDict[minecraftId.toString()] return minecraftId unless entries? return minecraftId unless entries.length > 0 return entries[0] normalizeRecipeGrid = (grid)-> result = grid if grid.rawWidth is 2 and grid.rawHeight is 3 result = [ [ grid[0][0], grid[0][1], grid[1][0] ], [ grid[1][1], grid[2][0], grid[2][1] ], [ null, null, null ] ] else if grid.rawWidth is 1 and grid.rawHeight is 3 result = [ [ grid[0][0], grid[1][0], grid[2][0] ], [ null, null, null ] [ null, null, null ] ] else if grid.rawWidth is 1 and grid.rawHeight is 2 result = [ [ grid[0][0], grid[1][0], null ], [ null, null, null ] [ null, null, null ] ] computeGridSize result return result parseMinecraftId = (minecraftId)-> match = /^([^:]*):([^:]*)(:([0-9*]*))?$/.exec minecraftId return null unless match? return new MinecraftId match[1], match[2], match[4] parseRecipeInputs = (recipeType, text)-> allRows = [] currentRow = null currentItem = '' completeCurrentItem = -> currentItem = currentItem.trim() if currentItem isnt 'null' match = currentItem.match /^ *([^ ]*)( \* ([0-9]+))?/ quantity = parseInt match[3] if _.isNaN(quantity) then quantity = 1 if match? currentRow.push( minecraftId: normalizeMinecraftId parseMinecraftId match[1] quantity: quantity ) else currentRow.push null currentItem = '' inputText = if recipeType is 'Shapeless' then text[0..text.length] else text[1..text.length - 2] for c in inputText.split '' if c is '[' currentRow = [] else if c is ']' completeCurrentItem() allRows.push currentRow currentRow = null else if c is ',' if currentRow? completeCurrentItem() else if c in ['<', '>'] # do nothing else if currentItem? currentItem += c if recipeType is 'Shapeless' a = _.flatten allRows allRows = [ [ a[0], a[1], a[4] ] [ a[2], a[3], a[5] ] [ a[6], a[7], a[8] ] ] computeGridSize allRows return allRows # Stage Functions ###################################################################################################### assignGroups = -> for item in MOD.items MOD.assignGroup item copyImages = -> for item in MOD.items continue unless item.iconFile? itemPath = path.join 'data', MOD.slug, 'items', item.slug fs.ensureDir itemPath fs.copySync item.iconFile, path.join(itemPath, 'icon.png'), clobber:true discardNonModItems = -> result = [] for item in MOD.items if MOD.contains item result.push item MOD.items = result loadModHelper = -> MOD = require "./mods/#{MOD_SCRIPT}" MOD.slug ?= MOD_SCRIPT MOD.version ?= '1.0' MOD.items ?= [] MOD.oreDict ?= {} MOD.adjustRecipe ?= (recipe)-> return recipe MOD.assignGroup ?= (item)-> return null MOD.contains ?= (id)-> return true MOD.correctItem ?= (item)-> # do nothing MOD.makeCorrections ?= -> # do nothing MOD.normalizeName ?= (item, index=1)-> return item.gameName MOD.scrubMinetweakerLine ?= (lineText)-> return lineText MOD.shouldNormalizeGrid ?= (item, grid)-> return false makeCorrections = -> for item in MOD.items MOD.correctItem item MOD.makeCorrections() removeDuplicates = -> result = [] lastDisplayName = null for item in MOD.items continue if item.displayName is lastDisplayName result.push item lastDisplayName = item.displayName MOD.items = result scanFurnaceRecipes = -> fileText = fs.readFileSync path.join(WORLD_DIR, MINETWEAKER_LOG), 'utf-8' for line in fileText.split '\n' line = MOD.scrubMinetweakerLine line match = line.match /furnace.addRecipe\(<([^>]*)>, <([^>]*)>/ continue unless match? outputId = parseMinecraftId match[1] outputItem = findItem minecraftId:outputId if not outputItem? console.error "Skipping furnace recipe for #{match[1]} because no matching item could be found" continue inputId = parseMinecraftId match[2] inputItem = findItem minecraftId:inputId if not inputItem? console.error "Skipping furnace recipe for #{match[1]} because input item #{match[2]} could not be found" continue recipe = inputNames: [ inputItem.gameName, 'furnace fuel' ] quantity: 1 extras: [] tools: [ 'Furnace' ] pattern: '.0. ... .1.' outputItem.recipes ?= [] outputItem.recipes.push recipe scanIconFiles = -> iconFiles = fs.readdirSync path.join WORLD_DIR, ICON_DIR for fileName in iconFiles match = fileName.match /^(.*?)(_([0-9]+))?\.png$/ continue unless match? baseItem = findItem iconFileName:match[1] if not baseItem? console.error "Could not find item for icon file: #{fileName}" continue index = parseInt match[3] index = if Number.isNaN(index) then 1 else index if index is 1 item = baseItem item.displayName = MOD.normalizeName baseItem, index else displayName = MOD.normalizeName baseItem, index if displayName item = minecraftId: baseItem.minecraftId.createVariant index gameName: baseItem.gameName displayName: displayName iconFileName: fileName slug: computeSlug displayName addItem item else console.error "Could not find item for icon file: #{fileName}" item.iconFile = path.join WORLD_DIR, ICON_DIR, fileName scanItemPanelCsv = -> fileText = fs.readFileSync path.join(WORLD_DIR, ITEMPANEL_CSV), 'utf-8' lines = fileText.split '\n' lines.sort() priorLine = null for line, lineNumber in lines continue if lineNumber is 0 continue if priorLine is line match = line.match /^([^,]*),([^,]*),([^,]*),([^,]*),([^,]*)$/ continue unless match item = minecraftId: parseMinecraftId "#{match[1]}:#{match[3]}" gameName: match[5] iconFileName: match[5].replace(/:/g, '_') item.displayName = MOD.normalizeName item item.slug = computeSlug item.displayName addItem item priorLine = line scanOreDict = -> fileText = fs.readFileSync path.join(WORLD_DIR, MINETWEAKER_LOG), 'utf-8' lines = fileText.split '\n' index = 0 while true break if index >= lines.length line = lines[index] index += 1 match = line.match /^Ore entries for <([^>]*)> :$/ continue unless match? genericIdText = match[1] while true break if index >= lines.length line = lines[index] match = line.match /^ <([^>]*)>$/ break unless match? index += 1 MOD.oreDict[genericIdText] ?= [] MOD.oreDict[genericIdText].push parseMinecraftId match[1] scanRecipes = -> fileText = fs.readFileSync path.join(WORLD_DIR, MINETWEAKER_LOG), 'utf-8' for line in fileText.split '\n' line = MOD.scrubMinetweakerLine line match = line.match /recipes.add(Shaped|Shapeless)\(<([^>]*)>( \* ([0-9]+))?, (.*)\);/ continue unless match? recipeType = match[1] outputId = parseMinecraftId match[2] outputItem = findItem minecraftId:outputId continue unless outputItem? quantity = parseInt match[4] quantity = if _.isNaN(quantity) then 1 else quantity inputGrid = parseRecipeInputs recipeType, match[5] if MOD.shouldNormalizeGrid outputItem, inputGrid inputGrid = normalizeRecipeGrid inputGrid continue if inputGrid is null recipe = inputNames: [] quantity: quantity extras: [] tools: if inputGrid.isLarge then ['Crafting Table'] else [] lookUpItems inputGrid computePattern recipe, inputGrid recipe = MOD.adjustRecipe recipe if recipe? outputItem.recipes ?= [] outputItem.recipes.push recipe sortItems = -> MOD.items.sort (a, b)-> if a.groupName isnt b.groupName if not a.groupName? then return -1 if not b.groupName? then return +1 return if a.groupName < b.groupName then -1 else +1 if a.displayName isnt b.displayName return if a.displayName < b.displayName then -1 else +1 return a.minecraftId.compareTo b.minecraftId writeModVersionFile = -> lines = ['schema: 1', ''] indent = 0 write = (lineText)-> for i in [0...indent] lineText = ' ' + lineText lines.push lineText currentGroupName = null for item in MOD.items if item.groupName isnt currentGroupName indent = 0 write "group: #{item.groupName}" write '' currentGroupName = item.groupName indent++ write "item: #{item.displayName}" indent++ # write "minecraftId: #{item.minecraftId}" if (not item.isGatherable? and not item.recipes?) or item.isGatherable write 'gatherable: yes' if item.recipes? for recipe in item.recipes write 'recipe:' indent++ write "input: #{recipe.inputNames.join(', ')}" write "pattern: #{recipe.pattern}" if recipe.quantity isnt 1 write "quantity: #{recipe.quantity}" if recipe.tools.length > 0 write "tools: #{recipe.tools.join(', ')}" indent-- indent-- write '' versionPath = path.join 'data', MOD.slug, 'versions', MOD.version fs.ensureDirSync versionPath fs.writeFileSync path.join(versionPath, 'mod-version.cg'), lines.join('\n'), encoding:'utf-8' writeJsonDump = -> versionPath = path.join 'data', MOD.slug, 'versions', MOD.version fs.ensureDirSync versionPath fs.writeFileSync path.join(versionPath, 'mod-version.json'), JSON.stringify(MOD.items, null, 4), encoding:'utf-8' ######################################################################################################################## loadModHelper() scanItemPanelCsv() scanIconFiles() scanOreDict() scanRecipes() scanFurnaceRecipes() discardNonModItems() assignGroups() sortItems() removeDuplicates() makeCorrections() writeModVersionFile() # writeJsonDump() copyImages()