Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Module:VehicleHardpoint

From the Star Citizen Wiki, the fidelity™ encyclopedia
More languages
Module documentation[view][edit][history][purge]
This documentation is transcluded from Module:VehicleHardpoint/doc. Changes can be proposed in the talk page.
Function list
L 22 — t
L 32 — translate
L 40 — hasChildren
L 49 — makeSmwQueryObject
L 116 — makeKey
L 174 — methodtable.getHardpointData
L 200 — addSubComponents
L 317 — methodtable.makeObject
L 345 — setTypeSubtype
L 474 — methodtable.setHardPointObjects
L 482 — cleanClassName
L 497 — addToOut
L 536 — addHardpoints
L 609 — methodtable.setParts
L 622 — makeKey
L 636 — addToOut
L 652 — addParts
L 701 — methodtable.setComponents
L 741 — methodtable.querySmwStore
L 765 — methodtable.group
L 804 — methodtable.createDataStructure
L 821 — stratify
L 858 — methodtable.makeSubtitle
L 1047 — methodtable.makeOutput
L 1051 — makeEntry
L 1159 — makeSection
L 1217 — methodtable.out
L 1258 — methodtable.makeDebugOutput
L 1299 — VehicleHardpoint.fixTypes
L 1302 — assign
L 1320 — fixHardpoint
L 1371 — VehicleHardpoint.new
L 1384 — VehicleHardpoint.outputTable
L 1401 — VehicleHardpoint.test
L 1424 — VehicleHardpoint.evalRule
L 1430 — invalidRule
👌 All tests passed.
Unit tests
Name Expected Actual
👌 testApplyConcatFix
👌 testApplyFixVtolThruster
👌 testNestedRuleFalse
👌 testNestedRuleTrue
👌 testNestedRuleTrue2
👌 testSimpleAndEqRuleFalse
👌 testSimpleAndEqRuleTrue
👌 testSimpleEqRuleFalse
👌 testSimpleEqRuleTrue
👌 testSimpleMatchRuleFalse
👌 testSimpleMatchRuleTrue

This module saves and displays the hardpoints found on a vehicle. Implements {{Vehicle hardpoints}}.


require( 'strict' )

local VehicleHardpoint = {}

local metatable = {}
local methodtable = {}

metatable.__index = methodtable

local i18n = require( 'Module:i18n' ):new()
local TNT = require( 'Module:Translate' ):new()
local common = require( 'Module:Common' ) -- formatNum and spairs
local hatnote = require( 'Module:Hatnote' )._hatnote
local data = mw.loadJsonData( 'Module:VehicleHardpoint/data.json' )
local config = mw.loadJsonData( 'Module:VehicleHardpoint/config.json' )


--- Wrapper function for Module:i18n.translate
---
--- @param key string The translation key
--- @return string If the key was not found, the key is returned
local function t( key )
	return i18n:translate( key )
end


--- Calls TNT with the given key
---
--- @param key string The translation key
--- @param addSuffix boolean|nil Adds a language suffix if config.smw_multilingual_text is true
--- @return string If the key was not found in the .tab page, the key is returned
local function translate( key, addSuffix, ... )
	return TNT:translate( 'Module:VehicleHardpoint/i18n.json', config, key, addSuffix, {...} ) or key
end


--- Checks if an entry contains a 'child' key with further entries
---
--- @return boolean
local function hasChildren( row )
    return row.children ~= nil and type( row.children ) == 'table' and #row.children > 0
end


--- Creates the object that is used to query the SMW store
---
--- @param page string the vehicle page containing data
--- @return table
local function makeSmwQueryObject( page )
    local langSuffix = ''
    if config.smw_multilingual_text == true then
        langSuffix = '+lang=' .. ( config.module_lang or mw.getContentLanguage():getCode() )
    end

    return {
        string.format(
            '[[-Has subobject::' .. page .. ']][[%s::+]][[%s::+]]',
            t( 'SMW_HardpointType' ),
            t( 'SMW_VehicleHardpointsTemplateGroup' )
        ),
        string.format( '?%s#-=from_gamedata', t( 'SMW_FromGameData' ) ),
        string.format( '?%s#-=count', t( 'SMW_ItemQuantity' ) ),
        string.format( '?%s#-=min_size', t( 'SMW_HardpointMinimumSize' ) ),
        string.format( '?%s#-=max_size', t( 'SMW_HardpointMaximumSize' ) ),
        string.format( '?%s#-=class', t( 'SMW_VehicleHardpointsTemplateGroup' ) ), langSuffix,
        string.format( '?%s#-=type', t( 'SMW_HardpointType' ) ), langSuffix,
        string.format( '?%s#-=sub_type', t( 'SMW_HardpointSubtype' ) ), langSuffix,
        string.format( '?%s#-=name', t( 'SMW_Name' ) ),
        string.format( '?%s#-n=scu', t( 'SMW_Inventory' ) ),
        string.format( '?%s#-=length', t( 'SMW_EntityLength' ) ),
        string.format( '?%s#-=width', t( 'SMW_EntityWidth' ) ),
        string.format( '?%s#-=height', t( 'SMW_EntityHeight' ) ),
        string.format( '?UUID#-=uuid' ),
        string.format( '?%s#-=hardpoint', t( 'SMW_Hardpoint' ) ) ,
        string.format( '?%s#-=class_name', t( 'SMW_HardpointClassName' ) ) ,
        string.format( '?%s#-=magazine_capacity', t( 'SMW_MagazineCapacity' ) ),
        string.format( '?%s=thrust_capacity', t( 'SMW_ThrustCapacity' ) ),
        string.format( '?%s=damage', t( 'SMW_Damage' ) ),
        string.format( '?%s=damage_radius', t( 'SMW_DamageRadius' ) ),
        string.format( '?%s=fuel_capacity', t( 'SMW_FuelCapacity' ) ),
        string.format( '?%s=fuel_intake_rate', t( 'SMW_FuelIntakeRate' ) ),
        string.format( '?%s#-=parent_hardpoint', t( 'SMW_ParentHardpoint' ) ),
        string.format( '?%s#-=root_hardpoint', t( 'SMW_RootHardpoint' ) ),
        string.format( '?%s#-=parent_uuid', t( 'SMW_ParentHardpointUuid' ) ),
        string.format( '?%s#-=icon', t( 'SMW_Icon' ) ),
        string.format( '?%s=hp', t( 'SMW_HitPoints' ) ),
        string.format( '?%s#-=position', t( 'SMW_Position' ) ),
        -- These are subquery chains, they require that the 'Name' attribute is of type Page
        -- And that these pages contain SMW attributes
        '?' .. t( 'SMW_Name' ) .. '.' .. t( 'SMW_Grade' ) .. '#-=item_grade',
        '?' .. t( 'SMW_Name' ) .. '.' .. t( 'SMW_Class' ) .. '#-=item_class',
        '?' .. t( 'SMW_Name' ) .. '.' .. t( 'SMW_Size' ) .. '#-=item_size',
        '?' .. t( 'SMW_Name' ) .. '.' .. t( 'SMW_Manufacturer' ) .. '#-=manufacturer',
        string.format(
            'sort=%s,%s,%s,%s,%s',
            t( 'SMW_VehicleHardpointsTemplateGroup' ),
            t( 'SMW_Hardpoint' ),
            t( 'SMW_HardpointType' ),
            t( 'SMW_HardpointMaximumSize' ),
            t( 'SMW_ItemQuantity' )
        ),
        'order=asc,desc,asc,asc,asc',
        'limit=1000'
    }
