// Enhanced Map Editor with Zoom, Pan, Connections, and Live Stats let currentLocations = []; let availableNPCs = []; let availableItems = []; let availableInteractables = []; let selectedLocationId = null; let selectedNPCId = null; let selectedItemId = null; let selectedInteractableId = null; let connections = []; let liveStats = { players: {}, enemies: {} }; let canvas, ctx; let currentTab = 'locations'; // Helper for i18n display function getI18nDisplay(val) { if (typeof val === 'object' && val !== null) { return val.en || val.es || ''; } return val || ''; } // View state let scale = 50; let minScale = 10; let maxScale = 200; let offsetX = 0; let offsetY = 0; let isDragging = false; let dragStartX = 0; let dragStartY = 0; let clickStartTime = 0; let clickStartPos = { x: 0, y: 0 }; // Live stats refresh let liveStatsInterval = null; // Check authentication on load window.addEventListener('DOMContentLoaded', async () => { const response = await fetch('/api/check-auth'); const data = await response.json(); if (data.authenticated) { showEditor(); } else { document.getElementById('loginContainer').style.display = 'flex'; } }); // Ensure canvas is properly sized after everything loads window.addEventListener('load', () => { if (canvas) { setTimeout(resizeCanvas, 100); } }); // Login form handler document.getElementById('loginForm').addEventListener('submit', async (e) => { e.preventDefault(); const password = document.getElementById('password').value; try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); const data = await response.json(); if (data.success) { showEditor(); } else { showError(data.message); } } catch (error) { showError('Login failed: ' + error.message); } }); function showError(message) { const errorDiv = document.getElementById('loginError'); errorDiv.textContent = message; errorDiv.style.display = 'block'; } function showSuccess(message) { const successDiv = document.getElementById('saveSuccess'); successDiv.textContent = message; successDiv.style.display = 'block'; setTimeout(() => { successDiv.style.display = 'none'; }, 3000); } async function showEditor() { document.getElementById('loginContainer').style.display = 'none'; document.getElementById('editorContainer').style.display = 'flex'; // Initialize canvas canvas = document.getElementById('editorCanvas'); ctx = canvas.getContext('2d'); // Load data await loadLocations(); await loadConnections(); await loadAvailableNPCs(); await loadAvailableInteractablesForLocations(); // Load interactables for location instances await loadAvailableItemsForLocations(); // Load items for interactable rewards await loadLiveStats(); // Resize canvas after everything is loaded and rendered setTimeout(() => { resizeCanvas(); window.addEventListener('resize', resizeCanvas); }, 100); // Start live stats refresh (every 5 seconds) liveStatsInterval = setInterval(loadLiveStats, 5000); // Draw map drawMap(); // Canvas event handlers canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseup', handleMouseUp); canvas.addEventListener('wheel', handleWheel, { passive: false }); canvas.addEventListener('mouseleave', () => { isDragging = false; }); } function resizeCanvas() { canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; drawMap(); } async function loadLocations() { try { const response = await fetch('/api/editor/locations', { credentials: 'same-origin' }); const data = await response.json(); currentLocations = data.locations; renderLocationList(); // Trigger resize after locations are rendered setTimeout(resizeCanvas, 50); } catch (error) { console.error('Failed to load locations:', error); } } async function loadConnections() { try { const response = await fetch('/api/editor/connections', { credentials: 'same-origin' }); const data = await response.json(); connections = data.connections; console.log('Loaded connections:', connections.length); } catch (error) { console.error('Failed to load connections:', error); } } async function loadAvailableNPCs() { try { const response = await fetch('/api/editor/available-npcs', { credentials: 'same-origin' }); const data = await response.json(); availableNPCs = data.npcs; } catch (error) { console.error('Failed to load NPCs:', error); } } async function loadLiveStats() { try { const response = await fetch('/api/editor/live-stats', { credentials: 'same-origin' }); if (!response.ok) { console.error('Live stats request failed:', response.status); return; } const data = await response.json(); liveStats.players = data.players_by_location || {}; liveStats.enemies = data.enemies_by_location || {}; drawMap(); // Redraw to show updated stats } catch (error) { console.error('Failed to load live stats:', error); } } function renderLocationList() { const list = document.getElementById('locationList'); list.innerHTML = ''; currentLocations.forEach(location => { const item = document.createElement('div'); item.className = 'location-item'; const displayName = getI18nDisplay(location.name); item.dataset.name = displayName; item.dataset.id = location.id; if (location.id === selectedLocationId) { item.classList.add('active'); } const playerCount = liveStats.players[location.id] || 0; const enemyCount = liveStats.enemies[location.id] || 0; item.innerHTML = `
${displayName}
📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}
${playerCount > 0 || enemyCount > 0 ? `
👥 ${playerCount} | 👹 ${enemyCount}
` : ''} `; item.onclick = () => selectLocation(location.id); list.appendChild(item); }); } async function selectLocation(locationId) { selectedLocationId = locationId; renderLocationList(); // Load full location details try { const response = await fetch(`/api/editor/location/${locationId}`); const location = await response.json(); populateForm(location); } catch (error) { console.error('Failed to load location details:', error); } drawMap(); } function populateForm(location) { document.getElementById('noSelectionMessage').classList.add('hidden'); document.getElementById('propertiesForm').classList.remove('hidden'); document.getElementById('locationId').value = location.id; // Handle i18n name const name = location.name || ''; if (typeof name === 'object') { document.getElementById('locationName_en').value = name.en || ''; document.getElementById('locationName_es').value = name.es || ''; } else { document.getElementById('locationName_en').value = name; document.getElementById('locationName_es').value = ''; } // Handle i18n description const desc = location.description || ''; if (typeof desc === 'object') { document.getElementById('locationDescription_en').value = desc.en || ''; document.getElementById('locationDescription_es').value = desc.es || ''; } else { document.getElementById('locationDescription_en').value = desc; document.getElementById('locationDescription_es').value = ''; } document.getElementById('locationX').value = location.x; document.getElementById('locationY').value = location.y; document.getElementById('dangerLevel').value = location.danger_level; document.getElementById('encounterRate').value = location.encounter_rate; document.getElementById('wanderingChance').value = location.wandering_chance; document.getElementById('imagePath').value = location.image_path || ''; // Update image preview updateImagePreview(location.image_path); // Render spawn list renderSpawnList(location.spawn_npcs); // Render interactables list renderInteractablesList(location.interactables || {}); // Render connections renderConnectionsList(location.id); } function updateImagePreview(imagePath) { const preview = document.getElementById('imagePreview'); if (imagePath) { preview.innerHTML = `Location image`; } else { preview.innerHTML = 'No image'; } } function renderSpawnList(spawns) { const list = document.getElementById('spawnList'); list.innerHTML = ''; spawns.forEach((spawn, index) => { const item = document.createElement('div'); item.className = 'spawn-item'; item.innerHTML = `
${spawn.emoji} ${spawn.name}
Weight: ${spawn.weight}
`; list.appendChild(item); }); } function renderConnectionsList(locationId) { const list = document.getElementById('connectionList'); if (!list) { console.error('connectionList element not found!'); return; } list.innerHTML = ''; // Get connections from the global connections array const locationConnections = connections.filter(conn => conn.from === locationId); if (!locationConnections || locationConnections.length === 0) { list.innerHTML = '
No connections
'; return; } locationConnections.forEach(conn => { const destLocation = currentLocations.find(l => l.id === conn.to); if (destLocation) { const item = document.createElement('div'); item.className = 'connection-item'; item.innerHTML = `
${getDirectionEmoji(conn.direction)} ${conn.direction}
→ ${destLocation.name}
`; list.appendChild(item); } }); } function getDirectionEmoji(direction) { const emojiMap = { 'north': '⬆️', 'south': '⬇️', 'east': '➡️', 'west': '⬅️', 'northeast': '↗️', 'northwest': '↖️', 'southeast': '↘️', 'southwest': '↙️', 'up': '⬆️', 'down': '⬇️', 'inside': '🚪', 'outside': '🚪' }; return emojiMap[direction.toLowerCase()] || '🔀'; } // ==================== CANVAS DRAWING ==================== function drawMap() { if (!ctx) return; // Clear canvas ctx.fillStyle = '#0f0f1e'; ctx.fillRect(0, 0, canvas.width, canvas.height); const centerX = canvas.width / 2 + offsetX; const centerY = canvas.height / 2 + offsetY; // Draw grid drawGrid(centerX, centerY); // Draw connections first (under locations) drawConnections(centerX, centerY); // Draw locations drawLocations(centerX, centerY); // Draw zoom indicator ctx.fillStyle = '#e0e0e0'; ctx.font = '12px monospace'; ctx.textAlign = 'left'; ctx.fillText(`Zoom: ${Math.round(scale)}px/unit`, 10, 20); } function drawGrid(centerX, centerY) { ctx.strokeStyle = '#1a1a3e'; ctx.lineWidth = 1; // Vertical lines for (let x = centerX % scale; x < canvas.width; x += scale) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } // Horizontal lines for (let y = centerY % scale; y < canvas.height; y += scale) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } // Draw axes ctx.strokeStyle = '#3a3a6a'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(centerX, 0); ctx.lineTo(centerX, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(canvas.width, centerY); ctx.stroke(); } function drawConnections(centerX, centerY) { console.log('Drawing connections, total:', connections.length); connections.forEach(conn => { const fromLoc = currentLocations.find(l => l.id === conn.from); const toLoc = currentLocations.find(l => l.id === conn.to); if (fromLoc && toLoc) { const x1 = centerX + fromLoc.x * scale; const y1 = centerY - fromLoc.y * scale; const x2 = centerX + toLoc.x * scale; const y2 = centerY - toLoc.y * scale; // Draw arrow ctx.strokeStyle = '#00bcd4'; // Cyan color to avoid confusion with axes ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); // Draw arrowhead const angle = Math.atan2(y2 - y1, x2 - x1); const headLength = 10; ctx.strokeStyle = '#00bcd4'; // Match arrow color ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo( x2 - headLength * Math.cos(angle - Math.PI / 6), y2 - headLength * Math.sin(angle - Math.PI / 6) ); ctx.moveTo(x2, y2); ctx.lineTo( x2 - headLength * Math.cos(angle + Math.PI / 6), y2 - headLength * Math.sin(angle + Math.PI / 6) ); ctx.stroke(); // Calculate positions for labels const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; // Position "from" direction closer to origin (25% along the line) const fromLabelX = x1 + (x2 - x1) * 0.25; const fromLabelY = y1 + (y2 - y1) * 0.25; // Position cost in the middle const costX = midX; const costY = midY; // Position "to" direction closer to destination (75% along the line) const toLabelX = x1 + (x2 - x1) * 0.75; const toLabelY = y1 + (y2 - y1) * 0.75; // Calculate perpendicular offset for text positioning const perpAngle = angle + Math.PI / 2; const textOffset = 8; ctx.fillStyle = '#8a8aaa'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; // Check if there's a reverse connection const reverseConn = connections.find(c => c.from === conn.to && c.to === conn.from); if (reverseConn) { // If bidirectional, offset labels to avoid overlap // "from" direction on one side ctx.fillText( conn.direction, fromLabelX + Math.cos(perpAngle) * textOffset, fromLabelY + Math.sin(perpAngle) * textOffset ); // "to" direction (reverse) on the other side ctx.fillText( reverseConn.direction, toLabelX - Math.cos(perpAngle) * textOffset, toLabelY - Math.sin(perpAngle) * textOffset ); } else { // Single direction - center the label ctx.fillText(conn.direction, fromLabelX, fromLabelY - 5); } // Draw distance/cost in the middle const distance = Math.sqrt(Math.pow(toLoc.x - fromLoc.x, 2) + Math.pow(toLoc.y - fromLoc.y, 2)); const cost = conn.stamina_cost || Math.ceil(distance * 2); // Use actual cost if available ctx.fillStyle = '#ffb74d'; ctx.fillText(`⚡${cost}`, costX, costY + 3); } }); } function drawLocations(centerX, centerY) { const dangerColors = ['#4caf50', '#8bc34a', '#ffa726', '#ff5722', '#d32f2f']; currentLocations.forEach(location => { const x = centerX + location.x * scale; const y = centerY - location.y * scale; // Draw location circle const color = dangerColors[location.danger_level] || '#9e9e9e'; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 15, 0, Math.PI * 2); ctx.fill(); // Highlight selected if (location.id === selectedLocationId) { ctx.strokeStyle = '#ffa726'; ctx.lineWidth = 3; ctx.stroke(); } // Draw label ctx.fillStyle = '#e0e0e0'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(getI18nDisplay(location.name), x, y + 30); // Draw live stats badges const playerCount = liveStats.players[location.id] || 0; const enemyCount = liveStats.enemies[location.id] || 0; let badgeY = y - 20; if (playerCount > 0) { drawBadge(x - 10, badgeY, `👥${playerCount}`, '#2196f3'); badgeY -= 15; } if (enemyCount > 0) { drawBadge(x + 10, badgeY, `👹${enemyCount}`, '#f44336'); } }); } function drawBadge(x, y, text, color) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 12, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, x, y); ctx.textBaseline = 'alphabetic'; } // ==================== MOUSE INTERACTION ==================== function handleMouseDown(e) { const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; clickStartTime = Date.now(); clickStartPos = { x: mouseX, y: mouseY }; dragStartX = mouseX - offsetX; dragStartY = mouseY - offsetY; } function handleMouseMove(e) { if (e.buttons === 1) { // Left mouse button const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // Check if moved enough to be a drag const dx = mouseX - clickStartPos.x; const dy = mouseY - clickStartPos.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > 5) { isDragging = true; canvas.style.cursor = 'grabbing'; offsetX = mouseX - dragStartX; offsetY = mouseY - dragStartY; drawMap(); } } } function handleMouseUp(e) { const clickDuration = Date.now() - clickStartTime; if (!isDragging && clickDuration < 300) { // This was a click, not a drag handleCanvasClick(e); } isDragging = false; canvas.style.cursor = 'crosshair'; } function handleWheel(e) { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // Get world position before zoom const centerX = canvas.width / 2 + offsetX; const centerY = canvas.height / 2 + offsetY; const worldX = (mouseX - centerX) / scale; const worldY = -(mouseY - centerY) / scale; // Zoom const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; const newScale = Math.max(minScale, Math.min(maxScale, scale * zoomFactor)); if (newScale !== scale) { // Adjust offset to keep mouse position stable const newCenterX = canvas.width / 2 + offsetX; const newCenterY = canvas.height / 2 + offsetY; const newScreenX = newCenterX + worldX * newScale; const newScreenY = newCenterY - worldY * newScale; offsetX += mouseX - newScreenX; offsetY += mouseY - newScreenY; scale = newScale; drawMap(); } } function handleCanvasClick(e) { const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; const centerX = canvas.width / 2 + offsetX; const centerY = canvas.height / 2 + offsetY; // Check if clicked on existing location for (const location of currentLocations) { const screenX = centerX + location.x * scale; const screenY = centerY - location.y * scale; const distance = Math.sqrt(Math.pow(clickX - screenX, 2) + Math.pow(clickY - screenY, 2)); if (distance < 15) { selectLocation(location.id); return; } } // Clicked on empty space - create new location const worldX = ((clickX - centerX) / scale).toFixed(1); const worldY = (-(clickY - centerY) / scale).toFixed(1); if (confirm(`Create new location at (${worldX}, ${worldY})?`)) { createLocationAt(parseFloat(worldX), parseFloat(worldY)); } } // ==================== ZOOM CONTROLS ==================== function zoomIn() { scale = Math.min(maxScale, scale * 1.2); drawMap(); } function zoomOut() { scale = Math.max(minScale, scale / 1.2); drawMap(); } function resetView() { scale = 50; offsetX = 0; offsetY = 0; drawMap(); } // ==================== LOCATION MANAGEMENT ==================== function createLocationAt(x, y) { const newId = 'location_' + Date.now(); const newLocation = { id: newId, name: 'New Location', description: 'Enter description...', image_path: '', x: x, y: y, danger_level: 0, encounter_rate: 0.0, wandering_chance: 0.0, spawn_npcs: [] }; currentLocations.push(newLocation); selectedLocationId = newId; renderLocationList(); populateForm(newLocation); drawMap(); } function createNewLocation() { createLocationAt(0, 0); } async function saveLocation() { const locationData = { id: document.getElementById('locationId').value, name: { en: document.getElementById('locationName_en').value, es: document.getElementById('locationName_es').value }, description: { en: document.getElementById('locationDescription_en').value, es: document.getElementById('locationDescription_es').value }, x: parseFloat(document.getElementById('locationX').value), y: parseFloat(document.getElementById('locationY').value), danger_level: parseInt(document.getElementById('dangerLevel').value), encounter_rate: parseFloat(document.getElementById('encounterRate').value), wandering_chance: parseFloat(document.getElementById('wanderingChance').value), image_path: document.getElementById('imagePath').value, spawn_npcs: getCurrentSpawns(), interactables: getInteractableInstances() }; try { const response = await fetch('/api/editor/location', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(locationData) }); const data = await response.json(); if (data.success) { showSuccess('Location saved successfully!'); await loadLocations(); await loadConnections(); drawMap(); } else { alert('Failed to save: ' + data.error); } } catch (error) { alert('Failed to save location: ' + error.message); } } async function deleteCurrentLocation() { const locationId = document.getElementById('locationId').value; if (!confirm(`Delete location "${locationId}"? This will also remove all connections to/from this location.`)) { return; } try { const response = await fetch(`/api/editor/location/${locationId}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { showSuccess('Location deleted successfully!'); selectedLocationId = null; document.getElementById('noSelectionMessage').classList.remove('hidden'); document.getElementById('propertiesForm').classList.add('hidden'); await loadLocations(); await loadConnections(); drawMap(); } else { alert('Failed to delete: ' + data.error); } } catch (error) { alert('Failed to delete location: ' + error.message); } } // Wrapper functions for header buttons function saveCurrentLocation() { if (!selectedLocationId) { alert('Please select a location first'); return; } saveLocation(); } // ==================== CONNECTION MANAGEMENT ==================== function showAddConnectionModal() { const modal = document.getElementById('addConnectionModal'); const searchInput = document.getElementById('connectionSearch'); // Clear search searchInput.value = ''; // Populate the list filterConnectionList(); modal.style.display = 'flex'; searchInput.focus(); } function filterConnectionList() { const list = document.getElementById('connectionTargetList'); const searchTerm = document.getElementById('connectionSearch').value.toLowerCase(); const currentId = document.getElementById('locationId').value; list.innerHTML = ''; const filteredLocations = currentLocations.filter(location => { if (location.id === currentId) return false; if (!searchTerm) return true; return location.name.toLowerCase().includes(searchTerm) || location.id.toLowerCase().includes(searchTerm); }); if (filteredLocations.length === 0) { list.innerHTML = '
No locations found
'; return; } filteredLocations.forEach(location => { const item = document.createElement('div'); item.className = 'connection-target-item'; item.innerHTML = `
${location.name}
📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}
`; item.onclick = () => promptAddConnection(currentId, location.id); list.appendChild(item); }); } function closeAddConnectionModal() { document.getElementById('addConnectionModal').style.display = 'none'; } function filterLocationList() { const searchTerm = document.getElementById('locationSearchInput').value.toLowerCase(); const locationList = document.getElementById('locationList'); const items = locationList.getElementsByClassName('location-item'); Array.from(items).forEach(item => { const name = item.dataset.name ? item.dataset.name.toLowerCase() : ''; const id = item.dataset.id ? item.dataset.id.toLowerCase() : ''; if (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) { item.style.display = ''; } else { item.style.display = 'none'; } }); } function filterNPCList() { const searchTerm = document.getElementById('npcSearch').value.toLowerCase(); const list = document.getElementById('npcSelectList'); const items = list.getElementsByClassName('npc-item'); Array.from(items).forEach(item => { const name = item.dataset.name ? item.dataset.name.toLowerCase() : ''; const id = item.dataset.id ? item.dataset.id.toLowerCase() : ''; if (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) { item.style.display = ''; } else { item.style.display = 'none'; } }); } // Store connection data temporarily let pendingConnection = null; function promptAddConnection(fromId, toId) { // Store the connection data pendingConnection = { fromId, toId }; // Get location names const fromLocation = currentLocations.find(loc => loc.id === fromId); const toLocation = currentLocations.find(loc => loc.id === toId); // Update modal document.getElementById('fromLocationName').textContent = fromLocation ? fromLocation.name : fromId; document.getElementById('toLocationName').textContent = toLocation ? toLocation.name : toId; // Reset selection document.getElementById('directionSelect').value = ''; document.getElementById('autoReverseConnection').checked = true; // Close location list modal and open direction modal closeAddConnectionModal(); document.getElementById('selectDirectionModal').style.display = 'flex'; // Update reverse connection text based on selection updateReverseConnectionText(); } function updateReverseConnectionText() { const directionSelect = document.getElementById('directionSelect'); const reverseText = document.getElementById('reverseConnectionText'); const checkbox = document.getElementById('autoReverseConnection'); if (!directionSelect || !reverseText || !checkbox) return; const direction = directionSelect.value; if (!direction) { reverseText.textContent = 'Will also create a connection back from destination to source'; return; } const reverseDirection = getReverseDirection(direction); const fromLocation = currentLocations.find(loc => loc.id === pendingConnection.fromId); const toLocation = currentLocations.find(loc => loc.id === pendingConnection.toId); if (checkbox.checked) { reverseText.innerHTML = `Will create: ${toLocation?.name || 'destination'} → ${reverseDirection} → ${fromLocation?.name || 'source'}`; } else { reverseText.textContent = 'Only one-way connection will be created'; } } // Listen to direction changes if (document.getElementById('directionSelect')) { document.getElementById('directionSelect').addEventListener('change', updateReverseConnectionText); document.getElementById('autoReverseConnection').addEventListener('change', updateReverseConnectionText); } function getReverseDirection(direction) { const reverseMap = { 'north': 'south', 'south': 'north', 'east': 'west', 'west': 'east', 'northeast': 'southwest', 'northwest': 'southeast', 'southeast': 'northwest', 'southwest': 'northeast', 'up': 'down', 'down': 'up', 'inside': 'outside', 'outside': 'inside' }; return reverseMap[direction] || direction; } function closeSelectDirectionModal() { document.getElementById('selectDirectionModal').style.display = 'none'; pendingConnection = null; } async function confirmAddConnection() { if (!pendingConnection) return; const direction = document.getElementById('directionSelect').value; const autoReverse = document.getElementById('autoReverseConnection').checked; if (!direction) { alert('Please select a direction'); return; } const { fromId, toId } = pendingConnection; try { // Add the main connection const response = await fetch('/api/editor/connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: fromId, to: toId, direction: direction.toLowerCase() }) }); const data = await response.json(); if (!data.success) { alert('Failed to add connection: ' + data.error); return; } // Add reverse connection if enabled if (autoReverse) { const reverseDirection = getReverseDirection(direction); const reverseResponse = await fetch('/api/editor/connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: toId, to: fromId, direction: reverseDirection }) }); const reverseData = await reverseResponse.json(); if (reverseData.success) { showSuccess(`Connection added! (bidirectional: ${direction} ⇄ ${reverseDirection})`); } else { showSuccess(`Connection added (${direction}), but reverse failed: ${reverseData.error}`); } } else { showSuccess(`Connection added! (one-way: ${direction})`); } // Refresh the view await loadConnections(); await selectLocation(fromId); // Refresh form drawMap(); closeSelectDirectionModal(); } catch (error) { alert('Failed to add connection: ' + error.message); } } // Store pending deletion data let pendingDeletion = null; function deleteConnection(fromId, toId) { // Get location information const fromLocation = currentLocations.find(loc => loc.id === fromId); const toLocation = currentLocations.find(loc => loc.id === toId); if (!fromLocation || !toLocation) { alert('Location not found'); return; } // Find the direction of this connection const direction = Object.entries(fromLocation.exits || {}).find(([dir, dest]) => dest === toId)?.[0]; // Check if reverse connection exists const reverseDirection = getReverseDirection(direction); const hasReverseConnection = toLocation.exits && toLocation.exits[reverseDirection] === fromId; // Store deletion data pendingDeletion = { fromId, toId, direction, hasReverseConnection, reverseDirection }; // Update modal content document.getElementById('deleteFromLocationName').textContent = fromLocation.name; document.getElementById('deleteToLocationName').textContent = toLocation.name; document.getElementById('deleteDirectionText').textContent = direction || 'unknown'; // Show/hide reverse connection option const reverseOption = document.getElementById('deleteReverseOption'); if (hasReverseConnection) { reverseOption.style.display = 'block'; document.getElementById('deleteReverseText').innerHTML = `Also delete: ${toLocation.name} → ${reverseDirection} → ${fromLocation.name}`; document.getElementById('deleteBothConnections').checked = true; } else { reverseOption.style.display = 'none'; document.getElementById('deleteBothConnections').checked = false; } // Show modal document.getElementById('deleteConnectionModal').style.display = 'flex'; } function closeDeleteConnectionModal() { document.getElementById('deleteConnectionModal').style.display = 'none'; pendingDeletion = null; } async function confirmDeleteConnection() { if (!pendingDeletion) return; const { fromId, toId, hasReverseConnection, reverseDirection } = pendingDeletion; const deleteBoth = document.getElementById('deleteBothConnections').checked; try { // Delete the main connection const response = await fetch('/api/editor/connection', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: fromId, to: toId }) }); const data = await response.json(); if (!data.success) { alert('Failed to delete connection: ' + data.error); return; } // Delete reverse connection if requested and exists if (deleteBoth && hasReverseConnection) { const reverseResponse = await fetch('/api/editor/connection', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: toId, to: fromId }) }); const reverseData = await reverseResponse.json(); if (reverseData.success) { showSuccess('Both connections deleted!'); } else { showSuccess(`Connection deleted, but reverse failed: ${reverseData.error}`); } } else { showSuccess('Connection deleted!'); } // Refresh the view await loadConnections(); await selectLocation(fromId); // Refresh form drawMap(); closeDeleteConnectionModal(); } catch (error) { alert('Failed to delete connection: ' + error.message); } } // ==================== SPAWN MANAGEMENT ==================== function getCurrentSpawns() { const spawns = []; const spawnItems = document.querySelectorAll('.spawn-item'); spawnItems.forEach(item => { const nameDiv = item.querySelector('.spawn-item-name'); const weightDiv = item.querySelector('.spawn-item-weight'); if (nameDiv && weightDiv) { const text = nameDiv.textContent; const npcName = text.substring(text.indexOf(' ') + 1).trim(); const weight = parseInt(weightDiv.textContent.replace('Weight: ', '')); const npc = availableNPCs.find(n => n.name === npcName); if (npc) { spawns.push({ npc_id: npc.id, weight: weight }); } } }); return spawns; } function showAddSpawnModal() { const modal = document.getElementById('addSpawnModal'); const list = document.getElementById('npcSelectList'); list.innerHTML = ''; // Clear search input document.getElementById('npcSearch').value = ''; availableNPCs.forEach(npc => { const item = document.createElement('div'); item.className = 'npc-item npc-select-item'; item.dataset.name = npc.name; item.dataset.id = npc.id; item.innerHTML = `
${npc.emoji} ${npc.name}
HP: ${npc.hp_range[0]}-${npc.hp_range[1]} | DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} | XP: ${npc.xp_reward}
`; item.onclick = () => addSpawn(npc); list.appendChild(item); }); modal.style.display = 'flex'; // Focus the search input setTimeout(() => document.getElementById('npcSearch').focus(), 100); } function closeAddSpawnModal() { document.getElementById('addSpawnModal').style.display = 'none'; } function addSpawn(npc) { const weight = prompt(`Enter spawn weight for ${npc.name}:`, '50'); if (weight && !isNaN(weight)) { const list = document.getElementById('spawnList'); const item = document.createElement('div'); item.className = 'spawn-item'; item.innerHTML = `
${npc.emoji} ${npc.name}
Weight: ${weight}
`; list.appendChild(item); } closeAddSpawnModal(); } function removeSpawn(index) { const spawnItems = document.querySelectorAll('.spawn-item'); if (spawnItems[index]) { spawnItems[index].remove(); } } // ==================== INTERACTABLE INSTANCES ==================== let currentEditingInteractableInstanceId = null; async function showAddInteractableModal() { const modal = document.getElementById('addInteractableModal'); const list = document.getElementById('interactableSelectList'); list.innerHTML = '
Loading interactables...
'; modal.style.display = 'flex'; // Clear search input document.getElementById('interactableSearch').value = ''; console.log('Current availableInteractables:', availableInteractables); // Ensure interactables are loaded if (!availableInteractables || availableInteractables.length === 0) { console.log('Loading interactables...'); await loadAvailableInteractablesForLocations(); } // Clear the loading message list.innerHTML = ''; console.log('After loading, availableInteractables:', availableInteractables); // Check again after loading if (!availableInteractables || availableInteractables.length === 0) { console.log('No interactables found after loading'); list.innerHTML = '
No interactables available. Create one in the Interactables tab first.
'; return; } console.log(`Rendering ${availableInteractables.length} interactables`); availableInteractables.forEach(interactable => { const item = document.createElement('div'); item.className = 'npc-item npc-select-item'; item.dataset.name = interactable.name; item.dataset.id = interactable.id; const actionCount = Object.keys(interactable.actions || {}).length; item.innerHTML = `
${interactable.name}
${interactable.description}
${actionCount} action(s)
`; item.onclick = () => addInteractableToLocation(interactable); list.appendChild(item); }); // Focus the search input setTimeout(() => document.getElementById('interactableSearch').focus(), 100); } function closeAddInteractableModal() { document.getElementById('addInteractableModal').style.display = 'none'; } function filterInteractableSelectList() { const search = document.getElementById('interactableSearch').value.toLowerCase(); const items = document.querySelectorAll('#interactableSelectList .npc-select-item'); items.forEach(item => { const name = item.dataset.name.toLowerCase(); if (name.includes(search)) { item.style.display = 'flex'; } else { item.style.display = 'none'; } }); } function addInteractableToLocation(interactable) { // Generate a unique instance ID const instanceId = `${interactable.id}_${Date.now()}`; // Create instance with default outcomes const instance = { template_id: interactable.id, outcomes: {} }; // Initialize default outcomes for each action // Actions is an object, not an array for (const [actionId, action] of Object.entries(interactable.actions || {})) { instance.outcomes[actionId] = { success_rate: 0.5, stamina_cost: action.stamina_cost || 0, text: { success: `You successfully ${action.label.toLowerCase()}.`, failure: `You failed to ${action.label.toLowerCase()}.` }, rewards: { items: [], damage: 0 } }; } // Add to UI list const list = document.getElementById('interactablesList'); const item = document.createElement('div'); item.className = 'spawn-item'; item.dataset.instanceId = instanceId; const actionCount = Object.keys(interactable.actions || {}).length; item.innerHTML = `
${interactable.name}
${actionCount} action(s)
`; // Store instance data item.dataset.instanceData = JSON.stringify(instance); list.appendChild(item); closeAddInteractableModal(); } function removeInteractableInstance(instanceId) { const items = document.querySelectorAll('.spawn-item'); items.forEach(item => { if (item.dataset.instanceId === instanceId) { item.remove(); } }); } function editInteractableInstance(instanceId, templateId) { currentEditingInteractableInstanceId = instanceId; // Find the instance element const items = document.querySelectorAll('.spawn-item'); let instanceElement = null; items.forEach(item => { if (item.dataset.instanceId === instanceId) { instanceElement = item; } }); if (!instanceElement) return; const instanceData = JSON.parse(instanceElement.dataset.instanceData); const template = availableInteractables.find(i => i.id === templateId); if (!template) { alert('Template not found!'); return; } // Build the editor UI const editor = document.getElementById('interactableInstanceEditor'); editor.innerHTML = `

