aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-06 23:05:04 -0400
committerhistoria <[not public]>2026-07-06 23:05:04 -0400
commit9292634f2f8d71a53879ff07e1330c671b207d51 (patch)
treedcffadf7f697e8ad0705d787d8f599bdeeedb812 /internal/game
parent911aae15f4e869c93239ef7c630c61e1d9d4ef3f (diff)
downloadthehouseoficarus-9292634f2f8d71a53879ff07e1330c671b207d51.tar.gz
feat: split the concept of hidden and impassable exits. add player_flag for discovered hidden exits.
Diffstat (limited to 'internal/game')
-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
13 files changed, 242 insertions, 23 deletions
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 == '↙'
}