Module:GameData: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Remove Upgrades skip in find_value so abilities gaining charges via upgrades are included (with help from vergir-bot LLM)
Vergir (talk | contribs)
Support "Key:Value" and dot-path prop targets in entity matching (with help from vergir-bot LLM)
 
(15 intermediate revisions by 5 users not shown)
Line 1: Line 1:
-- Module:GameData
 
-- Provides generic access to game data files, with consistent active-entity filtering.
-- Provides generic access to game data files
-- Used by ItemTables, and intended for future AbilityTables, HeroTables, etc.


local p = {}
local p = {}
Line 11: Line 10:
     ABILITIES = "Data:AbilityData.json",
     ABILITIES = "Data:AbilityData.json",
     HEROES    = "Data:HeroData.json",
     HEROES    = "Data:HeroData.json",
    CONVARS  = "Data:Convars.json",
}
}
-- Street Brawl ships a partial overlay of ability properties rather than a full
-- dataset, so it is deliberately kept out of p.Dataset: its top-level keys are
-- sections ("ability-changes", "item-buckets"), not entity records, and it is
-- not usable with get_entities. Only get_prop consults it.
local STREET_BRAWL = "Data:StreetBrawlData.json"


-- Returns true if a record represents an active, usable entity.
-- Returns true if a record represents an active, usable entity.
Line 18: Line 24:
--  - Name must not be nil
--  - Name must not be nil
--  - IsSelectable, if present, must not be false
--  - IsSelectable, if present, must not be false
-- Specifically for hero abilities, some of them are split into two entities, eg. Vexing Bolt has a sub-ability: Redirect Bolt.
-- The secondary sub-abilities most often have nil name and get filtered out by this function
local function is_active(record)
local function is_active(record)
     if record["IsDisabled"] == true then return false end
     if record["IsDisabled"] == true then return false end
Line 25: Line 34:
end
end


-- Recursively searches a record for a given key at any nesting level,
-- Recursively searches a record for a match against prop, which may be:
-- including inside the Upgrades array. This means abilities or items that
--   - a key name: matches if the key exists with a non-zero/non-empty value
-- only gain a property via an upgrade tier will still be matched.
--  - a plain value: matches if the string appears as a value anywhere in the record
-- @param  record  table   the data record to search
-- Returns true if a match is found, false otherwise.
-- @param  key    string the internal property name to look for
-- This handles plain single-word targets; the "Key:Value" and dotted-path forms
-- @return          any    the value if found, nil otherwise
-- are handled by record_matches_path below.
local function find_value(record, key)
local function record_matches_prop(record, prop)
     for k, v in pairs(record) do
     for k, v in pairs(record) do
         if k == key then
        -- Key match: property name exists with a meaningful value
             return v
         if k == prop then
         elseif type(v) == "table" then
             if type(v) == "table" then
             local found = find_value(v, key)
                if v["Value"] ~= nil then
             if found ~= nil then return found end
                    v = v["Value"]
                else
                    for _ in pairs(v) do return true end
                    return false
                end
            end
            if v ~= nil and v ~= 0 and v ~= "" and v ~= "0" and v ~= "0m" then
                return true
            end
        end
        if type(v) == "table" and k ~= "DisabledStateMask" then
            if record_matches_prop(v, prop) then return true end
         elseif type(v) == "string" then
            -- Value match: prop string appears as a value (e.g. Scale.Type = "melee")
            if v == prop then return true end
        end
    end
    return false
end
 
-- True if a value counts as "present": non-zero, non-empty, and not a
-- placeholder distance. A table is judged by its .Value field when it has one,
-- otherwise by whether it holds anything at all.
local function value_is_meaningful(v)
    if type(v) == "table" then
        if v["Value"] ~= nil then
            v = v["Value"]
        else
            for _ in pairs(v) do return true end
             return false
        end
    end
    return v ~= nil and v ~= 0 and v ~= "" and v ~= "0" and v ~= "0m"
end
 