${getI18nDisplay(template.name)}

${getI18nDisplay(template.description)}

${Object.entries(template.actions || {}).map(([actionId, action]) => { const outcome = instanceData.outcomes[actionId] || {}; return `

Action: ${action.label}

Item (type to search)
Quantity
Chance (0-1)
${(outcome.rewards?.items || []).map((reward, idx) => { const selectedItem = availableItems.find(i => i.id === reward.item_id); const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
`; }).join('')}
Item (type to search)
Quantity
Chance (0-1)
${(outcome.rewards?.crit_items || []).map((reward, idx) => { const selectedItem = availableItems.find(i => i.id === reward.item_id); const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
`; }).join('')}
`; }).join('')}
`; document.getElementById('editInteractableInstanceModal').style.display = 'flex'; } function closeEditInteractableInstanceModal() { document.getElementById('editInteractableInstanceModal').style.display = 'none'; currentEditingInteractableInstanceId = null; } function addRewardRow(actionId) { const container = document.getElementById(`item_rewards_${actionId}`); const div = document.createElement('div'); div.className = 'reward-item'; div.style.display = 'flex'; div.style.gap = '10px'; div.style.marginBottom = '5px'; div.style.alignItems = 'center'; div.style.position = 'relative'; div.innerHTML = `
`; container.appendChild(div); } function addCritRewardRow(actionId) { const container = document.getElementById(`crit_item_rewards_${actionId}`); const div = document.createElement('div'); div.className = 'reward-item'; div.style.display = 'flex'; div.style.gap = '10px'; div.style.marginBottom = '5px'; div.style.alignItems = 'center'; div.style.position = 'relative'; div.innerHTML = `
`; container.appendChild(div); } // Autocomplete functions for item selection function showItemDropdown(input) { filterItemDropdown(input); } function filterItemDropdown(input) { const searchText = input.value.toLowerCase(); const dropdown = input.parentElement.querySelector('.item-dropdown'); const hiddenInput = input.parentElement.querySelector('.reward-item-id'); if (!dropdown) return; // Filter items const filteredItems = availableItems.filter(item => { const itemText = `${item.emoji || ''} ${getI18nDisplay(item.name)} ${item.id}`.toLowerCase(); return itemText.includes(searchText); }); // Build dropdown HTML dropdown.innerHTML = ''; dropdown.style.display = 'block'; if (filteredItems.length === 0) { dropdown.innerHTML = '
No items found
'; return; } // Create elements dynamically with proper event listeners filteredItems.forEach(item => { const optionDiv = document.createElement('div'); optionDiv.className = 'dropdown-item'; optionDiv.style.padding = '8px'; optionDiv.style.cursor = 'pointer'; optionDiv.style.borderBottom = '1px solid #2a2a4a'; optionDiv.innerHTML = ` ${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} ${item.id} `; // Add hover effects optionDiv.addEventListener('mouseover', function () { this.style.background = '#2a2a4a'; }); optionDiv.addEventListener('mouseout', function () { this.style.background = 'transparent'; }); // Add click handler optionDiv.addEventListener('click', function (e) { e.stopPropagation(); // Set the visible input to show emoji + name input.value = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id}`; // Set the hidden input to store the item_id hiddenInput.value = item.id; dropdown.style.display = 'none'; }); dropdown.appendChild(optionDiv); }); } function selectItem(element) { const itemId = element.dataset.id; const itemName = element.dataset.name; const container = element.closest('.reward-item'); const searchInput = container.querySelector('.reward-item-search'); const hiddenInput = container.querySelector('.reward-item-id'); const dropdown = container.querySelector('.item-dropdown'); searchInput.value = itemName; hiddenInput.value = itemId; dropdown.style.display = 'none'; } // Close dropdowns when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.reward-item') && !e.target.closest('.material-item') && !e.target.closest('.tool-item')) { document.querySelectorAll('.item-dropdown, .material-dropdown, .tool-dropdown').forEach(dropdown => { dropdown.style.display = 'none'; }); } }); // Material dropdown functions function showMaterialDropdown(input, prefix) { filterMaterialDropdown(input, prefix); } function filterMaterialDropdown(input, prefix) { const searchText = input.value.toLowerCase(); const dropdown = input.parentElement.querySelector('.material-dropdown'); const hiddenInput = input.parentElement.querySelector('.material-item-id'); if (!dropdown) return; // Filter items const filteredItems = availableItems.filter(item => { const itemText = `${item.emoji || ''} ${getI18nDisplay(item.name)} ${item.id}`.toLowerCase(); return itemText.includes(searchText); }); // Build dropdown HTML dropdown.innerHTML = ''; dropdown.style.display = 'block'; if (filteredItems.length === 0) { dropdown.innerHTML = '
No items found
'; return; } // Create elements dynamically with proper event listeners filteredItems.forEach(item => { const optionDiv = document.createElement('div'); optionDiv.className = 'dropdown-item'; optionDiv.style.padding = '8px'; optionDiv.style.cursor = 'pointer'; optionDiv.style.borderBottom = '1px solid #2a2a4a'; optionDiv.innerHTML = ` ${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} ${item.id} `; // Add hover effects optionDiv.addEventListener('mouseover', function () { this.style.background = '#2a2a4a'; }); optionDiv.addEventListener('mouseout', function () { this.style.background = 'transparent'; }); // Add click handler optionDiv.addEventListener('click', function (e) { e.stopPropagation(); input.value = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id}`; hiddenInput.value = item.id; dropdown.style.display = 'none'; }); dropdown.appendChild(optionDiv); }); } // Tool dropdown functions function showToolDropdown(input, prefix) { filterToolDropdown(input, prefix); } function filterToolDropdown(input, prefix) { const searchText = input.value.toLowerCase(); const dropdown = input.parentElement.querySelector('.tool-dropdown'); const hiddenInput = input.parentElement.querySelector('.tool-item-id'); if (!dropdown) return; // Filter items const filteredItems = availableItems.filter(item => { const itemText = `${item.emoji || ''} ${getI18nDisplay(item.name)} ${item.id}`.toLowerCase(); return itemText.includes(searchText); }); // Build dropdown HTML dropdown.innerHTML = ''; dropdown.style.display = 'block'; if (filteredItems.length === 0) { dropdown.innerHTML = '
No items found
'; return; } // Create elements dynamically with proper event listeners filteredItems.forEach(item => { const optionDiv = document.createElement('div'); optionDiv.className = 'dropdown-item'; optionDiv.style.padding = '8px'; optionDiv.style.cursor = 'pointer'; optionDiv.style.borderBottom = '1px solid #2a2a4a'; optionDiv.innerHTML = ` ${item.emoji || '🔧'} ${getI18nDisplay(item.name) || item.id} ${item.id} `; // Add hover effects optionDiv.addEventListener('mouseover', function () { this.style.background = '#2a2a4a'; }); optionDiv.addEventListener('mouseout', function () { this.style.background = 'transparent'; }); // Add click handler optionDiv.addEventListener('click', function (e) { e.stopPropagation(); input.value = `${item.emoji || '🔧'} ${getI18nDisplay(item.name) || item.id}`; hiddenInput.value = item.id; dropdown.style.display = 'none'; }); dropdown.appendChild(optionDiv); }); } // Image preview update function function updateItemImagePreview(imagePath) { const preview = document.getElementById('itemImagePreview'); if (!preview) return; if (imagePath && imagePath.trim()) { preview.innerHTML = `Item image`; } else { preview.innerHTML = 'No image'; } } function saveInteractableInstance() { if (!currentEditingInteractableInstanceId) return; // Find the instance element const items = document.querySelectorAll('.spawn-item'); let instanceElement = null; items.forEach(item => { if (item.dataset.instanceId === currentEditingInteractableInstanceId) { instanceElement = item; } }); if (!instanceElement) return; const instanceData = JSON.parse(instanceElement.dataset.instanceData); const template = availableInteractables.find(i => i.id === instanceData.template_id); if (!template) return; // Collect all outcomes for (const [actionId, action] of Object.entries(template.actions || {})) { const successRate = parseFloat(document.getElementById(`success_rate_${actionId}`).value); const staminaCost = parseInt(document.getElementById(`stamina_cost_${actionId}`).value); const successText = document.getElementById(`success_text_${actionId}`).value; const failureText = document.getElementById(`failure_text_${actionId}`).value; const critSuccessText = document.getElementById(`crit_success_text_${actionId}`).value; const critFailureText = document.getElementById(`crit_failure_text_${actionId}`).value; const critSuccessChance = parseFloat(document.getElementById(`crit_success_chance_${actionId}`).value); const critFailureChance = parseFloat(document.getElementById(`crit_failure_chance_${actionId}`).value); const damage = parseInt(document.getElementById(`damage_${actionId}`).value); const critDamage = parseInt(document.getElementById(`crit_damage_${actionId}`).value); // Collect item rewards const rewardContainer = document.getElementById(`item_rewards_${actionId}`); const rewardItems = rewardContainer.querySelectorAll('.reward-item'); const items = []; rewardItems.forEach(rewardItem => { const itemId = rewardItem.querySelector('.reward-item-id').value; const quantity = parseInt(rewardItem.querySelector('.reward-quantity').value); const chance = parseFloat(rewardItem.querySelector('.reward-chance').value); if (itemId) { items.push({ item_id: itemId, quantity: quantity || 1, chance: chance || 1 }); } }); // Collect critical success item rewards const critRewardContainer = document.getElementById(`crit_item_rewards_${actionId}`); const critRewardItems = critRewardContainer.querySelectorAll('.reward-item'); const critItems = []; critRewardItems.forEach(rewardItem => { const itemId = rewardItem.querySelector('.reward-item-id').value; const quantity = parseInt(rewardItem.querySelector('.reward-quantity').value); const chance = parseFloat(rewardItem.querySelector('.reward-chance').value); if (itemId) { critItems.push({ item_id: itemId, quantity: quantity || 1, chance: chance || 1 }); } }); instanceData.outcomes[actionId] = { success_rate: successRate, stamina_cost: staminaCost, crit_success_chance: critSuccessChance, crit_failure_chance: critFailureChance, text: { success: successText, failure: failureText, crit_success: critSuccessText, crit_failure: critFailureText }, rewards: { items: items, damage: damage, crit_items: critItems, crit_damage: critDamage } }; } // Update the stored data instanceElement.dataset.instanceData = JSON.stringify(instanceData); closeEditInteractableInstanceModal(); showSuccess('Interactable instance updated!'); } function getInteractableInstances() { const instances = {}; const items = document.querySelectorAll('#interactablesList .spawn-item'); items.forEach(item => { const instanceId = item.dataset.instanceId; const instanceData = JSON.parse(item.dataset.instanceData); instances[instanceId] = instanceData; }); return instances; } function renderInteractablesList(interactables) { console.log('renderInteractablesList called with:', interactables); console.log('availableInteractables:', availableInteractables); const list = document.getElementById('interactablesList'); if (!list) { console.error('interactablesList element not found!'); return; } list.innerHTML = ''; if (!interactables || Object.keys(interactables).length === 0) { console.log('No interactables to render'); return; } console.log('Rendering', Object.keys(interactables).length, 'interactables'); Object.entries(interactables).forEach(([instanceId, instance]) => { // Support both old format (template_id) and new format (id) const templateId = instance.template_id || instance.id; let displayName = instance.name || 'Unknown'; let actionCount = 0; console.log(`Processing instance ${instanceId}, template_id: ${templateId}`); // Try to find template in available interactables const template = availableInteractables.find(i => i.id === templateId); if (template) { displayName = getI18nDisplay(template.name); actionCount = Object.keys(template.actions || {}).length; console.log(`Found template: ${displayName} with ${actionCount} actions`); } else { console.log(`Template ${templateId} not found in availableInteractables`); // For new format instances that have full data if (instance.name) { displayName = getI18nDisplay(instance.name); } if (instance.actions) { actionCount = Object.keys(instance.actions).length; } else if (instance.outcomes) { actionCount = Object.keys(instance.outcomes).length; } } const item = document.createElement('div'); item.className = 'spawn-item'; item.dataset.instanceId = instanceId; item.dataset.instanceData = JSON.stringify(instance); item.innerHTML = `
${displayName}
${actionCount} action(s)
`; list.appendChild(item); console.log(`Appended item for ${instanceId}`); }); console.log('Finished rendering interactables'); } // ==================== IMAGE UPLOAD ==================== async function uploadImage() { const fileInput = document.getElementById('imageUpload'); const file = fileInput.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); try { const response = await fetch('/api/editor/upload-image', { method: 'POST', body: formData }); const data = await response.json(); if (data.success) { document.getElementById('imagePath').value = data.image_path; updateImagePreview(data.image_path); showSuccess(data.message); } else { alert('Upload failed: ' + data.error); } } catch (error) { alert('Upload failed: ' + error.message); } } async function uploadNPCImage() { const fileInput = document.getElementById('npcImageUpload'); const file = fileInput.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); try { const response = await fetch('/api/editor/upload-image', { method: 'POST', body: formData }); const data = await response.json(); if (data.success) { document.getElementById('npcImagePath').value = data.image_path; updateNPCImagePreview(data.image_path); showSuccess(data.message); } else { alert('Upload failed: ' + data.error); } } catch (error) { alert('Upload failed: ' + error.message); } } function updateNPCImagePreview(imagePath) { const preview = document.getElementById('npcImagePreview'); if (imagePath && imagePath.trim() !== '') { preview.innerHTML = `NPC image`; } else { preview.innerHTML = 'No image'; } } async function uploadInteractableImage() { const fileInput = document.getElementById('interactableImageUpload'); const file = fileInput.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); try { const response = await fetch('/api/editor/upload-image', { method: 'POST', body: formData }); const data = await response.json(); if (data.success) { document.getElementById('interactableImagePath').value = data.image_path; updateInteractableImagePreview(data.image_path); showSuccess(data.message); } else { alert('Upload failed: ' + data.error); } } catch (error) { alert('Upload failed: ' + error.message); } } // ==================== PYTHON EXPORT ==================== async function exportToPython() { try { const response = await fetch('/api/editor/export-python'); const data = await response.json(); if (data.success) { // Create a downloadable file const blob = new Blob([data.python_code], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'world_loader_updates.py'; a.click(); URL.revokeObjectURL(url); showSuccess('Python code exported! Review before applying to world_loader.py'); } else { alert('Export failed: ' + data.error); } } catch (error) { alert('Export failed: ' + error.message); } } // ==================== UTILITY ==================== async function logout() { try { await fetch('/api/logout', { method: 'POST' }); if (liveStatsInterval) { clearInterval(liveStatsInterval); } window.location.reload(); } catch (error) { console.error('Logout failed:', error); } } async function restartBot() { if (!confirm('Are you sure you want to restart the bot? This will temporarily disconnect active users.')) { return; } try { const response = await fetch('/api/editor/restart-bot', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (response.ok) { showSuccess('✅ Bot restarted successfully! Changes will be loaded.'); } else { showError(`Failed to restart bot: ${data.error || 'Unknown error'}`); } } catch (error) { console.error('Restart failed:', error); showError('Failed to restart bot. Check console for details.'); } } async function refreshBotLogs() { try { const lineCount = document.getElementById('logsLineCount').value || 100; const logsViewer = document.getElementById('logsViewer'); logsViewer.innerHTML = '
Loading logs...
'; const response = await fetch(`/api/editor/bot-logs?lines=${lineCount}`); const data = await response.json(); if (response.ok && data.success) { displayLogs(data.logs); } else { logsViewer.innerHTML = `
Failed to load logs: ${data.error || 'Unknown error'}
`; } } catch (error) { console.error('Failed to fetch logs:', error); document.getElementById('logsViewer').innerHTML = '
Failed to fetch logs. Check console for details.
'; } } function displayLogs(logsText) { const logsViewer = document.getElementById('logsViewer'); if (!logsText || logsText.trim() === '') { logsViewer.innerHTML = '
No logs available
'; return; } const lines = logsText.split('\n'); let html = ''; lines.forEach(line => { if (!line.trim()) return; let className = 'log-line'; // Color code based on log level if (line.includes('ERROR') || line.includes('Error') || line.includes('error')) { className += ' log-error'; } else if (line.includes('WARNING') || line.includes('Warning') || line.includes('warning')) { className += ' log-warning'; } else if (line.includes('INFO') || line.includes('Info')) { className += ' log-info'; } else if (line.includes('✅') || line.includes('SUCCESS') || line.includes('Success')) { className += ' log-success'; } // Escape HTML to prevent XSS const escapedLine = line .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); html += `
${escapedLine}
`; }); logsViewer.innerHTML = html; // Auto-scroll to bottom logsViewer.scrollTop = logsViewer.scrollHeight; } function clearLogsDisplay() { document.getElementById('logsViewer').innerHTML = '
Logs cleared. Click "Refresh" to reload.
'; } // ==================== TAB SWITCHING ==================== function switchTab(tabName) { currentTab = tabName; // Update tab buttons document.querySelectorAll('.tab-button').forEach(btn => { if (btn.textContent.toLowerCase().includes(tabName)) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); // Update tab content - ensure all are hidden first document.querySelectorAll('.tab-content').forEach(content => { content.classList.remove('active'); content.style.display = 'none'; }); // Show only the selected tab const activeTab = document.getElementById(`tab-${tabName}`); if (activeTab) { activeTab.classList.add('active'); activeTab.style.display = (tabName === 'locations') ? 'grid' : 'flex'; } // Load data for the tab if (tabName === 'npcs') { loadNPCManagement(); } else if (tabName === 'items') { loadItemManagement(); } else if (tabName === 'interactables') { loadInteractableManagement(); } else if (tabName === 'players') { loadPlayerManagement(); } else if (tabName === 'accounts') { loadAccountManagement(); } else if (tabName === 'logs') { // Auto-load logs when switching to logs tab refreshBotLogs(); } else if (tabName === 'locations') { // Load interactables for the locations tab (needed for adding instances) loadAvailableInteractablesForLocations(); // Force canvas resize when switching to locations tab setTimeout(() => { resizeCanvas(); }, 10); } } async function loadAvailableInteractablesForLocations() { try { const response = await fetch('/api/editor/interactables'); const data = await response.json(); console.log('Interactables API response:', data); if (data.interactables) { // Check if it's an array or object if (Array.isArray(data.interactables)) { availableInteractables = data.interactables; } else { // If it's an object, convert to array availableInteractables = Object.entries(data.interactables).map(([id, interactable]) => ({ id, ...interactable })); } console.log('Loaded interactables:', availableInteractables.length); } else { console.error('No interactables in response'); availableInteractables = []; } } catch (error) { console.error('Failed to load interactables:', error); availableInteractables = []; } } async function loadAvailableItemsForLocations() { try { const response = await fetch('/api/editor/items'); const data = await response.json(); availableItems = data.items || []; } catch (error) { console.error('Failed to load items:', error); } } function saveCurrentItem() { if (currentTab === 'locations') { saveCurrentLocation(); } else if (currentTab === 'npcs') { saveCurrentNPC(); } else if (currentTab === 'items') { saveCurrentItem_(); } else if (currentTab === 'interactables') { saveCurrentInteractable(); } } // ==================== NPC MANAGEMENT ==================== async function loadNPCManagement() { try { const response = await fetch('/api/editor/npcs', { credentials: 'same-origin' }); const data = await response.json(); availableNPCs = data.npcs || []; renderNPCManagementList(); } catch (error) { console.error('Failed to load NPCs:', error); } } function renderNPCManagementList() { const list = document.getElementById('npcManagementList'); list.innerHTML = ''; availableNPCs.forEach(npc => { const item = document.createElement('div'); item.className = 'management-item'; const displayName = getI18nDisplay(npc.name); item.dataset.name = displayName; item.dataset.id = npc.id; if (npc.id === selectedNPCId) { item.classList.add('active'); } item.innerHTML = `
${npc.emoji} ${displayName}
HP: ${npc.hp_min}-${npc.hp_max} | DMG: ${npc.damage_min}-${npc.damage_max}
`; item.onclick = () => selectNPC(npc.id); list.appendChild(item); }); } function filterNPCManagementList() { const searchTerm = document.getElementById('npcManagementSearch').value.toLowerCase(); const items = document.querySelectorAll('#npcManagementList .management-item'); items.forEach(item => { const name = item.dataset.name?.toLowerCase() || ''; const id = item.dataset.id?.toLowerCase() || ''; item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none'; }); } function selectNPC(npcId) { selectedNPCId = npcId; renderNPCManagementList(); const npc = availableNPCs.find(n => n.id === npcId); if (!npc) return; const editor = document.getElementById('npcEditor'); editor.innerHTML = `

