package admin import ( "fmt" "net/http" "os" "path/filepath" "strconv" "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, }) warnings := s.reconcileCourseExits(body) s.reloadCoursesIfWired() resp := map[string]any{"ok": true, "id": id} if len(warnings) > 0 { resp["warnings"] = warnings } writeJSON(w, resp) case http.MethodDelete: oldContent, err := snapshotFile(path) 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 // --- course map exit management --- // Set exit_dir on the previous obstacle. // Append-only: the new obstacle is last so no new->next link is needed. if insertAt > 0 { prevIdx := insertAt - 1 prevRoom := obstacleRoomID(newList[prevIdx]) if prevRoom > 0 { dir := strings.TrimSpace(strings.ToLower(body.Direction)) if dir == "auto" || dir == "" { dir = "north" } if !isHorizontalDir(dir) { writeJSON(w, map[string]any{"error": "invalid direction: " + dir}) return } prevObstacle := newList[prevIdx] prevObstacle["exit_dir"] = dir newList[prevIdx] = prevObstacle } } anyList = make([]any, len(newList)) for i, o := range newList { anyList[i] = o } m["obstacles"] = anyList newContent, err := writeMapAsYAML(path, m) if err != nil { writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) return } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("attach room %d to course %s", roomID, body.CourseID), FilePath: path, OldContent: oldContent, NewContent: newContent, }) 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 // detachRoomFromCourse removes roomID from the course it belongs to, if any. // Returns the course ID that was modified, or "" if none match. func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted bool) { ids, err := s.listCourseIDs() if err != nil { return "", false } 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) if idx > 0 { delete(obstacles[idx-1], "exit_dir") } remaining := make([]map[string]any, 0, len(obstacles)-1) remaining = append(remaining, obstacles[:idx]...) remaining = append(remaining, obstacles[idx+1:]...) if len(remaining) == 0 { os.Remove(path) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("delete course %s (last obstacle removed)", id), FilePath: path, OldContent: oldContent, IsDelete: true, }) s.reloadCoursesIfWired() return id, true } anyList := make([]any, len(remaining)) for i, o := range remaining { anyList[i] = o } m["obstacles"] = anyList newContent, err := writeMapAsYAML(path, m) if err != nil { return "", false } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("detach room %d from course %s", roomID, id), FilePath: path, OldContent: oldContent, NewContent: newContent, }) s.reloadCoursesIfWired() return id, false } return "", false } func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } courseID, deleted := s.detachRoomFromCourse(roomID) if courseID == "" && !deleted { writeJSON(w, map[string]any{"error": "room is not part of any course"}) return } writeJSON(w, map[string]any{"ok": true, "course_id": courseID, "deleted": deleted}) } // loadRoomMapByID reads a room YAML file as a generic map. func (s *AdminServer) loadRoomMapByID(roomID int) (map[string]any, []byte, string, error) { 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 } // 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 } // exitIsAlwaysBlocked returns whether an exit value carries always_blocked: true. func exitIsAlwaysBlocked(v any) bool { if mm, ok := v.(map[string]any); ok { if h, ok := mm["always_blocked"]; ok { return h == true } } return false } // isHorizontalDir returns whether d is one of the 8 horizontal directions. func isHorizontalDir(d string) bool { for _, h := range horizontalExitDirs { if d == h { return true } } return false } // reconcileExitOnRoom validates that roomID has an exit in dir. // If expectedTarget > 0, also checks the exit points to that room. // Returns an error string or empty string on success. func (s *AdminServer) validateExitDir(roomID int, dir string, expectedTarget int) string { m, _, _, err := s.loadRoomMapByID(roomID) if err != nil { return "cannot load room " + strconv.Itoa(roomID) } ex, _ := m["exits"].(map[string]any) existing := ex[dir] if existing == nil { return "room " + strconv.Itoa(roomID) + " has no exit in direction " + dir } if expectedTarget > 0 { target := exitTargetRoom(existing) if target != expectedTarget { return "direction " + dir + " on room " + strconv.Itoa(roomID) + " points to room " + strconv.Itoa(target) + ", expected " + strconv.Itoa(expectedTarget) } } return "" } // reconcileCourseExits validates every obstacle's exit_dir against the actual // exits on the corresponding room. Returns warnings for any mismatches found. func (s *AdminServer) reconcileCourseExits(m map[string]any) []string { var warnings []string obstacles := asMapList(m["obstacles"]) for i, obs := range obstacles { roomID := obstacleRoomID(obs) dir, _ := obs["exit_dir"].(string) if dir == "" { continue } var nextRoom int if i < len(obstacles)-1 { nextRoom = obstacleRoomID(obstacles[i+1]) } errMsg := s.validateExitDir(roomID, dir, nextRoom) if errMsg != "" { warnings = append(warnings, errMsg) } } return warnings }