Module:Clue list

From the RuneScape Wiki, the wiki for all things RuneScape
Jump to navigation Jump to search
Module documentation
This documentation is transcluded from Module:Clue list/doc. [edit] [history] [purge]
This module does not have any documentation. Please consider adding documentation at Module:Clue list/doc. [edit]
Module:Clue list's function list is invoked by Template:Clue list.
Module:Clue list requires Module:Clue solution.
Function list
L 30 — group_key
L 37 — join_or
L 43 — finalize_display
L 55 — group_rows
L 72 — strip_file_prefix
L 77 — get_clue_bucket_data
L 131 — rows_to_parsed
L 239 — build_coordinate_row
L 269 — build_cryptic_row
L 303 — build_compass_section
L 325 — build_compscan_section
L 347 — build_emote_row
L 385 — build_map_row
L 419 — build_anagram_row
L 492 — p.coordinates
L 538 — p.cryptics
L 548 — p.anagrams
L 558 — p.emotes
L 568 — p.maps
L 578 — build_compscan_list
L 589 — p.compass
L 600 — p.scan
L 615 — p.list

local cs = require('Module:Clue solution')
local fmt = cs.fmt
local build_popup_pin = cs.build_popup_pin
local CLUE_TYPE_DATA_FIELDS = cs.CLUE_TYPE_DATA_FIELDS
local ANAGRAM_PREAMBLE = cs.ANAGRAM_PREAMBLE

local categories_to_exclude = {
    "Historical article",
    "Pages using information from game APIs or cache",
    "Removed content"
}

local p = {}

--- Grouping key fields per clue type (which bucket fields identify a unique clue)
local GROUP_BY = {
    coordinate = { 'clue' },
    cryptic    = { 'clue' },
    simple     = { 'clue' },
    anagram    = { 'clue' },
    emote      = { 'clue' },
    map        = { 'clue', 'page_name' },
    compass    = { 'title' },
    scan       = { 'clue', 'group' },
}

local CLUE_TABLE_CLASS = 'wikitable cluetable'

--- Return the first non-nil, non-empty field value from a row, or 'Unknown'.
local function group_key(row, fields)
    for _, field in ipairs(fields) do
        if row[field] and row[field] ~= '' then return row[field] end
    end
    return 'Unknown'
end

local function join_or(parts, fallback, separator)
    separator = separator or '<br>'
    return #parts > 0 and table.concat(parts, separator) or fallback
end

--- Build a display string from accumulated {formatted, title} parts
local function finalize_display(parts, empty)
    if #parts == 0 then return empty end
    if #parts == 1 then return parts[1].formatted end
    local out = {}
    for _, part in ipairs(parts) do
        local val = part.formatted
        if part.title then val = "'''" .. part.title .. ":''' " .. val end
        table.insert(out, val)
    end
    return table.concat(out, '<br>')
end

local function group_rows(rows)
    if not rows or #rows == 0 then return {}, {} end
    local clue_type = rows[1].clue_type
    local groups = {}
    local order = {}
    for _, row in ipairs(rows) do
        local key = group_key(row, GROUP_BY[clue_type])
        if not groups[key] then
            groups[key] = {}
            table.insert(order, key)
        end
        table.insert(groups[key], row)
    end
    return groups, order
end

--- Strip the File: prefix that the bucket stores; fmt functions add it back.
local function strip_file_prefix(s)
    if not s then return nil end
    return (s:gsub('^[Ff]ile:', ''))
end

local function get_clue_bucket_data(frame, default_type)
    local parent = frame:getParent()
    local source = parent and parent.args or frame.args

    local b = bucket('clue')
        .select(
            'page_name',
            'clue_type',
            'difficulty',
            'clue',
            'solution',
            'image',
            'title',
            'location_title',
            'location',
            'travel',
            'requirements',
            'x_coordinate',
            'y_coordinate',
            'map_id',
            'plane',
            'type_data'
        )

    local clue_type = source.type or default_type
    if clue_type then
        b.where({ 'clue_type', clue_type })
    end

    for _, category in ipairs(categories_to_exclude) do
        b.where(bucket.Not("Category:" .. category))
    end

    if source.difficulty then
        b.where({ 'difficulty', source.difficulty })
    end

    if source.orderby then
        b.orderBy(source.orderby)
    end

    b.where({ 'clue', '!=', bucket.Null() })
    b.limit(source.limit or 1000)

    local rows = b.run()

    for _, row in ipairs(rows) do
        row.type_data = row.type_data and mw.text.jsonDecode(row.type_data) or {}
        row.group = row.group or row.type_data.group
    end

    return rows
