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 += ''; + return; + } var c = blendColors(fr.color, tr.color).replace('#',''); g += ''; }); @@ -370,6 +376,7 @@ function selectRoom(id) { API.get('/api/rooms/' + id).then(function(data) { renderSidePanel(data); + loadRoomCourseData(id); }).catch(function(e) { notify('Failed to load room: '+e.message, 'error'); }); @@ -402,6 +409,7 @@ function renderSidePanel(data) { html += ''; html += ''; html += ''; + html += ''; html += ''; html += '
'; @@ -412,6 +420,16 @@ function renderSidePanel(data) { else if (currentPanelTab === 4) html += renderMobsTab(mobs); else if (currentPanelTab === 5) html += renderItemsTab(spawns); else if (currentPanelTab === 6) html += renderHazardTab(r); + else if (currentPanelTab === 7) { + if (currentRoomData.course === undefined) { + html += renderCourseTabLoading(); + loadRoomCourseData(r.id); + } else if (currentRoomData.course === null) { + html += renderCourseTabEmpty(); + } else { + html += renderCourseTab(); + } + } html += '
'; panel.innerHTML = html; @@ -477,6 +495,489 @@ function renderHazardTab(r) { return html; } +// ---- Course tab --------------------------------------------------------- + +var OBSTACLE_VERBS = ['scramble','jump','swing','balance','climb','crawl','vault','leap','slide']; + +function loadRoomCourseData(id) { + if (!currentRoomData) return; + // Mark as "fetching" (undefined) so switchPanelTab knows to show Loading + // rather than the empty state, and so re-clicking the tab doesn't refetch. + if (currentRoomData.course === undefined) { + // first time for this room + } else { + currentRoomData.course = undefined; + } + API.get('/api/rooms/' + id + '/course').then(function(c) { + currentRoomData.course = c; + _coursePhases = null; + if (currentPanelTab === 7 && selectedRoom === id) { + var content = document.querySelector('.panel-tab-content'); + if (content) content.innerHTML = renderCourseTab(); + } + }).catch(function() { + if (currentRoomData) currentRoomData.course = null; + }); +} + +function renderCourseTabLoading() { + return '

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 += '
'; + h += '

Step ' + (idx + 1) + ', Room #' + roomID + '

'; + h += '
(This Obstacle) — ' + esc(cd.course_name || cd.course_id || '') + '
'; + // Prev/Next buttons row + h += '
'; + if (prevRoom) { + h += ''; + } + if (nextRoom) { + h += ''; + } + if (!prevRoom && !nextRoom) { + h += 'Single-step course'; + } + h += '
'; + h += '
'; + + // Obstacle details + h += '
'; + h += '
'; + h += '
'; + h += '
'; + var fd = obstacle.fail_damage || [0,0]; + var fdMin = Array.isArray(fd) ? fd[0] : 0; + var fdMax = Array.isArray(fd) ? fd[1] : 0; + h += ''; + h += ''; + h += '
'; + h += '
'; + var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : ''; + h += ''; + h += '
Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.
'; + h += '
'; + h += '
'; + + // Phases section + h += '
'; + h += '
Phases (messages)
'; + h += '
'; + h += ''; + h += '
'; + + h += '
'; + h += ''; + h += '
'; + + // Collapsible course details (starts collapsed) + h += '
'; + h += '
'; + h += '' + (_courseDetailsCollapsed ? '▶' : '▼') + ''; + h += 'Course Details'; + h += '
'; + if (!_courseDetailsCollapsed) { + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + } + h += '
'; + + setTimeout(function() { renderCoursePhases(); }, 0); + return h; +} + +function courseToggleDetails() { + _courseDetailsCollapsed = !_courseDetailsCollapsed; + if (currentPanelTab === 7 && currentRoomData) { + var content = document.querySelector('.panel-tab-content'); + if (content) content.innerHTML = renderCourseTab(); + } +} + +var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','southeast','southwest']; + +function renderCourseTabEmpty() { + var h = '

This room is not part of any course.

'; + h += '
'; + h += '
'; + h += ''; + h += ''; + return h; +} + +// ---- phases ------------------------------------------------------------- + +function getCoursePhases() { + var c = currentRoomData && currentRoomData.course; + if (!c) return []; + var obs = c.obstacle || {}; + var phases = obs.phases; + if (!Array.isArray(phases)) { + // build from legacy messages form + var msgs = obs.messages || []; + var tpp = obs.ticks_per_phase || 0; + phases = msgs.map(function(m, i) { + return { message: m, delay: i === 0 ? 0 : tpp, fail_check: (i === 1) }; + }); + } + return phases; +} + +var _coursePhases = null; + +function ensureCoursePhasesLoaded() { + if (_coursePhases === null) { + _coursePhases = getCoursePhases().slice(); + } +} + +function renderCoursePhases() { + ensureCoursePhasesLoaded(); + var container = $('#coursePhases'); + if (!container) return; + var h = ''; + _coursePhases.forEach(function(p, i) { + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + h += ''; + h += '
'; + }); + container.innerHTML = h; +} + +function courseAddPhase() { + ensureCoursePhasesLoaded(); + _coursePhases.push({ message: '', delay: 0, fail_check: false }); + renderCoursePhases(); + courseAutoSave(); +} + +function courseRemovePhase(i) { + ensureCoursePhasesLoaded(); + _coursePhases.splice(i, 1); + renderCoursePhases(); + courseAutoSave(); +} + +function courseUpdatePhase(i) { + ensureCoursePhasesLoaded(); + if (i < 0 || i >= _coursePhases.length) return; + var row = document.querySelector('.course-phase[data-idx="' + i + '"]'); + if (!row) return; + _coursePhases[i].message = row.querySelector('.phase-msg').value; + _coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0; + courseAutoSave(); +} + +function courseSetFailCheck(i) { + ensureCoursePhasesLoaded(); + _coursePhases.forEach(function(p, j) { p.fail_check = (j === i); }); + renderCoursePhases(); + courseAutoSave(); +} + +// ---- save / detach ------------------------------------------------------ + +function courseSyncPhasesFromDOM() { + ensureCoursePhasesLoaded(); + var rows = document.querySelectorAll('.course-phase'); + rows.forEach(function(row) { + var i = parseInt(row.getAttribute('data-idx')); + if (i < 0 || i >= _coursePhases.length) return; + var msgEl = row.querySelector('.phase-msg'); + var delayEl = row.querySelector('.phase-delay'); + if (msgEl) _coursePhases[i].message = msgEl.value; + if (delayEl) _coursePhases[i].delay = parseFloat(delayEl.value) || 0; + }); +} + +var _courseSaveTimer = null; + +function courseAutoSave() { + if (_courseSaveTimer) clearTimeout(_courseSaveTimer); + _courseSaveTimer = setTimeout(function() { courseSave(true); }, 600); +} + +function courseSave(silent) { + if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return; } + var c = currentRoomData.course; + var course = c.course || {}; + var courseID = c.course_id; + if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; } + + courseSyncPhasesFromDOM(); + + // Course-level fields (may be absent if the details section is collapsed; + // fall back to existing course values). + var nameEl = document.querySelector('#courseName'); + var rlEl = document.querySelector('#courseRequiredLevel'); + var srEl = document.querySelector('#courseStartRoom'); + var cxEl = document.querySelector('#courseCompletionXP'); + var name = nameEl ? nameEl.value : (course.name || ''); + var reqLevel = rlEl ? (parseInt(rlEl.value) || 0) : (course.required_level || 0); + var startRoom = srEl ? (parseInt(srEl.value) || 0) : (course.start_room || 0); + var completionXP = cxEl ? (parseInt(cxEl.value) || 0) : (course.completion_xp || 0); + + var verbEl = document.querySelector('#courseObstacleVerb'); + var xpEl = document.querySelector('#courseObstacleXP'); + var fminEl = document.querySelector('#courseObstacleFailMin'); + var fmaxEl = document.querySelector('#courseObstacleFailMax'); + var fcEl = document.querySelector('#courseObstacleFailChance'); + var verb = verbEl ? verbEl.value : (c.obstacle && c.obstacle.verb) || 'climb'; + var xp = xpEl ? (parseInt(xpEl.value) || 0) : (c.obstacle && c.obstacle.xp) || 0; + var failMin = fminEl ? (parseInt(fminEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[0] : 0); + var failMax = fmaxEl ? (parseInt(fmaxEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[1] : 0); + var fcStr = fcEl ? fcEl.value : (c.obstacle && (c.obstacle.fail_chance !== undefined && c.obstacle.fail_chance !== null) ? String(c.obstacle.fail_chance) : ''); + var fc = fcStr === '' ? null : parseFloat(fcStr); + if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; } + + var obstacles = (course.obstacles || []).slice(); + var newObs = { + room_id: currentRoomData.room.id, + verb: verb, + xp: xp, + fail_damage: [failMin, failMax] + }; + if (fc !== null) newObs.fail_chance = fc; + newObs.phases = (_coursePhases || []).map(function(p) { + var ph = { message: p.message || '', delay: p.delay || 0 }; + if (p.fail_check) ph.fail_check = true; + return ph; + }); + obstacles[c.obstacle_index] = newObs; + + var body = { + name: name, + required_level: reqLevel, + start_room: startRoom, + completion_xp: completionXP, + obstacles: obstacles + }; + + API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() { + if (!silent) notify('Course ' + courseID + ' saved', 'success'); + updateUndoBar(); + // Refresh the cached course data WITHOUT re-rendering the panel, so the + // builder's cursor isn't disrupted (auto-save uses silent=true). + API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) { + currentRoomData.course = c; + _coursePhases = null; + }).catch(function() {}); + }).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); }); +} + +function courseDetach() { + if (!currentRoomData || !currentRoomData.course) return; + var c = currentRoomData.course; + var total = c.total_obstacles || 1; + var msg = 'Remove room #' + currentRoomData.room.id + ' from course ' + c.course_id + '?'; + if (total <= 1) msg = 'This is the last obstacle of course ' + c.course_id + '. Removing it will DELETE the course. Continue?'; + if (!confirm(msg)) return; + fetch('/api/rooms/' + currentRoomData.room.id + '/courses/detach', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: '{}', + credentials: 'same-origin' + }).then(function(r) { return r.json(); }).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success'); + updateUndoBar(); + _coursePhases = null; + currentRoomData.course = null; + loadRoomCourseData(currentRoomData.room.id); + var content = document.querySelector('.panel-tab-content'); + if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab(); + }).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); }); +} + +// ---- attach / new course ------------------------------------------------ + +function addRoomToCourse(courseID) { + if (!currentRoomData || !currentRoomData.room) return; + var roomID = currentRoomData.room.id; + var dirSel = document.querySelector('#courseAttachDir'); + var dir = dirSel ? dirSel.value : ''; + fetch('/api/rooms/' + roomID + '/courses/attach', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ course_id: courseID, after_index: 999, direction: dir }), + credentials: 'same-origin' + }).then(function(r) { return r.json(); }).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify('Added room #' + roomID + ' to course ' + courseID, 'success'); + updateUndoBar(); + _coursePhases = null; + loadRoomCourseData(roomID); + }).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); }); +} + +function searchCourses(input) { + searchAttachShow(input, function(r) { addRoomToCourse(r.id); }); +} + +function searchAttachShow(input, onSelect) { + var val = input.value.trim(); + var results = input.parentElement.querySelector('.search-results'); + if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; } + API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) { + var items = data.courses || []; + if (items.length === 0) { + results.style.display = 'block'; + results.innerHTML = '
No results
'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; + }).join(''); + results._items = items; + results._onSelect = onSelect; + }).catch(function() { results.style.display = 'none'; }); +} + +function selectCourseResult(el) { + var results = el.parentElement; + var idx = Array.prototype.indexOf.call(results.children, el); + if (results._items && results._items[idx] && results._onSelect) { + results._onSelect(results._items[idx]); + results.style.display = 'none'; + results.innerHTML = ''; + var input = results.parentElement.querySelector('.search-input'); + if (input) input.value = ''; + } +} + +function courseShowNew() { + if (!currentRoomData || !currentRoomData.room) return; + var roomID = currentRoomData.room.id; + + var overlay = document.createElement('div'); + overlay.className = 'cm-overlay exit-overlay'; + var menu = document.createElement('div'); + menu.className = 'cm-menu exit-modal'; + menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201'; + + var h = '

New Course (room #' + roomID + ' = step 1)

'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + + menu.innerHTML = h; + document.body.appendChild(overlay); + document.body.appendChild(menu); + overlay.onclick = courseCloseModal; +} + +function courseCloseModal() { + var ov = document.querySelector('.exit-overlay'); + if (ov) ov.remove(); + var m = document.querySelector('.exit-modal'); + if (m) m.remove(); +} + +function courseCreateConfirm() { + if (!currentRoomData || !currentRoomData.room) return; + var roomID = currentRoomData.room.id; + var id = val('#newCourseID'); + if (!id) { notify('Course ID required', 'error'); return; } + var name = val('#newCourseName'); + var reqLevel = parseInt(val('#newCourseReqLevel')) || 0; + var startRoom = parseInt(val('#newCourseStartRoom')) || 0; + var completionXP = parseInt(val('#newCourseCompletionXP')) || 0; + var verb = sel('#newCourseVerb'); + var xp = parseInt(val('#newCourseXP')) || 0; + var failMin = parseInt(val('#newCourseFailMin')) || 0; + var failMax = parseInt(val('#newCourseFailMax')) || 0; + var fcStr = val('#newCourseFailChance'); + var fc = fcStr === '' ? null : parseFloat(fcStr); + if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; } + var msg = val('#newCourseMsg') || ''; + + var obstacle = { + room_id: roomID, + verb: verb, + xp: xp, + fail_damage: [failMin, failMax], + phases: [{ message: msg, delay: 0 }] + }; + if (fc !== null) obstacle.fail_chance = fc; + + var body = { + id: id, + name: name, + required_level: reqLevel, + start_room: startRoom, + completion_xp: completionXP, + obstacles: [obstacle] + }; + + API.post('/api/courses', body).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify('Course ' + id + ' created', 'success'); + updateUndoBar(); + courseCloseModal(); + _coursePhases = null; + loadRoomCourseData(roomID); + }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); +} + +// tiny helpers used by the course tab +function val(sel) { var el = document.querySelector(sel); return el ? el.value : ''; } +function sel(sel) { var el = document.querySelector(sel); return el ? el.value : ''; } + function searchHazards(input) { searchAndShow(input, 'hazards', function(r) { addHazard(r.id); }); } @@ -528,6 +1029,17 @@ function switchPanelTab(idx) { else if (idx === 4) html = renderMobsTab(mobs); else if (idx === 5) html = renderItemsTab(spawns); else if (idx === 6) html = renderHazardTab(r); + else if (idx === 7) { + _coursePhases = null; + if (currentRoomData.course === undefined) { + html = renderCourseTabLoading(); + loadRoomCourseData(r.id); + } else if (currentRoomData.course === null) { + html = renderCourseTabEmpty(); + } else { + html = renderCourseTab(); + } + } var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = html; var tabs = document.querySelectorAll('.panel-tab'); @@ -646,10 +1158,12 @@ function renderExitsSection(r) { var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : ''; var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null; var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null; - var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags); + var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false; + var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden); h += '
'; h += '' + d + ''; h += '# ' + target + ''; + h += ''; if (bmsg) h += 'Blocked: ' + esc(bmsg) + ''; if (cond) h += 'Conditional'; h += ''; @@ -679,6 +1193,7 @@ function editExitCondition(dir) { var bmsg = exit.blocked_message || ''; var setFlags = exit.set_flags || null; var setPlayerFlags = exit.set_player_flags || null; + var hidden = exit.hidden || false; var flagsStr = ''; if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', '); @@ -697,6 +1212,7 @@ function editExitCondition(dir) { h += '
'; h += '
'; h += '
'; + h += '
'; h += '
'; h += ''; @@ -834,6 +1350,8 @@ function saveExitCondition() { var bmsg = document.getElementById('exitBmsg'); exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : ''; + var hiddenEl = document.getElementById('exitHidden'); + exit.hidden = hiddenEl ? hiddenEl.checked : false; var flagsStr = document.getElementById('exitFlags'); var pflagsStr = document.getElementById('exitPFlags'); exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : ''); @@ -928,6 +1446,21 @@ function deleteExit(dir, target) { }).catch(function(e) { notify('Delete exit failed: ' + extractError(e), 'error'); }); } +function toggleExitHidden(dir) { + if (!currentRoomData || !currentRoomData.room) return; + var exits = currentRoomData.room.exits || {}; + var exit = exits[dir]; + if (!exit) return; + if (typeof exit !== 'object') { + exit = {room: exit}; + exits[dir] = exit; + currentRoomData.room.exits = exits; + } + exit.hidden = !exit.hidden; + autoSave(); + reRenderPanel(); +} + function extractError(e) { var msg = e.message || 'unknown error'; try { var j = JSON.parse(msg); if (j.error) msg = j.error; } catch(_) {} diff --git a/internal/admin/templates/courses.html b/internal/admin/templates/courses.html deleted file mode 100644 index 60e07f8..0000000 --- a/internal/admin/templates/courses.html +++ /dev/null @@ -1,14 +0,0 @@ -{{define "body-courses"}} -
-
- -
- -
-
-

Select a course to edit

-
-
- - -{{end}} diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index 410c8df..009e1f7 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -17,7 +17,6 @@ Drops Hazards Techs - Courses Modules Players Dashboard @@ -37,7 +36,6 @@ {{else if eq .Page "drops"}}{{template "body-drops" .}} {{else if eq .Page "hazards"}}{{template "body-hazards" .}} {{else if eq .Page "techs"}}{{template "body-techs" .}} -{{else if eq .Page "courses"}}{{template "body-courses" .}} {{else if eq .Page "modules"}}{{template "body-modules" .}} {{else if eq .Page "players"}}{{template "body-players" .}} {{else if eq .Page "dashboard"}}{{template "body-dashboard" .}} -- cgit v1.2.3