aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static/map.js
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-07 21:22:39 -0400
committerhistoria <[not public]>2026-07-07 21:22:39 -0400
commit74803657d587b1f93b739c1ee25bb8d62df95413 (patch)
tree0166974468c452bb13454d37d4b83d2e681446c0 /internal/admin/static/map.js
parentbae42946d24bd533cdbc1b271ce14acd752689d0 (diff)
downloadthehouseoficarus-74803657d587b1f93b739c1ee25bb8d62df95413.tar.gz
fix: agility course creation fixed and improved in admin web gui
Diffstat (limited to 'internal/admin/static/map.js')
-rw-r--r--internal/admin/static/map.js292
1 files changed, 249 insertions, 43 deletions
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 9974b2c..7cd4430 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -591,6 +591,7 @@ function loadRoomCourseData(id) {
API.get('/api/rooms/' + id + '/course').then(function(c) {
currentRoomData.course = c;
_coursePhases = null;
+ _courseFailShown = null;
if (currentPanelTab === 7 && selectedRoom === id) {
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = renderCourseTab();
@@ -618,6 +619,8 @@ function renderCourseTab() {
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;
+ var isLast = idx === total - 1;
+ var exitDir = obstacle.exit_dir || '';
// Header: Step N, Room #X (This Obstacle)
h += '<div class="course-header">';
@@ -626,10 +629,11 @@ function renderCourseTab() {
// 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>';
+ var prevDir = course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].exit_dir || '';
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">&larr; Step ' + idx + ' (#' + prevRoom + ')' + (prevDir ? ' from ' + prevDir : '') + '</button>';
}
if (nextRoom) {
- h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') &rarr;</button>';
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') via ' + (exitDir || '?') + ' &rarr;</button>';
}
if (!prevRoom && !nextRoom) {
h += '<span style="font-size:11px;color:#888">Single-step course</span>';
@@ -637,26 +641,71 @@ function renderCourseTab() {
h += '</div>';
h += '</div>';
- // Obstacle details
+ // Direction to next step
h += '<div class="course-obstacle-section">';
- h += '<div class="form-group"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">';
+ h += '<div class="form-group"><label>Direction to next step</label>';
+ h += '<div style="display:flex;gap:8px;align-items:center">';
+ h += '<select id="courseExitDir" onchange="courseAutoSave()" style="flex:1">';
+ h += '<option value="">(not set)</option>';
+ HORIZONTAL_DIRS.forEach(function(d) {
+ h += '<option value="' + d + '"' + (exitDir === d ? ' selected' : '') + '>' + d + '</option>';
+ });
+ h += '</select>';
+ if (isLast) {
+ h += '<button class="btn btn-sm btn-primary" onclick="courseAddNextStep(\'' + esc(cd.course_id) + '\')">Add New Step</button>';
+ }
+ h += '</div>';
+ if (isLast) {
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">Pick a direction and click "Add New Step" to append the next obstacle (and jump to it). Otherwise the room this exit points to is the lap-completion destination.</div>';
+ } else {
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">The exit in this direction is used for the post-phase teleport. Must reference an existing exit.</div>';
+ }
+ h += '</div>';
+
+ // Obstacle details
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><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">';
+ h += '<div class="form-group" style="flex:1"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
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 onFailRoom = obstacle.on_fail || '';
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>';
+ if (_courseFailShown === null) {
+ var hasFailChance = fcVal !== '';
+ var hasFailDmg = fdMin > 0 || fdMax > 0;
+ var hasOnFail = !!obstacle.on_fail;
+ var hasFailCheckPhase = _coursePhases && _coursePhases.some(function(p){ return p.fail_check; });
+ _courseFailShown = hasFailChance || hasFailDmg || hasOnFail || hasFailCheckPhase;
+ }
+ if (_courseFailShown) {
+ h += '<div class="obj-card" style="margin-top:10px">';
+ h += '<div class="obj-card-header">';
+ h += '<span class="obj-card-label">Failure</span>';
+ h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();courseRemoveFailure()">X</button>';
+ h += '</div>';
+ h += '<div class="obj-card-body">';
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Min Dmg</label><input id="courseObstacleFailMin" type="number" value="' + fdMin + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group" style="flex:1"><label>Max Dmg</label><input id="courseObstacleFailMax" type="number" value="' + fdMax + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Fail Room</label><input id="courseObstacleOnFailRoom" type="number" value="' + onFailRoom + '" placeholder="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group" style="flex:1"><label>Fail Chance %</label>';
+ h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '</div>';
+ h += '</div>';
+ 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>';
+ } else {
+ h += '<button class="btn btn-sm section-add-btn" style="margin-top:8px" onclick="courseAddFailure()">+ Add Failure Chance</button>';
+ }
h += '</div>';
// Phases section
@@ -670,7 +719,7 @@ function renderCourseTab() {
h += '<button class="btn btn-danger" onclick="courseDetach()">Remove from course</button>';
h += '</div>';
- // Collapsible course details (starts collapsed)
+ // Collapsible course details
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>';
@@ -679,9 +728,11 @@ function renderCourseTab() {
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 class="form-group"><label>Start Room (fail return hub)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group" style="flex:1"><label>XP per Lap</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
h += '</div>';
}
h += '</div>';
@@ -702,7 +753,7 @@ var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','so
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 += '<div class="form-group"><label>Direction from the previous step to this room</label>';
h += '<select id="courseAttachDir"><option value="">auto</option>';
HORIZONTAL_DIRS.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; });
h += '</select></div>';
@@ -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 += '<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 += '<div style="display:flex;align-items:center;gap:6px;margin-bottom: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>';
+ if (_courseFailShown) {
+ 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 += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>';
h += '</div>';
h += '<button class="btn btn-sm btn-danger" onclick="courseRemovePhase(' + i + ')">X</button>';
h += '</div>';
@@ -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 = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
@@ -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';