// Suitbuilder JavaScript - Constraint Solver Frontend Logic console.log('Suitbuilder.js loaded - VERSION: CONSISTENT_SUIT_COUNT_v4'); // Configuration const API_BASE = '/inv/suitbuilder'; let currentSuits = []; let lockedSlots = new Map(); // slot -> { set: string|null, setId: number|null, spells: string[] } let selectedSuit = null; let currentSearchController = null; // AbortController for current search // Common legendary cantrips for lock form const COMMON_CANTRIPS = [ // Attributes 'Legendary Strength', 'Legendary Endurance', 'Legendary Coordination', 'Legendary Quickness', 'Legendary Focus', 'Legendary Willpower', // Weapon Skills 'Legendary Finesse Weapon Aptitude', 'Legendary Heavy Weapon Aptitude', 'Legendary Light Weapon Aptitude', 'Legendary Missile Weapon Aptitude', 'Legendary Two Handed Combat Aptitude', // Magic Skills 'Legendary War Magic Aptitude', 'Legendary Void Magic Aptitude', 'Legendary Creature Enchantment Aptitude', 'Legendary Item Enchantment Aptitude', 'Legendary Life Magic Aptitude', // Defense 'Legendary Magic Resistance', 'Legendary Invulnerability' ]; // Common legendary wards for lock form const COMMON_WARDS = [ 'Legendary Flame Ward', 'Legendary Frost Ward', 'Legendary Acid Ward', 'Legendary Storm Ward', 'Legendary Slashing Ward', 'Legendary Piercing Ward', 'Legendary Bludgeoning Ward', 'Legendary Armor' ]; // Armor slots (have equipment sets) const ARMOR_SLOTS = ['Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands', 'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet']; // Initialize when page loads document.addEventListener('DOMContentLoaded', function() { initializeSuitbuilder(); }); /** * Initialize all suitbuilder functionality */ function initializeSuitbuilder() { loadCharacters(); setupEventListeners(); setupSlotInteractions(); } /** * Load available characters for selection */ async function loadCharacters() { try { const response = await fetch(`${API_BASE}/characters`); if (!response.ok) { throw new Error('Failed to load characters'); } const data = await response.json(); displayCharacters(data.characters); } catch (error) { console.error('Error loading characters:', error); document.getElementById('characterList').innerHTML = '
Failed to load characters
'; } } /** * Display characters in the selection panel */ function displayCharacters(characters) { const characterList = document.getElementById('characterList'); if (!characters || characters.length === 0) { characterList.innerHTML = '
No characters found
'; return; } let html = ''; characters.forEach(character => { // Sanitize character name for HTML ID (replace special chars with underscores) const safeId = character.replace(/[^a-zA-Z0-9]/g, '_'); html += `
`; }); characterList.innerHTML = html; // Setup character checkbox interactions setupCharacterCheckboxes(); } /** * Setup character checkbox interactions */ function setupCharacterCheckboxes() { const allCheckbox = document.getElementById('char_all'); const characterCheckboxes = document.querySelectorAll('.character-checkbox:not([value="all"])'); // "All Characters" checkbox toggle allCheckbox.addEventListener('change', function() { characterCheckboxes.forEach(cb => { cb.checked = this.checked; }); }); // Individual character checkbox changes characterCheckboxes.forEach(cb => { cb.addEventListener('change', function() { // Update "All Characters" checkbox state const checkedCount = Array.from(characterCheckboxes).filter(cb => cb.checked).length; allCheckbox.checked = checkedCount === characterCheckboxes.length; allCheckbox.indeterminate = checkedCount > 0 && checkedCount < characterCheckboxes.length; }); }); } /** * Setup all event listeners */ function setupEventListeners() { // Main action buttons document.getElementById('searchSuits').addEventListener('click', performSuitSearch); document.getElementById('clearAll').addEventListener('click', clearAllConstraints); // Slot control buttons document.getElementById('lockSelectedSlots').addEventListener('click', lockSelectedSlots); document.getElementById('clearAllLocks').addEventListener('click', clearAllLocks); document.getElementById('resetSlotView').addEventListener('click', resetSlotView); } /** * Setup slot interaction functionality */ function setupSlotInteractions() { // Lock button interactions document.querySelectorAll('.lock-btn').forEach(btn => { btn.addEventListener('click', function() { const slot = this.dataset.slot; toggleSlotLock(slot); }); }); // Slot item click interactions document.querySelectorAll('.slot-item').forEach(item => { item.addEventListener('click', function() { const slot = this.dataset.slot; handleSlotClick(slot); }); }); } /** * Perform suit search with current constraints using streaming results */ // Global timer for search let searchStartTime = null; let searchTimerInterval = null; async function performSuitSearch() { const constraints = gatherConstraints(); if (!validateConstraints(constraints)) { return; } const resultsDiv = document.getElementById('suitResults'); const countSpan = document.getElementById('resultsCount'); // Start timer IMMEDIATELY searchStartTime = Date.now(); if (searchTimerInterval) clearInterval(searchTimerInterval); // Reset current suits and UI with new fancy template currentSuits = []; resultsDiv.innerHTML = `
âŗ
Initializing...
Time
0.0
seconds
Evaluated
0
combinations
Rate
-
per second
Found
0
suits
Verbose Output 0
`; countSpan.textContent = ''; // Start client-side timer update (every 100ms for responsive display) searchTimerInterval = setInterval(() => { const elapsed = (Date.now() - searchStartTime) / 1000; const timeEl = document.getElementById('elapsedTime'); if (timeEl) timeEl.textContent = elapsed.toFixed(1); }, 100); try { await streamOptimalSuits(constraints); } catch (error) { // Don't show error for user-cancelled searches if (error.name === 'AbortError') { console.log('Search cancelled by user'); } else { console.error('Suit search error:', error); resultsDiv.innerHTML = `
❌ Suit search failed: ${error.message}
`; countSpan.textContent = ''; } } finally { // Stop client-side timer if (searchTimerInterval) { clearInterval(searchTimerInterval); searchTimerInterval = null; } } } /** * Gather all current constraints from the form */ function gatherConstraints() { // Get selected characters const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked:not([value="all"])')) .map(cb => cb.value); // Get rating constraints const constraints = { characters: selectedCharacters, min_armor: document.getElementById('minArmor').value || null, max_armor: document.getElementById('maxArmor').value || null, min_crit_damage: document.getElementById('minCritDmg').value || null, max_crit_damage: document.getElementById('maxCritDmg').value || null, min_damage_rating: document.getElementById('minDmgRating').value || null, max_damage_rating: document.getElementById('maxDmgRating').value || null, // Equipment status include_equipped: document.getElementById('includeEquipped').checked, include_inventory: document.getElementById('includeInventory').checked, // Equipment sets primary_set: document.getElementById('primarySet').value || null, secondary_set: document.getElementById('secondarySet').value || null, // Legendary cantrips (from cantrips-grid only) legendary_cantrips: Array.from(document.querySelectorAll('.cantrips-grid input:checked')) .map(cb => cb.value) .filter(value => value.includes('Legendary')), // Legendary wards (separate section) protection_spells: Array.from(document.querySelectorAll('#protection_flame, #protection_frost, #protection_acid, #protection_storm, #protection_slashing, #protection_piercing, #protection_bludgeoning, #protection_armor')) .filter(cb => cb.checked) .map(cb => cb.value), // Locked slots - convert Map to object for API locked_slots: Object.fromEntries( Array.from(lockedSlots.entries()).map(([slot, info]) => [ slot, { set_id: info.setId || null, spells: info.spells || [] } ]) ) }; return constraints; } /** * Validate constraints before search */ function validateConstraints(constraints) { if (!constraints.characters || constraints.characters.length === 0) { alert('Please select at least one character.'); return false; } if (!constraints.primary_set && !constraints.secondary_set && constraints.legendary_cantrips.length === 0 && constraints.protection_spells.length === 0 && !constraints.min_armor && !constraints.min_crit_damage && !constraints.min_damage_rating) { alert('Please specify at least one constraint (equipment sets, cantrips, legendary wards, or rating minimums).'); return false; } return true; } /** * Stream optimal suits using Server-Sent Events with progressive results */ async function streamOptimalSuits(constraints) { // Prepare constraint data for POST request const requestBody = { characters: constraints.characters.length > 0 ? constraints.characters : [], primary_set: constraints.primary_set ? parseInt(constraints.primary_set) : null, secondary_set: constraints.secondary_set ? parseInt(constraints.secondary_set) : null, required_spells: [ ...constraints.legendary_cantrips, ...constraints.protection_spells ], locked_slots: constraints.locked_slots || {}, include_equipped: constraints.include_equipped, include_inventory: constraints.include_inventory, min_armor: constraints.min_armor ? parseInt(constraints.min_armor) : null, max_armor: constraints.max_armor ? parseInt(constraints.max_armor) : null, min_crit_damage: constraints.min_crit_damage ? parseInt(constraints.min_crit_damage) : null, max_crit_damage: constraints.max_crit_damage ? parseInt(constraints.max_crit_damage) : null, min_damage_rating: constraints.min_damage_rating ? parseInt(constraints.min_damage_rating) : null, max_damage_rating: constraints.max_damage_rating ? parseInt(constraints.max_damage_rating) : null, max_results: 10, search_timeout: 300 }; console.log('Starting suit search with constraints:', requestBody); // Cancel any existing search if (currentSearchController) { currentSearchController.abort(); } // Create new AbortController for this search currentSearchController = new AbortController(); // Use fetch with streaming response instead of EventSource for POST support const response = await fetch(`${API_BASE}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), signal: currentSearchController.signal // Add abort signal }); if (!response.ok) { throw new Error(`Search failed: ${response.statusText}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); return new Promise((resolve, reject) => { let searchStopped = false; let buffer = ''; async function readStream() { try { while (true) { const { done, value } = await reader.read(); if (done) { resolve(); break; } if (searchStopped) { await reader.cancel(); resolve(); break; } // Process SSE data buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer let currentEventType = null; for (const line of lines) { if (line.startsWith('event: ')) { currentEventType = line.substring(7).trim(); continue; } if (line.startsWith('data: ')) { const data = line.substring(6); try { const eventData = JSON.parse(data); // Handle different event types based on the current event type if (currentEventType === 'suit') { handleSuitEvent(eventData); } else if (currentEventType === 'progress') { handleProgressEvent(eventData); } else if (currentEventType === 'phase') { handlePhaseEvent(eventData); } else if (currentEventType === 'log') { handleLogEvent(eventData); } else if (currentEventType === 'complete') { handleCompleteEvent(eventData); resolve(); return; } else if (currentEventType === 'error') { handleErrorEvent(eventData); reject(new Error(eventData.message || 'Search error')); return; } // Reset event type after processing currentEventType = null; } catch (parseError) { console.warn('Failed to parse SSE data:', data, 'Event type:', currentEventType); } } } } } catch (error) { // Don't treat abort as an error if (error.name === 'AbortError') { console.log('Search was aborted by user'); resolve(); } else { reject(error); } } } readStream(); // Event handlers function handleSuitEvent(data) { try { console.log('NEW handleSuitEvent called with data:', data); // Transform backend suit format to frontend format const transformedSuit = transformSuitData(data); console.log('Transformed suit:', transformedSuit); // Insert suit in score-ordered position (highest score first) const insertIndex = insertSuitInScoreOrder(transformedSuit); console.log('Insert index returned:', insertIndex); // If suit wasn't inserted (not good enough for top N), skip DOM update if (insertIndex === -1) { console.log('Suit not inserted, skipping DOM update'); return; } // Insert DOM element at the correct position insertSuitDOMAtPosition(transformedSuit, insertIndex); console.log('DOM insertion complete'); // Trim excess suits from array and DOM to maintain consistent count trimExcessSuits(); // Update count document.getElementById('foundCount').textContent = currentSuits.length; document.getElementById('resultsCount').textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`; } catch (error) { console.error('Error processing suit data:', error); console.error('Stack trace:', error.stack); } } function handleProgressEvent(data) { try { // Always show frontend's actual suit count for consistency document.getElementById('foundCount').textContent = currentSuits.length; document.getElementById('checkedCount').textContent = (data.evaluated || 0).toLocaleString(); // Update rate display const rateEl = document.getElementById('searchRate'); if (rateEl && data.rate !== undefined) { rateEl.textContent = data.rate.toLocaleString(); } // Update bucket progress bar if (data.current_depth !== undefined && data.total_buckets) { const bucketContainer = document.getElementById('bucketProgressContainer'); const bucketProgress = document.getElementById('bucketProgress'); const currentBucket = document.getElementById('currentBucket'); if (bucketContainer) bucketContainer.style.display = 'block'; if (bucketProgress) { const progress = (data.current_depth / data.total_buckets) * 100; bucketProgress.style.width = `${progress}%`; } if (currentBucket && data.current_bucket) { currentBucket.textContent = `Searching: ${data.current_bucket} (${data.current_depth + 1}/${data.total_buckets})`; } } // Note: Timer is now handled client-side for immediate response } catch (error) { console.error('Error processing progress data:', error); } } function handlePhaseEvent(data) { try { const phaseIcon = document.getElementById('phaseIcon'); const phaseText = document.getElementById('searchPhase'); const phaseProgress = document.getElementById('phaseProgress'); // Phase icons const icons = { 'loading': '📂', 'loaded': '✅', 'buckets': 'đŸ—‚ī¸', 'buckets_done': '✅', 'reducing': 'âœ‚ī¸', 'sorting': '📊', 'searching': '🔍' }; if (phaseIcon) { phaseIcon.textContent = icons[data.phase] || 'âŗ'; // Add/remove animation for searching phase if (data.phase === 'searching') { phaseIcon.classList.add('searching'); } } if (phaseText) { phaseText.textContent = data.message || data.phase; } // Update initialization progress bar if (phaseProgress && data.phase_number && data.total_phases) { const progress = (data.phase_number / data.total_phases) * 100; phaseProgress.style.width = `${progress}%`; // Make it indeterminate during searching if (data.phase === 'searching') { phaseProgress.classList.add('indeterminate'); } } // Log the phase change addLogEntry(data.message, 'phase'); } catch (error) { console.error('Error processing phase data:', error); } } function handleLogEvent(data) { try { addLogEntry(data.message, data.level || 'info', data.timestamp); } catch (error) { console.error('Error processing log data:', error); } } function addLogEntry(message, level = 'info', timestamp = null) { const logContainer = document.getElementById('verboseLog'); const logCount = document.getElementById('logCount'); if (!logContainer) return; const elapsed = timestamp || ((Date.now() - searchStartTime) / 1000); const entry = document.createElement('div'); entry.className = `log-entry log-${level}`; entry.innerHTML = `[${elapsed.toFixed(1)}s] ${message}`; logContainer.appendChild(entry); // Trim old entries (max 100) while (logContainer.children.length > 100) { logContainer.removeChild(logContainer.firstChild); } // Update log count if (logCount) { logCount.textContent = logContainer.children.length; } // Auto-scroll to bottom logContainer.scrollTop = logContainer.scrollHeight; } function handleCompleteEvent(data) { try { const duration = ((Date.now() - searchStartTime) / 1000).toFixed(1); // Update phase indicator to complete const phaseIcon = document.getElementById('phaseIcon'); const phaseText = document.getElementById('searchPhase'); const phaseProgress = document.getElementById('phaseProgress'); if (phaseIcon) { phaseIcon.textContent = '✅'; phaseIcon.classList.remove('searching'); phaseIcon.classList.add('complete'); } if (phaseText) { phaseText.textContent = `Search complete! Found ${currentSuits.length} suits`; phaseText.classList.add('complete'); } if (phaseProgress) { phaseProgress.classList.remove('indeterminate'); phaseProgress.style.width = '100%'; } // Fill bucket progress bar const bucketProgress = document.getElementById('bucketProgress'); if (bucketProgress) { bucketProgress.style.width = '100%'; } // Hide stop button const stopBtn = document.getElementById('stopSearch'); if (stopBtn) { stopBtn.style.display = 'none'; } // Add completion log entry addLogEntry(`Search complete: ${currentSuits.length} suits found in ${duration}s`, 'success'); // Update final results count const countSpan = document.getElementById('resultsCount'); if (countSpan) { countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`; } } catch (error) { console.error('Error processing completion data:', error); } } function handleErrorEvent(data) { try { const loadingDiv = document.querySelector('.loading'); if (loadingDiv) { loadingDiv.innerHTML = `❌ Search error: ${data.message}`; } } catch (error) { console.error('Error processing error data:', error); } } // Add stop search functionality const stopButton = document.getElementById('stopSearch'); stopButton.addEventListener('click', () => { searchStopped = true; // Actually abort the HTTP request if (currentSearchController) { currentSearchController.abort(); currentSearchController = null; } // Update phase indicator to show stopped const phaseIcon = document.getElementById('phaseIcon'); const phaseText = document.getElementById('searchPhase'); const phaseProgress = document.getElementById('phaseProgress'); if (phaseIcon) { phaseIcon.textContent = 'âšī¸'; phaseIcon.classList.remove('searching'); } if (phaseText) { phaseText.textContent = `Search stopped - Found ${currentSuits.length} suits`; phaseText.style.color = '#ffa726'; } if (phaseProgress) { phaseProgress.classList.remove('indeterminate'); } // Hide stop button stopButton.style.display = 'none'; // Add log entry addLogEntry(`Search stopped by user with ${currentSuits.length} suits found`, 'warning'); // Update results count const countSpan = document.getElementById('resultsCount'); if (countSpan) { countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''} (search stopped)`; } }); }); } /** * Transform backend suit optimization response to frontend format */ function transformSuitsResponse(data) { if (!data.suits || data.suits.length === 0) { return []; } return data.suits.map(suit => { return transformSuitData(suit); }); } /** * Transform individual suit data from backend to frontend format */ function transformSuitData(suit) { return { id: suit.id || currentSuits.length + 1, score: Math.round(suit.score || 0), items: suit.items || {}, stats: suit.stats || {}, missing: suit.missing || [], notes: suit.notes || [], alternatives: [], primary_set: suit.stats?.primary_set || '', primary_set_count: suit.stats?.primary_set_count || 0, secondary_set: suit.stats?.secondary_set || '', secondary_set_count: suit.stats?.secondary_set_count || 0, total_armor: suit.stats?.total_armor || 0, total_crit_damage: suit.stats?.total_crit_damage || 0, total_damage_rating: suit.stats?.total_damage_rating || 0, spell_coverage: suit.stats?.spell_coverage || 0 }; } /** * Insert a suit into the currentSuits array in score-ordered position (highest first) * Keeps only top MAX_SUITS to match backend behavior and ensure consistent counts */ const MAX_SUITS = 10; // Must match max_results sent to backend function insertSuitInScoreOrder(suit) { console.log(`Inserting suit with score ${suit.score}. Current suits:`, currentSuits.map(s => s.score)); // Find the correct position to insert the suit (highest score first) let insertIndex = 0; for (let i = 0; i < currentSuits.length; i++) { if (suit.score > currentSuits[i].score) { insertIndex = i; break; } insertIndex = i + 1; } // If we already have MAX_SUITS and this suit would be at the end (worst), skip it if (currentSuits.length >= MAX_SUITS && insertIndex >= MAX_SUITS) { console.log(`Suit with score ${suit.score} not good enough for top ${MAX_SUITS}, skipping`); return -1; // Signal that suit wasn't inserted } // Insert the suit at the correct position currentSuits.splice(insertIndex, 0, suit); console.log(`Inserted at index ${insertIndex}. New order:`, currentSuits.map(s => s.score)); return insertIndex; } /** * Trim excess suits from both the array and DOM to maintain MAX_SUITS limit * This ensures consistent suit counts between runs */ function trimExcessSuits() { const streamingResults = document.getElementById('streamingResults'); if (!streamingResults) return; // Remove excess suits from array (keep only top MAX_SUITS) while (currentSuits.length > MAX_SUITS) { const removed = currentSuits.pop(); console.log(`Trimmed suit with score ${removed.score} (exceeds MAX_SUITS=${MAX_SUITS})`); } // Remove excess DOM elements (keep only top MAX_SUITS) while (streamingResults.children.length > MAX_SUITS) { const lastChild = streamingResults.lastElementChild; if (lastChild) { console.log(`Removing DOM element for suit at position ${streamingResults.children.length}`); lastChild.remove(); } } // Update medals after trimming updateAllMedals(); } /** * Regenerate the entire results display to maintain proper score ordering */ function regenerateResultsDisplay() { console.log('Regenerating display with suits:', currentSuits.map(s => `Score: ${s.score}, ID: ${s.id}`)); const streamingResults = document.getElementById('streamingResults'); if (!streamingResults) return; // Clear existing results streamingResults.innerHTML = ''; // Re-add all suits in their current (score-ordered) positions currentSuits.forEach((suit, index) => { const scoreClass = getScoreClass(suit.score); const medal = index === 0 ? 'đŸĨ‡' : index === 1 ? 'đŸĨˆ' : index === 2 ? 'đŸĨ‰' : '🔸'; const suitHtml = `
${medal} Suit #${suit.id} (Score: ${suit.score})
${formatSuitStats(suit)}
${formatSuitItems(suit.items)} ${suit.missing && suit.missing.length > 0 ? `
Missing: ${suit.missing.join(', ')}
` : ''} ${suit.notes && suit.notes.length > 0 ? `
${suit.notes.join(' â€ĸ ')}
` : ''}
`; streamingResults.insertAdjacentHTML('beforeend', suitHtml); }); // Re-add click handlers for all suits document.querySelectorAll('.suit-item').forEach(item => { item.addEventListener('click', function() { const suitId = parseInt(this.dataset.suitId); selectSuit(suitId); }); }); } /** * Insert a suit DOM element at the correct position and update all medal rankings */ function insertSuitDOMAtPosition(suit, insertIndex) { console.log('insertSuitDOMAtPosition called with suit score:', suit.score, 'at index:', insertIndex); const streamingResults = document.getElementById('streamingResults'); if (!streamingResults) { console.error('streamingResults element not found!'); return; } console.log('Current DOM children count:', streamingResults.children.length); // Create the new suit HTML const scoreClass = getScoreClass(suit.score); const suitHtml = `
🔸 Suit #${suit.id} (Score: ${suit.score})
${formatSuitStats(suit)}
${formatSuitItems(suit.items)} ${suit.missing && suit.missing.length > 0 ? `
Missing: ${suit.missing.join(', ')}
` : ''} ${suit.notes && suit.notes.length > 0 ? `
${suit.notes.join(' â€ĸ ')}
` : ''}
`; // Insert at the correct position const existingSuits = streamingResults.children; if (insertIndex >= existingSuits.length) { // Insert at the end streamingResults.insertAdjacentHTML('beforeend', suitHtml); } else { // Insert before the suit at insertIndex existingSuits[insertIndex].insertAdjacentHTML('beforebegin', suitHtml); } // Update all medal rankings after insertion updateAllMedals(); // Add click handler for the new suit const newSuitElement = streamingResults.children[insertIndex]; newSuitElement.addEventListener('click', function() { const suitId = parseInt(this.dataset.suitId); selectSuit(suitId); }); } /** * Update medal rankings for all displayed suits */ function updateAllMedals() { const streamingResults = document.getElementById('streamingResults'); if (!streamingResults) return; Array.from(streamingResults.children).forEach((suitElement, index) => { const medal = index === 0 ? 'đŸĨ‡' : index === 1 ? 'đŸĨˆ' : index === 2 ? 'đŸĨ‰' : '🔸'; const scoreElement = suitElement.querySelector('.suit-score'); if (scoreElement) { const scoreText = scoreElement.textContent; // Replace the existing medal with the new one scoreElement.textContent = scoreText.replace(/^[đŸĨ‡đŸĨˆđŸĨ‰đŸ”¸]\s*/, medal + ' '); } }); } /** * Generate suit combinations from available items * This is a simplified algorithm - the full constraint solver will be more sophisticated */ function generateSuitCombinations(itemsBySlot, constraints) { const suits = []; // For now, create a few example suits based on available items // This will be replaced with the full constraint solver algorithm // Try to build suits with the best items from each slot const armorSlots = ['Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands', 'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet']; const jewelrySlots = ['Neck', 'Left Ring', 'Right Ring', 'Left Wrist', 'Right Wrist', 'Trinket']; const clothingSlots = ['Shirt', 'Pants']; // Generate a few sample combinations for (let i = 0; i < Math.min(5, 20); i++) { const suit = { id: i + 1, score: Math.floor(Math.random() * 40) + 60, // Random score 60-100% items: {}, missing: [], alternatives: [] }; // Try to fill each slot with available items [...armorSlots, ...jewelrySlots, ...clothingSlots].forEach(slot => { const availableItems = itemsBySlot[slot]; if (availableItems && availableItems.length > 0) { // Pick the best item for this slot (simplified) const bestItem = availableItems[Math.floor(Math.random() * Math.min(3, availableItems.length))]; suit.items[slot] = bestItem; } }); // Calculate missing pieces based on set requirements if (constraints.primary_set || constraints.secondary_set) { suit.missing = calculateMissingPieces(suit.items, constraints); } // Only include suits that have at least some items if (Object.keys(suit.items).length > 0) { suits.push(suit); } } // Sort by score (best first) suits.sort((a, b) => b.score - a.score); return suits.slice(0, 10); // Return top 10 suits } /** * Calculate missing pieces for set requirements */ function calculateMissingPieces(suitItems, constraints) { const missing = []; if (constraints.primary_set) { const primaryItems = Object.values(suitItems).filter(item => item.set_name && item.set_name.includes(getSetNameById(constraints.primary_set)) ); if (primaryItems.length < 5) { missing.push(`${5 - primaryItems.length} more ${getSetNameById(constraints.primary_set)} pieces`); } } if (constraints.secondary_set) { const secondaryItems = Object.values(suitItems).filter(item => item.set_name && item.set_name.includes(getSetNameById(constraints.secondary_set)) ); if (secondaryItems.length < 4) { missing.push(`${4 - secondaryItems.length} more ${getSetNameById(constraints.secondary_set)} pieces`); } } return missing; } /** * Get set name by ID */ function getSetNameById(setId) { const setNames = { '13': "Soldier's", '14': "Adept's", '15': "Archer's", '16': "Defender's", '19': "Hearty", '20': "Dexterous", '21': "Wise", '22': "Swift", '24': "Reinforced", '26': "Flame Proof", '29': "Lightning Proof", '40': "Heroic Protector", '41': "Heroic Destroyer", '46': "Relic Alduressa", '47': "Ancient Relic", '48': "Noble Relic" }; return setNames[setId] || `Set ${setId}`; } /** * Display suit search results */ function displaySuitResults(suits) { const resultsDiv = document.getElementById('suitResults'); const countSpan = document.getElementById('resultsCount'); if (!suits || suits.length === 0) { resultsDiv.innerHTML = '
No suits found matching your constraints. Try relaxing some requirements.
'; countSpan.textContent = ''; return; } // Sort suits by score (highest first) before displaying const sortedSuits = [...suits].sort((a, b) => b.score - a.score); countSpan.textContent = `Found ${sortedSuits.length} suit${sortedSuits.length !== 1 ? 's' : ''}`; let html = ''; sortedSuits.forEach((suit, index) => { const scoreClass = getScoreClass(suit.score); const medal = index === 0 ? 'đŸĨ‡' : index === 1 ? 'đŸĨˆ' : index === 2 ? 'đŸĨ‰' : '🔸'; html += `
${medal} Suit #${suit.id} (Score: ${suit.score}%)
${formatSuitStats(suit)}
${formatSuitItems(suit.items)} ${suit.missing && suit.missing.length > 0 ? `
Missing: ${suit.missing.join(', ')}
` : ''} ${suit.notes && suit.notes.length > 0 ? `
${suit.notes.join(' â€ĸ ')}
` : ''}
`; }); resultsDiv.innerHTML = html; // Add click handlers for suit selection document.querySelectorAll('.suit-item').forEach(item => { item.addEventListener('click', function() { const suitId = parseInt(this.dataset.suitId); selectSuit(suitId); }); }); } /** * Get CSS class for score */ function getScoreClass(score) { if (score >= 90) return 'excellent'; if (score >= 75) return 'good'; if (score >= 60) return 'fair'; return 'poor'; } /** * Format suit items for display - shows ALL armor slots even if empty */ function formatSuitItems(items) { console.log(`[DEBUG] formatSuitItems called with items:`, items); // Define all expected armor/equipment slots in logical order const allSlots = [ // Armor slots 'Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands', 'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet', // Jewelry slots 'Neck', 'Left Ring', 'Right Ring', 'Left Wrist', 'Right Wrist', 'Trinket', // Clothing slots 'Shirt', 'Pants' ]; console.log(`[DEBUG] allSlots:`, allSlots); // Create table structure with header let html = `
Slot
Character
Item
Set
Spells
Armor
Ratings
`; allSlots.forEach(slot => { const item = items ? items[slot] : null; // DEBUG: Log all slots and items console.log(`[DEBUG] Processing slot '${slot}', item:`, item); if (item) { // Item exists in this slot const character = item.source_character || item.character_name || 'Unknown'; const itemName = item.name || 'Unknown Item'; // Only show set names for armor items (not jewelry or clothing) const armorSlots = ['Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands', 'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet']; const setName = (armorSlots.includes(slot) && item.set_name) ? item.set_name : '-'; const spells = formatItemSpells(item); const armor = formatItemArmor(item); const ratings = formatItemRatingsColumns(item); const needsReducing = isMultiSlotItem(item) ? '*' : ''; html += `
${slot}
${character}
${itemName}${needsReducing}
${setName}
${spells}
${armor}
${ratings}
`; } else { // Empty slot html += `
${slot}
-
-
-
-
-
-
`; } }); html += `
`; return html; } /** * Format item spells for column display (focus on Legendary spells) */ function formatItemSpells(item) { const spellArray = item.spells || item.spell_names || []; if (!Array.isArray(spellArray) || spellArray.length === 0) { return '-'; } // Filter for important spells (Legendary, Epic) const importantSpells = spellArray.filter(spell => spell.includes('Legendary') || spell.includes('Epic') ); if (importantSpells.length === 0) { return `${spellArray.length} spells`; } // Show up to 2 important spells, abbreviate the rest const displaySpells = importantSpells.slice(0, 2); let result = displaySpells.join(', '); if (importantSpells.length > 2) { result += ` +${importantSpells.length - 2} more`; } return result; } /** * Format item armor for column display */ function formatItemArmor(item) { if (item.armor_level && item.armor_level > 0) { return item.armor_level.toString(); } return '-'; } /** * Format item ratings for column display */ function formatItemRatingsColumns(item) { const ratings = []; // Access ratings from the ratings object if available, fallback to direct properties const itemRatings = item.ratings || item; // Helper function to get rating value, treating null/undefined/negative as 0 function getRatingValue(value) { if (value === null || value === undefined || value < 0) return 0; return Math.round(value); // Round to nearest integer } // Determine if this is clothing (shirts/pants) or armor // Check item name patterns since ObjectClass 3 items (clothing) may appear in various slots const itemName = item.name || ''; const isClothing = itemName.toLowerCase().includes('shirt') || itemName.toLowerCase().includes('pants') || itemName.toLowerCase().includes('breeches') || itemName.toLowerCase().includes('baggy') || (item.slot === 'Shirt' || item.slot === 'Pants'); if (isClothing) { // Clothing: Show DR and DRR const damageRating = getRatingValue(itemRatings.damage_rating); const damageResist = getRatingValue(itemRatings.damage_resist_rating); ratings.push(`DR${damageRating}`); ratings.push(`DRR${damageResist}`); } else { // Armor: Show CD and CDR const critDamage = getRatingValue(itemRatings.crit_damage_rating); const critDamageResist = getRatingValue(itemRatings.crit_damage_resist_rating); ratings.push(`CD${critDamage}`); ratings.push(`CDR${critDamageResist}`); } return ratings.join(' '); } /** * Check if item is multi-slot and needs reducing * Only armor items need reduction - jewelry can naturally go in multiple slots */ function isMultiSlotItem(item) { if (!item.slot_name) return false; const slots = item.slot_name.split(',').map(s => s.trim()); if (slots.length <= 1) return false; // Jewelry items that can go in multiple equivalent slots (normal behavior, no reduction needed) const jewelryPatterns = [ ['Left Ring', 'Right Ring'], ['Left Wrist', 'Right Wrist'] ]; // Check if this matches any jewelry pattern for (const pattern of jewelryPatterns) { if (pattern.length === slots.length && pattern.every(slot => slots.includes(slot))) { return false; // This is jewelry, no reduction needed } } // If it has multiple slots and isn't jewelry, it's armor that needs reduction return true; } /** * Format suit statistics for display */ function formatSuitStats(suit) { if (!suit) return ''; const statParts = []; // Show set names with counts if (suit.primary_set && suit.primary_set_count > 0) { statParts.push(`${suit.primary_set}: ${suit.primary_set_count}/5`); } if (suit.secondary_set && suit.secondary_set_count > 0) { statParts.push(`${suit.secondary_set}: ${suit.secondary_set_count}/4`); } // Show total armor if (suit.total_armor > 0) { statParts.push(`Armor: ${suit.total_armor}`); } // Show spell coverage if (suit.spell_coverage > 0) { statParts.push(`Spells: ${suit.spell_coverage}`); } return statParts.length > 0 ? `
${statParts.join(' â€ĸ ')}
` : ''; } /** * Format item properties for display */ function formatItemProperties(item) { const properties = []; // Handle set name (backend sends item_set_name) if (item.item_set_name) { properties.push(item.item_set_name); } else if (item.set_name) { properties.push(item.set_name); } // Handle spells (backend sends spells array) const spellArray = item.spells || item.spell_names; if (spellArray && Array.isArray(spellArray)) { spellArray.forEach(spell => { if (spell.includes('Legendary')) { properties.push(spell); } }); } if (item.crit_damage_rating > 0) { properties.push(`Crit Dmg +${item.crit_damage_rating}`); } if (item.damage_rating > 0) { properties.push(`Dmg Rating +${item.damage_rating}`); } if (item.heal_boost > 0) { properties.push(`Heal Boost +${item.heal_boost}`); } return properties.join(', '); } /** * Format item ratings for display (separate from properties) */ function formatItemRatings(item) { const ratings = []; // Armor level if (item.armor_level && item.armor_level > 0) { ratings.push(`AL ${item.armor_level}`); } // Damage ratings if (item.crit_damage_rating && item.crit_damage_rating > 0) { ratings.push(`CD +${item.crit_damage_rating}`); } if (item.damage_rating && item.damage_rating > 0) { ratings.push(`DR +${item.damage_rating}`); } // Resist ratings if (item.crit_damage_resist_rating && item.crit_damage_resist_rating > 0) { ratings.push(`CDR +${item.crit_damage_resist_rating}`); } if (item.damage_resist_rating && item.damage_resist_rating > 0) { ratings.push(`DRR +${item.damage_resist_rating}`); } // Other ratings if (item.heal_boost_rating && item.heal_boost_rating > 0) { ratings.push(`HB +${item.heal_boost_rating}`); } if (item.vitality_rating && item.vitality_rating > 0) { ratings.push(`VIT +${item.vitality_rating}`); } return ratings.join(', '); } /** * Select a suit and populate the visual slots */ function selectSuit(suitId) { const suit = currentSuits.find(s => s.id === suitId); if (!suit) return; // Update visual selection document.querySelectorAll('.suit-item').forEach(item => { item.classList.remove('selected'); }); document.querySelector(`[data-suit-id="${suitId}"]`).classList.add('selected'); // Populate visual slots populateVisualSlots(suit.items); selectedSuit = suit; } /** * Populate the visual equipment slots with suit items */ function populateVisualSlots(items) { // Clear non-locked slots first document.querySelectorAll('.slot-content').forEach(slot => { const slotName = slot.id.replace('slot_', '').replace(/_/g, ' '); // Skip locked slots - preserve their summary if (!lockedSlots.has(slotName)) { slot.innerHTML = 'Empty'; slot.parentElement.classList.remove('populated'); } }); // Populate non-locked slots with items Object.entries(items).forEach(([slotName, item]) => { // Skip locked slots - they keep their configured info if (lockedSlots.has(slotName)) return; const slotId = `slot_${slotName.replace(/ /g, '_')}`; const slotElement = document.getElementById(slotId); if (slotElement) { const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; slotElement.innerHTML = `
${item.name}
${item.source_character || item.character_name || 'Unknown'}
${formatItemProperties(item)}
${needsReducing} `; slotElement.parentElement.classList.add('populated'); } }); // Update suit summary updateSuitSummary(); } /** * Clear a single slot */ function clearSlot(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); slotContent.innerHTML = 'Empty'; slotElement.classList.remove('populated'); // Also remove from selectedSuit if present if (selectedSuit && selectedSuit.items[slotName]) { delete selectedSuit.items[slotName]; } updateSuitSummary(); } /** * Update the suit summary display */ function updateSuitSummary() { const summaryDiv = document.getElementById('suitSummary'); if (!summaryDiv) return; if (!selectedSuit || Object.keys(selectedSuit.items).length === 0) { summaryDiv.innerHTML = '
Select a suit to see summary
'; return; } const lines = Object.entries(selectedSuit.items).map(([slot, item]) => { const spells = (item.spell_names || []).map(s => s.replace('Legendary ', 'L.')).join(', ') || 'No spells'; const char = item.source_character || item.character_name || 'Unknown'; return `
${slot}: ${item.name} [${spells}] - ${char}
`; }).join(''); summaryDiv.innerHTML = lines; } /** * Copy suit summary to clipboard */ function copySuitSummary() { if (!selectedSuit) return; const text = Object.entries(selectedSuit.items).map(([slot, item]) => { const spells = (item.spell_names || []).join(', ') || 'No spells'; const char = item.source_character || item.character_name || 'Unknown'; return `${slot}: ${item.name} [${spells}] - ${char}`; }).join('\n'); navigator.clipboard.writeText(text).then(() => { // Brief visual feedback const btn = document.querySelector('.copy-summary-btn'); if (btn) { const original = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = original, 1500); } }); } /** * Toggle lock state of a slot */ function toggleSlotLock(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const lockBtn = slotElement.querySelector('.lock-btn'); if (lockedSlots.has(slotName)) { // Unlocking - remove from map and clear visual unlockSlot(slotName); } else { // Locking - check if slot is populated const isPopulated = slotElement.classList.contains('populated'); if (isPopulated && selectedSuit && selectedSuit.items[slotName]) { // Auto-extract set/spells from the populated item const item = selectedSuit.items[slotName]; const lockInfo = { set: item.set_name || null, setId: item.set_id || null, spells: item.spell_names || item.spells || [] }; lockedSlots.set(slotName, lockInfo); slotElement.classList.add('locked'); lockBtn.classList.add('locked'); renderLockedSlotSummary(slotName, lockInfo); } else { // Empty slot - show lock form for manual entry showLockSlotForm(slotName); } } } /** * Unlock a slot and restore its content */ function unlockSlot(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const lockBtn = slotElement.querySelector('.lock-btn'); const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); lockedSlots.delete(slotName); slotElement.classList.remove('locked'); lockBtn.classList.remove('locked'); // Restore slot content (either from selected suit or empty) if (selectedSuit && selectedSuit.items[slotName]) { const item = selectedSuit.items[slotName]; const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; slotContent.innerHTML = `
${item.name}
${item.source_character || item.character_name || 'Unknown'}
${formatItemProperties(item)}
${needsReducing} `; } else { slotContent.innerHTML = 'Empty'; slotElement.classList.remove('populated'); } } /** * Show lock form for manual slot configuration */ function showLockSlotForm(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); const isArmorSlot = ARMOR_SLOTS.includes(slotName); // Build spell checkboxes const spellCheckboxes = [...COMMON_CANTRIPS, ...COMMON_WARDS].map(spell => ` `).join(''); // Build set dropdown (armor only) const setDropdown = isArmorSlot ? `
` : ''; slotContent.innerHTML = `
Configure Locked Slot
${setDropdown}
${spellCheckboxes}
`; slotElement.classList.add('configuring'); } /** * Get equipment set options for dropdown */ function getEquipmentSetOptions() { // Equipment sets - IDs must match getSetNameById() and backend const commonSets = [ { id: 14, name: "Adept's" }, { id: 13, name: "Soldier's" }, { id: 15, name: "Archer's" }, { id: 16, name: "Defender's" }, { id: 19, name: 'Hearty' }, { id: 20, name: 'Dexterous' }, { id: 21, name: 'Wise' }, { id: 22, name: 'Swift' }, { id: 24, name: 'Reinforced' }, { id: 26, name: 'Flame Proof' }, { id: 29, name: 'Lightning Proof' }, { id: 40, name: 'Heroic Protector' }, { id: 41, name: 'Heroic Destroyer' }, { id: 46, name: 'Relic Alduressa' }, { id: 47, name: 'Ancient Relic' }, { id: 48, name: 'Noble Relic' } ]; return commonSets.map(set => `` ).join(''); } /** * Save lock slot form and apply lock */ function saveLockSlotForm(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const lockBtn = slotElement.querySelector('.lock-btn'); const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); const isArmorSlot = ARMOR_SLOTS.includes(slotName); // Get selected set (armor only) let setId = null; let setName = null; if (isArmorSlot) { const setSelect = document.getElementById(`lockSetSelect_${slotName.replace(/ /g, '_')}`); if (setSelect && setSelect.value) { setId = parseInt(setSelect.value); setName = setSelect.options[setSelect.selectedIndex].text; } } // Get selected spells const selectedSpells = []; slotContent.querySelectorAll('.lock-spell-checkbox input:checked').forEach(cb => { selectedSpells.push(cb.value); }); // Create lock info const lockInfo = { set: setName, setId: setId, spells: selectedSpells }; // Store and render lockedSlots.set(slotName, lockInfo); slotElement.classList.remove('configuring'); slotElement.classList.add('locked'); lockBtn.classList.add('locked'); renderLockedSlotSummary(slotName, lockInfo); } /** * Cancel lock slot form */ function cancelLockSlotForm(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); slotElement.classList.remove('configuring'); // Restore original content if (selectedSuit && selectedSuit.items[slotName]) { const item = selectedSuit.items[slotName]; const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; slotContent.innerHTML = `
${item.name}
${item.source_character || item.character_name || 'Unknown'}
${formatItemProperties(item)}
${needsReducing} `; } else { slotContent.innerHTML = 'Empty'; } } /** * Render locked slot summary display */ function renderLockedSlotSummary(slotName, lockInfo) { const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); const setBadge = lockInfo.set ? `
${lockInfo.set}
` : ''; const spellPills = lockInfo.spells.length > 0 ? `
${lockInfo.spells.map(s => `${s.replace('Legendary ', 'L. ')}`).join('')}
` : '
No spells specified
'; slotContent.innerHTML = `
🔒 Locked
${setBadge} ${spellPills}
`; } /** * Handle slot click events */ function handleSlotClick(slotName) { // Could open item selection dialog in the future console.log(`Clicked slot: ${slotName}`); } /** * Lock currently selected/populated slots (auto-extract from items) */ function lockSelectedSlots() { document.querySelectorAll('.slot-item.populated').forEach(slot => { const slotName = slot.dataset.slot; if (!lockedSlots.has(slotName)) { toggleSlotLock(slotName); } }); } /** * Clear all slot locks */ function clearAllLocks() { // Get all locked slot names before clearing const lockedSlotNames = Array.from(lockedSlots.keys()); lockedSlots.clear(); // Restore each slot lockedSlotNames.forEach(slotName => { unlockSlot(slotName); }); // Also clear any remaining visual lock states document.querySelectorAll('.slot-item').forEach(slot => { slot.classList.remove('locked'); slot.classList.remove('configuring'); const lockBtn = slot.querySelector('.lock-btn'); if (lockBtn) lockBtn.classList.remove('locked'); }); } /** * Reset the slot view */ function resetSlotView() { clearAllLocks(); document.querySelectorAll('.slot-content').forEach(slot => { const slotName = slot.id.replace('slot_', '').replace('_', ' '); slot.innerHTML = 'Empty'; slot.parentElement.classList.remove('populated'); }); selectedSuit = null; // Clear suit selection document.querySelectorAll('.suit-item').forEach(item => { item.classList.remove('selected'); }); } /** * Clear all constraints and reset form */ function clearAllConstraints() { // Clear all input fields document.querySelectorAll('input[type="number"]').forEach(input => { input.value = ''; }); // Reset checkboxes document.querySelectorAll('input[type="checkbox"]').forEach(cb => { if (cb.id === 'char_all' || cb.id === 'includeEquipped' || cb.id === 'includeInventory') { cb.checked = true; } else { cb.checked = false; } }); // Reset dropdowns document.querySelectorAll('select').forEach(select => { select.selectedIndex = 0; }); // Reset character selection document.querySelectorAll('.character-checkbox:not([value="all"])').forEach(cb => { cb.checked = true; }); // Clear results document.getElementById('suitResults').innerHTML = '
Configure constraints above and click "Search Suits" to find optimal loadouts.
'; document.getElementById('resultsCount').textContent = ''; // Reset slots resetSlotView(); }