ATLYSS TechPendium

Module:Trade Items

Viewing old revision of Module:Trade_Items

You are viewing an old revision of this page from 3/29/2026, 12:11:48 PM.

View latest version
This Module contains Wiki logic and must be loaded using require keyword inside other logic modules.
Note that require supports only static imports.
This module can be {{#invoke}}-ed to render dynamic parts of the page.
const Utils = await require("Utils");
const Wiki = await require("Wiki");
const Game = await require("Game");
const { latest: latestVersion } = await requireData("Versions");
const { TooltipBuilder } = await require("Tooltip_Builder");
const { InfoboxBuilder } = await require("Infobox_Builder");

function buildTradeItemTooltipHtml(itemData, itemId) {
    const tooltip = new TooltipBuilder(`TradeItem:Item:${itemId}`);

    const name = itemData.name || itemId;
    const description = itemData.description || "";

    const sanitizedDescription = description
        .replace(/\n/g, " ")
        .replace(/<color=(.*?)>(.*?)<\/color>/g,
            "<span style='color:$1;'>$2</span>");

    tooltip.addLine(
        `<div style="display:flex;justify-content:space-between;">` +
        `<div>'''${name}'''</div>` +
        `<div>[[File:${itemData.id}_icon.png|26px|inline|alt=${name}]]</div>` +
        `</div>`
    );

    if (sanitizedDescription) {
        tooltip.addLine(`<div>${sanitizedDescription.replace(/\n/g, "<br>")}</div>`);
    }

    tooltip.addLine(`<div>Rarity: ${Wiki.wikiRarity(itemData.rarity)}</div>`);
    tooltip.addLine(`<div>Stack Size: ${itemData.stack?.max ?? "N/A"}</div>`);

    if (itemData.value?.vendor) {
        tooltip.addLine(`<div>Buy Price: ${itemData.value.vendor} Crowns</div>`);
        tooltip.addLine(`<div>Sell Price: ${Game.getSellPrice(itemData.value.vendor)} Crowns</div>`);
    }

    return tooltip.build();
}

async function renderTable(items, version, notes = {}) {
    let wikitext = "";
    let tooltips = "";

    const notesAvailable = !!Object.keys(notes).length;

    wikitext += '{| class="wiki-table" style="width: auto;"\n';
    wikitext += `! colspan=${notesAvailable ? "4" : "3"} | :Version: ${Wiki.versionSelector(version, "Trade_Items", "wikiTable")}\n`;
    wikitext += '|-\n';
    wikitext += `! Name !! Rarity !! Max Stack${notesAvailable ? " !! Notes" : ""}\n`;

    for (const id in items) {
        const item = items[id];
        const itemId = item.id || id;

        wikitext += '|-\n';

        const tooltipId = `TradeItem:Item:${itemId}`;

        // Link only inside table
        wikitext += `| <span data-tooltip-id="${tooltipId}">${availablePages.has(`File:${item.id}_icon.png`) ? `[[File:${item.id}_icon.png|16px|inline|alt=${item.name}|link=Trade Items/${item.name}]]` : ""}[[Trade Items/${item.name}|${item.name}]]</span>\n`;

        // Collect tooltip block separately
        tooltips += buildTradeItemTooltipHtml(item, itemId);

        // Rarity
        wikitext += `| ${item.rarity ? Wiki.wikiRarity(item.rarity) : "N/A"}\n`;

        // Max Stack
        wikitext += `| ${item.stack?.max ?? "N/A"}\n`;

        if (notesAvailable) {
            wikitext += `| ${notes[item.name] ?? ""}\n`;
        }
    }

    wikitext += '|}\n';
    wikitext += tooltips;

    return wikitext;
}

async function renderNavbox(items, version) {
    let wikitext = '';
    let tooltips = '';

    const versionTooltip = new TooltipBuilder(`TradeItem:Navbox:Version:${version.replace(/\./g, '_')}`);
    versionTooltip.addLine(
        `Data version: '''${version}''' <span class="${version === latestVersion ? 'latest' : 'outdated'}">(${version === latestVersion ? 'latest' : 'outdated'})</span>`
    );

    wikitext += `{| class="wiki-table navbox"\n`;
    wikitext += `! :Version: ${Wiki.versionCode(version, versionTooltip)}\n`;
    wikitext += `|-\n`;
    wikitext += `! Trade Items Navigation\n`;
    wikitext += `|-\n| `;

    const itemLinks = [];

    for (const id in items) {
        const item = items[id];
        const itemId = item.id || id;
        const tooltipId = `TradeItem:Item:${itemId}`;

        // ✅ Inline span only
        itemLinks.push(
            `<span data-tooltip-id="${tooltipId}">${availablePages.has(`File:${item.id}_icon.png`) ? `[[File:${item.id}_icon.png|16px|inline|alt=${item.name}|link=Trade Items/${item.name}]]` : ""}[[Trade Items/${item.name}|${item.name}]]</span>`
        );

        // ✅ Collect tooltip block separately
        tooltips += buildTradeItemTooltipHtml(item, itemId);
    }

    wikitext += itemLinks.join(' • ') + '\n';
    wikitext += `|}\n`;

    // ✅ Append tooltip blocks AFTER navbox
    wikitext += tooltips;

    return wikitext;
}

async function wikiTable(props) {
    const args = Utils.resolveArgs(props);
    const selectedVersion = args["version"] || args[0];
    const notes = args["notes"];

    const { data, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(data)) {
        return `<div id="Trade_Items-wikiTable">⚠️ Trade item data is unavailable for version ${version}.${Wiki.versionSelector(version, "Trade_Items", "wikiTable")}</div>`;
    }

    const parsedNotes = notes?.length
        ? notes.split(",").reduce((acc, pair) => {
            const [key, ...rest] = pair.split(":");

            if (!key) return acc;

            acc[key.trim()] = rest.join(":").trim();
            return acc;
        }, {})
        : notes;

    const sortedData = Utils.sortObjectByKey(data);

    const table = await renderTable(sortedData, version, parsedNotes || {});
    return `<div id="Trade_Items-wikiTable">${table}</div>`;
};

async function wikiNavbox() {
    const { data, version } = await Utils.resolveData("Trade_Items", false);

    if (Utils.isModuleEmpty(data)) {
        return '⚠️ Trade items data is unavailable for all known versions.';
    }

    const sortedData = Utils.sortObjectByKey(data);

    let output = '';
    output += await renderNavbox(sortedData, version);

    return output;
};

async function wikiTooltip(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    if (!itemArg) return "";

    frame.tradeItemsCache ??= {};
    frame.tradeItemsCache.tooltip ??= {};

    const itemId = itemArg
        .toLowerCase()
        .replace(/\s+|\'/g, '_')
        .split("/")
        .pop();

    const cacheKey = `TradeItem:${itemId}`;

    if (frame.tradeItemsCache.tooltip[cacheKey]) {
        return frame.tradeItemsCache.tooltip[cacheKey];
    }

    const { data } = await Utils.resolveData("Trade_Items", false);

    if (Utils.isModuleEmpty(data)) {
        return "⚠️ Trade items data are unavailable.";
    }

    const itemData = data[itemId];

    if (!itemData) {
        return `⚠️ Trade item '${itemArg}' not found.`;
    }

    const tooltipHtml = buildTradeItemTooltipHtml(itemData, itemId);

    const linkHtml =
        `<span data-tooltip-id="TradeItem:Item:${itemId}">` +
        `[[Trade Items/${itemData.name}|${itemData.name}]]` +
        `</span>`;

    const result = linkHtml + tooltipHtml;

    frame.tradeItemsCache.tooltip[cacheKey] = result;

    return result;
}

async function wikiInfobox(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { data, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(data)) {
        return `⚠️ Trade items data is unavailable for version ${selectedVersion}.${Wiki.versionSelector(version, "Trade_Items", "wikiInfobox", [itemArg])}`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, '_').split("/").pop();
    const itemData = data[itemId];

    if (!itemData) {
        return `⚠️ Trade item '${itemArg}' not found in data version ${version}.`;
    }

    const infobox = new InfoboxBuilder();

    infobox.setTitle((itemData.name || itemArg) + Wiki.versionSelector(version, "Trade_Items", "wikiInfobox", [itemArg]));
    infobox.setSubtitle(["Trade Item", ...itemData.tags].join(" "));
    infobox.setRarity(itemData.rarity);
    infobox.setImage(`[[File:${itemData.id}_icon.png|32px|${itemData.name || itemArg}]]`);

    if (itemData.description) {
        infobox.setDescription(itemData.description.replace(/\n{2,}/g, "<br>").replace(/<color=(.*?)>(.*?)<\/color>/g, "<span style='color:$1;'>$2</span>"));
    }

    if (itemData.stack?.max) {
        infobox.startSection("General").addRow("Max Stack Size", itemData.stack.max);
    }

    if (itemData.value?.vendor) {
        infobox.startSection("Price").addRow("Buy Price", itemData.value.vendor).addRow("Sell Price", Game.getSellPrice(itemData.value.vendor));
    }

    return `{{#og:|title=${itemData.name}|image=File:${itemId}_icon.png}}<div id="Trade_Items-wikiInfobox" style="float: ${infobox.float || "right"}">${infobox.build()}</div>`;
}

async function wikiDropSources(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { data: items, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(items)) {
        return `<div id="Trade_Items-wikiDropSources">⚠️ Trade item data unavailable for ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiDropSources", [itemArg])}</div>`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, "_").split("/").pop();
    const itemData = items[itemId];

    if (!itemData) {
        return `<div id="Trade_Items-wikiDropSources">⚠️ Trade item '${itemArg}' not found in ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiDropSources", [itemArg])}</div>`;
    }

    const dropSources = [];

    function resolveLootTableName(rawName) {
        if (!rawName) return { name: rawName, difficulty: null };

        let name = rawName.replace(/^lootTable_/i, "");

        // Extract difficulty
        let difficulty = null;
        const diffMatch = name.match(/\b(easy|normal|hard)\b/i);
        if (diffMatch) {
            difficulty = diffMatch[1].toUpperCase();
            name = name.replace(diffMatch[0], "");
        }

        // Extract tier
        let tier = "";
        const tierMatch = name.match(/tier(\d+)/i);
        if (tierMatch) {
            tier = `Tier ${tierMatch[1]}`;
            name = name.replace(/tier\d+/i, "");
        }

        // Fix level formatting
        name = name.replace(/lv(\d+)/i, "Lv $1");

        // CamelCase → spaced
        name = name.replace(/([a-z])([A-Z])/g, "$1 $2");

        // Underscores → spaces
        name = name.replace(/_/g, " ");

        // Normalize spacing early
        name = name.replace(/\s+/g, " ").trim();

        // Fix known spelling mistakes (data normalization layer)
        const corrections = {
            "Catcombs": "Catacombs"
        };

        for (const wrong in corrections) {
            const regex = new RegExp(`\\b${wrong}\\b`, "gi");
            name = name.replace(regex, corrections[wrong]);
        }

        // Title Case
        name = name.replace(/\b\w/g, c => c.toUpperCase());

        // Append tier only if valid
        if (tier) name += ` (${tier})`;

        // FINAL SAFETY: remove empty parentheses
        name = name.replace(/\(\s*\)/g, "").trim();

        return { name, difficulty };
    }

    function renderDifficultyBadge(difficulty) {
        if (!difficulty) return "";

        let color = "#888";
        if (difficulty === "HARD") color = "#c0392b";
        if (difficulty === "NORMAL") color = "#888";
        if (difficulty === "EASY") color = "#27ae60";

        return ` <span style="color:${color}; font-weight:bold;">${difficulty}</span>`;
    }

    // === ENEMIES (Game module calculation) ===
    const { data: creeps } = await Utils.resolveData("Creeps", false, version);

    if (!Utils.isModuleEmpty(creeps)) {
        for (const creepId in creeps) {
            const creep = creeps[creepId];

            const drop = await Game.calculateCreepDrops(creep, itemData.guid);
            if (!drop) continue;

            dropSources.push({
                category: "Enemy",
                name: creep.name,
                level: creep.level ?? "?",
                isElite: creep.isElite ?? false,
                rawChance: +(drop.dropChance * 100).toFixed(2),
                effectiveChance: +(drop.effectiveDrop * 100).toFixed(4),
                quantity: drop.quantityMin === drop.quantityMax
                    ? drop.quantityMin
                    : `${drop.quantityMin}-${drop.quantityMax}`
            });
        }
    }

    // === LOOT TABLES (Game module calculation) ===
    const { data: lootTables } = await Utils.resolveData("Loot_Tables", false, version);

    if (!Utils.isModuleEmpty(lootTables)) {
        for (const tableId in lootTables) {
            const table = lootTables[tableId];

            // Exclude gambling tables
            if (/cost/i.test(table.name) && /tier\d+/i.test(table.name)) continue;

            const drop = await Game.calculateLootTableDrops(table, itemData.guid);
            if (!drop) continue;

            dropSources.push({
                category: "Loot Table",
                name: table.name,
                rawChance: +(drop.dropChance * 100).toFixed(2),
                effectiveChance: +(drop.effectiveDrop * 100).toFixed(4),
                quantity: drop.quantityMin === drop.quantityMax
                    ? drop.quantityMin
                    : `${drop.quantityMin}-${drop.quantityMax}`
            });
        }
    }

    if (!dropSources.length) {
        return `<div id="Trade_Items-wikiDropSources">No known drops in ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiDropSources", [itemArg])}</div>`;
    }

    // Group
    const grouped = {};
    for (const entry of dropSources) {
        if (!grouped[entry.category]) grouped[entry.category] = [];
        grouped[entry.category].push(entry);
    }

    // Sort each category
    for (const key in grouped) {
        grouped[key].sort((a, b) => b.effectiveChance - a.effectiveChance);
    }

    // === RENDER ===
    let wikitext = `{| class="wiki-table" style="width:auto;"\n`;
    wikitext += `! colspan="6" | :Version: ${Wiki.versionSelector(version, "Trade_Items", "wikiDropSources", [itemArg])}\n`;

    for (const category in grouped) {

        wikitext += "|-\n";
        wikitext += `! colspan="6" | ${category} Drops\n`;

        if (category === "Enemy") {

            wikitext += "|-\n";
            wikitext += "! Source !! Level !! Elite !! Raw Chance !! Effective Chance !! Quantity\n";

            for (const source of grouped[category]) {
                wikitext += "|-\n";
                wikitext += `| [[Creeps/${source.name}|${source.name}]]`;
                wikitext += ` || ${source.level}`;
                wikitext += ` || ${source.isElite ? "Yes" : "No"}`;
                wikitext += ` || ${source.rawChance.toFixed(2)}%`;
                wikitext += ` || ${source.effectiveChance.toFixed(4)}%`;
                wikitext += ` || ${source.quantity}\n`;
            }

        } else {

            wikitext += "|-\n";
            wikitext += "! Source !! Raw Chance !! Effective Chance !! Quantity\n";

            for (const source of grouped[category]) {
                const resolved = resolveLootTableName(source.name);
                const badge = renderDifficultyBadge(resolved.difficulty);

                wikitext += "|-\n";
                wikitext += `| ${resolved.name}${badge}`;
                wikitext += ` || ${source.rawChance.toFixed(2)}%`;
                wikitext += ` || ${source.effectiveChance.toFixed(4)}%`;
                wikitext += ` || ${source.quantity}\n`;
            }
        }
    }

    wikitext += "|}";

    return `<div id="Trade_Items-wikiDropSources">${wikitext}</div>`;
}

async function wikiShopSources(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { data: items, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(items)) {
        return `<div id="Trade_Items-wikiShopSources">⚠️ Trade item data unavailable for ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiShopSources", [itemArg])}</div>`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, "_").split("/").pop();
    const itemData = items[itemId];

    if (!itemData) {
        return `<div id="Trade_Items-wikiShopSources">⚠️ Trade item '${itemArg}' not found in ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiShopSources", [itemArg])}</div>`;
    }

    const shopSources = [];

    // ==================================================
    // HELPER: Parse Gambling Table Name
    // Pattern: loottable_<npc>_<amount>cost_tierXX
    // ==================================================
    function parseGamblingTable(rawName) {
        if (!rawName) return null;

        let name = rawName.replace(/^lootTable_/i, "");

        const tierMatch = name.match(/tier(\d+)/i);
        const tier = tierMatch ? `Tier ${parseInt(tierMatch[1], 10)}` : null;

        const costMatch = name.match(/_(\d+)cost/i);
        const cost = costMatch ? parseInt(costMatch[1], 10) : null;

        const npcMatch = name.match(/^(.+?)_\d+cost/i);
        const npcRaw = npcMatch ? npcMatch[1] : null;

        if (!npcRaw || cost === null) return null;

        const npcName = npcRaw
            .replace(/_/g, " ")
            .replace(/\b\w/g, c => c.toUpperCase());

        return {
            npcName,
            tier,
            price: cost
        };
    }

    // ================================
    // SHOPS MODULE
    // ================================
    const { data: shops } = await Utils.resolveData("Shops", false, version);

    if (!Utils.isModuleEmpty(shops)) {
        for (const shopId in shops) {
            const shop = shops[shopId];
            if (!shop?.tables?.length) continue;

            for (const table of shop.tables) {
                if (!table?.items?.length) continue;

                for (const entry of table.items) {
                    if (entry.itemGuid !== itemData.guid) continue;

                    let price = null;
                    let currency = null;

                    if (entry.specialStoreCost && entry.specialStoreCostQuantity) {
                        price = entry.specialStoreCostQuantity;
                        currency = Object.values(items).find(
                            i => i.guid === entry.specialStoreCost
                        );
                    }

                    if (!price && itemData.value?.vendor && !entry.isGambleSlot) {
                        price = itemData.value.vendor;
                        currency = { name: "Crowns", id: "gold" };
                    }

                    shopSources.push({
                        category: entry.isGambleSlot ? "Gambling" : "Direct Sale",
                        shopName: shop.name,
                        npcName: shop.shopOwner?.name,
                        levelRequirement: table.levelRequirement ?? null,
                        price,
                        currency,
                        chance: null
                    });
                }
            }
        }
    }

    // =================================
    // LOOT_TABLES MODULE (Pure Gambling)
    // =================================
    const { data: lootTables } = await Utils.resolveData("Loot_Tables", false, version);

    if (!Utils.isModuleEmpty(lootTables)) {
        for (const tableId in lootTables) {
            const table = lootTables[tableId];
            if (!table?.drops?.length) continue;

            if (!/cost/i.test(table.name) || !/tier\d+/i.test(table.name)) continue;

            const entry = table.drops.find(d => d.guid === itemData.guid);
            if (!entry) continue;

            const parsed = parseGamblingTable(table.name);
            if (!parsed) continue;

            shopSources.push({
                category: "Gambling",
                shopName: parsed.npcName,
                npcName: parsed.npcName,
                levelRequirement: parsed.tier,
                price: parsed.price,
                currency: { name: "Crowns", id: "gold" },
                chance: entry.dropChance ?? null
            });
        }
    }

    if (!shopSources.length) {
        return `<div id="Trade_Items-wikiShopSources">No known shop sources in ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiShopSources", [itemArg])}</div>`;
    }

    // ================================
    // GROUP
    // ================================
    const grouped = {};
    for (const entry of shopSources) {
        if (!grouped[entry.category]) grouped[entry.category] = [];
        grouped[entry.category].push(entry);
    }

    // ================================
    // SORTING
    // ================================
    for (const category in grouped) {

        if (category === "Gambling") {
            // Highest chance first
            grouped[category].sort((a, b) => {
                const chanceA = a.chance ?? 0;
                const chanceB = b.chance ?? 0;
                return chanceB - chanceA;
            });
        } else {
            // Direct Sale: level → price
            grouped[category].sort((a, b) => {
                const lvlA = parseInt(a.levelRequirement) || 0;
                const lvlB = parseInt(b.levelRequirement) || 0;
                if (lvlA !== lvlB) return lvlA - lvlB;
                return (a.price ?? 0) - (b.price ?? 0);
            });
        }
    }

    // ================================
    // RENDER
    // ================================
    let wikitext = `{| class="wiki-table" style="width:auto;"\n`;
    wikitext += `! colspan="5" | :Version: ${Wiki.versionSelector(version, "Trade_Items", "wikiShopSources", [itemArg])}\n`;

    for (const category in grouped) {

        wikitext += "|-\n";
        wikitext += `! colspan="5" | ${category}\n`;

        wikitext += "|-\n";
        wikitext += "! Shop !! Vendor !! Level/Tier !! Price !! Chance\n";

        for (const source of grouped[category]) {

            let priceText = "-";

            if (source.price && source.currency) {
                if (source.currency.id === "gold") {
                    priceText = `${source.price} ${source.currency.name}`;
                } else {
                    priceText = `${source.price} × [[Trade Items/${source.currency.name}|${source.currency.name}]]`;
                }
            }

            let chanceText = "—";
            if (source.chance !== null && source.chance !== undefined) {
                chanceText = `${(source.chance * 100).toFixed(2)}%`;
            }

            const shopLink = `[[Shops/${source.shopName}|${source.shopName}]]`;
            const npcLink = source.npcName
                ? `[[NPCs/${source.npcName}|${source.npcName}]]`
                : "—";

            wikitext += "|-\n";
            wikitext += `| ${shopLink}`;
            wikitext += ` || ${npcLink}`;
            wikitext += ` || ${source.levelRequirement ?? "-"}`;
            wikitext += ` || ${priceText}`;
            wikitext += ` || ${chanceText}\n`;
        }
    }

    wikitext += "|}";

    return `<div id="Trade_Items-wikiShopSources">${wikitext}</div>`;
}

async function wikiQuestSources(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { data: items, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(items)) {
        return `<div id="Trade_Items-wikiQuestSources">⚠️ Trade item data is unavailable for version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestSources", [itemArg])}</div>`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, '_').split("/").pop();
    const itemData = items[itemId];

    if (!itemData) {
        return `<div id="Trade_Items-wikiQuestSources">⚠️ Trade item '${itemArg}' not found in data version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestSources", [itemArg])}</div>`;
    }

    const { data: quests } = await Utils.resolveData("Quests", false, version);

    if (Utils.isModuleEmpty(quests)) {
        return `<div id="Trade_Items-wikiQuestSources">⚠️ Quest data is unavailable for version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestSources", [itemArg])}</div>`;
    }

    const questSources = [];

    for (const questId in quests) {
        const quest = quests[questId];
        if (!quest?.rewards?.items?.length) continue;

        const rewardEntry = quest.rewards.items.find(r => r?.guid === itemData.guid);
        if (!rewardEntry) continue;

        questSources.push({
            questName: quest.name,
            questLevel: quest.level ?? null,
            quantity: rewardEntry.quantity ?? 0,
            questId
        });
    }

    if (!questSources.length) {
        return `<div id="Trade_Items-wikiQuestSources">No known quest rewards in version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestSources", [itemArg])}</div>`;
    }

    // Sort by level ascending, then quest name
    questSources.sort((a, b) => {
        const lvlA = a.questLevel ?? 0;
        const lvlB = b.questLevel ?? 0;

        if (lvlA !== lvlB) return lvlA - lvlB;
        return a.questName.localeCompare(b.questName);
    });

    let wikitext = `{| class="wiki-table" style="width:auto;"\n`;
    wikitext += `! colspan="3" | :Version: ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestSources", [itemArg])}\n`;
    wikitext += "|-\n";
    wikitext += "! Quest !! Level !! Reward Quantity\n";

    for (const source of questSources) {
        const questLink = `[[Quests/${source.questName}|${source.questName}]]`;
        wikitext += "|-\n";
        wikitext += `| ${questLink} || ${source.questLevel ?? "-"} || ${source.quantity}\n`;
    }

    wikitext += "|}\n";

    return `<div id="Trade_Items-wikiQuestSources">${wikitext}</div>`;
}

async function wikiEnchantEquipment(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { data: tradeItems, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(tradeItems)) {
        return `<div id="Trade_Items-wikiEnchantEquipment">⚠️ Trade item data is unavailable for version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiEnchantEquipment", [itemArg])}</div>`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, '_').split("/").pop();
    const itemData = tradeItems[itemId];

    if (!itemData) {
        return `<div id="Trade_Items-wikiEnchantEquipment">⚠️ Trade item '${itemArg}' not found in data version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiEnchantEquipment", [itemArg])}</div>`;
    }

    const { data: equipmentData } = await Utils.resolveData("Equipment", false, version);

    if (Utils.isModuleEmpty(equipmentData)) {
        return `<div id="Trade_Items-wikiEnchantEquipment">⚠️ Equipment data is unavailable for version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiEnchantEquipment", [itemArg])}</div>`;
    }

    const sources = [];

    for (const eqId in equipmentData) {
        const eq = equipmentData[eqId];

        if (!eq?.modifiers?.cost?.item) continue;

        if (eq?.rarity == "exotic") continue;

        if (eq.modifiers.cost.item !== itemData.id && eq.modifiers.cost.item !== itemData.guid) continue;

        sources.push({
            equipmentName: eq.name,
            quantity: eq.modifiers.cost.quantity ?? 1,
            equipmentId: eq.id
        });
    }

    if (!sources.length) {
        return `<div id="Trade_Items-wikiEnchantEquipment">No equipment uses this trade item as enchanting material in version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiEnchantEquipment", [itemArg])}</div>`;
    }

    // Sort by equipment name
    sources.sort((a, b) => a.equipmentName.localeCompare(b.equipmentName));

    let wikitext = `{| class="wiki-table" style="width:auto;"\n`;
    wikitext += `! colspan="2" | :Version: ${Wiki.versionSelector(version, "Trade_Items", "wikiEnchantEquipment", [itemArg])}\n`;
    wikitext += "|-\n";
    wikitext += "! Equipment !! Quantity Required\n";

    for (const source of sources) {
        const equipmentLink = `[[Equipment/${source.equipmentName}|${source.equipmentName}]]`;

        wikitext += "|-\n";
        wikitext += `| ${equipmentLink} || ${source.quantity}\n`;
    }

    wikitext += "|}\n";

    return `<div id="Trade_Items-wikiEnchantEquipment">${wikitext}</div>`;
}

function diffObjects(oldObj, newObj, basePath = "") {
    const changes = [];

    const isObject = (val) =>
        val && typeof val === "object" && !Array.isArray(val);

    const allKeys = new Set([
        ...Object.keys(oldObj || {}),
        ...Object.keys(newObj || {})
    ]);

    for (const key of allKeys) {
        const oldValue = oldObj?.[key];
        const newValue = newObj?.[key];

        const path = basePath ? `${basePath}.${key}` : key;

        if (isObject(oldValue) && isObject(newValue)) {
            changes.push(...diffObjects(oldValue, newValue, path));
            continue;
        }

        if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
            changes.push({
                field: path,
                oldValue,
                newValue
            });
        }
    }

    return changes;
}

async function wikiQuestUse(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { data: items, version } = await Utils.resolveData("Trade_Items", false, selectedVersion);

    if (Utils.isModuleEmpty(items)) {
        return `<div id="Trade_Items-wikiQuestUse">⚠️ Trade item data is unavailable for version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestUse", [itemArg])}</div>`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, '_').split("/").pop();
    const itemData = items[itemId];

    if (!itemData) {
        return `<div id="Trade_Items-wikiQuestUse">⚠️ Trade item '${itemArg}' not found in data version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestUse", [itemArg])}</div>`;
    }

    const { data: quests } = await Utils.resolveData("Quests", false, version);

    if (Utils.isModuleEmpty(quests)) {
        return `<div id="Trade_Items-wikiQuestUse">⚠️ Quest data is unavailable for version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestUse", [itemArg])}</div>`;
    }

    const questUses = [];

    for (const questId in quests) {
        const quest = quests[questId];
        if (!quest?.objectives?.items?.length) continue;

        const objectiveEntry = quest.objectives.items.find(o => o?.guid === itemData.guid);
        if (!objectiveEntry) continue;

        questUses.push({
            questName: quest.name,
            questLevel: quest.level ?? null,
            quantity: objectiveEntry.count ?? 0,
        });
    }

    if (!questUses.length) {
        return `<div id="Trade_Items-wikiQuestUse">No known quest uses in version ${version}. ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestUse", [itemArg])}</div>`;
    }

    // Sort by level ascending, then quest name
    questUses.sort((a, b) => {
        const lvlA = a.questLevel ?? 0;
        const lvlB = b.questLevel ?? 0;

        if (lvlA !== lvlB) return lvlA - lvlB;
        return a.questName.localeCompare(b.questName);
    });

    let wikitext = `{| class="wiki-table" style="width:auto;"\n`;
    wikitext += `! colspan="3" | :Version: ${Wiki.versionSelector(version, "Trade_Items", "wikiQuestUse", [itemArg])}\n`;
    wikitext += "|-\n";
    wikitext += "! Quest !! Level !! Quantity\n";

    for (const use of questUses) {
        const questLink = `[[Quests/${use.questName}|${use.questName}]]`;
        wikitext += "|-\n";
        wikitext += `| ${questLink} || ${use.questLevel ?? "-"} || ${use.quantity}\n`;
    }

    wikitext += "|}\n";

    return `<div id="Trade_Items-wikiQuestUse">${wikitext}</div>`;
}

async function wikiChanges(props) {
    const args = Utils.resolveArgs(props);
    const itemArg = args["item"] || args[0];
    const selectedVersion = args["version"] || args[1];

    const { latest: latestAlias, versions: availableVersions } = await requireData("Versions");

    let resolvedVersion;
    if (!selectedVersion || selectedVersion === latestAlias) {
        resolvedVersion = availableVersions[0]?.id;
    } else {
        resolvedVersion = selectedVersion;
    }

    const { data: currentData } = await Utils.resolveData("Trade_Items", false, resolvedVersion);

    if (Utils.isModuleEmpty(currentData)) {
        return `<div id="Trade_Items-wikiChanges">⚠️ Trade item data unavailable for version ${resolvedVersion}. ${Wiki.versionSelector(resolvedVersion, "Trade_Items", "wikiChanges", [itemArg])}</div>`;
    }

    const itemId = itemArg?.toLowerCase()?.replace(/\s+|\'/g, '_').split("/").pop();
    const currentItem = currentData[itemId];

    if (!currentItem) {
        return `<div id="Trade_Items-wikiChanges">⚠️ Trade item '${itemArg}' not found in version ${resolvedVersion}. ${Wiki.versionSelector(resolvedVersion, "Trade_Items", "wikiChanges", [itemArg])}</div>`;
    }

    const currentIndex = availableVersions.findIndex(v => v.id === resolvedVersion);
    const previousVersion = currentIndex >= 0 ? availableVersions[currentIndex + 1]?.id : null;

    if (!previousVersion) {
        return `<div id="Trade_Items-wikiChanges">No previous version available to compare. ${Wiki.versionSelector(resolvedVersion, "Trade_Items", "wikiChanges", [itemArg])}</div>`;
    }

    const { data: previousData } = await Utils.resolveData("Trade_Items", false, previousVersion);
    const previousItem = previousData?.[itemId];

    if (!previousItem) {
        return `<div id="Trade_Items-wikiChanges">
{| class="wiki-table" style="width:auto;"
! ${currentItem.name} was '''added''' in ${resolvedVersion} ${Wiki.versionSelector(resolvedVersion, "Trade_Items", "wikiChanges", [itemArg])}
|}
</div>`;
    }

    // -------------------------
    // DIFF
    // -------------------------

    const ignoredPrefixes = ["icon", "guid", "id"];

    const rawChanges = diffObjects(previousItem, currentItem)
        .filter(change =>
            !ignoredPrefixes.some(prefix =>
                change.field.startsWith(prefix)
            )
        );

    if (!rawChanges.length) {
        return `<div id="Trade_Items-wikiChanges">No gameplay changes detected between ${previousVersion} and ${resolvedVersion}. ${Wiki.versionSelector(resolvedVersion, "Trade_Items", "wikiChanges", [itemArg])}</div>`;
    }

    // -------------------------
    // GROUP CHANGES
    // -------------------------

    const sections = {
        General: [],
        Stats: [],
        Value: [],
        Modifiers: [],
        Other: []
    };

    for (const change of rawChanges) {
        if (change.field.startsWith("stats")) {
            sections.Stats.push(change);
        } else if (change.field.startsWith("buyPrice") || change.field.startsWith("sellPrice")) {
            sections.Value.push(change);
        } else if (change.field.startsWith("modifiers")) {
            sections.Modifiers.push(change);
        } else if (["name", "description", "rarity", "stackSize"].some(f => change.field.startsWith(f))) {
            sections.General.push(change);
        } else {
            sections.Other.push(change);
        }
    }

    // -------------------------
    // FORMAT HELPERS
    // -------------------------

    const formatValue = (val) => {
        if (val === undefined) return "—";
        if (typeof val === "number") return val;
        if (typeof val === "string") return `"${val}"`;
        return `<code>${JSON.stringify(val)}</code>`;
    };

    const formatChangeRow = (change) => {
        const { field, oldValue, newValue } = change;

        let displayOld = formatValue(oldValue);
        let displayNew = formatValue(newValue);

        let indicator = "";

        if (oldValue === undefined) {
            indicator = "🟢 Added";
        } else if (newValue === undefined) {
            indicator = "🔴 Removed";
        } else if (typeof oldValue === "number" && typeof newValue === "number") {
            const diff = newValue - oldValue;
            if (diff > 0) indicator = `▲ +${diff}`;
            else if (diff < 0) indicator = `▼ ${diff}`;
        }

        return `|-\n| ${field} || ${displayOld} || ${displayNew} || ${indicator}\n`;
    };

    // -------------------------
    // BUILD WIKITABLE
    // -------------------------

    let wikitext = `{| class="wiki-table" style="width:auto;"\n`;
    wikitext += `! colspan="4" | Changes for ${currentItem.name} in ${resolvedVersion} (compared to ${previousVersion}) ${Wiki.versionSelector(resolvedVersion, "Trade_Items", "wikiChanges", [itemArg])}\n`;
    wikitext += "|-\n";
    wikitext += "! Field !! Previous !! New !! Change\n";

    for (const sectionName in sections) {
        const section = sections[sectionName];
        if (!section.length) continue;

        wikitext += `|-\n| colspan="4" style="background:#222; font-weight:bold;" | ${sectionName}\n`;

        section.sort((a, b) => a.field.localeCompare(b.field));

        for (const change of section) {
            wikitext += formatChangeRow(change);
        }
    }

    wikitext += "|}\n";

    return `<div id="Trade_Items-wikiChanges">${wikitext}</div>`;
}

exports = {
    wikiTable,
    wikiNavbox,
    wikiTooltip,
    wikiInfobox,
    wikiDropSources,
    wikiShopSources,
    wikiQuestSources,
    wikiEnchantEquipment,
    wikiQuestUse,
    wikiChanges,
}
Last Edited by LiveGobe on 3/29/2026, 12:11:48 PM

This page categories: