DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 52: BATTLETECH weapons data exporter

OK, let's make an interactive visualization of the BATTLETECH weapons data. The way I usually do them is:

  • Ruby program to go over game data, preprocess it, and export it all to JSON
  • some JavaScript frontend program that just works with this preprocessed single JSON and doesn't need to deal with any complexity

I did something similar with Hearts of Iron IV - Division Designer - which unfortunately cannot be updated to latest HoI4 version without full rewrite.

Exporter

#!/usr/bin/env ruby

require "json"
require "memoist"
require "pathname"
require "pry"

class String
  def camelize
    gsub(/_([a-z])/) { $1.upcase }
  end
end

class AmmoBox
  extend Memoist
  attr_reader :data, :path

  def initialize(path)
    @path = path
    @data = JSON.parse(path.read)
  end

  memoize def id
    @data["AmmoID"].sub(/\AAmmunition_/, "")
  end

  def tonnage
    @data["Tonnage"]
  end

  def capacity
    @data["Capacity"]
  end
end

class Weapon
  extend Memoist
  attr_reader :game, :data, :path

  def initialize(game, path)
    @game = game
    @path = path
    @data = JSON.parse(path.read)
  end

  memoize def name
    bonuses = [bonus_a, bonus_b].compact
    if bonuses.empty?
      base_name
    else
      "#{base_name} (#{bonuses.join(", ")})"
    end
  end

  memoize def base_name
    [
      data["Description"]["Name"],
      data["Description"]["UIName"],
    ].compact.last.gsub(" +", "+")
  end

  memoize def bonus_a
    data["BonusValueA"] == "" ? nil : data["BonusValueA"].gsub(/[a-z]\K\./, "").gsub(/[\+\-]\K /, "")
  end

  memoize def bonus_b
    data["BonusValueB"] == "" ? nil : data["BonusValueB"].gsub(/[a-z]\K\./, "").gsub(/[\+\-]\K /, "")
  end

  def category
    @data["Category"]
  end

  def subtype
    @data["WeaponSubType"]
  end

  def tonnage
    @data["Tonnage"]
  end

  def damage
    shots * base_damage
  end

  def base_damage
    @data["Damage"]
  end

  def shots
    @data["ShotsWhenFired"]
  end

  def heat
    @data["HeatGenerated"]
  end

  def ammo_per_shot
    @data["ShotsWhenFired"] * data["ProjectilesPerShot"]
  end

  def heat_tonnage
    heat / 3.0
  end

  # 10 rounds of shootnig at target
  def ammo_tonnage_per_shot
    @game.ammo_weights.fetch(ammo_category) * ammo_per_shot
  end

  def total_tonnage
    tonnage + heat_tonnage + ammo_tonnage
  end

  def ammo_category
    @data["ammoCategoryID"] || @data["AmmoCategory"]
  end

  def purchasable?
    @data["Description"]["Purchasable"]
  end

  def weapon_effect
    @data["WeaponEffectID"]
  end

  def ignore?
    [
      category == "Melee",
      name == "AI Laser",
      subtype == "TAG",
      subtype == "Narc",
      subtype =~ /\ACOIL/,
      weapon_effect == "WeaponEffect-Artillery_MechMortar",
      weapon_effect == "WeaponEffect-Artillery_Thumper",
    ].any?
  end

  def min_range
    @data["MinRange"]
  end

  def max_range
    @data["MaxRange"]
  end

  def indirect_fire
    @data["IndirectFireCapable"]
  end

  def as_json
    {
      name:,
      tonnage:,
      heat:,
      shots:,
      base_damage:,
      ammo_tonnage_per_shot:,
      min_range:,
      max_range:,
      indirect_fire:,
    }.transform_keys(&:to_s).transform_keys(&:camelize)
  end
end

class BattleTechGame
  extend Memoist

  def initialize(game_root, *dlc_roots)
    @game_root = Pathname(game_root)
    @dlc_roots = dlc_roots.map{|path| Pathname(path)}
  end

  memoize def data_root
    @game_root + "BattleTech_Data/StreamingAssets/data"
  end

  def roots
    [data_root, *@dlc_roots]
  end

  memoize def weapon_files
    roots
      .flat_map{|root| root.glob("weapon/*.json")}
      .select{|n| n.basename.to_s != "WeaponTemplate.json"}
  end

  memoize def weapons
    weapon_files.map{|path| Weapon.new(self, path)}
  end

  memoize def ammobox_files
    roots
      .flat_map{|root| root.glob("ammunitionBox/*.json")}
      .select{|n| n.basename.to_s != "AmmoBoxTemplate.json"}
  end

  memoize def ammoboxes
    ammobox_files.map{|path| AmmoBox.new(path)}
  end

  memoize def ammo_weights
    # MG box occurs twice, but with same ratio
    ammoboxes.to_h{|a| [a.id, a.tonnage.to_f/a.capacity]}.merge("NotSet" => 0.0)
  end

  def inspect
    "BattechGame"
  end

  def as_json
    weapons
      .reject(&:ignore?)
      .map(&:as_json)
  end
end

game = BattleTechGame.new(*ARGV)
puts JSON.pretty_generate(game.as_json)
Enter fullscreen mode Exit fullscreen mode

There are a few interesting things in the code, mainly:

  • as_json which turns objects into JSON-able data, but it doesn't generate the final String (as to_json would), so it's composable
  • Ruby 3 style Hashes, like {name:} which means {name: name} in Ruby 2.x, or {:name => name} in Ruby 1.x.
  • .transform_keys(&:camelize) to turn that Hash into something more JavaScript-like, with nasty camel-casing

Generated JSON

Here's just a fragment:

[
  {
    "name": "AC/10",
    "tonnage": 12,
    "heat": 12,
    "shots": 1,
    "baseDamage": 60,
    "ammoTonnagePerShot": 0.125,
    "minRange": 0,
    "maxRange": 450,
    "indirectFire": false
  },
  ...
  {
    "name": "LRM5++ (+2 Dmg)",
    "tonnage": 2,
    "heat": 6,
    "shots": 5,
    "baseDamage": 6,
    "ammoTonnagePerShot": 0.041666666666666664,
    "minRange": 180,
    "maxRange": 630,
    "indirectFire": true
  },
  ...
  {
    "name": "M Pulse++ (-4 Heat, +1 Acc)",
    "tonnage": 2,
    "heat": 16,
    "shots": 1,
    "baseDamage": 50,
    "ammoTonnagePerShot": 0.0,
    "minRange": 0,
    "maxRange": 270,
    "indirectFire": false
  },
  ...
]
Enter fullscreen mode Exit fullscreen mode

I'm just guessing the fields I'll need here. It's totally possible I'll need to add or change some fields.

Arguably since source data is JSON, I could just gather all the JSONs, put them into one big JSON array, and use that, but I don't like this pattern.

Story so far

All the code is on GitHub.

Coming next

In the next few episodes I'll create a small Svelte interactive visualization of the BATTLETECH weapons data.

Discussion (0)