MediaWiki:ClassMasteryCalculator.js: Difference between revisions

From EQOA Sandstorm Wiki
Jump to navigationJump to search
No edit summary
No edit summary
 
(46 intermediate revisions by 2 users not shown)
Line 1: Line 1:
mw.hook('wikipage.content').add(function () {
mw.hook('wikipage.content').add(function () {
     function waitForCMData(attemptsLeft) {
    var cmCalculatorStarted = false;
         if (
 
    window.EQOA_CM_DATA &&
     function cmDataIsReady() {
    window.EQOA_CM_DATA.masteries &&
         return !!window.EQOA_CM_DATA &&
) {
            (
    initClassMasteryCalculator();
                (
    return;
                    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 (attemptsLeft <= 0) {
            var app = document.getElementById('cm-calculator-app');
             if (app) {
             if (app) {
                 app.innerHTML = '<div style="color:red;">EQOA_CM_DATA masteries failed to load. Check MediaWiki:ClassMasteryRawData.js.</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 29: 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 showLockedCms = true;
var activeCmView = 'detailed';
        var visibleMasteries = [];
var showLockedCms = true;
        var isEmbeddedInCharacterBuilder = !!window.EQOA_CHARACTER_BUILDER_STATE;
var visibleMasteries = [];
 
        var BASE_STAT_REQ_FIELDS = {
            STR: 'Base STR Req',
            STA: 'Base STA Req',
            DEX: 'Base DEX Req',
            AGI: 'Base AGI Req',
            INT: 'Base INT Req',
            WIS: 'Base WIS Req',
            CHA: 'Base CHA Req'
        };


         function safeArray(value) {
         function safeArray(value) {
Line 64: Line 70:
         }
         }


function buildMasteriesFromRaw(rawText) {
        function splitList(value) {
    var lines = String(rawText || '').split(/\r?\n/);
            value = String(value || '').trim();
    var headerIndex = -1;
            if (!value || value.toLowerCase() === 'all') return ['All'];
    var headers = [];
            return value.split(/\s+/);
    var rows = [];
        }


    function splitList(value) {
        function buildMasteriesFromRaw(rawText) {
        value = String(value || '').trim();
            var lines = String(rawText || '').split(/\r?\n/);
        if (!value || value === 'All') return ['All'];
            var headerLineIndex = -1;
        return value.split(/\s+/);
            var headers = [];
    }
            var headerMap = {};
            var rows = [];


    function get(cols, name) {
            function get(cols, name) {
        var index = headers.indexOf(name);
                var indexes = headerMap[name] || [];
        return index === -1 ? '' : String(cols[index] || '').trim();
                if (!indexes.length) return '';
    }
                return String(cols[indexes[0]] || '').trim();
            }


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


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


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


            value = String(cols[index] || '').trim();
                    if (values.indexOf(value) === -1) {
                        values.push(value);
                    }
                });


            if (
                 return values;
                value &&
                !/^[0-9A-F]{4}$/i.test(value) &&
                values.indexOf(value) === -1
            ) {
                 values.push(value);
             }
             }
        });


        return values;
    }


    function addNumberStat(stats, cols, header, key) {
function getAllHeadersContaining(cols, text) {
        var value = parseInt(get(cols, header), 10);
    var values = [];
        if (!isNaN(value) && value !== 0) {
    text = String(text || '').toLowerCase();
            stats[key] = value;
        }
    }


     lines.forEach(function (line, index) {
     headers.forEach(function (header, index) {
         if (headerIndex === -1 && line.indexOf('Mastery\tDescription') === 0) {
         var value;
            headerIndex = index;
        }
    });


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


    headers = lines[headerIndex].split('\t').map(function (x) {
        value = String(cols[index] || '').trim();
        return String(x || '').trim();
    });


    lines.slice(headerIndex + 1).forEach(function (line) {
        if (!value) return;
        var cols;
         if (/^[0-9A-F]{4}$/i.test(value)) return;
         var name;
        var stats = {};


         if (!line.trim()) return;
         if (values.indexOf(value) === -1) {
 
             values.push(value);
        cols = line.split('\t');
         }
        name = get(cols, 'Mastery');
 
        if (!name) return;
 
        addNumberStat(stats, cols, 'AC', 'ac');
        addNumberStat(stats, cols, 'STR', 'str');
        addNumberStat(stats, cols, 'STA', 'sta');
        addNumberStat(stats, cols, 'AGI', 'agi');
        addNumberStat(stats, cols, 'DEX', 'dex');
        addNumberStat(stats, cols, 'WIS', 'wis');
        addNumberStat(stats, cols, 'INT', 'int');
        addNumberStat(stats, cols, 'CHA', 'cha');
        addNumberStat(stats, cols, 'HP Max', 'hp');
        addNumberStat(stats, cols, 'PWR Max', 'pow');
 
        rows.push({
            name: name,
            description: get(cols, 'Description'),
            classification: get(cols, 'Classification'),
            subCategory: get(cols, 'Sub-Category'),
            races: splitList(get(cols, 'Race')),
            classes: splitList(get(cols, 'Class')),
            lycanthropy: get(cols, 'Lycanthropy'),
            level: parseInt(get(cols, 'Level'), 10) || 0,
            points: parseInt(get(cols, 'Points'), 10) || 0,
            cmSpentReq: parseInt(get(cols, 'CM Spent Req'), 10) || 0,
            requirements: getAll(cols, 'CM Req 1')
                .concat(getAll(cols, 'CM Req 2'))
                .concat(getAll(cols, 'CM Req 3'))
                .concat(getAll(cols, 'CM Req 4')),
             spellsAdded: 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: 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
         });
     });
     });


     return rows;
     return values;
}
}




        function getCharacterBuilderState() {
            if (
                window.EQOA_CHARACTER_BUILDER_STATE &&
                typeof window.EQOA_CHARACTER_BUILDER_STATE.getState === 'function'
            ) {
                return window.EQOA_CHARACTER_BUILDER_STATE.getState();
            }
            return null;
        }
        function addSelectedCmBaseStatGains(baseStats) {
            var result = {
                STR: numberOrZero(baseStats.STR),
                STA: numberOrZero(baseStats.STA),
                DEX: numberOrZero(baseStats.DEX),
                AGI: numberOrZero(baseStats.AGI),
                INT: numberOrZero(baseStats.INT),
                WIS: numberOrZero(baseStats.WIS),
                CHA: numberOrZero(baseStats.CHA)
            };
            Object.keys(selectedMasteries).forEach(function (name) {
                var cm = selectedMasteries[name];
                var stats = cm && cm.stats ? cm.stats : {};
                result.STR += numberOrZero(stats.str);
                result.STA += numberOrZero(stats.sta);
                result.DEX += numberOrZero(stats.dex);
                result.AGI += numberOrZero(stats.agi);
                result.INT += numberOrZero(stats.int);
                result.WIS += numberOrZero(stats.wis);
                result.CHA += numberOrZero(stats.cha);
            });
            return result;
        }
        function updateBaseStatInputsDisplay(baseStats) {
            var map = {
                STR: 'cm-base-str',
                STA: 'cm-base-sta',
                DEX: 'cm-base-dex',
                AGI: 'cm-base-agi',
                INT: 'cm-base-int',
                WIS: 'cm-base-wis',
                CHA: 'cm-base-cha'
            };
            Object.keys(map).forEach(function (stat) {
                var input = document.getElementById(map[stat]);


                 if (input) {
            function addStat(stats, cols, header, key) {
                     input.value = numberOrZero(baseStats[stat]);
                var value = parseInt(get(cols, header), 10);
                 if (!isNaN(value) && value !== 0) {
                     stats[key] = value;
                 }
                 }
            });
        }
        function getBaseStatsFromInputs() {
            var builderState = getCharacterBuilderState();
            var baseStats;
            if (builderState && builderState.baseStats) {
                baseStats = builderState.baseStats;
            } else {
                baseStats = {
                    STR: numberOrZero(document.getElementById('cm-base-str') && document.getElementById('cm-base-str').value),
                    STA: numberOrZero(document.getElementById('cm-base-sta') && document.getElementById('cm-base-sta').value),
                    DEX: numberOrZero(document.getElementById('cm-base-dex') && document.getElementById('cm-base-dex').value),
                    AGI: numberOrZero(document.getElementById('cm-base-agi') && document.getElementById('cm-base-agi').value),
                    INT: numberOrZero(document.getElementById('cm-base-int') && document.getElementById('cm-base-int').value),
                    WIS: numberOrZero(document.getElementById('cm-base-wis') && document.getElementById('cm-base-wis').value),
                    CHA: numberOrZero(document.getElementById('cm-base-cha') && document.getElementById('cm-base-cha').value)
                };
             }
             }


             return addSelectedCmBaseStatGains(baseStats);
             function addStatAny(stats, cols, headers, key) {
        }
                var found = false;


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


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


                if (!match) return null;
                    value = parseInt(get(cols, header), 10);


                token = match[1];
                    if (!isNaN(value) && value !== 0) {
 
                        stats[key] = value;
                if (token.indexOf('-') !== -1) {
                         found = true;
                    foundClass = token.split('-')[0];
                     }
                    path = token.split('-').slice(1).join('-');
                 });
                } else {
                    classCodes.some(function (classCode) {
                         if (token.toUpperCase().indexOf(classCode) === 0) {
                            foundClass = classCode;
                            path = token.slice(classCode.length);
                            return true;
                        }
 
                        return false;
                     });
                 }
 
                if (!foundClass || !path) return null;
 
                return {
                    classCode: foundClass.toUpperCase(),
                    path: path,
                    quest: match[0]
                };
             }
             }


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


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


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


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


Line 327: Line 179:
                 var cols;
                 var cols;
                 var name;
                 var name;
                var quest;
var stats = {};
                var exemplarPath;
var baseStatReqs = {};
                var reqs = {};
var cmId = '';


                 if (!line.trim()) return;
                 if (!line.trim()) return;


                cols = line.split('\t');
cols = line.split('\t');
                name = cols[headerIndex.Mastery];
name = get(cols, 'Mastery');
                quest = headerIndex.Quest === undefined ? '' : cols[headerIndex.Quest];
 
cmId =
    get(cols, 'CmID') ||
    get(cols, 'CMID') ||
    get(cols, 'CM ID') ||
    get(cols, 'Cm ID');


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


                Object.keys(BASE_STAT_REQ_FIELDS).forEach(function (stat) {
if (!name) return;
                    var field = BASE_STAT_REQ_FIELDS[stat];
 
                    var columnIndex = headerIndex[field];
addStat(stats, cols, 'AC', 'ac');
                    var value;
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');


                    if (columnIndex === undefined) return;
/* Regen / resource ticks */
addStat(stats, cols, 'HoT', 'hot');
addStat(stats, cols, 'HOT', 'hot');
addStat(stats, cols, 'PoT', 'pot');
addStat(stats, cols, 'POT', 'pot');


                    value = numberOrZero(cols[columnIndex]);
/* Modifiers */
addStat(stats, cols, 'HP Factor', 'hpFactor');
addStat(stats, cols, 'OFF Mod', 'offMod');
addStat(stats, cols, 'DEF Mod', 'defMod');
addStat(stats, cols, 'Speed', 'speed');


                     if (value > 0) {
/* Stat cap increases */
                         reqs[stat] = value;
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;
                     }
                     }
                 });
                 });


                exemplarPath = getExemplarPathFromQuest(quest);
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'),


                rawRows.push({
                    quest: get(cols, 'Quest'),
                     name: name,
                    level: parseInt(get(cols, 'Level'), 10) || 0,
                     reqs: reqs,
                    points: parseInt(get(cols, 'Points'), 10) || 0,
                     quest: quest,
 
                     exemplarPath: exemplarPath
                    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
                 });
                 });
            });


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


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


            safeArray(data.masteries).forEach(function (cm, index) {
function buildCmIdsByNameFromRaw(rawText) {
                var row;
    var idsByName = {};
                var namedReqs;
    var lines = String(rawText || '').split(/\r?\n/);


                if (!cm || !cm.name) return;
    lines.forEach(function (line) {
        var cols;
        var id;
        var name;


                row = rawRows[index];
        line = String(line || '').trim();


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


                    if (Object.keys(row.reqs).length) {
        if (line.indexOf('\t') !== -1) {
                        cm.baseStatReqs = row.reqs;
            cols = line.split('\t');
                    }
        } else if (line.indexOf('\\t') !== -1) {
            cols = line.split('\\t');
        } else {
            cols = line.split(/\s{2,}/);
        }


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


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


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


if (
     return idsByName;
     (!data.masteries || !data.masteries.length) &&
    window.EQOA_CM_RAW
) {
    data.masteries = buildMasteriesFromRaw(window.EQOA_CM_RAW);
}
}
        applyRawBaseStatRequirements();


        app.innerHTML =
if (window.EQOA_CM_RAW) {
            '<div class="cm-calculator-box">' +
    var rawMasteriesWithIds = buildMasteriesFromRaw(window.EQOA_CM_RAW);
                '<div class="character-builder-title" style="display:none;">EQOA: Sandstorm Class Mastery Builder</div>' +
    var cmIdsByName = buildCmIdsByNameFromRaw(window.EQOA_CM_IDS_RAW || window.EQOA_CM_RAW);
    var rawMasteriesByName = {};


                '<div class="character-builder-controls">' +
    rawMasteriesWithIds.forEach(function (cm) {
                    '<label>Race</label>' +
        if (cm.name && cmIdsByName[cm.name]) {
                    '<select id="cm-race"></select>' +
            cm.id = cmIdsByName[cm.name];
        }


                    '<label>Class</label>' +
        if (cm.name) {
                    '<select id="cm-class"></select>' +
            rawMasteriesByName[cm.name] = cm;
        }
    });


                    '<label>Character Level</label>' +
    if (!data.masteries || !data.masteries.length) {
                    '<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +
        data.masteries = rawMasteriesWithIds;
    } else {
        data.masteries.forEach(function (cm) {
            var rawCm;


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


                    '<label>Transformation Path</label>' +
            rawCm = cm.name ? rawMasteriesByName[cm.name] : null;
                    '<select id="cm-path">' +
                        '<option value="">None</option>' +
                        '<option value="Werelion">Lionwere</option>' +
                        '<option value="Werebear">Bearwere</option>' +
                        '<option value="Werewolf">Wolfwere</option>' +
                        '<option value="Wererat">Ratwere</option>' +
                        '<option value="Weregator">Gatorwere</option>' +
                        '<option value="Vampire">Vampire</option>' +
                        '<option value="Were Hunter">Were-Hunter</option>' +
                    '</select>' +


                    '<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
            if (rawCm && rawCm.stats) {
                '</div>' +
                cm.stats = cm.stats || {};


                 '<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' +
                 Object.keys(rawCm.stats).forEach(function (statKey) {
                    cm.stats[statKey] = rawCm.stats[statKey];
                });
            }
        });
    }


                '<table class="character-builder-stat-table">' +
    if (window.EQOA_CM_DATA) {
                    '<tr>' +
        window.EQOA_CM_DATA.masteries = data.masteries;
                        '<th>Available</th>' +
    }
                        '<th>Spent</th>' +
}
                        '<th>Remaining</th>' +
                    '</tr>' +
                    '<tr>' +
                        '<td id="cm-available">1500</td>' +
                        '<td id="cm-spent">0</td>' +
                        '<td id="cm-remaining">1500</td>' +
                    '</tr>' +
                '</table>' +


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


                 '<div id="cm-master-class-panel" class="cm-master-class-panel">' +
        function getBuilderState() {
                    '<span class="cm-master-class-label">None selected</span>' +
            if (
                 '</div>' +
                window.EQOA_CHARACTER_BUILDER_STATE &&
                 typeof window.EQOA_CHARACTER_BUILDER_STATE.getState === 'function'
            ) {
                 return window.EQOA_CHARACTER_BUILDER_STATE.getState();
            }


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


                '<div id="cm-build-card-panel" class="cm-build-card-panel" style="display:none;"></div>' +
function getSelectedTransformationPath() {
    var builderState = getBuilderState();


                '<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +
    if (builderState && builderState.transformationPath !== undefined) {
        return builderState.transformationPath;
    }


                '<div id="cm-unlocked-summary">' +
    return pathSelect ? pathSelect.value : '';
                    'No stats or spells gained yet.' +
}
                '</div>' +


                '<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' +
        function isEmbedded() {
            return !!getBuilderState();
        }


                '<div class="cm-tabs">' +
        function getRaceCode(raceName) {
                    '<button type="button" class="cm-tab-button cm-tab-active" data-tab="General">General</button>' +
            var raceCodes = data.raceCodes || {};
                    '<button type="button" class="cm-tab-button" data-tab="Racial">Racial</button>' +
            return raceCodes[raceName] || raceName;
                    '<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">' +
        function listAllowsValue(list, value) {
                    '<div class="cm-search-row">' +
            var wanted = String(value || '').toUpperCase();
                        '<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' +
                        '<button type="button" id="cm-clear-search" class="cm-clear-search-button" title="Clear search">Clear</button>' +
                    '</div>' +


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


                 '<div class="cm-view-toggle">' +
            return safeArray(list).some(function (entry) {
                    '<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
                 var text = String(entry || '').toUpperCase().trim();
                    '<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
                 var tokens;
                 '</div>' +


                 '<div class="cm-main-layout">' +
                 if (!text || text === 'ALL') return true;
                    '<div class="cm-purchased-panel">' +
                        '<div class="cm-panel-title">CM Purchased</div>' +
                        '<div id="cm-purchased-list">None purchased.</div>' +
                    '</div>' +


                    '<div id="cm-mastery-panel" class="character-builder-warning-box">' +
                tokens = text.split(/\s+/);
                        'Select a race and class to begin.' +
                return tokens.indexOf(wanted) !== -1;
                    '</div>' +
            });
                '</div>' +
        }


                '<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
        function normalizePath(value) {
            '</div>';
            value = String(value || '').toLowerCase().replace(/[\s-]/g, '');


        var raceSelect = document.getElementById('cm-race');
            if (!value || value === 'all' || value === 'none') return '';
        var classSelect = document.getElementById('cm-class');
        var levelInput = document.getElementById('cm-level');
        var pathSelect = document.getElementById('cm-path');
        var resetButton = document.getElementById('cm-reset');
        var searchInput = document.getElementById('cm-search');
        var clearSearchButton = document.getElementById('cm-clear-search');
        var showLockedInput = document.getElementById('cm-show-locked');
        var viewButtons = document.querySelectorAll('.cm-view-button');
        var cmTooltip = document.getElementById('cm-tooltip');
        var shareUrlButton = document.getElementById('cm-share-url');
        var exportCardButton = document.getElementById('cm-export-card');
function applyCmTheme(theme) {
    if (!app) 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'
            };


    app.classList.remove('cm-theme-dark');
            return map[value] || value;
    app.classList.remove('cm-theme-light');
        }


    if (theme === 'light') {
        function isWerePath(path) {
        app.classList.add('cm-theme-light');
            return ['werelion', 'werebear', 'werewolf', 'wererat', 'weregator'].indexOf(path) !== -1;
    } else {
        }
        app.classList.add('cm-theme-dark');
    }
}


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


    try {
            if (name.indexOf('lion bite') !== -1) return 'werelion';
        var saved = localStorage.getItem('eqoa-wiki-theme');
            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';


        if (saved === 'light') {
             return '';
             isLight = true;
        } else if (saved === 'dark') {
            isLight = false;
         }
         }
    } catch (e) {}


    applyCmTheme(isLight ? 'light' : 'dark');
        function isSharedWereMastery(name) {
}
            return name === 'Lycanthropic Resistances' ||
 
                name === 'Lycanthropic Regeneration' ||
        if (isEmbeddedInCharacterBuilder) {
                name === 'Lycanthropic Rejuvenation' ||
            if (raceSelect) raceSelect.disabled = true;
                name === 'Combat Senses' ||
            if (classSelect) classSelect.disabled = true;
                name === 'Defensive Instincts';
            if (levelInput) levelInput.disabled = true;
         }
         }


Line 585: Line 482:
                 int: 'INT',
                 int: 'INT',
                 cha: 'CHA',
                 cha: 'CHA',
                STR: 'STR',
 
                STA: 'STA',
pr: 'PR',
                AGI: 'AGI',
dr: 'DR',
                DEX: 'DEX',
fr: 'FR',
                WIS: 'WIS',
cr: 'CR',
                INT: 'INT',
lr: 'LR',
                CHA: 'CHA',
ar: 'AR',
hot: 'HoT',
pot: 'PoT',
hpFactor: 'HP Factor',
offMod: 'OFF Mod',
defMod: 'DEF Mod',
speed: 'Speed',
 
                 strMax: 'STR Max',
                 strMax: 'STR Max',
                 staMax: 'STA Max',
                 staMax: 'STA Max',
Line 598: Line 502:
                 wisMax: 'WIS Max',
                 wisMax: 'WIS Max',
                 intMax: 'INT Max',
                 intMax: 'INT Max',
                 chaMax: 'CHA Max',
                 chaMax: 'CHA Max'
                offMod: 'Offense Mod',
                defMod: 'Defense Mod',
                hasteMod: 'Haste Mod',
                regenMod: 'Regen Mod',
                powerRegenMod: 'Power Regen Mod',
                healMod: 'Healing Mod',
                spellMod: 'Spell Mod',
                meleeMod: 'Melee Mod',
                fireResist: 'Fire Resist',
                coldResist: 'Cold Resist',
                diseaseResist: 'Disease Resist',
                poisonResist: 'Poison Resist',
                magicResist: 'Magic Resist',
                fireResistMax: 'Fire Resist Max',
                coldResistMax: 'Cold Resist Max',
                diseaseResistMax: 'Disease Resist Max',
                poisonResistMax: 'Poison Resist Max',
                magicResistMax: 'Magic Resist Max'
             };
             };


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


Line 637: Line 515:
         }
         }


