added portals, quest tracking, discord monitor etc etc
This commit is contained in:
parent
72de9b0f7f
commit
dffd295091
312 changed files with 4130 additions and 7 deletions
|
|
@ -52,6 +52,14 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Portal toggle -->
|
||||
<div class="portal-toggle">
|
||||
<label>
|
||||
<input type="checkbox" id="portalToggle">
|
||||
🌀 Show Portals
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Inventory search link -->
|
||||
<div class="inventory-search-link">
|
||||
<a href="#" id="inventorySearchBtn" onclick="openInventorySearch()">
|
||||
|
|
@ -73,6 +81,13 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Quest Status link -->
|
||||
<div class="quest-status-link">
|
||||
<a href="#" id="questStatusBtn" onclick="openQuestStatus()">
|
||||
⏰ Quest Status
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Container for sort and filter controls -->
|
||||
<div id="sortButtons" class="sort-buttons"></div>
|
||||
|
||||
|
|
@ -96,6 +111,7 @@
|
|||
<canvas id="heatmapCanvas"></canvas>
|
||||
<svg id="trails"></svg>
|
||||
<div id="dots"></div>
|
||||
<div id="portals"></div>
|
||||
</div>
|
||||
<div id="tooltip" class="tooltip"></div>
|
||||
<div id="coordinates" class="coordinates"></div>
|
||||
|
|
|
|||
|
|
@ -410,11 +410,13 @@ function displayResults(data) {
|
|||
|
||||
// Format last updated timestamp
|
||||
const lastUpdated = item.last_updated ?
|
||||
new Date(item.last_updated).toLocaleDateString('en-US', {
|
||||
new Date(item.last_updated).toLocaleString('sv-SE', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}) : '-';
|
||||
|
||||
// Use the formatted name with material from the API
|
||||
|
|
|
|||
115
static/script.js
115
static/script.js
|
|
@ -189,6 +189,11 @@ let heatTimeout = null;
|
|||
const HEAT_PADDING = 50; // px beyond viewport to still draw
|
||||
const HEAT_THROTTLE = 16; // ~60 fps
|
||||
|
||||
/* ---------- Portal Map Globals ---------- */
|
||||
let portalEnabled = false;
|
||||
let portalData = null;
|
||||
let portalContainer = null;
|
||||
|
||||
/**
|
||||
* ---------- Player Color Assignment ----------------------------
|
||||
* Uses a predefined accessible color palette for player dots to ensure
|
||||
|
|
@ -446,6 +451,107 @@ function clearHeatmap() {
|
|||
}
|
||||
}
|
||||
|
||||
/* ---------- Portal Map Functions ---------- */
|
||||
function initPortalMap() {
|
||||
portalContainer = document.getElementById('portals');
|
||||
if (!portalContainer) {
|
||||
console.error('Portal container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = document.getElementById('portalToggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('change', e => {
|
||||
portalEnabled = e.target.checked;
|
||||
if (portalEnabled) {
|
||||
fetchPortalData();
|
||||
} else {
|
||||
clearPortals();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPortalData() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/portals`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Portal API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
portalData = data.portals; // [{character_name, portal_name, ns, ew, z}]
|
||||
console.log(`Loaded ${portalData.length} portal discoveries from last 24 hours`);
|
||||
renderPortals();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch portal data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCoordinate(coord) {
|
||||
// Handle both formats:
|
||||
// String format: "42.3N", "15.7S", "33.7E", "28.2W"
|
||||
// Numeric format: "-96.9330958" (already signed)
|
||||
|
||||
// Check if it's already a number
|
||||
if (typeof coord === 'number') {
|
||||
return coord;
|
||||
}
|
||||
|
||||
// Check if it's a numeric string
|
||||
const numericValue = parseFloat(coord);
|
||||
if (!isNaN(numericValue) && coord.match(/^-?\d+\.?\d*$/)) {
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
// Parse string format like "42.3N"
|
||||
const match = coord.match(/^([0-9.]+)([NSEW])$/);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const direction = match[2];
|
||||
|
||||
if (direction === 'S' || direction === 'W') {
|
||||
return -value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderPortals() {
|
||||
if (!portalEnabled || !portalData || !portalContainer || !imgW || !imgH) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing portals
|
||||
clearPortals();
|
||||
|
||||
for (const portal of portalData) {
|
||||
// Coordinates are already floats from the API
|
||||
const ns = portal.ns;
|
||||
const ew = portal.ew;
|
||||
|
||||
// Convert to pixel coordinates
|
||||
const { x, y } = worldToPx(ew, ns);
|
||||
|
||||
// Create portal icon
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'portal-icon';
|
||||
icon.style.left = `${x}px`;
|
||||
icon.style.top = `${y}px`;
|
||||
icon.title = `${portal.portal_name} (discovered by ${portal.character_name})`;
|
||||
|
||||
portalContainer.appendChild(icon);
|
||||
}
|
||||
|
||||
console.log(`Rendered ${portalData.length} portal icons`);
|
||||
}
|
||||
|
||||
function clearPortals() {
|
||||
if (portalContainer) {
|
||||
portalContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
|
|
@ -1163,6 +1269,7 @@ img.onload = () => {
|
|||
startPolling();
|
||||
initWebSocket();
|
||||
initHeatMap();
|
||||
initPortalMap();
|
||||
};
|
||||
|
||||
// Ensure server health polling starts regardless of image loading
|
||||
|
|
@ -1874,4 +1981,12 @@ function openPlayerDebug() {
|
|||
window.open('/debug.html', '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Quest Status interface in a new browser tab.
|
||||
*/
|
||||
function openQuestStatus() {
|
||||
// Open the Quest Status page in a new tab
|
||||
window.open('/quest-status.html', '_blank');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -606,6 +606,30 @@ body.noselect, body.noselect * {
|
|||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#portals {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.portal-icon {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
font-size: 6px;
|
||||
line-height: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 50;
|
||||
opacity: 0.9;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.portal-icon::before {
|
||||
content: '🌀';
|
||||
display: block;
|
||||
}
|
||||
.trail-path {
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
|
|
@ -1304,6 +1328,25 @@ body.noselect, body.noselect * {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.portal-toggle {
|
||||
margin: 0 0 12px;
|
||||
padding: 6px 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid #9c4aff;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.portal-toggle input {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-toggle label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Inventory search link styling */
|
||||
.inventory-search-link {
|
||||
margin: 0 0 12px;
|
||||
|
|
@ -1389,6 +1432,34 @@ body.noselect, body.noselect * {
|
|||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
.quest-status-link {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid #ffab4a;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quest-status-link a {
|
||||
color: #ffab4a;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quest-status-link a:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 171, 74, 0.1);
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
/* Sortable column styles for inventory tables */
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
|
|
|
|||
709
static/suitbuilder.css
Normal file
709
static/suitbuilder.css
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
/* Suitbuilder CSS - Three-Panel Layout with Equipment Slots */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.suitbuilder-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.suitbuilder-header {
|
||||
background: linear-gradient(135deg, #1e3a8a, #3b82f6);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.suitbuilder-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suitbuilder-header p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Main Content - Three Panel Layout */
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 120px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Panel Base Styles */
|
||||
.characters-panel,
|
||||
.constraints-panel,
|
||||
.slots-panel {
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.characters-panel {
|
||||
flex: 0 0 15%;
|
||||
min-width: 200px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.constraints-panel {
|
||||
flex: 0 0 55%;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.slots-panel {
|
||||
flex: 0 0 30%;
|
||||
min-width: 350px;
|
||||
}
|
||||
|
||||
/* Panel Headers */
|
||||
.panel-header {
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Character Selection Panel */
|
||||
.character-selection {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.character-list {
|
||||
margin-top: 0.5rem;
|
||||
max-height: calc(100vh - 250px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.checkbox-item label {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Constraints Panel */
|
||||
.constraints-form {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.constraint-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.constraint-section h4 {
|
||||
font-size: 1rem;
|
||||
color: #374151;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.filter-group input[type="number"] {
|
||||
width: 70px;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
flex: 1;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Cantrips Grid */
|
||||
.cantrips-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cantrips-grid .checkbox-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
.results-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.suit-results {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #d1d5db;
|
||||
}
|
||||
|
||||
.suit-item {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.suit-item:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.suit-item.selected {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.suit-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.suit-score {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.suit-score.excellent { color: #059669; }
|
||||
.suit-score.good { color: #3b82f6; }
|
||||
.suit-score.fair { color: #f59e0b; }
|
||||
.suit-score.poor { color: #ef4444; }
|
||||
|
||||
.suit-items {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suit-item-entry {
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-character {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.item-properties {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.need-reducing {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
/* Constraint Analysis Display */
|
||||
.missing-items {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #fecaca;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.missing-items:before {
|
||||
content: "⚠️ Missing: ";
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.suit-notes {
|
||||
background: #f0f9ff;
|
||||
color: #1e40af;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid #bfdbfe;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.suit-notes:before {
|
||||
content: "✓ ";
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* Equipment Slots Panel */
|
||||
.slots-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.slot-category {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.slot-category h4 {
|
||||
font-size: 1rem;
|
||||
color: #374151;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Slots Grid */
|
||||
.slots-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.armor-slots {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.jewelry-slots {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.clothing-slots {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* Individual Slot Items */
|
||||
.slot-item {
|
||||
background: white;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot-item:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.slot-item.locked {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.slot-item.populated {
|
||||
border-color: #059669;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.slot-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slot-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.slot-item-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.slot-item-character {
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.slot-item-properties {
|
||||
font-size: 0.65rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.slot-controls {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.lock-btn:hover {
|
||||
opacity: 1;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.lock-btn.locked {
|
||||
opacity: 1;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* Slot Controls Section */
|
||||
.slot-controls-section {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.slot-controls-section .btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
font-style: italic;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading::before {
|
||||
content: "🔍 ";
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Error States */
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fecaca;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1200px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.characters-panel,
|
||||
.constraints-panel,
|
||||
.slots-panel {
|
||||
flex: none;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.slots-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.suitbuilder-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.cantrips-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.slots-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Suit Results Styling */
|
||||
.suit-stats {
|
||||
padding: 8px 12px;
|
||||
background: #444;
|
||||
border-bottom: 1px solid #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.suit-stats-line {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suit-notes {
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
background: #e8f5e8;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: #2d5016;
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
|
||||
.missing-items {
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
background: #ffeaea;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: #c62828;
|
||||
border-left: 3px solid #f44336;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.suit-item-entry {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-character {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.item-properties {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.need-reducing {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
font-size: 10px;
|
||||
background: #fff3e0;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Progressive Search Styles */
|
||||
.search-progress {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stop-search-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.stop-search-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.stop-search-btn:active {
|
||||
background: #bd2130;
|
||||
}
|
||||
|
||||
#streamingResults {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* Enhance loading messages */
|
||||
.loading {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 6px;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
}
|
||||
467
static/suitbuilder.html
Normal file
467
static/suitbuilder.html
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Suitbuilder - Dereth Tracker</title>
|
||||
<link rel="stylesheet" href="suitbuilder.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="suitbuilder-container">
|
||||
<!-- Header -->
|
||||
<div class="suitbuilder-header">
|
||||
<h1>🛡️ Suitbuilder</h1>
|
||||
<p>Multi-Character Loadout Optimizer</p>
|
||||
</div>
|
||||
|
||||
<!-- Three-Panel Layout -->
|
||||
<div class="main-content">
|
||||
<!-- Left Panel: Character Selection (15%) -->
|
||||
<div class="characters-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Characters</h3>
|
||||
</div>
|
||||
<div class="character-selection">
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="char_all" class="character-checkbox" value="all" checked>
|
||||
<label for="char_all">All Characters</label>
|
||||
</div>
|
||||
<div id="characterList" class="character-list">
|
||||
<!-- Characters will be loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Panel: Constraints & Results (55%) -->
|
||||
<div class="constraints-panel">
|
||||
<!-- Constraints Form -->
|
||||
<div class="constraints-form">
|
||||
<div class="panel-header">
|
||||
<h3>Constraints</h3>
|
||||
</div>
|
||||
|
||||
<!-- Rating Constraints -->
|
||||
<div class="constraint-section">
|
||||
<h4>Rating Requirements</h4>
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Armor Level:</label>
|
||||
<input type="number" id="minArmor" placeholder="Min" min="0" max="9999">
|
||||
<span>-</span>
|
||||
<input type="number" id="maxArmor" placeholder="Max" min="0" max="9999">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Crit Damage:</label>
|
||||
<input type="number" id="minCritDmg" placeholder="Min" min="0" max="999">
|
||||
<span>-</span>
|
||||
<input type="number" id="maxCritDmg" placeholder="Max" min="0" max="999">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Damage Rating:</label>
|
||||
<input type="number" id="minDmgRating" placeholder="Min" min="0" max="999">
|
||||
<span>-</span>
|
||||
<input type="number" id="maxDmgRating" placeholder="Max" min="0" max="999">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Status -->
|
||||
<div class="constraint-section">
|
||||
<h4>Equipment Status</h4>
|
||||
<div class="filter-row">
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="includeEquipped" checked>
|
||||
<label for="includeEquipped">Equipped Items</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="includeInventory" checked>
|
||||
<label for="includeInventory">Inventory Items</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Sets -->
|
||||
<div class="constraint-section">
|
||||
<h4>Equipment Sets</h4>
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>Primary Set (5 pieces):</label>
|
||||
<select id="primarySet">
|
||||
<option value="">None</option>
|
||||
<option value="14">Adept's</option>
|
||||
<option value="16">Defender's</option>
|
||||
<option value="13">Soldier's</option>
|
||||
<option value="21">Wise</option>
|
||||
<option value="40">Heroic Protector</option>
|
||||
<option value="41">Heroic Destroyer</option>
|
||||
<option value="46">Relic Alduressa</option>
|
||||
<option value="47">Ancient Relic</option>
|
||||
<option value="48">Noble Relic</option>
|
||||
<option value="15">Archer's</option>
|
||||
<option value="19">Hearty</option>
|
||||
<option value="20">Dexterous</option>
|
||||
<option value="22">Swift</option>
|
||||
<option value="24">Reinforced</option>
|
||||
<option value="26">Flame Proof</option>
|
||||
<option value="29">Lightning Proof</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Secondary Set (4 pieces):</label>
|
||||
<select id="secondarySet">
|
||||
<option value="">None</option>
|
||||
<option value="13">Soldier's</option>
|
||||
<option value="14">Adept's</option>
|
||||
<option value="16">Defender's</option>
|
||||
<option value="21">Wise</option>
|
||||
<option value="40">Heroic Protector</option>
|
||||
<option value="41">Heroic Destroyer</option>
|
||||
<option value="46">Relic Alduressa</option>
|
||||
<option value="47">Ancient Relic</option>
|
||||
<option value="48">Noble Relic</option>
|
||||
<option value="15">Archer's</option>
|
||||
<option value="19">Hearty</option>
|
||||
<option value="20">Dexterous</option>
|
||||
<option value="22">Swift</option>
|
||||
<option value="24">Reinforced</option>
|
||||
<option value="26">Flame Proof</option>
|
||||
<option value="29">Lightning Proof</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legendary Cantrips (Same as inventory.html) -->
|
||||
<div class="constraint-section">
|
||||
<h4>Legendary Cantrips</h4>
|
||||
<div class="cantrips-grid">
|
||||
<!-- Legendary Attributes -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_strength" value="Legendary Strength">
|
||||
<label for="cantrip_legendary_strength">Strength</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_endurance" value="Legendary Endurance">
|
||||
<label for="cantrip_legendary_endurance">Endurance</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_quickness" value="Legendary Quickness">
|
||||
<label for="cantrip_legendary_quickness">Quickness</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_coordination" value="Legendary Coordination">
|
||||
<label for="cantrip_legendary_coordination">Coordination</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_willpower" value="Legendary Willpower">
|
||||
<label for="cantrip_legendary_willpower">Willpower</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_focus" value="Legendary Focus">
|
||||
<label for="cantrip_legendary_focus">Focus</label>
|
||||
</div>
|
||||
<!-- Legendary Weapon Skills -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_finesse" value="Legendary Finesse Weapons">
|
||||
<label for="cantrip_legendary_finesse">Finesse</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_heavy" value="Legendary Heavy Weapons">
|
||||
<label for="cantrip_legendary_heavy">Heavy</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_light" value="Legendary Light Weapons">
|
||||
<label for="cantrip_legendary_light">Light</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_missile" value="Legendary Missile Weapons">
|
||||
<label for="cantrip_legendary_missile">Missile</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_twohanded" value="Legendary Two Handed Combat">
|
||||
<label for="cantrip_legendary_twohanded">Two-handed</label>
|
||||
</div>
|
||||
<!-- Legendary Magic Skills -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_war" value="Legendary War Magic">
|
||||
<label for="cantrip_legendary_war">War Magic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_void" value="Legendary Void Magic">
|
||||
<label for="cantrip_legendary_void">Void Magic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_creature" value="Legendary Creature Enchantment">
|
||||
<label for="cantrip_legendary_creature">Creature</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_item" value="Legendary Item Enchantment">
|
||||
<label for="cantrip_legendary_item">Item</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_life" value="Legendary Life Magic">
|
||||
<label for="cantrip_legendary_life">Life Magic</label>
|
||||
</div>
|
||||
<!-- Legendary Defense -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_magic_defense" value="Legendary Magic Defense">
|
||||
<label for="cantrip_legendary_magic_defense">Magic Def</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_melee_defense" value="Legendary Melee Defense">
|
||||
<label for="cantrip_legendary_melee_defense">Melee Def</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legendary Wards -->
|
||||
<div class="constraint-section">
|
||||
<h4>Legendary Wards</h4>
|
||||
<div class="cantrips-grid">
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_flame" value="Legendary Flame Ward">
|
||||
<label for="protection_flame">Flame</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_frost" value="Legendary Frost Ward">
|
||||
<label for="protection_frost">Frost</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_acid" value="Legendary Acid Ward">
|
||||
<label for="protection_acid">Acid</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_storm" value="Legendary Storm Ward">
|
||||
<label for="protection_storm">Storm</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_slashing" value="Legendary Slashing Ward">
|
||||
<label for="protection_slashing">Slashing</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_piercing" value="Legendary Piercing Ward">
|
||||
<label for="protection_piercing">Piercing</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_bludgeoning" value="Legendary Bludgeoning Ward">
|
||||
<label for="protection_bludgeoning">Bludgeoning</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="protection_armor" value="Legendary Armor">
|
||||
<label for="protection_armor">Armor</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button type="button" class="btn btn-primary" id="searchSuits">Search Suits</button>
|
||||
<button type="button" class="btn btn-secondary" id="clearAll">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="results-section">
|
||||
<div class="panel-header">
|
||||
<h3>Suit Results</h3>
|
||||
<span id="resultsCount" class="results-count"></span>
|
||||
</div>
|
||||
<div id="suitResults" class="suit-results">
|
||||
<div class="no-results">Configure constraints above and click "Search Suits" to find optimal loadouts.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Visual Equipment Slots (30%) -->
|
||||
<div class="slots-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Equipment Slots</h3>
|
||||
</div>
|
||||
|
||||
<!-- Armor Slots -->
|
||||
<div class="slot-category">
|
||||
<h4>Armor (9 slots)</h4>
|
||||
<div class="slots-grid armor-slots">
|
||||
<div class="slot-item" data-slot="Head">
|
||||
<div class="slot-header">Head</div>
|
||||
<div class="slot-content" id="slot_Head">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Head">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Chest">
|
||||
<div class="slot-header">Chest</div>
|
||||
<div class="slot-content" id="slot_Chest">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Chest">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Upper Arms">
|
||||
<div class="slot-header">Upper Arms</div>
|
||||
<div class="slot-content" id="slot_Upper_Arms">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Upper Arms">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Lower Arms">
|
||||
<div class="slot-header">Lower Arms</div>
|
||||
<div class="slot-content" id="slot_Lower_Arms">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Lower Arms">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Hands">
|
||||
<div class="slot-header">Hands</div>
|
||||
<div class="slot-content" id="slot_Hands">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Hands">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Abdomen">
|
||||
<div class="slot-header">Abdomen</div>
|
||||
<div class="slot-content" id="slot_Abdomen">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Abdomen">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Upper Legs">
|
||||
<div class="slot-header">Upper Legs</div>
|
||||
<div class="slot-content" id="slot_Upper_Legs">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Upper Legs">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Lower Legs">
|
||||
<div class="slot-header">Lower Legs</div>
|
||||
<div class="slot-content" id="slot_Lower_Legs">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Lower Legs">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Feet">
|
||||
<div class="slot-header">Feet</div>
|
||||
<div class="slot-content" id="slot_Feet">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Feet">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jewelry Slots -->
|
||||
<div class="slot-category">
|
||||
<h4>Jewelry (6 slots)</h4>
|
||||
<div class="slots-grid jewelry-slots">
|
||||
<div class="slot-item" data-slot="Neck">
|
||||
<div class="slot-header">Neck</div>
|
||||
<div class="slot-content" id="slot_Neck">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Neck">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Left Ring">
|
||||
<div class="slot-header">Left Ring</div>
|
||||
<div class="slot-content" id="slot_Left_Ring">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Left Ring">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Right Ring">
|
||||
<div class="slot-header">Right Ring</div>
|
||||
<div class="slot-content" id="slot_Right_Ring">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Right Ring">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Left Wrist">
|
||||
<div class="slot-header">Left Wrist</div>
|
||||
<div class="slot-content" id="slot_Left_Wrist">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Left Wrist">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Right Wrist">
|
||||
<div class="slot-header">Right Wrist</div>
|
||||
<div class="slot-content" id="slot_Right_Wrist">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Right Wrist">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Trinket">
|
||||
<div class="slot-header">Trinket</div>
|
||||
<div class="slot-content" id="slot_Trinket">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Trinket">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clothing Slots -->
|
||||
<div class="slot-category">
|
||||
<h4>Clothing (2 slots)</h4>
|
||||
<div class="slots-grid clothing-slots">
|
||||
<div class="slot-item" data-slot="Shirt">
|
||||
<div class="slot-header">Shirt</div>
|
||||
<div class="slot-content" id="slot_Shirt">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Shirt">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-item" data-slot="Pants">
|
||||
<div class="slot-header">Pants</div>
|
||||
<div class="slot-content" id="slot_Pants">
|
||||
<span class="empty-slot">Empty</span>
|
||||
</div>
|
||||
<div class="slot-controls">
|
||||
<button class="lock-btn" data-slot="Pants">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot Controls -->
|
||||
<div class="slot-controls-section">
|
||||
<button type="button" class="btn btn-secondary" id="lockSelectedSlots">Lock Selected</button>
|
||||
<button type="button" class="btn btn-secondary" id="clearAllLocks">Clear All Locks</button>
|
||||
<button type="button" class="btn btn-secondary" id="resetSlotView">Reset View</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="suitbuilder.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
936
static/suitbuilder.js
Normal file
936
static/suitbuilder.js
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
// Suitbuilder JavaScript - Constraint Solver Frontend Logic
|
||||
|
||||
// Configuration
|
||||
const API_BASE = '/inv';
|
||||
let currentSuits = [];
|
||||
let lockedSlots = new Set();
|
||||
let selectedSuit = null;
|
||||
|
||||
// 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/list`);
|
||||
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 => {
|
||||
html += `
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="char_${character.character_name}"
|
||||
class="character-checkbox" value="${character.character_name}" checked>
|
||||
<label for="char_${character.character_name}">${character.character_name}</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
|
||||
*/
|
||||
async function performSuitSearch() {
|
||||
const constraints = gatherConstraints();
|
||||
|
||||
if (!validateConstraints(constraints)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsDiv = document.getElementById('suitResults');
|
||||
const countSpan = document.getElementById('resultsCount');
|
||||
|
||||
// Reset current suits and UI
|
||||
currentSuits = [];
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="loading">
|
||||
🔍 Searching for optimal suits...
|
||||
<div class="search-progress">
|
||||
<div class="progress-stats">
|
||||
Found: <span id="foundCount">0</span> suits |
|
||||
Checked: <span id="checkedCount">0</span> combinations |
|
||||
Time: <span id="elapsedTime">0.0</span>s
|
||||
</div>
|
||||
<button id="stopSearch" class="stop-search-btn">Stop Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="streamingResults"></div>
|
||||
`;
|
||||
countSpan.textContent = '';
|
||||
|
||||
try {
|
||||
await streamOptimalSuits(constraints);
|
||||
} catch (error) {
|
||||
console.error('Suit search error:', error);
|
||||
resultsDiv.innerHTML = `<div class="error">❌ Suit search failed: ${error.message}</div>`;
|
||||
countSpan.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
locked_slots: Array.from(lockedSlots)
|
||||
};
|
||||
|
||||
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) {
|
||||
// Build request parameters for the streaming constraint solver
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Character selection
|
||||
if (constraints.characters.length > 0) {
|
||||
params.append('characters', constraints.characters.join(','));
|
||||
} else {
|
||||
params.append('include_all_characters', 'true');
|
||||
}
|
||||
|
||||
// Equipment sets
|
||||
if (constraints.primary_set) {
|
||||
params.append('primary_set', constraints.primary_set);
|
||||
}
|
||||
if (constraints.secondary_set) {
|
||||
params.append('secondary_set', constraints.secondary_set);
|
||||
}
|
||||
|
||||
// Legendary cantrips
|
||||
if (constraints.legendary_cantrips.length > 0) {
|
||||
params.append('legendary_cantrips', constraints.legendary_cantrips.join(','));
|
||||
}
|
||||
|
||||
// Legendary wards
|
||||
if (constraints.protection_spells.length > 0) {
|
||||
params.append('legendary_wards', constraints.protection_spells.join(','));
|
||||
}
|
||||
|
||||
// Rating constraints
|
||||
if (constraints.min_armor) params.append('min_armor', constraints.min_armor);
|
||||
if (constraints.max_armor) params.append('max_armor', constraints.max_armor);
|
||||
if (constraints.min_crit_damage) params.append('min_crit_damage', constraints.min_crit_damage);
|
||||
if (constraints.max_crit_damage) params.append('max_crit_damage', constraints.max_crit_damage);
|
||||
if (constraints.min_damage_rating) params.append('min_damage_rating', constraints.min_damage_rating);
|
||||
if (constraints.max_damage_rating) params.append('max_damage_rating', constraints.max_damage_rating);
|
||||
|
||||
// Equipment status
|
||||
params.append('include_equipped', constraints.include_equipped.toString());
|
||||
params.append('include_inventory', constraints.include_inventory.toString());
|
||||
|
||||
// Locked slots
|
||||
if (lockedSlots.size > 0) {
|
||||
params.append('locked_slots', Array.from(lockedSlots).join(','));
|
||||
}
|
||||
|
||||
// Search depth (default to balanced)
|
||||
params.append('search_depth', 'balanced');
|
||||
|
||||
const streamUrl = `${API_BASE}/optimize/suits/stream?${params.toString()}`;
|
||||
console.log('Streaming suits with URL:', streamUrl);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const eventSource = new EventSource(streamUrl);
|
||||
let searchStopped = false;
|
||||
|
||||
// Add stop search functionality
|
||||
const stopButton = document.getElementById('stopSearch');
|
||||
stopButton.addEventListener('click', () => {
|
||||
searchStopped = true;
|
||||
eventSource.close();
|
||||
|
||||
// Update UI to show search was stopped
|
||||
const loadingDiv = document.querySelector('.loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.innerHTML = '⏹️ Search stopped by user.';
|
||||
}
|
||||
|
||||
// Update results count
|
||||
const countSpan = document.getElementById('resultsCount');
|
||||
if (countSpan) {
|
||||
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''} (search stopped)`;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Handle individual suit results
|
||||
eventSource.addEventListener('suit', (event) => {
|
||||
try {
|
||||
const suit = JSON.parse(event.data);
|
||||
|
||||
// Transform backend suit format to frontend format
|
||||
const transformedSuit = transformSuitData(suit);
|
||||
currentSuits.push(transformedSuit);
|
||||
|
||||
// Add suit to streaming results
|
||||
addSuitToResults(transformedSuit, currentSuits.length - 1);
|
||||
|
||||
// Update count
|
||||
document.getElementById('foundCount').textContent = currentSuits.length;
|
||||
document.getElementById('resultsCount').textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing suit data:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle progress updates
|
||||
eventSource.addEventListener('progress', (event) => {
|
||||
try {
|
||||
const progress = JSON.parse(event.data);
|
||||
document.getElementById('foundCount').textContent = progress.found || currentSuits.length;
|
||||
document.getElementById('checkedCount').textContent = progress.checked || 0;
|
||||
document.getElementById('elapsedTime').textContent = progress.elapsed || '0.0';
|
||||
} catch (error) {
|
||||
console.error('Error processing progress data:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search completion
|
||||
eventSource.addEventListener('complete', (event) => {
|
||||
try {
|
||||
const completion = JSON.parse(event.data);
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingDiv = document.querySelector('.loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.innerHTML = `✅ Search complete! Found ${completion.total_found} suits in ${completion.total_time}s.`;
|
||||
}
|
||||
|
||||
// Update final results count
|
||||
const countSpan = document.getElementById('resultsCount');
|
||||
if (countSpan) {
|
||||
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
resolve();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing completion data:', error);
|
||||
eventSource.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle timeout
|
||||
eventSource.addEventListener('timeout', (event) => {
|
||||
try {
|
||||
const timeout = JSON.parse(event.data);
|
||||
|
||||
// Update UI to show timeout
|
||||
const loadingDiv = document.querySelector('.loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.innerHTML = `⏰ ${timeout.message}`;
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
resolve();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing timeout data:', error);
|
||||
eventSource.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
try {
|
||||
const errorData = JSON.parse(event.data);
|
||||
console.error('Stream error:', errorData.message);
|
||||
|
||||
const loadingDiv = document.querySelector('.loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.innerHTML = `❌ Search error: ${errorData.message}`;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing error data:', error);
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
reject(new Error('Stream error occurred'));
|
||||
});
|
||||
|
||||
// Handle connection errors
|
||||
eventSource.onerror = (event) => {
|
||||
if (!searchStopped) {
|
||||
console.error('EventSource error:', event);
|
||||
eventSource.close();
|
||||
reject(new Error('Connection error during streaming'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single suit to the streaming results display
|
||||
*/
|
||||
function addSuitToResults(suit, index) {
|
||||
const streamingResults = document.getElementById('streamingResults');
|
||||
if (!streamingResults) return;
|
||||
|
||||
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);
|
||||
|
||||
// Add click handler for the new suit
|
||||
const newSuitElement = streamingResults.lastElementChild;
|
||||
newSuitElement.addEventListener('click', function() {
|
||||
const suitId = parseInt(this.dataset.suitId);
|
||||
selectSuit(suitId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
countSpan.textContent = `Found ${suits.length} suit${suits.length !== 1 ? 's' : ''}`;
|
||||
|
||||
let html = '';
|
||||
suits.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
|
||||
*/
|
||||
function formatSuitItems(items) {
|
||||
let html = '';
|
||||
|
||||
if (!items || Object.keys(items).length === 0) {
|
||||
return '<div class="no-items">No items in this suit</div>';
|
||||
}
|
||||
|
||||
Object.entries(items).forEach(([slot, item]) => {
|
||||
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
||||
const properties = formatItemProperties(item);
|
||||
|
||||
html += `
|
||||
<div class="suit-item-entry">
|
||||
<strong>${slot}:</strong>
|
||||
<span class="item-character">${item.character_name}</span> -
|
||||
<span class="item-name">${item.name}</span>
|
||||
${properties ? `<span class="item-properties">(${properties})</span>` : ''}
|
||||
${needsReducing}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 all slots first
|
||||
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');
|
||||
});
|
||||
|
||||
// Populate with items
|
||||
Object.entries(items).forEach(([slotName, item]) => {
|
||||
const slotId = `slot_${slotName.replace(' ', '_')}`;
|
||||
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.character_name}</div>
|
||||
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
||||
${needsReducing}
|
||||
`;
|
||||
slotElement.parentElement.classList.add('populated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
lockedSlots.delete(slotName);
|
||||
slotElement.classList.remove('locked');
|
||||
lockBtn.classList.remove('locked');
|
||||
} else {
|
||||
lockedSlots.add(slotName);
|
||||
slotElement.classList.add('locked');
|
||||
lockBtn.classList.add('locked');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle slot click events
|
||||
*/
|
||||
function handleSlotClick(slotName) {
|
||||
// For now, just toggle lock state
|
||||
// Later this could open item selection dialog
|
||||
console.log(`Clicked slot: ${slotName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock currently selected slots
|
||||
*/
|
||||
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() {
|
||||
lockedSlots.clear();
|
||||
document.querySelectorAll('.slot-item').forEach(slot => {
|
||||
slot.classList.remove('locked');
|
||||
slot.querySelector('.lock-btn').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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue