// 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 =
'
Failed to load characters
';
}
}
/**
* Display characters in the selection panel
*/
function displayCharacters(characters) {
const characterList = document.getElementById('characterList');
if (!characters || characters.length === 0) {
characterList.innerHTML = '
No characters found
';
return;
}
let html = '';
characters.forEach(character => {
// Sanitize character name for HTML ID (replace special chars with underscores)
const safeId = character.replace(/[^a-zA-Z0-9]/g, '_');
html += `
`;
});
characterList.innerHTML = html;
// Setup character checkbox interactions
setupCharacterCheckboxes();
}
/**
* Setup character checkbox interactions
*/
function setupCharacterCheckboxes() {
const allCheckbox = document.getElementById('char_all');
const characterCheckboxes = document.querySelectorAll('.character-checkbox:not([value="all"])');
// "All Characters" checkbox toggle
allCheckbox.addEventListener('change', function() {
characterCheckboxes.forEach(cb => {
cb.checked = this.checked;
});
});
// Individual character checkbox changes
characterCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
// Update "All Characters" checkbox state
const checkedCount = Array.from(characterCheckboxes).filter(cb => cb.checked).length;
allCheckbox.checked = checkedCount === characterCheckboxes.length;
allCheckbox.indeterminate = checkedCount > 0 && checkedCount < characterCheckboxes.length;
});
});
}
/**
* Setup all event listeners
*/
function setupEventListeners() {
// Main action buttons
document.getElementById('searchSuits').addEventListener('click', performSuitSearch);
document.getElementById('clearAll').addEventListener('click', clearAllConstraints);
// Slot control buttons
document.getElementById('lockSelectedSlots').addEventListener('click', lockSelectedSlots);
document.getElementById('clearAllLocks').addEventListener('click', clearAllLocks);
document.getElementById('resetSlotView').addEventListener('click', resetSlotView);
}
/**
* Setup slot interaction functionality
*/
function setupSlotInteractions() {
// Lock button interactions
document.querySelectorAll('.lock-btn').forEach(btn => {
btn.addEventListener('click', function() {
const slot = this.dataset.slot;
toggleSlotLock(slot);
});
});
// Slot item click interactions
document.querySelectorAll('.slot-item').forEach(item => {
item.addEventListener('click', function() {
const slot = this.dataset.slot;
handleSlotClick(slot);
});
});
}
/**
* Perform suit search with current constraints using streaming results
*/
// 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 = `
`;
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 = `
`;
// 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 = '
No suits found matching your constraints. Try relaxing some requirements.
';
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 += `
`;
});
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 = `
Slot
Character
Item
Set
Spells
Armor
Ratings
`;
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) ? '*' : '';
html += `
${slot}
${character}
${itemName}${needsReducing}
${setName}
${spells}
${armor}
${ratings}
`;
} else {
// Empty slot
html += `
${slot}
-
-
-
-
-
-
`;
}
});
html += `
`;
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 ? `
${statParts.join(' âĸ ')}
` : '';
}
/**
* Format item properties for display
*/
function formatItemProperties(item) {
const properties = [];
// Handle set name (backend sends item_set_name)
if (item.item_set_name) {
properties.push(item.item_set_name);
} else if (item.set_name) {
properties.push(item.set_name);
}
// Handle spells (backend sends spells array)
const spellArray = item.spells || item.spell_names;
if (spellArray && Array.isArray(spellArray)) {
spellArray.forEach(spell => {
if (spell.includes('Legendary')) {
properties.push(spell);
}
});
}
if (item.crit_damage_rating > 0) {
properties.push(`Crit Dmg +${item.crit_damage_rating}`);
}
if (item.damage_rating > 0) {
properties.push(`Dmg Rating +${item.damage_rating}`);
}
if (item.heal_boost > 0) {
properties.push(`Heal Boost +${item.heal_boost}`);
}
return properties.join(', ');
}
/**
* 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 = 'Empty';
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) ? 'Need Reducing' : '';
slotElement.innerHTML = `