Files
echoes-of-the-ash/web-map/editor_enhanced.js
2025-11-27 16:27:01 +01:00

4808 lines
182 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `
<div class="location-item-name">${location.name}</div>
<div class="location-item-coords">📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}</div>
${playerCount > 0 || enemyCount > 0 ? `<div class="location-item-stats">👥 ${playerCount} | 👹 ${enemyCount}</div>` : ''}
`;
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 = `<img src="/${imagePath}" alt="Location image">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
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 = `
<div class="spawn-item-info">
<div class="spawn-item-name">${spawn.emoji} ${spawn.name}</div>
<div class="spawn-item-weight">Weight: ${spawn.weight}</div>
</div>
<button class="btn btn-remove" onclick="removeSpawn(${index})">Remove</button>
`;
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 = '<div style="padding: 10px; text-align: center; opacity: 0.5;">No connections</div>';
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 = `
<div class="connection-info">
<div class="connection-direction" style="font-weight: 500; color: #ffa726;">
${getDirectionEmoji(conn.direction)} ${conn.direction}
</div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 4px;">
${destLocation.name}
</div>
</div>
<button class="btn btn-remove" onclick="deleteConnection('${locationId}', '${conn.to}')"
style="width: 32px; height: 32px; padding: 0; font-size: 20px; line-height: 1;">×</button>
`;
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 = '<div style="padding: 20px; text-align: center; opacity: 0.5;">No locations found</div>';
return;
}
filteredLocations.forEach(location => {
const item = document.createElement('div');
item.className = 'connection-target-item';
item.innerHTML = `
<div><strong>${location.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}
</div>
`;
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: <strong>${toLocation?.name || 'destination'}</strong> → ${reverseDirection} → <strong>${fromLocation?.name || 'source'}</strong>`;
} 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: <strong>${toLocation.name}</strong> → ${reverseDirection} → <strong>${fromLocation.name}</strong>`;
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 = `
<div><strong>${npc.emoji} ${npc.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
HP: ${npc.hp_range[0]}-${npc.hp_range[1]} |
DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} |
XP: ${npc.xp_reward}
</div>
`;
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 = `
<div class="spawn-item-info">
<div class="spawn-item-name">${npc.emoji} ${npc.name}</div>
<div class="spawn-item-weight">Weight: ${weight}</div>
</div>
<button class="btn btn-remove" onclick="this.parentElement.remove()">Remove</button>
`;
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 = '<div style="padding: 20px; text-align: center; opacity: 0.5;">Loading interactables...</div>';
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 = '<div style="padding: 20px; text-align: center; opacity: 0.5;">No interactables available. Create one in the Interactables tab first.</div>';
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 = `
<div><strong>${interactable.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
${interactable.description}<br>
${actionCount} action(s)
</div>
`;
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 = `
<div class="spawn-item-info">
<div class="spawn-item-name">${interactable.name}</div>
<div class="spawn-item-weight">${actionCount} action(s)</div>
</div>
<div style="display: flex; gap: 5px;">
<button class="btn btn-primary" onclick="editInteractableInstance('${instanceId}', '${interactable.id}')">Edit</button>
<button class="btn btn-remove" onclick="removeInteractableInstance('${instanceId}')">Remove</button>
</div>
`;
// 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 = `
<div style="background: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<h4 style="margin: 0 0 5px 0;">${template.name}</h4>
<p style="margin: 0; opacity: 0.7; font-size: 0.9em;">${template.description}</p>
</div>
<div style="display: flex; flex-direction: column; gap: 20px;">
${Object.entries(template.actions || {}).map(([actionId, action]) => {
const outcome = instanceData.outcomes[actionId] || {};
return `
<div style="background: rgba(255,255,255,0.03); padding: 15px; border-radius: 8px;">
<h4 style="margin: 0 0 15px 0;">Action: ${action.label}</h4>
<div class="property-group">
<label>Success Rate (0.0-1.0)</label>
<input type="number" id="success_rate_${actionId}"
min="0" max="1" step="0.1"
value="${outcome.success_rate || 0.5}">
</div>
<div class="property-group">
<label>Stamina Cost</label>
<input type="number" id="stamina_cost_${actionId}"
min="0" step="1"
value="${outcome.stamina_cost || action.stamina_cost || 0}">
</div>
<div class="property-group">
<label>Success Text</label>
<textarea id="success_text_${actionId}" rows="2">${outcome.text?.success || ''}</textarea>
</div>
<div class="property-group">
<label>Failure Text</label>
<textarea id="failure_text_${actionId}" rows="2">${outcome.text?.failure || ''}</textarea>
</div>
<div class="property-group">
<label>Critical Success Text</label>
<textarea id="crit_success_text_${actionId}" rows="2">${outcome.text?.crit_success || ''}</textarea>
</div>
<div class="property-group">
<label>Critical Failure Text</label>
<textarea id="crit_failure_text_${actionId}" rows="2">${outcome.text?.crit_failure || ''}</textarea>
</div>
<div class="property-group">
<label>Critical Success Chance (0.0-1.0)</label>
<input type="number" id="crit_success_chance_${actionId}"
min="0" max="1" step="0.05"
value="${outcome.crit_success_chance || 0.1}">
</div>
<div class="property-group">
<label>Critical Failure Chance (0.0-1.0)</label>
<input type="number" id="crit_failure_chance_${actionId}"
min="0" max="1" step="0.05"
value="${outcome.crit_failure_chance || 0.1}">
</div>
<div class="property-group">
<label>Damage on Failure</label>
<input type="number" id="damage_${actionId}"
min="0" step="1"
value="${outcome.rewards?.damage || 0}">
</div>
<div class="property-group">
<label>Damage on Critical Failure</label>
<input type="number" id="crit_damage_${actionId}"
min="0" step="1"
value="${outcome.rewards?.crit_damage || 0}">
</div>
<div class="property-group" style="grid-column: 1 / -1;">
<label>Item Rewards</label>
<div style="display: flex; gap: 10px; margin-bottom: 5px; font-size: 0.9em; opacity: 0.7; padding: 0 5px;">
<div style="flex: 2;">Item (type to search)</div>
<div style="flex: 1;">Quantity</div>
<div style="flex: 1;">Chance (0-1)</div>
<div style="width: 40px;"></div>
</div>
<div id="item_rewards_${actionId}" style="margin-bottom: 10px;">
${(outcome.rewards?.items || []).map((reward, idx) => {
const selectedItem = availableItems.find(i => i.id === reward.item_id);
const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : '';
return `
<div class="reward-item" style="display: flex; gap: 10px; margin-bottom: 5px; align-items: center; position: relative;">
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
value="${displayValue}"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="${reward.item_id || ''}">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="${reward.quantity || 1}"
min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="${reward.chance || 1}"
min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
</div>
`;
}).join('')}
</div>
<button class="btn btn-add" onclick="addRewardRow('${actionId}')">+ Add Item Reward</button>
</div>
<div class="property-group" style="grid-column: 1 / -1;">
<label>Critical Success Item Rewards</label>
<div style="display: flex; gap: 10px; margin-bottom: 5px; font-size: 0.9em; opacity: 0.7; padding: 0 5px;">
<div style="flex: 2;">Item (type to search)</div>
<div style="flex: 1;">Quantity</div>
<div style="flex: 1;">Chance (0-1)</div>
<div style="width: 40px;"></div>
</div>
<div id="crit_item_rewards_${actionId}" style="margin-bottom: 10px;">
${(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 `
<div class="reward-item" style="display: flex; gap: 10px; margin-bottom: 5px; align-items: center; position: relative;">
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
value="${displayValue}"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="${reward.item_id || ''}">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="${reward.quantity || 1}"
min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="${reward.chance || 1}"
min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
</div>
`;
}).join('')}
</div>
<button class="btn btn-add" onclick="addCritRewardRow('${actionId}')">+ Add Critical Success Reward</button>
</div>
</div>
`;
}).join('')}
</div>
`;
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 = `
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="1" min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="1" min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
`;
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 = `
<div style="flex: 2; position: relative;">
<input type="text" class="reward-item-search"
placeholder="Type to search items..."
oninput="filterItemDropdown(this)"
onfocus="showItemDropdown(this)"
style="width: 100%; padding: 8px; background: #1a1a3e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="reward-item-id" value="">
<div class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="1" value="1" min="1" style="flex: 1;" class="reward-quantity">
<input type="number" placeholder="1.0" value="1" min="0" max="1" step="0.1" style="flex: 1;" class="reward-chance">
<button class="btn btn-remove" onclick="this.parentElement.remove()">×</button>
`;
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 = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
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}
<span style="opacity: 0.5; font-size: 0.85em; margin-left: 5px;">${item.id}</span>
`;
// 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 = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
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}
<span style="opacity: 0.5; font-size: 0.85em; margin-left: 5px;">${item.id}</span>
`;
// 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 = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
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}
<span style="opacity: 0.5; font-size: 0.85em; margin-left: 5px;">${item.id}</span>
`;
// 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 = `<img src="${imagePath}" alt="Item image" style="max-width: 100%; max-height: 150px; object-fit: contain;" onerror="this.parentElement.innerHTML='<span style=\\'opacity: 0.5;\\'>Image not found</span>'">`;
} else {
preview.innerHTML = '<span style="opacity: 0.5;">No image</span>';
}
}
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 = `
<div class="spawn-item-info">
<div class="spawn-item-name">${displayName}</div>
<div class="spawn-item-weight">${actionCount} action(s)</div>
</div>
<div style="display: flex; gap: 5px;">
<button class="btn btn-primary" onclick="editInteractableInstance('${instanceId}', '${templateId}')">Edit</button>
<button class="btn btn-remove" onclick="removeInteractableInstance('${instanceId}')">Remove</button>
</div>
`;
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 = `<img src="${imagePath}" alt="NPC image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
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 = '<div style="opacity: 0.5; text-align: center; padding: 20px;">Loading logs...</div>';
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 = `<div class="log-error">Failed to load logs: ${data.error || 'Unknown error'}</div>`;
}
} catch (error) {
console.error('Failed to fetch logs:', error);
document.getElementById('logsViewer').innerHTML = '<div class="log-error">Failed to fetch logs. Check console for details.</div>';
}
}
function displayLogs(logsText) {
const logsViewer = document.getElementById('logsViewer');
if (!logsText || logsText.trim() === '') {
logsViewer.innerHTML = '<div style="opacity: 0.5; text-align: center; padding: 20px;">No logs available</div>';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
html += `<div class="${className}">${escapedLine}</div>`;
});
logsViewer.innerHTML = html;
// Auto-scroll to bottom
logsViewer.scrollTop = logsViewer.scrollHeight;
}
function clearLogsDisplay() {
document.getElementById('logsViewer').innerHTML = '<div style="opacity: 0.5; text-align: center; padding: 20px;">Logs cleared. Click "Refresh" to reload.</div>';
}
// ==================== 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 = `
<div><strong>${npc.emoji} ${npc.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
HP: ${npc.hp_min}-${npc.hp_max} | DMG: ${npc.damage_min}-${npc.damage_max}
</div>
`;
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 = `
<h2>Edit NPC: ${npc.name}</h2>
<div class="form-grid">
<div class="property-group">
<label>ID</label>
<input type="text" id="npcId" value="${npc.id}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Name</label>
<input type="text" id="npcName" value="${npc.name}">
</div>
<div class="property-group">
<label>Emoji</label>
<input type="text" id="npcEmoji" value="${npc.emoji}" maxlength="2">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>HP Min</label>
<input type="number" id="npcHpMin" value="${npc.hp_min}" min="1">
</div>
<div class="property-group">
<label>HP Max</label>
<input type="number" id="npcHpMax" value="${npc.hp_max}" min="1">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Damage Min</label>
<input type="number" id="npcDamageMin" value="${npc.damage_min}" min="0">
</div>
<div class="property-group">
<label>Damage Max</label>
<input type="number" id="npcDamageMax" value="${npc.damage_max}" min="0">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>XP Reward</label>
<input type="number" id="npcXp" value="${npc.xp_reward}" min="0">
</div>
<div class="property-group">
<label>Defense</label>
<input type="number" id="npcDefense" value="${npc.defense || 0}" min="0">
</div>
</div>
<div class="property-group">
<label>Description</label>
<textarea id="npcDescription" rows="3" style="width: 100%;">${npc.description || ''}</textarea>
</div>
<div class="property-group">
<label>NPC Image</label>
<input type="text" id="npcImagePath" value="${npc.image_path || ''}" placeholder="images/npcs/npc_name.webp" oninput="updateNPCImagePreview(this.value)">
<div class="file-input-wrapper">
<input type="file" id="npcImageUpload" accept="image/*" onchange="uploadNPCImage()">
<label for="npcImageUpload" class="file-input-label">📤 Upload New Image</label>
</div>
<div class="image-preview" id="npcImagePreview">
${npc.image_path ? `<img src="${npc.image_path}" alt="NPC image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">` : '<span>No image</span>'}
</div>
</div>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Loot Table</h3>
<div id="npcLootTable"></div>
<button class="btn btn-add" onclick="addNPCLoot()">+ Add Loot Item</button>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Corpse Loot</h3>
<div id="npcCorpseLoot"></div>
<button class="btn btn-add" onclick="addNPCCorpseLoot()">+ Add Corpse Loot</button>
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a; display: flex; gap: 10px;">
<button class="btn btn-danger" style="flex: 1;" onclick="deleteCurrentNPC()">🗑️ Delete NPC</button>
</div>
`;
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 = `
<button class="array-item-remove" onclick="removeNPCLoot(${index})">×</button>
<div class="form-grid">
<div class="property-group">
<label>Item</label>
<div style="position: relative;">
<input type="text"
id="lootItem${index}"
value="${loot.item_id}"
placeholder="Type to search items..."
oninput="filterNPCLootItemDropdown(${index})"
onfocus="showNPCLootItemDropdown(${index})"
autocomplete="off">
<div id="lootItemDropdown${index}" class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
</div>
<div class="property-group">
<label>Quantity Min</label>
<input type="number" id="lootQuantityMin${index}" value="${quantityMin}" min="1">
</div>
<div class="property-group">
<label>Quantity Max</label>
<input type="number" id="lootQuantityMax${index}" value="${quantityMax}" min="1">
</div>
<div class="property-group">
<label>Drop Chance (0-1)</label>
<input type="number" id="lootChance${index}" value="${loot.drop_chance || 0.5}" step="0.01" min="0" max="1">
</div>
</div>
`;
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 = `
<button class="array-item-remove" onclick="removeNPCCorpseLoot(${index})">×</button>
<div class="form-grid">
<div class="property-group">
<label>Item</label>
<div style="position: relative;">
<input type="text"
id="corpseItem${index}"
value="${loot.item_id}"
placeholder="Type to search items..."
oninput="filterNPCCorpseItemDropdown(${index})"
onfocus="showNPCCorpseItemDropdown(${index})"
autocomplete="off">
<div id="corpseItemDropdown${index}" class="item-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
</div>
<div class="property-group">
<label>Quantity Min</label>
<input type="number" id="corpseQuantityMin${index}" value="${quantityMin}" min="1">
</div>
<div class="property-group">
<label>Quantity Max</label>
<input type="number" id="corpseQuantityMax${index}" value="${quantityMax}" min="1">
</div>
<div class="property-group">
<label>Required Tool (optional)</label>
<input type="text" id="corpseRequiredTool${index}" value="${loot.required_tool || ''}" placeholder="e.g., knife">
</div>
</div>
`;
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 = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
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} <span style="opacity: 0.6; font-size: 0.9em;">(${item.id})</span>`;
// 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 = '<div style="padding: 8px; opacity: 0.5;">No items found</div>';
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} <span style="opacity: 0.6; font-size: 0.9em;">(${item.id})</span>`;
// 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 = '<div style="text-align: center; opacity: 0.5; padding: 50px;"><h3>Select an NPC or create a new one</h3></div>';
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 = `
<div><strong>${item.emoji ? item.emoji + ' ' : ''}${item.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
Type: ${item.type} | Weight: ${item.weight}kg
</div>
`;
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 = `
<h2>Edit Item: ${item.name}</h2>
<!-- Basic Properties -->
<div class="form-grid">
<div class="property-group">
<label>ID</label>
<input type="text" id="itemId" value="${item.id}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Name</label>
<input type="text" id="itemName" value="${item.name}">
</div>
<div class="property-group">
<label>Emoji</label>
<input type="text" id="itemEmoji" value="${item.emoji || ''}" maxlength="2">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>Type</label>
<select id="itemType">
<option value="resource" ${item.type === 'resource' ? 'selected' : ''}>Resource</option>
<option value="consumable" ${item.type === 'consumable' ? 'selected' : ''}>Consumable</option>
<option value="weapon" ${item.type === 'weapon' ? 'selected' : ''}>Weapon</option>
<option value="armor" ${item.type === 'armor' ? 'selected' : ''}>Armor</option>
<option value="clothing" ${item.type === 'clothing' ? 'selected' : ''}>Clothing</option>
<option value="backpack" ${item.type === 'backpack' ? 'selected' : ''}>Backpack</option>
<option value="tool" ${item.type === 'tool' ? 'selected' : ''}>Tool</option>
<option value="quest" ${item.type === 'quest' ? 'selected' : ''}>Quest Item</option>
</select>
</div>
<div class="property-group">
<label>Weight (kg)</label>
<input type="number" id="itemWeight" value="${item.weight}" step="0.1" min="0">
</div>
<div class="property-group">
<label>Volume</label>
<input type="number" id="itemVolume" value="${item.volume}" step="0.1" min="0">
</div>
</div>
<div class="property-group">
<label>Description</label>
<textarea id="itemDescription" rows="3" style="width: 100%;">${item.description || ''}</textarea>
</div>
<div class="property-group">
<label>Image Path</label>
<input type="text" id="itemImagePath" value="${item.image_path || ''}"
placeholder="images/items/example.webp"
oninput="updateItemImagePreview(this.value)">
<div class="file-input-wrapper">
<input type="file" id="itemImageUpload" accept="image/*" onchange="uploadItemImage()">
<label for="itemImageUpload" class="file-input-label">📤 Upload New Image</label>
</div>
</div>
<div class="property-group">
<label>Image Preview</label>
<div id="itemImagePreview" style="min-height: 100px; background: #1a1a3e; border-radius: 4px; padding: 10px; display: flex; align-items: center; justify-content: center;">
${item.image_path ? `<img src="${item.image_path}" alt="Item image" style="max-width: 100%; max-height: 150px; object-fit: contain;" onerror="this.parentElement.innerHTML='<span style=\\'opacity: 0.5;\\'>Image not found</span>'">` : '<span style="opacity: 0.5;">No image</span>'}
</div>
</div>
<div class="property-group">
<label style="display: inline-block;">
<input type="checkbox" id="itemStackable" ${item.stackable !== false ? 'checked' : ''}>
<span style="margin-left: 5px;">Stackable</span>
</label>
</div>
<!-- Consumable Properties -->
<details style="margin-top: 20px;">
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
💊 Consumable Properties
</summary>
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
<div class="property-group">
<label style="display: inline-block;">
<input type="checkbox" id="itemConsumable" ${item.type === 'consumable' ? 'checked' : ''}>
<span style="margin-left: 5px;">Consumable</span>
</label>
</div>
<div class="form-grid">
<div class="property-group">
<label>HP Restore</label>
<input type="number" id="itemHpRestore" value="${item.hp_restore || 0}" min="0">
</div>
<div class="property-group">
<label>Stamina Restore</label>
<input type="number" id="itemStaminaRestore" value="${item.stamina_restore || 0}" min="0">
</div>
<div class="property-group">
<label>Treats (medical)</label>
<input type="text" id="itemTreats" value="${item.treats || ''}" placeholder="bleeding, poisoned">
</div>
</div>
</div>
</details>
<!-- Equipment Properties -->
<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
⚔️ Equipment Properties
</summary>
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
<div class="property-group">
<label style="display: inline-block;">
<input type="checkbox" id="itemEquippable" ${item.equippable ? 'checked' : ''}>
<span style="margin-left: 5px;">Equippable</span>
</label>
</div>
<div class="form-grid">
<div class="property-group">
<label>Slot</label>
<select id="itemSlot">
<option value="">None</option>
<option value="weapon" ${item.slot === 'weapon' ? 'selected' : ''}>Weapon</option>
<option value="armor" ${item.slot === 'armor' ? 'selected' : ''}>Armor</option>
<option value="head" ${item.slot === 'head' ? 'selected' : ''}>Head</option>
<option value="torso" ${item.slot === 'torso' ? 'selected' : ''}>Torso</option>
<option value="legs" ${item.slot === 'legs' ? 'selected' : ''}>Legs</option>
<option value="feet" ${item.slot === 'feet' ? 'selected' : ''}>Feet</option>
<option value="backpack" ${item.slot === 'backpack' ? 'selected' : ''}>Backpack</option>
<option value="tool" ${item.slot === 'tool' ? 'selected' : ''}>Tool</option>
</select>
</div>
<div class="property-group">
<label>Durability</label>
<input type="number" id="itemDurability" value="${item.durability || 0}" min="0">
</div>
<div class="property-group">
<label>Tier</label>
<input type="number" id="itemTier" value="${item.tier || 0}" min="0" max="5">
</div>
<div class="property-group">
<label>Encumbrance</label>
<input type="number" id="itemEncumbrance" value="${item.encumbrance || 0}" min="0">
</div>
</div>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Stats</h4>
<div class="form-grid">
<div class="property-group">
<label>Damage Min</label>
<input type="number" id="itemDamageMin" value="${item.stats?.damage_min || 0}" min="0">
</div>
<div class="property-group">
<label>Damage Max</label>
<input type="number" id="itemDamageMax" value="${item.stats?.damage_max || 0}" min="0">
</div>
<div class="property-group">
<label>Armor</label>
<input type="number" id="itemArmor" value="${item.stats?.armor || 0}" min="0">
</div>
<div class="property-group">
<label>HP Bonus</label>
<input type="number" id="itemHpBonus" value="${item.stats?.hp_bonus || 0}">
</div>
<div class="property-group">
<label>Stamina Bonus</label>
<input type="number" id="itemStaminaBonus" value="${item.stats?.stamina_bonus || 0}">
</div>
<div class="property-group">
<label>Weight Capacity</label>
<input type="number" id="itemWeightCapacity" value="${item.stats?.weight_capacity || 0}" min="0">
</div>
<div class="property-group">
<label>Volume Capacity</label>
<input type="number" id="itemVolumeCapacity" value="${item.stats?.volume_capacity || 0}" min="0">
</div>
</div>
</div>
</details>
<!-- Weapon Effects -->
<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
💥 Weapon Effects
</summary>
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
<div id="weaponEffectsList">
${renderWeaponEffects(item.weapon_effects || {})}
</div>
<button class="btn btn-secondary" onclick="addWeaponEffect()" style="margin-top: 10px;">+ Add Effect</button>
</div>
</details>
<!-- Crafting System -->
<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
🔨 Crafting
</summary>
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
<div class="property-group">
<label style="display: inline-block;">
<input type="checkbox" id="itemCraftable" ${item.craftable ? 'checked' : ''}>
<span style="margin-left: 5px;">Craftable</span>
</label>
</div>
<div class="property-group">
<label>Craft Level Required</label>
<input type="number" id="itemCraftLevel" value="${item.craft_level || 0}" min="0">
</div>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Craft Materials</h4>
<div id="craftMaterialsList">
${renderMaterialsList(item.craft_materials || [], 'craft')}
</div>
<button class="btn btn-secondary" onclick="addCraftMaterial()" style="margin-top: 10px;">+ Add Material</button>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Craft Tools</h4>
<div id="craftToolsList">
${renderToolsList(item.craft_tools || [], 'craft')}
</div>
<button class="btn btn-secondary" onclick="addCraftTool()" style="margin-top: 10px;">+ Add Tool</button>
</div>
</details>
<!-- Repair System -->
<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
🔧 Repair
</summary>
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
<div class="property-group">
<label style="display: inline-block;">
<input type="checkbox" id="itemRepairable" ${item.repairable ? 'checked' : ''}>
<span style="margin-left: 5px;">Repairable</span>
</label>
</div>
<div class="property-group">
<label>Repair Percentage</label>
<input type="number" id="itemRepairPercentage" value="${item.repair_percentage || 0}" min="0" max="100" step="5">
</div>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Repair Materials</h4>
<div id="repairMaterialsList">
${renderMaterialsList(item.repair_materials || [], 'repair')}
</div>
<button class="btn btn-secondary" onclick="addRepairMaterial()" style="margin-top: 10px;">+ Add Material</button>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Repair Tools</h4>
<div id="repairToolsList">
${renderToolsList(item.repair_tools || [], 'repair')}
</div>
<button class="btn btn-secondary" onclick="addRepairTool()" style="margin-top: 10px;">+ Add Tool</button>
</div>
</details>
<!-- Uncrafting System -->
<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; color: #ffa726; padding: 10px; background: #1a1a3e; border-radius: 4px;">
♻️ Uncrafting (Salvage)
</summary>
<div style="padding: 15px; background: #0f0f1e; border-radius: 4px; margin-top: 5px;">
<div class="property-group">
<label style="display: inline-block;">
<input type="checkbox" id="itemUncraftable" ${item.uncraftable ? 'checked' : ''}>
<span style="margin-left: 5px;">Uncraftable</span>
</label>
</div>
<div class="property-group">
<label>Uncraft Loss Chance (0-1)</label>
<input type="number" id="itemUncraftLossChance" value="${item.uncraft_loss_chance || 0}" min="0" max="1" step="0.05">
</div>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Uncraft Yield</h4>
<div id="uncraftYieldList">
${renderMaterialsList(item.uncraft_yield || [], 'uncraft')}
</div>
<button class="btn btn-secondary" onclick="addUncraftYield()" style="margin-top: 10px;">+ Add Yield</button>
<h4 style="color: #8bc34a; margin: 15px 0 10px;">Uncraft Tools</h4>
<div id="uncraftToolsList">
${renderToolsList(item.uncraft_tools || [], 'uncraft')}
</div>
<button class="btn btn-secondary" onclick="addUncraftTool()" style="margin-top: 10px;">+ Add Tool</button>
</div>
</details>
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a; display: flex; gap: 10px;">
<button class="btn btn-danger" style="flex: 1;" onclick="deleteCurrentItem()">🗑️ Delete Item</button>
</div>
`;
}
// Helper functions for rendering arrays
function renderMaterialsList(materials, prefix) {
if (!materials || materials.length === 0) {
return '<div style="opacity: 0.5; padding: 10px;">No materials</div>';
}
return materials.map((mat, index) => {
const selectedItem = availableItems.find(i => i.id === mat.item_id);
const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : '';
return `
<div class="material-item" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center; background: #1a1a3e; padding: 10px; border-radius: 4px;">
<div style="flex: 2; position: relative;">
<input type="text" class="material-item-search"
value="${displayValue}"
placeholder="Type to search items..."
oninput="filterMaterialDropdown(this, '${prefix}')"
onfocus="showMaterialDropdown(this, '${prefix}')"
style="width: 100%; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="material-item-id" id="${prefix}MaterialId${index}" value="${mat.item_id || ''}">
<div class="material-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="Quantity" value="${mat.quantity || 1}" min="1"
id="${prefix}MaterialQty${index}" style="flex: 1; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<button class="btn btn-remove" onclick="remove${capitalize(prefix)}Material(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
</div>
`;
}).join('');
}
function renderToolsList(tools, prefix) {
if (!tools || tools.length === 0) {
return '<div style="opacity: 0.5; padding: 10px;">No tools required</div>';
}
return tools.map((tool, index) => {
const selectedItem = availableItems.find(i => i.id === tool.item_id);
const displayValue = selectedItem ? `${selectedItem.emoji || '🔧'} ${selectedItem.name}` : '';
return `
<div class="tool-item" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center; background: #1a1a3e; padding: 10px; border-radius: 4px;">
<div style="flex: 2; position: relative;">
<input type="text" class="tool-item-search"
value="${displayValue}"
placeholder="Type to search tools..."
oninput="filterToolDropdown(this, '${prefix}')"
onfocus="showToolDropdown(this, '${prefix}')"
style="width: 100%; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<input type="hidden" class="tool-item-id" id="${prefix}ToolId${index}" value="${tool.item_id || ''}">
<div class="tool-dropdown" style="display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: #1a1a3e; border: 1px solid #3a3a6a; border-radius: 4px; margin-top: 2px; z-index: 1000;"></div>
</div>
<input type="number" placeholder="Durability Cost" value="${tool.durability_cost || 0}" min="0"
id="${prefix}ToolDurability${index}" style="flex: 1; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<button class="btn btn-remove" onclick="remove${capitalize(prefix)}Tool(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
</div>
`;
}).join('');
}
function renderWeaponEffects(effects) {
if (!effects || Object.keys(effects).length === 0) {
return '<div style="opacity: 0.5; padding: 10px;">No weapon effects</div>';
}
return Object.entries(effects).map(([effectName, effectData], index) => `
<div class="weapon-effect-item" style="background: #1a1a3e; padding: 10px; border-radius: 4px; margin-bottom: 10px;">
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
<input type="text" placeholder="Effect Name" value="${effectName}"
id="weaponEffectName${index}" style="flex: 1;">
<button class="btn btn-remove" onclick="removeWeaponEffect(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
</div>
<div class="form-grid">
<div class="property-group">
<label>Chance (0-1)</label>
<input type="number" id="weaponEffectChance${index}" value="${effectData.chance || 0}" min="0" max="1" step="0.05">
</div>
<div class="property-group">
<label>Damage</label>
<input type="number" id="weaponEffectDamage${index}" value="${effectData.damage || 0}" min="0">
</div>
<div class="property-group">
<label>Duration (turns)</label>
<input type="number" id="weaponEffectDuration${index}" value="${effectData.duration || 0}" min="0">
</div>
</div>
</div>
`).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 = '<div style="text-align: center; opacity: 0.5; padding: 50px;"><h3>Select an item or create a new one</h3></div>';
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 = `
<div><strong>${inter.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
Actions: ${actionCount}
</div>
`;
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 += `
<div class="array-item" style="margin-bottom: 15px; position: relative;">
<button class="btn btn-remove" style="position: absolute; top: 10px; right: 10px;"
onclick="deleteInteractableAction('${actionId}')">× Remove</button>
<h4 style="color: #ffa726; margin-bottom: 10px;">${action.label}</h4>
<div class="form-grid">
<div class="property-group">
<label>Action ID</label>
<input type="text" value="${actionId}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Label</label>
<input type="text" class="action-label" data-action="${actionId}" value="${action.label}">
</div>
<div class="property-group">
<label>Stamina Cost</label>
<input type="number" class="action-stamina" data-action="${actionId}" value="${action.stamina_cost}" min="0">
</div>
</div>
</div>
`;
}
editor.innerHTML = `
<h2>Edit Interactable: ${inter.name}</h2>
<div class="form-grid">
<div class="property-group">
<label>ID</label>
<input type="text" id="interactableId" value="${inter.id}" readonly style="opacity: 0.6;">
</div>
<div class="property-group">
<label>Name</label>
<input type="text" id="interactableName" value="${inter.name}">
</div>
</div>
<div class="property-group">
<label>Description</label>
<textarea id="interactableDescription" rows="3" style="width: 100%;">${inter.description || ''}</textarea>
</div>
<div class="property-group">
<label>Interactable Image</label>
<input type="text" id="interactableImagePath" value="${inter.image_path || ''}"
placeholder="images/interactables/name.webp"
oninput="updateInteractableImagePreview(this.value)">
<div class="file-input-wrapper">
<input type="file" id="interactableImageUpload" accept="image/*" onchange="uploadInteractableImage()">
<label for="interactableImageUpload" class="file-input-label">📤 Upload New Image</label>
</div>
<div class="image-preview" id="interactableImagePreview">
${inter.image_path ? `<img src="/${inter.image_path}" alt="Interactable image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">` : '<span>No image</span>'}
</div>
</div>
<h3 style="color: #ffa726; margin: 30px 0 15px;">Actions</h3>
<div id="interactableActions">
${actionsHtml}
</div>
<button class="btn btn-add" onclick="addNewInteractableAction()" style="margin-top: 10px;">+ Add Action</button>
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a; display: flex; gap: 10px;">
<button class="btn btn-danger" style="flex: 1;" onclick="deleteCurrentInteractable()">🗑️ Delete Interactable</button>
</div>
`;
}
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 = `
<h3>Select an interactable to edit</h3>
<p style="opacity: 0.7;">Choose from the list or create a new one.</p>
`;
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 = `<img src="/${imagePath}" alt="Interactable image" onerror="this.parentElement.innerHTML='<span>Image not found</span>'">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
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 = '<div style="text-align: center; opacity: 0.5; padding: 20px;">No players found</div>';
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 = `
<div><strong>${statusBadge} ${player.character_name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
ID: ${player.id} | Lvl ${player.level} | ${player.location_id}
</div>
`;
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 = `
<h2>Edit Player: ${player.character_name}</h2>
<div style="display: flex; gap: 10px; margin-bottom: 20px; padding: 15px; background: ${isBanned ? '#4a1a1a' : '#1a1a3e'}; border-radius: 8px;">
<div style="flex: 1;">
<strong>Character ID:</strong> ${player.id}
</div>
<div style="flex: 1;">
<strong>Account:</strong> ${player.email || 'N/A'}
</div>
<div style="flex: 1;">
<strong>Status:</strong> ${isBanned ? '🚫 Banned' : (isPremium ? '⭐ Premium' : '✅ Active')}
</div>
<div style="flex: 1;">
<strong>Created:</strong> ${player.character_created_at ? new Date(player.character_created_at * 1000).toLocaleDateString() : 'N/A'}
</div>
</div>
<!-- Basic Stats -->
<h3 style="color: #ffa726; margin-top: 20px;">⚔️ Character Stats</h3>
<div class="form-grid">
<div class="property-group">
<label>Character Name</label>
<input type="text" id="playerName" value="${player.character_name || ''}">
</div>
<div class="property-group">
<label>Location</label>
<input type="text" id="playerLocation" value="${player.location_id || ''}">
</div>
<div class="property-group">
<label>Level</label>
<input type="number" id="playerLevel" value="${player.level || 1}" min="1">
</div>
<div class="property-group">
<label>XP</label>
<input type="number" id="playerXP" value="${player.xp || 0}" min="0">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>HP</label>
<input type="number" id="playerHP" value="${player.hp || 100}" min="0">
</div>
<div class="property-group">
<label>Max HP</label>
<input type="number" id="playerMaxHP" value="${player.max_hp || 100}" min="1">
</div>
<div class="property-group">
<label>Stamina</label>
<input type="number" id="playerStamina" value="${player.stamina || 100}" min="0">
</div>
<div class="property-group">
<label>Max Stamina</label>
<input type="number" id="playerMaxStamina" value="${player.max_stamina || 100}" min="1">
</div>
</div>
<h4 style="color: #8bc34a; margin-top: 15px;">Attributes</h4>
<div class="form-grid">
<div class="property-group">
<label>💪 Strength</label>
<input type="number" id="playerStrength" value="${player.strength || 5}" min="1">
</div>
<div class="property-group">
<label>🏃 Agility</label>
<input type="number" id="playerAgility" value="${player.agility || 5}" min="1">
</div>
<div class="property-group">
<label>❤️ Endurance</label>
<input type="number" id="playerEndurance" value="${player.endurance || 5}" min="1">
</div>
<div class="property-group">
<label>🧠 Intelligence</label>
<input type="number" id="playerIntelligence" value="${player.intelligence || 5}" min="1">
</div>
</div>
<h4 style="color: #8bc34a; margin-top: 15px;">Capacity</h4>
<div class="form-grid">
<div class="property-group">
<label>Weight Capacity</label>
<input type="number" id="playerWeightCap" value="${player.weight_capacity || 20}" min="0" step="0.1">
</div>
<div class="property-group">
<label>Volume Capacity</label>
<input type="number" id="playerVolumeCap" value="${player.volume_capacity || 15}" min="0" step="0.1">
</div>
</div>
<!-- Inventory -->
<h3 style="color: #ffa726; margin-top: 30px;">🎒 Inventory (Read-Only)</h3>
<div style="max-height: 300px; overflow-y: auto; background: #0f0f1e; padding: 10px; border-radius: 4px;">
${player.inventory && player.inventory.length > 0 ?
player.inventory.map(item => `
<div style="padding: 8px; margin-bottom: 5px; background: #1a1a3e; border-radius: 4px;">
<strong>${item.item_id}</strong> x${item.quantity}
${item.unique_item_data ? ' (Unique)' : ''}
</div>
`).join('') :
'<div style="opacity: 0.5;">No items</div>'
}
</div>
<!-- Equipment -->
<h3 style="color: #ffa726; margin-top: 30px;">⚔️ Equipment (Read-Only)</h3>
<div style="background: #0f0f1e; padding: 10px; border-radius: 4px;">
${Object.keys(player.equipped || {}).length > 0 ?
Object.entries(player.equipped).map(([slot, item]) => `
<div style="padding: 8px; margin-bottom: 5px; background: #1a1a3e; border-radius: 4px;">
<strong>${slot}:</strong> ${item.item_id}
${item.unique_item_data ? ' (Unique)' : ''}
</div>
`).join('') :
'<div style="opacity: 0.5;">No equipment</div>'
}
</div>
<!-- Account Management -->
<h3 style="color: #ff5252; margin-top: 30px;">🔧 Account Management</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
${isBanned ?
`<button class="btn btn-secondary" onclick="unbanPlayer(${player.account_id})">✅ Unban Account</button>` :
`<button class="btn btn-danger" onclick="banPlayer(${player.account_id})">🚫 Ban Account</button>`
}
<button class="btn btn-warning" onclick="resetPlayer(${player.id})">🔄 Reset Player</button>
<button class="btn btn-danger" onclick="deletePlayer(${player.account_id})">🗑️ Delete Account</button>
</div>
${isBanned && player.ban_reason ? `
<div style="margin-top: 10px; padding: 10px; background: #4a1a1a; border-radius: 4px;">
<strong>Ban Reason:</strong> ${player.ban_reason}
</div>
` : ''}
<!-- Save Button -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a;">
<button class="btn btn-primary" style="width: 100%;" onclick="savePlayer()">💾 Save Player Stats</button>
</div>
`;
}
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 = '<div style="text-align: center; opacity: 0.5; padding: 50px;"><h3>Select a player to edit</h3></div>';
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 = '<div style="text-align: center; opacity: 0.5; padding: 20px;">No accounts found</div>';
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 = `
<div><strong>${premiumBadge} ${account.email}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 5px;">
${account.character_count} character${account.character_count !== 1 ? 's' : ''}
</div>
`;
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 = `
<h2>Edit Account: ${account.email}</h2>
<div style="display: flex; gap: 10px; margin-bottom: 20px; padding: 15px; background: #1a1a3e; border-radius: 8px;">
<div style="flex: 1;">
<strong>Account ID:</strong> ${account.id}
</div>
<div style="flex: 1;">
<strong>Status:</strong> ${isPremium ? '⭐ Premium' : '🆓 Free'}
</div>
<div style="flex: 1;">
<strong>Created:</strong> ${account.created_at ? new Date(account.created_at * 1000).toLocaleDateString() : 'N/A'}
</div>
</div>
<!-- Account Details -->
<h3 style="color: #ffa726; margin-top: 20px;">📧 Account Details</h3>
<div class="form-grid">
<div class="property-group">
<label>Email</label>
<input type="email" id="accountEmail" value="${account.email || ''}">
</div>
<div class="property-group">
<label>Premium Expires (Unix Timestamp)</label>
<input type="number" id="accountPremiumExpires" value="${account.premium_expires_at || ''}" placeholder="Leave empty for free">
</div>
</div>
<!-- Characters List -->
<h3 style="color: #ffa726; margin-top: 30px;">👥 Characters (${account.characters.length})</h3>
<div style="max-height: 400px; overflow-y: auto; background: #0f0f1e; padding: 10px; border-radius: 4px; margin-bottom: 20px;">
${account.characters.length > 0 ?
account.characters.map(char => `
<div style="padding: 12px; margin-bottom: 8px; background: #1a1a3e; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${char.name}</strong> ${char.is_dead ? '💀' : ''}
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 4px;">
Level ${char.level} | ${char.location_id} | HP: ${char.hp}/${char.max_hp}
</div>
</div>
<button class="btn btn-secondary" onclick="viewCharacter(${char.id})">
👤 View Character
</button>
</div>
`).join('') :
'<div style="opacity: 0.5; text-align: center; padding: 20px;">No characters</div>'
}
</div>
<!-- Save Button -->
<div style="margin-top: 20px; padding-top: 20px; border-top: 2px solid #3a3a6a;">
<button class="btn btn-primary" style="width: 100%;" onclick="saveAccount()">💾 Save Account</button>
</div>
`;
}
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 `
<div id="inventoryRows">
${inventory.map((item, index) => renderInventoryRow(item, index)).join('')}
</div>
<button class="btn btn-add" onclick="addInventoryRow()" style="margin-top: 10px;">+ Add Item</button>
<button class="btn btn-primary" onclick="saveInventory()" style="margin-top: 10px; margin-left: 10px;">💾 Save Inventory</button>
`;
}
function renderInventoryRow(item, index) {
return `
<div class="inventory-row" data-index="${index}" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center; background: #1a1a3e; padding: 10px; border-radius: 4px;">
<input type="text" placeholder="item_id" value="${item.item_id || ''}"
class="inv-item-id" style="flex: 2; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;"
list="itemsList">
<input type="number" placeholder="Qty" value="${item.quantity || 1}" min="1"
class="inv-quantity" style="flex: 1; padding: 8px; background: #0f0f1e; color: #e0e0e0; border: 1px solid #3a3a6a; border-radius: 4px;">
<label style="flex: 0 0 auto; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" class="inv-equipped" ${item.is_equipped ? 'checked' : ''}>
Equipped
</label>
<button class="btn btn-remove" onclick="removeInventoryRow(${index})" style="width: 32px; height: 32px; padding: 0;">×</button>
</div>
`;
}
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 = `
<h2>Edit Player: ${player.character_name || player.name}</h2>
${player.account_id ? `
<div style="margin-bottom: 15px;">
<a href="#" onclick="viewAccount(${player.account_id}); return false;" style="color: #64b5f6; text-decoration: none;">
📧 View Account: ${player.email || 'Account #' + player.account_id}
</a>
</div>
` : ''}
<div style="display: flex; gap: 10px; margin-bottom: 20px; padding: 15px; background: ${isBanned ? '#4a1a1a' : '#1a1a3e'}; border-radius: 8px;">
<div style="flex: 1;">
<strong>Character ID:</strong> ${player.id}
</div>
<div style="flex: 1;">
<strong>Account:</strong> ${player.email || 'N/A'}
</div>
<div style="flex: 1;">
<strong>Status:</strong> ${isBanned ? '🚫 Banned' : (isPremium ? '⭐ Premium' : '✅ Active')}
</div>
<div style="flex: 1;">
<strong>Created:</strong> ${player.character_created_at ? new Date(player.character_created_at * 1000).toLocaleDateString() : 'N/A'}
</div>
</div>
<!-- Basic Stats -->
<h3 style="color: #ffa726; margin-top: 20px;">⚔️ Character Stats</h3>
<div class="form-grid">
<div class="property-group">
<label>Character Name</label>
<input type="text" id="playerName" value="${player.character_name || player.name || ''}">
</div>
<div class="property-group">
<label>Location</label>
<input type="text" id="playerLocation" value="${player.location_id || ''}">
</div>
<div class="property-group">
<label>Level</label>
<input type="number" id="playerLevel" value="${player.level || 1}" min="1">
</div>
<div class="property-group">
<label>XP</label>
<input type="number" id="playerXP" value="${player.xp || 0}" min="0">
</div>
</div>
<div class="form-grid">
<div class="property-group">
<label>HP</label>
<input type="number" id="playerHP" value="${player.hp || 100}" min="0">
</div>
<div class="property-group">
<label>Max HP</label>
<input type="number" id="playerMaxHP" value="${player.max_hp || 100}" min="1">
</div>
<div class="property-group">
<label>Stamina</label>
<input type="number" id="playerStamina" value="${player.stamina || 100}" min="0">
</div>
<div class="property-group">
<label>Max Stamina</label>
<input type="number" id="playerMaxStamina" value="${player.max_stamina || 100}" min="1">
</div>
</div>
<h4 style="color: #8bc34a; margin-top: 15px;">Attributes</h4>
<div class="form-grid">
<div class="property-group">
<label>💪 Strength</label>
<input type="number" id="playerStrength" value="${player.strength || 5}" min="1">
</div>
<div class="property-group">
<label>🏃 Agility</label>
<input type="number" id="playerAgility" value="${player.agility || 5}" min="1">
</div>
<div class="property-group">
<label>❤️ Endurance</label>
<input type="number" id="playerEndurance" value="${player.endurance || 5}" min="1">
</div>
<div class="property-group">
<label>🧠 Intelligence</label>
<input type="number" id="playerIntelligence" value="${player.intellect || 5}" min="1">
</div>
</div>
<!-- Editable Inventory -->
<h3 style="color: #ffa726; margin-top: 30px;">🎒 Inventory</h3>
<datalist id="itemsList">
${Object.keys(availableItemsData || {}).map(itemId => `<option value="${itemId}">${itemId}</option>`).join('')}
</datalist>
${renderEditableInventory(player.inventory || [])}
<!-- Equipment (from inventory) -->
<h3 style="color: #ffa726; margin-top: 30px;"> Equipped Items</h3>
<div style="background: #0f0f1e; padding: 10px; border-radius: 4px; margin-bottom: 20px;">
${player.equipped && Object.keys(player.equipped).length > 0 ?
Object.entries(player.equipped).map(([slot, item]) => `
<div style="padding: 8px; margin-bottom: 5px; background: #1a1a3e; border-radius: 4px;">
<strong>${item.item_id}</strong> x${item.quantity}
</div>
`).join('') :
'<div style="opacity: 0.5;">No equipment (use "Equipped" checkbox in inventory)</div>'
}
</div>
<!-- Account Management -->
<h3 style="color: #ff5252; margin-top: 30px;">🔧 Account Management</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
${isBanned ?
`<button class="btn btn-secondary" onclick="unbanPlayer(${player.account_id})">✅ Unban Account</button>` :
`<button class="btn btn-danger" onclick="banPlayer(${player.account_id})">🚫 Ban Account</button>`
}
<button class="btn btn-warning" onclick="resetPlayer(${player.id})">🔄 Reset Player</button>
<button class="btn btn-danger" onclick="deletePlayer(${player.account_id})">🗑 Delete Account</button>
</div>
${isBanned && player.ban_reason ? `
<div style="margin-top: 10px; padding: 10px; background: #4a1a1a; border-radius: 4px;">
<strong>Ban Reason:</strong> ${player.ban_reason}
</div>
` : ''}
<!-- Save Button -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #3a3a6a;">
<button class="btn btn-primary" style="width: 100%;" onclick="savePlayer()">💾 Save Player Stats</button>
</div>
`;
// Load items data for datalist
loadAvailableItemsData();
};