Fix score-based ordering in suitbuilder frontend

Updated JavaScript to maintain score ordering during streaming search:
- Replace addSuitToResults() with insertSuitInScoreOrder()
- Add regenerateResultsDisplay() to maintain proper DOM ordering
- Medal assignment (🥇🥈🥉) now based on score ranking, not arrival order
- Suits with highest scores now always appear at top during live search
- Updated displaySuitResults() to sort by score before displaying

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
erik 2025-07-03 20:02:01 +00:00
parent 4d19e29847
commit e7ca39318f

View file

@ -1,7 +1,7 @@
// Suitbuilder JavaScript - Constraint Solver Frontend Logic // Suitbuilder JavaScript - Constraint Solver Frontend Logic
// Configuration // Configuration
const API_BASE = '/inv'; const API_BASE = '/inv/suitbuilder';
let currentSuits = []; let currentSuits = [];
let lockedSlots = new Set(); let lockedSlots = new Set();
let selectedSuit = null; let selectedSuit = null;
@ -25,7 +25,7 @@ function initializeSuitbuilder() {
*/ */
async function loadCharacters() { async function loadCharacters() {
try { try {
const response = await fetch(`${API_BASE}/characters/list`); const response = await fetch(`${API_BASE}/characters`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to load characters'); throw new Error('Failed to load characters');
} }
@ -52,11 +52,13 @@ function displayCharacters(characters) {
let html = ''; let html = '';
characters.forEach(character => { characters.forEach(character => {
// Sanitize character name for HTML ID (replace special chars with underscores)
const safeId = character.replace(/[^a-zA-Z0-9]/g, '_');
html += ` html += `
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="char_${character.character_name}" <input type="checkbox" id="char_${safeId}"
class="character-checkbox" value="${character.character_name}" checked> class="character-checkbox" value="${character}" checked>
<label for="char_${character.character_name}">${character.character_name}</label> <label for="char_${safeId}">${character}</label>
</div> </div>
`; `;
}); });
@ -234,66 +236,181 @@ function validateConstraints(constraints) {
* Stream optimal suits using Server-Sent Events with progressive results * Stream optimal suits using Server-Sent Events with progressive results
*/ */
async function streamOptimalSuits(constraints) { async function streamOptimalSuits(constraints) {
// Build request parameters for the streaming constraint solver // Prepare constraint data for POST request
const params = new URLSearchParams(); 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 console.log('Starting suit search with constraints:', requestBody);
if (constraints.characters.length > 0) {
params.append('characters', constraints.characters.join(',')); // Use fetch with streaming response instead of EventSource for POST support
} else { const response = await fetch(`${API_BASE}/search`, {
params.append('include_all_characters', 'true'); method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
} }
// Equipment sets const reader = response.body.getReader();
if (constraints.primary_set) { const decoder = new TextDecoder();
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) => { return new Promise((resolve, reject) => {
const eventSource = new EventSource(streamUrl);
let searchStopped = false; 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 // Add stop search functionality
const stopButton = document.getElementById('stopSearch'); const stopButton = document.getElementById('stopSearch');
stopButton.addEventListener('click', () => { stopButton.addEventListener('click', () => {
searchStopped = true; searchStopped = true;
eventSource.close();
// Update UI to show search was stopped // Update UI to show search was stopped
const loadingDiv = document.querySelector('.loading'); const loadingDiv = document.querySelector('.loading');
@ -306,118 +423,7 @@ async function streamOptimalSuits(constraints) {
if (countSpan) { if (countSpan) {
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''} (search stopped)`; 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,12 +464,35 @@ 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'); const streamingResults = document.getElementById('streamingResults');
if (!streamingResults) return; 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 scoreClass = getScoreClass(suit.score);
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸'; const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸';
@ -486,13 +515,15 @@ function addSuitToResults(suit, index) {
`; `;
streamingResults.insertAdjacentHTML('beforeend', suitHtml); streamingResults.insertAdjacentHTML('beforeend', suitHtml);
});
// Add click handler for the new suit // Re-add click handlers for all suits
const newSuitElement = streamingResults.lastElementChild; document.querySelectorAll('.suit-item').forEach(item => {
newSuitElement.addEventListener('click', function() { item.addEventListener('click', function() {
const suitId = parseInt(this.dataset.suitId); const suitId = parseInt(this.dataset.suitId);
selectSuit(suitId); selectSuit(suitId);
}); });
});
} }
/** /**
@ -612,10 +643,13 @@ function displaySuitResults(suits) {
return; 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 = ''; let html = '';
suits.forEach((suit, index) => { sortedSuits.forEach((suit, index) => {
const scoreClass = getScoreClass(suit.score); const scoreClass = getScoreClass(suit.score);
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸'; 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) { function formatSuitItems(items) {
let html = ''; let html = '';
if (!items || Object.keys(items).length === 0) { // Define all expected armor/equipment slots in logical order
return '<div class="no-items">No items in this suit</div>'; 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]) => { allSlots.forEach(slot => {
const item = items ? items[slot] : null;
if (item) {
// Item exists in this slot
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : ''; const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : '';
const properties = formatItemProperties(item); const properties = formatItemProperties(item);
const ratings = formatItemRatings(item);
html += ` html += `
<div class="suit-item-entry"> <div class="suit-item-entry">
<strong>${slot}:</strong> <strong>${slot}:</strong>
<span class="item-character">${item.character_name}</span> - <span class="item-character">${item.source_character || item.character_name || 'Unknown'}</span> -
<span class="item-name">${item.name}</span> <span class="item-name">${item.name}</span>
${properties ? `<span class="item-properties">(${properties})</span>` : ''} ${properties ? `<span class="item-properties">(${properties})</span>` : ''}
${ratings ? `<span class="item-ratings">[${ratings}]</span>` : ''}
${needsReducing} ${needsReducing}
</div> </div>
`; `;
} else {
// Empty slot
html += `
<div class="suit-item-entry empty-slot">
<strong>${slot}:</strong>
<span class="empty-slot-text">- Empty -</span>
</div>
`;
}
}); });
return html; return html;
@ -781,6 +837,47 @@ function formatItemProperties(item) {
return properties.join(', '); 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 * Select a suit and populate the visual slots
*/ */