MediaWiki:ClassMasteryCalculator.js
From EQOA Sandstorm Wiki
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
- Opera: Press Ctrl-F5.
mw.hook('wikipage.content').add(function () {
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 addStat(stats, cols, header, key) {
var value = parseInt(get(cols, header), 10);
if (!isNaN(value) && value !== 0) {
stats[key] = value;
}
}
lines.forEach(function (line, index) {
if (headerLineIndex === -1 && line.indexOf('Mastery\tDescription') === 0) {
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 = {};
if (!line.trim()) return;
cols = line.split('\t');
name = get(cols, 'Mastery');
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, 'STR Max', 'strMax');
addStat(stats, cols, 'STA Max', 'staMax');
addStat(stats, cols, 'AGI Max', 'agiMax');
addStat(stats, cols, 'DEX Max', 'dexMax');
addStat(stats, cols, 'WIS Max', 'wisMax');
addStat(stats, cols, 'INT Max', 'intMax');
addStat(stats, cols, '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({
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')),
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;
}
if ((!data.masteries || !data.masteries.length) && window.EQOA_CM_RAW) {
data.masteries = buildMasteriesFromRaw(window.EQOA_CM_RAW);
}
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',
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 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 findCmByName(name) {
var found = null;
safeArray(data.masteries).some(function (cm) {
if (cm.name === name) {
found = cm;
return true;
}
return false;
});
return found;
}
function applySharedClassMasteriesFromBuilder(builderState) {
var names;
if (!builderState || sharedCmBuildApplied) return;
names = safeArray(builderState.selectedClassMasteries);
if (!names.length) return;
selectedMasteries = {};
names.forEach(function (name) {
var cm = findCmByName(name);
if (cm) {
selectedMasteries[cm.name] = cm;
}
});
sharedCmBuildApplied = true;
}
function isMasterClassTitle(cm) {
return cm && cm.classification === 'Master Class';
}
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 getPathFlag(cm) {
var found = '';
getCmPlayerFlags(cm).forEach(function (flag) {
var text = String(flag || '').trim().toLowerCase();
if (
!found &&
(
text.indexOf('exemplar') !== -1 ||
text.indexOf('path') !== -1 ||
text.indexOf('quietblade') !== -1 ||
text.indexOf('laughingknives') !== -1
)
) {
found = flag;
}
});
return found;
}
function isClassPathCm(cm) {
var name = String((cm && cm.name) || '').toLowerCase();
if (!cm) return false;
if (getPathFlag(cm)) return true;
return getCmType(cm) === 'Class' &&
getRomanRank(cm.name) > 0 &&
(
name.indexOf('dance of the laughing knives') !== -1 ||
name.indexOf('kiss of the quiet blade') !== -1
);
}
function getClassPathKey(cm) {
var flag = getPathFlag(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">None selected</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">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(),
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).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 names = Object.keys(selectedMasteries).sort();
if (!list) return;
if (!names.length) {
list.innerHTML = 'None purchased.';
return;
}
list.innerHTML = names.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(list.querySelectorAll('.cm-purchased-item'), function (row) {
row.addEventListener('click', function () {
delete selectedMasteries[row.getAttribute('data-cm-name')];
updateSummary();
});
});
}
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) : 'None selected') +
'</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 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 +
(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 +
(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 = findCmByName(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();
});
});
if (resetButton) {
resetButton.addEventListener('click', function () {
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();
});
}
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();
}
}
});