end

local function rows_to_parsed(rows)
    local first = rows[1]
    local clue_type = first.clue_type
    local is_multi = #rows > 1

    -- Clue-level data
    local parsed = {
        clue = first.clue,
        clue_type = clue_type,
        difficulty = first.difficulty:lower(),
        solution = first.solution,
        title = first.title,
        requirements = first.requirements,
    }

    local td = first.type_data
    for _, key in ipairs(CLUE_TYPE_DATA_FIELDS[clue_type] or {}) do
        if td[key] ~= nil then
            parsed[key] = td[key]
        end
    end

    parsed.clueimage = strip_file_prefix(parsed.clueimage)

    parsed.display = {
        clue        = fmt.clue_link(parsed.clue, parsed.difficulty),
        solution    = fmt.solution(parsed.solution),
        chathead    = fmt.chathead(parsed.solution, parsed.chathead),
        second_step = fmt.second_step(parsed),
        fight       = fmt.fight(parsed.fight),
        clueimage   = fmt.image(parsed.clueimage),
        location    = fmt.location(first.location),
    }

    parsed.display.title = ANAGRAM_PREAMBLE .. parsed.title .. parsed.display.clue

    local loc_parts, img_parts, map_parts = {}, {}, {}
    local travel_parts, travel_seen = {}, {}
    local req_parts, req_seen = {}, {}

    for _, row in ipairs(rows) do
        local title = row.location_title

        if row.location then
            local loc = row.location
            if is_multi and title then
                loc = "'''" .. title .. ":'''<br>" .. loc
            end
            table.insert(loc_parts, loc)
        end

        local image = strip_file_prefix(row.image)
        if image then
            if is_multi and title then
                table.insert(img_parts, "'''" .. title .. "'''")
            end
            table.insert(img_parts, fmt.image(image))
        end

        if row.x_coordinate and row.y_coordinate then
            local map_data = {
                xs = row.x_coordinate,
                ys = row.y_coordinate,
                mapID = row.map_id,
                plane = row.plane,
            }

            local row_display = {}
            for k, v in pairs(parsed.display) do row_display[k] = v end
            row_display.location = fmt.location(row.location)

            local popup_pin = build_popup_pin(map_data, row_display, clue_type)

            local extra_pin = nil
            if parsed.hideyhole and type(parsed.hideyhole) == 'table'
                and parsed.hideyhole.x and parsed.hideyhole.y then
                extra_pin = parsed.hideyhole
            end

            local map_cell = fmt.map(map_data, extra_pin, popup_pin)

            if is_multi and title then
                table.insert(map_parts, "'''" .. title .. "'''")
            end
            table.insert(map_parts, map_cell)
        end

        if row.travel and not travel_seen[row.travel] then
            travel_seen[row.travel] = true
            table.insert(travel_parts, { formatted = fmt.travel(row.travel), title = title })
        end

        if row.requirements and not req_seen[row.requirements] then
            req_seen[row.requirements] = true
            table.insert(req_parts, { formatted = fmt.list(row.requirements), title = title })
        end
    end

    parsed.location_display = join_or(loc_parts, "''None''", '<br><br>')
    parsed.image_display = join_or(img_parts, "''None''")
    parsed.map_display = join_or(map_parts, "''None''")
    parsed.travel_display = finalize_display(travel_parts, fmt.travel(nil))
    parsed.requirements_display = finalize_display(req_parts, fmt.list(nil))
    parsed.is_multi = is_multi

    return parsed
end

local function build_coordinate_row(rows)
    if not rows or #rows == 0 then return '' end
    local parsed = rows_to_parsed(rows)

    local t = mw.html.create('table')
    t:addClass(CLUE_TABLE_CLASS .. ' cluetable-coord align-left-3')
        :css({ ['width'] = '100%', ['text-align'] = 'center' })

    t:tr()
        :th({ 'Coordinates', css = { 'width', '20%' } })
        :th({ 'Location', css = { 'width', '20%' } })
        :th({ 'Suggested travel', css = { 'width', '30%' } })
        :th({ 'Image', css = { ['width'] = '15%', ['text-align'] = 'center' } })
        :th({ 'Map', css = { ['width'] = '15%', ['text-align'] = 'center' } })
    t:tr()
        :td({ parsed.display.clue, css = { 'text-align', 'center' } })
        :td(parsed.location_display)
        :td({ parsed.travel_display, attr = { 'rowspan', 3 } })
        :td({ parsed.image_display, attr = { 'rowspan', 3 }, css = { 'text-align', 'center' } })
        :td({ parsed.map_display, attr = { 'rowspan', 3 }, css = { 'text-align', 'center' } })
    t:tr()
        :th({ 'Requirements', css = { 'width', '20%' } })
        :th({ 'Fight?', css = { 'width', '20%' } })
    t:tr()
        :td(parsed.requirements_display)
        :td(parsed.display.fight)

    return tostring(t)
