MediaWiki:ClassMasteryCalculator.js: Difference between revisions
From EQOA Sandstorm Wiki
Jump to navigationJump to search
RenodorDev (talk | contribs) No edit summary |
RenodorDev (talk | contribs) No edit summary |
||
| Line 57: | Line 57: | ||
}; | }; | ||
} | } | ||
| Line 69: | Line 74: | ||
var rawRows = []; | var rawRows = []; | ||
var reqsByName = {}; | var reqsByName = {}; | ||
function getExemplarPathFromQuest(quest) { | |||
var match = String(quest || '').match(/Exemplar-([A-Z]+)-([^=\s]+)=99/i); | |||
if (!match) return null; | |||
return { | |||
classCode: match[1].toUpperCase(), | |||
path: match[2], | |||
quest: match[0] | |||
}; | |||
} | |||
lines.forEach(function (line, index) { | lines.forEach(function (line, index) { | ||
| Line 89: | Line 106: | ||
var cols; | var cols; | ||
var name; | var name; | ||
var quest; | |||
var exemplarPath; | |||
var reqs = {}; | var reqs = {}; | ||
| Line 95: | Line 114: | ||
cols = line.split('\t'); | cols = line.split('\t'); | ||
name = cols[headerIndex.Mastery]; | name = cols[headerIndex.Mastery]; | ||
quest = headerIndex.Quest === undefined ? '' : cols[headerIndex.Quest]; | |||
if (!name) return; | if (!name) return; | ||
| Line 111: | Line 131: | ||
} | } | ||
}); | }); | ||
exemplarPath = getExemplarPathFromQuest(quest); | |||
rawRows.push({ | rawRows.push({ | ||
name: name, | name: name, | ||
reqs: reqs | reqs: reqs, | ||
quest: quest, | |||
exemplarPath: exemplarPath | |||
}); | }); | ||
| Line 134: | Line 158: | ||
row = rawRows[index]; | row = rawRows[index]; | ||
if (row && row.name === cm.name | if (row && row.name === cm.name) { | ||
cm.rawQuest = row.quest || ''; | |||
if (Object.keys(row.reqs).length) { | |||
cm.baseStatReqs = row.reqs; | |||
} | |||
if (row.exemplarPath) { | |||
cm.exemplarPath = row.exemplarPath; | |||
} | |||
return; | return; | ||
} | } | ||
| Line 146: | Line 179: | ||
}); | }); | ||
} | } | ||
| Line 318: | Line 352: | ||
function formatStatName(statName) { | function formatStatName(statName) { | ||
var map = { | var map = { | ||
ac: 'AC', | ac: 'AC', | ||
hp: 'HP', | hp: 'HP', | ||
| Line 714: | Line 749: | ||
return singles.sort(); | return singles.sort(); | ||
} | } | ||
function getSelectedExemplarPathForClass(classCode) { | |||
var selectedPath = null; | |||
classCode = String(classCode || '').toUpperCase(); | |||
Object.keys(selectedMasteries).forEach(function (name) { | |||
var selectedCm = selectedMasteries[name]; | |||
var exemplarPath = selectedCm && selectedCm.exemplarPath; | |||
if (!exemplarPath || selectedPath) return; | |||
if (String(exemplarPath.classCode || '').toUpperCase() === classCode) { | |||
selectedPath = exemplarPath; | |||
} | |||
}); | |||
return selectedPath; | |||
} | |||
function getExemplarPathLockReason(cm) { | |||
var exemplarPath = cm && cm.exemplarPath; | |||
var selectedPath; | |||
if (!exemplarPath) return ''; | |||
selectedPath = getSelectedExemplarPathForClass(exemplarPath.classCode); | |||
if (!selectedPath) return ''; | |||
if (String(selectedPath.path) === String(exemplarPath.path)) { | |||
return ''; | |||
} | |||
return 'Exemplar path already chosen: ' + selectedPath.path; | |||
} | |||
function getCmLockReasons(cm, options) { | function getCmLockReasons(cm, options) { | ||
| Line 751: | Line 822: | ||
} | } | ||
}); | }); | ||
var exemplarLockReason = getExemplarPathLockReason(cm); | |||
if (exemplarLockReason) { | |||
reasons.push(exemplarLockReason); | |||
} | |||
if (options.includePointBudget && spent + numberOrZero(cm.points) > available) { | if (options.includePointBudget && spent + numberOrZero(cm.points) > available) { | ||
| Line 779: | Line 856: | ||
var panel = document.getElementById('cm-master-class-panel'); | var panel = document.getElementById('cm-master-class-panel'); | ||
var selectedTitle = getSelectedMasterClassTitle(); | var selectedTitle = getSelectedMasterClassTitle(); | ||
if (!panel) return; | if (!panel) return; | ||
| Line 1,035: | Line 1,111: | ||
var canvas = document.createElement('canvas'); | var canvas = document.createElement('canvas'); | ||
var ctx = canvas.getContext('2d'); | var ctx = canvas.getContext('2d'); | ||
| Line 1,127: | Line 1,202: | ||
items.forEach(function (item, index) { | items.forEach(function (item, index) { | ||
var col = Math.floor(index / rows); | var col = Math.floor(index / rows); | ||
var row = index % rows; | var row = index % rows; | ||
var drawX = x + col * (colWidth + colGap); | var drawX = x + col * (colWidth + colGap); | ||
| Line 1,318: | Line 1,392: | ||
total += selectedMasteries[name].points || 0; | total += selectedMasteries[name].points || 0; | ||
} | } | ||
}); | }); | ||
| Line 1,569: | Line 1,642: | ||
if (value) { | if (value) { | ||
var sign = value > 0 ? '+' : ''; | var sign = value > 0 ? '+' : ''; | ||
statLines.push( | statLines.push( | ||
| Line 1,676: | Line 1,750: | ||
cmClasses.indexOf('All') !== -1 || | cmClasses.indexOf('All') !== -1 || | ||
cmClasses.indexOf(selectedClass) !== -1; | cmClasses.indexOf(selectedClass) !== -1; | ||
var tabOk = getCmDisplayTab(cm) === activeCmTab; | var tabOk = getCmDisplayTab(cm) === activeCmTab; | ||
Revision as of 02:46, 15 May 2026
mw.hook('wikipage.content').add(function () {
function waitForCMData(attemptsLeft) {
if (window.EQOA_CM_DATA) {
initClassMasteryCalculator();
return;
}
if (attemptsLeft <= 0) {
var app = document.getElementById('cm-calculator-app');
if (app) {
app.innerHTML = '<div style="color:red;">EQOA_CM_DATA failed to load.</div>';
}
return;
}
setTimeout(function () {
waitForCMData(attemptsLeft - 1);
}, 100);
}
waitForCMData(30);
function initClassMasteryCalculator() {
var app = document.getElementById('cm-calculator-app');
if (!app) return;
var data = window.EQOA_CM_DATA || {};
var selectedMasteries = {};
var activeCmTab = 'General';
var activeCmView = 'detailed';
var showLockedCms = true;
var visibleMasteries = [];
var 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 numberOrZero(value) {
var n = Number(String(value || '').trim());
return Number.isFinite(n) ? n : 0;
}
function getBaseStatsFromInputs() {
return {
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)
};
}
function applyRawBaseStatRequirements() {
var raw = window.EQOA_CM_RAW || '';
var lines = String(raw).split(/\r?\n/);
var headerLineIndex = -1;
var headers = [];
var headerIndex = {};
var rawRows = [];
var reqsByName = {};
function getExemplarPathFromQuest(quest) {
var match = String(quest || '').match(/Exemplar-([A-Z]+)-([^=\s]+)=99/i);
if (!match) return null;
return {
classCode: match[1].toUpperCase(),
path: match[2],
quest: match[0]
};
}
lines.forEach(function (line, index) {
if (headerLineIndex === -1 && line.indexOf('Mastery\tDescription') === 0) {
headerLineIndex = index;
}
});
if (headerLineIndex === -1) {
return;
}
headers = lines[headerLineIndex].split('\t');
headers.forEach(function (header, index) {
headerIndex[String(header).trim()] = index;
});
lines.slice(headerLineIndex + 1).forEach(function (line) {
var cols;
var name;
var quest;
var exemplarPath;
var reqs = {};
if (!line.trim()) return;
cols = line.split('\t');
name = cols[headerIndex.Mastery];
quest = headerIndex.Quest === undefined ? '' : cols[headerIndex.Quest];
if (!name) return;
Object.keys(BASE_STAT_REQ_FIELDS).forEach(function (stat) {
var field = BASE_STAT_REQ_FIELDS[stat];
var columnIndex = headerIndex[field];
var value;
if (columnIndex === undefined) return;
value = numberOrZero(cols[columnIndex]);
if (value > 0) {
reqs[stat] = value;
}
});
exemplarPath = getExemplarPathFromQuest(quest);
rawRows.push({
name: name,
reqs: reqs,
quest: quest,
exemplarPath: exemplarPath
});
if (Object.keys(reqs).length) {
if (!reqsByName[name]) {
reqsByName[name] = [];
}
reqsByName[name].push(reqs);
}
});
safeArray(data.masteries).forEach(function (cm, index) {
var row;
var namedReqs;
if (!cm || !cm.name) return;
row = rawRows[index];
if (row && row.name === cm.name) {
cm.rawQuest = row.quest || '';
if (Object.keys(row.reqs).length) {
cm.baseStatReqs = row.reqs;
}
if (row.exemplarPath) {
cm.exemplarPath = row.exemplarPath;
}
return;
}
namedReqs = reqsByName[cm.name];
if (namedReqs && namedReqs.length) {
cm.baseStatReqs = namedReqs[0];
}
});
}
applyRawBaseStatRequirements();
app.innerHTML =
'<div class="cm-calculator-box">' +
'<div class="character-builder-title">Class Mastery Calculator</div>' +
'<div class="character-builder-controls">' +
'<label>Race</label>' +
'<select id="cm-race"></select>' +
'<label>Class</label>' +
'<select id="cm-class"></select>' +
'<label>Character Level</label>' +
'<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +
'<label>Base Stats</label>' +
'<span>' +
'STR <input type="number" id="cm-base-str" min="0" value="0" style="width:55px;"> ' +
'STA <input type="number" id="cm-base-sta" min="0" value="0" style="width:55px;"> ' +
'DEX <input type="number" id="cm-base-dex" min="0" value="0" style="width:55px;"> ' +
'AGI <input type="number" id="cm-base-agi" min="0" value="0" style="width:55px;"> ' +
'INT <input type="number" id="cm-base-int" min="0" value="0" style="width:55px;"> ' +
'WIS <input type="number" id="cm-base-wis" min="0" value="0" style="width:55px;"> ' +
'CHA <input type="number" id="cm-base-cha" min="0" value="0" style="width:55px;">' +
'</span>' +
'<label>Transformation Path</label>' +
'<select id="cm-path">' +
'<option value="">None</option>' +
'<option value="Werelion">Lionwere</option>' +
'<option value="Werebear">Bearwere</option>' +
'<option value="Werewolf">Wolfwere</option>' +
'<option value="Wererat">Ratwere</option>' +
'<option value="Weregator">Gatorwere</option>' +
'<option value="Vampire">Vampire</option>' +
'<option value="Were Hunter">Were-Hunter</option>' +
'</select>' +
'<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
'</div>' +
'<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' +
'<table class="character-builder-stat-table">' +
'<tr>' +
'<th>Available</th>' +
'<th>Spent</th>' +
'<th>Remaining</th>' +
'</tr>' +
'<tr>' +
'<td id="cm-available">1500</td>' +
'<td id="cm-spent">0</td>' +
'<td id="cm-remaining">1500</td>' +
'</tr>' +
'</table>' +
'<div class="character-builder-section-title" style="margin-top:18px;">Master Class Title</div>' +
'<div id="cm-master-class-panel" class="cm-master-class-panel">' +
'<span class="cm-master-class-label">None selected</span>' +
'</div>' +
'<div class="cm-build-actions">' +
'<button type="button" id="cm-share-url" class="cm-action-button">Generate Share URL</button>' +
'<button type="button" id="cm-export-card" class="cm-action-button">Export Build Card</button>' +
'</div>' +
'<div id="cm-build-card-panel" class="cm-build-card-panel" style="display:none;"></div>' +
'<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</div>' +
'<div id="cm-unlocked-summary">' +
'No stats or spells gained yet.' +
'</div>' +
'<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' +
'<div class="cm-tabs">' +
'<button type="button" class="cm-tab-button cm-tab-active" data-tab="General">General</button>' +
'<button type="button" class="cm-tab-button" data-tab="Racial">Racial</button>' +
'<button type="button" class="cm-tab-button" data-tab="Archetype">Archetype</button>' +
'<button type="button" class="cm-tab-button" data-tab="Class">Class</button>' +
'</div>' +
'<div class="cm-search-tools">' +
'<div class="cm-search-row">' +
'<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' +
'<button type="button" id="cm-clear-search" class="cm-clear-search-button" title="Clear search">Clear</button>' +
'</div>' +
'<label class="cm-lock-toggle">' +
'<input type="checkbox" id="cm-show-locked" checked>' +
'<span>Show locked</span>' +
'</label>' +
'</div>' +
'<div class="cm-view-toggle">' +
'<button type="button" class="cm-view-button cm-view-active" data-view="detailed">Detailed</button>' +
'<button type="button" class="cm-view-button" data-view="compact">Compact</button>' +
'</div>' +
'<div class="cm-main-layout">' +
'<div class="cm-purchased-panel">' +
'<div class="cm-panel-title">CM Purchased</div>' +
'<div id="cm-purchased-list">None purchased.</div>' +
'</div>' +
'<div id="cm-mastery-panel" class="character-builder-warning-box">' +
'Select a race and class to begin.' +
'</div>' +
'</div>' +
'<div id="cm-tooltip" class="cm-tooltip" style="display:none;"></div>' +
'</div>';
var raceSelect = document.getElementById('cm-race');
var classSelect = document.getElementById('cm-class');
var levelInput = document.getElementById('cm-level');
var pathSelect = document.getElementById('cm-path');
var resetButton = document.getElementById('cm-reset');
var searchInput = document.getElementById('cm-search');
var clearSearchButton = document.getElementById('cm-clear-search');
var showLockedInput = document.getElementById('cm-show-locked');
var viewButtons = document.querySelectorAll('.cm-view-button');
var cmTooltip = document.getElementById('cm-tooltip');
var shareUrlButton = document.getElementById('cm-share-url');
var exportCardButton = document.getElementById('cm-export-card');
var buildCardPanel = document.getElementById('cm-build-card-panel');
function safeArray(value) {
return Object.prototype.toString.call(value) === '[object Array]' ? value : [];
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function numberOrZero(value) {
var n = Number(String(value || '').trim());
return isFinite(n) ? n : 0;
}
function getInputNumber(id) {
var input = document.getElementById(id);
return input ? numberOrZero(input.value) : 0;
}
function getBaseStatsFromInputs() {
return {
STR: getInputNumber('cm-base-str'),
STA: getInputNumber('cm-base-sta'),
DEX: getInputNumber('cm-base-dex'),
AGI: getInputNumber('cm-base-agi'),
INT: getInputNumber('cm-base-int'),
WIS: getInputNumber('cm-base-wis'),
CHA: getInputNumber('cm-base-cha')
};
}
function formatStatName(statName) {
var map = {
ac: 'AC',
hp: 'HP',
pow: 'Power',
str: 'STR',
sta: 'STA',
agi: 'AGI',
dex: 'DEX',
wis: 'WIS',
int: 'INT',
cha: 'CHA',
STR: 'STR',
STA: 'STA',
AGI: 'AGI',
DEX: 'DEX',
WIS: 'WIS',
INT: 'INT',
CHA: 'CHA',
strMax: 'STR Max',
staMax: 'STA Max',
agiMax: 'AGI Max',
dexMax: 'DEX Max',
wisMax: 'WIS Max',
intMax: 'INT Max',
chaMax: 'CHA Max',
offMod: 'Offense Mod',
defMod: 'Defense Mod',
hasteMod: 'Haste Mod',
regenMod: 'Regen Mod',
powerRegenMod: 'Power Regen Mod',
healMod: 'Healing Mod',
spellMod: 'Spell Mod',
meleeMod: 'Melee Mod',
fireResist: 'Fire Resist',
coldResist: 'Cold Resist',
diseaseResist: 'Disease Resist',
poisonResist: 'Poison Resist',
magicResist: 'Magic Resist',
fireResistMax: 'Fire Resist Max',
coldResistMax: 'Cold Resist Max',
diseaseResistMax: 'Disease Resist Max',
poisonResistMax: 'Poison Resist Max',
magicResistMax: 'Magic Resist Max'
};
if (map[statName]) {
return map[statName];
}
return String(statName || '')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, function (letter) {
return letter.toUpperCase();
});
}
function addOption(select, value, label) {
var option = document.createElement('option');
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function normalizePath(value) {
value = String(value || '').toLowerCase().replace(/[\s-]/g, '');
var map = {
lionwere: 'werelion',
werelion: 'werelion',
bearwere: 'werebear',
werebear: 'werebear',
wolfwere: 'werewolf',
werewolf: 'werewolf',
ratwere: 'wererat',
wererat: 'wererat',
gatorwere: 'weregator',
weregator: 'weregator',
vampire: 'vampire',
werehunter: 'werehunter',
hunterwere: 'werehunter',
werebeast: 'werebeast'
};
return map[value] || value;
}
function getBitePath(name) {
name = String(name || '').toLowerCase();
if (name.indexOf('lion bite') !== -1) return 'werelion';
if (name.indexOf('bear bite') !== -1) return 'werebear';
if (name.indexOf('wolf bite') !== -1) return 'werewolf';
if (name.indexOf('rat bite') !== -1) return 'wererat';
if (name.indexOf('gator bite') !== -1) return 'weregator';
return '';
}
function isSharedWereMastery(name) {
return name === 'Lycanthropic Resistances' ||
name === 'Lycanthropic Regeneration' ||
name === 'Combat Senses';
}
function isWerePath(path) {
return path === 'werelion' ||
path === 'werebear' ||
path === 'werewolf' ||
path === 'wererat' ||
path === 'weregator';
}
function isMasterClassTitle(cm) {
var name = String((cm && cm.name) || '');
return name === 'Berserker' ||
name === 'Chosen' ||
name === 'Deputy' ||
name === 'Doomseeker' ||
name === 'Immortal' ||
name === 'Juggernaut' ||
name === 'Mercenary' ||
name === 'Savage' ||
name === 'Scholar' ||
name === 'Tinker' ||
name === 'Arcane Were-Hunter' ||
name === 'Divine Were-Hunter' ||
name === 'Gypsy Were-Hunter' ||
name === 'Heroic Were-Hunter' ||
name === 'Metabolist' ||
name === 'Transmuter' ||
name === 'Gypsy' ||
name === 'Minstrel' ||
name === 'Exorcist' ||
name === 'Faithful' ||
name === 'Storm Caller' ||
name === 'Wilding' ||
name === 'Animator' ||
name === 'Spellbinder' ||
name === 'Aeromancer' ||
name === 'Geomancer' ||
name === 'Hydromancer' ||
name === 'Pyromancer' ||
name === 'Ninja' ||
name === 'Sensei' ||
name === 'Defiler' ||
name === 'Lich' ||
name === 'Crusader' ||
name === 'Guardian' ||
name === 'Forester' ||
name === 'Hunter' ||
name === 'Assassin' ||
name === 'Burglar' ||
name === 'Death Knight' ||
name === 'Slayer' ||
name === 'Elder' ||
name === 'Mystic' ||
name === 'Defender' ||
name === 'Hero' ||
name === 'Channeler' ||
name === 'Sorcerer' ||
name === 'Bravo' ||
name === 'Diviner' ||
name === 'Sage' ||
name === 'Soldier';
}
function getSelectedMasterClassTitle() {
var selectedTitle = '';
Object.keys(selectedMasteries).forEach(function (name) {
if (isMasterClassTitle(selectedMasteries[name])) {
selectedTitle = name;
}
});
return selectedTitle;
}
function getCmValueByPossibleKeys(cm, keys) {
var normalizedWanted = {};
var found = '';
keys.forEach(function (key) {
normalizedWanted[String(key).toLowerCase().replace(/[^a-z0-9]/g, '')] = true;
});
Object.keys(cm || {}).forEach(function (key) {
var normalizedKey;
if (found !== '') return;
normalizedKey = String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
if (normalizedWanted[normalizedKey]) {
found = cm[key];
}
});
return found;
}
function getCmStatMinRequirements(cm) {
var reqs = [];
if (!cm || !cm.baseStatReqs) return reqs;
Object.keys(cm.baseStatReqs).forEach(function (statName) {
var value = numberOrZero(cm.baseStatReqs[statName]);
if (value > 0) {
reqs.push({
stat: statName,
value: value
});
}
});
return reqs;
}
function getStatMinRequirementsHtml(cm) {
var reqs = getCmStatMinRequirements(cm);
if (!reqs.length) return '';
return reqs.map(function (req) {
return '<span class="cm-stat-min-req">' +
'Base ' +
escapeHtml(formatStatName(req.stat)) +
' ' +
escapeHtml(req.value) +
'</span>';
}).join(', ');
}
function getCmSearchText(cm) {
var parts = [];
var stats = cm && cm.stats ? cm.stats : {};
var statMinReqs;
if (!cm) return '';
parts.push(cm.name || '');
parts.push(cm.description || '');
parts.push(cm.classification || '');
parts.push(cm.lycanthropy || '');
Object.keys(stats).forEach(function (statName) {
parts.push(statName);
parts.push(formatStatName(statName));
parts.push(stats[statName]);
});
statMinReqs = getCmStatMinRequirements(cm);
statMinReqs.forEach(function (req) {
parts.push(req.stat);
parts.push(formatStatName(req.stat));
parts.push(req.value);
});
safeArray(cm.spellsAdded).forEach(function (spell) {
parts.push(spell);
});
safeArray(cm.spellsRemoved).forEach(function (spell) {
parts.push(spell);
});
safeArray(cm.requirements).forEach(function (req) {
parts.push(req);
});
return parts.join(' ').toLowerCase();
}
function getRequirementHtml(reqName) {
var owned = !!selectedMasteries[reqName];
var className = owned ? 'cm-req-owned' : 'cm-req-missing';
return '<span class="' + className + '">' + escapeHtml(reqName) + '</span>';
}
function getRequirementsHtml(cm) {
var reqs = safeArray(cm && cm.requirements);
if (!reqs.length) return '';
return reqs.map(function (reqName) {
return getRequirementHtml(reqName);
}).join(', ');
}
function getCmChainBase(name) {
return String(name || '').replace(/\s+\d+$/, '');
}
function getCmChainNumber(name) {
var match = String(name || '').match(/\s+(\d+)$/);
return match ? parseInt(match[1], 10) : null;
}
function getCmChain(cm) {
var base;
var currentNumber;
var chain = [];
if (!cm || !cm.name) return [];
base = getCmChainBase(cm.name);
currentNumber = getCmChainNumber(cm.name);
if (currentNumber === null) return [];
safeArray(data.masteries).forEach(function (item) {
if (
item &&
getCmChainBase(item.name) === base &&
getCmChainNumber(item.name) !== null
) {
chain.push(item.name);
}
});
chain.sort(function (a, b) {
return getCmChainNumber(a) - getCmChainNumber(b);
});
return chain;
}
function getCmChainHtml(cm) {
var chain = getCmChain(cm);
if (chain.length <= 1) return '';
return chain.map(function (name) {
var owned = !!selectedMasteries[name];
var current = cm && cm.name === name;
var className = 'cm-chain-step';
if (owned) {
className += ' cm-chain-owned';
}
if (current) {
className += ' cm-chain-current';
}
return '<span class="' + className + '">' + escapeHtml(name) + '</span>';
}).join('<span class="cm-chain-arrow"> > </span>');
}
function getCompressedMasteryNames(names) {
var groups = {};
var singles = [];
names.forEach(function (name) {
var base = getCmChainBase(name);
var number = getCmChainNumber(name);
if (number === null) {
singles.push(name);
return;
}
if (!groups[base]) {
groups[base] = [];
}
groups[base].push(number);
});
Object.keys(groups).forEach(function (base) {
groups[base].sort(function (a, b) {
return a - b;
});
if (groups[base].length === 1) {
singles.push(base + ' ' + groups[base][0]);
} else {
singles.push(base + ' ' + groups[base].join(' > '));
}
});
return singles.sort();
}
function getSelectedExemplarPathForClass(classCode) {
var selectedPath = null;
classCode = String(classCode || '').toUpperCase();
Object.keys(selectedMasteries).forEach(function (name) {
var selectedCm = selectedMasteries[name];
var exemplarPath = selectedCm && selectedCm.exemplarPath;
if (!exemplarPath || selectedPath) return;
if (String(exemplarPath.classCode || '').toUpperCase() === classCode) {
selectedPath = exemplarPath;
}
});
return selectedPath;
}
function getExemplarPathLockReason(cm) {
var exemplarPath = cm && cm.exemplarPath;
var selectedPath;
if (!exemplarPath) return '';
selectedPath = getSelectedExemplarPathForClass(exemplarPath.classCode);
if (!selectedPath) return '';
if (String(selectedPath.path) === String(exemplarPath.path)) {
return '';
}
return 'Exemplar path already chosen: ' + selectedPath.path;
}
function getCmLockReasons(cm, options) {
var spent = getSpentPoints();
var available = getAvailablePoints();
var baseStats = getBaseStatsFromInputs();
var level = parseInt(levelInput.value, 10);
var reasons = [];
options = options || {};
if (!cm) return reasons;
if (isNaN(level)) {
level = 1;
}
if (cm.level && cm.level > level) {
reasons.push('Reach level ' + cm.level);
}
if (cm.cmSpentReq && spent < cm.cmSpentReq) {
reasons.push('Spend ' + (cm.cmSpentReq - spent) + ' more CM');
}
getCmStatMinRequirements(cm).forEach(function (req) {
var current = numberOrZero(baseStats[req.stat]);
if (current < req.value) {
reasons.push('Requires base ' + req.stat + ' ' + req.value);
}
});
safeArray(cm.requirements).forEach(function (reqName) {
if (!selectedMasteries[reqName]) {
reasons.push('Requires ' + reqName);
}
});
var exemplarLockReason = getExemplarPathLockReason(cm);
if (exemplarLockReason) {
reasons.push(exemplarLockReason);
}
if (options.includePointBudget && spent + numberOrZero(cm.points) > available) {
reasons.push('Not enough CM points');
}
return reasons;
}
function getCmUnlockHint(cm) {
return getCmLockReasons(cm).join(' | ');
}
function canPurchaseCm(cm) {
return getCmLockReasons(cm, {
includePointBudget: true
}).length === 0;
}
function updateMasterClassPanel() {
var panel = document.getElementById('cm-master-class-panel');
var selectedTitle = getSelectedMasterClassTitle();
if (!panel) return;
if (!selectedTitle) {
panel.innerHTML =
'<span class="cm-master-class-label">None selected</span>';
return;
}
panel.innerHTML =
'<span class="cm-master-class-label">' + escapeHtml(selectedTitle) + '</span>' +
'<button type="button" id="cm-remove-master-class" class="cm-mini-remove-button">Remove</button>';
document.getElementById('cm-remove-master-class').addEventListener('click', function () {
delete selectedMasteries[selectedTitle];
hideCmTooltip();
updateSummary();
});
}
function getCmDisplayTab(cm) {
var classification = String((cm && cm.classification) || '');
var races = safeArray(cm && cm.races);
var classes = safeArray(cm && cm.classes);
if (classification === 'Master Class') {
if (races.length && races[0] !== 'All') {
return 'Racial';
}
if (classes.length && classes[0] !== 'All') {
return 'Class';
}
}
return classification;
}
function getCmType(cm) {
if (isMasterClassTitle(cm)) {
return 'Master Class';
}
return getCmDisplayTab(cm) || 'Other';
}
function getCmTypeClass(cm) {
var type = getCmType(cm);
if (type === 'General') return 'cm-type-general';
if (type === 'Racial') return 'cm-type-racial';
if (type === 'Archetype') return 'cm-type-archetype';
if (type === 'Class') return 'cm-type-class';
if (type === 'Master Class') return 'cm-type-master';
return 'cm-type-other';
}
function findCmByName(name) {
var found = null;
safeArray(data.masteries).forEach(function (cm) {
if (!found && cm && cm.name === name) {
found = cm;
}
});
return found;
}
function getSelectedMasteryNames() {
return Object.keys(selectedMasteries).filter(function (name) {
return selectedMasteries[name];
}).sort();
}
function copyTextToClipboard(text, button) {
function showCopied() {
var oldText;
if (!button) {
alert('Copied.');
return;
}
oldText = button.textContent;
button.textContent = 'Copied';
setTimeout(function () {
button.textContent = oldText;
}, 1200);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(showCopied, function () {
alert(text);
});
return;
}
alert(text);
}
function getBuildTotals() {
var statTotals = {};
var spellsAdded = {};
var spellsRemoved = {};
Object.keys(selectedMasteries).forEach(function (name) {
var cm = selectedMasteries[name];
var stats;
if (!cm) return;
stats = cm.stats || {};
Object.keys(stats).forEach(function (statName) {
var value = parseInt(stats[statName], 10);
if (!isNaN(value) && value !== 0) {
if (!statTotals[statName]) {
statTotals[statName] = 0;
}
statTotals[statName] += value;
}
});
safeArray(cm.spellsAdded).forEach(function (spell) {
if (spell) spellsAdded[spell] = true;
});
safeArray(cm.spellsRemoved).forEach(function (spell) {
if (spell) spellsRemoved[spell] = true;
});
});
return {
stats: statTotals,
spellsAdded: spellsAdded,
spellsRemoved: spellsRemoved
};
}
function buildShareUrl() {
var baseStats = getBaseStatsFromInputs();
var build = {
r: raceSelect.value,
c: classSelect.value,
l: levelInput.value,
p: pathSelect ? pathSelect.value : '',
b: baseStats,
cms: getSelectedMasteryNames()
};
return window.location.href.split('#')[0] +
'#cm-build=' +
encodeURIComponent(JSON.stringify(build));
}
function setBaseStatInputs(baseStats) {
baseStats = baseStats || {};
document.getElementById('cm-base-str').value = numberOrZero(baseStats.STR);
document.getElementById('cm-base-sta').value = numberOrZero(baseStats.STA);
document.getElementById('cm-base-dex').value = numberOrZero(baseStats.DEX);
document.getElementById('cm-base-agi').value = numberOrZero(baseStats.AGI);
document.getElementById('cm-base-int').value = numberOrZero(baseStats.INT);
document.getElementById('cm-base-wis').value = numberOrZero(baseStats.WIS);
document.getElementById('cm-base-cha').value = numberOrZero(baseStats.CHA);
}
function loadBuildFromUrl() {
var hash = window.location.hash || '';
var raw;
var build;
if (hash.indexOf('#cm-build=') !== 0) return;
raw = hash.replace('#cm-build=', '');
try {
build = JSON.parse(decodeURIComponent(raw));
} catch (e) {
return;
}
if (build.r && data.raceClasses && data.raceClasses[build.r]) {
raceSelect.value = build.r;
loadClasses();
}
if (build.c) {
classSelect.value = build.c;
}
if (build.l) {
levelInput.value = build.l;
}
if (pathSelect && build.p !== undefined) {
pathSelect.value = build.p;
}
if (build.b) {
setBaseStatInputs(build.b);
}
selectedMasteries = {};
safeArray(build.cms).forEach(function (name) {
var cm = findCmByName(name);
if (cm) {
selectedMasteries[cm.name] = cm;
}
});
updateSummary();
}
function loadQrLibrary(callback) {
if (window.QRious) {
callback(true);
return;
}
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js';
script.onload = function () {
callback(!!window.QRious);
};
script.onerror = function () {
callback(false);
};
document.head.appendChild(script);
}
function renderBuildCard() {
loadQrLibrary(function (qrReady) {
var shareUrl = buildShareUrl();
var totals = getBuildTotals();
var selectedNames = getSelectedMasteryNames();
var purchasedDisplayNames = getCompressedMasteryNames(selectedNames);
var selectedTitle = getSelectedMasterClassTitle() || 'None';
var available = getAvailablePoints();
var spent = getSpentPoints();
var remaining = available - spent;
var statsList = Object.keys(totals.stats).sort();
var gainedList = Object.keys(totals.spellsAdded).sort();
var removedList = Object.keys(totals.spellsRemoved).sort();
var baseStats = getBaseStatsFromInputs();
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
var cardWidth = 1800;
var margin = 55;
var gap = 35;
var panelW = cardWidth - margin * 2;
var y = 390;
var qrW = 360;
var summaryW = panelW - qrW - gap;
var qrH = 360;
function roundedRect(x, y, w, h, r) {
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
return;
}
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
}
function drawPanel(x, y, w, h, title) {
ctx.strokeStyle = '#9b7330';
ctx.lineWidth = 3;
roundedRect(x, y, w, h, 16);
ctx.stroke();
ctx.fillStyle = '#d9a64d';
ctx.font = 'bold 36px Georgia';
ctx.textAlign = 'center';
ctx.fillText(title, x + w / 2, y + 48);
ctx.strokeStyle = 'rgba(217,166,77,0.35)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x + 35, y + 68);
ctx.lineTo(x + w - 35, y + 68);
ctx.stroke();
ctx.textAlign = 'left';
}
function drawInfoBox(label, value, x, y, w) {
ctx.strokeStyle = '#9b7330';
ctx.lineWidth = 2;
roundedRect(x, y, w, 100, 12);
ctx.stroke();
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f4e6c8';
ctx.font = 'bold 25px Georgia';
ctx.fillText(label, x + 28, y + 38);
ctx.font = 'bold 30px Georgia';
ctx.fillText(value || 'None', x + 28, y + 72);
}
function measureColumnList(items, columns, lineH) {
if (!items.length) return lineH;
return Math.ceil(items.length / columns) * lineH;
}
function drawColumnList(items, x, y, width, columns, color, font, lineH) {
var colGap = 42;
var colWidth = (width - colGap * (columns - 1)) / columns;
var rows = Math.ceil(Math.max(items.length, 1) / columns);
ctx.fillStyle = color;
ctx.font = font;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
if (!items.length) {
ctx.fillText('None', x, y);
return y + lineH;
}
items.forEach(function (item, index) {
var col = Math.floor(index / rows);
var row = index % rows;
var drawX = x + col * (colWidth + colGap);
var drawY = y + row * lineH;
ctx.fillText(item, drawX, drawY);
});
return y + rows * lineH;
}
var statsItems = statsList.map(function (statName) {
var value = totals.stats[statName];
var sign = value > 0 ? '+' : '';
return formatStatName(statName) + ': ' + sign + value;
});
var baseStatItems = Object.keys(baseStats).map(function (statName) {
return statName + ': ' + baseStats[statName];
});
var gainedItems = gainedList.map(function (spell) {
return '+ ' + spell;
});
var removedItems = removedList.map(function (spell) {
return '- ' + spell;
});
var statsHeight = measureColumnList(statsItems.concat(baseStatItems), 3, 30);
var purchasedHeight = measureColumnList(purchasedDisplayNames, 2, 30);
var gainedHeight = measureColumnList(gainedItems, 2, 30);
var removedHeight = measureColumnList(removedItems, 2, 30);
var summaryH = Math.max(490, 365 + statsHeight);
var topRowH = Math.max(summaryH, qrH);
var purchasedH = Math.max(280, 130 + purchasedHeight);
var spellsH = Math.max(420, 250 + gainedHeight + removedHeight);
canvas.width = cardWidth;
canvas.height =
390 +
topRowH + 35 +
purchasedH + 35 +
spellsH + 180;
ctx.fillStyle = '#05080d';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#9b7330';
ctx.lineWidth = 6;
roundedRect(8, 8, canvas.width - 16, canvas.height - 16, 28);
ctx.stroke();
ctx.textAlign = 'center';
ctx.fillStyle = '#d9a64d';
ctx.font = 'bold 78px Georgia';
ctx.fillText('EQOA: SANDSTORM', canvas.width / 2, 100);
ctx.font = 'bold 46px Georgia';
ctx.fillText('CLASS MASTERY CALCULATOR', canvas.width / 2, 155);
ctx.fillStyle = '#f4e6c8';
ctx.font = 'bold 38px Georgia';
ctx.fillText('Unnamed CM Build', canvas.width / 2, 205);
drawInfoBox('Race', raceSelect.value || 'None', margin, 250, 380);
drawInfoBox('Class', classSelect.value || 'None', 480, 250, 380);
drawInfoBox('Level', String(levelInput.value || '60'), 905, 250, 300);
drawInfoBox('Path', (pathSelect && pathSelect.value) || 'None', 1250, 250, 495);
drawPanel(margin, y, summaryW, topRowH, 'CM SUMMARY');
drawPanel(margin + summaryW + gap, y, qrW, qrH, 'SCAN');
ctx.fillStyle = '#f4e6c8';
ctx.font = 'bold 29px Georgia';
ctx.fillText('Master Class: ' + selectedTitle, margin + 45, y + 115);
ctx.fillText('Points: ' + spent + ' / ' + available + ' spent', margin + 45, y + 155);
ctx.fillText('Remaining: ' + remaining, margin + 45, y + 195);
ctx.fillStyle = '#d9a64d';
ctx.font = 'bold 30px Georgia';
ctx.fillText('Base Stats', margin + 45, y + 255);
var afterBaseY = drawColumnList(baseStatItems, margin + 45, y + 295, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
ctx.fillStyle = '#d9a64d';
ctx.font = 'bold 30px Georgia';
ctx.fillText('Stats Gained', margin + 45, afterBaseY + 45);
drawColumnList(statsItems, margin + 45, afterBaseY + 85, summaryW - 90, 3, '#f4e6c8', 'bold 23px Georgia', 30);
if (qrReady) {
var qrCanvas = document.createElement('canvas');
new QRious({
element: qrCanvas,
value: shareUrl,
size: 220,
background: 'white',
foreground: 'black',
level: 'M'
});
ctx.drawImage(qrCanvas, margin + summaryW + gap + 70, y + 105, 220, 220);
}
y += topRowH + 35;
drawPanel(margin, y, panelW, purchasedH, 'PURCHASED CMS');
drawColumnList(purchasedDisplayNames, margin + 45, y + 115, panelW - 90, 2, '#e066ff', 'bold 23px Georgia', 30);
y += purchasedH + 35;
drawPanel(margin, y, panelW, spellsH, 'SPELLS');
ctx.fillStyle = '#8fd9ff';
ctx.font = 'bold 30px Georgia';
ctx.fillText('Gained', margin + 45, y + 115);
var afterGainedY = drawColumnList(gainedItems, margin + 45, y + 155, panelW - 90, 2, '#8fd9ff', 'bold 22px Georgia', 30);
ctx.fillStyle = '#ff8f8f';
ctx.font = 'bold 30px Georgia';
ctx.fillText('Removed', margin + 45, afterGainedY + 55);
drawColumnList(removedItems, margin + 45, afterGainedY + 95, panelW - 90, 2, '#ff8f8f', 'bold 22px Georgia', 30);
ctx.fillStyle = '#d9a64d';
ctx.font = 'bold 30px Georgia';
ctx.textAlign = 'center';
ctx.fillText(
'EQOA: Sandstorm Wiki Class Mastery Calculator',
canvas.width / 2,
canvas.height - 55
);
var link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = 'EQOA_CM_Build.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
function loadRaces() {
raceSelect.innerHTML = '';
Object.keys(data.raceClasses || {}).forEach(function (race) {
addOption(raceSelect, race, race);
});
loadClasses();
}
function loadClasses() {
classSelect.innerHTML = '';
var race = raceSelect.value;
var classList = safeArray((data.raceClasses || {})[race]);
classList.forEach(function (classCode) {
addOption(
classSelect,
classCode,
classCode + ' - ' + ((data.classes || {})[classCode] || classCode)
);
});
updateSummary();
}
function getAvailablePoints() {
var level = parseInt(levelInput.value, 10);
if (isNaN(level) || level < 1) level = 1;
if (level > 60) level = 60;
levelInput.value = level;
return level * 25;
}
function getSpentPoints() {
var total = 0;
Object.keys(selectedMasteries).forEach(function (name) {
if (selectedMasteries[name]) {
total += selectedMasteries[name].points || 0;
}
});
return total;
}
Array.prototype.forEach.call(viewButtons, function (button) {
button.addEventListener('click', function () {
activeCmView = button.getAttribute('data-view') || 'detailed';
Array.prototype.forEach.call(viewButtons, function (btn) {
btn.classList.remove('cm-view-active');
});
button.classList.add('cm-view-active');
renderMasteryList();
});
});
function updatePurchasedList() {
var list = document.getElementById('cm-purchased-list');
var purchasedRows = [];
var groups = {
'Master Class': [],
'General': [],
'Racial': [],
'Archetype': [],
'Class': [],
'Other': []
};
var groupOrder = [
'Master Class',
'General',
'Racial',
'Archetype',
'Class',
'Other'
];
var html = '';
if (!list) return;
Object.keys(selectedMasteries).forEach(function (name) {
var cm = selectedMasteries[name];
var groupName;
if (!cm) return;
if (isMasterClassTitle(cm)) {
groupName = 'Master Class';
} else {
groupName = getCmDisplayTab(cm);
if (!groups[groupName]) {
groupName = 'Other';
}
}
groups[groupName].push(cm);
});
groupOrder.forEach(function (groupName) {
groups[groupName].sort(function (a, b) {
return String(a.name || '').localeCompare(String(b.name || ''));
});
});
groupOrder.forEach(function (groupName) {
if (!groups[groupName].length) return;
html +=
'<div class="cm-purchased-group">' +
'<div class="cm-purchased-group-title">' + escapeHtml(groupName) + '</div>';
groups[groupName].forEach(function (cm) {
var index = purchasedRows.length;
purchasedRows.push(cm);
html +=
'<div class="cm-purchased-item" data-purchased-index="' + index + '">' +
'<span>' + escapeHtml(cm.name) + '</span>' +
'<span class="cm-purchased-cost">' + escapeHtml(cm.points) + '</span>' +
'</div>';
});
html += '</div>';
});
if (!html) {
list.innerHTML = 'None purchased.';
return;
}
list.innerHTML = html;
Array.prototype.forEach.call(
list.querySelectorAll('.cm-purchased-item'),
function (row) {
var index = parseInt(row.getAttribute('data-purchased-index'), 10);
var cm = purchasedRows[index];
row.addEventListener('mouseenter', function () {
showCmTooltip(cm, row);
});
row.addEventListener('mouseleave', hideCmTooltip);
row.addEventListener('click', function () {
if (!cm) return;
delete selectedMasteries[cm.name];
hideCmTooltip();
updateSummary();
});
}
);
}
function updateUnlockedSummary() {
var summary = document.getElementById('cm-unlocked-summary');
var statTotals = {};
var spellsAdded = {};
var spellsRemoved = {};
var hasStats = false;
if (!summary) return;
Object.keys(selectedMasteries).forEach(function (name) {
var cm = selectedMasteries[name];
var stats;
if (!cm) return;
stats = cm.stats || {};
Object.keys(stats).forEach(function (statName) {
var value = parseInt(stats[statName], 10);
if (!isNaN(value) && value !== 0) {
if (!statTotals[statName]) {
statTotals[statName] = 0;
}
statTotals[statName] += value;
hasStats = true;
}
});
safeArray(cm.spellsAdded).forEach(function (spell) {
if (spell) {
spellsAdded[spell] = true;
}
});
safeArray(cm.spellsRemoved).forEach(function (spell) {
if (spell) {
spellsRemoved[spell] = true;
}
});
});
var html = '';
html += '<div class="cm-unlocked-grid cm-unlocked-grid-three">';
html += '<div class="cm-unlocked-box">';
html += '<div class="cm-panel-title">Stats</div>';
if (!hasStats) {
html += '<div class="cm-empty-line">No stat changes yet.</div>';
} else {
Object.keys(statTotals).sort().forEach(function (statName) {
var value = statTotals[statName];
var sign = value > 0 ? '+' : '';
html +=
'<div class="cm-purchased-item cm-stat-row">' +
'<span>' + escapeHtml(formatStatName(statName)) + '</span>' +
'<span class="cm-purchased-cost">' + sign + escapeHtml(value) + '</span>' +
'</div>';
});
}
html += '</div>';
html += '<div class="cm-unlocked-box">';
html += '<div class="cm-panel-title">Spells Gained</div>';
if (!Object.keys(spellsAdded).length) {
html += '<div class="cm-empty-line">No spells gained yet.</div>';
} else {
Object.keys(spellsAdded).sort().forEach(function (spell) {
html +=
'<div class="cm-purchased-item cm-spell-row cm-spell-gained">' +
'<span>+ ' + escapeHtml(spell) + '</span>' +
'</div>';
});
}
html += '</div>';
html += '<div class="cm-unlocked-box">';
html += '<div class="cm-panel-title">Spells Removed</div>';
if (!Object.keys(spellsRemoved).length) {
html += '<div class="cm-empty-line">No spells removed yet.</div>';
} else {
Object.keys(spellsRemoved).sort().forEach(function (spell) {
html +=
'<div class="cm-purchased-item cm-spell-row cm-spell-removed">' +
'<span>- ' + escapeHtml(spell) + '</span>' +
'</div>';
});
}
html += '</div>';
html += '</div>';
summary.innerHTML = html;
}
function updateSummary() {
var available = getAvailablePoints();
var spent = getSpentPoints();
var remaining = available - spent;
document.getElementById('cm-available').textContent = available;
document.getElementById('cm-spent').textContent = spent;
document.getElementById('cm-remaining').textContent = remaining;
updatePurchasedList();
updateMasterClassPanel();
updateUnlockedSummary();
renderMasteryList();
}
function buildCmTooltipHtml(cm) {
var statLines = [];
var spellAdds;
var spellRemoves;
var reqHtml = '';
var statMinHtml = '';
var statMinReqHtml = '';
if (!cm) return '';
Object.keys(cm.stats || {}).forEach(function (key) {
var value = cm.stats[key];
if (value) {
var sign = value > 0 ? '+' : '';
statLines.push(
'<div class="cm-tooltip-stat">' +
escapeHtml(formatStatName(key)) + ': ' + sign + escapeHtml(value) +
'</div>'
);
}
});
spellAdds = safeArray(cm.spellsAdded).map(function (spell) {
return '<div class="cm-tooltip-spell-add">+ ' + escapeHtml(spell) + '</div>';
}).join('');
spellRemoves = safeArray(cm.spellsRemoved).map(function (spell) {
return '<div class="cm-tooltip-spell-remove">- ' + escapeHtml(spell) + '</div>';
}).join('');
if (safeArray(cm.requirements).length) {
reqHtml =
'<div class="cm-tooltip-req">' +
'Requires: ' + getRequirementsHtml(cm) +
'</div>';
}
statMinHtml = getStatMinRequirementsHtml(cm);
if (statMinHtml) {
statMinReqHtml =
'<div class="cm-tooltip-req">' +
'Stat Req: ' + statMinHtml +
'</div>';
}
return (
'<div class="cm-tooltip-title">' + escapeHtml(cm.name) + '</div>' +
'<div class="cm-tooltip-desc">' + escapeHtml(cm.description) + '</div>' +
'<div class="cm-tooltip-meta">' +
'Level ' + escapeHtml(cm.level) +
' | Cost ' + escapeHtml(cm.points) +
(cm.cmSpentReq ? ' | CM Spent Req ' + escapeHtml(cm.cmSpentReq) : '') +
'</div>' +
reqHtml +
statMinReqHtml +
statLines.join('') +
spellAdds +
spellRemoves
);
}
function showCmTooltip(cm, card) {
if (!cmTooltip || !cm) return;
cmTooltip.innerHTML = buildCmTooltipHtml(cm);
cmTooltip.style.display = 'block';
var rect = card.getBoundingClientRect();
var tooltipWidth = 340;
var leftPos = rect.right + 12;
if (leftPos + tooltipWidth > window.innerWidth) {
leftPos = rect.left - tooltipWidth - 12;
}
cmTooltip.style.left = leftPos + 'px';
cmTooltip.style.top = Math.max(10, rect.top) + 'px';
}
function hideCmTooltip() {
if (cmTooltip) {
cmTooltip.style.display = 'none';
}
}
function renderMasteryList() {
var masteryPanel = document.getElementById('cm-mastery-panel');
if (!masteryPanel) return;
var selectedRace = raceSelect.value;
var selectedRaceCode = (data.raceCodes || {})[selectedRace];
var selectedClass = classSelect.value;
var selectedPath = pathSelect ? pathSelect.value : '';
var selectedPathNormalized = normalizePath(selectedPath);
var selectedIsWere = isWerePath(selectedPathNormalized);
var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : '';
var seenMasteries = {};
var availableMasteries = [];
var lockedMasteries = [];
visibleMasteries = [];
safeArray(data.masteries).forEach(function (cm) {
var cmRaces = safeArray(cm.races);
var cmClasses = safeArray(cm.classes);
var cmName = String(cm.name || '');
var cmPath = normalizePath(cm.lycanthropy);
var bitePath = getBitePath(cmName);
var pathOk = false;
var raceOk =
!cmRaces.length ||
cmRaces.indexOf('All') !== -1 ||
cmRaces.indexOf(selectedRaceCode) !== -1;
var classOk =
!cmClasses.length ||
cmClasses.indexOf('All') !== -1 ||
cmClasses.indexOf(selectedClass) !== -1;
var tabOk = getCmDisplayTab(cm) === activeCmTab;
var searchOk =
!searchQuery ||
getCmSearchText(cm).indexOf(searchQuery) !== -1;
if (bitePath) {
pathOk = selectedPathNormalized === bitePath;
} else if (!cmPath) {
pathOk = true;
} else if (!selectedPathNormalized) {
pathOk = false;
} else if (selectedIsWere && isSharedWereMastery(cmName)) {
pathOk = cmPath === 'werebeast' || isWerePath(cmPath);
} else if (selectedIsWere && cmPath === 'werebeast') {
pathOk = true;
} else {
pathOk = cmPath === selectedPathNormalized;
}
if (
!raceOk ||
!classOk ||
!tabOk ||
!pathOk ||
!searchOk ||
selectedMasteries[cmName] ||
seenMasteries[cmName]
) {
return;
}
seenMasteries[cmName] = true;
var unlockHint = getCmUnlockHint(cm);
if (unlockHint) {
lockedMasteries.push({
cm: cm,
locked: true,
unlockHint: unlockHint
});
} else {
availableMasteries.push({
cm: cm,
locked: false,
unlockHint: ''
});
}
});
if (showLockedCms) {
visibleMasteries = availableMasteries.concat(lockedMasteries);
} else {
visibleMasteries = availableMasteries;
}
if (!visibleMasteries.length) {
masteryPanel.innerHTML = 'No Class Masteries found for this selection.';
return;
}
masteryPanel.innerHTML = visibleMasteries.map(function (entry, index) {
var cm = entry.cm;
var lockedClass = entry.locked ? ' cm-card-locked' : '';
var typeClass = getCmTypeClass(cm);
var cmType = getCmType(cm);
var requirementsHtml = getRequirementsHtml(cm);
var statMinHtml = getStatMinRequirementsHtml(cm);
var chainHtml = getCmChainHtml(cm);
var lockReasonsHtml = '';
if (entry.locked) {
lockReasonsHtml =
'<div class="cm-unlock-hint">' +
'<strong>Locked:</strong><br>' +
getCmLockReasons(cm).map(function (reason) {
return escapeHtml(reason);
}).join('<br>') +
'</div>';
}
var metaHtml =
'<div class="cm-card-meta">' +
'Level: ' + escapeHtml(cm.level) +
' | Cost: ' + escapeHtml(cm.points) +
(cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') +
'</div>';
if (activeCmView === 'compact') {
return (
'<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
'<div class="cm-card-compact-row">' +
'<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
'<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
metaHtml +
'</div>' +
(statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
(chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
lockReasonsHtml
+
'</div>'
);
}
return (
'<div class="cm-card cm-purchasable-card ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' +
'<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' +
'<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' +
'<div class="cm-card-desc">' + escapeHtml(cm.description) + '</div>' +
metaHtml +
(requirementsHtml ? '<div class="cm-card-req">Requires: ' + requirementsHtml + '</div>' : '') +
(statMinHtml ? '<div class="cm-card-req">Stat Req: ' + statMinHtml + '</div>' : '') +
(chainHtml ? '<div class="cm-chain-preview">Chain: ' + chainHtml + '</div>' : '') +
lockReasonsHtml
+
'</div>'
);
}).join('');
Array.prototype.forEach.call(
masteryPanel.querySelectorAll('.cm-purchasable-card'),
function (card) {
var cmIndex = parseInt(card.getAttribute('data-cm-index'), 10);
var entry = visibleMasteries[cmIndex];
var cm = entry ? entry.cm : null;
card.addEventListener('mouseenter', function () {
showCmTooltip(cm, card);
});
card.addEventListener('mouseleave', hideCmTooltip);
card.addEventListener('click', function () {
var available;
var spent;
var selectedMasterClassTitle;
if (!cm || !entry) return;
if (entry.locked) {
alert(entry.unlockHint);
return;
}
var purchaseReasons = getCmLockReasons(cm, {
includePointBudget: true
});
if (purchaseReasons.length) {
alert(purchaseReasons.join(' | '));
return;
}
if (isMasterClassTitle(cm)) {
selectedMasterClassTitle = getSelectedMasterClassTitle();
if (selectedMasterClassTitle && selectedMasterClassTitle !== cm.name) {
alert('You can only select one Master Class Title per character build. Remove ' + selectedMasterClassTitle + ' first.');
return;
}
}
selectedMasteries[cm.name] = cm;
hideCmTooltip();
updateSummary();
});
}
);
}
raceSelect.addEventListener('change', loadClasses);
classSelect.addEventListener('change', updateSummary);
levelInput.addEventListener('input', updateSummary);
[
'cm-base-str',
'cm-base-sta',
'cm-base-dex',
'cm-base-agi',
'cm-base-int',
'cm-base-wis',
'cm-base-cha'
].forEach(function (id) {
var input = document.getElementById(id);
if (input) {
input.addEventListener('input', updateSummary);
}
});
if (searchInput) {
searchInput.addEventListener('input', renderMasteryList);
}
if (clearSearchButton && searchInput) {
clearSearchButton.addEventListener('click', function () {
searchInput.value = '';
renderMasteryList();
searchInput.focus();
});
}
if (shareUrlButton) {
shareUrlButton.addEventListener('click', function () {
copyTextToClipboard(buildShareUrl(), shareUrlButton);
});
}
if (exportCardButton) {
exportCardButton.addEventListener('click', function () {
renderBuildCard();
});
}
if (showLockedInput) {
showLockedInput.addEventListener('change', function () {
showLockedCms = showLockedInput.checked;
renderMasteryList();
});
}
if (pathSelect) {
pathSelect.addEventListener('change', updateSummary);
}
Array.prototype.forEach.call(
document.querySelectorAll('.cm-tab-button'),
function (button) {
button.addEventListener('click', function () {
activeCmTab = button.getAttribute('data-tab');
Array.prototype.forEach.call(
document.querySelectorAll('.cm-tab-button'),
function (btn) {
btn.classList.remove('cm-tab-active');
}
);
button.classList.add('cm-tab-active');
updateSummary();
});
}
);
if (resetButton) {
resetButton.addEventListener('click', function () {
selectedMasteries = {};
activeCmTab = 'General';
activeCmView = 'detailed';
showLockedCms = true;
levelInput.value = 60;
setBaseStatInputs({});
if (pathSelect) {
pathSelect.value = '';
}
Array.prototype.forEach.call(
document.querySelectorAll('.cm-tab-button'),
function (btn) {
btn.classList.remove('cm-tab-active');
}
);
var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]');
if (generalTab) {
generalTab.classList.add('cm-tab-active');
}
Array.prototype.forEach.call(viewButtons, function (btn) {
btn.classList.remove('cm-view-active');
});
var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]');
if (detailedView) {
detailedView.classList.add('cm-view-active');
}
if (searchInput) {
searchInput.value = '';
}
if (showLockedInput) {
showLockedInput.checked = true;
}
hideCmTooltip();
updateSummary();
});
}
loadRaces();
loadBuildFromUrl();
}
});