Module:Mech

From BTAWiki
Jump to navigation Jump to search

Documentation for this module may be created at Module:Mech/doc

-- Module for handling information related to a BattleMech

-- p for "package", the interface of this module. Returned at the end
local p = {}

local core = {}
p.core = core

-- imports
local cargo = mw.ext.cargo
local getArgs = require('Module:Arguments').getArgs
local gear = require('Module:Gear').core

p.cargo = {}

-- p.cargo.chassis represents the schema of the information contained in a
-- chassisdef file.
p.cargo.chassis = {
  -- table is the name of the cargo table
  table = 'Chassis',
  -- fields is the schema of all of the fields of the Mech
  fields = {
    -- id is the primary key for this table. We expect it to be unique. It
    -- will be used as the foreign key in other tables to refer to this
    -- particular mech variant.
    id = {
      field = 'Id',
      type = 'String',
    },
    cost = {
      -- field defines the name of the field in the cargo table. for the
      -- field name, we use the name of the property in the ChassisDef
      -- json.
      field = 'Cost',
      -- type defines the type of the field
      type = 'Integer',
    },
    rarity = {
      field = 'Rarity',
      type = 'Integer',
    },
    purchasable = {
      field = 'Purchasable',
      type = 'Boolean',
    },
    manufacturer = {
      field = 'Manufacturer',
      type = 'String',
    },
    model = {
      field = 'Model',
      type = 'String',
    },
    uiName = {
      field = 'UIName',
      type = 'String',
    },
    name = {
      field = 'Name',
      type = 'String',
    },
    details = {
      field = 'Details',
      -- Details use the Text type, which is unindexed and intended for
      -- longer-form data.
      type = 'Text',
    },
    icon = {
      field = 'Icon',
      type = 'String',
    },
    tonnage = {
      field = 'Tonnage',
      type = 'Integer',
    },
    weightClass = {
      -- TODO(rust dev): this is probably an enum type. I bet we can
      -- do something with that.
      field = 'weightClass',
      type = 'String'
    },
    variantName = {
      field = 'VariantName',
      type = 'String',
    },
    stockRole = {
      field = 'StockRole',
      type = 'Text',
    },
    yangsThoughts = {
      field = 'YangsThoughts',
      type = 'Text',
    },
    leftArmActuatorLimit = {
      -- TODO(rust dev): another enum
      field = 'LeftArmActuatorLimit',
      type = 'String',
    },
    rightArmActuatorLimit = {
      field = 'RightArmActuatorLimit',
      type = 'String'
    },
    startingTonnage = {
      field = 'StartingTonnage',
      type = 'Float',
    },
    chassisTags = {
      field = 'ChassisTags',
      type = 'List (,) of String',
    },
  }
}

-- p.cargo.chassis_locations describes the information about a location on a mech
-- chassis
p.cargo.chassis_locations = {
  table = 'ChassisLocations',
  fields = {
    -- chassisID is the id of the chassis this location belongs to
    chassisID = {
      field = 'ChassisID',
      type = 'String',
    },
    location = {
      -- TODO(rust dev): this is probably an enum, and we can do something with
      -- that.
      field = 'Location',
      type = 'String'
    },
    tonnage = {
      field = 'Tonnage',
      type = 'Integer',
    },
    inventorySlots = {
      field = 'InventorySlots',
      type = 'Integer',
    },
    maxArmor = {
      field = 'MaxArmor',
      type = 'Integer',
    },
    maxRearArmor = {
      field = 'MaxRearArmor',
      type = 'Integer',
    },
    internalStructure = {
      field = 'InternalStructure',
      type = 'Integer',
    },
    hardpoints = {
      -- Hardpoints are done as a list of string. The value of the string is the
      -- type of hardpoint so, it might have the form in the template like
      --
      --   |Hardpoints = Energy,Energy,Missile
      --
      field = 'Hardpoints',
      type = 'List (,) of String',
    },
    omniHardpoints = {
      -- OmniHardpoints are the same, but for hardpoints where Omni == true
      field = 'OmniHardpoints',
      type = 'List (,) of String',
    }
  }
}

