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 1: | Line 1: | ||
mw.hook('wikipage.content').add(function () { | mw.hook('wikipage.content').add(function () { | ||
function waitForCMData(attemptsLeft) { | function waitForCMData(attemptsLeft) { | ||
var hasData = !!window.EQOA_CM_DATA; | |||
var hasMasteries = hasData && window.EQOA_CM_DATA.masteries && window.EQOA_CM_DATA.masteries.length; | |||
var hasRaw = !!window.EQOA_CM_RAW; | |||
) { | |||
if (hasData && (hasMasteries || hasRaw)) { | |||
initClassMasteryCalculator(); | |||
} | return; | ||
} | |||
if (attemptsLeft <= 0) { | if (attemptsLeft <= 0) { | ||
var app = document.getElementById('cm-calculator-app'); | var app = document.getElementById('cm-calculator-app'); | ||
if (app) { | if (app) { | ||
app.innerHTML = '<div style="color:red;"> | app.innerHTML = '<div style="color:red;">Class Mastery data failed to load.</div>'; | ||
} | } | ||
return; | return; | ||
| Line 22: | Line 23: | ||
} | } | ||
waitForCMData( | waitForCMData(80); | ||
function initClassMasteryCalculator() { | function initClassMasteryCalculator() { | ||
| Line 34: | Line 35: | ||
var showLockedCms = true; | var showLockedCms = true; | ||
var visibleMasteries = []; | var visibleMasteries = []; | ||
function safeArray(value) { | function safeArray(value) { | ||
| Line 64: | Line 54: | ||
} | } | ||
function | 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; | |||
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'), | |||
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 | function getBuilderState() { | ||
if ( | if ( | ||
window.EQOA_CHARACTER_BUILDER_STATE && | window.EQOA_CHARACTER_BUILDER_STATE && | ||
| Line 191: | Line 207: | ||
} | } | ||
function | 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), | STR: numberOrZero(baseStats.STR), | ||
STA: numberOrZero(baseStats.STA), | STA: numberOrZero(baseStats.STA), | ||
| Line 203: | Line 369: | ||
Object.keys(selectedMasteries).forEach(function (name) { | Object.keys(selectedMasteries).forEach(function (name) { | ||
var | var stats = selectedMasteries[name].stats || {}; | ||
result.STR += numberOrZero(stats.str); | result.STR += numberOrZero(stats.str); | ||
result.STA += numberOrZero(stats.sta); | result.STA += numberOrZero(stats.sta); | ||
| Line 231: | Line 395: | ||
Object.keys(map).forEach(function (stat) { | Object.keys(map).forEach(function (stat) { | ||
var input = document.getElementById(map[stat]); | var input = document.getElementById(map[stat]); | ||
if (input) input.value = numberOrZero(baseStats[stat]); | |||
if (input) | |||
}); | }); | ||
} | } | ||
function | function getAvailablePoints() { | ||
var builderState = | var builderState = getBuilderState(); | ||
var | 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. | if (builderState && builderState.cmAvailable !== undefined) { | ||
available = numberOrZero(builderState.cmAvailable); | |||
if (available < 0) available = 0; | |||
if (available > max) available = max; | |||
return available; | |||
} | } | ||
return | return max; | ||
} | } | ||
function | function getSpentPoints() { | ||
var | var total = 0; | ||
function | 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 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; | |||
} | } | ||
app.innerHTML = | app.innerHTML = | ||
| Line 450: | Line 597: | ||
'<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' + | '<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' + | ||
'<table class="character-builder-stat-table">' + | '<table class="character-builder-stat-table">' + | ||
'<tr> | '<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>' + | |||
'<tr> | |||
'</table>' + | '</table>' + | ||
'<div class="character-builder-section-title" style="margin-top:18px;">Master Class Title</div>' + | '<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">None selected</div>' + | |||
'<div id="cm-master-class-panel" class="cm-master-class-panel | |||
'<div class="cm-build-actions">' + | '<div class="cm-build-actions">' + | ||
| Line 474: | Line 609: | ||
'<button type="button" id="cm-export-card" class="cm-action-button">Export Build Card</button>' + | '<button type="button" id="cm-export-card" class="cm-action-button">Export Build Card</button>' + | ||
'</div>' + | '</div>' + | ||
'<div class="character-builder-section-title" style="margin-top:18px;">Stats / Spells Gained from CM</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 id="cm-unlocked-summary"> | |||
'<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' + | '<div class="character-builder-section-title" style="margin-top:18px;">Class Masteries</div>' + | ||
'<div class="cm-tabs">' + | '<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 cm-tab-active" data-tab="General">General</button>' + | ||
| Line 495: | Line 624: | ||
'<div class="cm-search-row">' + | '<div class="cm-search-row">' + | ||
'<input type="search" id="cm-search" class="cm-search-input" placeholder="Search mastery, stat, spell, requirement...">' + | '<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 | '<button type="button" id="cm-clear-search" class="cm-clear-search-button">Clear</button>' + | ||
'</div>' + | '</div>' + | ||
'<label class="cm-lock-toggle"><input type="checkbox" id="cm-show-locked" checked> <span>Show locked</span></label>' + | |||
'<label class="cm-lock-toggle"> | |||
'</div>' + | '</div>' + | ||
| Line 514: | Line 639: | ||
'<div id="cm-purchased-list">None purchased.</div>' + | '<div id="cm-purchased-list">None purchased.</div>' + | ||
'</div>' + | '</div>' + | ||
'<div id="cm-mastery-panel" class="character-builder-warning-box">Select a race and class to begin.</div>' + | |||
'<div id="cm-mastery-panel" class="character-builder-warning-box"> | |||
'</div>' + | '</div>' + | ||
| Line 535: | Line 657: | ||
var shareUrlButton = document.getElementById('cm-share-url'); | var shareUrlButton = document.getElementById('cm-share-url'); | ||
var exportCardButton = document.getElementById('cm-export-card'); | var exportCardButton = document.getElementById('cm-export-card'); | ||
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 | function loadRaces() { | ||
raceSelect.innerHTML = ''; | |||
Object.keys(data.raceClasses || {}).forEach(function (race) { | |||
addOption(raceSelect, race, race); | |||
}); | }); | ||
loadClasses(); | |||
} | } | ||
function | function loadClasses() { | ||
var | 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 | function syncFromCharacterBuilder() { | ||
var | var builderState = getBuilderState(); | ||
if (! | if (!builderState) return; | ||
if (builderState.race && data.raceClasses[builderState.race]) { | |||
raceSelect.value = builderState.race; | |||
loadClasses(); | |||
if ( | |||
} | } | ||
if (builderState.classCode) { | |||
classSelect.value = builderState.classCode; | |||
} | } | ||
if ( | if (builderState.level !== undefined) { | ||
levelInput.value = builderState.level; | |||
} | } | ||
if ( | if (builderState.baseStats) { | ||
updateBaseStatInputsDisplay(builderState.baseStats); | |||
} | } | ||
updateSummary(); | |||
} | } | ||
window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER = syncFromCharacterBuilder; | |||
if (isEmbedded()) { | |||
raceSelect.disabled = true; | |||
classSelect.disabled = true; | |||
levelInput.disabled = true; | |||
} | } | ||
| Line 1,205: | Line 745: | ||
Object.keys(selectedMasteries).forEach(function (name) { | Object.keys(selectedMasteries).forEach(function (name) { | ||
var cm = selectedMasteries[name]; | var cm = selectedMasteries[name]; | ||
Object.keys(cm.stats || {}).forEach(function (statName) { | |||
var value = parseInt(cm.stats[statName], 10); | |||
var value = parseInt(stats[statName], 10); | |||
if (!isNaN(value) && value !== 0) { | if (!isNaN(value) && value !== 0) { | ||
statTotals[statName] = (statTotals[statName] || 0) + value; | |||
} | } | ||
}); | }); | ||
| Line 1,238: | Line 769: | ||
}; | }; | ||
} | } | ||
function updatePurchasedList() { | function updatePurchasedList() { | ||
var list = document.getElementById('cm-purchased-list'); | var list = document.getElementById('cm-purchased-list'); | ||
var | var names = Object.keys(selectedMasteries).sort(); | ||
if (!list) return; | if (!list) return; | ||
if (!names.length) { | |||
list.innerHTML = 'None purchased.'; | list.innerHTML = 'None purchased.'; | ||
return; | return; | ||
} | } | ||
list.innerHTML = | 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 = selectedTitle ? escapeHtml(selectedTitle) : 'None selected'; | |||
} | } | ||
function updateUnlockedSummary() { | function updateUnlockedSummary() { | ||
var summary = document.getElementById('cm-unlocked-summary'); | var summary = document.getElementById('cm-unlocked-summary'); | ||
var | var totals = getBuildTotals(); | ||
var html = '<div class="cm-unlocked-grid cm-unlocked-grid-three">'; | |||
var html = ' | |||
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>'; | |||
html += '<div class="cm-unlocked-box"> | |||
if (! | |||
html += '<div | |||
} else { | } else { | ||
Object.keys( | Object.keys(totals.stats).sort().forEach(function (statName) { | ||
var value = | var value = totals.stats[statName]; | ||
html += '<div class="cm-purchased-item"><span>' + | |||
escapeHtml(formatStatName(statName)) + | |||
html += | '</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>'; | ||
html += '<div class="cm-unlocked-box"> | 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>'; | |||
}); | |||
html += '<div class="cm- | if (!Object.keys(totals.spellsRemoved).length) html += '<div>No spells removed yet.</div>'; | ||
} | |||
html += '</div>'; | html += '</div>'; | ||
html += '</div>'; | html += '</div>'; | ||
| Line 1,974: | Line 852: | ||
var remaining = available - spent; | var remaining = available - spent; | ||
if ( | if (remaining < 0) remaining = 0; | ||
if (isEmbedded()) { | |||
updateBaseStatInputsDisplay(getBaseStatsFromInputs()); | updateBaseStatInputsDisplay(getBaseStatsFromInputs()); | ||
} | } | ||
| Line 1,988: | Line 868: | ||
} | } | ||
function | function buildTooltipHtml(cm) { | ||
var | 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) { | Object.keys(cm.stats || {}).forEach(function (key) { | ||
var value = cm.stats[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; | |||
return | |||
} | } | ||
function showCmTooltip(cm, card) { | function showCmTooltip(cm, card) { | ||
var rect; | |||
var tooltipWidth = 340; | |||
var leftPos; | |||
if (!cmTooltip || !cm) return; | if (!cmTooltip || !cm) return; | ||
cmTooltip.innerHTML = | cmTooltip.innerHTML = buildTooltipHtml(cm); | ||
cmTooltip.style.display = 'block'; | cmTooltip.style.display = 'block'; | ||
rect = card.getBoundingClientRect(); | |||
leftPos = rect.right + 12; | |||
if (leftPos + tooltipWidth > window.innerWidth) { | if (leftPos + tooltipWidth > window.innerWidth) { | ||
| Line 2,073: | Line 924: | ||
function hideCmTooltip() { | function hideCmTooltip() { | ||
if (cmTooltip) | if (cmTooltip) cmTooltip.style.display = 'none'; | ||
} | } | ||
function renderMasteryList() { | function renderMasteryList() { | ||
var masteryPanel = document.getElementById('cm-mastery-panel'); | var masteryPanel = document.getElementById('cm-mastery-panel'); | ||
var selectedRace = raceSelect.value; | var selectedRace = raceSelect.value; | ||
var selectedRaceCode = | var selectedRaceCode = getRaceCode(selectedRace); | ||
var selectedClass = classSelect.value; | var selectedClass = classSelect.value; | ||
var | var selectedPathNormalized = normalizePath(pathSelect ? pathSelect.value : ''); | ||
var selectedIsWere = isWerePath(selectedPathNormalized); | var selectedIsWere = isWerePath(selectedPathNormalized); | ||
var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : ''; | var searchQuery = searchInput ? String(searchInput.value || '').toLowerCase() : ''; | ||
var seen = {}; | |||
var | |||
var availableMasteries = []; | var availableMasteries = []; | ||
var lockedMasteries = []; | var lockedMasteries = []; | ||
if (!masteryPanel) return; | |||
visibleMasteries = []; | visibleMasteries = []; | ||
safeArray(data.masteries).forEach(function (cm) { | safeArray(data.masteries).forEach(function (cm) { | ||
var cmName = String(cm.name || ''); | var cmName = String(cm.name || ''); | ||
var cmPath = normalizePath(cm.lycanthropy); | var cmPath = normalizePath(cm.lycanthropy); | ||
| Line 2,107: | Line 952: | ||
var tabOk; | var tabOk; | ||
var searchOk; | var searchOk; | ||
var | var lockReasons; | ||
searchOk = | 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) { | if (bitePath) { | ||
| Line 2,142: | Line 980: | ||
!searchOk || | !searchOk || | ||
selectedMasteries[cmName] || | selectedMasteries[cmName] || | ||
seen[cmName] | |||
) { | ) { | ||
return; | return; | ||
} | } | ||
seen[cmName] = true; | |||
lockReasons = getCmLockReasons(cm, false); | |||
if ( | if (lockReasons.length) { | ||
lockedMasteries.push({ | lockedMasteries.push({ | ||
cm: cm, | cm: cm, | ||
locked: true, | locked: true, | ||
unlockHint: | unlockHint: lockReasons.join(' | ') | ||
}); | }); | ||
} else { | } else { | ||
| Line 2,166: | Line 1,004: | ||
}); | }); | ||
visibleMasteries = showLockedCms ? availableMasteries.concat(lockedMasteries) : availableMasteries; | |||
if (!visibleMasteries.length) { | if (!visibleMasteries.length) { | ||
masteryPanel.innerHTML = 'No Class Masteries found for this selection.'; | masteryPanel.innerHTML = | ||
'No Class Masteries found for this selection.<br>' + | |||
'<small>Loaded masteries: ' + safeArray(data.masteries).length + '</small>'; | |||
return; | return; | ||
} | } | ||
| Line 2,182: | Line 1,018: | ||
var typeClass = getCmTypeClass(cm); | var typeClass = getCmTypeClass(cm); | ||
var cmType = getCmType(cm); | var cmType = getCmType(cm); | ||
var | var reqHtml = getRequirementsHtml(cm); | ||
var | var statReqHtml = getStatReqHtml(cm); | ||
var | var lockHtml = ''; | ||
var metaHtml; | var metaHtml; | ||
if (entry.locked) { | if (entry.locked) { | ||
lockHtml = | |||
'<div class="cm-unlock-hint">' + | '<div class="cm-unlock-hint">' + | ||
'<strong>Locked:</strong><br>' + | '<strong>Locked:</strong><br>' + | ||
escapeHtml(entry.unlockHint).replace(/\|/g, '<br>') + | |||
'</div>'; | '</div>'; | ||
} | } | ||
| Line 2,204: | Line 1,037: | ||
(cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') + | (cm.cmSpentReq ? ' | CM Spent Req: ' + escapeHtml(cm.cmSpentReq) : '') + | ||
'</div>' + | '</div>' + | ||
'<div class="cm-card-meta">' + | '<div class="cm-card-meta">' + escapeHtml(getCmPointStatusText()) + '</div>'; | ||
if (activeCmView === 'compact') { | if (activeCmView === 'compact') { | ||
return | return '<div class="cm-card cm-purchasable-card cm-card-compact ' + typeClass + lockedClass + '" data-cm-index="' + index + '">' + | ||
'<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' + | '<div class="cm-card-title">' + escapeHtml(cm.name) + '</div>' + | ||
'<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' + | '<div class="cm-card-type-tag">' + escapeHtml(cmType) + '</div>' + | ||
metaHtml + | 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(''); | }).join(''); | ||
Array.prototype.forEach.call( | 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; | return; | ||
} | } | ||
} | |||
selectedMasteries[cm.name] = cm; | |||
hideCmTooltip(); | |||
updateSummary(); | |||
}); | |||
}); | |||
} | |||
function buildShareUrl() { | |||
return window.location.href.split('#')[0] + | |||
'#cm-build=' + | |||
encodeURIComponent(JSON.stringify({ | |||
r: raceSelect.value, | |||
c: classSelect.value, | |||
l: levelInput.value, | |||
p: pathSelect ? pathSelect.value : '', | |||
b: getBaseStatsFromInputs(), | |||
cms: Object.keys(selectedMasteries).sort() | |||
})); | |||
} | |||
function copyTextToClipboard(text, button) { | |||
if (navigator.clipboard && navigator.clipboard.writeText) { | |||
navigator.clipboard.writeText(text).then(function () { | |||
var oldText = button.textContent; | |||
button.textContent = 'Copied'; | |||
setTimeout(function () { | |||
button.textContent = oldText; | |||
}, 1200); | |||
}); | |||
} else { | |||
alert(text); | |||
} | |||
} | |||
function renderBuildCard() { | |||
alert('Export Build Card is temporarily disabled in this recovery version. Share URL still works.'); | |||
} | |||
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(); | |||
} | } | ||
| Line 2,302: | Line 1,194: | ||
].forEach(function (id) { | ].forEach(function (id) { | ||
var input = document.getElementById(id); | var input = document.getElementById(id); | ||
if (input) input.addEventListener('input', updateSummary); | |||
if (input) | |||
}); | }); | ||
| Line 2,317: | Line 1,206: | ||
renderMasteryList(); | renderMasteryList(); | ||
searchInput.focus(); | searchInput.focus(); | ||
}); | |||
} | |||
if (showLockedInput) { | |||
showLockedInput.addEventListener('change', function () { | |||
showLockedCms = showLockedInput.checked; | |||
renderMasteryList(); | |||
}); | }); | ||
} | } | ||
| Line 2,327: | Line 1,223: | ||
if (exportCardButton) { | if (exportCardButton) { | ||
exportCardButton.addEventListener('click', | exportCardButton.addEventListener('click', renderBuildCard); | ||
} | } | ||
| Line 2,343: | Line 1,230: | ||
} | } | ||
if ( | if (isEmbedded()) { | ||
window.addEventListener('eqoa-character-builder-state-change', syncFromCharacterBuilder); | window.addEventListener('eqoa-character-builder-state-change', syncFromCharacterBuilder); | ||
} | } | ||
Array.prototype.forEach.call( | Array.prototype.forEach.call(document.querySelectorAll('.cm-tab-button'), function (button) { | ||
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) { | if (resetButton) { | ||
| Line 2,373: | Line 1,266: | ||
activeCmView = 'detailed'; | activeCmView = 'detailed'; | ||
showLockedCms = true; | showLockedCms = true; | ||
levelInput.value = 60; | levelInput.value = 60; | ||
setBaseStatInputs({}); | setBaseStatInputs({}); | ||
if (pathSelect) | if (pathSelect) pathSelect.value = ''; | ||
if (searchInput) searchInput.value = ''; | |||
if (showLockedInput) showLockedInput.checked = true; | |||
Array.prototype.forEach.call( | Array.prototype.forEach.call(document.querySelectorAll('.cm-tab-button'), function (btn) { | ||
btn.classList.remove('cm-tab-active'); | |||
}); | |||
Array.prototype.forEach.call(viewButtons, function (btn) { | Array.prototype.forEach.call(viewButtons, function (btn) { | ||
| Line 2,397: | Line 1,281: | ||
}); | }); | ||
var generalTab = document.querySelector('.cm-tab-button[data-tab="General"]'); | |||
var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]'); | var detailedView = document.querySelector('.cm-view-button[data-view="detailed"]'); | ||
if ( | if (generalTab) generalTab.classList.add('cm-tab-active'); | ||
if (detailedView) detailedView.classList.add('cm-view-active'); | |||
hideCmTooltip(); | hideCmTooltip(); | ||
| Line 2,417: | Line 1,294: | ||
loadSavedCmTheme(); | loadSavedCmTheme(); | ||
window.addEventListener('eqoa-wiki-theme-change', function (event) { | window.addEventListener('eqoa-wiki-theme-change', function (event) { | ||
if (event.detail && event.detail.theme) { | |||
applyCmTheme(event.detail.theme); | |||
} | |||
}); | }); | ||
loadRaces(); | loadRaces(); | ||
if ( | if (isEmbedded()) { | ||
syncFromCharacterBuilder(); | |||
} else { | } else { | ||
loadBuildFromUrl(); | |||
} | } | ||
} | } | ||
}); | }); | ||
Revision as of 19:00, 16 May 2026
mw.hook('wikipage.content').add(function () {
function waitForCMData(attemptsLeft) {
var hasData = !!window.EQOA_CM_DATA;
var hasMasteries = hasData && window.EQOA_CM_DATA.masteries && window.EQOA_CM_DATA.masteries.length;
var hasRaw = !!window.EQOA_CM_RAW;
if (hasData && (hasMasteries || hasRaw)) {
initClassMasteryCalculator();
return;
}
if (attemptsLeft <= 0) {
var app = document.getElementById('cm-calculator-app');
if (app) {
app.innerHTML = '<div style="color:red;">Class Mastery data failed to load.</div>';
}
return;
}
setTimeout(function () {
waitForCMData(attemptsLeft - 1);
}, 100);
}
waitForCMData(80);
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 = [];
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'),
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 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 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;
}
app.innerHTML =
'<div class="cm-calculator-box">' +
'<div class="character-builder-title" style="display:none;">EQOA: Sandstorm Class Mastery Builder</div>' +
'<div class="character-builder-controls">' +
'<label>Race</label>' +
'<select id="cm-race"></select>' +
'<label>Class</label>' +
'<select id="cm-class"></select>' +
'<label>Character Level</label>' +
'<input type="number" id="cm-level" min="1" max="60" value="60" style="width:70px;">' +
'<label>Base Stats</label>' +
'<span>' +
'STR <input type="number" id="cm-base-str" min="0" value="0" style="width:55px;"> ' +
'STA <input type="number" id="cm-base-sta" min="0" value="0" style="width:55px;"> ' +
'DEX <input type="number" id="cm-base-dex" min="0" value="0" style="width:55px;"> ' +
'AGI <input type="number" id="cm-base-agi" min="0" value="0" style="width:55px;"> ' +
'INT <input type="number" id="cm-base-int" min="0" value="0" style="width:55px;"> ' +
'WIS <input type="number" id="cm-base-wis" min="0" value="0" style="width:55px;"> ' +
'CHA <input type="number" id="cm-base-cha" min="0" value="0" style="width:55px;">' +
'</span>' +
'<label>Transformation Path</label>' +
'<select id="cm-path">' +
'<option value="">None</option>' +
'<option value="Werelion">Lionwere</option>' +
'<option value="Werebear">Bearwere</option>' +
'<option value="Werewolf">Wolfwere</option>' +
'<option value="Wererat">Ratwere</option>' +
'<option value="Weregator">Gatorwere</option>' +
'<option value="Vampire">Vampire</option>' +
'<option value="Were Hunter">Were-Hunter</option>' +
'</select>' +
'<button type="button" id="cm-reset" class="cm-reset-button">Reset</button>' +
'</div>' +
'<div class="character-builder-section-title" style="margin-top:15px;">Point Summary</div>' +
'<table class="character-builder-stat-table">' +
'<tr><th>Available</th><th>Spent</th><th>Remaining</th></tr>' +
'<tr><td id="cm-available">1500</td><td id="cm-spent">0</td><td id="cm-remaining">1500</td></tr>' +
'</table>' +
'<div class="character-builder-section-title" style="margin-top:18px;">Master Class Title</div>' +
'<div id="cm-master-class-panel" class="cm-master-class-panel">None selected</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 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');
var shareUrlButton = document.getElementById('cm-share-url');
var exportCardButton = document.getElementById('cm-export-card');
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);
}
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 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 = selectedTitle ? escapeHtml(selectedTitle) : 'None selected';
}
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();
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(pathSelect ? pathSelect.value : '');
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);
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;
}
}
selectedMasteries[cm.name] = cm;
hideCmTooltip();
updateSummary();
});
});
}
function buildShareUrl() {
return window.location.href.split('#')[0] +
'#cm-build=' +
encodeURIComponent(JSON.stringify({
r: raceSelect.value,
c: classSelect.value,
l: levelInput.value,
p: pathSelect ? pathSelect.value : '',
b: getBaseStatsFromInputs(),
cms: Object.keys(selectedMasteries).sort()
}));
}
function copyTextToClipboard(text, button) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
var oldText = button.textContent;
button.textContent = 'Copied';
setTimeout(function () {
button.textContent = oldText;
}, 1200);
});
} else {
alert(text);
}
}
function renderBuildCard() {
alert('Export Build Card is temporarily disabled in this recovery version. Share URL still works.');
}
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 (shareUrlButton) {
shareUrlButton.addEventListener('click', function () {
copyTextToClipboard(buildShareUrl(), shareUrlButton);
});
}
if (exportCardButton) {
exportCardButton.addEventListener('click', renderBuildCard);
}
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 = {};
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();
}
}
});