';
@@ -733,6 +784,7 @@ function getCoursePhases() {
}
var _coursePhases = null;
+var _courseFailShown = null;
function ensureCoursePhasesLoaded() {
if (_coursePhases === null) {
@@ -748,11 +800,13 @@ function renderCoursePhases() {
_coursePhases.forEach(function(p, i) {
h += '
';
h += '
';
- h += '
';
- h += '
';
h += '
X ';
h += '
';
@@ -765,6 +819,7 @@ function courseAddPhase() {
_coursePhases.push({ message: '', delay: 0, fail_check: false });
renderCoursePhases();
courseAutoSave();
+ updateCourseIndicator();
}
function courseRemovePhase(i) {
@@ -772,6 +827,7 @@ function courseRemovePhase(i) {
_coursePhases.splice(i, 1);
renderCoursePhases();
courseAutoSave();
+ updateCourseIndicator();
}
function courseUpdatePhase(i) {
@@ -782,6 +838,7 @@ function courseUpdatePhase(i) {
_coursePhases[i].message = row.querySelector('.phase-msg').value;
_coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0;
courseAutoSave();
+ updateCourseIndicator();
}
function courseSetFailCheck(i) {
@@ -789,6 +846,34 @@ function courseSetFailCheck(i) {
_coursePhases.forEach(function(p, j) { p.fail_check = (j === i); });
renderCoursePhases();
courseAutoSave();
+ updateCourseIndicator();
+}
+
+function courseAddFailure() {
+ _courseFailShown = true;
+ if (currentPanelTab === 7 && currentRoomData) {
+ var content = document.querySelector('.panel-tab-content');
+ if (content) content.innerHTML = renderCourseTab();
+ }
+}
+
+function courseRemoveFailure() {
+ _courseFailShown = false;
+ ensureCoursePhasesLoaded();
+ _coursePhases.forEach(function(p) { p.fail_check = false; });
+ var fminEl = document.querySelector('#courseObstacleFailMin');
+ var fmaxEl = document.querySelector('#courseObstacleFailMax');
+ var frmEl = document.querySelector('#courseObstacleOnFailRoom');
+ var fcEl = document.querySelector('#courseObstacleFailChance');
+ if (fminEl) fminEl.value = '0';
+ if (fmaxEl) fmaxEl.value = '0';
+ if (frmEl) frmEl.value = '';
+ if (fcEl) fcEl.value = '';
+ courseSave(true);
+ if (currentPanelTab === 7 && currentRoomData) {
+ var content = document.querySelector('.panel-tab-content');
+ if (content) content.innerHTML = renderCourseTab();
+ }
}
// ---- save / detach ------------------------------------------------------
@@ -808,17 +893,20 @@ function courseSyncPhasesFromDOM() {
var _courseSaveTimer = null;
+function updateCourseIndicator() {
+}
+
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; }
+ if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return Promise.resolve(); }
var c = currentRoomData.course;
var course = c.course || {};
var courseID = c.course_id;
- if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; }
+ if (!courseID) { if (!silent) notify('Missing course id', 'error'); return Promise.resolve(); }
courseSyncPhasesFromDOM();
@@ -838,6 +926,8 @@ function courseSave(silent) {
var fminEl = document.querySelector('#courseObstacleFailMin');
var fmaxEl = document.querySelector('#courseObstacleFailMax');
var fcEl = document.querySelector('#courseObstacleFailChance');
+ var exitDirEl = document.querySelector('#courseExitDir');
+ var onFailEl = document.querySelector('#courseObstacleOnFailRoom');
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);
@@ -845,8 +935,10 @@ function courseSave(silent) {
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 exitDir = exitDirEl ? exitDirEl.value : (c.obstacle && c.obstacle.exit_dir) || '';
var obstacles = (course.obstacles || []).slice();
+ var onFail = onFailEl ? (parseInt(onFailEl.value) || 0) : (c.obstacle && c.obstacle.on_fail) || 0;
var newObs = {
room_id: currentRoomData.room.id,
verb: verb,
@@ -854,6 +946,8 @@ function courseSave(silent) {
fail_damage: [failMin, failMax]
};
if (fc !== null) newObs.fail_chance = fc;
+ if (exitDir) newObs.exit_dir = exitDir;
+ if (onFail) newObs.on_fail = onFail;
newObs.phases = (_coursePhases || []).map(function(p) {
var ph = { message: p.message || '', delay: p.delay || 0 };
if (p.fail_check) ph.fail_check = true;
@@ -869,25 +963,23 @@ function courseSave(silent) {
obstacles: obstacles
};
- API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() {
- if (!silent) notify('Course ' + courseID + ' saved', 'success');
+ return API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function(resp) {
+ notify('Course ' + courseID + ' saved', 'success');
+ if (resp && resp.warnings && resp.warnings.length > 0) {
+ resp.warnings.forEach(function(w) { notify(w, 'error'); });
+ }
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) {
+ return API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) {
currentRoomData.course = c;
_coursePhases = null;
+ _courseFailShown = null;
}).catch(function() {});
- }).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); });
+ }).catch(function(e) { 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'},
@@ -898,6 +990,7 @@ function courseDetach() {
notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success');
updateUndoBar();
_coursePhases = null;
+ _courseFailShown = null;
currentRoomData.course = null;
loadRoomCourseData(currentRoomData.room.id);
var content = document.querySelector('.panel-tab-content');
@@ -905,7 +998,107 @@ function courseDetach() {
}).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); });
}
-// ---- attach / new course ------------------------------------------------
+// ---- attach / new course / add next step ---------------------------------
+
+// ensureCourseExit guarantees an exit exists from fromID toward dir.
+// If exitExists is already true the current exit is left untouched;
+// otherwise a hidden, always-blocked, one-way exit to toID is created.
+function ensureCourseExit(fromID, toID, dir, exitExists) {
+ if (exitExists) return Promise.resolve();
+ return API.post('/api/rooms/link', {
+ from: fromID, to: toID, dir: dir,
+ oneway: true, hidden: true, always_blocked: true
+ }).then(function(res) {
+ if (res && res.error) { throw new Error(res.error); }
+ });
+}
+
+function courseAddNextStep(courseID) {
+ if (!currentRoomData || !currentRoomData.room) return;
+ if (_courseSaveTimer) { clearTimeout(_courseSaveTimer); _courseSaveTimer = null; }
+ var roomID = currentRoomData.room.id;
+ var dirSel = document.querySelector('#courseExitDir');
+ var dir = dirSel ? dirSel.value : '';
+ if (!dir) { notify('Select a direction', 'error'); return; }
+
+ // Resolve the target room in this direction and whether an exit already
+ // exists. Priority: an explicit exit, then a room already on the grid in
+ // that direction, then finally create a brand-new room.
+ var existingExit = (currentRoomData.room.exits || {})[dir];
+ var resolveTarget;
+ if (existingExit) {
+ var target = typeof existingExit === 'object' ? existingExit.room : existingExit;
+ if (!target) { notify('Exit in direction ' + dir + ' has no target room', 'error'); return; }
+ resolveTarget = Promise.resolve({ targetID: target, exitExists: true });
+ } else {
+ var src = roomMap[roomID];
+ var delta = null;
+ for (var i = 0; i < gridDirs.length; i++) { if (gridDirs[i].dir === dir) { delta = gridDirs[i]; break; } }
+ var neighborID = (src && delta) ? occupied[(src.x + delta.dx) + ',' + (src.y + delta.dy)] : null;
+ if (neighborID) {
+ resolveTarget = Promise.resolve({ targetID: neighborID, exitExists: false });
+ } else {
+ // Empty cell: create a new room (backend adds a hidden, blocked one-way exit).
+ resolveTarget = createRoomPromise(roomID, dir, true, true, true).then(function(res) {
+ return { targetID: res.id, exitExists: true };
+ });
+ }
+ }
+
+ resolveTarget.then(function(t) {
+ var targetID = Number(t.targetID);
+ var exitExists = t.exitExists;
+ var cd = currentRoomData.course || {};
+ var course = cd.course || {};
+ var startRoom = Number(course.start_room || 0);
+ var obstacles = course.obstacles || [];
+
+ // Rule 1: target is the course's start room -> complete a loop. Set the
+ // last obstacle's exit_dir and don't add a new step.
+ if (startRoom && targetID === startRoom) {
+ return ensureCourseExit(roomID, targetID, dir, exitExists).then(function() {
+ var exitSel = document.querySelector('#courseExitDir');
+ if (exitSel) exitSel.value = dir;
+ return courseSave(true);
+ }).then(function() {
+ notify('Loop completed: last step exits ' + dir + ' to start room #' + targetID, 'success');
+ return loadMap().then(function() {
+ loadRoomCourseData(currentRoomData.room.id);
+ var content = document.querySelector('.panel-tab-content');
+ if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab();
+ });
+ });
+ }
+
+ // Rule 2: target is already some other step of this course -> refuse.
+ for (var i = 0; i < obstacles.length; i++) {
+ if (Number(obstacles[i] && obstacles[i].room_id) === targetID) {
+ notify('Room #' + targetID + ' is already step ' + (i + 1) + ' of this course.', 'error');
+ return;
+ }
+ }
+
+ // Rule 3: attach the existing/new room as the next step.
+ return ensureCourseExit(roomID, targetID, dir, exitExists).then(function() {
+ return fetch('/api/rooms/' + targetID + '/courses/attach', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ course_id: courseID, after_index: currentRoomData.course.obstacle_index, direction: dir }),
+ credentials: 'same-origin'
+ }).then(function(r) { return r.json(); });
+ }).then(function(res) {
+ if (!res) return;
+ if (res.error) { notify('Add failed: ' + res.error, 'error'); return; }
+ notify('Added step to course ' + courseID, 'success');
+ updateUndoBar();
+ _coursePhases = null;
+ _courseFailShown = null;
+ return loadMap().then(function() {
+ selectRoom(targetID);
+ });
+ });
+ }).catch(function(e) { notify('Add failed: ' + extractError(e), 'error'); });
+}
function addRoomToCourse(courseID) {
if (!currentRoomData || !currentRoomData.room) return;
@@ -922,6 +1115,7 @@ function addRoomToCourse(courseID) {
notify('Added room #' + roomID + ' to course ' + courseID, 'success');
updateUndoBar();
_coursePhases = null;
+ _courseFailShown = null;
loadRoomCourseData(roomID);
}).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); });
}
@@ -1050,6 +1244,7 @@ function courseCreateConfirm() {
updateUndoBar();
courseCloseModal();
_coursePhases = null;
+ _courseFailShown = null;
loadRoomCourseData(roomID);
}).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
}
@@ -1111,6 +1306,7 @@ function switchPanelTab(idx) {
else if (idx === 6) html = renderHazardTab(r);
else if (idx === 7) {
_coursePhases = null;
+ _courseFailShown = null;
if (currentRoomData.course === undefined) {
html = renderCourseTabLoading();
loadRoomCourseData(r.id);
@@ -1925,8 +2121,10 @@ function deleteRoom(id) {
}
}
}
- API.del('/api/rooms/' + id).then(function() {
- notify('Room #' + id + ' deleted', 'success');
+ API.del('/api/rooms/' + id).then(function(res) {
+ var msg = 'Room #' + id + ' deleted';
+ if (res && res.course_cleaned) msg += ' (removed from course ' + res.course_cleaned + ')';
+ notify(msg, 'success');
updateUndoBar();
selectedRoom = null;
$('#panelContent').innerHTML = '
Room deleted
';
@@ -1934,23 +2132,31 @@ function deleteRoom(id) {
}).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); });
}
-function createRoom(fromID, dir, oneway) {
- API.get('/api/next-room-id?from=' + fromID).then(function(r) {
+function createRoomPromise(fromID, dir, oneway, hidden, blocked) {
+ return API.get('/api/next-room-id?from=' + fromID).then(function(r) {
var newID = r.id;
var name = 'Room #' + newID;
var body = { name: name, link_from: fromID, link_dir: dir };
if (oneway) body.link_oneway = true;
+ if (hidden) body.link_hidden = true;
+ if (blocked) body.link_blocked = true;
var srcRoom = roomMap[fromID];
if (srcRoom && srcRoom.color) body.color = srcRoom.color;
- API.post('/api/rooms', body).then(function(resp) {
+ return API.post('/api/rooms', body).then(function(resp) {
var createdId = (resp && resp.room && resp.room.id) || newID;
- notify('Room #' + createdId + ' created', 'success');
- updateUndoBar();
- loadMap().then(function() { selectRoom(createdId); });
- }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
+ return { id: createdId };
+ });
});
}
+function createRoom(fromID, dir, oneway) {
+ createRoomPromise(fromID, dir, oneway, false, false).then(function(res) {
+ notify('Room #' + res.id + ' created', 'success');
+ updateUndoBar();
+ loadMap().then(function() { selectRoom(res.id); });
+ }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
+}
+
function showRoomContextMenu(x, y, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
--
cgit v1.2.3