diff options
28 files changed, 414 insertions, 74 deletions
diff --git a/building_guide/courses.md b/building_guide/courses.md index e5deb32..5e92318 100644 --- a/building_guide/courses.md +++ b/building_guide/courses.md @@ -6,7 +6,7 @@ Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a s > 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). +> When a room is added as a course step, the admin GUI auto-creates **always-blocked one-way exits** between consecutive obstacle rooms so the in-game map can lay them out correctly. The direction of these exits is chosen by the builder. Always-blocked exits are impassable to players but visible on the map — 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 diff --git a/building_guide/rooms.md b/building_guide/rooms.md index d3412de..111404a 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -90,6 +90,40 @@ exits: boarded_shuttle: true # marks "left this area" on the way out ``` +### Hidden exits + +Hidden exits are invisible in `look` and `verbs` output until the player +discovers them by traversing them once. Even hidden, the exit is still +traversable — just not listed. Once a player moves through a hidden exit, it +becomes "discovered" for that character and appears in listing with a +`(HIDDEN)` tag. On the map, hidden exits (and rooms only reachable via them) +do not appear until the character discovers them. Gods in `god` mode see all +hidden exits regardless of discovery. + +```yaml +exits: + down: + room: 1002 + hidden: true +``` + +### Always-blocked exits + +Always-blocked exits are impassable to players but always visible. They +appear in `look`/`verbs` and render on the map as blocked `X` connectors. +Gods in `god` mode can traverse them. Primarily used for agility course +room-to-room links so the map can lay the course out visually. + +```yaml +exits: + north: + room: 99999221 + always_blocked: true +``` + +Hidden and always_blocked can be combined: the exit is invisible until +discovered (or seen by a god), and then shows as impassable. + ### Map grid & one-way exits All exits (north/south/east/west/ne/nw/se/sw) must form a consistent 3D grid: from diff --git a/data/.admin_history.json b/data/.admin_history.json new file mode 100644 index 0000000..f990f30 --- /dev/null +++ b/data/.admin_history.json @@ -0,0 +1 @@ +{"history":[{"time":"2026-07-06T22:04:45-04:00","description":"unlink 2001 north 1005","file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1005\n south:\n room: 2006\n southeast:\n room: 2005\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T22:04:45-04:00","description":"unlink 1005 south 2001 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T22:04:48-04:00","description":"create room 1006","file_path":"data/rooms/intro/1006.yaml","old_content":"","new_content":"exits:\n south: 2001\nid: 1006\nname: 'Room #1006'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T22:04:55-04:00","description":"unlink 1006 south 2001","file_path":"data/rooms/intro/1006.yaml","old_content":"exits:\n south: 2001\nid: 1006\nname: 'Room #1006'\n","new_content":"id: 1006\nname: 'Room #1006'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T22:05:09-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n blocked_message: \"\"\n condition: null\n hidden: true\n room: 1006\n set_flags: null\n set_player_flags: null\n south:\n room: 2006\n southeast:\n room: 2005\nhazard: \"\"\nid: 2001\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false}],"redo":null}
\ No newline at end of file diff --git a/data/help/hideallexits.yaml b/data/help/hideallexits.yaml new file mode 100644 index 0000000..4461cc1 --- /dev/null +++ b/data/help/hideallexits.yaml @@ -0,0 +1,7 @@ +name: hideallexits +description: |- + Admin-only. Resets all discovered hidden exits for the current character, + clearing every "hidden_exit_*" player flag. The next time you traverse a + hidden exit it will be marked as newly discovered again. Useful for + testing hidden exit discovery mechanics. + Usage: hideallexits diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml index 4f31b7f..faf82e3 100644 --- a/data/rooms/intro/1005.yaml +++ b/data/rooms/intro/1005.yaml @@ -2,9 +2,7 @@ id: 1005 name: 'Room #1005' color: "" description: [] -exits: - south: - room: 2001 +exits: {} objects: [] item_spawns: [] mobs: [] diff --git a/data/rooms/intro/1006.yaml b/data/rooms/intro/1006.yaml new file mode 100644 index 0000000..5a87adb --- /dev/null +++ b/data/rooms/intro/1006.yaml @@ -0,0 +1,12 @@ +id: 1006 +name: 'Room #1006' +color: "" +description: [] +exits: {} +objects: [] +item_spawns: [] +mobs: [] +on_enter: [] +hazard: "" +block_transport: false +triggers: [] diff --git a/data/rooms/intro/2001.yaml b/data/rooms/intro/2001.yaml index 0387c3e..a9570f6 100644 --- a/data/rooms/intro/2001.yaml +++ b/data/rooms/intro/2001.yaml @@ -1,5 +1,4 @@ -id: 2001 -name: Test Room +block_transport: false color: "" description: - text: It's a test room @@ -7,17 +6,18 @@ exits: east: room: 2002 north: - room: 1005 + blocked_message: "" + condition: null + always_blocked: true + room: 1006 + set_flags: null + set_player_flags: null south: room: 2006 southeast: room: 2005 -objects: - - name: hidden guy - hidden: true - description: - - text: YOU FOUND HIM - - id: tree +hazard: "" +id: 2001 item_spawns: - id: bronze_axe quantity: 1 @@ -31,7 +31,12 @@ item_spawns: mobs: - id: man - id: flight_attendant_copy +name: Test Room +objects: + - description: + - text: YOU FOUND HIM + hidden: true + name: hidden guy + - id: tree on_enter: [] -hazard: "" -block_transport: false triggers: [] diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 5168165..a7802f9 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -50,7 +50,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { return nil, false } return room, true - }, nil, func(gc world.GridConflict) { + }, nil, nil, func(gc world.GridConflict) { if gc.Kind == "overlap" { log.Printf("map: grid overlap: room %d wants cell %v already held by %d (via %d->%s)", gc.Target, gc.Want, gc.Occupier, gc.From, gc.Dir) @@ -171,6 +171,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { Dir string `json:"dir"` Bidirectional bool `json:"bidirectional"` Hidden bool `json:"hidden"` + AlwaysBlocked bool `json:"always_blocked"` } type UDLink struct { @@ -260,7 +261,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { bidirectional := false if targetRoom, ok := roomData[target]; ok { - if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden { + if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden && !oppExit.AlwaysBlocked { bidirectional = true } } @@ -277,6 +278,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { Dir: string(dir), Bidirectional: bidirectional, Hidden: exit.Hidden, + AlwaysBlocked: exit.AlwaysBlocked, }) } } diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index f81a204..86c7cdf 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -378,8 +378,8 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ NewContent: newContent, }) - // --- hidden exit management --- - // Create hidden exits so the map BFS lays the course rooms in sequence. + // --- course map exit management --- + // Create always-blocked exits so the map BFS lays the course rooms in sequence. var prevRoom, nextRoom int if insertAt > 0 { prevRoom = obstacleRoomID(newList[insertAt-1]) @@ -391,24 +391,24 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ if prevRoom > 0 { pm, _, _, perr := s.loadRoomMapByID(prevRoom) if perr == nil { - // Delete the old hidden link from prevRoom to nextRoom (now bypassed). + // Delete the old blocked link from prevRoom to nextRoom (now bypassed). if nextRoom > 0 { - oldDir := findHiddenExitDirToTarget(pm, nextRoom) + oldDir := findCourseExitDirToTarget(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)) + setCourseBlockedExit(pm, dir, roomID) + s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("blocked 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)) + setCourseBlockedExit(cm, dir, nextRoom) + s.saveRoomMapByID(roomID, cm, fmt.Sprintf("blocked exit %d->%d for course %s", roomID, nextRoom, body.CourseID)) } } @@ -448,25 +448,25 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ nextRoom = obstacleRoomID(obstacles[idx+1]) } - // --- hidden exit cleanup --- + // --- course map exit cleanup --- var relinkDir string if prevRoom > 0 { pm, _, _, perr := s.loadRoomMapByID(prevRoom) if perr == nil { - relinkDir = findHiddenExitDirToTarget(pm, roomID) + relinkDir = findCourseExitDirToTarget(pm, roomID) if relinkDir != "" { removeExit(pm, relinkDir) - s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("remove hidden exit to %d (course %s)", roomID, id)) + s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("remove blocked exit to %d (course %s)", roomID, id)) } } } if nextRoom > 0 { cm, _, _, cerr := s.loadRoomMapByID(roomID) if cerr == nil { - d := findHiddenExitDirToTarget(cm, nextRoom) + d := findCourseExitDirToTarget(cm, nextRoom) if d != "" { removeExit(cm, d) - s.saveRoomMapByID(roomID, cm, fmt.Sprintf("remove hidden exit to %d (course %s)", nextRoom, id)) + s.saveRoomMapByID(roomID, cm, fmt.Sprintf("remove blocked exit to %d (course %s)", nextRoom, id)) } } } @@ -475,8 +475,8 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ 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)) + setCourseBlockedExit(pm, dir, nextRoom) + s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("relink blocked exit %d->%d (course %s)", prevRoom, nextRoom, id)) } } @@ -573,11 +573,12 @@ func roomExitsMap(m map[string]any) map[string]any { return ex } -// setHiddenExit adds or replaces a hidden exit in direction dir targeting -// targetRoomID. -func setHiddenExit(m map[string]any, dir string, targetRoomID int) { +// setCourseBlockedExit adds or replaces an always-blocked exit in direction dir +// targeting targetRoomID. Always-blocked exits are impassable but render on the map; +// used to lay out course rooms in visual sequence. +func setCourseBlockedExit(m map[string]any, dir string, targetRoomID int) { ex := roomExitsMap(m) - ex[dir] = map[string]any{"room": targetRoomID, "hidden": true} + ex[dir] = map[string]any{"room": targetRoomID, "always_blocked": true} } // removeExit deletes an exit by direction. Returns true if it existed. @@ -608,25 +609,25 @@ func exitTargetRoom(v any) int { return 0 } -// exitIsHidden returns whether an exit value carries hidden: true. -func exitIsHidden(v any) bool { +// exitIsAlwaysBlocked returns whether an exit value carries always_blocked: true. +func exitIsAlwaysBlocked(v any) bool { if mm, ok := v.(map[string]any); ok { - if h, ok := mm["hidden"]; ok { + if h, ok := mm["always_blocked"]; ok { return h == true } } return false } -// findHiddenExitDirToTarget returns the direction of a hidden exit from m +// findCourseExitDirToTarget returns the direction of an always-blocked exit from m // targeting targetRoomID, or "" if none. -func findHiddenExitDirToTarget(m map[string]any, targetRoomID int) string { +func findCourseExitDirToTarget(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 { + if exitIsAlwaysBlocked(v) && exitTargetRoom(v) == targetRoomID { return dir } } diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 74143a8..75f4779 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -458,7 +458,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { return nil, false } return room, true - }, nil, nil) + }, nil, nil, nil) cA, okA := grid.Coord[body.From] cB, okB := grid.Coord[body.To] @@ -542,7 +542,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From} } return &roomCopy, true - }, nil, func(gc world.GridConflict) { + }, nil, nil, func(gc world.GridConflict) { conflicts++ }) _ = testGrid diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 0dab98c..8080451 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -253,10 +253,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.always_blocked) { + g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#C44" stroke-width="5" opacity="0.75" stroke-dasharray="6,3" vector-effect="non-scaling-stroke"/>'; + return; + } 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"/>'; + g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#C7C" stroke-width="5" opacity="0.7" stroke-dasharray="2,2" vector-effect="non-scaling-stroke"/>'; return; } var c = blendColors(fr.color, tr.color).replace('#',''); @@ -1232,15 +1234,18 @@ function renderExitsSection(r) { 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 hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false; - var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden); + var alwaysBlocked = (typeof exit === 'object' && exit.always_blocked) ? exit.always_blocked : false; + var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden || alwaysBlocked); var isBidi = mapData && mapData.links && mapData.links.some(function(l) { return l.bidirectional && ((l.from === r.id && l.to === target) || (l.from === target && l.to === r.id)); }); 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>'; + if (alwaysBlocked) h += '<span class="exit-meta">Impassable</span>'; if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>'; if (cond) h += '<span class="exit-meta">Conditional</span>'; + if (hidden) h += '<span class="exit-meta" style="color:#c7c">Hidden</span>'; h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>'; if (isBidi) h += '<button class="btn btn-sm" onclick="deleteLink(' + target + ',' + r.id + ',\'from\')" title="Remove reverse exit to make one-way">\u21C6</button>'; h += '<button class="btn btn-sm btn-danger" onclick="deleteExit(\'' + d + '\', ' + target + ')">X</button>'; @@ -1271,6 +1276,7 @@ function editExitCondition(dir) { var setFlags = exit.set_flags || null; var setPlayerFlags = exit.set_player_flags || null; var hidden = exit.hidden || false; + var alwaysBlocked = exit.always_blocked || false; var flagsStr = ''; if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', '); @@ -1290,6 +1296,7 @@ function editExitCondition(dir) { 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 class="form-group"><label><input type="checkbox" id="exitAlwaysBlocked"' + (alwaysBlocked ? ' checked' : '') + '> Always Blocked (impassable, shown on map)</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>'; @@ -1429,6 +1436,8 @@ function saveExitCondition() { exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : ''; var hiddenEl = document.getElementById('exitHidden'); exit.hidden = hiddenEl ? hiddenEl.checked : false; + var alwaysBlockedEl = document.getElementById('exitAlwaysBlocked'); + exit.always_blocked = alwaysBlockedEl ? alwaysBlockedEl.checked : false; var flagsStr = document.getElementById('exitFlags'); var pflagsStr = document.getElementById('exitPFlags'); exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : ''); @@ -1475,6 +1484,10 @@ function clearExitCondition() { if (ef) ef.value = ''; var pf = document.getElementById('exitPFlags'); if (pf) pf.value = ''; + var hx = document.getElementById('exitHidden'); + if (hx) hx.checked = false; + var ab = document.getElementById('exitAlwaysBlocked'); + if (ab) ab.checked = false; } function parseFlagInput(val) { diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go index f48c2bb..af26923 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, p.GodMode) + path := g.findPathToRoom(p, p.RoomID, targetID) 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, p.GodMode) + path := g.findPathToRoom(p, p.RoomID, id) 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, p.GodMode) + path := g.findPathToRoom(p, p.RoomID, id) if path == nil { continue } diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index 96a5421..807f424 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -275,6 +275,7 @@ func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[ }, func(id int) bool { return roomIndex[id] }, nil, + nil, ) return rg.Coord, rg.RoomAt } diff --git a/internal/game/cmd_hideallexits.go b/internal/game/cmd_hideallexits.go new file mode 100644 index 0000000..559da8b --- /dev/null +++ b/internal/game/cmd_hideallexits.go @@ -0,0 +1,38 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeHideAllExits(sess *net.Session, args []string, rawInput string) { + if !g.checkAdmin(sess) { + sess.WriteLine("Unknown command.") + return + } + p := sess.Player + if p == nil { + return + } + if p.Flags == nil { + sess.WriteLine("No discovered exits to clear.") + return + } + var cleared []string + for k := range p.Flags { + if strings.HasPrefix(k, "hidden_exit_") { + cleared = append(cleared, k) + } + } + if len(cleared) == 0 { + sess.WriteLine("No discovered exits to clear.") + return + } + for _, k := range cleared { + delete(p.Flags, k) + } + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("Cleared %d discovered exit(s).", len(cleared))) +} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 6063160..3b9eeee 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -31,8 +31,12 @@ 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.") + if !p.GodMode && exitDef.AlwaysBlocked { + msg := exitDef.BlockedMessage + if msg == "" { + msg = fmt.Sprintf("The way %s is impassable.", exitDir) + } + sess.WriteLine(msg) return } @@ -153,6 +157,13 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { } oldRoom := p.RoomID + if oldRoom != targetID && exitDir != "" { + if room, err := g.World.LoadRoom(oldRoom); err == nil { + if ed, ok := room.Exits[g.World.ResolveExit(exitDir)]; ok && ed.Hidden && ed.Room == targetID { + g.markExitDiscovered(p, oldRoom, g.World.ResolveExit(exitDir)) + } + } + } p.RoomID = targetID p.HazardTimer = 0 p.Stats.RecordRoomVisit(targetID) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 1247fc3..462bf53 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -160,6 +160,7 @@ var commandRegistry = map[string]commandDef{ "close": {(*Game).executeClose, ClassInstant}, "god": {(*Game).executeGod, ClassInstant}, "ungod": {(*Game).executeUnGod, ClassInstant}, + "hideallexits": {(*Game).executeHideAllExits, ClassInstant}, } func classifyCommand(cmd string) CommandClass { diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 5c24d60..988cd08 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -151,6 +151,8 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { BlockedMessage: exitDef.BlockedMessage, SetFlags: exitDef.SetFlags, SetPlayerFlags: exitDef.SetPlayerFlags, + Hidden: exitDef.Hidden, + AlwaysBlocked: exitDef.AlwaysBlocked, } curData, err := yaml.Marshal(curRoom) if err != nil { @@ -169,6 +171,8 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { BlockedMessage: targetExit.BlockedMessage, SetFlags: targetExit.SetFlags, SetPlayerFlags: targetExit.SetPlayerFlags, + Hidden: targetExit.Hidden, + AlwaysBlocked: targetExit.AlwaysBlocked, } } targetPath, tok2 := g.World.GetRoomPath(targetID) diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index e4f1fa1..239f96b 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -139,9 +139,14 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { if room, err := g.World.LoadRoom(p.RoomID); err == nil { var exitDirs []string for _, dir := range world.ExitOrder { - if _, ok := room.Exits[dir]; ok { - exitDirs = append(exitDirs, string(dir)) + ed, ok := room.Exits[dir] + if !ok { + continue } + if ed.Hidden && !g.exitDiscovered(p, p.RoomID, dir) { + continue + } + exitDirs = append(exitDirs, string(dir)) } if len(exitDirs) > 0 { sectionExits = append(sectionExits, strings.Join(exitDirs, ", ")) diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index 9bc7183..4b3b7e0 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, p.GodMode) + path := g.findPathToRoom(p, p.RoomID, roomID) 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, godMode bool) []string { +func (g *Game) findPathToRoom(p *player.Player, fromRoom, toRoom int) []string { if fromRoom == toRoom { return nil } @@ -216,9 +216,12 @@ func (g *Game) findPathToRoom(fromRoom, toRoom int, godMode bool) []string { if !exists { continue } - if exitDef.Hidden && !godMode { - continue - } + if !p.GodMode && exitDef.AlwaysBlocked { + continue + } + if exitDef.Hidden && !g.exitDiscovered(p, cur.roomID, dir) { + continue + } if visited[exitDef.Room] { continue } diff --git a/internal/game/exit_discovery.go b/internal/game/exit_discovery.go new file mode 100644 index 0000000..1d07e13 --- /dev/null +++ b/internal/game/exit_discovery.go @@ -0,0 +1,31 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func discoveredExitFlag(roomID int, dir world.ExitDir) string { + return fmt.Sprintf("hidden_exit_%d_%s", roomID, strings.ToLower(string(dir))) +} + +func (g *Game) exitDiscovered(p *player.Player, roomID int, dir world.ExitDir) bool { + if p.GodMode { + return true + } + return getPlayerFlagInt(p, discoveredExitFlag(roomID, dir)) != 0 +} + +func (g *Game) markExitDiscovered(p *player.Player, roomID int, dir world.ExitDir) { + g.setPlayerFlag(p, discoveredExitFlag(roomID, dir), 1) +} + +func (g *Game) exitHiddenFiltered(p *player.Player, roomID int, dir world.ExitDir, exitHidden bool) bool { + if !exitHidden { + return true + } + return g.exitDiscovered(p, roomID, dir) +} diff --git a/internal/game/exit_discovery_test.go b/internal/game/exit_discovery_test.go new file mode 100644 index 0000000..58177de --- /dev/null +++ b/internal/game/exit_discovery_test.go @@ -0,0 +1,81 @@ +package game + +import ( + "testing" + + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func TestDiscoveredExitFlagKey(t *testing.T) { + key := discoveredExitFlag(2001, world.North) + if key != "hidden_exit_2001_north" { + t.Errorf("unexpected flag key: %q", key) + } + key = discoveredExitFlag(100, world.Down) + if key != "hidden_exit_100_down" { + t.Errorf("unexpected flag key: %q", key) + } +} + +func TestExitDiscoveredUndiscovered(t *testing.T) { + p := &player.Player{} + if g := (&Game{}).exitDiscovered(p, 42, world.East); g { + t.Error("expected undiscovered to be false") + } +} + +func TestExitDiscoveredAfterMark(t *testing.T) { + g := &Game{Flags: NewFlagStore()} + p := &player.Player{} + roomID := 42 + dir := world.East + flagKey := discoveredExitFlag(roomID, dir) + + if g.exitDiscovered(p, roomID, dir) { + t.Error("should not be discovered before marking") + } + g.markExitDiscovered(p, roomID, dir) + if !g.exitDiscovered(p, roomID, dir) { + t.Error("should be discovered after marking") + } + if getPlayerFlagInt(p, flagKey) == 0 { + t.Error("flag should be set after marking") + } +} + +func TestExitDiscoveredGodMode(t *testing.T) { + p := &player.Player{GodMode: true} + if !(&Game{}).exitDiscovered(p, 99, world.South) { + t.Error("god mode should always see exits as discovered") + } +} + +func TestExitHiddenFiltered(t *testing.T) { + g := &Game{Flags: NewFlagStore()} + p := &player.Player{} + + if !g.exitHiddenFiltered(p, 1, world.North, false) { + t.Error("non-hidden exit should pass filter") + } + if g.exitHiddenFiltered(p, 1, world.North, true) { + t.Error("hidden undiscovered exit should be filtered") + } + g.markExitDiscovered(p, 1, world.North) + if !g.exitHiddenFiltered(p, 1, world.North, true) { + t.Error("hidden discovered exit should pass filter") + } +} + +func TestExitDiscoveredDifferentRoom(t *testing.T) { + g := &Game{Flags: NewFlagStore()} + p := &player.Player{} + + g.markExitDiscovered(p, 10, world.North) + if g.exitDiscovered(p, 10, world.East) { + t.Error("discovering north should not mark east as discovered") + } + if g.exitDiscovered(p, 20, world.North) { + t.Error("discovering room 10 north should not mark room 20 north") + } +} diff --git a/internal/game/look_room.go b/internal/game/look_room.go index 887bb1d..8bd85be 100644 --- a/internal/game/look_room.go +++ b/internal/game/look_room.go @@ -88,7 +88,7 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro if !ok { continue } - if exitDef.Hidden && !p.GodMode { + if exitDef.Hidden && !g.exitDiscovered(p, room.ID, dir) { continue } coloredDir := g.colorize(sess, "exit_direction", string(dir)) @@ -97,11 +97,14 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro if err == nil { targetName = g.colorize(sess, "exit_name", targetRoom.Name) } + if exitDef.AlwaysBlocked { + targetName += " (impassable)" + } if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { targetName += " (blocked)" } if exitDef.Hidden { - targetName += " (hidden)" + targetName += " (HIDDEN)" } lines = append(lines, exitLine{coloredDir, targetName}) if visibleLen(coloredDir) > maxDirLen { @@ -117,7 +120,7 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro first := true for _, dir := range world.ExitOrder { exitDef, ok := room.Exits[dir] - if !ok || (exitDef.Hidden && !p.GodMode) { + if !ok || (exitDef.Hidden && !g.exitDiscovered(p, room.ID, dir)) { continue } if !first { diff --git a/internal/game/look_target.go b/internal/game/look_target.go index bf130be..ead38a7 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -26,6 +26,18 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine("You can't see anything that way.") return } + if !p.GodMode && exitDef.AlwaysBlocked { + msg := exitDef.BlockedMessage + if msg == "" { + msg = fmt.Sprintf("The way %s is impassable.", exitDir) + } + sess.WriteLine(msg) + return + } + if exitDef.Hidden && !g.exitDiscovered(p, p.RoomID, exitDir) { + sess.WriteLine("You can't see anything that way.") + return + } if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { if !p.GodMode { msg := exitDef.BlockedMessage diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 1bc9adc..5e57bc7 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -6,6 +6,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) @@ -55,10 +56,26 @@ type mapGraph struct { // player's z-plane is constant. const playerZ = 0 -func buildGraph(g *Game, startRoomID int) *mapGraph { +func buildGraph(g *Game, sess *net.Session, startRoomID int) *mapGraph { rg := world.BuildGrid(startRoomID, func(id int) (*world.Room, bool) { return loadRoom(g, id) - }, nil, nil) + }, nil, func(fromID int, dir world.ExitDir, targetID int) bool { + if sess == nil || sess.Player == nil { + return true + } + room, ok := loadRoom(g, fromID) + if !ok { + return true + } + exit, ok := room.Exits[dir] + if !ok || exit.Room != targetID { + return true + } + if exit.Hidden && !g.exitDiscovered(sess.Player, fromID, dir) { + return false + } + return true + }, nil) return &mapGraph{posToRoom: rg.RoomAt, dist: rg.Dist} } @@ -88,7 +105,7 @@ func renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, bo func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string { visited := roomsVisited(sess) - bg := buildGraph(g, roomID) + bg := buildGraph(g, sess, roomID) colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) @@ -261,7 +278,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { visited := roomsVisited(sess) - bg := buildGraph(g, roomID) + bg := buildGraph(g, sess, roomID) colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) @@ -602,10 +619,15 @@ 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 + p := sessPlayer(sess) + if exit.Hidden && !g.exitDiscovered(p, from, dir) { + return exitAbsent + } + if exit.AlwaysBlocked { + if p != nil && p.GodMode { + return exitOpen + } + return exitBlocked } if exit.Condition == nil || sess == nil || sess.Player == nil { return exitOpen @@ -616,6 +638,13 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb return exitBlocked } +func sessPlayer(sess *net.Session) *player.Player { + if sess != nil { + return sess.Player + } + return nil +} + func isDiagonalGlyph(ch rune) bool { return ch == '\\' || ch == '/' || ch == '↗' || ch == '↖' || ch == '↘' || ch == '↙' } diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 41b34e0..360f97c 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -991,7 +991,7 @@ func validateRoomGrid(s Source) []Issue { continue } - grid := world.BuildGrid(origin, load, include, func(c world.GridConflict) { + grid := world.BuildGrid(origin, load, include, nil, func(c world.GridConflict) { switch c.Kind { case "twist": issues = append(issues, Issue{ @@ -1044,7 +1044,7 @@ func validateRoomGrid(s Source) []Issue { if placed[id] { continue } - grid := world.BuildGrid(id, load, include, onConf(id)) + grid := world.BuildGrid(id, load, include, nil, onConf(id)) for rid := range grid.Coord { placed[rid] = true } diff --git a/internal/world/grid.go b/internal/world/grid.go index 837f737..2ea897a 100644 --- a/internal/world/grid.go +++ b/internal/world/grid.go @@ -32,12 +32,15 @@ type RoomGrid struct { // are then not followed, but any coordinate already assigned to it stands. // - include, when non-nil, restricts placement to targets for which it returns // true (pass nil to place every reachable target). +// - edgeInclude, when non-nil, restricts traversal to exits for which it +// returns true (pass nil to follow every exit whose target passes include). +// It receives (fromRoomID, dir, targetRoomID) and is checked before include. // - onConflict, when non-nil, is called for each twist/overlap encountered. // // The first assignment to a room or cell always wins; conflicting placements are // skipped. In a world that passes grid validation there are no conflicts, so the // layout is unambiguous. -func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, onConflict func(GridConflict)) RoomGrid { +func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, edgeInclude func(int, ExitDir, int) bool, onConflict func(GridConflict)) RoomGrid { g := RoomGrid{ Coord: map[int][3]int{seed: {0, 0, 0}}, RoomAt: map[[3]int]int{{0, 0, 0}: seed}, diff --git a/internal/world/room.go b/internal/world/room.go index 8843b9e..29cce0b 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -81,6 +81,7 @@ type ExitDef struct { SetFlags map[string]any `yaml:"set_flags,omitempty"` SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"` Hidden bool `yaml:"hidden,omitempty"` + AlwaysBlocked bool `yaml:"always_blocked,omitempty"` } func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error { diff --git a/internal/world/room_test.go b/internal/world/room_test.go index e7b1718..1ad974e 100644 --- a/internal/world/room_test.go +++ b/internal/world/room_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "gopkg.in/yaml.v3" ) func writeRoom(t *testing.T, dir string, body string) { @@ -90,3 +92,45 @@ func TestNormalizeObjectName(t *testing.T) { } } } + +func TestExitDefUnmarshalAlwaysBlocked(t *testing.T) { + y := `exits: + north: + room: 5 + always_blocked: true + south: 3 + east: + room: 7 + hidden: true +` + var room struct { + Exits map[ExitDir]ExitDef `yaml:"exits"` + } + if err := yaml.Unmarshal([]byte(y), &room); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(room.Exits) != 3 { + t.Fatalf("expected 3 exits, got %d", len(room.Exits)) + } + n := room.Exits[North] + if !n.AlwaysBlocked { + t.Error("north exit should have AlwaysBlocked=true") + } + if n.Room != 5 { + t.Errorf("north target = %d, want 5", n.Room) + } + s := room.Exits[South] + if s.Room != 3 { + t.Errorf("south target = %d, want 3", s.Room) + } + if s.AlwaysBlocked { + t.Error("south exit should not have AlwaysBlocked") + } + e := room.Exits[East] + if !e.Hidden { + t.Error("east exit should have Hidden=true") + } + if e.AlwaysBlocked { + t.Error("east exit should not have AlwaysBlocked") + } +} |
