diff options
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/api_courses.go | 138 | ||||
| -rw-r--r-- | internal/admin/api_map.go | 36 | ||||
| -rw-r--r-- | internal/admin/api_room_courses.go | 662 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 14 | ||||
| -rw-r--r-- | internal/admin/api_search.go | 21 | ||||
| -rw-r--r-- | internal/admin/server.go | 6 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 14 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 535 | ||||
| -rw-r--r-- | internal/admin/templates/courses.html | 14 | ||||
| -rw-r--r-- | internal/admin/templates/layout.html | 2 |
10 files changed, 1266 insertions, 176 deletions
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 += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#1B6FC0" stroke-width="5" opacity="0.75" stroke-dasharray="2,2" vector-effect="non-scaling-stroke"/>'; + return; + } var c = blendColors(fr.color, tr.color).replace('#',''); g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.55"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>'; }); @@ -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 += '<button class="panel-tab' + (currentPanelTab === 4 ? ' active' : '') + '" onclick="switchPanelTab(4)">Mobs</button>'; html += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>'; html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>'; + html += '<button class="panel-tab' + (currentPanelTab === 7 ? ' active' : '') + '" onclick="switchPanelTab(7)">Course</button>'; html += '</div>'; html += '<div class="panel-tab-content">'; @@ -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 += '</div>'; 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 '<p style="color:#888;text-align:center;margin-top:30px">Loading course...</p>'; +} + +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 += '<div class="course-header">'; + h += '<h3 style="margin:0 0 4px 0">Step ' + (idx + 1) + ', Room #' + roomID + '</h3>'; + h += '<div style="font-size:11px;color:#aaa;margin-bottom:6px">(This Obstacle) — ' + esc(cd.course_name || cd.course_id || '') + '</div>'; + // Prev/Next buttons row + h += '<div style="display:flex;gap:6px;margin-bottom:6px">'; + if (prevRoom) { + h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">← Step ' + idx + ' (#' + prevRoom + ')</button>'; + } + if (nextRoom) { + h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') →</button>'; + } + if (!prevRoom && !nextRoom) { + h += '<span style="font-size:11px;color:#888">Single-step course</span>'; + } + h += '</div>'; + h += '</div>'; + + // Obstacle details + h += '<div class="course-obstacle-section">'; + h += '<div class="form-group"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">'; + OBSTACLE_VERBS.forEach(function(v) { + h += '<option value="' + v + '"' + (obstacle.verb === v ? ' selected' : '') + '>' + v + '</option>'; + }); + h += '</select></div>'; + h += '<div class="form-group"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">'; + var fd = obstacle.fail_damage || [0,0]; + var fdMin = Array.isArray(fd) ? fd[0] : 0; + var fdMax = Array.isArray(fd) ? fd[1] : 0; + h += '<input id="courseObstacleFailMin" type="number" value="' + fdMin + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">'; + h += '<input id="courseObstacleFailMax" type="number" value="' + fdMax + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">'; + h += '</div></div>'; + h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label>'; + var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : ''; + h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">'; + h += '<div style="font-size:10px;color:#888;margin-top:2px">Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.</div>'; + h += '</div>'; + h += '</div>'; + + // Phases section + h += '<div class="course-phases-section">'; + h += '<div class="section-divider">Phases (messages)</div>'; + h += '<div id="coursePhases"></div>'; + h += '<button class="btn btn-sm section-add-btn" id="addPhaseBtn" onclick="courseAddPhase()">+ Add Message</button>'; + h += '</div>'; + + h += '<div class="btn-row" style="margin-top:10px">'; + h += '<button class="btn btn-danger" onclick="courseDetach()">Remove from course</button>'; + h += '</div>'; + + // Collapsible course details (starts collapsed) + h += '<div class="course-details-card" style="margin-top:12px">'; + h += '<div class="obj-card-header" onclick="courseToggleDetails()">'; + h += '<span class="obj-card-arrow">' + (_courseDetailsCollapsed ? '▶' : '▼') + '</span>'; + h += '<span>Course Details</span>'; + h += '</div>'; + if (!_courseDetailsCollapsed) { + h += '<div class="obj-card-body">'; + h += '<div class="form-group"><label>Course Name</label><input id="courseName" value="' + escAttr(course.name || '') + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Start Room (hub / fail return)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Completion XP (lap bonus)</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '</div>'; + } + h += '</div>'; + + 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 = '<p style="color:#888;font-size:11px;margin:6px 0 12px">This room is not part of any course.</p>'; + h += '<div class="form-group"><label>Direction for new step exit</label>'; + h += '<select id="courseAttachDir"><option value="">auto</option>'; + HORIZONTAL_DIRS.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; }); + h += '</select></div>'; + h += '<div class="section-search">'; + h += '<input type="text" class="search-input" placeholder="Add to course..." oninput="searchCourses(this)">'; + h += '<div class="search-results" style="display:none"></div>'; + h += '</div>'; + h += '<button class="btn btn-sm section-add-btn" style="margin-top:8px" onclick="courseShowNew()">+ New Course</button>'; + 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 += '<div class="entry-row course-phase" data-idx="' + i + '">'; + h += '<div class="entry-fields">'; + h += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>'; + h += '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">'; + h += '<label class="mini-label">Delay</label><input class="mini-input phase-delay" type="text" inputmode="numeric" value="' + (p.delay || 0) + '" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')">'; + h += '<label class="mini-label" style="display:flex;align-items:center;gap:3px"><input type="radio" class="phase-fail" name="phaseFail" onchange="courseSetFailCheck(' + i + ')"' + (p.fail_check ? ' checked' : '') + '> Fail check here</label>'; + h += '</div>'; + h += '</div>'; + h += '<button class="btn btn-sm btn-danger" onclick="courseRemovePhase(' + i + ')">X</button>'; + h += '</div>'; + }); + 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 = '<div class="sr-item" style="color:#888">No results</div>'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '<div class="sr-item" onclick="event.stopPropagation(); selectCourseResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</div>'; + }).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 = '<h3 style="margin-bottom:8px;font-size:13px">New Course (room #' + roomID + ' = step 1)</h3>'; + h += '<div class="form-group"><label>Course ID (filename)</label><input id="newCourseID" placeholder="e.g. vent_shaft" ></div>'; + h += '<div class="form-group"><label>Course Name</label><input id="newCourseName" placeholder="e.g. Ventilation Shaft Course"></div>'; + h += '<div class="form-group"><label>Required Level</label><input id="newCourseReqLevel" type="number" value="1"></div>'; + h += '<div class="form-group"><label>Start Room</label><input id="newCourseStartRoom" type="number" value="' + roomID + '"></div>'; + h += '<div class="form-group"><label>Completion XP</label><input id="newCourseCompletionXP" type="number" value="0"></div>'; + h += '<div class="form-group"><label>Verb</label><select id="newCourseVerb">'; + OBSTACLE_VERBS.forEach(function(v) { + h += '<option value="' + v + '"' + (v === 'climb' ? ' selected' : '') + '>' + v + '</option>'; + }); + h += '</select></div>'; + h += '<div class="form-group"><label>XP</label><input id="newCourseXP" type="number" value="0"></div>'; + h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">'; + h += '<input id="newCourseFailMin" type="number" value="0" style="flex:1">'; + h += '<input id="newCourseFailMax" type="number" value="0" style="flex:1">'; + h += '</div></div>'; + h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label><input id="newCourseFailChance" type="text" placeholder="derived"></div>'; + h += '<div class="form-group"><label>First message</label><textarea id="newCourseMsg" rows="2" placeholder="You approach the obstacle..."></textarea></div>'; + h += '<div class="btn-row" style="margin-top:10px">'; + h += '<button class="btn btn-primary" onclick="courseCreateConfirm()">Create</button>'; + h += '<button class="btn" onclick="courseCloseModal()">Cancel</button>'; + h += '</div>'; + + 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 += '<div class="exit-row" data-dir="' + d + '">'; h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>'; h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>'; + h += '<label style="font-size:11px;margin-left:4px;cursor:pointer;color:#aaa"><input type="checkbox" class="exit-hidden" style="vertical-align:middle"' + (hidden ? ' checked' : '') + ' onchange="toggleExitHidden(\'' + d + '\')"> Hidden</label>'; if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>'; if (cond) h += '<span class="exit-meta">Conditional</span>'; h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>'; @@ -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 += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>'; h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>'; h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>'; + h += '<div class="form-group"><label><input type="checkbox" id="exitHidden"' + (hidden ? ' checked' : '') + '> Hidden</label></div>'; h += '<div style="margin-top:10px;border-top:1px solid var(--border);padding-top:8px">'; h += '<label style="font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px">Conditions</label>'; @@ -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"}} -<div class="editor-container"> - <div class="editor-list" id="itemList"> - <div class="search-bar"><input placeholder="Search courses..." oninput="filterList(this.value)"></div> - <div id="listEntries"></div> - <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Course</button> - </div> - <div class="editor-main" id="editorMain"> - <p style="color:#888;text-align:center;margin-top:60px">Select a course to edit</p> - </div> -</div> -<script src="/static/editor.js"></script> -<script>initEditor('courses', [{key:'name',label:'Name',type:'text'},{key:'skill',label:'Skill',type:'text'},{key:'level',label:'Level',type:'number'},{key:'cost',label:'Cost',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script> -{{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 @@ <a href="/editor/drops" class="{{if eq .Page "drops"}}active{{end}}">Drops</a> <a href="/editor/hazards" class="{{if eq .Page "hazards"}}active{{end}}">Hazards</a> <a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a> - <a href="/editor/courses" class="{{if eq .Page "courses"}}active{{end}}">Courses</a> <a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a> <a href="/editor/players" class="{{if eq .Page "players"}}active{{end}}">Players</a> <a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a> @@ -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" .}} |
