aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/courses.md2
-rw-r--r--building_guide/rooms.md14
-rw-r--r--cmd/thoi/main.go4
-rw-r--r--internal/admin/api_room_courses.go4
-rw-r--r--internal/admin/api_rooms.go50
-rw-r--r--internal/admin/server.go56
-rw-r--r--internal/admin/static/map.js2
-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
-rw-r--r--internal/player/store.go24
-rw-r--r--internal/world/room_migrate.go71
-rw-r--r--internal/world/room_migrate_test.go111
25 files changed, 811 insertions, 250 deletions
diff --git a/building_guide/courses.md b/building_guide/courses.md
index 5e92318..71dd255 100644
--- a/building_guide/courses.md
+++ b/building_guide/courses.md
@@ -6,7 +6,7 @@ Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a s
> Editing courses is done from the **Map screen** in the admin web GUI: select an obstacle room and use its **Course** side-panel tab to create a course, add the room as a step, edit all of its parameters, or remove it. The legacy standalone "Courses" editor tab has been removed.
>
-> When a room is added as a course step, the admin GUI auto-creates **always-blocked one-way exits** between consecutive obstacle rooms so the in-game map can lay them out correctly. The direction of these exits is chosen by the builder. Always-blocked exits are impassable to players but visible on the map — they exist solely for map rendering. On the map, course links appear as blue dashed one-way arrows (colored via the `map_course` theme token).
+> When a room is added as a course step, the admin GUI auto-creates **always-blocked one-way exits** between consecutive obstacle rooms so the in-game map can lay them out correctly. The direction of these exits is chosen by the builder. Always-blocked exits are blocked to players (movement is refused with the standard "blocked" message and they show "(blocked)" in `look`) but visible on the map — they exist solely for map rendering. On the map, course links appear as blue dashed one-way arrows (colored via the `map_course` theme token).
### Course YAML
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 111404a..35fd85a 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -109,9 +109,11 @@ exits:
### Always-blocked exits
-Always-blocked exits are impassable to players but always visible. They
-appear in `look`/`verbs` and render on the map as blocked `X` connectors.
-Gods in `god` mode can traverse them. Primarily used for agility course
+Always-blocked exits are blocked to players but always visible. They appear in
+`look`/`verbs`/`exits` tagged `(blocked)` (same as a conditionally-blocked exit),
+refuse movement with the standard "The way <dir> is blocked." message, and
+render on the map as blocked `X` connectors. Gods in `god` mode can traverse
+them (and see no `(blocked)` tag). Primarily used for agility course
room-to-room links so the map can lay the course out visually.
```yaml
@@ -121,8 +123,12 @@ exits:
always_blocked: true
```
+`always_blocked` is absolute: when set, a `condition:` on the same exit is not
+evaluated (the exit is blocked unconditionally). Don't combine the two — if you
+need a conditionally-gated exit, use `condition:` alone.
+
Hidden and always_blocked can be combined: the exit is invisible until
-discovered (or seen by a god), and then shows as impassable.
+discovered (or seen by a god), and then shows as blocked.
### Map grid & one-way exits
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index 65b79e5..60a30aa 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -84,7 +84,7 @@ func main() {
}
if cfg.AdminHTTP.Enabled {
- adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll)
+ adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs)
if err != nil {
log.Fatalf("failed to start admin HTTP server: %v", err)
}
@@ -97,7 +97,7 @@ func main() {
}
if cfg.AdminHTTPS.Enabled {
- adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll)
+ adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs)
if err != nil {
log.Fatalf("failed to start admin HTTPS server: %v", err)
}
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index 86c7cdf..740a102 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -574,8 +574,8 @@ func roomExitsMap(m map[string]any) map[string]any {
}
// 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.
+// targeting targetRoomID. Always-blocked exits are blocked to players 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, "always_blocked": true}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 75f4779..124b093 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -821,37 +821,40 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
s.world.ClearRoomState(body.ID)
+ // Remap every genuine room-ID reference (exit targets, trigger/on-enter
+ // room+teleport+despawn_rooms, mob wander_rooms) from the old ID to the
+ // new one across all rooms — including the renamed room itself, so a
+ // self-loop exit is corrected too. Only rooms whose references actually
+ // moved are re-marshaled (preserving untouched files' formatting).
+ idMap := map[int]int{body.ID: body.NewID}
allIDs, _ := listRoomIDs(s.dataDir)
- var exitUpdates int
+ var refUpdates int
for _, otherID := range allIDs {
- if otherID == body.NewID {
- continue
- }
otherRoom, loadErr := s.world.LoadRoom(otherID)
if loadErr != nil {
continue
}
- changed := false
- for _, dir := range world.ExitOrder {
- exit, exists := otherRoom.Exits[dir]
- if !exists || exit.Room != body.ID {
- continue
- }
- exit.Room = body.NewID
- otherRoom.Exits[dir] = exit
- changed = true
- }
- if changed {
- otherPath, pathOk := s.world.GetRoomPath(otherID)
- if pathOk {
- writeYAMLFile(otherPath, otherRoom)
- exitUpdates++
- }
+ if !otherRoom.RewriteRoomIDs(idMap) {
+ continue
+ }
+ otherPath, pathOk := s.world.GetRoomPath(otherID)
+ if !pathOk {
+ continue
}
+ writeYAMLFile(otherPath, otherRoom)
+ refUpdates++
+ }
+
+ // Migrate room-ID-embedded state across all characters (discovered-exit
+ // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) so a
+ // rename does not strand players or leave stale hidden_exit_<id>_<dir>
+ // flags pointing at the old room ID.
+ if s.rewriteRoomIDs != nil {
+ s.rewriteRoomIDs(idMap)
}
s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("rename room %d to %d (updated %d exits)", body.ID, body.NewID, exitUpdates),
+ Description: fmt.Sprintf("rename room %d to %d (updated %d refs)", body.ID, body.NewID, refUpdates),
FilePath: oldPath,
NewFilePath: newPath,
OldContent: data,
@@ -941,6 +944,11 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ
writeJSON(w, map[string]any{"error": "yaml parse failed: " + err.Error()})
return
}
+ // A duplicate file was never in the room index (only one of the pair
+ // is indexed), so no player has visited it and no other room points at
+ // it. Renaming it to an unused ID therefore needs no exit/trigger/wander
+ // reference rewrite and no player-state migration — only its own id
+ // field and filename change.
m["id"] = body.NewID
newContent, err := writeMapAsYAML(newRel, m)
diff --git a/internal/admin/server.go b/internal/admin/server.go
index f0fa58e..6ad20f5 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -36,22 +36,27 @@ import (
var embedded embed.FS
type AdminServer struct {
- cfg *config.Config
- useTLS bool
- accountStore *player.AccountStore
- world *world.World
- itemStore *item.ItemStore
- objectStore *object.ObjectStore
- mobStore *world.MobStore
- dataDir string
- httpServer *http.Server
- undoStack *UndoStack
- tmpl *template.Template
- cookieSecret []byte
+ cfg *config.Config
+ useTLS bool
+ accountStore *player.AccountStore
+ world *world.World
+ itemStore *item.ItemStore
+ objectStore *object.ObjectStore
+ mobStore *world.MobStore
+ dataDir string
+ httpServer *http.Server
+ undoStack *UndoStack
+ tmpl *template.Template
+ cookieSecret []byte
reloadCourses func()
+ // rewriteRoomIDs migrates room-ID-embedded state across all characters
+ // (online + offline) when a room ID changes. Wired from the Game so the
+ // admin web GUI can trigger the same migration as the swapid command
+ // without the admin package depending on game.
+ rewriteRoomIDs func(idMap map[int]int)
}
-func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func()) (*AdminServer, error) {
+func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int)) (*AdminServer, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("cookie secret: %w", err)
@@ -83,18 +88,19 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
}
s := &AdminServer{
- cfg: cfg,
- useTLS: useTLS,
- accountStore: accountStore,
- world: w,
- itemStore: is,
- objectStore: os,
- mobStore: ms,
- dataDir: dataDir,
- undoStack: NewUndoStack(dataDir),
- tmpl: tmpl,
- cookieSecret: secret,
- reloadCourses: reloadCourses,
+ cfg: cfg,
+ useTLS: useTLS,
+ accountStore: accountStore,
+ world: w,
+ itemStore: is,
+ objectStore: os,
+ mobStore: ms,
+ dataDir: dataDir,
+ undoStack: NewUndoStack(dataDir),
+ tmpl: tmpl,
+ cookieSecret: secret,
+ reloadCourses: reloadCourses,
+ rewriteRoomIDs: rewriteRoomIDs,
}
mux := http.NewServeMux()
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 8080451..3fdd543 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -1296,7 +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 class="form-group"><label><input type="checkbox" id="exitAlwaysBlocked"' + (alwaysBlocked ? ' checked' : '') + '> Always Blocked (blocked, 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>';
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 {
diff --git a/internal/player/store.go b/internal/player/store.go
index bc5db11..2691d71 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -149,3 +149,27 @@ func (s *AccountStore) FindCharacter(name string) (string, error) {
}
return "", fmt.Errorf("character not found: %s", name)
}
+
+// ListCharacterNames returns the stem (character name) of every
+// data/players/characters/*.yaml file. Used by room-ID migration to walk and
+// rewrite offline characters. Online characters are excluded by the caller
+// (their in-memory state is updated directly).
+func (s *AccountStore) ListCharacterNames() ([]string, error) {
+ dir := filepath.Join(s.dataDir, "players", "characters")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ var names []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := strings.TrimSuffix(e.Name(), ".yaml")
+ if name == "" {
+ continue
+ }
+ names = append(names, name)
+ }
+ return names, nil
+}
diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go
new file mode 100644
index 0000000..6d79499
--- /dev/null
+++ b/internal/world/room_migrate.go
@@ -0,0 +1,71 @@
+package world
+
+// RewriteRoomIDs remaps every genuine room-ID reference in this room per idMap
+// (oldID -> newID): exit targets, trigger Room/Teleport/DespawnRooms, on-enter
+// Teleport/DespawnRooms, and mob WanderRooms. It does NOT touch author-chosen
+// set_flags/set_player_flags values or condition `value:` fields — those are not
+// structural room references (and historically the swapid regex rewrote them
+// incorrectly as a side effect of matching any bare integer).
+//
+// idMap is assumed to be a bijection (each old ID maps to a distinct new ID);
+// this holds for both swap (A<->B) and rename-to-unused (A->B). Returns whether
+// any field was changed.
+func (r *Room) RewriteRoomIDs(idMap map[int]int) bool {
+ if r == nil || len(idMap) == 0 {
+ return false
+ }
+ changed := false
+ for dir, exit := range r.Exits {
+ if remapInt(idMap, &exit.Room) {
+ r.Exits[dir] = exit
+ changed = true
+ }
+ }
+ for i := range r.Triggers {
+ if remapInt(idMap, &r.Triggers[i].Room) {
+ changed = true
+ }
+ for j := range r.Triggers[i].Steps {
+ s := &r.Triggers[i].Steps[j]
+ if remapInt(idMap, &s.Teleport) {
+ changed = true
+ }
+ if s.SpawnMob != nil && remapIntSlice(idMap, s.SpawnMob.DespawnRooms) {
+ changed = true
+ }
+ }
+ }
+ for i := range r.OnEnter {
+ s := &r.OnEnter[i]
+ if remapInt(idMap, &s.Teleport) {
+ changed = true
+ }
+ if s.SpawnMob != nil && remapIntSlice(idMap, s.SpawnMob.DespawnRooms) {
+ changed = true
+ }
+ }
+ for i := range r.Mobs {
+ if remapIntSlice(idMap, r.Mobs[i].WanderRooms) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+func remapInt(idMap map[int]int, n *int) bool {
+ if newID, ok := idMap[*n]; ok && newID != *n {
+ *n = newID
+ return true
+ }
+ return false
+}
+
+func remapIntSlice(idMap map[int]int, slice []int) bool {
+ changed := false
+ for i := range slice {
+ if remapInt(idMap, &slice[i]) {
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/internal/world/room_migrate_test.go b/internal/world/room_migrate_test.go
new file mode 100644
index 0000000..b035172
--- /dev/null
+++ b/internal/world/room_migrate_test.go
@@ -0,0 +1,111 @@
+package world
+
+import "testing"
+
+func TestRoomRewriteRoomIDs(t *testing.T) {
+ r := &Room{
+ Exits: map[ExitDir]ExitDef{
+ North: {Room: 10, SetFlags: map[string]any{"last": 10}},
+ South: {Room: 99},
+ },
+ Triggers: []TriggerDef{
+ {
+ Room: 30,
+ Steps: []TriggerStep{
+ {Teleport: 40, SpawnMob: &SpawnMobConfig{DespawnRooms: []int{50, 60}}},
+ },
+ },
+ },
+ OnEnter: []EnterStep{
+ {Teleport: 70, SpawnMob: &SpawnMobConfig{DespawnRooms: []int{80}}},
+ },
+ Mobs: []RoomMob{
+ {ID: "guard", WanderRooms: []int{90, 100}},
+ },
+ }
+
+ idMap := map[int]int{
+ 10: 11, 20: 21, 30: 31, 40: 41, 50: 51, 60: 61,
+ 70: 71, 80: 81, 90: 91, 100: 101,
+ }
+ if !r.RewriteRoomIDs(idMap) {
+ t.Fatal("expected changed=true")
+ }
+
+ checkExit := func(dir ExitDir, want int) {
+ t.Helper()
+ if got := r.Exits[dir].Room; got != want {
+ t.Errorf("exit %s room = %d, want %d", dir, got, want)
+ }
+ }
+ checkExit(North, 11)
+ checkExit(South, 99) // unchanged (not in idMap)
+
+ // Author set_flags values are NOT structural room references and must not
+ // be remapped (the old swapid regex wrongly rewrote these).
+ if v := r.Exits[North].SetFlags["last"]; v != 10 {
+ t.Errorf("set_flags value remapped: got %v, want 10", v)
+ }
+
+ if r.Triggers[0].Room != 31 {
+ t.Errorf("trigger room = %d, want 31", r.Triggers[0].Room)
+ }
+ if r.Triggers[0].Steps[0].Teleport != 41 {
+ t.Errorf("trigger teleport = %d, want 41", r.Triggers[0].Steps[0].Teleport)
+ }
+ if got := r.Triggers[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 2 || got[0] != 51 || got[1] != 61 {
+ t.Errorf("trigger despawn_rooms = %v, want [51 61]", got)
+ }
+ if r.OnEnter[0].Teleport != 71 {
+ t.Errorf("on_enter teleport = %d, want 71", r.OnEnter[0].Teleport)
+ }
+ if got := r.OnEnter[0].SpawnMob.DespawnRooms; len(got) != 1 || got[0] != 81 {
+ t.Errorf("on_enter despawn_rooms = %v, want [81]", got)
+ }
+ if got := r.Mobs[0].WanderRooms; len(got) != 2 || got[0] != 91 || got[1] != 101 {
+ t.Errorf("mob wander_rooms = %v, want [91 101]", got)
+ }
+}
+
+func TestRoomRewriteRoomIDsNoChange(t *testing.T) {
+ r := &Room{
+ Exits: map[ExitDir]ExitDef{North: {Room: 5}},
+ }
+ // idMap does not mention 5 -> nothing changes.
+ if r.RewriteRoomIDs(map[int]int{99: 100}) {
+ t.Error("expected changed=false when no references match")
+ }
+ if r.Exits[North].Room != 5 {
+ t.Errorf("exit room = %d, want 5 (untouched)", r.Exits[North].Room)
+ }
+}
+
+func TestRoomRewriteRoomIDsSwapRoundTrips(t *testing.T) {
+ r := &Room{Exits: map[ExitDir]ExitDef{
+ North: {Room: 10},
+ South: {Room: 20},
+ }}
+ swap := map[int]int{10: 20, 20: 10}
+ r.RewriteRoomIDs(swap)
+ if r.Exits[North].Room != 20 || r.Exits[South].Room != 10 {
+ t.Errorf("after swap: north=%d south=%d, want north=20 south=10", r.Exits[North].Room, r.Exits[South].Room)
+ }
+ // Applying the same swap again reverts (a swap is its own inverse).
+ r.RewriteRoomIDs(swap)
+ if r.Exits[North].Room != 10 || r.Exits[South].Room != 20 {
+ t.Errorf("after double swap: north=%d south=%d, want north=10 south=20", r.Exits[North].Room, r.Exits[South].Room)
+ }
+}
+
+func TestRoomRewriteRoomIDsIdempotentRename(t *testing.T) {
+ r := &Room{Exits: map[ExitDir]ExitDef{North: {Room: 10}}}
+ rename := map[int]int{10: 11}
+ r.RewriteRoomIDs(rename)
+ if r.Exits[North].Room != 11 {
+ t.Fatalf("after rename: room=%d, want 11", r.Exits[North].Room)
+ }
+ // Second pass: 11 is not a key in idMap, so nothing changes.
+ if r.RewriteRoomIDs(rename) {
+ t.Error("expected changed=false on second pass of a rename idMap")
+ }
+}