-- p.cargo.mech represents the information contained in a mechdef file.
p.cargo.mech = {
  table = 'Mech',
  fields = {
    -- id is the id of this mechdef.
    id = {
      field = 'Id',
      type = 'String',
    },
    -- chassisID is the foreign key pointed to the ChassisDef that corresponds
    -- to this mech.
    chassisID = {
      field = 'ChassisID',
      type = 'String',
    },
    -- mechTags is a list of strings, the tags for the mech. corresponds to
    -- MechTags.items in the json file.
    mechTags = {
      field = 'MechTags',
      type = 'List (,) of String',
    },
    cost = {
      field = 'Cost',
      type = 'Integer',
    },
    rarity = {
      field = 'Rarity',
      type = 'Integer',
    },
    purchasable = {
      field = 'Purchasable',
      type = 'Boolean',
    },
    manufacturer = {
      field = 'Manufacturer',
      type = 'String',
    },
    model = {
      field = 'Model',
      type = 'String',
    },
    uiName = {
      field = 'UIName',
      type = 'String',
    },
    name = {
      field = 'Name',
      type = 'String',
    },
    details = {
      field = 'Details',
      -- Details use the Text type, which is unindexed and intended for
      -- longer-form data.
      type = 'Text',
    },
    icon = {
      field = 'Icon',
      type = 'String',
    },
    simGameMechPartCost = {
      field = 'simGameMechPartCost',
      type = 'Integer',
    },
    version = {
      field = 'Version',
      type = 'Integer',
    },
  },
}

p.cargo.mech_locations = {
  table = 'MechLocations',
  -- a lot of the data in a mechdef seems redundant and not interesting for
  -- our purposes. however, include it here in the interest of correctness.
  fields = {
    -- mechID is the ID of the mech that this location belongs to
    mechID = {
      field = 'MechID',
      type = 'String',
    },
    -- location is the location being described here
    location = {
      field = 'Location',
      type = 'String',
    },
    currentArmor = {
      field = 'CurrentArmor',
      type = 'Integer',
    },
    currentRearArmor = {
      field = 'CurrentRearArmor',
      type = 'Integer',
    },
    currentInternalStructure = {
      field = 'CurrentInternalStructure',
      type = 'Integer',
    },
    assignedArmor = {
      field = 'AssignedArmor',
      type = 'Integer',
    },
    assignedRearArmor = {
      field = 'AssignedRearArmor',
      type = 'Integer',
    },
  },
}

-- p.cargo.mech_inventory describes an item in the inventory of a mech
--
-- excludes some fields which don't appear to be relevant:
--   * SimGameUID
--   * GUID
--   * DamageLevel
--   * prefabName
--   * hasPrefabName
p.cargo.mech_inventory = {
  table = 'MechInventory',
  fields = {
    -- mechID is the ID of the mechdef that this inventory item is from
    mechID = {
      field = 'MechID',
      type = 'String',
    },
    mountedLocation = {
      field = 'MountedLocation',
      type = 'String',
    },
    -- componentDefID is the ID of the component that that is mounted in this
    -- location
    componentDefID = {
      field = 'ComponentDefID',
      type = 'String',
    },
    componentDefType = {
      field = 'ComponentDefType',
      -- probably yet another enum.
      type = 'String'
    },
    hardpointSlot = {
      field = 'HardpointSlot',
      type = 'Integer',
    },
    -- fixedEquipment is a bit different. a mech can have equipment defined in
    -- the chassisdef as well as the mechdef. Equipment defined in the
    -- chassisdef is "FixedEquipment". Instead of having a separate table for
    -- this, we will instead set this flag true, and the mechID will instead
    -- refer to a chassis ID.
    fixedEquipment = {
      field = 'FixedEquipment',
      type = 'Boolean',
    },
    -- count is a way to ensure that otherwise duplicate equipment in the same
    -- spot gets a separate row in the database.
    count = {
      field = 'Count',
      type = 'Integer',
    },
  },
}