function getRaceCodeForCm(raceName) {
        function getCmDisplayTab(cm) {
    var raceCodes = data.raceCodes || {};
            var classification = String((cm && cm.classification) || '');


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


    var fallback = {
            return classification || 'General';
        Human: 'HUM',
         }
        Elf: 'ELF',
        'Dark Elf': 'DELF',
        Dwarf: 'DWF',
        Gnome: 'GNO',
         Halfling: 'HLF',
        Barbarian: 'BAR',
        Erudite: 'ERU',
        Ogre: 'OGR',


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


    return fallback[raceName] || raceName;
        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;


function listAllowsValue(list, value) {
            if (builderState && builderState.baseStats) {
    var wanted = String(value || '').toUpperCase();
                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)
                };
            }


    if (!wanted) return true;
            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)
            };


    return safeArray(list).some(function (entry) {
            Object.keys(selectedMasteries).forEach(function (name) {
        var text = String(entry || '').toUpperCase().trim();
                var stats = selectedMasteries[name].stats || {};
        var tokens;
                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);
            });


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


         tokens = text.split(/\s+/);
         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'
            };


        return tokens.indexOf(wanted) !== -1;
            Object.keys(map).forEach(function (stat) {
    });
                var input = document.getElementById(map[stat]);
}
                if (input) input.value = numberOrZero(baseStats[stat]);
            });
        }