Edit NPC: ${npc.name}

${npc.image_path ? `NPC image` : 'No image'}

Loot Table

Corpse Loot

`; renderNPCLootTable(npc.loot_table || []); renderNPCCorpseLoot(npc.corpse_loot || []); } function renderNPCLootTable(lootTable) { const container = document.getElementById('npcLootTable'); container.innerHTML = ''; lootTable.forEach((loot, index) => { // Handle both old and new formats for quantities let quantityMin = loot.quantity_min || 1; let quantityMax = loot.quantity_max || 1; // Parse quantity_range if it exists (old format) if (Array.isArray(loot.quantity_range)) { quantityMin = loot.quantity_range[0] || 1; quantityMax = loot.quantity_range[1] || 1; } else if (typeof loot.quantity_range === 'string') { try { const parsed = JSON.parse(loot.quantity_range); quantityMin = parsed[0] || 1; quantityMax = parsed[1] || 1; } catch (e) { quantityMin = 1; quantityMax = 1; } } const item = document.createElement('div'); item.className = 'array-item'; item.innerHTML = `
`; container.appendChild(item); }); } function renderNPCCorpseLoot(corpseLoot) { const container = document.getElementById('npcCorpseLoot'); container.innerHTML = ''; corpseLoot.forEach((loot, index) => { // Handle both old and new formats for quantities let quantityMin = loot.quantity_min || 1; let quantityMax = loot.quantity_max || 1; const item = document.createElement('div'); item.className = 'array-item'; item.innerHTML = `
`; container.appendChild(item); }); } function createNewNPC() { const npcId = prompt('Enter NPC ID (e.g., zombie, raider):'); if (!npcId) return; const newNPC = { id: npcId, name: 'New NPC', emoji: '👹', hp_min: 10, hp_max: 20, damage_min: 1, damage_max: 5, xp_reward: 10, defense: 0, description: '', image_path: '', loot_table: [], corpse_loot: [] }; availableNPCs.push(newNPC); renderNPCManagementList(); selectNPC(npcId); } async function saveCurrentNPC() { if (!selectedNPCId) { alert('No NPC selected'); return; } const npcData = { id: document.getElementById('npcId').value, name: { en: document.getElementById('npcName_en').value, es: document.getElementById('npcName_es').value }, emoji: document.getElementById('npcEmoji').value, hp_min: parseInt(document.getElementById('npcHpMin').value), hp_max: parseInt(document.getElementById('npcHpMax').value), damage_min: parseInt(document.getElementById('npcDamageMin').value), damage_max: parseInt(document.getElementById('npcDamageMax').value), xp_reward: parseInt(document.getElementById('npcXp').value), defense: parseInt(document.getElementById('npcDefense').value), description: { en: document.getElementById('npcDescription_en').value, es: document.getElementById('npcDescription_es').value }, image_path: document.getElementById('npcImagePath').value, loot_table: getNPCLootTable(), corpse_loot: getNPCCorpseLoot() }; try { const response = await fetch('/api/editor/npc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(npcData) }); const data = await response.json(); if (data.success) { showSuccess('NPC saved successfully!'); await loadNPCManagement(); } else { alert('Failed to save: ' + data.error); } } catch (error) { alert('Failed to save NPC: ' + error.message); } } function getNPCLootTable() { const container = document.getElementById('npcLootTable'); const items = container.querySelectorAll('.array-item'); const lootTable = []; items.forEach((item, index) => { const itemId = document.getElementById(`lootItem${index}`).value; const quantityMin = parseInt(document.getElementById(`lootQuantityMin${index}`).value); const quantityMax = parseInt(document.getElementById(`lootQuantityMax${index}`).value); const chance = parseFloat(document.getElementById(`lootChance${index}`).value); if (itemId) { lootTable.push({ item_id: itemId, quantity_min: quantityMin, quantity_max: quantityMax, drop_chance: chance }); } }); return lootTable; } function getNPCCorpseLoot() { const container = document.getElementById('npcCorpseLoot'); const items = container.querySelectorAll('.array-item'); const corpseLoot = []; items.forEach((item, index) => { const itemId = document.getElementById(`corpseItem${index}`).value; const quantityMin = parseInt(document.getElementById(`corpseQuantityMin${index}`).value); const quantityMax = parseInt(document.getElementById(`corpseQuantityMax${index}`).value); const requiredTool = document.getElementById(`corpseRequiredTool${index}`).value || null; if (itemId) { corpseLoot.push({ item_id: itemId, quantity_min: quantityMin, quantity_max: quantityMax, required_tool: requiredTool }); } }); return corpseLoot; } // Item dropdown functions for NPC loot tables function showNPCLootItemDropdown(index) { filterNPCLootItemDropdown(index); } function filterNPCLootItemDropdown(index) { const input = document.getElementById(`lootItem${index}`); const dropdown = document.getElementById(`lootItemDropdown${index}`); const searchTerm = input.value.toLowerCase(); dropdown.innerHTML = ''; dropdown.style.display = 'block'; const filtered = availableItems.filter(item => item.id.toLowerCase().includes(searchTerm) || (getI18nDisplay(item.name).toLowerCase().includes(searchTerm)) || (item.emoji && item.emoji.includes(searchTerm)) ); if (filtered.length === 0) { dropdown.innerHTML = '
No items found
'; return; } filtered.forEach(item => { const option = document.createElement('div'); option.className = 'dropdown-item'; option.style.padding = '8px'; option.style.cursor = 'pointer'; option.style.borderBottom = '1px solid #2a2a4a'; option.innerHTML = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} (${item.id})`; // Add hover effects option.addEventListener('mouseover', function () { this.style.background = '#2a2a4a'; }); option.addEventListener('mouseout', function () { this.style.background = 'transparent'; }); option.addEventListener('click', () => { input.value = item.id; dropdown.style.display = 'none'; }); dropdown.appendChild(option); }); } function showNPCCorpseItemDropdown(index) { filterNPCCorpseItemDropdown(index); } function filterNPCCorpseItemDropdown(index) { const input = document.getElementById(`corpseItem${index}`); const dropdown = document.getElementById(`corpseItemDropdown${index}`); const searchTerm = input.value.toLowerCase(); dropdown.innerHTML = ''; dropdown.style.display = 'block'; const filtered = availableItems.filter(item => item.id.toLowerCase().includes(searchTerm) || (getI18nDisplay(item.name).toLowerCase().includes(searchTerm)) || (item.emoji && item.emoji.includes(searchTerm)) ); if (filtered.length === 0) { dropdown.innerHTML = '
No items found
'; return; } filtered.forEach(item => { const option = document.createElement('div'); option.className = 'dropdown-item'; option.style.padding = '8px'; option.style.cursor = 'pointer'; option.style.borderBottom = '1px solid #2a2a4a'; option.innerHTML = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} (${item.id})`; // Add hover effects option.addEventListener('mouseover', function () { this.style.background = '#2a2a4a'; }); option.addEventListener('mouseout', function () { this.style.background = 'transparent'; }); option.addEventListener('click', () => { input.value = item.id; dropdown.style.display = 'none'; }); dropdown.appendChild(option); }); } // Hide dropdowns when clicking outside document.addEventListener('click', function (e) { if (!e.target.matches('[id^="lootItem"], [id^="corpseItem"]')) { document.querySelectorAll('.item-dropdown').forEach(dropdown => { dropdown.style.display = 'none'; }); } }); function addNPCLoot() { const npc = availableNPCs.find(n => n.id === selectedNPCId); if (!npc) return; if (!npc.loot_table) npc.loot_table = []; npc.loot_table.push({ item_id: '', quantity_range: [1, 1], drop_chance: 0.5 }); renderNPCLootTable(npc.loot_table); } function removeNPCLoot(index) { const npc = availableNPCs.find(n => n.id === selectedNPCId); if (!npc || !npc.loot_table) return; npc.loot_table.splice(index, 1); renderNPCLootTable(npc.loot_table); } function addNPCCorpseLoot() { const npc = availableNPCs.find(n => n.id === selectedNPCId); if (!npc) return; if (!npc.corpse_loot) npc.corpse_loot = []; npc.corpse_loot.push({ item_id: '', quantity: 1 }); renderNPCCorpseLoot(npc.corpse_loot); } function removeNPCCorpseLoot(index) { const npc = availableNPCs.find(n => n.id === selectedNPCId); if (!npc || !npc.corpse_loot) return; npc.corpse_loot.splice(index, 1); renderNPCCorpseLoot(npc.corpse_loot); } async function deleteCurrentNPC() { if (!selectedNPCId) return; if (!confirm(`Delete NPC "${selectedNPCId}"? This cannot be undone.`)) { return; } try { const response = await fetch(`/api/editor/npc/${selectedNPCId}`, { method: 'DELETE', credentials: 'same-origin' }); const data = await response.json(); if (data.success) { showSuccess('NPC deleted successfully!'); selectedNPCId = null; document.getElementById('npcEditor').innerHTML = '