-- cargo_declare is a function which returns another function. The return value
-- is evaluated to declare the cargo table.
--
-- this is taken in large part from the Path of Exile wiki's Item2 module.
--
-- TODO(rust dev): define this in a separate module for reuse
function cargo_declare (data)
  return function (frame)
    if frame == nil then
      frame = mw.getCurrentFrame()
    end

    -- dcl_args are the arguments we will be passing to #cargo_declare
    local dcl_args = {}

    -- set the table name
    dcl_args._table = data.table

    -- for every field, we create an argument mapping the field name to its
    -- type.
    for k, field_data in pairs(data.fields) do
      if field_data then
        dcl_args[field_data.field] = field_data.type
      end
    end

    -- call #cargo_declare to declare the table before ending the function.
    frame:callParserFunction('#cargo_declare:', dcl_args)
  end
end

-- invoke these functions to declare the tables.
p.table_chassis = cargo_declare(p.cargo.chassis)
p.table_chassis_locations = cargo_declare(p.cargo.chassis_locations)
p.table_mech = cargo_declare(p.cargo.mech)
p.table_mech_locations = cargo_declare(p.cargo.mech_locations)
p.table_inventory = cargo_declare(p.cargo.mech_inventory)

-- stores the data in cargo. We don't just pass in all the template arguments,
-- as the template may have parameters that are not part of the chassis
-- definition. here instead, we iterate through the known fields of the table
-- and look for matching argument names. If we find one, we add it to the data
-- we use in the cargo store call.
function cargo_store(frame, schema, tpl_args)
  local cargo_data = {_table = schema.table}

  for _, field in pairs(schema.fields) do
    local arg = tpl_args[field.field]
    if arg ~= nil then
      -- consistently cast boolean values to the same type as 1 or 0.
      if field.type == 'Boolean' then
        if arg == 'true' or arg == '1' or arg == 'yes' then
          arg = '1'
        else
          arg ='0'
        end
      end

      cargo_data[field.field] = arg
    end
  end
  return frame:callParserFunction('#cargo_store:', cargo_data)
end

--
-- Classes

-- core.mech defines the lua-centric mech object
core.mech = {}

function core.mech.byVariant(variantName)
  local where = string.format(
    '%s.%s="%s"', p.cargo.chassis.table, 'VariantName', variantName
  )

  return core.mech:new(where)
end

function core.mech.byChassisID(chassisID)
  local where = string.format(
    '%s.%s="%s"', p.cargo.chassis.table, 'Id', chassisID
  )

  return core.mech:new(where)
end

function core.mech:new(where)
  local mech = {}

  setmetatable(mech, self)
  self.__index = self

  local chassisfields = {
    'Id',
    'Name',
    'UIName',
    'VariantName',
    'Details',
    'StockRole',
    'weightClass',
    'Tonnage',
    'StartingTonnage',
    'LeftArmActuatorLimit',
    'RightArmActuatorLimit',
  }

  local chassisRow = cargo.query(
    p.cargo.chassis.table, table.concat(chassisfields, ','), { where = where }
  )
  chassisRow = chassisRow[1]
  if chassisRow == nil then
    return nil
  end

  mech.chassisID = chassisRow['Id']
  mech.uiName = chassisRow['UIName']
  mech.name = chassisRow['Name']
  mech.variantName = chassisRow['VariantName']
  mech.details = chassisRow['Details']
  mech.weightClass = chassisRow['weightClass']
  mech.stockRole = chassisRow['StockRole']
  mech.leftArmActuatorLimit = chassisRow['LeftArmActuatorLimit']
  mech.rightArmActuatorLimit = chassisRow['RightArmActuatorLimit']
  mech.tonnage = tonumber(chassisRow['Tonnage'], 10)
  mech.startingTonnage = tonumber(chassisRow['StartingTonnage'], 10)

  local mechfields = {
    'MechTags',
    'Id',
  }
  local mechwhere = string.format(
    '%s.%s="%s"', p.cargo.mech.table, 'ChassisID', mech.chassisID
  )

  local mechRow = cargo.query(
    p.cargo.mech.table, table.concat(mechfields, ','), { where = mechwhere }
  )

  mechRow = mechRow[1]
  mech.mechID = mechRow['Id']
  mech.tags = mw.text.split(mechRow['MechTags'], ',')

  return mech
