MediaWiki:CharacterBuilder.js: Difference between revisions
From EQOA Sandstorm Wiki
Jump to navigationJump to search
RenodorDev (talk | contribs) No edit summary |
DevRenodor (talk | contribs) No edit summary |
||
| (215 intermediate revisions by 2 users not shown) | |||
| Line 1: | Line 1: | ||
mw.hook('wikipage.content').add(function () { | mw.hook('wikipage.content').add(function () { | ||
function loadGeneratedItemChunks() { | |||
var chunkNumbers = []; | |||
var i; | |||
var chunkCount = 5; | |||
var version = '20260601items5rangefix'; | |||
for (i = 1; i <= chunkCount; i++) { | |||
chunkNumbers.push(i); | |||
} | |||
window.EQOA_GENERATED_ITEM_CHUNKS = []; | |||
window.EQOA_GENERATED_ITEMS = []; | |||
return Promise.all(chunkNumbers.map(function (chunkNumber) { | |||
return mw.loader.getScript( | |||
mw.config.get('wgScriptPath') + | |||
'/index.php?title=MediaWiki:CharacterBuilderGeneratedItems' + | |||
chunkNumber + | |||
'.js&action=raw&ctype=text/javascript&v=' + | |||
version | |||
); | |||
})).then(function () { | |||
window.EQOA_GENERATED_ITEMS = [].concat.apply( | |||
[], | |||
window.EQOA_GENERATED_ITEM_CHUNKS || [] | |||
).filter(Boolean); | |||
console.log( | |||
'EQOA generated items loaded:', | |||
window.EQOA_GENERATED_ITEMS.length, | |||
'chunks:', | |||
window.EQOA_GENERATED_ITEM_CHUNKS.length | |||
); | |||
console.log( | |||
'Throw items loaded:', | |||
window.EQOA_GENERATED_ITEMS.filter(function (item) { | |||
return /throw/i.test(String(item.name || '')); | |||
}).map(function (item) { | |||
return item.name + ' / ' + item.slot; | |||
}) | |||
); | |||
window.dispatchEvent(new CustomEvent('eqoa-generated-items-ready')); | |||
}); | |||
} | |||
function waitForBuilderData(attemptsLeft) { | function waitForBuilderData(attemptsLeft) { | ||
if (window.EQOA_BUILDER_DATA) { | if (window.EQOA_BUILDER_DATA && window.EQOA_GENERATED_ITEMS) { | ||
initCharacterBuilder(); | initCharacterBuilder(); | ||
return; | return; | ||
| Line 20: | Line 67: | ||
} | } | ||
waitForBuilderData( | loadGeneratedItemChunks().then(function () { | ||
waitForBuilderData(100); | |||
}).catch(function (error) { | |||
console.error('Generated item chunks failed to load.', error); | |||
var app = document.getElementById('character-builder-app'); | |||
if (app) { | |||
app.innerHTML = '<div style="color:red;">Generated item chunks failed to load.</div>'; | |||
} | |||
}); | |||
function initCharacterBuilder() { | function initCharacterBuilder() { | ||
| Line 29: | Line 85: | ||
var equippedItems = {}; | var equippedItems = {}; | ||
var itemCache = {}; | var itemCache = {}; | ||
var pendingSharedBuild = null; | |||
var sharedBuildApplied = false; | |||
var missingSharedItems = []; | |||
var sharedClassMasteries = []; | |||
var classPathChoices = {}; | |||
var statKeys = [ | var statKeys = [ | ||
"str", "sta", "agi", "dex", "wis", "int", "cha", | "lvl", "str", "sta", "agi", "dex", "wis", "int", "cha", | ||
"hp", "pow", "ac", "pr", "dr", "fr", "cr", "lr", "ar", | "hp", "pow", "ac", "pr", "dr", "fr", "cr", "lr", "ar", | ||
"dmg", "hot", "pot", "dur" | "dmg", "hot", "pot", "dur" | ||
]; | ]; | ||
var gearSlots = [ | var textKeys = [ | ||
"effect", "proc", "weaponstyle", | |||
"droppedby", "zone", "droprate" | |||
]; | |||
var trainingStats = { | |||
str: 0, | |||
sta: 0, | |||
agi: 0, | |||
dex: 0, | |||
wis: 0, | |||
int: 0, | |||
cha: 0 | |||
}; | |||
var gearSlots = [ | |||
"Head", "Chest", "Robe", "Hands", "Legs", "Feet", | |||
"Forearm", "Bracelet1", "Bracelet2", | |||
"Waist", "Neck", "Ear1", "Ear2", | |||
"Ring1", "Ring2", "Primary", "Secondary", | |||
"Shield", "Held1", "Held2", "Range" | |||
]; | |||
var classPathTiers = [20, 30, 40, 49, 60]; | |||
var classPathOptionsByClass = { | |||
WAR: ['Bellow', 'Taunt'], | |||
PAL: ['Barrier', 'Taunt'], | |||
SK: ['Blood', 'Taunt'], | |||
CL: ['Stack Heal', 'Resist Buff'], | |||
DRD: ['AoE Stack', 'FR Debuff'], | |||
SHA: ['Pet Summon', 'Debuff'], | |||
BRD: ['Boon', 'Combat'], | |||
RAN: ['Melee Buff', 'Range Buff'], | |||
RGE: ['Wound', 'Avoidance'], | |||
ROG: ['Wound', 'Avoidance'], | |||
MNK: ['Melee Buff', 'Defense Buff'], | |||
ALC: ['Cocktails', 'Knack'], | |||
ENC: ['Boon', 'Mind'], | |||
MAG: ['Pet Buff', 'Stones'], | |||
NEC: ['Blood', 'Power'], | |||
WIZ: ['Clash', 'Power'] | |||
}; | |||
var classPathOptionsByClass = { | |||
WAR: ['Bellow', 'Taunt'], | |||
PAL: ['Barrier', 'Taunt'], | |||
SK: ['Blood', 'Taunt'], | |||
CL: ['Stack Heal', 'Resist Buff'], | |||
DRD: ['AoE Stack', 'FR Debuff'], | |||
SHA: ['Pet Summon', 'Debuff'], | |||
BRD: ['Boon', 'Combat'], | |||
RAN: ['Melee Buff', 'Range Buff'], | |||
RGE: ['Wound', 'Avoidance'], | |||
ROG: ['Wound', 'Avoidance'], | |||
MNK: ['Melee Buff', 'Defense Buff'], | |||
ALC: ['Cocktails', 'Knack'], | |||
ENC: ['Boon', 'Mind'], | |||
MAG: ['Pet Buff', 'Stones'], | |||
NEC: ['Blood', 'Power'], | |||
WIZ: ['Clash', 'Power'] | |||
}; | |||
function getTrainingPointsForLevel(level) { | |||
level = parseInt(level, 10); | |||
if (isNaN(level) || level < 1) level = 1; | |||
if (level > 60) level = 60; | |||
var tp = 20; | |||
for (var lvl = 2; lvl <= level; lvl++) { | |||
if (lvl <= 14) tp += 3; | |||
else if (lvl <= 29) tp += 5; | |||
else if (lvl <= 44) tp += 9; | |||
else if (lvl <= 59) tp += 14; | |||
else if (lvl === 60) tp += 3; | |||
} | |||
return tp; | |||
} | |||
app.innerHTML = ` | app.innerHTML = ` | ||
<div class="character-builder-box"> | <div class="character-builder-box"> | ||
<div class="character-builder-title">EQOA Character Builder</div> | |||
<div class="character-builder-title-row"> | |||
<div class="character-builder-title">EQOA: Sandstorm Character Builder</div> | |||
<button id="cb-theme-toggle" type="button"> | |||
Toggle Theme | |||
</button> | |||
</div> | |||
<div class="cb-builder-tabs"> | |||
<button type="button" class="cb-tab-button cb-tab-active" data-cb-tab="equipment">Equipment</button> | |||
<button type="button" class="cb-tab-button" data-cb-tab="class-mastery">Class Mastery</button> | |||
</div> | |||
<div id="cb-tab-equipment" class="cb-tab-panel cb-tab-panel-active"> | |||
<div class="character-builder-controls"> | <div class="character-builder-controls"> | ||
<label>Race</label> | <label>Race</label> | ||
<select id="cb- | <select id="cb-race"></select> | ||
<label>Class</label> | |||
<select id="cb-class"></select> | |||
<label>Level</label> | |||
<input type="number" id="cb-level" min="1" max="60" value="60" style="width:70px;"> | |||
<label>Available CMs</label> | |||
<input type="number" id="cb-cm-points" min="0" max="1500" value="1500" style="width:80px;"> | |||
<br><label>Transformation Path</label> | |||
<select id="cb-transformation-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> | |||
<div class="cb-class-path-controls"> | |||
<label>Class Path 20</label> | |||
<select id="cb-class-path-20"></select> | |||
<label>Class Path 30</label> | |||
<select id="cb-class-path-30"></select> | |||
<label>Class Path 40</label> | |||
<select id="cb-class-path-40"></select> | |||
<label>Class Path 49</label> | |||
<select id="cb-class-path-49"></select> | |||
<label>Class Path 60</label> | |||
<select id="cb-class-path-60"></select> | |||
</div> | |||
<label> | <label>Build Name</label> | ||
< | <input type="text" id="cb-build-name" placeholder="Lv60 Ogre SK Raid Tank" style="width:220px;"> | ||
<label>Build Notes</label> | |||
<textarea id="cb-build-notes" placeholder="Example: Raid tank setup focused on HP, AC, and disease resist." rows="3" style="width:260px;"></textarea> | |||
<button id="cb- | <button id="cb-generate-share-url" type="button">Generate Share URL</button> | ||
<button id="cb-copy-share-url" type="button">Copy URL</button> | |||
<button id="cb-reset-build" type="button">Reset Build</button> | |||
<button id="cb-export-card" type="button">Export Build Card</button> | |||
</div> | </div> | ||
<div class="character-builder-section-title" style="margin-top:15px;">Compare Shared Builds</div> | |||
<div class="character-builder-controls"> | |||
<label>Build URL 1</label> | |||
<input type="text" id="cb-compare-url-a" placeholder="Paste first shared build URL"> | |||
<label>Build URL 2</label> | |||
<input type="text" id="cb-compare-url-b" placeholder="Paste second shared build URL"> | |||
<button id="cb-compare-builds" type="button">Compare Builds</button> | |||
</div> | |||
<div id="cb-compare-results" class="character-builder-warning-box" style="display:none; margin-top:10px;"></div> | |||
<div id="cb-warning-box" class="character-builder-warning-box" style="display:none;"></div> | |||
<div id="cb-share-url-box" class="character-builder-warning-box" style="display:none; margin-top:10px;"> | |||
<b>Share Build URL</b><br> | |||
<input type="text" id="cb-share-url-output" readonly style="width:100%; box-sizing:border-box; margin-top:6px;"> | |||
<div id="cb-share-url-status" style="margin-top:5px;"></div> | |||
</div> | |||
<div class="character-builder-section-title" style="margin-top:18px;">Character Summary</div> | |||
<div class="cb-ingame-stat-panel"> | |||
<div class="cb-ingame-stat-column"> | |||
<div class="cb-ingame-row cb-ingame-header-row"> | |||
<span>Training Points</span> | |||
<span></span> | |||
<span id="cb-tp-remaining">482</span> | |||
</div> | |||
<span id="cb-tp-available" style="display:none;">482</span> | |||
<span id="cb-tp-spent" style="display:none;">0</span> | |||
<div class="cb-ingame-row"> | |||
<span>Strength</span> | |||
<input type="number" id="train-str" min="0" value="0"> | |||
<span id="cb-str">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Stamina</span> | |||
<input type="number" id="train-sta" min="0" value="0"> | |||
<span id="cb-sta">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Agility</span> | |||
<input type="number" id="train-agi" min="0" value="0"> | |||
<span id="cb-agi">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Dexterity</span> | |||
<input type="number" id="train-dex" min="0" value="0"> | |||
<span id="cb-dex">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Wisdom</span> | |||
<input type="number" id="train-wis" min="0" value="0"> | |||
<span id="cb-wis">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Intelligence</span> | |||
<input type="number" id="train-int" min="0" value="0"> | |||
<span id="cb-int">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Charisma</span> | |||
<input type="number" id="train-cha" min="0" value="0"> | |||
<span id="cb-cha">0</span> | |||
</div> | |||
</div> | |||
<div class="cb-ingame-stat-column"> | |||
<div class="cb-ingame-row"> | |||
<span>AC</span> | |||
<span></span> | |||
<span id="cb-ac">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>HP</span> | |||
<span></span> | |||
<span id="cb-hp">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Power</span> | |||
<span></span> | |||
<span id="cb-pow">0</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Fire</span> | |||
<span></span> | |||
<span id="cb-fr">48</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Ice</span> | |||
<span></span> | |||
<span id="cb-cr">48</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Lightning</span> | |||
<span></span> | |||
<span id="cb-lr">48</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Arcane</span> | |||
<span></span> | |||
<span id="cb-ar">48</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Poison</span> | |||
<span></span> | |||
<span id="cb-pr">48</span> | |||
</div> | |||
<div class="cb-ingame-row"> | |||
<span>Disease</span> | |||
<span></span> | |||
<span id="cb-dr">48</span> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="character-builder-section-title"> | |||
<div class="character-builder-section-title" style="margin-top:18px;">Weapon Summary</div> | |||
<table class="character-builder-stat-table"> | <table class="character-builder-stat-table"> | ||
<tr> | <tr> | ||
<th> | <th>Slot</th> | ||
<th> | <th>Weapon</th> | ||
<th> | <th>DMG</th> | ||
<th> | <th>Style</th> | ||
<th> | <th>Proc / Effect</th> | ||
</tr> | </tr> | ||
<tr> | <tr> | ||
<td | <td>Primary</td> | ||
<td id="cb- | <td id="cb-weapon-primary-name">-</td> | ||
<td id="cb- | <td id="cb-weapon-primary-dmg">-</td> | ||
<td id="cb- | <td id="cb-weapon-primary-style">-</td> | ||
<td id="cb- | <td id="cb-weapon-primary-effect">-</td> | ||
<td | </tr> | ||
<td id="cb- | <tr> | ||
<td id="cb- | <td>Secondary</td> | ||
<td id="cb- | <td id="cb-weapon-secondary-name">-</td> | ||
<td id="cb- | <td id="cb-weapon-secondary-dmg">-</td> | ||
<td id="cb-weapon-secondary-style">-</td> | |||
<td | <td id="cb-weapon-secondary-effect">-</td> | ||
<td id="cb- | </tr> | ||
<td id="cb- | <tr> | ||
<td id="cb- | <td>Range</td> | ||
<td id="cb- | <td id="cb-weapon-range-name">-</td> | ||
<td id="cb-weapon-range-dmg">-</td> | |||
<td id="cb-weapon-range-style">-</td> | |||
<td id="cb-weapon-range-effect">-</td> | |||
</tr> | </tr> | ||
</table> | </table> | ||
<div class="character-builder-section-title" style="margin-top:15px;">Global Item Search</div> | |||
<div class="character-builder-controls"> | |||
<label>Search All Items</label> | |||
<input type="text" id="cb-global-item-search" placeholder="Search item name, class, slot, zone, dropped by..."> | |||
</div> | |||
<div id="cb-global-search-results" class="character-builder-warning-box" style="display:none; margin-top:10px;"></div> | |||
<div class="character-builder-equipment-layout"> | |||
<div> | |||
<div class="character-builder-section-title cb-nested-section-title">Equipped Items</div> | |||
<div class="character-builder-equipped-summary"> | |||
<table class="character-builder-equipped-table"> | |||
<tbody id="cb-equipped-summary-body"></tbody> | |||
</table> | |||
</div> | |||
</div> | |||
<div class="character-builder-gear-column"> | |||
<div class="character-builder-section-title cb-nested-section-title">Equipment</div> | |||
<div id="cb-loading-message">Loading item categories...</div> | |||
<div id="cb-gear-grid" class="character-builder-gear-grid"></div> | |||
</div> | |||
</div> | |||
</div> | |||
<div id="cb-tab-class-mastery" class="cb-tab-panel"> | |||
<div id="cm-calculator-app"></div> | |||
</div> | |||
<div id="cb-export-card-container" style="position:fixed; left:-99999px; top:0; width:1200px; min-width:1200px; height:auto; pointer-events:none; z-index:-9999;"> | |||
<div id="cb-export-card-inner" class="cb-export-card-dashboard"> | |||
<div class="cb-export-title-main">EQOA: SANDSTORM</div> | |||
<div class="cb-export-title-sub">CHARACTER BUILDER</div> | |||
<div id="cb-export-build-name" class="cb-export-build-name">Unnamed Build</div> | |||
<span id="cb-export-level" style="display:none;"></span> | |||
<span id="cb-export-class" style="display:none;"></span> | |||
<span id="cb-export-master-class" style="display:none;"></span> | |||
<div class="cb-export-character-sheet"> | |||
<div class="cb-export-level-panel cb-export-panel"> | |||
<div id="cb-export-level-title" class="cb-export-level-title">Level 60 Rogue</div> | |||
</div> | |||
<div class="cb-export-left-info-panel"> | |||
<div class="cb-export-panel-title">General Info</div> | |||
<div class="cb-export-identity-stack"> | |||
<div> | |||
<b>Race / Transformation</b> | |||
<span id="cb-export-race-transform">-</span> | |||
</div> | |||
<div> | |||
<b>Class Path</b> | |||
<span id="cb-export-class-path">None</span> | |||
</div> | |||
<div> | |||
<b>Exemplar Path</b> | |||
<span id="cb-export-exemplar-path">None</span> | |||
</div> | |||
</div> | |||
<div class="cb-export-bars"> | |||
<div class="cb-export-resource-row cb-export-hp-row"> | |||
<b>HP</b> | |||
<div class="cb-export-resource-bar"> | |||
<i></i><i></i><i></i><i></i><i></i> | |||
<span id="cb-export-hp-bar">0</span> | |||
</div> | |||
</div> | |||
<div class="cb-export-resource-row cb-export-pow-row"> | |||
<b>POW</b> | |||
<div class="cb-export-resource-bar"> | |||
<i></i><i></i><i></i><i></i><i></i> | |||
<span id="cb-export-pow-bar">0</span> | |||
</div> | |||
</div> | |||
<div class="cb-export-ac-row"> | |||
<b>AC</b> | |||
<span id="cb-export-ac-value">0</span> | |||
</div> | |||
</div> | |||
<div class="cb-export-stat-block"> | |||
<div class="cb-export-small-title">Core Stats</div> | |||
<div id="cb-export-core-stats"></div> | |||
<div class="cb-export-small-title cb-export-resist-title">Resists</div> | |||
<div id="cb-export-resists"></div> | |||
</div> | |||
</div> | |||
<div class="cb-export-equipment-panel cb-export-panel"> | |||
<div class="cb-export-panel-title">Equipment</div> | |||
<div id="cb-export-items"></div> | |||
</div> | |||
<div class="cb-export-cm-panel cb-export-panel"> | |||
<div class="cb-export-panel-title">Class Mastery</div> | |||
<div id="cb-export-cm"></div> | |||
</div> | |||
<div class="cb-export-panel cb-export-notes-panel"> | |||
<div class="cb-export-panel-title">Build Notes</div> | |||
<div id="cb-export-notes" class="cb-export-notes-box"></div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<div id="cb-item-tooltip" class="character-builder-tooltip" style="display:none;"></div> | |||
<div id="cb-compare-tooltip" class="character-builder-tooltip cb-compare-tooltip" style="display:none;"></div> | |||
</div> | </div> | ||
`; | `; | ||
var exportCmBox = document.getElementById('cb-export-cm'); | |||
var transformationPathSelect = document.getElementById('cb-transformation-path'); | |||
var raceSelect = document.getElementById('cb-race'); | var raceSelect = document.getElementById('cb-race'); | ||
var classSelect = document.getElementById('cb-class'); | var classSelect = document.getElementById('cb-class'); | ||
var levelInput = document.getElementById('cb-level'); | |||
var cmPointsInput = document.getElementById('cb-cm-points'); | |||
var buildNameInput = document.getElementById('cb-build-name'); | |||
var buildNotesInput = document.getElementById('cb-build-notes'); | |||
var gearGrid = document.getElementById('cb-gear-grid'); | var gearGrid = document.getElementById('cb-gear-grid'); | ||
var loadingMessage = document.getElementById('cb-loading-message'); | var loadingMessage = document.getElementById('cb-loading-message'); | ||
var | var generateShareButton = document.getElementById('cb-generate-share-url'); | ||
var copyShareButton = document.getElementById('cb-copy-share-url'); | |||
var resetBuildButton = document.getElementById('cb-reset-build'); | |||
var exportCardButton = document.getElementById('cb-export-card'); | |||
var shareUrlBox = document.getElementById('cb-share-url-box'); | |||
var shareUrlOutput = document.getElementById('cb-share-url-output'); | |||
var shareUrlStatus = document.getElementById('cb-share-url-status'); | |||
var equippedSummaryBody = document.getElementById('cb-equipped-summary-body'); | |||
var warningBox = document.getElementById('cb-warning-box'); | |||
var globalItemSearchInput = document.getElementById('cb-global-item-search'); | |||
var globalSearchResultsBox = document.getElementById('cb-global-search-results'); | |||
var compareUrlAInput = document.getElementById('cb-compare-url-a'); | |||
var compareUrlBInput = document.getElementById('cb-compare-url-b'); | |||
var compareBuildsButton = document.getElementById('cb-compare-builds'); | |||
var compareResultsBox = document.getElementById('cb-compare-results'); | |||
var tooltip = document.getElementById('cb-item-tooltip'); | |||
var compareTooltip = document.getElementById('cb-compare-tooltip'); | |||
var exportCardInner = document.getElementById('cb-export-card-inner'); | |||
var exportNotesBox = document.getElementById('cb-export-notes'); | |||
var exportCmBox = document.getElementById('cb-export-cm'); | |||
var exportMasterClassBox = document.getElementById('cb-export-master-class'); | |||
var exportClassPathBox = document.getElementById('cb-export-class-path'); | |||
var exportExemplarPathBox = document.getElementById('cb-export-exemplar-path'); | |||
var exportTransformPathBox = document.getElementById('cb-export-transform-path'); | |||
var themeToggleButton = document.getElementById('cb-theme-toggle'); | |||
var builderTabButtons = document.querySelectorAll('.cb-tab-button'); | |||
var builderTabPanels = document.querySelectorAll('.cb-tab-panel'); | |||
function switchBuilderTab(tabName) { | |||
Array.prototype.forEach.call(builderTabButtons, function (button) { | |||
if (button.getAttribute('data-cb-tab') === tabName) { | |||
button.classList.add('cb-tab-active'); | |||
} else { | |||
button.classList.remove('cb-tab-active'); | |||
} | |||
}); | |||
Array.prototype.forEach.call(builderTabPanels, function (panel) { | |||
if (panel.id === 'cb-tab-' + tabName) { | |||
panel.classList.add('cb-tab-panel-active'); | |||
} else { | |||
panel.classList.remove('cb-tab-panel-active'); | |||
} | |||
}); | |||
if (tabName === 'class-mastery') { | |||
publishCharacterBuilderState(); | |||
setTimeout(function () { | |||
if (window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER) { | |||
window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER(); | |||
} | |||
}, 0); | |||
} | |||
} | |||
function getTransformationPath() { | |||
return transformationPathSelect ? transformationPathSelect.value : ''; | |||
} | |||
function addOption(select, value, label) { | function addOption(select, value, label) { | ||
| Line 132: | Line 665: | ||
option.textContent = label; | option.textContent = label; | ||
select.appendChild(option); | select.appendChild(option); | ||
} | |||
function getBuildParamFromUrl() { | |||
try { | |||
var params = new URLSearchParams(window.location.search); | |||
return params.get('b') || params.get('build'); | |||
} catch (e) { | |||
return null; | |||
} | |||
} | |||
function encodeBuildData(buildData) { | |||
var json = JSON.stringify(buildData); | |||
try { | |||
if (typeof LZString !== 'undefined' && LZString.compressToEncodedURIComponent) { | |||
return 'lz:' + LZString.compressToEncodedURIComponent(json); | |||
} | |||
} catch (e) { | |||
console.warn('LZString compression failed. Falling back to base64.', e); | |||
} | |||
return 'b64:' + btoa(unescape(encodeURIComponent(json))); | |||
} | |||
function decodeBuildData(encoded) { | |||
try { | |||
if (encoded.indexOf('lz:') === 0) { | |||
if (typeof LZString !== 'undefined' && LZString.decompressFromEncodedURIComponent) { | |||
var decompressed = LZString.decompressFromEncodedURIComponent(encoded.substring(3)); | |||
return JSON.parse(decompressed); | |||
} | |||
console.error('LZString is not loaded yet.'); | |||
return null; | |||
} | |||
if (encoded.indexOf('b64:') === 0) { | |||
encoded = encoded.substring(4); | |||
} | |||
var json = decodeURIComponent(escape(atob(encoded))); | |||
return JSON.parse(json); | |||
} catch (e) { | |||
console.error('Could not decode shared build.', e); | |||
return null; | |||
} | |||
} | |||
function getClassPathOptionsForClass(classCode, tier) { | |||
var options = classPathOptionsByClass[classCode] || []; | |||
if (parseInt(tier, 10) === 60) { | |||
return options.concat(['General']); | |||
} | |||
return options; | |||
} | |||
function getClassPathSelect(tier) { | |||
return document.getElementById('cb-class-path-' + tier); | |||
} | |||
function populateClassPathControls() { | |||
var classCode = classSelect ? classSelect.value : ''; | |||
var level = getCharacterLevel(); | |||
classPathTiers.forEach(function (tier) { | |||
var select = getClassPathSelect(tier); | |||
var options = getClassPathOptionsForClass(classCode, tier); | |||
var savedValue = classPathChoices[tier] || ''; | |||
if (!select) return; | |||
select.innerHTML = ''; | |||
addOption(select, '', level >= tier ? 'Choose Path' : 'Requires Lv ' + tier); | |||
options.forEach(function (optionName) { | |||
addOption(select, optionName, optionName); | |||
}); | |||
if (level < tier) { | |||
select.value = ''; | |||
select.disabled = true; | |||
classPathChoices[tier] = ''; | |||
} else { | |||
select.disabled = false; | |||
if (options.indexOf(savedValue) !== -1) { | |||
select.value = savedValue; | |||
} else { | |||
select.value = ''; | |||
classPathChoices[tier] = ''; | |||
} | |||
} | |||
}); | |||
} | |||
function bindClassPathControls() { | |||
classPathTiers.forEach(function (tier) { | |||
var select = getClassPathSelect(tier); | |||
if (!select) return; | |||
select.addEventListener('change', function () { | |||
if (getCharacterLevel() < tier) { | |||
select.value = ''; | |||
classPathChoices[tier] = ''; | |||
return; | |||
} | |||
classPathChoices[tier] = select.value || ''; | |||
publishCharacterBuilderState(); | |||
}); | |||
}); | |||
} | |||
function getSelectedClassPathChoices() { | |||
var result = {}; | |||
classPathTiers.forEach(function (tier) { | |||
var select = getClassPathSelect(tier); | |||
var value = select ? select.value : classPathChoices[tier]; | |||
if (getCharacterLevel() >= tier && value) { | |||
result[tier] = value; | |||
} | |||
}); | |||
return result; | |||
} | |||
function getSelectedClassPathSummary() { | |||
var choices = getSelectedClassPathChoices(); | |||
var parts = []; | |||
classPathTiers.forEach(function (tier) { | |||
if (choices[tier]) { | |||
parts.push({ | |||
tier: tier, | |||
value: choices[tier] | |||
}); | |||
} | |||
}); | |||
return parts; | |||
} | |||
function getCurrentBuildData() { | |||
var gear = {}; | |||
gearSlots.forEach(function (slot) { | |||
if (equippedItems[slot]) { | |||
gear[slot] = equippedItems[slot].name; | |||
} | |||
}); | |||
return { | |||
v: 1, | |||
n: buildNameInput ? buildNameInput.value.trim() : "", | |||
no: buildNotesInput ? buildNotesInput.value.trim() : "", | |||
r: raceSelect.value, | |||
c: classSelect.value, | |||
l: getCharacterLevel(), | |||
cm: getAvailableCmPoints(), | |||
p: getTransformationPath(), | |||
cp: getSelectedClassPathChoices(), | |||
cms: ( | |||
window.EQOA_CLASS_MASTERY_STATE && | |||
typeof window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries === 'function' | |||
) | |||
? window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries() | |||
: sharedClassMasteries, | |||
t: { | |||
str: parseInt(trainingStats.str, 10) || 0, | |||
sta: parseInt(trainingStats.sta, 10) || 0, | |||
agi: parseInt(trainingStats.agi, 10) || 0, | |||
dex: parseInt(trainingStats.dex, 10) || 0, | |||
wis: parseInt(trainingStats.wis, 10) || 0, | |||
int: parseInt(trainingStats.int, 10) || 0, | |||
cha: parseInt(trainingStats.cha, 10) || 0 | |||
}, | |||
i: gear | |||
}; | |||
} | |||
function generateShareUrl() { | |||
var buildData = getCurrentBuildData(); | |||
var encoded = encodeBuildData(buildData); | |||
var pageTitle = mw.config.get('wgPageName') || mw.config.get('wgTitle'); | |||
var scriptUrl = mw.config.get('wgScript'); | |||
if (scriptUrl.indexOf('http') !== 0) { | |||
scriptUrl = window.location.origin + scriptUrl; | |||
} | |||
var baseUrl = scriptUrl + '?title=' + encodeURIComponent(pageTitle); | |||
var url = baseUrl + '&b=' + encodeURIComponent(encoded); | |||
if (shareUrlOutput) shareUrlOutput.value = url; | |||
if (shareUrlBox) shareUrlBox.style.display = 'block'; | |||
if (shareUrlStatus) shareUrlStatus.textContent = 'Share URL generated.'; | |||
try { | |||
window.history.replaceState(null, document.title, url); | |||
} catch (e) {} | |||
return url; | |||
} | |||
function copyShareUrl() { | |||
var url = shareUrlOutput && shareUrlOutput.value ? shareUrlOutput.value : generateShareUrl(); | |||
function markCopied() { | |||
if (shareUrlBox) shareUrlBox.style.display = 'block'; | |||
if (shareUrlStatus) shareUrlStatus.textContent = 'Copied to clipboard.'; | |||
} | |||
function markManualCopy() { | |||
if (shareUrlBox) shareUrlBox.style.display = 'block'; | |||
if (shareUrlOutput) { | |||
shareUrlOutput.focus(); | |||
shareUrlOutput.select(); | |||
} | |||
if (shareUrlStatus) shareUrlStatus.textContent = 'Copy blocked by browser. Select the URL above and copy it manually.'; | |||
} | |||
if (navigator.clipboard && navigator.clipboard.writeText) { | |||
navigator.clipboard.writeText(url).then(markCopied).catch(markManualCopy); | |||
} else { | |||
try { | |||
if (shareUrlOutput) { | |||
shareUrlOutput.focus(); | |||
shareUrlOutput.select(); | |||
} | |||
document.execCommand('copy'); | |||
markCopied(); | |||
} catch (e) { | |||
markManualCopy(); | |||
} | |||
} | |||
} | |||
function resetBuild() { | |||
equippedItems = {}; | |||
pendingSharedBuild = null; | |||
sharedBuildApplied = false; | |||
classPathChoices = {}; | |||
populateClassPathControls(); | |||
levelInput.value = 60; | |||
if (cmPointsInput) { | |||
cmPointsInput.value = getMaxCmPointsForLevel(); | |||
} | |||
if (buildNameInput) { | |||
buildNameInput.value = ''; | |||
} | |||
if (buildNotesInput) { | |||
buildNotesInput.value = ''; | |||
} | |||
Object.keys(trainingStats).forEach(function (key) { | |||
trainingStats[key] = 0; | |||
var input = document.getElementById('train-' + key); | |||
if (input) input.value = 0; | |||
}); | |||
if (shareUrlBox) shareUrlBox.style.display = 'none'; | |||
if (shareUrlOutput) shareUrlOutput.value = ''; | |||
if (shareUrlStatus) shareUrlStatus.textContent = ''; | |||
try { | |||
window.history.replaceState( | |||
null, | |||
document.title, | |||
window.location.origin + window.location.pathname | |||
); | |||
} catch (e) {} | |||
buildGearSlots(); | |||
updateStats(); | |||
} | |||
function getAllLoadedItemsFlat() { | |||
var all = []; | |||
var seen = {}; | |||
Object.keys(itemCache).forEach(function (slotType) { | |||
(itemCache[slotType] || []).forEach(function (item) { | |||
if (!item || !item.name) return; | |||
var key = item.name.toLowerCase(); | |||
if (!seen[key]) { | |||
seen[key] = item; | |||
all.push(item); | |||
} | |||
}); | |||
}); | |||
return all; | |||
} | |||
function findLoadedItemByName(itemName, slot) { | |||
if (!itemName) return null; | |||
var name = String(itemName).toLowerCase(); | |||
var slotType = getBuilderSlotType(slot); | |||
var slotItems = itemCache[slotType] || []; | |||
for (var i = 0; i < slotItems.length; i++) { | |||
if (slotItems[i].name && slotItems[i].name.toLowerCase() === name) { | |||
return slotItems[i]; | |||
} | |||
} | |||
var allItems = getAllLoadedItemsFlat(); | |||
for (var j = 0; j < allItems.length; j++) { | |||
if (allItems[j].name && allItems[j].name.toLowerCase() === name) { | |||
return allItems[j]; | |||
} | |||
} | |||
return null; | |||
} | |||
function setTrainingInputsFromState() { | |||
Object.keys(trainingStats).forEach(function (key) { | |||
var input = document.getElementById('train-' + key); | |||
if (input) input.value = trainingStats[key]; | |||
}); | |||
} | |||
function applySharedBuildBasicState(buildData) { | |||
if (!buildData || sharedBuildApplied) return; | |||
buildData.buildName = buildData.buildName || buildData.n; | |||
buildData.buildNotes = buildData.buildNotes || buildData.no; | |||
buildData.race = buildData.race || buildData.r; | |||
buildData.classCode = buildData.classCode || buildData.c; | |||
buildData.level = buildData.level || buildData.l; | |||
buildData.cmPoints = buildData.cmPoints || buildData.cm; | |||
buildData.trainingStats = buildData.trainingStats || buildData.t; | |||
buildData.equippedItemNames = buildData.equippedItemNames || buildData.i; | |||
buildData.transformationPath = buildData.transformationPath || buildData.p; | |||
buildData.classPathChoices = buildData.classPathChoices || buildData.cp || {}; | |||
buildData.classMasteries = buildData.classMasteries || buildData.cms || []; | |||
sharedClassMasteries = buildData.classMasteries || []; | |||
if (buildData.race && data.baseStats[buildData.race]) { | |||
raceSelect.value = buildData.race; | |||
loadClasses(true); | |||
} | |||
if (buildData.classCode) { | |||
var classExists = false; | |||
Array.prototype.forEach.call(classSelect.options, function (option) { | |||
if (option.value === buildData.classCode) classExists = true; | |||
}); | |||
if (classExists) classSelect.value = buildData.classCode; | |||
} | |||
if (buildData.level !== undefined) { | |||
levelInput.value = buildData.level; | |||
getCharacterLevel(); | |||
} | |||
if (buildData.cmPoints !== undefined && cmPointsInput) { | |||
cmPointsInput.value = buildData.cmPoints; | |||
getAvailableCmPoints(); | |||
} | |||
if (buildData.transformationPath !== undefined && transformationPathSelect) { | |||
transformationPathSelect.value = buildData.transformationPath; | |||
} | |||
if (buildData.classPathChoices) { | |||
classPathChoices = buildData.classPathChoices; | |||
populateClassPathControls(); | |||
} | |||
if (buildData.buildName && buildNameInput) { | |||
buildNameInput.value = buildData.buildName; | |||
} | |||
if (buildData.buildNotes && buildNotesInput) { | |||
buildNotesInput.value = buildData.buildNotes; | |||
} | |||
if (buildData.trainingStats) { | |||
Object.keys(trainingStats).forEach(function (key) { | |||
trainingStats[key] = parseInt(buildData.trainingStats[key], 10) || 0; | |||
}); | |||
setTrainingInputsFromState(); | |||
Object.keys(trainingStats).forEach(function (key) { | |||
clampTrainingInput(key); | |||
}); | |||
setTrainingInputsFromState(); | |||
} | |||
} | |||
function applySharedBuildEquipment(buildData) { | |||
if (!buildData || !buildData.equippedItemNames) return; | |||
equippedItems = {}; | |||
gearSlots.forEach(function (slot) { | |||
var itemName = buildData.equippedItemNames[slot]; | |||
var item = findLoadedItemByName(itemName, slot); | |||
if (item && classCanUseItem(item, classSelect.value) && levelCanUseItem(item)) { | |||
equippedItems[slot] = item; | |||
} else if (itemName) { | |||
missingSharedItems.push(itemName); | |||
} | |||
}); | |||
enforceDisabledSlots(); | |||
sharedBuildApplied = true; | |||
buildGearSlots(); | |||
updateStats(); | |||
if (shareUrlBox && shareUrlOutput) { | |||
shareUrlBox.style.display = 'block'; | |||
shareUrlOutput.value = window.location.href; | |||
if (shareUrlStatus) { | |||
if (missingSharedItems.length > 0) { | |||
shareUrlStatus.innerHTML = | |||
'Shared build loaded, but some items could not be found:<br>• ' + | |||
missingSharedItems.join('<br>• '); | |||
} else { | |||
shareUrlStatus.textContent = 'Shared build loaded.'; | |||
} | |||
} | |||
} | |||
} | |||
function tryLoadSharedBuildFromUrl() { | |||
var encoded = getBuildParamFromUrl(); | |||
if (!encoded) return; | |||
pendingSharedBuild = decodeBuildData(encoded); | |||
if (pendingSharedBuild) { | |||
applySharedBuildBasicState(pendingSharedBuild); | |||
} else if (shareUrlBox && shareUrlStatus) { | |||
shareUrlBox.style.display = 'block'; | |||
shareUrlStatus.textContent = 'Could not load shared build URL.'; | |||
} | |||
} | } | ||
| Line 137: | Line 1,144: | ||
addOption(raceSelect, race, race); | addOption(raceSelect, race, race); | ||
}); | }); | ||
function getCharacterLevel() { | |||
var level = parseInt(levelInput.value, 10); | |||
if (isNaN(level) || level < 1) level = 1; | |||
if (level > 60) level = 60; | |||
levelInput.value = level; | |||
return level; | |||
} | |||
function getMaxCmPointsForLevel() { | |||
var max = getCharacterLevel() * 25; | |||
if (max > 1500) { | |||
max = 1500; | |||
} | |||
return max; | |||
} | |||
function getAvailableCmPoints() { | |||
var max = getMaxCmPointsForLevel(); | |||
var value; | |||
if (!cmPointsInput) { | |||
return max; | |||
} | |||
cmPointsInput.max = max; | |||
value = parseInt(cmPointsInput.value, 10); | |||
if (isNaN(value)) { | |||
value = max; | |||
} | |||
if (value < 0) { | |||
value = 0; | |||
} | |||
if (value > max) { | |||
value = max; | |||
} | |||
cmPointsInput.value = value; | |||
return value; | |||
} | |||
function getBuilderSlotType(slot) { | function getBuilderSlotType(slot) { | ||
| Line 148: | Line 1,205: | ||
function getCategoryForSlot(slotType) { | function getCategoryForSlot(slotType) { | ||
return slotType + " Items"; | return slotType + " Items"; | ||
} | |||
function getCategoriesForSlotType(slotType) { | |||
if (slotType === "Primary") { | |||
return ["Primary Items", "Secondary Items"]; | |||
} | |||
return [getCategoryForSlot(slotType)]; | |||
} | } | ||
| Line 178: | Line 1,242: | ||
}).then(function (data) { | }).then(function (data) { | ||
var page = data.query.pages[0]; | var page = data.query.pages[0]; | ||
if (!page || !page.revisions || !page.revisions[0]) return ""; | if (!page || !page.revisions || !page.revisions[0]) return ""; | ||
| Line 186: | Line 1,251: | ||
return page.revisions[0].content || ""; | return page.revisions[0].content || ""; | ||
}); | }); | ||
} | |||
function parseCategories(wikitext) { | |||
var categories = []; | |||
var regex = /\[\[Category:([^\]|]+)(?:\|[^\]]*)?\]\]/gi; | |||
var match; | |||
while ((match = regex.exec(wikitext)) !== null) { | |||
categories.push(match[1].trim()); | |||
} | |||
return categories; | |||
} | } | ||
function parseItemBox(wikitext, fallbackName) { | function parseItemBox(wikitext, fallbackName) { | ||
var match = wikitext.match(/\{\{ItemBox([\s\S]*?)\}\}/i); | var match = wikitext.match(/\{\{(?:ItemBox|ItemPage)([\s\S]*?)\}\}/i); | ||
if (!match) return null; | if (!match) return null; | ||
var content = match[1]; | var content = match[1]; | ||
var item = { | var item = { name: fallbackName }; | ||
var lines = content.split("\n"); | var lines = content.split("\n"); | ||
| Line 215: | Line 1,289: | ||
if (!item.name) item.name = fallbackName; | if (!item.name) item.name = fallbackName; | ||
item.categories = parseCategories(wikitext); | |||
item.is2HD = item.categories.indexOf("2HD Items") !== -1 || | |||
String(item.weaponstyle || "").toUpperCase() === "2HD"; | |||
item.isShield = item.categories.indexOf("Shield Items") !== -1 || | |||
String(item.slot || "").toLowerCase() === "shield"; | |||
item.isHeld = item.categories.indexOf("Held Items") !== -1 || | |||
String(item.slot || "").toLowerCase() === "held"; | |||
item.isSecondary = item.categories.indexOf("Secondary Items") !== -1 || | |||
String(item.slot || "").toLowerCase() === "secondary"; | |||
item.isPrimary = item.categories.indexOf("Primary Items") !== -1 || | |||
String(item.slot || "").toLowerCase() === "primary"; | |||
statKeys.forEach(function (key) { | statKeys.forEach(function (key) { | ||
| Line 222: | Line 1,313: | ||
} else { | } else { | ||
item[key] = 0; | item[key] = 0; | ||
} | |||
}); | |||
textKeys.forEach(function (key) { | |||
if (item[key] === undefined) { | |||
item[key] = ""; | |||
} | } | ||
}); | }); | ||
| Line 242: | Line 1,339: | ||
} | } | ||
function loadItemsForSlotType(slotType) { | function levelCanUseItem(item) { | ||
var characterLevel = getCharacterLevel(); | |||
var itemLevel = parseInt(item.lvl, 10) || 0; | |||
if (itemLevel <= 0) return true; | |||
return itemLevel <= characterLevel; | |||
} | |||
function dedupePages(pages) { | |||
var seen = {}; | |||
var result = []; | |||
pages.forEach(function (page) { | |||
if (!seen[page.title]) { | |||
seen[page.title] = true; | |||
result.push(page); | |||
} | |||
}); | |||
return result; | |||
} | |||
function normalizeGeneratedSlot(item) { | |||
var slot = String(item && item.slot || '').trim(); | |||
var slotKey = slot.toLowerCase(); | |||
var name = String(item && item.name || '').toLowerCase(); | |||
var style = String(item && item.weaponstyle || '').toLowerCase(); | |||
if (slotKey === 'helm') return 'Head'; | |||
if (slotKey === 'helmet') return 'Head'; | |||
if (slotKey === 'necklace') return 'Neck'; | |||
if (slotKey === 'earring') return 'Ear'; | |||
if (slotKey === 'leggings') return 'Legs'; | |||
if (slotKey === 'leg') return 'Legs'; | |||
if (slotKey === 'bow') return 'Range'; | |||
if (slotKey === 'range') return 'Range'; | |||
if (slotKey === 'ranged') return 'Range'; | |||
if (slotKey === 'thrown') return 'Range'; | |||
if (slotKey === 'throwing') return 'Range'; | |||
if ( | |||
style.indexOf('bow') !== -1 || | |||
style.indexOf('crossbow') !== -1 || | |||
style.indexOf('shortbow') !== -1 || | |||
style.indexOf('longbow') !== -1 || | |||
style.indexOf('throw') !== -1 || | |||
style.indexOf('javelin') !== -1 || | |||
name.indexOf('bow') !== -1 || | |||
name.indexOf('crossbow') !== -1 || | |||
name.indexOf('throw') !== -1 || | |||
name.indexOf('javelin') !== -1 | |||
) { | |||
return 'Range'; | |||
} | |||
return slot; | |||
} | |||
function normalizeGeneratedItem(generatedItem, slotType) { | |||
var item = Object.assign({}, generatedItem); | |||
item.slot = normalizeGeneratedSlot(item); | |||
if (!item.name) return null; | |||
if (Array.isArray(item.class)) { | |||
item.classList = item.class.map(function (x) { | |||
return String(x).trim().toUpperCase(); | |||
}); | |||
item.class = item.classList.join(", "); | |||
} else if (item.class) { | |||
item.classList = String(item.class).split(",").map(function (x) { | |||
return x.trim().toUpperCase(); | |||
}); | |||
} else { | |||
item.classList = []; | |||
} | |||
item.categories = item.categories || [slotType + " Items"]; | |||
item.is2HD = item.categories.indexOf("2HD Items") !== -1 || | |||
String(item.weaponstyle || "").toUpperCase() === "2HD"; | |||
item.isShield = String(item.slot || "").toLowerCase() === "shield"; | |||
item.isHeld = String(item.slot || "").toLowerCase() === "held"; | |||
item.isSecondary = String(item.slot || "").toLowerCase() === "secondary"; | |||
item.isPrimary = String(item.slot || "").toLowerCase() === "primary"; | |||
item.isRange = String(item.slot || "").toLowerCase() === "range"; | |||
statKeys.forEach(function (key) { | |||
item[key] = parseFloat(item[key]) || 0; | |||
}); | |||
textKeys.forEach(function (key) { | |||
if (item[key] === undefined) item[key] = ""; | |||
}); | |||
return item; | |||
} | |||
function loadItemsForSlotType(slotType) { | |||
if (itemCache[slotType]) { | |||
return Promise.resolve(itemCache[slotType]); | |||
} | |||
if (window.EQOA_GENERATED_ITEMS && window.EQOA_GENERATED_ITEMS.length) { | |||
var items = window.EQOA_GENERATED_ITEMS | |||
.filter(function (generatedItem) { | |||
var normalizedSlot; | |||
if (!generatedItem) return false; | |||
normalizedSlot = normalizeGeneratedSlot(generatedItem); | |||
if (slotType === "Primary") { | |||
return normalizedSlot === "Primary" || normalizedSlot === "Secondary"; | |||
return | } | ||
} | |||
if (slotType === "Secondary") { | |||
return normalizedSlot === "Secondary"; | |||
} | |||
return normalizedSlot === slotType; | |||
}) | |||
.map(function (generatedItem) { | |||
return normalizeGeneratedItem(generatedItem, slotType); | |||
}) | |||
.filter(function (item) { | |||
return item !== null; | |||
}); | }); | ||
} | |||
itemCache[slotType] = items; | |||
return Promise.resolve(items); | |||
} | |||
return Promise.reject("EQOA_GENERATED_ITEMS is not loaded."); | |||
} | |||
function loadAllSlotItems() { | function loadAllSlotItems() { | ||
| Line 285: | Line 1,495: | ||
return Promise.all(promises).then(function () { | return Promise.all(promises).then(function () { | ||
loadingMessage.textContent = "Items loaded."; | loadingMessage.textContent = "Items loaded."; | ||
if (pendingSharedBuild && !sharedBuildApplied) { | |||
applySharedBuildEquipment(pendingSharedBuild); | |||
return; | |||
} | |||
buildGearSlots(); | buildGearSlots(); | ||
updateStats(); | updateStats(); | ||
}).catch(function (error) { | }).catch(function (error) { | ||
loadingMessage.textContent = "Error loading items | loadingMessage.textContent = "Error loading items."; | ||
console.error(error); | console.error(error); | ||
}); | }); | ||
} | } | ||
function loadClasses() { | function getDisabledSlots() { | ||
var disabled = {}; | |||
var primary = equippedItems.Primary; | |||
var secondary = equippedItems.Secondary; | |||
var shield = equippedItems.Shield; | |||
var held1 = equippedItems.Held1; | |||
var held2 = equippedItems.Held2; | |||
var range = equippedItems.Range; | |||
var forearm = equippedItems.Forearm; | |||
var bracelet1 = equippedItems.Bracelet1; | |||
var bracelet2 = equippedItems.Bracelet2; | |||
function disable(slot, reason) { | |||
if (!disabled[slot]) { | |||
disabled[slot] = reason; | |||
} | |||
} | |||
/* 2HD Primary locks off-hand style slots */ | |||
if (primary && primary.is2HD) { | |||
disable("Secondary", "Disabled because a 2HD weapon is equipped in Primary."); | |||
disable("Shield", "Disabled because a 2HD weapon is equipped in Primary."); | |||
disable("Held1", "Disabled because a 2HD weapon is equipped in Primary."); | |||
disable("Held2", "Disabled because a 2HD weapon is equipped in Primary."); | |||
disable("Range", "Disabled because a 2HD weapon is equipped in Primary."); | |||
} | |||
/* Primary + Secondary uses both hands */ | |||
if (primary && secondary) { | |||
disable("Held1", "Disabled because Primary and Secondary are both equipped."); | |||
disable("Held2", "Disabled because Primary and Secondary are both equipped."); | |||
disable("Shield", "Disabled because Primary and Secondary are both equipped."); | |||
disable("Range", "Disabled because Primary and Secondary are both equipped."); | |||
} | |||
/* Primary uses main hand, blocking Held1 and Range */ | |||
if (primary) { | |||
disable("Held1", "Disabled because Primary is equipped."); | |||
disable("Range", "Disabled because Primary is equipped."); | |||
} | |||
/* Secondary uses off hand, blocking Held2 and Range */ | |||
if (secondary) { | |||
disable("Held2", "Disabled because Secondary is equipped."); | |||
disable("Range", "Disabled because Secondary is equipped."); | |||
} | |||
/* Shield allows Held1, but blocks Held2 and Range */ | |||
if (shield) { | |||
disable("Held2", "Disabled because a Shield is equipped."); | |||
disable("Range", "Disabled because a Shield is equipped."); | |||
} | |||
/* Held2 blocks Shield and Range */ | |||
if (held2) { | |||
disable("Shield", "Disabled because Held2 is equipped."); | |||
disable("Range", "Disabled because Held2 is equipped."); | |||
} | |||
/* Held1 blocks Primary and Range */ | |||
if (held1) { | |||
disable("Primary", "Disabled because Held1 is equipped."); | |||
disable("Range", "Disabled because Held1 is equipped."); | |||
} | |||
/* Held1 + Held2 uses both hands */ | |||
if (held1 && held2) { | |||
disable("Primary", "Disabled because Held1 and Held2 are both equipped."); | |||
disable("Secondary", "Disabled because Held1 and Held2 are both equipped."); | |||
disable("Shield", "Disabled because Held1 and Held2 are both equipped."); | |||
disable("Range", "Disabled because Held1 and Held2 are both equipped."); | |||
} | |||
/* Optional cleanup: Range conflicts with Shield / Held2 / dual wield */ | |||
/* Range uses both weapon/off-hand space */ | |||
if (range) { | |||
disable("Primary", "Disabled because Range is equipped."); | |||
disable("Secondary", "Disabled because Range is equipped."); | |||
disable("Shield", "Disabled because Range is equipped."); | |||
disable("Held1", "Disabled because Range is equipped."); | |||
disable("Held2", "Disabled because Range is equipped."); | |||
} | |||
/* Forearm vs Bracelet slots */ | |||
if (forearm) { | |||
disable("Bracelet1", "Disabled because a Forearm item is equipped."); | |||
disable("Bracelet2", "Disabled because a Forearm item is equipped."); | |||
} | |||
if (bracelet1 || bracelet2) { | |||
disable("Forearm", "Disabled because Bracelet1 or Bracelet2 is equipped."); | |||
} | |||
return disabled; | |||
} | |||
function isLoreItem(item) { | |||
if (!item) return false; | |||
return String(item.lore || "") | |||
.trim() | |||
.toUpperCase() === "LORE"; | |||
} | |||
function isLoreConflict(item, currentSlot) { | |||
if (!item || !item.name) return false; | |||
if (!isLoreItem(item)) return false; | |||
var itemName = item.name.toLowerCase(); | |||
for (var slot in equippedItems) { | |||
if (slot === currentSlot) continue; | |||
var equipped = equippedItems[slot]; | |||
if (!equipped || !equipped.name) continue; | |||
if (equipped.name.toLowerCase() === itemName) { | |||
return true; | |||
} | |||
} | |||
return false; | |||
} | |||
function enforceDisabledSlots() { | |||
var disabled = getDisabledSlots(); | |||
Object.keys(disabled).forEach(function (slot) { | |||
if (equippedItems[slot]) { | |||
delete equippedItems[slot]; | |||
} | |||
}); | |||
var equippedLoreNames = {}; | |||
gearSlots.forEach(function (slot) { | |||
var item = equippedItems[slot]; | |||
if (!item || !item.name) return; | |||
if (!isLoreItem(item)) return; | |||
var key = item.name.toLowerCase(); | |||
if (equippedLoreNames[key]) { | |||
delete equippedItems[slot]; | |||
} else { | |||
equippedLoreNames[key] = true; | |||
} | |||
}); | |||
} | |||
function loadClasses(preserveEquipped) { | |||
classSelect.innerHTML = ''; | classSelect.innerHTML = ''; | ||
| Line 303: | Line 1,681: | ||
}); | }); | ||
equippedItems = {}; | if (!preserveEquipped) { | ||
equippedItems = {}; | |||
} | |||
buildGearSlots(); | buildGearSlots(); | ||
updateStats(); | updateStats(); | ||
| Line 310: | Line 1,691: | ||
function buildGearSlots() { | function buildGearSlots() { | ||
gearGrid.innerHTML = ''; | gearGrid.innerHTML = ''; | ||
var disabledSlots = getDisabledSlots(); | |||
gearSlots.forEach(function (slot) { | gearSlots.forEach(function (slot) { | ||
var slotBox = document.createElement('div'); | var slotBox = document.createElement('div'); | ||
slotBox.className = 'character-builder-slot'; | slotBox.className = 'character-builder-slot'; | ||
if (disabledSlots[slot]) { | |||
slotBox.className += ' cb-slot-disabled'; | |||
} | |||
slotBox.innerHTML = | slotBox.innerHTML = | ||
'<div class="character-builder-section-title cb-slot-section-title">' + slot + '</div>' + | |||
'<input type="text" class="cb-slot-search" id="cb-search-' + slot.toLowerCase() + '" placeholder="Search ' + slot + '">' + | |||
'<div class="cb-picker-tools">' + | |||
'<select class="cb-slot-filter" id="cb-filter-' + slot.toLowerCase() + '">' + | |||
'<option value="">Any Stat</option>' + | |||
'<option value="hp">HP</option>' + | |||
'<option value="pow">POW</option>' + | |||
'<option value="ac">AC</option>' + | |||
'<option value="dmg">DMG</option>' + | |||
'<option value="str">STR</option>' + | |||
'<option value="sta">STA</option>' + | |||
'<option value="agi">AGI</option>' + | |||
'<option value="dex">DEX</option>' + | |||
'<option value="wis">WIS</option>' + | |||
'<option value="int">INT</option>' + | |||
'<option value="cha">CHA</option>' + | |||
'<option value="pr">PR</option>' + | |||
'<option value="dr">DR</option>' + | |||
'<option value="fr">FR</option>' + | |||
'<option value="cr">CR</option>' + | |||
'<option value="lr">LR</option>' + | |||
'<option value="ar">AR</option>' + | |||
'<option value="proc">Has Proc</option>' + | |||
'<option value="effect">Has Effect</option>' + | |||
'<option value="2hd">2HD</option>' + | |||
'</select>' + | |||
'<select class="cb-slot-sort" id="cb-sort-' + slot.toLowerCase() + '">' + | |||
'<option value="name">Sort: Name</option>' + | |||
'<option value="lvl">Sort: Level</option>' + | |||
'<option value="hp">Sort: HP</option>' + | |||
'<option value="pow">Sort: POW</option>' + | |||
'<option value="ac">Sort: AC</option>' + | |||
'<option value="dmg">Sort: DMG</option>' + | |||
'<option value="str">Sort: STR</option>' + | |||
'<option value="sta">Sort: STA</option>' + | |||
'<option value="agi">Sort: AGI</option>' + | |||
'<option value="dex">Sort: DEX</option>' + | |||
'<option value="wis">Sort: WIS</option>' + | |||
'<option value="int">Sort: INT</option>' + | |||
'<option value="cha">Sort: CHA</option>' + | |||
'</select>' + | |||
'</div>' + | |||
'<div class="cb-upgrade-row">' + | |||
'<label class="cb-upgrade-toggle">' + | |||
'<input type="checkbox" id="cb-upgrades-' + slot.toLowerCase() + '">' + | |||
' Upgrades Only' + | |||
'</label>' + | |||
'</div>' + | |||
'<div class="cb-item-picker-list" id="cb-picker-' + slot.toLowerCase() + '"></div>' + | |||
'<div class="cb-slot-disabled-note" id="cb-disabled-note-' + slot.toLowerCase() + '"></div>'; | |||
gearGrid.appendChild(slotBox); | gearGrid.appendChild(slotBox); | ||
populateItemPicker(slot, disabledSlots[slot]); | |||
}); | }); | ||
updateEquippedSummary(); | |||
} | |||
function isItemUpgrade(slot, newItem) { | |||
var currentItem = equippedItems[slot]; | |||
if (!currentItem) return true; | |||
var compareKeys = [ | |||
"hp", "pow", "ac", "dmg", | |||
"str", "sta", "agi", "dex", | |||
"wis", "int", "cha", | |||
"pr", "dr", "fr", "cr", "lr", "ar" | |||
]; | |||
for (var i = 0; i < compareKeys.length; i++) { | |||
var key = compareKeys[i]; | |||
var newValue = parseFloat(newItem[key]) || 0; | |||
var currentValue = parseFloat(currentItem[key]) || 0; | |||
if (newValue > currentValue) { | |||
return true; | |||
} | } | ||
} | |||
return false; | |||
} | |||
function getFilteredItemsForSlot(slot, searchText, filterKey, sortKey) { | |||
var classCode = classSelect.value; | |||
var slotType = getBuilderSlotType(slot); | |||
var items = itemCache[slotType] || []; | |||
searchText = (searchText || "").toLowerCase(); | |||
filterKey = filterKey || ""; | |||
sortKey = sortKey || "name"; | |||
var | var filtered = items.filter(function (item) { | ||
if (!classCanUseItem(item, classCode)) return false; | |||
var | if (!levelCanUseItem(item)) return false; | ||
if (slot === "Primary") { | |||
if (item.isRange) return false; | |||
if (!item.isPrimary && !item.isSecondary) return false; | |||
} | |||
if (slot === "Secondary") { | |||
if (item.isRange) return false; | |||
if (!item.isSecondary) return false; | |||
} | |||
if (searchText && item.name.toLowerCase().indexOf(searchText) === -1) { | |||
return false; | |||
} | |||
if (filterKey) { | |||
if (filterKey === "proc" && !item.proc) return false; | |||
if (filterKey === "effect" && !item.effect) return false; | |||
if (filterKey === "2hd" && !item.is2HD) return false; | |||
if ( | |||
filterKey !== "proc" && | |||
filterKey !== "effect" && | |||
filterKey !== "2hd" | |||
) { | |||
if (!item[filterKey] || parseFloat(item[filterKey]) <= 0) return false; | |||
} | |||
} | |||
return true; | |||
}); | |||
filtered.sort(function (a, b) { | |||
if (sortKey === "name") { | |||
return String(a.name || "").localeCompare(String(b.name || "")); | |||
} | |||
var av = parseFloat(a[sortKey]) || 0; | |||
var bv = parseFloat(b[sortKey]) || 0; | |||
if (bv !== av) return bv - av; | |||
return String(a.name || "").localeCompare(String(b.name || "")); | |||
}); | |||
return filtered; | |||
} | |||
function populateItemPicker(slot, disabledReason) { | |||
var picker = document.getElementById('cb-picker-' + slot.toLowerCase()); | |||
var searchInput = document.getElementById('cb-search-' + slot.toLowerCase()); | |||
var disabledNote = document.getElementById('cb-disabled-note-' + slot.toLowerCase()); | |||
var filterSelect = document.getElementById('cb-filter-' + slot.toLowerCase()); | |||
var sortSelect = document.getElementById('cb-sort-' + slot.toLowerCase()); | |||
var upgradesOnlyToggle = document.getElementById('cb-upgrades-' + slot.toLowerCase()); | |||
if (!picker) return; | |||
if (disabledReason) { | |||
if (searchInput) searchInput.disabled = true; | |||
picker.innerHTML = '<div class="cb-picker-disabled">Disabled</div>'; | |||
if (disabledNote) disabledNote.textContent = disabledReason; | |||
return; | |||
} | |||
function rebuildPicker() { | |||
var searchText = searchInput ? searchInput.value : ""; | |||
var filterKey = filterSelect ? filterSelect.value : ""; | |||
var sortKey = sortSelect ? sortSelect.value : "name"; | |||
var filteredItems = getFilteredItemsForSlot(slot, searchText, filterKey, sortKey); | |||
if (upgradesOnlyToggle && upgradesOnlyToggle.checked) { | |||
filteredItems = filteredItems.filter(function (item) { | |||
return isItemUpgrade(slot, item); | |||
}); | |||
} | |||
filteredItems = filteredItems.slice(0, 10); | |||
var currentItem = equippedItems[slot]; | |||
picker.innerHTML = ''; | |||
var noneRow = document.createElement('div'); | |||
noneRow.className = 'cb-picker-item cb-picker-none'; | |||
noneRow.textContent = '-- None --'; | |||
if (!currentItem) { | |||
noneRow.className += ' cb-picker-selected'; | |||
} | |||
noneRow.addEventListener('click', function () { | |||
delete equippedItems[slot]; | |||
hideTooltip(); | |||
hideCompareTooltip(); | |||
enforceDisabledSlots(); | |||
buildGearSlots(); | |||
updateStats(); | |||
}); | |||
picker.appendChild(noneRow); | |||
filteredItems.forEach(function (item) { | |||
var row = document.createElement('div'); | |||
row.className = 'cb-picker-item'; | |||
if (currentItem === item) { | |||
row.className += ' cb-picker-selected'; | |||
} | |||
var itemNameSpan = document.createElement('span'); | |||
itemNameSpan.textContent = item.name + (item.lvl ? ' (Lv ' + item.lvl + ')' : ''); | |||
itemNameSpan.className = 'cb-rarity-' + normalizeRarity(item.rarity); | |||
var itemPageLink = document.createElement('span'); | |||
itemPageLink.textContent = ' ↗'; | |||
itemPageLink.title = 'Open item page'; | |||
itemPageLink.className = 'cb-item-page-link'; | |||
itemPageLink.addEventListener('click', function (event) { | |||
event.stopPropagation(); | |||
openItemPage(item.name); | |||
}); | |||
row.appendChild(itemNameSpan); | |||
row.appendChild(itemPageLink); | |||
row.addEventListener('mouseenter', function (event) { | |||
showCompareTooltip(slot, item, event); | |||
}); | |||
row.addEventListener('mousemove', function (event) { | |||
moveCompareTooltip(event); | |||
}); | |||
row.addEventListener('mouseleave', function () { | |||
hideCompareTooltip(); | |||
}); | |||
row.addEventListener('click', function () { | |||
equippedItems[slot] = item; | |||
hideTooltip(); | |||
hideCompareTooltip(); | |||
enforceDisabledSlots(); | |||
buildGearSlots(); | |||
updateStats(); | |||
}); | |||
picker.appendChild(row); | |||
}); | |||
} | |||
rebuildPicker(); | |||
if (searchInput) { | |||
searchInput.addEventListener('input', rebuildPicker); | |||
} | |||
if (filterSelect) { | |||
filterSelect.addEventListener('change', rebuildPicker); | |||
} | |||
if (sortSelect) { | |||
sortSelect.addEventListener('change', rebuildPicker); | |||
} | |||
if (upgradesOnlyToggle) { | |||
upgradesOnlyToggle.addEventListener('change', rebuildPicker); | |||
} | |||
} | |||
function validateEquipment() { | |||
var warnings = []; | |||
var primary = equippedItems.Primary; | |||
var secondary = equippedItems.Secondary; | |||
var shield = equippedItems.Shield; | |||
var held1 = equippedItems.Held1; | |||
var held2 = equippedItems.Held2; | |||
var forearm = equippedItems.Forearm; | |||
var bracelet1 = equippedItems.Bracelet1; | |||
var bracelet2 = equippedItems.Bracelet2; | |||
if (forearm && (bracelet1 || bracelet2)) { | |||
warnings.push("Forearm item is equipped with Bracelet1/Bracelet2. Use either 1 Forearm or 2 Bracelets."); | |||
} | |||
if (primary && primary.is2HD) { | |||
if (secondary) warnings.push("2HD weapon in Primary conflicts with Secondary."); | |||
if (shield) warnings.push("2HD weapon in Primary conflicts with Shield."); | |||
if (held1 || held2) warnings.push("2HD weapon in Primary conflicts with Held1/Held2."); | |||
} | |||
if (shield && secondary) { | |||
warnings.push("Shield conflicts with Secondary weapon."); | |||
} | |||
if (shield && (held1 || held2)) { | |||
warnings.push("Shield conflicts with Held1/Held2."); | |||
} | |||
if ((held1 || held2) && primary && secondary) { | |||
warnings.push("Held items conflict because Primary and Secondary are both equipped."); | |||
} | |||
return warnings; | |||
} | |||
function updateWarnings() { | |||
var warnings = validateEquipment(); | |||
var disabledSlots = getDisabledSlots(); | |||
Object.keys(disabledSlots).forEach(function (slot) { | |||
warnings.push(slot + " is " + disabledSlots[slot]); | |||
}); | }); | ||
if (!warningBox) return; | |||
if (warnings.length === 0) { | |||
warningBox.style.display = "none"; | |||
warningBox.innerHTML = ""; | |||
return; | |||
} | |||
warningBox.style.display = "block"; | |||
warningBox.innerHTML = | |||
"<b>Equipment Warnings</b><br>" + | |||
warnings.map(function (w) { | |||
return "• " + w; | |||
}).join("<br>"); | |||
} | |||
function cbHasValue(value) { | |||
return value !== undefined && value !== null && value !== 0 && value !== ""; | |||
} | |||
function cbGetStatValue(item, key) { | |||
if (!item) return undefined; | |||
if (key === "dmg") { | |||
if (cbHasValue(item.dmg)) return item.dmg; | |||
if (cbHasValue(item.damage)) return item.damage; | |||
} | |||
return item[key]; | |||
} | |||
function formatItemStats(item) { | |||
var rows = []; | |||
function add(label, key) { | |||
var value = cbGetStatValue(item, key); | |||
if (cbHasValue(value)) { | |||
rows.push('<div><span>' + label + '</span> ' + value + '</div>'); | |||
} | |||
} | |||
add("DMG:", "dmg"); | |||
add("AC:", "ac"); | |||
add("STR:", "str"); | |||
add("STA:", "sta"); | |||
add("AGI:", "agi"); | |||
add("DEX:", "dex"); | |||
add("WIS:", "wis"); | |||
add("INT:", "int"); | |||
add("CHA:", "cha"); | |||
add("HP:", "hp"); | |||
add("HoT:", "hot"); | |||
add("POW:", "pow"); | |||
add("PoT:", "pot"); | |||
add("PR:", "pr"); | |||
add("DR:", "dr"); | |||
add("FR:", "fr"); | |||
add("CR:", "cr"); | |||
add("LR:", "lr"); | |||
add("AR:", "ar"); | |||
return rows.join(""); | |||
} | |||
function getItemTagHtml(item) { | |||
var tags = []; | |||
var lore = String(item.lore || "").trim().toUpperCase(); | |||
var noTrade = String(item.notrade || item.noTrade || "").trim().toUpperCase(); | |||
var trade = String(item.trade || "").trim().toUpperCase(); | |||
if (noTrade === "NO TRADE" || trade === "1" || trade === "NO TRADE") { | |||
tags.push("NO TRADE"); | |||
} | |||
if (lore === "LORE" || lore === "1") { | |||
tags.push("LORE"); | |||
} | |||
if (!tags.length) return ""; | |||
return '<div class="cb-tooltip-tags">' + | |||
tags.map(function (tag) { | |||
return '<span class="cb-tooltip-tag">' + tag + '</span>'; | |||
}).join("") + | |||
'</div>'; | |||
} | |||
function getClassText(item) { | |||
if (!item || !item.class) return ""; | |||
if (Array.isArray(item.class)) { | |||
return item.class.join(", "); | |||
} | |||
return item.class; | |||
} | |||
function buildTooltipHtml(item) { | |||
var html = ''; | |||
var classText = getClassText(item); | |||
html += '<div class="cb-tooltip-title cb-rarity-' + | |||
normalizeRarity(item.rarity) + | |||
'">' + | |||
item.name + | |||
'</div>'; | |||
html += getItemTagHtml(item); | |||
if (cbHasValue(item.lvl)) { | |||
html += '<div class="cb-tooltip-line"><b>Level:</b> ' + item.lvl + '</div>'; | |||
} | |||
if (item.slot) { | |||
html += '<div class="cb-tooltip-line"><b>Slot:</b> ' + item.slot + '</div>'; | |||
} | |||
if (cbHasValue(item.dur)) { | |||
html += '<div class="cb-tooltip-line"><b>DUR:</b> ' + item.dur + '</div>'; | |||
} | |||
if (cbHasValue(item.gearhp)) { | |||
html += '<div class="cb-tooltip-line"><b>Gear HP:</b> ' + item.gearhp + '</div>'; | |||
} | |||
if (classText) { | |||
html += '<div class="cb-tooltip-line"><b>Class:</b> ' + classText + '</div>'; | |||
} | |||
if (item.is2HD) { | |||
html += '<div class="cb-tooltip-line"><b>Type:</b> 2HD Weapon</div>'; | |||
} | |||
if (item.weaponstyle) { | |||
html += '<div class="cb-tooltip-line"><b>Style:</b> ' + item.weaponstyle + '</div>'; | |||
} | |||
if (item.proc) { | |||
html += '<div class="cb-tooltip-line"><b>Proc:</b> ' + item.proc + '</div>'; | |||
} | |||
if (item.effect) { | |||
html += '<div class="cb-tooltip-line"><b>Effect:</b> ' + item.effect + '</div>'; | |||
} | |||
if (item.droppedby) { | |||
html += '<div class="cb-tooltip-line"><b>Dropped By:</b> ' + item.droppedby + '</div>'; | |||
} | |||
if (item.zone) { | |||
html += '<div class="cb-tooltip-line"><b>Zone:</b> ' + item.zone + '</div>'; | |||
} | |||
if (item.droprate) { | |||
html += '<div class="cb-tooltip-line"><b>Drop Rate:</b> ' + item.droprate + '</div>'; | |||
} | |||
html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Item Stats</b></div>'; | |||
html += '<div class="cb-tooltip-stats">' + formatItemStats(item) + '</div>'; | |||
return html; | |||
} | |||
function getCompareValue(newItem, oldItem, key) { | |||
var newValue = parseFloat(cbGetStatValue(newItem, key)) || 0; | |||
var oldValue = oldItem ? (parseFloat(cbGetStatValue(oldItem, key)) || 0) : 0; | |||
return newValue - oldValue; | |||
} | |||
function buildCompareTooltipHtml(slot, newItem) { | |||
var oldItem = equippedItems[slot]; | |||
var html = ''; | |||
var classText = getClassText(newItem); | |||
html += '<div class="cb-tooltip-title cb-rarity-' + | |||
normalizeRarity(newItem.rarity) + | |||
'">' + | |||
newItem.name + | |||
'</div>'; | |||
html += getItemTagHtml(newItem); | |||
if (oldItem) { | |||
html += '<div class="cb-tooltip-line"><b>Currently:</b> ' + oldItem.name + '</div>'; | |||
} else { | |||
html += '<div class="cb-tooltip-line"><b>Currently:</b> None</div>'; | |||
} | |||
if (cbHasValue(newItem.lvl)) { | |||
html += '<div class="cb-tooltip-line"><b>Level:</b> ' + newItem.lvl + '</div>'; | |||
} | |||
if (newItem.slot) { | |||
html += '<div class="cb-tooltip-line"><b>Slot:</b> ' + newItem.slot + '</div>'; | |||
} | |||
if (cbHasValue(newItem.dur)) { | |||
html += '<div class="cb-tooltip-line"><b>DUR:</b> ' + newItem.dur + '</div>'; | |||
} | |||
if (cbHasValue(newItem.gearhp)) { | |||
html += '<div class="cb-tooltip-line"><b>Gear HP:</b> ' + newItem.gearhp + '</div>'; | |||
} | |||
if (classText) { | |||
html += '<div class="cb-tooltip-line"><b>Class:</b> ' + classText + '</div>'; | |||
} | |||
if (newItem.is2HD) { | |||
html += '<div class="cb-tooltip-line"><b>Type:</b> 2HD Weapon</div>'; | |||
} | |||
if (newItem.weaponstyle) { | |||
html += '<div class="cb-tooltip-line"><b>Style:</b> ' + newItem.weaponstyle + '</div>'; | |||
} | |||
if (newItem.proc) { | |||
html += '<div class="cb-tooltip-line"><b>Proc:</b> ' + newItem.proc + '</div>'; | |||
} | |||
if (newItem.effect) { | |||
html += '<div class="cb-tooltip-line"><b>Effect:</b> ' + newItem.effect + '</div>'; | |||
} | |||
var compareRows = []; | |||
function addCompare(label, key) { | |||
var diff = getCompareValue(newItem, oldItem, key); | |||
if (diff === 0) return; | |||
var cls = diff > 0 ? 'cb-compare-positive' : 'cb-compare-negative'; | |||
var sign = diff > 0 ? '+' : ''; | |||
compareRows.push( | |||
'<div><span>' + label + '</span> <b class="' + cls + '">' + sign + diff + '</b></div>' | |||
); | |||
} | |||
addCompare("DMG:", "dmg"); | |||
addCompare("AC:", "ac"); | |||
addCompare("STR:", "str"); | |||
addCompare("STA:", "sta"); | |||
addCompare("AGI:", "agi"); | |||
addCompare("DEX:", "dex"); | |||
addCompare("WIS:", "wis"); | |||
addCompare("INT:", "int"); | |||
addCompare("CHA:", "cha"); | |||
addCompare("HP:", "hp"); | |||
addCompare("HoT:", "hot"); | |||
addCompare("POW:", "pow"); | |||
addCompare("PoT:", "pot"); | |||
addCompare("PR:", "pr"); | |||
addCompare("DR:", "dr"); | |||
addCompare("FR:", "fr"); | |||
addCompare("CR:", "cr"); | |||
addCompare("LR:", "lr"); | |||
addCompare("AR:", "ar"); | |||
html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Comparison</b></div>'; | |||
if (compareRows.length) { | |||
html += '<div class="cb-tooltip-stats">' + compareRows.join("") + '</div>'; | |||
} else { | |||
html += '<div class="cb-tooltip-line">No stat difference.</div>'; | |||
} | |||
html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Item Stats</b></div>'; | |||
html += '<div class="cb-tooltip-stats">' + formatItemStats(newItem) + '</div>'; | |||
if (isLoreConflict(newItem, slot)) { | |||
html += '<div class="cb-tooltip-warning">LORE item already equipped</div>'; | |||
} | |||
return html; | |||
} | |||
function showTooltip(item, event) { | |||
if (!tooltip || !item) return; | |||
tooltip.className = | |||
'character-builder-tooltip cb-tooltip-rarity-' + | |||
normalizeRarity(item.rarity); | |||
tooltip.innerHTML = buildTooltipHtml(item); | |||
tooltip.style.display = 'block'; | |||
moveTooltip(event); | |||
} | |||
function moveTooltip(event) { | |||
if (!tooltip || tooltip.style.display === 'none') return; | |||
tooltip.style.left = (event.pageX + 14) + 'px'; | |||
tooltip.style.top = (event.pageY + 14) + 'px'; | |||
} | |||
function hideTooltip() { | |||
if (!tooltip) return; | |||
tooltip.style.display = 'none'; | |||
} | |||
function showCompareTooltip(slot, item, event) { | |||
if (!compareTooltip || !item) return; | |||
compareTooltip.className = | |||
'character-builder-tooltip cb-compare-tooltip cb-tooltip-rarity-' + | |||
normalizeRarity(item.rarity); | |||
compareTooltip.innerHTML = | |||
buildCompareTooltipHtml(slot, item); | |||
compareTooltip.style.display = 'block'; | |||
moveCompareTooltip(event); | |||
} | |||
function moveCompareTooltip(event) { | |||
if (!compareTooltip || compareTooltip.style.display === 'none') return; | |||
compareTooltip.style.left = (event.pageX + 14) + 'px'; | |||
compareTooltip.style.top = (event.pageY + 14) + 'px'; | |||
} | |||
function hideCompareTooltip() { | |||
if (!compareTooltip) return; | |||
compareTooltip.style.display = 'none'; | |||
} | |||
function updateEquippedSummary() { | |||
if (!equippedSummaryBody) return; | |||
equippedSummaryBody.innerHTML = ''; | |||
gearSlots.forEach(function (slot) { | |||
var item = equippedItems[slot]; | |||
var row = document.createElement('tr'); | |||
var slotCell = document.createElement('td'); | |||
slotCell.textContent = slot; | |||
slotCell.style.fontWeight = 'bold'; | |||
var itemCell = document.createElement('td'); | |||
if (item) { | |||
var itemSpan = document.createElement('span'); | |||
itemSpan.textContent = item.name; | |||
itemSpan.className = | |||
'cb-equipped-item-name cb-rarity-' + | |||
normalizeRarity(item.rarity); | |||
itemSpan.style.cursor = 'pointer'; | |||
itemSpan.addEventListener('click', function () { | |||
openItemPage(item.name); | |||
}); | |||
itemSpan.addEventListener('mouseenter', function (event) { | |||
showTooltip(item, event); | |||
}); | |||
itemSpan.addEventListener('mousemove', moveTooltip); | |||
itemSpan.addEventListener('mouseleave', hideTooltip); | |||
itemCell.appendChild(itemSpan); | |||
} else { | } else { | ||
itemCell.textContent = '-'; | |||
} | } | ||
row.appendChild(slotCell); | |||
row.appendChild(itemCell); | |||
equippedSummaryBody.appendChild(row); | |||
}); | }); | ||
} | |||
function updateWeaponSummary() { | |||
function setWeaponRow(slotName, prefix) { | |||
var item = equippedItems[slotName]; | |||
var nameEl = document.getElementById(prefix + '-name'); | |||
var dmgEl = document.getElementById(prefix + '-dmg'); | |||
var styleEl = document.getElementById(prefix + '-style'); | |||
var effectEl = document.getElementById(prefix + '-effect'); | |||
if (!nameEl || !dmgEl || !styleEl || !effectEl) return; | |||
nameEl.innerHTML = ''; | |||
if (item) { | |||
var link = document.createElement('span'); | |||
link.textContent = item.name; | |||
link.className = | |||
'cb-equipped-item-name cb-rarity-' + | |||
normalizeRarity(item.rarity); | |||
link.style.cursor = 'pointer'; | |||
link.addEventListener('click', function () { | |||
openItemPage(item.name); | |||
}); | |||
link.addEventListener('mouseenter', function (event) { | |||
showTooltip(item, event); | |||
}); | |||
link.addEventListener('mousemove', moveTooltip); | |||
link.addEventListener('mouseleave', hideTooltip); | |||
nameEl.appendChild(link); | |||
} else { | |||
nameEl.textContent = '-'; | |||
} | |||
dmgEl.textContent = item && item.dmg ? item.dmg : '-'; | |||
if (item) { | |||
if (item.weaponstyle) { | |||
styleEl.textContent = item.weaponstyle; | |||
} else if (item.is2HD) { | |||
styleEl.textContent = '2HD'; | |||
} else if (item.isPrimary || item.isSecondary) { | |||
styleEl.textContent = '1HD'; | |||
} else { | |||
styleEl.textContent = '-'; | |||
} | |||
} else { | |||
styleEl.textContent = '-'; | |||
} | |||
var effectText = '-'; | |||
if (item) { | |||
if (item.proc) { | |||
effectText = item.proc; | |||
} else if (item.effect) { | |||
effectText = item.effect; | |||
} | |||
} | |||
effectEl.textContent = effectText; | |||
} | |||
setWeaponRow('Primary', 'cb-weapon-primary'); | |||
setWeaponRow('Secondary', 'cb-weapon-secondary'); | |||
setWeaponRow('Range', 'cb-weapon-range'); | |||
} | } | ||
| Line 355: | Line 2,473: | ||
var race = raceSelect.value; | var race = raceSelect.value; | ||
var classCode = classSelect.value; | var classCode = classSelect.value; | ||
return data.baseStats[race][classCode]; | |||
} | |||
return | function getItemVitalBonus(currentValue, baseValue) { | ||
currentValue = parseInt(currentValue, 10) || 0; | |||
baseValue = parseInt(baseValue, 10) || 0; | |||
var bonus = currentValue - baseValue; | |||
return bonus > 0 ? bonus : 0; | |||
} | |||
function normalizeVitalRaceKey(race) { | |||
race = String(race || '').toLowerCase(); | |||
if (race.indexOf('human') !== -1 || race === 'hum') return 'Human'; | |||
if (race.indexOf('barbarian') !== -1 || race === 'bar') return 'Barbarian'; | |||
return race; | |||
} | |||
function normalizeVitalClassKey(classCode) { | |||
classCode = String(classCode || '').toUpperCase(); | |||
if (classCode === 'ROG') return 'RGE'; | |||
if (classCode === 'ROGUE') return 'RGE'; | |||
if (classCode === 'WARRIOR') return 'WAR'; | |||
if (classCode === 'PALADIN') return 'PAL'; | |||
if (classCode === 'SHADOWKNIGHT') return 'SK'; | |||
if (classCode === 'SHADOW KNIGHT') return 'SK'; | |||
if (classCode === 'MAGICIAN') return 'MAG'; | |||
if (classCode === 'SHAMAN') return 'SHA'; | |||
return classCode; | |||
} | |||
function getConfiguredBaseVital(vitalKey, race, classCode, level) { | |||
var config = window.EQOA_CONFIG; | |||
var raceKey = normalizeVitalRaceKey(race); | |||
var classKey = normalizeVitalClassKey(classCode); | |||
if (parseInt(level, 10) !== 60) { | |||
return null; | |||
} | |||
if ( | |||
!config || | |||
!config.characterBuilderVitals || | |||
!config.characterBuilderVitals.level60Base || | |||
!config.characterBuilderVitals.level60Base[vitalKey] || | |||
!config.characterBuilderVitals.level60Base[vitalKey][raceKey] | |||
) { | |||
return null; | |||
} | |||
if (config.characterBuilderVitals.level60Base[vitalKey][raceKey][classKey] !== undefined) { | |||
return config.characterBuilderVitals.level60Base[vitalKey][raceKey][classKey]; | |||
} | |||
return null; | |||
} | |||
function getClassMasteryBaseStats() { | |||
var base = getBaseStats() || {}; | |||
var result = {}; | |||
['str', 'sta', 'agi', 'dex', 'wis', 'int', 'cha'].forEach(function (key) { | |||
result[key.toUpperCase()] = | |||
(parseInt(base[key], 10) || 0) + | |||
(parseInt(trainingStats[key], 10) || 0); | |||
}); | |||
return result; | |||
} | |||
function publishCharacterBuilderState() { | |||
window.EQOA_CHARACTER_BUILDER_STATE = { | |||
getState: function () { | |||
return { | |||
race: raceSelect.value, | |||
classCode: classSelect.value, | |||
level: getCharacterLevel(), | |||
transformationPath: getTransformationPath(), | |||
cmAvailable: getAvailableCmPoints(), | |||
cmMax: getMaxCmPointsForLevel(), | |||
baseStats: getClassMasteryBaseStats(), | |||
selectedClassMasteries: sharedClassMasteries | |||
}; | |||
} | |||
}; | |||
if (window.dispatchEvent) { | |||
window.dispatchEvent(new CustomEvent('eqoa-character-builder-state-change')); | |||
} | |||
} | |||
if (transformationPathSelect) { | |||
transformationPathSelect.addEventListener('change', function () { | |||
updateStats(); | |||
if (window.dispatchEvent) { | |||
window.dispatchEvent(new CustomEvent('eqoa-character-builder-state-change')); | |||
} | |||
}); | |||
} | |||
function getSpentTrainingPoints() { | |||
var total = 0; | |||
Object.keys(trainingStats).forEach(function (key) { | |||
total += parseInt(trainingStats[key], 10) || 0; | |||
}); | |||
return total; | |||
} | |||
function clampTrainingInput(changedKey) { | |||
var level = getCharacterLevel(); | |||
var availableTP = getTrainingPointsForLevel(level); | |||
var spentOther = 0; | |||
Object.keys(trainingStats).forEach(function (key) { | |||
if (key !== changedKey) { | |||
spentOther += parseInt(trainingStats[key], 10) || 0; | |||
} | |||
}); | |||
var input = document.getElementById('train-' + changedKey); | |||
var value = parseInt(input.value, 10); | |||
if (isNaN(value) || value < 0) value = 0; | |||
var maxAllowed = availableTP - spentOther; | |||
if (maxAllowed < 0) maxAllowed = 0; | |||
if (value > maxAllowed) { | |||
value = maxAllowed; | |||
input.value = value; | |||
} | |||
trainingStats[changedKey] = value; | |||
} | } | ||
| Line 363: | Line 2,633: | ||
} | } | ||
function updateStats() { | function setCappedStat(elementId, value, cap) { | ||
var element = document.getElementById(elementId); | |||
if (!element) return; | |||
value = parseFloat(value) || 0; | |||
cap = parseFloat(cap) || 0; | |||
if (value > cap) { | |||
element.innerHTML = | |||
cap + | |||
'<span class="cb-stat-cap">(' + cap + ')</span>' + | |||
'<span class="cb-overcap">(+' + (value - cap) + ')</span>'; | |||
} else { | |||
element.innerHTML = | |||
value + | |||
'<span class="cb-stat-cap">(' + cap + ')</span>'; | |||
} | |||
} | |||
function getClassMasteryStatCaps() { | |||
var caps = { | |||
str: 400, | |||
sta: 400, | |||
agi: 400, | |||
dex: 400, | |||
wis: 400, | |||
int: 400, | |||
cha: 400 | |||
}; | |||
if ( | |||
window.EQOA_CLASS_MASTERY_STATE && | |||
typeof window.EQOA_CLASS_MASTERY_STATE.getStatCaps === 'function' | |||
) { | |||
var cmCaps = window.EQOA_CLASS_MASTERY_STATE.getStatCaps(); | |||
Object.keys(caps).forEach(function (key) { | |||
if (cmCaps[key] !== undefined) { | |||
caps[key] = parseInt(cmCaps[key], 10) || caps[key]; | |||
} | |||
}); | |||
} | |||
return caps; | |||
} | |||
function getBuildDataFromUrl(url) { | |||
try { | |||
var parsedUrl = new URL(url); | |||
var encoded = | |||
parsedUrl.searchParams.get('b') || | |||
parsedUrl.searchParams.get('build'); | |||
var buildData; | |||
if (!encoded) return null; | |||
buildData = decodeBuildData(encoded); | |||
if (!buildData) return null; | |||
buildData.buildName = buildData.buildName || buildData.n; | |||
buildData.buildNotes = buildData.buildNotes || buildData.no; | |||
buildData.race = buildData.race || buildData.r; | |||
buildData.classCode = buildData.classCode || buildData.c; | |||
buildData.level = buildData.level || buildData.l; | |||
buildData.cmPoints = buildData.cmPoints || buildData.cm; | |||
buildData.trainingStats = buildData.trainingStats || buildData.t; | |||
buildData.equippedItemNames = buildData.equippedItemNames || buildData.i; | |||
buildData.transformationPath = buildData.transformationPath || buildData.p; | |||
buildData.classMasteries = buildData.classMasteries || buildData.cms || []; | |||
return buildData; | |||
} catch (e) { | |||
console.error('Could not read build URL for comparison.', e); | |||
return null; | |||
} | |||
} | |||
function formatCompareValue(value) { | |||
if (value === undefined || value === null || value === '') return '-'; | |||
return value; | |||
} | |||
function compareTextValue(label, a, b) { | |||
if (a === b) { | |||
return '<div><b>' + label + ':</b> ' + formatCompareValue(a) + '</div>'; | |||
} | |||
return '<div><b>' + label + ':</b> ' + | |||
formatCompareValue(a) + | |||
' → <b>' + | |||
formatCompareValue(b) + | |||
'</b></div>'; | |||
} | |||
function compareBuildEquipment(buildA, buildB) { | |||
var html = '<div style="margin-top:10px;"><b>Equipment Differences</b></div>'; | |||
gearSlots.forEach(function (slot) { | |||
var itemA = buildA.equippedItemNames && buildA.equippedItemNames[slot] | |||
? buildA.equippedItemNames[slot] | |||
: '-'; | |||
var itemB = buildB.equippedItemNames && buildB.equippedItemNames[slot] | |||
? buildB.equippedItemNames[slot] | |||
: '-'; | |||
if (itemA !== itemB) { | |||
html += '<div><b>' + slot + ':</b> ' + itemA + ' → <b>' + itemB + '</b></div>'; | |||
} | |||
}); | |||
return html; | |||
} | |||
function calculateBuildTotalsFromSharedBuild(buildData) { | |||
var race = buildData.race; | |||
var classCode = buildData.classCode; | |||
if (!race || !classCode || !data.baseStats[race] || !data.baseStats[race][classCode]) { | |||
return null; | |||
} | |||
var base = data.baseStats[race][classCode]; | |||
var total = { | |||
str: base.str || 0, | |||
sta: base.sta || 0, | |||
agi: base.agi || 0, | |||
dex: base.dex || 0, | |||
wis: base.wis || 0, | |||
int: base.int || 0, | |||
cha: base.cha || 0, | |||
hp: base.hp || 0, | |||
pow: base.pow || 0, | |||
ac: base.ac || 0, | |||
pr: 48, | |||
dr: 48, | |||
fr: 48, | |||
cr: 48, | |||
lr: 48, | |||
ar: 48 | |||
}; | |||
if (buildData.trainingStats) { | |||
Object.keys(buildData.trainingStats).forEach(function (key) { | |||
if (total[key] !== undefined) { | |||
total[key] += parseInt(buildData.trainingStats[key], 10) || 0; | |||
} | |||
}); | |||
} | |||
if (buildData.equippedItemNames) { | |||
gearSlots.forEach(function (slot) { | |||
var itemName = buildData.equippedItemNames[slot]; | |||
var item = findLoadedItemByName(itemName, slot); | |||
if (!item) return; | |||
["str", "sta", "agi", "dex", "wis", "int", "cha", "hp", "pow", "ac", "pr", "dr", "fr", "cr", "lr", "ar"].forEach(function (key) { | |||
total[key] += parseFloat(item[key]) || 0; | |||
}); | |||
}); | |||
} | |||
return total; | |||
} | |||
function compareBuildStats(buildA, buildB) { | |||
var totalsA = calculateBuildTotalsFromSharedBuild(buildA); | |||
var totalsB = calculateBuildTotalsFromSharedBuild(buildB); | |||
if (!totalsA || !totalsB) { | |||
return '<div style="margin-top:10px;"><b>Stat Differences</b><br>Could not calculate stats for one or both builds.</div>'; | |||
} | |||
var statOrder = [ | |||
"str", "sta", "agi", "dex", "wis", "int", "cha", | |||
"hp", "pow", "ac", | |||
"pr", "dr", "fr", "cr", "lr", "ar" | |||
]; | |||
var html = '<div style="margin-top:10px;"><b>Stat Differences</b></div>'; | |||
statOrder.forEach(function (key) { | |||
var a = parseFloat(totalsA[key]) || 0; | |||
var b = parseFloat(totalsB[key]) || 0; | |||
var diff = b - a; | |||
var label = key.toUpperCase(); | |||
var diffText = diff > 0 ? ' <span class="cb-compare-positive">(+' + diff + ')</span>' : | |||
diff < 0 ? ' <span class="cb-compare-negative">(' + diff + ')</span>' : | |||
' (0)'; | |||
html += '<div><b>' + label + ':</b> ' + a + ' → ' + b + diffText + '</div>'; | |||
}); | |||
return html; | |||
} | |||
function compareSharedBuilds() { | |||
if (!compareUrlAInput || !compareUrlBInput || !compareResultsBox) return; | |||
var buildA = getBuildDataFromUrl(compareUrlAInput.value.trim()); | |||
var buildB = getBuildDataFromUrl(compareUrlBInput.value.trim()); | |||
if (!buildA || !buildB) { | |||
compareResultsBox.style.display = 'block'; | |||
compareResultsBox.innerHTML = 'Could not compare builds. Make sure both URLs are valid shared build URLs.'; | |||
return; | |||
} | |||
var html = '<b>Build Comparison</b><br>'; | |||
html += compareTextValue('Build Name', buildA.buildName || '-', buildB.buildName || '-'); | |||
html += compareTextValue('Race', buildA.race || '-', buildB.race || '-'); | |||
html += compareTextValue('Class', buildA.classCode || '-', buildB.classCode || '-'); | |||
html += compareTextValue('Level', buildA.level || '-', buildB.level || '-'); | |||
html += compareBuildEquipment(buildA, buildB); | |||
html += compareBuildStats(buildA, buildB); | |||
compareResultsBox.innerHTML = html; | |||
compareResultsBox.style.display = 'block'; | |||
} | |||
function applyBuilderTheme(theme) { | |||
if (!app) return; | |||
app.classList.remove('cb-theme-dark'); | |||
app.classList.remove('cb-theme-light'); | |||
if (theme === 'light') { | |||
app.classList.add('cb-theme-light'); | |||
} else { | |||
app.classList.add('cb-theme-dark'); | |||
} | |||
} | |||
function toggleBuilderTheme() { | |||
var isLight = app.classList.contains('cb-theme-light'); | |||
if (isLight) { | |||
applyBuilderTheme('dark'); | |||
} else { | |||
applyBuilderTheme('light'); | |||
} | |||
} | |||
function loadSavedBuilderTheme() { | |||
var isLight = | |||
document.documentElement.classList.contains('eqoa-theme-light') || | |||
document.body.classList.contains('eqoa-theme-light'); | |||
applyBuilderTheme(isLight ? 'light' : 'dark'); | |||
} | |||
function getItemPrimarySlotLabel(item) { | |||
if (!item) return "-"; | |||
if (item.slot) return item.slot; | |||
if (item.isPrimary) return "Primary"; | |||
if (item.isSecondary) return "Secondary"; | |||
if (item.isShield) return "Shield"; | |||
if (item.isHeld) return "Held"; | |||
if (item.categories && item.categories.length) { | |||
for (var i = 0; i < item.categories.length; i++) { | |||
var cat = item.categories[i]; | |||
if (cat.indexOf(" Items") !== -1) { | |||
return cat.replace(" Items", ""); | |||
} | |||
} | |||
} | |||
return "-"; | |||
} | |||
function searchAllLoadedItems(query) { | |||
query = String(query || "").toLowerCase().trim(); | |||
if (!query) return []; | |||
var allItems = getAllLoadedItemsFlat(); | |||
return allItems.filter(function (item) { | |||
var searchable = [ | |||
item.name, | |||
item.class, | |||
item.slot, | |||
item.weaponstyle, | |||
item.proc, | |||
item.effect, | |||
item.droppedby, | |||
item.zone, | |||
item.droprate, | |||
item.categories ? item.categories.join(" ") : "" | |||
].join(" ").toLowerCase(); | |||
return searchable.indexOf(query) !== -1; | |||
}).slice(0, 30); | |||
} | |||
function renderGlobalItemSearchResults() { | |||
if (!globalItemSearchInput || !globalSearchResultsBox) return; | |||
var query = globalItemSearchInput.value; | |||
var results = searchAllLoadedItems(query); | |||
if (!query || !query.trim()) { | |||
globalSearchResultsBox.style.display = "none"; | |||
globalSearchResultsBox.innerHTML = ""; | |||
return; | |||
} | |||
globalSearchResultsBox.style.display = "block"; | |||
if (!results.length) { | |||
globalSearchResultsBox.innerHTML = "No matching items found."; | |||
return; | |||
} | |||
var html = '<b>Global Item Search Results</b><br>'; | |||
results.forEach(function (item) { | |||
var slotLabel = getItemPrimarySlotLabel(item); | |||
var lvlText = item.lvl ? "Lv " + item.lvl : "No Lv"; | |||
var classText = item.class ? item.class : "ALL"; | |||
var sourceText = ""; | |||
if (item.zone || item.droppedby) { | |||
sourceText = " — " + [ | |||
item.zone || "", | |||
item.droppedby || "" | |||
].filter(Boolean).join(" / "); | |||
} | |||
html += '<div style="margin-top:6px;">' + | |||
'<span class="cb-equipped-item-name" style="cursor:pointer;" data-global-item-name="' + item.name.replace(/"/g, '"') + '">' + | |||
item.name + | |||
'</span>' + | |||
' — ' + slotLabel + | |||
' — ' + lvlText + | |||
' — ' + classText + | |||
sourceText + | |||
'</div>'; | |||
}); | |||
globalSearchResultsBox.innerHTML = html; | |||
Array.prototype.forEach.call( | |||
globalSearchResultsBox.querySelectorAll('[data-global-item-name]'), | |||
function (el) { | |||
el.addEventListener('click', function () { | |||
openItemPage(el.getAttribute('data-global-item-name')); | |||
}); | |||
el.addEventListener('mouseenter', function (event) { | |||
var itemName = el.getAttribute('data-global-item-name'); | |||
var item = findLoadedItemByName(itemName, "Primary") || getAllLoadedItemsFlat().find(function (x) { | |||
return x.name === itemName; | |||
}); | |||
if (item) { | |||
showTooltip(item, event); | |||
} | |||
}); | |||
el.addEventListener('mousemove', moveTooltip); | |||
el.addEventListener('mouseleave', hideTooltip); | |||
} | |||
); | |||
} | |||
function escapeExportHtml(value) { | |||
return String(value == null ? '' : value) | |||
.replace(/&/g, '&') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>') | |||
.replace(/"/g, '"') | |||
.replace(/'/g, '''); | |||
} | |||
function populateExportCard() { | |||
var buildName = buildNameInput ? buildNameInput.value.trim() : ''; | |||
function setExportText(id, value) { | |||
var el = document.getElementById(id); | |||
if (el) { | |||
el.textContent = value; | |||
} | |||
} | |||
var classIcons = { | |||
WAR: '/images/classes/war.png', | |||
PAL: '/images/classes/pal.png', | |||
SK: '/images/classes/sk.png', | |||
RGE: '/images/classes/rge.png', | |||
BRD: '/images/classes/brd.png', | |||
RAN: '/images/classes/ran.png', | |||
MNK: '/images/classes/mnk.png', | |||
CL: '/images/classes/cl.png', | |||
DRD: '/images/classes/drd.png', | |||
SHA: '/images/classes/sha.png', | |||
NEC: '/images/classes/nec.png', | |||
MAG: '/images/classes/mag.png', | |||
ENC: '/images/classes/enc.png', | |||
WIZ: '/images/classes/wiz.png', | |||
ALC: '/images/classes/alc.png' | |||
}; | |||
setExportText('cb-export-build-name', buildName || 'Unnamed Build'); | |||
setExportText( | |||
'cb-export-race-transform', | |||
(raceSelect.value || '-') + ', ' + (getTransformationPath() || 'None') | |||
); | |||
setExportText( | |||
'cb-export-class', | |||
data.classes && data.classes[classSelect.value] | |||
? data.classes[classSelect.value] | |||
: (classSelect.value || '-') | |||
); | |||
var classIcons = { | |||
WAR: '/eqoawiki/index.php/Special:Redirect/file/WAR.png', | |||
PAL: '/eqoawiki/index.php/Special:Redirect/file/PAL.png', | |||
SK: '/eqoawiki/index.php/Special:Redirect/file/SK.png', | |||
RGE: '/eqoawiki/index.php/Special:Redirect/file/RGE.png', | |||
BRD: '/eqoawiki/index.php/Special:Redirect/file/BRD.png', | |||
RAN: '/eqoawiki/index.php/Special:Redirect/file/RAN.png', | |||
MNK: '/eqoawiki/index.php/Special:Redirect/file/MNK.png', | |||
CL: '/eqoawiki/index.php/Special:Redirect/file/CL.png', | |||
DRD: '/eqoawiki/index.php/Special:Redirect/file/DRD.png', | |||
SHA: '/eqoawiki/index.php/Special:Redirect/file/SHA.png', | |||
NEC: '/eqoawiki/index.php/Special:Redirect/file/NEC.png', | |||
MAG: '/eqoawiki/index.php/Special:Redirect/file/MAG.png', | |||
ENC: '/eqoawiki/index.php/Special:Redirect/file/ENC.png', | |||
WIZ: '/eqoawiki/index.php/Special:Redirect/file/WIZ.png', | |||
ALC: '/eqoawiki/index.php/Special:Redirect/file/ALC.png' | |||
}; | |||
var classIcon = document.getElementById('cb-export-class-icon'); | |||
if (classIcon) { | |||
classIcon.src = | |||
classIcons[classSelect.value] || ''; | |||
classIcon.style.display = | |||
classIcons[classSelect.value] | |||
? 'block' | |||
: 'none'; | |||
} | |||
var classIcon = document.getElementById('cb-export-class-icon'); | |||
if (classIcon) { | |||
classIcon.src = | |||
classIcons[classSelect.value] || ''; | |||
classIcon.style.display = | |||
classIcons[classSelect.value] | |||
? 'block' | |||
: 'none'; | |||
} | |||
setExportText('cb-export-level', getCharacterLevel()); | |||
function statLine(key) { | |||
var el = document.getElementById('cb-' + key); | |||
var value = el ? el.textContent : '0'; | |||
return '<div><b>' + key.toUpperCase() + ':</b> ' + value + '</div>'; | |||
} | |||
var coreStats = [ | |||
'str', 'wis', | |||
'sta', 'int', | |||
'agi', 'cha', | |||
'dex' | |||
]; | |||
var resists = [ | |||
'fr', 'ar', | |||
'cr', 'pr', | |||
'lr', 'dr' | |||
]; | |||
var vitals = [ | |||
'hp', | |||
'pow', | |||
'ac' | |||
]; | |||
document.getElementById('cb-export-core-stats').innerHTML = | |||
coreStats.map(statLine).join(''); | |||
document.getElementById('cb-export-resists').innerHTML = | |||
resists.map(statLine).join(''); | |||
setExportText( | |||
'cb-export-hp-bar', | |||
document.getElementById('cb-hp') ? document.getElementById('cb-hp').textContent : '0' | |||
); | |||
setExportText( | |||
'cb-export-pow-bar', | |||
document.getElementById('cb-pow') ? document.getElementById('cb-pow').textContent : '0' | |||
); | |||
setExportText( | |||
'cb-export-ac-value', | |||
document.getElementById('cb-ac') ? document.getElementById('cb-ac').textContent : '0' | |||
); | |||
var equipmentRows = [ | |||
['Head', 'Hands'], | |||
['Ear1', 'Ear2'], | |||
['Neck', 'Waist'], | |||
['Bracelet1', 'Bracelet2'], | |||
['Forearm', 'Bracelet2'], | |||
['Chest', 'Legs'], | |||
['Robe', 'Feet'], | |||
['Ring1', 'Ring2'], | |||
['Primary', 'Secondary'], | |||
['Held1', 'Held2'], | |||
['Range', ''] | |||
]; | |||
function getExportItemHtml(slot) { | |||
var item; | |||
if (!slot) return ''; | |||
item = equippedItems[slot]; | |||
if (!item) return ''; | |||
return '<span class="cb-export-slot-label">' + | |||
slot + | |||
'</span> ' + | |||
'<span class="cb-rarity-' + | |||
normalizeRarity(item.rarity) + | |||
'">' + | |||
escapeExportHtml(item.name) + | |||
'</span>'; | |||
} | |||
function rowHasAnyItem(row) { | |||
return row.some(function (slot) { | |||
return slot && equippedItems[slot]; | |||
}); | |||
} | |||
var itemsHtml = ''; | |||
equipmentRows.forEach(function (row) { | |||
if (!rowHasAnyItem(row)) return; | |||
if (row[0] === 'Bracelet1' && equippedItems.Forearm) return; | |||
if (row[0] === 'Forearm' && !equippedItems.Forearm) return; | |||
itemsHtml += | |||
'<div class="cb-export-equipment-row">' + | |||
'<div>' + getExportItemHtml(row[0]) + '</div>' + | |||
'<div>' + getExportItemHtml(row[1]) + '</div>' + | |||
'</div>'; | |||
}); | |||
document.getElementById('cb-export-items').innerHTML = | |||
itemsHtml || '<div class="cb-export-empty">No equipment selected.</div>'; | |||
if (exportNotesBox) { | |||
var notes = buildNotesInput | |||
? buildNotesInput.value.trim() | |||
: ''; | |||
exportNotesBox.textContent = | |||
notes || 'No build notes.'; | |||
} | |||
var cmState = window.EQOA_CLASS_MASTERY_STATE; | |||
var cmData = cmState && typeof cmState.getExportData === 'function' | |||
? cmState.getExportData() | |||
: null; | |||
if (exportMasterClassBox) { | |||
exportMasterClassBox.textContent = | |||
cmData && cmData.masterClassTitle ? cmData.masterClassTitle : 'None'; | |||
} | |||
var exportLevelTitleBox = document.getElementById('cb-export-level-title'); | |||
if (exportLevelTitleBox) { | |||
exportLevelTitleBox.textContent = | |||
'Level ' + | |||
getCharacterLevel() + | |||
' ' + | |||
( | |||
cmData && cmData.masterClassTitle | |||
? cmData.masterClassTitle | |||
: ( | |||
data.classes && data.classes[classSelect.value] | |||
? data.classes[classSelect.value] | |||
: classSelect.value | |||
) | |||
); | |||
} | |||
if (exportClassPathBox) { | |||
var classPathRows = getSelectedClassPathSummary(); | |||
if (!classPathRows.length) { | |||
exportClassPathBox.textContent = 'None'; | |||
} else { | |||
exportClassPathBox.innerHTML = classPathRows.map(function (row) { | |||
return '<div class="cb-export-class-path-row">' + | |||
'<b>Lv' + escapeExportHtml(row.tier) + '</b> ' + | |||
escapeExportHtml(row.value) + | |||
'</div>'; | |||
}).join(''); | |||
} | |||
} | |||
if (exportExemplarPathBox) { | |||
exportExemplarPathBox.textContent = | |||
cmData && cmData.exemplarPathChoice ? cmData.exemplarPathChoice : 'None'; | |||
} | |||
if (exportTransformPathBox) { | |||
exportTransformPathBox.textContent = getTransformationPath() || 'None'; | |||
} | |||
function getExportCmRows(selectedMasteries) { | |||
var groups = {}; | |||
var rows = []; | |||
function romanToNumber(value) { | |||
return { | |||
I: 1, | |||
II: 2, | |||
III: 3, | |||
IV: 4, | |||
V: 5 | |||
}[String(value || '').toUpperCase()] || 0; | |||
} | |||
selectedMasteries.forEach(function (name) { | |||
var match = String(name || '').match(/^(.*)\s+(\d+|I|II|III|IV|V)$/i); | |||
var baseName; | |||
var rankText; | |||
var rankValue; | |||
if (!match) { | |||
rows.push({ | |||
label: name, | |||
sort: name | |||
}); | |||
return; | |||
} | |||
baseName = match[1]; | |||
rankText = match[2]; | |||
rankValue = parseInt(rankText, 10); | |||
if (isNaN(rankValue)) { | |||
rankValue = romanToNumber(rankText); | |||
} | |||
if (!groups[baseName]) { | |||
groups[baseName] = { | |||
baseName: baseName, | |||
ranks: [] | |||
}; | |||
} | |||
groups[baseName].ranks.push({ | |||
text: rankText, | |||
value: rankValue | |||
}); | |||
}); | |||
Object.keys(groups).sort().forEach(function (baseName) { | |||
var group = groups[baseName]; | |||
group.ranks.sort(function (a, b) { | |||
return a.value - b.value; | |||
}); | |||
rows.push({ | |||
label: group.baseName + ' ' + group.ranks.map(function (rank) { | |||
return rank.text; | |||
}).join(' > '), | |||
sort: group.baseName | |||
}); | |||
}); | |||
rows.sort(function (a, b) { | |||
return String(a.sort || '').localeCompare(String(b.sort || '')); | |||
}); | |||
return rows; | |||
} | |||
if (exportCmBox) { | |||
if (!cmData) { | |||
exportCmBox.innerHTML = '<div>No Class Mastery data selected.</div>'; | |||
} else { | |||
var html = ''; | |||
var cmRows = getExportCmRows(cmData.selectedMasteries || []); | |||
var splitIndex = Math.ceil(cmRows.length / 2); | |||
var leftRows = cmRows.slice(0, splitIndex); | |||
var rightRows = cmRows.slice(splitIndex); | |||
function renderCmColumn(rows) { | |||
if (!rows.length) { | |||
return '<div>None selected.</div>'; | |||
} | |||
return rows.map(function (row) { | |||
return '<div class="cb-export-cm-row">- ' + escapeExportHtml(row.label) + '</div>'; | |||
}).join(''); | |||
} | |||
html += '<div><b>CMs:</b> ' + cmData.spent + ' spent / ' + cmData.available + ' available</div>'; | |||
html += '<div style="margin-top:10px;"><b>Selected Masteries</b></div>'; | |||
html += '<div class="cb-export-cm-columns">' + | |||
'<div>' + renderCmColumn(leftRows) + '</div>' + | |||
'<div>' + renderCmColumn(rightRows) + '</div>' + | |||
'</div>'; | |||
exportCmBox.innerHTML = html; | |||
} | |||
} | |||
} | |||
function exportBuildCardImage() { | |||
if (typeof html2canvas === 'undefined') { | |||
alert('Export image tool is still loading. Try again in a moment.'); | |||
return; | |||
} | |||
if (!exportCardInner) { | |||
alert('Export card container not found.'); | |||
return; | |||
} | |||
populateExportCard(); | |||
var exportContainer = document.getElementById('cb-export-card-container'); | |||
if (exportContainer) { | |||
exportContainer.style.position = 'fixed'; | |||
exportContainer.style.left = '-99999px'; | |||
exportContainer.style.top = '0'; | |||
exportContainer.style.width = '1200px'; | |||
exportContainer.style.minWidth = '1200px'; | |||
exportContainer.style.opacity = '1'; | |||
exportContainer.style.pointerEvents = 'none'; | |||
exportContainer.style.zIndex = '-9999'; | |||
exportContainer.style.display = 'block'; | |||
} | |||
exportCardInner.style.width = '1200px'; | |||
exportCardInner.style.minWidth = '1200px'; | |||
exportCardInner.style.display = 'block'; | |||
setTimeout(function () { | |||
html2canvas(exportCardInner, { | |||
backgroundColor: '#100a06', | |||
scale: 2, | |||
useCORS: true, | |||
allowTaint: true, | |||
logging: false, | |||
onclone: function (clonedDocument) { | |||
Array.prototype.forEach.call( | |||
clonedDocument.querySelectorAll('#cb-export-card-inner *'), | |||
function (el) { | |||
el.style.backgroundImage = 'none'; | |||
el.style.webkitMaskImage = 'none'; | |||
el.style.maskImage = 'none'; | |||
} | |||
); | |||
var hpBar = clonedDocument.querySelector('.cb-export-hp-row .cb-export-resource-bar'); | |||
var powBar = clonedDocument.querySelector('.cb-export-pow-row .cb-export-resource-bar'); | |||
if (hpBar) { | |||
hpBar.style.setProperty('background-color', '#b00000', 'important'); | |||
hpBar.style.setProperty('border-color', '#050505', 'important'); | |||
Array.prototype.forEach.call(hpBar.querySelectorAll('i'), function (bubble) { | |||
bubble.style.setProperty('background-color', '#b00000', 'important'); | |||
}); | |||
} | |||
if (powBar) { | |||
powBar.style.setProperty('background-color', '#0070b8', 'important'); | |||
powBar.style.setProperty('border-color', '#050505', 'important'); | |||
Array.prototype.forEach.call(powBar.querySelectorAll('i'), function (bubble) { | |||
bubble.style.setProperty('background-color', '#0070b8', 'important'); | |||
}); | |||
} | |||
} | |||
}).then(function (canvas) { | |||
var link = document.createElement('a'); | |||
var buildName = buildNameInput && buildNameInput.value | |||
? buildNameInput.value.trim() | |||
: 'EQOA-Build'; | |||
link.download = buildName.replace(/[^a-z0-9]/gi, '_') + '.png'; | |||
link.href = canvas.toDataURL('image/png'); | |||
link.click(); | |||
}).catch(function (error) { | |||
console.error('Export build card failed.', error); | |||
alert('Export failed. Check console for details.'); | |||
}); | |||
}, 150); | |||
} | |||
function normalizeRarity(rarity) { | |||
return String(rarity || 'common') | |||
.toLowerCase() | |||
.replace(/\s+/g, '-'); | |||
} | |||
function getClassArchetype(classCode) { | |||
classCode = normalizeVitalClassKey(classCode); | |||
if (['WAR', 'PAL', 'SK'].indexOf(classCode) !== -1) return 'tank'; | |||
if (['RGE', 'ROG', 'BRD', 'RAN', 'MNK', 'ALC'].indexOf(classCode) !== -1) return 'melee'; | |||
if (['CL', 'DRD', 'SHA'].indexOf(classCode) !== -1) return 'priest'; | |||
return 'caster'; | |||
} | |||
function getBaseHpFactor(classCode) { | |||
var archetype = getClassArchetype(classCode); | |||
if (archetype === 'tank') return 24; | |||
if (archetype === 'melee') return 16; | |||
if (archetype === 'priest') return 13; | |||
return 10; | |||
} | |||
function getHeartyHpFactorBonus() { | |||
var selected = []; | |||
if ( | |||
window.EQOA_CLASS_MASTERY_STATE && | |||
typeof window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries === 'function' | |||
) { | |||
selected = window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries(); | |||
} | |||
return selected.reduce(function (total, name) { | |||
var match = String(name || '').match(/^Hearty\s+(\d+|I|II|III|IV|V)$/i); | |||
var rank; | |||
if (!match) return total; | |||
rank = parseInt(match[1], 10); | |||
if (isNaN(rank)) { | |||
rank = { | |||
I: 1, | |||
II: 2, | |||
III: 3, | |||
IV: 4, | |||
V: 5 | |||
}[String(match[1]).toUpperCase()] || 0; | |||
} | |||
return total + rank; | |||
}, 0); | |||
} | |||
function calculateFormulaHp(classCode, level, stamina, itemHpBonus) { | |||
var hpFactor = getBaseHpFactor(classCode) + getHeartyHpFactorBonus(); | |||
return Math.floor( | |||
(hpFactor + ((parseFloat(stamina) || 0) / 11)) * | |||
(parseInt(level, 10) || 1) | |||
) + (parseInt(itemHpBonus, 10) || 0); | |||
} | |||
function getPowerStatsForClass(classCode) { | |||
classCode = normalizeVitalClassKey(classCode); | |||
if (['CL', 'DRD', 'SHA', 'PAL'].indexOf(classCode) !== -1) { | |||
return { primary: 'wis', secondary: 'cha' }; | |||
} | |||
if (['WAR', 'SK', 'RGE', 'ROG', 'BRD', 'RAN', 'MNK', 'ALC'].indexOf(classCode) !== -1) { | |||
return { primary: 'dex', secondary: 'agi' }; | |||
} | |||
return { primary: 'int', secondary: 'agi' }; | |||
} | |||
function calculateFormulaPow(classCode, level, totalStats, itemPowBonus) { | |||
var powerStats = getPowerStatsForClass(classCode); | |||
var primary = parseFloat(totalStats[powerStats.primary]) || 0; | |||
var secondary = parseFloat(totalStats[powerStats.secondary]) || 0; | |||
return Math.floor( | |||
(28 + (primary / 10) + (secondary / 20)) * | |||
(parseInt(level, 10) || 1) | |||
) + (parseInt(itemPowBonus, 10) || 0); | |||
} | |||
function updateStats(skipPublish) { | |||
var base = getBaseStats(); | var base = getBaseStats(); | ||
if (!base) return; | if (!base) return; | ||
var level = getCharacterLevel(); | |||
var vitalsSection = document.getElementById('cb-player-vitals-section'); | |||
if (vitalsSection) { | |||
var showVitals = | |||
window.EQOA_CONFIG && | |||
window.EQOA_CONFIG.features && | |||
( | |||
window.EQOA_CONFIG.features.characterBuilderCalculatedHP || | |||
window.EQOA_CONFIG.features.characterBuilderCalculatedPOW | |||
); | |||
vitalsSection.style.display = showVitals ? '' : 'none'; | |||
} | |||
var total = { | var total = { | ||
| Line 378: | Line 3,643: | ||
pow: base.pow || 0, | pow: base.pow || 0, | ||
ac: base.ac || 0, | ac: base.ac || 0, | ||
pr: 48, | |||
dr: 48, | |||
fr: 48, | |||
cr: 48, | |||
lr: 48, | |||
ar: 48 | |||
}; | }; | ||
Object.keys(trainingStats).forEach(function (key) { | |||
total[key] += parseInt(trainingStats[key], 10) || 0; | |||
}); | |||
Object.keys(equippedItems).forEach(function (slot) { | Object.keys(equippedItems).forEach(function (slot) { | ||
| Line 407: | Line 3,676: | ||
}); | }); | ||
document.getElementById('cb- | var availableTP = getTrainingPointsForLevel(level); | ||
document.getElementById('cb- | var spentTP = getSpentTrainingPoints(); | ||
document.getElementById('cb- | var remainingTP = availableTP - spentTP; | ||
if (remainingTP < 0) remainingTP = 0; | |||
document.getElementById('cb-tp-available').textContent = availableTP; | |||
document.getElementById('cb-tp-spent').textContent = spentTP; | |||
document.getElementById('cb-tp-remaining').textContent = remainingTP; | |||
var statCaps = getClassMasteryStatCaps(); | |||
setCappedStat('cb-str', total.str, statCaps.str); | |||
setCappedStat('cb-sta', total.sta, statCaps.sta); | |||
setCappedStat('cb-agi', total.agi, statCaps.agi); | |||
setCappedStat('cb-dex', total.dex, statCaps.dex); | |||
setCappedStat('cb-wis', total.wis, statCaps.wis); | |||
setCappedStat('cb-int', total.int, statCaps.int); | |||
setCappedStat('cb-cha', total.cha, statCaps.cha); | |||
var itemHP = getItemVitalBonus(total.hp, base.hp || 0); | |||
total.hp = calculateFormulaHp( | |||
classSelect.value, | |||
level, | |||
total.sta, | |||
itemHP | |||
); | |||
var itemPOW = getItemVitalBonus(total.pow, base.pow || 0); | |||
total.pow = calculateFormulaPow( | |||
classSelect.value, | |||
level, | |||
total, | |||
itemPOW | |||
); | |||
document.getElementById('cb-hp').textContent = total.hp; | document.getElementById('cb-hp').textContent = total.hp; | ||
document.getElementById('cb-pow').textContent = total.pow; | document.getElementById('cb-pow').textContent = total.pow; | ||
document.getElementById('cb-ac').textContent = total.ac; | document.getElementById('cb-ac').textContent = total.ac; | ||
document.getElementById('cb- | |||
document.getElementById('cb- | var playerHpBox = document.getElementById('cb-player-hp'); | ||
var playerPowBox = document.getElementById('cb-player-pow'); | |||
document.getElementById(' | if (playerHpBox) playerHpBox.textContent = total.hp; | ||
if (playerPowBox) playerPowBox.textContent = total.pow; | |||
setCappedStat('cb-pr', total.pr, 315); | |||
setCappedStat('cb-dr', total.dr, 315); | |||
setCappedStat('cb-fr', total.fr, 315); | |||
setCappedStat('cb-cr', total.cr, 315); | |||
setCappedStat('cb-lr', total.lr, 315); | |||
setCappedStat('cb-ar', total.ar, 315); | |||
updateEquippedSummary(); | |||
updateWeaponSummary(); | |||
updateWarnings(); | |||
if (!skipPublish) { | |||
publishCharacterBuilderState(); | |||
} | |||
} | |||
function removeInvalidEquippedItemsForLevel() { | |||
Object.keys(equippedItems).forEach(function (slot) { | |||
if (!levelCanUseItem(equippedItems[slot])) { | |||
delete equippedItems[slot]; | |||
} | |||
}); | |||
} | |||
function bindTrainingInputs() { | |||
Object.keys(trainingStats).forEach(function (key) { | |||
var input = document.getElementById('train-' + key); | |||
input.addEventListener('input', function () { | |||
clampTrainingInput(key); | |||
updateStats(); | |||
}); | |||
}); | |||
} | |||
function resetClassMasteryFromCharacterBuilder() { | |||
if (window.EQOA_RESET_CLASS_MASTERY) { | |||
window.EQOA_RESET_CLASS_MASTERY(); | |||
} | |||
} | |||
function resetCharacterBuildAfterIdentityChange() { | |||
equippedItems = {}; | |||
pendingSharedBuild = null; | |||
sharedBuildApplied = false; | |||
missingSharedItems = []; | |||
classPathChoices = {}; | |||
populateClassPathControls(); | |||
if (buildNameInput) buildNameInput.value = ''; | |||
if (buildNotesInput) buildNotesInput.value = ''; | |||
Object.keys(trainingStats).forEach(function (key) { | |||
trainingStats[key] = 0; | |||
var input = document.getElementById('train-' + key); | |||
if (input) input.value = 0; | |||
}); | |||
levelInput.value = 60; | |||
if (cmPointsInput) { | |||
cmPointsInput.value = getMaxCmPointsForLevel(); | |||
} | |||
if (shareUrlBox) shareUrlBox.style.display = 'none'; | |||
if (shareUrlOutput) shareUrlOutput.value = ''; | |||
if (shareUrlStatus) shareUrlStatus.textContent = ''; | |||
resetClassMasteryFromCharacterBuilder(); | |||
} | |||
raceSelect.addEventListener('change', function () { | |||
resetCharacterBuildAfterIdentityChange(); | |||
loadClasses(); | |||
buildGearSlots(); | |||
updateStats(); | |||
}); | |||
classSelect.addEventListener('change', function () { | |||
resetCharacterBuildAfterIdentityChange(); | |||
buildGearSlots(); | |||
populateClassPathControls(); | |||
updateStats(); | |||
}); | |||
levelInput.addEventListener('change', function () { | |||
getAvailableCmPoints(); | |||
Object.keys(trainingStats).forEach(function (key) { | |||
clampTrainingInput(key); | |||
}); | |||
removeInvalidEquippedItemsForLevel(); | |||
enforceDisabledSlots(); | |||
buildGearSlots(); | |||
populateClassPathControls(); | |||
updateStats(); | |||
}); | |||
if (cmPointsInput) { | |||
cmPointsInput.addEventListener('change', function () { | |||
getAvailableCmPoints(); | |||
publishCharacterBuilderState(); | |||
}); | |||
} | |||
if (generateShareButton) { | |||
generateShareButton.addEventListener('click', generateShareUrl); | |||
} | |||
if (copyShareButton) { | |||
copyShareButton.addEventListener('click', copyShareUrl); | |||
} | } | ||
if (resetBuildButton) { | |||
resetBuildButton.addEventListener('click', resetBuild); | |||
} | |||
if (compareBuildsButton) { | |||
compareBuildsButton.addEventListener('click', compareSharedBuilds); | |||
} | |||
if (globalItemSearchInput) { | |||
globalItemSearchInput.addEventListener('input', renderGlobalItemSearchResults); | |||
} | |||
/* Theme is controlled globally by MediaWiki:Common.js */ | |||
window.EQOA_EXPORT_COMBINED_BUILD_CARD = exportBuildCardImage; | |||
if (exportCardButton) { | |||
exportCardButton.addEventListener('click', exportBuildCardImage); | |||
} | |||
Array.prototype.forEach.call(builderTabButtons, function (button) { | |||
button.addEventListener('click', function () { | |||
switchBuilderTab(button.getAttribute('data-cb-tab')); | |||
}); | |||
}); | |||
loadSavedBuilderTheme(); | |||
window.addEventListener('eqoa-class-mastery-state-change', function () { | |||
updateStats(true); | |||
}); | |||
window.addEventListener('eqoa-wiki-theme-change', function (event) { | |||
if (event.detail && event.detail.theme) { | |||
applyBuilderTheme(event.detail.theme); | |||
} | |||
}); | |||
loadClasses(); | |||
bindTrainingInputs(); | |||
bindClassPathControls(); | |||
populateClassPathControls(); | |||
tryLoadSharedBuildFromUrl(); | |||
loadAllSlotItems(); | |||
updateStats(); | |||
publishCharacterBuilderState(); | |||
} | } | ||
}); | }); | ||
function openItemPage(itemName) { | |||
window.open( | |||
mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(itemName)), | |||
'_blank' | |||
); | |||
} | |||
Latest revision as of 01:04, 1 June 2026
mw.hook('wikipage.content').add(function () {
function loadGeneratedItemChunks() {
var chunkNumbers = [];
var i;
var chunkCount = 5;
var version = '20260601items5rangefix';
for (i = 1; i <= chunkCount; i++) {
chunkNumbers.push(i);
}
window.EQOA_GENERATED_ITEM_CHUNKS = [];
window.EQOA_GENERATED_ITEMS = [];
return Promise.all(chunkNumbers.map(function (chunkNumber) {
return mw.loader.getScript(
mw.config.get('wgScriptPath') +
'/index.php?title=MediaWiki:CharacterBuilderGeneratedItems' +
chunkNumber +
'.js&action=raw&ctype=text/javascript&v=' +
version
);
})).then(function () {
window.EQOA_GENERATED_ITEMS = [].concat.apply(
[],
window.EQOA_GENERATED_ITEM_CHUNKS || []
).filter(Boolean);
console.log(
'EQOA generated items loaded:',
window.EQOA_GENERATED_ITEMS.length,
'chunks:',
window.EQOA_GENERATED_ITEM_CHUNKS.length
);
console.log(
'Throw items loaded:',
window.EQOA_GENERATED_ITEMS.filter(function (item) {
return /throw/i.test(String(item.name || ''));
}).map(function (item) {
return item.name + ' / ' + item.slot;
})
);
window.dispatchEvent(new CustomEvent('eqoa-generated-items-ready'));
});
}
function waitForBuilderData(attemptsLeft) {
if (window.EQOA_BUILDER_DATA && window.EQOA_GENERATED_ITEMS) {
initCharacterBuilder();
return;
}
if (attemptsLeft <= 0) {
var app = document.getElementById('character-builder-app');
if (app) {
app.innerHTML = '<div style="color:red;">EQOA_BUILDER_DATA failed to load.</div>';
}
return;
}
setTimeout(function () {
waitForBuilderData(attemptsLeft - 1);
}, 100);
}
loadGeneratedItemChunks().then(function () {
waitForBuilderData(100);
}).catch(function (error) {
console.error('Generated item chunks failed to load.', error);
var app = document.getElementById('character-builder-app');
if (app) {
app.innerHTML = '<div style="color:red;">Generated item chunks failed to load.</div>';
}
});
function initCharacterBuilder() {
var app = document.getElementById('character-builder-app');
if (!app) return;
var data = window.EQOA_BUILDER_DATA;
var equippedItems = {};
var itemCache = {};
var pendingSharedBuild = null;
var sharedBuildApplied = false;
var missingSharedItems = [];
var sharedClassMasteries = [];
var classPathChoices = {};
var statKeys = [
"lvl", "str", "sta", "agi", "dex", "wis", "int", "cha",
"hp", "pow", "ac", "pr", "dr", "fr", "cr", "lr", "ar",
"dmg", "hot", "pot", "dur"
];
var textKeys = [
"effect", "proc", "weaponstyle",
"droppedby", "zone", "droprate"
];
var trainingStats = {
str: 0,
sta: 0,
agi: 0,
dex: 0,
wis: 0,
int: 0,
cha: 0
};
var gearSlots = [
"Head", "Chest", "Robe", "Hands", "Legs", "Feet",
"Forearm", "Bracelet1", "Bracelet2",
"Waist", "Neck", "Ear1", "Ear2",
"Ring1", "Ring2", "Primary", "Secondary",
"Shield", "Held1", "Held2", "Range"
];
var classPathTiers = [20, 30, 40, 49, 60];
var classPathOptionsByClass = {
WAR: ['Bellow', 'Taunt'],
PAL: ['Barrier', 'Taunt'],
SK: ['Blood', 'Taunt'],
CL: ['Stack Heal', 'Resist Buff'],
DRD: ['AoE Stack', 'FR Debuff'],
SHA: ['Pet Summon', 'Debuff'],
BRD: ['Boon', 'Combat'],
RAN: ['Melee Buff', 'Range Buff'],
RGE: ['Wound', 'Avoidance'],
ROG: ['Wound', 'Avoidance'],
MNK: ['Melee Buff', 'Defense Buff'],
ALC: ['Cocktails', 'Knack'],
ENC: ['Boon', 'Mind'],
MAG: ['Pet Buff', 'Stones'],
NEC: ['Blood', 'Power'],
WIZ: ['Clash', 'Power']
};
var classPathOptionsByClass = {
WAR: ['Bellow', 'Taunt'],
PAL: ['Barrier', 'Taunt'],
SK: ['Blood', 'Taunt'],
CL: ['Stack Heal', 'Resist Buff'],
DRD: ['AoE Stack', 'FR Debuff'],
SHA: ['Pet Summon', 'Debuff'],
BRD: ['Boon', 'Combat'],
RAN: ['Melee Buff', 'Range Buff'],
RGE: ['Wound', 'Avoidance'],
ROG: ['Wound', 'Avoidance'],
MNK: ['Melee Buff', 'Defense Buff'],
ALC: ['Cocktails', 'Knack'],
ENC: ['Boon', 'Mind'],
MAG: ['Pet Buff', 'Stones'],
NEC: ['Blood', 'Power'],
WIZ: ['Clash', 'Power']
};
function getTrainingPointsForLevel(level) {
level = parseInt(level, 10);
if (isNaN(level) || level < 1) level = 1;
if (level > 60) level = 60;
var tp = 20;
for (var lvl = 2; lvl <= level; lvl++) {
if (lvl <= 14) tp += 3;
else if (lvl <= 29) tp += 5;
else if (lvl <= 44) tp += 9;
else if (lvl <= 59) tp += 14;
else if (lvl === 60) tp += 3;
}
return tp;
}
app.innerHTML = `
<div class="character-builder-box">
<div class="character-builder-title-row">
<div class="character-builder-title">EQOA: Sandstorm Character Builder</div>
<button id="cb-theme-toggle" type="button">
Toggle Theme
</button>
</div>
<div class="cb-builder-tabs">
<button type="button" class="cb-tab-button cb-tab-active" data-cb-tab="equipment">Equipment</button>
<button type="button" class="cb-tab-button" data-cb-tab="class-mastery">Class Mastery</button>
</div>
<div id="cb-tab-equipment" class="cb-tab-panel cb-tab-panel-active">
<div class="character-builder-controls">
<label>Race</label>
<select id="cb-race"></select>
<label>Class</label>
<select id="cb-class"></select>
<label>Level</label>
<input type="number" id="cb-level" min="1" max="60" value="60" style="width:70px;">
<label>Available CMs</label>
<input type="number" id="cb-cm-points" min="0" max="1500" value="1500" style="width:80px;">
<br><label>Transformation Path</label>
<select id="cb-transformation-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>
<div class="cb-class-path-controls">
<label>Class Path 20</label>
<select id="cb-class-path-20"></select>
<label>Class Path 30</label>
<select id="cb-class-path-30"></select>
<label>Class Path 40</label>
<select id="cb-class-path-40"></select>
<label>Class Path 49</label>
<select id="cb-class-path-49"></select>
<label>Class Path 60</label>
<select id="cb-class-path-60"></select>
</div>
<label>Build Name</label>
<input type="text" id="cb-build-name" placeholder="Lv60 Ogre SK Raid Tank" style="width:220px;">
<label>Build Notes</label>
<textarea id="cb-build-notes" placeholder="Example: Raid tank setup focused on HP, AC, and disease resist." rows="3" style="width:260px;"></textarea>
<button id="cb-generate-share-url" type="button">Generate Share URL</button>
<button id="cb-copy-share-url" type="button">Copy URL</button>
<button id="cb-reset-build" type="button">Reset Build</button>
<button id="cb-export-card" type="button">Export Build Card</button>
</div>
<div class="character-builder-section-title" style="margin-top:15px;">Compare Shared Builds</div>
<div class="character-builder-controls">
<label>Build URL 1</label>
<input type="text" id="cb-compare-url-a" placeholder="Paste first shared build URL">
<label>Build URL 2</label>
<input type="text" id="cb-compare-url-b" placeholder="Paste second shared build URL">
<button id="cb-compare-builds" type="button">Compare Builds</button>
</div>
<div id="cb-compare-results" class="character-builder-warning-box" style="display:none; margin-top:10px;"></div>
<div id="cb-warning-box" class="character-builder-warning-box" style="display:none;"></div>
<div id="cb-share-url-box" class="character-builder-warning-box" style="display:none; margin-top:10px;">
<b>Share Build URL</b><br>
<input type="text" id="cb-share-url-output" readonly style="width:100%; box-sizing:border-box; margin-top:6px;">
<div id="cb-share-url-status" style="margin-top:5px;"></div>
</div>
<div class="character-builder-section-title" style="margin-top:18px;">Character Summary</div>
<div class="cb-ingame-stat-panel">
<div class="cb-ingame-stat-column">
<div class="cb-ingame-row cb-ingame-header-row">
<span>Training Points</span>
<span></span>
<span id="cb-tp-remaining">482</span>
</div>
<span id="cb-tp-available" style="display:none;">482</span>
<span id="cb-tp-spent" style="display:none;">0</span>
<div class="cb-ingame-row">
<span>Strength</span>
<input type="number" id="train-str" min="0" value="0">
<span id="cb-str">0</span>
</div>
<div class="cb-ingame-row">
<span>Stamina</span>
<input type="number" id="train-sta" min="0" value="0">
<span id="cb-sta">0</span>
</div>
<div class="cb-ingame-row">
<span>Agility</span>
<input type="number" id="train-agi" min="0" value="0">
<span id="cb-agi">0</span>
</div>
<div class="cb-ingame-row">
<span>Dexterity</span>
<input type="number" id="train-dex" min="0" value="0">
<span id="cb-dex">0</span>
</div>
<div class="cb-ingame-row">
<span>Wisdom</span>
<input type="number" id="train-wis" min="0" value="0">
<span id="cb-wis">0</span>
</div>
<div class="cb-ingame-row">
<span>Intelligence</span>
<input type="number" id="train-int" min="0" value="0">
<span id="cb-int">0</span>
</div>
<div class="cb-ingame-row">
<span>Charisma</span>
<input type="number" id="train-cha" min="0" value="0">
<span id="cb-cha">0</span>
</div>
</div>
<div class="cb-ingame-stat-column">
<div class="cb-ingame-row">
<span>AC</span>
<span></span>
<span id="cb-ac">0</span>
</div>
<div class="cb-ingame-row">
<span>HP</span>
<span></span>
<span id="cb-hp">0</span>
</div>
<div class="cb-ingame-row">
<span>Power</span>
<span></span>
<span id="cb-pow">0</span>
</div>
<div class="cb-ingame-row">
<span>Fire</span>
<span></span>
<span id="cb-fr">48</span>
</div>
<div class="cb-ingame-row">
<span>Ice</span>
<span></span>
<span id="cb-cr">48</span>
</div>
<div class="cb-ingame-row">
<span>Lightning</span>
<span></span>
<span id="cb-lr">48</span>
</div>
<div class="cb-ingame-row">
<span>Arcane</span>
<span></span>
<span id="cb-ar">48</span>
</div>
<div class="cb-ingame-row">
<span>Poison</span>
<span></span>
<span id="cb-pr">48</span>
</div>
<div class="cb-ingame-row">
<span>Disease</span>
<span></span>
<span id="cb-dr">48</span>
</div>
</div>
</div>
<div class="character-builder-section-title" style="margin-top:18px;">Weapon Summary</div>
<table class="character-builder-stat-table">
<tr>
<th>Slot</th>
<th>Weapon</th>
<th>DMG</th>
<th>Style</th>
<th>Proc / Effect</th>
</tr>
<tr>
<td>Primary</td>
<td id="cb-weapon-primary-name">-</td>
<td id="cb-weapon-primary-dmg">-</td>
<td id="cb-weapon-primary-style">-</td>
<td id="cb-weapon-primary-effect">-</td>
</tr>
<tr>
<td>Secondary</td>
<td id="cb-weapon-secondary-name">-</td>
<td id="cb-weapon-secondary-dmg">-</td>
<td id="cb-weapon-secondary-style">-</td>
<td id="cb-weapon-secondary-effect">-</td>
</tr>
<tr>
<td>Range</td>
<td id="cb-weapon-range-name">-</td>
<td id="cb-weapon-range-dmg">-</td>
<td id="cb-weapon-range-style">-</td>
<td id="cb-weapon-range-effect">-</td>
</tr>
</table>
<div class="character-builder-section-title" style="margin-top:15px;">Global Item Search</div>
<div class="character-builder-controls">
<label>Search All Items</label>
<input type="text" id="cb-global-item-search" placeholder="Search item name, class, slot, zone, dropped by...">
</div>
<div id="cb-global-search-results" class="character-builder-warning-box" style="display:none; margin-top:10px;"></div>
<div class="character-builder-equipment-layout">
<div>
<div class="character-builder-section-title cb-nested-section-title">Equipped Items</div>
<div class="character-builder-equipped-summary">
<table class="character-builder-equipped-table">
<tbody id="cb-equipped-summary-body"></tbody>
</table>
</div>
</div>
<div class="character-builder-gear-column">
<div class="character-builder-section-title cb-nested-section-title">Equipment</div>
<div id="cb-loading-message">Loading item categories...</div>
<div id="cb-gear-grid" class="character-builder-gear-grid"></div>
</div>
</div>
</div>
<div id="cb-tab-class-mastery" class="cb-tab-panel">
<div id="cm-calculator-app"></div>
</div>
<div id="cb-export-card-container" style="position:fixed; left:-99999px; top:0; width:1200px; min-width:1200px; height:auto; pointer-events:none; z-index:-9999;">
<div id="cb-export-card-inner" class="cb-export-card-dashboard">
<div class="cb-export-title-main">EQOA: SANDSTORM</div>
<div class="cb-export-title-sub">CHARACTER BUILDER</div>
<div id="cb-export-build-name" class="cb-export-build-name">Unnamed Build</div>
<span id="cb-export-level" style="display:none;"></span>
<span id="cb-export-class" style="display:none;"></span>
<span id="cb-export-master-class" style="display:none;"></span>
<div class="cb-export-character-sheet">
<div class="cb-export-level-panel cb-export-panel">
<div id="cb-export-level-title" class="cb-export-level-title">Level 60 Rogue</div>
</div>
<div class="cb-export-left-info-panel">
<div class="cb-export-panel-title">General Info</div>
<div class="cb-export-identity-stack">
<div>
<b>Race / Transformation</b>
<span id="cb-export-race-transform">-</span>
</div>
<div>
<b>Class Path</b>
<span id="cb-export-class-path">None</span>
</div>
<div>
<b>Exemplar Path</b>
<span id="cb-export-exemplar-path">None</span>
</div>
</div>
<div class="cb-export-bars">
<div class="cb-export-resource-row cb-export-hp-row">
<b>HP</b>
<div class="cb-export-resource-bar">
<i></i><i></i><i></i><i></i><i></i>
<span id="cb-export-hp-bar">0</span>
</div>
</div>
<div class="cb-export-resource-row cb-export-pow-row">
<b>POW</b>
<div class="cb-export-resource-bar">
<i></i><i></i><i></i><i></i><i></i>
<span id="cb-export-pow-bar">0</span>
</div>
</div>
<div class="cb-export-ac-row">
<b>AC</b>
<span id="cb-export-ac-value">0</span>
</div>
</div>
<div class="cb-export-stat-block">
<div class="cb-export-small-title">Core Stats</div>
<div id="cb-export-core-stats"></div>
<div class="cb-export-small-title cb-export-resist-title">Resists</div>
<div id="cb-export-resists"></div>
</div>
</div>
<div class="cb-export-equipment-panel cb-export-panel">
<div class="cb-export-panel-title">Equipment</div>
<div id="cb-export-items"></div>
</div>
<div class="cb-export-cm-panel cb-export-panel">
<div class="cb-export-panel-title">Class Mastery</div>
<div id="cb-export-cm"></div>
</div>
<div class="cb-export-panel cb-export-notes-panel">
<div class="cb-export-panel-title">Build Notes</div>
<div id="cb-export-notes" class="cb-export-notes-box"></div>
</div>
</div>
</div>
</div>
</div>
<div id="cb-item-tooltip" class="character-builder-tooltip" style="display:none;"></div>
<div id="cb-compare-tooltip" class="character-builder-tooltip cb-compare-tooltip" style="display:none;"></div>
</div>
`;
var exportCmBox = document.getElementById('cb-export-cm');
var transformationPathSelect = document.getElementById('cb-transformation-path');
var raceSelect = document.getElementById('cb-race');
var classSelect = document.getElementById('cb-class');
var levelInput = document.getElementById('cb-level');
var cmPointsInput = document.getElementById('cb-cm-points');
var buildNameInput = document.getElementById('cb-build-name');
var buildNotesInput = document.getElementById('cb-build-notes');
var gearGrid = document.getElementById('cb-gear-grid');
var loadingMessage = document.getElementById('cb-loading-message');
var generateShareButton = document.getElementById('cb-generate-share-url');
var copyShareButton = document.getElementById('cb-copy-share-url');
var resetBuildButton = document.getElementById('cb-reset-build');
var exportCardButton = document.getElementById('cb-export-card');
var shareUrlBox = document.getElementById('cb-share-url-box');
var shareUrlOutput = document.getElementById('cb-share-url-output');
var shareUrlStatus = document.getElementById('cb-share-url-status');
var equippedSummaryBody = document.getElementById('cb-equipped-summary-body');
var warningBox = document.getElementById('cb-warning-box');
var globalItemSearchInput = document.getElementById('cb-global-item-search');
var globalSearchResultsBox = document.getElementById('cb-global-search-results');
var compareUrlAInput = document.getElementById('cb-compare-url-a');
var compareUrlBInput = document.getElementById('cb-compare-url-b');
var compareBuildsButton = document.getElementById('cb-compare-builds');
var compareResultsBox = document.getElementById('cb-compare-results');
var tooltip = document.getElementById('cb-item-tooltip');
var compareTooltip = document.getElementById('cb-compare-tooltip');
var exportCardInner = document.getElementById('cb-export-card-inner');
var exportNotesBox = document.getElementById('cb-export-notes');
var exportCmBox = document.getElementById('cb-export-cm');
var exportMasterClassBox = document.getElementById('cb-export-master-class');
var exportClassPathBox = document.getElementById('cb-export-class-path');
var exportExemplarPathBox = document.getElementById('cb-export-exemplar-path');
var exportTransformPathBox = document.getElementById('cb-export-transform-path');
var themeToggleButton = document.getElementById('cb-theme-toggle');
var builderTabButtons = document.querySelectorAll('.cb-tab-button');
var builderTabPanels = document.querySelectorAll('.cb-tab-panel');
function switchBuilderTab(tabName) {
Array.prototype.forEach.call(builderTabButtons, function (button) {
if (button.getAttribute('data-cb-tab') === tabName) {
button.classList.add('cb-tab-active');
} else {
button.classList.remove('cb-tab-active');
}
});
Array.prototype.forEach.call(builderTabPanels, function (panel) {
if (panel.id === 'cb-tab-' + tabName) {
panel.classList.add('cb-tab-panel-active');
} else {
panel.classList.remove('cb-tab-panel-active');
}
});
if (tabName === 'class-mastery') {
publishCharacterBuilderState();
setTimeout(function () {
if (window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER) {
window.EQOA_SYNC_CLASS_MASTERY_FROM_BUILDER();
}
}, 0);
}
}
function getTransformationPath() {
return transformationPathSelect ? transformationPathSelect.value : '';
}
function addOption(select, value, label) {
var option = document.createElement('option');
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function getBuildParamFromUrl() {
try {
var params = new URLSearchParams(window.location.search);
return params.get('b') || params.get('build');
} catch (e) {
return null;
}
}
function encodeBuildData(buildData) {
var json = JSON.stringify(buildData);
try {
if (typeof LZString !== 'undefined' && LZString.compressToEncodedURIComponent) {
return 'lz:' + LZString.compressToEncodedURIComponent(json);
}
} catch (e) {
console.warn('LZString compression failed. Falling back to base64.', e);
}
return 'b64:' + btoa(unescape(encodeURIComponent(json)));
}
function decodeBuildData(encoded) {
try {
if (encoded.indexOf('lz:') === 0) {
if (typeof LZString !== 'undefined' && LZString.decompressFromEncodedURIComponent) {
var decompressed = LZString.decompressFromEncodedURIComponent(encoded.substring(3));
return JSON.parse(decompressed);
}
console.error('LZString is not loaded yet.');
return null;
}
if (encoded.indexOf('b64:') === 0) {
encoded = encoded.substring(4);
}
var json = decodeURIComponent(escape(atob(encoded)));
return JSON.parse(json);
} catch (e) {
console.error('Could not decode shared build.', e);
return null;
}
}
function getClassPathOptionsForClass(classCode, tier) {
var options = classPathOptionsByClass[classCode] || [];
if (parseInt(tier, 10) === 60) {
return options.concat(['General']);
}
return options;
}
function getClassPathSelect(tier) {
return document.getElementById('cb-class-path-' + tier);
}
function populateClassPathControls() {
var classCode = classSelect ? classSelect.value : '';
var level = getCharacterLevel();
classPathTiers.forEach(function (tier) {
var select = getClassPathSelect(tier);
var options = getClassPathOptionsForClass(classCode, tier);
var savedValue = classPathChoices[tier] || '';
if (!select) return;
select.innerHTML = '';
addOption(select, '', level >= tier ? 'Choose Path' : 'Requires Lv ' + tier);
options.forEach(function (optionName) {
addOption(select, optionName, optionName);
});
if (level < tier) {
select.value = '';
select.disabled = true;
classPathChoices[tier] = '';
} else {
select.disabled = false;
if (options.indexOf(savedValue) !== -1) {
select.value = savedValue;
} else {
select.value = '';
classPathChoices[tier] = '';
}
}
});
}
function bindClassPathControls() {
classPathTiers.forEach(function (tier) {
var select = getClassPathSelect(tier);
if (!select) return;
select.addEventListener('change', function () {
if (getCharacterLevel() < tier) {
select.value = '';
classPathChoices[tier] = '';
return;
}
classPathChoices[tier] = select.value || '';
publishCharacterBuilderState();
});
});
}
function getSelectedClassPathChoices() {
var result = {};
classPathTiers.forEach(function (tier) {
var select = getClassPathSelect(tier);
var value = select ? select.value : classPathChoices[tier];
if (getCharacterLevel() >= tier && value) {
result[tier] = value;
}
});
return result;
}
function getSelectedClassPathSummary() {
var choices = getSelectedClassPathChoices();
var parts = [];
classPathTiers.forEach(function (tier) {
if (choices[tier]) {
parts.push({
tier: tier,
value: choices[tier]
});
}
});
return parts;
}
function getCurrentBuildData() {
var gear = {};
gearSlots.forEach(function (slot) {
if (equippedItems[slot]) {
gear[slot] = equippedItems[slot].name;
}
});
return {
v: 1,
n: buildNameInput ? buildNameInput.value.trim() : "",
no: buildNotesInput ? buildNotesInput.value.trim() : "",
r: raceSelect.value,
c: classSelect.value,
l: getCharacterLevel(),
cm: getAvailableCmPoints(),
p: getTransformationPath(),
cp: getSelectedClassPathChoices(),
cms: (
window.EQOA_CLASS_MASTERY_STATE &&
typeof window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries === 'function'
)
? window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries()
: sharedClassMasteries,
t: {
str: parseInt(trainingStats.str, 10) || 0,
sta: parseInt(trainingStats.sta, 10) || 0,
agi: parseInt(trainingStats.agi, 10) || 0,
dex: parseInt(trainingStats.dex, 10) || 0,
wis: parseInt(trainingStats.wis, 10) || 0,
int: parseInt(trainingStats.int, 10) || 0,
cha: parseInt(trainingStats.cha, 10) || 0
},
i: gear
};
}
function generateShareUrl() {
var buildData = getCurrentBuildData();
var encoded = encodeBuildData(buildData);
var pageTitle = mw.config.get('wgPageName') || mw.config.get('wgTitle');
var scriptUrl = mw.config.get('wgScript');
if (scriptUrl.indexOf('http') !== 0) {
scriptUrl = window.location.origin + scriptUrl;
}
var baseUrl = scriptUrl + '?title=' + encodeURIComponent(pageTitle);
var url = baseUrl + '&b=' + encodeURIComponent(encoded);
if (shareUrlOutput) shareUrlOutput.value = url;
if (shareUrlBox) shareUrlBox.style.display = 'block';
if (shareUrlStatus) shareUrlStatus.textContent = 'Share URL generated.';
try {
window.history.replaceState(null, document.title, url);
} catch (e) {}
return url;
}
function copyShareUrl() {
var url = shareUrlOutput && shareUrlOutput.value ? shareUrlOutput.value : generateShareUrl();
function markCopied() {
if (shareUrlBox) shareUrlBox.style.display = 'block';
if (shareUrlStatus) shareUrlStatus.textContent = 'Copied to clipboard.';
}
function markManualCopy() {
if (shareUrlBox) shareUrlBox.style.display = 'block';
if (shareUrlOutput) {
shareUrlOutput.focus();
shareUrlOutput.select();
}
if (shareUrlStatus) shareUrlStatus.textContent = 'Copy blocked by browser. Select the URL above and copy it manually.';
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(markCopied).catch(markManualCopy);
} else {
try {
if (shareUrlOutput) {
shareUrlOutput.focus();
shareUrlOutput.select();
}
document.execCommand('copy');
markCopied();
} catch (e) {
markManualCopy();
}
}
}
function resetBuild() {
equippedItems = {};
pendingSharedBuild = null;
sharedBuildApplied = false;
classPathChoices = {};
populateClassPathControls();
levelInput.value = 60;
if (cmPointsInput) {
cmPointsInput.value = getMaxCmPointsForLevel();
}
if (buildNameInput) {
buildNameInput.value = '';
}
if (buildNotesInput) {
buildNotesInput.value = '';
}
Object.keys(trainingStats).forEach(function (key) {
trainingStats[key] = 0;
var input = document.getElementById('train-' + key);
if (input) input.value = 0;
});
if (shareUrlBox) shareUrlBox.style.display = 'none';
if (shareUrlOutput) shareUrlOutput.value = '';
if (shareUrlStatus) shareUrlStatus.textContent = '';
try {
window.history.replaceState(
null,
document.title,
window.location.origin + window.location.pathname
);
} catch (e) {}
buildGearSlots();
updateStats();
}
function getAllLoadedItemsFlat() {
var all = [];
var seen = {};
Object.keys(itemCache).forEach(function (slotType) {
(itemCache[slotType] || []).forEach(function (item) {
if (!item || !item.name) return;
var key = item.name.toLowerCase();
if (!seen[key]) {
seen[key] = item;
all.push(item);
}
});
});
return all;
}
function findLoadedItemByName(itemName, slot) {
if (!itemName) return null;
var name = String(itemName).toLowerCase();
var slotType = getBuilderSlotType(slot);
var slotItems = itemCache[slotType] || [];
for (var i = 0; i < slotItems.length; i++) {
if (slotItems[i].name && slotItems[i].name.toLowerCase() === name) {
return slotItems[i];
}
}
var allItems = getAllLoadedItemsFlat();
for (var j = 0; j < allItems.length; j++) {
if (allItems[j].name && allItems[j].name.toLowerCase() === name) {
return allItems[j];
}
}
return null;
}
function setTrainingInputsFromState() {
Object.keys(trainingStats).forEach(function (key) {
var input = document.getElementById('train-' + key);
if (input) input.value = trainingStats[key];
});
}
function applySharedBuildBasicState(buildData) {
if (!buildData || sharedBuildApplied) return;
buildData.buildName = buildData.buildName || buildData.n;
buildData.buildNotes = buildData.buildNotes || buildData.no;
buildData.race = buildData.race || buildData.r;
buildData.classCode = buildData.classCode || buildData.c;
buildData.level = buildData.level || buildData.l;
buildData.cmPoints = buildData.cmPoints || buildData.cm;
buildData.trainingStats = buildData.trainingStats || buildData.t;
buildData.equippedItemNames = buildData.equippedItemNames || buildData.i;
buildData.transformationPath = buildData.transformationPath || buildData.p;
buildData.classPathChoices = buildData.classPathChoices || buildData.cp || {};
buildData.classMasteries = buildData.classMasteries || buildData.cms || [];
sharedClassMasteries = buildData.classMasteries || [];
if (buildData.race && data.baseStats[buildData.race]) {
raceSelect.value = buildData.race;
loadClasses(true);
}
if (buildData.classCode) {
var classExists = false;
Array.prototype.forEach.call(classSelect.options, function (option) {
if (option.value === buildData.classCode) classExists = true;
});
if (classExists) classSelect.value = buildData.classCode;
}
if (buildData.level !== undefined) {
levelInput.value = buildData.level;
getCharacterLevel();
}
if (buildData.cmPoints !== undefined && cmPointsInput) {
cmPointsInput.value = buildData.cmPoints;
getAvailableCmPoints();
}
if (buildData.transformationPath !== undefined && transformationPathSelect) {
transformationPathSelect.value = buildData.transformationPath;
}
if (buildData.classPathChoices) {
classPathChoices = buildData.classPathChoices;
populateClassPathControls();
}
if (buildData.buildName && buildNameInput) {
buildNameInput.value = buildData.buildName;
}
if (buildData.buildNotes && buildNotesInput) {
buildNotesInput.value = buildData.buildNotes;
}
if (buildData.trainingStats) {
Object.keys(trainingStats).forEach(function (key) {
trainingStats[key] = parseInt(buildData.trainingStats[key], 10) || 0;
});
setTrainingInputsFromState();
Object.keys(trainingStats).forEach(function (key) {
clampTrainingInput(key);
});
setTrainingInputsFromState();
}
}
function applySharedBuildEquipment(buildData) {
if (!buildData || !buildData.equippedItemNames) return;
equippedItems = {};
gearSlots.forEach(function (slot) {
var itemName = buildData.equippedItemNames[slot];
var item = findLoadedItemByName(itemName, slot);
if (item && classCanUseItem(item, classSelect.value) && levelCanUseItem(item)) {
equippedItems[slot] = item;
} else if (itemName) {
missingSharedItems.push(itemName);
}
});
enforceDisabledSlots();
sharedBuildApplied = true;
buildGearSlots();
updateStats();
if (shareUrlBox && shareUrlOutput) {
shareUrlBox.style.display = 'block';
shareUrlOutput.value = window.location.href;
if (shareUrlStatus) {
if (missingSharedItems.length > 0) {
shareUrlStatus.innerHTML =
'Shared build loaded, but some items could not be found:<br>• ' +
missingSharedItems.join('<br>• ');
} else {
shareUrlStatus.textContent = 'Shared build loaded.';
}
}
}
}
function tryLoadSharedBuildFromUrl() {
var encoded = getBuildParamFromUrl();
if (!encoded) return;
pendingSharedBuild = decodeBuildData(encoded);
if (pendingSharedBuild) {
applySharedBuildBasicState(pendingSharedBuild);
} else if (shareUrlBox && shareUrlStatus) {
shareUrlBox.style.display = 'block';
shareUrlStatus.textContent = 'Could not load shared build URL.';
}
}
Object.keys(data.baseStats).forEach(function (race) {
addOption(raceSelect, race, race);
});
function getCharacterLevel() {
var level = parseInt(levelInput.value, 10);
if (isNaN(level) || level < 1) level = 1;
if (level > 60) level = 60;
levelInput.value = level;
return level;
}
function getMaxCmPointsForLevel() {
var max = getCharacterLevel() * 25;
if (max > 1500) {
max = 1500;
}
return max;
}
function getAvailableCmPoints() {
var max = getMaxCmPointsForLevel();
var value;
if (!cmPointsInput) {
return max;
}
cmPointsInput.max = max;
value = parseInt(cmPointsInput.value, 10);
if (isNaN(value)) {
value = max;
}
if (value < 0) {
value = 0;
}
if (value > max) {
value = max;
}
cmPointsInput.value = value;
return value;
}
function getBuilderSlotType(slot) {
if (slot.indexOf("Bracelet") === 0) return "Bracelet";
if (slot.indexOf("Ring") === 0) return "Ring";
if (slot.indexOf("Ear") === 0) return "Ear";
if (slot.indexOf("Held") === 0) return "Held";
return slot;
}
function getCategoryForSlot(slotType) {
return slotType + " Items";
}
function getCategoriesForSlotType(slotType) {
if (slotType === "Primary") {
return ["Primary Items", "Secondary Items"];
}
return [getCategoryForSlot(slotType)];
}
function apiGet(params) {
return new mw.Api().get(params);
}
function getPagesInCategory(categoryName) {
return apiGet({
action: "query",
list: "categorymembers",
cmtitle: "Category:" + categoryName,
cmlimit: "500",
cmnamespace: "0",
format: "json"
}).then(function (data) {
return data.query.categorymembers || [];
});
}
function getPageWikitext(title) {
return apiGet({
action: "query",
prop: "revisions",
titles: title,
rvprop: "content",
rvslots: "main",
formatversion: "2",
format: "json"
}).then(function (data) {
var page = data.query.pages[0];
if (!page || !page.revisions || !page.revisions[0]) return "";
if (page.revisions[0].slots && page.revisions[0].slots.main) {
return page.revisions[0].slots.main.content || "";
}
return page.revisions[0].content || "";
});
}
function parseCategories(wikitext) {
var categories = [];
var regex = /\[\[Category:([^\]|]+)(?:\|[^\]]*)?\]\]/gi;
var match;
while ((match = regex.exec(wikitext)) !== null) {
categories.push(match[1].trim());
}
return categories;
}
function parseItemBox(wikitext, fallbackName) {
var match = wikitext.match(/\{\{(?:ItemBox|ItemPage)([\s\S]*?)\}\}/i);
if (!match) return null;
var content = match[1];
var item = { name: fallbackName };
var lines = content.split("\n");
lines.forEach(function (line) {
line = line.trim();
if (line.indexOf("|") !== 0) return;
var equalIndex = line.indexOf("=");
if (equalIndex === -1) return;
var key = line.substring(1, equalIndex).trim().toLowerCase();
var value = line.substring(equalIndex + 1).trim();
if (!key) return;
item[key] = value;
});
if (!item.name) item.name = fallbackName;
item.categories = parseCategories(wikitext);
item.is2HD = item.categories.indexOf("2HD Items") !== -1 ||
String(item.weaponstyle || "").toUpperCase() === "2HD";
item.isShield = item.categories.indexOf("Shield Items") !== -1 ||
String(item.slot || "").toLowerCase() === "shield";
item.isHeld = item.categories.indexOf("Held Items") !== -1 ||
String(item.slot || "").toLowerCase() === "held";
item.isSecondary = item.categories.indexOf("Secondary Items") !== -1 ||
String(item.slot || "").toLowerCase() === "secondary";
item.isPrimary = item.categories.indexOf("Primary Items") !== -1 ||
String(item.slot || "").toLowerCase() === "primary";
statKeys.forEach(function (key) {
if (item[key] !== undefined && item[key] !== "") {
var num = parseFloat(String(item[key]).replace(/[^\d.-]/g, ""));
item[key] = isNaN(num) ? 0 : num;
} else {
item[key] = 0;
}
});
textKeys.forEach(function (key) {
if (item[key] === undefined) {
item[key] = "";
}
});
if (item.class) {
item.classList = item.class.split(",").map(function (x) {
return x.trim().toUpperCase();
});
} else {
item.classList = [];
}
return item;
}
function classCanUseItem(item, classCode) {
if (!item.classList || item.classList.length === 0) return true;
if (item.classList.indexOf("ALL") !== -1) return true;
return item.classList.indexOf(classCode) !== -1;
}
function levelCanUseItem(item) {
var characterLevel = getCharacterLevel();
var itemLevel = parseInt(item.lvl, 10) || 0;
if (itemLevel <= 0) return true;
return itemLevel <= characterLevel;
}
function dedupePages(pages) {
var seen = {};
var result = [];
pages.forEach(function (page) {
if (!seen[page.title]) {
seen[page.title] = true;
result.push(page);
}
});
return result;
}
function normalizeGeneratedSlot(item) {
var slot = String(item && item.slot || '').trim();
var slotKey = slot.toLowerCase();
var name = String(item && item.name || '').toLowerCase();
var style = String(item && item.weaponstyle || '').toLowerCase();
if (slotKey === 'helm') return 'Head';
if (slotKey === 'helmet') return 'Head';
if (slotKey === 'necklace') return 'Neck';
if (slotKey === 'earring') return 'Ear';
if (slotKey === 'leggings') return 'Legs';
if (slotKey === 'leg') return 'Legs';
if (slotKey === 'bow') return 'Range';
if (slotKey === 'range') return 'Range';
if (slotKey === 'ranged') return 'Range';
if (slotKey === 'thrown') return 'Range';
if (slotKey === 'throwing') return 'Range';
if (
style.indexOf('bow') !== -1 ||
style.indexOf('crossbow') !== -1 ||
style.indexOf('shortbow') !== -1 ||
style.indexOf('longbow') !== -1 ||
style.indexOf('throw') !== -1 ||
style.indexOf('javelin') !== -1 ||
name.indexOf('bow') !== -1 ||
name.indexOf('crossbow') !== -1 ||
name.indexOf('throw') !== -1 ||
name.indexOf('javelin') !== -1
) {
return 'Range';
}
return slot;
}
function normalizeGeneratedItem(generatedItem, slotType) {
var item = Object.assign({}, generatedItem);
item.slot = normalizeGeneratedSlot(item);
if (!item.name) return null;
if (Array.isArray(item.class)) {
item.classList = item.class.map(function (x) {
return String(x).trim().toUpperCase();
});
item.class = item.classList.join(", ");
} else if (item.class) {
item.classList = String(item.class).split(",").map(function (x) {
return x.trim().toUpperCase();
});
} else {
item.classList = [];
}
item.categories = item.categories || [slotType + " Items"];
item.is2HD = item.categories.indexOf("2HD Items") !== -1 ||
String(item.weaponstyle || "").toUpperCase() === "2HD";
item.isShield = String(item.slot || "").toLowerCase() === "shield";
item.isHeld = String(item.slot || "").toLowerCase() === "held";
item.isSecondary = String(item.slot || "").toLowerCase() === "secondary";
item.isPrimary = String(item.slot || "").toLowerCase() === "primary";
item.isRange = String(item.slot || "").toLowerCase() === "range";
statKeys.forEach(function (key) {
item[key] = parseFloat(item[key]) || 0;
});
textKeys.forEach(function (key) {
if (item[key] === undefined) item[key] = "";
});
return item;
}
function loadItemsForSlotType(slotType) {
if (itemCache[slotType]) {
return Promise.resolve(itemCache[slotType]);
}
if (window.EQOA_GENERATED_ITEMS && window.EQOA_GENERATED_ITEMS.length) {
var items = window.EQOA_GENERATED_ITEMS
.filter(function (generatedItem) {
var normalizedSlot;
if (!generatedItem) return false;
normalizedSlot = normalizeGeneratedSlot(generatedItem);
if (slotType === "Primary") {
return normalizedSlot === "Primary" || normalizedSlot === "Secondary";
}
if (slotType === "Secondary") {
return normalizedSlot === "Secondary";
}
return normalizedSlot === slotType;
})
.map(function (generatedItem) {
return normalizeGeneratedItem(generatedItem, slotType);
})
.filter(function (item) {
return item !== null;
});
itemCache[slotType] = items;
return Promise.resolve(items);
}
return Promise.reject("EQOA_GENERATED_ITEMS is not loaded.");
}
function loadAllSlotItems() {
loadingMessage.textContent = "Loading item categories...";
var uniqueSlotTypes = [];
gearSlots.forEach(function (slot) {
var slotType = getBuilderSlotType(slot);
if (uniqueSlotTypes.indexOf(slotType) === -1) {
uniqueSlotTypes.push(slotType);
}
});
var promises = uniqueSlotTypes.map(function (slotType) {
return loadItemsForSlotType(slotType);
});
return Promise.all(promises).then(function () {
loadingMessage.textContent = "Items loaded.";
if (pendingSharedBuild && !sharedBuildApplied) {
applySharedBuildEquipment(pendingSharedBuild);
return;
}
buildGearSlots();
updateStats();
}).catch(function (error) {
loadingMessage.textContent = "Error loading items.";
console.error(error);
});
}
function getDisabledSlots() {
var disabled = {};
var primary = equippedItems.Primary;
var secondary = equippedItems.Secondary;
var shield = equippedItems.Shield;
var held1 = equippedItems.Held1;
var held2 = equippedItems.Held2;
var range = equippedItems.Range;
var forearm = equippedItems.Forearm;
var bracelet1 = equippedItems.Bracelet1;
var bracelet2 = equippedItems.Bracelet2;
function disable(slot, reason) {
if (!disabled[slot]) {
disabled[slot] = reason;
}
}
/* 2HD Primary locks off-hand style slots */
if (primary && primary.is2HD) {
disable("Secondary", "Disabled because a 2HD weapon is equipped in Primary.");
disable("Shield", "Disabled because a 2HD weapon is equipped in Primary.");
disable("Held1", "Disabled because a 2HD weapon is equipped in Primary.");
disable("Held2", "Disabled because a 2HD weapon is equipped in Primary.");
disable("Range", "Disabled because a 2HD weapon is equipped in Primary.");
}
/* Primary + Secondary uses both hands */
if (primary && secondary) {
disable("Held1", "Disabled because Primary and Secondary are both equipped.");
disable("Held2", "Disabled because Primary and Secondary are both equipped.");
disable("Shield", "Disabled because Primary and Secondary are both equipped.");
disable("Range", "Disabled because Primary and Secondary are both equipped.");
}
/* Primary uses main hand, blocking Held1 and Range */
if (primary) {
disable("Held1", "Disabled because Primary is equipped.");
disable("Range", "Disabled because Primary is equipped.");
}
/* Secondary uses off hand, blocking Held2 and Range */
if (secondary) {
disable("Held2", "Disabled because Secondary is equipped.");
disable("Range", "Disabled because Secondary is equipped.");
}
/* Shield allows Held1, but blocks Held2 and Range */
if (shield) {
disable("Held2", "Disabled because a Shield is equipped.");
disable("Range", "Disabled because a Shield is equipped.");
}
/* Held2 blocks Shield and Range */
if (held2) {
disable("Shield", "Disabled because Held2 is equipped.");
disable("Range", "Disabled because Held2 is equipped.");
}
/* Held1 blocks Primary and Range */
if (held1) {
disable("Primary", "Disabled because Held1 is equipped.");
disable("Range", "Disabled because Held1 is equipped.");
}
/* Held1 + Held2 uses both hands */
if (held1 && held2) {
disable("Primary", "Disabled because Held1 and Held2 are both equipped.");
disable("Secondary", "Disabled because Held1 and Held2 are both equipped.");
disable("Shield", "Disabled because Held1 and Held2 are both equipped.");
disable("Range", "Disabled because Held1 and Held2 are both equipped.");
}
/* Optional cleanup: Range conflicts with Shield / Held2 / dual wield */
/* Range uses both weapon/off-hand space */
if (range) {
disable("Primary", "Disabled because Range is equipped.");
disable("Secondary", "Disabled because Range is equipped.");
disable("Shield", "Disabled because Range is equipped.");
disable("Held1", "Disabled because Range is equipped.");
disable("Held2", "Disabled because Range is equipped.");
}
/* Forearm vs Bracelet slots */
if (forearm) {
disable("Bracelet1", "Disabled because a Forearm item is equipped.");
disable("Bracelet2", "Disabled because a Forearm item is equipped.");
}
if (bracelet1 || bracelet2) {
disable("Forearm", "Disabled because Bracelet1 or Bracelet2 is equipped.");
}
return disabled;
}
function isLoreItem(item) {
if (!item) return false;
return String(item.lore || "")
.trim()
.toUpperCase() === "LORE";
}
function isLoreConflict(item, currentSlot) {
if (!item || !item.name) return false;
if (!isLoreItem(item)) return false;
var itemName = item.name.toLowerCase();
for (var slot in equippedItems) {
if (slot === currentSlot) continue;
var equipped = equippedItems[slot];
if (!equipped || !equipped.name) continue;
if (equipped.name.toLowerCase() === itemName) {
return true;
}
}
return false;
}
function enforceDisabledSlots() {
var disabled = getDisabledSlots();
Object.keys(disabled).forEach(function (slot) {
if (equippedItems[slot]) {
delete equippedItems[slot];
}
});
var equippedLoreNames = {};
gearSlots.forEach(function (slot) {
var item = equippedItems[slot];
if (!item || !item.name) return;
if (!isLoreItem(item)) return;
var key = item.name.toLowerCase();
if (equippedLoreNames[key]) {
delete equippedItems[slot];
} else {
equippedLoreNames[key] = true;
}
});
}
function loadClasses(preserveEquipped) {
classSelect.innerHTML = '';
var race = raceSelect.value;
Object.keys(data.baseStats[race]).forEach(function (classCode) {
var className = data.classes[classCode] || classCode;
addOption(classSelect, classCode, classCode + ' - ' + className);
});
if (!preserveEquipped) {
equippedItems = {};
}
buildGearSlots();
updateStats();
}
function buildGearSlots() {
gearGrid.innerHTML = '';
var disabledSlots = getDisabledSlots();
gearSlots.forEach(function (slot) {
var slotBox = document.createElement('div');
slotBox.className = 'character-builder-slot';
if (disabledSlots[slot]) {
slotBox.className += ' cb-slot-disabled';
}
slotBox.innerHTML =
'<div class="character-builder-section-title cb-slot-section-title">' + slot + '</div>' +
'<input type="text" class="cb-slot-search" id="cb-search-' + slot.toLowerCase() + '" placeholder="Search ' + slot + '">' +
'<div class="cb-picker-tools">' +
'<select class="cb-slot-filter" id="cb-filter-' + slot.toLowerCase() + '">' +
'<option value="">Any Stat</option>' +
'<option value="hp">HP</option>' +
'<option value="pow">POW</option>' +
'<option value="ac">AC</option>' +
'<option value="dmg">DMG</option>' +
'<option value="str">STR</option>' +
'<option value="sta">STA</option>' +
'<option value="agi">AGI</option>' +
'<option value="dex">DEX</option>' +
'<option value="wis">WIS</option>' +
'<option value="int">INT</option>' +
'<option value="cha">CHA</option>' +
'<option value="pr">PR</option>' +
'<option value="dr">DR</option>' +
'<option value="fr">FR</option>' +
'<option value="cr">CR</option>' +
'<option value="lr">LR</option>' +
'<option value="ar">AR</option>' +
'<option value="proc">Has Proc</option>' +
'<option value="effect">Has Effect</option>' +
'<option value="2hd">2HD</option>' +
'</select>' +
'<select class="cb-slot-sort" id="cb-sort-' + slot.toLowerCase() + '">' +
'<option value="name">Sort: Name</option>' +
'<option value="lvl">Sort: Level</option>' +
'<option value="hp">Sort: HP</option>' +
'<option value="pow">Sort: POW</option>' +
'<option value="ac">Sort: AC</option>' +
'<option value="dmg">Sort: DMG</option>' +
'<option value="str">Sort: STR</option>' +
'<option value="sta">Sort: STA</option>' +
'<option value="agi">Sort: AGI</option>' +
'<option value="dex">Sort: DEX</option>' +
'<option value="wis">Sort: WIS</option>' +
'<option value="int">Sort: INT</option>' +
'<option value="cha">Sort: CHA</option>' +
'</select>' +
'</div>' +
'<div class="cb-upgrade-row">' +
'<label class="cb-upgrade-toggle">' +
'<input type="checkbox" id="cb-upgrades-' + slot.toLowerCase() + '">' +
' Upgrades Only' +
'</label>' +
'</div>' +
'<div class="cb-item-picker-list" id="cb-picker-' + slot.toLowerCase() + '"></div>' +
'<div class="cb-slot-disabled-note" id="cb-disabled-note-' + slot.toLowerCase() + '"></div>';
gearGrid.appendChild(slotBox);
populateItemPicker(slot, disabledSlots[slot]);
});
updateEquippedSummary();
}
function isItemUpgrade(slot, newItem) {
var currentItem = equippedItems[slot];
if (!currentItem) return true;
var compareKeys = [
"hp", "pow", "ac", "dmg",
"str", "sta", "agi", "dex",
"wis", "int", "cha",
"pr", "dr", "fr", "cr", "lr", "ar"
];
for (var i = 0; i < compareKeys.length; i++) {
var key = compareKeys[i];
var newValue = parseFloat(newItem[key]) || 0;
var currentValue = parseFloat(currentItem[key]) || 0;
if (newValue > currentValue) {
return true;
}
}
return false;
}
function getFilteredItemsForSlot(slot, searchText, filterKey, sortKey) {
var classCode = classSelect.value;
var slotType = getBuilderSlotType(slot);
var items = itemCache[slotType] || [];
searchText = (searchText || "").toLowerCase();
filterKey = filterKey || "";
sortKey = sortKey || "name";
var filtered = items.filter(function (item) {
if (!classCanUseItem(item, classCode)) return false;
if (!levelCanUseItem(item)) return false;
if (slot === "Primary") {
if (item.isRange) return false;
if (!item.isPrimary && !item.isSecondary) return false;
}
if (slot === "Secondary") {
if (item.isRange) return false;
if (!item.isSecondary) return false;
}
if (searchText && item.name.toLowerCase().indexOf(searchText) === -1) {
return false;
}
if (filterKey) {
if (filterKey === "proc" && !item.proc) return false;
if (filterKey === "effect" && !item.effect) return false;
if (filterKey === "2hd" && !item.is2HD) return false;
if (
filterKey !== "proc" &&
filterKey !== "effect" &&
filterKey !== "2hd"
) {
if (!item[filterKey] || parseFloat(item[filterKey]) <= 0) return false;
}
}
return true;
});
filtered.sort(function (a, b) {
if (sortKey === "name") {
return String(a.name || "").localeCompare(String(b.name || ""));
}
var av = parseFloat(a[sortKey]) || 0;
var bv = parseFloat(b[sortKey]) || 0;
if (bv !== av) return bv - av;
return String(a.name || "").localeCompare(String(b.name || ""));
});
return filtered;
}
function populateItemPicker(slot, disabledReason) {
var picker = document.getElementById('cb-picker-' + slot.toLowerCase());
var searchInput = document.getElementById('cb-search-' + slot.toLowerCase());
var disabledNote = document.getElementById('cb-disabled-note-' + slot.toLowerCase());
var filterSelect = document.getElementById('cb-filter-' + slot.toLowerCase());
var sortSelect = document.getElementById('cb-sort-' + slot.toLowerCase());
var upgradesOnlyToggle = document.getElementById('cb-upgrades-' + slot.toLowerCase());
if (!picker) return;
if (disabledReason) {
if (searchInput) searchInput.disabled = true;
picker.innerHTML = '<div class="cb-picker-disabled">Disabled</div>';
if (disabledNote) disabledNote.textContent = disabledReason;
return;
}
function rebuildPicker() {
var searchText = searchInput ? searchInput.value : "";
var filterKey = filterSelect ? filterSelect.value : "";
var sortKey = sortSelect ? sortSelect.value : "name";
var filteredItems = getFilteredItemsForSlot(slot, searchText, filterKey, sortKey);
if (upgradesOnlyToggle && upgradesOnlyToggle.checked) {
filteredItems = filteredItems.filter(function (item) {
return isItemUpgrade(slot, item);
});
}
filteredItems = filteredItems.slice(0, 10);
var currentItem = equippedItems[slot];
picker.innerHTML = '';
var noneRow = document.createElement('div');
noneRow.className = 'cb-picker-item cb-picker-none';
noneRow.textContent = '-- None --';
if (!currentItem) {
noneRow.className += ' cb-picker-selected';
}
noneRow.addEventListener('click', function () {
delete equippedItems[slot];
hideTooltip();
hideCompareTooltip();
enforceDisabledSlots();
buildGearSlots();
updateStats();
});
picker.appendChild(noneRow);
filteredItems.forEach(function (item) {
var row = document.createElement('div');
row.className = 'cb-picker-item';
if (currentItem === item) {
row.className += ' cb-picker-selected';
}
var itemNameSpan = document.createElement('span');
itemNameSpan.textContent = item.name + (item.lvl ? ' (Lv ' + item.lvl + ')' : '');
itemNameSpan.className = 'cb-rarity-' + normalizeRarity(item.rarity);
var itemPageLink = document.createElement('span');
itemPageLink.textContent = ' ↗';
itemPageLink.title = 'Open item page';
itemPageLink.className = 'cb-item-page-link';
itemPageLink.addEventListener('click', function (event) {
event.stopPropagation();
openItemPage(item.name);
});
row.appendChild(itemNameSpan);
row.appendChild(itemPageLink);
row.addEventListener('mouseenter', function (event) {
showCompareTooltip(slot, item, event);
});
row.addEventListener('mousemove', function (event) {
moveCompareTooltip(event);
});
row.addEventListener('mouseleave', function () {
hideCompareTooltip();
});
row.addEventListener('click', function () {
equippedItems[slot] = item;
hideTooltip();
hideCompareTooltip();
enforceDisabledSlots();
buildGearSlots();
updateStats();
});
picker.appendChild(row);
});
}
rebuildPicker();
if (searchInput) {
searchInput.addEventListener('input', rebuildPicker);
}
if (filterSelect) {
filterSelect.addEventListener('change', rebuildPicker);
}
if (sortSelect) {
sortSelect.addEventListener('change', rebuildPicker);
}
if (upgradesOnlyToggle) {
upgradesOnlyToggle.addEventListener('change', rebuildPicker);
}
}
function validateEquipment() {
var warnings = [];
var primary = equippedItems.Primary;
var secondary = equippedItems.Secondary;
var shield = equippedItems.Shield;
var held1 = equippedItems.Held1;
var held2 = equippedItems.Held2;
var forearm = equippedItems.Forearm;
var bracelet1 = equippedItems.Bracelet1;
var bracelet2 = equippedItems.Bracelet2;
if (forearm && (bracelet1 || bracelet2)) {
warnings.push("Forearm item is equipped with Bracelet1/Bracelet2. Use either 1 Forearm or 2 Bracelets.");
}
if (primary && primary.is2HD) {
if (secondary) warnings.push("2HD weapon in Primary conflicts with Secondary.");
if (shield) warnings.push("2HD weapon in Primary conflicts with Shield.");
if (held1 || held2) warnings.push("2HD weapon in Primary conflicts with Held1/Held2.");
}
if (shield && secondary) {
warnings.push("Shield conflicts with Secondary weapon.");
}
if (shield && (held1 || held2)) {
warnings.push("Shield conflicts with Held1/Held2.");
}
if ((held1 || held2) && primary && secondary) {
warnings.push("Held items conflict because Primary and Secondary are both equipped.");
}
return warnings;
}
function updateWarnings() {
var warnings = validateEquipment();
var disabledSlots = getDisabledSlots();
Object.keys(disabledSlots).forEach(function (slot) {
warnings.push(slot + " is " + disabledSlots[slot]);
});
if (!warningBox) return;
if (warnings.length === 0) {
warningBox.style.display = "none";
warningBox.innerHTML = "";
return;
}
warningBox.style.display = "block";
warningBox.innerHTML =
"<b>Equipment Warnings</b><br>" +
warnings.map(function (w) {
return "• " + w;
}).join("<br>");
}
function cbHasValue(value) {
return value !== undefined && value !== null && value !== 0 && value !== "";
}
function cbGetStatValue(item, key) {
if (!item) return undefined;
if (key === "dmg") {
if (cbHasValue(item.dmg)) return item.dmg;
if (cbHasValue(item.damage)) return item.damage;
}
return item[key];
}
function formatItemStats(item) {
var rows = [];
function add(label, key) {
var value = cbGetStatValue(item, key);
if (cbHasValue(value)) {
rows.push('<div><span>' + label + '</span> ' + value + '</div>');
}
}
add("DMG:", "dmg");
add("AC:", "ac");
add("STR:", "str");
add("STA:", "sta");
add("AGI:", "agi");
add("DEX:", "dex");
add("WIS:", "wis");
add("INT:", "int");
add("CHA:", "cha");
add("HP:", "hp");
add("HoT:", "hot");
add("POW:", "pow");
add("PoT:", "pot");
add("PR:", "pr");
add("DR:", "dr");
add("FR:", "fr");
add("CR:", "cr");
add("LR:", "lr");
add("AR:", "ar");
return rows.join("");
}
function getItemTagHtml(item) {
var tags = [];
var lore = String(item.lore || "").trim().toUpperCase();
var noTrade = String(item.notrade || item.noTrade || "").trim().toUpperCase();
var trade = String(item.trade || "").trim().toUpperCase();
if (noTrade === "NO TRADE" || trade === "1" || trade === "NO TRADE") {
tags.push("NO TRADE");
}
if (lore === "LORE" || lore === "1") {
tags.push("LORE");
}
if (!tags.length) return "";
return '<div class="cb-tooltip-tags">' +
tags.map(function (tag) {
return '<span class="cb-tooltip-tag">' + tag + '</span>';
}).join("") +
'</div>';
}
function getClassText(item) {
if (!item || !item.class) return "";
if (Array.isArray(item.class)) {
return item.class.join(", ");
}
return item.class;
}
function buildTooltipHtml(item) {
var html = '';
var classText = getClassText(item);
html += '<div class="cb-tooltip-title cb-rarity-' +
normalizeRarity(item.rarity) +
'">' +
item.name +
'</div>';
html += getItemTagHtml(item);
if (cbHasValue(item.lvl)) {
html += '<div class="cb-tooltip-line"><b>Level:</b> ' + item.lvl + '</div>';
}
if (item.slot) {
html += '<div class="cb-tooltip-line"><b>Slot:</b> ' + item.slot + '</div>';
}
if (cbHasValue(item.dur)) {
html += '<div class="cb-tooltip-line"><b>DUR:</b> ' + item.dur + '</div>';
}
if (cbHasValue(item.gearhp)) {
html += '<div class="cb-tooltip-line"><b>Gear HP:</b> ' + item.gearhp + '</div>';
}
if (classText) {
html += '<div class="cb-tooltip-line"><b>Class:</b> ' + classText + '</div>';
}
if (item.is2HD) {
html += '<div class="cb-tooltip-line"><b>Type:</b> 2HD Weapon</div>';
}
if (item.weaponstyle) {
html += '<div class="cb-tooltip-line"><b>Style:</b> ' + item.weaponstyle + '</div>';
}
if (item.proc) {
html += '<div class="cb-tooltip-line"><b>Proc:</b> ' + item.proc + '</div>';
}
if (item.effect) {
html += '<div class="cb-tooltip-line"><b>Effect:</b> ' + item.effect + '</div>';
}
if (item.droppedby) {
html += '<div class="cb-tooltip-line"><b>Dropped By:</b> ' + item.droppedby + '</div>';
}
if (item.zone) {
html += '<div class="cb-tooltip-line"><b>Zone:</b> ' + item.zone + '</div>';
}
if (item.droprate) {
html += '<div class="cb-tooltip-line"><b>Drop Rate:</b> ' + item.droprate + '</div>';
}
html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Item Stats</b></div>';
html += '<div class="cb-tooltip-stats">' + formatItemStats(item) + '</div>';
return html;
}
function getCompareValue(newItem, oldItem, key) {
var newValue = parseFloat(cbGetStatValue(newItem, key)) || 0;
var oldValue = oldItem ? (parseFloat(cbGetStatValue(oldItem, key)) || 0) : 0;
return newValue - oldValue;
}
function buildCompareTooltipHtml(slot, newItem) {
var oldItem = equippedItems[slot];
var html = '';
var classText = getClassText(newItem);
html += '<div class="cb-tooltip-title cb-rarity-' +
normalizeRarity(newItem.rarity) +
'">' +
newItem.name +
'</div>';
html += getItemTagHtml(newItem);
if (oldItem) {
html += '<div class="cb-tooltip-line"><b>Currently:</b> ' + oldItem.name + '</div>';
} else {
html += '<div class="cb-tooltip-line"><b>Currently:</b> None</div>';
}
if (cbHasValue(newItem.lvl)) {
html += '<div class="cb-tooltip-line"><b>Level:</b> ' + newItem.lvl + '</div>';
}
if (newItem.slot) {
html += '<div class="cb-tooltip-line"><b>Slot:</b> ' + newItem.slot + '</div>';
}
if (cbHasValue(newItem.dur)) {
html += '<div class="cb-tooltip-line"><b>DUR:</b> ' + newItem.dur + '</div>';
}
if (cbHasValue(newItem.gearhp)) {
html += '<div class="cb-tooltip-line"><b>Gear HP:</b> ' + newItem.gearhp + '</div>';
}
if (classText) {
html += '<div class="cb-tooltip-line"><b>Class:</b> ' + classText + '</div>';
}
if (newItem.is2HD) {
html += '<div class="cb-tooltip-line"><b>Type:</b> 2HD Weapon</div>';
}
if (newItem.weaponstyle) {
html += '<div class="cb-tooltip-line"><b>Style:</b> ' + newItem.weaponstyle + '</div>';
}
if (newItem.proc) {
html += '<div class="cb-tooltip-line"><b>Proc:</b> ' + newItem.proc + '</div>';
}
if (newItem.effect) {
html += '<div class="cb-tooltip-line"><b>Effect:</b> ' + newItem.effect + '</div>';
}
var compareRows = [];
function addCompare(label, key) {
var diff = getCompareValue(newItem, oldItem, key);
if (diff === 0) return;
var cls = diff > 0 ? 'cb-compare-positive' : 'cb-compare-negative';
var sign = diff > 0 ? '+' : '';
compareRows.push(
'<div><span>' + label + '</span> <b class="' + cls + '">' + sign + diff + '</b></div>'
);
}
addCompare("DMG:", "dmg");
addCompare("AC:", "ac");
addCompare("STR:", "str");
addCompare("STA:", "sta");
addCompare("AGI:", "agi");
addCompare("DEX:", "dex");
addCompare("WIS:", "wis");
addCompare("INT:", "int");
addCompare("CHA:", "cha");
addCompare("HP:", "hp");
addCompare("HoT:", "hot");
addCompare("POW:", "pow");
addCompare("PoT:", "pot");
addCompare("PR:", "pr");
addCompare("DR:", "dr");
addCompare("FR:", "fr");
addCompare("CR:", "cr");
addCompare("LR:", "lr");
addCompare("AR:", "ar");
html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Comparison</b></div>';
if (compareRows.length) {
html += '<div class="cb-tooltip-stats">' + compareRows.join("") + '</div>';
} else {
html += '<div class="cb-tooltip-line">No stat difference.</div>';
}
html += '<div class="cb-tooltip-line" style="margin-top:6px;"><b>Item Stats</b></div>';
html += '<div class="cb-tooltip-stats">' + formatItemStats(newItem) + '</div>';
if (isLoreConflict(newItem, slot)) {
html += '<div class="cb-tooltip-warning">LORE item already equipped</div>';
}
return html;
}
function showTooltip(item, event) {
if (!tooltip || !item) return;
tooltip.className =
'character-builder-tooltip cb-tooltip-rarity-' +
normalizeRarity(item.rarity);
tooltip.innerHTML = buildTooltipHtml(item);
tooltip.style.display = 'block';
moveTooltip(event);
}
function moveTooltip(event) {
if (!tooltip || tooltip.style.display === 'none') return;
tooltip.style.left = (event.pageX + 14) + 'px';
tooltip.style.top = (event.pageY + 14) + 'px';
}
function hideTooltip() {
if (!tooltip) return;
tooltip.style.display = 'none';
}
function showCompareTooltip(slot, item, event) {
if (!compareTooltip || !item) return;
compareTooltip.className =
'character-builder-tooltip cb-compare-tooltip cb-tooltip-rarity-' +
normalizeRarity(item.rarity);
compareTooltip.innerHTML =
buildCompareTooltipHtml(slot, item);
compareTooltip.style.display = 'block';
moveCompareTooltip(event);
}
function moveCompareTooltip(event) {
if (!compareTooltip || compareTooltip.style.display === 'none') return;
compareTooltip.style.left = (event.pageX + 14) + 'px';
compareTooltip.style.top = (event.pageY + 14) + 'px';
}
function hideCompareTooltip() {
if (!compareTooltip) return;
compareTooltip.style.display = 'none';
}
function updateEquippedSummary() {
if (!equippedSummaryBody) return;
equippedSummaryBody.innerHTML = '';
gearSlots.forEach(function (slot) {
var item = equippedItems[slot];
var row = document.createElement('tr');
var slotCell = document.createElement('td');
slotCell.textContent = slot;
slotCell.style.fontWeight = 'bold';
var itemCell = document.createElement('td');
if (item) {
var itemSpan = document.createElement('span');
itemSpan.textContent = item.name;
itemSpan.className =
'cb-equipped-item-name cb-rarity-' +
normalizeRarity(item.rarity);
itemSpan.style.cursor = 'pointer';
itemSpan.addEventListener('click', function () {
openItemPage(item.name);
});
itemSpan.addEventListener('mouseenter', function (event) {
showTooltip(item, event);
});
itemSpan.addEventListener('mousemove', moveTooltip);
itemSpan.addEventListener('mouseleave', hideTooltip);
itemCell.appendChild(itemSpan);
} else {
itemCell.textContent = '-';
}
row.appendChild(slotCell);
row.appendChild(itemCell);
equippedSummaryBody.appendChild(row);
});
}
function updateWeaponSummary() {
function setWeaponRow(slotName, prefix) {
var item = equippedItems[slotName];
var nameEl = document.getElementById(prefix + '-name');
var dmgEl = document.getElementById(prefix + '-dmg');
var styleEl = document.getElementById(prefix + '-style');
var effectEl = document.getElementById(prefix + '-effect');
if (!nameEl || !dmgEl || !styleEl || !effectEl) return;
nameEl.innerHTML = '';
if (item) {
var link = document.createElement('span');
link.textContent = item.name;
link.className =
'cb-equipped-item-name cb-rarity-' +
normalizeRarity(item.rarity);
link.style.cursor = 'pointer';
link.addEventListener('click', function () {
openItemPage(item.name);
});
link.addEventListener('mouseenter', function (event) {
showTooltip(item, event);
});
link.addEventListener('mousemove', moveTooltip);
link.addEventListener('mouseleave', hideTooltip);
nameEl.appendChild(link);
} else {
nameEl.textContent = '-';
}
dmgEl.textContent = item && item.dmg ? item.dmg : '-';
if (item) {
if (item.weaponstyle) {
styleEl.textContent = item.weaponstyle;
} else if (item.is2HD) {
styleEl.textContent = '2HD';
} else if (item.isPrimary || item.isSecondary) {
styleEl.textContent = '1HD';
} else {
styleEl.textContent = '-';
}
} else {
styleEl.textContent = '-';
}
var effectText = '-';
if (item) {
if (item.proc) {
effectText = item.proc;
} else if (item.effect) {
effectText = item.effect;
}
}
effectEl.textContent = effectText;
}
setWeaponRow('Primary', 'cb-weapon-primary');
setWeaponRow('Secondary', 'cb-weapon-secondary');
setWeaponRow('Range', 'cb-weapon-range');
}
function getBaseStats() {
var race = raceSelect.value;
var classCode = classSelect.value;
return data.baseStats[race][classCode];
}
function getItemVitalBonus(currentValue, baseValue) {
currentValue = parseInt(currentValue, 10) || 0;
baseValue = parseInt(baseValue, 10) || 0;
var bonus = currentValue - baseValue;
return bonus > 0 ? bonus : 0;
}
function normalizeVitalRaceKey(race) {
race = String(race || '').toLowerCase();
if (race.indexOf('human') !== -1 || race === 'hum') return 'Human';
if (race.indexOf('barbarian') !== -1 || race === 'bar') return 'Barbarian';
return race;
}
function normalizeVitalClassKey(classCode) {
classCode = String(classCode || '').toUpperCase();
if (classCode === 'ROG') return 'RGE';
if (classCode === 'ROGUE') return 'RGE';
if (classCode === 'WARRIOR') return 'WAR';
if (classCode === 'PALADIN') return 'PAL';
if (classCode === 'SHADOWKNIGHT') return 'SK';
if (classCode === 'SHADOW KNIGHT') return 'SK';
if (classCode === 'MAGICIAN') return 'MAG';
if (classCode === 'SHAMAN') return 'SHA';
return classCode;
}
function getConfiguredBaseVital(vitalKey, race, classCode, level) {
var config = window.EQOA_CONFIG;
var raceKey = normalizeVitalRaceKey(race);
var classKey = normalizeVitalClassKey(classCode);
if (parseInt(level, 10) !== 60) {
return null;
}
if (
!config ||
!config.characterBuilderVitals ||
!config.characterBuilderVitals.level60Base ||
!config.characterBuilderVitals.level60Base[vitalKey] ||
!config.characterBuilderVitals.level60Base[vitalKey][raceKey]
) {
return null;
}
if (config.characterBuilderVitals.level60Base[vitalKey][raceKey][classKey] !== undefined) {
return config.characterBuilderVitals.level60Base[vitalKey][raceKey][classKey];
}
return null;
}
function getClassMasteryBaseStats() {
var base = getBaseStats() || {};
var result = {};
['str', 'sta', 'agi', 'dex', 'wis', 'int', 'cha'].forEach(function (key) {
result[key.toUpperCase()] =
(parseInt(base[key], 10) || 0) +
(parseInt(trainingStats[key], 10) || 0);
});
return result;
}
function publishCharacterBuilderState() {
window.EQOA_CHARACTER_BUILDER_STATE = {
getState: function () {
return {
race: raceSelect.value,
classCode: classSelect.value,
level: getCharacterLevel(),
transformationPath: getTransformationPath(),
cmAvailable: getAvailableCmPoints(),
cmMax: getMaxCmPointsForLevel(),
baseStats: getClassMasteryBaseStats(),
selectedClassMasteries: sharedClassMasteries
};
}
};
if (window.dispatchEvent) {
window.dispatchEvent(new CustomEvent('eqoa-character-builder-state-change'));
}
}
if (transformationPathSelect) {
transformationPathSelect.addEventListener('change', function () {
updateStats();
if (window.dispatchEvent) {
window.dispatchEvent(new CustomEvent('eqoa-character-builder-state-change'));
}
});
}
function getSpentTrainingPoints() {
var total = 0;
Object.keys(trainingStats).forEach(function (key) {
total += parseInt(trainingStats[key], 10) || 0;
});
return total;
}
function clampTrainingInput(changedKey) {
var level = getCharacterLevel();
var availableTP = getTrainingPointsForLevel(level);
var spentOther = 0;
Object.keys(trainingStats).forEach(function (key) {
if (key !== changedKey) {
spentOther += parseInt(trainingStats[key], 10) || 0;
}
});
var input = document.getElementById('train-' + changedKey);
var value = parseInt(input.value, 10);
if (isNaN(value) || value < 0) value = 0;
var maxAllowed = availableTP - spentOther;
if (maxAllowed < 0) maxAllowed = 0;
if (value > maxAllowed) {
value = maxAllowed;
input.value = value;
}
trainingStats[changedKey] = value;
}
function addStat(total, item, key) {
total[key] += parseFloat(item[key]) || 0;
}
function setCappedStat(elementId, value, cap) {
var element = document.getElementById(elementId);
if (!element) return;
value = parseFloat(value) || 0;
cap = parseFloat(cap) || 0;
if (value > cap) {
element.innerHTML =
cap +
'<span class="cb-stat-cap">(' + cap + ')</span>' +
'<span class="cb-overcap">(+' + (value - cap) + ')</span>';
} else {
element.innerHTML =
value +
'<span class="cb-stat-cap">(' + cap + ')</span>';
}
}
function getClassMasteryStatCaps() {
var caps = {
str: 400,
sta: 400,
agi: 400,
dex: 400,
wis: 400,
int: 400,
cha: 400
};
if (
window.EQOA_CLASS_MASTERY_STATE &&
typeof window.EQOA_CLASS_MASTERY_STATE.getStatCaps === 'function'
) {
var cmCaps = window.EQOA_CLASS_MASTERY_STATE.getStatCaps();
Object.keys(caps).forEach(function (key) {
if (cmCaps[key] !== undefined) {
caps[key] = parseInt(cmCaps[key], 10) || caps[key];
}
});
}
return caps;
}
function getBuildDataFromUrl(url) {
try {
var parsedUrl = new URL(url);
var encoded =
parsedUrl.searchParams.get('b') ||
parsedUrl.searchParams.get('build');
var buildData;
if (!encoded) return null;
buildData = decodeBuildData(encoded);
if (!buildData) return null;
buildData.buildName = buildData.buildName || buildData.n;
buildData.buildNotes = buildData.buildNotes || buildData.no;
buildData.race = buildData.race || buildData.r;
buildData.classCode = buildData.classCode || buildData.c;
buildData.level = buildData.level || buildData.l;
buildData.cmPoints = buildData.cmPoints || buildData.cm;
buildData.trainingStats = buildData.trainingStats || buildData.t;
buildData.equippedItemNames = buildData.equippedItemNames || buildData.i;
buildData.transformationPath = buildData.transformationPath || buildData.p;
buildData.classMasteries = buildData.classMasteries || buildData.cms || [];
return buildData;
} catch (e) {
console.error('Could not read build URL for comparison.', e);
return null;
}
}
function formatCompareValue(value) {
if (value === undefined || value === null || value === '') return '-';
return value;
}
function compareTextValue(label, a, b) {
if (a === b) {
return '<div><b>' + label + ':</b> ' + formatCompareValue(a) + '</div>';
}
return '<div><b>' + label + ':</b> ' +
formatCompareValue(a) +
' → <b>' +
formatCompareValue(b) +
'</b></div>';
}
function compareBuildEquipment(buildA, buildB) {
var html = '<div style="margin-top:10px;"><b>Equipment Differences</b></div>';
gearSlots.forEach(function (slot) {
var itemA = buildA.equippedItemNames && buildA.equippedItemNames[slot]
? buildA.equippedItemNames[slot]
: '-';
var itemB = buildB.equippedItemNames && buildB.equippedItemNames[slot]
? buildB.equippedItemNames[slot]
: '-';
if (itemA !== itemB) {
html += '<div><b>' + slot + ':</b> ' + itemA + ' → <b>' + itemB + '</b></div>';
}
});
return html;
}
function calculateBuildTotalsFromSharedBuild(buildData) {
var race = buildData.race;
var classCode = buildData.classCode;
if (!race || !classCode || !data.baseStats[race] || !data.baseStats[race][classCode]) {
return null;
}
var base = data.baseStats[race][classCode];
var total = {
str: base.str || 0,
sta: base.sta || 0,
agi: base.agi || 0,
dex: base.dex || 0,
wis: base.wis || 0,
int: base.int || 0,
cha: base.cha || 0,
hp: base.hp || 0,
pow: base.pow || 0,
ac: base.ac || 0,
pr: 48,
dr: 48,
fr: 48,
cr: 48,
lr: 48,
ar: 48
};
if (buildData.trainingStats) {
Object.keys(buildData.trainingStats).forEach(function (key) {
if (total[key] !== undefined) {
total[key] += parseInt(buildData.trainingStats[key], 10) || 0;
}
});
}
if (buildData.equippedItemNames) {
gearSlots.forEach(function (slot) {
var itemName = buildData.equippedItemNames[slot];
var item = findLoadedItemByName(itemName, slot);
if (!item) return;
["str", "sta", "agi", "dex", "wis", "int", "cha", "hp", "pow", "ac", "pr", "dr", "fr", "cr", "lr", "ar"].forEach(function (key) {
total[key] += parseFloat(item[key]) || 0;
});
});
}
return total;
}
function compareBuildStats(buildA, buildB) {
var totalsA = calculateBuildTotalsFromSharedBuild(buildA);
var totalsB = calculateBuildTotalsFromSharedBuild(buildB);
if (!totalsA || !totalsB) {
return '<div style="margin-top:10px;"><b>Stat Differences</b><br>Could not calculate stats for one or both builds.</div>';
}
var statOrder = [
"str", "sta", "agi", "dex", "wis", "int", "cha",
"hp", "pow", "ac",
"pr", "dr", "fr", "cr", "lr", "ar"
];
var html = '<div style="margin-top:10px;"><b>Stat Differences</b></div>';
statOrder.forEach(function (key) {
var a = parseFloat(totalsA[key]) || 0;
var b = parseFloat(totalsB[key]) || 0;
var diff = b - a;
var label = key.toUpperCase();
var diffText = diff > 0 ? ' <span class="cb-compare-positive">(+' + diff + ')</span>' :
diff < 0 ? ' <span class="cb-compare-negative">(' + diff + ')</span>' :
' (0)';
html += '<div><b>' + label + ':</b> ' + a + ' → ' + b + diffText + '</div>';
});
return html;
}
function compareSharedBuilds() {
if (!compareUrlAInput || !compareUrlBInput || !compareResultsBox) return;
var buildA = getBuildDataFromUrl(compareUrlAInput.value.trim());
var buildB = getBuildDataFromUrl(compareUrlBInput.value.trim());
if (!buildA || !buildB) {
compareResultsBox.style.display = 'block';
compareResultsBox.innerHTML = 'Could not compare builds. Make sure both URLs are valid shared build URLs.';
return;
}
var html = '<b>Build Comparison</b><br>';
html += compareTextValue('Build Name', buildA.buildName || '-', buildB.buildName || '-');
html += compareTextValue('Race', buildA.race || '-', buildB.race || '-');
html += compareTextValue('Class', buildA.classCode || '-', buildB.classCode || '-');
html += compareTextValue('Level', buildA.level || '-', buildB.level || '-');
html += compareBuildEquipment(buildA, buildB);
html += compareBuildStats(buildA, buildB);
compareResultsBox.innerHTML = html;
compareResultsBox.style.display = 'block';
}
function applyBuilderTheme(theme) {
if (!app) return;
app.classList.remove('cb-theme-dark');
app.classList.remove('cb-theme-light');
if (theme === 'light') {
app.classList.add('cb-theme-light');
} else {
app.classList.add('cb-theme-dark');
}
}
function toggleBuilderTheme() {
var isLight = app.classList.contains('cb-theme-light');
if (isLight) {
applyBuilderTheme('dark');
} else {
applyBuilderTheme('light');
}
}
function loadSavedBuilderTheme() {
var isLight =
document.documentElement.classList.contains('eqoa-theme-light') ||
document.body.classList.contains('eqoa-theme-light');
applyBuilderTheme(isLight ? 'light' : 'dark');
}
function getItemPrimarySlotLabel(item) {
if (!item) return "-";
if (item.slot) return item.slot;
if (item.isPrimary) return "Primary";
if (item.isSecondary) return "Secondary";
if (item.isShield) return "Shield";
if (item.isHeld) return "Held";
if (item.categories && item.categories.length) {
for (var i = 0; i < item.categories.length; i++) {
var cat = item.categories[i];
if (cat.indexOf(" Items") !== -1) {
return cat.replace(" Items", "");
}
}
}
return "-";
}
function searchAllLoadedItems(query) {
query = String(query || "").toLowerCase().trim();
if (!query) return [];
var allItems = getAllLoadedItemsFlat();
return allItems.filter(function (item) {
var searchable = [
item.name,
item.class,
item.slot,
item.weaponstyle,
item.proc,
item.effect,
item.droppedby,
item.zone,
item.droprate,
item.categories ? item.categories.join(" ") : ""
].join(" ").toLowerCase();
return searchable.indexOf(query) !== -1;
}).slice(0, 30);
}
function renderGlobalItemSearchResults() {
if (!globalItemSearchInput || !globalSearchResultsBox) return;
var query = globalItemSearchInput.value;
var results = searchAllLoadedItems(query);
if (!query || !query.trim()) {
globalSearchResultsBox.style.display = "none";
globalSearchResultsBox.innerHTML = "";
return;
}
globalSearchResultsBox.style.display = "block";
if (!results.length) {
globalSearchResultsBox.innerHTML = "No matching items found.";
return;
}
var html = '<b>Global Item Search Results</b><br>';
results.forEach(function (item) {
var slotLabel = getItemPrimarySlotLabel(item);
var lvlText = item.lvl ? "Lv " + item.lvl : "No Lv";
var classText = item.class ? item.class : "ALL";
var sourceText = "";
if (item.zone || item.droppedby) {
sourceText = " — " + [
item.zone || "",
item.droppedby || ""
].filter(Boolean).join(" / ");
}
html += '<div style="margin-top:6px;">' +
'<span class="cb-equipped-item-name" style="cursor:pointer;" data-global-item-name="' + item.name.replace(/"/g, '"') + '">' +
item.name +
'</span>' +
' — ' + slotLabel +
' — ' + lvlText +
' — ' + classText +
sourceText +
'</div>';
});
globalSearchResultsBox.innerHTML = html;
Array.prototype.forEach.call(
globalSearchResultsBox.querySelectorAll('[data-global-item-name]'),
function (el) {
el.addEventListener('click', function () {
openItemPage(el.getAttribute('data-global-item-name'));
});
el.addEventListener('mouseenter', function (event) {
var itemName = el.getAttribute('data-global-item-name');
var item = findLoadedItemByName(itemName, "Primary") || getAllLoadedItemsFlat().find(function (x) {
return x.name === itemName;
});
if (item) {
showTooltip(item, event);
}
});
el.addEventListener('mousemove', moveTooltip);
el.addEventListener('mouseleave', hideTooltip);
}
);
}
function escapeExportHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function populateExportCard() {
var buildName = buildNameInput ? buildNameInput.value.trim() : '';
function setExportText(id, value) {
var el = document.getElementById(id);
if (el) {
el.textContent = value;
}
}
var classIcons = {
WAR: '/images/classes/war.png',
PAL: '/images/classes/pal.png',
SK: '/images/classes/sk.png',
RGE: '/images/classes/rge.png',
BRD: '/images/classes/brd.png',
RAN: '/images/classes/ran.png',
MNK: '/images/classes/mnk.png',
CL: '/images/classes/cl.png',
DRD: '/images/classes/drd.png',
SHA: '/images/classes/sha.png',
NEC: '/images/classes/nec.png',
MAG: '/images/classes/mag.png',
ENC: '/images/classes/enc.png',
WIZ: '/images/classes/wiz.png',
ALC: '/images/classes/alc.png'
};
setExportText('cb-export-build-name', buildName || 'Unnamed Build');
setExportText(
'cb-export-race-transform',
(raceSelect.value || '-') + ', ' + (getTransformationPath() || 'None')
);
setExportText(
'cb-export-class',
data.classes && data.classes[classSelect.value]
? data.classes[classSelect.value]
: (classSelect.value || '-')
);
var classIcons = {
WAR: '/eqoawiki/index.php/Special:Redirect/file/WAR.png',
PAL: '/eqoawiki/index.php/Special:Redirect/file/PAL.png',
SK: '/eqoawiki/index.php/Special:Redirect/file/SK.png',
RGE: '/eqoawiki/index.php/Special:Redirect/file/RGE.png',
BRD: '/eqoawiki/index.php/Special:Redirect/file/BRD.png',
RAN: '/eqoawiki/index.php/Special:Redirect/file/RAN.png',
MNK: '/eqoawiki/index.php/Special:Redirect/file/MNK.png',
CL: '/eqoawiki/index.php/Special:Redirect/file/CL.png',
DRD: '/eqoawiki/index.php/Special:Redirect/file/DRD.png',
SHA: '/eqoawiki/index.php/Special:Redirect/file/SHA.png',
NEC: '/eqoawiki/index.php/Special:Redirect/file/NEC.png',
MAG: '/eqoawiki/index.php/Special:Redirect/file/MAG.png',
ENC: '/eqoawiki/index.php/Special:Redirect/file/ENC.png',
WIZ: '/eqoawiki/index.php/Special:Redirect/file/WIZ.png',
ALC: '/eqoawiki/index.php/Special:Redirect/file/ALC.png'
};
var classIcon = document.getElementById('cb-export-class-icon');
if (classIcon) {
classIcon.src =
classIcons[classSelect.value] || '';
classIcon.style.display =
classIcons[classSelect.value]
? 'block'
: 'none';
}
var classIcon = document.getElementById('cb-export-class-icon');
if (classIcon) {
classIcon.src =
classIcons[classSelect.value] || '';
classIcon.style.display =
classIcons[classSelect.value]
? 'block'
: 'none';
}
setExportText('cb-export-level', getCharacterLevel());
function statLine(key) {
var el = document.getElementById('cb-' + key);
var value = el ? el.textContent : '0';
return '<div><b>' + key.toUpperCase() + ':</b> ' + value + '</div>';
}
var coreStats = [
'str', 'wis',
'sta', 'int',
'agi', 'cha',
'dex'
];
var resists = [
'fr', 'ar',
'cr', 'pr',
'lr', 'dr'
];
var vitals = [
'hp',
'pow',
'ac'
];
document.getElementById('cb-export-core-stats').innerHTML =
coreStats.map(statLine).join('');
document.getElementById('cb-export-resists').innerHTML =
resists.map(statLine).join('');
setExportText(
'cb-export-hp-bar',
document.getElementById('cb-hp') ? document.getElementById('cb-hp').textContent : '0'
);
setExportText(
'cb-export-pow-bar',
document.getElementById('cb-pow') ? document.getElementById('cb-pow').textContent : '0'
);
setExportText(
'cb-export-ac-value',
document.getElementById('cb-ac') ? document.getElementById('cb-ac').textContent : '0'
);
var equipmentRows = [
['Head', 'Hands'],
['Ear1', 'Ear2'],
['Neck', 'Waist'],
['Bracelet1', 'Bracelet2'],
['Forearm', 'Bracelet2'],
['Chest', 'Legs'],
['Robe', 'Feet'],
['Ring1', 'Ring2'],
['Primary', 'Secondary'],
['Held1', 'Held2'],
['Range', '']
];
function getExportItemHtml(slot) {
var item;
if (!slot) return '';
item = equippedItems[slot];
if (!item) return '';
return '<span class="cb-export-slot-label">' +
slot +
'</span> ' +
'<span class="cb-rarity-' +
normalizeRarity(item.rarity) +
'">' +
escapeExportHtml(item.name) +
'</span>';
}
function rowHasAnyItem(row) {
return row.some(function (slot) {
return slot && equippedItems[slot];
});
}
var itemsHtml = '';
equipmentRows.forEach(function (row) {
if (!rowHasAnyItem(row)) return;
if (row[0] === 'Bracelet1' && equippedItems.Forearm) return;
if (row[0] === 'Forearm' && !equippedItems.Forearm) return;
itemsHtml +=
'<div class="cb-export-equipment-row">' +
'<div>' + getExportItemHtml(row[0]) + '</div>' +
'<div>' + getExportItemHtml(row[1]) + '</div>' +
'</div>';
});
document.getElementById('cb-export-items').innerHTML =
itemsHtml || '<div class="cb-export-empty">No equipment selected.</div>';
if (exportNotesBox) {
var notes = buildNotesInput
? buildNotesInput.value.trim()
: '';
exportNotesBox.textContent =
notes || 'No build notes.';
}
var cmState = window.EQOA_CLASS_MASTERY_STATE;
var cmData = cmState && typeof cmState.getExportData === 'function'
? cmState.getExportData()
: null;
if (exportMasterClassBox) {
exportMasterClassBox.textContent =
cmData && cmData.masterClassTitle ? cmData.masterClassTitle : 'None';
}
var exportLevelTitleBox = document.getElementById('cb-export-level-title');
if (exportLevelTitleBox) {
exportLevelTitleBox.textContent =
'Level ' +
getCharacterLevel() +
' ' +
(
cmData && cmData.masterClassTitle
? cmData.masterClassTitle
: (
data.classes && data.classes[classSelect.value]
? data.classes[classSelect.value]
: classSelect.value
)
);
}
if (exportClassPathBox) {
var classPathRows = getSelectedClassPathSummary();
if (!classPathRows.length) {
exportClassPathBox.textContent = 'None';
} else {
exportClassPathBox.innerHTML = classPathRows.map(function (row) {
return '<div class="cb-export-class-path-row">' +
'<b>Lv' + escapeExportHtml(row.tier) + '</b> ' +
escapeExportHtml(row.value) +
'</div>';
}).join('');
}
}
if (exportExemplarPathBox) {
exportExemplarPathBox.textContent =
cmData && cmData.exemplarPathChoice ? cmData.exemplarPathChoice : 'None';
}
if (exportTransformPathBox) {
exportTransformPathBox.textContent = getTransformationPath() || 'None';
}
function getExportCmRows(selectedMasteries) {
var groups = {};
var rows = [];
function romanToNumber(value) {
return {
I: 1,
II: 2,
III: 3,
IV: 4,
V: 5
}[String(value || '').toUpperCase()] || 0;
}
selectedMasteries.forEach(function (name) {
var match = String(name || '').match(/^(.*)\s+(\d+|I|II|III|IV|V)$/i);
var baseName;
var rankText;
var rankValue;
if (!match) {
rows.push({
label: name,
sort: name
});
return;
}
baseName = match[1];
rankText = match[2];
rankValue = parseInt(rankText, 10);
if (isNaN(rankValue)) {
rankValue = romanToNumber(rankText);
}
if (!groups[baseName]) {
groups[baseName] = {
baseName: baseName,
ranks: []
};
}
groups[baseName].ranks.push({
text: rankText,
value: rankValue
});
});
Object.keys(groups).sort().forEach(function (baseName) {
var group = groups[baseName];
group.ranks.sort(function (a, b) {
return a.value - b.value;
});
rows.push({
label: group.baseName + ' ' + group.ranks.map(function (rank) {
return rank.text;
}).join(' > '),
sort: group.baseName
});
});
rows.sort(function (a, b) {
return String(a.sort || '').localeCompare(String(b.sort || ''));
});
return rows;
}
if (exportCmBox) {
if (!cmData) {
exportCmBox.innerHTML = '<div>No Class Mastery data selected.</div>';
} else {
var html = '';
var cmRows = getExportCmRows(cmData.selectedMasteries || []);
var splitIndex = Math.ceil(cmRows.length / 2);
var leftRows = cmRows.slice(0, splitIndex);
var rightRows = cmRows.slice(splitIndex);
function renderCmColumn(rows) {
if (!rows.length) {
return '<div>None selected.</div>';
}
return rows.map(function (row) {
return '<div class="cb-export-cm-row">- ' + escapeExportHtml(row.label) + '</div>';
}).join('');
}
html += '<div><b>CMs:</b> ' + cmData.spent + ' spent / ' + cmData.available + ' available</div>';
html += '<div style="margin-top:10px;"><b>Selected Masteries</b></div>';
html += '<div class="cb-export-cm-columns">' +
'<div>' + renderCmColumn(leftRows) + '</div>' +
'<div>' + renderCmColumn(rightRows) + '</div>' +
'</div>';
exportCmBox.innerHTML = html;
}
}
}
function exportBuildCardImage() {
if (typeof html2canvas === 'undefined') {
alert('Export image tool is still loading. Try again in a moment.');
return;
}
if (!exportCardInner) {
alert('Export card container not found.');
return;
}
populateExportCard();
var exportContainer = document.getElementById('cb-export-card-container');
if (exportContainer) {
exportContainer.style.position = 'fixed';
exportContainer.style.left = '-99999px';
exportContainer.style.top = '0';
exportContainer.style.width = '1200px';
exportContainer.style.minWidth = '1200px';
exportContainer.style.opacity = '1';
exportContainer.style.pointerEvents = 'none';
exportContainer.style.zIndex = '-9999';
exportContainer.style.display = 'block';
}
exportCardInner.style.width = '1200px';
exportCardInner.style.minWidth = '1200px';
exportCardInner.style.display = 'block';
setTimeout(function () {
html2canvas(exportCardInner, {
backgroundColor: '#100a06',
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
onclone: function (clonedDocument) {
Array.prototype.forEach.call(
clonedDocument.querySelectorAll('#cb-export-card-inner *'),
function (el) {
el.style.backgroundImage = 'none';
el.style.webkitMaskImage = 'none';
el.style.maskImage = 'none';
}
);
var hpBar = clonedDocument.querySelector('.cb-export-hp-row .cb-export-resource-bar');
var powBar = clonedDocument.querySelector('.cb-export-pow-row .cb-export-resource-bar');
if (hpBar) {
hpBar.style.setProperty('background-color', '#b00000', 'important');
hpBar.style.setProperty('border-color', '#050505', 'important');
Array.prototype.forEach.call(hpBar.querySelectorAll('i'), function (bubble) {
bubble.style.setProperty('background-color', '#b00000', 'important');
});
}
if (powBar) {
powBar.style.setProperty('background-color', '#0070b8', 'important');
powBar.style.setProperty('border-color', '#050505', 'important');
Array.prototype.forEach.call(powBar.querySelectorAll('i'), function (bubble) {
bubble.style.setProperty('background-color', '#0070b8', 'important');
});
}
}
}).then(function (canvas) {
var link = document.createElement('a');
var buildName = buildNameInput && buildNameInput.value
? buildNameInput.value.trim()
: 'EQOA-Build';
link.download = buildName.replace(/[^a-z0-9]/gi, '_') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
}).catch(function (error) {
console.error('Export build card failed.', error);
alert('Export failed. Check console for details.');
});
}, 150);
}
function normalizeRarity(rarity) {
return String(rarity || 'common')
.toLowerCase()
.replace(/\s+/g, '-');
}
function getClassArchetype(classCode) {
classCode = normalizeVitalClassKey(classCode);
if (['WAR', 'PAL', 'SK'].indexOf(classCode) !== -1) return 'tank';
if (['RGE', 'ROG', 'BRD', 'RAN', 'MNK', 'ALC'].indexOf(classCode) !== -1) return 'melee';
if (['CL', 'DRD', 'SHA'].indexOf(classCode) !== -1) return 'priest';
return 'caster';
}
function getBaseHpFactor(classCode) {
var archetype = getClassArchetype(classCode);
if (archetype === 'tank') return 24;
if (archetype === 'melee') return 16;
if (archetype === 'priest') return 13;
return 10;
}
function getHeartyHpFactorBonus() {
var selected = [];
if (
window.EQOA_CLASS_MASTERY_STATE &&
typeof window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries === 'function'
) {
selected = window.EQOA_CLASS_MASTERY_STATE.getSelectedMasteries();
}
return selected.reduce(function (total, name) {
var match = String(name || '').match(/^Hearty\s+(\d+|I|II|III|IV|V)$/i);
var rank;
if (!match) return total;
rank = parseInt(match[1], 10);
if (isNaN(rank)) {
rank = {
I: 1,
II: 2,
III: 3,
IV: 4,
V: 5
}[String(match[1]).toUpperCase()] || 0;
}
return total + rank;
}, 0);
}
function calculateFormulaHp(classCode, level, stamina, itemHpBonus) {
var hpFactor = getBaseHpFactor(classCode) + getHeartyHpFactorBonus();
return Math.floor(
(hpFactor + ((parseFloat(stamina) || 0) / 11)) *
(parseInt(level, 10) || 1)
) + (parseInt(itemHpBonus, 10) || 0);
}
function getPowerStatsForClass(classCode) {
classCode = normalizeVitalClassKey(classCode);
if (['CL', 'DRD', 'SHA', 'PAL'].indexOf(classCode) !== -1) {
return { primary: 'wis', secondary: 'cha' };
}
if (['WAR', 'SK', 'RGE', 'ROG', 'BRD', 'RAN', 'MNK', 'ALC'].indexOf(classCode) !== -1) {
return { primary: 'dex', secondary: 'agi' };
}
return { primary: 'int', secondary: 'agi' };
}
function calculateFormulaPow(classCode, level, totalStats, itemPowBonus) {
var powerStats = getPowerStatsForClass(classCode);
var primary = parseFloat(totalStats[powerStats.primary]) || 0;
var secondary = parseFloat(totalStats[powerStats.secondary]) || 0;
return Math.floor(
(28 + (primary / 10) + (secondary / 20)) *
(parseInt(level, 10) || 1)
) + (parseInt(itemPowBonus, 10) || 0);
}
function updateStats(skipPublish) {
var base = getBaseStats();
if (!base) return;
var level = getCharacterLevel();
var vitalsSection = document.getElementById('cb-player-vitals-section');
if (vitalsSection) {
var showVitals =
window.EQOA_CONFIG &&
window.EQOA_CONFIG.features &&
(
window.EQOA_CONFIG.features.characterBuilderCalculatedHP ||
window.EQOA_CONFIG.features.characterBuilderCalculatedPOW
);
vitalsSection.style.display = showVitals ? '' : 'none';
}
var total = {
str: base.str || 0,
sta: base.sta || 0,
agi: base.agi || 0,
dex: base.dex || 0,
wis: base.wis || 0,
int: base.int || 0,
cha: base.cha || 0,
hp: base.hp || 0,
pow: base.pow || 0,
ac: base.ac || 0,
pr: 48,
dr: 48,
fr: 48,
cr: 48,
lr: 48,
ar: 48
};
Object.keys(trainingStats).forEach(function (key) {
total[key] += parseInt(trainingStats[key], 10) || 0;
});
Object.keys(equippedItems).forEach(function (slot) {
var item = equippedItems[slot];
addStat(total, item, "str");
addStat(total, item, "sta");
addStat(total, item, "agi");
addStat(total, item, "dex");
addStat(total, item, "wis");
addStat(total, item, "int");
addStat(total, item, "cha");
addStat(total, item, "hp");
addStat(total, item, "pow");
addStat(total, item, "ac");
addStat(total, item, "pr");
addStat(total, item, "dr");
addStat(total, item, "fr");
addStat(total, item, "cr");
addStat(total, item, "lr");
addStat(total, item, "ar");
});
var availableTP = getTrainingPointsForLevel(level);
var spentTP = getSpentTrainingPoints();
var remainingTP = availableTP - spentTP;
if (remainingTP < 0) remainingTP = 0;
document.getElementById('cb-tp-available').textContent = availableTP;
document.getElementById('cb-tp-spent').textContent = spentTP;
document.getElementById('cb-tp-remaining').textContent = remainingTP;
var statCaps = getClassMasteryStatCaps();
setCappedStat('cb-str', total.str, statCaps.str);
setCappedStat('cb-sta', total.sta, statCaps.sta);
setCappedStat('cb-agi', total.agi, statCaps.agi);
setCappedStat('cb-dex', total.dex, statCaps.dex);
setCappedStat('cb-wis', total.wis, statCaps.wis);
setCappedStat('cb-int', total.int, statCaps.int);
setCappedStat('cb-cha', total.cha, statCaps.cha);
var itemHP = getItemVitalBonus(total.hp, base.hp || 0);
total.hp = calculateFormulaHp(
classSelect.value,
level,
total.sta,
itemHP
);
var itemPOW = getItemVitalBonus(total.pow, base.pow || 0);
total.pow = calculateFormulaPow(
classSelect.value,
level,
total,
itemPOW
);
document.getElementById('cb-hp').textContent = total.hp;
document.getElementById('cb-pow').textContent = total.pow;
document.getElementById('cb-ac').textContent = total.ac;
var playerHpBox = document.getElementById('cb-player-hp');
var playerPowBox = document.getElementById('cb-player-pow');
if (playerHpBox) playerHpBox.textContent = total.hp;
if (playerPowBox) playerPowBox.textContent = total.pow;
setCappedStat('cb-pr', total.pr, 315);
setCappedStat('cb-dr', total.dr, 315);
setCappedStat('cb-fr', total.fr, 315);
setCappedStat('cb-cr', total.cr, 315);
setCappedStat('cb-lr', total.lr, 315);
setCappedStat('cb-ar', total.ar, 315);
updateEquippedSummary();
updateWeaponSummary();
updateWarnings();
if (!skipPublish) {
publishCharacterBuilderState();
}
}
function removeInvalidEquippedItemsForLevel() {
Object.keys(equippedItems).forEach(function (slot) {
if (!levelCanUseItem(equippedItems[slot])) {
delete equippedItems[slot];
}
});
}
function bindTrainingInputs() {
Object.keys(trainingStats).forEach(function (key) {
var input = document.getElementById('train-' + key);
input.addEventListener('input', function () {
clampTrainingInput(key);
updateStats();
});
});
}
function resetClassMasteryFromCharacterBuilder() {
if (window.EQOA_RESET_CLASS_MASTERY) {
window.EQOA_RESET_CLASS_MASTERY();
}
}
function resetCharacterBuildAfterIdentityChange() {
equippedItems = {};
pendingSharedBuild = null;
sharedBuildApplied = false;
missingSharedItems = [];
classPathChoices = {};
populateClassPathControls();
if (buildNameInput) buildNameInput.value = '';
if (buildNotesInput) buildNotesInput.value = '';
Object.keys(trainingStats).forEach(function (key) {
trainingStats[key] = 0;
var input = document.getElementById('train-' + key);
if (input) input.value = 0;
});
levelInput.value = 60;
if (cmPointsInput) {
cmPointsInput.value = getMaxCmPointsForLevel();
}
if (shareUrlBox) shareUrlBox.style.display = 'none';
if (shareUrlOutput) shareUrlOutput.value = '';
if (shareUrlStatus) shareUrlStatus.textContent = '';
resetClassMasteryFromCharacterBuilder();
}
raceSelect.addEventListener('change', function () {
resetCharacterBuildAfterIdentityChange();
loadClasses();
buildGearSlots();
updateStats();
});
classSelect.addEventListener('change', function () {
resetCharacterBuildAfterIdentityChange();
buildGearSlots();
populateClassPathControls();
updateStats();
});
levelInput.addEventListener('change', function () {
getAvailableCmPoints();
Object.keys(trainingStats).forEach(function (key) {
clampTrainingInput(key);
});
removeInvalidEquippedItemsForLevel();
enforceDisabledSlots();
buildGearSlots();
populateClassPathControls();
updateStats();
});
if (cmPointsInput) {
cmPointsInput.addEventListener('change', function () {
getAvailableCmPoints();
publishCharacterBuilderState();
});
}
if (generateShareButton) {
generateShareButton.addEventListener('click', generateShareUrl);
}
if (copyShareButton) {
copyShareButton.addEventListener('click', copyShareUrl);
}
if (resetBuildButton) {
resetBuildButton.addEventListener('click', resetBuild);
}
if (compareBuildsButton) {
compareBuildsButton.addEventListener('click', compareSharedBuilds);
}
if (globalItemSearchInput) {
globalItemSearchInput.addEventListener('input', renderGlobalItemSearchResults);
}
/* Theme is controlled globally by MediaWiki:Common.js */
window.EQOA_EXPORT_COMBINED_BUILD_CARD = exportBuildCardImage;
if (exportCardButton) {
exportCardButton.addEventListener('click', exportBuildCardImage);
}
Array.prototype.forEach.call(builderTabButtons, function (button) {
button.addEventListener('click', function () {
switchBuilderTab(button.getAttribute('data-cb-tab'));
});
});
loadSavedBuilderTheme();
window.addEventListener('eqoa-class-mastery-state-change', function () {
updateStats(true);
});
window.addEventListener('eqoa-wiki-theme-change', function (event) {
if (event.detail && event.detail.theme) {
applyBuilderTheme(event.detail.theme);
}
});
loadClasses();
bindTrainingInputs();
bindClassPathControls();
populateClassPathControls();
tryLoadSharedBuildFromUrl();
loadAllSlotItems();
updateStats();
publishCharacterBuilderState();
}
});
function openItemPage(itemName) {
window.open(
mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(itemName)),
'_blank'
);
}