diff options
| author | historia <[not public]> | 2026-07-05 22:35:21 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-05 22:35:21 -0400 |
| commit | 94823b52168b44894fb5e9c960358ed02ebb122b (patch) | |
| tree | 3060799b89bb7edfa708c72bdd981107d40f4d09 /internal/admin/api_room_courses.go | |
| parent | 843fa6db681e494bf46e191655323a40577542b5 (diff) | |
| download | thehouseoficarus-94823b52168b44894fb5e9c960358ed02ebb122b.tar.gz | |
feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure
Diffstat (limited to 'internal/admin/api_room_courses.go')
| -rw-r--r-- | internal/admin/api_room_courses.go | 662 |
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 |
