aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_room_courses.go247
-rw-r--r--internal/admin/api_rooms.go78
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/map.js292
-rw-r--r--internal/behavior/types.go1
-rw-r--r--internal/game/act_agility.go104
-rw-r--r--internal/game/core_course.go75
-rw-r--r--internal/game/core_course_test.go4
-rw-r--r--internal/game/game.go1
9 files changed, 566 insertions, 238 deletions
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index 740a102..e4cf849 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"gopkg.in/yaml.v3"
@@ -229,8 +230,13 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
NewContent: newContent,
IsCreate: isCreate,
})
+ warnings := s.reconcileCourseExits(body)
s.reloadCoursesIfWired()
- writeJSON(w, map[string]any{"ok": true, "id": id})
+ resp := map[string]any{"ok": true, "id": id}
+ if len(warnings) > 0 {
+ resp["warnings"] = warnings
+ }
+ writeJSON(w, resp)
case http.MethodDelete:
oldContent, err := snapshotFile(path)
@@ -366,6 +372,33 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
}
m["obstacles"] = anyList
+ // --- course map exit management ---
+ // Set exit_dir on the previous obstacle.
+ // Append-only: the new obstacle is last so no new->next link is needed.
+ if insertAt > 0 {
+ prevIdx := insertAt - 1
+ prevRoom := obstacleRoomID(newList[prevIdx])
+ if prevRoom > 0 {
+ dir := strings.TrimSpace(strings.ToLower(body.Direction))
+ if dir == "auto" || dir == "" {
+ dir = "north"
+ }
+ if !isHorizontalDir(dir) {
+ writeJSON(w, map[string]any{"error": "invalid direction: " + dir})
+ return
+ }
+
+ prevObstacle := newList[prevIdx]
+ prevObstacle["exit_dir"] = dir
+ newList[prevIdx] = prevObstacle
+ }
+ }
+ anyList = make([]any, len(newList))
+ for i, o := range newList {
+ anyList[i] = o
+ }
+ m["obstacles"] = anyList
+
newContent, err := writeMapAsYAML(path, m)
if err != nil {
writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
@@ -378,55 +411,18 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
NewContent: newContent,
})
- // --- course map exit management ---
- // Create always-blocked exits so the map BFS lays the course rooms in sequence.
- var prevRoom, nextRoom int
- if insertAt > 0 {
- prevRoom = obstacleRoomID(newList[insertAt-1])
- }
- if insertAt < len(newList)-1 {
- nextRoom = obstacleRoomID(newList[insertAt+1])
- }
-
- if prevRoom > 0 {
- pm, _, _, perr := s.loadRoomMapByID(prevRoom)
- if perr == nil {
- // Delete the old blocked link from prevRoom to nextRoom (now bypassed).
- if nextRoom > 0 {
- oldDir := findCourseExitDirToTarget(pm, nextRoom)
- if oldDir != "" {
- removeExit(pm, oldDir)
- }
- }
- dir := ensureValidDirection(body.Direction, pm)
- setCourseBlockedExit(pm, dir, roomID)
- s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("blocked exit %d->%d for course %s", prevRoom, roomID, body.CourseID))
- }
- }
- if nextRoom > 0 {
- cm, _, _, cerr := s.loadRoomMapByID(roomID)
- if cerr == nil {
- dir := autoPickDirection(cm)
- setCourseBlockedExit(cm, dir, nextRoom)
- s.saveRoomMapByID(roomID, cm, fmt.Sprintf("blocked exit %d->%d for course %s", roomID, nextRoom, body.CourseID))
- }
- }
-
s.reloadCoursesIfWired()
writeJSON(w, map[string]any{"ok": true, "course_id": body.CourseID, "obstacle_index": insertAt, "total_obstacles": len(newList)})
}
// Detach a room from its course; delete the course file if it becomes empty
-func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
+// detachRoomFromCourse removes roomID from the course it belongs to, if any.
+// Returns the course ID that was modified, or "" if none match.
+func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted bool) {
ids, err := s.listCourseIDs()
if err != nil {
- writeJSON(w, map[string]any{"error": err.Error()})
- return
+ return "", false
}
for _, id := range ids {
m, _, path, err := s.loadCourseMap(id)
@@ -440,44 +436,8 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
}
oldContent, _ := snapshotFile(path)
- var prevRoom, nextRoom int
if idx > 0 {
- prevRoom = obstacleRoomID(obstacles[idx-1])
- }
- if idx < len(obstacles)-1 {
- nextRoom = obstacleRoomID(obstacles[idx+1])
- }
-
- // --- course map exit cleanup ---
- var relinkDir string
- if prevRoom > 0 {
- pm, _, _, perr := s.loadRoomMapByID(prevRoom)
- if perr == nil {
- relinkDir = findCourseExitDirToTarget(pm, roomID)
- if relinkDir != "" {
- removeExit(pm, relinkDir)
- s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("remove blocked exit to %d (course %s)", roomID, id))
- }
- }
- }
- if nextRoom > 0 {
- cm, _, _, cerr := s.loadRoomMapByID(roomID)
- if cerr == nil {
- d := findCourseExitDirToTarget(cm, nextRoom)
- if d != "" {
- removeExit(cm, d)
- s.saveRoomMapByID(roomID, cm, fmt.Sprintf("remove blocked exit to %d (course %s)", nextRoom, id))
- }
- }
- }
- // Relink prevRoom -> nextRoom
- if prevRoom > 0 && nextRoom > 0 {
- pm, _, _, perr := s.loadRoomMapByID(prevRoom)
- if perr == nil {
- dir := ensureValidDirection(relinkDir, pm)
- setCourseBlockedExit(pm, dir, nextRoom)
- s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("relink blocked exit %d->%d (course %s)", prevRoom, nextRoom, id))
- }
+ delete(obstacles[idx-1], "exit_dir")
}
remaining := make([]map[string]any, 0, len(obstacles)-1)
@@ -485,10 +445,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
remaining = append(remaining, obstacles[idx+1:]...)
if len(remaining) == 0 {
- if err := os.Remove(path); err != nil {
- writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
- return
- }
+ os.Remove(path)
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("delete course %s (last obstacle removed)", id),
FilePath: path,
@@ -496,8 +453,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
IsDelete: true,
})
s.reloadCoursesIfWired()
- writeJSON(w, map[string]any{"ok": true, "course_id": id, "deleted": true})
- return
+ return id, true
}
anyList := make([]any, len(remaining))
@@ -507,8 +463,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
m["obstacles"] = anyList
newContent, err := writeMapAsYAML(path, m)
if err != nil {
- writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
- return
+ return "", false
}
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("detach room %d from course %s", roomID, id),
@@ -517,13 +472,23 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
NewContent: newContent,
})
s.reloadCoursesIfWired()
- writeJSON(w, map[string]any{"ok": true, "course_id": id, "obstacle_index": idx, "total_obstacles": len(remaining)})
- return
+ return id, false
}
- writeJSON(w, map[string]any{"error": "room is not part of any course"})
+ return "", false
}
-// --- hidden exit management on room files -------------------------------
+func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ courseID, deleted := s.detachRoomFromCourse(roomID)
+ if courseID == "" && !deleted {
+ writeJSON(w, map[string]any{"error": "room is not part of any course"})
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "course_id": courseID, "deleted": deleted})
+}
// loadRoomMapByID reads a room YAML file as a generic map.
func (s *AdminServer) loadRoomMapByID(roomID int) (map[string]any, []byte, string, error) {
@@ -562,25 +527,6 @@ func (s *AdminServer) saveRoomMapByID(roomID int, m map[string]any, desc string)
return nil
}
-// roomExitsMap returns the exits sub-map from a room map, initializing it
-// as an empty map if absent.
-func roomExitsMap(m map[string]any) map[string]any {
- if ex, ok := m["exits"].(map[string]any); ok {
- return ex
- }
- ex := make(map[string]any)
- m["exits"] = ex
- return ex
-}
-
-// setCourseBlockedExit adds or replaces an always-blocked exit in direction dir
-// targeting targetRoomID. Always-blocked exits are blocked to players but
-// render on the map; used to lay out course rooms in visual sequence.
-func setCourseBlockedExit(m map[string]any, dir string, targetRoomID int) {
- ex := roomExitsMap(m)
- ex[dir] = map[string]any{"room": targetRoomID, "always_blocked": true}
-}
-
// removeExit deletes an exit by direction. Returns true if it existed.
func removeExit(m map[string]any, dir string) bool {
ex, ok := m["exits"].(map[string]any)
@@ -619,45 +565,66 @@ func exitIsAlwaysBlocked(v any) bool {
return false
}
-// findCourseExitDirToTarget returns the direction of an always-blocked exit from m
-// targeting targetRoomID, or "" if none.
-func findCourseExitDirToTarget(m map[string]any, targetRoomID int) string {
- ex, ok := m["exits"].(map[string]any)
- if !ok {
- return ""
- }
- for dir, v := range ex {
- if exitIsAlwaysBlocked(v) && exitTargetRoom(v) == targetRoomID {
- return dir
+
+
+// isHorizontalDir returns whether d is one of the 8 horizontal directions.
+func isHorizontalDir(d string) bool {
+ for _, h := range horizontalExitDirs {
+ if d == h {
+ return true
}
}
- return ""
+ return false
}
-// autoPickDirection returns the first horizontal direction that has no exit
-// on room map m.
-func autoPickDirection(m map[string]any) string {
+
+
+// reconcileExitOnRoom validates that roomID has an exit in dir.
+// If expectedTarget > 0, also checks the exit points to that room.
+// Returns an error string or empty string on success.
+func (s *AdminServer) validateExitDir(roomID int, dir string, expectedTarget int) string {
+ m, _, _, err := s.loadRoomMapByID(roomID)
+ if err != nil {
+ return "cannot load room " + strconv.Itoa(roomID)
+ }
ex, _ := m["exits"].(map[string]any)
- for _, d := range horizontalExitDirs {
- if _, occupied := ex[d]; !occupied {
- return d
+
+ existing := ex[dir]
+ if existing == nil {
+ return "room " + strconv.Itoa(roomID) + " has no exit in direction " + dir
+ }
+ if expectedTarget > 0 {
+ target := exitTargetRoom(existing)
+ if target != expectedTarget {
+ return "direction " + dir + " on room " + strconv.Itoa(roomID) + " points to room " + strconv.Itoa(target) + ", expected " + strconv.Itoa(expectedTarget)
}
}
- return "north"
+ return ""
}
-// ensureValidDirection returns dir if it's a valid horizontal direction,
-// otherwise auto-picks one from m.
-func ensureValidDirection(dir string, m map[string]any) string {
- dir = strings.TrimSpace(strings.ToLower(dir))
- for _, d := range horizontalExitDirs {
- if d == dir {
- ex, _ := m["exits"].(map[string]any)
- if _, occupied := ex[dir]; !occupied {
- return dir
- }
- break
+// reconcileCourseExits validates every obstacle's exit_dir against the actual
+// exits on the corresponding room. Returns warnings for any mismatches found.
+func (s *AdminServer) reconcileCourseExits(m map[string]any) []string {
+ var warnings []string
+ obstacles := asMapList(m["obstacles"])
+
+ for i, obs := range obstacles {
+ roomID := obstacleRoomID(obs)
+ dir, _ := obs["exit_dir"].(string)
+ if dir == "" {
+ continue
+ }
+
+ var nextRoom int
+ if i < len(obstacles)-1 {
+ nextRoom = obstacleRoomID(obstacles[i+1])
+ }
+
+ errMsg := s.validateExitDir(roomID, dir, nextRoom)
+ if errMsg != "" {
+ warnings = append(warnings, errMsg)
}
}
- return autoPickDirection(m)
+
+ return warnings
} \ No newline at end of file
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 49a6601..0925f8b 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -48,6 +48,8 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
var linkFrom *int
var linkDir *string
var linkOneWay bool
+ var linkHidden bool
+ var linkBlocked bool
if v, ok := m["link_from"]; ok {
if f, ok := v.(float64); ok {
lf := int(f)
@@ -68,6 +70,18 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
}
delete(m, "link_oneway")
}
+ if v, ok := m["link_hidden"]; ok {
+ if b, ok := v.(bool); ok {
+ linkHidden = b
+ }
+ delete(m, "link_hidden")
+ }
+ if v, ok := m["link_blocked"]; ok {
+ if b, ok := v.(bool); ok {
+ linkBlocked = b
+ }
+ delete(m, "link_blocked")
+ }
fromID := 0
if linkFrom != nil {
@@ -80,6 +94,46 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
}
m["id"] = id
+ if linkFrom != nil && linkDir != nil && *linkDir != "" {
+ dir := world.ExitDir(strings.ToLower(*linkDir))
+ opp, validDir := world.OppositeExit[dir]
+ if validDir {
+ conflicts := 0
+ world.BuildGrid(s.cfg.StartingRoom, func(rid int) (*world.Room, bool) {
+ if rid == id {
+ ex := make(map[world.ExitDir]world.ExitDef)
+ if !linkOneWay {
+ ex[opp] = world.ExitDef{Room: *linkFrom}
+ }
+ return &world.Room{Exits: ex}, true
+ }
+ r, err := s.world.LoadRoom(rid)
+ if err != nil {
+ return nil, false
+ }
+ if rid == *linkFrom {
+ rc := *r
+ rc.Exits = make(map[world.ExitDir]world.ExitDef, len(r.Exits)+1)
+ for k, v := range r.Exits {
+ rc.Exits[k] = v
+ }
+ rc.Exits[dir] = world.ExitDef{Room: id}
+ return &rc, true
+ }
+ return r, true
+ }, nil, nil, func(gc world.GridConflict) {
+ conflicts++
+ })
+ if conflicts > 0 {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("Add Next Step: that grid spot is already occupied — room would cause map overlap")})
+ return
+ }
+ } else {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid direction: %s", *linkDir)})
+ return
+ }
+ }
+
exits, _ := m["exits"].(map[string]any)
if exits == nil {
exits = make(map[string]any)
@@ -100,7 +154,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
if srcRoom.Exits == nil {
srcRoom.Exits = make(map[world.ExitDir]world.ExitDef)
}
- srcRoom.Exits[dir] = world.ExitDef{Room: id}
+ srcRoom.Exits[dir] = world.ExitDef{Room: id, Hidden: linkHidden, AlwaysBlocked: linkBlocked}
srcPath, srcOk := s.world.GetRoomPath(*linkFrom)
if srcOk {
oldContent, _ := snapshotFile(srcPath)
@@ -247,6 +301,8 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
return
}
+ courseID, _ := s.detachRoomFromCourse(id)
+
if err := os.Remove(path); err != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("delete room: %v", err)})
return
@@ -301,7 +357,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
ExtraFiles: extraFiles,
})
- writeJSON(w, map[string]any{"ok": true})
+ resp := map[string]any{"ok": true}
+ if courseID != "" {
+ resp["course_cleaned"] = courseID
+ }
+ writeJSON(w, resp)
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
@@ -424,10 +484,12 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var body struct {
- From int `json:"from"`
- To int `json:"to"`
- Dir string `json:"dir"`
- OneWay bool `json:"oneway"`
+ From int `json:"from"`
+ To int `json:"to"`
+ Dir string `json:"dir"`
+ OneWay bool `json:"oneway"`
+ Hidden bool `json:"hidden"`
+ AlwaysBlocked bool `json:"always_blocked"`
}
if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 {
writeJSONError(w, "invalid body, need from and to", http.StatusBadRequest)
@@ -555,13 +617,13 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
if roomA.Exits == nil {
roomA.Exits = make(map[world.ExitDir]world.ExitDef)
}
- roomA.Exits[dir] = world.ExitDef{Room: body.To}
+ roomA.Exits[dir] = world.ExitDef{Room: body.To, Hidden: body.Hidden, AlwaysBlocked: body.AlwaysBlocked}
if !body.OneWay {
if roomB.Exits == nil {
roomB.Exits = make(map[world.ExitDir]world.ExitDef)
}
- roomB.Exits[oppDir] = world.ExitDef{Room: body.From}
+ roomB.Exits[oppDir] = world.ExitDef{Room: body.From, Hidden: body.Hidden, AlwaysBlocked: body.AlwaysBlocked}
}
pathA, okA2 := s.world.GetRoomPath(body.From)
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 + ')">&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';
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 03876e5..c43a0d9 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -143,6 +143,7 @@ type ObstacleData struct {
TotalObstacles int
NextRoom int
StartRoom int
+ OnFailRoom int
ObstacleXP int
CompletionXP int
// FailChance is the resolved failure probability for this attempt.
diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go
index 18dbf4a..a243b44 100644
--- a/internal/game/act_agility.go
+++ b/internal/game/act_agility.go
@@ -44,6 +44,7 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle
TotalObstacles: info.TotalObstacles,
NextRoom: info.NextRoom,
StartRoom: info.StartRoom,
+ OnFailRoom: info.OnFailRoom,
ObstacleXP: info.ObstacleXP,
CompletionXP: info.CompletionXP,
FailChance: failPtr,
@@ -62,6 +63,11 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
phase := d.Phase
phases := d.Phases
+ if len(phases) == 0 {
+ g.completeObstacle(sess, p, d)
+ return
+ }
+
if phase >= len(phases) {
g.cancelAction(p)
return
@@ -84,58 +90,61 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
nextPhase := phase + 1
if nextPhase >= len(phases) {
- obstacleXP := d.ObstacleXP
- completionXP := d.CompletionXP
- nextRoom := d.NextRoom
- startRoom := d.StartRoom
- obstacleIndex := d.ObstacleIndex
- totalObstacles := d.TotalObstacles
- courseID := d.CourseID
-
- if obstacleXP > 0 {
- g.awardSkillXP(sess, p, player.Agility, obstacleXP)
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
- }
+ g.completeObstacle(sess, p, d)
+ return
+ }
+
+ d.Phase = nextPhase
+ p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay)
+}
+
+func (g *Game) completeObstacle(sess *net.Session, p *player.Player, d *behavior.ObstacleData) {
+ obstacleXP := d.ObstacleXP
+ completionXP := d.CompletionXP
+ nextRoom := d.NextRoom
+ obstacleIndex := d.ObstacleIndex
+ totalObstacles := d.TotalObstacles
+ courseID := d.CourseID
+
+ isLastObstacle := obstacleIndex == totalObstacles-1
+
+ if obstacleXP > 0 {
+ g.awardSkillXP(sess, p, player.Agility, obstacleXP)
+ if !isLastObstacle && p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp",
+ fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
}
+ }
- isLastObstacle := obstacleIndex == totalObstacles-1
-
- if isLastObstacle {
- if completionXP > 0 {
- g.awardSkillXP(sess, p, player.Agility, completionXP)
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility])))
- }
- }
-
- lapKey := "agility_laps_" + courseID
- laps := getPlayerFlagInt(p, lapKey) + 1
- g.setPlayerFlag(p, lapKey, laps)
-
- courseName := p.Action.TargetName
- sess.WriteLine(g.colorize(sess, "broadcast",
- fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps)))
-
- g.cancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
- } else {
- g.cancelAction(p)
- g.teleportPlayer(sess, p, nextRoom)
+ if isLastObstacle {
+ if completionXP > 0 {
+ g.awardSkillXP(sess, p, player.Agility, completionXP)
}
- g.AccountStore.SaveCharacter(p)
- return
+ lapKey := "agility_laps_" + courseID
+ laps := getPlayerFlagInt(p, lapKey) + 1
+ g.setPlayerFlag(p, lapKey, laps)
+
+ courseName := p.Action.TargetName
+ msg := fmt.Sprintf("You've completed %d lap%s of %s", laps, pluralLap(laps), courseName)
+ combinedXP := obstacleXP + completionXP
+ if p.OptionBool("xp_drops") && combinedXP > 0 {
+ msg += fmt.Sprintf(" (+%d%s)", combinedXP, player.SkillAbbr[player.Agility])
+ }
+ sess.WriteLine(g.colorize(sess, "broadcast", msg))
+
+ g.cancelAction(p)
+ g.teleportPlayer(sess, p, nextRoom)
+ } else {
+ g.cancelAction(p)
+ g.teleportPlayer(sess, p, nextRoom)
}
- d.Phase = nextPhase
- p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay)
+ g.AccountStore.SaveCharacter(p)
}
func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) {
- startRoom := d.StartRoom
+ onFailRoom := d.OnFailRoom
failMin := d.FailDamageMin
failMax := d.FailDamageMax
@@ -155,7 +164,14 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs
g.AccountStore.SaveCharacter(p)
g.cancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
+ g.teleportPlayer(sess, p, onFailRoom)
+}
+
+func pluralLap(n int) string {
+ if n == 1 {
+ return ""
+ }
+ return "s"
}
// calcFailChance returns the failure probability for an obstacle attempt.
diff --git a/internal/game/core_course.go b/internal/game/core_course.go
index 54894fd..031eff5 100644
--- a/internal/game/core_course.go
+++ b/internal/game/core_course.go
@@ -1,11 +1,14 @@
package game
import (
+ "log"
+ "os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/world"
)
// ObstaclePhase is a single phase of an obstacle's advancement sequence.
@@ -31,6 +34,8 @@ type ObstacleDef struct {
TicksPerPhase float64 `yaml:"ticks_per_phase"`
Messages []string `yaml:"messages"`
Phases []ObstaclePhase `yaml:"phases"`
+ ExitDir string `yaml:"exit_dir"`
+ OnFailRoom int `yaml:"on_fail,omitempty"`
}
type CourseConfig struct {
@@ -62,7 +67,9 @@ type ObstacleInfo struct {
FailDamage [2]int
NextRoom int
StartRoom int
+ OnFailRoom int
RequiredLevel int
+ ExitDir string
}
var obstacleVerbs = map[string]bool{}
@@ -85,6 +92,7 @@ type CourseStore struct {
courses map[string]*CourseConfig
roomToObstacle map[int]*ObstacleInfo
loaded bool
+ world *world.World
}
func NewCourseStore(dataDir string) *CourseStore {
@@ -94,6 +102,10 @@ func NewCourseStore(dataDir string) *CourseStore {
}
}
+func (cs *CourseStore) SetWorld(w *world.World) {
+ cs.world = w
+}
+
func (cs *CourseStore) LoadAll() {
cs.mu.Lock()
defer cs.mu.Unlock()
@@ -160,6 +172,52 @@ func resolvePhases(obs ObstacleDef) []PhaseInfo {
return phases
}
+func (cs *CourseStore) resolveExitTarget(roomID int, dir string) int {
+ if cs.world == nil || dir == "" {
+ return 0
+ }
+ path, ok := cs.world.GetRoomPath(roomID)
+ if !ok {
+ return 0
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return 0
+ }
+ var m map[string]any
+ if err := yaml.Unmarshal(data, &m); err != nil {
+ return 0
+ }
+ exits, _ := m["exits"].(map[string]any)
+ if exits == nil {
+ return 0
+ }
+ v, ok := exits[dir]
+ if !ok {
+ return 0
+ }
+ switch x := v.(type) {
+ case int:
+ return x
+ case int64:
+ return int(x)
+ case float64:
+ return int(x)
+ case map[string]any:
+ if r, ok := x["room"]; ok {
+ switch y := r.(type) {
+ case int:
+ return y
+ case int64:
+ return int(y)
+ case float64:
+ return int(y)
+ }
+ }
+ }
+ return 0
+}
+
func (cs *CourseStore) loadAllLocked() {
cs.loaded = true
cs.roomToObstacle = make(map[int]*ObstacleInfo)
@@ -178,6 +236,16 @@ func (cs *CourseStore) loadAllLocked() {
nextRoom := 0
if i < totalObstacles-1 {
nextRoom = cfg.Obstacles[i+1].RoomID
+ } else if obs.ExitDir != "" {
+ resolved := cs.resolveExitTarget(obs.RoomID, obs.ExitDir)
+ if resolved != 0 {
+ nextRoom = resolved
+ } else {
+ log.Printf("[WARNING] Course %q: last obstacle room %d has exit_dir=%q but no exit in that direction; falling back to start_room %d", cfg.ID, obs.RoomID, obs.ExitDir, cfg.StartRoom)
+ nextRoom = cfg.StartRoom
+ }
+ } else {
+ nextRoom = cfg.StartRoom
}
completionXP := 0
@@ -185,6 +253,11 @@ func (cs *CourseStore) loadAllLocked() {
completionXP = cfg.CompletionXP
}
+ onFailRoom := obs.OnFailRoom
+ if onFailRoom == 0 {
+ onFailRoom = cfg.StartRoom
+ }
+
info := &ObstacleInfo{
CourseID: cfg.ID,
CourseName: cfg.Name,
@@ -198,7 +271,9 @@ func (cs *CourseStore) loadAllLocked() {
FailDamage: obs.FailDamage,
NextRoom: nextRoom,
StartRoom: cfg.StartRoom,
+ OnFailRoom: onFailRoom,
RequiredLevel: cfg.RequiredLevel,
+ ExitDir: obs.ExitDir,
}
cs.roomToObstacle[obs.RoomID] = info
localVerbs[obs.Verb] = true
diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go
index 3d9fdc2..64ec8ad 100644
--- a/internal/game/core_course_test.go
+++ b/internal/game/core_course_test.go
@@ -130,8 +130,8 @@ obstacles:
if o2.CompletionXP != 50 {
t.Errorf("last obstacle should carry completion_xp, got %d", o2.CompletionXP)
}
- if o2.NextRoom != 0 {
- t.Errorf("last obstacle should have next_room 0, got %d", o2.NextRoom)
+ if o2.NextRoom != 100 {
+ t.Errorf("last obstacle with no exit_dir should fall back to start_room 100, got %d", o2.NextRoom)
}
if cs.GetObstacle(999) != nil {
diff --git a/internal/game/game.go b/internal/game/game.go
index b9fb0af..f34e649 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -101,6 +101,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
hackingStates: make(map[string]*hacking.Session),
enterSeqs: make(map[string]*enterSeq),
}
+ g.CourseStore.SetWorld(g.World)
g.CourseStore.LoadAll()
g.LoadMods()
g.LoadTechs()