MediaWiki:ClassMasteryCalculator.js: Difference between revisions

From EQOA Sandstorm Wiki
Jump to navigationJump to search
No edit summary
No edit summary
Line 31: Line 31:
         var showLockedCms = true;
         var showLockedCms = true;
         var visibleMasteries = [];
         var visibleMasteries = [];
var isEmbeddedInCharacterBuilder = !!window.EQOA_CHARACTER_BUILDER_STATE;
        var isEmbeddedInCharacterBuilder = !!window.EQOA_CHARACTER_BUILDER_STATE;


function getCharacterBuilderState() {
        var BASE_STAT_REQ_FIELDS = {
    if (
            STR: 'Base STR Req',
        window.EQOA_CHARACTER_BUILDER_STATE &&
            STA: 'Base STA Req',
        typeof window.EQOA_CHARACTER_BUILDER_STATE.getState === 'function'
            DEX: 'Base DEX Req',
    ) {
            AGI: 'Base AGI Req',
         return window.EQOA_CHARACTER_BUILDER_STATE.getState();
            INT: 'Base INT Req',
    }
            WIS: 'Base WIS Req',
            CHA: 'Base CHA Req'
         };


    return null;
        function safeArray(value) {
}
            return Object.prototype.toString.call(value) === '[object Array]' ? value : [];
        }


var BASE_STAT_REQ_FIELDS = {
        function numberOrZero(value) {
    STR: 'Base STR Req',
            var n = Number(String(value || '').trim());
    STA: 'Base STA Req',
            return isFinite(n) ? n : 0;
    DEX: 'Base DEX Req',
        }
    AGI: 'Base AGI Req',
    INT: 'Base INT Req',
    WIS: 'Base WIS Req',
    CHA: 'Base CHA Req'
};


function numberOrZero(value) {
        function escapeHtml(value) {
    var n = Number(String(value || '').trim());
            return String(value == null ? '' : value)
    return Number.isFinite(n) ? n : 0;
                .replace(/&/g, '&')
}
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#039;');
        }


        function getCharacterBuilderState() {
            if (
                window.EQOA_CHARACTER_BUILDER_STATE &&
                typeof window.EQOA_CHARACTER_BUILDER_STATE.getState === 'function'
            ) {
                return window.EQOA_CHARACTER_BUILDER_STATE.getState();
            }


            return null;
        }


        function addSelectedCmBaseStatGains(baseStats) {
            var result = {
                STR: numberOrZero(baseStats.STR),
                STA: numberOrZero(baseStats.STA),
                DEX: numberOrZero(baseStats.DEX),
                AGI: numberOrZero(baseStats.AGI),
                INT: numberOrZero(baseStats.INT),
                WIS: numberOrZero(baseStats.WIS),
                CHA: numberOrZero(baseStats.CHA)
            };


function addSelectedCmBaseStatGains(baseStats) {
            Object.keys(selectedMasteries).forEach(function (name) {
    var result = {
                var cm = selectedMasteries[name];
        STR: numberOrZero(baseStats.STR),
                var stats = cm && cm.stats ? cm.stats : {};
        STA: numberOrZero(baseStats.STA),
        DEX: numberOrZero(baseStats.DEX),
        AGI: numberOrZero(baseStats.AGI),
        INT: numberOrZero(baseStats.INT),
        WIS: numberOrZero(baseStats.WIS),
        CHA: numberOrZero(baseStats.CHA)
    };


    Object.keys(selectedMasteries).forEach(function (name) {
                result.STR += numberOrZero(stats.str);
        var cm = selectedMasteries[name];
                result.STA += numberOrZero(stats.sta);
        var stats = cm && cm.stats ? cm.stats : {};
                result.DEX += numberOrZero(stats.dex);
                result.AGI += numberOrZero(stats.agi);
                result.INT += numberOrZero(stats.int);
                result.WIS += numberOrZero(stats.wis);
                result.CHA += numberOrZero(stats.cha);
            });


        result.STR += numberOrZero(stats.str);
            return result;
         result.STA += numberOrZero(stats.sta);
         }
        result.DEX += numberOrZero(stats.dex);
        result.AGI += numberOrZero(stats.agi);
        result.INT += numberOrZero(stats.int);
        result.WIS += numberOrZero(stats.wis);
        result.CHA += numberOrZero(stats.cha);
    });


    return result;
        function updateBaseStatInputsDisplay(baseStats) {
}
            var map = {
                STR: 'cm-base-str',
                STA: 'cm-base-sta',
                DEX: 'cm-base-dex',
                AGI: 'cm-base-agi',
                INT: 'cm-base-int',
                WIS: 'cm-base-wis',
                CHA: 'cm-base-cha'
            };


function updateBaseStatInputsDisplay(baseStats) {
            Object.keys(map).forEach(function (stat) {
    var map = {
                var input = document.getElementById(map[stat]);
        STR: 'cm-base-str',
        STA: 'cm-base-sta',
        DEX: 'cm-base-dex',
        AGI: 'cm-base-agi',
        INT: 'cm-base-int',
        WIS: 'cm-base-wis',
        CHA: 'cm-base-cha'
    };


    Object.keys(map).forEach(function (stat) {
                if (input) {
        var input = document.getElementById(map[stat]);
                    input.value = numberOrZero(baseStats[stat]);
 
                }
        if (input) {
            });
            input.value = numberOrZero(baseStats[stat]);
         }
         }
    });
}


        function getBaseStatsFromInputs() {
            var builderState = getCharacterBuilderState();
            var baseStats;


function getBaseStatsFromInputs() {
            if (builderState && builderState.baseStats) {
    var builderState = getCharacterBuilderState();
                baseStats = builderState.baseStats;
    var baseStats;
            } else {
                baseStats = {
                    STR: numberOrZero(document.getElementById('cm-base-str') && document.getElementById('cm-base-str').value),
                    STA: numberOrZero(document.getElementById('cm-base-sta') && document.getElementById('cm-base-sta').value),
                    DEX: numberOrZero(document.getElementById('cm-base-dex') && document.getElementById('cm-base-dex').value),
                    AGI: numberOrZero(document.getElementById('cm-base-agi') && document.getElementById('cm-base-agi').value),
                    INT: numberOrZero(document.getElementById('cm-base-int') && document.getElementById('cm-base-int').value),
                    WIS: numberOrZero(document.getElementById('cm-base-wis') && document.getElementById('cm-base-wis').value),
                    CHA: numberOrZero(document.getElementById('cm-base-cha') && document.getElementById('cm-base-cha').value)
                };
            }


    if (builderState && builderState.baseStats) {
            return addSelectedCmBaseStatGains(baseStats);
        baseStats = builderState.baseStats;
         }
    } else {
         baseStats = {
            STR: numberOrZero(document.getElementById('cm-base-str') && document.getElementById('cm-base-str').value),
            STA: numberOrZero(document.getElementById('cm-base-sta') && document.getElementById('cm-base-sta').value),
            DEX: numberOrZero(document.getElementById('cm-base-dex') && document.getElementById('cm-base-dex').value),
            AGI: numberOrZero(document.getElementById('cm-base-agi') && document.getElementById('cm-base-agi').value),
            INT: numberOrZero(document.getElementById('cm-base-int') && document.getElementById('cm-base-int').value),
            WIS: numberOrZero(document.getElementById('cm-base-wis') && document.getElementById('cm-base-wis').value),
            CHA: numberOrZero(document.getElementById('cm-base-cha') && document.getElementById('cm-base-cha').value)
        };
    }


    return addSelectedCmBaseStatGains(baseStats);
        function applyRawBaseStatRequirements() {
}
            var raw = window.EQOA_CM_RAW || '';
            var lines = String(raw).split(/\r?\n/);
            var headerLineIndex = -1;
            var headers = [];
            var headerIndex = {};
            var rawRows = [];
            var reqsByName = {};


            function getExemplarPathFromQuest(quest) {
                var text = String(quest || '');
                var match = text.match(/Exemplar-([^=\s]+)=99/i);
                var token;
                var classCodes = [
                    'WAR', 'RAN', 'PAL', 'MNK', 'BRD', 'RGE', 'ROG',
                    'DRD', 'SHA', 'MAG', 'NEC', 'ENC', 'WIZ', 'ALC',
                    'SK', 'CL'
                ];
                var foundClass = '';
                var path = '';


                if (!match) return null;


                token = match[1];


                if (token.indexOf('-') !== -1) {
                    foundClass = token.split('-')[0];
                    path = token.split('-').slice(1).join('-');
                } else {
                    classCodes.some(function (classCode) {
                        if (token.toUpperCase().indexOf(classCode) === 0) {
                            foundClass = classCode;
                            path = token.slice(classCode.length);
                            return true;
                        }


                        return false;
                    });
                }


                if (!foundClass || !path) return null;


                return {
                    classCode: foundClass.toUpperCase(),
                    path: path,
                    quest: match[0]
                };
            }


            lines.forEach(function (line, index) {
                if (headerLineIndex === -1 && line.indexOf('Mastery\tDescription') === 0) {
                    headerLineIndex = index;
                }
            });


            if (headerLineIndex === -1) {
                return;
            }


function applyRawBaseStatRequirements() {
            headers = lines[headerLineIndex].split('\t');
    var raw = window.EQOA_CM_RAW || '';
    var lines = String(raw).split(/\r?\n/);
    var headerLineIndex = -1;
    var headers = [];
    var headerIndex = {};
    var rawRows = [];
    var reqsByName = {};


            headers.forEach(function (header, index) {
                headerIndex[String(header).trim()] = index;
            });


            lines.slice(headerLineIndex + 1).forEach(function (line) {
                var cols;
                var name;
                var quest;
                var exemplarPath;
                var reqs = {};


                if (!line.trim()) return;


                cols = line.split('\t');
                name = cols[headerIndex.Mastery];
                quest = headerIndex.Quest === undefined ? '' : cols[headerIndex.Quest];


                if (!name) return;


                Object.keys(BASE_STAT_REQ_FIELDS).forEach(function (stat) {
                    var field = BASE_STAT_REQ_FIELDS[stat];
                    var columnIndex = headerIndex[field];
                    var value;


    function getExemplarPathFromQuest(quest) {
                    if (columnIndex === undefined) return;
    var text = String(quest || '');
    var match = text.match(/Exemplar-([^=\s]+)=99/i);
    var token;
    var classCodes = [
        'WAR',
        'RAN',
        'PAL',
        'MNK',
        'BRD',
        'RGE',
        'ROG',
        'DRD',
        'SHA',
        'MAG',
        'NEC',
        'ENC',
        'WIZ',
        'ALC',
        'SK',
        'CL'
    ];
    var foundClass = '';
    var path = '';


    if (!match) return null;
                    value = numberOrZero(cols[columnIndex]);


    token = match[1];
                    if (value > 0) {
                        reqs[stat] = value;
                    }
                });


    if (token.indexOf('-') !== -1) {
                 exemplarPath = getExemplarPathFromQuest(quest);
        foundClass = token.split('-')[0];
        path = token.split('-').slice(1).join('-');
    } else {
        classCodes.some(function (classCode) {
            if (token.toUpperCase().indexOf(classCode) === 0) {
                foundClass = classCode;
                 path = token.slice(classCode.length);
                return true;
            }


            return false;
                rawRows.push({
        });
                    name: name,
    }
                    reqs: reqs,
                    quest: quest,
                    exemplarPath: exemplarPath
                });


    if (!foundClass || !path) return null;
                if (Object.keys(reqs).length) {
                    if (!reqsByName[name]) {
                        reqsByName[name] = [];
                    }


    return {
                    reqsByName[name].push(reqs);
        classCode: foundClass.toUpperCase(),
                }
        path: path,
            });
        quest: match[0]
    };
}


            safeArray(data.masteries).forEach(function (cm, index) {
                var row;
                var namedReqs;


                if (!cm || !cm.name) return;


                row = rawRows[index];


                if (row && row.name === cm.name) {
                    cm.rawQuest = row.quest || '';


                    if (Object.keys(row.reqs).length) {
                        cm.baseStatReqs = row.reqs;
                    }


    lines.forEach(function (line, index) {
                    if (row.exemplarPath) {
        if (headerLineIndex === -1 && line.indexOf('Mastery\tDescription') === 0) {
                        cm.exemplarPath = row.exemplarPath;
            headerLineIndex = index;
                    }
        }
    });


    if (headerLineIndex === -1) {
                    return;
        return;
                }
    }


    headers = lines[headerLineIndex].split('\t');
                namedReqs = reqsByName[cm.name];


    headers.forEach(function (header, index) {
                if (namedReqs && namedReqs.length) {
        headerIndex[String(header).trim()] = index;
                    cm.baseStatReqs = namedReqs[0];
    });
                }
            });
        }


    lines.slice(headerLineIndex + 1).forEach(function (line) {
        applyRawBaseStatRequirements();
        var cols;
        var name;
        var quest;
        var exemplarPath;
        var reqs = {};


         if (!line.trim()) return;
         app.innerHTML =
            '<div class="cm-calculator-box">' +
                '<div class="character-builder-title" style="display:none;">EQOA: Sandstorm Class Mastery Builder</div>' +


        cols = line.split('\t');
                '<div class="character-builder-controls">' +
        name = cols[headerIndex.Mastery];
                    '<label>Race</label>' +
        quest = headerIndex.Quest === undefined ? '' : cols[headerIndex.Quest];
                    '<select id="cm-race"></select>' +


        if (!name) return;
                    '<label>Class</label>' +
                    '<select id="cm-class"></select>' +


        Object.keys(BASE_STAT_REQ_FIELDS).forEach(function (stat) {
                    '<label>Character Level</label>' +
            var field = BASE_STAT_REQ_FIELDS[stat];
                    '<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +
            var columnIndex = headerIndex[field];
            var value;


            if (columnIndex === undefined) return;
                    '<label>Base Stats</label>' +
                    '<span>' +
                        'STR <input type="number" id="cm-base-str" min="0" value="0" style="width:55px;"> ' +
                        'STA <input type="number" id="cm-base-sta" min="0" value="0" style="width:55px;"> ' +
                        'DEX <input type="number" id="cm-base-dex" min="0" value="0" style="width:55px;"> ' +
                        'AGI <input type="number" id="cm-base-agi" min="0" value="0" style="width:55px;"> ' +
                        'INT <input type="number" id="cm-base-int" min="0" value="0" style="width:55px;"> ' +
                        'WIS <input type="number" id="cm-base-wis" min="0" value="0" style="width:55px;"> ' +
                        'CHA <input type="number" id="cm-base-cha" min="0" value="0" style="width:55px;">' +
                    '</span>' +


            value = numberOrZero(cols[columnIndex]);
                    '<label>Transformation Path</label>' +
 
                    '<select id="cm-path">' +
            if (value > 0) {
                        '<option value="">None</option>' +
                reqs[stat] = value;
                        '<option value="Werelion">Lionwere</option>' +
            }
                        '<option value="Werebear">Bearwere</option>' +
        });
                        '<option value="Werewolf">Wolfwere</option>' +
 
                        '<option value="Wererat">Ratwere</option>' +
        exemplarPath = getExemplarPathFromQuest(quest);
                        '<option value="Weregator">Gatorwere</option>' +
                        '<option value="Vampire">Vampire</option>' +
                        '<option value="Were Hunter">Were-Hunter</option>' +
                    '</select>' +


        rawRows.push({
                    '<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
            name: name,
                '</div>' +
            reqs: reqs,
            quest: quest,
            exemplarPath: exemplarPath
        });


        if (Object.keys(reqs).length) {
                 '<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' +
            if (!reqsByName[name]) {
                 reqsByName[name] = [];
            }


            reqsByName[name].push(reqs);
                '<table class="character-builder-stat-table">' +
        }
                    '<tr>' +
    });
                        '<th>Available</th>' +
                        '<th>Spent</th>' +
                        '<th>Remaining</th>' +
                    '</tr>' +
                    '<tr>' +
                        '<td id="cm-available">1500</td>' +
                        '<td id="cm-spent">0</td>' +
                        '<td id="cm-remaining">1500</td>' +
                    '</tr>' +
                '</table>' +


    safeArray(data.masteries).forEach(function (cm, index) {
                '<div class="character-builder-section-title" style="margin-top:18px;">Master Class Title</div>' +
        var row;
        var namedReqs;


        if (!cm || !cm.name) return;
                '<div id="cm-master-class-panel" class="cm-master-class-panel">' +
                    '<span class="cm-master-class-label">None selected</span>' +
                '</div>' +


        row = rawRows[index];
                '<div class="cm-build-actions">' +
                    '<button type="button" id="cm-share-url" class="cm-action-button">Generate Share URL</button>' +
                    '<button type="button" id="cm-export-card" class="cm-action-button">Export Build Card</button>' +
                '</div>' +


        if (row && row.name === cm.name) {
                '<div id="cm-build-card-panel" class="cm-build-card-panel" style="display:none;"></div>' +
            cm.rawQuest = row.quest || '';


            if (Object.keys(row.reqs).length) {
                 '<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +
                 cm.baseStatReqs = row.reqs;
            }


            if (row.exemplarPath) {
                 '<div id="cm-unlocked-summary">' +
                 cm.exemplarPath = row.exemplarPath;
                    'No stats or spells gained yet.' +
            }
                '</div>' +


            return;
                '<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' +
        }


        namedReqs = reqsByName[cm.name];
                '<div class="cm-tabs">' +
                    '<button type="button" class="cm-tab-button cm-tab-active" data-tab="General">General</button>' +
                    '<button type="button" class="cm-tab-button" data-tab="Racial">Racial</button>' +
                    '<button type="button" class="cm-tab-button" data-tab="Archetype">Archetype</button>' +
                    '<button type="button" class="cm-tab-button" data-tab="Class">Class</button>' +
                '</div>' +


        if (namedReqs && namedReqs.length) {
                '<div class="cm-search-tools">' +
            cm.baseStatReqs = namedReqs[0];
                    '<div class="cm-search-row">' +
        }
                        '<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' +
    });
                        '<button type="button" id="cm-clear-search" class="cm-clear-search-button" title="Clear search">Clear</button>' +
}
                    '</div>' +


                    '<label class="cm-lock-toggle">' +
                        '<input type="checkbox" id="cm-show-locked" checked>' +
                        '<span>Show locked</span>' +
                    '</label>' +
                '</div>' +


                '<div class="cm-view-toggle">' +
                    '<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
                    '<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
                '</div>' +


                '<div class="cm-main-layout">' +
                    '<div class="cm-purchased-panel">' +
                        '<div class="cm-panel-title">CM Purchased</div>' +
                        '<div id="cm-purchased-list">None purchased.</div>' +
                    '</div>' +


                    '<div id="cm-mastery-panel" class="character-builder-warning-box">' +
                        'Select a race and class to begin.' +
                    '</div>' +
                '</div>' +


