aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_room_courses.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/api_room_courses.go')
-rw-r--r--internal/admin/api_room_courses.go662
1 files changed, 662 insertions, 0 deletions
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