// 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';
// 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';
item.dataset.name = location.name;
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 = `
${location.name}
📍 (${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;
document.getElementById('locationName').value = location.name;
document.getElementById('locationDescription').value = location.description;
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 = ` `;
} 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}
Remove
`;
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(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: document.getElementById('locationName').value,
description: document.getElementById('locationDescription').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}
Remove
`;
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)
Edit
Remove
`;
// 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 = `
${template.name}
${template.description}
${Object.entries(template.actions || {}).map(([actionId, action]) => {
const outcome = instanceData.outcomes[actionId] || {};
return `
Action: ${action.label}
Success Rate (0.0-1.0)
Stamina Cost
Success Text
Failure Text
Critical Success Text
Critical Failure Text
Critical Success Chance (0.0-1.0)
Critical Failure Chance (0.0-1.0)
Damage on Failure
Damage on Critical Failure
Item Rewards
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 || '📦'} ${selectedItem.name}` : '';
return `
`;
}).join('')}
+ Add Item Reward
Critical Success Item Rewards
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 || '📦'} ${selectedItem.name}` : '';
return `
`;
}).join('')}
+ Add Critical Success Reward
`;
}).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 || ''} ${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 || '📦'} ${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 || '📦'} ${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 || ''} ${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 || '📦'} ${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 || '📦'} ${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 || ''} ${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 || '🔧'} ${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 || '🔧'} ${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 = ` `;
} 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 = 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 = 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)
Edit
Remove
`;
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 = ` `;
} 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';
item.dataset.name = npc.name;
item.dataset.id = npc.id;
if (npc.id === selectedNPCId) {
item.classList.add('active');
}
item.innerHTML = `
${npc.emoji} ${npc.name}
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}
Description
Loot Table
+ Add Loot Item
Corpse Loot
+ Add Corpse Loot
🗑️ Delete NPC
`;
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: document.getElementById('npcName').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: document.getElementById('npcDescription').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) ||
(item.name && 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 || '📦'} ${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) ||
(item.name && 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 || '📦'} ${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';
elem.dataset.name = item.name;
elem.dataset.id = item.id;
elem.dataset.type = item.type || '';
if (item.id === selectedItemId) {
elem.classList.add('active');
}
elem.innerHTML = `
${item.emoji ? item.emoji + ' ' : ''}${item.name}
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}
Description
Image Preview
${item.image_path ? `
` : '
No image '}
Stackable
💊 Consumable Properties
⚔️ Equipment Properties
💥 Weapon Effects
${renderWeaponEffects(item.weapon_effects || {})}
+ Add Effect
🔨 Crafting
🔧 Repair
♻️ Uncrafting (Salvage)
🗑️ Delete Item
`;
}
// 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 || '📦'} ${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 || '🔧'} ${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: document.getElementById('itemName').value,
emoji: document.getElementById('itemEmoji').value,
type: document.getElementById('itemType').value,
weight: parseFloat(document.getElementById('itemWeight').value),
volume: parseFloat(document.getElementById('itemVolume').value),
description: document.getElementById('itemDescription').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 += `
`;
}
editor.innerHTML = `
Edit Interactable: ${inter.name}
Description
Actions
${actionsHtml}
+ Add Action
🗑️ Delete Interactable
`;
}
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 = ` `;
} 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 ?
`✅ Unban Account ` :
`🚫 Ban Account `
}
🔄 Reset Player
🗑️ Delete Account
${isBanned && player.ban_reason ? `
Ban Reason: ${player.ban_reason}
` : ''}
💾 Save Player Stats
`;
}
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}
👤 View Character
`).join('') :
'
No characters
'
}
💾 Save Account
`;
}
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('')}
+ Add Item
💾 Save Inventory
`;
}
function renderInventoryRow(item, index) {
return `
Equipped
×
`;
}
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 ? `
` : ''}
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 => `${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 ?
`✅ Unban Account ` :
`🚫 Ban Account `
}
🔄 Reset Player
🗑️ Delete Account
${isBanned && player.ban_reason ? `
Ban Reason: ${player.ban_reason}
` : ''}
💾 Save Player Stats
`;
// Load items data for datalist
loadAvailableItemsData();
};