aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-05 22:35:21 -0400
committerhistoria <[not public]>2026-07-05 22:35:21 -0400
commit94823b52168b44894fb5e9c960358ed02ebb122b (patch)
tree3060799b89bb7edfa708c72bdd981107d40f4d09 /internal/admin
parent843fa6db681e494bf46e191655323a40577542b5 (diff)
downloadthehouseoficarus-94823b52168b44894fb5e9c960358ed02ebb122b.tar.gz
feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_courses.go138
-rw-r--r--internal/admin/api_map.go36
-rw-r--r--internal/admin/api_room_courses.go662
-rw-r--r--internal/admin/api_rooms.go14
-rw-r--r--internal/admin/api_search.go21
-rw-r--r--internal/admin/server.go6
-rw-r--r--internal/admin/static/admin.css14
-rw-r--r--internal/admin/static/map.js535
-rw-r--r--internal/admin/templates/courses.html14
-rw-r--r--internal/admin/templates/layout.html2
10 files changed, 1266 insertions, 176 deletions
diff --git a/internal/admin/api_courses.go b/internal/admin/api_courses.go
deleted file mode 100644
index 64218cb..0000000
--- a/internal/admin/api_courses.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package admin
-
-import (
- "net/http"
- "os"
- "path/filepath"
- "strings"
-)
-
-func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- tree, err := listYAMLTree(s.dataDir, "courses")
- if err != nil {
- writeJSON(w, map[string]any{"error": err.Error()})
- return
- }
- if tree == nil {
- tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}}
- }
- writeJSON(w, tree)
- case http.MethodPost:
- var m map[string]any
- if err := readJSON(r, &m); err != nil {
- writeJSON(w, map[string]any{"error": "invalid json"})
- return
- }
- id, ok := m["id"].(string)
- if !ok || strings.TrimSpace(id) == "" {
- writeJSON(w, map[string]any{"error": "missing id"})
- return
- }
- delete(m, "id")
- path := filepath.Join(s.dataDir, "courses", id+".yaml")
- if _, err := os.Stat(path); err == nil {
- writeJSON(w, map[string]any{"error": "course already exists"})
- return
- }
- newContent, err := writeMapAsYAML(path, m)
- if err != nil {
- writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
- return
- }
- s.undoStack.Push(ChangeDesc{
- Description: "Created course " + id,
- FilePath: path,
- NewContent: newContent,
- IsCreate: true,
- })
- writeJSON(w, map[string]any{"ok": true, "id": id})
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
- id := strings.TrimPrefix(r.URL.Path, "/api/courses/")
- if id == "" {
- http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- data, path, err := findYAMLFileInSubdirs(s.dataDir, "courses", id)
- if err != nil {
- writeJSON(w, map[string]any{"error": "not found: " + id})
- return
- }
- raw, err := yamlToMap(data)
- if err != nil {
- writeJSON(w, map[string]any{"error": "parse error: " + err.Error()})
- return
- }
- raw["id"] = id
- raw["_path"] = path
- raw["_raw"] = string(data)
- writeJSON(w, raw)
-
- case http.MethodPost, http.MethodPut:
- var body map[string]any
- if err := readJSON(r, &body); err != nil {
- writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()})
- return
- }
- delete(body, "id")
- delete(body, "_path")
- delete(body, "_raw")
-
- path := filepath.Join(s.dataDir, "courses", id+".yaml")
- var oldContent []byte
- if existing, err := snapshotFile(path); err == nil {
- oldContent = existing
- }
-
- newContent, err := writeMapAsYAML(path, body)
- if err != nil {
- writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
- return
- }
-
- isCreate := oldContent == nil
- desc := "Updated course " + id
- if isCreate {
- desc = "Created course " + id
- }
- s.undoStack.Push(ChangeDesc{
- Description: desc,
- FilePath: path,
- OldContent: oldContent,
- NewContent: newContent,
- IsCreate: isCreate,
- })
- writeJSON(w, map[string]any{"ok": true, "id": id})
-
- case http.MethodDelete:
- _, path, err := findYAMLFileInSubdirs(s.dataDir, "courses", id)
- if err != nil {
- writeJSON(w, map[string]any{"error": "not found: " + id})
- return
- }
- oldContent, _ := snapshotFile(path)
- if err := os.Remove(path); err != nil {
- writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
- return
- }
- s.undoStack.Push(ChangeDesc{
- Description: "Deleted course " + id,
- FilePath: path,
- OldContent: oldContent,
- IsDelete: true,
- })
- writeJSON(w, map[string]any{"ok": true, "id": id})
-
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index 7759029..bb81e59 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -134,6 +134,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
To int `json:"to"`
Dir string `json:"dir"`
Bidirectional bool `json:"bidirectional"`
+ Hidden bool `json:"hidden"`
}
type UDLink struct {
@@ -221,25 +222,26 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
continue
}
- bidirectional := false
- if targetRoom, ok := roomData[target]; ok {
- if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid {
- bidirectional = true
- }
+ bidirectional := false
+ if targetRoom, ok := roomData[target]; ok {
+ if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden {
+ bidirectional = true
}
+ }
- key := linkKey(rid, target)
- if bidirectional && seenLinks[key] {
- continue
- }
- seenLinks[key] = true
-
- links = append(links, LinkEntry{
- From: rid,
- To: target,
- Dir: string(dir),
- Bidirectional: bidirectional,
- })
+ key := linkKey(rid, target)
+ if bidirectional && seenLinks[key] {
+ continue
+ }
+ seenLinks[key] = true
+
+ links = append(links, LinkEntry{
+ From: rid,
+ To: target,
+ Dir: string(dir),
+ Bidirectional: bidirectional,
+ Hidden: exit.Hidden,
+ })
}
}
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
new file mode 100644
index 0000000..f81a204
--- /dev/null
+++ b/internal/admin/api_room_courses.go
@@ -0,0 +1,662 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+var horizontalExitDirs = []string{
+ "north", "south", "east", "west",
+ "northeast", "northwest", "southeast", "southwest",
+}
+
+// coursesDir is the subdirectory under dataDir holding course YAML files.
+func (s *AdminServer) coursesDir() string { return filepath.Join(s.dataDir, "courses") }
+
+// courseFilePath returns the on-disk path for a course id. Course files live
+// flat in data/courses/ (no subdirectories) per the existing layout.
+func (s *AdminServer) courseFilePath(id string) string {
+ return filepath.Join(s.coursesDir(), id+".yaml")
+}
+
+func (s *AdminServer) reloadCoursesIfWired() {
+ if s.reloadCourses != nil {
+ s.reloadCourses()
+ }
+}
+
+func (s *AdminServer) listCourseIDs() ([]string, error) {
+ entries, err := os.ReadDir(s.coursesDir())
+ if err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
+ continue
+ }
+ ids = append(ids, strings.TrimSuffix(e.Name(), ".yaml"))
+ }
+ return ids, nil
+}
+
+// loadCourseMap reads a course YAML file as a generic map (for both GET and
+// internal manipulation). Returns nil if the file does not exist.
+func (s *AdminServer) loadCourseMap(id string) (map[string]any, []byte, string, error) {
+ path := s.courseFilePath(id)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, nil, path, err
+ }
+ var m map[string]any
+ if err := yaml.Unmarshal(data, &m); err != nil {
+ return nil, data, path, err
+ }
+ return m, data, path, nil
+}
+
+// helpers -----------------------------------------------------------------
+
+// asMapList coerces a yaml-decoded value into []map[string]any.
+func asMapList(v any) []map[string]any {
+ if list, ok := v.([]any); ok {
+ out := make([]map[string]any, 0, len(list))
+ for _, e := range list {
+ if mm, ok := e.(map[string]any); ok {
+ out = append(out, mm)
+ } else {
+ out = append(out, map[string]any{})
+ }
+ }
+ return out
+ }
+ return nil
+}
+
+// obstacleRoomID extracts the room_id (int) from an obstacle map entry.
+func obstacleRoomID(o map[string]any) int {
+ switch v := o["room_id"].(type) {
+ case int:
+ return v
+ case int64:
+ return int(v)
+ case float64:
+ return int(v)
+ }
+ return 0
+}
+
+// toInt coerces yaml-decoded numbers to int.
+func toInt(v any) int {
+ switch x := v.(type) {
+ case int:
+ return x
+ case int64:
+ return int(x)
+ case float64:
+ return int(x)
+ }
+ return 0
+}
+
+// toFloat64 coerces yaml-decoded numbers to float64.
+func toFloat64(v any) float64 {
+ switch x := v.(type) {
+ case int:
+ return float64(x)
+ case int64:
+ return float64(x)
+ case float64:
+ return x
+ }
+ return 0
+}
+
+// indexOfObstacleForRoom returns the index of the obstacle whose room_id
+// matches roomID, or -1 if none.
+func indexOfObstacleForRoom(obstacles []map[string]any, roomID int) int {
+ for i, o := range obstacles {
+ if obstacleRoomID(o) == roomID {
+ return i
+ }
+ }
+ return -1
+}
+
+// Courses list / create ---------------------------------------------------
+
+func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := s.listCourseIDs()
+ if err != nil {
+ writeJSON(w, map[string]any{"root": []string{}, "dirs": map[string][]string{}})
+ return
+ }
+ writeJSON(w, map[string]any{"root": ids, "dirs": map[string][]string{}})
+
+ case http.MethodPost:
+ var body map[string]any
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid json"})
+ return
+ }
+ id, _ := body["id"].(string)
+ id = strings.TrimSpace(id)
+ if id == "" {
+ writeJSON(w, map[string]any{"error": "missing id"})
+ return
+ }
+ delete(body, "id")
+ path := s.courseFilePath(id)
+ if _, err := os.Stat(path); err == nil {
+ writeJSON(w, map[string]any{"error": "course already exists"})
+ return
+ }
+ newContent, err := writeMapAsYAML(path, body)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Created course " + id,
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+ s.reloadCoursesIfWired()
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// Course by id: get / put / delete ---------------------------------------
+
+func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/courses/")
+ if id == "" {
+ writeJSON(w, map[string]any{"error": "missing id"})
+ return
+ }
+ path := s.courseFilePath(id)
+
+ switch r.Method {
+ case http.MethodGet:
+ m, data, _, err := s.loadCourseMap(id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ m["id"] = id
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+
+ case http.MethodPost, http.MethodPut:
+ var body map[string]any
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ delete(body, "id")
+ delete(body, "_raw")
+ delete(body, "_path")
+
+ var oldContent []byte
+ if existing, err := snapshotFile(path); err == nil {
+ oldContent = existing
+ }
+ newContent, err := writeMapAsYAML(path, body)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+ isCreate := oldContent == nil
+ desc := "Updated course " + id
+ if isCreate {
+ desc = "Created course " + id
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ IsCreate: isCreate,
+ })
+ s.reloadCoursesIfWired()
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ case http.MethodDelete:
+ oldContent, err := snapshotFile(path)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ if err := os.Remove(path); err != nil {
+ writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Deleted course " + id,
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ s.reloadCoursesIfWired()
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// Room-course lookup ------------------------------------------------------
+
+// handleRoomCourse returns the course this room belongs to (if any) along
+// with the obstacle index and total obstacle count. Returns a JSON null
+// body when the room is not part of any course.
+func (s *AdminServer) handleRoomCourse(w http.ResponseWriter, r *http.Request, roomID int) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ ids, err := s.listCourseIDs()
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ for _, id := range ids {
+ m, raw, _, err := s.loadCourseMap(id)
+ if err != nil {
+ continue
+ }
+ obstacles := asMapList(m["obstacles"])
+ idx := indexOfObstacleForRoom(obstacles, roomID)
+ if idx < 0 {
+ continue
+ }
+ m["id"] = id
+ delete(m, "_raw")
+ writeJSON(w, map[string]any{
+ "course": m,
+ "course_id": id,
+ "course_name": m["name"],
+ "obstacle_index": idx,
+ "total_obstacles": len(obstacles),
+ "obstacle": obstacles[idx],
+ "obstacles": obstacles,
+ "_raw": string(raw),
+ "room_id": roomID,
+ })
+ return
+ }
+ writeJSON(w, nil)
+}
+
+// Attach a room to an existing course as a new obstacle -------------------
+
+func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Request, roomID int) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ CourseID string `json:"course_id"`
+ AfterIndex int `json:"after_index"`
+ Direction string `json:"direction"`
+ Obstacle map[string]any `json:"obstacle"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid json"})
+ return
+ }
+ if strings.TrimSpace(body.CourseID) == "" {
+ writeJSON(w, map[string]any{"error": "missing course_id"})
+ return
+ }
+ m, _, path, err := s.loadCourseMap(body.CourseID)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "course not found: " + body.CourseID})
+ return
+ }
+ oldContent, _ := snapshotFile(path)
+
+ obstacles := asMapList(m["obstacles"])
+ for i, o := range obstacles {
+ if obstacleRoomID(o) == roomID {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d is already step %d of course %s", roomID, i+1, body.CourseID)})
+ return
+ }
+ }
+ if body.Obstacle == nil {
+ body.Obstacle = map[string]any{}
+ }
+ body.Obstacle["room_id"] = roomID
+ if _, ok := body.Obstacle["verb"]; !ok {
+ body.Obstacle["verb"] = "climb"
+ }
+ if _, ok := body.Obstacle["xp"]; !ok {
+ body.Obstacle["xp"] = 0
+ }
+ if _, ok := body.Obstacle["fail_damage"]; !ok {
+ body.Obstacle["fail_damage"] = []any{0, 0}
+ }
+
+ insertAt := body.AfterIndex + 1
+ if insertAt < 0 {
+ insertAt = 0
+ }
+ if insertAt > len(obstacles) {
+ insertAt = len(obstacles)
+ }
+ newList := make([]map[string]any, 0, len(obstacles)+1)
+ newList = append(newList, obstacles[:insertAt]...)
+ newList = append(newList, body.Obstacle)
+ newList = append(newList, obstacles[insertAt:]...)
+
+ 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()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("attach room %d to course %s", roomID, body.CourseID),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+
+ // --- hidden exit management ---
+ // Create hidden 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 hidden link from prevRoom to nextRoom (now bypassed).
+ if nextRoom > 0 {
+ oldDir := findHiddenExitDirToTarget(pm, nextRoom)
+ if oldDir != "" {
+ removeExit(pm, oldDir)
+ }
+ }
+ dir := ensureValidDirection(body.Direction, pm)
+ setHiddenExit(pm, dir, roomID)
+ s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("hidden exit %d->%d for course %s", prevRoom, roomID, body.CourseID))
+ }
+ }
+ if nextRoom > 0 {
+ cm, _, _, cerr := s.loadRoomMapByID(roomID)
+ if cerr == nil {
+ dir := autoPickDirection(cm)
+ setHiddenExit(cm, dir, nextRoom)
+ s.saveRoomMapByID(roomID, cm, fmt.Sprintf("hidden 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
+ }
+ ids, err := s.listCourseIDs()
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ for _, id := range ids {
+ m, _, path, err := s.loadCourseMap(id)
+ if err != nil {
+ continue
+ }
+ obstacles := asMapList(m["obstacles"])
+ idx := indexOfObstacleForRoom(obstacles, roomID)
+ if idx < 0 {
+ continue
+ }
+ 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])
+ }
+
+ // --- hidden exit cleanup ---
+ var relinkDir string
+ if prevRoom > 0 {
+ pm, _, _, perr := s.loadRoomMapByID(prevRoom)
+ if perr == nil {
+ relinkDir = findHiddenExitDirToTarget(pm, roomID)
+ if relinkDir != "" {
+ removeExit(pm, relinkDir)
+ s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("remove hidden exit to %d (course %s)", roomID, id))
+ }
+ }
+ }
+ if nextRoom > 0 {
+ cm, _, _, cerr := s.loadRoomMapByID(roomID)
+ if cerr == nil {
+ d := findHiddenExitDirToTarget(cm, nextRoom)
+ if d != "" {
+ removeExit(cm, d)
+ s.saveRoomMapByID(roomID, cm, fmt.Sprintf("remove hidden 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)
+ setHiddenExit(pm, dir, nextRoom)
+ s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("relink hidden exit %d->%d (course %s)", prevRoom, nextRoom, id))
+ }
+ }
+
+ remaining := make([]map[string]any, 0, len(obstacles)-1)
+ remaining = append(remaining, obstacles[:idx]...)
+ 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
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("delete course %s (last obstacle removed)", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ s.reloadCoursesIfWired()
+ writeJSON(w, map[string]any{"ok": true, "course_id": id, "deleted": true})
+ return
+ }
+
+ anyList := make([]any, len(remaining))
+ for i, o := range remaining {
+ anyList[i] = o
+ }
+ m["obstacles"] = anyList
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("detach room %d from course %s", roomID, id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+ s.reloadCoursesIfWired()
+ writeJSON(w, map[string]any{"ok": true, "course_id": id, "obstacle_index": idx, "total_obstacles": len(remaining)})
+ return
+ }
+ writeJSON(w, map[string]any{"error": "room is not part of any course"})
+}
+
+// --- hidden exit management on room files -------------------------------
+
+// loadRoomMapByID reads a room YAML file as a generic map.
+func (s *AdminServer) loadRoomMapByID(roomID int) (map[string]any, []byte, string, error) {
+ path, ok := s.world.GetRoomPath(roomID)
+ if !ok {
+ return nil, nil, "", fmt.Errorf("room %d not found", roomID)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, nil, path, err
+ }
+ var m map[string]any
+ if err := yaml.Unmarshal(data, &m); err != nil {
+ return nil, data, path, err
+ }
+ return m, data, path, nil
+}
+
+// saveRoomMapByID writes a room map back to disk and pushes an undo entry.
+func (s *AdminServer) saveRoomMapByID(roomID int, m map[string]any, desc string) error {
+ path, ok := s.world.GetRoomPath(roomID)
+ if !ok {
+ return fmt.Errorf("room %d not found", roomID)
+ }
+ oldContent, _ := snapshotFile(path)
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ return err
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+ 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
+}
+
+// setHiddenExit adds or replaces a hidden exit in direction dir targeting
+// targetRoomID.
+func setHiddenExit(m map[string]any, dir string, targetRoomID int) {
+ ex := roomExitsMap(m)
+ ex[dir] = map[string]any{"room": targetRoomID, "hidden": 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)
+ if !ok {
+ return false
+ }
+ if _, exists := ex[dir]; exists {
+ delete(ex, dir)
+ return true
+ }
+ return false
+}
+
+// exitTargetRoom returns the target room ID of an exit value (scalar or map).
+func exitTargetRoom(v any) int {
+ switch x := v.(type) {
+ case int:
+ return x
+ case int64:
+ return int(x)
+ case float64:
+ return int(x)
+ case map[string]any:
+ return toInt(x["room"])
+ }
+ return 0
+}
+
+// exitIsHidden returns whether an exit value carries hidden: true.
+func exitIsHidden(v any) bool {
+ if mm, ok := v.(map[string]any); ok {
+ if h, ok := mm["hidden"]; ok {
+ return h == true
+ }
+ }
+ return false
+}
+
+// findHiddenExitDirToTarget returns the direction of a hidden exit from m
+// targeting targetRoomID, or "" if none.
+func findHiddenExitDirToTarget(m map[string]any, targetRoomID int) string {
+ ex, ok := m["exits"].(map[string]any)
+ if !ok {
+ return ""
+ }
+ for dir, v := range ex {
+ if exitIsHidden(v) && exitTargetRoom(v) == targetRoomID {
+ return dir
+ }
+ }
+ return ""
+}
+
+// autoPickDirection returns the first horizontal direction that has no exit
+// on room map m.
+func autoPickDirection(m map[string]any) string {
+ ex, _ := m["exits"].(map[string]any)
+ for _, d := range horizontalExitDirs {
+ if _, occupied := ex[d]; !occupied {
+ return d
+ }
+ }
+ return "north"
+}
+
+// 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
+ }
+ }
+ return autoPickDirection(m)
+} \ No newline at end of file
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index b7f7d0e..448503d 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -138,7 +138,9 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/rooms/")
+ sub := ""
if idx := strings.IndexByte(idStr, '/'); idx >= 0 {
+ sub = idStr[idx+1:]
idStr = idStr[:idx]
}
id, err := strconv.Atoi(idStr)
@@ -147,6 +149,18 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
return
}
+ switch sub {
+ case "course":
+ s.handleRoomCourse(w, r, id)
+ return
+ case "courses/attach":
+ s.handleRoomAttachCourse(w, r, id)
+ return
+ case "courses/detach":
+ s.handleRoomDetachCourse(w, r, id)
+ return
+ }
+
switch r.Method {
case http.MethodGet:
path, ok := s.world.GetRoomPath(id)
diff --git a/internal/admin/api_search.go b/internal/admin/api_search.go
index 32ef292..d54e7f1 100644
--- a/internal/admin/api_search.go
+++ b/internal/admin/api_search.go
@@ -2,8 +2,12 @@ package admin
import (
"net/http"
+ "os"
+ "path/filepath"
"strconv"
"strings"
+
+ "gopkg.in/yaml.v3"
)
func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) {
@@ -30,6 +34,7 @@ func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) {
Objects []searchResult `json:"objects"`
Hazards []searchResult `json:"hazards"`
Drops []searchResult `json:"drops"`
+ Courses []searchResult `json:"courses"`
}
var resp searchResponse
@@ -98,5 +103,21 @@ func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
+ courseIDs, _ := listYAMLFiles(s.dataDir, "courses")
+ for _, id := range courseIDs {
+ raw, err := os.ReadFile(filepath.Join(s.dataDir, "courses", id+".yaml"))
+ if err != nil {
+ continue
+ }
+ var c map[string]any
+ if err := yaml.Unmarshal(raw, &c); err != nil {
+ continue
+ }
+ name, _ := c["name"].(string)
+ if strings.Contains(strings.ToLower(id), q) || strings.Contains(strings.ToLower(name), q) {
+ resp.Courses = append(resp.Courses, searchResult{ID: id, Name: name})
+ }
+ }
+
writeJSON(w, resp)
}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index e29de9a..f0fa58e 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -48,9 +48,10 @@ type AdminServer struct {
undoStack *UndoStack
tmpl *template.Template
cookieSecret []byte
+ reloadCourses func()
}
-func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string) (*AdminServer, error) {
+func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func()) (*AdminServer, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("cookie secret: %w", err)
@@ -93,6 +94,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
undoStack: NewUndoStack(dataDir),
tmpl: tmpl,
cookieSecret: secret,
+ reloadCourses: reloadCourses,
}
mux := http.NewServeMux()
@@ -152,6 +154,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/modules/dirs", s.handleEntityDirs("modules"))
apiMux.HandleFunc("/api/modules", s.handleModules)
apiMux.HandleFunc("/api/modules/", s.handleModuleByID)
+ apiMux.HandleFunc("/api/courses", s.handleCourses)
+ apiMux.HandleFunc("/api/courses/", s.handleCourseByID)
apiMux.HandleFunc("/api/players", s.handlePlayers)
apiMux.HandleFunc("/api/dashboard", s.handleDashboard)
apiMux.HandleFunc("/api/flags", s.handleFlags)
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 1f6aeec..376bf23 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -281,9 +281,9 @@ g.ud-hover:hover text{font-weight:bold}
.panel-tab-content .section-add-btn:hover{background:var(--hover)}
.panel-tab-content .mini-label{font-size:9px;color:#888;display:block;margin-bottom:1px;margin-top:3px}
.panel-tab-content .mini-input{width:60px!important;display:inline-block;margin-right:4px}
-.obj-card{background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:5px;margin-bottom:10px;overflow:hidden;break-inside:avoid;display:inline-block;width:100%}
-.obj-card.obj-card-full{column-span:all}
-.obj-fields-wrap{column-count:2;column-gap:10px}
+.obj-card{background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:5px;margin-bottom:10px;overflow:hidden;flex:1 1 calc(50% - 5px);min-width:340px;max-width:calc(50% - 5px)}
+.obj-card.obj-card-full{flex:1 1 100%;max-width:100%}
+.obj-fields-wrap{display:flex;flex-wrap:wrap;gap:10px}
.obj-card-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(15,52,96,.3);cursor:pointer;user-select:none;font-size:13px;font-weight:bold}
.obj-card-header:hover{background:rgba(15,52,96,.5)}
.obj-card-arrow{font-size:10px;width:14px;color:#888}
@@ -355,3 +355,11 @@ g.ud-hover:hover text{font-weight:bold}
.ingredients-col-byproducts{flex:1;min-width:0}
.ingredients-col-x{width:26px;display:flex;align-items:center;justify-content:center}
.ingredients-col-num{width:24px;text-align:center;color:#888;font-size:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
+
+/* Course tab (map side panel) */
+.course-header{border-bottom:1px solid var(--border);padding-bottom:6px;margin-bottom:8px}
+.course-obstacle-section,.course-phases-section{margin-top:10px;border-top:1px solid var(--border);padding-top:6px}
+.section-divider{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
+.course-phase .phase-msg{font-family:monospace}
+.course-phase .mini-input{width:auto!important;flex:1}
+.course-details-card{margin-top:10px}
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 740edf3..f0b4d87 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -200,6 +200,12 @@ function renderMap(data) {
if (!fr || !tr) return;
var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2;
var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2;
+ if (l.hidden) {
+ // Hidden course-link exit: distinct color (map_course, ~#1B6FC0) and
+ // tighter dashed arrow style.
+ g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#1B6FC0" stroke-width="5" opacity="0.75" stroke-dasharray="2,2" vector-effect="non-scaling-stroke"/>';
+ return;
+ }
var c = blendColors(fr.color, tr.color).replace('#','');
g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.55"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>';
});
@@ -370,6 +376,7 @@ function selectRoom(id) {
API.get('/api/rooms/' + id).then(function(data) {
renderSidePanel(data);
+ loadRoomCourseData(id);
}).catch(function(e) {
notify('Failed to load room: '+e.message, 'error');
});
@@ -402,6 +409,7 @@ function renderSidePanel(data) {
html += '<button class="panel-tab' + (currentPanelTab === 4 ? ' active' : '') + '" onclick="switchPanelTab(4)">Mobs</button>';
html += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>';
html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 7 ? ' active' : '') + '" onclick="switchPanelTab(7)">Course</button>';
html += '</div>';
html += '<div class="panel-tab-content">';
@@ -412,6 +420,16 @@ function renderSidePanel(data) {
else if (currentPanelTab === 4) html += renderMobsTab(mobs);
else if (currentPanelTab === 5) html += renderItemsTab(spawns);
else if (currentPanelTab === 6) html += renderHazardTab(r);
+ else if (currentPanelTab === 7) {
+ if (currentRoomData.course === undefined) {
+ html += renderCourseTabLoading();
+ loadRoomCourseData(r.id);
+ } else if (currentRoomData.course === null) {
+ html += renderCourseTabEmpty();
+ } else {
+ html += renderCourseTab();
+ }
+ }
html += '</div>';
panel.innerHTML = html;
@@ -477,6 +495,489 @@ function renderHazardTab(r) {
return html;
}
+// ---- Course tab ---------------------------------------------------------
+
+var OBSTACLE_VERBS = ['scramble','jump','swing','balance','climb','crawl','vault','leap','slide'];
+
+function loadRoomCourseData(id) {
+ if (!currentRoomData) return;
+ // Mark as "fetching" (undefined) so switchPanelTab knows to show Loading
+ // rather than the empty state, and so re-clicking the tab doesn't refetch.
+ if (currentRoomData.course === undefined) {
+ // first time for this room
+ } else {
+ currentRoomData.course = undefined;
+ }
+ API.get('/api/rooms/' + id + '/course').then(function(c) {
+ currentRoomData.course = c;
+ _coursePhases = null;
+ if (currentPanelTab === 7 && selectedRoom === id) {
+ var content = document.querySelector('.panel-tab-content');
+ if (content) content.innerHTML = renderCourseTab();
+ }
+ }).catch(function() {
+ if (currentRoomData) currentRoomData.course = null;
+ });
+}
+
+function renderCourseTabLoading() {
+ return '<p style="color:#888;text-align:center;margin-top:30px">Loading course...</p>';
+}
+
+var _courseDetailsCollapsed = true;
+
+function renderCourseTab() {
+ var cd = currentRoomData && currentRoomData.course ? currentRoomData.course : null;
+ if (!cd || cd === null) {
+ return renderCourseTabEmpty();
+ }
+ var h = '';
+ var obstacle = cd.obstacle || {};
+ var idx = cd.obstacle_index, total = cd.total_obstacles;
+ var course = cd.course || {};
+ var prevRoom = idx > 0 ? (course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].room_id) : 0;
+ var nextRoom = idx < total - 1 ? (course.obstacles && course.obstacles[idx+1] && course.obstacles[idx+1].room_id) : 0;
+ var roomID = currentRoomData.room.id;
+
+ // Header: Step N, Room #X (This Obstacle)
+ h += '<div class="course-header">';
+ h += '<h3 style="margin:0 0 4px 0">Step ' + (idx + 1) + ', Room #' + roomID + '</h3>';
+ h += '<div style="font-size:11px;color:#aaa;margin-bottom:6px">(This Obstacle) &mdash; ' + esc(cd.course_name || cd.course_id || '') + '</div>';
+ // Prev/Next buttons row
+ h += '<div style="display:flex;gap:6px;margin-bottom:6px">';
+ if (prevRoom) {
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">&larr; Step ' + idx + ' (#' + prevRoom + ')</button>';
+ }
+ if (nextRoom) {
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') &rarr;</button>';
+ }
+ if (!prevRoom && !nextRoom) {
+ h += '<span style="font-size:11px;color:#888">Single-step course</span>';
+ }
+ h += '</div>';
+ h += '</div>';
+
+ // Obstacle details
+ h += '<div class="course-obstacle-section">';
+ h += '<div class="form-group"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">';
+ OBSTACLE_VERBS.forEach(function(v) {
+ h += '<option value="' + v + '"' + (obstacle.verb === v ? ' selected' : '') + '>' + v + '</option>';
+ });
+ h += '</select></div>';
+ h += '<div class="form-group"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">';
+ var fd = obstacle.fail_damage || [0,0];
+ var fdMin = Array.isArray(fd) ? fd[0] : 0;
+ var fdMax = Array.isArray(fd) ? fd[1] : 0;
+ h += '<input id="courseObstacleFailMin" type="number" value="' + fdMin + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '<input id="courseObstacleFailMax" type="number" value="' + fdMax + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '</div></div>';
+ h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label>';
+ var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : '';
+ h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.</div>';
+ h += '</div>';
+ h += '</div>';
+
+ // Phases section
+ h += '<div class="course-phases-section">';
+ h += '<div class="section-divider">Phases (messages)</div>';
+ h += '<div id="coursePhases"></div>';
+ h += '<button class="btn btn-sm section-add-btn" id="addPhaseBtn" onclick="courseAddPhase()">+ Add Message</button>';
+ h += '</div>';
+
+ h += '<div class="btn-row" style="margin-top:10px">';
+ h += '<button class="btn btn-danger" onclick="courseDetach()">Remove from course</button>';
+ h += '</div>';
+
+ // Collapsible course details (starts collapsed)
+ h += '<div class="course-details-card" style="margin-top:12px">';
+ h += '<div class="obj-card-header" onclick="courseToggleDetails()">';
+ h += '<span class="obj-card-arrow">' + (_courseDetailsCollapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<span>Course Details</span>';
+ h += '</div>';
+ if (!_courseDetailsCollapsed) {
+ h += '<div class="obj-card-body">';
+ h += '<div class="form-group"><label>Course Name</label><input id="courseName" value="' + escAttr(course.name || '') + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Start Room (hub / fail return)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Completion XP (lap bonus)</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
+ }
+ h += '</div>';
+
+ setTimeout(function() { renderCoursePhases(); }, 0);
+ return h;
+}
+
+function courseToggleDetails() {
+ _courseDetailsCollapsed = !_courseDetailsCollapsed;
+ if (currentPanelTab === 7 && currentRoomData) {
+ var content = document.querySelector('.panel-tab-content');
+ if (content) content.innerHTML = renderCourseTab();
+ }
+}
+
+var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','southeast','southwest'];
+
+function renderCourseTabEmpty() {
+ var h = '<p style="color:#888;font-size:11px;margin:6px 0 12px">This room is not part of any course.</p>';
+ h += '<div class="form-group"><label>Direction for new step exit</label>';
+ h += '<select id="courseAttachDir"><option value="">auto</option>';
+ HORIZONTAL_DIRS.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; });
+ h += '</select></div>';
+ h += '<div class="section-search">';
+ h += '<input type="text" class="search-input" placeholder="Add to course..." oninput="searchCourses(this)">';
+ h += '<div class="search-results" style="display:none"></div>';
+ h += '</div>';
+ h += '<button class="btn btn-sm section-add-btn" style="margin-top:8px" onclick="courseShowNew()">+ New Course</button>';
+ return h;
+}
+
+// ---- phases -------------------------------------------------------------
+
+function getCoursePhases() {
+ var c = currentRoomData && currentRoomData.course;
+ if (!c) return [];
+ var obs = c.obstacle || {};
+ var phases = obs.phases;
+ if (!Array.isArray(phases)) {
+ // build from legacy messages form
+ var msgs = obs.messages || [];
+ var tpp = obs.ticks_per_phase || 0;
+ phases = msgs.map(function(m, i) {
+ return { message: m, delay: i === 0 ? 0 : tpp, fail_check: (i === 1) };
+ });
+ }
+ return phases;
+}
+
+var _coursePhases = null;
+
+function ensureCoursePhasesLoaded() {
+ if (_coursePhases === null) {
+ _coursePhases = getCoursePhases().slice();
+ }
+}
+
+function renderCoursePhases() {
+ ensureCoursePhasesLoaded();
+ var container = $('#coursePhases');
+ if (!container) return;
+ var h = '';
+ _coursePhases.forEach(function(p, i) {
+ h += '<div class="entry-row course-phase" data-idx="' + i + '">';
+ h += '<div class="entry-fields">';
+ h += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>';
+ h += '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">';
+ h += '<label class="mini-label">Delay</label><input class="mini-input phase-delay" type="text" inputmode="numeric" value="' + (p.delay || 0) + '" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')">';
+ h += '<label class="mini-label" style="display:flex;align-items:center;gap:3px"><input type="radio" class="phase-fail" name="phaseFail" onchange="courseSetFailCheck(' + i + ')"' + (p.fail_check ? ' checked' : '') + '> Fail check here</label>';
+ h += '</div>';
+ h += '</div>';
+ h += '<button class="btn btn-sm btn-danger" onclick="courseRemovePhase(' + i + ')">X</button>';
+ h += '</div>';
+ });
+ container.innerHTML = h;
+}
+
+function courseAddPhase() {
+ ensureCoursePhasesLoaded();
+ _coursePhases.push({ message: '', delay: 0, fail_check: false });
+ renderCoursePhases();
+ courseAutoSave();
+}
+
+function courseRemovePhase(i) {
+ ensureCoursePhasesLoaded();
+ _coursePhases.splice(i, 1);
+ renderCoursePhases();
+ courseAutoSave();
+}
+
+function courseUpdatePhase(i) {
+ ensureCoursePhasesLoaded();
+ if (i < 0 || i >= _coursePhases.length) return;
+ var row = document.querySelector('.course-phase[data-idx="' + i + '"]');
+ if (!row) return;
+ _coursePhases[i].message = row.querySelector('.phase-msg').value;
+ _coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0;
+ courseAutoSave();
+}
+
+function courseSetFailCheck(i) {
+ ensureCoursePhasesLoaded();
+ _coursePhases.forEach(function(p, j) { p.fail_check = (j === i); });
+ renderCoursePhases();
+ courseAutoSave();
+}
+
+// ---- save / detach ------------------------------------------------------
+
+function courseSyncPhasesFromDOM() {
+ ensureCoursePhasesLoaded();
+ var rows = document.querySelectorAll('.course-phase');
+ rows.forEach(function(row) {
+ var i = parseInt(row.getAttribute('data-idx'));
+ if (i < 0 || i >= _coursePhases.length) return;
+ var msgEl = row.querySelector('.phase-msg');
+ var delayEl = row.querySelector('.phase-delay');
+ if (msgEl) _coursePhases[i].message = msgEl.value;
+ if (delayEl) _coursePhases[i].delay = parseFloat(delayEl.value) || 0;
+ });
+}
+
+var _courseSaveTimer = null;
+
+function courseAutoSave() {
+ if (_courseSaveTimer) clearTimeout(_courseSaveTimer);
+ _courseSaveTimer = setTimeout(function() { courseSave(true); }, 600);
+}
+
+function courseSave(silent) {
+ if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return; }
+ var c = currentRoomData.course;
+ var course = c.course || {};
+ var courseID = c.course_id;
+ if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; }
+
+ courseSyncPhasesFromDOM();
+
+ // Course-level fields (may be absent if the details section is collapsed;
+ // fall back to existing course values).
+ var nameEl = document.querySelector('#courseName');
+ var rlEl = document.querySelector('#courseRequiredLevel');
+ var srEl = document.querySelector('#courseStartRoom');
+ var cxEl = document.querySelector('#courseCompletionXP');
+ var name = nameEl ? nameEl.value : (course.name || '');
+ var reqLevel = rlEl ? (parseInt(rlEl.value) || 0) : (course.required_level || 0);
+ var startRoom = srEl ? (parseInt(srEl.value) || 0) : (course.start_room || 0);
+ var completionXP = cxEl ? (parseInt(cxEl.value) || 0) : (course.completion_xp || 0);
+
+ var verbEl = document.querySelector('#courseObstacleVerb');
+ var xpEl = document.querySelector('#courseObstacleXP');
+ var fminEl = document.querySelector('#courseObstacleFailMin');
+ var fmaxEl = document.querySelector('#courseObstacleFailMax');
+ var fcEl = document.querySelector('#courseObstacleFailChance');
+ var verb = verbEl ? verbEl.value : (c.obstacle && c.obstacle.verb) || 'climb';
+ var xp = xpEl ? (parseInt(xpEl.value) || 0) : (c.obstacle && c.obstacle.xp) || 0;
+ var failMin = fminEl ? (parseInt(fminEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[0] : 0);
+ var failMax = fmaxEl ? (parseInt(fmaxEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[1] : 0);
+ var fcStr = fcEl ? fcEl.value : (c.obstacle && (c.obstacle.fail_chance !== undefined && c.obstacle.fail_chance !== null) ? String(c.obstacle.fail_chance) : '');
+ var fc = fcStr === '' ? null : parseFloat(fcStr);
+ if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; }
+
+ var obstacles = (course.obstacles || []).slice();
+ var newObs = {
+ room_id: currentRoomData.room.id,
+ verb: verb,
+ xp: xp,
+ fail_damage: [failMin, failMax]
+ };
+ if (fc !== null) newObs.fail_chance = fc;
+ newObs.phases = (_coursePhases || []).map(function(p) {
+ var ph = { message: p.message || '', delay: p.delay || 0 };
+ if (p.fail_check) ph.fail_check = true;
+ return ph;
+ });
+ obstacles[c.obstacle_index] = newObs;
+
+ var body = {
+ name: name,
+ required_level: reqLevel,
+ start_room: startRoom,
+ completion_xp: completionXP,
+ obstacles: obstacles
+ };
+
+ API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() {
+ if (!silent) notify('Course ' + courseID + ' saved', 'success');
+ updateUndoBar();
+ // Refresh the cached course data WITHOUT re-rendering the panel, so the
+ // builder's cursor isn't disrupted (auto-save uses silent=true).
+ API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) {
+ currentRoomData.course = c;
+ _coursePhases = null;
+ }).catch(function() {});
+ }).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); });
+}
+
+function courseDetach() {
+ if (!currentRoomData || !currentRoomData.course) return;
+ var c = currentRoomData.course;
+ var total = c.total_obstacles || 1;
+ var msg = 'Remove room #' + currentRoomData.room.id + ' from course ' + c.course_id + '?';
+ if (total <= 1) msg = 'This is the last obstacle of course ' + c.course_id + '. Removing it will DELETE the course. Continue?';
+ if (!confirm(msg)) return;
+ fetch('/api/rooms/' + currentRoomData.room.id + '/courses/detach', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: '{}',
+ credentials: 'same-origin'
+ }).then(function(r) { return r.json(); }).then(function(res) {
+ if (res.error) { notify(res.error, 'error'); return; }
+ notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success');
+ updateUndoBar();
+ _coursePhases = null;
+ currentRoomData.course = null;
+ loadRoomCourseData(currentRoomData.room.id);
+ var content = document.querySelector('.panel-tab-content');
+ if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab();
+ }).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); });
+}
+
+// ---- attach / new course ------------------------------------------------
+
+function addRoomToCourse(courseID) {
+ if (!currentRoomData || !currentRoomData.room) return;
+ var roomID = currentRoomData.room.id;
+ var dirSel = document.querySelector('#courseAttachDir');
+ var dir = dirSel ? dirSel.value : '';
+ fetch('/api/rooms/' + roomID + '/courses/attach', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ course_id: courseID, after_index: 999, direction: dir }),
+ credentials: 'same-origin'
+ }).then(function(r) { return r.json(); }).then(function(res) {
+ if (res.error) { notify(res.error, 'error'); return; }
+ notify('Added room #' + roomID + ' to course ' + courseID, 'success');
+ updateUndoBar();
+ _coursePhases = null;
+ loadRoomCourseData(roomID);
+ }).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); });
+}
+
+function searchCourses(input) {
+ searchAttachShow(input, function(r) { addRoomToCourse(r.id); });
+}
+
+function searchAttachShow(input, onSelect) {
+ var val = input.value.trim();
+ var results = input.parentElement.querySelector('.search-results');
+ if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; }
+ API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
+ var items = data.courses || [];
+ if (items.length === 0) {
+ results.style.display = 'block';
+ results.innerHTML = '<div class="sr-item" style="color:#888">No results</div>';
+ return;
+ }
+ results.style.display = 'block';
+ results.innerHTML = items.map(function(r) {
+ return '<div class="sr-item" onclick="event.stopPropagation(); selectCourseResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</div>';
+ }).join('');
+ results._items = items;
+ results._onSelect = onSelect;
+ }).catch(function() { results.style.display = 'none'; });
+}
+
+function selectCourseResult(el) {
+ var results = el.parentElement;
+ var idx = Array.prototype.indexOf.call(results.children, el);
+ if (results._items && results._items[idx] && results._onSelect) {
+ results._onSelect(results._items[idx]);
+ results.style.display = 'none';
+ results.innerHTML = '';
+ var input = results.parentElement.querySelector('.search-input');
+ if (input) input.value = '';
+ }
+}
+
+function courseShowNew() {
+ if (!currentRoomData || !currentRoomData.room) return;
+ var roomID = currentRoomData.room.id;
+
+ var overlay = document.createElement('div');
+ overlay.className = 'cm-overlay exit-overlay';
+ var menu = document.createElement('div');
+ menu.className = 'cm-menu exit-modal';
+ menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201';
+
+ var h = '<h3 style="margin-bottom:8px;font-size:13px">New Course (room #' + roomID + ' = step 1)</h3>';
+ h += '<div class="form-group"><label>Course ID (filename)</label><input id="newCourseID" placeholder="e.g. vent_shaft" ></div>';
+ h += '<div class="form-group"><label>Course Name</label><input id="newCourseName" placeholder="e.g. Ventilation Shaft Course"></div>';
+ h += '<div class="form-group"><label>Required Level</label><input id="newCourseReqLevel" type="number" value="1"></div>';
+ h += '<div class="form-group"><label>Start Room</label><input id="newCourseStartRoom" type="number" value="' + roomID + '"></div>';
+ h += '<div class="form-group"><label>Completion XP</label><input id="newCourseCompletionXP" type="number" value="0"></div>';
+ h += '<div class="form-group"><label>Verb</label><select id="newCourseVerb">';
+ OBSTACLE_VERBS.forEach(function(v) {
+ h += '<option value="' + v + '"' + (v === 'climb' ? ' selected' : '') + '>' + v + '</option>';
+ });
+ h += '</select></div>';
+ h += '<div class="form-group"><label>XP</label><input id="newCourseXP" type="number" value="0"></div>';
+ h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">';
+ h += '<input id="newCourseFailMin" type="number" value="0" style="flex:1">';
+ h += '<input id="newCourseFailMax" type="number" value="0" style="flex:1">';
+ h += '</div></div>';
+ h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label><input id="newCourseFailChance" type="text" placeholder="derived"></div>';
+ h += '<div class="form-group"><label>First message</label><textarea id="newCourseMsg" rows="2" placeholder="You approach the obstacle..."></textarea></div>';
+ h += '<div class="btn-row" style="margin-top:10px">';
+ h += '<button class="btn btn-primary" onclick="courseCreateConfirm()">Create</button>';
+ h += '<button class="btn" onclick="courseCloseModal()">Cancel</button>';
+ h += '</div>';
+
+ menu.innerHTML = h;
+ document.body.appendChild(overlay);
+ document.body.appendChild(menu);
+ overlay.onclick = courseCloseModal;
+}
+
+function courseCloseModal() {
+ var ov = document.querySelector('.exit-overlay');
+ if (ov) ov.remove();
+ var m = document.querySelector('.exit-modal');
+ if (m) m.remove();
+}
+
+function courseCreateConfirm() {
+ if (!currentRoomData || !currentRoomData.room) return;
+ var roomID = currentRoomData.room.id;
+ var id = val('#newCourseID');
+ if (!id) { notify('Course ID required', 'error'); return; }
+ var name = val('#newCourseName');
+ var reqLevel = parseInt(val('#newCourseReqLevel')) || 0;
+ var startRoom = parseInt(val('#newCourseStartRoom')) || 0;
+ var completionXP = parseInt(val('#newCourseCompletionXP')) || 0;
+ var verb = sel('#newCourseVerb');
+ var xp = parseInt(val('#newCourseXP')) || 0;
+ var failMin = parseInt(val('#newCourseFailMin')) || 0;
+ var failMax = parseInt(val('#newCourseFailMax')) || 0;
+ var fcStr = val('#newCourseFailChance');
+ var fc = fcStr === '' ? null : parseFloat(fcStr);
+ if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; }
+ var msg = val('#newCourseMsg') || '';
+
+ var obstacle = {
+ room_id: roomID,
+ verb: verb,
+ xp: xp,
+ fail_damage: [failMin, failMax],
+ phases: [{ message: msg, delay: 0 }]
+ };
+ if (fc !== null) obstacle.fail_chance = fc;
+
+ var body = {
+ id: id,
+ name: name,
+ required_level: reqLevel,
+ start_room: startRoom,
+ completion_xp: completionXP,
+ obstacles: [obstacle]
+ };
+
+ API.post('/api/courses', body).then(function(res) {
+ if (res.error) { notify(res.error, 'error'); return; }
+ notify('Course ' + id + ' created', 'success');
+ updateUndoBar();
+ courseCloseModal();
+ _coursePhases = null;
+ loadRoomCourseData(roomID);
+ }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
+}
+
+// tiny helpers used by the course tab
+function val(sel) { var el = document.querySelector(sel); return el ? el.value : ''; }
+function sel(sel) { var el = document.querySelector(sel); return el ? el.value : ''; }
+
function searchHazards(input) {
searchAndShow(input, 'hazards', function(r) { addHazard(r.id); });
}
@@ -528,6 +1029,17 @@ function switchPanelTab(idx) {
else if (idx === 4) html = renderMobsTab(mobs);
else if (idx === 5) html = renderItemsTab(spawns);
else if (idx === 6) html = renderHazardTab(r);
+ else if (idx === 7) {
+ _coursePhases = null;
+ if (currentRoomData.course === undefined) {
+ html = renderCourseTabLoading();
+ loadRoomCourseData(r.id);
+ } else if (currentRoomData.course === null) {
+ html = renderCourseTabEmpty();
+ } else {
+ html = renderCourseTab();
+ }
+ }
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = html;
var tabs = document.querySelectorAll('.panel-tab');
@@ -646,10 +1158,12 @@ function renderExitsSection(r) {
var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : '';
var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null;
var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null;
- var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags);
+ var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false;
+ var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden);
h += '<div class="exit-row" data-dir="' + d + '">';
h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>';
h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>';
+ h += '<label style="font-size:11px;margin-left:4px;cursor:pointer;color:#aaa"><input type="checkbox" class="exit-hidden" style="vertical-align:middle"' + (hidden ? ' checked' : '') + ' onchange="toggleExitHidden(\'' + d + '\')"> Hidden</label>';
if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>';
if (cond) h += '<span class="exit-meta">Conditional</span>';
h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>';
@@ -679,6 +1193,7 @@ function editExitCondition(dir) {
var bmsg = exit.blocked_message || '';
var setFlags = exit.set_flags || null;
var setPlayerFlags = exit.set_player_flags || null;
+ var hidden = exit.hidden || false;
var flagsStr = '';
if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', ');
@@ -697,6 +1212,7 @@ function editExitCondition(dir) {
h += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>';
h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>';
h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>';
+ h += '<div class="form-group"><label><input type="checkbox" id="exitHidden"' + (hidden ? ' checked' : '') + '> Hidden</label></div>';
h += '<div style="margin-top:10px;border-top:1px solid var(--border);padding-top:8px">';
h += '<label style="font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px">Conditions</label>';
@@ -834,6 +1350,8 @@ function saveExitCondition() {
var bmsg = document.getElementById('exitBmsg');
exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : '';
+ var hiddenEl = document.getElementById('exitHidden');
+ exit.hidden = hiddenEl ? hiddenEl.checked : false;
var flagsStr = document.getElementById('exitFlags');
var pflagsStr = document.getElementById('exitPFlags');
exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : '');
@@ -928,6 +1446,21 @@ function deleteExit(dir, target) {
}).catch(function(e) { notify('Delete exit failed: ' + extractError(e), 'error'); });
}
+function toggleExitHidden(dir) {
+ if (!currentRoomData || !currentRoomData.room) return;
+ var exits = currentRoomData.room.exits || {};
+ var exit = exits[dir];
+ if (!exit) return;
+ if (typeof exit !== 'object') {
+ exit = {room: exit};
+ exits[dir] = exit;
+ currentRoomData.room.exits = exits;
+ }
+ exit.hidden = !exit.hidden;
+ autoSave();
+ reRenderPanel();
+}
+
function extractError(e) {
var msg = e.message || 'unknown error';
try { var j = JSON.parse(msg); if (j.error) msg = j.error; } catch(_) {}
diff --git a/internal/admin/templates/courses.html b/internal/admin/templates/courses.html
deleted file mode 100644
index 60e07f8..0000000
--- a/internal/admin/templates/courses.html
+++ /dev/null
@@ -1,14 +0,0 @@
-{{define "body-courses"}}
-<div class="editor-container">
- <div class="editor-list" id="itemList">
- <div class="search-bar"><input placeholder="Search courses..." oninput="filterList(this.value)"></div>
- <div id="listEntries"></div>
- <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Course</button>
- </div>
- <div class="editor-main" id="editorMain">
- <p style="color:#888;text-align:center;margin-top:60px">Select a course to edit</p>
- </div>
-</div>
-<script src="/static/editor.js"></script>
-<script>initEditor('courses', [{key:'name',label:'Name',type:'text'},{key:'skill',label:'Skill',type:'text'},{key:'level',label:'Level',type:'number'},{key:'cost',label:'Cost',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script>
-{{end}}
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
index 410c8df..009e1f7 100644
--- a/internal/admin/templates/layout.html
+++ b/internal/admin/templates/layout.html
@@ -17,7 +17,6 @@
<a href="/editor/drops" class="{{if eq .Page "drops"}}active{{end}}">Drops</a>
<a href="/editor/hazards" class="{{if eq .Page "hazards"}}active{{end}}">Hazards</a>
<a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a>
- <a href="/editor/courses" class="{{if eq .Page "courses"}}active{{end}}">Courses</a>
<a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a>
<a href="/editor/players" class="{{if eq .Page "players"}}active{{end}}">Players</a>
<a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
@@ -37,7 +36,6 @@
{{else if eq .Page "drops"}}{{template "body-drops" .}}
{{else if eq .Page "hazards"}}{{template "body-hazards" .}}
{{else if eq .Page "techs"}}{{template "body-techs" .}}
-{{else if eq .Page "courses"}}{{template "body-courses" .}}
{{else if eq .Page "modules"}}{{template "body-modules" .}}
{{else if eq .Page "players"}}{{template "body-players" .}}
{{else if eq .Page "dashboard"}}{{template "body-dashboard" .}}