end

local function build_cryptic_row(rows)
    if not rows or #rows == 0 then return '' end
    local parsed = rows_to_parsed(rows)

    local challenge = parsed.display.second_step
    local main_rowspan = 1 + (challenge and 2 or 0)

    local t = mw.html.create('table')
    t:addClass(CLUE_TABLE_CLASS .. ' cluetable-cryptic'):css({ ['width'] = '100%' })

    t:tr()
        :th({ 'Cryptic', css = { 'width', '15%' } })
        :th({ 'Solution', css = { 'width', '15%' } })
        :th({ 'Location', css = { 'width', '15%' } })
        :th({ 'Suggested travel', css = { 'width', '20%' } })
        :th({ 'Image', css = { ['width'] = '17%', ['text-align'] = 'center' } })
        :th({ 'Map', css = { ['width'] = '18%', ['text-align'] = 'center' } })

    t:tr()
        :td({ parsed.display.clue, attr = { 'rowspan', main_rowspan }, css = { 'text-align', 'center' } })
        :td({ parsed.display.solution, css = { 'text-align', 'center' } })
        :td({ parsed.location_display, attr = { 'rowspan', main_rowspan } })
        :td({ parsed.travel_display, attr = { 'rowspan', main_rowspan } })
        :td({ parsed.image_display, attr = { 'rowspan', main_rowspan }, css = { 'text-align', 'center' } })
        :td({ parsed.map_display, attr = { 'rowspan', main_rowspan }, css = { 'text-align', 'center' } })

    if challenge then
        t:tr():th({ 'Challenge', css = { 'height', '1em' } })
        t:tr():td({ '\n' .. challenge .. '\n', css = { 'text-align', 'left' } })
    end

    return tostring(t)
end

local function build_compass_section(rows)
    local t = mw.html.create('table')
    t:addClass('wikitable'):css('width', '100%')

    t:tr()
        :th({ 'Location', css = { 'width', '100%' } })
        :th('Image')
        :th('Map')

    for _, row in ipairs(rows) do
        local parsed = rows_to_parsed({ row })
        local location = row.location or 'Unknown location'

        t:tr()
            :tag('td'):wikitext(location):done()
            :tag('td'):wikitext(parsed.image_display):done()
            :tag('td'):wikitext(parsed.map_display):done()
    end

    return tostring(t)
end

local function build_compscan_section(rows)
    local t = mw.html.create('table')
    t:addClass('wikitable'):css('width', '100%')

    t:tr()
        :th({ 'Possible locations', css = { 'width', '100%' } })
        :th('Image')

    for _, row in ipairs(rows) do
        local parsed = rows_to_parsed({ row })
        local location = row.location or 'Unknown location'
        local map_cell = parsed.map_display
        local td_text = (map_cell ~= "''None''") and (location .. '<br>' .. map_cell) or location

        t:tr()
            :tag('td'):wikitext(td_text):done()
            :tag('td'):wikitext(parsed.image_display)
    end

    return tostring(t)
end

local function build_emote_row(rows)
    if not rows or #rows == 0 then return '' end
    local parsed = rows_to_parsed(rows)

    local has_req = parsed.requirements and parsed.requirements ~= ''

    local t = mw.html.create('table')
    t:addClass(CLUE_TABLE_CLASS .. ' cluetable-emote'):css('width', '100%')

    t:tr()
        :th({ 'Clue', css = { 'width', '25%' } })
        :th({ 'Requirements', css = has_req and { 'width', '30%' } or nil })
        :th({ 'Location', css = { 'width', '25%' } })
        :th({ 'Image', css = { 'width', '20%' } })
    t:tr()
        :td({ fmt.clue_link(parsed.clue, parsed.difficulty, parsed.title), css = { 'text-align', 'center' } })
        :td(parsed.requirements_display)
        :td({ parsed.map_display, attr = { 'rowspan', 7 }, css = { 'text-align', 'center' } })
        :td({ parsed.image_display, attr = { 'rowspan', 7 }, css = { 'text-align', 'center' } })
    t:tr()
        :th('Hidey-hole')
        :th('Fight?')
    t:tr()
        :td({ fmt.hideyhole(parsed.hideyhole and parsed.hideyhole.page, 30), css = { 'text-align', 'center' } })
        :td(parsed.display.fight)
    t:tr()
        :th('Items')
        :th('Suggested travel')
    t:tr()
        :td({ fmt.items(parsed.items), css = { 'text-align', 'center' } })
        :td({ parsed.travel_display, attr = { 'rowspan', 3 } })

    t:tr():th('Emote')
    t:tr():td({ fmt.emotes(parsed.emotes), css = { 'text-align', 'center' } })

    return tostring(t)