function normalizeClassification(value) {
        function getAvailablePoints() {
    return String(value || '').trim().toLowerCase();
            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;


        function normalizePath(value) {
            levelInput.value = level;
    value = String(value || '').toLowerCase().replace(/[\s-]/g, '');
            max = Math.min(level * 25, 1500);


    if (
            if (builderState && builderState.cmAvailable !== undefined) {
        value === '' ||
                available = numberOrZero(builderState.cmAvailable);
        value === 'all' ||
                if (available < 0) available = 0;
        value === 'none' ||
                if (available > max) available = max;
        value === 'null' ||
                return available;
        value === 'undefined'
            }
    ) {
        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;
             return max;
}
 
        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) {
         function getSpentPoints() {
             return name === 'Lycanthropic Resistances' ||
             var total = 0;
                name === 'Lycanthropic Regeneration' ||
                name === 'Combat Senses';
        }


        function isWerePath(path) {
            Object.keys(selectedMasteries).forEach(function (name) {
            return path === 'werelion' ||
                 total += numberOrZero(selectedMasteries[name].points);
                 path === 'werebear' ||
            });
                path === 'werewolf' ||
                path === 'wererat' ||
                path === 'weregator';
        }


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


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


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


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


Line 821: Line 651:
             Object.keys(cm.baseStatReqs).forEach(function (statName) {
             Object.keys(cm.baseStatReqs).forEach(function (statName) {
                 var value = numberOrZero(cm.baseStatReqs[statName]);
                 var value = numberOrZero(cm.baseStatReqs[statName]);
                 if (value > 0) {
                 if (value > 0) {
                     reqs.push({
                     reqs.push({ stat: statName, value: value });
                        stat: statName,
                        value: value
                    });
                 }
                 }
             });
             });
Line 833: Line 659:
         }
         }


         function getStatMinRequirementsHtml(cm) {
         function getCmLockReasons(cm, includePoints) {
             var reqs = getCmStatMinRequirements(cm);
             var spent = getSpentPoints();
            var available = getAvailablePoints();
            var baseStats = getBaseStatsFromInputs();
            var level = parseInt(levelInput.value, 10) || 1;
            var reasons = [];


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


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


        function getCmSearchText(cm) {
            getCmStatMinRequirements(cm).forEach(function (req) {
            var parts = [];
                if (numberOrZero(baseStats[req.stat]) < req.value) {
            var stats = cm && cm.stats ? cm.stats : {};
                    reasons.push('Requires base ' + req.stat + ' ' + req.value);
            var statMinReqs;
                 }
 
            if (!cm) return '';
 
            parts.push(cm.name || '');
            parts.push(cm.description || '');
            parts.push(cm.classification || '');
            parts.push(cm.lycanthropy || '');
 
            Object.keys(stats).forEach(function (statName) {
                parts.push(statName);
                 parts.push(formatStatName(statName));
                parts.push(stats[statName]);
             });
             });


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


             safeArray(cm.spellsAdded).forEach(function (spell) {
             if (includePoints && spent + numberOrZero(cm.points) > available) {
                 parts.push(spell);
                 reasons.push('Not enough CM points');
             });
             }


            safeArray(cm.spellsRemoved).forEach(function (spell) {
             return reasons;
                parts.push(spell);
            });
 
            safeArray(cm.requirements).forEach(function (req) {
                parts.push(req);
            });
 
             return parts.join(' ').toLowerCase();
         }
         }


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


         function getRequirementsHtml(cm) {
         function getStatReqHtml(cm) {
             var reqs = safeArray(cm && cm.requirements);
             return getCmStatMinRequirements(cm).map(function (req) {
 
                 return 'Base ' + escapeHtml(req.stat) + ' ' + escapeHtml(req.value);
            if (!reqs.length) return '';
 
            return reqs.map(function (reqName) {
                 return getRequirementHtml(reqName);
             }).join(', ');
             }).join(', ');
         }
         }


        function getCmChainBase(name) {
function getCmPassiveEffectText(cm) {
            return String(name || '').replace(/\s+\d+$/, '');
    var fields = [
        }
        cm.effect,
        cm.effects,
        cm.effectText,
        cm.bonus,
        cm.passive,
        cm.description
    ];
 
    var found = '';


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


            return match ? parseInt(match[1], 10) : null;
        if (!found && value && value.toLowerCase() !== 'none') {
            found = value;
         }
         }
    });


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


            if (!cm || !cm.name) return [];