Select an NPC or create a new one

'; await loadNPCManagement(); } else { alert('Failed to delete: ' + data.error); } } catch (error) { alert('Failed to delete NPC: ' + error.message); } } // ==================== ITEM MANAGEMENT ==================== async function loadItemManagement() { try { const response = await fetch('/api/editor/items', { credentials: 'same-origin' }); const data = await response.json(); availableItems = data.items || []; renderItemManagementList(); } catch (error) { console.error('Failed to load items:', error); } } function renderItemManagementList() { const list = document.getElementById('itemManagementList'); list.innerHTML = ''; availableItems.forEach(item => { const elem = document.createElement('div'); elem.className = 'management-item'; const displayName = getI18nDisplay(item.name); elem.dataset.name = displayName; elem.dataset.id = item.id; elem.dataset.type = item.type || ''; if (item.id === selectedItemId) { elem.classList.add('active'); } elem.innerHTML = `
${item.emoji ? item.emoji + ' ' : ''}${displayName}
Type: ${item.type} | Weight: ${item.weight}kg
`; elem.onclick = () => selectItem(item.id); list.appendChild(elem); }); } function filterItemManagementList() { const searchTerm = document.getElementById('itemManagementSearch').value.toLowerCase(); const categoryFilter = document.getElementById('itemCategoryFilter')?.value || 'all'; const items = document.querySelectorAll('#itemManagementList .management-item'); items.forEach(item => { const name = item.dataset.name?.toLowerCase() || ''; const id = item.dataset.id?.toLowerCase() || ''; const type = item.dataset.type?.toLowerCase() || ''; const matchesSearch = !searchTerm || name.includes(searchTerm) || id.includes(searchTerm); const matchesCategory = categoryFilter === 'all' || type === categoryFilter; item.style.display = (matchesSearch && matchesCategory) ? '' : 'none'; }); } function selectItem(itemId) { selectedItemId = itemId; renderItemManagementList(); const item = availableItems.find(i => i.id === itemId); if (!item) return; const editor = document.getElementById('itemEditor'); editor.innerHTML = `

