var CELL = 100;
var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null;
var panX = 0, panY = 0, scale = 1;
var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0;
var dragRoom = false;
var linkDrag = false, linkFromId = null, linkTargetId = null, linkTargetDir = null, linkGhostDir = null;
var linkDragOneWay = false;
var ctrlHeld = false;
var delOneWay = false;
var delDrag = false, delFromId = null, delTargetId = null;
var roomMap = {}, occupied = {};
var dirChangedByUser = false;
var currentPanelTab = 0;
var gridDirs = [
{dx:0,dy:-1,dir:'north'},{dx:0,dy:1,dir:'south'},
{dx:1,dy:0,dir:'east'},{dx:-1,dy:0,dir:'west'},
{dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'},
{dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'}
];
var saveTimer = null;
var upTarget = {}, downTarget = {};
var _mapMouseCleanup = false;
function isCtrlKey(e) {
return e && (e.key === 'Control' || e.code === 'ControlLeft' || e.code === 'ControlRight' || e.keyCode === 17);
}
function onMapMouseMove(e) {
if (!dragging) return;
var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
if (delFromId && moved && !delDrag) { delDrag = true; }
if (delDrag && moved) {
delOneWay = e.ctrlKey;
updateDelDragTarget(e);
showDelDragBadge();
return;
}
if (!dragRoom && !delDrag && !delFromId) {
panX += e.clientX - prevX;
panY += e.clientY - prevY;
prevX = e.clientX; prevY = e.clientY;
updateView();
}
if (linkFromId && moved) {
linkDrag = true;
linkDragOneWay = e.ctrlKey;
showLinkDragBadge();
updateLinkDragTarget(e);
}
}
function onMapMouseUp(e) {
if (!dragging) return;
document.removeEventListener('mousemove', onMapMouseMove);
document.removeEventListener('mouseup', onMapMouseUp);
_mapMouseCleanup = false;
var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
if (delFromId && !delDrag && !moved) {
showRoomContextMenu(startX, startY, delFromId);
clearDelHighlight();
dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
return;
}
if (delDrag && delFromId && delTargetId) {
deleteLink(delFromId, delTargetId, (delOneWay || e.ctrlKey) ? 'from' : '');
clearDelHighlight();
hideDragBadge();
dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
return;
}
if (linkDrag && linkFromId) {
var oneway = linkDragOneWay || e.ctrlKey;
if (linkTargetId) { createLink(linkFromId, linkTargetId, oneway, linkTargetDir); }
else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir, oneway); }
} else if (!moved && !delDrag) {
if (e.target.classList.contains('rm')) {
selectRoom(parseInt(e.target.getAttribute('data-id')));
} else if (e.target.classList.contains('ghost')) {
createRoom(selectedRoom, e.target.getAttribute('data-dir'), e.ctrlKey);
}
}
clearLinkHighlight();
clearDelHighlight();
hideDragBadge();
dragging = false; dragRoom = false;
linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
}
function cancelMapDrag() {
if (!dragging) return;
document.removeEventListener('mousemove', onMapMouseMove);
document.removeEventListener('mouseup', onMapMouseUp);
_mapMouseCleanup = false;
clearLinkHighlight();
clearDelHighlight();
hideDragBadge();
dragging = false; dragRoom = false;
linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
}
function showDragBadge(text, oneway) {
var badge = document.getElementById('linkModeBadge');
if (!badge) return;
badge.textContent = text;
badge.className = 'link-mode-badge' + (oneway ? ' oneway' : '');
badge.style.display = '';
}
function hideDragBadge() {
var badge = document.getElementById('linkModeBadge');
if (badge) badge.style.display = 'none';
}
function showLinkDragBadge() {
var label = linkDragOneWay ? 'One way link' : 'Two-way link';
var toLabel = linkTargetId ? linkTargetId : (linkGhostDir ? 'new' : null);
if (linkFromId && toLabel) label += ' (#' + linkFromId + ' to #' + toLabel + ')';
showDragBadge(label, linkDragOneWay);
}
function showDelDragBadge() {
var label = delOneWay ? 'One way remove' : 'Two-way remove';
if (delFromId && delTargetId) label += ' (#' + delFromId + ' to #' + delTargetId + ')';
showDragBadge(label, delOneWay);
}
function refreshDragMode(ctrlDown) {
if (linkDrag && linkFromId) { linkDragOneWay = ctrlDown; showLinkDragBadge(); }
if (delDrag && delFromId) { delOneWay = ctrlDown; showDelDragBadge(); }
}
function changeZ(dz) {
if (dz === 0) currentZ = 0; else currentZ += dz;
panX = 0; panY = 0; scale = 1;
loadMap();
}
function changeDir(dir) {
currentDir = dir;
dirChangedByUser = true;
selectedRoom = null;
panX = 0; panY = 0; scale = 1;
loadMap();
}
function zoomIn() {
scale *= 1.1;
if (scale > 5) scale = 5;
updateView();
}
function zoomOut() {
scale *= 0.9;
if (scale < 0.1) scale = 0.1;
updateView();
}
function loadMap() {
$('#zLabel').textContent = 'Z=' + currentZ;
var url = '/api/map?z=' + currentZ;
if (currentDir) url += '&dir=' + encodeURIComponent(currentDir);
var wasDirChange = dirChangedByUser;
dirChangedByUser = false;
return API.get(url).then(function(data) {
mapData = data;
var dirUpdated = false;
if (!currentDir && data.dir) {
currentDir = data.dir;
dirUpdated = true;
}
if (dirUpdated || wasDirChange) {
updateDirSelect();
}
renderMap(data);
renderDisconnectedList(data);
var newRoomBtn = $('#newRoomBtn');
var newRoomSep = $('#newRoomSep');
if (newRoomBtn && newRoomSep) {
var empty = !data.rooms || data.rooms.length === 0;
newRoomBtn.style.display = empty ? '' : 'none';
newRoomSep.style.display = empty ? '' : 'none';
}
if (wasDirChange && data.seed) {
selectRoom(data.seed);
}
}).catch(function(e) {
notify('Failed to load map: ' + e.message, 'error');
});
}
function blendColors(colorA, colorB) {
if (!colorA && !colorB) return '#666666';
if (!colorB) return xtermToCss(parseXtermIdx(colorA));
if (!colorA) return xtermToCss(parseXtermIdx(colorB));
var idxA = parseXtermIdx(colorA);
var idxB = parseXtermIdx(colorB);
var rgbA = xtermToRGB(idxA);
var rgbB = xtermToRGB(idxB);
var r = Math.round((rgbA[0] + rgbB[0]) / 2);
var g = Math.round((rgbA[1] + rgbB[1]) / 2);
var b = Math.round((rgbA[2] + rgbB[2]) / 2);
return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1).toUpperCase();
}
function parseXtermIdx(v) {
if (!v) return 0;
v = String(v).toUpperCase().replace(/[^0-9A-F]/g, '');
if (v.length >= 2) return parseInt(v.substring(0, 2), 16) || 0;
return 0;
}
function renderMap(data) {
var svg = $('#mapSvg');
var container = $('#mapContainer');
var W = container.clientWidth;
var H = container.clientHeight;
roomMap = {}; occupied = {};
upTarget = {}; downTarget = {};
var hasUp = {}, hasDown = {};
if (data.rooms) data.rooms.forEach(function(r) {
roomMap[r.id] = r;
occupied[r.x+','+r.y] = r.id;
});
if (data.upLinks) data.upLinks.forEach(function(l) {
upTarget[l.from] = l.to; hasUp[l.from] = true;
});
if (data.downLinks) data.downLinks.forEach(function(l) {
downTarget[l.from] = l.to; hasDown[l.from] = true;
});
var g = '';
if (data.links) {
data.links.forEach(function(l) {
var fr = roomMap[l.from], tr = roomMap[l.to];
if (!fr || !tr) return;
var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2;
var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2;
if (l.hidden) {
// Hidden course-link exit: distinct color (map_course, ~#1B6FC0) and
// tighter dashed arrow style.
g += '';
return;
}
var c = blendColors(fr.color, tr.color).replace('#','');
g += '';
});
}
var roomHTML = '';
if (data.rooms) {
data.rooms.forEach(function(r) {
var x = r.x * CELL + 8, y = r.y * CELL + 8, w = CELL-16, h = CELL-16;
var fill = resolveColor(r.color);
var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff';
var sel = selectedRoom === r.id;
roomHTML += '';
var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)';
roomHTML += '#'+r.id+'';
roomHTML += wrapText(r.name, x+w/2, y+h/2+2, w, txtColor);
if (hasUp[r.id] && upTarget[r.id]) {
roomHTML += '\u2191';
}
if (hasDown[r.id] && downTarget[r.id]) {
roomHTML += '\u2193';
}
if (sel) {
var btnBW = 18;
roomHTML += '+\u2191';
roomHTML += '+\u2193';
}
});
}
var ghostHTML = '';
if (selectedRoom && roomMap[selectedRoom]) {
var sr = roomMap[selectedRoom];
gridDirs.forEach(function(d) {
var nx = sr.x + d.dx, ny = sr.y + d.dy;
var key = nx+','+ny;
if (!occupied[key]) {
ghostHTML += '';
ghostHTML += '+';
}
});
}
var innerHTML = g + roomHTML + ghostHTML;
var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
svg.setAttribute('viewBox', vb.join(' '));
svg.setAttribute('width', W);
svg.setAttribute('height', H);
svg.innerHTML = '' + innerHTML + '';
svg.oncontextmenu = function(e) { e.preventDefault(); };
if (!svg._ctxWired) {
svg.addEventListener('contextmenu', function(e) { e.preventDefault(); e.stopPropagation(); });
svg._ctxWired = true;
}
if (!svg._mapKeysWired) {
svg.setAttribute('tabindex', '0');
svg.addEventListener('keydown', function(e) {
if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); }
});
svg.addEventListener('keyup', function(e) {
if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); }
});
svg._mapKeysWired = true;
}
svg.onmousedown = function(e) {
if (e.button === 2) e.preventDefault();
var onRoom = e.target.classList.contains('rm');
var onGhost = e.target.classList.contains('ghost');
var onUdBtn = e.target.classList.contains('ud-btn');
var onNavBtn = e.target.classList.contains('nav-btn');
if (onNavBtn) {
e.stopPropagation();
var dz = parseInt(e.target.getAttribute('data-dz'));
var tid = parseInt(e.target.getAttribute('data-target'));
currentZ += dz;
loadMap().then(function() { selectRoom(tid); });
return;
}
if (e.button === 2 && onRoom) {
delFromId = parseInt(e.target.getAttribute('data-id'));
delDrag = false; delTargetId = null; delOneWay = false;
dragging = true;
if (svg.focus) svg.focus();
startX = e.clientX; startY = e.clientY;
prevX = e.clientX; prevY = e.clientY;
dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null;
clearLinkHighlight();
document.addEventListener('mousemove', onMapMouseMove);
document.addEventListener('mouseup', onMapMouseUp);
_mapMouseCleanup = true;
return;
}
if (e.button !== 0) return;
if (onUdBtn) {
e.stopPropagation();
var dir = e.target.getAttribute('data-dir');
createRoom(selectedRoom, dir);
return;
}
dragRoom = onRoom || onGhost;
dragging = true;
if (svg.focus) svg.focus();
startX = e.clientX; startY = e.clientY;
prevX = e.clientX; prevY = e.clientY;
linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null;
linkDrag = false;
linkTargetId = null;
linkGhostDir = null;
linkDragOneWay = onRoom ? (e.ctrlKey || ctrlHeld) : false;
delDrag = false; delFromId = null; delTargetId = null;
clearLinkHighlight();
document.addEventListener('mousemove', onMapMouseMove);
document.addEventListener('mouseup', onMapMouseUp);
_mapMouseCleanup = true;
};
svg.onwheel = function(e) {
e.preventDefault();
var zf = e.deltaY < 0 ? 1.1 : 0.9;
scale *= zf;
if (scale < 0.1) scale = 0.1;
if (scale > 5) scale = 5;
updateView();
};
}
function wrapText(name, cx, cy, maxWidth, color) {
if (!name) name = '';
color = color || '#fff';
var charW = 6;
var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW));
var words = name.split(/\s+/);
var lines = [];
var cur = '';
words.forEach(function(w) {
if (w.length > maxLen) {
if (cur) { lines.push(cur); cur = ''; }
for (var j = 0; j < w.length; j += maxLen) {
lines.push(w.substring(j, j + maxLen));
}
} else if (!cur) {
cur = w;
} else if ((cur + ' ' + w).length <= maxLen) {
cur += ' ' + w;
} else {
lines.push(cur);
cur = w;
}
});
if (cur) lines.push(cur);
if (lines.length === 0) lines.push('');
if (lines.length > 3) lines = lines.slice(0, 3);
var dy = -(lines.length - 1) * 6;
return lines.map(function(l, i) {
return ''+esc(l)+'';
}).join('');
}
function updateView() {
var svg = $('#mapSvg');
if (!svg) return;
var W = svg.parentElement.clientWidth;
var H = svg.parentElement.clientHeight;
var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
svg.setAttribute('viewBox', vb.join(' '));
}
function selectRoom(id) {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
var prev = selectedRoom;
selectedRoom = id;
if (mapData && prev !== id) {
renderMap(mapData);
}
API.get('/api/rooms/' + id).then(function(data) {
renderSidePanel(data);
loadRoomCourseData(id);
}).catch(function(e) {
notify('Failed to load room: '+e.message, 'error');
});
}
var currentRoomData = null;
var lastDcDir = null;
function renderSidePanel(data) {
var panel = $('#panelContent');
if (!data || !data.room) {
panel.innerHTML = '
Room not found
';
return;
}
currentRoomData = data;
var r = data.room;
var file = data.file || '';
var objects = Array.isArray(r.objects) ? r.objects : [];
var localObjs = objects.filter(function(o) { return !o.id; });
var refObjs = objects.filter(function(o) { return !!o.id; });
var spawns = Array.isArray(r.item_spawns) ? r.item_spawns : [];
var mobs = Array.isArray(r.mobs) ? r.mobs : [];
var html = '
';
html += '';
html += '';
html += '';
html += '';
html += '';
html += '';
html += '';
html += '';
html += '
';
html += '
';
if (currentPanelTab === 0) html += renderRoomTab(r, file);
else if (currentPanelTab === 1) html += renderExitsTab(r);
else if (currentPanelTab === 2) html += renderLocalTab(localObjs);
else if (currentPanelTab === 3) html += renderObjectsTab(refObjs);
else if (currentPanelTab === 4) html += renderMobsTab(mobs);
else if (currentPanelTab === 5) html += renderItemsTab(spawns);
else if (currentPanelTab === 6) html += renderHazardTab(r);
else if (currentPanelTab === 7) {
if (currentRoomData.course === undefined) {
html += renderCourseTabLoading();
loadRoomCourseData(r.id);
} else if (currentRoomData.course === null) {
html += renderCourseTabEmpty();
} else {
html += renderCourseTab();
}
}
html += '
';
panel.innerHTML = html;
setTimeout(function() {
var cf = document.querySelector('#panelContent .color-field');
if (cf) {
bindColorField(cf);
var ci = cf.querySelector('input');
if (ci) ci.addEventListener('input', function() { autoSave(); });
}
loadRoomDirs(function(dirs) {
var current = getRoomDirFromFile(file);
var areaSel = $('#dirSelect');
if (areaSel && current) {
areaSel.value = current;
loadDisconnectedRooms(current);
}
var sel = $('#roomDir');
if (!sel) return;
sel.innerHTML = '';
dirs.forEach(function(d) {
var opt = document.createElement('option');
opt.value = d;
opt.textContent = d || '(root)';
if (d === current) opt.selected = true;
sel.appendChild(opt);
});
});
}, 50);
}
function renderRoomTab(r, file) {
var html = '
' + esc(file) + '
';
html += '';
html += '';
html += '
';
var descText = '';
if (Array.isArray(r.description)) {
descText = r.description.map(function(d) { return d.text || ''; }).join('\n\n');
} else if (typeof r.description === 'string') {
descText = r.description;
}
html += '';
html += '';
html += '';
return html;
}
function renderExitsTab(r) {
return renderExitsSection(r);
}
function renderHazardTab(r) {
var html = '
';
html += '';
html += '';
html += '
';
var current = r.hazard || '';
if (current) {
html += '
' + esc(current) + '
';
}
return html;
}
// ---- Course tab ---------------------------------------------------------
var OBSTACLE_VERBS = ['scramble','jump','swing','balance','climb','crawl','vault','leap','slide'];
function loadRoomCourseData(id) {
if (!currentRoomData) return;
// Mark as "fetching" (undefined) so switchPanelTab knows to show Loading
// rather than the empty state, and so re-clicking the tab doesn't refetch.
if (currentRoomData.course === undefined) {
// first time for this room
} else {
currentRoomData.course = undefined;
}
API.get('/api/rooms/' + id + '/course').then(function(c) {
currentRoomData.course = c;
_coursePhases = null;
if (currentPanelTab === 7 && selectedRoom === id) {
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = renderCourseTab();
}
}).catch(function() {
if (currentRoomData) currentRoomData.course = null;
});
}
function renderCourseTabLoading() {
return '
Loading course...
';
}
var _courseDetailsCollapsed = true;
function renderCourseTab() {
var cd = currentRoomData && currentRoomData.course ? currentRoomData.course : null;
if (!cd || cd === null) {
return renderCourseTabEmpty();
}
var h = '';
var obstacle = cd.obstacle || {};
var idx = cd.obstacle_index, total = cd.total_obstacles;
var course = cd.course || {};
var prevRoom = idx > 0 ? (course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].room_id) : 0;
var nextRoom = idx < total - 1 ? (course.obstacles && course.obstacles[idx+1] && course.obstacles[idx+1].room_id) : 0;
var roomID = currentRoomData.room.id;
// Header: Step N, Room #X (This Obstacle)
h += '
';
if (prevRoom) {
h += '';
}
if (nextRoom) {
h += '';
}
if (!prevRoom && !nextRoom) {
h += 'Single-step course';
}
h += '
';
h += '
';
// Obstacle details
h += '
';
h += '';
h += '';
h += '
';
var fd = obstacle.fail_damage || [0,0];
var fdMin = Array.isArray(fd) ? fd[0] : 0;
var fdMax = Array.isArray(fd) ? fd[1] : 0;
h += '';
h += '';
h += '
';
h += '
';
var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : '';
h += '';
h += '
Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.
';
h += '
';
h += '
';
// Phases section
h += '
';
h += '
Phases (messages)
';
h += '';
h += '';
h += '
';
h += '
';
h += '';
h += '
';
// Collapsible course details (starts collapsed)
h += '
';
h += '
';
h += '' + (_courseDetailsCollapsed ? '▶' : '▼') + '';
h += 'Course Details';
h += '
';
if (!_courseDetailsCollapsed) {
h += '
';
h += '';
h += '';
h += '';
h += '';
h += '
';
}
h += '
';
setTimeout(function() { renderCoursePhases(); }, 0);
return h;
}
function courseToggleDetails() {
_courseDetailsCollapsed = !_courseDetailsCollapsed;
if (currentPanelTab === 7 && currentRoomData) {
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = renderCourseTab();
}
}
var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','southeast','southwest'];
function renderCourseTabEmpty() {
var h = '
This room is not part of any course.
';
h += '
';
h += '
';
h += '
';
h += '';
h += '';
h += '
';
h += '';
return h;
}
// ---- phases -------------------------------------------------------------
function getCoursePhases() {
var c = currentRoomData && currentRoomData.course;
if (!c) return [];
var obs = c.obstacle || {};
var phases = obs.phases;
if (!Array.isArray(phases)) {
// build from legacy messages form
var msgs = obs.messages || [];
var tpp = obs.ticks_per_phase || 0;
phases = msgs.map(function(m, i) {
return { message: m, delay: i === 0 ? 0 : tpp, fail_check: (i === 1) };
});
}
return phases;
}
var _coursePhases = null;
function ensureCoursePhasesLoaded() {
if (_coursePhases === null) {
_coursePhases = getCoursePhases().slice();
}
}
function renderCoursePhases() {
ensureCoursePhasesLoaded();
var container = $('#coursePhases');
if (!container) return;
var h = '';
_coursePhases.forEach(function(p, i) {
h += '
';
h += '
';
h += '';
h += '
';
h += '';
h += '';
h += '
';
h += '
';
h += '';
h += '
';
});
container.innerHTML = h;
}
function courseAddPhase() {
ensureCoursePhasesLoaded();
_coursePhases.push({ message: '', delay: 0, fail_check: false });
renderCoursePhases();
courseAutoSave();
}
function courseRemovePhase(i) {
ensureCoursePhasesLoaded();
_coursePhases.splice(i, 1);
renderCoursePhases();
courseAutoSave();
}
function courseUpdatePhase(i) {
ensureCoursePhasesLoaded();
if (i < 0 || i >= _coursePhases.length) return;
var row = document.querySelector('.course-phase[data-idx="' + i + '"]');
if (!row) return;
_coursePhases[i].message = row.querySelector('.phase-msg').value;
_coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0;
courseAutoSave();
}
function courseSetFailCheck(i) {
ensureCoursePhasesLoaded();
_coursePhases.forEach(function(p, j) { p.fail_check = (j === i); });
renderCoursePhases();
courseAutoSave();
}
// ---- save / detach ------------------------------------------------------
function courseSyncPhasesFromDOM() {
ensureCoursePhasesLoaded();
var rows = document.querySelectorAll('.course-phase');
rows.forEach(function(row) {
var i = parseInt(row.getAttribute('data-idx'));
if (i < 0 || i >= _coursePhases.length) return;
var msgEl = row.querySelector('.phase-msg');
var delayEl = row.querySelector('.phase-delay');
if (msgEl) _coursePhases[i].message = msgEl.value;
if (delayEl) _coursePhases[i].delay = parseFloat(delayEl.value) || 0;
});
}
var _courseSaveTimer = null;
function courseAutoSave() {
if (_courseSaveTimer) clearTimeout(_courseSaveTimer);
_courseSaveTimer = setTimeout(function() { courseSave(true); }, 600);
}
function courseSave(silent) {
if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return; }
var c = currentRoomData.course;
var course = c.course || {};
var courseID = c.course_id;
if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; }
courseSyncPhasesFromDOM();
// Course-level fields (may be absent if the details section is collapsed;
// fall back to existing course values).
var nameEl = document.querySelector('#courseName');
var rlEl = document.querySelector('#courseRequiredLevel');
var srEl = document.querySelector('#courseStartRoom');
var cxEl = document.querySelector('#courseCompletionXP');
var name = nameEl ? nameEl.value : (course.name || '');
var reqLevel = rlEl ? (parseInt(rlEl.value) || 0) : (course.required_level || 0);
var startRoom = srEl ? (parseInt(srEl.value) || 0) : (course.start_room || 0);
var completionXP = cxEl ? (parseInt(cxEl.value) || 0) : (course.completion_xp || 0);
var verbEl = document.querySelector('#courseObstacleVerb');
var xpEl = document.querySelector('#courseObstacleXP');
var fminEl = document.querySelector('#courseObstacleFailMin');
var fmaxEl = document.querySelector('#courseObstacleFailMax');
var fcEl = document.querySelector('#courseObstacleFailChance');
var verb = verbEl ? verbEl.value : (c.obstacle && c.obstacle.verb) || 'climb';
var xp = xpEl ? (parseInt(xpEl.value) || 0) : (c.obstacle && c.obstacle.xp) || 0;
var failMin = fminEl ? (parseInt(fminEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[0] : 0);
var failMax = fmaxEl ? (parseInt(fmaxEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[1] : 0);
var fcStr = fcEl ? fcEl.value : (c.obstacle && (c.obstacle.fail_chance !== undefined && c.obstacle.fail_chance !== null) ? String(c.obstacle.fail_chance) : '');
var fc = fcStr === '' ? null : parseFloat(fcStr);
if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; }
var obstacles = (course.obstacles || []).slice();
var newObs = {
room_id: currentRoomData.room.id,
verb: verb,
xp: xp,
fail_damage: [failMin, failMax]
};
if (fc !== null) newObs.fail_chance = fc;
newObs.phases = (_coursePhases || []).map(function(p) {
var ph = { message: p.message || '', delay: p.delay || 0 };
if (p.fail_check) ph.fail_check = true;
return ph;
});
obstacles[c.obstacle_index] = newObs;
var body = {
name: name,
required_level: reqLevel,
start_room: startRoom,
completion_xp: completionXP,
obstacles: obstacles
};
API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() {
if (!silent) notify('Course ' + courseID + ' saved', 'success');
updateUndoBar();
// Refresh the cached course data WITHOUT re-rendering the panel, so the
// builder's cursor isn't disrupted (auto-save uses silent=true).
API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) {
currentRoomData.course = c;
_coursePhases = null;
}).catch(function() {});
}).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); });
}
function courseDetach() {
if (!currentRoomData || !currentRoomData.course) return;
var c = currentRoomData.course;
var total = c.total_obstacles || 1;
var msg = 'Remove room #' + currentRoomData.room.id + ' from course ' + c.course_id + '?';
if (total <= 1) msg = 'This is the last obstacle of course ' + c.course_id + '. Removing it will DELETE the course. Continue?';
if (!confirm(msg)) return;
fetch('/api/rooms/' + currentRoomData.room.id + '/courses/detach', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: '{}',
credentials: 'same-origin'
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.error) { notify(res.error, 'error'); return; }
notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success');
updateUndoBar();
_coursePhases = null;
currentRoomData.course = null;
loadRoomCourseData(currentRoomData.room.id);
var content = document.querySelector('.panel-tab-content');
if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab();
}).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); });
}
// ---- attach / new course ------------------------------------------------
function addRoomToCourse(courseID) {
if (!currentRoomData || !currentRoomData.room) return;
var roomID = currentRoomData.room.id;
var dirSel = document.querySelector('#courseAttachDir');
var dir = dirSel ? dirSel.value : '';
fetch('/api/rooms/' + roomID + '/courses/attach', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ course_id: courseID, after_index: 999, direction: dir }),
credentials: 'same-origin'
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.error) { notify(res.error, 'error'); return; }
notify('Added room #' + roomID + ' to course ' + courseID, 'success');
updateUndoBar();
_coursePhases = null;
loadRoomCourseData(roomID);
}).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); });
}
function searchCourses(input) {
searchAttachShow(input, function(r) { addRoomToCourse(r.id); });
}
function searchAttachShow(input, onSelect) {
var val = input.value.trim();
var results = input.parentElement.querySelector('.search-results');
if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; }
API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
var items = data.courses || [];
if (items.length === 0) {
results.style.display = 'block';
results.innerHTML = '
';
}).join('');
results._items = items;
results._onSelect = onSelect;
}).catch(function() { results.style.display = 'none'; });
}
function selectCourseResult(el) {
var results = el.parentElement;
var idx = Array.prototype.indexOf.call(results.children, el);
if (results._items && results._items[idx] && results._onSelect) {
results._onSelect(results._items[idx]);
results.style.display = 'none';
results.innerHTML = '';
var input = results.parentElement.querySelector('.search-input');
if (input) input.value = '';
}
}
function courseShowNew() {
if (!currentRoomData || !currentRoomData.room) return;
var roomID = currentRoomData.room.id;
var overlay = document.createElement('div');
overlay.className = 'cm-overlay exit-overlay';
var menu = document.createElement('div');
menu.className = 'cm-menu exit-modal';
menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201';
var h = '
New Course (room #' + roomID + ' = step 1)
';
h += '';
h += '';
h += '';
h += '';
h += '';
h += '';
h += '';
h += '
';
h += '';
h += '';
h += '
';
h += '';
h += '';
h += '
';
h += '';
h += '';
h += '
';
menu.innerHTML = h;
document.body.appendChild(overlay);
document.body.appendChild(menu);
overlay.onclick = courseCloseModal;
}
function courseCloseModal() {
var ov = document.querySelector('.exit-overlay');
if (ov) ov.remove();
var m = document.querySelector('.exit-modal');
if (m) m.remove();
}
function courseCreateConfirm() {
if (!currentRoomData || !currentRoomData.room) return;
var roomID = currentRoomData.room.id;
var id = val('#newCourseID');
if (!id) { notify('Course ID required', 'error'); return; }
var name = val('#newCourseName');
var reqLevel = parseInt(val('#newCourseReqLevel')) || 0;
var startRoom = parseInt(val('#newCourseStartRoom')) || 0;
var completionXP = parseInt(val('#newCourseCompletionXP')) || 0;
var verb = sel('#newCourseVerb');
var xp = parseInt(val('#newCourseXP')) || 0;
var failMin = parseInt(val('#newCourseFailMin')) || 0;
var failMax = parseInt(val('#newCourseFailMax')) || 0;
var fcStr = val('#newCourseFailChance');
var fc = fcStr === '' ? null : parseFloat(fcStr);
if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; }
var msg = val('#newCourseMsg') || '';
var obstacle = {
room_id: roomID,
verb: verb,
xp: xp,
fail_damage: [failMin, failMax],
phases: [{ message: msg, delay: 0 }]
};
if (fc !== null) obstacle.fail_chance = fc;
var body = {
id: id,
name: name,
required_level: reqLevel,
start_room: startRoom,
completion_xp: completionXP,
obstacles: [obstacle]
};
API.post('/api/courses', body).then(function(res) {
if (res.error) { notify(res.error, 'error'); return; }
notify('Course ' + id + ' created', 'success');
updateUndoBar();
courseCloseModal();
_coursePhases = null;
loadRoomCourseData(roomID);
}).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
}
// tiny helpers used by the course tab
function val(sel) { var el = document.querySelector(sel); return el ? el.value : ''; }
function sel(sel) { var el = document.querySelector(sel); return el ? el.value : ''; }
function searchHazards(input) {
searchAndShow(input, 'hazards', function(r) { addHazard(r.id); });
}
function addHazard(id) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.hazard = id;
saveNow();
reRenderPanel();
}
function clearHazard() {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.hazard = '';
saveNow();
reRenderPanel();
}
function updateRoomBlockTransport() {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.block_transport = $('#roomBlockTransport').checked;
saveNow();
}
function getRoomDirFromFile(file) {
var rel = (file || '').replace(/^data\/rooms\/?/, '');
var idx = rel.indexOf('/');
if (idx >= 0) {
var subIdx = rel.indexOf('/', idx + 1);
if (subIdx >= 0) return rel.substring(0, subIdx);
return rel.substring(0, idx);
}
return '';
}
function switchPanelTab(idx) {
currentPanelTab = idx;
if (!currentRoomData) return;
var r = currentRoomData.room;
var file = currentRoomData.file || '';
var objects = Array.isArray(r.objects) ? r.objects : [];
var localObjs = objects.filter(function(o) { return !o.id; });
var refObjs = objects.filter(function(o) { return !!o.id; });
var spawns = Array.isArray(r.item_spawns) ? r.item_spawns : [];
var mobs = Array.isArray(r.mobs) ? r.mobs : [];
var html = '';
if (idx === 0) html = renderRoomTab(r, file);
else if (idx === 1) html = renderExitsTab(r);
else if (idx === 2) html = renderLocalTab(localObjs);
else if (idx === 3) html = renderObjectsTab(refObjs);
else if (idx === 4) html = renderMobsTab(mobs);
else if (idx === 5) html = renderItemsTab(spawns);
else if (idx === 6) html = renderHazardTab(r);
else if (idx === 7) {
_coursePhases = null;
if (currentRoomData.course === undefined) {
html = renderCourseTabLoading();
loadRoomCourseData(r.id);
} else if (currentRoomData.course === null) {
html = renderCourseTabEmpty();
} else {
html = renderCourseTab();
}
}
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = html;
var tabs = document.querySelectorAll('.panel-tab');
tabs.forEach(function(t, i) { t.classList.toggle('active', i === idx); });
if (idx === 0) {
setTimeout(function() {
var cf = document.querySelector('#panelContent .color-field');
if (cf) bindColorField(cf);
loadRoomDirs(function(dirs) {
var sel = $('#roomDir');
if (!sel) return;
sel.innerHTML = '';
var current = getRoomDirFromFile(file);
dirs.forEach(function(d) {
var opt = document.createElement('option');
opt.value = d;
opt.textContent = d || '(root)';
if (d === current) opt.selected = true;
sel.appendChild(opt);
});
});
}, 50);
}
}
function renderLocalTab(localObjs) {
var h = '';
localObjs.forEach(function(obj, i) {
var descText = '';
if (Array.isArray(obj.description)) {
descText = obj.description.map(function(d) { return d.text || ''; }).join('\n');
} else if (typeof obj.description === 'string') {
descText = obj.description;
}
h += '
';
h += '
';
h += '';
h += '';
h += '
';
h += '';
h += '';
h += '
';
h += '
';
h += '';
h += '
';
});
h += '';
return h;
}
function renderObjectsTab(refObjs) {
var h = '';
refObjs.forEach(function(obj, i) {
h += '
';
h += '
' + esc(obj.id || '') + '
';
h += '';
h += '';
h += '
';
});
h += '
';
h += '';
h += '';
h += '
';
return h;
}
function renderMobsTab(mobs) {
var h = '';
mobs.forEach(function(mob, i) {
h += '
';
h += '
' + esc(mob.id || '') + '
';
h += '';
h += '';
h += '
';
});
h += '
';
h += '';
h += '';
h += '
';
return h;
}
function renderItemsTab(spawns) {
var h = '';
spawns.forEach(function(spawn, i) {
h += '
';
h += '
';
h += '
';
h += '' + esc(spawn.id || '') + '';
h += '';
h += '';
h += '
';
h += '
';
h += '';
h += '';
h += '
';
});
h += '
';
h += '';
h += '';
h += '
';
return h;
}
function renderExitsSection(r) {
var exits = r.exits || {};
var exitDirs = ['north','south','east','west','northeast','northwest','southeast','southwest','up','down'];
var h = '
';
h += '' + d + '';
h += '# ' + target + '';
if (bmsg) h += 'Blocked: ' + esc(bmsg) + '';
if (cond) h += 'Conditional';
h += '';
if (isBidi) h += '';
h += '';
h += '
';
});
h += '
';
h += '';
h += '';
h += '';
h += '';
h += '
';
h += '
';
return h;
}
var editingExitDir = null;
function editExitCondition(dir) {
editingExitDir = dir;
var exits = (currentRoomData && currentRoomData.room && currentRoomData.room.exits) || {};
var exit = exits[dir];
if (!exit) return;
if (typeof exit !== 'object') { exit = {room: exit}; }
var cond = exit.condition || null;
var bmsg = exit.blocked_message || '';
var setFlags = exit.set_flags || null;
var setPlayerFlags = exit.set_player_flags || null;
var hidden = exit.hidden || false;
var flagsStr = '';
if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', ');
var pflagsStr = '';
if (setPlayerFlags) pflagsStr = Object.keys(setPlayerFlags).map(function(k) { return k + '=' + setPlayerFlags[k]; }).join(', ');
var parsed = parseCondition(cond);
var overlay = document.createElement('div');
overlay.className = 'cm-overlay exit-overlay';
var menu = document.createElement('div');
menu.className = 'cm-menu exit-modal';
menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201';
var h = '
Exit: ' + esc(dir) + ' → #' + exit.room + '
';
h += '';
h += '';
h += '';
h += '';
h += '
';
h += '';
h += '';
h += '';
h += '
';
h += '';
h += '';
h += '';
h += '
';
menu.innerHTML = h;
document.body.appendChild(overlay);
document.body.appendChild(menu);
overlay.onclick = closeExitModal;
menu._condClauses = parsed.clauses;
menu._condMode = parsed.mode;
renderConditionClauses(menu);
}
function parseCondition(cond) {
var clauses = [];
if (!cond || Object.keys(cond).length === 0) return {clauses: clauses, mode: 'all'};
var arr = cond.all_of;
if (!arr || !Array.isArray(arr) || arr.length === 0) arr = cond.any_of;
if (arr && Array.isArray(arr) && arr.length > 0) {
arr.forEach(function(c) {
var clause = extractClause(c);
if (clause) clauses.push(clause);
});
return {clauses: clauses, mode: 'all'};
}
var clause = extractClause(cond);
if (clause) clauses.push(clause);
return {clauses: clauses, mode: 'all'};
}
function extractClause(c) {
var not = c.not === true;
if (c.player_flag && c.player_flag !== '') return {type: 'player_flag', value: c.player_flag, not: not};
if (c.flag && c.flag !== '') return {type: 'flag', value: c.flag, not: not};
if (c.has_item && c.has_item !== '') return {type: 'has_item', value: c.has_item, not: not};
if (c.min_credits && c.min_credits !== 0) return {type: 'min_credits', value: String(c.min_credits), not: not};
if (c.value && typeof c.value === 'object' && c.value.key) return {type: 'value', value: c.value.key + '=' + (c.value.value || ''), not: not};
return null;
}
function renderConditionClauses(menu) {
var container = menu.querySelector('#condClauses');
if (!container) return;
var clauses = menu._condClauses || [];
var h = '';
clauses.forEach(function(clause, i) {
h += '
';
h += '';
if (clause.type === 'value') {
var kv = clause.value ? clause.value.split('=', 2) : ['',''];
var kn = (kv[0] || '').trim();
var kvv = (kv[1] || '').trim();
h += '';
h += '=';
h += '';
} else {
h += '';
}
h += '';
h += '';
h += '
';
});
container.innerHTML = h;
}
function updateClauseData(el) {
var clauseEl = el.closest('.cond-clause');
var menu = el.closest('.exit-modal');
if (!clauseEl || !menu) return;
var idx = parseInt(clauseEl.getAttribute('data-idx'));
var clause = menu._condClauses[idx];
if (!clause) return;
clause.type = clauseEl.querySelector('.cond-type').value;
clause.not = clauseEl.querySelector('.cond-not').checked;
if (clause.type === 'value') {
var k = clauseEl.querySelector('.cond-val-key');
var v = clauseEl.querySelector('.cond-val-val');
clause.value = (k ? k.value.trim() : '') + '=' + (v ? v.value.trim() : '');
} else {
var val = clauseEl.querySelector('.cond-val');
clause.value = val ? val.value : '';
}
}
function addConditionClause() {
var menu = document.querySelector('.exit-modal');
if (!menu) return;
if (!menu._condClauses) menu._condClauses = [];
menu._condClauses.push({type: 'player_flag', value: '', not: false});
renderConditionClauses(menu);
}
function removeConditionClause(btn) {
var menu = btn.closest('.exit-modal');
var clauseEl = btn.closest('.cond-clause');
if (!menu || !clauseEl) return;
var idx = parseInt(clauseEl.getAttribute('data-idx'));
menu._condClauses.splice(idx, 1);
renderConditionClauses(menu);
}
function closeExitModal() {
editingExitDir = null;
var ov = document.querySelector('.exit-overlay');
if (ov) ov.remove();
var m = document.querySelector('.exit-modal');
if (m) m.remove();
}
function saveExitCondition() {
if (!editingExitDir || !currentRoomData || !currentRoomData.room) return;
var menu = document.querySelector('.exit-modal');
if (!menu) return;
var r = currentRoomData.room;
var exits = r.exits || {};
var exit = exits[editingExitDir];
if (!exit) return;
if (typeof exit !== 'object') {
exit = {room: exit};
exits[editingExitDir] = exit;
r.exits = exits;
}
var bmsg = document.getElementById('exitBmsg');
exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : '';
var hiddenEl = document.getElementById('exitHidden');
exit.hidden = hiddenEl ? hiddenEl.checked : false;
var flagsStr = document.getElementById('exitFlags');
var pflagsStr = document.getElementById('exitPFlags');
exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : '');
exit.set_player_flags = parseFlagInput(pflagsStr ? pflagsStr.value : '');
var clauseEls = menu.querySelectorAll('.cond-clause');
clauseEls.forEach(function(el) { updateClauseData(el); });
var clauses = (menu._condClauses || []).filter(function(c) { return c.value && c.value !== '' && c.value !== '='; });
var condition = null;
if (clauses.length === 1) {
condition = buildSingleCondition(clauses[0]);
} else if (clauses.length > 1) {
condition = {all_of: clauses.map(function(c) { return buildSingleCondition(c); })};
}
exit.condition = condition;
r.exits = exits;
autoSave();
closeExitModal();
reRenderPanel();
}
function buildSingleCondition(clause) {
var obj = {};
if (clause.not) obj.not = true;
if (clause.type === 'value') {
var parts = (clause.value || '').split('=', 2);
obj.value = {key: (parts[0] || '').trim(), value: (parts[1] || '').trim()};
} else {
obj[clause.type] = clause.value;
}
return obj;
}
function clearExitCondition() {
var menu = document.querySelector('.exit-modal');
if (!menu) return;
menu._condClauses = [];
renderConditionClauses(menu);
var bmsg = document.getElementById('exitBmsg');
if (bmsg) bmsg.value = '';
var ef = document.getElementById('exitFlags');
if (ef) ef.value = '';
var pf = document.getElementById('exitPFlags');
if (pf) pf.value = '';
}
function parseFlagInput(val) {
if (!val || !val.trim()) return null;
var m = {};
val.split(',').forEach(function(pair) {
var kv = pair.trim().split('=');
if (kv.length === 2) {
var v = kv[1].trim();
if (v === 'true') v = true;
else if (v === 'false') v = false;
else if (!isNaN(v)) v = parseFloat(v);
m[kv[0].trim()] = v;
}
});
return Object.keys(m).length > 0 ? m : null;
}
function addExit() {
if (!selectedRoom || !currentRoomData) return;
var dir = $('#exitDirSelect').value;
var target = parseInt($('#exitTargetID').value);
if (!dir || !target) { notify('Enter target room ID', 'error'); return; }
var reciprocalEl = $('#exitReciprocal');
var reciprocal = reciprocalEl ? reciprocalEl.checked : true;
var body = {from: selectedRoom, to: target, dir: dir};
if (!reciprocal) body.oneway = true;
API.post('/api/rooms/link', body).then(function(res) {
if (res && res.error) { notify('Add exit failed: ' + res.error, 'error'); return; }
notify('Exit added: ' + dir + ' #' + target + (reciprocal ? '' : ' (one-way)'), 'success');
updateUndoBar();
loadMap().then(function() { selectRoom(selectedRoom); });
}).catch(function(e) { notify('Add exit failed: ' + extractError(e), 'error'); });
}
function deleteExit(dir, target) {
if (!selectedRoom || !currentRoomData) return;
fetch('/api/rooms/link', {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({from: selectedRoom, to: target}),
credentials: 'same-origin'
}).then(function(r) {
return r.json().then(function(j) {
if (!r.ok) throw new Error(j.error || 'request failed');
if (j.error) throw new Error(j.error);
return j;
});
}).then(function() {
notify('Removed exit ' + dir + ' to #' + target, 'success');
updateUndoBar();
loadMap().then(function() { selectRoom(selectedRoom); });
}).catch(function(e) { notify('Delete exit failed: ' + extractError(e), 'error'); });
}
function toggleExitHidden(dir) {
if (!currentRoomData || !currentRoomData.room) return;
var exits = currentRoomData.room.exits || {};
var exit = exits[dir];
if (!exit) return;
if (typeof exit !== 'object') {
exit = {room: exit};
exits[dir] = exit;
currentRoomData.room.exits = exits;
}
exit.hidden = !exit.hidden;
autoSave();
reRenderPanel();
}
function extractError(e) {
var msg = e.message || 'unknown error';
try { var j = JSON.parse(msg); if (j.error) msg = j.error; } catch(_) {}
return msg;
}
function getRoomObjects() {
if (!currentRoomData || !currentRoomData.room) return [];
var objects = currentRoomData.room.objects || [];
if (!Array.isArray(objects)) return [];
return objects;
}
function setRoomObjects(objs) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.objects = objs;
saveNow();
}
function getRoomSpawns() {
if (!currentRoomData || !currentRoomData.room) return [];
var spawns = currentRoomData.room.item_spawns || [];
if (!Array.isArray(spawns)) return [];
return spawns;
}
function setRoomSpawns(spawns) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.item_spawns = spawns;
saveNow();
}
function getRoomMobs() {
if (!currentRoomData || !currentRoomData.room) return [];
var mobs = currentRoomData.room.mobs || [];
if (!Array.isArray(mobs)) return [];
return mobs;
}
function setRoomMobs(mobs) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.mobs = mobs;
saveNow();
}
function addLocalObject() {
var objs = getRoomObjects();
objs.push({name: '', description: [{text: ''}], aliases: [], hidden: false});
setRoomObjects(objs);
reRenderPanel();
}
function updateLocalObject(i) {
var objs = getRoomObjects();
var localObjs = objs.filter(function(o) { return !o.id; });
if (i < 0 || i >= localObjs.length) return;
var row = document.querySelector('.panel-tab-content .entry-row[data-idx="' + i + '"]');
if (!row) return;
var nameEl = row.querySelector('.local-name');
var descEl = row.querySelector('.local-desc');
var aliasesEl = row.querySelector('.local-aliases');
var hiddenEl = row.querySelector('.local-hidden');
localObjs[i].name = nameEl ? nameEl.value : '';
var descVal = descEl ? descEl.value : '';
if (descVal) {
localObjs[i].description = descVal.split('\n').map(function(line) { return {text: line}; });
} else {
localObjs[i].description = [];
}
if (aliasesEl && aliasesEl.value) {
localObjs[i].aliases = aliasesEl.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
} else {
localObjs[i].aliases = [];
}
localObjs[i].hidden = hiddenEl ? hiddenEl.checked : false;
var refObjs = objs.filter(function(o) { return !!o.id; });
setRoomObjects(localObjs.concat(refObjs));
}
function removeLocalObject(i) {
var objs = getRoomObjects();
var localObjs = objs.filter(function(o) { return !o.id; });
if (i < 0 || i >= localObjs.length) return;
localObjs.splice(i, 1);
var refObjs = objs.filter(function(o) { return !!o.id; });
setRoomObjects(localObjs.concat(refObjs));
reRenderPanel();
}
function removeRefObject(i) {
var objs = getRoomObjects();
var refObjs = objs.filter(function(o) { return !!o.id; });
if (i < 0 || i >= refObjs.length) return;
refObjs.splice(i, 1);
var localObjs = objs.filter(function(o) { return !o.id; });
setRoomObjects(localObjs.concat(refObjs));
reRenderPanel();
}
function addRefObject(id) {
var objs = getRoomObjects();
objs.push({id: id});
setRoomObjects(objs);
reRenderPanel();
}
function searchRefObjects(input) {
searchAndShow(input, 'objects', function(r) { addRefObject(r.id); });
}
function removeSpawn(i) {
var spawns = getRoomSpawns();
spawns.splice(i, 1);
setRoomSpawns(spawns);
reRenderPanel();
}
function updateSpawn(i) {
var spawns = getRoomSpawns();
if (i < 0 || i >= spawns.length) return;
var row = document.querySelector('.panel-tab-content .entry-row[data-idx="' + i + '"]');
if (!row) return;
var qtyEl = row.querySelector('.spawn-qty');
var respawnEl = row.querySelector('.spawn-respawn');
if (qtyEl) spawns[i].quantity = parseInt(qtyEl.value) || 0;
if (respawnEl) spawns[i].respawn_ticks = parseInt(respawnEl.value) || 0;
setRoomSpawns(spawns);
}
function addSpawn(id) {
var spawns = getRoomSpawns();
spawns.push({id: id, quantity: 1, respawn_ticks: 0});
setRoomSpawns(spawns);
reRenderPanel();
}
function searchItemSpawns(input) {
searchAndShow(input, 'items', function(r) { addSpawn(r.id); });
}
function removeMob(i) {
var mobs = getRoomMobs();
mobs.splice(i, 1);
setRoomMobs(mobs);
reRenderPanel();
}
function addMob(id) {
var mobs = getRoomMobs();
mobs.push({id: id});
setRoomMobs(mobs);
reRenderPanel();
}
function searchMobs(input) {
searchAndShow(input, 'mobs', function(r) { addMob(r.id); });
}
function searchAndShow(input, type, onSelect) {
var val = input.value.trim();
var results = input.parentElement.querySelector('.search-results');
if (!val) { results.style.display = 'none'; results.innerHTML = ''; results._selectedIndex = -1; return; }
API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
var items = [];
if (type === 'objects' && data.objects) items = data.objects;
if (type === 'items' && data.items) items = data.items;
if (type === 'mobs' && data.mobs) items = data.mobs;
if (type === 'hazards' && data.hazards) items = data.hazards;
if (items.length === 0) {
results.style.display = 'block';
results.innerHTML = '