// Suitbuilder JavaScript - Constraint Solver Frontend Logic // Configuration const API_BASE = '/inv'; let currentSuits = []; let lockedSlots = new Set(); let selectedSuit = null; // 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/list`); 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 => { 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 */ async function performSuitSearch() { const constraints = gatherConstraints(); if (!validateConstraints(constraints)) { return; } const resultsDiv = document.getElementById('suitResults'); const countSpan = document.getElementById('resultsCount'); // Reset current suits and UI currentSuits = []; resultsDiv.innerHTML = `
🔍 Searching for optimal suits...
Found: 0 suits | Checked: 0 combinations | Time: 0.0s
`; countSpan.textContent = ''; try { await streamOptimalSuits(constraints); } catch (error) { console.error('Suit search error:', error); resultsDiv.innerHTML = `
❌ Suit search failed: ${error.message}
`; countSpan.textContent = ''; } } /** * 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 locked_slots: Array.from(lockedSlots) }; 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) { // Build request parameters for the streaming constraint solver const params = new URLSearchParams(); // Character selection if (constraints.characters.length > 0) { params.append('characters', constraints.characters.join(',')); } else { params.append('include_all_characters', 'true'); } // Equipment sets if (constraints.primary_set) { params.append('primary_set', constraints.primary_set); } if (constraints.secondary_set) { params.append('secondary_set', constraints.secondary_set); } // Legendary cantrips if (constraints.legendary_cantrips.length > 0) { params.append('legendary_cantrips', constraints.legendary_cantrips.join(',')); } // Legendary wards if (constraints.protection_spells.length > 0) { params.append('legendary_wards', constraints.protection_spells.join(',')); } // Rating constraints if (constraints.min_armor) params.append('min_armor', constraints.min_armor); if (constraints.max_armor) params.append('max_armor', constraints.max_armor); if (constraints.min_crit_damage) params.append('min_crit_damage', constraints.min_crit_damage); if (constraints.max_crit_damage) params.append('max_crit_damage', constraints.max_crit_damage); if (constraints.min_damage_rating) params.append('min_damage_rating', constraints.min_damage_rating); if (constraints.max_damage_rating) params.append('max_damage_rating', constraints.max_damage_rating); // Equipment status params.append('include_equipped', constraints.include_equipped.toString()); params.append('include_inventory', constraints.include_inventory.toString()); // Locked slots if (lockedSlots.size > 0) { params.append('locked_slots', Array.from(lockedSlots).join(',')); } // Search depth (default to balanced) params.append('search_depth', 'balanced'); const streamUrl = `${API_BASE}/optimize/suits/stream?${params.toString()}`; console.log('Streaming suits with URL:', streamUrl); return new Promise((resolve, reject) => { const eventSource = new EventSource(streamUrl); let searchStopped = false; // Add stop search functionality const stopButton = document.getElementById('stopSearch'); stopButton.addEventListener('click', () => { searchStopped = true; eventSource.close(); // Update UI to show search was stopped const loadingDiv = document.querySelector('.loading'); if (loadingDiv) { loadingDiv.innerHTML = 'âšī¸ Search stopped by user.'; } // Update results count const countSpan = document.getElementById('resultsCount'); if (countSpan) { countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''} (search stopped)`; } resolve(); }); // Handle individual suit results eventSource.addEventListener('suit', (event) => { try { const suit = JSON.parse(event.data); // Transform backend suit format to frontend format const transformedSuit = transformSuitData(suit); currentSuits.push(transformedSuit); // Add suit to streaming results addSuitToResults(transformedSuit, currentSuits.length - 1); // 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); } }); // Handle progress updates eventSource.addEventListener('progress', (event) => { try { const progress = JSON.parse(event.data); document.getElementById('foundCount').textContent = progress.found || currentSuits.length; document.getElementById('checkedCount').textContent = progress.checked || 0; document.getElementById('elapsedTime').textContent = progress.elapsed || '0.0'; } catch (error) { console.error('Error processing progress data:', error); } }); // Handle search completion eventSource.addEventListener('complete', (event) => { try { const completion = JSON.parse(event.data); // Hide loading indicator const loadingDiv = document.querySelector('.loading'); if (loadingDiv) { loadingDiv.innerHTML = `✅ Search complete! Found ${completion.total_found} suits in ${completion.total_time}s.`; } // Update final results count const countSpan = document.getElementById('resultsCount'); if (countSpan) { countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`; } eventSource.close(); resolve(); } catch (error) { console.error('Error processing completion data:', error); eventSource.close(); resolve(); } }); // Handle timeout eventSource.addEventListener('timeout', (event) => { try { const timeout = JSON.parse(event.data); // Update UI to show timeout const loadingDiv = document.querySelector('.loading'); if (loadingDiv) { loadingDiv.innerHTML = `⏰ ${timeout.message}`; } eventSource.close(); resolve(); } catch (error) { console.error('Error processing timeout data:', error); eventSource.close(); resolve(); } }); // Handle errors eventSource.addEventListener('error', (event) => { try { const errorData = JSON.parse(event.data); console.error('Stream error:', errorData.message); const loadingDiv = document.querySelector('.loading'); if (loadingDiv) { loadingDiv.innerHTML = `❌ Search error: ${errorData.message}`; } } catch (error) { console.error('Error parsing error data:', error); } eventSource.close(); reject(new Error('Stream error occurred')); }); // Handle connection errors eventSource.onerror = (event) => { if (!searchStopped) { console.error('EventSource error:', event); eventSource.close(); reject(new Error('Connection error during streaming')); } }; }); } /** * 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 }; } /** * Add a single suit to the streaming results display */ function addSuitToResults(suit, index) { const streamingResults = document.getElementById('streamingResults'); if (!streamingResults) return; 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); // Add click handler for the new suit const newSuitElement = streamingResults.lastElementChild; newSuitElement.addEventListener('click', function() { const suitId = parseInt(this.dataset.suitId); selectSuit(suitId); }); } /** * 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; } countSpan.textContent = `Found ${suits.length} suit${suits.length !== 1 ? 's' : ''}`; let html = ''; suits.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 */ function formatSuitItems(items) { let html = ''; if (!items || Object.keys(items).length === 0) { return '
No items in this suit
'; } Object.entries(items).forEach(([slot, item]) => { const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; const properties = formatItemProperties(item); html += `
${slot}: ${item.character_name} - ${item.name} ${properties ? `(${properties})` : ''} ${needsReducing}
`; }); return html; } /** * 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(', '); } /** * 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 all slots first document.querySelectorAll('.slot-content').forEach(slot => { const slotName = slot.id.replace('slot_', '').replace('_', ' '); slot.innerHTML = 'Empty'; slot.parentElement.classList.remove('populated'); }); // Populate with items Object.entries(items).forEach(([slotName, item]) => { const slotId = `slot_${slotName.replace(' ', '_')}`; const slotElement = document.getElementById(slotId); if (slotElement) { const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; slotElement.innerHTML = `
${item.name}
${item.character_name}
${formatItemProperties(item)}
${needsReducing} `; slotElement.parentElement.classList.add('populated'); } }); } /** * 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)) { lockedSlots.delete(slotName); slotElement.classList.remove('locked'); lockBtn.classList.remove('locked'); } else { lockedSlots.add(slotName); slotElement.classList.add('locked'); lockBtn.classList.add('locked'); } } /** * Handle slot click events */ function handleSlotClick(slotName) { // For now, just toggle lock state // Later this could open item selection dialog console.log(`Clicked slot: ${slotName}`); } /** * Lock currently selected slots */ 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() { lockedSlots.clear(); document.querySelectorAll('.slot-item').forEach(slot => { slot.classList.remove('locked'); slot.querySelector('.lock-btn').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(); }