MediaWiki:CharacterBuilder.js

From EQOA Sandstorm Wiki
Revision as of 07:23, 14 May 2026 by RenodorDev (talk | contribs)
Jump to navigationJump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
mw.hook('wikipage.content').add(function () {

    function waitForBuilderData(attemptsLeft) {
        if (window.EQOA_BUILDER_DATA) {
            initCharacterBuilder();
            return;
        }

        if (attemptsLeft <= 0) {
            var app = document.getElementById('character-builder-app');
            if (app) {
                app.innerHTML = '<div style="color:red;">EQOA_BUILDER_DATA failed to load.</div>';
            }
            return;
        }

        setTimeout(function () {
            waitForBuilderData(attemptsLeft - 1);
        }, 100);
    }

    waitForBuilderData(30);

    function initCharacterBuilder() {
        var app = document.getElementById('character-builder-app');
        if (!app) return;

        var data = window.EQOA_BUILDER_DATA;
        var equippedItems = {};
        var itemCache = {};
        var pendingSharedBuild = null;
        var sharedBuildApplied = false;

        var statKeys = [
            "lvl", "str", "sta", "agi", "dex", "wis", "int", "cha",
            "hp", "pow", "ac", "pr", "dr", "fr", "cr", "lr", "ar",
            "dmg", "hot", "pot", "dur"
        ];

        var textKeys = [
            "effect", "proc", "weaponstyle"
        ];

        var trainingStats = {
            str: 0,
            sta: 0,
            agi: 0,
            dex: 0,
            wis: 0,
            int: 0,
            cha: 0
        };

        var gearSlots = [
            "Head", "Chest", "Hands", "Legs", "Feet",
            "Forearm", "Bracelet1", "Bracelet2",
            "Waist", "Neck", "Ear1", "Ear2",
            "Ring1", "Ring2", "Primary", "Secondary",
            "Shield", "Held1", "Held2", "Robe", "Range"
        ];

        function getTrainingPointsForLevel(level) {
            level = parseInt(level, 10);
            if (isNaN(level) || level < 1) level = 1;
            if (level > 60) level = 60;

            var tp = 20;

            for (var lvl = 2; lvl <= level; lvl++) {
                if (lvl <= 14) tp += 3;
                else if (lvl <= 29) tp += 5;
                else if (lvl <= 44) tp += 9;
                else if (lvl <= 59) tp += 14;
                else if (lvl === 60) tp += 3;
            }

            return tp;
        }

        app.innerHTML = `
            <div class="character-builder-box">

                <div class="character-builder-title">EQOA Character Builder</div>

                <div class="character-builder-controls">
                    <label>Race</label>
                    <select id="cb-race"></select>

                    <label>Class</label>
                    <select id="cb-class"></select>

                    <label>Level</label>
                    <input type="number" id="cb-level" min="1" max="60" value="60" style="width:70px;">

                    <button id="cb-refresh-items">Refresh Items</button>
                    <button id="cb-generate-share-url" type="button">Generate Share URL</button>
                    <button id="cb-copy-share-url" type="button">Copy URL</button>
                </div>

                <div id="cb-warning-box" class="character-builder-warning-box" style="display:none;"></div>

                <div id="cb-share-url-box" class="character-builder-warning-box" style="display:none; margin-top:10px;">
                    <b>Share Build URL</b><br>
                    <input type="text" id="cb-share-url-output" readonly style="width:100%; box-sizing:border-box; margin-top:6px;">
                    <div id="cb-share-url-status" style="margin-top:5px;"></div>
                </div>

                <div class="character-builder-section-title" style="margin-top:15px;">Training Points</div>

                <table class="character-builder-stat-table">
                    <tr>
                        <th>Available TP</th>
                        <th>Spent TP</th>
                        <th>Remaining TP</th>
                    </tr>
                    <tr>
                        <td id="cb-tp-available">482</td>
                        <td id="cb-tp-spent">0</td>
                        <td id="cb-tp-remaining">482</td>
                    </tr>
                </table>

                <div class="character-builder-section-title" style="margin-top:18px;">Training Allocation</div>

                <table class="character-builder-stat-table">
                    <tr>
                        <th>STR</th>
                        <th>STA</th>
                        <th>AGI</th>
                        <th>DEX</th>
                        <th>WIS</th>
                        <th>INT</th>
                        <th>CHA</th>
                    </tr>
                    <tr>
                        <td><input type="number" id="train-str" min="0" value="0" style="width:60px;"></td>
                        <td><input type="number" id="train-sta" min="0" value="0" style="width:60px;"></td>
                        <td><input type="number" id="train-agi" min="0" value="0" style="width:60px;"></td>
                        <td><input type="number" id="train-dex" min="0" value="0" style="width:60px;"></td>
                        <td><input type="number" id="train-wis" min="0" value="0" style="width:60px;"></td>
                        <td><input type="number" id="train-int" min="0" value="0" style="width:60px;"></td>
                        <td><input type="number" id="train-cha" min="0" value="0" style="width:60px;"></td>
                    </tr>
                </table>

                <div class="character-builder-section-title" style="margin-top:18px;">Total Stats</div>

                <table class="character-builder-stat-table">
                    <tr>
                        <th>STR</th><th>STA</th><th>AGI</th><th>DEX</th><th>WIS</th><th>INT</th><th>CHA</th>
                        <th>HP</th><th>POW</th><th>AC</th>
                        <th>PR</th><th>DR</th><th>FR</th><th>CR</th><th>LR</th><th>AR</th>
                    </tr>
                    <tr>
                        <td id="cb-str">0</td><td id="cb-sta">0</td><td id="cb-agi">0</td><td id="cb-dex">0</td>
                        <td id="cb-wis">0</td><td id="cb-int">0</td><td id="cb-cha">0</td>
                        <td id="cb-hp">0</td><td id="cb-pow">0</td><td id="cb-ac">0</td>
                        <td id="cb-pr">0</td><td id="cb-dr">0</td><td id="cb-fr">0</td><td id="cb-cr">0</td>
                        <td id="cb-lr">0</td><td id="cb-ar">0</td>
                    </tr>
                </table>

                <div class="character-builder-section-title" style="margin-top:18px;">Weapon Summary</div>

                <table class="character-builder-stat-table">
                    <tr>
                        <th>Slot</th>
                        <th>Weapon</th>
                        <th>DMG</th>
                        <th>Style</th>
                        <th>Proc / Effect</th>
                    </tr>
                    <tr>
                        <td>Primary</td>
                        <td id="cb-weapon-primary-name">-</td>
                        <td id="cb-weapon-primary-dmg">-</td>
                        <td id="cb-weapon-primary-style">-</td>
                        <td id="cb-weapon-primary-effect">-</td>
                    </tr>
                    <tr>
                        <td>Secondary</td>
                        <td id="cb-weapon-secondary-name">-</td>
                        <td id="cb-weapon-secondary-dmg">-</td>
                        <td id="cb-weapon-secondary-style">-</td>
                        <td id="cb-weapon-secondary-effect">-</td>
                    </tr>
                    <tr>
                        <td>Range</td>
                        <td id="cb-weapon-range-name">-</td>
                        <td id="cb-weapon-range-dmg">-</td>
                        <td id="cb-weapon-range-style">-</td>
                        <td id="cb-weapon-range-effect">-</td>
                    </tr>
                </table>

                <div class="character-builder-equipment-layout">

                    <div class="character-builder-equipped-summary">
                        <div class="character-builder-section-title" style="margin-top:18px;">Equipped Items</div>
                        <table class="character-builder-equipped-table">
                            <tbody id="cb-equipped-summary-body"></tbody>
                        </table>
                    </div>

                    <div class="character-builder-gear-column">
                        <div class="character-builder-section-title" style="margin-top:18px;">Equipment</div>
                        <div id="cb-loading-message">Loading item categories...</div>
                        <div id="cb-gear-grid" class="character-builder-gear-grid"></div>
                    </div>

                </div>

                <div id="cb-item-tooltip" class="character-builder-tooltip" style="display:none;"></div>
                <div id="cb-compare-tooltip" class="character-builder-tooltip cb-compare-tooltip" style="display:none;"></div>

            </div>
        `;

        var raceSelect = document.getElementById('cb-race');
        var classSelect = document.getElementById('cb-class');
        var levelInput = document.getElementById('cb-level');
        var gearGrid = document.getElementById('cb-gear-grid');
        var loadingMessage = document.getElementById('cb-loading-message');
        var refreshButton = document.getElementById('cb-refresh-items');
        var generateShareButton = document.getElementById('cb-generate-share-url');
        var copyShareButton = document.getElementById('cb-copy-share-url');
        var shareUrlBox = document.getElementById('cb-share-url-box');
        var shareUrlOutput = document.getElementById('cb-share-url-output');
        var shareUrlStatus = document.getElementById('cb-share-url-status');
        var equippedSummaryBody = document.getElementById('cb-equipped-summary-body');
        var warningBox = document.getElementById('cb-warning-box');
        var tooltip = document.getElementById('cb-item-tooltip');
        var compareTooltip = document.getElementById('cb-compare-tooltip');

        function addOption(select, value, label) {
            var option = document.createElement('option');
            option.value = value;
            option.textContent = label;
            select.appendChild(option);
        }


        function getBuildParamFromUrl() {
            try {
                var params = new URLSearchParams(window.location.search);
                return params.get('build');
            } catch (e) {
                return null;
            }
        }

        function encodeBuildData(buildData) {
            var json = JSON.stringify(buildData);
            return btoa(unescape(encodeURIComponent(json)));
        }

        function decodeBuildData(encoded) {
            try {
                var json = decodeURIComponent(escape(atob(encoded)));
                return JSON.parse(json);
            } catch (e) {
                console.error('Could not decode shared build.', e);
                return null;
            }
        }

        function getCurrentBuildData() {
            var gear = {};

            gearSlots.forEach(function (slot) {
                gear[slot] = equippedItems[slot] ? equippedItems[slot].name : '';
            });

            return {
                version: 1,
                race: raceSelect.value,
                classCode: classSelect.value,
                level: getCharacterLevel(),
                trainingStats: {
                    str: parseInt(trainingStats.str, 10) || 0,
                    sta: parseInt(trainingStats.sta, 10) || 0,
                    agi: parseInt(trainingStats.agi, 10) || 0,
                    dex: parseInt(trainingStats.dex, 10) || 0,
                    wis: parseInt(trainingStats.wis, 10) || 0,
                    int: parseInt(trainingStats.int, 10) || 0,
                    cha: parseInt(trainingStats.cha, 10) || 0
                },
                equippedItemNames: gear
            };
        }

        function generateShareUrl() {
            var buildData = getCurrentBuildData();
            var encoded = encodeBuildData(buildData);
            var baseUrl = window.location.origin + window.location.pathname;
            var url = baseUrl + '?build=' + encodeURIComponent(encoded);

            if (shareUrlOutput) shareUrlOutput.value = url;
            if (shareUrlBox) shareUrlBox.style.display = 'block';
            if (shareUrlStatus) shareUrlStatus.textContent = 'Share URL generated.';

            try {
                var newUrl = baseUrl + '?build=' + encodeURIComponent(encoded);
                window.history.replaceState(null, document.title, newUrl);
            } catch (e) {}

            return url;
        }

        function copyShareUrl() {
            var url = shareUrlOutput && shareUrlOutput.value ? shareUrlOutput.value : generateShareUrl();

            function markCopied() {
                if (shareUrlBox) shareUrlBox.style.display = 'block';
                if (shareUrlStatus) shareUrlStatus.textContent = 'Copied to clipboard.';
            }

            function markManualCopy() {
                if (shareUrlBox) shareUrlBox.style.display = 'block';
                if (shareUrlOutput) {
                    shareUrlOutput.focus();
                    shareUrlOutput.select();
                }
                if (shareUrlStatus) shareUrlStatus.textContent = 'Copy blocked by browser. Select the URL above and copy it manually.';
            }

            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(url).then(markCopied).catch(markManualCopy);
            } else {
                try {
                    if (shareUrlOutput) {
                        shareUrlOutput.focus();
                        shareUrlOutput.select();
                    }
                    document.execCommand('copy');
                    markCopied();
                } catch (e) {
                    markManualCopy();
                }
            }
        }

        function getAllLoadedItemsFlat() {
            var all = [];
            var seen = {};

            Object.keys(itemCache).forEach(function (slotType) {
                (itemCache[slotType] || []).forEach(function (item) {
                    if (!item || !item.name) return;
                    var key = item.name.toLowerCase();
                    if (!seen[key]) {
                        seen[key] = item;
                        all.push(item);
                    }
                });
            });

            return all;
        }

        function findLoadedItemByName(itemName, slot) {
            if (!itemName) return null;

            var name = String(itemName).toLowerCase();
            var slotType = getBuilderSlotType(slot);
            var slotItems = itemCache[slotType] || [];

            for (var i = 0; i < slotItems.length; i++) {
                if (slotItems[i].name && slotItems[i].name.toLowerCase() === name) {
                    return slotItems[i];
                }
            }

            var allItems = getAllLoadedItemsFlat();
            for (var j = 0; j < allItems.length; j++) {
                if (allItems[j].name && allItems[j].name.toLowerCase() === name) {
                    return allItems[j];
                }
            }

            return null;
        }

        function setTrainingInputsFromState() {
            Object.keys(trainingStats).forEach(function (key) {
                var input = document.getElementById('train-' + key);
                if (input) input.value = trainingStats[key];
            });
        }

        function applySharedBuildBasicState(buildData) {
            if (!buildData || sharedBuildApplied) return;

            if (buildData.race && data.baseStats[buildData.race]) {
                raceSelect.value = buildData.race;
                loadClasses(true);
            }

            if (buildData.classCode) {
                var classExists = false;
                Array.prototype.forEach.call(classSelect.options, function (option) {
                    if (option.value === buildData.classCode) classExists = true;
                });

                if (classExists) classSelect.value = buildData.classCode;
            }

            if (buildData.level !== undefined) {
                levelInput.value = buildData.level;
                getCharacterLevel();
            }

            if (buildData.trainingStats) {
                Object.keys(trainingStats).forEach(function (key) {
                    trainingStats[key] = parseInt(buildData.trainingStats[key], 10) || 0;
                });
                setTrainingInputsFromState();
                Object.keys(trainingStats).forEach(function (key) {
                    clampTrainingInput(key);
                });
                setTrainingInputsFromState();
            }
        }

        function applySharedBuildEquipment(buildData) {
            if (!buildData || !buildData.equippedItemNames) return;

            equippedItems = {};

            gearSlots.forEach(function (slot) {
                var itemName = buildData.equippedItemNames[slot];
                var item = findLoadedItemByName(itemName, slot);

                if (item && classCanUseItem(item, classSelect.value) && levelCanUseItem(item)) {
                    equippedItems[slot] = item;
                }
            });

            enforceDisabledSlots();
            sharedBuildApplied = true;
            buildGearSlots();
            updateStats();

            if (shareUrlBox && shareUrlOutput) {
                shareUrlBox.style.display = 'block';
                shareUrlOutput.value = window.location.href;
                if (shareUrlStatus) shareUrlStatus.textContent = 'Shared build loaded.';
            }
        }

        function tryLoadSharedBuildFromUrl() {
            var encoded = getBuildParamFromUrl();
            if (!encoded) return;

            pendingSharedBuild = decodeBuildData(encoded);

            if (pendingSharedBuild) {
                applySharedBuildBasicState(pendingSharedBuild);
            } else if (shareUrlBox && shareUrlStatus) {
                shareUrlBox.style.display = 'block';
                shareUrlStatus.textContent = 'Could not load shared build URL.';
            }
        }

        Object.keys(data.baseStats).forEach(function (race) {
            addOption(raceSelect, race, race);
        });

        function getCharacterLevel() {
            var level = parseInt(levelInput.value, 10);
            if (isNaN(level) || level < 1) level = 1;
            if (level > 60) level = 60;
            levelInput.value = level;
            return level;
        }

        function getBuilderSlotType(slot) {
            if (slot.indexOf("Bracelet") === 0) return "Bracelet";
            if (slot.indexOf("Ring") === 0) return "Ring";
            if (slot.indexOf("Ear") === 0) return "Ear";
            if (slot.indexOf("Held") === 0) return "Held";
            return slot;
        }

        function getCategoryForSlot(slotType) {
            return slotType + " Items";
        }

        function getCategoriesForSlotType(slotType) {
            if (slotType === "Primary") {
                return ["Primary Items", "Secondary Items"];
            }
            return [getCategoryForSlot(slotType)];
        }

        function apiGet(params) {
            return new mw.Api().get(params);
        }

        function getPagesInCategory(categoryName) {
            return apiGet({
                action: "query",
                list: "categorymembers",
                cmtitle: "Category:" + categoryName,
                cmlimit: "500",
                cmnamespace: "0",
                format: "json"
            }).then(function (data) {
                return data.query.categorymembers || [];
            });
        }

        function getPageWikitext(title) {
            return apiGet({
                action: "query",
                prop: "revisions",
                titles: title,
                rvprop: "content",
                rvslots: "main",
                formatversion: "2",
                format: "json"
            }).then(function (data) {
                var page = data.query.pages[0];

                if (!page || !page.revisions || !page.revisions[0]) return "";

                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    return page.revisions[0].slots.main.content || "";
                }

                return page.revisions[0].content || "";
            });
        }

        function parseCategories(wikitext) {
            var categories = [];
            var regex = /\[\[Category:([^\]|]+)(?:\|[^\]]*)?\]\]/gi;
            var match;

            while ((match = regex.exec(wikitext)) !== null) {
                categories.push(match[1].trim());
            }

            return categories;
        }

        function parseItemBox(wikitext, fallbackName) {
            var match = wikitext.match(/\{\{ItemBox([\s\S]*?)\}\}/i);
            if (!match) return null;

            var content = match[1];
            var item = { name: fallbackName };
            var lines = content.split("\n");

            lines.forEach(function (line) {
                line = line.trim();
                if (line.indexOf("|") !== 0) return;

                var equalIndex = line.indexOf("=");
                if (equalIndex === -1) return;

                var key = line.substring(1, equalIndex).trim().toLowerCase();
                var value = line.substring(equalIndex + 1).trim();

                if (!key) return;

                item[key] = value;
            });

            if (!item.name) item.name = fallbackName;

            item.categories = parseCategories(wikitext);

            item.is2HD = item.categories.indexOf("2HD Items") !== -1 ||
                         String(item.weaponstyle || "").toUpperCase() === "2HD";

            item.isShield = item.categories.indexOf("Shield Items") !== -1 ||
                            String(item.slot || "").toLowerCase() === "shield";

            item.isHeld = item.categories.indexOf("Held Items") !== -1 ||
                          String(item.slot || "").toLowerCase() === "held";

            item.isSecondary = item.categories.indexOf("Secondary Items") !== -1 ||
                               String(item.slot || "").toLowerCase() === "secondary";

            item.isPrimary = item.categories.indexOf("Primary Items") !== -1 ||
                             String(item.slot || "").toLowerCase() === "primary";

            statKeys.forEach(function (key) {
                if (item[key] !== undefined && item[key] !== "") {
                    var num = parseFloat(String(item[key]).replace(/[^\d.-]/g, ""));
                    item[key] = isNaN(num) ? 0 : num;
                } else {
                    item[key] = 0;
                }
            });

            textKeys.forEach(function (key) {
                if (item[key] === undefined) {
                    item[key] = "";
                }
            });

            if (item.class) {
                item.classList = item.class.split(",").map(function (x) {
                    return x.trim().toUpperCase();
                });
            } else {
                item.classList = [];
            }

            return item;
        }

        function classCanUseItem(item, classCode) {
            if (!item.classList || item.classList.length === 0) return true;
            if (item.classList.indexOf("ALL") !== -1) return true;
            return item.classList.indexOf(classCode) !== -1;
        }

        function levelCanUseItem(item) {
            var characterLevel = getCharacterLevel();
            var itemLevel = parseInt(item.lvl, 10) || 0;
            if (itemLevel <= 0) return true;
            return itemLevel <= characterLevel;
        }

        function dedupePages(pages) {
            var seen = {};
            var result = [];

            pages.forEach(function (page) {
                if (!seen[page.title]) {
                    seen[page.title] = true;
                    result.push(page);
                }
            });

            return result;
        }

        function loadItemsForSlotType(slotType) {
            if (itemCache[slotType]) {
                return Promise.resolve(itemCache[slotType]);
            }

            var categories = getCategoriesForSlotType(slotType);

            var categoryPromises = categories.map(function (categoryName) {
                return getPagesInCategory(categoryName);
            });

            return Promise.all(categoryPromises).then(function (categoryResults) {
                var pages = [];

                categoryResults.forEach(function (result) {
                    pages = pages.concat(result);
                });

                pages = dedupePages(pages);

                var pagePromises = pages.map(function (page) {
                    return getPageWikitext(page.title).then(function (wikitext) {
                        return parseItemBox(wikitext, page.title);
                    });
                });

                return Promise.all(pagePromises).then(function (items) {
                    items = items.filter(function (item) {
                        return item !== null;
                    });

                    itemCache[slotType] = items;
                    return items;
                });
            });
        }

        function loadAllSlotItems() {
            loadingMessage.textContent = "Loading item categories...";

            var uniqueSlotTypes = [];

            gearSlots.forEach(function (slot) {
                var slotType = getBuilderSlotType(slot);
                if (uniqueSlotTypes.indexOf(slotType) === -1) {
                    uniqueSlotTypes.push(slotType);
                }
            });

            var promises = uniqueSlotTypes.map(function (slotType) {
                return loadItemsForSlotType(slotType);
            });

            return Promise.all(promises).then(function () {
                loadingMessage.textContent = "Items loaded.";

                if (pendingSharedBuild && !sharedBuildApplied) {
                    applySharedBuildEquipment(pendingSharedBuild);
                    return;
                }

                buildGearSlots();
                updateStats();
            }).catch(function (error) {
                loadingMessage.textContent = "Error loading items.";
                console.error(error);
            });
        }

        function getDisabledSlots() {
            var disabled = {};

            var primary = equippedItems.Primary;
            var forearm = equippedItems.Forearm;
            var bracelet1 = equippedItems.Bracelet1;
            var bracelet2 = equippedItems.Bracelet2;

            if (primary && primary.is2HD) {
                disabled.Secondary = "Disabled because a 2HD weapon is equipped in Primary.";
                disabled.Shield = "Disabled because a 2HD weapon is equipped in Primary.";
                disabled.Held1 = "Disabled because a 2HD weapon is equipped in Primary.";
                disabled.Held2 = "Disabled because a 2HD weapon is equipped in Primary.";
            }

            if (forearm) {
                disabled.Bracelet1 = "Disabled because a Forearm item is equipped.";
                disabled.Bracelet2 = "Disabled because a Forearm item is equipped.";
            }

            if (bracelet1 || bracelet2) {
                disabled.Forearm = "Disabled because Bracelet1 or Bracelet2 is equipped.";
            }

            return disabled;
        }

        function enforceDisabledSlots() {
            var disabled = getDisabledSlots();

            Object.keys(disabled).forEach(function (slot) {
                if (equippedItems[slot]) {
                    delete equippedItems[slot];
                }
            });
        }

        function loadClasses(preserveEquipped) {
            classSelect.innerHTML = '';

            var race = raceSelect.value;

            Object.keys(data.baseStats[race]).forEach(function (classCode) {
                var className = data.classes[classCode] || classCode;
                addOption(classSelect, classCode, classCode + ' - ' + className);
            });

            if (!preserveEquipped) {
                equippedItems = {};
            }

            buildGearSlots();
            updateStats();
        }

        function buildGearSlots() {
            gearGrid.innerHTML = '';

            var disabledSlots = getDisabledSlots();

            gearSlots.forEach(function (slot) {
                var slotBox = document.createElement('div');
                slotBox.className = 'character-builder-slot';


                if (disabledSlots[slot]) {
                    slotBox.className += ' cb-slot-disabled';
                }

                slotBox.innerHTML =
                    '<label>' + slot + '</label>' +
                    '<input type="text" class="cb-slot-search" id="cb-search-' + slot.toLowerCase() + '" placeholder="Search ' + slot + '">' +
                    '<div class="cb-item-picker-list" id="cb-picker-' + slot.toLowerCase() + '"></div>' +
                    '<div class="cb-slot-disabled-note" id="cb-disabled-note-' + slot.toLowerCase() + '"></div>';

                gearGrid.appendChild(slotBox);

                populateItemPicker(slot, disabledSlots[slot]);
            });

            updateEquippedSummary();
        }

        function getFilteredItemsForSlot(slot, searchText) {
            var classCode = classSelect.value;
            var slotType = getBuilderSlotType(slot);
            var items = itemCache[slotType] || [];

            searchText = (searchText || "").toLowerCase();

            return items.filter(function (item) {
                if (!classCanUseItem(item, classCode)) return false;
                if (!levelCanUseItem(item)) return false;

                if (slot === "Primary") {
                    if (!(item.isPrimary || item.isSecondary)) return false;
                }

                if (slot === "Secondary") {
                    if (!item.isSecondary) return false;
                }

                if (searchText && item.name.toLowerCase().indexOf(searchText) === -1) {
                    return false;
                }

                return true;
            });
        }

        function populateItemPicker(slot, disabledReason) {
            var picker = document.getElementById('cb-picker-' + slot.toLowerCase());
            var searchInput = document.getElementById('cb-search-' + slot.toLowerCase());
            var disabledNote = document.getElementById('cb-disabled-note-' + slot.toLowerCase());

            if (!picker) return;

            if (disabledReason) {
                if (searchInput) searchInput.disabled = true;
                picker.innerHTML = '<div class="cb-picker-disabled">Disabled</div>';
                if (disabledNote) disabledNote.textContent = disabledReason;
                return;
            }

            function rebuildPicker() {
                var searchText = searchInput ? searchInput.value : "";
                var filteredItems = getFilteredItemsForSlot(slot, searchText);
                var currentItem = equippedItems[slot];

                picker.innerHTML = '';

                var noneRow = document.createElement('div');
                noneRow.className = 'cb-picker-item cb-picker-none';
                noneRow.textContent = '-- None --';

                if (!currentItem) {
                    noneRow.className += ' cb-picker-selected';
                }

                noneRow.addEventListener('click', function () {
                    delete equippedItems[slot];
                    hideTooltip();
                    hideCompareTooltip();
                    enforceDisabledSlots();
                    buildGearSlots();
                    updateStats();
                });

                picker.appendChild(noneRow);

                filteredItems.forEach(function (item) {
                    var row = document.createElement('div');
                    row.className = 'cb-picker-item';

                    if (currentItem === item) {
                        row.className += ' cb-picker-selected';
                    }

                    var itemNameSpan = document.createElement('span');
itemNameSpan.textContent = item.name + (item.lvl ? ' (Lv ' + item.lvl + ')' : '');

var itemPageLink = document.createElement('span');
itemPageLink.textContent = ' Γåù';
itemPageLink.title = 'Open item page';
itemPageLink.className = 'cb-item-page-link';

itemPageLink.addEventListener('click', function (event) {
    event.stopPropagation();
    openItemPage(item.name);
});

row.appendChild(itemNameSpan);
row.appendChild(itemPageLink);

                    row.addEventListener('mouseenter', function (event) {
                        showCompareTooltip(slot, item, event);
                    });

                    row.addEventListener('mousemove', function (event) {
                        moveCompareTooltip(event);
                    });

                    row.addEventListener('mouseleave', function () {
                        hideCompareTooltip();
                    });

                    row.addEventListener('click', function () {
                        equippedItems[slot] = item;
                        hideTooltip();
                        hideCompareTooltip();
                        enforceDisabledSlots();
                        buildGearSlots();
                        updateStats();
                    });

                    picker.appendChild(row);
                });
            }

            rebuildPicker();

            if (searchInput) {
                searchInput.addEventListener('input', rebuildPicker);
            }
        }

        function validateEquipment() {
            var warnings = [];

            var primary = equippedItems.Primary;
            var secondary = equippedItems.Secondary;
            var shield = equippedItems.Shield;
            var held1 = equippedItems.Held1;
            var held2 = equippedItems.Held2;
            var forearm = equippedItems.Forearm;
            var bracelet1 = equippedItems.Bracelet1;
            var bracelet2 = equippedItems.Bracelet2;

            if (forearm && (bracelet1 || bracelet2)) {
                warnings.push("Forearm item is equipped with Bracelet1/Bracelet2. Use either 1 Forearm or 2 Bracelets.");
            }

            if (primary && primary.is2HD) {
                if (secondary) warnings.push("2HD weapon in Primary conflicts with Secondary.");
                if (shield) warnings.push("2HD weapon in Primary conflicts with Shield.");
                if (held1 || held2) warnings.push("2HD weapon in Primary conflicts with Held1/Held2.");
            }

            if (shield && secondary) {
                warnings.push("Shield conflicts with Secondary weapon.");
            }

            if (shield && (held1 || held2)) {
                warnings.push("Shield conflicts with Held1/Held2.");
            }

            if ((held1 || held2) && (primary || secondary || shield)) {
                warnings.push("Held items may conflict with Primary / Secondary / Shield. Confirm this setup is valid.");
            }

            return warnings;
        }

        function updateWarnings() {
            var warnings = validateEquipment();
            var disabledSlots = getDisabledSlots();

            Object.keys(disabledSlots).forEach(function (slot) {
                warnings.push(slot + " is " + disabledSlots[slot]);
            });

            if (!warningBox) return;

            if (warnings.length === 0) {
                warningBox.style.display = "none";
                warningBox.innerHTML = "";
                return;
            }

            warningBox.style.display = "block";
            warningBox.innerHTML =
                "<b>Equipment Warnings</b><br>" +
                warnings.map(function (w) {
                    return "ΓÇó " + w;
                }).join("<br>");
        }

        function formatItemStats(item) {
            var rows = [];

            function add(label, key) {
                if (item[key] !== undefined && item[key] !== 0 && item[key] !== "") {
                    rows.push('<div><span>' + label + '</span> ' + item[key] + '</div>');
                }
            }

            add("Lvl:", "lvl");
            add("DMG:", "dmg");
            add("AC:", "ac");
            add("STR:", "str");
            add("STA:", "sta");
            add("AGI:", "agi");
            add("DEX:", "dex");
            add("WIS:", "wis");
            add("INT:", "int");
            add("CHA:", "cha");
            add("HP:", "hp");
            add("HoT:", "hot");
            add("POW:", "pow");
            add("PoT:", "pot");
            add("PR:", "pr");
            add("DR:", "dr");
            add("FR:", "fr");
            add("CR:", "cr");
            add("LR:", "lr");
            add("AR:", "ar");
            add("DUR:", "dur");

            return rows.join("");
        }

        function buildTooltipHtml(item) {
            var classText = item.class || "";
            var slotText = item.slot || "";

            var html = '';
            html += '<div class="cb-tooltip-title">' + item.name + '</div>';

            if (slotText) html += '<div class="cb-tooltip-line"><b>Slot:</b> ' + slotText + '</div>';
            if (classText) html += '<div class="cb-tooltip-line"><b>Class:</b> ' + classText + '</div>';
            if (item.is2HD) html += '<div class="cb-tooltip-line"><b>Type:</b> 2HD Weapon</div>';
            if (item.weaponstyle) html += '<div class="cb-tooltip-line"><b>Style:</b> ' + item.weaponstyle + '</div>';
            if (item.proc) html += '<div class="cb-tooltip-line"><b>Proc:</b> ' + item.proc + '</div>';
            if (item.effect) html += '<div class="cb-tooltip-line"><b>Effect:</b> ' + item.effect + '</div>';

            html += '<div class="cb-tooltip-stats">' + formatItemStats(item) + '</div>';

            return html;
        }

        function getCompareValue(newItem, oldItem, key) {
            var newValue = parseFloat(newItem[key]) || 0;
            var oldValue = oldItem ? (parseFloat(oldItem[key]) || 0) : 0;
            return newValue - oldValue;
        }

        function buildCompareTooltipHtml(slot, newItem) {
            var oldItem = equippedItems[slot];
            var html = '';

            html += '<div class="cb-tooltip-title">' + newItem.name + '</div>';

            if (oldItem) {
                html += '<div class="cb-tooltip-line"><b>Currently:</b> ' + oldItem.name + '</div>';
            } else {
                html += '<div class="cb-tooltip-line"><b>Currently:</b> None</div>';
            }

            if (newItem.slot) {
                html += '<div class="cb-tooltip-line"><b>Slot:</b> ' + newItem.slot + '</div>';
            }

            if (newItem.class) {
                html += '<div class="cb-tooltip-line"><b>Class:</b> ' + newItem.class + '</div>';
            }

            if (newItem.is2HD) {
                html += '<div class="cb-tooltip-line"><b>Type:</b> 2HD Weapon</div>';
            }

            if (newItem.weaponstyle) {
                html += '<div class="cb-tooltip-line"><b>Style:</b> ' + newItem.weaponstyle + '</div>';
            }

            if (newItem.proc) {
                html += '<div class="cb-tooltip-line"><b>Proc:</b> ' + newItem.proc + '</div>';
            }

            if (newItem.effect) {
                html += '<div class="cb-tooltip-line"><b>Effect:</b> ' + newItem.effect + '</div>';
            }

            var compareRows = [];

            function addCompare(label, key) {
                var diff = getCompareValue(newItem, oldItem, key);
                if (diff === 0) return;

                var cls = diff > 0 ? 'cb-compare-positive' : 'cb-compare-negative';
                var sign = diff > 0 ? '+' : '';

                compareRows.push(
                    '<div><span>' + label + '</span> <b class="' + cls + '">' + sign + diff + '</b></div>'
                );
            }

            addCompare("Lvl:", "lvl");
            addCompare("DMG:", "dmg");
            addCompare("AC:", "ac");
            addCompare("STR:", "str");
            addCompare("STA:", "sta");
            addCompare("AGI:", "agi");
            addCompare("DEX:", "dex");
            addCompare("WIS:", "wis");
            addCompare("INT:", "int");
            addCompare("CHA:", "cha");
            addCompare("HP:", "hp");
            addCompare("HoT:", "hot");
            addCompare("POW:", "pow");
            addCompare("PoT:", "pot");
            addCompare("PR:", "pr");
            addCompare("DR:", "dr");
            addCompare("FR:", "fr");
            addCompare("CR:", "cr");
            addCompare("LR:", "lr");
            addCompare("AR:", "ar");
            addCompare("DUR:", "dur");

            html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Comparison</b></div>';

            if (compareRows.length) {
                html += '<div class="cb-tooltip-stats">' + compareRows.join("") + '</div>';
            } else {
                html += '<div class="cb-tooltip-line">No stat difference.</div>';
            }

            html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Item Stats</b></div>';
            html += '<div class="cb-tooltip-stats">' + formatItemStats(newItem) + '</div>';

            return html;
        }

        function showTooltip(item, event) {
            if (!tooltip || !item) return;
            tooltip.innerHTML = buildTooltipHtml(item);
            tooltip.style.display = 'block';
            moveTooltip(event);
        }

        function moveTooltip(event) {
            if (!tooltip || tooltip.style.display === 'none') return;
            tooltip.style.left = (event.pageX + 14) + 'px';
            tooltip.style.top = (event.pageY + 14) + 'px';
        }

        function hideTooltip() {
            if (!tooltip) return;
            tooltip.style.display = 'none';
        }

        function showCompareTooltip(slot, item, event) {
            if (!compareTooltip || !item) return;
            compareTooltip.innerHTML = buildCompareTooltipHtml(slot, item);
            compareTooltip.style.display = 'block';
            moveCompareTooltip(event);
        }

        function moveCompareTooltip(event) {
            if (!compareTooltip || compareTooltip.style.display === 'none') return;
            compareTooltip.style.left = (event.pageX + 14) + 'px';
            compareTooltip.style.top = (event.pageY + 14) + 'px';
        }

        function hideCompareTooltip() {
            if (!compareTooltip) return;
            compareTooltip.style.display = 'none';
        }

        function updateEquippedSummary() {
            if (!equippedSummaryBody) return;

            equippedSummaryBody.innerHTML = '';

            gearSlots.forEach(function (slot) {
                var item = equippedItems[slot];
                var row = document.createElement('tr');

                var slotCell = document.createElement('td');
                slotCell.textContent = slot;
                slotCell.style.fontWeight = 'bold';

                var itemCell = document.createElement('td');

                if (item) {
                    var itemSpan = document.createElement('span');
                    itemSpan.textContent = item.name;
itemSpan.className = 'cb-equipped-item-name';
itemSpan.style.cursor = 'pointer';

itemSpan.addEventListener('click', function () {
    window.open(
        mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(item.name)),
        '_blank'
    );
});

                    itemSpan.addEventListener('mouseenter', function (event) {
                        showTooltip(item, event);
                    });

                    itemSpan.addEventListener('mousemove', moveTooltip);
                    itemSpan.addEventListener('mouseleave', hideTooltip);

                    itemCell.appendChild(itemSpan);
                } else {
                    itemCell.textContent = '-';
                }

                row.appendChild(slotCell);
                row.appendChild(itemCell);
                equippedSummaryBody.appendChild(row);
            });
        }

        function updateWeaponSummary() {
            function setWeaponRow(slotName, prefix) {
                var item = equippedItems[slotName];

                var nameEl = document.getElementById(prefix + '-name');
                var dmgEl = document.getElementById(prefix + '-dmg');
                var styleEl = document.getElementById(prefix + '-style');
                var effectEl = document.getElementById(prefix + '-effect');

                if (!nameEl || !dmgEl || !styleEl || !effectEl) return;

                nameEl.innerHTML = '';

if (item) {
    var link = document.createElement('span');
    link.textContent = item.name;
    link.className = 'cb-equipped-item-name';
    link.style.cursor = 'pointer';

    link.addEventListener('click', function () {
        window.open(
            mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(item.name)),
            '_blank'
        );
    });

    nameEl.appendChild(link);
} else {
    nameEl.textContent = '-';
}
                dmgEl.textContent = item && item.dmg ? item.dmg : '-';

                if (item) {
                    if (item.weaponstyle) {
                        styleEl.textContent = item.weaponstyle;
                    } else if (item.is2HD) {
                        styleEl.textContent = '2HD';
                    } else if (item.isPrimary || item.isSecondary) {
                        styleEl.textContent = '1HD';
                    } else {
                        styleEl.textContent = '-';
                    }
                } else {
                    styleEl.textContent = '-';
                }

                var effectText = '-';

                if (item) {
                    if (item.proc) {
                        effectText = item.proc;
                    } else if (item.effect) {
                        effectText = item.effect;
                    }
                }

                effectEl.textContent = effectText;
            }

            setWeaponRow('Primary', 'cb-weapon-primary');
            setWeaponRow('Secondary', 'cb-weapon-secondary');
            setWeaponRow('Range', 'cb-weapon-range');
        }

        function getBaseStats() {
            var race = raceSelect.value;
            var classCode = classSelect.value;
            return data.baseStats[race][classCode];
        }

        function getSpentTrainingPoints() {
            var total = 0;

            Object.keys(trainingStats).forEach(function (key) {
                total += parseInt(trainingStats[key], 10) || 0;
            });

            return total;
        }

        function clampTrainingInput(changedKey) {
            var level = getCharacterLevel();
            var availableTP = getTrainingPointsForLevel(level);
            var spentOther = 0;

            Object.keys(trainingStats).forEach(function (key) {
                if (key !== changedKey) {
                    spentOther += parseInt(trainingStats[key], 10) || 0;
                }
            });

            var input = document.getElementById('train-' + changedKey);
            var value = parseInt(input.value, 10);

            if (isNaN(value) || value < 0) value = 0;

            var maxAllowed = availableTP - spentOther;
            if (maxAllowed < 0) maxAllowed = 0;

            if (value > maxAllowed) {
                value = maxAllowed;
                input.value = value;
            }

            trainingStats[changedKey] = value;
        }

        function addStat(total, item, key) {
            total[key] += parseFloat(item[key]) || 0;
        }

        function setCappedStat(elementId, value, cap) {
            var element = document.getElementById(elementId);
            if (!element) return;

            value = parseFloat(value) || 0;

            if (value > cap) {
                var over = value - cap;
                element.innerHTML = cap + ' <span class="cb-overcap">(+' + over + ')</span>';
            } else {
                element.textContent = value;
            }
        }

        function updateStats() {
            var base = getBaseStats();
            if (!base) return;

            var level = getCharacterLevel();

            var total = {
                str: base.str || 0,
                sta: base.sta || 0,
                agi: base.agi || 0,
                dex: base.dex || 0,
                wis: base.wis || 0,
                int: base.int || 0,
                cha: base.cha || 0,
                hp: base.hp || 0,
                pow: base.pow || 0,
                ac: base.ac || 0,
                pr: 0,
                dr: 0,
                fr: 0,
                cr: 0,
                lr: 0,
                ar: 0
            };

            Object.keys(trainingStats).forEach(function (key) {
                total[key] += parseInt(trainingStats[key], 10) || 0;
            });

            Object.keys(equippedItems).forEach(function (slot) {
                var item = equippedItems[slot];

                addStat(total, item, "str");
                addStat(total, item, "sta");
                addStat(total, item, "agi");
                addStat(total, item, "dex");
                addStat(total, item, "wis");
                addStat(total, item, "int");
                addStat(total, item, "cha");
                addStat(total, item, "hp");
                addStat(total, item, "pow");
                addStat(total, item, "ac");
                addStat(total, item, "pr");
                addStat(total, item, "dr");
                addStat(total, item, "fr");
                addStat(total, item, "cr");
                addStat(total, item, "lr");
                addStat(total, item, "ar");
            });

            var availableTP = getTrainingPointsForLevel(level);
            var spentTP = getSpentTrainingPoints();
            var remainingTP = availableTP - spentTP;

            if (remainingTP < 0) remainingTP = 0;

            document.getElementById('cb-tp-available').textContent = availableTP;
            document.getElementById('cb-tp-spent').textContent = spentTP;
            document.getElementById('cb-tp-remaining').textContent = remainingTP;

            setCappedStat('cb-str', total.str, 650);
            setCappedStat('cb-sta', total.sta, 650);
            setCappedStat('cb-agi', total.agi, 650);
            setCappedStat('cb-dex', total.dex, 650);
            setCappedStat('cb-wis', total.wis, 650);
            setCappedStat('cb-int', total.int, 650);
            setCappedStat('cb-cha', total.cha, 650);

            document.getElementById('cb-hp').textContent = total.hp;
            document.getElementById('cb-pow').textContent = total.pow;
            document.getElementById('cb-ac').textContent = total.ac;

            setCappedStat('cb-pr', total.pr, 315);
            setCappedStat('cb-dr', total.dr, 315);
            setCappedStat('cb-fr', total.fr, 315);
            setCappedStat('cb-cr', total.cr, 315);
            setCappedStat('cb-lr', total.lr, 315);
            setCappedStat('cb-ar', total.ar, 315);

            updateEquippedSummary();
            updateWeaponSummary();
            updateWarnings();
        }

        function removeInvalidEquippedItemsForLevel() {
            Object.keys(equippedItems).forEach(function (slot) {
                if (!levelCanUseItem(equippedItems[slot])) {
                    delete equippedItems[slot];
                }
            });
        }

        function bindTrainingInputs() {
            Object.keys(trainingStats).forEach(function (key) {
                var input = document.getElementById('train-' + key);

                input.addEventListener('input', function () {
                    clampTrainingInput(key);
                    updateStats();
                });
            });
        }

        raceSelect.addEventListener('change', loadClasses);

        classSelect.addEventListener('change', function () {
            equippedItems = {};
            buildGearSlots();
            updateStats();
        });

        levelInput.addEventListener('input', function () {
            Object.keys(trainingStats).forEach(function (key) {
                clampTrainingInput(key);
            });

            removeInvalidEquippedItemsForLevel();
            enforceDisabledSlots();
            buildGearSlots();
            updateStats();
        });

        refreshButton.addEventListener('click', function () {
            itemCache = {};
            equippedItems = {};
            pendingSharedBuild = null;
            sharedBuildApplied = false;
            hideTooltip();
            hideCompareTooltip();
            loadAllSlotItems();
        });

        if (generateShareButton) {
            generateShareButton.addEventListener('click', generateShareUrl);
        }

        if (copyShareButton) {
            copyShareButton.addEventListener('click', copyShareUrl);
        }

        loadClasses();
        bindTrainingInputs();
        tryLoadSharedBuildFromUrl();
        loadAllSlotItems();
        updateStats();
    }

});

function openItemPage(itemName) {
    window.open(
        mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(itemName)),
        '_blank'
    );
}