end

local standard_actuators = {
}
function core.mech:getLocations()
  if self.locations ~= nil then
    return self.locations
  end

  where = string.format(
    '%s.%s="%s"', 
    p.cargo.chassis_locations.table, 'ChassisID', self.chassisID
  )
  fields = {
    'Location',
    'InventorySlots',
    'MaxArmor',
    'MaxRearArmor',
    'InternalStructure',
    'Hardpoints',
    'OmniHardpoints',
  }

  local chassisLocations = cargo.query(
    p.cargo.chassis_locations.table, 
    table.concat(fields, ','),
    { where = where }
  )

  self.locations = {}
  for _, row in ipairs(chassisLocations) do
    local location = {
      tonnage = tonumber(row['Tonnage'], 10),
      location = row['Location'],
      maxArmor = tonumber(row['MaxArmor'], 10),
      maxRearArmor = tonumber(row['MaxRearArmor'], 10),
      internalStructure = tonumber(row['InternalStructure'], 10),
      hardpoints = mw.text.split(row['Hardpoints'], ','),
      gear = {},
    }
    if row['OmniHardpoints'] == '' then
      location.omniHardpoints = {}
    else
      location.omniHardpoints = mw.text.split(row['OmniHardpoints'], ',')
    end

    self.locations[row['Location']] = location
  end

  local mechwhere = string.format(
    '%s.%s="%s"', 
    p.cargo.mech_locations.table, 'MechID', self.mechID
  )
  local mechfields = {
    'Location',
    'AssignedArmor',
    'AssignedRearArmor',
  }


  local mechlocations = cargo.query(
    p.cargo.mech_locations.table,
    table.concat(mechfields, ','),
    { where = mechwhere }
  )

  for _, row in ipairs(mechlocations) do
    self.locations[row['Location']].assignedArmor = tonumber(row['AssignedArmor'], 10)
    self.locations[row['Location']].assignedRearArmor = tonumber(row['AssignedRearArmor'], 10)
  end

  local mechGear = gear.get_gear(self.chassisID, self.mechID)
  for _, gear in ipairs(mechGear) do
    -- do not include in the gear list fixed-location gear like structure,
    -- gyro, or armor
    local exempt = false
    -- don't display standard actuators, which have IDs starting with emod
    -- all other actuators, display
    if string.match(gear.id, 'emod_arm_part') or string.match(gear.id, 'emod_leg') or string.match(gear.id, 'Gear_Cockpit_Generic_Standard') then
      gear.nodisplay = true
    elseif string.match(gear.id, 'emod_arm_part_lower') or string.match(gear.id, 'emod_arm_part_hand') then
      gear.nodisplay = false 
    end

    for _, category in ipairs(gear.categories) do
      if category == 'EnginePart' then
        gear.nodisplay = true
      elseif category == 'Gyro' then
        self.gyro = gear
        exempt = true
      elseif category == 'Armor' then
        self.armor = gear
        exempt = true
      elseif category == 'Structure' then
        self.structure = gear
        exempt = true
      end

      if category == 'Quirk' then
        self.quirk = gear
      end
    end

    if not exempt then
      table.insert(self.locations[gear.location].gear, gear)
    end
  end

  self.jumpjets = gear.get_jumpjets(self.chassisID, self.mechID)

  return self.locations
