From 94823b52168b44894fb5e9c960358ed02ebb122b Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Sun, 5 Jul 2026 22:35:21 -0400
Subject: feat: courses gui overhaul, add course tab to map, add hidden exits
to give courses structure
---
internal/admin/api_courses.go | 138 -------
internal/admin/api_map.go | 36 +-
internal/admin/api_room_courses.go | 662 ++++++++++++++++++++++++++++++++++
internal/admin/api_rooms.go | 14 +
internal/admin/api_search.go | 21 ++
internal/admin/server.go | 6 +-
internal/admin/static/admin.css | 14 +-
internal/admin/static/map.js | 535 ++++++++++++++++++++++++++-
internal/admin/templates/courses.html | 14 -
internal/admin/templates/layout.html | 2 -
10 files changed, 1266 insertions(+), 176 deletions(-)
delete mode 100644 internal/admin/api_courses.go
create mode 100644 internal/admin/api_room_courses.go
delete mode 100644 internal/admin/templates/courses.html
(limited to 'internal/admin')
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 += '
Loading course...
'; +} + +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 += 'This room is not part of any course.
'; + h += 'Select a course to edit
-