Edit Item: ${item.name}

${item.image_path ? `Item image` : 'No image'}
💊 Consumable Properties
⚔️ Equipment Properties

Stats

💥 Weapon Effects
${renderWeaponEffects(item.weapon_effects || {})}
🔨 Crafting

Craft Materials

${renderMaterialsList(item.craft_materials || [], 'craft')}

Craft Tools

${renderToolsList(item.craft_tools || [], 'craft')}
🔧 Repair

Repair Materials

${renderMaterialsList(item.repair_materials || [], 'repair')}

Repair Tools

${renderToolsList(item.repair_tools || [], 'repair')}
♻️ Uncrafting (Salvage)

Uncraft Yield

${renderMaterialsList(item.uncraft_yield || [], 'uncraft')}

Uncraft Tools

${renderToolsList(item.uncraft_tools || [], 'uncraft')}
`; } // Helper functions for rendering arrays function renderMaterialsList(materials, prefix) { if (!materials || materials.length === 0) { return '
No materials
'; } return materials.map((mat, index) => { const selectedItem = availableItems.find(i => i.id === mat.item_id); const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
`; }).join(''); } function renderToolsList(tools, prefix) { if (!tools || tools.length === 0) { return '
No tools required
'; } return tools.map((tool, index) => { const selectedItem = availableItems.find(i => i.id === tool.item_id); const displayValue = selectedItem ? `${selectedItem.emoji || '🔧'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
`; }).join(''); } function renderWeaponEffects(effects) { if (!effects || Object.keys(effects).length === 0) { return '
No weapon effects
'; } return Object.entries(effects).map(([effectName, effectData], index) => `
`).join(''); } function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } function createNewItem() { const itemId = prompt('Enter Item ID (e.g., health_potion, iron_sword):'); if (!itemId) return; const newItem = { id: itemId, name: 'New Item', emoji: '', type: 'resource', weight: 0.1, volume: 0.1, description: '', stackable: true, hp_restore: 0, stamina_restore: 0, damage: 0, defense: 0, capacity_weight: 0, capacity_volume: 0 }; availableItems.push(newItem); renderItemManagementList(); selectItem(itemId); } async function saveCurrentItem_() { if (!selectedItemId) { alert('No item selected'); return; } // Get current item to count arrays const currentItem = availableItems.find(i => i.id === selectedItemId); // Build complete item data object const itemData = { id: document.getElementById('itemId').value, name: { en: document.getElementById('itemName_en').value, es: document.getElementById('itemName_es').value }, emoji: document.getElementById('itemEmoji').value, type: document.getElementById('itemType').value, weight: parseFloat(document.getElementById('itemWeight').value), volume: parseFloat(document.getElementById('itemVolume').value), description: { en: document.getElementById('itemDescription_en').value, es: document.getElementById('itemDescription_es').value }, image_path: document.getElementById('itemImagePath')?.value || '', stackable: document.getElementById('itemStackable').checked, // Consumable properties hp_restore: parseInt(document.getElementById('itemHpRestore')?.value) || 0, stamina_restore: parseInt(document.getElementById('itemStaminaRestore')?.value) || 0, treats: document.getElementById('itemTreats')?.value || '', // Equipment properties equippable: document.getElementById('itemEquippable')?.checked || false, slot: document.getElementById('itemSlot')?.value || '', durability: parseInt(document.getElementById('itemDurability')?.value) || 0, tier: parseInt(document.getElementById('itemTier')?.value) || 0, encumbrance: parseInt(document.getElementById('itemEncumbrance')?.value) || 0, // Stats object stats: {}, // Weapon effects weapon_effects: {}, // Crafting craftable: document.getElementById('itemCraftable')?.checked || false, craft_level: parseInt(document.getElementById('itemCraftLevel')?.value) || 0, craft_materials: [], craft_tools: [], // Repair repairable: document.getElementById('itemRepairable')?.checked || false, repair_percentage: parseInt(document.getElementById('itemRepairPercentage')?.value) || 0, repair_materials: [], repair_tools: [], // Uncrafting uncraftable: document.getElementById('itemUncraftable')?.checked || false, uncraft_loss_chance: parseFloat(document.getElementById('itemUncraftLossChance')?.value) || 0, uncraft_yield: [], uncraft_tools: [] }; // Collect stats const damageMin = parseInt(document.getElementById('itemDamageMin')?.value) || 0; const damageMax = parseInt(document.getElementById('itemDamageMax')?.value) || 0; const armor = parseInt(document.getElementById('itemArmor')?.value) || 0; const hpBonus = parseInt(document.getElementById('itemHpBonus')?.value) || 0; const staminaBonus = parseInt(document.getElementById('itemStaminaBonus')?.value) || 0; const weightCapacity = parseInt(document.getElementById('itemWeightCapacity')?.value) || 0; const volumeCapacity = parseInt(document.getElementById('itemVolumeCapacity')?.value) || 0; if (damageMin > 0) itemData.stats.damage_min = damageMin; if (damageMax > 0) itemData.stats.damage_max = damageMax; if (armor > 0) itemData.stats.armor = armor; if (hpBonus !== 0) itemData.stats.hp_bonus = hpBonus; if (staminaBonus !== 0) itemData.stats.stamina_bonus = staminaBonus; if (weightCapacity > 0) itemData.stats.weight_capacity = weightCapacity; if (volumeCapacity > 0) itemData.stats.volume_capacity = volumeCapacity; // Collect arrays if (currentItem) { itemData.craft_materials = collectMaterialsFromForm('craft', currentItem.craft_materials?.length || 0); itemData.craft_tools = collectToolsFromForm('craft', currentItem.craft_tools?.length || 0); itemData.repair_materials = collectMaterialsFromForm('repair', currentItem.repair_materials?.length || 0); itemData.repair_tools = collectToolsFromForm('repair', currentItem.repair_tools?.length || 0); itemData.uncraft_yield = collectMaterialsFromForm('uncraft', currentItem.uncraft_yield?.length || 0); itemData.uncraft_tools = collectToolsFromForm('uncraft', currentItem.uncraft_tools?.length || 0); itemData.weapon_effects = collectWeaponEffectsFromForm(Object.keys(currentItem.weapon_effects || {}).length); } try { const response = await fetch('/api/editor/item', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(itemData) }); const data = await response.json(); if (data.success) { showSuccess('Item saved successfully!'); await loadItemManagement(); // Re-select to refresh UI with saved data selectItem(selectedItemId); } else { alert('Failed to save: ' + data.error); } } catch (error) { alert('Failed to save item: ' + error.message); } } // Helper functions to collect array data from form function collectMaterialsFromForm(prefix, count) { const materials = []; for (let i = 0; i < count; i++) { const idElem = document.getElementById(`${prefix}MaterialId${i}`); const qtyElem = document.getElementById(`${prefix}MaterialQty${i}`); if (idElem && qtyElem && idElem.value.trim()) { materials.push({ item_id: idElem.value.trim(), quantity: parseInt(qtyElem.value) || 1 }); } } return materials; } function collectToolsFromForm(prefix, count) { const tools = []; for (let i = 0; i < count; i++) { const idElem = document.getElementById(`${prefix}ToolId${i}`); const durElem = document.getElementById(`${prefix}ToolDurability${i}`); if (idElem && durElem && idElem.value.trim()) { tools.push({ item_id: idElem.value.trim(), durability_cost: parseInt(durElem.value) || 0 }); } } return tools; } function collectWeaponEffectsFromForm(count) { const effects = {}; for (let i = 0; i < count; i++) { const nameElem = document.getElementById(`weaponEffectName${i}`); const chanceElem = document.getElementById(`weaponEffectChance${i}`); const damageElem = document.getElementById(`weaponEffectDamage${i}`); const durationElem = document.getElementById(`weaponEffectDuration${i}`); if (nameElem && nameElem.value.trim()) { effects[nameElem.value.trim()] = { chance: parseFloat(chanceElem?.value) || 0, damage: parseInt(damageElem?.value) || 0, duration: parseInt(durationElem?.value) || 0 }; } } return effects; } // Array management functions function addCraftMaterial() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.craft_materials) item.craft_materials = []; item.craft_materials.push({ item_id: '', quantity: 1 }); refreshItemEditor(); } function removeCraftMaterial(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.craft_materials) return; item.craft_materials.splice(index, 1); refreshItemEditor(); } function addCraftTool() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.craft_tools) item.craft_tools = []; item.craft_tools.push({ item_id: '', durability_cost: 0 }); refreshItemEditor(); } function removeCraftTool(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.craft_tools) return; item.craft_tools.splice(index, 1); refreshItemEditor(); } function addRepairMaterial() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.repair_materials) item.repair_materials = []; item.repair_materials.push({ item_id: '', quantity: 1 }); refreshItemEditor(); } function removeRepairMaterial(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.repair_materials) return; item.repair_materials.splice(index, 1); refreshItemEditor(); } function addRepairTool() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.repair_tools) item.repair_tools = []; item.repair_tools.push({ item_id: '', durability_cost: 0 }); refreshItemEditor(); } function removeRepairTool(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.repair_tools) return; item.repair_tools.splice(index, 1); refreshItemEditor(); } function addUncraftYield() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.uncraft_yield) item.uncraft_yield = []; item.uncraft_yield.push({ item_id: '', quantity: 1 }); refreshItemEditor(); } function removeUncraftYield(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.uncraft_yield) return; item.uncraft_yield.splice(index, 1); refreshItemEditor(); } function addUncraftTool() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.uncraft_tools) item.uncraft_tools = []; item.uncraft_tools.push({ item_id: '', durability_cost: 0 }); refreshItemEditor(); } function removeUncraftTool(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.uncraft_tools) return; item.uncraft_tools.splice(index, 1); refreshItemEditor(); } function addWeaponEffect() { const item = availableItems.find(i => i.id === selectedItemId); if (!item) return; if (!item.weapon_effects) item.weapon_effects = {}; const effectName = prompt('Enter effect name (e.g., bleeding, stun):'); if (!effectName) return; item.weapon_effects[effectName] = { chance: 0.1, damage: 1, duration: 3 }; refreshItemEditor(); } function removeWeaponEffect(index) { const item = availableItems.find(i => i.id === selectedItemId); if (!item || !item.weapon_effects) return; const effectNames = Object.keys(item.weapon_effects); if (index < effectNames.length) { delete item.weapon_effects[effectNames[index]]; refreshItemEditor(); } } // Helper function to refresh item editor while preserving details state function refreshItemEditor() { // Save the open state of all details elements const editor = document.getElementById('itemEditor'); const detailsElements = editor ? editor.querySelectorAll('details') : []; const openStates = Array.from(detailsElements).map(details => details.open); // Refresh the UI selectItem(selectedItemId); // Restore the open state after a brief delay to allow DOM to update setTimeout(() => { const newDetailsElements = document.getElementById('itemEditor')?.querySelectorAll('details'); if (newDetailsElements) { newDetailsElements.forEach((details, index) => { if (openStates[index]) { details.open = true; } }); } }, 10); } async function deleteCurrentItem() { if (!selectedItemId) return; if (!confirm(`Delete item "${selectedItemId}"? This cannot be undone.`)) { return; } try { const response = await fetch(`/api/editor/item/${selectedItemId}`, { method: 'DELETE', credentials: 'same-origin' }); const data = await response.json(); if (data.success) { showSuccess('Item deleted successfully!'); selectedItemId = null; document.getElementById('itemEditor').innerHTML = '

Select an item or create a new one

'; await loadItemManagement(); } else { alert('Failed to delete: ' + data.error); } } catch (error) { alert('Failed to delete item: ' + error.message); } } // ==================== INTERACTABLES MANAGEMENT ==================== async function loadInteractableManagement() { try { const response = await fetch('/api/editor/interactables', { credentials: 'same-origin' }); const data = await response.json(); availableInteractables = data.interactables; renderInteractableManagementList(); } catch (error) { console.error('Failed to load interactables:', error); } } function renderInteractableManagementList() { const list = document.getElementById('interactableManagementList'); list.innerHTML = ''; availableInteractables.forEach(inter => { const elem = document.createElement('div'); elem.className = 'management-item'; elem.dataset.name = inter.name; elem.dataset.id = inter.id; if (inter.id === selectedInteractableId) { elem.classList.add('active'); } const actionCount = Object.keys(inter.actions || {}).length; elem.innerHTML = `
${inter.name}
Actions: ${actionCount}
`; elem.onclick = () => selectInteractable(inter.id); list.appendChild(elem); }); } function filterInteractableManagementList() { const searchTerm = document.getElementById('interactableManagementSearch').value.toLowerCase(); const items = document.querySelectorAll('#interactableManagementList .management-item'); items.forEach(item => { const name = item.dataset.name?.toLowerCase() || ''; const id = item.dataset.id?.toLowerCase() || ''; item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none'; }); } function selectInteractable(interId) { selectedInteractableId = interId; renderInteractableManagementList(); const inter = availableInteractables.find(i => i.id === interId); if (!inter) return; const editor = document.getElementById('interactableEditor'); // Render actions let actionsHtml = ''; for (const [actionId, action] of Object.entries(inter.actions || {})) { actionsHtml += `

${action.label}

`; } editor.innerHTML = `

Edit Interactable: ${inter.name}

${inter.image_path ? `Interactable image` : 'No image'}

Actions

${actionsHtml}
`; } function createNewInteractable() { const interId = prompt('Enter Interactable ID (e.g., rubble, dumpster):'); if (!interId) return; const newInteractable = { id: interId, name: 'New Interactable', description: '', image_path: '', actions: { 'search': { id: 'search', label: '🔎 Search', stamina_cost: 2 } } }; availableInteractables.push(newInteractable); renderInteractableManagementList(); selectInteractable(interId); } async function saveCurrentInteractable() { if (!selectedInteractableId) { alert('No interactable selected'); return; } // Get basic fields const interData = { id: document.getElementById('interactableId').value, name: document.getElementById('interactableName').value, description: document.getElementById('interactableDescription').value, image_path: document.getElementById('interactableImagePath').value, actions: {} }; // Get actions const inter = availableInteractables.find(i => i.id === selectedInteractableId); if (inter && inter.actions) { for (const [actionId, action] of Object.entries(inter.actions)) { const labelInput = document.querySelector(`.action-label[data-action="${actionId}"]`); const staminaInput = document.querySelector(`.action-stamina[data-action="${actionId}"]`); interData.actions[actionId] = { id: actionId, label: labelInput ? labelInput.value : action.label, stamina_cost: staminaInput ? parseInt(staminaInput.value) : action.stamina_cost }; } } try { const response = await fetch('/api/editor/interactable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(interData) }); const data = await response.json(); if (data.success) { showSuccess('Interactable saved successfully!'); await loadInteractableManagement(); } else { alert('Failed to save: ' + data.error); } } catch (error) { alert('Failed to save interactable: ' + error.message); } } async function deleteCurrentInteractable() { if (!selectedInteractableId) { alert('No interactable selected'); return; } if (!confirm(`Delete interactable "${selectedInteractableId}"?`)) { return; } try { const response = await fetch(`/api/editor/interactable/${selectedInteractableId}`, { method: 'DELETE', credentials: 'same-origin' }); const data = await response.json(); if (data.success) { showSuccess('Interactable deleted successfully!'); selectedInteractableId = null; document.getElementById('interactableEditor').innerHTML = `

Select an interactable to edit

Choose from the list or create a new one.

`; await loadInteractableManagement(); } else { alert('Failed to delete: ' + data.error); } } catch (error) { alert('Failed to delete interactable: ' + error.message); } } function updateInteractableImagePreview(imagePath) { const preview = document.getElementById('interactableImagePreview'); if (imagePath && imagePath.trim() !== '') { preview.innerHTML = `Interactable image`; } else { preview.innerHTML = 'No image'; } } async function uploadItemImage() { const fileInput = document.getElementById('itemImageUpload'); const file = fileInput.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); try { const response = await fetch('/api/editor/upload-image', { method: 'POST', body: formData }); const data = await response.json(); if (data.success) { document.getElementById('itemImagePath').value = data.image_path; updateItemImagePreview(data.image_path); showSuccess(data.message); } else { alert('Upload failed: ' + data.error); } } catch (error) { alert('Upload failed: ' + error.message); } } function addNewInteractableAction() { const actionId = prompt('Enter Action ID (e.g., search, break, open):'); if (!actionId) return; const inter = availableInteractables.find(i => i.id === selectedInteractableId); if (!inter) return; // Check if action already exists if (inter.actions && inter.actions[actionId]) { alert('Action with this ID already exists!'); return; } // Add new action if (!inter.actions) { inter.actions = {}; } inter.actions[actionId] = { id: actionId, label: actionId.charAt(0).toUpperCase() + actionId.slice(1), stamina_cost: 2 }; // Re-render the editor selectInteractable(selectedInteractableId); } function deleteInteractableAction(actionId) { if (!confirm(`Delete action "${actionId}"?`)) { return; } const inter = availableInteractables.find(i => i.id === selectedInteractableId); if (!inter || !inter.actions) return; delete inter.actions[actionId]; // Re-render the editor selectInteractable(selectedInteractableId); } // ==================== PLAYER MANAGEMENT ==================== let allPlayers = []; let selectedPlayerId = null; async function loadPlayerManagement() { try { const response = await fetch('/api/editor/players', { credentials: 'same-origin' }); const data = await response.json(); if (data.players) { allPlayers = data.players; renderPlayerList(); } else { console.error('Failed to load players:', data.error); allPlayers = []; renderPlayerList(); } } catch (error) { console.error('Error loading players:', error); allPlayers = []; renderPlayerList(); } } function renderPlayerList() { const list = document.getElementById('playerList'); if (!list) return; list.innerHTML = ''; if (allPlayers.length === 0) { list.innerHTML = '
No players found
'; return; } allPlayers.forEach(player => { const elem = document.createElement('div'); elem.className = 'management-item'; elem.dataset.name = player.character_name; elem.dataset.characterId = player.id; elem.dataset.status = player.is_banned ? 'banned' : (player.premium_until ? 'premium' : 'active'); if (player.id === selectedPlayerId) { elem.classList.add('active'); } const statusBadge = player.is_banned ? '🚫' : (player.premium_until ? '⭐' : ''); elem.innerHTML = `
${statusBadge} ${player.character_name}
ID: ${player.id} | Lvl ${player.level} | ${player.location_id}
`; elem.onclick = () => selectPlayer(player.id); list.appendChild(elem); }); filterPlayerList(); } function filterPlayerList() { const searchTerm = document.getElementById('playerSearch')?.value.toLowerCase() || ''; const statusFilter = document.getElementById('playerStatusFilter')?.value || 'all'; const items = document.querySelectorAll('#playerList .management-item'); items.forEach(item => { const name = item.dataset.name?.toLowerCase() || ''; const status = item.dataset.status || ''; const matchesSearch = !searchTerm || name.includes(searchTerm) || item.dataset.characterId.includes(searchTerm); const matchesStatus = statusFilter === 'all' || status === statusFilter; item.style.display = (matchesSearch && matchesStatus) ? '' : 'none'; }); } async function selectPlayer(characterId) { selectedPlayerId = characterId; renderPlayerList(); try { const response = await fetch(`/api/editor/player/${characterId}`, { credentials: 'same-origin' }); const player = await response.json(); if (player.error) { alert('Error loading player: ' + player.error); return; } renderPlayerEditor(player); } catch (error) { console.error('Error loading player details:', error); alert('Failed to load player details'); } } function renderPlayerEditor(player) { const editor = document.getElementById('playerEditor'); if (!editor) return; const isBanned = player.is_banned || false; const isPremium = player.premium_until && new Date(player.premium_until) > new Date(); editor.innerHTML = `

Edit Player: ${player.character_name}

Character ID: ${player.id}
Account: ${player.email || 'N/A'}
Status: ${isBanned ? '🚫 Banned' : (isPremium ? '⭐ Premium' : '✅ Active')}
Created: ${player.character_created_at ? new Date(player.character_created_at * 1000).toLocaleDateString() : 'N/A'}

⚔️ Character Stats

Attributes

Capacity

🎒 Inventory (Read-Only)

${player.inventory && player.inventory.length > 0 ? player.inventory.map(item => `
${item.item_id} x${item.quantity} ${item.unique_item_data ? ' (Unique)' : ''}
`).join('') : '
No items
' }

⚔️ Equipment (Read-Only)

${Object.keys(player.equipped || {}).length > 0 ? Object.entries(player.equipped).map(([slot, item]) => `
${slot}: ${item.item_id} ${item.unique_item_data ? ' (Unique)' : ''}
`).join('') : '
No equipment
' }

🔧 Account Management

${isBanned ? `` : `` }
${isBanned && player.ban_reason ? `
Ban Reason: ${player.ban_reason}
` : ''}
`; } async function savePlayer() { if (!selectedPlayerId) return; const playerData = { character_name: document.getElementById('playerName').value, location_id: document.getElementById('playerLocation').value, level: parseInt(document.getElementById('playerLevel').value), xp: parseInt(document.getElementById('playerXP').value), hp: parseInt(document.getElementById('playerHP').value), max_hp: parseInt(document.getElementById('playerMaxHP').value), stamina: parseInt(document.getElementById('playerStamina').value), max_stamina: parseInt(document.getElementById('playerMaxStamina').value), strength: parseInt(document.getElementById('playerStrength').value), agility: parseInt(document.getElementById('playerAgility').value), endurance: parseInt(document.getElementById('playerEndurance').value), intelligence: parseInt(document.getElementById('playerIntelligence').value), weight_capacity: parseFloat(document.getElementById('playerWeightCap').value), volume_capacity: parseFloat(document.getElementById('playerVolumeCap').value) }; try { const response = await fetch(`/api/editor/player/${selectedPlayerId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(playerData) }); const data = await response.json(); if (data.success) { showSuccess('Player saved successfully!'); await loadPlayerManagement(); } else { alert('Failed to save: ' + data.error); } } catch (error) { alert('Failed to save player: ' + error.message); } } async function banPlayer(telegramId) { const reason = prompt('Enter ban reason:'); if (!reason) return; try { const response = await fetch(`/api/editor/account/${telegramId}/ban`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ is_banned: true, ban_reason: reason }) }); const data = await response.json(); if (data.success) { showSuccess('Account banned successfully!'); await loadPlayerManagement(); selectPlayer(telegramId); } else { alert('Failed to ban: ' + data.error); } } catch (error) { alert('Failed to ban account: ' + error.message); } } async function unbanPlayer(telegramId) { try { const response = await fetch(`/api/editor/account/${telegramId}/ban`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ is_banned: false }) }); const data = await response.json(); if (data.success) { showSuccess('Account unbanned successfully!'); await loadPlayerManagement(); selectPlayer(telegramId); } else { alert('Failed to unban: ' + data.error); } } catch (error) { alert('Failed to unban account: ' + error.message); } } async function resetPlayer(telegramId) { if (!confirm('Reset this player to starting state? This will clear inventory and equipment!')) { return; } try { const response = await fetch(`/api/editor/player/${telegramId}/reset`, { method: 'POST', credentials: 'same-origin' }); const data = await response.json(); if (data.success) { showSuccess('Player reset successfully!'); await loadPlayerManagement(); selectPlayer(telegramId); } else { alert('Failed to reset: ' + data.error); } } catch (error) { alert('Failed to reset player: ' + error.message); } } async function deletePlayer(accountId) { const playerName = allPlayers.find(p => p.account_id === accountId)?.character_name || 'this player'; if (!confirm(`DELETE ${playerName} and ALL associated data? This CANNOT be undone!`)) { return; } if (!confirm('Are you ABSOLUTELY SURE? This will permanently delete the account!')) { return; } try { const response = await fetch(`/api/editor/account/${telegramId}/delete`, { method: 'DELETE', credentials: 'same-origin' }); const data = await response.json(); if (data.success) { showSuccess('Account deleted successfully!'); selectedPlayerId = null; document.getElementById('playerEditor').innerHTML = '

Select a player to edit

'; await loadPlayerManagement(); } else { alert('Failed to delete: ' + data.error); } } catch (error) { alert('Failed to delete account: ' + error.message); } } // ==================== ACCOUNT MANAGEMENT ==================== let allAccounts = []; let selectedAccountId = null; async function loadAccountManagement() { try { const response = await fetch('/api/editor/accounts', { credentials: 'same-origin' }); const data = await response.json(); if (data.accounts) { allAccounts = data.accounts; renderAccountList(); } else { console.error('Failed to load accounts:', data.error); allAccounts = []; renderAccountList(); } } catch (error) { console.error('Error loading accounts:', error); allAccounts = []; renderAccountList(); } } function renderAccountList() { const list = document.getElementById('accountList'); if (!list) return; list.innerHTML = ''; if (allAccounts.length === 0) { list.innerHTML = '
No accounts found
'; return; } allAccounts.forEach(account => { const elem = document.createElement('div'); elem.className = 'management-item'; elem.dataset.email = account.email.toLowerCase(); elem.dataset.accountId = account.id; if (account.id === selectedAccountId) { elem.classList.add('active'); } const premiumBadge = account.is_premium ? '⭐' : ''; elem.innerHTML = `
${premiumBadge} ${account.email}
${account.character_count} character${account.character_count !== 1 ? 's' : ''}
`; elem.onclick = () => selectAccount(account.id); list.appendChild(elem); }); filterAccountList(); } function filterAccountList() { const searchTerm = document.getElementById('accountSearch')?.value.toLowerCase() || ''; const items = document.querySelectorAll('#accountList .management-item'); items.forEach(item => { const email = item.dataset.email || ''; const matchesSearch = !searchTerm || email.includes(searchTerm) || item.dataset.accountId.includes(searchTerm); item.style.display = matchesSearch ? '' : 'none'; }); } async function selectAccount(accountId) { selectedAccountId = accountId; renderAccountList(); try { const response = await fetch(`/api/editor/account/${accountId}`, { credentials: 'same-origin' }); const account = await response.json(); if (account.error) { alert('Error loading account: ' + account.error); return; } renderAccountEditor(account); } catch (error) { console.error('Error loading account details:', error); alert('Failed to load account details'); } } function renderAccountEditor(account) { const editor = document.getElementById('accountEditor'); if (!editor) return; const isPremium = account.premium_expires_at && account.premium_expires_at > Date.now() / 1000; const premiumDate = account.premium_expires_at ? new Date(account.premium_expires_at * 1000).toLocaleDateString() : 'None'; editor.innerHTML = `

Edit Account: ${account.email}

Account ID: ${account.id}
Status: ${isPremium ? '⭐ Premium' : '🆓 Free'}
Created: ${account.created_at ? new Date(account.created_at * 1000).toLocaleDateString() : 'N/A'}

📧 Account Details

👥 Characters (${account.characters.length})

${account.characters.length > 0 ? account.characters.map(char => `
${char.name} ${char.is_dead ? '💀' : ''}
Level ${char.level} | ${char.location_id} | HP: ${char.hp}/${char.max_hp}
`).join('') : '
No characters
' }
`; } async function saveAccount() { if (!selectedAccountId) return; const accountData = { email: document.getElementById('accountEmail').value, premium_expires_at: parseFloat(document.getElementById('accountPremiumExpires').value) || null }; try { const response = await fetch(`/api/editor/account/${selectedAccountId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(accountData) }); const data = await response.json(); if (data.success) { showSuccess('Account saved successfully!'); await loadAccountManagement(); } else { alert('Failed to save: ' + data.error); } } catch (error) { alert('Failed to save account: ' + error.message); } } // Cross-navigation functions function viewCharacter(characterId) { switchTab('players'); setTimeout(() => selectPlayer(characterId), 100); } function viewAccount(accountId) { switchTab('accounts'); setTimeout(() => selectAccount(accountId), 100); } // ==================== EDITABLE INVENTORY ==================== let availableItemsData = null; async function loadAvailableItemsData() { if (availableItemsData) return availableItemsData; try { const response = await fetch('/gamedata/items.json'); availableItemsData = await response.json(); return availableItemsData; } catch (error) { console.error('Error loading items:', error); return {}; } } function renderEditableInventory(inventory) { return `
${inventory.map((item, index) => renderInventoryRow(item, index)).join('')}
`; } function renderInventoryRow(item, index) { return `
`; } function addInventoryRow() { const container = document.getElementById('inventoryRows'); if (!container) return; const currentRows = container.querySelectorAll('.inventory-row').length; const newRow = renderInventoryRow({ item_id: '', quantity: 1, is_equipped: false }, currentRows); container.insertAdjacentHTML('beforeend', newRow); } function removeInventoryRow(index) { const row = document.querySelector(`.inventory-row[data-index="${index}"]`); if (row) { row.remove(); } } async function saveInventory() { if (!selectedPlayerId) return; const rows = document.querySelectorAll('.inventory-row'); const inventory = []; rows.forEach(row => { const itemId = row.querySelector('.inv-item-id').value.trim(); const quantity = parseInt(row.querySelector('.inv-quantity').value) || 1; const isEquipped = row.querySelector('.inv-equipped').checked; if (itemId) { inventory.push({ item_id: itemId, quantity: quantity, is_equipped: isEquipped }); } }); try { const response = await fetch(`/api/editor/player/${selectedPlayerId}/inventory`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ inventory: inventory }) }); const data = await response.json(); if (data.success) { showSuccess('Inventory saved successfully!'); // Reload player to show updated inventory selectPlayer(selectedPlayerId); } else { alert('Failed to save inventory: ' + data.error); } } catch (error) { alert('Failed to save inventory: ' + error.message); } } // Update renderPlayerEditor to use editable inventory and add account link const originalRenderPlayerEditor = renderPlayerEditor; renderPlayerEditor = function (player) { const editor = document.getElementById('playerEditor'); if (!editor) return; const isBanned = player.is_banned || false; const isPremium = player.premium_expires_at && player.premium_expires_at > Date.now() / 1000; editor.innerHTML = `

Edit Player: ${player.character_name || player.name}

${player.account_id ? `
📧 View Account: ${player.email || 'Account #' + player.account_id}
` : ''}
Character ID: ${player.id}
Account: ${player.email || 'N/A'}
Status: ${isBanned ? '🚫 Banned' : (isPremium ? '⭐ Premium' : '✅ Active')}
Created: ${player.character_created_at ? new Date(player.character_created_at * 1000).toLocaleDateString() : 'N/A'}

⚔️ Character Stats

Attributes

🎒 Inventory

${Object.keys(availableItemsData || {}).map(itemId => ``).join('')} ${renderEditableInventory(player.inventory || [])}

⚔️ Equipped Items

${player.equipped && Object.keys(player.equipped).length > 0 ? Object.entries(player.equipped).map(([slot, item]) => `
${item.item_id} x${item.quantity}
`).join('') : '
No equipment (use "Equipped" checkbox in inventory)
' }

🔧 Account Management

${isBanned ? `` : `` }
${isBanned && player.ban_reason ? `
Ban Reason: ${player.ban_reason}
` : ''}
`; // Load items data for datalist loadAvailableItemsData(); };