aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/static')
-rw-r--r--internal/admin/static/admin.css14
-rw-r--r--internal/admin/static/map.js535
2 files changed, 545 insertions, 4 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 1f6aeec..376bf23 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -281,9 +281,9 @@ g.ud-hover:hover text{font-weight:bold}
.panel-tab-content .section-add-btn:hover{background:var(--hover)}
.panel-tab-content .mini-label{font-size:9px;color:#888;display:block;margin-bottom:1px;margin-top:3px}
.panel-tab-content .mini-input{width:60px!important;display:inline-block;margin-right:4px}
-.obj-card{background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:5px;margin-bottom:10px;overflow:hidden;break-inside:avoid;display:inline-block;width:100%}
-.obj-card.obj-card-full{column-span:all}
-.obj-fields-wrap{column-count:2;column-gap:10px}
+.obj-card{background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:5px;margin-bottom:10px;overflow:hidden;flex:1 1 calc(50% - 5px);min-width:340px;max-width:calc(50% - 5px)}
+.obj-card.obj-card-full{flex:1 1 100%;max-width:100%}
+.obj-fields-wrap{display:flex;flex-wrap:wrap;gap:10px}
.obj-card-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(15,52,96,.3);cursor:pointer;user-select:none;font-size:13px;font-weight:bold}
.obj-card-header:hover{background:rgba(15,52,96,.5)}
.obj-card-arrow{font-size:10px;width:14px;color:#888}
@@ -355,3 +355,11 @@ g.ud-hover:hover text{font-weight:bold}
.ingredients-col-byproducts{flex:1;min-width:0}
.ingredients-col-x{width:26px;display:flex;align-items:center;justify-content:center}
.ingredients-col-num{width:24px;text-align:center;color:#888;font-size:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
+
+/* Course tab (map side panel) */
+.course-header{border-bottom:1px solid var(--border);padding-bottom:6px;margin-bottom:8px}
+.course-obstacle-section,.course-phases-section{margin-top:10px;border-top:1px solid var(--border);padding-top:6px}
+.section-divider{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
+.course-phase .phase-msg{font-family:monospace}
+.course-phase .mini-input{width:auto!important;flex:1}
+.course-details-card{margin-top:10px}
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 740edf3..f0b4d87 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -200,6 +200,12 @@ function renderMap(data) {
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 += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#1B6FC0" stroke-width="5" opacity="0.75" stroke-dasharray="2,2" vector-effect="non-scaling-stroke"/>';
+ return;
+ }
var c = blendColors(fr.color, tr.color).replace('#','');
g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.55"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>';
});
@@ -370,6 +376,7 @@ function selectRoom(id) {
API.get('/api/rooms/' + id).then(function(data) {
renderSidePanel(data);
+ loadRoomCourseData(id);
}).catch(function(e) {
notify('Failed to load room: '+e.message, 'error');
});
@@ -402,6 +409,7 @@ function renderSidePanel(data) {
html += '<button class="panel-tab' + (currentPanelTab === 4 ? ' active' : '') + '" onclick="switchPanelTab(4)">Mobs</button>';
html += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>';
html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 7 ? ' active' : '') + '" onclick="switchPanelTab(7)">Course</button>';
html += '</div>';
html += '<div class="panel-tab-content">';
@@ -412,6 +420,16 @@ function renderSidePanel(data) {
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 += '</div>';
panel.innerHTML = html;
@@ -477,6 +495,489 @@ function renderHazardTab(r) {
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 '<p style="color:#888;text-align:center;margin-top:30px">Loading course...</p>';
+}
+
+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 += '<div class="course-header">';
+ h += '<h3 style="margin:0 0 4px 0">Step ' + (idx + 1) + ', Room #' + roomID + '</h3>';
+ h += '<div style="font-size:11px;color:#aaa;margin-bottom:6px">(This Obstacle) &mdash; ' + esc(cd.course_name || cd.course_id || '') + '</div>';
+ // Prev/Next buttons row
+ h += '<div style="display:flex;gap:6px;margin-bottom:6px">';
+ if (prevRoom) {
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">&larr; Step ' + idx + ' (#' + prevRoom + ')</button>';
+ }
+ if (nextRoom) {
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') &rarr;</button>';
+ }
+ if (!prevRoom && !nextRoom) {
+ h += '<span style="font-size:11px;color:#888">Single-step course</span>';
+ }
+ h += '</div>';
+ h += '</div>';
+
+ // Obstacle details
+ h += '<div class="course-obstacle-section">';
+ h += '<div class="form-group"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">';
+ OBSTACLE_VERBS.forEach(function(v) {
+ h += '<option value="' + v + '"' + (obstacle.verb === v ? ' selected' : '') + '>' + v + '</option>';
+ });
+ h += '</select></div>';
+ h += '<div class="form-group"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">';
+ var fd = obstacle.fail_damage || [0,0];
+ var fdMin = Array.isArray(fd) ? fd[0] : 0;
+ var fdMax = Array.isArray(fd) ? fd[1] : 0;
+ h += '<input id="courseObstacleFailMin" type="number" value="' + fdMin + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '<input id="courseObstacleFailMax" type="number" value="' + fdMax + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '</div></div>';
+ h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label>';
+ var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : '';
+ h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.</div>';
+ h += '</div>';
+ h += '</div>';
+
+ // Phases section
+ h += '<div class="course-phases-section">';
+ h += '<div class="section-divider">Phases (messages)</div>';
+ h += '<div id="coursePhases"></div>';
+ h += '<button class="btn btn-sm section-add-btn" id="addPhaseBtn" onclick="courseAddPhase()">+ Add Message</button>';
+ h += '</div>';
+
+ h += '<div class="btn-row" style="margin-top:10px">';
+ h += '<button class="btn btn-danger" onclick="courseDetach()">Remove from course</button>';
+ h += '</div>';
+
+ // Collapsible course details (starts collapsed)
+ h += '<div class="course-details-card" style="margin-top:12px">';
+ h += '<div class="obj-card-header" onclick="courseToggleDetails()">';
+ h += '<span class="obj-card-arrow">' + (_courseDetailsCollapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<span>Course Details</span>';
+ h += '</div>';
+ if (!_courseDetailsCollapsed) {
+ h += '<div class="obj-card-body">';
+ h += '<div class="form-group"><label>Course Name</label><input id="courseName" value="' + escAttr(course.name || '') + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Start Room (hub / fail return)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Completion XP (lap bonus)</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
+ }
+ h += '</div>';
+
+ 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 = '<p style="color:#888;font-size:11px;margin:6px 0 12px">This room is not part of any course.</p>';
+ h += '<div class="form-group"><label>Direction for new step exit</label>';
+ h += '<select id="courseAttachDir"><option value="">auto</option>';
+ HORIZONTAL_DIRS.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; });
+ h += '</select></div>';
+ h += '<div class="section-search">';
+ h += '<input type="text" class="search-input" placeholder="Add to course..." oninput="searchCourses(this)">';
+ h += '<div class="search-results" style="display:none"></div>';
+ h += '</div>';
+ h += '<button class="btn btn-sm section-add-btn" style="margin-top:8px" onclick="courseShowNew()">+ New Course</button>';
+ 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 += '<div class="entry-row course-phase" data-idx="' + i + '">';
+ h += '<div class="entry-fields">';
+ h += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>';
+ h += '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">';
+ h += '<label class="mini-label">Delay</label><input class="mini-input phase-delay" type="text" inputmode="numeric" value="' + (p.delay || 0) + '" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')">';
+ h += '<label class="mini-label" style="display:flex;align-items:center;gap:3px"><input type="radio" class="phase-fail" name="phaseFail" onchange="courseSetFailCheck(' + i + ')"' + (p.fail_check ? ' checked' : '') + '> Fail check here</label>';
+ h += '</div>';
+ h += '</div>';
+ h += '<button class="btn btn-sm btn-danger" onclick="courseRemovePhase(' + i + ')">X</button>';
+ h += '</div>';
+ });
+ 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 = '<div class="sr-item" style="color:#888">No results</div>';
+ return;
+ }
+ results.style.display = 'block';
+ results.innerHTML = items.map(function(r) {
+ return '<div class="sr-item" onclick="event.stopPropagation(); selectCourseResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</div>';
+ }).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 = '<h3 style="margin-bottom:8px;font-size:13px">New Course (room #' + roomID + ' = step 1)</h3>';
+ h += '<div class="form-group"><label>Course ID (filename)</label><input id="newCourseID" placeholder="e.g. vent_shaft" ></div>';
+ h += '<div class="form-group"><label>Course Name</label><input id="newCourseName" placeholder="e.g. Ventilation Shaft Course"></div>';
+ h += '<div class="form-group"><label>Required Level</label><input id="newCourseReqLevel" type="number" value="1"></div>';
+ h += '<div class="form-group"><label>Start Room</label><input id="newCourseStartRoom" type="number" value="' + roomID + '"></div>';
+ h += '<div class="form-group"><label>Completion XP</label><input id="newCourseCompletionXP" type="number" value="0"></div>';
+ h += '<div class="form-group"><label>Verb</label><select id="newCourseVerb">';
+ OBSTACLE_VERBS.forEach(function(v) {
+ h += '<option value="' + v + '"' + (v === 'climb' ? ' selected' : '') + '>' + v + '</option>';
+ });
+ h += '</select></div>';
+ h += '<div class="form-group"><label>XP</label><input id="newCourseXP" type="number" value="0"></div>';
+ h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">';
+ h += '<input id="newCourseFailMin" type="number" value="0" style="flex:1">';
+ h += '<input id="newCourseFailMax" type="number" value="0" style="flex:1">';
+ h += '</div></div>';
+ h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label><input id="newCourseFailChance" type="text" placeholder="derived"></div>';
+ h += '<div class="form-group"><label>First message</label><textarea id="newCourseMsg" rows="2" placeholder="You approach the obstacle..."></textarea></div>';
+ h += '<div class="btn-row" style="margin-top:10px">';
+ h += '<button class="btn btn-primary" onclick="courseCreateConfirm()">Create</button>';
+ h += '<button class="btn" onclick="courseCloseModal()">Cancel</button>';
+ h += '</div>';
+
+ 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); });
}
@@ -528,6 +1029,17 @@ function switchPanelTab(idx) {
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');
@@ -646,10 +1158,12 @@ function renderExitsSection(r) {
var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : '';
var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null;
var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null;
- var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags);
+ var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false;
+ var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden);
h += '<div class="exit-row" data-dir="' + d + '">';
h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>';
h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>';
+ h += '<label style="font-size:11px;margin-left:4px;cursor:pointer;color:#aaa"><input type="checkbox" class="exit-hidden" style="vertical-align:middle"' + (hidden ? ' checked' : '') + ' onchange="toggleExitHidden(\'' + d + '\')"> Hidden</label>';
if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>';
if (cond) h += '<span class="exit-meta">Conditional</span>';
h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>';
@@ -679,6 +1193,7 @@ function editExitCondition(dir) {
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(', ');
@@ -697,6 +1212,7 @@ function editExitCondition(dir) {
h += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>';
h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>';
h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>';
+ h += '<div class="form-group"><label><input type="checkbox" id="exitHidden"' + (hidden ? ' checked' : '') + '> Hidden</label></div>';
h += '<div style="margin-top:10px;border-top:1px solid var(--border);padding-top:8px">';
h += '<label style="font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px">Conditions</label>';
@@ -834,6 +1350,8 @@ function saveExitCondition() {
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 : '');
@@ -928,6 +1446,21 @@ function deleteExit(dir, target) {
}).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(_) {}