aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-05 22:35:21 -0400
committerhistoria <[not public]>2026-07-05 22:35:21 -0400
commit94823b52168b44894fb5e9c960358ed02ebb122b (patch)
tree3060799b89bb7edfa708c72bdd981107d40f4d09
parent843fa6db681e494bf46e191655323a40577542b5 (diff)
downloadthehouseoficarus-94823b52168b44894fb5e9c960358ed02ebb122b.tar.gz
feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure
-rw-r--r--building_guide/courses.md75
-rw-r--r--cmd/thoi/main.go4
-rw-r--r--data/.admin_history.json1
-rw-r--r--data/rooms/example_unused/agility2/99999200.yaml26
-rw-r--r--data/rooms/example_unused/agility2/99999202.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999206.yaml4
-rw-r--r--internal/admin/api_courses.go138
-rw-r--r--internal/admin/api_map.go36
-rw-r--r--internal/admin/api_room_courses.go662
-rw-r--r--internal/admin/api_rooms.go14
-rw-r--r--internal/admin/api_search.go21
-rw-r--r--internal/admin/server.go6
-rw-r--r--internal/admin/static/admin.css14
-rw-r--r--internal/admin/static/map.js535
-rw-r--r--internal/admin/templates/courses.html14
-rw-r--r--internal/admin/templates/layout.html2
-rw-r--r--internal/behavior/types.go14
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/game/act_agility.go66
-rw-r--r--internal/game/cmd_aps.go6
-rw-r--r--internal/game/cmd_color.go1
-rw-r--r--internal/game/cmd_move.go5
-rw-r--r--internal/game/cmd_walk.go7
-rw-r--r--internal/game/core_course.go78
-rw-r--r--internal/game/core_course_test.go140
-rw-r--r--internal/game/look_room.go56
-rw-r--r--internal/game/render_map.go47
-rw-r--r--internal/world/room.go1
28 files changed, 1715 insertions, 291 deletions
diff --git a/building_guide/courses.md b/building_guide/courses.md
index e11f909..e5deb32 100644
--- a/building_guide/courses.md
+++ b/building_guide/courses.md
@@ -4,6 +4,10 @@ Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a s
**The filename is the ID** (`vent_shaft.yaml` → `vent_shaft`); the loader derives it from the filename. Do not put an `id:` field in the file — it is ignored.
+> Editing courses is done from the **Map screen** in the admin web GUI: select an obstacle room and use its **Course** side-panel tab to create a course, add the room as a step, edit all of its parameters, or remove it. The legacy standalone "Courses" editor tab has been removed.
+>
+> When a room is added as a course step, the admin GUI auto-creates **hidden one-way exits** between consecutive obstacle rooms so the in-game map can lay them out correctly. The direction of these hidden exits is chosen by the builder. Hidden exits are invisible in `look` output and cannot be traversed — they exist solely for map rendering. On the map, course links appear as blue dashed one-way arrows (colored via the `map_course` theme token).
+
### Course YAML
```yaml
@@ -14,29 +18,27 @@ completion_xp: 40
obstacles:
- room_id: 201
verb: scramble
- ticks_per_phase: 2
xp: 8
fail_damage: [1, 2]
- messages:
- - "You approach the corroded ventilation wall..."
- - "You find footholds in the rusted panels and begin to climb..."
- - "You scramble up the wall and haul yourself onto the ledge!"
+ fail_chance: 0.25 # optional; omit to derive from agility level
+ phases:
+ - message: "You approach the corroded ventilation wall..."
+ delay: 0
+ - message: "You find footholds in the rusted panels and begin to climb..."
+ delay: 2
+ fail_check: true # the failure roll happens on this phase
+ - message: "You scramble up the wall and haul yourself onto the ledge!"
+ delay: 2
- room_id: 202
verb: balance
- ticks_per_phase: 2
- xp: 8
- fail_damage: [1, 2]
- messages:
- - "You step onto the narrow coolant pipe..."
- - "Arms outstretched, you carefully place one foot in front of the other..."
- - "You reach the other side of the pipe and step onto solid ground!"
+ ...
```
### CourseConfig Fields
| Field | Type | Description |
| ---------------- | ------------- | ---------------------------------------------------- |
-| `id` | string | Unique course identifier |
+| `id` | string | Unique course identifier (filename stem) |
| `name` | string | Display name shown to players |
| `required_level` | int | Minimum Agility level to attempt the course |
| `start_room` | int | Hub room — teleported here on fail or lap completion |
@@ -45,31 +47,36 @@ obstacles:
### ObstacleDef Fields
-| Field | Type | Description |
-| ----------------- | -------- | --------------------------------------------------------------------------- |
-| `room_id` | int | Room ID for this obstacle |
-| `verb` | string | Command the player types to attempt it (e.g. `scramble`, `jump`, `climb`) |
-| `ticks_per_phase` | float64 | Ticks between each phase message (supports fractional via `engine.ToTicks`) |
-| `xp` | int | XP awarded for successfully completing this single obstacle |
-| `fail_damage` | [2]int | `[min, max]` damage on failure (HP clamped to minimum 1 — cannot kill) |
-| `messages` | []string | Exactly 3 strings for the 3-phase advancement system |
+| Field | Type | Description |
+| -------------- | ------------- | ---------------------------------------------------------------------------- |
+| `room_id` | int | Room ID for this obstacle |
+| `verb` | string | Command the player types to attempt it (e.g. `scramble`, `jump`, `climb`) |
+| `xp` | int | XP awarded for successfully completing this single obstacle |
+| `fail_damage` | [2]int | `[min, max]` damage on failure (HP clamped to minimum 1 — cannot kill) |
+| `fail_chance` | float64 | Optional explicit failure probability (0–1). Omit to derive dynamically. |
+| `phases` | []ObstaclePhase | Preferred form: ordered list of phases (see below) |
+| `ticks_per_phase` | float64 | **Legacy form only:** shared delay between phases (used when `phases` absent)|
+| `messages` | []string | **Legacy form only:** exactly 3 messages (used when `phases` absent) |
-### 3-Phase Message System
+**Two forms are supported.** If `phases` is provided it is used directly. Otherwise the legacy `messages` + `ticks_per_phase` form is auto-migrated: 3 phases with delays `[0, ticks_per_phase, ticks_per_phase]` and the failure check on the middle (index 1) phase. The admin GUI always writes the `phases` form when saving.
-Each obstacle advances through 3 phases, printing one message per phase:
+### ObstaclePhase Fields
-```
-Phase 0 (start): messages[0] printed immediately
-Phase 1 (middle): messages[1] printed — FAILURE CHECK happens here
-Phase 2 (end): messages[2] printed — XP awarded, teleport to next room
-```
+| Field | Type | Description |
+| ------------ | -------- | -------------------------------------------------------------------------- |
+| `message` | string | Text printed when this phase advances |
+| `delay` | float64 | Ticks to wait before printing this phase (supports fractional via `engine.ToTicks`) |
+| `fail_check` | bool | If true, the obstacle's failure roll happens on this phase (set on at most one phase) |
+
+### Phase Advancement
+
+An obstacle advances through its phases in order. For each phase, the engine waits `delay` ticks, then prints the message. If the phase carries `fail_check`, the failure roll is made *before* awarding success. On the final phase, XP is awarded and the player is teleported to the next obstacle's room (or to `start_room` if this was the last obstacle — completing a lap).
-Failure only occurs at phase 1. Success chance is based on Agility level:
-- At required level: 70% success (30% fail)
-- Each level above reduces fail chance by 1% (down to minimum 5%)
-- Fail chance capped at 60%
+### Failure
-On failure, the player takes random damage in `[fail_damage[0], fail_damage[1]]`, is teleported back to `start_room`, and must restart the course.
+- If the obstacle defines `fail_chance`, that value (clamped to 0–1) is the failure probability for every attempt regardless of the player's level.
+- Otherwise the chance is derived from Agility level vs `required_level`: 30% fail at the required level, −1% per level above, clamped to 5–60%.
+- On failure, the player takes random damage in `[fail_damage[0], fail_damage[1]]`, is teleported back to `start_room`, and must restart the course.
### Lap Counting
@@ -115,4 +122,4 @@ exits:
| `leap` | leaping |
| `slide` | sliding |
-Obstacle verbs are auto-registered at startup from all course YAML files. No changes to `cmd_registry.go` needed when adding new courses — any verb in a `data/courses/` file will work.
+Obstacle verbs are auto-registered at startup from all course YAML files. No changes to `cmd_registry.go` needed when adding new courses — any verb in a `data/courses/` file will work. \ No newline at end of file
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index 23a2ca4..65b79e5 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -84,7 +84,7 @@ func main() {
}
if cfg.AdminHTTP.Enabled {
- adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir)
+ adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll)
if err != nil {
log.Fatalf("failed to start admin HTTP server: %v", err)
}
@@ -97,7 +97,7 @@ func main() {
}
if cfg.AdminHTTPS.Enabled {
- adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir)
+ adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll)
if err != nil {
log.Fatalf("failed to start admin HTTPS server: %v", err)
}
diff --git a/data/.admin_history.json b/data/.admin_history.json
new file mode 100644
index 0000000..6926f3a
--- /dev/null
+++ b/data/.admin_history.json
@@ -0,0 +1 @@
+{"history":[{"time":"2026-07-05T22:17:30-04:00","description":"unlink 99999202 down 99999200","file_path":"data/rooms/example_unused/agility2/99999202.yaml","old_content":"name: \"Ventilation Shaft - Coolant Pipe Walkway\"\ndescription: \"A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery.\"\non_enter:\n - message: \"Type 'balance' to cross the pipe.\"\nexits:\n down:\n room: 99999200\n blocked_message: \"\"\n","new_content":"id: 99999202\nname: Ventilation Shaft - Coolant Pipe Walkway\ncolor: \"\"\ndescription:\n - text: A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery.\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter:\n - message: Type 'balance' to cross the pipe.\n condition: null\n delay: 0\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-05T22:17:30-04:00","description":"unlink 99999200 up 99999202 (reverse)","file_path":"data/rooms/example_unused/agility2/99999200.yaml","old_content":"name: \"Agility Training Grounds\"\ndescription: \"A cavernous space beneath the asteroid's surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced).\"\nexits:\n south: 9999917\n north: 99999201\n east: 99999210\n west: 99999220\n","new_content":"id: 99999200\nname: Agility Training Grounds\ncolor: \"\"\ndescription:\n - text: 'A cavernous space beneath the asteroid''s surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced).'\nexits:\n east:\n room: 99999210\n north:\n room: 99999201\n south:\n room: 9999917\n west:\n room: 99999220\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false}],"redo":null} \ No newline at end of file
diff --git a/data/rooms/example_unused/agility2/99999200.yaml b/data/rooms/example_unused/agility2/99999200.yaml
index 7397d5d..adf85a2 100644
--- a/data/rooms/example_unused/agility2/99999200.yaml
+++ b/data/rooms/example_unused/agility2/99999200.yaml
@@ -1,7 +1,21 @@
-name: "Agility Training Grounds"
-description: "A cavernous space beneath the asteroid's surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced)."
+id: 99999200
+name: Agility Training Grounds
+color: ""
+description:
+ - text: 'A cavernous space beneath the asteroid''s surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced).'
exits:
- south: 9999917
- north: 99999201
- east: 99999210
- west: 99999220
+ east:
+ room: 99999210
+ north:
+ room: 99999201
+ south:
+ room: 9999917
+ west:
+ room: 99999220
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999202.yaml b/data/rooms/example_unused/agility2/99999202.yaml
index 7b4c6f9..c4e068a 100644
--- a/data/rooms/example_unused/agility2/99999202.yaml
+++ b/data/rooms/example_unused/agility2/99999202.yaml
@@ -1,8 +1,26 @@
-name: "Ventilation Shaft - Coolant Pipe Walkway"
-description: "A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery."
+id: 99999202
+name: Ventilation Shaft - Coolant Pipe Walkway
+color: ""
+description:
+ - text: A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'balance' to cross the pipe."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'balance' to cross the pipe.
+ condition: null
+ delay: 0
+ set_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999206.yaml b/data/rooms/example_unused/agility2/99999206.yaml
new file mode 100644
index 0000000..389ec3f
--- /dev/null
+++ b/data/rooms/example_unused/agility2/99999206.yaml
@@ -0,0 +1,4 @@
+exits:
+ southeast: 99999200
+id: 99999206
+name: 'Room #99999206'
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) &mdash; ' + 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 + ')">&larr; Step ' + idx + ' (#' + prevRoom + ')</button>';
+ }
+ if (nextRoom) {
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') &rarr;</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 ? '&#9654;' : '&#9660;') + '</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" .}}
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 {