-- True if a value equals `expected`, which must already be lower-cased. A table
-- is unwrapped to its .Value field, so "Damage:90" matches
-- { Value = 90, Scale = {...} }. A table with no .Value never matches, which is
-- what keeps "Damage:spirit" from matching on a nested Damage.Scale.Type.
local function value_equals(v, expected)
    if type(v) == "table" then v = v["Value"] end
    if v == nil or type(v) == "table" then return false end
    return mw.ustring.lower(tostring(v)) == expected
end
 
-- Follows path segments from `index` onward, starting at `node`. Segments after
-- the first are followed strictly: no searching, one step per segment. Numeric
-- segments fall back to array indices ("Upgrades.1.Damage"), and array elements
-- are stepped through transparently, so "Upgrades.Damage" reaches the Damage
-- field of any upgrade entry.
local function follow_path(node, segments, index, value)
    if index > #segments then
        if value ~= nil then return value_equals(node, value) end
        return value_is_meaningful(node)
    end
    if type(node) ~= "table" then return false end
 
    local segment = segments[index]
    local next_node = node[segment]
    if next_node == nil then
        local num = tonumber(segment)
        if num then next_node = node[num] end
    end
    if next_node ~= nil and follow_path(next_node, segments, index + 1, value) then
        return true
    end
 
    for _, element in ipairs(node) do
        if type(element) == "table" and follow_path(element, segments, index, value) then
            return true
        end
    end
    return false
end
 
-- Recursively locates the first path segment at any depth, then follows the rest
-- from there. Searching for the first segment is what lets a path reach into
-- sub-ability records the same way plain key targets already do.
local function record_matches_path(record, segments, value)
    for k, v in pairs(record) do
        if k == segments[1] and follow_path(v, segments, 2, value) then
            return true
        end
        if type(v) == "table" and k ~= "DisabledStateMask" then
             if record_matches_path(v, segments, value) then return true end
        end
    end
    return false
end
 
-- Splits a target into path segments plus an optional expected value. The first
-- colon separates the two halves, so dots inside the value are safe
-- ("Radius:3.5"). Returns nil for a plain single-word target with no colon,
-- which keeps its existing key-name-or-string-value meaning.
local function parse_target(prop)
    if type(prop) ~= "string" then return nil end
 
    local keys, value = prop, nil
    local colon = prop:find(":", 1, true)
    if colon then
        keys  = mw.text.trim(prop:sub(1, colon - 1))
        value = mw.text.trim(prop:sub(colon + 1))
        if keys == "" or value == "" then return nil end
        value = mw.ustring.lower(value)
    end
 
    local segments = {}
    for segment in keys:gmatch("[^%.]+") do table.insert(segments, segment) end
    if #segments == 0 then return nil end
    if #segments == 1 and value == nil then return nil end
 
    return segments, value
end
 
-- Returns true if a record matches at least one of the given properties. A
-- property may take any of four forms:
--  "Key"          the key exists anywhere in the record with a meaningful
--                  value, or the string appears as a value anywhere
--  "Key:Value"    the key exists anywhere and its own value equals Value,
--                  compared case-insensitively
--  "A.B.C"        that exact nested chain exists with a meaningful value
--  "A.B.C:Value"  that chain exists and ends in Value
-- Only the first segment of a path is searched for; the rest are followed one
-- step at a time, so a value nested deeper under the key does not match. A
-- structured target that resolves to nothing falls back to plain matching, so a
-- value that happens to contain a dot ("38.1 50.8") still works as a target.
-- Public so other modules (e.g. Module:AbilityTable membership) can reuse it.
function p.entity_matches(record, properties)
    for _, prop in ipairs(properties) do
        local segments, value = parse_target(prop)
        if segments and record_matches_path(record, segments, value) then
            return true
        elseif record_matches_prop(record, prop) then
            return true
         end
         end
     end
     end
     return nil
     return false
end
end