end


--- Creates a 'key' based on various data points found on the hardpoint and item
--- Based on this key, the count of some entries is generated
---
--- @param row table - API Data
--- @param hardpointData table - Data from getHardpointData
--- @param parent table|nil - Parent hardpoint (A settable SMW Subobject)
--- @param root string|nil - Root hardpoint
--- @return string Key
local function makeKey( row, hardpointData, parent, root )
    local key

    -- If the hardpoint has an item attached
    if type( row.item ) == 'table' then
        -- List of item types that should always be grouped together
        -- i.e. their count is increased instead of them being displayed as separate boxes
        if row.type == 'ManneuverThruster' or
           row.type == 'MainThruster' or
           row.type == 'ArmorLocker' or
           row.type == 'Bed' or
           row.type == 'Cargo'
        then
            key = row.type .. row.sub_type
        else
            local suffix = ( row.item.name or '' )
            if suffix == '<= PLACEHOLDER =>' then
                suffix = row.item.uuid
            end

            -- Adding the uuid to the key ensures separate boxes if the equipped item differs
            key = row.type .. row.sub_type .. suffix
        end
    else
        -- If no item is set, use the pre-defined class and type
        key = hardpointData.class .. hardpointData.type
    end

    -- Appends the parent and root hardpoints in order to not mess up child counts
    -- Without this, a vehicle with four turrets containing each one weapon would be listed as
    -- having four turrets that each has four weapons (if the exact weapon is equipped on each turret)
    if parent ~= nil and parent[ t( 'SMW_Name' ) ] ~= nil and
       row.type ~= 'DecoyLauncherMagazine' and
       row.type ~= 'NoiseLauncherMagazine'
    then
        --key = key .. parent[ t( 'SMW_Hardpoint' ) ]
        key = key .. ( parent[ t( 'SMW_Name' ) ] or parent[ t( 'SMW_Hardpoint' ) ] )
    end

    if root ~= nil and not string.match( key, root ) and ( hardpointData.class == 'Weapons' or hardpointData.class == 'Utility' ) then
        key = key .. root
    end

--    if hardpointData.class == 'Weapons' and row.name ~= nil and row.type == 'MissileLauncher' then
--        key = key .. row.item.name or row.name
--    end

    --mw.logObject( string.format( 'Key: %s', key ), '📐 [VehicleHardpoint] makekey' )

    return key
end


--- Get pre-defined hardpoint data for a given hardpoint type
--- If no type is found, the hardpoint name is matched against the defined regexes until the first one matches
---
--- @param hardpointType string
--- @return table|nil
function methodtable.getHardpointData( self, hardpointType )
    if type( data.matches[ hardpointType ] ) == 'table' then
        return data.matches[ hardpointType ]
    end

    for hType, mappingData in pairs( data.matches ) do
        if hardpointType == hType then
            return mappingData
        elseif type( mappingData.matches ) == 'table' then
            for _, matcher in pairs( mappingData.matches ) do
                if string.match( hardpointType, matcher ) ~= nil then
                    return mappingData
                end
            end
        end
    end

    return nil
end


