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) }