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 = `
-
-