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)

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
  },
  ...
]

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.