end

local function build_map_row(rows)
    if not rows or #rows == 0 then return '' end
    local parsed = rows_to_parsed(rows)

    local t = mw.html.create('table')
    t:addClass(CLUE_TABLE_CLASS .. ' cluetable-map'):css('width', '100%')

    t:tr()
        :th({ 'Map Clue', css = { 'width', '15%' } })
        :th({ 'Solution', css = { 'width', '15%' } })
        :th({ 'Suggested travel', css = { 'width', '20%' } })
        :th({ 'Image', css = { ['width'] = '17%', ['text-align'] = 'center' } })
        :th({ 'Map', css = { ['width'] = '18%', ['text-align'] = 'center' } })

    t:tr()
        :td({ parsed.display.clueimage, css = { 'text-align', 'center' } })
        :td({ parsed.display.solution, css = { 'text-align', 'center' } })
        :td({ parsed.travel_display, attr = { 'rowspan', 3 } })
        :td({ parsed.image_display, attr = { 'rowspan', 3 }, css = { 'text-align', 'center' } })
        :td({ parsed.map_display, attr = { 'rowspan', 3 }, css = { 'text-align', 'center' } })

    t:tr()

    t:tr()
        :th('Clue')
        :th('Location')

    t:tr()
        :td({ fmt.clue_link(parsed.clue, parsed.difficulty, parsed.title), css = { 'text-align', 'center' } })
        :td({ parsed.location_display, css = { 'text-align', 'center' } })

    return tostring(t)
end

local function build_anagram_row(rows)
    if not rows or #rows == 0 then return '' end
    local parsed = rows_to_parsed(rows)

    local challenge
    if parsed.is_multi then
        local variants, seen = {}, {}
        for _, row in ipairs(rows) do
            local rtd = row.type_data
            local ans = rtd.answer
            if ans and tostring(ans):lower() ~= 'none' then
                local s = tostring(ans)
                if not seen[s] then
                    seen[s] = { answer = s, titles = {} }
                    table.insert(variants, seen[s])
                end
                if row.location_title then
                    table.insert(seen[s].titles, row.location_title)
                end
            end
        end
        if #variants > 1 then
            local parts = {}
            if parsed.question then
                table.insert(parts, "'''Question:''' " .. tostring(parsed.question))
            end
            table.insert(parts, "'''Answer:'''")
            local answer_parts = {}
            for _, v in ipairs(variants) do
                local line = '* ' .. v.answer
                if #v.titles > 0 then
                    line = line .. ' (' .. table.concat(v.titles, ' / ') .. ')'
                end
                table.insert(answer_parts, line)
            end
            table.insert(parts, "\n" .. table.concat(answer_parts, "\n") .. "\n")
            challenge = table.concat(parts, '<br>')
        end
    end

    if not challenge then
        challenge = parsed.display.second_step
    end

    local main_rowspan = challenge and 3 or 1

    local t = mw.html.create('table')
    t:addClass(CLUE_TABLE_CLASS .. ' cluetable-anagram'):css('width', '100%')

    t:tr()
        :th({ 'Anagram', css = { 'width', '15%' } })
        :th({ 'Solution', css = { 'width', '15%' } })
        :th({ 'Location', css = { 'width', '15%' } })
        :th({ 'Suggested travel', css = { 'width', '25%' } })
        :th({ 'Map', css = { 'width', '15%' } })
        :th({ 'Image', css = { 'width', '15%' } })

    t:tr()
        :td({ parsed.display.title, attr = { 'rowspan', main_rowspan }, css = { 'text-align', 'center' } })
        :td({ parsed.display.chathead, css = { 'text-align', 'center' } })
        :td({ parsed.location_display, attr = { 'rowspan', main_rowspan } })
        :td({ parsed.travel_display, attr = { 'rowspan', main_rowspan } })
        :td({ parsed.map_display, attr = { 'rowspan', main_rowspan }, css = { 'text-align', 'center' } })
        :td({ parsed.image_display, attr = { 'rowspan', main_rowspan }, css = { 'text-align', 'center' } })

    if challenge then
        t:tr():th({ 'Challenge', css = { 'height', '1em' } })
        t:tr():td({ '\n' .. challenge .. '\n', css = { 'text-align', 'left' } })
    end

    return tostring(t)
