diff options
Diffstat (limited to 'internal/admin/static')
| -rw-r--r-- | internal/admin/static/admin.css | 2 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 292 |
2 files changed, 250 insertions, 44 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index d878db5..b710491 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -55,7 +55,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f .link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64} #mapSvg:focus{outline:none} #mapSvg,#mapSvg *{user-select:none;-webkit-user-select:none;-moz-user-select:none} -.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} +.notification{position:fixed;bottom:16px;left:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} .notification.error{border-color:var(--danger)} .notification.success{border-color:var(--success)} @keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} 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 + ')">← 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 + ')">← 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 + ') →</button>'; + h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') via ' + (exitDir || '?') + ' →</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 ? '▶' : '▼') + '</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'; |