applyRawBaseStatRequirements();
                '<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
            '</div>';


        var raceSelect = document.getElementById('cm-race');
        var classSelect = document.getElementById('cm-class');
        var levelInput = document.getElementById('cm-level');
        var pathSelect = document.getElementById('cm-path');
        var resetButton = document.getElementById('cm-reset');
        var searchInput = document.getElementById('cm-search');
        var clearSearchButton = document.getElementById('cm-clear-search');
        var showLockedInput = document.getElementById('cm-show-locked');
        var viewButtons = document.querySelectorAll('.cm-view-button');
        var cmTooltip = document.getElementById('cm-tooltip');
        var shareUrlButton = document.getElementById('cm-share-url');
        var exportCardButton = document.getElementById('cm-export-card');


         app.innerHTML =
         if (isEmbeddedInCharacterBuilder) {
             '<div class="cm-calculator-box">' +
            if (raceSelect) raceSelect.disabled = true;
                '<div class="character-builder-title" style="display:none;">EQOA: Sandstorm Class Mastery Builder</div>' +
             if (classSelect) classSelect.disabled = true;
            if (levelInput) levelInput.disabled = true;
        }


                 '<div class="character-builder-controls">' +
        function formatStatName(statName) {
                    '<label>Race</label>' +
            var map = {
                    '<select id="cm-race"></select>' +
                ac: 'AC',
                hp: 'HP',
                 pow: 'Power',
                str: 'STR',
                sta: 'STA',
                agi: 'AGI',
                dex: 'DEX',
                wis: 'WIS',
                int: 'INT',
                cha: 'CHA',


                    '<label>Class</label>' +
                STR: 'STR',
                    '<select id="cm-class"></select>' +
                STA: 'STA',
 
                AGI: 'AGI',
                    '<label>Character Level</label>' +
                DEX: 'DEX',
                    '<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +
                WIS: 'WIS',
 
                INT: 'INT',
                    '<label>Base Stats</label>' +
                CHA: 'CHA',
                    '<span>' +
                strMax: 'STR Max',
                        'STR <input type="number" id="cm-base-str" min="0" value="0" style="width:55px;"> ' +
                staMax: 'STA Max',
                        'STA <input type="number" id="cm-base-sta" min="0" value="0" style="width:55px;"> ' +
                agiMax: 'AGI Max',
                        'DEX <input type="number" id="cm-base-dex" min="0" value="0" style="width:55px;"> ' +
                dexMax: 'DEX Max',
                        'AGI <input type="number" id="cm-base-agi" min="0" value="0" style="width:55px;"> ' +
                wisMax: 'WIS Max',
                        'INT <input type="number" id="cm-base-int" min="0" value="0" style="width:55px;"> ' +
                intMax: 'INT Max',
                        'WIS <input type="number" id="cm-base-wis" min="0" value="0" style="width:55px;"> ' +
                chaMax: 'CHA Max',
                        'CHA <input type="number" id="cm-base-cha" min="0" value="0" style="width:55px;">' +
                offMod: 'Offense Mod',
                    '</span>' +
                defMod: 'Defense Mod',
 
                hasteMod: 'Haste Mod',
                    '<label>Transformation Path</label>' +
                regenMod: 'Regen Mod',
                    '<select id="cm-path">' +
                powerRegenMod: 'Power Regen Mod',
                        '<option value="">None</option>' +
                healMod: 'Healing Mod',
                        '<option value="Werelion">Lionwere</option>' +
                spellMod: 'Spell Mod',
                        '<option value="Werebear">Bearwere</option>' +
                meleeMod: 'Melee Mod',
                        '<option value="Werewolf">Wolfwere</option>' +
                fireResist: 'Fire Resist',
                        '<option value="Wererat">Ratwere</option>' +
                coldResist: 'Cold Resist',
                        '<option value="Weregator">Gatorwere</option>' +
                diseaseResist: 'Disease Resist',
                        '<option value="Vampire">Vampire</option>' +
                poisonResist: 'Poison Resist',
                        '<option value="Were Hunter">Were-Hunter</option>' +
                magicResist: 'Magic Resist',
                    '</select>' +
                fireResistMax: 'Fire Resist Max',
                coldResistMax: 'Cold Resist Max',
                diseaseResistMax: 'Disease Resist Max',
                poisonResistMax: 'Poison Resist Max',
                magicResistMax: 'Magic Resist Max'
            };


                    '<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
            if (map[statName]) {
                 '</div>' +
                 return map[statName];
            }


                '<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' +
            return String(statName || '')
 
                 .replace(/([a-z])([A-Z])/g, '$1 $2')
                 '<table class="character-builder-stat-table">' +
                .replace(/^./, function (letter) {
                    '<tr>' +
                     return letter.toUpperCase();
                        '<th>Available</th>' +
                 });
                        '<th>Spent</th>' +
        }
                        '<th>Remaining</th>' +
                    '</tr>' +
                    '<tr>' +
                        '<td id="cm-available">1500</td>' +
                        '<td id="cm-spent">0</td>' +
                        '<td id="cm-remaining">1500</td>' +
                     '</tr>' +
                 '</table>' +


                '<div class="character-builder-section-title" style="margin-top:18px;">Master Class Title</div>' +
        function addOption(select, value, label) {
            var option = document.createElement('option');
            option.value = value;
            option.textContent = label;
            select.appendChild(option);
        }


                '<div id="cm-master-class-panel" class="cm-master-class-panel">' +
        function normalizePath(value) {
                    '<span class="cm-master-class-label">None selected</span>' +
            value = String(value || '').toLowerCase().replace(/[\s-]/g, '');
                '</div>' +


                 '<div class="cm-build-actions">' +
            var map = {
                    '<button type="button" id="cm-share-url" class="cm-action-button">Generate Share URL</button>' +
                lionwere: 'werelion',
                    '<button type="button" id="cm-export-card" class="cm-action-button">Export Build Card</button>' +
                werelion: 'werelion',
                 '</div>' +
                bearwere: 'werebear',
                werebear: 'werebear',
                wolfwere: 'werewolf',
                werewolf: 'werewolf',
                ratwere: 'wererat',
                wererat: 'wererat',
                gatorwere: 'weregator',
                weregator: 'weregator',
                 vampire: 'vampire',
                werehunter: 'werehunter',
                hunterwere: 'werehunter',
                 werebeast: 'werebeast'
            };


                '<div id="cm-build-card-panel" class="cm-build-card-panel" style="display:none;"></div>' +
            return map[value] || value;
        }


                '<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +
        function getBitePath(name) {
            name = String(name || '').toLowerCase();


                '<div id="cm-unlocked-summary">' +
            if (name.indexOf('lion bite') !== -1) return 'werelion';
                    'No stats or spells gained yet.' +
            if (name.indexOf('bear bite') !== -1) return 'werebear';
                '</div>' +
            if (name.indexOf('wolf bite') !== -1) return 'werewolf';
            if (name.indexOf('rat bite') !== -1) return 'wererat';
            if (name.indexOf('gator bite') !== -1) return 'weregator';


                '<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' +
            return '';
        }


                '<div class="cm-tabs">' +
        function isSharedWereMastery(name) {
                    '<button type="button" class="cm-tab-button cm-tab-active" data-tab="General">General</button>' +
            return name === 'Lycanthropic Resistances' ||
                    '<button type="button" class="cm-tab-button" data-tab="Racial">Racial</button>' +
                name === 'Lycanthropic Regeneration' ||
                    '<button type="button" class="cm-tab-button" data-tab="Archetype">Archetype</button>' +
                name === 'Combat Senses';
                    '<button type="button" class="cm-tab-button" data-tab="Class">Class</button>' +
        }
                '</div>' +


                '<div class="cm-search-tools">' +
        function isWerePath(path) {
                    '<div class="cm-search-row">' +
            return path === 'werelion' ||
                        '<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' +
                path === 'werebear' ||
                        '<button type="button" id="cm-clear-search" class="cm-clear-search-button" title="Clear search">Clear</button>' +
                path === 'werewolf' ||
                    '</div>' +
                path === 'wererat' ||
                path === 'weregator';
        }


                    '<label class="cm-lock-toggle">' +
        function isMasterClassTitle(cm) {
                        '<input type="checkbox" id="cm-show-locked" checked>' +
            var name = String((cm && cm.name) || '');
                        '<span>Show locked</span>' +
                    '</label>' +
                '</div>' +


                 '<div class="cm-view-toggle">' +
            return name === 'Berserker' ||
                    '<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
                 name === 'Chosen' ||
                    '<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
                name === 'Deputy' ||
                 '</div>' +
                name === 'Doomseeker' ||
 
                name === 'Immortal' ||
                 '<div class="cm-main-layout">' +
                name === 'Juggernaut' ||
                    '<div class="cm-purchased-panel">' +
                name === 'Mercenary' ||
                        '<div class="cm-panel-title">CM Purchased</div>' +
                 name === 'Savage' ||
                        '<div id="cm-purchased-list">None purchased.</div>' +
                name === 'Scholar' ||
                    '</div>' +
                 name === 'Tinker' ||
 
                name === 'Arcane Were-Hunter' ||
                    '<div id="cm-mastery-panel" class="character-builder-warning-box">' +
                name === 'Divine Were-Hunter' ||
                        'Select a race and class to begin.' +
                name === 'Gypsy Were-Hunter' ||
                    '</div>' +
                name === 'Heroic Were-Hunter' ||
                 '</div>' +
                name === 'Metabolist' ||
 
                name === 'Transmuter' ||
                 '<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
                name === 'Gypsy' ||
            '</div>';
                name === 'Minstrel' ||
 
                name === 'Exorcist' ||
        var raceSelect = document.getElementById('cm-race');
                name === 'Faithful' ||
        var classSelect = document.getElementById('cm-class');
                 name === 'Storm Caller' ||
        var levelInput = document.getElementById('cm-level');
                name === 'Wilding' ||
if (isEmbeddedInCharacterBuilder) {
                 name === 'Animator' ||
    if (raceSelect) raceSelect.disabled = true;
                name === 'Spellbinder' ||
    if (classSelect) classSelect.disabled = true;
                name === 'Aeromancer' ||
    if (levelInput) levelInput.disabled = true;
                name === 'Geomancer' ||
}
                name === 'Hydromancer' ||
 
                name === 'Pyromancer' ||
        var pathSelect = document.getElementById('cm-path');
                name === 'Ninja' ||
        var resetButton = document.getElementById('cm-reset');
                name === 'Sensei' ||
        var searchInput = document.getElementById('cm-search');
                name === 'Defiler' ||
        var clearSearchButton = document.getElementById('cm-clear-search');
                name === 'Lich' ||
        var showLockedInput = document.getElementById('cm-show-locked');
                name === 'Crusader' ||
        var viewButtons = document.querySelectorAll('.cm-view-button');
                name === 'Guardian' ||
        var cmTooltip = document.getElementById('cm-tooltip');
                name === 'Forester' ||
        var shareUrlButton = document.getElementById('cm-share-url');
                name === 'Hunter' ||
        var exportCardButton = document.getElementById('cm-export-card');
                name === 'Assassin' ||
        var buildCardPanel = document.getElementById('cm-build-card-panel');
                name === 'Burglar' ||
 
                name === 'Death Knight' ||
        function safeArray(value) {
                name === 'Slayer' ||
            return Object.prototype.toString.call(value) === '[object Array]' ? value : [];
                name === 'Elder' ||
        }
                name === 'Mystic' ||
 
                name === 'Defender' ||
        function escapeHtml(value) {
                name === 'Hero' ||
            return String(value == null ? '' : value)
                name === 'Channeler' ||
                 .replace(/&/g, '&amp;')
                 name === 'Sorcerer' ||
                 .replace(/</g, '&lt;')
                 name === 'Bravo' ||
                 .replace(/>/g, '&gt;')
                 name === 'Diviner' ||
                 .replace(/"/g, '&quot;')
                 name === 'Sage' ||
                 .replace(/'/g, '&#039;');
                 name === 'Soldier';
         }
         }


         function numberOrZero(value) {
         function getSelectedMasterClassTitle() {
             var n = Number(String(value || '').trim());
             var selectedTitle = '';
             return isFinite(n) ? n : 0;
 
        }
            Object.keys(selectedMasteries).forEach(function (name) {
                if (isMasterClassTitle(selectedMasteries[name])) {
                    selectedTitle = name;
                }
             });


        function getInputNumber(id) {
             return selectedTitle;
            var input = document.getElementById(id);
             return input ? numberOrZero(input.value) : 0;
         }
         }


          
         function getCmStatMinRequirements(cm) {
            var reqs = [];


            if (!cm || !cm.baseStatReqs) return reqs;


            Object.keys(cm.baseStatReqs).forEach(function (statName) {
                var value = numberOrZero(cm.baseStatReqs[statName]);


                if (value > 0) {
                    reqs.push({
                        stat: statName,
                        value: value
                    });
                }
            });
            return reqs;
        }


        function getStatMinRequirementsHtml(cm) {
            var reqs = getCmStatMinRequirements(cm);


function getBaseStatsFromInputs() {
            if (!reqs.length) return '';
    var builderState = getCharacterBuilderState();
    var baseStats;


    if (builderState && builderState.baseStats) {
             return reqs.map(function (req) {
        baseStats = builderState.baseStats;
                return '<span class="cm-stat-min-req">' +
    } else {
                    'Base ' +
        baseStats = {
                    escapeHtml(formatStatName(req.stat)) +
             STR: numberOrZero(document.getElementById('cm-base-str') && document.getElementById('cm-base-str').value),
                    ' ' +
            STA: numberOrZero(document.getElementById('cm-base-sta') && document.getElementById('cm-base-sta').value),
                    escapeHtml(req.value) +
            DEX: numberOrZero(document.getElementById('cm-base-dex') && document.getElementById('cm-base-dex').value),
                '</span>';
            AGI: numberOrZero(document.getElementById('cm-base-agi') && document.getElementById('cm-base-agi').value),
             }).join(', ');
            INT: numberOrZero(document.getElementById('cm-base-int') && document.getElementById('cm-base-int').value),
         }
            WIS: numberOrZero(document.getElementById('cm-base-wis') && document.getElementById('cm-base-wis').value),
             CHA: numberOrZero(document.getElementById('cm-base-cha') && document.getElementById('cm-base-cha').value)
         };
    }


    return addSelectedCmBaseStatGains(baseStats);
        function getCmSearchText(cm) {
}
            var parts = [];
            var stats = cm && cm.stats ? cm.stats : {};
            var statMinReqs;


            if (!cm) return '';


        function formatStatName(statName) {
            parts.push(cm.name || '');
             var map = {
             parts.push(cm.description || '');
                ac: 'AC',
            parts.push(cm.classification || '');
                hp: 'HP',
            parts.push(cm.lycanthropy || '');
                pow: 'Power',
 
                str: 'STR',
            Object.keys(stats).forEach(function (statName) {
                sta: 'STA',
                 parts.push(statName);
                 agi: 'AGI',
                 parts.push(formatStatName(statName));
                 dex: 'DEX',
                 parts.push(stats[statName]);
                 wis: 'WIS',
            });
                int: 'INT',
 
                cha: 'CHA',
            statMinReqs = getCmStatMinRequirements(cm);
                STR: 'STR',
            statMinReqs.forEach(function (req) {
                STA: 'STA',
                 parts.push(req.stat);
                 AGI: 'AGI',
                 parts.push(formatStatName(req.stat));
                 DEX: 'DEX',
                 parts.push(req.value);
                 WIS: 'WIS',
             });
                INT: 'INT',
                CHA: 'CHA',
                strMax: 'STR Max',
                staMax: 'STA Max',
                agiMax: 'AGI Max',
                dexMax: 'DEX Max',
                wisMax: 'WIS Max',
                intMax: 'INT Max',
                chaMax: 'CHA Max',
                offMod: 'Offense Mod',
                defMod: 'Defense Mod',
                hasteMod: 'Haste Mod',
                regenMod: 'Regen Mod',
                powerRegenMod: 'Power Regen Mod',
                healMod: 'Healing Mod',
                spellMod: 'Spell Mod',
                meleeMod: 'Melee Mod',
                fireResist: 'Fire Resist',
                coldResist: 'Cold Resist',
                diseaseResist: 'Disease Resist',
                poisonResist: 'Poison Resist',
                magicResist: 'Magic Resist',
                fireResistMax: 'Fire Resist Max',
                coldResistMax: 'Cold Resist Max',
                diseaseResistMax: 'Disease Resist Max',
                poisonResistMax: 'Poison Resist Max',
                magicResistMax: 'Magic Resist Max'
             };


             if (map[statName]) {
             safeArray(cm.spellsAdded).forEach(function (spell) {
                 return map[statName];
                 parts.push(spell);
             }
             });
 
            safeArray(cm.spellsRemoved).forEach(function (spell) {
                parts.push(spell);
            });
 
            safeArray(cm.requirements).forEach(function (req) {
                parts.push(req);
            });


             return String(statName || '')
             return parts.join(' ').toLowerCase();
                .replace(/([a-z])([A-Z])/g, '$1 $2')
                .replace(/^./, function (letter) {
                    return letter.toUpperCase();
                });
         }
         }


         function addOption(select, value, label) {
         function getRequirementHtml(reqName) {
             var option = document.createElement('option');
             var owned = !!selectedMasteries[reqName];
             option.value = value;
             var className = owned ? 'cm-req-owned' : 'cm-req-missing';
             option.textContent = label;
 
            select.appendChild(option);
             return '<span class="' + className + '">' + escapeHtml(reqName) + '</span>';
         }
         }


         function normalizePath(value) {
         function getRequirementsHtml(cm) {
             value = String(value || '').toLowerCase().replace(/[\s-]/g, '');
             var reqs = safeArray(cm && cm.requirements);


             var map = {
             if (!reqs.length) return '';
                lionwere: 'werelion',
                werelion: 'werelion',
                bearwere: 'werebear',
                werebear: 'werebear',
                wolfwere: 'werewolf',
                werewolf: 'werewolf',
                ratwere: 'wererat',
                wererat: 'wererat',
                gatorwere: 'weregator',
                weregator: 'weregator',
                vampire: 'vampire',
                werehunter: 'werehunter',
                hunterwere: 'werehunter',
                werebeast: 'werebeast'
            };


             return map[value] || value;
             return reqs.map(function (reqName) {
                return getRequirementHtml(reqName);
            }).join(', ');
         }
         }


         function getBitePath(name) {
         function getCmChainBase(name) {
             name = String(name || '').toLowerCase();
             return String(name || '').replace(/\s+\d+$/, '');
        }


            if (name.indexOf('lion bite') !== -1) return 'werelion';
        function getCmChainNumber(name) {
             if (name.indexOf('bear bite') !== -1) return 'werebear';
             var match = String(name || '').match(/\s+(\d+)$/);
            if (name.indexOf('wolf bite') !== -1) return 'werewolf';
            if (name.indexOf('rat bite') !== -1) return 'wererat';
            if (name.indexOf('gator bite') !== -1) return 'weregator';


             return '';
             return match ? parseInt(match[1], 10) : null;
         }
         }


         function isSharedWereMastery(name) {
         function getCmChain(cm) {
             return name === 'Lycanthropic Resistances' ||
             var base;
                name === 'Lycanthropic Regeneration' ||
            var currentNumber;
                name === 'Combat Senses';
            var chain = [];
        }


        function isWerePath(path) {
            if (!cm || !cm.name) return [];
            return path === 'werelion' ||
                path === 'werebear' ||
                path === 'werewolf' ||
                path === 'wererat' ||
                path === 'weregator';
        }


        function isMasterClassTitle(cm) {
            base = getCmChainBase(cm.name);
             var name = String((cm && cm.name) || '');
             currentNumber = getCmChainNumber(cm.name);


             return name === 'Berserker' ||
             if (currentNumber === null) return [];
                name === 'Chosen' ||
                name === 'Deputy' ||
                name === 'Doomseeker' ||
                name === 'Immortal' ||
                name === 'Juggernaut' ||
                name === 'Mercenary' ||
                name === 'Savage' ||
                name === 'Scholar' ||
                name === 'Tinker' ||
                name === 'Arcane Were-Hunter' ||
                name === 'Divine Were-Hunter' ||


                name === 'Gypsy Were-Hunter' ||
            safeArray(data.masteries).forEach(function (item) {
                 name === 'Heroic Were-Hunter' ||
                 if (
                name === 'Metabolist' ||
                    item &&
                name === 'Transmuter' ||
                    getCmChainBase(item.name) === base &&
                name === 'Gypsy' ||
                    getCmChainNumber(item.name) !== null
                 name === 'Minstrel' ||
                 ) {
                name === 'Exorcist' ||
                    chain.push(item.name);
                 name === 'Faithful' ||
                 }
                name === 'Storm Caller' ||
            });
                name === 'Wilding' ||
 
                name === 'Animator' ||
            chain.sort(function (a, b) {
                 name === 'Spellbinder' ||
                 return getCmChainNumber(a) - getCmChainNumber(b);
                name === 'Aeromancer' ||
            });
                name === 'Geomancer' ||
 
                name === 'Hydromancer' ||
            return chain;
                name === 'Pyromancer' ||
        }
                name === 'Ninja' ||
 
                name === 'Sensei' ||
        function getCmChainHtml(cm) {
                name === 'Defiler' ||
            var chain = getCmChain(cm);
                name === 'Lich' ||
 
                name === 'Crusader' ||
            if (chain.length <= 1) return '';
                name === 'Guardian' ||
 
                name === 'Forester' ||
            return chain.map(function (name) {
                 name === 'Hunter' ||
                 var owned = !!selectedMasteries[name];
                name === 'Assassin' ||
                 var current = cm && cm.name === name;
                 name === 'Burglar' ||
                 var className = 'cm-chain-step';
                name === 'Death Knight' ||
                name === 'Slayer' ||
                 name === 'Elder' ||
                name === 'Mystic' ||
                name === 'Defender' ||
                name === 'Hero' ||
                name === 'Channeler' ||
                name === 'Sorcerer' ||
                name === 'Bravo' ||
                name === 'Diviner' ||
                name === 'Sage' ||
                name === 'Soldier';
        }


        function getSelectedMasterClassTitle() {
                if (owned) {
            var selectedTitle = '';
                    className += ' cm-chain-owned';
                }


            Object.keys(selectedMasteries).forEach(function (name) {
                 if (current) {
                 if (isMasterClassTitle(selectedMasteries[name])) {
                     className += ' cm-chain-current';
                     selectedTitle = name;
                 }
                 }
            });


             return selectedTitle;
                return '<span class="' + className + '">' + escapeHtml(name) + '</span>';
             }).join('<span class="cm-chain-arrow"> &gt; </span>');
         }
         }


         function getCmValueByPossibleKeys(cm, keys) {
         function getCompressedMasteryNames(names) {
             var normalizedWanted = {};
             var groups = {};
             var found = '';
             var singles = [];


             keys.forEach(function (key) {
             names.forEach(function (name) {
                 normalizedWanted[String(key).toLowerCase().replace(/[^a-z0-9]/g, '')] = true;
                 var base = getCmChainBase(name);
            });
                var number = getCmChainNumber(name);


            Object.keys(cm || {}).forEach(function (key) {
                if (number === null) {
                 var normalizedKey;
                    singles.push(name);
                    return;
                 }


                 if (found !== '') return;
                 if (!groups[base]) {
                    groups[base] = [];
                }


                 normalizedKey = String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
                 groups[base].push(number);
 
                if (normalizedWanted[normalizedKey]) {
                    found = cm[key];
                }
             });
             });


             return found;
             Object.keys(groups).forEach(function (base) {
        }
                groups[base].sort(function (a, b) {
                    return a - b;
                });


       
                if (groups[base].length === 1) {
                    singles.push(base + ' ' + groups[base][0]);
                } else {
                    singles.push(base + ' ' + groups[base].join(' > '));
                }
            });


            return singles.sort();
        }


function getCmStatMinRequirements(cm) {
        function getSelectedExemplarPathForClass(classCode) {
    var reqs = [];
            var selectedPath = null;


    if (!cm || !cm.baseStatReqs) return reqs;
            classCode = String(classCode || '').toUpperCase();


    Object.keys(cm.baseStatReqs).forEach(function (statName) {
            Object.keys(selectedMasteries).forEach(function (name) {
        var value = numberOrZero(cm.baseStatReqs[statName]);
                var selectedCm = selectedMasteries[name];
                var exemplarPath = selectedCm && selectedCm.exemplarPath;
 
                if (!exemplarPath || selectedPath) return;


        if (value > 0) {
                if (String(exemplarPath.classCode || '').toUpperCase() === classCode) {
            reqs.push({
                    selectedPath = exemplarPath;
                stat: statName,
                 }
                 value: value
             });
             });
            return selectedPath;
         }
         }
    });


    return reqs;
        function getExemplarPathLockReason(cm) {
}
            var exemplarPath = cm && cm.exemplarPath;
            var selectedPath;


            if (!exemplarPath) return '';


            selectedPath = getSelectedExemplarPathForClass(exemplarPath.classCode);


            if (!selectedPath) return '';


            if (String(selectedPath.path) === String(exemplarPath.path)) {
                return '';
            }


            return 'Exemplar path already chosen: ' + selectedPath.path;
        }


        function getCmLockReasons(cm, options) {
            var spent = getSpentPoints();
            var available = getAvailablePoints();
            var baseStats = getBaseStatsFromInputs();
            var level = parseInt(levelInput.value, 10);
            var reasons = [];
            var exemplarLockReason;


        function getStatMinRequirementsHtml(cm) {
            options = options || {};
             var reqs = getCmStatMinRequirements(cm);
 
            if (!cm) return reasons;
 
             if (isNaN(level)) {
                level = 1;
            }


             if (!reqs.length) return '';
             if (cm.level && cm.level > level) {
                reasons.push('Reach level ' + cm.level);
            }


             return reqs.map(function (req) {
             if (cm.cmSpentReq && spent < cm.cmSpentReq) {
                 return '<span class="cm-stat-min-req">' +
                 reasons.push('Spend ' + (cm.cmSpentReq - spent) + ' more CM');
                    'Base ' +
             }
                    escapeHtml(formatStatName(req.stat)) +
                    ' ' +
                    escapeHtml(req.value) +
                '</span>';
             }).join(', ');
        }


        function getCmSearchText(cm) {
            getCmStatMinRequirements(cm).forEach(function (req) {
            var parts = [];
                var current = numberOrZero(baseStats[req.stat]);
            var stats = cm && cm.stats ? cm.stats : {};
            var statMinReqs;


            if (!cm) return '';
                if (current < req.value) {
 
                    reasons.push('Requires base ' + req.stat + ' ' + req.value);
            parts.push(cm.name || '');
                 }
            parts.push(cm.description || '');
            parts.push(cm.classification || '');
            parts.push(cm.lycanthropy || '');
 
            Object.keys(stats).forEach(function (statName) {
                parts.push(statName);
                parts.push(formatStatName(statName));
                 parts.push(stats[statName]);
             });
             });


             statMinReqs = getCmStatMinRequirements(cm);
             safeArray(cm.requirements).forEach(function (reqName) {
            statMinReqs.forEach(function (req) {
                 if (!selectedMasteries[reqName]) {
                 parts.push(req.stat);
                    reasons.push('Requires ' + reqName);
                parts.push(formatStatName(req.stat));
                 }
                 parts.push(req.value);
             });
             });


             safeArray(cm.spellsAdded).forEach(function (spell) {
             exemplarLockReason = getExemplarPathLockReason(cm);
                parts.push(spell);
            });


             safeArray(cm.spellsRemoved).forEach(function (spell) {
             if (exemplarLockReason) {
                 parts.push(spell);
                 reasons.push(exemplarLockReason);
             });
             }


             safeArray(cm.requirements).forEach(function (req) {
             if (options.includePointBudget && spent + numberOrZero(cm.points) > available) {
                 parts.push(req);
                 reasons.push('Not enough CM points');
             });
             }


             return parts.join(' ').toLowerCase();
             return reasons;
         }
         }


         function getRequirementHtml(reqName) {
         function getCmUnlockHint(cm) {
            var owned = !!selectedMasteries[reqName];
             return getCmLockReasons(cm).join(' | ');
            var className = owned ? 'cm-req-owned' : 'cm-req-missing';
 
             return '<span class="' + className + '">' + escapeHtml(reqName) + '</span>';
         }
         }


         function getRequirementsHtml(cm) {
         function updateMasterClassPanel() {
             var reqs = safeArray(cm && cm.requirements);
             var panel = document.getElementById('cm-master-class-panel');
            var selectedTitle = getSelectedMasterClassTitle();


             if (!reqs.length) return '';
             if (!panel) return;


             return reqs.map(function (reqName) {
             if (!selectedTitle) {
                 return getRequirementHtml(reqName);
                panel.innerHTML =
             }).join(', ');
                    '<span class="cm-master-class-label">None selected</span>';
        }
                 return;
             }
 
            panel.innerHTML =
                '<span class="cm-master-class-label">' + escapeHtml(selectedTitle) + '</span>' +
                '<button type="button" id="cm-remove-master-class" class="cm-mini-remove-button">Remove</button>';


        function getCmChainBase(name) {
             document.getElementById('cm-remove-master-class').addEventListener('click', function () {
             return String(name || '').replace(/\s+\d+$/, '');
                delete selectedMasteries[selectedTitle];
                hideCmTooltip();
                updateSummary();
            });
         }
         }


         function getCmChainNumber(name) {
         function getCmDisplayTab(cm) {
             var match = String(name || '').match(/\s+(\d+)$/);
             var classification = String((cm && cm.classification) || '');
            var races = safeArray(cm && cm.races);
            var classes = safeArray(cm && cm.classes);


             return match ? parseInt(match[1], 10) : null;
             if (classification === 'Master Class') {
        }
                if (races.length && races[0] !== 'All') {
                    return 'Racial';
                }


        function getCmChain(cm) {
                if (classes.length && classes[0] !== 'All') {
            var base;
                    return 'Class';
            var currentNumber;
                }
             var chain = [];
             }


             if (!cm || !cm.name) return [];
             return classification;
        }


            base = getCmChainBase(cm.name);
        function getCmType(cm) {
             currentNumber = getCmChainNumber(cm.name);
             if (isMasterClassTitle(cm)) {
                return 'Master Class';
            }


             if (currentNumber === null) return [];
             return getCmDisplayTab(cm) || 'Other';
        }


            safeArray(data.masteries).forEach(function (item) {
        function getCmTypeClass(cm) {
                if (
            var type = getCmType(cm);
                    item &&
                    getCmChainBase(item.name) === base &&
                    getCmChainNumber(item.name) !== null
                ) {
                    chain.push(item.name);
                }
            });


             chain.sort(function (a, b) {
             if (type === 'General') return 'cm-type-general';
                return getCmChainNumber(a) - getCmChainNumber(b);
            if (type === 'Racial') return 'cm-type-racial';
             });
            if (type === 'Archetype') return 'cm-type-archetype';
            if (type === 'Class') return 'cm-type-class';
             if (type === 'Master Class') return 'cm-type-master';


             return chain;
             return 'cm-type-other';
         }
         }


         function getCmChainHtml(cm) {
         function findCmByName(name) {
             var chain = getCmChain(cm);
             var found = null;


             if (chain.length <= 1) return '';
             safeArray(data.masteries).forEach(function (cm) {
                if (!found && cm && cm.name === name) {
                    found = cm;
                }
            });


             return chain.map(function (name) {
             return found;
                var owned = !!selectedMasteries[name];
        }
                var current = cm && cm.name === name;
                var className = 'cm-chain-step';


                if (owned) {
        function getSelectedMasteryNames() {
                    className += ' cm-chain-owned';
            return Object.keys(selectedMasteries).filter(function (name) {
                }
                 return selectedMasteries[name];
 
             }).sort();
                if (current) {
                    className += ' cm-chain-current';
                }
 
                 return '<span class="' + className + '">' + escapeHtml(name) + '</span>';
             }).join('<span class="cm-chain-arrow"> &gt; </span>');
         }
         }


         function getCompressedMasteryNames(names) {
         function copyTextToClipboard(text, button) {
             var groups = {};
             function showCopied() {
            var singles = [];
                var oldText;


            names.forEach(function (name) {
                 if (!button) {
                var base = getCmChainBase(name);
                     alert('Copied.');
                var number = getCmChainNumber(name);
 
                 if (number === null) {
                     singles.push(name);
                     return;
                     return;
                 }
                 }


                 if (!groups[base]) {
                 oldText = button.textContent;
                    groups[base] = [];
                 button.textContent = 'Copied';
                 }


                 groups[base].push(number);
                 setTimeout(function () {
            });
                    button.textContent = oldText;
                }, 1200);
            }


             Object.keys(groups).forEach(function (base) {
             if (navigator.clipboard && navigator.clipboard.writeText) {
                 groups[base].sort(function (a, b) {
                 navigator.clipboard.writeText(text).then(showCopied, function () {
                     return a - b;
                     alert(text);
                 });
                 });
                return;
            }


                if (groups[base].length === 1) {
             alert(text);
                    singles.push(base + ' ' + groups[base][0]);
                } else {
                    singles.push(base + ' ' + groups[base].join(' > '));
                }
            });
 
             return singles.sort();
         }
         }


function getSelectedExemplarPathForClass(classCode) {
        function getBuildTotals() {
    var selectedPath = null;
            var statTotals = {};
            var spellsAdded = {};
            var spellsRemoved = {};


    classCode = String(classCode || '').toUpperCase();
            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var stats;


    Object.keys(selectedMasteries).forEach(function (name) {
                if (!cm) return;
        var selectedCm = selectedMasteries[name];
        var exemplarPath = selectedCm && selectedCm.exemplarPath;


        if (!exemplarPath || selectedPath) return;
                stats = cm.stats || {};


        if (String(exemplarPath.classCode || '').toUpperCase() === classCode) {
                Object.keys(stats).forEach(function (statName) {
            selectedPath = exemplarPath;
                    var value = parseInt(stats[statName], 10);
        }
    });


    return selectedPath;
                    if (!isNaN(value) && value !== 0) {
}
                        if (!statTotals[statName]) {
                            statTotals[statName] = 0;
                        }


function getExemplarPathLockReason(cm) {
                        statTotals[statName] += value;
                    }
                });


    var exemplarPath = cm && cm.exemplarPath;
                safeArray(cm.spellsAdded).forEach(function (spell) {
    var selectedPath;
                    if (spell) spellsAdded[spell] = true;
                });


    if (!exemplarPath) return '';
                safeArray(cm.spellsRemoved).forEach(function (spell) {
                    if (spell) spellsRemoved[spell] = true;
                });
            });


    selectedPath = getSelectedExemplarPathForClass(exemplarPath.classCode);
            return {
                stats: statTotals,
                spellsAdded: spellsAdded,
                spellsRemoved: spellsRemoved
            };
        }


    if (!selectedPath) return '';
        function buildShareUrl() {
 
            var baseStats = getBaseStatsFromInputs();
    if (String(selectedPath.path) === String(exemplarPath.path)) {
            var build = {
        return '';
                r: raceSelect.value,
    }
                c: classSelect.value,
                l: levelInput.value,
                p: pathSelect ? pathSelect.value : '',
                b: baseStats,
                cms: getSelectedMasteryNames()
            };


    return 'Exemplar path already chosen: ' + selectedPath.path;
            return window.location.href.split('#')[0] +
}
                '#cm-build=' +
                encodeURIComponent(JSON.stringify(build));
        }


         function getCmLockReasons(cm, options) {
         function setBaseStatInputs(baseStats) {
    var spent = getSpentPoints();
            baseStats = baseStats || {};
    var available = getAvailablePoints();
    var baseStats = getBaseStatsFromInputs();
    var level = parseInt(levelInput.value, 10);
    var reasons = [];


    options = options || {};
            document.getElementById('cm-base-str').value = numberOrZero(baseStats.STR);
            document.getElementById('cm-base-sta').value = numberOrZero(baseStats.STA);
            document.getElementById('cm-base-dex').value = numberOrZero(baseStats.DEX);
            document.getElementById('cm-base-agi').value = numberOrZero(baseStats.AGI);
            document.getElementById('cm-base-int').value = numberOrZero(baseStats.INT);
            document.getElementById('cm-base-wis').value = numberOrZero(baseStats.WIS);
            document.getElementById('cm-base-cha').value = numberOrZero(baseStats.CHA);
        }


    if (!cm) return reasons;
        function loadBuildFromUrl() {
            var hash = window.location.hash || '';
            var raw;
            var build;


    if (isNaN(level)) {
            if (hash.indexOf('#cm-build=') !== 0) return;
        level = 1;
    }


    if (cm.level && cm.level > level) {
            raw = hash.replace('#cm-build=', '');
        reasons.push('Reach level ' + cm.level);
    }


    if (cm.cmSpentReq && spent < cm.cmSpentReq) {
            try {
        reasons.push('Spend ' + (cm.cmSpentReq - spent) + ' more CM');
                build = JSON.parse(decodeURIComponent(raw));
    }
            } catch (e) {
                return;
            }


    getCmStatMinRequirements(cm).forEach(function (req) {
            if (build.r && data.raceClasses && data.raceClasses[build.r]) {
        var current = numberOrZero(baseStats[req.stat]);
                raceSelect.value = build.r;
                loadClasses();
            }


        if (current < req.value) {
            if (build.c) {
            reasons.push('Requires base ' + req.stat + ' ' + req.value);
                classSelect.value = build.c;
        }
            }
    });


    safeArray(cm.requirements).forEach(function (reqName) {
            if (build.l) {
        if (!selectedMasteries[reqName]) {
                levelInput.value = build.l;
            reasons.push('Requires ' + reqName);
            }
        }
    });
var exemplarLockReason = getExemplarPathLockReason(cm);


if (exemplarLockReason) {
            if (pathSelect && build.p !== undefined) {
    reasons.push(exemplarLockReason);
                pathSelect.value = build.p;
}
            }


            if (build.b) {
                setBaseStatInputs(build.b);
            }


    if (options.includePointBudget && spent + numberOrZero(cm.points) > available) {
            selectedMasteries = {};
        reasons.push('Not enough CM points');
    }


    return reasons;
            safeArray(build.cms).forEach(function (name) {
}
                var cm = findCmByName(name);


function getCmUnlockHint(cm) {
                if (cm) {
    return getCmLockReasons(cm).join(' | ');
                    selectedMasteries[cm.name] = cm;
}
                }
 
            });
function canPurchaseCm(cm) {
    return getCmLockReasons(cm, {
        includePointBudget: true
    }).length === 0;
}


 
            updateSummary();
        }


        function loadQrLibrary(callback) {
            if (window.QRious) {
                callback(true);
                return;
            }


            var script = document.createElement('script');
            script.src = 'https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js';
            script.onload = function () {
                callback(!!window.QRious);
            };
            script.onerror = function () {
                callback(false);
            };


            document.head.appendChild(script);
        }


        function renderBuildCard() {
            loadQrLibrary(function (qrReady) {
                var shareUrl = buildShareUrl();
                var totals = getBuildTotals();
                var selectedNames = getSelectedMasteryNames();
                var purchasedDisplayNames = getCompressedMasteryNames(selectedNames);
                var selectedTitle = getSelectedMasterClassTitle() || 'None';
                var available = getAvailablePoints();
                var spent = getSpentPoints();
                var remaining = available - spent;
                var statsList = Object.keys(totals.stats).sort();
                var gainedList = Object.keys(totals.spellsAdded).sort();
                var removedList = Object.keys(totals.spellsRemoved).sort();
                var baseStats = getBaseStatsFromInputs();


                var canvas = document.createElement('canvas');
                var ctx = canvas.getContext('2d');


        function updateMasterClassPanel() {
                var cardWidth = 1800;
            var panel = document.getElementById('cm-master-class-panel');
                var margin = 55;
            var selectedTitle = getSelectedMasterClassTitle();
                 var gap = 35;
 
                 var panelW = cardWidth - margin * 2;
            if (!panel) return;
                 var y = 390;
 
            if (!selectedTitle) {
                 panel.innerHTML =
                    '<span class="cm-master-class-label">None selected</span>';
                 return;
            }
 
            panel.innerHTML =
                '<span class="cm-master-class-label">' + escapeHtml(selectedTitle) + '</span>' +
                 '<button type="button" id="cm-remove-master-class" class="cm-mini-remove-button">Remove</button>';
 
            document.getElementById('cm-remove-master-class').addEventListener('click', function () {
                delete selectedMasteries[selectedTitle];
                hideCmTooltip();
                updateSummary();
            });
        }


        function getCmDisplayTab(cm) {
                var qrW = 360;
            var classification = String((cm && cm.classification) || '');
                var summaryW = panelW - qrW - gap;
            var races = safeArray(cm && cm.races);
                var qrH = 360;
            var classes = safeArray(cm && cm.classes);


            if (classification === 'Master Class') {
                function roundedRect(x, y, w, h, r) {
                if (races.length && races[0] !== 'All') {
                    if (ctx.roundRect) {
                    return 'Racial';
                        ctx.beginPath();
                }
                        ctx.roundRect(x, y, w, h, r);
                        return;
                    }


                if (classes.length && classes[0] !== 'All') {
                    ctx.beginPath();
                     return 'Class';
                    ctx.moveTo(x + r, y);
                 }
                    ctx.lineTo(x + w - r, y);
            }
                    ctx.quadraticCurveTo(x + w, y, x + w, y + r);
                    ctx.lineTo(x + w, y + h - r);
                    ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
                    ctx.lineTo(x + r, y + h);
                    ctx.quadraticCurveTo(x, y + h, x, y + h - r);
                    ctx.lineTo(x, y + r);
                     ctx.quadraticCurveTo(x, y, x + r, y);
                 }


            return classification;
                function drawPanel(x, y, w, h, title) {
        }
                    ctx.strokeStyle = '#9b7330';
                    ctx.lineWidth = 3;
                    roundedRect(x, y, w, h, 16);
                    ctx.stroke();


        function getCmType(cm) {
                    ctx.fillStyle = '#d9a64d';
            if (isMasterClassTitle(cm)) {
                    ctx.font = 'bold 36px Georgia';
                return 'Master Class';
                    ctx.textAlign = 'center';
            }
                    ctx.fillText(title, x + w / 2, y + 48);


            return getCmDisplayTab(cm) || 'Other';
                    ctx.strokeStyle = 'rgba(217,166,77,0.35)';
        }
                    ctx.lineWidth = 1;
 
                    ctx.beginPath();
        function getCmTypeClass(cm) {
                    ctx.moveTo(x + 35, y + 68);
            var type = getCmType(cm);
                    ctx.lineTo(x + w - 35, y + 68);
                    ctx.stroke();


            if (type === 'General') return 'cm-type-general';
                    ctx.textAlign = 'left';
            if (type === 'Racial') return 'cm-type-racial';
                }
            if (type === 'Archetype') return 'cm-type-archetype';
            if (type === 'Class') return 'cm-type-class';
            if (type === 'Master Class') return 'cm-type-master';


            return 'cm-type-other';
                function drawInfoBox(label, value, x, y, w) {
        }
                    ctx.strokeStyle = '#9b7330';
                    ctx.lineWidth = 2;
                    roundedRect(x, y, w, 100, 12);
                    ctx.stroke();


        function findCmByName(name) {
                    ctx.textAlign = 'left';
            var found = null;
                    ctx.textBaseline = 'alphabetic';


            safeArray(data.masteries).forEach(function (cm) {
                    ctx.fillStyle = '#f4e6c8';
                if (!found && cm && cm.name === name) {
                     ctx.font = 'bold 25px Georgia';
                     found = cm;
                    ctx.fillText(label, x + 28, y + 38);
                }
            });


            return found;
                    ctx.font = 'bold 30px Georgia';
        }


        function getSelectedMasteryNames() {
                    ctx.fillText(value || 'None', x + 28, y + 72);
            return Object.keys(selectedMasteries).filter(function (name) {
                 }
                 return selectedMasteries[name];
            }).sort();
        }


        function copyTextToClipboard(text, button) {
                function measureColumnList(items, columns, lineH) {
            function showCopied() {
                    if (!items.length) return lineH;
                var oldText;
                     return Math.ceil(items.length / columns) * lineH;
 
 
                if (!button) {
                     alert('Copied.');
                    return;
                 }
                 }


                 oldText = button.textContent;
                 function drawColumnList(items, x, y, width, columns, color, font, lineH) {
                button.textContent = 'Copied';
                    var colGap = 42;
                    var colWidth = (width - colGap * (columns - 1)) / columns;
                    var rows = Math.ceil(Math.max(items.length, 1) / columns);


                setTimeout(function () {
                    ctx.fillStyle = color;
                     button.textContent = oldText;
                     ctx.font = font;
                }, 1200);
                    ctx.textAlign = 'left';
            }
                    ctx.textBaseline = 'alphabetic';


            if (navigator.clipboard && navigator.clipboard.writeText) {
                    if (!items.length) {
                navigator.clipboard.writeText(text).then(showCopied, function () {
                        ctx.fillText('None', x, y);
                    alert(text);
                        return y + lineH;
                });
                    }
                return;
            }


            alert(text);
                    items.forEach(function (item, index) {
        }
                        var col = Math.floor(index / rows);
                        var row = index % rows;
                        var drawX = x + col * (colWidth + colGap);
                        var drawY = y + row * lineH;


        function getBuildTotals() {
                        ctx.fillText(item, drawX, drawY);
            var statTotals = {};
                    });
            var spellsAdded = {};
            var spellsRemoved = {};


            Object.keys(selectedMasteries).forEach(function (name) {
                    return y + rows * lineH;
                var cm = selectedMasteries[name];
                 }
                 var stats;


                 if (!cm) return;
                 var statsItems = statsList.map(function (statName) {
                    var value = totals.stats[statName];
                    var sign = value > 0 ? '+' : '';
                    return formatStatName(statName) + ': ' + sign + value;
                });


                 stats = cm.stats || {};
                 var baseStatItems = Object.keys(baseStats).map(function (statName) {
                    return statName + ': ' + baseStats[statName];
                });


                 Object.keys(stats).forEach(function (statName) {
                 var gainedItems = gainedList.map(function (spell) {
                     var value = parseInt(stats[statName], 10);
                     return '+ ' + spell;
 
                    if (!isNaN(value) && value !== 0) {
                        if (!statTotals[statName]) {
                            statTotals[statName] = 0;
                        }
 
                        statTotals[statName] += value;
                    }
                 });
                 });


                 safeArray(cm.spellsAdded).forEach(function (spell) {
                 var removedItems = removedList.map(function (spell) {
                     if (spell) spellsAdded[spell] = true;
                     return '- ' + spell;
                 });
                 });


                 safeArray(cm.spellsRemoved).forEach(function (spell) {
                 var statsHeight = measureColumnList(statsItems.concat(baseStatItems), 3, 30);
                    if (spell) spellsRemoved[spell] = true;
                var purchasedHeight = measureColumnList(purchasedDisplayNames, 2, 30);
                 });
                 var gainedHeight = measureColumnList(gainedItems, 2, 30);
            });
                var removedHeight = measureColumnList(removedItems, 2, 30);


            return {
                var summaryH = Math.max(490, 365 + statsHeight);
                 stats: statTotals,
                 var topRowH = Math.max(summaryH, qrH);
                 spellsAdded: spellsAdded,
                 var purchasedH = Math.max(280, 130 + purchasedHeight);
                 spellsRemoved: spellsRemoved
                 var spellsH = Math.max(420, 250 + gainedHeight + removedHeight);
            };
        }


        function buildShareUrl() {
                canvas.width = cardWidth;
            var baseStats = getBaseStatsFromInputs();
                 canvas.height =
            var build = {
                    390 +
                 r: raceSelect.value,
                    topRowH + 35 +
                c: classSelect.value,
                    purchasedH + 35 +
                l: levelInput.value,
                    spellsH + 180;
                p: pathSelect ? pathSelect.value : '',
                b: baseStats,
                cms: getSelectedMasteryNames()
            };


            return window.location.href.split('#')[0] +
                ctx.fillStyle = '#05080d';
                 '#cm-build=' +
                 ctx.fillRect(0, 0, canvas.width, canvas.height);
                encodeURIComponent(JSON.stringify(build));
        }


        function setBaseStatInputs(baseStats) {
                ctx.strokeStyle = '#9b7330';
            baseStats = baseStats || {};
                ctx.lineWidth = 6;
                roundedRect(8, 8, canvas.width - 16, canvas.height - 16, 28);
                ctx.stroke();


            document.getElementById('cm-base-str').value = numberOrZero(baseStats.STR);
                ctx.textAlign = 'center';
            document.getElementById('cm-base-sta').value = numberOrZero(baseStats.STA);
                ctx.fillStyle = '#d9a64d';
            document.getElementById('cm-base-dex').value = numberOrZero(baseStats.DEX);
                ctx.font = 'bold 78px Georgia';
            document.getElementById('cm-base-agi').value = numberOrZero(baseStats.AGI);
                ctx.fillText('EQOA: SANDSTORM', canvas.width / 2, 100);
            document.getElementById('cm-base-int').value = numberOrZero(baseStats.INT);
            document.getElementById('cm-base-wis').value = numberOrZero(baseStats.WIS);
            document.getElementById('cm-base-cha').value = numberOrZero(baseStats.CHA);
        }


        function loadBuildFromUrl() {
                ctx.font = 'bold 46px Georgia';
            var hash = window.location.hash || '';
                ctx.fillText('CLASS MASTERY CALCULATOR', canvas.width / 2, 155);
            var raw;
            var build;


            if (hash.indexOf('#cm-build=') !== 0) return;
                ctx.fillStyle = '#f4e6c8';
                ctx.font = 'bold 38px Georgia';
                ctx.fillText('Unnamed CM Build', canvas.width / 2, 205);


            raw = hash.replace('#cm-build=', '');
                drawInfoBox('Race', raceSelect.value || 'None', margin, 250, 380);
                drawInfoBox('Class', classSelect.value || 'None', 480, 250, 380);
                drawInfoBox('Level', String(levelInput.value || '60'), 905, 250, 300);
                drawInfoBox('Path', (pathSelect && pathSelect.value) || 'None', 1250, 250, 495);


            try {
                 drawPanel(margin, y, summaryW, topRowH, 'CM SUMMARY');
                 build = JSON.parse(decodeURIComponent(raw));
                drawPanel(margin + summaryW + gap, y, qrW, qrH, 'SCAN');
            } catch (e) {
                return;
            }


            if (build.r && data.raceClasses && data.raceClasses[build.r]) {
                ctx.fillStyle = '#f4e6c8';
                 raceSelect.value = build.r;
                ctx.font = 'bold 29px Georgia';
                 loadClasses();
                ctx.fillText('Master Class: ' + selectedTitle, margin + 45, y + 115);
            }
                 ctx.fillText('Points: ' + spent + ' / ' + available + ' spent', margin + 45, y + 155);
                 ctx.fillText('Remaining: ' + remaining, margin + 45, y + 195);


            if (build.c) {
                ctx.fillStyle = '#d9a64d';
                 classSelect.value = build.c;
                 ctx.font = 'bold 30px Georgia';
            }
                ctx.fillText('Base Stats', margin + 45, y + 255);


            if (build.l) {
                var afterBaseY = drawColumnList(baseStatItems, margin + 45, y + 295, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
                levelInput.value = build.l;
            }


            if (pathSelect && build.p !== undefined) {
                ctx.fillStyle = '#d9a64d';
                 pathSelect.value = build.p;
                 ctx.font = 'bold 30px Georgia';
            }
                ctx.fillText('Stats Gained', margin + 45, afterBaseY + 45);


            if (build.b) {
                setBaseStatInputs(build.b);
            }


            selectedMasteries = {};
                drawColumnList(statsItems, margin + 45, afterBaseY + 85, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);


            safeArray(build.cms).forEach(function (name) {
                if (qrReady) {
                var cm = findCmByName(name);
                    var qrCanvas = document.createElement('canvas');
 
                    new QRious({
                        element: qrCanvas,
                        value: shareUrl,
                        size: 220,
                        background: 'white',
                        foreground: 'black',
                        level: 'M'
                    });


                if (cm) {
                    ctx.drawImage(qrCanvas, margin + summaryW + gap + 70, y + 105, 220, 220);
                    selectedMasteries[cm.name] = cm;
                 }
                 }
            });


            updateSummary();
                y += topRowH + 35;
        }
 
                drawPanel(margin, y, panelW, purchasedH, 'PURCHASED CMS');
                drawColumnList(purchasedDisplayNames, margin + 45, y + 115, panelW - 90, 2, '#e066ff', 'bold 23px Georgia', 30);


        function loadQrLibrary(callback) {
                 y += purchasedH + 35;
            if (window.QRious) {
                 callback(true);
                return;
            }


            var script = document.createElement('script');
                drawPanel(margin, y, panelW, spellsH, 'SPELLS');
            script.src = 'https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js';
            script.onload = function () {
                callback(!!window.QRious);
            };
            script.onerror = function () {
                callback(false);
            };


            document.head.appendChild(script);
                ctx.fillStyle = '#8fd9ff';
        }
                ctx.font = 'bold 30px Georgia';
                ctx.fillText('Gained', margin + 45, y + 115);


        function renderBuildCard() {
                 var afterGainedY = drawColumnList(gainedItems, margin + 45, y + 155, panelW - 90, 2, '#8fd9ff', 'bold 22px Georgia', 30);
            loadQrLibrary(function (qrReady) {
                 var shareUrl = buildShareUrl();
                var totals = getBuildTotals();
                var selectedNames = getSelectedMasteryNames();
                var purchasedDisplayNames = getCompressedMasteryNames(selectedNames);
                var selectedTitle = getSelectedMasterClassTitle() || 'None';
                var available = getAvailablePoints();
                var spent = getSpentPoints();
                var remaining = available - spent;
                var statsList = Object.keys(totals.stats).sort();
                var gainedList = Object.keys(totals.spellsAdded).sort();
                var removedList = Object.keys(totals.spellsRemoved).sort();
                var baseStats = getBaseStatsFromInputs();


                 var canvas = document.createElement('canvas');
                 ctx.fillStyle = '#ff8f8f';
                 var ctx = canvas.getContext('2d');
                ctx.font = 'bold 30px Georgia';
                 ctx.fillText('Removed', margin + 45, afterGainedY + 55);


                 var cardWidth = 1800;
                 drawColumnList(removedItems, margin + 45, afterGainedY + 95, panelW - 90, 2, '#ff8f8f', 'bold 22px Georgia', 30);
                var margin = 55;
                var gap = 35;
                var panelW = cardWidth - margin * 2;
                var y = 390;


                 var qrW = 360;
                 ctx.fillStyle = '#d9a64d';
                 var summaryW = panelW - qrW - gap;
                 ctx.font = 'bold 30px Georgia';
                 var qrH = 360;
                 ctx.textAlign = 'center';
                ctx.fillText(
                    'EQOA: Sandstorm Wiki Class Mastery Calculator',
                    canvas.width / 2,
                    canvas.height - 55
                );


                 function roundedRect(x, y, w, h, r) {
                 var link = document.createElement('a');
                    if (ctx.roundRect) {
                link.href = canvas.toDataURL('image/png');
                        ctx.beginPath();
                link.download = 'EQOA_CM_Build.png';
                        ctx.roundRect(x, y, w, h, r);
                document.body.appendChild(link);
                        return;
                link.click();
                    }
                document.body.removeChild(link);
            });
        }
 
        function loadRaces() {
            raceSelect.innerHTML = '';


                    ctx.beginPath();
            Object.keys(data.raceClasses || {}).forEach(function (race) {
                    ctx.moveTo(x + r, y);
                addOption(raceSelect, race, race);
                    ctx.lineTo(x + w - r, y);
            });
                    ctx.quadraticCurveTo(x + w, y, x + w, y + r);
                    ctx.lineTo(x + w, y + h - r);
                    ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
                    ctx.lineTo(x + r, y + h);
                    ctx.quadraticCurveTo(x, y + h, x, y + h - r);
                    ctx.lineTo(x, y + r);
                    ctx.quadraticCurveTo(x, y, x + r, y);
                }


                function drawPanel(x, y, w, h, title) {
            loadClasses();
                    ctx.strokeStyle = '#9b7330';
        }
                    ctx.lineWidth = 3;
                    roundedRect(x, y, w, h, 16);
                    ctx.stroke();


                    ctx.fillStyle = '#d9a64d';
        function selectOptionIfExists(select, value) {
                    ctx.font = 'bold 36px Georgia';
            var found = false;
                    ctx.textAlign = 'center';
                    ctx.fillText(title, x + w / 2, y + 48);


                    ctx.strokeStyle = 'rgba(217,166,77,0.35)';
            if (!select) return false;
                    ctx.lineWidth = 1;
                    ctx.beginPath();
                    ctx.moveTo(x + 35, y + 68);
                    ctx.lineTo(x + w - 35, y + 68);
                    ctx.stroke();


                     ctx.textAlign = 'left';
            Array.prototype.forEach.call(select.options, function (option) {
                if (option.value === value) {
                     found = true;
                 }
                 }
            });


                function drawInfoBox(label, value, x, y, w) {
            if (found) {
                    ctx.strokeStyle = '#9b7330';
                select.value = value;
                    ctx.lineWidth = 2;
            }
                    roundedRect(x, y, w, 100, 12);
                    ctx.stroke();


                    ctx.textAlign = 'left';
            return found;
                    ctx.textBaseline = 'alphabetic';
        }


                    ctx.fillStyle = '#f4e6c8';
        function findCmRaceForBuilderState(builderState) {
                    ctx.font = 'bold 25px Georgia';
            var raceName = builderState && builderState.race;
                    ctx.fillText(label, x + 28, y + 38);
            var classCode = builderState && builderState.classCode;
            var raceKeys = Object.keys(data.raceClasses || {});
            var foundRace = '';


                    ctx.font = 'bold 30px Georgia';
            if (raceName && data.raceClasses && data.raceClasses[raceName]) {
                    ctx.fillText(value || 'None', x + 28, y + 72);
                return raceName;
                }
            }


                function measureColumnList(items, columns, lineH) {
            raceKeys.forEach(function (race) {
                    if (!items.length) return lineH;
                if (foundRace) return;
                     return Math.ceil(items.length / columns) * lineH;
 
                if (race === raceName) {
                    foundRace = race;
                     return;
                 }
                 }


                 function drawColumnList(items, x, y, width, columns, color, font, lineH) {
                 if (data.raceCodes && data.raceCodes[race] === raceName) {
                     var colGap = 42;
                     foundRace = race;
                     var colWidth = (width - colGap * (columns - 1)) / columns;
                     return;
                    var rows = Math.ceil(Math.max(items.length, 1) / columns);
                }


                     ctx.fillStyle = color;
                if (
                     ctx.font = font;
                     classCode &&
                     ctx.textAlign = 'left';
                     safeArray((data.raceClasses || {})[race]).indexOf(classCode) !== -1
                    ctx.textBaseline = 'alphabetic';
                ) {
                     foundRace = race;
                }
            });


                    if (!items.length) {
            return foundRace || raceKeys[0] || '';
                        ctx.fillText('None', x, y);
        }
                        return y + lineH;
                    }


                    items.forEach(function (item, index) {
        function syncFromCharacterBuilder() {
                        var col = Math.floor(index / rows);
            var builderState = getCharacterBuilderState();
                        var row = index % rows;
            var raceName;
                        var drawX = x + col * (colWidth + colGap);
            var classCode;
                        var drawY = y + row * lineH;


                        ctx.fillText(item, drawX, drawY);
            if (!builderState) return;
                    });
 
            raceName = findCmRaceForBuilderState(builderState);
            classCode = builderState.classCode;


                    return y + rows * lineH;
            if (raceName && raceSelect.value !== raceName) {
                 }
                raceSelect.value = raceName;
                 loadClasses();
            }


                var statsItems = statsList.map(function (statName) {
            if (classCode) {
                    var value = totals.stats[statName];
                selectOptionIfExists(classSelect, classCode);
                    var sign = value > 0 ? '+' : '';
            }
                    return formatStatName(statName) + ': ' + sign + value;
                });


                var baseStatItems = Object.keys(baseStats).map(function (statName) {
            if (builderState.level !== undefined) {
                    return statName + ': ' + baseStats[statName];
                levelInput.value = builderState.level;
                });
            }


                var gainedItems = gainedList.map(function (spell) {
            updateBaseStatInputsDisplay(getBaseStatsFromInputs());
                    return '+ ' + spell;
                });


                var removedItems = removedList.map(function (spell) {
            setTimeout(function () {
                    return '- ' + spell;
                updateSummary();
                });
            }, 0);
        }


                var statsHeight = measureColumnList(statsItems.concat(baseStatItems), 3, 30);
        window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;
                var purchasedHeight = measureColumnList(purchasedDisplayNames, 2, 30);
                var gainedHeight = measureColumnList(gainedItems, 2, 30);
                var removedHeight = measureColumnList(removedItems, 2, 30);


                var summaryH = Math.max(490, 365 + statsHeight);
        function loadClasses() {
                var topRowH = Math.max(summaryH, qrH);
            classSelect.innerHTML = '';
                var purchasedH = Math.max(280, 130 + purchasedHeight);
                var spellsH = Math.max(420, 250 + gainedHeight + removedHeight);


                canvas.width = cardWidth;
            var race = raceSelect.value;
                canvas.height =
            var classList = safeArray((data.raceClasses || {})[race]);
                    390 +
                    topRowH + 35 +
                    purchasedH + 35 +
                    spellsH + 180;


                ctx.fillStyle = '#05080d';
            classList.forEach(function (classCode) {
                 ctx.fillRect(0, 0, canvas.width, canvas.height);
                 addOption(
                    classSelect,
                    classCode,
                    classCode + ' - ' + ((data.classes || {})[classCode] || classCode)
                );
            });


                ctx.strokeStyle = '#9b7330';
            updateSummary();
                ctx.lineWidth = 6;
        }
                roundedRect(8, 8, canvas.width - 16, canvas.height - 16, 28);
                ctx.stroke();


                ctx.textAlign = 'center';
        function getAvailablePoints() {
                ctx.fillStyle = '#d9a64d';
            var level = parseInt(levelInput.value, 10);
                ctx.font = 'bold 78px Georgia';
                ctx.fillText('EQOA: SANDSTORM', canvas.width / 2, 100);


                ctx.font = 'bold 46px Georgia';
            if (isNaN(level) || level < 1) level = 1;
                ctx.fillText('CLASS MASTERY CALCULATOR', canvas.width / 2, 155);
            if (level > 60) level = 60;


                ctx.fillStyle = '#f4e6c8';
            levelInput.value = level;
                ctx.font = 'bold 38px Georgia';
                ctx.fillText('Unnamed CM Build', canvas.width / 2, 205);


                drawInfoBox('Race', raceSelect.value || 'None', margin, 250, 380);
            return level * 25;
                drawInfoBox('Class', classSelect.value || 'None', 480, 250, 380);
        }
                drawInfoBox('Level', String(levelInput.value || '60'), 905, 250, 300);
                drawInfoBox('Path', (pathSelect && pathSelect.value) || 'None', 1250, 250, 495);


                drawPanel(margin, y, summaryW, topRowH, 'CM SUMMARY');
        function getSpentPoints() {
                drawPanel(margin + summaryW + gap, y, qrW, qrH, 'SCAN');
            var total = 0;


                ctx.fillStyle = '#f4e6c8';
            Object.keys(selectedMasteries).forEach(function (name) {
                ctx.font = 'bold 29px Georgia';
                 if (selectedMasteries[name]) {
                 ctx.fillText('Master Class: ' + selectedTitle, margin + 45, y + 115);
                    total += selectedMasteries[name].points || 0;
                 ctx.fillText('Points: ' + spent + ' / ' + available + ' spent', margin + 45, y + 155);
                 }
                ctx.fillText('Remaining: ' + remaining, margin + 45, y + 195);
            });
 
            return total;
        }


                ctx.fillStyle = '#d9a64d';
        function getCmPointStatusText() {
                ctx.font = 'bold 30px Georgia';
            var total = getAvailablePoints();
                ctx.fillText('Base Stats', margin + 45, y + 255);
            var spent = getSpentPoints();
            var left = total - spent;


                var afterBaseY = drawColumnList(baseStatItems, margin + 45, y + 295, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
            if (left < 0) left = 0;


                ctx.fillStyle = '#d9a64d';
            return 'Total CMs: ' + total + ' | Spent: ' + spent + ' | Left: ' + left;
                ctx.font = 'bold 30px Georgia';
        }
                ctx.fillText('Stats Gained', margin + 45, afterBaseY + 45);


                drawColumnList(statsItems, margin + 45, afterBaseY + 85, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
        Array.prototype.forEach.call(viewButtons, function (button) {
            button.addEventListener('click', function () {
                activeCmView = button.getAttribute('data-view') || 'detailed';


                 if (qrReady) {
                 Array.prototype.forEach.call(viewButtons, function (btn) {
                     var qrCanvas = document.createElement('canvas');
                     btn.classList.remove('cm-view-active');
                });


                    new QRious({
                button.classList.add('cm-view-active');
                        element: qrCanvas,
                        value: shareUrl,
                        size: 220,
                        background: 'white',
                        foreground: 'black',
                        level: 'M'
                    });


                    ctx.drawImage(qrCanvas, margin + summaryW + gap + 70, y + 105, 220, 220);
                renderMasteryList();
                }
            });
        });


                y += topRowH + 35;
        function updatePurchasedList() {
 
            var list = document.getElementById('cm-purchased-list');
                 drawPanel(margin, y, panelW, purchasedH, 'PURCHASED CMS');
            var purchasedRows = [];
                 drawColumnList(purchasedDisplayNames, margin + 45, y + 115, panelW - 90, 2, '#e066ff', 'bold 23px Georgia', 30);
            var groups = {
 
                 'Master Class': [],
                y += purchasedH + 35;
                'General': [],
 
                'Racial': [],
                 drawPanel(margin, y, panelW, spellsH, 'SPELLS');
                 'Archetype': [],
 
                'Class': [],
                 ctx.fillStyle = '#8fd9ff';
                'Other': []
                 ctx.font = 'bold 30px Georgia';
            };
                ctx.fillText('Gained', margin + 45, y + 115);
            var groupOrder = [
                 'Master Class',
                'General',
                'Racial',
                'Archetype',
                 'Class',
                 'Other'
            ];
            var html = '';


                var afterGainedY = drawColumnList(gainedItems, margin + 45, y + 155, panelW - 90, 2, '#8fd9ff', 'bold 22px Georgia', 30);
            if (!list) return;


                ctx.fillStyle = '#ff8f8f';
            Object.keys(selectedMasteries).forEach(function (name) {
                 ctx.font = 'bold 30px Georgia';
                 var cm = selectedMasteries[name];
                 ctx.fillText('Removed', margin + 45, afterGainedY + 55);
                 var groupName;


                 drawColumnList(removedItems, margin + 45, afterGainedY + 95, panelW - 90, 2, '#ff8f8f', 'bold 22px Georgia', 30);
                 if (!cm) return;


                 ctx.fillStyle = '#d9a64d';
                 if (isMasterClassTitle(cm)) {
                 ctx.font = 'bold 30px Georgia';
                    groupName = 'Master Class';
                ctx.textAlign = 'center';
                 } else {
                ctx.fillText(
                    groupName = getCmDisplayTab(cm);
                    'EQOA: Sandstorm Wiki Class Mastery Calculator',
 
                     canvas.width / 2,
                    if (!groups[groupName]) {
                    canvas.height - 55
                        groupName = 'Other';
                 );
                     }
                 }


                 var link = document.createElement('a');
                 groups[groupName].push(cm);
                link.href = canvas.toDataURL('image/png');
                link.download = 'EQOA_CM_Build.png';
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
             });
             });
        }


        function loadRaces() {
            groupOrder.forEach(function (groupName) {
            raceSelect.innerHTML = '';
                groups[groupName].sort(function (a, b) {
 
                    return String(a.name || '').localeCompare(String(b.name || ''));
            Object.keys(data.raceClasses || {}).forEach(function (race) {
                 });
                 addOption(raceSelect, race, race);
             });
             });


             loadClasses();
             groupOrder.forEach(function (groupName) {
        }
                if (!groups[groupName].length) return;


                html +=
                    '<div class="cm-purchased-group">' +
                        '<div class="cm-purchased-group-title">' + escapeHtml(groupName) + '</div>';


                groups[groupName].forEach(function (cm) {
                    var index = purchasedRows.length;
                    purchasedRows.push(cm);


                    html +=
                        '<div class="cm-purchased-item" data-purchased-index="' + index + '">' +
                            '<span>' + escapeHtml(cm.name) + '</span>' +
                            '<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
                        '</div>';
                });


function selectOptionIfExists(select, value) {
                html += '</div>';
    var found = false;
            });


    if (!select) return false;
            if (!html) {
                list.innerHTML = 'None purchased.';
                return;
            }


    Array.prototype.forEach.call(select.options, function (option) {
            list.innerHTML = html;
        if (option.value === value) {
            found = true;
        }
    });


    if (found) {
            Array.prototype.forEach.call(
        select.value = value;
                list.querySelectorAll('.cm-purchased-item'),
    }
                function (row) {
                    var index = parseInt(row.getAttribute('data-purchased-index'), 10);
                    var cm = purchasedRows[index];


    return found;
                    row.addEventListener('mouseenter', function () {
}
                        showCmTooltip(cm, row);
                    });


function findCmRaceForBuilderState(builderState) {
                    row.addEventListener('mouseleave', hideCmTooltip);
    var raceName = builderState && builderState.race;
    var classCode = builderState && builderState.classCode;
    var raceKeys = Object.keys(data.raceClasses || {});
    var foundRace = '';


    if (raceName && data.raceClasses && data.raceClasses[raceName]) {
                    row.addEventListener('click', function () {
        return raceName;
                        if (!cm) return;
    }


    raceKeys.forEach(function (race) {
                        delete selectedMasteries[cm.name];
        if (foundRace) return;
                        hideCmTooltip();
 
                        updateSummary();
        if (race === raceName) {
                    });
            foundRace = race;
                }
             return;
             );
         }
         }


        if (data.raceCodes && data.raceCodes[race] === raceName) {
            foundRace = race;
            return;
        }


         if (
         function updateUnlockedSummary() {
             classCode &&
             var summary = document.getElementById('cm-unlocked-summary');
             safeArray((data.raceClasses || {})[race]).indexOf(classCode) !== -1
             var statTotals = {};
        ) {
            var spellsAdded = {};
             foundRace = race;
            var spellsRemoved = {};
        }
             var hasStats = false;
    });
            var html = '';


    return foundRace || raceKeys[0] || '';
            if (!summary) return;
}


function syncFromCharacterBuilder() {
            Object.keys(selectedMasteries).forEach(function (name) {
    var builderState = getCharacterBuilderState();
                var cm = selectedMasteries[name];
    var raceName;
                var stats;
    var classCode;


    if (!builderState) return;
                if (!cm) return;


    raceName = findCmRaceForBuilderState(builderState);
    classCode = builderState.classCode;


    if (raceName && raceSelect.value !== raceName) {
                stats = cm.stats || {};
        raceSelect.value = raceName;
        loadClasses();
    }


    if (classCode) {
                Object.keys(stats).forEach(function (statName) {
        selectOptionIfExists(classSelect, classCode);
                    var value = parseInt(stats[statName], 10);
    }


    if (builderState.level !== undefined) {
                    if (!isNaN(value) && value !== 0) {
        levelInput.value = builderState.level;
                        if (!statTotals[statName]) {
    }
                            statTotals[statName] = 0;
                        }


    updateBaseStatInputsDisplay(getBaseStatsFromInputs());
                        statTotals[statName] += value;
                        hasStats = true;
                    }
                });


    setTimeout(function () {
                safeArray(cm.spellsAdded).forEach(function (spell) {
        updateSummary();
                    if (spell) {
    }, 0);
                        spellsAdded[spell] = true;
}
                    }
                });


window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;
                safeArray(cm.spellsRemoved).forEach(function (spell) {
                    if (spell) {
                        spellsRemoved[spell] = true;
                    }
                });
            });


            html += '<div class="cm-unlocked-grid cm-unlocked-grid-three">';


            html += '<div class="cm-unlocked-box">';
            html += '<div class="cm-panel-title">Stats</div>';


            if (!hasStats) {
                html += '<div class="cm-empty-line">No stat changes yet.</div>';
            } else {
                Object.keys(statTotals).sort().forEach(function (statName) {
                    var value = statTotals[statName];
                    var sign = value > 0 ? '+' : '';


                    html +=
                        '<div class="cm-purchased-item cm-stat-row">' +
                            '<span>' + escapeHtml(formatStatName(statName)) + '</span>' +
                            '<span class="cm-purchased-cost">' + sign + escapeHtml(value) + '</span>' +
                        '</div>';
                });
            }


            html += '</div>';


            html += '<div class="cm-unlocked-box">';
            html += '<div class="cm-panel-title">Spells Gained</div>';


            if (!Object.keys(spellsAdded).length) {
                html += '<div class="cm-empty-line">No spells gained yet.</div>';
            } else {
                Object.keys(spellsAdded).sort().forEach(function (spell) {
                    html +=
                        '<div class="cm-purchased-item cm-spell-row cm-spell-gained">' +
                            '<span>+ ' + escapeHtml(spell) + '</span>' +
                        '</div>';
                });
            }


            html += '</div>';


        function loadClasses() {
            html += '<div class="cm-unlocked-box">';
             classSelect.innerHTML = '';
             html += '<div class="cm-panel-title">Spells Removed</div>';


             var race = raceSelect.value;
             if (!Object.keys(spellsRemoved).length) {
             var classList = safeArray((data.raceClasses || {})[race]);
                html += '<div class="cm-empty-line">No spells removed yet.</div>';
             } else {
                Object.keys(spellsRemoved).sort().forEach(function (spell) {
                    html +=
                        '<div class="cm-purchased-item cm-spell-row cm-spell-removed">' +
                            '<span>- ' + escapeHtml(spell) + '</span>' +
                        '</div>';
                });
            }


             classList.forEach(function (classCode) {
             html += '</div>';
                addOption(
             html += '</div>';
                    classSelect,
                    classCode,
                    classCode + ' - ' + ((data.classes || {})[classCode] || classCode)
                );
             });


             updateSummary();
             summary.innerHTML = html;
         }
         }


         function getAvailablePoints() {
         function updateSummary() {
             var level = parseInt(levelInput.value, 10);
            var available = getAvailablePoints();
             var spent = getSpentPoints();
            var remaining = available - spent;


             if (isNaN(level) || level < 1) level = 1;
             if (isEmbeddedInCharacterBuilder) {
             if (level > 60) level = 60;
                updateBaseStatInputsDisplay(getBaseStatsFromInputs());
             }


             levelInput.value = level;
             document.getElementById('cm-available').textContent = available;
            document.getElementById('cm-spent').textContent = spent;
            document.getElementById('cm-remaining').textContent = remaining;


             return level * 25;
             updatePurchasedList();
            updateMasterClassPanel();
            updateUnlockedSummary();
            renderMasteryList();
         }
         }


         function getSpentPoints() {
         function buildCmTooltipHtml(cm) {
             var total = 0;
             var statLines = [];
            var spellAdds;
            var spellRemoves;
            var reqHtml = '';
            var statMinHtml = '';
            var statMinReqHtml = '';


             Object.keys(selectedMasteries).forEach(function (name) {
             if (!cm) return '';
                if (selectedMasteries[name]) {
                    total += selectedMasteries[name].points || 0;
                }
            });


             return total;
             Object.keys(cm.stats || {}).forEach(function (key) {
        }
                var value = cm.stats[key];


function getCmPointStatusText() {
                if (value) {
    var total = getAvailablePoints();
                    var sign = value > 0 ? '+' : '';
    var spent = getSpentPoints();
                    statLines.push(
    var left = total - spent;
                        '<div class="cm-tooltip-stat">' +
 
                            escapeHtml(formatStatName(key)) + ': ' + sign + escapeHtml(value) +
    if (left < 0) left = 0;
                        '</div>'
 
                    );
    return 'Total CMs: ' + total + ' | Spent: ' + spent + ' | Left: ' + left;
                }
}
            });


            spellAdds = safeArray(cm.spellsAdded).map(function (spell) {
                return '<div class="cm-tooltip-spell-add">+ ' + escapeHtml(spell) + '</div>';
            }).join('');


            spellRemoves = safeArray(cm.spellsRemoved).map(function (spell) {
                return '<div class="cm-tooltip-spell-remove">- ' + escapeHtml(spell) + '</div>';
            }).join('');


            if (safeArray(cm.requirements).length) {
                reqHtml =
                    '<div class="cm-tooltip-req">' +
                        'Requires: ' + getRequirementsHtml(cm) +
                    '</div>';
            }


            statMinHtml = getStatMinRequirementsHtml(cm);


            if (statMinHtml) {
                statMinReqHtml =
                    '<div class="cm-tooltip-req">' +
                        'Stat Req: ' + statMinHtml +
                    '</div>';
            }


            return (
                '<div class="cm-tooltip-title">' + escapeHtml(cm.name) + '</div>' +
                '<div class="cm-tooltip-desc">' + escapeHtml(cm.description) + '</div>' +
                '<div class="cm-tooltip-meta">' +
                    'Level ' + escapeHtml(cm.level) +
                    ' | Cost ' + escapeHtml(cm.points) +
                    (cm.cmSpentReq ? ' | CM Spent Req ' + escapeHtml(cm.cmSpentReq) : '') +
                '</div>' +
                '<div class="cm-tooltip-meta">' +
                    escapeHtml(getCmPointStatusText()) +
                '</div>' +
                reqHtml +
                statMinReqHtml +
                statLines.join('') +
                spellAdds +
                spellRemoves
            );
        }


         Array.prototype.forEach.call(viewButtons, function (button) {
         function showCmTooltip(cm, card) {
             button.addEventListener('click', function () {
             if (!cmTooltip || !cm) return;
                activeCmView = button.getAttribute('data-view') || 'detailed';


                Array.prototype.forEach.call(viewButtons, function (btn) {
            cmTooltip.innerHTML = buildCmTooltipHtml(cm);
                    btn.classList.remove('cm-view-active');
            cmTooltip.style.display = 'block';
                });


                button.classList.add('cm-view-active');
            var rect = card.getBoundingClientRect();
            var tooltipWidth = 340;
            var leftPos = rect.right + 12;


                renderMasteryList();
            if (leftPos + tooltipWidth > window.innerWidth) {
             });
                leftPos = rect.left - tooltipWidth - 12;
        });
             }


        function updatePurchasedList() {
             cmTooltip.style.left = leftPos + 'px';
             var list = document.getElementById('cm-purchased-list');
             cmTooltip.style.top = Math.max(10, rect.top) + 'px';
            var purchasedRows = [];
        }
            var groups = {
                'Master Class': [],
                'General': [],
                'Racial': [],
                'Archetype': [],
                'Class': [],
                'Other': []
            };
             var groupOrder = [
                'Master Class',
                'General',
                'Racial',
                'Archetype',
                'Class',
                'Other'
            ];
            var html = '';


             if (!list) return;
        function hideCmTooltip() {
             if (cmTooltip) {
                cmTooltip.style.display = 'none';
            }
        }


            Object.keys(selectedMasteries).forEach(function (name) {
        function renderMasteryList() {
                var cm = selectedMasteries[name];
            var masteryPanel = document.getElementById('cm-mastery-panel');
                var groupName;
            if (!masteryPanel) return;


                if (!cm) return;
            var selectedRace = raceSelect.value;
            var selectedRaceCode = (data.raceCodes || {})[selectedRace];
            var selectedClass = classSelect.value;
            var selectedPath = pathSelect ? pathSelect.value : '';
            var selectedPathNormalized = normalizePath(selectedPath);
            var selectedIsWere = isWerePath(selectedPathNormalized);
            var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : '';
            var seenMasteries = {};
            var availableMasteries = [];
            var lockedMasteries = [];


                if (isMasterClassTitle(cm)) {
            visibleMasteries = [];
                    groupName = 'Master Class';
                } else {
                    groupName = getCmDisplayTab(cm);


                    if (!groups[groupName]) {
            safeArray(data.masteries).forEach(function (cm) {
                        groupName = 'Other';
                var cmRaces = safeArray(cm.races);
                    }
                var cmClasses = safeArray(cm.classes);
                 }
                var cmName = String(cm.name || '');
                var cmPath = normalizePath(cm.lycanthropy);
                 var bitePath = getBitePath(cmName);


                 groups[groupName].push(cm);
                 var pathOk = false;
            });
                var raceOk;
                var classOk;
                var tabOk;
                var searchOk;
                var unlockHint;


            groupOrder.forEach(function (groupName) {
                 raceOk =
                 groups[groupName].sort(function (a, b) {
                     !cmRaces.length ||
                     return String(a.name || '').localeCompare(String(b.name || ''));
                    cmRaces.indexOf('All') !== -1 ||
                });
                    cmRaces.indexOf(selectedRaceCode) !== -1;
            });


            groupOrder.forEach(function (groupName) {
                classOk =
                if (!groups[groupName].length) return;
                    !cmClasses.length ||
                    cmClasses.indexOf('All') !== -1 ||
                    cmClasses.indexOf(selectedClass) !== -1;


                 html +=
                 tabOk = getCmDisplayTab(cm) === activeCmTab;
                    '<div class="cm-purchased-group">' +
                        '<div class="cm-purchased-group-title">' + escapeHtml(groupName) + '</div>';


                 groups[groupName].forEach(function (cm) {
                 searchOk =
                     var index = purchasedRows.length;
                     !searchQuery ||
                     purchasedRows.push(cm);
                     getCmSearchText(cm).indexOf(searchQuery) !== -1;


                     html +=
                if (bitePath) {
                        '<div class="cm-purchased-item" data-purchased-index="' + index + '">' +
                     pathOk = selectedPathNormalized === bitePath;
                            '<span>' + escapeHtml(cm.name) + '</span>' +
                } else if (!cmPath) {
                            '<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
                    pathOk = true;
                        '</div>';
                } else if (!selectedPathNormalized) {
                 });
                    pathOk = false;
                } else if (selectedIsWere && isSharedWereMastery(cmName)) {
                    pathOk = cmPath === 'werebeast' || isWerePath(cmPath);
                } else if (selectedIsWere && cmPath === 'werebeast') {
                    pathOk = true;
                 } else {
                    pathOk = cmPath === selectedPathNormalized;
                }


                 html += '</div>';
                 if (
            });
                    !raceOk ||
 
                    !classOk ||
            if (!html) {
                    !tabOk ||
                list.innerHTML = 'None purchased.';
                    !pathOk ||
                 return;
                    !searchOk ||
            }
                    selectedMasteries[cmName] ||
                    seenMasteries[cmName]
                ) {
                    return;
                 }


            list.innerHTML = html;
                seenMasteries[cmName] = true;


            Array.prototype.forEach.call(
                 unlockHint = getCmUnlockHint(cm);
                 list.querySelectorAll('.cm-purchased-item'),
                function (row) {
                    var index = parseInt(row.getAttribute('data-purchased-index'), 10);
                    var cm = purchasedRows[index];


                     row.addEventListener('mouseenter', function () {
                if (unlockHint) {
                         showCmTooltip(cm, row);
                     lockedMasteries.push({
                         cm: cm,
                        locked: true,
                        unlockHint: unlockHint
                     });
                     });
 
                } else {
                     row.addEventListener('mouseleave', hideCmTooltip);
                     availableMasteries.push({
 
                         cm: cm,
                    row.addEventListener('click', function () {
                         locked: false,
                         if (!cm) return;
                         unlockHint: ''
 
                        delete selectedMasteries[cm.name];
                         hideCmTooltip();
                         updateSummary();
 
                     });
                     });
                 }
                 }
             );
             });
        }


        function updateUnlockedSummary() {
            if (showLockedCms) {
            var summary = document.getElementById('cm-unlocked-summary');
                visibleMasteries = availableMasteries.concat(lockedMasteries);
             var statTotals = {};
             } else {
            var spellsAdded = {};
                visibleMasteries = availableMasteries;
             var spellsRemoved = {};
             }
            var hasStats = false;


             if (!summary) return;
             if (!visibleMasteries.length) {
                masteryPanel.innerHTML = 'No Class Masteries found for this selection.';
                return;
            }


             Object.keys(selectedMasteries).forEach(function (name) {
             masteryPanel.innerHTML = visibleMasteries.map(function (entry, index) {
                 var cm = selectedMasteries[name];
                var cm = entry.cm;
                 var stats;
                var lockedClass = entry.locked ? ' cm-card-locked' : '';
                var typeClass = getCmTypeClass(cm);
                var cmType = getCmType(cm);
                var requirementsHtml = getRequirementsHtml(cm);
                var statMinHtml = getStatMinRequirementsHtml(cm);
                 var chainHtml = getCmChainHtml(cm);
                var lockReasonsHtml = '';
                 var metaHtml;


                 if (!cm) return;
                 if (entry.locked) {
                    lockReasonsHtml =
                        '<div class="cm-unlock-hint">' +
                            '<strong>Locked:</strong><br>' +
                            getCmLockReasons(cm).map(function (reason) {
                                return escapeHtml(reason);
                            }).join('<br>') +
                        '</div>';
                }


                 stats = cm.stats || {};
                 metaHtml =
                    '<div class="cm-card-meta">' +
                        'Level: ' + escapeHtml(cm.level) +
                        ' | Cost: ' + escapeHtml(cm.points) +
                        (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
                    '</div>' +
                    '<div class="cm-card-meta">' +
                        escapeHtml(getCmPointStatusText()) +
                    '</div>';


                 Object.keys(stats).forEach(function (statName) {
                 if (activeCmView === 'compact') {
                    var value = parseInt(stats[statName], 10);
                    return (
 
                        '<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                    if (!isNaN(value) && value !== 0) {
                            '<div class="cm-card-compact-row">' +
                        if (!statTotals[statName]) {
                                '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
                             statTotals[statName] = 0;
                                '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
                        }
                                metaHtml +
                            '</div>' +
                            (statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
                            (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
                             lockReasonsHtml +
                        '</div>'
                    );
                }


                        statTotals[statName] += value;
                return (
                         hasStats = true;
                    '<div class="cm-card cm-purchasable-card ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                    }
                         '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
                });
                        '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
 
                        '<div class="cm-card-desc">' + escapeHtml(cm.description) + '</div>' +
                safeArray(cm.spellsAdded).forEach(function (spell) {
                        metaHtml +
                    if (spell) {
                        (requirementsHtml ? '<div class="cm-card-req">Requires: ' + requirementsHtml + '</div>' : '') +
                         spellsAdded[spell] = true;
                        (statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
                     }
                        (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
                 });
                         lockReasonsHtml +
                     '</div>'
                 );
            }).join('');


                 safeArray(cm.spellsRemoved).forEach(function (spell) {
            Array.prototype.forEach.call(
                     if (spell) {
                 masteryPanel.querySelectorAll('.cm-purchasable-card'),
                        spellsRemoved[spell] = true;
                function (card) {
                     }
                     var cmIndex = parseInt(card.getAttribute('data-cm-index'), 10);
                });
                    var entry = visibleMasteries[cmIndex];
            });
                     var cm = entry ? entry.cm : null;


            var html = '';
                    card.addEventListener('mouseenter', function () {
                        showCmTooltip(cm, card);
                    });


            html += '<div class="cm-unlocked-grid cm-unlocked-grid-three">';
                    card.addEventListener('mouseleave', hideCmTooltip);


            html += '<div class="cm-unlocked-box">';
                    card.addEventListener('click', function () {
            html += '<div class="cm-panel-title">Stats</div>';
                        var purchaseReasons;
                        var selectedMasterClassTitle;


            if (!hasStats) {
                        if (!cm || !entry) return;
                html += '<div class="cm-empty-line">No stat changes yet.</div>';
            } else {
                Object.keys(statTotals).sort().forEach(function (statName) {
                    var value = statTotals[statName];
                    var sign = value > 0 ? '+' : '';


                    html +=
                         if (entry.locked) {
                         '<div class="cm-purchased-item cm-stat-row">' +
                             alert(entry.unlockHint);
                            '<span>' + escapeHtml(formatStatName(statName)) + '</span>' +
                            return;
                             '<span class="cm-purchased-cost">' + sign + escapeHtml(value) + '</span>' +
                        }
                        '</div>';
                });
            }


            html += '</div>';
                        purchaseReasons = getCmLockReasons(cm, {
                            includePointBudget: true
                        });


            html += '<div class="cm-unlocked-box">';
                        if (purchaseReasons.length) {
            html += '<div class="cm-panel-title">Spells Gained</div>';
                            alert(purchaseReasons.join(' | '));
                            return;
                        }


            if (!Object.keys(spellsAdded).length) {
                        if (isMasterClassTitle(cm)) {
                html += '<div class="cm-empty-line">No spells gained yet.</div>';
                            selectedMasterClassTitle = getSelectedMasterClassTitle();
            } else {
                Object.keys(spellsAdded).sort().forEach(function (spell) {
                    html +=
                        '<div class="cm-purchased-item cm-spell-row cm-spell-gained">' +
                            '<span>+ ' + escapeHtml(spell) + '</span>' +
                        '</div>';
                });
            }


            html += '</div>';
                            if (selectedMasterClassTitle && selectedMasterClassTitle !== cm.name) {
                                alert('You can only select one Master Class Title per character build. Remove ' + selectedMasterClassTitle + ' first.');
                                return;
                            }
                        }


            html += '<div class="cm-unlocked-box">';
                        selectedMasteries[cm.name] = cm;
            html += '<div class="cm-panel-title">Spells Removed</div>';


            if (!Object.keys(spellsRemoved).length) {
                        hideCmTooltip();
                 html += '<div class="cm-empty-line">No spells removed yet.</div>';
                        updateSummary();
            } else {
                    });
                Object.keys(spellsRemoved).sort().forEach(function (spell) {
                 }
                    html +=
            );
                        '<div class="cm-purchased-item cm-spell-row cm-spell-removed">' +
        }
                            '<span>- ' + escapeHtml(spell) + '</span>' +
 
                        '</div>';
        raceSelect.addEventListener('change', loadClasses);
                 });
        classSelect.addEventListener('change', updateSummary);
        levelInput.addEventListener('input', updateSummary);
 
        [
            'cm-base-str',
            'cm-base-sta',
            'cm-base-dex',
            'cm-base-agi',
            'cm-base-int',
            'cm-base-wis',
            'cm-base-cha'
        ].forEach(function (id) {
            var input = document.getElementById(id);
 
            if (input) {
                 input.addEventListener('input', updateSummary);
             }
             }
        });


            html += '</div>';
        if (searchInput) {
             html += '</div>';
             searchInput.addEventListener('input', renderMasteryList);
 
            summary.innerHTML = html;
         }
         }


         function updateSummary() {
         if (clearSearchButton && searchInput) {
             var available = getAvailablePoints();
             clearSearchButton.addEventListener('click', function () {
            var spent = getSpentPoints();
                searchInput.value = '';
            var remaining = available - spent;
                renderMasteryList();
if (isEmbeddedInCharacterBuilder) {
                searchInput.focus();
    updateBaseStatInputsDisplay(getBaseStatsFromInputs());
            });
}
        }


        if (shareUrlButton) {
            shareUrlButton.addEventListener('click', function () {
                copyTextToClipboard(buildShareUrl(), shareUrlButton);
            });
        }


            document.getElementById('cm-available').textContent = available;
        if (exportCardButton) {
             document.getElementById('cm-spent').textContent = spent;
             exportCardButton.addEventListener('click', function () {
            document.getElementById('cm-remaining').textContent = remaining;
                renderBuildCard();
 
             });
            updatePurchasedList();
            updateMasterClassPanel();
             updateUnlockedSummary();
            renderMasteryList();
         }
         }


         function buildCmTooltipHtml(cm) {
         if (showLockedInput) {
            var statLines = [];
            showLockedInput.addEventListener('change', function () {
            var spellAdds;
                showLockedCms = showLockedInput.checked;
             var spellRemoves;
                renderMasteryList();
            var reqHtml = '';
             });
             var statMinHtml = '';
        }
            var statMinReqHtml = '';
 
        if (pathSelect) {
             pathSelect.addEventListener('change', updateSummary);
        }


            if (!cm) return '';
        if (isEmbeddedInCharacterBuilder) {
            window.addEventListener('eqoa-character-builder-state-change', syncFromCharacterBuilder);
        }


             Object.keys(cm.stats || {}).forEach(function (key) {
        Array.prototype.forEach.call(
                var value = cm.stats[key];
             document.querySelectorAll('.cm-tab-button'),
            function (button) {
                button.addEventListener('click', function () {
                    activeCmTab = button.getAttribute('data-tab');


                if (value) {
                     Array.prototype.forEach.call(
                     var sign = value > 0 ? '+' : '';
                         document.querySelectorAll('.cm-tab-button'),
                    statLines.push(
                        function (btn) {
                         '<div class="cm-tooltip-stat">' +
                             btn.classList.remove('cm-tab-active');
                             escapeHtml(formatStatName(key)) + ': ' + sign + escapeHtml(value) +
                         }
                         '</div>'
                     );
                     );
                }
            });


            spellAdds = safeArray(cm.spellsAdded).map(function (spell) {
                    button.classList.add('cm-tab-active');
                return '<div class="cm-tooltip-spell-add">+ ' + escapeHtml(spell) + '</div>';
            }).join('');


            spellRemoves = safeArray(cm.spellsRemoved).map(function (spell) {
                    updateSummary();
                 return '<div class="cm-tooltip-spell-remove">- ' + escapeHtml(spell) + '</div>';
                 });
            }).join('');
 
            if (safeArray(cm.requirements).length) {
                reqHtml =
                    '<div class="cm-tooltip-req">' +
                        'Requires: ' + getRequirementsHtml(cm) +
                    '</div>';
             }
             }
        );


            statMinHtml = getStatMinRequirementsHtml(cm);
        if (resetButton) {
 
             resetButton.addEventListener('click', function () {
             if (statMinHtml) {
                 selectedMasteries = {};
                 statMinReqHtml =
                activeCmTab = 'General';
                    '<div class="cm-tooltip-req">' +
                activeCmView = 'detailed';
                        'Stat Req: ' + statMinHtml +
                showLockedCms = true;
                    '</div>';
            }


            return (
                 levelInput.value = 60;
                 '<div class="cm-tooltip-title">' + escapeHtml(cm.name) + '</div>' +
                 setBaseStatInputs({});
                '<div class="cm-tooltip-desc">' + escapeHtml(cm.description) + '</div>' +
                 '<div class="cm-tooltip-meta">' +
    'Level ' + escapeHtml(cm.level) +
    ' | Cost ' + escapeHtml(cm.points) +
    (cm.cmSpentReq ? ' | CM Spent Req ' + escapeHtml(cm.cmSpentReq) : '') +
'</div>' +
'<div class="cm-tooltip-meta">' +
    escapeHtml(getCmPointStatusText()) +
'</div>' +


                 reqHtml +
                 if (pathSelect) {
                statMinReqHtml +
                    pathSelect.value = '';
                statLines.join('') +
                 }
                spellAdds +
                 spellRemoves
            );
        }


        function showCmTooltip(cm, card) {
                Array.prototype.forEach.call(
            if (!cmTooltip || !cm) return;
                    document.querySelectorAll('.cm-tab-button'),
                    function (btn) {
                        btn.classList.remove('cm-tab-active');
                    }
                );


            cmTooltip.innerHTML = buildCmTooltipHtml(cm);
                var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
            cmTooltip.style.display = 'block';
                if (generalTab) {
                    generalTab.classList.add('cm-tab-active');
                }


            var rect = card.getBoundingClientRect();
                Array.prototype.forEach.call(viewButtons, function (btn) {
            var tooltipWidth = 340;
                    btn.classList.remove('cm-view-active');
            var leftPos = rect.right + 12;
                });


            if (leftPos + tooltipWidth > window.innerWidth) {
                var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]');
                leftPos = rect.left - tooltipWidth - 12;
                if (detailedView) {
            }
                    detailedView.classList.add('cm-view-active');
                }


            cmTooltip.style.left = leftPos + 'px';
                if (searchInput) {
            cmTooltip.style.top = Math.max(10, rect.top) + 'px';
                    searchInput.value = '';
        }
                }


        function hideCmTooltip() {
                if (showLockedInput) {
            if (cmTooltip) {
                    showLockedInput.checked = true;
                 cmTooltip.style.display = 'none';
                }
             }
 
                hideCmTooltip();
                 updateSummary();
             });
         }
         }


         function renderMasteryList() {
         loadRaces();
            var masteryPanel = document.getElementById('cm-mastery-panel');
            if (!masteryPanel) return;
 
            var selectedRace = raceSelect.value;
            var selectedRaceCode = (data.raceCodes || {})[selectedRace];
            var selectedClass = classSelect.value;
            var selectedPath = pathSelect ? pathSelect.value : '';
            var selectedPathNormalized = normalizePath(selectedPath);
            var selectedIsWere = isWerePath(selectedPathNormalized);
            var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : '';
            var seenMasteries = {};
            var availableMasteries = [];
            var lockedMasteries = [];
 
            visibleMasteries = [];
 
            safeArray(data.masteries).forEach(function (cm) {
                var cmRaces = safeArray(cm.races);
                var cmClasses = safeArray(cm.classes);
                var cmName = String(cm.name || '');
                var cmPath = normalizePath(cm.lycanthropy);
                var bitePath = getBitePath(cmName);
                var pathOk = false;
 
                var raceOk =
                    !cmRaces.length ||
                    cmRaces.indexOf('All') !== -1 ||
                    cmRaces.indexOf(selectedRaceCode) !== -1;
 
                var classOk =
                    !cmClasses.length ||
                    cmClasses.indexOf('All') !== -1 ||
                    cmClasses.indexOf(selectedClass) !== -1;
 
                var tabOk = getCmDisplayTab(cm) === activeCmTab;
 
                var searchOk =
                    !searchQuery ||
                    getCmSearchText(cm).indexOf(searchQuery) !== -1;
 
                if (bitePath) {
                    pathOk = selectedPathNormalized === bitePath;
                } else if (!cmPath) {
                    pathOk = true;
                } else if (!selectedPathNormalized) {
                    pathOk = false;
                } else if (selectedIsWere && isSharedWereMastery(cmName)) {
                    pathOk = cmPath === 'werebeast' || isWerePath(cmPath);
                } else if (selectedIsWere && cmPath === 'werebeast') {
                    pathOk = true;
                } else {
                    pathOk = cmPath === selectedPathNormalized;
                }
 
                if (
                    !raceOk ||
                    !classOk ||
                    !tabOk ||
                    !pathOk ||
                    !searchOk ||
                    selectedMasteries[cmName] ||
                    seenMasteries[cmName]
                ) {
                    return;
                }
 
                seenMasteries[cmName] = true;
 
                var unlockHint = getCmUnlockHint(cm);
 
                if (unlockHint) {
                    lockedMasteries.push({
                        cm: cm,
                        locked: true,
                        unlockHint: unlockHint
                    });
                } else {
                    availableMasteries.push({
                        cm: cm,
                        locked: false,
                        unlockHint: ''
                    });
                }
            });
 
            if (showLockedCms) {
                visibleMasteries = availableMasteries.concat(lockedMasteries);
            } else {
                visibleMasteries = availableMasteries;
            }
 
            if (!visibleMasteries.length) {
                masteryPanel.innerHTML = 'No Class Masteries found for this selection.';
                return;
            }
 
            masteryPanel.innerHTML = visibleMasteries.map(function (entry, index) {
                var cm = entry.cm;
                var lockedClass = entry.locked ? ' cm-card-locked' : '';
                var typeClass = getCmTypeClass(cm);
                var cmType = getCmType(cm);
                var requirementsHtml = getRequirementsHtml(cm);
                var statMinHtml = getStatMinRequirementsHtml(cm);
                var chainHtml = getCmChainHtml(cm);
var lockReasonsHtml = '';
 
if (entry.locked) {
    lockReasonsHtml =
        '<div class="cm-unlock-hint">' +
            '<strong>Locked:</strong><br>' +
            getCmLockReasons(cm).map(function (reason) {
                return escapeHtml(reason);
            }).join('<br>') +
        '</div>';
}
 
 
                                var metaHtml =
                    '<div class="cm-card-meta">' +
                        'Level: ' + escapeHtml(cm.level) +
                        ' | Cost: ' + escapeHtml(cm.points) +
                        (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
                    '</div>' +
                    '<div class="cm-card-meta">' +
                        escapeHtml(getCmPointStatusText()) +
                    '</div>';
 
 
 
 
                if (activeCmView === 'compact') {
                    return (
                        '<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                            '<div class="cm-card-compact-row">' +
                                '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
                                '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
                                metaHtml +
                            '</div>' +
                            (statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
                            (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
                            lockReasonsHtml +
                        '</div>'
                    );
                }
 
                return (
                    '<div class="cm-card cm-purchasable-card ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                        '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
                        '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
                        '<div class="cm-card-desc">' + escapeHtml(cm.description) + '</div>' +
                        metaHtml +
                        (requirementsHtml ? '<div class="cm-card-req">Requires: ' + requirementsHtml + '</div>' : '') +
                        (statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
                        (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
                        lockReasonsHtml +
                    '</div>'
                );
            }).join('');
 
            Array.prototype.forEach.call(
                masteryPanel.querySelectorAll('.cm-purchasable-card'),
                function (card) {
                    var cmIndex = parseInt(card.getAttribute('data-cm-index'), 10);
                    var entry = visibleMasteries[cmIndex];
                    var cm = entry ? entry.cm : null;
 
                    card.addEventListener('mouseenter', function () {
                        showCmTooltip(cm, card);
                    });
 
                    card.addEventListener('mouseleave', hideCmTooltip);
 
                    card.addEventListener('click', function () {
                        var available;
                        var spent;
                        var selectedMasterClassTitle;
 
                        if (!cm || !entry) return;
 
                        if (entry.locked) {
                            alert(entry.unlockHint);
                            return;
                        }
 
                        var purchaseReasons = getCmLockReasons(cm, {
    includePointBudget: true
});
 
if (purchaseReasons.length) {
    alert(purchaseReasons.join(' | '));
    return;
}
 
 
                        if (isMasterClassTitle(cm)) {
                            selectedMasterClassTitle = getSelectedMasterClassTitle();
 
                            if (selectedMasterClassTitle && selectedMasterClassTitle !== cm.name) {
                                alert('You can only select one Master Class Title per character build. Remove ' + selectedMasterClassTitle + ' first.');
                                return;
                            }
                        }
 
                        selectedMasteries[cm.name] = cm;
 
                        hideCmTooltip();
                        updateSummary();
                    });
                }
            );
        }
 
        raceSelect.addEventListener('change', loadClasses);
        classSelect.addEventListener('change', updateSummary);
        levelInput.addEventListener('input', updateSummary);
 
        [
            'cm-base-str',
            'cm-base-sta',
            'cm-base-dex',
            'cm-base-agi',
            'cm-base-int',
            'cm-base-wis',
            'cm-base-cha'
        ].forEach(function (id) {
            var input = document.getElementById(id);
 
            if (input) {
                input.addEventListener('input', updateSummary);
            }
        });
 
        if (searchInput) {
            searchInput.addEventListener('input', renderMasteryList);
        }
 
        if (clearSearchButton && searchInput) {
            clearSearchButton.addEventListener('click', function () {
                searchInput.value = '';
                renderMasteryList();
                searchInput.focus();
            });
        }
 
        if (shareUrlButton) {
            shareUrlButton.addEventListener('click', function () {
                copyTextToClipboard(buildShareUrl(), shareUrlButton);
            });
        }
 
        if (exportCardButton) {
            exportCardButton.addEventListener('click', function () {
                renderBuildCard();
            });
        }
 
        if (showLockedInput) {
            showLockedInput.addEventListener('change', function () {
                showLockedCms = showLockedInput.checked;
                renderMasteryList();
            });
        }
 
        if (pathSelect) {
            pathSelect.addEventListener('change', updateSummary);
        }
if (isEmbeddedInCharacterBuilder) {
    window.addEventListener('eqoa-character-builder-state-change', syncFromCharacterBuilder);
}
 
 
        Array.prototype.forEach.call(
            document.querySelectorAll('.cm-tab-button'),
            function (button) {
                button.addEventListener('click', function () {
                    activeCmTab = button.getAttribute('data-tab');
 
                    Array.prototype.forEach.call(
                        document.querySelectorAll('.cm-tab-button'),
                        function (btn) {
                            btn.classList.remove('cm-tab-active');
                        }
                    );
 
                    button.classList.add('cm-tab-active');
 
                    updateSummary();
                });
            }
        );
 
        if (resetButton) {
            resetButton.addEventListener('click', function () {
                selectedMasteries = {};
 
                activeCmTab = 'General';
                activeCmView = 'detailed';
                showLockedCms = true;
 
                levelInput.value = 60;
                setBaseStatInputs({});
 
                if (pathSelect) {
                    pathSelect.value = '';
                }
 
                Array.prototype.forEach.call(
                    document.querySelectorAll('.cm-tab-button'),
                    function (btn) {
                        btn.classList.remove('cm-tab-active');
                    }
                );
 
                var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
                if (generalTab) {
                    generalTab.classList.add('cm-tab-active');
                }


                Array.prototype.forEach.call(viewButtons, function (btn) {
        if (isEmbeddedInCharacterBuilder) {
                    btn.classList.remove('cm-view-active');
            syncFromCharacterBuilder();
                });
        } else {
 
            loadBuildFromUrl();
                var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]');
                if (detailedView) {
                    detailedView.classList.add('cm-view-active');
                }
 
                if (searchInput) {
                    searchInput.value = '';
                }
 
                if (showLockedInput) {
                    showLockedInput.checked = true;
                }
 
                hideCmTooltip();
                updateSummary();
            });
         }
         }
        loadRaces();
if (isEmbeddedInCharacterBuilder) {
    syncFromCharacterBuilder();
} else {
    loadBuildFromUrl();
}
     }
     }
});
});

Revision as of 02:10, 16 May 2026

mw.hook('wikipage.content').add(function () {
    function waitForCMData(attemptsLeft) {
        if (window.EQOA_CM_DATA) {
            initClassMasteryCalculator();
            return;
        }

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

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

    waitForCMData(30);

    function initClassMasteryCalculator() {
        var app = document.getElementById('cm-calculator-app');
        if (!app) return;

        var data = window.EQOA_CM_DATA || {};
        var selectedMasteries = {};
        var activeCmTab = 'General';
        var activeCmView = 'detailed';
        var showLockedCms = true;
        var visibleMasteries = [];
        var isEmbeddedInCharacterBuilder = !!window.EQOA_CHARACTER_BUILDER_STATE;

        var BASE_STAT_REQ_FIELDS = {
            STR: 'Base STR Req',
            STA: 'Base STA Req',
            DEX: 'Base DEX Req',
            AGI: 'Base AGI Req',
            INT: 'Base INT Req',
            WIS: 'Base WIS Req',
            CHA: 'Base CHA Req'
        };

        function safeArray(value) {
            return Object.prototype.toString.call(value) === '[object Array]' ? value : [];
        }

        function numberOrZero(value) {
            var n = Number(String(value || '').trim());
            return isFinite(n) ? n : 0;
        }

        function escapeHtml(value) {
            return String(value == null ? '' : value)
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#039;');
        }

        function getCharacterBuilderState() {
            if (
                window.EQOA_CHARACTER_BUILDER_STATE &&
                typeof window.EQOA_CHARACTER_BUILDER_STATE.getState === 'function'
            ) {
                return window.EQOA_CHARACTER_BUILDER_STATE.getState();
            }

            return null;
        }

        function addSelectedCmBaseStatGains(baseStats) {
            var result = {
                STR: numberOrZero(baseStats.STR),
                STA: numberOrZero(baseStats.STA),
                DEX: numberOrZero(baseStats.DEX),
                AGI: numberOrZero(baseStats.AGI),
                INT: numberOrZero(baseStats.INT),
                WIS: numberOrZero(baseStats.WIS),
                CHA: numberOrZero(baseStats.CHA)
            };

            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var stats = cm && cm.stats ? cm.stats : {};

                result.STR += numberOrZero(stats.str);
                result.STA += numberOrZero(stats.sta);
                result.DEX += numberOrZero(stats.dex);
                result.AGI += numberOrZero(stats.agi);
                result.INT += numberOrZero(stats.int);
                result.WIS += numberOrZero(stats.wis);
                result.CHA += numberOrZero(stats.cha);
            });

            return result;
        }

        function updateBaseStatInputsDisplay(baseStats) {
            var map = {
                STR: 'cm-base-str',
                STA: 'cm-base-sta',
                DEX: 'cm-base-dex',
                AGI: 'cm-base-agi',
                INT: 'cm-base-int',
                WIS: 'cm-base-wis',
                CHA: 'cm-base-cha'
            };

            Object.keys(map).forEach(function (stat) {
                var input = document.getElementById(map[stat]);

                if (input) {
                    input.value = numberOrZero(baseStats[stat]);
                }
            });
        }

        function getBaseStatsFromInputs() {
            var builderState = getCharacterBuilderState();
            var baseStats;

            if (builderState && builderState.baseStats) {
                baseStats = builderState.baseStats;
            } else {
                baseStats = {
                    STR: numberOrZero(document.getElementById('cm-base-str') && document.getElementById('cm-base-str').value),
                    STA: numberOrZero(document.getElementById('cm-base-sta') && document.getElementById('cm-base-sta').value),
                    DEX: numberOrZero(document.getElementById('cm-base-dex') && document.getElementById('cm-base-dex').value),
                    AGI: numberOrZero(document.getElementById('cm-base-agi') && document.getElementById('cm-base-agi').value),
                    INT: numberOrZero(document.getElementById('cm-base-int') && document.getElementById('cm-base-int').value),
                    WIS: numberOrZero(document.getElementById('cm-base-wis') && document.getElementById('cm-base-wis').value),
                    CHA: numberOrZero(document.getElementById('cm-base-cha') && document.getElementById('cm-base-cha').value)
                };
            }

            return addSelectedCmBaseStatGains(baseStats);
        }

        function applyRawBaseStatRequirements() {
            var raw = window.EQOA_CM_RAW || '';
            var lines = String(raw).split(/\r?\n/);
            var headerLineIndex = -1;
            var headers = [];
            var headerIndex = {};
            var rawRows = [];
            var reqsByName = {};

            function getExemplarPathFromQuest(quest) {
                var text = String(quest || '');
                var match = text.match(/Exemplar-([^=\s]+)=99/i);
                var token;
                var classCodes = [
                    'WAR', 'RAN', 'PAL', 'MNK', 'BRD', 'RGE', 'ROG',
                    'DRD', 'SHA', 'MAG', 'NEC', 'ENC', 'WIZ', 'ALC',
                    'SK', 'CL'
                ];
                var foundClass = '';
                var path = '';

                if (!match) return null;

                token = match[1];

                if (token.indexOf('-') !== -1) {
                    foundClass = token.split('-')[0];
                    path = token.split('-').slice(1).join('-');
                } else {
                    classCodes.some(function (classCode) {
                        if (token.toUpperCase().indexOf(classCode) === 0) {
                            foundClass = classCode;
                            path = token.slice(classCode.length);
                            return true;
                        }

                        return false;
                    });
                }

                if (!foundClass || !path) return null;

                return {
                    classCode: foundClass.toUpperCase(),
                    path: path,
                    quest: match[0]
                };
            }

            lines.forEach(function (line, index) {
                if (headerLineIndex === -1 && line.indexOf('Mastery\tDescription') === 0) {
                    headerLineIndex = index;
                }
            });

            if (headerLineIndex === -1) {
                return;
            }

            headers = lines[headerLineIndex].split('\t');

            headers.forEach(function (header, index) {
                headerIndex[String(header).trim()] = index;
            });

            lines.slice(headerLineIndex + 1).forEach(function (line) {
                var cols;
                var name;
                var quest;
                var exemplarPath;
                var reqs = {};

                if (!line.trim()) return;

                cols = line.split('\t');
                name = cols[headerIndex.Mastery];
                quest = headerIndex.Quest === undefined ? '' : cols[headerIndex.Quest];

                if (!name) return;

                Object.keys(BASE_STAT_REQ_FIELDS).forEach(function (stat) {
                    var field = BASE_STAT_REQ_FIELDS[stat];
                    var columnIndex = headerIndex[field];
                    var value;

                    if (columnIndex === undefined) return;

                    value = numberOrZero(cols[columnIndex]);

                    if (value > 0) {
                        reqs[stat] = value;
                    }
                });

                exemplarPath = getExemplarPathFromQuest(quest);

                rawRows.push({
                    name: name,
                    reqs: reqs,
                    quest: quest,
                    exemplarPath: exemplarPath
                });

                if (Object.keys(reqs).length) {
                    if (!reqsByName[name]) {
                        reqsByName[name] = [];
                    }

                    reqsByName[name].push(reqs);
                }
            });

            safeArray(data.masteries).forEach(function (cm, index) {
                var row;
                var namedReqs;

                if (!cm || !cm.name) return;

                row = rawRows[index];

                if (row && row.name === cm.name) {
                    cm.rawQuest = row.quest || '';

                    if (Object.keys(row.reqs).length) {
                        cm.baseStatReqs = row.reqs;
                    }

                    if (row.exemplarPath) {
                        cm.exemplarPath = row.exemplarPath;
                    }

                    return;
                }

                namedReqs = reqsByName[cm.name];

                if (namedReqs && namedReqs.length) {
                    cm.baseStatReqs = namedReqs[0];
                }
            });
        }

        applyRawBaseStatRequirements();

        app.innerHTML =
            '<div class="cm-calculator-box">' +
                '<div class="character-builder-title" style="display:none;">EQOA: Sandstorm Class Mastery Builder</div>' +

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

                    '<label>Class</label>' +
                    '<select id="cm-class"></select>' +

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

                    '<label>Base Stats</label>' +
                    '<span>' +
                        'STR <input type="number" id="cm-base-str" min="0" value="0" style="width:55px;"> ' +
                        'STA <input type="number" id="cm-base-sta" min="0" value="0" style="width:55px;"> ' +
                        'DEX <input type="number" id="cm-base-dex" min="0" value="0" style="width:55px;"> ' +
                        'AGI <input type="number" id="cm-base-agi" min="0" value="0" style="width:55px;"> ' +
                        'INT <input type="number" id="cm-base-int" min="0" value="0" style="width:55px;"> ' +
                        'WIS <input type="number" id="cm-base-wis" min="0" value="0" style="width:55px;"> ' +
                        'CHA <input type="number" id="cm-base-cha" min="0" value="0" style="width:55px;">' +
                    '</span>' +

                    '<label>Transformation Path</label>' +
                    '<select id="cm-path">' +
                        '<option value="">None</option>' +
                        '<option value="Werelion">Lionwere</option>' +
                        '<option value="Werebear">Bearwere</option>' +
                        '<option value="Werewolf">Wolfwere</option>' +
                        '<option value="Wererat">Ratwere</option>' +
                        '<option value="Weregator">Gatorwere</option>' +
                        '<option value="Vampire">Vampire</option>' +
                        '<option value="Were Hunter">Were-Hunter</option>' +
                    '</select>' +

                    '<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
                '</div>' +

                '<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' +

                '<table class="character-builder-stat-table">' +
                    '<tr>' +
                        '<th>Available</th>' +
                        '<th>Spent</th>' +
                        '<th>Remaining</th>' +
                    '</tr>' +
                    '<tr>' +
                        '<td id="cm-available">1500</td>' +
                        '<td id="cm-spent">0</td>' +
                        '<td id="cm-remaining">1500</td>' +
                    '</tr>' +
                '</table>' +

                '<div class="character-builder-section-title" style="margin-top:18px;">Master Class Title</div>' +

                '<div id="cm-master-class-panel" class="cm-master-class-panel">' +
                    '<span class="cm-master-class-label">None selected</span>' +
                '</div>' +

                '<div class="cm-build-actions">' +
                    '<button type="button" id="cm-share-url" class="cm-action-button">Generate Share URL</button>' +
                    '<button type="button" id="cm-export-card" class="cm-action-button">Export Build Card</button>' +
                '</div>' +

                '<div id="cm-build-card-panel" class="cm-build-card-panel" style="display:none;"></div>' +

                '<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +

                '<div id="cm-unlocked-summary">' +
                    'No stats or spells gained yet.' +
                '</div>' +

                '<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' +

                '<div class="cm-tabs">' +
                    '<button type="button" class="cm-tab-button cm-tab-active" data-tab="General">General</button>' +
                    '<button type="button" class="cm-tab-button" data-tab="Racial">Racial</button>' +
                    '<button type="button" class="cm-tab-button" data-tab="Archetype">Archetype</button>' +
                    '<button type="button" class="cm-tab-button" data-tab="Class">Class</button>' +
                '</div>' +

                '<div class="cm-search-tools">' +
                    '<div class="cm-search-row">' +
                        '<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' +
                        '<button type="button" id="cm-clear-search" class="cm-clear-search-button" title="Clear search">Clear</button>' +
                    '</div>' +

                    '<label class="cm-lock-toggle">' +
                        '<input type="checkbox" id="cm-show-locked" checked>' +
                        '<span>Show locked</span>' +
                    '</label>' +
                '</div>' +

                '<div class="cm-view-toggle">' +
                    '<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
                    '<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
                '</div>' +

                '<div class="cm-main-layout">' +
                    '<div class="cm-purchased-panel">' +
                        '<div class="cm-panel-title">CM Purchased</div>' +
                        '<div id="cm-purchased-list">None purchased.</div>' +
                    '</div>' +

                    '<div id="cm-mastery-panel" class="character-builder-warning-box">' +
                        'Select a race and class to begin.' +
                    '</div>' +
                '</div>' +

                '<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
            '</div>';

        var raceSelect = document.getElementById('cm-race');
        var classSelect = document.getElementById('cm-class');
        var levelInput = document.getElementById('cm-level');
        var pathSelect = document.getElementById('cm-path');
        var resetButton = document.getElementById('cm-reset');
        var searchInput = document.getElementById('cm-search');
        var clearSearchButton = document.getElementById('cm-clear-search');
        var showLockedInput = document.getElementById('cm-show-locked');
        var viewButtons = document.querySelectorAll('.cm-view-button');
        var cmTooltip = document.getElementById('cm-tooltip');
        var shareUrlButton = document.getElementById('cm-share-url');
        var exportCardButton = document.getElementById('cm-export-card');

        if (isEmbeddedInCharacterBuilder) {
            if (raceSelect) raceSelect.disabled = true;
            if (classSelect) classSelect.disabled = true;
            if (levelInput) levelInput.disabled = true;
        }

        function formatStatName(statName) {
            var map = {
                ac: 'AC',
                hp: 'HP',
                pow: 'Power',
                str: 'STR',
                sta: 'STA',
                agi: 'AGI',
                dex: 'DEX',
                wis: 'WIS',
                int: 'INT',
                cha: 'CHA',

                STR: 'STR',
                STA: 'STA',
                AGI: 'AGI',
                DEX: 'DEX',
                WIS: 'WIS',
                INT: 'INT',
                CHA: 'CHA',
                strMax: 'STR Max',
                staMax: 'STA Max',
                agiMax: 'AGI Max',
                dexMax: 'DEX Max',
                wisMax: 'WIS Max',
                intMax: 'INT Max',
                chaMax: 'CHA Max',
                offMod: 'Offense Mod',
                defMod: 'Defense Mod',
                hasteMod: 'Haste Mod',
                regenMod: 'Regen Mod',
                powerRegenMod: 'Power Regen Mod',
                healMod: 'Healing Mod',
                spellMod: 'Spell Mod',
                meleeMod: 'Melee Mod',
                fireResist: 'Fire Resist',
                coldResist: 'Cold Resist',
                diseaseResist: 'Disease Resist',
                poisonResist: 'Poison Resist',
                magicResist: 'Magic Resist',
                fireResistMax: 'Fire Resist Max',
                coldResistMax: 'Cold Resist Max',
                diseaseResistMax: 'Disease Resist Max',
                poisonResistMax: 'Poison Resist Max',
                magicResistMax: 'Magic Resist Max'
            };

            if (map[statName]) {
                return map[statName];
            }

            return String(statName || '')
                .replace(/([a-z])([A-Z])/g, '$1 $2')
                .replace(/^./, function (letter) {
                    return letter.toUpperCase();
                });
        }

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

        function normalizePath(value) {
            value = String(value || '').toLowerCase().replace(/[\s-]/g, '');

            var map = {
                lionwere: 'werelion',
                werelion: 'werelion',
                bearwere: 'werebear',
                werebear: 'werebear',
                wolfwere: 'werewolf',
                werewolf: 'werewolf',
                ratwere: 'wererat',
                wererat: 'wererat',
                gatorwere: 'weregator',
                weregator: 'weregator',
                vampire: 'vampire',
                werehunter: 'werehunter',
                hunterwere: 'werehunter',
                werebeast: 'werebeast'
            };

            return map[value] || value;
        }

        function getBitePath(name) {
            name = String(name || '').toLowerCase();

            if (name.indexOf('lion bite') !== -1) return 'werelion';
            if (name.indexOf('bear bite') !== -1) return 'werebear';
            if (name.indexOf('wolf bite') !== -1) return 'werewolf';
            if (name.indexOf('rat bite') !== -1) return 'wererat';
            if (name.indexOf('gator bite') !== -1) return 'weregator';

            return '';
        }

        function isSharedWereMastery(name) {
            return name === 'Lycanthropic Resistances' ||
                name === 'Lycanthropic Regeneration' ||
                name === 'Combat Senses';
        }

        function isWerePath(path) {
            return path === 'werelion' ||
                path === 'werebear' ||
                path === 'werewolf' ||
                path === 'wererat' ||
                path === 'weregator';
        }

        function isMasterClassTitle(cm) {
            var name = String((cm && cm.name) || '');

            return name === 'Berserker' ||
                name === 'Chosen' ||
                name === 'Deputy' ||
                name === 'Doomseeker' ||
                name === 'Immortal' ||
                name === 'Juggernaut' ||
                name === 'Mercenary' ||
                name === 'Savage' ||
                name === 'Scholar' ||
                name === 'Tinker' ||
                name === 'Arcane Were-Hunter' ||
                name === 'Divine Were-Hunter' ||
                name === 'Gypsy Were-Hunter' ||
                name === 'Heroic Were-Hunter' ||
                name === 'Metabolist' ||
                name === 'Transmuter' ||
                name === 'Gypsy' ||
                name === 'Minstrel' ||
                name === 'Exorcist' ||
                name === 'Faithful' ||
                name === 'Storm Caller' ||
                name === 'Wilding' ||
                name === 'Animator' ||
                name === 'Spellbinder' ||
                name === 'Aeromancer' ||
                name === 'Geomancer' ||
                name === 'Hydromancer' ||
                name === 'Pyromancer' ||
                name === 'Ninja' ||
                name === 'Sensei' ||
                name === 'Defiler' ||
                name === 'Lich' ||
                name === 'Crusader' ||
                name === 'Guardian' ||
                name === 'Forester' ||
                name === 'Hunter' ||
                name === 'Assassin' ||
                name === 'Burglar' ||
                name === 'Death Knight' ||
                name === 'Slayer' ||
                name === 'Elder' ||
                name === 'Mystic' ||
                name === 'Defender' ||
                name === 'Hero' ||
                name === 'Channeler' ||
                name === 'Sorcerer' ||
                name === 'Bravo' ||
                name === 'Diviner' ||
                name === 'Sage' ||
                name === 'Soldier';
        }

        function getSelectedMasterClassTitle() {
            var selectedTitle = '';

            Object.keys(selectedMasteries).forEach(function (name) {
                if (isMasterClassTitle(selectedMasteries[name])) {
                    selectedTitle = name;
                }
            });

            return selectedTitle;
        }

        function getCmStatMinRequirements(cm) {
            var reqs = [];

            if (!cm || !cm.baseStatReqs) return reqs;

            Object.keys(cm.baseStatReqs).forEach(function (statName) {
                var value = numberOrZero(cm.baseStatReqs[statName]);

                if (value > 0) {
                    reqs.push({
                        stat: statName,
                        value: value
                    });
                }
            });

            return reqs;
        }

        function getStatMinRequirementsHtml(cm) {
            var reqs = getCmStatMinRequirements(cm);

            if (!reqs.length) return '';

            return reqs.map(function (req) {
                return '<span class="cm-stat-min-req">' +
                    'Base ' +
                    escapeHtml(formatStatName(req.stat)) +
                    ' ' +
                    escapeHtml(req.value) +
                '</span>';
            }).join(', ');
        }

        function getCmSearchText(cm) {
            var parts = [];
            var stats = cm && cm.stats ? cm.stats : {};
            var statMinReqs;

            if (!cm) return '';

            parts.push(cm.name || '');
            parts.push(cm.description || '');
            parts.push(cm.classification || '');
            parts.push(cm.lycanthropy || '');

            Object.keys(stats).forEach(function (statName) {
                parts.push(statName);
                parts.push(formatStatName(statName));
                parts.push(stats[statName]);
            });

            statMinReqs = getCmStatMinRequirements(cm);
            statMinReqs.forEach(function (req) {
                parts.push(req.stat);
                parts.push(formatStatName(req.stat));
                parts.push(req.value);
            });

            safeArray(cm.spellsAdded).forEach(function (spell) {
                parts.push(spell);
            });

            safeArray(cm.spellsRemoved).forEach(function (spell) {
                parts.push(spell);
            });

            safeArray(cm.requirements).forEach(function (req) {
                parts.push(req);
            });

            return parts.join(' ').toLowerCase();
        }

        function getRequirementHtml(reqName) {
            var owned = !!selectedMasteries[reqName];
            var className = owned ? 'cm-req-owned' : 'cm-req-missing';

            return '<span class="' + className + '">' + escapeHtml(reqName) + '</span>';
        }

        function getRequirementsHtml(cm) {
            var reqs = safeArray(cm && cm.requirements);

            if (!reqs.length) return '';

            return reqs.map(function (reqName) {
                return getRequirementHtml(reqName);
            }).join(', ');
        }

        function getCmChainBase(name) {
            return String(name || '').replace(/\s+\d+$/, '');
        }

        function getCmChainNumber(name) {
            var match = String(name || '').match(/\s+(\d+)$/);

            return match ? parseInt(match[1], 10) : null;
        }

        function getCmChain(cm) {
            var base;
            var currentNumber;
            var chain = [];

            if (!cm || !cm.name) return [];

            base = getCmChainBase(cm.name);
            currentNumber = getCmChainNumber(cm.name);

            if (currentNumber === null) return [];

            safeArray(data.masteries).forEach(function (item) {
                if (
                    item &&
                    getCmChainBase(item.name) === base &&
                    getCmChainNumber(item.name) !== null
                ) {
                    chain.push(item.name);
                }
            });

            chain.sort(function (a, b) {
                return getCmChainNumber(a) - getCmChainNumber(b);
            });

            return chain;
        }

        function getCmChainHtml(cm) {
            var chain = getCmChain(cm);

            if (chain.length <= 1) return '';

            return chain.map(function (name) {
                var owned = !!selectedMasteries[name];
                var current = cm && cm.name === name;
                var className = 'cm-chain-step';

                if (owned) {
                    className += ' cm-chain-owned';
                }

                if (current) {
                    className += ' cm-chain-current';
                }

                return '<span class="' + className + '">' + escapeHtml(name) + '</span>';
            }).join('<span class="cm-chain-arrow"> &gt; </span>');
        }

        function getCompressedMasteryNames(names) {
            var groups = {};
            var singles = [];

            names.forEach(function (name) {
                var base = getCmChainBase(name);
                var number = getCmChainNumber(name);

                if (number === null) {
                    singles.push(name);
                    return;
                }

                if (!groups[base]) {
                    groups[base] = [];
                }

                groups[base].push(number);
            });

            Object.keys(groups).forEach(function (base) {
                groups[base].sort(function (a, b) {
                    return a - b;
                });

                if (groups[base].length === 1) {
                    singles.push(base + ' ' + groups[base][0]);
                } else {
                    singles.push(base + ' ' + groups[base].join(' > '));
                }
            });

            return singles.sort();
        }

        function getSelectedExemplarPathForClass(classCode) {
            var selectedPath = null;

            classCode = String(classCode || '').toUpperCase();

            Object.keys(selectedMasteries).forEach(function (name) {
                var selectedCm = selectedMasteries[name];
                var exemplarPath = selectedCm && selectedCm.exemplarPath;

                if (!exemplarPath || selectedPath) return;

                if (String(exemplarPath.classCode || '').toUpperCase() === classCode) {
                    selectedPath = exemplarPath;
                }
            });

            return selectedPath;
        }

        function getExemplarPathLockReason(cm) {
            var exemplarPath = cm && cm.exemplarPath;
            var selectedPath;

            if (!exemplarPath) return '';

            selectedPath = getSelectedExemplarPathForClass(exemplarPath.classCode);

            if (!selectedPath) return '';

            if (String(selectedPath.path) === String(exemplarPath.path)) {
                return '';
            }

            return 'Exemplar path already chosen: ' + selectedPath.path;
        }

        function getCmLockReasons(cm, options) {
            var spent = getSpentPoints();
            var available = getAvailablePoints();
            var baseStats = getBaseStatsFromInputs();
            var level = parseInt(levelInput.value, 10);
            var reasons = [];
            var exemplarLockReason;

            options = options || {};

            if (!cm) return reasons;

            if (isNaN(level)) {
                level = 1;
            }

            if (cm.level && cm.level > level) {
                reasons.push('Reach level ' + cm.level);
            }

            if (cm.cmSpentReq && spent < cm.cmSpentReq) {
                reasons.push('Spend ' + (cm.cmSpentReq - spent) + ' more CM');
            }

            getCmStatMinRequirements(cm).forEach(function (req) {
                var current = numberOrZero(baseStats[req.stat]);

                if (current < req.value) {
                    reasons.push('Requires base ' + req.stat + ' ' + req.value);
                }
            });

            safeArray(cm.requirements).forEach(function (reqName) {
                if (!selectedMasteries[reqName]) {
                    reasons.push('Requires ' + reqName);
                }
            });

            exemplarLockReason = getExemplarPathLockReason(cm);

            if (exemplarLockReason) {
                reasons.push(exemplarLockReason);
            }

            if (options.includePointBudget && spent + numberOrZero(cm.points) > available) {
                reasons.push('Not enough CM points');
            }

            return reasons;
        }

        function getCmUnlockHint(cm) {
            return getCmLockReasons(cm).join(' | ');
        }

        function updateMasterClassPanel() {
            var panel = document.getElementById('cm-master-class-panel');
            var selectedTitle = getSelectedMasterClassTitle();

            if (!panel) return;

            if (!selectedTitle) {
                panel.innerHTML =
                    '<span class="cm-master-class-label">None selected</span>';
                return;
            }

            panel.innerHTML =
                '<span class="cm-master-class-label">' + escapeHtml(selectedTitle) + '</span>' +
                '<button type="button" id="cm-remove-master-class" class="cm-mini-remove-button">Remove</button>';

            document.getElementById('cm-remove-master-class').addEventListener('click', function () {
                delete selectedMasteries[selectedTitle];
                hideCmTooltip();
                updateSummary();
            });
        }

        function getCmDisplayTab(cm) {
            var classification = String((cm && cm.classification) || '');
            var races = safeArray(cm && cm.races);
            var classes = safeArray(cm && cm.classes);

            if (classification === 'Master Class') {
                if (races.length && races[0] !== 'All') {
                    return 'Racial';
                }

                if (classes.length && classes[0] !== 'All') {
                    return 'Class';
                }
            }

            return classification;
        }

        function getCmType(cm) {
            if (isMasterClassTitle(cm)) {
                return 'Master Class';
            }

            return getCmDisplayTab(cm) || 'Other';
        }

        function getCmTypeClass(cm) {
            var type = getCmType(cm);

            if (type === 'General') return 'cm-type-general';
            if (type === 'Racial') return 'cm-type-racial';
            if (type === 'Archetype') return 'cm-type-archetype';
            if (type === 'Class') return 'cm-type-class';
            if (type === 'Master Class') return 'cm-type-master';

            return 'cm-type-other';
        }

        function findCmByName(name) {
            var found = null;

            safeArray(data.masteries).forEach(function (cm) {
                if (!found && cm && cm.name === name) {
                    found = cm;
                }
            });

            return found;
        }

        function getSelectedMasteryNames() {
            return Object.keys(selectedMasteries).filter(function (name) {
                return selectedMasteries[name];
            }).sort();
        }

        function copyTextToClipboard(text, button) {
            function showCopied() {
                var oldText;

                if (!button) {
                    alert('Copied.');
                    return;
                }

                oldText = button.textContent;
                button.textContent = 'Copied';

                setTimeout(function () {
                    button.textContent = oldText;
                }, 1200);
            }

            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(text).then(showCopied, function () {
                    alert(text);
                });
                return;
            }

            alert(text);
        }

        function getBuildTotals() {
            var statTotals = {};
            var spellsAdded = {};
            var spellsRemoved = {};

            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var stats;

                if (!cm) return;

                stats = cm.stats || {};

                Object.keys(stats).forEach(function (statName) {
                    var value = parseInt(stats[statName], 10);

                    if (!isNaN(value) && value !== 0) {
                        if (!statTotals[statName]) {
                            statTotals[statName] = 0;
                        }

                        statTotals[statName] += value;
                    }
                });

                safeArray(cm.spellsAdded).forEach(function (spell) {
                    if (spell) spellsAdded[spell] = true;
                });

                safeArray(cm.spellsRemoved).forEach(function (spell) {
                    if (spell) spellsRemoved[spell] = true;
                });
            });

            return {
                stats: statTotals,
                spellsAdded: spellsAdded,
                spellsRemoved: spellsRemoved
            };
        }

        function buildShareUrl() {
            var baseStats = getBaseStatsFromInputs();
            var build = {
                r: raceSelect.value,
                c: classSelect.value,
                l: levelInput.value,
                p: pathSelect ? pathSelect.value : '',
                b: baseStats,
                cms: getSelectedMasteryNames()
            };

            return window.location.href.split('#')[0] +
                '#cm-build=' +
                encodeURIComponent(JSON.stringify(build));
        }

        function setBaseStatInputs(baseStats) {
            baseStats = baseStats || {};

            document.getElementById('cm-base-str').value = numberOrZero(baseStats.STR);
            document.getElementById('cm-base-sta').value = numberOrZero(baseStats.STA);
            document.getElementById('cm-base-dex').value = numberOrZero(baseStats.DEX);
            document.getElementById('cm-base-agi').value = numberOrZero(baseStats.AGI);
            document.getElementById('cm-base-int').value = numberOrZero(baseStats.INT);
            document.getElementById('cm-base-wis').value = numberOrZero(baseStats.WIS);
            document.getElementById('cm-base-cha').value = numberOrZero(baseStats.CHA);
        }

        function loadBuildFromUrl() {
            var hash = window.location.hash || '';
            var raw;
            var build;

            if (hash.indexOf('#cm-build=') !== 0) return;

            raw = hash.replace('#cm-build=', '');

            try {
                build = JSON.parse(decodeURIComponent(raw));
            } catch (e) {
                return;
            }

            if (build.r && data.raceClasses && data.raceClasses[build.r]) {
                raceSelect.value = build.r;
                loadClasses();
            }

            if (build.c) {
                classSelect.value = build.c;
            }

            if (build.l) {
                levelInput.value = build.l;
            }

            if (pathSelect && build.p !== undefined) {
                pathSelect.value = build.p;
            }

            if (build.b) {
                setBaseStatInputs(build.b);
            }

            selectedMasteries = {};

            safeArray(build.cms).forEach(function (name) {
                var cm = findCmByName(name);

                if (cm) {
                    selectedMasteries[cm.name] = cm;
                }
            });

            updateSummary();
        }

        function loadQrLibrary(callback) {
            if (window.QRious) {
                callback(true);
                return;
            }

            var script = document.createElement('script');
            script.src = 'https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js';
            script.onload = function () {
                callback(!!window.QRious);
            };
            script.onerror = function () {
                callback(false);
            };

            document.head.appendChild(script);
        }

        function renderBuildCard() {
            loadQrLibrary(function (qrReady) {
                var shareUrl = buildShareUrl();
                var totals = getBuildTotals();
                var selectedNames = getSelectedMasteryNames();
                var purchasedDisplayNames = getCompressedMasteryNames(selectedNames);
                var selectedTitle = getSelectedMasterClassTitle() || 'None';
                var available = getAvailablePoints();
                var spent = getSpentPoints();
                var remaining = available - spent;
                var statsList = Object.keys(totals.stats).sort();
                var gainedList = Object.keys(totals.spellsAdded).sort();
                var removedList = Object.keys(totals.spellsRemoved).sort();
                var baseStats = getBaseStatsFromInputs();

                var canvas = document.createElement('canvas');
                var ctx = canvas.getContext('2d');

                var cardWidth = 1800;
                var margin = 55;
                var gap = 35;
                var panelW = cardWidth - margin * 2;
                var y = 390;

                var qrW = 360;
                var summaryW = panelW - qrW - gap;
                var qrH = 360;

                function roundedRect(x, y, w, h, r) {
                    if (ctx.roundRect) {
                        ctx.beginPath();
                        ctx.roundRect(x, y, w, h, r);
                        return;
                    }

                    ctx.beginPath();
                    ctx.moveTo(x + r, y);
                    ctx.lineTo(x + w - r, y);
                    ctx.quadraticCurveTo(x + w, y, x + w, y + r);
                    ctx.lineTo(x + w, y + h - r);
                    ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
                    ctx.lineTo(x + r, y + h);
                    ctx.quadraticCurveTo(x, y + h, x, y + h - r);
                    ctx.lineTo(x, y + r);
                    ctx.quadraticCurveTo(x, y, x + r, y);
                }

                function drawPanel(x, y, w, h, title) {
                    ctx.strokeStyle = '#9b7330';
                    ctx.lineWidth = 3;
                    roundedRect(x, y, w, h, 16);
                    ctx.stroke();

                    ctx.fillStyle = '#d9a64d';
                    ctx.font = 'bold 36px Georgia';
                    ctx.textAlign = 'center';
                    ctx.fillText(title, x + w / 2, y + 48);

                    ctx.strokeStyle = 'rgba(217,166,77,0.35)';
                    ctx.lineWidth = 1;
                    ctx.beginPath();
                    ctx.moveTo(x + 35, y + 68);
                    ctx.lineTo(x + w - 35, y + 68);
                    ctx.stroke();

                    ctx.textAlign = 'left';
                }

                function drawInfoBox(label, value, x, y, w) {
                    ctx.strokeStyle = '#9b7330';
                    ctx.lineWidth = 2;
                    roundedRect(x, y, w, 100, 12);
                    ctx.stroke();

                    ctx.textAlign = 'left';
                    ctx.textBaseline = 'alphabetic';

                    ctx.fillStyle = '#f4e6c8';
                    ctx.font = 'bold 25px Georgia';
                    ctx.fillText(label, x + 28, y + 38);

                    ctx.font = 'bold 30px Georgia';

                    ctx.fillText(value || 'None', x + 28, y + 72);
                }

                function measureColumnList(items, columns, lineH) {
                    if (!items.length) return lineH;
                    return Math.ceil(items.length / columns) * lineH;
                }

                function drawColumnList(items, x, y, width, columns, color, font, lineH) {
                    var colGap = 42;
                    var colWidth = (width - colGap * (columns - 1)) / columns;
                    var rows = Math.ceil(Math.max(items.length, 1) / columns);

                    ctx.fillStyle = color;
                    ctx.font = font;
                    ctx.textAlign = 'left';
                    ctx.textBaseline = 'alphabetic';

                    if (!items.length) {
                        ctx.fillText('None', x, y);
                        return y + lineH;
                    }

                    items.forEach(function (item, index) {
                        var col = Math.floor(index / rows);
                        var row = index % rows;
                        var drawX = x + col * (colWidth + colGap);
                        var drawY = y + row * lineH;

                        ctx.fillText(item, drawX, drawY);
                    });

                    return y + rows * lineH;
                }

                var statsItems = statsList.map(function (statName) {
                    var value = totals.stats[statName];
                    var sign = value > 0 ? '+' : '';
                    return formatStatName(statName) + ': ' + sign + value;
                });

                var baseStatItems = Object.keys(baseStats).map(function (statName) {
                    return statName + ': ' + baseStats[statName];
                });

                var gainedItems = gainedList.map(function (spell) {
                    return '+ ' + spell;
                });

                var removedItems = removedList.map(function (spell) {
                    return '- ' + spell;
                });

                var statsHeight = measureColumnList(statsItems.concat(baseStatItems), 3, 30);
                var purchasedHeight = measureColumnList(purchasedDisplayNames, 2, 30);
                var gainedHeight = measureColumnList(gainedItems, 2, 30);
                var removedHeight = measureColumnList(removedItems, 2, 30);

                var summaryH = Math.max(490, 365 + statsHeight);
                var topRowH = Math.max(summaryH, qrH);
                var purchasedH = Math.max(280, 130 + purchasedHeight);
                var spellsH = Math.max(420, 250 + gainedHeight + removedHeight);

                canvas.width = cardWidth;
                canvas.height =
                    390 +
                    topRowH + 35 +
                    purchasedH + 35 +
                    spellsH + 180;

                ctx.fillStyle = '#05080d';
                ctx.fillRect(0, 0, canvas.width, canvas.height);

                ctx.strokeStyle = '#9b7330';
                ctx.lineWidth = 6;
                roundedRect(8, 8, canvas.width - 16, canvas.height - 16, 28);
                ctx.stroke();

                ctx.textAlign = 'center';
                ctx.fillStyle = '#d9a64d';
                ctx.font = 'bold 78px Georgia';
                ctx.fillText('EQOA: SANDSTORM', canvas.width / 2, 100);

                ctx.font = 'bold 46px Georgia';
                ctx.fillText('CLASS MASTERY CALCULATOR', canvas.width / 2, 155);

                ctx.fillStyle = '#f4e6c8';
                ctx.font = 'bold 38px Georgia';
                ctx.fillText('Unnamed CM Build', canvas.width / 2, 205);

                drawInfoBox('Race', raceSelect.value || 'None', margin, 250, 380);
                drawInfoBox('Class', classSelect.value || 'None', 480, 250, 380);
                drawInfoBox('Level', String(levelInput.value || '60'), 905, 250, 300);
                drawInfoBox('Path', (pathSelect && pathSelect.value) || 'None', 1250, 250, 495);

                drawPanel(margin, y, summaryW, topRowH, 'CM SUMMARY');
                drawPanel(margin + summaryW + gap, y, qrW, qrH, 'SCAN');

                ctx.fillStyle = '#f4e6c8';
                ctx.font = 'bold 29px Georgia';
                ctx.fillText('Master Class: ' + selectedTitle, margin + 45, y + 115);
                ctx.fillText('Points: ' + spent + ' / ' + available + ' spent', margin + 45, y + 155);
                ctx.fillText('Remaining: ' + remaining, margin + 45, y + 195);

                ctx.fillStyle = '#d9a64d';
                ctx.font = 'bold 30px Georgia';
                ctx.fillText('Base Stats', margin + 45, y + 255);

                var afterBaseY = drawColumnList(baseStatItems, margin + 45, y + 295, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);

                ctx.fillStyle = '#d9a64d';
                ctx.font = 'bold 30px Georgia';
                ctx.fillText('Stats Gained', margin + 45, afterBaseY + 45);


                drawColumnList(statsItems, margin + 45, afterBaseY + 85, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);

                if (qrReady) {
                    var qrCanvas = document.createElement('canvas');

                    new QRious({
                        element: qrCanvas,
                        value: shareUrl,
                        size: 220,
                        background: 'white',
                        foreground: 'black',
                        level: 'M'
                    });

                    ctx.drawImage(qrCanvas, margin + summaryW + gap + 70, y + 105, 220, 220);
                }

                y += topRowH + 35;

                drawPanel(margin, y, panelW, purchasedH, 'PURCHASED CMS');
                drawColumnList(purchasedDisplayNames, margin + 45, y + 115, panelW - 90, 2, '#e066ff', 'bold 23px Georgia', 30);

                y += purchasedH + 35;

                drawPanel(margin, y, panelW, spellsH, 'SPELLS');

                ctx.fillStyle = '#8fd9ff';
                ctx.font = 'bold 30px Georgia';
                ctx.fillText('Gained', margin + 45, y + 115);

                var afterGainedY = drawColumnList(gainedItems, margin + 45, y + 155, panelW - 90, 2, '#8fd9ff', 'bold 22px Georgia', 30);

                ctx.fillStyle = '#ff8f8f';
                ctx.font = 'bold 30px Georgia';
                ctx.fillText('Removed', margin + 45, afterGainedY + 55);

                drawColumnList(removedItems, margin + 45, afterGainedY + 95, panelW - 90, 2, '#ff8f8f', 'bold 22px Georgia', 30);

                ctx.fillStyle = '#d9a64d';
                ctx.font = 'bold 30px Georgia';
                ctx.textAlign = 'center';
                ctx.fillText(
                    'EQOA: Sandstorm Wiki Class Mastery Calculator',
                    canvas.width / 2,
                    canvas.height - 55
                );

                var link = document.createElement('a');
                link.href = canvas.toDataURL('image/png');
                link.download = 'EQOA_CM_Build.png';
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
            });
        }

        function loadRaces() {
            raceSelect.innerHTML = '';

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

            loadClasses();
        }

        function selectOptionIfExists(select, value) {
            var found = false;

            if (!select) return false;

            Array.prototype.forEach.call(select.options, function (option) {
                if (option.value === value) {
                    found = true;
                }
            });

            if (found) {
                select.value = value;
            }

            return found;
        }

        function findCmRaceForBuilderState(builderState) {
            var raceName = builderState && builderState.race;
            var classCode = builderState && builderState.classCode;
            var raceKeys = Object.keys(data.raceClasses || {});
            var foundRace = '';

            if (raceName && data.raceClasses && data.raceClasses[raceName]) {
                return raceName;
            }

            raceKeys.forEach(function (race) {
                if (foundRace) return;

                if (race === raceName) {
                    foundRace = race;
                    return;
                }

                if (data.raceCodes && data.raceCodes[race] === raceName) {
                    foundRace = race;
                    return;
                }

                if (
                    classCode &&
                    safeArray((data.raceClasses || {})[race]).indexOf(classCode) !== -1
                ) {
                    foundRace = race;
                }
            });

            return foundRace || raceKeys[0] || '';
        }

        function syncFromCharacterBuilder() {
            var builderState = getCharacterBuilderState();
            var raceName;
            var classCode;

            if (!builderState) return;

            raceName = findCmRaceForBuilderState(builderState);
            classCode = builderState.classCode;

            if (raceName && raceSelect.value !== raceName) {
                raceSelect.value = raceName;
                loadClasses();
            }

            if (classCode) {
                selectOptionIfExists(classSelect, classCode);
            }

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

            updateBaseStatInputsDisplay(getBaseStatsFromInputs());

            setTimeout(function () {
                updateSummary();
            }, 0);
        }

        window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;

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

            var race = raceSelect.value;
            var classList = safeArray((data.raceClasses || {})[race]);

            classList.forEach(function (classCode) {
                addOption(
                    classSelect,
                    classCode,
                    classCode + ' - ' + ((data.classes || {})[classCode] || classCode)
                );
            });

            updateSummary();
        }

        function getAvailablePoints() {
            var level = parseInt(levelInput.value, 10);

            if (isNaN(level) || level < 1) level = 1;
            if (level > 60) level = 60;

            levelInput.value = level;

            return level * 25;
        }

        function getSpentPoints() {
            var total = 0;

            Object.keys(selectedMasteries).forEach(function (name) {
                if (selectedMasteries[name]) {
                    total += selectedMasteries[name].points || 0;
                }
            });

            return total;
        }

        function getCmPointStatusText() {
            var total = getAvailablePoints();
            var spent = getSpentPoints();
            var left = total - spent;

            if (left < 0) left = 0;

            return 'Total CMs: ' + total + ' | Spent: ' + spent + ' | Left: ' + left;
        }

        Array.prototype.forEach.call(viewButtons, function (button) {
            button.addEventListener('click', function () {
                activeCmView = button.getAttribute('data-view') || 'detailed';

                Array.prototype.forEach.call(viewButtons, function (btn) {
                    btn.classList.remove('cm-view-active');
                });

                button.classList.add('cm-view-active');

                renderMasteryList();
            });
        });

        function updatePurchasedList() {
            var list = document.getElementById('cm-purchased-list');
            var purchasedRows = [];
            var groups = {
                'Master Class': [],
                'General': [],
                'Racial': [],
                'Archetype': [],
                'Class': [],
                'Other': []
            };
            var groupOrder = [
                'Master Class',
                'General',
                'Racial',
                'Archetype',
                'Class',
                'Other'
            ];
            var html = '';

            if (!list) return;

            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var groupName;

                if (!cm) return;

                if (isMasterClassTitle(cm)) {
                    groupName = 'Master Class';
                } else {
                    groupName = getCmDisplayTab(cm);

                    if (!groups[groupName]) {
                        groupName = 'Other';
                    }
                }

                groups[groupName].push(cm);
            });

            groupOrder.forEach(function (groupName) {
                groups[groupName].sort(function (a, b) {
                    return String(a.name || '').localeCompare(String(b.name || ''));
                });
            });

            groupOrder.forEach(function (groupName) {
                if (!groups[groupName].length) return;

                html +=
                    '<div class="cm-purchased-group">' +
                        '<div class="cm-purchased-group-title">' + escapeHtml(groupName) + '</div>';

                groups[groupName].forEach(function (cm) {
                    var index = purchasedRows.length;
                    purchasedRows.push(cm);

                    html +=
                        '<div class="cm-purchased-item" data-purchased-index="' + index + '">' +
                            '<span>' + escapeHtml(cm.name) + '</span>' +
                            '<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
                        '</div>';
                });

                html += '</div>';
            });

            if (!html) {
                list.innerHTML = 'None purchased.';
                return;
            }

            list.innerHTML = html;

            Array.prototype.forEach.call(
                list.querySelectorAll('.cm-purchased-item'),
                function (row) {
                    var index = parseInt(row.getAttribute('data-purchased-index'), 10);
                    var cm = purchasedRows[index];

                    row.addEventListener('mouseenter', function () {
                        showCmTooltip(cm, row);
                    });

                    row.addEventListener('mouseleave', hideCmTooltip);

                    row.addEventListener('click', function () {
                        if (!cm) return;

                        delete selectedMasteries[cm.name];
                        hideCmTooltip();
                        updateSummary();
                    });
                }
            );
        }


        function updateUnlockedSummary() {
            var summary = document.getElementById('cm-unlocked-summary');
            var statTotals = {};
            var spellsAdded = {};
            var spellsRemoved = {};
            var hasStats = false;
            var html = '';

            if (!summary) return;

            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var stats;

                if (!cm) return;


                stats = cm.stats || {};

                Object.keys(stats).forEach(function (statName) {
                    var value = parseInt(stats[statName], 10);

                    if (!isNaN(value) && value !== 0) {
                        if (!statTotals[statName]) {
                            statTotals[statName] = 0;
                        }

                        statTotals[statName] += value;
                        hasStats = true;
                    }
                });

                safeArray(cm.spellsAdded).forEach(function (spell) {
                    if (spell) {
                        spellsAdded[spell] = true;
                    }
                });

                safeArray(cm.spellsRemoved).forEach(function (spell) {
                    if (spell) {
                        spellsRemoved[spell] = true;
                    }
                });
            });

            html += '<div class="cm-unlocked-grid cm-unlocked-grid-three">';

            html += '<div class="cm-unlocked-box">';
            html += '<div class="cm-panel-title">Stats</div>';

            if (!hasStats) {
                html += '<div class="cm-empty-line">No stat changes yet.</div>';
            } else {
                Object.keys(statTotals).sort().forEach(function (statName) {
                    var value = statTotals[statName];
                    var sign = value > 0 ? '+' : '';

                    html +=
                        '<div class="cm-purchased-item cm-stat-row">' +
                            '<span>' + escapeHtml(formatStatName(statName)) + '</span>' +
                            '<span class="cm-purchased-cost">' + sign + escapeHtml(value) + '</span>' +
                        '</div>';
                });
            }

            html += '</div>';

            html += '<div class="cm-unlocked-box">';
            html += '<div class="cm-panel-title">Spells Gained</div>';

            if (!Object.keys(spellsAdded).length) {
                html += '<div class="cm-empty-line">No spells gained yet.</div>';
            } else {
                Object.keys(spellsAdded).sort().forEach(function (spell) {
                    html +=
                        '<div class="cm-purchased-item cm-spell-row cm-spell-gained">' +
                            '<span>+ ' + escapeHtml(spell) + '</span>' +
                        '</div>';
                });
            }

            html += '</div>';

            html += '<div class="cm-unlocked-box">';
            html += '<div class="cm-panel-title">Spells Removed</div>';

            if (!Object.keys(spellsRemoved).length) {
                html += '<div class="cm-empty-line">No spells removed yet.</div>';
            } else {
                Object.keys(spellsRemoved).sort().forEach(function (spell) {
                    html +=
                        '<div class="cm-purchased-item cm-spell-row cm-spell-removed">' +
                            '<span>- ' + escapeHtml(spell) + '</span>' +
                        '</div>';
                });
            }

            html += '</div>';
            html += '</div>';

            summary.innerHTML = html;
        }

        function updateSummary() {
            var available = getAvailablePoints();
            var spent = getSpentPoints();
            var remaining = available - spent;

            if (isEmbeddedInCharacterBuilder) {
                updateBaseStatInputsDisplay(getBaseStatsFromInputs());
            }

            document.getElementById('cm-available').textContent = available;
            document.getElementById('cm-spent').textContent = spent;
            document.getElementById('cm-remaining').textContent = remaining;

            updatePurchasedList();
            updateMasterClassPanel();
            updateUnlockedSummary();
            renderMasteryList();
        }

        function buildCmTooltipHtml(cm) {
            var statLines = [];
            var spellAdds;
            var spellRemoves;
            var reqHtml = '';
            var statMinHtml = '';
            var statMinReqHtml = '';

            if (!cm) return '';

            Object.keys(cm.stats || {}).forEach(function (key) {
                var value = cm.stats[key];

                if (value) {
                    var sign = value > 0 ? '+' : '';
                    statLines.push(
                        '<div class="cm-tooltip-stat">' +
                            escapeHtml(formatStatName(key)) + ': ' + sign + escapeHtml(value) +
                        '</div>'
                    );
                }
            });

            spellAdds = safeArray(cm.spellsAdded).map(function (spell) {
                return '<div class="cm-tooltip-spell-add">+ ' + escapeHtml(spell) + '</div>';
            }).join('');

            spellRemoves = safeArray(cm.spellsRemoved).map(function (spell) {
                return '<div class="cm-tooltip-spell-remove">- ' + escapeHtml(spell) + '</div>';
            }).join('');

            if (safeArray(cm.requirements).length) {
                reqHtml =
                    '<div class="cm-tooltip-req">' +
                        'Requires: ' + getRequirementsHtml(cm) +
                    '</div>';
            }

            statMinHtml = getStatMinRequirementsHtml(cm);

            if (statMinHtml) {
                statMinReqHtml =
                    '<div class="cm-tooltip-req">' +
                        'Stat Req: ' + statMinHtml +
                    '</div>';
            }

            return (
                '<div class="cm-tooltip-title">' + escapeHtml(cm.name) + '</div>' +
                '<div class="cm-tooltip-desc">' + escapeHtml(cm.description) + '</div>' +
                '<div class="cm-tooltip-meta">' +
                    'Level ' + escapeHtml(cm.level) +
                    ' | Cost ' + escapeHtml(cm.points) +
                    (cm.cmSpentReq ? ' | CM Spent Req ' + escapeHtml(cm.cmSpentReq) : '') +
                '</div>' +
                '<div class="cm-tooltip-meta">' +
                    escapeHtml(getCmPointStatusText()) +
                '</div>' +
                reqHtml +
                statMinReqHtml +
                statLines.join('') +
                spellAdds +
                spellRemoves
            );
        }

        function showCmTooltip(cm, card) {
            if (!cmTooltip || !cm) return;

            cmTooltip.innerHTML = buildCmTooltipHtml(cm);
            cmTooltip.style.display = 'block';

            var rect = card.getBoundingClientRect();
            var tooltipWidth = 340;
            var leftPos = rect.right + 12;

            if (leftPos + tooltipWidth > window.innerWidth) {
                leftPos = rect.left - tooltipWidth - 12;
            }

            cmTooltip.style.left = leftPos + 'px';
            cmTooltip.style.top = Math.max(10, rect.top) + 'px';
        }

        function hideCmTooltip() {
            if (cmTooltip) {
                cmTooltip.style.display = 'none';
            }
        }

        function renderMasteryList() {
            var masteryPanel = document.getElementById('cm-mastery-panel');
            if (!masteryPanel) return;

            var selectedRace = raceSelect.value;
            var selectedRaceCode = (data.raceCodes || {})[selectedRace];
            var selectedClass = classSelect.value;
            var selectedPath = pathSelect ? pathSelect.value : '';
            var selectedPathNormalized = normalizePath(selectedPath);
            var selectedIsWere = isWerePath(selectedPathNormalized);
            var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : '';
            var seenMasteries = {};
            var availableMasteries = [];
            var lockedMasteries = [];

            visibleMasteries = [];

            safeArray(data.masteries).forEach(function (cm) {
                var cmRaces = safeArray(cm.races);
                var cmClasses = safeArray(cm.classes);
                var cmName = String(cm.name || '');
                var cmPath = normalizePath(cm.lycanthropy);
                var bitePath = getBitePath(cmName);

                var pathOk = false;
                var raceOk;
                var classOk;
                var tabOk;
                var searchOk;
                var unlockHint;

                raceOk =
                    !cmRaces.length ||
                    cmRaces.indexOf('All') !== -1 ||
                    cmRaces.indexOf(selectedRaceCode) !== -1;

                classOk =
                    !cmClasses.length ||
                    cmClasses.indexOf('All') !== -1 ||
                    cmClasses.indexOf(selectedClass) !== -1;

                tabOk = getCmDisplayTab(cm) === activeCmTab;

                searchOk =
                    !searchQuery ||
                    getCmSearchText(cm).indexOf(searchQuery) !== -1;

                if (bitePath) {
                    pathOk = selectedPathNormalized === bitePath;
                } else if (!cmPath) {
                    pathOk = true;
                } else if (!selectedPathNormalized) {
                    pathOk = false;
                } else if (selectedIsWere && isSharedWereMastery(cmName)) {
                    pathOk = cmPath === 'werebeast' || isWerePath(cmPath);
                } else if (selectedIsWere && cmPath === 'werebeast') {
                    pathOk = true;
                } else {
                    pathOk = cmPath === selectedPathNormalized;
                }

                if (
                    !raceOk ||
                    !classOk ||
                    !tabOk ||
                    !pathOk ||
                    !searchOk ||
                    selectedMasteries[cmName] ||
                    seenMasteries[cmName]
                ) {
                    return;
                }

                seenMasteries[cmName] = true;

                unlockHint = getCmUnlockHint(cm);

                if (unlockHint) {
                    lockedMasteries.push({
                        cm: cm,
                        locked: true,
                        unlockHint: unlockHint
                    });
                } else {
                    availableMasteries.push({
                        cm: cm,
                        locked: false,
                        unlockHint: ''
                    });
                }
            });

            if (showLockedCms) {
                visibleMasteries = availableMasteries.concat(lockedMasteries);
            } else {
                visibleMasteries = availableMasteries;
            }

            if (!visibleMasteries.length) {
                masteryPanel.innerHTML = 'No Class Masteries found for this selection.';
                return;
            }

            masteryPanel.innerHTML = visibleMasteries.map(function (entry, index) {
                var cm = entry.cm;
                var lockedClass = entry.locked ? ' cm-card-locked' : '';
                var typeClass = getCmTypeClass(cm);
                var cmType = getCmType(cm);
                var requirementsHtml = getRequirementsHtml(cm);
                var statMinHtml = getStatMinRequirementsHtml(cm);
                var chainHtml = getCmChainHtml(cm);
                var lockReasonsHtml = '';
                var metaHtml;

                if (entry.locked) {
                    lockReasonsHtml =
                        '<div class="cm-unlock-hint">' +
                            '<strong>Locked:</strong><br>' +
                            getCmLockReasons(cm).map(function (reason) {
                                return escapeHtml(reason);
                            }).join('<br>') +
                        '</div>';
                }

                metaHtml =
                    '<div class="cm-card-meta">' +
                        'Level: ' + escapeHtml(cm.level) +
                        ' | Cost: ' + escapeHtml(cm.points) +
                        (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
                    '</div>' +
                    '<div class="cm-card-meta">' +
                        escapeHtml(getCmPointStatusText()) +
                    '</div>';

                if (activeCmView === 'compact') {
                    return (
                        '<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                            '<div class="cm-card-compact-row">' +
                                '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
                                '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
                                metaHtml +
                            '</div>' +
                            (statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
                            (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
                            lockReasonsHtml +
                        '</div>'
                    );
                }

                return (
                    '<div class="cm-card cm-purchasable-card ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                        '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
                        '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
                        '<div class="cm-card-desc">' + escapeHtml(cm.description) + '</div>' +
                        metaHtml +
                        (requirementsHtml ? '<div class="cm-card-req">Requires: ' + requirementsHtml + '</div>' : '') +
                        (statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
                        (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
                        lockReasonsHtml +
                    '</div>'
                );
            }).join('');

            Array.prototype.forEach.call(
                masteryPanel.querySelectorAll('.cm-purchasable-card'),
                function (card) {
                    var cmIndex = parseInt(card.getAttribute('data-cm-index'), 10);
                    var entry = visibleMasteries[cmIndex];
                    var cm = entry ? entry.cm : null;

                    card.addEventListener('mouseenter', function () {
                        showCmTooltip(cm, card);
                    });

                    card.addEventListener('mouseleave', hideCmTooltip);

                    card.addEventListener('click', function () {
                        var purchaseReasons;
                        var selectedMasterClassTitle;

                        if (!cm || !entry) return;

                        if (entry.locked) {
                            alert(entry.unlockHint);
                            return;
                        }

                        purchaseReasons = getCmLockReasons(cm, {
                            includePointBudget: true
                        });

                        if (purchaseReasons.length) {
                            alert(purchaseReasons.join(' | '));
                            return;
                        }

                        if (isMasterClassTitle(cm)) {
                            selectedMasterClassTitle = getSelectedMasterClassTitle();

                            if (selectedMasterClassTitle && selectedMasterClassTitle !== cm.name) {
                                alert('You can only select one Master Class Title per character build. Remove ' + selectedMasterClassTitle + ' first.');
                                return;
                            }
                        }

                        selectedMasteries[cm.name] = cm;

                        hideCmTooltip();
                        updateSummary();
                    });
                }
            );
        }

        raceSelect.addEventListener('change', loadClasses);
        classSelect.addEventListener('change', updateSummary);
        levelInput.addEventListener('input', updateSummary);

        [
            'cm-base-str',
            'cm-base-sta',
            'cm-base-dex',
            'cm-base-agi',
            'cm-base-int',
            'cm-base-wis',
            'cm-base-cha'
        ].forEach(function (id) {
            var input = document.getElementById(id);

            if (input) {
                input.addEventListener('input', updateSummary);
            }
        });

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

        if (clearSearchButton && searchInput) {
            clearSearchButton.addEventListener('click', function () {
                searchInput.value = '';
                renderMasteryList();
                searchInput.focus();
            });
        }

        if (shareUrlButton) {
            shareUrlButton.addEventListener('click', function () {
                copyTextToClipboard(buildShareUrl(), shareUrlButton);
            });
        }

        if (exportCardButton) {
            exportCardButton.addEventListener('click', function () {
                renderBuildCard();
            });
        }

        if (showLockedInput) {
            showLockedInput.addEventListener('change', function () {
                showLockedCms = showLockedInput.checked;
                renderMasteryList();
            });
        }

        if (pathSelect) {
            pathSelect.addEventListener('change', updateSummary);
        }

        if (isEmbeddedInCharacterBuilder) {
            window.addEventListener('eqoa-character-builder-state-change', syncFromCharacterBuilder);
        }

        Array.prototype.forEach.call(
            document.querySelectorAll('.cm-tab-button'),
            function (button) {
                button.addEventListener('click', function () {
                    activeCmTab = button.getAttribute('data-tab');

                    Array.prototype.forEach.call(
                        document.querySelectorAll('.cm-tab-button'),
                        function (btn) {
                            btn.classList.remove('cm-tab-active');
                        }
                    );

                    button.classList.add('cm-tab-active');

                    updateSummary();
                });
            }
        );

        if (resetButton) {
            resetButton.addEventListener('click', function () {
                selectedMasteries = {};
                activeCmTab = 'General';
                activeCmView = 'detailed';
                showLockedCms = true;

                levelInput.value = 60;
                setBaseStatInputs({});

                if (pathSelect) {
                    pathSelect.value = '';
                }

                Array.prototype.forEach.call(
                    document.querySelectorAll('.cm-tab-button'),
                    function (btn) {
                        btn.classList.remove('cm-tab-active');
                    }
                );

                var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
                if (generalTab) {
                    generalTab.classList.add('cm-tab-active');
                }

                Array.prototype.forEach.call(viewButtons, function (btn) {
                    btn.classList.remove('cm-view-active');
                });

                var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]');
                if (detailedView) {
                    detailedView.classList.add('cm-view-active');
                }

                if (searchInput) {
                    searchInput.value = '';
                }

                if (showLockedInput) {
                    showLockedInput.checked = true;
                }

                hideCmTooltip();
                updateSummary();
            });
        }

        loadRaces();

        if (isEmbeddedInCharacterBuilder) {
            syncFromCharacterBuilder();
        } else {
            loadBuildFromUrl();
        }
    }
});