Debug and inventory

This commit is contained in:
erik 2025-06-19 17:46:19 +00:00
parent 1febf6e918
commit 80a0a16bab
15 changed files with 2764 additions and 341 deletions

654
static/debug.html Normal file
View file

@ -0,0 +1,654 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Player Debug - Dereth Tracker</title>
<style>
body {
font-family: "Segoe UI", sans-serif;
background: #111;
color: #eee;
margin: 20px;
line-height: 1.4;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #222;
border: 1px solid #444;
border-radius: 8px;
padding: 15px;
}
.stat-card h3 {
margin: 0 0 10px 0;
color: #88f;
font-size: 1.1rem;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
color: #fff;
}
.sections {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.section {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
}
.section h2 {
margin: 0 0 15px 0;
color: #88f;
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.event-log {
max-height: 400px;
overflow-y: auto;
background: #000;
border: 1px solid #333;
border-radius: 4px;
padding: 10px;
font-family: monospace;
font-size: 0.9rem;
}
.event-enter {
color: #4f4;
}
.event-exit {
color: #f44;
}
.flapping-player {
background: #332;
border: 1px solid #664;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.flapping-player .name {
font-weight: bold;
color: #ff6;
}
.flapping-player .score {
color: #f88;
font-size: 0.9rem;
}
.history-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #333;
font-family: monospace;
font-size: 0.9rem;
}
.history-entry:last-child {
border-bottom: none;
}
.timestamp {
color: #888;
font-size: 0.8rem;
}
.player-count {
font-weight: bold;
color: #4f4;
}
.status {
text-align: center;
padding: 20px;
background: #222;
border-radius: 8px;
margin-bottom: 20px;
}
.loading {
color: #88f;
}
.error {
color: #f44;
}
.last-updated {
text-align: center;
color: #888;
font-size: 0.9rem;
margin-top: 20px;
}
.timing-player {
background: #223;
border: 1px solid #446;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.timing-player .name {
font-weight: bold;
color: #ff8;
}
.timing-player .issue {
color: #f88;
font-size: 0.9rem;
}
.timing-player .stats {
color: #aaa;
font-size: 0.8rem;
margin-top: 5px;
font-family: monospace;
}
.timing-good {
border-color: #464 !important;
background: #232 !important;
}
.timing-warning {
border-color: #664 !important;
background: #332 !important;
}
.timing-critical {
border-color: #644 !important;
background: #322 !important;
}
@media (max-width: 768px) {
.sections {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔍 Player Debug Dashboard</h1>
<p>Real-time tracking of player list changes to debug flapping issues</p>
</div>
<div class="status" id="status">
<div class="loading">Loading player debug data...</div>
</div>
<div class="stats-grid" id="statsGrid" style="display: none;">
<div class="stat-card">
<h3>Current Players</h3>
<div class="stat-value" id="currentPlayers">-</div>
</div>
<div class="stat-card">
<h3>Total Events</h3>
<div class="stat-value" id="totalEvents">-</div>
</div>
<div class="stat-card">
<h3>Flapping Players</h3>
<div class="stat-value" id="flappingCount">-</div>
</div>
<div class="stat-card">
<h3>Recent Activity</h3>
<div class="stat-value" id="recentActivity">-</div>
</div>
<div class="stat-card">
<h3>Timing Issues</h3>
<div class="stat-value" id="timingIssues">-</div>
</div>
<div class="stat-card">
<h3>Tracked Players</h3>
<div class="stat-value" id="trackedPlayers">-</div>
</div>
<div class="stat-card">
<h3>WebSocket Connections</h3>
<div class="stat-value" id="websocketConnections">-</div>
</div>
<div class="stat-card">
<h3>Database Queries</h3>
<div class="stat-value" id="databaseQueries">-</div>
</div>
<div class="stat-card">
<h3>Recent Activity</h3>
<div class="stat-value" id="recentActivityCount">-</div>
</div>
</div>
<div class="sections" id="sections" style="display: none;">
<div class="section">
<h2>🔄 Flapping Players</h2>
<div id="flappingPlayers">
<p style="color: #888; font-style: italic;">No flapping detected</p>
</div>
</div>
<div class="section">
<h2>⏰ Timing Issues</h2>
<div id="timingProblems">
<p style="color: #888; font-style: italic;">Loading timing analysis...</p>
</div>
</div>
<div class="section">
<h2>📊 Player History</h2>
<div id="playerHistory" class="event-log">
<p style="color: #888; font-style: italic;">Loading history...</p>
</div>
</div>
<div class="section">
<h2>📝 Recent Events</h2>
<div id="recentEvents" class="event-log">
<p style="color: #888; font-style: italic;">Loading events...</p>
</div>
</div>
<div class="section">
<h2>🎯 Most Active Players</h2>
<div id="frequentEvents">
<p style="color: #888; font-style: italic;">Loading activity...</p>
</div>
</div>
<div class="section">
<h2>📈 Telemetry Timing</h2>
<div id="telemetryTiming">
<p style="color: #888; font-style: italic;">Loading telemetry data...</p>
</div>
</div>
<div class="section">
<h2>🔌 WebSocket Health</h2>
<div id="websocketHealth">
<p style="color: #888; font-style: italic;">Loading WebSocket data...</p>
</div>
</div>
<div class="section">
<h2>🗄️ Database Performance</h2>
<div id="databasePerformance">
<p style="color: #888; font-style: italic;">Loading database data...</p>
</div>
</div>
<div class="section">
<h2>⚡ Recent Activity</h2>
<div id="recentActivityFeed" class="event-log">
<p style="color: #888; font-style: italic;">Loading recent activity...</p>
</div>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
<script>
let pollInterval;
let isPolling = false;
function formatTimestamp(isoString) {
const date = new Date(isoString);
return date.toLocaleTimeString();
}
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleString();
}
async function fetchDebugData() {
try {
// Fetch all four endpoints
const [flappingResponse, websocketResponse, databaseResponse, activityResponse] = await Promise.all([
fetch('/debug/player-flapping'),
fetch('/debug/websocket-health'),
fetch('/debug/database-performance'),
fetch('/debug/recent-activity')
]);
if (!flappingResponse.ok) {
throw new Error(`HTTP ${flappingResponse.status}: ${flappingResponse.statusText}`);
}
const data = await flappingResponse.json();
const websocketData = websocketResponse.ok ? await websocketResponse.json() : null;
const databaseData = databaseResponse.ok ? await databaseResponse.json() : null;
const activityData = activityResponse.ok ? await activityResponse.json() : null;
// Combine the data
const enhancedData = {
...data,
websocket_health: websocketData,
database_performance: databaseData,
recent_activity: activityData
};
updateUI(enhancedData);
showSuccess();
} catch (error) {
console.error('Failed to fetch debug data:', error);
showError(`Failed to fetch data: ${error.message}`);
}
}
function updateUI(data) {
// Update stats
document.getElementById('currentPlayers').textContent = data.current_players;
document.getElementById('totalEvents').textContent = data.tracking_stats.total_events;
document.getElementById('flappingCount').textContent = data.flapping_analysis.flapping_players.length;
const recentActivity = data.flapping_analysis.recent_activity;
document.getElementById('recentActivity').textContent =
`+${recentActivity.enters} -${recentActivity.exits} (${recentActivity.net_change > 0 ? '+' : ''}${recentActivity.net_change})`;
// Update flapping players
const flappingDiv = document.getElementById('flappingPlayers');
if (data.flapping_analysis.flapping_players.length === 0) {
flappingDiv.innerHTML = '<p style="color: #4f4; font-style: italic;">✅ No flapping detected</p>';
} else {
flappingDiv.innerHTML = data.flapping_analysis.flapping_players.map(player => `
<div class="flapping-player">
<div class="name">${player.character_name}</div>
<div class="score">Flap Score: ${player.flap_score} | Events: ${player.events}</div>
<div style="font-size: 0.8rem; color: #aaa; margin-top: 5px;">
Recent: ${player.recent_activity.join(' → ')}
</div>
</div>
`).join('');
}
// Update history
const historyDiv = document.getElementById('playerHistory');
if (data.history.length === 0) {
historyDiv.innerHTML = '<p style="color: #888; font-style: italic;">No history available</p>';
} else {
historyDiv.innerHTML = data.history.slice().reverse().map(entry => `
<div class="history-entry">
<span class="timestamp">${formatTimestamp(entry.timestamp)}</span>
<span class="player-count">${entry.player_count} players</span>
</div>
`).join('');
}
// Update recent events
const eventsDiv = document.getElementById('recentEvents');
if (data.recent_events.length === 0) {
eventsDiv.innerHTML = '<p style="color: #888; font-style: italic;">No recent events</p>';
} else {
eventsDiv.innerHTML = data.recent_events.slice().reverse().map(event => `
<div class="event-${event.type}">
${formatTimestamp(event.timestamp)} - ${event.character_name} ${event.type === 'enter' ? 'joined' : 'left'} (${event.total_players} total)
</div>
`).join('');
}
// Update frequent events
const frequentDiv = document.getElementById('frequentEvents');
if (data.flapping_analysis.frequent_events.length === 0) {
frequentDiv.innerHTML = '<p style="color: #888; font-style: italic;">No activity data</p>';
} else {
frequentDiv.innerHTML = data.flapping_analysis.frequent_events.map(player => `
<div style="display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #333;">
<span>${player.character_name}</span>
<span style="color: #88f;">${player.event_count} events</span>
</div>
`).join('');
}
// Update timing issues
const timingDiv = document.getElementById('timingProblems');
const timingData = data.timing_analysis || { problem_players: [], summary: { total_tracked_players: 0 } };
document.getElementById('timingIssues').textContent = timingData.problem_players.length;
document.getElementById('trackedPlayers').textContent = timingData.summary.total_tracked_players;
if (timingData.problem_players.length === 0) {
timingDiv.innerHTML = '<p style="color: #4f4; font-style: italic;">✅ No timing issues detected</p>';
} else {
timingDiv.innerHTML = timingData.problem_players.map(player => {
let severityClass = 'timing-good';
const maxInterval = player.max_interval || 0;
const avgInterval = player.avg_interval || 0;
const totalMessages = player.total_messages || 0;
if (maxInterval > 60) severityClass = 'timing-critical';
else if (maxInterval > 45) severityClass = 'timing-warning';
const issue = maxInterval > 30 ?
`Max gap: ${maxInterval.toFixed(1)}s (>${30}s threshold)` :
`Irregular timing patterns detected`;
return `
<div class="timing-player ${severityClass}">
<div class="name">${player.character_name || 'Unknown'}</div>
<div class="issue">${issue}</div>
<div class="stats">
Messages: ${totalMessages} |
Avg gap: ${avgInterval.toFixed(1)}s |
Max gap: ${maxInterval.toFixed(1)}s
</div>
</div>
`;
}).join('');
}
// Update telemetry timing section
const telemetryDiv = document.getElementById('telemetryTiming');
if (timingData.summary.total_tracked_players === 0) {
telemetryDiv.innerHTML = '<p style="color: #888; font-style: italic;">No telemetry data available</p>';
} else {
const healthyCount = timingData.summary.total_tracked_players - timingData.problem_players.length;
telemetryDiv.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Total Players Tracked:</span>
<span style="color: #88f; font-weight: bold;">${timingData.summary.total_tracked_players}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Healthy Timing:</span>
<span style="color: #4f4; font-weight: bold;">${healthyCount}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Timing Issues:</span>
<span style="color: #f44; font-weight: bold;">${timingData.problem_players.length}</span>
</div>
</div>
<div style="font-size: 0.9rem; color: #aaa; line-height: 1.4;">
Players with telemetry gaps >30s are flagged as timing issues.
This can cause players to temporarily disappear from the active list.
</div>
`;
}
// Update WebSocket health data
const websocketData = data.websocket_health;
if (websocketData) {
// Update stat card
document.getElementById('websocketConnections').textContent =
`${websocketData.total_connections} (P:${websocketData.plugin_connections}/B:${websocketData.browser_connections})`;
// Update detailed section
const websocketDiv = document.getElementById('websocketHealth');
websocketDiv.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Plugin Connections:</span>
<span style="color: #88f; font-weight: bold;">${websocketData.plugin_connections}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Browser Connections:</span>
<span style="color: #4f4; font-weight: bold;">${websocketData.browser_connections}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Total Connections:</span>
<span style="color: #ff8; font-weight: bold;">${websocketData.total_connections}</span>
</div>
</div>
<div style="font-size: 0.9rem; color: #aaa;">
Plugin connections receive telemetry from game clients.<br>
Browser connections show live updates to web users.
</div>
`;
} else {
document.getElementById('websocketConnections').textContent = 'Error';
document.getElementById('websocketHealth').innerHTML = '<p style="color: #f44;">Failed to load WebSocket data</p>';
}
// Update database performance data
const databaseData = data.database_performance;
if (databaseData) {
// Update stat card
document.getElementById('databaseQueries').textContent =
`${databaseData.total_queries} (${databaseData.average_query_time}s avg)`;
// Update detailed section
const databaseDiv = document.getElementById('databasePerformance');
databaseDiv.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Total Queries:</span>
<span style="color: #88f; font-weight: bold;">${databaseData.total_queries}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Total Query Time:</span>
<span style="color: #4f4; font-weight: bold;">${databaseData.total_query_time}s</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Average Query Time:</span>
<span style="color: #ff8; font-weight: bold;">${databaseData.average_query_time}s</span>
</div>
</div>
<div style="font-size: 0.9rem; color: #aaa;">
Tracks telemetry database write operations performance.<br>
Each telemetry message triggers 1-2 database queries.
</div>
`;
} else {
document.getElementById('databaseQueries').textContent = 'Error';
document.getElementById('databasePerformance').innerHTML = '<p style="color: #f44;">Failed to load database performance data</p>';
}
// Update recent activity data
const activityData = data.recent_activity;
if (activityData) {
// Update stat card
document.getElementById('recentActivityCount').textContent =
`${activityData.total_messages}/${activityData.max_messages}`;
// Update detailed section
const activityDiv = document.getElementById('recentActivityFeed');
if (activityData.recent_messages.length === 0) {
activityDiv.innerHTML = '<p style="color: #888; font-style: italic;">No recent activity</p>';
} else {
activityDiv.innerHTML = activityData.recent_messages.slice().reverse().map(msg => {
const timeStr = formatTimestamp(msg.timestamp);
const killInfo = msg.kill_delta > 0 ? ` (+${msg.kill_delta} kills)` : '';
const queryTime = msg.query_time ? ` ${msg.query_time}ms` : '';
return `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid #333; font-family: monospace; font-size: 0.85rem;">
<span>
<span style="color: #888;">${timeStr}</span>
<span style="color: #88f; margin-left: 8px;">${msg.character_name}</span>
<span style="color: #aaa; margin-left: 8px;">kills:${msg.kills}${killInfo}</span>
</span>
<span style="color: #666;">${queryTime}</span>
</div>
`;
}).join('');
}
} else {
document.getElementById('recentActivityCount').textContent = 'Error';
document.getElementById('recentActivityFeed').innerHTML = '<p style="color: #f44;">Failed to load recent activity data</p>';
}
// Update last updated
document.getElementById('lastUpdated').textContent = `Last updated: ${formatDate(new Date().toISOString())}`;
}
function showSuccess() {
document.getElementById('status').style.display = 'none';
document.getElementById('statsGrid').style.display = 'grid';
document.getElementById('sections').style.display = 'grid';
}
function showError(message) {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = `<div class="error">❌ ${message}</div>`;
statusDiv.style.display = 'block';
document.getElementById('statsGrid').style.display = 'none';
document.getElementById('sections').style.display = 'none';
}
function startPolling() {
if (isPolling) return;
isPolling = true;
fetchDebugData(); // Initial fetch
pollInterval = setInterval(fetchDebugData, 5000); // Poll every 5 seconds
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
isPolling = false;
}
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
});
// Start polling when page loads
window.addEventListener('load', startPolling);
// Stop polling when page unloads
window.addEventListener('beforeunload', stopPolling);
</script>
</body>
</html>

View file

@ -46,6 +46,20 @@
</a>
</div>
<!-- Suitbuilder link -->
<div class="suitbuilder-link">
<a href="#" id="suitbuilderBtn" onclick="openSuitbuilder()">
🛡️ Suitbuilder
</a>
</div>
<!-- Player Debug link -->
<div class="debug-link">
<a href="#" id="playerDebugBtn" onclick="openPlayerDebug()">
🔍 Player Debug
</a>
</div>
<!-- Text input to filter active players by name -->
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />

715
static/inventory.html Normal file
View file

@ -0,0 +1,715 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inventory Search - Dereth Tracker</title>
<link rel="stylesheet" href="style.css">
<style>
/* Override main app styles to enable scrolling */
html, body {
overflow: auto !important;
height: auto !important;
display: block !important;
margin: 0;
padding: 0;
font-size: 11px;
font-family: Arial, sans-serif;
background: #f0f0f0;
}
/* Inventory-specific styles */
.inventory-container {
display: flex;
gap: 5px;
padding: 5px;
}
.character-sidebar {
width: 120px;
background: white;
border: 1px solid #999;
padding: 3px;
height: fit-content;
}
.character-sidebar h4 {
margin: 0 0 3px 0;
font-size: 10px;
font-weight: bold;
color: #000;
}
.character-list {
max-height: 300px;
overflow-y: auto;
}
.character-item {
display: flex;
align-items: center;
margin-bottom: 1px;
font-size: 9px;
}
.character-item input[type="checkbox"] {
margin: 0 2px 0 0;
width: 9px;
height: 9px;
}
.character-item label {
cursor: pointer;
color: #000;
flex: 1;
}
.main-content {
flex: 1;
}
.inventory-header {
background: #333;
color: white;
padding: 4px 8px;
margin-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.inventory-header h1 {
margin: 0;
font-size: 14px;
font-weight: normal;
}
.search-form {
background: white;
padding: 5px;
border: 1px solid #ccc;
}
.filter-row {
display: flex;
gap: 10px;
margin-bottom: 5px;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 3px;
}
.filter-group label {
font-weight: bold;
font-size: 10px;
color: #000;
}
input[type="text"],
input[type="number"],
select {
border: 1px solid #999;
padding: 1px 3px;
font-size: 11px;
height: 18px;
}
input[type="text"] {
width: 120px;
}
input[type="number"] {
width: 40px;
}
select {
width: 100px;
}
.filter-section {
display: flex;
align-items: flex-start;
gap: 5px;
margin-bottom: 3px;
}
.section-label {
font-weight: bold;
font-size: 10px;
min-width: 50px;
padding-top: 2px;
color: #000;
}
.checkbox-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
flex: 1;
}
.checkbox-item {
display: flex;
align-items: center;
font-size: 9px;
white-space: nowrap;
}
.checkbox-item input[type="checkbox"] {
margin: 0 1px 0 0;
width: 9px;
height: 9px;
}
.checkbox-item label {
cursor: pointer;
color: #000;
}
.search-actions {
display: flex;
gap: 5px;
margin-top: 3px;
}
.btn {
padding: 2px 8px;
border: 1px solid #999;
background: #e0e0e0;
font-size: 10px;
cursor: pointer;
}
.btn-primary {
background: #4a80c0;
color: white;
border-color: #336699;
}
.btn:hover {
background: #d0d0d0;
}
.btn-primary:hover {
background: #336699;
}
.results-container {
background: white;
border: 1px solid #999;
margin-top: 5px;
max-height: calc(100vh - 150px);
overflow: auto;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
color: #000;
}
.results-table th {
background: #d0d0d0;
padding: 1px 3px;
text-align: left;
font-weight: bold;
border: 1px solid #999;
position: sticky;
top: 0;
font-size: 9px;
}
.results-table td {
padding: 1px 3px;
border: 1px solid #ddd;
}
.results-table tbody tr:hover {
background-color: #f0f0f0;
}
.item-name {
color: #0066cc;
font-weight: bold;
}
.status-equipped {
color: #008800;
}
.status-inventory {
color: #5f6368;
}
.loading {
text-align: center;
padding: 20px;
color: #5f6368;
font-size: 11px;
}
.no-results {
text-align: center;
padding: 20px;
color: #5f6368;
font-size: 11px;
}
.error {
text-align: center;
padding: 10px;
color: #d93025;
background: #fef7e0;
margin: 5px;
font-size: 11px;
}
.results-info {
padding: 3px 5px;
background: #f0f0f0;
border-bottom: 1px solid #999;
font-weight: normal;
color: #333;
font-size: 10px;
}
.back-link {
display: inline-flex;
align-items: center;
color: #1a73e8;
text-decoration: none;
font-weight: 500;
margin-bottom: 16px;
transition: color 0.2s;
}
.back-link:hover {
color: #1557b0;
}
.back-link::before {
content: "←";
margin-right: 8px;
font-size: 1.2rem;
}
/* Set Analysis specific styles */
.set-analysis-section {
background: white;
border: 1px solid #999;
margin-top: 5px;
}
.set-analysis-form {
background: #f8f8f8;
padding: 8px;
border-bottom: 1px solid #ddd;
}
.set-analysis-form h3 {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: bold;
color: #333;
}
.set-analysis-form select {
width: 140px;
}
.text-right {
text-align: right;
}
</style>
</head>
<body>
<div class="inventory-header">
<h1>Inventory Search</h1>
<a href="/" style="color: white; text-decoration: none; font-size: 11px;">← Back</a>
</div>
<div class="inventory-container">
<!-- Character Selection Sidebar -->
<div class="character-sidebar">
<h4>Characters</h4>
<div class="character-item">
<input type="checkbox" id="char_all" checked>
<label for="char_all">All Characters</label>
</div>
<div class="character-list" id="characterList">
<!-- Character checkboxes will be populated by JavaScript -->
</div>
</div>
<div class="main-content">
<form class="search-form" id="inventorySearchForm">
<!-- Row 0: Equipment Type Selection -->
<div class="filter-row">
<div class="filter-group">
<label>Type:</label>
<div style="display: flex; gap: 10px;">
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="armorOnly" value="armor" checked style="margin-right: 3px;">
Armor Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="jewelryOnly" value="jewelry" style="margin-right: 3px;">
Jewelry Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="allItems" value="all" style="margin-right: 3px;">
All Items
</label>
</div>
</div>
</div>
<!-- Row 0.5: Slot Selection -->
<div class="filter-row">
<div class="filter-group">
<label>Slot:</label>
<select id="slotFilter">
<option value="">All Slots</option>
<optgroup label="Armor Slots">
<option value="Head">Head</option>
<option value="Chest">Chest</option>
<option value="Upper Arms">Upper Arms</option>
<option value="Lower Arms">Lower Arms</option>
<option value="Hands">Hands</option>
<option value="Abdomen">Abdomen</option>
<option value="Upper Legs">Upper Legs</option>
<option value="Lower Legs">Lower Legs</option>
<option value="Feet">Feet</option>
<option value="Shield">Shield</option>
</optgroup>
<optgroup label="Jewelry Slots">
<option value="Neck">Neck</option>
<option value="Left Ring">Left Ring</option>
<option value="Right Ring">Right Ring</option>
<option value="Left Wrist">Left Wrist</option>
<option value="Right Wrist">Right Wrist</option>
<option value="Trinket">Trinket</option>
</optgroup>
</select>
</div>
</div>
<!-- Row 1: Basic filters -->
<div class="filter-row">
<div class="filter-group">
<label>Name:</label>
<input type="text" id="searchText" placeholder="Item name">
</div>
<div class="filter-group">
<label>Status:</label>
<select id="searchEquipStatus">
<option value="all">All</option>
<option value="equipped">Equipped</option>
<option value="unequipped">Inventory</option>
</select>
</div>
</div>
<!-- Row 2: Stats -->
<div class="filter-row">
<div class="filter-group">
<label>Armor:</label>
<input type="number" id="searchMinArmor" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxArmor" placeholder="Max">
</div>
<div class="filter-group">
<label>Crit:</label>
<input type="number" id="searchMinCritDamage" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxCritDamage" placeholder="Max">
</div>
<div class="filter-group">
<label>Dmg:</label>
<input type="number" id="searchMinDamageRating" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxDamageRating" placeholder="Max">
</div>
<div class="filter-group">
<label>Heal:</label>
<input type="number" id="searchMinHealBoost" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxHealBoost" placeholder="Max">
</div>
</div>
<!-- Equipment Sets -->
<div class="filter-section">
<label class="section-label">Set:</label>
<div class="checkbox-container" id="equipmentSets">
<div class="checkbox-item">
<input type="checkbox" id="set_14" value="14">
<label for="set_14">Adept's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_16" value="16">
<label for="set_16">Defender's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_13" value="13">
<label for="set_13">Soldier's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_21" value="21">
<label for="set_21">Wise</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_40" value="40">
<label for="set_40">Heroic Protector</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_41" value="41">
<label for="set_41">Heroic Destroyer</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_46" value="46">
<label for="set_46">Relic Alduressa</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_47" value="47">
<label for="set_47">Ancient Relic</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_48" value="48">
<label for="set_48">Noble Relic</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_15" value="15">
<label for="set_15">Archer's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_19" value="19">
<label for="set_19">Hearty</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_20" value="20">
<label for="set_20">Dexterous</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_22" value="22">
<label for="set_22">Swift</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_24" value="24">
<label for="set_24">Reinforced</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_26" value="26">
<label for="set_26">Flame Proof</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_29" value="29">
<label for="set_29">Lightning Proof</label>
</div>
</div>
</div>
<!-- Legendary Cantrips -->
<div class="filter-section">
<label class="section-label">Cantrips:</label>
<div class="checkbox-container" id="cantrips">
<!-- 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="filter-section">
<label class="section-label">Wards:</label>
<div class="checkbox-container" id="protections">
<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>
<div class="search-actions">
<button type="button" class="btn btn-secondary" id="clearBtn">Clear All</button>
<button type="submit" class="btn btn-primary">Search Items</button>
<button type="button" class="btn btn-secondary" id="setAnalysisBtn">Analyze Sets</button>
<button type="button" class="btn btn-secondary" id="slotViewBtn">Slot View</button>
</div>
</form>
<!-- Set Analysis Section -->
<div class="set-analysis-section" id="setAnalysisSection" style="display: none;">
<div class="set-analysis-form">
<h3>Equipment Set Combination Analysis</h3>
<div class="filter-row">
<div class="filter-group">
<label>Primary Set (5 pieces):</label>
<select id="primarySetSelect">
<option value="14">Adept's Set</option>
<option value="16">Defender's Set</option>
<option value="13">Soldier's Set</option>
<option value="21">Wise Set</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>
</select>
</div>
<div class="filter-group">
<label>Secondary Set (4 pieces):</label>
<select id="secondarySetSelect">
<option value="13">Soldier's Set</option>
<option value="14">Adept's Set</option>
<option value="16">Defender's Set</option>
<option value="21">Wise Set</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>
</select>
</div>
<div class="filter-group">
<button type="button" class="btn btn-primary" id="runSetAnalysis">Analyze</button>
<button type="button" class="btn btn-secondary" id="backToSearch">Back to Search</button>
</div>
</div>
</div>
<div class="results-container" id="setAnalysisResults">
<div class="no-results">Select primary and secondary sets above and click "Analyze" to find valid combinations.</div>
</div>
</div>
<!-- Slot View Section -->
<div class="slot-view-section" id="slotViewSection" style="display: none;">
<div class="slot-view-header">
<h3>Equipment Slot View</h3>
<div class="filter-row">
<button type="button" class="btn btn-primary" id="loadSlotView">Load Items</button>
<button type="button" class="btn btn-secondary" id="backToSearchFromSlots">Back to Search</button>
</div>
</div>
<div class="slots-grid" id="slotsGrid">
<!-- Slots will be populated by JavaScript -->
</div>
</div>
<div class="results-container" id="searchResults">
<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>
</div>
</div>
</div>
<script src="inventory.js"></script>
</body>
</html>

722
static/inventory.js Normal file
View file

@ -0,0 +1,722 @@
/**
* Inventory Search Application
* Dedicated JavaScript for the inventory search page
*/
// Configuration - use main app proxy for inventory service
const API_BASE = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:8766' // Local development - direct to inventory service
: `${window.location.origin}/inv`; // Production - through main app proxy
// DOM Elements - will be set after DOM loads
let searchForm, clearBtn, searchResults;
// Sorting state
let currentSort = {
field: 'name',
direction: 'asc'
};
// Store current search results for client-side sorting
let currentResultsData = null;
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
// Get DOM elements after DOM is loaded
searchForm = document.getElementById('inventorySearchForm');
clearBtn = document.getElementById('clearBtn');
searchResults = document.getElementById('searchResults');
initializeEventListeners();
loadCharacterOptions();
});
/**
* Initialize all event listeners
*/
function initializeEventListeners() {
// Form submission
searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
await performSearch();
});
// Clear button
clearBtn.addEventListener('click', clearAllFields);
// Slot filter change
document.getElementById('slotFilter').addEventListener('change', handleSlotFilterChange);
// Set analysis buttons
document.getElementById('setAnalysisBtn').addEventListener('click', showSetAnalysis);
document.getElementById('backToSearch').addEventListener('click', showSearchSection);
document.getElementById('runSetAnalysis').addEventListener('click', performSetAnalysis);
// Checkbox visual feedback for cantrips and equipment sets
document.querySelectorAll('.checkbox-item input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', handleCheckboxChange);
});
}
/**
* Load available characters for the checkbox list
*/
async function loadCharacterOptions() {
try {
// Use inventory service proxy endpoint for character list
const response = await fetch(`${window.location.origin}/inventory-characters`);
const data = await response.json();
if (data.characters && data.characters.length > 0) {
const characterList = document.getElementById('characterList');
// Sort characters by name
data.characters.sort((a, b) => a.character_name.localeCompare(b.character_name));
// Add character checkboxes
data.characters.forEach(char => {
const div = document.createElement('div');
div.className = 'character-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `char_${char.character_name}`;
checkbox.value = char.character_name;
checkbox.className = 'character-checkbox';
checkbox.checked = true; // Check all by default
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = char.character_name;
div.appendChild(checkbox);
div.appendChild(label);
characterList.appendChild(div);
});
// Set up event listeners for character selection
setupCharacterCheckboxes();
}
} catch (error) {
console.warn('Could not load character list:', error);
}
}
/**
* Setup character checkbox functionality
*/
function setupCharacterCheckboxes() {
const allCheckbox = document.getElementById('char_all');
const characterCheckboxes = document.querySelectorAll('.character-checkbox');
// Handle "All Characters" checkbox
allCheckbox.addEventListener('change', function() {
characterCheckboxes.forEach(cb => {
cb.checked = this.checked;
});
});
// Handle individual character checkboxes
characterCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
// If any individual checkbox is unchecked, uncheck "All"
if (!this.checked) {
allCheckbox.checked = false;
} else {
// If all individual checkboxes are checked, check "All"
const allChecked = Array.from(characterCheckboxes).every(checkbox => checkbox.checked);
allCheckbox.checked = allChecked;
}
});
});
}
/**
* Handle checkbox change events for visual feedback
*/
function handleCheckboxChange(e) {
const item = e.target.closest('.checkbox-item');
if (e.target.checked) {
item.classList.add('checked');
} else {
item.classList.remove('checked');
}
}
/**
* Clear all form fields and checkboxes
*/
function clearAllFields() {
searchForm.reset();
// Reset character selection to "All"
document.getElementById('char_all').checked = true;
document.querySelectorAll('.character-checkbox').forEach(cb => {
cb.checked = true;
});
// Clear checkbox visual states
document.querySelectorAll('.checkbox-item').forEach(item => {
item.classList.remove('checked');
});
// Reset equipment type to armor
document.getElementById('armorOnly').checked = true;
// Reset slot filter
document.getElementById('slotFilter').value = '';
// Reset results and clear stored data
currentResultsData = null;
searchResults.innerHTML = '<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>';
}
/**
* Handle slot filter changes
*/
function handleSlotFilterChange() {
// If we have current results, reapply filtering and sorting
if (currentResultsData) {
// Reset items to original unfiltered data
const originalData = JSON.parse(JSON.stringify(currentResultsData));
// Apply slot filtering
applySlotFilter(originalData);
// Apply sorting
sortResults(originalData);
// Display results
displayResults(originalData);
}
}
/**
* Perform the search based on form inputs
*/
async function performSearch() {
searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>';
try {
const params = buildSearchParameters();
const searchUrl = `${API_BASE}/search/items?${params.toString()}`;
console.log('Search URL:', searchUrl);
const response = await fetch(searchUrl);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Search failed');
}
// Store results for client-side re-sorting
currentResultsData = data;
// Apply client-side slot filtering
applySlotFilter(data);
// Sort the results client-side before displaying
sortResults(data);
displayResults(data);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = `<div class="error">❌ Search failed: ${error.message}</div>`;
}
}
/**
* Build search parameters from form inputs
*/
function buildSearchParameters() {
const params = new URLSearchParams();
// Equipment type selection
const equipmentType = document.querySelector('input[name="equipmentType"]:checked').value;
if (equipmentType === 'armor') {
params.append('armor_only', 'true');
} else if (equipmentType === 'jewelry') {
params.append('jewelry_only', 'true');
}
// If 'all' is selected, don't add any type filter
// Basic search parameters - handle character selection
const allCharactersChecked = document.getElementById('char_all').checked;
if (!allCharactersChecked) {
// Get selected characters
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
.map(cb => cb.value);
if (selectedCharacters.length === 1) {
// Single character selected
params.append('character', selectedCharacters[0]);
} else if (selectedCharacters.length > 1) {
// Multiple characters - use comma-separated list
params.append('characters', selectedCharacters.join(','));
} else {
// No characters selected - search nothing
return { items: [], total_count: 0, page: 1, total_pages: 0 };
}
} else {
// All characters selected
params.append('include_all_characters', 'true');
}
addParam(params, 'text', 'searchText');
addParam(params, 'material', 'searchMaterial');
const equipStatus = document.getElementById('searchEquipStatus').value;
if (equipStatus && equipStatus !== 'all') {
params.append('equipment_status', equipStatus);
}
// Armor statistics parameters
addParam(params, 'min_armor', 'searchMinArmor');
addParam(params, 'max_armor', 'searchMaxArmor');
addParam(params, 'min_crit_damage_rating', 'searchMinCritDamage');
addParam(params, 'max_crit_damage_rating', 'searchMaxCritDamage');
addParam(params, 'min_damage_rating', 'searchMinDamageRating');
addParam(params, 'max_damage_rating', 'searchMaxDamageRating');
addParam(params, 'min_heal_boost_rating', 'searchMinHealBoost');
addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost');
// Requirements parameters
addParam(params, 'min_level', 'searchMinLevel');
addParam(params, 'max_level', 'searchMaxLevel');
addParam(params, 'min_workmanship', 'searchMinWorkmanship');
addParam(params, 'max_workmanship', 'searchMaxWorkmanship');
// Value parameters
addParam(params, 'min_value', 'searchMinValue');
addParam(params, 'max_value', 'searchMaxValue');
addParam(params, 'max_burden', 'searchMaxBurden');
// Equipment set filters
const selectedEquipmentSets = getSelectedEquipmentSets();
if (selectedEquipmentSets.length === 1) {
params.append('item_set', selectedEquipmentSets[0]);
} else if (selectedEquipmentSets.length > 1) {
params.append('item_sets', selectedEquipmentSets.join(','));
}
// Cantrip filters
const selectedCantrips = getSelectedCantrips();
const selectedProtections = getSelectedProtections();
const allSpells = [...selectedCantrips, ...selectedProtections];
if (allSpells.length > 0) {
params.append('legendary_cantrips', allSpells.join(','));
}
// Pagination only - sorting will be done client-side
params.append('limit', '1000'); // Show all items on one page
return params;
}
/**
* Helper function to add parameter if value exists
*/
function addParam(params, paramName, elementId) {
const value = document.getElementById(elementId)?.value?.trim();
if (value) {
params.append(paramName, value);
}
}
/**
* Get selected equipment sets
*/
function getSelectedEquipmentSets() {
const selectedSets = [];
document.querySelectorAll('#equipmentSets input[type="checkbox"]:checked').forEach(cb => {
selectedSets.push(cb.value);
});
return selectedSets;
}
/**
* Get selected legendary cantrips
*/
function getSelectedCantrips() {
const selectedCantrips = [];
document.querySelectorAll('#cantrips input[type="checkbox"]:checked').forEach(cb => {
selectedCantrips.push(cb.value);
});
return selectedCantrips;
}
/**
* Get selected protection spells
*/
function getSelectedProtections() {
const selectedProtections = [];
document.querySelectorAll('#protections input[type="checkbox"]:checked').forEach(cb => {
selectedProtections.push(cb.value);
});
return selectedProtections;
}
/**
* Display search results in the UI
*/
function displayResults(data) {
if (!data.items || data.items.length === 0) {
searchResults.innerHTML = '<div class="no-results">No items found matching your search criteria.</div>';
return;
}
const getSortIcon = (field) => {
if (currentSort.field === field) {
return currentSort.direction === 'asc' ? ' ▲' : ' ▼';
}
return '';
};
let html = `
<div class="results-info">
Found <strong>${data.total_count}</strong> items - Showing all results
</div>
<table class="results-table">
<thead>
<tr>
<th class="sortable" data-sort="character_name">Character${getSortIcon('character_name')}</th>
<th class="sortable" data-sort="name">Item Name${getSortIcon('name')}</th>
<th class="sortable" data-sort="item_type_name">Type${getSortIcon('item_type_name')}</th>
<th class="text-right sortable" data-sort="slot_name">Slot${getSortIcon('slot_name')}</th>
<th class="text-right sortable" data-sort="coverage">Coverage${getSortIcon('coverage')}</th>
<th class="text-right sortable" data-sort="armor_level">Armor${getSortIcon('armor_level')}</th>
<th class="sortable" data-sort="spell_names">Spells/Cantrips${getSortIcon('spell_names')}</th>
<th class="text-right sortable" data-sort="crit_damage_rating">Crit Dmg${getSortIcon('crit_damage_rating')}</th>
<th class="text-right sortable" data-sort="damage_rating">Dmg Rating${getSortIcon('damage_rating')}</th>
<th class="text-right sortable" data-sort="last_updated">Last Updated${getSortIcon('last_updated')}</th>
</tr>
</thead>
<tbody>
`;
data.items.forEach((item) => {
const armor = item.armor_level > 0 ? item.armor_level : '-';
const critDmg = item.crit_damage_rating > 0 ? item.crit_damage_rating : '-';
const dmgRating = item.damage_rating > 0 ? item.damage_rating : '-';
const status = item.is_equipped ? '⚔️ Equipped' : '📦 Inventory';
const statusClass = item.is_equipped ? 'status-equipped' : 'status-inventory';
// Use the slot_name provided by the API instead of incorrect mapping
const slot = item.slot_name || 'Unknown';
// Coverage placeholder - will need to be added to backend later
const coverage = item.coverage || '-';
// Format last updated timestamp
const lastUpdated = item.last_updated ?
new Date(item.last_updated).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) : '-';
// Use the formatted name with material from the API
let displayName = item.name;
// The API should already include material in the name, but use material_name if available
if (item.material_name && item.material_name !== '' && !item.name.toLowerCase().includes(item.material_name.toLowerCase())) {
displayName = `${item.material_name} ${item.name}`;
}
// Format spells/cantrips list
let spellsDisplay = '-';
if (item.spell_names && item.spell_names.length > 0) {
// Highlight legendary cantrips in a different color
const formattedSpells = item.spell_names.map(spell => {
if (spell.toLowerCase().includes('legendary')) {
return `<span class="legendary-cantrip">${spell}</span>`;
} else {
return `<span class="regular-spell">${spell}</span>`;
}
});
spellsDisplay = formattedSpells.join('<br>');
}
// Get item type for display
const itemType = item.item_type_name || '-';
html += `
<tr>
<td>${item.character_name}</td>
<td class="item-name">${displayName}</td>
<td>${itemType}</td>
<td class="text-right">${slot}</td>
<td class="text-right">${coverage}</td>
<td class="text-right">${armor}</td>
<td class="spells-cell">${spellsDisplay}</td>
<td class="text-right">${critDmg}</td>
<td class="text-right">${dmgRating}</td>
<td class="text-right">${lastUpdated}</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
// Add pagination info if needed
if (data.total_pages > 1) {
html += `
<div style="padding: 16px 24px; text-align: center; color: #5f6368; border-top: 1px solid #e8eaed;">
Showing page ${data.page} of ${data.total_pages} pages
</div>
`;
}
searchResults.innerHTML = html;
// Add click event listeners to sortable headers
document.querySelectorAll('.sortable').forEach(header => {
header.addEventListener('click', () => {
const sortField = header.getAttribute('data-sort');
handleSort(sortField);
});
});
}
/**
* Apply client-side slot filtering
*/
function applySlotFilter(data) {
const selectedSlot = document.getElementById('slotFilter').value;
if (!selectedSlot || !data.items) {
return; // No filter or no data
}
// Filter items that can be equipped in the selected slot
data.items = data.items.filter(item => {
const slotName = item.slot_name || '';
// Check if the item's slot_name contains the selected slot
// This handles multi-slot items like "Left Ring, Right Ring"
return slotName.includes(selectedSlot);
});
// Update total count
data.total_count = data.items.length;
}
/**
* Sort results client-side based on current sort settings
*/
function sortResults(data) {
if (!data.items || data.items.length === 0) return;
const field = currentSort.field;
const direction = currentSort.direction;
data.items.sort((a, b) => {
let aVal = a[field];
let bVal = b[field];
// Handle null/undefined values
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Special handling for spell_names array
if (field === 'spell_names') {
// Convert arrays to strings for sorting
aVal = Array.isArray(aVal) ? aVal.join(', ').toLowerCase() : '';
bVal = Array.isArray(bVal) ? bVal.join(', ').toLowerCase() : '';
const result = aVal.localeCompare(bVal);
return direction === 'asc' ? result : -result;
}
// Determine if we're sorting numbers or strings
const isNumeric = typeof aVal === 'number' || (!isNaN(aVal) && !isNaN(parseFloat(aVal)));
if (isNumeric) {
// Numeric sorting
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
const result = aVal - bVal;
return direction === 'asc' ? result : -result;
} else {
// String sorting
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
const result = aVal.localeCompare(bVal);
return direction === 'asc' ? result : -result;
}
});
}
/**
* Handle column sorting
*/
function handleSort(field) {
// If clicking the same field, toggle direction
if (currentSort.field === field) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
// New field, default to ascending
currentSort.field = field;
currentSort.direction = 'asc';
}
// Re-display current results with new sorting (no new search needed)
if (currentResultsData) {
// Reset items to original unfiltered data
const originalData = JSON.parse(JSON.stringify(currentResultsData));
// Apply slot filtering first
applySlotFilter(originalData);
// Then apply sorting
sortResults(originalData);
// Display results
displayResults(originalData);
}
}
/**
* Show set analysis section
*/
function showSetAnalysis() {
document.getElementById('setAnalysisSection').style.display = 'block';
document.getElementById('searchResults').style.display = 'none';
searchForm.style.display = 'none';
}
/**
* Show search section
*/
function showSearchSection() {
document.getElementById('setAnalysisSection').style.display = 'none';
document.getElementById('searchResults').style.display = 'block';
searchForm.style.display = 'block';
}
/**
* Perform set combination analysis
*/
async function performSetAnalysis() {
const primarySet = document.getElementById('primarySetSelect').value;
const secondarySet = document.getElementById('secondarySetSelect').value;
const setAnalysisResults = document.getElementById('setAnalysisResults');
if (primarySet === secondarySet) {
setAnalysisResults.innerHTML = '<div class="error">❌ Primary and secondary sets must be different.</div>';
return;
}
setAnalysisResults.innerHTML = '<div class="loading">🔍 Analyzing set combinations...</div>';
try {
const params = new URLSearchParams();
params.append('primary_set', primarySet);
params.append('secondary_set', secondarySet);
params.append('primary_count', '5');
params.append('secondary_count', '4');
// Use selected characters or all characters
const allCharactersChecked = document.getElementById('char_all').checked;
if (allCharactersChecked) {
params.append('include_all_characters', 'true');
} else {
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
.map(cb => cb.value);
if (selectedCharacters.length > 0) {
params.append('characters', selectedCharacters.join(','));
} else {
setAnalysisResults.innerHTML = '<div class="error">❌ Please select at least one character or check "All Characters".</div>';
return;
}
}
const analysisUrl = `${API_BASE}/analyze/sets?${params.toString()}`;
console.log('Set Analysis URL:', analysisUrl);
const response = await fetch(analysisUrl);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Set analysis failed');
}
displaySetAnalysisResults(data);
} catch (error) {
console.error('Set analysis error:', error);
setAnalysisResults.innerHTML = `<div class="error">❌ Set analysis failed: ${error.message}</div>`;
}
}
/**
* Display set analysis results
*/
function displaySetAnalysisResults(data) {
const setAnalysisResults = document.getElementById('setAnalysisResults');
if (!data.character_analysis || data.character_analysis.length === 0) {
setAnalysisResults.innerHTML = '<div class="no-results">No characters found with the selected sets.</div>';
return;
}
let html = `
<div class="results-info">
<strong>${data.primary_set.name}</strong> (${data.primary_set.pieces_needed} pieces) +
<strong>${data.secondary_set.name}</strong> (${data.secondary_set.pieces_needed} pieces)<br>
Found <strong>${data.characters_can_build}</strong> of <strong>${data.total_characters}</strong> characters who can build this combination
</div>
<table class="results-table">
<thead>
<tr>
<th>Character</th>
<th>Can Build?</th>
<th>${data.primary_set.name}</th>
<th>${data.secondary_set.name}</th>
<th>Primary Items</th>
<th>Secondary Items</th>
</tr>
</thead>
<tbody>
`;
data.character_analysis.forEach((char) => {
const canBuild = char.can_build_combination;
const canBuildText = canBuild ? '✅ Yes' : '❌ No';
const canBuildClass = canBuild ? 'status-equipped' : 'status-inventory';
const primaryStatus = `${char.primary_set_available}/${char.primary_set_needed}`;
const secondaryStatus = `${char.secondary_set_available}/${char.secondary_set_needed}`;
// Format item lists
const primaryItems = char.primary_items.map(item =>
`${item.name}${item.equipped ? ' ⚔️' : ''}`
).join('<br>') || '-';
const secondaryItems = char.secondary_items.map(item =>
`${item.name}${item.equipped ? ' ⚔️' : ''}`
).join('<br>') || '-';
html += `
<tr>
<td><strong>${char.character_name}</strong></td>
<td class="${canBuildClass}">${canBuildText}</td>
<td class="text-right">${primaryStatus}</td>
<td class="text-right">${secondaryStatus}</td>
<td class="spells-cell">${primaryItems}</td>
<td class="spells-cell">${secondaryItems}</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
setAnalysisResults.innerHTML = html;
}

View file

@ -1776,4 +1776,20 @@ function openInventorySearch() {
window.open('/inventory.html', '_blank');
}
/**
* Opens the Suitbuilder interface in a new browser tab.
*/
function openSuitbuilder() {
// Open the Suitbuilder page in a new tab
window.open('/suitbuilder.html', '_blank');
}
/**
* Opens the Player Debug interface in a new browser tab.
*/
function openPlayerDebug() {
// Open the Player Debug page in a new tab
window.open('/debug.html', '_blank');
}

View file

@ -1240,6 +1240,62 @@ body.noselect, body.noselect * {
margin: -2px -4px;
}
.suitbuilder-link {
margin: 0 0 12px;
padding: 8px 12px;
background: var(--card);
border: 1px solid #ff6b4a;
border-radius: 4px;
text-align: center;
}
.suitbuilder-link a {
color: #ff6b4a;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
display: block;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.suitbuilder-link a:hover {
color: #fff;
background: rgba(255, 107, 74, 0.1);
border-radius: 2px;
padding: 2px 4px;
margin: -2px -4px;
}
.debug-link {
margin: 0 0 12px;
padding: 8px 12px;
background: var(--card);
border: 1px solid #4aff6b;
border-radius: 4px;
text-align: center;
}
.debug-link a {
color: #4aff6b;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
display: block;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.debug-link a:hover {
color: #fff;
background: rgba(74, 255, 107, 0.1);
border-radius: 2px;
padding: 2px 4px;
margin: -2px -4px;
}
/* Sortable column styles for inventory tables */
.sortable {
cursor: pointer;