function getCmGainLossHtml(cm) {
    var parts = [];


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


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


            safeArray(data.masteries).forEach(function (item) {
        className = value > 0 ? 'cm-card-gain' : 'cm-card-loss';
                if (
                    item &&
                    getCmChainBase(item.name) === base &&
                    getCmChainNumber(item.name) !== null
                ) {
                    chain.push(item.name);
                }
            });


             chain.sort(function (a, b) {
        parts.push(
                 return getCmChainNumber(a) - getCmChainNumber(b);
             '<span class="' + className + '">' +
            });
                escapeHtml(formatStatName(key)) + ' ' +
                 (value > 0 ? '+' : '') +
                escapeHtml(value) +
            '</span>'
        );
    });


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


         function getCmChainHtml(cm) {
         parts.push(
             var chain = getCmChain(cm);
            '<span class="cm-card-gain">+ ' +
                escapeHtml(spell) +
             '</span>'
        );
    });


            if (chain.length <= 1) return '';
    safeArray(cm.spellsRemoved).forEach(function (spell) {
        if (!spell) return;


            return chain.map(function (name) {
        parts.push(
                var owned = !!selectedMasteries[name];
            '<span class="cm-card-loss">- ' +
                 var current = cm && cm.name === name;
                 escapeHtml(spell) +
                var className = 'cm-chain-step';
            '</span>'
        );
    });


                if (owned) {
    if (!parts.length) {
                    className += ' cm-chain-owned';
        var passiveText = getCmPassiveEffectText(cm);
                }


                if (current) {
        if (passiveText) {
                    className += ' cm-chain-current';
            parts.push(
                 }
                 '<span class="cm-card-effect-note">' +
 
                    escapeHtml(passiveText) +
                return '<span class="' + className + '">' + escapeHtml(name) + '</span>';
                '</span>'
             }).join('<span class="cm-chain-arrow"> &gt; </span>');
             );
         }
         }
    }


        function getCompressedMasteryNames(names) {
if (cm.description) {
            var groups = {};
    parts.unshift(
             var singles = [];
        '<span class="cm-card-description-chip">' +
             escapeHtml(cm.description) +
        '</span>'
    );
}


            names.forEach(function (name) {
    if (!parts.length) return '';
                var base = getCmChainBase(name);
                var number = getCmChainNumber(name);


                if (number === null) {
    return '<div class="cm-card-effects">' + parts.join('') + '</div>';
                    singles.push(name);
}
                    return;
                }


                if (!groups[base]) {
        function getCmSearchText(cm) {
                    groups[base] = [];
            var parts = [
                }
                cm.name,
                cm.description,
                cm.classification,
                cm.lycanthropy
            ];


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


             Object.keys(groups).forEach(function (base) {
             safeArray(cm.requirements).forEach(function (value) { parts.push(value); });
                groups[base].sort(function (a, b) {
            safeArray(cm.spellsAdded).forEach(function (value) { parts.push(value); });
                    return a - b;
            safeArray(cm.spellsRemoved).forEach(function (value) { parts.push(value); });
                });


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


        function getSelectedExemplarPathForClass(classCode) {
            var selectedPath = null;


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


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


                if (!exemplarPath || selectedPath) return;


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


            return selectedPath;
        }


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


            if (!exemplarPath) return '';


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


            if (!selectedPath) return '';
    if (!wanted) return null;


            if (String(selectedPath.path) === String(exemplarPath.path)) {
    safeArray(data.masteries).some(function (cm) {
                return '';
        var cmName = String(cm.name || '').trim().toLowerCase();
            }
        var cmId = String(cm.id || cm.cmId || cm.CmID || '').trim().toLowerCase();


             return 'Exemplar path already chosen: ' + selectedPath.path;
        if (cmName === wanted || cmId === wanted) {
            found = cm;
             return true;
         }
         }


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


            options = options || {};
    return found;
}


            if (!cm) return reasons;
var masterClassTitles = [
    'Berserker',
    'Chosen',
    'Deputy',
    'Doomseeker',
    'Immortal',
    'Juggernaut',
    'Mercenary',
    'Savage',
    'Scholar',
    'Tinker',


            if (isNaN(level)) {
    'Arcane Were-Hunter',
                level = 1;
    'Divine Were-Hunter',
            }
    'Gypsy Were-Hunter',
    'Heroic Were-Hunter',
    'Metabolist',
    'Transmuter',
    'Gypsy',
    'Minstrel',
    'Exorcist',
    'Faithful',
    'Storm Caller',
    'Wilding',
    'Animator',


            if (cm.level && cm.level > level) {
    'Spellbinder',
                reasons.push('Reach level ' + cm.level);
    'Aeromancer',
            }
    'Geomancer',
    'Hydromancer',
    'Pyromancer',
    'Ninja',
    'Sensei',


            if (cm.cmSpentReq && spent < cm.cmSpentReq) {
    'Defiler',
                reasons.push('Spend ' + (cm.cmSpentReq - spent) + ' more CM');
    'Lich',
            }
    'Crusader',
    'Guardian',
    'Forester',
    'Hunter',
    'Assassin',
    'Burglar',
    'Death Knight',
    'Slayer',


            getCmStatMinRequirements(cm).forEach(function (req) {
    'Elder',
                var current = numberOrZero(baseStats[req.stat]);
    'Mystic',
    'Defender',


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


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


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


            if (exemplarLockReason) {
function isMasterClassTitle(cm) {
                reasons.push(exemplarLockReason);
    var cmName;
            }


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


            return reasons;
    cmName = normalizeMasterClassName(cm.name);
        }


        function getCmUnlockHint(cm) {
    return masterClassTitles.some(function (title) {
            return getCmLockReasons(cm).join(' | ');
        return normalizeMasterClassName(title) === cmName;
        }
    });
}


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


            if (!panel) return;


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


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


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


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


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


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


            return classification;
        }


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


            return getCmDisplayTab(cm) || 'Other';
function applySharedClassMasteriesFromBuilder(builderState) {
        }
    var names;


        function getCmTypeClass(cm) {
    if (!builderState || sharedCmBuildApplied) return;
            var type = getCmType(cm);


            if (type === 'General') return 'cm-type-general';
    names = safeArray(builderState.selectedClassMasteries);
            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';
    if (!names.length) return;
        }


        function findCmByName(name) {
    selectedMasteries = {};
            var found = null;


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


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


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


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


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


                oldText = button.textContent;
        function getSelectedMasterClassTitle() {
                button.textContent = 'Copied';
    var selectedTitle = '';


                setTimeout(function () {
    Object.keys(selectedMasteries).forEach(function (name) {
                    button.textContent = oldText;
        if (isMasterClassTitle(selectedMasteries[name])) {
                }, 1200);
            selectedTitle = name;
            }
        }
    });


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


            alert(text);
        }


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


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


                if (!cm) return;


                stats = cm.stats || {};


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


                    if (!isNaN(value) && value !== 0) {
function getCmPlayerFlags(cm) {
                        if (!statTotals[statName]) {
    var flags = [];
                            statTotals[statName] = 0;
                        }


                        statTotals[statName] += value;
    if (!cm) return flags;
                    }
                });


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


                safeArray(cm.spellsRemoved).forEach(function (spell) {
    Object.keys(cm).forEach(function (key) {
                    if (spell) spellsRemoved[spell] = true;
        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 {
                stats: statTotals,
                spellsAdded: spellsAdded,
                spellsRemoved: spellsRemoved
            };
         }
         }
    });


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


            return window.location.href.split('#')[0] +
function getRomanRank(value) {
                '#cm-build=' +
    var match = String(value || '').trim().match(/\s+(I|II|III|IV|V)$/i);
                encodeURIComponent(JSON.stringify(build));
        }


        function setBaseStatInputs(baseStats) {
    if (!match) return 0;
            baseStats = baseStats || {};


            document.getElementById('cm-base-str').value = numberOrZero(baseStats.STR);
    return {
            document.getElementById('cm-base-sta').value = numberOrZero(baseStats.STA);
        I: 1,
            document.getElementById('cm-base-dex').value = numberOrZero(baseStats.DEX);
        II: 2,
            document.getElementById('cm-base-agi').value = numberOrZero(baseStats.AGI);
        III: 3,
            document.getElementById('cm-base-int').value = numberOrZero(baseStats.INT);
        IV: 4,
            document.getElementById('cm-base-wis').value = numberOrZero(baseStats.WIS);
        V: 5
            document.getElementById('cm-base-cha').value = numberOrZero(baseStats.CHA);
    }[match[1].toUpperCase()] || 0;
        }
}


        function loadBuildFromUrl() {
function stripRankSuffix(value) {
            var hash = window.location.hash || '';
    return String(value || '')
            var raw;
        .trim()
            var build;
        .replace(/\s+(I|II|III|IV|V)$/i, '');
}


            if (hash.indexOf('#cm-build=') !== 0) return;
function normalizePathKey(value) {
 
    return stripRankSuffix(value)
            raw = hash.replace('#cm-build=', '');
        .toLowerCase()
        .replace(/[^a-z0-9]/g, '');
}


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


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


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


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


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


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


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


            safeArray(build.cms).forEach(function (name) {
    if (/^exemplar-/i.test(questText)) {
                var cm = findCmByName(name);
        return questText;
    }


                if (cm) {
    getCmPlayerFlags(cm).forEach(function (flag) {
                    selectedMasteries[cm.name] = cm;
        var text = String(flag || '').trim();
                }
            });


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


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


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


            document.head.appendChild(script);
function isClassPathCm(cm) {
        }
    return !!getExemplarFlag(cm);
}


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


                var canvas = document.createElement('canvas');
    if (flag) {
                var ctx = canvas.getContext('2d');
        return normalizePathKey(flag);
    }


                var cardWidth = 1800;
    return normalizePathKey(cm && cm.name);
                var margin = 55;
}
                var gap = 35;
                var panelW = cardWidth - margin * 2;
                var y = 390;


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


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


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


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


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


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


                    ctx.textAlign = 'left';
                }


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


                    ctx.textAlign = 'left';
    Object.keys(selectedMasteries).forEach(function (name) {
                    ctx.textBaseline = 'alphabetic';
        var cm = selectedMasteries[name];


                    ctx.fillStyle = '#f4e6c8';
        if (!selectedKey && isClassPathCm(cm)) {
                    ctx.font = 'bold 25px Georgia';
            selectedKey = getClassPathKey(cm);
                    ctx.fillText(label, x + 28, y + 38);
        }
    });


                    ctx.font = 'bold 30px Georgia';
    return selectedKey;
                    ctx.fillText(value || 'None', x + 28, y + 72);
}
                }


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


                function drawColumnList(items, x, y, width, columns, color, font, lineH) {
    Object.keys(selectedMasteries).forEach(function (name) {
                    var colGap = 42;
        var cm = selectedMasteries[name];
                    var colWidth = (width - colGap * (columns - 1)) / columns;
                    var rows = Math.ceil(Math.max(items.length, 1) / columns);


                    ctx.fillStyle = color;
        if (!selectedChoice && isClassPathCm(cm)) {
                    ctx.font = font;
            selectedChoice = stripRankSuffix(cm.name);
                    ctx.textAlign = 'left';
        }
                    ctx.textBaseline = 'alphabetic';
    });


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


                    items.forEach(function (item, index) {
function getPreviousRankName(cm) {
                        var col = Math.floor(index / rows);
    var rank = getRomanRank(cm && cm.name);
                        var row = index % rows;
    var baseName = stripRankSuffix(cm && cm.name);
                        var drawX = x + col * (colWidth + colGap);
    var previousRank;
                        var drawY = y + row * lineH;


                        ctx.fillText(item, drawX, drawY);
    if (rank <= 1) return '';
                    });


                    return y + rows * lineH;
    previousRank = {
                }
        2: 'I',
        3: 'II',
        4: 'III',
        5: 'IV'
    }[rank];


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


                var baseStatItems = Object.keys(baseStats).map(function (statName) {
function getClassPathLockReasons(cm) {
                    return statName + ': ' + baseStats[statName];
    var reasons = [];
                });
    var selectedPathKey;
    var thisPathKey;
    var previousRankName;


                var gainedItems = gainedList.map(function (spell) {
    if (!isClassPathCm(cm)) return reasons;
                    return '+ ' + spell;
                });


                var removedItems = removedList.map(function (spell) {
    selectedPathKey = getSelectedClassPathKey();
                    return '- ' + spell;
    thisPathKey = getClassPathKey(cm);
                });
    previousRankName = getPreviousRankName(cm);


                var statsHeight = measureColumnList(statsItems.concat(baseStatItems), 3, 30);
    if (selectedPathKey && thisPathKey && selectedPathKey !== thisPathKey) {
                var purchasedHeight = measureColumnList(purchasedDisplayNames, 2, 30);
        reasons.push('You already selected a different Class / Exemplar Path');
                var gainedHeight = measureColumnList(gainedItems, 2, 30);
    }
                var removedHeight = measureColumnList(removedItems, 2, 30);


                var summaryH = Math.max(490, 365 + statsHeight);
    if (previousRankName && !selectedMasteries[previousRankName]) {
                var topRowH = Math.max(summaryH, qrH);
        reasons.push('Requires ' + previousRankName + ' first');
                var purchasedH = Math.max(280, 130 + purchasedHeight);
    }
                var spellsH = Math.max(420, 250 + gainedHeight + removedHeight);


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


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


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


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


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


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


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


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


                ctx.fillStyle = '#f4e6c8';
app.innerHTML =
                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';
            '<div class="cm-calculator-box">' +
                 ctx.font = 'bold 30px Georgia';
                 '<div class="character-builder-title" style="display:none;">EQOA: Sandstorm Class Mastery Builder</div>' +
                ctx.fillText('Base Stats', margin + 45, y + 255);


                 var afterBaseY = drawColumnList(baseStatItems, margin + 45, y + 295, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
                 '<div class="character-builder-controls">' +
                    '<label>Race</label>' +
                    '<select id="cm-race"></select>' +


                ctx.fillStyle = '#d9a64d';
                    '<label>Class</label>' +
                ctx.font = 'bold 30px Georgia';
                    '<select id="cm-class"></select>' +
                ctx.fillText('Stats Gained', margin + 45, afterBaseY + 45);


                drawColumnList(statsItems, margin + 45, afterBaseY + 85, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
                    '<label>Character Level</label>' +
                    '<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +


                if (qrReady) {
                    '<label>Base Stats</label>' +
                     var qrCanvas = document.createElement('canvas');
                     '<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>' +
       


                     new QRious({
                     '<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
                        element: qrCanvas,
                '</div>' +
                        value: shareUrl,
                        size: 220,
                        background: 'white',
                        foreground: 'black',
                        level: 'M'
                    });


                    ctx.drawImage(qrCanvas, margin + summaryW + gap + 70, y + 105, 220, 220);
              '<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>' +


                y += topRowH + 35;
'<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>' +


                drawPanel(margin, y, panelW, purchasedH, 'PURCHASED CMS');
'<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +
                 drawColumnList(purchasedDisplayNames, margin + 45, y + 115, panelW - 90, 2, '#e066ff', 'bold 23px Georgia', 30);
                 '<div id="cm-unlocked-summary">No stats or spells gained yet.</div>' +


                 y += purchasedH + 35;
                 '<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>' +


                 drawPanel(margin, y, panelW, spellsH, 'SPELLS');
                 '<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>' +


                 ctx.fillStyle = '#8fd9ff';
                 '<div class="cm-view-toggle">' +
                ctx.font = 'bold 30px Georgia';
                    '<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
                 ctx.fillText('Gained', margin + 45, y + 115);
                    '<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
                 '</div>' +


                var afterGainedY = drawColumnList(gainedItems, margin + 45, y + 155, panelW - 90, 2, '#8fd9ff', 'bold 22px Georgia', 30);
              '<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>' +


                 ctx.fillStyle = '#ff8f8f';
                 '<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
                ctx.font = 'bold 30px Georgia';
            '</div>';
                ctx.fillText('Removed', margin + 45, afterGainedY + 55);


                 drawColumnList(removedItems, margin + 45, afterGainedY + 95, panelW - 90, 2, '#ff8f8f', 'bold 22px Georgia', 30);
        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');


                 ctx.fillStyle = '#d9a64d';
            try {
                 ctx.font = 'bold 30px Georgia';
                 var saved = localStorage.getItem('eqoa-wiki-theme');
                 ctx.textAlign = 'center';
                 if (saved === 'light') isLight = true;
                ctx.fillText(
                 if (saved === 'dark') isLight = false;
                    'EQOA: Sandstorm Wiki Class Mastery Calculator',
            } catch (e) {}
                    canvas.width / 2,
                    canvas.height - 55
                );


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


Line 1,596: Line 1,268:
         }
         }


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


             if (!select) return false;
             classSelect.innerHTML = '';


             Array.prototype.forEach.call(select.options, function (option) {
             classList.forEach(function (classCode) {
                 if (option.value === value) {
                 addOption(
                    found = true;
                    classSelect,
                }
                    classCode,
                    classCode + ' - ' + ((data.classes || {})[classCode] || classCode)
                );
             });
             });


             if (found) {
             updateSummary();
                select.value = value;
            }
 
            return found;
        }
 
        function findCmRaceForBuilderState(builderState) {
            var raceName = builderState && builderState.race;
            var classCode = builderState && builderState.classCode;
            var raceKeys = Object.keys(data.raceClasses || {});
            var foundRace = '';
 
            if (raceName && data.raceClasses && data.raceClasses[raceName]) {
                return raceName;
            }
 
            raceKeys.forEach(function (race) {
                if (foundRace) return;
 
                if (race === raceName) {
                    foundRace = race;
                    return;
                }
 
                if (data.raceCodes && data.raceCodes[race] === raceName) {
                    foundRace = race;
                    return;
                }
 
                if (
                    classCode &&
                    safeArray((data.raceClasses || {})[race]).indexOf(classCode) !== -1
                ) {
                    foundRace = race;
                }
            });
 
            return foundRace || raceKeys[0] || '';
         }
         }


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


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


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


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


Line 1,671: Line 1,303:
             }
             }


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


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


         window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;
         window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder;


         function loadClasses() {
         if (isEmbedded()) {
             classSelect.innerHTML = '';
             raceSelect.disabled = true;
 
             classSelect.disabled = true;
             var race = raceSelect.value;
             levelInput.disabled = true;
            var classList = safeArray((data.raceClasses || {})[race]);
 
             classList.forEach(function (classCode) {
                addOption(
                    classSelect,
                    classCode,
                    classCode + ' - ' + ((data.classes || {})[classCode] || classCode)
                );
            });
 
            updateSummary();
         }
         }


         function getAvailablePoints() {
         function getBuildTotals() {
    var builderState = getCharacterBuilderState();
            var statTotals = {};
    var level = parseInt(levelInput.value, 10);
            var spellsAdded = {};
    var max;
            var spellsRemoved = {};
    var available;


    if (isNaN(level) || level < 1) level = 1;
            Object.keys(selectedMasteries).forEach(function (name) {
    if (level > 60) level = 60;
                var cm = selectedMasteries[name];


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


    max = level * 25;
                    if (!isNaN(value) && value !== 0) {
                        statTotals[statName] = (statTotals[statName] || 0) + value;
                    }
                });


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


    if (builderState && builderState.cmAvailable !== undefined) {
                safeArray(cm.spellsRemoved).forEach(function (spell) {
        available = numberOrZero(builderState.cmAvailable);
                    if (spell) spellsRemoved[spell] = true;
                });
            });


        if (available < 0) {
            return {
             available = 0;
                stats: statTotals,
                spellsAdded: spellsAdded,
                spellsRemoved: spellsRemoved
             };
         }
         }


        if (available > max) {
function getUnlockedStatCaps() {
            available = max;
    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 || {};


         return available;
         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 max;
     return caps;
}
}


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


        function getSpentPoints() {
    Object.keys(selectedMasteries).sort().forEach(function (name) {
            var total = 0;
        var cm = selectedMasteries[name];
 
        var type = getCmType(cm);
            Object.keys(selectedMasteries).forEach(function (name) {
                if (selectedMasteries[name]) {
                    total += selectedMasteries[name].points || 0;
                }
            });


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


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


            if (left < 0) left = 0;
function getClassMasteryExportData() {
    var totals = getBuildTotals();


            return 'Total CMs: ' + total + ' | Spent: ' + spent + ' | Left: ' + left;
    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];


        Array.prototype.forEach.call(viewButtons, function (button) {
    return cm.id || cm.cmId || cm.CmID || cm.name;
            button.addEventListener('click', function () {
}).sort(),
                activeCmView = button.getAttribute('data-view') || 'detailed';
        stats: totals.stats,
        spellsAdded: Object.keys(totals.spellsAdded).sort(),
        spellsRemoved: Object.keys(totals.spellsRemoved).sort()
    };
}


                Array.prototype.forEach.call(viewButtons, function (btn) {
function publishClassMasteryState() {
                    btn.classList.remove('cm-view-active');
    window.EQOA_CLASS_MASTERY_STATE = {
                });
        getStatCaps: getUnlockedStatCaps,
getSelectedMasteries: function () {
    return Object.keys(selectedMasteries).map(function (name) {
        var cm = selectedMasteries[name];


                button.classList.add('cm-view-active');
        return cm.id || cm.cmId || cm.CmID || cm.name;
    }).sort();
},
        getExportData: getClassMasteryExportData
    };


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


        function updatePurchasedList() {
function updatePurchasedList() {
            var list = document.getElementById('cm-purchased-list');
    var list = document.getElementById('cm-purchased-list');
            var purchasedRows = [];
    var masterList = document.getElementById('cm-master-class-purchased-list');
            var groups = {
    var names = Object.keys(selectedMasteries).sort();
                'Master Class': [],
    var masterNames = [];
                'General': [],
    var purchasedNames = [];
                'Racial': [],
                'Archetype': [],
                'Class': [],
                'Other': []
            };
            var groupOrder = [
                'Master Class',
                'General',
                'Racial',
                'Archetype',
                'Class',
                'Other'
            ];
            var html = '';


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


            Object.keys(selectedMasteries).forEach(function (name) {
    function renderRows(container, rowNames, emptyText) {
                var cm = selectedMasteries[name];
        if (!container) return;
                var groupName;


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


                if (isMasterClassTitle(cm)) {
        container.innerHTML = rowNames.map(function (name) {
                    groupName = 'Master Class';
            var cm = selectedMasteries[name];
                } else {
                    groupName = getCmDisplayTab(cm);


                    if (!groups[groupName]) {
            return '<div class="cm-purchased-item" data-cm-name="' + escapeHtml(name) + '">' +
                        groupName = 'Other';
                '<span>' + escapeHtml(name) + '</span>' +
                    }
                '<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
                }
            '</div>';
        }).join('');


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


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


             groupOrder.forEach(function (groupName) {
             row.addEventListener('mouseleave', hideCmTooltip);
                if (!groups[groupName].length) return;


                html +=
            row.addEventListener('click', function () {
                    '<div class="cm-purchased-group">' +
                 delete selectedMasteries[cmName];
                        '<div class="cm-purchased-group-title">' + escapeHtml(groupName) + '</div>';
                hideCmTooltip();
 
                updateSummary();
                 groups[groupName].forEach(function (cm) {
                    var index = purchasedRows.length;
                    purchasedRows.push(cm);
 
                    html +=
                        '<div class="cm-purchased-item" data-purchased-index="' + index + '">' +
                            '<span>' + escapeHtml(cm.name) + '</span>' +
                            '<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
                        '</div>';
                });
 
                html += '</div>';
             });
             });
        });
    }


            if (!html) {
    renderRows(masterList, masterNames, 'None selected.');
                list.innerHTML = 'None purchased.';
    renderRows(list, purchasedNames, 'None purchased.');
                return;
}
            }


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


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


                    row.addEventListener('mouseenter', function () {
    panel.innerHTML =
                        showCmTooltip(cm, row);
        '<span class="cm-master-class-text">' +
                    });
        (selectedTitle ? escapeHtml(selectedTitle) : '-') +
 
        '</span>';
                    row.addEventListener('mouseleave', hideCmTooltip);
}
 
                    row.addEventListener('click', function () {
                        if (!cm) return;
 
                        delete selectedMasteries[cm.name];
                        hideCmTooltip();
                        updateSummary();
                    });
                }
            );
        }


         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;
             var html = '';


            if (!summary) return;
             html += '<div class="cm-unlocked-box"><div class="cm-panel-title">Stats</div>';
 
             if (!Object.keys(totals.stats).length) {
            Object.keys(selectedMasteries).forEach(function (name) {
                 html += '<div>No stat changes yet.</div>';
                var cm = selectedMasteries[name];
                var stats;
 
                if (!cm) return;
 
                stats = cm.stats || {};
 
                Object.keys(stats).forEach(function (statName) {
                    var value = parseInt(stats[statName], 10);
 
                    if (!isNaN(value) && value !== 0) {
                        if (!statTotals[statName]) {
                            statTotals[statName] = 0;
                        }
 
                        statTotals[statName] += value;
                        hasStats = true;
                    }
                });
 
                safeArray(cm.spellsAdded).forEach(function (spell) {
                    if (spell) {
                        spellsAdded[spell] = true;
                    }
                });
 
                safeArray(cm.spellsRemoved).forEach(function (spell) {
                    if (spell) {
                        spellsRemoved[spell] = true;
                    }
                });
            });
 
            html += '<div class="cm-unlocked-grid cm-unlocked-grid-three">';
 
             html += '<div class="cm-unlocked-box">';
            html += '<div class="cm-panel-title">Stats</div>';
 
             if (!hasStats) {
                 html += '<div class="cm-empty-line">No stat changes yet.</div>';
             } else {
             } 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 class="cm-purchased-cost">' + sign + escapeHtml(value) + '</span>' +
                        '</span></div>';
                        '</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,974: Line 1,548:
             var remaining = available - spent;
             var remaining = available - spent;


             if (isEmbeddedInCharacterBuilder) {
             if (remaining < 0) remaining = 0;
 
            if (isEmbedded()) {
                 updateBaseStatInputsDisplay(getBaseStatsFromInputs());
                 updateBaseStatInputsDisplay(getBaseStatsFromInputs());
             }
             }
Line 1,983: Line 1,559:


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


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


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


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


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


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


         function showCmTooltip(cm, card) {
         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 2,073: Line 1,621:


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


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


             visibleMasteries = [];
             visibleMasteries = [];


             safeArray(data.masteries).forEach(function (cm) {
             safeArray(data.masteries).forEach(function (cm) {
                var cmRaces = safeArray(cm.races);
                var cmClasses = safeArray(cm.classes);
                 var cmName = String(cm.name || '');
                 var cmName = String(cm.name || '');
                 var cmPath = normalizePath(cm.lycanthropy);
                 var cmPath = normalizePath(cm.lycanthropy);
Line 2,107: Line 1,649:
                 var tabOk;
                 var tabOk;
                 var searchOk;
                 var searchOk;
                 var unlockHint;
                 var lockReasons;
 
                raceOk = listAllowsValue(cmRaces, selectedRaceCode);


classOk = listAllowsValue(cmClasses, selectedClass);
                raceOk = listAllowsValue(cm.races, selectedRaceCode);
 
                classOk = listAllowsValue(cm.classes, selectedClass);
tabOk =
                tabOk = String(getCmDisplayTab(cm)).toLowerCase() === String(activeCmTab).toLowerCase();
    normalizeClassification(getCmDisplayTab(cm)) ===
                 searchOk = !searchQuery || getCmSearchText(cm).indexOf(searchQuery) !== -1;
    normalizeClassification(activeCmTab);
 
                 searchOk =
                    !searchQuery ||
                    getCmSearchText(cm).indexOf(searchQuery) !== -1;


                 if (bitePath) {
                 if (bitePath) {
Line 2,142: Line 1,677:
                     !searchOk ||
                     !searchOk ||
                     selectedMasteries[cmName] ||
                     selectedMasteries[cmName] ||
                     seenMasteries[cmName]
                     seen[cmName]
                 ) {
                 ) {
                     return;
                     return;
                 }
                 }


                 seenMasteries[cmName] = true;
                 seen[cmName] = true;


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


                 if (unlockHint) {
                 if (lockReasons.length) {
                     lockedMasteries.push({
                     lockedMasteries.push({
                         cm: cm,
                         cm: cm,
                         locked: true,
                         locked: true,
                         unlockHint: unlockHint
                         unlockHint: lockReasons.join(' | ')
                     });
                     });
                 } else {
                 } else {
Line 2,166: Line 1,701:
             });
             });


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


             if (!visibleMasteries.length) {
             if (!visibleMasteries.length) {
                 masteryPanel.innerHTML = 'No Class Masteries found for this selection.';
                 masteryPanel.innerHTML =
                    'No Class Masteries found for this selection.<br>' +
                    '<small>Loaded masteries: ' + safeArray(data.masteries).length + '</small>';
                 return;
                 return;
             }
             }
Line 2,182: Line 1,715:
                 var typeClass = getCmTypeClass(cm);
                 var typeClass = getCmTypeClass(cm);
                 var cmType = getCmType(cm);
                 var cmType = getCmType(cm);
                var requirementsHtml = getRequirementsHtml(cm);
var reqHtml = getRequirementsHtml(cm);
                var statMinHtml = getStatMinRequirementsHtml(cm);
var statReqHtml = getStatReqHtml(cm);
                var chainHtml = getCmChainHtml(cm);
var gainLossHtml = getCmGainLossHtml(cm);
                var lockReasonsHtml = '';
var lockHtml = '';
                var metaHtml;
var metaHtml;


                 if (entry.locked) {
                 if (entry.locked) {
                     lockReasonsHtml =
                     lockHtml =
                         '<div class="cm-unlock-hint">' +
                         '<div class="cm-unlock-hint">' +
                             '<strong>Locked:</strong><br>' +
                             '<strong>Locked:</strong><br>' +
                             getCmLockReasons(cm).map(function (reason) {
                             escapeHtml(entry.unlockHint).replace(/\|/g, '<br>') +
                                return escapeHtml(reason);
                            }).join('<br>') +
                         '</div>';
                         '</div>';
                 }
                 }
Line 2,204: Line 1,735:
                         (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
                         (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
                     '</div>' +
                     '</div>' +
                     '<div class="cm-card-meta">' +
                     '<div class="cm-card-meta">' + escapeHtml(getCmPointStatusText()) + '</div>';
                        escapeHtml(getCmPointStatusText()) +
                    '</div>';


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


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


             Array.prototype.forEach.call(
             Array.prototype.forEach.call(masteryPanel.querySelectorAll('.cm-purchasable-card'), function (card) {
                masteryPanel.querySelectorAll('.cm-purchasable-card'),
                var index = parseInt(card.getAttribute('data-cm-index'), 10);
                function (card) {
                var entry = visibleMasteries[index];
                    var cmIndex = parseInt(card.getAttribute('data-cm-index'), 10);
                var cm = entry ? entry.cm : null;
                    var entry = visibleMasteries[cmIndex];
 
                    var cm = entry ? entry.cm : null;
          /*      card.addEventListener('mouseenter', function () {
                    showCmTooltip(cm, card);
                });
 
                card.addEventListener('mouseleave', hideCmTooltip); */
 
                card.addEventListener('click', function () {
                    var 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;
}


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


                     card.addEventListener('mouseleave', hideCmTooltip);
selectedMasteries[cm.name] = cm;
                     hideCmTooltip();
                    updateSummary();
                });
            });
        }


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


                        if (!cm || !entry) return;


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


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


                        if (purchaseReasons.length) {
        function setBaseStatInputs(baseStats) {
                            alert(purchaseReasons.join(' | '));
            baseStats = baseStats || {};
                            return;
            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 (isMasterClassTitle(cm)) {
        function loadBuildFromUrl() {
                            selectedMasterClassTitle = getSelectedMasterClassTitle();
            var hash = window.location.hash || '';
            var build;


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


                        selectedMasteries[cm.name] = cm;
            try {
                build = JSON.parse(decodeURIComponent(hash.replace('#cm-build=', '')));
            } catch (e) {
                return;
            }


                        hideCmTooltip();
            if (build.r && data.raceClasses && data.raceClasses[build.r]) {
                        updateSummary();
                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();
         }
         }


Line 2,302: Line 1,876:
         ].forEach(function (id) {
         ].forEach(function (id) {
             var input = document.getElementById(id);
             var input = document.getElementById(id);
            if (input) input.addEventListener('input', updateSummary);
        });


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


         if (searchInput) {
         if (searchInput) {
Line 2,317: Line 1,889:
                 renderMasteryList();
                 renderMasteryList();
                 searchInput.focus();
                 searchInput.focus();
            });
        }
        if (shareUrlButton) {
            shareUrlButton.addEventListener('click', function () {
                copyTextToClipboard(buildShareUrl(), shareUrlButton);
            });
        }
        if (exportCardButton) {
            exportCardButton.addEventListener('click', function () {
                renderBuildCard();
             });
             });
         }
         }
Line 2,343: Line 1,903:
         }
         }


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


         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 (button) {
                activeCmTab = button.getAttribute('data-tab');
                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');
                });


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


                    button.classList.add('cm-tab-active');
        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;


                levelInput.value = 60;
        function resetClassMasterySelections() {
                setBaseStatInputs({});
    selectedMasteries = {};
    sharedCmBuildApplied = false;
    activeCmTab = 'General';
    activeCmView = 'detailed';
    showLockedCms = true;
    levelInput.value = 60;
    setBaseStatInputs({});


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


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


                var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
    Array.prototype.forEach.call(viewButtons, function (btn) {
                if (generalTab) {
        btn.classList.remove('cm-view-active');
                    generalTab.classList.add('cm-tab-active');
    });
                }


                Array.prototype.forEach.call(viewButtons, function (btn) {
    var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
                    btn.classList.remove('cm-view-active');
    var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]');
                });


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


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


                if (showLockedInput) {
window.EQOA_RESET_CLASS_MASTERY = resetClassMasterySelections;
                    showLockedInput.checked = true;
                }


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


         loadSavedCmTheme();
         loadSavedCmTheme();


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


loadRaces();
        loadRaces();


if (isEmbeddedInCharacterBuilder) {
        if (isEmbedded()) {
    syncFromCharacterBuilder();
            syncFromCharacterBuilder();
} else {
        } else {
    loadBuildFromUrl();
            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();
        }
    }
});