end

function core.mech:getEngine()
  if self.engine ~= nil then
    return self.engine
  end

  self.engine = gear.get_engine(self.chassisID, self.mechID)

  return self.engine
end

function core.mech:getWeapons()
  if self.weapons ~= nil then
    return self.weapons
  end
  
  self.weapons = gear.get_weapons(self.chassisID, self.mechID)

  return self.weapons
end

function core.mech:getHeatsinks()
  if self.heatsinks ~= nil then
    return self.heatsinks
  end

  self.heatsinks = gear.get_heatsinks(self.chassisID, self.mechID)

  return self.heatsinks
end

--
-- Templates
--

-- raw_table generates a raw table consisting of the data
function raw_table(schema, tpl_args)
  local rawTable = mw.html.create('table')
  rawTable:addClass('wikitable')

  for _, field in pairs(schema.fields) do
    rawTable:tag('tr')
      :tag('th'):wikitext(field.field):done()
      :tag('td'):wikitext(tpl_args[field.field])
  end
  return rawTable
end

-- p.chassis is a template which defines a Mech chassis.
function p.chassis (frame)
  tpl_args = getArgs(frame, {
    -- parentFirst tells us to prefer args from the parent page (the template
    -- that calls #invoke).
    parentFirst = true
  })

  -- p.mechChassis is a template for raw data, and so we display the raw data
  -- instead of anything fancy. To really display a mech, we need way more than
  -- the chassis, anyway.
  local chassisTable = raw_table(p.cargo.chassis, tpl_args)

  cargo_store(frame, p.cargo.chassis, tpl_args)

  return tostring(chassisTable)
end

-- p.chassisLocation is a template which defines a Mech chassis location
function p.chassisLocation (frame)
  tpl_args = getArgs(frame, {
    parentFirst = true,
  })

  local locationTable = raw_table(p.cargo.chassis_locations, tpl_args)

  cargo_store(frame, p.cargo.chassis_locations, tpl_args)

  return tostring(locationTable)
end

function p.mech (frame)
  tpl_args = getArgs(frame, {
    parentFirst = true
  })

  cargo_store(frame, p.cargo.mech, tpl_args)

  return tostring(raw_table(p.cargo.mech, tpl_args))
end

function p.mechLocation (frame)
  tpl_args = getArgs(frame, {
    parentFirst = true
  })

  cargo_store(frame, p.cargo.mech_locations, tpl_args)

  return tostring(raw_table(p.cargo.mech_locations, tpl_args))
end

function p.mechInventory (frame)
  tpl_args = getArgs(frame, {
    parentFirst = true
  })

  cargo_store(frame, p.cargo.mech_inventory, tpl_args)

  return tostring(raw_table(p.cargo.mech_inventory, tpl_args))
end

-- mechInfoBox creates a mech infobox.
function p.mechInfoBox (frame)
  tpl_args = getArgs(frame, {
    parentFirst = true
  })

  local mech
  if tpl_args['VariantName'] ~= nil and tpl_args['VariantName'] ~= '' then
    mech = core.mech.byVariant(tpl_args['VariantName'])
  elseif tpl_args['ChassisID'] ~= nil and tpl_args['ChassisID'] ~= '' then
    mech = core.mech.byChassisID(tpl_args['ChassisID'])
  end

  if mech == nil then
    local args = {
      variantname = tpl_args['VariantName'],
      mechtype = 'Unknown Mech',
      weight = '???',
      class = '???',
    }
    args['stock role'] = 'Mech not found'
    return frame:expandTemplate{
      title = 'InfoboxVariant',
      args = args
    }
  end

  local structure = 0
  local armor = 0
  -- table keys in lua are also strings. these table keys are the same strings
  -- we expect the hardpoints to be. this is a shortcut to make counting
  -- hardpoints easier.
  local hardpoint_counts = {
    Energy = 0,
    Ballistic = 0,
    Missile = 0,
    Artillery = 0,
    AntiPersonnel = 0,
    BombBay = 0,
    MeleeWeapon = 0,
  }
  local omniHardpoints = 0

  -- mechtype is 'Standard BattleMech', unless we find a non-zero number of
  -- Omni hardpoints. then, we'll change it to be 'OmniMech'.
  local mechtype = 'BattleMech'

  for _, location in pairs(mech:getLocations()) do
    -- add the value of each location's InternalStructure together. all of the
    -- fields returned from cargo are strings, so we have to first convert them
    -- to numbers. I think. I might be wrong about this, actually.
    structure = structure + location.internalStructure
    armor = armor + location.assignedArmor
    if location.assignedRearArmor ~= -1 then
      armor = armor + location.assignedRearArmor
    end

    -- hardpoints is a list of strings. we split that list along the commas to
    -- get an actual list we can iterate.
    for _, hardpoint in ipairs(location.hardpoints) do
      -- then, we check to see if the hardpoint is one of the keys of the
      -- hardpoint_counts table
      if hardpoint_counts[hardpoint] ~= nil then
        -- if it is, then we increment that entry's counter.
        hardpoint_counts[hardpoint] = hardpoint_counts[hardpoint] + 1
      end
    end
    omniHardpoints = omniHardpoints + #location.omniHardpoints
  end
  
  local damage = 0
  local stab = 0
  local heat_damage = 0
  local heat_gen = 0
  for _, weapon in ipairs(mech:getWeapons()) do
    if weapon.damage ~= nil then
      damage = damage + (weapon.damage * weapon.shots)
      stab = stab + (weapon.instability * weapon.shots)
      heat_damage = heat_damage + (weapon.heatDamage * weapon.shots)
      heat_gen = heat_gen + weapon.heat
    end
  end

  local engine = mech:getEngine()
  local heatsinks = mech:getHeatsinks()

  local dissipation = engine.heat_sinking
  for _, heatsink in ipairs(heatsinks) do
    dissipation = dissipation + heatsink.dissipation
  end

  -- finally, to avoid re-inventing the wheel for this demo, we call the
  -- existing InfoboxVariant template with the data we've computed from the
  -- mech's database entries.
  local template_args = {
    variantname = mech.variantName,
    image = mech.name .. '.png',
    mechtype = mechtype,
    weight = '' .. mech.tonnage .. 'T',
    class = mech.weightClass,
    armor = armor,
    structure = structure,
    maxdamage = damage,
    maxstability = stab,
    maxheat = heat_damage,
    alphaheat = heat_gen,
    heatsinking = dissipation,
  }
  if engine ~= nil then
    if engine.fixed then
      template_args.coresize = engine.core .. ' (Fixed)'
    else
      template_args.coresize = engine.core
    end

    if engine.shield.fixed then
      template_args.enginetype = engine.shield.name .. ' (Fixed)'
    else
      template_args.enginetype = engine.shield.name
    end

    if engine.ecooling > 0 then
      if engine.ecooling_fixed then
        template_args.ecooling = string.format('+%d (Fixed)', engine.ecooling)
      else
        template_args.ecooling = string.format('+%d', engine.ecooling)
      end
    else
      template_args.ecooling = 'None'
    end

    if engine.cooling_fixed then
      template_args.heatsinkkit = engine.heatsinkkit .. ' (Fixed)'
    else
      template_args.heatsinkkit = engine.heatsinkkit
    end
  end

  if mech.gyro ~= nil then
    if mech.gyro.fixed then
      template_args.gyro = mech.gyro.name .. ' (Fixed)'
    else
      template_args.gyro = mech.gyro.name
    end
    if string.find(mech.gyro.name, "Omni") then
      template_args.mechtype = 'OmniMech'
    end
  end
  if mech.armor ~= nil then
    if mech.armor.fixed then
      template_args.armortype = mech.armor.name .. ' (Fixed)'
    else
      template_args.armortype = mech.armor.name
    end
  end
  if mech.structure ~= nil then
    template_args.structuretype = mech.structure.name .. ' (Fixed)'
  end

  for locationName, location in pairs(mech.locations) do
    local locationstring = ""
    if locationName == 'Head' then
      locationstring = 'headequip'
    elseif locationName == 'CenterTorso' then
      locationstring = 'ctequip'
    elseif locationName == 'LeftTorso' then
      locationstring = 'ltequip'
    elseif locationName == 'RightTorso' then
      locationstring = 'rtequip'
    elseif locationName == 'LeftArm' then
      locationstring = 'laequip'
    elseif locationName == 'RightArm' then
      locationstring = 'raequip'
    elseif locationName == 'LeftLeg' then 
      locationstring = 'llequip'
    else 
      locationstring = 'rlequip'
    end

    local gearSlot = 1
    for i, gear in ipairs(location.gear) do
      -- skip gear marked nodisplay, which includes stuff like actuators and
      -- gyros.
      if not gear.nodisplay then
        if gear.fixed == '1' or gear.fixed then
          template_args[locationstring .. gearSlot] = gear.uiname .. ' (Fixed)'
        else
          template_args[locationstring .. gearSlot] = gear.uiname
        end
        gearSlot = gearSlot + 1
      end
    end
  end

  if hardpoint_counts.Ballistic > 0 then 
    template_args.ballistic = hardpoint_counts.Ballistic
  end
  if hardpoint_counts.Energy > 0 then
    template_args.energy = hardpoint_counts.Energy
  end
  
  if hardpoint_counts.Missile > 0 then
    template_args.missile = hardpoint_counts.Missile
  end

  if hardpoint_counts.Artillery > 0 then
    template_args.artillery = hardpoint_counts.Artillery
  end

  if hardpoint_counts.AntiPersonnel > 0 then
    template_args.support = hardpoint_counts.AntiPersonnel
  end
  
  if omniHardpoints > 0 then
    template_args.omni = omniHardpoints
  end

  if hardpoint_counts.BombBay > 0 then
    template_args.bomb = hardpoint_counts.BombBay
  end

  if hardpoint_counts.MeleeWeapon > 0 then
    template_args.melee = hardpoint_counts.MeleeWeapon
  end

  -- walk MP, according to the mech bay, is some weird number. This math is
  -- 110% made up out of nowhere, and only mostly correct, probably. For some
  -- mechs, it'll need manual override.
  --
  -- local walkMeters = (mech.engine.core / mech.tonnage) * 30
  -- local runMeters = walkMeters * 1.5

  -- local walkMP = math.floor(walkMeters / 28)
  -- local runMP = math.floor(runMeters / 28)

  local walkMP = math.ceil(mech.engine.core / mech.tonnage)
  local runMP = math.ceil(walkMP * 1.5)
  local jumpMP = 0 
  for _, j in ipairs(mech.jumpjets) do
    jumpMP = jumpMP + j.jump
  end

  if jumpMP >= 0 then
    template_args.speed = string.format('%d/%d/%d', walkMP, runMP, jumpMP)
  end

  template_args['stock role'] = mech.stockRole

  template_args['kickdamage'] = mech.tonnage
  template_args['punchdamage'] = math.ceil(mech.tonnage / 2)

  -- finally, lastly, the user can put in raw template data and override our
  -- calculations. so, before we return anything, go through tpl_args and
  -- replace any outgoing template args
  for arg, val in pairs(tpl_args) do
    -- skip the VariantName arg, though. That one belongs to us.
    if arg ~= 'VariantName' then
      template_args[arg] = val
    end
  end

  return frame:expandTemplate{
    title = 'InfoboxVariant',
    args = template_args
  }
end

return p