-- Returns all active entities from a dataset that have a non-nil, non-zero,
-- Returns all active entities from a dataset that match at least one of the
-- non-empty value for at least one of the given internal property keys.
-- given properties. A property may be a key name (the property exists with a
-- non-zero value), a plain string value anywhere in the record (e.g. "melee"
-- matching Scale.Type = "melee"), a "Key:Value" pair, or a dotted path with an
-- optional value ("Scale.Type:cooldown"). See p.entity_matches for details.
-- @param  dataset    string  one of the GameData.Dataset constants
-- @param  dataset    string  one of the GameData.Dataset constants
-- @param  properties  table  array of internal property name strings
-- @param  properties  table  array of property strings
-- @return              table  array of matching entity records
-- @return              table  array of matching entity records
function p.get_entities(dataset, properties)
function p.get_entities(dataset, properties)
Line 53: Line 198:


     for _, record in pairs(data) do
     for _, record in pairs(data) do
         if is_active(record) then
         if is_active(record) and p.entity_matches(record, properties) then
             for _, prop in ipairs(properties) do
             table.insert(results, record)
                local value = find_value(record, prop)
        end
                if value ~= nil and value ~= 0 and value ~= "" and value ~= "0" and value ~= "0m" then
    end
                    table.insert(results, record)
 
                    break
    return results
                 end
end
 
--------------------------------------------------------------------------------
-- Unified property lookup: get_prop
-- Uses ResourceLookup to route directly to the correct dataset.
-- Returns raw values for use in templates and expressions.
--------------------------------------------------------------------------------
 
-- Find an entity by display name or internal key.
-- Uses ResourceLookup type field to go directly to the right dataset.
-- Returns (entity_record, type_string, internal_key) or (nil, nil, nil).
local function find_entity(identifier)
    local resource = mw.loadJsonData("Data:ResourceLookup.json")[identifier:lower()]
    if resource then
        local data
        if resource.type == "ability" then
            data = mw.loadJsonData(p.Dataset.ABILITIES)
        elseif resource.type == "hero" then
            data = mw.loadJsonData(p.Dataset.HEROES)
        elseif resource.type == "item" then
            data = mw.loadJsonData(p.Dataset.ITEMS)
        end
        if data and data[resource.key] then
            return data[resource.key], resource.type, resource.key
        end
    end
 
    -- Fallback: try as direct internal key
    local datasets = {
        { p.Dataset.ABILITIES, "ability" },
        { p.Dataset.HEROES,    "hero" },
        { p.Dataset.ITEMS,    "item" },
    }
    for _, ds in ipairs(datasets) do
        local data = mw.loadJsonData(ds[1])
        if data[identifier] then return data[identifier], ds[2], identifier end
    end
 
    return nil, nil, nil
end
 
-- Traverse a table using dot notation, unwrap tables with a .Value field.
-- Supports numeric indices for arrays (e.g. "Upgrades.1.WeaponDamageBonus").
local function resolve_prop(tbl, prop)
    if not prop or prop == "" then return nil end
    local element = tbl
    for segment in string.gmatch(prop, "[^%.]+") do
        if type(element) ~= "table" then return nil end
        local next = element[segment]
        if next == nil then
            local num = tonumber(segment)
            if num then next = element[num] end
        end
        element = next
        if element == nil then return nil end
    end
    if type(element) == "table" then
        return element.Value or ""
    end
    return element
end
 
-- Looks up a console variable in Data:Convars.json. A convar value is stored
-- either as a plain scalar (e.g. "adsp_alley_min": 122) or as a table holding
-- the value plus a description (e.g. { value = 75, description = "..." }), in
-- which case only the value is returned. The exact key is tried first, then a
-- lower-case form. Returns (value, found).
local function lookup_convar(key)
    local data = mw.loadJsonData(p.Dataset.CONVARS)
    local entry = data[key]
    if entry == nil then
        entry = data[mw.ustring.lower(key)]
    end
    if entry == nil then
        return nil, false
    end
    if type(entry) == "table" then
        return entry.value, true
    end
    return entry, true
