diff options
| author | historia <[not public]> | 2026-07-06 23:05:04 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-06 23:05:04 -0400 |
| commit | 9292634f2f8d71a53879ff07e1330c671b207d51 (patch) | |
| tree | dcffadf7f697e8ad0705d787d8f599bdeeedb812 /internal/game | |
| parent | 911aae15f4e869c93239ef7c630c61e1d9d4ef3f (diff) | |
| download | thehouseoficarus-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.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_dig.go | 1 | ||||
| -rw-r--r-- | internal/game/cmd_hideallexits.go | 38 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 15 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 1 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 9 | ||||
| -rw-r--r-- | internal/game/cmd_walk.go | 13 | ||||
| -rw-r--r-- | internal/game/exit_discovery.go | 31 | ||||
| -rw-r--r-- | internal/game/exit_discovery_test.go | 81 | ||||
| -rw-r--r-- | internal/game/look_room.go | 9 | ||||
| -rw-r--r-- | internal/game/look_target.go | 12 | ||||
| -rw-r--r-- | internal/game/render_map.go | 45 |
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 == '↙' } |
