aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/cmd_aps.go6
-rw-r--r--internal/game/cmd_hideallexits.go2
-rw-r--r--internal/game/cmd_look.go28
-rw-r--r--internal/game/cmd_move.go11
-rw-r--r--internal/game/cmd_swapid.go91
-rw-r--r--internal/game/cmd_verbs.go2
-rw-r--r--internal/game/cmd_walk.go9
-rw-r--r--internal/game/exit_discovery.go15
-rw-r--r--internal/game/exit_discovery_test.go75
-rw-r--r--internal/game/exit_display.go56
-rw-r--r--internal/game/exit_migrate.go150
-rw-r--r--internal/game/exit_migrate_test.go107
-rw-r--r--internal/game/look_room.go123
-rw-r--r--internal/game/look_target.go23
-rw-r--r--internal/game/render_map.go25
15 files changed, 529 insertions, 194 deletions
diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go
index af26923..839455e 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, p.RoomID, targetID)
+ path := g.findPathToRoom(sess, 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, p.RoomID, id)
+ path := g.findPathToRoom(sess, 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, p.RoomID, id)
+ path := g.findPathToRoom(sess, p.RoomID, id)
if path == nil {
continue
}
diff --git a/internal/game/cmd_hideallexits.go b/internal/game/cmd_hideallexits.go
index 559da8b..d9747c9 100644
--- a/internal/game/cmd_hideallexits.go
+++ b/internal/game/cmd_hideallexits.go
@@ -22,7 +22,7 @@ func (g *Game) executeHideAllExits(sess *net.Session, args []string, rawInput st
}
var cleared []string
for k := range p.Flags {
- if strings.HasPrefix(k, "hidden_exit_") {
+ if strings.HasPrefix(k, hiddenExitFlagPrefix) {
cleared = append(cleared, k)
}
}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index c34052a..f36fd9e 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -67,31 +67,13 @@ func (g *Game) doExits(sess *net.Session) {
sess.WriteLine("There are no exits here.")
return
}
- type exitLine struct {
- dir string
- name string
- }
- var lines []exitLine
- maxDirLen := 0
- for _, dir := range world.ExitOrder {
- exitDef, ok := room.Exits[dir]
- if !ok {
- continue
- }
- coloredDir := g.colorize(sess, "exit_direction", string(dir))
- targetRoom, err := g.World.LoadRoom(exitDef.Room)
- targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room))
- if err == nil {
- targetName = g.colorize(sess, "exit_name", targetRoom.Name)
- }
- lines = append(lines, exitLine{coloredDir, targetName})
- if visibleLen(coloredDir) > maxDirLen {
- maxDirLen = visibleLen(coloredDir)
- }
+ lines := g.formatExitLines(sess, room)
+ if len(lines) == 0 {
+ sess.WriteLine("There are no exits here.")
+ return
}
for _, l := range lines {
- pad := maxDirLen + (len(l.dir) - visibleLen(l.dir))
- sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.name))
+ sess.WriteLine(l)
}
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 3b9eeee..f236d67 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -31,16 +31,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
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 !p.GodMode && exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
+ if ed := g.exitDisplayState(sess, room.ID, exitDir, exitDef); ed.Blocked {
msg := exitDef.BlockedMessage
if msg == "" {
msg = fmt.Sprintf("The way %s is blocked.", exitDir)
diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go
index 6ee1118..bbcc79c 100644
--- a/internal/game/cmd_swapid.go
+++ b/internal/game/cmd_swapid.go
@@ -3,10 +3,10 @@ package game
import (
"fmt"
"os"
- "path/filepath"
- "regexp"
"strconv"
+ "gopkg.in/yaml.v3"
+
"thehouseoficarus/internal/net"
)
@@ -63,38 +63,25 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
return
}
- roomsDir := filepath.Join(g.DataDir, "rooms")
- re1 := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, id1))
- re2 := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, id2))
- placeholder := fmt.Sprintf("__SWAPID_TEMP_%d__", id1^id2)
-
- var modified []string
- err := filepath.WalkDir(roomsDir, func(roomPath string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || filepath.Ext(roomPath) != ".yaml" {
- return nil
- }
- data, rerr := os.ReadFile(roomPath)
- if rerr != nil {
- return nil
- }
- text := string(data)
- if !re1.MatchString(text) && !re2.MatchString(text) {
- return nil
+ // Refuse while non-admin players are online: swapping room IDs relocates
+ // every character whose state embeds those IDs, which is disruptive to
+ // normal play. Admins may stay online (their own state is migrated too).
+ for _, other := range g.Hub.AllSessions() {
+ if other == sess || other.Player == nil {
+ continue
}
- text = re1.ReplaceAllString(text, placeholder)
- text = re2.ReplaceAllString(text, strconv.Itoa(id1))
- text = regexp.MustCompile(placeholder).ReplaceAllString(text, strconv.Itoa(id2))
- if werr := os.WriteFile(roomPath, []byte(text), 0644); werr != nil {
- return werr
+ if !g.checkAdmin(other) {
+ sess.WriteLine("Cannot swap room IDs while non-admin players are online.")
+ return
}
- modified = append(modified, roomPath)
- return nil
- })
- if err != nil {
- sess.WriteLine(fmt.Sprintf("Error updating room files: %v", err))
- return
}
+ // Swap the two files first. After this, path1 holds the old room id2's
+ // content and path2 holds the old room id1's content. Rebuilding the index
+ // then makes LoadRoom(id1)/LoadRoom(id2) return the swapped content, so the
+ // reference rewrite below operates on the correctly-positioned rooms and
+ // re-marshaling writes an `id:` field matching the new filename (keeping
+ // the file's id field consistent with its name, as the old regex did).
tempPath := path1 + ".swapid-tmp"
if rerr := os.Rename(path1, tempPath); rerr != nil {
sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id1, rerr))
@@ -113,14 +100,44 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
}
g.World.RebuildRoomIndex(g.DataDir)
- g.World.LoadRoom(id1)
- g.World.LoadRoom(id2)
- if p.RoomID == id1 {
- p.RoomID = id2
- } else if p.RoomID == id2 {
- p.RoomID = id1
+ // Remap every genuine room-ID reference (exit targets, trigger/on-enter
+ // room+teleport+despawn_rooms, mob wander_rooms) across all rooms. This
+ // replaces the old \b<id>\b regex, which also clobbered unrelated bare
+ // integers (delays, quantities, color indices) and author flag values.
+ // The two swapped rooms are always re-marshaled to correct their `id:`
+ // field; other rooms are re-marshaled only if a reference actually moved.
+ idMap := map[int]int{id1: id2, id2: id1}
+ var updated int
+ for id := range g.World.RoomIndex() {
+ room, err := g.World.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ changed := room.RewriteRoomIDs(idMap)
+ if !changed && id != id1 && id != id2 {
+ continue
+ }
+ path, ok := g.World.GetRoomPath(id)
+ if !ok {
+ continue
+ }
+ data, merr := yaml.Marshal(room)
+ if merr != nil {
+ continue
+ }
+ if werr := os.WriteFile(path, data, 0644); werr != nil {
+ continue
+ }
+ updated++
}
- sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d files updated).", id1, id2, len(modified)))
+ // Migrate room-ID-embedded state across all characters (online + offline):
+ // discovered-exit player flags, current RoomID, EnterSeqRoom, MapSymbols,
+ // and RoomsVisited. This is what the player asked for — without it,
+ // swapid leaves every character's hidden_exit_<id>_<dir> flags (and more)
+ // pointing at stale room IDs.
+ g.RewriteRoomIDs(idMap)
+
+ sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d files updated).", id1, id2, updated))
}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index 239f96b..b5c69ce 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -143,7 +143,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
if !ok {
continue
}
- if ed.Hidden && !g.exitDiscovered(p, p.RoomID, dir) {
+ if !g.exitDisplayState(sess, p.RoomID, dir, ed).Visible {
continue
}
exitDirs = append(exitDirs, string(dir))
diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go
index 4b3b7e0..de10cbd 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, p.RoomID, roomID)
+ path := g.findPathToRoom(sess, 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(p *player.Player, fromRoom, toRoom int) []string {
+func (g *Game) findPathToRoom(sess *net.Session, fromRoom, toRoom int) []string {
if fromRoom == toRoom {
return nil
}
@@ -216,10 +216,7 @@ func (g *Game) findPathToRoom(p *player.Player, fromRoom, toRoom int) []string {
if !exists {
continue
}
- if !p.GodMode && exitDef.AlwaysBlocked {
- continue
- }
- if exitDef.Hidden && !g.exitDiscovered(p, cur.roomID, dir) {
+ if ed := g.exitDisplayState(sess, cur.roomID, dir, exitDef); !ed.Visible || ed.Blocked {
continue
}
if visited[exitDef.Room] {
diff --git a/internal/game/exit_discovery.go b/internal/game/exit_discovery.go
index 1d07e13..cf09252 100644
--- a/internal/game/exit_discovery.go
+++ b/internal/game/exit_discovery.go
@@ -8,8 +8,14 @@ import (
"thehouseoficarus/internal/world"
)
+// hiddenExitFlagPrefix is the canonical prefix for discovered-exit player flags
+// ("hidden_exit_<roomID>_<dir>"). Shared by discoveredExitFlag, the
+// hideallexits admin command, and the room-ID migration in exit_migrate.go so
+// the format never drifts between writers and readers.
+const hiddenExitFlagPrefix = "hidden_exit_"
+
func discoveredExitFlag(roomID int, dir world.ExitDir) string {
- return fmt.Sprintf("hidden_exit_%d_%s", roomID, strings.ToLower(string(dir)))
+ return fmt.Sprintf("%s%d_%s", hiddenExitFlagPrefix, roomID, strings.ToLower(string(dir)))
}
func (g *Game) exitDiscovered(p *player.Player, roomID int, dir world.ExitDir) bool {
@@ -22,10 +28,3 @@ func (g *Game) exitDiscovered(p *player.Player, roomID int, dir world.ExitDir) b
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
index 58177de..3c0b360 100644
--- a/internal/game/exit_discovery_test.go
+++ b/internal/game/exit_discovery_test.go
@@ -3,6 +3,8 @@ package game
import (
"testing"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
@@ -51,22 +53,6 @@ func TestExitDiscoveredGodMode(t *testing.T) {
}
}
-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{}
@@ -79,3 +65,60 @@ func TestExitDiscoveredDifferentRoom(t *testing.T) {
t.Error("discovering room 10 north should not mark room 20 north")
}
}
+
+func TestExitDisplayState(t *testing.T) {
+ g := &Game{Flags: NewFlagStore()}
+ const roomID = 7
+ dir := world.North
+
+ // {player_flag: open} — passes when "open" is set, fails otherwise.
+ openCond := &behavior.Condition{PlayerFlag: "open"}
+ hiddenDiscoveredSess := sessWithFlags(map[string]any{discoveredExitFlag(roomID, dir): 1})
+ openSess := sessWithFlags(map[string]any{"open": true})
+ closedSess := sessWithFlags(map[string]any{})
+ godSess := sessWithFlags(map[string]any{})
+
+ assertDisp := func(name string, sess *net.Session, exit world.ExitDef, wantVisible, wantBlocked, wantTagHidden bool) {
+ t.Helper()
+ ed := g.exitDisplayState(sess, roomID, dir, exit)
+ if ed.Visible != wantVisible || ed.Blocked != wantBlocked || ed.TagHidden != wantTagHidden {
+ t.Errorf("%s: got {Visible:%v Blocked:%v TagHidden:%v}, want {Visible:%v Blocked:%v TagHidden:%v}",
+ name, ed.Visible, ed.Blocked, ed.TagHidden, wantVisible, wantBlocked, wantTagHidden)
+ }
+ }
+
+ // Plain open exit: visible, not blocked, not hidden.
+ assertDisp("plain", openSess, world.ExitDef{Room: 1}, true, false, false)
+
+ // Hidden + undiscovered: absent.
+ assertDisp("hidden-undiscovered", closedSess, world.ExitDef{Room: 1, Hidden: true}, false, false, false)
+ // Hidden + discovered: visible, tagged hidden, not blocked.
+ assertDisp("hidden-discovered", hiddenDiscoveredSess, world.ExitDef{Room: 1, Hidden: true}, true, false, true)
+ // Hidden + god: god discovers — visible, tagged hidden.
+ godSess.Player.GodMode = true
+ assertDisp("hidden-god", godSess, world.ExitDef{Room: 1, Hidden: true}, true, false, true)
+ godSess.Player.GodMode = false
+
+ // always_blocked (non-god): visible, blocked, condition ignored.
+ assertDisp("always-blocked", closedSess, world.ExitDef{Room: 1, AlwaysBlocked: true}, true, true, false)
+ // always_blocked + a PASSING condition still blocks (condition not evaluated).
+ assertDisp("always-blocked-over-passing-cond", openSess, world.ExitDef{Room: 1, AlwaysBlocked: true, Condition: openCond}, true, true, false)
+ // always_blocked + god: not blocked (god bypass).
+ godSess.Player.GodMode = true
+ assertDisp("always-blocked-god", godSess, world.ExitDef{Room: 1, AlwaysBlocked: true}, true, false, false)
+ godSess.Player.GodMode = false
+
+ // Condition failing (no always_blocked): blocked.
+ assertDisp("cond-failing", closedSess, world.ExitDef{Room: 1, Condition: openCond}, true, true, false)
+ // Condition passing: not blocked.
+ assertDisp("cond-passing", openSess, world.ExitDef{Room: 1, Condition: openCond}, true, false, false)
+ // Condition failing + god: not blocked (god bypass).
+ godSess.Player.GodMode = true
+ assertDisp("cond-failing-god", godSess, world.ExitDef{Room: 1, Condition: openCond}, true, false, false)
+ godSess.Player.GodMode = false
+
+ // nil session (background render): non-hidden visible & not blocked;
+ // hidden absent (no player to have discovered it).
+ assertDisp("nil-sess-plain", nil, world.ExitDef{Room: 1}, true, false, false)
+ assertDisp("nil-sess-hidden", nil, world.ExitDef{Room: 1, Hidden: true}, false, false, false)
+}
diff --git a/internal/game/exit_display.go b/internal/game/exit_display.go
new file mode 100644
index 0000000..4540669
--- /dev/null
+++ b/internal/game/exit_display.go
@@ -0,0 +1,56 @@
+package game
+
+import (
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+// exitDisplay summarizes how an exit appears to a player: whether it is listed
+// at all (and traversable by the map BFS / walk pathfinder), whether movement
+// is refused (and the look listing should tag it "(blocked)"), and whether the
+// listing should tag it "(HIDDEN)".
+//
+// It is the single source of truth shared by the look listing, the standalone
+// `exits` command, `look <direction>`, movement, the map connector state, the
+// `verbs` command, and the walk pathfinder — replacing the per-callsite inline
+// checks that previously disagreed (e.g. gods saw a "(blocked)" tag in look
+// even though they can traverse and the map renders the link open).
+//
+// Semantics:
+// - Hidden exit: visible only once discovered (gods always discover). When
+// visible it is tagged "(HIDDEN)". A nil player (background render) never
+// discovers, so hidden exits are absent.
+// - God mode: exits are visible and never blocked (matches map rendering).
+// - always_blocked: absolute — blocks movement/listing/map regardless of any
+// condition. The condition is NOT evaluated when always_blocked is set.
+// - condition: blocks when present and failing for this player.
+type exitDisplay struct {
+ Visible bool
+ Blocked bool
+ TagHidden bool
+}
+
+func (g *Game) exitDisplayState(sess *net.Session, roomID int, dir world.ExitDir, exit world.ExitDef) exitDisplay {
+ var ed exitDisplay
+ p := sessPlayer(sess)
+ if exit.Hidden {
+ // exitDiscovered already returns true for gods; nil player never discovers.
+ if p != nil && g.exitDiscovered(p, roomID, dir) {
+ ed.Visible = true
+ ed.TagHidden = true
+ }
+ return ed
+ }
+ ed.Visible = true
+ if p == nil || p.GodMode {
+ return ed
+ }
+ if exit.AlwaysBlocked {
+ ed.Blocked = true
+ return ed
+ }
+ if exit.Condition != nil && !g.checkCondition(sess, exit.Condition) {
+ ed.Blocked = true
+ }
+ return ed
+}
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
new file mode 100644
index 0000000..845a6b2
--- /dev/null
+++ b/internal/game/exit_migrate.go
@@ -0,0 +1,150 @@
+package game
+
+import (
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/player"
+)
+
+// rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per
+// idMap (oldID -> newID): discovered-exit player flag keys
+// (hidden_exit_<id>_<dir>), current RoomID, EnterSeqRoom, MapSymbols keys, and
+// Stats.RoomsVisited keys. Returns whether any field was changed.
+//
+// idMap is assumed to be a bijection (see Room.RewriteRoomIDs); the flag-key
+// and int-map rekeys use delete-all-then-set-all so a swap (A<->B) cannot
+// clobber an entry mid-rewrite.
+func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
+ if p == nil || len(idMap) == 0 {
+ return false
+ }
+ changed := false
+ if remapScalar(idMap, &p.RoomID) {
+ changed = true
+ }
+ if p.EnterSeqRoom != 0 && remapScalar(idMap, &p.EnterSeqRoom) {
+ changed = true
+ }
+ if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) {
+ changed = true
+ }
+ if len(p.MapSymbols) > 0 && rekeyIntMap(p.MapSymbols, idMap) {
+ changed = true
+ }
+ if len(p.Stats.RoomsVisited) > 0 && rekeyIntMap(p.Stats.RoomsVisited, idMap) {
+ changed = true
+ }
+ return changed
+}
+
+func remapScalar(idMap map[int]int, n *int) bool {
+ if newID, ok := idMap[*n]; ok && newID != *n {
+ *n = newID
+ return true
+ }
+ return false
+}
+
+// rewriteDiscoveredExitFlags rewrites hidden_exit_<roomID>_<dir> flag keys,
+// remapping the embedded roomID per idMap. Flag values are preserved.
+func rewriteDiscoveredExitFlags(flags map[string]any, idMap map[int]int) bool {
+ type move struct {
+ oldKey, newKey string
+ val any
+ }
+ var moves []move
+ for k, v := range flags {
+ if !strings.HasPrefix(k, hiddenExitFlagPrefix) {
+ continue
+ }
+ rest := k[len(hiddenExitFlagPrefix):] // "<roomID>_<dir>"
+ idx := strings.IndexByte(rest, '_')
+ if idx <= 0 {
+ continue
+ }
+ n, err := strconv.Atoi(rest[:idx])
+ if err != nil {
+ continue
+ }
+ newID, ok := idMap[n]
+ if !ok || newID == n {
+ continue
+ }
+ moves = append(moves, move{k, hiddenExitFlagPrefix + strconv.Itoa(newID) + "_" + rest[idx+1:], v})
+ }
+ if len(moves) == 0 {
+ return false
+ }
+ for _, m := range moves {
+ delete(flags, m.oldKey)
+ }
+ for _, m := range moves {
+ flags[m.newKey] = m.val
+ }
+ return true
+}
+
+// rekeyIntMap rekeys a map[int]V per idMap (oldID -> newID), preserving values.
+// Uses delete-all-then-set-all so a swap cannot clobber an entry.
+func rekeyIntMap[V any](m map[int]V, idMap map[int]int) bool {
+ type entry struct {
+ key int
+ val V
+ }
+ var saved []entry
+ for k, v := range m {
+ if newID, ok := idMap[k]; ok && newID != k {
+ saved = append(saved, entry{k, v})
+ }
+ }
+ if len(saved) == 0 {
+ return false
+ }
+ for _, e := range saved {
+ delete(m, e.key)
+ }
+ for _, e := range saved {
+ m[idMap[e.key]] = e.val
+ }
+ return true
+}
+
+// RewriteRoomIDs is the shared entry point called by every room-ID change path
+// (the swapid command and the admin web GUI rename) to migrate room-ID-embedded
+// state across all characters. Online players are updated in memory (and
+// re-saved); offline characters are loaded, rewritten, and saved. It follows
+// the same cross-goroutine mutation precedent as cmd_undig/cmd_summon.
+func (g *Game) RewriteRoomIDs(idMap map[int]int) {
+ if len(idMap) == 0 {
+ return
+ }
+ online := make(map[string]bool)
+ if g.Hub != nil {
+ for _, sess := range g.Hub.AllSessions() {
+ if sess.Player == nil {
+ continue
+ }
+ if rewritePlayerRoomIDs(sess.Player, idMap) {
+ g.AccountStore.SaveCharacter(sess.Player)
+ }
+ online[sess.Player.Name] = true
+ }
+ }
+ names, err := g.AccountStore.ListCharacterNames()
+ if err != nil {
+ return
+ }
+ for _, name := range names {
+ if online[name] {
+ continue
+ }
+ p, err := g.AccountStore.LoadCharacter(name)
+ if err != nil {
+ continue
+ }
+ if rewritePlayerRoomIDs(p, idMap) {
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+}
diff --git a/internal/game/exit_migrate_test.go b/internal/game/exit_migrate_test.go
new file mode 100644
index 0000000..05edea5
--- /dev/null
+++ b/internal/game/exit_migrate_test.go
@@ -0,0 +1,107 @@
+package game
+
+import (
+ "reflect"
+ "testing"
+
+ "thehouseoficarus/internal/player"
+)
+
+func TestRewritePlayerRoomIDsRename(t *testing.T) {
+ p := &player.Player{
+ RoomID: 10,
+ EnterSeqRoom: 20,
+ Flags: map[string]any{
+ "hidden_exit_10_north": 1,
+ "hidden_exit_20_down": 1,
+ "unrelated_flag": 1,
+ },
+ MapSymbols: map[int]player.MapSymbolData{
+ 10: {Char: "X"},
+ 30: {Char: "Y"},
+ },
+ }
+ p.Stats.RoomsVisited = map[int]bool{10: true, 40: true}
+
+ idMap := map[int]int{10: 100, 20: 200}
+ if !rewritePlayerRoomIDs(p, idMap) {
+ t.Fatal("expected changed=true")
+ }
+
+ if p.RoomID != 100 {
+ t.Errorf("RoomID = %d, want 100", p.RoomID)
+ }
+ if p.EnterSeqRoom != 200 {
+ t.Errorf("EnterSeqRoom = %d, want 200", p.EnterSeqRoom)
+ }
+
+ wantFlags := map[string]any{
+ "hidden_exit_100_north": 1,
+ "hidden_exit_200_down": 1,
+ "unrelated_flag": 1,
+ }
+ if !reflect.DeepEqual(p.Flags, wantFlags) {
+ t.Errorf("Flags = %v, want %v", p.Flags, wantFlags)
+ }
+
+ if _, ok := p.MapSymbols[100]; !ok {
+ t.Error("MapSymbols missing key 100")
+ }
+ if _, ok := p.MapSymbols[30]; !ok {
+ t.Error("MapSymbols missing unchanged key 30")
+ }
+ if _, ok := p.MapSymbols[10]; ok {
+ t.Error("MapSymbols should not still have key 10")
+ }
+ if !p.Stats.RoomsVisited[100] {
+ t.Error("RoomsVisited missing key 100")
+ }
+ if !p.Stats.RoomsVisited[40] {
+ t.Error("RoomsVisited missing unchanged key 40")
+ }
+ if p.Stats.RoomsVisited[10] {
+ t.Error("RoomsVisited should not still have key 10")
+ }
+}
+
+func TestRewritePlayerRoomIDsNoChange(t *testing.T) {
+ p := &player.Player{
+ RoomID: 5,
+ Flags: map[string]any{"hidden_exit_5_north": 1},
+ }
+ if rewritePlayerRoomIDs(p, map[int]int{99: 100}) {
+ t.Error("expected changed=false when no embedded ID matches")
+ }
+ if p.RoomID != 5 {
+ t.Errorf("RoomID = %d, want 5 (untouched)", p.RoomID)
+ }
+ if _, ok := p.Flags["hidden_exit_5_north"]; !ok {
+ t.Error("hidden_exit_5_north should be unchanged")
+ }
+}
+
+// A swap idMap (A<->B) must not clobber a flag whose old key maps to another
+// flag's old key and vice versa.
+func TestRewritePlayerRoomIDsSwap(t *testing.T) {
+ p := &player.Player{
+ RoomID: 10,
+ Flags: map[string]any{
+ "hidden_exit_10_north": 1,
+ "hidden_exit_100_east": 1,
+ },
+ }
+ swap := map[int]int{10: 100, 100: 10}
+ if !rewritePlayerRoomIDs(p, swap) {
+ t.Fatal("expected changed=true")
+ }
+ if p.RoomID != 100 {
+ t.Errorf("RoomID = %d, want 100", p.RoomID)
+ }
+ wantFlags := map[string]any{
+ "hidden_exit_100_north": 1,
+ "hidden_exit_10_east": 1,
+ }
+ if !reflect.DeepEqual(p.Flags, wantFlags) {
+ t.Errorf("Flags = %v, want %v", p.Flags, wantFlags)
+ }
+}
diff --git a/internal/game/look_room.go b/internal/game/look_room.go
index 8bd85be..7fe527f 100644
--- a/internal/game/look_room.go
+++ b/internal/game/look_room.go
@@ -72,64 +72,81 @@ func (g *Game) resolveObjDesc(sess *net.Session, def *object.ObjectDef) (string,
return "", false
}
-func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) {
- if len(room.Exits) > 0 {
- sess.WriteLine("")
- if p.OptionBool("exits") {
- sess.WriteLine("Exits:")
- type exitLine struct {
- dir string
- targetName string
- }
- var lines []exitLine
- maxDirLen := 0
- for _, dir := range world.ExitOrder {
- exitDef, ok := room.Exits[dir]
- if !ok {
- continue
- }
- if exitDef.Hidden && !g.exitDiscovered(p, room.ID, dir) {
+// formatExitLines builds the verbose " dir - target [tags]" exit listing,
+// applying hidden-exit filtering and blocked/HIDDEN tags via exitDisplayState.
+// Shared by showRoomExits (the `look` exit section) and the standalone `exits`
+// command so they always agree on what is listed and how it is tagged.
+func (g *Game) formatExitLines(sess *net.Session, room *world.Room) []string {
+ type exitLine struct {
+ dir string
+ targetName string
+ }
+ var lines []exitLine
+ maxDirLen := 0
+ for _, dir := range world.ExitOrder {
+ exitDef, ok := room.Exits[dir]
+ if !ok {
+ continue
+ }
+ ed := g.exitDisplayState(sess, room.ID, dir, exitDef)
+ if !ed.Visible {
continue
}
coloredDir := g.colorize(sess, "exit_direction", string(dir))
- targetRoom, err := g.World.LoadRoom(exitDef.Room)
- targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room))
- if err == nil {
- targetName = g.colorize(sess, "exit_name", targetRoom.Name)
- }
- if exitDef.AlwaysBlocked {
- targetName += " (impassable)"
- }
- if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
- targetName += " (blocked)"
- }
- if exitDef.Hidden {
- targetName += " (HIDDEN)"
- }
- lines = append(lines, exitLine{coloredDir, targetName})
- if visibleLen(coloredDir) > maxDirLen {
- maxDirLen = visibleLen(coloredDir)
- }
- }
+ targetRoom, err := g.World.LoadRoom(exitDef.Room)
+ targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room))
+ if err == nil {
+ targetName = g.colorize(sess, "exit_name", targetRoom.Name)
+ }
+ if ed.Blocked {
+ targetName += " (blocked)"
+ }
+ if ed.TagHidden {
+ targetName += " (HIDDEN)"
+ }
+ lines = append(lines, exitLine{coloredDir, targetName})
+ if visibleLen(coloredDir) > maxDirLen {
+ maxDirLen = visibleLen(coloredDir)
+ }
+ }
+ out := make([]string, len(lines))
+ for i, l := range lines {
+ pad := maxDirLen + (len(l.dir) - visibleLen(l.dir))
+ out[i] = fmt.Sprintf(" %-*s - %s", pad, l.dir, l.targetName)
+ }
+ return out
+}
+
+func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) {
+ if len(room.Exits) == 0 {
+ return
+ }
+ sess.WriteLine("")
+ if p.OptionBool("exits") {
+ lines := g.formatExitLines(sess, room)
+ if len(lines) > 0 {
+ sess.WriteLine("Exits:")
for _, l := range lines {
- pad := maxDirLen + (len(l.dir) - visibleLen(l.dir))
- sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.targetName))
+ sess.WriteLine(l)
}
- } else {
- sess.Write("Exits: ")
- first := true
- for _, dir := range world.ExitOrder {
- exitDef, ok := room.Exits[dir]
- if !ok || (exitDef.Hidden && !g.exitDiscovered(p, room.ID, dir)) {
- continue
- }
- if !first {
- sess.Write(", ")
- }
- sess.Write(g.colorize(sess, "exit_direction", string(dir)))
- first = false
- }
- sess.WriteLine("")
}
+ return
+ }
+ sess.Write("Exits: ")
+ first := true
+ for _, dir := range world.ExitOrder {
+ exitDef, ok := room.Exits[dir]
+ if !ok {
+ continue
+ }
+ if !g.exitDisplayState(sess, room.ID, dir, exitDef).Visible {
+ continue
+ }
+ if !first {
+ sess.Write(", ")
+ }
+ sess.Write(g.colorize(sess, "exit_direction", string(dir)))
+ first = false
}
+ sess.WriteLine("")
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index ead38a7..8b0a57c 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -26,28 +26,19 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine("You can't see anything that way.")
return
}
- if !p.GodMode && exitDef.AlwaysBlocked {
+ ed := g.exitDisplayState(sess, p.RoomID, exitDir, exitDef)
+ if !ed.Visible {
+ sess.WriteLine("You can't see anything that way.")
+ return
+ }
+ if ed.Blocked {
msg := exitDef.BlockedMessage
if msg == "" {
- msg = fmt.Sprintf("The way %s is impassable.", exitDir)
+ msg = fmt.Sprintf("The way %s is blocked.", 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
- if msg == "" {
- msg = fmt.Sprintf("The way %s is blocked.", exitDir)
- }
- sess.WriteLine(msg)
- return
- }
- }
g.World.SeedGroundItems(exitDef.Room)
g.seedRoomMobs(exitDef.Room)
origRoom := p.RoomID
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index 5e57bc7..0177595 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -60,9 +60,6 @@ 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, 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
@@ -71,10 +68,7 @@ func buildGraph(g *Game, sess *net.Session, startRoomID int) *mapGraph {
if !ok || exit.Room != targetID {
return true
}
- if exit.Hidden && !g.exitDiscovered(sess.Player, fromID, dir) {
- return false
- }
- return true
+ return g.exitDisplayState(sess, fromID, dir, exit).Visible
}, nil)
return &mapGraph{posToRoom: rg.RoomAt, dist: rg.Dist}
}
@@ -619,23 +613,14 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb
if !ok || exit.Room != neighbor {
return exitAbsent
}
- p := sessPlayer(sess)
- if exit.Hidden && !g.exitDiscovered(p, from, dir) {
+ ed := g.exitDisplayState(sess, from, dir, exit)
+ if !ed.Visible {
return exitAbsent
}
- if exit.AlwaysBlocked {
- if p != nil && p.GodMode {
- return exitOpen
- }
+ if ed.Blocked {
return exitBlocked
}
- if exit.Condition == nil || sess == nil || sess.Player == nil {
- return exitOpen
- }
- if sess.Player.GodMode || g.checkCondition(sess, exit.Condition) {
- return exitOpen
- }
- return exitBlocked
+ return exitOpen
}
func sessPlayer(sess *net.Session) *player.Player {