diff options
| author | historia <[not public]> | 2026-07-07 00:22:32 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-07 00:22:32 -0400 |
| commit | a51f7a53aa2c487ebf94ba0363038574ae8bb590 (patch) | |
| tree | b393a8f9e4732b40f6fe8058d086bd631537ad3b /internal | |
| parent | 9292634f2f8d71a53879ff07e1330c671b207d51 (diff) | |
| download | thehouseoficarus-a51f7a53aa2c487ebf94ba0363038574ae8bb590.tar.gz | |
feat: hidden exits (and related flags) work with commands that change room ids. exit code simplified and unified between impassable and blocked exits.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_room_courses.go | 4 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 50 | ||||
| -rw-r--r-- | internal/admin/server.go | 56 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 2 | ||||
| -rw-r--r-- | internal/game/cmd_aps.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_hideallexits.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 28 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 11 | ||||
| -rw-r--r-- | internal/game/cmd_swapid.go | 91 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_walk.go | 9 | ||||
| -rw-r--r-- | internal/game/exit_discovery.go | 15 | ||||
| -rw-r--r-- | internal/game/exit_discovery_test.go | 75 | ||||
| -rw-r--r-- | internal/game/exit_display.go | 56 | ||||
| -rw-r--r-- | internal/game/exit_migrate.go | 150 | ||||
| -rw-r--r-- | internal/game/exit_migrate_test.go | 107 | ||||
| -rw-r--r-- | internal/game/look_room.go | 123 | ||||
| -rw-r--r-- | internal/game/look_target.go | 23 | ||||
| -rw-r--r-- | internal/game/render_map.go | 25 | ||||
| -rw-r--r-- | internal/player/store.go | 24 | ||||
| -rw-r--r-- | internal/world/room_migrate.go | 71 | ||||
| -rw-r--r-- | internal/world/room_migrate_test.go | 111 |
22 files changed, 798 insertions, 243 deletions
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") + } +} |
