diff --git a/static/suitbuilder.js b/static/suitbuilder.js index 2c652c10..f828e093 100644 --- a/static/suitbuilder.js +++ b/static/suitbuilder.js @@ -1,7 +1,7 @@ // Suitbuilder JavaScript - Constraint Solver Frontend Logic // Configuration -const API_BASE = '/inv'; +const API_BASE = '/inv/suitbuilder'; let currentSuits = []; let lockedSlots = new Set(); let selectedSuit = null; @@ -25,7 +25,7 @@ function initializeSuitbuilder() { */ async function loadCharacters() { try { - const response = await fetch(`${API_BASE}/characters/list`); + const response = await fetch(`${API_BASE}/characters`); if (!response.ok) { throw new Error('Failed to load characters'); } @@ -52,11 +52,13 @@ function displayCharacters(characters) { 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 += `
- - + +
`; }); @@ -234,66 +236,181 @@ function validateConstraints(constraints) { * 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(); + // 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_items: {}, // TODO: implement locked items + 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 + }; - // Character selection - if (constraints.characters.length > 0) { - params.append('characters', constraints.characters.join(',')); - } else { - params.append('include_all_characters', 'true'); + console.log('Starting suit search with constraints:', requestBody); + + // 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) + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.statusText}`); } - // 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); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); return new Promise((resolve, reject) => { - const eventSource = new EventSource(streamUrl); 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 === '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) { + reject(error); + } + } + + readStream(); + + // Event handlers + function handleSuitEvent(data) { + try { + // Transform backend suit format to frontend format + const transformedSuit = transformSuitData(data); + + // Insert suit in score-ordered position (highest score first) + insertSuitInScoreOrder(transformedSuit); + + // Regenerate entire results display to maintain proper ordering + regenerateResultsDisplay(); + + // 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); + } + } + + function handleProgressEvent(data) { + try { + document.getElementById('foundCount').textContent = data.found || currentSuits.length; + document.getElementById('checkedCount').textContent = data.evaluated || 0; + document.getElementById('elapsedTime').textContent = data.elapsed || '0.0'; + } catch (error) { + console.error('Error processing progress data:', error); + } + } + + function handleCompleteEvent(data) { + try { + // Hide loading indicator + const loadingDiv = document.querySelector('.loading'); + if (loadingDiv) { + loadingDiv.innerHTML = `✅ Search complete! Found ${data.suits_found} suits in ${data.duration}s.`; + } + + // 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; - eventSource.close(); // Update UI to show search was stopped const loadingDiv = document.querySelector('.loading'); @@ -306,118 +423,7 @@ async function streamOptimalSuits(constraints) { 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')); - } - }; }); } @@ -458,40 +464,65 @@ function transformSuitData(suit) { } /** - * Add a single suit to the streaming results display + * Insert a suit into the currentSuits array in score-ordered position (highest first) */ -function addSuitToResults(suit, index) { +function insertSuitInScoreOrder(suit) { + // 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; + } + + // Insert the suit at the correct position + currentSuits.splice(insertIndex, 0, suit); +} + +/** + * Regenerate the entire results display to maintain proper score ordering + */ +function regenerateResultsDisplay() { const streamingResults = document.getElementById('streamingResults'); if (!streamingResults) return; - const scoreClass = getScoreClass(suit.score); - const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸'; + // Clear existing results + streamingResults.innerHTML = ''; - const suitHtml = ` -
-
-
- ${medal} Suit #${suit.id} (Score: ${suit.score}) + // 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(' • ')}
` : ''}
-
- ${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); + }); - 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); + // 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); + }); }); } @@ -612,10 +643,13 @@ function displaySuitResults(suits) { return; } - countSpan.textContent = `Found ${suits.length} suit${suits.length !== 1 ? 's' : ''}`; + // 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 = ''; - suits.forEach((suit, index) => { + sortedSuits.forEach((suit, index) => { const scoreClass = getScoreClass(suit.score); const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸'; @@ -660,28 +694,50 @@ function getScoreClass(score) { } /** - * Format suit items for display + * Format suit items for display - shows ALL armor slots even if empty */ function formatSuitItems(items) { let html = ''; - if (!items || Object.keys(items).length === 0) { - return '
No items in this suit
'; - } + // 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' + ]; - Object.entries(items).forEach(([slot, item]) => { - const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; - const properties = formatItemProperties(item); + allSlots.forEach(slot => { + const item = items ? items[slot] : null; - html += ` -
- ${slot}: - ${item.character_name} - - ${item.name} - ${properties ? `(${properties})` : ''} - ${needsReducing} -
- `; + if (item) { + // Item exists in this slot + const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; + const properties = formatItemProperties(item); + const ratings = formatItemRatings(item); + + html += ` +
+ ${slot}: + ${item.source_character || item.character_name || 'Unknown'} - + ${item.name} + ${properties ? `(${properties})` : ''} + ${ratings ? `[${ratings}]` : ''} + ${needsReducing} +
+ `; + } else { + // Empty slot + html += ` +
+ ${slot}: + - Empty - +
+ `; + } }); return html; @@ -781,6 +837,47 @@ function formatItemProperties(item) { 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 */