aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_map.go6
-rw-r--r--internal/admin/api_room_courses.go51
-rw-r--r--internal/admin/api_rooms.go4
-rw-r--r--internal/admin/static/map.js21
-rw-r--r--internal/game/cmd_aps.go6
-rw-r--r--internal/game/cmd_dig.go1
-rw-r--r--internal/game/cmd_hideallexits.go38
-rw-r--r--internal/game/cmd_move.go15
-rw-r--r--internal/game/cmd_registry.go1
-rw-r--r--internal/game/cmd_room_insert.go4
-rw-r--r--internal/game/cmd_verbs.go9
-rw-r--r--internal/game/cmd_walk.go13
-rw-r--r--internal/game/exit_discovery.go31
-rw-r--r--internal/game/exit_discovery_test.go81
-rw-r--r--internal/game/look_room.go9
-rw-r--r--internal/game/look_target.go12
-rw-r--r--internal/game/render_map.go45
-rw-r--r--internal/validate/checks.go4
-rw-r--r--internal/world/grid.go5
-rw-r--r--internal/world/room.go1
-rw-r--r--internal/world/room_test.go44
21 files changed, 342 insertions, 59 deletions
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")
+ }
+}