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 - internal/behavior/types.go | 14 +- internal/config/config.go | 1 + internal/game/act_agility.go | 66 ++-- internal/game/cmd_aps.go | 6 +- internal/game/cmd_color.go | 1 + internal/game/cmd_move.go | 5 + internal/game/cmd_walk.go | 7 +- internal/game/core_course.go | 78 +++- internal/game/core_course_test.go | 140 +++++++ internal/game/look_room.go | 56 +-- internal/game/render_map.go | 47 ++- internal/world/room.go | 1 + 22 files changed, 1622 insertions(+), 242 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 create mode 100644 internal/game/core_course_test.go (limited to 'internal') 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" .}} diff --git a/internal/behavior/types.go b/internal/behavior/types.go index 99f09a0..03876e5 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -145,14 +145,22 @@ type ObstacleData struct { StartRoom int ObstacleXP int CompletionXP int - FailChance float64 + // FailChance is the resolved failure probability for this attempt. + // nil => derived at runtime from agility level vs RequiredLevel. + FailChance *float64 FailDamageMin int FailDamageMax int - Messages []any - TicksPerPhase float64 + Phases []PhaseData RequiredLevel int } +// PhaseData is the runtime form of an obstacle phase. +type PhaseData struct { + Message string + Delay float64 + FailCheck bool +} + type FarmData struct { Prefix string SeedID string diff --git a/internal/config/config.go b/internal/config/config.go index 728c8a3..d67d7f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,7 @@ func DefaultColors() ColorsConfig { "farm_disease": "C4", "map_at": "0F", "map_blocked": "C4", + "map_course": "1B", "safespot_alert": "C4 bold", } } diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index d66b9c5..18dbf4a 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -12,15 +12,24 @@ import ( func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { failChance := g.calcFailChance(p, info) - - msgs := make([]any, len(info.Messages)) - for i, m := range info.Messages { - msgs[i] = m + var failPtr *float64 + if failChance != nil { + v := *failChance + failPtr = &v } gerund := info.Verb + "ing" - if g, ok := verbGerund[info.Verb]; ok { - gerund = g + if gr, ok := verbGerund[info.Verb]; ok { + gerund = gr + } + + phases := make([]behavior.PhaseData, len(info.Phases)) + for i, ph := range info.Phases { + phases[i] = behavior.PhaseData{ + Message: ph.Message, + Delay: ph.Delay, + FailCheck: ph.FailCheck, + } } p.Action = &behavior.Action{ @@ -37,11 +46,10 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle StartRoom: info.StartRoom, ObstacleXP: info.ObstacleXP, CompletionXP: info.CompletionXP, - FailChance: failChance, + FailChance: failPtr, FailDamageMin: info.FailDamage[0], FailDamageMax: info.FailDamage[1], - Messages: msgs, - TicksPerPhase: info.TicksPerPhase, + Phases: phases, RequiredLevel: info.RequiredLevel, }, } @@ -52,19 +60,21 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { d := p.Action.Data.(*behavior.ObstacleData) phase := d.Phase - messages := d.Messages - ticksPerPhase := d.TicksPerPhase + phases := d.Phases - if phase >= len(messages) { + if phase >= len(phases) { g.cancelAction(p) return } - msg := messages[phase].(string) - sess.WriteLine(g.colorize(sess, "broadcast", msg)) + ph := phases[phase] + sess.WriteLine(g.colorize(sess, "broadcast", ph.Message)) - if phase == 1 { - failChance := d.FailChance + if ph.FailCheck { + failChance := 0.30 + if d.FailChance != nil { + failChance = *d.FailChance + } if rand.Float64() < failChance { g.obstacleFail(sess, p, d) return @@ -73,7 +83,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { nextPhase := phase + 1 - if nextPhase >= len(messages) { + if nextPhase >= len(phases) { obstacleXP := d.ObstacleXP completionXP := d.CompletionXP nextRoom := d.NextRoom @@ -121,7 +131,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { } d.Phase = nextPhase - p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) + p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay) } func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { @@ -148,7 +158,21 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs g.teleportPlayer(sess, p, startRoom) } -func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { +// calcFailChance returns the failure probability for an obstacle attempt. +// If the obstacle defines an explicit fail_chance it is used; otherwise the +// chance is derived from the player's Agility level relative to the course's +// required level (70% success at required, scaling to 95% above, capped). +func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) *float64 { + if info.FailChance != nil { + v := *info.FailChance + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + return &v + } level := p.Level(player.Agility) required := info.RequiredLevel chance := 0.30 - float64(level-required)*0.01 @@ -158,5 +182,5 @@ func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { if chance > 0.60 { chance = 0.60 } - return chance -} + return &chance +} \ No newline at end of file diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go index a5cff78..f48c2bb 100644 --- a/internal/game/cmd_aps.go +++ b/internal/game/cmd_aps.go @@ -41,7 +41,7 @@ func (g *Game) executeAps(sess *net.Session, args []string, rawInput string) { return } - path := g.findPathToRoom(p.RoomID, targetID) + path := g.findPathToRoom(p.RoomID, targetID, p.GodMode) if path == nil { room, err := g.World.LoadRoom(targetID) roomName := fmt.Sprintf("#%d", targetID) @@ -112,7 +112,7 @@ func displayApsNodes(g *Game, sess *net.Session, p *player.Player, known []int) } dist := 0 if id != p.RoomID { - path := g.findPathToRoom(p.RoomID, id) + path := g.findPathToRoom(p.RoomID, id, p.GodMode) if path != nil { dist = len(path) } @@ -163,7 +163,7 @@ func resolveApsTarget(g *Game, sess *net.Session, p *player.Player, known []int, if id == p.RoomID { return id } - path := g.findPathToRoom(p.RoomID, id) + path := g.findPathToRoom(p.RoomID, id, p.GodMode) if path == nil { continue } diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index a2ec2c4..d48e314 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -167,6 +167,7 @@ var colorCategoryOrder = []string{ "map_at", "map_blocked", + "map_course", "safespot_alert", } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index f12c494..6063160 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -31,6 +31,11 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { return } + if exitDef.Hidden && !p.GodMode { + sess.WriteLine("You can't go that way.") + return + } + if !p.GodMode && exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { msg := exitDef.BlockedMessage if msg == "" { diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index ec62eec..9bc7183 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -23,7 +23,7 @@ func (g *Game) doWalk(sess *net.Session, args []string) { input := strings.Join(args, "") if roomID, err := strconv.Atoi(input); err == nil { - path := g.findPathToRoom(p.RoomID, roomID) + path := g.findPathToRoom(p.RoomID, roomID, p.GodMode) if path == nil { sess.WriteLine(fmt.Sprintf("No path found to room #%d.", roomID)) return @@ -188,7 +188,7 @@ var walkSearchDirs = []world.ExitDir{ world.Up, world.Down, } -func (g *Game) findPathToRoom(fromRoom, toRoom int) []string { +func (g *Game) findPathToRoom(fromRoom, toRoom int, godMode bool) []string { if fromRoom == toRoom { return nil } @@ -216,6 +216,9 @@ func (g *Game) findPathToRoom(fromRoom, toRoom int) []string { if !exists { continue } + if exitDef.Hidden && !godMode { + continue + } if visited[exitDef.Room] { continue } diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 5d5c7c5..54894fd 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -8,13 +8,29 @@ import ( "thehouseoficarus/internal/behavior" ) +// ObstaclePhase is a single phase of an obstacle's advancement sequence. +// Each phase prints a message after waiting Delay ticks, then optionally +// performs the failure check. +type ObstaclePhase struct { + Message string `yaml:"message"` + Delay float64 `yaml:"delay"` + FailCheck bool `yaml:"fail_check,omitempty"` +} + +// ObstacleDef is the YAML representation of a single agility obstacle. +// Two equivalent forms are supported: +// - Legacy: Messages (exactly 3) + TicksPerPhase + fail at phase index 1. +// - Preferred: Phases, an explicit ordered list with per-phase delays and +// an optional fail_check flag (at most one phase should carry it). type ObstacleDef struct { - RoomID int `yaml:"room_id"` - Verb string `yaml:"verb"` - TicksPerPhase float64 `yaml:"ticks_per_phase"` - XP int `yaml:"xp"` - FailDamage [2]int `yaml:"fail_damage"` - Messages []string `yaml:"messages"` + RoomID int `yaml:"room_id"` + Verb string `yaml:"verb"` + XP int `yaml:"xp"` + FailDamage [2]int `yaml:"fail_damage"` + FailChance *float64 `yaml:"fail_chance,omitempty"` + TicksPerPhase float64 `yaml:"ticks_per_phase"` + Messages []string `yaml:"messages"` + Phases []ObstaclePhase `yaml:"phases"` } type CourseConfig struct { @@ -26,16 +42,23 @@ type CourseConfig struct { Obstacles []ObstacleDef `yaml:"obstacles"` } +// PhaseInfo is the runtime-resolved form of an ObstaclePhase. +type PhaseInfo struct { + Message string + Delay float64 + FailCheck bool +} + type ObstacleInfo struct { CourseID string CourseName string ObstacleIndex int TotalObstacles int Verb string - Messages []string - TicksPerPhase float64 + Phases []PhaseInfo ObstacleXP int CompletionXP int + FailChance *float64 // nil => derived from agility level FailDamage [2]int NextRoom int StartRoom int @@ -104,6 +127,39 @@ func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { return cs.roomToObstacle[roomID] } +// resolvePhases converts an ObstacleDef into a normalized PhaseInfo list. +// Preferred form: explicit Phases. Legacy form: Messages with a shared +// TicksPerPhase and the failure check at the middle (index 1) phase. +func resolvePhases(obs ObstacleDef) []PhaseInfo { + if len(obs.Phases) > 0 { + phases := make([]PhaseInfo, 0, len(obs.Phases)) + for _, p := range obs.Phases { + phases = append(phases, PhaseInfo{ + Message: p.Message, + Delay: p.Delay, + FailCheck: p.FailCheck, + }) + } + return phases + } + msgs := obs.Messages + phases := make([]PhaseInfo, 0, len(msgs)) + for i, m := range msgs { + var delay float64 + if i == 0 { + delay = 0 + } else { + delay = obs.TicksPerPhase + } + phases = append(phases, PhaseInfo{ + Message: m, + Delay: delay, + FailCheck: i == 1, + }) + } + return phases +} + func (cs *CourseStore) loadAllLocked() { cs.loaded = true cs.roomToObstacle = make(map[int]*ObstacleInfo) @@ -135,10 +191,10 @@ func (cs *CourseStore) loadAllLocked() { ObstacleIndex: i, TotalObstacles: totalObstacles, Verb: obs.Verb, - Messages: obs.Messages, - TicksPerPhase: obs.TicksPerPhase, + Phases: resolvePhases(obs), ObstacleXP: obs.XP, CompletionXP: completionXP, + FailChance: obs.FailChance, FailDamage: obs.FailDamage, NextRoom: nextRoom, StartRoom: cfg.StartRoom, @@ -151,4 +207,4 @@ func (cs *CourseStore) loadAllLocked() { }) obstacleVerbs = localVerbs -} +} \ No newline at end of file diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go new file mode 100644 index 0000000..3d9fdc2 --- /dev/null +++ b/internal/game/core_course_test.go @@ -0,0 +1,140 @@ +package game + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolvePhasesPrefersExplicitPhases(t *testing.T) { + obs := ObstacleDef{ + RoomID: 5, + Verb: "climb", + Phases: []ObstaclePhase{ + {Message: "start", Delay: 0}, + {Message: "middle", Delay: 2, FailCheck: true}, + {Message: "end", Delay: 1.5}, + }, + // Legacy fields set as a decoy; should be ignored when Phases present. + TicksPerPhase: 99, + Messages: []string{"ignored1", "ignored2", "ignored3"}, + } + got := resolvePhases(obs) + if len(got) != 3 { + t.Fatalf("expected 3 phases, got %d", len(got)) + } + if got[0].Message != "start" || got[0].Delay != 0 || got[0].FailCheck { + t.Errorf("phase 0 wrong: %+v", got[0]) + } + if got[1].Message != "middle" || got[1].Delay != 2 || !got[1].FailCheck { + t.Errorf("phase 1 (fail check) wrong: %+v", got[1]) + } + if got[2].Message != "end" || got[2].Delay != 1.5 { + t.Errorf("phase 2 wrong: %+v", got[2]) + } +} + +func TestResolvePhasesLegacyMigration(t *testing.T) { + obs := ObstacleDef{ + RoomID: 7, + Verb: "jump", + TicksPerPhase: 2, + Messages: []string{"a", "b", "c"}, + } + got := resolvePhases(obs) + if len(got) != 3 { + t.Fatalf("expected 3 legacy phases, got %d", len(got)) + } + if got[0].Message != "a" || got[0].Delay != 0 || got[0].FailCheck { + t.Errorf("legacy phase 0 wrong: %+v", got[0]) + } + if got[1].Message != "b" || got[1].Delay != 2 || !got[1].FailCheck { + t.Errorf("legacy phase 1 must carry fail_check at index 1: %+v", got[1]) + } + if got[2].Message != "c" || got[2].Delay != 2 || got[2].FailCheck { + t.Errorf("legacy phase 2 wrong: %+v", got[2]) + } +} + +func TestCourseStoreLoadsWithExplicitFailChance(t *testing.T) { + dir := t.TempDir() + coursesDir := filepath.Join(dir, "courses") + if err := os.MkdirAll(coursesDir, 0755); err != nil { + t.Fatal(err) + } + yaml := []byte(` +name: "Test Course" +required_level: 10 +start_room: 100 +completion_xp: 50 +obstacles: + - room_id: 101 + verb: climb + xp: 12 + fail_damage: [2, 5] + fail_chance: 0.20 + phases: + - message: "begin" + delay: 0 + - message: "mid" + delay: 2 + fail_check: true + - message: "done" + delay: 2 + - room_id: 102 + verb: jump + xp: 14 + fail_damage: [1, 3] + messages: ["p0", "p1", "p2"] + ticks_per_phase: 1 +`) + if err := os.WriteFile(filepath.Join(coursesDir, "testcourse.yaml"), yaml, 0644); err != nil { + t.Fatal(err) + } + + cs := NewCourseStore(dir) + cs.LoadAll() + + // Obstacle 1: explicit phases + explicit fail_chance + o1 := cs.GetObstacle(101) + if o1 == nil { + t.Fatal("expected obstacle for room 101") + } + if len(o1.Phases) != 3 || o1.Phases[1].FailCheck != true { + t.Errorf("obstacle 1 phases wrong: %+v", o1.Phases) + } + if o1.FailChance == nil || *o1.FailChance != 0.20 { + t.Errorf("expected explicit fail_chance 0.20, got %v", o1.FailChance) + } + if o1.ObstacleIndex != 0 || o1.TotalObstacles != 2 { + t.Errorf("index/total wrong: %d/%d", o1.ObstacleIndex, o1.TotalObstacles) + } + if o1.NextRoom != 102 { + t.Errorf("expected next room 102, got %d", o1.NextRoom) + } + if o1.CompletionXP != 0 { + t.Errorf("first obstacle should have no completion xp, got %d", o1.CompletionXP) + } + + // Obstacle 2: legacy migration, derived fail chance (nil) + o2 := cs.GetObstacle(102) + if o2 == nil { + t.Fatal("expected obstacle for room 102") + } + if len(o2.Phases) != 3 || !o2.Phases[1].FailCheck { + t.Errorf("legacy obstacle phases not migrated: %+v", o2.Phases) + } + if o2.FailChance != nil { + t.Errorf("legacy obstacle should have nil (derived) fail_chance, got %v", *o2.FailChance) + } + if o2.CompletionXP != 50 { + t.Errorf("last obstacle should carry completion_xp, got %d", o2.CompletionXP) + } + if o2.NextRoom != 0 { + t.Errorf("last obstacle should have next_room 0, got %d", o2.NextRoom) + } + + if cs.GetObstacle(999) != nil { + t.Error("expected nil for unrelated room") + } +} \ No newline at end of file diff --git a/internal/game/look_room.go b/internal/game/look_room.go index f35d564..887bb1d 100644 --- a/internal/game/look_room.go +++ b/internal/game/look_room.go @@ -83,24 +83,30 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro } var lines []exitLine maxDirLen := 0 - for _, dir := range world.ExitOrder { - exitDef, ok := room.Exits[dir] - if !ok { - continue - } - coloredDir := g.colorize(sess, "exit_direction", string(dir)) - targetRoom, err := g.World.LoadRoom(exitDef.Room) - targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) - if err == nil { - targetName = g.colorize(sess, "exit_name", targetRoom.Name) - } - if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { - targetName += " (blocked)" - } - lines = append(lines, exitLine{coloredDir, targetName}) - if visibleLen(coloredDir) > maxDirLen { - maxDirLen = visibleLen(coloredDir) - } + for _, dir := range world.ExitOrder { + exitDef, ok := room.Exits[dir] + if !ok { + continue + } + if exitDef.Hidden && !p.GodMode { + continue + } + coloredDir := g.colorize(sess, "exit_direction", string(dir)) + targetRoom, err := g.World.LoadRoom(exitDef.Room) + targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) + if err == nil { + targetName = g.colorize(sess, "exit_name", targetRoom.Name) + } + if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { + targetName += " (blocked)" + } + if exitDef.Hidden { + targetName += " (hidden)" + } + lines = append(lines, exitLine{coloredDir, targetName}) + if visibleLen(coloredDir) > maxDirLen { + maxDirLen = visibleLen(coloredDir) + } } for _, l := range lines { pad := maxDirLen + (len(l.dir) - visibleLen(l.dir)) @@ -110,13 +116,15 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro sess.Write("Exits: ") first := true for _, dir := range world.ExitOrder { - if _, ok := room.Exits[dir]; ok { - if !first { - sess.Write(", ") - } - sess.Write(g.colorize(sess, "exit_direction", string(dir))) - first = false + exitDef, ok := room.Exits[dir] + if !ok || (exitDef.Hidden && !p.GodMode) { + continue + } + if !first { + sess.Write(", ") } + sess.Write(g.colorize(sess, "exit_direction", string(dir))) + first = false } sess.WriteLine("") } diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 179277a..1bc9adc 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -95,7 +95,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string dimSpec := resolveDim(g, sess) ctx := &mapRenderCtx{ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, - atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), courseSpec: resolveMapCourse(g, sess), mg: mg, diagPairs: make(map[[2]int][2]int), } @@ -268,7 +268,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m dimSpec := resolveDim(g, sess) ctx := &mapRenderCtx{ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, - atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), courseSpec: resolveMapCourse(g, sess), mg: mg, diagPairs: make(map[[2]int][2]int), } @@ -433,6 +433,13 @@ func resolveMapBlocked(g *Game, sess *net.Session) color.ColorSpec { return color.Parse("C4") } +func resolveMapCourse(g *Game, sess *net.Session) color.ColorSpec { + if sess != nil { + return g.resolveColor(sess, "map_course") + } + return color.Parse("1B") +} + // mapRenderCtx bundles the per-render state shared by node and connector drawing // so the tiny and full maps build cells the same way. type mapRenderCtx struct { @@ -444,6 +451,7 @@ type mapRenderCtx struct { atSpec color.ColorSpec dimSpec color.ColorSpec blockedSpec color.ColorSpec + courseSpec color.ColorSpec mg mapGlyphs diagPairs map[[2]int][2]int } @@ -497,14 +505,44 @@ func (c *mapRenderCtx) connectorCell(roomA, roomB int, dirAB, dirBA world.ExitDi switch { case out == exitOpen: + if c.isCourseLink(roomA, roomB) { + return c.courseColoredCell(roomA, roomB, arrowGlyph(c.mg, outDir)) + } return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, outDir)) case out == exitAbsent && in == exitOpen: + if c.isCourseLink(roomA, roomB) { + return c.courseColoredCell(roomA, roomB, arrowGlyph(c.mg, inDir)) + } return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, inDir)) default: return mapCell{char: 'X', spec: c.blockedSpec}, true } } +// isCourseLink reports whether roomA and roomB are consecutive obstacles in +// the same agility course (one's NextRoom equals the other's room id). +func (c *mapRenderCtx) isCourseLink(roomA, roomB int) bool { + if c.g == nil || c.g.CourseStore == nil { + return false + } + if info := c.g.CourseStore.GetObstacle(roomA); info != nil && info.NextRoom == roomB { + return true + } + if info := c.g.CourseStore.GetObstacle(roomB); info != nil && info.NextRoom == roomA { + return true + } + return false +} + +// courseColoredCell draws a one-way course arrow using the map_course color +// (dimmed if either endpoint is unvisited). +func (c *mapRenderCtx) courseColoredCell(roomA, roomB int, glyph rune) (mapCell, bool) { + if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { + return mapCell{char: glyph, spec: c.dimSpec}, true + } + return mapCell{char: glyph, spec: c.courseSpec}, true +} + // coloredCell applies the shared link coloring logic for bar/arrow glyphs // (dim if either endpoint is unvisited, otherwise the gradient average). func (c *mapRenderCtx) coloredCell(roomA, roomB int, glyph rune) (mapCell, bool) { @@ -564,6 +602,11 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb if !ok || exit.Room != neighbor { return exitAbsent } + // Hidden exits are non-traversable to players but are followed by the map + // layout BFS, so they always render as traversable (open) connectors. + if exit.Hidden { + return exitOpen + } if exit.Condition == nil || sess == nil || sess.Player == nil { return exitOpen } diff --git a/internal/world/room.go b/internal/world/room.go index 9aa93f0..8843b9e 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -80,6 +80,7 @@ type ExitDef struct { BlockedMessage string `yaml:"blocked_message,omitempty"` SetFlags map[string]any `yaml:"set_flags,omitempty"` SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` } func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error { -- cgit v1.2.3