end
 
-- {{#invoke:GameData|get_prop|...}}
-- Two modes:
--  Entity:  {{#invoke:GameData|get_prop|ENTITY_NAME|PROPERTY|VARIANT}}
--            Finds entity via ResourceLookup, returns the raw value (no
--            formatting). Supports dot notation for nested properties
--            (e.g. "Scale.Value"); tables with a .Value field are unwrapped.
--            The optional VARIANT selects a game mode variant. "Street Brawl"
--            (case and spacing insensitive) returns the value changed for
--            Street Brawl, falling back to the base value when Street Brawl
--            does not change that property.
--  Convar:  {{#invoke:GameData|get_prop|Convar|CONVAR_NAME}}
--            Looks up the convar in Data:Convars.json and returns its value,
--            unwrapping the { value, description } form when present.
function p.get_prop(frame)
    local arg1 = frame.args[1]
    local arg2 = frame.args[2]
    local arg3 = frame.args[3]
    if not arg1 then return "" end
 
    -- Convar mode: first argument is the literal keyword "Convar".
    if mw.ustring.lower(arg1) == "convar" then
        if not arg2 or arg2 == "" then return "" end
        local value, found = lookup_convar(arg2)
        if not found then
            return '<span style="color:red;">Convar not found: ' .. arg2 .. '</span>'
        end
        if value == nil then return "" end
        return value
    end
 
    -- Entity mode: search abilities, heroes, and items.
    local name = arg1
    local prop = arg2
    if not prop then return "" end
 
    local entity, etype, ekey = find_entity(name)
    if not entity then
        return '<span style="color:red;">Entity not found: ' .. name .. '</span>'
    end
 
    -- Street Brawl changes a handful of ability properties. The overlay lists
    -- only what changed, so a miss here just means "unchanged" and the base
    -- record answers instead. That includes the empty string resolve_prop
    -- returns for a table with no Value field: the overlay records are partial,
    -- so an overridden table often holds nothing but a nested Scale.
    if arg3 and arg3 ~= "" then
        local mode = mw.ustring.lower(mw.text.trim(arg3))
        mode = mode:gsub("%s+", "")
        if mode ~= "streetbrawl" then
            return '<span style="color:red;">Unknown variant: ' .. arg3 .. '</span>'
        end
        if etype == "ability" then
            local changed = mw.loadJsonData(STREET_BRAWL)["ability-changes"][ekey]
            if changed then
                local value = resolve_prop(changed, prop)
                 if value ~= nil and value ~= "" then return value end
             end
             end
         end
         end
     end
     end


     return results
     local result = resolve_prop(entity, prop)
    if result ~= nil then return result end
 
    return '<span style="color:red;">Prop not found: ' .. name .. '/' .. prop .. '</span>'
end
end


return p
return p

Latest revision as of 00:05, 10 August 2026

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

-- Provides generic access to game data files

local p = {}

-- Dataset descriptors. Values are the data file paths passed to mw.loadJsonData.
-- Callers should use these constants rather than raw strings.
p.Dataset = {
    ITEMS     = "Data:ItemData.json",
    ABILITIES = "Data:AbilityData.json",
    HEROES    = "Data:HeroData.json",
    CONVARS   = "Data:Convars.json",
}

-- Street Brawl ships a partial overlay of ability properties rather than a full
-- dataset, so it is deliberately kept out of p.Dataset: its top-level keys are
-- sections ("ability-changes", "item-buckets"), not entity records, and it is
-- not usable with get_entities. Only get_prop consults it.
local STREET_BRAWL = "Data:StreetBrawlData.json"

-- Returns true if a record represents an active, usable entity.
-- Applies consistently across all datasets:
--   - IsDisabled must not be true
--   - Name must not be nil
--   - IsSelectable, if present, must not be false

-- Specifically for hero abilities, some of them are split into two entities, eg. Vexing Bolt has a sub-ability: Redirect Bolt.
-- The secondary sub-abilities most often have nil name and get filtered out by this function
local function is_active(record)
    if record["IsDisabled"] == true then return false end
    if record["Name"] == nil then return false end
    if record["IsSelectable"] ~= nil and record["IsSelectable"] == false then return false end
    return true
end

-- Recursively searches a record for a match against prop, which may be:
--   - a key name: matches if the key exists with a non-zero/non-empty value
--   - a plain value: matches if the string appears as a value anywhere in the record
-- Returns true if a match is found, false otherwise.
-- This handles plain single-word targets; the "Key:Value" and dotted-path forms
-- are handled by record_matches_path below.
local function record_matches_prop(record, prop)
    for k, v in pairs(record) do
        -- Key match: property name exists with a meaningful value
        if k == prop then
            if type(v) == "table" then
                if v["Value"] ~= nil then
                    v = v["Value"]
                else
                    for _ in pairs(v) do return true end
                    return false
                end
            end
            if v ~= nil and v ~= 0 and v ~= "" and v ~= "0" and v ~= "0m" then
                return true
            end
        end
        if type(v) == "table" and k ~= "DisabledStateMask" then
            if record_matches_prop(v, prop) then return true end
        elseif type(v) == "string" then
            -- Value match: prop string appears as a value (e.g. Scale.Type = "melee")
            if v == prop then return true end
        end
    end
    return false
end

-- True if a value counts as "present": non-zero, non-empty, and not a
-- placeholder distance. A table is judged by its .Value field when it has one,
-- otherwise by whether it holds anything at all.
local function value_is_meaningful(v)
    if type(v) == "table" then
        if v["Value"] ~= nil then
            v = v["Value"]
        else
            for _ in pairs(v) do return true end
            return false
        end
    end
    return v ~= nil and v ~= 0 and v ~= "" and v ~= "0" and v ~= "0m"
end

-- True if a value equals `expected`, which must already be lower-cased. A table
-- is unwrapped to its .Value field, so "Damage:90" matches
-- { Value = 90, Scale = {...} }. A table with no .Value never matches, which is
-- what keeps "Damage:spirit" from matching on a nested Damage.Scale.Type.
local function value_equals(v, expected)
    if type(v) == "table" then v = v["Value"] end
    if v == nil or type(v) == "table" then return false end
    return mw.ustring.lower(tostring(v)) == expected
end

-- Follows path segments from `index` onward, starting at `node`. Segments after
-- the first are followed strictly: no searching, one step per segment. Numeric
-- segments fall back to array indices ("Upgrades.1.Damage"), and array elements
-- are stepped through transparently, so "Upgrades.Damage" reaches the Damage
-- field of any upgrade entry.
local function follow_path(node, segments, index, value)
    if index > #segments then
        if value ~= nil then return value_equals(node, value) end
        return value_is_meaningful(node)
    end
    if type(node) ~= "table" then return false end

    local segment = segments[index]
    local next_node = node[segment]
    if next_node == nil then
        local num = tonumber(segment)
        if num then next_node = node[num] end
    end
    if next_node ~= nil and follow_path(next_node, segments, index + 1, value) then
        return true
    end

    for _, element in ipairs(node) do
        if type(element) == "table" and follow_path(element, segments, index, value) then
            return true
        end
    end
    return false
end

-- Recursively locates the first path segment at any depth, then follows the rest
-- from there. Searching for the first segment is what lets a path reach into
-- sub-ability records the same way plain key targets already do.
local function record_matches_path(record, segments, value)
    for k, v in pairs(record) do
        if k == segments[1] and follow_path(v, segments, 2, value) then
            return true
        end
        if type(v) == "table" and k ~= "DisabledStateMask" then
            if record_matches_path(v, segments, value) then return true end
        end
    end
    return false
end

-- Splits a target into path segments plus an optional expected value. The first
-- colon separates the two halves, so dots inside the value are safe
-- ("Radius:3.5"). Returns nil for a plain single-word target with no colon,
-- which keeps its existing key-name-or-string-value meaning.
local function parse_target(prop)
    if type(prop) ~= "string" then return nil end

    local keys, value = prop, nil
    local colon = prop:find(":", 1, true)
    if colon then
        keys  = mw.text.trim(prop:sub(1, colon - 1))
        value = mw.text.trim(prop:sub(colon + 1))
        if keys == "" or value == "" then return nil end
        value = mw.ustring.lower(value)
    end

    local segments = {}
    for segment in keys:gmatch("[^%.]+") do table.insert(segments, segment) end
    if #segments == 0 then return nil end
    if #segments == 1 and value == nil then return nil end

    return segments, value
end

-- Returns true if a record matches at least one of the given properties. A
-- property may take any of four forms:
--   "Key"          the key exists anywhere in the record with a meaningful
--                  value, or the string appears as a value anywhere
--   "Key:Value"    the key exists anywhere and its own value equals Value,
--                  compared case-insensitively
--   "A.B.C"        that exact nested chain exists with a meaningful value
--   "A.B.C:Value"  that chain exists and ends in Value
-- Only the first segment of a path is searched for; the rest are followed one
-- step at a time, so a value nested deeper under the key does not match. A
-- structured target that resolves to nothing falls back to plain matching, so a
-- value that happens to contain a dot ("38.1 50.8") still works as a target.
-- Public so other modules (e.g. Module:AbilityTable membership) can reuse it.
function p.entity_matches(record, properties)
    for _, prop in ipairs(properties) do
        local segments, value = parse_target(prop)
        if segments and record_matches_path(record, segments, value) then
            return true
        elseif record_matches_prop(record, prop) then
            return true
        end
    end
    return false
end

-- Returns all active entities from a dataset that match at least one of the
-- given properties. A property may be a key name (the property exists with a
-- non-zero value), a plain string value anywhere in the record (e.g. "melee"
-- matching Scale.Type = "melee"), a "Key:Value" pair, or a dotted path with an
-- optional value ("Scale.Type:cooldown"). See p.entity_matches for details.
-- @param   dataset     string  one of the GameData.Dataset constants
-- @param   properties  table   array of property strings
-- @return              table   array of matching entity records
function p.get_entities(dataset, properties)
    local data = mw.loadJsonData(dataset)
    local results = {}

    for _, record in pairs(data) do
        if is_active(record) and p.entity_matches(record, properties) then
            table.insert(results, record)
        end
    end

    return results
end

--------------------------------------------------------------------------------
-- Unified property lookup: get_prop
-- Uses ResourceLookup to route directly to the correct dataset.
-- Returns raw values for use in templates and expressions.
--------------------------------------------------------------------------------

-- Find an entity by display name or internal key.
-- Uses ResourceLookup type field to go directly to the right dataset.
-- Returns (entity_record, type_string, internal_key) or (nil, nil, nil).
local function find_entity(identifier)
    local resource = mw.loadJsonData("Data:ResourceLookup.json")[identifier:lower()]
    if resource then
        local data
        if resource.type == "ability" then
            data = mw.loadJsonData(p.Dataset.ABILITIES)
        elseif resource.type == "hero" then
            data = mw.loadJsonData(p.Dataset.HEROES)
        elseif resource.type == "item" then
            data = mw.loadJsonData(p.Dataset.ITEMS)
        end
        if data and data[resource.key] then
            return data[resource.key], resource.type, resource.key
        end
    end

    -- Fallback: try as direct internal key
    local datasets = {
        { p.Dataset.ABILITIES, "ability" },
        { p.Dataset.HEROES,    "hero" },
        { p.Dataset.ITEMS,     "item" },
    }
    for _, ds in ipairs(datasets) do
        local data = mw.loadJsonData(ds[1])
        if data[identifier] then return data[identifier], ds[2], identifier end
    end

    return nil, nil, nil
end

-- Traverse a table using dot notation, unwrap tables with a .Value field.
-- Supports numeric indices for arrays (e.g. "Upgrades.1.WeaponDamageBonus").
local function resolve_prop(tbl, prop)
    if not prop or prop == "" then return nil end
    local element = tbl
    for segment in string.gmatch(prop, "[^%.]+") do
        if type(element) ~= "table" then return nil end
        local next = element[segment]
        if next == nil then
            local num = tonumber(segment)
            if num then next = element[num] end
        end
        element = next
        if element == nil then return nil end
    end
    if type(element) == "table" then
        return element.Value or ""
    end
    return element
end

-- Looks up a console variable in Data:Convars.json. A convar value is stored
-- either as a plain scalar (e.g. "adsp_alley_min": 122) or as a table holding
-- the value plus a description (e.g. { value = 75, description = "..." }), in
-- which case only the value is returned. The exact key is tried first, then a
-- lower-case form. Returns (value, found).
local function lookup_convar(key)
    local data = mw.loadJsonData(p.Dataset.CONVARS)
    local entry = data[key]
    if entry == nil then
        entry = data[mw.ustring.lower(key)]
    end
    if entry == nil then
        return nil, false
    end
    if type(entry) == "table" then
        return entry.value, true
    end
    return entry, true
end

-- {{#invoke:GameData|get_prop|...}}
-- Two modes:
--   Entity:  {{#invoke:GameData|get_prop|ENTITY_NAME|PROPERTY|VARIANT}}
--            Finds entity via ResourceLookup, returns the raw value (no
--            formatting). Supports dot notation for nested properties
--            (e.g. "Scale.Value"); tables with a .Value field are unwrapped.
--            The optional VARIANT selects a game mode variant. "Street Brawl"
--            (case and spacing insensitive) returns the value changed for
--            Street Brawl, falling back to the base value when Street Brawl
--            does not change that property.
--   Convar:  {{#invoke:GameData|get_prop|Convar|CONVAR_NAME}}
--            Looks up the convar in Data:Convars.json and returns its value,
--            unwrapping the { value, description } form when present.
function p.get_prop(frame)
    local arg1 = frame.args[1]
    local arg2 = frame.args[2]
    local arg3 = frame.args[3]
    if not arg1 then return "" end

    -- Convar mode: first argument is the literal keyword "Convar".
    if mw.ustring.lower(arg1) == "convar" then
        if not arg2 or arg2 == "" then return "" end
        local value, found = lookup_convar(arg2)
        if not found then
            return '<span style="color:red;">Convar not found: ' .. arg2 .. '</span>'
        end
        if value == nil then return "" end
        return value
    end

    -- Entity mode: search abilities, heroes, and items.
    local name = arg1
    local prop = arg2
    if not prop then return "" end

    local entity, etype, ekey = find_entity(name)
    if not entity then
        return '<span style="color:red;">Entity not found: ' .. name .. '</span>'
    end

    -- Street Brawl changes a handful of ability properties. The overlay lists
    -- only what changed, so a miss here just means "unchanged" and the base
    -- record answers instead. That includes the empty string resolve_prop
    -- returns for a table with no Value field: the overlay records are partial,
    -- so an overridden table often holds nothing but a nested Scale.
    if arg3 and arg3 ~= "" then
        local mode = mw.ustring.lower(mw.text.trim(arg3))
        mode = mode:gsub("%s+", "")
        if mode ~= "streetbrawl" then
            return '<span style="color:red;">Unknown variant: ' .. arg3 .. '</span>'
        end
        if etype == "ability" then
            local changed = mw.loadJsonData(STREET_BRAWL)["ability-changes"][ekey]
            if changed then
                local value = resolve_prop(changed, prop)
                if value ~= nil and value ~= "" then return value end
            end
        end
    end

    local result = resolve_prop(entity, prop)
    if result ~= nil then return result end

    return '<span style="color:red;">Prop not found: ' .. name .. '/' .. prop .. '</span>'
end

return p