MediaWiki:ClassMasteryCalculator.js: Difference between revisions

From EQOA Sandstorm Wiki
Jump to navigationJump to search
No edit summary
No edit summary
 
(76 intermediate revisions by 2 users not shown)
Line 1: Line 1:
mw.hook('wikipage.content').add(function () {
mw.hook('wikipage.content').add(function () {
    var cmCalculatorStarted = false;


     function waitForCMData(attemptsLeft) {
     function cmDataIsReady() {
         if (window.EQOA_CM_DATA) {
         return !!window.EQOA_CM_DATA &&
            (
                (
                    window.EQOA_CM_DATA.masteries &&
                    window.EQOA_CM_DATA.masteries.length
                ) ||
                window.EQOA_CM_RAW
            );
    }
 
    function waitForCMReady(attemptsLeft) {
        var app = document.getElementById('cm-calculator-app');
 
        if (app && cmDataIsReady()) {
            if (cmCalculatorStarted) return;
 
            cmCalculatorStarted = true;
             initClassMasteryCalculator();
             initClassMasteryCalculator();
             return;
             return;
Line 8: Line 25:


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


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


     waitForCMData(30);
     waitForCMReady(100);


     function initClassMasteryCalculator() {
     function initClassMasteryCalculator() {
Line 27: Line 45:


         var data = window.EQOA_CM_DATA || {};
         var data = window.EQOA_CM_DATA || {};
        var selectedMasteries = {};
var selectedMasteries = {};
        var activeCmTab = "General";
var sharedCmBuildApplied = false;
var activeCmView = "detailed";
var activeCmTab = 'General';
var activeCmView = 'detailed';
var showLockedCms = true;
var showLockedCms = true;
var visibleMasteries = [];
var visibleMasteries = [];


        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;');
        }


         app.innerHTML =
         function splitList(value) {
             '<div class="cm-calculator-box">' +
            value = String(value || '').trim();
                 '<div class="character-builder-title">Class Mastery Calculator</div>' +
            if (!value || value.toLowerCase() === 'all') return ['All'];
             return value.split(/\s+/);
        }
 
        function buildMasteriesFromRaw(rawText) {
            var lines = String(rawText || '').split(/\r?\n/);
            var headerLineIndex = -1;
            var headers = [];
            var headerMap = {};
            var rows = [];
 
            function get(cols, name) {
                var indexes = headerMap[name] || [];
                if (!indexes.length) return '';
                 return String(cols[indexes[0]] || '').trim();
            }
 
            function getAll(cols, name) {
                var indexes = headerMap[name] || [];
                var values = [];
 
                indexes.forEach(function (index) {
                    var value = String(cols[index] || '').trim();
 
                    if (!value) return;
                    if (/^[0-9A-F]{4}$/i.test(value)) return;
 
                    if (values.indexOf(value) === -1) {
                        values.push(value);
                    }
                });
 
                return values;
            }
 
 
function getAllHeadersContaining(cols, text) {
    var values = [];
    text = String(text || '').toLowerCase();
 
    headers.forEach(function (header, index) {
        var value;


                '<div class="character-builder-controls">' +
        if (String(header || '').toLowerCase().indexOf(text) === -1) return;
                    '<label>Race</label>' +
                    '<select id="cm-race"></select>' +


                    '<label>Class</label>' +
        value = String(cols[index] || '').trim();
                    '<select id="cm-class"></select>' +


                    '<label>Character Level</label>' +
        if (!value) return;
                    '<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +
        if (/^[0-9A-F]{4}$/i.test(value)) return;


                    '<label>Transformation Path</label>' +
        if (values.indexOf(value) === -1) {
                    '<select id="cm-path">' +
            values.push(value);
                        '<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>' +
    return values;
                '</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>' +
            function addStat(stats, cols, header, key) {
                var value = parseInt(get(cols, header), 10);
                if (!isNaN(value) && value !== 0) {
                    stats[key] = value;
                }
            }


'<div id="cm-master-class-panel" class="cm-master-class-panel">' +
            function addStatAny(stats, cols, headers, key) {
    '<span class="cm-master-class-label">None selected</span>' +
                var found = false;
'</div>' +


'<div class="cm-build-actions">' +
                headers.forEach(function (header) {
    '<button type="button" id="cm-share-url" class="cm-action-button">Generate Share URL</button>' +
                    var value;
    '<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>' +
                    if (found) return;


                    value = parseInt(get(cols, header), 10);


'<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +
                    if (!isNaN(value) && value !== 0) {
                        stats[key] = value;
                        found = true;
                    }
                });
            }


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


                '<div id="cm-unlocked-summary">' +
            if (headerLineIndex === -1) return [];
                    'No stats or spells gained yet.' +
                '</div>' +


                 '<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' +
            headers = lines[headerLineIndex].split('\t').map(function (header) {
                 return String(header || '').trim();
            });


                 '<div class="cm-tabs">' +
            headers.forEach(function (header, index) {
    '<button type="button" class="cm-tab-button cm-tab-active" data-tab="General">General</button>' +
                 if (!headerMap[header]) headerMap[header] = [];
    '<button type="button" class="cm-tab-button" data-tab="Racial">Racial</button>' +
                headerMap[header].push(index);
    '<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">' +
            lines.slice(headerLineIndex + 1).forEach(function (line) {
    '<div class="cm-search-row">' +
                var cols;
        '<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' +
                var name;
        '<button type="button" id="cm-clear-search" class="cm-clear-search-button" title="Clear search">Clear</button>' +
var stats = {};
    '</div>' +
var baseStatReqs = {};
var cmId = '';


    '<label class="cm-lock-toggle">' +
                if (!line.trim()) return;
        '<input type="checkbox" id="cm-show-locked" checked>' +
        '<span>Show locked</span>' +
    '</label>' +
'</div>' +


cols = line.split('\t');
name = get(cols, 'Mastery');


'<div class="cm-view-toggle">' +
cmId =
     '<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
    get(cols, 'CmID') ||
     '<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
     get(cols, 'CMID') ||
'</div>' +
     get(cols, 'CM ID') ||
    get(cols, 'Cm ID');


if (!cmId && /^[0-9A-F]{4}$/i.test(String(cols[0] || '').trim())) {
    cmId = String(cols[0] || '').trim();
}


if (!name) return;


                '<div class="cm-main-layout">' +
addStat(stats, cols, 'AC', 'ac');
                    '<div class="cm-purchased-panel">' +
addStat(stats, cols, 'STR', 'str');
                        '<div class="cm-panel-title">CM Purchased</div>' +
addStat(stats, cols, 'STA', 'sta');
                        '<div id="cm-purchased-list">None purchased.</div>' +
addStat(stats, cols, 'AGI', 'agi');
                    '</div>' +
addStat(stats, cols, 'DEX', 'dex');
addStat(stats, cols, 'WIS', 'wis');
addStat(stats, cols, 'INT', 'int');
addStat(stats, cols, 'CHA', 'cha');


                    '<div id="cm-mastery-panel" class="character-builder-warning-box">' +
addStat(stats, cols, 'HP Max', 'hp');
                        'Select a race and class to begin.' +
addStat(stats, cols, 'PWR Max', 'pow');
                    '</div>' +
addStat(stats, cols, 'POW Max', 'pow');
                '</div>' +
addStat(stats, cols, 'Power Max', 'pow');


                '<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
/* Resists */
            '</div>';
addStat(stats, cols, 'PR', 'pr');
addStat(stats, cols, 'DR', 'dr');
addStat(stats, cols, 'FR', 'fr');
addStat(stats, cols, 'CR', 'cr');
addStat(stats, cols, 'LR', 'lr');
addStat(stats, cols, 'AR', 'ar');


        var raceSelect = document.getElementById('cm-race');
/* Regen / resource ticks */
        var classSelect = document.getElementById('cm-class');
addStat(stats, cols, 'HoT', 'hot');
        var levelInput = document.getElementById('cm-level');
addStat(stats, cols, 'HOT', 'hot');
        var pathSelect = document.getElementById('cm-path');
addStat(stats, cols, 'PoT', 'pot');
        var resetButton = document.getElementById('cm-reset');
addStat(stats, cols, 'POT', 'pot');
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');
var buildCardPanel = document.getElementById('cm-build-card-panel');


/* Modifiers */
addStat(stats, cols, 'HP Factor', 'hpFactor');
addStat(stats, cols, 'OFF Mod', 'offMod');
addStat(stats, cols, 'DEF Mod', 'defMod');
addStat(stats, cols, 'Speed', 'speed');


/* Stat cap increases */
addStatAny(stats, cols, ['STR Max', 'Max STR', 'STR MAX'], 'strMax');
addStatAny(stats, cols, ['STA Max', 'Max STA', 'STA MAX'], 'staMax');
addStatAny(stats, cols, ['AGI Max', 'Max AGI', 'AGI MAX'], 'agiMax');
addStatAny(stats, cols, ['DEX Max', 'Max DEX', 'DEX MAX'], 'dexMax');
addStatAny(stats, cols, ['WIS Max', 'Max WIS', 'WIS MAX'], 'wisMax');
addStatAny(stats, cols, ['INT Max', 'Max INT', 'INT MAX'], 'intMax');
addStatAny(stats, cols, ['CHA Max', 'Max CHA', 'CHA MAX'], 'chaMax');
                ['STR', 'STA', 'DEX', 'AGI', 'INT', 'WIS', 'CHA'].forEach(function (stat) {
                    var value = parseInt(get(cols, 'Base ' + stat + ' Req'), 10);
                    if (!isNaN(value) && value > 0) {
                        baseStatReqs[stat] = value;
                    }
                });


function getCompressedMasteryNames(names) {
rows.push({
     var groups = {};
     id: cmId,
     var singles = [];
     name: name,
                    description: get(cols, 'Description'),
                    classification: get(cols, 'Classification'),
                    subCategory: get(cols, 'Sub-Category'),
playerFlags: []
    .concat(getAll(cols, 'Playerflags'))
    .concat(getAll(cols, 'Player Flags'))
    .concat(getAll(cols, 'Playerflag'))
    .concat(getAll(cols, 'Player Flag'))
    .concat(getAllHeadersContaining(cols, 'playerflag'))
    .concat(getAllHeadersContaining(cols, 'player flag')),
                    races: splitList(get(cols, 'Race')),
                    classes: splitList(get(cols, 'Class')),
                    lycanthropy: get(cols, 'Lycanthropy'),


    names.forEach(function (name) {
                    quest: get(cols, 'Quest'),
                    level: parseInt(get(cols, 'Level'), 10) || 0,
                    points: parseInt(get(cols, 'Points'), 10) || 0,


        var base = getCmChainBase(name);
                    cmSpentReq: parseInt(get(cols, 'CM Spent Req'), 10) || 0,
        var number = getCmChainNumber(name);
                    requirements: []
                        .concat(getAll(cols, 'CM Req 1'))
                        .concat(getAll(cols, 'CM Req 2'))
                        .concat(getAll(cols, 'CM Req 3'))
                        .concat(getAll(cols, 'CM Req 4')),
                    spellsAdded: []
                        .concat(getAll(cols, '=+Spell 1'))
                        .concat(getAll(cols, '=+Spell 2'))
                        .concat(getAll(cols, '=+Spell 3'))
                        .concat(getAll(cols, '=+Spell 4'))
                        .concat(getAll(cols, '=+Spell 5'))
                        .concat(getAll(cols, '=+Spell 6')),
                    spellsRemoved: []
                        .concat(getAll(cols, '-Spell 1'))
                        .concat(getAll(cols, '-Spell 2'))
                        .concat(getAll(cols, '-Spell 3'))
                        .concat(getAll(cols, '-Spell 4'))
                        .concat(getAll(cols, '-Spell 5'))
                        .concat(getAll(cols, '-Spell 6')),
                    stats: stats,
                    baseStatReqs: baseStatReqs
                });
            });


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


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


        groups[base].push(number);
function buildCmIdsByNameFromRaw(rawText) {
     });
    var idsByName = {};
    var lines = String(rawText || '').split(/\r?\n/);
 
     lines.forEach(function (line) {
        var cols;
        var id;
        var name;
 
        line = String(line || '').trim();


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


         if (groups[base].length === 1) {
         if (line.indexOf('\t') !== -1) {
             singles.push(base + ' ' + groups[base][0]);
            cols = line.split('\t');
        } else if (line.indexOf('\\t') !== -1) {
             cols = line.split('\\t');
         } else {
         } else {
             singles.push(base + ' ' + groups[base].join(' > '));
             cols = line.split(/\s{2,}/);
         }
         }
        id = String(cols[0] || '').trim().replace(/[^0-9A-F]/gi, '');
        name = String(cols[1] || '').trim();
        if (!/^[0-9A-F]{4}$/i.test(id)) return;
        if (!name || name.toLowerCase() === 'mastery') return;
        idsByName[name] = id;
     });
     });


     return singles.sort();
     return idsByName;
}
}


if (window.EQOA_CM_RAW) {
    var rawMasteriesWithIds = buildMasteriesFromRaw(window.EQOA_CM_RAW);
    var cmIdsByName = buildCmIdsByNameFromRaw(window.EQOA_CM_IDS_RAW || window.EQOA_CM_RAW);
    var rawMasteriesByName = {};


 
    rawMasteriesWithIds.forEach(function (cm) {
         function safeArray(value) {
         if (cm.name && cmIdsByName[cm.name]) {
             return Object.prototype.toString.call(value) === '[object Array]' ? value : [];
             cm.id = cmIdsByName[cm.name];
         }
         }


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


    if (!data.masteries || !data.masteries.length) {
        data.masteries = rawMasteriesWithIds;
    } else {
        data.masteries.forEach(function (cm) {
            var rawCm;


            if (cm.name && cmIdsByName[cm.name]) {
                cm.id = cmIdsByName[cm.name];
            }


            rawCm = cm.name ? rawMasteriesByName[cm.name] : null;


            if (rawCm && rawCm.stats) {
                cm.stats = cm.stats || {};


function formatStatName(statName) {
                Object.keys(rawCm.stats).forEach(function (statKey) {
    var map = {
                    cm.stats[statKey] = rawCm.stats[statKey];
        ac: 'AC',
                });
         hp: 'HP',
            }
        pow: 'Power',
         });
    }


        str: 'STR',
    if (window.EQOA_CM_DATA) {
         sta: 'STA',
         window.EQOA_CM_DATA.masteries = data.masteries;
        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',
         function getBuilderState() {
        defMod: 'Defense Mod',
            if (
        hasteMod: 'Haste Mod',
                window.EQOA_CHARACTER_BUILDER_STATE &&
        regenMod: 'Regen Mod',
                typeof window.EQOA_CHARACTER_BUILDER_STATE.getState === 'function'
        powerRegenMod: 'Power Regen Mod',
            ) {
        healMod: 'Healing Mod',
                return window.EQOA_CHARACTER_BUILDER_STATE.getState();
        spellMod: 'Spell Mod',
            }
        meleeMod: 'Melee Mod',


        fireResist: 'Fire Resist',
            return null;
        coldResist: 'Cold Resist',
         }
        diseaseResist: 'Disease Resist',
        poisonResist: 'Poison Resist',
         magicResist: 'Magic Resist',


        fireResistMax: 'Fire Resist Max',
function getSelectedTransformationPath() {
        coldResistMax: 'Cold Resist Max',
     var builderState = getBuilderState();
        diseaseResistMax: 'Disease Resist Max',
        poisonResistMax: 'Poison Resist Max',
        magicResistMax: 'Magic Resist Max'
     };


     if (map[statName]) {
     if (builderState && builderState.transformationPath !== undefined) {
         return map[statName];
         return builderState.transformationPath;
     }
     }


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


        function isEmbedded() {
            return !!getBuilderState();
        }


        function getRaceCode(raceName) {
            var raceCodes = data.raceCodes || {};
            return raceCodes[raceName] || raceName;
        }


        function listAllowsValue(list, value) {
            var wanted = String(value || '').toUpperCase();


        function addOption(select, value, label) {
            if (!wanted) return true;
            var option = document.createElement('option');
 
            option.value = value;
            return safeArray(list).some(function (entry) {
            option.textContent = label;
                var text = String(entry || '').toUpperCase().trim();
             select.appendChild(option);
                var tokens;
 
                if (!text || text === 'ALL') return true;
 
                tokens = text.split(/\s+/);
                return tokens.indexOf(wanted) !== -1;
             });
         }
         }


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


             var map = {
             var map = {
Line 300: Line 444:


             return map[value] || value;
             return map[value] || value;
        }
        function isWerePath(path) {
            return ['werelion', 'werebear', 'werewolf', 'wererat', 'weregator'].indexOf(path) !== -1;
         }
         }


Line 317: Line 465:
             return name === 'Lycanthropic Resistances' ||
             return name === 'Lycanthropic Resistances' ||
                 name === 'Lycanthropic Regeneration' ||
                 name === 'Lycanthropic Regeneration' ||
                 name === 'Combat Senses';
                name === 'Lycanthropic Rejuvenation' ||
                 name === 'Combat Senses' ||
                name === 'Defensive Instincts';
         }
         }


         function isWerePath(path) {
         function formatStatName(statName) {
             return path === 'werelion' ||
             var map = {
                 path === 'werebear' ||
                ac: 'AC',
                 path === 'werewolf' ||
                hp: 'HP',
                 path === 'wererat' ||
                pow: 'Power',
                path === 'weregator';
                str: 'STR',
                sta: 'STA',
                agi: 'AGI',
                dex: 'DEX',
                wis: 'WIS',
                int: 'INT',
                cha: 'CHA',
 
pr: 'PR',
dr: 'DR',
fr: 'FR',
cr: 'CR',
lr: 'LR',
ar: 'AR',
hot: 'HoT',
pot: 'PoT',
hpFactor: 'HP Factor',
offMod: 'OFF Mod',
defMod: 'DEF Mod',
speed: 'Speed',
 
                strMax: 'STR Max',
                staMax: 'STA Max',
                agiMax: 'AGI Max',
                dexMax: 'DEX Max',
                 wisMax: 'WIS Max',
                 intMax: 'INT Max',
                 chaMax: 'CHA Max'
            };
 
            return map[statName] || String(statName || '').toUpperCase();
         }
         }


         function isMasterClassTitle(cm) {
         function addOption(select, value, label) {
             var name = String((cm && cm.name) || '');
             var option = document.createElement('option');
 
             option.value = value;
             return name === 'Berserker' ||
            option.textContent = label;
                name === 'Chosen' ||
            select.appendChild(option);
                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() {
         function getCmDisplayTab(cm) {
             var selectedTitle = '';
             var classification = String((cm && cm.classification) || '');


             Object.keys(selectedMasteries).forEach(function (name) {
             if (classification === 'Master Class') {
                 if (isMasterClassTitle(selectedMasteries[name])) {
                if (safeArray(cm.races).length && safeArray(cm.races)[0] !== 'All') return 'Racial';
                    selectedTitle = name;
                 if (safeArray(cm.classes).length && safeArray(cm.classes)[0] !== 'All') return 'Class';
                }
             }
             });


             return selectedTitle;
             return classification || 'General';
         }
         }


        function getCmType(cm) {
            return getCmDisplayTab(cm) || 'Other';
        }


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


function getCmSearchText(cm) {
            if (type === 'General') return 'cm-type-general';
    var parts = [];
            if (type === 'Racial') return 'cm-type-racial';
    var stats = cm && cm.stats ? cm.stats : {};
            if (type === 'Archetype') return 'cm-type-archetype';
            if (type === 'Class') return 'cm-type-class';
            if (type === 'Master Class') return 'cm-type-master';


    if (!cm) return '';
            return 'cm-type-other';
        }


    parts.push(cm.name || '');
        function getBaseStatsFromInputs() {
    parts.push(cm.description || '');
            var builderState = getBuilderState();
    parts.push(cm.classification || '');
            var baseStats;
    parts.push(cm.lycanthropy || '');
            var result;


    Object.keys(stats).forEach(function (statName) {
            if (builderState && builderState.baseStats) {
        parts.push(statName);
                baseStats = builderState.baseStats;
        parts.push(stats[statName]);
            } 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)
                };
            }


    safeArray(cm.spellsAdded).forEach(function (spell) {
            result = {
        parts.push(spell);
                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)
            };


    safeArray(cm.spellsRemoved).forEach(function (spell) {
            Object.keys(selectedMasteries).forEach(function (name) {
        parts.push(spell);
                var stats = selectedMasteries[name].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);
            });


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


    return parts.join(' ').toLowerCase();
        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 getAvailablePoints() {
            var builderState = getBuilderState();
            var level = parseInt(levelInput.value, 10);
            var max;
            var available;


function getRequirementHtml(reqName) {
            if (isNaN(level) || level < 1) level = 1;
    var owned = !!selectedMasteries[reqName];
            if (level > 60) level = 60;
    var className = owned ? 'cm-req-owned' : 'cm-req-missing';


    return '<span class="' + className + '">' + escapeHtml(reqName) + '</span>';
            levelInput.value = level;
}
            max = Math.min(level * 25, 1500);


function getRequirementsHtml(cm) {
            if (builderState && builderState.cmAvailable !== undefined) {
    var reqs = safeArray(cm && cm.requirements);
                available = numberOrZero(builderState.cmAvailable);
                if (available < 0) available = 0;
                if (available > max) available = max;
                return available;
            }


    if (!reqs.length) return '';
            return max;
        }


    return reqs.map(function (reqName) {
        function getSpentPoints() {
        return getRequirementHtml(reqName);
            var total = 0;
    }).join(', ');
}


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


            return total;
        }


function getCmChainBase(name) {
        function getCmPointStatusText() {
    return String(name || '').replace(/\s+\d+$/, '');
            var total = getAvailablePoints();
}
            var spent = getSpentPoints();
            var left = total - spent;


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


    return match ? parseInt(match[1], 10) : null;
            return 'Total CMs: ' + total + ' | Spent: ' + spent + ' | Left: ' + left;
}
        }


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


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


    base = getCmChainBase(cm.name);
            Object.keys(cm.baseStatReqs).forEach(function (statName) {
    currentNumber = getCmChainNumber(cm.name);
                var value = numberOrZero(cm.baseStatReqs[statName]);
                if (value > 0) {
                    reqs.push({ stat: statName, value: value });
                }
            });


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


    safeArray(data.masteries).forEach(function (item) {
        function getCmLockReasons(cm, includePoints) {
        if (
            var spent = getSpentPoints();
             item &&
             var available = getAvailablePoints();
            getCmChainBase(item.name) === base &&
             var baseStats = getBaseStatsFromInputs();
             getCmChainNumber(item.name) !== null
             var level = parseInt(levelInput.value, 10) || 1;
        ) {
            var reasons = [];
             chain.push(item.name);
        }
    });


    chain.sort(function (a, b) {
            if (cm.level && cm.level > level) {
        return getCmChainNumber(a) - getCmChainNumber(b);
                reasons.push('Reach level ' + cm.level);
    });
            }


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


function getCmChainHtml(cm) {
            getCmStatMinRequirements(cm).forEach(function (req) {
    var chain = getCmChain(cm);
                if (numberOrZero(baseStats[req.stat]) < req.value) {
                    reasons.push('Requires base ' + req.stat + ' ' + req.value);
                }
            });


    if (chain.length <= 1) return '';
            safeArray(cm.requirements).forEach(function (reqName) {
                if (!selectedMasteries[reqName]) {
                    reasons.push('Requires ' + reqName);
                }
            });


    return chain.map(function (name) {
            if (includePoints && spent + numberOrZero(cm.points) > available) {
        var owned = !!selectedMasteries[name];
                reasons.push('Not enough CM points');
        var current = cm && cm.name === name;
            }
        var className = 'cm-chain-step';


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


         if (current) {
         function getRequirementsHtml(cm) {
             className += ' cm-chain-current';
             return safeArray(cm.requirements).map(function (reqName) {
                return '<span class="' + (selectedMasteries[reqName] ? 'cm-req-owned' : 'cm-req-missing') + '">' +
                    escapeHtml(reqName) +
                '</span>';
            }).join(', ');
         }
         }


         return '<span class="' + className + '">' + escapeHtml(name) + '</span>';
         function getStatReqHtml(cm) {
    }).join('<span class="cm-chain-arrow"> &gt; </span>');
            return getCmStatMinRequirements(cm).map(function (req) {
}
                return 'Base ' + escapeHtml(req.stat) + ' ' + escapeHtml(req.value);
            }).join(', ');
        }


function getCmPassiveEffectText(cm) {
    var fields = [
        cm.effect,
        cm.effects,
        cm.effectText,
        cm.bonus,
        cm.passive,
        cm.description
    ];


    var found = '';


    fields.forEach(function (value) {
        value = String(value || '').trim();


function getCmUnlockHint(cm) {
        if (!found && value && value.toLowerCase() !== 'none') {
    var spent = getSpentPoints();
            found = value;
     var hints = [];
        }
     });


     if (!cm) return '';
     return found;
}


    if (cm.level && cm.level > parseInt(levelInput.value, 10)) {
function getCmGainLossHtml(cm) {
        hints.push('Reach level ' + cm.level);
    var parts = [];
    }


     if (cm.cmSpentReq && spent < cm.cmSpentReq) {
     Object.keys(cm.stats || {}).sort().forEach(function (key) {
         hints.push('Spend ' + (cm.cmSpentReq - spent) + ' more CM');
         var value = numberOrZero(cm.stats[key]);
    }
        var className;


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


    return hints.join(' | ');
        className = value > 0 ? 'cm-card-gain' : 'cm-card-loss';
}


        parts.push(
            '<span class="' + className + '">' +
                escapeHtml(formatStatName(key)) + ' ' +
                (value > 0 ? '+' : '') +
                escapeHtml(value) +
            '</span>'
        );
    });


    safeArray(cm.spellsAdded).forEach(function (spell) {
        if (!spell) return;


        parts.push(
            '<span class="cm-card-gain">+ ' +
                escapeHtml(spell) +
            '</span>'
        );
    });


    safeArray(cm.spellsRemoved).forEach(function (spell) {
        if (!spell) return;


function updateMasterClassPanel() {
        parts.push(
    var panel = document.getElementById('cm-master-class-panel');
            '<span class="cm-card-loss">- ' +
     var selectedTitle = getSelectedMasterClassTitle();
                escapeHtml(spell) +
            '</span>'
        );
     });


     if (!panel) return;
     if (!parts.length) {
        var passiveText = getCmPassiveEffectText(cm);


    if (!selectedTitle) {
        if (passiveText) {
        panel.innerHTML =
            parts.push(
            '<span class="cm-master-class-label">None selected</span>';
                '<span class="cm-card-effect-note">' +
         return;
                    escapeHtml(passiveText) +
                '</span>'
            );
         }
     }
     }


     panel.innerHTML =
if (cm.description) {
         '<span class="cm-master-class-label">' + escapeHtml(selectedTitle) + '</span>' +
     parts.unshift(
        '<button type="button" id="cm-remove-master-class" class="cm-mini-remove-button">Remove</button>';
         '<span class="cm-card-description-chip">' +
            escapeHtml(cm.description) +
        '</span>'
    );
}
 
    if (!parts.length) return '';


     document.getElementById('cm-remove-master-class').addEventListener('click', function () {
     return '<div class="cm-card-effects">' + parts.join('') + '</div>';
        delete selectedMasteries[selectedTitle];
        hideCmTooltip();
        updateSummary();
    });
}
}


        function getCmSearchText(cm) {
            var parts = [
                cm.name,
                cm.description,
                cm.classification,
                cm.lycanthropy
            ];


            Object.keys(cm.stats || {}).forEach(function (key) {
                parts.push(key);
                parts.push(formatStatName(key));
                parts.push(cm.stats[key]);
            });
            safeArray(cm.requirements).forEach(function (value) { parts.push(value); });
            safeArray(cm.spellsAdded).forEach(function (value) { parts.push(value); });
            safeArray(cm.spellsRemoved).forEach(function (value) { parts.push(value); });
            return parts.join(' ').toLowerCase();
        }


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) {
function findCmByNameOrId(value) {
     var found = null;
     var found = null;
    var wanted = String(value || '').trim().toLowerCase();


     safeArray(data.masteries).forEach(function (cm) {
    if (!wanted) return null;
         if (!found && cm && cm.name === name) {
 
     safeArray(data.masteries).some(function (cm) {
         var cmName = String(cm.name || '').trim().toLowerCase();
        var cmId = String(cm.id || cm.cmId || cm.CmID || '').trim().toLowerCase();
 
        if (cmName === wanted || cmId === wanted) {
             found = cm;
             found = cm;
            return true;
         }
         }
        return false;
     });
     });


Line 600: Line 845:
}
}


function getSelectedMasteryNames() {
var masterClassTitles = [
     return Object.keys(selectedMasteries).filter(function (name) {
    'Berserker',
        return selectedMasteries[name];
    'Chosen',
     }).sort();
    'Deputy',
}
    'Doomseeker',
    'Immortal',
    'Juggernaut',
     'Mercenary',
    'Savage',
     'Scholar',
    'Tinker',


function copyTextToClipboard(text, button) {
    'Arcane Were-Hunter',
     function showCopied() {
    'Divine Were-Hunter',
        var oldText;
    'Gypsy Were-Hunter',
    'Heroic Were-Hunter',
    'Metabolist',
    'Transmuter',
    'Gypsy',
     'Minstrel',
    'Exorcist',
    'Faithful',
    'Storm Caller',
    'Wilding',
    'Animator',


        if (!button) {
    'Spellbinder',
            alert('Copied.');
    'Aeromancer',
            return;
    'Geomancer',
        }
    'Hydromancer',
    'Pyromancer',
    'Ninja',
    'Sensei',


        oldText = button.textContent;
    'Defiler',
        button.textContent = 'Copied';
    'Lich',
    'Crusader',
    'Guardian',
    'Forester',
    'Hunter',
    'Assassin',
    'Burglar',
    'Death Knight',
    'Slayer',


    'Elder',
    'Mystic',
    'Defender',


        setTimeout(function () {
    'Hero',
            button.textContent = oldText;
    'Channeler',
        }, 1200);
     'Sorcerer',
     }


     if (navigator.clipboard && navigator.clipboard.writeText) {
     'Bravo',
        navigator.clipboard.writeText(text).then(showCopied, function () {
    'Diviner',
            alert(text);
    'Sage',
        });
    'Soldier'
        return;
];
    }


     alert(text);
function normalizeMasterClassName(value) {
     return String(value || '')
        .toLowerCase()
        .replace(/[\s\u00a0]+/g, ' ')
        .trim();
}
}


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


     Object.keys(selectedMasteries).forEach(function (name) {
     if (!cm || !cm.name) return false;
        var cm = selectedMasteries[name];
        var stats;


        if (!cm) return;
    cmName = normalizeMasterClassName(cm.name);


         stats = cm.stats || {};
    return masterClassTitles.some(function (title) {
         return normalizeMasterClassName(title) === cmName;
    });
}


        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 build = {
        r: raceSelect.value,
        c: classSelect.value,
        l: levelInput.value,
        p: pathSelect ? pathSelect.value : '',
        cms: getSelectedMasteryNames()
    };


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


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]) {
function applySharedClassMasteriesFromBuilder(builderState) {
        raceSelect.value = build.r;
    var names;
        loadClasses();
    }


     if (build.c) {
     if (!builderState || sharedCmBuildApplied) return;
        classSelect.value = build.c;
    }


     if (build.l) {
     names = safeArray(builderState.selectedClassMasteries);
        levelInput.value = build.l;
    }


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


     selectedMasteries = {};
     selectedMasteries = {};


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


         if (cm) {
         if (cm) {
Line 731: Line 952:
     });
     });


     updateSummary();
     sharedCmBuildApplied = true;
}
}


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 getSelectedMasterClassTitle() {
}
    var selectedTitle = '';


function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
     Object.keys(selectedMasteries).forEach(function (name) {
     var words = String(text || '').split(' ');
         if (isMasterClassTitle(selectedMasteries[name])) {
    var line = '';
             selectedTitle = name;
    var lines = [];
    var i;
    var testLine;
 
    for (i = 0; i < words.length; i++) {
        testLine = line + words[i] + ' ';
 
         if (ctx.measureText(testLine).width > maxWidth && line) {
            lines.push(line.trim());
            line = words[i] + ' ';
        } else {
             line = testLine;
         }
         }
    }
    if (line) {
        lines.push(line.trim());
    }
    if (maxLines && lines.length > maxLines) {
        lines = lines.slice(0, maxLines);
        lines[lines.length - 1] += '...';
    }
    lines.forEach(function (lineText, index) {
        ctx.fillText(lineText, x, y + (index * lineHeight));
     });
     });


     return y + (lines.length * lineHeight);
     return selectedTitle;
}
}


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


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


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


    ctx.textAlign = 'left';
}






function getCmPlayerFlags(cm) {
    var flags = [];


    if (!cm) return flags;


    safeArray(cm.playerFlags).forEach(function (flag) {
        flags.push(flag);
    });


 
    Object.keys(cm).forEach(function (key) {
 
         if (String(key).toLowerCase().indexOf('playerflag') !== -1) {
 
            if (typeof cm[key] === 'string') {
function renderBuildCard() {
                flags.push(cm[key]);
    loadQrLibrary(function (qrReady) {
            } else {
         var shareUrl = buildShareUrl();
                safeArray(cm[key]).forEach(function (flag) {
        var totals = getBuildTotals();
                    flags.push(flag);
        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 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 wrapLines(text, maxWidth, font) {
    return flags;
            var words = String(text || '').split(' ');
}
            var lines = [];
            var line = '';
            var i;
            var test;


            ctx.font = font;
function getRomanRank(value) {
    var match = String(value || '').trim().match(/\s+(I|II|III|IV|V)$/i);


            for (i = 0; i < words.length; i++) {
    if (!match) return 0;
                test = line ? line + ' ' + words[i] : words[i];


                if (ctx.measureText(test).width > maxWidth && line) {
    return {
                    lines.push(line);
        I: 1,
                    line = words[i];
        II: 2,
                } else {
        III: 3,
                    line = test;
        IV: 4,
                }
        V: 5
            }
    }[match[1].toUpperCase()] || 0;
}


            if (line) {
function stripRankSuffix(value) {
                lines.push(line);
    return String(value || '')
            }
        .trim()
        .replace(/\s+(I|II|III|IV|V)$/i, '');
}


            return lines.length ? lines : [''];
function normalizePathKey(value) {
        }
    return stripRankSuffix(value)
        .toLowerCase()
        .replace(/[^a-z0-9]/g, '');
}


        function measureWrappedList(items, columns, width, font, lineH, itemGap) {
            var colGap = 30;
            var colWidth = (width - colGap * (columns - 1)) / columns;
            var colHeights = [];
            var i;


            for (i = 0; i < columns; i++) {
                colHeights[i] = 0;
            }


            if (!items.length) {
                return lineH;
            }


            items.forEach(function (item, index) {
                var col = index % columns;
                var lines = wrapLines(item, colWidth, font);
                colHeights[col] += lines.length * lineH + itemGap;
            });


            return Math.max.apply(null, colHeights);
        }


        function drawWrappedList(items, x, y, width, columns, color, font, lineH, itemGap) {
            var colGap = 30;
            var colWidth = (width - colGap * (columns - 1)) / columns;
            var colY = [];
            var i;


            for (i = 0; i < columns; i++) {
function getExemplarFlag(cm) {
                colY[i] = y;
    var found = '';
            }
    var questText = String((cm && cm.quest) || '').trim();


            ctx.fillStyle = color;
    if (/^exemplar-/i.test(questText)) {
            ctx.font = font;
        return questText;
            ctx.textAlign = 'left';
    }
 
            if (!items.length) {
                ctx.fillText('None', x, y);
                return y + lineH;
            }


            items.forEach(function (item, index) {
    getCmPlayerFlags(cm).forEach(function (flag) {
                var col = index % columns;
        var text = String(flag || '').trim();
                var drawX = x + col * (colWidth + colGap);
                var lines = wrapLines(item, colWidth, font);


                lines.forEach(function (line) {
        if (!found && /^exemplar-/i.test(text)) {
                    ctx.fillText(line, drawX, colY[col]);
            found = text;
                    colY[col] += lineH;
                });
 
                colY[col] += itemGap;
            });
 
            return Math.max.apply(null, colY);
         }
         }
    });


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


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


            ctx.strokeStyle = 'rgba(217,166,77,0.35)';
function isClassPathCm(cm) {
            ctx.lineWidth = 1;
    return !!getExemplarFlag(cm);
            ctx.beginPath();
}
            ctx.moveTo(x + 35, y + 68);
            ctx.lineTo(x + w - 35, y + 68);
            ctx.stroke();


            ctx.textAlign = 'left';
function getClassPathKey(cm) {
        }
    var flag = getExemplarFlag(cm);


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


     ctx.textAlign = 'left';
     return normalizePathKey(cm && cm.name);
    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);
}




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


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


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


        var statsHeight = measureWrappedList(statsItems, 3, summaryW - 90, 'bold 24px Georgia', 29, 2);
        var purchasedHeight = measureWrappedList(purchasedDisplayNames, 3, panelW - 90, 'bold 24px Georgia', 29, 4);
        var gainedHeight = measureWrappedList(gainedItems, 3, panelW - 90, 'bold 23px Georgia', 28, 3);
        var removedHeight = measureWrappedList(removedItems, 3, panelW - 90, 'bold 23px Georgia', 28, 3);


        var summaryH = Math.max(430, 335 + statsHeight);
function getSelectedClassPathKey() {
        var topRowH = Math.max(summaryH, qrH);
    var selectedKey = '';
        var purchasedH = Math.max(260, 120 + purchasedHeight);
        var spellsH = Math.max(420, 260 + gainedHeight + removedHeight);


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


         canvas.width = cardWidth;
         if (!selectedKey && isClassPathCm(cm)) {
         canvas.height =
            selectedKey = getClassPathKey(cm);
    390 +
         }
    topRowH + 35 +
     });
    purchasedH + 35 +
     spellsH + 180;


    return selectedKey;
}


        ctx.fillStyle = '#05080d';
function getSelectedExemplarPathChoice() {
        ctx.fillRect(0, 0, canvas.width, canvas.height);
    var selectedChoice = '';


        ctx.strokeStyle = '#9b7330';
    Object.keys(selectedMasteries).forEach(function (name) {
        ctx.lineWidth = 6;
         var cm = selectedMasteries[name];
        roundedRect(8, 8, canvas.width - 16, canvas.height - 16, 28);
         ctx.stroke();


         ctx.textAlign = 'center';
         if (!selectedChoice && isClassPathCm(cm)) {
        ctx.fillStyle = '#d9a64d';
            selectedChoice = stripRankSuffix(cm.name);
         ctx.font = 'bold 78px Georgia';
         }
        ctx.fillText('EQOA: SANDSTORM', canvas.width / 2, 100);
    });


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


        ctx.fillStyle = '#f4e6c8';
function getPreviousRankName(cm) {
        ctx.font = 'bold 38px Georgia';
    var rank = getRomanRank(cm && cm.name);
        ctx.fillText('Unnamed CM Build', canvas.width / 2, 205);
    var baseName = stripRankSuffix(cm && cm.name);
    var previousRank;


        drawInfoBox('Race', raceSelect.value || 'None', margin, 250, 380);
    if (rank <= 1) return '';
        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');
    previousRank = {
         drawPanel(margin + summaryW + gap, y, qrW, qrH, 'SCAN');
         2: 'I',
        3: 'II',
        4: 'III',
         5: 'IV'
    }[rank];


        ctx.fillStyle = '#f4e6c8';
    return previousRank ? baseName + ' ' + previousRank : '';
        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';
function getClassPathLockReasons(cm) {
        ctx.font = 'bold 30px Georgia';
    var reasons = [];
        ctx.fillText('Stats Gained', margin + 45, y + 255);
    var selectedPathKey;
    var thisPathKey;
    var previousRankName;


        drawWrappedList(statsItems, margin + 45, y + 295, summaryW - 90, 3, '#f4e6c8', 'bold 24px Georgia', 29, 2);
    if (!isClassPathCm(cm)) return reasons;


        if (qrReady) {
    selectedPathKey = getSelectedClassPathKey();
            var qrCanvas = document.createElement('canvas');
    thisPathKey = getClassPathKey(cm);
    previousRankName = getPreviousRankName(cm);


            new QRious({
    if (selectedPathKey && thisPathKey && selectedPathKey !== thisPathKey) {
                element: qrCanvas,
        reasons.push('You already selected a different Class / Exemplar Path');
                value: shareUrl,
    }
                size: 220,
                background: 'white',
                foreground: 'black',
                level: 'M'
            });


            ctx.drawImage(qrCanvas, margin + summaryW + gap + 70, y + 105, 220, 220);
    if (previousRankName && !selectedMasteries[previousRankName]) {
         } else {
         reasons.push('Requires ' + previousRankName + ' first');
            ctx.fillStyle = '#f4e6c8';
    }
            ctx.font = 'bold 16px Georgia';
            drawWrappedText(ctx, shareUrl, margin + summaryW + gap + 30, y + 115, qrW - 60, 22, 9);
        }


        y += topRowH + 35;
    return reasons;
}


        drawPanel(margin, y, panelW, purchasedH, 'PURCHASED CMS');
        drawWrappedList(purchasedDisplayNames, margin + 45, y + 110, panelW - 90, 3, '#e066ff', 'bold 22px Georgia', 26, 4);




        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 = drawWrappedList(gainedItems, margin + 45, y + 155, panelW - 90, 3, '#8fd9ff', 'bold 21px Georgia', 25, 3);
var afterGainedY = drawWrappedList(gainedItems, margin + 45, y + 155, panelW - 90, 3, '#8fd9ff', 'bold 23px Georgia', 28, 3);


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


        drawWrappedList(removedItems, margin + 45, afterGainedY + 85, panelW - 90, 3, '#ff8f8f', 'bold 21px Georgia', 25, 3);
app.innerHTML =


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


        ctx.fillStyle = '#d9a64d';
                '<div class="character-builder-controls">' +
        ctx.font = 'bold 30px Georgia';
                    '<label>Race</label>' +
        ctx.textAlign = 'center';
                    '<select id="cm-race"></select>' +
        ctx.fillText(
            'EQOA: Sandstorm Wiki Class Mastery Calculator',
            canvas.width / 2,
            canvas.height - 55
        );


        var link = document.createElement('a');
                    '<label>Class</label>' +
        link.href = canvas.toDataURL('image/png');
                    '<select id="cm-class"></select>' +
        link.download = 'EQOA_CM_Build.png';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    });
}


                    '<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>' +
       


                    '<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 cm-master-class-heading" style="margin-top:18px;">Master Class Title</div>' +
'<div id="cm-master-class-panel" class="cm-master-class-panel"><span class="cm-master-class-text">-</span></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">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">Master Class</div>' +
        '<div id="cm-master-class-purchased-list">None selected.</div>' +
        '<div class="cm-panel-title" style="margin-top:12px;">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');


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') {
        function applyCmTheme(theme) {
        if (races.length && races[0] !== 'All') {
            app.classList.remove('cm-theme-dark');
            return 'Racial';
            app.classList.remove('cm-theme-light');
            app.classList.add(theme === 'light' ? 'cm-theme-light' : 'cm-theme-dark');
         }
         }


         if (classes.length && classes[0] !== 'All') {
         function loadSavedCmTheme() {
            return 'Class';
            var isLight =
        }
                document.documentElement.classList.contains('eqoa-theme-light') ||
    }
                document.body.classList.contains('eqoa-theme-light');
 
    return classification;
}
 
 
 
 


            try {
                var saved = localStorage.getItem('eqoa-wiki-theme');
                if (saved === 'light') isLight = true;
                if (saved === 'dark') isLight = false;
            } catch (e) {}


            applyCmTheme(isLight ? 'light' : 'dark');
        }


         function loadRaces() {
         function loadRaces() {
Line 1,167: Line 1,269:


         function loadClasses() {
         function loadClasses() {
            classSelect.innerHTML = '';
             var race = raceSelect.value;
             var race = raceSelect.value;
             var classList = safeArray((data.raceClasses || {})[race]);
             var classList = safeArray((data.raceClasses || {})[race]);
            classSelect.innerHTML = '';


             classList.forEach(function (classCode) {
             classList.forEach(function (classCode) {
Line 1,183: Line 1,285:
         }
         }


         function getAvailablePoints() {
         function syncFromCharacterBuilder() {
             var level = parseInt(levelInput.value, 10);
             var builderState = getBuilderState();
 
            if (!builderState) return;


             if (isNaN(level) || level < 1) level = 1;
             if (builderState.race && data.raceClasses[builderState.race]) {
            if (level > 60) level = 60;
                raceSelect.value = builderState.race;
                loadClasses();
            }


             levelInput.value = level;
             if (builderState.classCode) {
                classSelect.value = builderState.classCode;
            }


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


        function getSpentPoints() {
            if (builderState.baseStats) {
            var total = 0;
    updateBaseStatInputsDisplay(builderState.baseStats);
}


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


            return total;
updateSummary();
         }
         }


          
         window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;


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


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


        Array.prototype.forEach.call(viewButtons, function (btn) {
            Object.keys(selectedMasteries).forEach(function (name) {
            btn.classList.remove('cm-view-active');
                var cm = selectedMasteries[name];
        });


        button.classList.add('cm-view-active');
                Object.keys(cm.stats || {}).forEach(function (statName) {
                    var value = parseInt(cm.stats[statName], 10);


        renderMasteryList();
                    if (!isNaN(value) && value !== 0) {
    });
                        statTotals[statName] = (statTotals[statName] || 0) + 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 updatePurchasedList() {
function getUnlockedStatCaps() {
     var list = document.getElementById('cm-purchased-list');
     var caps = {
    var purchasedRows = [];
        str: 400,
    var groups = {
         sta: 400,
         'Master Class': [],
         agi: 400,
         'General': [],
         dex: 400,
         'Racial': [],
         wis: 400,
         'Archetype': [],
         int: 400,
         'Class': [],
         cha: 400
         'Other': []
     };
     };
    var groupOrder = [
        'Master Class',
        'General',
        'Racial',
        'Archetype',
        'Class',
        'Other'
    ];
    var html = '';


     if (!list) return;
     Object.keys(selectedMasteries).forEach(function (name) {
        var stats = selectedMasteries[name].stats || {};
 
        caps.str += numberOrZero(stats.strMax);
        caps.sta += numberOrZero(stats.staMax);
        caps.agi += numberOrZero(stats.agiMax);
        caps.dex += numberOrZero(stats.dexMax);
        caps.wis += numberOrZero(stats.wisMax);
        caps.int += numberOrZero(stats.intMax);
        caps.cha += numberOrZero(stats.chaMax);
    });
 
    return caps;
}
 
function getSelectedMasteryByType(typeName) {
    var found = '';


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


         if (!cm) return;
         if (!found && type === typeName) {
            found = name;
        }
    });


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


            if (!groups[groupName]) {
function getClassMasteryExportData() {
                groupName = 'Other';
    var totals = getBuildTotals();
            }
        }


         groups[groupName].push(cm);
    return {
     });
        race: raceSelect ? raceSelect.value : '',
        classCode: classSelect ? classSelect.value : '',
        level: levelInput ? levelInput.value : '',
        path: getSelectedTransformationPath(),
         classPathChoice: getSelectedMasteryByType('Class'),
        exemplarPathChoice: getSelectedExemplarPathChoice(),
        available: getAvailablePoints(),
        spent: getSpentPoints(),
        remaining: Math.max(0, getAvailablePoints() - getSpentPoints()),
        masterClassTitle: getSelectedMasterClassTitle(),
selectedMasteries: Object.keys(selectedMasteries).sort(),
selectedMasteryIds: Object.keys(selectedMasteries).map(function (name) {
     var cm = selectedMasteries[name];


     groupOrder.forEach(function (groupName) {
     return cm.id || cm.cmId || cm.CmID || cm.name;
         groups[groupName].sort(function (a, b) {
}).sort(),
            return String(a.name || '').localeCompare(String(b.name || ''));
         stats: totals.stats,
        });
        spellsAdded: Object.keys(totals.spellsAdded).sort(),
    });
        spellsRemoved: Object.keys(totals.spellsRemoved).sort()
    };
}


     groupOrder.forEach(function (groupName) {
function publishClassMasteryState() {
         if (!groups[groupName].length) return;
    window.EQOA_CLASS_MASTERY_STATE = {
        getStatCaps: getUnlockedStatCaps,
getSelectedMasteries: function () {
     return Object.keys(selectedMasteries).map(function (name) {
         var cm = selectedMasteries[name];


         html +=
         return cm.id || cm.cmId || cm.CmID || cm.name;
            '<div class="cm-purchased-group">' +
    }).sort();
                '<div class="cm-purchased-group-title">' + escapeHtml(groupName) + '</div>';
},
        getExportData: getClassMasteryExportData
    };


        groups[groupName].forEach(function (cm) {
    if (window.dispatchEvent) {
            var index = purchasedRows.length;
        window.dispatchEvent(new CustomEvent('eqoa-class-mastery-state-change'));
            purchasedRows.push(cm);
    }
}


            html +=
function updatePurchasedList() {
                '<div class="cm-purchased-item" data-purchased-index="' + index + '">' +
    var list = document.getElementById('cm-purchased-list');
                    '<span>' + escapeHtml(cm.name) + '</span>' +
    var masterList = document.getElementById('cm-master-class-purchased-list');
                    '<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
    var names = Object.keys(selectedMasteries).sort();
                '</div>';
    var masterNames = [];
        });
    var purchasedNames = [];


         html += '</div>';
    names.forEach(function (name) {
         if (isMasterClassTitle(selectedMasteries[name])) {
            masterNames.push(name);
        } else {
            purchasedNames.push(name);
        }
     });
     });


     if (!html) {
     function renderRows(container, rowNames, emptyText) {
        list.innerHTML = 'None purchased.';
        if (!container) return;
        return;
 
    }
        if (!rowNames.length) {
            container.innerHTML = emptyText;
            return;
        }
 
        container.innerHTML = rowNames.map(function (name) {
            var cm = selectedMasteries[name];


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


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


             row.addEventListener('mouseenter', function () {
             row.addEventListener('mouseenter', function () {
Line 1,315: Line 1,480:


             row.addEventListener('click', function () {
             row.addEventListener('click', function () {
                if (!cm) return;
                 delete selectedMasteries[cmName];
 
                 delete selectedMasteries[cm.name];
                 hideCmTooltip();
                 hideCmTooltip();
                 updateSummary();
                 updateSummary();
             });
             });
         }
         });
     );
     }
 
    renderRows(masterList, masterNames, 'None selected.');
    renderRows(list, purchasedNames, 'None purchased.');
}
}


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


    if (!panel) return;


     
    panel.innerHTML =
        '<span class="cm-master-class-text">' +
        (selectedTitle ? escapeHtml(selectedTitle) : '-') +
        '</span>';
}


         function updateUnlockedSummary() {
         function updateUnlockedSummary() {
             var summary = document.getElementById('cm-unlocked-summary');
             var summary = document.getElementById('cm-unlocked-summary');
             var statTotals = {};
             var totals = getBuildTotals();
             var spellsAdded = {};
             var html = '<div class="cm-unlocked-grid cm-unlocked-grid-three">';
            var spellsRemoved = {};
            var hasStats = false;
 
            if (!summary) return;
 
            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var stats;


                if (!cm) return;
             html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Stats</div>';
 
             if (!Object.keys(totals.stats).length) {
                stats = cm.stats || {};
                 html += '<div>No stat changes yet.</div>';
 
                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;
                    }
                });
            });
 
            var html = '';
 
            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 {
             } else {
                 Object.keys(statTotals).sort().forEach(function (statName) {
                 Object.keys(totals.stats).sort().forEach(function (statName) {
                     var value = statTotals[statName];
                     var value = totals.stats[statName];
                    var sign = value > 0 ? '+' : '';
                     html += '<div class="cm-purchased-item"><span>' +
 
                        escapeHtml(formatStatName(statName)) +
                     html +=
                        '</span><span class="cm-purchased-cost">' +
                        '<div class="cm-purchased-item cm-stat-row">' +
                        (value > 0 ? '+' : '') +
                            '<span>' + escapeHtml(formatStatName(statName)) + '</span>' +
                        escapeHtml(value) +
 
                        '</span></div>';
                            '<span class="cm-purchased-cost">' + sign + escapeHtml(value) + '</span>' +
                        '</div>';
                 });
                 });
             }
             }
            html += '</div>';


            html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Spells Gained</div>';
            Object.keys(totals.spellsAdded).sort().forEach(function (spell) {
                html += '<div class="cm-purchased-item">+ ' + escapeHtml(spell) + '</div>';
            });
            if (!Object.keys(totals.spellsAdded).length) html += '<div>No spells gained yet.</div>';
             html += '</div>';
             html += '</div>';


             html += '<div class="cm-unlocked-box">';
             html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Spells Removed</div>';
            html += '<div class="cm-panel-title">Spells Gained</div>';
             Object.keys(totals.spellsRemoved).sort().forEach(function (spell) {
 
                 html += '<div class="cm-purchased-item">- ' + escapeHtml(spell) + '</div>';
             if (!Object.keys(spellsAdded).length) {
             });
                 html += '<div class="cm-empty-line">No spells gained yet.</div>';
            if (!Object.keys(totals.spellsRemoved).length) html += '<div>No spells removed 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>';
            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>';
             html += '</div>';


Line 1,437: Line 1,547:
             var spent = getSpentPoints();
             var spent = getSpentPoints();
             var remaining = available - spent;
             var remaining = available - spent;
            if (remaining < 0) remaining = 0;
            if (isEmbedded()) {
                updateBaseStatInputsDisplay(getBaseStatsFromInputs());
            }


             document.getElementById('cm-available').textContent = available;
             document.getElementById('cm-available').textContent = available;
Line 1,445: Line 1,561:
updateMasterClassPanel();
updateMasterClassPanel();
updateUnlockedSummary();
updateUnlockedSummary();
publishClassMasteryState();
renderMasteryList();
renderMasteryList();
         }
         }


         function buildCmTooltipHtml(cm) {
         function buildTooltipHtml(cm) {
             var statLines = [];
             var html = '';


             if (!cm) return '';
             html += '<div class="cm-tooltip-title">' + escapeHtml(cm.name) + '</div>';
            html += '<div class="cm-tooltip-desc">' + escapeHtml(cm.description) + '</div>';
            html += '<div class="cm-tooltip-meta">Level ' + escapeHtml(cm.level) +
                ' | Cost ' + escapeHtml(cm.points) +
                (cm.cmSpentReq ? ' | CM Spent Req ' + escapeHtml(cm.cmSpentReq) : '') +
                '</div>';
            html += '<div class="cm-tooltip-meta">' + escapeHtml(getCmPointStatusText()) + '</div>';


             Object.keys(cm.stats || {}).forEach(function (key) {
             Object.keys(cm.stats || {}).forEach(function (key) {
                 var value = cm.stats[key];
                 var value = cm.stats[key];
                var sign = value > 0 ? '+' : '';


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


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


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


 
             return html;
             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>' +
                reqHtml +
                statLines.join('') +
                spellAdds +
                spellRemoves
            );
         }
         }


         function showCmTooltip(cm, card) {
         function showCmTooltip(cm, card) {
            var rect;
            var tooltipWidth = 340;
            var leftPos;
             if (!cmTooltip || !cm) return;
             if (!cmTooltip || !cm) return;


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


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


             if (leftPos + tooltipWidth > window.innerWidth) {
             if (leftPos + tooltipWidth > window.innerWidth) {
Line 1,520: Line 1,621:


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


        function renderMasteryList() {
            var masteryPanel = document.getElementById('cm-mastery-panel');
            var selectedRace = raceSelect.value;
            var selectedRaceCode = getRaceCode(selectedRace);
            var selectedClass = classSelect.value;
            var selectedPathNormalized = normalizePath(getSelectedTransformationPath());
            var selectedIsWere = isWerePath(selectedPathNormalized);
            var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : '';
            var seen = {};
            var availableMasteries = [];
            var lockedMasteries = [];
            if (!masteryPanel) return;


            visibleMasteries = [];


            safeArray(data.masteries).forEach(function (cm) {
                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 lockReasons;


        function renderMasteryList() {
                raceOk = listAllowsValue(cm.races, selectedRaceCode);
    var masteryPanel = document.getElementById('cm-mastery-panel');
                classOk = listAllowsValue(cm.classes, selectedClass);
    if (!masteryPanel) return;
                tabOk = String(getCmDisplayTab(cm)).toLowerCase() === String(activeCmTab).toLowerCase();
                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] ||
                    seen[cmName]
                ) {
                    return;
                }


    var selectedRace = raceSelect.value;
                seen[cmName] = true;
    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 = [];
                lockReasons = getCmLockReasons(cm, false).concat(getClassPathLockReasons(cm));


    safeArray(data.masteries).forEach(function (cm) {
                if (lockReasons.length) {
        var cmRaces = safeArray(cm.races);
                    lockedMasteries.push({
        var cmClasses = safeArray(cm.classes);
                        cm: cm,
        var reqs = safeArray(cm.requirements);
                        locked: true,
        var cmName = String(cm.name || '');
                        unlockHint: lockReasons.join(' | ')
        var cmPath = normalizePath(cm.lycanthropy);
                    });
        var bitePath = getBitePath(cmName);
                } else {
        var pathOk = false;
                    availableMasteries.push({
                        cm: cm,
                        locked: false,
                        unlockHint: ''
                    });
                }
            });


        var raceOk =
            visibleMasteries = showLockedCms ? availableMasteries.concat(lockedMasteries) : availableMasteries;
            !cmRaces.length ||
            cmRaces.indexOf(selectedRaceCode) !== -1;


        var classOk =
             if (!visibleMasteries.length) {
             !cmClasses.length ||
                masteryPanel.innerHTML =
            cmClasses.indexOf(selectedClass) !== -1;
                    'No Class Masteries found for this selection.<br>' +
                    '<small>Loaded masteries: ' + safeArray(data.masteries).length + '</small>';
                return;
            }


        var tabOk = getCmDisplayTab(cm) === activeCmTab;
            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 reqHtml = getRequirementsHtml(cm);
var statReqHtml = getStatReqHtml(cm);
var gainLossHtml = getCmGainLossHtml(cm);
var lockHtml = '';
var metaHtml;


        var searchOk =
                if (entry.locked) {
            !searchQuery ||
                    lockHtml =
            getCmSearchText(cm).indexOf(searchQuery) !== -1;
                        '<div class="cm-unlock-hint">' +
                            '<strong>Locked:</strong><br>' +
                            escapeHtml(entry.unlockHint).replace(/\|/g, '<br>') +
                        '</div>';
                }


        if (bitePath) {
                metaHtml =
            pathOk = selectedPathNormalized === bitePath;
                    '<div class="cm-card-meta">' +
        } else if (!cmPath) {
                        'Level: ' + escapeHtml(cm.level) +
            pathOk = true;
                        ' | Cost: ' + escapeHtml(cm.points) +
        } else if (!selectedPathNormalized) {
                        (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
            pathOk = false;
                    '</div>' +
        } else if (selectedIsWere && isSharedWereMastery(cmName)) {
                    '<div class="cm-card-meta">' + escapeHtml(getCmPointStatusText()) + '</div>';
            pathOk = cmPath === 'werebeast' || isWerePath(cmPath);
        } else if (selectedIsWere && cmPath === 'werebeast') {
            pathOk = true;
        } else {
            pathOk = cmPath === selectedPathNormalized;
        }


        if (
                if (activeCmView === 'compact') {
            !raceOk ||
                    return '<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
            !classOk ||
                        '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
            !tabOk ||
                        '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
            !pathOk ||
metaHtml +
            !searchOk ||
gainLossHtml +
            selectedMasteries[cmName] ||
(statReqHtml ? '<div class="cm-card-req">Stat Req: ' + statReqHtml + '</div>' : '') +
            seenMasteries[cmName]
lockHtml +
        ) {
                    '</div>';
            return;
                }
        }


        seenMasteries[cmName] = true;
                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 +
gainLossHtml +
(reqHtml ? '<div class="cm-card-req">Requires: ' + reqHtml + '</div>' : '') +
                    (statReqHtml ? '<div class="cm-card-req">Stat Req: ' + statReqHtml + '</div>' : '') +
                    lockHtml +
                '</div>';
            }).join('');


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


        if (unlockHint) {
          /*      card.addEventListener('mouseenter', function () {
            lockedMasteries.push({
                    showCmTooltip(cm, card);
                cm: cm,
                });
                locked: true,
                unlockHint: unlockHint
            });
        } else {
            availableMasteries.push({
                cm: cm,
                locked: false,
                unlockHint: ''
            });
        }
    });


    if (showLockedCms) {
                card.addEventListener('mouseleave', hideCmTooltip); */
    visibleMasteries = availableMasteries.concat(lockedMasteries);
} else {
    visibleMasteries = availableMasteries;
}


                card.addEventListener('click', function () {
                    var reasons;
                    var selectedTitle;


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


    masteryPanel.innerHTML = visibleMasteries.map(function (entry, index) {
                    if (entry.locked) {
    var cm = entry.cm;
                        alert(entry.unlockHint);
    var lockedClass = entry.locked ? ' cm-card-locked' : '';
                        return;
    var typeClass = getCmTypeClass(cm);
                    }
    var cmType = getCmType(cm);
    var requirementsHtml = getRequirementsHtml(cm);
    var chainHtml = getCmChainHtml(cm);


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


    if (activeCmView === 'compact') {
                    if (reasons.length) {
        return (
                        alert(reasons.join(' | '));
            '<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
                        return;
                '<div class="cm-card-compact-row">' +
                    }
                     '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
 
                    '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
                     if (isMasterClassTitle(cm)) {
                    metaHtml +
    selectedTitle = getSelectedMasterClassTitle();
                '</div>' +
 
                (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
    if (selectedTitle && selectedTitle !== cm.name) {
                (entry.unlockHint ? '<div class="cm-unlock-hint">' + escapeHtml(entry.unlockHint) + '</div>' : '') +
        alert('You can only select one Master Class Title per character build.');
            '</div>'
         return;
         );
     }
     }
}


    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>' : '') +
            (chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
            (entry.unlockHint ? '<div class="cm-unlock-hint">' + escapeHtml(entry.unlockHint) + '</div>' : '') +
        '</div>'
    );
}).join('');


var classPathReasons = getClassPathLockReasons(cm);


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


    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 () {
selectedMasteries[cm.name] = cm;
                 showCmTooltip(cm, card);
                    hideCmTooltip();
                    updateSummary();
                 });
             });
             });
        }


            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;
                }


                available = getAvailablePoints();
        function setBaseStatInputs(baseStats) {
                spent = getSpentPoints();
            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);
        }


                if (spent + cm.points > available) {
        function loadBuildFromUrl() {
                    alert('Not enough CM points.');
            var hash = window.location.hash || '';
                    return;
            var build;
                }


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


                    if (selectedMasterClassTitle && selectedMasterClassTitle !== cm.name) {
            try {
                        alert('You can only select one Master Class Title per character build. Remove ' + selectedMasterClassTitle + ' first.');
                build = JSON.parse(decodeURIComponent(hash.replace('#cm-build=', '')));
                        return;
            } catch (e) {
                    }
                return;
                }
            }


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


                hideCmTooltip();
            if (build.c) classSelect.value = build.c;
                updateSummary();
            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 = findCmByNameOrId(name);
                if (cm) selectedMasteries[cm.name] = cm;
            });


            updateSummary();
        }


         raceSelect.addEventListener('change', loadClasses);
         raceSelect.addEventListener('change', loadClasses);
         classSelect.addEventListener('change', updateSummary);
         classSelect.addEventListener('change', updateSummary);
         levelInput.addEventListener('input', updateSummary);
         levelInput.addEventListener('input', updateSummary);
if (searchInput) {
    searchInput.addEventListener('input', renderMasteryList);
}


if (clearSearchButton && searchInput) {
        [
    clearSearchButton.addEventListener('click', function () {
            'cm-base-str',
        searchInput.value = '';
            'cm-base-sta',
        renderMasteryList();
            'cm-base-dex',
        searchInput.focus();
            '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 (shareUrlButton) {
    shareUrlButton.addEventListener('click', function () {
        copyTextToClipboard(buildShareUrl(), shareUrlButton);
    });
}


if (exportCardButton) {
         if (searchInput) {
    exportCardButton.addEventListener('click', function () {
            searchInput.addEventListener('input', renderMasteryList);
         renderBuildCard();
         }
    });
}
 
 
if (showLockedInput) {
    showLockedInput.addEventListener('change', function () {
        showLockedCms = showLockedInput.checked;
         renderMasteryList();
    });
}


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


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


         if (pathSelect) {
         if (pathSelect) {
Line 1,766: Line 1,903:
         }
         }


         Array.prototype.forEach.call(
         if (isEmbedded()) {
            document.querySelectorAll('.cm-tab-button'),
            window.addEventListener('eqoa-character-builder-state-change', syncFromCharacterBuilder);
            function (button) {
        }
                button.addEventListener('click', function () {
                    activeCmTab = button.getAttribute('data-tab');


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


                     button.classList.add('cm-tab-active');
                Array.prototype.forEach.call(document.querySelectorAll('.cm-tab-button'), function (btn) {
                     btn.classList.remove('cm-tab-active');
                });
 
                button.classList.add('cm-tab-active');
                updateSummary();
            });
        });
 
        Array.prototype.forEach.call(viewButtons, function (button) {
            button.addEventListener('click', function () {
                activeCmView = button.getAttribute('data-view') || 'detailed';


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


        if (resetButton) {
                button.classList.add('cm-view-active');
    resetButton.addEventListener('click', function () {
                renderMasteryList();
        selectedMasteries = {};
            });
        activeCmTab = 'General';
        });
        activeCmView = 'detailed';
 
showLockedCms = true;
        function resetClassMasterySelections() {
    selectedMasteries = {};
    sharedCmBuildApplied = false;
    activeCmTab = 'General';
    activeCmView = 'detailed';
    showLockedCms = true;
    levelInput.value = 60;
    setBaseStatInputs({});


        levelInput.value = 60;
    if (pathSelect) pathSelect.value = '';
    if (searchInput) searchInput.value = '';
    if (showLockedInput) showLockedInput.checked = true;


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


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


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


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


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


if (showLockedInput) {
window.EQOA_RESET_CLASS_MASTERY = resetClassMasterySelections;
     showLockedInput.checked = true;
 
if (resetButton) {
     resetButton.addEventListener('click', resetClassMasterySelections);
}
}


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


        window.addEventListener('eqoa-wiki-theme-change', function (event) {
            if (event.detail && event.detail.theme) {
                applyCmTheme(event.detail.theme);
            }
        });


         loadRaces();
         loadRaces();
loadBuildFromUrl();


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

Latest revision as of 22:28, 25 June 2026

mw.hook('wikipage.content').add(function () {
    var cmCalculatorStarted = false;

    function cmDataIsReady() {
        return !!window.EQOA_CM_DATA &&
            (
                (
                    window.EQOA_CM_DATA.masteries &&
                    window.EQOA_CM_DATA.masteries.length
                ) ||
                window.EQOA_CM_RAW
            );
    }

    function waitForCMReady(attemptsLeft) {
        var app = document.getElementById('cm-calculator-app');

        if (app && cmDataIsReady()) {
            if (cmCalculatorStarted) return;

            cmCalculatorStarted = true;
            initClassMasteryCalculator();
            return;
        }

        if (attemptsLeft <= 0) {
            if (app) {
                app.innerHTML =
                    '<div style="color:red;">EQOA Class Mastery failed to load.</div>';
            }

            return;
        }

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

    waitForCMReady(100);

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

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

        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 splitList(value) {
            value = String(value || '').trim();
            if (!value || value.toLowerCase() === 'all') return ['All'];
            return value.split(/\s+/);
        }

        function buildMasteriesFromRaw(rawText) {
            var lines = String(rawText || '').split(/\r?\n/);
            var headerLineIndex = -1;
            var headers = [];
            var headerMap = {};
            var rows = [];

            function get(cols, name) {
                var indexes = headerMap[name] || [];
                if (!indexes.length) return '';
                return String(cols[indexes[0]] || '').trim();
            }

            function getAll(cols, name) {
                var indexes = headerMap[name] || [];
                var values = [];

                indexes.forEach(function (index) {
                    var value = String(cols[index] || '').trim();

                    if (!value) return;
                    if (/^[0-9A-F]{4}$/i.test(value)) return;

                    if (values.indexOf(value) === -1) {
                        values.push(value);
                    }
                });

                return values;
            }


function getAllHeadersContaining(cols, text) {
    var values = [];
    text = String(text || '').toLowerCase();

    headers.forEach(function (header, index) {
        var value;

        if (String(header || '').toLowerCase().indexOf(text) === -1) return;

        value = String(cols[index] || '').trim();

        if (!value) return;
        if (/^[0-9A-F]{4}$/i.test(value)) return;

        if (values.indexOf(value) === -1) {
            values.push(value);
        }
    });

    return values;
}



            function addStat(stats, cols, header, key) {
                var value = parseInt(get(cols, header), 10);
                if (!isNaN(value) && value !== 0) {
                    stats[key] = value;
                }
            }

            function addStatAny(stats, cols, headers, key) {
                var found = false;

                headers.forEach(function (header) {
                    var value;

                    if (found) return;

                    value = parseInt(get(cols, header), 10);

                    if (!isNaN(value) && value !== 0) {
                        stats[key] = value;
                        found = true;
                    }
                });
            }

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

            if (headerLineIndex === -1) return [];

            headers = lines[headerLineIndex].split('\t').map(function (header) {
                return String(header || '').trim();
            });

            headers.forEach(function (header, index) {
                if (!headerMap[header]) headerMap[header] = [];
                headerMap[header].push(index);
            });

            lines.slice(headerLineIndex + 1).forEach(function (line) {
                var cols;
                var name;
var stats = {};
var baseStatReqs = {};
var cmId = '';

                if (!line.trim()) return;

cols = line.split('\t');
name = get(cols, 'Mastery');

cmId =
    get(cols, 'CmID') ||
    get(cols, 'CMID') ||
    get(cols, 'CM ID') ||
    get(cols, 'Cm ID');

if (!cmId && /^[0-9A-F]{4}$/i.test(String(cols[0] || '').trim())) {
    cmId = String(cols[0] || '').trim();
}

if (!name) return;

addStat(stats, cols, 'AC', 'ac');
addStat(stats, cols, 'STR', 'str');
addStat(stats, cols, 'STA', 'sta');
addStat(stats, cols, 'AGI', 'agi');
addStat(stats, cols, 'DEX', 'dex');
addStat(stats, cols, 'WIS', 'wis');
addStat(stats, cols, 'INT', 'int');
addStat(stats, cols, 'CHA', 'cha');

addStat(stats, cols, 'HP Max', 'hp');
addStat(stats, cols, 'PWR Max', 'pow');
addStat(stats, cols, 'POW Max', 'pow');
addStat(stats, cols, 'Power Max', 'pow');

/* Resists */
addStat(stats, cols, 'PR', 'pr');
addStat(stats, cols, 'DR', 'dr');
addStat(stats, cols, 'FR', 'fr');
addStat(stats, cols, 'CR', 'cr');
addStat(stats, cols, 'LR', 'lr');
addStat(stats, cols, 'AR', 'ar');

/* Regen / resource ticks */
addStat(stats, cols, 'HoT', 'hot');
addStat(stats, cols, 'HOT', 'hot');
addStat(stats, cols, 'PoT', 'pot');
addStat(stats, cols, 'POT', 'pot');

/* Modifiers */
addStat(stats, cols, 'HP Factor', 'hpFactor');
addStat(stats, cols, 'OFF Mod', 'offMod');
addStat(stats, cols, 'DEF Mod', 'defMod');
addStat(stats, cols, 'Speed', 'speed');

/* Stat cap increases */
addStatAny(stats, cols, ['STR Max', 'Max STR', 'STR MAX'], 'strMax');
addStatAny(stats, cols, ['STA Max', 'Max STA', 'STA MAX'], 'staMax');
addStatAny(stats, cols, ['AGI Max', 'Max AGI', 'AGI MAX'], 'agiMax');
addStatAny(stats, cols, ['DEX Max', 'Max DEX', 'DEX MAX'], 'dexMax');
addStatAny(stats, cols, ['WIS Max', 'Max WIS', 'WIS MAX'], 'wisMax');
addStatAny(stats, cols, ['INT Max', 'Max INT', 'INT MAX'], 'intMax');
addStatAny(stats, cols, ['CHA Max', 'Max CHA', 'CHA MAX'], 'chaMax');
                ['STR', 'STA', 'DEX', 'AGI', 'INT', 'WIS', 'CHA'].forEach(function (stat) {
                    var value = parseInt(get(cols, 'Base ' + stat + ' Req'), 10);
                    if (!isNaN(value) && value > 0) {
                        baseStatReqs[stat] = value;
                    }
                });

rows.push({
    id: cmId,
    name: name,
                    description: get(cols, 'Description'),
                    classification: get(cols, 'Classification'),
                    subCategory: get(cols, 'Sub-Category'),
playerFlags: []
    .concat(getAll(cols, 'Playerflags'))
    .concat(getAll(cols, 'Player Flags'))
    .concat(getAll(cols, 'Playerflag'))
    .concat(getAll(cols, 'Player Flag'))
    .concat(getAllHeadersContaining(cols, 'playerflag'))
    .concat(getAllHeadersContaining(cols, 'player flag')),
                    races: splitList(get(cols, 'Race')),
                    classes: splitList(get(cols, 'Class')),
                    lycanthropy: get(cols, 'Lycanthropy'),

                    quest: get(cols, 'Quest'),
                    level: parseInt(get(cols, 'Level'), 10) || 0,
                    points: parseInt(get(cols, 'Points'), 10) || 0,

                    cmSpentReq: parseInt(get(cols, 'CM Spent Req'), 10) || 0,
                    requirements: []
                        .concat(getAll(cols, 'CM Req 1'))
                        .concat(getAll(cols, 'CM Req 2'))
                        .concat(getAll(cols, 'CM Req 3'))
                        .concat(getAll(cols, 'CM Req 4')),
                    spellsAdded: []
                        .concat(getAll(cols, '=+Spell 1'))
                        .concat(getAll(cols, '=+Spell 2'))
                        .concat(getAll(cols, '=+Spell 3'))
                        .concat(getAll(cols, '=+Spell 4'))
                        .concat(getAll(cols, '=+Spell 5'))
                        .concat(getAll(cols, '=+Spell 6')),
                    spellsRemoved: []
                        .concat(getAll(cols, '-Spell 1'))
                        .concat(getAll(cols, '-Spell 2'))
                        .concat(getAll(cols, '-Spell 3'))
                        .concat(getAll(cols, '-Spell 4'))
                        .concat(getAll(cols, '-Spell 5'))
                        .concat(getAll(cols, '-Spell 6')),
                    stats: stats,
                    baseStatReqs: baseStatReqs
                });
            });

            return rows;
        }


function buildCmIdsByNameFromRaw(rawText) {
    var idsByName = {};
    var lines = String(rawText || '').split(/\r?\n/);

    lines.forEach(function (line) {
        var cols;
        var id;
        var name;

        line = String(line || '').trim();

        if (!line) return;

        if (line.indexOf('\t') !== -1) {
            cols = line.split('\t');
        } else if (line.indexOf('\\t') !== -1) {
            cols = line.split('\\t');
        } else {
            cols = line.split(/\s{2,}/);
        }

        id = String(cols[0] || '').trim().replace(/[^0-9A-F]/gi, '');
        name = String(cols[1] || '').trim();

        if (!/^[0-9A-F]{4}$/i.test(id)) return;
        if (!name || name.toLowerCase() === 'mastery') return;

        idsByName[name] = id;
    });

    return idsByName;
}

if (window.EQOA_CM_RAW) {
    var rawMasteriesWithIds = buildMasteriesFromRaw(window.EQOA_CM_RAW);
    var cmIdsByName = buildCmIdsByNameFromRaw(window.EQOA_CM_IDS_RAW || window.EQOA_CM_RAW);
    var rawMasteriesByName = {};

    rawMasteriesWithIds.forEach(function (cm) {
        if (cm.name && cmIdsByName[cm.name]) {
            cm.id = cmIdsByName[cm.name];
        }

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

    if (!data.masteries || !data.masteries.length) {
        data.masteries = rawMasteriesWithIds;
    } else {
        data.masteries.forEach(function (cm) {
            var rawCm;

            if (cm.name && cmIdsByName[cm.name]) {
                cm.id = cmIdsByName[cm.name];
            }

            rawCm = cm.name ? rawMasteriesByName[cm.name] : null;

            if (rawCm && rawCm.stats) {
                cm.stats = cm.stats || {};

                Object.keys(rawCm.stats).forEach(function (statKey) {
                    cm.stats[statKey] = rawCm.stats[statKey];
                });
            }
        });
    }

    if (window.EQOA_CM_DATA) {
        window.EQOA_CM_DATA.masteries = data.masteries;
    }
}


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

            return null;
        }

function getSelectedTransformationPath() {
    var builderState = getBuilderState();

    if (builderState && builderState.transformationPath !== undefined) {
        return builderState.transformationPath;
    }

    return pathSelect ? pathSelect.value : '';
}

        function isEmbedded() {
            return !!getBuilderState();
        }

        function getRaceCode(raceName) {
            var raceCodes = data.raceCodes || {};
            return raceCodes[raceName] || raceName;
        }

        function listAllowsValue(list, value) {
            var wanted = String(value || '').toUpperCase();

            if (!wanted) return true;

            return safeArray(list).some(function (entry) {
                var text = String(entry || '').toUpperCase().trim();
                var tokens;

                if (!text || text === 'ALL') return true;

                tokens = text.split(/\s+/);
                return tokens.indexOf(wanted) !== -1;
            });
        }

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

            if (!value || value === 'all' || value === 'none') return '';

            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 isWerePath(path) {
            return ['werelion', 'werebear', 'werewolf', 'wererat', 'weregator'].indexOf(path) !== -1;
        }

        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 === 'Lycanthropic Rejuvenation' ||
                name === 'Combat Senses' ||
                name === 'Defensive Instincts';
        }

        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',

pr: 'PR',
dr: 'DR',
fr: 'FR',
cr: 'CR',
lr: 'LR',
ar: 'AR',
hot: 'HoT',
pot: 'PoT',
hpFactor: 'HP Factor',
offMod: 'OFF Mod',
defMod: 'DEF Mod',
speed: 'Speed',

                strMax: 'STR Max',
                staMax: 'STA Max',
                agiMax: 'AGI Max',
                dexMax: 'DEX Max',
                wisMax: 'WIS Max',
                intMax: 'INT Max',
                chaMax: 'CHA Max'
            };

            return map[statName] || String(statName || '').toUpperCase();
        }

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

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

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

            return classification || 'General';
        }

        function getCmType(cm) {
            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 getBaseStatsFromInputs() {
            var builderState = getBuilderState();
            var baseStats;
            var result;

            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)
                };
            }

            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 stats = selectedMasteries[name].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 getAvailablePoints() {
            var builderState = getBuilderState();
            var level = parseInt(levelInput.value, 10);
            var max;
            var available;

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

            levelInput.value = level;
            max = Math.min(level * 25, 1500);

            if (builderState && builderState.cmAvailable !== undefined) {
                available = numberOrZero(builderState.cmAvailable);
                if (available < 0) available = 0;
                if (available > max) available = max;
                return available;
            }

            return max;
        }

        function getSpentPoints() {
            var total = 0;

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

            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;
        }

        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 getCmLockReasons(cm, includePoints) {
            var spent = getSpentPoints();
            var available = getAvailablePoints();
            var baseStats = getBaseStatsFromInputs();
            var level = parseInt(levelInput.value, 10) || 1;
            var reasons = [];

            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) {
                if (numberOrZero(baseStats[req.stat]) < req.value) {
                    reasons.push('Requires base ' + req.stat + ' ' + req.value);
                }
            });

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

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

            return reasons;
        }

        function getRequirementsHtml(cm) {
            return safeArray(cm.requirements).map(function (reqName) {
                return '<span class="' + (selectedMasteries[reqName] ? 'cm-req-owned' : 'cm-req-missing') + '">' +
                    escapeHtml(reqName) +
                '</span>';
            }).join(', ');
        }

        function getStatReqHtml(cm) {
            return getCmStatMinRequirements(cm).map(function (req) {
                return 'Base ' + escapeHtml(req.stat) + ' ' + escapeHtml(req.value);
            }).join(', ');
        }

function getCmPassiveEffectText(cm) {
    var fields = [
        cm.effect,
        cm.effects,
        cm.effectText,
        cm.bonus,
        cm.passive,
        cm.description
    ];

    var found = '';

    fields.forEach(function (value) {
        value = String(value || '').trim();

        if (!found && value && value.toLowerCase() !== 'none') {
            found = value;
        }
    });

    return found;
}

function getCmGainLossHtml(cm) {
    var parts = [];

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

        if (!value) return;

        className = value > 0 ? 'cm-card-gain' : 'cm-card-loss';

        parts.push(
            '<span class="' + className + '">' +
                escapeHtml(formatStatName(key)) + ' ' +
                (value > 0 ? '+' : '') +
                escapeHtml(value) +
            '</span>'
        );
    });

    safeArray(cm.spellsAdded).forEach(function (spell) {
        if (!spell) return;

        parts.push(
            '<span class="cm-card-gain">+ ' +
                escapeHtml(spell) +
            '</span>'
        );
    });

    safeArray(cm.spellsRemoved).forEach(function (spell) {
        if (!spell) return;

        parts.push(
            '<span class="cm-card-loss">- ' +
                escapeHtml(spell) +
            '</span>'
        );
    });

    if (!parts.length) {
        var passiveText = getCmPassiveEffectText(cm);

        if (passiveText) {
            parts.push(
                '<span class="cm-card-effect-note">' +
                    escapeHtml(passiveText) +
                '</span>'
            );
        }
    }

if (cm.description) {
    parts.unshift(
        '<span class="cm-card-description-chip">' +
            escapeHtml(cm.description) +
        '</span>'
    );
}

    if (!parts.length) return '';

    return '<div class="cm-card-effects">' + parts.join('') + '</div>';
}

        function getCmSearchText(cm) {
            var parts = [
                cm.name,
                cm.description,
                cm.classification,
                cm.lycanthropy
            ];

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

            safeArray(cm.requirements).forEach(function (value) { parts.push(value); });
            safeArray(cm.spellsAdded).forEach(function (value) { parts.push(value); });
            safeArray(cm.spellsRemoved).forEach(function (value) { parts.push(value); });

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









function findCmByNameOrId(value) {
    var found = null;
    var wanted = String(value || '').trim().toLowerCase();

    if (!wanted) return null;

    safeArray(data.masteries).some(function (cm) {
        var cmName = String(cm.name || '').trim().toLowerCase();
        var cmId = String(cm.id || cm.cmId || cm.CmID || '').trim().toLowerCase();

        if (cmName === wanted || cmId === wanted) {
            found = cm;
            return true;
        }

        return false;
    });

    return found;
}

var masterClassTitles = [
    'Berserker',
    'Chosen',
    'Deputy',
    'Doomseeker',
    'Immortal',
    'Juggernaut',
    'Mercenary',
    'Savage',
    'Scholar',
    'Tinker',

    'Arcane Were-Hunter',
    'Divine Were-Hunter',
    'Gypsy Were-Hunter',
    'Heroic Were-Hunter',
    'Metabolist',
    'Transmuter',
    'Gypsy',
    'Minstrel',
    'Exorcist',
    'Faithful',
    'Storm Caller',
    'Wilding',
    'Animator',

    'Spellbinder',
    'Aeromancer',
    'Geomancer',
    'Hydromancer',
    'Pyromancer',
    'Ninja',
    'Sensei',

    'Defiler',
    'Lich',
    'Crusader',
    'Guardian',
    'Forester',
    'Hunter',
    'Assassin',
    'Burglar',
    'Death Knight',
    'Slayer',

    'Elder',
    'Mystic',
    'Defender',

    'Hero',
    'Channeler',
    'Sorcerer',

    'Bravo',
    'Diviner',
    'Sage',
    'Soldier'
];

function normalizeMasterClassName(value) {
    return String(value || '')
        .toLowerCase()
        .replace(/[\s\u00a0]+/g, ' ')
        .trim();
}

function isMasterClassTitle(cm) {
    var cmName;

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

    cmName = normalizeMasterClassName(cm.name);

    return masterClassTitles.some(function (title) {
        return normalizeMasterClassName(title) === cmName;
    });
}











function applySharedClassMasteriesFromBuilder(builderState) {
    var names;

    if (!builderState || sharedCmBuildApplied) return;

    names = safeArray(builderState.selectedClassMasteries);

    if (!names.length) return;

    selectedMasteries = {};

    names.forEach(function (name) {
var cm = findCmByNameOrId(name);

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

    sharedCmBuildApplied = true;
}



        function getSelectedMasterClassTitle() {
    var selectedTitle = '';

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

    return selectedTitle;
}







function getCmPlayerFlags(cm) {
    var flags = [];

    if (!cm) return flags;

    safeArray(cm.playerFlags).forEach(function (flag) {
        flags.push(flag);
    });

    Object.keys(cm).forEach(function (key) {
        if (String(key).toLowerCase().indexOf('playerflag') !== -1) {
            if (typeof cm[key] === 'string') {
                flags.push(cm[key]);
            } else {
                safeArray(cm[key]).forEach(function (flag) {
                    flags.push(flag);
                });
            }
        }
    });

    return flags;
}

function getRomanRank(value) {
    var match = String(value || '').trim().match(/\s+(I|II|III|IV|V)$/i);

    if (!match) return 0;

    return {
        I: 1,
        II: 2,
        III: 3,
        IV: 4,
        V: 5
    }[match[1].toUpperCase()] || 0;
}

function stripRankSuffix(value) {
    return String(value || '')
        .trim()
        .replace(/\s+(I|II|III|IV|V)$/i, '');
}

function normalizePathKey(value) {
    return stripRankSuffix(value)
        .toLowerCase()
        .replace(/[^a-z0-9]/g, '');
}







function getExemplarFlag(cm) {
    var found = '';
    var questText = String((cm && cm.quest) || '').trim();

    if (/^exemplar-/i.test(questText)) {
        return questText;
    }

    getCmPlayerFlags(cm).forEach(function (flag) {
        var text = String(flag || '').trim();

        if (!found && /^exemplar-/i.test(text)) {
            found = text;
        }
    });

    return found;
}

function getPathFlag(cm) {
    return getExemplarFlag(cm);
}

function isClassPathCm(cm) {
    return !!getExemplarFlag(cm);
}

function getClassPathKey(cm) {
    var flag = getExemplarFlag(cm);

    if (flag) {
        return normalizePathKey(flag);
    }

    return normalizePathKey(cm && cm.name);
}








function getSelectedClassPathKey() {
    var selectedKey = '';

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

        if (!selectedKey && isClassPathCm(cm)) {
            selectedKey = getClassPathKey(cm);
        }
    });

    return selectedKey;
}

function getSelectedExemplarPathChoice() {
    var selectedChoice = '';

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

        if (!selectedChoice && isClassPathCm(cm)) {
            selectedChoice = stripRankSuffix(cm.name);
        }
    });

    return selectedChoice;
}

function getPreviousRankName(cm) {
    var rank = getRomanRank(cm && cm.name);
    var baseName = stripRankSuffix(cm && cm.name);
    var previousRank;

    if (rank <= 1) return '';

    previousRank = {
        2: 'I',
        3: 'II',
        4: 'III',
        5: 'IV'
    }[rank];

    return previousRank ? baseName + ' ' + previousRank : '';
}

function getClassPathLockReasons(cm) {
    var reasons = [];
    var selectedPathKey;
    var thisPathKey;
    var previousRankName;

    if (!isClassPathCm(cm)) return reasons;

    selectedPathKey = getSelectedClassPathKey();
    thisPathKey = getClassPathKey(cm);
    previousRankName = getPreviousRankName(cm);

    if (selectedPathKey && thisPathKey && selectedPathKey !== thisPathKey) {
        reasons.push('You already selected a different Class / Exemplar Path');
    }

    if (previousRankName && !selectedMasteries[previousRankName]) {
        reasons.push('Requires ' + previousRankName + ' first');
    }

    return reasons;
}








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>' +
         

                    '<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 cm-master-class-heading" style="margin-top:18px;">Master Class Title</div>' +
'<div id="cm-master-class-panel" class="cm-master-class-panel"><span class="cm-master-class-text">-</span></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">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">Master Class</div>' +
        '<div id="cm-master-class-purchased-list">None selected.</div>' +
        '<div class="cm-panel-title" style="margin-top:12px;">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');


        function applyCmTheme(theme) {
            app.classList.remove('cm-theme-dark');
            app.classList.remove('cm-theme-light');
            app.classList.add(theme === 'light' ? 'cm-theme-light' : 'cm-theme-dark');
        }

        function loadSavedCmTheme() {
            var isLight =
                document.documentElement.classList.contains('eqoa-theme-light') ||
                document.body.classList.contains('eqoa-theme-light');

            try {
                var saved = localStorage.getItem('eqoa-wiki-theme');
                if (saved === 'light') isLight = true;
                if (saved === 'dark') isLight = false;
            } catch (e) {}

            applyCmTheme(isLight ? 'light' : 'dark');
        }

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

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

            loadClasses();
        }

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

            classSelect.innerHTML = '';

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

            updateSummary();
        }

        function syncFromCharacterBuilder() {
            var builderState = getBuilderState();

            if (!builderState) return;

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

            if (builderState.classCode) {
                classSelect.value = builderState.classCode;
            }

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

            if (builderState.baseStats) {
    updateBaseStatInputsDisplay(builderState.baseStats);
}

applySharedClassMasteriesFromBuilder(builderState);

updateSummary();
        }

        window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;

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

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

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

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

                    if (!isNaN(value) && value !== 0) {
                        statTotals[statName] = (statTotals[statName] || 0) + 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 getUnlockedStatCaps() {
    var caps = {
        str: 400,
        sta: 400,
        agi: 400,
        dex: 400,
        wis: 400,
        int: 400,
        cha: 400
    };

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

        caps.str += numberOrZero(stats.strMax);
        caps.sta += numberOrZero(stats.staMax);
        caps.agi += numberOrZero(stats.agiMax);
        caps.dex += numberOrZero(stats.dexMax);
        caps.wis += numberOrZero(stats.wisMax);
        caps.int += numberOrZero(stats.intMax);
        caps.cha += numberOrZero(stats.chaMax);
    });

    return caps;
}

function getSelectedMasteryByType(typeName) {
    var found = '';

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

        if (!found && type === typeName) {
            found = name;
        }
    });

    return found;
}

function getClassMasteryExportData() {
    var totals = getBuildTotals();

    return {
        race: raceSelect ? raceSelect.value : '',
        classCode: classSelect ? classSelect.value : '',
        level: levelInput ? levelInput.value : '',
        path: getSelectedTransformationPath(),
        classPathChoice: getSelectedMasteryByType('Class'),
        exemplarPathChoice: getSelectedExemplarPathChoice(),
        available: getAvailablePoints(),
        spent: getSpentPoints(),
        remaining: Math.max(0, getAvailablePoints() - getSpentPoints()),
        masterClassTitle: getSelectedMasterClassTitle(),
selectedMasteries: Object.keys(selectedMasteries).sort(),
selectedMasteryIds: Object.keys(selectedMasteries).map(function (name) {
    var cm = selectedMasteries[name];

    return cm.id || cm.cmId || cm.CmID || cm.name;
}).sort(),
        stats: totals.stats,
        spellsAdded: Object.keys(totals.spellsAdded).sort(),
        spellsRemoved: Object.keys(totals.spellsRemoved).sort()
    };
}

function publishClassMasteryState() {
    window.EQOA_CLASS_MASTERY_STATE = {
        getStatCaps: getUnlockedStatCaps,
getSelectedMasteries: function () {
    return Object.keys(selectedMasteries).map(function (name) {
        var cm = selectedMasteries[name];

        return cm.id || cm.cmId || cm.CmID || cm.name;
    }).sort();
},
        getExportData: getClassMasteryExportData
    };

    if (window.dispatchEvent) {
        window.dispatchEvent(new CustomEvent('eqoa-class-mastery-state-change'));
    }
}

function updatePurchasedList() {
    var list = document.getElementById('cm-purchased-list');
    var masterList = document.getElementById('cm-master-class-purchased-list');
    var names = Object.keys(selectedMasteries).sort();
    var masterNames = [];
    var purchasedNames = [];

    names.forEach(function (name) {
        if (isMasterClassTitle(selectedMasteries[name])) {
            masterNames.push(name);
        } else {
            purchasedNames.push(name);
        }
    });

    function renderRows(container, rowNames, emptyText) {
        if (!container) return;

        if (!rowNames.length) {
            container.innerHTML = emptyText;
            return;
        }

        container.innerHTML = rowNames.map(function (name) {
            var cm = selectedMasteries[name];

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

        Array.prototype.forEach.call(container.querySelectorAll('.cm-purchased-item'), function (row) {
            var cmName = row.getAttribute('data-cm-name');
            var cm = selectedMasteries[cmName];

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

            row.addEventListener('mouseleave', hideCmTooltip);

            row.addEventListener('click', function () {
                delete selectedMasteries[cmName];
                hideCmTooltip();
                updateSummary();
            });
        });
    }

    renderRows(masterList, masterNames, 'None selected.');
    renderRows(list, purchasedNames, 'None purchased.');
}

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

    if (!panel) return;

    panel.innerHTML =
        '<span class="cm-master-class-text">' +
        (selectedTitle ? escapeHtml(selectedTitle) : '-') +
        '</span>';
}

        function updateUnlockedSummary() {
            var summary = document.getElementById('cm-unlocked-summary');
            var totals = getBuildTotals();
            var html = '<div class="cm-unlocked-grid cm-unlocked-grid-three">';

            html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Stats</div>';
            if (!Object.keys(totals.stats).length) {
                html += '<div>No stat changes yet.</div>';
            } else {
                Object.keys(totals.stats).sort().forEach(function (statName) {
                    var value = totals.stats[statName];
                    html += '<div class="cm-purchased-item"><span>' +
                        escapeHtml(formatStatName(statName)) +
                        '</span><span class="cm-purchased-cost">' +
                        (value > 0 ? '+' : '') +
                        escapeHtml(value) +
                        '</span></div>';
                });
            }
            html += '</div>';

            html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Spells Gained</div>';
            Object.keys(totals.spellsAdded).sort().forEach(function (spell) {
                html += '<div class="cm-purchased-item">+ ' + escapeHtml(spell) + '</div>';
            });
            if (!Object.keys(totals.spellsAdded).length) html += '<div>No spells gained yet.</div>';
            html += '</div>';

            html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Spells Removed</div>';
            Object.keys(totals.spellsRemoved).sort().forEach(function (spell) {
                html += '<div class="cm-purchased-item">- ' + escapeHtml(spell) + '</div>';
            });
            if (!Object.keys(totals.spellsRemoved).length) html += '<div>No spells removed yet.</div>';
            html += '</div>';

            html += '</div>';

            summary.innerHTML = html;
        }

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

            if (remaining < 0) remaining = 0;

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

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

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

        function buildTooltipHtml(cm) {
            var html = '';

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

            Object.keys(cm.stats || {}).forEach(function (key) {
                var value = cm.stats[key];
                var sign = value > 0 ? '+' : '';

                html += '<div class="cm-tooltip-stat">' +
                    escapeHtml(formatStatName(key)) +
                    ': ' +
                    sign +
                    escapeHtml(value) +
                    '</div>';
            });

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

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

            return html;
        }

        function showCmTooltip(cm, card) {
            var rect;
            var tooltipWidth = 340;
            var leftPos;

            if (!cmTooltip || !cm) return;

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

            rect = card.getBoundingClientRect();
            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');
            var selectedRace = raceSelect.value;
            var selectedRaceCode = getRaceCode(selectedRace);
            var selectedClass = classSelect.value;
            var selectedPathNormalized = normalizePath(getSelectedTransformationPath());
            var selectedIsWere = isWerePath(selectedPathNormalized);
            var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : '';
            var seen = {};
            var availableMasteries = [];
            var lockedMasteries = [];

            if (!masteryPanel) return;

            visibleMasteries = [];

            safeArray(data.masteries).forEach(function (cm) {
                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 lockReasons;

                raceOk = listAllowsValue(cm.races, selectedRaceCode);
                classOk = listAllowsValue(cm.classes, selectedClass);
                tabOk = String(getCmDisplayTab(cm)).toLowerCase() === String(activeCmTab).toLowerCase();
                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] ||
                    seen[cmName]
                ) {
                    return;
                }

                seen[cmName] = true;

                lockReasons = getCmLockReasons(cm, false).concat(getClassPathLockReasons(cm));

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

            visibleMasteries = showLockedCms ? availableMasteries.concat(lockedMasteries) : availableMasteries;

            if (!visibleMasteries.length) {
                masteryPanel.innerHTML =
                    'No Class Masteries found for this selection.<br>' +
                    '<small>Loaded masteries: ' + safeArray(data.masteries).length + '</small>';
                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 reqHtml = getRequirementsHtml(cm);
var statReqHtml = getStatReqHtml(cm);
var gainLossHtml = getCmGainLossHtml(cm);
var lockHtml = '';
var metaHtml;

                if (entry.locked) {
                    lockHtml =
                        '<div class="cm-unlock-hint">' +
                            '<strong>Locked:</strong><br>' +
                            escapeHtml(entry.unlockHint).replace(/\|/g, '<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-title">' + escapeHtml(cm.name) + '</div>' +
                        '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
metaHtml +
gainLossHtml +
(statReqHtml ? '<div class="cm-card-req">Stat Req: ' + statReqHtml + '</div>' : '') +
lockHtml +
                    '</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 +
gainLossHtml +
(reqHtml ? '<div class="cm-card-req">Requires: ' + reqHtml + '</div>' : '') +
                    (statReqHtml ? '<div class="cm-card-req">Stat Req: ' + statReqHtml + '</div>' : '') +
                    lockHtml +
                '</div>';
            }).join('');

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

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

                card.addEventListener('mouseleave', hideCmTooltip); */

                card.addEventListener('click', function () {
                    var reasons;
                    var selectedTitle;

                    if (!cm || !entry) return;

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

                    reasons = getCmLockReasons(cm, true);

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

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

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


var classPathReasons = getClassPathLockReasons(cm);

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


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




        

        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 build;

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

            try {
                build = JSON.parse(decodeURIComponent(hash.replace('#cm-build=', '')));
            } 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 = findCmByNameOrId(name);
                if (cm) selectedMasteries[cm.name] = cm;
            });

            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 (showLockedInput) {
            showLockedInput.addEventListener('change', function () {
                showLockedCms = showLockedInput.checked;
                renderMasteryList();
            });
        }

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

        if (isEmbedded()) {
            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();
            });
        });

        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 resetClassMasterySelections() {
    selectedMasteries = {};
    sharedCmBuildApplied = false;
    activeCmTab = 'General';
    activeCmView = 'detailed';
    showLockedCms = true;
    levelInput.value = 60;
    setBaseStatInputs({});

    if (pathSelect) pathSelect.value = '';
    if (searchInput) searchInput.value = '';
    if (showLockedInput) showLockedInput.checked = true;

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

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

    var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
    var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]');

    if (generalTab) generalTab.classList.add('cm-tab-active');
    if (detailedView) detailedView.classList.add('cm-view-active');

    hideCmTooltip();
    updateSummary();
}

window.EQOA_RESET_CLASS_MASTERY = resetClassMasterySelections;

if (resetButton) {
    resetButton.addEventListener('click', resetClassMasterySelections);
}

        loadSavedCmTheme();

        window.addEventListener('eqoa-wiki-theme-change', function (event) {
            if (event.detail && event.detail.theme) {
                applyCmTheme(event.detail.theme);
            }
        });

        loadRaces();

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