--- Creates a child object for weapons and counter measure ammunitions
--- As well as weapon ports on armor locker
---
--- @param hardpoint table A hardpoint object form the API
--- @return nil
local function addSubComponents( hardpoint )
    if type( hardpoint.item ) ~= 'table' then
        return
    end

    if type( hardpoint.children ) ~= 'table' then
        hardpoint.children = {}
    end

    if hardpoint.item.type == 'WeaponDefensive' or hardpoint.item.type == 'WeaponGun' then
        local item_type = 'Magazine'
        if string.sub( hardpoint.class_name, -5 ) == 'chaff' then
            item_type = 'NoiseLauncherMagazine'
        elseif string.sub( hardpoint.class_name, -5 ) == 'flare' then
            item_type = 'DecoyLauncherMagazine'
        end

        local capacity = {}
        local magazineName = translate( 'Magazine' )
        if hardpoint.item.type == 'WeaponGun' and type( hardpoint.item.vehicle_weapon ) == 'table' then
            table.insert( capacity, hardpoint.item.vehicle_weapon.capacity )

            -- This is a laser weapon, add another capacity of -1 to indicate that this weapon has infinite ammo
            if type( hardpoint.item.vehicle_weapon.regeneration ) == 'table' then
                table.insert( capacity, -1 )
                magazineName = translate( 'Capacitor' )
            end
        elseif type( hardpoint.item.counter_measure ) == 'table' then
            table.insert( capacity, hardpoint.item.counter_measure.capacity )
        end

        table.insert( hardpoint.children, {
            name = 'faux_hardpoint_magazine',
            class_name = 'FAUX_' .. item_type .. 'Magazine',
            type = item_type,
            sub_type = item_type,
            min_size = 1,
            max_size = 1,
            item = {
                name = magazineName,
                type = item_type,
                sub_type = item_type,
                magazine_capacity = capacity
            }
        } )
    end

    -- This seems to be a weapon rack
    if ( hardpoint.item.type == 'Usable' or hardpoint.item.type == 'Door' ) and type( hardpoint.item.ports ) == 'table' then
        local item_type = 'WeaponPort'
        for _, port in pairs( hardpoint.item.ports ) do
            -- Prevent stuff like mattress and pillow to count as weapon ports (I don't think SC let you hide weapons inside them :P)
            if ( string.find( port.name, 'weapon', 1, true ) or string.find( port.display_name, 'weapon', 1, true ) ) then
                local sub_type = item_type .. tostring( port.sizes.min or 0 ) .. tostring( port.sizes.max or 0 )
                local name = 'WeaponPort'

                if port.sizes.max == 5 or string.find( port.display_name, 'launcher', 1, true ) then
                    name = name .. 'Launcher'
                elseif port.sizes.max == 4 or string.find( port.display_name, 'rifle', 1, true ) then
                    name = name .. 'Rifle'
                elseif string.find( port.display_name, 'multitool', 1, true ) then
                    name = name .. 'Multitool'
                elseif string.find( port.display_name, 'addon', 1, true ) then
                    name = name .. 'Addon'
                -- Assume size 1 is pistol slot if it is not specified as multitool or addon
                elseif port.sizes.max == 1 or string.find( port.display_name, 'pistol', 1, true ) then
                    name = name .. 'Pistol'
                end

                table.insert( hardpoint.children, {
                    name = 'faux_hardpoint_weaponport',
                    class_name = 'FAUX_WeaponPort',
                    type = item_type,
                    sub_type = sub_type,
                    min_size = port.sizes.min,
                    max_size = port.sizes.max,
                    item = {
                        name = translate( name ),
                        type = item_type,
                        sub_type = sub_type,
                    }
                } )
            end
        end
    end

    -- Missiles Set on Ports
    if hardpoint.item.type == 'MissileLauncher' and type( hardpoint.item.ports ) == 'table' then
        for _, port in pairs( hardpoint.item.ports ) do
            if type( port.equipped_item ) == 'table' then
                local item = port.equipped_item
                table.insert( hardpoint.children, {
                    name = port.name,
                    --class_name = port.equipped_item
                    type = 'Missile',
                    sub_type = item.sub_type,
                    min_size = port.sizes.min,
                    max_size = port.sizes.max,
                    item = {
                        name = item.name,
                        type = item.type,
                        sub_type = item.sub_type,
                    }
                } )
            end
        end
    end
end


--- Builds the object that is saved to SMW as a Subobject
---
--- @param row table - API Data
--- @param hardpointData table|nil - Data from getHardpointData
--- @param parent table|nil - Parent hardpoint
--- @param root string|nil - Root hardpoint
--- @return table|nil
function methodtable.makeObject( self, row, hardpointData, parent, root )
    local object = {}

    if hardpointData == nil then
        hardpointData = self:getHardpointData( row.type or row.name )
    end

    if hardpointData == nil then
        return nil
    end

    object[ t( 'SMW_Hardpoint' ) ] = row.name
    object[ t( 'SMW_FromGameData' ) ] = true
    object[ t( 'SMW_HardpointMinimumSize' ) ] = row.min_size
    object[ t( 'SMW_HardpointMaximumSize' ) ] = row.max_size
    object[ t( 'SMW_VehicleHardpointsTemplateGroup' ) ] = translate( hardpointData.class, true )
    object[ t( 'SMW_HitPoints' ) ] = row.damage_max
    object[ t( 'SMW_Position' ) ] = row.position

    if type( row.class_name ) == 'string' then
        object[ t( 'SMW_HardpointClassName' ) ] = row.class_name
    end

    object[ t( 'SMW_HardpointType' ) ] = translate( hardpointData.type, true )
    object[ t( 'SMW_HardpointSubtype' ) ] = translate( hardpointData.type, true )

    -- FIXME: Is there a way to use Lua table key directly instead of setting subtype separately in data.json?
    -- For some components (e.g. missile), the key is the subtype of the component
    local function setTypeSubtype( match )
        if match ~= nil then
            if match.type ~= nil then
                object[ t( 'SMW_HardpointType' ) ] = translate( match.type, true )
            end
            if match.subtype ~= nil then
                object[ t( 'SMW_HardpointSubtype' ) ] = translate( match.subtype, true )
            end
        end
    end

    setTypeSubtype( data.matches[ row.type ] )
    setTypeSubtype( data.matches[ row.sub_type ] )

    if hardpointData.item ~= nil and type( hardpointData.item.name ) == 'string' then
        object[ t( 'SMW_Name' ) ] = hardpointData.item.name
    end

    if type( row.item ) == 'table' then
        local itemObj = row.item

        if itemObj.name ~= '<= PLACEHOLDER =>' then
            local match = string.match( row.class_name or '', '[Dd]estruct_(%d+s)' )

            if row.type == 'SelfDestruct' and match ~= nil then
                object[ t( 'SMW_Name' ) ] = string.format( '%s (%s)', t( 'SMW_SelfDestruct' ), match )
                -- Set self-destruct stats
                -- FIXME: Do subquery instead when CIG properly implement self-destruct components
                if itemObj.self_destruct then
                    object[ t( 'SMW_Damage' ) ] = itemObj.self_destruct.damage
                    object[ t( 'SMW_DamageRadius' ) ] = itemObj.self_destruct.radius
                end
            else
                object[ t( 'SMW_Name' ) ] = itemObj.name
            end
        else
            object[ t( 'SMW_Name' ) ] = object[ t( 'SMW_HardpointSubtype' ) ]
            -- Remove lang suffix
            local parts = mw.text.split( object[ t( 'SMW_Name' ) ], '@', true )
            object[ t( 'SMW_Name' ) ] = parts[ 1 ] or object[ t( 'SMW_Name' ) ]
        end

        object[ t( 'SMW_MagazineCapacity' ) ] = itemObj.magazine_capacity

        if ( itemObj.type == 'Cargo' or itemObj.type == 'SeatAccess' or itemObj.type == 'CargoGrid' or itemObj.type == 'Container' )
                and type( itemObj.inventory ) == 'table' then
            object[ t( 'SMW_Inventory' ) ] = common.formatNum( (itemObj.inventory.scu or nil ), nil )

            if itemObj.type == 'CargoGrid' then
                object[ t( 'SMW_EntityHeight' ) ] = common.formatNum( (itemObj.inventory.height or nil ), nil )
                object[ t( 'SMW_EntityLength' ) ] = common.formatNum( (itemObj.inventory.length or nil ), nil )
                object[ t( 'SMW_EntityWidth' ) ] = common.formatNum( (itemObj.inventory.width or nil ), nil )
            end
        end

        if itemObj.thruster then
            object[ t( 'SMW_ThrustCapacity' ) ] = itemObj.thruster.thrust_capacity
            --- Convert to per Newton since thrust capacity is in Newton
            object[ t( 'SMW_FuelBurnRate' ) ] = itemObj.thruster.fuel_burn_per_10k_newton / 10000
        end

        if itemObj.fuel_tank and itemObj.fuel_tank.capacity > 0 then
            object[ t( 'SMW_FuelCapacity' ) ] = itemObj.fuel_tank.capacity
        end

        if itemObj.fuel_intake then
            object[ t( 'SMW_FuelIntakeRate' ) ] = itemObj.fuel_intake.fuel_push_rate
        end

        if object[ t( 'SMW_HardpointMinimumSize' ) ] == nil then
            object[ t( 'SMW_HardpointMinimumSize' ) ] = itemObj.size
            object[ t( 'SMW_HardpointMaximumSize' ) ] = itemObj.size
        end

        object[ 'UUID' ] = row.item.uuid
    end

    if parent ~= nil then
        object[ t( 'SMW_ParentHardpointUuid' ) ] = parent[ 'UUID' ]
        object[ t( 'SMW_ParentHardpoint' ) ] = parent[ t( 'SMW_Name' ) ]
    end

    if root ~= nil then
        object[ t( 'SMW_RootHardpoint' ) ] = root
    end

    -- Icon
    local icon = hardpointData.type
    if data.section_label_fixes[ hardpointData.class ] ~= nil or data.section_label_fixes[ hardpointData.type ] ~= nil then
        icon = data.section_label_fixes[ hardpointData.class ] or data.section_label_fixes[ hardpointData.type ]
    end

    for hType, iconKey in pairs( data.icons ) do
        if hType == icon then
            -- Disable label missing icons for now
            if iconKey == '' then
                icon = nil
                break
            end
            -- Apply icon key override
            icon = iconKey
        end
    end

    if icon ~= nil then
        if config.icon_name_localized == true then
            icon = translate( icon )
        end

        if config.icon_name_lowercase == true then
            icon = string.lower( icon )
        end

        object[ t( 'SMW_Icon' ) ] = string.format( 'File:%s%s.svg', config.icon_prefix, icon )
    end

    -- Remove SeatAccess Hardpoints without storage
    if row.item ~= nil and row.item.type == 'SeatAccess' and object[ t( 'SMW_Inventory' ) ] == nil then
        object = nil
    end

    return object;
end


--- Sets all available hardpoints as SMW subobjects
--- This method should be called by the accompanying Vehicle Module
---
--- @param hardpoints table API Hardpoint data
function methodtable.setHardPointObjects( self, hardpoints )
    if type( hardpoints ) ~= 'table' then
        error( translate( 'msg_invalid_hardpoints_object' ) )
    end

    local objects = {}
    local depth = 1

    local function cleanClassName( input )
        if string.find( input, 'turret', 1, true ) then
            local parts = mw.text.split( input, 'turret', true )
            input = parts[ 1 ] or input
        end

        for _, remove in pairs( { 'top', 'bottom', 'left', 'right', 'front', 'rear', 'bubble', 'side' } ) do
            input = string.gsub( input, '_' .. remove, '', 1 )
        end

        return input
    end

    -- Adds the subobject to the list of objects that should be saved to SMW
    -- Increases the item quantity / or combined cargo capacity for objects that have equal keys
    local function addToOut( object, key )
        if object == nil then
            return
        end

        -- If this key (object) has not been seen before, save it to the list of subobjects
        if type( objects[ key ] ) ~= 'table' then
            objects[ key ] = object
            objects[ key ][ t( 'SMW_ItemQuantity' ) ] = 1
        else 
            -- This key (object) has been seen before: Increase the quantity and any other cumulative metrics
            -- Sometimes objects[ key ][ t( 'SMW_ItemQuantity' ) ] is undefined, fallback to 1
            objects[ key ][ t( 'SMW_ItemQuantity' ) ] = objects[ key ][ t( 'SMW_ItemQuantity' ) ] + 1 or 1
            if object[ t( 'SMW_Position' ) ] ~= nil then
                if type( objects[ key ][ t( 'SMW_Position' ) ] ) == 'table' then
                    table.insert( objects[ key ][ t( 'SMW_Position' ) ], object[ t( 'SMW_Position' ) ] )
                else
                    objects[ key ][ t( 'SMW_Position' ) ] = {
                        objects[ key ][ t( 'SMW_Position' ) ],
                        object[ t( 'SMW_Position' ) ]
                    }
                end
            end

            local inventoryKey = t( 'SMW_Inventory' )
            -- Accumulate the cargo capacities of all cargo grids
            if object[ inventoryKey ] ~= nil and objects[ key ][ inventoryKey ] ~= nil then
                local sucExisting, numExisting = pcall( tonumber, objects[ key ][ inventoryKey ], 10 )
                local sucNew, numNew = pcall( tonumber, object[ inventoryKey ], 10 )

                if sucExisting and sucNew and numExisting ~= nil and numNew ~= nil then
                    objects[ key ][ inventoryKey ] = numExisting + numNew
                end
            end
        end
    end


    -- Iterates through the list of hardpoints found on the API object
    local function addHardpoints( hardpoints, parent, root )
        for _, hardpoint in pairs( hardpoints ) do
            hardpoint.name = string.lower( hardpoint.name )

            if type( hardpoint.class_name ) == 'string' then
                hardpoint.class_name = cleanClassName( string.lower( hardpoint.class_name ) )
            end

            hardpoint = VehicleHardpoint.fixTypes( hardpoint, data.fixes )

            local hardpointData = self:getHardpointData( hardpoint.type or hardpoint.name )

            if hardpointData ~= nil then
                if depth == 1 then
                    if type( hardpoint.item ) == 'table' then
                        root = hardpoint.class_name or hardpoint.name

                        if root == '<= PLACEHOLDER =>' then
                            root = hardpointData.type
                        end
                    else
                        root = hardpoint.name
                    end
                    --mw.logObject( string.format( 'Root: %s', root ), '📐 [VehicleHardpoint] addHardpoints' )
                end

                addSubComponents( hardpoint )

                -- Based on the key, the hardpoint is either used as "standalone" (i.e. saved as a single subobject)
                -- or, if the key already exists, the count if increased by one (so no extra subobject is generated)
                local key = makeKey( hardpoint, hardpointData, parent, root )

                local obj = self:makeObject( hardpoint, hardpointData, parent, root )

                addToOut( obj, key )

                -- Generate child subobjects
                if hasChildren( hardpoint ) then
                    depth = depth + 1
                    addHardpoints( hardpoint.children, obj, root )
                end
            elseif hasChildren( hardpoint ) then
                -- Fix for P72, if the main hardpoint is ignored, but it has children, try them
                for _, child in pairs( hardpoint.children ) do
                    table.insert( hardpoints, child )
                end
            end
        end

        depth = depth - 1

        if depth < 1 then
            depth = 1
            root = nil
        end
    end

    addHardpoints( hardpoints )

    --mw.logObject( objects, '📐 [VehicleHardpoint] setHardPointObjects' )

    for _, subobject in pairs( objects ) do
        mw.smw.subobject( subobject )
    end

    return objects
end


--- Sets all available vehicle parts as SMW subobjects
--- This method should be called by the accompanying Vehicle Module
---
--- @param parts table API Parts data
function methodtable.setParts( self, parts )
    if type( parts ) ~= 'table' then
        error( translate( 'msg_invalid_hardpoints_object' ) )
    end

    local objects = {}
    local depth = 1

    local partData = {
        class = 'VehiclePart',
        type = 'VehiclePart',
    }

    local function makeKey( row, parent )
        local key = row.name

        if parent ~= nil then
            key = key .. parent[ t( 'SMW_Hardpoint' ) ]
        end

        --mw.logObject( string.format( 'Key: %s', key ), '📐 [VehicleHardpoint] makeKey' )

        return key
    end


    -- Adds the subobject to the list of objects that should be saved to SMW
    local function addToOut( object, key )
        if object == nil then
            return
        end

        -- If this key (object) has not been seen before, save it to the list of subobjects
        if type( objects[ key ] ) ~= 'table' then
            if object ~= nil then
                objects[ key ] = object
                objects[ key ][ t( 'SMW_ItemQuantity' ) ] = 1
            end
        end
    end


    -- Iterates through the list of parts found on the API object
    local function addParts( parts, parent, root )
        for _, part in pairs( parts ) do
            part.type = 'VehiclePart'
            part.min_size = 1
            part.max_size = 1
            part.item = {
                name = part.display_name
            }

            if depth == 1 then
                root = part.name
                --mw.logObject( string.format( 'Root: %s', root ), '📐 [VehicleHardpoint] addParts' )
            end

            local key = makeKey( part, parent )

            local obj = self:makeObject( part, partData, parent, root )

            addToOut( obj, key )

            -- Generate child subobjects
            if hasChildren( part ) then
                depth = depth + 1
                addParts( part.children, obj, root )
            end
        end

        depth = depth - 1

        if depth < 1 then
            depth = 1
            root = nil
        end
    end

    addParts( parts )

    --mw.logObject( objects, '📐 [VehicleHardpoint] setParts' )

    for _, subobject in pairs( objects ) do
        mw.smw.subobject( subobject )
    end
end


--- Sets all available ship-matrix components as SMW subobjects
--- This method should be called by the accompanying Vehicle Module
---
--- @param components table API components data
function methodtable.setComponents( self, components )
    if type( components ) ~= 'table' then
        error( translate( 'msg_invalid_hardpoints_object' ) )
    end

    local lang = mw.getContentLanguage()

    for _, component in pairs( components ) do
        local parts = mw.text.split( components.type, '_', true )
        local type = ''
        for _, part in ipairs( parts ) do
            type = type .. lang:ucfirst( part )
        end

        type = mw.text.trim( type, 's' )

        mw.smw.subobject( {
            [ t( 'SMW_VehicleHardpointsTemplateGroup' ) ] = translate( component.class, true ),
            [ t( 'SMW_HardpointType' ) ] = translate( type, true ),
            [ t( 'SMW_Name' ) ] = translate( component.name:gsub( ' ', '' ) ),
            [ t( 'SMW_ItemQuantity' ) ] = component.quantity,
            --[ 'Komponentenbefestigungen' ] = component.mounts,
            [ t( 'SMW_Size' ) ] = component.component_size,
            [ t( 'SMW_HardpointMaximumSize' ) ] = component.size,
            [ t( 'SMW_FromGameData' ) ] = false,
        } )
    end

    --mw.logObject( objects, '📐 [VehicleHardpoint] setParts' )

    for _, subobject in pairs( objects ) do
        mw.smw.subobject( subobject )
    end
end


--- Queries the SMW store for all available hardpoint subobjects for a given page
---
--- @param page string - The page to query
--- @return table|nil hardpoints
function methodtable.querySmwStore( self, page )
    -- Cache multiple calls
    if self.smwData ~= nil then
        return self.smwData
    end

    local smwData = mw.smw.ask( makeSmwQueryObject( page ) )

    if smwData == nil or smwData[ 1 ] == nil then
        return nil
    end

    --mw.logObject( smwData, '📐 [VehicleHardpoint] querySmwStore' )

    self.smwData = smwData

    return self.smwData
end


--- Group Hardpoints by Class and type
---
--- @param smwData table SMW data - Requires a 'class' key on each row
--- @return table
function methodtable.group( self, smwData )
    local grouped = {}

    if type( smwData ) ~= 'table' then
        return {}
    end

    for _, row in ipairs( smwData ) do
        if not row.isChild and row.class ~= nil and row.type ~= nil and
            -- Specifically hide manually added weapon ports that have no parent
            -- This should not be needed anymore if weapon lockers are found everywhere with an uuid
            row.type ~= translate( 'WeaponPort' ) and
            not string.find( row.type, translate( 'Magazine' ), 1, true )
        then
            if type( grouped[ row.class ] ) ~= 'table' then
                grouped[ row.class ] = {}
            end

            if type( grouped[ row.class ][ row.type ] ) ~= 'table' then
                grouped[ row.class ][ row.type ] = {}
            end

            table.insert( grouped[ row.class ][ row.type ], row )

            self.iconMap[ row.class ] = row.icon
            self.iconMap[ row.type ] = row.icon
        end
    end

    --mw.logObject( grouped, '📐 [VehicleHardpoint] grouped' )

    return grouped
end


--- Adds children to the according parents
---
--- @param smwData table All available Hardpoint objects for this page
--- @return table The stratified table
function methodtable.createDataStructure( self, smwData )
    -- Maps a key to the index of the subobject, this way children can be set on their parent
    local idMapping = {}

    for index, object in ipairs( smwData ) do
        local keyMap
        if object.class == translate( 'VehiclePart' ) and object.name ~= nil then
            keyMap = object.name
        else
            keyMap = ( object.root_hardpoint or object.class_name or '' ) .. ( object.name or object.type or '' )
        end

        idMapping[ keyMap ] = index
    end

    -- Iterates through the list of SMW hardpoint subobjects
    -- If the 'parent_hardpoint' key is set (i.e. the hardpoint is a child), it is added as a child to the parent object
    local function stratify( toStratify )
        for _, object in ipairs( toStratify ) do
            if object.parent_hardpoint ~= nil then
                local parentEl
                if object.class == translate( 'VehiclePart' ) and object.parent_hardpoint ~= nil then
                    parentEl = toStratify[ idMapping[ object.parent_hardpoint ] ]
                else
                    parentEl = toStratify[ idMapping[ ( ( object.root_hardpoint or '' ) .. object.parent_hardpoint ) ] ]
                end

                if parentEl ~= nil then
                    if parentEl.children == nil then
                        parentEl.children = {}
                    end

                    object.isChild = true

                    table.insert( parentEl.children, object )
                end
            end
        end
    end

    -- SMW outputs a "flat" List of objects, after this the output is more or less equal to that from the API
    stratify( smwData )

    return smwData
end


--- Creates the subtitle that is shown in the card
---
--- Show info based on importance to readers
--- When the first tier is not available, show the next tier
---
--- @param item table Item i.e. row from the smw query
--- @return string
function methodtable.makeSubtitle( self, item )
    local subtitle = {}

    -- Tier 1
    -- Component-specific stats that affects gameplay

    -- SCU
    if item.scu ~= nil then
        -- Fix for german number format
        if string.find( item.scu, ',', 1, true ) then
            item.scu = string.gsub( item.scu, ',', '.' )
        end

        if type( item.scu ) ~= 'number' then
            local success, scu = pcall( tonumber, item.scu, 10 )

            if success then
                item.scu = scu
            end
        end

        -- We need to use raw value from SMW to show scu in different units (SCU, K µSCU)
        -- So we need to format the number manually
        if item.type == translate( 'CargoGrid' ) then
            table.insert( subtitle,
                common.formatNum( item.scu ) .. ' SCU' or 'N/A'
            )
            if item.length and item.width and item.height then
                table.insert( subtitle,
                    string.format(
                        'L %s x W %s x H %s',
                        item.length,
                        item.width,
                        item.height
                    )
                )
            end
        elseif item.type == translate( 'PersonalStorage' ) then
            table.insert( subtitle,
                common.formatNum( item.scu * 1000 ) .. 'K µSCU' or 'N/A'
            )
        end
    end

    -- Components that don't have a wiki page currently
    -- Magazine Capacity
    if item.magazine_capacity ~= nil then
        if type( item.magazine_capacity ) == 'table' then
            table.insert( subtitle,
                string.format(
                    '%s/∞ %s',
                    item.magazine_capacity[ 1 ],
                    translate( 'Ammunition' )
                )
            )
        else
            table.insert( subtitle,
                string.format(
                    '%s/%s %s',
                    item.magazine_capacity,
                    item.magazine_capacity,
                    translate( 'Ammunition' )
                )
            )
        end
    end

    -- Parts
    if item.hp ~= nil then
        table.insert( subtitle,
            item.hp
        )
    end

    -- Fuel tanks
    if item.fuel_capacity ~= nil then
        table.insert( subtitle,
            item.fuel_capacity
        )
    end

    -- Fuel intake
    if item.fuel_intake_rate ~= nil then
        table.insert( subtitle,
            item.fuel_intake_rate
        )
    end

    -- Self destruct
    if item.damage ~= nil and item.damage_radius ~= nil then
        table.insert( subtitle,
            string.format(
                '%s · %s',
                item.damage,
                item.damage_radius
            )
        )
    end

    -- Thrusters
    if item.thrust_capacity ~= nil then
        table.insert( subtitle,
            item.thrust_capacity
        )
    end

    -- Weapon ports
    if item.type == translate( 'WeaponPort' ) then
        table.insert( subtitle,
            string.format(
                '%s (S%s – S%s)',
                translate( 'Weapon' ),
                item.min_size or 0,
                item.max_size or 0
            )
        )
    end

    -- Items with Grade and/or Class
    if item.item_grade ~= nil or item.item_class ~= nil then
        local grade_class = ''

        -- TODO can't use lang suffix for subquery properties
        if type( item.item_class ) == 'table' then
            local parts = mw.text.split( item.item_class[ 1 ], ' (', true )
            if #parts == 2 then
                grade_class = parts[ 1 ]
                item.item_class = parts[ 1 ]
            else
                grade_class = grade_class[ 1 ]
                item.item_class = item.item_class[ 1 ]
            end
        end

        if item.item_grade ~= nil and item.item_class ~= nil then
            grade_class = string.format( '%s (%s)', item.item_class, item.item_grade )
        elseif item.item_grade ~= nil then
            grade_class = item.item_grade
        end

        table.insert( subtitle,
            grade_class
        )
    end

    -- Tier 2
    -- Info that might affect gameplay but not as important
    if next( subtitle ) == nil then
        -- Position
        if item.position ~= nil then
            if type( item.position ) ~= 'table' then
                item.position = { item.position }
            end
    
            local converted = {}
            for _, position in ipairs( item.position ) do
                table.insert( converted, mw.text.trim( mw.getContentLanguage():ucfirst( string.gsub( position, '_', ' ' ) ) ) )
            end

            table.insert( subtitle,
                table.concat( converted, ', ' )
            )
        end
    end

    -- Tier 3
    -- Info that does not affect gameplay
    if next( subtitle ) == nil then
        -- Manufacturer
        if item.manufacturer ~= nil and item.manufacturer ~= 'N/A' then
            table.insert( subtitle,
                string.format( '[[%s]]', item.manufacturer )
            )
        end
    end

    -- Return if there are no information at all
    if next( subtitle ) == nil then
        return ''
    end

    return table.concat( subtitle, ' · ' )
end


--- Generate the output
---
--- @param groupedData table Grouped SMW data
--- @return table
function methodtable.makeOutput( self, groupedData )
    local classOutput = {}

    -- An item with potential children
    local function makeEntry( item, depth )
        -- Info if data stems from ship-matrix or game files
        if classOutput.info == nil then
            local text
            if item.from_gamedata == true then
                text = translate( 'msg_from_gamedata' )
            else
                text = translate( 'msg_from_shipmatrix' )
            end

            classOutput.info = hatnote( text, { icon = 'WikimediaUI-Robot.svg' } )
        end

        depth = depth or 1

        local row = mw.html.create( 'div' )
            :addClass( 'template-component' )
            :addClass( string.format( 'template-component--level-%d', depth ) )
               :tag( 'div' )
                  :addClass( 'template-component__connectors' )
                      :tag( 'div' ):addClass( 'template-component__connectorX' ):done()
                      :tag( 'div' ):addClass( 'template-component__connectorY' ):done()
              :done()

        local size = 'N/A'
        local prefix = ''

        -- If Ship-Matrix components are not saved to SMW, always output the 'S' prefix
        if item.from_gamedata == nil then
            prefix = 'S'
        else
            if item.from_gamedata == true or
               item.from_gamedata == 1 or
               item.from_gamedata == '1' or -- For uninitialized attributes
               item.class == translate( 'Weapons' )
            then
                prefix = 'S'
            end
        end

        if item.item_size ~= nil then
            size = string.format( '%s%s', prefix, item.item_size )
        else
            size = string.format( '%s%s', prefix, item.max_size )
        end

        local nodeSizeCount = mw.html.create( 'div' )
            :addClass('template-component__port')
                :tag( 'div' )
                    :addClass( 'template-component__count' )
                    :wikitext( string.format( '%dx', item.count ) )
                :done()

        nodeSizeCount
            :tag( 'div' )
                :addClass( 'template-component__size' )
                    :wikitext( size )
                :done()

        nodeSizeCount = nodeSizeCount:allDone()

        local name = item.sub_type or item.type
        if item.name ~= nil then
            if config.name_fixes[ item.name ] ~= nil then
                name = string.format( '[[%s|%s]]', config.name_fixes[ item.name ], item.name )
            else
                name = string.format( '[[%s]]', item.name )
            end

            if item.class_name and item.name == item.sub_type then
                name = string.format( '%s<span class="template-component__title-subtext">%s</span>', name, item.class_name )
            end
        end

        local nodeItem = mw.html.create( 'div' )
               :addClass( 'template-component__item' )
                    :tag( 'div' )
                    :addClass( 'template-component__title' )
                    :wikitext( name )
                    :done()
        
        local subtitle = self:makeSubtitle( item )
        if subtitle ~= '' then
            nodeItem:tag( 'div' )
                :addClass( 'template-component__subtitle' )
                :wikitext( subtitle )
        end

        row:tag( 'div' )
           :addClass( 'template-component__card' )
           :node( nodeSizeCount )
           :node( nodeItem )
       :done()

        row = tostring( row )

        if type( item.children ) == 'table' then
            depth = depth + 1
            for _, child in ipairs( item.children ) do
                row = row .. makeEntry( child, depth )
            end
        end

        return row
    end


    -- Items of a given class e.g. avionics
    local function makeSection( types )
        local out = ''

        for classType, items in common.spairs( types ) do
            local label = classType

            -- Label override
            -- Note: This must be manually changed on the data.json page
            if data.section_label_fixes[ classType ] ~= nil then
                label = data.section_label_fixes[ classType ]
            end

            local icon = ''
            if self.iconMap[ classType ] ~= nil then
                icon = string.format( '[[%s|20px|link=]]', self.iconMap[ classType ] )
            end

            local section = mw.html.create( 'div' )
                  :addClass( 'template-components__section')
                      :tag( 'div' )
                          :addClass( 'template-components__label' )
                          :wikitext( string.format(
                              '%s %s',
                              icon,
                              classType
                          ) )
                      :done()
                      :tag( 'div' ):addClass( 'template-components__group' )

            local str = ''

            for _, item in ipairs( items ) do
                if not item.isChild then
                    local subGroup = mw.html.create( 'div' )
                        :addClass( 'template-components__subgroup' )
                            :node( makeEntry( item ) )
                        :allDone()
                    str = str .. tostring( subGroup )
                end
            end

            out = out .. tostring( section:node( str ):allDone() )
        end

        return out
    end

    for class, types in common.spairs( groupedData ) do
        classOutput[ class ] = makeSection( types )
    end

    --mw.logObject( classOutput, '📐 [VehicleHardpoint] makeOutput' )

    return classOutput
end


--- Generates tabber output
function methodtable.out( self )
    local smwData = self:querySmwStore( self.page )

    if smwData == nil then
        return hatnote( TNT.format( 'Module:VehicleHardpoint/i18n.json', 'msg_no_data', self.page ), { icon = 'WikimediaUI-Error.svg' } )
    end

    smwData = self:createDataStructure( smwData )
    smwData = self:group( smwData )

    local output = self:makeOutput( smwData )

    local tabberData = {}

    for i, grouping in ipairs( data.class_groupings ) do
        local key = grouping[ 1 ]
        local groups = grouping[ 2 ]

        local groupContent = ''
        local label = {}

        for _, group in ipairs( groups ) do
            groupContent = groupContent .. ( output[ translate( group ) ] or '' )
            table.insert( label, translate( group ) )
        end

        if #groupContent == 0 then
            groupContent = translate( 'empty_' .. key )
        end

        tabberData[ 'label' .. i ] = table.concat( label, ' & ' )
        tabberData[ 'content' .. i ] = groupContent
    end

    return mw.getCurrentFrame():extensionTag{
        name = 'templatestyles', args = { src = config.template_styles_page }
    } .. require( 'Module:Tabber' ).renderTabber( tabberData )
end


--- Generates debug output
function methodtable.makeDebugOutput( self )
    local debug = require( 'Module:Common/Debug' )
    self.smwData = nil
    local smwData = self:querySmwStore( self.page )
    local struct = self:createDataStructure( smwData or {} )
    local group = self:group( struct )

    return debug.collapsedDebugSections({
        {
            title = 'SMW Query',
            content = debug.convertSmwQueryObject( makeSmwQueryObject( self.page ) ),
        },
        {
            title = 'SMW Data',
            content = smwData,
            tag = 'pre',
        },
        {
            title = 'Datastructure',
            content = struct,
            tag = 'pre',
        },
        {
            title = 'Grouped',
            content = group,
            tag = 'pre',
        },
        {
            title = 'Output',
            content = self:makeOutput( group ),
            tag = 'pre',
        },
    })
end


--- Manually fix some (sub_)types by checking the hardpoint name
---
--- @param hardpoint table Entry from the api
--- @param fixes table
--- @return table The fixed entry
function VehicleHardpoint.fixTypes( hardpoint, fixes )
    --- Assign key value pairs on a hardpoint
    --- @param kv table Table containing 'key=value' string pairs
    local function assign( kv )
        for _, assignment in pairs( kv ) do
            local parts = mw.text.split( assignment, '=', true )

            if #parts == 2 then
                if string.find( parts[ 2 ], '+', 1, true ) then
                    local valueParts = mw.text.split( parts[ 2 ], '+', true )

                    parts[ 2 ] = valueParts[ 1 ] .. ( hardpoint[ valueParts[ 2 ] ] or '' )
                end

                hardpoint[ parts[ 1 ] ] = parts[ 2 ]
            end
        end
    end

    --- Set fixes on a hardpoint if tests evaluate to true
    --- @param tests table
    local function fixHardpoint( tests )
        for _, test in ipairs( tests ) do
            if VehicleHardpoint.evalRule( test[ 'if' ], hardpoint ) then
                local kv = test[ 'then' ]
                if type( kv ) ~= 'table' then
                    kv = { kv }
                end

                assign( kv )
            end
        end
    end

    for _, fix in ipairs( fixes ) do
        if type( fix.type ) == 'table' then
            for _, v in pairs( fix.type ) do
                if v == hardpoint.type then
                    fixHardpoint( fix.modification )
                    break
                end
            end
        elseif type( fix.type ) == 'string' and fix.type == hardpoint.type then
            fixHardpoint( fix.modification )
            break
        end
    end

    -- Manual mapping defined in Module:VehicleHardpoint/Data
    if type( hardpoint.item ) == 'table' and hardpoint.item ~= nil then
        -- If this is a noise launcher, but the class name says decoy, change Noise to Decoy
        if string.find( hardpoint.item.name, 'Noise', 1, true ) and string.find( hardpoint.class_name, 'decoy', 1, true ) then
            hardpoint.item.name = string.gsub( hardpoint.item.name, ' Noise ', ' Decoy ' )
        end

        for _, mapping in pairs( data.hardpoint_type_fixes ) do
            for _, matcher in pairs( data.matches[ mapping ][ 'matches' ] ) do
                if string.match( hardpoint.name, matcher ) ~= nil then
                    hardpoint.type = mapping
                    return hardpoint
                end
            end
        end
    end

    return hardpoint
end


--- New Instance
---
--- @return table VehicleHardpoint
function VehicleHardpoint.new( self, page )
    local instance = {
        page = page or nil,
        iconMap = {}
    }

    setmetatable( instance, metatable )

    return instance
end


--- Parser call for generating the table
function VehicleHardpoint.outputTable( frame )
    local args = require( 'Module:Arguments' ).getArgs( frame )
    local page = args[ 1 ] or args[ 'Name' ] or mw.title.getCurrentTitle().text

    local instance = VehicleHardpoint:new( page )
    local out = instance:out()

    local debugOutput = ''
    if args['debug'] ~= nil then
        debugOutput = instance:makeDebugOutput()
    end

    return out .. debugOutput
end


--- Set the hardpoints of the 300i as subobjects to the current page
function VehicleHardpoint.test( frame )
    frame = frame or { args = {} }
    local page = frame.args['Name'] or 'dc39ca6b-1d76-4db5-9346-356f49954978'
    local json = mw.text.jsonDecode( mw.ext.Apiunto.get_raw( 'v2/vehicles/' .. page, {
        include = {
            'hardpoints',
            'parts'
        },
    } ) )

    local hardpoint = VehicleHardpoint:new( page )
    hardpoint:setHardPointObjects( json.data.hardpoints )
    hardpoint:setParts( json.data.parts )
end


--- Evaluates rules from 'data.fixes'
---
--- @param rules table A rules object from data.fixes
--- @param hardpoint table The hardpoint to evaluate
--- @param returnInvalid boolean|nil If invalid rules should be returned beneath the result
--- @return boolean
--- @return table?
function VehicleHardpoint.evalRule( rules, hardpoint, returnInvalid )
    returnInvalid = returnInvalid or false
    local stepVal = {}
    local combination = {}
    local invalidRules = {}

    local function invalidRule( rule, index )
        table.insert( invalidRules, string.format( 'Invalid Rule found, skipping: <%s (Element %d)>', rule, index ) )
    end

    for index, rule in ipairs( rules ) do
        if type( rule ) == 'string' then
            -- mw.logObject( string.format( 'Evaluating rule %s', rule ), '📐 [VehicleHardpoint] evalRule' )

            if string.find( rule, ':', 1, true ) ~= nil then
                local parts = mw.text.split( rule, ':', true )

                -- Simple check if a key equals a value
                if #parts == 2 then
                    local result = hardpoint[ parts[ 1 ] ] == parts[ 2 ]
                    -- mw.logObject( string.format( 'Rule <%s == %s>, equates to %s', hardpoint[ parts[ 1 ] ], parts[ 2 ], tostring( result ) ), '📐 [VehicleHardpoint] evalRule' )

                    table.insert( stepVal, result )
                    -- String Match
                elseif #parts == 3 then
                    local key = parts[ 1 ]
                    local fn = parts[ 2 ]

                    -- Remove key and 'match' in order to combine the last parts again
                    table.remove( parts, 1 )
                    table.remove( parts, 1 )

                    local matcher = string.lower( table.concat( parts, ':' ) )

                    local result = string[ fn ]( string.lower( hardpoint[ key ] ), matcher ) ~= nil
                    -- mw.logObject( string.format( 'Rule <%s matches %s>, equates to %s', hardpoint[ key ], matcher, tostring( result ) ), '📐 [VehicleHardpoint] evalRule' )

                    table.insert( stepVal, result )
                else
                    invalidRule( rule, index )
                end
                -- A combination rule
            elseif rule == 'and' or rule == 'or' then
                table.insert( combination, rule )
            end
            -- A sub rule
        elseif type( rule ) == 'table' then
            local matches, invalid = VehicleHardpoint.evalRule( rule, hardpoint )

            table.insert( stepVal, matches )

            for _, v in ipairs( invalid or {} ) do
                table.insert( invalidRules, v )
            end
        else
            -- mw.logObject( 'Is invalid ' .. rule, '📐 [VehicleHardpoint] evalRule' )
            invalidRule( rule, index )
        end
    end

    local ruleMatches = false
    for index, matched in ipairs( stepVal ) do
        if index == 1 then
            ruleMatches = matched
        else
            -- mw.logObject( 'test is ' .. combination[ index - 1 ], '📐 [VehicleHardpoint] evalRule' )
            if combination[ index - 1 ] == 'and' then
                ruleMatches = ruleMatches and matched
            else
                ruleMatches = ruleMatches or matched
            end
        end
    end

    -- mw.logObject( 'Final rule result is ' .. tostring( ruleMatches ), '📐 [VehicleHardpoint] evalRule' )

    if returnInvalid then
        return ruleMatches, invalidRules
    else
        return ruleMatches
    end
end


return VehicleHardpoint