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