- Expand locked item spell selection to include weapon skills, magic skills, and defenses - Preserve locked slot configuration when selecting different suits - Add clear button (x) to individual equipment slots for granular control - Add suit summary section below equipment slots with copy-to-clipboard functionality Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1886 lines
No EOL
67 KiB
JavaScript
1886 lines
No EOL
67 KiB
JavaScript
// 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 =
|
|
'<div class="error">Failed to load characters</div>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display characters in the selection panel
|
|
*/
|
|
function displayCharacters(characters) {
|
|
const characterList = document.getElementById('characterList');
|
|
|
|
if (!characters || characters.length === 0) {
|
|
characterList.innerHTML = '<div class="no-results">No characters found</div>';
|
|
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 += `
|
|
<div class="checkbox-item">
|
|
<input type="checkbox" id="char_${safeId}"
|
|
class="character-checkbox" value="${character}" checked>
|
|
<label for="char_${safeId}">${character}</label>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
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 = `
|
|
<div class="search-status-container">
|
|
<!-- Phase indicator with animated icon -->
|
|
<div class="phase-indicator">
|
|
<div class="phase-icon" id="phaseIcon">⏳</div>
|
|
<div class="phase-text" id="searchPhase">Initializing...</div>
|
|
</div>
|
|
|
|
<!-- Progress bars -->
|
|
<div class="progress-bars">
|
|
<div class="progress-bar-container">
|
|
<label>Initialization</label>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill phase-progress" id="phaseProgress" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="progress-bar-container" id="bucketProgressContainer" style="display: none;">
|
|
<label>Search Progress</label>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill bucket-progress" id="bucketProgress" style="width: 0%"></div>
|
|
</div>
|
|
<span class="progress-label" id="currentBucket"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats grid -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Time</div>
|
|
<div class="stat-value" id="elapsedTime">0.0</div>
|
|
<div class="stat-unit">seconds</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Evaluated</div>
|
|
<div class="stat-value" id="checkedCount">0</div>
|
|
<div class="stat-unit">combinations</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Rate</div>
|
|
<div class="stat-value" id="searchRate">-</div>
|
|
<div class="stat-unit">per second</div>
|
|
</div>
|
|
<div class="stat-card highlight">
|
|
<div class="stat-label">Found</div>
|
|
<div class="stat-value" id="foundCount">0</div>
|
|
<div class="stat-unit">suits</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Verbose log area -->
|
|
<details class="verbose-log-section" open>
|
|
<summary>Verbose Output <span class="log-count" id="logCount">0</span></summary>
|
|
<div class="verbose-log" id="verboseLog"></div>
|
|
</details>
|
|
|
|
<!-- Stop button -->
|
|
<button id="stopSearch" class="stop-search-btn">
|
|
<span class="stop-icon">⏹</span> Stop Search
|
|
</button>
|
|
</div>
|
|
<div id="streamingResults"></div>
|
|
`;
|
|
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 = `<div class="error">❌ Suit search failed: ${error.message}</div>`;
|
|
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 = `<span class="log-time">[${elapsed.toFixed(1)}s]</span> <span class="log-message">${message}</span>`;
|
|
|
|
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 = `
|
|
<div class="suit-item" data-suit-id="${suit.id}">
|
|
<div class="suit-header">
|
|
<div class="suit-score ${scoreClass}">
|
|
${medal} Suit #${suit.id} (Score: ${suit.score})
|
|
</div>
|
|
</div>
|
|
<div class="suit-stats">
|
|
${formatSuitStats(suit)}
|
|
</div>
|
|
<div class="suit-items">
|
|
${formatSuitItems(suit.items)}
|
|
${suit.missing && suit.missing.length > 0 ? `<div class="missing-items">Missing: ${suit.missing.join(', ')}</div>` : ''}
|
|
${suit.notes && suit.notes.length > 0 ? `<div class="suit-notes">${suit.notes.join(' • ')}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="suit-item" data-suit-id="${suit.id}">
|
|
<div class="suit-header">
|
|
<div class="suit-score ${scoreClass}">
|
|
🔸 Suit #${suit.id} (Score: ${suit.score})
|
|
</div>
|
|
</div>
|
|
<div class="suit-stats">
|
|
${formatSuitStats(suit)}
|
|
</div>
|
|
<div class="suit-items">
|
|
${formatSuitItems(suit.items)}
|
|
${suit.missing && suit.missing.length > 0 ? `<div class="missing-items">Missing: ${suit.missing.join(', ')}</div>` : ''}
|
|
${suit.notes && suit.notes.length > 0 ? `<div class="suit-notes">${suit.notes.join(' • ')}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = '<div class="no-results">No suits found matching your constraints. Try relaxing some requirements.</div>';
|
|
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 += `
|
|
<div class="suit-item" data-suit-id="${suit.id}">
|
|
<div class="suit-header">
|
|
<div class="suit-score ${scoreClass}">
|
|
${medal} Suit #${suit.id} (Score: ${suit.score}%)
|
|
</div>
|
|
</div>
|
|
<div class="suit-stats">
|
|
${formatSuitStats(suit)}
|
|
</div>
|
|
<div class="suit-items">
|
|
${formatSuitItems(suit.items)}
|
|
${suit.missing && suit.missing.length > 0 ? `<div class="missing-items">Missing: ${suit.missing.join(', ')}</div>` : ''}
|
|
${suit.notes && suit.notes.length > 0 ? `<div class="suit-notes">${suit.notes.join(' • ')}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
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 = `
|
|
<div class="suit-items-table">
|
|
<div class="suit-items-header">
|
|
<div class="col-slot">Slot</div>
|
|
<div class="col-character">Character</div>
|
|
<div class="col-item">Item</div>
|
|
<div class="col-set">Set</div>
|
|
<div class="col-spells">Spells</div>
|
|
<div class="col-armor">Armor</div>
|
|
<div class="col-ratings">Ratings</div>
|
|
</div>
|
|
<div class="suit-items-body">
|
|
`;
|
|
|
|
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) ? '<span class="need-reducing">*</span>' : '';
|
|
|
|
html += `
|
|
<div class="suit-item-row">
|
|
<div class="col-slot">${slot}</div>
|
|
<div class="col-character">${character}</div>
|
|
<div class="col-item">${itemName}${needsReducing}</div>
|
|
<div class="col-set">${setName}</div>
|
|
<div class="col-spells">${spells}</div>
|
|
<div class="col-armor">${armor}</div>
|
|
<div class="col-ratings">${ratings}</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Empty slot
|
|
html += `
|
|
<div class="suit-item-row empty-slot">
|
|
<div class="col-slot">${slot}</div>
|
|
<div class="col-character">-</div>
|
|
<div class="col-item">-</div>
|
|
<div class="col-set">-</div>
|
|
<div class="col-spells">-</div>
|
|
<div class="col-armor">-</div>
|
|
<div class="col-ratings">-</div>
|
|
</div>
|
|
`;
|
|
}
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 ? `<div class="suit-stats-line">${statParts.join(' • ')}</div>` : '';
|
|
}
|
|
|
|
/**
|
|
* 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 = '<span class="empty-slot">Empty</span>';
|
|
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) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
|
|
|
slotElement.innerHTML = `
|
|
<div class="slot-item-name">${item.name}</div>
|
|
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
|
|
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
|
${needsReducing}
|
|
<button class="clear-slot-btn" onclick="clearSlot('${slotName}')" title="Clear slot">x</button>
|
|
`;
|
|
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 = '<span class="empty-slot">Empty</span>';
|
|
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 = '<div class="no-summary">Select a suit to see summary</div>';
|
|
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 `<div class="summary-line">
|
|
<span class="summary-slot">${slot}:</span>
|
|
<span class="summary-item">${item.name}</span>
|
|
<span class="summary-spells">[${spells}]</span>
|
|
<span class="summary-char">- ${char}</span>
|
|
</div>`;
|
|
}).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) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
|
slotContent.innerHTML = `
|
|
<div class="slot-item-name">${item.name}</div>
|
|
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
|
|
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
|
${needsReducing}
|
|
`;
|
|
} else {
|
|
slotContent.innerHTML = '<span class="empty-slot">Empty</span>';
|
|
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 => `
|
|
<label class="lock-spell-checkbox">
|
|
<input type="checkbox" value="${spell}">
|
|
<span>${spell.replace('Legendary ', 'L. ')}</span>
|
|
</label>
|
|
`).join('');
|
|
|
|
// Build set dropdown (armor only)
|
|
const setDropdown = isArmorSlot ? `
|
|
<div class="lock-form-set">
|
|
<label>Equipment Set:</label>
|
|
<select class="lock-set-select" id="lockSetSelect_${slotName.replace(/ /g, '_')}">
|
|
<option value="">None</option>
|
|
${getEquipmentSetOptions()}
|
|
</select>
|
|
</div>
|
|
` : '';
|
|
|
|
slotContent.innerHTML = `
|
|
<div class="lock-form">
|
|
<div class="lock-form-header">Configure Locked Slot</div>
|
|
${setDropdown}
|
|
<div class="lock-form-spells">
|
|
<label>Spells this item has:</label>
|
|
<div class="lock-spell-checkboxes">
|
|
${spellCheckboxes}
|
|
</div>
|
|
</div>
|
|
<div class="lock-form-actions">
|
|
<button class="lock-save-btn" onclick="saveLockSlotForm('${slotName}')">Lock</button>
|
|
<button class="lock-cancel-btn" onclick="cancelLockSlotForm('${slotName}')">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 =>
|
|
`<option value="${set.id}">${set.name}</option>`
|
|
).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) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
|
slotContent.innerHTML = `
|
|
<div class="slot-item-name">${item.name}</div>
|
|
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
|
|
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
|
${needsReducing}
|
|
`;
|
|
} else {
|
|
slotContent.innerHTML = '<span class="empty-slot">Empty</span>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render locked slot summary display
|
|
*/
|
|
function renderLockedSlotSummary(slotName, lockInfo) {
|
|
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
|
|
|
const setBadge = lockInfo.set ?
|
|
`<div class="locked-set-badge">${lockInfo.set}</div>` : '';
|
|
|
|
const spellPills = lockInfo.spells.length > 0 ?
|
|
`<div class="locked-spells">
|
|
${lockInfo.spells.map(s => `<span class="locked-spell">${s.replace('Legendary ', 'L. ')}</span>`).join('')}
|
|
</div>` : '<div class="locked-no-spells">No spells specified</div>';
|
|
|
|
slotContent.innerHTML = `
|
|
<div class="locked-slot-info">
|
|
<div class="locked-header">🔒 Locked</div>
|
|
${setBadge}
|
|
${spellPills}
|
|
<button class="unlock-btn" onclick="unlockSlot('${slotName}')">Unlock</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = '<span class="empty-slot">Empty</span>';
|
|
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 =
|
|
'<div class="no-results">Configure constraints above and click "Search Suits" to find optimal loadouts.</div>';
|
|
document.getElementById('resultsCount').textContent = '';
|
|
|
|
// Reset slots
|
|
resetSlotView();
|
|
} |