end

function p.coordinates(frame)
    local rows = get_clue_bucket_data(frame, 'coordinate')
    local output = {}

    -- Two-level grouping: first by coordinate prefix (for jump links), then by clue
    local digit_groups = {}
    local sorted_keys = {}
    for _, row in ipairs(rows) do
        local prefix = row.clue and row.clue:match('^(%d%d)') or '00'
        if not digit_groups[prefix] then
            digit_groups[prefix] = {}
            table.insert(sorted_keys, prefix)
        end
        table.insert(digit_groups[prefix], row)
    end
    table.sort(sorted_keys)

    local difficulty = rows[1] and rows[1].difficulty and rows[1].difficulty:lower() or ''
    local anchor_prefix = difficulty ~= '' and (difficulty .. '-coord-') or 'coord-'

    local jump_links = {}
    for _, key in ipairs(sorted_keys) do
        table.insert(jump_links, string.format('[[#%s%s|%s]]', anchor_prefix, key, key))
    end

    if #jump_links > 0 then
        table.insert(output,
            '<div class="tile" role="navigation" id="toc" style="text-align: center; margin: 1.5em auto; padding: 1em 2em; width: fit-content; max-width: initial">')
        table.insert(output, "'''Jump to:'''<br> " .. table.concat(jump_links, ' &#8226; '))
        table.insert(output, '</div>')
    end

    for _, key in ipairs(sorted_keys) do
        table.insert(output, '<span id="' .. anchor_prefix .. key .. '"></span>')

        local clue_groups, clue_order = group_rows(digit_groups[key])
        table.sort(clue_order)

        for _, clue_text in ipairs(clue_order) do
            table.insert(output, build_coordinate_row(clue_groups[clue_text]))
        end
    end

    return table.concat(output, '\n')
end

function p.cryptics(frame)
    local rows = get_clue_bucket_data(frame, 'cryptic')
    local groups, order = group_rows(rows)
    local output = {}
    for _, key in ipairs(order) do
        table.insert(output, build_cryptic_row(groups[key]))
    end
    return table.concat(output, '\n')
end

function p.anagrams(frame)
    local rows = get_clue_bucket_data(frame, 'anagram')
    local groups, order = group_rows(rows)
    local output = {}
    for _, key in ipairs(order) do
        table.insert(output, build_anagram_row(groups[key]))
    end
    return table.concat(output, '\n')
end

function p.emotes(frame)
    local rows = get_clue_bucket_data(frame, 'emote')
    local groups, order = group_rows(rows)
    local output = {}
    for _, key in ipairs(order) do
        table.insert(output, build_emote_row(groups[key]))
    end
    return table.concat(output, '\n')
end

function p.maps(frame)
    local rows = get_clue_bucket_data(frame, 'map')
    local groups, order = group_rows(rows)
    local output = {}
    for _, key in ipairs(order) do
        table.insert(output, build_map_row(groups[key]))
    end
    return table.concat(output, '\n')
end

local function build_compscan_list(frame, default_type)
    local rows = get_clue_bucket_data(frame, default_type)
    local groups, order = group_rows(rows)
    local output = {}
    for _, group_name in ipairs(order) do
        table.insert(output, string.format('====[[%s]]====', group_name))
        table.insert(output, build_compscan_section(groups[group_name]))
    end
    return table.concat(output, '\n')
end

function p.compass(frame)
    local rows = get_clue_bucket_data(frame, 'compass')
    local groups, order = group_rows(rows)
    local output = {}
    for _, group_name in ipairs(order) do
        table.insert(output, string.format('====%s====', group_name))
        table.insert(output, build_compass_section(groups[group_name]))
    end
    return table.concat(output, '\n')
end

function p.scan(frame)
    return build_compscan_list(frame, 'scan')
end

local LIST_HANDLERS = {
    coordinate = 'coordinates',
    cryptic    = 'cryptics',
    simple     = 'cryptics',
    compass    = 'compass',
    scan       = 'scan',
    anagram    = 'anagrams',
    emote      = 'emotes',
    map        = 'maps',
}

function p.list(frame)
    local parent = frame:getParent()
    local clue_type = (parent and parent.args or frame.args).type

    local handler = LIST_HANDLERS[clue_type] and p[LIST_HANDLERS[clue_type]]
    if handler then return handler(frame) end
    return '<span class="error">Unknown clue type: ' .. tostring(clue_type) .. '</span>'
end

return p